Files
Kigi-CLI/crates/codegen/kigi-bin/src/main.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

2411 lines
101 KiB
Rust

#![allow(
unused_imports,
unused_variables,
unused_mut,
unreachable_code,
dead_code
)]
#[cfg(all(feature = "jemalloc", unix))]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
#[cfg(all(feature = "jemalloc", feature = "release-dist", unix))]
mod jemalloc_malloc_conf {
/// jemalloc looks up `extern const char *malloc_conf` — a thin pointer,
/// not a Rust `&[u8]` fat pointer.
#[repr(transparent)]
struct MallocConfPtr(*const u8);
unsafe impl Sync for MallocConfPtr {}
static CONF: [u8; 63] = *b"prof:true,prof_active:false,lg_prof_sample:19,prof_final:false\0";
#[cfg(not(any(target_os = "macos", target_os = "ios")))]
#[used]
#[unsafe(export_name = "malloc_conf")]
static MALLOC_CONF: MallocConfPtr = MallocConfPtr(CONF.as_ptr());
#[cfg(any(target_os = "macos", target_os = "ios"))]
#[used]
#[unsafe(export_name = "_rjem_malloc_conf")]
static MALLOC_CONF: MallocConfPtr = MallocConfPtr(CONF.as_ptr());
}
use anyhow::Result;
use kigi_shell::agent::app::{run_leader, run_stdio_agent};
use kigi_shell::agent::config::Config as AgentConfig;
use kigi_shell::leader::{
ClientCapabilities, ClientMode, ControlCommand, LeaderCapabilities, LeaderDescriptor,
LeaderRegistration, LeaderTarget, leader_is_older_than,
};
use kigi_shell::leader::{ControlPayload, LeaderClient, connect_or_spawn, default_socket_path};
use kigi_tui::app::{
AgentCmd, Command, LeaderMgmtArgs, LeaderMgmtCommand, LeaderTargetArgs, PagerArgs,
resolve_use_leader,
};
use kigi_tui::client_identity::PAGER_CLIENT_VERSION;
use kigi_update::{UpdateConfig, auto_update, enforce_minimum_version_or_exit};
use std::env;
use std::net::SocketAddr;
use tokio_util::sync::CancellationToken;
/// Apply global endpoint CLI args to an existing config.
fn apply_agent_endpoint_args(agent_args: &kigi_tui::app::AgentArgs, config: &mut AgentConfig) {
if let Some(v) = &agent_args.coding_api_base_url {
config.endpoints.coding_api_base_url = Some(v.clone());
}
if let Some(v) = &agent_args.api_base_url {
config.endpoints.api_base_url = Some(v.clone());
}
}
/// Resolve --agent-profile path: canonicalize and verify the file exists.
fn resolve_agent_profile_path(path: &std::path::Path) -> std::path::PathBuf {
match dunce::canonicalize(path) {
Ok(abs) if abs.is_file() => abs,
Ok(abs) => {
eprintln!(
"error: --agent-profile path is not a file: {}",
abs.display()
);
std::process::exit(1);
}
Err(e) => {
eprintln!("error: --agent-profile path '{}': {}", path.display(), e);
std::process::exit(1);
}
}
}
/// Print startup information for the serve command.
fn print_serve_startup_info(bind_addr: SocketAddr, secret: &str) {
eprintln!();
eprintln!(" Kigi agent server starting...");
eprintln!();
eprintln!(" Address: {}:{}", bind_addr.ip(), bind_addr.port());
eprintln!(" Secret: {}", secret);
eprintln!();
eprintln!(
" WebSocket URL: ws://{}/ws?server-key={}",
bind_addr, secret
);
eprintln!();
}
/// Entrypoint tag for `kigi -p`; keys the quiet stderr default in `init_tracing_simple`.
const HEADLESS_ENTRYPOINT: &str = "headless";
/// Initialize simple tracing for non-TUI agent modes.
fn init_tracing_simple(app_entrypoint: &'static str) {
use kigi_log::debug_log::RMCP_SSE_NOISE_TARGET;
use tracing_subscriber::{EnvFilter, Layer as _, fmt, layer::SubscriberExt as _};
let default_filter = if app_entrypoint == HEADLESS_ENTRYPOINT {
"off"
} else {
"error"
};
let env_filter = match EnvFilter::try_from_default_env() {
Ok(filter) => filter.add_directive(
format!("{RMCP_SSE_NOISE_TARGET}=error")
.parse()
.expect("static rmcp directive must parse"),
),
Err(_) => EnvFilter::new(default_filter),
};
let fmt_layer = fmt::layer()
.with_target(false)
.with_ansi(true)
.with_writer(std::io::stderr);
let registry = tracing_subscriber::registry()
.with(fmt_layer.with_filter(env_filter))
.with(kigi_log::sampling_log::layer())
.with(kigi_log::instrumentation::layer())
.with(kigi_log::hooks_log::layer());
kigi_log::debug_log::install_firehose(registry, app_entrypoint);
}
/// `kigi setup`: rendering + exit codes only; fetch logic lives in `kigi_shell::managed_config`.
/// `json` prints the served configuration instead of installing it.
async fn run_setup_command(json: bool) {
use kigi_shell::managed_config::{self, SetupOutcome};
if !managed_config::has_principal() {
eprintln!("No deployment key or team sign-in found.");
eprintln!();
eprintln!("To install managed configuration, sign in with a team using `kigi login`,");
eprintln!("or set a deployment key:");
eprintln!();
if cfg!(unix) {
eprintln!(" export KIGI_DEPLOYMENT_KEY=<your-key>");
} else {
eprintln!(" $env:KIGI_DEPLOYMENT_KEY=\"<your-key>\"");
}
eprintln!(" kigi setup");
eprintln!();
eprintln!("Or add the key to ~/.kigi/config.toml:");
eprintln!();
eprintln!(" [endpoints]");
eprintln!(" deployment_key = \"<your-key>\"");
eprintln!();
eprintln!(
"If you don't have a deployment key, contact your organization's Kigi administrator."
);
std::process::exit(1);
}
if json {
match managed_config::fetch_setup_report().await {
Ok(report) => {
let out = serde_json::to_string_pretty(&report)
.expect("setup report has no non-serializable values");
println!("{out}");
if !report.configured {
eprintln!(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
);
}
}
Err(e) => {
eprintln!("Couldn't fetch managed configuration. {e}");
std::process::exit(1);
}
}
return;
}
match managed_config::run_setup().await {
SetupOutcome::Installed => eprintln!("Applied managed configuration."),
SetupOutcome::NothingConfigured => {
eprintln!(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
);
}
SetupOutcome::Failed(e) => {
eprintln!("Couldn't apply managed configuration. {e}");
std::process::exit(1);
}
}
}
async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> {
match args.command {
LeaderMgmtCommand::Kill => kill_leaders().await,
LeaderMgmtCommand::List { json } => {
let leaders = kigi_shell::leader::discover_leaders().await;
if json {
let payload: Vec<_> = leaders.iter().map(leader_descriptor_json).collect();
println!(
"{}",
serde_json::to_string(&serde_json::Value::Array(payload))?
);
} else if leaders.is_empty() {
println!("No leader candidates found.");
} else {
for d in &leaders {
print_leader_descriptor(d);
}
}
Ok(())
}
LeaderMgmtCommand::Info { target, json } => {
let (descriptor, client) = connect_to_leader(&target).await?;
let info = match ensure_control_caps(client.registration()) {
Ok(_) => client
.send_control(ControlCommand::GetLeaderInfo)
.await
.ok()
.and_then(|r| r.ok()),
Err(_) => None,
};
if json {
let payload = leader_info_json(&descriptor, client.registration(), info.as_ref())?;
println!("{}", serde_json::to_string(&payload)?);
} else if let Some(info) = info {
println!("{info:#?}");
} else {
print_leader_descriptor(&descriptor);
eprintln!(
" (detailed info unavailable — leader does not advertise control capabilities)"
);
}
client.cancel();
Ok(())
}
}
}
async fn kill_leaders() -> Result<()> {
let leaders = kigi_shell::leader::discover_leaders().await;
if leaders.is_empty() {
eprintln!("No leader candidates found.");
return Ok(());
}
let mut killed = 0u32;
let mut cleaned = 0u32;
for d in &leaders {
let Some(pid) = leader_pid(d) else {
continue;
};
if !kigi_shell::util::is_kigi_process(pid) {
if let Some(ref lock) = d.lock_path {
eprintln!(" PID {pid} is not a kigi process, removing stale lock");
let _ = std::fs::remove_file(lock);
cleaned += 1;
}
if let Some(ref sock) = d.socket_path {
let _ = std::fs::remove_file(sock);
}
continue;
}
eprintln!(" Killing leader PID {pid}");
if let Err(e) = kigi_shell::util::kill_process_by_pid(pid) {
eprintln!(" warning: failed to terminate PID {pid}: {e}");
continue;
}
killed += 1;
}
if killed > 0 {
eprintln!("Killed {killed} leader process(es).");
} else if cleaned > 0 {
eprintln!("No live leader processes found (cleaned up {cleaned} stale lock(s)).");
} else {
eprintln!("No live leader processes found.");
}
Ok(())
}
fn resolve_target(args: &LeaderTargetArgs) -> LeaderTarget {
match args.pid {
Some(pid) => LeaderTarget::Pid(pid),
None => LeaderTarget::Default,
}
}
async fn connect_to_leader(
args: &LeaderTargetArgs,
) -> Result<(LeaderDescriptor, kigi_shell::leader::LeaderClient)> {
let target = resolve_target(args);
let selection = kigi_shell::leader::resolve_leader_target(target)
.await
.map_err(|e| anyhow::anyhow!("{}", e.message))?;
let socket_path = selection
.socket_path()
.ok_or_else(|| anyhow::anyhow!("resolved leader target did not include a socket path"))?;
let client = kigi_shell::leader::LeaderClient::connect(
socket_path.to_path_buf(),
"kigi-pager-leader-cli",
ClientMode::Stdio,
ClientCapabilities::default(),
)
.await?;
Ok((selection.descriptor, client))
}
/// Prefer socket-verified live PID over a possibly-recycled lock file PID.
fn leader_pid(d: &LeaderDescriptor) -> Option<u32> {
d.live_info.as_ref().map(|li| li.pid).or(d.pid_from_lock)
}
fn print_leader_descriptor(d: &LeaderDescriptor) {
let pid = leader_pid(d)
.map(|p| p.to_string())
.unwrap_or_else(|| "?".into());
let sock = d
.socket_path
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "?".into());
let state = format!("{:?}", d.classification);
eprintln!(" PID {pid} ({state}) -- {sock}");
}
fn leader_descriptor_json(d: &LeaderDescriptor) -> serde_json::Value {
serde_json::json!(
{ "pid" : leader_pid(d), "pidFromLock" : d.pid_from_lock, "pidLive" : d.live_info
.as_ref().map(| li | li.pid), "classification" : format!("{:?}", d
.classification), "socketPath" : d.socket_path.as_deref().map(| p | p.display()
.to_string()), "lockPath" : d.lock_path.as_deref().map(| p | p.display()
.to_string()), "socketSuffix" : d.socket_suffix, }
)
}
fn leader_info_json(
d: &LeaderDescriptor,
reg: &LeaderRegistration,
info: Option<&kigi_shell::leader::ControlPayload>,
) -> Result<serde_json::Value> {
let mut val = leader_descriptor_json(d);
val["clientId"] = serde_json::json!(reg.client_id);
if let Some(info) = info {
val["info"] = serde_json::to_value(info)?;
}
Ok(val)
}
fn ensure_control_caps(reg: &LeaderRegistration) -> Result<&LeaderCapabilities> {
reg.leader_capabilities
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Leader does not advertise capabilities (legacy version)"))
}
/// How to rebuild one session's `session/load` after a leader reconnect.
#[derive(Default, Clone)]
struct CachedSession {
/// Verbatim `session/load` request JSON (preferred replay form: preserves
/// the client's exact cwd / mcpServers / meta). `None` when the session
/// was only ever created via `session/new` — the load is synthesized.
load_request_json: Option<String>,
/// `cwd` captured from `session/new` / `session/load` params.
cwd: Option<String>,
/// `mcpServers` captured from `session/new` / `session/load` params.
mcp_servers_json: Option<String>,
}
/// ACP state cached from the stdio stream for replay after leader reconnect.
///
/// Tracks EVERY session the external client has open (IDE clients drive
/// multiple sessions over one bridge), not just the most recent one — a
/// leader crash must restore all of them or the others die with
/// "unknown session id" on their next prompt.
#[derive(Default, Clone)]
struct StdioReplayState {
initialize_json: Option<String>,
/// Sessions to restore on reconnect, keyed by session id, in first-seen
/// order (Vec keeps replay order deterministic).
sessions: Vec<(String, CachedSession)>,
/// cwd/mcp from the most recent `session/new` REQUEST whose response has
/// not been observed yet. Folded into `sessions` when the response
/// carrying the assigned session id arrives. Never replayed while
/// unconfirmed (the id is unknown; the client's own request died with the
/// old leader and is its to retry).
pending_new: Option<CachedSession>,
/// Most recently created/loaded session id — reported in
/// `kigi/leader_reconnected` as the primary restored session.
last_session_id: Option<String>,
}
impl StdioReplayState {
fn upsert_session(&mut self, sid: &str, cached: CachedSession) {
if let Some((_, existing)) = self.sessions.iter_mut().find(|(id, _)| id == sid) {
*existing = cached;
} else {
self.sessions.push((sid.to_string(), cached));
}
}
fn remove_session(&mut self, sid: &str) {
self.sessions.retain(|(id, _)| id != sid);
if self.last_session_id.as_deref() == Some(sid) {
self.last_session_id = None;
}
}
}
fn cache_outgoing_acp_state(msg: &str, state: &std::sync::Mutex<StdioReplayState>) {
let Ok(json) = serde_json::from_str::<serde_json::Value>(msg) else {
return;
};
let method = json
.get("method")
.and_then(|m| m.as_str())
.unwrap_or_default();
match method {
"initialize" => {
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
s.initialize_json = Some(msg.to_string());
}
"session/load" => {
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
if let Some(params) = json.get("params") {
let sid = params
.get("sessionId")
.or_else(|| params.get("session_id"))
.and_then(|v| v.as_str());
if let Some(sid) = sid {
let cached = CachedSession {
load_request_json: Some(msg.to_string()),
cwd: params
.get("cwd")
.and_then(|v| v.as_str())
.map(str::to_string),
mcp_servers_json: params
.get("mcpServers")
.and_then(|m| serde_json::to_string(m).ok()),
};
s.upsert_session(sid, cached);
s.last_session_id = Some(sid.to_string());
}
}
}
"session/new" => {
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
let params = json.get("params");
s.pending_new = Some(CachedSession {
load_request_json: None,
cwd: params
.and_then(|p| p.get("cwd"))
.and_then(|v| v.as_str())
.map(str::to_string),
mcp_servers_json: params
.and_then(|p| p.get("mcpServers"))
.and_then(|m| serde_json::to_string(m).ok()),
});
}
"kigi/session/close" | "_kigi/session/close" => {
if let Some(sid) = json
.get("params")
.and_then(|p| p.get("sessionId").or_else(|| p.get("session_id")))
.and_then(|v| v.as_str())
{
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
s.remove_session(sid);
}
}
_ => {}
}
}
fn cache_incoming_session_id(msg: &str, state: &std::sync::Mutex<StdioReplayState>) {
let Ok(json) = serde_json::from_str::<serde_json::Value>(msg) else {
return;
};
if let Some(sid) = json
.get("result")
.and_then(|r| r.get("sessionId").or_else(|| r.get("session_id")))
.and_then(|v| v.as_str())
{
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
if let Some(pending) = s.pending_new.take() {
s.upsert_session(sid, pending);
}
s.last_session_id = Some(sid.to_string());
}
}
/// Synthetic JSON-RPC id for the `session/load` the bridge constructs itself
/// (when the external client only ever sent `session/new`). A string id can
/// never collide with a numeric id the external client may have in flight.
const REPLAY_LOAD_REQUEST_ID: &str = "kigi/leader-replay/session-load";
/// Max silence between two messages from the leader during a replayed request.
/// A `session/load` streams replay notifications continuously once it starts,
/// but the pre-replay phase (MCP resolution, session file reads) can be quiet
/// for a while on large sessions.
const REPLAY_RECV_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// Overall deadline for one replayed request's response.
const REPLAY_RESPONSE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(180);
/// Outcome of one replayed request (see [`replay_request_until_response`]).
enum ReplayOutcome {
/// The response for the replayed request arrived with a `result`.
ResponseOk,
/// The response arrived but carried an `error` (e.g. session not found).
ResponseErr,
/// The connection closed / timed out / stdout broke before the response.
Failed,
}
/// True when `msg` is the JSON-RPC *response* to the request with `expected_id`
/// (no `method` key + matching `id`).
fn parse_replay_response(msg: &str, expected_id: &serde_json::Value) -> Option<ReplayOutcome> {
let json = serde_json::from_str::<serde_json::Value>(msg).ok()?;
if json.get("method").is_some() {
return None;
}
if json.get("id") != Some(expected_id) {
return None;
}
if json.get("error").is_some() {
Some(ReplayOutcome::ResponseErr)
} else {
Some(ReplayOutcome::ResponseOk)
}
}
/// Send one replayed request to the (new) leader and pump messages until its
/// response arrives.
///
/// `session/load` emits the full replay stream (session/update notifications)
/// BEFORE its response, so "wait for the next message" is not "wait for the
/// response". Everything that is not the response itself is forwarded verbatim
/// to the external client's stdout — exactly what the pre-reconnect stream
/// would have carried. Only the response to the replayed request is swallowed
/// (the external client already received a response for its original send and
/// must not see a duplicate or unknown-id response).
///
/// Returning before the `session/load` response is the root cause of the
/// "unknown session id" failures after a leader crash: the bridge declared
/// the reconnect complete while the new leader was still loading the session,
/// and the client's next `session/prompt` raced (and lost against) the load.
async fn replay_request_until_response(
tx: &tokio::sync::mpsc::UnboundedSender<String>,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
stdout: &mut (impl tokio::io::AsyncWrite + Unpin),
request_json: &str,
what: &str,
) -> ReplayOutcome {
use tokio::io::AsyncWriteExt as _;
let expected_id = serde_json::from_str::<serde_json::Value>(request_json)
.ok()
.and_then(|v| v.get("id").cloned());
if tx.send(request_json.to_string()).is_err() {
tracing::warn!(what, "replay: failed to send request");
return ReplayOutcome::Failed;
}
let Some(expected_id) = expected_id else {
return ReplayOutcome::ResponseOk;
};
let deadline = tokio::time::Instant::now() + REPLAY_RESPONSE_DEADLINE;
loop {
let now = tokio::time::Instant::now();
if now >= deadline {
tracing::warn!(what, "replay: response deadline exceeded");
return ReplayOutcome::Failed;
}
let per_recv = REPLAY_RECV_TIMEOUT.min(deadline - now);
match tokio::time::timeout(per_recv, rx.recv()).await {
Ok(Some(msg)) => {
if let Some(outcome) = parse_replay_response(&msg, &expected_id) {
tracing::debug!(
what,
ok = matches!(outcome, ReplayOutcome::ResponseOk),
"replay: response received"
);
return outcome;
}
if stdout.write_all(msg.as_bytes()).await.is_err()
|| stdout.write_all(b"\n").await.is_err()
|| stdout.flush().await.is_err()
{
tracing::warn!(what, "replay: stdout closed while forwarding");
return ReplayOutcome::Failed;
}
}
Ok(None) => {
tracing::warn!(what, "replay: leader closed before response");
return ReplayOutcome::Failed;
}
Err(_) => {
tracing::warn!(what, "replay: timed out waiting for response");
return ReplayOutcome::Failed;
}
}
}
}
/// Build the `session/load` JSON to replay for one cached session: the
/// verbatim client request when available, else a synthesized load from the
/// captured `session/new` parameters.
fn replay_load_json(sid: &str, cached: &CachedSession) -> Option<String> {
if let Some(ref verbatim) = cached.load_request_json {
return Some(verbatim.clone());
}
let cwd = cached.cwd.as_deref()?;
let mut params = serde_json::json!({ "sessionId" : sid, "cwd" : cwd, });
if let Some(ref mcp_raw) = cached.mcp_servers_json
&& let Ok(mcp_val) = serde_json::from_str::<serde_json::Value>(mcp_raw)
{
params["mcpServers"] = mcp_val;
}
Some(
serde_json::json!(
{ "jsonrpc" : "2.0", "id" : REPLAY_LOAD_REQUEST_ID, "method" :
"session/load", "params" : params, }
)
.to_string(),
)
}
/// Replay cached `initialize` + every cached `session/load` to a freshly
/// (re-)elected leader, blocking until the leader has actually finished
/// loading EACH session (loads are sent strictly sequentially, each awaiting
/// its response — the synthesized-id reuse relies on this ordering).
///
/// Returns the primary restored session id (the most recently active one,
/// falling back to any successfully restored session). `None` when there was
/// nothing to replay or every restore failed — callers emit
/// `kigi/leader_reconnected` with empty params in that case, signalling the
/// external client to re-establish state itself.
async fn replay_acp_state_after_reconnect(
tx: &tokio::sync::mpsc::UnboundedSender<String>,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
stdout: &mut (impl tokio::io::AsyncWrite + Unpin),
state: &StdioReplayState,
) -> Option<String> {
if let Some(ref init_json) = state.initialize_json {
match replay_request_until_response(tx, rx, stdout, init_json, "initialize").await {
ReplayOutcome::ResponseOk => {}
ReplayOutcome::ResponseErr => {
tracing::warn!("replay: initialize was rejected by new leader");
return None;
}
ReplayOutcome::Failed => return None,
}
} else {
tracing::debug!("replay: no cached initialize; skipping ACP replay");
return None;
}
if state.sessions.is_empty() {
tracing::debug!("replay: no sessions to replay");
return None;
}
let mut restored: Vec<String> = Vec::new();
for (sid, cached) in &state.sessions {
let Some(load_json) = replay_load_json(sid, cached) else {
tracing::warn!(
session_id = % sid, "replay: no way to rebuild session/load; skipping"
);
continue;
};
match replay_request_until_response(tx, rx, stdout, &load_json, "session/load").await {
ReplayOutcome::ResponseOk => {
tracing::info!(session_id = % sid, "replay: session restored");
restored.push(sid.clone());
}
ReplayOutcome::ResponseErr => {
tracing::warn!(
session_id = % sid, "replay: session/load was rejected by new leader"
);
}
ReplayOutcome::Failed => {
tracing::warn!(
session_id = % sid,
"replay: transport failure during session/load; aborting remaining replays"
);
break;
}
}
}
state
.last_session_id
.as_ref()
.filter(|sid| restored.iter().any(|r| r == *sid))
.cloned()
.or_else(|| restored.last().cloned())
}
/// Flush observability, then exit. Used by the agent/headless signal handler.
///
/// Does NOT write terminal escape codes — agent mode never enables TUI modes.
/// The TUI has its own signal handler (`app::signal_handler`) that does the
/// full crossterm teardown.
fn shutdown_and_flush_telemetry(exit_code: i32) -> ! {
kigi_log::debug_log::flush();
std::process::exit(exit_code);
}
/// Emitted by both leader guards (server mode and leader-connect) so the two sites
/// can't drift.
const PLUGIN_DIR_LEADER_WARNING: &str = "kigi: --plugin-dir is ignored in leader mode; run with --no-leader to \
load per-process plugins";
/// Run the `agent` subcommand, dispatching to the appropriate mode.
async fn run_agent_command(
agent_args: Box<kigi_tui::app::AgentArgs>,
permission_mode_flag: Option<String>,
trust: bool,
no_auto_update: bool,
disable_web_search: bool,
update_config: &UpdateConfig,
) -> Result<()> {
let _signal_flush = tokio::spawn(async {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut term = signal(SignalKind::terminate()).ok();
let mut hup = signal(SignalKind::hangup()).ok();
tokio::select! {
_ = tokio::signal::ctrl_c() => { shutdown_and_flush_telemetry(130); } _ =
async { if let Some(sig) = term.as_mut() { let _ = sig.recv(). await; }
else { std::future::pending::< () > (). await; } } => {
shutdown_and_flush_telemetry(143); } _ = async { if let Some(sig) = hup
.as_mut() { let _ = sig.recv(). await; } else { std::future::pending::<
() > (). await; } } => { shutdown_and_flush_telemetry(129); }
}
}
#[cfg(not(unix))]
{
if tokio::signal::ctrl_c().await.is_ok() {
shutdown_and_flush_telemetry(130);
}
}
});
init_tracing_simple("agent");
kigi_log::instrumentation::install_panic_hook();
if trust {
match std::env::current_dir() {
Ok(cwd) => kigi_shell::agent::folder_trust::grant_folder_trust(&cwd),
Err(e) => {
tracing::warn!(
error = % e, "--trust: failed to resolve cwd; folder not trusted"
)
}
}
}
// Fire-and-forget model-catalog warmup (nothing joins the handle now that
// the xAI settings fetch it used to carry is gone).
drop(kigi_shell::agent::models::start_early_prefetch(None));
kigi_shell::agent::mvp_agent::warm_async_http_client();
tokio::task::spawn_blocking(|| {});
let mode = agent_args.mode.clone().unwrap_or(AgentCmd::Stdio);
let is_stdio = matches!(mode, AgentCmd::Stdio);
let is_leader = matches!(mode, AgentCmd::Leader(_));
if !is_stdio && !is_leader {
eprintln!(
"Kigi v{}",
kigi_version::display_version_with_commit(
env!("VERSION_WITH_COMMIT"),
kigi_update::channel_label(),
)
);
if should_check_for_updates(no_auto_update) {
auto_update::run_update_if_available(
auto_update::UpdateRunMode::NonBlocking,
false,
update_config,
)
.await
.ok();
}
}
let raw_config = kigi_shell::config::load_effective_config()
.map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))?;
let mut agent_config = AgentConfig::new_from_toml_cfg(&raw_config)
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {}", e))?;
agent_config.default_model_override = agent_args.model.clone();
agent_config.reasoning_effort_override = agent_args
.reasoning_effort
.as_deref()
.and_then(kigi_shell::sampling::types::parse_canonical_effort_token);
let launch_yolo = kigi_shell::util::config::effective_yolo_for_launch(
agent_args.yolo,
permission_mode_flag.as_deref(),
None,
);
if let Some(warning) = launch_yolo.blocked_warning {
eprintln!("kigi: {warning}");
}
agent_config.default_yolo_mode = launch_yolo.yolo;
agent_config.default_auto_mode = kigi_shell::util::config::effective_auto_for_launch(
agent_args.yolo,
permission_mode_flag.as_deref(),
None,
);
agent_config.agent_profile_path = agent_args
.agent_profile
.as_deref()
.map(resolve_agent_profile_path);
agent_config.client_version = Some(PAGER_CLIENT_VERSION.to_string());
if is_leader && !agent_args.plugin_dirs.is_empty() {
eprintln!("{PLUGIN_DIR_LEADER_WARNING}");
} else {
agent_config.plugins.cli_plugin_dirs = agent_args.canonical_plugin_dirs();
}
apply_agent_endpoint_args(&agent_args, &mut agent_config);
agent_config.resolve_runtime_fields(&kigi_shell::agent::config::RuntimeResolutionContext {
raw_config: &raw_config,
remote_settings: None,
cwd: None,
is_headless: !is_leader,
cli_subagents: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
disable_web_search,
todo_gate: false,
laziness_debug_log: None,
storage_mode: None,
});
let agent_memory_config = agent_config.memory_config.clone();
let leader_eligible = is_stdio;
let (use_leader, policy_disable_reason) = resolve_use_leader(
agent_args.leader,
agent_args.no_leader,
&raw_config,
leader_eligible,
);
tracing::info!(use_leader, ?policy_disable_reason, "leader mode resolved");
if stdio_direct_update_eligible(is_stdio, use_leader)
&& should_check_for_updates(no_auto_update)
{
let update_config = update_config.clone();
tokio::spawn(async move {
auto_update::run_update_if_available(
auto_update::UpdateRunMode::NonBlocking,
false,
&update_config,
)
.await
.ok();
});
}
if use_leader {
if !agent_args.plugin_dirs.is_empty() {
eprintln!("{PLUGIN_DIR_LEADER_WARNING}");
}
use kigi_shell::leader::{
ClientCapabilities, ClientMode, LeaderReconnector, ReconnectPolicy, connect_or_spawn,
};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex as TokioMutex;
// Only `agent stdio` is leader-eligible (see `leader_eligible` above).
let mode = ClientMode::Stdio;
let default_model = agent_config
.default_model_override
.clone()
.or(agent_config.models.default.clone());
let client_type = std::env::args().collect::<Vec<_>>().join(" ");
let capabilities = ClientCapabilities {
yolo_mode: launch_yolo.yolo,
auto_mode: agent_config.default_auto_mode && !launch_yolo.yolo,
default_model,
client_version: Some(PAGER_CLIENT_VERSION.to_string()),
code_nav_enabled: false,
terminal: false,
fs_read: false,
fs_write: false,
};
let conn = connect_or_spawn(&client_type, mode, capabilities.clone()).await?;
let (tx, rx) = conn.into_channels();
let (status_tx, _status_rx) = LeaderReconnector::status_channel();
let reconnector = LeaderReconnector::new(&client_type, mode, capabilities, status_tx);
let cancel = CancellationToken::new();
{
{
let replay_state = Arc::new(std::sync::Mutex::new(StdioReplayState::default()));
let leader_tx = Arc::new(TokioMutex::new(tx));
let leader_tx_stdin = leader_tx.clone();
let replay_state_stdin = replay_state.clone();
let cancel_stdin = cancel.clone();
let stdin_task = tokio::spawn(async move {
let mut stdin_lines = kigi_acp_lib::spawn_stdin_line_reader();
loop {
tokio::select! {
biased; _ = cancel_stdin.cancelled() => break, maybe_line =
stdin_lines.recv() => { let Some(line) = maybe_line else {
break }; let line = String::from_utf8_lossy(& line); let
trimmed = line.trim_end_matches(['\r', '\n']).to_string(); if
trimmed.is_empty() { continue; }
if trimmed
.contains("\"initialize\"") || trimmed
.contains("\"session/load\"") || trimmed
.contains("\"session/new\"") { cache_outgoing_acp_state(&
trimmed, & replay_state_stdin); } let send_deadline =
tokio::time::Instant::now() +
std::time::Duration::from_secs(300); loop { { let tx =
leader_tx_stdin.lock(). await; if tx.send(trimmed.clone())
.is_ok() { break; } } if cancel_stdin.is_cancelled() ||
tokio::time::Instant::now() >= send_deadline {
tracing::error!("stdio bridge: dropping client message after \
reconnect retries were exhausted");
break; }
tokio::time::sleep(std::time::Duration::from_millis(250)).
await; } }
}
}
});
let cancel_stdout = cancel.clone();
let stdout_task = tokio::spawn(async move {
let mut stdout = tokio::io::stdout();
let mut rx = rx;
loop {
match rx.recv().await {
Some(ref msg) => {
if msg.contains("\"sessionId\"") || msg.contains("\"session_id\"") {
cache_incoming_session_id(msg, &replay_state);
}
if stdout.write_all(msg.as_bytes()).await.is_err()
|| stdout.write_all(b"\n").await.is_err()
|| stdout.flush().await.is_err()
{
break;
}
}
None => {
tracing::warn!(
"Leader disconnected (stdio), attempting reconnect..."
);
match reconnector
.reconnect(ReconnectPolicy::bounded(), &cancel_stdout)
.await
{
Ok((new_tx, mut new_rx, _disconnect_rx)) => {
tracing::info!("Reconnected to leader (stdio)");
let replayed_session_id = {
let mut tx_guard = leader_tx.lock().await;
*tx_guard = new_tx;
let state = replay_state
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
replay_acp_state_after_reconnect(
&tx_guard,
&mut new_rx,
&mut stdout,
&state,
)
.await
};
rx = new_rx;
reconnector.notify_connected();
let params = match replayed_session_id {
Some(ref sid) => {
serde_json::json!({ "sessionId" : sid }).to_string()
}
None => "{}".to_string(),
};
let notification = format!(
r#"{{"jsonrpc":"2.0","method":"kigi/leader_reconnected","params":{params}}}"#
);
let _ = stdout.write_all(notification.as_bytes()).await;
let _ = stdout.write_all(b"\n").await;
let _ = stdout.flush().await;
continue;
}
Err(e) => {
tracing::error!(error = % e, "Failed to reconnect (stdio)");
cancel_stdout.cancel();
break;
}
}
}
}
}
});
tokio::select! {
_ = stdin_task => {} _ = stdout_task => {}
}
return Ok(());
}
}
}
match mode {
AgentCmd::Stdio => run_stdio_agent(&agent_config, None, agent_memory_config).await,
AgentCmd::Serve(a) => {
let secret = a.get_secret();
let server_config = kigi_shell::agent::ServerConfig {
bind_addr: a.bind,
secret: secret.clone(),
};
print_serve_startup_info(a.bind, &secret);
kigi_shell::agent::run_agent_server(server_config, agent_config).await
}
AgentCmd::Leader(a) => {
let leader_auto_update = if !should_check_for_updates(
no_auto_update || a.no_auto_update,
) {
tracing::info!("Leader auto-update disabled");
None
} else {
let update_config_for_leader = update_config.clone();
Some(kigi_shell::agent::app::LeaderAutoUpdateConfig {
check_interval: std::time::Duration::from_secs(60 * 60),
check_fn: Box::new(move || {
let uc = update_config_for_leader.clone();
Box::pin(async move {
let current_config = kigi_shell::util::config::load_config().await;
if current_config.cli.auto_update == Some(false) {
return false;
}
match auto_update::ensure_latest_on_disk(&uc).await {
Ok(outcome) => {
if let Some(v) = &outcome.installed {
if let Err(e) = kigi_shell::managed_config::sync().await {
tracing::warn!(
"Leader auto-update: managed config refresh failed: {e}"
);
}
tracing::info!(
"Leader auto-update: v{v} installed successfully"
);
} else if outcome.relaunch_needed {
tracing::info!(
"Leader auto-update: newer binary already on disk, \
relaunching without download"
);
}
outcome.relaunch_needed
}
Err(e) => {
tracing::warn!(
"Leader auto-update: check/download failed, \
staying alive: {e:#}"
);
false
}
}
})
}),
})
};
run_leader(
&agent_config,
a.no_exit_on_disconnect,
leader_auto_update,
agent_memory_config,
)
.await
}
}
}
/// Raise the per-process file descriptor soft limit on macOS.
///
/// macOS has a conservative default soft `RLIMIT_NOFILE` (256) that is easily
/// exceeded by parallel directory walking + file copying in worktree creation,
/// stdio MCP servers, tool subprocesses, and async runtime sockets.
///
/// We raise the soft limit toward the hard limit, capped at 8192 to stay below
/// `FD_SETSIZE` (1024 on macOS) safety boundaries in any C dependency that may
/// still use `select(2)` -- Rust std + tokio use `kqueue`, but vendored C code
/// can corrupt the stack if it select()'s on an fd >= FD_SETSIZE. 8192 also
/// keeps fork-time fd-table iteration cheap for any child that does
/// "close all fds up to rlim_cur" on exec.
///
/// Best-effort: silently ignores all errors (process limits can be tightened by
/// containers/cgroups and we should never block startup on a non-essential
/// optimization).
#[cfg(target_os = "macos")]
fn raise_fd_limit() {
const TARGET: libc::rlim_t = 8192;
unsafe {
let mut rlim = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
if libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) != 0 {
return;
}
let new_cur = rlim.rlim_max.min(TARGET);
if new_cur <= rlim.rlim_cur {
return;
}
let old = rlim.rlim_cur;
rlim.rlim_cur = new_cur;
if libc::setrlimit(libc::RLIMIT_NOFILE, &rlim) == 0 {
tracing::trace!(old, new = new_cur, "raised RLIMIT_NOFILE");
}
}
}
#[cfg(not(target_os = "macos"))]
fn raise_fd_limit() {}
/// Single audit point for the `Command::Dashboard` soft-subcommand.
/// Sets `KIGI_OPEN_DASHBOARD_AT_STARTUP=1` if the user asked for
/// `kigi dashboard`, and clears `args.command` so the regular
/// subcommand match doesn't try to handle it.
///
/// The dashboard is independent of leader mode — it renders local
/// sessions and, when a leader happens to be present, additionally shows
/// the leader roster — so `kigi dashboard` does NOT force leader mode and
/// is compatible with `--no-leader`.
///
/// The only gate is the feature flag: a disabled dashboard
/// (`[dashboard].enabled = false` / `KIGI_AGENT_DASHBOARD=0`) is a CLI
/// error here, before the TUI starts, because the welcome view silently
/// drops the equivalent runtime toast.
fn flag_dashboard_at_startup_if_requested(args: &mut PagerArgs) -> Result<()> {
if !matches!(args.command, Some(Command::Dashboard)) {
return Ok(());
}
if !kigi_tui::views::dashboard::dashboard_enabled() {
anyhow::bail!(
"the Agent Dashboard is disabled. Enable it by removing \
`[dashboard] enabled = false` from ~/.kigi/config.toml and \
unsetting KIGI_AGENT_DASHBOARD=0."
);
}
args.command = None;
unsafe { std::env::set_var("KIGI_OPEN_DASHBOARD_AT_STARTUP", "1") };
Ok(())
}
const RUNTIME_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
/// A plain runtime drop blocks forever on an uncancellable in-flight blocking
/// task; `shutdown_timeout` abandons it after `grace` so exit can't hang.
fn run_and_shutdown<F: std::future::Future>(
runtime: tokio::runtime::Runtime,
fut: F,
grace: std::time::Duration,
) -> F::Output {
let output = runtime.block_on(fut);
runtime.shutdown_timeout(grace);
output
}
/// Return freed-but-retained jemalloc pages to the OS.
///
/// `arena.<MALLCTL_ARENAS_ALL>.purge` madvises away all dirty/muzzy pages in
/// every arena. The pager invokes this (via the `memory_release` seam) right
/// after known memory cliffs — e.g. dropping a session load's replay
/// transient — so a long-session resume doesn't leave hundreds of MB of dead
/// pages counted against the process for its lifetime (macOS keeps
/// `MADV_FREE`d pages in RSS until systemwide pressure).
#[cfg(all(feature = "jemalloc", unix))]
fn purge_jemalloc_retained_pages() {
static NAME: &[u8] = b"arena.4096.purge\0";
let ret = unsafe {
tikv_jemalloc_sys::mallctl(
NAME.as_ptr().cast(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
0,
)
};
if ret != 0 {
static WARN_ONCE: std::sync::Once = std::sync::Once::new();
WARN_ONCE.call_once(|| {
tracing::warn!(
errno = ret,
"jemalloc arena purge mallctl failed; retained-page release is inert"
);
});
}
}
/// Allocator gauges for the memory trace (`memory_trace` seam): advance the
/// jemalloc epoch so the `stats.*` reads are current, then read each gauge.
/// Returns `None` if any mallctl fails (trace records the absence). Rides
/// the `tikv-jemalloc-ctl` raw helpers (introduced by the heap-profile
/// hooks below) instead of hand-rolled mallctl.
#[cfg(all(feature = "jemalloc", unix))]
fn jemalloc_allocator_stats() -> Option<kigi_tui::memory_trace::AllocatorStats> {
/// SAFETY: callers pass fixed NUL-terminated `stats.*` size_t ctl names.
unsafe fn gauge(name: &[u8]) -> Option<u64> {
unsafe {
tikv_jemalloc_ctl::raw::read::<usize>(name)
.ok()
.map(|v| v as u64)
}
}
unsafe {
tikv_jemalloc_ctl::raw::write(b"epoch\0", 1u64).ok()?;
Some(kigi_tui::memory_trace::AllocatorStats {
allocated: gauge(b"stats.allocated\0")?,
active: gauge(b"stats.active\0")?,
resident: gauge(b"stats.resident\0")?,
mapped: gauge(b"stats.mapped\0")?,
retained: gauge(b"stats.retained\0")?,
metadata: gauge(b"stats.metadata\0")?,
})
}
}
/// Full jemalloc statistics dump for threshold snapshots
/// (`malloc_stats_print` default human-readable format, arena detail
/// included) — the artifact the GCS memory-trace upload ships for offline
/// analysis. Raw `tikv_jemalloc_sys` because jemalloc-ctl has no
/// callback-form stats_print.
#[cfg(all(feature = "jemalloc", unix))]
fn jemalloc_stats_dump() -> String {
unsafe extern "C" fn append(opaque: *mut std::ffi::c_void, msg: *const std::ffi::c_char) {
unsafe {
let out = &mut *opaque.cast::<String>();
out.push_str(&std::ffi::CStr::from_ptr(msg).to_string_lossy());
}
}
let mut out = String::new();
unsafe {
tikv_jemalloc_sys::malloc_stats_print(
Some(append),
(&raw mut out).cast(),
std::ptr::null(),
);
}
out
}
fn main() {
kigi_pager_minimal::install();
#[cfg(all(feature = "jemalloc", unix))]
kigi_tui::memory_release::install_release_hook(purge_jemalloc_retained_pages);
#[cfg(all(feature = "jemalloc", unix))]
{
kigi_tui::memory_trace::install_allocator_stats_provider(jemalloc_allocator_stats);
kigi_tui::memory_trace::install_allocator_dump_provider(jemalloc_stats_dump);
}
if let Some(code) = kigi_tui::app::mermaid_worker::maybe_run_render_subprocess() {
std::process::exit(code);
}
kigi_tui::memory_trace::start(kigi_shell::util::kigi_home::kigi_home().join("memtrace"));
raise_fd_limit();
if let Err(e) = kigi_config::validate_requirements() {
eprintln!("Couldn't start Kigi: {e}");
eprintln!();
eprintln!(
"Update Kigi to a version the policy allows, or ask your administrator \
to fix the managed requirements."
);
std::process::exit(2);
}
kigi_tui::docs::extract_user_guide_docs(&kigi_shell::util::kigi_home::kigi_home());
kigi_crash_handler::install_terminal_restore_only();
if kigi_shell::util::config::load_crash_handler_enabled_sync() {
let crash_dir = kigi_shell::util::kigi_home::kigi_home().join("crash");
if let Some(report) = kigi_crash_handler::check_previous_crash(&crash_dir) {
eprintln!("Kigi crashed during your last session.");
eprintln!(" Signal: {}", report.signal_name);
eprintln!(" Version: {}", report.app_version);
eprintln!(" Report: {}", report.report_path.display());
eprintln!();
}
if !kigi_crash_handler::install(kigi_crash_handler::CrashHandlerConfig {
app_version: env!("VERSION_WITH_COMMIT").to_string(),
crash_dir: crash_dir.clone(),
}) {
eprintln!(
"warning: crash handler enabled but failed to install (check permissions on {})",
crash_dir.display()
);
}
}
let crashed = kigi_shell::active_sessions::collect_crashed().unwrap_or_default();
if !crashed.is_empty() {
tracing::info!(
count = crashed.len(),
"Found crashed sessions from a previous run"
);
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap_or_else(|e| panic!("failed to start tokio runtime: {e}"));
let result = run_and_shutdown(runtime, async_main(), RUNTIME_SHUTDOWN_GRACE);
kigi_log::debug_log::flush();
if let Err(e) = result {
kigi_tty_utils::restore_native_stderr();
eprintln!("Error: {e:#}");
std::process::exit(1);
}
}
async fn async_main() -> Result<()> {
let _ = rustls::crypto::ring::default_provider().install_default();
let mut args = PagerArgs::parse_and_apply_cwd()?;
if let Some(ref mode) = args.compaction_mode {
unsafe { std::env::set_var("KIGI_COMPACTION_MODE", mode) };
}
if let Some(ref detail) = args.compaction_detail {
unsafe { std::env::set_var("KIGI_COMPACTION_DETAIL", detail) };
}
if args.chat() {
anyhow::bail!(
"--chat is no longer supported: the kimi.com chat frontend it drove was \
removed along with the xAI backend."
);
}
if let Some(ref socket) = args.leader_socket {
unsafe { std::env::set_var(kigi_shell::leader::LEADER_SOCKET_ENV, socket) };
}
if !args.mcp_config_file.is_empty() {
// Fail fast on unusable files: an explicitly passed config that
// cannot be read must abort, not silently degrade the session.
for path in &args.mcp_config_file {
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("--mcp-config-file {}: {e}", path.display()))?;
serde_json::from_str::<serde_json::Value>(&content).map_err(|e| {
anyhow::anyhow!("--mcp-config-file {}: invalid JSON: {e}", path.display())
})?;
}
let joined = std::env::join_paths(&args.mcp_config_file).map_err(|e| {
anyhow::anyhow!("--mcp-config-file paths cannot be joined into the env: {e}")
})?;
unsafe { std::env::set_var(kigi_shell::util::config::MCP_CONFIG_FILES_ENV, joined) };
}
if let Some(ref path) = args.debug_file {
unsafe {
std::env::set_var("KIGI_DEBUG_LOG", path);
std::env::remove_var("KIGI_LOG_FILE");
}
}
if args.debug || args.debug_file.is_some() {
let set_if_unset = |k: &str, v: &str| {
if std::env::var_os(k).is_none() {
unsafe { std::env::set_var(k, v) };
}
};
set_if_unset("KIGI_DEBUG_LOG", "1");
set_if_unset("KIGI_HOOKS_LOG", "1");
}
if let Some(Command::Completions { shell }) = &args.command {
kigi_tui::completions_cmd::run(*shell);
return Ok(());
}
if let Some(Command::Wrap(ref wrap_args)) = args.command {
return kigi_tui::wrap_cmd::run(wrap_args);
}
let saved_profile = args.saved_resume_profile();
let sandbox_profile_arg = match args.startup_sandbox_profile(saved_profile.as_deref()) {
kigi_tui::app::cli::SandboxStartup::Apply(profile) => profile,
kigi_tui::app::cli::SandboxStartup::Conflict { requested, saved } => {
eprintln!(
"error: cannot resume this session under sandbox profile '{requested}' — \
it was created with '{saved}'. Omit --sandbox to resume with '{saved}', \
or start a new session to use '{requested}'."
);
std::process::exit(1);
}
};
kigi_shell::config::apply_sandbox(None, sandbox_profile_arg.as_deref(), args.cwd.as_deref());
flag_dashboard_at_startup_if_requested(&mut args)?;
let is_interactive = args.command.is_none()
&& args.single.is_none()
&& args.prompt_json.is_none()
&& args.prompt_file.is_none();
kigi_shell::http::set_client_name(if is_interactive {
kigi_workspace::permission::ClientType::KigiPager
} else {
kigi_workspace::permission::ClientType::Generic
});
let update_config = build_update_config();
if let Some(command) = args.command.take() {
match command {
Command::Version { json } => {
if json {
let payload = serde_json::json!(
{ "currentVersion" : env!("VERSION_WITH_COMMIT"), "channel" :
kigi_update::channel_name().unwrap_or("unknown"), }
);
println!("{}", serde_json::to_string(&payload)?);
} else {
println!(
"kigi {}",
kigi_version::display_version_with_commit(
env!("VERSION_WITH_COMMIT"),
kigi_update::channel_label(),
)
);
}
return Ok(());
}
Command::Acp(agent_args) => {
if let Some(mode) = &agent_args.mode {
anyhow::bail!(
"`kigi acp` always runs the stdio server; `{mode:?}` does not apply. \
Use `kigi agent ...` for other runtime modes."
);
}
enforce_minimum_version_or_exit(&update_config).await;
return run_agent_command(
agent_args,
args.permission_mode_flag.clone(),
args.trust,
args.no_auto_update,
args.disable_web_search,
&update_config,
)
.await;
}
Command::Agent(agent_args) => {
if args.leader || args.no_leader {
let flag = if args.leader {
"--leader"
} else {
"--no-leader"
};
anyhow::bail!(
"top-level {flag} applies to the pager TUI, not the agent subcommand. \
Use `kigi-pager agent {flag}` instead."
);
}
enforce_minimum_version_or_exit(&update_config).await;
return run_agent_command(
agent_args,
args.permission_mode_flag.clone(),
args.trust,
args.no_auto_update,
args.disable_web_search,
&update_config,
)
.await;
}
Command::Inspect { json } => {
let cwd = std::env::current_dir().unwrap_or_default();
kigi_shell::inspect::inspect(&cwd, json).await?;
return Ok(());
}
Command::ImportKimi { dry_run } => {
init_tracing_simple("cli");
return kigi_tui::import_kimi_cmd::run(dry_run);
}
Command::Setup { json } => {
init_tracing_simple("cli");
run_setup_command(json).await;
return Ok(());
}
Command::Mcp(mcp_args) => {
init_tracing_simple("cli");
return kigi_tui::mcp_cmd::run(mcp_args).await;
}
Command::Plugin(plugin_args) => {
init_tracing_simple("cli");
return kigi_tui::plugin_cmd::run(plugin_args).await;
}
Command::Models => {
init_tracing_simple("cli");
let config = kigi_shell::config::load_effective_config_disk_only()
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let agent_config = AgentConfig::new_from_toml_cfg(&config)
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
return kigi_tui::models::list_available_models(&agent_config).await;
}
Command::Leader(leader_args) => {
init_tracing_simple("cli");
return run_leader_mgmt(leader_args).await;
}
Command::Worktree(worktree_args) => {
init_tracing_simple("cli");
let config = kigi_shell::config::load_effective_config_disk_only()
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let agent_config = AgentConfig::new_from_toml_cfg(&config)
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
return kigi_tui::worktree_cmd::run(worktree_args, &agent_config).await;
}
Command::Sessions(sessions_args) => {
init_tracing_simple("cli");
return kigi_tui::sessions_cmd::run(sessions_args).await;
}
Command::Export(export_args) => {
init_tracing_simple("cli");
return kigi_tui::export_cmd::run(export_args);
}
Command::Trace(trace_args) => {
init_tracing_simple("cli");
let config = kigi_shell::config::load_effective_config_disk_only()
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let agent_config = AgentConfig::new_from_toml_cfg(&config)
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
return kigi_tui::trace_cmd::run(trace_args, &agent_config).await;
}
Command::Memory(memory_args) => {
return kigi_tui::memory_cmd::run(memory_args);
}
Command::Update {
check,
json,
force_reinstall,
version,
alpha,
stable,
enterprise,
} => {
init_tracing_simple("cli");
let channel_switch = get_channel_switch(alpha, stable, enterprise);
return run_update_command(
check,
json,
force_reinstall,
version,
channel_switch,
&update_config,
)
.await;
}
Command::Login => {
init_tracing_simple("cli");
let config = kigi_shell::config::load_effective_config_disk_only()
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let config = AgentConfig::new_from_toml_cfg(&config)
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
kigi_shell::auth::run_cli_login(&config).await?;
println!();
kigi_shell::instrumentation::finalize_and_exit(0);
}
Command::Logout => {
init_tracing_simple("cli");
let config = kigi_shell::config::load_effective_config_disk_only()
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let config = AgentConfig::new_from_toml_cfg(&config)
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
kigi_shell::auth::run_cli_logout(&config)?;
kigi_shell::instrumentation::finalize_and_exit(0);
}
Command::Wrap(ref wrap_args) => {
return kigi_tui::wrap_cmd::run(wrap_args);
}
Command::Completions { shell } => {
kigi_tui::completions_cmd::run(shell);
return Ok(());
}
Command::Dashboard => {
args.command = Some(Command::Dashboard);
flag_dashboard_at_startup_if_requested(&mut args)?;
}
}
}
let headless_prompt = kigi_tui::headless::HeadlessPrompt::from_args(
args.single.as_deref(),
args.prompt_json.as_deref(),
args.prompt_file.as_deref(),
)?;
if let Some(prompt) = headless_prompt {
init_tracing_simple(HEADLESS_ENTRYPOINT);
enforce_minimum_version_or_exit(&update_config).await;
let launch_yolo = kigi_shell::util::config::effective_yolo_for_launch(
args.yolo,
args.permission_mode_flag.as_deref(),
None,
);
if let Some(warning) = launch_yolo.blocked_warning {
eprintln!("kigi: {warning}");
}
let json_schema = args
.json_schema
.as_deref()
.map(kigi_tui::headless::parse_json_schema)
.transpose()?;
if json_schema.is_some() {
if args.output_format == kigi_tui::headless::OutputFormat::Plain {
args.output_format = kigi_tui::headless::OutputFormat::Json;
}
if args.self_verify {
anyhow::bail!(
"--json-schema and --self-verify cannot be used together: \
verification output would corrupt the structured response"
);
}
}
return kigi_tui::headless::run_single_turn(
prompt,
args.verbatim,
kigi_tui::headless::HeadlessOptions {
session_id: args.session_id.clone(),
resume: args.resume_session.or(args.load_session),
cwd: args.cwd,
yolo: launch_yolo.yolo,
trust: args.trust,
output_format: args.output_format,
json_schema,
model: args.model,
rules: args.rules,
system_prompt_override: args.system_prompt_override.clone(),
continue_last_session: args.continue_last_session,
fork_session: args.fork_session,
worktree: args.worktree,
restore_code: args.restore_code,
agent: args.agent.clone(),
agents_json: args.agents_json.clone(),
cli_tools: args.cli_tools.clone(),
cli_disallowed_tools: args.cli_disallowed_tools.clone(),
disable_web_search: args.disable_web_search,
allow_rules: args.allow_rules.clone(),
deny_rules: args.deny_rules.clone(),
max_turns: args.max_turns,
permission_mode_flag: args.permission_mode_flag.clone(),
reasoning_effort: args.reasoning_effort.clone(),
self_verify: args.self_verify,
best_of_n: args.best_of_n,
wait_for_background: !args.no_wait_for_background,
background_wait_timeout: std::time::Duration::from_secs(
args.background_wait_timeout_secs,
),
},
)
.await;
}
enforce_minimum_version_or_exit(&update_config).await;
type UpdateWaitHandle = tokio::task::JoinHandle<std::io::Result<std::process::ExitStatus>>;
let bg_update_wait: std::sync::Arc<tokio::sync::Mutex<Option<UpdateWaitHandle>>> =
std::sync::Arc::new(tokio::sync::Mutex::new(None));
let bg_update_rx: Option<tokio::sync::oneshot::Receiver<Option<auto_update::UpdateAvailable>>> =
if should_check_for_updates(args.no_auto_update) {
let update_config = update_config.clone();
let wait_slot = bg_update_wait.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let check = auto_update::check_update_background(&update_config).await;
if let Some(mut child) = check.download {
*wait_slot.lock().await = Some(tokio::spawn(async move { child.wait().await }));
}
let _ = tx.send(check.update);
});
Some(rx)
} else {
None
};
let result = kigi_tui::app::run(args, bg_update_rx).await;
kigi_sandbox::flush();
match result {
Ok(true) => {
let adopted = bg_update_wait.lock().await.take();
if finish_update_on_exit(adopted, &update_config).await {
eprintln!("Update installed. Run `kigi` to start.");
} else {
eprintln!("Update did not complete. Run `kigi update` to retry.");
}
Ok(())
}
Ok(false) => Ok(()),
Err(e) => Err(e),
}
}
/// Complete the update after a quit-for-update (Ctrl+U) exit. Returns `true`
/// when an update path completed without a reported failure.
///
/// Prefers awaiting the parked waiter for the background `kigi update` child
/// spawned at startup — the download is usually already done or in flight.
/// Only when there is no waiter (spawn failed, or no download was needed
/// because the target was already on disk) or the child failed does this
/// fall back to a fresh blocking `kigi update`, which itself resolves to
/// "Already up to date" without downloading when the disk is current.
async fn finish_update_on_exit(
adopted: Option<tokio::task::JoinHandle<std::io::Result<std::process::ExitStatus>>>,
update_config: &UpdateConfig,
) -> bool {
let run_blocking = |reason: Option<String>| async move {
if let Some(reason) = reason {
eprintln!("{reason}");
}
auto_update::run_update_if_available(
auto_update::UpdateRunMode::Blocking,
false,
update_config,
)
.await
.is_ok()
};
match adopted {
Some(handle) => {
eprintln!("Waiting for the update download to finish...");
match handle.await {
Ok(Ok(status)) if status.success() => true,
Ok(Ok(status)) => {
run_blocking(Some(format!(
"Background update exited with {status}; retrying..."
)))
.await
}
Ok(Err(e)) => {
run_blocking(Some(format!(
"Could not wait for the background update ({e}); retrying..."
)))
.await
}
Err(join_err) => {
run_blocking(Some(format!(
"Background update waiter failed ({join_err}); retrying..."
)))
.await
}
}
}
None => run_blocking(None).await,
}
}
/// Build an [`UpdateConfig`] from the current environment and config files.
fn build_update_config() -> UpdateConfig {
let mut config = UpdateConfig::from_environment();
cryptify::flow_stmt!({
{
config.deployment_key =
kigi_shell::agent::config::EndpointsConfig::default().deployment_key;
}
});
if let Ok(root) = kigi_shell::config::load_effective_config_disk_only()
&& let Some(ch) = kigi_shell::util::config::channel_from_toml_opt(&root)
{
config.channel = ch;
}
config
}
/// Centralized gate for all auto-update checks. Add new suppression
/// rules here — not at each call site.
fn should_check_for_updates(no_auto_update_flag: bool) -> bool {
if cfg!(debug_assertions) {
return false;
}
if no_auto_update_flag {
return false;
}
if std::env::var_os("KIGI_DISABLE_AUTOUPDATER").is_some() {
return false;
}
true
}
/// Mode-gate for the direct stdio agent's background auto-update.
///
/// Only the *direct* stdio agent is newly eligible: every other agent mode
/// already self-updates at the top of `run_agent_command`, and a leader-backed
/// stdio process is a thin bridge whose updates are owned by the leader
/// (`LeaderAutoUpdateConfig`). Update suppression (`--no-auto-update`,
/// `KIGI_DISABLE_AUTOUPDATER`, debug builds) is layered on separately via
/// [`should_check_for_updates`].
fn stdio_direct_update_eligible(is_stdio: bool, use_leader: bool) -> bool {
is_stdio && !use_leader
}
/// Map the mutually-exclusive channel flags to a channel name. clap enforces
/// that at most one is set, so the order is irrelevant.
fn get_channel_switch(alpha: bool, stable: bool, enterprise: bool) -> Option<&'static str> {
if alpha {
Some("alpha")
} else if stable {
Some("stable")
} else if enterprise {
Some("enterprise")
} else {
None
}
}
/// Handle `kigi-pager update [--check] [--json] [--force-reinstall] [--version X] [--alpha|--stable|--enterprise]`.
async fn run_update_command(
check: bool,
json: bool,
force_reinstall: bool,
version: Option<String>,
channel_switch: Option<&str>,
base_update_config: &UpdateConfig,
) -> Result<()> {
if json && !check {
anyhow::bail!("--json requires --check");
}
let mut update_config = base_update_config.clone();
if check {
if version.is_some() {
anyhow::bail!("--version cannot be used with --check");
}
auto_update::apply_channel_switch(channel_switch, &mut update_config).await;
let status = auto_update::check_update_status(&update_config).await;
auto_update::print_update_status(&status, json)?;
return Ok(());
}
if let Some(ref v) = version
&& semver::Version::parse(v).is_err()
{
anyhow::bail!(
"'{}' is not a valid version. Expected semver like 0.1.150",
v
);
}
let installed = auto_update::run_update(
force_reinstall,
version.as_deref(),
channel_switch,
&mut update_config,
)
.await?;
if let Some(installed_version) = installed {
signal_leaders_to_relaunch(&installed_version).await;
}
Ok(())
}
/// After a successful `kigi update`, ask any running leader on this machine that
/// is older than `installed_version` to relaunch onto the new binary (bounded
/// grace; running sessions close and reconnect via `session/load`).
///
/// Best-effort and non-fatal: discovery/connect/control failures are logged and
/// skipped. The leader re-checks the directional version guard authoritatively;
/// the pager-side `live_info` check just avoids connecting to newer leaders.
async fn signal_leaders_to_relaunch(installed_version: &str) {
for d in kigi_shell::leader::discover_leaders().await {
if d.classification != kigi_shell::leader::LeaderDiscoveryState::Reachable {
continue;
}
let Some(socket_path) = d.socket_path.clone() else {
continue;
};
if let Some(ref live) = d.live_info
&& !leader_is_older_than(&live.leader_binary_version, installed_version)
{
continue;
}
let client = match kigi_shell::leader::LeaderClient::connect(
socket_path,
"kigi-pager-update",
ClientMode::Stdio,
ClientCapabilities::default(),
)
.await
{
Ok(c) => c,
Err(e) => {
tracing::debug!(
error = % e, "Could not connect to leader to signal relaunch"
);
continue;
}
};
if !client.registration().supports_relaunch() {
client.cancel();
continue;
}
match client
.send_control(ControlCommand::RelaunchForUpdate {
to_version: installed_version.to_string(),
})
.await
{
Ok(Ok(kigi_shell::leader::ControlPayload::Relaunching {
from_version,
to_version,
..
})) => {
eprintln!(" ↻ Relaunching shared session (leader {from_version}{to_version})…");
}
Ok(Ok(kigi_shell::leader::ControlPayload::RelaunchDeclined { reason })) => {
tracing::debug!(% reason, "Leader declined relaunch");
}
Ok(Ok(_)) => {}
Ok(Err(e)) => {
tracing::debug!(error = % e.message, "Leader relaunch control error");
}
Err(e) => {
tracing::debug!(
error = % e,
"Leader relaunch ack not received (leader may be exiting)"
);
}
}
client.cancel();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Only the direct (non-leader) stdio agent is newly eligible for the
/// background auto-update. Leader-backed stdio defers to the leader's own
/// updater, and non-stdio modes already update at the top of
/// `run_agent_command`.
#[test]
fn stdio_direct_update_eligible_only_for_non_leader_stdio() {
assert!(
stdio_direct_update_eligible(true, false),
"direct stdio agent should be eligible",
);
assert!(
!stdio_direct_update_eligible(true, true),
"leader-backed stdio defers to the leader's updater",
);
assert!(
!stdio_direct_update_eligible(false, false),
"non-stdio modes update at the top of run_agent_command",
);
assert!(
!stdio_direct_update_eligible(false, true),
"non-stdio leader path is not stdio-eligible",
);
}
use clap::Parser as _;
/// `kigi dashboard` flags the startup hook without forcing leader mode —
/// the dashboard is independent of leader mode, so the launch keeps
/// whatever leader setting the user (or config) chose.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn dashboard_subcommand_flags_startup_without_forcing_leader() {
let mut args = PagerArgs::try_parse_from(["kigi", "dashboard"]).unwrap();
assert!(!args.leader, "fixture: no explicit --leader");
flag_dashboard_at_startup_if_requested(&mut args).unwrap();
assert!(!args.leader, "dashboard must NOT force leader mode");
assert!(
args.command.is_none(),
"soft subcommand must be consumed so the interactive path runs",
);
assert_eq!(
std::env::var("KIGI_OPEN_DASHBOARD_AT_STARTUP").as_deref(),
Ok("1"),
"startup hook flag must be set",
);
unsafe { std::env::remove_var("KIGI_OPEN_DASHBOARD_AT_STARTUP") };
}
/// `kigi dashboard --no-leader` is allowed — the dashboard does not
/// require a leader, so the combination launches into the dashboard in
/// non-leader mode.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn dashboard_subcommand_allows_no_leader() {
let mut args = PagerArgs::try_parse_from(["kigi", "--no-leader", "dashboard"]).unwrap();
flag_dashboard_at_startup_if_requested(&mut args)
.expect("--no-leader + dashboard must be allowed");
assert!(args.no_leader, "--no-leader must be preserved");
assert!(!args.leader, "dashboard must not force leader mode");
assert!(
args.command.is_none(),
"soft subcommand must be consumed so the interactive path runs",
);
assert_eq!(
std::env::var("KIGI_OPEN_DASHBOARD_AT_STARTUP").as_deref(),
Ok("1"),
"startup hook flag must be set",
);
unsafe { std::env::remove_var("KIGI_OPEN_DASHBOARD_AT_STARTUP") };
}
/// `KIGI_AGENT_DASHBOARD=0` disables the feature — the subcommand
/// must error visibly before the TUI starts.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn dashboard_subcommand_errors_when_disabled() {
unsafe { std::env::set_var("KIGI_AGENT_DASHBOARD", "0") };
let mut args = PagerArgs::try_parse_from(["kigi", "dashboard"]).unwrap();
let result = flag_dashboard_at_startup_if_requested(&mut args);
unsafe { std::env::remove_var("KIGI_AGENT_DASHBOARD") };
let err = result.expect_err("disabled dashboard must error");
assert!(err.to_string().contains("disabled"), "got: {err}");
assert!(
std::env::var("KIGI_OPEN_DASHBOARD_AT_STARTUP").is_err(),
"failure path must not flag the startup hook",
);
}
fn make_state() -> std::sync::Mutex<StdioReplayState> {
std::sync::Mutex::new(StdioReplayState::default())
}
#[test]
fn cache_initialize_request() {
let state = make_state();
let msg = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
cache_outgoing_acp_state(msg, &state);
let s = state.lock().unwrap();
assert_eq!(s.initialize_json.as_deref(), Some(msg));
}
#[test]
fn cache_session_load_preserves_full_request() {
let state = make_state();
let msg = r#"{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"s1","cwd":"/tmp","mcpServers":[]}}"#;
cache_outgoing_acp_state(msg, &state);
let s = state.lock().unwrap();
let (sid, cached) = &s.sessions[0];
assert_eq!(sid, "s1");
assert_eq!(cached.load_request_json.as_deref(), Some(msg));
assert_eq!(cached.cwd.as_deref(), Some("/tmp"));
assert!(cached.mcp_servers_json.is_some());
assert_eq!(s.last_session_id.as_deref(), Some("s1"));
}
#[test]
fn cache_session_new_is_pending_until_response_assigns_id() {
let state = make_state();
let load = r#"{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"s1","cwd":"/tmp"}}"#;
cache_outgoing_acp_state(load, &state);
let new = r#"{"jsonrpc":"2.0","id":3,"method":"session/new","params":{"cwd":"/home"}}"#;
cache_outgoing_acp_state(new, &state);
{
let s = state.lock().unwrap();
assert_eq!(s.sessions.len(), 1);
assert_eq!(s.sessions[0].0, "s1");
assert!(s.pending_new.is_some());
assert_eq!(
s.pending_new.as_ref().unwrap().cwd.as_deref(),
Some("/home")
);
}
cache_incoming_session_id(
r#"{"jsonrpc":"2.0","id":3,"result":{"sessionId":"s2"}}"#,
&state,
);
let s = state.lock().unwrap();
assert!(s.pending_new.is_none());
assert_eq!(s.sessions.len(), 2);
assert_eq!(s.sessions[1].0, "s2");
assert_eq!(s.sessions[1].1.cwd.as_deref(), Some("/home"));
assert_eq!(s.last_session_id.as_deref(), Some("s2"));
}
#[test]
fn cache_session_close_stops_replaying_it() {
let state = make_state();
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"s1","cwd":"/tmp"}}"#,
&state,
);
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":3,"method":"_kigi/session/close","params":{"sessionId":"s1"}}"#,
&state,
);
let s = state.lock().unwrap();
assert!(s.sessions.is_empty(), "closed session must not be replayed");
assert!(s.last_session_id.is_none());
}
/// An UNCONFIRMED `session/new` (leader died before its response) must not
/// be replayed — its id was never assigned — but previously loaded
/// sessions still restore.
#[tokio::test]
async fn replay_after_unconfirmed_session_new_restores_prior_sessions() {
let state = make_state();
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
&state,
);
let load_a = r#"{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"session-A","cwd":"/old"}}"#;
cache_outgoing_acp_state(load_a, &state);
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":3,"method":"session/new","params":{"cwd":"/new"}}"#,
&state,
);
let (leader_tx, mut leader_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (response_tx, mut response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let responder = tokio::spawn(async move {
let _init = leader_rx.recv().await.unwrap();
response_tx
.send(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#.to_string())
.unwrap();
let load = leader_rx.recv().await.unwrap();
assert!(load.contains("session-A"), "unexpected replay: {load}");
response_tx
.send(r#"{"jsonrpc":"2.0","id":2,"result":{}}"#.to_string())
.unwrap();
});
let s = state.lock().unwrap().clone();
let mut sink = Vec::new();
let result =
replay_acp_state_after_reconnect(&leader_tx, &mut response_rx, &mut sink, &s).await;
assert_eq!(
result.as_deref(),
Some("session-A"),
"prior session must be restored even though the new one was unconfirmed"
);
responder.await.unwrap();
}
#[test]
fn fallback_replay_json_escapes_special_chars() {
let cached = CachedSession {
load_request_json: None,
cwd: Some(r#"C:\Users\test path"#.into()),
mcp_servers_json: None,
};
let json = replay_load_json(r#"session"with"quotes"#, &cached)
.expect("cwd present → load synthesized");
let parsed: serde_json::Value =
serde_json::from_str(&json).expect("fallback replay JSON must be valid");
assert_eq!(
parsed["params"]["sessionId"].as_str().unwrap(),
r#"session"with"quotes"#
);
assert_eq!(
parsed["params"]["cwd"].as_str().unwrap(),
r#"C:\Users\test path"#
);
assert_eq!(parsed["id"].as_str(), Some(REPLAY_LOAD_REQUEST_ID));
}
#[test]
fn cache_incoming_session_id_from_response() {
let state = make_state();
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp"}}"#,
&state,
);
let msg = r#"{"jsonrpc":"2.0","id":1,"result":{"sessionId":"abc123"}}"#;
cache_incoming_session_id(msg, &state);
let s = state.lock().unwrap();
assert_eq!(s.last_session_id.as_deref(), Some("abc123"));
assert_eq!(s.sessions.len(), 1);
assert_eq!(s.sessions[0].0, "abc123");
}
/// A multi-session client (IDE driving several sessions over one bridge)
/// gets EVERY session replayed after a reconnect, in first-seen order.
#[tokio::test]
async fn replay_restores_all_cached_sessions() {
let state = make_state();
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
&state,
);
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"sess-1","cwd":"/a"}}"#,
&state,
);
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":3,"method":"session/new","params":{"cwd":"/b"}}"#,
&state,
);
cache_incoming_session_id(
r#"{"jsonrpc":"2.0","id":3,"result":{"sessionId":"sess-2"}}"#,
&state,
);
let (leader_tx, mut leader_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (response_tx, mut response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let responder = tokio::spawn(async move {
let _init = leader_rx.recv().await.unwrap();
response_tx
.send(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#.to_string())
.unwrap();
let load1 = leader_rx.recv().await.unwrap();
assert!(load1.contains("sess-1"), "expected sess-1 first: {load1}");
response_tx
.send(r#"{"jsonrpc":"2.0","id":2,"result":{}}"#.to_string())
.unwrap();
let load2 = leader_rx.recv().await.unwrap();
assert!(load2.contains("sess-2"), "expected sess-2 second: {load2}");
let load2_json: serde_json::Value = serde_json::from_str(&load2).unwrap();
assert_eq!(load2_json["id"].as_str(), Some(REPLAY_LOAD_REQUEST_ID));
response_tx
.send(
serde_json::json!(
{ "jsonrpc" : "2.0", "id" : REPLAY_LOAD_REQUEST_ID, "result" : {}
}
)
.to_string(),
)
.unwrap();
});
let s = state.lock().unwrap().clone();
let mut sink = Vec::new();
let result =
replay_acp_state_after_reconnect(&leader_tx, &mut response_rx, &mut sink, &s).await;
assert_eq!(result.as_deref(), Some("sess-2"));
responder.await.unwrap();
}
/// One broken session must not doom the rest: a rejected load is skipped
/// and the remaining sessions still restore.
#[tokio::test]
async fn replay_skips_rejected_session_and_restores_the_rest() {
let state = make_state();
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
&state,
);
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"sess-bad","cwd":"/a"}}"#,
&state,
);
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":3,"method":"session/load","params":{"sessionId":"sess-good","cwd":"/b"}}"#,
&state,
);
let (leader_tx, mut leader_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (response_tx, mut response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let responder = tokio::spawn(async move {
let _init = leader_rx.recv().await.unwrap();
response_tx
.send(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#.to_string())
.unwrap();
let _bad = leader_rx.recv().await.unwrap();
response_tx
.send(
r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"Invalid params","data":"unknown session id"}}"#
.to_string(),
)
.unwrap();
let good = leader_rx.recv().await.unwrap();
assert!(good.contains("sess-good"));
response_tx
.send(r#"{"jsonrpc":"2.0","id":3,"result":{}}"#.to_string())
.unwrap();
});
let s = state.lock().unwrap().clone();
let mut sink = Vec::new();
let result =
replay_acp_state_after_reconnect(&leader_tx, &mut response_rx, &mut sink, &s).await;
assert_eq!(result.as_deref(), Some("sess-good"));
responder.await.unwrap();
}
#[test]
fn cache_incoming_ignores_non_session_response() {
let state = make_state();
let msg = r#"{"jsonrpc":"2.0","id":1,"result":{"models":[]}}"#;
cache_incoming_session_id(msg, &state);
let s = state.lock().unwrap();
assert!(s.last_session_id.is_none());
assert!(s.sessions.is_empty());
}
#[tokio::test]
async fn replay_with_no_cached_state_returns_none() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let state = StdioReplayState::default();
let mut sink = Vec::new();
let result = replay_acp_state_after_reconnect(&tx, &mut rx, &mut sink, &state).await;
assert!(result.is_none());
}
#[tokio::test]
async fn replay_sends_initialize_and_session_load() {
let (leader_tx, mut leader_rx) = tokio::sync::mpsc::unbounded_channel();
let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel();
let state_mutex = make_state();
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
&state_mutex,
);
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"s1","cwd":"/tmp"}}"#,
&state_mutex,
);
let state = state_mutex.lock().unwrap().clone();
let responder = tokio::spawn(async move {
let _init = leader_rx.recv().await.unwrap();
response_tx
.send(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#.to_string())
.unwrap();
let _load = leader_rx.recv().await.unwrap();
response_tx
.send(r#"{"jsonrpc":"2.0","id":2,"result":{"sessionId":"s1"}}"#.to_string())
.unwrap();
});
let mut rx = response_rx;
let mut sink = Vec::new();
let result = replay_acp_state_after_reconnect(&leader_tx, &mut rx, &mut sink, &state).await;
assert_eq!(result.as_deref(), Some("s1"));
assert!(
sink.is_empty(),
"responses to replayed requests must be swallowed, not forwarded"
);
responder.await.unwrap();
}
/// Regression test for the post-leader-crash "unknown session id" bug.
///
/// `session/load` streams replay notifications BEFORE its response. The
/// old drain logic consumed exactly one message per replayed request and
/// returned — declaring the reconnect complete while the new leader was
/// still loading the session. The replay must instead:
/// 1. wait for the actual `session/load` RESPONSE (matched by id),
/// 2. forward interleaved notifications to the client verbatim,
/// 3. swallow only the responses to the replayed requests.
#[tokio::test]
async fn replay_waits_for_load_response_through_notifications() {
let (leader_tx, mut leader_rx) = tokio::sync::mpsc::unbounded_channel();
let (response_tx, mut response_rx) = tokio::sync::mpsc::unbounded_channel();
let state_mutex = make_state();
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":7,"method":"initialize","params":{}}"#,
&state_mutex,
);
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":8,"method":"session/load","params":{"sessionId":"s9","cwd":"/tmp"}}"#,
&state_mutex,
);
let state = state_mutex.lock().unwrap().clone();
let responder = tokio::spawn(async move {
let _init = leader_rx.recv().await.unwrap();
response_tx
.send(
r#"{"jsonrpc":"2.0","method":"kigi/leader/version_mismatch","params":{}}"#
.to_string(),
)
.unwrap();
response_tx
.send(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#.to_string())
.unwrap();
let _load = leader_rx.recv().await.unwrap();
for i in 0..3 {
response_tx
.send(
format!(
r#"{{"jsonrpc":"2.0","method":"session/update","params":{{"sessionId":"s9","n":{i}}}}}"#
),
)
.unwrap();
}
response_tx
.send(r#"{"jsonrpc":"2.0","id":8,"result":{}}"#.to_string())
.unwrap();
});
let mut sink = Vec::new();
let result =
replay_acp_state_after_reconnect(&leader_tx, &mut response_rx, &mut sink, &state).await;
assert_eq!(
result.as_deref(),
Some("s9"),
"replay must succeed once the load response arrives"
);
let forwarded = String::from_utf8(sink).unwrap();
let lines: Vec<&str> = forwarded.lines().collect();
assert_eq!(
lines.len(),
4,
"expected exactly the 4 notifications, got: {lines:?}"
);
assert!(lines[0].contains("version_mismatch"));
assert!(lines[1].contains(r#""n":0"#));
assert!(lines[2].contains(r#""n":1"#));
assert!(lines[3].contains(r#""n":2"#));
assert!(!forwarded.contains(r#""id":7"#), "init response leaked");
assert!(!forwarded.contains(r#""id":8"#), "load response leaked");
responder.await.unwrap();
}
/// A `session/load` rejected by the new leader (error response) must
/// surface as a failed replay (`None`) so the bridge emits
/// `kigi/leader_reconnected` with empty params and the external client
/// knows to re-establish state itself.
#[tokio::test]
async fn replay_returns_none_when_load_is_rejected() {
let (leader_tx, mut leader_rx) = tokio::sync::mpsc::unbounded_channel();
let (response_tx, mut response_rx) = tokio::sync::mpsc::unbounded_channel();
let state_mutex = make_state();
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
&state_mutex,
);
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"gone","cwd":"/tmp"}}"#,
&state_mutex,
);
let state = state_mutex.lock().unwrap().clone();
let responder = tokio::spawn(async move {
let _init = leader_rx.recv().await.unwrap();
response_tx
.send(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#.to_string())
.unwrap();
let _load = leader_rx.recv().await.unwrap();
response_tx
.send(
r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"Invalid params","data":"unknown session id"}}"#
.to_string(),
)
.unwrap();
});
let mut sink = Vec::new();
let result =
replay_acp_state_after_reconnect(&leader_tx, &mut response_rx, &mut sink, &state).await;
assert!(result.is_none(), "rejected load must not claim success");
responder.await.unwrap();
}
/// The synthetic fallback `session/load` (client only ever sent
/// `session/new`) uses a string request id that cannot collide with the
/// external client's numeric ids — and the response matcher honors it.
#[tokio::test]
async fn replay_fallback_load_uses_reserved_string_id() {
let (leader_tx, mut leader_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (response_tx, mut response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let state_mutex = make_state();
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
&state_mutex,
);
cache_outgoing_acp_state(
r#"{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp"}}"#,
&state_mutex,
);
cache_incoming_session_id(
r#"{"jsonrpc":"2.0","id":2,"result":{"sessionId":"s-new"}}"#,
&state_mutex,
);
let state = state_mutex.lock().unwrap().clone();
let responder = tokio::spawn(async move {
let _init = leader_rx.recv().await.unwrap();
response_tx
.send(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#.to_string())
.unwrap();
let load = leader_rx.recv().await.unwrap();
let load_json: serde_json::Value = serde_json::from_str(&load).unwrap();
assert_eq!(
load_json["id"].as_str(),
Some(REPLAY_LOAD_REQUEST_ID),
"fallback load must use the reserved string id"
);
response_tx
.send(
serde_json::json!(
{ "jsonrpc" : "2.0", "id" : REPLAY_LOAD_REQUEST_ID, "result" : {}
}
)
.to_string(),
)
.unwrap();
});
let mut sink = Vec::new();
let result =
replay_acp_state_after_reconnect(&leader_tx, &mut response_rx, &mut sink, &state).await;
assert_eq!(result.as_deref(), Some("s-new"));
responder.await.unwrap();
}
fn multi_thread_runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("build runtime")
}
#[test]
fn run_and_shutdown_bounds_teardown_despite_stuck_blocking_task() {
use std::time::{Duration, Instant};
let grace = Duration::from_millis(200);
let ceiling = grace * 8;
let stuck_sleep = Duration::from_secs(10);
let runtime = multi_thread_runtime();
let (started_tx, started_rx) = std::sync::mpsc::channel::<()>();
runtime.spawn_blocking(move || {
let _ = started_tx.send(());
std::thread::sleep(stuck_sleep);
});
started_rx.recv().expect("blocking task must start");
let start = Instant::now();
let out = run_and_shutdown(runtime, async { 7_u32 }, grace);
let elapsed = start.elapsed();
assert_eq!(out, 7, "must return the future's output");
assert!(
elapsed >= grace,
"returned in {elapsed:?}, before the {grace:?} grace — timeout not exercised",
);
assert!(
elapsed < ceiling,
"teardown took {elapsed:?}; stuck task must be abandoned under {ceiling:?}",
);
}
#[test]
fn run_and_shutdown_is_fast_without_blocking_work() {
use std::time::{Duration, Instant};
let runtime = multi_thread_runtime();
let grace = Duration::from_secs(5);
let start = Instant::now();
let out = run_and_shutdown(runtime, async { 42_u32 }, grace);
let elapsed = start.elapsed();
assert_eq!(out, 42, "must pass the future's output through");
assert!(
elapsed < grace,
"clean teardown took {elapsed:?}; grace must be a ceiling, not a floor",
);
}
#[test]
fn run_and_shutdown_passes_err_output_through() {
use std::time::Duration;
let runtime = multi_thread_runtime();
let out = run_and_shutdown(
runtime,
async { Err::<(), String>("boom".to_string()) },
Duration::from_secs(5),
);
assert_eq!(
out,
Err("boom".to_string()),
"Err output must pass through unchanged",
);
}
}