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,230 @@
|
||||
//! Per-session pending-interaction registry.
|
||||
//!
|
||||
//! Permissions, `ask_user_question`, and plan approval are **blocking ACP
|
||||
//! reverse-requests**: the agent parks a tool-loop future on an in-memory
|
||||
//! oneshot and waits for the driver to answer. While such a request is open we
|
||||
//! record it here, keyed by `tool_call_id` (stable, lives in the transcript →
|
||||
//! survives reconnect). This registry is the single source of truth for "what
|
||||
//! is pending right now" and is read by the roster to surface
|
||||
//! [`crate::agent::roster::RosterActivity::NeedsInput`].
|
||||
//!
|
||||
//! Pending interactions are **requests, not notifications** — they are never
|
||||
//! persisted. We broadcast `pending_interaction` / `interaction_resolved`
|
||||
//! **fire-and-forget** via the gateway (same idiom as
|
||||
//! [`crate::session::summary`]); the routing layer fans them to every
|
||||
//! subscriber because they carry a `sessionId`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
|
||||
use crate::extensions::notification::{SessionNotification, SessionUpdate as XaiSessionUpdate};
|
||||
|
||||
/// Shared per-session map of open reverse-requests, keyed by `tool_call_id`.
|
||||
///
|
||||
/// Mirrors the `current_prompt_id` signal on
|
||||
/// [`crate::session::handle::SessionHandle`]: the same `Arc` is shared between
|
||||
/// the session actor (which mutates it) and the handle (which the roster reads
|
||||
/// synchronously).
|
||||
pub type PendingInteractions = Arc<Mutex<HashMap<String, PendingKind>>>;
|
||||
|
||||
/// Which kind of blocking reverse-request is pending.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PendingKind {
|
||||
/// `request_permission` for a tool action.
|
||||
Permission,
|
||||
/// `x.ai/ask_user_question`.
|
||||
Question,
|
||||
/// `x.ai/exit_plan_mode` plan approval.
|
||||
PlanApproval,
|
||||
}
|
||||
|
||||
/// Whether a blocking plan-approval reverse-request is parked in `pending`.
|
||||
///
|
||||
/// The resume re-park issues `x.ai/exit_plan_mode` from a detached task
|
||||
/// with no running turn, making it the one parked interaction that also carries a
|
||||
/// persisted gate (`awaiting_plan_approval`). `session_has_live_work` consults
|
||||
/// this to keep such a session resident until the decision is answered or a real
|
||||
/// disconnect `Err`s the reverse-request — otherwise an idle-unload drops the
|
||||
/// parked future and its guard clears the on-disk gate. Permission/question parks
|
||||
/// carry no persisted gate, so they are intentionally not counted here. Poisoned
|
||||
/// lock → recover the map (module idiom) and read it.
|
||||
pub(crate) fn has_parked_plan_approval(pending: &PendingInteractions) -> bool {
|
||||
pending
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.values()
|
||||
.any(|k| *k == PendingKind::PlanApproval)
|
||||
}
|
||||
|
||||
/// Fire-and-forget broadcast of a session notification carrying a `sessionId`
|
||||
/// (so the routing layer fans it out to every subscriber). Never persisted.
|
||||
fn broadcast(gateway: &GatewaySender, session_id: &acp::SessionId, update: XaiSessionUpdate) {
|
||||
let notification = SessionNotification {
|
||||
session_id: session_id.clone(),
|
||||
update,
|
||||
meta: None,
|
||||
};
|
||||
if let Ok(params) = serde_json::value::to_raw_value(¬ification) {
|
||||
gateway.forward_fire_and_forget(acp::ExtNotification::new(
|
||||
"x.ai/session_notification",
|
||||
params.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard registering an open reverse-request for the lifetime of the
|
||||
/// parked oneshot.
|
||||
///
|
||||
/// On construction it inserts `(tool_call_id, kind)` into the registry and
|
||||
/// broadcasts `pending_interaction`. On drop — which happens whether the await
|
||||
/// returns normally, is cancelled, or errors — it removes the entry and (if it
|
||||
/// actually removed one) broadcasts `interaction_resolved`. The
|
||||
/// remove-or-no-op makes resolution **idempotent / first-answer-wins**: a
|
||||
/// second drop / already-removed key is silent.
|
||||
pub struct PendingInteractionGuard {
|
||||
pending: PendingInteractions,
|
||||
gateway: GatewaySender,
|
||||
session_id: acp::SessionId,
|
||||
tool_call_id: String,
|
||||
}
|
||||
|
||||
impl PendingInteractionGuard {
|
||||
/// Register a pending interaction and broadcast `pending_interaction`.
|
||||
pub fn new(
|
||||
pending: PendingInteractions,
|
||||
gateway: GatewaySender,
|
||||
session_id: acp::SessionId,
|
||||
tool_call_id: String,
|
||||
kind: PendingKind,
|
||||
) -> Self {
|
||||
{
|
||||
let mut map = pending.lock().unwrap_or_else(|e| e.into_inner());
|
||||
map.insert(tool_call_id.clone(), kind);
|
||||
}
|
||||
broadcast(
|
||||
&gateway,
|
||||
&session_id,
|
||||
XaiSessionUpdate::PendingInteraction {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
kind,
|
||||
},
|
||||
);
|
||||
Self {
|
||||
pending,
|
||||
gateway,
|
||||
session_id,
|
||||
tool_call_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PendingInteractionGuard {
|
||||
fn drop(&mut self) {
|
||||
let removed = {
|
||||
let mut map = self.pending.lock().unwrap_or_else(|e| e.into_inner());
|
||||
map.remove(&self.tool_call_id).is_some()
|
||||
};
|
||||
// First-answer-wins: only announce resolution if this guard actually
|
||||
// owned the live entry. An already-resolved id is a silent no-op.
|
||||
if removed {
|
||||
broadcast(
|
||||
&self.gateway,
|
||||
&self.session_id,
|
||||
XaiSessionUpdate::InteractionResolved {
|
||||
tool_call_id: self.tool_call_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn new_registry() -> PendingInteractions {
|
||||
Arc::new(Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_inserts_then_removes() {
|
||||
let reg = new_registry();
|
||||
// No gateway round-trip is exercised here (broadcast is best-effort and
|
||||
// a dead sender simply drops). We only assert the registry mutation.
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let gateway = GatewaySender::new(tx);
|
||||
{
|
||||
let _g = PendingInteractionGuard::new(
|
||||
reg.clone(),
|
||||
gateway,
|
||||
acp::SessionId::new("sess-1"),
|
||||
"call-1".to_string(),
|
||||
PendingKind::Permission,
|
||||
);
|
||||
assert_eq!(reg.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
reg.lock().unwrap().get("call-1").copied(),
|
||||
Some(PendingKind::Permission)
|
||||
);
|
||||
}
|
||||
assert!(reg.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// `has_parked_plan_approval` counts ONLY a parked plan-approval; other
|
||||
/// kinds (permission / question) carry no persisted gate and must not, by
|
||||
/// themselves, report the session live.
|
||||
#[test]
|
||||
fn has_parked_plan_approval_only_counts_plan_approval() {
|
||||
let reg = new_registry();
|
||||
assert!(!has_parked_plan_approval(®));
|
||||
|
||||
reg.lock()
|
||||
.unwrap()
|
||||
.insert("perm".to_string(), PendingKind::Permission);
|
||||
reg.lock()
|
||||
.unwrap()
|
||||
.insert("q".to_string(), PendingKind::Question);
|
||||
assert!(
|
||||
!has_parked_plan_approval(®),
|
||||
"permission/question parks must not count as a parked approval"
|
||||
);
|
||||
|
||||
reg.lock()
|
||||
.unwrap()
|
||||
.insert("plan".to_string(), PendingKind::PlanApproval);
|
||||
assert!(has_parked_plan_approval(®));
|
||||
|
||||
reg.lock().unwrap().remove("plan");
|
||||
assert!(!has_parked_plan_approval(®));
|
||||
}
|
||||
|
||||
/// A poisoned registry lock must not panic the predicate: it recovers the
|
||||
/// inner map (module idiom) and reports the parked approval truthfully.
|
||||
#[test]
|
||||
fn has_parked_plan_approval_recovers_poisoned_lock() {
|
||||
let reg = new_registry();
|
||||
reg.lock()
|
||||
.unwrap()
|
||||
.insert("plan".to_string(), PendingKind::PlanApproval);
|
||||
|
||||
let reg_poison = reg.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _g = reg_poison.lock().unwrap();
|
||||
panic!("poison pending_interactions");
|
||||
})
|
||||
.join();
|
||||
assert!(
|
||||
reg.lock().is_err(),
|
||||
"precondition: the lock must be poisoned"
|
||||
);
|
||||
|
||||
assert!(
|
||||
has_parked_plan_approval(®),
|
||||
"a poisoned lock must still surface the parked approval, not panic"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user