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.
- coding_agent_cli/__init__.py +47 -0
- coding_agent_cli/_args.py +66 -0
- coding_agent_cli/_classify.py +170 -0
- coding_agent_cli/_drivers/__init__.py +1 -0
- coding_agent_cli/_drivers/_common.py +330 -0
- coding_agent_cli/_drivers/_usage.py +77 -0
- coding_agent_cli/_drivers/agy.py +340 -0
- coding_agent_cli/_drivers/claude.py +304 -0
- coding_agent_cli/_drivers/codex.py +333 -0
- coding_agent_cli/_drivers/codex_rollout.py +439 -0
- coding_agent_cli/_drivers/pi.py +333 -0
- coding_agent_cli/_platform.py +171 -0
- coding_agent_cli/_process.py +338 -0
- coding_agent_cli/api.py +190 -0
- coding_agent_cli/events.py +109 -0
- coding_agent_cli/py.typed +0 -0
- coding_agent_cli/registry.py +67 -0
- coding_agent_cli/types.py +90 -0
- coding_agent_cli-0.1.0.dist-info/METADATA +217 -0
- coding_agent_cli-0.1.0.dist-info/RECORD +22 -0
- coding_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- coding_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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"]
|