pcode 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 (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,547 @@
1
+ """One CLI process and the pcode history its transcript holds."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import time
8
+ from collections import deque
9
+ from collections.abc import AsyncIterator, Callable
10
+ from contextlib import suppress
11
+ from dataclasses import dataclass
12
+ from tempfile import mkstemp
13
+ from typing import Any
14
+
15
+ from pydantic_ai.exceptions import ModelAPIError
16
+
17
+ from pcode.claude_sdk.cli import CLI_ENV
18
+ from pcode.claude_sdk.errors import (
19
+ ClaudeConnectionError,
20
+ ClaudeHTTPError,
21
+ ClaudeProcessError,
22
+ ClaudeStartError,
23
+ )
24
+ from pcode.claude_sdk.messages import _blocks, _mcp_content, _result_ids
25
+ from pcode.claude_sdk.resume import ForkPoint
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ SERVER = "pcode"
30
+ # How the CLI names an MCP tool to the model; pcode's own names are restored.
31
+ TOOL_PREFIX = f"mcp__{SERVER}__"
32
+ # The CLI sends the model's tool_use id with each MCP call under this key.
33
+ TOOL_USE_ID = "claudecode/toolUseId"
34
+ CLOSE_TIMEOUT_SECONDS = 5.0
35
+ # A finished turn's result follows its last message within milliseconds.
36
+ TURN_END_TIMEOUT_SECONDS = 60.0
37
+ # The CLI calls a tool's handler as the tool_use block ends; allow for a slow
38
+ # machine before deciding it never will (a call it refused or could not parse).
39
+ CALL_TIMEOUT_SECONDS = 10.0
40
+ # `connect` returns before the CLI has listed pcode's tools; a message sent in
41
+ # that gap goes out with none (verified with 2.1.285), so wait for them.
42
+ TOOLS_READY_TIMEOUT_SECONDS = 30.0
43
+ TOOLS_READY_POLL_SECONDS = 0.02
44
+ # Statuses for the CLI's error kinds when it reports no HTTP status itself.
45
+ # `server_error` has none on purpose: without a status it is a connection failure
46
+ # (`ClaudeConnectionError`), while a real 5xx or 529 always carries its status.
47
+ ERROR_STATUS = {
48
+ "authentication_failed": 401,
49
+ "rate_limit": 429,
50
+ "invalid_request": 400,
51
+ }
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class SessionConfig:
56
+ """Everything fixed for the life of one CLI process."""
57
+
58
+ model: str
59
+ cwd: str
60
+ system_prompt: str
61
+ tools: str # canonical JSON of [{name, description, input_schema}]
62
+ effort: str | None = None
63
+ thinking: str | None = None # canonical JSON of the thinking setting
64
+ # pcode's output ceiling; the CLI clamps it to the model's own limit.
65
+ max_tokens: int | None = None
66
+
67
+ def options(self, server, resume: ForkPoint | None, stderr, prompt_file: str) -> Any:
68
+ from claude_agent_sdk import ClaudeAgentOptions
69
+
70
+ thinking = json.loads(self.thinking) if self.thinking else None
71
+ if thinking is not None and thinking.get("type") != "disabled":
72
+ thinking = {"display": "summarized", **thinking}
73
+ return ClaudeAgentOptions(
74
+ # No built-in tools, settings, CLAUDE.md, plugins or other MCP
75
+ # servers: the model sees pcode's prompt and pcode's tools only.
76
+ tools=[],
77
+ setting_sources=[],
78
+ strict_mcp_config=True,
79
+ mcp_servers={SERVER: {"type": "sdk", "name": SERVER, "instance": server}},
80
+ allowed_tools=[f"mcp__{SERVER}"],
81
+ system_prompt={"type": "file", "path": prompt_file},
82
+ include_partial_messages=True,
83
+ model=self.model,
84
+ cwd=self.cwd,
85
+ env={
86
+ **CLI_ENV,
87
+ **(
88
+ {"CLAUDE_CODE_MAX_OUTPUT_TOKENS": str(self.max_tokens)}
89
+ if self.max_tokens
90
+ else {}
91
+ ),
92
+ },
93
+ effort=self.effort,
94
+ thinking=thinking,
95
+ # Readable thinking for scrollback when no thinking setting says so.
96
+ extra_args={} if thinking is not None else {"thinking-display": "summarized"},
97
+ resume=resume.session_id if resume else None,
98
+ fork_session=resume is not None,
99
+ resume_session_at=resume.uuid if resume else None,
100
+ stderr=stderr,
101
+ )
102
+
103
+
104
+ def _client_factory(options) -> Any:
105
+ from claude_agent_sdk import ClaudeSDKClient
106
+
107
+ return ClaudeSDKClient(options)
108
+
109
+
110
+ class _Diverged(ClaudeProcessError):
111
+ """The CLI's transcript stopped matching pcode's history (it acted on its own)."""
112
+
113
+ live = False # raised while continuing a live process, which a fork can replace
114
+
115
+
116
+ class ClaudeSession:
117
+ """One live CLI process and the pcode history its transcript holds."""
118
+
119
+ def __init__(self, config: SessionConfig, resume: ForkPoint | None = None) -> None:
120
+ self.config = config
121
+ self.resume = resume
122
+ # History hashes the transcript holds, always ending on an assistant message.
123
+ self.chain: list[str] = []
124
+ # Tool calls the last response made, whose results the CLI is waiting for.
125
+ self.open_tool_ids: tuple[str, ...] = ()
126
+ self.cli_session_id: str | None = None
127
+ self.response_uuid: str | None = None
128
+ # Whether the current response was read through its message_stop.
129
+ self.complete = False
130
+ # Whether it called a tool by a name the CLI does not offer (pcode's bare
131
+ # name). The CLI refuses such a call, and a transcript that holds one
132
+ # teaches the model to repeat it, so it must never be forked.
133
+ self.stray_call = False
134
+ self.busy = False
135
+ self.dead = False
136
+ self.last_used = time.monotonic()
137
+ # Set by the pool: stop an idle process that went its own way.
138
+ self.retire: Callable[[], None] | None = None
139
+ self._client: Any = None
140
+ self._prompt_file: str | None = None
141
+ self._pump_task: asyncio.Task | None = None
142
+ self._queue: asyncio.Queue = asyncio.Queue()
143
+ self._slots: dict[str, asyncio.Future] = {}
144
+ # Calls the CLI parked with pcode, and results it got from pcode (through
145
+ # a released handler, or sent as input). Any other result is its own.
146
+ self._called: set[str] = set()
147
+ self._answered: set[str] = set()
148
+ # Pulsed on every change a waiter may be waiting for.
149
+ self._changed = asyncio.Event()
150
+ self._last_uuid: str | None = None
151
+ # Whether pcode asked for the next message; anything else is the CLI's own.
152
+ self._expecting = False
153
+ self._final = False
154
+ self._turn_done = asyncio.Event()
155
+ self._turn_done.set()
156
+ self._api_error: tuple[str | None, str] | None = None
157
+ self._stderr: deque[str] = deque(maxlen=20)
158
+ self._closed = False
159
+
160
+ # Lifecycle
161
+
162
+ async def connect(self, factory=None) -> None:
163
+ # A file, not argv: a large prompt would pass Linux's 128 KiB argument
164
+ # limit, and argv is readable by every local user. mkstemp makes it 0600.
165
+ descriptor, self._prompt_file = mkstemp(prefix="pcode-claude-", suffix=".md")
166
+ with os.fdopen(descriptor, "w") as file:
167
+ file.write(self.config.system_prompt)
168
+ options = self.config.options(
169
+ self._server(), self.resume, self._stderr.append, self._prompt_file
170
+ )
171
+ self._client = (factory or _client_factory)(options)
172
+ try:
173
+ await self._client.connect()
174
+ except Exception as error:
175
+ self.dead = True
176
+ self._remove_prompt_file()
177
+ raise ClaudeStartError(
178
+ self.config.model, f"Claude Code could not start: {self._detail(error)}"
179
+ ) from error
180
+ if not self._closed:
181
+ try:
182
+ await self._tools_ready()
183
+ except BaseException as error:
184
+ stopped, self._closed, self.dead = self._closed, True, True
185
+ self._remove_prompt_file()
186
+ with suppress(Exception):
187
+ await self._client.disconnect()
188
+ if stopped and isinstance(error, Exception):
189
+ message = "Claude Code was stopped."
190
+ raise ClaudeProcessError(self.config.model, message) from error
191
+ raise
192
+ if self._closed: # closed while connecting: `close` found nothing to stop
193
+ with suppress(Exception):
194
+ await self._client.disconnect()
195
+ raise ClaudeProcessError(self.config.model, "Claude Code was stopped.")
196
+ self._pump_task = asyncio.create_task(self._pump(), name="claude-sdk-pump")
197
+
198
+ async def _tools_ready(self) -> None:
199
+ """Wait until the CLI offers every pcode tool to the model.
200
+
201
+ A request sent before then declares no tools, so the model calls them
202
+ by the bare names in pcode's prompt, which the CLI refuses.
203
+ """
204
+ expected = len(json.loads(self.config.tools))
205
+ state = "not listed"
206
+ try:
207
+ async with asyncio.timeout(TOOLS_READY_TIMEOUT_SECONDS):
208
+ while True:
209
+ status = await self._client.get_mcp_status()
210
+ server = next(
211
+ (s for s in status.get("mcpServers") or [] if s.get("name") == SERVER),
212
+ None,
213
+ )
214
+ if server is not None:
215
+ state = str(server.get("status"))
216
+ if state == "connected" and len(server.get("tools") or []) >= expected:
217
+ return
218
+ if state in ("failed", "needs-auth", "disabled"):
219
+ break
220
+ await asyncio.sleep(TOOLS_READY_POLL_SECONDS)
221
+ except TimeoutError:
222
+ pass
223
+ raise ClaudeProcessError(
224
+ self.config.model, f"Claude Code did not load pcode's tools ({state})."
225
+ )
226
+
227
+ async def close(self) -> None:
228
+ """Stop the process without letting it act on its own first.
229
+
230
+ A process closed while parked records the missing results as errors and
231
+ carries on (verified: two more billed requests), so interrupt the turn.
232
+ """
233
+ if self._closed:
234
+ return
235
+ self._closed = self.dead = True
236
+ self._changed.set()
237
+ # A reader still waiting (pcode exiting mid-request) must not wait forever.
238
+ self._queue.put_nowait(
239
+ ("error", ClaudeProcessError(self.config.model, "Claude Code was stopped."), None)
240
+ )
241
+ client = self._client
242
+ if client is not None:
243
+ if not self._turn_done.is_set():
244
+ with suppress(Exception):
245
+ async with asyncio.timeout(CLOSE_TIMEOUT_SECONDS):
246
+ await client.interrupt()
247
+ with suppress(Exception):
248
+ await client.disconnect()
249
+ if self._pump_task is not None:
250
+ self._pump_task.cancel()
251
+ for future in self._slots.values():
252
+ if not future.done():
253
+ future.cancel()
254
+ self._remove_prompt_file()
255
+
256
+ def _remove_prompt_file(self) -> None:
257
+ if self._prompt_file is not None:
258
+ with suppress(OSError):
259
+ os.unlink(self._prompt_file)
260
+ self._prompt_file = None
261
+
262
+ def _detail(self, error: BaseException | None = None) -> str:
263
+ from pcode.diagnostics import redact
264
+
265
+ detail = "; ".join(list(self._stderr)[-3:]) or (str(error) if error else "")
266
+ return redact(detail or (type(error).__name__ if error else "no detail"))[:500]
267
+
268
+ def _diverge(self, why: str) -> None:
269
+ """Retire a process whose transcript no longer matches pcode's history."""
270
+ if self.dead:
271
+ return
272
+ logger.debug("Claude Code session diverged: %s", why)
273
+ self.dead = True
274
+ self._changed.set()
275
+ if self._expecting:
276
+ # A reader waits for the answer to its input, but what the CLI sends
277
+ # next follows its own history: drop it and fail the reader (the
278
+ # runtime's retry forks from the last shared message).
279
+ self._expecting = False
280
+ error = ClaudeProcessError(self.config.model, f"Claude Code went its own way: {why}.")
281
+ self._queue.put_nowait(("error", error, None))
282
+ if not self.busy and self.retire is not None:
283
+ self.retire() # interrupts whatever it started on its own
284
+
285
+ # Tool handlers
286
+
287
+ def _server(self) -> Any:
288
+ # Built on mcp 2's constructor callbacks: the SDK helper would hide the
289
+ # request `_meta` that carries the tool_use id.
290
+ import mcp.types as types
291
+ from mcp.server import Server
292
+
293
+ tools = [
294
+ types.Tool.model_validate(
295
+ {
296
+ "name": tool["name"],
297
+ "description": tool.get("description") or "",
298
+ "inputSchema": tool["input_schema"],
299
+ # Keep results inline: pcode already spills large ones.
300
+ "_meta": {"anthropic/maxResultSizeChars": 10_000_000},
301
+ }
302
+ )
303
+ for tool in json.loads(self.config.tools)
304
+ ]
305
+
306
+ async def list_tools(ctx, params):
307
+ return types.ListToolsResult(tools=tools)
308
+
309
+ async def call_tool(ctx, params):
310
+ meta = params.meta
311
+ extra = meta if isinstance(meta, dict) else (getattr(meta, "model_extra", None) or {})
312
+ tool_use_id = extra.get(TOOL_USE_ID)
313
+ if not isinstance(tool_use_id, str):
314
+ self._diverge("a tool call without an id")
315
+ return types.CallToolResult(
316
+ content=[
317
+ types.TextContent(type="text", text="pcode could not match this call.")
318
+ ],
319
+ isError=True,
320
+ )
321
+ self._called.add(tool_use_id)
322
+ self._changed.set()
323
+ # Parked until pcode runs the tool and sends its result.
324
+ try:
325
+ block = await self._slot(tool_use_id)
326
+ finally:
327
+ self._slots.pop(tool_use_id, None) # never hold results once answered
328
+ self._answered.add(tool_use_id)
329
+ return types.CallToolResult.model_validate(
330
+ {"content": _mcp_content(block), "isError": bool(block.get("is_error"))}
331
+ )
332
+
333
+ return Server(SERVER, on_list_tools=list_tools, on_call_tool=call_tool)
334
+
335
+ def _slot(self, tool_use_id: str) -> asyncio.Future:
336
+ future = self._slots.get(tool_use_id)
337
+ if future is None:
338
+ future = self._slots[tool_use_id] = asyncio.get_running_loop().create_future()
339
+ return future
340
+
341
+ # Input
342
+
343
+ async def _write(self, content: list[dict]) -> None:
344
+ async def message():
345
+ yield {
346
+ "type": "user",
347
+ "message": {"role": "user", "content": content},
348
+ "parent_tool_use_id": None,
349
+ }
350
+
351
+ await self._client.query(message())
352
+
353
+ async def _until(self, condition: Callable[[], bool]) -> None:
354
+ while not condition():
355
+ self._changed.clear()
356
+ await self._changed.wait()
357
+
358
+ def _ask(self) -> None:
359
+ """Expect the CLI's next message; raise if it has already gone its own way."""
360
+ if self.dead:
361
+ raise _Diverged(self.config.model, "Claude Code continued on its own.")
362
+ self._expecting = True
363
+ self.complete = False
364
+
365
+ async def start(self, content: list[dict]) -> None:
366
+ """Begin a turn with a new user message."""
367
+ try:
368
+ async with asyncio.timeout(TURN_END_TIMEOUT_SECONDS):
369
+ # Or until it goes its own way, which then need not run to the end.
370
+ await self._until(lambda: self._turn_done.is_set() or self.dead)
371
+ except TimeoutError:
372
+ raise _Diverged(self.config.model, "Claude Code did not end its turn.") from None
373
+ self._ask()
374
+ self._turn_done.clear()
375
+ # A fork may start with results for the calls its transcript ends on.
376
+ self._answered.update(b["tool_use_id"] for b in content if b.get("type") == "tool_result")
377
+ await self._write(content)
378
+
379
+ async def answer(self, message: dict) -> None:
380
+ """Continue a parked turn: release its handlers, plus any new user input.
381
+
382
+ The CLI parks a call as its tool_use block ends. One that never arrives
383
+ was refused or unparseable, and the CLI has answered it itself.
384
+ Input written before the results is already queued when they arrive, so
385
+ the CLI sends both in one request (verified with 2.1.283).
386
+ """
387
+ try:
388
+ async with asyncio.timeout(CALL_TIMEOUT_SECONDS):
389
+ await self._until(lambda: bool(self._called & set(self.open_tool_ids)) or self.dead)
390
+ except TimeoutError:
391
+ self._diverge("no tool call reached pcode")
392
+ self._ask()
393
+ blocks = _blocks(message)
394
+ other = [b for b in blocks if b.get("type") != "tool_result"]
395
+ if other:
396
+ await self._write(other)
397
+ for block in blocks:
398
+ if block.get("type") == "tool_result":
399
+ future = self._slot(block["tool_use_id"])
400
+ if not future.done():
401
+ future.set_result(block)
402
+ self.open_tool_ids = ()
403
+
404
+ async def send(self, message: dict) -> None:
405
+ if self.open_tool_ids:
406
+ await self.answer(message)
407
+ else:
408
+ await self.start(_blocks(message))
409
+
410
+ # Output
411
+
412
+ async def _pump(self) -> None:
413
+ """Drain the SDK (its buffer holds only 100 messages) and track the turn."""
414
+ from claude_agent_sdk.types import (
415
+ AssistantMessage,
416
+ ResultMessage,
417
+ StreamEvent,
418
+ SystemMessage,
419
+ UserMessage,
420
+ )
421
+
422
+ error: Exception | None = None
423
+ stop_reason = None
424
+ wanted = False
425
+ try:
426
+ async for message in self._client.receive_messages():
427
+ if getattr(message, "parent_tool_use_id", None):
428
+ continue
429
+ if isinstance(message, StreamEvent):
430
+ event = message.event
431
+ kind = event.get("type")
432
+ if kind == "message_start":
433
+ stop_reason = None
434
+ wanted, self._expecting = self._expecting, False
435
+ if not wanted:
436
+ # A request of its own (a nudge after a thinking-only
437
+ # reply, output-limit recovery, a retried tool call).
438
+ self._diverge("a message pcode did not ask for")
439
+ elif kind == "message_delta":
440
+ stop_reason = (event.get("delta") or {}).get("stop_reason") or stop_reason
441
+ elif kind == "message_stop" and stop_reason != "tool_use":
442
+ # The turn ends here; absorb its result message.
443
+ self._final = True
444
+ if wanted:
445
+ self._queue.put_nowait(("event", event, self._last_uuid))
446
+ elif isinstance(message, AssistantMessage):
447
+ self._last_uuid = message.uuid or self._last_uuid
448
+ if message.error:
449
+ text = " ".join(getattr(b, "text", "") for b in message.content)
450
+ self._api_error = (message.error, text.strip())
451
+ elif isinstance(message, UserMessage):
452
+ self._check_results(message)
453
+ elif isinstance(message, ResultMessage):
454
+ self.cli_session_id = message.session_id or self.cli_session_id
455
+ final, self._final = self._final, False
456
+ if message.is_error:
457
+ self.dead = True
458
+ self._turn_done.set()
459
+ self._changed.set()
460
+ # A result after the final message is expected; any other
461
+ # ends the turn without one, which a waiting reader must hear.
462
+ if not final or message.is_error:
463
+ self._queue.put_nowait(("result", message, None))
464
+ elif isinstance(message, SystemMessage) and message.subtype == "init":
465
+ self.cli_session_id = message.data.get("session_id") or self.cli_session_id
466
+ except asyncio.CancelledError:
467
+ raise
468
+ except Exception as cause:
469
+ error = cause
470
+ self.dead = True
471
+ self._turn_done.set()
472
+ self._changed.set()
473
+ stopped = ClaudeProcessError(
474
+ self.config.model, f"Claude Code stopped: {self._detail(error)}"
475
+ )
476
+ stopped.__cause__ = error
477
+ self._queue.put_nowait(("error", stopped, None))
478
+
479
+ def _check_results(self, message) -> None:
480
+ """Notice the CLI answering one of pcode's calls itself (a refused input).
481
+
482
+ Checked against results pcode actually handed over, not ones it merely
483
+ sent: with calls made one at a time, the CLI can refuse a later call
484
+ after pcode has sent every result.
485
+ """
486
+ content = message.content if isinstance(message.content, list) else []
487
+ for block in content:
488
+ tool_use_id = getattr(block, "tool_use_id", None)
489
+ if tool_use_id and tool_use_id not in self._answered and not self._closed:
490
+ self._diverge(f"Claude Code answered tool call {tool_use_id} itself")
491
+
492
+ async def response(self) -> AsyncIterator[dict]:
493
+ """Raw stream events of the CLI's next assistant message, through message_stop."""
494
+ tool_ids: list[str] = []
495
+ stop_reason = None
496
+ self.stray_call = False
497
+ while True:
498
+ kind, value, uuid = await self._queue.get()
499
+ if kind == "error":
500
+ self._queue.put_nowait((kind, value, uuid)) # every later reader fails too
501
+ raise value
502
+ if kind == "result":
503
+ self.dead = True
504
+ raise self._result_error(value)
505
+ event_type = value.get("type")
506
+ if event_type == "content_block_start":
507
+ block = value.get("content_block") or {}
508
+ if block.get("type") == "tool_use":
509
+ tool_ids.append(block.get("id"))
510
+ wire = str(block.get("name", ""))
511
+ self.stray_call = self.stray_call or not wire.startswith(TOOL_PREFIX)
512
+ name = wire.removeprefix(TOOL_PREFIX)
513
+ value = {**value, "content_block": {**block, "name": name}}
514
+ elif event_type == "message_delta":
515
+ stop_reason = (value.get("delta") or {}).get("stop_reason") or stop_reason
516
+ elif event_type == "message_stop":
517
+ # Settled before handing it over, in case the reader stops here.
518
+ self.open_tool_ids = tuple(tool_ids) if stop_reason == "tool_use" else ()
519
+ self.response_uuid = uuid
520
+ self.complete = True
521
+ yield value
522
+ return
523
+ yield value
524
+
525
+ def _result_error(self, result) -> Exception:
526
+ kind, text = self._api_error or (None, "")
527
+ self._api_error = None
528
+ text = text or result.result or "; ".join(result.errors or []) or result.subtype
529
+ if kind == "server_error" and not result.api_error_status:
530
+ return ClaudeConnectionError(self.config.model, text)
531
+ status = result.api_error_status or ERROR_STATUS.get(kind or "")
532
+ if status:
533
+ body = {"type": "error", "error": {"type": kind or result.subtype, "message": text}}
534
+ return ClaudeHTTPError(status, self.config.model, body)
535
+ return ModelAPIError(self.config.model, f"Claude Code ended the turn: {text}")
536
+
537
+ # Matching
538
+
539
+ def accepts(self, config: SessionConfig, messages: list[dict], chain: list[str]) -> bool:
540
+ """Whether `messages` is this transcript plus exactly one new user message."""
541
+ known = len(self.chain)
542
+ if self.busy or self.dead or not known or config != self.config:
543
+ return False
544
+ if len(messages) != known + 1 or chain[known - 1] != self.chain[-1]:
545
+ return False
546
+ message = messages[known]
547
+ return message["role"] == "user" and _result_ids(message) == set(self.open_tool_ids)