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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
+545
View File
@@ -0,0 +1,545 @@
use serde::Serialize;
/// Maximum serialized size for `toolInput` or `toolResult` in bytes (128 KB).
pub const MAX_PAYLOAD_SIZE: usize = 128 * 1024;
/// Hook event types.
///
/// Accepts both PascalCase (`"PreToolUse"`) and snake_case (`"pre_tool_use"`)
/// during deserialization for migration compatibility.
/// Serializes to snake_case for the hook envelope wire format.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEventName {
// ── Session lifecycle ───────────────────────────────────────
SessionStart,
SessionEnd,
/// Fires when an agent turn ends (completed, cancelled, or error).
Stop,
/// Fires when the turn ends due to an API error. Output and exit code are ignored.
StopFailure,
// ── Tool events ─────────────────────────────────────────────
PreToolUse,
PostToolUse,
/// Fires after a tool call fails (throws an error).
PostToolUseFailure,
/// Fires when a tool call is denied by the permission system.
PermissionDenied,
// ── User / notification events ──────────────────────────────
/// Fires when the user submits a prompt.
UserPromptSubmit,
/// Fires when a notification is sent (e.g., permission prompt, idle).
Notification,
// ── Subagent events ─────────────────────────────────────────
/// Fires when a subagent is spawned.
SubagentStart,
/// Fires when a subagent completes.
SubagentStop,
/// Alias for SubagentStop (kept for backward compatibility).
SubagentEnd,
// ── Compaction events ───────────────────────────────────────
/// Fires before context compaction.
PreCompact,
/// Fires after context compaction completes.
PostCompact,
}
impl<'de> serde::Deserialize<'de> for HookEventName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
// PascalCase (native) + snake_case + camelCase (third-party compat).
// Per-operation hook names (beforeShellExecution, afterFileEdit, etc.)
// map to our generic PreToolUse/PostToolUse — the hook script receives the
// tool name in JSON input and can filter, or use the `matcher` field.
"SessionStart" | "session_start" | "sessionStart" => Ok(Self::SessionStart),
"PreToolUse"
| "pre_tool_use"
| "preToolUse"
| "beforeShellExecution"
| "beforeMCPExecution"
| "beforeReadFile" => Ok(Self::PreToolUse),
"PostToolUse"
| "post_tool_use"
| "postToolUse"
| "afterShellExecution"
| "afterMCPExecution"
| "afterFileEdit"
| "afterAgentResponse"
| "afterAgentThought" => Ok(Self::PostToolUse),
"PostToolUseFailure" | "post_tool_use_failure" | "postToolUseFailure" => {
Ok(Self::PostToolUseFailure)
}
"SessionEnd" | "session_end" | "sessionEnd" => Ok(Self::SessionEnd),
"Stop" | "stop" => Ok(Self::Stop),
"StopFailure" | "stop_failure" | "stopFailure" => Ok(Self::StopFailure),
"Notification" | "notification" => Ok(Self::Notification),
"UserPromptSubmit" | "user_prompt_submit" | "beforeSubmitPrompt" => {
Ok(Self::UserPromptSubmit)
}
"PermissionDenied" | "permission_denied" | "permissionDenied" => {
Ok(Self::PermissionDenied)
}
"SubagentStart" | "subagent_start" | "subagentStart" => Ok(Self::SubagentStart),
"SubagentStop" | "subagent_stop" | "subagentStop" => Ok(Self::SubagentStop),
"SubagentEnd" | "subagent_end" | "subagentEnd" => Ok(Self::SubagentEnd),
"PreCompact" | "pre_compact" | "preCompact" => Ok(Self::PreCompact),
"PostCompact" | "post_compact" | "postCompact" => Ok(Self::PostCompact),
other => Err(serde::de::Error::custom(format!(
"unknown hook event: '{other}'. Expected one of: \
SessionStart, PreToolUse, PostToolUse, PostToolUseFailure, \
SessionEnd, Stop, StopFailure, Notification, UserPromptSubmit, \
PermissionDenied, SubagentStart, SubagentStop, \
PreCompact, PostCompact"
))),
}
}
}
impl std::fmt::Display for HookEventName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SessionStart => write!(f, "session_start"),
Self::PreToolUse => write!(f, "pre_tool_use"),
Self::PostToolUse => write!(f, "post_tool_use"),
Self::PostToolUseFailure => write!(f, "post_tool_use_failure"),
Self::SessionEnd => write!(f, "session_end"),
Self::Stop => write!(f, "stop"),
Self::StopFailure => write!(f, "stop_failure"),
Self::Notification => write!(f, "notification"),
Self::UserPromptSubmit => write!(f, "user_prompt_submit"),
Self::PermissionDenied => write!(f, "permission_denied"),
Self::SubagentStart => write!(f, "subagent_start"),
Self::SubagentStop | Self::SubagentEnd => write!(f, "subagent_stop"),
Self::PreCompact => write!(f, "pre_compact"),
Self::PostCompact => write!(f, "post_compact"),
}
}
}
impl HookEventName {
/// Collapse alias variants to their canonical form so a registration and the fired
/// event meet on one key regardless of which spelling each used (`SubagentEnd` is an
/// alias of `SubagentStop`).
pub fn canonical(self) -> Self {
match self {
Self::SubagentEnd => Self::SubagentStop,
other => other,
}
}
/// Returns true if this event type uses blocking (deny/allow) semantics.
pub fn is_blocking(&self) -> bool {
matches!(self, Self::PreToolUse)
}
/// Events that don't support matcher patterns (fire on every occurrence).
pub fn is_lifecycle(&self) -> bool {
matches!(
self,
Self::SessionStart | Self::SessionEnd | Self::Stop | Self::UserPromptSubmit
)
}
}
/// The normalized event envelope sent to hook commands on stdin as JSON.
///
/// Contains common metadata plus an event-specific payload.
/// All field names use camelCase for the JSON wire format.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HookEventEnvelope {
pub hook_event_name: HookEventName,
pub session_id: String,
pub cwd: String,
pub workspace_root: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub transcript_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_id: Option<String>,
#[serde(flatten)]
pub payload: HookPayload,
}
/// Event-specific payload variants, flattened into the envelope JSON via
/// `#[serde(untagged)]`. Grouped to match `HookEventName`.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum HookPayload {
// ── Session lifecycle ───────────────────────────────────────
SessionStart {
source: String,
#[serde(rename = "modelId", skip_serializing_if = "Option::is_none")]
model_id: Option<String>,
#[serde(rename = "agentType", skip_serializing_if = "Option::is_none")]
agent_type: Option<String>,
},
SessionEnd {
reason: String,
#[serde(rename = "turnCount", skip_serializing_if = "Option::is_none")]
turn_count: Option<u64>,
#[serde(rename = "toolCallCount", skip_serializing_if = "Option::is_none")]
tool_call_count: Option<u64>,
},
Stop {
reason: String,
},
StopFailure {
error: String,
},
// ── Tool events ─────────────────────────────────────────────
PreToolUse {
/// The tool the model invoked. For the meta-dispatch tools (`use_tool`
/// and the external MCP-call tool) this is the resolved underlying tool
/// (`server__tool`), not the dispatcher — matchers key on it directly.
#[serde(rename = "toolName")]
tool_name: String,
#[serde(rename = "toolUseId")]
tool_use_id: String,
#[serde(rename = "toolInput")]
tool_input: serde_json::Value,
#[serde(rename = "toolInputTruncated")]
tool_input_truncated: bool,
#[serde(rename = "permissionMode", skip_serializing_if = "Option::is_none")]
permission_mode: Option<String>,
/// The subagent's type when this tool runs inside one (the envelope's `sessionId`
/// gives its identity); `None` for the top-level session.
#[serde(rename = "subagentType", skip_serializing_if = "Option::is_none")]
subagent_type: Option<String>,
},
PostToolUse {
/// Resolved underlying tool for meta-dispatch tools (see `PreToolUse`).
#[serde(rename = "toolName")]
tool_name: String,
#[serde(rename = "toolUseId")]
tool_use_id: String,
#[serde(rename = "toolInput")]
tool_input: serde_json::Value,
#[serde(rename = "toolResult")]
tool_result: serde_json::Value,
#[serde(rename = "toolInputTruncated")]
tool_input_truncated: bool,
#[serde(rename = "toolResultTruncated")]
tool_result_truncated: bool,
#[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
duration_ms: Option<u64>,
#[serde(rename = "isBackgrounded")]
is_backgrounded: bool,
#[serde(rename = "subagentType", skip_serializing_if = "Option::is_none")]
subagent_type: Option<String>,
},
PostToolUseFailure {
/// Resolved underlying tool for meta-dispatch tools (see `PreToolUse`).
#[serde(rename = "toolName")]
tool_name: String,
#[serde(rename = "toolUseId")]
tool_use_id: String,
#[serde(rename = "toolInput")]
tool_input: serde_json::Value,
#[serde(rename = "toolInputTruncated")]
tool_input_truncated: bool,
error: String,
#[serde(rename = "subagentType", skip_serializing_if = "Option::is_none")]
subagent_type: Option<String>,
},
PermissionDenied {
/// Resolved underlying tool for meta-dispatch tools (see `PreToolUse`).
#[serde(rename = "toolName")]
tool_name: String,
#[serde(rename = "toolUseId")]
tool_use_id: String,
#[serde(rename = "toolInput")]
tool_input: serde_json::Value,
#[serde(rename = "toolInputTruncated")]
tool_input_truncated: bool,
},
// ── User / notification events ──────────────────────────────
/// Fires when the user submits a prompt.
UserPromptSubmit {
#[serde(skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
},
/// Fires on agent notifications (permission prompts, idle, etc.).
Notification {
#[serde(rename = "notificationType")]
notification_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
/// Compat: some callers use `level` instead of `notificationType`.
#[serde(skip_serializing_if = "Option::is_none")]
level: Option<String>,
},
// ── Subagent events ─────────────────────────────────────────
/// Fires when a subagent is spawned.
SubagentStart {
#[serde(rename = "subagentId")]
subagent_id: String,
#[serde(rename = "subagentType")]
subagent_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
/// Fires when a subagent completes.
SubagentStop {
#[serde(rename = "subagentId")]
subagent_id: String,
#[serde(rename = "subagentType")]
subagent_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(rename = "exitCode", skip_serializing_if = "Option::is_none")]
exit_code: Option<i32>,
#[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
duration_ms: Option<u64>,
},
// ── Compaction events ───────────────────────────────────────
PreCompact {
/// "manual" or "auto".
source: String,
},
PostCompact {
/// "manual" or "auto".
source: String,
},
}
/// Truncate a JSON value if its serialized size exceeds `MAX_PAYLOAD_SIZE`.
///
/// Returns `(possibly_truncated_value, was_truncated)`.
pub fn truncate_payload(value: serde_json::Value) -> (serde_json::Value, bool) {
let serialized = serde_json::to_string(&value).unwrap_or_default();
if serialized.len() <= MAX_PAYLOAD_SIZE {
return (value, false);
}
// Cut at the largest char boundary <= MAX_PAYLOAD_SIZE so the slice never
// splits a multibyte codepoint.
let mut end = MAX_PAYLOAD_SIZE;
while !serialized.is_char_boundary(end) {
end -= 1;
}
let mut result = serialized[..end].to_string();
result.push_str(" [truncated]");
(serde_json::Value::String(result), true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_name_deser_all_variants() {
let cases: &[(&str, &str, HookEventName)] = &[
("SessionStart", "session_start", HookEventName::SessionStart),
("PreToolUse", "pre_tool_use", HookEventName::PreToolUse),
("PostToolUse", "post_tool_use", HookEventName::PostToolUse),
(
"PostToolUseFailure",
"post_tool_use_failure",
HookEventName::PostToolUseFailure,
),
("SessionEnd", "session_end", HookEventName::SessionEnd),
("Stop", "stop", HookEventName::Stop),
("StopFailure", "stop_failure", HookEventName::StopFailure),
("Notification", "notification", HookEventName::Notification),
(
"UserPromptSubmit",
"user_prompt_submit",
HookEventName::UserPromptSubmit,
),
(
"PermissionDenied",
"permission_denied",
HookEventName::PermissionDenied,
),
(
"SubagentStart",
"subagent_start",
HookEventName::SubagentStart,
),
("SubagentStop", "subagent_stop", HookEventName::SubagentStop),
("SubagentEnd", "subagent_end", HookEventName::SubagentEnd),
("PreCompact", "pre_compact", HookEventName::PreCompact),
("PostCompact", "post_compact", HookEventName::PostCompact),
];
for (pascal, snake, expected) in cases {
let from_pascal: HookEventName =
serde_json::from_str(&format!("\"{pascal}\"")).unwrap();
assert_eq!(
from_pascal, *expected,
"PascalCase deser failed for {pascal}"
);
let from_snake: HookEventName = serde_json::from_str(&format!("\"{snake}\"")).unwrap();
assert_eq!(from_snake, *expected, "snake_case deser failed for {snake}");
}
}
#[test]
fn event_name_display_all_variants() {
let cases: &[(HookEventName, &str)] = &[
(HookEventName::SessionStart, "session_start"),
(HookEventName::PreToolUse, "pre_tool_use"),
(HookEventName::PostToolUse, "post_tool_use"),
(HookEventName::PostToolUseFailure, "post_tool_use_failure"),
(HookEventName::SessionEnd, "session_end"),
(HookEventName::Stop, "stop"),
(HookEventName::StopFailure, "stop_failure"),
(HookEventName::Notification, "notification"),
(HookEventName::UserPromptSubmit, "user_prompt_submit"),
(HookEventName::PermissionDenied, "permission_denied"),
(HookEventName::SubagentStart, "subagent_start"),
(HookEventName::SubagentStop, "subagent_stop"),
(HookEventName::SubagentEnd, "subagent_stop"), // alias collapses
(HookEventName::PreCompact, "pre_compact"),
(HookEventName::PostCompact, "post_compact"),
];
for (event, expected) in cases {
assert_eq!(&event.to_string(), expected, "Display wrong for {event:?}");
}
}
#[test]
fn event_name_serde_roundtrip() {
let name = HookEventName::PreToolUse;
let json = serde_json::to_string(&name).unwrap();
assert_eq!(json, "\"pre_tool_use\"");
let parsed: HookEventName = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, name);
}
#[test]
fn event_name_unknown_rejected() {
let result = serde_json::from_str::<HookEventName>("\"UnknownEvent\"");
assert!(result.is_err());
}
#[test]
fn event_name_is_blocking() {
assert!(HookEventName::PreToolUse.is_blocking());
for event in [
HookEventName::SessionStart,
HookEventName::PostToolUse,
HookEventName::PostToolUseFailure,
HookEventName::SessionEnd,
HookEventName::Stop,
HookEventName::StopFailure,
HookEventName::Notification,
HookEventName::UserPromptSubmit,
HookEventName::PermissionDenied,
HookEventName::SubagentStart,
HookEventName::SubagentStop,
HookEventName::SubagentEnd,
HookEventName::PreCompact,
HookEventName::PostCompact,
] {
assert!(!event.is_blocking(), "{event:?} should not be blocking");
}
}
#[test]
fn event_name_is_lifecycle() {
let lifecycle = [
HookEventName::SessionStart,
HookEventName::SessionEnd,
HookEventName::Stop,
HookEventName::UserPromptSubmit,
];
for event in lifecycle {
assert!(event.is_lifecycle(), "{event:?} should be lifecycle");
}
let matchable = [
HookEventName::PreToolUse,
HookEventName::PostToolUse,
HookEventName::PostToolUseFailure,
HookEventName::PermissionDenied,
HookEventName::StopFailure,
HookEventName::Notification,
HookEventName::SubagentStart,
HookEventName::SubagentStop,
HookEventName::SubagentEnd,
HookEventName::PreCompact,
HookEventName::PostCompact,
];
for event in matchable {
assert!(
!event.is_lifecycle(),
"{event:?} should support matchers, not be lifecycle"
);
}
}
#[test]
fn truncate_small_payload() {
let value = serde_json::json!({"key": "small"});
let (result, truncated) = truncate_payload(value.clone());
assert!(!truncated);
assert_eq!(result, value);
}
#[test]
fn truncate_large_payload() {
let big_string = "x".repeat(MAX_PAYLOAD_SIZE + 1000);
let value = serde_json::Value::String(big_string);
let (result, truncated) = truncate_payload(value);
assert!(truncated);
let s = result.as_str().unwrap();
assert!(s.ends_with("[truncated]"));
// Serialized size of the result string value should be <= MAX_PAYLOAD_SIZE + overhead
assert!(s.len() < MAX_PAYLOAD_SIZE + 100);
}
#[test]
fn truncate_large_payload_cuts_on_char_boundary() {
// '€' is 3 bytes, so the MAX_PAYLOAD_SIZE-th byte lands mid-codepoint.
let value = serde_json::Value::String("".repeat(MAX_PAYLOAD_SIZE));
let (result, truncated) = truncate_payload(value);
assert!(truncated);
assert!(result.as_str().unwrap().ends_with("[truncated]"));
}
#[test]
fn envelope_serializes_camel_case() {
let envelope = HookEventEnvelope {
hook_event_name: HookEventName::SessionStart,
session_id: "test-session".into(),
cwd: "/tmp".into(),
workspace_root: "/tmp".into(),
timestamp: "2025-01-01T00:00:00Z".into(),
transcript_path: None,
client_identifier: None,
prompt_id: None,
payload: HookPayload::SessionStart {
source: "new".into(),
model_id: Some("grok-3".into()),
agent_type: None,
},
};
let json = serde_json::to_string(&envelope).unwrap();
assert!(json.contains("hookEventName"));
assert!(json.contains("sessionId"));
assert!(json.contains("workspaceRoot"));
assert!(json.contains("modelId"));
// Should NOT contain snake_case versions
assert!(!json.contains("hook_event_name"));
assert!(!json.contains("session_id"));
}
}