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/host.py
ADDED
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
"""A headless process that owns one conversation, for terminals to attach to.
|
|
2
|
+
|
|
3
|
+
The host runs a `SessionController` exactly as a terminal would in-process,
|
|
4
|
+
but renders nothing: its view, `HostView`, sends every call to the attached
|
|
5
|
+
terminals over `pcode.rpc`, and keeps the calls since the running turn began
|
|
6
|
+
so a terminal attaching mid-turn can catch up. Terminals come and go; the host
|
|
7
|
+
stops when told to, when the session ends itself, or after an idle timeout.
|
|
8
|
+
|
|
9
|
+
Started by `pcode.remote.spawn_host`, which runs `python -m pcode.host` from the
|
|
10
|
+
terminal that asked for it, so the host inherits that terminal's environment
|
|
11
|
+
(direnv credentials, `PATH`) the way a local session would.
|
|
12
|
+
|
|
13
|
+
The connection starts with one plain line each way (`hello`, then `ready` or
|
|
14
|
+
`error`), so a host and terminal on different protocol versions refuse each
|
|
15
|
+
other with a message instead of hanging. Then both ends are `rpc.Peer`s: the
|
|
16
|
+
terminal calls `attach` for its `welcome`, sends intents (`INTENTS`) and
|
|
17
|
+
queries, and receives view calls.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import asyncio
|
|
22
|
+
import itertools
|
|
23
|
+
import os
|
|
24
|
+
import signal
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from pcode import remote_profile
|
|
30
|
+
from pcode.controller import INTENTS, SESSION_FIELDS, SessionController
|
|
31
|
+
from pcode.error_report import error_message
|
|
32
|
+
from pcode.host_protocol import (
|
|
33
|
+
LINE_LIMIT,
|
|
34
|
+
PROTOCOL,
|
|
35
|
+
HostEntry,
|
|
36
|
+
code_fingerprint,
|
|
37
|
+
dumps,
|
|
38
|
+
forget_stopped,
|
|
39
|
+
host_dir,
|
|
40
|
+
read_message,
|
|
41
|
+
remember_stopped,
|
|
42
|
+
remove_entry,
|
|
43
|
+
socket_path,
|
|
44
|
+
write_entry,
|
|
45
|
+
)
|
|
46
|
+
from pcode.rpc import Peer
|
|
47
|
+
from pcode.runtime import CommandOutput, EditPreview, TextDelta, ThinkingDelta
|
|
48
|
+
from pcode.ui import Activity
|
|
49
|
+
|
|
50
|
+
# Consecutive events of these kinds merge in the catch-up buffer: deltas join,
|
|
51
|
+
# and a newer snapshot of the same call replaces the older one.
|
|
52
|
+
DELTA_EVENTS = (TextDelta, ThinkingDelta)
|
|
53
|
+
|
|
54
|
+
# How long a host with nothing to do waits for a terminal before stopping, when
|
|
55
|
+
# `session_host_idle_minutes` is 0: long enough to /switch away and back.
|
|
56
|
+
IDLE_GRACE_SECONDS = 15.0
|
|
57
|
+
SNAPSHOT_EVENTS = (CommandOutput, EditPreview)
|
|
58
|
+
|
|
59
|
+
# View calls that change what the conversation is, so a terminal attaching
|
|
60
|
+
# later reads it from the journal instead of from the buffer.
|
|
61
|
+
RESETS = {"conversation_reset", "replay_conversation", "show_branch"}
|
|
62
|
+
|
|
63
|
+
# What a terminal may call besides the controller's intents.
|
|
64
|
+
HOST_CALLS = frozenset({"attach", "query", "send", "run", "stop", "asides_read"})
|
|
65
|
+
|
|
66
|
+
_MISSING = object()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MirroredActivity(Activity):
|
|
70
|
+
"""The host's live-panel state; each change to a session field goes to the terminals."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, send) -> None:
|
|
73
|
+
object.__setattr__(self, "_send", None)
|
|
74
|
+
object.__setattr__(self, "_sent", {})
|
|
75
|
+
super().__init__()
|
|
76
|
+
object.__setattr__(self, "_send", send)
|
|
77
|
+
|
|
78
|
+
def __setattr__(self, name: str, value) -> None:
|
|
79
|
+
object.__setattr__(self, name, value)
|
|
80
|
+
if name in SESSION_FIELDS and self._send is not None:
|
|
81
|
+
self.push()
|
|
82
|
+
|
|
83
|
+
def push(self) -> None:
|
|
84
|
+
"""Send the session fields that changed since the last push; lists compare by value."""
|
|
85
|
+
changes = {}
|
|
86
|
+
for name in SESSION_FIELDS:
|
|
87
|
+
value = getattr(self, name)
|
|
88
|
+
if isinstance(value, list):
|
|
89
|
+
value = list(value)
|
|
90
|
+
if self._sent.get(name, _MISSING) != value:
|
|
91
|
+
self._sent[name] = value
|
|
92
|
+
changes[name] = value
|
|
93
|
+
if changes:
|
|
94
|
+
self._send(changes)
|
|
95
|
+
|
|
96
|
+
def session_fields(self) -> dict:
|
|
97
|
+
return {name: getattr(self, name) for name in SESSION_FIELDS}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _Run:
|
|
101
|
+
"""A command sent with `run`, until it has run."""
|
|
102
|
+
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self.done = asyncio.get_running_loop().create_future()
|
|
105
|
+
# The background tasks (compaction, MCP) as it began; None until it does.
|
|
106
|
+
self.work: tuple | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _Owner:
|
|
110
|
+
"""A headless caller's queued message: when it starts, or that it never will.
|
|
111
|
+
|
|
112
|
+
Told to that caller alone, in order with the view calls, so everything
|
|
113
|
+
between `message_started` and `after_turn` is its turn.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, client: "_Client") -> None:
|
|
117
|
+
self.client = client
|
|
118
|
+
|
|
119
|
+
def started(self) -> None:
|
|
120
|
+
self.client.peer.notify("message_started")
|
|
121
|
+
|
|
122
|
+
def dropped(self) -> None:
|
|
123
|
+
self.client.peer.notify("message_dropped")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class _Client:
|
|
127
|
+
"""One attached terminal, and the calls it may make."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, host: "SessionHost", number: int) -> None:
|
|
130
|
+
self.host = host
|
|
131
|
+
self.number = number
|
|
132
|
+
self.peer: Peer | None = None
|
|
133
|
+
# A caller with no editor (`--attach --print`): popups and side-answer
|
|
134
|
+
# notices meant for "the terminal last used" skip it, and it does not
|
|
135
|
+
# count as a terminal watching the session.
|
|
136
|
+
self.headless = False
|
|
137
|
+
# What attaching took from the terminals, given back if it turns out headless.
|
|
138
|
+
self.displaced: _Client | None = None
|
|
139
|
+
self.unseen_before = False
|
|
140
|
+
|
|
141
|
+
# Intents: the controller's, with commands tagged by who sent them.
|
|
142
|
+
|
|
143
|
+
def submit(self, text: str, mode: str) -> None:
|
|
144
|
+
if mode == "shell" and remote_profile.active() is not None:
|
|
145
|
+
# A `!command` runs outside the sandbox; an unattended host takes none,
|
|
146
|
+
# whoever connects (its own model's commands cannot connect at all).
|
|
147
|
+
raise PermissionError("Shell mode is off in a remote session.")
|
|
148
|
+
self.host.touch(self)
|
|
149
|
+
self.host.controller.submit(text, mode)
|
|
150
|
+
|
|
151
|
+
def command(self, text: str, tag=None) -> None:
|
|
152
|
+
self.host.touch(self)
|
|
153
|
+
self.host.controller.command(text, (self.number, tag))
|
|
154
|
+
|
|
155
|
+
# Host calls for a caller with no editor to watch
|
|
156
|
+
|
|
157
|
+
def send(self, text: str) -> None:
|
|
158
|
+
"""Queue `text` as a turn of its own; the caller hears when it starts (see `_Owner`)."""
|
|
159
|
+
self.become_headless()
|
|
160
|
+
self.host.active_at = time.monotonic()
|
|
161
|
+
self.host.controller.submit(text, "queue", owner=_Owner(self))
|
|
162
|
+
|
|
163
|
+
async def run(self, text: str) -> bool:
|
|
164
|
+
"""`command`, returning once it has run, and whether it did.
|
|
165
|
+
|
|
166
|
+
A command dropped from the queue (a Ctrl+C elsewhere cleared it) or
|
|
167
|
+
refused (one that waits for an idle session) did not. Background work
|
|
168
|
+
the command started (compaction, MCP sign-in) is waited for, as its
|
|
169
|
+
outcome is the point of sending, say, `/compact`.
|
|
170
|
+
"""
|
|
171
|
+
self.become_headless()
|
|
172
|
+
self.host.active_at = time.monotonic()
|
|
173
|
+
tag = (self.number, f"run-{next(self.host.run_ids)}")
|
|
174
|
+
run = self.host.runs[tag] = _Run()
|
|
175
|
+
try:
|
|
176
|
+
self.host.controller.command(text, tag)
|
|
177
|
+
await run.done
|
|
178
|
+
finally:
|
|
179
|
+
self.host.runs.pop(tag, None)
|
|
180
|
+
if run.work is None:
|
|
181
|
+
return False
|
|
182
|
+
controller = self.host.controller
|
|
183
|
+
compaction, mcp = run.work
|
|
184
|
+
if controller.compact_task not in (None, compaction):
|
|
185
|
+
await controller.compact_idle.wait()
|
|
186
|
+
if controller.mcp_task not in (None, mcp):
|
|
187
|
+
await controller.mcp_idle.wait()
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
def become_headless(self) -> None:
|
|
191
|
+
"""This caller has no editor: give the terminals back what attaching took."""
|
|
192
|
+
if self.headless:
|
|
193
|
+
return
|
|
194
|
+
self.headless = True
|
|
195
|
+
host = self.host
|
|
196
|
+
if host._latest is self:
|
|
197
|
+
host._latest = self.displaced
|
|
198
|
+
host.update(attached=len(host.terminals()), unseen=host.entry.unseen or self.unseen_before)
|
|
199
|
+
|
|
200
|
+
def cancel(self) -> None:
|
|
201
|
+
self.host.controller.cancel()
|
|
202
|
+
|
|
203
|
+
def set_thinking(self, mode: str) -> None:
|
|
204
|
+
self.host.controller.set_thinking(mode)
|
|
205
|
+
|
|
206
|
+
def adjust_effort(self, direction: int) -> None:
|
|
207
|
+
self.host.controller.adjust_effort(direction)
|
|
208
|
+
self.host.controller.view.session_changed()
|
|
209
|
+
|
|
210
|
+
def stop_jobs(self, job_ids) -> None:
|
|
211
|
+
self.host.controller.stop_jobs(job_ids)
|
|
212
|
+
|
|
213
|
+
def cancel_asides(self) -> None:
|
|
214
|
+
self.host.controller.cancel_asides()
|
|
215
|
+
|
|
216
|
+
def watch_job(self, job_id) -> None:
|
|
217
|
+
self.host.controller.watch_job(job_id)
|
|
218
|
+
|
|
219
|
+
# Host calls
|
|
220
|
+
|
|
221
|
+
def attach(self) -> dict:
|
|
222
|
+
"""Everything needed to show the session now; later changes follow as view calls.
|
|
223
|
+
|
|
224
|
+
Built and registered with no await in between, so every change arrives
|
|
225
|
+
exactly once: in this welcome or after it.
|
|
226
|
+
"""
|
|
227
|
+
return self.host.attach(self)
|
|
228
|
+
|
|
229
|
+
async def query(self, name: str, *args):
|
|
230
|
+
return await self.host.controller.query(name, *args)
|
|
231
|
+
|
|
232
|
+
def asides_read(self, identities) -> None:
|
|
233
|
+
"""The terminal showed these answers; no terminal needs telling they are ready."""
|
|
234
|
+
for aside in self.host.controller.asides.items:
|
|
235
|
+
if aside.id in identities and not aside.read:
|
|
236
|
+
aside.read = True
|
|
237
|
+
self.host.emit_aside(aside)
|
|
238
|
+
|
|
239
|
+
def stop(self, keep_worktree: bool = False) -> None:
|
|
240
|
+
# A remote profile keeps its worktree however the host is stopped.
|
|
241
|
+
self.host.keep_worktree = bool(keep_worktree) or remote_profile.active() is not None
|
|
242
|
+
self.host.stop()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class HostView:
|
|
246
|
+
"""The controller's view in a host: calls go to the attached terminals.
|
|
247
|
+
|
|
248
|
+
Plain calls go to every terminal and into the catch-up buffer. Popups and
|
|
249
|
+
commands handed back go to the terminal whose command is running (a
|
|
250
|
+
command's tag carries its sender), or else the one that sent the last
|
|
251
|
+
thing; with no terminal attached they are answered with nothing.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
def __init__(self, host: "SessionHost") -> None:
|
|
255
|
+
self._host = host
|
|
256
|
+
|
|
257
|
+
def __getattr__(self, name: str):
|
|
258
|
+
if name.startswith("_"):
|
|
259
|
+
raise AttributeError(name)
|
|
260
|
+
|
|
261
|
+
def call(*args, **kwargs):
|
|
262
|
+
self._host.emit(name, args, kwargs)
|
|
263
|
+
|
|
264
|
+
return call
|
|
265
|
+
|
|
266
|
+
# Turns: the buffer restarts where a turn begins.
|
|
267
|
+
|
|
268
|
+
def turn_started(self, text: str, *, echo: bool) -> None:
|
|
269
|
+
self._host.turn_began(text)
|
|
270
|
+
self._host.emit("turn_started", (text,), {"echo": echo})
|
|
271
|
+
|
|
272
|
+
async def after_turn(self) -> None:
|
|
273
|
+
self._host.turn_finished()
|
|
274
|
+
self._host.emit("after_turn", (), {})
|
|
275
|
+
|
|
276
|
+
async def after_command(self, tag=None) -> None:
|
|
277
|
+
# Terminals get it untagged, as ever; a `run` caller is waiting for its own.
|
|
278
|
+
self._host.emit("after_command", (), {})
|
|
279
|
+
run = self._host.run_for(tag)
|
|
280
|
+
if run is not None and not run.done.done():
|
|
281
|
+
run.done.set_result(None)
|
|
282
|
+
if not self._host.controller.running:
|
|
283
|
+
# Only the terminal that sent it: `sender` would guess for an untagged one.
|
|
284
|
+
client = self._host.sender(tag)[0] if isinstance(tag, (list, tuple)) else None
|
|
285
|
+
if client is not None:
|
|
286
|
+
# Ahead of `host_closed`, which can overtake the reply to `run`:
|
|
287
|
+
# ending the session was what this caller asked for, so its
|
|
288
|
+
# terminal exits as an in-process one would, not as after a crash.
|
|
289
|
+
client.peer.notify("session_ended")
|
|
290
|
+
self._host.stop()
|
|
291
|
+
|
|
292
|
+
def session_changed(self) -> None:
|
|
293
|
+
self._host.push_state()
|
|
294
|
+
|
|
295
|
+
def commands_changed(self) -> None:
|
|
296
|
+
self._host.push_state()
|
|
297
|
+
|
|
298
|
+
def redraw(self) -> None:
|
|
299
|
+
pass # Terminals repaint on what they receive.
|
|
300
|
+
|
|
301
|
+
def aside_changed(self, aside) -> None:
|
|
302
|
+
self._host.emit_aside(aside)
|
|
303
|
+
|
|
304
|
+
def aside_answered(self, aside) -> None:
|
|
305
|
+
self._host.emit_aside(aside)
|
|
306
|
+
client = self._host.latest_client()
|
|
307
|
+
if client is not None:
|
|
308
|
+
client.peer.notify("aside_answered", self._host.aside_state(aside))
|
|
309
|
+
|
|
310
|
+
# Requests to one terminal
|
|
311
|
+
|
|
312
|
+
async def run_command(self, text: str, *, idle: bool, tag) -> None:
|
|
313
|
+
self._host.run_began(tag)
|
|
314
|
+
client, own_tag = self._host.sender(tag)
|
|
315
|
+
if client is None:
|
|
316
|
+
return
|
|
317
|
+
try:
|
|
318
|
+
await client.peer.request("run_command", text, idle=idle, tag=own_tag)
|
|
319
|
+
except ConnectionError:
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
def command_started(self, tag) -> None:
|
|
323
|
+
self._host.run_began(tag)
|
|
324
|
+
client, own_tag = self._host.sender(tag)
|
|
325
|
+
self._host.running_command = client
|
|
326
|
+
if client is not None:
|
|
327
|
+
client.peer.notify("command_started", own_tag)
|
|
328
|
+
|
|
329
|
+
def command_finished(self) -> None:
|
|
330
|
+
client, self._host.running_command = self._host.running_command, None
|
|
331
|
+
if client is not None and client.peer is not None:
|
|
332
|
+
client.peer.notify("command_finished")
|
|
333
|
+
|
|
334
|
+
async def _ask(self, method: str, *args):
|
|
335
|
+
client = self._host.running_command or self._host.latest_client()
|
|
336
|
+
if client is None:
|
|
337
|
+
self._host.emit("note", (f"{method} needs an attached terminal.",), {})
|
|
338
|
+
return None
|
|
339
|
+
try:
|
|
340
|
+
return await client.peer.request(method, *args)
|
|
341
|
+
except ConnectionError:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
async def choose_model(self, values, providers, current):
|
|
345
|
+
return await self._ask("choose_model", list(values), sorted(providers), current)
|
|
346
|
+
|
|
347
|
+
async def read_asides(self):
|
|
348
|
+
return await self._ask("read_asides")
|
|
349
|
+
|
|
350
|
+
async def browse_jobs(self) -> None:
|
|
351
|
+
await self._ask("browse_jobs")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class SessionHost:
|
|
355
|
+
"""Serves one controller to the terminals attached to it."""
|
|
356
|
+
|
|
357
|
+
def __init__(self, entry: HostEntry, directory: Path | None = None) -> None:
|
|
358
|
+
self.entry = entry
|
|
359
|
+
self.directory = directory or host_dir()
|
|
360
|
+
self.clients: dict[int, _Client] = {}
|
|
361
|
+
self._numbers = itertools.count(1)
|
|
362
|
+
self._latest: _Client | None = None
|
|
363
|
+
self.running_command: _Client | None = None
|
|
364
|
+
# Commands sent with `run`, by tag, until they have run.
|
|
365
|
+
self.runs: dict[tuple, _Run] = {}
|
|
366
|
+
self.run_ids = itertools.count(1)
|
|
367
|
+
self.view = HostView(self)
|
|
368
|
+
self.activity = MirroredActivity(lambda changes: self.emit("state", (changes,), {}))
|
|
369
|
+
self.controller = SessionController(self.view, self.activity)
|
|
370
|
+
self.controller.interactive = True
|
|
371
|
+
# A loop task cancelled while the host shuts down is not a Ctrl+C.
|
|
372
|
+
self.controller.closing = lambda: not self.controller.running
|
|
373
|
+
# Everything shown since the journal offset `settled_end`: a terminal
|
|
374
|
+
# attaching reads the journal up to there and replays these after it.
|
|
375
|
+
self.buffer: list[tuple[str, tuple, dict]] = []
|
|
376
|
+
self.settled_end: int | None = None
|
|
377
|
+
self._state: dict = {}
|
|
378
|
+
self.stopped = asyncio.Event()
|
|
379
|
+
# A restart resumes this session in its worktree, so stopping must not tidy it away.
|
|
380
|
+
self.keep_worktree = False
|
|
381
|
+
self.server: asyncio.AbstractServer | None = None
|
|
382
|
+
self.tasks: list[asyncio.Task] = []
|
|
383
|
+
# When the host last had a terminal or work; the idle clock starts here.
|
|
384
|
+
self.active_at = time.monotonic()
|
|
385
|
+
# Stopped for being idle, not asked to: an unseen result is remembered.
|
|
386
|
+
self.idle_stopped = False
|
|
387
|
+
# Open connections, counted from hello on: a terminal mid-handshake is not
|
|
388
|
+
# a client yet, and must not find the host gone.
|
|
389
|
+
self.connections = 0
|
|
390
|
+
# A remote profile's per-turn wall clock, while a turn runs.
|
|
391
|
+
self._turn_clock: asyncio.TimerHandle | None = None
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def socket(self) -> Path:
|
|
395
|
+
return socket_path(self.entry.id, self.directory)
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def busy(self) -> bool:
|
|
399
|
+
return bool(self.activity.busy or self.controller.working())
|
|
400
|
+
|
|
401
|
+
# Serving
|
|
402
|
+
|
|
403
|
+
async def serve(self) -> None:
|
|
404
|
+
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
405
|
+
path = self.socket
|
|
406
|
+
path.unlink(missing_ok=True)
|
|
407
|
+
self.server = await asyncio.start_unix_server(self._accept, path=path, limit=LINE_LIMIT)
|
|
408
|
+
os.chmod(path, 0o600)
|
|
409
|
+
self.update(state="idle")
|
|
410
|
+
if self.entry.session_id:
|
|
411
|
+
# Running again, so /switch lists the host instead of the stopped one.
|
|
412
|
+
forget_stopped(self.entry.session_id, self.directory)
|
|
413
|
+
|
|
414
|
+
async def boot(self) -> None:
|
|
415
|
+
"""Build the runtime, say what a new session says, then start the loops.
|
|
416
|
+
|
|
417
|
+
Terminals may attach while this runs: they see the startup notes as
|
|
418
|
+
they come, and anything they send waits for the loops.
|
|
419
|
+
"""
|
|
420
|
+
controller = self.controller
|
|
421
|
+
try:
|
|
422
|
+
await controller.initialize_runtime()
|
|
423
|
+
except Exception as error: # noqa: BLE001 - reported to the terminals.
|
|
424
|
+
controller.startup_error = error
|
|
425
|
+
self.view.error(error_message(error), title="Agent startup failed")
|
|
426
|
+
print(f"startup failed: {error_message(error)}", file=sys.stderr, flush=True)
|
|
427
|
+
finally:
|
|
428
|
+
controller.startup_pending = False
|
|
429
|
+
# A resumed conversation is in the journal already; what follows is not.
|
|
430
|
+
self.reset_buffer()
|
|
431
|
+
if controller.startup_error is None:
|
|
432
|
+
if controller.resuming:
|
|
433
|
+
# Terminals attached while it loaded draw it now (the state
|
|
434
|
+
# first, so they know which journal to read).
|
|
435
|
+
self.push_state()
|
|
436
|
+
self.view.replay_conversation()
|
|
437
|
+
controller.show_startup_context()
|
|
438
|
+
controller.warn_without_credentials()
|
|
439
|
+
await controller.warn_meridian_thinking()
|
|
440
|
+
# Optional, and it can hang; the session must not wait for it.
|
|
441
|
+
self.tasks.append(asyncio.create_task(self._refresh_context()))
|
|
442
|
+
profile = remote_profile.active()
|
|
443
|
+
if profile is None or profile.mcp:
|
|
444
|
+
controller.start_mcp_defaults()
|
|
445
|
+
self.push_state()
|
|
446
|
+
self.start()
|
|
447
|
+
|
|
448
|
+
async def _refresh_context(self) -> None:
|
|
449
|
+
refresh = getattr(self.controller.runtime, "refresh_context", None)
|
|
450
|
+
if refresh is None:
|
|
451
|
+
return
|
|
452
|
+
try:
|
|
453
|
+
await refresh()
|
|
454
|
+
except Exception as error: # noqa: BLE001 - metadata only.
|
|
455
|
+
print(f"context metadata unavailable: {error}", file=sys.stderr, flush=True)
|
|
456
|
+
self.push_state()
|
|
457
|
+
|
|
458
|
+
def start(self) -> None:
|
|
459
|
+
"""Run the controller's loops: turns, commands, and background jobs."""
|
|
460
|
+
controller = self.controller
|
|
461
|
+
for work in (controller.consume(), controller.consume_commands(), controller.watch_jobs()):
|
|
462
|
+
self.tasks.append(asyncio.create_task(work))
|
|
463
|
+
controller.ready.set()
|
|
464
|
+
|
|
465
|
+
async def _accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
466
|
+
self.connections += 1
|
|
467
|
+
self.active_at = time.monotonic()
|
|
468
|
+
try:
|
|
469
|
+
await self._handshake(reader, writer)
|
|
470
|
+
finally:
|
|
471
|
+
self.connections -= 1
|
|
472
|
+
|
|
473
|
+
async def _handshake(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
474
|
+
try:
|
|
475
|
+
hello = await read_message(reader)
|
|
476
|
+
except ValueError:
|
|
477
|
+
hello = None
|
|
478
|
+
if hello is None or hello.get("type") != "hello":
|
|
479
|
+
writer.close()
|
|
480
|
+
return
|
|
481
|
+
if hello.get("protocol") != PROTOCOL:
|
|
482
|
+
writer.write(
|
|
483
|
+
dumps(
|
|
484
|
+
{
|
|
485
|
+
"type": "error",
|
|
486
|
+
"message": f"This session host speaks protocol {PROTOCOL}, the terminal "
|
|
487
|
+
f"{hello.get('protocol')}. Restart one of them on the same pcode.",
|
|
488
|
+
}
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
await writer.drain()
|
|
492
|
+
writer.close()
|
|
493
|
+
return
|
|
494
|
+
writer.write(dumps({"type": "ready", "protocol": PROTOCOL}))
|
|
495
|
+
client = _Client(self, next(self._numbers))
|
|
496
|
+
client.peer = Peer(reader, writer, client, allowed=INTENTS | HOST_CALLS)
|
|
497
|
+
try:
|
|
498
|
+
await client.peer.serve()
|
|
499
|
+
except (ValueError, ConnectionError):
|
|
500
|
+
pass
|
|
501
|
+
finally:
|
|
502
|
+
client.peer.close()
|
|
503
|
+
if self.clients.pop(client.number, None) is not None:
|
|
504
|
+
self.active_at = time.monotonic()
|
|
505
|
+
if self._latest is client:
|
|
506
|
+
self._latest = next(reversed(self.clients.values()), None)
|
|
507
|
+
if self.running_command is client:
|
|
508
|
+
self.running_command = None
|
|
509
|
+
if not self.stopped.is_set():
|
|
510
|
+
self.update(attached=len(self.terminals()))
|
|
511
|
+
|
|
512
|
+
def attach(self, client: _Client) -> dict:
|
|
513
|
+
from pcode.rpc import encode
|
|
514
|
+
|
|
515
|
+
self.clients[client.number] = client
|
|
516
|
+
client.displaced, self._latest = self._latest, client
|
|
517
|
+
client.unseen_before = self.entry.unseen
|
|
518
|
+
# Shown now, so whatever finished while nobody watched has been seen.
|
|
519
|
+
self.update(attached=len(self.terminals()), unseen=False)
|
|
520
|
+
self._state = self.controller.session_state()
|
|
521
|
+
return {
|
|
522
|
+
"id": self.entry.id,
|
|
523
|
+
"pid": os.getpid(),
|
|
524
|
+
"session": self._state,
|
|
525
|
+
"activity": self.activity.session_fields(),
|
|
526
|
+
"asides": [self.aside_state(aside) for aside in self.controller.asides.items],
|
|
527
|
+
"settled_end": self.settled_end,
|
|
528
|
+
"forked_from": getattr(
|
|
529
|
+
getattr(self.controller.runtime, "session", None), "forked_from", None
|
|
530
|
+
)
|
|
531
|
+
or "",
|
|
532
|
+
"fork_requested": getattr(
|
|
533
|
+
getattr(self.controller.runtime, "session", None), "fork_requested", False
|
|
534
|
+
),
|
|
535
|
+
# Already encoded: a buffered call's arguments are whatever the view got.
|
|
536
|
+
"calls": [
|
|
537
|
+
[name, encode(list(args)), encode(kwargs)] for name, args, kwargs in self.buffer
|
|
538
|
+
],
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
def touch(self, client: _Client) -> None:
|
|
542
|
+
self._latest = client
|
|
543
|
+
self.active_at = time.monotonic()
|
|
544
|
+
|
|
545
|
+
def terminals(self) -> list[_Client]:
|
|
546
|
+
"""The attached clients with an editor, in attach order: not headless callers."""
|
|
547
|
+
return [client for client in self.clients.values() if not client.headless]
|
|
548
|
+
|
|
549
|
+
def latest_client(self) -> _Client | None:
|
|
550
|
+
"""The terminal last used, for what goes to one terminal; never a headless caller."""
|
|
551
|
+
latest = self._latest
|
|
552
|
+
if latest is not None and self.clients.get(latest.number) is latest and not latest.headless:
|
|
553
|
+
return latest
|
|
554
|
+
# Attach order: the newest terminal that has an editor.
|
|
555
|
+
return next(reversed(self.terminals()), None)
|
|
556
|
+
|
|
557
|
+
def sender(self, tag) -> tuple[_Client | None, object]:
|
|
558
|
+
"""The terminal a command came from, and the tag it gave it."""
|
|
559
|
+
if isinstance(tag, (list, tuple)) and len(tag) == 2:
|
|
560
|
+
return self.clients.get(tag[0]), tag[1]
|
|
561
|
+
return self.latest_client(), None
|
|
562
|
+
|
|
563
|
+
def run_for(self, tag) -> _Run | None:
|
|
564
|
+
# Only `run` tags are strings: a terminal's own tag may not even be hashable.
|
|
565
|
+
if isinstance(tag, tuple) and len(tag) == 2 and isinstance(tag[1], str):
|
|
566
|
+
return self.runs.get(tag)
|
|
567
|
+
return None
|
|
568
|
+
|
|
569
|
+
def run_began(self, tag) -> None:
|
|
570
|
+
"""A `run` command is being handled: note the background work already going."""
|
|
571
|
+
if (run := self.run_for(tag)) is not None:
|
|
572
|
+
run.work = (self.controller.compact_task, self.controller.mcp_task)
|
|
573
|
+
|
|
574
|
+
# Showing
|
|
575
|
+
|
|
576
|
+
def emit(self, method: str, args: tuple, kwargs: dict) -> None:
|
|
577
|
+
"""Send a view call to every terminal and keep it for the ones that attach later."""
|
|
578
|
+
if method in RESETS:
|
|
579
|
+
self.reset_buffer()
|
|
580
|
+
for client in list(self.clients.values()):
|
|
581
|
+
client.peer.notify(method, *args, **kwargs)
|
|
582
|
+
if method in RESETS:
|
|
583
|
+
return
|
|
584
|
+
self._buffer(method, args, kwargs)
|
|
585
|
+
|
|
586
|
+
def _buffer(self, method: str, args: tuple, kwargs: dict) -> None:
|
|
587
|
+
previous = self.buffer[-1] if self.buffer else None
|
|
588
|
+
if method == "turn_event" and previous is not None and previous[0] == "turn_event":
|
|
589
|
+
event, before = args[0], previous[1][0]
|
|
590
|
+
if type(event) is type(before) and isinstance(event, DELTA_EVENTS):
|
|
591
|
+
merged = type(event)(before.text + event.text)
|
|
592
|
+
self.buffer[-1] = ("turn_event", (merged,), previous[2])
|
|
593
|
+
return
|
|
594
|
+
if (
|
|
595
|
+
type(event) is type(before)
|
|
596
|
+
and isinstance(event, SNAPSHOT_EVENTS)
|
|
597
|
+
and event.call_id == before.call_id
|
|
598
|
+
):
|
|
599
|
+
self.buffer[-1] = (method, args, kwargs)
|
|
600
|
+
return
|
|
601
|
+
self.buffer.append((method, args, kwargs))
|
|
602
|
+
|
|
603
|
+
def reset_buffer(self) -> None:
|
|
604
|
+
"""The journal now holds everything shown so far; start the buffer again."""
|
|
605
|
+
session = getattr(self.controller.runtime, "session", None)
|
|
606
|
+
self.settled_end = session.journal_size() if session is not None else None
|
|
607
|
+
self.buffer = []
|
|
608
|
+
|
|
609
|
+
def aside_state(self, aside) -> dict:
|
|
610
|
+
"""A side question as a terminal needs it: everything but the reply it continues from."""
|
|
611
|
+
return {
|
|
612
|
+
"id": aside.id,
|
|
613
|
+
"question": aside.question,
|
|
614
|
+
"model": aside.model,
|
|
615
|
+
"label": aside.label,
|
|
616
|
+
"effort": aside.effort,
|
|
617
|
+
"status": aside.status,
|
|
618
|
+
"answer": aside.answer,
|
|
619
|
+
"activity": aside.activity,
|
|
620
|
+
"error": aside.error,
|
|
621
|
+
# Monotonic clock readings, which are system-wide: comparable in the terminal.
|
|
622
|
+
"started": aside.started,
|
|
623
|
+
"finished": aside.finished,
|
|
624
|
+
"thread": aside.thread,
|
|
625
|
+
"conversation": aside.conversation,
|
|
626
|
+
"base": aside.base,
|
|
627
|
+
"bridged": aside.bridged,
|
|
628
|
+
"replied": aside.reply is not None,
|
|
629
|
+
"read": aside.read,
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
def emit_aside(self, aside) -> None:
|
|
633
|
+
for client in list(self.clients.values()):
|
|
634
|
+
client.peer.notify("aside_changed", self.aside_state(aside))
|
|
635
|
+
|
|
636
|
+
def push_state(self) -> None:
|
|
637
|
+
"""Send the session state if it changed since the last one sent."""
|
|
638
|
+
state = self.controller.session_state()
|
|
639
|
+
if state != self._state:
|
|
640
|
+
self._state = state
|
|
641
|
+
for client in list(self.clients.values()):
|
|
642
|
+
client.peer.notify("session_state", state)
|
|
643
|
+
session = getattr(self.controller.runtime, "session", None)
|
|
644
|
+
if session is not None and session.info.id != self.entry.session_id:
|
|
645
|
+
self.update(session_id=session.info.id)
|
|
646
|
+
forget_stopped(session.info.id, self.directory)
|
|
647
|
+
# /switch lists the session by its name once it has one, not its first prompt.
|
|
648
|
+
if (title := state.get("session_title")) and title != self.entry.title:
|
|
649
|
+
self.update(title=title)
|
|
650
|
+
|
|
651
|
+
# The entry other terminals read
|
|
652
|
+
|
|
653
|
+
def update(self, **changes) -> None:
|
|
654
|
+
for key, value in changes.items():
|
|
655
|
+
setattr(self.entry, key, value)
|
|
656
|
+
write_entry(self.entry, self.directory)
|
|
657
|
+
|
|
658
|
+
def turn_began(self, prompt: str) -> None:
|
|
659
|
+
self.reset_buffer()
|
|
660
|
+
self.update(state="working", last_prompt=prompt, title=self.entry.title or prompt)
|
|
661
|
+
profile = remote_profile.active()
|
|
662
|
+
if profile is not None:
|
|
663
|
+
remote_profile.BUDGET.start()
|
|
664
|
+
if profile.turn_minutes:
|
|
665
|
+
self._turn_clock = asyncio.get_running_loop().call_later(
|
|
666
|
+
profile.turn_minutes * 60, self.turn_expired
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
def turn_expired(self) -> None:
|
|
670
|
+
"""The remote profile's wall clock ran out: stop the turn and say why."""
|
|
671
|
+
self._turn_clock = None
|
|
672
|
+
controller = self.controller
|
|
673
|
+
task = controller.live_task
|
|
674
|
+
if task is None or task.done():
|
|
675
|
+
return
|
|
676
|
+
self.view.warning(remote_profile.BUDGET.expire())
|
|
677
|
+
# As Ctrl+C does: commands the turn is waiting on stop with it.
|
|
678
|
+
controller.set_cancel_policy("stop")
|
|
679
|
+
task.cancel()
|
|
680
|
+
|
|
681
|
+
def turn_finished(self) -> None:
|
|
682
|
+
if self._turn_clock is not None:
|
|
683
|
+
self._turn_clock.cancel()
|
|
684
|
+
self._turn_clock = None
|
|
685
|
+
outcome = {"done": "done", "failed": "failed", "cancelled": "cancelled"}.get(
|
|
686
|
+
self.activity.prompt_state, "done"
|
|
687
|
+
)
|
|
688
|
+
self.active_at = time.monotonic()
|
|
689
|
+
self.push_state()
|
|
690
|
+
self.update(
|
|
691
|
+
state="idle",
|
|
692
|
+
outcome=outcome,
|
|
693
|
+
turns=self.entry.turns + 1,
|
|
694
|
+
# A headless `--attach -p` caller printed the reply, so it was seen.
|
|
695
|
+
unseen=not self.clients,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
# Stopping
|
|
699
|
+
|
|
700
|
+
def idle(self) -> bool:
|
|
701
|
+
"""Nothing would be lost by stopping: no terminal, no work, no running job."""
|
|
702
|
+
jobs = getattr(self.controller.runtime, "jobs", None)
|
|
703
|
+
running = jobs.running() if jobs is not None else []
|
|
704
|
+
return (
|
|
705
|
+
not self.clients
|
|
706
|
+
and not self.connections
|
|
707
|
+
and not self.busy
|
|
708
|
+
and self.controller.command_idle.is_set()
|
|
709
|
+
and not running
|
|
710
|
+
and not self.controller.startup_pending
|
|
711
|
+
and not self.controller.asides.running
|
|
712
|
+
# A title asked for beside the first turn would be lost.
|
|
713
|
+
and (self.controller.naming_task is None or self.controller.naming_task.done())
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
async def stop_when_idle(
|
|
717
|
+
self, minutes: float, *, every: float = 2.0, grace: float = IDLE_GRACE_SECONDS
|
|
718
|
+
) -> None:
|
|
719
|
+
"""Stop once idle for `minutes`, or for `grace` seconds when `minutes` is 0.
|
|
720
|
+
|
|
721
|
+
Polled rather than told: jobs and side questions end without a hook,
|
|
722
|
+
and reading this state every couple of seconds costs nothing. The
|
|
723
|
+
journal keeps the conversation, and a result nobody saw stays listed
|
|
724
|
+
in /switch (`remember_stopped`).
|
|
725
|
+
"""
|
|
726
|
+
limit = minutes * 60 if minutes else grace
|
|
727
|
+
while not self.stopped.is_set():
|
|
728
|
+
await asyncio.sleep(every)
|
|
729
|
+
if not self.idle():
|
|
730
|
+
self.active_at = time.monotonic()
|
|
731
|
+
elif time.monotonic() - self.active_at >= limit:
|
|
732
|
+
print(f"idle for {limit:g} s; stopping", file=sys.stderr, flush=True)
|
|
733
|
+
self.idle_stopped = True
|
|
734
|
+
self.stop()
|
|
735
|
+
return
|
|
736
|
+
|
|
737
|
+
def stop(self) -> None:
|
|
738
|
+
self.stopped.set()
|
|
739
|
+
|
|
740
|
+
async def close(self) -> None:
|
|
741
|
+
controller = self.controller
|
|
742
|
+
controller.running = False
|
|
743
|
+
# Terminals hear that the host is going, not the teardown after it
|
|
744
|
+
# (a "Run cancelled" for a host stopped while idle).
|
|
745
|
+
clients, self.clients = list(self.clients.values()), {}
|
|
746
|
+
for client in clients:
|
|
747
|
+
client.peer.notify("host_closed")
|
|
748
|
+
client.peer.close()
|
|
749
|
+
controller.cancel()
|
|
750
|
+
for task in self.tasks:
|
|
751
|
+
task.cancel()
|
|
752
|
+
await asyncio.gather(*self.tasks, *controller.tasks(), return_exceptions=True)
|
|
753
|
+
await controller.asides.close()
|
|
754
|
+
await controller.stop_naming()
|
|
755
|
+
if self.server is not None:
|
|
756
|
+
self.server.close()
|
|
757
|
+
if controller.extensions is not None:
|
|
758
|
+
await controller.extensions.close()
|
|
759
|
+
remove_entry(self.entry.id, self.directory)
|
|
760
|
+
if self.idle_stopped and self.entry.unseen:
|
|
761
|
+
# After the entry goes, so /switch never sees both. Only a saved
|
|
762
|
+
# session has an ID, so only one /resume could reopen.
|
|
763
|
+
remember_stopped(self.entry, self.directory)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _parser() -> argparse.ArgumentParser:
|
|
767
|
+
parser = argparse.ArgumentParser(description="Run one pcode conversation headless.")
|
|
768
|
+
parser.add_argument("--id", required=True)
|
|
769
|
+
parser.add_argument("--model", required=True)
|
|
770
|
+
parser.add_argument("--workspace", type=Path, required=True)
|
|
771
|
+
parser.add_argument("--resume")
|
|
772
|
+
parser.add_argument("--fork", action="store_true")
|
|
773
|
+
parser.add_argument("--session-dir", type=Path)
|
|
774
|
+
parser.add_argument("--no-save", action="store_true")
|
|
775
|
+
parser.add_argument("--worktree", nargs="?", const=True)
|
|
776
|
+
parser.add_argument("--no-worktree", action="store_true")
|
|
777
|
+
# JSON from `RemoteProfile.to_json`: an unattended host's fixed limits.
|
|
778
|
+
parser.add_argument("--remote-profile")
|
|
779
|
+
return parser
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def remote_resume_workspace(info, workspace: Path, profile) -> Path:
|
|
783
|
+
"""Where a remote session resumes: its own worktree, else a fresh one.
|
|
784
|
+
|
|
785
|
+
A local resume whose worktree was merged or cleaned away falls back to the
|
|
786
|
+
project checkout. An unattended one must never work in the shared
|
|
787
|
+
checkout, so it gets a new worktree instead.
|
|
788
|
+
"""
|
|
789
|
+
from pcode.app import _enter_worktree
|
|
790
|
+
|
|
791
|
+
if Path(info.workspace).is_dir():
|
|
792
|
+
return Path(info.workspace).resolve()
|
|
793
|
+
print(f"workspace: {info.workspace} is gone; continuing in a new worktree", file=sys.stderr)
|
|
794
|
+
created, _ = _enter_worktree(workspace, True, always=True, base=profile.base)
|
|
795
|
+
return created.resolve()
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
async def _serve(args: argparse.Namespace) -> None:
|
|
799
|
+
from pcode.app import _enter_worktree, _resume_workspace
|
|
800
|
+
from pcode.preferences import (
|
|
801
|
+
SETTINGS,
|
|
802
|
+
load_preferences,
|
|
803
|
+
set_project_root,
|
|
804
|
+
thinking_mode_preference,
|
|
805
|
+
)
|
|
806
|
+
from pcode.project_trust import prompt_trust
|
|
807
|
+
from pcode.sessions import SavedSession, first_prompt
|
|
808
|
+
from pcode.worktree import leave_worktree
|
|
809
|
+
|
|
810
|
+
os.environ.setdefault("PYDANTIC_AI_NO_BANNER", "1")
|
|
811
|
+
profile = (
|
|
812
|
+
remote_profile.RemoteProfile.from_json(args.remote_profile) if args.remote_profile else None
|
|
813
|
+
)
|
|
814
|
+
# Before anything reads it: extensions, the sandbox policy, the agent.
|
|
815
|
+
remote_profile.activate(profile)
|
|
816
|
+
workspace = args.workspace.resolve()
|
|
817
|
+
entry = HostEntry(
|
|
818
|
+
id=args.id,
|
|
819
|
+
pid=os.getpid(),
|
|
820
|
+
model=args.model,
|
|
821
|
+
workspace=str(workspace),
|
|
822
|
+
log=os.environ.get("PCODE_HOST_LOG", ""),
|
|
823
|
+
code=code_fingerprint(),
|
|
824
|
+
)
|
|
825
|
+
write_entry(entry)
|
|
826
|
+
set_project_root(workspace)
|
|
827
|
+
# The terminal that started this host asked already; nobody is here to ask.
|
|
828
|
+
prompt_trust(workspace, ask=None)
|
|
829
|
+
saved = None
|
|
830
|
+
session_id = None
|
|
831
|
+
if args.resume:
|
|
832
|
+
saved = SavedSession.open(
|
|
833
|
+
args.resume, args.session_dir, workspace, fork_if_open=True, fork=args.fork
|
|
834
|
+
)
|
|
835
|
+
try:
|
|
836
|
+
if profile is not None:
|
|
837
|
+
workspace = remote_resume_workspace(saved.info, workspace, profile)
|
|
838
|
+
else:
|
|
839
|
+
workspace = Path(_resume_workspace(saved.info, workspace)).resolve()
|
|
840
|
+
except BaseException:
|
|
841
|
+
saved.abandon() # A copy made for this resume is removed, not left listed.
|
|
842
|
+
raise
|
|
843
|
+
entry.session_id = saved.info.id
|
|
844
|
+
prompt = first_prompt(saved.info, saved.directory.parent)
|
|
845
|
+
first = "" if prompt.startswith("(") else prompt
|
|
846
|
+
entry.title = saved.info.name or saved.info.title or first
|
|
847
|
+
elif profile is not None:
|
|
848
|
+
# Never the shared checkout, even from inside a linked worktree.
|
|
849
|
+
workspace, session_id = _enter_worktree(
|
|
850
|
+
workspace, args.worktree or True, always=True, base=profile.base
|
|
851
|
+
)
|
|
852
|
+
workspace = workspace.resolve()
|
|
853
|
+
elif not args.no_worktree:
|
|
854
|
+
workspace, session_id = _enter_worktree(workspace, args.worktree)
|
|
855
|
+
workspace = workspace.resolve()
|
|
856
|
+
entry.workspace = str(workspace)
|
|
857
|
+
write_entry(entry)
|
|
858
|
+
|
|
859
|
+
host = SessionHost(entry)
|
|
860
|
+
# An unattended session's worktree is left for its owner to inspect.
|
|
861
|
+
host.keep_worktree = profile is not None
|
|
862
|
+
controller = host.controller
|
|
863
|
+
controller.model = args.model
|
|
864
|
+
controller.workspace = workspace
|
|
865
|
+
controller.session_dir = saved.directory.parent if saved is not None else args.session_dir
|
|
866
|
+
controller.save_sessions = not args.no_save
|
|
867
|
+
controller._saved_session = saved
|
|
868
|
+
controller._session_id = session_id
|
|
869
|
+
controller.resuming = saved is not None
|
|
870
|
+
controller._needs_runtime = True
|
|
871
|
+
controller.startup_pending = True
|
|
872
|
+
controller.activity.thinking_mode = thinking_mode_preference()
|
|
873
|
+
controller.register_skills()
|
|
874
|
+
await host.serve()
|
|
875
|
+
loop = asyncio.get_running_loop()
|
|
876
|
+
for signum in (signal.SIGTERM, signal.SIGINT):
|
|
877
|
+
loop.add_signal_handler(signum, host.stop)
|
|
878
|
+
print(f"serving {host.socket}", file=sys.stderr, flush=True)
|
|
879
|
+
await host.boot()
|
|
880
|
+
watcher = None
|
|
881
|
+
try:
|
|
882
|
+
idle_minutes = _idle_minutes(load_preferences(), SETTINGS)
|
|
883
|
+
host.active_at = time.monotonic() # A slow boot is not idle time.
|
|
884
|
+
if idle_minutes is not None:
|
|
885
|
+
watcher = asyncio.create_task(host.stop_when_idle(idle_minutes))
|
|
886
|
+
await host.stopped.wait()
|
|
887
|
+
finally:
|
|
888
|
+
if watcher is not None:
|
|
889
|
+
watcher.cancel()
|
|
890
|
+
from pcode.claude_sdk import shutdown
|
|
891
|
+
|
|
892
|
+
try:
|
|
893
|
+
await host.close()
|
|
894
|
+
finally:
|
|
895
|
+
await shutdown()
|
|
896
|
+
runtime = controller.runtime
|
|
897
|
+
if runtime is not None and hasattr(runtime, "close"):
|
|
898
|
+
runtime.close()
|
|
899
|
+
if not host.keep_worktree:
|
|
900
|
+
# A terminal that asked to keep it tidies it itself, and can ask first.
|
|
901
|
+
leave_worktree(
|
|
902
|
+
controller.workspace,
|
|
903
|
+
getattr(runtime, "session", None),
|
|
904
|
+
ask=None,
|
|
905
|
+
notify=lambda text: print(text, file=sys.stderr, flush=True),
|
|
906
|
+
# Nobody chose to leave a session that stopped for being idle,
|
|
907
|
+
# so its branch waits to be resumed rather than merged.
|
|
908
|
+
merge=not host.idle_stopped,
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _idle_minutes(preferences: dict, settings: dict) -> int | None:
|
|
913
|
+
"""`session_host_idle_minutes` as minutes; None for off. A bad value is the default."""
|
|
914
|
+
key = "session_host_idle_minutes"
|
|
915
|
+
for value in (preferences.get(key), settings[key].default):
|
|
916
|
+
if value == "off":
|
|
917
|
+
return None
|
|
918
|
+
if isinstance(value, str) and value.isascii() and value.isdecimal():
|
|
919
|
+
return int(value)
|
|
920
|
+
return 0
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def main(argv: list[str] | None = None) -> None:
|
|
924
|
+
import faulthandler
|
|
925
|
+
|
|
926
|
+
args = _parser().parse_args(argv)
|
|
927
|
+
# `kill -USR1 PID` writes every thread's stack to the log, for a host that hangs.
|
|
928
|
+
faulthandler.register(signal.SIGUSR1)
|
|
929
|
+
# The terminal that started the host may close; the host outlives it.
|
|
930
|
+
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
|
931
|
+
try:
|
|
932
|
+
asyncio.run(_serve(args))
|
|
933
|
+
except Exception as error: # noqa: BLE001 - the log is the only reader.
|
|
934
|
+
print(f"session host failed: {error_message(error)}", file=sys.stderr, flush=True)
|
|
935
|
+
remove_entry(args.id)
|
|
936
|
+
raise SystemExit(1) from error
|
|
937
|
+
remove_entry(args.id)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
if __name__ == "__main__":
|
|
941
|
+
main()
|