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/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