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).
251 lines
10 KiB
Rust
251 lines
10 KiB
Rust
//! TUI-side signal handlers that restore the terminal before exiting.
|
|
//!
|
|
//! Without these, signal-triggered termination (SIGINT, SIGTERM, SIGHUP from
|
|
//! WSL/SSH disconnect) leaves the user's terminal in raw mode with the
|
|
//! alternate screen still active, mouse capture still on, and Kitty keyboard
|
|
//! protocol flags still pushed. The next time the terminal becomes visible the
|
|
//! user sees a stale snapshot of the TUI that looks alive but is backed by a
|
|
//! dead process.
|
|
//!
|
|
//! SIGINT / SIGTERM / SIGHUP are handled in a tokio task. The handler runs
|
|
//! in normal Rust context (not actual signal-handler context), so it can use
|
|
//! the full [`super::emit_terminal_teardown_sequences`] path
|
|
//! (`with_locked_stderr`, conditional cursor-style reset, multiplexer flush) plus
|
|
//! `disable_raw_mode`, then flush Sentry/OpenTelemetry, then exit.
|
|
//!
|
|
//! SIGPIPE is intentionally left alone. The current disposition is `SIG_IGN`
|
|
//! (Rust's stdlib default), which means writes to a closed pipe return
|
|
//! `Err(BrokenPipe)` instead of terminating the process. The writer thread
|
|
//! already swallows those errors. Installing a custom handler would change
|
|
//! the disposition inherited by fork+exec children (MCP servers, hooks) from
|
|
//! `SIG_IGN` to `SIG_DFL`, re-introducing a prior SIGPIPE regression.
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
use agent_client_protocol as acp;
|
|
|
|
use super::ScreenMode;
|
|
|
|
/// Whether the active screen mode is fullscreen. Set by [`install`].
|
|
static SCREEN_MODE_FULLSCREEN: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// Most recently active session ID for signal-handler cleanup.
|
|
/// `parking_lot::Mutex` avoids poisoning in the `-> !` exit path.
|
|
static CURRENT_SESSION_ID: parking_lot::Mutex<Option<acp::SessionId>> =
|
|
parking_lot::Mutex::new(None);
|
|
|
|
pub(crate) fn set_current_session_id(id: Option<acp::SessionId>) {
|
|
*CURRENT_SESSION_ID.lock() = id;
|
|
}
|
|
|
|
/// Lets the signal handler route SIGINT/SIGTERM/SIGHUP into the same graceful
|
|
/// quit as `/exit` (running teardown + history/telemetry flushes) instead of a
|
|
/// hard exit. Registered by the event loop before it starts.
|
|
static QUIT_NOTIFY: parking_lot::Mutex<Option<std::sync::Arc<tokio::sync::Notify>>> =
|
|
parking_lot::Mutex::new(None);
|
|
|
|
pub(crate) fn set_quit_notify(notify: std::sync::Arc<tokio::sync::Notify>) {
|
|
*QUIT_NOTIFY.lock() = Some(notify);
|
|
}
|
|
|
|
/// Whether the TUI currently owns the terminal. Set by [`install`], cleared
|
|
/// by [`mark_restored`] or by [`shutdown_with_terminal_restore`] after
|
|
/// teardown completes. The SIGPIPE path (SIG_IGN, no handler) does not
|
|
/// interact with this flag.
|
|
static TERMINAL_OWNED: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// Install signal handlers for the TUI lifecycle. Call after `init_terminal`.
|
|
pub(crate) fn install(mode: ScreenMode) {
|
|
SCREEN_MODE_FULLSCREEN.store(mode.is_fullscreen(), Ordering::Release);
|
|
TERMINAL_OWNED.store(true, Ordering::Release);
|
|
|
|
// Ignore SIGTTIN/SIGTTOU so the pager can't be suspended if a
|
|
// child process (or its grandchild) briefly steals the terminal's
|
|
// foreground process group. Every TUI that reads from stdin should
|
|
// do this — without it, a single tcsetpgrp() from a rogue child
|
|
// stops the entire pager, leaving the terminal in raw mode.
|
|
#[cfg(unix)]
|
|
unsafe {
|
|
libc::signal(libc::SIGTTIN, libc::SIG_IGN);
|
|
libc::signal(libc::SIGTTOU, libc::SIG_IGN);
|
|
}
|
|
|
|
spawn_async_signal_task();
|
|
}
|
|
|
|
/// Mark the terminal as no longer owned by the TUI. Called by the clean
|
|
/// shutdown path and the panic hook so a signal arriving after the shell
|
|
/// has reclaimed the terminal does not re-run teardown.
|
|
pub(crate) fn mark_restored() {
|
|
TERMINAL_OWNED.store(false, Ordering::Release);
|
|
}
|
|
|
|
fn spawn_async_signal_task() {
|
|
tokio::spawn(async {
|
|
#[cfg(unix)]
|
|
{
|
|
use tokio::signal::unix::{SignalKind, signal};
|
|
let mut sigterm = signal(SignalKind::terminate()).ok();
|
|
let mut sighup = signal(SignalKind::hangup()).ok();
|
|
// First signal requests the graceful quit; a second forces exit.
|
|
let code = next_signal_code(&mut sigterm, &mut sighup).await;
|
|
request_graceful_or_exit(code);
|
|
let code2 = next_signal_code(&mut sigterm, &mut sighup).await;
|
|
shutdown_with_terminal_restore(code2);
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
// ctrl_c gets the first-graceful / second-force treatment. Console
|
|
// close, logoff, and shutdown stay immediate: the OS grants only a
|
|
// short window, so graceful teardown may not finish.
|
|
use tokio::signal::windows;
|
|
let mut ctrl_close = windows::ctrl_close().ok();
|
|
let mut ctrl_logoff = windows::ctrl_logoff().ok();
|
|
let mut ctrl_shutdown = windows::ctrl_shutdown().ok();
|
|
tokio::select! {
|
|
_ = tokio::signal::ctrl_c() => {
|
|
request_graceful_or_exit(130);
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
shutdown_with_terminal_restore(130);
|
|
}
|
|
_ = async {
|
|
if let Some(s) = ctrl_close.as_mut() { let _ = s.recv().await; }
|
|
else { std::future::pending::<()>().await; }
|
|
} => shutdown_with_terminal_restore(1),
|
|
_ = async {
|
|
if let Some(s) = ctrl_logoff.as_mut() { let _ = s.recv().await; }
|
|
else { std::future::pending::<()>().await; }
|
|
} => shutdown_with_terminal_restore(0),
|
|
_ = async {
|
|
if let Some(s) = ctrl_shutdown.as_mut() { let _ = s.recv().await; }
|
|
else { std::future::pending::<()>().await; }
|
|
} => shutdown_with_terminal_restore(0),
|
|
}
|
|
}
|
|
#[cfg(not(any(unix, windows)))]
|
|
{
|
|
if tokio::signal::ctrl_c().await.is_ok() {
|
|
request_graceful_or_exit(130);
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
shutdown_with_terminal_restore(130);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Wait for the next SIGINT/SIGTERM/SIGHUP and map it to its exit code.
|
|
#[cfg(unix)]
|
|
async fn next_signal_code(
|
|
sigterm: &mut Option<tokio::signal::unix::Signal>,
|
|
sighup: &mut Option<tokio::signal::unix::Signal>,
|
|
) -> i32 {
|
|
tokio::select! {
|
|
_ = tokio::signal::ctrl_c() => 130,
|
|
_ = async {
|
|
if let Some(s) = sigterm.as_mut() { let _ = s.recv().await; }
|
|
else { std::future::pending::<()>().await; }
|
|
} => 143,
|
|
_ = async {
|
|
if let Some(s) = sighup.as_mut() { let _ = s.recv().await; }
|
|
else { std::future::pending::<()>().await; }
|
|
} => 129,
|
|
}
|
|
}
|
|
|
|
/// Request the event loop's graceful quit when it is registered and the TUI
|
|
/// still owns the terminal; otherwise hard-exit (agent mode, or a signal after
|
|
/// teardown already started).
|
|
fn request_graceful_or_exit(code: i32) {
|
|
let notify = QUIT_NOTIFY.lock().clone();
|
|
if TERMINAL_OWNED.load(Ordering::Acquire)
|
|
&& let Some(n) = notify
|
|
{
|
|
n.notify_one();
|
|
} else {
|
|
shutdown_with_terminal_restore(code);
|
|
}
|
|
}
|
|
|
|
/// Restore the terminal first, then flush observability, then exit.
|
|
///
|
|
/// Restore must precede the (up to 2-second) Sentry flush; otherwise the
|
|
/// user stares at a raw-mode + alt-screen + mouse-SGR terminal for that
|
|
/// whole window. Best-effort: a frame queued on the writer thread microseconds
|
|
/// before the signal can still land after our teardown writes -- the writer
|
|
/// thread is not reachable from here without a deadlock risk.
|
|
fn shutdown_with_terminal_restore(exit_code: i32) -> ! {
|
|
// The graceful quit (or a prior teardown) already restored the terminal;
|
|
// skip teardown and just flush telemetry before exiting.
|
|
if !TERMINAL_OWNED.load(Ordering::Acquire) {
|
|
flush_telemetry_and_exit(exit_code);
|
|
}
|
|
let mode = if SCREEN_MODE_FULLSCREEN.load(Ordering::Acquire) {
|
|
ScreenMode::Fullscreen
|
|
} else {
|
|
ScreenMode::Inline
|
|
};
|
|
// Signal-path shutdown has no terminal handle, so fall back to the screen
|
|
// bottom for the final cursor position.
|
|
super::emit_terminal_teardown_sequences(mode, None);
|
|
let _ = crossterm::terminal::disable_raw_mode();
|
|
// Mark after teardown so concurrent paths see TERMINAL_OWNED == true
|
|
// until all escape sequences and tcsetattr have been written.
|
|
TERMINAL_OWNED.store(false, Ordering::Release);
|
|
// Best-effort unregister (non-blocking flock to avoid hanging).
|
|
if let Some(ref sid) = *CURRENT_SESSION_ID.lock() {
|
|
let _ = kigi_shell::active_sessions::try_unregister(sid);
|
|
}
|
|
flush_telemetry_and_exit(exit_code);
|
|
}
|
|
|
|
/// Shared `-> !` exit tail of `shutdown_with_terminal_restore`'s early-return
|
|
/// and full-teardown paths.
|
|
fn flush_telemetry_and_exit(exit_code: i32) -> ! {
|
|
// Reap detached (setsid) background children before the hard exit. This tail
|
|
// runs on the force/second-signal and agent-mode paths that skip the
|
|
// graceful quit; the graceful path reaps them in `app::run`'s teardown.
|
|
kigi_tty_utils::global_process_scope().kill_all();
|
|
// Restore fd 2 so Sentry/OTEL flushes reach the terminal.
|
|
kigi_tty_utils::restore_native_stderr();
|
|
// Flush the --debug firehose on TUI signal exit (this path bypasses main's flush).
|
|
kigi_log::debug_log::flush();
|
|
std::process::exit(exit_code);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Restores `TERMINAL_OWNED` to its prior value on drop, including on panic.
|
|
struct TerminalOwnedGuard(bool);
|
|
|
|
impl TerminalOwnedGuard {
|
|
fn set(value: bool) -> Self {
|
|
Self(TERMINAL_OWNED.swap(value, Ordering::AcqRel))
|
|
}
|
|
}
|
|
|
|
impl Drop for TerminalOwnedGuard {
|
|
fn drop(&mut self) {
|
|
TERMINAL_OWNED.store(self.0, Ordering::Release);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_graceful_or_exit_notifies_registered_quit() {
|
|
let notify = std::sync::Arc::new(tokio::sync::Notify::new());
|
|
set_quit_notify(notify.clone());
|
|
let stored = QUIT_NOTIFY.lock().clone().expect("quit notify registered");
|
|
assert!(std::sync::Arc::ptr_eq(&stored, ¬ify));
|
|
|
|
// request_graceful_or_exit only notifies (vs process::exit) while the
|
|
// terminal reads as owned; the guard restores the flag even on panic.
|
|
let _owned_guard = TerminalOwnedGuard::set(true);
|
|
request_graceful_or_exit(130);
|
|
|
|
tokio::time::timeout(std::time::Duration::from_secs(1), notify.notified())
|
|
.await
|
|
.expect("graceful branch notified the registered quit handle");
|
|
}
|
|
}
|