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
@@ -0,0 +1,216 @@
//! Dedicated-thread reader for the ACP stdio transport's standard input.
//!
//! Every ACP client (VS Code extension, grok-desktop, the leader bridge) drives
//! the agent over a **persistent, bidirectional** newline-delimited JSON-RPC
//! stream on stdio: it writes requests on the child's stdin and reads responses
//! on stdout, keeping **stdin open for the whole session**.
//!
//! # Why not `tokio::io::stdin()`
//!
//! `tokio::io::stdin()` is not truly asynchronous. Tokio services it with a
//! blocking `std::io` read on an internal pool thread, and that read **cannot be
//! cancelled**. For interactive / persistent uses the
//! [`tokio::io::Stdin`](https://docs.rs/tokio/latest/tokio/io/struct.Stdin.html)
//! docs recommend "spawn a thread dedicated to user input and use blocking IO
//! directly in that thread". [`spawn_stdin_line_reader`] does exactly that.
//!
//! # Why the reader takes *exclusive* ownership of stdin (Windows)
//!
//! `std::io::Stdin` is a process-global handle guarded by a re-entrant mutex
//! (the `StdinLock`). A blocking read **holds that lock for the entire duration
//! of the read** — and for the persistent stdio transport the reader is almost
//! always parked in a read, waiting for the client's next line. If *any other*
//! code in the process then calls `std::io::stdin()` (e.g. a stray interactive
//! prompt reached only on a particular platform), it blocks on the lock until
//! the reader's in-flight read returns — which only happens at **EOF**, i.e.
//! when the client closes stdin. For a persistent ACP client that never closes
//! stdin mid-session this is a hard hang: the agent freezes part-way through a
//! request (observed on **Windows** during `session/new`) and only unblocks when
//! the transport is torn down. macOS/Linux don't reach the offending stray read,
//! so they were unaffected — but the hazard is real on any platform.
//!
//! To make the transport robust, on Windows the reader thread takes a **private
//! duplicate** of the real stdin handle and then points the process's standard
//! input at **`NUL`**. The reader keeps reading the client's bytes through its
//! private handle, while every *other* `std::io::stdin()` read in the process
//! observes immediate EOF instead of deadlocking on the lock. This mirrors what
//! already makes leader mode safe (the agent subprocess is spawned with
//! `stdin = NUL`, so its stray reads EOF instantly). Unix keeps reading
//! `std::io::stdin()` directly — it has no second stdin reader on these paths
//! and the extra FFI/`dup` would add risk for no benefit.
//!
//! # Escaped-slash normalization (acp 0.6 wire workaround)
//!
//! Every line is forwarded through `normalize_json_line` — see the
//! crate-private `normalize` module for the contract and its scope.
use std::io::BufRead;
use tokio::sync::mpsc;
use crate::normalize::normalize_json_line;
/// Channel depth for buffered stdin lines. Small: the reader thread blocks on a
/// full channel, applying natural backpressure to a flooding peer rather than
/// growing memory without bound.
const STDIN_LINE_CHANNEL_DEPTH: usize = 64;
/// Spawn a dedicated OS thread that reads newline-delimited lines from the
/// process's standard input with **synchronous, blocking** `std::io` and yields
/// each line (its trailing `\n` included, like `read_line`/`read_until`) on the
/// returned channel. A final line without a trailing newline is still delivered
/// before the channel closes.
///
/// Yielded lines are **not guaranteed byte-verbatim**: a line the pinned acp
/// 0.6 envelope would otherwise drop (a `\/`-escaped `method`, as Foundation
/// encoders emit) is re-serialized compactly (key order, whitespace, and
/// number formatting normalized) before forwarding — see the crate-private
/// `normalize` module. Every line the envelope already accepts, and anything
/// that fails to parse, passes through byte-identical (trailing terminator
/// always preserved).
///
/// The channel closes (so [`recv`](mpsc::Receiver::recv) returns `None`) when
/// stdin reaches EOF, the read fails, or the [`Receiver`](mpsc::Receiver) is
/// dropped. The reader is meant to be the **sole** stdin consumer in the
/// agent-stdio / leader-bridge paths; on Windows it enforces that by redirecting
/// the process's standard input to `NUL` so stray readers can't deadlock on it
/// (see the [module docs](self)).
pub fn spawn_stdin_line_reader() -> mpsc::Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel::<Vec<u8>>(STDIN_LINE_CHANNEL_DEPTH);
// On Windows, synchronously take a private duplicate of the real stdin and
// redirect the process's standard input to `NUL` *before* the reader thread
// parks in a blocking read holding the global `StdinLock`. After this, any
// other `std::io::stdin()` read in the process EOFs immediately instead of
// deadlocking. `None` means we couldn't isolate (we fall back to reading
// `std::io::stdin()` directly — no worse than before).
#[cfg(windows)]
let private_stdin: Option<std::fs::File> = isolate_process_stdin();
std::thread::Builder::new()
.name("acp-stdin".to_string())
.spawn(move || {
#[cfg(windows)]
if let Some(file) = private_stdin {
forward_lines(std::io::BufReader::new(file), &tx);
return;
}
let stdin = std::io::stdin();
forward_lines(stdin.lock(), &tx);
})
.expect("failed to spawn acp-stdin reader thread");
rx
}
/// Read `\n`-delimited lines from `reader` and forward each on `tx` — via
/// [`normalize_json_line`], so bytes are verbatim except for the lines that
/// workaround rewrites (terminator always preserved) — until EOF, a read
/// error, or the receiver is dropped.
fn forward_lines<R: BufRead>(mut reader: R, tx: &mpsc::Sender<Vec<u8>>) {
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line) {
// EOF or a fatal read error: return, dropping `tx` closes the channel.
Ok(0) | Err(_) => break,
Ok(_) => {}
}
let normalized = normalize_json_line(std::mem::take(&mut line));
// `blocking_send` parks this thread (not a runtime worker) when the
// channel is full, and errors only once the receiver is dropped — at
// which point there is nothing left to feed.
if tx.blocking_send(normalized).is_err() {
break;
}
}
}
/// Duplicate the real stdin handle for private use and repoint the process's
/// `STD_INPUT_HANDLE` at `NUL`, returning the duplicate as an owned [`File`].
///
/// Returns `None` (caller falls back to `std::io::stdin()`) when there is no
/// stdin handle or duplication fails. Win32 declarations are inlined to avoid a
/// `windows`/`windows-sys` dependency, matching the pager's console setup.
///
/// [`File`]: std::fs::File
#[cfg(windows)]
fn isolate_process_stdin() -> Option<std::fs::File> {
use std::os::windows::io::FromRawHandle as _;
// Win32 constants (inlined to avoid a dependency).
const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // (DWORD)-10
const DUPLICATE_SAME_ACCESS: u32 = 0x0000_0002;
const GENERIC_READ: u32 = 0x8000_0000;
const FILE_SHARE_READ: u32 = 0x0000_0001;
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
const OPEN_EXISTING: u32 = 0x0000_0003;
const INVALID_HANDLE: *mut core::ffi::c_void = -1_isize as *mut core::ffi::c_void;
unsafe extern "system" {
fn GetStdHandle(nStdHandle: u32) -> *mut core::ffi::c_void;
fn SetStdHandle(nStdHandle: u32, hHandle: *mut core::ffi::c_void) -> i32;
fn GetCurrentProcess() -> *mut core::ffi::c_void;
fn DuplicateHandle(
hSourceProcessHandle: *mut core::ffi::c_void,
hSourceHandle: *mut core::ffi::c_void,
hTargetProcessHandle: *mut core::ffi::c_void,
lpTargetHandle: *mut *mut core::ffi::c_void,
dwDesiredAccess: u32,
bInheritHandle: i32,
dwOptions: u32,
) -> i32;
fn CreateFileW(
lpFileName: *const u16,
dwDesiredAccess: u32,
dwShareMode: u32,
lpSecurityAttributes: *mut core::ffi::c_void,
dwCreationDisposition: u32,
dwFlagsAndAttributes: u32,
hTemplateFile: *mut core::ffi::c_void,
) -> *mut core::ffi::c_void;
}
// SAFETY: standard Win32 console/file calls; every return value is checked
// before use and the duplicated handle is wrapped in an owning `File`.
unsafe {
let current = GetStdHandle(STD_INPUT_HANDLE);
if current.is_null() || current == INVALID_HANDLE {
return None;
}
let process = GetCurrentProcess();
let mut duplicate: *mut core::ffi::c_void = std::ptr::null_mut();
if DuplicateHandle(
process,
current,
process,
&mut duplicate,
0,
0, // not inheritable
DUPLICATE_SAME_ACCESS,
) == 0
{
return None;
}
// Repoint the process's std input at NUL so stray `std::io::stdin()`
// reads observe EOF instead of blocking on the held `StdinLock`. If NUL
// can't be opened we still return the duplicate so the reader works;
// we just forgo the stray-read isolation.
let nul: Vec<u16> = "NUL\0".encode_utf16().collect();
let nul_handle = CreateFileW(
nul.as_ptr(),
GENERIC_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null_mut(),
OPEN_EXISTING,
0,
std::ptr::null_mut(),
);
if nul_handle != INVALID_HANDLE && !nul_handle.is_null() {
SetStdHandle(STD_INPUT_HANDLE, nul_handle);
}
Some(std::fs::File::from_raw_handle(duplicate as _))
}
}