diff --git a/README.md b/README.md index 3c2b2b8..0e1d7ab 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Why really?** The agent that wrote your code is the worst judge of it. -**What?** Every coding-agent CLI (Claude Code, Codex, Antigravity, Qwen, Vibe, Grok) reads your diff so you don't have to (you weren't going to). Read-only, in parallel; you stay in your agent, it makes the call. (Read-only is enforced by each agent's own CLI - except Antigravity, which has no such switch and is held to it by its plan mode instead. If the tree moves during a run, you get told.) +**What?** Every coding-agent CLI (Claude Code, Codex, Antigravity, Qwen, Vibe, Grok, Kimi) reads your diff so you don't have to (you weren't going to). Read-only, in parallel; you stay in your agent, it makes the call. (Read-only is enforced by each agent's own CLI, with two exceptions: Kimi has its write and shell tools removed, and Antigravity is held to it by its plan mode. If the tree moves during a run, you get told.) **How?** One tiny script that installs, updates, and runs all the agents for you, on Windows, macOS, and Linux. No setup, no server, no MCP. @@ -16,20 +16,20 @@ Paste this into Claude Code, Codex, or your coding agent of choice: > Create a "/postmortemthis" skill to review whatever we are working on. Download > https://github.com/Softeria/postmortemthis/releases/latest/download/postmortemthis.cmd -> once into the skill folder (re-fetch only if missing), then run the local copy — it runs +> once into the skill folder (re-fetch only if missing), then run the local copy. It runs > one prompt across several coding agents at once. Run it as `echo "…" | sh postmortemthis.cmd`. Then, before the real postmortem, run **/postmortemthis**. -That prompt is the whole install — your agent writes the skill from it. **Tweak the wording before you hit enter** (different agents, a sharper review focus, your own house rules); the [skill in this repo](skills/postmortemthis/SKILL.md) is just a starting point, and your agent will happily make one as good or better. The script is the only fixed part. +That prompt is the whole install: your agent writes the skill from it. **Tweak the wording before you hit enter** (different agents, a sharper review focus, your own house rules); the [skill in this repo](skills/postmortemthis/SKILL.md) is just a starting point, and your agent will happily make one as good or better. The script is the only fixed part. ## Auth Your own logins where you have them. For the rest, `sh postmortemthis.cmd login` (OpenRouter OAuth) or set `OPENROUTER_API_KEY`. Usage bills to your account; this resells nothing. -Even Grok works on OpenRouter: its own CLI can't reach OpenRouter, but its model (`x-ai/grok-build-0.1`) is there, so postmortemthis runs it through an OpenAI-compatible harness — no xAI subscription needed, just the key. +Even Grok works on OpenRouter: its own CLI can't reach OpenRouter, but its model (`x-ai/grok-build-0.1`) is there, so postmortemthis runs it through an OpenAI-compatible harness. No xAI subscription needed, just the key. -Run `sh postmortemthis.cmd setup` for a guided pass over every agent — it pokes each one, then lets you log in (many have a free or already-paid tier), force OpenRouter, or disable it, and can fire a test prompt at the end. Choices are saved and honored on every run. +Run `sh postmortemthis.cmd setup` for a guided pass over every agent: it pokes each one, then lets you log in (many have a free or already-paid tier), force OpenRouter, or disable it, and can fire a test prompt at the end. Choices are saved and honored on every run. ## CLI diff --git a/assets/overview.svg b/assets/overview.svg index c548767..57a4ad3 100644 --- a/assets/overview.svg +++ b/assets/overview.svg @@ -1,4 +1,4 @@ - + @@ -20,13 +20,14 @@ - + postmortemthis Every coding agent you have. One verdict. + @@ -72,8 +73,10 @@ + + - + postmortemthis the CLI Claude Code runs EVERY AGENT CLI / OWN TOOLS / OWN MODEL / READ-ONLY / IN PARALLEL @@ -140,6 +143,16 @@ Grok Build + + + + + Kimi + Moonshot + + Kimi K3 + + @@ -148,6 +161,7 @@ ship shit + diff --git a/assets/social-preview.png b/assets/social-preview.png index 6ee034f..5e39472 100644 Binary files a/assets/social-preview.png and b/assets/social-preview.png differ diff --git a/skills/postmortemthis/SKILL.md b/skills/postmortemthis/SKILL.md index 4aa7bc6..ef84221 100644 --- a/skills/postmortemthis/SKILL.md +++ b/skills/postmortemthis/SKILL.md @@ -1,6 +1,6 @@ --- name: postmortemthis -description: Fan one prompt across every coding agent on this machine (Claude Code, Codex, Antigravity, Qwen, Vibe, Grok) at once and synthesize their replies. A multi-agent second opinion on anything — a diff, an idea, a design, a question. Use when the user wants several agents to weigh in, asks for a "postmortem" or a second opinion, or runs /postmortemthis. +description: Fan one prompt across every coding agent on this machine (Claude Code, Codex, Antigravity, Qwen, Vibe, Grok, Kimi) at once and synthesize their replies. A multi-agent second opinion on anything: a diff, an idea, a design, a question. Use when the user wants several agents to weigh in, asks for a "postmortem" or a second opinion, or runs /postmortemthis. --- # postmortemthis @@ -12,24 +12,25 @@ skill builds the prompt, runs the fan-out, and synthesizes the answers. It is **read-only by default**: it reports what the agents say, it does not edit, stage, commit, or push. Applying fixes is a separate, explicit step. -Each agent is held read-only by its own CLI - a sandbox, a tool allowlist, a deny-on-approval -mode - with one exception: Antigravity has no such switch, so it runs in its plan mode, which -refuses to write but is a persona rather than a sandbox. The wrapper compares `git status` +Each agent is held read-only by its own CLI: a sandbox, a tool allowlist, a deny-on-approval +mode. Two have no such switch. Kimi runs with its write and shell tools removed, and gets the +diff in its prompt instead. Antigravity runs in its plan mode, which refuses to write but is a +persona rather than a sandbox. The wrapper compares `git status` before and after the run and says so in the run notes if the tree moved. ## 1. Decide what to ask The prompt is whatever the user wants several agents to weigh in on. There is **no git -requirement** — a postmortem can be about anything: +requirement**. A postmortem can be about anything: -- **Reviewing current work** — the sensible default when the user just runs +- **Reviewing current work**: the sensible default when the user just runs `/postmortemthis` inside a repo with changes. Scope it to the uncommitted + branch - changes and ask for bugs, risks, and edge cases. Don't paste the diff into the prompt — + changes and ask for bugs, risks, and edge cases. Don't paste the diff into the prompt; the agents read the working tree themselves. -- **An idea, plan, design, or question** — even in an empty folder. Just pass it through; +- **An idea, plan, design, or question**, even in an empty folder. Just pass it through; no repo, no diff needed. - **Whatever the user named** as an argument (e.g. `/postmortemthis is this API shape - sane?`) — use that as the prompt directly. + sane?`): use that as the prompt directly. If it's genuinely ambiguous what they want a second opinion on, ask one short question rather than assuming a diff. @@ -38,7 +39,7 @@ Keep the prompt to a focused single line about **what to look for**. ## 2. Get the wrapper (once) -Use the local copy bundled next to this SKILL.md. Only fetch it if missing — don't +Use the local copy bundled next to this SKILL.md. Only fetch it if missing; don't re-download and execute a remote script on every run: ```bash @@ -50,11 +51,11 @@ sh "$cmd" doctor # optional: show which agents are wired up ``` If `doctor` reports zero agents, point the user at `sh "$cmd" login` (OpenRouter OAuth) or -setting `OPENROUTER_API_KEY`. Don't run `login` yourself — it's interactive. +setting `OPENROUTER_API_KEY`. Don't run `login` yourself, it's interactive. ## 3. Run -Pipe the prompt on stdin. This drives several agents, so it can take a few minutes — give +Pipe the prompt on stdin. This drives several agents and can take a few minutes, so give it a generous timeout, and persist the output so a killed or scrolled-past run isn't lost: ```bash @@ -71,13 +72,13 @@ Announce the log path before the wait. Run the fan-out **once** per request; don ## 4. Synthesize Read from the log file, not the inline scrollback (which can truncate). Each agent answers -independently, so you'll get several overlapping opinions — don't just dump them all: +independently, so you'll get several overlapping opinions. Don't just dump them all: -- **Consensus** — what more than one agent flagged (highest signal). -- **Notable single-agent points** — worth a look even if only one raised them. -- **Disagreements / likely false positives** — where agents conflict or are clearly wrong. +- **Consensus**: what more than one agent flagged (highest signal). +- **Notable single-agent points**: worth a look even if only one raised them. +- **Disagreements / likely false positives**: where agents conflict or are clearly wrong. - Cite `file:line` where the agents did. -Lead your reply with the log path, keep the synthesis tight, and end with a clear takeaway -— for a review, a ship / no-ship call; for an idea, where the agents agreed and where they +Lead your reply with the log path, keep the synthesis tight, and end with a clear takeaway: +for a review, a ship / no-ship call; for an idea, where the agents agreed and where they pushed back. diff --git a/src/agents.rs b/src/agents.rs index da6b414..1fa8be8 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -1,9 +1,10 @@ use crate::gg; +use crate::kimi; use crate::openrouter; use crate::vibe; use std::borrow::Cow; use std::ffi::OsString; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; @@ -15,9 +16,10 @@ captured, so a plan or artifact file is not read back. Do not modify anything in workspace - this is a review, not a change."; /// A supported agent CLI. Each runs headless in the repo's cwd, using its own -/// native harness and whatever auth the user already has. All read-only, but only -/// five by their own CLI - Antigravity has no such switch and rides on its plan -/// mode instead (see `args`), so main.rs watches the tree for it. +/// native harness and whatever auth the user already has. All read-only, most by +/// their own CLI's switch. Kimi has none, so its write tools are removed (see +/// `kimi.rs`); Antigravity has none either and rides on its plan mode instead +/// (see `args`), so main.rs watches the tree for it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Agent { Claude, @@ -26,15 +28,17 @@ pub enum Agent { Qwen, Vibe, Grok, + Kimi, } -pub const ALL: [Agent; 6] = [ +pub const ALL: [Agent; 7] = [ Agent::Claude, Agent::Codex, Agent::Antigravity, Agent::Qwen, Agent::Vibe, Agent::Grok, + Agent::Kimi, ]; /// Codex `-c` overrides that define OpenRouter as a custom model provider - @@ -71,6 +75,7 @@ impl Agent { Agent::Qwen => "qwen", Agent::Vibe => "vibe", Agent::Grok => "grok", + Agent::Kimi => "kimi", } } @@ -89,7 +94,7 @@ impl Agent { /// `antigravity-cli` and not `antigravity` on purpose: gg caches it under the /// repo name whatever alias you run, and until gg 200 `gg update` looked it up /// by cache name, so `update antigravity` silently found nothing. Fixed since, - /// but the cache name is the one that works on every gg. + /// but the cache name is the one that works on every gg. Same for kimi. pub fn gg_tool(&self) -> &'static str { match self { Agent::Claude => "claude", @@ -98,6 +103,7 @@ impl Agent { Agent::Qwen => "qwen", Agent::Vibe => "vibe", Agent::Grok => "grok", + Agent::Kimi => "kimi-cli", } } @@ -117,6 +123,7 @@ impl Agent { Agent::Antigravity => None, // Grok's OpenRouter leg drives its own model through codex's harness. Agent::Grok => Some("x-ai/grok-build-0.1"), + Agent::Kimi => Some("moonshotai/kimi-k3"), } } @@ -129,6 +136,7 @@ impl Agent { Agent::Codex => "run `codex login` to refresh its login", Agent::Antigravity => "run `antigravity` once to refresh its Google login", Agent::Grok => "run `grok login`, or set XAI_API_KEY (console.x.ai)", + Agent::Kimi => "run `kimi login` to refresh its login", Agent::Qwen | Agent::Vibe => "no native login; runs on OpenRouter", } } @@ -141,6 +149,7 @@ impl Agent { "qwen" | "qwen-code" => Some(Agent::Qwen), "vibe" | "mistral-vibe" => Some(Agent::Vibe), "grok" | "grok-build" => Some(Agent::Grok), + "kimi" | "kimi-cli" | "kimi-code" => Some(Agent::Kimi), _ => None, } } @@ -239,6 +248,9 @@ impl Agent { // `-p` (alias --single) takes the prompt as its value, not on stdin, // so it goes last and the runner appends the prompt (see reads_stdin). Agent::Grok => vec!["--permission-mode", "dontAsk", "-p"], + // `--quiet` spelled out: print mode, plain text, final answer only. + // Read-only comes from the agent file `command` adds, not from here. + Agent::Kimi => vec!["--print", "--output-format", "text", "--final-message-only"], } } @@ -246,10 +258,19 @@ impl Agent { /// (see `args`) is told by its own system prompt to put the findings in an /// artifact file and not repeat them in the reply, so the review landed in /// its brain dir and stdout got "I have created a plan, please review it". - /// The coda pulls it back into the reply. Everyone else gets it untouched. - pub fn decorate_prompt<'a>(&self, prompt: &'a str) -> Cow<'a, str> { + /// The coda pulls it back into the reply. Kimi has no shell (see `kimi.rs`), + /// so it gets the diff it cannot fetch. Everyone else gets it untouched. + pub fn decorate_prompt<'a>(&self, prompt: &'a str, repo: &Path) -> Cow<'a, str> { match self { Agent::Antigravity => Cow::Owned(format!("{prompt}{ANTIGRAVITY_CODA}")), + Agent::Kimi => Cow::Owned(match kimi::diff_for_prompt(repo) { + Some(diff) => format!( + "{prompt}\n\n---\nYou have no shell in this run, so you cannot run git. \ + In case the request is about the current changes, here they are. Files \ + marked ?? are new and not in the diff; read them yourself.\n\n{diff}" + ), + None => format!("{prompt}\n\n---\nYou have no shell in this run."), + }), _ => Cow::Borrowed(prompt), } } @@ -266,6 +287,39 @@ impl Agent { } } + /// Where this agent should write its own debug log, so a dead leg can say + /// why. Only Antigravity needs one: on a 429 it retries with exponential + /// backoff and prints NOTHING on stdout or stderr while it does, and our + /// `--print-timeout 24h` (see `args`) means it never reaches the one point + /// where it would speak up. A quota error is then indistinguishable from a + /// hang - the leg just burns the whole --timeout and reports `_timed out_`. + /// The cause only ever exists in `--log-file`, so we ask for one and read + /// it back (see `take_diagnosis`). + /// + /// One path per process per agent, not per attempt: attempts within a leg + /// are sequential, and antigravity is native-only so it has just the one. + fn diag_log(&self) -> Option { + match self { + Agent::Antigravity => Some(std::env::temp_dir().join(format!( + "postmortemthis-{}-{}.log", + self.name(), + std::process::id() + ))), + _ => None, + } + } + + /// Read this agent's debug log back, delete it, and boil it down to the + /// reason the leg died - or None when the log says nothing useful. Always + /// call it after the process ends, even on success, so the file is cleaned + /// up; only show the result on a failure. + pub fn take_diagnosis(&self) -> Option { + let path = self.diag_log()?; + let log = std::fs::read_to_string(&path).ok(); + let _ = std::fs::remove_file(&path); + cause_from_log(&log?) + } + /// The base command that reaches this agent's CLI - through gg when a gg is /// available, else the native binary on PATH. Callers layer their own args /// on top (`command` for a review, `login_command` for a login). @@ -292,7 +346,24 @@ impl Agent { } else { let mut cmd = runner.base_command(); cmd.args(self.workspace_args(repo)); + // Before args(): antigravity's `-p` has to stay the last flag, as + // the runner appends the prompt right after it. + if let Some(log) = self.diag_log() { + // A stale log from a killed earlier run would be read back as + // this attempt's cause. + let _ = std::fs::remove_file(&log); + cmd.arg("--log-file").arg(&log); + } cmd.args(self.args()); + if runner == Agent::Kimi { + let home = + kimi::home().expect("attempt_plan rules out kimi without its scratch dir"); + cmd.arg("--agent-file").arg(home.join("agent.yaml")); + cmd.arg("--mcp-config-file").arg(home.join("mcp.json")); + if openrouter { + cmd.arg("--config-file").arg(home.join("config.toml")); + } + } cmd.current_dir(repo); cmd }; @@ -317,6 +388,10 @@ impl Agent { /// empty plan means there is nothing to try - no login and no key. pub fn attempt_plan(&self, skip_native: bool) -> Vec { let mut plan = Vec::new(); + // Kimi without its agent file would run with every write tool approved. + if *self == Agent::Kimi && kimi::home().is_none() { + return plan; + } if self.authed() && !skip_native { plan.push(false); } @@ -361,6 +436,7 @@ impl Agent { Agent::Qwen => "Alibaba", Agent::Vibe => "Mistral", Agent::Grok => "xAI", + Agent::Kimi => "Moonshot", } } @@ -405,7 +481,7 @@ impl Agent { fn login_invocation(&self) -> Option<&'static [&'static str]> { match self { Agent::Claude | Agent::Antigravity => Some(&[]), - Agent::Codex | Agent::Grok => Some(&["login"]), + Agent::Codex | Agent::Grok | Agent::Kimi => Some(&["login"]), Agent::Qwen | Agent::Vibe => None, } } @@ -441,6 +517,13 @@ impl Agent { ], // Grok's OpenRouter leg runs through codex, which reads the same key. Agent::Codex | Agent::Grok => vec![("OPENROUTER_API_KEY", key.to_string())], + // The model lives in the scratch config.toml; the key goes in by env + // so it is never written to disk. The base URL is in both: kimi + // lets a stray OPENAI_BASE_URL in the user's env beat the config. + Agent::Kimi => vec![ + ("OPENAI_API_KEY", key.to_string()), + ("OPENAI_BASE_URL", "https://openrouter.ai/api/v1".into()), + ], // Antigravity has no OpenRouter route, so it is never run with // `openrouter` set - no env to inject. Agent::Antigravity => vec![], @@ -481,6 +564,7 @@ impl Agent { OnceLock::new(), OnceLock::new(), OnceLock::new(), + OnceLock::new(), ]; CACHE[*self as usize] .get_or_init(|| { @@ -557,6 +641,7 @@ impl Agent { // on login (NOT on install, unlike the ~/.grok dir), so it is a safe // signal. Either counts as usable headless auth. Agent::Grok => exists(".grok/auth.json") || env_set("XAI_API_KEY"), + Agent::Kimi => kimi::native_configured(&kimi::share_dir(Path::new(&home))), // Qwen and Vibe have no widely-held native login wired up; they // run through OpenRouter when a key is present (see attempt_plan). Agent::Qwen | Agent::Vibe => false, @@ -570,12 +655,15 @@ impl Agent { Agent::Codex => "logged in".into(), Agent::Antigravity => "logged in (Google account)".into(), Agent::Grok => "signed in (grok login or XAI_API_KEY)".into(), + Agent::Kimi => "logged in".into(), Agent::Qwen | Agent::Vibe => "logged in".into(), } } else if matches!(self, Agent::Qwen | Agent::Vibe) { "runs via OpenRouter (needs a key; no native login wired up)".into() } else if matches!(self, Agent::Grok) { "not signed in - run `grok login`, or set XAI_API_KEY (console.x.ai)".into() + } else if matches!(self, Agent::Kimi) { + "not logged in - run `kimi login`".into() } else { format!( "no credentials found - run `{}` once to log in", @@ -585,6 +673,68 @@ impl Agent { } } +/// Boil an agent's own debug log (see `Agent::diag_log`) down to why the leg +/// died, or None when it says nothing useful. Antigravity's is glog: tens of +/// thousands of lines, of which the ones that matter are the retry loop it +/// never prints - "Run: attempt 2 failed (RESOURCE_EXHAUSTED (code 429): +/// Individual quota reached...), retrying in 7s". +/// +/// Only the retry loop, and deliberately so. Every wider rule tried here fired +/// on something cosmetic: "every line mentioning error or failed" picks up the +/// screenful of "You are not logged into Antigravity" a HEALTHY run logs while +/// auth warms up, and "every line carrying a gRPC status" picks up background +/// chatter like a 503 on conversation-title generation - which arrives while +/// the real turn is running fine, and would blame a plain too-short --timeout +/// on the server. A cause we cannot pin to the turn itself is worse than no +/// cause at all, because `run_notes` stops advising a longer timeout once it +/// has one. Matching nothing is the safe failure: the caller just gets the +/// unchanged "raise --timeout" note. +fn cause_from_log(log: &str) -> Option { + const MAX_LINES: usize = 5; + let mut seen = Vec::new(); + let mut out: Vec<&str> = Vec::new(); + for line in log.lines() { + let lower = line.to_ascii_lowercase(); + if !(lower.contains("attempt") && lower.contains("failed")) { + continue; + } + let msg = strip_glog_prefix(line).trim(); + if msg.is_empty() { + continue; + } + // One line per retry, identical but for the attempt number and the + // countdown, so dedupe on the shape and keep the first - the original + // cause, before the backoff buried it. The dot goes with the digits: + // the backoff prints "4s" then "7.697951167s", and on digits alone + // those two are still different shapes. + let shape: String = msg + .chars() + .filter(|c| !c.is_ascii_digit() && *c != '.') + .collect(); + if seen.contains(&shape) { + continue; + } + seen.push(shape); + out.push(msg); + if out.len() == MAX_LINES { + break; + } + } + (!out.is_empty()).then(|| out.join("\n")) +} + +/// Drop glog's `I0913 16:32:19.310138 369 run.go:389] ` header and return the +/// message. A line that is not glog comes back untouched. +fn strip_glog_prefix(line: &str) -> &str { + let mut chars = line.chars(); + let glog = matches!(chars.next(), Some('I' | 'W' | 'E' | 'F')) + && chars.next().is_some_and(|c| c.is_ascii_digit()); + match glog.then(|| line.split_once("] ")).flatten() { + Some((_, msg)) => msg, + None => line, + } +} + /// Codex's read-only `exec` harness. `or_model = Some(model)` points it at /// OpenRouter on that model (the `-c` provider overrides go right after `exec`, /// which codex's built-in openai provider needs for the Responses wire API); diff --git a/src/kimi.rs b/src/kimi.rs new file mode 100644 index 0000000..2734630 --- /dev/null +++ b/src/kimi.rs @@ -0,0 +1,175 @@ +//! Scratch files that keep Moonshot's `kimi` CLI read-only, and point it at +//! OpenRouter when it has no login of its own. +//! +//! Kimi has no read-only switch. `--print` auto-approves every tool call, plan +//! mode included (print mode approves the switch out of it), and hooks are no +//! help: SessionStart, PreToolUse and Stop hooks in the real config location +//! all stayed silent in print mode when tried. What does hold is an agent file +//! that names the tools it may have, and only those: read, search, think, and +//! a todo list. An allowlist rather than excluding the writers, so a write +//! tool added or renamed upstream (the leg auto-updates) stays out. Not Shell, +//! not WriteFile/StrReplaceFile, not Agent (its `coder` subagent writes), and +//! not the web tools either - a prompt injection in a diff should have nowhere +//! to send anything. MCP servers are tools too, and the user's `~/.kimi/mcp.json` +//! loads unless `--mcp-config-file` replaces it, so that gets an empty one. +//! +//! No Shell also means no `git diff`, so `agents::decorate_prompt` hands Kimi +//! the working-tree diff in its prompt. + +use anyhow::{Context, Result}; +use std::fs::DirBuilder; +#[cfg(unix)] +use std::os::unix::fs::DirBuilderExt; +use std::path::PathBuf; +use std::sync::OnceLock; + +/// A live scratch dir. Dropping it removes the directory. +pub struct Home { + dir: PathBuf, +} + +static HOME: OnceLock = OnceLock::new(); + +/// The scratch dir, once it has been created. Without it the kimi leg has no +/// agent file and must not run (see `Agent::attempt_plan`). +pub fn home() -> Option { + HOME.get().cloned() +} + +impl Home { + /// Write the agent file, the empty MCP config, and an OpenRouter config on + /// `model` (key from OPENAI_API_KEY, so it never touches disk). + pub fn create(model: &str) -> Result { + let dir = private_dir().context("creating kimi scratch dir")?; + for (name, body) in [ + ("agent.yaml", AGENT_YAML.to_string()), + ("mcp.json", r#"{"mcpServers":{}}"#.to_string()), + ("config.toml", openrouter_config(model)), + ] { + std::fs::write(dir.join(name), body).with_context(|| format!("writing kimi {name}"))?; + } + let _ = HOME.set(dir.clone()); + Ok(Home { dir }) + } +} + +/// A fresh directory under the temp dir that nobody else could have planted: +/// random name, must not already exist, and owner-only on unix. The agent file +/// in it is what keeps kimi read-only, so a predictable path in a shared /tmp +/// would let another local user pre-create it and swap the file. +fn private_dir() -> Result { + let mut builder = DirBuilder::new(); + #[cfg(unix)] + builder.mode(0o700); + let mut last = None; + for _ in 0..8 { + let mut rand = [0u8; 8]; + getrandom::getrandom(&mut rand).map_err(|e| anyhow::anyhow!("random dir name: {e}"))?; + let name: String = rand.iter().map(|b| format!("{b:02x}")).collect(); + let dir = std::env::temp_dir().join(format!("postmortemthis-kimi-{name}")); + match builder.create(&dir) { + Ok(()) => return Ok(dir), + Err(e) => last = Some(e), + } + } + Err(last.expect("eight attempts").into()) +} + +impl Drop for Home { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// `tools` given while extending replaces the default list, it does not add +/// to it (kimi logs "Loaded tools: [...]" with exactly these). +const AGENT_YAML: &str = r#"version: 1 +agent: + extend: default + tools: + - "kimi_cli.tools.file:ReadFile" + - "kimi_cli.tools.file:ReadMediaFile" + - "kimi_cli.tools.file:Glob" + - "kimi_cli.tools.file:Grep" + - "kimi_cli.tools.think:Think" + - "kimi_cli.tools.todo:SetTodoList" +"#; + +/// Replaces the user's `~/.kimi/config.toml` on the OpenRouter leg only; the +/// native leg keeps theirs, since that is where `kimi login` puts the model. +fn openrouter_config(model: &str) -> String { + format!( + r#"default_model = "openrouter" +telemetry = false + +[providers.openrouter] +type = "openai_legacy" +base_url = "https://openrouter.ai/api/v1" +api_key = "" + +[models.openrouter] +provider = "openrouter" +model = "{model}" +max_context_size = 262144 +"# + ) +} + +/// Where kimi keeps its config: `~/.kimi`, unless KIMI_SHARE_DIR moves it. +pub fn share_dir(home: &std::path::Path) -> PathBuf { + match std::env::var_os("KIMI_SHARE_DIR") { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => home.join(".kimi"), + } +} + +/// Does the user's own Kimi config name a model? A credentials file alone is +/// not enough: without `default_model` kimi exits with "LLM not set". +pub fn native_configured(share: &std::path::Path) -> bool { + let Ok(config) = std::fs::read_to_string(share.join("config.toml")) else { + return false; + }; + config.lines().any(|line| { + line.split_once('=').is_some_and(|(k, v)| { + k.trim() == "default_model" && !v.trim().trim_matches('"').trim().is_empty() + }) + }) +} + +/// The working-tree diff, for a leg with no shell to fetch it. None outside a +/// git repo. Capped: a huge diff would crowd out the request itself. +pub fn diff_for_prompt(repo: &std::path::Path) -> Option { + const MAX: usize = 200_000; + let git = |args: &[&str]| { + let out = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).into_owned()) + }; + let status = git(&["status", "--short"])?; + // No external diff driver and no textconv: either is a user-configured + // program, run here outside the agent's controls and before its timeout. + // HEAD does not exist in a repo with no commits yet; git's empty tree does + // in every repo, and diffing against it shows the whole index. + const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + let diff = git(&["diff", "HEAD", "--no-color", "--no-ext-diff", "--no-textconv"]) + .or_else(|| git(&["diff", EMPTY_TREE, "--no-color", "--no-ext-diff", "--no-textconv"])) + .unwrap_or_default(); + let mut diff = diff; + if diff.len() > MAX { + let mut cut = MAX; + while !diff.is_char_boundary(cut) { + cut -= 1; + } + diff.truncate(cut); + diff.push_str("\n[diff truncated]\n"); + } + Some(format!( + "git status --short:\n{status}\ngit diff HEAD:\n{diff}" + )) +} diff --git a/src/main.rs b/src/main.rs index ab11cac..fd119a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod agents; mod gg; +mod kimi; mod login; mod openrouter; mod runner; @@ -158,13 +159,15 @@ fn run(args: RunArgs) -> Result<()> { ); // Antigravity's leg is held read-only by a persona, not a sandbox (see - // agents::Agent::args), so "it did not write" is an assumption about a CLI - // that moves fast. Better to notice than to trust a comment. + // agents::Agent::args), and kimi's by a config file that strips its write + // tools (see kimi.rs), so "it did not write" is an assumption about CLIs + // that move fast. Better to notice than to trust a comment. let tree_before = tree_state(&cwd); let reports = execute(&selected, &skip_native, &prompt, &cwd, timeout)?; - let tree_touched = selected.contains(&Agent::Antigravity) + let watched = [Agent::Antigravity, Agent::Kimi]; + let tree_touched = selected.iter().any(|a| watched.contains(a)) && tree_before.is_some() && tree_state(&cwd) != tree_before; @@ -210,21 +213,35 @@ fn report_section(r: &Report) -> String { } else { "native login".to_string() }; + // A timeout gets the detail too, not just a failure: an agent can burn the + // whole timeout on something it never printed (see Agent::diag_log), and + // "_timed out_" alone sends the caller off to raise --timeout for nothing. + let detail = failure_detail(r); + let with_detail = |head: String| match detail.is_empty() { + true => head, + false => format!("{head}\n\n```\n{detail}\n```"), + }; let body = match &r.outcome { Outcome::Ok => r.output.trim().to_string(), - Outcome::TimedOut => "_timed out_".to_string(), - Outcome::Failed(why) => { - let detail = condense_failure(&r.stderr); - if detail.is_empty() { - format!("_failed: {why}_") - } else { - format!("_failed: {why}_\n\n```\n{detail}\n```") - } - } + Outcome::TimedOut => with_detail("_timed out_".to_string()), + Outcome::Failed(why) => with_detail(format!("_failed: {why}_")), }; format!("# {} ({provenance})\n\n{body}\n", r.agent.name()) } +/// What a dead leg had to say: both pipes, then its own log's diagnosis. stdout +/// is not only for the answer: kimi prints its API errors there ("Error code: +/// 402 ...") and keeps stderr for "To resume this session", which would be all +/// the report showed. +fn failure_detail(r: &Report) -> String { + let detail = condense_failure(&format!("{}\n{}", r.output, r.stderr)); + match &r.diagnosis { + Some(cause) if detail.is_empty() => cause.clone(), + Some(cause) => format!("{detail}\n{cause}"), + None => detail, + } +} + /// Trim a failed agent's stderr down to something readable. Agents often log the /// same error several times (grok prints a 403 five times over), colour it with /// ANSI escapes, and pad it with blank lines; strip the colour, drop the blanks, @@ -293,7 +310,7 @@ fn run_notes( if tree_touched { notes.push( - "- the working tree changed while the agents ran. Every leg but antigravity is held read-only by its own CLI; antigravity's plan mode is a persona, not a sandbox, so it is the one to suspect. Run `git status` and `git diff` before trusting the review above.".to_string(), + "- the working tree changed while the agents ran. Most legs are held read-only by their own CLI's switch; antigravity's plan mode is a persona, not a sandbox, and kimi's write tools are stripped by a config file, so those two are the ones to suspect. Run `git status` and `git diff` before trusting the review above.".to_string(), ); } @@ -307,9 +324,16 @@ fn run_notes( )); } if r.outcome == Outcome::TimedOut { - notes.push(format!( - "- {name}: timed out. Raise --timeout or shorten the prompt." - )); + // A timeout that came with a reason is not a slow model, and more + // --timeout just buys more of the same error: antigravity backing + // off a 429 will sit there all day. Point at the reason instead. + notes.push(if r.diagnosis.is_none() { + format!("- {name}: timed out. Raise --timeout or shorten the prompt.") + } else { + format!( + "- {name}: timed out, but it logged why - see its section above. Raising --timeout will not help if that is a quota or auth error." + ) + }); } // Exit status is a poor test of "answered": an agent can exit 0 having // printed "I have created a plan, please review it", or nothing at all @@ -472,6 +496,7 @@ fn execute( timeout: Duration, ) -> Result> { let _vibe = start_vibe_home(selected); + let _kimi = start_kimi_home(selected); prewarm(selected, cwd); runner::run_all(selected, prompt, cwd, timeout, skip_native) } @@ -613,3 +638,18 @@ fn start_vibe_home(selected: &[Agent]) -> Option { } } +/// Write kimi's scratch dir (agent file, empty MCP config, OpenRouter config) +/// when kimi is selected. Unlike vibe's it is needed on the native leg too: +/// the agent file is what keeps kimi read-only. +fn start_kimi_home(selected: &[Agent]) -> Option { + if !selected.contains(&Agent::Kimi) { + return None; + } + match kimi::Home::create(Agent::Kimi.openrouter_model().expect("kimi has an OpenRouter model")) { + Ok(home) => Some(home), + Err(e) => { + eprintln!("postmortemthis: could not prepare kimi's read-only agent file ({e}); skipping kimi"); + None + } + } +} diff --git a/src/runner.rs b/src/runner.rs index 07fd33f..89d19b1 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -19,6 +19,11 @@ pub struct Report { /// A native attempt was made and failed, then OpenRouter was used. Drives /// the "your native login failed" run note. pub fell_back: bool, + /// Why the leg died, from the agent's own log (see `Agent::diag_log`), when + /// it died and the log said. Its own field, not folded into stderr: the run + /// notes key off it, and some agents never leave stderr empty (kimi always + /// prints "To resume this session"). + pub diagnosis: Option, } #[derive(Debug, PartialEq, Eq)] @@ -90,7 +95,9 @@ fn run_one(agent: Agent, prompt: &str, repo: &Path, timeout: Duration, skip_nati if plan.is_empty() { // A native-only agent (antigravity) has no OpenRouter fallback, so // the failure is purely "not logged in" - don't mention a key it can't use. - let why = if agent.supports_openrouter() { + let why = if agent == Agent::Kimi && crate::kimi::home().is_none() { + "could not write its read-only agent file (see the warning above)" + } else if agent.supports_openrouter() { "no usable login and no OpenRouter key" } else { "no usable login (native-only; cannot use OpenRouter)" @@ -102,6 +109,7 @@ fn run_one(agent: Agent, prompt: &str, repo: &Path, timeout: Duration, skip_nati elapsed: started.elapsed(), outcome: Outcome::Failed(why.into()), used_openrouter: false, + diagnosis: None, fell_back: false, }; } @@ -151,7 +159,7 @@ fn run_attempt( ) -> Report { let started = Instant::now(); let mut cmd = agent.command(repo, openrouter); - let prompt = agent.decorate_prompt(prompt); + let prompt = agent.decorate_prompt(prompt, repo); let prompt = prompt.as_ref(); // Most read the prompt on stdin; vibe (always) and grok's native leg take it // as a trailing argument and get no stdin. @@ -176,6 +184,7 @@ fn run_attempt( elapsed: started.elapsed(), outcome: Outcome::Failed(format!("failed to spawn: {e}")), used_openrouter: openrouter, + diagnosis: None, fell_back: false, }; } @@ -206,6 +215,7 @@ fn run_attempt( elapsed: started.elapsed(), outcome: Outcome::Failed(format!("wait failed: {e}")), used_openrouter: openrouter, + diagnosis: None, fell_back: false, }; } @@ -221,6 +231,10 @@ fn run_attempt( Some(s) if s.success() => Outcome::Ok, Some(s) => Outcome::Failed(s.to_string()), }; + // Unconditionally, even on success: this also deletes the log file. An + // agent that keeps its reasons to itself (antigravity on a 429 says nothing + // on either pipe - see Agent::diag_log) only explains itself here. + let diagnosis = agent.take_diagnosis().filter(|_| outcome != Outcome::Ok); Report { agent, @@ -229,6 +243,7 @@ fn run_attempt( elapsed: started.elapsed(), outcome, used_openrouter: openrouter, + diagnosis, fell_back: false, } }