M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,729 @@
|
||||
//! 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 + remote
|
||||
//! - `x.ai/session/delete` delete a session locally + remote
|
||||
//! - `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 rename_chat_conversation(agent, &req.session_id, &req.title).await;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if agent.is_writeback_storage()
|
||||
&& let Some(auth) = agent.current_auth()
|
||||
&& !auth.is_zdr_team()
|
||||
{
|
||||
use crate::remote::client::BackendClient;
|
||||
use crate::session::export::ExportedMetadata;
|
||||
|
||||
let mut metadata = ExportedMetadata::from_summary(summary);
|
||||
metadata.title = Some(req.title.clone());
|
||||
metadata.updated_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
if let Err(e) = BackendClient::new()
|
||||
.with_auth_manager(agent.auth_manager.clone())
|
||||
.save_session_data(&req.session_id, &[], Some(&metadata))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, session_id = %req.session_id, "failed to sync renamed title to backend");
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = if agent
|
||||
.auth_manager
|
||||
.current_or_expired()
|
||||
.is_some_and(|a| a.is_zdr_team())
|
||||
{
|
||||
None
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename_chat_conversation(
|
||||
agent: &MvpAgent,
|
||||
conversation_id: &str,
|
||||
title: &str,
|
||||
) -> ExtResult {
|
||||
use crate::remote::{ConvError, UpdateConversationBody};
|
||||
|
||||
let Some(client) = agent.conversations_client() else {
|
||||
return Err(acp::Error::invalid_request()
|
||||
.data("chat session rename requires the conversations lane (OIDC + chat feature)"));
|
||||
};
|
||||
|
||||
let body = UpdateConversationBody {
|
||||
title: Some(title.to_owned()),
|
||||
starred: None,
|
||||
};
|
||||
client
|
||||
.update_conversation(conversation_id, &body)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
ConvError::NoOauth => acp::Error::invalid_request()
|
||||
.data("chat session rename requires xAI OAuth credentials"),
|
||||
ConvError::Http { status: 404 } => acp::Error::invalid_request()
|
||||
.data(format!("conversation not found: {conversation_id}")),
|
||||
other => acp::Error::internal_error()
|
||||
.data(format!("chat conversation rename failed: {other}")),
|
||||
})?;
|
||||
|
||||
// If this conversation is open live, notify clients of the new title.
|
||||
let session_id = acp::SessionId::new(Arc::from(conversation_id));
|
||||
if agent.sessions.borrow().contains_key(&session_id) {
|
||||
notify_session_title(agent, session_id, title).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
session_id = %conversation_id,
|
||||
title = %title,
|
||||
"Chat conversation renamed"
|
||||
);
|
||||
|
||||
to_raw_response(&serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
// 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 soft_delete_chat_conversation(agent, &req.session_id).await;
|
||||
}
|
||||
|
||||
let session_id = acp::SessionId::new(Arc::from(req.session_id.as_str()));
|
||||
|
||||
// For writeback storage (non-ZDR): remote delete is authoritative for
|
||||
// the cloud history and runs first; on failure no local bits are
|
||||
// touched so the pager does not remove the row or toast success.
|
||||
let needs_remote =
|
||||
agent.is_writeback_storage() && agent.current_auth().is_some_and(|a| !a.is_zdr_team());
|
||||
|
||||
// Shared delete: remote-first, then local disk + FTS eviction.
|
||||
// Mirrored by the `grok sessions delete <id>` CLI path.
|
||||
crate::session::persistence::delete_session_history(
|
||||
&req.session_id,
|
||||
req.cwd.as_deref(),
|
||||
needs_remote,
|
||||
agent.auth_manager.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let crate::session::persistence::DeleteSessionError::Remote(_) = &e {
|
||||
tracing::warn!(?e, session_id = %req.session_id, "failed to delete remote session data");
|
||||
}
|
||||
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 }))
|
||||
}
|
||||
|
||||
async fn soft_delete_chat_conversation(agent: &MvpAgent, conversation_id: &str) -> ExtResult {
|
||||
use crate::remote::ConvError;
|
||||
|
||||
let Some(client) = agent.conversations_client() else {
|
||||
return Err(acp::Error::invalid_request()
|
||||
.data("chat session delete requires the conversations lane (OIDC + chat feature)"));
|
||||
};
|
||||
|
||||
client
|
||||
.soft_delete_conversation(conversation_id)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
ConvError::NoOauth => acp::Error::invalid_request()
|
||||
.data("chat session delete requires xAI OAuth credentials"),
|
||||
other => acp::Error::internal_error()
|
||||
.data(format!("chat conversation soft-delete failed: {other}")),
|
||||
})?;
|
||||
|
||||
let session_id = acp::SessionId::new(Arc::from(conversation_id));
|
||||
if agent.sessions.borrow().contains_key(&session_id) {
|
||||
agent.request_session_shutdown(&session_id);
|
||||
agent.remove_session(&session_id);
|
||||
}
|
||||
|
||||
tracing::info!(session_id = %conversation_id, "Chat conversation soft-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 agent_id = crate::util::agent_id::agent_id();
|
||||
let response = fork_session(request, &agent_id, Some(agent.auth_manager.clone()))
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
|
||||
to_raw_response(&response)
|
||||
}
|
||||
Reference in New Issue
Block a user