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/gateway.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""Session hosts for clients with no terminal: start one, send it a message, stop it.
|
|
2
|
+
|
|
3
|
+
`pcode --attach HOST --print` and the email listener (`pcode.email_remote`) both
|
|
4
|
+
drive hosts without an editor. What they share lives here: attaching as a
|
|
5
|
+
headless caller, sending one message and following only its own turn (the host
|
|
6
|
+
says when it starts, or that it was dropped, in order with what it shows; see
|
|
7
|
+
`pcode.host._Owner`), and detaching. Each client brings a `HeadlessView`
|
|
8
|
+
subclass for what to do with the turn's output.
|
|
9
|
+
|
|
10
|
+
The rest is the surface a remote control needs:
|
|
11
|
+
|
|
12
|
+
start_session(workspace, profile=..., resume=None) -> HostEntry
|
|
13
|
+
send(entry, text) -> TurnResult # waits for the message's turn to end
|
|
14
|
+
status(entry) -> SessionStatus
|
|
15
|
+
stop(entry) # cancels the turn and the host's queue
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import time
|
|
24
|
+
from contextlib import asynccontextmanager
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from pcode.host_protocol import HostEntry, find_host, socket_path
|
|
29
|
+
from pcode.remote import HostError, RemoteController, notify_host, spawn_host
|
|
30
|
+
from pcode.remote_profile import LIMIT_PREFIX, RemoteProfile
|
|
31
|
+
from pcode.rpc import RemoteError
|
|
32
|
+
from pcode.runtime import Message, TextDelta
|
|
33
|
+
from pcode.ui import Activity
|
|
34
|
+
|
|
35
|
+
# How a message's turn ended, as `TurnResult.outcome` reports it.
|
|
36
|
+
COMPLETED, FAILED, CANCELLED, LIMIT = "completed", "failed", "cancelled", "limit"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HeadlessView:
|
|
40
|
+
"""The view a headless caller gives the host: its own turn or command, nothing else.
|
|
41
|
+
|
|
42
|
+
Tracks whether this caller's message or command is the one running, and
|
|
43
|
+
how it ended. What to do with its output is the subclass's: `event` for
|
|
44
|
+
each turn event, `scrollback` for notes, warnings and errors, `retry` when
|
|
45
|
+
a provider request is retried, `settle` when a reply block must be closed,
|
|
46
|
+
and `gone` when the host went away first.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Scrollback passed on while this caller's own turn or command runs.
|
|
50
|
+
SCROLLBACK = frozenset(
|
|
51
|
+
{"user", "note", "retained_note", "flash", "cancelled", "tool_result", "shell_result"}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
# The host's live-panel fields, mirrored by the RemoteController.
|
|
56
|
+
self.activity = Activity()
|
|
57
|
+
# Whether the message's turn is running, or the command sent is.
|
|
58
|
+
self.turn = False
|
|
59
|
+
self.command = False
|
|
60
|
+
self.failed = False
|
|
61
|
+
# The message was dropped from the host's queue without running.
|
|
62
|
+
self.dropped = False
|
|
63
|
+
# The command sent ended the session, so the host stopping is its doing.
|
|
64
|
+
self.ended = False
|
|
65
|
+
# How the turn ended: the host's prompt state ("done", "failed", "cancelled").
|
|
66
|
+
self.prompt_state = ""
|
|
67
|
+
self.finished = asyncio.Event()
|
|
68
|
+
|
|
69
|
+
# What a subclass does with the output
|
|
70
|
+
|
|
71
|
+
def event(self, event) -> None:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def retry(self, text: str) -> None:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def settle(self) -> None:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
def scrollback(self, name: str, args: tuple, kwargs: dict) -> None:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def gone(self, *, stopped: bool) -> None:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
# What the host says about this caller's own message or command
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def showing(self) -> bool:
|
|
90
|
+
return self.turn or self.command
|
|
91
|
+
|
|
92
|
+
def message_started(self) -> None:
|
|
93
|
+
self.turn = True
|
|
94
|
+
|
|
95
|
+
def message_dropped(self) -> None:
|
|
96
|
+
self.dropped = self.failed = True
|
|
97
|
+
self.finished.set()
|
|
98
|
+
|
|
99
|
+
def session_ended(self) -> None:
|
|
100
|
+
self.ended = True
|
|
101
|
+
|
|
102
|
+
def host_gone(self, *, stopped: bool) -> None:
|
|
103
|
+
if self.finished.is_set():
|
|
104
|
+
return
|
|
105
|
+
self.settle()
|
|
106
|
+
if not self.ended:
|
|
107
|
+
self.failed = True
|
|
108
|
+
self.gone(stopped=stopped)
|
|
109
|
+
self.finished.set()
|
|
110
|
+
|
|
111
|
+
# The turn
|
|
112
|
+
|
|
113
|
+
def turn_event(self, event) -> None:
|
|
114
|
+
if self.turn:
|
|
115
|
+
self.event(event)
|
|
116
|
+
|
|
117
|
+
def turn_retry(self, text: str) -> None:
|
|
118
|
+
if self.turn:
|
|
119
|
+
self.retry(text)
|
|
120
|
+
|
|
121
|
+
def turn_ended(self) -> None:
|
|
122
|
+
if self.turn:
|
|
123
|
+
self.settle()
|
|
124
|
+
|
|
125
|
+
async def after_turn(self) -> None:
|
|
126
|
+
if self.turn:
|
|
127
|
+
self.turn = False
|
|
128
|
+
self.prompt_state = self.activity.prompt_state
|
|
129
|
+
self.failed = self.failed or self.prompt_state != "done"
|
|
130
|
+
self.finished.set()
|
|
131
|
+
|
|
132
|
+
# Scrollback
|
|
133
|
+
|
|
134
|
+
def _scrollback(self, name: str, *args, **kwargs) -> None:
|
|
135
|
+
if self.showing:
|
|
136
|
+
self.settle()
|
|
137
|
+
self.scrollback(name, args, kwargs)
|
|
138
|
+
|
|
139
|
+
def warning(self, text: str) -> None:
|
|
140
|
+
self._scrollback("warning", text)
|
|
141
|
+
# A command's warning is a refusal ("unavailable while working"), which
|
|
142
|
+
# a caller must not read as success. A turn's is advice; how the turn
|
|
143
|
+
# ended says whether it worked.
|
|
144
|
+
self.failed = self.failed or self.command
|
|
145
|
+
|
|
146
|
+
def error(self, text: str, *, title: str = "Error") -> None:
|
|
147
|
+
self._scrollback("error", text, title=title)
|
|
148
|
+
self.failed = self.failed or self.command
|
|
149
|
+
|
|
150
|
+
def show_events(self, events) -> None:
|
|
151
|
+
# Not `_scrollback`: tool events go beside a reply block, not after settling it.
|
|
152
|
+
if self.showing:
|
|
153
|
+
self.scrollback("events", (tuple(events),), {})
|
|
154
|
+
|
|
155
|
+
# What the host hands back to the terminal that sent a command
|
|
156
|
+
|
|
157
|
+
async def run_command(self, text: str, *, idle: bool, tag) -> None:
|
|
158
|
+
# Terminal commands never reach the host from here, so this is one it lacks.
|
|
159
|
+
self.error(f"Unknown command {text.split(maxsplit=1)[0]}.")
|
|
160
|
+
|
|
161
|
+
async def _popup(self, *args):
|
|
162
|
+
self.error("This command opens a picker, which needs the interactive terminal.")
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
choose_model = read_asides = browse_jobs = _popup
|
|
166
|
+
|
|
167
|
+
def __getattr__(self, name: str):
|
|
168
|
+
if name.startswith("_"):
|
|
169
|
+
raise AttributeError(name)
|
|
170
|
+
if name in self.SCROLLBACK:
|
|
171
|
+
return lambda *args, **kwargs: self._scrollback(name, *args, **kwargs)
|
|
172
|
+
# Anything else repaints a live panel this caller does not have.
|
|
173
|
+
return lambda *args, **kwargs: None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@asynccontextmanager
|
|
177
|
+
async def attached(entry: HostEntry, view: HeadlessView):
|
|
178
|
+
"""Attach to the host as a headless caller; what it showed before is not ours."""
|
|
179
|
+
controller, welcome = await RemoteController.connect(entry.socket, view, view.activity)
|
|
180
|
+
# Wired before anything else is awaited, so no close goes unnoticed.
|
|
181
|
+
controller.on_closed = lambda: view.host_gone(stopped=controller.host_stopped)
|
|
182
|
+
if controller.peer.closed.is_set():
|
|
183
|
+
controller.on_closed()
|
|
184
|
+
try:
|
|
185
|
+
await controller.start({**welcome, "calls": []})
|
|
186
|
+
yield controller
|
|
187
|
+
finally:
|
|
188
|
+
await controller.detach()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def host_call(controller: RemoteController, method: str, text: str, view: HeadlessView):
|
|
192
|
+
"""A call only newer hosts have; None when the host went away (and has said so)."""
|
|
193
|
+
try:
|
|
194
|
+
return await controller.peer.request(method, text)
|
|
195
|
+
except RemoteError as error:
|
|
196
|
+
if error.type_name == "PermissionError":
|
|
197
|
+
raise HostError(
|
|
198
|
+
"The session host runs older pcode; /restart it to send it messages headlessly."
|
|
199
|
+
) from error
|
|
200
|
+
raise
|
|
201
|
+
except ConnectionError:
|
|
202
|
+
view.host_gone(stopped=controller.host_stopped)
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def submit(
|
|
207
|
+
controller: RemoteController, text: str, view: HeadlessView, on_queued=None
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Queue `text` behind whatever the host is doing, and wait for its turn to end.
|
|
210
|
+
|
|
211
|
+
`on_queued` is called once the host holds the message, so a caller that
|
|
212
|
+
wants to cancel it knows a cancel from then on reaches it.
|
|
213
|
+
"""
|
|
214
|
+
await host_call(controller, "send", text, view)
|
|
215
|
+
if on_queued is not None:
|
|
216
|
+
on_queued()
|
|
217
|
+
await view.finished.wait()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# The remote-control surface
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass
|
|
224
|
+
class ChangeSummary:
|
|
225
|
+
"""Where a session's work is, and what it changed since `base`."""
|
|
226
|
+
|
|
227
|
+
worktree: str
|
|
228
|
+
branch: str
|
|
229
|
+
diff_stat: str
|
|
230
|
+
untracked: list[str] = field(default_factory=list)
|
|
231
|
+
|
|
232
|
+
def describe(self) -> str:
|
|
233
|
+
lines = [f"Worktree: {self.worktree}", f"Branch: {self.branch or '(detached)'}"]
|
|
234
|
+
if self.diff_stat:
|
|
235
|
+
lines += ["", self.diff_stat]
|
|
236
|
+
if self.untracked:
|
|
237
|
+
shown = self.untracked[:20]
|
|
238
|
+
more = len(self.untracked) - len(shown)
|
|
239
|
+
lines += ["", "New files:", *(f" {path}" for path in shown)]
|
|
240
|
+
if more:
|
|
241
|
+
lines.append(f" … and {more} more")
|
|
242
|
+
if not self.diff_stat and not self.untracked:
|
|
243
|
+
lines.append("No changes.")
|
|
244
|
+
return "\n".join(lines)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass
|
|
248
|
+
class TurnResult:
|
|
249
|
+
"""How one message's turn went: its reply, how it ended, and what it changed."""
|
|
250
|
+
|
|
251
|
+
reply: str
|
|
252
|
+
outcome: str
|
|
253
|
+
# Warnings and errors the turn showed, in order.
|
|
254
|
+
notes: list[str] = field(default_factory=list)
|
|
255
|
+
changes: ChangeSummary | None = None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass
|
|
259
|
+
class SessionStatus:
|
|
260
|
+
host: str
|
|
261
|
+
session_id: str
|
|
262
|
+
# "starting", "idle", "working", or "stopped" (no host running).
|
|
263
|
+
state: str
|
|
264
|
+
title: str = ""
|
|
265
|
+
last_prompt: str = ""
|
|
266
|
+
turns: int = 0
|
|
267
|
+
outcome: str = ""
|
|
268
|
+
workspace: str = ""
|
|
269
|
+
attached: int = 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class CollectView(HeadlessView):
|
|
273
|
+
"""Keeps the turn's reply text and its warnings, for a client that sends them on."""
|
|
274
|
+
|
|
275
|
+
def __init__(self) -> None:
|
|
276
|
+
super().__init__()
|
|
277
|
+
self.block = ""
|
|
278
|
+
self.messages: list[str] = []
|
|
279
|
+
self.notes: list[str] = []
|
|
280
|
+
self.limit = ""
|
|
281
|
+
|
|
282
|
+
def event(self, event) -> None:
|
|
283
|
+
if isinstance(event, TextDelta):
|
|
284
|
+
self.block += event.text
|
|
285
|
+
elif isinstance(event, Message):
|
|
286
|
+
text = event.markdown or self.block
|
|
287
|
+
if text.strip():
|
|
288
|
+
self.messages.append(text.strip())
|
|
289
|
+
self.block = ""
|
|
290
|
+
|
|
291
|
+
def retry(self, text: str) -> None:
|
|
292
|
+
# The retried request's partial text was abandoned.
|
|
293
|
+
self.block = ""
|
|
294
|
+
self.notes.append(text)
|
|
295
|
+
|
|
296
|
+
def settle(self) -> None:
|
|
297
|
+
if self.block.strip():
|
|
298
|
+
self.messages.append(self.block.strip())
|
|
299
|
+
self.block = ""
|
|
300
|
+
|
|
301
|
+
def scrollback(self, name: str, args: tuple, kwargs: dict) -> None:
|
|
302
|
+
if name in ("warning", "error") and args:
|
|
303
|
+
text = str(args[0])
|
|
304
|
+
self.notes.append(text)
|
|
305
|
+
if text.startswith(LIMIT_PREFIX):
|
|
306
|
+
self.limit = text
|
|
307
|
+
|
|
308
|
+
def gone(self, *, stopped: bool) -> None:
|
|
309
|
+
if self.ended:
|
|
310
|
+
self.notes.append("The session ended, and its host stopped.")
|
|
311
|
+
else:
|
|
312
|
+
self.notes.append(
|
|
313
|
+
"The session host stopped before this finished."
|
|
314
|
+
if stopped
|
|
315
|
+
else "The session host went away."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def outcome(self) -> str:
|
|
319
|
+
if self.limit:
|
|
320
|
+
return LIMIT
|
|
321
|
+
if self.dropped or self.prompt_state == "cancelled":
|
|
322
|
+
return CANCELLED
|
|
323
|
+
return FAILED if self.failed else COMPLETED
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# Hosts started here, reaped once they exit so none lingers as a zombie.
|
|
327
|
+
_children: set[asyncio.Task] = set()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _reap(process: subprocess.Popen) -> None:
|
|
331
|
+
task = asyncio.get_running_loop().create_task(asyncio.to_thread(process.wait))
|
|
332
|
+
_children.add(task)
|
|
333
|
+
task.add_done_callback(_children.discard)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def start_session(
|
|
337
|
+
workspace: Path,
|
|
338
|
+
*,
|
|
339
|
+
profile: RemoteProfile | None,
|
|
340
|
+
model: str | None = None,
|
|
341
|
+
resume: str | None = None,
|
|
342
|
+
session_dir: Path | None = None,
|
|
343
|
+
directory: Path | None = None,
|
|
344
|
+
timeout: float = 300.0,
|
|
345
|
+
) -> HostEntry:
|
|
346
|
+
"""Start a host and return its entry once it accepts connections.
|
|
347
|
+
|
|
348
|
+
`resume` continues a saved session (one whose host idled out) on the model
|
|
349
|
+
it was using; otherwise `model`, else the saved default.
|
|
350
|
+
"""
|
|
351
|
+
from pcode.preferences import load_preferences
|
|
352
|
+
from pcode.sessions import read_info, resolve_session
|
|
353
|
+
|
|
354
|
+
if resume:
|
|
355
|
+
model = read_info(resolve_session(resume, session_dir, workspace)).model
|
|
356
|
+
model = model or load_preferences().get("model")
|
|
357
|
+
if not model:
|
|
358
|
+
raise ValueError("A session host needs a model: set a default with /model.")
|
|
359
|
+
identity, process, log = spawn_host(
|
|
360
|
+
model=model,
|
|
361
|
+
workspace=workspace,
|
|
362
|
+
resume=resume,
|
|
363
|
+
session_dir=session_dir,
|
|
364
|
+
directory=directory,
|
|
365
|
+
profile=profile,
|
|
366
|
+
worktree=True,
|
|
367
|
+
)
|
|
368
|
+
_reap(process)
|
|
369
|
+
path = socket_path(identity, directory)
|
|
370
|
+
deadline = time.monotonic() + timeout
|
|
371
|
+
while not path.exists():
|
|
372
|
+
if process.poll() is not None:
|
|
373
|
+
tail = log.read_text(errors="replace").strip().splitlines()[-8:]
|
|
374
|
+
raise HostError(
|
|
375
|
+
f"The session host exited during startup (status {process.returncode}).\n"
|
|
376
|
+
+ "\n".join(tail)
|
|
377
|
+
+ f"\nLog: {log}"
|
|
378
|
+
)
|
|
379
|
+
if time.monotonic() > deadline:
|
|
380
|
+
raise HostError(f"The session host did not start within {timeout:.0f}s; see {log}.")
|
|
381
|
+
await asyncio.sleep(0.05)
|
|
382
|
+
return find_host(identity, directory)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# Settings that would let a repository's config run a command, overridden for
|
|
386
|
+
# every git call made on a worktree an unattended model has written to.
|
|
387
|
+
_INERT_GIT = (
|
|
388
|
+
"-c", "core.fsmonitor=false",
|
|
389
|
+
"-c", "core.hooksPath=/dev/null",
|
|
390
|
+
"-c", "core.pager=cat",
|
|
391
|
+
"-c", "diff.external=",
|
|
392
|
+
"-c", "core.sshCommand=false",
|
|
393
|
+
) # fmt: skip
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def inert_git(workspace: str | Path, *args: str) -> str:
|
|
397
|
+
"""Run git on `workspace` with nothing in its config able to run code; stdout or ""."""
|
|
398
|
+
env = {
|
|
399
|
+
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
400
|
+
"HOME": os.environ.get("HOME", "/"),
|
|
401
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
402
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
403
|
+
}
|
|
404
|
+
result = subprocess.run(
|
|
405
|
+
["git", *_INERT_GIT, "-C", str(workspace), *args],
|
|
406
|
+
capture_output=True,
|
|
407
|
+
text=True,
|
|
408
|
+
timeout=30,
|
|
409
|
+
env=env,
|
|
410
|
+
)
|
|
411
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def changes(workspace: str | Path, base: str | None = None) -> ChangeSummary:
|
|
415
|
+
"""What the worktree changed since `base` (committed or not); since HEAD without one."""
|
|
416
|
+
return ChangeSummary(
|
|
417
|
+
worktree=str(workspace),
|
|
418
|
+
branch=inert_git(workspace, "symbolic-ref", "--quiet", "--short", "HEAD"),
|
|
419
|
+
diff_stat=inert_git(
|
|
420
|
+
workspace, "diff", "--no-ext-diff", "--no-textconv", "--stat", base or "HEAD"
|
|
421
|
+
),
|
|
422
|
+
untracked=inert_git(workspace, "ls-files", "--others", "--exclude-standard").splitlines(),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
async def send(
|
|
427
|
+
entry: HostEntry, text: str, *, base: str | None = None, on_queued=None
|
|
428
|
+
) -> TurnResult:
|
|
429
|
+
"""Send `text` as its own turn (queued behind the host's work); wait for it to end."""
|
|
430
|
+
view = CollectView()
|
|
431
|
+
async with attached(entry, view) as controller:
|
|
432
|
+
if controller.startup_error is not None:
|
|
433
|
+
return TurnResult("", FAILED, [str(controller.startup_error)])
|
|
434
|
+
await submit(controller, text, view, on_queued)
|
|
435
|
+
if view.dropped and controller.startup_error is not None:
|
|
436
|
+
view.notes.append(str(controller.startup_error))
|
|
437
|
+
view.settle()
|
|
438
|
+
summary = await asyncio.to_thread(changes, entry.workspace, base)
|
|
439
|
+
return TurnResult("\n\n".join(view.messages), view.outcome(), view.notes, summary)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def status(entry: HostEntry, directory: Path | None = None) -> SessionStatus:
|
|
443
|
+
"""What the host says about itself now; "stopped" once it is gone."""
|
|
444
|
+
try:
|
|
445
|
+
current = find_host(entry.id, directory)
|
|
446
|
+
except LookupError:
|
|
447
|
+
return SessionStatus(
|
|
448
|
+
entry.id, entry.session_id, "stopped", entry.title, workspace=entry.workspace
|
|
449
|
+
)
|
|
450
|
+
return SessionStatus(
|
|
451
|
+
host=current.id,
|
|
452
|
+
session_id=current.session_id,
|
|
453
|
+
state=current.state,
|
|
454
|
+
title=current.title,
|
|
455
|
+
last_prompt=current.last_prompt,
|
|
456
|
+
turns=current.turns,
|
|
457
|
+
outcome=current.outcome,
|
|
458
|
+
workspace=current.workspace,
|
|
459
|
+
attached=current.attached,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def stop(entry: HostEntry) -> None:
|
|
464
|
+
"""Cancel the running turn and everything queued behind it; the host keeps running."""
|
|
465
|
+
await notify_host(entry, "cancel")
|