helm-server 0.1.0__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.
- helm_server/__init__.py +3 -0
- helm_server/__main__.py +3 -0
- helm_server/acp.py +722 -0
- helm_server/address.py +59 -0
- helm_server/cli.py +214 -0
- helm_server/config.py +86 -0
- helm_server/console/404.html +8 -0
- helm_server/console/_next/static/chunks/201-ecef95b81469223d.js +1 -0
- helm_server/console/_next/static/chunks/293-ee592ff59e8ce2e6.js +1 -0
- helm_server/console/_next/static/chunks/363-70a53ad0283ce855.js +1 -0
- helm_server/console/_next/static/chunks/375-f62175e49eb5d9aa.js +1 -0
- helm_server/console/_next/static/chunks/43eb9ba4-7e1c5d21763d9eb8.js +1 -0
- helm_server/console/_next/static/chunks/532-00835d842d7c0210.js +1 -0
- helm_server/console/_next/static/chunks/69-04f480fc82519544.js +1 -0
- helm_server/console/_next/static/chunks/844-98a3ec041e7a2b76.js +1 -0
- helm_server/console/_next/static/chunks/979-2f5c557872f40a47.js +1 -0
- helm_server/console/_next/static/chunks/999-0b892a6060ef8678.js +1 -0
- helm_server/console/_next/static/chunks/app/(auth)/(routes)/sign-in/[[...sign-in]]/page-066d4a1245c3cc2e.js +1 -0
- helm_server/console/_next/static/chunks/app/(auth)/(routes)/sign-up/[[...sign-up]]/page-066d4a1245c3cc2e.js +1 -0
- helm_server/console/_next/static/chunks/app/(auth)/error-1ec1ad7a2df557bc.js +1 -0
- helm_server/console/_next/static/chunks/app/(auth)/layout-066d4a1245c3cc2e.js +1 -0
- helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/code/page-2914efccccf68c98.js +1 -0
- helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/console/page-6c8479514527e739.js +1 -0
- helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/conversation/page-066d4a1245c3cc2e.js +1 -0
- helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/dashboard/page-066d4a1245c3cc2e.js +1 -0
- helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/help/page-961567c37010d8ff.js +1 -0
- helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/image/page-0227eb0066cf598e.js +1 -0
- helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/settings/page-b9089adef92bf8eb.js +1 -0
- helm_server/console/_next/static/chunks/app/(dashboard)/error-2d6064d8b73a7b86.js +1 -0
- helm_server/console/_next/static/chunks/app/(dashboard)/layout-33c767365bd2906e.js +1 -0
- helm_server/console/_next/static/chunks/app/(landing)/error-0965ae952e9ae6cd.js +1 -0
- helm_server/console/_next/static/chunks/app/(landing)/layout-066d4a1245c3cc2e.js +1 -0
- helm_server/console/_next/static/chunks/app/(landing)/page-066d4a1245c3cc2e.js +1 -0
- helm_server/console/_next/static/chunks/app/(landing)/welcome/page-4e62bb12cb334014.js +1 -0
- helm_server/console/_next/static/chunks/app/_not-found/page-9b07e72d48d76ed2.js +1 -0
- helm_server/console/_next/static/chunks/app/layout-579b5da72eaf488a.js +1 -0
- helm_server/console/_next/static/chunks/framework-3d0dca36a4a33ef1.js +1 -0
- helm_server/console/_next/static/chunks/main-app-bbb707078d4589bd.js +1 -0
- helm_server/console/_next/static/chunks/main-f89f759535b7c220.js +1 -0
- helm_server/console/_next/static/chunks/pages/_app-0d261f2eefdc8a6e.js +1 -0
- helm_server/console/_next/static/chunks/pages/_error-572f2f4b0ed26a49.js +1 -0
- helm_server/console/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- helm_server/console/_next/static/chunks/webpack-f680f37e1455f20a.js +1 -0
- helm_server/console/_next/static/css/b4948be5aeab3586.css +3 -0
- helm_server/console/_next/static/vcbjpFWgBdBoqGwcUZPuZ/_buildManifest.js +1 -0
- helm_server/console/_next/static/vcbjpFWgBdBoqGwcUZPuZ/_ssgManifest.js +1 -0
- helm_server/console/apple-touch-icon.png +0 -0
- helm_server/console/code.html +8 -0
- helm_server/console/code.txt +30 -0
- helm_server/console/console.html +8 -0
- helm_server/console/console.txt +30 -0
- helm_server/console/conversation.html +1 -0
- helm_server/console/conversation.txt +27 -0
- helm_server/console/dashboard.html +1 -0
- helm_server/console/dashboard.txt +27 -0
- helm_server/console/favicon.ico +0 -0
- helm_server/console/help.html +8 -0
- helm_server/console/help.txt +27 -0
- helm_server/console/image.html +8 -0
- helm_server/console/image.txt +30 -0
- helm_server/console/index.html +1 -0
- helm_server/console/index.txt +25 -0
- helm_server/console/next.svg +1 -0
- helm_server/console/scribbl-192.png +0 -0
- helm_server/console/scribbl-512-maskable.png +0 -0
- helm_server/console/scribbl-512.png +0 -0
- helm_server/console/settings.html +8 -0
- helm_server/console/settings.txt +30 -0
- helm_server/console/sign-in.html +1 -0
- helm_server/console/sign-in.txt +25 -0
- helm_server/console/sign-up.html +1 -0
- helm_server/console/sign-up.txt +25 -0
- helm_server/console/welcome.html +8 -0
- helm_server/console/welcome.txt +26 -0
- helm_server/console.py +102 -0
- helm_server/doctor.py +123 -0
- helm_server/events.py +171 -0
- helm_server/fleet.py +101 -0
- helm_server/main.py +242 -0
- helm_server/pair.py +238 -0
- helm_server/paths.py +76 -0
- helm_server/push.py +277 -0
- helm_server/runners.py +99 -0
- helm_server/server.py +754 -0
- helm_server/service.py +230 -0
- helm_server/session.py +1487 -0
- helm_server/skill/pair-helm/SKILL.md +198 -0
- helm_server/skill_notice.py +120 -0
- helm_server/spaces.py +532 -0
- helm_server/ticket.py +96 -0
- helm_server/uploads.py +81 -0
- helm_server-0.1.0.dist-info/METADATA +200 -0
- helm_server-0.1.0.dist-info/RECORD +96 -0
- helm_server-0.1.0.dist-info/WHEEL +5 -0
- helm_server-0.1.0.dist-info/entry_points.txt +2 -0
- helm_server-0.1.0.dist-info/top_level.txt +1 -0
helm_server/acp.py
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
"""ACP (Agent Client Protocol) runner for long-lived cursor-agent sessions.
|
|
2
|
+
|
|
3
|
+
`agent acp` speaks JSON-RPC on stdio. That is the only path that can surface
|
|
4
|
+
permission prompts, AskQuestion, plan approval, and todos. The print runner
|
|
5
|
+
stays the default (`AGENT_SESSION_RUNNER=print`); set `acp` to use this.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
HOME = os.path.expanduser("~")
|
|
20
|
+
LineCallback = Callable[[str], None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _agent_bin() -> str:
|
|
24
|
+
from helm_server import runners
|
|
25
|
+
|
|
26
|
+
return runners.cursor_acp_cli()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AcpError(RuntimeError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
EXITED = "cursor-agent acp exited; run `cursor-agent update` and `cursor-agent status`"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_INSTRUCTIONS_MARK = "Repo instructions (loaded for this session):"
|
|
37
|
+
_INSTRUCTIONS_LIMIT = 60_000
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _split_frontmatter(text: str) -> tuple[dict, str]:
|
|
41
|
+
if not text.startswith("---\n"):
|
|
42
|
+
return {}, text
|
|
43
|
+
end = text.find("\n---\n", 4)
|
|
44
|
+
if end < 0:
|
|
45
|
+
return {}, text
|
|
46
|
+
header: dict[str, str] = {}
|
|
47
|
+
for line in text[4:end].splitlines():
|
|
48
|
+
if ":" not in line:
|
|
49
|
+
continue
|
|
50
|
+
key, value = line.split(":", 1)
|
|
51
|
+
header[key.strip().lower()] = value.strip()
|
|
52
|
+
return header, text[end + 5 :]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def always_apply_rules(cwd: str) -> list[tuple[str, str]]:
|
|
56
|
+
"""alwaysApply rule bodies from the repo, in filename order."""
|
|
57
|
+
rules_dir = os.path.join(cwd, ".cursor", "rules")
|
|
58
|
+
if not os.path.isdir(rules_dir):
|
|
59
|
+
return []
|
|
60
|
+
found: list[tuple[str, str]] = []
|
|
61
|
+
for name in sorted(os.listdir(rules_dir)):
|
|
62
|
+
if not name.endswith((".mdc", ".md")):
|
|
63
|
+
continue
|
|
64
|
+
path = os.path.join(rules_dir, name)
|
|
65
|
+
try:
|
|
66
|
+
with open(path, encoding="utf-8") as handle:
|
|
67
|
+
raw = handle.read()
|
|
68
|
+
except OSError:
|
|
69
|
+
continue
|
|
70
|
+
header, body = _split_frontmatter(raw)
|
|
71
|
+
if header.get("alwaysapply", "").lower() != "true":
|
|
72
|
+
continue
|
|
73
|
+
body = body.strip()
|
|
74
|
+
if body:
|
|
75
|
+
found.append((name, body))
|
|
76
|
+
return found
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def project_instructions(cwd: str, limit: int = _INSTRUCTIONS_LIMIT) -> str:
|
|
80
|
+
"""AGENTS.md plus always-on rules. `cursor-agent -p` loads these; ACP does not."""
|
|
81
|
+
chunks: list[str] = []
|
|
82
|
+
for name, body in always_apply_rules(cwd):
|
|
83
|
+
chunks.append("## .cursor/rules/%s\n%s" % (name, body))
|
|
84
|
+
agents = os.path.join(cwd, "AGENTS.md")
|
|
85
|
+
if os.path.isfile(agents):
|
|
86
|
+
try:
|
|
87
|
+
with open(agents, encoding="utf-8") as handle:
|
|
88
|
+
text = handle.read().strip()
|
|
89
|
+
except OSError:
|
|
90
|
+
text = ""
|
|
91
|
+
if text:
|
|
92
|
+
chunks.append("## AGENTS.md\n" + text)
|
|
93
|
+
blob = "\n\n".join(chunks).strip()
|
|
94
|
+
if len(blob) > limit:
|
|
95
|
+
blob = blob[:limit].rstrip() + "\n\n[truncated]"
|
|
96
|
+
return blob
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def with_project_instructions(cwd: str, prompt: str, *, first_turn: bool = True) -> str:
|
|
100
|
+
"""Attach repo rules on the first ACP turn only.
|
|
101
|
+
|
|
102
|
+
Re-wrapping every follow-up as "loaded for this session" / "User request:"
|
|
103
|
+
made short answers like "B" look like a brand-new chat with no prior A/B.
|
|
104
|
+
"""
|
|
105
|
+
prompt = prompt or ""
|
|
106
|
+
if not first_turn:
|
|
107
|
+
return prompt
|
|
108
|
+
if prompt.startswith(_INSTRUCTIONS_MARK):
|
|
109
|
+
return prompt
|
|
110
|
+
body = project_instructions(cwd)
|
|
111
|
+
if not body:
|
|
112
|
+
return prompt
|
|
113
|
+
return _INSTRUCTIONS_MARK + "\n\n" + body + "\n\nUser request:\n" + prompt
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_IMAGE_MIME = {
|
|
117
|
+
".jpg": "image/jpeg",
|
|
118
|
+
".jpeg": "image/jpeg",
|
|
119
|
+
".png": "image/png",
|
|
120
|
+
".webp": "image/webp",
|
|
121
|
+
".gif": "image/gif",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def image_mime(path: str) -> str:
|
|
126
|
+
ext = os.path.splitext(path or "")[1].lower()
|
|
127
|
+
return _IMAGE_MIME.get(ext, "image/png")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def image_prompt_block(path: str) -> Optional[dict]:
|
|
131
|
+
"""ACP `session/prompt` image block. file:// resource URIs are rejected."""
|
|
132
|
+
if not path or not os.path.isfile(path):
|
|
133
|
+
return None
|
|
134
|
+
with open(path, "rb") as handle:
|
|
135
|
+
raw = handle.read()
|
|
136
|
+
if not raw:
|
|
137
|
+
return None
|
|
138
|
+
return {
|
|
139
|
+
"type": "image",
|
|
140
|
+
"mimeType": image_mime(path),
|
|
141
|
+
"data": base64.b64encode(raw).decode("ascii"),
|
|
142
|
+
"uri": "file://%s" % os.path.abspath(path),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def prompt_blocks(prompt: str, images: Optional[list] = None) -> list:
|
|
147
|
+
blocks = [{"type": "text", "text": prompt}]
|
|
148
|
+
for path in images or []:
|
|
149
|
+
block = image_prompt_block(path)
|
|
150
|
+
if block:
|
|
151
|
+
blocks.append(block)
|
|
152
|
+
return blocks
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _model_choice_id(item) -> str:
|
|
156
|
+
if isinstance(item, str):
|
|
157
|
+
return item.strip()
|
|
158
|
+
if isinstance(item, dict):
|
|
159
|
+
return str(item.get("modelId") or item.get("id") or "").strip()
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _model_choice_name(item) -> str:
|
|
164
|
+
if isinstance(item, str):
|
|
165
|
+
return item.strip()
|
|
166
|
+
if isinstance(item, dict):
|
|
167
|
+
return str(item.get("name") or item.get("label") or "").strip()
|
|
168
|
+
return ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _compact_model_token(value: str) -> str:
|
|
172
|
+
return "".join(ch for ch in (value or "").lower() if ch.isalnum())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def resolve_model_id(requested: str, available) -> str:
|
|
176
|
+
"""Map a CLI `--list-models` id onto an ACP `modelId` when the labels differ."""
|
|
177
|
+
needle = (requested or "").strip()
|
|
178
|
+
if not needle:
|
|
179
|
+
return ""
|
|
180
|
+
pairs = []
|
|
181
|
+
for item in available or []:
|
|
182
|
+
mid = _model_choice_id(item)
|
|
183
|
+
name = _model_choice_name(item)
|
|
184
|
+
if mid or name:
|
|
185
|
+
pairs.append((mid, name))
|
|
186
|
+
for mid, name in pairs:
|
|
187
|
+
if mid == needle or name == needle:
|
|
188
|
+
return mid or needle
|
|
189
|
+
lower = needle.lower()
|
|
190
|
+
exact = [mid or name for mid, name in pairs if mid.lower() == lower or name.lower() == lower]
|
|
191
|
+
if len(exact) == 1:
|
|
192
|
+
return exact[0]
|
|
193
|
+
compact = _compact_model_token(needle)
|
|
194
|
+
fuzzy = [
|
|
195
|
+
mid or name
|
|
196
|
+
for mid, name in pairs
|
|
197
|
+
if compact
|
|
198
|
+
and compact
|
|
199
|
+
in (_compact_model_token(mid) + _compact_model_token(name))
|
|
200
|
+
]
|
|
201
|
+
if len(fuzzy) == 1:
|
|
202
|
+
return fuzzy[0]
|
|
203
|
+
return needle
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class AcpClient:
|
|
207
|
+
"""One `agent acp` process. Tests inject a transport instead of spawning."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, cwd: str, env: Optional[dict] = None, transport=None):
|
|
210
|
+
self.cwd = cwd
|
|
211
|
+
self.env = env or os.environ.copy()
|
|
212
|
+
self._id = 0
|
|
213
|
+
self._pending: dict[int, queue.Queue] = {}
|
|
214
|
+
self._lock = threading.Lock()
|
|
215
|
+
self._inbox: queue.Queue = queue.Queue()
|
|
216
|
+
self._closed = threading.Event()
|
|
217
|
+
self.session_id = ""
|
|
218
|
+
self.models: list[dict] = []
|
|
219
|
+
self.modes: list[dict] = []
|
|
220
|
+
self.current_model = ""
|
|
221
|
+
self.current_mode = "agent"
|
|
222
|
+
# Rules go out once per live ACP process; follow-ups keep the bare prompt.
|
|
223
|
+
self.instructions_sent = False
|
|
224
|
+
if transport is not None:
|
|
225
|
+
self.proc = transport
|
|
226
|
+
self._stdin = transport.stdin
|
|
227
|
+
self._stdout = transport.stdout
|
|
228
|
+
else:
|
|
229
|
+
self.proc = subprocess.Popen(
|
|
230
|
+
[_agent_bin(), "acp"],
|
|
231
|
+
cwd=cwd,
|
|
232
|
+
stdin=subprocess.PIPE,
|
|
233
|
+
stdout=subprocess.PIPE,
|
|
234
|
+
stderr=subprocess.DEVNULL,
|
|
235
|
+
text=True,
|
|
236
|
+
bufsize=1,
|
|
237
|
+
env=self.env,
|
|
238
|
+
)
|
|
239
|
+
self._stdin = self.proc.stdin
|
|
240
|
+
self._stdout = self.proc.stdout
|
|
241
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True, name="acp-read")
|
|
242
|
+
self._reader.start()
|
|
243
|
+
|
|
244
|
+
def close(self) -> None:
|
|
245
|
+
self._closed.set()
|
|
246
|
+
try:
|
|
247
|
+
if self._stdin:
|
|
248
|
+
self._stdin.close()
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
try:
|
|
252
|
+
if getattr(self.proc, "poll", None) is None or self.proc.poll() is None:
|
|
253
|
+
self.proc.terminate()
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
def _read_loop(self) -> None:
|
|
258
|
+
stream = self._stdout
|
|
259
|
+
if stream is None:
|
|
260
|
+
return
|
|
261
|
+
try:
|
|
262
|
+
while not self._closed.is_set():
|
|
263
|
+
line = stream.readline()
|
|
264
|
+
if not line:
|
|
265
|
+
break
|
|
266
|
+
try:
|
|
267
|
+
msg = json.loads(line)
|
|
268
|
+
except json.JSONDecodeError:
|
|
269
|
+
continue
|
|
270
|
+
if not isinstance(msg, dict):
|
|
271
|
+
continue
|
|
272
|
+
mid = msg.get("id")
|
|
273
|
+
if mid is not None and ("result" in msg or "error" in msg):
|
|
274
|
+
waiter = self._pending.get(int(mid))
|
|
275
|
+
if waiter is not None:
|
|
276
|
+
waiter.put(msg)
|
|
277
|
+
continue
|
|
278
|
+
self._inbox.put(msg)
|
|
279
|
+
finally:
|
|
280
|
+
self._closed.set()
|
|
281
|
+
with self._lock:
|
|
282
|
+
waiters = list(self._pending.values())
|
|
283
|
+
for waiter in waiters:
|
|
284
|
+
waiter.put({"error": {"message": EXITED}})
|
|
285
|
+
|
|
286
|
+
def request(self, method: str, params=None, timeout: float = 60) -> dict:
|
|
287
|
+
with self._lock:
|
|
288
|
+
if self._closed.is_set():
|
|
289
|
+
raise AcpError(EXITED)
|
|
290
|
+
self._id += 1
|
|
291
|
+
rid = self._id
|
|
292
|
+
waiter: queue.Queue = queue.Queue()
|
|
293
|
+
self._pending[rid] = waiter
|
|
294
|
+
payload = {"jsonrpc": "2.0", "id": rid, "method": method}
|
|
295
|
+
if params is not None:
|
|
296
|
+
payload["params"] = params
|
|
297
|
+
try:
|
|
298
|
+
if self._stdin is None:
|
|
299
|
+
raise AcpError("ACP stdin closed")
|
|
300
|
+
self._stdin.write(json.dumps(payload) + "\n")
|
|
301
|
+
self._stdin.flush()
|
|
302
|
+
except (OSError, ValueError) as exc:
|
|
303
|
+
self._pending.pop(rid, None)
|
|
304
|
+
raise AcpError(EXITED) from exc
|
|
305
|
+
try:
|
|
306
|
+
msg = waiter.get(timeout=timeout)
|
|
307
|
+
except queue.Empty:
|
|
308
|
+
raise AcpError("ACP timed out on %s" % method)
|
|
309
|
+
finally:
|
|
310
|
+
self._pending.pop(rid, None)
|
|
311
|
+
if msg.get("error"):
|
|
312
|
+
err = msg["error"]
|
|
313
|
+
raise AcpError(str(err.get("message") or err))
|
|
314
|
+
result = msg.get("result")
|
|
315
|
+
return result if isinstance(result, dict) else {}
|
|
316
|
+
|
|
317
|
+
def respond(self, req_id, result) -> None:
|
|
318
|
+
if self._stdin is None:
|
|
319
|
+
return
|
|
320
|
+
self._stdin.write(json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result}) + "\n")
|
|
321
|
+
self._stdin.flush()
|
|
322
|
+
|
|
323
|
+
def poll_notice(self, timeout: float = 0.05) -> Optional[dict]:
|
|
324
|
+
try:
|
|
325
|
+
return self._inbox.get(timeout=max(0.0, timeout))
|
|
326
|
+
except queue.Empty:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
def initialize(self) -> dict:
|
|
330
|
+
return self.request(
|
|
331
|
+
"initialize",
|
|
332
|
+
{
|
|
333
|
+
"protocolVersion": 1,
|
|
334
|
+
"clientCapabilities": {
|
|
335
|
+
"fs": {"readTextFile": False, "writeTextFile": False},
|
|
336
|
+
"terminal": False,
|
|
337
|
+
},
|
|
338
|
+
"clientInfo": {"name": "agent-session", "version": "1"},
|
|
339
|
+
},
|
|
340
|
+
timeout=30,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def authenticate(self) -> dict:
|
|
344
|
+
return self.request("authenticate", {"methodId": "cursor_login"}, timeout=30)
|
|
345
|
+
|
|
346
|
+
def session_new(self, cwd: str, mcp_servers=None) -> dict:
|
|
347
|
+
result = self.request(
|
|
348
|
+
"session/new",
|
|
349
|
+
{"cwd": cwd, "mcpServers": mcp_servers or []},
|
|
350
|
+
timeout=30,
|
|
351
|
+
)
|
|
352
|
+
self._remember_session(result)
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
def session_load(self, session_id: str, cwd: str = "", mcp_servers=None) -> dict:
|
|
356
|
+
# cursor-agent answers "Internal error" when cwd or mcpServers is missing.
|
|
357
|
+
result = self.request(
|
|
358
|
+
"session/load",
|
|
359
|
+
{"sessionId": session_id, "cwd": cwd or self.cwd, "mcpServers": mcp_servers or []},
|
|
360
|
+
timeout=30,
|
|
361
|
+
)
|
|
362
|
+
self._remember_session(result, fallback=session_id)
|
|
363
|
+
return result
|
|
364
|
+
|
|
365
|
+
def session_prompt(self, prompt: str, images: Optional[list] = None, timeout: float = 1800) -> dict:
|
|
366
|
+
return self.request(
|
|
367
|
+
"session/prompt",
|
|
368
|
+
{"sessionId": self.session_id, "prompt": prompt_blocks(prompt, images)},
|
|
369
|
+
timeout=timeout,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def session_cancel(self) -> None:
|
|
373
|
+
if not self.session_id or self._stdin is None:
|
|
374
|
+
return
|
|
375
|
+
self._stdin.write(
|
|
376
|
+
json.dumps(
|
|
377
|
+
{
|
|
378
|
+
"jsonrpc": "2.0",
|
|
379
|
+
"method": "session/cancel",
|
|
380
|
+
"params": {"sessionId": self.session_id},
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
+ "\n"
|
|
384
|
+
)
|
|
385
|
+
self._stdin.flush()
|
|
386
|
+
|
|
387
|
+
def set_mode(self, mode: str) -> None:
|
|
388
|
+
if mode not in ("agent", "plan", "ask") or not self.session_id:
|
|
389
|
+
return
|
|
390
|
+
try:
|
|
391
|
+
self.request("session/set_mode", {"sessionId": self.session_id, "modeId": mode}, timeout=15)
|
|
392
|
+
self.current_mode = mode
|
|
393
|
+
except AcpError:
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
def set_model(self, model: str) -> None:
|
|
397
|
+
if not model or not self.session_id:
|
|
398
|
+
return
|
|
399
|
+
candidates = []
|
|
400
|
+
resolved = resolve_model_id(model, self.models)
|
|
401
|
+
for candidate in (resolved, model):
|
|
402
|
+
if candidate and candidate not in candidates:
|
|
403
|
+
candidates.append(candidate)
|
|
404
|
+
for candidate in candidates:
|
|
405
|
+
try:
|
|
406
|
+
self.request(
|
|
407
|
+
"session/set_model",
|
|
408
|
+
{"sessionId": self.session_id, "modelId": candidate},
|
|
409
|
+
timeout=15,
|
|
410
|
+
)
|
|
411
|
+
self.current_model = candidate
|
|
412
|
+
return
|
|
413
|
+
except AcpError:
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
def _remember_session(self, result: dict, fallback: str = "") -> None:
|
|
417
|
+
self.session_id = str(result.get("sessionId") or fallback)
|
|
418
|
+
modes = result.get("modes") if isinstance(result.get("modes"), dict) else {}
|
|
419
|
+
self.current_mode = str(modes.get("currentModeId") or self.current_mode or "agent")
|
|
420
|
+
self.modes = list(modes.get("availableModes") or [])
|
|
421
|
+
models = result.get("models") if isinstance(result.get("models"), dict) else {}
|
|
422
|
+
self.current_model = str(models.get("currentModelId") or self.current_model)
|
|
423
|
+
self.models = list(models.get("availableModels") or [])
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _emit(on_line: LineCallback, payload: dict) -> None:
|
|
427
|
+
on_line(json.dumps(payload))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def acp_notice_to_lines(msg: dict) -> list[dict]:
|
|
431
|
+
"""Map an ACP notification to stream-json-shaped objects."""
|
|
432
|
+
method = msg.get("method")
|
|
433
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
434
|
+
if method == "session/update":
|
|
435
|
+
update = params.get("update") if isinstance(params.get("update"), dict) else params
|
|
436
|
+
kind = update.get("sessionUpdate") or update.get("session_update")
|
|
437
|
+
content = update.get("content") if isinstance(update.get("content"), dict) else {}
|
|
438
|
+
text = content.get("text") if isinstance(content.get("text"), str) else update.get("text")
|
|
439
|
+
if kind in ("agent_message_chunk", "agent_message") and text:
|
|
440
|
+
return [
|
|
441
|
+
{
|
|
442
|
+
"type": "assistant",
|
|
443
|
+
"timestamp_ms": int(time.time() * 1000),
|
|
444
|
+
"message": {"role": "assistant", "content": [{"type": "text", "text": text}]},
|
|
445
|
+
}
|
|
446
|
+
]
|
|
447
|
+
if kind in ("agent_thought_chunk", "agent_thought") and text:
|
|
448
|
+
return [{"type": "thinking", "subtype": "delta", "text": text}]
|
|
449
|
+
if kind in ("tool_call", "tool_call_update"):
|
|
450
|
+
status = "completed" if kind == "tool_call_update" and update.get("status") in (
|
|
451
|
+
"completed",
|
|
452
|
+
"failed",
|
|
453
|
+
) else "started"
|
|
454
|
+
title = str(update.get("title") or update.get("kind") or "tool")
|
|
455
|
+
call_id = str(update.get("toolCallId") or update.get("tool_call_id") or "")
|
|
456
|
+
return [
|
|
457
|
+
{
|
|
458
|
+
"type": "tool_call",
|
|
459
|
+
"subtype": status,
|
|
460
|
+
"call_id": call_id,
|
|
461
|
+
"tool_call": {"function": {"name": title, "arguments": json.dumps(update.get("rawInput") or {})}},
|
|
462
|
+
}
|
|
463
|
+
]
|
|
464
|
+
if kind == "plan":
|
|
465
|
+
return [{"type": "thinking", "subtype": "delta", "text": str(update.get("text") or update.get("plan") or "")}]
|
|
466
|
+
return []
|
|
467
|
+
if method == "cursor/update_todos":
|
|
468
|
+
return [{"type": "thinking", "subtype": "delta", "text": ""}]
|
|
469
|
+
return []
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def pending_from_notice(msg: dict) -> Optional[dict]:
|
|
473
|
+
method = msg.get("method")
|
|
474
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
475
|
+
req_id = msg.get("id")
|
|
476
|
+
if method == "session/request_permission":
|
|
477
|
+
options = params.get("options") or [
|
|
478
|
+
{"optionId": "allow-once", "name": "Allow once"},
|
|
479
|
+
{"optionId": "allow-always", "name": "Always allow"},
|
|
480
|
+
{"optionId": "reject-once", "name": "Reject"},
|
|
481
|
+
]
|
|
482
|
+
return {
|
|
483
|
+
"kind": "permission",
|
|
484
|
+
"rpcId": req_id,
|
|
485
|
+
"title": str(params.get("title") or "Permission"),
|
|
486
|
+
"detail": str(params.get("toolCall") or params.get("description") or ""),
|
|
487
|
+
"options": options,
|
|
488
|
+
}
|
|
489
|
+
if method == "cursor/ask_question":
|
|
490
|
+
return {
|
|
491
|
+
"kind": "question",
|
|
492
|
+
"rpcId": req_id,
|
|
493
|
+
"title": str(params.get("title") or "Question"),
|
|
494
|
+
"questions": params.get("questions") or [],
|
|
495
|
+
}
|
|
496
|
+
if method == "cursor/create_plan":
|
|
497
|
+
return {
|
|
498
|
+
"kind": "plan",
|
|
499
|
+
"rpcId": req_id,
|
|
500
|
+
"title": str(params.get("name") or "Plan"),
|
|
501
|
+
"overview": str(params.get("overview") or ""),
|
|
502
|
+
"plan": str(params.get("plan") or ""),
|
|
503
|
+
"todos": params.get("todos") or [],
|
|
504
|
+
}
|
|
505
|
+
if method == "cursor/update_todos":
|
|
506
|
+
return {
|
|
507
|
+
"kind": "todos",
|
|
508
|
+
"rpcId": None,
|
|
509
|
+
"todos": params.get("todos") or [],
|
|
510
|
+
"merge": bool(params.get("merge")),
|
|
511
|
+
}
|
|
512
|
+
if method == "cursor/task":
|
|
513
|
+
return {
|
|
514
|
+
"kind": "task",
|
|
515
|
+
"rpcId": None,
|
|
516
|
+
"description": str(params.get("description") or ""),
|
|
517
|
+
"subagentType": params.get("subagentType"),
|
|
518
|
+
}
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def auto_permission_result(policy: str) -> Optional[dict]:
|
|
523
|
+
if policy in ("yolo", "auto"):
|
|
524
|
+
return {"outcome": {"outcome": "selected", "optionId": "allow-always"}}
|
|
525
|
+
if policy == "auto-review":
|
|
526
|
+
return {"outcome": {"outcome": "selected", "optionId": "allow-once"}}
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class AcpRunner:
|
|
531
|
+
"""CursorRunner-compatible wrapper around one ACP process per chat."""
|
|
532
|
+
|
|
533
|
+
def __init__(self):
|
|
534
|
+
self._clients: dict[str, AcpClient] = {}
|
|
535
|
+
self._lock = threading.Lock()
|
|
536
|
+
self.on_pending: Optional[Callable[[dict], None]] = None
|
|
537
|
+
self.answer: Optional[Callable[[str], Optional[dict]]] = None
|
|
538
|
+
self.policy = "yolo"
|
|
539
|
+
|
|
540
|
+
def create_chat(self, cwd: str) -> Optional[str]:
|
|
541
|
+
client = AcpClient(cwd)
|
|
542
|
+
try:
|
|
543
|
+
client.initialize()
|
|
544
|
+
client.authenticate()
|
|
545
|
+
client.session_new(cwd)
|
|
546
|
+
except Exception:
|
|
547
|
+
client.close()
|
|
548
|
+
return None
|
|
549
|
+
if not client.session_id:
|
|
550
|
+
client.close()
|
|
551
|
+
return None
|
|
552
|
+
with self._lock:
|
|
553
|
+
self._clients[client.session_id] = client
|
|
554
|
+
return client.session_id
|
|
555
|
+
|
|
556
|
+
def persist_list(self) -> str:
|
|
557
|
+
try:
|
|
558
|
+
proc = subprocess.run(
|
|
559
|
+
[_agent_bin(), "persist", "list"],
|
|
560
|
+
capture_output=True,
|
|
561
|
+
text=True,
|
|
562
|
+
timeout=30,
|
|
563
|
+
)
|
|
564
|
+
except Exception as exc:
|
|
565
|
+
return str(exc)
|
|
566
|
+
return ((proc.stdout or "") + (proc.stderr or "")).strip()
|
|
567
|
+
|
|
568
|
+
def list_models(self) -> str:
|
|
569
|
+
try:
|
|
570
|
+
proc = subprocess.run(
|
|
571
|
+
[_agent_bin(), "--list-models"],
|
|
572
|
+
capture_output=True,
|
|
573
|
+
text=True,
|
|
574
|
+
timeout=30,
|
|
575
|
+
)
|
|
576
|
+
except Exception as exc:
|
|
577
|
+
return str(exc)
|
|
578
|
+
return ((proc.stdout or "") + (proc.stderr or "")).strip()
|
|
579
|
+
|
|
580
|
+
def _client(self, cwd: str, resume_id: Optional[str], env: Optional[dict]) -> AcpClient:
|
|
581
|
+
if resume_id:
|
|
582
|
+
with self._lock:
|
|
583
|
+
existing = self._clients.get(resume_id)
|
|
584
|
+
if existing and not existing._closed.is_set():
|
|
585
|
+
return existing
|
|
586
|
+
client = AcpClient(cwd, env=env)
|
|
587
|
+
client.initialize()
|
|
588
|
+
client.authenticate()
|
|
589
|
+
if resume_id:
|
|
590
|
+
try:
|
|
591
|
+
client.session_load(resume_id, cwd)
|
|
592
|
+
except AcpError:
|
|
593
|
+
client.session_new(cwd)
|
|
594
|
+
else:
|
|
595
|
+
client.session_new(cwd)
|
|
596
|
+
with self._lock:
|
|
597
|
+
if client.session_id:
|
|
598
|
+
self._clients[client.session_id] = client
|
|
599
|
+
if resume_id and resume_id != client.session_id:
|
|
600
|
+
self._clients[resume_id] = client
|
|
601
|
+
return client
|
|
602
|
+
|
|
603
|
+
def run(
|
|
604
|
+
self,
|
|
605
|
+
cwd: str,
|
|
606
|
+
prompt: str,
|
|
607
|
+
resume_id: Optional[str],
|
|
608
|
+
mode: Optional[str],
|
|
609
|
+
on_line: LineCallback,
|
|
610
|
+
env: Optional[dict] = None,
|
|
611
|
+
timeout: int = 1800,
|
|
612
|
+
model: Optional[str] = None,
|
|
613
|
+
cancel: Optional[threading.Event] = None,
|
|
614
|
+
images: Optional[list] = None,
|
|
615
|
+
) -> int:
|
|
616
|
+
try:
|
|
617
|
+
client = self._client(cwd, resume_id, env)
|
|
618
|
+
except Exception as exc:
|
|
619
|
+
_emit(on_line, {"type": "text", "text": "\n[acp start failed: %s]\n" % exc})
|
|
620
|
+
return 1
|
|
621
|
+
if mode:
|
|
622
|
+
client.set_mode(mode)
|
|
623
|
+
if model:
|
|
624
|
+
client.set_model(model)
|
|
625
|
+
if client.session_id:
|
|
626
|
+
_emit(
|
|
627
|
+
on_line,
|
|
628
|
+
{
|
|
629
|
+
"type": "system",
|
|
630
|
+
"subtype": "init",
|
|
631
|
+
"session_id": client.session_id,
|
|
632
|
+
"model": client.current_model or model or "",
|
|
633
|
+
"cwd": cwd,
|
|
634
|
+
"permissionMode": self.policy,
|
|
635
|
+
},
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
done = threading.Event()
|
|
639
|
+
outcome: dict = {"rc": 1, "final": []}
|
|
640
|
+
|
|
641
|
+
def prompt_thread():
|
|
642
|
+
try:
|
|
643
|
+
first_turn = not client.instructions_sent
|
|
644
|
+
instructed = with_project_instructions(cwd, prompt, first_turn=first_turn)
|
|
645
|
+
client.instructions_sent = True
|
|
646
|
+
result = client.session_prompt(instructed, images=images, timeout=timeout)
|
|
647
|
+
outcome["rc"] = 0 if result.get("stopReason") != "cancelled" else 130
|
|
648
|
+
text = result.get("result") or result.get("text")
|
|
649
|
+
if isinstance(text, str) and text:
|
|
650
|
+
outcome["final"].append(
|
|
651
|
+
{
|
|
652
|
+
"type": "result",
|
|
653
|
+
"subtype": "success",
|
|
654
|
+
"is_error": False,
|
|
655
|
+
"result": text,
|
|
656
|
+
"session_id": client.session_id,
|
|
657
|
+
}
|
|
658
|
+
)
|
|
659
|
+
except AcpError as exc:
|
|
660
|
+
outcome["final"].append({"type": "text", "text": "\n[%s]\n" % exc})
|
|
661
|
+
outcome["rc"] = 1
|
|
662
|
+
finally:
|
|
663
|
+
done.set()
|
|
664
|
+
|
|
665
|
+
thread = threading.Thread(target=prompt_thread, daemon=True, name="acp-prompt")
|
|
666
|
+
thread.start()
|
|
667
|
+
deadline = time.time() + timeout
|
|
668
|
+
while not done.is_set():
|
|
669
|
+
if cancel is not None and cancel.is_set():
|
|
670
|
+
client.session_cancel()
|
|
671
|
+
done.wait(5)
|
|
672
|
+
return 130
|
|
673
|
+
if time.time() >= deadline:
|
|
674
|
+
client.session_cancel()
|
|
675
|
+
_emit(on_line, {"type": "text", "text": "\n[timed out]\n"})
|
|
676
|
+
return 124
|
|
677
|
+
notice = client.poll_notice(0.1)
|
|
678
|
+
if not notice:
|
|
679
|
+
continue
|
|
680
|
+
pending = pending_from_notice(notice)
|
|
681
|
+
if pending and pending.get("kind") == "permission":
|
|
682
|
+
auto = auto_permission_result(self.policy)
|
|
683
|
+
if auto and pending.get("rpcId") is not None:
|
|
684
|
+
client.respond(pending["rpcId"], auto)
|
|
685
|
+
pending = None
|
|
686
|
+
if pending and pending.get("kind") in ("permission", "question", "plan"):
|
|
687
|
+
if self.on_pending:
|
|
688
|
+
self.on_pending(pending)
|
|
689
|
+
answered = None
|
|
690
|
+
wait_until = time.time() + 300
|
|
691
|
+
while time.time() < wait_until and not done.is_set():
|
|
692
|
+
if cancel is not None and cancel.is_set():
|
|
693
|
+
if pending.get("rpcId") is not None:
|
|
694
|
+
client.respond(pending["rpcId"], {"outcome": {"outcome": "cancelled"}})
|
|
695
|
+
client.session_cancel()
|
|
696
|
+
return 130
|
|
697
|
+
if self.answer:
|
|
698
|
+
answered = self.answer(str(pending.get("rpcId")))
|
|
699
|
+
if answered is not None:
|
|
700
|
+
break
|
|
701
|
+
time.sleep(0.1)
|
|
702
|
+
if pending.get("rpcId") is not None:
|
|
703
|
+
client.respond(pending["rpcId"], answered or {"outcome": {"outcome": "cancelled"}})
|
|
704
|
+
continue
|
|
705
|
+
self._emit_notice(notice, pending, on_line)
|
|
706
|
+
# The CLI writes its last updates just before the prompt result, so
|
|
707
|
+
# they can still be queued when the result ends the loop above.
|
|
708
|
+
while True:
|
|
709
|
+
notice = client.poll_notice(0)
|
|
710
|
+
if not notice:
|
|
711
|
+
break
|
|
712
|
+
self._emit_notice(notice, pending_from_notice(notice), on_line)
|
|
713
|
+
for payload in outcome["final"]:
|
|
714
|
+
_emit(on_line, payload)
|
|
715
|
+
thread.join(2)
|
|
716
|
+
return int(outcome["rc"])
|
|
717
|
+
|
|
718
|
+
def _emit_notice(self, notice: dict, pending: Optional[dict], on_line: LineCallback) -> None:
|
|
719
|
+
if pending and pending.get("kind") in ("todos", "task") and self.on_pending:
|
|
720
|
+
self.on_pending(pending)
|
|
721
|
+
for payload in acp_notice_to_lines(notice):
|
|
722
|
+
_emit(on_line, payload)
|