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