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/jobs.py ADDED
@@ -0,0 +1,584 @@
1
+ """Session-scoped registry of shell jobs that outlive the agent run.
2
+
3
+ One execution mechanism, two ways to wait. Every command is launched the same
4
+ way -- a detached supervisor in its own session, writing a combined output log
5
+ and an atomic status file -- so "foreground" and "background" differ only in
6
+ whether the caller waits for the result, not in how the process runs. That is
7
+ what makes the interesting transitions cheap: a wait can be abandoned when the
8
+ user interrupts with a follow-up without disturbing the command, and a
9
+ backgrounded job can be waited on later without re-running anything.
10
+
11
+ Ownership is deliberately here rather than in the tool call: the tool call is
12
+ the thing that gets cancelled. A registry that outlives it can still say what
13
+ is running, deliver exits without the model polling, and clean up logs.
14
+
15
+ The registry also outlives pcode. Its state lives under the XDG state dir, one
16
+ directory per pcode process holding the job logs and a `registry.json`, so a
17
+ later pcode can adopt whatever a dead one left running instead of the user
18
+ hunting for it with `ps`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import shutil
26
+ import signal
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ import threading
31
+ import time
32
+ from collections.abc import Callable, Iterable, Mapping
33
+ from contextlib import contextmanager
34
+ from contextvars import ContextVar
35
+ from dataclasses import dataclass, field
36
+ from pathlib import Path
37
+
38
+ # Logs of finished jobs are kept so the model can re-read output it already
39
+ # summarized, but not forever: each job owns a directory, and a long session
40
+ # runs hundreds. Evicting the oldest finished job bounds the disk cost without
41
+ # ever deleting a log while its command is still writing to it.
42
+ RETAINED_FINISHED_JOBS = 50
43
+
44
+ # The model-visible tail of a job's log. Matches the window Harness used, which
45
+ # is sized for a tool result rather than for the whole log.
46
+ OUTPUT_TAIL_BYTES = 16_000
47
+
48
+ # A stop is SIGTERM first, so a server can release its port and a migration can
49
+ # roll back; only a command that ignores it for this long is killed.
50
+ STOP_GRACE_SECONDS = 2.0
51
+
52
+ _SUPERVISOR = Path(__file__).with_name("_job_supervisor.py")
53
+
54
+
55
+ # Command-preview keys for a watched job (`/jobs watch`), so the preview can
56
+ # be shown for it even when the model's own commands are hidden.
57
+ WATCHED_PREFIX = "job:"
58
+
59
+
60
+ def jobs_root() -> Path:
61
+ """Where every pcode process keeps its job logs and registry record."""
62
+ state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
63
+ return state / "pcode" / "jobs"
64
+
65
+
66
+ def format_duration(seconds: float) -> str:
67
+ if seconds < 1:
68
+ return f"{seconds * 1000:.0f}ms"
69
+ if seconds < 60:
70
+ return f"{seconds:.1f}s"
71
+ minutes, rest = divmod(int(seconds), 60)
72
+ if minutes < 60:
73
+ return f"{minutes}m{rest:02d}s"
74
+ hours, minutes = divmod(minutes, 60)
75
+ return f"{hours}h{minutes:02d}m"
76
+
77
+
78
+ def _clip(text: str, limit: int) -> str:
79
+ text = " ".join(text.split())
80
+ return text if len(text) <= limit else text[: limit - 1] + "…"
81
+
82
+
83
+ @dataclass
84
+ class Job:
85
+ """One launched command and everything known about it without re-reading disk."""
86
+
87
+ id: str
88
+ command: str
89
+ directory: Path
90
+ supervisor_pid: int
91
+ started_at: float
92
+ background: bool
93
+ # Why the model ran it, in its own words, for jobs it expects to come back
94
+ # to. Empty for ordinary foreground commands, where the command is read
95
+ # next to its own result and a label would only repeat it.
96
+ purpose: str = ""
97
+ pid: int | None = None
98
+ exit_code: int | None = None
99
+ ended_at: float | None = None
100
+ stopped: bool = False
101
+ # Set when a foreground wait was abandoned rather than completed, so the
102
+ # next report can say why the model is holding a handle it did not ask for.
103
+ detached: bool = False
104
+ # Inherited from a pcode process that exited while this command ran. The
105
+ # model of this session never launched it, so it is never woken for it.
106
+ adopted: bool = False
107
+ # True while a tool call is blocking on this job. The terminal uses it to
108
+ # tell "in the background" from "what the live row already shows".
109
+ waiting: bool = False
110
+ announced: set[str] = field(default_factory=set)
111
+
112
+ @property
113
+ def output_path(self) -> Path:
114
+ return self.directory / "output.log"
115
+
116
+ @property
117
+ def status_path(self) -> Path:
118
+ return self.directory / "status.json"
119
+
120
+ @property
121
+ def running(self) -> bool:
122
+ return self.exit_code is None and not self.stopped
123
+
124
+ @property
125
+ def elapsed(self) -> float:
126
+ return (self.ended_at or time.time()) - self.started_at
127
+
128
+ def outcome(self) -> str:
129
+ if self.stopped:
130
+ return "stopped"
131
+ if self.exit_code is None:
132
+ return "running"
133
+ return f"exit {self.exit_code}"
134
+
135
+ def label(self) -> str:
136
+ """The shortest honest name for this job: its purpose, else its command."""
137
+ return _clip(self.purpose or self.command, 80)
138
+
139
+ def summary(self) -> str:
140
+ """One line for the inventory view, where you decide what to stop.
141
+
142
+ Unlike `label`, this keeps the command even when a purpose exists: a
143
+ stated intention is not evidence of what is actually running.
144
+ """
145
+ detail = self.label()
146
+ if self.purpose:
147
+ detail += f" · {_clip(self.command, 60)}"
148
+ line = f"[{self.id}] {detail} → {self.outcome()} · {format_duration(self.elapsed)}"
149
+ if self.adopted:
150
+ line += " · adopted from an earlier pcode"
151
+ return line
152
+
153
+ def record(self) -> dict:
154
+ """What a later pcode needs to adopt this job: where it is, not what it said."""
155
+ return {
156
+ "command": self.command,
157
+ "directory": str(self.directory),
158
+ "supervisor_pid": self.supervisor_pid,
159
+ "started_at": self.started_at,
160
+ "purpose": self.purpose,
161
+ }
162
+
163
+
164
+ class JobRegistry:
165
+ """Launches jobs, tracks their exits, and owns their log directories."""
166
+
167
+ def __init__(
168
+ self,
169
+ *,
170
+ retain: int = RETAINED_FINISHED_JOBS,
171
+ state: Callable[[], Path] | None = None,
172
+ ) -> None:
173
+ self.jobs: dict[str, Job] = {}
174
+ self._retain = retain
175
+ # Resolved lazily: the state dir comes from the environment, which
176
+ # tests change after the process-wide registry has been created.
177
+ self._state = state
178
+ self._home: Path | None = None
179
+ self._counter = 0
180
+ self._waited: set[str] = set()
181
+ # Jobs sent SIGTERM, by the time they get SIGKILL instead.
182
+ self._terminating: dict[str, float] = {}
183
+ # Ctrl+C means "stop what you are doing"; a typed follow-up means "stop
184
+ # waiting, keep working". Only an abandoned wait consults this, so a
185
+ # job the model explicitly backgrounded is never caught by either.
186
+ self.cancel_policy = "detach"
187
+ # Bumped by `release_waits`. A wait that sees it change hands back a
188
+ # handle instead of blocking on to its timeout.
189
+ self.release_generation = 0
190
+
191
+ def launch(
192
+ self,
193
+ command: str,
194
+ *,
195
+ cwd: Path,
196
+ env: Mapping[str, str] | None = None,
197
+ background: bool = False,
198
+ purpose: str = "",
199
+ ) -> Job:
200
+ self._counter += 1
201
+ home = self._directory()
202
+ if home is None:
203
+ directory = Path(tempfile.mkdtemp(prefix="pcode-job-"))
204
+ else:
205
+ directory = home / f"j{self._counter}"
206
+ directory.mkdir(parents=True, exist_ok=True)
207
+ try:
208
+ sandbox = COMMAND_SANDBOX.get()
209
+ prefix = [] if sandbox is None else sandbox(directory)
210
+ process = subprocess.Popen(
211
+ [*prefix, sys.executable, str(_SUPERVISOR), str(directory), command],
212
+ cwd=cwd,
213
+ env=None if env is None else dict(env),
214
+ stdin=subprocess.DEVNULL,
215
+ stdout=subprocess.DEVNULL,
216
+ stderr=subprocess.DEVNULL,
217
+ start_new_session=True,
218
+ )
219
+ except BaseException:
220
+ shutil.rmtree(directory, ignore_errors=True)
221
+ raise
222
+ # Reap the supervisor without tying the command's lifetime to this
223
+ # event loop: the thread is a daemon and only calls wait().
224
+ threading.Thread(target=process.wait, daemon=True).start()
225
+ job = Job(
226
+ id=f"j{self._counter}",
227
+ command=command,
228
+ directory=directory,
229
+ supervisor_pid=process.pid,
230
+ started_at=time.time(),
231
+ background=background,
232
+ purpose=" ".join(purpose.split()),
233
+ )
234
+ self.jobs[job.id] = job
235
+ self._save()
236
+ return job
237
+
238
+ def _directory(self) -> Path | None:
239
+ """This process's own directory under the state root, or None when ephemeral."""
240
+ if self._state is None:
241
+ return None
242
+ if self._home is None:
243
+ self._home = self._state() / str(os.getpid())
244
+ self._home.mkdir(parents=True, exist_ok=True)
245
+ return self._home
246
+
247
+ def home(self) -> Path | None:
248
+ """Where this registry publishes its jobs (`registry.json`), or None when ephemeral."""
249
+ return self._directory()
250
+
251
+ def _save(self) -> None:
252
+ home = self._directory()
253
+ if home is None:
254
+ return
255
+ record = {
256
+ "owner_pid": os.getpid(),
257
+ "jobs": {job.id: job.record() for job in self.jobs.values()},
258
+ }
259
+ pending = home / "registry.tmp"
260
+ pending.write_text(json.dumps(record), encoding="utf-8")
261
+ pending.replace(home / "registry.json")
262
+
263
+ def adopt_orphans(self) -> list[Job]:
264
+ """Take over the running jobs of pcode processes that are gone.
265
+
266
+ Finished orphans are only logs nobody will read; their directories go
267
+ with the dead process's record. A running one gets a fresh id here and
268
+ keeps its directory, because its supervisor is still writing there.
269
+ """
270
+ if self._state is None:
271
+ return []
272
+ root = self._state()
273
+ if not root.is_dir():
274
+ return []
275
+ adopted = []
276
+ for home in sorted(root.iterdir()):
277
+ if home.name == str(os.getpid()) or not home.is_dir():
278
+ continue
279
+ try:
280
+ record = json.loads((home / "registry.json").read_text(encoding="utf-8"))
281
+ except (OSError, ValueError):
282
+ continue
283
+ owner = record.get("owner_pid") if isinstance(record, dict) else None
284
+ if isinstance(owner, int) and _alive(owner):
285
+ continue
286
+ entries = record.get("jobs", {})
287
+ kept = set()
288
+ for entry in entries.values() if isinstance(entries, dict) else ():
289
+ job = self._adopt(entry)
290
+ if job is not None:
291
+ adopted.append(job)
292
+ kept.add(job.directory.resolve())
293
+ for child in home.iterdir():
294
+ if child.is_dir() and child.resolve() not in kept:
295
+ shutil.rmtree(child, ignore_errors=True)
296
+ elif child.is_file():
297
+ child.unlink(missing_ok=True)
298
+ _remove_if_empty(home)
299
+ if adopted:
300
+ self._save()
301
+ return adopted
302
+
303
+ def _adopt(self, entry: object) -> Job | None:
304
+ if not isinstance(entry, dict):
305
+ return None
306
+ try:
307
+ directory = Path(entry["directory"])
308
+ supervisor_pid = int(entry["supervisor_pid"])
309
+ command = str(entry["command"])
310
+ started_at = float(entry["started_at"])
311
+ except (KeyError, TypeError, ValueError):
312
+ return None
313
+ if not directory.is_dir() or not _alive(supervisor_pid):
314
+ return None
315
+ self._counter += 1
316
+ job = Job(
317
+ id=f"j{self._counter}",
318
+ command=command,
319
+ directory=directory,
320
+ supervisor_pid=supervisor_pid,
321
+ started_at=started_at,
322
+ # Nothing here is waiting on it, which is what background means.
323
+ background=True,
324
+ purpose=str(entry.get("purpose") or ""),
325
+ adopted=True,
326
+ )
327
+ status = self._read_status(job)
328
+ if status is not None:
329
+ if status.get("exit_code") is not None:
330
+ self._counter -= 1
331
+ return None
332
+ job.pid = status.get("pid")
333
+ self.jobs[job.id] = job
334
+ return job
335
+
336
+ def get(self, job_id: str) -> Job | None:
337
+ return self.jobs.get(job_id)
338
+
339
+ def running(self) -> list[Job]:
340
+ self.refresh()
341
+ return [job for job in self.jobs.values() if job.running]
342
+
343
+ def refresh(self) -> list[Job]:
344
+ """Read published statuses; return the jobs that finished since last time.
345
+
346
+ Cheap enough to call on a UI tick: one small read per still-running job.
347
+ """
348
+ self._escalate()
349
+ finished = []
350
+ for job in list(self.jobs.values()):
351
+ if not job.running:
352
+ continue
353
+ status = self._read_status(job)
354
+ if status is None:
355
+ continue
356
+ if job.pid is None:
357
+ job.pid = status.get("pid")
358
+ if status.get("exit_code") is None:
359
+ continue
360
+ job.exit_code = status["exit_code"]
361
+ job.ended_at = status.get("ended_at") or time.time()
362
+ finished.append(job)
363
+ if finished:
364
+ self._evict()
365
+ return finished
366
+
367
+ def _escalate(self) -> None:
368
+ """SIGKILL a stopped job that outlived its SIGTERM grace period."""
369
+ for job_id, deadline in list(self._terminating.items()):
370
+ job = self.jobs.get(job_id)
371
+ status = self._read_status(job) if job is not None else None
372
+ if job is None or (status is not None and status.get("exit_code") is not None):
373
+ del self._terminating[job_id]
374
+ elif time.time() >= deadline:
375
+ _kill_session(job.supervisor_pid)
376
+ del self._terminating[job_id]
377
+
378
+ def _read_status(self, job: Job) -> dict | None:
379
+ try:
380
+ status = json.loads(job.status_path.read_text(encoding="utf-8"))
381
+ except (OSError, ValueError):
382
+ # Absent until the supervisor's first publication, and never
383
+ # partially readable: it is written to a temp file and renamed.
384
+ return None
385
+ return status if isinstance(status, dict) else None
386
+
387
+ def take_announcements(self, channel: str, job_id: str | None = None) -> list[Job]:
388
+ """Finished jobs this channel has not reported yet, or just `job_id`.
389
+
390
+ Separate channels so the terminal and the model can each be told once.
391
+ Only jobs the model was handed a handle for are worth announcing to it;
392
+ a foreground command that completed inside its own tool call already
393
+ reported itself.
394
+ """
395
+ self.refresh()
396
+ pending = [
397
+ job
398
+ for job in self.jobs.values()
399
+ if not job.running
400
+ and channel not in job.announced
401
+ and self.announceable(job)
402
+ and job_id in (None, job.id)
403
+ ]
404
+ for job in pending:
405
+ job.announced.add(channel)
406
+ return pending
407
+
408
+ def announceable(self, job: Job) -> bool:
409
+ """Whether anyone was left holding a handle, so its exit is news."""
410
+ return job.background or job.detached or job.stopped or job.id in self._waited
411
+
412
+ def mark_waited(self, job: Job) -> None:
413
+ """Record that a wait returned a handle instead of a result."""
414
+ self._waited.add(job.id)
415
+
416
+ def release_waits(self) -> None:
417
+ """End every in-flight wait now, without touching the commands.
418
+
419
+ A steering message is for the next model request, and a foreground
420
+ wait would otherwise hold that request back until its timeout. The
421
+ wait returns a handle, the job keeps running, and its exit is still
422
+ delivered; the model just hears from the user first.
423
+ """
424
+ self.release_generation += 1
425
+
426
+ def read_output(self, job: Job, *, max_bytes: int = OUTPUT_TAIL_BYTES) -> tuple[str, bool]:
427
+ """The tail of the job's log, and whether anything was dropped before it."""
428
+ try:
429
+ size = job.output_path.stat().st_size
430
+ with job.output_path.open("rb") as source:
431
+ source.seek(max(0, size - max_bytes))
432
+ data = source.read(max_bytes)
433
+ except OSError:
434
+ return "", False
435
+ return data.decode("utf-8", errors="replace"), size > len(data)
436
+
437
+ def stop(self, job: Job) -> bool:
438
+ """Stop the job's whole process group. Returns whether it was running.
439
+
440
+ The group gets SIGTERM now and SIGKILL from a later `refresh` if it is
441
+ still there after the grace period. Nothing blocks on it: the caller
442
+ may be a cancellation handler on the event loop.
443
+ """
444
+ return bool(self.stop_all([job]))
445
+
446
+ def stop_all(self, jobs: Iterable[Job] | None = None) -> list[Job]:
447
+ self.refresh()
448
+ stopped = []
449
+ for job in list(jobs if jobs is not None else self.jobs.values()):
450
+ if not job.running:
451
+ continue
452
+ _terminate_session(job.supervisor_pid)
453
+ job.stopped = True
454
+ job.ended_at = time.time()
455
+ self._terminating[job.id] = time.time() + STOP_GRACE_SECONDS
456
+ stopped.append(job)
457
+ if stopped:
458
+ self._save()
459
+ return stopped
460
+
461
+ def _evict(self) -> None:
462
+ finished = [job for job in self.jobs.values() if not job.running]
463
+ evicted = sorted(finished, key=lambda job: job.ended_at or 0)[: -self._retain or None]
464
+ for job in evicted:
465
+ shutil.rmtree(job.directory, ignore_errors=True)
466
+ _remove_if_empty(job.directory.parent)
467
+ del self.jobs[job.id]
468
+ if evicted:
469
+ self._save()
470
+
471
+ def reset(self) -> None:
472
+ """Stop everything and forget it. For tests, which share the registry.
473
+
474
+ Deliberately harsher than `shutdown`: a test that leaked a process
475
+ should not leave it running for the next one.
476
+ """
477
+ for job in self.jobs.values():
478
+ _kill_session(job.supervisor_pid)
479
+ shutil.rmtree(job.directory, ignore_errors=True)
480
+ self.jobs.clear()
481
+ self._waited.clear()
482
+ self._terminating.clear()
483
+ self._counter = 0
484
+ self.cancel_policy = "detach"
485
+ self.release_generation = 0
486
+ if self._home is not None:
487
+ shutil.rmtree(self._home, ignore_errors=True)
488
+ self._home = None
489
+
490
+ def shutdown(self) -> None:
491
+ """Drop the logs of finished jobs; leave running ones and their logs alone.
492
+
493
+ A job that outlives the session is the point of the design, so this
494
+ must not kill anything. Its directory and the registry record stay so
495
+ the next pcode can adopt it; both go once nothing is left running.
496
+ """
497
+ for job in list(self.jobs.values()):
498
+ if not job.running:
499
+ shutil.rmtree(job.directory, ignore_errors=True)
500
+ del self.jobs[job.id]
501
+ if self._home is None:
502
+ return
503
+ if self.jobs:
504
+ self._save()
505
+ else:
506
+ shutil.rmtree(self._home, ignore_errors=True)
507
+ self._home = None
508
+
509
+
510
+ def _kill_session(pid: int) -> None:
511
+ _signal_session(pid, signal.SIGKILL)
512
+
513
+
514
+ def _terminate_session(pid: int) -> None:
515
+ _signal_session(pid, signal.SIGTERM)
516
+
517
+
518
+ def _signal_session(pid: int, signum: int) -> None:
519
+ if os.name == "nt": # pragma: no cover -- no Windows CI.
520
+ subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], check=False, capture_output=True)
521
+ return
522
+ try:
523
+ os.killpg(pid, signum)
524
+ except (ProcessLookupError, PermissionError):
525
+ pass
526
+
527
+
528
+ def _alive(pid: int) -> bool:
529
+ if pid <= 0:
530
+ return False
531
+ try:
532
+ os.kill(pid, 0)
533
+ except ProcessLookupError:
534
+ return False
535
+ except PermissionError:
536
+ return True
537
+ return True
538
+
539
+
540
+ def _remove_if_empty(directory: Path) -> None:
541
+ try:
542
+ directory.rmdir()
543
+ except OSError:
544
+ pass
545
+
546
+
547
+ # Set around one tool call by a sandboxing extension (the bundled `sandbox`):
548
+ # given a job's directory, the argv that runs its supervisor inside a sandbox.
549
+ # Wrapping the supervisor rather than the command keeps the command text the
550
+ # user and model see unchanged, and a stop still signals the whole group.
551
+ COMMAND_SANDBOX: ContextVar[Callable[[Path], list[str]] | None] = ContextVar(
552
+ "command_sandbox", default=None
553
+ )
554
+
555
+ _REGISTRY = JobRegistry(state=jobs_root)
556
+ _WORKER_REGISTRY: ContextVar[JobRegistry | None] = ContextVar("worker_job_registry", default=None)
557
+
558
+
559
+ @contextmanager
560
+ def isolated_registry():
561
+ """Own an isolated worker's jobs; no process may outlive its task snapshot.
562
+
563
+ Shared workers retain the session registry. Isolated workers cannot inspect
564
+ or stop a parent's jobs, and are torn down before their Git result is recorded.
565
+ """
566
+ jobs = JobRegistry()
567
+ token = _WORKER_REGISTRY.set(jobs)
568
+ try:
569
+ yield jobs
570
+ finally:
571
+ try:
572
+ jobs.reset()
573
+ finally:
574
+ _WORKER_REGISTRY.reset(token)
575
+
576
+
577
+ def registry() -> JobRegistry:
578
+ """The process-wide registry.
579
+
580
+ Shared rather than per-run on purpose: a run is exactly the scope a job is
581
+ supposed to escape, and the worker sub-agent should see -- and be able to
582
+ stop -- the same jobs the parent started.
583
+ """
584
+ return _WORKER_REGISTRY.get() or _REGISTRY