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,343 @@
|
||||
//! Finalizing a turn from a terminal turn signal.
|
||||
//!
|
||||
//! The pager learns a turn reached its terminal outcome from two rails: the
|
||||
//! fire-and-forget `x.ai/session/prompt_complete` broadcast (the one-release
|
||||
//! compat path for not-yet-upgraded leaders) and the durable, persisted+replayed
|
||||
//! `XaiSessionUpdate::TurnCompleted`. Both converge on
|
||||
//! [`finalize_turn_from_terminal`] so the turn-finalize behavior lives in one
|
||||
//! place — and so a viewer that re-attaches mid-turn can finalize the turn from
|
||||
//! replay instead of staying stuck on "Waiting…".
|
||||
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
|
||||
use super::agent::AgentId;
|
||||
use super::agent_view::AgentView;
|
||||
use super::app_view::AppView;
|
||||
|
||||
/// Push a turn-terminal marker ("Turn completed/cancelled/failed"), folding
|
||||
/// any pending stop/stop_failure hook runs into it so they render inline
|
||||
/// (right-justified) on the marker line instead of as a standalone block.
|
||||
///
|
||||
/// All four marker rails route through here: the driver's `PromptResponse`,
|
||||
/// the lost-RPC reconcile, the viewer finalize, and wake turns'
|
||||
/// `push_wake_end_marker` (acp_handler). `event == None` (bash turns,
|
||||
/// rate-limit / re-auth UX that replaces the marker) flushes the held hooks
|
||||
/// as the legacy standalone lifecycle block so failures stay visible.
|
||||
///
|
||||
/// A stamped stash folds only on an exact ending-id match. On a mismatch the
|
||||
/// real-turn rails flush it standalone (the ending turn is THE turn — an
|
||||
/// older stash has no marker coming); the wake rail instead passes
|
||||
/// `preserve_mismatched_stash` so a REAL turn's leftover stash stays pending
|
||||
/// for its own marker rail rather than flushing on an unrelated wake. An
|
||||
/// unstamped stash keeps the legacy stashed-during-this-turn heuristic.
|
||||
///
|
||||
/// The marker carries a snapshot of the background work still running
|
||||
/// ("Worked for X. N commands still running") when any exists — a
|
||||
/// workless marker renders the legacy text unchanged. Announcing work opens
|
||||
/// the between-turns status window: completions landing before the next
|
||||
/// turn re-emit a fresh work-only status line after their chip.
|
||||
pub(super) fn push_turn_terminal_marker(
|
||||
agent: &mut AgentView,
|
||||
event: Option<SessionEvent>,
|
||||
ending_prompt_id: Option<&str>,
|
||||
preserve_mismatched_stash: bool,
|
||||
) {
|
||||
let pending = agent.pending_stop_hooks.take();
|
||||
let groups = match pending {
|
||||
None => Vec::new(),
|
||||
Some(pending) => {
|
||||
let stale = match (pending.prompt_id.as_deref(), ending_prompt_id) {
|
||||
(Some(stashed), Some(ending)) => stashed != ending,
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
};
|
||||
if stale {
|
||||
if preserve_mismatched_stash {
|
||||
// Wake rail: the stash belongs to a real turn whose own
|
||||
// marker rail will fold it — leave it pending.
|
||||
agent.pending_stop_hooks = Some(pending);
|
||||
} else {
|
||||
for (name, runs) in pending.groups {
|
||||
agent.scrollback.push_lifecycle_hooks(name, runs);
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
} else {
|
||||
pending.groups
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match event {
|
||||
Some(event) => {
|
||||
agent.push_end_marker_block(event, groups, ending_prompt_id.map(str::to_string));
|
||||
}
|
||||
None => {
|
||||
for (name, runs) in groups {
|
||||
agent.scrollback.push_lifecycle_hooks(name, runs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What applying a terminal turn signal did to one agent.
|
||||
pub(super) enum TerminalApply {
|
||||
/// No change: a driver turn the signal does not provably match, or a
|
||||
/// duplicate/stale terminal for an already-finished viewer turn.
|
||||
Ignored,
|
||||
/// Driver: the lost-RPC reconcile was armed. The turn is NOT finished — the
|
||||
/// `PromptResponse` RPC owns the driver's lifecycle. Reported as a state
|
||||
/// change so the reconcile sweep's animation tick stays scheduled.
|
||||
ReconcileArmed,
|
||||
/// Viewer: the turn was finished and (for non-rate-limit reasons) a terminal
|
||||
/// marker pushed. The caller drops any stale running-prompt adoption.
|
||||
ViewerFinalized,
|
||||
}
|
||||
|
||||
/// Arm lost-`PromptResponse` reconcile for the driver turn we own.
|
||||
///
|
||||
/// - **Exact** `prompt_id` match → arm (canonical).
|
||||
/// - **Missing** wire `promptId` (`None` or empty) → arm on `current_prompt_id`
|
||||
/// only when the turn is not mid-tool/thinking/compact/retry (legacy /
|
||||
/// broken `TurnCompleted` payloads).
|
||||
/// - **Non-empty mismatch** → ignore (stale/peer terminal must not kill a
|
||||
/// newer live turn after grace).
|
||||
///
|
||||
/// Never clobber an existing arm for a different pid; keep earliest
|
||||
/// `received_at` when re-arming the same pid.
|
||||
fn arm_driver_turn_end_reconcile(
|
||||
agent: &mut AgentView,
|
||||
session_id: &str,
|
||||
prompt_id: Option<&str>,
|
||||
stop_reason: Option<&str>,
|
||||
agent_result: Option<&str>,
|
||||
cancel_trigger: Option<&str>,
|
||||
) -> bool {
|
||||
if agent.session.loading_replay {
|
||||
return false;
|
||||
}
|
||||
if !(agent.session.state.is_turn_running() || agent.session.state.is_cancelling()) {
|
||||
return false;
|
||||
}
|
||||
let Some(current) = agent.session.current_prompt_id.clone() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let (arm_pid, arm_via) = match prompt_id {
|
||||
Some(pid) if pid == current.as_str() => (current, "exact"),
|
||||
Some("") => {
|
||||
if driver_mid_active_work(agent) {
|
||||
return false;
|
||||
}
|
||||
(current, "empty_wire_pid")
|
||||
}
|
||||
Some(_) => return false,
|
||||
None => {
|
||||
if driver_mid_active_work(agent) {
|
||||
return false;
|
||||
}
|
||||
(current, "missing_wire_pid")
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(pending) = agent.pending_turn_end_reconcile.as_ref() {
|
||||
if pending.prompt_id != arm_pid {
|
||||
return false;
|
||||
}
|
||||
// Same pid already armed — keep earliest received_at; refresh outcome.
|
||||
let received_at = pending.received_at;
|
||||
agent.pending_turn_end_reconcile = Some(super::agent_view::PendingTurnEnd {
|
||||
prompt_id: arm_pid.clone(),
|
||||
stop_reason: stop_reason.map(str::to_string),
|
||||
agent_result: agent_result.map(str::to_string),
|
||||
cancel_trigger: cancel_trigger.map(str::to_string),
|
||||
received_at,
|
||||
});
|
||||
crate::unified_log::info(
|
||||
"turn.end_reconcile.armed",
|
||||
Some(session_id),
|
||||
Some(serde_json::json!({
|
||||
"prompt_id": arm_pid,
|
||||
"wire_prompt_id": prompt_id,
|
||||
"arm_via": arm_via,
|
||||
"stop_reason": stop_reason,
|
||||
"refreshed": true,
|
||||
})),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
crate::unified_log::info(
|
||||
"turn.end_reconcile.armed",
|
||||
Some(session_id),
|
||||
Some(serde_json::json!({
|
||||
"prompt_id": arm_pid,
|
||||
"wire_prompt_id": prompt_id,
|
||||
"arm_via": arm_via,
|
||||
"stop_reason": stop_reason,
|
||||
})),
|
||||
);
|
||||
agent.pending_turn_end_reconcile = Some(super::agent_view::PendingTurnEnd {
|
||||
prompt_id: arm_pid,
|
||||
stop_reason: stop_reason.map(str::to_string),
|
||||
agent_result: agent_result.map(str::to_string),
|
||||
cancel_trigger: cancel_trigger.map(str::to_string),
|
||||
received_at: std::time::Instant::now(),
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn driver_mid_active_work(agent: &AgentView) -> bool {
|
||||
matches!(
|
||||
agent.session.tracker.activity(),
|
||||
Some(crate::acp::tracker::TurnActivity::ToolRunning { .. })
|
||||
| Some(crate::acp::tracker::TurnActivity::Thinking)
|
||||
| Some(crate::acp::tracker::TurnActivity::AutoCompacting)
|
||||
| Some(crate::acp::tracker::TurnActivity::Retrying { .. })
|
||||
)
|
||||
}
|
||||
|
||||
/// Finalize a turn from a terminal signal, shared by the `prompt_complete`
|
||||
/// broadcast and the durable `TurnCompleted` update so both behave identically.
|
||||
///
|
||||
/// DRIVER (`!attached_as_viewer`): the `PromptResponse` RPC owns the turn
|
||||
/// lifecycle (it carries context this signal lacks: error classes, rewind
|
||||
/// bookkeeping, adoption hand-off), so do NOT finish the turn here — that would
|
||||
/// race/double-finish on every normal turn end (the signal is emitted BEFORE the
|
||||
/// RPC response is written). But the RPC response can be LOST in transit (leader
|
||||
/// response routing / reconnect races), and
|
||||
/// it is the ONLY exit from `TurnRunning`/`TurnCancelling`. So when the signal
|
||||
/// refers to the turn this client is driving (exact pid, or missing/empty pid
|
||||
/// while not mid-tool), arm a deferred reconcile: if the RPC lands within the
|
||||
/// grace window it disarms this (see `TaskResult::PromptResponse`); otherwise
|
||||
/// the event loop finishes the turn from it (`reconcile_overdue_turn_ends`).
|
||||
///
|
||||
/// VIEWER (`attached_as_viewer`): a viewer adopts the driver's turn and never
|
||||
/// receives its `PromptResponse`, so this is its only non-interactive exit from
|
||||
/// `TurnRunning`. Finish the turn and push the "Turn completed/cancelled/failed"
|
||||
/// marker mapped from `stop_reason`. Idempotent: a duplicate/stale terminal for
|
||||
/// an already-finished turn pushes nothing and returns [`TerminalApply::Ignored`].
|
||||
///
|
||||
/// `cancel_trigger` is the signal's `_meta.cancelTrigger`, when stamped:
|
||||
/// `"send_now"` marks the cancel as the silent half of a cancel-and-send, so
|
||||
/// the `TurnCancelled` marker is suppressed (the sender's new prompt renders
|
||||
/// as the next turn). Absent meta means a normal cancel — except when this
|
||||
/// client just dispatched the send-now (`AgentView::expect_send_now_cancel`,
|
||||
/// the older-shell fallback, consumed here on the viewer finalize).
|
||||
pub(super) fn finalize_turn_from_terminal(
|
||||
agent: &mut AgentView,
|
||||
session_id: &str,
|
||||
prompt_id: Option<&str>,
|
||||
stop_reason: Option<&str>,
|
||||
agent_result: Option<&str>,
|
||||
cancel_trigger: Option<&str>,
|
||||
) -> TerminalApply {
|
||||
if !agent.attached_as_viewer {
|
||||
if arm_driver_turn_end_reconcile(
|
||||
agent,
|
||||
session_id,
|
||||
prompt_id,
|
||||
stop_reason,
|
||||
agent_result,
|
||||
cancel_trigger,
|
||||
) {
|
||||
return TerminalApply::ReconcileArmed;
|
||||
}
|
||||
return TerminalApply::Ignored;
|
||||
}
|
||||
|
||||
// Viewer: the driver's turn ended — exit TurnRunning. Only act when a turn
|
||||
// is actually in progress so a stray/duplicate signal is harmless (a
|
||||
// duplicate finds the turn already finished here and pushes no marker).
|
||||
if !agent.session.state.is_busy() && agent.session.current_prompt_id.is_none() {
|
||||
return TerminalApply::Ignored;
|
||||
}
|
||||
|
||||
// Capture elapsed BEFORE `mark_turn_finished()` clears `turn_started_at`. The
|
||||
// anchor was back-dated from the authoritative `turnStartMs` on adoption, so
|
||||
// this reads the same wall-clock duration the driver shows.
|
||||
let elapsed = agent.turn_elapsed().unwrap_or_default();
|
||||
// Read before `finish_turn()` clears it; keys the pending stop-hook stash.
|
||||
let ending_prompt_id = agent
|
||||
.session
|
||||
.current_prompt_id
|
||||
.clone()
|
||||
.or_else(|| prompt_id.map(str::to_string));
|
||||
|
||||
agent.session.finish_turn(&mut agent.scrollback);
|
||||
|
||||
// Wire meta wins; else the client-side expectation (older-shell fallback).
|
||||
// Taken at every viewer finalize so it can't go stale.
|
||||
let expected_send_now = agent.expect_send_now_cancel.take();
|
||||
let send_now_cancel = match cancel_trigger {
|
||||
Some(trigger) => trigger == "send_now",
|
||||
None => expected_send_now.is_some(),
|
||||
};
|
||||
|
||||
// A viewer never receives the driver's `PromptResponse` RPC — the source of
|
||||
// the driver's "Worked for X" marker. Surface the equivalent here.
|
||||
// The signal only carries a coarse `stop_reason` (no doom-loop category, no
|
||||
// driver-local rate-limit / re-auth context), so map it to the closest event:
|
||||
let event = match stop_reason {
|
||||
// Send-now cancel: no marker (the sender's new prompt renders as the
|
||||
// next turn; neither cancelled nor a substitute completed).
|
||||
Some("cancelled") if send_now_cancel => None,
|
||||
Some("cancelled") => Some(SessionEvent::TurnCancelled { elapsed }),
|
||||
// Rate limits drive a dedicated UX on the driver and are not actionable
|
||||
// from a viewer — don't surface a stray "Turn failed" line.
|
||||
Some("rate_limit") => None,
|
||||
Some("error") => Some(SessionEvent::TurnFailed {
|
||||
error: agent_result
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "unknown error".to_string()),
|
||||
elapsed: Some(elapsed),
|
||||
}),
|
||||
// end_turn / max_tokens / max_turn_requests / refusal / unknown → done.
|
||||
_ => Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(elapsed),
|
||||
}),
|
||||
};
|
||||
push_turn_terminal_marker(agent, event, ending_prompt_id.as_deref(), false);
|
||||
|
||||
agent.mark_turn_finished();
|
||||
|
||||
TerminalApply::ViewerFinalized
|
||||
}
|
||||
|
||||
/// Map a [`finalize_turn_from_terminal`] outcome to the redraw/tick bool that
|
||||
/// BOTH terminal rails (`prompt_complete` and the live `TurnCompleted`) RETURN
|
||||
/// DIRECTLY, applying the viewer-finalize side effect. Keeping this mapping in
|
||||
/// one place is load-bearing: the live `TurnCompleted` arm must return this
|
||||
/// instead of routing through `changed && is_active` (see below).
|
||||
///
|
||||
/// - `Ignored` -> `false`.
|
||||
/// - `ReconcileArmed` -> `true` UNCONDITIONALLY (not gated on visibility). The
|
||||
/// lost-RPC reconcile sweep rides the animation tick, and the event loop only
|
||||
/// re-arms the tick when a batch reports a change. A background-tab driver
|
||||
/// (`is_active == false`) that armed the reconcile must still report the change
|
||||
/// or `reconcile_overdue_turn_ends` never fires and the turn strands on
|
||||
/// "Waiting…" — the exact bug this rail fixes.
|
||||
/// - `ViewerFinalized` -> `true` only when `is_active` (drop pending adoption).
|
||||
pub(super) fn apply_terminal_outcome(
|
||||
outcome: TerminalApply,
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
is_active: bool,
|
||||
) -> bool {
|
||||
match outcome {
|
||||
TerminalApply::Ignored => false,
|
||||
TerminalApply::ReconcileArmed => true,
|
||||
TerminalApply::ViewerFinalized => {
|
||||
if let Some(p) = app.pending_running_adoptions.remove(&agent_id)
|
||||
&& let Some(agent) = app.agents.get_mut(&agent_id)
|
||||
{
|
||||
agent.discard_pending_adoption_updates(&p.prompt_id);
|
||||
}
|
||||
is_active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "turn_completion/tests.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user