Sampler / inference (PRD F3):
- kimi_compat.rs: single adaptation point for the Kimi chat/completions
dialect (thinking-field mapping, model_id stripping, empty-content
tool-call message fix, stream_options.include_usage), with kimi-cli
source citations
- Rate-limit handling reworked for Kimi/Moonshot semantics; UA kigi/{version}
- /models replaces the xAI models-v2 endpoint everywhere; idle model
refresh carries X-Msh-* device headers only (X-XAI-Token-Auth and
x-grok-client-mode/CLIENT_MODE_HEADER machinery deleted)
Cloud-surface excision (PRD §5, zero-egress):
- remote/ conversations lane, cli-chat-proxy-types crate, prod/ dir,
share command, credit bar: deleted (single local session lane;
paginate() replaces merge_and_paginate)
- Subscription/tier gate stack deleted end-to-end: AppView
gate/tier/team/ZDR fields, app/subscription.rs watch loop,
dispatch/billing.rs paywall + SuperGrok upsell, free-usage-exhausted
chain, tier-restricted commands, GateInfo, RemoteSettings gate fields,
SettingsUpdateNotification gate fields
- /privacy + coding-data-sharing setting deleted (backed by a dead xAI
RPC; Kigi is zero-egress — nothing to share or retain remotely)
Auth UX correctness (user-reported):
- Device-flow fixtures now mirror the live Kimi payload shape
(https://www.kimi.com/code/authorize_device?user_code=..., verified
against auth.kimi.com); the fabricated auth.kimi.com/device?code=...
URLs are gone
- open_browser_detached is a no-op under cfg(test): unit tests drove
wiremock fixture URLs into the real browser (root cause of the
"garbage mock link" ABCD-1234 tabs)
- Welcome/pager-minimal rebrand: Grok Build -> Kigi, grok.com ->
kimi.com, "Sign in to Grok" -> "Sign in to Kimi"
616 lines
25 KiB
Rust
616 lines
25 KiB
Rust
//! Session-administration extension handlers.
|
|
//!
|
|
//! Methods grouped here are operational/admin endpoints that mutate
|
|
//! persistent or shared agent state but are not part of the per-turn prompt
|
|
//! lifecycle:
|
|
//!
|
|
//! - `x.ai/session/rename` rename a session locally
|
|
//! - `x.ai/session/delete` delete a session locally
|
|
//! - `x.ai/session/update_mcp_servers` mid-session MCP server swap
|
|
//! - `x.ai/session/fork` fork a session into a new one
|
|
//! - `x.ai/internal/reload_all_mcp_servers` config hot-reload, all sessions
|
|
//! - `x.ai/internal/reload_project_mcp_servers` config hot-reload, cwd-scoped
|
|
//! - `x.ai/internal/reload_skills` skills file watcher fan-out
|
|
//! - `x.ai/internal/reload_models` model list hot-reload from config.toml
|
|
//! - `x.ai/internal/reload_models_cache` model catalog hot-reload from disk cache
|
|
//! - `x.ai/internal/auth_cleared` auth hot-clear cleanup
|
|
//! - `x.ai/plugins/reload` rebuild shared plugin registry
|
|
//! - `x.ai/commands/list` list slash commands
|
|
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use agent_client_protocol as acp;
|
|
use agent_client_protocol::Client as _;
|
|
use serde::Deserialize;
|
|
|
|
use super::{ExtResult, parse_params, to_raw_response};
|
|
use crate::agent::MvpAgent;
|
|
use crate::session::persistence::list_summaries;
|
|
use crate::session::storage::StorageAdapter;
|
|
use crate::session::storage::jsonl::JsonlStorageAdapter;
|
|
use crate::session::unified_list::SessionKind;
|
|
use crate::session::{ExtMethodResult, SessionCommand};
|
|
|
|
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
|
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
|
match args.method.as_ref() {
|
|
"x.ai/session/rename" => handle_session_rename(agent, args).await,
|
|
"x.ai/session/delete" => handle_session_delete(agent, args).await,
|
|
"x.ai/session/update_mcp_servers" => handle_update_mcp_servers(agent, args).await,
|
|
"x.ai/session/fork" => handle_session_fork(agent, args).await,
|
|
"x.ai/internal/reload_all_mcp_servers" => handle_reload_all_mcp_servers(agent).await,
|
|
"x.ai/internal/reload_project_mcp_servers" => {
|
|
handle_reload_project_mcp_servers(agent, args).await
|
|
}
|
|
"x.ai/internal/reload_skills" => handle_reload_skills(agent),
|
|
"x.ai/internal/reload_models" => handle_reload_models(agent),
|
|
"x.ai/internal/reload_models_cache" => handle_reload_models_cache(agent),
|
|
"x.ai/internal/auth_cleared" => handle_auth_cleared(agent),
|
|
"x.ai/plugins/reload" => handle_plugins_reload(agent).await,
|
|
"x.ai/commands/list" => handle_commands_list(agent, args).await,
|
|
_ => Err(acp::Error::method_not_found()),
|
|
}
|
|
}
|
|
|
|
// session/rename
|
|
|
|
/// Handles renaming a session.
|
|
async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RenameRequest {
|
|
session_id: String,
|
|
title: String,
|
|
#[serde(default)]
|
|
cwd: Option<String>,
|
|
#[serde(default)]
|
|
kind: SessionKind,
|
|
}
|
|
|
|
let mut req: RenameRequest = parse_params(args)?;
|
|
// Manual titles must be non-blank: `Summary.title_is_manual` binds to a
|
|
// real `generated_title`, so reject whitespace-only input at the boundary.
|
|
req.title = req.title.trim().to_string();
|
|
if req.title.is_empty() {
|
|
return Err(acp::Error::invalid_request().data("title must not be blank"));
|
|
}
|
|
|
|
if req.kind == SessionKind::Chat {
|
|
return Err(acp::Error::invalid_request()
|
|
.data("chat conversations are not available in kigi (local sessions only)"));
|
|
}
|
|
|
|
let session_id = acp::SessionId::new(Arc::from(req.session_id.as_str()));
|
|
|
|
// Find the session info, scoping to cwd if provided
|
|
let summaries = list_summaries(req.cwd.as_deref())
|
|
.await
|
|
.map_err(|e| acp::Error::internal_error().data(format!("failed to list sessions: {e}")))?;
|
|
|
|
let summary = summaries
|
|
.iter()
|
|
.find(|s| s.info.id == session_id)
|
|
.ok_or_else(|| {
|
|
acp::Error::invalid_request().data(format!("session not found: {}", req.session_id))
|
|
})?;
|
|
|
|
let info = summary.info.clone();
|
|
|
|
// Update the session title in local storage
|
|
let storage = JsonlStorageAdapter::default();
|
|
storage
|
|
.update_session_title(&info, req.title.clone())
|
|
.await
|
|
.map_err(|e| {
|
|
acp::Error::internal_error().data(format!("failed to update session title: {e}"))
|
|
})?;
|
|
|
|
// Update session search index with new title
|
|
crate::session::storage::search::notify_session_updated(&info.id.to_string(), &info.cwd);
|
|
|
|
// Send a SessionSummaryGenerated notification so the TUI updates its title
|
|
notify_session_title(agent, session_id, &req.title).await;
|
|
|
|
// Hook 2: update session replica with summary (fire-and-forget)
|
|
if let Some(client) = agent.session_registry_client() {
|
|
let sid = req.session_id.to_string();
|
|
let title = Some(req.title.clone());
|
|
tokio::spawn(async move {
|
|
let update = crate::agent::session_registry_client::UpdateRequest {
|
|
summary: title,
|
|
first_prompt: None,
|
|
last_turn_number: None,
|
|
repo_head_at_end: None,
|
|
restorable_turn_number: None,
|
|
};
|
|
if let Err(e) = client.update(&sid, &update).await {
|
|
tracing::warn!(error = %e, "session registry summary update failed (non-fatal)");
|
|
}
|
|
});
|
|
}
|
|
|
|
tracing::info!(session_id = %req.session_id, title = %req.title, "Session renamed");
|
|
|
|
to_raw_response(&serde_json::json!({ "success": true }))
|
|
}
|
|
|
|
/// Notify connected clients of a session's new title via
|
|
/// `SessionSummaryGenerated`.
|
|
async fn notify_session_title(agent: &MvpAgent, session_id: acp::SessionId, title: &str) {
|
|
use crate::extensions::notification::{SessionNotification, SessionUpdate};
|
|
|
|
let notification = SessionNotification {
|
|
session_id,
|
|
update: SessionUpdate::SessionSummaryGenerated {
|
|
session_summary: title.to_owned(),
|
|
},
|
|
meta: None,
|
|
};
|
|
if let Ok(params) = serde_json::value::to_raw_value(¬ification) {
|
|
let ext_notification =
|
|
acp::ExtNotification::new("x.ai/session_notification", params.into());
|
|
let _ = agent.gateway.ext_notification(ext_notification).await;
|
|
}
|
|
}
|
|
|
|
// session/delete
|
|
|
|
/// Delete a session from history.
|
|
async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct DeleteRequest {
|
|
session_id: String,
|
|
#[serde(default)]
|
|
cwd: Option<String>,
|
|
#[serde(default)]
|
|
kind: SessionKind,
|
|
}
|
|
|
|
let req: DeleteRequest = parse_params(args)?;
|
|
|
|
if req.kind == SessionKind::Chat {
|
|
return Err(acp::Error::invalid_request()
|
|
.data("chat conversations are not available in kigi (local sessions only)"));
|
|
}
|
|
|
|
let session_id = acp::SessionId::new(Arc::from(req.session_id.as_str()));
|
|
|
|
// Local disk + FTS eviction. Mirrored by the `kigi sessions delete <id>`
|
|
// CLI path.
|
|
crate::session::persistence::delete_session_history(&req.session_id, req.cwd.as_deref())
|
|
.await
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
|
|
|
// If an in-memory live session exists for this id (e.g. the user
|
|
// deleted history for a session that is still open in another agent
|
|
// or the current one), shut it down and drop the MvpAgent bookkeeping
|
|
// so we don't leave a live actor whose on-disk/FTS state is gone.
|
|
if agent.sessions.borrow().contains_key(&session_id) {
|
|
agent.request_session_shutdown(&session_id);
|
|
agent.remove_session(&session_id);
|
|
}
|
|
|
|
tracing::info!(session_id = %req.session_id, "Session deleted");
|
|
|
|
to_raw_response(&serde_json::json!({ "success": true }))
|
|
}
|
|
|
|
// session/update_mcp_servers
|
|
|
|
async fn handle_update_mcp_servers(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct Params {
|
|
session_id: acp::SessionId,
|
|
mcp_servers: Vec<acp::McpServer>,
|
|
}
|
|
|
|
let params: Params = parse_params(args)?;
|
|
|
|
let (handle, cwd) = {
|
|
let sessions = agent.sessions.borrow();
|
|
let h = sessions
|
|
.get(¶ms.session_id)
|
|
.cloned()
|
|
.ok_or_else(|| acp::Error::invalid_params().data("unknown session id"))?;
|
|
let cwd = std::path::PathBuf::from(&h.info.cwd);
|
|
(h, cwd)
|
|
};
|
|
|
|
let managed = agent.get_managed_mcp_configs().await;
|
|
let merged = crate::session::managed_mcp::merge_managed_mcp_servers(
|
|
params.mcp_servers.clone(),
|
|
&cwd,
|
|
&managed,
|
|
agent.plugin_registry_handle().snapshot().as_deref(),
|
|
&agent.cfg.borrow().compat_resolved,
|
|
);
|
|
|
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
|
handle
|
|
.cmd_tx
|
|
.send(SessionCommand::UpdateMcpServers {
|
|
mcp_servers: merged,
|
|
respond_to: tx,
|
|
})
|
|
.map_err(|_| acp::Error::internal_error().data("session closed"))?;
|
|
|
|
// Wait for the session actor to finish MCP re-initialization.
|
|
rx.await
|
|
.map_err(|_| acp::Error::internal_error().data("session closed"))?
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
|
|
|
// Persist the new client set on the handle so config hot-reloads
|
|
// (`reload_all_mcp_servers` / `reload_project_mcp_servers`) re-merge from
|
|
// the client's latest intent rather than the `session/new` snapshot —
|
|
// otherwise a reload would resurrect servers the client just removed
|
|
// (or drop ones it just added).
|
|
if let Some(h) = agent.sessions.borrow_mut().get_mut(¶ms.session_id) {
|
|
h.initial_client_mcp_servers = params.mcp_servers;
|
|
}
|
|
|
|
ExtMethodResult::success(serde_json::json!({ "ok": true }))
|
|
.to_ext_response()
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
|
}
|
|
|
|
// internal/reload_skills
|
|
|
|
/// Reload skills for ALL active sessions. Called by the skills file watcher
|
|
/// (via ACP injection from `app.rs`) when `SKILL.md` files change.
|
|
fn handle_reload_skills(agent: &MvpAgent) -> ExtResult {
|
|
let session_ids: Vec<acp::SessionId> = agent.sessions.borrow().keys().cloned().collect();
|
|
for sid in &session_ids {
|
|
if let Some(handle) = agent.sessions.borrow().get(sid).cloned() {
|
|
let _ = handle.cmd_tx.send(SessionCommand::ReloadSkills);
|
|
}
|
|
}
|
|
ExtMethodResult::success(serde_json::json!({ "reloaded": session_ids.len() }))
|
|
.to_ext_response()
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
|
}
|
|
|
|
// internal/reload_all_mcp_servers
|
|
|
|
/// Reload MCP servers for ALL active sessions. Called by the config
|
|
/// hot-reload watcher when `[mcp_servers]` changes in config.toml.
|
|
async fn handle_reload_all_mcp_servers(agent: &MvpAgent) -> ExtResult {
|
|
let session_ids: Vec<acp::SessionId> = agent.sessions.borrow().keys().cloned().collect();
|
|
|
|
if session_ids.is_empty() {
|
|
return ExtMethodResult::success(serde_json::json!({ "updated": 0 }))
|
|
.to_ext_response()
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()));
|
|
}
|
|
|
|
let managed = agent.get_managed_mcp_configs().await;
|
|
let mut updated = 0u32;
|
|
for session_id in &session_ids {
|
|
let Some(handle) = agent.sessions.borrow().get(session_id).cloned() else {
|
|
continue;
|
|
};
|
|
let cwd = std::path::PathBuf::from(&handle.info.cwd);
|
|
let compat = agent.cfg.borrow().compat_resolved;
|
|
// Re-seed the merge with the session's original client-provided MCP
|
|
// servers (e.g. a managed connector injected at `session/new` by a
|
|
// client session binding). `merge_managed_mcp_servers` already
|
|
// re-reads every disk source (config.toml, plugins, ~/.claude.json,
|
|
// ~/.cursor/mcp.json, .mcp.json) internally, so passing
|
|
// `load_mcp_servers()` output here was redundant — and silently
|
|
// dropped client servers that exist in no on-disk config, tearing
|
|
// them down on every config hot-reload.
|
|
let merged = crate::session::managed_mcp::merge_managed_mcp_servers(
|
|
handle.initial_client_mcp_servers.clone(),
|
|
&cwd,
|
|
&managed,
|
|
agent.plugin_registry_handle().snapshot().as_deref(),
|
|
&compat,
|
|
);
|
|
|
|
let (tx, _rx) = tokio::sync::oneshot::channel();
|
|
if handle
|
|
.cmd_tx
|
|
.send(SessionCommand::UpdateMcpServers {
|
|
mcp_servers: merged,
|
|
respond_to: tx,
|
|
})
|
|
.is_ok()
|
|
{
|
|
updated += 1;
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
updated,
|
|
total = session_ids.len(),
|
|
"reloaded MCP servers for active sessions"
|
|
);
|
|
ExtMethodResult::success(serde_json::json!({ "updated": updated }))
|
|
.to_ext_response()
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
|
}
|
|
|
|
// internal/reload_project_mcp_servers
|
|
|
|
/// Reload MCP servers for sessions whose `cwd` matches (or sits beneath)
|
|
/// the project root passed in `params.cwd`. Called by the config
|
|
/// hot-reload watcher when `<cwd>/.kigi/config.toml`,
|
|
/// `<cwd>/.mcp.json`, or `<cwd>/.claude.json` changes.
|
|
///
|
|
/// Sessions in unrelated cwds are intentionally NOT touched — that is
|
|
/// the whole point of [`crate::config::reloader::ConfigUpdate::
|
|
/// ProjectMcpServersChanged`] being a per-cwd variant. The legacy
|
|
/// [`handle_reload_all_mcp_servers`] is still the fan-out for global
|
|
/// `~/.kigi/config.toml` edits.
|
|
async fn handle_reload_project_mcp_servers(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
|
#[derive(Deserialize)]
|
|
struct Params {
|
|
cwd: String,
|
|
}
|
|
|
|
let params: Params = parse_params(args)?;
|
|
let target_cwd = std::path::PathBuf::from(¶ms.cwd);
|
|
|
|
// Collect (session_id, cwd) pairs once so we don't hold the
|
|
// `sessions` RefCell borrow across `.await` points.
|
|
let session_ids: Vec<(acp::SessionId, std::path::PathBuf)> = agent
|
|
.sessions
|
|
.borrow()
|
|
.iter()
|
|
.map(|(sid, h)| (sid.clone(), std::path::PathBuf::from(&h.info.cwd)))
|
|
.filter(|(_, cwd)| cwd_matches(cwd, &target_cwd))
|
|
.collect();
|
|
|
|
if session_ids.is_empty() {
|
|
return ExtMethodResult::success(serde_json::json!({ "updated": 0 }))
|
|
.to_ext_response()
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()));
|
|
}
|
|
|
|
let managed = agent.get_managed_mcp_configs().await;
|
|
let mut updated = 0u32;
|
|
for (session_id, cwd) in &session_ids {
|
|
let Some(handle) = agent.sessions.borrow().get(session_id).cloned() else {
|
|
continue;
|
|
};
|
|
// See `handle_reload_all_mcp_servers`: seed with the session's
|
|
// client-provided servers, not `load_mcp_servers()` — the merge
|
|
// re-reads all disk sources itself, and client-provided servers
|
|
// (session bindings) must survive config hot-reloads.
|
|
let merged = crate::session::managed_mcp::merge_managed_mcp_servers(
|
|
handle.initial_client_mcp_servers.clone(),
|
|
cwd,
|
|
&managed,
|
|
agent.plugin_registry_handle().snapshot().as_deref(),
|
|
&agent.cfg.borrow().compat_resolved,
|
|
);
|
|
|
|
let (tx, _rx) = tokio::sync::oneshot::channel();
|
|
if handle
|
|
.cmd_tx
|
|
.send(SessionCommand::UpdateMcpServers {
|
|
mcp_servers: merged,
|
|
respond_to: tx,
|
|
})
|
|
.is_ok()
|
|
{
|
|
updated += 1;
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
updated,
|
|
total = session_ids.len(),
|
|
cwd = %target_cwd.display(),
|
|
"reloaded project MCP servers for matching sessions"
|
|
);
|
|
ExtMethodResult::success(serde_json::json!({ "updated": updated }))
|
|
.to_ext_response()
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
|
}
|
|
|
|
/// Returns `true` iff `session_cwd` equals `target_cwd` or sits
|
|
/// beneath it (so a `<repo>/` edit reloads `<repo>/subdir/` sessions
|
|
/// too).
|
|
///
|
|
/// This uses `Path::starts_with`, which is
|
|
/// **component-aware** — `/repo-test` does NOT match `/repo` even
|
|
/// though the byte prefix matches. That is the desired behavior
|
|
/// (component-aware avoids the `/foo-bar` ⊂ `/foo` foot-gun). Paths
|
|
/// come from `SessionInfo::cwd` (always absolute) and the watcher's
|
|
/// emitted path (also absolute), so no canonicalization is needed
|
|
/// here. The `==` short-circuit is redundant (`Path::starts_with` is
|
|
/// reflexive) but kept for an explicit zero-allocation fast path.
|
|
fn cwd_matches(session_cwd: &std::path::Path, target_cwd: &std::path::Path) -> bool {
|
|
session_cwd == target_cwd || session_cwd.starts_with(target_cwd)
|
|
}
|
|
|
|
// internal/reload_models
|
|
|
|
/// Re-resolve the agent model list from config.toml. Called by the config
|
|
/// hot-reload watcher when `[model.*]` or `[models]` changes.
|
|
///
|
|
/// Re-reads config from disk, re-runs the same resolution logic as
|
|
/// `new_with_models()` for user TOML config entries, and swaps the model list
|
|
/// in-place. Prefetched (API) and default models are NOT re-fetched -- only
|
|
/// BYOK entries from config are updated.
|
|
fn handle_reload_models(agent: &MvpAgent) -> ExtResult {
|
|
let disk_config = crate::config::load_effective_config()
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
|
|
|
let toml_config = crate::agent::config::Config::new_from_toml_cfg(&disk_config)
|
|
.map_err(|e| acp::Error::internal_error().data(e))?;
|
|
|
|
// Merge TOML-derived model fields into the agent's in-memory config so
|
|
// runtime-only fields (#[serde(skip)]: remote_settings, endpoints, CLI
|
|
// flags) are preserved. Only model-related TOML fields are refreshed.
|
|
{
|
|
let agent_config = agent.cfg.borrow();
|
|
let overrides = crate::config::ModelOverrideConfig::resolve(
|
|
agent_config.web_search_model_override.as_deref(),
|
|
agent_config.session_summary_model_override.as_deref(),
|
|
&disk_config,
|
|
agent_config.remote_settings.as_ref(),
|
|
);
|
|
drop(agent_config);
|
|
let mut agent_config = agent.cfg.borrow_mut();
|
|
agent_config.models = toml_config.models.clone();
|
|
agent_config.config_models = toml_config.config_models.clone();
|
|
agent_config.web_search_model = overrides.web_search;
|
|
agent_config.session_summary_model = overrides.session_summary;
|
|
agent_config.image_description_model = overrides.image_description;
|
|
agent_config.prompt_suggest_model_pin = overrides.prompt_suggestion;
|
|
}
|
|
// Recompute the campaign overlay + `pre_campaign_default` (the catalog-miss
|
|
// fallback) so reload matches spawn; `new_from_toml_cfg` reset it to None.
|
|
{
|
|
let mut agent_config = agent.cfg.borrow_mut();
|
|
crate::util::config::sync_campaign_fields(&mut agent_config);
|
|
}
|
|
let merged_config = agent.cfg.borrow().clone();
|
|
|
|
agent.models_manager.apply_config(merged_config);
|
|
|
|
let count = agent.models_manager.models().len();
|
|
tracing::info!(count, "model list reloaded from config.toml");
|
|
ExtMethodResult::success(serde_json::json!({ "models": count }))
|
|
.to_ext_response()
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
|
}
|
|
|
|
// internal/reload_models_cache
|
|
|
|
/// Hot-reload the model catalog from `~/.kigi/models_cache.json` after an
|
|
/// external write detected by the config watcher.
|
|
///
|
|
/// Routed through the agent's ACP stream (injected by the
|
|
/// `ConfigUpdate::ModelsCacheChanged` arm in `agent/app.rs`) instead of being
|
|
/// applied directly on the manager from the config-update task: stream
|
|
/// requests are processed in order, so when `config.toml` and
|
|
/// `models_cache.json` change in the same watcher batch this runs strictly
|
|
/// after `reload_models`' `apply_config` accepted or rejected the new config,
|
|
/// rather than rebuilding the catalog and notifying clients mid-flight.
|
|
fn handle_reload_models_cache(agent: &MvpAgent) -> ExtResult {
|
|
agent.models_manager.reload_from_disk_cache();
|
|
ExtMethodResult::success(serde_json::json!({ "reloaded": true }))
|
|
.to_ext_response()
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
|
}
|
|
|
|
fn handle_auth_cleared(agent: &MvpAgent) -> ExtResult {
|
|
agent.disable_managed_gateway_tools_and_refresh_sessions();
|
|
ExtMethodResult::success(serde_json::json!({ "ok": true }))
|
|
.to_ext_response()
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
|
}
|
|
|
|
// plugins/reload
|
|
|
|
async fn handle_plugins_reload(agent: &MvpAgent) -> ExtResult {
|
|
// Rebuild the shared registry so future/new sessions clone the latest.
|
|
let session_cwd = agent
|
|
.sessions
|
|
.borrow()
|
|
.values()
|
|
.next()
|
|
.map(|h| std::path::PathBuf::from(&h.info.cwd));
|
|
let mut plugins = agent.cfg.borrow().plugins.clone();
|
|
plugins.merge_claude_enabled_plugins(session_cwd.as_deref());
|
|
let disk_cfg = plugins.to_discovery_config();
|
|
// Folder-trust gates repo-local project plugins (hooks/MCP). Resolve and
|
|
// record the verdict for this cwd (honoring the real remote), then gate
|
|
// plugins on it.
|
|
let project_trusted = session_cwd.as_deref().is_some_and(|c| {
|
|
let remote_settings = agent.cfg.borrow().remote_settings.clone();
|
|
crate::agent::folder_trust::resolve_and_record(c, remote_settings.as_ref(), false)
|
|
});
|
|
// Explicit desktop `x.ai/plugins/reload`: force a full local-install re-copy.
|
|
agent
|
|
.plugin_registry_handle()
|
|
.reload(session_cwd.as_deref(), &disk_cfg, project_trusted, true);
|
|
|
|
// Eagerly fan out the new registry to every live session: each adopts a
|
|
// cwd-correct snapshot (hooks + MCP + skills + client slash-command
|
|
// catalog), the same refresh the originating session of a reload gets.
|
|
agent.broadcast_plugin_registry_to_sessions(None);
|
|
|
|
super::to_ext_response(Ok(serde_json::json!({"ok": true})))
|
|
}
|
|
|
|
// commands/list
|
|
|
|
async fn handle_commands_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
|
let req: crate::session::slash_commands::ListCommandsRequest = parse_params(args)?;
|
|
|
|
let skills_config = agent.cfg.borrow().skills.clone();
|
|
let compat = agent.cfg.borrow().compat_resolved;
|
|
let availability = agent.command_availability();
|
|
|
|
// For a given cwd, compute the plugin registry the same way a session would
|
|
// at spawn time (via build_for_cwd) and the same way reload_plugins_impl does
|
|
// (ancestor project config walk + vendor compat merge). This is required so
|
|
// that `x.ai/commands/list` (the pull used by grok-desktop after session
|
|
// start) returns plugin-provided slash commands for the target cwd.
|
|
//
|
|
// The shared snapshot is only populated at agent boot (using process CWD)
|
|
// and by explicit reloads. In desktop<->docker (and ssh) setups the agent's
|
|
// launch CWD is unrelated to the user's chosen workspace dir, so relying on
|
|
// snapshot() alone meant the post-start pull returned no project plugin
|
|
// skills until the user manually reloaded.
|
|
let plugin_reg = if let Some(cwd_str) = &req.cwd {
|
|
let cwd = Path::new(cwd_str);
|
|
|
|
// Folder-trust gates repo-local project plugins (hooks/MCP). Resolve and
|
|
// record the verdict for this cwd (honoring the real remote) BEFORE the
|
|
// plugins-config read below: that read gates its project-paths merge on
|
|
// the recorded verdict, and a cold cwd (client-supplied, no session
|
|
// resolve yet) must not first take the gate's remote-less backstop —
|
|
// that would record a kill-switch-blind deny no later resolve can lift.
|
|
let remote_settings = agent.cfg.borrow().remote_settings.clone();
|
|
let project_trusted =
|
|
crate::agent::folder_trust::resolve_and_record(cwd, remote_settings.as_ref(), false);
|
|
|
|
// Effective [plugins] config (global + ancestor project configs +
|
|
// vendor compat merge), shared with reload_plugins_impl and the eager
|
|
// fan-out so the menu agrees with each session's registry for this cwd.
|
|
let disk_cfg = crate::config::resolve_effective_plugins_config(cwd).to_discovery_config();
|
|
|
|
// Fresh discovery for *this* cwd (includes .kigi/plugins under it, plus
|
|
// the cli --plugin-dir dirs). Does not mutate the shared snapshot.
|
|
agent
|
|
.plugin_registry_handle()
|
|
.build_for_cwd(cwd, &disk_cfg, &[], project_trusted)
|
|
} else {
|
|
// No cwd: global/user skills only (pre-session case). Use the boot snapshot.
|
|
agent.plugin_registry_handle().snapshot()
|
|
};
|
|
|
|
let response = crate::session::slash_commands::list_commands(
|
|
req.cwd.as_deref(),
|
|
&skills_config,
|
|
plugin_reg.as_deref(),
|
|
availability,
|
|
compat,
|
|
)
|
|
.await;
|
|
Ok(acp::ExtResponse::new(Arc::from(
|
|
serde_json::value::to_raw_value(&response)?,
|
|
)))
|
|
}
|
|
|
|
// session/fork
|
|
|
|
async fn handle_session_fork(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
|
use crate::session::fork::{ForkSessionRequest, fork_session};
|
|
|
|
let request: ForkSessionRequest = parse_params(args)?;
|
|
|
|
let response = fork_session(request)
|
|
.await
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
|
|
|
to_raw_response(&response)
|
|
}
|