superqode 0.1.5__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.
- superqode/__init__.py +33 -0
- superqode/acp/__init__.py +23 -0
- superqode/acp/client.py +913 -0
- superqode/acp/permission_screen.py +457 -0
- superqode/acp/types.py +480 -0
- superqode/acp_discovery.py +856 -0
- superqode/agent/__init__.py +22 -0
- superqode/agent/edit_strategies.py +334 -0
- superqode/agent/loop.py +892 -0
- superqode/agent/qe_report_templates.py +39 -0
- superqode/agent/system_prompts.py +353 -0
- superqode/agent_output.py +721 -0
- superqode/agent_stream.py +953 -0
- superqode/agents/__init__.py +59 -0
- superqode/agents/acp_registry.py +305 -0
- superqode/agents/client.py +249 -0
- superqode/agents/data/augmentcode.com.toml +51 -0
- superqode/agents/data/cagent.dev.toml +51 -0
- superqode/agents/data/claude.com.toml +60 -0
- superqode/agents/data/codeassistant.dev.toml +51 -0
- superqode/agents/data/codex.openai.com.toml +57 -0
- superqode/agents/data/fastagent.ai.toml +66 -0
- superqode/agents/data/geminicli.com.toml +77 -0
- superqode/agents/data/goose.block.xyz.toml +54 -0
- superqode/agents/data/junie.jetbrains.com.toml +56 -0
- superqode/agents/data/kimi.moonshot.cn.toml +57 -0
- superqode/agents/data/llmlingagent.dev.toml +51 -0
- superqode/agents/data/molt.bot.toml +49 -0
- superqode/agents/data/opencode.ai.toml +60 -0
- superqode/agents/data/stakpak.dev.toml +51 -0
- superqode/agents/data/vtcode.dev.toml +51 -0
- superqode/agents/discovery.py +266 -0
- superqode/agents/messaging.py +160 -0
- superqode/agents/persona.py +166 -0
- superqode/agents/registry.py +421 -0
- superqode/agents/schema.py +72 -0
- superqode/agents/unified.py +367 -0
- superqode/app/__init__.py +111 -0
- superqode/app/constants.py +314 -0
- superqode/app/css.py +366 -0
- superqode/app/models.py +118 -0
- superqode/app/suggester.py +125 -0
- superqode/app/widgets.py +1591 -0
- superqode/app_enhanced.py +399 -0
- superqode/app_main.py +17187 -0
- superqode/approval.py +312 -0
- superqode/atomic.py +296 -0
- superqode/commands/__init__.py +1 -0
- superqode/commands/acp.py +965 -0
- superqode/commands/agents.py +180 -0
- superqode/commands/auth.py +278 -0
- superqode/commands/config.py +374 -0
- superqode/commands/init.py +826 -0
- superqode/commands/providers.py +819 -0
- superqode/commands/qe.py +1145 -0
- superqode/commands/roles.py +380 -0
- superqode/commands/serve.py +172 -0
- superqode/commands/suggestions.py +127 -0
- superqode/commands/superqe.py +460 -0
- superqode/config/__init__.py +51 -0
- superqode/config/loader.py +812 -0
- superqode/config/schema.py +498 -0
- superqode/core/__init__.py +111 -0
- superqode/core/roles.py +281 -0
- superqode/danger.py +386 -0
- superqode/data/superqode-template.yaml +1522 -0
- superqode/design_system.py +1080 -0
- superqode/dialogs/__init__.py +6 -0
- superqode/dialogs/base.py +39 -0
- superqode/dialogs/model.py +130 -0
- superqode/dialogs/provider.py +870 -0
- superqode/diff_view.py +919 -0
- superqode/enterprise.py +21 -0
- superqode/evaluation/__init__.py +25 -0
- superqode/evaluation/adapters.py +93 -0
- superqode/evaluation/behaviors.py +89 -0
- superqode/evaluation/engine.py +209 -0
- superqode/evaluation/scenarios.py +96 -0
- superqode/execution/__init__.py +36 -0
- superqode/execution/linter.py +538 -0
- superqode/execution/modes.py +347 -0
- superqode/execution/resolver.py +283 -0
- superqode/execution/runner.py +642 -0
- superqode/file_explorer.py +811 -0
- superqode/file_viewer.py +471 -0
- superqode/flash.py +183 -0
- superqode/guidance/__init__.py +58 -0
- superqode/guidance/config.py +203 -0
- superqode/guidance/prompts.py +71 -0
- superqode/harness/__init__.py +54 -0
- superqode/harness/accelerator.py +291 -0
- superqode/harness/config.py +319 -0
- superqode/harness/validator.py +147 -0
- superqode/history.py +279 -0
- superqode/integrations/superopt_runner.py +124 -0
- superqode/logging/__init__.py +49 -0
- superqode/logging/adapters.py +219 -0
- superqode/logging/formatter.py +923 -0
- superqode/logging/integration.py +341 -0
- superqode/logging/sinks.py +170 -0
- superqode/logging/unified_log.py +417 -0
- superqode/lsp/__init__.py +26 -0
- superqode/lsp/client.py +544 -0
- superqode/main.py +1069 -0
- superqode/mcp/__init__.py +89 -0
- superqode/mcp/auth_storage.py +380 -0
- superqode/mcp/client.py +1236 -0
- superqode/mcp/config.py +319 -0
- superqode/mcp/integration.py +337 -0
- superqode/mcp/oauth.py +436 -0
- superqode/mcp/oauth_callback.py +385 -0
- superqode/mcp/types.py +290 -0
- superqode/memory/__init__.py +31 -0
- superqode/memory/feedback.py +342 -0
- superqode/memory/store.py +522 -0
- superqode/notifications.py +369 -0
- superqode/optimization/__init__.py +5 -0
- superqode/optimization/config.py +33 -0
- superqode/permissions/__init__.py +25 -0
- superqode/permissions/rules.py +488 -0
- superqode/plan.py +323 -0
- superqode/providers/__init__.py +33 -0
- superqode/providers/gateway/__init__.py +165 -0
- superqode/providers/gateway/base.py +228 -0
- superqode/providers/gateway/litellm_gateway.py +1170 -0
- superqode/providers/gateway/openresponses_gateway.py +436 -0
- superqode/providers/health.py +297 -0
- superqode/providers/huggingface/__init__.py +74 -0
- superqode/providers/huggingface/downloader.py +472 -0
- superqode/providers/huggingface/endpoints.py +442 -0
- superqode/providers/huggingface/hub.py +531 -0
- superqode/providers/huggingface/inference.py +394 -0
- superqode/providers/huggingface/transformers_runner.py +516 -0
- superqode/providers/local/__init__.py +100 -0
- superqode/providers/local/base.py +438 -0
- superqode/providers/local/discovery.py +418 -0
- superqode/providers/local/lmstudio.py +256 -0
- superqode/providers/local/mlx.py +457 -0
- superqode/providers/local/ollama.py +486 -0
- superqode/providers/local/sglang.py +268 -0
- superqode/providers/local/tgi.py +260 -0
- superqode/providers/local/tool_support.py +477 -0
- superqode/providers/local/vllm.py +258 -0
- superqode/providers/manager.py +1338 -0
- superqode/providers/models.py +1016 -0
- superqode/providers/models_dev.py +578 -0
- superqode/providers/openresponses/__init__.py +87 -0
- superqode/providers/openresponses/converters/__init__.py +17 -0
- superqode/providers/openresponses/converters/messages.py +343 -0
- superqode/providers/openresponses/converters/tools.py +268 -0
- superqode/providers/openresponses/schema/__init__.py +56 -0
- superqode/providers/openresponses/schema/models.py +585 -0
- superqode/providers/openresponses/streaming/__init__.py +5 -0
- superqode/providers/openresponses/streaming/parser.py +338 -0
- superqode/providers/openresponses/tools/__init__.py +21 -0
- superqode/providers/openresponses/tools/apply_patch.py +352 -0
- superqode/providers/openresponses/tools/code_interpreter.py +290 -0
- superqode/providers/openresponses/tools/file_search.py +333 -0
- superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
- superqode/providers/registry.py +716 -0
- superqode/providers/usage.py +332 -0
- superqode/pure_mode.py +384 -0
- superqode/qr/__init__.py +23 -0
- superqode/qr/dashboard.py +781 -0
- superqode/qr/generator.py +1018 -0
- superqode/qr/templates.py +135 -0
- superqode/safety/__init__.py +41 -0
- superqode/safety/sandbox.py +413 -0
- superqode/safety/warnings.py +256 -0
- superqode/server/__init__.py +33 -0
- superqode/server/lsp_server.py +775 -0
- superqode/server/web.py +250 -0
- superqode/session/__init__.py +25 -0
- superqode/session/persistence.py +580 -0
- superqode/session/sharing.py +477 -0
- superqode/session.py +475 -0
- superqode/sidebar.py +2991 -0
- superqode/stream_view.py +648 -0
- superqode/styles/__init__.py +3 -0
- superqode/superqe/__init__.py +184 -0
- superqode/superqe/acp_runner.py +1064 -0
- superqode/superqe/constitution/__init__.py +62 -0
- superqode/superqe/constitution/evaluator.py +308 -0
- superqode/superqe/constitution/loader.py +432 -0
- superqode/superqe/constitution/schema.py +250 -0
- superqode/superqe/events.py +591 -0
- superqode/superqe/frameworks/__init__.py +65 -0
- superqode/superqe/frameworks/base.py +234 -0
- superqode/superqe/frameworks/e2e.py +263 -0
- superqode/superqe/frameworks/executor.py +237 -0
- superqode/superqe/frameworks/javascript.py +409 -0
- superqode/superqe/frameworks/python.py +373 -0
- superqode/superqe/frameworks/registry.py +92 -0
- superqode/superqe/mcp_tools/__init__.py +47 -0
- superqode/superqe/mcp_tools/core_tools.py +418 -0
- superqode/superqe/mcp_tools/registry.py +230 -0
- superqode/superqe/mcp_tools/testing_tools.py +167 -0
- superqode/superqe/noise.py +89 -0
- superqode/superqe/orchestrator.py +778 -0
- superqode/superqe/roles.py +609 -0
- superqode/superqe/session.py +713 -0
- superqode/superqe/skills/__init__.py +57 -0
- superqode/superqe/skills/base.py +106 -0
- superqode/superqe/skills/core_skills.py +899 -0
- superqode/superqe/skills/registry.py +90 -0
- superqode/superqe/verifier.py +101 -0
- superqode/superqe_cli.py +76 -0
- superqode/tool_call.py +358 -0
- superqode/tools/__init__.py +93 -0
- superqode/tools/agent_tools.py +496 -0
- superqode/tools/base.py +324 -0
- superqode/tools/batch_tool.py +133 -0
- superqode/tools/diagnostics.py +311 -0
- superqode/tools/edit_tools.py +653 -0
- superqode/tools/enhanced_base.py +515 -0
- superqode/tools/file_tools.py +269 -0
- superqode/tools/file_tracking.py +45 -0
- superqode/tools/lsp_tools.py +610 -0
- superqode/tools/network_tools.py +350 -0
- superqode/tools/permissions.py +400 -0
- superqode/tools/question_tool.py +324 -0
- superqode/tools/search_tools.py +598 -0
- superqode/tools/shell_tools.py +259 -0
- superqode/tools/todo_tools.py +121 -0
- superqode/tools/validation.py +80 -0
- superqode/tools/web_tools.py +639 -0
- superqode/tui.py +1152 -0
- superqode/tui_integration.py +875 -0
- superqode/tui_widgets/__init__.py +27 -0
- superqode/tui_widgets/widgets/__init__.py +18 -0
- superqode/tui_widgets/widgets/progress.py +185 -0
- superqode/tui_widgets/widgets/tool_display.py +188 -0
- superqode/undo_manager.py +574 -0
- superqode/utils/__init__.py +5 -0
- superqode/utils/error_handling.py +323 -0
- superqode/utils/fuzzy.py +257 -0
- superqode/widgets/__init__.py +477 -0
- superqode/widgets/agent_collab.py +390 -0
- superqode/widgets/agent_store.py +936 -0
- superqode/widgets/agent_switcher.py +395 -0
- superqode/widgets/animation_manager.py +284 -0
- superqode/widgets/code_context.py +356 -0
- superqode/widgets/command_palette.py +412 -0
- superqode/widgets/connection_status.py +537 -0
- superqode/widgets/conversation_history.py +470 -0
- superqode/widgets/diff_indicator.py +155 -0
- superqode/widgets/enhanced_status_bar.py +385 -0
- superqode/widgets/enhanced_toast.py +476 -0
- superqode/widgets/file_browser.py +809 -0
- superqode/widgets/file_reference.py +585 -0
- superqode/widgets/issue_timeline.py +340 -0
- superqode/widgets/leader_key.py +264 -0
- superqode/widgets/mode_switcher.py +445 -0
- superqode/widgets/model_picker.py +234 -0
- superqode/widgets/permission_preview.py +1205 -0
- superqode/widgets/prompt.py +358 -0
- superqode/widgets/provider_connect.py +725 -0
- superqode/widgets/pty_shell.py +587 -0
- superqode/widgets/qe_dashboard.py +321 -0
- superqode/widgets/resizable_sidebar.py +377 -0
- superqode/widgets/response_changes.py +218 -0
- superqode/widgets/response_display.py +528 -0
- superqode/widgets/rich_tool_display.py +613 -0
- superqode/widgets/sidebar_panels.py +1180 -0
- superqode/widgets/slash_complete.py +356 -0
- superqode/widgets/split_view.py +612 -0
- superqode/widgets/status_bar.py +273 -0
- superqode/widgets/superqode_display.py +786 -0
- superqode/widgets/thinking_display.py +815 -0
- superqode/widgets/throbber.py +87 -0
- superqode/widgets/toast.py +206 -0
- superqode/widgets/unified_output.py +1073 -0
- superqode/workspace/__init__.py +75 -0
- superqode/workspace/artifacts.py +472 -0
- superqode/workspace/coordinator.py +353 -0
- superqode/workspace/diff_tracker.py +429 -0
- superqode/workspace/git_guard.py +373 -0
- superqode/workspace/git_snapshot.py +526 -0
- superqode/workspace/manager.py +750 -0
- superqode/workspace/snapshot.py +357 -0
- superqode/workspace/watcher.py +535 -0
- superqode/workspace/worktree.py +440 -0
- superqode-0.1.5.dist-info/METADATA +204 -0
- superqode-0.1.5.dist-info/RECORD +288 -0
- superqode-0.1.5.dist-info/WHEEL +5 -0
- superqode-0.1.5.dist-info/entry_points.txt +3 -0
- superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
- superqode-0.1.5.dist-info/top_level.txt +1 -0
superqode/acp/client.py
ADDED
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACP Client for SuperQode.
|
|
3
|
+
|
|
4
|
+
Handles communication with ACP-compatible coding agents like OpenCode.
|
|
5
|
+
This is the primary interface for all ACP agent communication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Awaitable, Optional, Dict, List
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from time import monotonic
|
|
17
|
+
|
|
18
|
+
from superqode.acp.types import (
|
|
19
|
+
PermissionOption,
|
|
20
|
+
ToolCall,
|
|
21
|
+
ToolCallUpdate,
|
|
22
|
+
ContentBlock,
|
|
23
|
+
InitializeResponse,
|
|
24
|
+
NewSessionResponse,
|
|
25
|
+
SessionPromptResponse,
|
|
26
|
+
CreateTerminalResponse,
|
|
27
|
+
TerminalOutputResponse,
|
|
28
|
+
WaitForTerminalExitResponse,
|
|
29
|
+
AvailableMode,
|
|
30
|
+
AvailableModel,
|
|
31
|
+
ModesResponse,
|
|
32
|
+
ModelsResponse,
|
|
33
|
+
SlashCommand,
|
|
34
|
+
AvailableCommandsResponse,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
PROTOCOL_VERSION = 1
|
|
39
|
+
CLIENT_NAME = "SuperQode"
|
|
40
|
+
CLIENT_VERSION = "0.1.0"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ACPMessage:
|
|
45
|
+
"""A message received from the agent."""
|
|
46
|
+
|
|
47
|
+
type: str
|
|
48
|
+
data: dict[str, Any]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ACPStats:
|
|
53
|
+
"""Statistics from an ACP session."""
|
|
54
|
+
|
|
55
|
+
tool_count: int = 0
|
|
56
|
+
files_modified: List[str] = field(default_factory=list)
|
|
57
|
+
files_read: List[str] = field(default_factory=list)
|
|
58
|
+
duration: float = 0.0
|
|
59
|
+
stop_reason: str = ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ACPClient:
|
|
64
|
+
"""
|
|
65
|
+
ACP (Agent Client Protocol) client for communicating with coding agents.
|
|
66
|
+
|
|
67
|
+
This client manages the subprocess communication with an ACP-compatible agent
|
|
68
|
+
and handles the JSON-RPC protocol.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
project_root: Path
|
|
72
|
+
command: str # e.g., "opencode acp"
|
|
73
|
+
model: Optional[str] = None
|
|
74
|
+
|
|
75
|
+
# Callbacks for handling agent events
|
|
76
|
+
on_message: Optional[Callable[[str], Awaitable[None]]] = None
|
|
77
|
+
on_thinking: Optional[Callable[[str], Awaitable[None]]] = None
|
|
78
|
+
on_tool_call: Optional[Callable[[ToolCall], Awaitable[None]]] = None
|
|
79
|
+
on_tool_update: Optional[Callable[[ToolCallUpdate], Awaitable[None]]] = None
|
|
80
|
+
on_permission_request: Optional[
|
|
81
|
+
Callable[[List[PermissionOption], ToolCall], Awaitable[str]]
|
|
82
|
+
] = None
|
|
83
|
+
on_plan: Optional[Callable[[List[dict]], Awaitable[None]]] = None
|
|
84
|
+
|
|
85
|
+
# Internal state
|
|
86
|
+
_process: Optional[asyncio.subprocess.Process] = field(default=None, repr=False)
|
|
87
|
+
_request_id: int = field(default=0, repr=False)
|
|
88
|
+
_pending_requests: Dict[int, asyncio.Future] = field(default_factory=dict, repr=False)
|
|
89
|
+
_session_id: str = field(default="", repr=False)
|
|
90
|
+
_tool_calls: Dict[str, ToolCall] = field(default_factory=dict, repr=False)
|
|
91
|
+
_read_task: Optional[asyncio.Task] = field(default=None, repr=False)
|
|
92
|
+
_terminal_count: int = field(default=0, repr=False)
|
|
93
|
+
_terminals: Dict[str, dict] = field(default_factory=dict, repr=False)
|
|
94
|
+
|
|
95
|
+
# Tracking stats
|
|
96
|
+
_files_modified: List[str] = field(default_factory=list, repr=False)
|
|
97
|
+
_files_read: List[str] = field(default_factory=list, repr=False)
|
|
98
|
+
_tool_actions: List[dict] = field(default_factory=list, repr=False)
|
|
99
|
+
_start_time: float = field(default=0.0, repr=False)
|
|
100
|
+
_message_buffer: str = field(default="", repr=False)
|
|
101
|
+
|
|
102
|
+
def reset_stats(self) -> None:
|
|
103
|
+
"""Reset tracking stats for a new prompt."""
|
|
104
|
+
self._files_modified = []
|
|
105
|
+
self._files_read = []
|
|
106
|
+
self._tool_actions = []
|
|
107
|
+
self._start_time = monotonic()
|
|
108
|
+
self._message_buffer = ""
|
|
109
|
+
|
|
110
|
+
def get_stats(self) -> ACPStats:
|
|
111
|
+
"""Get current session stats."""
|
|
112
|
+
return ACPStats(
|
|
113
|
+
tool_count=len(self._tool_actions),
|
|
114
|
+
files_modified=self._files_modified.copy(),
|
|
115
|
+
files_read=self._files_read.copy(),
|
|
116
|
+
duration=monotonic() - self._start_time if self._start_time else 0.0,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def get_message_buffer(self) -> str:
|
|
120
|
+
"""Get accumulated message text."""
|
|
121
|
+
return self._message_buffer
|
|
122
|
+
|
|
123
|
+
async def start(self) -> bool:
|
|
124
|
+
"""Start the ACP agent subprocess."""
|
|
125
|
+
try:
|
|
126
|
+
# Use command as-is - model selection is handled via ACP protocol
|
|
127
|
+
# Don't add -m flag as not all agents support it (e.g., opencode acp)
|
|
128
|
+
cmd = self.command
|
|
129
|
+
|
|
130
|
+
env = os.environ.copy()
|
|
131
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
132
|
+
|
|
133
|
+
# Add --print-logs for debugging if needed
|
|
134
|
+
if "opencode" in cmd:
|
|
135
|
+
cmd = f"{cmd} --print-logs"
|
|
136
|
+
|
|
137
|
+
self._process = await asyncio.create_subprocess_shell(
|
|
138
|
+
cmd,
|
|
139
|
+
stdin=asyncio.subprocess.PIPE,
|
|
140
|
+
stdout=asyncio.subprocess.PIPE,
|
|
141
|
+
stderr=asyncio.subprocess.STDOUT, # Merge stderr into stdout
|
|
142
|
+
cwd=str(self.project_root),
|
|
143
|
+
env=env,
|
|
144
|
+
limit=10 * 1024 * 1024, # 10MB buffer
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Start reading output
|
|
148
|
+
self._read_task = asyncio.create_task(self._read_loop())
|
|
149
|
+
|
|
150
|
+
# Initialize the protocol
|
|
151
|
+
await self._initialize()
|
|
152
|
+
|
|
153
|
+
# Create a new session
|
|
154
|
+
await self._new_session()
|
|
155
|
+
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
except Exception as e:
|
|
159
|
+
if self.on_thinking:
|
|
160
|
+
await self.on_thinking(f"[startup error] {e}")
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
async def stop(self) -> None:
|
|
164
|
+
"""Stop the ACP agent subprocess."""
|
|
165
|
+
if self._read_task:
|
|
166
|
+
self._read_task.cancel()
|
|
167
|
+
try:
|
|
168
|
+
await self._read_task
|
|
169
|
+
except asyncio.CancelledError:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
if self._process:
|
|
173
|
+
self._process.terminate()
|
|
174
|
+
try:
|
|
175
|
+
await asyncio.wait_for(self._process.wait(), timeout=5.0)
|
|
176
|
+
except asyncio.TimeoutError:
|
|
177
|
+
self._process.kill()
|
|
178
|
+
self._process = None
|
|
179
|
+
|
|
180
|
+
async def send_prompt(self, prompt: str) -> Optional[str]:
|
|
181
|
+
"""
|
|
182
|
+
Send a prompt to the agent and wait for completion.
|
|
183
|
+
|
|
184
|
+
Returns the stop reason.
|
|
185
|
+
"""
|
|
186
|
+
# Reset stats for this prompt
|
|
187
|
+
self.reset_stats()
|
|
188
|
+
|
|
189
|
+
content_blocks: List[ContentBlock] = [{"type": "text", "text": prompt}]
|
|
190
|
+
|
|
191
|
+
response = await self._call_method(
|
|
192
|
+
"session/prompt",
|
|
193
|
+
prompt=content_blocks,
|
|
194
|
+
sessionId=self._session_id,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
stop_reason = response.get("stopReason") if response else None
|
|
198
|
+
return stop_reason
|
|
199
|
+
|
|
200
|
+
async def cancel(self) -> bool:
|
|
201
|
+
"""Cancel the current operation."""
|
|
202
|
+
try:
|
|
203
|
+
await self._send_notification(
|
|
204
|
+
"session/cancel",
|
|
205
|
+
sessionId=self._session_id,
|
|
206
|
+
_meta={},
|
|
207
|
+
)
|
|
208
|
+
return True
|
|
209
|
+
except Exception:
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
async def switch_model(self, new_model: str) -> bool:
|
|
213
|
+
"""
|
|
214
|
+
Switch to a new model, creating a new session.
|
|
215
|
+
|
|
216
|
+
When the user changes the model, we need to:
|
|
217
|
+
1. Stop the current session cleanly
|
|
218
|
+
2. Update the model configuration
|
|
219
|
+
3. Start fresh with a new session
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
new_model: The new model identifier to switch to.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True if switch was successful, False otherwise.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
# Cancel any pending operations
|
|
229
|
+
await self.cancel()
|
|
230
|
+
|
|
231
|
+
# Stop the current agent process
|
|
232
|
+
await self.stop()
|
|
233
|
+
|
|
234
|
+
# Update model
|
|
235
|
+
self.model = new_model
|
|
236
|
+
|
|
237
|
+
# Reset internal state
|
|
238
|
+
self._session_id = ""
|
|
239
|
+
self._tool_calls.clear()
|
|
240
|
+
self._terminals.clear()
|
|
241
|
+
self._terminal_count = 0
|
|
242
|
+
self.reset_stats()
|
|
243
|
+
|
|
244
|
+
# Start fresh with new session
|
|
245
|
+
return await self.start()
|
|
246
|
+
|
|
247
|
+
except Exception as e:
|
|
248
|
+
if self.on_thinking:
|
|
249
|
+
await self.on_thinking(f"[model switch error] {e}")
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
async def reset_session(self) -> bool:
|
|
253
|
+
"""
|
|
254
|
+
Reset the current session without changing the model.
|
|
255
|
+
|
|
256
|
+
Creates a new session with the same configuration.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
True if reset was successful, False otherwise.
|
|
260
|
+
"""
|
|
261
|
+
try:
|
|
262
|
+
# Cancel any pending operations
|
|
263
|
+
await self.cancel()
|
|
264
|
+
|
|
265
|
+
# Reset internal state
|
|
266
|
+
self._tool_calls.clear()
|
|
267
|
+
self._terminals.clear()
|
|
268
|
+
self._terminal_count = 0
|
|
269
|
+
self.reset_stats()
|
|
270
|
+
|
|
271
|
+
# Create new session
|
|
272
|
+
await self._new_session()
|
|
273
|
+
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
if self.on_thinking:
|
|
278
|
+
await self.on_thinking(f"[session reset error] {e}")
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
def get_current_model(self) -> Optional[str]:
|
|
282
|
+
"""Get the currently configured model."""
|
|
283
|
+
return self.model
|
|
284
|
+
|
|
285
|
+
def get_session_id(self) -> str:
|
|
286
|
+
"""Get the current session ID."""
|
|
287
|
+
return self._session_id
|
|
288
|
+
|
|
289
|
+
# ========================================================================
|
|
290
|
+
# Internal Methods
|
|
291
|
+
# ========================================================================
|
|
292
|
+
|
|
293
|
+
async def _initialize(self) -> InitializeResponse:
|
|
294
|
+
"""Initialize the ACP protocol."""
|
|
295
|
+
response = await self._call_method(
|
|
296
|
+
"initialize",
|
|
297
|
+
protocolVersion=PROTOCOL_VERSION,
|
|
298
|
+
clientCapabilities={
|
|
299
|
+
"fs": {
|
|
300
|
+
"readTextFile": True,
|
|
301
|
+
"writeTextFile": True,
|
|
302
|
+
},
|
|
303
|
+
"terminal": True,
|
|
304
|
+
},
|
|
305
|
+
clientInfo={
|
|
306
|
+
"name": CLIENT_NAME,
|
|
307
|
+
"title": "SuperQode - Multi-Agent Coding Team",
|
|
308
|
+
"version": CLIENT_VERSION,
|
|
309
|
+
},
|
|
310
|
+
)
|
|
311
|
+
return response
|
|
312
|
+
|
|
313
|
+
async def _new_session(self) -> NewSessionResponse:
|
|
314
|
+
"""Create a new session."""
|
|
315
|
+
response = await self._call_method(
|
|
316
|
+
"session/new",
|
|
317
|
+
cwd=str(self.project_root),
|
|
318
|
+
mcpServers=[],
|
|
319
|
+
)
|
|
320
|
+
self._session_id = response.get("sessionId", "")
|
|
321
|
+
return response
|
|
322
|
+
|
|
323
|
+
async def _call_method(self, method: str, **params) -> Dict[str, Any]:
|
|
324
|
+
"""Call a JSON-RPC method and wait for response."""
|
|
325
|
+
self._request_id += 1
|
|
326
|
+
request_id = self._request_id
|
|
327
|
+
|
|
328
|
+
request = {
|
|
329
|
+
"jsonrpc": "2.0",
|
|
330
|
+
"method": method,
|
|
331
|
+
"params": params,
|
|
332
|
+
"id": request_id,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
# Create future for response
|
|
336
|
+
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
337
|
+
self._pending_requests[request_id] = future
|
|
338
|
+
|
|
339
|
+
# Send request
|
|
340
|
+
await self._send_json(request)
|
|
341
|
+
|
|
342
|
+
# Wait for response
|
|
343
|
+
try:
|
|
344
|
+
response = await asyncio.wait_for(future, timeout=300.0) # 5 min timeout
|
|
345
|
+
return response
|
|
346
|
+
except asyncio.TimeoutError:
|
|
347
|
+
del self._pending_requests[request_id]
|
|
348
|
+
raise
|
|
349
|
+
|
|
350
|
+
async def _send_notification(self, method: str, **params) -> None:
|
|
351
|
+
"""Send a JSON-RPC notification (no response expected)."""
|
|
352
|
+
notification = {
|
|
353
|
+
"jsonrpc": "2.0",
|
|
354
|
+
"method": method,
|
|
355
|
+
"params": params,
|
|
356
|
+
}
|
|
357
|
+
await self._send_json(notification)
|
|
358
|
+
|
|
359
|
+
async def _send_json(self, data: dict) -> None:
|
|
360
|
+
"""Send JSON data to the agent."""
|
|
361
|
+
if self._process and self._process.stdin:
|
|
362
|
+
json_bytes = json.dumps(data).encode("utf-8") + b"\n"
|
|
363
|
+
self._process.stdin.write(json_bytes)
|
|
364
|
+
await self._process.stdin.drain()
|
|
365
|
+
|
|
366
|
+
async def _read_loop(self) -> None:
|
|
367
|
+
"""Read and process output from the agent."""
|
|
368
|
+
if not self._process or not self._process.stdout:
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
while True:
|
|
372
|
+
try:
|
|
373
|
+
line = await self._process.stdout.readline()
|
|
374
|
+
if not line:
|
|
375
|
+
break
|
|
376
|
+
|
|
377
|
+
line_str = line.decode("utf-8").strip()
|
|
378
|
+
if not line_str:
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
data = json.loads(line_str)
|
|
383
|
+
await self._handle_message(data)
|
|
384
|
+
except json.JSONDecodeError:
|
|
385
|
+
# Not JSON - might be debug output, log it
|
|
386
|
+
if self.on_thinking and line_str:
|
|
387
|
+
await self.on_thinking(f"[agent] {line_str}")
|
|
388
|
+
|
|
389
|
+
except asyncio.CancelledError:
|
|
390
|
+
break
|
|
391
|
+
except Exception as e:
|
|
392
|
+
if self.on_thinking:
|
|
393
|
+
await self.on_thinking(f"[error] {e}")
|
|
394
|
+
break
|
|
395
|
+
|
|
396
|
+
async def _handle_message(self, data: dict) -> None:
|
|
397
|
+
"""Handle an incoming JSON-RPC message."""
|
|
398
|
+
# Check if it's a response to a pending request
|
|
399
|
+
if "result" in data or "error" in data:
|
|
400
|
+
request_id = data.get("id")
|
|
401
|
+
if request_id and request_id in self._pending_requests:
|
|
402
|
+
future = self._pending_requests.pop(request_id)
|
|
403
|
+
if "error" in data:
|
|
404
|
+
future.set_exception(Exception(data["error"].get("message", "Unknown error")))
|
|
405
|
+
else:
|
|
406
|
+
future.set_result(data.get("result", {}))
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
# It's a request from the agent - handle it
|
|
410
|
+
method = data.get("method", "")
|
|
411
|
+
params = data.get("params", {})
|
|
412
|
+
request_id = data.get("id")
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
result = await self._handle_agent_request(method, params)
|
|
416
|
+
|
|
417
|
+
# Send response if this was a request (not notification)
|
|
418
|
+
if request_id is not None:
|
|
419
|
+
response = {
|
|
420
|
+
"jsonrpc": "2.0",
|
|
421
|
+
"result": result,
|
|
422
|
+
"id": request_id,
|
|
423
|
+
}
|
|
424
|
+
await self._send_json(response)
|
|
425
|
+
|
|
426
|
+
except Exception as e:
|
|
427
|
+
if request_id is not None:
|
|
428
|
+
error_response = {
|
|
429
|
+
"jsonrpc": "2.0",
|
|
430
|
+
"error": {
|
|
431
|
+
"code": -32603,
|
|
432
|
+
"message": str(e),
|
|
433
|
+
},
|
|
434
|
+
"id": request_id,
|
|
435
|
+
}
|
|
436
|
+
await self._send_json(error_response)
|
|
437
|
+
|
|
438
|
+
async def _handle_agent_request(self, method: str, params: dict) -> Any:
|
|
439
|
+
"""Handle a request from the agent."""
|
|
440
|
+
|
|
441
|
+
if method == "session/update":
|
|
442
|
+
await self._handle_session_update(params)
|
|
443
|
+
return {}
|
|
444
|
+
|
|
445
|
+
elif method == "session/request_permission":
|
|
446
|
+
return await self._handle_permission_request(params)
|
|
447
|
+
|
|
448
|
+
elif method == "fs/read_text_file":
|
|
449
|
+
return self._handle_read_file(params)
|
|
450
|
+
|
|
451
|
+
elif method == "fs/write_text_file":
|
|
452
|
+
return self._handle_write_file(params)
|
|
453
|
+
|
|
454
|
+
elif method == "terminal/create":
|
|
455
|
+
return await self._handle_terminal_create(params)
|
|
456
|
+
|
|
457
|
+
elif method == "terminal/output":
|
|
458
|
+
return await self._handle_terminal_output(params)
|
|
459
|
+
|
|
460
|
+
elif method == "terminal/kill":
|
|
461
|
+
return self._handle_terminal_kill(params)
|
|
462
|
+
|
|
463
|
+
elif method == "terminal/release":
|
|
464
|
+
return self._handle_terminal_release(params)
|
|
465
|
+
|
|
466
|
+
elif method == "terminal/wait_for_exit":
|
|
467
|
+
return await self._handle_terminal_wait_for_exit(params)
|
|
468
|
+
|
|
469
|
+
else:
|
|
470
|
+
raise Exception(f"Unknown method: {method}")
|
|
471
|
+
|
|
472
|
+
async def _handle_session_update(self, params: dict) -> None:
|
|
473
|
+
"""Handle session update notifications."""
|
|
474
|
+
# The params dict IS the update - sessionUpdate is a direct key
|
|
475
|
+
update = params
|
|
476
|
+
update_type = update.get("sessionUpdate", "")
|
|
477
|
+
|
|
478
|
+
# Also check if update is nested (some implementations do this)
|
|
479
|
+
if not update_type and "update" in params:
|
|
480
|
+
update = params.get("update", {})
|
|
481
|
+
update_type = update.get("sessionUpdate", "")
|
|
482
|
+
|
|
483
|
+
if update_type == "agent_message_chunk":
|
|
484
|
+
content = update.get("content", {})
|
|
485
|
+
text = self._content_to_text(content)
|
|
486
|
+
if text:
|
|
487
|
+
self._message_buffer += text
|
|
488
|
+
if self.on_message:
|
|
489
|
+
await self.on_message(text)
|
|
490
|
+
|
|
491
|
+
elif update_type == "agent_thought_chunk":
|
|
492
|
+
content = update.get("content", {})
|
|
493
|
+
text = self._content_to_text(content)
|
|
494
|
+
if text and self.on_thinking:
|
|
495
|
+
await self.on_thinking(text)
|
|
496
|
+
|
|
497
|
+
elif update_type == "tool_call":
|
|
498
|
+
tool_call_id = update.get("toolCallId", "")
|
|
499
|
+
self._tool_calls[tool_call_id] = update
|
|
500
|
+
|
|
501
|
+
# Track tool action
|
|
502
|
+
kind = update.get("kind", "other")
|
|
503
|
+
title = update.get("title", "")
|
|
504
|
+
raw_input = update.get("rawInput", {})
|
|
505
|
+
self._tool_actions.append(
|
|
506
|
+
{
|
|
507
|
+
"tool": title,
|
|
508
|
+
"kind": kind,
|
|
509
|
+
"input": raw_input,
|
|
510
|
+
}
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
# Track file operations from tool call
|
|
514
|
+
locations = update.get("locations", [])
|
|
515
|
+
for loc in locations:
|
|
516
|
+
path = loc.get("path", "")
|
|
517
|
+
if path:
|
|
518
|
+
if kind in ("edit", "write", "delete"):
|
|
519
|
+
if path not in self._files_modified:
|
|
520
|
+
self._files_modified.append(path)
|
|
521
|
+
elif kind == "read":
|
|
522
|
+
if path not in self._files_read:
|
|
523
|
+
self._files_read.append(path)
|
|
524
|
+
|
|
525
|
+
if self.on_tool_call:
|
|
526
|
+
await self.on_tool_call(update)
|
|
527
|
+
|
|
528
|
+
elif update_type == "tool_call_update":
|
|
529
|
+
tool_call_id = update.get("toolCallId", "")
|
|
530
|
+
if tool_call_id in self._tool_calls:
|
|
531
|
+
# Merge update into existing tool call
|
|
532
|
+
for key, value in update.items():
|
|
533
|
+
if value is not None:
|
|
534
|
+
self._tool_calls[tool_call_id][key] = value
|
|
535
|
+
if self.on_tool_update:
|
|
536
|
+
await self.on_tool_update(update)
|
|
537
|
+
|
|
538
|
+
elif update_type == "plan":
|
|
539
|
+
entries = update.get("entries", [])
|
|
540
|
+
if self.on_plan:
|
|
541
|
+
await self.on_plan(entries)
|
|
542
|
+
|
|
543
|
+
def _content_to_text(self, content: Any) -> str:
|
|
544
|
+
"""Convert ACP content blocks into a displayable text string."""
|
|
545
|
+
if content is None:
|
|
546
|
+
return ""
|
|
547
|
+
if isinstance(content, list):
|
|
548
|
+
parts = [self._content_to_text(item) for item in content]
|
|
549
|
+
return "".join([p for p in parts if p])
|
|
550
|
+
if not isinstance(content, dict):
|
|
551
|
+
return str(content)
|
|
552
|
+
|
|
553
|
+
content_type = content.get("type")
|
|
554
|
+
if content_type == "text":
|
|
555
|
+
return content.get("text", "")
|
|
556
|
+
if content_type == "image":
|
|
557
|
+
mime = content.get("mimeType", "image")
|
|
558
|
+
data = content.get("data", "")
|
|
559
|
+
size = len(data) if isinstance(data, str) else 0
|
|
560
|
+
return f"[image:{mime} {size} bytes]"
|
|
561
|
+
if content_type == "audio":
|
|
562
|
+
mime = content.get("mimeType", "audio")
|
|
563
|
+
return f"[audio:{mime}]"
|
|
564
|
+
if content_type in ("resource", "embedded_resource", "embeddedResource"):
|
|
565
|
+
name = content.get("name") or content.get("uri") or "resource"
|
|
566
|
+
return f"[resource:{name}]"
|
|
567
|
+
if content_type in ("resource_link", "link"):
|
|
568
|
+
name = content.get("title") or content.get("uri") or "link"
|
|
569
|
+
return f"[link:{name}]"
|
|
570
|
+
|
|
571
|
+
text = content.get("text")
|
|
572
|
+
if text:
|
|
573
|
+
return text
|
|
574
|
+
return ""
|
|
575
|
+
|
|
576
|
+
async def _handle_permission_request(self, params: dict) -> dict:
|
|
577
|
+
"""Handle permission request from agent."""
|
|
578
|
+
options = params.get("options", [])
|
|
579
|
+
tool_call = params.get("toolCall", {})
|
|
580
|
+
|
|
581
|
+
# Store tool call if not already stored
|
|
582
|
+
tool_call_id = tool_call.get("toolCallId", "")
|
|
583
|
+
if tool_call_id and tool_call_id not in self._tool_calls:
|
|
584
|
+
self._tool_calls[tool_call_id] = tool_call
|
|
585
|
+
|
|
586
|
+
# Call the permission callback if set
|
|
587
|
+
if self.on_permission_request:
|
|
588
|
+
option_id = await self.on_permission_request(options, tool_call)
|
|
589
|
+
return {
|
|
590
|
+
"outcome": {
|
|
591
|
+
"outcome": "selected",
|
|
592
|
+
"optionId": option_id,
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
# Default: allow once
|
|
597
|
+
for opt in options:
|
|
598
|
+
if opt.get("kind") == "allow_once":
|
|
599
|
+
return {
|
|
600
|
+
"outcome": {
|
|
601
|
+
"outcome": "selected",
|
|
602
|
+
"optionId": opt.get("optionId", ""),
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
# Fallback to first option
|
|
607
|
+
if options:
|
|
608
|
+
return {
|
|
609
|
+
"outcome": {
|
|
610
|
+
"outcome": "selected",
|
|
611
|
+
"optionId": options[0].get("optionId", ""),
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return {"outcome": {"outcome": "cancelled"}}
|
|
616
|
+
|
|
617
|
+
def _handle_read_file(self, params: dict) -> dict:
|
|
618
|
+
"""Handle file read request."""
|
|
619
|
+
path = params.get("path", "")
|
|
620
|
+
line = params.get("line")
|
|
621
|
+
limit = params.get("limit")
|
|
622
|
+
|
|
623
|
+
# Track file read
|
|
624
|
+
if path and path not in self._files_read:
|
|
625
|
+
self._files_read.append(path)
|
|
626
|
+
|
|
627
|
+
read_path = self.project_root / path
|
|
628
|
+
try:
|
|
629
|
+
text = read_path.read_text(encoding="utf-8", errors="ignore")
|
|
630
|
+
|
|
631
|
+
if line is not None:
|
|
632
|
+
line = max(0, line - 1)
|
|
633
|
+
lines = text.splitlines()
|
|
634
|
+
if limit is None:
|
|
635
|
+
text = "\n".join(lines[line:])
|
|
636
|
+
else:
|
|
637
|
+
text = "\n".join(lines[line : line + limit])
|
|
638
|
+
|
|
639
|
+
return {"content": text}
|
|
640
|
+
except IOError:
|
|
641
|
+
return {"content": ""}
|
|
642
|
+
|
|
643
|
+
def _handle_write_file(self, params: dict) -> dict:
|
|
644
|
+
"""Handle file write request."""
|
|
645
|
+
path = params.get("path", "")
|
|
646
|
+
content = params.get("content", "")
|
|
647
|
+
|
|
648
|
+
# Track file modification
|
|
649
|
+
if path and path not in self._files_modified:
|
|
650
|
+
self._files_modified.append(path)
|
|
651
|
+
|
|
652
|
+
write_path = self.project_root / path
|
|
653
|
+
write_path.parent.mkdir(parents=True, exist_ok=True)
|
|
654
|
+
write_path.write_text(content, encoding="utf-8")
|
|
655
|
+
return {}
|
|
656
|
+
|
|
657
|
+
# ========================================================================
|
|
658
|
+
# Mode and Model Management (ACP Protocol Completeness)
|
|
659
|
+
# ========================================================================
|
|
660
|
+
|
|
661
|
+
async def get_available_modes(self) -> List[AvailableMode]:
|
|
662
|
+
"""Get list of available modes from the agent."""
|
|
663
|
+
try:
|
|
664
|
+
response = await self._call_method(
|
|
665
|
+
"session/modes",
|
|
666
|
+
sessionId=self._session_id,
|
|
667
|
+
)
|
|
668
|
+
return response.get("modes", [])
|
|
669
|
+
except Exception:
|
|
670
|
+
return []
|
|
671
|
+
|
|
672
|
+
async def get_available_models(self) -> List[AvailableModel]:
|
|
673
|
+
"""Get list of available models from the agent."""
|
|
674
|
+
try:
|
|
675
|
+
response = await self._call_method(
|
|
676
|
+
"session/models",
|
|
677
|
+
sessionId=self._session_id,
|
|
678
|
+
)
|
|
679
|
+
return response.get("models", [])
|
|
680
|
+
except Exception:
|
|
681
|
+
return []
|
|
682
|
+
|
|
683
|
+
async def set_mode(self, mode_slug: str) -> bool:
|
|
684
|
+
"""Set the current mode for the session."""
|
|
685
|
+
try:
|
|
686
|
+
await self._call_method(
|
|
687
|
+
"session/set_mode",
|
|
688
|
+
sessionId=self._session_id,
|
|
689
|
+
modeSlug=mode_slug,
|
|
690
|
+
)
|
|
691
|
+
return True
|
|
692
|
+
except Exception:
|
|
693
|
+
return False
|
|
694
|
+
|
|
695
|
+
async def set_model(self, model_id: str) -> bool:
|
|
696
|
+
"""Set the current model for the session."""
|
|
697
|
+
try:
|
|
698
|
+
await self._call_method(
|
|
699
|
+
"session/set_model",
|
|
700
|
+
sessionId=self._session_id,
|
|
701
|
+
modelId=model_id,
|
|
702
|
+
)
|
|
703
|
+
return True
|
|
704
|
+
except Exception:
|
|
705
|
+
return False
|
|
706
|
+
|
|
707
|
+
async def get_current_mode(self) -> Optional[str]:
|
|
708
|
+
"""Get the current mode."""
|
|
709
|
+
try:
|
|
710
|
+
response = await self._call_method(
|
|
711
|
+
"session/modes",
|
|
712
|
+
sessionId=self._session_id,
|
|
713
|
+
)
|
|
714
|
+
return response.get("currentMode")
|
|
715
|
+
except Exception:
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
async def get_current_model(self) -> Optional[str]:
|
|
719
|
+
"""Get the current model."""
|
|
720
|
+
try:
|
|
721
|
+
response = await self._call_method(
|
|
722
|
+
"session/models",
|
|
723
|
+
sessionId=self._session_id,
|
|
724
|
+
)
|
|
725
|
+
return response.get("currentModel")
|
|
726
|
+
except Exception:
|
|
727
|
+
return None
|
|
728
|
+
|
|
729
|
+
# ========================================================================
|
|
730
|
+
# Slash Commands (ACP Protocol Completeness)
|
|
731
|
+
# ========================================================================
|
|
732
|
+
|
|
733
|
+
async def get_available_commands(self) -> List[SlashCommand]:
|
|
734
|
+
"""Get list of available slash commands from the agent."""
|
|
735
|
+
try:
|
|
736
|
+
response = await self._call_method(
|
|
737
|
+
"session/commands",
|
|
738
|
+
sessionId=self._session_id,
|
|
739
|
+
)
|
|
740
|
+
return response.get("commands", [])
|
|
741
|
+
except Exception:
|
|
742
|
+
return []
|
|
743
|
+
|
|
744
|
+
async def execute_command(
|
|
745
|
+
self, command_name: str, args: Optional[Dict[str, Any]] = None
|
|
746
|
+
) -> Optional[str]:
|
|
747
|
+
"""Execute a slash command."""
|
|
748
|
+
try:
|
|
749
|
+
response = await self._call_method(
|
|
750
|
+
"session/execute_command",
|
|
751
|
+
sessionId=self._session_id,
|
|
752
|
+
command=command_name,
|
|
753
|
+
args=args or {},
|
|
754
|
+
)
|
|
755
|
+
return response.get("result")
|
|
756
|
+
except Exception as e:
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
# ========================================================================
|
|
760
|
+
# Batch Operations (ACP Protocol Completeness)
|
|
761
|
+
# ========================================================================
|
|
762
|
+
|
|
763
|
+
async def batch_request(self, requests: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
764
|
+
"""Execute multiple requests in a batch."""
|
|
765
|
+
try:
|
|
766
|
+
response = await self._call_method(
|
|
767
|
+
"batch",
|
|
768
|
+
requests=requests,
|
|
769
|
+
)
|
|
770
|
+
return response.get("responses", [])
|
|
771
|
+
except Exception:
|
|
772
|
+
return []
|
|
773
|
+
|
|
774
|
+
# ========================================================================
|
|
775
|
+
# Terminal Handling
|
|
776
|
+
# ========================================================================
|
|
777
|
+
|
|
778
|
+
async def _handle_terminal_create(self, params: dict) -> CreateTerminalResponse:
|
|
779
|
+
"""Handle terminal create request."""
|
|
780
|
+
command = params.get("command", "")
|
|
781
|
+
args = params.get("args", [])
|
|
782
|
+
cwd = params.get("cwd")
|
|
783
|
+
env_vars = params.get("env", [])
|
|
784
|
+
|
|
785
|
+
self._terminal_count += 1
|
|
786
|
+
terminal_id = f"terminal-{self._terminal_count}"
|
|
787
|
+
|
|
788
|
+
# Build environment
|
|
789
|
+
env = os.environ.copy()
|
|
790
|
+
for var in env_vars:
|
|
791
|
+
env[var["name"]] = var["value"]
|
|
792
|
+
|
|
793
|
+
# Build full command
|
|
794
|
+
if args:
|
|
795
|
+
full_command = f"{command} {' '.join(args)}"
|
|
796
|
+
else:
|
|
797
|
+
full_command = command
|
|
798
|
+
|
|
799
|
+
# Start the process
|
|
800
|
+
try:
|
|
801
|
+
process = await asyncio.create_subprocess_shell(
|
|
802
|
+
full_command,
|
|
803
|
+
stdout=asyncio.subprocess.PIPE,
|
|
804
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
805
|
+
stdin=asyncio.subprocess.PIPE,
|
|
806
|
+
cwd=cwd or str(self.project_root),
|
|
807
|
+
env=env,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
self._terminals[terminal_id] = {
|
|
811
|
+
"process": process,
|
|
812
|
+
"output": "",
|
|
813
|
+
"truncated": False,
|
|
814
|
+
"exit_code": None,
|
|
815
|
+
"signal": None,
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
# Start reading output in background
|
|
819
|
+
asyncio.create_task(self._read_terminal_output(terminal_id))
|
|
820
|
+
|
|
821
|
+
return {"terminalId": terminal_id}
|
|
822
|
+
|
|
823
|
+
except Exception as e:
|
|
824
|
+
raise Exception(f"Failed to create terminal: {e}")
|
|
825
|
+
|
|
826
|
+
async def _read_terminal_output(self, terminal_id: str) -> None:
|
|
827
|
+
"""Read output from a terminal process."""
|
|
828
|
+
terminal = self._terminals.get(terminal_id)
|
|
829
|
+
if not terminal:
|
|
830
|
+
return
|
|
831
|
+
|
|
832
|
+
process = terminal["process"]
|
|
833
|
+
output_limit = 100 * 1024 # 100KB limit
|
|
834
|
+
|
|
835
|
+
try:
|
|
836
|
+
while True:
|
|
837
|
+
chunk = await process.stdout.read(4096)
|
|
838
|
+
if not chunk:
|
|
839
|
+
break
|
|
840
|
+
|
|
841
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
842
|
+
|
|
843
|
+
if len(terminal["output"]) + len(text) > output_limit:
|
|
844
|
+
terminal["truncated"] = True
|
|
845
|
+
remaining = output_limit - len(terminal["output"])
|
|
846
|
+
terminal["output"] += text[:remaining]
|
|
847
|
+
break
|
|
848
|
+
else:
|
|
849
|
+
terminal["output"] += text
|
|
850
|
+
|
|
851
|
+
# Process finished
|
|
852
|
+
await process.wait()
|
|
853
|
+
terminal["exit_code"] = process.returncode
|
|
854
|
+
|
|
855
|
+
except Exception:
|
|
856
|
+
pass
|
|
857
|
+
|
|
858
|
+
async def _handle_terminal_output(self, params: dict) -> TerminalOutputResponse:
|
|
859
|
+
"""Handle terminal output request."""
|
|
860
|
+
terminal_id = params.get("terminalId", "")
|
|
861
|
+
terminal = self._terminals.get(terminal_id)
|
|
862
|
+
|
|
863
|
+
if not terminal:
|
|
864
|
+
return {
|
|
865
|
+
"output": "",
|
|
866
|
+
"truncated": False,
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
result: TerminalOutputResponse = {
|
|
870
|
+
"output": terminal["output"],
|
|
871
|
+
"truncated": terminal["truncated"],
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if terminal["exit_code"] is not None:
|
|
875
|
+
result["exitStatus"] = {"exitCode": terminal["exit_code"]}
|
|
876
|
+
|
|
877
|
+
return result
|
|
878
|
+
|
|
879
|
+
def _handle_terminal_kill(self, params: dict) -> dict:
|
|
880
|
+
"""Handle terminal kill request."""
|
|
881
|
+
terminal_id = params.get("terminalId", "")
|
|
882
|
+
terminal = self._terminals.get(terminal_id)
|
|
883
|
+
|
|
884
|
+
if terminal and terminal["process"]:
|
|
885
|
+
terminal["process"].terminate()
|
|
886
|
+
|
|
887
|
+
return {}
|
|
888
|
+
|
|
889
|
+
def _handle_terminal_release(self, params: dict) -> dict:
|
|
890
|
+
"""Handle terminal release request."""
|
|
891
|
+
terminal_id = params.get("terminalId", "")
|
|
892
|
+
if terminal_id in self._terminals:
|
|
893
|
+
del self._terminals[terminal_id]
|
|
894
|
+
return {}
|
|
895
|
+
|
|
896
|
+
async def _handle_terminal_wait_for_exit(self, params: dict) -> WaitForTerminalExitResponse:
|
|
897
|
+
"""Handle terminal wait for exit request."""
|
|
898
|
+
terminal_id = params.get("terminalId", "")
|
|
899
|
+
terminal = self._terminals.get(terminal_id)
|
|
900
|
+
|
|
901
|
+
if not terminal:
|
|
902
|
+
return {"exitCode": -1, "signal": None}
|
|
903
|
+
|
|
904
|
+
process = terminal["process"]
|
|
905
|
+
|
|
906
|
+
# Wait for process to complete
|
|
907
|
+
await process.wait()
|
|
908
|
+
terminal["exit_code"] = process.returncode
|
|
909
|
+
|
|
910
|
+
return {
|
|
911
|
+
"exitCode": terminal["exit_code"],
|
|
912
|
+
"signal": terminal["signal"],
|
|
913
|
+
}
|