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
456 changes: 254 additions & 202 deletions Cargo.lock

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,14 @@ members = [
"crates/jcode-mobile-core",
"crates/jcode-mobile-sim",
"crates/jcode-desktop",
"crates/jcode-hooks",
"crates/jcode-keywords",
"crates/jcode-mempalace-adapter",
"crates/jcode-plugin-core",
"crates/jcode-plugin-runtime",
"crates/jcode-mempalace-adapter",
"crates/jcode-render-core",
"crates/jcode-keywords",
"evals/jbench",

]

# Local override: build against the fast_file_search main branch which
Expand Down Expand Up @@ -122,6 +124,9 @@ path = "src/bin/tui_bench.rs"
required-features = ["dev-bins"]

[dependencies]
# Hook system for lifecycle events
jcode-hooks = { path = "crates/jcode-hooks" }

# Cross-provider session conversion engine (resume/import any provider -> jcode).
# Pinned to a specific commit SHA on the upstream fork so the dependency is
# reproducible and doesn't accidentally pick up in-flight changes. Bump the
Expand Down Expand Up @@ -177,6 +182,7 @@ similar = "2" # diffing for edits
dirs = "5" # home directory
anyhow = "1"
thiserror = "1"
strum = { version = "0.26", features = ["derive"] }
libc = "0.2" # Unix system calls (flock)
chrono = { version = "0.4", features = ["serde"] }
regex = "1"
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-app-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ proctitle = "0.1"
# Embeddings (local inference) live in jcode-base; this crate forwards the
# `embeddings` feature to jcode-base rather than depending on jcode-embedding.
jcode-gateway-types = { path = "../jcode-gateway-types" }
jcode-hooks = { path = "../jcode-hooks" }
jcode-logging = { path = "../jcode-logging" }

# OAuth
Expand Down
184 changes: 184 additions & 0 deletions crates/jcode-app-core/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ use crate::skill::SkillRegistry;
use crate::tool::{Registry, ToolContext, ToolExecutionMode};
use anyhow::Result;
use futures::StreamExt;
use jcode_hooks::{DispatchConfig, HookContext, HookEvent, HookInputBuilder, HookRegistry};
#[cfg(feature = "dcp")]
use std::cell::Cell;

use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::io::{self, Write};
Expand Down Expand Up @@ -235,6 +239,13 @@ pub struct Agent {
stdin_request_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::tool::StdinInputRequest>>,
/// Canonical reducer-backed view of runtime provider/model selection.
provider_runtime_state: ProviderRuntimeState,
/// Hook registry for dispatching lifecycle hooks.
hook_registry: HookRegistry,
/// Dispatch configuration for hook execution.
dispatch_config: DispatchConfig,
/// DCP plugin for context pruning (behind feature flag).
#[cfg(feature = "dcp")]
dcp: Option<crate::dcp_plugin::DcpPlugin>,
}

