coding-agent-cli 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.
@@ -0,0 +1,333 @@
1
+ """Pi CLI driver using Pi's non-interactive JSON event stream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .._classify import keyword_code
11
+ from .._process import ProcessOutcome, run_process
12
+ from ..events import SessionIdentified, TextDelta, ToolCompleted, UsageUpdated
13
+ from ..registry import EventHandler, SpawnHandler
14
+ from ..types import Diagnostic, Request, Result
15
+ from ._common import (
16
+ CliDriver,
17
+ Diagnostics,
18
+ EventBuffer,
19
+ build_result,
20
+ deadline_for,
21
+ parse_json_object,
22
+ process_facts,
23
+ read_usage,
24
+ tool_started,
25
+ tool_status,
26
+ )
27
+ from ._usage import TokenCounters
28
+
29
+ MAX_DIAGNOSTICS = 50
30
+ _PI_USAGE_KEYS = {
31
+ "input_key": "input",
32
+ "output_key": "output",
33
+ "cache_read_key": "cacheRead",
34
+ "cache_write_key": "cacheWrite",
35
+ }
36
+
37
+
38
+ def _message_text(message: Any) -> str:
39
+ """Extract text from Pi's AssistantMessage content representation."""
40
+ if not isinstance(message, dict) or message.get("role") != "assistant":
41
+ return ""
42
+ content = message.get("content", "")
43
+ if isinstance(content, str):
44
+ return content
45
+ if not isinstance(content, list):
46
+ return ""
47
+ parts: list[str] = []
48
+ for block in content:
49
+ if isinstance(block, str):
50
+ parts.append(block)
51
+ elif isinstance(block, dict) and block.get("type") == "text":
52
+ text = block.get("text", "")
53
+ if isinstance(text, str):
54
+ parts.append(text)
55
+ return "".join(parts)
56
+
57
+
58
+ def _assistant_model(message: Any) -> str | None:
59
+ if isinstance(message, dict) and message.get("role") == "assistant":
60
+ model = message.get("model")
61
+ if isinstance(model, str) and model:
62
+ return model
63
+ return None
64
+
65
+
66
+ def _event_model(event: dict[str, Any]) -> str | None:
67
+ """Return the model named by one event: its assistant message, else its last assistant message."""
68
+ model = _assistant_model(event.get("message"))
69
+ if model:
70
+ return model
71
+ messages = event.get("messages")
72
+ if isinstance(messages, list):
73
+ for m in reversed(messages):
74
+ model = _assistant_model(m)
75
+ if model:
76
+ return model
77
+ return None
78
+
79
+
80
+ def _tool_call_id(event: dict[str, Any]) -> str:
81
+ return str(event.get("toolCallId") or event.get("tool_call_id") or event.get("id") or "")
82
+
83
+
84
+ class PiRun:
85
+ """Parse one Pi run line by line. Raw lines are never retained.
86
+
87
+ Diagnostics keep the first ``MAX_DIAGNOSTICS`` lines, plus the first line for each CLI error code
88
+ beyond that bound, so the classification matches an unbounded run. Response IDs are kept only for
89
+ usage de-duplication.
90
+ """
91
+
92
+ def __init__(self, sink: EventHandler) -> None:
93
+ self.events = EventBuffer(sink)
94
+ self.diagnostics = Diagnostics(MAX_DIAGNOSTICS)
95
+ self.counters = TokenCounters()
96
+ self.session_id = ""
97
+ self.agent_message = ""
98
+ self.structured_error = ""
99
+ self.model: str | None = None
100
+ self.tool_counter = 0
101
+ self.raw_to_entity: dict[str, str] = {}
102
+ self.recorded_responses: set[str] = set()
103
+ self.turn_counter = 0
104
+ self.turn_completed_in_current_turn = False
105
+
106
+ async def feed(self, line: str) -> None:
107
+ stripped = line.strip()
108
+ event = parse_json_object(stripped)
109
+ if event is None:
110
+ if stripped:
111
+ self._keep_diagnostic(stripped)
112
+ else:
113
+ model = _event_model(event)
114
+ if model:
115
+ self.model = model
116
+ self._output(event)
117
+ self._telemetry(event)
118
+ await self.events.flush()
119
+
120
+ def finish(self, process: ProcessOutcome, cancel: asyncio.Event, requested_session: str | None) -> Result:
121
+ """Map the parsed stream and the transport outcome to a result."""
122
+ facts = process_facts(process, cancel)
123
+ facts.text = self.agent_message
124
+ if self.structured_error:
125
+ facts.cli_error = Diagnostic(code="execution_error", message=self.structured_error)
126
+ facts.session_id = self.session_id or requested_session or ""
127
+ facts.diagnostics = self.diagnostics.text()
128
+ return build_result(facts, model=self.model, usage=self.counters.usage())
129
+
130
+ def _keep_diagnostic(self, text: str) -> None:
131
+ code = keyword_code(text)
132
+ self.diagnostics.add(text, marks=(code,) if code else ())
133
+
134
+ def _identify(self, value: str) -> None:
135
+ if value and value != self.session_id:
136
+ self.session_id = value
137
+ self.events.emit(SessionIdentified(session_id=value))
138
+
139
+ def _record_message_error(self, message: Any) -> None:
140
+ if not isinstance(message, dict) or message.get("stopReason") != "error":
141
+ return
142
+ error = message.get("errorMessage")
143
+ if isinstance(error, str) and error:
144
+ self._keep_diagnostic(error)
145
+ self.structured_error = error
146
+ else:
147
+ self.structured_error = "error"
148
+
149
+ def _message_end(self, message: Any) -> None:
150
+ text = _message_text(message)
151
+ if text:
152
+ self.agent_message = text
153
+ self._record_message_error(message)
154
+
155
+ def _agent_end(self, messages: Any) -> None:
156
+ if not isinstance(messages, list):
157
+ return
158
+ for message in reversed(messages):
159
+ if not self.structured_error:
160
+ self._record_message_error(message)
161
+ text = _message_text(message)
162
+ if text:
163
+ self.agent_message = text
164
+ break
165
+
166
+ def _output(self, event: dict[str, Any]) -> None:
167
+ """Collect the final reply, session ID, and error diagnostics from one event."""
168
+ event_type = event.get("type")
169
+ if event_type == "session" and isinstance(event.get("id"), str):
170
+ self._identify(event["id"])
171
+ if event_type in {"message_end", "message"}:
172
+ self._message_end(event.get("message"))
173
+ if event_type == "agent_end":
174
+ self._agent_end(event.get("messages"))
175
+ if event_type in {"error", "server_error"}:
176
+ error = event.get("error") or event.get("message")
177
+ if isinstance(error, str) and error:
178
+ self._keep_diagnostic(error)
179
+
180
+ def _telemetry(self, event: dict[str, Any]) -> None:
181
+ event_type = event.get("type", "")
182
+ if event_type == "message_update":
183
+ self._message_update(event)
184
+ elif event_type == "text_delta":
185
+ text = event.get("text")
186
+ self._emit_text(text if isinstance(text, str) else event.get("delta"))
187
+ elif event_type in {"tool_execution_start", "tool_call"}:
188
+ self._start_tool(event)
189
+ elif event_type in {"tool_execution_end", "tool_result"}:
190
+ self._complete_tool(event)
191
+
192
+ if event_type == "turn_start":
193
+ self.turn_counter += 1
194
+ self.turn_completed_in_current_turn = False
195
+ elif event_type in ("message_end", "turn_end"):
196
+ self._message_usage(event.get("message"))
197
+ elif event_type in ("agent_end", "summary"):
198
+ self._summary_usage(event.get("usage") or event.get("totalUsage"))
199
+
200
+ def _emit_text(self, text: Any) -> None:
201
+ if isinstance(text, str) and text:
202
+ self.events.emit(TextDelta(text=text, entity_id=f"msg-{self.turn_counter or 1}"))
203
+
204
+ def _message_update(self, event: dict[str, Any]) -> None:
205
+ update = event.get("assistantMessageEvent")
206
+ if not isinstance(update, dict) or update.get("type") != "text_delta":
207
+ return
208
+ delta = update.get("delta")
209
+ self._emit_text(delta if isinstance(delta, str) else update.get("text"))
210
+
211
+ def _start_tool(self, event: dict[str, Any]) -> None:
212
+ self.tool_counter += 1
213
+ entity_id = f"tool-{self.tool_counter}"
214
+ raw_id = _tool_call_id(event)
215
+ if raw_id:
216
+ self.raw_to_entity[raw_id] = entity_id
217
+ tool_name = str(event.get("toolName") or event.get("tool_name") or event.get("tool") or event.get("name") or "")
218
+ self.events.emit(tool_started(entity_id, "pi", tool_name, tool_input=event.get("args")))
219
+
220
+ def _complete_tool(self, event: dict[str, Any]) -> None:
221
+ entity_id = self.raw_to_entity.pop(_tool_call_id(event), None)
222
+ if entity_id is None:
223
+ return
224
+ is_error = event.get("isError")
225
+ status = "error" if is_error is True else "completed" if is_error is False else tool_status(event.get("status"))
226
+ self.events.emit(ToolCompleted(status=status, entity_id=entity_id))
227
+
228
+ def _response_id(self, msg: dict[str, Any]) -> str | None:
229
+ """Return the ID to record this assistant message under, or None when it was already counted."""
230
+ resp_id = str(msg.get("responseId") or msg.get("id") or "")
231
+ if resp_id:
232
+ return None if resp_id in self.recorded_responses else resp_id
233
+ if self.turn_counter == 0:
234
+ self.turn_counter = 1
235
+ self.turn_completed_in_current_turn = False
236
+ elif self.turn_completed_in_current_turn:
237
+ return None
238
+ assigned_id = f"turn_{self.turn_counter}"
239
+ return None if assigned_id in self.recorded_responses else assigned_id
240
+
241
+ def _message_usage(self, msg: Any) -> None:
242
+ if not isinstance(msg, dict) or msg.get("role") != "assistant":
243
+ return
244
+ assigned_id = self._response_id(msg)
245
+ if assigned_id is None:
246
+ return
247
+
248
+ self.recorded_responses.add(assigned_id)
249
+ self.turn_completed_in_current_turn = True
250
+
251
+ u = msg.get("usage")
252
+ if not isinstance(u, dict):
253
+ return
254
+ tok = read_usage(u, **_PI_USAGE_KEYS)
255
+ if not tok.observed:
256
+ return
257
+ self.counters.record_response(
258
+ assigned_id,
259
+ input=tok.input,
260
+ output=tok.output,
261
+ cache_read=tok.cache_read,
262
+ cache_write=tok.cache_write,
263
+ )
264
+ if tok.input is not None:
265
+ self.counters.record_context(used=tok.input + (tok.output or 0))
266
+ self.events.emit(UsageUpdated(usage=self.counters.usage()))
267
+
268
+ def _summary_usage(self, u: Any) -> None:
269
+ if not isinstance(u, dict):
270
+ return
271
+ tok = read_usage(u, **_PI_USAGE_KEYS)
272
+ if not tok.observed:
273
+ return
274
+ self.counters.record_totals(
275
+ input=tok.input,
276
+ output=tok.output,
277
+ cache_read=tok.cache_read,
278
+ cache_write=tok.cache_write,
279
+ )
280
+ self.events.emit(UsageUpdated(usage=self.counters.usage()))
281
+
282
+
283
+ class PiDriver(CliDriver):
284
+ """Runs ``pi --mode json`` with the prompt on stdin. The driver never adds an approval flag."""
285
+
286
+ name = "pi"
287
+ executable = "pi"
288
+ owned_flags = ("--mode", "--session")
289
+ portable_flags = {
290
+ "system_prompt": ("--system-prompt",),
291
+ "model": ("--model",),
292
+ "reasoning": ("--thinking",),
293
+ }
294
+ help_markers = ("--mode", "--session", "--thinking")
295
+
296
+ def _argv(self, executable: Path, req: Request) -> list[str]:
297
+ argv = [os.fspath(executable), "--mode", "json"]
298
+ if req.session_id:
299
+ argv.extend(["--session", req.session_id])
300
+ argv.extend(self.portable_argv(req))
301
+ argv.extend(req.args)
302
+ return argv
303
+
304
+ async def run(
305
+ self,
306
+ req: Request,
307
+ *,
308
+ on_event: EventHandler,
309
+ cancel: asyncio.Event,
310
+ on_spawn: SpawnHandler | None,
311
+ ) -> Result:
312
+ prepared = self.preflight(req, cancel)
313
+ if isinstance(prepared, Result):
314
+ return prepared
315
+ cwd, executable = prepared
316
+ self.log.info("run start resume=%s prompt_len=%d", bool(req.session_id), len(req.prompt))
317
+ run = PiRun(on_event)
318
+ process = await run_process(
319
+ self._argv(executable, req),
320
+ req.prompt,
321
+ dict(req.env),
322
+ os.fspath(cwd),
323
+ deadline_for(req),
324
+ cancel,
325
+ run.feed,
326
+ on_spawn=on_spawn,
327
+ )
328
+ result = run.finish(process, cancel, req.session_id)
329
+ self.log.info("run done outcome=%s", result.outcome)
330
+ return result
331
+
332
+
333
+ __all__ = ["MAX_DIAGNOSTICS", "PiDriver", "PiRun"]
@@ -0,0 +1,171 @@
1
+ """Cross-platform launch and process-tree kill helpers.
2
+
3
+ Agent CLIs often start child processes (an npm ``.cmd`` launcher starts Node on
4
+ Windows). Each CLI runs in its own process group so the whole tree can be killed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ import shutil
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from collections.abc import Mapping
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ TERM_GRACE_S = 1.0
21
+ _POLL_S = 0.05
22
+ _TASKKILL_TIMEOUT_S = 5.0
23
+
24
+ if sys.platform == "win32":
25
+ from ctypes import WinDLL, c_void_p, wintypes
26
+
27
+ _PROCESS_TERMINATE = 0x0001
28
+ _PROCESS_SET_QUOTA = 0x0100
29
+ _kernel32 = WinDLL("kernel32", use_last_error=True)
30
+ _kernel32.CreateJobObjectW.argtypes = (c_void_p, wintypes.LPCWSTR)
31
+ _kernel32.CreateJobObjectW.restype = wintypes.HANDLE
32
+ _kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
33
+ _kernel32.OpenProcess.restype = wintypes.HANDLE
34
+ _kernel32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
35
+ _kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
36
+ _kernel32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
37
+ _kernel32.TerminateJobObject.restype = wintypes.BOOL
38
+ _kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
39
+ _kernel32.CloseHandle.restype = wintypes.BOOL
40
+
41
+ # Windows job object handle per tracked process ID.
42
+ _jobs: dict[int, int] = {}
43
+
44
+
45
+ def prepare_command(command: list[str]) -> list[str]:
46
+ """Use a safe native launcher for a resolved command.
47
+
48
+ npm exposes both ``.cmd`` and ``.ps1`` shims on Windows. Passing untrusted
49
+ prompt text through a batch shim lets ``cmd.exe`` interpret characters such
50
+ as ``&`` and ``%``. Prefer the companion PowerShell shim, whose ``-File``
51
+ arguments retain their literal values.
52
+ """
53
+ if os.name != "nt" or not command:
54
+ return command
55
+ executable = Path(command[0])
56
+ if executable.suffix.lower() not in {".bat", ".cmd"}:
57
+ return command
58
+ powershell_shim = executable.with_suffix(".ps1")
59
+ powershell = shutil.which("pwsh") or shutil.which("powershell")
60
+ if not powershell_shim.is_file() or powershell is None:
61
+ raise OSError(
62
+ f"Windows batch launcher {executable} requires a matching .ps1 shim "
63
+ "and PowerShell so arguments can be passed safely"
64
+ )
65
+ return [
66
+ powershell,
67
+ "-NoLogo",
68
+ "-NoProfile",
69
+ "-NonInteractive",
70
+ "-ExecutionPolicy",
71
+ "Bypass",
72
+ "-File",
73
+ os.fspath(powershell_shim),
74
+ *command[1:],
75
+ ]
76
+
77
+
78
+ def process_group_kwargs() -> Mapping[str, Any]:
79
+ """Return subprocess options for a new, independently killable group."""
80
+ if os.name == "nt":
81
+ return {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
82
+ return {"start_new_session": True}
83
+
84
+
85
+ def track_process_tree(process_id: int) -> None:
86
+ """Put a Windows process in a job object so descendants it orphans can still be killed.
87
+
88
+ ``taskkill /T`` finds descendants through their parent, so it misses a grandchild
89
+ whose parent already exited. A job holds every descendant started after this call.
90
+ No-op on POSIX, where the process group covers this.
91
+ """
92
+ if sys.platform != "win32":
93
+ return
94
+ job = _kernel32.CreateJobObjectW(None, None)
95
+ if not job:
96
+ return
97
+ process = _kernel32.OpenProcess(_PROCESS_SET_QUOTA | _PROCESS_TERMINATE, False, process_id)
98
+ assigned = bool(process) and bool(_kernel32.AssignProcessToJobObject(job, process))
99
+ if process:
100
+ _kernel32.CloseHandle(process)
101
+ if assigned:
102
+ _jobs[process_id] = job
103
+ else:
104
+ _kernel32.CloseHandle(job)
105
+
106
+
107
+ def _taskkill(process_id: int) -> None:
108
+ taskkill = shutil.which("taskkill")
109
+ if taskkill is None:
110
+ return
111
+ try:
112
+ subprocess.run(
113
+ [taskkill, "/PID", str(process_id), "/T", "/F"],
114
+ stdin=subprocess.DEVNULL,
115
+ stdout=subprocess.DEVNULL,
116
+ stderr=subprocess.DEVNULL,
117
+ check=False,
118
+ shell=False,
119
+ timeout=_TASKKILL_TIMEOUT_S,
120
+ )
121
+ except (OSError, subprocess.SubprocessError):
122
+ return
123
+
124
+
125
+ def _group_exists(group_id: int) -> bool:
126
+ assert sys.platform != "win32"
127
+ try:
128
+ os.killpg(group_id, 0)
129
+ except ProcessLookupError:
130
+ return False
131
+ except PermissionError:
132
+ return True
133
+ return True
134
+
135
+
136
+ def _signal_group(group_id: int, sig: int) -> bool:
137
+ """Send ``sig`` to the group. Return False when the group is gone."""
138
+ assert sys.platform != "win32"
139
+ try:
140
+ os.killpg(group_id, sig)
141
+ except ProcessLookupError:
142
+ return False
143
+ except OSError:
144
+ return True
145
+ return True
146
+
147
+
148
+ async def kill_process_tree(process_id: int, *, grace_s: float = TERM_GRACE_S) -> None:
149
+ """Kill a POSIX process group or a Windows process tree without blocking the loop.
150
+
151
+ POSIX sends SIGTERM, waits up to ``grace_s`` for the group to go away, then sends
152
+ SIGKILL. Windows terminates the tracked job, or runs ``taskkill /T /F`` in a thread.
153
+ """
154
+ if sys.platform == "win32":
155
+ job = _jobs.pop(process_id, None)
156
+ if job is None:
157
+ await asyncio.to_thread(_taskkill, process_id)
158
+ return
159
+ _kernel32.TerminateJobObject(job, 1)
160
+ _kernel32.CloseHandle(job)
161
+ return
162
+ if not _signal_group(process_id, signal.SIGTERM):
163
+ return
164
+ deadline = time.monotonic() + max(0.0, grace_s)
165
+ while _group_exists(process_id) and time.monotonic() < deadline: # noqa: ASYNC110 - no event signals group exit
166
+ await asyncio.sleep(_POLL_S)
167
+ if _group_exists(process_id):
168
+ _signal_group(process_id, signal.SIGKILL)
169
+
170
+
171
+ __all__ = ["kill_process_tree", "prepare_command", "process_group_kwargs", "track_process_tree"]