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).
436 lines
16 KiB
Rust
436 lines
16 KiB
Rust
//! Layer 1: PTY management — spawn, inject keys, resize, drain output.
|
|
|
|
use std::io::{Read, Write};
|
|
use std::path::Path;
|
|
use std::sync::mpsc;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result};
|
|
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
|
|
|
/// Raw key byte constants for terminal input injection.
|
|
pub mod keys {
|
|
pub const J: &[u8] = b"j";
|
|
pub const K: &[u8] = b"k";
|
|
pub const Q: &[u8] = b"q";
|
|
pub const DOWN: &[u8] = b"\x1b[B";
|
|
pub const UP: &[u8] = b"\x1b[A";
|
|
pub const PGDN: &[u8] = b"\x1b[6~";
|
|
pub const PGUP: &[u8] = b"\x1b[5~";
|
|
pub const ENTER: &[u8] = b"\r";
|
|
pub const CTRL_C: &[u8] = b"\x03";
|
|
/// Ctrl+R (0x12) — prompt history search / scrollback mouse-reporting toggle.
|
|
pub const CTRL_R: &[u8] = b"\x12";
|
|
pub const ESC: &[u8] = b"\x1b";
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) enum PtyRead {
|
|
Chunk(Vec<u8>),
|
|
Timeout,
|
|
Closed,
|
|
}
|
|
|
|
/// Low-level PTY controller: spawns a child process inside a PTY and provides
|
|
/// methods to inject input, resize, and drain output.
|
|
pub struct PtyController {
|
|
child: Box<dyn portable_pty::Child + Send>,
|
|
writer: Box<dyn Write + Send>,
|
|
reader_rx: mpsc::Receiver<Vec<u8>>,
|
|
#[allow(dead_code)] // Kept alive to hold the PTY open; used by resize().
|
|
master: Box<dyn portable_pty::MasterPty + Send>,
|
|
}
|
|
|
|
impl PtyController {
|
|
/// Spawn a binary inside a PTY with the given terminal size.
|
|
///
|
|
/// `env` is a list of `(key, value)` pairs to set on the child process.
|
|
pub fn spawn(
|
|
binary: &Path,
|
|
size: PtySize,
|
|
args: &[&str],
|
|
env: &[(&str, &str)],
|
|
) -> Result<Self> {
|
|
Self::spawn_in_dir(binary, size, args, env, None)
|
|
}
|
|
|
|
/// Like [`spawn`](Self::spawn), with an optional child working directory.
|
|
pub fn spawn_in_dir(
|
|
binary: &Path,
|
|
size: PtySize,
|
|
args: &[&str],
|
|
env: &[(&str, &str)],
|
|
cwd: Option<&Path>,
|
|
) -> Result<Self> {
|
|
let pty_system = native_pty_system();
|
|
let pair = pty_system.openpty(size)?;
|
|
|
|
let mut cmd = CommandBuilder::new(binary);
|
|
for arg in args {
|
|
cmd.arg(*arg);
|
|
}
|
|
if let Some(dir) = cwd {
|
|
cmd.cwd(dir);
|
|
}
|
|
apply_child_env(&mut cmd, env);
|
|
|
|
let child = pair.slave.spawn_command(cmd)?;
|
|
// Drop the slave so we get EOF when the child exits.
|
|
drop(pair.slave);
|
|
|
|
let reader = pair.master.try_clone_reader()?;
|
|
let writer = pair.master.take_writer()?;
|
|
let reader_rx = spawn_reader(reader);
|
|
|
|
Ok(Self {
|
|
child,
|
|
writer,
|
|
reader_rx,
|
|
master: pair.master,
|
|
})
|
|
}
|
|
|
|
/// Write raw key bytes into the PTY stdin.
|
|
pub fn inject_keys(&mut self, keys: &[u8]) -> Result<()> {
|
|
self.writer
|
|
.write_all(keys)
|
|
.context("failed to write to PTY stdin")
|
|
}
|
|
|
|
/// Resize the PTY (sends SIGWINCH to the child). Arguments are `(rows, cols)`.
|
|
pub fn resize(&self, rows: u16, cols: u16) -> Result<()> {
|
|
self.master
|
|
.resize(PtySize {
|
|
rows,
|
|
cols,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.context("failed to resize PTY")
|
|
}
|
|
|
|
/// Drain the reader channel, collecting all available chunks within `timeout`.
|
|
pub fn drain_output(&self, timeout: Duration) -> Vec<Vec<u8>> {
|
|
let mut chunks = Vec::new();
|
|
let deadline = std::time::Instant::now() + timeout;
|
|
|
|
loop {
|
|
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
|
if remaining.is_zero() {
|
|
break;
|
|
}
|
|
match self.reader_rx.recv_timeout(remaining) {
|
|
Ok(chunk) => chunks.push(chunk),
|
|
Err(mpsc::RecvTimeoutError::Timeout) => break,
|
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
|
}
|
|
}
|
|
|
|
chunks
|
|
}
|
|
|
|
/// Send 'q' to trigger the pager's quit handler and wait for exit.
|
|
///
|
|
/// Key injection is best-effort — the child may have already exited
|
|
/// (e.g. no ACP server), so write failures are silently ignored.
|
|
pub fn quit(&mut self) -> Result<()> {
|
|
let _ = self.inject_keys(keys::Q);
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
match self.child.try_wait()? {
|
|
Some(_) => return Ok(()),
|
|
None if std::time::Instant::now() >= deadline => {
|
|
self.child.kill()?;
|
|
self.child
|
|
.wait()
|
|
.context("failed to wait for pager child after kill")?;
|
|
return Ok(());
|
|
}
|
|
None => std::thread::sleep(Duration::from_millis(50)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Receive one chunk, distinguishing timeout from reader EOF.
|
|
///
|
|
/// Processing each chunk inline preserves inter-chunk timing.
|
|
pub(crate) fn recv_chunk(&self, timeout: Duration) -> PtyRead {
|
|
match self.reader_rx.recv_timeout(timeout) {
|
|
Ok(chunk) => PtyRead::Chunk(chunk),
|
|
Err(mpsc::RecvTimeoutError::Timeout) => PtyRead::Timeout,
|
|
Err(mpsc::RecvTimeoutError::Disconnected) => PtyRead::Closed,
|
|
}
|
|
}
|
|
|
|
/// Check whether the child process is still running.
|
|
pub fn is_running(&mut self) -> bool {
|
|
matches!(self.child.try_wait(), Ok(None))
|
|
}
|
|
|
|
/// Poll child status once, preserving process-query errors.
|
|
pub(crate) fn try_exit_code(&mut self) -> Result<Option<u32>> {
|
|
self.child
|
|
.try_wait()
|
|
.map(|status| status.map(|status| status.exit_code()))
|
|
.context("failed to query PTY child status")
|
|
}
|
|
|
|
/// Wait up to `timeout` for the child to exit, returning its exit code
|
|
/// (`None` if it's still running at the deadline). Call once and cache the
|
|
/// result — `try_wait` reaps the child, so the status isn't re-readable.
|
|
pub fn wait_exit_code(&mut self, timeout: Duration) -> Option<u32> {
|
|
let deadline = std::time::Instant::now() + timeout;
|
|
loop {
|
|
match self.child.try_wait() {
|
|
Ok(Some(status)) => return Some(status.exit_code()),
|
|
Ok(None) if std::time::Instant::now() >= deadline => return None,
|
|
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
|
|
Err(_) => return None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Child PID, falling back to the PTY's foreground process group.
|
|
#[cfg(unix)]
|
|
pub fn child_pid(&self) -> Option<u32> {
|
|
self.child
|
|
.process_id()
|
|
.or_else(|| self.master.process_group_leader().map(|p| p as u32))
|
|
}
|
|
|
|
/// Child PID (no foreground-group fallback — ConPTY has no process groups).
|
|
#[cfg(windows)]
|
|
pub fn child_pid(&self) -> Option<u32> {
|
|
self.child.process_id()
|
|
}
|
|
|
|
/// Deliver a signal directly to the child (unix), bypassing the PTY line
|
|
/// discipline. Exercises the real SIGINT/SIGTERM/SIGHUP paths (distinct from
|
|
/// injected Ctrl+C key bytes, which are key events under raw mode).
|
|
/// Call before the child is reaped: a reaped pid can be reused.
|
|
#[cfg(unix)]
|
|
pub fn send_signal(&self, signal: i32) -> Result<()> {
|
|
let pid = self.child_pid().context("no child pid to signal")?;
|
|
// SAFETY: libc::kill has no memory-safety preconditions, and child_pid()
|
|
// only yields a positive live-child pid (never the kill(0)/kill(-1) broadcast).
|
|
let rc = unsafe { libc::kill(pid as libc::pid_t, signal) };
|
|
if rc != 0 {
|
|
return Err(anyhow::Error::from(std::io::Error::last_os_error())).context("libc::kill");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for PtyController {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
/// Host terminal identity markers stripped from the child environment.
|
|
///
|
|
/// The pager's terminal detection
|
|
/// (`kigi-pager-render/src/terminal/mod.rs`:
|
|
/// `detect_terminal_brand_from_env` / `detect_byobu_from_env` /
|
|
/// `detect_multiplexer_from_env` / `detect_tmux_meta_from_env`, plus
|
|
/// `embedded_editor.rs`'s `embedded_editor_from_env`) reads all of these,
|
|
/// so any one leaking from the harness's own host terminal reclassifies the
|
|
/// child: a dev running tests inside tmux leaks `TMUX` (every cell becomes
|
|
/// the remuxed profile), inside Cursor leaks `CURSOR_TRACE_ID` (checked
|
|
/// *before* `TERM_PROGRAM`, so it overrides even a test-injected brand),
|
|
/// inside nvim's `:terminal` leaks `NVIM` (clipboard OSC 52 wrapping).
|
|
/// Keep this list in sync with the detection source above.
|
|
const HOST_TERMINAL_ENV_VARS: &[&str] = &[
|
|
// Brand chain (detect_terminal_brand_from_env), in detection order.
|
|
"CURSOR_TRACE_ID",
|
|
"VSCODE_GIT_ASKPASS_MAIN",
|
|
"TERM_PROGRAM",
|
|
"TERM_PROGRAM_VERSION",
|
|
"TERMINAL_EMULATOR",
|
|
"WEZTERM_VERSION",
|
|
"ITERM_SESSION_ID",
|
|
"ITERM_PROFILE",
|
|
"LC_TERMINAL",
|
|
"LC_TERMINAL_VERSION",
|
|
"TERM_SESSION_ID",
|
|
"KITTY_WINDOW_ID",
|
|
"ALACRITTY_SOCKET",
|
|
"TERMINATOR_UUID",
|
|
"VTE_VERSION",
|
|
"WT_SESSION",
|
|
// Multiplexer / Byobu markers (detect_multiplexer_from_env,
|
|
// detect_byobu_from_env, detect_tmux_meta_from_env).
|
|
"TMUX",
|
|
"TMUX_PANE",
|
|
"ZELLIJ",
|
|
"ZELLIJ_SESSION_NAME",
|
|
"STY",
|
|
"BYOBU_BACKEND",
|
|
"BYOBU_CONFIG_DIR",
|
|
"BYOBU_DISTRO",
|
|
"CMUX_SOCKET_PATH",
|
|
"CMUX_PANEL_ID",
|
|
"CMUX_BUNDLE_ID",
|
|
// Embedded editor markers (embedded_editor_from_env).
|
|
"NVIM",
|
|
"NVIM_LISTEN_ADDRESS",
|
|
"VIM_TERMINAL",
|
|
"INSIDE_EMACS",
|
|
];
|
|
|
|
/// Prepare the child environment: fixed `TERM`, color and host-terminal
|
|
/// hygiene strips, then the caller's `env` pairs.
|
|
///
|
|
/// Strips run BEFORE the caller env is applied, preserving the contract
|
|
/// that tests may re-inject any marker (e.g. `TERM_PROGRAM=vscode`, or a
|
|
/// fake `NVIM` socket) to simulate that host — see
|
|
/// `tests/pty_e2e/doubled_lines_out_of_band_repro.rs` in the pager crate.
|
|
fn apply_child_env(cmd: &mut CommandBuilder, env: &[(&str, &str)]) {
|
|
// Set TERM so the pager renders with full color support.
|
|
cmd.env("TERM", "xterm-256color");
|
|
// Strip inherited color opt-outs/overrides for the same reason: a
|
|
// leaked NO_COLOR (common in agent/CI shells) renders the pager
|
|
// colorless, making style-sensitive assertions (e.g. the selection
|
|
// highlight color swap) silently untestable on some hosts. Tests may
|
|
// re-set these via the `env` list (applied after this).
|
|
for color_var in ["NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE"] {
|
|
cmd.env_remove(color_var);
|
|
}
|
|
// Strip SSH vars inherited from the parent: the harness PTY is a
|
|
// local terminal, but `SSH_CONNECTION`/`SSH_TTY` leaking through
|
|
// makes the pager's terminal detector report SSH and disable the
|
|
// drag-drop image classifier (see
|
|
// `try_handle_dropped_paths_paste` in `agent_view.rs`).
|
|
for ssh_var in ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "SSH_AUTH_SOCK"] {
|
|
cmd.env_remove(ssh_var);
|
|
}
|
|
// Neutralize parent-terminal identity bleed: agent hosts often export
|
|
// TERM_PROGRAM=ghostty/iTerm/etc. (and mux/editor markers) which make
|
|
// the child pager adopt that host's key/modifier/clipboard quirks even
|
|
// though we only set TERM above.
|
|
for term_var in HOST_TERMINAL_ENV_VARS {
|
|
cmd.env_remove(term_var);
|
|
}
|
|
for &(key, val) in env {
|
|
cmd.env(key, val);
|
|
}
|
|
}
|
|
|
|
/// Spawn a background thread that reads from the PTY master and sends
|
|
/// chunks over an `mpsc` channel. The reader is blocking (WezTerm pattern),
|
|
/// so it must live on its own thread.
|
|
fn spawn_reader(mut reader: Box<dyn Read + Send>) -> mpsc::Receiver<Vec<u8>> {
|
|
let (tx, rx) = mpsc::channel();
|
|
std::thread::Builder::new()
|
|
.name("pty-reader".into())
|
|
.spawn(move || {
|
|
let mut buf = [0u8; 8192];
|
|
loop {
|
|
match reader.read(&mut buf) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => {
|
|
if tx.send(buf[..n].to_vec()).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.expect("failed to spawn pty-reader thread");
|
|
rx
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Every host-terminal marker the pager's detection chain reads must be
|
|
/// stripped from the child env — polluted entries are seeded via
|
|
/// `cmd.env` (same `CommandBuilder` map that inherited base-env entries
|
|
/// live in, so `env_remove` takes the identical path) rather than
|
|
/// process-global `set_var` (racy under parallel tests).
|
|
#[test]
|
|
fn apply_child_env_strips_all_host_terminal_markers() {
|
|
let mut cmd = CommandBuilder::new("true");
|
|
for var in HOST_TERMINAL_ENV_VARS {
|
|
cmd.env(var, "polluted");
|
|
}
|
|
for ssh_var in ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "SSH_AUTH_SOCK"] {
|
|
cmd.env(ssh_var, "polluted");
|
|
}
|
|
for color_var in ["NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE"] {
|
|
cmd.env(color_var, "polluted");
|
|
}
|
|
// Unrelated vars must survive the hygiene pass untouched.
|
|
cmd.env("KIGI_SCROLL_LOG", "/tmp/scroll.jsonl");
|
|
|
|
apply_child_env(&mut cmd, &[]);
|
|
|
|
for var in HOST_TERMINAL_ENV_VARS {
|
|
assert!(
|
|
cmd.get_env(var).is_none(),
|
|
"host terminal marker {var} leaked into the child env"
|
|
);
|
|
}
|
|
for ssh_var in ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "SSH_AUTH_SOCK"] {
|
|
assert!(
|
|
cmd.get_env(ssh_var).is_none(),
|
|
"SSH marker {ssh_var} leaked into the child env"
|
|
);
|
|
}
|
|
for color_var in ["NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE"] {
|
|
assert!(
|
|
cmd.get_env(color_var).is_none(),
|
|
"color override {color_var} leaked into the child env"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
cmd.get_env("TERM").and_then(|v| v.to_str()),
|
|
Some("xterm-256color")
|
|
);
|
|
assert_eq!(
|
|
cmd.get_env("KIGI_SCROLL_LOG").and_then(|v| v.to_str()),
|
|
Some("/tmp/scroll.jsonl"),
|
|
"hygiene must not touch unrelated vars"
|
|
);
|
|
}
|
|
|
|
/// The documented override contract: strips run BEFORE the caller env,
|
|
/// so tests can re-inject any marker to simulate a specific host
|
|
/// (e.g. the fake-nvim wrapper repro or the xtversion brand fixtures).
|
|
#[test]
|
|
fn apply_child_env_caller_env_overrides_survive_strips() {
|
|
let mut cmd = CommandBuilder::new("true");
|
|
cmd.env("TMUX", "/tmp/host-tmux,999,0");
|
|
cmd.env("CURSOR_TRACE_ID", "host-cursor");
|
|
|
|
apply_child_env(
|
|
&mut cmd,
|
|
&[
|
|
("TERM_PROGRAM", "vscode"),
|
|
("NVIM", "/tmp/fake-nvim.sock"),
|
|
("TERM", "xterm-kitty"),
|
|
],
|
|
);
|
|
|
|
// Host pollution is gone…
|
|
assert!(cmd.get_env("TMUX").is_none());
|
|
assert!(cmd.get_env("CURSOR_TRACE_ID").is_none());
|
|
// …but caller-injected markers (applied after strips) survive,
|
|
// including overriding the harness's own TERM default.
|
|
assert_eq!(
|
|
cmd.get_env("TERM_PROGRAM").and_then(|v| v.to_str()),
|
|
Some("vscode")
|
|
);
|
|
assert_eq!(
|
|
cmd.get_env("NVIM").and_then(|v| v.to_str()),
|
|
Some("/tmp/fake-nvim.sock")
|
|
);
|
|
assert_eq!(
|
|
cmd.get_env("TERM").and_then(|v| v.to_str()),
|
|
Some("xterm-kitty")
|
|
);
|
|
}
|
|
}
|