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.
Files changed (288) hide show
  1. superqode/__init__.py +33 -0
  2. superqode/acp/__init__.py +23 -0
  3. superqode/acp/client.py +913 -0
  4. superqode/acp/permission_screen.py +457 -0
  5. superqode/acp/types.py +480 -0
  6. superqode/acp_discovery.py +856 -0
  7. superqode/agent/__init__.py +22 -0
  8. superqode/agent/edit_strategies.py +334 -0
  9. superqode/agent/loop.py +892 -0
  10. superqode/agent/qe_report_templates.py +39 -0
  11. superqode/agent/system_prompts.py +353 -0
  12. superqode/agent_output.py +721 -0
  13. superqode/agent_stream.py +953 -0
  14. superqode/agents/__init__.py +59 -0
  15. superqode/agents/acp_registry.py +305 -0
  16. superqode/agents/client.py +249 -0
  17. superqode/agents/data/augmentcode.com.toml +51 -0
  18. superqode/agents/data/cagent.dev.toml +51 -0
  19. superqode/agents/data/claude.com.toml +60 -0
  20. superqode/agents/data/codeassistant.dev.toml +51 -0
  21. superqode/agents/data/codex.openai.com.toml +57 -0
  22. superqode/agents/data/fastagent.ai.toml +66 -0
  23. superqode/agents/data/geminicli.com.toml +77 -0
  24. superqode/agents/data/goose.block.xyz.toml +54 -0
  25. superqode/agents/data/junie.jetbrains.com.toml +56 -0
  26. superqode/agents/data/kimi.moonshot.cn.toml +57 -0
  27. superqode/agents/data/llmlingagent.dev.toml +51 -0
  28. superqode/agents/data/molt.bot.toml +49 -0
  29. superqode/agents/data/opencode.ai.toml +60 -0
  30. superqode/agents/data/stakpak.dev.toml +51 -0
  31. superqode/agents/data/vtcode.dev.toml +51 -0
  32. superqode/agents/discovery.py +266 -0
  33. superqode/agents/messaging.py +160 -0
  34. superqode/agents/persona.py +166 -0
  35. superqode/agents/registry.py +421 -0
  36. superqode/agents/schema.py +72 -0
  37. superqode/agents/unified.py +367 -0
  38. superqode/app/__init__.py +111 -0
  39. superqode/app/constants.py +314 -0
  40. superqode/app/css.py +366 -0
  41. superqode/app/models.py +118 -0
  42. superqode/app/suggester.py +125 -0
  43. superqode/app/widgets.py +1591 -0
  44. superqode/app_enhanced.py +399 -0
  45. superqode/app_main.py +17187 -0
  46. superqode/approval.py +312 -0
  47. superqode/atomic.py +296 -0
  48. superqode/commands/__init__.py +1 -0
  49. superqode/commands/acp.py +965 -0
  50. superqode/commands/agents.py +180 -0
  51. superqode/commands/auth.py +278 -0
  52. superqode/commands/config.py +374 -0
  53. superqode/commands/init.py +826 -0
  54. superqode/commands/providers.py +819 -0
  55. superqode/commands/qe.py +1145 -0
  56. superqode/commands/roles.py +380 -0
  57. superqode/commands/serve.py +172 -0
  58. superqode/commands/suggestions.py +127 -0
  59. superqode/commands/superqe.py +460 -0
  60. superqode/config/__init__.py +51 -0
  61. superqode/config/loader.py +812 -0
  62. superqode/config/schema.py +498 -0
  63. superqode/core/__init__.py +111 -0
  64. superqode/core/roles.py +281 -0
  65. superqode/danger.py +386 -0
  66. superqode/data/superqode-template.yaml +1522 -0
  67. superqode/design_system.py +1080 -0
  68. superqode/dialogs/__init__.py +6 -0
  69. superqode/dialogs/base.py +39 -0
  70. superqode/dialogs/model.py +130 -0
  71. superqode/dialogs/provider.py +870 -0
  72. superqode/diff_view.py +919 -0
  73. superqode/enterprise.py +21 -0
  74. superqode/evaluation/__init__.py +25 -0
  75. superqode/evaluation/adapters.py +93 -0
  76. superqode/evaluation/behaviors.py +89 -0
  77. superqode/evaluation/engine.py +209 -0
  78. superqode/evaluation/scenarios.py +96 -0
  79. superqode/execution/__init__.py +36 -0
  80. superqode/execution/linter.py +538 -0
  81. superqode/execution/modes.py +347 -0
  82. superqode/execution/resolver.py +283 -0
  83. superqode/execution/runner.py +642 -0
  84. superqode/file_explorer.py +811 -0
  85. superqode/file_viewer.py +471 -0
  86. superqode/flash.py +183 -0
  87. superqode/guidance/__init__.py +58 -0
  88. superqode/guidance/config.py +203 -0
  89. superqode/guidance/prompts.py +71 -0
  90. superqode/harness/__init__.py +54 -0
  91. superqode/harness/accelerator.py +291 -0
  92. superqode/harness/config.py +319 -0
  93. superqode/harness/validator.py +147 -0
  94. superqode/history.py +279 -0
  95. superqode/integrations/superopt_runner.py +124 -0
  96. superqode/logging/__init__.py +49 -0
  97. superqode/logging/adapters.py +219 -0
  98. superqode/logging/formatter.py +923 -0
  99. superqode/logging/integration.py +341 -0
  100. superqode/logging/sinks.py +170 -0
  101. superqode/logging/unified_log.py +417 -0
  102. superqode/lsp/__init__.py +26 -0
  103. superqode/lsp/client.py +544 -0
  104. superqode/main.py +1069 -0
  105. superqode/mcp/__init__.py +89 -0
  106. superqode/mcp/auth_storage.py +380 -0
  107. superqode/mcp/client.py +1236 -0
  108. superqode/mcp/config.py +319 -0
  109. superqode/mcp/integration.py +337 -0
  110. superqode/mcp/oauth.py +436 -0
  111. superqode/mcp/oauth_callback.py +385 -0
  112. superqode/mcp/types.py +290 -0
  113. superqode/memory/__init__.py +31 -0
  114. superqode/memory/feedback.py +342 -0
  115. superqode/memory/store.py +522 -0
  116. superqode/notifications.py +369 -0
  117. superqode/optimization/__init__.py +5 -0
  118. superqode/optimization/config.py +33 -0
  119. superqode/permissions/__init__.py +25 -0
  120. superqode/permissions/rules.py +488 -0
  121. superqode/plan.py +323 -0
  122. superqode/providers/__init__.py +33 -0
  123. superqode/providers/gateway/__init__.py +165 -0
  124. superqode/providers/gateway/base.py +228 -0
  125. superqode/providers/gateway/litellm_gateway.py +1170 -0
  126. superqode/providers/gateway/openresponses_gateway.py +436 -0
  127. superqode/providers/health.py +297 -0
  128. superqode/providers/huggingface/__init__.py +74 -0
  129. superqode/providers/huggingface/downloader.py +472 -0
  130. superqode/providers/huggingface/endpoints.py +442 -0
  131. superqode/providers/huggingface/hub.py +531 -0
  132. superqode/providers/huggingface/inference.py +394 -0
  133. superqode/providers/huggingface/transformers_runner.py +516 -0
  134. superqode/providers/local/__init__.py +100 -0
  135. superqode/providers/local/base.py +438 -0
  136. superqode/providers/local/discovery.py +418 -0
  137. superqode/providers/local/lmstudio.py +256 -0
  138. superqode/providers/local/mlx.py +457 -0
  139. superqode/providers/local/ollama.py +486 -0
  140. superqode/providers/local/sglang.py +268 -0
  141. superqode/providers/local/tgi.py +260 -0
  142. superqode/providers/local/tool_support.py +477 -0
  143. superqode/providers/local/vllm.py +258 -0
  144. superqode/providers/manager.py +1338 -0
  145. superqode/providers/models.py +1016 -0
  146. superqode/providers/models_dev.py +578 -0
  147. superqode/providers/openresponses/__init__.py +87 -0
  148. superqode/providers/openresponses/converters/__init__.py +17 -0
  149. superqode/providers/openresponses/converters/messages.py +343 -0
  150. superqode/providers/openresponses/converters/tools.py +268 -0
  151. superqode/providers/openresponses/schema/__init__.py +56 -0
  152. superqode/providers/openresponses/schema/models.py +585 -0
  153. superqode/providers/openresponses/streaming/__init__.py +5 -0
  154. superqode/providers/openresponses/streaming/parser.py +338 -0
  155. superqode/providers/openresponses/tools/__init__.py +21 -0
  156. superqode/providers/openresponses/tools/apply_patch.py +352 -0
  157. superqode/providers/openresponses/tools/code_interpreter.py +290 -0
  158. superqode/providers/openresponses/tools/file_search.py +333 -0
  159. superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
  160. superqode/providers/registry.py +716 -0
  161. superqode/providers/usage.py +332 -0
  162. superqode/pure_mode.py +384 -0
  163. superqode/qr/__init__.py +23 -0
  164. superqode/qr/dashboard.py +781 -0
  165. superqode/qr/generator.py +1018 -0
  166. superqode/qr/templates.py +135 -0
  167. superqode/safety/__init__.py +41 -0
  168. superqode/safety/sandbox.py +413 -0
  169. superqode/safety/warnings.py +256 -0
  170. superqode/server/__init__.py +33 -0
  171. superqode/server/lsp_server.py +775 -0
  172. superqode/server/web.py +250 -0
  173. superqode/session/__init__.py +25 -0
  174. superqode/session/persistence.py +580 -0
  175. superqode/session/sharing.py +477 -0
  176. superqode/session.py +475 -0
  177. superqode/sidebar.py +2991 -0
  178. superqode/stream_view.py +648 -0
  179. superqode/styles/__init__.py +3 -0
  180. superqode/superqe/__init__.py +184 -0
  181. superqode/superqe/acp_runner.py +1064 -0
  182. superqode/superqe/constitution/__init__.py +62 -0
  183. superqode/superqe/constitution/evaluator.py +308 -0
  184. superqode/superqe/constitution/loader.py +432 -0
  185. superqode/superqe/constitution/schema.py +250 -0
  186. superqode/superqe/events.py +591 -0
  187. superqode/superqe/frameworks/__init__.py +65 -0
  188. superqode/superqe/frameworks/base.py +234 -0
  189. superqode/superqe/frameworks/e2e.py +263 -0
  190. superqode/superqe/frameworks/executor.py +237 -0
  191. superqode/superqe/frameworks/javascript.py +409 -0
  192. superqode/superqe/frameworks/python.py +373 -0
  193. superqode/superqe/frameworks/registry.py +92 -0
  194. superqode/superqe/mcp_tools/__init__.py +47 -0
  195. superqode/superqe/mcp_tools/core_tools.py +418 -0
  196. superqode/superqe/mcp_tools/registry.py +230 -0
  197. superqode/superqe/mcp_tools/testing_tools.py +167 -0
  198. superqode/superqe/noise.py +89 -0
  199. superqode/superqe/orchestrator.py +778 -0
  200. superqode/superqe/roles.py +609 -0
  201. superqode/superqe/session.py +713 -0
  202. superqode/superqe/skills/__init__.py +57 -0
  203. superqode/superqe/skills/base.py +106 -0
  204. superqode/superqe/skills/core_skills.py +899 -0
  205. superqode/superqe/skills/registry.py +90 -0
  206. superqode/superqe/verifier.py +101 -0
  207. superqode/superqe_cli.py +76 -0
  208. superqode/tool_call.py +358 -0
  209. superqode/tools/__init__.py +93 -0
  210. superqode/tools/agent_tools.py +496 -0
  211. superqode/tools/base.py +324 -0
  212. superqode/tools/batch_tool.py +133 -0
  213. superqode/tools/diagnostics.py +311 -0
  214. superqode/tools/edit_tools.py +653 -0
  215. superqode/tools/enhanced_base.py +515 -0
  216. superqode/tools/file_tools.py +269 -0
  217. superqode/tools/file_tracking.py +45 -0
  218. superqode/tools/lsp_tools.py +610 -0
  219. superqode/tools/network_tools.py +350 -0
  220. superqode/tools/permissions.py +400 -0
  221. superqode/tools/question_tool.py +324 -0
  222. superqode/tools/search_tools.py +598 -0
  223. superqode/tools/shell_tools.py +259 -0
  224. superqode/tools/todo_tools.py +121 -0
  225. superqode/tools/validation.py +80 -0
  226. superqode/tools/web_tools.py +639 -0
  227. superqode/tui.py +1152 -0
  228. superqode/tui_integration.py +875 -0
  229. superqode/tui_widgets/__init__.py +27 -0
  230. superqode/tui_widgets/widgets/__init__.py +18 -0
  231. superqode/tui_widgets/widgets/progress.py +185 -0
  232. superqode/tui_widgets/widgets/tool_display.py +188 -0
  233. superqode/undo_manager.py +574 -0
  234. superqode/utils/__init__.py +5 -0
  235. superqode/utils/error_handling.py +323 -0
  236. superqode/utils/fuzzy.py +257 -0
  237. superqode/widgets/__init__.py +477 -0
  238. superqode/widgets/agent_collab.py +390 -0
  239. superqode/widgets/agent_store.py +936 -0
  240. superqode/widgets/agent_switcher.py +395 -0
  241. superqode/widgets/animation_manager.py +284 -0
  242. superqode/widgets/code_context.py +356 -0
  243. superqode/widgets/command_palette.py +412 -0
  244. superqode/widgets/connection_status.py +537 -0
  245. superqode/widgets/conversation_history.py +470 -0
  246. superqode/widgets/diff_indicator.py +155 -0
  247. superqode/widgets/enhanced_status_bar.py +385 -0
  248. superqode/widgets/enhanced_toast.py +476 -0
  249. superqode/widgets/file_browser.py +809 -0
  250. superqode/widgets/file_reference.py +585 -0
  251. superqode/widgets/issue_timeline.py +340 -0
  252. superqode/widgets/leader_key.py +264 -0
  253. superqode/widgets/mode_switcher.py +445 -0
  254. superqode/widgets/model_picker.py +234 -0
  255. superqode/widgets/permission_preview.py +1205 -0
  256. superqode/widgets/prompt.py +358 -0
  257. superqode/widgets/provider_connect.py +725 -0
  258. superqode/widgets/pty_shell.py +587 -0
  259. superqode/widgets/qe_dashboard.py +321 -0
  260. superqode/widgets/resizable_sidebar.py +377 -0
  261. superqode/widgets/response_changes.py +218 -0
  262. superqode/widgets/response_display.py +528 -0
  263. superqode/widgets/rich_tool_display.py +613 -0
  264. superqode/widgets/sidebar_panels.py +1180 -0
  265. superqode/widgets/slash_complete.py +356 -0
  266. superqode/widgets/split_view.py +612 -0
  267. superqode/widgets/status_bar.py +273 -0
  268. superqode/widgets/superqode_display.py +786 -0
  269. superqode/widgets/thinking_display.py +815 -0
  270. superqode/widgets/throbber.py +87 -0
  271. superqode/widgets/toast.py +206 -0
  272. superqode/widgets/unified_output.py +1073 -0
  273. superqode/workspace/__init__.py +75 -0
  274. superqode/workspace/artifacts.py +472 -0
  275. superqode/workspace/coordinator.py +353 -0
  276. superqode/workspace/diff_tracker.py +429 -0
  277. superqode/workspace/git_guard.py +373 -0
  278. superqode/workspace/git_snapshot.py +526 -0
  279. superqode/workspace/manager.py +750 -0
  280. superqode/workspace/snapshot.py +357 -0
  281. superqode/workspace/watcher.py +535 -0
  282. superqode/workspace/worktree.py +440 -0
  283. superqode-0.1.5.dist-info/METADATA +204 -0
  284. superqode-0.1.5.dist-info/RECORD +288 -0
  285. superqode-0.1.5.dist-info/WHEEL +5 -0
  286. superqode-0.1.5.dist-info/entry_points.txt +3 -0
  287. superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
  288. superqode-0.1.5.dist-info/top_level.txt +1 -0
@@ -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
+ }