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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|