impl Agent {
Expand Down Expand Up @@ -286,6 +297,10 @@ impl Agent {
rewind_undo_snapshot: None,
stdin_request_tx: None,
provider_runtime_state: ProviderRuntimeState::observed(initial_provider_model),
hook_registry: HookRegistry::default(),
dispatch_config: DispatchConfig::default(),
#[cfg(feature = "dcp")]
dcp: crate::dcp_plugin::DcpPlugin::new().ok(),
};
crate::tool::set_session_tool_policy(
&agent.session.id,
Expand Down Expand Up @@ -325,6 +340,33 @@ impl Agent {
agent.session.provider_key =
crate::session::derive_session_provider_key(agent.provider.name());
agent.session.ensure_initial_session_context_message();

// Dispatch SessionStart hooks (fire-and-forget, observational only)
{
let registry = agent.hook_registry.clone();
let config = agent.dispatch_config.clone();
let session_id = agent.session.id.clone();
let cwd = agent.session.working_dir.clone().unwrap_or_default();
let hook_input = HookInputBuilder::new()
.session(&session_id, &cwd)
.event("SessionStart")
.build();
let ctx = HookContext::for_session_start(session_id, cwd);
let event = HookEvent::SessionStart;
tokio::spawn(async move {
let handlers = registry.get_matching(&event, &ctx);
if !handlers.is_empty() {
jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await;
}
});
}

// Wire DCP plugin into registry so DCP tools can access it
#[cfg(feature = "dcp")]
if let Some(dcp) = agent.dcp.take() {
agent.registry.set_dcp(dcp);
}

agent.seed_compaction_from_session();
agent.log_env_snapshot("create");
crate::telemetry::begin_session_with_parent(
Expand Down Expand Up @@ -384,6 +426,33 @@ impl Agent {
agent.restore_reasoning_effort_from_session();
agent.session.ensure_initial_session_context_message();
agent.sync_memory_dedup_state_from_session();

// Dispatch SessionStart hooks (fire-and-forget, observational only)
{
let registry = agent.hook_registry.clone();
let config = agent.dispatch_config.clone();
let session_id = agent.session.id.clone();
let cwd = agent.session.working_dir.clone().unwrap_or_default();
let hook_input = HookInputBuilder::new()
.session(&session_id, &cwd)
.event("SessionStart")
.build();
let ctx = HookContext::for_session_start(session_id, cwd);
let event = HookEvent::SessionStart;
tokio::spawn(async move {
let handlers = registry.get_matching(&event, &ctx);
if !handlers.is_empty() {
jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await;
}
});
}

// Wire DCP plugin into registry so DCP tools can access it
#[cfg(feature = "dcp")]
if let Some(dcp) = agent.dcp.take() {
agent.registry.set_dcp(dcp);
}

agent.seed_compaction_from_session();
agent.log_env_snapshot("attach");
crate::telemetry::begin_session_with_parent(
Expand Down Expand Up @@ -832,11 +901,71 @@ impl Agent {
&self.provider.model(),
crate::telemetry::SessionEndReason::NormalExit,
);

// Dispatch SessionEnd hooks (fire-and-forget, observational only)
{
let registry = self.hook_registry.clone();
let config = self.dispatch_config.clone();
let session_id = self.session.id.clone();
let hook_input = HookInputBuilder::new()
.session(&session_id, "")
.event("SessionEnd")
.build();
let ctx = HookContext::for_session_end(session_id.clone());
let event = HookEvent::SessionEnd;
tokio::spawn(async move {
let handlers = registry.get_matching(&event, &ctx);
if !handlers.is_empty() {
jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await;
}
});
}

// Dispatch AgentEnd hooks (fire-and-forget, observational only)
{
let registry = self.hook_registry.clone();
let config = self.dispatch_config.clone();
let session_id = self.session.id.clone();
let hook_input = HookInputBuilder::new()
.session(&session_id, "")
.event("AgentEnd")
.build();
let ctx = HookContext::for_agent_end(session_id);
let event = HookEvent::AgentEnd;
tokio::spawn(async move {
let handlers = registry.get_matching(&event, &ctx);
if !handlers.is_empty() {
jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await;
}
});
}

self.persist_soft_interrupt_snapshot();
self.session.mark_closed();
if !self.session.messages.is_empty() {
self.persist_session_best_effort("session close state");
}

// Dispatch SessionUpdated hooks — session state changed to "closed"
{
let registry = self.hook_registry.clone();
let config = self.dispatch_config.clone();
let session_id = self.session.id.clone();
let cwd = self.session.working_dir.clone().unwrap_or_default();
let hook_input = HookInputBuilder::new()
.session(&session_id, &cwd)
.event("SessionUpdated")
.session_state("active", "closed", "normal_exit")
.build();
let ctx = HookContext::for_session_updated(session_id, cwd);
let event = HookEvent::SessionUpdated;
tokio::spawn(async move {
let handlers = registry.get_matching(&event, &ctx);
if !handlers.is_empty() {
jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await;
}
});
}
}

pub fn mark_crashed(&mut self, message: Option<String>) {
Expand All @@ -845,18 +974,73 @@ impl Agent {
&self.provider.model(),
crate::telemetry::SessionEndReason::Unknown,
);
let crash_msg = message
.clone()
.unwrap_or_else(|| "unknown crash".to_string());
self.persist_soft_interrupt_snapshot();
self.session.mark_crashed(message);
if !self.session.messages.is_empty() {
self.persist_session_best_effort("session crash state");
}

// Dispatch SessionUpdated hooks — session state changed to "crashed"
{
let registry = self.hook_registry.clone();
let config = self.dispatch_config.clone();
let session_id = self.session.id.clone();
let cwd = self.session.working_dir.clone().unwrap_or_default();
let hook_input = HookInputBuilder::new()
.session(&session_id, &cwd)
.event("SessionUpdated")
.session_state("active", "crashed", &crash_msg)
.build();
let ctx = HookContext::for_session_updated(session_id.clone(), cwd.clone());
let event = HookEvent::SessionUpdated;
tokio::spawn(async move {
let handlers = registry.get_matching(&event, &ctx);
if !handlers.is_empty() {
jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await;
}
});
}

// Dispatch SessionError hooks — the session encountered a fatal error
{
let registry = self.hook_registry.clone();
let config = self.dispatch_config.clone();
let session_id = self.session.id.clone();
let cwd = self.session.working_dir.clone().unwrap_or_default();
let mut hook_input = HookInputBuilder::new()
.session(&session_id, &cwd)
.event("SessionError")
.build();
hook_input.error = Some(crash_msg);
let ctx = HookContext::for_session_error(session_id, cwd);
let event = HookEvent::SessionError;
tokio::spawn(async move {
let handlers = registry.get_matching(&event, &ctx);
if !handlers.is_empty() {
jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await;
}
});
}
}

/// Get the last token usage from the most recent API request
pub fn last_usage(&self) -> &TokenUsage {
&self.last_usage
}

/// Get a reference to the hook registry for external dispatch.
pub fn hook_registry(&self) -> &HookRegistry {
&self.hook_registry
}

/// Get a reference to the dispatch configuration for external dispatch.
pub fn dispatch_config(&self) -> &DispatchConfig {
&self.dispatch_config
}

pub fn token_usage_totals(&self) -> crate::protocol::TokenUsageTotals {
self.session.token_usage_totals()
}
Expand Down
Loading
Loading