monkeyscode 1.0.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,730 @@
1
+ """
2
+ Drive an installed ``mc`` over stdio (the local agent, not the cloud API).
3
+
4
+ Spawns ``mc -p --input-format stream-json --output-format stream-json
5
+ --permission-prompt stdio`` and speaks the same NDJSON the CLI prints
6
+ (``schema_version`` 1). Hooks, MCP servers, settings and permission rules
7
+ all apply, because the real CLI runs them.
8
+
9
+ One-shot::
10
+
11
+ from monkeyscode.cli_process import query
12
+
13
+ async for event in query("explain src/main.ts"):
14
+ if isinstance(event, ResultMessage):
15
+ print(event.result)
16
+
17
+ Multi-turn with approvals::
18
+
19
+ async def can_use_tool(tool, tool_input, ctx):
20
+ if tool == "create_file" and tool_input.get("path", "").startswith("docs/"):
21
+ return PermissionAllow()
22
+ return PermissionDeny(message="only docs/ may be written")
23
+
24
+ async with MonkeysCodeClient(CliOptions(can_use_tool=can_use_tool)) as client:
25
+ await client.send("write docs/intro.md")
26
+ async for event in client.receive_turn():
27
+ ...
28
+ await client.send("now write src/x.ts")
29
+ result = await client.receive_result()
30
+
31
+ Standard library only. The TypeScript (``@monkeyscode/sdk/subprocess``) and
32
+ Go (``monkeyscode.Query``) clients implement the same protocol.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import contextlib
39
+ import json
40
+ import os
41
+ import shutil
42
+ import tempfile
43
+ from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Mapping
44
+ from dataclasses import dataclass, field
45
+ from typing import Any, Literal
46
+
47
+ SUPPORTED_SCHEMA_VERSION = 1
48
+
49
+ # ── Events ───────────────────────────────────────────────────────────
50
+
51
+
52
+ @dataclass
53
+ class TextBlock:
54
+ text: str
55
+ type: Literal["text"] = "text"
56
+
57
+
58
+ @dataclass
59
+ class ToolUseBlock:
60
+ id: str
61
+ name: str
62
+ input: dict[str, Any]
63
+ type: Literal["tool_use"] = "tool_use"
64
+
65
+
66
+ @dataclass
67
+ class ToolResultBlock:
68
+ tool_use_id: str
69
+ content: str
70
+ is_error: bool
71
+ type: Literal["tool_result"] = "tool_result"
72
+
73
+
74
+ @dataclass
75
+ class PermissionDenial:
76
+ tool_name: str
77
+ tool_input: dict[str, Any]
78
+ reason: str
79
+ tool_use_id: str | None = None
80
+ suggested_rule: str | None = None
81
+
82
+
83
+ @dataclass
84
+ class Usage:
85
+ input_tokens: int = 0
86
+ output_tokens: int = 0
87
+ cached_tokens: int = 0
88
+ thinking_tokens: int = 0
89
+
90
+
91
+ @dataclass
92
+ class InitMessage:
93
+ session_id: str
94
+ schema_version: int
95
+ cwd: str
96
+ model: str
97
+ permission_mode: str
98
+ tools: list[str]
99
+ mcp_servers: list[dict[str, str]]
100
+ cli_version: str
101
+ resumed_from: str | None = None
102
+ forked_from: str | None = None
103
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
104
+ type: Literal["system"] = "system"
105
+ subtype: Literal["init"] = "init"
106
+
107
+
108
+ @dataclass
109
+ class AssistantMessage:
110
+ session_id: str
111
+ content: list[TextBlock | ToolUseBlock]
112
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
113
+ type: Literal["assistant"] = "assistant"
114
+
115
+ @property
116
+ def text(self) -> str:
117
+ return "".join(b.text for b in self.content if isinstance(b, TextBlock))
118
+
119
+
120
+ @dataclass
121
+ class UserMessage:
122
+ """A prompt (``content`` is a str) or tool output (a list of ToolResultBlock)."""
123
+
124
+ session_id: str
125
+ content: str | list[ToolResultBlock]
126
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
127
+ type: Literal["user"] = "user"
128
+
129
+
130
+ @dataclass
131
+ class ResultMessage:
132
+ subtype: str
133
+ is_error: bool
134
+ result: str
135
+ session_id: str
136
+ num_turns: int
137
+ duration_ms: int
138
+ usage: Usage
139
+ cost_usd: float
140
+ is_estimated: bool
141
+ permission_denials: list[PermissionDenial]
142
+ exit_code: int
143
+ structured: Any = None
144
+ error: str | None = None
145
+ schema_errors: list[str] | None = None
146
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
147
+ type: Literal["result"] = "result"
148
+
149
+
150
+ @dataclass
151
+ class SystemMessage:
152
+ """Informational ``system`` events (retrying, compacted, hook, warning, …)."""
153
+
154
+ subtype: str
155
+ session_id: str
156
+ data: dict[str, Any]
157
+ type: Literal["system"] = "system"
158
+
159
+
160
+ @dataclass
161
+ class ErrorMessage:
162
+ error: str
163
+ session_id: str | None = None
164
+ type: Literal["error"] = "error"
165
+
166
+
167
+ Event = (
168
+ InitMessage | AssistantMessage | UserMessage | ResultMessage | SystemMessage | ErrorMessage
169
+ )
170
+
171
+
172
+ def parse_event(e: Mapping[str, Any]) -> Event:
173
+ """Typed view of one stream-json line. Unknown fields are kept in ``raw``."""
174
+ t = e.get("type")
175
+ sid = str(e.get("session_id", ""))
176
+ if t == "system" and e.get("subtype") == "init":
177
+ return InitMessage(
178
+ session_id=sid,
179
+ schema_version=int(e.get("schema_version", 0)),
180
+ cwd=str(e.get("cwd", "")),
181
+ model=str(e.get("model", "")),
182
+ permission_mode=str(e.get("permission_mode", "")),
183
+ tools=list(e.get("tools", [])),
184
+ mcp_servers=list(e.get("mcp_servers", [])),
185
+ cli_version=str(e.get("cli_version", "")),
186
+ resumed_from=e.get("resumed_from"),
187
+ forked_from=e.get("forked_from"),
188
+ raw=dict(e),
189
+ )
190
+ if t == "assistant":
191
+ blocks: list[TextBlock | ToolUseBlock] = []
192
+ for b in e.get("message", {}).get("content", []):
193
+ if b.get("type") == "text":
194
+ blocks.append(TextBlock(text=b.get("text", "")))
195
+ elif b.get("type") == "tool_use":
196
+ blocks.append(
197
+ ToolUseBlock(id=b["id"], name=b["name"], input=dict(b.get("input", {}))),
198
+ )
199
+ return AssistantMessage(session_id=sid, content=blocks, raw=dict(e))
200
+ if t == "user":
201
+ c = e.get("message", {}).get("content", "")
202
+ if isinstance(c, list):
203
+ results = [
204
+ ToolResultBlock(
205
+ tool_use_id=b["tool_use_id"],
206
+ content=str(b.get("content", "")),
207
+ is_error=bool(b.get("is_error")),
208
+ )
209
+ for b in c
210
+ if b.get("type") == "tool_result"
211
+ ]
212
+ return UserMessage(session_id=sid, content=results, raw=dict(e))
213
+ return UserMessage(session_id=sid, content=str(c), raw=dict(e))
214
+ if t == "result":
215
+ u = e.get("usage", {}) or {}
216
+ return ResultMessage(
217
+ subtype=str(e.get("subtype", "")),
218
+ is_error=bool(e.get("is_error")),
219
+ result=str(e.get("result", "")),
220
+ session_id=sid,
221
+ num_turns=int(e.get("num_turns", 0)),
222
+ duration_ms=int(e.get("duration_ms", 0)),
223
+ usage=Usage(
224
+ input_tokens=int(u.get("input_tokens", 0)),
225
+ output_tokens=int(u.get("output_tokens", 0)),
226
+ cached_tokens=int(u.get("cached_tokens", 0)),
227
+ thinking_tokens=int(u.get("thinking_tokens", 0)),
228
+ ),
229
+ cost_usd=float(e.get("cost_usd", 0) or 0),
230
+ is_estimated=bool(e.get("is_estimated")),
231
+ permission_denials=[
232
+ PermissionDenial(
233
+ tool_name=d.get("tool_name", ""),
234
+ tool_input=dict(d.get("tool_input", {})),
235
+ reason=d.get("reason", ""),
236
+ tool_use_id=d.get("tool_use_id"),
237
+ suggested_rule=d.get("suggested_rule"),
238
+ )
239
+ for d in e.get("permission_denials", [])
240
+ ],
241
+ exit_code=int(e.get("exit_code", 0)),
242
+ structured=e.get("structured"),
243
+ error=e.get("error"),
244
+ schema_errors=e.get("schema_errors"),
245
+ raw=dict(e),
246
+ )
247
+ if t == "error":
248
+ return ErrorMessage(error=str(e.get("error", "")), session_id=e.get("session_id"))
249
+ data = {k: v for k, v in e.items() if k not in ("type", "subtype", "session_id")}
250
+ return SystemMessage(subtype=str(e.get("subtype", t or "")), session_id=sid, data=data)
251
+
252
+
253
+ # ── Permissions ──────────────────────────────────────────────────────
254
+
255
+
256
+ @dataclass
257
+ class PermissionAllow:
258
+ updated_input: dict[str, Any] | None = None
259
+ behavior: Literal["allow"] = "allow"
260
+
261
+
262
+ @dataclass
263
+ class PermissionDeny:
264
+ message: str | None = None
265
+ behavior: Literal["deny"] = "deny"
266
+
267
+
268
+ PermissionResult = PermissionAllow | PermissionDeny
269
+
270
+
271
+ @dataclass
272
+ class ToolPermissionContext:
273
+ description: str
274
+ suggestions: list[str]
275
+ tool_use_id: str | None = None
276
+
277
+
278
+ CanUseTool = Callable[[str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult]]
279
+
280
+ # ── Options ──────────────────────────────────────────────────────────
281
+
282
+
283
+ @dataclass
284
+ class CliOptions:
285
+ """Options for the CLI subprocess. ``None`` means "use the CLI's default"."""
286
+
287
+ #: ``mc`` executable, or argv for a wrapper (``["node", "cli.cjs"]``).
288
+ #: Default: ``$MC_PATH``, else ``mc`` on PATH.
289
+ path_to_mc: str | list[str] | None = None
290
+ cwd: str | None = None
291
+ model: str | None = None
292
+ permission_mode: Literal["default", "acceptEdits", "plan", "bypass"] | None = None
293
+ effort: Literal["low", "medium", "high"] | None = None
294
+ allowed_tools: list[str] | None = None
295
+ disallowed_tools: list[str] | None = None
296
+ add_dirs: list[str] | None = None
297
+ #: Approve every ordinary ask when no ``can_use_tool`` is given (deny rules still apply).
298
+ auto_approve: bool = False
299
+ can_use_tool: CanUseTool | None = None
300
+ permission_timeout_sec: float | None = None
301
+ mcp_config: list[str] | None = None
302
+ strict_mcp_config: bool = False
303
+ #: Ignore the repository's .monkeyscode settings, hooks and MCP servers (untrusted code).
304
+ ignore_repo_settings: bool = False
305
+ #: Extra settings layer: a dict (sent inline as JSON) or a file path.
306
+ settings: dict[str, Any] | str | None = None
307
+ append_system_prompt: str | None = None
308
+ system_prompt_file: str | None = None
309
+ max_cost_usd: float | None = None
310
+ max_tokens: int | None = None
311
+ #: JSON Schema for the final answer: a dict, or a path.
312
+ json_schema: dict[str, Any] | str | None = None
313
+ resume: str | None = None
314
+ continue_session: bool = False
315
+ fork_session: bool = False
316
+ no_hooks: bool = False
317
+ #: Wall-clock limit in ms (default: none).
318
+ timeout_ms: int | None = None
319
+ env: dict[str, str | None] | None = None
320
+ extra_args: list[str] | None = None
321
+ #: Called with each stderr line of the CLI.
322
+ stderr: Callable[[str], None] | None = None
323
+
324
+
325
+ class McError(Exception):
326
+ """Base class for subprocess-client errors."""
327
+
328
+
329
+ class McSchemaError(McError):
330
+ def __init__(self, schema_version: int, cli_version: str) -> None:
331
+ super().__init__(
332
+ f"mc {cli_version} speaks stream-json schema_version {schema_version}; this client "
333
+ f"supports {SUPPORTED_SCHEMA_VERSION}. "
334
+ "Upgrade the monkeyscode package (or install a matching mc).",
335
+ )
336
+ self.schema_version = schema_version
337
+ self.cli_version = cli_version
338
+
339
+
340
+ class McProcessError(McError):
341
+ def __init__(self, message: str, exit_code: int | None, stderr_tail: str = "") -> None:
342
+ super().__init__(f"{message}\n{stderr_tail}" if stderr_tail else message)
343
+ self.exit_code = exit_code
344
+ self.stderr_tail = stderr_tail
345
+
346
+
347
+ def build_args(o: CliOptions, write_json: Callable[[Any], str] | None = None) -> list[str]:
348
+ """CLI argv (no executable). Always an argv list, never a shell string."""
349
+ a = [
350
+ "-p",
351
+ "--input-format",
352
+ "stream-json",
353
+ "--output-format",
354
+ "stream-json",
355
+ "--permission-prompt",
356
+ "stdio",
357
+ ]
358
+
359
+ def push(flag: str, v: object) -> None:
360
+ if v is not None and v != "":
361
+ a.extend([flag, str(v)])
362
+
363
+ push("--workspace", o.cwd)
364
+ push("--model", o.model)
365
+ push("--permission-mode", o.permission_mode)
366
+ push("--effort", o.effort)
367
+ for flag, values in (
368
+ ("--allowed-tools", o.allowed_tools),
369
+ ("--disallowed-tools", o.disallowed_tools),
370
+ ("--add-dir", o.add_dirs),
371
+ ("--mcp-config", o.mcp_config),
372
+ ):
373
+ if values:
374
+ a.extend([flag, *values])
375
+ if o.strict_mcp_config:
376
+ a.append("--strict-mcp-config")
377
+ if o.ignore_repo_settings:
378
+ a.append("--ignore-repo-settings")
379
+ if o.settings is not None:
380
+ push("--settings", o.settings if isinstance(o.settings, str) else json.dumps(o.settings))
381
+ push("--append-system-prompt", o.append_system_prompt)
382
+ push("--system-prompt-file", o.system_prompt_file)
383
+ push("--max-cost", o.max_cost_usd)
384
+ push("--max-tokens", o.max_tokens)
385
+ if o.json_schema is not None:
386
+ path = (
387
+ o.json_schema
388
+ if isinstance(o.json_schema, str)
389
+ else (write_json or _write_temp_json)(o.json_schema)
390
+ )
391
+ push("--json-schema", path)
392
+ push("--resume", o.resume)
393
+ if o.continue_session:
394
+ a.append("--continue")
395
+ if o.fork_session:
396
+ a.append("--fork-session")
397
+ if o.no_hooks:
398
+ a.append("--no-hooks")
399
+ push("--permission-timeout", o.permission_timeout_sec)
400
+ a.extend(["--timeout", str(o.timeout_ms or 0)])
401
+ if o.extra_args:
402
+ a.extend(o.extra_args)
403
+ return a
404
+
405
+
406
+ _temp_dirs: list[str] = []
407
+
408
+
409
+ def _write_temp_json(v: Any) -> str:
410
+ d = tempfile.mkdtemp(prefix="mc-sdk-")
411
+ _temp_dirs.append(d)
412
+ p = os.path.join(d, "schema.json")
413
+ with open(p, "w", encoding="utf-8") as f:
414
+ json.dump(v, f)
415
+ return p
416
+
417
+
418
+ def _resolve_command(o: CliOptions) -> list[str]:
419
+ c = o.path_to_mc or os.environ.get("MC_PATH") or "mc"
420
+ if isinstance(c, list):
421
+ if not c:
422
+ raise ValueError("path_to_mc must not be empty")
423
+ return list(c)
424
+ found = shutil.which(c) if os.sep not in c else c
425
+ return [found or c]
426
+
427
+
428
+ # ── Client ───────────────────────────────────────────────────────────
429
+
430
+ _END = object()
431
+
432
+
433
+ class MonkeysCodeClient:
434
+ """
435
+ A long-lived ``mc`` session. Use as an async context manager.
436
+
437
+ ``send()`` queues a user message; ``receive()`` yields every event;
438
+ ``receive_turn()`` yields until (and including) the next result;
439
+ ``interrupt()`` stops the current turn; ``close()`` ends stdin and waits.
440
+ """
441
+
442
+ def __init__(self, options: CliOptions | None = None) -> None:
443
+ self.options = options or CliOptions()
444
+ self._proc: asyncio.subprocess.Process | None = None
445
+ self._events: asyncio.Queue[object] = asyncio.Queue()
446
+ self._tasks: list[asyncio.Task[None]] = []
447
+ self._stderr_tail = ""
448
+ self._interrupts = 0
449
+ self._sent = 0
450
+ self._results = 0
451
+ self._ending = False
452
+ self._stdin_closed = False
453
+ self._write_lock = asyncio.Lock()
454
+ self.session_id: str | None = None
455
+ self.last_result: ResultMessage | None = None
456
+ self.exit_code: int | None = None
457
+ self._error: BaseException | None = None
458
+
459
+ async def __aenter__(self) -> MonkeysCodeClient:
460
+ await self.connect()
461
+ return self
462
+
463
+ async def __aexit__(self, *exc: object) -> None:
464
+ await self.close()
465
+
466
+ async def connect(self) -> None:
467
+ cmd = _resolve_command(self.options)
468
+ argv = [*cmd, *build_args(self.options)]
469
+ env = dict(os.environ)
470
+ for k, v in (self.options.env or {}).items():
471
+ if v is None:
472
+ env.pop(k, None)
473
+ else:
474
+ env[k] = v
475
+ try:
476
+ self._proc = await asyncio.create_subprocess_exec(
477
+ *argv,
478
+ cwd=self.options.cwd,
479
+ env=env,
480
+ stdin=asyncio.subprocess.PIPE,
481
+ stdout=asyncio.subprocess.PIPE,
482
+ stderr=asyncio.subprocess.PIPE,
483
+ limit=16 * 1024 * 1024,
484
+ )
485
+ except (FileNotFoundError, PermissionError) as err:
486
+ raise McProcessError(
487
+ f"could not start {cmd[0]}: {err}. Install the CLI "
488
+ "(npm i -g monkeyscode-cli) or set path_to_mc / MC_PATH.",
489
+ None,
490
+ ) from err
491
+ self._tasks = [
492
+ asyncio.create_task(self._read_stdout()),
493
+ asyncio.create_task(self._read_stderr()),
494
+ ]
495
+
496
+ # ── writing ────────────────────────────────────────────────
497
+
498
+ async def _write(self, obj: Mapping[str, Any]) -> None:
499
+ proc = self._proc
500
+ if proc is None or proc.stdin is None or self._stdin_closed:
501
+ return
502
+ async with self._write_lock:
503
+ try:
504
+ proc.stdin.write((json.dumps(obj) + "\n").encode())
505
+ await proc.stdin.drain()
506
+ except (BrokenPipeError, ConnectionResetError):
507
+ self._stdin_closed = True
508
+
509
+ async def send(self, message: str) -> None:
510
+ """Queue a user message."""
511
+ if self._ending:
512
+ raise McError("send() after end()")
513
+ self._sent += 1
514
+ await self._write({"type": "user", "message": {"role": "user", "content": message}})
515
+
516
+ async def interrupt(self) -> None:
517
+ """Stop the current turn (its result has subtype ``error_interrupted``)."""
518
+ self._interrupts += 1
519
+ await self._write(
520
+ {
521
+ "type": "control_request",
522
+ "request_id": f"int_{self._interrupts}",
523
+ "request": {"subtype": "interrupt"},
524
+ },
525
+ )
526
+
527
+ async def end(self) -> None:
528
+ """No more messages. stdin closes once every sent message has its result."""
529
+ self._ending = True
530
+ await self._maybe_close_stdin()
531
+
532
+ async def _maybe_close_stdin(self) -> None:
533
+ proc = self._proc
534
+ if (
535
+ self._ending
536
+ and not self._stdin_closed
537
+ and self._results >= self._sent
538
+ and proc
539
+ and proc.stdin
540
+ ):
541
+ self._stdin_closed = True
542
+ with contextlib.suppress(Exception):
543
+ proc.stdin.close()
544
+
545
+ # ── reading ────────────────────────────────────────────────
546
+
547
+ async def _answer(self, e: Mapping[str, Any]) -> None:
548
+ req = e.get("request") or {}
549
+ if req.get("subtype") != "can_use_tool":
550
+ return
551
+ suggestions = list(req.get("suggestions") or [])
552
+ cb = self.options.can_use_tool
553
+ r: PermissionResult
554
+ try:
555
+ if cb is not None:
556
+ ctx = ToolPermissionContext(
557
+ description=str(req.get("description", "")),
558
+ suggestions=suggestions,
559
+ tool_use_id=req.get("tool_use_id"),
560
+ )
561
+ r = await cb(str(req.get("tool_name", "")), dict(req.get("input") or {}), ctx)
562
+ elif self.options.auto_approve:
563
+ r = PermissionAllow()
564
+ else:
565
+ hint = (
566
+ f' To allow it, add allowed_tools=["{suggestions[0]}"].' if suggestions else ""
567
+ )
568
+ r = PermissionDeny(
569
+ message=(
570
+ f"Not approved (headless): requires approval.{hint} "
571
+ "Continue without this action if possible."
572
+ ),
573
+ )
574
+ except Exception as err:
575
+ r = PermissionDeny(message=f"Permission callback failed: {err}")
576
+ if isinstance(r, PermissionAllow):
577
+ resp: dict[str, Any] = {"behavior": "allow"}
578
+ if r.updated_input is not None:
579
+ resp["updated_input"] = r.updated_input
580
+ else:
581
+ resp = {"behavior": "deny"}
582
+ if r.message:
583
+ resp["message"] = r.message
584
+ await self._write(
585
+ {"type": "control_response", "request_id": e.get("request_id"), "response": resp},
586
+ )
587
+
588
+ async def _read_stdout(self) -> None:
589
+ assert self._proc and self._proc.stdout
590
+ try:
591
+ while True:
592
+ line = await self._proc.stdout.readline()
593
+ if not line:
594
+ break
595
+ text = line.decode("utf-8", "replace").strip()
596
+ if not text:
597
+ continue
598
+ try:
599
+ raw = json.loads(text)
600
+ except json.JSONDecodeError:
601
+ if self.options.stderr:
602
+ self.options.stderr(f"[stdout] {text}")
603
+ continue
604
+ t = raw.get("type")
605
+ if t == "control_request":
606
+ # Answer concurrently: a slow callback must not stall the stream.
607
+ self._tasks.append(asyncio.create_task(self._answer(raw)))
608
+ continue
609
+ if t == "control_response":
610
+ continue
611
+ ev = parse_event(raw)
612
+ if isinstance(ev, InitMessage):
613
+ if ev.schema_version != SUPPORTED_SCHEMA_VERSION:
614
+ self._error = McSchemaError(ev.schema_version, ev.cli_version)
615
+ with contextlib.suppress(ProcessLookupError):
616
+ self._proc.kill()
617
+ break
618
+ self.session_id = ev.session_id
619
+ if isinstance(ev, ResultMessage):
620
+ self.last_result = ev
621
+ self._results += 1
622
+ await self._events.put(ev)
623
+ if isinstance(ev, ResultMessage):
624
+ await self._maybe_close_stdin()
625
+ finally:
626
+ self.exit_code = await self._proc.wait()
627
+ for d in _temp_dirs:
628
+ shutil.rmtree(d, ignore_errors=True)
629
+ _temp_dirs.clear()
630
+ if self._error is None and self.session_id is None:
631
+ self._error = McProcessError(
632
+ f"mc exited ({self.exit_code}) before system/init",
633
+ self.exit_code,
634
+ self._stderr_tail.strip(),
635
+ )
636
+ await self._events.put(_END)
637
+
638
+ async def _read_stderr(self) -> None:
639
+ assert self._proc and self._proc.stderr
640
+ while True:
641
+ line = await self._proc.stderr.readline()
642
+ if not line:
643
+ return
644
+ s = line.decode("utf-8", "replace").rstrip("\n")
645
+ self._stderr_tail = (self._stderr_tail + s + "\n")[-4000:]
646
+ if self.options.stderr:
647
+ self.options.stderr(s)
648
+
649
+ async def receive(self) -> AsyncIterator[Event]:
650
+ """Every event until the CLI exits."""
651
+ while True:
652
+ ev = await self._events.get()
653
+ if ev is _END:
654
+ self._events.put_nowait(_END) # later receive() calls end too
655
+ if self._error is not None:
656
+ raise self._error
657
+ return
658
+ yield ev # type: ignore[misc,unused-ignore]
659
+
660
+ async def receive_turn(self) -> AsyncIterator[Event]:
661
+ """Events up to and including the next result."""
662
+ async for ev in self.receive():
663
+ yield ev
664
+ if isinstance(ev, ResultMessage):
665
+ return
666
+
667
+ async def receive_result(self) -> ResultMessage:
668
+ """Skip to the next result."""
669
+ async for ev in self.receive_turn():
670
+ if isinstance(ev, ResultMessage):
671
+ return ev
672
+ raise McProcessError(
673
+ f"mc exited ({self.exit_code}) without a result",
674
+ self.exit_code,
675
+ self._stderr_tail.strip(),
676
+ )
677
+
678
+ async def close(self) -> None:
679
+ """End input and wait for the CLI to exit (killed after 10 s)."""
680
+ self._ending = True
681
+ proc = self._proc
682
+ if proc is None:
683
+ return
684
+ if not self._stdin_closed and proc.stdin:
685
+ self._stdin_closed = True
686
+ with contextlib.suppress(Exception):
687
+ proc.stdin.close()
688
+ try:
689
+ await asyncio.wait_for(proc.wait(), timeout=10)
690
+ except asyncio.TimeoutError:
691
+ with contextlib.suppress(ProcessLookupError):
692
+ proc.kill()
693
+ await proc.wait()
694
+ for t in self._tasks:
695
+ if not t.done():
696
+ with contextlib.suppress(asyncio.TimeoutError, asyncio.CancelledError):
697
+ await asyncio.wait_for(t, timeout=2)
698
+
699
+
700
+ async def query(
701
+ prompt: str | AsyncIterable[str],
702
+ options: CliOptions | None = None,
703
+ ) -> AsyncIterator[Event]:
704
+ """
705
+ Run a prompt (or a stream of prompts) and yield every event.
706
+
707
+ With a string, the session ends after its result. With an async iterable,
708
+ each item is a turn; the session ends when the iterable is exhausted.
709
+ """
710
+ client = MonkeysCodeClient(options)
711
+ await client.connect()
712
+ feeder: asyncio.Task[None] | None = None
713
+ try:
714
+ if isinstance(prompt, str):
715
+ await client.send(prompt)
716
+ await client.end()
717
+ else:
718
+
719
+ async def feed() -> None:
720
+ async for p in prompt:
721
+ await client.send(p)
722
+ await client.end()
723
+
724
+ feeder = asyncio.create_task(feed())
725
+ async for ev in client.receive():
726
+ yield ev
727
+ finally:
728
+ if feeder and not feeder.done():
729
+ feeder.cancel()
730
+ await client.close()