kiwimatecoder 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 (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,823 @@
1
+ """The ACP agent server: request dispatch, sessions, turns, and permissions.
2
+
3
+ This implements the agent side of a focused Agent Client Protocol subset over
4
+ newline-delimited JSON-RPC 2.0:
5
+
6
+ * client -> agent requests: ``initialize``, ``session/new``, ``session/prompt``
7
+ and the ``session/cancel`` notification;
8
+ * agent -> client requests: ``session/request_permission`` (used to bridge the
9
+ regular permission gate to the editor);
10
+ * agent -> client notifications: ``session/update`` for assistant text chunks
11
+ and tool-call progress;
12
+ * optional file delegation through ``fs/read_text_file`` / ``fs/write_text_file``
13
+ when the client advertises filesystem capabilities.
14
+
15
+ The transport is injected as two callables (``read_line``/``write_line``), so
16
+ tests drive the server with in-memory queues and the real process uses
17
+ :mod:`kiwimatecoder.acp.stdio`. Every wait is bounded: unanswered permission
18
+ requests are denied after ``acp.permission_timeout`` seconds (default 300).
19
+
20
+ Turns reuse :func:`kiwimatecoder.headless.run_agent_once`; the agent's block
21
+ and gate callbacks run inside the normal permission flow, so an editor in the
22
+ configured ``ask`` mode sees exactly the approvals the terminal would ask for.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import inspect
29
+ import logging
30
+ import uuid
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from typing import Any, Callable
34
+
35
+ from kiwimatecoder import __version__, config
36
+ from kiwimatecoder.acp.protocol import (
37
+ ERROR_INTERNAL,
38
+ ERROR_INVALID_PARAMS,
39
+ ERROR_INVALID_REQUEST,
40
+ ERROR_METHOD_NOT_FOUND,
41
+ ERROR_PARSE,
42
+ ERROR_SERVER,
43
+ AcpError,
44
+ AcpTimeoutError,
45
+ IdAllocator,
46
+ decode_lines,
47
+ encode,
48
+ error_response,
49
+ notification_message,
50
+ request_message,
51
+ success_response,
52
+ )
53
+ from kiwimatecoder.headless import build_session, run_agent_once, shutdown_runtime
54
+ from kiwimatecoder.session import Session
55
+
56
+ _log = logging.getLogger(__name__)
57
+
58
+ PROTOCOL_VERSION = 1
59
+ DEFAULT_PERMISSION_TIMEOUT = 300.0
60
+ SHUTDOWN_GRACE_SECONDS = 5.0
61
+
62
+ # Incoming transport hooks. ``read_line`` returns None (or "") at EOF; either
63
+ # callable may be sync or async.
64
+ ReadLine = Callable[[], Any]
65
+ WriteLine = Callable[[str], Any]
66
+
67
+ PERMISSION_OPTIONS: list[dict[str, Any]] = [
68
+ {"optionId": "allow_once", "name": "Allow once", "kind": "allow_once"},
69
+ {"optionId": "allow_always", "name": "Allow always", "kind": "allow_always"},
70
+ {"optionId": "reject_once", "name": "Reject", "kind": "reject_once"},
71
+ ]
72
+
73
+ # Tools whose result/args are read-like, edit-like, and so on. Anything not
74
+ # listed maps to ACP's generic "other".
75
+ TOOL_KINDS: dict[str, str] = {
76
+ "read_file": "read",
77
+ "list_dir": "read",
78
+ "view_image": "read",
79
+ "write_file": "edit",
80
+ "edit_file": "edit",
81
+ "search": "search",
82
+ "run_bash": "execute",
83
+ "shell": "execute",
84
+ "shell_jobs": "execute",
85
+ "web_fetch": "fetch",
86
+ "web_search": "fetch",
87
+ "http_get": "fetch",
88
+ "http_request": "fetch",
89
+ "task": "think",
90
+ }
91
+
92
+
93
+ def tool_kind(tool_name: str) -> str:
94
+ """Map a KiwiMateCoder tool name to an ACP tool-call kind."""
95
+ return TOOL_KINDS.get(tool_name, "other")
96
+
97
+
98
+ def _describe_tool(tool_name: str, args: dict[str, Any]) -> str:
99
+ """A short human-readable title for a tool-call update."""
100
+ for key in ("path", "command", "query", "url", "description", "action"):
101
+ value = args.get(key)
102
+ if value:
103
+ text = str(value)
104
+ if len(text) > 80:
105
+ text = text[:77] + "..."
106
+ return f"{tool_name} {text}"
107
+ return tool_name
108
+
109
+
110
+ def prompt_text(prompt: Any) -> tuple[str, int]:
111
+ """Concatenate a prompt's text parts; count any image parts skipped."""
112
+ if not isinstance(prompt, list):
113
+ return "", 0
114
+ parts: list[str] = []
115
+ images = 0
116
+ for item in prompt:
117
+ if not isinstance(item, dict):
118
+ continue
119
+ kind = item.get("type")
120
+ if kind == "text":
121
+ text = item.get("text")
122
+ if isinstance(text, str):
123
+ parts.append(text)
124
+ elif kind == "image":
125
+ images += 1
126
+ return "\n".join(parts), images
127
+
128
+
129
+ @dataclass
130
+ class _SessionState:
131
+ """One ``session/new`` conversation plus its in-flight tool calls."""
132
+
133
+ session: Session
134
+ tool_counter: int = 0
135
+ in_progress: list[tuple[str, str]] = field(default_factory=list)
136
+ pending_permission_id: str | None = None
137
+
138
+ def allocate_call_id(self) -> str:
139
+ self.tool_counter += 1
140
+ return f"call-{self.tool_counter}"
141
+
142
+ def begin_tool(self, tool_name: str, args: dict[str, Any]) -> tuple[str, str, str]:
143
+ """Register a starting tool call and return ``(id, title, kind)``."""
144
+ call_id = self.pending_permission_id or self.allocate_call_id()
145
+ self.pending_permission_id = None
146
+ self.in_progress.append((call_id, tool_name))
147
+ return call_id, _describe_tool(tool_name, args), tool_kind(tool_name)
148
+
149
+ def end_tool(self, tool_name: str) -> str | None:
150
+ """Resolve the call id for a finished tool, preferring a name match."""
151
+ for index, (call_id, name) in enumerate(self.in_progress):
152
+ if name == tool_name:
153
+ del self.in_progress[index]
154
+ return call_id
155
+ if self.in_progress:
156
+ return self.in_progress.pop(0)[0]
157
+ return None
158
+
159
+
160
+ @dataclass
161
+ class _TurnState:
162
+ """Bookkeeping for one running ``session/prompt``."""
163
+
164
+ session_id: str
165
+ done_reasons: list[str] = field(default_factory=list)
166
+ saw_text: bool = False
167
+ cancelled: bool = False
168
+ loop: asyncio.AbstractEventLoop | None = None
169
+ task: asyncio.Task[Any] | None = None
170
+ pending_updates: list[Any] = field(default_factory=list)
171
+
172
+
173
+ @dataclass
174
+ class _PendingRequest:
175
+ """An agent -> client request awaiting its response."""
176
+
177
+ future: asyncio.Future[Any]
178
+ method: str
179
+ session_id: str | None = None
180
+
181
+
182
+ def _consume_future_exception(future: Any) -> None:
183
+ """Swallow an unobserved exception on a fire-and-forget future."""
184
+ try:
185
+ future.exception()
186
+ except BaseException:
187
+ pass
188
+
189
+
190
+ class AcpServer:
191
+ """A transport-agnostic ACP agent.
192
+
193
+ ``workspace`` is the default session root when ``session/new`` omits
194
+ ``cwd``; ``permission_timeout`` overrides the configured
195
+ ``acp.permission_timeout`` (seconds) and is mainly useful in tests.
196
+ """
197
+
198
+ def __init__(
199
+ self,
200
+ *,
201
+ workspace: str | Path | None = None,
202
+ permission_timeout: float | None = None,
203
+ ) -> None:
204
+ self.default_workspace = (
205
+ Path(workspace).expanduser() if workspace is not None else None
206
+ )
207
+ if permission_timeout is None:
208
+ permission_timeout = float(
209
+ config.get_acp().get("permission_timeout", DEFAULT_PERMISSION_TIMEOUT)
210
+ )
211
+ self.permission_timeout = float(permission_timeout)
212
+
213
+ self._sessions: dict[str, _SessionState] = {}
214
+ self._turns: dict[str, _TurnState] = {}
215
+ self._pending: dict[int, _PendingRequest] = {}
216
+ self._inflight: set[asyncio.Task[Any]] = set()
217
+ self._ids = IdAllocator()
218
+ self._client_capabilities: dict[str, Any] = {}
219
+ self._fs_read = False
220
+ self._fs_write = False
221
+
222
+ self._loop: asyncio.AbstractEventLoop | None = None
223
+ self._read_line: ReadLine | None = None
224
+ self._write_line: WriteLine | None = None
225
+ self._write_lock: asyncio.Lock | None = None
226
+ self._closed = False
227
+
228
+ # -- lifecycle ----------------------------------------------------------
229
+
230
+ async def serve(self, read_line: ReadLine, write_line: WriteLine) -> int:
231
+ """Read and dispatch messages until EOF, then shut down cleanly.
232
+
233
+ ``read_line`` returns the next line (with or without a trailing
234
+ newline) or ``None``/``""`` at end of input; ``write_line`` accepts a
235
+ terminated line. Both may be synchronous or asynchronous callables.
236
+ Returns ``0`` after a clean EOF.
237
+ """
238
+ self._loop = asyncio.get_running_loop()
239
+ self._read_line = read_line
240
+ self._write_line = write_line
241
+ self._write_lock = asyncio.Lock()
242
+ self._closed = False
243
+ try:
244
+ while not self._closed:
245
+ line = read_line()
246
+ if inspect.isawaitable(line):
247
+ line = await line
248
+ if line is None or line == "":
249
+ break
250
+ await self._handle_line(str(line))
251
+ finally:
252
+ await self.shutdown()
253
+ return 0
254
+
255
+ async def shutdown(self) -> None:
256
+ """Cancel active turns, release pending requests, and stop managers."""
257
+ self._closed = True
258
+ for turn in list(self._turns.values()):
259
+ self._cancel_turn(turn)
260
+ for request_id, pending in list(self._pending.items()):
261
+ if not pending.future.done():
262
+ if pending.method == "session/request_permission":
263
+ pending.future.set_result({"outcome": {"outcome": "cancelled"}})
264
+ else:
265
+ pending.future.set_exception(
266
+ AcpError(ERROR_SERVER, "Server is shutting down")
267
+ )
268
+ self._pending.pop(request_id, None)
269
+ if self._inflight:
270
+ done, still = await asyncio.wait(
271
+ list(self._inflight), timeout=SHUTDOWN_GRACE_SECONDS
272
+ )
273
+ for task in still:
274
+ task.cancel()
275
+ if still:
276
+ await asyncio.wait(still, timeout=SHUTDOWN_GRACE_SECONDS)
277
+ try:
278
+ shutdown_runtime()
279
+ except Exception: # pragma: no cover - defensive: cleanup must not raise
280
+ _log.debug("ACP shutdown cleanup failed", exc_info=True)
281
+
282
+ # -- incoming messages --------------------------------------------------
283
+
284
+ async def _handle_line(self, line: str) -> None:
285
+ """Parse one transport line and dispatch every message it contains."""
286
+ text = line if line.endswith("\n") else line + "\n"
287
+ messages, _remainder = decode_lines(text)
288
+ if not messages:
289
+ if line.strip():
290
+ await self._send(
291
+ error_response(None, ERROR_PARSE, "Parse error: invalid JSON")
292
+ )
293
+ return
294
+ for message in messages:
295
+ await self._dispatch(message)
296
+
297
+ async def _dispatch(self, message: dict[str, Any]) -> None:
298
+ method = message.get("method")
299
+ if isinstance(method, str):
300
+ if "id" in message:
301
+ if method == "session/prompt":
302
+ task = asyncio.create_task(self._handle_prompt_request(message))
303
+ self._inflight.add(task)
304
+ task.add_done_callback(self._inflight.discard)
305
+ return
306
+ await self._handle_request(message["id"], method, message.get("params"))
307
+ return
308
+ await self._handle_notification(method, message.get("params"))
309
+ return
310
+ if "id" in message and ("result" in message or "error" in message):
311
+ self._resolve_response(message)
312
+ return
313
+ await self._send(
314
+ error_response(
315
+ message.get("id"), ERROR_INVALID_REQUEST, "Invalid JSON-RPC message"
316
+ )
317
+ )
318
+
319
+ async def _handle_request(
320
+ self, request_id: Any, method: str, params: Any
321
+ ) -> None:
322
+ payload = params if isinstance(params, dict) else {}
323
+ try:
324
+ result = await self._dispatch_request(method, payload)
325
+ except AcpError as exc:
326
+ await self._send(
327
+ error_response(request_id, exc.code, exc.message, exc.data)
328
+ )
329
+ return
330
+ except Exception as exc: # pragma: no cover - defensive
331
+ _log.debug("ACP request %s failed", method, exc_info=True)
332
+ await self._send(
333
+ error_response(request_id, ERROR_INTERNAL, f"Internal error: {exc}")
334
+ )
335
+ return
336
+ await self._send(success_response(request_id, result))
337
+
338
+ async def _dispatch_request(self, method: str, params: dict[str, Any]) -> Any:
339
+ if method == "initialize":
340
+ return await self._initialize(params)
341
+ if method == "session/new":
342
+ return await self._new_session(params)
343
+ # ``session/prompt`` never reaches here (dispatched as a background
344
+ # task), and ``session/request_permission`` is agent -> client only.
345
+ raise AcpError(ERROR_METHOD_NOT_FOUND, f"Method not found: {method}")
346
+
347
+ async def _handle_notification(self, method: str, params: Any) -> None:
348
+ payload = params if isinstance(params, dict) else {}
349
+ if method == "session/cancel":
350
+ await self._cancel(payload)
351
+ # Unknown notifications are ignored per JSON-RPC 2.0.
352
+
353
+ async def _handle_prompt_request(self, message: dict[str, Any]) -> None:
354
+ request_id = message.get("id")
355
+ params = message.get("params")
356
+ try:
357
+ result = await self._prompt(params if isinstance(params, dict) else {})
358
+ except AcpError as exc:
359
+ await self._send(
360
+ error_response(request_id, exc.code, exc.message, exc.data)
361
+ )
362
+ return
363
+ except Exception as exc: # pragma: no cover - defensive
364
+ _log.debug("ACP prompt failed", exc_info=True)
365
+ await self._send(
366
+ error_response(request_id, ERROR_INTERNAL, f"Internal error: {exc}")
367
+ )
368
+ return
369
+ await self._send(success_response(request_id, result))
370
+
371
+ # -- client -> agent handlers -------------------------------------------
372
+
373
+ async def _initialize(self, params: dict[str, Any]) -> dict[str, Any]:
374
+ # The agent reports the protocol version it implements; a client on a
375
+ # newer version negotiates down (or disconnects if incompatible).
376
+ capabilities = params.get("clientCapabilities")
377
+ self._client_capabilities = (
378
+ capabilities if isinstance(capabilities, dict) else {}
379
+ )
380
+ fs = self._client_capabilities.get("fs")
381
+ if isinstance(fs, dict):
382
+ self._fs_read = bool(fs.get("readTextFile"))
383
+ self._fs_write = bool(fs.get("writeTextFile"))
384
+ return {
385
+ "protocolVersion": PROTOCOL_VERSION,
386
+ "agentCapabilities": {
387
+ "loadSession": False,
388
+ "promptCapabilities": {"image": True},
389
+ },
390
+ "agentInfo": {"name": "kiwimatecoder", "version": __version__},
391
+ }
392
+
393
+ async def _new_session(self, params: dict[str, Any]) -> dict[str, Any]:
394
+ if self.default_workspace is not None:
395
+ raw_cwd: Any = params.get("cwd") or self.default_workspace
396
+ else:
397
+ raw_cwd = params.get("cwd") or Path.cwd()
398
+ root = Path(str(raw_cwd)).expanduser()
399
+ if not root.is_dir():
400
+ raise AcpError(
401
+ ERROR_INVALID_PARAMS, f"Workspace is not a directory: {root}"
402
+ )
403
+ try:
404
+ session = build_session(workspace=root)
405
+ except (ValueError, KeyError) as exc:
406
+ raise AcpError(ERROR_INVALID_PARAMS, str(exc)) from exc
407
+ # ``mcpServers`` is accepted but not wired: MCP servers come from the
408
+ # user's KiwiMateCoder config. The session uses the configured
409
+ # permission mode (never an assumed auto-accept).
410
+ session_id = uuid.uuid4().hex
411
+ self._sessions[session_id] = _SessionState(session=session)
412
+ return {"sessionId": session_id}
413
+
414
+ async def _prompt(self, params: dict[str, Any]) -> dict[str, Any]:
415
+ session_id = str(params.get("sessionId") or "")
416
+ state = self._sessions.get(session_id)
417
+ if state is None:
418
+ raise AcpError(ERROR_INVALID_PARAMS, f"Unknown session '{session_id}'.")
419
+ if session_id in self._turns:
420
+ raise AcpError(
421
+ ERROR_SERVER, "A turn is already running for this session."
422
+ )
423
+
424
+ text, images = prompt_text(params.get("prompt"))
425
+ if not text.strip() and not images:
426
+ raise AcpError(
427
+ ERROR_INVALID_PARAMS, "prompt must contain at least one text part."
428
+ )
429
+ if images:
430
+ note = f"[{images} image part(s) were ignored: this server has no image input]"
431
+ text = f"{text}\n{note}".strip()
432
+
433
+ turn = _TurnState(session_id=session_id)
434
+ self._turns[session_id] = turn
435
+
436
+ def on_event(name: str, payload: dict[str, Any]) -> None:
437
+ self._translate_event(state, turn, name, payload)
438
+
439
+ def confirm(summary: str, preview: str | None) -> bool:
440
+ return self._request_permission_sync(turn, state, summary)
441
+
442
+ try:
443
+ outcome = await asyncio.to_thread(
444
+ self._run_turn_thread, state, turn, text, confirm, on_event
445
+ )
446
+ finally:
447
+ if self._turns.get(session_id) is turn:
448
+ self._turns.pop(session_id, None)
449
+
450
+ # The worker thread schedules notifications onto this loop; wait for
451
+ # them so updates always precede the prompt's response.
452
+ await self._flush_updates(turn)
453
+
454
+ if turn.cancelled:
455
+ return {"stopReason": "cancelled"}
456
+ # Some paths (budget stop, provider error before text) never stream a
457
+ # delta; surface their text as the one agent message chunk.
458
+ if not turn.saw_text and outcome is not None and outcome.text:
459
+ await self._emit_update(
460
+ session_id,
461
+ {
462
+ "sessionUpdate": "agent_message_chunk",
463
+ "content": {"type": "text", "text": outcome.text},
464
+ },
465
+ )
466
+ return {"stopReason": self._stop_reason(turn, outcome)}
467
+
468
+ async def _cancel(self, params: dict[str, Any]) -> None:
469
+ session_id = str(params.get("sessionId") or "")
470
+ turn = self._turns.get(session_id)
471
+ if turn is not None:
472
+ self._cancel_turn(turn)
473
+
474
+ def _cancel_turn(self, turn: _TurnState) -> None:
475
+ turn.cancelled = True
476
+ # A thread blocked waiting for the editor's permission answer is woken
477
+ # with a denial so cancellation is never stuck behind that wait.
478
+ self._resolve_pending_for_session(
479
+ turn.session_id, {"outcome": {"outcome": "cancelled"}}
480
+ )
481
+ state = self._sessions.get(turn.session_id)
482
+ if state is not None and state.in_progress:
483
+ for call_id, tool_name in state.in_progress:
484
+ self._emit_update_threadsafe(
485
+ turn,
486
+ {
487
+ "sessionUpdate": "tool_call_update",
488
+ "toolCallId": call_id,
489
+ "status": "failed",
490
+ "content": [
491
+ {
492
+ "type": "content",
493
+ "content": {
494
+ "type": "text",
495
+ "text": f"{tool_name} cancelled",
496
+ },
497
+ }
498
+ ],
499
+ },
500
+ )
501
+ state.in_progress.clear()
502
+ loop, task = turn.loop, turn.task
503
+ if loop is not None and task is not None:
504
+ try:
505
+ loop.call_soon_threadsafe(task.cancel)
506
+ except RuntimeError:
507
+ pass
508
+
509
+ def _resolve_pending_for_session(
510
+ self, session_id: str, result: dict[str, Any]
511
+ ) -> None:
512
+ for request_id, pending in list(self._pending.items()):
513
+ if (
514
+ pending.method == "session/request_permission"
515
+ and pending.session_id == session_id
516
+ ):
517
+ if not pending.future.done():
518
+ pending.future.set_result(result)
519
+ self._pending.pop(request_id, None)
520
+
521
+ # -- turn execution (worker thread) -------------------------------------
522
+
523
+ def _run_turn_thread(
524
+ self,
525
+ state: _SessionState,
526
+ turn: _TurnState,
527
+ text: str,
528
+ confirm: Callable[[str, str | None], bool],
529
+ on_event: Callable[[str, dict[str, Any]], None],
530
+ ) -> Any:
531
+ """Run one ``run_agent_once`` turn on a dedicated event loop.
532
+
533
+ The agent's confirm callback is synchronous, so the turn runs on its
534
+ own loop in a worker thread while this server's loop stays free to
535
+ answer ``session/request_permission`` and ``session/cancel``.
536
+ """
537
+ loop = asyncio.new_event_loop()
538
+ turn.loop = loop
539
+ try:
540
+ asyncio.set_event_loop(loop)
541
+ task = loop.create_task(
542
+ run_agent_once(
543
+ text,
544
+ session=state.session,
545
+ confirm=confirm,
546
+ on_event=on_event,
547
+ render_text=False,
548
+ )
549
+ )
550
+ turn.task = task
551
+ if turn.cancelled:
552
+ task.cancel()
553
+ try:
554
+ return loop.run_until_complete(task)
555
+ except asyncio.CancelledError:
556
+ turn.cancelled = True
557
+ return None
558
+ finally:
559
+ turn.loop = None
560
+ turn.task = None
561
+ loop.close()
562
+
563
+ def _stop_reason(self, turn: _TurnState, outcome: Any) -> str:
564
+ if turn.cancelled:
565
+ return "cancelled"
566
+ if "max_turns" in turn.done_reasons or "budget" in turn.done_reasons:
567
+ return "max_tokens"
568
+ if outcome is None or not getattr(outcome, "success", False):
569
+ return "refusal"
570
+ return "end_turn"
571
+
572
+ # -- permission bridge --------------------------------------------------
573
+
574
+ def _request_permission_sync(
575
+ self, turn: _TurnState, state: _SessionState, summary: str
576
+ ) -> bool:
577
+ """Ask the editor to approve an action, denied on timeout/EOF."""
578
+ loop = self._loop
579
+ if loop is None or loop.is_closed():
580
+ return False
581
+ tool_name = summary.split("(", 1)[0].strip()
582
+ call_id = state.allocate_call_id()
583
+ state.pending_permission_id = call_id
584
+ params = {
585
+ "sessionId": turn.session_id,
586
+ "toolCall": {
587
+ "toolCallId": call_id,
588
+ "title": summary,
589
+ "kind": tool_kind(tool_name),
590
+ "status": "pending",
591
+ },
592
+ "options": PERMISSION_OPTIONS,
593
+ }
594
+ try:
595
+ future = asyncio.run_coroutine_threadsafe(
596
+ self._request(
597
+ "session/request_permission",
598
+ params,
599
+ session_id=turn.session_id,
600
+ timeout=self.permission_timeout,
601
+ ),
602
+ loop,
603
+ )
604
+ result = future.result(timeout=self.permission_timeout + 5.0)
605
+ except Exception:
606
+ state.pending_permission_id = None
607
+ return False
608
+
609
+ selected = _selected_option(result)
610
+ if selected is None:
611
+ state.pending_permission_id = None
612
+ return False
613
+ if selected == "allow_always" and tool_name:
614
+ try:
615
+ config.persist_always_allowed_tool(tool_name)
616
+ except OSError: # pragma: no cover - unwritable config dir
617
+ pass
618
+ state.session.allow_always(tool_name)
619
+ return True
620
+
621
+ # -- agent -> client plumbing -------------------------------------------
622
+
623
+ async def _request(
624
+ self,
625
+ method: str,
626
+ params: dict[str, Any],
627
+ *,
628
+ session_id: str | None = None,
629
+ timeout: float | None = None,
630
+ ) -> Any:
631
+ loop = self._loop
632
+ assert loop is not None
633
+ request_id = self._ids.next()
634
+ future: asyncio.Future[Any] = loop.create_future()
635
+ self._pending[request_id] = _PendingRequest(
636
+ future=future, method=method, session_id=session_id
637
+ )
638
+ try:
639
+ await self._send(request_message(request_id, method, params))
640
+ try:
641
+ return await asyncio.wait_for(future, timeout)
642
+ except asyncio.TimeoutError:
643
+ raise AcpTimeoutError(
644
+ f"{method} timed out after {timeout:g}s"
645
+ ) from None
646
+ finally:
647
+ self._pending.pop(request_id, None)
648
+
649
+ def _resolve_response(self, message: dict[str, Any]) -> None:
650
+ request_id = message.get("id")
651
+ if not isinstance(request_id, int):
652
+ return
653
+ pending = self._pending.get(request_id)
654
+ if pending is None or pending.future.done():
655
+ return
656
+ error = message.get("error")
657
+ if isinstance(error, dict):
658
+ code = error.get("code")
659
+ pending.future.set_exception(
660
+ AcpError(
661
+ code if isinstance(code, int) else ERROR_INTERNAL,
662
+ str(error.get("message") or "Client request failed"),
663
+ error.get("data"),
664
+ )
665
+ )
666
+ return
667
+ pending.future.set_result(message.get("result"))
668
+
669
+ def _translate_event(
670
+ self, state: _SessionState, turn: _TurnState, name: str, payload: dict[str, Any]
671
+ ) -> None:
672
+ """Forward one agent render event to the client as ``session/update``."""
673
+ if name == "text_delta":
674
+ text = str(payload.get("text") or "")
675
+ if text:
676
+ turn.saw_text = True
677
+ self._emit_update_threadsafe(
678
+ turn,
679
+ {
680
+ "sessionUpdate": "agent_message_chunk",
681
+ "content": {"type": "text", "text": text},
682
+ },
683
+ )
684
+ elif name == "tool_start":
685
+ tool_name = str(payload.get("tool") or "")
686
+ args = payload.get("args")
687
+ call_id, title, kind = state.begin_tool(
688
+ tool_name, args if isinstance(args, dict) else {}
689
+ )
690
+ self._emit_update_threadsafe(
691
+ turn,
692
+ {
693
+ "sessionUpdate": "tool_call",
694
+ "toolCallId": call_id,
695
+ "title": title,
696
+ "kind": kind,
697
+ "status": "in_progress",
698
+ },
699
+ )
700
+ elif name == "tool_end":
701
+ tool_name = str(payload.get("tool") or "")
702
+ finished_id = state.end_tool(tool_name)
703
+ if finished_id is not None:
704
+ ok = bool(payload.get("ok"))
705
+ duration = payload.get("duration_ms")
706
+ detail = f"{tool_name} {'completed' if ok else 'failed'}"
707
+ if isinstance(duration, int):
708
+ detail += f" ({duration}ms)"
709
+ self._emit_update_threadsafe(
710
+ turn,
711
+ {
712
+ "sessionUpdate": "tool_call_update",
713
+ "toolCallId": finished_id,
714
+ "status": "completed" if ok else "failed",
715
+ "content": [
716
+ {
717
+ "type": "content",
718
+ "content": {"type": "text", "text": detail},
719
+ }
720
+ ],
721
+ },
722
+ )
723
+ elif name == "done":
724
+ turn.done_reasons.append(str(payload.get("reason") or ""))
725
+
726
+ def _emit_update_threadsafe(
727
+ self, turn: _TurnState, update: dict[str, Any]
728
+ ) -> None:
729
+ loop = self._loop
730
+ if loop is None or loop.is_closed():
731
+ return
732
+ try:
733
+ future = asyncio.run_coroutine_threadsafe(
734
+ self._emit_update(turn.session_id, update), loop
735
+ )
736
+ except RuntimeError:
737
+ return
738
+ turn.pending_updates.append(future)
739
+ future.add_done_callback(_consume_future_exception)
740
+
741
+ async def _flush_updates(self, turn: _TurnState) -> None:
742
+ """Await every ``session/update`` scheduled during the turn."""
743
+ pending, turn.pending_updates = list(turn.pending_updates), []
744
+ if not pending:
745
+ return
746
+ await asyncio.gather(
747
+ *(asyncio.wrap_future(future) for future in pending),
748
+ return_exceptions=True,
749
+ )
750
+
751
+ async def _emit_update(self, session_id: str, update: dict[str, Any]) -> None:
752
+ await self._send(
753
+ notification_message(
754
+ "session/update", {"sessionId": session_id, "update": update}
755
+ )
756
+ )
757
+
758
+ async def _send(self, message: dict[str, Any]) -> None:
759
+ if self._write_line is None:
760
+ return
761
+ line = encode(message)
762
+ lock = self._write_lock
763
+ if lock is None:
764
+ await self._write(line)
765
+ return
766
+ async with lock:
767
+ await self._write(line)
768
+
769
+ async def _write(self, line: str) -> None:
770
+ if self._write_line is None:
771
+ return
772
+ result = self._write_line(line)
773
+ if inspect.isawaitable(result):
774
+ await result
775
+
776
+ # -- optional client filesystem delegation ------------------------------
777
+
778
+ async def read_text_file(self, session_id: str, path: str) -> str:
779
+ """Read a file, delegating to the client when it advertises fs reads.
780
+
781
+ Falls back to the local filesystem otherwise. This is an opt-in layer
782
+ for hosts that call it; the built-in agent tools always operate on the
783
+ local filesystem.
784
+ """
785
+ if self._fs_read and session_id in self._sessions:
786
+ result = await self._request(
787
+ "fs/read_text_file",
788
+ {"sessionId": session_id, "path": path},
789
+ session_id=session_id,
790
+ timeout=self.permission_timeout,
791
+ )
792
+ content = result.get("content") if isinstance(result, dict) else None
793
+ return str(content) if content is not None else ""
794
+ return Path(path).read_text(encoding="utf-8")
795
+
796
+ async def write_text_file(self, session_id: str, path: str, content: str) -> None:
797
+ """Write a file, delegating to the client when it advertises fs writes."""
798
+ if self._fs_write and session_id in self._sessions:
799
+ await self._request(
800
+ "fs/write_text_file",
801
+ {"sessionId": session_id, "path": path, "content": content},
802
+ session_id=session_id,
803
+ timeout=self.permission_timeout,
804
+ )
805
+ return
806
+ target = Path(path)
807
+ target.parent.mkdir(parents=True, exist_ok=True)
808
+ target.write_text(content, encoding="utf-8")
809
+
810
+
811
+ def _selected_option(result: Any) -> str | None:
812
+ """Return the selected option id when the client approved the action."""
813
+ if not isinstance(result, dict):
814
+ return None
815
+ outcome = result.get("outcome")
816
+ if not isinstance(outcome, dict):
817
+ return None
818
+ if outcome.get("outcome") != "selected":
819
+ return None
820
+ option_id = outcome.get("optionId")
821
+ if option_id in {"allow_once", "allow_always"}:
822
+ return str(option_id)
823
+ return None