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
@@ -0,0 +1,425 @@
1
+ """Worktree-aware delegation over the pinned Harness run/event/budget machinery.
2
+
3
+ Only the built-in worker is reconstructible in another workspace. Specialized
4
+ extension agents keep their own capabilities and shared-workspace semantics.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import os
12
+ import signal
13
+ from contextvars import ContextVar
14
+ from dataclasses import asdict, dataclass, field, replace
15
+ from pathlib import Path
16
+ from typing import Any, Literal
17
+
18
+ from pydantic_ai import ModelRetry, RunContext
19
+ from pydantic_ai.workspaces import LocalWorkspaceBackend, Workspace
20
+ from pydantic_ai_harness.subagents import SubAgents
21
+ from pydantic_ai_harness.subagents._toolset import SubAgentToolset, _Ended
22
+
23
+ from pcode.delegation import ResumableAgent
24
+ from pcode.error_report import error_message
25
+ from pcode.preferences import SETTINGS, load_preferences
26
+ from pcode.task_worktrees import TaskWorktrees
27
+ from pcode.worktree import WorktreeError, describe, setup_scripts
28
+
29
+ logger = logging.getLogger(__name__)
30
+ _outcome: ContextVar[str | None] = ContextVar("isolated_delegation_outcome", default=None)
31
+
32
+ # Setup hooks run with no model watching and their output discarded, so a hang
33
+ # is invisible until the user gives up. Room for a cold dependency install.
34
+ SETUP_TIMEOUT_SECONDS = 900
35
+
36
+
37
+ def isolation_enabled() -> bool:
38
+ """Require explicit worker opt-in as well as the active worktree preference.
39
+
40
+ Never under a remote profile: a task worktree runs the project's
41
+ `worktree-setup` outside the sandbox, from a branch the model can commit to.
42
+ """
43
+ from pcode import remote_profile
44
+
45
+ if remote_profile.active() is not None:
46
+ return False
47
+ preferences = load_preferences()
48
+ return all(
49
+ preferences.get(key, SETTINGS[key].default) == "on"
50
+ for key in ("worktree", "worker_isolation")
51
+ )
52
+
53
+
54
+ async def _joined(task):
55
+ """Do not abandon a mutation/cleanup thread when the caller is cancelled."""
56
+ cancelled = False
57
+ while not task.done():
58
+ try:
59
+ await asyncio.shield(task)
60
+ except asyncio.CancelledError:
61
+ cancelled = True
62
+ try:
63
+ result = task.result()
64
+ except Exception:
65
+ if cancelled:
66
+ raise asyncio.CancelledError from None
67
+ raise
68
+ if cancelled:
69
+ raise asyncio.CancelledError
70
+ return result
71
+
72
+
73
+ async def _finish(store, task_id, status, summary):
74
+ return await _joined(
75
+ asyncio.create_task(asyncio.to_thread(store.finish, task_id, status, summary, wait=True))
76
+ )
77
+
78
+
79
+ async def _stop_setup(process):
80
+ # Setup owns a whole process group, including grandchildren holding pipes.
81
+ # Always kill the group after the grace period, even if its leader exited.
82
+ try:
83
+ os.killpg(process.pid, signal.SIGTERM)
84
+ except ProcessLookupError:
85
+ pass
86
+ try:
87
+ await asyncio.wait_for(process.wait(), timeout=1)
88
+ except asyncio.TimeoutError:
89
+ pass
90
+ finally:
91
+ try:
92
+ os.killpg(process.pid, signal.SIGKILL)
93
+ except ProcessLookupError:
94
+ pass
95
+ await process.wait()
96
+
97
+
98
+ async def _setup(tree):
99
+ """Use the trusted setup policy, but own cancellable subprocesses per task."""
100
+ env = {
101
+ **os.environ,
102
+ "PCODE_MAIN": str(tree.main),
103
+ "PCODE_WORKTREE": str(tree.path),
104
+ "PCODE_BRANCH": tree.branch,
105
+ }
106
+ for script in setup_scripts(tree):
107
+ command = [str(script)] if os.access(script, os.X_OK) else ["sh", str(script)]
108
+ launch = asyncio.create_task(
109
+ asyncio.create_subprocess_exec(
110
+ *command,
111
+ cwd=tree.path,
112
+ env=env,
113
+ start_new_session=True,
114
+ stdout=asyncio.subprocess.DEVNULL,
115
+ stderr=asyncio.subprocess.DEVNULL,
116
+ )
117
+ )
118
+ process = None
119
+ try:
120
+ # If cancelled during spawn, join it to obtain and reap its process.
121
+ process = await asyncio.shield(launch)
122
+ code = await process.wait()
123
+ if code:
124
+ raise WorktreeError(f"{script} exited {code}")
125
+ except asyncio.CancelledError:
126
+ if process is None:
127
+ try:
128
+ await _joined(launch)
129
+ except asyncio.CancelledError:
130
+ pass
131
+ process = launch.result()
132
+ await _joined(asyncio.create_task(_stop_setup(process)))
133
+ raise
134
+
135
+
136
+ def _worker_slots() -> asyncio.Semaphore | None:
137
+ limit = int(
138
+ load_preferences().get("worker_concurrency", SETTINGS["worker_concurrency"].default)
139
+ )
140
+ return asyncio.Semaphore(limit) if limit else None
141
+
142
+
143
+ @dataclass(kw_only=True)
144
+ class WorkspaceSubAgents(SubAgents):
145
+ workspace: Path
146
+ worker_factory: Any
147
+ worker_slots: asyncio.Semaphore | None = field(
148
+ default_factory=_worker_slots, repr=False, compare=False
149
+ )
150
+
151
+ def get_toolset(self):
152
+ return WorkspaceSubAgentToolset(
153
+ workspace=self.workspace,
154
+ worker_factory=self.worker_factory,
155
+ worker_slots=self.worker_slots,
156
+ agents=self._by_name,
157
+ forward_usage=self.forward_usage,
158
+ inherit_tools=self.inherit_tools,
159
+ shared_capabilities=self.shared_capabilities,
160
+ event_stream_handler=self.event_stream_handler,
161
+ tool_name=self.tool_name,
162
+ tool_retries=self.tool_retries,
163
+ contain_errors=self.contain_errors,
164
+ call_counts=self._call_counts,
165
+ models=self._menu,
166
+ )
167
+
168
+ def get_instructions(self):
169
+ return (super().get_instructions() or "") + (
170
+ "\nWorker workspace_mode defaults to auto: shared unless both worktree=on and "
171
+ "worker_isolation=on in the active workspace's config. worker_isolation defaults "
172
+ "to off; worktree alone never isolates workers. Isolation requires a clean tracked "
173
+ "parent checkout; commit a checkpoint first, never stash or silently switch modes. "
174
+ "Use shared explicitly for work that needs the live parent files; coordinate writes. "
175
+ "Specialized delegates retain their own tools and use shared mode. "
176
+ "Isolated results are branches, not changes to your checkout. Review them, then "
177
+ "integrate_task into this parent branch and verify the combined changes. "
178
+ "Use list_task_worktrees to recover task IDs after restart. "
179
+ "Never push worker branches or merge them directly into mainline. "
180
+ "Ask the user before discard_task(confirm=True) destroys unintegrated work."
181
+ + (
182
+ # Harness's listing above reads as the only keys allowed.
183
+ "\nBesides the keys listed, `model` takes any other provider:model name."
184
+ if self._menu
185
+ else ""
186
+ )
187
+ )
188
+
189
+
190
+ class WorkspaceSubAgentToolset(SubAgentToolset):
191
+ def __init__(self, *, workspace: Path, worker_factory, worker_slots, **kwargs):
192
+ self.workspace = workspace
193
+ self.worker_factory = worker_factory
194
+ self.worker_slots = worker_slots
195
+ super().__init__(**kwargs)
196
+ self.add_function(self.integrate_task)
197
+ self.add_function(self.discard_task)
198
+ self.add_function(self.list_task_worktrees)
199
+
200
+ def _prepare_delegate(self, ctx, tool_def):
201
+ """Offer `model` as any `provider:model` name, not only a /subagents key.
202
+
203
+ The description is static, so the schema stays cache-stable however
204
+ many names get resolved on demand.
205
+ """
206
+ tool_def = super()._prepare_delegate(ctx, tool_def)
207
+ if tool_def is None:
208
+ return None
209
+ schema = {**tool_def.parameters_json_schema}
210
+ schema["properties"] = {
211
+ **schema.get("properties", {}),
212
+ "model": {
213
+ "type": "string",
214
+ "description": (
215
+ "Optional model to run the sub-agent on, as provider:model (any "
216
+ "/model name), such as for a second opinion from another model. "
217
+ "Omit it to use the sub-agent's default, the session's model."
218
+ ),
219
+ },
220
+ }
221
+ return replace(tool_def, parameters_json_schema=schema)
222
+
223
+ async def _add_model(self, agent_name: str, key: str | None) -> None:
224
+ """Put a name off the /subagents menu on it, resolved the way /btw resolves one.
225
+
226
+ Harness's own key check then runs unchanged. A delegate restricted to
227
+ some keys is left for that check to refuse, without resolving anything.
228
+ """
229
+ sub_agent = self._agents.get(agent_name)
230
+ if key is None or key in self._models or sub_agent is None or sub_agent.models:
231
+ return
232
+ from pydantic_ai_harness.subagents import ModelOption
233
+
234
+ from pcode.agent import side_model
235
+
236
+ try:
237
+ # Reads logins and preferences from disk.
238
+ chosen = await asyncio.to_thread(side_model, key)
239
+ except ValueError as error:
240
+ raise ModelRetry(str(error)) from error
241
+ except Exception as error:
242
+ # A broken login file is the model's to work around, not a failed turn.
243
+ raise ModelRetry(f"Cannot use {key}: {type(error).__name__}: {error}") from error
244
+ # Kept for later delegations in this session; the schema never lists it.
245
+ self._models[key] = ModelOption(chosen.model, settings=chosen.settings)
246
+
247
+ async def delegate_task(
248
+ self,
249
+ ctx: RunContext,
250
+ agent_name: str,
251
+ task: str,
252
+ workspace_mode: Literal["auto", "isolated", "shared"] = "auto",
253
+ model: str | None = None,
254
+ purpose: str = "",
255
+ ) -> Any:
256
+ """Delegate a self-contained task; the child does not see this conversation.
257
+
258
+ auto shares the parent's live files unless both worktree=on and
259
+ worker_isolation=on in the active config. isolated requires both settings
260
+ and clean tracked files, creates a branch at the parent's HEAD, and returns
261
+ a persistent task record. shared is not read-only. Specialized agents only
262
+ support shared mode. Review isolated results before integrate_task.
263
+
264
+ Args:
265
+ purpose: What the sub-agent is doing, at most 8 words, present tense
266
+ (e.g. "fixing the /btw cache prefix"). It labels the delegation
267
+ in the user's task list; the sub-agent never sees it.
268
+ """
269
+ # `purpose` is display-only: the event layer reads it from the call's arguments.
270
+ if agent_name == "worker" and self.worker_slots is not None:
271
+ async with self.worker_slots:
272
+ return await self._delegate(ctx, agent_name, task, workspace_mode, model)
273
+ return await self._delegate(ctx, agent_name, task, workspace_mode, model)
274
+
275
+ async def _delegate(self, ctx, agent_name, task, workspace_mode, model):
276
+ await self._add_model(agent_name, model)
277
+ isolated = workspace_mode == "isolated" or (
278
+ workspace_mode == "auto" and agent_name == "worker" and isolation_enabled()
279
+ )
280
+ if not isolated:
281
+ return await super().delegate_task(ctx, agent_name, task, model)
282
+ if not isolation_enabled():
283
+ raise ModelRetry(
284
+ "Isolated delegation requires both worktree=on and worker_isolation=on "
285
+ "in the active config. Use shared mode without worker isolation."
286
+ )
287
+ if agent_name != "worker":
288
+ raise ModelRetry("Only the built-in worker supports isolated workspaces; use shared.")
289
+ key = self._resolve_model_key(agent_name, self._agents[agent_name], model)
290
+ try:
291
+ store = TaskWorktrees(self.workspace)
292
+ creation = asyncio.create_task(asyncio.to_thread(store.create, wait=True))
293
+ try:
294
+ record = await _joined(creation)
295
+ except asyncio.CancelledError:
296
+ if not creation.cancelled() and creation.exception() is None:
297
+ await _finish(
298
+ store,
299
+ creation.result().task_id,
300
+ "cancelled",
301
+ "Delegation cancelled during creation; checkout preserved.",
302
+ )
303
+ raise
304
+ except WorktreeError as error:
305
+ raise ModelRetry(str(error)) from error
306
+ token = _outcome.set(None)
307
+ try:
308
+ # Timeout or failure preserves the artifact below.
309
+ try:
310
+ async with asyncio.timeout(SETUP_TIMEOUT_SECONDS):
311
+ await _setup(describe(Path(record.worktree)))
312
+ except TimeoutError:
313
+ raise WorktreeError(f"Worktree setup exceeded {SETUP_TIMEOUT_SECONDS}s") from None
314
+ async with self.worker_factory(Path(record.worktree)) as worker:
315
+ prompt = (
316
+ f"{task}\n\nYou are working in an isolated task worktree.\n"
317
+ f"Task ID: {record.task_id}\nBase commit: {record.base_commit}\n"
318
+ f"Branch: {record.branch}\nWorkspace: {record.worktree}\n"
319
+ "Edit and test here. Commit completed changes on this branch. "
320
+ "Do not push, merge into another checkout, or remove this worktree. "
321
+ "The parent will review and integrate your result. Stop all jobs before "
322
+ "finishing; remaining jobs will be terminated when delegation ends."
323
+ )
324
+ # Harness hands a delegate the parent's `ctx.workspace`; this
325
+ # one works in its own checkout instead.
326
+ child_ctx = replace(
327
+ ctx, workspace=Workspace(LocalWorkspaceBackend(record.worktree))
328
+ )
329
+ output = await self._run_delegation(
330
+ child_ctx,
331
+ agent_name,
332
+ replace(self._agents[agent_name], agent=worker),
333
+ task=prompt,
334
+ key=key,
335
+ )
336
+ status = "completed" if _outcome.get() == "ok" else "failed"
337
+ record = await _finish(store, record.task_id, status, output)
338
+ except asyncio.CancelledError:
339
+ await _finish(
340
+ store, record.task_id, "cancelled", "Delegation cancelled; partial work preserved."
341
+ )
342
+ raise
343
+ except Exception as error:
344
+ # Preserve the artifact even when Harness reports a retry or setup fails.
345
+ # A contained crash arrives as Harness's retry, already described.
346
+ summary = str(error) if isinstance(error, ModelRetry) else None
347
+ record = await _finish(
348
+ store, record.task_id, "failed", summary or f"{type(error).__name__}: {error}"
349
+ )
350
+ finally:
351
+ _outcome.reset(token)
352
+ return {
353
+ **asdict(record),
354
+ "workspace_mode": "isolated",
355
+ "verification": "Worker-reported; see summary.",
356
+ }
357
+
358
+ async def _run_delegation(self, ctx, agent_name, sub_agent, *, task, key):
359
+ resumable = replace(sub_agent, agent=ResumableAgent(sub_agent.agent))
360
+ return await super()._run_delegation(ctx, agent_name, resumable, task=task, key=key)
361
+
362
+ def _crash_outcome(self, agent_name, sub_agent, exc):
363
+ """Harness's containment, minus the raw exception text and the traceback.
364
+
365
+ Harness puts `str(exc)` in the parent's retry message, which the model,
366
+ the transcript and the panel all see; SDK messages can carry provider
367
+ bodies, so describe it the way a failed turn is described instead. Its
368
+ warning prints a traceback over the terminal, which has no log handler.
369
+ """
370
+ contain = sub_agent.contain_errors
371
+ if not (self._contain_errors if contain is None else contain):
372
+ raise exc
373
+ logger.debug("Contained crash from sub-agent %r", agent_name, exc_info=exc)
374
+ return _Ended(
375
+ outcome="contained",
376
+ output=(
377
+ f"Sub-agent {agent_name!r} crashed: {error_message(exc)} "
378
+ "Treat this as a recoverable failure and decide from existing evidence."
379
+ ),
380
+ cause=exc,
381
+ )
382
+
383
+ async def _settle(self, *args, **kwargs):
384
+ ended = await super()._settle(*args, **kwargs)
385
+ _outcome.set(ended.outcome)
386
+ return ended
387
+
388
+ async def integrate_task(self, task_id: str) -> dict:
389
+ """Merge a completed task into its recorded parent, never mainline.
390
+
391
+ Requires clean parent and child checkouts. Conflicts remain in the parent;
392
+ resolve and commit them, then retry. The child is kept for worktree cleanup.
393
+ """
394
+ try:
395
+ record = await _joined(
396
+ asyncio.create_task(
397
+ asyncio.to_thread(TaskWorktrees(self.workspace).integrate, task_id)
398
+ )
399
+ )
400
+ return asdict(record)
401
+ except WorktreeError as error:
402
+ raise ModelRetry(str(error)) from error
403
+
404
+ async def discard_task(self, task_id: str, confirm: bool = False) -> dict:
405
+ """Remove an inactive task checkout and branch.
406
+
407
+ Unintegrated or dirty work requires confirm=True, only after the user has
408
+ explicitly approved discarding it. Active tasks cannot be discarded.
409
+ """
410
+ try:
411
+ record = await _joined(
412
+ asyncio.create_task(
413
+ asyncio.to_thread(TaskWorktrees(self.workspace).discard, task_id, confirm)
414
+ )
415
+ )
416
+ return asdict(record)
417
+ except WorktreeError as error:
418
+ raise ModelRetry(str(error)) from error
419
+
420
+ async def list_task_worktrees(self) -> list[dict]:
421
+ """List this parent's persistent worker artifacts, including failed tasks."""
422
+ try:
423
+ return [asdict(record) for record in TaskWorktrees(self.workspace).list()]
424
+ except WorktreeError as error:
425
+ raise ModelRetry(str(error)) from error
pcode/job_notices.py ADDED
@@ -0,0 +1,54 @@
1
+ """Deliver job exits to the model at the next request, so it never polls.
2
+
3
+ The reason a model writes `sleep 30; cat status.json` is that nothing else will
4
+ ever tell it the command finished. Steering already proves the seam exists: a
5
+ capability can append to the request the framework is about to send, between
6
+ model requests and never mid-tool. Completion notices ride the same seam, which
7
+ is why a background job costs no extra model requests and no waiting turns.
8
+ """
9
+
10
+ from pydantic_ai.capabilities import AbstractCapability
11
+ from pydantic_ai.messages import UserPromptPart
12
+
13
+ from pcode.jobs import JobRegistry, format_duration
14
+
15
+ # A failed job's tail rides along with its notice: the model fetches it nearly
16
+ # every time, and a request saved is worth more than the bytes. A success is
17
+ # usually acted on without reading, so it stays a handle.
18
+ FAILURE_TAIL_BYTES = 2_048
19
+
20
+
21
+ def notice(job, tail: str = "") -> str:
22
+ # `label` rather than `summary`: a notice arrives turns later and has to be
23
+ # read at a glance, so what the job was for beats how it was spelled. The
24
+ # command is still one `job_output` away.
25
+ text = f"[{job.id}] {job.label()} → {job.outcome()} after {format_duration(job.elapsed)}."
26
+ if job.stopped:
27
+ return text + " It was stopped, so its output may be incomplete."
28
+ if tail.strip():
29
+ return (
30
+ text
31
+ + f' Read all of its output with job_output("{job.id}"). Last output:\n'
32
+ + tail.rstrip("\n")
33
+ )
34
+ return text + f' Read its output with job_output("{job.id}").'
35
+
36
+
37
+ def notice_for(jobs: JobRegistry, job) -> str:
38
+ """The notice with a failed job's output tail attached."""
39
+ tail = ""
40
+ if not job.stopped and job.exit_code not in (None, 0):
41
+ tail, _ = jobs.read_output(job, max_bytes=FAILURE_TAIL_BYTES)
42
+ return notice(job, tail)
43
+
44
+
45
+ class JobNotices(AbstractCapability):
46
+ def __init__(self, jobs: JobRegistry) -> None:
47
+ self.jobs = jobs
48
+
49
+ async def before_model_request(self, ctx, request_context):
50
+ # Appended to the framework's own request, so tool results stay ahead
51
+ # of the notice and the notice is persisted with the conversation.
52
+ for job in self.jobs.take_announcements("model"):
53
+ request_context.messages[-1].parts.append(UserPromptPart(notice_for(self.jobs, job)))
54
+ return request_context