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