返回 CodeWhale
command.rs
根目录 / crates / tui / src / extension_host / command.rs
1 //! Extension slash commands: owned registrations in the user command registry.
2 //!
3 //! A command mirrors a tool. The host proposes it with `registry/register`
4 //! (`kind = "command"`); [`super::registry::OwnerRegistry`] admits or refuses
5 //! it under the same owner/generation/handle rules; the admitted commands of
6 //! the owners an engine's workspace desires are loaded into the existing user
7 //! command registry (`commands::user_registry`, at the lowest
8 //! precedence, so a built-in or a markdown command always wins the spelling);
9 //! and a user invocation becomes `command/run` to the host. Revocation,
10 //! deactivation, crash and generation change remove the registrations the
11 //! same way they remove tools, and each change bumps [`epoch`] so the user
12 //! registry reloads.
13 //!
14 //! What a command can return, chosen from what the user-command machinery
15 //! already does: text shown to the user (a `System` transcript cell, like a
16 //! built-in's message) and/or a prompt submitted as the user's next message
17 //! (`AppAction::SendMessage`, like a markdown command's expanded template).
18 //! The host never calls the model or a tool itself: anything the model does
19 //! after a `submit` goes through the normal turn, tool approval included.
20 //! Invoking a command is the user's own action, so it needs no approval of
21 //! its own; it still re-checks that the owner is live, that its reviewed
22 //! receipt is current, and that the host is running before anything is sent.
23 //!
24 //! **The built-in command table is a port.** This module is runtime-side and
25 //! may not reach into `commands`, so registration asks
26 //! [`BuiltinCommandCatalog`] whether a built-in command answers to a name. The
27 //! commands side implements it (`commands::BuiltinCommandNames`) and the
28 //! composition root installs it once at startup ([`install_builtin_commands`]).
29 //! With none installed a command registration is refused, never accepted
30 //! unchecked.
31 //!
32 //! Known limitations:
33 //! * The UI event loop awaits the command (like `/balance`), bounded by
34 //! `SupervisionOptions::command_run_deadline` (30 s) and then cancelled with
35 //! `$/cancel`. A keypress does not cancel it earlier.
36 //! * Commands are TUI-only: the Runtime API's `GET /v1/commands` omits them
37 //! and cannot run them.
38 //! * A clash with a user, workspace or manifest (markdown) command is not a
39 //! registration-time refusal, because only the user registry knows the
40 //! workspace: the markdown command wins and the extension command is left
41 //! out with a load error.
42 //! * The handler gets no mutable agent or session handle, and no attachments. It is
43 //! told the workspace the command was loaded for (`ExtensionCommandRef`) and
44 //! its plugin's data directory, plus the invoking session id when known,
45 //! all read-only strings. User commands have no Engine turn or agent identity.
46 //! * A command's result text is bounded and stripped of terminal escapes; a
47 //! prompt over [`MAX_PROMPT_BYTES`] is refused, never truncated.
48
49 use std::sync::atomic::{AtomicU64, Ordering};
50 use std::sync::{Arc, OnceLock};
51
52 use serde_json::Value;
53
54 use super::ManagerShared;
55 use super::protocol::{CommandResultWire, CommandRunParams, CoreRequest};
56 use super::registry::CommandRegistration;
57 use super::supervisor::HostCallError;
58 use crate::plugins::types::PluginAuthority;
59
60 /// How long the user waits for one command before it is cancelled.
61 pub const COMMAND_RUN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
62 /// Longest result text shown; the rest is cut with a marker.
63 pub const MAX_TEXT_BYTES: usize = 64 * 1024;
64 /// Longest prompt a command may submit. Over this it is refused outright.
65 pub const MAX_PROMPT_BYTES: usize = 128 * 1024;
66
67 /// What registration needs to know about the built-in slash commands, and
68 /// nothing more: read-only, implemented by the commands side.
69 pub trait BuiltinCommandCatalog: Send + Sync {
70 /// Whether a built-in command answers to `name` (lower case): a canonical
71 /// name, an alias, or one of the mode aliases the dispatcher answers ahead
72 /// of the registry.
73 fn answers_to(&self, name: &str) -> bool;
74 }
75
76 static BUILTIN_COMMANDS: OnceLock<Arc<dyn BuiltinCommandCatalog>> = OnceLock::new();
77
78 /// Install the process's catalog, once at startup. The first install wins and
79 /// returns `true`; a later one is ignored.
80 pub fn install_builtin_commands(catalog: Arc<dyn BuiltinCommandCatalog>) -> bool {
81 BUILTIN_COMMANDS.set(catalog).is_ok()
82 }
83
84 /// The installed catalog. `None` means none was installed (or, in a test, the
85 /// thread asked for none): command registration is refused.
86 pub(crate) fn builtin_commands() -> Option<Arc<dyn BuiltinCommandCatalog>> {
87 #[cfg(test)]
88 if let Some(chosen) = TEST_CATALOG.with(|cell| cell.borrow().clone()) {
89 return chosen;
90 }
91 BUILTIN_COMMANDS.get().cloned()
92 }
93
94 #[cfg(test)]
95 thread_local! {
96 /// `Some(None)` is a thread that asked for no catalog at all.
97 static TEST_CATALOG: std::cell::RefCell<Option<Option<Arc<dyn BuiltinCommandCatalog>>>> =
98 const { std::cell::RefCell::new(None) };
99 }
100
101 /// Test-only: route [`builtin_commands`] on this thread to a catalog (or to
102 /// none) until dropped, restoring what was there before, so tests never touch
103 /// the process-wide install.
104 #[cfg(test)]
105 pub(crate) struct BuiltinCommandsGuard(Option<Option<Arc<dyn BuiltinCommandCatalog>>>);
106
107 #[cfg(test)]
108 impl BuiltinCommandsGuard {
109 pub(crate) fn install(catalog: Arc<dyn BuiltinCommandCatalog>) -> Self {
110 Self(TEST_CATALOG.with(|cell| cell.replace(Some(Some(catalog)))))
111 }
112
113 /// This thread has no catalog, whatever the process installed.
114 pub(crate) fn absent() -> Self {
115 Self(TEST_CATALOG.with(|cell| cell.replace(Some(None))))
116 }
117 }
118
119 #[cfg(test)]
120 impl Drop for BuiltinCommandsGuard {
121 fn drop(&mut self) {
122 TEST_CATALOG.with(|cell| *cell.borrow_mut() = self.0.take());
123 }
124 }
125
126 static EPOCH: AtomicU64 = AtomicU64::new(0);
127
128 /// Something that may have changed which commands are live: a registration,
129 /// a revocation, an activation, a host exit, an attachment's desired set.
130 /// Spurious bumps are harmless (one extra registry reload).
131 pub(crate) fn bump_epoch() {
132 EPOCH.fetch_add(1, Ordering::SeqCst);
133 }
134
135 /// The user registry reloads when this moves.
136 #[must_use]
137 pub(crate) fn epoch() -> u64 {
138 EPOCH.load(Ordering::SeqCst)
139 }
140
141 /// A reference to one admitted command, carried by the user-registry entry
142 /// and the dispatch action. It names the exact registration: a handle is
143 /// never reused, so a stale reference can only fail, never run a newer one.
144 /// It holds no owner token.
145 #[derive(Debug, Clone, PartialEq, Eq)]
146 pub struct ExtensionCommandRef {
147 pub handle: u64,
148 pub plugin_id: String,
149 pub generation: u64,
150 /// `extension:<plugin>`, the origin shown beside the command's output.
151 pub origin: String,
152 /// The workspace whose user registry loaded the command: what the handler
153 /// is told as the place the user ran it.
154 pub workspace: std::path::PathBuf,
155 pub selection: Option<super::composition_scope::SelectionRevision>,
156 pub scope: Option<super::protocol::EntryRef>,
157 pub content_hash: String,
158 }
159
160 /// One live command as the user registry loads it.
161 #[derive(Debug, Clone)]
162 pub struct ExtensionCommandEntry {
163 pub registration: CommandRegistration,
164 /// The owner's reviewed authority, so the user registry hides the command
165 /// the moment the plugin is disabled or loses trust.
166 pub authority: PluginAuthority,
167 /// The workspace this entry was loaded for.
168 pub workspace: std::path::PathBuf,
169 pub selection: Option<super::composition_scope::SelectionRevision>,
170 }
171
172 impl ExtensionCommandEntry {
173 #[must_use]
174 pub fn reference(&self) -> ExtensionCommandRef {
175 ExtensionCommandRef {
176 handle: self.registration.handle,
177 plugin_id: self.registration.owner.plugin_id.clone(),
178 generation: self.registration.owner.generation,
179 origin: format!("extension:{}", self.registration.plugin_name),
180 workspace: self.workspace.clone(),
181 selection: self.selection,
182 scope: self.registration.scope.clone(),
183 content_hash: self.registration.content_hash.clone(),
184 }
185 }
186 }
187
188 /// What a command asked the core to do with its answer.
189 #[derive(Debug, Clone, PartialEq, Eq)]
190 pub enum CommandOutcome {
191 /// Show `text` (possibly empty) to the user.
192 Show { text: String },
193 /// Submit `prompt` as the user's next message; show `note` beside it.
194 Submit {
195 prompt: String,
196 note: Option<String>,
197 },
198 }
199
200 fn strip_escapes(text: &str) -> String {
201 let mut clean = String::with_capacity(text.len());
202 codewhale_secrets::sanitize::strip_ansi_into(text, &mut clean);
203 clean
204 }
205
206 /// Strip terminal escapes and bound the text. A command's output is
207 /// plugin-controlled and goes straight into the transcript.
208 fn display_text(text: &str) -> String {
209 let mut clean = strip_escapes(text);
210 if clean.len() > MAX_TEXT_BYTES {
211 let mut end = MAX_TEXT_BYTES;
212 while !clean.is_char_boundary(end) {
213 end -= 1;
214 }
215 clean.truncate(end);
216 clean.push_str("\n… (output truncated)");
217 }
218 clean
219 }
220
221 /// The phrase shown after `/<name> (extension:<plugin>):` on failure.
222 fn map_call_error(error: HostCallError) -> String {
223 match error {
224 HostCallError::Cancelled(reason) => format!("cancelled: {reason}"),
225 HostCallError::Timeout { after, .. } => {
226 format!("timed out after {}s and was cancelled", after.as_secs())
227 }
228 HostCallError::Exited(reason) => format!("extension host is down: exited: {reason}"),
229 HostCallError::Busy => "extension host is busy; try again".to_string(),
230 HostCallError::Rpc { code, message } => {
231 if code == super::protocol::error_code::NOT_AVAILABLE {
232 format!("not available: {message}")
233 } else {
234 format!("failed: {message}")
235 }
236 }
237 }
238 }
239
240 /// Run one command for the user. Errors are the text to show as a failure.
241 pub(crate) async fn run(
242 shared: &ManagerShared,
243 command: &ExtensionCommandRef,
244 raw_input: &str,
245 session_id: Option<&str>,
246 ) -> Result<CommandOutcome, String> {
247 let bound = command
248 .selection
249 .and_then(|revision| shared.plugins_for_selection(revision, session_id));
250 let caller = bound.as_ref().map(|(plugins, _)| Arc::clone(plugins));
251 let agent_id = bound.and_then(|(_, agent_id)| agent_id);
252 shared.check_selection(
253 command.selection,
254 caller.as_deref(),
255 &command.plugin_id,
256 &command.content_hash,
257 command.scope.as_ref(),
258 )?;
259 let (host, registration) = shared.live_host_for_command(command).await?;
260 shared.check_selection(
261 command.selection,
262 caller.as_deref(),
263 &command.plugin_id,
264 &command.content_hash,
265 command.scope.as_ref(),
266 )?;
267 let deadline = shared.options.supervision.command_run_deadline;
268 let request = CoreRequest::CommandRun(CommandRunParams {
269 handle: registration.handle,
270 command_id: uuid::Uuid::new_v4().simple().to_string(),
271 raw_input: raw_input.to_string(),
272 deadline_ms: u64::try_from(deadline.as_millis()).unwrap_or(u64::MAX),
273 workspace: command.workspace.to_str().map(str::to_owned),
274 session_id: session_id.filter(|id| !id.is_empty()).map(str::to_owned),
275 agent_id,
276 origin_turn_id: None,
277 });
278 let value: Value = host
279 .call(request, Some(registration.owner.plugin_id.clone()))
280 .await
281 .map_err(map_call_error)?;
282 shared.check_selection(
283 command.selection,
284 caller.as_deref(),
285 &command.plugin_id,
286 &command.content_hash,
287 command.scope.as_ref(),
288 )?;
289 shared.live_host_for_command(command).await?;
290 shared.check_selection(
291 command.selection,
292 caller.as_deref(),
293 &command.plugin_id,
294 &command.content_hash,
295 command.scope.as_ref(),
296 )?;
297 let wire: CommandResultWire = serde_json::from_value(value)
298 .map_err(|error| format!("returned a malformed result: {error}"))?;
299 match wire {
300 CommandResultWire::Success { text } => Ok(CommandOutcome::Show {
301 text: text.as_deref().map(display_text).unwrap_or_default(),
302 }),
303 CommandResultWire::Error { text } => Err(display_text(&text)),
304 CommandResultWire::Submit { prompt, text } => {
305 let prompt = strip_escapes(&prompt);
306 if prompt.trim().is_empty() {
307 return Err("submitted an empty prompt".to_string());
308 }
309 if prompt.len() > MAX_PROMPT_BYTES {
310 return Err(format!(
311 "returned a prompt of {} bytes, over the {MAX_PROMPT_BYTES}-byte limit; it was not submitted",
312 prompt.len()
313 ));
314 }
315 Ok(CommandOutcome::Submit {
316 prompt,
317 note: text.as_deref().map(display_text).filter(|t| !t.is_empty()),
318 })
319 }
320 }
321 }
322
322 lines RUST