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
pcode/remote.py
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
"""The terminal's side of a session host (see `pcode.host`).
|
|
2
|
+
|
|
3
|
+
`RemoteController` stands in for `SessionController` while another process runs
|
|
4
|
+
the conversation. What the terminal asks for goes to the host as intents
|
|
5
|
+
(`submit`, `command`, `cancel`, ...) or queries; what the host shows arrives as
|
|
6
|
+
view calls, which `PreviewApp` renders exactly as it renders its own
|
|
7
|
+
controller's. The session's state (model, effort, commands, the live panel's
|
|
8
|
+
session fields, side questions) is mirrored from what the host sends.
|
|
9
|
+
|
|
10
|
+
`HostedSession` is the controller's `runtime` for the parts of the terminal that
|
|
11
|
+
read the conversation itself (`/tree`, `/tools`, `/diffs`, replay): it reads the
|
|
12
|
+
host's journal from disk, without owning it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import inspect
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from uuid import uuid4
|
|
25
|
+
|
|
26
|
+
from pcode.aside import Aside, Asides
|
|
27
|
+
from pcode.commands import Command, CommandRegistry
|
|
28
|
+
from pcode.controller import (
|
|
29
|
+
MODEL_COMMANDS,
|
|
30
|
+
SESSION_FIELDS,
|
|
31
|
+
VIEW_CALLS,
|
|
32
|
+
SessionController,
|
|
33
|
+
mcp_enable,
|
|
34
|
+
)
|
|
35
|
+
from pcode.host_protocol import (
|
|
36
|
+
LINE_LIMIT,
|
|
37
|
+
PROTOCOL,
|
|
38
|
+
HostEntry,
|
|
39
|
+
dumps,
|
|
40
|
+
host_dir,
|
|
41
|
+
list_hosts,
|
|
42
|
+
read_message,
|
|
43
|
+
socket_path,
|
|
44
|
+
)
|
|
45
|
+
from pcode.jobs import Job, JobRegistry
|
|
46
|
+
from pcode.remote_profile import RemoteProfile, scrubbed_env
|
|
47
|
+
from pcode.rpc import Peer, decode
|
|
48
|
+
from pcode.runtime import EditCompleted
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HostError(RuntimeError):
|
|
52
|
+
"""The host could not be reached, refused this terminal, or went away."""
|
|
53
|
+
|
|
54
|
+
# Carries no provider text, so it is shown as is.
|
|
55
|
+
sanitized = True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class JobsView(JobRegistry):
|
|
59
|
+
"""The host's shell jobs, read from its registry directory for the jobs browser.
|
|
60
|
+
|
|
61
|
+
Read only: nothing here launches, stops, or deletes a job (stopping is an
|
|
62
|
+
intent sent to the host); `refresh` only reads what the host published.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, home: Path) -> None:
|
|
66
|
+
super().__init__()
|
|
67
|
+
self.home = home
|
|
68
|
+
|
|
69
|
+
def refresh(self) -> list:
|
|
70
|
+
try:
|
|
71
|
+
record = json.loads((self.home / "registry.json").read_text(encoding="utf-8"))
|
|
72
|
+
except (OSError, ValueError):
|
|
73
|
+
record = {}
|
|
74
|
+
entries = record.get("jobs") if isinstance(record, dict) else None
|
|
75
|
+
for identity, entry in (entries if isinstance(entries, dict) else {}).items():
|
|
76
|
+
if identity not in self.jobs and isinstance(entry, dict):
|
|
77
|
+
try:
|
|
78
|
+
self.jobs[identity] = Job(
|
|
79
|
+
id=identity,
|
|
80
|
+
command=str(entry["command"]),
|
|
81
|
+
directory=Path(entry["directory"]),
|
|
82
|
+
supervisor_pid=int(entry["supervisor_pid"]),
|
|
83
|
+
started_at=float(entry["started_at"]),
|
|
84
|
+
background=True,
|
|
85
|
+
purpose=str(entry.get("purpose") or ""),
|
|
86
|
+
)
|
|
87
|
+
except (KeyError, TypeError, ValueError):
|
|
88
|
+
continue
|
|
89
|
+
for job in self.jobs.values():
|
|
90
|
+
if job.exit_code is not None:
|
|
91
|
+
continue
|
|
92
|
+
status = self._read_status(job)
|
|
93
|
+
if status is None:
|
|
94
|
+
continue
|
|
95
|
+
job.pid = job.pid or status.get("pid")
|
|
96
|
+
if status.get("exit_code") is not None:
|
|
97
|
+
job.exit_code = status["exit_code"]
|
|
98
|
+
job.ended_at = status.get("ended_at") or time.time()
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class HostedSession:
|
|
103
|
+
"""The runtime a terminal attached to a host sees: the host's journal, read from disk."""
|
|
104
|
+
|
|
105
|
+
remote = True
|
|
106
|
+
agent = None
|
|
107
|
+
mcp = None
|
|
108
|
+
recovery_blocked = None
|
|
109
|
+
inspections = None
|
|
110
|
+
|
|
111
|
+
def __init__(self, controller: "RemoteController", welcome: dict) -> None:
|
|
112
|
+
self.controller = controller
|
|
113
|
+
self.id = welcome["id"]
|
|
114
|
+
self.pid = welcome["pid"]
|
|
115
|
+
self.session = None
|
|
116
|
+
self.session_id = ""
|
|
117
|
+
self.jobs = None
|
|
118
|
+
self.lost = False
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def tree(self):
|
|
122
|
+
return self.session.tree if self.session is not None else None
|
|
123
|
+
|
|
124
|
+
def follow(self, state: dict) -> None:
|
|
125
|
+
"""Keep up with the host: its journal (a new one after /new) and its jobs."""
|
|
126
|
+
from pcode.sessions import SessionError, SessionJournal
|
|
127
|
+
|
|
128
|
+
self.session_id = state.get("session_id", "")
|
|
129
|
+
directory = state.get("session_directory", "")
|
|
130
|
+
if not directory:
|
|
131
|
+
self.session = None
|
|
132
|
+
elif self.session is None or str(self.session.directory) != directory:
|
|
133
|
+
try:
|
|
134
|
+
self.session = SessionJournal.read(Path(directory))
|
|
135
|
+
except (OSError, SessionError, ValueError):
|
|
136
|
+
self.session = None
|
|
137
|
+
else:
|
|
138
|
+
self.session.refresh()
|
|
139
|
+
home = state.get("jobs_directory", "")
|
|
140
|
+
if not home:
|
|
141
|
+
self.jobs = None
|
|
142
|
+
elif self.jobs is None or str(self.jobs.home) != home:
|
|
143
|
+
self.jobs = JobsView(Path(home))
|
|
144
|
+
|
|
145
|
+
def refresh(self) -> None:
|
|
146
|
+
from pcode.sessions import SessionError
|
|
147
|
+
|
|
148
|
+
if self.session is not None:
|
|
149
|
+
try:
|
|
150
|
+
self.session.refresh()
|
|
151
|
+
except (OSError, SessionError, ValueError):
|
|
152
|
+
pass # Keep what was read; the next refresh catches up.
|
|
153
|
+
|
|
154
|
+
def stop(self, *, keep_worktree: bool = False) -> None:
|
|
155
|
+
self.controller.peer.notify("stop", keep_worktree)
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
self.controller.close()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _Calls:
|
|
162
|
+
"""What the host may call on this terminal: its view methods, and the mirrors."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, controller: "RemoteController") -> None:
|
|
165
|
+
self._controller = controller
|
|
166
|
+
# Calls that arrive before the welcome is applied wait for it.
|
|
167
|
+
self._held: list | None = []
|
|
168
|
+
|
|
169
|
+
def __getattr__(self, name: str):
|
|
170
|
+
if name.startswith("_"):
|
|
171
|
+
raise AttributeError(name)
|
|
172
|
+
controller = self._controller
|
|
173
|
+
|
|
174
|
+
def call(*args, **kwargs):
|
|
175
|
+
if self._held is not None:
|
|
176
|
+
self._held.append((name, args, kwargs))
|
|
177
|
+
return None
|
|
178
|
+
return controller.receive(name, args, kwargs)
|
|
179
|
+
|
|
180
|
+
return call
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# The host's argument completers a terminal can run itself: each reads only the
|
|
184
|
+
# model catalog the host sends with its state, or the shared filesystem.
|
|
185
|
+
COMPLETERS = frozenset({"aside_completions", "model_list_completions", "path_completions"})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _proxy(state: dict, controller: "RemoteController") -> Command:
|
|
189
|
+
"""A command the host runs, listed here for help and completion."""
|
|
190
|
+
name = state["name"]
|
|
191
|
+
# A host started before `completer` existed flags /btw with `models`.
|
|
192
|
+
completer = state.get("completer") or ("aside_completions" if state.get("models") else None)
|
|
193
|
+
|
|
194
|
+
arguments = tuple(state.get("arguments") or ())
|
|
195
|
+
|
|
196
|
+
def refuse(argument: str) -> None:
|
|
197
|
+
raise ValueError(f"{name} runs in the session host.")
|
|
198
|
+
|
|
199
|
+
return Command(
|
|
200
|
+
name,
|
|
201
|
+
state["description"],
|
|
202
|
+
refuse,
|
|
203
|
+
arguments,
|
|
204
|
+
tuple(state.get("aliases") or ()),
|
|
205
|
+
free_arguments=bool(state.get("free_arguments")),
|
|
206
|
+
# The host sends a list only for commands whose arguments change; the
|
|
207
|
+
# rest (/worktree, /show-tasks, ...) complete from their fixed list.
|
|
208
|
+
argument_provider=lambda: tuple(controller.arguments.get(name, arguments)),
|
|
209
|
+
group=state.get("group") or "Other",
|
|
210
|
+
argument_descriptions=state.get("argument_descriptions") or None,
|
|
211
|
+
argument_completer=getattr(controller, completer) if completer in COMPLETERS else None,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class RemoteController:
|
|
216
|
+
"""A session host's controller, as seen from an attached terminal."""
|
|
217
|
+
|
|
218
|
+
aside_completions = SessionController.aside_completions
|
|
219
|
+
model_list_completions = SessionController.model_list_completions
|
|
220
|
+
|
|
221
|
+
def path_completions(self, argument: str):
|
|
222
|
+
"""An extension command's path argument, resolved against the host's workspace."""
|
|
223
|
+
from pcode.path_completion import complete_paths
|
|
224
|
+
|
|
225
|
+
return complete_paths(argument, self.workspace)
|
|
226
|
+
|
|
227
|
+
def __init__(self, view, activity) -> None:
|
|
228
|
+
self.view = view
|
|
229
|
+
self.activity = activity
|
|
230
|
+
self.peer: Peer | None = None
|
|
231
|
+
self.runtime: HostedSession | None = None
|
|
232
|
+
self.running = True
|
|
233
|
+
self.registry = CommandRegistry()
|
|
234
|
+
self.asides = Asides()
|
|
235
|
+
self.activity.asides = self.asides.items
|
|
236
|
+
self.arguments: dict[str, list[str]] = {}
|
|
237
|
+
self.state: dict = {}
|
|
238
|
+
self.model: str | None = None
|
|
239
|
+
self.pending_model: str | None = None
|
|
240
|
+
self.workspace = Path.cwd()
|
|
241
|
+
self.session_dir: Path | None = None
|
|
242
|
+
self.save_sessions = True
|
|
243
|
+
self.resuming = False
|
|
244
|
+
self.extensions = None
|
|
245
|
+
self.skill_command_names: list[str] = []
|
|
246
|
+
self._session_id = None
|
|
247
|
+
self._saved_session = None
|
|
248
|
+
self._needs_runtime = False
|
|
249
|
+
self.startup_pending = False
|
|
250
|
+
self.startup_error: Exception | None = None
|
|
251
|
+
self.interactive = True
|
|
252
|
+
self._calls = _Calls(self)
|
|
253
|
+
self._serving: asyncio.Task | None = None
|
|
254
|
+
self.on_closed = lambda: None
|
|
255
|
+
# The host said it was stopping, rather than just going away.
|
|
256
|
+
self.host_stopped = False
|
|
257
|
+
# One per command sent and not yet answered by `after_command`, oldest
|
|
258
|
+
# first: the host runs them in order and reports each exactly once.
|
|
259
|
+
self._command_waits: list = []
|
|
260
|
+
|
|
261
|
+
# Connecting
|
|
262
|
+
|
|
263
|
+
@classmethod
|
|
264
|
+
async def connect(cls, path: Path, view, activity) -> tuple["RemoteController", dict]:
|
|
265
|
+
"""Attach to the host at `path`; returns the controller and the host's welcome."""
|
|
266
|
+
try:
|
|
267
|
+
reader, writer = await asyncio.open_unix_connection(str(path), limit=LINE_LIMIT)
|
|
268
|
+
except OSError as error:
|
|
269
|
+
raise HostError(f"Cannot reach the session host at {path}: {error}") from error
|
|
270
|
+
try:
|
|
271
|
+
writer.write(dumps({"type": "hello", "protocol": PROTOCOL}))
|
|
272
|
+
await writer.drain()
|
|
273
|
+
reply = await read_message(reader)
|
|
274
|
+
except (OSError, ValueError) as error:
|
|
275
|
+
writer.close()
|
|
276
|
+
raise HostError(f"The session host at {path} did not answer: {error}") from error
|
|
277
|
+
if reply is None or reply.get("type") != "ready":
|
|
278
|
+
writer.close()
|
|
279
|
+
message = (reply or {}).get("message") or "The session host closed the connection."
|
|
280
|
+
raise HostError(message)
|
|
281
|
+
controller = cls(view, activity)
|
|
282
|
+
controller.peer = Peer(reader, writer, controller._calls, allowed=VIEW_CALLS)
|
|
283
|
+
controller.peer.on_close = controller._closed
|
|
284
|
+
controller._serving = asyncio.create_task(controller.peer.serve())
|
|
285
|
+
try:
|
|
286
|
+
welcome = await controller.peer.request("attach")
|
|
287
|
+
except ConnectionError as error:
|
|
288
|
+
raise HostError("The session host closed the connection.") from error
|
|
289
|
+
controller.runtime = HostedSession(controller, welcome)
|
|
290
|
+
controller.apply_state(welcome["session"])
|
|
291
|
+
for name, value in welcome["activity"].items():
|
|
292
|
+
controller.apply_field(name, value)
|
|
293
|
+
for state in welcome["asides"]:
|
|
294
|
+
controller.apply_aside(state)
|
|
295
|
+
return controller, welcome
|
|
296
|
+
|
|
297
|
+
async def start(self, welcome: dict) -> None:
|
|
298
|
+
"""Deliver the calls buffered in `welcome`, then whatever arrived since."""
|
|
299
|
+
for name, args, kwargs in welcome["calls"]:
|
|
300
|
+
await self._deliver(name, decode(args), decode(kwargs))
|
|
301
|
+
while self._calls._held:
|
|
302
|
+
name, args, kwargs = self._calls._held.pop(0)
|
|
303
|
+
await self._deliver(name, args, kwargs)
|
|
304
|
+
self._calls._held = None
|
|
305
|
+
|
|
306
|
+
async def _deliver(self, name: str, args, kwargs) -> None:
|
|
307
|
+
result = self.receive(name, args, kwargs)
|
|
308
|
+
if inspect.isawaitable(result):
|
|
309
|
+
await result
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def id(self) -> str:
|
|
313
|
+
return self.runtime.id if self.runtime is not None else ""
|
|
314
|
+
|
|
315
|
+
def close(self) -> None:
|
|
316
|
+
"""Detach: the host keeps running."""
|
|
317
|
+
self.running = False
|
|
318
|
+
self._end_command_waits()
|
|
319
|
+
if self.peer is not None:
|
|
320
|
+
self.peer.on_close = None
|
|
321
|
+
self.peer.close()
|
|
322
|
+
|
|
323
|
+
async def detach(self) -> None:
|
|
324
|
+
"""`close`, then wait until what was sent has gone out."""
|
|
325
|
+
self.close()
|
|
326
|
+
if self._serving is not None:
|
|
327
|
+
await asyncio.gather(self._serving, return_exceptions=True)
|
|
328
|
+
|
|
329
|
+
def _end_command_waits(self) -> None:
|
|
330
|
+
while self._command_waits:
|
|
331
|
+
self.activity.end_wait(self._command_waits.pop())
|
|
332
|
+
|
|
333
|
+
def _closed(self) -> None:
|
|
334
|
+
self._end_command_waits()
|
|
335
|
+
if self.runtime is not None:
|
|
336
|
+
self.runtime.lost = True
|
|
337
|
+
self.on_closed()
|
|
338
|
+
|
|
339
|
+
# What the host sends
|
|
340
|
+
|
|
341
|
+
def receive(self, name: str, args, kwargs):
|
|
342
|
+
if name == "state":
|
|
343
|
+
for field, value in args[0].items():
|
|
344
|
+
self.apply_field(field, value)
|
|
345
|
+
self.view.redraw()
|
|
346
|
+
return None
|
|
347
|
+
if name == "session_state":
|
|
348
|
+
self.apply_state(args[0])
|
|
349
|
+
self.view.commands_changed()
|
|
350
|
+
self.view.redraw()
|
|
351
|
+
return None
|
|
352
|
+
if name == "aside_changed":
|
|
353
|
+
self.apply_aside(args[0])
|
|
354
|
+
self.view.redraw()
|
|
355
|
+
return None
|
|
356
|
+
if name == "aside_answered":
|
|
357
|
+
self.view.aside_answered(self.apply_aside(args[0]))
|
|
358
|
+
return None
|
|
359
|
+
if name == "host_closed":
|
|
360
|
+
self.host_stopped = True
|
|
361
|
+
return None
|
|
362
|
+
if name == "read_asides":
|
|
363
|
+
return self._read_asides()
|
|
364
|
+
if name == "after_command" and self._command_waits:
|
|
365
|
+
self.activity.end_wait(self._command_waits.pop(0))
|
|
366
|
+
if self.runtime and (
|
|
367
|
+
name in ("turn_ended", "replay_conversation", "show_branch")
|
|
368
|
+
# The host journals an edit before sending it. /diffs reads the
|
|
369
|
+
# running turn's edits from the journal, mid-turn too.
|
|
370
|
+
or (name == "turn_event" and isinstance(args[0], EditCompleted))
|
|
371
|
+
):
|
|
372
|
+
self.runtime.refresh()
|
|
373
|
+
return getattr(self.view, name)(*args, **kwargs)
|
|
374
|
+
|
|
375
|
+
def apply_field(self, name: str, value) -> None:
|
|
376
|
+
if name not in SESSION_FIELDS:
|
|
377
|
+
return
|
|
378
|
+
if name == "prompt_state" and value != getattr(self.activity, name):
|
|
379
|
+
# Autohiding the task list is this terminal's preference, applied here.
|
|
380
|
+
if value == "running":
|
|
381
|
+
self.activity.tasks_autohidden = False
|
|
382
|
+
elif self.activity.autohide_tasks:
|
|
383
|
+
self.activity.tasks_autohidden = True
|
|
384
|
+
setattr(self.activity, name, value)
|
|
385
|
+
|
|
386
|
+
def apply_state(self, state: dict) -> None:
|
|
387
|
+
self.state = state
|
|
388
|
+
self.model = state.get("model")
|
|
389
|
+
self.pending_model = state.get("pending_model")
|
|
390
|
+
self.workspace = Path(state.get("workspace") or self.workspace)
|
|
391
|
+
self.startup_pending = bool(state.get("startup_pending"))
|
|
392
|
+
error = state.get("startup_error")
|
|
393
|
+
self.startup_error = HostError(error) if error else None
|
|
394
|
+
self.arguments = state.get("arguments") or {}
|
|
395
|
+
self.skill_command_names = list(state.get("skills") or ())
|
|
396
|
+
self.save_sessions = bool(state.get("saving"))
|
|
397
|
+
directory = state.get("session_directory")
|
|
398
|
+
self.session_dir = Path(directory).parent if directory else self.session_dir
|
|
399
|
+
commands = state.get("commands") or []
|
|
400
|
+
if [command.name for command in self.registry.commands] != [c["name"] for c in commands]:
|
|
401
|
+
self.registry = CommandRegistry()
|
|
402
|
+
for command in commands:
|
|
403
|
+
self.registry.register(_proxy(command, self))
|
|
404
|
+
if self.runtime is not None:
|
|
405
|
+
self.runtime.follow(state)
|
|
406
|
+
|
|
407
|
+
def apply_aside(self, state: dict) -> Aside:
|
|
408
|
+
aside = next((item for item in self.asides.items if item.id == state["id"]), None)
|
|
409
|
+
new = aside is None
|
|
410
|
+
if new:
|
|
411
|
+
aside = Aside(question=state["question"], id=state["id"])
|
|
412
|
+
self.asides.items.append(aside)
|
|
413
|
+
for name in (
|
|
414
|
+
"model",
|
|
415
|
+
"label",
|
|
416
|
+
"effort",
|
|
417
|
+
"status",
|
|
418
|
+
"answer",
|
|
419
|
+
"activity",
|
|
420
|
+
"error",
|
|
421
|
+
"started",
|
|
422
|
+
"finished",
|
|
423
|
+
"thread",
|
|
424
|
+
"conversation",
|
|
425
|
+
"base",
|
|
426
|
+
"bridged",
|
|
427
|
+
):
|
|
428
|
+
if name in state:
|
|
429
|
+
setattr(aside, name, state[name])
|
|
430
|
+
# The reply lives in the host; a follow-up only needs to know there is one.
|
|
431
|
+
aside.reply = True if state.get("replied") else None
|
|
432
|
+
# Read here and not yet reported stays read.
|
|
433
|
+
aside.read = aside.read or bool(state.get("read"))
|
|
434
|
+
if new:
|
|
435
|
+
# The host dropped its oldest settled threads the same way.
|
|
436
|
+
self.asides._trim()
|
|
437
|
+
return aside
|
|
438
|
+
|
|
439
|
+
async def _read_asides(self):
|
|
440
|
+
"""The viewer; then the host learns what was read, so no terminal shows it as ready."""
|
|
441
|
+
try:
|
|
442
|
+
return await self.view.read_asides()
|
|
443
|
+
finally:
|
|
444
|
+
read = [aside.id for aside in self.asides.items if aside.read]
|
|
445
|
+
if read and self.peer is not None:
|
|
446
|
+
self.peer.notify("asides_read", read)
|
|
447
|
+
|
|
448
|
+
# What the terminal asks for
|
|
449
|
+
|
|
450
|
+
def submit(self, text: str, mode: str) -> None:
|
|
451
|
+
# Set now so Enter + Ctrl+C in one input batch cancels the pending
|
|
452
|
+
# request rather than clearing the draft; the host's state follows.
|
|
453
|
+
self.activity.busy = True
|
|
454
|
+
self.peer.notify("submit", text, mode)
|
|
455
|
+
|
|
456
|
+
def command(self, text: str, tag=None) -> None:
|
|
457
|
+
if text.split()[0] in MODEL_COMMANDS or mcp_enable(text):
|
|
458
|
+
self.activity.busy = True
|
|
459
|
+
self._command_waits.append(self.activity.begin_wait(f"Running {text.split()[0]}"))
|
|
460
|
+
self.peer.notify("command", text, tag)
|
|
461
|
+
|
|
462
|
+
def cancel(self) -> None:
|
|
463
|
+
# The host drops queued commands, some without an `after_command`.
|
|
464
|
+
self._end_command_waits()
|
|
465
|
+
self.peer.notify("cancel")
|
|
466
|
+
|
|
467
|
+
def set_thinking(self, mode: str) -> None:
|
|
468
|
+
self.peer.notify("set_thinking", mode)
|
|
469
|
+
|
|
470
|
+
def adjust_effort(self, direction: int) -> None:
|
|
471
|
+
self.peer.notify("adjust_effort", direction)
|
|
472
|
+
|
|
473
|
+
def stop_jobs(self, job_ids) -> None:
|
|
474
|
+
self.peer.notify("stop_jobs", job_ids)
|
|
475
|
+
|
|
476
|
+
def cancel_asides(self) -> None:
|
|
477
|
+
# The answers run in the host; this terminal's records only mirror them.
|
|
478
|
+
self.peer.notify("cancel_asides")
|
|
479
|
+
|
|
480
|
+
def watch_job(self, job_id) -> None:
|
|
481
|
+
self.peer.notify("watch_job", job_id)
|
|
482
|
+
|
|
483
|
+
async def query(self, name: str, *args):
|
|
484
|
+
with self.activity.waiting("Waiting for the session host"):
|
|
485
|
+
return await self.peer.request("query", name, *args)
|
|
486
|
+
|
|
487
|
+
def current_effort(self) -> str:
|
|
488
|
+
return self.state.get("effort", "n/a") if self.model else "n/a"
|
|
489
|
+
|
|
490
|
+
def context_label(self) -> str:
|
|
491
|
+
return self.state.get("context", "")
|
|
492
|
+
|
|
493
|
+
def session_title(self) -> str:
|
|
494
|
+
return self.state.get("session_title") or ""
|
|
495
|
+
|
|
496
|
+
def model_suggestions(self) -> list[str]:
|
|
497
|
+
return self.state.get("models") or []
|
|
498
|
+
|
|
499
|
+
def meridian_thinking_state(self) -> tuple[None, None]:
|
|
500
|
+
return None, None # Probed in the host, where the proxy is configured.
|
|
501
|
+
|
|
502
|
+
async def warn_meridian_thinking(self) -> None:
|
|
503
|
+
pass # The host warns at startup.
|
|
504
|
+
|
|
505
|
+
def check_bridge(self, thread: str):
|
|
506
|
+
"""What the side-answer viewer checks before offering to merge; the host checks again."""
|
|
507
|
+
if self.activity.busy or self.activity.queued:
|
|
508
|
+
raise ValueError("Adding to the conversation waits for the running turn")
|
|
509
|
+
return self.asides.follows(thread)
|
|
510
|
+
|
|
511
|
+
def follow_up_aside(self, thread: str, question: str) -> None:
|
|
512
|
+
self.asides.follows(thread) # Refuses here what the host would refuse.
|
|
513
|
+
|
|
514
|
+
async def ask() -> None:
|
|
515
|
+
try:
|
|
516
|
+
await self.query("follow_up_aside", thread, question)
|
|
517
|
+
except ValueError as error:
|
|
518
|
+
self.view.warning(str(error))
|
|
519
|
+
|
|
520
|
+
asyncio.create_task(ask())
|
|
521
|
+
|
|
522
|
+
async def navigate_tree(self, identity: str | None, *, edit: bool = False) -> str:
|
|
523
|
+
return await self.query("navigate_tree", identity, edit)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@dataclass
|
|
527
|
+
class HostLaunch:
|
|
528
|
+
"""A host to connect to once the terminal's event loop runs: just spawned, or running."""
|
|
529
|
+
|
|
530
|
+
id: str
|
|
531
|
+
process: subprocess.Popen | None = None
|
|
532
|
+
log: Path | None = None
|
|
533
|
+
directory: Path | None = None
|
|
534
|
+
|
|
535
|
+
async def connect(self, view, activity) -> tuple[RemoteController, dict]:
|
|
536
|
+
return await wait_for_host(
|
|
537
|
+
self.id, view, activity, self.process, self.log, directory=self.directory
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
@classmethod
|
|
541
|
+
def running(cls, entry: HostEntry) -> "HostLaunch":
|
|
542
|
+
return cls(entry.id, log=Path(entry.log) if entry.log else None)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def spawn_host(
|
|
546
|
+
*,
|
|
547
|
+
model: str,
|
|
548
|
+
workspace: Path,
|
|
549
|
+
resume: str | None = None,
|
|
550
|
+
session_dir: Path | None = None,
|
|
551
|
+
no_save: bool = False,
|
|
552
|
+
worktree=None,
|
|
553
|
+
no_worktree: bool = False,
|
|
554
|
+
fork: bool = False,
|
|
555
|
+
directory: Path | None = None,
|
|
556
|
+
profile: RemoteProfile | None = None,
|
|
557
|
+
) -> tuple[str, subprocess.Popen, Path]:
|
|
558
|
+
"""Start a host from this process, so it inherits this terminal's environment.
|
|
559
|
+
|
|
560
|
+
With a remote `profile` it inherits only the allowlisted part of it, always
|
|
561
|
+
works in a fresh worktree, and runs under the profile's limits.
|
|
562
|
+
"""
|
|
563
|
+
directory = directory or host_dir()
|
|
564
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
565
|
+
identity = uuid4().hex[:8]
|
|
566
|
+
socket_path(identity, directory) # Fail here, not in the child, on a too-long path.
|
|
567
|
+
log = directory / f"{identity}.log"
|
|
568
|
+
argv = [
|
|
569
|
+
sys.executable,
|
|
570
|
+
"-m",
|
|
571
|
+
"pcode.host",
|
|
572
|
+
"--id",
|
|
573
|
+
identity,
|
|
574
|
+
"--model",
|
|
575
|
+
model,
|
|
576
|
+
"--workspace",
|
|
577
|
+
str(workspace),
|
|
578
|
+
]
|
|
579
|
+
if resume:
|
|
580
|
+
argv += ["--resume", resume]
|
|
581
|
+
if fork:
|
|
582
|
+
argv.append("--fork")
|
|
583
|
+
if session_dir:
|
|
584
|
+
argv += ["--session-dir", str(session_dir)]
|
|
585
|
+
if no_save:
|
|
586
|
+
argv.append("--no-save")
|
|
587
|
+
if profile is not None:
|
|
588
|
+
# The profile always makes a worktree; only its name may be chosen.
|
|
589
|
+
argv += ["--remote-profile", profile.to_json()]
|
|
590
|
+
if isinstance(worktree, str):
|
|
591
|
+
argv += ["--worktree", worktree]
|
|
592
|
+
elif no_worktree:
|
|
593
|
+
argv.append("--no-worktree")
|
|
594
|
+
elif isinstance(worktree, str):
|
|
595
|
+
argv += ["--worktree", worktree]
|
|
596
|
+
elif worktree:
|
|
597
|
+
argv.append("--worktree")
|
|
598
|
+
inherited = os.environ if profile is None else scrubbed_env(os.environ)
|
|
599
|
+
env = {**inherited, "PCODE_HOST_LOG": str(log), "PCODE_HOST_DIR": str(directory)}
|
|
600
|
+
fd = os.open(log, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600)
|
|
601
|
+
with os.fdopen(fd, "ab") as output:
|
|
602
|
+
process = subprocess.Popen(
|
|
603
|
+
argv,
|
|
604
|
+
stdin=subprocess.DEVNULL,
|
|
605
|
+
stdout=output,
|
|
606
|
+
stderr=output,
|
|
607
|
+
cwd=workspace,
|
|
608
|
+
env=env,
|
|
609
|
+
# Its own session: closing this terminal's tab does not signal it.
|
|
610
|
+
start_new_session=True,
|
|
611
|
+
)
|
|
612
|
+
return identity, process, log
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
async def wait_for_host(
|
|
616
|
+
identity: str,
|
|
617
|
+
view,
|
|
618
|
+
activity,
|
|
619
|
+
process: subprocess.Popen | None = None,
|
|
620
|
+
log: Path | None = None,
|
|
621
|
+
*,
|
|
622
|
+
directory: Path | None = None,
|
|
623
|
+
timeout: float = 300.0,
|
|
624
|
+
) -> tuple[RemoteController, dict]:
|
|
625
|
+
"""Connect once the host is listening. Worktree setup can make that a while."""
|
|
626
|
+
path = socket_path(identity, directory)
|
|
627
|
+
deadline = time.monotonic() + timeout
|
|
628
|
+
while True:
|
|
629
|
+
if process is not None and process.poll() is not None:
|
|
630
|
+
raise HostError(_exit_message(process.returncode, log))
|
|
631
|
+
if process is None and not path.exists() and not path.with_suffix(".json").exists():
|
|
632
|
+
# Neither socket nor entry: it stopped (both go together), as
|
|
633
|
+
# opposed to starting up, which writes the entry long before the socket.
|
|
634
|
+
raise HostError(f"Session host {identity} is no longer running.")
|
|
635
|
+
if path.exists():
|
|
636
|
+
try:
|
|
637
|
+
return await RemoteController.connect(path, view, activity)
|
|
638
|
+
except HostError:
|
|
639
|
+
if process is None:
|
|
640
|
+
raise
|
|
641
|
+
if time.monotonic() > deadline:
|
|
642
|
+
raise HostError(f"The session host did not start within {timeout:.0f}s; see {log}.")
|
|
643
|
+
await asyncio.sleep(0.05)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _exit_message(code: int, log: Path | None) -> str:
|
|
647
|
+
tail = ""
|
|
648
|
+
if log is not None:
|
|
649
|
+
try:
|
|
650
|
+
lines = log.read_text(errors="replace").strip().splitlines()
|
|
651
|
+
tail = "\n".join(lines[-8:])
|
|
652
|
+
except OSError:
|
|
653
|
+
pass
|
|
654
|
+
message = f"The session host exited during startup (status {code})."
|
|
655
|
+
if tail:
|
|
656
|
+
message += f"\n{tail}"
|
|
657
|
+
if log is not None:
|
|
658
|
+
message += f"\nLog: {log}"
|
|
659
|
+
return message
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def others(current: str | None, directory: Path | None = None) -> list[HostEntry]:
|
|
663
|
+
return [entry for entry in list_hosts(directory) if entry.id != current]
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _running(pid: int) -> bool:
|
|
667
|
+
# A host this terminal spawned is its child: until reaped it lingers as a
|
|
668
|
+
# zombie that still answers kill(pid, 0), so a wait would run to its timeout.
|
|
669
|
+
try:
|
|
670
|
+
reaped, _ = os.waitpid(pid, os.WNOHANG)
|
|
671
|
+
except ChildProcessError:
|
|
672
|
+
pass # Not our child (attached to a running host): kill() tells.
|
|
673
|
+
else:
|
|
674
|
+
if reaped:
|
|
675
|
+
return False
|
|
676
|
+
try:
|
|
677
|
+
os.kill(pid, 0)
|
|
678
|
+
except ProcessLookupError:
|
|
679
|
+
return False
|
|
680
|
+
except PermissionError:
|
|
681
|
+
return True
|
|
682
|
+
return True
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
async def wait_for_exit(pid: int, timeout: float = 30.0) -> None:
|
|
686
|
+
"""Until the host has let go of its session: the next host can then open it."""
|
|
687
|
+
deadline = time.monotonic() + timeout
|
|
688
|
+
while _running(pid) and time.monotonic() < deadline:
|
|
689
|
+
await asyncio.sleep(0.05)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def wait_for_exit_sync(pid: int, timeout: float = 30.0) -> None:
|
|
693
|
+
deadline = time.monotonic() + timeout
|
|
694
|
+
while _running(pid) and time.monotonic() < deadline:
|
|
695
|
+
time.sleep(0.05)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
async def stop_entry(entry: HostEntry, *, keep_worktree: bool = False) -> None:
|
|
699
|
+
"""Stop another host without attaching to it."""
|
|
700
|
+
await notify_host(entry, "stop", keep_worktree)
|
|
701
|
+
await wait_for_exit(entry.pid)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
async def notify_host(entry: HostEntry, method: str, *args) -> None:
|
|
705
|
+
"""Make one call on a host without attaching to it (`stop`, `cancel`)."""
|
|
706
|
+
reader, writer = await asyncio.open_unix_connection(str(entry.socket), limit=LINE_LIMIT)
|
|
707
|
+
writer.write(dumps({"type": "hello", "protocol": PROTOCOL}))
|
|
708
|
+
await writer.drain()
|
|
709
|
+
reply = await read_message(reader)
|
|
710
|
+
if reply is None or reply.get("type") != "ready":
|
|
711
|
+
writer.close()
|
|
712
|
+
raise HostError((reply or {}).get("message") or "The session host refused.")
|
|
713
|
+
peer = Peer(reader, writer, object(), allowed=frozenset())
|
|
714
|
+
serving = asyncio.create_task(peer.serve())
|
|
715
|
+
peer.notify(method, *args)
|
|
716
|
+
peer.close()
|
|
717
|
+
await asyncio.gather(serving, return_exceptions=True)
|