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:
@@ -0,0 +1,250 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user