pcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/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}")
|