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/controller.py ADDED
@@ -0,0 +1,3496 @@
1
+ """What runs a conversation: the `SessionController` a session host runs.
2
+
3
+ The terminal only renders and attaches over the socket; `--no-host` and
4
+ `--print` run a controller in-process. See dev/development.md#session-hosts.
5
+ """
6
+
7
+ import asyncio
8
+ import inspect
9
+ import threading
10
+ from collections.abc import Callable
11
+ from contextlib import aclosing
12
+ from dataclasses import replace
13
+ from pathlib import Path
14
+ from typing import Protocol
15
+
16
+ from pcode.aside import (
17
+ EFFORT_MARK,
18
+ MODEL_MARK,
19
+ Aside,
20
+ Asides,
21
+ Bridge,
22
+ SideTarget,
23
+ effort_fragment,
24
+ exchanges,
25
+ model_fragment,
26
+ model_labels,
27
+ parse_models,
28
+ prompt_target,
29
+ summary_request,
30
+ )
31
+ from pcode.commands import Command, CommandRegistry
32
+ from pcode.error_report import error_message
33
+ from pcode.jobs import OUTPUT_TAIL_BYTES, WATCHED_PREFIX, format_duration
34
+ from pcode.logins import Logins
35
+ from pcode.preferences import (
36
+ EFFORTS,
37
+ SETTINGS,
38
+ THINKING_MODES,
39
+ apply_effort,
40
+ apply_thinking,
41
+ effort_for,
42
+ effort_setting,
43
+ effort_unavailable,
44
+ from_project,
45
+ load_preferences,
46
+ save_model_effort,
47
+ save_preferences,
48
+ subagent_models,
49
+ )
50
+ from pcode.rpc import transportable
51
+ from pcode.runtime import CommandOutput, JobFinished, Message, ToolSummary
52
+ from pcode.shell_mode import execute, shell_command
53
+ from pcode.tool_display import command_text
54
+
55
+ # What the terminal handles itself. A skill or extension command may not take
56
+ # one of these names (nor a session command's): built-ins win.
57
+ TERMINAL_COMMANDS = frozenset(
58
+ {
59
+ "/help",
60
+ "/commands",
61
+ "/config",
62
+ "/bind",
63
+ "/unbind",
64
+ "/quit",
65
+ "/exit",
66
+ "/status",
67
+ "/tools",
68
+ "/diffs",
69
+ "/links",
70
+ "/copy",
71
+ "/tree",
72
+ "/agents",
73
+ "/resume",
74
+ "/switch",
75
+ "/restart",
76
+ "/stop",
77
+ "/detach",
78
+ "/show-tasks",
79
+ "/autohide-tasks",
80
+ "/show-thinking",
81
+ "/show-edits",
82
+ "/show-commands",
83
+ "/group-tools",
84
+ "/theme",
85
+ "/syntax",
86
+ "/theme-preview",
87
+ "/redraw",
88
+ }
89
+ )
90
+
91
+ WORKTREE_ACTIONS = {
92
+ "status": "Branch, mainline, and what is unmerged",
93
+ "merge": "Merge the mainline into this branch, then fast-forward the mainline",
94
+ "resolve": "Ask the model to resolve the conflicts a merge stopped on",
95
+ "finish": "Merge, remove the worktree and its branch, and quit",
96
+ "remove": "Delete the merged worktree; the branch stays",
97
+ "list": "Every worktree of this repository",
98
+ "clean": "Delete every other worktree with nothing uncommitted or unmerged",
99
+ }
100
+
101
+
102
+ def meridian_thinking_note(base: str | None, passthrough: bool | None) -> str:
103
+ if passthrough:
104
+ return "Meridian forwards readable thinking, so it appears in scrollback."
105
+ if passthrough is False:
106
+ return (
107
+ f"The Meridian proxy at {base} is not forwarding thinking, so none will appear. "
108
+ f"Turn on passthrough → Thinking Passthrough at {base}/settings "
109
+ "(this changes it for every client of that proxy)."
110
+ )
111
+ return (
112
+ "Meridian must forward readable thinking for scrollback. Managed Meridian does; "
113
+ "for an external proxy, check passthrough → Thinking Passthrough in its /settings "
114
+ "page. This toggle only changes pcode's display."
115
+ )
116
+
117
+
118
+ # The fields of `Activity` the session sets. Everything else in it is derived
119
+ # from the events the terminal renders (tools, workers, plan, previews).
120
+ SESSION_FIELDS = (
121
+ "busy",
122
+ "status",
123
+ "queued",
124
+ "queued_prompts",
125
+ "queued_modes",
126
+ "prompt",
127
+ "prompt_state",
128
+ "turn_outcome",
129
+ "prompt_kind",
130
+ "prompt_detail",
131
+ "user_command",
132
+ "job_count",
133
+ "watched_job",
134
+ "notice",
135
+ "notice_expires",
136
+ )
137
+
138
+ # What a terminal attached to a host may call on its controller. Intents are
139
+ # sent without waiting; `query` names one of QUERIES and returns its value.
140
+ INTENTS = frozenset(
141
+ {
142
+ "submit",
143
+ "command",
144
+ "cancel",
145
+ "set_thinking",
146
+ "adjust_effort",
147
+ "stop_jobs",
148
+ "watch_job",
149
+ "cancel_asides",
150
+ }
151
+ )
152
+ QUERIES = frozenset(
153
+ {
154
+ "session_overview",
155
+ "meridian_thinking_state",
156
+ "follow_up_aside",
157
+ "check_bridge",
158
+ "navigate_tree",
159
+ }
160
+ )
161
+
162
+ # Commands that change the conversation itself, refused while a turn runs or
163
+ # prompts wait (bar /compact and /resend sent while idle, which go first).
164
+ IDLE_COMMANDS = frozenset({"/resend", "/new", "/resume", "/login", "/logout", "/compact"})
165
+
166
+ # Commands that make a model request or change the toolset. Each holds the
167
+ # session busy from Enter until its handler starts, so a Ctrl+C in the same
168
+ # input batch cancels it instead of clearing the draft.
169
+ MODEL_COMMANDS = frozenset({"/compact", "/resend"})
170
+
171
+ # Commands that only change the display. They run even before the runtime is
172
+ # ready and are never dropped by a Ctrl+C clearing the queue.
173
+ FRONTEND_COMMANDS = frozenset(
174
+ {
175
+ "/quit",
176
+ "/exit",
177
+ "/help",
178
+ "/commands",
179
+ "/theme",
180
+ "/theme-preview",
181
+ "/syntax",
182
+ "/show-tasks",
183
+ "/autohide-tasks",
184
+ "/show-thinking",
185
+ "/show-edits",
186
+ "/show-commands",
187
+ "/group-tools",
188
+ "/redraw",
189
+ "/config",
190
+ "/bind",
191
+ "/unbind",
192
+ }
193
+ )
194
+
195
+ # Session commands that can recover from a failed startup: picking a model that
196
+ # builds starts the agent the bad one could not, and signing in is often the fix.
197
+ RECOVERY_COMMANDS = frozenset({"/model", "/login"})
198
+
199
+ # One queued command: the queue generation it was sent in, its text, whether
200
+ # the session was idle when it was sent, and the popup generation the terminal
201
+ # stamped on it.
202
+ QueuedCommand = tuple[int, str, bool, object]
203
+
204
+ STARTUP_FAILED = "Agent startup failed; choose another model with /model or restart pcode."
205
+
206
+
207
+ class MessageOwner(Protocol):
208
+ """Whoever waits on one queued message (a headless caller): told when its turn
209
+ starts, or that it never will. Either is said exactly once."""
210
+
211
+ def started(self) -> None: ...
212
+ def dropped(self) -> None: ...
213
+
214
+
215
+ # One queued message: the queue generation it was sent in, its text, how it is
216
+ # sent ("steering", "queue", "interrupt", "shell", "resend", "wake"), and its
217
+ # owner, if anyone waits on it.
218
+ Item = tuple[int, str, str, MessageOwner | None]
219
+
220
+
221
+ class PromptQueue:
222
+ """Messages waiting for the model, in order, mirrored into the live panel.
223
+
224
+ The panel lists them from `activity.queued_prompts` and `queued_modes`, so
225
+ every change goes through here and the two cannot drift. `generation`
226
+ moves on at every `clear`: an item fetched before a clear, while a command
227
+ or popup held the queue up, is recognised as stale and skipped. Commands
228
+ are stamped with it too, for the same reason.
229
+ """
230
+
231
+ def __init__(self, activity) -> None:
232
+ self.activity = activity
233
+ self.generation = 0
234
+ self._items: asyncio.Queue[Item] = asyncio.Queue()
235
+
236
+ def __len__(self) -> int:
237
+ return len(self.activity.queued_prompts)
238
+
239
+ def put(
240
+ self, text: str, mode: str, *, first: bool = False, owner: MessageOwner | None = None
241
+ ) -> None:
242
+ """Queue `text`; `first` puts it ahead of everything already waiting."""
243
+ if first:
244
+ waiting = self._drain()
245
+ self._items.put_nowait((self.generation, text, mode, owner))
246
+ for item in waiting:
247
+ self._items.put_nowait(item)
248
+ self.activity.queued_prompts.insert(0, text)
249
+ self.activity.queued_modes.insert(0, mode)
250
+ else:
251
+ self._items.put_nowait((self.generation, text, mode, owner))
252
+ self.activity.queued_prompts.append(text)
253
+ self.activity.queued_modes.append(mode)
254
+ self._sync()
255
+
256
+ async def get(self) -> Item:
257
+ """The next item, stale or not; check `current` before acting on it."""
258
+ return await self._items.get()
259
+
260
+ def current(self, item: Item) -> bool:
261
+ return item[0] == self.generation
262
+
263
+ def taken(self) -> None:
264
+ """The item just fetched is being acted on: drop it from the panel."""
265
+ self.activity.queued_prompts.pop(0)
266
+ self.activity.queued_modes.pop(0)
267
+ self._sync()
268
+
269
+ def clear(self) -> int:
270
+ """Drop everything waiting and start a new generation. Returns how many were dropped."""
271
+ self.generation += 1
272
+ count = len(self.activity.queued_prompts)
273
+ for *_, owner in self._drain():
274
+ if owner is not None:
275
+ owner.dropped()
276
+ self.activity.queued_prompts.clear()
277
+ self.activity.queued_modes.clear()
278
+ self._sync()
279
+ return count
280
+
281
+ def steering(self) -> int:
282
+ """How many current steering messages are waiting."""
283
+ items = self._drain()
284
+ for item in items:
285
+ self._items.put_nowait(item)
286
+ return sum(
287
+ 1
288
+ for generation, _text, mode, _owner in items
289
+ if generation == self.generation and mode == "steering"
290
+ )
291
+
292
+ def take_steering(self, limit: int | None = None) -> list[str]:
293
+ """Remove and return the current steering messages, the first `limit` of
294
+ them if given; everything else keeps its place."""
295
+ messages = []
296
+ for item in self._drain():
297
+ generation, text, mode, _owner = item
298
+ if (
299
+ generation == self.generation
300
+ and mode == "steering"
301
+ and (limit is None or len(messages) < limit)
302
+ ):
303
+ messages.append(text)
304
+ index = next(
305
+ i
306
+ for i, queued in enumerate(
307
+ zip(self.activity.queued_prompts, self.activity.queued_modes)
308
+ )
309
+ if queued == (text, mode)
310
+ )
311
+ self.activity.queued_prompts.pop(index)
312
+ self.activity.queued_modes.pop(index)
313
+ else:
314
+ self._items.put_nowait(item)
315
+ self._sync()
316
+ return messages
317
+
318
+ def _drain(self) -> list[Item]:
319
+ items = []
320
+ while not self._items.empty():
321
+ items.append(self._items.get_nowait())
322
+ return items
323
+
324
+ def _sync(self) -> None:
325
+ self.activity.queued = len(self.activity.queued_prompts)
326
+
327
+
328
+ class SessionView(Protocol):
329
+ """What the controller shows, implemented by whatever is displaying the session.
330
+
331
+ In-process that is `PreviewApp`; in a session host it will be the socket,
332
+ so arguments stay plain values and runtime events. It grows as logic moves
333
+ here from the terminal.
334
+ """
335
+
336
+ # Scrollback
337
+ def user(self, text: str) -> None: ...
338
+ def note(self, text: str) -> None: ...
339
+ def retained_note(self, text: str) -> None: ...
340
+ def flash(self, text: str) -> None: ...
341
+ def warning(self, text: str) -> None: ...
342
+ def error(self, text: str, *, title: str = "Error") -> None: ...
343
+ def cancelled(self) -> None: ...
344
+ def tool_result(self, event) -> None: ...
345
+ def shell_result(
346
+ self, command: str, output: str, *, failed: bool, elapsed_seconds: float
347
+ ) -> None: ...
348
+
349
+ # The live panel's own state (queue, busy, prompt row) changed; repaint.
350
+ def redraw(self) -> None: ...
351
+
352
+ # A turn, in order: started, its events, retries, ended. `drop_output`
353
+ # removes a live command preview once its command is done.
354
+ def turn_started(self, text: str, *, echo: bool) -> None: ...
355
+ def turn_event(self, event) -> None: ...
356
+ def turn_retry(self, text: str) -> None: ...
357
+ def turn_ended(self) -> None: ...
358
+ def finish_text(self) -> None: ...
359
+ def show_output(self, event) -> None: ...
360
+ def drop_output(self, call_id: str) -> None: ...
361
+ async def after_turn(self) -> None: ...
362
+
363
+ # A command the controller does not handle itself, run by the terminal
364
+ # that sent it; `after_command` follows every command, with its tag.
365
+ async def run_command(self, text: str, *, idle: bool, tag: object) -> None: ...
366
+ async def after_command(self, tag: object = None) -> None: ...
367
+
368
+ # One of the session's own commands is running (`tag` as for run_command),
369
+ # and has finished. Its popups, below, return None when dismissed.
370
+ def command_started(self, tag: object) -> None: ...
371
+ def command_finished(self) -> None: ...
372
+
373
+ # The session's commands changed (skills, extension commands), or the
374
+ # conversation was replaced by a new one. `session_changed` says anything
375
+ # else in `session_state` may have (model, effort, context, completions).
376
+ def commands_changed(self) -> None: ...
377
+ def session_changed(self) -> None: ...
378
+ def conversation_reset(self, title: str) -> None: ...
379
+ def replay_conversation(self) -> None: ...
380
+ def show_branch(self) -> None: ...
381
+ def preview_reply(self, text: str) -> None: ...
382
+ def show_events(self, events) -> None: ...
383
+
384
+ # A side question's answer moved (it streams), or it arrived.
385
+ def aside_changed(self, aside) -> None: ...
386
+ def aside_answered(self, aside) -> None: ...
387
+
388
+ # Popups the session's commands open, in the terminal that sent them.
389
+ async def browse_jobs(self) -> None: ...
390
+ async def choose_model(self, values, providers, current: str | None) -> str | None: ...
391
+ async def read_asides(self): ...
392
+
393
+
394
+ # What a host may call on an attached terminal: its view, and the mirrors of
395
+ # the live panel (`state`), the session (`session_state`), and side questions.
396
+ # The `message_*` and `session_ended` calls go only to a headless caller (see
397
+ # pcode.remote_print): about its own message, and a command that ended the session.
398
+ VIEW_CALLS = frozenset(
399
+ {name for name in vars(SessionView) if not name.startswith("_")}
400
+ | {"state", "session_state", "aside_changed", "aside_answered", "host_closed"}
401
+ | {"message_started", "message_dropped", "session_ended"}
402
+ )
403
+
404
+ transportable(Bridge)
405
+
406
+
407
+ def mcp_enable(text: str) -> bool:
408
+ """An /mcp command that may sign in, which prompts queued behind it wait for."""
409
+ words = text.split()
410
+ return words[:1] == ["/mcp"] and words[1:2] in (["enable"], ["enable-all"])
411
+
412
+
413
+ def wake_row(text: str) -> tuple[str, str]:
414
+ """The live row for a turn a finished job started: a badge, not an echo."""
415
+ return "Job finished", text.partition("\n")[0].partition(" Read ")[0]
416
+
417
+
418
+ def delivered_job(event) -> str | None:
419
+ """The job a top-level `wait_for_job` or `job_output` call just collected, if any."""
420
+ if (
421
+ isinstance(event, ToolSummary)
422
+ and event.name in {"wait_for_job", "job_output"}
423
+ and not event.parent_call_id
424
+ ):
425
+ return event.detail.partition(" ")[0]
426
+ return None
427
+
428
+
429
+ class SessionController:
430
+ """Runs a conversation: what it does next, the turns themselves, and stopping them.
431
+
432
+ Owns the runtime, the prompt and command queues, the tasks doing work (a
433
+ turn, MCP work, a history rewrite), and whether the session reads as busy.
434
+ Everything it shows goes through `view`; `activity` holds the live panel's
435
+ session state (busy, queue, prompt row), which the view paints. The view is
436
+ the terminal in-process (`PreviewApp`), or `pcode.host.HostView` in a host.
437
+
438
+ Areas of responsibility, in roughly the order their methods appear:
439
+
440
+ - Slash commands: registering the commands the session handles.
441
+ - Turn lifecycle: busy state, prompt and command queues, dispatch, turns,
442
+ steering, and cancellation.
443
+ - Background work: MCP enabling and sign-out, compaction, history rewrites.
444
+ - Shell jobs: the footer's active count, a watched tail, and job wake-ups.
445
+ - MCP servers: `/mcp` and saving default servers.
446
+ - History: `/compact`, `/autocompact`, `/resend`.
447
+ - The session: skills, extensions and sub-agents, building the runtime,
448
+ switching models, effort, worktrees, startup, and resuming.
449
+ - Side questions (`/btw`) and conversation branches (`/tree`).
450
+
451
+ Provider sign-in and sign-out live in `pcode.logins.Logins`, held as `logins`.
452
+ """
453
+
454
+ def __init__(self, view: SessionView, activity, runtime=None) -> None:
455
+ self.view = view
456
+ self.activity = activity
457
+ self.runtime = runtime
458
+ # False once the owner (the terminal, or the host) is done with it.
459
+ self.running = True
460
+ # Building the runtime has not finished yet, or failed with this.
461
+ self.startup_pending = False
462
+ self.startup_error: Exception | None = None
463
+ self.model: str | None = None
464
+ self.workspace = Path.cwd()
465
+ # Where saved sessions live, and whether this one is saved.
466
+ self.session_dir: Path | None = None
467
+ self.save_sessions = False
468
+ # Consumed by the first saved session so it shares its ID with the
469
+ # worktree created for it; later `/new` sessions get their own.
470
+ self._session_id: str | None = None
471
+ self._saved_session = None
472
+ self.resuming = False
473
+ # Whether the resumed conversation is on screen already (a terminal
474
+ # draws it before the runtime loads; a host only once it has).
475
+ self.conversation_shown = False
476
+ # The runtime is built once the event loop is up (see initialize_runtime).
477
+ self._needs_runtime = False
478
+ self._loop: asyncio.AbstractEventLoop | None = None
479
+ # A model chosen mid-run, adopted before the next request.
480
+ self.pending_model: str | None = None
481
+ # User extensions load with the runtime; their commands register once it exists.
482
+ self.extensions = None
483
+ self.extension_command_names: list[str] = []
484
+ self.skill_command_names: list[str] = []
485
+ self._startup_context_shown: set[str] = set()
486
+ self._meridian_thinking_warned = False
487
+ # Whether commands run through the command loop (a terminal or a host),
488
+ # so slow ones can show a row; without one, a job runs inline.
489
+ self.interactive = False
490
+ # Work a command asked for, done once its handler returns: a slow job
491
+ # under a system row, a skill's prompt, an extension reload, a sign-in.
492
+ self.job_requested: tuple[str, str, Callable[[], list[str]]] | None = None
493
+ self.skill_requested: str | None = None
494
+ self.skill_mcp_requested: tuple[str, tuple[str, ...]] | None = None
495
+ self.reload_requested = False
496
+ self.reload_pending = False # Asked for mid-turn; `consume` runs it between turns.
497
+ self.login_requested: str | None = None
498
+ self.logout_requested: str | None = None
499
+ # Runs the sign-ins and sign-outs those two ask for.
500
+ self.logins = Logins(self)
501
+ # Side questions run beside the conversation instead of in it, so they
502
+ # keep their own records and never enter the queue.
503
+ self.asides = Asides()
504
+ # The live panel spins a row per running question; share the list so
505
+ # it needs no refresh hook of its own.
506
+ self.activity.asides = self.asides.items
507
+ self.asides.on_failure = self.record_aside_failure
508
+ self.asides.on_update = lambda aside: self.view.aside_changed(aside)
509
+ self.asides.on_settle = self.aside_settled
510
+ # Titling a session beside its first turn (`session_naming`): the running
511
+ # request, and the sessions asked already while open, so a failure is
512
+ # not retried on every turn. Opening a session (/new, /resume) clears it.
513
+ self.naming_task: asyncio.Task | None = None
514
+ self._naming_tried: set[str] = set()
515
+ self._model_suggestions: tuple[str | None, float, list[str]] | None = None
516
+ # Whether the owner is shutting down, so a cancelled turn is not a Ctrl+C.
517
+ self.closing: Callable[[], bool] = lambda: False
518
+ self.prompts = PromptQueue(activity)
519
+ self.commands: asyncio.Queue[QueuedCommand] = asyncio.Queue()
520
+ # Backend commands sent before the runtime was ready, in order.
521
+ self.startup_commands: list[QueuedCommand] = []
522
+ self.ready = asyncio.Event()
523
+ # Set while nothing holds queued prompts back: no command running, no
524
+ # MCP work, no history rewrite. The turn loop waits on all three.
525
+ self.command_idle = asyncio.Event()
526
+ self.mcp_idle = asyncio.Event()
527
+ self.compact_idle = asyncio.Event()
528
+ for idle in (self.command_idle, self.mcp_idle, self.compact_idle):
529
+ idle.set()
530
+ self.live_task: asyncio.Task | None = None
531
+ self.mcp_task: asyncio.Task | None = None
532
+ self.compact_task: asyncio.Task | None = None
533
+ # A browser sign-in: tracked like a turn so Ctrl+C cancels it.
534
+ self.login_task: asyncio.Task | None = None
535
+ # Commands queued but not started that hold the session busy.
536
+ self.pending_mcp = 0
537
+ self.pending_model_command = 0
538
+ # The running turn is being cancelled to make way for an "interrupt"
539
+ # message, so its failure must not clear the queue that message is in.
540
+ self.interrupt_pending = False
541
+ # The MCP server (or "defaults") being enabled or signed out of.
542
+ self.mcp_enabling: str | None = None
543
+ # The status row for that work, restored if a turn beside it ends first.
544
+ self.mcp_status = ""
545
+ # While MCP work is pending or running: how many steering messages sent
546
+ # before it may still reach the running turn. Later ones wait for it.
547
+ self.steering_before_mcp: int | None = None
548
+ # Servers marked enabled in mcp.json, enabled without a browser after a
549
+ # conversation starts (startup, /new, resume).
550
+ self.mcp_defaults_requested = False
551
+ # The watched job's output tail, as last pinned in the live panel.
552
+ self.pinned: CommandOutput | None = None
553
+ # The slash commands the session handles; the terminal runs the rest.
554
+ self.registry = CommandRegistry()
555
+ self.register_commands()
556
+
557
+ # --- Slash commands ---
558
+
559
+ def register_commands(self) -> None:
560
+ # Registration order is /help's and completion's order. The helpers are
561
+ # consecutive runs of that order, not `group=` buckets: the groups
562
+ # interleave, and sorting by them would reorder /help.
563
+ for command in (
564
+ *self._aside_commands(),
565
+ *self._model_commands(),
566
+ *self._session_commands(),
567
+ *self._resource_commands(),
568
+ ):
569
+ self.registry.register(command)
570
+
571
+ def _aside_commands(self) -> tuple[Command, ...]:
572
+ """The side question command."""
573
+ return (
574
+ Command(
575
+ "/btw",
576
+ "Ask a side question beside the running turn ($MODEL ... picks models, "
577
+ "+EFFORT the effort); "
578
+ "bare opens the answers",
579
+ self.aside,
580
+ aliases=("/side",),
581
+ free_arguments=True,
582
+ argument_completer=self.aside_completions,
583
+ group="Inspect",
584
+ ),
585
+ )
586
+
587
+ def _model_commands(self) -> tuple[Command, ...]:
588
+ """Commands for the model, its sign-ins, and the extensions it runs with."""
589
+ from pcode.models import LEGACY_ANTHROPIC_AUTH, login_sources
590
+
591
+ login_targets = (
592
+ "Anthropic, OpenAI Codex, or Claude Code (claude/meridian)"
593
+ if LEGACY_ANTHROPIC_AUTH
594
+ else "Claude Code or OpenAI Codex"
595
+ )
596
+ return (
597
+ Command(
598
+ "/model",
599
+ "Choose a model; keeps the conversation",
600
+ self.select_model,
601
+ group="Model",
602
+ ),
603
+ Command(
604
+ "/effort",
605
+ "Set reasoning effort: low / medium / high / xhigh / default",
606
+ self.effort,
607
+ ("low", "medium", "high", "xhigh", "default"),
608
+ group="Model",
609
+ ),
610
+ Command(
611
+ "/login",
612
+ f"Sign in to {login_targets} in a browser",
613
+ self.logins.login,
614
+ login_sources(),
615
+ group="Model",
616
+ ),
617
+ Command(
618
+ "/logout",
619
+ "Remove a stored login (anthropic or openai-codex)",
620
+ self.logins.logout,
621
+ ("anthropic", "openai-codex"),
622
+ group="Model",
623
+ ),
624
+ Command(
625
+ "/extensions",
626
+ "Extensions: list / on NAME / off NAME",
627
+ self.manage_extensions,
628
+ ("list", "on", "off"),
629
+ free_arguments=True,
630
+ argument_provider=self.extension_arguments,
631
+ group="Model",
632
+ ),
633
+ Command(
634
+ "/subagents",
635
+ "Models delegate_task lists for sub-agents: MODEL ... sets them, off clears, "
636
+ "bare lists",
637
+ self.subagents,
638
+ free_arguments=True,
639
+ argument_completer=self.model_list_completions,
640
+ group="Model",
641
+ ),
642
+ Command(
643
+ "/reload",
644
+ "Reload extensions; keeps the conversation",
645
+ self.reload,
646
+ group="Model",
647
+ ),
648
+ )
649
+
650
+ def _session_commands(self) -> tuple[Command, ...]:
651
+ """Commands for the conversation itself and its worktree."""
652
+ return (
653
+ Command(
654
+ "/new", "Start a new conversation; clears the screen", self.new, group="Session"
655
+ ),
656
+ Command(
657
+ "/rename",
658
+ "Name this conversation for /resume: NAME sets, - clears, bare shows",
659
+ self.rename,
660
+ free_arguments=True,
661
+ group="Session",
662
+ ),
663
+ Command(
664
+ "/worktree",
665
+ "This session's git worktree: status / merge / resolve / finish / remove"
666
+ " / list / clean",
667
+ self.worktree,
668
+ tuple(WORKTREE_ACTIONS),
669
+ group="Session",
670
+ argument_descriptions=WORKTREE_ACTIONS,
671
+ ),
672
+ Command(
673
+ "/compact",
674
+ "Summarize older context now; optional FOCUS steers the summary",
675
+ self.compact,
676
+ free_arguments=True,
677
+ group="Session",
678
+ ),
679
+ Command(
680
+ "/autocompact",
681
+ "Compact automatically near the context limit: on / off / TOKENS (e.g. 200k) "
682
+ "/ auto",
683
+ self.autocompact,
684
+ ("on", "off", "auto"),
685
+ free_arguments=True,
686
+ group="Session",
687
+ ),
688
+ Command(
689
+ "/resend",
690
+ "Ask the model again from the last checkpoint, without a new message",
691
+ self.resend,
692
+ group="Session",
693
+ ),
694
+ )
695
+
696
+ def _resource_commands(self) -> tuple[Command, ...]:
697
+ """Commands for what runs beside the model: MCP servers, shell jobs, plan usage."""
698
+ return (
699
+ Command(
700
+ "/mcp",
701
+ "Manage MCP servers: list / enable NAME [--save] / enable-all / "
702
+ "disable NAME [--save] / logout NAME",
703
+ self.mcp,
704
+ ("list", "enable", "enable-all", "disable"),
705
+ free_arguments=True,
706
+ argument_provider=self.mcp_arguments,
707
+ group="Model",
708
+ ),
709
+ Command(
710
+ "/jobs",
711
+ "Browse shell jobs and their output; stop ID / stop all / watch ID / unwatch",
712
+ self.jobs,
713
+ free_arguments=True,
714
+ argument_provider=self.jobs_arguments,
715
+ group="Session",
716
+ ),
717
+ Command(
718
+ "/usage",
719
+ "Plan limits and spend for the Claude Code and Codex logins",
720
+ self.usage,
721
+ group="Inspect",
722
+ ),
723
+ )
724
+
725
+ # --- What the footer, an attached terminal, and the host read ---
726
+
727
+ def context_label(self) -> str:
728
+ """The footer's context usage: tokens used of the window or autocompact cap."""
729
+ if not self.model or self.startup_pending or self.startup_error is not None:
730
+ return ""
731
+ from pcode.context_usage import context_label
732
+
733
+ resolved = getattr(getattr(self.runtime, "agent", None), "model", None)
734
+ history = getattr(self.runtime, "context_history", None)
735
+ if history is None:
736
+ history = getattr(self.runtime, "history", ())
737
+ try:
738
+ return context_label(resolved or self.model, history, compact_at=self.compact_cap())
739
+ except Exception: # noqa: BLE001 - a footer label must not break anything.
740
+ return ""
741
+
742
+ def session_state(self) -> dict:
743
+ """What a terminal attached to a host shows of the session, beyond the live panel."""
744
+ saved = getattr(self.runtime, "session", None)
745
+ commands = []
746
+ arguments = {}
747
+ for command in self.registry.commands:
748
+ commands.append(
749
+ {
750
+ "name": command.name,
751
+ "description": command.description,
752
+ "arguments": list(command.arguments),
753
+ "aliases": list(command.aliases),
754
+ "free_arguments": command.free_arguments,
755
+ "group": command.group,
756
+ "argument_descriptions": dict(command.argument_descriptions or {}),
757
+ # Which of the controller's completers the terminal runs locally.
758
+ "completer": getattr(command.argument_completer, "__name__", None),
759
+ # What a terminal from before `completer` reads to complete /btw.
760
+ "models": command.argument_completer == self.aside_completions,
761
+ }
762
+ )
763
+ if command.argument_provider is not None:
764
+ try:
765
+ arguments[command.name] = list(command.argument_provider())
766
+ except Exception: # noqa: BLE001 - completion must not break anything.
767
+ arguments[command.name] = []
768
+ return {
769
+ "model": self.model,
770
+ "pending_model": self.pending_model,
771
+ "effort": self.current_effort(),
772
+ "context": self.context_label(),
773
+ "workspace": str(self.workspace),
774
+ "session_id": saved.info.id if saved is not None else "",
775
+ "session_title": self.session_title(),
776
+ "session_directory": str(saved.directory) if saved is not None else "",
777
+ "saving": saved is not None
778
+ or getattr(self.runtime, "session_factory", None) is not None,
779
+ "startup_pending": self.startup_pending,
780
+ "startup_error": error_message(self.startup_error) if self.startup_error else "",
781
+ "commands": commands,
782
+ "arguments": arguments,
783
+ "skills": list(self.skill_command_names),
784
+ "jobs_directory": str(self._jobs_home() or ""),
785
+ # For `/btw $MODEL` completion, which cannot wait on a query per key.
786
+ # Not while starting: the first scan imports provider SDKs.
787
+ "models": [] if self.startup_pending else list(self.model_suggestions()),
788
+ }
789
+
790
+ def _jobs_home(self):
791
+ registry = getattr(self.runtime, "jobs", None)
792
+ home = getattr(registry, "home", None)
793
+ return home() if callable(home) else None
794
+
795
+ def set_thinking(self, mode: str) -> None:
796
+ """Ask the provider for the readable thinking `mode` needs from the next request."""
797
+ if mode not in THINKING_MODES:
798
+ return # From a mismatched terminal over the host socket; keep the current mode.
799
+ self.activity.thinking_mode = mode
800
+ agent = getattr(self.runtime, "agent", None)
801
+ if agent is not None and self.model:
802
+ apply_thinking(agent, self.model, mode)
803
+
804
+ async def query(self, name: str, *args):
805
+ """One of QUERIES, for a terminal that cannot call the controller directly."""
806
+ if name not in QUERIES:
807
+ raise ValueError(f"Unknown query {name!r}.")
808
+ result = getattr(self, name)(*args)
809
+ if inspect.isawaitable(result):
810
+ result = await result
811
+ return result
812
+
813
+ # --- Busy state ---
814
+
815
+ def command_taken(self, name: str) -> bool:
816
+ return name in TERMINAL_COMMANDS or self.registry.find(name) is not None
817
+
818
+ def tasks(self) -> list[asyncio.Task]:
819
+ work = (self.live_task, self.mcp_task, self.compact_task, self.login_task)
820
+ return [task for task in work if task]
821
+
822
+ def working(self) -> bool:
823
+ """A turn, MCP work, or a history rewrite is running."""
824
+ return any(not task.done() for task in self.tasks())
825
+
826
+ def turn_running(self) -> bool:
827
+ return self.live_task is not None and not self.live_task.done()
828
+
829
+ async def idle(self) -> None:
830
+ """Wait until nothing holds queued prompts back."""
831
+ await self.command_idle.wait()
832
+ await self.mcp_idle.wait()
833
+ await self.compact_idle.wait()
834
+
835
+ @property
836
+ def commands_pending(self) -> bool:
837
+ return bool(self.pending_mcp or self.pending_model_command)
838
+
839
+ def refresh_busy(self) -> None:
840
+ """Busy while anything is queued, pending, or running."""
841
+ self.activity.busy = bool(
842
+ self.activity.queued_prompts or self.commands_pending or self.working()
843
+ )
844
+
845
+ # --- Shell waits ---
846
+
847
+ def set_cancel_policy(self, policy: str) -> None:
848
+ """Say what an abandoned shell wait should do to its command.
849
+
850
+ Set before cancelling, because by the time the tool call sees
851
+ `CancelledError` there is nothing left to tell it apart from any other
852
+ cancellation. Reset to the safe default once the turn is over.
853
+ """
854
+ registry = getattr(self.runtime, "jobs", None)
855
+ if registry is not None:
856
+ registry.cancel_policy = policy
857
+
858
+ def release_shell_waits(self) -> None:
859
+ """Hand any foreground shell wait back to the model as a job handle.
860
+
861
+ Steering is delivered at the next model request, and a wait on a slow
862
+ command is what stands between now and that request. Ending the wait
863
+ gets the message there promptly; the command itself is untouched.
864
+ """
865
+ registry = getattr(self.runtime, "jobs", None)
866
+ if registry is not None:
867
+ registry.release_waits()
868
+
869
+ # --- Jobs ---
870
+
871
+ def report_finished_jobs(self, job_id: str | None = None) -> list:
872
+ """Announce job exits in scrollback, each one once. Returns those announced."""
873
+ registry = getattr(self.runtime, "jobs", None)
874
+ if registry is None:
875
+ return []
876
+ from pcode.shell import REDUCED_SHELL_OUTPUT, result_projection
877
+
878
+ finished = registry.take_announcements("ui", job_id)
879
+ for job in finished:
880
+ output, truncated = registry.read_output(job)
881
+ if truncated:
882
+ output = REDUCED_SHELL_OUTPUT + "\n" + output
883
+ result = output + f"\n[{job.id} · {job.outcome()} · {format_duration(job.elapsed)}]"
884
+ event = JobFinished(
885
+ "shell",
886
+ f"{command_text(job.label())} → {job.id} · {job.outcome()}",
887
+ failed=job.stopped or job.exit_code != 0,
888
+ elapsed_seconds=job.elapsed,
889
+ command=command_text(job.command),
890
+ result=command_text(result_projection(result)),
891
+ purpose=command_text(job.purpose),
892
+ )
893
+ saved = getattr(self.runtime, "session", None)
894
+ if saved is not None:
895
+ saved.event(event, run_id=saved.tree.active or "")
896
+ self.view.tool_result(event)
897
+ return finished
898
+
899
+ def report_delivered_job(self, job_id: str) -> None:
900
+ """Write a job's exit where the call that collected it settled.
901
+
902
+ The call's own row is left out of scrollback because the exit notice
903
+ says the same thing. Holding that notice until idle would print it
904
+ after the final answer, long after the model acted on the result.
905
+ A job that is still running, or was already reported, prints nothing.
906
+ """
907
+ registry = getattr(self.runtime, "jobs", None)
908
+ job = registry.get(job_id) if registry is not None else None
909
+ if job is None or job.running or "ui" in job.announced:
910
+ return
911
+ # A suppressed row does not commit streamed prose; the notice must not
912
+ # land ahead of text the model wrote before making the call.
913
+ self.view.finish_text()
914
+ self.report_finished_jobs(job_id)
915
+
916
+ def wake_prompt(self) -> str | None:
917
+ """The turn a finished job starts on its own, or None when nothing should.
918
+
919
+ Only a job the model launched and expects to hear about wakes it: one
920
+ it backgrounded, or was handed a handle for when a wait ended early.
921
+ An adopted job belongs to a model that is gone. The text is exactly
922
+ the notice the model would have received at its next request, so
923
+ waking costs a request, never a different conversation. Called while
924
+ idle: a job that ended mid-turn after the last request is included,
925
+ because nothing else is going to deliver it.
926
+ """
927
+ registry = getattr(self.runtime, "jobs", None)
928
+ if registry is None or not self.model:
929
+ return None
930
+ wakeable = [
931
+ job
932
+ for job in registry.jobs.values()
933
+ if not job.running
934
+ # A stop is the user's or the model's own doing, not news to act on.
935
+ and not job.stopped
936
+ and not job.adopted
937
+ and "model" not in job.announced
938
+ and registry.announceable(job)
939
+ ]
940
+ if not wakeable:
941
+ return None
942
+ if load_preferences().get("job_wake", SETTINGS["job_wake"].default) != "on":
943
+ return None
944
+ from pcode.job_notices import notice_for
945
+
946
+ for job in wakeable:
947
+ job.announced.add("model")
948
+ return "\n\n".join(notice_for(registry, job) for job in wakeable)
949
+
950
+ # --- Sending ---
951
+
952
+ def submit(self, text: str, mode: str, *, owner: MessageOwner | None = None) -> None:
953
+ """Queue a message for the model, sent the way `mode` says.
954
+
955
+ `steering` joins the running turn at its next request, `queue` waits
956
+ for it to end, `interrupt` cancels it, and `shell` runs a `!command`
957
+ in turn, never as steering: its result rides the next request rather
958
+ than being spliced into a running one. `owner` hears when it starts,
959
+ or that it was dropped; a steering message never starts a turn.
960
+ """
961
+ if mode == "steering" and prompt_target(text)[0] is not None:
962
+ # A turn cannot change models mid-request: a prompt naming one gets
963
+ # its own turn on it, never spliced into one that starts first.
964
+ mode = "queue"
965
+ if mode == "interrupt" and self.turn_running():
966
+ self.clear_queue()
967
+ self.interrupt_pending = True
968
+ # The user is redirecting the model, not cancelling its work: an
969
+ # in-flight shell wait is abandoned, and its command keeps running
970
+ # under its job id.
971
+ self.set_cancel_policy("detach")
972
+ if not self.live_task.cancelling():
973
+ self.live_task.cancel()
974
+ self.prompts.put(text, mode, owner=owner)
975
+ # Set immediately so Enter + Ctrl+C in one input batch cancels the
976
+ # pending request rather than clearing the user's draft.
977
+ self.activity.busy = True
978
+ if mode == "steering" and self.turn_running():
979
+ # Queued above, released here: the wait returns its handle and the
980
+ # next model request carries this message.
981
+ self.release_shell_waits()
982
+
983
+ def command(self, text: str, tag: object = None) -> None:
984
+ """Queue a slash command; `tag` travels with it back to the terminal."""
985
+ idle = not self.activity.busy and not self.activity.queued_prompts
986
+ self.commands.put_nowait((self.prompts.generation, text, idle, tag))
987
+ self.command_idle.clear()
988
+ if text.split()[0] in MODEL_COMMANDS:
989
+ self.pending_model_command += 1
990
+ self.activity.busy = True
991
+ if mcp_enable(text):
992
+ self.hold_later_steering()
993
+ self.pending_mcp += 1
994
+ # Enter + Ctrl+C in one input batch must cancel activation before
995
+ # its command worker has had a chance to start OAuth.
996
+ self.activity.busy = True
997
+
998
+ def command_started(self, text: str) -> None:
999
+ """A command from `command` is being handled: it no longer holds the session busy."""
1000
+ if text.split()[0] in MODEL_COMMANDS:
1001
+ self.pending_model_command -= 1
1002
+ elif mcp_enable(text):
1003
+ self.pending_mcp -= 1
1004
+ else:
1005
+ return
1006
+ self.refresh_busy()
1007
+
1008
+ def command_finished(self) -> None:
1009
+ if self.commands_pending:
1010
+ self.activity.busy = True
1011
+ if self.commands.empty() and not self.startup_commands:
1012
+ self.command_idle.set()
1013
+
1014
+ # --- Stopping ---
1015
+
1016
+ def clear_queue(self) -> None:
1017
+ """Drop every queued message and pending command, saying what went."""
1018
+ self.startup_commands.clear()
1019
+ if self.commands.empty():
1020
+ self.command_idle.set()
1021
+ if self.pending_mcp:
1022
+ self.view.warning("Pending MCP enable command cancelled.")
1023
+ self.pending_mcp = 0
1024
+ if self.pending_model_command:
1025
+ self.view.warning("Pending model command cancelled.")
1026
+ self.pending_model_command = 0
1027
+ if count := self.prompts.clear():
1028
+ self.view.note(f"Cleared {count} queued message(s).")
1029
+ if self.steering_before_mcp is not None:
1030
+ self.steering_before_mcp = 0
1031
+
1032
+ def cancel(self) -> None:
1033
+ """Ctrl+C: clear the queue and stop what is running."""
1034
+ self.interrupt_pending = False
1035
+ self.clear_queue()
1036
+ active = [task for task in self.tasks() if not task.done()]
1037
+ # Ctrl+C means "stop working", so a command the turn is waiting on is
1038
+ # stopped with it. A typed follow-up takes the other branch and only
1039
+ # abandons the wait. Either way a job the model explicitly backgrounded
1040
+ # keeps running: nothing is waiting on it to abandon.
1041
+ self.set_cancel_policy("stop")
1042
+ if active:
1043
+ # Repeated interrupts must not interrupt persistence/auth cleanup.
1044
+ # Side questions are deliberately parallel: an interrupt aimed at
1045
+ # the turn must not also throw away work the turn is not doing.
1046
+ for task in active:
1047
+ if not task.cancelling():
1048
+ task.cancel()
1049
+ elif not self.cancel_asides():
1050
+ self.activity.busy = False
1051
+ # A command that ended the session cancels nothing the user ran.
1052
+ if self.running:
1053
+ self.view.cancelled()
1054
+
1055
+ def cancel_asides(self) -> int:
1056
+ """Stop the running side questions alone; a running turn carries on."""
1057
+ if stopped := self.asides.cancel():
1058
+ self.view.note(f"Stopped {stopped} side question(s).")
1059
+ return stopped
1060
+
1061
+ def _steering_limit(self) -> int | None:
1062
+ # Like queued prompts, steering sent after MCP work waits for it, so
1063
+ # "/mcp enable x" then "use x" reaches the model with x's tools.
1064
+ if self.mcp_task is None and not self.pending_mcp:
1065
+ return None
1066
+ return self.steering_before_mcp
1067
+
1068
+ def has_steering(self) -> bool:
1069
+ """Peek at deliverable input without consuming, displaying, or journaling it."""
1070
+ limit = self._steering_limit()
1071
+ return self.prompts.steering() > 0 and (limit is None or limit > 0)
1072
+
1073
+ def take_steering(self) -> list[str]:
1074
+ """The runtime's hook: steering messages for the next model request."""
1075
+ limit = self._steering_limit()
1076
+ messages = self.prompts.take_steering(limit)
1077
+ self.steering_before_mcp = None if limit is None else limit - len(messages)
1078
+ for text in messages:
1079
+ self.activity.start_prompt(text)
1080
+ self.view.user(text)
1081
+ if messages:
1082
+ self.view.redraw()
1083
+ return messages
1084
+
1085
+ def turn_ended(self, success: bool) -> None:
1086
+ """Settle the queue after a turn: a failure drops what was waiting behind it."""
1087
+ if not success and not self.interrupt_pending:
1088
+ self.clear_queue()
1089
+ self.interrupt_pending = False
1090
+ self.refresh_busy()
1091
+
1092
+ async def consume_commands(self) -> None:
1093
+ """Run slash commands one at a time, in the order they were sent."""
1094
+ while self.running:
1095
+ generation, text, submitted_idle, tag = await self.commands.get()
1096
+ name = text.split(maxsplit=1)[0]
1097
+ try:
1098
+ if name not in FRONTEND_COMMANDS and not self.ready.is_set():
1099
+ # Keep consuming frontend-only commands while backend
1100
+ # commands wait, preserving their order for readiness.
1101
+ self.startup_commands.append((generation, text, submitted_idle, tag))
1102
+ continue
1103
+ if name in FRONTEND_COMMANDS or self.dispatchable(generation, name):
1104
+ self.command_started(text)
1105
+ await self.dispatch(text, idle=submitted_idle, tag=tag)
1106
+ except Exception as error:
1107
+ self.command_failed(name, error)
1108
+ finally:
1109
+ self.command_finished()
1110
+ # Every command taken off the queue reports here, dropped or not:
1111
+ # a host's `run` caller waits for exactly this.
1112
+ await self.view.after_command(tag)
1113
+
1114
+ def dispatchable(self, generation: int, name: str) -> bool:
1115
+ """Whether a session command dequeued now should run; says why when it should not."""
1116
+ if generation != self.prompts.generation:
1117
+ return False # Ctrl+C cleared the queue it was sent in.
1118
+ if self.startup_error is not None and name not in RECOVERY_COMMANDS:
1119
+ self.view.warning(STARTUP_FAILED)
1120
+ return False
1121
+ return True
1122
+
1123
+ async def dispatch(self, text: str, *, idle: bool, tag: object = None) -> None:
1124
+ """Run one slash command: the session's own here, anything else in the terminal.
1125
+
1126
+ `idle` says whether the session was idle when it was sent: /compact or
1127
+ /resend sent then runs ahead of prompts queued behind it since.
1128
+ """
1129
+ name = text.split(maxsplit=1)[0]
1130
+ before_queue = name in MODEL_COMMANDS and idle and not self.working()
1131
+ if (
1132
+ name in IDLE_COMMANDS
1133
+ and (self.activity.busy or self.activity.queued)
1134
+ and not before_queue
1135
+ ):
1136
+ self.view.warning(
1137
+ f"{name} is unavailable while working. "
1138
+ "Cancel with Ctrl+C or wait for the run to finish, then retry."
1139
+ )
1140
+ elif self.registry.find(name) is None:
1141
+ await self.view.run_command(text, idle=idle, tag=tag)
1142
+ else:
1143
+ # The terminal needs the tag for any popup the command opens.
1144
+ self.view.command_started(tag)
1145
+ try:
1146
+ await self.run_command(text, before_queue=before_queue)
1147
+ finally:
1148
+ self.view.command_finished()
1149
+ await self.follow_up()
1150
+ self.view.session_changed()
1151
+
1152
+ async def follow_up(self) -> None:
1153
+ """Do what the command just handled asked for once its handler returned."""
1154
+ if self.job_requested is not None:
1155
+ await self.perform_job()
1156
+ if self.skill_requested is not None:
1157
+ if self.skill_mcp_requested is not None:
1158
+ skill, names = self.skill_mcp_requested
1159
+ self.skill_mcp_requested = None
1160
+ # Started before the prompt is queued: the consumer then waits
1161
+ # for it on mcp_idle.
1162
+ self.start_skill_mcp(skill, names)
1163
+ prompt, self.skill_requested = self.skill_requested, None
1164
+ # Queued like a typed message so send mode, steering, and
1165
+ # cancellation keep their usual meaning.
1166
+ self.prompts.put(prompt, load_preferences().get("send_mode", "steering"))
1167
+ self.activity.busy = True
1168
+ if self.mcp_defaults_requested:
1169
+ self.start_mcp_defaults()
1170
+ if not self.running:
1171
+ self.cancel()
1172
+ if active := self.tasks():
1173
+ await asyncio.gather(*active, return_exceptions=True)
1174
+ if self.reload_requested:
1175
+ await self.reload_extensions()
1176
+ elif self.reload_pending and not (self.activity.busy or self.activity.queued_prompts):
1177
+ # Deferred by busy work that was not a turn, which `consume` would wait out.
1178
+ await self.apply_pending_reload()
1179
+ if self.login_requested:
1180
+ await self.perform_login()
1181
+ if self.logout_requested:
1182
+ await self.logins.perform_logout()
1183
+
1184
+ async def perform_login(self) -> None:
1185
+ """Run the requested sign-in as cancellable work, so Ctrl+C abandons it.
1186
+
1187
+ Waiting on a browser holds the session busy, which is what routes
1188
+ Ctrl+C to `cancel` rather than to the draft. A cancelled sign-in ends
1189
+ here; only the command loop's own cancellation propagates.
1190
+ """
1191
+ # Taken here, not in the task: a Ctrl+C landing before the task's first
1192
+ # step must still consume the request, or the next command would rerun it.
1193
+ source, self.login_requested = self.login_requested, None
1194
+ task = asyncio.create_task(self.logins.perform_login(source))
1195
+ self.login_task = task
1196
+ self.activity.busy = True
1197
+ try:
1198
+ await asyncio.wait({task})
1199
+ except asyncio.CancelledError:
1200
+ task.cancel()
1201
+ raise
1202
+ finally:
1203
+ self.login_task = None
1204
+ self.refresh_busy()
1205
+ if not task.cancelled():
1206
+ task.result()
1207
+
1208
+ async def run_command(self, text: str, *, before_queue: bool = False) -> None:
1209
+ """Run one of the session's own commands; a handler may be sync or async."""
1210
+ parts = text.strip().split(maxsplit=1)
1211
+ command = self.registry.find(parts[0])
1212
+ argument = parts[1].strip() if len(parts) > 1 else ""
1213
+ try:
1214
+ if argument and not command.free_arguments and argument not in command.arguments:
1215
+ usage = "|".join(command.arguments)
1216
+ raise ValueError(f"Usage: {command.name}" + (f" [{usage}]" if usage else ""))
1217
+ if before_queue:
1218
+ result = command.handler(argument, before_queue=True)
1219
+ else:
1220
+ result = command.handler(argument)
1221
+ if inspect.isawaitable(result):
1222
+ await result
1223
+ except ValueError as error:
1224
+ self.view.error(str(error))
1225
+
1226
+ # --- Turns ---
1227
+
1228
+ async def consume(self) -> None:
1229
+ """Run queued messages one at a time, each once nothing holds the queue."""
1230
+ await self.ready.wait()
1231
+ while self.running:
1232
+ await self.idle()
1233
+ item = await self.prompts.get()
1234
+ _generation, text, mode, owner = item
1235
+ if self.startup_error is not None:
1236
+ self.clear_queue()
1237
+ if owner is not None:
1238
+ owner.dropped()
1239
+ self.activity.busy = False
1240
+ self.view.warning(STARTUP_FAILED)
1241
+ continue
1242
+ await self.idle()
1243
+ if not self.running:
1244
+ return
1245
+ if not self.prompts.current(item):
1246
+ # Cancelled while waiting for a command/modal. Fetched before
1247
+ # the clear, so the clear could not say so to its owner.
1248
+ if owner is not None:
1249
+ owner.dropped()
1250
+ continue
1251
+ self.prompts.taken()
1252
+ # A model chosen mid-run takes effect here, before the request
1253
+ # that follows it is sent.
1254
+ if self.pending_model is not None:
1255
+ await self.apply_pending_model()
1256
+ await self.apply_pending_reload()
1257
+ if owner is not None:
1258
+ # Everything shown from here to `after_turn` is this message's.
1259
+ owner.started()
1260
+ success = True
1261
+ try:
1262
+ resend = mode == "resend"
1263
+ if mode == "shell":
1264
+ self.live_task = asyncio.create_task(self.run_shell(text))
1265
+ try:
1266
+ success = await self.live_task
1267
+ except asyncio.CancelledError:
1268
+ if self.closing():
1269
+ return
1270
+ success = False
1271
+ elif not (resend or self.model):
1272
+ self.view.preview_reply(text)
1273
+ else:
1274
+ wake = mode == "wake"
1275
+ if wake:
1276
+ label, detail = wake_row(text)
1277
+ self.activity.start_prompt(label, kind="system", detail=detail)
1278
+ else:
1279
+ self.activity.start_prompt(text)
1280
+ self.runtime.take_steering = self.take_steering
1281
+ self.runtime.has_steering = self.has_steering
1282
+ self.live_task = asyncio.create_task(
1283
+ self.run_turn(
1284
+ text,
1285
+ resend=resend,
1286
+ wake=wake,
1287
+ )
1288
+ )
1289
+ try:
1290
+ success = await self.live_task
1291
+ except asyncio.CancelledError:
1292
+ # Cancellation before run_turn's first instruction.
1293
+ if self.closing():
1294
+ return
1295
+ success = False
1296
+ self.activity.finish_prompt("cancelled")
1297
+ self.activity.turn_outcome = "cancelled"
1298
+ self.view.cancelled()
1299
+ except Exception as error:
1300
+ self.view.error(error_message(error), title="Agent failed")
1301
+ self.activity.turn_outcome = "failed"
1302
+ success = False
1303
+ finally:
1304
+ self.live_task = None
1305
+ if self.closing():
1306
+ return
1307
+ self.turn_ended(success)
1308
+ # Adopt it as soon as the turn ends so the footer and /status
1309
+ # agree with what the next request will use.
1310
+ if self.pending_model is not None:
1311
+ await self.apply_pending_model()
1312
+ await self.apply_pending_reload()
1313
+ self.view.session_changed()
1314
+ await self.view.after_turn()
1315
+
1316
+ async def stream_on(self, target: SideTarget, prompt: str):
1317
+ """Stream a turn of `prompt` on `target` alone, from `$MODEL` or `+EFFORT`.
1318
+
1319
+ The conversation keeps its own model: only this turn's requests go to
1320
+ `target`, and the next prompt is back where /model left it. Resolving
1321
+ happens here, inside the turn, so a bad name fails it like any other
1322
+ turn failure.
1323
+ """
1324
+ if not prompt:
1325
+ raise ValueError("Nothing to send after the model; add the prompt after it.")
1326
+ self.check_effort(target)
1327
+ options = await self.target_options(target)
1328
+ if "model" in options:
1329
+ self.view.note(
1330
+ f"This prompt runs on {target.model}"
1331
+ + (f" at {target.effort} effort" if target.effort else "")
1332
+ + f"; the conversation stays on {self.model}. Another model starts "
1333
+ "without the conversation's prompt cache, so it pays for the whole prompt."
1334
+ )
1335
+ elif target.effort:
1336
+ self.view.note(f"This prompt runs at {target.effort} effort.")
1337
+ async with aclosing(self.runtime.stream(prompt, **options)) as stream:
1338
+ async for event in stream:
1339
+ yield event
1340
+
1341
+ async def run_turn(
1342
+ self,
1343
+ text: str,
1344
+ *,
1345
+ resend: bool = False,
1346
+ wake: bool = False,
1347
+ ) -> bool:
1348
+ """Run a turn: `resend` asks again from the last checkpoint, and `wake` is one
1349
+ a finished job started, shown as a badge rather than as typed text."""
1350
+ runtime = self.runtime
1351
+ self.view.turn_started(text, echo=not wake)
1352
+ if wake:
1353
+ # Scrollback already carries the job's summary line; the prompt is
1354
+ # pcode's, so it is labelled as system work rather than quoted.
1355
+ label, detail = wake_row(text)
1356
+ self.activity.start_prompt(label, kind="system", detail=detail)
1357
+ else:
1358
+ self.activity.start_prompt(text)
1359
+ self.activity.status = "Waiting for model…"
1360
+
1361
+ def compaction_notice(text):
1362
+ # The sentence goes to scrollback; the status row keeps to a phase
1363
+ # (see `status_parts`), and the model has the turn once it is done.
1364
+ starting = text.startswith("Compacting")
1365
+ self.activity.status = "Compacting context…" if starting else "Waiting for model…"
1366
+ self.view.note(text)
1367
+
1368
+ runtime.compaction_notice = compaction_notice
1369
+
1370
+ def retry_notice(text):
1371
+ # Separate abandoned partial text/thinking from the next attempt.
1372
+ self.activity.status = f"Retrying · {text}"
1373
+ self.view.turn_retry(text)
1374
+
1375
+ if hasattr(runtime, "retry_notice"):
1376
+ runtime.retry_notice = retry_notice
1377
+ if hasattr(runtime, "warning_notice"):
1378
+ runtime.warning_notice = self.view.warning
1379
+ failure = None
1380
+ cancelled = False
1381
+ chosen, prompt = (None, text) if resend or wake else prompt_target(text)
1382
+ source = (
1383
+ self.stream_on(chosen, prompt)
1384
+ if chosen is not None
1385
+ else runtime.stream(None if resend else text)
1386
+ )
1387
+ # A turn creates the session's journal on first use. Attached terminals
1388
+ # read it from disk (/tools, /diffs), so they need its path mid-turn,
1389
+ # not only once the turn ends.
1390
+ session = getattr(runtime, "session", None)
1391
+ journaled = session is not None
1392
+ # The title is asked for beside the first turn rather than after it: the
1393
+ # prompt alone names it as well as prompt and reply do, and a long turn
1394
+ # left the session untitled for minutes. A session that existed already
1395
+ # (resumed, or after a first turn that failed) is named from its first
1396
+ # prompt on disk.
1397
+ first_prompt = prompt if session is None and not (resend or wake) else None
1398
+ if journaled:
1399
+ self.start_naming(first_prompt)
1400
+ try:
1401
+ async with aclosing(source) as stream:
1402
+ async for event in stream:
1403
+ if not journaled and getattr(runtime, "session", None) is not None:
1404
+ journaled = True
1405
+ self.view.session_changed()
1406
+ self.start_naming(first_prompt)
1407
+ self.view.turn_event(event)
1408
+ if (job_id := delivered_job(event)) is not None:
1409
+ self.report_delivered_job(job_id)
1410
+ except asyncio.CancelledError:
1411
+ cancelled = True
1412
+ except Exception as error:
1413
+ failure = error
1414
+ finally:
1415
+ # A turn that ended before its first event opened the session all the same.
1416
+ if getattr(runtime, "session", None) is not None:
1417
+ self.start_naming(first_prompt)
1418
+ self.view.turn_ended()
1419
+ # MCP work started beside the turn may outlast it.
1420
+ self.activity.status = self.mcp_status if self.mcp_task is not None else ""
1421
+ # Abandoning a wait is the exception, not the rule: restore the safe
1422
+ # default so the next Ctrl+C-free cancellation cannot kill a command.
1423
+ self.set_cancel_policy("detach")
1424
+ outcome = "cancelled" if cancelled else "failed" if failure else "done"
1425
+ self.activity.finish_prompt(outcome)
1426
+ self.activity.turn_outcome = outcome
1427
+ self.report_finished_jobs()
1428
+ self.view.redraw()
1429
+ if cancelled:
1430
+ self.view.cancelled()
1431
+ # A cancelled turn used to take its commands with it. Say plainly
1432
+ # what survived, so "still running" is never a surprise.
1433
+ registry = getattr(runtime, "jobs", None)
1434
+ running = registry.running() if registry is not None else []
1435
+ if running:
1436
+ self.view.note(
1437
+ f"{len(running)} command(s) still running: "
1438
+ + ", ".join(f"[{job.id}] {job.label()}" for job in running[:3])
1439
+ + ". Use /jobs to list or stop them."
1440
+ )
1441
+ elif failure:
1442
+ from pcode.diagnostics import stale_install
1443
+
1444
+ self.view.error(error_message(failure), title="Agent failed")
1445
+ if hint := stale_install():
1446
+ self.view.warning(hint)
1447
+ if (cancelled or failure) and runtime.session:
1448
+ # A cancel is something the user asked for, so it has nothing worth
1449
+ # pointing at. Name the traceback file rather than the directory it
1450
+ # sits in: the frames are the point of looking.
1451
+ if failure:
1452
+ directory = runtime.session.directory
1453
+ errors = directory / "errors.log"
1454
+ target = errors if errors.exists() else directory
1455
+ self.view.note(f"Session and diagnostics: {target}")
1456
+ if runtime.recovery_blocked:
1457
+ self.view.warning(runtime.recovery_blocked)
1458
+ return not (cancelled or failure)
1459
+
1460
+ async def run_shell(self, text: str) -> bool:
1461
+ """Run a `!command` the user typed and hand its result to the runtime.
1462
+
1463
+ Output streams into the live command panel while it runs and is
1464
+ mirrored to scrollback when it ends. The model sees it on the next
1465
+ prompt, as a `shell` tool call, so ask a follow-up to discuss it.
1466
+ """
1467
+ # Lazy: pcode.shell pulls in the agent stack, which startup avoids.
1468
+ from pcode.shell import preview_text
1469
+
1470
+ command = shell_command(text)
1471
+ assert command is not None
1472
+ call_id = f"shell_mode_{id(self):x}"
1473
+ cwd, env = (
1474
+ self.runtime.shell_environment()
1475
+ if hasattr(self.runtime, "shell_environment")
1476
+ else (self.workspace, None)
1477
+ )
1478
+ self.view.user(text)
1479
+ self.activity.start_prompt(text)
1480
+ self.activity.user_command = True
1481
+ self.activity.status = "Running command…"
1482
+ buffered = ""
1483
+
1484
+ def show(chunk: str) -> None:
1485
+ nonlocal buffered
1486
+ buffered += chunk
1487
+ self.view.turn_event(CommandOutput(call_id, command, preview_text(buffered)))
1488
+
1489
+ run = None
1490
+ try:
1491
+ run = await execute(command, cwd=cwd, env=env, on_output=show)
1492
+ except asyncio.CancelledError:
1493
+ pass
1494
+ except OSError as error:
1495
+ self.view.error(str(error), title="Command failed to start")
1496
+ finally:
1497
+ self.view.drop_output(call_id)
1498
+ self.activity.user_command = False
1499
+ self.activity.status = ""
1500
+ if run is None:
1501
+ self.activity.finish_prompt("cancelled")
1502
+ self.view.warning("Command cancelled; the model was not told about it.")
1503
+ self.view.redraw()
1504
+ return False
1505
+ self.view.shell_result(
1506
+ command, run.output, failed=run.failed, elapsed_seconds=run.elapsed_seconds
1507
+ )
1508
+ if hasattr(self.runtime, "record_shell"):
1509
+ visible = await self.runtime.record_shell(run)
1510
+ reduced = not isinstance(visible, str) or visible != run.tool_result()
1511
+ self.view.note(
1512
+ "The model sees this command and its "
1513
+ + ("reduced output" if reduced else "output")
1514
+ + " with your next message."
1515
+ )
1516
+ self.activity.finish_prompt("done")
1517
+ self.view.redraw()
1518
+ return True
1519
+
1520
+ # --- Work beside the turn loop that holds queued prompts back ---
1521
+
1522
+ def start_mcp_task(self, name, coroutine, *, status: str, cancelled: str) -> None:
1523
+ """Run MCP work outside the model loop; queued prompts wait for it.
1524
+
1525
+ A running turn carries on beside it and sees the change from its next
1526
+ model request. It keeps its status line and, should this fail, the
1527
+ messages queued behind it, which were not waiting on this change.
1528
+ """
1529
+ self.hold_later_steering()
1530
+ self.mcp_idle.clear()
1531
+ self.mcp_enabling = name
1532
+ self.mcp_status = status
1533
+ self.activity.busy = True
1534
+ beside_turn = self.turn_running()
1535
+ if not beside_turn:
1536
+ self.activity.status = status
1537
+
1538
+ def finished(task):
1539
+ success = False
1540
+ try:
1541
+ task.result()
1542
+ success = True
1543
+ except asyncio.CancelledError:
1544
+ self.view.warning(cancelled)
1545
+ except Exception as error:
1546
+ self.report_mcp_error(name, error)
1547
+ finally:
1548
+ if not success and not beside_turn:
1549
+ self.clear_queue()
1550
+ self.mcp_task = None
1551
+ self.refresh_busy()
1552
+ if not self.turn_running():
1553
+ self.activity.status = ""
1554
+ self.mcp_enabling = None
1555
+ self.mcp_status = ""
1556
+ self.steering_before_mcp = None
1557
+ self.mcp_idle.set()
1558
+ self.view.redraw()
1559
+ self.view.session_changed()
1560
+
1561
+ self.mcp_task = asyncio.create_task(coroutine)
1562
+ # A done callback also handles cancellation before the coroutine starts.
1563
+ self.mcp_task.add_done_callback(finished)
1564
+
1565
+ def hold_later_steering(self) -> None:
1566
+ if self.steering_before_mcp is None:
1567
+ self.steering_before_mcp = self.prompts.steering()
1568
+
1569
+ def cancels(self) -> str:
1570
+ """How to abandon MCP work, which Ctrl+C stops along with any turn."""
1571
+ return (
1572
+ "Ctrl+C cancels it and the running turn." if self.turn_running() else "Ctrl+C cancels."
1573
+ )
1574
+
1575
+ def start_mcp_enable(self, name: str, *, save: bool = False) -> None:
1576
+ self.view.note(
1577
+ f"Enabling MCP '{name}'. OAuth sign-in happens now if needed; "
1578
+ f"{self.cancels()} No model request is made."
1579
+ )
1580
+ self.start_mcp_task(
1581
+ name,
1582
+ self.enable_mcp(name, save=save),
1583
+ status=f"Enabling MCP '{name}' — complete browser sign-in if prompted…",
1584
+ cancelled=f"MCP '{name}' sign-in cancelled; server remains off.",
1585
+ )
1586
+
1587
+ def start_mcp_enable_all(self) -> None:
1588
+ """Enable every configured server not already on."""
1589
+ from pcode.mcp import configured_servers
1590
+
1591
+ names = [
1592
+ name for name in sorted(configured_servers()) if name not in self.runtime.mcp.enabled
1593
+ ]
1594
+ if not names:
1595
+ self.view.note("Every configured MCP server is already enabled.")
1596
+ return
1597
+ self.view.note(
1598
+ f"Enabling MCP {', '.join(names)}. OAuth sign-in happens now if needed; "
1599
+ f"{self.cancels()} No model request is made."
1600
+ )
1601
+ self.start_mcp_task(
1602
+ ", ".join(names),
1603
+ self.enable_mcp_each(names),
1604
+ status="Enabling all MCP servers — complete browser sign-in if prompted…",
1605
+ cancelled="MCP enable cancelled; servers not yet enabled remain off.",
1606
+ )
1607
+
1608
+ def start_skill_mcp(self, skill: str, names) -> None:
1609
+ """Enable what `skill` declares before its prompt, which waits on MCP work."""
1610
+ from pcode.mcp import config_path, configured_servers
1611
+
1612
+ state = getattr(self.runtime, "mcp", None)
1613
+ if state is None:
1614
+ return
1615
+ try:
1616
+ configured = configured_servers()
1617
+ except ValueError as error:
1618
+ self.view.error(str(error))
1619
+ return
1620
+ if unknown := [name for name in names if name not in configured]:
1621
+ self.view.warning(
1622
+ f"The {skill} skill asks for MCP {', '.join(unknown)}, "
1623
+ f"not configured in {config_path()}."
1624
+ )
1625
+ wanted = [name for name in names if name in configured and name not in state.enabled]
1626
+ if not wanted:
1627
+ return
1628
+ # Same rule as /mcp enable: one MCP change at a time.
1629
+ if self.mcp_task is not None:
1630
+ listed = " ".join(f"`/mcp enable {name}`" for name in wanted)
1631
+ self.view.warning(
1632
+ f"The {skill} skill asks for MCP {', '.join(wanted)}, which cannot be "
1633
+ f"enabled while another MCP change is in progress. Run {listed} after it."
1634
+ )
1635
+ return
1636
+ self.view.note(
1637
+ f"Enabling MCP {', '.join(wanted)} for the {skill} skill. "
1638
+ f"OAuth sign-in happens now if needed; {self.cancels()}"
1639
+ )
1640
+ self.start_mcp_task(
1641
+ ", ".join(wanted),
1642
+ self.enable_mcp_each(wanted, reason=f" for the {skill} skill"),
1643
+ status=f"Enabling MCP for the {skill} skill — complete sign-in if prompted…",
1644
+ cancelled=f"MCP sign-in for the {skill} skill cancelled; its prompt was not sent.",
1645
+ )
1646
+
1647
+ def start_mcp_defaults(self) -> None:
1648
+ from pcode.mcp import default_servers
1649
+
1650
+ self.mcp_defaults_requested = False
1651
+ if getattr(self.runtime, "mcp", None) is None:
1652
+ return
1653
+ try:
1654
+ names = default_servers()
1655
+ except ValueError as error:
1656
+ self.view.error(str(error))
1657
+ return
1658
+ if not names:
1659
+ return
1660
+ self.start_mcp_task(
1661
+ "defaults",
1662
+ self.enable_mcp_defaults(names),
1663
+ status="Enabling default MCP servers…",
1664
+ cancelled="Default MCP enable cancelled; remaining servers stay off.",
1665
+ )
1666
+
1667
+ def start_mcp_logout(self, name: str) -> None:
1668
+ self.start_mcp_task(
1669
+ name,
1670
+ self.logout_mcp(name),
1671
+ status=f"Signing out of MCP '{name}'…",
1672
+ cancelled=f"MCP '{name}' sign-out cancelled.",
1673
+ )
1674
+
1675
+ def start_compact(self, focus: str) -> None:
1676
+ from pcode.ui import SYSTEM_COMMAND_LABELS
1677
+
1678
+ self.start_history_task(
1679
+ self.runtime.compact(focus),
1680
+ # Label the work instead of echoing "/compact <focus>", which
1681
+ # reads like the command was typed as part of a prompt.
1682
+ label=SYSTEM_COMMAND_LABELS["/compact"],
1683
+ detail=focus,
1684
+ status="Compacting context…",
1685
+ note="Compacting context with the current model. Ctrl+C cancels.",
1686
+ done=lambda result: result.description(),
1687
+ cancelled="Compaction cancelled; history unchanged.",
1688
+ failed="Compaction failed",
1689
+ )
1690
+
1691
+ def start_summary(self, request) -> None:
1692
+ # Checked before the task marks the session busy, which would refuse it.
1693
+ follows = self.check_bridge(request.thread)
1694
+ self.start_history_task(
1695
+ self.summarize_thread(follows, request.instructions),
1696
+ label="Summarizing side thread",
1697
+ detail=request.instructions,
1698
+ status="Summarizing side thread…",
1699
+ note="Summarizing the side thread into the conversation. Ctrl+C cancels.",
1700
+ done=lambda result: "Side thread summary added to the conversation.",
1701
+ cancelled="Summary cancelled; the conversation is unchanged.",
1702
+ failed="Side thread summary failed",
1703
+ )
1704
+
1705
+ def start_history_task(
1706
+ self, work, *, label, detail, status, note, done, cancelled, failed
1707
+ ) -> None:
1708
+ """Run work that rewrites the conversation's history, holding prompts back.
1709
+
1710
+ Prompts typed meanwhile queue behind it on `compact_idle`, the way a
1711
+ turn would, so nothing reads the history while it changes. `done`
1712
+ turns the result into the closing note.
1713
+ """
1714
+ self.compact_idle.clear()
1715
+ self.activity.busy = True
1716
+ self.activity.status = status
1717
+ self.activity.start_prompt(label, kind="system", detail=detail)
1718
+ self.view.note(note)
1719
+
1720
+ def finished(task):
1721
+ success = False
1722
+ try:
1723
+ result = task.result()
1724
+ self.view.note(done(result))
1725
+ self.activity.finish_prompt("done")
1726
+ success = True
1727
+ except asyncio.CancelledError:
1728
+ self.activity.finish_prompt("cancelled")
1729
+ self.view.warning(cancelled)
1730
+ except Exception as error:
1731
+ self.activity.finish_prompt("failed")
1732
+ self.view.error(error_message(error), title=failed)
1733
+ finally:
1734
+ if not success:
1735
+ self.clear_queue()
1736
+ self.compact_task = None
1737
+ self.refresh_busy()
1738
+ self.activity.status = ""
1739
+ self.compact_idle.set()
1740
+ self.view.redraw()
1741
+ self.view.session_changed()
1742
+
1743
+ self.compact_task = asyncio.create_task(work)
1744
+ self.compact_task.add_done_callback(finished)
1745
+
1746
+ # --- Jobs: a footer count, a watched tail, exits, and wake-ups ---
1747
+
1748
+ async def watch_jobs(self) -> None:
1749
+ """Keep the job count current, and report exits once the turn is over.
1750
+
1751
+ The count updates busy or idle and excludes completed jobs.
1752
+ Scrollback waits for idle, because a completion written mid-turn
1753
+ would land inside the model's streaming text.
1754
+ The model is told separately, at its next request, unless nothing
1755
+ is going to make one: then the job's notice starts the turn itself.
1756
+ Polling here costs one small file read per running job and
1757
+ replaces the model doing the same thing with `sleep`.
1758
+ """
1759
+ await self.ready.wait()
1760
+ self.adopt_jobs()
1761
+ while True:
1762
+ changed = False
1763
+ if not self.activity.busy and not self.activity.queued_prompts:
1764
+ changed = bool(self.report_finished_jobs())
1765
+ prompt = self.wake_prompt() if self.live_task is None else None
1766
+ if prompt is not None:
1767
+ self.submit(prompt, "wake")
1768
+ # Refresh even while busy so the footer count stays current.
1769
+ if self.refresh_jobs() or changed:
1770
+ self.view.redraw()
1771
+ await asyncio.sleep(1)
1772
+
1773
+ def refresh_jobs(self) -> bool:
1774
+ """Recompute the job count and watched tail. Returns whether they changed.
1775
+
1776
+ Count every running job, even while a tool waits on it. Completion
1777
+ notices stay pending until the turn ends or the idle watcher reports them.
1778
+ """
1779
+ registry = getattr(self.runtime, "jobs", None)
1780
+ if registry is None:
1781
+ return False
1782
+ registry.refresh()
1783
+ count = sum(job.running for job in registry.jobs.values())
1784
+ changed = count != self.activity.job_count
1785
+ self.activity.job_count = count
1786
+ return self._refresh_watched(registry) or changed
1787
+
1788
+ def _refresh_watched(self, registry) -> bool:
1789
+ from pcode.shell import preview_text
1790
+
1791
+ watched = self.activity.watched_job
1792
+ job = registry.get(watched) if watched else None
1793
+ if job is None or not job.running:
1794
+ self.activity.watched_job = ""
1795
+ return self._pin(None)
1796
+ tail, _ = registry.read_output(job, max_bytes=OUTPUT_TAIL_BYTES // 2)
1797
+ return self._pin(
1798
+ CommandOutput(WATCHED_PREFIX + job.id, job.command, preview_text(tail, final=True))
1799
+ )
1800
+
1801
+ def _pin(self, event: CommandOutput | None) -> bool:
1802
+ """Show `event` as the watched job's preview, replacing the last one."""
1803
+ if event == self.pinned:
1804
+ return False
1805
+ if self.pinned is not None and (event is None or event.call_id != self.pinned.call_id):
1806
+ self.view.drop_output(self.pinned.call_id)
1807
+ self.pinned = event
1808
+ if event is not None:
1809
+ self.view.show_output(event)
1810
+ return True
1811
+
1812
+ def adopt_jobs(self) -> None:
1813
+ """Take over what an earlier pcode left running, and say so."""
1814
+ registry = getattr(self.runtime, "jobs", None)
1815
+ adopted = registry.adopt_orphans() if registry is not None else []
1816
+ if adopted:
1817
+ self.view.note(
1818
+ f"Adopted {len(adopted)} job{'s' if len(adopted) != 1 else ''} "
1819
+ "still running from an earlier pcode; /jobs lists them."
1820
+ )
1821
+ for job in adopted:
1822
+ self.view.note(job.summary())
1823
+
1824
+ def jobs_arguments(self) -> tuple[str, ...]:
1825
+ """Complete `stop`/`watch` against jobs still running this session knows about."""
1826
+ registry = getattr(self.runtime, "jobs", None)
1827
+ if registry is None:
1828
+ return ()
1829
+ registry.refresh()
1830
+ running = sorted(
1831
+ (job for job in registry.jobs.values() if job.running), key=lambda job: job.started_at
1832
+ )
1833
+ return (
1834
+ "unwatch",
1835
+ "stop all",
1836
+ *(f"stop {job.id}" for job in running),
1837
+ *(f"watch {job.id}" for job in running),
1838
+ )
1839
+
1840
+ async def jobs(self, argument: str) -> None:
1841
+ """Browse, watch, or stop the shell jobs this session started.
1842
+
1843
+ Jobs outlive the turn that started them and, deliberately, the session
1844
+ itself, so the only way to know what is still running is to ask. Bare
1845
+ `/jobs` opens the browser; the subcommands act without it.
1846
+ """
1847
+ registry = getattr(self.runtime, "jobs", None)
1848
+ if registry is None:
1849
+ raise ValueError("/jobs requires a live model session.")
1850
+ registry.refresh()
1851
+ action, _, target = argument.partition(" ")
1852
+ # `list` predates the browser; it still opens it rather than failing.
1853
+ if not action or action == "list":
1854
+ if not registry.jobs:
1855
+ self.view.note("No jobs have been started.")
1856
+ return
1857
+ await self.view.browse_jobs()
1858
+ return
1859
+ if action == "unwatch":
1860
+ self.watch_job(None)
1861
+ return
1862
+ if action == "watch":
1863
+ job = registry.get(target.strip())
1864
+ if job is None:
1865
+ raise ValueError(f"No job {target.strip()!r}. Run /jobs to browse them.")
1866
+ if not job.running:
1867
+ raise ValueError(f"[{job.id}] has finished; nothing to watch.")
1868
+ self.watch_job(job.id)
1869
+ self.view.note(f"Watching [{job.id}] {job.label()}; /jobs unwatch hides it.")
1870
+ return
1871
+ if action != "stop":
1872
+ raise ValueError("/jobs takes stop ID, stop all, watch ID, or unwatch.")
1873
+ target = target.strip()
1874
+ if target == "all":
1875
+ self.stop_jobs(None)
1876
+ elif registry.get(target) is not None:
1877
+ self.stop_jobs([target])
1878
+ else:
1879
+ raise ValueError(f"No job {target!r}. Run /jobs to browse them.")
1880
+
1881
+ def watch_job(self, job_id: str | None) -> None:
1882
+ """Pin a running job's output tail into the preview, or unpin with None."""
1883
+ self.activity.watched_job = job_id or ""
1884
+ self.refresh_jobs()
1885
+
1886
+ def stop_jobs(self, job_ids: list[str] | None) -> None:
1887
+ """Stop these jobs, or every running one for None, and say so in scrollback."""
1888
+ registry = self.runtime.jobs
1889
+ jobs = None if job_ids is None else [registry.get(job_id) for job_id in job_ids]
1890
+ stopped = registry.stop_all([job for job in jobs if job] if jobs is not None else None)
1891
+ for job in stopped:
1892
+ # Printed here, so the idle watcher does not repeat it.
1893
+ job.announced.add("ui")
1894
+ self.view.note(f"Stopped [{job.id}] {job.label()}")
1895
+ if not stopped:
1896
+ self.view.note("Nothing was running.")
1897
+ # Now, not at the watcher's next tick: the rows answer this command.
1898
+ self.refresh_jobs()
1899
+
1900
+ # --- MCP servers ---
1901
+
1902
+ def mcp_arguments(self) -> tuple[str, ...]:
1903
+ from pcode.mcp import configured_servers
1904
+
1905
+ state = getattr(self.runtime, "mcp", None)
1906
+ enabled = state.enabled if state else {}
1907
+ try:
1908
+ names = configured_servers()
1909
+ except ValueError:
1910
+ names = {}
1911
+ oauth = [
1912
+ name
1913
+ for name, raw in sorted(names.items())
1914
+ if isinstance(raw, dict) and raw.get("auth") == "oauth"
1915
+ ]
1916
+ default_on = {
1917
+ name for name, raw in names.items() if isinstance(raw, dict) and raw.get("enabled")
1918
+ }
1919
+ disable = []
1920
+ for name in sorted(enabled.keys() | default_on):
1921
+ if name in enabled:
1922
+ disable.append(f"disable {name}")
1923
+ if name in names:
1924
+ disable.append(f"disable {name} --save")
1925
+ return (
1926
+ "list",
1927
+ *(("enable-all",) if names else ()),
1928
+ *(
1929
+ item
1930
+ for name in sorted(names)
1931
+ for item in (f"enable {name}", f"enable {name} --save")
1932
+ if not (item.endswith("--save") and name in default_on and name in enabled)
1933
+ ),
1934
+ *disable,
1935
+ *(f"logout {name}" for name in oauth),
1936
+ )
1937
+
1938
+ def mcp(self, argument: str) -> None:
1939
+ from pcode.mcp import config_path, configured_servers
1940
+
1941
+ parts = argument.split()
1942
+ save = parts[-1:] == ["--save"]
1943
+ if save:
1944
+ parts = parts[:-1]
1945
+ state = getattr(self.runtime, "mcp", None)
1946
+ enabled = state.enabled if state else {}
1947
+ if not save and (not parts or parts == ["list"]):
1948
+ self.view.note(f"MCP config: {config_path()}")
1949
+ try:
1950
+ names = configured_servers()
1951
+ except ValueError as error:
1952
+ self.view.error(str(error))
1953
+ names = {}
1954
+ for name in sorted(names.keys() | enabled.keys()):
1955
+ status = "enabled" if name in enabled else "off"
1956
+ raw = names.get(name)
1957
+ if isinstance(raw, dict) and raw.get("enabled") is True:
1958
+ status += " (default on)"
1959
+ self.view.note(f"{name}: {status}")
1960
+ if not names and not enabled:
1961
+ self.view.note("No MCP servers configured. Add an mcpServers object here.")
1962
+ self.view.note(
1963
+ 'MCP defaults to off unless a server sets "enabled": true. '
1964
+ "Use /mcp enable NAME, /mcp enable-all, /mcp disable NAME, or /mcp logout NAME. "
1965
+ "Add --save to enable or disable to change the default too."
1966
+ )
1967
+ return
1968
+ if (parts != ["enable-all"] or save) and (
1969
+ len(parts) != 2
1970
+ or parts[0] not in ({"enable", "disable"} if save else {"enable", "disable", "logout"})
1971
+ ):
1972
+ raise ValueError(
1973
+ "Usage: /mcp list | /mcp enable NAME [--save] | /mcp enable-all "
1974
+ "| /mcp disable NAME [--save] | /mcp logout NAME"
1975
+ )
1976
+ # Slash commands precede queued prompts, and queued and steering
1977
+ # messages wait for MCP work, so an enable + prompt submitted in one
1978
+ # input batch authenticates first. A running turn sees the change from
1979
+ # its next model request.
1980
+ if self.mcp_enabling:
1981
+ raise ValueError("Another MCP change is still in progress. Wait for it, then retry.")
1982
+ if state is None:
1983
+ raise ValueError("MCP requires a live model. Start pcode with -m PROVIDER:MODEL.")
1984
+ if parts == ["enable-all"]:
1985
+ self.start_mcp_enable_all()
1986
+ return
1987
+ action, name = parts
1988
+ if action == "enable":
1989
+ if name not in enabled:
1990
+ self.start_mcp_enable(name, save=save)
1991
+ return
1992
+ self.view.note(f"MCP '{name}' is already enabled.")
1993
+ elif action == "logout":
1994
+ self.start_mcp_logout(name)
1995
+ return
1996
+ elif name in enabled or not save:
1997
+ state.disable(name)
1998
+ self.view.note(
1999
+ f"MCP '{name}' disabled{self.mid_turn()}. Earlier results remain in history."
2000
+ )
2001
+ if save:
2002
+ self.save_mcp_default(name, action == "enable")
2003
+
2004
+ def save_mcp_default(self, name: str, enabled: bool) -> None:
2005
+ from pcode.mcp import config_path, save_default
2006
+
2007
+ try:
2008
+ save_default(name, enabled)
2009
+ except Exception as error:
2010
+ # Best effort after the change itself succeeded: never report it as a failure.
2011
+ self.view.warning(f"Could not save the MCP default ({error}); this applies only here.")
2012
+ return
2013
+ default = "on in every new conversation" if enabled else "off by default"
2014
+ self.view.note(f"MCP '{name}' is now {default} ({config_path()}).")
2015
+
2016
+ async def enable_mcp(self, name: str, *, save: bool = False) -> None:
2017
+ """Authorize outside the model loop; publish only a successfully enabled server."""
2018
+ await self.runtime.mcp.enable(name)
2019
+ self.view.note(
2020
+ f"MCP '{name}' enabled for this conversation{self.mid_turn()}. "
2021
+ "Its tools can perform actions with the server's permissions. "
2022
+ "OAuth sign-ins are saved for future sessions; /mcp logout NAME forgets one."
2023
+ )
2024
+ if save:
2025
+ self.save_mcp_default(name, True)
2026
+
2027
+ def mid_turn(self) -> str:
2028
+ """Says when an MCP change reaches a turn already running."""
2029
+ return ", from the running turn's next model request" if self.turn_running() else ""
2030
+
2031
+ def report_mcp_error(self, name: str, error: Exception) -> None:
2032
+ """Keep MCP setup tracebacks even though no model turn was started."""
2033
+ from pcode.diagnostics import error_report
2034
+ from pcode.mcp import error_message
2035
+
2036
+ self.view.error(f"MCP '{name}' remains off: {error_message(error)}")
2037
+ saved = getattr(self.runtime, "session", None)
2038
+ path = saved.record_error(error, run_id=f"mcp:{name}") if saved else None
2039
+ if path is not None:
2040
+ self.view.note(f"MCP diagnostics: {path}")
2041
+ else:
2042
+ # --no-save and failed writes still need actionable frames, but must
2043
+ # not create a session or silently persist a separate diagnostics file.
2044
+ self.view.note(error_report(error))
2045
+
2046
+ async def enable_mcp_each(self, names: list[str], *, reason: str = "") -> None:
2047
+ """Enable servers in order; one that fails stays off, the rest proceed."""
2048
+ for name in names:
2049
+ try:
2050
+ await self.runtime.mcp.enable(name)
2051
+ except Exception as error:
2052
+ self.report_mcp_error(name, error)
2053
+ else:
2054
+ self.view.note(f"MCP '{name}' enabled{reason}{self.mid_turn()}.")
2055
+
2056
+ async def enable_mcp_defaults(self, names: list[str]) -> None:
2057
+ """Enable `"enabled": true` servers, using saved sign-ins but never a browser."""
2058
+ from pcode.mcp_oauth import SignInRequired
2059
+
2060
+ for name in names:
2061
+ try:
2062
+ await self.runtime.mcp.enable(name, interactive=False)
2063
+ except SignInRequired:
2064
+ self.view.warning(f"MCP '{name}' needs a browser sign-in; run /mcp enable {name}.")
2065
+ except Exception as error:
2066
+ self.report_mcp_error(name, error)
2067
+ else:
2068
+ self.view.note(f"MCP '{name}' enabled (default on).")
2069
+
2070
+ async def logout_mcp(self, name: str) -> None:
2071
+ await self.runtime.mcp.forget(name)
2072
+ self.view.note(
2073
+ f"MCP '{name}' signed out and disabled. The next /mcp enable {name} opens a "
2074
+ "browser. This does not revoke the server-side grant."
2075
+ )
2076
+
2077
+ # --- Preferences the session saves (model, effort, autocompact) ---
2078
+
2079
+ def persist_defaults(self, **updates: str) -> None:
2080
+ try:
2081
+ save_preferences(**updates)
2082
+ except (OSError, ValueError):
2083
+ self.view.warning("Could not save defaults; this selection applies only here.")
2084
+
2085
+ def forget_defaults(self, *keys: str) -> None:
2086
+ from pcode.preferences import update_preferences
2087
+
2088
+ try:
2089
+ update_preferences({}, remove=keys)
2090
+ except (OSError, ValueError):
2091
+ self.view.warning("Could not update defaults; this change applies only here.")
2092
+
2093
+ # --- History: compaction and resending ---
2094
+
2095
+ def compact(self, argument: str, *, before_queue: bool = False) -> None:
2096
+ if not self.model or not hasattr(self.runtime, "compact"):
2097
+ raise ValueError("/compact requires a live model session.")
2098
+ if not before_queue and (self.activity.busy or self.activity.queued_prompts):
2099
+ raise ValueError("/compact is unavailable while working. Cancel or wait, then retry.")
2100
+ self.start_compact(argument)
2101
+
2102
+ def autocompact(self, argument: str) -> None:
2103
+ """`on`/`off` toggle; `200k` caps when it fires; `auto` drops the cap."""
2104
+ if not self.model or not hasattr(self.runtime, "auto_compact"):
2105
+ raise ValueError("/autocompact requires a live model session.")
2106
+ argument = argument.lower()
2107
+ # Allowed mid-turn: AutoCompaction reads both settings before each request.
2108
+ if argument:
2109
+ from pcode.compaction import effective_window
2110
+ from pcode.preferences import parse_token_count
2111
+
2112
+ limit = None if argument in {"on", "off", "auto"} else parse_token_count(argument)
2113
+ enabling = argument == "on" or limit is not None
2114
+ if enabling and effective_window(self.runtime.agent.model) is None:
2115
+ raise ValueError(
2116
+ "Unknown context window. Set PCODE_CONTEXT_WINDOW to the deployment's "
2117
+ "token limit before enabling automatic compaction."
2118
+ )
2119
+ if argument == "auto":
2120
+ self.runtime.auto_compact_limit = None
2121
+ self.forget_defaults("autocompact_tokens")
2122
+ elif limit is not None:
2123
+ self.runtime.auto_compact_limit = limit
2124
+ self.persist_defaults(autocompact_tokens=str(limit))
2125
+ if enabling:
2126
+ # Setting a cap means you want compaction, so it also turns it on.
2127
+ self.runtime.auto_compact = True
2128
+ self.persist_defaults(autocompact="on")
2129
+ elif argument == "off":
2130
+ self.runtime.auto_compact = False
2131
+ self.persist_defaults(autocompact="off")
2132
+ self.view.flash(
2133
+ f"Automatic compaction: {self.autocompact_state()}. "
2134
+ "Usage: /autocompact on|off|200k|auto"
2135
+ )
2136
+
2137
+ def compact_cap(self) -> int | None:
2138
+ """The token cap automatic compaction honors right now, if any."""
2139
+ if not getattr(self.runtime, "auto_compact", False):
2140
+ return None
2141
+ return getattr(self.runtime, "auto_compact_limit", None)
2142
+
2143
+ def autocompact_state(self) -> str:
2144
+ from pcode.context_usage import compact_tokens
2145
+
2146
+ if not getattr(self.runtime, "auto_compact", False):
2147
+ return "off"
2148
+ cap = self.compact_cap()
2149
+ return f"on at {compact_tokens(cap)}" if cap else "on (auto threshold)"
2150
+
2151
+ def resend(self, argument: str, *, before_queue: bool = False) -> None:
2152
+ """Ask again from the settled checkpoint instead of typing "continue"."""
2153
+ if argument:
2154
+ raise ValueError("/resend takes no arguments.")
2155
+ if not self.model or not hasattr(self.runtime, "resend_prompt"):
2156
+ raise ValueError("/resend requires a live model session.")
2157
+ if not before_queue and (self.activity.busy or self.activity.queued_prompts):
2158
+ raise ValueError("/resend is unavailable while working. Cancel or wait, then retry.")
2159
+ previous = self.runtime.resend_prompt()
2160
+ # Sent idle, before any prompts now queued behind it: keep that order.
2161
+ self.prompts.put(previous, "resend", first=True)
2162
+ self.activity.start_prompt(previous)
2163
+ self.activity.busy = True
2164
+
2165
+ # --- The session: its runtime, model, effort, extensions, and skills ---
2166
+
2167
+ def register_skills(self) -> None:
2168
+ """Expose discovered SKILL.md assets as commands, skipping any collision."""
2169
+ from pcode.skills import discover_skills, skill_commands
2170
+
2171
+ self.skill_command_names: list[str] = []
2172
+ for command in skill_commands(discover_skills(self.workspace), self.run_skill):
2173
+ names = (command.name, *command.aliases)
2174
+ # Bare names can collide with a built-in command; built-ins win, and
2175
+ # the prefixed form still reaches the skill.
2176
+ taken = [name for name in names if self.command_taken(name)]
2177
+ if command.name in taken:
2178
+ continue
2179
+ if taken:
2180
+ command = replace(
2181
+ command, aliases=tuple(name for name in command.aliases if name not in taken)
2182
+ )
2183
+ self.registry.register(command)
2184
+ self.skill_command_names.append(command.name)
2185
+
2186
+ def run_skill(self, skill, argument: str) -> None:
2187
+ from pcode.skills import skill_prompt
2188
+
2189
+ if not self.model:
2190
+ raise ValueError(f"/skill:{skill.name} requires a live model session.")
2191
+ self.skill_requested = skill_prompt(skill, argument)
2192
+ if skill.mcp_servers:
2193
+ self.skill_mcp_requested = (skill.name, skill.mcp_servers)
2194
+
2195
+ # --- Extensions and sub-agents ---
2196
+
2197
+ def register_extension_commands(self) -> None:
2198
+ """Expose extension commands, replacing the previous load's; built-ins win."""
2199
+ for name in self.extension_command_names:
2200
+ self.registry.unregister(name)
2201
+ self.extension_command_names = []
2202
+ if self.extensions is None:
2203
+ return
2204
+ for extension in self.extensions.extensions:
2205
+ for command in extension.commands:
2206
+ names = (command.name, *command.aliases)
2207
+ if taken := [name for name in names if self.command_taken(name)]:
2208
+ self.view.warning(
2209
+ f"Extension {extension.name}: {', '.join(taken)} already exists; skipped."
2210
+ )
2211
+ continue
2212
+ self.registry.register(command)
2213
+ self.extension_command_names.append(command.name)
2214
+ self.view.commands_changed()
2215
+
2216
+ def extension_arguments(self) -> tuple[str, ...]:
2217
+ """Complete `on`/`off` against the extensions this workspace discovered."""
2218
+ found = self.extensions.extensions if self.extensions else ()
2219
+ return (
2220
+ "list",
2221
+ *(f"on {e.name}" for e in found if not e.enabled),
2222
+ *(f"off {e.name}" for e in found if e.enabled),
2223
+ )
2224
+
2225
+ def manage_extensions(self, argument: str) -> None:
2226
+ """`/extensions` lists what loaded; `on NAME` / `off NAME` change it and reload."""
2227
+ from pcode.ext import PROJECT_DIR, set_enabled, user_extension_dir
2228
+ from pcode.project_trust import is_trusted
2229
+
2230
+ if not self.model:
2231
+ raise ValueError("/extensions requires a live model session.")
2232
+ parts = argument.split()
2233
+ if parts and parts != ["list"]:
2234
+ if len(parts) != 2 or parts[0] not in {"on", "off"}:
2235
+ raise ValueError("Usage: /extensions [list] | /extensions on|off NAME")
2236
+ action, name = parts
2237
+ known = {e.name for e in self.extensions.extensions} if self.extensions else set()
2238
+ if name not in known:
2239
+ listing = f" Known: {', '.join(sorted(known))}" if known else ""
2240
+ raise ValueError(f"Unknown extension '{name}'.{listing}")
2241
+ # Refuse before writing, so the preference cannot drift from the session.
2242
+ self.reload("")
2243
+ set_enabled(name, action == "on")
2244
+ self.view.note(f"Extension '{name}' turned {action}; reloading.")
2245
+ return
2246
+ lines = self.extensions.report(self.workspace) if self.extensions else []
2247
+ if not lines:
2248
+ lines = ["No extensions found."]
2249
+ lines.append(f"User extensions: {user_extension_dir()}")
2250
+ lines.append(
2251
+ f"Project extensions ({PROJECT_DIR}): "
2252
+ + (
2253
+ "on (repository trusted)"
2254
+ if is_trusted(self.workspace)
2255
+ else "off; answer the launch prompt or /config set project_extensions on"
2256
+ )
2257
+ )
2258
+ lines.append("Turn one on or off with /extensions on|off NAME. Ask pcode to write one.")
2259
+ self.view.note("\n".join(lines))
2260
+
2261
+ async def subagents(self, argument: str) -> None:
2262
+ """`/subagents` lists the models delegate_task may pick; `MODEL ...` or `off` sets them."""
2263
+ from pcode.agent import subagent_menu
2264
+
2265
+ names = list(dict.fromkeys(argument.split()))
2266
+ project = from_project("subagent_models")
2267
+ if not names:
2268
+ configured = subagent_models()
2269
+ if not configured:
2270
+ self.view.note(
2271
+ "No sub-agent models: sub-agents run on the session's model. "
2272
+ "/subagents MODEL [MODEL ...] lists others for delegate_task; it can also take "
2273
+ "any provider:model name per delegation."
2274
+ )
2275
+ return
2276
+ menu, problems = await asyncio.to_thread(subagent_menu, configured)
2277
+ lines = [
2278
+ "Sub-agent models listed for delegate_task (without one, the session's model)"
2279
+ + (", set by this workspace's .pcode/preferences.json" if project else "")
2280
+ + ":",
2281
+ *(f" {name}" for name in menu),
2282
+ *(f"Unavailable, left out: {problem}" for problem in problems),
2283
+ "/subagents MODEL [MODEL ...] replaces the list; /subagents off clears it.",
2284
+ ]
2285
+ self.view.note("\n".join(lines))
2286
+ return
2287
+ if not self.model:
2288
+ raise ValueError("/subagents requires a live model session.")
2289
+ if project:
2290
+ # A saved user choice would change nothing while the overlay decides.
2291
+ raise ValueError(
2292
+ "This workspace's .pcode/preferences.json sets subagent_models; change it "
2293
+ "with pcode config project set|unset subagent_models."
2294
+ )
2295
+ if names == ["off"]:
2296
+ names = []
2297
+ else:
2298
+ # Resolve before saving, so an unknown provider or a missing login is
2299
+ # refused here rather than silently left out of the menu.
2300
+ _, problems = await asyncio.to_thread(subagent_menu, names)
2301
+ if problems:
2302
+ raise ValueError("\n".join(problems))
2303
+ # Refuse before writing, so the preference cannot drift from the session.
2304
+ self.reload("")
2305
+ try:
2306
+ save_preferences(subagent_models=",".join(names))
2307
+ except (OSError, ValueError) as error:
2308
+ # The reload reads the saved list, so without it nothing would change.
2309
+ self.reload_requested = False
2310
+ raise ValueError(f"Could not save subagent_models: {error}") from error
2311
+ # Resolving checks only the provider and login, not the model id, and the
2312
+ # catalog is not exhaustive: flag what it does not know without refusing.
2313
+ known = set(await asyncio.to_thread(self.model_suggestions))
2314
+ if unknown := [name for name in names if name not in known]:
2315
+ self.view.warning(
2316
+ f"Not in the /model catalog, so check the spelling: {', '.join(unknown)}"
2317
+ )
2318
+ chosen = ", ".join(names) if names else "none; sub-agents run on the session's model"
2319
+ self.view.note(f"Sub-agent models: {chosen}. Reloading.")
2320
+
2321
+ def model_list_completions(self, argument: str):
2322
+ """Complete the word being typed in `/subagents` from the /model catalog.
2323
+
2324
+ Names already typed are not offered again, and `off` only as the first word.
2325
+ """
2326
+ from prompt_toolkit.completion import Completion
2327
+
2328
+ words = argument.split()
2329
+ fragment = words.pop() if words and not argument[-1].isspace() else ""
2330
+ if words[:1] == ["off"]:
2331
+ return
2332
+ if not words and "off".startswith(fragment):
2333
+ yield Completion(
2334
+ "off", start_position=-len(fragment), display_meta="Only the session's model"
2335
+ )
2336
+ needle = fragment.casefold()
2337
+ for model in self.model_suggestions():
2338
+ if model not in words and needle in model.casefold():
2339
+ yield Completion(model, start_position=-len(fragment))
2340
+
2341
+ def reload(self, argument: str) -> None:
2342
+ if argument:
2343
+ raise ValueError("/reload takes no arguments.")
2344
+ self._check_reloadable()
2345
+ if self.activity.busy or self.activity.queued_prompts:
2346
+ raise ValueError("/reload is unavailable while working. Cancel or wait, then retry.")
2347
+ self.reload_requested = True
2348
+
2349
+ def _check_reloadable(self) -> None:
2350
+ if not self.model or not hasattr(self.runtime, "replace_agent"):
2351
+ raise ValueError("/reload requires a live model session.")
2352
+
2353
+ def request_extension_reload(self) -> None:
2354
+ """An extension's `request_reload`: now when idle, else once the running turn ends.
2355
+
2356
+ Unlike a typed /reload, the extension has usually just changed what its
2357
+ `setup` contributes (`/browser attach`), so refusing mid-turn would
2358
+ only make the user retype it. The agent is never swapped under a
2359
+ request in flight: `consume` applies it between turns.
2360
+ """
2361
+ self._check_reloadable()
2362
+ if self.activity.busy or self.activity.queued_prompts:
2363
+ if not self.reload_pending:
2364
+ self.reload_pending = True
2365
+ self._extension_notice("Extensions reload once the running work finishes.", "info")
2366
+ else:
2367
+ self.reload_requested = True
2368
+
2369
+ async def apply_pending_reload(self) -> None:
2370
+ """Run a reload an extension asked for mid-turn, now that no request is in flight."""
2371
+ if not self.reload_pending:
2372
+ return
2373
+ try:
2374
+ await self.reload_extensions()
2375
+ except Exception as error:
2376
+ self.view.error(error_message(error), title="Reload failed")
2377
+
2378
+ async def reload_extensions(self) -> None:
2379
+ """Re-import every extension and rebuild the agent around the same conversation."""
2380
+ from pcode.agent import create_agent
2381
+
2382
+ self.reload_requested = False
2383
+ self.reload_pending = False
2384
+ loaded = await asyncio.to_thread(self._load_extensions)
2385
+ # Construct first, so a failure leaves the previous agent in place.
2386
+ agent = await asyncio.to_thread(
2387
+ create_agent, self.model, self.workspace, loaded.capabilities, loaded.subagents
2388
+ )
2389
+ apply_effort(agent, self.model, effort_for(self.model))
2390
+ apply_thinking(agent, self.model, self.activity.thinking_mode)
2391
+ self.extensions = loaded
2392
+ self.runtime.replace_agent(agent)
2393
+ await self.runtime.refresh_context()
2394
+ self.register_extension_commands()
2395
+ count = len(loaded.extensions) - len(loaded.failed) - len(loaded.disabled)
2396
+ summary = f"Reloaded {count} extension{'s' if count != 1 else ''}"
2397
+ if loaded.disabled:
2398
+ summary += f", {len(loaded.disabled)} off"
2399
+ if loaded.failed:
2400
+ summary += f", {len(loaded.failed)} failed"
2401
+ # A changed tool list or instruction invalidates the cached prompt prefix.
2402
+ self.view.note(f"{summary}. The next request rebuilds the prompt cache.")
2403
+ for line in loaded.report(self.workspace):
2404
+ self.view.note("Extension " + line)
2405
+
2406
+ def _extension_notice(self, text: str, level: str) -> None:
2407
+ """Route an extension's notice to the transcript from any thread."""
2408
+ show = {"warning": self.view.warning, "error": self.view.error}.get(level, self.view.note)
2409
+ loop = self._loop
2410
+ if (
2411
+ loop is not None
2412
+ and loop.is_running()
2413
+ and threading.current_thread() is not threading.main_thread()
2414
+ ):
2415
+ loop.call_soon_threadsafe(show, text)
2416
+ else:
2417
+ show(text)
2418
+
2419
+ def _load_extensions(self, workspace: Path | None = None):
2420
+ from pcode.ext import ExtensionUI, load_extensions
2421
+
2422
+ return load_extensions(
2423
+ workspace or self.workspace,
2424
+ ExtensionUI(self._extension_notice, self.request_extension_reload),
2425
+ session_dir=self.session_dir,
2426
+ )
2427
+
2428
+ # --- Runtime and model switching ---
2429
+
2430
+ def _create_runtime(self):
2431
+ """Import and construct the backend off the terminal's event loop."""
2432
+ from pcode.agent import create_agent
2433
+ from pcode.live import AgentRuntime
2434
+
2435
+ self.extensions = self._load_extensions()
2436
+ return AgentRuntime(
2437
+ create_agent(
2438
+ self.model,
2439
+ self.workspace,
2440
+ self.extensions.capabilities,
2441
+ self.extensions.subagents,
2442
+ ),
2443
+ self._saved_session,
2444
+ session_factory=self._create_session if self.save_sessions else None,
2445
+ )
2446
+
2447
+ def _create_session(self, model: str | None = None):
2448
+ from pcode.sessions import SavedSession
2449
+
2450
+ identity, self._session_id = self._session_id, None
2451
+ return SavedSession.create(
2452
+ model or self.model, self.workspace, self.session_dir, identity=identity
2453
+ )
2454
+
2455
+ def meridian_thinking_state(self) -> tuple[str | None, bool | None]:
2456
+ """(proxy URL, whether it forwards thinking) for the current Meridian model."""
2457
+ model = getattr(getattr(self.runtime, "agent", None), "model", None)
2458
+ if getattr(model, "system", None) != "meridian":
2459
+ return None, None
2460
+ from pcode.meridian import thinking_passthrough
2461
+
2462
+ base = str(model.base_url).rstrip("/")
2463
+ return base, thinking_passthrough(base, getattr(model.client, "api_key", None))
2464
+
2465
+ async def warn_meridian_thinking(self) -> None:
2466
+ """Say once when thinking display is on but the proxy drops thinking."""
2467
+ if self._meridian_thinking_warned or self.activity.thinking_mode == "off":
2468
+ return
2469
+ if not (self.model or "").startswith("meridian:"):
2470
+ return
2471
+ base, passthrough = await asyncio.to_thread(self.meridian_thinking_state)
2472
+ if passthrough is False:
2473
+ self._meridian_thinking_warned = True
2474
+ self.view.warning(meridian_thinking_note(base, passthrough))
2475
+
2476
+ async def switch_model(self, model: str) -> None:
2477
+ """Adopt a model now, or record it for the next request while working."""
2478
+ if self.activity.busy or self.activity.queued_prompts:
2479
+ # Replacing the agent mid-run would change the model of a request
2480
+ # that is already in flight. Defer like /effort instead of refusing.
2481
+ if model == self.model:
2482
+ self.pending_model = None
2483
+ self.persist_defaults(model=model)
2484
+ self.view.note(f"Already using {model}.")
2485
+ return
2486
+ self.pending_model = model
2487
+ self.view.note(
2488
+ f"Model: {model} (next request). This turn finishes on {self.model or 'preview'}."
2489
+ )
2490
+ return
2491
+ await self.activate_model(model)
2492
+
2493
+ async def apply_pending_model(self) -> None:
2494
+ """Adopt a model chosen mid-run, now that no request is in flight."""
2495
+ model, self.pending_model = self.pending_model, None
2496
+ if model is None:
2497
+ return
2498
+ try:
2499
+ await self.activate_model(model)
2500
+ except Exception as error:
2501
+ self.view.error(error_message(error), title="Model unchanged")
2502
+
2503
+ async def activate_model(self, model: str) -> None:
2504
+ from pcode.agent import create_agent
2505
+ from pcode.live import AgentRuntime
2506
+
2507
+ self.pending_model = None
2508
+ recovering = self.startup_error is not None
2509
+ if model == self.model and not recovering:
2510
+ self.persist_defaults(model=model)
2511
+ self.view.note(f"Already using {model}.")
2512
+ return
2513
+ if recovering and self.extensions is None:
2514
+ self.extensions = await asyncio.to_thread(self._load_extensions)
2515
+ # Construct first: a missing provider/login must leave the old session intact.
2516
+ capabilities = self.extensions.capabilities if self.extensions else ()
2517
+ subagents = self.extensions.subagents if self.extensions else ()
2518
+ agent = await asyncio.to_thread(
2519
+ create_agent, model, self.workspace, capabilities, subagents
2520
+ )
2521
+ apply_effort(agent, model, effort_for(model))
2522
+ apply_thinking(agent, model, self.activity.thinking_mode)
2523
+ save = self.save_sessions or getattr(self.runtime, "session_factory", None) is not None
2524
+ factory = (lambda: self._create_session(model)) if save else None
2525
+ if recovering:
2526
+ await self._finish_startup(AgentRuntime(agent, self._saved_session))
2527
+ if isinstance(self.runtime, AgentRuntime):
2528
+ saved = self.runtime.session
2529
+ if saved is not None:
2530
+ previous_model = saved.info.model
2531
+ saved.info.model = model
2532
+ try:
2533
+ saved.save_info()
2534
+ except OSError:
2535
+ saved.info.model = previous_model
2536
+ raise
2537
+ self.runtime.replace_agent(agent)
2538
+ self.runtime.session_factory = factory
2539
+ else:
2540
+ self.runtime = AgentRuntime(agent, session_factory=factory)
2541
+ self.model = model
2542
+ await self.runtime.refresh_context()
2543
+ self.persist_defaults(model=model)
2544
+ self.save_sessions = save
2545
+ if recovering:
2546
+ self.startup_error = None
2547
+ self.mcp_defaults_requested = True
2548
+ if self.resuming and not self.conversation_shown:
2549
+ # The state first, so a host's terminals know which journal to read.
2550
+ self.view.session_changed()
2551
+ self.view.replay_conversation()
2552
+ self.conversation_shown = True
2553
+ self.view.note(f"Model: {model}. Continuing the current conversation.")
2554
+ self.show_startup_context()
2555
+ self.warn_without_credentials()
2556
+ await self.warn_meridian_thinking()
2557
+
2558
+ # --- Effort ---
2559
+
2560
+ def current_effort(self) -> str:
2561
+ if not self.model:
2562
+ return "n/a"
2563
+ from pcode.preferences import current_effort
2564
+
2565
+ return current_effort(getattr(self.runtime, "agent", None), self.model)
2566
+
2567
+ def effort(self, argument: str) -> None:
2568
+ value = argument.strip().lower()
2569
+ agent = getattr(self.runtime, "agent", None)
2570
+ gated = agent is not None and effort_setting(self.model, agent.model) is None
2571
+ if not value:
2572
+ self.view.flash(
2573
+ effort_unavailable(self.model)
2574
+ if gated
2575
+ else f"Effort: {self.current_effort()}. "
2576
+ "Usage: /effort low|medium|high|xhigh|default"
2577
+ )
2578
+ return
2579
+ if value not in ("low", "medium", "high", "xhigh", "default"):
2580
+ self.view.flash("Usage: /effort low|medium|high|xhigh|default")
2581
+ return
2582
+ if agent is None:
2583
+ self.view.flash("Effort is unavailable until the agent has started.")
2584
+ return
2585
+ # `default` clears a level saved before the model was known to be gated,
2586
+ # so it stays allowed where setting one does not.
2587
+ if gated and value != "default":
2588
+ self.view.flash(effort_unavailable(self.model))
2589
+ return
2590
+ # Replace rather than mutate: an active run keeps its captured settings.
2591
+ apply_effort(agent, self.model, value)
2592
+ self.persist_defaults(model=self.model)
2593
+ # Per model: raising effort on one model must not raise it on the next.
2594
+ try:
2595
+ save_model_effort(self.model, value)
2596
+ except (OSError, ValueError):
2597
+ self.view.warning("Could not save defaults; this selection applies only here.")
2598
+ if gated:
2599
+ self.view.flash(f"Cleared the saved effort; {self.model} has no effort control.")
2600
+ return
2601
+ self.view.flash(f"Effort: {self.current_effort()} (next turn).")
2602
+
2603
+ def adjust_effort(self, direction: int) -> None:
2604
+ levels = ("low", "medium", "high", "xhigh")
2605
+ current = self.current_effort()
2606
+ # The provider default is unspecified; use medium as the starting point.
2607
+ index = levels.index(current) if current in levels else 1
2608
+ self.effort(levels[max(0, min(len(levels) - 1, index + direction))])
2609
+
2610
+ # --- Session overviews ---
2611
+
2612
+ def session_overview(self) -> list[tuple[str, str]]:
2613
+ """Label/value rows describing the live conversation.
2614
+
2615
+ One source for the `/status` notes and popup, so the
2616
+ two can never drift into describing the same session differently.
2617
+ """
2618
+ if not self.model:
2619
+ return [
2620
+ ("Model", "none · tools: none · network: none"),
2621
+ ("Preview turns", str(self.runtime.turns)),
2622
+ ("Mode", "Canned replies only. Start with -m PROVIDER:MODEL for a real agent."),
2623
+ ]
2624
+ totals = self.runtime.totals
2625
+ saved = self.runtime.session
2626
+ # What the session is called leads: it is what tells sessions apart,
2627
+ # and below the overhead rows it falls off a short terminal.
2628
+ rows = [
2629
+ *([("Name", saved.info.name)] if saved and saved.info.name else []),
2630
+ *([("Title", saved.info.title)] if saved and saved.info.title else []),
2631
+ ("Model", self.model),
2632
+ ("Effort", self.current_effort()),
2633
+ ("Workspace", str(self.workspace)),
2634
+ ("Turns", str(self.runtime.turns)),
2635
+ ("Tokens in/out", f"{self.runtime.input_tokens}/{self.runtime.output_tokens}"),
2636
+ # A cache read costs a fraction of an uncached token and a write costs
2637
+ # more than one, so the split is the part worth watching.
2638
+ (
2639
+ "Input cached read/write",
2640
+ f"{totals.cache_read}/{totals.cache_write} (uncached {totals.uncached_input})",
2641
+ ),
2642
+ ("Tools", "Coder tools enabled; no sandbox."),
2643
+ *self.overhead_overview(),
2644
+ (
2645
+ "Automatic compaction",
2646
+ self.autocompact_state() + " · /compact [focus] · /autocompact on|off|200k|auto",
2647
+ ),
2648
+ ]
2649
+ if saved:
2650
+ rows += [
2651
+ ("Session", saved.info.id),
2652
+ ("Saved in", str(saved.directory)),
2653
+ ("Started", saved.info.created[:16]),
2654
+ ("Updated", saved.info.updated[:16]),
2655
+ ]
2656
+ elif self.runtime.session_factory is not None:
2657
+ rows.append(("Session", "Will be saved after your first prompt."))
2658
+ else:
2659
+ rows.append(("Session", "Saving disabled; in memory only."))
2660
+ tree = getattr(self.runtime, "tree", None)
2661
+ if tree and tree.nodes:
2662
+ rows.append(("Branches", f"{len(tree.nodes)} turns in /tree"))
2663
+ mcp = getattr(self.runtime, "mcp", None)
2664
+ if enabled := sorted(getattr(mcp, "enabled", ()) or ()):
2665
+ rows.append(("MCP", ", ".join(enabled)))
2666
+ return rows
2667
+
2668
+ def overhead_overview(self) -> list[tuple[str, str]]:
2669
+ """Attribute the fixed part of the prompt: instructions, assets, tool schemas.
2670
+
2671
+ Read from the last request rather than re-derived, so the rows describe
2672
+ what the provider was actually sent. Nothing is available before the
2673
+ first request, where the alternative would be a parallel guess at a
2674
+ system prompt only the agent flow can resolve.
2675
+ """
2676
+ from pcode.context_breakdown import overhead_rows
2677
+ from pcode.context_usage import context_window
2678
+ from pcode.model_metadata import ContextWindowError
2679
+
2680
+ parameters = getattr(self.runtime, "request_parameters", None)
2681
+ if parameters is None:
2682
+ return [("Prompt overhead", "Measured on the first model request.")]
2683
+ try:
2684
+ resolved = getattr(getattr(self.runtime, "agent", None), "model", None)
2685
+ window = context_window(resolved or self.model)
2686
+ except ContextWindowError:
2687
+ window = None
2688
+ if window and (cap := self.compact_cap()):
2689
+ # Measured against the budget compaction actually leaves, as the footer is.
2690
+ window = min(window, cap)
2691
+ return overhead_rows(parameters, window=window)
2692
+
2693
+ # --- Slow command jobs ---
2694
+
2695
+ def defer(self, label: str, detail: str, job: Callable[[], list[str]]) -> None:
2696
+ """Run a slow command's work under a system badge, or inline without a terminal.
2697
+
2698
+ Handlers run on the terminal's event loop, so a job that takes seconds
2699
+ would freeze the screen with nothing to show for it. With a live
2700
+ terminal the work is picked up by the command loop, which paints a
2701
+ `◈ label ▸ detail` row (distinct from a model turn) and runs the job
2702
+ in a thread. The job returns lines for the transcript; a ValueError
2703
+ becomes the usual command error. A job started mid-turn leaves the
2704
+ turn's live row alone and says what it is doing in a notice instead.
2705
+ """
2706
+ if not self.interactive:
2707
+ self._report(job())
2708
+ return
2709
+ self.job_requested = (label, detail, job)
2710
+
2711
+ def _report(self, lines: list[str]) -> None:
2712
+ """Show a job's lines as one note, so its own indentation sets the hierarchy."""
2713
+ if lines:
2714
+ self.view.note("\n".join(lines))
2715
+
2716
+ async def perform_job(self) -> None:
2717
+ assert self.job_requested is not None
2718
+ label, detail, job = self.job_requested
2719
+ self.job_requested = None
2720
+ if self.activity.prompt_state == "running":
2721
+ await self._perform_job_alongside(label, detail, job)
2722
+ return
2723
+ self.activity.busy = True
2724
+ self.activity.start_prompt(label, kind="system", detail=detail)
2725
+ self.view.redraw()
2726
+ state = "failed"
2727
+ try:
2728
+ lines = await asyncio.to_thread(job)
2729
+ state = "done"
2730
+ except ValueError as error:
2731
+ self.view.error(str(error))
2732
+ else:
2733
+ self._report(lines)
2734
+ finally:
2735
+ self.activity.finish_prompt(state)
2736
+ self.activity.busy = bool(self.activity.queued_prompts)
2737
+ self.view.redraw()
2738
+
2739
+ async def _perform_job_alongside(
2740
+ self, label: str, detail: str, job: Callable[[], list[str]]
2741
+ ) -> None:
2742
+ """Run a job beside a live turn without taking over or ending its row."""
2743
+ self.activity.flash(f"{label} \u25b8 {detail}\u2026" if detail else f"{label}\u2026")
2744
+ try:
2745
+ lines = await asyncio.to_thread(job)
2746
+ except ValueError as error:
2747
+ self.view.error(str(error))
2748
+ else:
2749
+ self._report(lines)
2750
+ finally:
2751
+ self.activity.notice = ""
2752
+ self.view.redraw()
2753
+
2754
+ def usage(self, argument: str) -> None:
2755
+ from pcode.usage import usage_report
2756
+
2757
+ self.defer("Checking usage", "", usage_report)
2758
+
2759
+ # --- Worktrees ---
2760
+
2761
+ def worktree(self, argument: str) -> None:
2762
+ from pcode import worktree
2763
+
2764
+ action = argument or "status"
2765
+ if action == "list":
2766
+ self.view.note(worktree.listing(self.workspace) or "Not a git repository.")
2767
+ return
2768
+ if action == "clean":
2769
+ # Works from the mainline too, where the leftovers are most visible.
2770
+ self.defer("Cleaning worktrees", "", lambda: worktree.clean(self.workspace))
2771
+ return
2772
+ linked = worktree.describe(self.workspace)
2773
+ if linked is None:
2774
+ self.view.note(
2775
+ f"{self.workspace} is not a linked worktree. Start one with "
2776
+ "`pcode --worktree` or `/config set worktree on`."
2777
+ )
2778
+ return
2779
+ if action == "status":
2780
+ dirty = worktree.is_dirty(linked.path)
2781
+ count = worktree.unmerged_commits(linked)
2782
+ self.view.note(f"Worktree: {linked.path} (branch {linked.branch})")
2783
+ mainline = worktree.mainline_branch(linked.main)
2784
+ self.view.note(f"Mainline: {linked.main} ({mainline})")
2785
+ self.view.note(
2786
+ f"{count} unmerged commit{'s' if count != 1 else ''}"
2787
+ + (", uncommitted changes" if dirty else "")
2788
+ )
2789
+ return
2790
+ if action == "merge":
2791
+ # Allowed mid-turn: merge refuses a dirty tree, so it never runs
2792
+ # over uncommitted edits the model has in flight.
2793
+ self.defer("Merging worktree", linked.branch, lambda: [worktree.merge(linked)])
2794
+ return
2795
+ if self.activity.busy:
2796
+ raise ValueError("Wait for the current turn to finish before changing the worktree.")
2797
+ if action == "resolve":
2798
+ if not self.model:
2799
+ raise ValueError("/worktree resolve needs a live model session.")
2800
+ files = worktree.conflicted_files(linked.path)
2801
+ if not files:
2802
+ raise ValueError("No merge conflicts to resolve; run /worktree merge first.")
2803
+ # A prompt in command clothing, dispatched like a skill.
2804
+ self.skill_requested = worktree.resolve_prompt(linked, files)
2805
+ elif action == "remove":
2806
+ if worktree.unmerged_commits(linked):
2807
+ raise ValueError("Branch has unmerged commits; /worktree merge first.")
2808
+
2809
+ def remove() -> list[str]:
2810
+ result = worktree.remove(linked)
2811
+ self._leave_worktree(linked)
2812
+ return [result, "This session's workspace no longer exists; /quit."]
2813
+
2814
+ self.defer("Removing worktree", linked.branch, remove)
2815
+ elif action == "finish":
2816
+ # Refusals raise before anything is deleted, so the session stays put.
2817
+ def finish() -> list[str]:
2818
+ result = worktree.finish(linked)
2819
+ self._leave_worktree(linked)
2820
+ self.running = False
2821
+ return [f"\u2713 {result}"]
2822
+
2823
+ self.defer("Finishing worktree", linked.branch, finish)
2824
+
2825
+ def _leave_worktree(self, linked) -> None:
2826
+ """Point the saved session at the mainline so `pcode -c` still finds a directory."""
2827
+ session = getattr(self.runtime, "session", None)
2828
+ if session is None:
2829
+ return
2830
+ session.info.workspace = str(linked.main)
2831
+ try:
2832
+ session.save_info()
2833
+ except OSError:
2834
+ pass
2835
+ from pcode.sessions import forget_refs
2836
+
2837
+ # Its review checkpoint was of the checkout just removed.
2838
+ forget_refs(session.info)
2839
+
2840
+ # --- Startup and new conversations ---
2841
+
2842
+ def show_startup_context(self) -> None:
2843
+ """Report repository instructions and skills, each line only once.
2844
+
2845
+ A model switch re-runs this because starting without a model leaves
2846
+ nothing to report until a runtime exists. Repeating lines the
2847
+ transcript already carries is just noise, so only new ones print.
2848
+ """
2849
+ lines = []
2850
+ summary = getattr(self.runtime, "startup_context", None)
2851
+ if summary is not None:
2852
+ lines.extend(summary())
2853
+ if self.skill_command_names:
2854
+ lines.append("Skill commands: " + ", ".join(self.skill_command_names))
2855
+ warnings = []
2856
+ if self.extensions is not None:
2857
+ for extension, line in zip(
2858
+ self.extensions.extensions, self.extensions.report(self.workspace), strict=True
2859
+ ):
2860
+ # Shipped defaults, and extensions the user turned off, are not news
2861
+ # at every launch; /extensions lists them.
2862
+ if not extension.enabled or (extension.loaded and extension.scope == "bundled"):
2863
+ continue
2864
+ (lines if extension.loaded else warnings).append("Extension " + line)
2865
+ for line in lines + warnings:
2866
+ if line in self._startup_context_shown:
2867
+ continue
2868
+ self._startup_context_shown.add(line)
2869
+ if line in warnings:
2870
+ self.view.warning(line)
2871
+ else:
2872
+ self.view.retained_note(line)
2873
+
2874
+ def warn_without_credentials(self) -> None:
2875
+ """Say so at startup, not on the first prompt.
2876
+
2877
+ An `anthropic:` model with no selected credential is built with
2878
+ `defer_model_check`, so its agent keeps the unresolved model string and
2879
+ the terminal opens looking healthy. Report it while /login is still the
2880
+ obvious next step.
2881
+ """
2882
+ if not (self.model or "").startswith("anthropic:"):
2883
+ return
2884
+ agent = getattr(self.runtime, "agent", None)
2885
+ if agent is None or not isinstance(getattr(agent, "model", None), str):
2886
+ return
2887
+ from pcode.models import anthropic_credential_hint
2888
+
2889
+ self.view.warning(
2890
+ "No Anthropic credential is selected, so prompts will fail. "
2891
+ + anthropic_credential_hint()
2892
+ )
2893
+
2894
+ def rename(self, argument: str) -> None:
2895
+ """Name the saved session, so /resume can list and find it by that name."""
2896
+ session = getattr(self.runtime, "session", None)
2897
+ if session is None:
2898
+ raise ValueError("Nothing is saved yet; /rename works after the first prompt.")
2899
+ from pcode.tool_display import plain
2900
+
2901
+ # One line, no control characters, and short enough for a list row.
2902
+ name = plain(" ".join(plain(argument, None).split()), 80)
2903
+ if not name:
2904
+ current = session.info.name
2905
+ title = session.info.title
2906
+ self.view.flash(
2907
+ f"Session name: {current}. /rename - clears it."
2908
+ if current
2909
+ else f"Session title: {title}. /rename NAME replaces it."
2910
+ if title
2911
+ else "This session has no name. Usage: /rename NAME"
2912
+ )
2913
+ return
2914
+ session.info.name = None if name == "-" else name
2915
+ try:
2916
+ # A name is not activity: the session keeps its place and date in /resume.
2917
+ session.save_info(touch=False)
2918
+ except OSError as error:
2919
+ raise ValueError(f"Could not save the name: {error}") from error
2920
+ self.view.session_changed()
2921
+ self.view.flash(f"Session named: {name}" if name != "-" else "Session name cleared.")
2922
+
2923
+ def session_title(self) -> str:
2924
+ """What the session is called: its /rename name, else its asked-for title."""
2925
+ session = getattr(self.runtime, "session", None)
2926
+ if session is None:
2927
+ return ""
2928
+ return session.info.name or session.info.title or ""
2929
+
2930
+ def start_naming(self, prompt: str | None = None) -> None:
2931
+ """Ask for a title in the background, once, for a saved session that has none.
2932
+
2933
+ `prompt` is the session's first prompt when the caller has it; without
2934
+ one it is read from the journal.
2935
+ """
2936
+ session = getattr(self.runtime, "session", None)
2937
+ if session is None or not self.model:
2938
+ return
2939
+ info = session.info
2940
+ if info.name or info.title or info.id in self._naming_tried:
2941
+ return
2942
+ if self.naming_task is not None and not self.naming_task.done():
2943
+ return
2944
+ setting = SETTINGS["session_naming"]
2945
+ if load_preferences().get("session_naming", setting.default) != "on":
2946
+ return
2947
+ self._naming_tried.add(info.id)
2948
+ self.naming_task = asyncio.create_task(self._name_session(session, self.model, prompt))
2949
+
2950
+ async def _name_session(self, session, model: str, prompt: str | None) -> None:
2951
+ import logging
2952
+
2953
+ from pcode.session_naming import suggest_title
2954
+ from pcode.sessions import session_turns
2955
+
2956
+ try:
2957
+ if not (prompt and prompt.strip()):
2958
+ turns = await asyncio.to_thread(
2959
+ session_turns, session.info, session.directory.parent
2960
+ )
2961
+ prompt = next((turn.prompt for turn in turns or () if turn.prompt.strip()), "")
2962
+ if not prompt:
2963
+ return
2964
+ title = await suggest_title(model, prompt, workspace=self.workspace)
2965
+ except Exception as error: # noqa: BLE001 - a title is never worth an error.
2966
+ logging.getLogger(__name__).debug("session title failed: %s", error)
2967
+ return
2968
+ # Renamed meanwhile. /new and /resume cancel this, so the session is still open.
2969
+ if not title or session.info.name or getattr(self.runtime, "session", None) is not session:
2970
+ return
2971
+ session.info.title = title
2972
+ try:
2973
+ session.save_info(touch=False)
2974
+ except OSError:
2975
+ return
2976
+ self.view.session_changed()
2977
+ self.view.redraw()
2978
+
2979
+ def cancel_naming(self) -> None:
2980
+ """Another conversation is opening: its title request goes, and may be asked again."""
2981
+ if self.naming_task is not None and not self.naming_task.done():
2982
+ self.naming_task.cancel()
2983
+ self._naming_tried.clear()
2984
+
2985
+ async def stop_naming(self) -> None:
2986
+ """Cancel a title request and wait it out, before provider processes are stopped."""
2987
+ task = self.naming_task
2988
+ self.cancel_naming()
2989
+ if task is not None:
2990
+ await asyncio.gather(task, return_exceptions=True)
2991
+
2992
+ def new(self, argument: str) -> None:
2993
+ self.cancel_naming()
2994
+ self.runtime.reset()
2995
+ # On the host too, not just this terminal's copy, so one attaching later agrees.
2996
+ self.activity.turn_outcome = ""
2997
+ self.view.conversation_reset("New conversation")
2998
+ self.view.note(
2999
+ "Context reset; MCP servers are off unless marked enabled. Screen cleared; "
3000
+ "input history is unchanged."
3001
+ )
3002
+ if self.model and self.runtime.session:
3003
+ self.view.note(f"Saving session: {self.runtime.session.info.id}")
3004
+ self.mcp_defaults_requested = True
3005
+ # The old conversation's name leaves the tab with it.
3006
+ self.view.session_changed()
3007
+
3008
+ async def select_model(self, argument: str) -> None:
3009
+ """Pick a model in the terminal from the providers this session can reach."""
3010
+ from pcode.models import active_providers, model_catalog
3011
+
3012
+ providers = await asyncio.to_thread(active_providers, self.model)
3013
+ if not providers:
3014
+ self.view.note(
3015
+ "No active model providers. Use /login to sign in, "
3016
+ "set ANTHROPIC_API_KEY, run codex login, or export another "
3017
+ "provider's API key (see docs/providers.md). "
3018
+ "If model_providers is set, check that it allows an active provider."
3019
+ )
3020
+ return
3021
+ values = model_catalog(providers, self.model)
3022
+ model = await self.view.choose_model(values, providers, self.model)
3023
+ if model is not None:
3024
+ await self.switch_model(model)
3025
+
3026
+ async def initialize_runtime(self) -> None:
3027
+ """Build the agent (off the event loop) and restore a resumed conversation."""
3028
+ self._loop = asyncio.get_running_loop()
3029
+ if self._needs_runtime:
3030
+ # A cancelled to_thread await does not stop its thread. Keep ownership
3031
+ # until it finishes so a late-created runtime cannot leak on exit.
3032
+ task = asyncio.create_task(asyncio.to_thread(self._create_runtime))
3033
+ try:
3034
+ runtime = await asyncio.shield(task)
3035
+ except asyncio.CancelledError:
3036
+ try:
3037
+ runtime = await task
3038
+ except Exception:
3039
+ pass
3040
+ else:
3041
+ runtime.close()
3042
+ raise
3043
+ agent = getattr(runtime, "agent", None)
3044
+ if agent is not None:
3045
+ apply_effort(agent, self.model, effort_for(self.model))
3046
+ apply_thinking(agent, self.model, self.activity.thinking_mode)
3047
+ await self._finish_startup(runtime)
3048
+ elif self.resuming:
3049
+ await self.runtime.restore()
3050
+
3051
+ async def _finish_startup(self, runtime) -> None:
3052
+ """Adopt the first runtime: at launch, or from /model after launch failed."""
3053
+ self.runtime = runtime
3054
+ self._needs_runtime = False
3055
+ self.register_extension_commands()
3056
+ if self.resuming:
3057
+ await runtime.restore()
3058
+ self._seen_stopped_host()
3059
+
3060
+ def _seen_stopped_host(self) -> None:
3061
+ """A resumed session is open here, so /switch stops listing its stopped host."""
3062
+ from pcode.host_protocol import forget_stopped
3063
+
3064
+ session = getattr(self.runtime, "session", None)
3065
+ if session is not None:
3066
+ forget_stopped(session.info.id)
3067
+
3068
+ # --- Resuming another saved conversation in this process ---
3069
+
3070
+ async def resume_session(self, identity: str) -> None:
3071
+ from pcode.agent import create_agent
3072
+ from pcode.live import AgentRuntime
3073
+ from pcode.sessions import SavedSession
3074
+ from pcode.worktree import leave_worktree
3075
+
3076
+ current = getattr(self.runtime, "session", None)
3077
+ if current is not None and current.info.id == identity:
3078
+ self.view.note("This session is already active.")
3079
+ return
3080
+ saved = SavedSession.open(identity, self.session_dir, fork_if_open=True)
3081
+ try:
3082
+ target = self._resume_workspace(saved.info)
3083
+ # A session from another worktree gets that worktree's extensions
3084
+ # and skills; the same workspace keeps what is already loaded.
3085
+ extensions = self.extensions
3086
+ if target != self.workspace:
3087
+ extensions = await asyncio.to_thread(self._load_extensions, target)
3088
+ capabilities = extensions.capabilities if extensions else ()
3089
+ subagents = extensions.subagents if extensions else ()
3090
+ agent = create_agent(saved.info.model, target, capabilities, subagents)
3091
+ apply_effort(agent, saved.info.model, effort_for(saved.info.model))
3092
+ apply_thinking(agent, saved.info.model, self.activity.thinking_mode)
3093
+ runtime = AgentRuntime(agent, saved)
3094
+ await runtime.restore()
3095
+ await runtime.refresh_context()
3096
+ except BaseException:
3097
+ saved.abandon()
3098
+ raise
3099
+ # Keep the current conversation intact until recovery has succeeded.
3100
+ # Its title request ends first: the session it would save to is closing.
3101
+ await self.stop_naming()
3102
+ if target != self.workspace:
3103
+ # The worktree being left is tidied like at exit, but nobody is
3104
+ # asked: unmerged work stays put with a note on how to get back.
3105
+ leave_worktree(self.workspace, current, ask=None, notify=self.view.note)
3106
+ close = getattr(self.runtime, "close", None)
3107
+ if close is not None:
3108
+ close()
3109
+ if target != self.workspace:
3110
+ self._switch_workspace(target, extensions)
3111
+ self.runtime = runtime
3112
+ self._seen_stopped_host()
3113
+ self.model = saved.info.model
3114
+ self.session_dir = saved.directory.parent
3115
+ self.activity.prompt = ""
3116
+ self.activity.prompt_kind = "user"
3117
+ self.activity.prompt_detail = ""
3118
+ self.activity.turn_outcome = ""
3119
+ self.view.replay_conversation()
3120
+ self.mcp_defaults_requested = True
3121
+
3122
+ def _resume_workspace(self, info) -> Path:
3123
+ """Where a resumed session works: its own directory, if this repository's.
3124
+
3125
+ Another worktree of the same repository is fine (the session browser
3126
+ lists them), another repository is not: the conversation's paths,
3127
+ instructions, and extensions would all be wrong there.
3128
+
3129
+ A worktree removed from outside the session that owned it leaves no
3130
+ directory to go back to. The conversation is still worth resuming, so
3131
+ continue it here when this is the same repository.
3132
+ """
3133
+ from pcode.sessions import SessionError
3134
+ from pcode.worktree import repo_scope, session_scope
3135
+
3136
+ target = Path(info.workspace).resolve()
3137
+ if target == self.workspace:
3138
+ return target
3139
+ if not target.is_dir():
3140
+ if session_scope(info) != repo_scope(self.workspace):
3141
+ raise SessionError(
3142
+ f"Session workspace no longer exists: {target}. It belonged to another "
3143
+ "repository, so this one cannot continue it."
3144
+ )
3145
+ self.view.note(
3146
+ f"Session workspace {target} no longer exists; continuing in {self.workspace}."
3147
+ )
3148
+ return self.workspace
3149
+ if repo_scope(target) != repo_scope(self.workspace):
3150
+ raise SessionError("Workspace differs; refusing cross-repo resume.")
3151
+ return target
3152
+
3153
+ def _switch_workspace(self, workspace: Path, extensions) -> None:
3154
+ """Rebind everything keyed on the workspace to another worktree.
3155
+
3156
+ The agent is the caller's to replace; this covers what the app itself
3157
+ derives from the path: extension commands, skill commands, and the
3158
+ status line (whose branch watcher picks the new path up on its own).
3159
+ Project preferences and trust are per repository, so they stay.
3160
+ """
3161
+ self.workspace = workspace
3162
+ self.extensions = extensions
3163
+ self.register_extension_commands()
3164
+ for name in self.skill_command_names:
3165
+ self.registry.unregister(name)
3166
+ self.register_skills()
3167
+ self.view.commands_changed()
3168
+ self.view.note(f"Workspace: {workspace}")
3169
+
3170
+ # --- Side questions (/btw) and conversation branches (/tree) ---
3171
+
3172
+ def aside_settled(self, aside) -> None:
3173
+ if aside.status == "answered":
3174
+ self.view.aside_answered(aside)
3175
+ return
3176
+ if aside.status == "cancelled":
3177
+ self.view.note("Side question stopped.")
3178
+ else:
3179
+ on = f" on {aside.label}" if aside.label else ""
3180
+ self.view.warning(f"Side question{on} {aside.status}. {aside.error}".strip())
3181
+ saved = getattr(self.runtime, "session", None)
3182
+ if saved is not None and (saved.directory / "errors.log").exists():
3183
+ self.view.note(f"Diagnostics: {saved.directory / 'errors.log'}")
3184
+ # Not just a redraw: an attached terminal mirrors the status, and would
3185
+ # otherwise count a stopped or failed question as running forever.
3186
+ self.view.aside_changed(aside)
3187
+
3188
+ async def aside(self, argument: str) -> None:
3189
+ """`/btw [$MODEL[+EFFORT] | +EFFORT ...] QUESTION` asks beside the turn.
3190
+
3191
+ Bare `/btw` reads the answers.
3192
+ """
3193
+ models, question = parse_models(argument)
3194
+ if not question:
3195
+ if not self.asides.items:
3196
+ raise ValueError(
3197
+ "No side questions yet. Ask one with /btw QUESTION; "
3198
+ "it runs beside the conversation without interrupting it."
3199
+ )
3200
+ request = await self.view.read_asides()
3201
+ if request is None:
3202
+ return
3203
+ if request.action == "merge":
3204
+ await self.merge_thread(request.thread)
3205
+ else:
3206
+ self.start_summary(request)
3207
+ return
3208
+ if not self.model:
3209
+ raise ValueError("/btw needs a model; this is a local UI preview.")
3210
+ if self.startup_pending or self.startup_error is not None:
3211
+ raise ValueError("/btw is unavailable until the agent has started.")
3212
+ for target in models:
3213
+ self.check_effort(target)
3214
+ await self.start_aside(question, models)
3215
+
3216
+ def check_effort(self, target: SideTarget) -> None:
3217
+ """Refuse an effort the way /effort refuses it, before anything starts.
3218
+
3219
+ Rather than asking at an effort the provider would reject. The
3220
+ conversation's own model is judged on the object /effort judges, so
3221
+ one command cannot accept what the other refuses; another model is
3222
+ judged on its name, which is all that is known before `side_model`
3223
+ resolves it.
3224
+ """
3225
+ if not target.effort:
3226
+ return
3227
+ agent = getattr(self.runtime, "agent", None)
3228
+ name = target.model or self.model
3229
+ resolved = getattr(agent, "model", None) if name == self.model else None
3230
+ if effort_setting(name, resolved) is None:
3231
+ raise ValueError(effort_unavailable(name))
3232
+
3233
+ async def target_options(self, target: SideTarget) -> dict:
3234
+ """How a run asks `target`: `AgentRuntime.aside`/`stream` keyword arguments.
3235
+
3236
+ Another model is resolved now, so a bad name fails before anything is
3237
+ sent. The conversation's own model needs nothing, or, at another
3238
+ effort, its settings with that effort applied, taken now so a later
3239
+ /effort cannot reach a run already asked.
3240
+ """
3241
+ from pcode.agent import side_model, with_effort
3242
+
3243
+ if target.model not in ("", self.model):
3244
+ return {"model": await asyncio.to_thread(side_model, target.model, target.effort)}
3245
+ if not target.effort:
3246
+ return {}
3247
+ agent = self.runtime.agent
3248
+ return {
3249
+ "settings": with_effort(self.model, agent.model, agent.model_settings, target.effort)
3250
+ }
3251
+
3252
+ async def start_aside(self, question: str, models: list[SideTarget] | None = None) -> None:
3253
+ """Run a side question in the background, on the context available now.
3254
+
3255
+ With `models`, one side question starts per target. The conversation's
3256
+ own model takes the default path, which shares its prompt cache; every
3257
+ other one is resolved first, so a bad name fails the command before
3258
+ anything starts. An effort on the conversation's own model stays on
3259
+ that path, with the effort applied to its settings for that question.
3260
+ """
3261
+ models = models or [SideTarget()]
3262
+ others = [target for target in models if target.model not in ("", self.model)]
3263
+ options = [await self.target_options(target) for target in models]
3264
+ labels = model_labels(models)
3265
+ tree = getattr(self.runtime, "tree", None)
3266
+ for target, chosen in zip(models, options):
3267
+ self.asides.start(
3268
+ question,
3269
+ self._aside_work(question, chosen),
3270
+ model=target.model,
3271
+ label=labels[target],
3272
+ effort=target.effort,
3273
+ conversation=getattr(self.runtime, "conversation_id", ""),
3274
+ base=tree.active if tree is not None else None,
3275
+ )
3276
+ if others:
3277
+ names = ", ".join(dict.fromkeys(target.model for target in others))
3278
+ self.view.note(
3279
+ f"Asking beside the conversation on {names}: the turn keeps running and "
3280
+ "this question does not join it. Another model starts without the "
3281
+ "conversation's prompt cache, so it pays for the whole prompt. "
3282
+ "/btw opens the answers."
3283
+ )
3284
+ else:
3285
+ self.view.note(
3286
+ "Asking beside the conversation: the turn keeps running and "
3287
+ "this question does not join it. /btw opens the answer."
3288
+ )
3289
+
3290
+ def _aside_work(self, question: str, options: dict, target: SideTarget | None = None):
3291
+ """The background run for one side question, streaming into its record.
3292
+
3293
+ `target` is resolved in the run, so a follow-up naming a model it
3294
+ cannot use fails in its thread, where it was asked.
3295
+ """
3296
+
3297
+ async def work(aside):
3298
+ def report(answer: str, activity: str) -> None:
3299
+ self.asides.update(aside, answer=answer, activity=activity)
3300
+
3301
+ chosen = options
3302
+ if target is not None:
3303
+ chosen = {**options, **await self.target_options(target)}
3304
+ return await self.runtime.aside(question, report=report, **chosen)
3305
+
3306
+ return work
3307
+
3308
+ def follow_up_aside(self, thread: str, question: str) -> None:
3309
+ """Ask `question` as a follow-up in a side question's thread.
3310
+
3311
+ It continues from the thread's newest answer, on the model and effort
3312
+ that answered it unless the question starts with a `$MODEL[+EFFORT]`
3313
+ or `+EFFORT` of its own, as `/btw` does; see `AgentRuntime.aside`.
3314
+ Raises `ValueError` when there is nothing to continue yet, or the
3315
+ target is unusable, which the viewer shows as is.
3316
+ """
3317
+ models, question = parse_models(question)
3318
+ if len(models) > 1:
3319
+ raise ValueError("A follow-up asks one model; start a new /btw to ask several.")
3320
+ follows = self.asides.follows(thread)
3321
+ target = self.follow_up_target(follows, models[0]) if models else None
3322
+ options = {"after": follows.reply}
3323
+ if target is not None:
3324
+ options["fresh"] = True
3325
+ self.asides.start(
3326
+ question,
3327
+ self._aside_work(question, options, target),
3328
+ model=target.model if target else follows.model,
3329
+ label=model_labels([target])[target] if target else follows.label,
3330
+ effort=target.effort if target else follows.effort,
3331
+ thread=thread,
3332
+ )
3333
+
3334
+ def follow_up_target(self, follows: Aside, target: SideTarget) -> SideTarget | None:
3335
+ """Where a follow-up naming `target` runs, read against its thread; `None` stays put.
3336
+
3337
+ A bare `+EFFORT` keeps the thread's model, and naming the
3338
+ conversation's model means it, not whatever the thread is on. Naming
3339
+ exactly where the thread already runs changes nothing, so the follow-up
3340
+ keeps the thread's cache rather than starting a new session.
3341
+ """
3342
+ model = target.model or follows.model
3343
+ target = SideTarget("" if model == self.model else model, target.effort)
3344
+ if (target.model, target.effort) == (follows.model, follows.effort):
3345
+ return None
3346
+ self.check_effort(target)
3347
+ return target
3348
+
3349
+ def check_bridge(self, thread: str) -> Aside:
3350
+ """The answer a thread would be brought into the conversation from.
3351
+
3352
+ Raises `ValueError` saying why it cannot be yet: like forking in
3353
+ /tree, changing the conversation waits for the running turn, and a
3354
+ thread from another conversation has nowhere here to go.
3355
+ """
3356
+ if self.activity.busy or self.activity.queued:
3357
+ raise ValueError("Adding to the conversation waits for the running turn")
3358
+ follows = self.asides.follows(thread)
3359
+ root = self.asides.thread(thread)[0]
3360
+ tree = getattr(self.runtime, "tree", None)
3361
+ if (
3362
+ tree is None
3363
+ or root.conversation != getattr(self.runtime, "conversation_id", None)
3364
+ or (root.base is not None and root.base not in tree.nodes)
3365
+ ):
3366
+ raise ValueError("This thread was asked in another conversation")
3367
+ return follows
3368
+
3369
+ async def merge_thread(self, thread: str) -> None:
3370
+ """Add a side thread to the conversation tree where it was asked."""
3371
+ from pcode.diagnostics import redact
3372
+
3373
+ follows = self.check_bridge(thread)
3374
+ asked = self.asides.thread(thread)
3375
+ messages = follows.reply.messages
3376
+ steps = [
3377
+ (aside.question, aside.answer, messages[:end])
3378
+ for aside, end in exchanges(asked, messages)
3379
+ ]
3380
+ if not steps:
3381
+ raise ValueError("Nothing in that side thread to merge.")
3382
+ moved = await self.runtime.merge_aside(steps, asked[0].base)
3383
+ follows.bridged = "merged"
3384
+ self.view.aside_changed(follows)
3385
+ count = f"{len(steps)} side question{'s' if len(steps) > 1 else ''}"
3386
+ if moved:
3387
+ for question, answer, _ in steps:
3388
+ self.view.user(redact(question))
3389
+ self.view.show_events((Message(redact(answer)),))
3390
+ self.view.note(
3391
+ f"Merged {count} into the conversation, which continues from the last answer."
3392
+ )
3393
+ else:
3394
+ self.view.note(
3395
+ f"Merged {count} into /tree as a branch where the thread was asked; the "
3396
+ "conversation stays where it is. /tree switches to it."
3397
+ )
3398
+
3399
+ async def summarize_thread(self, follows: Aside, instructions: str) -> None:
3400
+ """Add a summary of `follows`'s thread to the conversation, and show it."""
3401
+ from pcode.diagnostics import redact
3402
+
3403
+ asked = self.asides.thread(follows.thread)
3404
+ questions = [aside.question for aside, _ in exchanges(asked, follows.reply.messages)]
3405
+ request = summary_request(questions, instructions)
3406
+ summary = await self.runtime.summarize_aside(follows.reply, request, instructions)
3407
+ follows.bridged = "summarized"
3408
+ self.view.aside_changed(follows)
3409
+ self.view.user(redact(request))
3410
+ self.view.show_events((Message(redact(summary)),))
3411
+
3412
+ def aside_completions(self, argument: str):
3413
+ """Complete a `$MODEL` word in `/btw` arguments from the /model catalog.
3414
+
3415
+ After a `+`, bare or ending a `$MODEL` word, the /effort levels complete.
3416
+ """
3417
+ from prompt_toolkit.completion import Completion
3418
+
3419
+ effort = effort_fragment(argument)
3420
+ if effort is not None:
3421
+ for level in EFFORTS:
3422
+ if level.startswith(effort.casefold()):
3423
+ yield Completion(
3424
+ level, start_position=-len(effort), display=EFFORT_MARK + level
3425
+ )
3426
+ return
3427
+ fragment = model_fragment(argument)
3428
+ if fragment is None:
3429
+ return
3430
+ needle = fragment.casefold()
3431
+ for model in self.model_suggestions():
3432
+ if needle in model.casefold():
3433
+ yield Completion(
3434
+ MODEL_MARK + model,
3435
+ start_position=-(len(fragment) + len(MODEL_MARK)),
3436
+ display=model,
3437
+ )
3438
+
3439
+ def model_suggestions(self) -> list[str]:
3440
+ """The /model picker's catalog, kept briefly so typing does not re-scan it."""
3441
+ from time import monotonic
3442
+
3443
+ from pcode.models import active_providers, model_catalog
3444
+
3445
+ cached = self._model_suggestions
3446
+ if cached is None or cached[0] != self.model or monotonic() - cached[1] > 30:
3447
+ models = model_catalog(active_providers(self.model), self.model)
3448
+ cached = self._model_suggestions = (self.model, monotonic(), models)
3449
+ return cached[2]
3450
+
3451
+ def record_aside_failure(self, aside, error: BaseException) -> None:
3452
+ """Keep a failed side question's frames beside the session's turn failures.
3453
+
3454
+ The viewer shows only the error summary, and nothing about a side
3455
+ question is journaled, so without this its traceback is simply lost.
3456
+ """
3457
+ from pcode.diagnostics import provider_context
3458
+
3459
+ saved = getattr(self.runtime, "session", None)
3460
+ if saved is None:
3461
+ return
3462
+ agent = getattr(self.runtime, "agent", None)
3463
+ saved.record_error(
3464
+ error,
3465
+ run_id=f"aside {aside.id}",
3466
+ provider_context=(
3467
+ provider_context(aside.model or agent.model) if agent is not None else None
3468
+ ),
3469
+ detail=f"Side question ({aside.status}"
3470
+ + (f" on {aside.model}" if aside.model else "")
3471
+ + (f" at {aside.effort} effort" if aside.effort else "")
3472
+ + f"): {aside.question}",
3473
+ )
3474
+
3475
+ async def navigate_tree(self, identity: str | None, edit: bool = False) -> str:
3476
+ if self.activity.busy or self.activity.queued:
3477
+ raise ValueError("/tree is unavailable while working or messages are queued.")
3478
+ draft = await self.runtime.navigate(identity, edit=edit)
3479
+ self.activity.turn_outcome = ""
3480
+ self.view.show_branch()
3481
+ self.view.note(
3482
+ "Context switched; previous branches are kept. File changes and tool effects "
3483
+ "are not undone."
3484
+ )
3485
+ return draft
3486
+
3487
+ def command_failed(self, name: str, error: Exception) -> None:
3488
+ """Report a slash command that raised, with frames saved for diagnosis."""
3489
+ from pcode.diagnostics import stale_install
3490
+
3491
+ self.view.error(error_message(error, unexpected=f"{name} failed"))
3492
+ if hint := stale_install():
3493
+ self.view.warning(hint)
3494
+ saved = getattr(self.runtime, "session", None)
3495
+ if saved is not None and (path := saved.record_error(error, run_id=name)):
3496
+ self.view.note(f"Session and diagnostics: {path}")