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).
217 lines
9.7 KiB
Rust
217 lines
9.7 KiB
Rust
//! 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 _))
|
|
}
|
|
}
|