Add installable agent lifecycle extensions

This commit is contained in:
Georg Bauer
2026-08-30 16:05:09 +02:00
parent 1ac559bbd0
commit 3977261bd3
10 changed files with 2838 additions and 61 deletions

8
Cargo.lock generated
View File

@@ -817,6 +817,7 @@ dependencies = [
"headless_chrome", "headless_chrome",
"iced", "iced",
"image", "image",
"libc",
"memmap2", "memmap2",
"muda", "muda",
"png 0.17.16", "png 0.17.16",
@@ -1198,6 +1199,7 @@ dependencies = [
"libc", "libc",
"libgit2-sys", "libgit2-sys",
"log", "log",
"openssl-probe",
"openssl-sys", "openssl-sys",
"url", "url",
] ]
@@ -2558,6 +2560,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]] [[package]]
name = "openssl-src" name = "openssl-src"
version = "300.6.1+3.6.3" version = "300.6.1+3.6.3"

View File

@@ -14,10 +14,11 @@ cc = "1.3.0"
[dependencies] [dependencies]
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] } diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] }
diesel_migrations = "2.3.2" diesel_migrations = "2.3.2"
git2 = { version = "0.21.0", features = ["cred", "vendored-libgit2", "vendored-openssl"] } git2 = { version = "0.21.0", features = ["https", "vendored-libgit2", "vendored-openssl"] }
headless_chrome = "1.0.22" headless_chrome = "1.0.22"
iced = { version = "0.14.0", default-features = false, features = ["advanced", "image-without-codecs", "markdown", "svg", "tokio", "wgpu"] } iced = { version = "0.14.0", default-features = false, features = ["advanced", "image-without-codecs", "markdown", "svg", "tokio", "wgpu"] }
image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] } image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
libc = "0.2.186"
memmap2 = "0.9.11" memmap2 = "0.9.11"
png = "0.17.16" png = "0.17.16"
regex = "1.13.1" regex = "1.13.1"

View File

