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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/shell_tools.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""The shell tool, expressed as jobs: one way to run, two ways to wait.
|
|
2
|
+
|
|
3
|
+
Harness's persistent `shell` launches every command detached and differs only
|
|
4
|
+
in whether the call blocks, which is the right shape. What it lacks is a name
|
|
5
|
+
for the thing that outlives the call. Without one, a command that is still
|
|
6
|
+
running can only be handed back as a PID and two paths, so the model's only way
|
|
7
|
+
to learn it finished is to poll with `sleep`, an interrupted wait kills the
|
|
8
|
+
command, and nothing can list what a session left running.
|
|
9
|
+
|
|
10
|
+
This toolset keeps the execution model and adds the missing noun. `shell`
|
|
11
|
+
returns either a finished result or a job handle; `wait_for_job` blocks on a
|
|
12
|
+
handle without re-running anything; completions are delivered by
|
|
13
|
+
`pcode.job_notices` at the next model request. Polling from the model is
|
|
14
|
+
therefore never the right move, and the instructions say so.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import codecs
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import anyio
|
|
26
|
+
from pydantic_ai import RunContext
|
|
27
|
+
from pydantic_ai.exceptions import ModelRetry
|
|
28
|
+
from pydantic_ai.tools import AgentDepsT
|
|
29
|
+
from pydantic_ai.toolsets import AbstractToolset
|
|
30
|
+
from pydantic_ai_harness.shell import Shell
|
|
31
|
+
from pydantic_ai_harness.shell._events import (
|
|
32
|
+
CommandFinishedEvent,
|
|
33
|
+
CommandOutputEvent,
|
|
34
|
+
CommandStartedEvent,
|
|
35
|
+
)
|
|
36
|
+
from pydantic_ai_harness.shell._policy import recoverable
|
|
37
|
+
from pydantic_ai_harness.shell._toolset import ShellToolset
|
|
38
|
+
|
|
39
|
+
from pcode.jobs import OUTPUT_TAIL_BYTES, Job, JobRegistry, format_duration, registry
|
|
40
|
+
from pcode.steering import steering_pending
|
|
41
|
+
|
|
42
|
+
# Long enough for a real build or test suite, short enough that the tool call
|
|
43
|
+
# returns before provider request timeouts and the conversation keeps its
|
|
44
|
+
# request/response cadence.
|
|
45
|
+
MAX_WAIT_SECONDS = 270.0
|
|
46
|
+
|
|
47
|
+
_POLL_INTERVAL = 0.05
|
|
48
|
+
# Bounds the events one wait emits into the live preview, matching the
|
|
49
|
+
# model-visible tail so "capped" means the same thing in both places.
|
|
50
|
+
_EVENT_BUDGET_BYTES = OUTPUT_TAIL_BYTES
|
|
51
|
+
# `until_output` keeps reading past the preview budget, but not without end.
|
|
52
|
+
_MATCH_READ_BYTES = 1_048_576
|
|
53
|
+
# What it matches against: a readiness banner is a line, not a log.
|
|
54
|
+
_MATCH_BUDGET_CHARS = 262_144
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class JobShellToolset(ShellToolset[AgentDepsT]):
|
|
58
|
+
"""Harness's shell toolset with its persistent tool replaced by job tools."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, *, jobs: JobRegistry, workdir: Path, **kwargs: Any) -> None:
|
|
61
|
+
super().__init__(**kwargs)
|
|
62
|
+
self._jobs = jobs
|
|
63
|
+
# Jobs run on this host through `pcode.jobs`, not `ctx.workspace`, so
|
|
64
|
+
# the toolset keeps its own start directory (upstream's is deprecated).
|
|
65
|
+
self._workdir = workdir
|
|
66
|
+
# Harness registered its own persistent `shell` from the tool list.
|
|
67
|
+
# Drop it and register ours under the same name, so the model sees one
|
|
68
|
+
# shell tool and the UI wiring keyed on that name still matches.
|
|
69
|
+
self.tools.pop("shell", None)
|
|
70
|
+
command_metadata = {"code_arg_name": "command", "code_arg_language": "shell"}
|
|
71
|
+
self.add_function(self.shell, name="shell", metadata=command_metadata)
|
|
72
|
+
self.add_function(self.wait_for_job, name="wait_for_job")
|
|
73
|
+
self.add_function(self.job_output, name="job_output")
|
|
74
|
+
self.add_function(self.stop_job, name="stop_job")
|
|
75
|
+
self.add_function(self.list_jobs, name="list_jobs")
|
|
76
|
+
|
|
77
|
+
async def for_run(self, ctx: RunContext[AgentDepsT]) -> AbstractToolset[AgentDepsT]:
|
|
78
|
+
"""Per-run instance, but the registry is shared: jobs outlive the run."""
|
|
79
|
+
return JobShellToolset[AgentDepsT](
|
|
80
|
+
jobs=self._jobs,
|
|
81
|
+
workdir=self._workdir,
|
|
82
|
+
allowed_commands=self._allowed_commands,
|
|
83
|
+
denied_commands=self._denied_commands,
|
|
84
|
+
denied_operators=self._denied_operators,
|
|
85
|
+
default_timeout=self._default_timeout,
|
|
86
|
+
max_output_chars=self._max_output_chars,
|
|
87
|
+
max_file_bytes=self._max_file_bytes,
|
|
88
|
+
persist_cwd=self._persist_cwd,
|
|
89
|
+
allow_interactive=self._allow_interactive,
|
|
90
|
+
env=self._env,
|
|
91
|
+
denied_env_patterns=self._denied_env_patterns,
|
|
92
|
+
tools=self._tools,
|
|
93
|
+
id=self.id,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
97
|
+
"""Leave jobs alone on run teardown; the registry owns their lifetime."""
|
|
98
|
+
await super().__aexit__(*args)
|
|
99
|
+
|
|
100
|
+
# -- tools ---------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
@recoverable
|
|
103
|
+
async def shell(
|
|
104
|
+
self,
|
|
105
|
+
ctx: RunContext[AgentDepsT],
|
|
106
|
+
command: str,
|
|
107
|
+
*,
|
|
108
|
+
background: bool = False,
|
|
109
|
+
timeout: float | None = None,
|
|
110
|
+
purpose: str = "",
|
|
111
|
+
) -> str:
|
|
112
|
+
"""Run a shell command and wait for its output and exit status.
|
|
113
|
+
|
|
114
|
+
If you have independent work to do while it runs, pass
|
|
115
|
+
`background=True` and you get a job handle immediately instead.
|
|
116
|
+
|
|
117
|
+
Either way the command runs the same way and outlives this call. When a
|
|
118
|
+
wait ends before the command does -- it exceeded `timeout`, or the user
|
|
119
|
+
sent a follow-up -- you get a job handle rather than a result, and the
|
|
120
|
+
command keeps running.
|
|
121
|
+
|
|
122
|
+
You are told when a job finishes: its exit status reaches you
|
|
123
|
+
automatically before your next model request. Don't `sleep` in a
|
|
124
|
+
separate call to pass time, and don't re-run a command to learn how the
|
|
125
|
+
first one went. To watch a long job, call `wait_for_job` with
|
|
126
|
+
`until_output` matching the line you care about (an early failure, a
|
|
127
|
+
readiness message). When the source only gives snapshots, a
|
|
128
|
+
`while ...; do ...; sleep N; done` loop inside one background command
|
|
129
|
+
is fine; the rule is about your own turns, not the script.
|
|
130
|
+
|
|
131
|
+
The reported exit status is the pipeline's last command, so
|
|
132
|
+
`make test | tail` exits 0 even when make fails; read the output.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
command: The shell command to run.
|
|
136
|
+
background: True to get a job handle at once instead of waiting.
|
|
137
|
+
timeout: Seconds to wait before handing back a job handle (max 270).
|
|
138
|
+
purpose: Why you are running this, at most 8 words, present tense
|
|
139
|
+
(e.g. "running the end-to-end suite"). Give it for background
|
|
140
|
+
or long-running commands, which are shown away from this
|
|
141
|
+
call; leave it empty for quick ones.
|
|
142
|
+
"""
|
|
143
|
+
self._check_command(command)
|
|
144
|
+
wait = self._wait_seconds(timeout)
|
|
145
|
+
job = self._jobs.launch(
|
|
146
|
+
command,
|
|
147
|
+
cwd=self._workdir,
|
|
148
|
+
env=self._resolve_env(),
|
|
149
|
+
background=background,
|
|
150
|
+
purpose=purpose,
|
|
151
|
+
)
|
|
152
|
+
await ctx.emit(
|
|
153
|
+
CommandStartedEvent(
|
|
154
|
+
tool_call_id=ctx.tool_call_id, command=command, pid=job.supervisor_pid
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
if background:
|
|
158
|
+
await self._emit_finished(ctx, job, truncated=False)
|
|
159
|
+
return self._handle(job, reason="Started in the background.")
|
|
160
|
+
return await self._wait(ctx, job, timeout=wait)
|
|
161
|
+
|
|
162
|
+
@recoverable
|
|
163
|
+
async def wait_for_job(
|
|
164
|
+
self,
|
|
165
|
+
ctx: RunContext[AgentDepsT],
|
|
166
|
+
job_id: str,
|
|
167
|
+
*,
|
|
168
|
+
timeout: float | None = None,
|
|
169
|
+
until_output: str | None = None,
|
|
170
|
+
) -> str:
|
|
171
|
+
"""Wait for a job started by `shell`. It is not re-run.
|
|
172
|
+
|
|
173
|
+
Returns the job's output and exit status once it finishes, or a job
|
|
174
|
+
handle again if the wait ends first. Use this when you need a
|
|
175
|
+
background job's result before you can continue.
|
|
176
|
+
|
|
177
|
+
Pass `until_output` to wait for readiness instead of exit: a server
|
|
178
|
+
that never exits is ready when its log says so.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
job_id: The job to wait for, e.g. "j3".
|
|
182
|
+
timeout: Seconds to wait before handing back a job handle (max 270).
|
|
183
|
+
until_output: Regular expression; stop waiting at the first match.
|
|
184
|
+
"""
|
|
185
|
+
job = self._require(job_id)
|
|
186
|
+
pattern = self._compile(until_output)
|
|
187
|
+
if not job.running:
|
|
188
|
+
return self._result(job)
|
|
189
|
+
await ctx.emit(
|
|
190
|
+
CommandStartedEvent(
|
|
191
|
+
tool_call_id=ctx.tool_call_id, command=job.command, pid=job.supervisor_pid
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
return await self._wait(ctx, job, timeout=self._wait_seconds(timeout), match=pattern)
|
|
195
|
+
|
|
196
|
+
@recoverable
|
|
197
|
+
async def job_output(self, job_id: str) -> str:
|
|
198
|
+
"""Read a job's output so far without waiting for it.
|
|
199
|
+
|
|
200
|
+
Use this to inspect progress. It is not a way to find out whether a job
|
|
201
|
+
finished: that is reported to you automatically.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
job_id: The job to read, e.g. "j3".
|
|
205
|
+
"""
|
|
206
|
+
job = self._require(job_id)
|
|
207
|
+
self._jobs.refresh()
|
|
208
|
+
if not job.running:
|
|
209
|
+
return self._result(job)
|
|
210
|
+
return self._handle(job, reason="Still running.")
|
|
211
|
+
|
|
212
|
+
@recoverable
|
|
213
|
+
async def stop_job(self, job_id: str) -> str:
|
|
214
|
+
"""Stop a running job and everything it started.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
job_id: The job to stop, e.g. "j3".
|
|
218
|
+
"""
|
|
219
|
+
job = self._require(job_id)
|
|
220
|
+
stopped = self._jobs.stop(job)
|
|
221
|
+
# This call is the report; a notice at the next request would only tell
|
|
222
|
+
# the model what it just did.
|
|
223
|
+
job.announced.add("model")
|
|
224
|
+
elapsed = format_duration(job.elapsed)
|
|
225
|
+
if not stopped:
|
|
226
|
+
return f"[{job.id} · {job.outcome()} · {elapsed}] Already finished."
|
|
227
|
+
return f"[{job.id} · stopped · {elapsed}] {job.label()}"
|
|
228
|
+
|
|
229
|
+
@recoverable
|
|
230
|
+
async def list_jobs(self) -> str:
|
|
231
|
+
"""List this session's jobs, running first, with their status."""
|
|
232
|
+
self._jobs.refresh()
|
|
233
|
+
jobs = sorted(self._jobs.jobs.values(), key=lambda job: (not job.running, job.started_at))
|
|
234
|
+
if not jobs:
|
|
235
|
+
return "No jobs have been started."
|
|
236
|
+
return "\n".join(job.summary() for job in jobs)
|
|
237
|
+
|
|
238
|
+
# -- waiting -------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
async def _wait(
|
|
241
|
+
self,
|
|
242
|
+
ctx: RunContext[AgentDepsT],
|
|
243
|
+
job: Job,
|
|
244
|
+
*,
|
|
245
|
+
timeout: float,
|
|
246
|
+
match: re.Pattern[str] | None = None,
|
|
247
|
+
) -> str:
|
|
248
|
+
"""Stream the job's log until it exits, matches, times out, or is abandoned.
|
|
249
|
+
|
|
250
|
+
Cancellation is the interesting case. The command is not ours to kill
|
|
251
|
+
just because the caller stopped listening: a follow-up means "stop
|
|
252
|
+
waiting", and only an explicit interrupt means "stop working". The
|
|
253
|
+
policy lives on the registry because the app knows which one happened
|
|
254
|
+
and this coroutine does not.
|
|
255
|
+
"""
|
|
256
|
+
stream = _OutputStream(job, ctx, matching=match is not None)
|
|
257
|
+
matched = False
|
|
258
|
+
released = False
|
|
259
|
+
generation = self._jobs.release_generation
|
|
260
|
+
job.waiting = True
|
|
261
|
+
try:
|
|
262
|
+
with anyio.move_on_after(timeout):
|
|
263
|
+
while True:
|
|
264
|
+
self._jobs.refresh()
|
|
265
|
+
if not job.running:
|
|
266
|
+
break
|
|
267
|
+
await stream.emit()
|
|
268
|
+
if match is not None and match.search(stream.matchable):
|
|
269
|
+
matched = True
|
|
270
|
+
break
|
|
271
|
+
# The release signal handles existing waits. Input may
|
|
272
|
+
# also arrive after tool admission but before this wait, or
|
|
273
|
+
# become deliverable once MCP activation finishes.
|
|
274
|
+
if self._jobs.release_generation != generation or steering_pending(ctx):
|
|
275
|
+
released = True
|
|
276
|
+
break
|
|
277
|
+
await anyio.sleep(_POLL_INTERVAL)
|
|
278
|
+
await stream.drain()
|
|
279
|
+
except BaseException:
|
|
280
|
+
if self._jobs.cancel_policy == "stop":
|
|
281
|
+
self._jobs.stop(job)
|
|
282
|
+
else:
|
|
283
|
+
job.detached = True
|
|
284
|
+
self._jobs.mark_waited(job)
|
|
285
|
+
raise
|
|
286
|
+
finally:
|
|
287
|
+
job.waiting = False
|
|
288
|
+
await self._emit_finished(ctx, job, truncated=stream.truncated)
|
|
289
|
+
if matched:
|
|
290
|
+
self._jobs.mark_waited(job)
|
|
291
|
+
return self._handle(job, reason="Matched until_output; the command is still running.")
|
|
292
|
+
if job.running:
|
|
293
|
+
self._jobs.mark_waited(job)
|
|
294
|
+
if released:
|
|
295
|
+
reason = "The user sent a follow-up, so the wait ended; the command did not."
|
|
296
|
+
else:
|
|
297
|
+
reason = f"The wait ended after {format_duration(timeout)}, not the command."
|
|
298
|
+
return self._handle(job, reason=f"{reason} It was not killed.")
|
|
299
|
+
return self._result(job)
|
|
300
|
+
|
|
301
|
+
async def _emit_finished(
|
|
302
|
+
self, ctx: RunContext[AgentDepsT], job: Job, *, truncated: bool
|
|
303
|
+
) -> None:
|
|
304
|
+
await ctx.emit(
|
|
305
|
+
CommandFinishedEvent(
|
|
306
|
+
tool_call_id=ctx.tool_call_id,
|
|
307
|
+
pid=job.supervisor_pid,
|
|
308
|
+
output_path=str(job.output_path),
|
|
309
|
+
status_path=str(job.status_path),
|
|
310
|
+
exit_code=job.exit_code,
|
|
311
|
+
truncated=truncated,
|
|
312
|
+
total_lines=None,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# -- result rendering ----------------------------------------------------
|
|
317
|
+
|
|
318
|
+
def _result(self, job: Job) -> str:
|
|
319
|
+
"""A finished job: its output and one status line, with no handles.
|
|
320
|
+
|
|
321
|
+
Handles are what you need to come back to a command later. A command
|
|
322
|
+
that is over has nothing to come back to, and repeating a PID and two
|
|
323
|
+
paths on every `ls` both wastes context and teaches the model that
|
|
324
|
+
every command is something to be managed.
|
|
325
|
+
|
|
326
|
+
Handing over the result is the model's report of the exit, so it gets
|
|
327
|
+
no second notice at its next request and no wake-up turn for it.
|
|
328
|
+
"""
|
|
329
|
+
job.announced.add("model")
|
|
330
|
+
output, truncated = self._jobs.read_output(job)
|
|
331
|
+
lines = [output.rstrip("\n")] if output.strip() else []
|
|
332
|
+
status = f"[{job.id} · {job.outcome()} · {format_duration(job.elapsed)}]"
|
|
333
|
+
if truncated:
|
|
334
|
+
status += f"\nEarlier output was dropped; the full log is at {job.output_path}"
|
|
335
|
+
lines.append(status)
|
|
336
|
+
return "\n".join(lines)
|
|
337
|
+
|
|
338
|
+
def _handle(self, job: Job, *, reason: str) -> str:
|
|
339
|
+
"""A running job: what it has printed, plus how to get back to it."""
|
|
340
|
+
output, truncated = self._jobs.read_output(job, max_bytes=OUTPUT_TAIL_BYTES // 2)
|
|
341
|
+
lines = []
|
|
342
|
+
if output.strip():
|
|
343
|
+
lines.append(output.rstrip("\n"))
|
|
344
|
+
if truncated:
|
|
345
|
+
lines.append(f"[earlier output is in {job.output_path}]")
|
|
346
|
+
lines.append(
|
|
347
|
+
f"[{job.id} · running · pid {job.pid or job.supervisor_pid} · "
|
|
348
|
+
f"{format_duration(job.elapsed)}] {reason}\n"
|
|
349
|
+
f"Command: {job.command}\n"
|
|
350
|
+
f"Its exit status will reach you automatically; do not sleep or poll for it. "
|
|
351
|
+
f'Use wait_for_job("{job.id}") to block on it, job_output("{job.id}") to see '
|
|
352
|
+
f'progress, stop_job("{job.id}") to stop it.'
|
|
353
|
+
)
|
|
354
|
+
return "\n".join(lines)
|
|
355
|
+
|
|
356
|
+
# -- helpers -------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def _require(self, job_id: str) -> Job:
|
|
359
|
+
job = self._jobs.get(job_id)
|
|
360
|
+
if job is None:
|
|
361
|
+
known = ", ".join(sorted(self._jobs.jobs)) or "none"
|
|
362
|
+
raise ModelRetry(f"No job {job_id!r}. Known jobs: {known}.")
|
|
363
|
+
return job
|
|
364
|
+
|
|
365
|
+
def _wait_seconds(self, timeout: float | None) -> float:
|
|
366
|
+
if timeout is None:
|
|
367
|
+
return min(self._default_timeout, MAX_WAIT_SECONDS)
|
|
368
|
+
if timeout <= 0:
|
|
369
|
+
raise ModelRetry("timeout must be greater than zero.")
|
|
370
|
+
return min(timeout, MAX_WAIT_SECONDS)
|
|
371
|
+
|
|
372
|
+
def _compile(self, pattern: str | None) -> re.Pattern[str] | None:
|
|
373
|
+
if pattern is None:
|
|
374
|
+
return None
|
|
375
|
+
try:
|
|
376
|
+
return re.compile(pattern)
|
|
377
|
+
except re.error as error:
|
|
378
|
+
raise ModelRetry(f"until_output is not a valid regular expression: {error}") from error
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class _OutputStream:
|
|
382
|
+
"""Incremental reader that turns a job's log into bounded UI events.
|
|
383
|
+
|
|
384
|
+
Emitting and matching have different budgets on purpose. The preview is a
|
|
385
|
+
transient view and stays small; `until_output` has to keep reading, because
|
|
386
|
+
a chatty server would otherwise print its readiness banner past the end of
|
|
387
|
+
a stream nobody is still watching.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
def __init__(self, job: Job, ctx: RunContext[Any], *, matching: bool = False) -> None:
|
|
391
|
+
self.job = job
|
|
392
|
+
self.ctx = ctx
|
|
393
|
+
self.matching = matching
|
|
394
|
+
self.offset = 0
|
|
395
|
+
self.matchable = ""
|
|
396
|
+
self.truncated = False
|
|
397
|
+
self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
398
|
+
|
|
399
|
+
async def emit(self) -> bool:
|
|
400
|
+
budget = _MATCH_READ_BYTES if self.matching else _EVENT_BUDGET_BYTES
|
|
401
|
+
if self.offset >= budget:
|
|
402
|
+
self.truncated = True
|
|
403
|
+
return False
|
|
404
|
+
# Never read across the preview boundary, so a chunk is either wholly
|
|
405
|
+
# showable or wholly beyond it and the decoder stays in step.
|
|
406
|
+
limit = _EVENT_BUDGET_BYTES if self.offset < _EVENT_BUDGET_BYTES else budget
|
|
407
|
+
try:
|
|
408
|
+
with self.job.output_path.open("rb") as source:
|
|
409
|
+
source.seek(self.offset)
|
|
410
|
+
chunk = source.read(limit - self.offset)
|
|
411
|
+
except OSError:
|
|
412
|
+
return False
|
|
413
|
+
showable = self.offset < _EVENT_BUDGET_BYTES
|
|
414
|
+
self.offset += len(chunk)
|
|
415
|
+
if not chunk:
|
|
416
|
+
return False
|
|
417
|
+
text = self.decoder.decode(chunk)
|
|
418
|
+
if not text:
|
|
419
|
+
return True
|
|
420
|
+
if showable:
|
|
421
|
+
await self.ctx.emit(CommandOutputEvent(text=text))
|
|
422
|
+
else:
|
|
423
|
+
self.truncated = True
|
|
424
|
+
self.matchable = (self.matchable + text)[-_MATCH_BUDGET_CHARS:]
|
|
425
|
+
return True
|
|
426
|
+
|
|
427
|
+
async def drain(self) -> None:
|
|
428
|
+
while await self.emit():
|
|
429
|
+
pass
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@dataclass
|
|
433
|
+
class JobShell(Shell[AgentDepsT]):
|
|
434
|
+
"""`Shell`, but its commands are jobs this session can name and come back to.
|
|
435
|
+
|
|
436
|
+
Commands start in `workdir` with `env` as their whole environment: they
|
|
437
|
+
run through `pcode.jobs` on this host, not through `ctx.workspace`, whose
|
|
438
|
+
local backend passes on only `PATH` and `HOME`.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
workdir: Path = field(default_factory=Path.cwd, kw_only=True)
|
|
442
|
+
|
|
443
|
+
def _make_toolset(self) -> JobShellToolset[AgentDepsT]:
|
|
444
|
+
return JobShellToolset[AgentDepsT](
|
|
445
|
+
jobs=registry(),
|
|
446
|
+
workdir=Path(self.workdir),
|
|
447
|
+
allowed_commands=self.allowed_commands,
|
|
448
|
+
denied_commands=self.denied_commands,
|
|
449
|
+
denied_operators=self.denied_operators,
|
|
450
|
+
default_timeout=self.default_timeout,
|
|
451
|
+
max_output_chars=self.max_output_chars,
|
|
452
|
+
max_file_bytes=self.max_file_bytes,
|
|
453
|
+
persist_cwd=self.persist_cwd,
|
|
454
|
+
allow_interactive=self.allow_interactive,
|
|
455
|
+
env=self.env,
|
|
456
|
+
denied_env_patterns=self.denied_env_patterns,
|
|
457
|
+
tools=self.tools,
|
|
458
|
+
id=self.id or "shell",
|
|
459
|
+
)
|
pcode/skills.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Turn discovered SKILL.md assets into slash commands the user can invoke.
|
|
2
|
+
|
|
3
|
+
The repo-context inventory already locates skills without reading them; this
|
|
4
|
+
module reads only each file's frontmatter, so completion can show a description
|
|
5
|
+
while the body still reaches the model through a normal tool read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pcode.commands import Command
|
|
14
|
+
from pcode.preferences import SETTINGS, load_preferences
|
|
15
|
+
|
|
16
|
+
PREFIX = "/skill:"
|
|
17
|
+
|
|
18
|
+
# Under the spec's free-form `metadata` map, so other assistants ignore it.
|
|
19
|
+
MCP_SERVERS_KEY = "metadata.pcode-mcp-servers"
|
|
20
|
+
|
|
21
|
+
# Mirrors Harness's RepoContext.asset_roots and its skills/**/SKILL.md glob.
|
|
22
|
+
# Importing Harness here would cost ~0.8s on the terminal's startup path, which
|
|
23
|
+
# the app otherwise keeps off the event loop; test_skills guards the drift.
|
|
24
|
+
ASSET_ROOTS = (".claude", ".agents", ".codex", ".grok")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Skill:
|
|
29
|
+
"""One discovered SKILL.md, named after its containing directory."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
path: str
|
|
33
|
+
description: str = ""
|
|
34
|
+
# MCP servers `/skill:NAME` enables before sending the skill's prompt.
|
|
35
|
+
mcp_servers: tuple[str, ...] = ()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _frontmatter(path: Path) -> dict[str, str]:
|
|
39
|
+
"""Read the leading `---` block as flat `key: value` pairs.
|
|
40
|
+
|
|
41
|
+
Indented pairs under a top-level `metadata:` are kept as `metadata.<key>`;
|
|
42
|
+
other nesting is ignored. Skills are authored for other assistants, so treat
|
|
43
|
+
anything unparsable as absent rather than failing the launch.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
text = path.read_text(errors="replace")
|
|
47
|
+
except OSError:
|
|
48
|
+
return {}
|
|
49
|
+
lines = text.splitlines()
|
|
50
|
+
if not lines or lines[0].strip() != "---":
|
|
51
|
+
return {}
|
|
52
|
+
fields = {}
|
|
53
|
+
section = ""
|
|
54
|
+
for line in lines[1:]:
|
|
55
|
+
if line.strip() == "---":
|
|
56
|
+
break
|
|
57
|
+
key, separator, value = line.partition(":")
|
|
58
|
+
if not separator or not key.strip():
|
|
59
|
+
continue
|
|
60
|
+
value = value.strip().strip("\"'")
|
|
61
|
+
if not key[0].isspace():
|
|
62
|
+
section = key.strip().lower()
|
|
63
|
+
fields[section] = value
|
|
64
|
+
elif section == "metadata":
|
|
65
|
+
fields[f"metadata.{key.strip().lower()}"] = value
|
|
66
|
+
return fields
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _names(value: str) -> tuple[str, ...]:
|
|
70
|
+
"""Split a comma- or space-separated list, dropping duplicates in order."""
|
|
71
|
+
return tuple(dict.fromkeys(value.replace(",", " ").split()))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def skill_dirs(workspace: Path) -> list[Path]:
|
|
75
|
+
"""Resolve the configured skill directories against `workspace`."""
|
|
76
|
+
configured = load_preferences().get("skill_dirs", SETTINGS["skill_dirs"].default) or ""
|
|
77
|
+
directories = []
|
|
78
|
+
for entry in configured.split(os.pathsep):
|
|
79
|
+
entry = entry.strip()
|
|
80
|
+
if not entry:
|
|
81
|
+
continue
|
|
82
|
+
path = Path(entry).expanduser()
|
|
83
|
+
directories.append(path if path.is_absolute() else workspace / path)
|
|
84
|
+
return directories
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reference(path: Path, workspace: Path) -> str:
|
|
88
|
+
"""Name the file for the model: workspace-relative inside, absolute outside."""
|
|
89
|
+
if path.is_relative_to(workspace):
|
|
90
|
+
return path.relative_to(workspace).as_posix()
|
|
91
|
+
return path.as_posix()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def discover_skills(workspace: Path) -> list[Skill]:
|
|
95
|
+
"""Locate skills, keeping the first of any duplicated name.
|
|
96
|
+
|
|
97
|
+
Workspace asset roots come first, so a project skill shadows a user-level one
|
|
98
|
+
of the same name.
|
|
99
|
+
"""
|
|
100
|
+
workspace = workspace.resolve()
|
|
101
|
+
roots = [workspace / root / "skills" for root in ASSET_ROOTS]
|
|
102
|
+
skills: dict[str, Skill] = {}
|
|
103
|
+
for directory in roots + skill_dirs(workspace):
|
|
104
|
+
for path in sorted(directory.glob("**/SKILL.md")):
|
|
105
|
+
if not path.is_file():
|
|
106
|
+
continue
|
|
107
|
+
name = path.parent.name.strip().replace(" ", "-")
|
|
108
|
+
if not name or name in skills:
|
|
109
|
+
continue
|
|
110
|
+
fields = _frontmatter(path)
|
|
111
|
+
skills[name] = Skill(
|
|
112
|
+
name,
|
|
113
|
+
_reference(path, workspace),
|
|
114
|
+
fields.get("description", ""),
|
|
115
|
+
_names(fields.get(MCP_SERVERS_KEY, "")),
|
|
116
|
+
)
|
|
117
|
+
return list(skills.values())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def skill_prompt(skill: Skill, argument: str) -> str:
|
|
121
|
+
"""Ask for the skill by path; the model reads the body itself."""
|
|
122
|
+
prompt = (
|
|
123
|
+
f'Use the "{skill.name}" skill: read {skill.path} '
|
|
124
|
+
"and follow its instructions for this request."
|
|
125
|
+
)
|
|
126
|
+
return f"{prompt}\n\n{argument}" if argument else prompt
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def skill_style() -> str:
|
|
130
|
+
return load_preferences().get("skill_commands", SETTINGS["skill_commands"].default)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def skill_commands(
|
|
134
|
+
skills: Sequence[Skill],
|
|
135
|
+
handler: Callable[[Skill, str], None],
|
|
136
|
+
style: str | None = None,
|
|
137
|
+
) -> list[Command]:
|
|
138
|
+
"""Build the commands for `skills`, in the configured naming style."""
|
|
139
|
+
style = style or skill_style()
|
|
140
|
+
if style == "off":
|
|
141
|
+
return []
|
|
142
|
+
commands = []
|
|
143
|
+
for skill in skills:
|
|
144
|
+
names = [PREFIX + skill.name] if style != "bare" else []
|
|
145
|
+
if style in ("bare", "both"):
|
|
146
|
+
names.append("/" + skill.name)
|
|
147
|
+
description = skill.description or f"Run the {skill.name} skill ({skill.path})"
|
|
148
|
+
# Skill descriptions are written for a model, not for a completion menu.
|
|
149
|
+
if len(description) > 96:
|
|
150
|
+
description = description[:95].rstrip() + "…"
|
|
151
|
+
commands.append(
|
|
152
|
+
Command(
|
|
153
|
+
names[0],
|
|
154
|
+
description,
|
|
155
|
+
lambda argument, skill=skill: handler(skill, argument),
|
|
156
|
+
aliases=tuple(names[1:]),
|
|
157
|
+
free_arguments=True,
|
|
158
|
+
group="Skills",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
return commands
|