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/aside.py ADDED
@@ -0,0 +1,541 @@
1
+ """Side questions asked beside a running turn; no terminal imports.
2
+
3
+ A side question ("btw") reuses the conversation's context but is never part of
4
+ it: it is not written to the session journal, does not appear in the
5
+ conversation tree, and cannot be steered or resent. It runs on the
6
+ conversation's own agent, with the same instructions and tool definitions, so
7
+ its requests reuse the provider cache the conversation has already paid for.
8
+ That is also why its framing travels in the question rather than the system
9
+ prompt: anything added ahead of the history would miss the cache.
10
+ """
11
+
12
+ import asyncio
13
+ import re
14
+ from collections.abc import Awaitable, Callable
15
+ from dataclasses import dataclass, field
16
+ from time import monotonic
17
+ from uuid import uuid4
18
+
19
+ from pcode.error_report import error_message
20
+ from pcode.preferences import EFFORTS
21
+
22
+ # One side question is a question, not a second conversation: it gets a small
23
+ # request budget and a hard deadline so a confused run cannot spend a session's
24
+ # worth of tokens in the background where nobody is watching it.
25
+ ASIDE_REQUEST_LIMIT = 12
26
+ ASIDE_TIMEOUT_SECONDS = 300
27
+ # Side questions accumulate over a long session; keep the recent threads
28
+ # readable rather than growing the viewer without bound.
29
+ ASIDE_HISTORY = 20
30
+ # `/btw $a $b QUESTION` fans out one side question per model. Each one sends
31
+ # the whole conversation, uncached on any model but the conversation's own.
32
+ ASIDE_MODEL_LIMIT = 4
33
+ MODEL_MARK = "$"
34
+ # `+high` asks on the conversation's model at that effort; `$model+high` on
35
+ # another model. The levels are the ones /effort accepts.
36
+ EFFORT_MARK = "+"
37
+ # How every provider prefix is spelled (`openai-codex`, `google-gla`).
38
+ PROVIDER_NAME = re.compile(r"[a-z][a-z0-9_-]*")
39
+ ASIDE_MODEL_USAGE = (
40
+ f"Usage: /btw [$PROVIDER:MODEL[+EFFORT] | +EFFORT ...] QUESTION (EFFORT: {'|'.join(EFFORTS)})"
41
+ )
42
+
43
+ ASIDE_FRAMING = (
44
+ "[Side question] The user is asking a side question about the conversation so far. "
45
+ "Answer only this question, briefly, preferring what the conversation already shows "
46
+ "over fresh investigation. Your answer appears in a popup beside the conversation "
47
+ "and is not added to it, so do not address the main agent, continue its task, "
48
+ "or promise work. Tools work normally, but the plan and delegation tools are "
49
+ "unavailable here."
50
+ )
51
+
52
+
53
+ # The first question's framing is already in the history a follow-up re-sends,
54
+ # so this only has to say the same rules still hold.
55
+ FOLLOW_UP_FRAMING = (
56
+ "[Side question follow-up] The user is following up on their side question above. "
57
+ "The same rules apply: answer only this, briefly; it is not added to the conversation."
58
+ )
59
+
60
+
61
+ def framed(question: str) -> str:
62
+ """The user message a side question is sent as."""
63
+ return f"{ASIDE_FRAMING}\n\nQuestion: {question}"
64
+
65
+
66
+ def framed_follow_up(question: str) -> str:
67
+ """The user message a follow-up to a side answer is sent as."""
68
+ return f"{FOLLOW_UP_FRAMING}\n\nQuestion: {question}"
69
+
70
+
71
+ # Asked in the thread, where its questions and answers are, so the summary
72
+ # reuses the thread's cache; `summary_request` is what the conversation records.
73
+ SUMMARY_FRAMING = (
74
+ "[Side thread summary] Summarize this side discussion for the main conversation, "
75
+ "which never saw it: what was asked, what was found, and anything that should change "
76
+ "the ongoing work. Be brief and concrete, with no preamble, and do not use tools."
77
+ )
78
+
79
+
80
+ def framed_summary(instructions: str) -> str:
81
+ """The user message asking a thread to summarize itself, with the user's focus if any."""
82
+ return SUMMARY_FRAMING + (f"\n\nFocus: {instructions}" if instructions else "")
83
+
84
+
85
+ def summary_request(questions: list[str], instructions: str = "") -> str:
86
+ """The user message a thread's summary is recorded under in the conversation.
87
+
88
+ The conversation never saw the thread, so this names its questions rather
89
+ than pointing at a discussion "above" that is not there.
90
+ """
91
+ lines = [
92
+ "[Side thread summary] Beside this conversation I asked side questions it did not see:"
93
+ ]
94
+ lines += [f"- {' '.join(question.split())}" for question in questions]
95
+ lines.append("Summarize what came of them for the rest of this conversation.")
96
+ if instructions:
97
+ lines.append(f"Focus: {instructions}")
98
+ return "\n".join(lines)
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class Bridge:
103
+ """A request to bring a side thread into the conversation.
104
+
105
+ `merge` adds the thread to the conversation tree as it is; `summary` adds a
106
+ summary of it to the active branch, focused by `instructions` when given.
107
+ """
108
+
109
+ thread: str
110
+ action: str
111
+ instructions: str = ""
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class SideReply:
116
+ """A side answer, and what a follow-up needs to continue from it.
117
+
118
+ `messages` is the run's whole history, conversation context included, so a
119
+ follow-up re-sends exactly the prefix this run just cached. `agent`,
120
+ `model` and `settings` are what it ran with, opaque here: a follow-up asks
121
+ the same model the same way even after /model or /effort changed the
122
+ conversation's.
123
+ """
124
+
125
+ answer: str
126
+ messages: list = field(default_factory=list, repr=False)
127
+ conversation_id: str = ""
128
+ agent: object = field(default=None, repr=False)
129
+ model: object = None
130
+ settings: object = None
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class SideTarget:
135
+ """Where one side question runs: a model and the effort to ask it at.
136
+
137
+ An empty `model` is the conversation's own; an empty `effort` keeps the
138
+ effort that model already gets.
139
+ """
140
+
141
+ model: str = ""
142
+ effort: str = ""
143
+
144
+
145
+ def split_effort(word: str) -> tuple[str, str]:
146
+ """Split `model+high` into the model and its effort.
147
+
148
+ Model ids can hold `:` and `/`, so only the part after the last `+` is
149
+ considered, and only a real level counts: anything else stays in the name.
150
+ """
151
+ name, mark, effort = word.rpartition(EFFORT_MARK)
152
+ if mark and effort in EFFORTS:
153
+ return name, effort
154
+ return word, ""
155
+
156
+
157
+ def _target(word: str) -> SideTarget:
158
+ if word.startswith(EFFORT_MARK):
159
+ effort = word.removeprefix(EFFORT_MARK)
160
+ if effort not in EFFORTS:
161
+ raise ValueError(f"Unknown effort `{word}`. {ASIDE_MODEL_USAGE}")
162
+ return SideTarget(effort=effort)
163
+ model, effort = split_effort(word.removeprefix(MODEL_MARK))
164
+ if not model:
165
+ raise ValueError(f"A model name must follow `{MODEL_MARK}`. {ASIDE_MODEL_USAGE}")
166
+ return SideTarget(model, effort)
167
+
168
+
169
+ def parse_models(argument: str) -> tuple[list[SideTarget], str]:
170
+ """Split `$model[+effort] [+effort ...] question` into targets and the question.
171
+
172
+ Only leading `$` and `+` words name targets, so either one inside the
173
+ question is text. Repeated targets collapse to one, in the order first
174
+ given; the same model at two efforts is two targets.
175
+ """
176
+ models: list[SideTarget] = []
177
+ rest = argument.strip()
178
+ while rest.startswith((MODEL_MARK, EFFORT_MARK)):
179
+ word, *tail = rest.split(maxsplit=1)
180
+ rest = tail[0] if tail else ""
181
+ target = _target(word)
182
+ if target not in models:
183
+ models.append(target)
184
+ if models and not rest:
185
+ raise ValueError(f"No question after the model. {ASIDE_MODEL_USAGE}")
186
+ if len(models) > ASIDE_MODEL_LIMIT:
187
+ raise ValueError(
188
+ f"At most {ASIDE_MODEL_LIMIT} models per side question; got {len(models)}."
189
+ )
190
+ return models, rest
191
+
192
+
193
+ def prompt_target(text: str) -> tuple[SideTarget | None, str]:
194
+ """Split a leading `$PROVIDER:MODEL[+EFFORT]` or `+EFFORT` word off a prompt.
195
+
196
+ A prompt is free text, so unlike `/btw` nothing here is refused: a word
197
+ only names a target when it reads as one unmistakably, a model with its
198
+ provider (`$openai:gpt-5`, never `$HOME` or `$PATH:/usr/bin`, whose
199
+ uppercase "provider" no provider is named like) or a real effort level
200
+ (`+high`, never `+1`), and anything else leaves the prompt as typed. One
201
+ word only, since one prompt runs one turn.
202
+ """
203
+ word, *tail = text.lstrip().split(maxsplit=1) or [""]
204
+ rest = tail[0] if tail else ""
205
+ if word.startswith(EFFORT_MARK):
206
+ effort = word.removeprefix(EFFORT_MARK)
207
+ return (SideTarget(effort=effort), rest) if effort in EFFORTS else (None, text)
208
+ if word.startswith(MODEL_MARK):
209
+ model, effort = split_effort(word.removeprefix(MODEL_MARK))
210
+ provider, colon, name = model.partition(":")
211
+ if colon and PROVIDER_NAME.fullmatch(provider) and name:
212
+ return SideTarget(model, effort), rest
213
+ return None, text
214
+
215
+
216
+ def _leading_word(argument: str) -> str | None:
217
+ """The word being typed, while every word so far is a `$` or `+` target."""
218
+ if not argument or argument[-1].isspace():
219
+ return None
220
+ words = argument.split()
221
+ if not all(word.startswith((MODEL_MARK, EFFORT_MARK)) for word in words):
222
+ return None
223
+ return words[-1]
224
+
225
+
226
+ def model_fragment(argument: str) -> str | None:
227
+ """The partial model name being typed in `/btw` arguments, if any.
228
+
229
+ Only a word in the leading run of `$` and `+` words completes as a model;
230
+ once the question has started, a `$` is ordinary text. A word already
231
+ carrying `+` is choosing an effort instead; see `effort_fragment`.
232
+ """
233
+ word = _leading_word(argument)
234
+ if word is None or not word.startswith(MODEL_MARK) or EFFORT_MARK in word:
235
+ return None
236
+ return word.removeprefix(MODEL_MARK)
237
+
238
+
239
+ def effort_fragment(argument: str) -> str | None:
240
+ """The partial effort being typed after `+` in a leading `/btw` word, if any."""
241
+ word = _leading_word(argument)
242
+ if word is None or EFFORT_MARK not in word:
243
+ return None
244
+ return word.rpartition(EFFORT_MARK)[2]
245
+
246
+
247
+ def model_labels(models: list[SideTarget]) -> dict[SideTarget, str]:
248
+ """Short labels: the model without its provider, unless two would collide.
249
+
250
+ An effort joins the label, so answers at different efforts stay apart; the
251
+ conversation's own model shows the effort alone.
252
+ """
253
+ names = list(dict.fromkeys(target.model for target in models if target.model))
254
+ short = {name: name.partition(":")[2] or name for name in names}
255
+ counts: dict[str, int] = {}
256
+ for label in short.values():
257
+ counts[label] = counts.get(label, 0) + 1
258
+ labels = {}
259
+ for target in models:
260
+ label = short.get(target.model, "")
261
+ if label and counts[label] > 1:
262
+ label = target.model
263
+ labels[target] = " \u00b7 ".join(part for part in (label, target.effort) if part)
264
+ return labels
265
+
266
+
267
+ def exchanges(thread: list["Aside"], messages: list) -> list[tuple["Aside", int]]:
268
+ """The thread's questions that `messages` answered, each with where its answer ends.
269
+
270
+ `messages` is the history the thread's newest answer ran with, so it holds
271
+ every question the chain answered; a failed or stopped follow-up never
272
+ joined it and is skipped. A question starts at the request carrying its
273
+ framed text, and its answer runs to the next one's start, or to the end.
274
+ """
275
+ from pydantic_ai.messages import ModelRequest, UserPromptPart
276
+
277
+ def asks(message, text: str) -> bool:
278
+ return isinstance(message, ModelRequest) and any(
279
+ isinstance(part, UserPromptPart) and part.content == text for part in message.parts
280
+ )
281
+
282
+ starts: list[tuple[Aside, int]] = []
283
+ index = 0
284
+ for number, aside in enumerate(thread):
285
+ text = framed_follow_up(aside.question) if number else framed(aside.question)
286
+ found = next((i for i in range(index, len(messages)) if asks(messages[i], text)), None)
287
+ if found is not None:
288
+ starts.append((aside, found))
289
+ index = found + 1
290
+ ends = [start for _, start in starts[1:]] + [len(messages)]
291
+ return [(aside, end) for (aside, _), end in zip(starts, ends)]
292
+
293
+
294
+ def settled_context(messages: list) -> list:
295
+ """The longest prefix of `messages` whose tool calls all have results.
296
+
297
+ The newest context available while a turn runs is the request in flight,
298
+ which usually ends in tool calls that have not returned yet. Providers
299
+ reject a history with an unanswered call, so a side question is asked
300
+ against the last point where the conversation was balanced.
301
+
302
+ The provider stack is imported here, not at module import: the terminal
303
+ reaches its first frame without loading it. See `dev/dependencies.md`.
304
+ """
305
+ from pydantic_ai.messages import (
306
+ NativeToolCallPart,
307
+ NativeToolReturnPart,
308
+ RetryPromptPart,
309
+ ToolCallPart,
310
+ ToolReturnPart,
311
+ )
312
+
313
+ calls = (ToolCallPart, NativeToolCallPart)
314
+ results = (ToolReturnPart, NativeToolReturnPart, RetryPromptPart)
315
+ outstanding: set[str] = set()
316
+ balanced = 0
317
+ for index, message in enumerate(messages, start=1):
318
+ for part in message.parts:
319
+ if isinstance(part, calls):
320
+ outstanding.add(part.tool_call_id)
321
+ elif isinstance(part, results):
322
+ outstanding.discard(part.tool_call_id)
323
+ if not outstanding:
324
+ balanced = index
325
+ return list(messages[:balanced])
326
+
327
+
328
+ @dataclass
329
+ class Aside:
330
+ """One side question and whatever of its answer has arrived."""
331
+
332
+ question: str
333
+ id: str = field(default_factory=lambda: uuid4().hex[:8])
334
+ # The model named with `/btw $MODEL`, and its short display form. Empty when
335
+ # the question was asked without one, on the conversation's own model.
336
+ model: str = ""
337
+ label: str = ""
338
+ # The effort named with `+EFFORT`; empty when the model's own applies.
339
+ effort: str = ""
340
+ # running → answered / failed / cancelled / timed out.
341
+ status: str = "running"
342
+ answer: str = ""
343
+ activity: str = "Waiting for model…"
344
+ error: str = ""
345
+ started: float = field(default_factory=monotonic)
346
+ finished: float | None = None
347
+ # Whether the answer has been opened in the viewer since it settled.
348
+ read: bool = False
349
+ # The id of the thread's first question; a follow-up shares it.
350
+ thread: str = ""
351
+ # Where a thread's first question was asked: the conversation, and its tree
352
+ # node active then. Merging forks the thread from that node.
353
+ conversation: str = ""
354
+ base: str | None = None
355
+ # How the thread was brought into the conversation, e.g. "merged".
356
+ bridged: str = ""
357
+ # What a follow-up continues from, once answered. Only a thread's newest
358
+ # answer keeps one: each holds a copy of the conversation.
359
+ reply: SideReply | None = field(default=None, repr=False)
360
+
361
+ def __post_init__(self) -> None:
362
+ self.thread = self.thread or self.id
363
+
364
+ @property
365
+ def running(self) -> bool:
366
+ return self.status == "running"
367
+
368
+ @property
369
+ def elapsed(self) -> float:
370
+ return (self.finished if self.finished is not None else monotonic()) - self.started
371
+
372
+ def settle(self, status: str, *, error: str = "") -> None:
373
+ self.status = status
374
+ self.error = error
375
+ self.activity = ""
376
+ self.finished = monotonic()
377
+
378
+ def state(self) -> str:
379
+ """Short status for the viewer list and footer, e.g. `answered 12s`."""
380
+ return f"{self.status} {self.elapsed:.0f}s"
381
+
382
+
383
+ class Asides:
384
+ """Every side question this session asked, oldest first.
385
+
386
+ A follow-up asked in the viewer is a side question of its own, sharing its
387
+ thread with the question it follows; the viewer shows each thread as one
388
+ conversation.
389
+ """
390
+
391
+ def __init__(self) -> None:
392
+ self.items: list[Aside] = []
393
+ self._tasks: dict[str, asyncio.Task] = {}
394
+ # Set by the terminal to repaint while an answer streams and to announce
395
+ # one that settled. Off-terminal callers (tests, `--print`) need neither.
396
+ self.on_update: Callable[[Aside], None] = lambda aside: None
397
+ self.on_settle: Callable[[Aside], None] = lambda aside: None
398
+ # Given the exception of a question that failed or timed out, so the
399
+ # frames can be kept where the session keeps turn failures.
400
+ self.on_failure: Callable[[Aside, BaseException], None] = lambda aside, error: None
401
+
402
+ @property
403
+ def running(self) -> int:
404
+ return sum(aside.running for aside in self.items)
405
+
406
+ @property
407
+ def unread(self) -> int:
408
+ return sum(not aside.running and not aside.read for aside in self.items)
409
+
410
+ def latest(self) -> Aside | None:
411
+ """The answer a bare `/btw` should open: newest unread, else newest."""
412
+ unread = [aside for aside in self.items if not aside.running and not aside.read]
413
+ return (unread or self.items or [None])[-1]
414
+
415
+ def threads(self) -> list[list[Aside]]:
416
+ """Each thread's questions in order, threads in the order they began."""
417
+ threads: dict[str, list[Aside]] = {}
418
+ for aside in self.items:
419
+ threads.setdefault(aside.thread, []).append(aside)
420
+ return list(threads.values())
421
+
422
+ def thread(self, thread: str) -> list[Aside]:
423
+ return [aside for aside in self.items if aside.thread == thread]
424
+
425
+ def follows(self, thread: str) -> Aside:
426
+ """The answer a follow-up in `thread` continues from.
427
+
428
+ Raises `ValueError` saying why when there is none to continue: the
429
+ newest question is still running, or no answer in it arrived.
430
+ """
431
+ asked = self.thread(thread)
432
+ if not asked:
433
+ raise ValueError("That side question is gone; ask again with /btw")
434
+ if asked[-1].running:
435
+ raise ValueError("Wait for this answer first")
436
+ for aside in reversed(asked):
437
+ if aside.reply is not None:
438
+ return aside
439
+ raise ValueError("No answer to follow up on; ask again with /btw")
440
+
441
+ def start(
442
+ self,
443
+ question: str,
444
+ work: Callable[[Aside], Awaitable[SideReply | None]],
445
+ *,
446
+ model: str = "",
447
+ label: str = "",
448
+ effort: str = "",
449
+ thread: str = "",
450
+ conversation: str = "",
451
+ base: str | None = None,
452
+ ) -> Aside:
453
+ """Register a side question and run `work` for it in the background.
454
+
455
+ `work` returns the reply a follow-up would continue from. `thread`
456
+ makes it a follow-up in that thread rather than a new one;
457
+ `conversation` and `base` say where a new one was asked.
458
+ """
459
+ aside = Aside(
460
+ question=question,
461
+ model=model,
462
+ label=label,
463
+ effort=effort,
464
+ thread=thread,
465
+ conversation=conversation,
466
+ base=base,
467
+ )
468
+ self.items.append(aside)
469
+ self._trim()
470
+ self._tasks[aside.id] = asyncio.create_task(self._run(aside, work))
471
+ # Shown as asked, before the first word of an answer arrives.
472
+ self.on_update(aside)
473
+ return aside
474
+
475
+ def _trim(self) -> None:
476
+ """Drop the oldest settled threads beyond `ASIDE_HISTORY`, whole.
477
+
478
+ A running question owns a live task, so its thread stays; a thread
479
+ loses no single question, or its conversation would read wrong.
480
+ """
481
+ threads = self.threads()
482
+ excess = len(threads) - ASIDE_HISTORY
483
+ for asked in threads:
484
+ if excess <= 0:
485
+ break
486
+ if not any(aside.running for aside in asked):
487
+ # In place: the live panel shares this list.
488
+ self.items[:] = [aside for aside in self.items if aside.thread != asked[0].thread]
489
+ excess -= 1
490
+
491
+ def cancel(self) -> int:
492
+ """Stop every running side question; returns how many were stopped."""
493
+ tasks = [task for task in self._tasks.values() if not task.done()]
494
+ for task in tasks:
495
+ if not task.cancelling():
496
+ task.cancel()
497
+ return len(tasks)
498
+
499
+ async def close(self) -> None:
500
+ self.cancel()
501
+ tasks = list(self._tasks.values())
502
+ if tasks:
503
+ await asyncio.gather(*tasks, return_exceptions=True)
504
+
505
+ def update(self, aside: Aside, *, answer: str, activity: str) -> None:
506
+ aside.answer = answer
507
+ aside.activity = activity
508
+ self.on_update(aside)
509
+
510
+ async def _run(self, aside: Aside, work: Callable[[Aside], Awaitable[None]]) -> None:
511
+ try:
512
+ async with asyncio.timeout(ASIDE_TIMEOUT_SECONDS):
513
+ reply = await work(aside)
514
+ except TimeoutError as error:
515
+ aside.settle("timed out", error=f"No answer within {ASIDE_TIMEOUT_SECONDS}s.")
516
+ self._failed(aside, error)
517
+ except asyncio.CancelledError:
518
+ aside.settle("cancelled")
519
+ raise
520
+ except Exception as error:
521
+ aside.settle("failed", error=error_message(error))
522
+ self._failed(aside, error)
523
+ else:
524
+ aside.settle("answered")
525
+ if reply is not None:
526
+ aside.reply = reply
527
+ # Follow-ups continue from the newest answer; an older one
528
+ # would only hold another copy of the conversation.
529
+ for older in self.thread(aside.thread):
530
+ if older is not aside:
531
+ older.reply = None
532
+ finally:
533
+ self._tasks.pop(aside.id, None)
534
+ self.on_settle(aside)
535
+
536
+ def _failed(self, aside: Aside, error: BaseException) -> None:
537
+ # Diagnostics must never replace the failure being diagnosed.
538
+ try:
539
+ self.on_failure(aside, error)
540
+ except Exception:
541
+ pass
pcode/aside_guard.py ADDED
@@ -0,0 +1,39 @@
1
+ """Refuse the tools a side question would use to change the conversation's state.
2
+
3
+ A side question runs on the conversation's own agent so its requests share the
4
+ provider cache with the turns around it, which means it also shares their
5
+ capability instances. Most tools are safe to share: a shell command or a file
6
+ edit changes the workspace, which the user asked about anyway. The plan and
7
+ delegated workers are different: they belong to the conversation, so a write
8
+ from a popup nobody is reading would silently replace the plan the main turn is
9
+ following, or start a worker billed to a turn that never asked for it.
10
+
11
+ The tools stay declared -- dropping them would change the request prefix and
12
+ break the cache -- and a call is answered with a failed result instead, so the
13
+ model reads why and carries on answering.
14
+ """
15
+
16
+ from pydantic_ai.capabilities import AbstractCapability
17
+ from pydantic_ai.exceptions import ToolFailed
18
+
19
+ from pcode.tool_display import PLAN_TOOLS
20
+
21
+ # Reading the plan is how a side question learns what the turn is doing.
22
+ PLAN_READS = frozenset({"read_plan", "get_available_tasks"})
23
+ # `list_task_worktrees` only reads, so it stays available with the plan reads.
24
+ DELEGATION_TOOLS = frozenset({"delegate_task", "integrate_task", "discard_task"})
25
+ REFUSED_TOOLS = (PLAN_TOOLS - PLAN_READS) | DELEGATION_TOOLS
26
+
27
+
28
+ class AsideGuard(AbstractCapability):
29
+ """Per-run capability for side questions; see the module docstring."""
30
+
31
+ async def wrap_tool_execute(self, ctx, *, call, tool_def, args, handler):
32
+ if call.tool_name in REFUSED_TOOLS:
33
+ # A failed result rather than `ModelRetry`: nothing about the call
34
+ # can be corrected, and a retry would spend the tool's retry budget.
35
+ raise ToolFailed(
36
+ f"{call.tool_name} is unavailable in a side question: the plan and "
37
+ "delegated tasks belong to the main conversation. Answer without it."
38
+ )
39
+ return await handler(args)