aicortexpy 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 (58) hide show
  1. aicortexpy/__init__.py +3 -0
  2. aicortexpy/__main__.py +5 -0
  3. aicortexpy/acp_client.py +559 -0
  4. aicortexpy/acp_conn.py +853 -0
  5. aicortexpy/acp_errors.py +107 -0
  6. aicortexpy/agent_env.py +88 -0
  7. aicortexpy/artifact_ws.py +233 -0
  8. aicortexpy/artifacts.py +67 -0
  9. aicortexpy/attachments.py +85 -0
  10. aicortexpy/auto_commit.py +106 -0
  11. aicortexpy/capabilities.py +92 -0
  12. aicortexpy/chat_acp_pool.py +321 -0
  13. aicortexpy/cli.py +133 -0
  14. aicortexpy/client.py +438 -0
  15. aicortexpy/codex_config.py +98 -0
  16. aicortexpy/config.py +298 -0
  17. aicortexpy/daemon.py +677 -0
  18. aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
  19. aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
  20. aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
  21. aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
  22. aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
  23. aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
  24. aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
  25. aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
  26. aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
  27. aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
  28. aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
  29. aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
  30. aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
  31. aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
  32. aicortexpy/design_seed.py +59 -0
  33. aicortexpy/diskusage.py +209 -0
  34. aicortexpy/execenv.py +264 -0
  35. aicortexpy/gc.py +286 -0
  36. aicortexpy/handoff_bundle.py +65 -0
  37. aicortexpy/health.py +178 -0
  38. aicortexpy/local_skills.py +157 -0
  39. aicortexpy/models.py +385 -0
  40. aicortexpy/openclaw_config.py +153 -0
  41. aicortexpy/pinned_workdir.py +60 -0
  42. aicortexpy/platform_info.py +61 -0
  43. aicortexpy/poisoned.py +44 -0
  44. aicortexpy/process.py +168 -0
  45. aicortexpy/procutil.py +61 -0
  46. aicortexpy/prompt.py +235 -0
  47. aicortexpy/repo.py +462 -0
  48. aicortexpy/runner.py +969 -0
  49. aicortexpy/runtime_config.py +540 -0
  50. aicortexpy/sandbox.py +264 -0
  51. aicortexpy/stream.py +422 -0
  52. aicortexpy/terminal.py +147 -0
  53. aicortexpy/tunnel.py +82 -0
  54. aicortexpy/wakeup.py +222 -0
  55. aicortexpy-0.1.0.dist-info/METADATA +106 -0
  56. aicortexpy-0.1.0.dist-info/RECORD +58 -0
  57. aicortexpy-0.1.0.dist-info/WHEEL +5 -0
  58. aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/acp_conn.py ADDED