@@ -40,10 +40,10 @@ struct AgentSkillMetadata {
description: String, description: String,
} }
struct AgentSkill { pub(crate) struct AgentSkill {
name: String, pub(crate) name: String,
description: String, pub(crate) description: String,
path: PathBuf, pub(crate) path: PathBuf,
} }
fn agent_skills_root(home: Option<&Path>) -> Option<PathBuf> { fn agent_skills_root(home: Option<&Path>) -> Option<PathBuf> {
@@ -76,7 +76,7 @@ fn skill_frontmatter(content: &str) -> Option<&str> {
Some(content[..end].trim_end_matches('\r')) Some(content[..end].trim_end_matches('\r'))
} }
fn discover_agent_skills(root: &Path) -> Vec<AgentSkill> { pub(crate) fn discover_agent_skills(root: &Path) -> Vec<AgentSkill> {
let Ok(root) = root.canonicalize() else { let Ok(root) = root.canonicalize() else {
return Vec::new(); return Vec::new();
}; };
@@ -109,8 +109,17 @@ fn discover_agent_skills(root: &Path) -> Vec<AgentSkill> {
skills skills
} }
#[cfg(test)]
fn agent_skills_prompt_for(root: &Path) -> Option<String> { fn agent_skills_prompt_for(root: &Path) -> Option<String> {
let skills = discover_agent_skills(root); agent_skills_prompt_for_roots(std::iter::once(root))
}
fn agent_skills_prompt_for_roots<'a>(roots: impl IntoIterator<Item = &'a Path>) -> Option<String> {
let mut skills = roots
.into_iter()
.flat_map(discover_agent_skills)
.collect::<Vec<_>>();
skills.sort_by(|left, right| left.name.cmp(&right.name));
if skills.is_empty() { if skills.is_empty() {
return None; return None;
} }
@@ -131,9 +140,14 @@ fn agent_skills_prompt_for(root: &Path) -> Option<String> {
)) ))
} }
pub(crate) fn agent_skills_prompt() -> Option<String> { pub(crate) fn agent_skills_prompt_with(extension_roots: &[PathBuf]) -> Option<String> {
let root = agent_skills_root(std::env::var_os("HOME").as_deref().map(Path::new))?; let standard = agent_skills_root(std::env::var_os("HOME").as_deref().map(Path::new));
agent_skills_prompt_for(&root) agent_skills_prompt_for_roots(
standard
.iter()
.map(PathBuf::as_path)
.chain(extension_roots.iter().map(PathBuf::as_path)),
)
} }
fn user_shell() -> OsString { fn user_shell() -> OsString {
@@ -1526,6 +1540,8 @@ struct RalphRuntime {
engine: EngineSettings, engine: EngineSettings,
turn: TurnSettings, turn: TurnSettings,
idle_timeout: Duration, idle_timeout: Duration,
extensions: crate::extensions::ExtensionRegistry,
session_id: i32,
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -1612,6 +1628,17 @@ impl Tools {
Ok(()) Ok(())
} }
pub(crate) fn enable_agent_skill_roots(&mut self, roots: &[PathBuf]) {
self.agent_skill_roots.extend(
roots
.iter()
.flat_map(|root| discover_agent_skills(root))
.filter_map(|skill| skill.path.parent().map(Path::to_owned)),
);
self.agent_skill_roots.sort();
self.agent_skill_roots.dedup();
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub(crate) fn enable_ralph( pub(crate) fn enable_ralph(
&mut self, &mut self,
@@ -1620,13 +1647,17 @@ impl Tools {
engine: EngineSettings, engine: EngineSettings,
turn: TurnSettings, turn: TurnSettings,
idle_timeout: Duration, idle_timeout: Duration,
extension_context: (crate::extensions::ExtensionRegistry, i32),
) { ) {
let (extensions, session_id) = extension_context;
self.ralph = Some(RalphRuntime { self.ralph = Some(RalphRuntime {
service, service,
model, model,
engine, engine,
turn, turn,
idle_timeout, idle_timeout,
extensions,
session_id,
}); });
} }
@@ -1786,6 +1817,53 @@ impl Tools {
// A unique cache namespace preserves tool continuations inside this // A unique cache namespace preserves tool continuations inside this
// round while preventing parent or earlier-round KV restoration. // round while preventing parent or earlier-round KV restoration.
let directory = RalphRoundDirectory::create(round)?; let directory = RalphRoundDirectory::create(round)?;
let mut round_turn = runtime.turn.clone();
let hooks = runtime.extensions.dispatch(
&[crate::extensions::HookEvent::SubagentStart {
session_id: runtime.session_id,
round,
}],
&self.root,
&runtime.model.to_string(),
cancel,
);
let _ = runtime.extensions.persist_hook_results(&hooks);
if let Some(status) = hooks.status() {
send_state(
events,
index,
ToolLifecycle::Running,
Some(format!("Ralph round {round}/{max_rounds} · {status}\n")),
);
}
if !hooks.errors.is_empty() {
send_state(
events,
index,
ToolLifecycle::Running,
Some(format!(
"Ralph round {round}/{max_rounds} · extension hook warning: {}\n",
hooks
.errors
.iter()
.map(|(id, error)| format!("{id}: {error}"))
.collect::<Vec<_>>()
.join("; ")
)),
);
}
for output in &hooks.outputs {
if let Some(context) = &output.additional_context {
round_turn.system_prompt.push_str("\n\n");
round_turn
.system_prompt
.push_str(&crate::extensions::wrap_context(
&output.extension_id,
&output.event,
context,
));
}
}
let mut messages = vec![ChatTurn { let mut messages = vec![ChatTurn {
user: true, user: true,
tool: false, tool: false,
@@ -1804,7 +1882,8 @@ impl Tools {
"Ralph round {round}/{max_rounds} · child generation {step}\n" "Ralph round {round}/{max_rounds} · child generation {step}\n"
)), )),
); );
let output = run_ralph_generation(runtime, &messages, &directory.0, cancel)?; let output =
run_ralph_generation(runtime, &round_turn, &messages, &directory.0, cancel)?;
let calls = parse_tool_calls(runtime.model, &output.message.content) let calls = parse_tool_calls(runtime.model, &output.message.content)
.map_err(|error| format!("malformed child tool syntax: {error}"))? .map_err(|error| format!("malformed child tool syntax: {error}"))?
.1; .1;
@@ -2622,13 +2701,14 @@ impl Tools {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn run_ralph_generation( fn run_ralph_generation(
runtime: &RalphRuntime, runtime: &RalphRuntime,
turn: &TurnSettings,
messages: &[ChatTurn], messages: &[ChatTurn],
checkpoint: &Path, checkpoint: &Path,
cancel: &AtomicBool, cancel: &AtomicBool,
) -> Result<crate::engine::GenerationOutput, String> { ) -> Result<crate::engine::GenerationOutput, String> {
let active = runtime.service.generate( let active = runtime.service.generate(
runtime.engine.clone(), runtime.engine.clone(),
runtime.turn.clone(), turn.clone(),
messages.to_vec(), messages.to_vec(),
CheckpointTarget::Transient(checkpoint.to_owned()), CheckpointTarget::Transient(checkpoint.to_owned()),
WorkSource::LocalChat, WorkSource::LocalChat,

View File

@@ -1,3 +1,4 @@
mod extensions;
mod generation; mod generation;
mod git; mod git;
mod model_manager; mod model_manager;
@@ -37,7 +38,7 @@ use crate::settings::{
use iced::widget::{markdown, scrollable, text_editor}; use iced::widget::{markdown, scrollable, text_editor};
use iced::{Size, Subscription, Task, keyboard, mouse, window}; use iced::{Size, Subscription, Task, keyboard, mouse, window};
use rfd::AsyncFileDialog; use rfd::AsyncFileDialog;
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -73,6 +74,13 @@ pub(crate) struct App {
config: Config, config: Config,
preference_draft: PreferenceDraft, preference_draft: PreferenceDraft,
preference_error: Option<String>, preference_error: Option<String>,
pub(super) extensions: crate::extensions::ExtensionRegistry,
pub(super) extension_source: String,
pub(super) extension_ref: String,
pub(super) extension_error: Option<String>,
extension_operation: Option<ActiveExtensionOperation>,
pub(super) pending_extension_trust: Option<String>,
pub(super) pending_extension_uninstall: Option<String>,
restore_dev_brain_confirmation: bool, restore_dev_brain_confirmation: bool,
selected_project: Option<i32>, selected_project: Option<i32>,
selected_session: Option<i32>, selected_session: Option<i32>,
@@ -150,6 +158,8 @@ pub(crate) struct App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_tool_check: Option<generation::ToolResultCheck>, active_tool_check: Option<generation::ToolResultCheck>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_extension_hooks: Option<generation::ExtensionHookRequest>,
#[cfg(target_os = "macos")]
agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>, agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_tools: Option<crate::agent::ActiveTools>, active_tools: Option<crate::agent::ActiveTools>,
@@ -206,6 +216,7 @@ struct ChatSnapshot {
active_generation: Option<ActiveGeneration>, active_generation: Option<ActiveGeneration>,
active_compaction: Option<generation::CompactionRequest>, active_compaction: Option<generation::CompactionRequest>,
active_tool_check: Option<generation::ToolResultCheck>, active_tool_check: Option<generation::ToolResultCheck>,
active_extension_hooks: Option<generation::ExtensionHookRequest>,
agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>, agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>,
active_tools: Option<crate::agent::ActiveTools>, active_tools: Option<crate::agent::ActiveTools>,
tool_cards: Vec<crate::agent::ToolCard>, tool_cards: Vec<crate::agent::ToolCard>,
@@ -260,6 +271,7 @@ pub(super) enum PreferenceSection {
Model, Model,
Endpoint, Endpoint,
DevBrain, DevBrain,
Extensions,
Git, Git,
Prompt, Prompt,
Generation, Generation,
@@ -270,10 +282,11 @@ pub(super) enum PreferenceSection {
} }
impl PreferenceSection { impl PreferenceSection {
const ALL: [Self; 10] = [ const ALL: [Self; 11] = [
Self::Model, Self::Model,
Self::Endpoint, Self::Endpoint,
Self::DevBrain, Self::DevBrain,
Self::Extensions,
Self::Git, Self::Git,
Self::Prompt, Self::Prompt,
Self::Generation, Self::Generation,
@@ -288,6 +301,7 @@ impl PreferenceSection {
Self::Model => "preferences-model", Self::Model => "preferences-model",
Self::Endpoint => "preferences-endpoint", Self::Endpoint => "preferences-endpoint",
Self::DevBrain => "preferences-dev-brain", Self::DevBrain => "preferences-dev-brain",
Self::Extensions => "preferences-extensions",
Self::Git => "preferences-git", Self::Git => "preferences-git",
Self::Prompt => "preferences-prompt", Self::Prompt => "preferences-prompt",
Self::Generation => "preferences-generation", Self::Generation => "preferences-generation",
@@ -303,6 +317,7 @@ impl PreferenceSection {
Self::Model => "Model & lifecycle", Self::Model => "Model & lifecycle",
Self::Endpoint => "Local endpoint", Self::Endpoint => "Local endpoint",
Self::DevBrain => "Dev Brain", Self::DevBrain => "Dev Brain",
Self::Extensions => "Agent extensions",
Self::Git => "Git diffs", Self::Git => "Git diffs",
Self::Prompt => "Prompt", Self::Prompt => "Prompt",
Self::Generation => "Generation", Self::Generation => "Generation",
@@ -368,6 +383,17 @@ pub(crate) enum Message {
PreferenceEndpointCorsChanged(bool), PreferenceEndpointCorsChanged(bool),
PreferenceDevBrainEnabledChanged(bool), PreferenceDevBrainEnabledChanged(bool),
PreferenceDevBrainVaultChanged(String), PreferenceDevBrainVaultChanged(String),
PreferenceExtensionSourceChanged(String),
PreferenceExtensionRefChanged(String),
InstallExtension,
UpdateExtension(String),
ToggleExtension(String, bool),
ConfirmExtensionTrust,
CancelExtensionTrust,
RequestUninstallExtension(String),
ConfirmUninstallExtension,
CancelUninstallExtension,
ExtensionOperationTick,
PreferenceGitDiffLayoutChanged(GitDiffLayout), PreferenceGitDiffLayoutChanged(GitDiffLayout),
PreferenceGitDiffAlgorithmChanged(GitDiffAlgorithm), PreferenceGitDiffAlgorithmChanged(GitDiffAlgorithm),
PreferenceGitContextLinesChanged(String), PreferenceGitContextLinesChanged(String),
@@ -497,6 +523,11 @@ pub(crate) enum Message {
ClearTransientCache, ClearTransientCache,
} }
pub(super) struct ActiveExtensionOperation {
label: String,
receiver: mpsc::Receiver<Result<crate::extensions::ExtensionRegistry, String>>,
}
impl App { impl App {
pub(crate) fn load(main_window: window::Id) -> Self { pub(crate) fn load(main_window: window::Id) -> Self {
let config = match Config::load(&config_path()) { let config = match Config::load(&config_path()) {
@@ -528,6 +559,15 @@ impl App {
Arc::new(Metrics::new(&application_support_path().join("kv-cache"))); Arc::new(Metrics::new(&application_support_path().join("kv-cache")));
let metrics_snapshot = metrics.snapshot(); let metrics_snapshot = metrics.snapshot();
let git_diff_layout = config.git.diff_layout; let git_diff_layout = config.git.diff_layout;
let extension_root = extensions_path();
let (extensions, extension_error) =
match crate::extensions::ExtensionRegistry::load(&extension_root) {
Ok(extensions) => (extensions, None),
Err(error) => (
crate::extensions::ExtensionRegistry::empty(&extension_root),
Some(error),
),
};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let (runtime_config, generation_service, endpoint, service_error) = let (runtime_config, generation_service, endpoint, service_error) =
spawn_services(&config, Arc::clone(&metrics)); spawn_services(&config, Arc::clone(&metrics));
@@ -548,6 +588,13 @@ impl App {
config, config,
preference_draft, preference_draft,
preference_error: None, preference_error: None,
extensions,
extension_source: String::new(),
extension_ref: String::new(),
extension_error,
extension_operation: None,
pending_extension_trust: None,
pending_extension_uninstall: None,
restore_dev_brain_confirmation: false, restore_dev_brain_confirmation: false,
selected_project: last_project, selected_project: last_project,
selected_session: None, selected_session: None,
@@ -617,6 +664,7 @@ impl App {
active_compaction: None, active_compaction: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_tool_check: None, active_tool_check: None,
active_extension_hooks: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
agent_tools: None, agent_tools: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -671,6 +719,15 @@ impl App {
let metrics = Arc::new(Metrics::new(&application_support_path().join("kv-cache"))); let metrics = Arc::new(Metrics::new(&application_support_path().join("kv-cache")));
let metrics_snapshot = metrics.snapshot(); let metrics_snapshot = metrics.snapshot();
let git_diff_layout = config.git.diff_layout; let git_diff_layout = config.git.diff_layout;
let extension_root = extensions_path();
let (extensions, extension_error) =
match crate::extensions::ExtensionRegistry::load(&extension_root) {
Ok(extensions) => (extensions, None),
Err(error) => (
crate::extensions::ExtensionRegistry::empty(&extension_root),
Some(error),
),
};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let (runtime_config, generation_service, endpoint, service_error) = let (runtime_config, generation_service, endpoint, service_error) =
spawn_services(&config, Arc::clone(&metrics)); spawn_services(&config, Arc::clone(&metrics));
@@ -695,6 +752,13 @@ impl App {
config, config,
preference_draft, preference_draft,
preference_error: None, preference_error: None,
extensions,
extension_source: String::new(),
extension_ref: String::new(),
extension_error,
extension_operation: None,
pending_extension_trust: None,
pending_extension_uninstall: None,
restore_dev_brain_confirmation: false, restore_dev_brain_confirmation: false,
selected_project: None, selected_project: None,
selected_session: None, selected_session: None,
@@ -762,6 +826,7 @@ impl App {
active_compaction: None, active_compaction: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
active_tool_check: None, active_tool_check: None,
active_extension_hooks: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
agent_tools: None, agent_tools: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -826,6 +891,7 @@ impl App {
active_generation: self.active_generation.take(), active_generation: self.active_generation.take(),
active_compaction: self.active_compaction.take(), active_compaction: self.active_compaction.take(),
active_tool_check: self.active_tool_check.take(), active_tool_check: self.active_tool_check.take(),
active_extension_hooks: self.active_extension_hooks.take(),
agent_tools: self.agent_tools.take(), agent_tools: self.agent_tools.take(),
active_tools: self.active_tools.take(), active_tools: self.active_tools.take(),
tool_cards: std::mem::take(&mut self.tool_cards), tool_cards: std::mem::take(&mut self.tool_cards),
@@ -872,6 +938,7 @@ impl App {
self.active_generation = snapshot.active_generation; self.active_generation = snapshot.active_generation;
self.active_compaction = snapshot.active_compaction; self.active_compaction = snapshot.active_compaction;
self.active_tool_check = snapshot.active_tool_check; self.active_tool_check = snapshot.active_tool_check;
self.active_extension_hooks = snapshot.active_extension_hooks;
self.agent_tools = snapshot.agent_tools; self.agent_tools = snapshot.agent_tools;
self.active_tools = snapshot.active_tools; self.active_tools = snapshot.active_tools;
self.tool_cards = snapshot.tool_cards; self.tool_cards = snapshot.tool_cards;
@@ -991,6 +1058,8 @@ impl App {
if let Some(id) = self.preferences_window { if let Some(id) = self.preferences_window {
self.preference_error = None; self.preference_error = None;
self.restore_dev_brain_confirmation = false; self.restore_dev_brain_confirmation = false;
self.pending_extension_trust = None;
self.pending_extension_uninstall = None;
return window::close(id); return window::close(id);
} }
} }
@@ -1083,6 +1152,8 @@ impl App {
self.preferences_window = None; self.preferences_window = None;
self.preference_error = None; self.preference_error = None;
self.restore_dev_brain_confirmation = false; self.restore_dev_brain_confirmation = false;
self.pending_extension_trust = None;
self.pending_extension_uninstall = None;
} }
if self.help_window == Some(id) { if self.help_window == Some(id) {
self.help_window = None; self.help_window = None;
@@ -1411,6 +1482,10 @@ impl App {
if let Some(check) = &self.active_tool_check { if let Some(check) = &self.active_tool_check {
check.active.cancel.store(true, Ordering::Relaxed); check.active.cancel.store(true, Ordering::Relaxed);
} }
#[cfg(target_os = "macos")]
if let Some(hooks) = &self.active_extension_hooks {
hooks.cancel.store(true, Ordering::Relaxed);
}
} }
Message::GenerationTick => { Message::GenerationTick => {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -1837,6 +1912,8 @@ impl App {
self.permission_mode = permission_mode; self.permission_mode = permission_mode;
self.error = restore_error; self.error = restore_error;
self.reload_projects(); self.reload_projects();
#[cfg(target_os = "macos")]
self.start_resume_extension_hooks();
return Task::batch([scroll_chat_to_end(), self.load_next_a2ui_image()]); return Task::batch([scroll_chat_to_end(), self.load_next_a2ui_image()]);
} }
Err(error) => { Err(error) => {
@@ -1977,6 +2054,12 @@ impl App {
iced::time::every(Duration::from_millis(100)).map(|_| Message::GitOperationTick), iced::time::every(Duration::from_millis(100)).map(|_| Message::GitOperationTick),
); );
} }
if self.extension_operation.is_some() {
subscriptions.push(
iced::time::every(Duration::from_millis(100))
.map(|_| Message::ExtensionOperationTick),
);
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let titling = self.active_titling.is_some(); let titling = self.active_titling.is_some();
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
@@ -2116,6 +2199,7 @@ impl App {
} }
fn quit(&mut self) -> Task<Message> { fn quit(&mut self) -> Task<Message> {
self.cancel_running_extension_hooks();
if self.database.is_none() { if self.database.is_none() {
return iced::exit(); return iced::exit();
} }
@@ -2403,6 +2487,10 @@ pub(crate) fn browser_profile_path() -> PathBuf {
application_support_path().join("browser") application_support_path().join("browser")
} }
pub(crate) fn extensions_path() -> PathBuf {
application_support_path().join("extensions")
}
/// The settings file, beside the project database. /// The settings file, beside the project database.
pub(crate) fn config_path() -> PathBuf { pub(crate) fn config_path() -> PathBuf {
application_support_path().join("config.yaml") application_support_path().join("config.yaml")

159
src/app/extensions.rs Normal file
View File

@@ -0,0 +1,159 @@
use super::*;
impl App {
pub(super) fn install_extension(&mut self) {
let source = self.extension_source.trim().to_owned();
let requested_ref = self.extension_ref.trim().to_owned();
if source.is_empty() {
self.extension_error = Some("Enter an HTTPS Git repository URL.".into());
return;
}
self.cancel_running_extension_hooks();
let root = extensions_path();
self.start_extension_operation("Installing extension", move || {
crate::extensions::ExtensionRegistry::install(
&root,
&source,
(!requested_ref.is_empty()).then_some(requested_ref.as_str()),
)
});
}
pub(super) fn update_extension(&mut self, id: String) {
self.cancel_running_extension_hooks();
let root = extensions_path();
self.start_extension_operation("Updating extension", move || {
crate::extensions::ExtensionRegistry::update(&root, &id)
});
}
pub(super) fn toggle_extension(&mut self, id: String, enabled: bool) {
let Some(extension) = self
.extensions
.extensions
.iter()
.find(|extension| extension.id == id)
else {
return;
};
if enabled && extension.has_commands() && !extension.trusted {
self.pending_extension_trust = Some(id);
return;
}
self.cancel_running_extension_hooks();
let root = extensions_path();
self.start_extension_operation(
if enabled {
"Enabling extension"
} else {
"Disabling extension"
},
move || crate::extensions::ExtensionRegistry::set_enabled(&root, &id, enabled),
);
}
pub(super) fn confirm_extension_trust(&mut self) {
let Some(id) = self.pending_extension_trust.take() else {
return;
};
self.cancel_running_extension_hooks();
let root = extensions_path();
self.start_extension_operation("Enabling trusted extension", move || {
crate::extensions::ExtensionRegistry::trust_and_enable(&root, &id)
});
}
pub(super) fn confirm_uninstall_extension(&mut self) {
let Some(id) = self.pending_extension_uninstall.take() else {
return;
};
self.cancel_running_extension_hooks();
let root = extensions_path();
self.start_extension_operation("Uninstalling extension", move || {
crate::extensions::ExtensionRegistry::uninstall(&root, &id)
});
}
fn start_extension_operation(
&mut self,
label: &str,
operation: impl FnOnce() -> Result<crate::extensions::ExtensionRegistry, String>
+ Send
+ 'static,
) {
if self.extension_operation.is_some() {
self.extension_error = Some("Another extension operation is already running.".into());
return;
}
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let _ = sender.send(operation());
});
self.extension_operation = Some(ActiveExtensionOperation {
label: label.into(),
receiver,
});
self.extension_error = None;
}
pub(super) fn poll_extension_operation(&mut self) {
let Some(operation) = &self.extension_operation else {
return;
};
match operation.receiver.try_recv() {
Ok(Ok(extensions)) => {
self.extensions = extensions;
self.extension_operation = None;
self.extension_source.clear();
self.extension_ref.clear();
self.extension_error = None;
#[cfg(target_os = "macos")]
{
self.agent_tools = None;
}
}
Ok(Err(error)) => {
self.extension_operation = None;
self.extension_error = Some(error);
}
Err(TryRecvError::Empty) => {}
Err(TryRecvError::Disconnected) => {
self.extension_operation = None;
self.extension_error = Some("The extension operation stopped unexpectedly.".into());
}
}
}
pub(super) fn extension_operation_label(&self) -> Option<&str> {
self.extension_operation
.as_ref()
.map(|operation| operation.label.as_str())
}
pub(super) fn cancel_running_extension_hooks(&mut self) {
#[cfg(target_os = "macos")]
{
let requests = self
.active_extension_hooks
.iter()
.chain(
self.background_chats
.values()
.filter_map(|chat| chat.active_extension_hooks.as_ref()),
)
.map(|request| (Arc::clone(&request.cancel), Arc::clone(&request.finished)))
.collect::<Vec<_>>();
for (cancel, _) in &requests {
cancel.store(true, Ordering::Relaxed);
}
let deadline = Instant::now() + Duration::from_secs(1);
while requests
.iter()
.any(|(_, finished)| !finished.load(Ordering::Acquire))
&& Instant::now() < deadline
{
thread::sleep(Duration::from_millis(10));
}
}
}
}

View File

@@ -27,6 +27,21 @@ pub(super) enum PendingContinuation {
DurableTool(Vec<PathBuf>), DurableTool(Vec<PathBuf>),
} }
#[cfg(target_os = "macos")]
pub(super) enum HookContinuation {
User { prompt: String, opening_turn: bool },
Resume,
Compaction(PendingContinuation),
}
#[cfg(target_os = "macos")]
pub(super) struct ExtensionHookRequest {
pub(super) cancel: Arc<AtomicBool>,
pub(super) finished: Arc<AtomicBool>,
receiver: mpsc::Receiver<Result<crate::extensions::HookBatchResult, String>>,
continuation: HookContinuation,
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub(super) struct CompactionRequest { pub(super) struct CompactionRequest {
pub(super) active: ActiveGeneration, pub(super) active: ActiveGeneration,
@@ -369,6 +384,34 @@ fn has_chat_after_last_compaction(messages: &[ChatMessage]) -> bool {
}) })
} }
fn extension_context_visibility(messages: &[ChatMessage], enabled: &BTreeSet<String>) -> Vec<bool> {
let mut latest_session_context = BTreeMap::new();
for (index, message) in messages.iter().enumerate() {
if let Some((id, "SessionStart")) = message
.system
.then(|| crate::extensions::context_identity(&message.content))
.flatten()
&& enabled.contains(id)
{
latest_session_context.insert(id.to_owned(), index);
}
}
messages
.iter()
.enumerate()
.map(|(index, message)| {
!message.system
|| crate::extensions::context_identity(&message.content).is_none_or(
|(id, event)| {
enabled.contains(id)
&& (event != "SessionStart"
|| latest_session_context.get(id) == Some(&index))
},
)
})
.collect()
}
#[cfg(any(target_os = "macos", test))] #[cfg(any(target_os = "macos", test))]
fn title_context(messages: impl IntoIterator<Item = ChatTurn>) -> Vec<ChatTurn> { fn title_context(messages: impl IntoIterator<Item = ChatTurn>) -> Vec<ChatTurn> {
let mut started = false; let mut started = false;
@@ -384,13 +427,24 @@ fn title_context(messages: impl IntoIterator<Item = ChatTurn>) -> Vec<ChatTurn>
} }
impl App { impl App {
fn agent_skills_prompt(&self) -> Option<String> {
let roots = self
.extensions
.enabled_skill_roots()
.unwrap_or_default()
.into_iter()
.map(|(_, root)| root)
.collect::<Vec<_>>();
crate::agent::agent_skills_prompt_with(&roots)
}
fn chat_system_prompt(&self, model: ModelChoice, prompt: &str) -> String { fn chat_system_prompt(&self, model: ModelChoice, prompt: &str) -> String {
let mut prompt = crate::agent::system_prompt(model, prompt, self.config.dev_brain.enabled); let mut prompt = crate::agent::system_prompt(model, prompt, self.config.dev_brain.enabled);
if self.config.a2ui_enabled { if self.config.a2ui_enabled {
prompt.push_str("\n\n"); prompt.push_str("\n\n");
prompt.push_str(crate::a2ui::SYSTEM_PROMPT); prompt.push_str(crate::a2ui::SYSTEM_PROMPT);
} }
if let Some(skills) = crate::agent::agent_skills_prompt() { if let Some(skills) = self.agent_skills_prompt() {
prompt.push_str("\n\n"); prompt.push_str("\n\n");
prompt.push_str(&skills); prompt.push_str(&skills);
} }
@@ -516,6 +570,74 @@ impl App {
} }
return; return;
} }
#[cfg(target_os = "macos")]
{
let preview_session_id = self.selected_session.unwrap_or_default();
let mut events = Vec::with_capacity(2);
if opening_turn {
events.push(crate::extensions::HookEvent::SessionStart {
session_id: preview_session_id,
reason: "startup",
});
}
events.push(crate::extensions::HookEvent::UserPromptSubmit {
session_id: preview_session_id,
prompt: prompt.clone(),
});
if self.extensions.has_hooks_for(&events) {
self.composer = text_editor::Content::new();
let session_id = match self.selected_session {
Some(session_id) => session_id,
None => {
let Some(project_id) = self.selected_project else {
return;
};
match self.persist_session(project_id) {
Ok(session_id) => session_id,
Err(error) => {
self.error = Some(format!("Could not create the session: {error}"));
return;
}
}
}
};
for event in &mut events {
match event {
crate::extensions::HookEvent::SessionStart {
session_id: event_session,
..
}
| crate::extensions::HookEvent::UserPromptSubmit {
session_id: event_session,
..
}
| crate::extensions::HookEvent::SubagentStart {
session_id: event_session,
..
} => *event_session = session_id,
}
}
self.start_extension_hooks(
events,
HookContinuation::User {
prompt,
opening_turn,
},
);
} else {
self.start_generation_after_hooks(prompt, opening_turn, Vec::new());
}
}
#[cfg(not(target_os = "macos"))]
self.start_generation_after_hooks(prompt, false, Vec::new());
}
fn start_generation_after_hooks(
&mut self,
prompt: String,
opening_turn: bool,
hook_contexts: Vec<SystemMessage>,
) {
let model = self.config.model; let model = self.config.model;
let generation = self.config.active_generation(); let generation = self.config.active_generation();
let runtime = self.config.runtime_for(model); let runtime = self.config.runtime_for(model);
@@ -553,6 +675,8 @@ impl App {
injected_system.push(SystemMessage::plain(crate::agent::datetime_context())); injected_system.push(SystemMessage::plain(crate::agent::datetime_context()));
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
injected_system.extend(hook_contexts);
#[cfg(target_os = "macos")]
let reminder_injected = self.system_prompt_reminder_due(); let reminder_injected = self.system_prompt_reminder_due();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if reminder_injected { if reminder_injected {
@@ -596,7 +720,6 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// A draft session only reaches the database once there is a turn to store.
let session_id = match self.selected_session { let session_id = match self.selected_session {
Some(session_id) => session_id, Some(session_id) => session_id,
None => { None => {
@@ -707,6 +830,211 @@ impl App {
} }
} }
#[cfg(target_os = "macos")]
fn start_extension_hooks(
&mut self,
events: Vec<crate::extensions::HookEvent>,
continuation: HookContinuation,
) {
if !self.extensions.has_hooks_for(&events) {
match continuation {
HookContinuation::User {
prompt,
opening_turn,
} => {
let restore = prompt.clone();
self.start_generation_after_hooks(prompt, opening_turn, Vec::new());
if !self.generating && self.composer.text().trim().is_empty() {
self.composer = text_editor::Content::with_text(&restore);
}
}
HookContinuation::Resume => {}
HookContinuation::Compaction(pending) => {
self.finish_compaction_continuation(pending)
}
}
return;
}
let Some(root) = self
.projects
.iter()
.find(|project| Some(project.project.id) == self.selected_project)
.map(|project| PathBuf::from(&project.project.path))
else {
self.error = Some("The active project is unavailable.".into());
self.generating = false;
return;
};
let registry = self.extensions.clone();
let activity = registry
.status_for(&events)
.unwrap_or_else(|| "Running agent extension hooks…".into());
let model = self.config.model.to_string();
let cancel = Arc::new(AtomicBool::new(false));
let finished = Arc::new(AtomicBool::new(false));
let worker_cancel = Arc::clone(&cancel);
let worker_finished = Arc::clone(&finished);
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let enabled = registry
.enabled_ids()
.into_iter()
.map(str::to_owned)
.collect::<Vec<_>>();
let result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
registry.dispatch(&events, &root, &model, &worker_cancel)
})) {
Ok(result) => result,
Err(_) => {
let mut result = crate::extensions::HookBatchResult::default();
for id in enabled {
result
.errors
.insert(id, "An agent extension hook worker panicked.".into());
}
result
}
};
worker_finished.store(true, Ordering::Release);
let _ = sender.send(Ok(result));
});
self.active_extension_hooks = Some(ExtensionHookRequest {
cancel,
finished,
receiver,
continuation,
});
self.generating = true;
self.stop_requested = false;
self.activity = Some(activity);
self.error = None;
}
#[cfg(target_os = "macos")]
pub(super) fn start_resume_extension_hooks(&mut self) {
let Some(session_id) = self.selected_session else {
return;
};
self.start_extension_hooks(
vec![crate::extensions::HookEvent::SessionStart {
session_id,
reason: "resume",
}],
HookContinuation::Resume,
);
}
#[cfg(target_os = "macos")]
fn poll_extension_hooks(&mut self) -> bool {
let Some(request) = &self.active_extension_hooks else {
return false;
};
let mut worker_error = None;
let result = match request.receiver.try_recv() {
Ok(Ok(result)) => result,
Ok(Err(error)) => {
worker_error = Some(error);
crate::extensions::HookBatchResult::default()
}
Err(TryRecvError::Empty) => return false,
Err(TryRecvError::Disconnected) => {
worker_error = Some("An agent extension hook stopped unexpectedly.".into());
crate::extensions::HookBatchResult::default()
}
};
let request = self.active_extension_hooks.take().unwrap();
if let Err(error) = self.extensions.record_hook_results(&result) {
self.error = Some(format!("Could not save extension hook status: {error}"));
}
let mut notices = Vec::new();
notices.extend(worker_error);
if let Some(status) = result.status() {
notices.push(status);
}
notices.extend(
result
.errors
.iter()
.map(|(id, error)| format!("{id}: {error}")),
);
if !notices.is_empty() {
self.context_notice = Some(notices.join(" · "));
}
if self.stop_requested {
if let HookContinuation::User { prompt, .. } = request.continuation
&& self.composer.text().trim().is_empty()
{
self.composer = text_editor::Content::with_text(&prompt);
}
self.generating = false;
self.activity = Some("Stopped".into());
return false;
}
let contexts = result
.outputs
.iter()
.filter_map(|output| {
output.additional_context.as_deref().map(|context| {
SystemMessage::plain(crate::extensions::wrap_context(
&output.extension_id,
&output.event,
context,
))
})
})
.collect::<Vec<_>>();
match request.continuation {
HookContinuation::User {
prompt,
opening_turn,
} => {
self.generating = false;
self.activity = None;
let restore = prompt.clone();
self.start_generation_after_hooks(prompt, opening_turn, contexts);
if !self.generating && self.composer.text().trim().is_empty() {
self.composer = text_editor::Content::with_text(&restore);
}
}
HookContinuation::Resume => {
self.generating = false;
self.activity = None;
if let Err(error) = self.persist_extension_contexts(&contexts) {
self.error = Some(error);
}
}
HookContinuation::Compaction(pending) => {
if let Err(error) = self.persist_extension_contexts(&contexts) {
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some(error);
} else {
self.finish_compaction_continuation(pending);
}
}
}
true
}
#[cfg(target_os = "macos")]
fn persist_extension_contexts(&mut self, contexts: &[SystemMessage]) -> Result<(), String> {
if contexts.is_empty() {
return Ok(());
}
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
let stored = self
.database
.as_mut()
.ok_or_else(|| "The project database is unavailable.".to_owned())?
.record_system_messages(session_id, contexts)
.map_err(|error| format!("Could not save extension context: {error}"))?;
self.conversation
.extend(stored.into_iter().map(ChatMessage::from));
Ok(())
}
fn system_prompt_reminder_due(&self) -> bool { fn system_prompt_reminder_due(&self) -> bool {
crate::agent::prompt_reminder_due(self.context_used, self.system_prompt_seen_at) crate::agent::prompt_reminder_due(self.context_used, self.system_prompt_seen_at)
} }
@@ -719,7 +1047,7 @@ impl App {
if let Some(skills) = self.dev_brain_skills_prompt() { if let Some(skills) = self.dev_brain_skills_prompt() {
reminders.push(skills); reminders.push(skills);
} }
reminders.extend(crate::agent::agent_skills_prompt()); reminders.extend(self.agent_skills_prompt());
if self.config.a2ui_enabled { if self.config.a2ui_enabled {
reminders.push(crate::a2ui::SYSTEM_PROMPT.to_owned()); reminders.push(crate::a2ui::SYSTEM_PROMPT.to_owned());
} }
@@ -747,6 +1075,10 @@ impl App {
} }
fn poll_generation_step(&mut self) -> bool { fn poll_generation_step(&mut self) -> bool {
#[cfg(target_os = "macos")]
if self.active_extension_hooks.is_some() {
return self.poll_extension_hooks();
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if self.active_compaction.is_some() { if self.active_compaction.is_some() {
return self.poll_compaction(); return self.poll_compaction();
@@ -1261,6 +1593,13 @@ impl App {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
tools.enable_dev_brain(&self.config.dev_brain, &projects)?; tools.enable_dev_brain(&self.config.dev_brain, &projects)?;
} }
let extension_skill_roots = self
.extensions
.enabled_skill_roots()?
.into_iter()
.map(|(_, root)| root)
.collect::<Vec<_>>();
tools.enable_agent_skill_roots(&extension_skill_roots);
self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools)))); self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools))));
} }
let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1); let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);
@@ -1293,7 +1632,7 @@ impl App {
child_turn.system_prompt.push_str("\n\n"); child_turn.system_prompt.push_str("\n\n");
child_turn.system_prompt.push_str(&skills); child_turn.system_prompt.push_str(&skills);
} }
if let Some(skills) = crate::agent::agent_skills_prompt() { if let Some(skills) = self.agent_skills_prompt() {
child_turn.system_prompt.push_str("\n\n"); child_turn.system_prompt.push_str("\n\n");
child_turn.system_prompt.push_str(&skills); child_turn.system_prompt.push_str(&skills);
} }
@@ -1306,6 +1645,7 @@ impl App {
effective.engine.clone(), effective.engine.clone(),
child_turn, child_turn,
idle_timeout, idle_timeout,
(self.extensions.clone(), session_id),
); );
let approval_mode = match self.permission_mode { let approval_mode = match self.permission_mode {
PermissionMode::Heuristic => crate::agent::ShellApprovalMode::Heuristic, PermissionMode::Heuristic => crate::agent::ShellApprovalMode::Heuristic,
@@ -1767,44 +2107,14 @@ impl App {
} }
self.context_used = compacted.context_tokens; self.context_used = compacted.context_tokens;
self.system_prompt_seen_at = compacted.context_tokens; self.system_prompt_seen_at = compacted.context_tokens;
self.generating = false; let session_id = self.selected_session.unwrap();
self.activity = None; self.start_extension_hooks(
match request.pending { vec![crate::extensions::HookEvent::SessionStart {
PendingContinuation::None => self.start_next_queued(), session_id,
PendingContinuation::User(prompt) => { reason: "compact",
self.composer = text_editor::Content::with_text(&prompt); }],
self.skip_compaction_once = true; HookContinuation::Compaction(request.pending),
self.start_generation(); );
}
PendingContinuation::Tool(result) => {
if let Err(error) = self.start_tool_result_check(
result,
ToolCheckStage::ResultAfterCompaction,
) {
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some(error);
}
}
PendingContinuation::DurableTool(touched_paths) => {
let continuation = self
.persist_workspace_instructions(&touched_paths)
.and_then(|()| {
self.start_tool_result_check(
crate::agent::ToolRunResult {
content: String::new(),
touched_paths,
},
ToolCheckStage::InstructionsAfterCompaction,
)
});
if let Err(error) = continuation {
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some(error);
}
}
}
return true; return true;
} }
Err(error) => { Err(error) => {
@@ -1841,6 +2151,47 @@ impl App {
} }
} }
#[cfg(target_os = "macos")]
fn finish_compaction_continuation(&mut self, pending: PendingContinuation) {
self.generating = false;
self.activity = None;
match pending {
PendingContinuation::None => self.start_next_queued(),
PendingContinuation::User(prompt) => {
self.composer = text_editor::Content::with_text(&prompt);
self.skip_compaction_once = true;
self.start_generation();
}
PendingContinuation::Tool(result) => {
if let Err(error) =
self.start_tool_result_check(result, ToolCheckStage::ResultAfterCompaction)
{
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some(error);
}
}
PendingContinuation::DurableTool(touched_paths) => {
let continuation = self
.persist_workspace_instructions(&touched_paths)
.and_then(|()| {
self.start_tool_result_check(
crate::agent::ToolRunResult {
content: String::new(),
touched_paths,
},
ToolCheckStage::InstructionsAfterCompaction,
)
});
if let Err(error) = continuation {
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some(error);
}
}
}
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn apply_compaction( fn apply_compaction(
&mut self, &mut self,
@@ -1924,13 +2275,24 @@ impl App {
fn model_chat_messages(&self) -> Vec<&ChatMessage> { fn model_chat_messages(&self) -> Vec<&ChatMessage> {
let start = compacted_context_start(&self.conversation); let start = compacted_context_start(&self.conversation);
self.conversation[start..] let visible = &self.conversation[start..];
let enabled = self
.extensions
.enabled_ids()
.into_iter()
.map(str::to_owned)
.collect();
let extension_visibility = extension_context_visibility(visible, &enabled);
visible
.iter() .iter()
.filter(|message| { .enumerate()
.filter(|(index, message)| {
!message.compaction !message.compaction
&& !(message.system && message.content.starts_with(LEGACY_AGENTS_PREFIX)) && !(message.system && message.content.starts_with(LEGACY_AGENTS_PREFIX))
&& !(message.instruction_metadata.is_some() && message.content.is_empty()) && !(message.instruction_metadata.is_some() && message.content.is_empty())
&& extension_visibility[*index]
}) })
.map(|(_, message)| message)
.collect() .collect()
} }
@@ -2121,12 +2483,13 @@ pub(super) fn session_title(reply: &str) -> Option<String> {
mod tests { mod tests {
use super::{ use super::{
ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, chat_turn, compacted_context_start, ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, chat_turn, compacted_context_start,
correction_already_sent, has_chat_after_last_compaction, has_misplaced_tool_call, correction_already_sent, extension_context_visibility, has_chat_after_last_compaction,
is_empty_response, promote_legacy_turn_summaries, queued_prompt, sync_a2ui_message, has_misplaced_tool_call, is_empty_response, promote_legacy_turn_summaries, queued_prompt,
title_context, sync_a2ui_message, title_context,
}; };
use crate::engine::ChatTurn; use crate::engine::ChatTurn;
use crate::model::ModelChoice; use crate::model::ModelChoice;
use std::collections::BTreeSet;
fn assistant(reasoning: Option<&str>, content: &str) -> ChatMessage { fn assistant(reasoning: Option<&str>, content: &str) -> ChatMessage {
ChatMessage { ChatMessage {
@@ -2450,4 +2813,44 @@ mod tests {
history.push(message(7, true, false, false, false)); history.push(message(7, true, false, false, false));
assert!(has_chat_after_last_compaction(&history)); assert!(has_chat_after_last_compaction(&history));
} }
#[test]
fn extension_context_deduplicates_session_start_and_rearms_after_compaction() {
let context = |id: &str, event: &str, value: &str| ChatMessage {
system: true,
content: crate::extensions::wrap_context(id, event, value),
..assistant(None, "")
};
let messages = vec![
context("ponytail", "SessionStart", "startup rules"),
context("ponytail", "UserPromptSubmit", "same-turn rules"),
context("ponytail", "SessionStart", "compact rules"),
context("disabled", "UserPromptSubmit", "must disappear"),
];
let enabled = BTreeSet::from(["ponytail".to_owned()]);
assert_eq!(
extension_context_visibility(&messages, &enabled),
[false, true, true, false]
);
let off = vec![
context("ponytail", "SessionStart", "full rules"),
context("ponytail", "SessionStart", ""),
];
assert_eq!(extension_context_visibility(&off, &enabled), [false, true]);
let user_prefix = ChatMessage {
user: true,
system: false,
content: crate::extensions::wrap_context("disabled", "SessionStart", "user text"),
..assistant(None, "")
};
assert_eq!(
extension_context_visibility(&[user_prefix], &enabled),
[true]
);
assert_eq!(
extension_context_visibility(&messages, &BTreeSet::new()),
[false, false, false, false]
);
}
} }

View File

@@ -482,6 +482,8 @@ impl App {
self.preference_draft = PreferenceDraft::from_saved(&self.config); self.preference_draft = PreferenceDraft::from_saved(&self.config);
self.preference_error = None; self.preference_error = None;
self.restore_dev_brain_confirmation = false; self.restore_dev_brain_confirmation = false;
self.pending_extension_trust = None;
self.pending_extension_uninstall = None;
let (id, open) = window::open(window::Settings { let (id, open) = window::open(window::Settings {
size: Size::new(920.0, 700.0), size: Size::new(920.0, 700.0),
min_size: Some(Size::new(720.0, 480.0)), min_size: Some(Size::new(720.0, 480.0)),
@@ -639,6 +641,23 @@ impl App {
message: Message, message: Message,
) -> Result<Message, Task<Message>> { ) -> Result<Message, Task<Message>> {
match message { match message {
Message::PreferenceExtensionSourceChanged(value) => {
self.extension_source = value;
self.extension_error = None;
}
Message::PreferenceExtensionRefChanged(value) => {
self.extension_ref = value;
self.extension_error = None;
}
Message::InstallExtension => self.install_extension(),
Message::UpdateExtension(id) => self.update_extension(id),
Message::ToggleExtension(id, enabled) => self.toggle_extension(id, enabled),
Message::ConfirmExtensionTrust => self.confirm_extension_trust(),
Message::CancelExtensionTrust => self.pending_extension_trust = None,
Message::RequestUninstallExtension(id) => self.pending_extension_uninstall = Some(id),
Message::ConfirmUninstallExtension => self.confirm_uninstall_extension(),
Message::CancelUninstallExtension => self.pending_extension_uninstall = None,
Message::ExtensionOperationTick => self.poll_extension_operation(),
Message::PreferenceModelChanged(model) => { Message::PreferenceModelChanged(model) => {
if let Err(error) = self.preference_draft.store_generation() { if let Err(error) = self.preference_draft.store_generation() {
self.preference_error = Some(error); self.preference_error = Some(error);

View File

@@ -240,6 +240,122 @@ impl App {
] ]
.spacing(10), .spacing(10),
); );
let mut installed_extensions = column![].spacing(10);
if self.extensions.extensions.is_empty() {
installed_extensions = installed_extensions.push(
text("No agent extensions are installed.")
.size(12)
.color(muted_text()),
);
}
for extension in &self.extensions.extensions {
let id = extension.id.clone();
let update_id = id.clone();
let uninstall_id = id.clone();
let enabled = extension.enabled;
let toggle_control = toggle(enabled)
.label(extension.name.clone())
.on_toggle(move |enabled| Message::ToggleExtension(id.clone(), enabled));
let details = format!(
"{}{}{} skills • hooks: {}",
extension.version,
extension.author,
extension.skill_count,
extension.hook_names(),
);
let source = format!(
"{}{} • commit {}",
extension.source_url,
extension
.requested_ref
.as_ref()
.map_or_else(String::new, |reference| format!(" @ {reference}")),
&extension.resolved_commit[..extension.resolved_commit.len().min(12)],
);
let mut row_content = column![
row![
toggle_control.width(Length::Fill),
action_button("Update").on_press(Message::UpdateExtension(update_id)),
action_button("Uninstall")
.on_press(Message::RequestUninstallExtension(uninstall_id)),
]
.spacing(8)
.align_y(Alignment::Center),
text(&extension.description).size(12),
text(details).size(12).color(muted_text()),
text(source).size(11).color(muted_text()),
]
.spacing(5);
if let Some(error) = &extension.last_error {
row_content = row_content.push(
text(format!("Last hook error: {error}"))
.size(12)
.style(iced::widget::text::danger),
);
}
if self.pending_extension_trust.as_deref() == Some(extension.id.as_str()) {
row_content = row_content.push(
column![
text("Trust this extension's command hooks?").size(13),
text("Hooks run local programs with your user permissions. DS4Server isolates their environment, limits runtime and output, and never invokes a shell, but the installed code can still read or change files you can access.")
.size(12),
row![
action_button("Cancel").on_press(Message::CancelExtensionTrust),
action_button("Trust and enable")
.on_press(Message::ConfirmExtensionTrust),
]
.spacing(8),
]
.spacing(7),
);
}
if self.pending_extension_uninstall.as_deref() == Some(extension.id.as_str()) {
row_content = row_content.push(
column![
text("Remove this extension and its stored session data?").size(13),
row![
action_button("Cancel").on_press(Message::CancelUninstallExtension),
action_button("Uninstall").on_press(Message::ConfirmUninstallExtension),
]
.spacing(8),
]
.spacing(7),
);
}
installed_extensions = installed_extensions
.push(row_content)
.push(rule::horizontal(1));
}
let mut extension_content = column![
text("Install a portable Codex plugin from an HTTPS Git repository. Updates are manual and preserve the selected ref.")
.size(12),
text_input("https://example.com/owner/extension.git", &self.extension_source)
.on_input(Message::PreferenceExtensionSourceChanged)
.padding(9),
row![
text_input("Optional branch, tag, or commit", &self.extension_ref)
.on_input(Message::PreferenceExtensionRefChanged)
.padding(9)
.width(Length::Fill),
action_button("Install").on_press(Message::InstallExtension),
]
.spacing(8)
.align_y(Alignment::Center),
installed_extensions,
]
.spacing(10);
if let Some(label) = self.extension_operation_label() {
extension_content = extension_content.push(text(format!("{label}")).size(12));
}
if let Some(error) = &self.extension_error {
extension_content =
extension_content.push(text(error).size(12).style(iced::widget::text::danger));
}
let extension_group = preference_group(
PreferenceSection::Extensions,
"AGENT EXTENSIONS",
extension_content,
);
let git_group = preference_group( let git_group = preference_group(
PreferenceSection::Git, PreferenceSection::Git,
"GIT DIFFS", "GIT DIFFS",
@@ -743,6 +859,7 @@ impl App {
model_group, model_group,
endpoint_group, endpoint_group,
dev_brain_group, dev_brain_group,
extension_group,
git_group, git_group,
prompt_group, prompt_group,
generation_group, generation_group,

1901
src/extensions.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ mod database;
mod dev_brain; mod dev_brain;
mod dsml; mod dsml;
mod engine; mod engine;
mod extensions;
mod instructions; mod instructions;
mod metrics; mod metrics;
mod model; mod model;