Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,833 changes: 1,833 additions & 0 deletions .omo/plans/plugin-master-plan.md

Large diffs are not rendered by default.

570 changes: 564 additions & 6 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ description = "Possibly the greatest coding agent ever built — blazing-fast TU
edition = "2024"
autobins = false

[workspace.package]
edition = "2024"

[workspace]
members = [
".",
Expand Down Expand Up @@ -66,6 +69,8 @@ members = [
"crates/jcode-mobile-core",
"crates/jcode-mobile-sim",
"crates/jcode-desktop",
"crates/jcode-plugin-core",
"crates/jcode-plugin-runtime",
"crates/jcode-mempalace-adapter",
"crates/jcode-render-core",
"evals/jbench",
Expand Down
2 changes: 2 additions & 0 deletions crates/jcode-app-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ jcode-memory-types = { path = "../jcode-memory-types" }
jcode-message-types = { path = "../jcode-message-types" }
jcode-overnight-core = { path = "../jcode-overnight-core" }
jcode-plan = { path = "../jcode-plan" }
jcode-plugin-core = { path = "../jcode-plugin-core" }
jcode-plugin-runtime = { path = "../jcode-plugin-runtime" }
jcode-swarm-core = { path = "../jcode-swarm-core" }
jcode-protocol = { path = "../jcode-protocol" }
jcode-selfdev-types = { path = "../jcode-selfdev-types" }
Expand Down
120 changes: 119 additions & 1 deletion crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use super::*;
use jcode_plugin_core::PluginEvent;
use jcode_plugin_core::events::{EventInput, HandlerAction};

/// Largest byte index `<= index` that is a UTF-8 char boundary in `text`.
/// Equivalent to the unstable `str::floor_char_boundary`, reimplemented so the
Expand Down Expand Up @@ -86,6 +88,17 @@ impl Agent {
let mut incomplete_continuations = 0u32;

loop {
// PLUGIN_EVENT: TurnStart - fire-and-forget at beginning of each turn
let turn_start = Instant::now();
if let Some(system) = crate::plugin::plugin_system() {
let session_id = self.session.id.clone();
let messages = serde_json::json!({ "message_count": self.session.messages.len() });
let input = EventInput::TurnStart { session_id, turn_number: 0, messages };
tokio::spawn(async move {
let _ = system.dispatch_event(PluginEvent::TurnStart, input, None).await;
});
}

let repaired = self.repair_missing_tool_outputs();
if repaired > 0 {
logging::warn(&format!(
Expand Down Expand Up @@ -300,6 +313,17 @@ impl Agent {

let mut retry_after_compaction = false;
let mut keepalive = stream_keepalive_ticker();
// PLUGIN_EVENT: MessageStart - before streaming response begins
if let Some(system) = crate::plugin::plugin_system() {
let session_id = self.session.id.clone();
let input = EventInput::MessageStart {
session_id,
role: "assistant".to_string(),
};
tokio::spawn(async move {
let _ = system.dispatch_event(PluginEvent::MessageStart, input, None).await;
});
}
loop {
let next_event = std::pin::pin!(stream.next());
let event = tokio::select! {
Expand Down Expand Up @@ -925,6 +949,20 @@ impl Agent {
None
};

// PLUGIN_EVENT: MessageEnd - fire-and-forget after response is saved
if let Some(system) = crate::plugin::plugin_system() {
let session_id = self.session.id.clone();
let content = text_content.clone();
let input = EventInput::MessageEnd {
session_id,
role: "assistant".to_string(),
content,
};
tokio::spawn(async move {
let _ = system.dispatch_event(PluginEvent::MessageEnd, input, None).await;
});
}

if let Some((encrypted_content, compacted_count)) = openai_native_compaction.take() {
self.apply_openai_native_compaction(encrypted_content, compacted_count)?;
}
Expand Down Expand Up @@ -1103,6 +1141,41 @@ impl Agent {
// Fall through to local execution for native tools with SDK errors
}

// PLUGIN_EVENT: PreToolUse - check if plugin blocks this tool
if let Some(system) = crate::plugin::plugin_system() {
let pre_input = EventInput::PreToolUse {
tool_name: tc.name.clone(),
tool_input: tc.input.clone(),
session_id: self.session.id.clone(),
};
let results = system.dispatch_event(PluginEvent::PreToolUse, pre_input, None).await;
if results.iter().any(|(_, r)| matches!(r.action, HandlerAction::Block(_))) {
let reason = results.iter()
.find_map(|(_, r)| {
if let HandlerAction::Block(ref reason) = r.action {
Some(reason.clone())
} else {
None
}
})
.unwrap_or_default();
logging::info(&format!("Tool '{}' blocked by plugin: {}", tc.name, reason));
let _ = event_tx.send(ServerEvent::ToolDone {
id: tc.id.clone(),
name: tc.name.clone(),
output: format!("[Blocked by plugin: {}]", reason),
error: Some("blocked_by_plugin".to_string()),
});
self.add_message(Role::User, vec![ContentBlock::ToolResult {
tool_use_id: tc.id.clone(),
content: format!("[Tool blocked by plugin: {}]", reason),
is_error: Some(true),
}]);
tool_results_dirty = true;
continue;
}
}

let ctx = ToolContext {
session_id: self.session.id.clone(),
message_id: message_id.clone(),
Expand Down Expand Up @@ -1210,13 +1283,29 @@ impl Agent {
});
}

let output_text = output.output.clone();
let blocks = tool_output_to_content_blocks(tc.id.clone(), output);
self.add_message_with_duration(
Role::User,
blocks,
Some(tool_elapsed.as_millis() as u64),
);
tool_results_dirty = true;
// PLUGIN_EVENT: PostToolUse (success)
if let Some(system) = crate::plugin::plugin_system() {
let tool_output = serde_json::json!({ "output": output_text });
let post_input = EventInput::PostToolUse {
tool_name: tc.name.clone(),
tool_input: tc.input.clone(),
tool_output,
duration_ms: tool_elapsed.as_millis() as u64,
success: true,
session_id: self.session.id.clone(),
};
tokio::spawn(async move {
let _ = system.dispatch_event(PluginEvent::PostToolUse, post_input, None).await;
});
}
}
Err(e) => {
let error_msg = format!("Error: {}", e);
Expand All @@ -1231,12 +1320,27 @@ impl Agent {
Role::User,
vec![ContentBlock::ToolResult {
tool_use_id: tc.id.clone(),
content: error_msg,
content: error_msg.clone(),
is_error: Some(true),
}],
Some(tool_elapsed.as_millis() as u64),
);
tool_results_dirty = true;
// PLUGIN_EVENT: PostToolUse (failure)
if let Some(system) = crate::plugin::plugin_system() {
let tool_output = serde_json::json!({ "error": error_msg });
let post_input = EventInput::PostToolUse {
tool_name: tc.name.clone(),
tool_input: tc.input.clone(),
tool_output,
duration_ms: tool_elapsed.as_millis() as u64,
success: false,
session_id: self.session.id.clone(),
};
tokio::spawn(async move {
let _ = system.dispatch_event(PluginEvent::PostToolUse, post_input, None).await;
});
}
}
}
} else if self.is_graceful_shutdown() {
Expand Down Expand Up @@ -1355,6 +1459,20 @@ impl Agent {
let _ = event_tx.send(event);
}
}

// PLUGIN_EVENT: TurnEnd - fire-and-forget at end of each turn
if let Some(system) = crate::plugin::plugin_system() {
let session_id = self.session.id.clone();
let duration_ms = turn_start.elapsed().as_millis() as u64;
let input = EventInput::TurnEnd {
session_id,
turn_number: 0,
duration_ms,
};
tokio::spawn(async move {
let _ = system.dispatch_event(PluginEvent::TurnEnd, input, None).await;
});
}
}

Ok(())
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-app-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub mod network_retry;
pub mod notifications;
pub mod overnight;
pub mod perf;
pub mod plugin;
pub mod prompt_placeholders;
pub mod prompt_templates;
pub mod replay;
Expand Down
88 changes: 88 additions & 0 deletions crates/jcode-app-core/src/plugin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use jcode_plugin_core::events::{EventInput, HandlerAction};
use jcode_plugin_core::PluginEvent;
pub use jcode_plugin_runtime::{check_kill_switches, is_force_deny, PluginSystem, DISABLE_ALL_PLUGINS, FORCE_DENY, SKIP_HOOKS};
use std::sync::atomic::Ordering;
use std::sync::OnceLock;

static PLUGIN_SYSTEM: OnceLock<PluginSystem> = OnceLock::new();

pub async fn init_plugins(config: &crate::config::PluginConfig) {
if PLUGIN_SYSTEM.get().is_some() {
return;
}
crate::logging::info("Initializing plugin system");
match PluginSystem::initialize(config).await {
Ok(system) => {
crate::logging::info("Plugin system initialized successfully");
let _ = PLUGIN_SYSTEM.set(system);
}
Err(e) => {
crate::logging::warn(&format!("Plugin system initialization failed: {e}"));
}
}
}

pub fn plugin_system() -> Option<&'static PluginSystem> {
PLUGIN_SYSTEM.get()
}

pub fn plugin_count() -> usize {
plugin_system()
.map(|sys| sys.dispatcher.plugin_count())
.unwrap_or(0)
}

pub enum PermissionVerdict {
Allow,
Deny,
Defer,
}

pub async fn check_permission(action: &str, args: &serde_json::Value) -> PermissionVerdict {
if DISABLE_ALL_PLUGINS.load(Ordering::SeqCst) {
return PermissionVerdict::Defer;
}

if is_force_deny() {
return PermissionVerdict::Deny;
}

let sys = match PLUGIN_SYSTEM.get() {
Some(s) => s,
None => return PermissionVerdict::Defer,
};

if SKIP_HOOKS.load(Ordering::SeqCst) {
return PermissionVerdict::Defer;
}

let tool_name = args
.get("tool")
.and_then(|v| v.as_str())
.map(|s| s.to_string());

let target = args
.get("target")
.and_then(|v| v.as_str())
.map(|s| s.to_string());

let event = PluginEvent::PermissionRequest;
let input = EventInput::PermissionRequest {
action: action.to_string(),
tool_name,
target,
session_id: String::new(),
};

let results = sys.dispatch_event(event, input, None).await;

for (_id, result) in &results {
match &result.action {
HandlerAction::Deny => return PermissionVerdict::Deny,
HandlerAction::Allow => return PermissionVerdict::Allow,
_ => continue,
}
}

PermissionVerdict::Defer
}
2 changes: 2 additions & 0 deletions crates/jcode-app-core/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,8 @@ impl Server {
// Persist auxiliary discovery metadata after the server is already live.
self.spawn_registry_metadata_publisher(registry_info);

crate::plugin::init_plugins(&crate::config::config().plugins).await;

// Spawn WebSocket gateway for iOS/web clients (if enabled)
let _gateway_handle = self.spawn_gateway(runtime);

Expand Down
55 changes: 55 additions & 0 deletions crates/jcode-app-core/src/tool/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,61 @@ impl Registry {
let tool = match tools.get(resolved_name) {
Some(tool) => tool.clone(),
None => {
// Plugin tool dispatch: route plugin_ prefixed tools through the plugin system
if resolved_name.starts_with("plugin_") {
if let Some(system) = crate::plugin::plugin_system() {
drop(tools);
crate::logging::event_info(
"TOOL_LIFECYCLE",
Self::tool_lifecycle_fields(
"start", name, resolved_name, &input, &ctx,
),
);
let started_at = std::time::Instant::now();
match system.execute_tool(resolved_name, &input).await {
Ok(output_text) => {
let latency_ms = started_at.elapsed().as_millis() as u64;
crate::telemetry::record_tool_execution(
resolved_name, &input, true, latency_ms,
);
let output = ToolOutput::new(output_text);
let output = self.guard_context_overflow(name, output).await;
let mut fields = Self::tool_lifecycle_fields(
"done", name, resolved_name, &input, &ctx,
);
fields.push(("elapsed_ms".to_string(), latency_ms.to_string()));
fields.push((
"output_bytes".to_string(),
output.output.len().to_string(),
));
fields.push((
"output_chars".to_string(),
output.output.chars().count().to_string(),
));
fields.push((
"image_count".to_string(),
output.images.len().to_string(),
));
crate::logging::event_info("TOOL_LIFECYCLE", fields);
return Ok(output);
}
Err(e) => {
let latency_ms = started_at.elapsed().as_millis() as u64;
crate::telemetry::record_tool_execution(
resolved_name, &input, false, latency_ms,
);
let mut fields = Self::tool_lifecycle_fields(
"error", name, resolved_name, &input, &ctx,
);
fields.push(("elapsed_ms".to_string(), latency_ms.to_string()));
fields.push(("error".to_string(), e.clone()));
crate::logging::event_warn("TOOL_LIFECYCLE", fields);
return Err(anyhow::anyhow!("Plugin tool error: {e}"));
}
}
}
}

// List available tools so the model can recover instead of
// spiraling through hallucinated names like "ToolSearch" (#104).
let mut available: Vec<&str> = tools.keys().map(|k| k.as_str()).collect();
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-base/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ jcode-core = { path = "../jcode-core" }
jcode-memory-types = { path = "../jcode-memory-types" }
jcode-message-types = { path = "../jcode-message-types" }
jcode-overnight-core = { path = "../jcode-overnight-core" }
jcode-plugin-core = { path = "../jcode-plugin-core" }
jcode-plan = { path = "../jcode-plan" }
jcode-swarm-core = { path = "../jcode-swarm-core" }
jcode-protocol = { path = "../jcode-protocol" }
Expand Down
Loading
Loading