@@ -0,0 +1,853 @@
1
+ """Long-lived / one-shot ACP connections for Cursor, Kiro, Hermes, Kimi, Dsh."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Callable, Protocol
15
+
16
+ from .acp_client import (
17
+ ACPClient,
18
+ PromptResult,
19
+ dsh_tool_name,
20
+ extract_session_id,
21
+ hermes_tool_name_from_title,
22
+ kimi_tool_name,
23
+ kiro_tool_name,
24
+ resolve_resumed_session_id,
25
+ )
26
+ from .acp_errors import ACPProviderErrorSniffer, promote_acp_result_on_provider_error
27
+ from .procutil import kill_process_tree, popen_kwargs_for_killable_group
28
+
29
+ log = logging.getLogger("aicortexpy.acp")
30
+
31
+ HANDSHAKE_TIMEOUT = 120.0
32
+ PROMPT_TIMEOUT = 20 * 60.0
33
+ STDOUT_MAX_LINE = 10 * 1024 * 1024 # match Go scanner max token
34
+
35
+ MessageCB = Callable[[dict[str, Any]], None]
36
+
37
+
38
+ @dataclass
39
+ class ACPPromptResult:
40
+ status: str
41
+ output: str
42
+ error: str = ""
43
+ session_id: str = ""
44
+ usage: dict[str, int] | None = None
45
+ duration_ms: int = 0
46
+ messages: list[dict[str, Any]] = field(default_factory=list)
47
+
48
+
49
+ class ACPChatConn(Protocol):
50
+ def session_id(self) -> str: ...
51
+ def run_prompt(
52
+ self,
53
+ prompt: str,
54
+ *,
55
+ timeout: float = PROMPT_TIMEOUT,
56
+ system_prompt: str = "",
57
+ on_message: MessageCB | None = None,
58
+ ) -> ACPPromptResult: ...
59
+ def close(self) -> None: ...
60
+
61
+
62
+ ACP_ONESHOT_PROVIDERS = frozenset({"kiro", "hermes", "kimi", "dsh"})
63
+
64
+
65
+ def cursor_acp_supported(executable: str) -> bool:
66
+ # Go probes `acp --help` without yolo flags.
67
+ return _acp_help_ok(executable or "cursor-agent", extra_before=["acp", "--help"])
68
+
69
+
70
+ def kiro_acp_supported(executable: str) -> bool:
71
+ return _acp_help_ok(executable or "kiro-cli", extra_before=["acp", "--help"])
72
+
73
+
74
+ def _acp_help_ok(executable: str, *, extra_before: list[str]) -> bool:
75
+ path = shutil.which(executable) or executable
76
+ args = [path, *extra_before]
77
+ try:
78
+ proc = subprocess.run(
79
+ args,
80
+ capture_output=True,
81
+ timeout=5,
82
+ check=False,
83
+ **_hide_window(),
84
+ )
85
+ return proc.returncode == 0
86
+ except (OSError, subprocess.TimeoutExpired):
87
+ return False
88
+
89
+
90
+ def _hide_window() -> dict:
91
+ if sys.platform != "win32":
92
+ return {}
93
+ startup = subprocess.STARTUPINFO()
94
+ startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW
95
+ return {"startupinfo": startup}
96
+
97
+
98
+ def choose_cursor_invocation(executable: str, args: list[str]) -> tuple[str, list[str]]:
99
+ """On Windows, route cursor-agent.cmd/.bat through powershell -File .ps1.
100
+
101
+ Matches Go chooseCursorInvocation — avoids cmd.exe %* mangling long argv.
102
+ """
103
+ path = shutil.which(executable) or executable
104
+ if sys.platform != "win32":
105
+ return path, args
106
+ ext = Path(path).suffix.lower()
107
+ if ext not in (".cmd", ".bat"):
108
+ return path, args
109
+ ps1 = Path(path).with_name("cursor-agent.ps1")
110
+ if not ps1.is_file():
111
+ return path, args
112
+ ps_exe = ""
113
+ for name in ("pwsh.exe", "powershell.exe"):
114
+ found = shutil.which(name)
115
+ if found:
116
+ ps_exe = found
117
+ break
118
+ if not ps_exe:
119
+ root = os.environ.get("SystemRoot") or r"C:\Windows"
120
+ candidate = Path(root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
121
+ if candidate.is_file():
122
+ ps_exe = str(candidate)
123
+ if not ps_exe:
124
+ return path, args
125
+ log.info(
126
+ "cursor-agent: routing through powershell -File to preserve argv tokens ps1=%s",
127
+ ps1,
128
+ )
129
+ return ps_exe, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(ps1), *args]
130
+
131
+
132
+ def _filter_blocked(custom_args: list[str], blocked: set[str], value_flags: set[str] | None = None) -> list[str]:
133
+ value_flags = value_flags or set()
134
+ out: list[str] = []
135
+ skip_next = False
136
+ for a in custom_args:
137
+ if skip_next:
138
+ skip_next = False
139
+ continue
140
+ if a in blocked:
141
+ if a in value_flags or a in ("--sandbox", "--trust-tools", "--profile", "-p"):
142
+ skip_next = True
143
+ continue
144
+ out.append(a)
145
+ return out
146
+
147
+
148
+ class _BaseACPConn:
149
+ provider: str = ""
150
+ dual_prompt_fields: bool = False # kiro: send content + prompt
151
+ model_via_config_option: bool = False # dsh
152
+ require_set_model: bool = False # cursor / kiro pooled Open
153
+ soft_set_model: bool = False
154
+ resume_method: str = "session/load" # kimi uses session/resume
155
+ resume_include_mcp: bool = True
156
+
157
+ def __init__(self) -> None:
158
+ self._proc: subprocess.Popen[bytes] | None = None
159
+ self._client: ACPClient | None = None
160
+ self._session_id = ""
161
+ self._model = ""
162
+ self._closed = False
163
+ self._reader_done = threading.Event()
164
+ self._stderr_done = threading.Event()
165
+ self._lock = threading.Lock()
166
+ self._sniffer: ACPProviderErrorSniffer | None = None
167
+ self._stdin_text = None
168
+
169
+ def session_id(self) -> str:
170
+ return self._session_id
171
+
172
+ def close(self) -> None:
173
+ with self._lock:
174
+ if self._closed:
175
+ return
176
+ self._closed = True
177
+ client = self._client
178
+ proc = self._proc
179
+ self._client = None
180
+ self._proc = None
181
+ if self._stdin_text is not None:
182
+ try:
183
+ self._stdin_text.close()
184
+ except OSError:
185
+ pass
186
+ if proc and proc.pid:
187
+ kill_process_tree(proc.pid)
188
+ try:
189
+ proc.wait(timeout=5)
190
+ except Exception:
191
+ pass
192
+ self._reader_done.wait(timeout=5)
193
+ self._stderr_done.wait(timeout=5)
194
+ if client:
195
+ client.close_all_pending(RuntimeError(f"{self.provider} acp connection closed"))
196
+
197
+ def run_prompt(
198
+ self,
199
+ prompt: str,
200
+ *,
201
+ timeout: float = PROMPT_TIMEOUT,
202
+ system_prompt: str = "",
203
+ on_message: MessageCB | None = None,
204
+ idle_watchdog: float = 0,
205
+ cancel_event: threading.Event | None = None,
206
+ ) -> ACPPromptResult:
207
+ if self._closed or not self._client or not self._session_id:
208
+ return ACPPromptResult(
209
+ status="failed",
210
+ output="",
211
+ error=f"{self.provider} acp connection closed",
212
+ )
213
+ client = self._client
214
+ output_parts: list[str] = []
215
+ messages: list[dict[str, Any]] = []
216
+ prompt_done = threading.Event()
217
+ done_result: list[PromptResult] = []
218
+ streaming = {"on": False}
219
+ last_activity = {"t": time.time()}
220
+ in_flight_tools = {"n": 0}
221
+
222
+ def emit(msg: dict[str, Any]) -> None:
223
+ if not streaming["on"]:
224
+ return
225
+ last_activity["t"] = time.time()
226
+ typ = msg.get("type")
227
+ if typ == "text":
228
+ output_parts.append(str(msg.get("content") or ""))
229
+ elif typ == "tool-use":
230
+ in_flight_tools["n"] += 1
231
+ elif typ == "tool-result":
232
+ in_flight_tools["n"] = max(0, in_flight_tools["n"] - 1)
233
+ messages.append(msg)
234
+ if on_message:
235
+ try:
236
+ on_message(msg)
237
+ except Exception as e:
238
+ log.debug("on_message failed: %s", e)
239
+
240
+ def on_prompt_done(pr: PromptResult) -> None:
241
+ if not streaming["on"]:
242
+ return
243
+ last_activity["t"] = time.time()
244
+ done_result.append(pr)
245
+ prompt_done.set()
246
+
247
+ prev_msg, prev_done, prev_accept = (
248
+ client.on_message,
249
+ client.on_prompt_done,
250
+ client.accept_notification,
251
+ )
252
+ client.on_message = emit
253
+ client.on_prompt_done = on_prompt_done
254
+ client.accept_notification = lambda _t: streaming["on"]
255
+
256
+ user_text = prompt
257
+ if system_prompt:
258
+ user_text = system_prompt + "\n\n---\n\n" + prompt
259
+ blocks = [{"type": "text", "text": user_text}]
260
+ params: dict[str, Any] = {
261
+ "sessionId": self._session_id,
262
+ "prompt": blocks,
263
+ }
264
+ if self.dual_prompt_fields:
265
+ params["content"] = blocks
266
+
267
+ # Go pooled RunPrompt caps at 20 minutes regardless of AgentTimeout.
268
+ if timeout <= 0 or timeout > PROMPT_TIMEOUT:
269
+ timeout = PROMPT_TIMEOUT
270
+
271
+ start = time.time()
272
+ status = "completed"
273
+ error = ""
274
+ stop_watch = threading.Event()
275
+
276
+ def _watch() -> None:
277
+ while not stop_watch.wait(0.25):
278
+ if cancel_event is not None and cancel_event.is_set():
279
+ try:
280
+ self.close()
281
+ except Exception:
282
+ pass
283
+ return
284
+ if idle_watchdog > 0 and streaming["on"]:
285
+ silent = time.time() - last_activity["t"]
286
+ if silent >= idle_watchdog and in_flight_tools["n"] == 0:
287
+ log.warning(
288
+ "%s acp idle watchdog fired after %.0fs",
289
+ self.provider,
290
+ idle_watchdog,
291
+ )
292
+ try:
293
+ self.close()
294
+ except Exception:
295
+ pass
296
+ return
297
+
298
+ watcher = threading.Thread(target=_watch, name=f"{self.provider}-acp-watch", daemon=True)
299
+ watcher.start()
300
+
301
+ try:
302
+ with client._usage_lock:
303
+ client.usage.input_tokens = 0
304
+ client.usage.output_tokens = 0
305
+ client.usage.cache_read_tokens = 0
306
+ streaming["on"] = True
307
+ last_activity["t"] = time.time()
308
+ client.request("session/prompt", params, timeout=timeout)
309
+ prompt_done.wait(timeout=0.05)
310
+ if done_result and done_result[0].stop_reason == "cancelled":
311
+ status = "aborted"
312
+ error = f"{self.provider} acp cancelled the prompt"
313
+ except TimeoutError:
314
+ status = "timeout"
315
+ error = f"{self.provider} acp timed out after {timeout:.0f}s"
316
+ except Exception as e:
317
+ if cancel_event is not None and cancel_event.is_set():
318
+ status = "aborted"
319
+ error = "cancelled"
320
+ elif idle_watchdog > 0 and (time.time() - last_activity["t"]) >= idle_watchdog:
321
+ status = "idle_watchdog"
322
+ error = f"{self.provider} acp idle for {idle_watchdog:.0f}s"
323
+ else:
324
+ status = "failed"
325
+ error = f"{self.provider} acp session/prompt failed: {e}"
326
+ finally:
327
+ streaming["on"] = False
328
+ stop_watch.set()
329
+ watcher.join(timeout=1)
330
+ client.on_message = prev_msg
331
+ client.on_prompt_done = prev_done
332
+ client.accept_notification = prev_accept
333
+
334
+ # Drain stderr sniffer before promote (Go joins stderrDone).
335
+ self._stderr_done.wait(timeout=2)
336
+ output = "".join(output_parts)
337
+ status, error = promote_acp_result_on_provider_error(
338
+ status, error, output, self._sniffer
339
+ )
340
+
341
+ usage = None
342
+ with client._usage_lock:
343
+ u = client.usage
344
+ if done_result:
345
+ u.input_tokens += done_result[0].usage.input_tokens
346
+ u.output_tokens += done_result[0].usage.output_tokens
347
+ u.cache_read_tokens += done_result[0].usage.cache_read_tokens
348
+ if u.input_tokens or u.output_tokens or u.cache_read_tokens:
349
+ usage = {
350
+ "input_tokens": u.input_tokens,
351
+ "output_tokens": u.output_tokens,
352
+ "cache_read_tokens": u.cache_read_tokens,
353
+ }
354
+ return ACPPromptResult(
355
+ status=status,
356
+ output=output,
357
+ error=error,
358
+ session_id=self._session_id,
359
+ usage=usage,
360
+ duration_ms=int((time.time() - start) * 1000),
361
+ messages=messages,
362
+ )
363
+
364
+ def _start_process(
365
+ self,
366
+ argv: list[str],
367
+ cwd: str,
368
+ env: dict[str, str] | None,
369
+ *,
370
+ tool_name_fn: Callable[[str, str], str],
371
+ ) -> None:
372
+ kwargs = popen_kwargs_for_killable_group()
373
+ kwargs.update(_hide_window())
374
+ self._sniffer = ACPProviderErrorSniffer(self.provider)
375
+ # Binary pipes + manual decode so we can raise the line-size limit.
376
+ self._proc = subprocess.Popen(
377
+ argv,
378
+ stdin=subprocess.PIPE,
379
+ stdout=subprocess.PIPE,
380
+ stderr=subprocess.PIPE,
381
+ cwd=cwd or None,
382
+ env=env,
383
+ bufsize=0,
384
+ **kwargs,
385
+ )
386
+ assert self._proc.stdin and self._proc.stdout and self._proc.stderr
387
+ import io
388
+
389
+ self._stdin_text = io.TextIOWrapper(
390
+ self._proc.stdin,
391
+ encoding="utf-8",
392
+ errors="replace",
393
+ write_through=True,
394
+ line_buffering=True,
395
+ )
396
+ self._client = ACPClient(self._stdin_text, tool_name_fn=tool_name_fn)
397
+ threading.Thread(target=self._read_stdout, name=f"{self.provider}-acp-out", daemon=True).start()
398
+ threading.Thread(target=self._read_stderr, name=f"{self.provider}-acp-err", daemon=True).start()
399
+ log.info("%s acp started pid=%s cwd=%s", self.provider, self._proc.pid, cwd)
400
+
401
+ def _read_stdout(self) -> None:
402
+ try:
403
+ assert self._proc and self._proc.stdout and self._client
404
+ buf = b""
405
+ while True:
406
+ chunk = self._proc.stdout.read(65536)
407
+ if not chunk:
408
+ break
409
+ buf += chunk
410
+ while True:
411
+ nl = buf.find(b"\n")
412
+ if nl < 0:
413
+ if len(buf) > STDOUT_MAX_LINE:
414
+ log.warning(
415
+ "%s acp stdout line exceeded %s bytes; dropping",
416
+ self.provider,
417
+ STDOUT_MAX_LINE,
418
+ )
419
+ buf = b""
420
+ break
421
+ line = buf[:nl].decode("utf-8", errors="replace")
422
+ buf = buf[nl + 1 :]
423
+ self._client.handle_line(line)
424
+ except Exception as e:
425
+ log.debug("%s acp stdout reader ended: %s", self.provider, e)
426
+ finally:
427
+ if self._client:
428
+ self._client.close_all_pending(
429
+ RuntimeError(f"{self.provider} acp process exited")
430
+ )
431
+ self._reader_done.set()
432
+
433
+ def _read_stderr(self) -> None:
434
+ try:
435
+ assert self._proc and self._proc.stderr
436
+ while True:
437
+ chunk = self._proc.stderr.read(4096)
438
+ if not chunk:
439
+ break
440
+ if self._sniffer:
441
+ self._sniffer.write(chunk)
442
+ text = chunk.decode("utf-8", errors="replace")
443
+ for line in text.splitlines():
444
+ line = line.rstrip()
445
+ if line:
446
+ log.info("[%s:acp:stderr] %s", self.provider, line)
447
+ except Exception:
448
+ pass
449
+ finally:
450
+ self._stderr_done.set()
451
+
452
+ def _handshake(
453
+ self,
454
+ *,
455
+ cwd: str,
456
+ model: str,
457
+ resume_session_id: str,
458
+ allow_resume: bool,
459
+ handshake_timeout: float,
460
+ hard_fail_resume: bool = False,
461
+ ) -> None:
462
+ assert self._client
463
+ self._client.request(
464
+ "initialize",
465
+ {
466
+ "protocolVersion": 1,
467
+ "clientInfo": {"name": "aicortex-agent-sdk", "version": "0.2.0"},
468
+ "clientCapabilities": {},
469
+ },
470
+ timeout=handshake_timeout,
471
+ )
472
+ session_id = ""
473
+ if allow_resume and resume_session_id:
474
+ try:
475
+ resume_params: dict[str, Any] = {
476
+ "cwd": cwd,
477
+ "sessionId": resume_session_id,
478
+ }
479
+ if self.resume_include_mcp:
480
+ resume_params["mcpServers"] = []
481
+ result = self._client.request(
482
+ self.resume_method,
483
+ resume_params,
484
+ timeout=handshake_timeout,
485
+ )
486
+ session_id, changed = resolve_resumed_session_id(resume_session_id, result)
487
+ if changed:
488
+ log.warning(
489
+ "%s acp resume returned different session id requested=%s actual=%s",
490
+ self.provider,
491
+ resume_session_id,
492
+ session_id,
493
+ )
494
+ except Exception as e:
495
+ if hard_fail_resume or self.provider == "kiro":
496
+ self.close()
497
+ raise RuntimeError(
498
+ f"{self.provider} acp {self.resume_method} failed: {e}"
499
+ ) from e
500
+ log.warning(
501
+ "%s acp %s failed, opening new session: %s",
502
+ self.provider,
503
+ self.resume_method,
504
+ e,
505
+ )
506
+ session_id = ""
507
+ if not session_id:
508
+ result = self._client.request(
509
+ "session/new",
510
+ {"cwd": cwd, "mcpServers": []},
511
+ timeout=handshake_timeout,
512
+ )
513
+ session_id = extract_session_id(result)
514
+ if not session_id:
515
+ self.close()
516
+ raise RuntimeError(f"{self.provider} acp session/new returned no session ID")
517
+ self._session_id = session_id
518
+ self._client.session_id = session_id
519
+ self._model = model
520
+ log.info("%s acp session ready session_id=%s", self.provider, session_id)
521
+ if not model:
522
+ return
523
+ try:
524
+ if self.model_via_config_option:
525
+ self._client.request(
526
+ "session/set_config_option",
527
+ {"sessionId": session_id, "configId": "model", "value": model},
528
+ timeout=handshake_timeout,
529
+ )
530
+ else:
531
+ self._client.request(
532
+ "session/set_model",
533
+ {"sessionId": session_id, "modelId": model},
534
+ timeout=handshake_timeout,
535
+ )
536
+ except Exception as e:
537
+ if self.soft_set_model:
538
+ log.debug("%s acp set_model skipped: %s", self.provider, e)
539
+ return
540
+ self.close()
541
+ raise RuntimeError(f"{self.provider} acp set model failed: {e}") from e
542
+
543
+
544
+ class CursorACPConn(_BaseACPConn):
545
+ provider = "cursor"
546
+ require_set_model = True
547
+
548
+ @classmethod
549
+ def open(
550
+ cls,
551
+ executable: str,
552
+ *,
553
+ cwd: str,
554
+ model: str = "",
555
+ resume_session_id: str = "",
556
+ custom_args: list[str] | None = None,
557
+ timeout: float = 0,
558
+ env: dict[str, str] | None = None,
559
+ allow_resume: bool = False,
560
+ ) -> CursorACPConn:
561
+ path = shutil.which(executable) or executable
562
+ blocked = {"acp", "--yolo", "--force", "--sandbox", "--trust"}
563
+ filtered = _filter_blocked(list(custom_args or []), blocked, {"--sandbox"})
564
+ acp_args = ["--yolo", "--trust", "--sandbox", "disabled", "acp", *filtered]
565
+ argv0, cmd_args = choose_cursor_invocation(path, acp_args)
566
+ argv = [argv0, *cmd_args]
567
+ conn = cls()
568
+ work = cwd or str(Path.cwd())
569
+ conn._start_process(argv, work, env, tool_name_fn=hermes_tool_name_from_title)
570
+ hs = HANDSHAKE_TIMEOUT
571
+ if timeout > 0:
572
+ hs = min(hs, timeout)
573
+ try:
574
+ conn._handshake(
575
+ cwd=work,
576
+ model=model,
577
+ resume_session_id=resume_session_id,
578
+ allow_resume=allow_resume,
579
+ handshake_timeout=hs,
580
+ )
581
+ except Exception:
582
+ conn.close()
583
+ raise
584
+ return conn
585
+
586
+
587
+ class KiroACPConn(_BaseACPConn):
588
+ provider = "kiro"
589
+ dual_prompt_fields = True
590
+ require_set_model = True
591
+
592
+ @classmethod
593
+ def open(
594
+ cls,
595
+ executable: str,
596
+ *,
597
+ cwd: str,
598
+ model: str = "",
599
+ resume_session_id: str = "",
600
+ custom_args: list[str] | None = None,
601
+ timeout: float = 0,
602
+ env: dict[str, str] | None = None,
603
+ ) -> KiroACPConn:
604
+ path = shutil.which(executable) or executable
605
+ blocked = {"acp", "-a", "--trust-all-tools", "--trust-tools"}
606
+ filtered = _filter_blocked(
607
+ list(custom_args or []), blocked, {"--trust-tools"}
608
+ )
609
+ argv = [path, "acp", "--trust-all-tools", *filtered]
610
+ conn = cls()
611
+ work = cwd or str(Path.cwd())
612
+ conn._start_process(argv, work, env, tool_name_fn=kiro_tool_name)
613
+ hs = timeout if timeout > 0 else HANDSHAKE_TIMEOUT
614
+ try:
615
+ conn._handshake(
616
+ cwd=work,
617
+ model=model,
618
+ resume_session_id=resume_session_id,
619
+ allow_resume=True,
620
+ handshake_timeout=hs,
621
+ hard_fail_resume=True,
622
+ )
623
+ except Exception:
624
+ conn.close()
625
+ raise
626
+ return conn
627
+
628
+
629
+ class HermesACPConn(_BaseACPConn):
630
+ provider = "hermes"
631
+ soft_set_model = True
632
+
633
+ @classmethod
634
+ def open(
635
+ cls,
636
+ executable: str,
637
+ *,
638
+ cwd: str,
639
+ model: str = "",
640
+ resume_session_id: str = "",
641
+ custom_args: list[str] | None = None,
642
+ timeout: float = 0,
643
+ env: dict[str, str] | None = None,
644
+ ) -> HermesACPConn:
645
+ path = shutil.which(executable) or executable
646
+ filtered = _filter_blocked(list(custom_args or []), {"acp"})
647
+ argv = [path, "acp", *filtered]
648
+ conn = cls()
649
+ work = cwd or str(Path.cwd())
650
+ environ = dict(env or {})
651
+ environ.setdefault("HERMES_YOLO_MODE", "1")
652
+ conn._start_process(argv, work, environ, tool_name_fn=hermes_tool_name_from_title)
653
+ hs = timeout if timeout > 0 else HANDSHAKE_TIMEOUT
654
+ try:
655
+ conn._handshake(
656
+ cwd=work,
657
+ model=model,
658
+ resume_session_id=resume_session_id,
659
+ allow_resume=True,
660
+ handshake_timeout=hs,
661
+ )
662
+ except Exception:
663
+ conn.close()
664
+ raise
665
+ return conn
666
+
667
+
668
+ class KimiACPConn(_BaseACPConn):
669
+ provider = "kimi"
670
+ soft_set_model = True
671
+ resume_method = "session/resume"
672
+ resume_include_mcp = False
673
+
674
+ @classmethod
675
+ def open(
676
+ cls,
677
+ executable: str,
678
+ *,
679
+ cwd: str,
680
+ model: str = "",
681
+ resume_session_id: str = "",
682
+ custom_args: list[str] | None = None,
683
+ timeout: float = 0,
684
+ env: dict[str, str] | None = None,
685
+ ) -> KimiACPConn:
686
+ path = shutil.which(executable) or executable
687
+ filtered = _filter_blocked(list(custom_args or []), {"acp"})
688
+ argv = [path, "acp", *filtered]
689
+ conn = cls()
690
+ work = cwd or str(Path.cwd())
691
+ conn._start_process(argv, work, env, tool_name_fn=kimi_tool_name)
692
+ hs = timeout if timeout > 0 else HANDSHAKE_TIMEOUT
693
+ try:
694
+ conn._handshake(
695
+ cwd=work,
696
+ model=model,
697
+ resume_session_id=resume_session_id,
698
+ allow_resume=True,
699
+ handshake_timeout=hs,
700
+ )
701
+ except Exception:
702
+ conn.close()
703
+ raise
704
+ return conn
705
+
706
+
707
+ class DshACPConn(_BaseACPConn):
708
+ provider = "dsh"
709
+ model_via_config_option = True
710
+ require_set_model = True
711
+
712
+ @classmethod
713
+ def open(
714
+ cls,
715
+ executable: str,
716
+ *,
717
+ cwd: str,
718
+ model: str = "",
719
+ resume_session_id: str = "",
720
+ custom_args: list[str] | None = None,
721
+ timeout: float = 0,
722
+ env: dict[str, str] | None = None,
723
+ ) -> DshACPConn:
724
+ path = shutil.which(executable) or executable
725
+ filtered = _filter_blocked(
726
+ list(custom_args or []), {"--profile", "-p"}, {"--profile", "-p"}
727
+ )
728
+ argv = [path, "--profile", "acp", *filtered]
729
+ conn = cls()
730
+ work = cwd or str(Path.cwd())
731
+ conn._start_process(argv, work, env, tool_name_fn=dsh_tool_name)
732
+ hs = timeout if timeout > 0 else HANDSHAKE_TIMEOUT
733
+ try:
734
+ conn._handshake(
735
+ cwd=work,
736
+ model=model,
737
+ resume_session_id=resume_session_id,
738
+ allow_resume=True,
739
+ handshake_timeout=hs,
740
+ )
741
+ except Exception:
742
+ conn.close()
743
+ raise
744
+ return conn
745
+
746
+
747
+ def open_acp_conn(
748
+ provider: str,
749
+ executable: str,
750
+ *,
751
+ cwd: str,
752
+ model: str = "",
753
+ resume_session_id: str = "",
754
+ custom_args: list[str] | None = None,
755
+ timeout: float = 0,
756
+ env: dict[str, str] | None = None,
757
+ ) -> _BaseACPConn:
758
+ if provider == "cursor":
759
+ return CursorACPConn.open(
760
+ executable,
761
+ cwd=cwd,
762
+ model=model,
763
+ resume_session_id=resume_session_id,
764
+ custom_args=custom_args,
765
+ timeout=timeout,
766
+ env=env,
767
+ allow_resume=False,
768
+ )
769
+ if provider == "kiro":
770
+ return KiroACPConn.open(
771
+ executable,
772
+ cwd=cwd,
773
+ model=model,
774
+ resume_session_id=resume_session_id,
775
+ custom_args=custom_args,
776
+ timeout=timeout,
777
+ env=env,
778
+ )
779
+ if provider == "hermes":
780
+ return HermesACPConn.open(
781
+ executable,
782
+ cwd=cwd,
783
+ model=model,
784
+ resume_session_id=resume_session_id,
785
+ custom_args=custom_args,
786
+ timeout=timeout,
787
+ env=env,
788
+ )
789
+ if provider == "kimi":
790
+ return KimiACPConn.open(
791
+ executable,
792
+ cwd=cwd,
793
+ model=model,
794
+ resume_session_id=resume_session_id,
795
+ custom_args=custom_args,
796
+ timeout=timeout,
797
+ env=env,
798
+ )
799
+ if provider == "dsh":
800
+ return DshACPConn.open(
801
+ executable,
802
+ cwd=cwd,
803
+ model=model,
804
+ resume_session_id=resume_session_id,
805
+ custom_args=custom_args,
806
+ timeout=timeout,
807
+ env=env,
808
+ )
809
+ raise RuntimeError(f"unsupported acp provider {provider!r}")
810
+
811
+
812
+ def run_acp_oneshot(
813
+ provider: str,
814
+ executable: str,
815
+ *,
816
+ cwd: str,
817
+ prompt: str,
818
+ model: str = "",
819
+ resume_session_id: str = "",
820
+ custom_args: list[str] | None = None,
821
+ timeout: float = 0,
822
+ env: dict[str, str] | None = None,
823
+ system_prompt: str = "",
824
+ on_message: MessageCB | None = None,
825
+ cancel_event: threading.Event | None = None,
826
+ idle_watchdog: float = 0,
827
+ ) -> ACPPromptResult:
828
+ """One-shot ACP turn (open → prompt → close). Used for non-chat kiro/hermes/kimi/dsh."""
829
+ t = timeout if timeout > 0 else PROMPT_TIMEOUT
830
+ try:
831
+ conn = open_acp_conn(
832
+ provider,
833
+ executable,
834
+ cwd=cwd,
835
+ model=model,
836
+ resume_session_id=resume_session_id,
837
+ custom_args=custom_args,
838
+ timeout=t,
839
+ env=env,
840
+ )
841
+ except Exception as e:
842
+ return ACPPromptResult(status="failed", output="", error=f"{provider} acp open failed: {e}")
843
+ try:
844
+ return conn.run_prompt(
845
+ prompt,
846
+ timeout=t,
847
+ system_prompt=system_prompt,
848
+ on_message=on_message,
849
+ idle_watchdog=idle_watchdog,
850
+ cancel_event=cancel_event,
851
+ )
852
+ finally:
853
+ conn.close()