pcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
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()