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.
Files changed (96) hide show
  1. helm_server/__init__.py +3 -0
  2. helm_server/__main__.py +3 -0
  3. helm_server/acp.py +722 -0
  4. helm_server/address.py +59 -0
  5. helm_server/cli.py +214 -0
  6. helm_server/config.py +86 -0
  7. helm_server/console/404.html +8 -0
  8. helm_server/console/_next/static/chunks/201-ecef95b81469223d.js +1 -0
  9. helm_server/console/_next/static/chunks/293-ee592ff59e8ce2e6.js +1 -0
  10. helm_server/console/_next/static/chunks/363-70a53ad0283ce855.js +1 -0
  11. helm_server/console/_next/static/chunks/375-f62175e49eb5d9aa.js +1 -0
  12. helm_server/console/_next/static/chunks/43eb9ba4-7e1c5d21763d9eb8.js +1 -0
  13. helm_server/console/_next/static/chunks/532-00835d842d7c0210.js +1 -0
  14. helm_server/console/_next/static/chunks/69-04f480fc82519544.js +1 -0
  15. helm_server/console/_next/static/chunks/844-98a3ec041e7a2b76.js +1 -0
  16. helm_server/console/_next/static/chunks/979-2f5c557872f40a47.js +1 -0
  17. helm_server/console/_next/static/chunks/999-0b892a6060ef8678.js +1 -0
  18. helm_server/console/_next/static/chunks/app/(auth)/(routes)/sign-in/[[...sign-in]]/page-066d4a1245c3cc2e.js +1 -0
  19. helm_server/console/_next/static/chunks/app/(auth)/(routes)/sign-up/[[...sign-up]]/page-066d4a1245c3cc2e.js +1 -0
  20. helm_server/console/_next/static/chunks/app/(auth)/error-1ec1ad7a2df557bc.js +1 -0
  21. helm_server/console/_next/static/chunks/app/(auth)/layout-066d4a1245c3cc2e.js +1 -0
  22. helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/code/page-2914efccccf68c98.js +1 -0
  23. helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/console/page-6c8479514527e739.js +1 -0
  24. helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/conversation/page-066d4a1245c3cc2e.js +1 -0
  25. helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/dashboard/page-066d4a1245c3cc2e.js +1 -0
  26. helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/help/page-961567c37010d8ff.js +1 -0
  27. helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/image/page-0227eb0066cf598e.js +1 -0
  28. helm_server/console/_next/static/chunks/app/(dashboard)/(routes)/settings/page-b9089adef92bf8eb.js +1 -0
  29. helm_server/console/_next/static/chunks/app/(dashboard)/error-2d6064d8b73a7b86.js +1 -0
  30. helm_server/console/_next/static/chunks/app/(dashboard)/layout-33c767365bd2906e.js +1 -0
  31. helm_server/console/_next/static/chunks/app/(landing)/error-0965ae952e9ae6cd.js +1 -0
  32. helm_server/console/_next/static/chunks/app/(landing)/layout-066d4a1245c3cc2e.js +1 -0
  33. helm_server/console/_next/static/chunks/app/(landing)/page-066d4a1245c3cc2e.js +1 -0
  34. helm_server/console/_next/static/chunks/app/(landing)/welcome/page-4e62bb12cb334014.js +1 -0
  35. helm_server/console/_next/static/chunks/app/_not-found/page-9b07e72d48d76ed2.js +1 -0
  36. helm_server/console/_next/static/chunks/app/layout-579b5da72eaf488a.js +1 -0
  37. helm_server/console/_next/static/chunks/framework-3d0dca36a4a33ef1.js +1 -0
  38. helm_server/console/_next/static/chunks/main-app-bbb707078d4589bd.js +1 -0
  39. helm_server/console/_next/static/chunks/main-f89f759535b7c220.js +1 -0
  40. helm_server/console/_next/static/chunks/pages/_app-0d261f2eefdc8a6e.js +1 -0
  41. helm_server/console/_next/static/chunks/pages/_error-572f2f4b0ed26a49.js +1 -0
  42. helm_server/console/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  43. helm_server/console/_next/static/chunks/webpack-f680f37e1455f20a.js +1 -0
  44. helm_server/console/_next/static/css/b4948be5aeab3586.css +3 -0
  45. helm_server/console/_next/static/vcbjpFWgBdBoqGwcUZPuZ/_buildManifest.js +1 -0
  46. helm_server/console/_next/static/vcbjpFWgBdBoqGwcUZPuZ/_ssgManifest.js +1 -0
  47. helm_server/console/apple-touch-icon.png +0 -0
  48. helm_server/console/code.html +8 -0
  49. helm_server/console/code.txt +30 -0
  50. helm_server/console/console.html +8 -0
  51. helm_server/console/console.txt +30 -0
  52. helm_server/console/conversation.html +1 -0
  53. helm_server/console/conversation.txt +27 -0
  54. helm_server/console/dashboard.html +1 -0
  55. helm_server/console/dashboard.txt +27 -0
  56. helm_server/console/favicon.ico +0 -0
  57. helm_server/console/help.html +8 -0
  58. helm_server/console/help.txt +27 -0
  59. helm_server/console/image.html +8 -0
  60. helm_server/console/image.txt +30 -0
  61. helm_server/console/index.html +1 -0
  62. helm_server/console/index.txt +25 -0
  63. helm_server/console/next.svg +1 -0
  64. helm_server/console/scribbl-192.png +0 -0
  65. helm_server/console/scribbl-512-maskable.png +0 -0
  66. helm_server/console/scribbl-512.png +0 -0
  67. helm_server/console/settings.html +8 -0
  68. helm_server/console/settings.txt +30 -0
  69. helm_server/console/sign-in.html +1 -0
  70. helm_server/console/sign-in.txt +25 -0
  71. helm_server/console/sign-up.html +1 -0
  72. helm_server/console/sign-up.txt +25 -0
  73. helm_server/console/welcome.html +8 -0
  74. helm_server/console/welcome.txt +26 -0
  75. helm_server/console.py +102 -0
  76. helm_server/doctor.py +123 -0
  77. helm_server/events.py +171 -0
  78. helm_server/fleet.py +101 -0
  79. helm_server/main.py +242 -0
  80. helm_server/pair.py +238 -0
  81. helm_server/paths.py +76 -0
  82. helm_server/push.py +277 -0
  83. helm_server/runners.py +99 -0
  84. helm_server/server.py +754 -0
  85. helm_server/service.py +230 -0
  86. helm_server/session.py +1487 -0
  87. helm_server/skill/pair-helm/SKILL.md +198 -0
  88. helm_server/skill_notice.py +120 -0
  89. helm_server/spaces.py +532 -0
  90. helm_server/ticket.py +96 -0
  91. helm_server/uploads.py +81 -0
  92. helm_server-0.1.0.dist-info/METADATA +200 -0
  93. helm_server-0.1.0.dist-info/RECORD +96 -0
  94. helm_server-0.1.0.dist-info/WHEEL +5 -0
  95. helm_server-0.1.0.dist-info/entry_points.txt +2 -0
  96. 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)