From 7585219d709c03f3baa6b7f25701ceaae15eae00 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 18:14:19 -0400 Subject: [PATCH 01/43] [Spike task-Iptx] Findings: Kimi Code CLI feasible with caveats as builder and architect Seed-session bootstrap (kimi -p role seed -> capture session id from stream-json -> TUI resume via -S) validated end-to-end; solves role injection, initial prompt delivery, and the stored-ID session contract. Includes reproducible POC script and full impact map / test matrix. Co-Authored-By: Claude Fable 5 --- .../spikes/task-Iptx-kimi-code-cli-support.md | 186 ++++++++++++++++++ codev/spikes/task-Iptx-kimi-poc.sh | 81 ++++++++ codev/state/task-Iptx_thread.md | 23 +++ 3 files changed, 290 insertions(+) create mode 100644 codev/spikes/task-Iptx-kimi-code-cli-support.md create mode 100755 codev/spikes/task-Iptx-kimi-poc.sh create mode 100644 codev/state/task-Iptx_thread.md diff --git a/codev/spikes/task-Iptx-kimi-code-cli-support.md b/codev/spikes/task-Iptx-kimi-code-cli-support.md new file mode 100644 index 000000000..88fc8c2ca --- /dev/null +++ b/codev/spikes/task-Iptx-kimi-code-cli-support.md @@ -0,0 +1,186 @@ +# Spike: Kimi Code CLI support as architect and builder + +**Date**: 2026-07-18 + +**Verdict**: +- **Builder**: **Feasible with Caveats** +- **Architect**: **Feasible with Caveats** + +Both verdicts rest on one validated pattern — the **seed-session bootstrap** (POC 6 below) — which simultaneously solves the three hard problems: role injection, initial-prompt delivery, and the stored-session-ID architect contract. + +## Question + +> What does it take to support kimi code cli as an architect and builder? + +Prompted by the architect handoff for spike task-Iptx. The decision that depends on the answer: whether to green-light a production integration project (and under which protocol), or document Kimi as unsupported. + +**Sources discipline**: all *documented* Kimi claims below come exclusively from the designated command reference, https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-command.html. Everything marked **(observed)** is an empirical result against the locally installed `kimi` 0.27.0 (`~/.kimi-code/bin/kimi`) and is not a documented guarantee. + +## Research Summary + +- **Kimi command reference** (exclusive source): `kimi [options]` starts an interactive TUI in the cwd. Relevant flags: `--session/-S [id]` (resume by id; `-r/--resume` alias), `--continue/-c` (resume most recent session *for the cwd*), `--prompt/-p` (single non-interactive prompt; conflicts with `--yolo`/`--auto`/`--plan`; auto permission policy; static deny rules still apply), `--output-format stream-json` (requires `-p`), `--yolo` (auto-approve tools; conflicts with `--auto`), `--auto` (agent does not ask user questions), `--plan`, `--skills-dir ` (**replaces** auto-discovered user+project skill dirs; repeatable), `--add-dir`. Subcommands: `login` (device-code OAuth; not a status probe), `doctor` (validates `config.toml`/`tui.toml` under `KIMI_CODE_HOME` or `~/.kimi-code`; exit 0 valid/skipped, 1 missing/invalid; **not** an auth check), `acp` (JSON-RPC over stdio), `server` (REST + WebSocket, loopback), `export [sessionId]` (defaults to most recent session in cwd). No documented system-prompt/instructions flag and no documented positional prompt. +- **PR #1059** (codex architect, PIR #929) reviewed against current HEAD: its durable lessons hold (provider abstraction, override-aware detection, centralized `buildArchitectArgs`, capability-gated resume, doctor/tests/docs), but the architect session architecture has since moved to the **stored-ID `HarnessProvider.session` contract** (#832) with ownership verification (#1145), crash-loop fallback (#1149), and sibling liveness pruning (#1150). The mtime-discovery architect path is gone; do not reintroduce it. +- **Current seams read at HEAD** (`165339ab` lineage): `utils/harness.ts` (provider interface: `buildRoleInjection`, `buildScriptRoleInjection`, `getWorktreeFiles?`, `session?` {`newSessionArgs`, `resumeArgs`, `verifyOwnership?`}, `buildResume?`; `detectHarnessFromCommand`; `resolveHarness` falls through to **CLAUDE_HARNESS** for unrecognized commands — the #1062 caveat), `utils/config.ts` (`getArchitectHarness`/`getBuilderHarness`, override-aware), `commands/spawn.ts` (`discoverResumeSession`), `commands/spawn-worktree.ts` (`startBuilderSession` emits `${baseCmd} ${fragment} "$(cat promptFile)"` — positional prompt; resume path emits `scriptFragment`), `commands/architect.ts` (no-Tower path via shared `buildArchitectArgs`), `servers/tower-utils.ts` (`buildArchitectArgs`, `resolveArchitectLaunch` — **synchronous**, `resolveArchitectRestart`, `buildArchitectCrashLoopFallback`, `siblingRegistrationIsLive`), `servers/tower-instances.ts` (launch + add-architect sites), `servers/tower-terminals.ts` (two shellper restart-bake sites), `servers/message-write.ts` (paced writes: 10ms inter-line, 50/80ms delayed Enter), `commands/doctor.ts` (per-CLI presence/auth checks + architect-shell branch), `codev/resources/arch.md` §"Supported Architect Harnesses & Conversation Resume (#929)". + +### What breaks today if you just point Codev at `kimi` + +1. `detectHarnessFromCommand('kimi')` → undefined → `resolveHarness` falls through to the **Claude harness** (#1062). Architect launch appends `--append-system-prompt `; **(observed)** `kimi --append-system-prompt x` → `error: unknown option`, exit 1 → shellper restart loop. +2. Builder fresh script appends the prompt positionally; **(observed)** `kimi ""` → `unknown command '…'`, exit 1 → same loop. +3. Because the false Claude harness exposes Claude's `session`/`buildResume`, a stale Claude `.jsonl` could route `--resume ` into `kimi` (the pre-#929 crash-loop class). + +A no-op custom harness is not enough: role injection would be silently dropped AND the positional initial prompt still kills the builder launch. + +## Empirical Observations (kimi 0.27.0) + +All labeled **(observed)**; reproducible via `task-Iptx-kimi-poc.sh` alongside this file. + +| # | Probe | Result | +|---|---|---| +| 1 | `kimi ""` (positional prompt) | `unknown command ''`, **exit 1** | +| 2 | `kimi --append-system-prompt x` / `kimi -c model_instructions_file=…` | unknown option / unknown command, **exit 1** (`-c` is `--continue` in Kimi) | +| 3 | Session store layout | `~/.kimi-code/sessions/wd__<12hex>/session_/` with `state.json` (`createdAt`, `updatedAt`, `workDir`, `lastPrompt`) + `agents/main/wire.jsonl`; global `~/.kimi-code/session_index.jsonl` maps `{sessionId, sessionDir, workDir}`; `workspaces.json` maps wd-hash → root path. **Exact cwd recorded per session** — stronger than Claude's encoded-path store | +| 4 | Session creation timing | Session dir + ID created **immediately at TUI launch**, before any prompt (`title: "New Session"`, no `lastPrompt`) | +| 5 | `kimi --continue -p "…"` in a dir with no sessions | Prints `No sessions to continue under ""; starting a fresh session.` and proceeds — **graceful, exit 0** | +| 6 | **Seed-session bootstrap** | `kimi -p "… acknowledge and wait" --output-format stream-json` → model acknowledges; stream-json emits a machine-readable meta line `{"role":"meta","type":"session.resume_hint","session_id":"session_",…}`. Then `kimi -S --yolo` opens the **TUI resuming that session**; a subsequent interactive turn shows the role briefing **retained and applied** (model kept the required `ROLE-OK` reply prefix) | +| 7 | `kimi -S -p "…"` (pinned-ID non-interactive resume) | Works; prior-turn context recalled correctly | +| 8 | `kimi -S session_00000000-…` (bogus id) | `error: failed to run prompt: Session "…" not found.` — **fast fail, exit 1** (clean signal for crash-loop fallback design) | +| 9 | TUI under a PTY (`script(1)`) | Renders fully (composer, status bar); typed input lands in composer | +| 10 | Submit timing | `text\r` in **one write** → treated as paste, **not submitted**. Text, then `\r` after **1s** → submits. The exact `message-write.ts` timing (10ms inter-line, **80ms** delayed Enter) → **not submitted**; same lines with a **1s** delayed Enter → submitted as **one** multi-line message, model replied correctly | +| 11 | `AGENTS.md` in cwd | **Read and applied natively** (instruction marker honored in reply) — like Codex, project context comes free | +| 12 | `--skills-dir` skill as role channel | Skill *description* always visible; **body is model-mediated** — the model must choose to invoke the Skill tool to load it (visible deliberation in thinking trace; it did load and apply in the probe). Probabilistic, not a guaranteed system-instruction channel; also `--skills-dir` **replaces** the user's normal skill dirs (documented) | +| 13 | Auth surface | OAuth artifacts at `~/.kimi-code/credentials/kimi-code.json` + `~/.kimi-code/oauth/kimi-code` when logged in (undocumented layout). `kimi doctor` validates config only, exit 0/1 as documented | +| 14 | `KIMI_CODE_HOME` | Redirects the home dir (documented for doctor; observed working) — natural **test seam** for session-store fixtures, but an isolated home also isolates credentials (so it is a test seam, not a per-worktree isolation mechanism) | + +## Approaches Tried + +### Approach 1: `-p`/argv-based prompt delivery (mechanical port of the Claude/Codex shape) +- **What**: positional prompt, role flags, `-p` as the builder loop command. +- **Result**: positional prompt and role flags rejected (obs. 1–2). `-p` is one-shot, no TUI, auto permission, conflicts with `--yolo`/`--auto`/`--plan` (documented); the builder loop would rerun the task after every exit and there is no durable PTY for `afx send`/gates. +- **Verdict**: Didn't work — as predicted in the handoff. + +### Approach 2: `--skills-dir` as the role channel +- **What**: generated skill carrying the role, injected via `--skills-dir`. +- **Result**: model-mediated load; worked once but is probabilistic, and replacement semantics would discard users' normal skills unless Codev merges them into the generated dir. +- **Verdict**: Partially worked — rejected as the *primary* role channel; viable only as a defense-in-depth supplement. + +### Approach 3: Seed-session bootstrap (recommended) +- **What**: (a) run `kimi -p "" --output-format stream-json` in the target cwd; (b) parse `session.resume_hint.session_id` from stdout; (c) persist the id; (d) launch the interactive TUI with `kimi -S --yolo`; (e) deliver the task/first instruction as a normal PTY message (Kimi-tuned delayed Enter). +- **Result**: end-to-end success (obs. 6, 7, 10). Role retained across the seed→TUI boundary and applied in interactive turns. Codev knows the exact session ID **before the TUI starts**. +- **Verdict**: Worked. Solves role injection, initial-prompt delivery, and the stored-ID session contract in one pattern, with no PTY readiness race for the *role* (only the task message needs PTY delivery, which is the same problem `afx send` already solves). + +### Approach 4: ACP / local server adapter +- **What**: `kimi acp` (JSON-RPC over stdio) or `kimi server` (REST + WebSocket) as a structured backend. +- **Result**: not POC'd. Documented to exist with local OpenAPI/AsyncAPI docs. Would give structured session/prompt control but replaces the entire PTY/terminal model Codev is built around (Tower terminals, dashboard, VSCode tabs, `afx send`) with a bespoke client for one CLI. +- **Verdict**: Not needed. The TUI harness path is validated; ACP/server is a much larger backend change with no parity payoff for this integration. Revisit only if a future Codev feature needs structured agent I/O generally. + +## Constraints Discovered + +- **No documented system-prompt flag and no positional prompt** — the whole launch shape must be provider-owned, not another pair of role args. +- **Session IDs cannot be pinned at creation** (no documented caller-supplied ID; bogus `-S` fast-fails) — the `session.newSessionArgs(sessionId)` mint-and-pin contract cannot be satisfied; a **capture** contract can (seed via `-p`, or post-launch store scan since the session dir appears at TUI start). +- **Paste/submit timing**: Kimi's paste window is longer than Claude's — 80ms delayed Enter fails, 1s works (threshold between 80ms and 1s, to be bisected during implementation). `message-write.ts` needs a per-harness Enter-delay knob; until then `afx send` to a Kimi PTY would silently not submit. +- **Role rides a user turn**, not a system prompt — weaker authority/trust semantics (the same limitation that deferred agy as an architect, #1063). Held up in POC; long-session drift is untested. +- **Undocumented reliance**: session store layout, `session_index.jsonl`, and the `session.resume_hint` stream-json meta line are all observations. Version-fragile; pin a minimum Kimi version and keep an integration smoke probe. +- **No write-guard parity**: Claude builders get the PreToolUse worktree write-guard hook (#1018). Kimi has no documented hook seam. The `-p` docs mention "static deny rules remain in effect", implying a deny-rule config exists somewhere outside the exclusive reference — a follow-up investigation, not a claimable guarantee. A Kimi builder must be documented as **not** having equivalent write isolation. +- **`--yolo` vs `--auto`**: recommend `--yolo` as the Codev default (matches `claude --dangerously-skip-permissions` semantics; trusted-workspace warning acknowledged). `--auto` suppresses agent→user questions, which Codev's gate/Q&A workflow depends on. Never combine (documented conflict). +- **Seed cost/latency**: one short model call (~5–15s) per fresh spawn; negligible tokens, but the fresh-launch path becomes **async** (a real contract change for `resolveArchitectLaunch`). +- **`--continue` is cwd-scoped**: safe for a builder's private worktree, unsafe for sibling architects sharing one cwd — but the seed pattern makes per-architect exact IDs available (captured from each seed's own stdout, so no store race), so `--continue` is never needed for architects. + +## Recommended Approach + +### Minimum viable integration (MVI): Kimi as **builder** + +Self-contained; no Tower launch-contract changes (the generated bash script owns the seed): + +1. **`KIMI_HARNESS`** in `harness.ts` + `detectHarnessFromCommand` recognizing `kimi` (kills the #1062 fallthrough for this CLI — the false-Claude behavior becomes impossible even before full support). +2. **Provider-owned builder launch shape**. New optional capability, e.g. `buildLaunchScript(ctx)` (or a `promptDelivery: 'argv' | 'seed-session'` discriminator branched in `spawn-worktree.ts`), generating: + ```bash + # .builder-start.sh (kimi shape) + if [ ! -s .builder-kimi-session ]; then + kimi -p "$(cat .builder-role.md) …ack-and-wait wrapper…" --output-format stream-json \ + | > .builder-kimi-session + fi + exec_loop kimi -S "$(cat .builder-kimi-session)" --yolo + ``` + Inner restarts resume the same session — role/task context survives restarts (better than the fresh-per-restart Claude loop). Task delivery: after PTY creation, `spawn.ts` posts the task prompt through Tower's message path (the validated delayed-Enter write), so the task turn is the "begin" signal. Seed failure (unauthenticated, network) exits non-zero before the loop → surfaced, not looped. +3. **`buildResume` for Kimi** (builder `afx spawn --resume`): prefer the persisted `.builder-kimi-session` id; fall back to newest `state.json` by `updatedAt` where `workDir == worktreePath` (via `session_index.jsonl`/store scan honoring `KIMI_CODE_HOME` as the test seam). Returns `{sessionId, args: ['-S', id], scriptFragment}` — fits the existing interface unchanged. (`--continue` is the degenerate alternative; explicit-ID keeps the null-return → fresh-with-role fallback semantics correct.) +4. **`message-write.ts` Enter-delay knob** per harness (Kimi ≥ ~1s until bisected; plumb the target session's harness or key off session metadata). +5. **`doctor`**: presence + version; optionally shell out to `kimi doctor` for config validity; **truthful auth story** — no documented status probe, so report credential-artifact presence as a heuristic and point at `kimi login` (never make a billed `-p` call without explicit opt-in). +6. Docs (`arch.md` harness section; config examples for `shell.builder`/`builderHarness`), skeleton mirror where framework files change, and the test matrix below. + +### Parity follow-up: Kimi as **architect** + +Everything above, plus the session-contract generalization: + +1. **Generalize `HarnessProvider.session`**: make `newSessionArgs` optional and add an async `seedSession(cwd, roleContent) → Promise` capability. Kimi implements `seedSession` (the `-p` seed + stream-json capture), `resumeArgs(id) = ['-S', id]`, and `verifyOwnership(id, cwd)` = session dir exists AND `state.json.workDir === cwd` (exact-path match — stronger than Claude's encoded-dir check; honors `KIMI_CODE_HOME` for tests). +2. **Async fresh-launch path**: `resolveArchitectLaunch` (and its four call sites: `launchInstance`, `add-architect`, both shellper restart-bakes, plus no-Tower `afx architect`) grows an async variant. Only the *fresh* branch awaits the seed; the *resume* branch stays synchronous (`-S `), so shellper restart-bake is unchanged in character. +3. **Invariant check** against #832/#1145/#1149/#1150: + - Stored-ID resume: satisfied via capture-at-seed (no cwd discovery anywhere — no #1145 hijack reintroduction; sibling architects each capture from their own seed's stdout, race-free). + - Ownership verification: satisfied (obs. 3; exact `workDir`). + - Crash-loop fallback (#1149): a fresh Kimi fallback cannot be precomputed synchronously (seeding is async). MVI decision: **omit the precomputed fallback for Kimi** — a dead resume fast-fails (obs. 8) into shellper's max-restart cap, and the next explicit start seeds fresh; document this as Codex-like degradation. Full parity later = async-capable `CrashLoopFallback`. + - Sibling liveness (#1150): `siblingRegistrationIsLive` works as-is once `verifyOwnership` exists. +4. **Acceptable-degradation alternative** (if the async seam is deferred): ship Kimi architect **Codex-like** — no `session` capability, fresh on every restart, role delivered by seed inside a generated architect launch script. Loses conversation persistence but requires zero Tower contract changes. This is a legitimate stage-1; the stored-ID contract is stage-2. + +### Answers to the handoff's §8 questions + +1. **Can a session ID be captured reliably?** Yes — from the seed's own stdout (`session.resume_hint`, machine-readable, observed) or from the store (session dir appears at TUI launch, `state.json.workDir` exact match). Capture-from-own-stdout is race-free even with concurrent launches. +2. **Can `--continue` implement builder resume?** Yes, safely, in a private worktree — including the no-prior-session case (graceful fresh start, exit 0, observed). But explicit-ID `buildResume` is preferred so the no-session case falls back to the role-injecting fresh path instead of a roleless fresh session. +3. **Is Codex-like initial support acceptable?** Yes for the architect (fresh after restart) as stage-1. For builders the seed pattern already gives *better* than Codex-like (context survives inner restarts) with no Tower changes. +4. **True per-architect resume requirements**: the `seedSession` capability + async fresh-launch seam + the #1149 fallback decision above; no invariant regressions identified. + +## File-by-file impact map (current HEAD) + +| File | Change | +|---|---| +| `packages/codev/src/agent-farm/utils/harness.ts` | `KIMI_HARNESS`; `detectHarnessFromCommand` + `BUILTIN_HARNESSES` entries; new `buildLaunchScript`/prompt-delivery capability; `session` contract generalization (`newSessionArgs?` + `seedSession?`); Kimi `buildResume`/`verifyOwnership`; new `kimi-session-discovery.ts` sibling module (store scan, `KIMI_CODE_HOME`-aware) | +| `packages/codev/src/agent-farm/commands/spawn-worktree.ts` | Branch `startBuilderSession`/`buildWorktreeLaunchScript` on the prompt-delivery capability → Kimi script shape (seed + `-S` loop + persisted `.builder-kimi-session`); gitignore/skip-worktree handling for the session file | +| `packages/codev/src/agent-farm/commands/spawn.ts` | Post-spawn task delivery via Tower message path for seed-style harnesses; `discoverResumeSession` works unchanged once Kimi has `buildResume` | +| `packages/codev/src/agent-farm/servers/tower-utils.ts` | Async variant of `resolveArchitectLaunch` fresh branch (awaits `seedSession`); `buildArchitectArgs` unchanged for flag-harnesses; Kimi fallback decision (#1149) encoded | +| `packages/codev/src/agent-farm/servers/tower-instances.ts` | Await the async launch resolution at `launchInstance` + `add-architect` sites (already async functions) | +| `packages/codev/src/agent-farm/servers/tower-terminals.ts` | Restart-bake sites unchanged in character (resume branch is sync); crash-loop fallback omitted for seed-style harnesses (stage-1) | +| `packages/codev/src/agent-farm/servers/message-write.ts` | Per-harness/session Enter-delay (Kimi ≥ bisected threshold); callers plumb the target's harness | +| `packages/codev/src/agent-farm/commands/architect.ts` | No-Tower path: await seed before spawn (function is already async) | +| `packages/codev/src/commands/doctor.ts` | `kimi` presence/version; optional `kimi doctor` config check; heuristic auth presence + `kimi login` guidance; architect-shell branch affirmation for kimi | +| `packages/codev/src/lib/config.ts` / types | Accept `kimi` wherever harness names are enumerated (audit; likely string-typed already) | +| `codev/resources/arch.md` (+ lessons) | Extend §"Supported Architect Harnesses & Conversation Resume"; document seed pattern, no-write-guard caveat, undocumented-surface reliance | +| `CLAUDE.md`/`AGENTS.md` + `codev-skeleton/` mirrors | Only if framework-facing docs/roles change (dual-tree rule) | + +## Test matrix + +**Unit** (existing patterns; `KIMI_CODE_HOME` as the fixture seam): +- `detectHarnessFromCommand('kimi'` / path forms`)` → `'kimi'`; unrecognized-fallthrough regression: `kimi` + stale Claude jsonl never yields `--resume ` or `--append-system-prompt` (the #929-class guard, four angles like PR #1059: harness, config, spawn-worktree, tower-instances). +- Kimi `buildResume`: fixture store → newest-by-`updatedAt` for exact `workDir`; null when none; `.builder-kimi-session` precedence. +- `verifyOwnership`: matching/mismatched `workDir`, missing dir, malformed `state.json`. +- Seed-output parser: `session.resume_hint` extraction; malformed/absent line → loud failure. +- Script generation: Kimi builder script shape (seed guard, `-S` loop, no positional prompt, no role flags); resume script uses `-S `. +- `resolveArchitectLaunch` async: fresh seeds + persists captured id; resume uses stored id sans role injection; `CODEV_SKIP_RESUME=1`; seed failure surfaces. +- `siblingRegistrationIsLive` with Kimi ownership semantics. +- `message-write` per-harness Enter delay selection. +- `doctor` kimi branch (presence, auth heuristic wording, architect affirmation). + +**Integration/manual** (real CLI; the PR #1059 checklist adapted): +- Fresh builder spawn → seed runs, TUI opens resumed, task arrives and submits; inner restart retains context; `afx spawn --resume` after kill; no-session resume falls back to fresh-with-role. +- Architect: `afx workspace start` with stale Claude jsonl present (no crash loop, no Claude flags); `add-architect` sibling; shellper reconnect resumes stored id; Tower stop/start liveness reconciliation; `afx architect` no-Tower. +- `afx send`: single-line, multiline (>3 lines), `--interrupt`, `--no-enter`, while streaming — bisect and pin the Enter delay. +- Dashboard + VSCode terminal render/input; Ctrl-C double-tap exit doesn't fight the restart loop. +- `codev doctor` with `shell.builder`/`shell.architect: "kimi"`. + +## Effort Estimate + +**Medium–Large** (~800–1200 LOC incl. tests). PR #1059 (codex, flag-only) touched 20 files; Kimi adds the async seed seam, a script-shape branch, session-capture plumbing, and the message-write knob on top of that footprint. + +**Recommended protocol**: **SPIR** for the full architect+builder integration (the `session`/launch-contract generalization is architectural; phases fall out naturally: 1 = harness + builder MVI, 2 = message delivery + doctor, 3 = architect/session parity). A builder-only MVI alone would fit **PIR** (design largely settled by this spike; `dev-approval` gate covers the live-TUI validation a diff can't show). + +## Next Steps + +- [ ] Architect decision: green-light SPIR spec for Kimi support (builder MVI first, architect parity staged) referencing this spike. +- [ ] During implementation: bisect the Kimi Enter-delay threshold; pin minimum supported Kimi version (≥ 0.27.0) and add a session-store smoke probe to catch layout drift. +- [ ] Follow-up investigation (separate, small): Kimi "static deny rules" config surface as a partial write-guard substitute for builders. +- [ ] Not pursued: ACP/`kimi server` adapter (larger backend change, no parity payoff — revisit only for structured-agent-I/O needs). + +## References + +- Exclusive Kimi documentation source: https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-command.html +- Prior art: PR #1059 "Support codex as an architect (PIR #929)" (merged 2026-06-28); `codev/reviews/929-support-codex-and-gemini-clis-.md`; `codev/plans/929-support-codex-and-gemini-clis-.md` +- Architecture: `codev/resources/arch.md` §"Supported Architect Harnesses & Conversation Resume (#929)"; issues/PRs #832, #1145, #1149, #1150, #1062, #1063 (agy deferral — same role-as-user-turn tradeoff), #1018 (write-guard) +- Current seams (HEAD `165339ab` lineage): `packages/codev/src/agent-farm/utils/harness.ts`, `utils/config.ts`, `commands/spawn.ts`, `commands/spawn-worktree.ts`, `commands/architect.ts`, `servers/tower-utils.ts`, `servers/tower-instances.ts`, `servers/tower-terminals.ts`, `servers/message-write.ts`, `packages/codev/src/commands/doctor.ts` +- POC transcript script: `codev/spikes/task-Iptx-kimi-poc.sh` (empirical evidence, kimi 0.27.0, 2026-07-18) diff --git a/codev/spikes/task-Iptx-kimi-poc.sh b/codev/spikes/task-Iptx-kimi-poc.sh new file mode 100755 index 000000000..ca1e2090e --- /dev/null +++ b/codev/spikes/task-Iptx-kimi-poc.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Spike task-Iptx — Kimi Code CLI empirical probes (kimi 0.27.0, 2026-07-18) +# +# Reproduces the observations in task-Iptx-kimi-code-cli-support.md. +# Requirements: authenticated `kimi` on PATH, `script` (util-linux), python3. +# Probes 5–10 make small real model calls. Run from any scratch directory. +# +# NOTE: results are OBSERVATIONS against kimi 0.27.0, not documented guarantees. +set -u +S="$(mktemp -d)/kimi-poc"; mkdir -p "$S" +echo "scratch: $S" + +echo "== 1. positional prompt (expect: unknown command, exit 1)" +kimi __codev_probe__; echo "exit=$?" + +echo "== 2. role flags (expect: unknown option/command, exit 1)" +kimi --append-system-prompt x; echo "exit=$?" +kimi -c model_instructions_file=/tmp/x; echo "exit=$?" # -c is --continue in kimi + +echo "== 3. session store layout (expect: wd__/session_/state.json)" +find ~/.kimi-code/sessions -maxdepth 2 | head -8 +head -2 ~/.kimi-code/session_index.jsonl + +echo "== 4. doctor (config-only validation, exit 0)" +kimi doctor; echo "exit=$?" + +echo "== 5. --continue with no prior session (expect: graceful fresh start, exit 0)" +mkdir -p "$S/empty" && cd "$S/empty" +kimi --continue -p "Reply with exactly: OK"; echo "exit=$?" + +echo "== 6. stream-json session id capture (expect: session.resume_hint meta line)" +OUT=$(kimi -p "Reply with exactly: PONG" --output-format stream-json) +echo "$OUT" +SID=$(echo "$OUT" | python3 -c "import json,sys +for l in sys.stdin: + o=json.loads(l) + if o.get('type')=='session.resume_hint': print(o['session_id'])") +echo "captured SID=$SID" + +echo "== 7. pinned-ID non-interactive resume (expect: context recalled)" +kimi -S "$SID" -p "What exact reply did I ask for before? One line."; echo "exit=$?" + +echo "== 8. bogus session id (expect: fast fail, exit 1)" +kimi -S session_00000000-0000-0000-0000-000000000000 -p hi; echo "exit=$?" + +echo "== 9. seed-session bootstrap: seed role via -p, resume in TUI, verify role retention" +mkdir -p "$S/seed" && cd "$S/seed" +OUT=$(kimi -p "ROLE BRIEFING: begin every reply with the exact token ROLE-OK followed by a space. Acknowledge and wait. Do not use tools." --output-format stream-json) +SID=$(echo "$OUT" | python3 -c "import json,sys +for l in sys.stdin: + o=json.loads(l) + if o.get('type')=='session.resume_hint': print(o['session_id'])") +echo "seed SID=$SID" +{ sleep 5; printf 'What is your role token? Reply per your briefing.'; sleep 1; printf '\r' + sleep 45; printf '\x03'; sleep 1; printf '\x03'; sleep 2; } | + script -qec "timeout 70 kimi -S $SID --yolo" /dev/null >/dev/null 2>&1 +WD=$(ls -d ~/.kimi-code/sessions/wd_seed_* 2>/dev/null | head -1) +echo "--- assistant turns (expect ROLE-OK prefix on the interactive turn too):" +grep -o '"part":{"type":"text","text":"[^"]*"' "$WD/$SID/agents/main/wire.jsonl" | tail -3 + +echo "== 10. submit-timing: message-write.ts pacing (80ms Enter) vs 1s Enter" +for delay in 0.08 1; do + mkdir -p "$S/ml-$delay" && cd "$S/ml-$delay" + { sleep 5; printf 'line one\n'; sleep 0.01; printf 'line two\n'; sleep 0.01 + printf 'reply with exactly ML-OK'; sleep "$delay"; printf '\r' + sleep 40; printf '\x03'; sleep 1; printf '\x03'; sleep 2; } | + script -qec "timeout 65 kimi --yolo" /dev/null >/dev/null 2>&1 + WD=$(ls -d ~/.kimi-code/sessions/wd_ml-${delay}_* 2>/dev/null | head -1) + LP=$(python3 -c "import json,glob +f=sorted(glob.glob('$WD/session_*/state.json'))[-1] +print(json.load(open(f)).get('lastPrompt'))" 2>/dev/null) + echo "enter-delay=${delay}s -> lastPrompt: $LP" # 0.08 -> None (not submitted); 1 -> full message +done + +echo "== 11. AGENTS.md read natively (expect XYZZY-7 prefix)" +mkdir -p "$S/agentsmd" && cd "$S/agentsmd" +printf '# Project instructions\n\nIMPORTANT: Begin every reply with the exact token XYZZY-7 followed by a space.\n' > AGENTS.md +kimi -p "Say hello in three words." + +echo "== 12. KIMI_CODE_HOME redirect (test seam)" +KIMI_CODE_HOME="$S/home" kimi doctor; echo "exit=$?" diff --git a/codev/state/task-Iptx_thread.md b/codev/state/task-Iptx_thread.md new file mode 100644 index 000000000..0ac58ac22 --- /dev/null +++ b/codev/state/task-Iptx_thread.md @@ -0,0 +1,23 @@ +# Builder thread: task-Iptx (SPIKE — Kimi Code CLI support) + +## 2026-07-18 — Spawn + brief received + +- Spawn template omitted the task block; architect delivered the authoritative handoff via message. +- **Question**: What does it take to support Kimi Code CLI as an architect and builder? +- Exclusive Kimi doc source: https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-command.html — everything else is empirical observation only. +- Prior art: PR #1059 (Codex as architect, PIR #929) — review against current HEAD, not mechanically. +- Constraint: no GitHub write permission in this environment → commit findings locally, notify architect, don't block on push. +- Installed `kimi` present at ~/.kimi-code/bin/kimi, version 0.27.0. + +Plan: research (Kimi ref page, PR #1059 + plan/review, harness seams) → minimal POCs (prompt delivery, role injection, session capture, --continue) → findings doc in codev/spikes/ → commit + notify. + +## 2026-07-18 — Research + POCs complete, findings written + +- Research: Kimi command reference captured in full; PR #1059 review + current HEAD seams read (harness.ts, config.ts, spawn/spawn-worktree, architect.ts, tower-utils/instances/terminals, message-write, doctor, arch.md §929). Confirmed: `kimi` today falls through to the Claude harness (#1062) and the builder script's positional prompt kills the launch. +- POC highlights (kimi 0.27.0, all labeled observations): + - Session store is cwd-keyed with exact `workDir` in `state.json` + global `session_index.jsonl`; session dir + ID created at TUI launch. + - **Seed-session bootstrap validated end-to-end**: `-p` role briefing → capture `session.resume_hint.session_id` from stream-json → `kimi -S --yolo` TUI resume → role retained in interactive turns. Solves role injection + initial prompt + stored-ID contract at once. + - `--continue` degrades gracefully with no prior session (exit 0); bogus `-S` id fast-fails exit 1; `AGENTS.md` read natively; `--skills-dir` body load is model-mediated (rejected as primary role channel). + - Submit timing: message-write.ts's 80ms delayed Enter does NOT submit in Kimi; 1s does (single- and multi-line as one message) → per-harness Enter-delay knob needed. +- **Verdict: Feasible with Caveats** for both builder and architect. Recommended: builder MVI (script-owned seed, no Tower contract change) then architect parity (async `seedSession` capability generalizing `HarnessProvider.session`). Effort Medium–Large; protocol SPIR (or PIR for builder-only MVI). +- Deliverables: `codev/spikes/task-Iptx-kimi-code-cli-support.md` + `task-Iptx-kimi-poc.sh` (reproducible probes). Committed locally only — no GitHub write permission in this environment (per architect note). From af1bc538bda2742771a4d19b1615ec26151dad47 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 18:18:21 -0400 Subject: [PATCH 02/43] [Spike task-Iptx] Addendum: task-delivery readiness barrier + corrected #1149 parity requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new observations: Kimi TUI never enters the alternate screen (no escape-based readiness signal), and PTY input during the seed window has no defined consumer (silently lost). Barrier design: sentinel + grace + store-verified delivery with retry; seed carries role+task, kick is a single BEGIN line. Architect parity correction: stored-ID resume without an async-buildable CrashLoopFallback is cap-exhaustion outage, not #1149 safety — ship Codex-like (stage 1) or stored-ID + async fallback (stage 2), no middle. Co-Authored-By: Claude Fable 5 --- .../spikes/task-Iptx-kimi-code-cli-support.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/codev/spikes/task-Iptx-kimi-code-cli-support.md b/codev/spikes/task-Iptx-kimi-code-cli-support.md index 88fc8c2ca..794b46805 100644 --- a/codev/spikes/task-Iptx-kimi-code-cli-support.md +++ b/codev/spikes/task-Iptx-kimi-code-cli-support.md @@ -177,6 +177,34 @@ Everything above, plus the session-contract generalization: - [ ] Follow-up investigation (separate, small): Kimi "static deny rules" config surface as a partial write-guard substitute for builders. - [ ] Not pursued: ACP/`kimi server` adapter (larger backend change, no parity payoff — revisit only for structured-agent-I/O needs). +## Addendum (2026-07-18, post-architect-review) + +Two corrections from architect review, with two additional probes. + +### A. Task-delivery readiness barrier (builder MVI) + +The original MVI said "spawn.ts posts the task through Tower's message path after PTY creation" — underspecified, because for the first ~5–15s the PTY's foreground process is the **seed `kimi -p` call**, not the TUI. Additional observations: + +- **(observed)** Kimi's TUI never emits the alternate-screen-enter escape (`ESC[?1049h` absent from both captured TUI transcripts) — it renders inline, so "TUI rendered" is not cleanly detectable from terminal escapes, and matching UI text (status bar/composer) would be version-fragile. +- **(observed)** Bytes written to the PTY while `kimi -p` runs have **no defined consumer**: the seed's prompt is argv-bound and was unaffected by an injected line (`lastPrompt` = seed prompt only), and the injected text was recorded nowhere — a task written early is silently lost, or at worst replayed unpredictably into the TUI composer from the PTY input buffer. A barrier is mandatory, not defensive. + +**Corrected design — layered barrier + verified delivery:** + +1. **Shrink the at-risk payload**: the seed turn carries **role + task briefing** (with an explicit "do not act; do not use tools; acknowledge and wait for BEGIN" wrapper — the ack-and-wait discipline held in POC 6 for the role; validate it holds with a task attached, else fall back to role-only seed and treat the full task as the delivered payload below). +2. **Sentinel**: the generated script prints `__CODEV_KIMI_SEED_DONE__ ` on its own line between seed completion and TUI exec. Tower (which already streams PTY output) gates any delivery on the sentinel — this deterministically bounds the seed window without guessing at timing. +3. **Grace + write**: after the sentinel, a short fixed grace (~2–3s) for the composer, then the kick message (`BEGIN`, single line) with the Kimi-tuned delayed Enter. +4. **Store-verified delivery (the actual guarantee)**: after writing, poll the session's `state.json` (`lastPrompt`/`updatedAt` — observed to update on submit) for confirmation; on timeout re-send Enter (the dominant observed failure is a swallowed Enter), then re-send the kick once, then surface a loud spawn warning. Ground truth from the store makes delivery self-healing and also absorbs the Enter-delay bisection uncertainty. + +Impact-map delta: the "spawn.ts post-spawn task delivery" row becomes a small Tower-side readiness-gated delivery routine (harness-owned sentinel pattern + verify function); test matrix adds sentinel parsing, the verify-retry state machine, and a seed-window write-loss regression test. + +### B. #1149 crash-loop fallback — corrected requirement for architect parity + +Concession: the original "stage-1: omit the precomputed fallback, rely on shellper's max-restart cap" is **not crash-loop-safe** — a dead stored session (store GC, manual deletion) makes every `-S` resume fast-fail (obs. 8); the restart loop burns to cap exhaustion, and per the documented lifecycle the permanent-exit handlers then **deregister the architect row**. That is a detectable outage requiring manual restart — a regression vs. Claude's self-healing, and must not be shipped under a "parity" claim. + +**Corrected requirement:** true architect resume parity REQUIRES preserving #1149's degrade-to-working-fresh semantic. Because a Kimi fresh-with-role launch can only be produced by the async seed, `CrashLoopFallback` (`session-manager.ts`) must be generalized so the fallback can be **built at degradation time**: an async `build(): Promise<{args, env}>` that runs `seedSession` (role re-seed → newly captured id) with `onApply` persisting the replacement id (the #1149 row-repair semantic, unchanged). The restart loop already tolerates inter-attempt delay; awaiting a 5–15s seed there is acceptable. A sync-only fallback (roleless fresh TUI) is ruled out by #1149's own constraint — the resume branch skips role injection, so the fallback must carry the role. + +**Corrected staging:** ship Kimi architect as EITHER (stage 1) Codex-like — no `session` capability, fresh on every restart, which is genuinely crash-loop-safe because no resume path exists — OR (stage 2) full stored-ID resume **with** the async-`build` fallback. The middle configuration (stored-ID resume, no async fallback) is not a shippable stage. Impact-map delta: add `packages/codev/src/terminal/session-manager.ts` (async-capable `CrashLoopFallback.build`); test matrix adds fallback-time seed success/failure (failure → capped restarts surfaced loudly, row NOT silently repaired). + ## References - Exclusive Kimi documentation source: https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-command.html From 1e22513544230732574a77be0c754749db063ab0 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 18:18:43 -0400 Subject: [PATCH 03/43] [Spike task-Iptx] Thread: post-review addendum logged Co-Authored-By: Claude Fable 5 --- codev/state/task-Iptx_thread.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/codev/state/task-Iptx_thread.md b/codev/state/task-Iptx_thread.md index 0ac58ac22..25e776f79 100644 --- a/codev/state/task-Iptx_thread.md +++ b/codev/state/task-Iptx_thread.md @@ -21,3 +21,9 @@ Plan: research (Kimi ref page, PR #1059 + plan/review, harness seams) → minima - Submit timing: message-write.ts's 80ms delayed Enter does NOT submit in Kimi; 1s does (single- and multi-line as one message) → per-harness Enter-delay knob needed. - **Verdict: Feasible with Caveats** for both builder and architect. Recommended: builder MVI (script-owned seed, no Tower contract change) then architect parity (async `seedSession` capability generalizing `HarnessProvider.session`). Effort Medium–Large; protocol SPIR (or PIR for builder-only MVI). - Deliverables: `codev/spikes/task-Iptx-kimi-code-cli-support.md` + `task-Iptx-kimi-poc.sh` (reproducible probes). Committed locally only — no GitHub write permission in this environment (per architect note). + +## 2026-07-18 — Post-review addendum + +Architect review flagged two gaps; both addressed in the findings addendum (commit 9f2370f6) with two extra probes: +- Task-delivery readiness: Kimi TUI has no alt-screen escape (verified) and seed-window PTY input is silently lost (verified) → corrected design is sentinel + grace + store-verified BEGIN kick with retry; seed turn carries role+task. +- #1149 parity: conceded that fast-fail-to-restart-cap is an outage (row deregistration), not crash-loop safety → parity requires an async-buildable CrashLoopFallback running seedSession at degradation time; valid stages are Codex-like or stored-ID+async-fallback, no middle. From 1e8d3c65ccd67589b4aad8c9e9e97a67cf10605b Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 18:59:08 -0400 Subject: [PATCH 04/43] chore(porch): 1201 init pir --- .../status.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml new file mode 100644 index 000000000..1817e7333 --- /dev/null +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -0,0 +1,18 @@ +id: '1201' +title: support-kimi-code-cli-as-a-bui +protocol: pir +phase: plan +plan_phases: [] +current_plan_phase: null +gates: + plan-approval: + status: pending + dev-approval: + status: pending + pr: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-07-18T22:59:08.361Z' +updated_at: '2026-07-18T22:59:08.362Z' From d49c292bd8739031cbba567c9aa91d4ba2eae14e Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:06:11 -0400 Subject: [PATCH 05/43] [PIR #1201] Plan draft Co-Authored-By: Claude Fable 5 --- .../1201-support-kimi-code-cli-as-a-bui.md | 182 ++++++++++++++++++ codev/state/pir-1201_thread.md | 15 ++ 2 files changed, 197 insertions(+) create mode 100644 codev/plans/1201-support-kimi-code-cli-as-a-bui.md create mode 100644 codev/state/pir-1201_thread.md diff --git a/codev/plans/1201-support-kimi-code-cli-as-a-bui.md b/codev/plans/1201-support-kimi-code-cli-as-a-bui.md new file mode 100644 index 000000000..1bf6a6620 --- /dev/null +++ b/codev/plans/1201-support-kimi-code-cli-as-a-bui.md @@ -0,0 +1,182 @@ +# PIR Plan: Support Kimi Code CLI as a builder + +**Issue**: cluesmith/codev#1201 +**Spike**: `codev/spikes/task-Iptx-kimi-code-cli-support.md` (verdict: Feasible with Caveats; POC-validated end-to-end, incl. the post-review addendum) + `codev/spikes/task-Iptx-kimi-poc.sh` +**Scope fence** (architect-confirmed): exactly the builder-MVI checklist in #1201. NO architect parity (no `resolveArchitectLaunch` / `CrashLoopFallback` changes), NO ACP / `kimi server` adapter. Write-guard parity is a documented caveat only. +**Evidence rule**: documented-Kimi claims cite only https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-command.html. Session store layout, `session_index.jsonl`, and the `session.resume_hint` stream-json meta line are **undocumented, observed** surfaces (kimi 0.27.0) — pinned via a minimum-version check and a session-store smoke probe in doctor. + +## Understanding + +Today, configuring `.codev/config.json` `shell.builder: "kimi"` (or `builderHarness: "kimi"`) produces a broken builder: + +1. `detectHarnessFromCommand('kimi')` (`packages/codev/src/agent-farm/utils/harness.ts:309-322`) doesn't recognize `kimi` → `resolveHarness` falls through to `CLAUDE_HARNESS` (`harness.ts:372` — the #1062 fallthrough). +2. The false Claude harness makes `startBuilderSession` (`packages/codev/src/agent-farm/commands/spawn-worktree.ts:800-808`) generate a script appending `--append-system-prompt "$(cat role)"` and a positional prompt `"$(cat .builder-prompt.txt)"`. Kimi rejects both (observed: unknown option / `unknown command`, exit 1) → the in-script `while true` loop restarts into the same failure forever. +3. The false Claude harness also exposes Claude's `buildResume`, so a stale Claude `.jsonl` for the worktree path can route `--resume ` into `kimi` (the pre-#929 crash-loop class). + +Kimi has **no documented system-prompt flag and no documented positional prompt**, so the fix can't be another pair of role args — the whole builder launch shape must be provider-owned. The spike validated the **seed-session bootstrap**: a `kimi -p "" --output-format stream-json` seed turn in the worktree, session id captured from the `session.resume_hint` meta line, persisted, then the interactive TUI looped with `kimi -S --yolo` — role/task context survives inner restarts. The spike addendum makes the **task-delivery readiness barrier mandatory**: bytes written to the PTY during the ~5–15s seed window have no defined consumer (observed: silently lost), so BEGIN delivery must be gated on an explicit sentinel and verified against the session store. + +## Proposed Change + +Eight work items, matching the issue checklist 1:1. + +### 1. `KIMI_HARNESS` + detection (`utils/harness.ts`) + +- `detectHarnessFromCommand`: add `if (basename.includes('kimi')) return 'kimi';`. This alone kills the #1062 false-Claude fallthrough for this CLI. +- New `KIMI_HARNESS: HarnessProvider`: + - `buildRoleInjection`: **throws** with a clear "Kimi is builder-only; architect support is stage 2 (use claude or codex)" message — the OPENCODE pattern (`harness.ts:174-181`). Any architect-path use fails loudly instead of silently mis-launching. + - `buildScriptRoleInjection`: returns `{ fragment: '', env: {} }` (role cannot ride argv; the real shape comes from the new capability below). + - `buildResume` — see item 4. + - **No `session` block.** The architect stored-UUID contract requires `newSessionArgs(sessionId)` (mint-and-pin), which Kimi cannot satisfy (no documented caller-supplied ID). Generalizing that contract (`newSessionArgs` optional + async `seedSession`) is the stage-2 architect work, explicitly out of scope. Builder resume verification lives inside `buildResume` via the discovery module's ownership check instead. +- New **optional provider capability** for provider-owned builder launch shapes: + ```ts + buildBuilderLaunchScript?(ctx: { + worktreePath: string; baseCmd: string; + promptFile: string | null; // .builder-prompt.txt (fresh paths) + roleFile: string | null; // .builder-role.md (null on no-role spawns) + seedFile: string | null; // .builder-seed.txt (fresh paths; see item 2) + resume?: { sessionId: string }; // resume path + }): string; + ``` + Only Kimi implements it; all existing harnesses are untouched (flag/argv shapes keep the current generic scripts). +- Kimi seed-delivery metadata on the provider (consumed by items 3/5): sentinel prefix `__CODEV_KIMI_SEED_DONE__`, kick message `BEGIN`, grace ms, and `messagePacing: { enterDelayMs: }`. + +### 2. Provider-owned launch shape (`commands/spawn-worktree.ts`) + +`startBuilderSession` (`spawn-worktree.ts:746`) and `buildWorktreeLaunchScript` (`spawn-worktree.ts:869`) branch: when the resolved harness has `buildBuilderLaunchScript`, use it for the script content (fresh-with-role, fresh-no-role, and resume variants all flow through the one capability). Generated Kimi fresh script (shape validated by spike POC 6): + +```bash +#!/bin/bash +cd "" +if [ ! -s .builder-kimi-session ]; then + kimi -p "$(cat '.builder-seed.txt')" --output-format stream-json \ + | node -e '' \ + > .builder-kimi-session +fi +SID="$(cat .builder-kimi-session)" +if [ -z "$SID" ]; then echo "Kimi seed failed (no session id captured) — check 'kimi login' / network"; exit 1; fi +echo "__CODEV_KIMI_SEED_DONE__ $SID" +while true; do + kimi -S "$SID" --yolo + echo ""; echo "Agent exited. Restarting in 2 seconds... (Ctrl+C to quit)"; sleep 2 +done +``` + +Key properties: +- **Seed failure exits before the loop** → surfaced once, not restart-looped (unauthenticated/network failures don't spin). +- **Seed is idempotent** (`-s` guard): a script relaunch reuses the persisted id, so role/task context survives inner restarts — and the sentinel is re-printed, re-arming delivery gating. +- `--yolo` is harness-owned (matches `claude --dangerously-skip-permissions` semantics; `--auto` rejected — it suppresses agent→user questions, which gates/Q&A depend on; the two conflict per the command reference). Users configure plain `shell.builder: "kimi"`. +- The extraction one-liner drains stdin to EOF before exiting (avoids EPIPE killing the seed mid-turn; the resume_hint line's position in the stream is undocumented). +- `.builder-seed.txt` (written by spawn-worktree on fresh paths) = ack-and-wait wrapper + role content + task briefing (the prompt): "initialize, do not act, do not use tools, acknowledge and wait for BEGIN". Primary design per the spike addendum; **fallback if the live probe shows the discipline doesn't hold with a task attached**: role-only seed, with the full task prompt becoming the delivered kick payload (the delivery machinery in item 3 is payload-agnostic, so the fallback is a content change, not a design change). +- Resume-variant script (from `buildResume`): no seed, no sentinel — straight `while true; do kimi -S '' --yolo; …` loop. +- `.builder-kimi-session` / `.builder-seed.txt` are spawn artifacts in the same class as the existing untracked `.builder-prompt.txt` / `.builder-role.md` / `.builder-start.sh` — same handling (never committed). + +### 3. Readiness barrier + store-verified BEGIN delivery (Tower) + +Per the spike addendum this is **required scope**, and it lives Tower-side (Tower already streams PTY output; it survives the spawn CLI exiting). + +- `createTerminal` (`packages/core/src/tower-client.ts:436`, `handleTerminalCreate` at `servers/tower-routes.ts:560`) gains an optional `seedKick` field: `{ sentinel, message, graceMs, verify: { kind: 'kimi-session-store', worktreePath } }`. `startBuilderSession` populates it from the harness's seed-delivery metadata on Kimi fresh spawns only. +- New module `packages/codev/src/agent-farm/servers/seed-kick.ts` — `armSeedKick(session, opts, log)`: + 1. Subscribe to the session's `'data'` events (`PtySession` is an `EventEmitter`, `terminal/pty-session.ts:289`); line-buffer and scan for `__CODEV_KIMI_SEED_DONE__ ` (robust to chunk boundaries); capture the id; unsubscribe. + 2. Fixed grace (~2.5s) for the composer to be ready. + 3. Write the kick (`BEGIN`, single line) via `writeMessageToSession` with the Kimi Enter delay (item 5). + 4. **Store-verified delivery** (the actual guarantee): poll the session's `state.json` (`lastPrompt`/`updatedAt` — observed to update on submit) via the discovery module (item 4). On timeout (~10s): re-send Enter (dominant observed failure is a swallowed Enter); still nothing → re-send the kick once; still nothing → loud WARN in the Tower log and terminal broadcast. Self-healing also absorbs any residual Enter-delay uncertainty. + 5. Sentinel timeout (~180s) → loud "seed never completed" WARN. +- Armed kicks are in-memory: a Tower restart during the seed window loses the kick. Documented caveat + remediation (`afx send "BEGIN"`). + +### 4. Session discovery: `buildResume` + ownership (`utils/kimi-session-discovery.ts`, new) + +Sibling module to `claude-session-discovery.ts`, all fail-soft (malformed/missing → null/false, never throw). Store root: `KIMI_CODE_HOME` env else `~/.kimi-code` (env var documented for doctor; the layout beneath is undocumented/observed), with an `opts.kimiHome` test seam: + +- `findLatestKimiSessionId(absolutePath)`: scan `sessions/wd_*/session_*/state.json`, filter `workDir === absolutePath` (realpath-tolerant on both sides, mirroring `claude-session-discovery.ts:100-106`), pick max `updatedAt`. Deliberately does **not** read `session_index.jsonl` — one undocumented surface instead of two; the directory scan is the ground truth the index merely mirrors. +- `verifyKimiSessionOwnership(sessionId, cwd)`: session dir exists AND `state.json.workDir === cwd` — exact-path match, stronger than Claude's encoded-dir check. +- `readKimiSessionState(sessionId)` → `{ workDir, updatedAt, lastPrompt } | null` — consumed by the seed-kick verifier (item 3) and the doctor smoke probe (item 6). + +`KIMI_HARNESS.buildResume(worktreePath)`: +1. `.builder-kimi-session` file in the worktree → its id, if `verifyKimiSessionOwnership` passes (a stale/GC'd id falls through rather than baking a fast-failing `-S` into the restart loop — kimi fast-fails on unknown ids, observed). +2. Else newest store session with exact `workDir` match. +3. Else `null` → `discoverResumeSession` (`commands/spawn.ts:87`) falls back to the fresh-with-role seed path — exactly the semantics that make explicit-ID preferable to `--continue` (a roleless fresh session is never possible). + +Returns `{ sessionId, args: ['-S', id], scriptFragment: "-S ''" }` — the existing interface, unchanged. + +### 5. Per-harness Enter-delay knob (`servers/message-write.ts`) + +Kimi's paste window is longer than Claude's: 80ms delayed Enter → not submitted; 1s → submitted (observed). Without this, `afx send` to a Kimi PTY silently doesn't submit. + +- `writeMessageToSession(session, message, noEnter, delayOffset?, pacing?: { enterDelayMs?: number })` — when set, overrides both `SIMPLE_ENTER_DELAY_MS` (50) and `PACED_ENTER_DELAY_MS` (80). Absent → current behavior byte-for-byte (Claude/codex/gemini paths untouched). +- `HarnessProvider.messagePacing?: { enterDelayMs }`; only Kimi sets it. The value is **bisected live during implement** (threshold is between 80ms and 1s) and pinned with margin; plan placeholder 1000ms. +- Call sites resolve pacing from the target terminal's registered type + workspace: builder → `getBuilderHarness(workspacePath)`, architect → `getArchitectHarness(workspacePath)`, else default — a small `resolvePacingForTarget` helper used by `deliverBufferedMessage` + the direct path (`tower-routes.ts:111`, `:1377`) and cron delivery (`tower-cron.ts:323`). The seed-kick writer (item 3) uses the same pacing directly. + +### 6. `codev doctor` (`src/commands/doctor.ts`) + +- `AI_DEPENDENCIES` (`doctor.ts:156`): add Kimi — `kimi --version` presence, **`minVersion: '0.27.0'`** (the version the undocumented surfaces were observed against), install hint → Kimi Code docs. +- **Truthful auth heuristic** (no billed probe, ever): custom `verifyKimi()` reporting credential-artifact presence (`/credentials/kimi-code.json` / `oauth/kimi-code` — undocumented layout, labeled as a heuristic in the output) with `kimi login` guidance when absent. Optionally also shell out to `kimi doctor` (config validity; documented exit 0/1 — explicitly *not* an auth check, and reported as such). +- **Session-store smoke probe**: when kimi is installed and a store exists, verify the observed layout still parses (`sessions/wd_*/session_*/state.json` with a `workDir` key) via `readKimiSessionState`; warn loudly on drift ("undocumented surface changed — resume and BEGIN-delivery verification may fail; check for a Kimi update"). +- Architect-shell branch (`doctor.ts:687-712` pattern): `resolvedHarness === 'kimi'` → warn "Kimi is builder-only (stage 2 for architects); use claude or codex for the architect". + +### 7. Docs + +- `codev/resources/arch.md` §"Supported Architect Harnesses & Conversation Resume (#929)" + the builder-harness/role-injection material around `arch.md:256`: kimi is builder-only; the seed-session bootstrap pattern; sentinel + store-verified BEGIN delivery; per-harness Enter pacing; **no write-guard parity** (Kimi has no documented hook seam — a Kimi builder does not get the #1018 PreToolUse write isolation; the `-p` docs' "static deny rules remain in effect" hints at a deny-rule surface outside the command reference — follow-up investigation, not a claimable guarantee); role rides a user turn, not a system prompt (same tradeoff that deferred agy, #1063); undocumented-surface reliance + the 0.27.0 pin. +- Config examples for `shell.builder: "kimi"` / `builderHarness: "kimi"` wherever harness config is documented; grep BOTH `codev/` and `codev-skeleton/` for harness enumerations before claiming done (per lessons-critical). Framework-file changes get mirrored to the skeleton; `arch.md` itself is user-evolved (no skeleton mirror). +- Review-time: route any new facts/lessons by hot/cold tier (Spec 987). + +### 8. Out of scope (fenced) + +No changes to `resolveArchitectLaunch`, `tower-instances.ts` launch sites, `tower-terminals.ts` restart-bake sites, `CrashLoopFallback`/`session-manager.ts`, or `commands/architect.ts`. No ACP/`kimi server`. Kimi-as-architect fails loudly via the `buildRoleInjection` throw + doctor warning. + +## Files to Change + +| File | Change | +|---|---| +| `packages/codev/src/agent-farm/utils/harness.ts` | `KIMI_HARNESS`; `BUILTIN_HARNESSES.kimi`; `detectHarnessFromCommand` kimi match; `buildBuilderLaunchScript` + `messagePacing` + seed-delivery metadata on the `HarnessProvider` interface; Kimi `buildResume` | +| `packages/codev/src/agent-farm/utils/kimi-session-discovery.ts` | **New** — store scan, ownership verify, state reader (`KIMI_CODE_HOME`-aware, `kimiHome` test seam) | +| `packages/codev/src/agent-farm/commands/spawn-worktree.ts` | Branch `startBuilderSession` / `buildWorktreeLaunchScript` on `buildBuilderLaunchScript`; write `.builder-seed.txt`; pass `seedKick` through `createPtySession` | +| `packages/codev/src/agent-farm/servers/seed-kick.ts` | **New** — sentinel watcher + grace + kick + store-verified retry state machine | +| `packages/codev/src/agent-farm/servers/tower-routes.ts` | `handleTerminalCreate` accepts/forwards `seedKick`; message paths pass resolved pacing | +| `packages/codev/src/agent-farm/servers/message-write.ts` | Optional `pacing.enterDelayMs` override | +| `packages/codev/src/agent-farm/servers/tower-cron.ts` | Pass resolved pacing at `deliverMessage` | +| `packages/core/src/tower-client.ts` | `createTerminal` options + `seedKick` field | +| `packages/codev/src/commands/doctor.ts` | Kimi presence/minVersion, auth heuristic, `kimi doctor` config check, session-store smoke probe, architect-kimi warning | +| `packages/codev/src/agent-farm/__tests__/…` + `servers/__tests__/…` | Tests per matrix below (extend `harness.test.ts`, `spawn-worktree.test.ts`, `spawn.test.ts`; new `kimi-session-discovery.test.ts`, `seed-kick.test.ts`; extend message-write + doctor tests) | +| `codev/resources/arch.md` (+ config-example docs, skeleton mirror where framework files change) | Item 7 | + +## Risks & Alternatives Considered + +- **Risk: undocumented surfaces drift with a Kimi update** (store layout, `resume_hint` meta line). Mitigation: 0.27.0 minimum-version check + doctor smoke probe; all discovery is fail-soft to the fresh-with-role path; the store-verified kick degrades to a loud warning, never a hang. +- **Risk: ack-and-wait discipline fails with a task attached** (model starts acting during the seed turn under `-p`'s auto permission policy). Mitigation: validated by live probe before pinning; fallback design (role-only seed, task as kick payload) is pre-planned and payload-compatible with the same delivery machinery. +- **Risk: Tower restarts during the seed window** → armed kick lost. Mitigation: documented remediation (`afx send "BEGIN"`); the sentinel re-prints on script relaunch, so a Tower that comes back before the TUI launch still arms correctly on rehydrate only if re-armed — accepted MVI limitation, documented. +- **Risk: EPIPE from the extraction pipe killing the seed mid-turn.** Mitigation: the one-liner drains stdin to EOF. +- **Risk: a stale `.builder-kimi-session` bakes a dead `-S` into the restart loop** (kimi fast-fails on unknown ids). Mitigation: `buildResume` ownership-verifies the file id before using it; the in-script seed guard only skips seeding when the file is non-empty, and a dead id there surfaces as a fast TUI exit → the restart loop's visible error, with `afx spawn --resume` (which re-verifies) as the recovery path. +- **Alternative: `--continue` for resume** — rejected: cwd-scoped and roleless on the no-session case; explicit-ID keeps the null → fresh-with-role fallback correct (spike §8 Q2). +- **Alternative: `--skills-dir` as role channel** — rejected: model-mediated (probabilistic) and replaces the user's skill dirs (spike Approach 2). +- **Alternative: ACP / `kimi server` adapter** — rejected: replaces the entire PTY/terminal model for one CLI (spike Approach 4); also fenced out by the architect. +- **Alternative: client-side (spawn.ts) BEGIN delivery** — rejected: dies with the spawn CLI process; Tower-side survives and owns the PTY stream already. + +## Test Plan + +### Unit (vitest; existing patterns; `kimiHome` fixture seam) + +- **Detection/resolution** (`harness.test.ts`): `detectHarnessFromCommand` → `'kimi'` for `kimi`, `/path/to/kimi`, `kimi --yolo`; `resolveHarness('kimi')` returns KIMI_HARNESS; `KIMI_HARNESS.buildRoleInjection` throws the builder-only error. +- **#929-class regression (required by issue; four angles)**: with `shell.builder`/`--builder-cmd` = `kimi` and a stale Claude `.jsonl` fixture for the worktree path: (a) resolved harness is kimi, not claude (config + override angles); (b) `discoverResumeSession` returns null/kimi-store results — never a Claude uuid; (c) generated launch script contains no `--resume ` and no `--append-system-prompt`; (d) `kimi` as `architectHarness` fails loudly (throw + doctor warning), never silently resolving Claude flags. +- **Discovery** (`kimi-session-discovery.test.ts`): newest-by-`updatedAt` with exact `workDir`; realpath tolerance; null on empty/missing store; ownership match/mismatch/missing-dir/malformed-`state.json`; `readKimiSessionState` happy/malformed. +- **`buildResume`**: `.builder-kimi-session` precedence; stale file id failing ownership falls through to store scan; nothing → null. +- **Script generation** (`spawn-worktree.test.ts`): fresh script has seed guard, sentinel echo, `-S` loop, `--yolo`, empty-id bailout; no positional prompt, no role flags. Resume script is seedless `-S ''` loop. Non-kimi harness scripts byte-identical to before (regression). +- **Seed-kick** (`seed-kick.test.ts`, fake timers + mock store): sentinel detected across chunked `data` events; nothing written before the sentinel (seed-window write-loss regression); grace honored; verify-success stops retries; swallowed-Enter → Enter re-send → kick re-send → loud warn sequence; sentinel timeout warns. +- **Message pacing** (`message-write` tests): `enterDelayMs` override honored on both short and paced paths; default paths unchanged. +- **Doctor**: kimi presence/minVersion gate; auth-heuristic wording (labeled heuristic, `kimi login` hint, no probe call); smoke-probe drift warning; kimi-as-architect warning branch. + +### Live demo (required before requesting dev-approval — real `kimi` 0.27.0) + +Runnable demo against a scratch workspace using the locally built CLI (builder command overridden to `kimi`), showing: + +1. **Seed-session bootstrap**: fresh spawn → seed runs (`kimi -p`, role + task briefing), session id captured from `session.resume_hint` into `.builder-kimi-session`. +2. **Sentinel-gated BEGIN**: `__CODEV_KIMI_SEED_DONE__ ` observed; kick delivered after grace; store-verified (state.json `lastPrompt`/`updatedAt` advanced); builder starts the task. +3. **`afx send` multiline**: >3-line message submits as one message with the bisected/pinned Enter delay (plus single-line and `--no-enter` spot checks). +4. **Inner-restart retention**: exit the TUI → restart loop re-enters `kimi -S ` → prior role/task context demonstrably intact. +5. Also exercised: `afx spawn --resume` after killing the terminal (explicit-ID resume), and the null-fallback (remove session file + store dir → fresh-with-role re-seed). +6. `codev doctor` output with kimi installed (presence, auth heuristic, smoke probe). + +During the demo build-out: **bisect the Enter-delay threshold** (80ms–1s) and pin the shipped value with margin; **validate the ack-and-wait-with-task seed** (else switch to the pre-planned role-only fallback). + +### Delivery mechanics (fork flow) + +Branch pushes go to the fork (`mohidmakhdoomi/codev`) via the configured pushurl. PR is cross-fork: `gh pr create -R cluesmith/codev --head mohidmakhdoomi:builder/pir-1201`, body = review file. **No self-merge** — done-state is PR open + CMAP feedback addressed/rebutted + architect notified; maintainers merge. Ask maintainers in the PR conversation to add `area/tower` to issue #1201. diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md new file mode 100644 index 000000000..0f05bd72b --- /dev/null +++ b/codev/state/pir-1201_thread.md @@ -0,0 +1,15 @@ +# Thread — pir-1201 (Support Kimi Code CLI as a builder) + +## 2026-07-18 — Plan phase + +- Spawned in PIR strict mode against issue #1201. Spike `task-Iptx` (findings + addendum + POC script) rode into the worktree from main — used as the design base. +- Architect constraints received mid-turn and folded into the plan: hard scope fence (builder MVI only — no architect parity, no ACP; write-guard is a caveat), evidence rule (documented claims cite the Kimi command reference only; store layout / `session_index.jsonl` / `resume_hint` labeled undocumented, kimi ≥ 0.27.0 pinned), fork flow (pushes land on mohidmakhdoomi/codev via per-worktree pushurl; cross-fork PR to cluesmith/codev; NO self-merge — maintainers merge), live demo required before dev-approval. +- Investigated all seams at HEAD: `harness.ts` (provider interface + #1062 claude fallthrough), `spawn-worktree.ts` script generation, `spawn.ts` `discoverResumeSession`, `message-write.ts` pacing constants, tower-routes/cron delivery paths, `createTerminal` surface (core tower-client + `handleTerminalCreate`), `claude-session-discovery.ts` (pattern for the kimi sibling), `doctor.ts` check structures. +- Key plan decisions: + - New optional `HarnessProvider.buildBuilderLaunchScript` capability — provider-owned script shape; only Kimi implements; existing harness scripts byte-identical. + - Seed-session bootstrap in the generated script (idempotent `-s` guard, seed-failure exits before the loop, sentinel re-printed on relaunch). + - Readiness barrier Tower-side (new `servers/seed-kick.ts`) armed via a `seedKick` field on createTerminal; store-verified BEGIN with Enter-resend → kick-resend → loud-warn ladder. + - `kimi-session-discovery.ts` scans the store directly (skips `session_index.jsonl` — one undocumented surface instead of two). + - NO `session` block on KIMI_HARNESS (mint-and-pin `newSessionArgs` unsatisfiable; contract generalization = stage 2). Architect use fails loudly via `buildRoleInjection` throw + doctor warning. + - Enter-delay: optional `pacing.enterDelayMs` on `writeMessageToSession`, sourced from `HarnessProvider.messagePacing`; bisect 80ms–1s live during implement. +- Plan committed at `codev/plans/1201-support-kimi-code-cli-as-a-bui.md`; sitting at plan-approval gate. From 73774d9176a6d58e594f680c56ce4fbbefa92fa5 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:06:14 -0400 Subject: [PATCH 06/43] chore(porch): 1201 plan-approval gate-requested --- codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml index 1817e7333..abac03bca 100644 --- a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -7,6 +7,7 @@ current_plan_phase: null gates: plan-approval: status: pending + requested_at: '2026-07-18T23:06:14.780Z' dev-approval: status: pending pr: @@ -15,4 +16,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-07-18T22:59:08.361Z' -updated_at: '2026-07-18T22:59:08.362Z' +updated_at: '2026-07-18T23:06:14.780Z' From fe7114ec53db604e525bfd29d1ee5ece38ecd7b9 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:13:52 -0400 Subject: [PATCH 07/43] chore(porch): 1201 plan-approval gate-approved --- .../projects/1201-support-kimi-code-cli-as-a-bui/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml index abac03bca..2e6e8892e 100644 --- a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -6,8 +6,9 @@ plan_phases: [] current_plan_phase: null gates: plan-approval: - status: pending + status: approved requested_at: '2026-07-18T23:06:14.780Z' + approved_at: '2026-07-18T23:13:52.402Z' dev-approval: status: pending pr: @@ -16,4 +17,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-07-18T22:59:08.361Z' -updated_at: '2026-07-18T23:06:14.780Z' +updated_at: '2026-07-18T23:13:52.402Z' From b26c5653ca139aa9aae3e1e1af2f5a26dcffbcff Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:14:06 -0400 Subject: [PATCH 08/43] chore(porch): 1201 implement phase-transition --- .../projects/1201-support-kimi-code-cli-as-a-bui/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml index 2e6e8892e..a3da2a8cd 100644 --- a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -1,7 +1,7 @@ id: '1201' title: support-kimi-code-cli-as-a-bui protocol: pir -phase: plan +phase: implement plan_phases: [] current_plan_phase: null gates: @@ -17,4 +17,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-07-18T22:59:08.361Z' -updated_at: '2026-07-18T23:13:52.402Z' +updated_at: '2026-07-18T23:14:06.603Z' From 2cf424c17de944cf9e8a6470f1297155d974ec5d Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:32:30 -0400 Subject: [PATCH 09/43] [PIR #1201] Kimi harness: detection, seed-session launch script, builder resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KIMI_HARNESS in harness.ts + detectHarnessFromCommand('kimi') — kills the #1062 false-Claude fallthrough; buildRoleInjection throws (builder-only) - New optional HarnessProvider.buildBuilderLaunchScript capability; Kimi generates the seed-session bootstrap script (idempotent seed via kimi -p stream-json, session.resume_hint capture, sentinel, pinned -S --yolo loop) - kimi-session-discovery.ts: store scan / ownership verify / state reader (undocumented store layout, observed on kimi 0.27.0; fail-soft) - buildResume: .builder-kimi-session precedence (ownership-verified) → store scan → null → fresh-with-role fallback - spawn-worktree branches on the capability; writes .builder-seed.txt and passes the seedKick request through createPtySession - Tests incl. the #929-class regression: kimi + stale Claude jsonl never yields --resume or --append-system-prompt Co-Authored-By: Claude Fable 5 --- .../src/agent-farm/__tests__/config.test.ts | 12 + .../__tests__/discover-resume-session.test.ts | 51 +++- .../src/agent-farm/__tests__/harness.test.ts | 195 ++++++++++++- .../__tests__/kimi-session-discovery.test.ts | 190 +++++++++++++ .../__tests__/spawn-worktree.test.ts | 122 +++++++- .../src/agent-farm/commands/spawn-worktree.ts | 111 +++++++- .../codev/src/agent-farm/utils/harness.ts | 260 ++++++++++++++++++ .../utils/kimi-session-discovery.ts | 197 +++++++++++++ 8 files changed, 1125 insertions(+), 13 deletions(-) create mode 100644 packages/codev/src/agent-farm/__tests__/kimi-session-discovery.test.ts create mode 100644 packages/codev/src/agent-farm/utils/kimi-session-discovery.ts diff --git a/packages/codev/src/agent-farm/__tests__/config.test.ts b/packages/codev/src/agent-farm/__tests__/config.test.ts index 295091e9a..8316095f4 100644 --- a/packages/codev/src/agent-farm/__tests__/config.test.ts +++ b/packages/codev/src/agent-farm/__tests__/config.test.ts @@ -139,4 +139,16 @@ describe('getArchitectHarness / getBuilderHarness override-awareness (#929)', () setCliOverrides({ builder: 'gemini' }); expect(getBuilderHarness().buildResume).toBeUndefined(); }); + + // Issue #1201, #929-class config angle: a kimi builder command must resolve + // the KIMI harness, not fall through to claude. The distinguishing + // properties: provider-owned launch script (kimi-only capability) and an + // architect-side buildRoleInjection that throws instead of emitting + // --append-system-prompt. + it('--builder-cmd kimi → kimi builder harness (provider-owned script, no claude flags)', () => { + setCliOverrides({ builder: 'kimi' }); + const harness = getBuilderHarness(); + expect(harness.buildBuilderLaunchScript).toBeDefined(); + expect(() => harness.buildRoleInjection('role', '/tmp/role.md')).toThrow(/builder shell/); + }); }); diff --git a/packages/codev/src/agent-farm/__tests__/discover-resume-session.test.ts b/packages/codev/src/agent-farm/__tests__/discover-resume-session.test.ts index e44c47e72..224c2f772 100644 --- a/packages/codev/src/agent-farm/__tests__/discover-resume-session.test.ts +++ b/packages/codev/src/agent-farm/__tests__/discover-resume-session.test.ts @@ -16,7 +16,7 @@ import { join } from 'node:path'; import { discoverResumeSession } from '../commands/spawn.js'; import { encodeClaudeProjectDir } from '../utils/claude-session-discovery.js'; -import { CLAUDE_HARNESS, CODEX_HARNESS, GEMINI_HARNESS } from '../utils/harness.js'; +import { CLAUDE_HARNESS, CODEX_HARNESS, GEMINI_HARNESS, KIMI_HARNESS } from '../utils/harness.js'; // discoverResumeSession reads from $HOME via os.homedir() through // findLatestSessionId. Override the env var for the duration of the test so @@ -118,4 +118,53 @@ describe('discoverResumeSession', () => { expect(discoverResumeSession('/some/worktree', false, CLAUDE_HARNESS)).toBeUndefined(); }); }); + + // ========================================================================= + // Issue #1201 — kimi. KIMI_HARNESS reads only the Kimi store, so a stale + // Claude jsonl for the same worktree must never surface as a kimi resume + // (the #929-class guard: `kimi --resume ` is impossible). + // KIMI_CODE_HOME (documented seam) pins the kimi store to the fixture home. + // ========================================================================= + + function pinKimiHome(kimiHome: string, fn: () => T): T { + const original = process.env.KIMI_CODE_HOME; + process.env.KIMI_CODE_HOME = kimiHome; + try { + return fn(); + } finally { + if (original === undefined) delete process.env.KIMI_CODE_HOME; + else process.env.KIMI_CODE_HOME = original; + } + } + + it('returns undefined for kimi when only a stale Claude jsonl exists (#929-class guard)', () => { + const worktree = '/Users/x/repo/.builders/pir-kimi'; + writeSession(projectsRoot, worktree, 'stale-claude-uuid', 1_700_000_000_000); + pinKimiHome(join(fakeHome, '.kimi-code'), () => { + expect(discoverResumeSession(worktree, true, KIMI_HARNESS)).toBeUndefined(); + }); + }); + + it('returns the kimi store session as -S resume when one exists for the worktree', () => { + const worktree = '/Users/x/repo/.builders/pir-kimi-2'; + // A stale Claude jsonl AND a kimi session both exist — the kimi id wins + // (and the claude uuid never appears in any form). + writeSession(projectsRoot, worktree, 'stale-claude-uuid', 1_700_000_000_000); + const kimiHome = join(fakeHome, '.kimi-code'); + const sessionDir = join(kimiHome, 'sessions', 'wd_x_000000000000', 'session_kimi-1'); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync( + join(sessionDir, 'state.json'), + JSON.stringify({ workDir: worktree, updatedAt: '2026-07-18T10:00:00Z' }), + 'utf-8', + ); + pinKimiHome(kimiHome, () => { + const resume = discoverResumeSession(worktree, true, KIMI_HARNESS); + expect(resume).toEqual({ + sessionId: 'session_kimi-1', + args: ['-S', 'session_kimi-1'], + scriptFragment: "-S 'session_kimi-1'", + }); + }); + }); }); diff --git a/packages/codev/src/agent-farm/__tests__/harness.test.ts b/packages/codev/src/agent-farm/__tests__/harness.test.ts index f01e8daa2..654ab3e57 100644 --- a/packages/codev/src/agent-farm/__tests__/harness.test.ts +++ b/packages/codev/src/agent-farm/__tests__/harness.test.ts @@ -1,9 +1,14 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { CLAUDE_HARNESS, CODEX_HARNESS, GEMINI_HARNESS, OPENCODE_HARNESS, + KIMI_HARNESS, + KIMI_SEED_SENTINEL, buildCustomHarnessProvider, validateCustomHarnessConfig, resolveHarness, @@ -386,5 +391,193 @@ describe('harness', () => { it('returns undefined for empty string', () => { expect(detectHarnessFromCommand('')).toBeUndefined(); }); + + // Issue #1201: recognizing `kimi` kills the #1062 unrecognized-command + // fallthrough to the claude harness for this CLI. + it('detects kimi', () => { + expect(detectHarnessFromCommand('kimi')).toBe('kimi'); + }); + + it('detects kimi from full path', () => { + expect(detectHarnessFromCommand('/home/user/.kimi-code/bin/kimi')).toBe('kimi'); + }); + + it('detects kimi with flags', () => { + expect(detectHarnessFromCommand('kimi --yolo')).toBe('kimi'); + }); + }); + + // =========================================================================== + // KIMI_HARNESS (Issue #1201 — builder-only, seed-session bootstrap) + // =========================================================================== + + describe('KIMI_HARNESS', () => { + it('resolveHarness("kimi") returns the kimi provider', () => { + expect(resolveHarness('kimi')).toBe(KIMI_HARNESS); + }); + + it('resolveHarness auto-detects kimi from the command string', () => { + expect(resolveHarness(undefined, undefined, 'kimi')).toBe(KIMI_HARNESS); + }); + + it('buildRoleInjection throws (kimi is builder-only — architect fence)', () => { + expect(() => KIMI_HARNESS.buildRoleInjection(ROLE_CONTENT, ROLE_FILE)).toThrow(/builder shell/); + expect(() => KIMI_HARNESS.buildRoleInjection(ROLE_CONTENT, ROLE_FILE)).toThrow(/architect/); + }); + + it('buildScriptRoleInjection is inert (role cannot ride argv)', () => { + expect(KIMI_HARNESS.buildScriptRoleInjection(ROLE_CONTENT, ROLE_FILE)).toEqual({ + fragment: '', + env: {}, + }); + }); + + // The architect stored-UUID contract needs newSessionArgs (mint-and-pin), + // which Kimi cannot satisfy — no session block means architects on kimi + // never persist/resume (they fail earlier at buildRoleInjection anyway). + it('has no session capability', () => { + expect(KIMI_HARNESS.session).toBeUndefined(); + }); + + it('declares message pacing with a longer Enter delay', () => { + expect(KIMI_HARNESS.messagePacing?.enterDelayMs).toBeGreaterThanOrEqual(1000); + }); + + describe('seedDelivery.buildSeedPrompt', () => { + const build = KIMI_HARNESS.seedDelivery!.buildSeedPrompt; + + it('role + task → ack-and-wait with BEGIN discipline, both payloads present', () => { + const prompt = build('ROLE BODY', 'TASK BODY'); + expect(prompt).toContain('Do NOT start working'); + expect(prompt).toContain('BEGIN'); + expect(prompt).toContain('=== YOUR ROLE ==='); + expect(prompt).toContain('ROLE BODY'); + expect(prompt).toContain('=== TASK BRIEFING'); + expect(prompt).toContain('TASK BODY'); + }); + + it('role only (interactive worktree mode) → waits for the user, no BEGIN protocol', () => { + const prompt = build('ROLE BODY', null); + expect(prompt).toContain('ROLE BODY'); + expect(prompt).not.toContain('BEGIN'); + expect(prompt).toContain('wait for instructions from the user'); + }); + + it('task only (no-role spawn) → BEGIN discipline without a role section', () => { + const prompt = build(null, 'TASK BODY'); + expect(prompt).toContain('TASK BODY'); + expect(prompt).toContain('BEGIN'); + expect(prompt).not.toContain('=== YOUR ROLE ==='); + }); + }); + + describe('buildBuilderLaunchScript', () => { + const ctxBase = { worktreePath: '/tmp/wt', baseCmd: 'kimi' }; + + it('fresh: seed guard + sentinel + pinned -S loop with --yolo; no role flags, no positional prompt', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + ...ctxBase, seedFile: '/tmp/wt/.builder-seed.txt', + }); + expect(script).toContain('if [ ! -s .builder-kimi-session ]'); + expect(script).toContain('--output-format stream-json'); + expect(script).toContain(`${KIMI_SEED_SENTINEL} $SID`); + expect(script).toContain('kimi --yolo -S "$SID"'); + expect(script).toContain('while true'); + // Seed failure exits BEFORE the loop — surfaced, never restart-looped. + expect(script.indexOf('exit 1')).toBeLessThan(script.indexOf('while true')); + // The #929/#1062 regression class: no claude-shaped flags, no + // positional prompt appended to the CLI. + expect(script).not.toContain('--append-system-prompt'); + expect(script).not.toContain('--resume'); + expect(script).not.toContain('.builder-prompt.txt'); + }); + + it('resume: no seed; persists the pinned id and loops -S on it', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + ...ctxBase, seedFile: null, resume: { sessionId: 'session_abc-123' }, + }); + expect(script).toContain("printf '%s' 'session_abc-123' > .builder-kimi-session"); + expect(script).toContain('kimi --yolo -S "$SID"'); + expect(script).not.toContain('stream-json'); + expect(script).not.toContain('--append-system-prompt'); + }); + + it('bare (nothing to seed): plain TUI loop', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ ...ctxBase, seedFile: null }); + expect(script).toContain('kimi --yolo'); + expect(script).not.toContain('-S'); + expect(script).not.toContain('stream-json'); + }); + + it('does not duplicate --yolo when the user already passed it', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: '/tmp/wt', baseCmd: 'kimi --yolo', seedFile: null, + }); + expect(script.match(/--yolo/g)!.length).toBeGreaterThan(0); + expect(script).not.toContain('--yolo --yolo'); + }); + }); + + describe('buildResume', () => { + let fakeHome: string; + let worktree: string; + + beforeEach(() => { + fakeHome = mkdtempSync(join(tmpdir(), 'kimi-harness-')); + worktree = join(fakeHome, 'worktree'); + mkdirSync(worktree, { recursive: true }); + }); + + afterEach(() => { + rmSync(fakeHome, { recursive: true, force: true }); + }); + + function writeStoreSession(sessionId: string, workDir: string, updatedAt: string): void { + const dir = join(fakeHome, '.kimi-code', 'sessions', 'wd_x_000000000000', sessionId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'state.json'), JSON.stringify({ workDir, updatedAt }), 'utf-8'); + } + + it('null when neither a session file nor a store match exists → fresh-with-role fallback', () => { + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })).toBeNull(); + }); + + it('prefers the ownership-verified .builder-kimi-session file', () => { + writeStoreSession('session_from-file', worktree, '2026-07-18T09:00:00Z'); + writeStoreSession('session_newer-in-store', worktree, '2026-07-18T11:00:00Z'); + writeFileSync(join(worktree, '.builder-kimi-session'), 'session_from-file\n', 'utf-8'); + const resume = KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome }); + expect(resume).toEqual({ + sessionId: 'session_from-file', + args: ['-S', 'session_from-file'], + scriptFragment: "-S 'session_from-file'", + }); + }); + + it('a stale session file (dead id) falls through to the store scan instead of resuming a dead -S', () => { + writeStoreSession('session_alive', worktree, '2026-07-18T10:00:00Z'); + writeFileSync(join(worktree, '.builder-kimi-session'), 'session_deleted-by-gc', 'utf-8'); + const resume = KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome }); + expect(resume?.sessionId).toBe('session_alive'); + }); + + it('store scan picks the newest session recorded for exactly this worktree', () => { + writeStoreSession('session_older', worktree, '2026-07-18T09:00:00Z'); + writeStoreSession('session_newest', worktree, '2026-07-18T11:00:00Z'); + writeStoreSession('session_other-dir', '/elsewhere', '2026-07-18T12:00:00Z'); + const resume = KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome }); + expect(resume?.sessionId).toBe('session_newest'); + }); + + // #929-class regression, harness angle: a stale CLAUDE jsonl for this + // worktree must never surface through the kimi harness — kimi reads + // only its own store. + it('ignores a stale Claude jsonl for the same worktree (never yields --resume )', () => { + const claudeDir = join(fakeHome, '.claude', 'projects', worktree.replace(/[/.]/g, '-')); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, 'stale-claude-uuid.jsonl'), '{}', 'utf-8'); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })).toBeNull(); + }); + }); }); }); diff --git a/packages/codev/src/agent-farm/__tests__/kimi-session-discovery.test.ts b/packages/codev/src/agent-farm/__tests__/kimi-session-discovery.test.ts new file mode 100644 index 000000000..4c1366aee --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/kimi-session-discovery.test.ts @@ -0,0 +1,190 @@ +/** + * Tests for Kimi session discovery via on-disk store introspection. + * + * Issue #1201 — Kimi Code CLI as a builder. The store layout is UNDOCUMENTED + * (observed on kimi 0.27.0): + * /sessions/wd_/session_/state.json + * with state.json carrying { workDir, updatedAt, lastPrompt }. Every function + * is fail-soft: malformed fixtures must yield null/false, never a throw. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + getKimiHome, + findLatestKimiSessionId, + verifyKimiSessionOwnership, + readKimiSessionState, + kimiStoreLayoutLooksDrifted, +} from '../utils/kimi-session-discovery.js'; + +describe('kimi session discovery', () => { + let kimiHome: string; + const opts = () => ({ kimiHome }); + + beforeEach(() => { + kimiHome = mkdtempSync(join(tmpdir(), 'kimi-store-')); + }); + + afterEach(() => { + rmSync(kimiHome, { recursive: true, force: true }); + }); + + function writeSession( + sessionId: string, + state: Record | string, + wdDir = 'wd_worktree_abc123def456', + ): string { + const dir = join(kimiHome, 'sessions', wdDir, sessionId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'state.json'), + typeof state === 'string' ? state : JSON.stringify(state), + 'utf-8', + ); + return dir; + } + + describe('getKimiHome', () => { + it('prefers the explicit kimiHome opt', () => { + expect(getKimiHome({ kimiHome: '/x/y' })).toBe('/x/y'); + }); + + it('falls back to KIMI_CODE_HOME env (documented seam)', () => { + const original = process.env.KIMI_CODE_HOME; + process.env.KIMI_CODE_HOME = '/env/kimi'; + try { + expect(getKimiHome()).toBe('/env/kimi'); + } finally { + if (original === undefined) delete process.env.KIMI_CODE_HOME; + else process.env.KIMI_CODE_HOME = original; + } + }); + }); + + describe('findLatestKimiSessionId', () => { + it('returns null on a missing store', () => { + expect(findLatestKimiSessionId('/some/worktree', opts())).toBeNull(); + }); + + it('returns null when no session matches the workDir', () => { + writeSession('session_aaa', { workDir: '/other/dir', updatedAt: '2026-07-18T10:00:00Z' }); + expect(findLatestKimiSessionId('/some/worktree', opts())).toBeNull(); + }); + + it('returns the exact-workDir match', () => { + writeSession('session_aaa', { workDir: '/some/worktree', updatedAt: '2026-07-18T10:00:00Z' }); + writeSession('session_bbb', { workDir: '/other/dir', updatedAt: '2026-07-18T12:00:00Z' }); + expect(findLatestKimiSessionId('/some/worktree', opts())).toBe('session_aaa'); + }); + + it('picks the newest by updatedAt among matches (across wd dirs)', () => { + writeSession('session_old', { workDir: '/wt', updatedAt: '2026-07-18T09:00:00Z' }, 'wd_a_111111111111'); + writeSession('session_new', { workDir: '/wt', updatedAt: '2026-07-18T11:00:00Z' }, 'wd_b_222222222222'); + writeSession('session_mid', { workDir: '/wt', updatedAt: '2026-07-18T10:00:00Z' }, 'wd_a_111111111111'); + expect(findLatestKimiSessionId('/wt', opts())).toBe('session_new'); + }); + + it('ranks sessions with a malformed updatedAt below parseable ones, but still returns a lone one', () => { + writeSession('session_broken-ts', { workDir: '/wt', updatedAt: 'not-a-date' }); + expect(findLatestKimiSessionId('/wt', opts())).toBe('session_broken-ts'); + writeSession('session_good', { workDir: '/wt', updatedAt: '2026-07-18T10:00:00Z' }); + expect(findLatestKimiSessionId('/wt', opts())).toBe('session_good'); + }); + + it('skips sessions with malformed state.json without throwing', () => { + writeSession('session_garbage', 'not json at all {'); + writeSession('session_ok', { workDir: '/wt', updatedAt: '2026-07-18T10:00:00Z' }); + expect(findLatestKimiSessionId('/wt', opts())).toBe('session_ok'); + }); + + it('matches workDir through a symlinked worktree path (realpath tolerance)', () => { + const realDir = mkdtempSync(join(tmpdir(), 'kimi-real-')); + const linkPath = join(kimiHome, 'link-to-real'); + symlinkSync(realDir, linkPath); + try { + // Kimi recorded the physical path; the caller asks with the logical one. + writeSession('session_sym', { workDir: realDir, updatedAt: '2026-07-18T10:00:00Z' }); + expect(findLatestKimiSessionId(linkPath, opts())).toBe('session_sym'); + } finally { + rmSync(realDir, { recursive: true, force: true }); + } + }); + }); + + describe('verifyKimiSessionOwnership', () => { + it('true for a session whose workDir matches exactly', () => { + writeSession('session_mine', { workDir: '/wt' }); + expect(verifyKimiSessionOwnership('session_mine', '/wt', opts())).toBe(true); + }); + + it('false on workDir mismatch (session belongs to another directory)', () => { + writeSession('session_other', { workDir: '/somewhere/else' }); + expect(verifyKimiSessionOwnership('session_other', '/wt', opts())).toBe(false); + }); + + it('false when the session dir is missing (store GC / manual deletion)', () => { + expect(verifyKimiSessionOwnership('session_gone', '/wt', opts())).toBe(false); + }); + + it('false on malformed state.json', () => { + writeSession('session_bad', '{{{'); + expect(verifyKimiSessionOwnership('session_bad', '/wt', opts())).toBe(false); + }); + + it('false for an empty session id', () => { + expect(verifyKimiSessionOwnership('', '/wt', opts())).toBe(false); + }); + }); + + describe('readKimiSessionState', () => { + it('returns workDir/updatedAt/lastPrompt for a valid session', () => { + writeSession('session_full', { + workDir: '/wt', + updatedAt: '2026-07-18T10:00:00Z', + lastPrompt: 'BEGIN', + }); + expect(readKimiSessionState('session_full', opts())).toEqual({ + workDir: '/wt', + updatedAt: '2026-07-18T10:00:00Z', + lastPrompt: 'BEGIN', + }); + }); + + it('nulls optional fields that are absent', () => { + writeSession('session_sparse', { workDir: '/wt' }); + expect(readKimiSessionState('session_sparse', opts())).toEqual({ + workDir: '/wt', + updatedAt: null, + lastPrompt: null, + }); + }); + + it('returns null for a missing session or malformed state', () => { + expect(readKimiSessionState('session_missing', opts())).toBeNull(); + writeSession('session_junk', 'nope'); + expect(readKimiSessionState('session_junk', opts())).toBeNull(); + }); + }); + + describe('kimiStoreLayoutLooksDrifted (doctor smoke probe)', () => { + it('false when the store does not exist (fresh install is not drift)', () => { + expect(kimiStoreLayoutLooksDrifted(opts())).toBe(false); + }); + + it('false when at least one session parses', () => { + writeSession('session_ok', { workDir: '/wt' }); + writeSession('session_bad', '###'); + expect(kimiStoreLayoutLooksDrifted(opts())).toBe(false); + }); + + it('true when session dirs exist but none parse (layout drift)', () => { + writeSession('session_bad1', '###'); + writeSession('session_bad2', { noWorkDirKey: true }); + expect(kimiStoreLayoutLooksDrifted(opts())).toBe(true); + }); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index 0d23c74f3..d341b2bc8 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -67,7 +67,7 @@ vi.mock('../../lib/forge.js', () => ({ })); // Mock the harness resolution to return claude harness by default -import { CLAUDE_HARNESS, OPENCODE_HARNESS } from '../utils/harness.js'; +import { CLAUDE_HARNESS, OPENCODE_HARNESS, KIMI_HARNESS } from '../utils/harness.js'; const getBuilderHarnessMock = vi.fn(() => CLAUDE_HARNESS); const getWorktreeConfigMock = vi.fn(() => ({ symlinks: [], postSpawn: [], devCommand: null, devUrls: [] })); vi.mock('../utils/config.js', () => ({ @@ -388,6 +388,126 @@ describe('spawn-worktree', () => { }); }); + // ========================================================================= + // startBuilderSession — kimi provider-owned launch shape (Issue #1201) + // + // Kimi has no role flag and no positional prompt (both exit 1). The harness + // owns the whole script: seed-session bootstrap (role+task via `kimi -p`, + // captured session id) + pinned `-S` TUI loop. The #929-class guard here: + // with the kimi harness resolved, NO generated script may contain + // --append-system-prompt, --resume, or a positional prompt. + // ========================================================================= + + describe('startBuilderSession kimi script (Issue #1201)', () => { + function findWrite(suffix: string): string | undefined { + const call = vi.mocked(writeFileSync).mock.calls.find( + c => typeof c[0] === 'string' && c[0].endsWith(suffix), + ); + return call ? (call[1] as string) : undefined; + } + + it('fresh spawn: seed bootstrap script + seed file + sentinel-gated BEGIN kick', async () => { + getBuilderHarnessMock.mockReturnValueOnce(KIMI_HARNESS); + await startBuilderSession( + { workspaceRoot: '/tmp/ws' } as any, + 'pir-k1', '/tmp/worktree', 'kimi', + 'TASK PROMPT', 'ROLE {PORT}', 'codev', + ); + + const script = findWrite('.builder-start.sh'); + expect(script).toBeDefined(); + expect(script).toContain('if [ ! -s .builder-kimi-session ]'); + expect(script).toContain('--output-format stream-json'); + expect(script).toContain('__CODEV_KIMI_SEED_DONE__ $SID'); + expect(script).toContain('kimi --yolo -S "$SID"'); + // #929/#1062 class: no claude-shaped flags, no positional prompt. + expect(script).not.toContain('--append-system-prompt'); + expect(script).not.toContain('--resume'); + expect(script).not.toContain('"$(cat \'/tmp/worktree/.builder-prompt.txt\')"'); + + // Seed file carries role (PORT-expanded) + task in the ack-and-wait wrapper. + const seed = findWrite('.builder-seed.txt'); + expect(seed).toBeDefined(); + expect(seed).toContain(`ROLE ${DEFAULT_TOWER_PORT}`); + expect(seed).toContain('TASK PROMPT'); + expect(seed).toContain('BEGIN'); + + // Reference files still written for inspection parity with other harnesses. + expect(findWrite('.builder-prompt.txt')).toBe('TASK PROMPT'); + expect(findWrite('.builder-role.md')).toContain(`ROLE ${DEFAULT_TOWER_PORT}`); + + // Tower is asked to arm the readiness-gated kick. + const createArgs = createTerminalMock.mock.calls.at(-1)![0]; + expect(createArgs.seedKick).toEqual({ + sentinel: '__CODEV_KIMI_SEED_DONE__', + message: 'BEGIN', + graceMs: 2500, + enterDelayMs: KIMI_HARNESS.messagePacing!.enterDelayMs, + verify: { kind: 'kimi-session-store', worktreePath: '/tmp/worktree' }, + }); + }); + + it('resume: provider resume script pins -S on the discovered id; no seed, no kick', async () => { + getBuilderHarnessMock.mockReturnValueOnce(KIMI_HARNESS); + await startBuilderSession( + { workspaceRoot: '/tmp/ws' } as any, + 'pir-k2', '/tmp/worktree', 'kimi', + 'PROMPT', 'ROLE', 'codev', + { sessionId: 'session_prev-1', scriptFragment: "-S 'session_prev-1'" }, + ); + + const script = findWrite('.builder-start.sh'); + expect(script).toBeDefined(); + expect(script).toContain("printf '%s' 'session_prev-1' > .builder-kimi-session"); + expect(script).toContain('kimi --yolo -S "$SID"'); + expect(script).not.toContain('stream-json'); + expect(script).not.toContain('--append-system-prompt'); + expect(script).not.toContain('--resume'); + + const createArgs = createTerminalMock.mock.calls.at(-1)![0]; + expect(createArgs.seedKick).toBeUndefined(); + }); + + it('claude spawns are unaffected: no seedKick is sent (regression)', async () => { + getBuilderHarnessMock.mockReturnValueOnce(CLAUDE_HARNESS); + await startBuilderSession( + { workspaceRoot: '/tmp/ws' } as any, + 'pir-k3', '/tmp/worktree', 'claude', + 'PROMPT', 'ROLE', 'codev', + ); + const createArgs = createTerminalMock.mock.calls.at(-1)![0]; + expect(createArgs.seedKick).toBeUndefined(); + }); + }); + + describe('buildWorktreeLaunchScript (kimi harness — interactive mode)', () => { + it('role, no prompt → seeds the role with an await-user wrapper; no BEGIN protocol', () => { + getBuilderHarnessMock.mockReturnValueOnce(KIMI_HARNESS); + const script = buildWorktreeLaunchScript( + '/tmp/worktree', 'kimi', { content: 'ROLE BODY', source: 'codev' }, '/tmp/ws', + ); + expect(script).toContain('if [ ! -s .builder-kimi-session ]'); + expect(script).toContain('kimi --yolo -S "$SID"'); + expect(script).not.toContain('--append-system-prompt'); + + const seedCall = vi.mocked(writeFileSync).mock.calls.find( + c => typeof c[0] === 'string' && c[0].endsWith('.builder-seed.txt'), + ); + expect(seedCall).toBeDefined(); + const seed = seedCall![1] as string; + expect(seed).toContain('ROLE BODY'); + expect(seed).not.toContain('BEGIN'); + }); + + it('no role, no prompt → bare TUI loop without a seed', () => { + getBuilderHarnessMock.mockReturnValueOnce(KIMI_HARNESS); + const script = buildWorktreeLaunchScript('/tmp/worktree', 'kimi', null, '/tmp/ws'); + expect(script).toContain('kimi --yolo'); + expect(script).not.toContain('stream-json'); + expect(script).not.toContain('.builder-kimi-session'); + }); + }); + // ========================================================================= // Collision Detection (unit-level) // ========================================================================= diff --git a/packages/codev/src/agent-farm/commands/spawn-worktree.ts b/packages/codev/src/agent-farm/commands/spawn-worktree.ts index 84432f3b7..f09070a01 100644 --- a/packages/codev/src/agent-farm/commands/spawn-worktree.ts +++ b/packages/codev/src/agent-farm/commands/spawn-worktree.ts @@ -19,7 +19,7 @@ import { defaultSessionOptions } from '../../terminal/index.js'; import { run, runStreaming, commandExists } from '../utils/shell.js'; import { fetchIssueOrThrow, type ForgeIssue } from '../../lib/github.js'; import { executeForgeCommand, type ForgeConfig } from '../../lib/forge.js'; -import { getTowerClient, DEFAULT_TOWER_PORT } from '../lib/tower-client.js'; +import { getTowerClient, DEFAULT_TOWER_PORT, type SeedKickRequest } from '../lib/tower-client.js'; // ============================================================================= // Dependency Checks @@ -650,6 +650,7 @@ export async function createPtySession( roleId: string; label?: string; }, + seedKick?: SeedKickRequest, ): Promise<{ terminalId: string }> { const { cols, rows } = defaultSessionOptions(); const client = getTowerClient(); @@ -660,6 +661,7 @@ export async function createPtySession( type: registration?.type, roleId: registration?.roleId, label: registration?.label, + seedKick, }); if (!terminal) { @@ -732,6 +734,75 @@ function installHarnessWorktreeFiles( } } +/** + * Build the launch script via the harness's provider-owned shape (Issue + * #1201 — currently only Kimi implements `buildBuilderLaunchScript`). + * + * Fresh paths write the same reference files as the generic shapes + * (.builder-prompt.txt, .builder-role.md) plus `.builder-seed.txt` — the + * seed-turn payload composed by the harness (role and/or task briefing in an + * ack-and-wait wrapper), since seed-style CLIs cannot take either via argv. + * When there is an initial task prompt, the returned `seedKick` asks Tower to + * deliver the harness's kick message (e.g. 'BEGIN') once the launch script's + * seed sentinel appears — writes into the PTY during the seed window are + * silently lost, so the kick must be readiness-gated Tower-side. + */ +function buildProviderOwnedScript( + harness: HarnessProvider, + worktreePath: string, + baseCmd: string, + prompt: string | null, + roleContent: string | null, + roleSource: string | null, + resume?: { sessionId: string }, +): { scriptContent: string; seedKick?: SeedKickRequest } { + const build = harness.buildBuilderLaunchScript!; + + if (resume) { + // Prior conversation already contains role + task context. + logger.info(`Resuming session ${resume.sessionId.slice(0, 8)}…`); + return { + scriptContent: build({ + worktreePath, baseCmd, seedFile: null, + resume: { sessionId: resume.sessionId }, + }), + }; + } + + if (prompt) { + writeFileSync(resolve(worktreePath, '.builder-prompt.txt'), prompt); + } + + let roleWithPort: string | null = null; + let roleFile = ''; + if (roleContent) { + roleWithPort = roleContent.replace(/\{PORT\}/g, String(DEFAULT_TOWER_PORT)); + roleFile = resolve(worktreePath, '.builder-role.md'); + writeFileSync(roleFile, roleWithPort); + logger.info(`Loaded role (${roleSource})`); + } + + installHarnessWorktreeFiles(harness, roleWithPort ?? '', roleFile, worktreePath); + + let seedFile: string | null = null; + let seedKick: SeedKickRequest | undefined; + if (harness.seedDelivery && (roleWithPort || prompt)) { + seedFile = resolve(worktreePath, '.builder-seed.txt'); + writeFileSync(seedFile, harness.seedDelivery.buildSeedPrompt(roleWithPort, prompt || null)); + if (prompt) { + seedKick = { + sentinel: harness.seedDelivery.sentinelPrefix, + message: harness.seedDelivery.kickMessage, + graceMs: harness.seedDelivery.graceMs, + enterDelayMs: harness.messagePacing?.enterDelayMs, + verify: { kind: 'kimi-session-store', worktreePath }, + }; + } + } + + return { scriptContent: build({ worktreePath, baseCmd, seedFile }), seedKick }; +} + /** * Start a terminal session for a builder. * @@ -739,9 +810,9 @@ function installHarnessWorktreeFiles( * form (e.g. `claude --resume `) via the pre-escaped `scriptFragment` * instead of a fresh prompt+role invocation. The saved conversation contains * the system prompt / role context already, so role injection and the initial - * prompt are intentionally skipped on that path. Only the Claude harness - * produces a resume object (Issue #929); codex/gemini pass `undefined` here - * and take the fresh role-injection path. + * prompt are intentionally skipped on that path. Only the Claude and Kimi + * harnesses produce a resume object (Issues #929, #1201); codex/gemini pass + * `undefined` here and take the fresh role-injection path. */ export async function startBuilderSession( config: Config, @@ -757,8 +828,17 @@ export async function startBuilderSession( const scriptPath = resolve(worktreePath, '.builder-start.sh'); let scriptContent: string; - - if (resume) { + let seedKick: SeedKickRequest | undefined; + + const sessionHarness = getBuilderHarness(config.workspaceRoot); + if (sessionHarness.buildBuilderLaunchScript) { + // Provider-owned launch shape (Issue #1201 — Kimi): the harness generates + // the entire script (seed bootstrap / pinned-id loop); no role flags, no + // positional prompt. + ({ scriptContent, seedKick } = buildProviderOwnedScript( + sessionHarness, worktreePath, baseCmd, prompt, roleContent, roleSource, resume, + )); + } else if (resume) { // Resume path: load the prior conversation via the harness-provided, // shell-escaped resume fragment. No prompt file, no role injection — both // are already part of the saved conversation. @@ -786,7 +866,7 @@ done logger.info(`Loaded role (${roleSource})`); // Resolve harness provider for role injection - const harness = getBuilderHarness(config.workspaceRoot); + const harness = sessionHarness; const { fragment, env } = harness.buildScriptRoleInjection(roleWithPort, roleFile); const envExports = Object.entries(env) .map(([k, v]) => `export ${k}='${shellEscapeSingleQuote(v)}'`) @@ -813,7 +893,7 @@ done // Install harness worktree files even without a role, so the write-guard // (Issue #1018) is deterministic across all Claude spawn modes. - installHarnessWorktreeFiles(getBuilderHarness(config.workspaceRoot), '', '', worktreePath); + installHarnessWorktreeFiles(sessionHarness, '', '', worktreePath); scriptContent = `#!/bin/bash cd "${worktreePath}" @@ -837,6 +917,7 @@ done [scriptPath], worktreePath, { workspacePath: config.workspaceRoot, type: 'builder', roleId: builderId }, + seedKick, ); logger.info(`Terminal session created: ${terminalId}`); return { terminalId }; @@ -872,6 +953,16 @@ export function buildWorktreeLaunchScript( role: { content: string; source: string } | null, workspaceRoot?: string, ): string { + const worktreeHarness = getBuilderHarness(workspaceRoot); + if (worktreeHarness.buildBuilderLaunchScript) { + // Provider-owned launch shape (Issue #1201 — Kimi). Interactive worktree + // mode has no initial prompt, so no seed kick is armed: the seed wrapper + // tells the agent to await instructions typed in the session. + const { scriptContent } = buildProviderOwnedScript( + worktreeHarness, worktreePath, baseCmd, null, role?.content ?? null, role?.source ?? null, + ); + return scriptContent; + } if (role) { const roleFile = resolve(worktreePath, '.builder-role.md'); const roleWithPort = role.content.replace(/\{PORT\}/g, String(DEFAULT_TOWER_PORT)); @@ -879,7 +970,7 @@ export function buildWorktreeLaunchScript( logger.info(`Loaded role (${role.source})`); // Resolve harness provider for role injection - const harness = getBuilderHarness(workspaceRoot); + const harness = worktreeHarness; const { fragment, env } = harness.buildScriptRoleInjection(roleWithPort, roleFile); const envExports = Object.entries(env) .map(([k, v]) => `export ${k}='${shellEscapeSingleQuote(v)}'`) @@ -902,7 +993,7 @@ done } // Install harness worktree files even without a role, so the write-guard // (Issue #1018) is deterministic across all Claude spawn modes. - installHarnessWorktreeFiles(getBuilderHarness(workspaceRoot), '', '', worktreePath); + installHarnessWorktreeFiles(worktreeHarness, '', '', worktreePath); return `#!/bin/bash cd "${worktreePath}" while true; do diff --git a/packages/codev/src/agent-farm/utils/harness.ts b/packages/codev/src/agent-farm/utils/harness.ts index 319bb4bdb..b896edcf9 100644 --- a/packages/codev/src/agent-farm/utils/harness.ts +++ b/packages/codev/src/agent-farm/utils/harness.ts @@ -12,13 +12,40 @@ * @see codev/specs/591-af-workspace-failure-with-code.md */ +import { join } from 'node:path'; +import { readFileSync } from 'node:fs'; import { findLatestSessionId, verifySessionOwnership } from './claude-session-discovery.js'; +import { + findLatestKimiSessionId, + verifyKimiSessionOwnership, + type KimiDiscoveryOpts, +} from './kimi-session-discovery.js'; import { buildWorktreeGuardFiles } from './worktree-write-guard.js'; // ============================================================================= // Types // ============================================================================= +/** + * Context for provider-owned builder launch scripts (Issue #1201). + * Only harnesses whose CLI cannot take a role/prompt via argv implement + * `buildBuilderLaunchScript` (currently Kimi); flag-shaped harnesses keep the + * generic scripts in spawn-worktree.ts. + */ +export interface BuilderLaunchScriptContext { + worktreePath: string; + /** The resolved builder command string (may include user flags). */ + baseCmd: string; + /** + * Absolute path to the seed-prompt file (.builder-seed.txt) written by the + * caller from `seedDelivery.buildSeedPrompt(...)`. Null on paths with + * nothing to seed (no role, no prompt) and on resume. + */ + seedFile: string | null; + /** Present on the resume path: relaunch pinned to this prior session. */ + resume?: { sessionId: string }; +} + export interface HarnessProvider { /** * For Node spawn() call sites (architect.ts, tower-utils.ts). @@ -101,6 +128,45 @@ export interface HarnessProvider { args: string[]; scriptFragment: string; } | null; + + /** + * Optional: provider-owned builder launch script (Issue #1201). When + * present, spawn-worktree.ts uses this INSTEAD of the generic + * `${baseCmd} ${roleFragment} ""` script shapes — for CLIs with no + * role flag and no positional prompt (Kimi), where the whole launch shape + * (seed-session bootstrap + pinned-id TUI loop) belongs to the provider. + */ + buildBuilderLaunchScript?(ctx: BuilderLaunchScriptContext): string; + + /** + * Optional: seed-session delivery metadata (Issue #1201), consumed by + * spawn-worktree.ts (seed-prompt file) and Tower's seed-kick module + * (readiness barrier). Only meaningful alongside buildBuilderLaunchScript. + * + * The generated script prints ` ` on its own + * line after the seed completes and before the interactive TUI starts. + * Tower gates any first-message delivery on that sentinel: bytes written to + * the PTY during the seed window have no defined consumer (observed: they + * are silently lost), so an ungated write would drop the task kick. + */ + seedDelivery?: { + sentinelPrefix: string; + /** Single-line kick delivered after the sentinel + grace (e.g. 'BEGIN'). */ + kickMessage: string; + /** Post-sentinel grace before writing the kick (composer warm-up). */ + graceMs: number; + /** Compose the seed-turn prompt from role and/or initial task prompt. */ + buildSeedPrompt(roleContent: string | null, taskPrompt: string | null): string; + }; + + /** + * Optional: PTY message pacing for this harness's CLI (Issue #1201). + * `enterDelayMs` overrides message-write.ts's default delayed-Enter timing — + * CLIs with a longer paste-detection window (Kimi) silently swallow an + * Enter that arrives too soon after the message body, so `afx send` never + * submits without this. + */ + messagePacing?: { enterDelayMs: number }; } /** Custom harness definition from .codev/config.json */ @@ -186,11 +252,204 @@ export const OPENCODE_HARNESS: HarnessProvider = { }]), }; +// ============================================================================= +// Kimi (Issue #1201 — builder-only) +// ============================================================================= + +/** + * Sentinel printed by the generated Kimi launch script between seed completion + * and TUI start. Tower's seed-kick module gates first-message delivery on it. + */ +export const KIMI_SEED_SENTINEL = '__CODEV_KIMI_SEED_DONE__'; + +/** File in the worktree persisting the seeded Kimi session id. */ +export const KIMI_SESSION_FILE = '.builder-kimi-session'; + +/** + * Delayed-Enter timing for Kimi PTYs. Kimi's paste-detection window is longer + * than Claude's: an Enter 80ms after the message body is treated as part of a + * paste and NOT submitted; 1s works (observed, kimi 0.27.0 — bisected during + * PIR #1201's live validation). Applied via messagePacing below. + */ +export const KIMI_ENTER_DELAY_MS = 1000; + +/** Map the shared `homeDir` test-seam option onto the Kimi store location. */ +function kimiOpts(opts?: { homeDir?: string }): KimiDiscoveryOpts | undefined { + return opts?.homeDir ? { kimiHome: join(opts.homeDir, '.kimi-code') } : undefined; +} + +/** + * Compose the seed-turn prompt: role and/or task briefing wrapped in an + * ack-and-wait discipline. The role rides a USER turn, not a system prompt + * (Kimi documents no system-prompt flag) — the same tradeoff that deferred + * agy as an architect (#1063). Validated end-to-end in spike task-Iptx. + */ +function buildKimiSeedPrompt(roleContent: string | null, taskPrompt: string | null): string { + const waitInstruction = taskPrompt + ? '- Reply with exactly "ROLE-OK", then wait. You will receive a message "BEGIN" in a later turn — only then start working on the task briefing, following your role.' + : '- Reply with exactly "ROLE-OK", then wait for instructions from the user in the interactive session.'; + const parts: string[] = [ + 'You are being initialized as an autonomous agent inside a project worktree.', + 'This initialization turn delivers your ROLE and TASK BRIEFING. Strict discipline for THIS turn:', + '- Do NOT start working yet. Do NOT use any tools. Do NOT read or write files.', + '- Internalize everything below; it governs the rest of this session.', + waitInstruction, + ]; + if (roleContent) { + parts.push('', '=== YOUR ROLE ===', roleContent); + } + if (taskPrompt) { + parts.push('', '=== TASK BRIEFING (do not act until BEGIN) ===', taskPrompt); + } + return parts.join('\n'); +} + +/** + * Append --yolo (auto-approve tools; the Kimi analog of + * `claude --dangerously-skip-permissions`) unless the user already passed it. + * `--auto` is deliberately NOT used: it suppresses agent→user questions, which + * the gate/Q&A workflow depends on, and it conflicts with --yolo (documented). + */ +function kimiTuiCmd(baseCmd: string): string { + return baseCmd.includes('--yolo') ? baseCmd : `${baseCmd} --yolo`; +} + +/** + * Extract the seeded session id from `kimi -p … --output-format stream-json` + * stdout: the machine-readable `session.resume_hint` meta line (UNDOCUMENTED, + * observed on kimi 0.27.0 — see kimi-session-discovery.ts header). Reads stdin + * to EOF before printing so the pipe never closes early (an early exit would + * EPIPE the seed process mid-turn). Exits 1 when no hint line is found — the + * session file ends up empty and the script's empty-id bailout fires. + */ +const KIMI_SEED_EXTRACTOR = + 'let b="";process.stdin.on("data",d=>b+=d);process.stdin.on("end",()=>{' + + 'for(const l of b.split("\\n")){try{const o=JSON.parse(l);' + + 'if(o&&o.type==="session.resume_hint"&&typeof o.session_id==="string"){process.stdout.write(o.session_id);return}}catch{}}' + + 'process.exit(1)})'; + +export const KIMI_HARNESS: HarnessProvider = { + buildRoleInjection: () => { + throw new Error( + 'Kimi is only supported as a builder shell, not as an architect shell ' + + '(stage 2 — see issue #1201). Kimi has no documented system-prompt flag; ' + + 'builder role injection uses a seed-session bootstrap owned by the builder ' + + 'launch script. Configure a different shell for the architect ' + + '(e.g., "claude --dangerously-skip-permissions" or "codex").', + ); + }, + // Role cannot ride argv (no role flag, no positional prompt — both exit 1, + // observed). The real shape is provider-owned via buildBuilderLaunchScript. + buildScriptRoleInjection: () => ({ fragment: '', env: {} }), + + // Builder resume (afx spawn --resume): prefer the id persisted by the launch + // script, ownership-verified so a stale id (store GC, manual deletion) falls + // through instead of baking a fast-failing `-S ` into the restart + // loop; else newest store session recorded for exactly this worktree; else + // null → callers take the fresh-with-role seed path (never a roleless fresh + // session — the reason explicit-ID is preferred over cwd-scoped --continue). + buildResume: (absolutePath, opts) => { + const kOpts = kimiOpts(opts); + let sessionId: string | null = null; + try { + const persisted = readFileSync(join(absolutePath, KIMI_SESSION_FILE), 'utf-8').trim(); + if (persisted && verifyKimiSessionOwnership(persisted, absolutePath, kOpts)) { + sessionId = persisted; + } + } catch { + // No persisted session file — fall through to the store scan + } + if (!sessionId) { + sessionId = findLatestKimiSessionId(absolutePath, kOpts); + } + if (!sessionId) return null; + return { + sessionId, + args: ['-S', sessionId], + scriptFragment: `-S '${shellEscapeSingleQuote(sessionId)}'`, + }; + }, + + buildBuilderLaunchScript: (ctx) => { + const tuiCmd = kimiTuiCmd(ctx.baseCmd); + const loop = `while true; do + ${tuiCmd} -S "$SID" + echo "" + echo "Agent exited. Restarting in 2 seconds... (Ctrl+C to quit)" + sleep 2 +done +`; + + if (ctx.resume) { + // Resume path: pinned prior session, no seed. Re-persist the id so the + // session file regains precedence for the next resume (it may be absent + // when the id came from a store scan) and so the file keeps serving as + // the Kimi marker for message pacing. + const escapedId = shellEscapeSingleQuote(ctx.resume.sessionId); + return `#!/bin/bash +cd "${ctx.worktreePath}" +printf '%s' '${escapedId}' > ${KIMI_SESSION_FILE} +SID='${escapedId}' +echo "${KIMI_SEED_SENTINEL} $SID" +${loop}`; + } + + if (ctx.seedFile) { + // Fresh path: seed-session bootstrap (spike task-Iptx, POC 6). The seed + // turn carries the role/task briefing; its captured session id pins the + // TUI loop, so context survives inner restarts. The `-s` guard makes the + // seed idempotent across script relaunches; a failed seed (auth, + // network, no resume_hint) leaves the file empty and exits BEFORE the + // loop — surfaced once, never restart-looped. + return `#!/bin/bash +cd "${ctx.worktreePath}" +if [ ! -s ${KIMI_SESSION_FILE} ]; then + echo "Seeding Kimi session (role/task briefing via kimi -p)..." + ${ctx.baseCmd} -p "$(cat '${shellEscapeSingleQuote(ctx.seedFile)}')" --output-format stream-json \\ + | node -e '${KIMI_SEED_EXTRACTOR}' > ${KIMI_SESSION_FILE} + echo "" +fi +SID="$(cat ${KIMI_SESSION_FILE} 2>/dev/null)" +if [ -z "$SID" ]; then + echo "ERROR: Kimi seed failed — no session id captured." >&2 + echo "Check authentication (kimi login) and network, then relaunch this terminal." >&2 + rm -f ${KIMI_SESSION_FILE} + exit 1 +fi +echo "${KIMI_SEED_SENTINEL} $SID" +${loop}`; + } + + // Nothing to seed (no role, no prompt): plain TUI loop. No session + // pinning — restarts start fresh, matching the bare-mode behavior of + // other harnesses. + return `#!/bin/bash +cd "${ctx.worktreePath}" +while true; do + ${tuiCmd} + echo "" + echo "Agent exited. Restarting in 2 seconds... (Ctrl+C to quit)" + sleep 2 +done +`; + }, + + seedDelivery: { + sentinelPrefix: KIMI_SEED_SENTINEL, + kickMessage: 'BEGIN', + graceMs: 2500, + buildSeedPrompt: buildKimiSeedPrompt, + }, + + messagePacing: { enterDelayMs: KIMI_ENTER_DELAY_MS }, +}; + const BUILTIN_HARNESSES: Record = { claude: CLAUDE_HARNESS, codex: CODEX_HARNESS, gemini: GEMINI_HARNESS, opencode: OPENCODE_HARNESS, + kimi: KIMI_HARNESS, }; // ============================================================================= @@ -317,6 +576,7 @@ export function detectHarnessFromCommand(command: string): string | undefined { if (basename.includes('codex')) return 'codex'; if (basename.includes('gemini')) return 'gemini'; if (basename.includes('opencode')) return 'opencode'; + if (basename.includes('kimi')) return 'kimi'; return undefined; } diff --git a/packages/codev/src/agent-farm/utils/kimi-session-discovery.ts b/packages/codev/src/agent-farm/utils/kimi-session-discovery.ts new file mode 100644 index 000000000..dd02ddb03 --- /dev/null +++ b/packages/codev/src/agent-farm/utils/kimi-session-discovery.ts @@ -0,0 +1,197 @@ +// Discover Kimi Code CLI sessions for a given working directory by inspecting +// Kimi's on-disk session store. +// +// ⚠ UNDOCUMENTED SURFACE. Kimi's command reference +// (https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-command.html) +// documents the KIMI_CODE_HOME env var but NOT the store layout beneath it. +// Everything below is observed behavior against kimi 0.27.0 (spike task-Iptx): +// +// /sessions/wd__<12hex>/session_/state.json +// state.json: { createdAt, updatedAt, workDir, lastPrompt?, title, ... } +// +// `workDir` records the session's exact cwd (stronger than Claude's +// encoded-path store — no encoding ambiguity). Session ids are the +// `session_` directory basenames, and match the `session_id` field of +// the `session.resume_hint` stream-json meta line emitted by `kimi -p +// --output-format stream-json`. +// +// Because the layout is undocumented, every function here is fail-soft: +// missing dirs, unreadable files, and malformed JSON yield null/false, never a +// throw. `codev doctor` carries a session-store smoke probe (and a kimi +// >= 0.27.0 version pin) to surface layout drift loudly instead. +// +// The intentionally omitted surface: `session_index.jsonl` (a global id → +// dir/workDir index). The directory scan below is the ground truth the index +// mirrors; reading only the tree keeps us on one undocumented surface, not two. + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { realpathSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export interface KimiSessionState { + workDir: string; + updatedAt: string | null; + lastPrompt: string | null; +} + +export interface KimiDiscoveryOpts { + /** Test seam: overrides both KIMI_CODE_HOME and ~/.kimi-code. */ + kimiHome?: string; +} + +/** + * Resolve the Kimi home directory. KIMI_CODE_HOME is documented (for `kimi + * doctor`) and honored by the CLI itself, so we honor it too; `opts.kimiHome` + * lets tests pin a fixture store without touching the environment. + */ +export function getKimiHome(opts?: KimiDiscoveryOpts): string { + return opts?.kimiHome ?? process.env.KIMI_CODE_HOME ?? join(homedir(), '.kimi-code'); +} + +/** Canonicalize a path for comparison; fall back to the input when realpath fails. */ +function realpathOrSelf(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +/** + * Two paths refer to the same directory if they match in either logical or + * physical (symlink-resolved) form — Kimi records its process cwd, which the + * OS may report physically (e.g. /tmp vs /private/tmp on macOS). + */ +function sameDir(a: string, b: string): boolean { + if (a === b) return true; + return realpathOrSelf(a) === realpathOrSelf(b); +} + +/** Read and parse a session directory's state.json. Fail-soft: null on any error. */ +function readStateJson(sessionDir: string): KimiSessionState | null { + try { + const raw = readFileSync(join(sessionDir, 'state.json'), 'utf-8'); + const parsed = JSON.parse(raw) as Record; + if (typeof parsed.workDir !== 'string') return null; + return { + workDir: parsed.workDir, + updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : null, + lastPrompt: typeof parsed.lastPrompt === 'string' ? parsed.lastPrompt : null, + }; + } catch { + return null; + } +} + +/** + * Iterate every session directory in the store, yielding + * { sessionId, sessionDir }. Session dirs live two levels down + * (sessions//); we accept any directory names to + * stay resilient to hash-scheme changes — state.json parsing is the filter. + */ +function* iterateSessionDirs(kimiHome: string): Generator<{ sessionId: string; sessionDir: string }> { + const sessionsRoot = join(kimiHome, 'sessions'); + let wdDirs: string[]; + try { + wdDirs = readdirSync(sessionsRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { + return; + } + for (const wd of wdDirs) { + let sessionDirs: string[]; + try { + sessionDirs = readdirSync(join(sessionsRoot, wd), { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { + continue; + } + for (const name of sessionDirs) { + yield { sessionId: name, sessionDir: join(sessionsRoot, wd, name) }; + } + } +} + +/** + * Return the session id of the most recent Kimi session whose recorded + * `workDir` is exactly `absolutePath` (realpath-tolerant), or null when none + * exists. "Most recent" = max `updatedAt` (ISO timestamp, observed); sessions + * with an unparseable `updatedAt` rank oldest. + */ +export function findLatestKimiSessionId( + absolutePath: string, + opts?: KimiDiscoveryOpts, +): string | null { + const home = getKimiHome(opts); + let bestId: string | null = null; + let bestTime = -Infinity; + + for (const { sessionId, sessionDir } of iterateSessionDirs(home)) { + const state = readStateJson(sessionDir); + if (!state || !sameDir(state.workDir, absolutePath)) continue; + const time = state.updatedAt ? Date.parse(state.updatedAt) : NaN; + // Unparseable timestamps rank below every real epoch (>= 0) but above the + // initial -Infinity sentinel, so a lone malformed match is still returned. + const rank = Number.isNaN(time) ? -1 : time; + if (rank > bestTime) { + bestTime = rank; + bestId = sessionId; + } + } + return bestId; +} + +/** + * Verify that `sessionId` still has a session on disk whose recorded `workDir` + * is `cwd` (Issue #1145 semantics, Kimi flavor — exact-path match, stronger + * than Claude's encoded-dir existence check). A stale id (store GC, manual + * deletion) fails here and callers degrade to a fresh role-injecting spawn + * instead of baking a fast-failing `kimi -S ` into a restart loop. + */ +export function verifyKimiSessionOwnership( + sessionId: string, + cwd: string, + opts?: KimiDiscoveryOpts, +): boolean { + const state = readKimiSessionState(sessionId, opts); + return state !== null && sameDir(state.workDir, cwd); +} + +/** + * Read the state.json of a session by id, or null when the session (or a + * parseable state.json) doesn't exist. Used by the seed-kick verifier + * (`lastPrompt`/`updatedAt` advance when a message submits — observed) and by + * doctor's session-store smoke probe. + */ +export function readKimiSessionState( + sessionId: string, + opts?: KimiDiscoveryOpts, +): KimiSessionState | null { + if (!sessionId) return null; + const home = getKimiHome(opts); + for (const entry of iterateSessionDirs(home)) { + if (entry.sessionId === sessionId) { + return readStateJson(entry.sessionDir); + } + } + return null; +} + +/** + * True when the store root exists but no session directory yields a parseable + * state.json with a `workDir` — the layout-drift signal doctor's smoke probe + * warns on. A missing/empty store is NOT drift (fresh install). + */ +export function kimiStoreLayoutLooksDrifted(opts?: KimiDiscoveryOpts): boolean { + const home = getKimiHome(opts); + if (!existsSync(join(home, 'sessions'))) return false; + let sawSessionDir = false; + for (const { sessionDir } of iterateSessionDirs(home)) { + sawSessionDir = true; + if (readStateJson(sessionDir) !== null) return false; + } + return sawSessionDir; +} From 8e86c4114d782501380d4bdd79b59418f1bf15ea Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:32:38 -0400 Subject: [PATCH 10/43] [PIR #1201] Tower: sentinel-gated BEGIN delivery + per-harness Enter pacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - seed-kick.ts: readiness barrier — waits for the launch script's __CODEV_KIMI_SEED_DONE__ sentinel (writes during the seed window are silently lost), grace, then a store-verified BEGIN kick with an Enter-resend → kick-resend → loud-warn retry ladder - createTerminal grows an optional seedKick field (core SeedKickRequest); handleTerminalCreate validates and arms it (malformed → ignored) - message-write.ts: optional pacing.enterDelayMs overriding both default Enter delays (Kimi swallows an 80ms Enter; defaults unchanged otherwise) - message-pacing.ts: resolves pacing per target — worktree marker probe first (override-proof for --builder-cmd spawns, survives Tower restarts), then config-resolved harness by terminal role - Wired at all delivery paths: send direct + buffered, cron Co-Authored-By: Claude Fable 5 --- .../bugfix-584-send-multiline-pacing.test.ts | 52 +++++ .../codev/src/agent-farm/lib/tower-client.ts | 1 + .../servers/__tests__/message-pacing.test.ts | 94 +++++++++ .../servers/__tests__/seed-kick.test.ts | 199 ++++++++++++++++++ .../src/agent-farm/servers/message-pacing.ts | 53 +++++ .../src/agent-farm/servers/message-write.ts | 18 +- .../codev/src/agent-farm/servers/seed-kick.ts | 194 +++++++++++++++++ .../src/agent-farm/servers/tower-cron.ts | 4 +- .../src/agent-farm/servers/tower-routes.ts | 22 +- packages/core/src/tower-client.ts | 24 +++ 10 files changed, 656 insertions(+), 5 deletions(-) create mode 100644 packages/codev/src/agent-farm/servers/__tests__/message-pacing.test.ts create mode 100644 packages/codev/src/agent-farm/servers/__tests__/seed-kick.test.ts create mode 100644 packages/codev/src/agent-farm/servers/message-pacing.ts create mode 100644 packages/codev/src/agent-farm/servers/seed-kick.ts diff --git a/packages/codev/src/agent-farm/__tests__/bugfix-584-send-multiline-pacing.test.ts b/packages/codev/src/agent-farm/__tests__/bugfix-584-send-multiline-pacing.test.ts index aa37942b3..982a876e6 100644 --- a/packages/codev/src/agent-farm/__tests__/bugfix-584-send-multiline-pacing.test.ts +++ b/packages/codev/src/agent-farm/__tests__/bugfix-584-send-multiline-pacing.test.ts @@ -186,4 +186,56 @@ describe('writeMessageToSession (Bugfix #584)', () => { expect(enterCount).toBe(2); }); }); + + // ========================================================================= + // Issue #1201 — per-harness Enter-delay override. Kimi's paste-detection + // window outlasts the 50/80ms defaults (an 80ms Enter is swallowed; 1s + // submits — observed), so callers pass pacing.enterDelayMs for kimi targets. + // ========================================================================= + + describe('per-harness enterDelayMs override (Issue #1201)', () => { + it('short message: Enter waits for the overridden delay', () => { + const session = makeSession(); + const msg = 'BEGIN'; + + const endTime = writeMessageToSession(session, msg, false, 0, { enterDelayMs: 1000 }); + expect(endTime).toBe(1000); + + // Default delay elapses — Enter must NOT have fired yet. + vi.advanceTimersByTime(50); + expect(session.writeCalls).toEqual([msg]); + + vi.advanceTimersByTime(950); + expect(session.writeCalls).toEqual([msg, '\r']); + }); + + it('multi-line message: final Enter waits for the overridden delay after the last line', () => { + const session = makeSession(); + const msg = 'line1\nline2\nline3\nline4'; + + const endTime = writeMessageToSession(session, msg, false, 0, { enterDelayMs: 1000 }); + // Last line lands at 3 * 10ms; Enter at lastLine + 1000. + expect(endTime).toBe(30 + 1000); + + vi.advanceTimersByTime(30 + 80); + expect(session.writeCalls).not.toContain('\r'); + + vi.advanceTimersByTime(1000 - 80); + expect(session.writeCalls).toContain('\r'); + }); + + it('no pacing argument → default delays unchanged (regression)', () => { + const session = makeSession(); + expect(writeMessageToSession(session, 'hi', false)).toBe(50); + const paced = makeSession(); + expect(writeMessageToSession(paced, 'a\nb\nc\nd', false)).toBe(30 + 80); + }); + + it('noEnter suppresses the Enter even with an override', () => { + const session = makeSession(); + writeMessageToSession(session, 'BEGIN', true, 0, { enterDelayMs: 1000 }); + vi.advanceTimersByTime(5000); + expect(session.writeCalls).toEqual(['BEGIN']); + }); + }); }); diff --git a/packages/codev/src/agent-farm/lib/tower-client.ts b/packages/codev/src/agent-farm/lib/tower-client.ts index bc4efc443..a64b6b670 100644 --- a/packages/codev/src/agent-farm/lib/tower-client.ts +++ b/packages/codev/src/agent-farm/lib/tower-client.ts @@ -15,6 +15,7 @@ export { type TowerTunnelStatus, type TowerStatus, type TowerTerminal, + type SeedKickRequest, } from '@cluesmith/codev-core/tower-client'; export { encodeWorkspacePath, decodeWorkspacePath } from '@cluesmith/codev-core/workspace'; diff --git a/packages/codev/src/agent-farm/servers/__tests__/message-pacing.test.ts b/packages/codev/src/agent-farm/servers/__tests__/message-pacing.test.ts new file mode 100644 index 000000000..6785bacd3 --- /dev/null +++ b/packages/codev/src/agent-farm/servers/__tests__/message-pacing.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for per-harness message-pacing resolution (Issue #1201). + * + * The marker-file probe is the load-bearing design point: it makes pacing + * correct even for builders spawned with a per-spawn `--builder-cmd kimi` + * override (which workspace config knows nothing about) and survives Tower + * restarts, because the marker lives in the worktree next to the session. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { KIMI_HARNESS, CLAUDE_HARNESS } from '../../utils/harness.js'; + +const existsSyncMock = vi.hoisted(() => vi.fn(() => false)); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: existsSyncMock }; +}); + +const getTerminalSessionByIdMock = vi.hoisted(() => vi.fn(() => null as unknown)); +vi.mock('../tower-terminals.js', () => ({ + getTerminalSessionById: getTerminalSessionByIdMock, +})); + +const getBuilderHarnessMock = vi.hoisted(() => vi.fn()); +const getArchitectHarnessMock = vi.hoisted(() => vi.fn()); +vi.mock('../../utils/config.js', () => ({ + getBuilderHarness: getBuilderHarnessMock, + getArchitectHarness: getArchitectHarnessMock, +})); + +import { resolvePacingForSession } from '../message-pacing.js'; + +describe('resolvePacingForSession', () => { + beforeEach(() => { + existsSyncMock.mockReset().mockReturnValue(false); + getTerminalSessionByIdMock.mockReset().mockReturnValue(null); + getBuilderHarnessMock.mockReset().mockReturnValue(CLAUDE_HARNESS); + getArchitectHarnessMock.mockReset().mockReturnValue(CLAUDE_HARNESS); + }); + + it('kimi marker in the session cwd → kimi pacing, without consulting config (override-proof)', () => { + existsSyncMock.mockImplementation((p: unknown) => + typeof p === 'string' && p.endsWith('/wt/.builder-kimi-session')); + const pacing = resolvePacingForSession({ id: 't1', cwd: '/wt' }); + expect(pacing).toBe(KIMI_HARNESS.messagePacing); + expect(getBuilderHarnessMock).not.toHaveBeenCalled(); + }); + + it('falls back to the persisted row cwd when the live session has none (post-restart rehydrate)', () => { + getTerminalSessionByIdMock.mockReturnValue({ + id: 't2', workspace_path: '/ws', type: 'builder', cwd: '/row-wt', + }); + existsSyncMock.mockImplementation((p: unknown) => + typeof p === 'string' && p.endsWith('/row-wt/.builder-kimi-session')); + expect(resolvePacingForSession({ id: 't2' })).toBe(KIMI_HARNESS.messagePacing); + }); + + it('no marker → config-resolved builder harness pacing for builder terminals', () => { + getTerminalSessionByIdMock.mockReturnValue({ + id: 't3', workspace_path: '/ws', type: 'builder', cwd: '/wt', + }); + getBuilderHarnessMock.mockReturnValue(KIMI_HARNESS); + expect(resolvePacingForSession({ id: 't3', cwd: '/wt' })).toBe(KIMI_HARNESS.messagePacing); + expect(getBuilderHarnessMock).toHaveBeenCalledWith('/ws'); + }); + + it('architect terminals resolve via the architect harness', () => { + getTerminalSessionByIdMock.mockReturnValue({ + id: 't4', workspace_path: '/ws', type: 'architect', cwd: '/ws', + }); + resolvePacingForSession({ id: 't4', cwd: '/ws' }); + expect(getArchitectHarnessMock).toHaveBeenCalledWith('/ws'); + expect(getBuilderHarnessMock).not.toHaveBeenCalled(); + }); + + it('claude everywhere → undefined (default pacing, regression)', () => { + getTerminalSessionByIdMock.mockReturnValue({ + id: 't5', workspace_path: '/ws', type: 'builder', cwd: '/wt', + }); + expect(resolvePacingForSession({ id: 't5', cwd: '/wt' })).toBeUndefined(); + }); + + it('unregistered terminal without a marker → undefined', () => { + expect(resolvePacingForSession({ id: 'ghost', cwd: '/wt' })).toBeUndefined(); + }); + + it('a throwing harness resolution (unknown explicit name) degrades to default pacing', () => { + getTerminalSessionByIdMock.mockReturnValue({ + id: 't6', workspace_path: '/ws', type: 'builder', cwd: '/wt', + }); + getBuilderHarnessMock.mockImplementation(() => { throw new Error('Unknown harness "typo"'); }); + expect(resolvePacingForSession({ id: 't6', cwd: '/wt' })).toBeUndefined(); + }); +}); diff --git a/packages/codev/src/agent-farm/servers/__tests__/seed-kick.test.ts b/packages/codev/src/agent-farm/servers/__tests__/seed-kick.test.ts new file mode 100644 index 000000000..4956cf82e --- /dev/null +++ b/packages/codev/src/agent-farm/servers/__tests__/seed-kick.test.ts @@ -0,0 +1,199 @@ +/** + * Tests for the seed-kick module (Issue #1201) — readiness-gated, + * store-verified first-message delivery for seed-style builder harnesses. + * + * The state machine under test: wait for sentinel → grace → write kick → + * poll store for submission → (re-send Enter → re-send kick once → loud warn). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { armSeedKick, parseSeedKick, type SeedKickOptions } from '../seed-kick.js'; +import type { KimiSessionState } from '../../utils/kimi-session-discovery.js'; + +class FakeSession extends EventEmitter { + writes: string[] = []; + write(data: string): void { + this.writes.push(data); + } +} + +const SENTINEL = '__CODEV_KIMI_SEED_DONE__'; + +function opts(overrides?: Partial): SeedKickOptions { + return { + sentinel: SENTINEL, + message: 'BEGIN', + graceMs: 2500, + enterDelayMs: 1000, + verify: { kind: 'kimi-session-store', worktreePath: '/tmp/wt' }, + ...overrides, + }; +} + +describe('parseSeedKick', () => { + it('accepts a full valid payload', () => { + expect(parseSeedKick({ + sentinel: SENTINEL, message: 'BEGIN', graceMs: 2000, enterDelayMs: 900, + verify: { kind: 'kimi-session-store', worktreePath: '/wt' }, + })).toEqual({ + sentinel: SENTINEL, message: 'BEGIN', graceMs: 2000, enterDelayMs: 900, + verify: { kind: 'kimi-session-store', worktreePath: '/wt' }, + }); + }); + + it('rejects malformed payloads (null, missing fields, wrong types) without throwing', () => { + expect(parseSeedKick(undefined)).toBeNull(); + expect(parseSeedKick(null)).toBeNull(); + expect(parseSeedKick('BEGIN')).toBeNull(); + expect(parseSeedKick({ message: 'BEGIN' })).toBeNull(); + expect(parseSeedKick({ sentinel: SENTINEL })).toBeNull(); + expect(parseSeedKick({ sentinel: '', message: 'BEGIN' })).toBeNull(); + }); + + it('drops an unknown verify kind but keeps the kick', () => { + const parsed = parseSeedKick({ + sentinel: SENTINEL, message: 'BEGIN', + verify: { kind: 'something-else', worktreePath: '/wt' }, + }); + expect(parsed).toEqual({ sentinel: SENTINEL, message: 'BEGIN' }); + }); +}); + +describe('armSeedKick', () => { + let session: FakeSession; + let log: ReturnType; + let storeState: KimiSessionState | null; + const readSessionState = vi.fn((_id: string) => storeState); + + beforeEach(() => { + vi.useFakeTimers(); + session = new FakeSession(); + log = vi.fn(); + storeState = null; + readSessionState.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function arm(o: SeedKickOptions = opts()): void { + armSeedKick(session, o, log, { readSessionState }); + } + + /** All bytes written so far, concatenated. */ + const written = () => session.writes.join(''); + + it('writes NOTHING before the sentinel (seed-window write-loss regression)', () => { + arm(); + session.emit('data', 'Seeding Kimi session...\r\n'); + vi.advanceTimersByTime(60_000); + expect(session.writes).toEqual([]); + }); + + it('delivers the kick after sentinel + grace, with the harness Enter delay', () => { + arm(); + session.emit('data', `${SENTINEL} session_abc-123\r\n`); + // Inside the grace window: still nothing. + vi.advanceTimersByTime(2_499); + expect(session.writes).toEqual([]); + // Grace elapses → message body written; Enter comes after enterDelayMs. + vi.advanceTimersByTime(1); + expect(written()).toBe('BEGIN'); + vi.advanceTimersByTime(999); + expect(written()).toBe('BEGIN'); + vi.advanceTimersByTime(1); + expect(written()).toBe('BEGIN\r'); + }); + + it('detects the sentinel across chunked data events', () => { + arm(); + session.emit('data', '__CODEV_KIMI_SEED'); + session.emit('data', '_DONE__ session_split'); + session.emit('data', '-id\r\n'); + vi.advanceTimersByTime(2_500 + 1_000); + expect(written()).toBe('BEGIN\r'); + }); + + it('stops polling and logs success once the store confirms submission', () => { + arm(); + session.emit('data', `${SENTINEL} session_ok\r\n`); + vi.advanceTimersByTime(2_500 + 1_000); // kick fully written + storeState = { workDir: '/tmp/wt', updatedAt: '2026-07-18T10:00:00Z', lastPrompt: 'BEGIN' }; + vi.advanceTimersByTime(1_000); // first poll + expect(readSessionState).toHaveBeenCalledWith('session_ok'); + expect(log).toHaveBeenCalledWith('INFO', expect.stringContaining('confirmed submitted')); + const writesAtConfirm = session.writes.length; + vi.advanceTimersByTime(60_000); + expect(session.writes.length).toBe(writesAtConfirm); // no retries after confirm + }); + + it('escalates: re-send Enter → re-send kick once → loud warn', () => { + arm(); + session.emit('data', `${SENTINEL} session_stuck\r\n`); + vi.advanceTimersByTime(2_500 + 1_000); + expect(written()).toBe('BEGIN\r'); + + // Stage 1 exhausts (8 polls @1s) → bare Enter re-sent. + vi.advanceTimersByTime(8_000); + expect(written()).toBe('BEGIN\r\r'); + expect(log).toHaveBeenCalledWith('WARN', expect.stringContaining('re-sending Enter')); + + // Stage 2 exhausts → the whole kick re-sent once. + vi.advanceTimersByTime(8_000 + 1_000); + expect(written()).toContain('BEGIN\r\rBEGIN'); + + // Stage 3 exhausts → loud final warning, no further writes. + const before = session.writes.length; + vi.advanceTimersByTime(8_000); + expect(log).toHaveBeenCalledWith('WARN', expect.stringContaining('Could not confirm delivery')); + vi.advanceTimersByTime(60_000); + expect(session.writes.length).toBe(before); + }); + + it('a swallowed Enter is healed by the Enter re-send (dominant failure mode)', () => { + arm(); + session.emit('data', `${SENTINEL} session_swallow\r\n`); + vi.advanceTimersByTime(2_500 + 1_000); + // Body sat in the composer; only after the Enter re-send does the store confirm. + vi.advanceTimersByTime(8_000); // Enter re-sent here + storeState = { workDir: '/tmp/wt', updatedAt: '2026-07-18T10:00:05Z', lastPrompt: 'BEGIN' }; + vi.advanceTimersByTime(1_000); + expect(log).toHaveBeenCalledWith('INFO', expect.stringContaining('confirmed submitted')); + // Never escalated to a kick re-send. + expect(written()).toBe('BEGIN\r\r'); + }); + + it('updatedAt movement alone is NOT treated as confirmation', () => { + arm(); + session.emit('data', `${SENTINEL} session_touch\r\n`); + vi.advanceTimersByTime(2_500 + 1_000); + // Store touched by the TUI opening — lastPrompt still absent. + storeState = { workDir: '/tmp/wt', updatedAt: '2026-07-18T10:00:09Z', lastPrompt: null }; + vi.advanceTimersByTime(8_000); + expect(log).toHaveBeenCalledWith('WARN', expect.stringContaining('re-sending Enter')); + }); + + it('skips verification when no verify descriptor is present', () => { + arm(opts({ verify: undefined })); + session.emit('data', `${SENTINEL} session_noverify\r\n`); + vi.advanceTimersByTime(2_500 + 1_000); + expect(written()).toBe('BEGIN\r'); + vi.advanceTimersByTime(60_000); + expect(readSessionState).not.toHaveBeenCalled(); + expect(session.writes.length).toBe(2); // body + Enter, no retries + }); + + it('warns loudly when the sentinel never appears', () => { + arm(); + session.emit('data', 'ERROR: Kimi seed failed\r\n'); + vi.advanceTimersByTime(180_000); + expect(log).toHaveBeenCalledWith('WARN', expect.stringContaining('sentinel never appeared')); + expect(session.writes).toEqual([]); + // A late sentinel after the timeout must not trigger a zombie kick. + session.emit('data', `${SENTINEL} session_late\r\n`); + vi.advanceTimersByTime(60_000); + expect(session.writes).toEqual([]); + }); +}); diff --git a/packages/codev/src/agent-farm/servers/message-pacing.ts b/packages/codev/src/agent-farm/servers/message-pacing.ts new file mode 100644 index 000000000..11941b1c2 --- /dev/null +++ b/packages/codev/src/agent-farm/servers/message-pacing.ts @@ -0,0 +1,53 @@ +/** + * Resolve per-harness message pacing for a target terminal (Issue #1201). + * + * Kimi PTYs need a longer delayed-Enter than the message-write defaults (an + * 80ms Enter is swallowed by paste detection; 1s submits — observed), so every + * delivery path (`afx send` direct + buffered, cron) resolves pacing before + * writing. + * + * Resolution order: + * 1. Worktree marker — a Kimi builder's launch script persists + * `.builder-kimi-session` in its cwd. This is deliberately checked FIRST: + * it is override-proof (correct even when the builder was spawned via + * `--builder-cmd kimi` against a workspace whose config says claude) and + * survives Tower restarts, since it lives on disk next to the session. + * 2. Config-resolved harness for the terminal's registered role (builder / + * architect) in its workspace — covers config-driven spawns and any future + * harness that sets `messagePacing`. + * 3. undefined → message-write defaults. + */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { getBuilderHarness, getArchitectHarness } from '../utils/config.js'; +import { KIMI_HARNESS, KIMI_SESSION_FILE } from '../utils/harness.js'; +import { getTerminalSessionById } from './tower-terminals.js'; +import type { MessagePacing } from './message-write.js'; + +export function resolvePacingForSession( + session: { id: string; cwd?: string }, +): MessagePacing | undefined { + const row = getTerminalSessionById(session.id); + + // 1. Kimi worktree marker in the terminal's cwd (live session's cwd, else + // the persisted row's — a rehydrated session may only have the latter). + const cwd = session.cwd || row?.cwd || null; + if (cwd && existsSync(join(cwd, KIMI_SESSION_FILE))) { + return KIMI_HARNESS.messagePacing; + } + + // 2. Config-resolved harness for the registered terminal role. + if (!row?.workspace_path) return undefined; + try { + if (row.type === 'builder') { + return getBuilderHarness(row.workspace_path).messagePacing; + } + if (row.type === 'architect') { + return getArchitectHarness(row.workspace_path).messagePacing; + } + } catch { + // resolveHarness throws on unknown explicit harness names — default pacing + } + return undefined; +} diff --git a/packages/codev/src/agent-farm/servers/message-write.ts b/packages/codev/src/agent-farm/servers/message-write.ts index 365e0accc..bdaaf1cc6 100644 --- a/packages/codev/src/agent-farm/servers/message-write.ts +++ b/packages/codev/src/agent-farm/servers/message-write.ts @@ -18,6 +18,18 @@ const INTER_LINE_DELAY_MS = 10; const PACED_ENTER_DELAY_MS = 80; const SIMPLE_ENTER_DELAY_MS = 50; +/** + * Per-harness pacing override (Issue #1201). Some CLIs have a longer + * paste-detection window than the defaults assume — Kimi silently swallows an + * Enter that arrives 80ms after the message body (1s works, observed), so + * messages to a Kimi PTY never submit under the default delays. When set, + * `enterDelayMs` replaces BOTH default Enter delays; all other timing + * (line pacing, thresholds) is unchanged. + */ +export interface MessagePacing { + enterDelayMs?: number; +} + /** * Write a message to a PTY session, pacing multi-line output to prevent * the terminal from treating it as a paste (Bugfix #584). @@ -28,10 +40,12 @@ const SIMPLE_ENTER_DELAY_MS = 50; * * @param delayOffset ms offset for all scheduled writes (used to serialize * multiple messages to the same session without interleaving) + * @param pacing optional per-harness timing override (Issue #1201) * @returns ms timestamp (from call time) when all writes complete */ export function writeMessageToSession( session: WritableSession, message: string, noEnter: boolean, delayOffset = 0, + pacing?: MessagePacing, ): number { const lines = message.split('\n'); @@ -42,7 +56,7 @@ export function writeMessageToSession( } else { setTimeout(() => session.write(message), delayOffset); } - const enterTime = delayOffset + SIMPLE_ENTER_DELAY_MS; + const enterTime = delayOffset + (pacing?.enterDelayMs ?? SIMPLE_ENTER_DELAY_MS); if (!noEnter) { setTimeout(() => session.write('\r'), enterTime); } @@ -64,7 +78,7 @@ export function writeMessageToSession( const lastLineTime = delayOffset + (lines.length - 1) * INTER_LINE_DELAY_MS; if (!noEnter) { - const enterTime = lastLineTime + PACED_ENTER_DELAY_MS; + const enterTime = lastLineTime + (pacing?.enterDelayMs ?? PACED_ENTER_DELAY_MS); setTimeout(() => session.write('\r'), enterTime); return enterTime; } diff --git a/packages/codev/src/agent-farm/servers/seed-kick.ts b/packages/codev/src/agent-farm/servers/seed-kick.ts new file mode 100644 index 000000000..fcc183c6f --- /dev/null +++ b/packages/codev/src/agent-farm/servers/seed-kick.ts @@ -0,0 +1,194 @@ +/** + * Readiness-gated first-message delivery for seed-style builder harnesses + * (Issue #1201 — Kimi). + * + * A seed-style launch script spends its first ~5–15s running a non-interactive + * seed call (`kimi -p …`) whose PTY has NO defined consumer for written bytes + * (observed: input written during the seed window is silently lost, or at + * worst replayed unpredictably into the TUI composer later). So the initial + * task kick ("BEGIN") must not be written until the script signals readiness + * by printing ` ` on the PTY — after the seed completes, + * before the interactive TUI starts. + * + * Delivery is then verified against the harness's session store (state.json's + * `lastPrompt` updates when a message actually submits — observed, kimi + * 0.27.0): the dominant failure mode is a swallowed Enter (paste detection), + * so on timeout we re-send Enter, then re-send the whole kick once, then warn + * loudly. Ground truth from the store makes delivery self-healing and absorbs + * any residual Enter-delay uncertainty. + * + * Armed kicks are in-memory only: if Tower restarts between terminal creation + * and the sentinel, the kick is lost — remediation is a manual + * `afx send "BEGIN"`. Documented caveat of the MVI. + */ + +import { writeMessageToSession } from './message-write.js'; +import { + readKimiSessionState, + type KimiSessionState, +} from '../utils/kimi-session-discovery.js'; + +/** Server-side validated shape of the wire `seedKick` field (see + * SeedKickRequest in @cluesmith/codev-core/tower-client). */ +export interface SeedKickOptions { + sentinel: string; + message: string; + graceMs?: number; + enterDelayMs?: number; + verify?: { kind: 'kimi-session-store'; worktreePath: string }; +} + +/** Minimal session surface needed to arm a kick (PtySession satisfies it). */ +export interface SeedKickSession { + write(data: string): void; + on(event: 'data', listener: (data: string) => void): unknown; + off(event: 'data', listener: (data: string) => void): unknown; +} + +export interface SeedKickTimings { + /** Give up waiting for the sentinel after this long. */ + sentinelTimeoutMs: number; + /** Post-sentinel grace before the first kick write. */ + defaultGraceMs: number; + /** Store-poll interval during verification. */ + verifyIntervalMs: number; + /** Polls before escalating to the next rung of the retry ladder. */ + verifyPollsPerStage: number; +} + +const DEFAULT_TIMINGS: SeedKickTimings = { + sentinelTimeoutMs: 180_000, + defaultGraceMs: 2_500, + verifyIntervalMs: 1_000, + verifyPollsPerStage: 8, +}; + +/** Rolling scan buffer cap — sentinel lines are short; no need to retain more. */ +const SCAN_BUFFER_MAX = 8_192; + +type LogFn = (level: 'INFO' | 'WARN' | 'ERROR', message: string) => void; + +export interface SeedKickDeps { + /** Test seam for the store reader (defaults to the real Kimi store). */ + readSessionState?: (sessionId: string) => KimiSessionState | null; + timings?: Partial; +} + +/** + * Validate the raw wire value of `seedKick`. Returns null (not a throw) on + * anything malformed — terminal creation must not fail over an optional field. + */ +export function parseSeedKick(raw: unknown): SeedKickOptions | null { + if (typeof raw !== 'object' || raw === null) return null; + const obj = raw as Record; + if (typeof obj.sentinel !== 'string' || !obj.sentinel) return null; + if (typeof obj.message !== 'string' || !obj.message) return null; + const out: SeedKickOptions = { sentinel: obj.sentinel, message: obj.message }; + if (typeof obj.graceMs === 'number' && obj.graceMs >= 0) out.graceMs = obj.graceMs; + if (typeof obj.enterDelayMs === 'number' && obj.enterDelayMs > 0) out.enterDelayMs = obj.enterDelayMs; + const verify = obj.verify as Record | undefined; + if ( + typeof verify === 'object' && verify !== null && + verify.kind === 'kimi-session-store' && + typeof verify.worktreePath === 'string' + ) { + out.verify = { kind: 'kimi-session-store', worktreePath: verify.worktreePath }; + } + return out; +} + +/** + * Arm a readiness-gated kick on a freshly created PTY session. + * Fire-and-forget: all outcomes (delivered, unconfirmed, sentinel timeout) + * are reported through `log`. + */ +export function armSeedKick( + session: SeedKickSession, + opts: SeedKickOptions, + log: LogFn, + deps?: SeedKickDeps, +): void { + const timings: SeedKickTimings = { ...DEFAULT_TIMINGS, ...deps?.timings }; + const readState = deps?.readSessionState ?? readKimiSessionState; + // Sentinel token + captured id, tolerant of PTY line endings and chunking. + const sentinelRe = new RegExp(`${escapeRegExp(opts.sentinel)}[ \\t]+(\\S+)`); + + let scanBuffer = ''; + let done = false; + + const onData = (data: string) => { + if (done) return; + scanBuffer = (scanBuffer + data).slice(-SCAN_BUFFER_MAX); + const match = sentinelRe.exec(scanBuffer); + if (!match) return; + done = true; + session.off('data', onData); + clearTimeout(sentinelTimer); + const sessionId = match[1]; + log('INFO', `Seed sentinel observed (session ${sessionId.slice(0, 16)}…); delivering kick in ${opts.graceMs ?? timings.defaultGraceMs}ms`); + setTimeout(() => deliverAndVerify(sessionId), opts.graceMs ?? timings.defaultGraceMs); + }; + + const sentinelTimer = setTimeout(() => { + if (done) return; + done = true; + session.off('data', onData); + log('WARN', `Seed sentinel never appeared within ${timings.sentinelTimeoutMs / 1000}s — initial kick "${opts.message}" NOT delivered. The seed may have failed (check the builder terminal); deliver manually via afx send.`); + }, timings.sentinelTimeoutMs); + + session.on('data', onData); + + const writeKick = () => { + writeMessageToSession(session, opts.message, false, 0, opts.enterDelayMs ? { enterDelayMs: opts.enterDelayMs } : undefined); + }; + + const confirmed = (sessionId: string): boolean => { + const state = readState(sessionId); + // lastPrompt reflects the last SUBMITTED message (observed) — the strong + // signal. updatedAt also moves on unrelated store writes (TUI open), so + // it is deliberately not treated as confirmation. + return !!state?.lastPrompt && state.lastPrompt.includes(opts.message); + }; + + function deliverAndVerify(sessionId: string): void { + writeKick(); + if (!opts.verify) { + log('INFO', 'Kick delivered (no store verification requested)'); + return; + } + + // Retry ladder: poll → re-send Enter → poll → re-send kick once → poll → warn. + type Stage = 'initial' | 'after-enter' | 'after-resend'; + let stage: Stage = 'initial'; + let pollsInStage = 0; + + const interval = setInterval(() => { + if (confirmed(sessionId)) { + clearInterval(interval); + log('INFO', `Kick "${opts.message}" confirmed submitted (session store lastPrompt)`); + return; + } + pollsInStage++; + if (pollsInStage < timings.verifyPollsPerStage) return; + pollsInStage = 0; + if (stage === 'initial') { + // Dominant observed failure: the Enter was swallowed by paste + // detection — the message body is sitting in the composer. + log('WARN', 'Kick not confirmed — re-sending Enter'); + session.write('\r'); + stage = 'after-enter'; + } else if (stage === 'after-enter') { + log('WARN', 'Kick still not confirmed — re-sending the kick message once'); + writeKick(); + stage = 'after-resend'; + } else { + clearInterval(interval); + log('WARN', `Could not confirm delivery of initial kick "${opts.message}" via the session store. The builder may be idle — check its terminal and send "${opts.message}" manually (afx send).`); + } + }, timings.verifyIntervalMs); + } +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/codev/src/agent-farm/servers/tower-cron.ts b/packages/codev/src/agent-farm/servers/tower-cron.ts index 5ec01626e..aad503042 100644 --- a/packages/codev/src/agent-farm/servers/tower-cron.ts +++ b/packages/codev/src/agent-farm/servers/tower-cron.ts @@ -14,6 +14,7 @@ import type { CronSchedule } from './tower-cron-parser.js'; import { formatBuilderMessage } from '../utils/message-format.js'; import { broadcastMessage } from './tower-messages.js'; import { writeMessageToSession } from './message-write.js'; +import { resolvePacingForSession } from './message-pacing.js'; import { getGlobalDb } from '../db/index.js'; // ============================================================================ @@ -320,7 +321,8 @@ function deliverMessage(task: CronTask, message: string): void { const formatted = formatBuilderMessage('af-cron', message); // Bugfix #584: pace multi-line output to avoid paste detection. - writeMessageToSession(session, formatted, false); + // Issue #1201: per-harness Enter pacing (Kimi needs a longer delayed Enter). + writeMessageToSession(session, formatted, false, 0, resolvePacingForSession(session)); broadcastMessage({ type: 'message', diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 9f7ef32ae..20ecafbd5 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -52,6 +52,8 @@ import { SendBuffer } from './send-buffer.js'; import type { BufferedMessage } from './send-buffer.js'; import type { PtySession } from '../../terminal/pty-session.js'; import { writeMessageToSession } from './message-write.js'; +import { resolvePacingForSession } from './message-pacing.js'; +import { armSeedKick, parseSeedKick } from './seed-kick.js'; import { getKnownWorkspacePaths, getInstances, @@ -108,7 +110,10 @@ const sendBuffer = new SendBuffer(); /** Deliver a buffered message to a session (write + broadcast + log). * Returns the ms timestamp when all writes complete (for serialization). */ function deliverBufferedMessage(session: PtySession, msg: BufferedMessage, delayOffset = 0): number { - const endTime = writeMessageToSession(session, msg.formattedMessage, msg.noEnter, delayOffset); + // Issue #1201: per-harness Enter pacing (Kimi needs a longer delayed Enter). + const endTime = writeMessageToSession( + session, msg.formattedMessage, msg.noEnter, delayOffset, resolvePacingForSession(session), + ); broadcastMessage(msg.broadcastPayload as Parameters[0]); return endTime; } @@ -657,6 +662,18 @@ async function handleTerminalCreate( } } + // Issue #1201: readiness-gated first-message delivery for seed-style + // harnesses (Kimi). Malformed seedKick values are ignored, never fatal. + const seedKick = parseSeedKick(body.seedKick); + if (seedKick) { + const ptySession = manager.getSession(info.id); + if (ptySession) { + armSeedKick(ptySession, seedKick, (level, message) => + ctx.log(level, `[seed-kick ${info.id.slice(0, 8)}] ${message}`)); + ctx.log('INFO', `Armed seed kick for terminal ${info.id.slice(0, 8)} (sentinel-gated "${seedKick.message}")`); + } + } + res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, persistent })); } catch (err) { @@ -1374,7 +1391,8 @@ async function handleSend( } else { // User is idle (or interrupt) — deliver immediately. // Bugfix #584: paces multi-line output to avoid paste detection. - writeMessageToSession(session, formattedMessage, noEnter); + // Issue #1201: per-harness Enter pacing (Kimi needs a longer delayed Enter). + writeMessageToSession(session, formattedMessage, noEnter, 0, resolvePacingForSession(session)); broadcastMessage(broadcastPayload); ctx.log('INFO', logMessage); } diff --git a/packages/core/src/tower-client.ts b/packages/core/src/tower-client.ts index c37e96f47..d170bbac3 100644 --- a/packages/core/src/tower-client.ts +++ b/packages/core/src/tower-client.ts @@ -23,6 +23,29 @@ const REQUEST_TIMEOUT_MS = 10000; */ export type TerminalType = 'architect' | 'builder' | 'shell' | 'dev'; +/** + * Readiness-gated first-message delivery for seed-style builder harnesses + * (Issue #1201 — Kimi). The launch script prints ` ` + * between seed completion and TUI start; Tower watches the PTY stream for it, + * waits `graceMs`, writes `message`, and (when `verify` is present) confirms + * submission against the harness's session store, re-sending on timeout. + * Bytes written to the PTY during the seed window are silently lost, so + * ungated delivery would drop the message. + */ +export interface SeedKickRequest { + /** Sentinel line prefix; the token after it is the harness session id. */ + sentinel: string; + /** Single-line kick message (e.g. 'BEGIN'). */ + message: string; + /** Post-sentinel grace before writing, in ms. */ + graceMs?: number; + /** Delayed-Enter override for the kick write (harness pacing). */ + enterDelayMs?: number; + /** Store-verified delivery. `kind` selects the verifier; only the Kimi + * session store is supported today. */ + verify?: { kind: 'kimi-session-store'; worktreePath: string }; +} + export interface TowerWorkspace { path: string; name: string; @@ -445,6 +468,7 @@ export class TowerClient { workspacePath?: string; type?: TerminalType; roleId?: string; + seedKick?: SeedKickRequest; }): Promise { const result = await this.request('/api/terminals', { method: 'POST', From 3d407856072ab13d7c3aa17588ea64011fb0a106 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:32:45 -0400 Subject: [PATCH 11/43] [PIR #1201] doctor: kimi presence, truthful auth heuristic, store smoke probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kimi in AI_DEPENDENCIES: kimi --version presence with minVersion 0.27.0 (pins the version the undocumented surfaces were observed against) - verifyKimi(): credential-artifact heuristic (no billed probe — Kimi documents no auth status command), kimi login guidance; supplementary 'kimi doctor' config check (documented exit codes, not an auth check) - Session-store layout smoke probe warns loudly on drift - Architect-shell branch: kimi configured as architect → builder-only warning Co-Authored-By: Claude Fable 5 --- packages/codev/src/commands/doctor.ts | 112 +++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/packages/codev/src/commands/doctor.ts b/packages/codev/src/commands/doctor.ts index 952cb63f6..7426236a3 100644 --- a/packages/codev/src/commands/doctor.ts +++ b/packages/codev/src/commands/doctor.ts @@ -12,6 +12,8 @@ import chalk from 'chalk'; import { query as claudeQuery } from '@anthropic-ai/claude-agent-sdk'; import { executeForgeCommandSync, loadForgeConfig, validateForgeConfig, resolveAllConcepts, type ConceptResolution } from '../lib/forge.js'; import { detectHarnessFromCommand } from '../agent-farm/utils/harness.js'; +import { getKimiHome, kimiStoreLayoutLooksDrifted } from '../agent-farm/utils/kimi-session-discovery.js'; +import { join } from 'node:path'; import { auditPrGates, formatPrGateWarning } from '../lib/pr-gate-audit.js'; import { auditFrameworkRefs, formatFrameworkRefFinding, hasFrameworkOverrides } from '../lib/framework-ref-audit.js'; import { resolveAgyBin, AGY_OAUTH_MARKERS } from './consult/index.js'; @@ -176,6 +178,24 @@ const AI_DEPENDENCIES: Dependency[] = [ linux: 'npm install -g opencode-ai', }, }, + // Kimi Code CLI (Issue #1201 — builder-only harness). The 0.27.0 floor pins + // the version the integration's UNDOCUMENTED surfaces (session store layout, + // stream-json session.resume_hint) were observed against. + { + name: 'Kimi', + command: 'kimi', + versionArg: '--version', + versionExtract: (output: string) => { + const match = output.match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : null; + }, + minVersion: '0.27.0', + required: false, + installHint: { + macos: 'see https://www.kimi.com/code (Kimi Code CLI)', + linux: 'see https://www.kimi.com/code (Kimi Code CLI)', + }, + }, ]; /** @@ -362,6 +382,60 @@ function verifyAiModel(modelName: string): CheckResult { } } +/** + * Verify the Kimi lane (Issue #1201). Kimi documents NO auth status probe + * (`kimi doctor` validates config only; `kimi login` is a device-code flow, + * not a check), and we never make a billed `-p` call from doctor — so the + * auth story is a TRUTHFUL HEURISTIC: report whether credential artifacts + * exist under the Kimi home (undocumented layout, observed on 0.27.0) and + * point at `kimi login` otherwise. + * + * Also runs two cheap supplementary probes: + * - `kimi doctor` (documented: exit 0 = config valid/skipped, 1 = invalid) — + * reported as a config check, explicitly not an auth check. + * - Session-store layout smoke probe: the builder integration reads the + * UNDOCUMENTED store (resume + BEGIN-delivery verification); if a store + * exists but no session parses, the layout likely drifted with a Kimi + * update — warn loudly rather than fail silently at spawn time. + */ +function verifyKimi(): CheckResult { + const kimiHome = getKimiHome(); + const hasCredentials = + existsSync(join(kimiHome, 'credentials', 'kimi-code.json')) || + existsSync(join(kimiHome, 'oauth', 'kimi-code')); + + if (!hasCredentials) { + return { + status: 'fail', + version: 'no auth artifacts', + note: 'Run "kimi login" (heuristic — doctor makes no billed probe; artifacts checked under ' + kimiHome + ')', + }; + } + + const notes: string[] = []; + try { + const result = spawnSync('kimi', ['doctor'], { encoding: 'utf-8', timeout: 10000, stdio: 'pipe' }); + if (result.status !== 0) { + notes.push('"kimi doctor" reports config issues (config check, not auth)'); + } + } catch { + // kimi doctor unavailable/timed out — skip the supplementary config check + } + + if (kimiStoreLayoutLooksDrifted()) { + notes.push('session store layout not recognized — a Kimi update may have changed the (undocumented) layout; builder resume and BEGIN-delivery verification may fail'); + } + + if (notes.length > 0) { + return { status: 'warn', version: 'auth artifacts present (heuristic)', note: notes.join('; ') }; + } + return { + status: 'ok', + version: 'auth artifacts present (heuristic)', + note: 'no documented status probe exists; doctor makes no billed call', + }; +} + const AGY_INSTALL_HINT = 'install: curl -fsSL https://antigravity.google/cli/install.sh | bash, then run `agy` once to sign in'; /** @@ -625,8 +699,10 @@ export async function doctor(): Promise { }); } - // Verify CLI-based models (agy handled separately below — custom OAuth probe) - for (const cliName of installedAiClis.filter(n => n !== 'Claude' && n !== 'Gemini (agy)')) { + // Verify CLI-based models (agy and Kimi handled separately — custom probes: + // agy has an OAuth-aware streaming probe; Kimi has a no-billed-call + // credential-artifact heuristic, Issue #1201) + for (const cliName of installedAiClis.filter(n => n !== 'Claude' && n !== 'Gemini (agy)' && n !== 'Kimi')) { console.log(chalk.blue(` ⋯ ${cliName.padEnd(12)} verifying...`)); process.stdout.write('\x1b[1A\x1b[2K'); @@ -645,6 +721,23 @@ export async function doctor(): Promise { } } + // Verify the Kimi lane via its heuristic probe (Issue #1201). + if (installedAiClis.includes('Kimi')) { + const kimiResult = verifyKimi(); + printStatus('Kimi', kimiResult); + if (kimiResult.status === 'ok' || kimiResult.status === 'warn') { + aiCliCount++; + } + if (kimiResult.status === 'warn' || kimiResult.status === 'fail') { + warnings++; + warningDetails.push({ + name: 'Kimi', + issue: kimiResult.version, + recommendation: kimiResult.note, + }); + } + } + // Verify the gemini lane (agy) via its custom OAuth-aware probe so an // agy-only setup still counts as an operational model. if (installedAiClis.includes('Gemini (agy)')) { @@ -709,6 +802,21 @@ export async function doctor(): Promise { issue: 'Gemini configured as architect shell (builder-only, not architect)', recommendation: 'Set shell.architect to "codex" or "claude --dangerously-skip-permissions" in .codev/config.json', }); + } else if (resolvedHarness === 'kimi') { + // Issue #1201: kimi is builder-only. It has no documented + // system-prompt flag; builder role injection uses a seed-session + // bootstrap owned by the builder launch script. Architect support is + // stage 2. + console.log(''); + console.log(chalk.yellow(' ⚠') + ' Kimi is configured as architect shell — this is unsupported.'); + console.log(chalk.yellow(' ') + 'Kimi is supported for builders only (Issue #1201); architect support is a planned follow-up.'); + console.log(chalk.yellow(' ') + 'Use codex or claude for the architect (e.g., "codex" or "claude --dangerously-skip-permissions").'); + warnings++; + warningDetails.push({ + name: 'Shell config', + issue: 'Kimi configured as architect shell (builder-only, not architect)', + recommendation: 'Set shell.architect to "codex" or "claude --dangerously-skip-permissions" in .codev/config.json', + }); } else if (resolvedHarness === 'codex') { // Issue #929: codex is a supported architect (config-driven). console.log(''); From f0754430f6e8365509e7a70e9c037ccf00258c93 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:34:33 -0400 Subject: [PATCH 12/43] [PIR #1201] Docs: kimi builder harness (arch.md + config examples, skeleton mirror) - arch.md: dedicated Kimi subsection (builder-only, seed-session bootstrap, sentinel-gated store-verified BEGIN, per-harness pacing, explicit-ID resume, undocumented-surface caveats + 0.27.0 pin, NO write-guard parity) - agent-farm.md (instance + skeleton): builder-harness config examples Co-Authored-By: Claude Fable 5 --- .../resources/commands/agent-farm.md | 21 +++++++++++++++++++ codev/resources/arch.md | 18 ++++++++++++++-- codev/resources/commands/agent-farm.md | 21 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/codev-skeleton/resources/commands/agent-farm.md b/codev-skeleton/resources/commands/agent-farm.md index 6f2ed0226..e558742b6 100644 --- a/codev-skeleton/resources/commands/agent-farm.md +++ b/codev-skeleton/resources/commands/agent-farm.md @@ -615,6 +615,27 @@ afx workspace start --architect-cmd "claude --model opus" afx spawn 42 --protocol spir --builder-cmd "claude --model haiku" ``` +### Builder harnesses + +The builder CLI's role/prompt mechanics are handled by a harness, auto-detected +from the command basename (`claude`, `codex`, `gemini`, `opencode`, `kimi`) or +pinned explicitly via `shell.builderHarness`. Example — Kimi Code CLI as the +builder (builder-only; requires kimi >= 0.27.0): + +```json +{ + "shell": { + "builder": "kimi" + } +} +``` + +Kimi builders use a seed-session bootstrap (role + task delivered via a +one-shot `kimi -p` call whose session the interactive TUI then resumes), so +context survives builder restarts. Note: Kimi has no hook seam, so Kimi +builders do NOT get the worktree write-guard Claude builders have. +Architect use of kimi and opencode is unsupported (use claude or codex there). + --- ## Files diff --git a/codev/resources/arch.md b/codev/resources/arch.md index c85d13cda..70de42297 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -253,7 +253,7 @@ All architect sessions (at all 3 creation points) receive a role prompt injected 1. Loads the architect role from `codev/roles/architect.md` (local) or `skeleton/roles/architect.md` (bundled fallback) via `loadRolePrompt()` 2. Writes the role content to `.architect-role.md` in the project directory -3. Delegates the CLI-specific injection to the configured `HarnessProvider` (`agent-farm/utils/harness.ts`, Spec 591): claude `--append-system-prompt`, codex `-c model_instructions_file=`, gemini `GEMINI_SYSTEM_MD` env var +3. Delegates the CLI-specific injection to the configured `HarnessProvider` (`agent-farm/utils/harness.ts`, Spec 591): claude `--append-system-prompt`, codex `-c model_instructions_file=`, gemini `GEMINI_SYSTEM_MD` env var; kimi has no role flag at all — builder-only, role delivered by the seed-session bootstrap (Issue #1201, see the Kimi subsection below) **Three architect creation points** where role injection is applied: - `tower-instances.ts` → `launchInstance()` (new project activation) @@ -271,7 +271,7 @@ A `codev doctor` audit (`lib/framework-ref-audit.ts`) flags shell-fetch of frame #### Supported Architect Harnesses & Conversation Resume (#929) -**Supported architect harnesses** (Issue #929): claude and codex are supported as architects, selected via `.codev/config.json` (`shell.architect` / `shell.architectHarness`) — the same config-driven mechanism builders use, and the *recommended* one. **Gemini is builder-only** — the Gemini CLI is retiring (#778), so it is not offered or affirmed as an architect (its `GEMINI_SYSTEM_MD` builder surface stays); `doctor` warns if `gemini` is configured as an architect. (agy, the gemini successor, is deferred as an architect to #1063 — its only role-injection channel is a visible first user turn.) Harness auto-detection is **override-aware**: `getArchitectHarness` / `getBuilderHarness` resolve the harness from the override-aware command (`getResolvedCommands` → `cliOverrides` / `TOWER_ARCHITECT_CMD` / config), so a `--architect-cmd codex` / `TOWER_ARCHITECT_CMD=codex` / `--builder-cmd gemini` with no matching harness config still resolves the *non-claude* harness, not claude. (Before #929 it auto-detected from the raw config value only — an override launched the non-claude CLI but resolved the claude harness, re-arming the resume crash-loop below.) An explicit `shell.architectHarness` / `shell.builderHarness` still wins over auto-detection. OpenCode remains builder-only (file-based injection needs an ephemeral worktree). Codex reads project context (`AGENTS.md`) natively, so no architect context-file seam is needed; the `getArchitectFiles` seam #1059 added for gemini was removed with gemini's architect support. +**Supported architect harnesses** (Issue #929): claude and codex are supported as architects, selected via `.codev/config.json` (`shell.architect` / `shell.architectHarness`) — the same config-driven mechanism builders use, and the *recommended* one. **Gemini is builder-only** — the Gemini CLI is retiring (#778), so it is not offered or affirmed as an architect (its `GEMINI_SYSTEM_MD` builder surface stays); `doctor` warns if `gemini` is configured as an architect. (agy, the gemini successor, is deferred as an architect to #1063 — its only role-injection channel is a visible first user turn.) Harness auto-detection is **override-aware**: `getArchitectHarness` / `getBuilderHarness` resolve the harness from the override-aware command (`getResolvedCommands` → `cliOverrides` / `TOWER_ARCHITECT_CMD` / config), so a `--architect-cmd codex` / `TOWER_ARCHITECT_CMD=codex` / `--builder-cmd gemini` with no matching harness config still resolves the *non-claude* harness, not claude. (Before #929 it auto-detected from the raw config value only — an override launched the non-claude CLI but resolved the claude harness, re-arming the resume crash-loop below.) An explicit `shell.architectHarness` / `shell.builderHarness` still wins over auto-detection. OpenCode remains builder-only (file-based injection needs an ephemeral worktree). **Kimi is builder-only too** (Issue #1201 — no system-prompt surface; seed-session bootstrap; see the dedicated subsection below). Codex reads project context (`AGENTS.md`) natively, so no architect context-file seam is needed; the `getArchitectFiles` seam #1059 added for gemini was removed with gemini's architect support. > **Caveat — unrecognized override commands still default to the claude harness (tracked in cluesmith/codev#1062).** `#929`'s override-awareness only covers *recognized* harness commands (claude/codex/gemini/opencode, matched by `detectHarnessFromCommand`). An override command the detector does **not** recognize — e.g. `TOWER_ARCHITECT_CMD=bash`, a wrapper script, or any custom launcher — with **no** explicit `shell.architectHarness` / `shell.builderHarness` falls through `resolveHarness` to the **claude** harness (`harness.ts`, the final `return CLAUDE_HARNESS`). With a stale Claude `.jsonl` present, that can still build ` --resume ` for the unrecognized command. This is **pre-existing and narrow** (not a #929 regression — #929 strictly *improved* the recognized codex case) and separable. Mitigation today: set an explicit `shell.architectHarness` / `shell.builderHarness` when using an unrecognized launcher command. @@ -279,6 +279,20 @@ A `codev doctor` audit (`lib/framework-ref-audit.ts`) flags shell-fetch of frame **Architect role injection is centralized in `buildArchitectArgs`** (`tower-utils.ts`), the shared helper every architect-launch path routes through — `launchInstance` (fresh), `add-architect` (sibling), shellper reconnect (×2), and the no-Tower `afx architect` (refactored in #929 to call `buildArchitectArgs` instead of duplicating injection). So the architect role is injected on **every** launch path, not just first-activation. (No architect context-file seam exists: claude/codex read project context natively; the gemini-only `getArchitectFiles` seam #1059 introduced was removed when gemini's architect support was dropped.) +#### Kimi Builder Harness (Issue #1201 — builder-only, seed-session bootstrap) + +**Kimi (`kimi` — the Kimi Code CLI) is a supported BUILDER harness; architect use is unsupported** (stage 2 — `KIMI_HARNESS.buildRoleInjection` throws and `doctor` warns, so misconfiguration fails loudly instead of falling through to claude flags). Select via `shell.builder: "kimi"` / `shell.builderHarness: "kimi"` or `--builder-cmd kimi` (detection is override-aware per #929). Minimum supported version: **kimi 0.27.0**. + +Kimi documents **no system-prompt flag and no positional prompt** (both exit 1), so the entire builder launch shape is provider-owned via the optional `HarnessProvider.buildBuilderLaunchScript` capability (only Kimi implements it; flag-shaped harnesses keep the generic scripts in `spawn-worktree.ts`). The generated script runs the **seed-session bootstrap**: (1) `kimi -p "" --output-format stream-json` in the worktree; (2) the session id is captured from the `session.resume_hint` stream-json meta line and persisted to `.builder-kimi-session`; (3) the interactive TUI loops on `kimi -S --yolo`, so role/task context **survives inner restarts** (stronger than the fresh-per-restart Claude loop). Seed failure (auth, network) exits before the loop — surfaced once, never restart-looped. The role rides a **user turn**, not a system prompt — the same weaker-authority tradeoff that deferred agy as an architect (#1063). + +**Task delivery is readiness-gated and store-verified** (`servers/seed-kick.ts`): bytes written to the PTY during the ~5–15s seed window have no consumer and are silently lost, so the script prints `__CODEV_KIMI_SEED_DONE__ ` between seed and TUI, and Tower (armed via the `seedKick` field on `createTerminal`) waits for that sentinel + a grace period, writes the `BEGIN` kick, then confirms submission against the session store (`state.json.lastPrompt`), re-sending Enter → the kick → a loud warning on failure. Armed kicks are in-memory: a Tower restart during the seed window loses the kick (remediation: `afx send "BEGIN"`). + +**Message pacing is per-harness** (`servers/message-pacing.ts` + `message-write.ts` `pacing.enterDelayMs`): Kimi's paste-detection window swallows an Enter sent 80ms after the body (the old default); Kimi targets get a ~1s delayed Enter. Resolution probes the worktree for `.builder-kimi-session` FIRST (override-proof for `--builder-cmd` spawns; survives Tower restarts) before falling back to the config-resolved harness. + +**Builder resume is explicit-ID** (`KIMI_HARNESS.buildResume`): the ownership-verified `.builder-kimi-session` id, else the newest store session whose `state.json.workDir` exactly matches the worktree, else null → fresh-with-role re-seed (never a roleless fresh session; cwd-scoped `--continue` is deliberately not used). No `session` block exists — the architect stored-UUID contract requires mint-and-pin `newSessionArgs`, which Kimi cannot satisfy; generalizing that contract (async `seedSession` + async-buildable `CrashLoopFallback` per the task-Iptx spike addendum) is the stage-2 architect work. + +**Caveats**: (a) the session store layout (`~/.kimi-code/sessions/wd_*/session_*/state.json`), and the `session.resume_hint` meta line are **undocumented, observed surfaces** — `codev doctor` pins kimi ≥ 0.27.0 and runs a session-store smoke probe that warns on layout drift; all discovery is fail-soft to the fresh path. (b) **No write-guard parity**: Kimi has no documented hook seam, so Kimi builders do NOT get the #1018 PreToolUse worktree write-guard — a Kimi builder can silently write outside its worktree; the documented "static deny rules" hint at a deny-rule surface outside the command reference (follow-up investigation, not a claimable guarantee). (c) Doctor's auth check is a **credential-artifact heuristic** (no documented status probe; no billed call is ever made). + #### Multi-Architect Support (Spec 755 / Spec 786) A workspace can host more than one architect terminal. Each architect has a stable name (`main` for the workspace's default; siblings via `afx workspace add-architect`). The primary use case is letting a sibling architect drive a focused workflow without monopolising `main`. diff --git a/codev/resources/commands/agent-farm.md b/codev/resources/commands/agent-farm.md index 5b7e33a93..e2a13178f 100644 --- a/codev/resources/commands/agent-farm.md +++ b/codev/resources/commands/agent-farm.md @@ -830,6 +830,27 @@ Customize commands via `.codev/config.json` (project root): } ``` +### Builder harnesses + +The builder CLI's role/prompt mechanics are handled by a harness, auto-detected +from the command basename (`claude`, `codex`, `gemini`, `opencode`, `kimi`) or +pinned explicitly via `shell.builderHarness`. Example — Kimi Code CLI as the +builder (builder-only; requires kimi >= 0.27.0, Issue #1201): + +```json +{ + "shell": { + "builder": "kimi" + } +} +``` + +Kimi builders use a seed-session bootstrap (role + task delivered via a +one-shot `kimi -p` call whose session the interactive TUI then resumes), so +context survives builder restarts. Note: Kimi has no hook seam, so Kimi +builders do NOT get the worktree write-guard Claude builders have (#1018). +Architect use of kimi and opencode is unsupported (claude or codex there). + ### Language-Agnostic Porch Checks By default, porch protocol checks use `npm run build` and `npm test`. Non-Node.js projects can override these via the `porch.checks` section in `.codev/config.json`: From b27e2d3872cee06b7ce9e4d0f854f6d661ecac67 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:38:26 -0400 Subject: [PATCH 13/43] [PIR #1201] Pacing resolution is fully best-effort; widen cron session type - resolvePacingForSession wraps its whole body in try/catch: pacing is advisory and must never break message delivery (a missing DB in the tower-routes test env surfaced this as 500s on /api/send) - CronDeps session shape carries id/cwd (the real PtySession provides both) - tower-routes test mock gains getTerminalSessionById Co-Authored-By: Claude Fable 5 --- .../agent-farm/__tests__/tower-routes.test.ts | 2 ++ .../src/agent-farm/servers/message-pacing.ts | 26 ++++++++++--------- .../src/agent-farm/servers/tower-cron.ts | 4 ++- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts index a82b236c2..3dc6fab8d 100644 --- a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts +++ b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts @@ -83,6 +83,8 @@ vi.mock('../servers/tower-terminals.js', () => ({ getTerminalsForWorkspace: mockGetTerminalsForWorkspace, getRehydratedTerminalsEntry: mockGetRehydratedTerminalsEntry, isStartupReconcileSettled: mockIsStartupReconcileSettled, + // Issue #1201: message-pacing resolution reads the persisted row (best-effort) + getTerminalSessionById: vi.fn(() => null), })); vi.mock('../servers/tower-tunnel.js', () => ({ diff --git a/packages/codev/src/agent-farm/servers/message-pacing.ts b/packages/codev/src/agent-farm/servers/message-pacing.ts index 11941b1c2..f5d980303 100644 --- a/packages/codev/src/agent-farm/servers/message-pacing.ts +++ b/packages/codev/src/agent-farm/servers/message-pacing.ts @@ -28,26 +28,28 @@ import type { MessagePacing } from './message-write.js'; export function resolvePacingForSession( session: { id: string; cwd?: string }, ): MessagePacing | undefined { - const row = getTerminalSessionById(session.id); + // Pacing is advisory: any failure here (missing DB, unknown harness name, + // fs error) must degrade to default pacing, never break message delivery. + try { + const row = getTerminalSessionById(session.id); - // 1. Kimi worktree marker in the terminal's cwd (live session's cwd, else - // the persisted row's — a rehydrated session may only have the latter). - const cwd = session.cwd || row?.cwd || null; - if (cwd && existsSync(join(cwd, KIMI_SESSION_FILE))) { - return KIMI_HARNESS.messagePacing; - } + // 1. Kimi worktree marker in the terminal's cwd (live session's cwd, else + // the persisted row's — a rehydrated session may only have the latter). + const cwd = session.cwd || row?.cwd || null; + if (cwd && existsSync(join(cwd, KIMI_SESSION_FILE))) { + return KIMI_HARNESS.messagePacing; + } - // 2. Config-resolved harness for the registered terminal role. - if (!row?.workspace_path) return undefined; - try { + // 2. Config-resolved harness for the registered terminal role. + if (!row?.workspace_path) return undefined; if (row.type === 'builder') { return getBuilderHarness(row.workspace_path).messagePacing; } if (row.type === 'architect') { return getArchitectHarness(row.workspace_path).messagePacing; } + return undefined; } catch { - // resolveHarness throws on unknown explicit harness names — default pacing + return undefined; } - return undefined; } diff --git a/packages/codev/src/agent-farm/servers/tower-cron.ts b/packages/codev/src/agent-farm/servers/tower-cron.ts index aad503042..9220aea4d 100644 --- a/packages/codev/src/agent-farm/servers/tower-cron.ts +++ b/packages/codev/src/agent-farm/servers/tower-cron.ts @@ -38,7 +38,9 @@ export interface CronDeps { log: (level: 'INFO' | 'ERROR' | 'WARN', message: string) => void; getKnownWorkspacePaths: () => string[]; resolveTarget: (target: string, fallbackWorkspace?: string) => unknown; - getTerminalManager: () => { getSession: (id: string) => { write: (data: string) => void } | undefined }; + // id/cwd feed per-harness pacing resolution (Issue #1201); the real + // PtySession provides both. + getTerminalManager: () => { getSession: (id: string) => { id: string; cwd?: string; write: (data: string) => void } | undefined }; } // ============================================================================ From ea6607c6491216977c19302f450a9c1a5d58505c Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:40:50 -0400 Subject: [PATCH 14/43] [PIR #1201] Pin Kimi Enter delay with live bisect evidence Bisected on kimi 0.27.0: 80ms and 100ms swallowed; 120/250/500/1000ms submit. Threshold ~100-120ms; shipped value stays 1000ms (~9x margin). Co-Authored-By: Claude Fable 5 --- packages/codev/src/agent-farm/utils/harness.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/codev/src/agent-farm/utils/harness.ts b/packages/codev/src/agent-farm/utils/harness.ts index b896edcf9..35329cd0b 100644 --- a/packages/codev/src/agent-farm/utils/harness.ts +++ b/packages/codev/src/agent-farm/utils/harness.ts @@ -267,9 +267,12 @@ export const KIMI_SESSION_FILE = '.builder-kimi-session'; /** * Delayed-Enter timing for Kimi PTYs. Kimi's paste-detection window is longer - * than Claude's: an Enter 80ms after the message body is treated as part of a - * paste and NOT submitted; 1s works (observed, kimi 0.27.0 — bisected during - * PIR #1201's live validation). Applied via messagePacing below. + * than Claude's: an Enter arriving too soon after the message body is treated + * as part of a paste and NOT submitted. Bisected live against kimi 0.27.0 + * (PIR #1201): 80ms and 100ms fail; 120ms, 250ms, 500ms, 1000ms submit — + * threshold ≈ 100–120ms. Pinned at 1000ms for ~9x margin (the POC-validated + * value; the only cost is submission latency, which is irrelevant for + * agent-to-agent messages). Applied via messagePacing below. */ export const KIMI_ENTER_DELAY_MS = 1000; From 6b39ca5c8dc0e98bf64da00a8ab37dcd9ccb6190 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:42:51 -0400 Subject: [PATCH 15/43] [PIR #1201] Live demo driver + results (all 5 checklist steps pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codev/spikes/pir-1201-kimi-builder-demo.mjs runs the real dist modules (script generator, armSeedKick, writeMessageToSession, buildResume) against a real kimi PTY: seed bootstrap, sentinel-gated store-verified BEGIN, multiline delivery at the pinned Enter delay, inner-restart context retention, and -S resume. Executed against kimi 0.27.0 — 5/5 PASS; the ack-and-wait-with-task seed discipline held (no fallback needed). Co-Authored-By: Claude Fable 5 --- codev/spikes/pir-1201-kimi-builder-demo.mjs | 193 ++++++++++++++++++++ codev/state/pir-1201_thread.md | 8 + 2 files changed, 201 insertions(+) create mode 100644 codev/spikes/pir-1201-kimi-builder-demo.mjs diff --git a/codev/spikes/pir-1201-kimi-builder-demo.mjs b/codev/spikes/pir-1201-kimi-builder-demo.mjs new file mode 100644 index 000000000..b7307dd33 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-builder-demo.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * PIR #1201 — live demo driver: Kimi builder launch path end-to-end against a + * REAL `kimi` (>= 0.27.0, authenticated), using the REAL built modules from + * packages/codev/dist — no Tower required. + * + * What it exercises, in order (the dev-approval demo checklist): + * 1. Seed-session bootstrap — KIMI_HARNESS.buildBuilderLaunchScript + + * seedDelivery.buildSeedPrompt generate .builder-start.sh/.builder-seed.txt + * exactly as spawn-worktree.ts does; the script runs `kimi -p` and captures + * the session id from the (undocumented) session.resume_hint meta line. + * 2. Sentinel-gated, store-verified BEGIN — the REAL armSeedKick watches the + * PTY for __CODEV_KIMI_SEED_DONE__, waits the grace, writes BEGIN with the + * Kimi Enter delay, and confirms against state.json.lastPrompt. + * 3. Multiline delivery — the REAL writeMessageToSession (paced lines + + * pinned Enter delay) submits a >3-line message to the running TUI. + * 4. Inner-restart retention — the TUI process is killed; the script's + * while-true loop re-enters `kimi -S `; a follow-up question verifies + * role/task context survived. buildResume() is then shown returning the + * same pinned id (afx spawn --resume path). + * + * Run from the repo root of this worktree (after `pnpm build`): + * node codev/spikes/pir-1201-kimi-builder-demo.mjs + * + * Output: PASS/FAIL per step, plus the raw evidence (lastPrompt values, + * assistant text extracted from the session wire log). + */ + +import { mkdtempSync, writeFileSync, chmodSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, '..', '..'); +const dist = (p) => join(repoRoot, 'packages', 'codev', 'dist', p); + +const { KIMI_HARNESS } = await import(dist('agent-farm/utils/harness.js')); +const { armSeedKick } = await import(dist('agent-farm/servers/seed-kick.js')); +const { writeMessageToSession } = await import(dist('agent-farm/servers/message-write.js')); +const { readKimiSessionState } = await import(dist('agent-farm/utils/kimi-session-discovery.js')); + +const require = createRequire(join(repoRoot, 'packages', 'codev', 'package.json')); +const pty = require('node-pty'); + +const worktree = mkdtempSync(join(tmpdir(), 'kimi-demo-wt-')); +console.log(`demo worktree: ${worktree}`); + +const results = []; +const record = (step, ok, evidence) => { + results.push({ step, ok, evidence }); + console.log(`\n[${ok ? 'PASS' : 'FAIL'}] ${step}\n ${evidence}`); +}; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// --- Generate the launch artifacts exactly as spawn-worktree.ts does -------- +const ROLE = 'You are a demo builder agent. Begin every reply with the exact token DEMO-OK followed by a space.'; +const TASK = 'Your task: when told to begin, reply (per your role) with a one-line haiku about git worktrees. Do not use tools.'; + +const seedFile = join(worktree, '.builder-seed.txt'); +writeFileSync(seedFile, KIMI_HARNESS.seedDelivery.buildSeedPrompt(ROLE, TASK)); + +const scriptPath = join(worktree, '.builder-start.sh'); +writeFileSync(scriptPath, KIMI_HARNESS.buildBuilderLaunchScript({ + worktreePath: worktree, baseCmd: 'kimi', seedFile, +})); +chmodSync(scriptPath, 0o755); +console.log('--- generated .builder-start.sh ---'); +console.log(readFileSync(scriptPath, 'utf-8')); + +// --- Host the script in a PTY, shimming the PtySession surface -------------- +const term = pty.spawn('/bin/bash', [scriptPath], { + name: 'xterm-256color', cols: 120, rows: 40, cwd: worktree, + env: { ...process.env }, +}); + +const dataListeners = new Set(); +let transcript = ''; +term.onData((d) => { + transcript += d; + for (const l of dataListeners) l(d); +}); +const session = { + write: (d) => term.write(d), + on: (ev, l) => { if (ev === 'data') dataListeners.add(l); }, + off: (ev, l) => { if (ev === 'data') dataListeners.delete(l); }, +}; + +const logs = []; +const log = (level, message) => { + logs.push(`${level}: ${message}`); + console.log(` [seed-kick ${level}] ${message}`); +}; + +// Arm the REAL readiness-gated kick (what handleTerminalCreate does). +armSeedKick(session, { + sentinel: KIMI_HARNESS.seedDelivery.sentinelPrefix, + message: KIMI_HARNESS.seedDelivery.kickMessage, + graceMs: KIMI_HARNESS.seedDelivery.graceMs, + enterDelayMs: KIMI_HARNESS.messagePacing.enterDelayMs, + verify: { kind: 'kimi-session-store', worktreePath: worktree }, +}, log); + +// --- Step 1+2: wait for seed, sentinel, verified BEGIN ---------------------- +const deadline = Date.now() + 150_000; +let sid = null; +while (Date.now() < deadline) { + const m = /__CODEV_KIMI_SEED_DONE__[ \t]+(\S+)/.exec(transcript); + if (m) { sid = m[1]; break; } + await sleep(500); +} +record('1. seed-session bootstrap (sentinel printed, id captured)', !!sid, `sid=${sid}`); +if (!sid) { term.kill(); process.exit(1); } + +let beginConfirmed = false; +const beginDeadline = Date.now() + 45_000; +while (Date.now() < beginDeadline) { + if (logs.some((l) => l.includes('confirmed submitted'))) { beginConfirmed = true; break; } + await sleep(500); +} +const st1 = readKimiSessionState(sid); +record('2. sentinel-gated BEGIN, store-verified', beginConfirmed, + `lastPrompt=${JSON.stringify(st1?.lastPrompt)}`); + +// Give the model time to answer BEGIN (the haiku per the task briefing). +await sleep(25_000); + +// --- Step 3: multiline delivery with the pinned Enter delay ----------------- +const multiline = ['This is a multiline delivery check.', 'Line two.', 'Line three.', + 'Reply per your role with the single token MULTI-OK and nothing else.'].join('\n'); +writeMessageToSession(session, multiline, false, 0, + { enterDelayMs: KIMI_HARNESS.messagePacing.enterDelayMs }); + +let multiOk = false; +const multiDeadline = Date.now() + 40_000; +while (Date.now() < multiDeadline) { + const st = readKimiSessionState(sid); + if (st?.lastPrompt?.includes('MULTI-OK')) { multiOk = true; break; } + await sleep(1000); +} +record('3. multiline afx-send-shaped delivery submits (pinned Enter delay)', multiOk, + `lastPrompt=${JSON.stringify(readKimiSessionState(sid)?.lastPrompt?.slice(0, 80))}`); +await sleep(20_000); // let the model reply before the restart + +// --- Step 4: inner-restart retention --------------------------------------- +// Kill the kimi TUI (not the script): the while-true loop restarts `-S $SID`. +const { execSync } = await import('node:child_process'); +try { + execSync(`pkill -f -- "-S ${sid}"`, { stdio: 'ignore' }); +} catch { /* pkill exits 1 if pattern raced; the transcript shows the restart */ } +await sleep(10_000); // restart notice (2s) + TUI warm-up + +writeMessageToSession(session, 'After this restart: what one-line task were you originally given? Reply per your role.', + false, 0, { enterDelayMs: KIMI_HARNESS.messagePacing.enterDelayMs }); +await sleep(30_000); + +// Evidence: assistant turns from the session wire log mention the task/role. +let retention = false; +let lastTurns = ''; +try { + const sessionsRoot = join(homedir(), '.kimi-code', 'sessions'); + const { readdirSync } = await import('node:fs'); + outer: + for (const wd of readdirSync(sessionsRoot)) { + const wire = join(sessionsRoot, wd, sid, 'agents', 'main', 'wire.jsonl'); + if (existsSync(wire)) { + const texts = [...readFileSync(wire, 'utf-8').matchAll(/"part":\{"type":"text","text":"((?:[^"\\]|\\.)*)"/g)] + .map((m) => m[1]); + lastTurns = texts.slice(-3).join(' | ').slice(0, 400); + retention = texts.slice(-2).some((t) => t.includes('DEMO-OK') && /haiku|worktree/i.test(t)); + break outer; + } + } +} catch (err) { + lastTurns = `wire log read failed: ${err.message}`; +} +record('4. inner-restart retention (role token + task recalled via kimi -S)', retention, + `last turns: ${lastTurns}`); + +// buildResume returns the same pinned id (afx spawn --resume path). +const resume = KIMI_HARNESS.buildResume(worktree); +record('4b. buildResume returns the pinned session (-S form)', + resume?.sessionId === sid && resume?.args?.[0] === '-S', + JSON.stringify(resume)); + +// --- Teardown --------------------------------------------------------------- +term.write('\x03'); await sleep(500); term.write('\x03'); await sleep(1000); +term.kill(); + +console.log('\n=== DEMO SUMMARY ==='); +for (const r of results) console.log(`${r.ok ? 'PASS' : 'FAIL'} ${r.step}`); +process.exit(results.every((r) => r.ok) ? 0 : 1); diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index 0f05bd72b..c630a1550 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -13,3 +13,11 @@ - NO `session` block on KIMI_HARNESS (mint-and-pin `newSessionArgs` unsatisfiable; contract generalization = stage 2). Architect use fails loudly via `buildRoleInjection` throw + doctor warning. - Enter-delay: optional `pacing.enterDelayMs` on `writeMessageToSession`, sourced from `HarnessProvider.messagePacing`; bisect 80ms–1s live during implement. - Plan committed at `codev/plans/1201-support-kimi-code-cli-as-a-bui.md`; sitting at plan-approval gate. + +## 2026-07-18 — Implement phase + +- Plan approved with one review note: make message-pacing resolution robust to a per-spawn `--builder-cmd` override. Solved without a DB migration: pacing probes the target's cwd for the `.builder-kimi-session` marker FIRST (the marker exists iff the launch script is Kimi-shaped — self-describing, survives Tower restarts, override-proof), then falls back to config-resolved harness by terminal role. +- Full MVI implemented across five commits: harness+discovery+script-shape, Tower seed-kick+pacing, doctor, docs, hardening. All porch checks (build, tests) green; suite 3592 passing after fixing a 500 my pacing hook caused in the /api/send test env (lesson: advisory features must be try/catch-total — pacing can never break delivery). +- Enter-delay bisect (real kimi 0.27.0, POC probe-10 method): 80ms fails (spike-confirmed), 120/250/500ms submit. Threshold ≈ 100ms; shipped constant pinned at 1000ms (~10x margin, POC-validated, latency-only cost). +- Demo driver at `codev/spikes/pir-1201-kimi-builder-demo.mjs` — runs the REAL dist modules (script generator, armSeedKick, writeMessageToSession, buildResume) against a real kimi PTY, covering the architect's 4-point demo checklist without touching the global Tower. Full `afx spawn` path needs the branch build installed into Tower (`pnpm -w run local-install`) — that restarts Tower, so it's the human's call at the gate. +- **Demo executed: ALL 5 steps PASS** (kimi 0.27.0, first run). Seed → sentinel → store-verified BEGIN (`lastPrompt="BEGIN"`); the ack-and-wait-with-task discipline HELD (spike addendum's open question — no fallback needed); multiline submitted with the pinned delay; TUI killed mid-session → `-S` restart recalled both role token and task verbatim; buildResume returned the pinned id. Sitting at dev-approval gate. From 671e8dda32514d89a55e49c26d3a7fb5fec21abd Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 19:43:32 -0400 Subject: [PATCH 16/43] chore(porch): 1201 dev-approval gate-requested --- codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml index a3da2a8cd..875596e94 100644 --- a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -11,10 +11,11 @@ gates: approved_at: '2026-07-18T23:13:52.402Z' dev-approval: status: pending + requested_at: '2026-07-18T23:43:32.608Z' pr: status: pending iteration: 1 build_complete: false history: [] started_at: '2026-07-18T22:59:08.361Z' -updated_at: '2026-07-18T23:14:06.603Z' +updated_at: '2026-07-18T23:43:32.609Z' From 215cda2a90f9cb6859421b952b70584881f3f811 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:42:00 -0400 Subject: [PATCH 17/43] chore(porch): 1201 dev-approval gate-approved --- .../projects/1201-support-kimi-code-cli-as-a-bui/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml index 875596e94..a0df7c27f 100644 --- a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -10,12 +10,13 @@ gates: requested_at: '2026-07-18T23:06:14.780Z' approved_at: '2026-07-18T23:13:52.402Z' dev-approval: - status: pending + status: approved requested_at: '2026-07-18T23:43:32.608Z' + approved_at: '2026-07-19T00:42:00.609Z' pr: status: pending iteration: 1 build_complete: false history: [] started_at: '2026-07-18T22:59:08.361Z' -updated_at: '2026-07-18T23:43:32.609Z' +updated_at: '2026-07-19T00:42:00.609Z' From fee9015ac7ecd6ec8dc85fa99fd5235dc386f874 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:43:42 -0400 Subject: [PATCH 18/43] chore(porch): 1201 review phase-transition --- .../projects/1201-support-kimi-code-cli-as-a-bui/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml index a0df7c27f..0afbc6504 100644 --- a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -1,7 +1,7 @@ id: '1201' title: support-kimi-code-cli-as-a-bui protocol: pir -phase: implement +phase: review plan_phases: [] current_plan_phase: null gates: @@ -19,4 +19,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-07-18T22:59:08.361Z' -updated_at: '2026-07-19T00:42:00.609Z' +updated_at: '2026-07-19T00:43:42.120Z' From e8fe2efb7589ae3f8396d8eca5875ae38ff6635c Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:45:18 -0400 Subject: [PATCH 19/43] [PIR #1201] Review + retrospective (lessons routed to cold tier) Co-Authored-By: Claude Fable 5 --- codev/resources/lessons-learned.md | 2 + .../1201-support-kimi-code-cli-as-a-bui.md | 85 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 codev/reviews/1201-support-kimi-code-cli-as-a-bui.md diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index f7dea5ef1..a714cef35 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -88,6 +88,8 @@ Generalizable wisdom extracted from review documents, ordered by impact. Updated - [From #1018] Against a *moving runtime*, only a deterministic guard holds — instructions, per-agent memory, and `git bisect` do not. The builder write-into-main-checkout bug is intrinsic model/CLI path-synthesis behavior (the model anchors a synthesized absolute path at the inferred repo root, dropping its `.builders//` worktree segment) that drifts across upgrades in both directions. The fix that survives version churn is a `PreToolUse` hook that converts a silent wrong-rooted write into a loud, correctable deny; the role-doc instruction is only a backstop. When a bug's root cause is "the model guessed wrong and got no corrective signal," reach for a runtime invariant, not a better prompt. - [From #1018] A guard's *surface* and its *blast radius* must match the actual hazard, not the role. The write-guard is builder-only and write-only by design: (a) the architect legitimately owns `main`, so the same hook there is a structural no-op (root resolves to the main checkout) and was deliberately not installed; (b) reads are left unguarded so codev's intentional cross-checkout reads (architect↔builder threads, sibling threads) keep working. Guarding "outside the worktree" symmetrically across roles or across read+write would have broken designed-in behavior. Scope the invariant to where the silent failure actually occurs. - [From #1018] `fs.writeFileSync` does not create missing parent dirs, and a git worktree only materializes directories that contain *tracked* files — git never checks out an empty dir. A path like `.claude/hooks/` (holding only a generated, intentionally-untracked file) therefore does not exist in a fresh worktree, and even `.claude/` may be absent in an adopter repo that tracks nothing under it. Any code that writes a generated file into a worktree subdir must `mkdir -p` its parent first; don't assume a dir exists just because a sibling tracked dir (e.g. `.claude/skills/`) does. +- [From #1201] An **advisory decorator on a critical path must be failure-total** — wrap its entire body in try/catch and degrade to the default, because any escape hatch it leaves open converts "nice-to-have missing" into "core feature broken". Per-harness message pacing merely *tunes* delivery timing, but its resolver (a DB row read + config resolution + fs stat) ran inline in `/api/send`; one throwing dependency in the test env turned every send into a 500. The narrow `try` around just the harness resolution wasn't enough — the failure came from a mocked-out module *outside* it. If the feature's contract is "when in doubt, defaults", the implementation must make *every* doubt resolve to defaults. +- [From #1201] For a per-instance runtime fact that config cannot know (here: "this builder terminal fronts a Kimi TUI", which a per-spawn `--builder-cmd` override creates against a claude-configured workspace), prefer a **self-describing on-disk marker in the instance's own directory** over threading metadata through registration/DB schema. The Kimi launch script necessarily persists `.builder-kimi-session` in its worktree, so probing the terminal's cwd for that file is ground truth by construction: override-proof, Tower-restart-proof, and zero-migration — where the alternatives (a new `terminal_sessions` column, an in-memory map) each fail one of those. Only reach for schema when no artifact of the behavior itself already marks the instance. - [From #1139] When you add an interactive resolution step (a picker, a prompt) in front of an API that has a documented defaulting parameter, the resolution must flow to every consumer of that default: return the resolved value from the command/function that owns the interaction and audit downstream callers. Two independently-correct changes composed into a silent no-op here. Spec 786 Phase 6 deliberately defaulted `injectArchitectText(architectName = 'main')` so the Backlog button kept working, and Issue 841 Gap 2 later added a QuickPick upstream in `codev.openArchitectTerminal`, but the picker's choice was consumed only for "which terminal to open," never returned, so the reference commands kept injecting into `main` no matter what the user picked. Neither change was wrong; the seam between them was. The tell to grep for: a `showQuickPick`/resolution whose result is used locally but not returned, sitting upstream of a call site that relies on a default the resolution was meant to supersede. - [From 810] The builder-overview shape is defined twice — the `OverviewBuilder` wire type (`packages/types`) and a structurally-identical local `BuilderOverview` interface in `overview.ts`, kept in sync by hand. Adding a field to only the wire type compiles for clients (vscode/dashboard) but breaks the codev build at the server-side `builders.push({...})` sites. Compounding footgun: the codev package has no `check-types` script, so the mismatch is invisible until a full `pnpm build` runs `tsc` over `codev/src` — vscode/dashboard type-checks pass meanwhile. When touching the overview projection, build the codev package, not just the client type-check. - [From 0395] Prompt-based instructions beat programmatic file manipulation for flexible document generation — the Builder already has context and can write natural responses, while code would need fragile parsing and placeholder logic diff --git a/codev/reviews/1201-support-kimi-code-cli-as-a-bui.md b/codev/reviews/1201-support-kimi-code-cli-as-a-bui.md new file mode 100644 index 000000000..8898882d0 --- /dev/null +++ b/codev/reviews/1201-support-kimi-code-cli-as-a-bui.md @@ -0,0 +1,85 @@ +# PIR Review: Support Kimi Code CLI as a builder + +Fixes #1201 + +## Summary + +Adds the Kimi Code CLI (`kimi`, ≥ 0.27.0) as a supported **builder** harness — `shell.builder: "kimi"` / `builderHarness: "kimi"` / `--builder-cmd kimi` now produce a working builder instead of the #1062 false-Claude fallthrough (which appended `--append-system-prompt` and a positional prompt, both rejected by kimi, and could route a stale Claude `--resume ` into it). Because Kimi documents no system-prompt flag and no positional prompt, the launch shape is provider-owned: a **seed-session bootstrap** (validated by spike task-Iptx) delivers role + task via a one-shot `kimi -p` whose captured session id pins a `kimi -S --yolo` TUI loop, with a Tower-side **readiness barrier** (sentinel-gated, store-verified `BEGIN` kick) and a per-harness delayed-Enter pacing knob so `afx send` actually submits. Kimi as an *architect* is explicitly out of scope (stage 2). + +## Files Changed + +`git diff --stat $(git merge-base main HEAD)` (excluding porch state commits): + +- `packages/codev/src/agent-farm/utils/harness.ts` (+263) — `KIMI_HARNESS`, detection, `buildBuilderLaunchScript` / `seedDelivery` / `messagePacing` interface capabilities, `buildResume` +- `packages/codev/src/agent-farm/utils/kimi-session-discovery.ts` (+197, new) — store scan / ownership verify / state reader (fail-soft; `KIMI_CODE_HOME`-aware) +- `packages/codev/src/agent-farm/commands/spawn-worktree.ts` (+111/−9) — provider-owned script branch, `.builder-seed.txt`, `seedKick` pass-through +- `packages/codev/src/agent-farm/servers/seed-kick.ts` (+194, new) — sentinel watcher + grace + store-verified kick retry ladder +- `packages/codev/src/agent-farm/servers/message-pacing.ts` (+55, new) — per-target pacing resolution (worktree-marker probe first, config-resolved harness fallback) +- `packages/codev/src/agent-farm/servers/message-write.ts` (+16/−2) — optional `pacing.enterDelayMs` override +- `packages/codev/src/agent-farm/servers/tower-routes.ts` (+20/−2) — `seedKick` on terminal create; pacing at both send paths +- `packages/codev/src/agent-farm/servers/tower-cron.ts` (+6/−2) — pacing at cron delivery +- `packages/core/src/tower-client.ts` (+24) — `SeedKickRequest` wire type on `createTerminal` +- `packages/codev/src/agent-farm/lib/tower-client.ts` (+1) — re-export +- `packages/codev/src/commands/doctor.ts` (+110/−2) — kimi presence/minVersion, auth heuristic, `kimi doctor` config check, store smoke probe, architect-kimi warning +- Tests (+~900 across 8 files): new `kimi-session-discovery.test.ts`, `seed-kick.test.ts`, `message-pacing.test.ts`; extended `harness.test.ts`, `spawn-worktree.test.ts`, `config.test.ts`, `discover-resume-session.test.ts`, `bugfix-584-send-multiline-pacing.test.ts` +- Docs: `codev/resources/arch.md` (+16/−2, dedicated Kimi subsection), `codev/resources/commands/agent-farm.md` + `codev-skeleton/resources/commands/agent-farm.md` (builder-harness config examples — skeleton mirrored) +- `codev/spikes/pir-1201-kimi-builder-demo.mjs` (+193, new) — runnable live-demo driver (real kimi, real dist modules) +- `codev/plans/1201-…md`, `codev/state/pir-1201_thread.md` + +Total: 27 files, +2378/−23. + +## Commits + +- `2cf424c1` [PIR #1201] Kimi harness: detection, seed-session launch script, builder resume +- `8e86c411` [PIR #1201] Tower: sentinel-gated BEGIN delivery + per-harness Enter pacing +- `3d407856` [PIR #1201] doctor: kimi presence, truthful auth heuristic, store smoke probe +- `f0754430` [PIR #1201] Docs: kimi builder harness (arch.md + config examples, skeleton mirror) +- `b27e2d38` [PIR #1201] Pacing resolution is fully best-effort; widen cron session type +- `ea6607c6` [PIR #1201] Pin Kimi Enter delay with live bisect evidence +- `6b39ca5c` [PIR #1201] Live demo driver + results (all 5 checklist steps pass) +- (plus `d49c292b` plan draft and porch state commits) + +## Test Results + +- `pnpm build`: ✓ pass (types → core → codev, incl. dashboard + skeleton copy) +- `pnpm test` (vitest): ✓ pass — 3592 passed, 48 skipped (~75 new tests). Porch's build/tests checks green at both the dev-approval and review transitions. +- **#929-class regression covered from four angles**: `kimi` + a stale Claude `.jsonl` can never yield `--resume ` or `--append-system-prompt` (harness `buildResume`, `discoverResumeSession`, config/override resolution, generated-script assertions). +- **Live validation on real kimi 0.27.0**: + - *Enter-delay bisect* (POC probe-10 method): 80ms and 100ms swallowed; 120/250/500/1000ms submit → threshold ≈ 100–120ms; shipped `KIMI_ENTER_DELAY_MS = 1000` (~9x margin; latency-only cost). + - *Demo driver* (`node codev/spikes/pir-1201-kimi-builder-demo.mjs`): 5/5 PASS — seed bootstrap + id capture; sentinel-gated store-verified BEGIN (`lastPrompt="BEGIN"`); multiline delivery at pinned delay; inner-restart context retention (role token + task recalled verbatim after killing the TUI); `buildResume` returns the pinned id. The spike addendum's open question — does ack-and-wait hold with a task attached? — **held**; the pre-planned role-only-seed fallback was not needed. + - *Human full-path verification at the dev-approval gate*: real `afx spawn` through Tower (branch build via local-install); all 4 checklist items passed live. + +## Architecture Updates + +Routed to the **COLD** tier (`codev/resources/arch.md`, updated in commit `f0754430`): a dedicated "Kimi Builder Harness (Issue #1201)" subsection under Agent Farm Internals — builder-only status, the seed-session bootstrap, the sentinel + store-verified BEGIN barrier, per-harness pacing with the marker-probe resolution order, explicit-ID resume, and the caveats (undocumented store surfaces + 0.27.0 pin + doctor smoke probe; **no write-guard parity** — Kimi has no documented hook seam; in-memory kick lost on Tower restart during the seed window). The harness enumeration lines in the same section were extended. + +No **HOT** tier (`arch-critical.md`) change: kimi support is subsystem detail, not a top-10 always-on system-shape fact; the existing hot facts (runtime resolution, dual-tree mirroring, porch/state invariants) already cover the decision surface this touches. + +## Lessons Learned Updates + +Routed to the **COLD** tier (`codev/resources/lessons-learned.md`, Architecture section, this commit): + +1. *Advisory decorators on critical paths must be failure-total* — the pacing resolver's narrow try/catch let a mocked-out dependency 500 every `/api/send` in the test env; the whole body now degrades to defaults. +2. *Per-instance runtime facts that config cannot know are best carried by a self-describing on-disk marker in the instance's own directory* — `.builder-kimi-session` makes pacing correct for `--builder-cmd` override spawns across Tower restarts with zero schema migration. + +No **HOT** tier (`lessons-critical.md`) change: both lessons are architecture-pattern reference material, not behavior-changing cross-cutting rules of the always-on caliber (the cap is full of broader rules that would each beat these on displacement). + +## Things to Look At During PR Review + +- **`seed-kick.ts` retry ladder semantics**: confirmation is `lastPrompt` containing the kick text — `updatedAt` movement is deliberately NOT trusted (the TUI touches the store on open, which would false-positive and suppress the Enter re-send). A false *negative* only costs a duplicate BEGIN + loud warn. +- **`message-pacing.ts` resolution order**: marker probe before config, by design (override robustness — see plan-review note). The probe stats one file per message send; sends are rare, so no perf concern. +- **Undocumented-surface reliance is deliberately narrow**: discovery scans only `sessions/*/*/state.json` (not `session_index.jsonl` — one undocumented surface instead of two); every reader is fail-soft to the fresh-with-role path; doctor carries the drift probe. +- **`kimiTuiCmd` appends `--yolo`** unless the user already passed it; `--auto` is deliberately never used (documented conflict with `--yolo`; suppresses agent→user questions the gate workflow needs). +- **Kimi builders have NO write-guard** (#1018 parity impossible — no documented hook seam). Documented in arch.md and the config docs; the "static deny rules" hint in Kimi's `-p` docs is flagged as follow-up investigation, not a claimed guarantee. +- Doctor's kimi lane follows the existing print-flow style (no dedicated unit tests, matching the opencode/gemini architect-warning precedent); its logic-bearing pieces (`kimiStoreLayoutLooksDrifted`, discovery readers) are unit-tested in the discovery suite. + +## How to Test Locally + +- **View diff**: VSCode sidebar → right-click builder `pir-1201` → **View Diff** (or `gh pr diff`). +- **Standalone demo (no Tower changes needed)**: from the branch checkout, `pnpm build` then `node codev/spikes/pir-1201-kimi-builder-demo.mjs` — requires an authenticated `kimi` ≥ 0.27.0; prints PASS/FAIL for all five checklist steps. +- **Full Tower path**: `pnpm -w run local-install` (restarts Tower), then from the main workspace root: `afx spawn --task "any small task" --builder-cmd kimi` → watch seed → `__CODEV_KIMI_SEED_DONE__` → BEGIN in the builder pane; `afx send ` with a >3-line message → submits as one message; kill the TUI (`Ctrl+C` once) → restart resumes with context; `afx spawn --resume` after killing the terminal. +- `codev doctor` with kimi installed → presence + version gate, heuristic auth line, smoke probe; with `shell.architect: "kimi"` → builder-only warning. + +--- + +*Maintainer note: please add the `area/tower` label to issue #1201 (we can't set labels cross-fork).* From b80dbb48679a23ab194428f38d621cc49529f4fa Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:45:29 -0400 Subject: [PATCH 20/43] chore(porch): 1201 record PR #1203 --- .../1201-support-kimi-code-cli-as-a-bui/status.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml index 0afbc6504..0684b2e0d 100644 --- a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -19,4 +19,9 @@ iteration: 1 build_complete: false history: [] started_at: '2026-07-18T22:59:08.361Z' -updated_at: '2026-07-19T00:43:42.120Z' +updated_at: '2026-07-19T00:45:29.121Z' +pr_history: + - phase: review + pr_number: 1203 + branch: builder/pir-1201 + created_at: '2026-07-19T00:45:29.121Z' From ad19c96e3c5b764c38f534e33fd6b0029a1c881a Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:45:34 -0400 Subject: [PATCH 21/43] chore(porch): 1201 review build-complete --- .../projects/1201-support-kimi-code-cli-as-a-bui/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml index 0684b2e0d..31d793b5c 100644 --- a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -16,10 +16,10 @@ gates: pr: status: pending iteration: 1 -build_complete: false +build_complete: true history: [] started_at: '2026-07-18T22:59:08.361Z' -updated_at: '2026-07-19T00:45:29.121Z' +updated_at: '2026-07-19T00:45:34.190Z' pr_history: - phase: review pr_number: 1203 From 732f04b8273d18fcdc6c08825fc85b4dce4899a3 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:51:15 -0400 Subject: [PATCH 22/43] [PIR #1201] Fix seed-kick confirmation false-positive (codex consultation finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivery check used lastPrompt.includes(kickMessage) — but on a fresh spawn lastPrompt initially holds the SEED prompt, whose ack-and-wait wrapper itself mentions BEGIN, so the verifier reported success before the kick ever submitted (silently defeating the swallowed-Enter recovery; the live demo's happy path masked it). Confirmation now requires whitespace-normalized EQUALITY (submitted messages land in lastPrompt with newlines flattened to spaces — observed on kimi 0.27.0). Two pinning regression tests added; live demo re-run post-fix: 5/5 PASS. Co-Authored-By: Claude Fable 5 --- .../1201-support-kimi-code-cli-as-a-bui.md | 3 +- .../servers/__tests__/seed-kick.test.ts | 39 +++++++++++++++++++ .../codev/src/agent-farm/servers/seed-kick.ts | 16 +++++++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/codev/reviews/1201-support-kimi-code-cli-as-a-bui.md b/codev/reviews/1201-support-kimi-code-cli-as-a-bui.md index 8898882d0..63bd88639 100644 --- a/codev/reviews/1201-support-kimi-code-cli-as-a-bui.md +++ b/codev/reviews/1201-support-kimi-code-cli-as-a-bui.md @@ -66,7 +66,8 @@ No **HOT** tier (`lessons-critical.md`) change: both lessons are architecture-pa ## Things to Look At During PR Review -- **`seed-kick.ts` retry ladder semantics**: confirmation is `lastPrompt` containing the kick text — `updatedAt` movement is deliberately NOT trusted (the TUI touches the store on open, which would false-positive and suppress the Enter re-send). A false *negative* only costs a duplicate BEGIN + loud warn. +- **PR-consultation finding (codex, REQUEST_CHANGES — FIXED)**: the original delivery confirmation used `lastPrompt.includes(kickMessage)`. Real defect: on a fresh spawn `lastPrompt` initially holds the *seed prompt*, whose ack-and-wait wrapper itself says "wait for BEGIN" — so the substring check reported success before the kick ever submitted, silently defeating the swallowed-Enter recovery (the live demo's happy path masked it: the kick genuinely landed, so the false-positive window was never observed). Fixed in `seed-kick.ts` by requiring whitespace-normalized **equality** (submitted messages land in `lastPrompt` with newlines flattened to spaces — observed), with two pinning regression tests (seed-prompt-containing-BEGIN must NOT confirm and must escalate to the Enter re-send; a multi-line kick payload must still confirm through the flattening). Gemini and Claude both returned APPROVE; PIR's consultation is single-pass, so this fix was **not** independently re-reviewed — please eyeball `confirmed()` in `seed-kick.ts` at the `pr` gate. The live demo was re-run after the fix: still 5/5 PASS (no false negative). +- **`seed-kick.ts` retry ladder semantics**: `updatedAt` movement is deliberately NOT trusted as confirmation (the TUI touches the store on open, which would false-positive and suppress the Enter re-send). A false *negative* only costs a duplicate BEGIN + loud warn. - **`message-pacing.ts` resolution order**: marker probe before config, by design (override robustness — see plan-review note). The probe stats one file per message send; sends are rare, so no perf concern. - **Undocumented-surface reliance is deliberately narrow**: discovery scans only `sessions/*/*/state.json` (not `session_index.jsonl` — one undocumented surface instead of two); every reader is fail-soft to the fresh-with-role path; doctor carries the drift probe. - **`kimiTuiCmd` appends `--yolo`** unless the user already passed it; `--auto` is deliberately never used (documented conflict with `--yolo`; suppresses agent→user questions the gate workflow needs). diff --git a/packages/codev/src/agent-farm/servers/__tests__/seed-kick.test.ts b/packages/codev/src/agent-farm/servers/__tests__/seed-kick.test.ts index 4956cf82e..9ec469447 100644 --- a/packages/codev/src/agent-farm/servers/__tests__/seed-kick.test.ts +++ b/packages/codev/src/agent-farm/servers/__tests__/seed-kick.test.ts @@ -165,6 +165,45 @@ describe('armSeedKick', () => { expect(written()).toBe('BEGIN\r\r'); }); + it('the SEED prompt containing the kick word is NOT confirmation (fresh-spawn false-positive regression)', () => { + // PR-consultation finding (codex, PIR #1201): on a fresh spawn, + // lastPrompt initially holds the SEED prompt, whose ack-and-wait wrapper + // mentions BEGIN ("wait for BEGIN"). A substring check would report the + // kick submitted before it was ever written, so a swallowed Enter would + // never be healed. Confirmation must require normalized EQUALITY. + arm(); + session.emit('data', `${SENTINEL} session_seeded\r\n`); + // Store state as the seed leaves it: lastPrompt = the full seed prompt. + storeState = { + workDir: '/tmp/wt', + updatedAt: '2026-07-18T10:00:00Z', + lastPrompt: 'Strict discipline: wait. You will receive a message "BEGIN" in a later turn.\n=== TASK BRIEFING (do not act until BEGIN) ===\ndo the thing', + }; + vi.advanceTimersByTime(2_500 + 1_000); + // The kick's Enter was swallowed; lastPrompt stays the seed prompt. + // Stage 1 must exhaust and re-send Enter — NOT report success. + vi.advanceTimersByTime(8_000); + expect(log).not.toHaveBeenCalledWith('INFO', expect.stringContaining('confirmed submitted')); + expect(log).toHaveBeenCalledWith('WARN', expect.stringContaining('re-sending Enter')); + expect(written()).toBe('BEGIN\r\r'); + // The re-sent Enter lands the kick: lastPrompt becomes exactly BEGIN. + storeState = { workDir: '/tmp/wt', updatedAt: '2026-07-18T10:00:20Z', lastPrompt: 'BEGIN' }; + vi.advanceTimersByTime(1_000); + expect(log).toHaveBeenCalledWith('INFO', expect.stringContaining('confirmed submitted')); + }); + + it('confirmation tolerates the observed newline-flattening of submitted messages', () => { + // Submitted multi-line messages land in lastPrompt with newlines + // flattened to spaces (observed on kimi 0.27.0) — normalized equality + // must still confirm a multi-line kick payload. + arm(opts({ message: 'line one\nline two' })); + session.emit('data', `${SENTINEL} session_flat\r\n`); + vi.advanceTimersByTime(2_500 + 1_000); + storeState = { workDir: '/tmp/wt', updatedAt: '2026-07-18T10:00:05Z', lastPrompt: 'line one line two' }; + vi.advanceTimersByTime(1_000); + expect(log).toHaveBeenCalledWith('INFO', expect.stringContaining('confirmed submitted')); + }); + it('updatedAt movement alone is NOT treated as confirmation', () => { arm(); session.emit('data', `${SENTINEL} session_touch\r\n`); diff --git a/packages/codev/src/agent-farm/servers/seed-kick.ts b/packages/codev/src/agent-farm/servers/seed-kick.ts index fcc183c6f..baf21ac22 100644 --- a/packages/codev/src/agent-farm/servers/seed-kick.ts +++ b/packages/codev/src/agent-farm/servers/seed-kick.ts @@ -146,8 +146,16 @@ export function armSeedKick( const state = readState(sessionId); // lastPrompt reflects the last SUBMITTED message (observed) — the strong // signal. updatedAt also moves on unrelated store writes (TUI open), so - // it is deliberately not treated as confirmation. - return !!state?.lastPrompt && state.lastPrompt.includes(opts.message); + // it is deliberately not treated as confirmation. The comparison must be + // normalized EQUALITY, not substring: on a fresh spawn lastPrompt is the + // SEED prompt (observed), whose ack-and-wait wrapper itself mentions the + // kick word ("wait for BEGIN") — a substring check would report success + // before the kick ever submitted, silently defeating the swallowed-Enter + // recovery (caught by the PR consultation, PIR #1201). A submitted + // message lands in lastPrompt with newlines flattened to spaces + // (observed), hence whitespace normalization on both sides. + return !!state?.lastPrompt && + normalizeWhitespace(state.lastPrompt) === normalizeWhitespace(opts.message); }; function deliverAndVerify(sessionId: string): void { @@ -192,3 +200,7 @@ export function armSeedKick( function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +function normalizeWhitespace(s: string): string { + return s.replace(/\s+/g, ' ').trim(); +} From b0ad0272e962f97df8d6ea1c7471c876b9fb1158 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:51:39 -0400 Subject: [PATCH 23/43] chore(porch): 1201 pr gate-requested --- .../projects/1201-support-kimi-code-cli-as-a-bui/status.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml index 31d793b5c..6e32f78fc 100644 --- a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -15,13 +15,15 @@ gates: approved_at: '2026-07-19T00:42:00.609Z' pr: status: pending + requested_at: '2026-07-19T00:51:39.674Z' iteration: 1 build_complete: true history: [] started_at: '2026-07-18T22:59:08.361Z' -updated_at: '2026-07-19T00:45:34.190Z' +updated_at: '2026-07-19T00:51:39.674Z' pr_history: - phase: review pr_number: 1203 branch: builder/pir-1201 created_at: '2026-07-19T00:45:29.121Z' +pr_ready_for_human: true From 3c0b6fc44969fac50801eb4ae738668bbce0e0cb Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:52:00 -0400 Subject: [PATCH 24/43] [PIR #1201] Thread: review phase + CMAP disposition logged Co-Authored-By: Claude Fable 5 --- codev/state/pir-1201_thread.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index c630a1550..5d8e27cb4 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -21,3 +21,11 @@ - Enter-delay bisect (real kimi 0.27.0, POC probe-10 method): 80ms fails (spike-confirmed), 120/250/500ms submit. Threshold ≈ 100ms; shipped constant pinned at 1000ms (~10x margin, POC-validated, latency-only cost). - Demo driver at `codev/spikes/pir-1201-kimi-builder-demo.mjs` — runs the REAL dist modules (script generator, armSeedKick, writeMessageToSession, buildResume) against a real kimi PTY, covering the architect's 4-point demo checklist without touching the global Tower. Full `afx spawn` path needs the branch build installed into Tower (`pnpm -w run local-install`) — that restarts Tower, so it's the human's call at the gate. - **Demo executed: ALL 5 steps PASS** (kimi 0.27.0, first run). Seed → sentinel → store-verified BEGIN (`lastPrompt="BEGIN"`); the ack-and-wait-with-task discipline HELD (spike addendum's open question — no fallback needed); multiline submitted with the pinned delay; TUI killed mid-session → `-S` restart recalled both role token and task verbatim; buildResume returned the pinned id. Sitting at dev-approval gate. + +## 2026-07-19 — Review phase + +- dev-approval approved after the human ran the full afx-spawn-through-Tower demo (all 4 checklist items live). +- Review file written; two lessons routed to COLD lessons-learned.md (advisory-decorator failure-totality; on-disk marker over schema for per-instance runtime facts). Arch already routed during implement (COLD arch.md subsection); no HOT-tier changes. +- Cross-fork PR opened: cluesmith/codev#1203 (head mohidmakhdoomi:builder/pir-1201). No self-merge — maintainers merge. +- CMAP (single advisory pass): gemini APPROVE, claude APPROVE, **codex REQUEST_CHANGES** — a real defect: seed-kick delivery confirmation used substring match on lastPrompt, but the fresh-spawn seed prompt itself contains "BEGIN", so the verifier false-positived before the kick submitted (the happy-path demo had masked it). **Fixed** (`732f04b8`): whitespace-normalized equality + two pinning regression tests; live demo re-run post-fix 5/5 PASS. Disposition recorded in `codev/projects/1201-*/1201-review-iter1-rebuttals.md` and flagged in the review's "Things to Look At" since PIR won't re-review it. Good CMAP catch — the exact class of thing solo review + a passing live demo can miss. +- Sitting at the pr gate. From 53bfea038de288533db69b9bbfafcdf85f3ff662 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:55:10 -0400 Subject: [PATCH 25/43] chore(porch): 1201 pr gate-approved --- .../1201-support-kimi-code-cli-as-a-bui/status.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml index 6e32f78fc..65674c073 100644 --- a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -14,16 +14,17 @@ gates: requested_at: '2026-07-18T23:43:32.608Z' approved_at: '2026-07-19T00:42:00.609Z' pr: - status: pending + status: approved requested_at: '2026-07-19T00:51:39.674Z' + approved_at: '2026-07-19T00:55:09.990Z' iteration: 1 build_complete: true history: [] started_at: '2026-07-18T22:59:08.361Z' -updated_at: '2026-07-19T00:51:39.674Z' +updated_at: '2026-07-19T00:55:09.990Z' pr_history: - phase: review pr_number: 1203 branch: builder/pir-1201 created_at: '2026-07-19T00:45:29.121Z' -pr_ready_for_human: true +pr_ready_for_human: false From 2f889cf2f1e6fa0b0d040c83783bb851bee9900f Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:55:25 -0400 Subject: [PATCH 26/43] chore(porch): 1201 protocol complete --- .../projects/1201-support-kimi-code-cli-as-a-bui/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml index 65674c073..7261541a4 100644 --- a/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -1,7 +1,7 @@ id: '1201' title: support-kimi-code-cli-as-a-bui protocol: pir -phase: review +phase: verified plan_phases: [] current_plan_phase: null gates: @@ -21,7 +21,7 @@ iteration: 1 build_complete: true history: [] started_at: '2026-07-18T22:59:08.361Z' -updated_at: '2026-07-19T00:55:09.990Z' +updated_at: '2026-07-19T00:55:25.203Z' pr_history: - phase: review pr_number: 1203 From 08d4311f52eec6f397dfb067872264f631bddfbe Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:55:46 -0400 Subject: [PATCH 27/43] [PIR #1201] Thread: pr gate approved, porch wrapped; PR open for maintainers Co-Authored-By: Claude Fable 5 --- codev/state/pir-1201_thread.md | 1 + 1 file changed, 1 insertion(+) diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index 5d8e27cb4..bda2f438a 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -29,3 +29,4 @@ - Cross-fork PR opened: cluesmith/codev#1203 (head mohidmakhdoomi:builder/pir-1201). No self-merge — maintainers merge. - CMAP (single advisory pass): gemini APPROVE, claude APPROVE, **codex REQUEST_CHANGES** — a real defect: seed-kick delivery confirmation used substring match on lastPrompt, but the fresh-spawn seed prompt itself contains "BEGIN", so the verifier false-positived before the kick submitted (the happy-path demo had masked it). **Fixed** (`732f04b8`): whitespace-normalized equality + two pinning regression tests; live demo re-run post-fix 5/5 PASS. Disposition recorded in `codev/projects/1201-*/1201-review-iter1-rebuttals.md` and flagged in the review's "Things to Look At" since PIR won't re-review it. Good CMAP catch — the exact class of thing solo review + a passing live demo can miss. - Sitting at the pr gate. +- pr gate approved by the human; porch protocol wrapped (`verified`, complete). Per the fork flow the merge is NOT ours: PR cluesmith/codev#1203 stays open for the maintainers, so no `--merged` record exists yet (recording one would be false state — it can be added if/when the maintainers merge). Standing by for maintainer feedback relayed via the architect. From 47d12ba9e433cb08b062e862e420d6f8364d0297 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 20:55:56 -0400 Subject: [PATCH 28/43] [PIR #1201] Record CMAP iter-1 disposition (codex finding accepted+fixed) Co-Authored-By: Claude Fable 5 --- .../1201-review-iter1-rebuttals.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-review-iter1-rebuttals.md diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-review-iter1-rebuttals.md b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-review-iter1-rebuttals.md new file mode 100644 index 000000000..94c523ee1 --- /dev/null +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-review-iter1-rebuttals.md @@ -0,0 +1,21 @@ +# Iteration 1 — disposition of review feedback (PIR #1201) + +Verdicts: gemini APPROVE · claude APPROVE · codex REQUEST_CHANGES. + +## Codex finding 1 — seed-kick confirmation false-positive: ACCEPTED, FIXED + +**Claim**: `seed-kick.ts` confirmed delivery via `state.lastPrompt.includes(opts.message)`; on a fresh spawn the seed prompt itself contains "BEGIN" (the ack-and-wait wrapper says 'You will receive a message "BEGIN"…' and the briefing header says "do not act until BEGIN"), so the verifier could report success even when the Tower-sent BEGIN never submitted — defeating the swallowed-Enter recovery. + +**Assessment**: real defect, confirmed against the spike's observed behavior (after a `kimi -p` seed, `state.json.lastPrompt` = the seed prompt). The live demo had not caught it because its kick genuinely submitted (`lastPrompt` overwritten to exactly `BEGIN`) — the false-positive window only matters on the failure path the verification exists to heal. + +**Fix** (commit `732f04b8`): confirmation now requires **whitespace-normalized equality** between `lastPrompt` and the kick message. Normalization matters because submitted multi-line messages land in `lastPrompt` with newlines flattened to spaces (observed, kimi 0.27.0), and it keeps the predicate correct for the pre-planned fallback where the whole task prompt becomes the kick payload. + +**Pinning tests** (both fail on the pre-fix code): +1. `seed-kick.test.ts` — "the SEED prompt containing the kick word is NOT confirmation": store state carrying a BEGIN-mentioning seed prompt must not confirm and must escalate to the Enter re-send; confirmation only fires once `lastPrompt` becomes exactly `BEGIN`. +2. "confirmation tolerates the observed newline-flattening": a multi-line kick payload still confirms through the flattening. + +**Post-fix validation**: full seed-kick suite 14/14; live demo re-run against real kimi 0.27.0 → 5/5 PASS (no false negative from the stricter predicate). + +## Codex finding 2 — test suite missed the case: ACCEPTED, FIXED + +Covered by the two pinning tests above; also documented in the review file's "Things to Look At During PR Review" with an explicit note that PIR's single-pass consultation did **not** re-review the fix, flagging `confirmed()` for the human's attention at the `pr` gate. From 2abd362a944a41ed6ee4a65411e241f87380b718 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Wed, 22 Jul 2026 21:21:25 -0400 Subject: [PATCH 29/43] [PIR #1201] Fix: persist the Kimi pacing marker on the bare launch shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer review (PR #1203): the bare no-role/no-prompt shape never wrote .builder-kimi-session, so pacing resolution fell back to the config-resolved harness and an override-spawned bare Kimi builder got claude's 80ms Enter — the swallowed-Enter bug this PR fixes. The bare branch now touches the marker (empty; preserves a previously seeded id; seed `! -s` guard and resume empty-id fallthrough unaffected), so every Kimi launch shape persists it. Regression tests: bare-script marker pins at the harness and spawn-worktree layers (both fail pre-fix; the spawn-worktree one previously asserted the marker's ABSENCE), plus a real-fs pacing test pinning the probe as existence-based (empty marker beats claude config). arch.md and the message-pacing.ts header now carry the softened, accurate claim: every launch shape persists the marker; presence is a breadcrumb, not an iff. Co-Authored-By: Claude Fable 5 --- codev/resources/arch.md | 2 +- codev/state/pir-1201_thread.md | 7 ++++++ .../src/agent-farm/__tests__/harness.test.ts | 8 ++++++- .../__tests__/spawn-worktree.test.ts | 8 +++++-- .../servers/__tests__/message-pacing.test.ts | 22 +++++++++++++++++++ .../src/agent-farm/servers/message-pacing.ts | 13 ++++++----- .../codev/src/agent-farm/utils/harness.ts | 16 ++++++++++++-- 7 files changed, 65 insertions(+), 11 deletions(-) diff --git a/codev/resources/arch.md b/codev/resources/arch.md index 70de42297..a456ffdd5 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -287,7 +287,7 @@ Kimi documents **no system-prompt flag and no positional prompt** (both exit 1), **Task delivery is readiness-gated and store-verified** (`servers/seed-kick.ts`): bytes written to the PTY during the ~5–15s seed window have no consumer and are silently lost, so the script prints `__CODEV_KIMI_SEED_DONE__ ` between seed and TUI, and Tower (armed via the `seedKick` field on `createTerminal`) waits for that sentinel + a grace period, writes the `BEGIN` kick, then confirms submission against the session store (`state.json.lastPrompt`), re-sending Enter → the kick → a loud warning on failure. Armed kicks are in-memory: a Tower restart during the seed window loses the kick (remediation: `afx send "BEGIN"`). -**Message pacing is per-harness** (`servers/message-pacing.ts` + `message-write.ts` `pacing.enterDelayMs`): Kimi's paste-detection window swallows an Enter sent 80ms after the body (the old default); Kimi targets get a ~1s delayed Enter. Resolution probes the worktree for `.builder-kimi-session` FIRST (override-proof for `--builder-cmd` spawns; survives Tower restarts) before falling back to the config-resolved harness. +**Message pacing is per-harness** (`servers/message-pacing.ts` + `message-write.ts` `pacing.enterDelayMs`): Kimi's paste-detection window swallows an Enter sent 80ms after the body (the old default); Kimi targets get a ~1s delayed Enter. Resolution probes the worktree for `.builder-kimi-session` FIRST (override-proof for `--builder-cmd` spawns; survives Tower restarts) before falling back to the config-resolved harness. The probe holds because **every Kimi launch shape persists the marker** — seed and resume write the session id, and the bare no-role/no-prompt shape touches it empty (existence-based probe, not content-based). The converse is not guaranteed: the marker is a launch breadcrumb, not proof of a live Kimi session — a leftover marker (e.g. a worktree later relaunched under another harness) keeps Kimi pacing until removed, whose only cost is a ~1s-slower Enter. **Builder resume is explicit-ID** (`KIMI_HARNESS.buildResume`): the ownership-verified `.builder-kimi-session` id, else the newest store session whose `state.json.workDir` exactly matches the worktree, else null → fresh-with-role re-seed (never a roleless fresh session; cwd-scoped `--continue` is deliberately not used). No `session` block exists — the architect stored-UUID contract requires mint-and-pin `newSessionArgs`, which Kimi cannot satisfy; generalizing that contract (async `seedSession` + async-buildable `CrashLoopFallback` per the task-Iptx spike addendum) is the stage-2 architect work. diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index bda2f438a..ade23d7a0 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -30,3 +30,10 @@ - CMAP (single advisory pass): gemini APPROVE, claude APPROVE, **codex REQUEST_CHANGES** — a real defect: seed-kick delivery confirmation used substring match on lastPrompt, but the fresh-spawn seed prompt itself contains "BEGIN", so the verifier false-positived before the kick submitted (the happy-path demo had masked it). **Fixed** (`732f04b8`): whitespace-normalized equality + two pinning regression tests; live demo re-run post-fix 5/5 PASS. Disposition recorded in `codev/projects/1201-*/1201-review-iter1-rebuttals.md` and flagged in the review's "Things to Look At" since PIR won't re-review it. Good CMAP catch — the exact class of thing solo review + a passing live demo can miss. - Sitting at the pr gate. - pr gate approved by the human; porch protocol wrapped (`verified`, complete). Per the fork flow the merge is NOT ours: PR cluesmith/codev#1203 stays open for the maintainers, so no `--merged` record exists yet (recording one would be false state — it can be added if/when the maintainers merge). Standing by for maintainer feedback relayed via the architect. + +## 2026-07-22 — Maintainer review iteration (PR #1203) + +- Maintainer (waleedkadous) REQUEST_CHANGES, one finding — real, accepted: the bare launch shape (no role, no prompt) never persisted `.builder-kimi-session`, so pacing resolution fell through to the config-resolved harness and an override-spawned bare Kimi builder (`--builder-cmd kimi` in a claude-configured workspace) got claude's 80ms Enter — the swallowed-Enter bug this PR exists to fix. The implement-phase claim "the marker exists iff the launch script is Kimi-shaped" was wrong for exactly this shape; seed and resume persisted it, bare did not. +- Fix (architect-driven; builder session had wrapped): the bare branch of `KIMI_HARNESS.buildBuilderLaunchScript` now `touch`es the marker before the TUI loop — empty (no id to pin), preserving any previously seeded id, and keeping both the seed `! -s` guard and buildResume's empty-id fallthrough intact. Every Kimi launch shape now persists the marker. +- Regression tests: the spawn-worktree bare-shape test that previously ASSERTED marker absence is flipped into the override-spawn pin, plus a harness-level bare-script pin (both fail pre-fix, verified) and a real-fs pacing test pinning the probe as existence-based (an empty marker must beat claude config — guards against a future content-based "improvement"). +- Docs: arch.md pacing paragraph and the message-pacing.ts header now state the accurate, softened claim — every launch shape persists the marker, probe is existence-based, and the converse doesn't hold (a leftover marker is a breadcrumb, not proof of a live Kimi session; cost of staleness is a ~1s-slower Enter). diff --git a/packages/codev/src/agent-farm/__tests__/harness.test.ts b/packages/codev/src/agent-farm/__tests__/harness.test.ts index 654ab3e57..1d5a81450 100644 --- a/packages/codev/src/agent-farm/__tests__/harness.test.ts +++ b/packages/codev/src/agent-farm/__tests__/harness.test.ts @@ -9,6 +9,7 @@ import { OPENCODE_HARNESS, KIMI_HARNESS, KIMI_SEED_SENTINEL, + KIMI_SESSION_FILE, buildCustomHarnessProvider, validateCustomHarnessConfig, resolveHarness, @@ -502,11 +503,16 @@ describe('harness', () => { expect(script).not.toContain('--append-system-prompt'); }); - it('bare (nothing to seed): plain TUI loop', () => { + it('bare (nothing to seed): plain TUI loop that still persists the pacing marker', () => { const script = KIMI_HARNESS.buildBuilderLaunchScript!({ ...ctxBase, seedFile: null }); expect(script).toContain('kimi --yolo'); expect(script).not.toContain('-S'); expect(script).not.toContain('stream-json'); + // PR #1203 review regression: EVERY Kimi launch shape must persist + // .builder-kimi-session — the pacing probe keys off its existence, so + // a bare override spawn without it resolves the config harness's + // Enter timing and sends get swallowed by paste detection. + expect(script).toContain(`touch ${KIMI_SESSION_FILE}`); }); it('does not duplicate --yolo when the user already passed it', () => { diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index d341b2bc8..b3cd7ed61 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -499,12 +499,16 @@ describe('spawn-worktree', () => { expect(seed).not.toContain('BEGIN'); }); - it('no role, no prompt → bare TUI loop without a seed', () => { + it('no role, no prompt (override spawn) → bare TUI loop without a seed, marker still persisted', () => { getBuilderHarnessMock.mockReturnValueOnce(KIMI_HARNESS); const script = buildWorktreeLaunchScript('/tmp/worktree', 'kimi', null, '/tmp/ws'); expect(script).toContain('kimi --yolo'); expect(script).not.toContain('stream-json'); - expect(script).not.toContain('.builder-kimi-session'); + // PR #1203 review regression: this is exactly the `--builder-cmd kimi` + // bare-spawn shape. Without the marker, pacing resolution falls back to + // the workspace's config harness (claude in an override spawn) and the + // swallowed-Enter bug this feature fixes comes back. + expect(script).toContain('touch .builder-kimi-session'); }); }); diff --git a/packages/codev/src/agent-farm/servers/__tests__/message-pacing.test.ts b/packages/codev/src/agent-farm/servers/__tests__/message-pacing.test.ts index 6785bacd3..7dcb9afb9 100644 --- a/packages/codev/src/agent-farm/servers/__tests__/message-pacing.test.ts +++ b/packages/codev/src/agent-farm/servers/__tests__/message-pacing.test.ts @@ -84,6 +84,28 @@ describe('resolvePacingForSession', () => { expect(resolvePacingForSession({ id: 'ghost', cwd: '/wt' })).toBeUndefined(); }); + it('bare override-spawn marker (empty file, as `touch` creates) → kimi pacing over claude config', async () => { + // PR #1203 review regression: the bare launch shape persists the marker + // EMPTY (no seeded id). The probe must key off existence, not content — + // a content-based probe would send a bare `--builder-cmd kimi` builder + // back to claude's Enter timing. Real fs, real empty file. + const realFs = await vi.importActual('node:fs'); + const { join } = await import('node:path'); + const { tmpdir } = await import('node:os'); + const dir = realFs.mkdtempSync(join(tmpdir(), 'pacing-bare-')); + try { + realFs.writeFileSync(join(dir, '.builder-kimi-session'), ''); + existsSyncMock.mockImplementation(realFs.existsSync); + getTerminalSessionByIdMock.mockReturnValue({ + id: 't8', workspace_path: '/ws', type: 'builder', cwd: dir, + }); + expect(resolvePacingForSession({ id: 't8', cwd: dir })).toBe(KIMI_HARNESS.messagePacing); + expect(getBuilderHarnessMock).not.toHaveBeenCalled(); + } finally { + realFs.rmSync(dir, { recursive: true, force: true }); + } + }); + it('a throwing harness resolution (unknown explicit name) degrades to default pacing', () => { getTerminalSessionByIdMock.mockReturnValue({ id: 't6', workspace_path: '/ws', type: 'builder', cwd: '/wt', diff --git a/packages/codev/src/agent-farm/servers/message-pacing.ts b/packages/codev/src/agent-farm/servers/message-pacing.ts index f5d980303..e8780ca40 100644 --- a/packages/codev/src/agent-farm/servers/message-pacing.ts +++ b/packages/codev/src/agent-farm/servers/message-pacing.ts @@ -7,11 +7,14 @@ * writing. * * Resolution order: - * 1. Worktree marker — a Kimi builder's launch script persists - * `.builder-kimi-session` in its cwd. This is deliberately checked FIRST: - * it is override-proof (correct even when the builder was spawned via - * `--builder-cmd kimi` against a workspace whose config says claude) and - * survives Tower restarts, since it lives on disk next to the session. + * 1. Worktree marker — every Kimi launch shape persists + * `.builder-kimi-session` in its cwd (seed/resume write the session id; + * the bare no-role/no-prompt shape touches it empty). This is + * deliberately checked FIRST: it is override-proof (correct even when the + * builder was spawned via `--builder-cmd kimi` against a workspace whose + * config says claude) and survives Tower restarts, since it lives on disk + * next to the session. The probe is existence-based, NOT content-based — + * an empty marker (bare shape) must still resolve Kimi pacing. * 2. Config-resolved harness for the terminal's registered role (builder / * architect) in its workspace — covers config-driven spawns and any future * harness that sets `messagePacing`. diff --git a/packages/codev/src/agent-farm/utils/harness.ts b/packages/codev/src/agent-farm/utils/harness.ts index 35329cd0b..495c28d0e 100644 --- a/packages/codev/src/agent-farm/utils/harness.ts +++ b/packages/codev/src/agent-farm/utils/harness.ts @@ -262,7 +262,12 @@ export const OPENCODE_HARNESS: HarnessProvider = { */ export const KIMI_SEED_SENTINEL = '__CODEV_KIMI_SEED_DONE__'; -/** File in the worktree persisting the seeded Kimi session id. */ +/** + * File in the worktree persisting the seeded Kimi session id. Doubles as the + * Kimi-shape marker for Tower's pacing probe (message-pacing.ts), so EVERY + * launch shape persists it: seed and resume write the captured id; the bare + * no-role/no-prompt shape touches it empty (there is no id to pin). + */ export const KIMI_SESSION_FILE = '.builder-kimi-session'; /** @@ -425,9 +430,16 @@ ${loop}`; // Nothing to seed (no role, no prompt): plain TUI loop. No session // pinning — restarts start fresh, matching the bare-mode behavior of - // other harnesses. + // other harnesses. The marker file is still persisted (empty — no id to + // pin) so Tower's pacing probe recognizes the worktree as Kimi-shaped; + // without it an override-spawned bare builder (`--builder-cmd kimi` in a + // claude-configured workspace) resolves the config harness's Enter timing + // and `afx send` payloads are swallowed by paste detection. `touch` + // preserves a previously seeded id, and an empty file keeps both the seed + // guard (`! -s`) and buildResume's empty-id fallthrough intact. return `#!/bin/bash cd "${ctx.worktreePath}" +touch ${KIMI_SESSION_FILE} while true; do ${tuiCmd} echo "" From 642b17266a157241bed45a4948f769d8834573e5 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Wed, 22 Jul 2026 21:27:41 -0400 Subject: [PATCH 30/43] [PIR #1201] Test: pin touch-before-loop ordering in bare-shape marker tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-fix CMAP on 2abd362a: gemini APPROVE, claude APPROVE, codex APPROVE with one MINOR — the regression tests asserted `touch .builder-kimi-session` appears in the generated script but not that it precedes the restart loop, so a refactor moving it after/inside the loop would keep them green while racing the first send. Both script-shape pins now assert the ordering, matching the suite's existing exit-1-before-loop precedent. Co-Authored-By: Claude Fable 5 --- codev/state/pir-1201_thread.md | 1 + packages/codev/src/agent-farm/__tests__/harness.test.ts | 6 +++++- .../codev/src/agent-farm/__tests__/spawn-worktree.test.ts | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index ade23d7a0..7111344a5 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -37,3 +37,4 @@ - Fix (architect-driven; builder session had wrapped): the bare branch of `KIMI_HARNESS.buildBuilderLaunchScript` now `touch`es the marker before the TUI loop — empty (no id to pin), preserving any previously seeded id, and keeping both the seed `! -s` guard and buildResume's empty-id fallthrough intact. Every Kimi launch shape now persists the marker. - Regression tests: the spawn-worktree bare-shape test that previously ASSERTED marker absence is flipped into the override-spawn pin, plus a harness-level bare-script pin (both fail pre-fix, verified) and a real-fs pacing test pinning the probe as existence-based (an empty marker must beat claude config — guards against a future content-based "improvement"). - Docs: arch.md pacing paragraph and the message-pacing.ts header now state the accurate, softened claim — every launch shape persists the marker, probe is existence-based, and the converse doesn't hold (a leftover marker is a breadcrumb, not proof of a live Kimi session; cost of staleness is a ~1s-slower Enter). +- Post-fix 3-way CMAP on 2abd362a (architect-run, commit-scoped): gemini APPROVE, claude APPROVE, codex APPROVE with one MINOR — the script-shape regression tests asserted the `touch` exists but not that it stays BEFORE the `while true` loop, so a refactor moving it inside/after the loop would keep them green. Accepted and fixed: ordering assertions added at both layers (harness + spawn-worktree), mirroring the suite's existing exit-1-before-loop precedent. Claude's NIT (thread phrasing) needs no action. diff --git a/packages/codev/src/agent-farm/__tests__/harness.test.ts b/packages/codev/src/agent-farm/__tests__/harness.test.ts index 1d5a81450..70d36b34a 100644 --- a/packages/codev/src/agent-farm/__tests__/harness.test.ts +++ b/packages/codev/src/agent-farm/__tests__/harness.test.ts @@ -511,8 +511,12 @@ describe('harness', () => { // PR #1203 review regression: EVERY Kimi launch shape must persist // .builder-kimi-session — the pacing probe keys off its existence, so // a bare override spawn without it resolves the config harness's - // Enter timing and sends get swallowed by paste detection. + // Enter timing and sends get swallowed by paste detection. The touch + // must also PRECEDE the loop, so the marker exists for the whole TUI + // lifetime (a touch after/inside the loop could race the first send). expect(script).toContain(`touch ${KIMI_SESSION_FILE}`); + expect(script.indexOf(`touch ${KIMI_SESSION_FILE}`)) + .toBeLessThan(script.indexOf('while true')); }); it('does not duplicate --yolo when the user already passed it', () => { diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index b3cd7ed61..e3adf54cf 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -507,8 +507,11 @@ describe('spawn-worktree', () => { // PR #1203 review regression: this is exactly the `--builder-cmd kimi` // bare-spawn shape. Without the marker, pacing resolution falls back to // the workspace's config harness (claude in an override spawn) and the - // swallowed-Enter bug this feature fixes comes back. + // swallowed-Enter bug this feature fixes comes back. The touch must + // precede the loop so the marker exists before the first send can land. expect(script).toContain('touch .builder-kimi-session'); + expect(script.indexOf('touch .builder-kimi-session')) + .toBeLessThan(script.indexOf('while true')); }); }); From 1de55e1336ed3f41eaf7e29f721a8810140e339c Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Wed, 22 Jul 2026 21:30:58 -0400 Subject: [PATCH 31/43] [PIR #1201] Test: complete existence guards on ordering assertions (CMAP iter 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMAP iteration 2 on 642b1726: 3x APPROVE with two complementary NITs, both accepted: the pre-existing exit-1-before-loop assertion lacked a toContain('exit 1') guard (removing exit 1 would vacuous-pass — indexOf returns -1, and -1 < anything), and the new touch-before-loop assertions lacked toContain('while true') (sound — a positive index is never < -1 — but the failure diagnostic would be opaque). Every ordering assertion in the kimi script-shape tests now guards both of its substrings. Co-Authored-By: Claude Fable 5 --- codev/state/pir-1201_thread.md | 1 + packages/codev/src/agent-farm/__tests__/harness.test.ts | 4 ++++ .../codev/src/agent-farm/__tests__/spawn-worktree.test.ts | 1 + 3 files changed, 6 insertions(+) diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index 7111344a5..9fd5e6011 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -38,3 +38,4 @@ - Regression tests: the spawn-worktree bare-shape test that previously ASSERTED marker absence is flipped into the override-spawn pin, plus a harness-level bare-script pin (both fail pre-fix, verified) and a real-fs pacing test pinning the probe as existence-based (an empty marker must beat claude config — guards against a future content-based "improvement"). - Docs: arch.md pacing paragraph and the message-pacing.ts header now state the accurate, softened claim — every launch shape persists the marker, probe is existence-based, and the converse doesn't hold (a leftover marker is a breadcrumb, not proof of a live Kimi session; cost of staleness is a ~1s-slower Enter). - Post-fix 3-way CMAP on 2abd362a (architect-run, commit-scoped): gemini APPROVE, claude APPROVE, codex APPROVE with one MINOR — the script-shape regression tests asserted the `touch` exists but not that it stays BEFORE the `while true` loop, so a refactor moving it inside/after the loop would keep them green. Accepted and fixed: ordering assertions added at both layers (harness + spawn-worktree), mirroring the suite's existing exit-1-before-loop precedent. Claude's NIT (thread phrasing) needs no action. +- CMAP iter 2 (commit-scoped, 642b1726): codex APPROVE (none), gemini APPROVE + NIT, claude APPROVE + NIT — two complementary guard gaps in the same tests, both verified against the file and accepted: (1) gemini — the pre-existing exit-1-before-loop precedent lacked a `toContain('exit 1')` guard, so removing `exit 1` would vacuous-pass (`indexOf` → -1, and -1 < anything); (2) claude — the new ordering assertions lacked `toContain('while true')`, sound but with an opaque failure message if the loop construct ever changed. Both fixed (one-line guards). Loop protocol updated per the human: iteration 3+ reviews the ENTIRE cumulative maintainer-response diff (47d12ba9..HEAD), not per-commit. diff --git a/packages/codev/src/agent-farm/__tests__/harness.test.ts b/packages/codev/src/agent-farm/__tests__/harness.test.ts index 70d36b34a..b23b54ffe 100644 --- a/packages/codev/src/agent-farm/__tests__/harness.test.ts +++ b/packages/codev/src/agent-farm/__tests__/harness.test.ts @@ -485,6 +485,9 @@ describe('harness', () => { expect(script).toContain('kimi --yolo -S "$SID"'); expect(script).toContain('while true'); // Seed failure exits BEFORE the loop — surfaced, never restart-looped. + // toContain guards the ordering check against a vacuous pass: without + // it, removing `exit 1` makes indexOf return -1, and -1 < anything. + expect(script).toContain('exit 1'); expect(script.indexOf('exit 1')).toBeLessThan(script.indexOf('while true')); // The #929/#1062 regression class: no claude-shaped flags, no // positional prompt appended to the CLI. @@ -515,6 +518,7 @@ describe('harness', () => { // must also PRECEDE the loop, so the marker exists for the whole TUI // lifetime (a touch after/inside the loop could race the first send). expect(script).toContain(`touch ${KIMI_SESSION_FILE}`); + expect(script).toContain('while true'); expect(script.indexOf(`touch ${KIMI_SESSION_FILE}`)) .toBeLessThan(script.indexOf('while true')); }); diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index e3adf54cf..5c78580c0 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -510,6 +510,7 @@ describe('spawn-worktree', () => { // swallowed-Enter bug this feature fixes comes back. The touch must // precede the loop so the marker exists before the first send can land. expect(script).toContain('touch .builder-kimi-session'); + expect(script).toContain('while true'); expect(script.indexOf('touch .builder-kimi-session')) .toBeLessThan(script.indexOf('while true')); }); From e3bfa5616e674c3a87bfffd86692b0178327210d Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Wed, 22 Jul 2026 21:33:26 -0400 Subject: [PATCH 32/43] =?UTF-8?q?[PIR=20#1201]=20Thread:=20CMAP=20loop=20c?= =?UTF-8?q?onverged=20(iter=203=20=E2=80=94=203x=20APPROVE,=20zero=20findi?= =?UTF-8?q?ngs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- codev/state/pir-1201_thread.md | 1 + 1 file changed, 1 insertion(+) diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index 9fd5e6011..3d91aa718 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -39,3 +39,4 @@ - Docs: arch.md pacing paragraph and the message-pacing.ts header now state the accurate, softened claim — every launch shape persists the marker, probe is existence-based, and the converse doesn't hold (a leftover marker is a breadcrumb, not proof of a live Kimi session; cost of staleness is a ~1s-slower Enter). - Post-fix 3-way CMAP on 2abd362a (architect-run, commit-scoped): gemini APPROVE, claude APPROVE, codex APPROVE with one MINOR — the script-shape regression tests asserted the `touch` exists but not that it stays BEFORE the `while true` loop, so a refactor moving it inside/after the loop would keep them green. Accepted and fixed: ordering assertions added at both layers (harness + spawn-worktree), mirroring the suite's existing exit-1-before-loop precedent. Claude's NIT (thread phrasing) needs no action. - CMAP iter 2 (commit-scoped, 642b1726): codex APPROVE (none), gemini APPROVE + NIT, claude APPROVE + NIT — two complementary guard gaps in the same tests, both verified against the file and accepted: (1) gemini — the pre-existing exit-1-before-loop precedent lacked a `toContain('exit 1')` guard, so removing `exit 1` would vacuous-pass (`indexOf` → -1, and -1 < anything); (2) claude — the new ordering assertions lacked `toContain('while true')`, sound but with an opaque failure message if the loop construct ever changed. Both fixed (one-line guards). Loop protocol updated per the human: iteration 3+ reviews the ENTIRE cumulative maintainer-response diff (47d12ba9..HEAD), not per-commit. +- CMAP iter 3 (full cumulative maintainer-response diff, 47d12ba9..1de55e13): gemini APPROVE / codex APPROVE / claude APPROVE, all with zero findings — loop converged. Claude's pass verified the no-race property (touch completes before Tower registers the terminal, so no send can precede the marker) and cross-file doc consistency (KIMI_SESSION_FILE JSDoc, message-pacing.ts header, arch.md tell one story). This journal entry is the termination record; it makes no code/doc claims and does not itself re-trigger the review loop. From bfc8d62f79961dff184e5370f5c777cf6d179c6c Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 25 Jul 2026 14:04:07 -0400 Subject: [PATCH 33/43] [PIR #1201] Adopt shared LAUNCH_LOOP_TAIL (#1244) in Kimi provider-owned loops Deliberate exit 0 now gates relaunch on a keypress instead of blind auto-respawn, matching the post-#1244 builder launch-loop contract. LAUNCH_LOOP_TAIL moves to utils/harness.ts (exported) so provider-owned scripts share it without a circular import; tests pin the tail across all Kimi launch shapes (fresh/resume/bare, seeded/bare interactive). Co-Authored-By: Claude Fable 5 --- codev/state/pir-1201_thread.md | 6 +++ .../src/agent-farm/__tests__/harness.test.ts | 17 ++++++++ .../__tests__/spawn-worktree.test.ts | 17 ++++++++ .../src/agent-farm/commands/spawn-worktree.ts | 30 +------------ .../codev/src/agent-farm/utils/harness.ts | 43 ++++++++++++++++--- 5 files changed, 78 insertions(+), 35 deletions(-) diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index a58e293db..74bd4ae93 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -45,3 +45,9 @@ - Human authorized resolving PR #1203 against current `origin/main` without merging the PR. The merge had one conflict, in `packages/codev/src/agent-farm/lib/tower-client.ts`; resolved by retaining all four type re-exports required by both branches: `HuskCandidate`, `HuskPreview`, `HuskSweepResult`, and `SeedKickRequest`. `git diff --name-only --diff-filter=U` confirmed no other conflicts. - Post-resolution verification: `pnpm build` passed; full `pnpm test` passed (185 files passed, 3 skipped; 3716 tests passed, 48 skipped). Branch is ready to push for CI. + +## 2026-07-25T18:04Z — post-approval iteration: adopt #1244 loop tail +- Merged origin/main (brings PR #1244's keypress-gated launch-loop contract). +- Moved LAUNCH_LOOP_TAIL from spawn-worktree.ts (module-local) to utils/harness.ts (exported) so Kimi's provider-owned scripts share it without a circular import; both Kimi loops (pinned -S and bare) now use it. +- Pinned the new tail across all Kimi shapes in harness.test.ts and spawn-worktree.test.ts. +- Suites green (harness+spawn-worktree 169, message-pacing+seed-kick 22); build clean. diff --git a/packages/codev/src/agent-farm/__tests__/harness.test.ts b/packages/codev/src/agent-farm/__tests__/harness.test.ts index b23b54ffe..405bfb4fb 100644 --- a/packages/codev/src/agent-farm/__tests__/harness.test.ts +++ b/packages/codev/src/agent-farm/__tests__/harness.test.ts @@ -523,6 +523,23 @@ describe('harness', () => { .toBeLessThan(script.indexOf('while true')); }); + // Bugfix #1241 / PR #1244: Kimi's provider-owned loops must share the + // exit-code-gated tail — a deliberate exit 0 gates the relaunch on a + // keypress instead of blind auto-respawn; crashes keep the auto-restart. + it.each([ + ['fresh', { ...ctxBase, seedFile: '/tmp/wt/.builder-seed.txt' }], + ['resume', { ...ctxBase, seedFile: null, resume: { sessionId: 'session_abc' } }], + ['bare', { ...ctxBase, seedFile: null }], + ] as const)('%s shape does not auto-restart on exit 0', (_name, ctx) => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!(ctx); + expect(script).toContain('status=$?'); + expect(script).toContain('if [ "$status" -eq 0 ]; then'); + expect(script).toContain('Press Enter to relaunch'); + expect(script).toContain('read -r || exit 0'); + // The crash path is untouched. + expect(script).toContain('Restarting in 2 seconds'); + }); + it('does not duplicate --yolo when the user already passed it', () => { const script = KIMI_HARNESS.buildBuilderLaunchScript!({ worktreePath: '/tmp/wt', baseCmd: 'kimi --yolo', seedFile: null, diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index b4472e333..560a2376b 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -537,6 +537,23 @@ describe('spawn-worktree', () => { expect(script.indexOf('touch .builder-kimi-session')) .toBeLessThan(script.indexOf('while true')); }); + + // Bugfix #1241 / PR #1244: Kimi's provider-owned scripts share the same + // exit-code-gated loop tail as the generic shapes — deliberate exit 0 + // must NOT auto-respawn. + it.each([ + ['seeded (role)', { content: 'ROLE BODY', source: 'codev' }], + ['bare (override spawn)', null], + ] as const)('%s → script does not auto-restart on exit 0', (_name, role) => { + getBuilderHarnessMock.mockReturnValueOnce(KIMI_HARNESS); + const script = buildWorktreeLaunchScript('/tmp/worktree', 'kimi', role, '/tmp/ws'); + expect(script).toContain('status=$?'); + expect(script).toContain('if [ "$status" -eq 0 ]; then'); + expect(script).toContain('Press Enter to relaunch'); + expect(script).toContain('read -r || exit 0'); + // The crash path is untouched. + expect(script).toContain('Restarting in 2 seconds'); + }); }); // ========================================================================= diff --git a/packages/codev/src/agent-farm/commands/spawn-worktree.ts b/packages/codev/src/agent-farm/commands/spawn-worktree.ts index ae0730d06..bd021a181 100644 --- a/packages/codev/src/agent-farm/commands/spawn-worktree.ts +++ b/packages/codev/src/agent-farm/commands/spawn-worktree.ts @@ -14,7 +14,7 @@ import { globSync } from 'glob'; import type { Config, ProtocolDefinition } from '../types.js'; import { logger, fatal } from '../utils/logger.js'; import { getBuilderHarness, getWorktreeConfig } from '../utils/config.js'; -import { shellEscapeSingleQuote, type HarnessProvider } from '../utils/harness.js'; +import { shellEscapeSingleQuote, LAUNCH_LOOP_TAIL, type HarnessProvider } from '../utils/harness.js'; import { defaultSessionOptions } from '../../terminal/index.js'; import { run, runStreaming, commandExists } from '../utils/shell.js'; import { fetchIssueOrThrow, type ForgeIssue } from '../../lib/github.js'; @@ -803,34 +803,6 @@ function buildProviderOwnedScript( return { scriptContent: build({ worktreePath, baseCmd, seedFile }), seedKick }; } -/** - * The tail shared by every builder launch loop, appended after the agent - * invocation inside `while true; do … done`. - * - * Issue #1241: exit code 0 is the user deliberately quitting (double Ctrl+C, - * `/quit`) — auto-respawning overrides that choice and forces them to race a - * second Ctrl+C into the sleep window, where a mistimed one lands in the fresh - * agent instead. It also feeds the #1224 class, where a respawn within ~2s - * collides with the dying predecessor's session lock. So a clean exit clears - * the screen and gates the relaunch on a keypress: recovery stays one keystroke - * away without anything happening on its own. Nonzero exits and signal deaths - * (bash reports those as 128+N) keep the historical auto-restart — that is what - * the loop is for. - * - * `read` failing means EOF on stdin, i.e. the terminal is gone; exit rather - * than spin the loop on an input that will never arrive. - */ -const LAUNCH_LOOP_TAIL = ` status=$? - if [ "$status" -eq 0 ]; then - clear - echo "Agent exited at your request. Press Enter to relaunch, or close this terminal." - read -r || exit 0 - continue - fi - echo "" - echo "Agent exited (code $status). Restarting in 2 seconds... (Ctrl+C to quit)" - sleep 2`; - /** * Start a terminal session for a builder. * diff --git a/packages/codev/src/agent-farm/utils/harness.ts b/packages/codev/src/agent-farm/utils/harness.ts index 495c28d0e..8e422ff54 100644 --- a/packages/codev/src/agent-farm/utils/harness.ts +++ b/packages/codev/src/agent-farm/utils/harness.ts @@ -177,6 +177,39 @@ export interface CustomHarnessConfig { roleScriptEnv?: Record; } +/** + * The tail shared by every builder launch loop, appended after the agent + * invocation inside `while true; do … done`. + * + * Issue #1241: exit code 0 is the user deliberately quitting (double Ctrl+C, + * `/quit`) — auto-respawning overrides that choice and forces them to race a + * second Ctrl+C into the sleep window, where a mistimed one lands in the fresh + * agent instead. It also feeds the #1224 class, where a respawn within ~2s + * collides with the dying predecessor's session lock. So a clean exit clears + * the screen and gates the relaunch on a keypress: recovery stays one keystroke + * away without anything happening on its own. Nonzero exits and signal deaths + * (bash reports those as 128+N) keep the historical auto-restart — that is what + * the loop is for. + * + * `read` failing means EOF on stdin, i.e. the terminal is gone; exit rather + * than spin the loop on an input that will never arrive. + * + * Lives here (not in spawn-worktree.ts) so provider-owned launch scripts — + * currently Kimi's `buildBuilderLaunchScript` — share the exact same tail as + * the generic shapes without a circular import (spawn-worktree.ts already + * imports from this module). + */ +export const LAUNCH_LOOP_TAIL = ` status=$? + if [ "$status" -eq 0 ]; then + clear + echo "Agent exited at your request. Press Enter to relaunch, or close this terminal." + read -r || exit 0 + continue + fi + echo "" + echo "Agent exited (code $status). Restarting in 2 seconds... (Ctrl+C to quit)" + sleep 2`; + // ============================================================================= // Built-in providers // ============================================================================= @@ -380,11 +413,11 @@ export const KIMI_HARNESS: HarnessProvider = { buildBuilderLaunchScript: (ctx) => { const tuiCmd = kimiTuiCmd(ctx.baseCmd); + // Shared loop tail (#1241/#1244): deliberate exit 0 → keypress-gated + // relaunch; nonzero/signal exits keep the historical auto-restart. const loop = `while true; do ${tuiCmd} -S "$SID" - echo "" - echo "Agent exited. Restarting in 2 seconds... (Ctrl+C to quit)" - sleep 2 +${LAUNCH_LOOP_TAIL} done `; @@ -442,9 +475,7 @@ cd "${ctx.worktreePath}" touch ${KIMI_SESSION_FILE} while true; do ${tuiCmd} - echo "" - echo "Agent exited. Restarting in 2 seconds... (Ctrl+C to quit)" - sleep 2 +${LAUNCH_LOOP_TAIL} done `; }, From 277967a6b7c12a6327d48b2e546ec42cc9b2796b Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 25 Jul 2026 14:07:46 -0400 Subject: [PATCH 34/43] [PIR #1201] Thread: CMAP + live kimi verification of loop-tail adoption Co-Authored-By: Claude Fable 5 --- codev/state/pir-1201_thread.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index 74bd4ae93..def55ed7e 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -51,3 +51,8 @@ - Moved LAUNCH_LOOP_TAIL from spawn-worktree.ts (module-local) to utils/harness.ts (exported) so Kimi's provider-owned scripts share it without a circular import; both Kimi loops (pinned -S and bare) now use it. - Pinned the new tail across all Kimi shapes in harness.test.ts and spawn-worktree.test.ts. - Suites green (harness+spawn-worktree 169, message-pacing+seed-kick 22); build clean. + +## 2026-07-25T18:07Z — CMAP + live verification of the loop-tail adoption +- CMAP (gemini, codex, claude) on the change set: unanimous APPROVE, zero findings, clean in one iteration. +- Full suite: 3802 passed / 48 skipped. +- Live kimi 0.29.1 verification (tmux PTY, real bare launch script from dist): /quit → exit 0 → keypress gate held (no respawn), Enter relaunched; SIGKILL → code 137 → auto-restart after 2s. Both branches behave per the #1244 contract. From 1424213fc6f575ead7650eaf84a2487158348aac Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 8 Aug 2026 23:01:58 -0400 Subject: [PATCH 35/43] [Spec 1201] fix: close a false-CLEAN on kimi's multi-row composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render gate delivers a message only onto a composer it can prove empty. For kimi it could not: KIMI_MARKER matches `│ >`, findMarkerRow takes the LAST match, and the scan started at that row — so a draft whose final line begins with `>` (a pasted quote, a markdown blockquote) put the marker on the CONTINUATION row and left the real text above the scanned region. The composer classified clean while holding unsent input, and a queued message would have been typed on top of it: the exact corruption Spec 1313 removes by construction. Found by the 3-way review, which reproduced it on a constructed screen and flagged that it could not confirm kimi's real multi-row geometry. Measured against real kimi 0.34.0 — it renders exactly that shape, so the defect is reachable, not theoretical. GateProfile gains an optional regionStartPatterns: an upper bound for the composer region, which kimi sets to the box top. The bound is EXCLUSIVE, mirroring the region end — the box-top row's right corner `╮` is not an ignorable glyph, so including that row counted it as user text and held every idle composer forever (caught by the fixture suite on the first attempt). Profiles that declare no region start keep scanning from the marker row exactly as before, and since no row below a last match can match, claude/codex/agy cannot reach any of the new behavior. The other input the review proposed — a marker-matching row below the composer in a second box — is NOT reachable in the shipped UI: measured, kimi's `/` menu renders as unclosed `│` rows with no `╰` beneath them, so a marker inside it yields no-region-end and holds. Captured as a fixture rather than argued. Four new fixtures, all from live capture: multiline-bare (the false CLEAN itself), multiline, menu, picker — the last two also answering the review's point that kimi shipped 3 fixtures where claude/codex ship menu and picker. --- .../fixtures/gate/kimi-menu.busy.txt | 60 ++++++++++++++ .../gate/kimi-multiline-bare.busy.txt | 45 +++++++++++ .../fixtures/gate/kimi-multiline.busy.txt | 37 +++++++++ .../fixtures/gate/kimi-picker.busy.txt | 71 +++++++++++++++++ .../agent-farm/__tests__/render-gate.test.ts | 56 ++++++++++++- .../src/agent-farm/servers/gate-profiles.ts | 16 ++++ .../agent-farm/servers/mailbox-delivery.ts | 10 ++- .../src/agent-farm/servers/render-gate.ts | 79 +++++++++++++++++-- 8 files changed, 364 insertions(+), 10 deletions(-) create mode 100644 packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-menu.busy.txt create mode 100644 packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline-bare.busy.txt create mode 100644 packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline.busy.txt create mode 100644 packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-picker.busy.txt diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-menu.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-menu.busy.txt new file mode 100644 index 000000000..fd48f31f2 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-menu.busy.txt @@ -0,0 +1,60 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-gate-6NoCq9 │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ > draft text  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 shift+enter: newline]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;;[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ > quoted second line  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ >  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ > /  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + │ → yolo Toggle YOLO mode: auto-approve tool actions, but the agent may still ask │]8;; + │  questions. │]8;; + │ model Switch LLM model │]8;; + │ permission Select permission mode │]8;; + │ plan Toggle plan mode │]8;; + │ settings Open TUI settings │]8;; + │  (1/48) │]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline-bare.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline-bare.busy.txt new file mode 100644 index 000000000..d74f0cd31 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline-bare.busy.txt @@ -0,0 +1,45 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-gate-6NoCq9 │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ > draft text  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 shift+enter: newline]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;;[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ > quoted second line  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ >  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline.busy.txt new file mode 100644 index 000000000..c8929e0d3 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline.busy.txt @@ -0,0 +1,37 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-gate-6NoCq9 │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ > draft text  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 shift+enter: newline]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;;[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ > quoted second line  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;;[?2026l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-picker.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-picker.busy.txt new file mode 100644 index 000000000..339c3175b --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-picker.busy.txt @@ -0,0 +1,71 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-gate-6NoCq9 │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ > draft text  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 shift+enter: newline]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;;[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ > quoted second line  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ >  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ > /  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + │ → yolo Toggle YOLO mode: auto-approve tool actions, but the agent may still ask │]8;; + │  questions. │]8;; + │ model Switch LLM model │]8;; + │ permission Select permission mode │]8;; + │ plan Toggle plan mode │]8;; + │ settings Open TUI settings │]8;; + │  (1/48) │]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ctrl+c: cancel | /theme to switch the terminal UI theme]8;; + context: 0% (0/256k)]8;; + + + + + + +[?2026l[?25l[?2026h  │ > @  │]8;;[?2026l[?25l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/render-gate.test.ts b/packages/codev/src/agent-farm/__tests__/render-gate.test.ts index a7f652a94..d2fff7d41 100644 --- a/packages/codev/src/agent-farm/__tests__/render-gate.test.ts +++ b/packages/codev/src/agent-farm/__tests__/render-gate.test.ts @@ -58,7 +58,7 @@ function profileForFixture(name: string): GateProfile { describe('render-gate — real captured fixtures (Spec 1313)', () => { const fixtures = readdirSync(FIXTURE_DIR).filter((f) => f.endsWith('.txt')).sort(); - it('the required states are all captured (claude+codex idle/draft/menu/picker, agy+kimi idle/draft/trust, wrapper/boot)', () => { + it('the required states are all captured (claude+codex+kimi idle/draft/menu/picker, agy+kimi trust, kimi multiline, wrapper/boot)', () => { for (const required of [ 'claude-idle.clean', 'claude-draft.busy', @@ -74,6 +74,14 @@ describe('render-gate — real captured fixtures (Spec 1313)', () => { 'kimi-idle.clean', 'kimi-draft.busy', 'kimi-trust.busy', + // The multi-row composer states. `kimi-multiline-bare` is the false-CLEAN + // this profile's regionStartPatterns exists to close — captured, not + // constructed — and menu/picker are the screen class where a LAST-match + // marker search is most likely to settle on the wrong row. + 'kimi-multiline.busy', + 'kimi-multiline-bare.busy', + 'kimi-menu.busy', + 'kimi-picker.busy', 'wrapper-boot.busy', ]) { expect(fixtures.some((f) => f.startsWith(required))).toBe(true); @@ -172,6 +180,52 @@ describe('render-gate — marker-span exemption is a no-op for claude/codex/agy .toMatchObject({ clean: true, detail: 'empty' }); }); + it('scans the WHOLE kimi composer box, so a draft above a bare-`>` row is still counted', async () => { + // The false-CLEAN found by the 3-way review (2026-08-09, claude F1), pinned against + // the real 0.34.0 capture rather than a constructed screen. kimi renders a two-line + // draft as `│ > implement the whole feature` / `│ >`; the second row matches the + // marker, findMarkerRow takes the LAST match, so scanning from the marker row left + // the real draft ABOVE the region and the composer read empty — a queued message + // would then have been typed on top of unsent user text. + const raw = readFileSync(`${FIXTURE_DIR}/kimi-multiline-bare.busy.txt`, 'utf8'); + expect(await classifyScreen(snapshotFromRaw(raw), KIMI_PROFILE)) + .toMatchObject({ clean: false, detail: 'user-text' }); + + // …and the fix is specifically the region start: the SAME bytes under a profile + // identical except that it declares no upper bound reproduce the old false CLEAN. + // If this ever stops classifying clean, the regionStartPatterns above is no longer + // what is protecting the composer, and this test has stopped testing the fix. + const { regionStartPatterns: _dropped, ...unbounded } = KIMI_PROFILE; + expect(await classifyScreen(snapshotFromRaw(raw), unbounded as GateProfile)) + .toMatchObject({ clean: true, detail: 'empty' }); + }); + + it('holds a boxed composer whose box top is off-screen instead of scanning a partial region', async () => { + // A marker row with no `╭` above it is a torn/mid-repaint frame for a boxed app. + // The region has no proven upper bound, so the safe answer is hold — the same call + // findRegionEnd already makes downward. + const snap = snapshotFromRaw(screen(' │ >', ' ╰────────────')); + expect(await classifyScreen(snap, KIMI_PROFILE)) + .toMatchObject({ clean: false, detail: 'no-region-start' }); + }); + + it('leaves claude/codex/agy on the marker row exactly as before (no region start declared)', async () => { + // The new upper bound is opt-in. These profiles declare none, so findRegionStart + // returns markerRow and the scan is byte-identical to the pre-change behavior — + // including that a row ABOVE the composer is never counted as draft text. + for (const p of [CLAUDE_PROFILE, CODEX_PROFILE, AGY_PROFILE]) { + expect(p.regionStartPatterns).toBeUndefined(); + } + // Behavioural half, on the two profiles whose marker survives screenLines' + // trimEnd on an empty composer (agy's `^> ` cannot — a bare `> ` row trims to + // `>` and stops matching, which is why its idle capture carries hint text). + // Text on the line ABOVE the composer is chat history, not a draft: still clean. + for (const [p, marker] of [[CLAUDE_PROFILE, '❯'], [CODEX_PROFILE, '›']] as const) { + const snap = snapshotFromRaw(screen('some earlier assistant output', marker, '──────────────────────')); + expect(await classifyScreen(snap, p)).toMatchObject({ clean: true, detail: 'empty' }); + } + }); + it('ignores g/y regex state so a stateful profile pattern cannot alias a previous call', () => { const sticky = /^\s*│\s*>/gy; expect(markerSpanEnd(' │ > ', sticky)).toBe(4); diff --git a/packages/codev/src/agent-farm/servers/gate-profiles.ts b/packages/codev/src/agent-farm/servers/gate-profiles.ts index efbde506b..c6be69477 100644 --- a/packages/codev/src/agent-farm/servers/gate-profiles.ts +++ b/packages/codev/src/agent-farm/servers/gate-profiles.ts @@ -110,6 +110,21 @@ const KIMI_MARKER = /^\s*│\s*>/; */ const KIMI_REGION_END = [/^\s*╰[─━╌┄]{3,}/]; +/** + * The rounded box TOP that opens kimi's composer (`` ╭─────╮ ``) — the region's + * upper bound, and the reason a multi-row kimi draft is scanned in full. + * + * kimi's composer grows downward: a two-line draft renders `│ > ` then + * `│ `. When line two begins with `>` (a pasted quote, a markdown + * blockquote) it matches {@link KIMI_MARKER} too, and since the classifier takes + * the LAST match, the region would start there and line one — real, unsent user + * text — would sit above it, uncounted. Measured on 0.34.0 (`kimi-multiline-bare` + * fixture): that screen classified `clean`, and a queued message would have been + * typed on top of the draft. Anchoring the region to the box top fixes it for any + * number of draft rows. + */ +const KIMI_REGION_START = [/^\s*╭[─━╌┄]{3,}/]; + /** * kimi composer profile (Issue #1201 — net-new measurement on 0.34.0, the same * shape of live capture the agy Phase-3 profile rests on). @@ -134,6 +149,7 @@ const KIMI_REGION_END = [/^\s*╰[─━╌┄]{3,}/]; export const KIMI_PROFILE: GateProfile = { app: 'kimi', markerPattern: KIMI_MARKER, + regionStartPatterns: KIMI_REGION_START, regionEndPatterns: KIMI_REGION_END, }; diff --git a/packages/codev/src/agent-farm/servers/mailbox-delivery.ts b/packages/codev/src/agent-farm/servers/mailbox-delivery.ts index 096bf4447..18f894184 100644 --- a/packages/codev/src/agent-farm/servers/mailbox-delivery.ts +++ b/packages/codev/src/agent-farm/servers/mailbox-delivery.ts @@ -232,8 +232,9 @@ export interface DeliveryOutcome { /** * A gate outcome the render gate CANNOT bound to a decision — an unrecognized app * (`no-profile`) or a recognized app whose composer region can't be found - * (`no-region-end`/`no-composer-marker` = a drifted TUI layout or an unrenderable #1047 - * ring). A sustained streak of these means the mail will NEVER deliver on its own, so it + * (`no-region-end`/`no-region-start`/`no-composer-marker` = a drifted TUI layout or an + * unrenderable #1047 ring). A sustained streak of these means the mail will NEVER deliver + * on its own, so it * is the class {@link MailboxDrainer.recordStreak} escalates to liveness telemetry; a * `busy`/`user-text` streak is deliberately excluded (a human legitimately at the line). * Shared by `recordStreak` and the cooldown branch of {@link MailboxDrainer.tick} so a @@ -243,7 +244,10 @@ function isClassifierStuck( reason: MailboxReason | null, detail: GateVerdict['detail'] | undefined ): boolean { - return reason === 'no-profile' || detail === 'no-region-end' || detail === 'no-composer-marker'; + return reason === 'no-profile' + || detail === 'no-region-end' + || detail === 'no-region-start' + || detail === 'no-composer-marker'; } /** diff --git a/packages/codev/src/agent-farm/servers/render-gate.ts b/packages/codev/src/agent-farm/servers/render-gate.ts index d2c811f2b..7874ad749 100644 --- a/packages/codev/src/agent-farm/servers/render-gate.ts +++ b/packages/codev/src/agent-farm/servers/render-gate.ts @@ -107,6 +107,24 @@ export interface GateProfile { * below the composer is never counted as user text. */ regionEndPatterns: RegExp[]; + /** + * Optional UPPER bound for the composer region, for apps whose composer spans + * more than the marker row (Issue #1201 — kimi draws a multi-row rounded box). + * + * Without one, scanning starts AT the marker row, and since {@link findMarkerRow} + * takes the LAST matching row, any lower row that looks like a marker moves the + * region down past real draft text — which is then never counted, so a composer + * holding a draft classifies CLEAN. Measured on kimi 0.34.0: a two-line draft + * whose second line is a bare `>` renders `│ > ` / `│ >`, and the second + * row matches kimi's marker. + * + * Set it and the region instead starts at the nearest matching line ABOVE the + * marker row (kimi: the box top `╭───`), so the whole composer is scanned. + * Left unset — claude, codex, agy — the region starts at the marker row exactly + * as before, and since no row below a LAST match can match, those profiles + * cannot reach any of the new behavior. + */ + regionStartPatterns?: RegExp[]; /** * Optional per-app placeholder signal: a 16-color palette index whose cells are * treated as placeholder/hint chrome (ignored), NOT user text. This is the @@ -129,10 +147,11 @@ export interface GateVerdict { * reason). `no-composer-marker` = wrapper/boot/picker/unknown screen (or a torn * replay that dropped the marker); `no-region-end` = a marker with no rule/status * line beneath it to bound the composer (a partial/mid-repaint frame) — held - * rather than scanning into status chrome; `user-text` = a draft or menu occupies - * the composer; `empty` = clean. + * rather than scanning into status chrome; `no-region-start` = the mirror of that + * for a profile whose composer is a box (kimi), when the box TOP is not on screen; + * `user-text` = a draft or menu occupies the composer; `empty` = clean. */ - detail: 'no-composer-marker' | 'no-region-end' | 'user-text' | 'empty'; + detail: 'no-composer-marker' | 'no-region-end' | 'no-region-start' | 'user-text' | 'empty'; } /** @@ -199,6 +218,33 @@ export function markerSpanEnd(line: string, pattern: RegExp): number { return m ? m.index + m[0].length : 1; } +/** + * First row of the composer region: the row just below the nearest + * `regionStartPatterns` match above `markerRow`, or -1 when the profile declares + * one and none is on screen. + * + * A profile with no `regionStartPatterns` returns `markerRow` — the original + * behavior, byte for byte. + * + * The bound is EXCLUSIVE, mirroring `endRow`: the matched line is the composer's + * boundary, not part of it. That matters concretely — kimi's box top renders + * `╭────╮`, and its right corner `╮` is not in {@link IGNORE_CHARS}, so including + * that row would count the corner as user text and hold every idle kimi composer + * forever. Excluding it keeps the region to the rows that can actually hold a draft. + * + * -1 is deliberate and mirrors {@link findRegionEnd}: for an app whose composer is + * a box, a marker with no box top above it is a partial/mid-repaint frame, so the + * region has no proven UPPER bound. Scanning from the marker row anyway is exactly + * the false-CLEAN this bound exists to prevent, so the caller must hold instead. + */ +function findRegionStart(lines: string[], markerRow: number, startPatterns?: RegExp[]): number { + if (!startPatterns || startPatterns.length === 0) return markerRow; + for (let i = markerRow - 1; i >= 0; i--) { + if (startPatterns.some((p) => p.test(lines[i]))) return i + 1; + } + return -1; +} + /** * First region-ending row after the marker (the rule/status line beneath the * composer), or -1 when none is found. -1 means the composer has no proven lower @@ -313,8 +359,20 @@ export function classifyBuffer( // empty/dim, return a false CLEAN). return { clean: false, reason: 'busy', detail: 'no-region-end' }; } + const startRow = findRegionStart(lines, markerRow, profile.regionStartPatterns); + if (startRow === -1) { + // A boxed composer whose box top is not on screen: the region has no proven + // upper bound, so scanning would count only the tail of a draft that may + // continue above. Hold — the same fail-toward-hold call as `no-region-end`. + return { clean: false, reason: 'busy', detail: 'no-region-start' }; + } const top = buf.viewportY; - const markerEnd = markerSpanEnd(lines[markerRow], profile.markerPattern); + // Re-compiled without g/y for the same reason markerSpanEnd does it: a stateful + // profile regex must not let one row's match position affect the next row's test. + const markerTest = new RegExp( + profile.markerPattern.source, + profile.markerPattern.flags.replace(/[gy]/g, ''), + ); const cell = buf.getNullCell(); const probe = buf.getNullCell(); // scratch cell for the ghost-tail look-ahead (never clobbers `cell`) // Cursor position is viewport-relative (matching `row`, which indexes from `viewportY`). @@ -322,14 +380,23 @@ export function classifyBuffer( const cursorCol = buf.cursorX; let userCells = 0; - for (let row = markerRow; row < endRow; row++) { + for (let row = startRow; row < endRow; row++) { const line = buf.getLine(top + row); if (!line) continue; + // The marker is chrome on EVERY row that renders it, not just the row the + // search settled on: a multi-row composer repeats its box edge, and with a + // region that starts above `markerRow` those upper rows are now scanned. + // Rows that do not match contribute 0, so profiles without a region start — + // where the only marker-matching row in the region IS `markerRow` — keep the + // exact previous exemption. + const markerEnd = markerTest.test(lines[row]) + ? markerSpanEnd(lines[row], profile.markerPattern) + : 0; for (let col = 0; col < cols; col++) { line.getCell(col, cell); const ch = cell.getChars(); if (!ch || WHITESPACE.test(ch) || IGNORE_CHARS.has(ch)) continue; - if (row === markerRow && col < markerEnd) continue; // the marker glyph itself (see markerSpanEnd) + if (col < markerEnd) continue; // the marker glyph itself (see markerSpanEnd) if (cell.isDim()) continue; // placeholder / hint chrome renders dim (claude/codex) if ( profile.placeholderFgPalette !== undefined && From 2c34cc717a1f79a932dbf103cde40c46566b805d Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 8 Aug 2026 23:02:15 -0400 Subject: [PATCH 36/43] [Spec 1201] fix: make the resume probe answer what kimi actually continues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inlined `node -e` store probe and findLatestKimiSessionId are the same question in two languages, and the cross-check test compared them against each other — agreement between duplicated omissions, not validation against kimi's continuation semantics. Two reviewers broke it from opposite directions: - DANGEROUS: an `archived: true` session matched on cwd alone, so the probe authorized `-c`. Kimi excludes archived sessions from the listing `-c` continues from, so it starts a FRESH session instead — one that never saw --agent-file, i.e. a silently roleless builder. That is the #929 hazard class this guard exists to prevent, reached through the guard itself. - SAFE BUT HARMFUL: readdirSync on a stray non-directory threw ENOTDIR into the single OUTER try, aborting the whole scan. One .DS_Store in the store disabled resume machine-wide, permanently and silently — a builder that crashed four hours in would restart with no context and its task re-queued. Same for a symlinked worktree and a trailing slash on the recorded cwd. Both implementations now share one resumability predicate — archived !== true and a `session_`-prefixed id, the filters kimi's own listing applies — plus sameDir's realpath tolerance, and each directory level gets its own try. Both filters err toward "not resumable", whose fallback is the role-carrying fresh launch. The predicate is deliberately NOT folded into iterateSessionDirs: inspectKimiStoreLayout must keep seeing unrecognized ids, because reporting that drift is its whole job. Every listed case is now a test asserting BOTH implementations. Also in the generated launch script: - Shell metacharacters. All three reviewers flagged the recovery hints, which interpolated builderId/taskFile into double-quoted bash echoes where bash re-scans them, so `$(…)` in a builder id executed when the hint printed. Every value now enters the script once as a single-quoted escaped assignment and is used through the shell variable; hints print via printf on the expansion, which bash does not re-scan. Pinned by running the generated function with a metacharacter id and asserting nothing executed. - Unbounded re-queueing. codev_launch_fresh queues the task, so a kimi dying before it minted a session re-queued the same mission every ~2s. The mailbox PERSISTS a held row, so one enqueue is enough; the guard resets only on the human-gated clean-exit relaunch, which is a deliberate new conversation and does want its task again. And in the doctor probes: both reported ok if ANY record matched, so after a store migration the pre-migration records hide every new one and the probe reports healthy through exactly the rename it exists to catch. Drift is now reported only when the newest non-conforming record is strictly newer than every conforming one; ties stay ok so the verdict never depends on directory iteration order. verifyKimi no longer reports "config issues" when spawnSync returns status null (spawn failure or timeout), which accused a healthy install on a slow machine, and two user-facing strings that still described the retired seed-session bootstrap now describe --agent-file. --- .../src/agent-farm/__tests__/harness.test.ts | 141 +++++++++++++++++- .../__tests__/kimi-session-discovery.test.ts | 51 ++++++- .../__tests__/spawn-worktree.test.ts | 3 +- .../codev/src/agent-farm/utils/harness.ts | 77 +++++++--- .../utils/kimi-session-discovery.ts | 126 ++++++++++++++-- packages/codev/src/commands/doctor.ts | 15 +- 6 files changed, 374 insertions(+), 39 deletions(-) diff --git a/packages/codev/src/agent-farm/__tests__/harness.test.ts b/packages/codev/src/agent-farm/__tests__/harness.test.ts index 566c14d50..f2007f86b 100644 --- a/packages/codev/src/agent-farm/__tests__/harness.test.ts +++ b/packages/codev/src/agent-farm/__tests__/harness.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, readFileSync, existsSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -575,8 +575,20 @@ describe('harness', () => { expect(script).toContain('--yolo'); // kimi takes no positional prompt, so the task rides the Spec 1313 mailbox // and the render gate delivers it onto a verified-empty composer. - expect(script).toContain("afx send 'pir-1201'"); - expect(script).toContain('/tmp/wt/.builder-prompt.txt'); + // + // The id and task path enter the script ONCE, as single-quoted assignments, + // and every later use goes through the shell variable — so a builder id or + // path containing a backtick or `$(…)` is never re-scanned as code, not even + // by the recovery hints (CMAP 2026-08-09). + expect(script).toContain("codev_builder_id='pir-1201'"); + expect(script).toContain("codev_task_file='/tmp/wt/.builder-prompt.txt'"); + expect(script).toContain('afx send "$codev_builder_id" "$(cat "$codev_task_file")"'); + // No interpolated value may appear inside a double-quoted echo/printf line, + // which is where bash WOULD re-scan it. + for (const line of script.split('\n').filter((l) => /^\s*(echo|printf)\b/.test(l))) { + expect(line).not.toContain('pir-1201'); + expect(line).not.toContain('/tmp/wt/.builder-prompt.txt'); + } // The #929/#1062 regression class: never claude-shaped flags, and never a // prompt appended as an argument (kimi exits 1 on both). Scoped to the lines // that actually INVOKE kimi — the script's prose mentions `afx spawn --resume`, @@ -784,6 +796,129 @@ describe('harness', () => { rmSync(join(fakeHome, '.kimi-code'), { recursive: true, force: true }); expect(runProbe(worktree)).toBe(false); }); + + it('queues the task ONCE across a crash-restart loop, and again after a clean-exit relaunch', () => { + // codex #4: codev_launch_fresh queues the task, and a kimi that dies before + // minting a session sends the loop back through fresh every 2s — so the same + // mission piled onto the mailbox indefinitely. The mailbox PERSISTS a held row, + // so one enqueue is enough; the human-gated clean-exit relaunch is the one + // deliberate new conversation that does want its task again. + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: 'pir-1201', + }); + writeFileSync(join(worktree, '.builder-prompt.txt'), 'THE TASK', 'utf-8'); + // Run the generated function bodies directly with a stub `afx` on PATH, + // driving the same state machine the loop does. + const bin = join(fakeHome, 'bin'); + mkdirSync(bin, { recursive: true }); + const calls = join(fakeHome, 'afx-calls.log'); + // `afx send ` → $3 is the task body. + writeFileSync(join(bin, 'afx'), `#!/bin/bash\necho "$3" >> '${calls}'\n`, { mode: 0o755 }); + const harnessFns = script.slice(script.indexOf('codev_builder_id='), script.indexOf('codev_has_session()')); + const res = spawnSync('bash', ['-c', + `${harnessFns}\n` + + // three crash-restart iterations, then a clean-exit relaunch + 'codev_queue_task; codev_queue_task; codev_queue_task\n' + + 'codev_task_queued=0\n' + + 'codev_queue_task\n', + ], { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, encoding: 'utf-8' }); + expect(res.status).toBe(0); + expect(readFileSync(calls, 'utf-8').trim().split('\n')).toEqual(['THE TASK', 'THE TASK']); + }); + + it('does not execute a builder id containing shell metacharacters', () => { + // claude F3 / codex #3: the recovery hints used to interpolate the id into a + // double-quoted echo, where bash re-scans it — so `$(…)` in an id ran when the + // hint printed. Proven at the shell, not by reading the string. + const evil = String.raw`pir-$(touch ${join(fakeHome, 'PWNED')})-\`touch ${join(fakeHome, 'PWNED2')}\``; + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: evil, + }); + const harnessFns = script.slice(script.indexOf('codev_builder_id='), script.indexOf('codev_has_session()')); + // No `afx` on PATH → both recovery hints print, which is the vulnerable path. + const res = spawnSync('bash', ['-c', `${harnessFns}\ncodev_queue_task\n`], + { env: { ...process.env, PATH: '/usr/bin:/bin' }, encoding: 'utf-8' }); + expect(res.status).toBe(0); + expect(existsSync(join(fakeHome, 'PWNED'))).toBe(false); + expect(existsSync(join(fakeHome, 'PWNED2'))).toBe(false); + // …and the id still reaches the human verbatim in the hint. + expect(res.stderr).toContain(evil); + }); + + // The divergences the 3-way review found (2026-08-09). Each asserts BOTH + // implementations, because the promise this block makes is that they agree — + // and every one of these used to be a case where they did not. + describe('the two implementations agree on the cases that used to split them', () => { + it('an ARCHIVED session does not authorize -c (kimi would not continue it)', () => { + // codex #1: `kimi -c` lists a cwd's sessions and drops archived ones, so + // resuming one silently starts a FRESH, roleless session. Existing on disk + // is not the same question as "kimi will continue it". + writeStoreSession('session_archived', { + id: 'session_archived', version: 2, cwd: worktree, updatedAt: 9, archived: true, + }); + expect(runProbe(worktree)).toBe(false); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })).toBeNull(); + }); + + it('an archived session does not mask a live one for the same cwd', () => { + writeStoreSession('session_archived', { + id: 'session_archived', version: 2, cwd: worktree, updatedAt: 99, archived: true, + }); + writeStoreSession('session_live', { + id: 'session_live', version: 2, cwd: worktree, updatedAt: 1, + }); + expect(runProbe(worktree)).toBe(true); + // …and the newer archived one must not win the recency race. + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId) + .toBe('session_live'); + }); + + it('a stray non-directory under sessions/ does not abort the whole scan', () => { + // claude F2: readdirSync on a file threw ENOTDIR into the single outer try, + // so ONE .DS_Store silently disabled resume for every worktree on the machine. + writeStoreSession('session_here', { id: 'session_here', version: 2, cwd: worktree, updatedAt: 1 }); + writeFileSync(join(fakeHome, '.kimi-code', 'sessions', '.DS_Store'), 'junk', 'utf-8'); + expect(runProbe(worktree)).toBe(true); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId).toBe('session_here'); + }); + + it('a stray non-directory INSIDE a wd bucket does not abort the scan either', () => { + writeStoreSession('session_here', { id: 'session_here', version: 2, cwd: worktree, updatedAt: 1 }); + writeFileSync( + join(fakeHome, '.kimi-code', 'sessions', 'wd_x_000000000000', 'index.jsonl'), + 'junk', + 'utf-8', + ); + expect(runProbe(worktree)).toBe(true); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId).toBe('session_here'); + }); + + it('a cwd recorded with a trailing slash still matches', () => { + writeStoreSession('session_slash', { + id: 'session_slash', version: 2, cwd: `${worktree}/`, updatedAt: 1, + }); + expect(runProbe(worktree)).toBe(true); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId).toBe('session_slash'); + }); + + it('a symlinked worktree path still matches (both sides realpath-tolerant)', () => { + const link = join(fakeHome, 'worktree-link'); + symlinkSync(worktree, link); + writeStoreSession('session_real', { id: 'session_real', version: 2, cwd: worktree, updatedAt: 1 }); + expect(runProbe(link)).toBe(true); + expect(KIMI_HARNESS.buildResume!(link, { homeDir: fakeHome })?.sessionId).toBe('session_real'); + }); + + it('a directory kimi would not recognize as a session id does not authorize -c', () => { + // The id `-c`'s listing filters on; an unrecognized directory is a drifted or + // stray one, and treating it as resumable is the roleless-fallback direction. + writeStoreSession('scratch_dir', { id: 'scratch_dir', version: 2, cwd: worktree, updatedAt: 1 }); + expect(runProbe(worktree)).toBe(false); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })).toBeNull(); + }); + }); }); describe('buildResume', () => { diff --git a/packages/codev/src/agent-farm/__tests__/kimi-session-discovery.test.ts b/packages/codev/src/agent-farm/__tests__/kimi-session-discovery.test.ts index f1e7b6a71..59ef6e2c9 100644 --- a/packages/codev/src/agent-farm/__tests__/kimi-session-discovery.test.ts +++ b/packages/codev/src/agent-farm/__tests__/kimi-session-discovery.test.ts @@ -13,7 +13,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, utimesSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; @@ -88,6 +88,26 @@ describe('kimi session discovery', () => { expect(findLatestKimiSessionId('/some/worktree', opts())).toBe('session_aaa'); }); + // Existing on disk is not the question — "would `kimi -c` continue it?" is. + // Kimi's cwd listing drops archived sessions and ids it does not recognize, and + // `-c` with nothing to continue does not fail: it starts a fresh session that + // never saw --agent-file, i.e. a silently roleless builder (#929 class). + it('skips an ARCHIVED session — kimi would not continue it', () => { + writeSession('session_archived', { cwd: '/wt', updatedAt: 5, archived: true }); + expect(findLatestKimiSessionId('/wt', opts())).toBeNull(); + }); + + it('prefers a live session over a NEWER archived one', () => { + writeSession('session_archived', { cwd: '/wt', updatedAt: 99, archived: true }); + writeSession('session_live', { cwd: '/wt', updatedAt: 1 }); + expect(findLatestKimiSessionId('/wt', opts())).toBe('session_live'); + }); + + it('skips a directory kimi would not recognize as a session id', () => { + writeSession('scratch-dir', { cwd: '/wt', updatedAt: 5 }); + expect(findLatestKimiSessionId('/wt', opts())).toBeNull(); + }); + it('picks the newest by updatedAt among matches (across wd dirs)', () => { writeSession('session_old', { workDir: '/wt', updatedAt: '2026-07-18T09:00:00Z' }, 'wd_a_111111111111'); writeSession('session_new', { workDir: '/wt', updatedAt: '2026-07-18T11:00:00Z' }, 'wd_b_222222222222'); @@ -162,6 +182,7 @@ describe('kimi session discovery', () => { cwd: '/wt', updatedAt: 1_760_000_000_000, version: 2, + archived: false, }); }); @@ -173,6 +194,7 @@ describe('kimi session discovery', () => { cwd: '/wt', updatedAt: Date.parse('2026-07-18T10:00:00Z'), version: null, + archived: false, }); }); @@ -182,6 +204,7 @@ describe('kimi session discovery', () => { cwd: '/wt', updatedAt: null, version: null, + archived: false, }); }); @@ -208,6 +231,32 @@ describe('kimi session discovery', () => { expect(inspectKimiStoreLayout(opts())).toEqual({ status: 'ok', sampled: 1 }); }); + // The blind spot in "any session matches" (CMAP 2026-08-09, codex #5): after a + // store migration the pre-migration sessions keep matching forever, so the probe + // would report healthy through exactly the rename it was built to catch. + /** Recency is the session directory's mtime; pin it so the ordering is explicit. */ + const touchDir = (dir: string, epochSeconds: number) => utimesSync(dir, epochSeconds, epochSeconds); + + it('reports drift when the NEWEST session stopped matching but older ones still do', () => { + touchDir(writeSession('session_old', { cwd: '/wt' }), 1_000); + touchDir(writeSession('session_new', { someRenamedField: '/wt' }), 9_000); + const layout = inspectKimiStoreLayout(opts()); + expect(layout.status).toBe('drifted'); + expect(layout.status === 'drifted' && layout.reason).toMatch(/most recently written session/); + }); + + it('stays ok when the non-matching session is the OLDER one (a leftover, not a migration)', () => { + touchDir(writeSession('session_old', { someRenamedField: '/wt' }), 1_000); + touchDir(writeSession('session_new', { cwd: '/wt' }), 9_000); + expect(inspectKimiStoreLayout(opts())).toEqual({ status: 'ok', sampled: 1 }); + }); + + it('stays ok on a tie, so the verdict never depends on directory iteration order', () => { + touchDir(writeSession('session_a', { cwd: '/wt' }), 5_000); + touchDir(writeSession('session_b', { someRenamedField: '/wt' }), 5_000); + expect(inspectKimiStoreLayout(opts())).toEqual({ status: 'ok', sampled: 1 }); + }); + it('names the working-directory field when no session carries one', () => { writeSession('session_bad1', '###'); writeSession('session_bad2', { noWorkDirKey: true }); diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index 281b2776d..280f130d6 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -518,7 +518,8 @@ describe('spawn-worktree', () => { const script = findWrite('.builder-start.sh'); expect(script).toBeDefined(); expect(script).toContain(`--agent-file '/tmp/worktree/${KIMI_AGENT_FILE}'`); - expect(script).toContain("afx send 'pir-k1'"); + expect(script).toContain("codev_builder_id='pir-k1'"); + expect(script).toContain('afx send "$codev_builder_id" "$(cat "$codev_task_file")"'); // The retired seed-session bootstrap leaves no trace. expect(script).not.toContain('stream-json'); expect(script).not.toContain('__CODEV_KIMI_SEED_DONE__'); diff --git a/packages/codev/src/agent-farm/utils/harness.ts b/packages/codev/src/agent-farm/utils/harness.ts index 5ab959200..4184bee65 100644 --- a/packages/codev/src/agent-farm/utils/harness.ts +++ b/packages/codev/src/agent-farm/utils/harness.ts @@ -413,24 +413,45 @@ function kimiTuiCmd(baseCmd: string): string { * * Fails CLOSED: any error (no store, unreadable dir, malformed JSON) exits * non-zero and the loop relaunches fresh WITH the role, which is always safe. - * `readStateJson`'s `cwd ?? workDir` tolerance is mirrored here; the generated - * snippet is pinned against a fixture store by a unit test so it cannot drift - * from {@link findLatestKimiSessionId} unnoticed. + * + * It mirrors {@link findLatestKimiSessionId} field for field — `cwd ?? workDir`, + * `sameDir`'s realpath tolerance, and `isResumable`'s archived / `session_` + * filters — because the two answer the same question in two languages and a + * divergence is a silent bug in EITHER direction: a probe that says yes where + * discovery says no sends `-c` down its roleless nothing-to-continue path, and a + * probe that says no where discovery says yes restarts a crashed builder with no + * context and re-queues its task. The generated snippet is pinned against fixture + * stores by a unit test that EXECUTES it and cross-checks both answers, so the + * mirroring cannot rot. */ const KIMI_HAS_SESSION_PROBE = - 'const {readdirSync,readFileSync}=require("fs"),{join}=require("path");' + + 'const {readdirSync,readFileSync,realpathSync}=require("fs"),{join}=require("path");' + 'const r=join(process.env.KIMI_CODE_HOME||join(require("os").homedir(),".kimi-code"),"sessions");' + - 'const c=process.argv[1];try{for(const a of readdirSync(r)){for(const b of readdirSync(join(r,a))){' + - 'try{const s=JSON.parse(readFileSync(join(r,a,b,"state.json"),"utf8"));' + - 'if((s.cwd??s.workDir)===c)process.exit(0)}catch{}}}}catch{}process.exit(1)'; + // Mirrors sameDir(): compare canonicalized paths, falling back to the literal + // when realpath fails, so a symlinked worktree or a trailing slash still matches. + 'const n=p=>{p=String(p).replace(/\\/+$/,"")||"/";try{return realpathSync(p)}catch{return p}};' + + 'const a0=process.argv[1],c=n(a0);' + + 'let ws=[];try{ws=readdirSync(r,{withFileTypes:true}).filter(e=>e.isDirectory())}catch{}' + + 'for(const w of ws){let ss=[];' + + // Each level gets its OWN try. A stray non-directory under sessions/ (a + // .DS_Store) made readdirSync throw ENOTDIR into the single outer try, which + // aborted the WHOLE scan — one junk file silently disabled resume for every + // worktree on the machine. + 'try{ss=readdirSync(join(r,w.name),{withFileTypes:true})' + + '.filter(e=>e.isDirectory()&&e.name.startsWith("session_"))}catch{continue}' + + 'for(const s of ss){try{const j=JSON.parse(readFileSync(join(r,w.name,s.name,"state.json"),"utf8"));' + + 'if(j.archived===true)continue;const d=j.cwd??j.workDir;' + + 'if(typeof d==="string"&&(d===a0||n(d)===c))process.exit(0)}catch{}}}' + + 'process.exit(1)'; export const KIMI_HARNESS: HarnessProvider = { buildRoleInjection: () => { throw new Error( 'Kimi is only supported as a builder shell, not as an architect shell ' + - '(stage 2 — see issue #1201). Kimi has no documented system-prompt flag; ' + - 'builder role injection uses a seed-session bootstrap owned by the builder ' + - 'launch script. Configure a different shell for the architect ' + + '(stage 2 — see issue #1201). Kimi takes no inline system-prompt argument: ' + + 'its role mechanism is "--agent-file ", which needs a file written ' + + 'into the agent\'s directory first — a seam only the builder launch path ' + + 'has. Configure a different shell for the architect ' + '(e.g., "claude --dangerously-skip-permissions" or "codex").', ); }, @@ -494,7 +515,7 @@ export const KIMI_HARNESS: HarnessProvider = { // harness probe matches on. if (!ctx.taskFile) { return `#!/bin/bash -cd "${ctx.worktreePath}" +cd '${shellEscapeSingleQuote(ctx.worktreePath)}' while true; do ${fresh} ${launchLoopTail()} @@ -513,15 +534,34 @@ done // Never a direct PTY write (Spec 1313 forbids it for message writers), so a // busy line, a boot screen, or 0.33.0's folder-trust dialog simply holds the // message instead of corrupting or losing it. - const queueTask = `codev_queue_task() { + // Every interpolated value enters the script exactly once, inside a + // single-quoted assignment escaped by shellEscapeSingleQuote — never inside + // executable double-quoted text. The recovery hints then print the values + // through `printf '%s\n'` with the shell VARIABLE expanded, because bash does + // not re-scan an expansion for command substitution: a builder id or task + // path containing a backtick or `$(…)` is printed literally instead of being + // executed when the hint is shown (CMAP 2026-08-09, codex #3 / claude F3). + const queueTask = `codev_builder_id='${shellEscapeSingleQuote(ctx.builderId ?? '')}' +codev_task_file='${shellEscapeSingleQuote(ctx.taskFile)}' +# Set once the task is on the mailbox, so a crash-restart loop cannot enqueue the +# same mission every two seconds while kimi is failing to start (the mailbox +# PERSISTS a held row — it does not need re-queueing to survive). Reset only on +# the human-gated clean-exit relaunch below, which is a deliberate new +# conversation and does want its task again. +codev_task_queued=0 +codev_queue_task() { + [ "$codev_task_queued" = 1 ] && return 0 if ! command -v afx >/dev/null 2>&1; then - echo "WARNING: afx is not on PATH — the builder's task was not queued." >&2 - echo " Queue it with: afx send ${ctx.builderId ?? ''} \\"\\$(cat '${ctx.taskFile}')\\"" >&2 + printf '%s\\n' "WARNING: afx is not on PATH — the builder's task was not queued." >&2 + printf '%s\\n' " Queue it with: afx send $codev_builder_id \\"\\$(cat $codev_task_file)\\"" >&2 + return 0 + fi + if afx send "$codev_builder_id" "$(cat "$codev_task_file")" >/dev/null 2>&1; then + codev_task_queued=1 return 0 fi - afx send '${shellEscapeSingleQuote(ctx.builderId ?? '')}' "$(cat '${shellEscapeSingleQuote(ctx.taskFile)}')" >/dev/null 2>&1 && return 0 - echo "WARNING: could not queue the builder's task (is Tower running?)." >&2 - echo " Retry with: afx send ${ctx.builderId ?? ''} \\"\\$(cat '${ctx.taskFile}')\\"" >&2 + printf '%s\\n' "WARNING: could not queue the builder's task (is Tower running?)." >&2 + printf '%s\\n' " Retry with: afx send $codev_builder_id \\"\\$(cat $codev_task_file)\\"" >&2 }`; // Crash restart resumes the conversation (#1233's builder-side contract) via @@ -531,7 +571,7 @@ done // ROLELESS builder (verified, 0.34.0). The guard fails closed, so the // fallback is always the role-carrying fresh launch. return `#!/bin/bash -cd "${ctx.worktreePath}" +cd '${shellEscapeSingleQuote(ctx.worktreePath)}' codev_fast_fail_secs="\${CODEV_LAUNCH_FAST_FAIL_SECS:-15}" ${queueTask} @@ -569,6 +609,7 @@ while true; do echo "Agent exited at your request. Press Enter to relaunch fresh, or close this terminal." read -r || exit 0 codev_launch=codev_launch_fresh + codev_task_queued=0 codev_fast_fails=0 continue fi diff --git a/packages/codev/src/agent-farm/utils/kimi-session-discovery.ts b/packages/codev/src/agent-farm/utils/kimi-session-discovery.ts index 8f5ba3352..8b454a7b0 100644 --- a/packages/codev/src/agent-farm/utils/kimi-session-discovery.ts +++ b/packages/codev/src/agent-farm/utils/kimi-session-discovery.ts @@ -29,7 +29,7 @@ // dir/cwd index). The directory scan below is the ground truth the index // mirrors; reading only the tree keeps us on one undocumented surface, not two. -import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs'; import { realpathSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { homedir } from 'node:os'; @@ -42,6 +42,13 @@ export interface KimiSessionState { updatedAt: number | null; /** Store schema version when present (v2 sessions carry `version: 2`). */ version: number | null; + /** + * v2's `archived` flag. Load-bearing for resume: kimi excludes archived + * sessions from the cwd listing `-c` continues from, so treating one as + * resumable makes `kimi -c` silently start a FRESH, roleless session — the + * #929 hazard the crash path exists to avoid (CMAP 2026-08-09, codex #1). + */ + archived: boolean; } export interface KimiDiscoveryOpts { @@ -58,6 +65,15 @@ export function getKimiHome(opts?: KimiDiscoveryOpts): string { return opts?.kimiHome ?? process.env.KIMI_CODE_HOME ?? join(homedir(), '.kimi-code'); } +/** Modification time in epoch ms, or -Infinity when it can't be read (ranks oldest). */ +function mtimeOrNegInf(p: string): number { + try { + return statSync(p).mtimeMs; + } catch { + return -Infinity; + } +} + /** Canonicalize a path for comparison; fall back to the input when realpath fails. */ function realpathOrSelf(p: string): string { try { @@ -107,6 +123,7 @@ function readStateJson(sessionDir: string): KimiSessionState | null { cwd: dir, updatedAt: parseTimestamp(parsed.updatedAt), version: typeof parsed.version === 'number' ? parsed.version : null, + archived: parsed.archived === true, }; } catch { return null; @@ -150,11 +167,30 @@ function* iterateSessionDirs(kimiHome: string): Generator<{ sessionId: string; s } } +/** + * Would `kimi -c` actually continue this session? + * + * Existing on disk is NOT enough. Kimi lists a cwd's sessions before continuing + * one, and that listing drops archived sessions and ids it does not recognize — + * so a session we call resumable but kimi skips sends `-c` down its + * nothing-to-continue path, which does not fail: it starts a FRESH session that + * never saw `--agent-file`, i.e. a silently roleless builder (#929 class). + * + * Both filters therefore err toward "not resumable", whose fallback is the + * role-carrying fresh launch — always safe. Deliberately NOT folded into + * {@link iterateSessionDirs}: {@link inspectKimiStoreLayout} must keep seeing + * unrecognized ids, because reporting that drift is its entire job. + */ +function isResumable(sessionId: string, state: KimiSessionState): boolean { + return sessionId.startsWith('session_') && !state.archived; +} + /** * Return the session id of the most recent Kimi session whose recorded working - * directory is exactly `absolutePath` (realpath-tolerant), or null when none - * exists. "Most recent" = max `updatedAt`; sessions with an unparseable - * timestamp rank oldest. + * directory is exactly `absolutePath` (realpath-tolerant) and that kimi would + * actually continue (see {@link isResumable}), or null when none exists. + * "Most recent" = max `updatedAt`; sessions with an unparseable timestamp rank + * oldest. */ export function findLatestKimiSessionId( absolutePath: string, @@ -167,6 +203,7 @@ export function findLatestKimiSessionId( for (const { sessionId, sessionDir } of iterateSessionDirs(home)) { const state = readStateJson(sessionDir); if (!state || !sameDir(state.cwd, absolutePath)) continue; + if (!isResumable(sessionId, state)) continue; // Unparseable timestamps rank below every real epoch (>= 0) but above the // initial -Infinity sentinel, so a lone malformed match is still returned. const rank = state.updatedAt ?? -1; @@ -191,7 +228,11 @@ export function verifyKimiSessionOwnership( opts?: KimiDiscoveryOpts, ): boolean { const state = readKimiSessionState(sessionId, opts); - return state !== null && sameDir(state.cwd, cwd); + // Same resumability filter discovery applies: an archived (or unrecognizably + // named) session exists on disk but is not one `kimi -c` will continue, and + // claiming ownership of it would hand the caller a resume that silently + // becomes a roleless fresh session. + return state !== null && sameDir(state.cwd, cwd) && isResumable(sessionId, state); } /** @@ -240,18 +281,55 @@ export function inspectKimiStoreLayout(opts?: KimiDiscoveryOpts): KimiStoreLayou let sawSessionDir = false; let sampled = 0; let badId: string | null = null; + // "Some session still matches" is too weak a health signal for a store that + // migrates: after a rename the OLD sessions keep matching forever and hide every + // new one, so the probe would report ok through exactly the migration it exists + // to catch (CMAP 2026-08-09, codex #5). So track the newest conforming session + // against the newest non-conforming one and report drift only when the bad one is + // STRICTLY newer — a tie (same timestamp, or no timestamps at all) reports ok, + // because a doctor warning that depends on directory-iteration order would be + // worse than the blind spot it closes. + let newestGood = -Infinity; + let newestBad = -Infinity; + let newestBadReason: string | null = null; for (const { sessionId, sessionDir } of iterateSessionDirs(home)) { sawSessionDir = true; - if (readStateJson(sessionDir) === null) continue; + const state = readStateJson(sessionDir); // The id `-S` accepts is the directory basename; 0.33.0+ prefixes it. - if (!sessionId.startsWith('session_')) { - badId ??= sessionId; + const goodId = sessionId.startsWith('session_'); + // Directory mtime, for EVERY session — not `updatedAt`. A session whose + // state.json no longer parses has no `updatedAt` to offer, and mixing the two + // would compare a kimi timestamp against a filesystem one, which is how the + // drifted session always wins. One signal, same units, available for all. + const recency = mtimeOrNegInf(sessionDir); + if (state !== null && goodId) { + sampled++; + if (recency > newestGood) newestGood = recency; continue; } - sampled++; + if (state === null) { + if (recency > newestBad) { + newestBad = recency; + newestBadReason = `state.json for "${sessionId}" no longer parses into a working-directory field`; + } + continue; + } + badId ??= sessionId; + if (recency > newestBad) { + newestBad = recency; + newestBadReason = `session id "${sessionId}" is no longer "session_"`; + } } if (!sawSessionDir) return { status: 'empty' }; - if (sampled > 0) return { status: 'ok', sampled }; + if (sampled > 0) { + if (newestBad > newestGood && newestBadReason) { + return { + status: 'drifted', + reason: `the most recently written session no longer matches the shape this integration reads — ${newestBadReason}; older sessions still match, which is what a store migration looks like`, + }; + } + return { status: 'ok', sampled }; + } if (badId) { return { status: 'drifted', @@ -300,6 +378,14 @@ export function inspectKimiTrustLayout(opts?: KimiDiscoveryOpts): KimiStoreLayou let sawRecord = false; let matched = 0; let mismatchExample: string | null = null; + // Same recency rule as the store probe, and the same conservative tie-break: + // after a scheme change kimi's OLD records keep agreeing forever, so "any record + // matches" would report healthy through the exact migration this probe exists to + // catch. Drift is reported only when the newest DISAGREEING record is strictly + // newer than every agreeing one. + let newestAgreeing = -Infinity; + let newestMismatchTime = -Infinity; + let newestMismatch: string | null = null; try { for (const name of readdirSync(dir)) { let root: unknown; @@ -310,14 +396,30 @@ export function inspectKimiTrustLayout(opts?: KimiDiscoveryOpts): KimiStoreLayou } if (typeof root !== 'string' || root.length === 0) continue; sawRecord = true; - if (basename(kimiTrustRecordPath(root, opts)) === name) matched++; - else mismatchExample ??= name; + const agrees = basename(kimiTrustRecordPath(root, opts)) === name; + const mtime = mtimeOrNegInf(join(dir, name)); + if (agrees) { + matched++; + if (mtime > newestAgreeing) newestAgreeing = mtime; + } else { + mismatchExample ??= name; + if (mtime > newestMismatchTime) { + newestMismatchTime = mtime; + newestMismatch = name; + } + } } } catch { return { status: 'empty' }; } if (!sawRecord) return { status: 'empty' }; + if (newestMismatch && newestMismatchTime > newestAgreeing) { + return { + status: 'drifted', + reason: `the most recently written workspace-trust record ("${newestMismatch}") does not match the derived "wd__" scheme, though older records still do — that is what a naming-scheme change looks like, and it means pre-recording trust for new builder worktrees has already stopped working`, + }; + } if (matched > 0) return { status: 'ok', sampled: matched }; return { status: 'drifted', diff --git a/packages/codev/src/commands/doctor.ts b/packages/codev/src/commands/doctor.ts index 66fd25f47..aefdd1d41 100644 --- a/packages/codev/src/commands/doctor.ts +++ b/packages/codev/src/commands/doctor.ts @@ -516,7 +516,13 @@ function verifyKimi(): CheckResult { const notes: string[] = []; try { const result = spawnSync('kimi', ['doctor'], { encoding: 'utf-8', timeout: 10000, stdio: 'pipe' }); - if (result.status !== 0) { + // `status` is null when the process never ran to completion (spawn failure, + // or the 10s timeout killing it by signal). That is "we learned nothing", + // not "config is broken" — reporting it as config issues would put a false + // failure in front of a user whose install is fine but whose machine is slow. + if (result.error || result.status === null) { + // nothing learned — stay silent rather than accuse a healthy install + } else if (result.status !== 0) { notes.push('"kimi doctor" reports config issues (config check, not auth)'); } } catch { @@ -966,9 +972,10 @@ export async function doctor(): Promise { recommendation: `Set shell.architect / shell.architectHarness to "codex" or "claude --dangerously-skip-permissions" in .codev/config.json, or define a custom "${architect.name}" harness and select it explicitly via shell.architectHarness (a bare shell.architect command stays retired)`, }); } else if (architect.name === 'kimi') { - // Issue #1201: kimi is builder-only. It has no documented - // system-prompt flag; builder role injection uses a seed-session - // bootstrap owned by the builder launch script. Architect support is + // Issue #1201: kimi is builder-only. Its role mechanism is + // `--agent-file `, which needs a file written into the agent's + // directory first — a seam only the builder launch path has, so there is + // nothing to inject into a bare architect command. Architect support is // stage 2. console.log(''); console.log(chalk.yellow(' ⚠') + ' Kimi is configured as architect shell — this is unsupported.'); From c8002da4f77a4b4715cf451f3611f6308dfa4f59 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 8 Aug 2026 23:02:28 -0400 Subject: [PATCH 37/43] [Spec 1201] docs: record the CMAP round, and fix the demo's role oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arch.md's Kimi section gains the facts this round established: the composer region's upper bound and why it is exclusive, the resumability filters and the probe divergences they close, the recency rule in both drift probes, and the single-quoted-assignment discipline in the generated script. The demo's two failing steps were the DEMO's fault, not the product's. Its role told the model to prefix every reply with a token and asserted on the prefix — which measures whether K3 honors a persistent output-format constraint, not whether the role was injected. Measured: it answered the task correctly while dropping the prefix, and when asked about its prefix it discussed the idea rather than emitting the token. The live --agent-file probe, run against a production-identical agent file, passed 7/7 including role survival across `kimi -c`. The demo now asks for a codeword — the same oracle the probe uses — with a comment saying why, so the weaker one does not come back. The measurement harness gains the screens the review said a happy-path run never produces: a multi-line draft, the same draft ending in a bare `>`, the `/` menu, and the `@` file picker. Those captures are what settled which of the two proposed false-CLEAN inputs was real. Commits the three probe scripts that arch.md and the profiles cite as evidence; they were untracked, so the "measured, see harness X" chain would have dangled after merge. --- .../1201-cmap-postpivot-dispositions.md | 147 +++++++ codev/resources/arch.md | 6 +- .../spikes/pir-1201-kimi-agentfile-probe.mjs | 163 ++++++++ codev/spikes/pir-1201-kimi-builder-demo.mjs | 377 +++++++++++------- codev/spikes/pir-1201-kimi-continue-probe.mjs | 82 ++++ codev/spikes/pir-1201-kimi-gate-measure.mjs | 231 +++++++++++ 6 files changed, 866 insertions(+), 140 deletions(-) create mode 100644 codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-postpivot-dispositions.md create mode 100644 codev/spikes/pir-1201-kimi-agentfile-probe.mjs create mode 100644 codev/spikes/pir-1201-kimi-continue-probe.mjs create mode 100644 codev/spikes/pir-1201-kimi-gate-measure.mjs diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-postpivot-dispositions.md b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-postpivot-dispositions.md new file mode 100644 index 000000000..2572f0033 --- /dev/null +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-postpivot-dispositions.md @@ -0,0 +1,147 @@ +# CMAP dispositions — post-pivot delta (2026-08-09) + +Three-way review of the design-pivot delta on PR #1203 (role → `--agent-file`, task → the +Spec 1313 mailbox, crash resume → guarded `kimi -c`), run after the `origin/main` merge at +`ae0d034a`. The brief asked reviewers to attack the shared `render-gate.ts` edit hardest, +per the architect's guardrail. + +**Verdicts: gemini APPROVE · codex REQUEST_CHANGES · claude REQUEST_CHANGES.** + +Both REQUEST_CHANGES verdicts were right, and they found the same two defects from opposite +directions. Neither was reachable from a happy-path live run — an empty composer and a clean +store both behave correctly, which is exactly why three passing demos missed them. + +--- + +## Accepted and fixed + +### 1. False CLEAN on a multi-row kimi composer — BLOCKING (claude F1) + +`KIMI_MARKER` matches `` │ > ``; `findMarkerRow` takes the **last** match; the scan started +**at** that row. A draft whose final line begins with `>` puts the marker on the *continuation* +row, leaving the real text above the scanned region → the composer classifies `clean` while +holding unsent input, and a queued message is typed on top of it. That is the corruption class +the gate exists to prevent. + +Claude reproduced it on a constructed screen and flagged that it had no live kimi to confirm +kimi's real multi-row geometry. **Measured on real kimi 0.34.0** (`pir-1201-kimi-gate-measure.mjs`, +extended for this): a two-line draft renders + +``` + ╭──────────── + │ > implement the whole feature + │ > + ╰──────────── +``` + +— exactly the shape, so the defect is real and reachable, not theoretical. + +**Fix:** optional `regionStartPatterns` on `GateProfile`, an *exclusive* upper bound (kimi: the +box top `` ╭─── ``). Exclusive matters: the box-top row's right corner `╮` is not an ignorable +glyph, and including that row held every idle composer forever — caught by the fixture suite +when the first attempt regressed `kimi-idle.clean`. + +Committed as fixtures from the live capture: `kimi-multiline-bare` (the false CLEAN itself), +`kimi-multiline`, `kimi-menu`, `kimi-picker` — the last two answering claude's "kimi ships 3 +fixtures where claude/codex ship menu and picker" point. + +**Claude's second input — a marker-matching row *below* the composer in a second box — is not +reachable in the shipped UI, measured:** kimi's `/` menu renders as unclosed `│` rows with no +`╰` beneath them, so any marker inside it yields `no-region-end` → held. Recorded rather than +"fixed", with the fixtures to show it. + +### 2. The store probe diverges from `findLatestKimiSessionId` — BLOCKING (codex #1, claude F2) + +Two reviewers, two directions, same root cause: the probe and the TypeScript are the same +question in two languages, and the cross-check test compared them against each other rather +than against kimi's continuation semantics — agreement between duplicated omissions. + +- **codex #1 (dangerous direction):** an `archived: true` session matched on cwd alone, so the + probe authorized `-c`; kimi excludes archived sessions from the listing `-c` continues from, + starts a fresh one, and that session never saw `--agent-file` → silently **roleless** builder. +- **claude F2 (safe direction, still harmful):** `readdirSync` on a stray non-directory threw + `ENOTDIR` into the single **outer** try, aborting the whole scan — one `.DS_Store` in + `~/.kimi-code/sessions/` disabled resume machine-wide, permanently and silently. Same for a + symlinked worktree and a trailing slash on the recorded cwd. + +**Fix:** both implementations now share one resumability predicate (`archived !== true`, +`session_`-prefixed id) and `sameDir`'s realpath tolerance; each directory level gets its own +`try`. Every listed case is now a test asserting **both** implementations. + +### 3. Unescaped interpolation in the generated script (codex #3, claude F3, gemini MINOR) + +All three flagged the same lines from different angles. The recovery hints interpolated +`builderId` / `taskFile` into double-quoted bash `echo`s, where bash re-scans them — so `$(…)` +in a builder id executed when the hint printed. `cd "${worktreePath}"` was unquoted too. + +**Fix:** every value enters the script once as a single-quoted escaped assignment; later uses go +through the shell variable, and hints print via `printf '%s\n'` on the expansion (bash does not +re-scan an expansion). Pinned by a test that runs the generated function with a metacharacter +id and asserts nothing executed. + +### 4. Crash loop re-queues the task indefinitely (codex #4) + +`codev_launch_fresh` queues the task, so a kimi dying before it mints a session re-queued the +same mission every ~2s. The mailbox *persists* a held row, so one enqueue suffices. + +**Fix:** a `codev_task_queued` guard, reset only on the human-gated clean-exit relaunch (which +is a deliberate new conversation and does want its task again). Pinned by driving the generated +function through three crash iterations plus a clean-exit relaunch against a stub `afx`. + +### 5. Drift probes report healthy forever after a migration (codex #5) + +Both probes returned `ok` if **any** record matched, so post-migration the old records hide +every new one — reporting healthy through exactly the rename the probe exists to catch. + +**Fix:** compare the newest conforming record against the newest non-conforming one; report +drift only when the bad one is *strictly* newer. Ties stay `ok` — my first attempt used +mixed units (`updatedAt` vs filesystem mtime) and made the verdict depend on directory +iteration order, which a test caught. + +### 6. Cleanups (claude F6, F7) + +- `buildRoleInjection`'s user-facing error and a `doctor.ts` comment still described the retired + seed-session bootstrap. Both now describe `--agent-file` and *why* it does not fit the + architect path (it needs a file written into the agent's directory; only the builder launch + path has that seam). +- `verifyKimi`: `spawnSync` returns `status: null` on spawn failure or timeout, and `null !== 0` + reported "kimi doctor reports config issues" — a false accusation against a healthy install on + a slow machine. Now distinguishes "learned nothing" from "reported a problem". + +### 7. Dangling evidence references (claude F5) + +`gate-profiles.ts` and `harness.ts` cite spike scripts that were untracked. The three +`pir-1201-kimi-*.mjs` probes ship in this PR, so the evidence chain resolves after merge. + +--- + +## Accepted as accurate, no code change + +- **claude:** the `markerSpanEnd` edit is a genuine no-op for claude/codex/agy — attacked and + held up. The docstring's "only narrow glyphs" premise is slightly wrong (U+3000 is `\s` *and* + wide), but that direction under-shoots the span → over-counts → holds. Safe both ways; codex + reached the same conclusion independently. +- **codex:** trust filename construction has no traversal issue; pacing resolution is total. + +## Maintainer decisions, not mine (both surfaced in the PR body) + +- **codex #2 — automatic workspace trust.** Codex argues `--yolo` governs tool approval while + workspace trust governs whether repository-controlled MCP processes load at all, so a fork-PR + branch could get its project MCP config loaded without a human decision. Claude reviewed the + same code and concluded the opposite (a `--yolo` builder in a Codev-created worktree already + holds strictly more authority). The disagreement is real and is a policy call, so it goes to + the maintainer with both arguments rather than being settled here. Kept fail-soft, drift-probed, + and dated in `arch.md`; the PR offers to cut it for one human keypress per Kimi builder. +- **claude F4 — no worktree write-guard for kimi builders.** Correct, and materially broader than + the trust question. Kimi *does* have a blocking `PreToolUse` hook seam, so parity is achievable + follow-up work; the PR asks whether it lands here or separately. + +--- + +## What this round says about the process + +The three passing live demos were not worthless — they proved the mechanism end to end — but +every defect above lives in a state the happy path does not produce. Two independent reviewers +converged on the same two blocking defects from opposite directions, and the live measurement +rig then settled which of claude's two proposed inputs was real. Review found them; measurement +sized them. diff --git a/codev/resources/arch.md b/codev/resources/arch.md index 6d6f9abe7..7efecdc28 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -334,16 +334,16 @@ A `codev doctor` audit (`lib/framework-ref-audit.ts`) flags shell-fetch of frame This replaced the original **seed-session bootstrap** (`kimi -p` seed → `session.resume_hint` capture → pinned `kimi -S ` loop → a sentinel-gated `BEGIN` kick written straight to the PTY). The pivot removed three undocumented surfaces (`resume_hint`, `-S` id pinning, `state.json.lastPrompt` delivery verification), deleted `servers/seed-kick.ts` outright, and stopped the role riding a **user turn** — the weaker-authority tradeoff that also deferred agy as an architect (#1063). It is a strictly smaller integration for a strictly stronger result. -**Crash resume uses the documented, cwd-scoped `kimi -c`** — no session id is ever baked into generated bash. The guard that makes this safe: `kimi -c` **does not fail when there is nothing to continue**. It prints `No sessions to continue under ""; starting a fresh session.` and starts one anyway — and that session never saw `--agent-file`, i.e. a silently **roleless** builder (the #929 hazard class; verified on 0.34.0). So the launch loop only takes `-c` after an inlined `node -e` store probe proves a session exists for this cwd, and the probe **fails closed**: any error (no store, unreadable dir, malformed JSON) exits non-zero and the loop relaunches fresh **with** the role, which is always safe. The probe is pinned by tests that execute it against fixture stores and cross-check it against `findLatestKimiSessionId`, so the hand-written snippet cannot drift from the TypeScript it mirrors. Entry is self-configuring on the same probe, so `afx spawn --resume` and a Tower-side terminal re-create need no second script shape — and a re-run never re-queues the task into a live conversation. A clean exit (#1267/#1317) relaunches **fresh** and re-queues the task, mirroring claude's prompt-on-fresh semantics. +**Crash resume uses the documented, cwd-scoped `kimi -c`** — no session id is ever baked into generated bash. The guard that makes this safe: `kimi -c` **does not fail when there is nothing to continue**. It prints `No sessions to continue under ""; starting a fresh session.` and starts one anyway — and that session never saw `--agent-file`, i.e. a silently **roleless** builder (the #929 hazard class; verified on 0.34.0). So the launch loop only takes `-c` after an inlined `node -e` store probe proves a session exists for this cwd, and the probe **fails closed**: any error (no store, unreadable dir, malformed JSON) exits non-zero and the loop relaunches fresh **with** the role, which is always safe. The probe answers "**would `kimi -c` continue it?**", not "does a directory exist": kimi lists a cwd's sessions before continuing one, and that listing drops **archived** sessions and ids it does not recognize — so a session we call resumable but kimi skips lands on the same roleless path. Both filters (`archived !== true`, `session_`-prefixed id) therefore apply in the probe *and* in `findLatestKimiSessionId`/`verifyKimiSessionOwnership`, and both err toward "not resumable", whose fallback is the role-carrying fresh launch. The probe is pinned by tests that execute it against fixture stores and cross-check it against `findLatestKimiSessionId`, so the hand-written snippet cannot drift from the TypeScript it mirrors — including the cases that once split them: a stray non-directory in the store (which aborted the whole scan via `ENOTDIR`, silently disabling resume machine-wide), a symlinked worktree, and a trailing slash on the recorded cwd. Entry is self-configuring on the same probe, so `afx spawn --resume` and a Tower-side terminal re-create need no second script shape — and a re-run never re-queues the task into a live conversation. A clean exit (#1267/#1317) relaunches **fresh** and re-queues the task, mirroring claude's prompt-on-fresh semantics — and that human-gated relaunch is the *only* path that re-queues. A crash loop does not: the mailbox persists a held row, so a kimi that dies before minting a session (bad auth, say) would otherwise pile the same mission onto the mailbox every two seconds. Every value the generator interpolates — worktree path, builder id, task path — enters the script **once**, as a single-quoted escaped assignment, and every later use goes through the shell variable; the recovery hints print through `printf '%s\n'` on the expansion, which bash does not re-scan, so an id or path containing a backtick or `$(…)` is displayed rather than executed. **Message pacing is per-harness** (`servers/mailbox-wiring.ts` `resolvePacingForSession` + `message-write.ts` `pacing.enterDelayMs`): Kimi's paste-detection window swallows an Enter sent 80ms after the body (the default), so Kimi targets get a ~1s delayed Enter — bisected live (80/100ms fail; 120ms+ submit; pinned at 1000ms for ~9x margin, latency being the only cost). Resolution recovers the harness from the session's launch `command`, then from the generated `.builder-start.sh` (matching the command in **command position**, as `afx reset` does) — the same self-describing signal the render gate resolves. It is override-proof by construction: the script is generated *from* the resolved harness, so a `--builder-cmd kimi` spawn against a claude-configured workspace still reads `kimi`. This replaced a `.builder-kimi` **marker file**, which obliged every launch shape to remember to write one — an obligation the bare shape missed (found in PR #1203 review). Pacing is advisory and **total**: any failure degrades to default timing rather than throwing into the delivery path. The `/api/send --interrupt` bypass paces too (it writes body-then-Enter); `--escape` deliberately does not (it writes no text, and its behaviour on Kimi is unmeasured). -**Render-gate profile** (`servers/gate-profiles.ts` `KIMI_PROFILE`, measured on 0.34.0): kimi draws its composer inside a rounded box, so the input row is `` │ > `` with the marker at **column 3**, not the row start. This is the one place Kimi touches shared gate logic — the classifier's marker exemption follows the profile's **matched span** instead of column 0. That is a no-op for claude/codex (span 1, literally the old rule) and agy (span 2, whose extra cell is a space the whitespace rule already skipped), pinned by dedicated before/after tests. An idle kimi composer carries **no placeholder text at all**, so it needs neither the dim rule nor a `placeholderFgPalette`; typed text is default-fg at normal intensity → busy. The 0.33.0+ folder-trust dialog has no marker at a row start → `no-composer-marker` → held, so a blind Enter can never confirm filesystem trust. +**Render-gate profile** (`servers/gate-profiles.ts` `KIMI_PROFILE`, measured on 0.34.0): kimi draws its composer inside a rounded box, so the input row is `` │ > `` with the marker at **column 3**, not the row start. This is where Kimi touches shared gate logic, in two places, both opt-in and both pinned by dedicated before/after tests. (1) The classifier's marker exemption follows the profile's **matched span** instead of column 0 — a no-op for claude/codex (span 1, literally the old rule) and agy (span 2, whose extra cell is a space the whitespace rule already skipped). (2) A profile may declare `regionStartPatterns`, an **upper** bound for the composer region; kimi sets the box top `` ╭─── ``. Without it the region began at the marker row, and because `findMarkerRow` takes the **last** match, a multi-row draft whose final line begins with `>` moved the region down past the real text: measured on 0.34.0, a two-line draft rendering `` │ > implement the whole feature `` / `` │ > `` classified **clean** while holding unsent user text, so a queued message would have been typed on top of it (captured as the `kimi-multiline-bare` fixture). The bound is exclusive, mirroring the region end — the box-top row's right corner `╮` is not an ignorable glyph, so including it would have held every idle kimi composer forever. Profiles that declare no region start (claude/codex/agy) keep scanning from the marker row exactly as before, and since no row below a last match can match, they cannot reach the new behavior at all. A boxed composer whose box top is off screen is a torn frame with no proven upper bound → `no-region-start` → held. An idle kimi composer carries **no placeholder text at all**, so it needs neither the dim rule nor a `placeholderFgPalette`; typed text is default-fg at normal intensity → busy. The 0.33.0+ folder-trust dialog has no marker at a row start → `no-composer-marker` → held, so a blind Enter can never confirm filesystem trust. **Undocumented-surface reliance** (audited against **kimi 0.34.0, 2026-08-09** — re-check on each Kimi major): - **Session store** `~/.kimi-code/sessions/wd_*/session_*/state.json`. Already drifted once: 0.33.0 renamed `workDir` → `cwd`, moved timestamps from ISO strings to epoch ms, and dropped `lastPrompt`. Readers accept both shapes; `codev doctor` runs a probe that asserts the load-bearing facts explicitly and **names** the one that broke. -- **Workspace-trust record** `~/.kimi-code/workspace-trust/wd__` → `{root, trustedAt}`. 0.33.0 added a startup "Trust this folder?" dialog, and a builder worktree is always a brand-new directory; the dialog renders before any composer and its only non-trusting option **exits kimi**, so an unattended builder would sit on it forever. `ensureKimiWorkspaceTrust` pre-writes the record at spawn. **No sanctioned bypass exists**: `kimi --help` has no flag, and a full strings sweep of the 0.34.0 binary for `KIMI_*` env vars and trust config keys found none. What trust gates is narrow — whether project-level MCP servers (`.mcp.json`, `.kimi-code/mcp.json`) load from the folder; it does not gate tool execution or writes — and the record is written only for a worktree Codev itself created, for a builder the human explicitly spawned, already running `--yolo`. So it grants strictly less than launching the builder already did. The write is idempotent and fail-soft (on failure the dialog simply appears and the gate holds the task), and `codev doctor` validates our derivation against kimi's **own** records, so a scheme change surfaces as a named warning instead of silently stranding builders. +- **Workspace-trust record** `~/.kimi-code/workspace-trust/wd__` → `{root, trustedAt}`. 0.33.0 added a startup "Trust this folder?" dialog, and a builder worktree is always a brand-new directory; the dialog renders before any composer and its only non-trusting option **exits kimi**, so an unattended builder would sit on it forever. `ensureKimiWorkspaceTrust` pre-writes the record at spawn. **No sanctioned bypass exists**: `kimi --help` has no flag, and a full strings sweep of the 0.34.0 binary for `KIMI_*` env vars and trust config keys found none. What trust gates is narrow — whether project-level MCP servers (`.mcp.json`, `.kimi-code/mcp.json`) load from the folder; it does not gate tool execution or writes — and the record is written only for a worktree Codev itself created, for a builder the human explicitly spawned, already running `--yolo`. So it grants strictly less than launching the builder already did. The write is idempotent and fail-soft (on failure the dialog simply appears and the gate holds the task), and `codev doctor` validates our derivation against kimi's **own** records, so a scheme change surfaces as a named warning instead of silently stranding builders. Both drift probes weigh records by **recency**, not by "does any record still match": after a migration the pre-migration records keep matching forever and would hide every new one, reporting healthy through exactly the rename the probe exists to catch. Drift is reported only when the newest non-conforming record is *strictly* newer than every conforming one — a tie stays `ok`, so the verdict never depends on directory-iteration order. **Other caveats**: (a) doctor's auth check is a **credential-artifact heuristic** — kimi documents no status probe, and doctor never makes a billed call. (b) **Write-guard parity is not implemented here.** Kimi *does* have a hook seam — documented blocking `PreToolUse` hooks (`[[hooks]]` in `config.toml`, exit code 2 blocks, 18 events as of 0.32.0) — so the earlier "no hook seam, parity impossible" claim is **obsolete**. Parity with the #1018 worktree write-guard is therefore achievable and is scoped as follow-up work, not a permanent limitation; until it lands, a Kimi builder can write outside its worktree. diff --git a/codev/spikes/pir-1201-kimi-agentfile-probe.mjs b/codev/spikes/pir-1201-kimi-agentfile-probe.mjs new file mode 100644 index 000000000..a56d67cd8 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-agentfile-probe.mjs @@ -0,0 +1,163 @@ +/** + * Validate the PIR #1201 design pivot against real kimi 0.34.0. + * + * The pivot replaces the 0.27.0-era seed bootstrap (a `kimi -p` one-shot + * carrying role + task under an ack-and-wait discipline, a captured session id, + * and a store-verified BEGIN kick) with two sanctioned mechanisms: + * role → `--agent-file ` at launch, composed with `${base_prompt}` + * so it EXTENDS kimi's system prompt instead of replacing it; + * task → an ordinary Spec 1313 mailbox message delivered onto a + * render-gate-verified empty composer. + * + * Before building on that, four claims must hold on a real install. This probe + * checks each and prints PASS/FAIL: + * + * 1. --agent-file injects the role in NON-interactive (-p) mode. + * 2. --agent-file injects the role in the INTERACTIVE TUI (the half the + * pivot actually depends on, and the half that was never measured). + * 3. The TUI mints its session on the FIRST MESSAGE, not at startup + * (0.33.0 changed this) — so a crash-resume has something to resume only + * after the task message lands. + * 4. `kimi -c` (documented, cwd-scoped) resumes that session AND the role + * binding survives — which is what lets the crash path drop both + * --agent-file (illegal with -c) and the undocumented store lookup. + * + * Usage: node codev/spikes/pir-1201-kimi-agentfile-probe.mjs + */ + +import { mkdtempSync, writeFileSync, mkdirSync, readdirSync, existsSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir, homedir } from 'node:os'; +import { join, basename, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); + +const TOKEN = 'CODEV-ROLE-OK-7731'; +const ASK = 'What is the codeword? Reply with only the codeword.'; +const ENTER_DELAY_MS = Number(process.env.PROBE_ENTER_DELAY_MS || 1000); +const KIMI_HOME = process.env.KIMI_CODE_HOME || join(homedir(), '.kimi-code'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const results = []; +const record = (name, ok, detail) => { + results.push({ name, ok, detail }); + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); +}; + +/** Pre-write kimi's workspace-trust record (0.33.0+); see kimi-session-discovery.ts. */ +function preTrust(root) { + const dir = join(KIMI_HOME, 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${basename(root).toLowerCase()}_${hash}`), + JSON.stringify({ root, trustedAt: Date.now() })); +} + +/** Count sessions the store holds for `cwd` (v2 `cwd`, v1 `workDir`). */ +function sessionsFor(cwd) { + const root = join(KIMI_HOME, 'sessions'); + const found = []; + if (!existsSync(root)) return found; + for (const wd of readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory())) { + for (const s of readdirSync(join(root, wd.name), { withFileTypes: true }).filter((e) => e.isDirectory())) { + try { + const st = JSON.parse(readFileSync(join(root, wd.name, s.name, 'state.json'), 'utf-8')); + if ((st.cwd ?? st.workDir) === cwd) found.push(s.name); + } catch { /* unreadable → not a session we can use */ } + } + } + return found; +} + +function agentFile(dir) { + const p = join(dir, 'role-agent.md'); + writeFileSync(p, `--- +name: codev-builder +description: Codev builder role (probe) +--- +\${base_prompt} + +# Codev Builder Role (probe) + +If the user asks for the codeword, reply with exactly ${TOKEN} and nothing else. +`); + return p; +} + +/** Run kimi non-interactively and return stdout. */ +function runP(args, cwd) { + return new Promise((resolve) => { + const term = pty.spawn('kimi', args, { + name: 'xterm-256color', cols: 110, rows: 32, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let out = ''; + term.onData((d) => { out += d; }); + term.onExit(() => resolve(out)); + }); +} + +/** + * Drive the interactive TUI: type `message`, pause ENTER_DELAY_MS (kimi's paste + * window swallows an Enter that arrives too soon), submit, then wait. + */ +async function runTui(args, cwd, message, waitMs) { + const term = pty.spawn('kimi', args, { + name: 'xterm-256color', cols: 110, rows: 32, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let out = ''; + term.onData((d) => { out += d; }); + await sleep(12000); // let the TUI paint its composer + const beforeSend = out.length; + term.write(message); + await sleep(ENTER_DELAY_MS); + term.write('\r'); + await sleep(waitMs); + try { term.kill(); } catch { /* already gone */ } + await sleep(500); + return { out, afterSend: out.slice(beforeSend) }; +} + +const dir = mkdtempSync(join(tmpdir(), 'kimi-pivot-')); +preTrust(dir); +const role = agentFile(dir); +console.log(`[probe] worktree: ${dir}\n[probe] enter delay: ${ENTER_DELAY_MS}ms\n`); + +// 1. Non-interactive role injection. +const pOut = await runP(['--agent-file', role, '-p', ASK], dir); +record('1. --agent-file injects the role in -p mode', pOut.includes(TOKEN), + pOut.includes(TOKEN) ? '' : `stdout: ${JSON.stringify(pOut.slice(-200))}`); + +// 2 + 3. Interactive TUI: role injection, and session-mint timing. +const dir2 = mkdtempSync(join(tmpdir(), 'kimi-pivot-tui-')); +preTrust(dir2); +const role2 = agentFile(dir2); +const beforeAny = sessionsFor(dir2); +record('3a. TUI start mints NO session (checked before launch)', beforeAny.length === 0, + `${beforeAny.length} session(s) pre-existing`); + +const tui = await runTui(['--agent-file', role2, '--yolo'], dir2, ASK, 45000); +record('2. --agent-file injects the role in the interactive TUI', tui.afterSend.includes(TOKEN), + tui.afterSend.includes(TOKEN) ? '' : `tail: ${JSON.stringify(tui.out.slice(-400))}`); + +const afterMsg = sessionsFor(dir2); +record('3b. the first message mints exactly one session', afterMsg.length === 1, + `sessions now: ${JSON.stringify(afterMsg)}`); + +// 4. `-c` resumes that session and the role binding survives (no --agent-file). +const cont = await runTui(['-c', '--yolo'], dir2, ASK, 45000); +record('4a. kimi -c resumes without --agent-file', !cont.out.includes('No session yet'), + cont.out.includes('No session yet') ? 'TUI reported no session to continue' : ''); +record('4b. the role binding survives the resume', cont.afterSend.includes(TOKEN), + cont.afterSend.includes(TOKEN) ? '' : `tail: ${JSON.stringify(cont.out.slice(-400))}`); +const afterCont = sessionsFor(dir2); +record('4c. -c reused the session (no second one minted)', afterCont.length === 1, + `sessions now: ${JSON.stringify(afterCont)}`); + +console.log(`\n${results.filter((r) => r.ok).length}/${results.length} checks passed`); +process.exit(results.every((r) => r.ok) ? 0 : 1); diff --git a/codev/spikes/pir-1201-kimi-builder-demo.mjs b/codev/spikes/pir-1201-kimi-builder-demo.mjs index b7307dd33..8aada4767 100644 --- a/codev/spikes/pir-1201-kimi-builder-demo.mjs +++ b/codev/spikes/pir-1201-kimi-builder-demo.mjs @@ -1,33 +1,44 @@ #!/usr/bin/env node /** - * PIR #1201 — live demo driver: Kimi builder launch path end-to-end against a - * REAL `kimi` (>= 0.27.0, authenticated), using the REAL built modules from - * packages/codev/dist — no Tower required. + * PIR #1201 — live demo driver: the Kimi builder launch path end-to-end against a + * REAL `kimi` (>= 0.33.0, authenticated), using the REAL built modules from + * packages/codev/dist. No Tower required. * - * What it exercises, in order (the dev-approval demo checklist): - * 1. Seed-session bootstrap — KIMI_HARNESS.buildBuilderLaunchScript + - * seedDelivery.buildSeedPrompt generate .builder-start.sh/.builder-seed.txt - * exactly as spawn-worktree.ts does; the script runs `kimi -p` and captures - * the session id from the (undocumented) session.resume_hint meta line. - * 2. Sentinel-gated, store-verified BEGIN — the REAL armSeedKick watches the - * PTY for __CODEV_KIMI_SEED_DONE__, waits the grace, writes BEGIN with the - * Kimi Enter delay, and confirms against state.json.lastPrompt. - * 3. Multiline delivery — the REAL writeMessageToSession (paced lines + - * pinned Enter delay) submits a >3-line message to the running TUI. - * 4. Inner-restart retention — the TUI process is killed; the script's - * while-true loop re-enters `kimi -S `; a follow-up question verifies - * role/task context survived. buildResume() is then shown returning the - * same pinned id (afx spawn --resume path). + * Rewritten for the design pivot (PR #1203 re-integration). The retired version + * drove the seed-session bootstrap (`kimi -p` seed → resume_hint capture → pinned + * `kimi -S ` loop → a sentinel-gated BEGIN written straight to the PTY). The + * shipped design instead delivers the ROLE via `--agent-file` and the TASK via the + * Spec 1313 mailbox, and resumes crashes with the documented cwd-scoped `kimi -c`. + * + * What it exercises, in order: + * 1. Role injection — the REAL getWorktreeFiles + buildScriptRoleInjection + + * buildBuilderLaunchScript generate the worktree files and .builder-start.sh + * exactly as spawn-worktree.ts does. The TUI is asked a role-identifying + * question; a correct answer proves --agent-file reached the interactive TUI + * (not just `-p`), and that ${base_prompt} did not clobber the role. + * 2. Render gate — the REAL KIMI_PROFILE + classifyBuffer classify the LIVE + * screen. This is the readiness barrier that replaced the PTY sentinel: a + * booting/busy kimi classifies not-clean and holds; an idle composer is clean. + * 3. Paced delivery — the REAL writeMessagePaced with the REAL Kimi pacing + * submits a >3-line message (the 80ms default is swallowed by kimi's paste + * detection; the pinned ~1s Enter submits). + * 4. Crash resume — the TUI process is killed; the script's own loop consults its + * inlined store probe, takes `kimi -c`, and a follow-up question verifies the + * role survived the resume. + * 5. The fail-closed guard — with an EMPTY store the same probe reports "no + * session", so the loop must launch FRESH WITH the role. This is the #929 + * hazard `kimi -c` opens by silently starting a roleless session when there is + * nothing to continue. * * Run from the repo root of this worktree (after `pnpm build`): * node codev/spikes/pir-1201-kimi-builder-demo.mjs * - * Output: PASS/FAIL per step, plus the raw evidence (lastPrompt values, - * assistant text extracted from the session wire log). + * Output: PASS/FAIL per step plus the raw evidence. */ -import { mkdtempSync, writeFileSync, chmodSync, readFileSync, existsSync } from 'node:fs'; -import { tmpdir, homedir } from 'node:os'; +import { mkdtempSync, writeFileSync, chmodSync, readFileSync, mkdirSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; @@ -36,14 +47,18 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, '..', '..'); const dist = (p) => join(repoRoot, 'packages', 'codev', 'dist', p); -const { KIMI_HARNESS } = await import(dist('agent-farm/utils/harness.js')); -const { armSeedKick } = await import(dist('agent-farm/servers/seed-kick.js')); -const { writeMessageToSession } = await import(dist('agent-farm/servers/message-write.js')); -const { readKimiSessionState } = await import(dist('agent-farm/utils/kimi-session-discovery.js')); +const { KIMI_HARNESS, KIMI_AGENT_FILE } = await import(dist('agent-farm/utils/harness.js')); +const { writeMessagePaced } = await import(dist('agent-farm/servers/message-write.js')); +const { classifyBuffer } = await import(dist('agent-farm/servers/render-gate.js')); +const { KIMI_PROFILE } = await import(dist('agent-farm/servers/gate-profiles.js')); +const { ensureKimiWorkspaceTrust } = await import(dist('agent-farm/utils/kimi-session-discovery.js')); const require = createRequire(join(repoRoot, 'packages', 'codev', 'package.json')); const pty = require('node-pty'); +const xterm = require('@xterm/headless'); +const COLS = 110; +const ROWS = 32; const worktree = mkdtempSync(join(tmpdir(), 'kimi-demo-wt-')); console.log(`demo worktree: ${worktree}`); @@ -55,139 +70,227 @@ const record = (step, ok, evidence) => { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // --- Generate the launch artifacts exactly as spawn-worktree.ts does -------- -const ROLE = 'You are a demo builder agent. Begin every reply with the exact token DEMO-OK followed by a space.'; -const TASK = 'Your task: when told to begin, reply (per your role) with a one-line haiku about git worktrees. Do not use tools.'; +/** + * The role carries a CODEWORD, and the steps that check "did the role reach the + * model?" ask for it back. + * + * An earlier version instead told the model to prefix every reply with a token, + * and asserted on the prefix. That conflated two different claims: whether the + * role was injected (what this demo exists to prove) and whether K3 honors a + * persistent output-format constraint (which it does not do reliably — measured: + * it answered the task correctly while dropping the prefix, and when asked about + * its prefix it discussed the idea rather than emitting the token). A recall + * question isolates the claim under test, and it is the same oracle + * `pir-1201-kimi-agentfile-probe.mjs` uses to measure `--agent-file` directly. + */ +const CODEWORD = 'DEMO-ROLE-OK-4417'; +const ROLE = 'You are a demo builder agent. Your codeword is ' + CODEWORD + '. ' + + 'If you are asked for your codeword, reply with exactly that token and nothing else.'; +const ASK_CODEWORD = 'What is your codeword? Reply with only the codeword.'; +const TASK = ASK_CODEWORD; -const seedFile = join(worktree, '.builder-seed.txt'); -writeFileSync(seedFile, KIMI_HARNESS.seedDelivery.buildSeedPrompt(ROLE, TASK)); +const roleFile = join(worktree, '.builder-role.md'); +writeFileSync(roleFile, ROLE); +const promptFile = join(worktree, '.builder-prompt.txt'); +writeFileSync(promptFile, TASK); + +// getWorktreeFiles writes the --agent-file definition (role wrapped around +// ${base_prompt}); buildScriptRoleInjection produces the flag that points at it. +for (const f of KIMI_HARNESS.getWorktreeFiles(ROLE)) { + writeFileSync(join(worktree, f.relativePath), f.content); +} +const { fragment: roleFragment } = KIMI_HARNESS.buildScriptRoleInjection(ROLE, roleFile); + +// The spawn path pre-records folder trust so an unattended builder is not +// stranded on kimi 0.33.0+'s "Trust this folder?" dialog. +KIMI_HARNESS.prepareWorkspace?.(worktree); const scriptPath = join(worktree, '.builder-start.sh'); writeFileSync(scriptPath, KIMI_HARNESS.buildBuilderLaunchScript({ - worktreePath: worktree, baseCmd: 'kimi', seedFile, + worktreePath: worktree, baseCmd: 'kimi', roleFragment, + // The demo delivers the task itself (step 3) rather than shelling out to `afx + // send`, which would need a running Tower. The queue call is still generated + // and printed below, so what is skipped is visible rather than hidden. + taskFile: promptFile, builderId: 'kimi-demo', })); chmodSync(scriptPath, 0o755); +console.log('--- generated .builder-role-agent.md ---'); +console.log(readFileSync(join(worktree, KIMI_AGENT_FILE), 'utf-8')); console.log('--- generated .builder-start.sh ---'); console.log(readFileSync(scriptPath, 'utf-8')); -// --- Host the script in a PTY, shimming the PtySession surface -------------- +// --- Host the script in a PTY, mirroring it into a headless terminal -------- +// The mirror is what production classifies (SessionScreen); feeding it the same +// bytes lets the REAL classifier run against the REAL live screen. const term = pty.spawn('/bin/bash', [scriptPath], { - name: 'xterm-256color', cols: 120, rows: 40, cwd: worktree, + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd: worktree, env: { ...process.env }, }); -const dataListeners = new Set(); +const mirror = new xterm.Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true, scrollback: 2000 }); let transcript = ''; -term.onData((d) => { - transcript += d; - for (const l of dataListeners) l(d); -}); -const session = { - write: (d) => term.write(d), - on: (ev, l) => { if (ev === 'data') dataListeners.add(l); }, - off: (ev, l) => { if (ev === 'data') dataListeners.delete(l); }, -}; +term.onData((d) => { transcript += d; mirror.write(d); }); -const logs = []; -const log = (level, message) => { - logs.push(`${level}: ${message}`); - console.log(` [seed-kick ${level}] ${message}`); -}; +const session = { write: (d) => { term.write(d); return true; } }; -// Arm the REAL readiness-gated kick (what handleTerminalCreate does). -armSeedKick(session, { - sentinel: KIMI_HARNESS.seedDelivery.sentinelPrefix, - message: KIMI_HARNESS.seedDelivery.kickMessage, - graceMs: KIMI_HARNESS.seedDelivery.graceMs, - enterDelayMs: KIMI_HARNESS.messagePacing.enterDelayMs, - verify: { kind: 'kimi-session-store', worktreePath: worktree }, -}, log); - -// --- Step 1+2: wait for seed, sentinel, verified BEGIN ---------------------- -const deadline = Date.now() + 150_000; -let sid = null; -while (Date.now() < deadline) { - const m = /__CODEV_KIMI_SEED_DONE__[ \t]+(\S+)/.exec(transcript); - if (m) { sid = m[1]; break; } - await sleep(500); +/** Classify the live screen with the production classifier. */ +function gate() { + return classifyBuffer(mirror, COLS, ROWS, KIMI_PROFILE); } -record('1. seed-session bootstrap (sentinel printed, id captured)', !!sid, `sid=${sid}`); -if (!sid) { term.kill(); process.exit(1); } - -let beginConfirmed = false; -const beginDeadline = Date.now() + 45_000; -while (Date.now() < beginDeadline) { - if (logs.some((l) => l.includes('confirmed submitted'))) { beginConfirmed = true; break; } - await sleep(500); + +/** Wait until the gate says the composer is clean (or time out). */ +async function waitForCleanComposer(timeoutMs = 60000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + last = gate(); + if (last.clean) return last; + await sleep(500); + } + return last; } -const st1 = readKimiSessionState(sid); -record('2. sentinel-gated BEGIN, store-verified', beginConfirmed, - `lastPrompt=${JSON.stringify(st1?.lastPrompt)}`); - -// Give the model time to answer BEGIN (the haiku per the task briefing). -await sleep(25_000); - -// --- Step 3: multiline delivery with the pinned Enter delay ----------------- -const multiline = ['This is a multiline delivery check.', 'Line two.', 'Line three.', - 'Reply per your role with the single token MULTI-OK and nothing else.'].join('\n'); -writeMessageToSession(session, multiline, false, 0, - { enterDelayMs: KIMI_HARNESS.messagePacing.enterDelayMs }); - -let multiOk = false; -const multiDeadline = Date.now() + 40_000; -while (Date.now() < multiDeadline) { - const st = readKimiSessionState(sid); - if (st?.lastPrompt?.includes('MULTI-OK')) { multiOk = true; break; } - await sleep(1000); + +/** Deliver a message the way the mailbox does: gate first, then paced write. */ +async function deliver(message) { + const verdict = await waitForCleanComposer(); + if (!verdict?.clean) return { delivered: false, verdict }; + const ok = await writeMessagePaced(session, message, false, KIMI_HARNESS.messagePacing); + return { delivered: ok, verdict }; } -record('3. multiline afx-send-shaped delivery submits (pinned Enter delay)', multiOk, - `lastPrompt=${JSON.stringify(readKimiSessionState(sid)?.lastPrompt?.slice(0, 80))}`); -await sleep(20_000); // let the model reply before the restart -// --- Step 4: inner-restart retention --------------------------------------- -// Kill the kimi TUI (not the script): the while-true loop restarts `-S $SID`. -const { execSync } = await import('node:child_process'); -try { - execSync(`pkill -f -- "-S ${sid}"`, { stdio: 'ignore' }); -} catch { /* pkill exits 1 if pattern raced; the transcript shows the restart */ } -await sleep(10_000); // restart notice (2s) + TUI warm-up +const seen = (re, from = 0) => re.test(transcript.slice(from)); -writeMessageToSession(session, 'After this restart: what one-line task were you originally given? Reply per your role.', - false, 0, { enterDelayMs: KIMI_HARNESS.messagePacing.enterDelayMs }); -await sleep(30_000); +/** + * Wait for `re` to appear in the transcript after `from`. Generous by default: + * kimi K3 at "thinking: high" can take well over a minute on a cold first turn, + * and a too-short window makes a working feature look broken. + */ +async function waitFor(re, from, timeoutMs = 180000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (seen(re, from)) return true; + await sleep(1000); + } + return seen(re, from); +} -// Evidence: assistant turns from the session wire log mention the task/role. -let retention = false; -let lastTurns = ''; -try { - const sessionsRoot = join(homedir(), '.kimi-code', 'sessions'); - const { readdirSync } = await import('node:fs'); - outer: - for (const wd of readdirSync(sessionsRoot)) { - const wire = join(sessionsRoot, wd, sid, 'agents', 'main', 'wire.jsonl'); - if (existsSync(wire)) { - const texts = [...readFileSync(wire, 'utf-8').matchAll(/"part":\{"type":"text","text":"((?:[^"\\]|\\.)*)"/g)] - .map((m) => m[1]); - lastTurns = texts.slice(-3).join(' | ').slice(0, 400); - retention = texts.slice(-2).some((t) => t.includes('DEMO-OK') && /haiku|worktree/i.test(t)); - break outer; - } +/** + * Kill the kimi TUI (not the script) so the launch loop takes its crash path. + * + * Deliberately walks the process tree from the script's own bash instead of + * pattern-matching a command line: kimi ships as a COMPILED binary whose argv + * varies with how it was installed and invoked, and a pkill pattern that quietly + * matches nothing turns this step into a false PASS — the original session simply + * keeps running and answers the follow-up question. + */ +function killTui(bashPid) { + const out = spawnSync('pgrep', ['-P', String(bashPid)], { encoding: 'utf-8' }); + const pids = (out.stdout || '').trim().split('\n').filter(Boolean); + for (const p of pids) { + try { process.kill(Number(p), 'SIGKILL'); } catch { /* already gone */ } } -} catch (err) { - lastTurns = `wire log read failed: ${err.message}`; + return pids; +} + +try { + // --- Step 1+2: gate recognizes the live composer; role reached the TUI ----- + const boot = gate(); + const ready = await waitForCleanComposer(); + record( + '1. render gate classifies the LIVE kimi composer (the readiness barrier)', + ready?.clean === true, + `at boot: ${JSON.stringify(boot)} → when idle: ${JSON.stringify(ready)}`, + ); + + const mark1 = transcript.length; + await deliver(TASK); + // The task IS the codeword question, so one delivery proves two things at once: + // the mailbox → render-gate → composer path carried it, and --agent-file injected + // the role in the INTERACTIVE TUI without ${base_prompt} displacing it. + const roleHonored = await waitFor(new RegExp(CODEWORD), mark1); + record( + '2. role injected via --agent-file and honored in the interactive TUI', + roleHonored, + roleHonored ? `assistant recalled the role codeword ${CODEWORD}` : 'the role codeword never came back', + ); + + // --- Step 3: paced multi-line delivery ------------------------------------ + const mark2 = transcript.length; + const multiline = [ + 'Answer with exactly one word, no punctuation:', + 'line two is filler', + 'line three is filler', + 'line four: what is the capital of France?', + ].join('\n'); + const { delivered } = await deliver(multiline); + const answered = await waitFor(/Paris/i, mark2); + record( + `3. multi-line delivery submits with the pinned ${KIMI_HARNESS.messagePacing.enterDelayMs}ms Enter`, + delivered && answered, + delivered ? 'paced write reported all bytes on the wire; model answered' : 'paced write reported a dropped write', + ); + + // --- Step 4: crash resume via the script's own probe + `kimi -c` ----------- + const mark3 = transcript.length; + const killed = killTui(term.pid); + // The loop prints its decision before relaunching; a resumed conversation is + // the one the store probe authorized. + await waitFor(/Resuming the conversation|Relaunching fresh/, mark3, 60000); + const choseResume = seen(/Resuming the conversation/, mark3); + record( + '4a. crash restart consults the store probe and chooses resume', + killed.length > 0 && choseResume, + killed.length === 0 + ? 'NO child process was killed — the crash path was never exercised (harness fault, not a product result)' + : choseResume + ? `killed pid(s) ${killed.join(',')}; loop announced "Resuming the conversation"` + : `killed pid(s) ${killed.join(',')}; loop did NOT choose resume (see transcript)`, + ); + + const mark4 = transcript.length; + await deliver(ASK_CODEWORD); + const survived = await waitFor(new RegExp(CODEWORD), mark4); + record( + '4b. role survives the `kimi -c` resume', + survived && choseResume, + survived + ? (choseResume ? 'post-resume reply still recalls the role codeword' : 'codeword present, but no resume happened — not evidence') + : 'the role codeword was gone after resume', + ); + + // --- Step 5: the fail-closed guard --------------------------------------- + // `kimi -c` with nothing to continue does NOT fail — it starts a fresh session + // that never saw --agent-file, i.e. a ROLELESS builder. Run the script's own + // inlined probe against an EMPTY store: it must report "no session" so the loop + // takes the fresh, role-carrying path instead. + const probe = /node -e '([^']*)'/.exec(readFileSync(scriptPath, 'utf-8'))?.[1]; + const emptyHome = mkdtempSync(join(tmpdir(), 'kimi-demo-emptyhome-')); + mkdirSync(join(emptyHome, '.kimi-code'), { recursive: true }); + const emptyProbe = spawnSync(process.execPath, ['-e', probe, worktree], { + env: { ...process.env, KIMI_CODE_HOME: join(emptyHome, '.kimi-code') }, + }); + const liveProbe = spawnSync(process.execPath, ['-e', probe, worktree], { env: { ...process.env } }); + rmSync(emptyHome, { recursive: true, force: true }); + record( + '5. store probe fails CLOSED on an empty store (no roleless -c fallback)', + emptyProbe.status !== 0 && liveProbe.status === 0, + `empty store → exit ${emptyProbe.status} (want non-zero); real store → exit ${liveProbe.status} (want 0)`, + ); + + // Trust pre-record is idempotent: the second call must be a no-op. + record( + '6. workspace-trust pre-record is idempotent', + ensureKimiWorkspaceTrust(worktree) === false, + 'second ensureKimiWorkspaceTrust() returned false (existing record left alone)', + ); +} finally { + try { term.kill(); } catch { /* already dead */ } +} + +const failed = results.filter((r) => !r.ok); +console.log(`\n=== ${results.length - failed.length}/${results.length} PASS ===`); +if (failed.length) { + console.log('failed steps:', failed.map((f) => f.step).join('; ')); + console.log('\n--- raw transcript tail ---\n' + transcript.slice(-4000)); } -record('4. inner-restart retention (role token + task recalled via kimi -S)', retention, - `last turns: ${lastTurns}`); - -// buildResume returns the same pinned id (afx spawn --resume path). -const resume = KIMI_HARNESS.buildResume(worktree); -record('4b. buildResume returns the pinned session (-S form)', - resume?.sessionId === sid && resume?.args?.[0] === '-S', - JSON.stringify(resume)); - -// --- Teardown --------------------------------------------------------------- -term.write('\x03'); await sleep(500); term.write('\x03'); await sleep(1000); -term.kill(); - -console.log('\n=== DEMO SUMMARY ==='); -for (const r of results) console.log(`${r.ok ? 'PASS' : 'FAIL'} ${r.step}`); -process.exit(results.every((r) => r.ok) ? 0 : 1); +process.exit(failed.length ? 1 : 0); diff --git a/codev/spikes/pir-1201-kimi-continue-probe.mjs b/codev/spikes/pir-1201-kimi-continue-probe.mjs new file mode 100644 index 000000000..98cbdf4f8 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-continue-probe.mjs @@ -0,0 +1,82 @@ +/** + * PIR #1201 — what does `kimi -c` do when there is NOTHING to continue? + * + * The pivot's crash path is `kimi -c --yolo` (documented, cwd-scoped) instead of + * a pinned `-S ` from the undocumented store. That is only safe if a crash + * BEFORE the first message — i.e. before 0.33.0's TUI has minted any session — + * fails loudly rather than silently starting a **roleless** fresh conversation. + * A silent roleless start is the #929 hazard class: the builder would run on with + * no role and nobody would know. + * + * Checks: + * A. `kimi -c -p "…"` in a virgin cwd — exit code and message. + * B. Whether it minted a session in that cwd anyway (silent-fresh evidence). + * C. Whether that fallback session carries the role (it cannot: -c forbids + * --agent-file), i.e. how bad a silent fallback would be. + * + * Usage: node codev/spikes/pir-1201-kimi-continue-probe.mjs + */ + +import { mkdtempSync, writeFileSync, mkdirSync, readdirSync, existsSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir, homedir } from 'node:os'; +import { join, basename, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); +const KIMI_HOME = process.env.KIMI_CODE_HOME || join(homedir(), '.kimi-code'); + +function preTrust(root) { + const dir = join(KIMI_HOME, 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${basename(root).toLowerCase()}_${hash}`), + JSON.stringify({ root, trustedAt: Date.now() })); +} + +function sessionsFor(cwd) { + const root = join(KIMI_HOME, 'sessions'); + const found = []; + if (!existsSync(root)) return found; + for (const wd of readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory())) { + for (const s of readdirSync(join(root, wd.name), { withFileTypes: true }).filter((e) => e.isDirectory())) { + try { + const st = JSON.parse(readFileSync(join(root, wd.name, s.name, 'state.json'), 'utf-8')); + if ((st.cwd ?? st.workDir) === cwd) found.push(s.name); + } catch { /* unreadable */ } + } + } + return found; +} + +function run(args, cwd) { + return new Promise((resolve) => { + const term = pty.spawn('kimi', args, { + name: 'xterm-256color', cols: 110, rows: 32, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let out = ''; + term.onData((d) => { out += d; }); + term.onExit(({ exitCode }) => resolve({ out, exitCode })); + }); +} + +const dir = mkdtempSync(join(tmpdir(), 'kimi-cont-')); +preTrust(dir); +console.log(`[probe] virgin cwd: ${dir}`); + +const r = await run(['-c', '-p', 'Say READY and nothing else.'], dir); +console.log(`\n[A] exit code: ${r.exitCode}`); +console.log(`[A] output:\n${r.out.trim().slice(0, 1200)}`); + +const after = sessionsFor(dir); +console.log(`\n[B] sessions minted in that cwd: ${after.length} ${JSON.stringify(after)}`); +console.log( + r.exitCode !== 0 + ? '\nVERDICT: `-c` FAILS LOUDLY with nothing to continue → the launch loop\'s fast-fail degrade converts it to a fresh (role-carrying) relaunch. Safe.' + : '\nVERDICT: `-c` SUCCEEDS with nothing to continue → it silently starts a conversation the role never reached. The loop must NOT enter on -c before a session exists.' +); +process.exit(0); diff --git a/codev/spikes/pir-1201-kimi-gate-measure.mjs b/codev/spikes/pir-1201-kimi-gate-measure.mjs new file mode 100644 index 000000000..0c820ba79 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-gate-measure.mjs @@ -0,0 +1,231 @@ +/** + * Kimi render-gate measurement (PIR #1201, re-integration against Spec 1313). + * + * Spec 1313's render gate delivers a message only onto a composer it can prove + * empty, and it does that per-app via a `GateProfile` (marker pattern, region-end + * patterns, optional placeholder color). An app with no profile holds every + * message with `no-profile` — so a measured Kimi profile is a functional + * prerequisite for `afx send` to a Kimi builder, not polish. + * + * This is the Kimi analogue of the agy Phase-3 measurement: drive a real `kimi` + * TUI under a PTY, capture the raw byte stream for each screen state, render it + * through the SAME data path the live gate uses (RingBuffer → @xterm/headless), + * and dump per-cell attributes so the profile is derived from observation rather + * than assumption. + * + * States captured: + * idle — settled composer, nothing typed (must classify CLEAN) + * draft — a few characters typed, no Enter (must classify BUSY) + * seed — `kimi -p … --output-format stream-json` running (must classify BUSY: + * this is the seed window, where a written byte has no consumer) + * + * Usage: node codev/spikes/pir-1201-kimi-gate-measure.mjs [outDir] + */ + +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); +const xterm = require(join(repoRoot, 'packages/codev/node_modules/@xterm/headless')); +const { Terminal } = xterm; + +const COLS = 110; +const ROWS = 32; +const outDir = process.argv[2] || join(repoRoot, 'codev/spikes/kimi-gate-capture'); +mkdirSync(outDir, { recursive: true }); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Render a raw PTY stream and dump the viewport + per-cell attributes. */ +async function render(raw) { + const term = new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true, scrollback: 2000 }); + await new Promise((resolve) => term.write(raw, resolve)); + const buf = term.buffer.active; + const top = buf.viewportY; + const lines = []; + for (let i = 0; i < ROWS; i++) { + const line = buf.getLine(top + i); + lines.push(line ? line.translateToString(true).trimEnd() : ''); + } + return { term, buf, top, lines }; +} + +/** Per-cell attribute dump for one viewport row — the evidence the profile rests on. */ +function dumpRow(buf, top, row) { + const line = buf.getLine(top + row); + if (!line) return ' (no line)'; + const cell = buf.getNullCell(); + const parts = []; + for (let col = 0; col < COLS; col++) { + line.getCell(col, cell); + const ch = cell.getChars(); + if (!ch || ch === ' ') continue; + const attrs = []; + if (cell.isDim()) attrs.push('dim'); + if (cell.isInverse()) attrs.push('inv'); + if (cell.isBold()) attrs.push('bold'); + if (cell.isFgPalette()) attrs.push(`fgPal=${cell.getFgColor()}`); + else if (cell.isFgRGB()) attrs.push(`fgRGB=${cell.getFgColor().toString(16)}`); + else attrs.push('fgDefault'); + parts.push(`${col}:${JSON.stringify(ch)}[${attrs.join(',')}]`); + } + return ' ' + (parts.join(' ') || '(empty)'); +} + +async function report(name, raw) { + writeFileSync(join(outDir, `${name}.raw.txt`), raw); + const { term, buf, top, lines } = await render(raw); + console.log(`\n${'='.repeat(78)}\n== ${name} (${raw.length} bytes)\n${'='.repeat(78)}`); + console.log(`cursor: row=${buf.cursorY} col=${buf.cursorX}`); + console.log('--- viewport (row: text) ---'); + lines.forEach((l, i) => { + if (l) console.log(`${String(i).padStart(2)}: ${JSON.stringify(l)}`); + }); + // Dump attributes for every non-empty row in the bottom third — the composer lives there. + console.log('--- per-cell attributes (non-empty rows, bottom half) ---'); + for (let i = Math.floor(ROWS / 2); i < ROWS; i++) { + if (!lines[i]) continue; + console.log(`row ${i}: ${JSON.stringify(lines[i])}`); + console.log(dumpRow(buf, top, i)); + } + term.dispose(); +} + +/** + * Pre-write kimi's workspace-trust record for `root` (0.33.0+). + * + * UNDOCUMENTED SURFACE, derived by observation on 0.34.0: trust lives at + * `~/.kimi-code/workspace-trust/wd__` + * holding `{root, trustedAt}`. Without it the pinned `-S` TUI opens on an + * interactive "Trust this folder?" dialog instead of a composer, and the + * dialog's only non-trusting option EXITS — so an unattended builder can never + * reach its prompt. (Trust gates project-level MCP servers only.) + */ +function preTrust(root) { + const dir = join(process.env.HOME, '.kimi-code', 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const slug = root.split('/').filter(Boolean).pop().toLowerCase(); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${slug}_${hash}`), JSON.stringify({ root, trustedAt: Date.now() })); +} + +async function captureTrustDialog() { + const cwd = mkdtempSync(join(tmpdir(), 'kimi-untrusted-')); + const term = pty.spawn('kimi', ['--yolo'], { + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let raw = ''; + term.onData((d) => { raw += d; }); + console.error('[measure] capturing the untrusted-folder dialog (18s)…'); + await sleep(18000); + try { term.kill(); } catch { /* already gone */ } + return { trust: raw }; +} + +async function captureTui() { + const cwd = mkdtempSync(join(tmpdir(), 'kimi-gate-')); + preTrust(cwd); + const term = pty.spawn('kimi', ['--yolo'], { + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let raw = ''; + term.onData((d) => { raw += d; }); + + console.error('[measure] waiting 20s for the kimi TUI to settle…'); + await sleep(20000); + const idle = raw; + + console.error('[measure] typing a draft (no Enter)…'); + term.write('draft text'); + await sleep(4000); + const draft = raw; + await clear(term, 40); + + // The screens the 3-way review (2026-08-09) said a happy-path run never + // produces, and which are exactly where a LAST-match marker search can pick + // the wrong row. Each is captured raw so the profile is derived from what kimi + // actually renders rather than from a constructed screen. + // + // multiline: a two-line draft whose SECOND line begins with ">" — a pasted + // quote or a markdown blockquote, and the shape that could make a + // continuation row look like the composer marker while the real draft text + // sits ABOVE it, outside the scanned region. + console.error('[measure] typing a multi-line draft whose 2nd line starts with ">"…'); + let multiline = null; + term.write('implement the whole feature\n> quoted second line'); + await sleep(4000); + multiline = raw; + await clear(term, 80); + + // The false-CLEAN shape itself: same two-line draft, but the last line is a + // BARE ">". Every cell the classifier would count then lives ABOVE the row it + // picks as the marker, so the composer reads empty while holding real text. + // Captured rather than constructed so the regression test rests on bytes kimi + // actually emitted. + console.error('[measure] typing a multi-line draft whose 2nd line is a bare ">"…'); + term.write('implement the whole feature\n>'); + await sleep(4000); + const multilineBare = raw; + await clear(term, 80); + + // menu: the "/" command list. picker: the "@" file list. Both render EXTRA + // rows around the composer, which is what makes them the interesting case. + console.error('[measure] opening the "/" command menu…'); + term.write('/'); + await sleep(4000); + const menu = raw; + await clear(term, 10); + + console.error('[measure] opening the "@" file picker…'); + term.write('@'); + await sleep(4000); + const picker = raw; + await clear(term, 10); + + term.kill(); + return { idle, draft, multiline, multilineBare, menu, picker }; +} + +/** Backspace the composer clean so the next capture starts from a settled idle screen. */ +async function clear(term, n) { + term.write('\x7f'.repeat(n)); + await sleep(2000); +} + +async function captureSeed() { + const cwd = mkdtempSync(join(tmpdir(), 'kimi-seed-')); + const term = pty.spawn('kimi', ['-p', 'Reply with exactly SEED-OK and nothing else.', + '--output-format', 'stream-json'], { + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let raw = ''; + term.onData((d) => { raw += d; }); + console.error('[measure] running the seed (non-interactive) for 12s…'); + await sleep(12000); + const seed = raw; + try { term.kill(); } catch { /* already gone */ } + return { seed }; +} + +const { idle, draft, multiline, multilineBare, menu, picker } = await captureTui(); +await report('kimi-idle', idle); +await report('kimi-draft', draft); +await report('kimi-multiline', multiline); +await report('kimi-multiline-bare', multilineBare); +await report('kimi-menu', menu); +await report('kimi-picker', picker); +const { trust } = await captureTrustDialog(); +await report('kimi-trust', trust); +const { seed } = await captureSeed(); +await report('kimi-seed', seed); +console.error(`\n[measure] raw captures written to ${outDir}`); +process.exit(0); From 4a7e2afe2688cc9042e83c543b37b61545b95a70 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 8 Aug 2026 23:02:31 -0400 Subject: [PATCH 38/43] =?UTF-8?q?[Spec=201201]=20docs:=20builder=20thread?= =?UTF-8?q?=20=E2=80=94=20post-pivot=20CMAP=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- codev/state/pir-1201_thread.md | 68 ++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index def55ed7e..c15e0ae4c 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -56,3 +56,71 @@ - CMAP (gemini, codex, claude) on the change set: unanimous APPROVE, zero findings, clean in one iteration. - Full suite: 3802 passed / 48 skipped. - Live kimi 0.29.1 verification (tmux PTY, real bare launch script from dist): /quit → exit 0 → keypress gate held (no respawn), Enter relaunched; SIGKILL → code 137 → auto-restart after 2s. Both branches behave per the #1244 contract. + +## 2026-08-08/09 — Re-integration after parking: merge main + design pivot + +The PR sat parked on two upstream blockers; both landed, the branch went stale (901 commits behind), and `kimi` itself drifted 0.27.0 → 0.34.0. This session re-integrates. + +**Merged `origin/main`** (10 conflicts). Took main's rewritten `spawn-worktree.ts` / `tower-routes.ts` / `tower-cron.ts` / `tower-client.ts` / `discover-resume-session.test.ts` wholesale — our versions were the retired `SendBuffer` / direct-PTY-write paths that Spec 1313 replaced, plus a launch-loop shape #1233/#1317 superseded. Hand-merged `doctor.ts` and three docs. + +**Design pivot** (architect-directed, PR comment 5229238112), validated live 7/7 against real kimi 0.34.0 before any code was committed to it: +- **Role via `--agent-file`** (0.31.0+), composed around `${base_prompt}` so it EXTENDS kimi's own system prompt instead of replacing it. Verified injecting in both `-p` and the interactive TUI — the half never measured in the original spike. +- **Task via the Spec 1313 mailbox**, delivered by the render gate onto a verified-empty composer. Never a direct PTY write. +- **Deleted** `seed-kick.ts`, the sentinel, the `-p` seed bootstrap, `.builder-seed.txt`, the ack-and-wait BEGIN discipline, and (later) the dead `SeedKickRequest` SDK surface. + +**The finding that shaped the launch loop.** `kimi -c` does NOT fail with nothing to continue — it prints `No sessions to continue…` and starts a fresh session that never saw `--agent-file`, i.e. a silently ROLELESS builder (#929 hazard class). So every path to `-c` is gated on an inlined `node -e` store probe that fails CLOSED to a role-carrying fresh launch. Pinned by tests that EXECUTE the probe against fixture stores and cross-check it against `findLatestKimiSessionId`, so the hand-written bash snippet cannot drift from the TypeScript it mirrors. + +**Pacing re-homed.** Spec 1313 replaced the routes `message-pacing.ts` hooked into, leaving pacing wired to nothing — every `afx send` to a Kimi builder would have been typed and never submitted. Now resolved in `mailbox-wiring.ts` (`resolveHarnessForSession` → `getBuiltinHarness(...).messagePacing`) and threaded through `writeMessagePaced`. Deleted `message-pacing.ts` AND the `.builder-kimi` marker: the harness name now comes out of the generated `.builder-start.sh`, which is generated FROM the resolved harness and so cannot be forgotten — the marker's coverage obligation is exactly what the maintainer's earlier finding was about. `--interrupt` paces too; `--escape` deliberately does not (writes no text; unmeasured on kimi). + +**Guardrail 1 (render-gate).** The one shared-code edit: the classifier's marker exemption follows the profile's matched span instead of column 0, because kimi's marker sits at column 3 inside a rounded box. Carries dedicated before/after pins — exact span per shipped profile (claude/codex 1 = literally the old rule, agy 2 whose extra cell is whitespace already skipped), a tightest-possible-draft test per profile proving no over-skip, and a direct demonstration that a span-2 kimi profile classifies the real idle capture `user-text` while the shipped span-4 one classifies it clean. Three REAL 0.34.0 captures added as fixtures (committed raw — they carry only throwaway `/tmp` paths, unlike the agy captures). **Flag this for CMAP.** + +**Guardrail 2 (trust).** No sanctioned bypass exists (audited 0.34.0: no `--help` flag; full strings sweep for `KIMI_*` env vars and trust config keys found nothing). Kept fail-soft, and added `inspectKimiTrustLayout` — it validates our undocumented `sha256(root)[:12]` derivation against kimi's OWN records, so a scheme change surfaces as a named `codev doctor` warning instead of silently stranding every new builder on the dialog. Doctor now reports the richer per-surface drift reasons; `kimiStoreLayoutLooksDrifted` deleted as production-dead. + +**Version floor raised 0.27.0 → 0.33.0.** `--agent-file` is the hard break (0.31.0), but every measurement here was taken on the agent-core-v2 engine 0.33.0 made default. Claiming 0.31–0.32 support would be unverified. Flagged in the PR as the maintainer's call. + +**Corrected an obsolete claim**: kimi DOES have a hook seam (blocking `PreToolUse`, `[[hooks]]` in config.toml, 18 events as of 0.32.0), so "#1018 write-guard parity impossible" was wrong. Docs now say parity is achievable follow-up work; the PR asks the maintainer whether it lands here or separately. + +Store drift also fixed (three renames, not one: `workDir`→`cwd`, ISO→epoch-ms timestamps, `lastPrompt` gone) with v1 back-compat retained. + +## 2026-08-09 — post-pivot CMAP round: two blocking defects, both fixed + +Collected the work left in flight at the context reset (nothing restarted — the demo and both +consultations were still alive and were allowed to finish). + +**CMAP: gemini APPROVE, codex REQUEST_CHANGES, claude REQUEST_CHANGES.** Both REQUEST_CHANGES +found the same two defects from opposite directions, and neither is reachable from a happy-path +run — an empty composer and a clean store both behave correctly, which is exactly why three +passing live demos missed them. Full dispositions in +`codev/projects/1201-*/1201-cmap-postpivot-dispositions.md`. + +1. **False CLEAN on a multi-row kimi composer (blocking).** kimi's marker `│ >` can match a + *continuation* row, and `findMarkerRow` takes the last match, so a draft whose final line + begins with `>` left the real text above the scanned region → clean verdict on a composer + holding unsent input. Claude reproduced it but had no live kimi to confirm the geometry; I + measured it — real 0.34.0 renders exactly that shape. Fixed with an optional, *exclusive* + `regionStartPatterns` upper bound (kimi: the box top). Exclusive was not cosmetic: my first + attempt included the box-top row, whose `╮` is not an ignorable glyph, and it held every idle + composer forever — the fixture suite caught it immediately. Claude's second proposed input (a + marker row inside a second box below the composer) is NOT reachable: measured, kimi's `/` menu + renders as unclosed `│` rows with no `╰`, so it yields `no-region-end` → held. Four new + fixtures from live capture: multiline-bare, multiline, menu, picker. +2. **Store probe diverged from the TypeScript (blocking).** codex found the dangerous direction + (an `archived` session authorized `-c`, which kimi then refuses to continue → fresh, roleless + session — the #929 class). Claude found the safe-but-harmful direction (one stray `.DS_Store` + threw ENOTDIR into the single outer try and disabled resume machine-wide, silently). The + cross-check test had been comparing two implementations of the same omissions. Both now share + one resumability predicate and per-level error handling, with every case asserted against both. +3. Plus: shell-metacharacter interpolation in the generated script (all three reviewers, from + different angles), unbounded task re-queueing in a crash loop, drift probes that report healthy + forever after a migration, and two stale seed-era strings. + +**The demo's role oracle was wrong, not the product.** Its two failures (steps 2 and 4b) were a +role that told the model to prefix every reply with a token — that measures K3's formatting +compliance, not role delivery. The live `--agent-file` probe passed 7/7 against a +production-identical agent file, including role survival across `kimi -c`. Rewrote the demo to +ask for a codeword instead (the same oracle the probe uses), with a comment saying why so nobody +restores the weaker one. + +**Verification:** `pnpm build` clean; full suite **4900 passed / 48 skipped / 0 failed**; live +demo **7/7** against real kimi 0.34.0, including the crash-resume claim that was withheld until +it passed. From b8df89880e95899e0c2bdf0a52697c4422a74171 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sun, 9 Aug 2026 00:45:55 -0400 Subject: [PATCH 39/43] [Spec 1201] fix: hold a kimi draft whose every cell is exempt chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architect review finding 1. Enter a newline then `>` and kimi renders `│ > ` / `│ >`: row one empty, row two matching the marker so its `>` is span-exempted. Every cell is whitespace, box chrome, or an exempted marker, so userCells is 0 and the composer reads CLEAN while holding unsent input — a held message would be typed on top of the user's draft. Bounding the region correctly does not help; the draft is real but literally uncountable. So hold on the region's SHAPE instead: a boxed composer taller than one interior row is a multi-line draft. That is only sound if box growth is exclusive to multi-line drafts, which is a claim about kimi, not about our code — so it was measured on real 0.34.0 first (pir-1201-kimi-box-growth.mjs). Idle, single-line draft, `/` menu, `@` picker and the post-reply steady state all hold at one interior row; only the newline drafts grow. The steady state is load-bearing: growth there would hold every later message forever, a liveness bug rather than a fail-safe one. The working states were measured too (pir-1201-kimi-working-states.mjs) — mid- generation, mode chrome, and a draft typed while the agent works are all one row, so "deliver while busy" does not silently become "hold until idle". Placed AFTER the cell scan, not before it, so the count keeps its ground-truth role: a text-bearing multi-row draft still reports `user-text` and every pre-existing fixture verdict is unchanged. Armed by a dedicated `growsWithDraft` profile field rather than by `regionStartPatterns` (CMAP: codex #1, claude Q5). The two are unrelated properties that merely coincide for kimi, and the hazard is concrete: the shipped codex-idle.clean.txt capture — a real, genuinely EMPTY composer — already spans two interior rows, so arming on the scan bound would have killed codex delivery the day anyone declared a region start for it. The rule now needs both opt-ins, and the inertness tests run on that real capture under four profile variants. Also: `isClassifierStuck` enumerated details as a closed || chain, so widening the union never forced a decision — now a Record keyed by the union, making the next new detail a compile error rather than a silent false (claude F2). Full suite 4906 passed / 48 skipped / 0 failed. --- codev/spikes/pir-1201-kimi-box-growth.mjs | 239 ++++++++++++++++++ codev/spikes/pir-1201-kimi-working-states.mjs | 139 ++++++++++ .../__tests__/fixtures/gate/README.md | 23 ++ .../fixtures/gate/kimi-newline-bare.busy.txt | 69 +++++ .../agent-farm/__tests__/render-gate.test.ts | 85 +++++++ .../src/agent-farm/servers/gate-profiles.ts | 30 +++ .../agent-farm/servers/mailbox-delivery.ts | 27 +- .../src/agent-farm/servers/render-gate.ts | 97 ++++++- 8 files changed, 699 insertions(+), 10 deletions(-) create mode 100644 codev/spikes/pir-1201-kimi-box-growth.mjs create mode 100644 codev/spikes/pir-1201-kimi-working-states.mjs create mode 100644 packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-newline-bare.busy.txt diff --git a/codev/spikes/pir-1201-kimi-box-growth.mjs b/codev/spikes/pir-1201-kimi-box-growth.mjs new file mode 100644 index 000000000..0fbde5c9d --- /dev/null +++ b/codev/spikes/pir-1201-kimi-box-growth.mjs @@ -0,0 +1,239 @@ +/** + * Kimi composer box-growth measurement (PIR #1201, architect review finding 1). + * + * QUESTION THIS ANSWERS: does kimi's composer box ever grow past ONE interior row + * for a reason other than a multi-line draft? + * + * Why it matters. `classifyBuffer` counts *cells* inside the composer region and + * calls a zero-cell region CLEAN. Kimi's per-row marker exemption makes that + * unsound for one draft shape: enter Shift+Enter then `>` and the screen is + * + * │ > <- row 1: empty + * │ > <- row 2: matches KIMI_MARKER, so its `>` is span-exempted + * + * Every cell is either whitespace or exempt chrome → userCells 0 → CLEAN → held + * mail is typed on top of unsent user input, the exact merge Spec 1313 exists to + * prevent. + * + * The proposed fix reads GEOMETRY rather than cells: for a profile that declares + * `regionStartPatterns` (kimi alone), a region spanning more than one interior row + * is a multi-line draft by construction → busy regardless of what the cells say. + * That is only sound if box growth is *exclusive* to multi-line drafts — a + * non-draft state that grows the box would hold delivery forever (a liveness bug, + * not a fail-safe one). Hence: measure before implementing. + * + * Geometry reported per state matches the classifier's own bounds exactly: + * startRow = (last box-top row) + 1 ... findRegionStart + * endRow = first box-bottom row after the LAST marker row ... findRegionEnd + * interior = endRow - startRow ... the rows classifyBuffer scans + * + * States captured (the architect's required list, plus the two that decide liveness): + * idle settled composer, nothing typed expect interior 1 + * draft short single-line draft expect interior 1 + * wrap one long line, no spaces, soft-wrapped informational: if this + * grows the box it carries text and is busy either way + * menu the "/" command list expect interior 1 + * picker the "@" file picker expect interior 1 + * newline-bare Shift+Enter then ">" (THE false-CLEAN) expect interior 2 + * newline-only Shift+Enter, nothing else expect interior 2 + * after-response idle again after a real reply expect interior 1 — this + * is the builder's steady state; growth here would hold forever + * + * Usage: node codev/spikes/pir-1201-kimi-box-growth.mjs [outDir] + */ + +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); +const xterm = require(join(repoRoot, 'packages/codev/node_modules/@xterm/headless')); +const { Terminal } = xterm; + +// The same 110x32 the suite classifies at, so geometry here IS geometry there. +const COLS = 110; +const ROWS = 32; +const outDir = process.argv[2] || join(repoRoot, 'codev/spikes/kimi-gate-capture'); +mkdirSync(outDir, { recursive: true }); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Mirrors of the production profile's patterns (gate-profiles.ts). Kept literal +// rather than imported so this spike stays a standalone observation of kimi, not +// a test of our own code. +const MARKER = /^\s*│\s*>/; +const BOX_TOP = /^\s*╭[─━╌┄]{3,}/; +const BOX_BOTTOM = /^\s*╰[─━╌┄]{3,}/; + +async function render(raw) { + const term = new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true, scrollback: 2000 }); + await new Promise((resolve) => term.write(raw, resolve)); + const buf = term.buffer.active; + const top = buf.viewportY; + const lines = []; + for (let i = 0; i < ROWS; i++) { + const line = buf.getLine(top + i); + lines.push(line ? line.translateToString(true).trimEnd() : ''); + } + return { term, lines }; +} + +/** Reproduce the classifier's region bounds and report the interior row count. */ +function geometry(lines) { + let markerRow = -1; + for (let i = 0; i < lines.length; i++) if (MARKER.test(lines[i])) markerRow = i; // LAST match + if (markerRow === -1) return { markerRow, verdict: 'no-composer-marker' }; + + let endRow = -1; + for (let i = markerRow + 1; i < lines.length; i++) { + if (BOX_BOTTOM.test(lines[i])) { endRow = i; break; } + } + if (endRow === -1) return { markerRow, verdict: 'no-region-end' }; + + let startRow = -1; + for (let i = markerRow - 1; i >= 0; i--) { + if (BOX_TOP.test(lines[i])) { startRow = i + 1; break; } + } + if (startRow === -1) return { markerRow, endRow, verdict: 'no-region-start' }; + + return { markerRow, startRow, endRow, interior: endRow - startRow, verdict: 'scanned' }; +} + +const results = []; + +async function report(name, raw, expectation) { + writeFileSync(join(outDir, `growth-${name}.raw.txt`), raw); + const { term, lines } = await render(raw); + const g = geometry(lines); + results.push({ name, ...g, expectation }); + console.log(`\n${'='.repeat(78)}\n== ${name} (${raw.length} bytes) — expected: ${expectation}\n${'='.repeat(78)}`); + console.log(`geometry: ${JSON.stringify(g)}`); + console.log('--- composer rows (the region and its bounds) ---'); + const from = g.startRow !== undefined ? g.startRow - 1 : Math.floor(ROWS / 2); + const to = g.endRow !== undefined ? g.endRow + 1 : ROWS - 1; + for (let i = Math.max(0, from); i <= Math.min(ROWS - 1, to); i++) { + const tag = g.startRow !== undefined && i >= g.startRow && i < g.endRow ? ' <== SCANNED' : ''; + console.log(`${String(i).padStart(2)}: ${JSON.stringify(lines[i])}${tag}`); + } + term.dispose(); +} + +/** + * Pre-write kimi's workspace-trust record (0.33.0+) so the TUI opens on a composer + * rather than the interactive trust dialog. Undocumented surface, derived by + * observation on 0.34.0 — see the harness spike for the full note. + */ +function preTrust(root) { + const dir = join(process.env.HOME, '.kimi-code', 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const slug = root.split('/').filter(Boolean).pop().toLowerCase(); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${slug}_${hash}`), JSON.stringify({ root, trustedAt: Date.now() })); +} + +/** Backspace the composer clean so the next capture starts from a settled idle screen. */ +async function clear(term, n) { + term.write('\x7f'.repeat(n)); + await sleep(2500); +} + +async function capture() { + const cwd = mkdtempSync(join(tmpdir(), 'kimi-growth-')); + preTrust(cwd); + const term = pty.spawn('kimi', ['--yolo'], { + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let raw = ''; + term.onData((d) => { raw += d; }); + + console.error('[growth] waiting 20s for the kimi TUI to settle…'); + await sleep(20000); + const idle = raw; + + console.error('[growth] short single-line draft…'); + term.write('draft text'); + await sleep(4000); + const draft = raw; + await clear(term, 40); + + // No spaces: forces a hard soft-wrap rather than a word-boundary break, which is + // the shape most likely to add an interior row without a newline in the draft. + console.error('[growth] long single line (soft wrap)…'); + term.write('x'.repeat(180)); + await sleep(4000); + const wrap = raw; + await clear(term, 260); + + console.error('[growth] "/" command menu…'); + term.write('/'); + await sleep(4000); + const menu = raw; + await clear(term, 10); + + console.error('[growth] "@" file picker…'); + term.write('@'); + await sleep(4000); + const picker = raw; + await clear(term, 10); + + // THE false-CLEAN shape: newline first (so row 1 is empty), then a bare ">" on + // row 2 — which matches the marker pattern and so is span-exempted as chrome. + console.error('[growth] newline then bare ">" (the false-CLEAN shape)…'); + term.write('\n>'); + await sleep(4000); + const newlineBare = raw; + await clear(term, 20); + + console.error('[growth] newline only…'); + term.write('\n'); + await sleep(4000); + const newlineOnly = raw; + await clear(term, 20); + + // The builder's STEADY state: a composer that has already carried a turn. If the + // box stays grown here, the geometry rule would hold every later message forever. + console.error('[growth] submitting a real prompt, then measuring idle-after-response (45s)…'); + term.write('Reply with exactly OK and nothing else.'); + await sleep(1200); + term.write('\r'); + await sleep(45000); + const afterResponse = raw; + + term.kill(); + return { idle, draft, wrap, menu, picker, newlineBare, newlineOnly, afterResponse }; +} + +const s = await capture(); +await report('idle', s.idle, 'interior 1'); +await report('draft', s.draft, 'interior 1'); +await report('wrap', s.wrap, 'informational (text-bearing either way)'); +await report('menu', s.menu, 'interior 1'); +await report('picker', s.picker, 'interior 1'); +await report('newline-bare', s.newlineBare, 'interior 2 (the false-CLEAN shape)'); +await report('newline-only', s.newlineOnly, 'interior 2'); +await report('after-response', s.afterResponse, 'interior 1 (steady state — growth here = permanent hold)'); + +console.log(`\n${'='.repeat(78)}\n== VERDICT TABLE\n${'='.repeat(78)}`); +for (const r of results) { + console.log( + `${r.name.padEnd(16)} interior=${String(r.interior ?? '-').padEnd(3)} ` + + `verdict=${(r.verdict ?? '-').padEnd(18)} expected: ${r.expectation}` + ); +} +const drafts = new Set(['newline-bare', 'newline-only']); +const grewWithoutDraft = results.filter( + (r) => r.interior !== undefined && r.interior > 1 && !drafts.has(r.name) && r.name !== 'wrap' +); +console.log( + grewWithoutDraft.length === 0 + ? '\nPREMISE HOLDS: only multi-line drafts grew the box. The geometry rule is safe.' + : `\nPREMISE CONTRADICTED by: ${grewWithoutDraft.map((r) => r.name).join(', ')} — do NOT implement; document the residual.` +); +console.error(`\n[growth] raw captures written to ${outDir}`); +process.exit(0); diff --git a/codev/spikes/pir-1201-kimi-working-states.mjs b/codev/spikes/pir-1201-kimi-working-states.mjs new file mode 100644 index 000000000..8fe5df12c --- /dev/null +++ b/codev/spikes/pir-1201-kimi-working-states.mjs @@ -0,0 +1,139 @@ +/** + * Kimi composer geometry while the agent is WORKING (PIR #1201, CMAP 2026-08-09). + * + * The box-growth measurement (`pir-1201-kimi-box-growth.mjs`) covered idle, drafts, + * menus, pickers and the post-reply steady state, and the multi-row-draft rule rests + * on it. The 3-way review flagged one class it did not enumerate: the composer WHILE + * the agent is generating (spinner / "esc to interrupt" / queued-message indicator), + * and the mode chrome (shift+tab mode cycle, `!` bash mode). + * + * If any of those grow the box past one interior row while carrying no countable + * cells, mail to a WORKING kimi builder would be held until it goes idle — bounded and + * self-healing, but a behavior change nothing documents. This probe answers it with + * bytes instead of reasoning. + * + * Usage: node codev/spikes/pir-1201-kimi-working-states.mjs + */ + +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); +const { Terminal } = require(join(repoRoot, 'packages/codev/node_modules/@xterm/headless')); + +const COLS = 110, ROWS = 32; +const outDir = join(repoRoot, 'codev/spikes/kimi-gate-capture'); +mkdirSync(outDir, { recursive: true }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const MARKER = /^\s*│\s*>/; +const BOX_TOP = /^\s*╭[─━╌┄]{3,}/; +const BOX_BOTTOM = /^\s*╰[─━╌┄]{3,}/; + +async function geometry(raw) { + const term = new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true, scrollback: 2000 }); + await new Promise((r) => term.write(raw, r)); + const buf = term.buffer.active; + const top = buf.viewportY; + const lines = []; + for (let i = 0; i < ROWS; i++) { + const l = buf.getLine(top + i); + lines.push(l ? l.translateToString(true).trimEnd() : ''); + } + let markerRow = -1; + for (let i = 0; i < ROWS; i++) if (MARKER.test(lines[i])) markerRow = i; + if (markerRow === -1) { term.dispose(); return { verdict: 'no-composer-marker', lines }; } + let endRow = -1; + for (let i = markerRow + 1; i < ROWS; i++) if (BOX_BOTTOM.test(lines[i])) { endRow = i; break; } + if (endRow === -1) { term.dispose(); return { verdict: 'no-region-end', lines }; } + let startRow = -1; + for (let i = markerRow - 1; i >= 0; i--) if (BOX_TOP.test(lines[i])) { startRow = i + 1; break; } + term.dispose(); + if (startRow === -1) return { verdict: 'no-region-start', lines }; + return { verdict: 'scanned', interior: endRow - startRow, startRow, endRow, lines }; +} + +function preTrust(root) { + const dir = join(process.env.HOME, '.kimi-code', 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const slug = root.split('/').filter(Boolean).pop().toLowerCase(); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${slug}_${hash}`), JSON.stringify({ root, trustedAt: Date.now() })); +} + +const results = []; +async function record(name, raw, note) { + writeFileSync(join(outDir, `working-${name}.raw.txt`), raw); + const g = await geometry(raw); + results.push({ name, ...g, note }); + console.log(`\n${'='.repeat(74)}\n== ${name} — ${note}\n${'='.repeat(74)}`); + console.log(`verdict=${g.verdict} interior=${g.interior ?? '-'}`); + const from = g.startRow !== undefined ? g.startRow - 1 : ROWS - 8; + const to = g.endRow !== undefined ? g.endRow + 1 : ROWS - 1; + for (let i = Math.max(0, from); i <= Math.min(ROWS - 1, to); i++) { + if (g.lines[i]) console.log(`${String(i).padStart(2)}: ${JSON.stringify(g.lines[i])}`); + } +} + +const cwd = mkdtempSync(join(tmpdir(), 'kimi-working-')); +preTrust(cwd); +const term = pty.spawn('kimi', ['--yolo'], { + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, +}); +let raw = ''; +term.onData((d) => { raw += d; }); + +console.error('[working] settling (20s)…'); +await sleep(20000); + +// Mode chrome first, while nothing is running. +console.error('[working] shift+tab mode cycle…'); +term.write('\x1b[Z'); +await sleep(3500); +await record('mode-cycle', raw, 'after shift+tab (mode chrome)'); + +console.error('[working] "!" bash mode…'); +term.write('!'); +await sleep(3500); +await record('bash-mode', raw, 'after "!" (bash mode)'); +term.write('\x7f'.repeat(5)); +await sleep(2500); + +// A prompt long enough to observe MID-generation rather than only the settled end. +console.error('[working] submitting a long-running prompt…'); +term.write('Count from 1 to 40, one number per line, each with a brief comment.'); +await sleep(1200); +term.write('\r'); + +await sleep(5000); +await record('generating-early', raw, 'MID-generation, ~5s after submit'); +await sleep(8000); +await record('generating-mid', raw, 'MID-generation, ~13s after submit'); + +// A message typed WHILE the agent works — kimi queues it; does the box grow? +console.error('[working] typing while the agent is still working…'); +term.write('queued while working'); +await sleep(4000); +await record('queued-while-working', raw, 'draft typed during generation'); + +term.kill(); + +console.log(`\n${'='.repeat(74)}\n== VERDICT TABLE\n${'='.repeat(74)}`); +for (const r of results) { + console.log(`${r.name.padEnd(24)} verdict=${String(r.verdict).padEnd(18)} interior=${r.interior ?? '-'} (${r.note})`); +} +// A grown box with no countable cells is the only shape that would newly hold mail. +const risky = results.filter((r) => r.verdict === 'scanned' && r.interior > 1 && r.name !== 'queued-while-working'); +console.log( + risky.length === 0 + ? '\nNO NEW HOLD: no working/mode state grew the box. The rule changes nothing for a working builder.' + : `\nBEHAVIOR CHANGE: ${risky.map((r) => r.name).join(', ')} grow the box — mail to a working builder would be held until idle.` +); +process.exit(0); diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/README.md b/packages/codev/src/agent-farm/__tests__/fixtures/gate/README.md index c51a5c186..06cf84f6d 100644 --- a/packages/codev/src/agent-farm/__tests__/fixtures/gate/README.md +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/README.md @@ -58,6 +58,29 @@ encodes the expected verdict: `-..txt`. gets). The box bottom (`` ╰───╯ ``, indented one column) is kimi's sole region-end pattern — the shared rule pattern requires the rule glyph to start the line and so cannot bound it. +- **kimi-multiline.busy.txt, kimi-multiline-bare.busy.txt, kimi-newline-bare.busy.txt, + kimi-menu.busy.txt, kimi-picker.busy.txt** — **real captures** (0.34.0, same harness) + of the multi-row composer states, which is where a LAST-match marker search goes + wrong. kimi renders a two-line draft as `` │ > `` / `` │ ``, so + a continuation row beginning with `>` matches the marker too and the search settles + on it, leaving line one *above* the scanned region. `kimi-multiline-bare` is that + false-CLEAN with a real draft above the bare `>` — closed by the profile's + `regionStartPatterns` (anchor the region to the box top). `kimi-newline-bare` is the + residual the region bound alone cannot close (architect review, 2026-08-09): a + newline then `>` renders `` │ > `` / `` │ > `` — row one empty, row two's `>` + span-exempted as chrome — so the draft is real but has **zero countable cells** no + matter how the region is bounded. It is held on the composer's *shape* instead + (`multi-row-draft`), which is sound because box growth was **measured** to be + exclusive to multi-line drafts on 0.34.0 (harness: + `codev/spikes/pir-1201-kimi-box-growth.mjs` — idle, single-line draft, `/` menu, `@` + picker and the post-reply steady state all hold at one interior row; and + `pir-1201-kimi-working-states.mjs` — mid-generation, mode chrome, and a draft typed + while the agent works, likewise one row). The menu and picker captures pin that kimi + draws those lists *outside* the box, below its bottom rule, so neither grows the + scanned region. The rule is armed by the profile's `growsWithDraft` flag, **not** by + `regionStartPatterns`: `codex-idle.clean.txt` is a real, genuinely empty composer that + already spans two interior rows, so arming on the scan bound alone would hold codex + mail forever the day codex declared one. - **wrapper-boot.busy.txt** — **synthetic** builder launch-loop screen (a born-dirty state with no composer marker). App-agnostic: no marker → busy under any profile. diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-newline-bare.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-newline-bare.busy.txt new file mode 100644 index 000000000..8d7ea8502 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-newline-bare.busy.txt @@ -0,0 +1,69 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-growth-PAHkwx │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx /model: switch model]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ > draft text  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;;[?2026l[?25l[?2026h  │ > xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │]8;; + │ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ > /  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + │ → yolo Toggle YOLO mode: auto-approve tool actions, but the agent may still ask │]8;; + │  questions. │]8;; + │ model Switch LLM model │]8;; + │ permission Select permission mode │]8;; + │ plan Toggle plan mode │]8;; + │ settings Open TUI settings │]8;; + │  (1/48) │]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;; + + + + + + +[?2026l[?25l[?2026h  │ > @  │]8;;[?2026l[?25l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;;[?2026l[?25l[?2026h  │ > │]8;; + │ >  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;; + context: 0% (0/256k)]8;;[?2026l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/render-gate.test.ts b/packages/codev/src/agent-farm/__tests__/render-gate.test.ts index d2fff7d41..f3f7ea0e2 100644 --- a/packages/codev/src/agent-farm/__tests__/render-gate.test.ts +++ b/packages/codev/src/agent-farm/__tests__/render-gate.test.ts @@ -80,6 +80,10 @@ describe('render-gate — real captured fixtures (Spec 1313)', () => { // marker search is most likely to settle on the wrong row. 'kimi-multiline.busy', 'kimi-multiline-bare.busy', + // The all-exempt draft: every row is whitespace or whitespace+`>`, so no + // amount of correct region bounding produces a countable cell. Held on the + // region's SHAPE instead (see the multi-row-draft rule). + 'kimi-newline-bare.busy', 'kimi-menu.busy', 'kimi-picker.busy', 'wrapper-boot.busy', @@ -200,6 +204,87 @@ describe('render-gate — marker-span exemption is a no-op for claude/codex/agy .toMatchObject({ clean: true, detail: 'empty' }); }); + it('holds an all-exempt multi-row kimi draft, which no cell count can catch', async () => { + // Architect review 2026-08-09, finding 1. Enter a newline then `>` and kimi renders + // `│ > ` / `│ >`: row 1 is empty, row 2 matches the marker so its `>` is + // span-exempted as chrome. userCells is 0 with the region bounded exactly right — + // the cell count is simply blind here, and a queued message would be typed on top + // of the unsent draft. Real 0.34.0 capture, not a constructed screen. + const raw = readFileSync(`${FIXTURE_DIR}/kimi-newline-bare.busy.txt`, 'utf8'); + expect(await classifyScreen(snapshotFromRaw(raw), KIMI_PROFILE)) + .toMatchObject({ clean: false, detail: 'multi-row-draft' }); + + // …and the before/after half: the SAME bytes under a profile identical except that + // it declares no upper bound reproduce the false CLEAN. This pins that the rule is + // what protects this screen — if it ever stops classifying clean, the fixture has + // drifted and this test has stopped testing the fix. + const { regionStartPatterns: _dropped, ...unbounded } = KIMI_PROFILE; + expect(await classifyScreen(snapshotFromRaw(raw), unbounded as GateProfile)) + .toMatchObject({ clean: true, detail: 'empty' }); + }); + + it('leaves the multi-row rule inert for the profiles that do not opt in', async () => { + // This is NOT a hypothetical shape. codex's real, captured, genuinely-EMPTY composer + // spans TWO interior rows under its shipped profile (measured across every fixture: + // codex-idle marker=18 start=18 end=20), so the rule's geometric predicate is + // already true on a screen that must stay clean. The opt-in gates are the only + // thing standing between that capture and codex mail being held forever — which is + // exactly why arming lives in its own field rather than riding regionStartPatterns. + const codexIdle = readFileSync(`${FIXTURE_DIR}/codex-idle.clean.txt`, 'utf8'); + expect(await classifyScreen(snapshotFromRaw(codexIdle), CODEX_PROFILE)) + .toMatchObject({ clean: true, detail: 'empty' }); + + // The differential, on the SAME BYTES: opt the profile in and that identical empty + // screen flips to busy. Without this half, deleting the rule outright would leave + // the inertness assertion above still passing. + const armed: GateProfile = { + ...CODEX_PROFILE, + regionStartPatterns: [/^\s*$/], // any blank line above the marker bounds the region + growsWithDraft: true, + }; + expect(await classifyScreen(snapshotFromRaw(codexIdle), armed)) + .toMatchObject({ clean: false, detail: 'multi-row-draft' }); + }); + + it('needs BOTH opt-ins: a region start alone never arms the rule', async () => { + // The decoupling itself (CMAP 2026-08-09, codex #1 / claude Q5). A profile that + // bounds its scan but makes no claim about box growth must classify exactly as it + // did before this rule existed — otherwise declaring a region start for an + // unrelated reason (a header line, a boxed redesign) is a silent delivery outage. + const codexIdle = readFileSync(`${FIXTURE_DIR}/codex-idle.clean.txt`, 'utf8'); + const boundedOnly: GateProfile = { ...CODEX_PROFILE, regionStartPatterns: [/^\s*$/] }; + expect(await classifyScreen(snapshotFromRaw(codexIdle), boundedOnly)) + .toMatchObject({ clean: true, detail: 'empty' }); + + // …and the converse: growsWithDraft without a region start is inert too, because + // `endRow - startRow` would then measure the distance to the status line rather + // than the composer's height — a number the rule has no business reading. + const growsOnly: GateProfile = { ...CODEX_PROFILE, growsWithDraft: true }; + expect(await classifyScreen(snapshotFromRaw(codexIdle), growsOnly)) + .toMatchObject({ clean: true, detail: 'empty' }); + }); + + it('treats an EMPTY regionStartPatterns array as unbounded in both places that read it', async () => { + // The drift the shared hasRegionStart predicate exists to prevent. findRegionStart + // falls back to `startRow = markerRow` for an empty array; if the rule instead read + // it as "bounded" (a plain truthiness check on the array would), the two would + // disagree and the rule would fire on a region it never bounded. + const codexIdle = readFileSync(`${FIXTURE_DIR}/codex-idle.clean.txt`, 'utf8'); + const armed: GateProfile = { ...CODEX_PROFILE, regionStartPatterns: [], growsWithDraft: true }; + expect(await classifyScreen(snapshotFromRaw(codexIdle), armed)) + .toMatchObject({ clean: true, detail: 'empty' }); + }); + + it('kimi is the only shipped profile that opts into the rule, and it declares both fields', async () => { + for (const p of [CLAUDE_PROFILE, CODEX_PROFILE, AGY_PROFILE]) { + expect(p.growsWithDraft).toBeUndefined(); + } + expect(KIMI_PROFILE.growsWithDraft).toBe(true); + // growsWithDraft is meaningless without a box top to measure height from, so the + // two must be declared together. Pinned as an invariant rather than a convention. + expect(KIMI_PROFILE.regionStartPatterns?.length).toBeGreaterThan(0); + }); + it('holds a boxed composer whose box top is off-screen instead of scanning a partial region', async () => { // A marker row with no `╭` above it is a torn/mid-repaint frame for a boxed app. // The region has no proven upper bound, so the safe answer is hold — the same call diff --git a/packages/codev/src/agent-farm/servers/gate-profiles.ts b/packages/codev/src/agent-farm/servers/gate-profiles.ts index c6be69477..f0eca387c 100644 --- a/packages/codev/src/agent-farm/servers/gate-profiles.ts +++ b/packages/codev/src/agent-farm/servers/gate-profiles.ts @@ -122,6 +122,32 @@ const KIMI_REGION_END = [/^\s*╰[─━╌┄]{3,}/]; * fixture): that screen classified `clean`, and a queued message would have been * typed on top of the draft. Anchoring the region to the box top fixes it for any * number of draft rows. + * + * This bounds the SCAN. The residual case it cannot close is armed separately by + * `growsWithDraft` on the profile below — a draft whose every + * row is whitespace or whitespace+`>` (enter a newline, then `>`) has zero countable + * cells no matter how correctly the region is bounded, because the second row + * matches {@link KIMI_MARKER} and its `>` is span-exempted as chrome. Shape, not + * cells, is the only evidence left — so a region grown past one interior row is held. + * + * That rule is sound only because box growth is EXCLUSIVE to multi-line drafts, which + * was measured on real kimi 0.34.0 rather than assumed + * (`codev/spikes/pir-1201-kimi-box-growth.mjs`): idle, a single-line draft, the `/` + * menu, the `@` picker, and the post-reply steady state all hold at exactly one + * interior row; only the newline drafts grow to two. The steady-state result is the + * load-bearing one — growth on a composer that has already carried a turn would hold + * every later message forever, a liveness bug rather than a fail-safe one. (A long + * soft-wrapped single line grows the box too, but it carries text and was already + * busy, so its verdict is unchanged.) + * + * The WORKING states were measured separately, because a rule that reads shape could + * otherwise turn "deliver while the agent is busy" into "hold until it goes idle" + * without anyone noticing (`pir-1201-kimi-working-states.mjs`, CMAP 2026-08-09 + * claude Q2): mid-generation at 5s and 13s, the shift+tab mode chrome, and a draft + * typed while the agent is still working ALL hold at one interior row. So the rule + * changes nothing for a working builder. (`!` bash mode replaces the `>` glyph, so it + * classifies `no-composer-marker` and holds — pre-existing, fail-safe, and correct: + * there is unsent input on that row.) */ const KIMI_REGION_START = [/^\s*╭[─━╌┄]{3,}/]; @@ -151,6 +177,10 @@ export const KIMI_PROFILE: GateProfile = { markerPattern: KIMI_MARKER, regionStartPatterns: KIMI_REGION_START, regionEndPatterns: KIMI_REGION_END, + // Measured, not assumed: kimi's box grows a row only when the draft gains a line. + // See KIMI_REGION_START above for the state-by-state table and why the post-reply + // steady state is the load-bearing row. kimi is the only profile that sets this. + growsWithDraft: true, }; /** Registry keyed by the harness name `detectHarnessFromCommand` returns. */ diff --git a/packages/codev/src/agent-farm/servers/mailbox-delivery.ts b/packages/codev/src/agent-farm/servers/mailbox-delivery.ts index 18f894184..87f9fe1eb 100644 --- a/packages/codev/src/agent-farm/servers/mailbox-delivery.ts +++ b/packages/codev/src/agent-farm/servers/mailbox-delivery.ts @@ -236,18 +236,35 @@ export interface DeliveryOutcome { * unrenderable #1047 ring). A sustained streak of these means the mail will NEVER deliver * on its own, so it * is the class {@link MailboxDrainer.recordStreak} escalates to liveness telemetry; a - * `busy`/`user-text` streak is deliberately excluded (a human legitimately at the line). + * `busy`/`user-text` streak is deliberately excluded (a human legitimately at the line), + * and `multi-row-draft` is excluded for the SAME reason — it is a human sitting on a + * multi-line draft, just one whose cells the classifier cannot count (CMAP 2026-08-09, + * claude F2). Its other reading — kimi growing its box while idle, i.e. the measured + * premise behind the rule having failed — would be a genuine stuck state, but it carries + * no recent output, and `surfaceLiveness` only alarms when output is recent, so + * including it would add false alarms without catching that case anyway. * Shared by `recordStreak` and the cooldown branch of {@link MailboxDrainer.tick} so a * skipped tick and a real pass agree on what counts as classifier-stuck (CMAP round 3). */ +const CLASSIFIER_STUCK_DETAILS: Record = { + // Held by a drifted profile or an unrenderable frame — will never clear on its own. + 'no-composer-marker': true, + 'no-region-end': true, + 'no-region-start': true, + // Held by a human at the composer — clears when they send or clear the draft. + 'multi-row-draft': false, + 'user-text': false, + empty: false, +}; + function isClassifierStuck( reason: MailboxReason | null, detail: GateVerdict['detail'] | undefined ): boolean { - return reason === 'no-profile' - || detail === 'no-region-end' - || detail === 'no-region-start' - || detail === 'no-composer-marker'; + // Exhaustive by TYPE, not by an `||` chain: widening GateVerdict['detail'] without + // classifying the new value is a compile error rather than a silent `false`, which is + // exactly how `multi-row-draft` slipped in unclassified when the union last grew. + return reason === 'no-profile' || (detail !== undefined && CLASSIFIER_STUCK_DETAILS[detail]); } /** diff --git a/packages/codev/src/agent-farm/servers/render-gate.ts b/packages/codev/src/agent-farm/servers/render-gate.ts index 7874ad749..25edacf3a 100644 --- a/packages/codev/src/agent-farm/servers/render-gate.ts +++ b/packages/codev/src/agent-farm/servers/render-gate.ts @@ -123,8 +123,35 @@ export interface GateProfile { * Left unset — claude, codex, agy — the region starts at the marker row exactly * as before, and since no row below a LAST match can match, those profiles * cannot reach any of the new behavior. + * + * Bounds the SCAN only. It does not arm the multi-row-draft rule — see + * {@link growsWithDraft}, which is deliberately a separate opt-in. */ regionStartPatterns?: RegExp[]; + /** + * Declares a MEASURED property of this app's composer: its box grows a row only + * when the draft gains a line, so a region taller than one interior row proves + * unsent input. Arms the multi-row-draft rule in {@link classifyBuffer}, which is + * the only thing that catches a draft with zero countable cells (kimi: a newline + * then `>`, whose every cell is whitespace, box chrome, or an exempted marker). + * + * Set this ONLY from a live measurement covering the app's idle screen, its menus + * and pickers, and — the one that matters most — its steady state after a reply. + * The rule holds mail on shape alone, so an app that grows its box for any reason + * other than a draft line would hold every message forever: a liveness failure, + * not the fail-safe direction. For kimi 0.34.0 that measurement is + * `codev/spikes/pir-1201-kimi-box-growth.mjs`. + * + * Kept separate from {@link regionStartPatterns} because the two are unrelated + * properties that merely coincide for kimi, and the hazard is concrete rather than + * theoretical: the shipped `codex-idle.clean.txt` capture — a real, genuinely EMPTY + * codex composer — already spans two interior rows. Were arming folded into + * `regionStartPatterns`, the day anyone declared one for codex (a header bound, a + * boxed redesign) codex mail would stop delivering, silently. Requires + * `regionStartPatterns` to be set as well, since without a box top the row count + * measures distance to the status line rather than the composer's height. + */ + growsWithDraft?: true; /** * Optional per-app placeholder signal: a 16-color palette index whose cells are * treated as placeholder/hint chrome (ignored), NOT user text. This is the @@ -149,9 +176,17 @@ export interface GateVerdict { * line beneath it to bound the composer (a partial/mid-repaint frame) — held * rather than scanning into status chrome; `no-region-start` = the mirror of that * for a profile whose composer is a box (kimi), when the box TOP is not on screen; + * `multi-row-draft` = a boxed composer grown past one interior row, i.e. a + * multi-line draft, held on SHAPE because its cells can all be exempt chrome; * `user-text` = a draft or menu occupies the composer; `empty` = clean. */ - detail: 'no-composer-marker' | 'no-region-end' | 'no-region-start' | 'user-text' | 'empty'; + detail: + | 'no-composer-marker' + | 'no-region-end' + | 'no-region-start' + | 'multi-row-draft' + | 'user-text' + | 'empty'; } /** @@ -238,13 +273,27 @@ export function markerSpanEnd(line: string, pattern: RegExp): number { * the false-CLEAN this bound exists to prevent, so the caller must hold instead. */ function findRegionStart(lines: string[], markerRow: number, startPatterns?: RegExp[]): number { - if (!startPatterns || startPatterns.length === 0) return markerRow; + if (!hasRegionStart(startPatterns)) return markerRow; for (let i = markerRow - 1; i >= 0; i--) { if (startPatterns.some((p) => p.test(lines[i]))) return i + 1; } return -1; } +/** + * Does this profile declare a proven UPPER bound for its composer region? + * + * Shared by {@link findRegionStart} and the multi-row-draft rule in + * {@link classifyBuffer} on purpose: both must agree on what "bounded" means. If + * they disagreed, a profile with an empty pattern array would fall back to + * `startRow = markerRow` while still being treated as bounded — and the row-count + * rule would then fire on claude/codex, whose composer legitimately sits more than + * one row above its rule line. + */ +function hasRegionStart(patterns?: RegExp[]): patterns is RegExp[] { + return patterns !== undefined && patterns.length > 0; +} + /** * First region-ending row after the marker (the rule/status line beneath the * composer), or -1 when none is found. -1 means the composer has no proven lower @@ -412,9 +461,47 @@ export function classifyBuffer( } } - return userCells === 0 - ? { clean: true, detail: 'empty' } - : { clean: false, reason: 'busy', detail: 'user-text' }; + if (userCells > 0) return { clean: false, reason: 'busy', detail: 'user-text' }; + + // Zero countable cells is NOT yet proof of an empty composer. One draft shape has no + // countable cells at all: type a newline and then `>` and kimi renders + // + // │ > <- row 1, empty + // │ > <- row 2, matches the marker, so its `>` is span-exempted as chrome + // + // every cell being whitespace, box chrome, or an exempted marker. Bounding the region + // correctly does not help — the draft is real but literally uncountable — so the last + // evidence available is the composer's SHAPE. For a boxed composer the box grows a row + // only when the draft gains a line, so a region spanning more than one interior row is + // positive evidence of unsent input. Generalizes to any draft whose rows are all + // whitespace or whitespace+`>`. + // + // Sound only because box growth is EXCLUSIVE to multi-line drafts, which was measured + // on real kimi 0.34.0 rather than assumed (`codev/spikes/pir-1201-kimi-box-growth.mjs`): + // idle, a single-line draft, the `/` menu, the `@` picker and the post-reply steady + // state all hold at one interior row; only the newline drafts grow to two. The steady + // state is the load-bearing measurement — growth on a composer that has already carried + // a turn would hold every later message forever, a liveness bug rather than a fail-safe + // one. + // + // Placed AFTER the scan, not before it, so the cell count keeps its ground-truth role: + // a text-bearing multi-row draft still reports `user-text`, and this detail is reserved + // for the case the count is blind to. + // + // Two conditions, both required and each carrying its own half of the meaning: + // `growsWithDraft` is the app's MEASURED promise that box height tracks draft lines, + // and `hasRegionStart` is what makes `endRow - startRow` mean "interior rows" at all. + // Neither alone is sufficient, and the second is not academic — the shipped + // `codex-idle.clean.txt` capture is a genuinely EMPTY composer spanning two interior + // rows, so an unbounded profile reaching this line would hold real mail forever. + if ( + profile.growsWithDraft && + hasRegionStart(profile.regionStartPatterns) && + endRow - startRow > 1 + ) { + return { clean: false, reason: 'busy', detail: 'multi-row-draft' }; + } + return { clean: true, detail: 'empty' }; } /** From 82b888b96e6234524eeef581a4b81b04bbd62728 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sun, 9 Aug 2026 00:46:05 -0400 Subject: [PATCH 40/43] [Spec 1201] docs: make the launch loop's task messaging tell the truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architect review findings 2 and 3, both message/comment only — no behavior change. The fast-fail branch echoed "Starting a fresh conversation with the original task", but it does not reset codev_task_queued, so codev_queue_task early-returns and nothing is re-queued. That behavior is correct — an undelivered row persists on the mailbox and re-queueing would duplicate it — the message was simply wrong, and in the delivered-then-crash-looping case it would tell an operator the fresh session has its mission when it does not. The first rewording then overcorrected: it asserted unconditionally that a task was still queued, which is false when `afx send` never succeeded (afx off PATH, Tower down), since the flag is only set on success and the fresh launch really does retry in that case (CMAP: codex #2, claude F4). The hint now branches on the flag and is accurate in both cases. Finding 3 records the accepted tradeoff in the other direction: the clean-exit branch does reset the flag, so a row that was never delivered gets queued twice and the mission arrives stated twice. Documented rather than fixed — de-duplicating needs either a delivery receipt the script cannot see or a mailbox-side identity check, and a duplicated instruction to an agent that has not started yet is recoverable by reading, unlike the crash-loop direction. Softened "delivered whenever the operator saw a composer": seeing one is necessary, not sufficient — the gate also has to have polled it empty, so a quit-before-delivery race leaves the row held too (CMAP: codex #3, claude F5). --- .../codev/src/agent-farm/utils/harness.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/codev/src/agent-farm/utils/harness.ts b/packages/codev/src/agent-farm/utils/harness.ts index 4184bee65..1a6d1612b 100644 --- a/packages/codev/src/agent-farm/utils/harness.ts +++ b/packages/codev/src/agent-farm/utils/harness.ts @@ -548,6 +548,19 @@ codev_task_file='${shellEscapeSingleQuote(ctx.taskFile)}' # PERSISTS a held row — it does not need re-queueing to survive). Reset only on # the human-gated clean-exit relaunch below, which is a deliberate new # conversation and does want its task again. +# +# ACCEPTED TRADEOFF in the other direction (architect review, 2026-08-09). The +# clean-exit reset assumes the first row was DELIVERED — the common case, but not +# a guarantee. Seeing a composer is necessary, not sufficient: the gate also has to +# have polled it EMPTY at least once. So the row can still be held if they quit at +# a screen that never rendered a composer (the 0.33.0 folder-trust dialog is the +# realistic case), or if they typed into the composer and quit within a couple of +# backstop ticks. The reset then queues a second identical row and both eventually +# deliver — one mission, stated twice. Left as-is deliberately: +# de-duplicating means either a delivery receipt the script cannot see or a +# mailbox-side identity check, and the failure is a duplicated instruction to an +# agent that has not started yet — recoverable by reading, unlike the crash-loop +# direction above, which floods a mailbox no one is draining. codev_task_queued=0 codev_queue_task() { [ "$codev_task_queued" = 1 ] && return 0 @@ -620,7 +633,20 @@ while true; do fi echo "" if [ "$codev_fast_fails" -ge 3 ]; then - echo "Agent failing immediately (code $status). Starting a fresh conversation with the original task in 2 seconds... (Ctrl+C to quit)" + # Deliberately does NOT say "with the original task": this branch does not reset + # codev_task_queued, so if the task DID reach the mailbox, codev_queue_task + # early-returns and nothing is re-queued. That is the correct behavior (an + # undelivered row PERSISTS on the mailbox; re-queueing would duplicate it) — but + # the operator has to be told which case they are in, and there are two, because + # the flag is only set on a SUCCESSFUL afx send. If queueing never succeeded (afx + # off PATH, Tower down) the flag is still 0 and the fresh launch below really does + # retry it, so an unconditional "still queued" would be a lie. + echo "Agent failing immediately (code $status). Starting a fresh conversation in 2 seconds... (Ctrl+C to quit)" + if [ "$codev_task_queued" = 1 ]; then + echo " The task is on the mailbox and is not re-queued; if it already reached the dead session, re-send it with 'afx send'." + else + echo " The task was never queued (see the warning above) — the fresh conversation will retry it." + fi codev_launch=codev_launch_fresh codev_fast_fails=0 elif codev_has_session; then From 1d09234acb6cf3c9bb3389e06bfdb6d4b9661970 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sun, 9 Aug 2026 00:46:13 -0400 Subject: [PATCH 41/43] [Spec 1201] docs: record the architect-review round and its CMAP dispositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builder thread plus the full disposition record for this round: gemini APPROVE, codex REQUEST_CHANGES, claude APPROVE-with-changes, every finding from both non-approving reviews accepted and none rejected. Notes the two deviations worth knowing about — the geometry rule moved after the cell scan rather than short-circuiting before it, and arming decoupled from regionStartPatterns onto its own field after codex-idle.clean.txt turned out to already satisfy the geometric predicate while being genuinely empty. --- ...1201-cmap-architect-review-dispositions.md | 133 ++++++++++++++++++ codev/state/pir-1201_thread.md | 114 +++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-architect-review-dispositions.md diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-architect-review-dispositions.md b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-architect-review-dispositions.md new file mode 100644 index 000000000..9113d7bd0 --- /dev/null +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-architect-review-dispositions.md @@ -0,0 +1,133 @@ +# CMAP dispositions — architect integration review follow-up (2026-08-09) + +Round: the architect's three non-blocking findings on PR #1203 at head `4a7e2afe`, plus the +3-way review of the resulting delta. Prior round's dispositions are in +`1201-cmap-postpivot-dispositions.md`; this file covers only this delta. + +Verdicts on the delta: **gemini APPROVE · codex REQUEST_CHANGES · claude APPROVE-with-changes**. +Every finding from both non-approving reviews was accepted. Nothing was rejected. + +Scope note: a mid-round architect message fenced the PR's three open maintainer decisions (trust +pre-write, 0.33.0 version floor, write-guard parity as follow-up). None were touched. + +--- + +## Architect finding 1 — residual false-CLEAN for an all-exempt draft + +**Measure-first, per instruction. The premise holds**, so the rule was implemented rather than +documented as a residual. + +Measured on real kimi 0.34.0 (`codev/spikes/pir-1201-kimi-box-growth.mjs`), interior rows = +`endRow - startRow`, the rows the classifier actually scans: + +| state | interior rows | +|---|---| +| idle | 1 | +| single-line draft | 1 | +| `/` command menu | 1 | +| `@` file picker | 1 | +| post-reply steady state | 1 | +| newline + bare `>` | **2** | +| newline only | **2** | +| long soft-wrapped single line | **2** (carries text → already busy; verdict unchanged) | + +The steady-state row is the load-bearing one: growth on a composer that has already carried a +turn would hold every later message forever — a liveness failure, which is worse than the +fail-safe direction the gate normally errs toward. + +The review then surfaced a class the spike had not enumerated (claude Q2), so it was measured +too (`pir-1201-kimi-working-states.mjs`): mid-generation at 5s and 13s, shift+tab mode chrome, +and a draft typed while the agent is working are **all one interior row**. So the rule does not +convert "deliver while busy" into "hold until idle". (`!` bash mode classifies +`no-composer-marker` and holds — pre-existing, fail-safe, and correct: there is unsent input on +that row.) + +### Deviation from the suggested implementation + +The architect's sketch short-circuited on geometry **before** the cell scan. Implemented that +way it changed an existing fixture's verdict detail — `kimi-multiline-bare` went from +`user-text` to `multi-row-draft`, because that draft is also multi-row — which would have +retired what the older guardrail test was actually testing and demoted the cell scan from +ground truth to dead weight on every multi-row screen. Moved **after** the scan: `userCells > 0` +still wins and still reports `user-text`; `multi-row-draft` is reserved for the case the count +is blind to. Every pre-existing fixture verdict is unchanged. claude independently confirmed +this ordering is not just preferable but *enforced* by the existing assertion at +`render-gate.test.ts:194`. + +--- + +## codex #1 / claude Q5+F1 — arming coupled to `regionStartPatterns` — **ACCEPTED** + +Both reviewers independently flagged that arming the rule off `regionStartPatterns` overloads a +field that means "the composer has an upper boundary" with an unrelated claim ("box height +tracks draft lines"). + +claude supplied evidence that makes this concrete rather than stylistic, **which I verified +myself** with a geometry probe over every shipped fixture: `codex-idle.clean.txt` — a real, +captured, genuinely **empty** codex composer — already spans **two interior rows** +(`marker=18 start=18 end=20`). The rule's geometric predicate is *already true* on a screen that +must stay clean; only the arming gate stands between that capture and codex mail being held +forever. The day anyone declared a region start for codex (a header bound, a boxed redesign), +delivery would die silently. + +Decoupled into an explicit profile field, `growsWithDraft?: true`, set only on `KIMI_PROFILE`. +The rule now requires **both**: `growsWithDraft` (the measured promise) and `hasRegionStart` +(what makes the arithmetic mean "interior rows" at all). codex proposed +`maxCleanInteriorRows?: number` instead; chose the boolean because it encodes the *measured +premise* rather than a tunable number, and a wrong threshold under it is caught by the app's own +idle fixture, which must classify clean. Pinned by three tests, all now built on codex's real +capture rather than a constructed screen: inert when neither field is set, inert with either one +alone, and armed only with both. + +## codex #2 / claude F4 — fast-fail hint not universally accurate — **ACCEPTED** + +My reworded echo asserted unconditionally that "an undelivered task is still queued on the +mailbox". False in a reachable third case: `codev_task_queued` is set only on a **successful** +`afx send`, so if afx is off PATH or Tower is down the flag is still 0, nothing is queued, and +the fresh relaunch really does retry it. The hint now branches on `[ "$codev_task_queued" = 1 ]` +and states the truth in both cases. Behavior still unchanged; this was a message-accuracy fix on +top of a message-accuracy fix. + +## codex #3 / claude F5 — tradeoff comment overstates delivery — **ACCEPTED** + +"true whenever the operator saw a composer to /quit from" is too strong: seeing a composer is +necessary, not sufficient — the gate also has to have polled it empty at least once. Softened, +and the quit-before-delivery race is now named alongside the trust-dialog case. + +## claude F2 — `isClassifierStuck` silently omitted the new detail — **ACCEPTED** + +`mailbox-delivery.ts` enumerated stuck details as a closed `||` chain, so widening +`GateVerdict['detail']` did not force a decision. claude checked the resulting behavior and it +was *right* (excluding `multi-row-draft` is correct — it is a human on a draft, and the +premise-failure reading carries no recent output, which `surfaceLiveness` requires to alarm), +but it read as an oversight. Replaced with a `Record` map, so +the next new detail is a **compile error** rather than a silent `false`, and documented why +`multi-row-draft` sits on the excluded side. + +## claude F3 + codex test note — the differential used constructed screens — **ACCEPTED** + +codex noted the armed/unarmed halves were "not literally the same bytes despite the comment"; +claude asked for the real codex-idle geometry to be cited. Both are answered by the same change: +the test now classifies the **actual `codex-idle.clean.txt` capture** under four profile +variants — shipped, armed, bounded-only, grows-only — so the differential runs on identical real +bytes and the hazard is demonstrated rather than described. + +## claude Q1 nit — non-null assertion — **ACCEPTED** + +`hasRegionStart` is now a type predicate (`patterns is RegExp[]`), so the `startPatterns!` +assertion is gone and the narrowing is checked rather than conventional. (Applied before +claude's review landed; it had read the pre-edit file.) + +--- + +## Verification + +- `pnpm build` clean; `tsc --noEmit` clean. +- Full suite **4906 passed / 48 skipped / 0 failed** (+6 on the pre-round 4900: five new tests + and one new fixture). +- Targeted suites — render-gate, harness, harness-integration, spawn-worktree, mailbox-pacing, + kimi-session-discovery — green. +- Two new live measurements against real kimi 0.34.0, both committed as reproducible spikes. +- No live demo re-run: the rule can only alter verdicts for a kimi composer past one interior + row, and delivery targets the idle composer, measured at one row in every state including + mid-generation. diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index c15e0ae4c..dbc5acaf6 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -124,3 +124,117 @@ restores the weaker one. **Verification:** `pnpm build` clean; full suite **4900 passed / 48 skipped / 0 failed**; live demo **7/7** against real kimi 0.34.0, including the crash-resume claim that was withheld until it passed. + +--- + +## 2026-08-09 — architect integration review, three findings + +The architect reviewed the PR at head `4a7e2afe` and returned three non-blocking findings. None +of them touch the three decisions parked for the upstream maintainer (trust pre-write, 0.33.0 +floor, write-guard parity as follow-up) — a later architect message fenced those explicitly, and +this round left all three exactly as the branch already implements them. + +**Finding 1 was measure-first, and the measurement is the interesting part.** The claim: a +residual false-CLEAN survives the `regionStartPatterns` fix. Enter a newline and then `>` and +kimi renders `│ > ` / `│ >` — row one empty, row two matching `KIMI_MARKER` so its `>` is +span-exempted as chrome. Every cell is whitespace, box chrome, or an exempted marker, so +`userCells` is 0 and the composer reads CLEAN *while holding unsent user input*. Bounding the +region correctly does not help: the draft is real but literally uncountable. That is the +corruption direction, so it mattered. + +The proposed fix reads the composer's **shape** instead — a boxed region spanning more than one +interior row is a multi-line draft by construction. Sound only if box growth is exclusive to +multi-line drafts, which is a claim about kimi, not about our code. So I measured it before +writing it (`codev/spikes/pir-1201-kimi-box-growth.mjs`, real kimi 0.34.0): + +| state | interior rows | +|---|---| +| idle | 1 | +| single-line draft | 1 | +| `/` menu | 1 | +| `@` picker | 1 | +| **post-reply steady state** | **1** | +| newline + bare `>` | 2 | +| newline only | 2 | +| long soft-wrapped single line | 2 | + +**Premise holds.** The steady-state row is the load-bearing one: growth on a composer that has +already carried a turn would hold every later message forever — a liveness bug, which is worse +than the fail-safe direction. The soft-wrap case grows the box too, but it carries text and was +already busy, so its verdict is unchanged. + +**One design correction I made against the suggestion.** Implemented as suggested — short-circuit +before the cell scan — the rule changed an *existing* fixture's verdict detail +(`kimi-multiline-bare` went from `user-text` to `multi-row-draft`), because that draft is also +multi-row. That would have masked the cell scan's ground-truth role and quietly retired what the +older guardrail test was actually testing. Moved the rule to **after** the scan: `userCells > 0` +still wins and still reports `user-text`, and `multi-row-draft` is reserved for exactly the case +the count is blind to. Every pre-existing fixture verdict is unchanged. + +The arming gate is a shared `hasRegionStart` predicate used by **both** `findRegionStart` and the +rule, so an empty-pattern array cannot be read as "bounded" by one and "unbounded" by the other — +that divergence would fire the rule on claude/codex, whose composer legitimately sits several rows +above its rule line. Pinned with an armed/unarmed differential on identical bytes, so deleting the +rule outright fails the inertness test rather than silently passing it. + +**Findings 2 and 3 were wording/comment only.** The fast-fail echo claimed to restart "with the +original task", but that branch leaves `codev_task_queued` set, so nothing is re-queued — correct +behavior (an undelivered row persists on the mailbox), wrong message. Reworded, and the operator +is now told which of the two cases they are in. Finding 3 records the accepted tradeoff in the +other direction: the clean-exit branch *does* reset the flag, so if the first row was never +delivered (quit at the trust dialog before a composer ever rendered) the mailbox ends up holding +the same mission twice. Documented rather than fixed, deliberately. + +**Verification:** `pnpm build` clean; full suite **4904 passed / 48 skipped / 0 failed** (+4 = +three new tests and one new fixture); targeted suites (render-gate, harness, harness-integration, +spawn-worktree, mailbox-pacing, kimi-session-discovery) 327 passed. + +### The CMAP on that delta found something better than what I built + +gemini APPROVE, codex REQUEST_CHANGES, claude APPROVE-with-changes. Every finding from both +non-approving reviews was accepted; nothing was rejected. Full dispositions: +`codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-architect-review-dispositions.md`. + +**The one that mattered.** Both codex and claude independently attacked the same thing: I armed +the geometry rule off `regionStartPatterns`, overloading a field that means "this composer has an +upper boundary" with an unrelated claim, "this composer's height tracks draft lines". They +coincide for kimi. claude then produced evidence that this is not stylistic, and I verified it +myself with a geometry probe over every shipped fixture rather than taking it on trust: + +**`codex-idle.clean.txt` — a real, captured, genuinely EMPTY codex composer — already spans two +interior rows** (`marker=18 start=18 end=20`). The rule's geometric predicate is *already true* +on a screen that must stay clean. Only the arming gate stood between that capture and codex mail +being held forever, and the day anyone declared a region start for codex — a header bound, a +boxed redesign — delivery would have died silently. That is the failure mode I was trying to +prevent for kimi, sitting one field declaration away for a different app. + +Decoupled into an explicit `growsWithDraft?: true`, set only on `KIMI_PROFILE`. The rule now +requires both: the measured promise *and* the bound that makes the arithmetic mean "interior +rows". codex wanted `maxCleanInteriorRows?: number` instead; I chose the boolean because it +encodes the measured premise rather than a tunable number, and a wrong threshold under it gets +caught by the app's own idle fixture. The three inertness tests now run on codex's **real** +capture under four profile variants, so the hazard is demonstrated on identical bytes rather than +described on a screen I invented. + +**Claude also found a gap in my measurement, so I measured it.** The spike had not enumerated the +composer *while the agent is generating* — if the box grew there, "deliver while busy" would have +silently become "hold until idle". It does not: mid-generation at 5s and 13s, shift+tab mode +chrome, and a draft typed during generation are all one interior row +(`pir-1201-kimi-working-states.mjs`). Claude asked for a line documenting what wasn't measured; a +measurement is a better answer than a caveat. + +**Two accuracy bugs in my own prose, both real.** The reworded fast-fail hint asserted +unconditionally that a task was still queued — false when `afx send` never succeeded, since the +flag is only set on success, and in that case the fresh launch genuinely does retry. Now branches +on the flag. And "delivered whenever the operator saw a composer" was too strong: seeing a +composer is necessary, not sufficient, since the gate also has to have polled it empty. A +message-accuracy fix on top of a message-accuracy fix, which is a fair thing to have been caught on. + +**One silent-omission class fixed:** `isClassifierStuck` enumerated details as a closed `||` +chain, so widening the union never forced a decision. Now a `Record` — the next new detail is a compile error rather than a silent `false`. + +**Verification:** build + `tsc --noEmit` clean; full suite **4906 passed / 48 skipped / 0 failed** +(+6 on the 4900 this round started from). No live demo re-run needed: the rule can only change +verdicts for a composer past one interior row, and delivery targets the idle composer — measured +at one row in every state, including mid-generation. From 65205524ab2b747e6c3dfa921ead559bb5c93098 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sun, 9 Aug 2026 01:25:38 -0400 Subject: [PATCH 42/43] [Spec 1201] fix: make a clean exit stick across a pre-mint crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architect finding 4. #1267's contract is "clean exit -> fresh rerun, no recovery", and main's claude loop enforces it BY IDENTITY: a clean exit mints a new session id and the superseded one is never named again. kimi cannot mint on demand and `-c` is cwd-scoped, so identity was never pinned — the guard only asked whether ANY session existed for this cwd. 0.33+ mints no session until the first message lands, so a crash between a clean-exit relaunch and the first delivery found the just-ended conversation still the newest, continued it, and delivered the re-queued task into the conversation the human walked away from — #1267's own motivating defect class. The probe now answers WHICH session rather than WHETHER one exists: it prints the newest resumable id for the cwd. The clean-exit branch records that id, and the crash branch takes `-c` only once the newest id differs. Boolean uses derive from "printed something", so there is still one probe and one mirror. Measured before building, since the design assumes `-c` targets the newest session and the existing probe only covered the zero-session case: two live sessions in one cwd on 0.34.0, two oracles — content (codewords ALPHA/BRAVO -> BRAVO) and store identity (only the newest session's dir was touched, nothing new minted, exit 0, no prompt). pir-1201-kimi-continue-newest-probe.mjs. CMAP found a defect this change INTRODUCED (claude F1, codex #2, blocking): moving the decision from $? onto stdout meant anything else writing to stdout counted as a session. Measured with NODE_OPTIONS=--require preloading a module that prints — the probe exits 1, the script read RESUME, and `kimi -c` with nothing to continue starts a session that never saw --agent-file. Silently roleless, the #929 class, produced by the guard's own upgrade. Now consumes both signals. The sketch's "empty on any error is fail-closed" was also wrong (claude F2, codex #1): a TRANSIENT probe failure records '' and the next crash sees the ended session as different-from-empty. Failure and empty store are now told apart by status, and an unknown baseline blocks resume until the next clean exit re-establishes one. Two probe/discovery divergences fixed rather than documented: `j.cwd ?? j.workDir` short-circuited where readStateJson falls through per-field, and the probe stripped a trailing slash before realpathSync while sameDir does not (the unsafe direction — realpathSync already normalizes one for any directory that exists, so the strip bought nothing). Tests: the composition is now driven for real — the actual `while` loop with stubbed launches, asserting resume,fresh,fresh — because injecting the superseded id from the test left the generated assignment pinned only by a string match. Non-vacuity is demonstrated by running the pre-fix predicate against the same store. Full suite 4915 passed / 48 skipped / 0 failed. --- .../pir-1201-kimi-continue-newest-probe.mjs | 129 +++++++ .../src/agent-farm/__tests__/harness.test.ts | 316 +++++++++++++++++- .../__tests__/spawn-worktree.test.ts | 4 +- .../codev/src/agent-farm/utils/harness.ts | 169 ++++++++-- 4 files changed, 580 insertions(+), 38 deletions(-) create mode 100644 codev/spikes/pir-1201-kimi-continue-newest-probe.mjs diff --git a/codev/spikes/pir-1201-kimi-continue-newest-probe.mjs b/codev/spikes/pir-1201-kimi-continue-newest-probe.mjs new file mode 100644 index 000000000..d5f9aec4f --- /dev/null +++ b/codev/spikes/pir-1201-kimi-continue-newest-probe.mjs @@ -0,0 +1,129 @@ +/** + * PIR #1201 — does `kimi -c` continue the NEWEST session when a cwd has several? + * + * The whole crash-resume design rests on this. `kimi -c` is cwd-scoped, not + * id-pinned, so "resume the conversation" is only well-defined if `-c` picks the + * most recently updated session deterministically and without prompting. Finding 4 + * (architect review, 2026-08-09) additionally proposes comparing session IDENTITY + * across a clean exit to keep a superseded conversation from being resurrected — + * that comparison is meaningless unless `-c` targets the newest. + * + * The sibling probe (`pir-1201-kimi-continue-probe.mjs`) answered the *zero*-session + * case (what `-c` does with nothing to continue). This answers the *many* case. + * + * Two independent oracles, because the model's answer alone is not proof: + * CONTENT — each session is seeded with a distinct codeword; ask `-c` which one + * it was told, and see which session's memory answered. + * IDENTITY — snapshot every session's updatedAt before and after, and see which + * session directory the `-c` turn actually landed in. This is the + * authoritative one: it reads the store rather than trusting the model. + * + * Usage: node codev/spikes/pir-1201-kimi-continue-newest-probe.mjs + */ + +import { mkdtempSync, writeFileSync, mkdirSync, readdirSync, existsSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir, homedir } from 'node:os'; +import { join, basename, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); +const KIMI_HOME = process.env.KIMI_CODE_HOME || join(homedir(), '.kimi-code'); + +function preTrust(root) { + const dir = join(KIMI_HOME, 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${basename(root).toLowerCase()}_${hash}`), + JSON.stringify({ root, trustedAt: Date.now() })); +} + +/** Every session recorded for `cwd`, with the fields discovery ranks on. */ +function sessionsFor(cwd) { + const root = join(KIMI_HOME, 'sessions'); + const found = []; + if (!existsSync(root)) return found; + for (const wd of readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory())) { + let entries = []; + try { + entries = readdirSync(join(root, wd.name), { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith('session_')); + } catch { continue; } + for (const s of entries) { + try { + const st = JSON.parse(readFileSync(join(root, wd.name, s.name, 'state.json'), 'utf-8')); + if ((st.cwd ?? st.workDir) === cwd) { + found.push({ id: s.name, updatedAt: st.updatedAt ?? null, archived: st.archived === true }); + } + } catch { /* unreadable */ } + } + } + return found.sort((a, b) => (b.updatedAt ?? -1) - (a.updatedAt ?? -1)); +} + +function run(args, cwd, ms = 60000) { + return new Promise((resolve) => { + const term = pty.spawn('kimi', args, { + name: 'xterm-256color', cols: 110, rows: 32, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let out = ''; + let done = false; + const finish = (code) => { if (!done) { done = true; resolve({ out, code }); } }; + term.onData((d) => { out += d; }); + term.onExit(({ exitCode }) => finish(exitCode)); + setTimeout(() => { try { term.kill(); } catch { /* gone */ } finish(-1); }, ms); + }); +} + +const cwd = mkdtempSync(join(tmpdir(), 'kimi-newest-')); +preTrust(cwd); +console.log(`cwd: ${cwd}\n`); + +console.error('[probe] seeding session A (codeword ALPHA)…'); +await run(['-p', 'Remember this codeword: ALPHA. Reply with only: OK'], cwd); +const afterA = sessionsFor(cwd); +console.log(`after A: ${JSON.stringify(afterA)}`); + +// A visible gap so updatedAt ordering is unambiguous rather than a same-millisecond tie. +await new Promise((r) => setTimeout(r, 3000)); + +console.error('[probe] seeding session B (codeword BRAVO)…'); +await run(['-p', 'Remember this codeword: BRAVO. Reply with only: OK'], cwd); +const afterB = sessionsFor(cwd); +console.log(`after B: ${JSON.stringify(afterB)}`); + +if (afterB.length < 2) { + console.log('\nINCONCLUSIVE: the cwd does not hold two sessions; cannot test the many case.'); + process.exit(2); +} +const newest = afterB[0].id; +const older = afterB[afterB.length - 1].id; +console.log(`\nnewest by updatedAt = ${newest}\noldest = ${older}`); + +console.error('[probe] running `kimi -c` and asking which codeword it holds…'); +const before = new Map(afterB.map((s) => [s.id, s.updatedAt])); +const cont = await run(['-c', '-p', 'Which codeword were you told to remember? Reply with only that word.'], cwd); +const afterC = sessionsFor(cwd); + +const answer = cont.out.includes('BRAVO') ? 'BRAVO' : cont.out.includes('ALPHA') ? 'ALPHA' : '(neither)'; +const touched = afterC.filter((s) => (s.updatedAt ?? -1) > (before.get(s.id) ?? -1)).map((s) => s.id); +const created = afterC.filter((s) => !before.has(s.id)).map((s) => s.id); + +console.log(`\nexit code : ${cont.code}`); +console.log(`CONTENT oracle : ${answer} (BRAVO = newest, ALPHA = oldest)`); +console.log(`IDENTITY oracle : touched=${JSON.stringify(touched)} created=${JSON.stringify(created)}`); +console.log(`after -c : ${JSON.stringify(afterC)}`); + +const continuedNewest = touched.includes(newest) && created.length === 0; +console.log( + '\n' + (continuedNewest && answer === 'BRAVO' + ? 'PREMISE HOLDS: `kimi -c` continued the NEWEST session, no prompt, no new session minted.' + : created.length > 0 + ? `PREMISE BROKEN: \`kimi -c\` MINTED a new session (${created.join(',')}) instead of continuing one.` + : `PREMISE BROKEN or AMBIGUOUS: content=${answer}, touched=${JSON.stringify(touched)}, expected newest=${newest}.`) +); +process.exit(0); diff --git a/packages/codev/src/agent-farm/__tests__/harness.test.ts b/packages/codev/src/agent-farm/__tests__/harness.test.ts index f2007f86b..f3eb41d4e 100644 --- a/packages/codev/src/agent-farm/__tests__/harness.test.ts +++ b/packages/codev/src/agent-farm/__tests__/harness.test.ts @@ -623,11 +623,11 @@ describe('harness', () => { // the gate must fail CLOSED to the role-carrying launch. it('never reaches -c without proving a session exists (the roleless-fallback guard)', () => { const script = KIMI_HARNESS.buildBuilderLaunchScript!(taskCtx); - expect(script).toContain('codev_has_session'); + expect(script).toContain('codev_should_resume'); // Entry selects resume only under the probe... - expect(script).toMatch(/if codev_has_session; then\n\s*codev_launch=codev_launch_resume\n\s*else\n\s*codev_launch=codev_launch_fresh/); + expect(script).toMatch(/if codev_should_resume; then\n\s*codev_launch=codev_launch_resume\n\s*else\n\s*codev_launch=codev_launch_fresh/); // ...and so does the crash path; its else-branch is fresh, not resume. - expect(script).toMatch(/elif codev_has_session; then[\s\S]*?codev_launch=codev_launch_resume\n\s*else\n[\s\S]*?codev_launch=codev_launch_fresh/); + expect(script).toMatch(/elif codev_should_resume; then[\s\S]*?codev_launch=codev_launch_resume\n\s*else\n[\s\S]*?codev_launch=codev_launch_fresh/); // `-c` appears ONLY inside codev_launch_resume, which only the probe selects. const resumeBody = script.slice( script.indexOf('codev_launch_resume() {'), @@ -645,7 +645,8 @@ describe('harness', () => { expect(script).toContain('kimi --yolo'); expect(script).toContain('while true'); // Nothing to pin and nothing to queue, so none of the state machine appears. - expect(script).not.toContain('codev_has_session'); + expect(script).not.toContain('codev_should_resume'); + expect(script).not.toContain('codev_superseded_id'); expect(script).not.toContain('afx send'); expect(script).not.toContain('-c'); }); @@ -682,9 +683,20 @@ describe('harness', () => { it('task-carrying: a clean exit relaunches fresh, not resumed', () => { const script = KIMI_HARNESS.buildBuilderLaunchScript!(taskCtx); const cleanExit = script.indexOf('if [ "$status" -eq 0 ]; then'); - const afterClean = script.slice(cleanExit, script.indexOf('fi', cleanExit)); + // Bound the branch on ITS OWN closing `fi` — the one at the loop body's two- + // space indent. Matching the next two letters "fi" stops inside prose + // ("fine", "confirm"); matching any indented `fi` stops at the nested + // baseline-capture conditional. Either way the slice silently shrinks and the + // assertions below stop inspecting the branch they name. + const close = script.slice(cleanExit).search(/\n {2}fi\n/); + const afterClean = script.slice(cleanExit, cleanExit + close); expect(afterClean).toContain('codev_launch=codev_launch_fresh'); expect(afterClean).not.toContain('codev_launch_resume'); + // Finding 4: relaunching fresh is not enough on its own — `-c` is cwd-scoped, + // so the branch must also RETIRE the ended conversation by id, or a crash in + // the pre-mint window resumes it right back. The behavioral half of this pin + // is the sticky-fresh block below, which runs the decision at the shell. + expect(afterClean).toContain('codev_superseded_id="$codev_prev_id"'); }); it('warns loudly but non-fatally when the task cannot be queued', () => { @@ -719,8 +731,12 @@ describe('harness', () => { * RUN against fixture stores, and its verdict is checked against the TypeScript * discovery it mirrors — if the two ever disagree, this fails instead of a * builder silently losing its role in the field. + * + * Since Finding 4 the probe answers WHICH session, not merely whether one exists, + * so every case here also asserts the printed id against discovery's (see + * `runProbe`) — identity is what the sticky-fresh contract turns on. */ - describe('the inlined crash-resume session probe (KIMI_HAS_SESSION_PROBE)', () => { + describe('the inlined crash-resume session probe (KIMI_NEWEST_SESSION_PROBE)', () => { let fakeHome: string; let worktree: string; @@ -743,12 +759,93 @@ describe('harness', () => { return m![1]; } - /** Run the probe exactly as the script does; true ⇔ exit 0 ⇔ "a session exists". */ + /** + * Run the probe exactly as the script does; true ⇔ it named a session. + * + * Also cross-checks, on every call, that the PRINTED id is exactly what + * `findLatestKimiSessionId` would return. Asserting inside the helper rather + * than per-test is deliberate: the identity claim then rides every case in + * this block — archived, junk, symlink, trailing slash — instead of only the + * cases someone remembered to extend. Since Finding 4 the loop resumes on + * WHICH session is newest, so a probe that agrees about existence but + * disagrees about identity would silently defeat the sticky-fresh contract. + */ function runProbe(cwd: string): boolean { const res = spawnSync(process.execPath, ['-e', extractProbe(), cwd], { env: { ...process.env, KIMI_CODE_HOME: join(fakeHome, '.kimi-code') }, + encoding: 'utf-8', }); - return res.status === 0; + const printed = (res.stdout ?? '').trim(); + const discovered = KIMI_HARNESS.buildResume!(cwd, { homeDir: fakeHome })?.sessionId ?? null; + expect(printed || null, 'probe must name the same session discovery does').toBe(discovered); + // Exit status and output must agree, since the script reads emptiness. + expect(res.status === 0).toBe(printed !== ''); + return printed !== ''; + } + + /** The id the generated script would treat as "the conversation to continue". */ + function probeId(cwd: string): string { + const res = spawnSync(process.execPath, ['-e', extractProbe(), cwd], { + env: { ...process.env, KIMI_CODE_HOME: join(fakeHome, '.kimi-code') }, + encoding: 'utf-8', + }); + return (res.stdout ?? '').trim(); + } + + /** + * Run the generated resume DECISION at the shell, with a real store underneath. + * `superseded` is what a clean exit would have recorded. Returns the branch the + * loop takes: RESUME (`kimi -c`) or FRESH (role-carrying new conversation). + */ + function decideBranch(superseded: string | null): string { + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: 'pir-1201', + }); + // The three generated pieces the decision is made of, run verbatim. + const fns = script.slice( + script.indexOf('codev_newest_session()'), + script.indexOf('codev_launch_fresh()'), + ); + const res = spawnSync('bash', ['-c', + `${fns}\n` + + (superseded === null ? '' : `codev_superseded_id='${superseded}'\n`) + + 'if codev_should_resume; then echo RESUME; else echo FRESH; fi\n', + ], { + cwd: worktree, + env: { ...process.env, KIMI_CODE_HOME: join(fakeHome, '.kimi-code') }, + encoding: 'utf-8', + }); + expect(res.status, res.stderr).toBe(0); + return res.stdout.trim(); + } + + /** + * The PRE-fix predicate — "does any session exist for this cwd?" — run against + * the same generated probe and the same store. Exists so the regression tests + * can show the defect rather than assert its absence: if this ever returns the + * same branch as `decideBranch` on the sticky-fresh fixture, the new guard has + * stopped doing anything and those tests have gone vacuous. + */ + function decideBranchLegacy(): string { + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: 'pir-1201', + }); + const fns = script.slice( + script.indexOf('codev_newest_session()'), + script.indexOf('codev_launch_fresh()'), + ); + const res = spawnSync('bash', ['-c', + `${fns}\n` + + 'if [ -n "$(codev_newest_session)" ]; then echo RESUME; else echo FRESH; fi\n', + ], { + cwd: worktree, + env: { ...process.env, KIMI_CODE_HOME: join(fakeHome, '.kimi-code') }, + encoding: 'utf-8', + }); + expect(res.status, res.stderr).toBe(0); + return res.stdout.trim(); } function writeStoreSession(sessionId: string, state: Record): void { @@ -815,7 +912,7 @@ describe('harness', () => { const calls = join(fakeHome, 'afx-calls.log'); // `afx send ` → $3 is the task body. writeFileSync(join(bin, 'afx'), `#!/bin/bash\necho "$3" >> '${calls}'\n`, { mode: 0o755 }); - const harnessFns = script.slice(script.indexOf('codev_builder_id='), script.indexOf('codev_has_session()')); + const harnessFns = script.slice(script.indexOf('codev_builder_id='), script.indexOf('codev_newest_session()')); const res = spawnSync('bash', ['-c', `${harnessFns}\n` + // three crash-restart iterations, then a clean-exit relaunch @@ -827,6 +924,176 @@ describe('harness', () => { expect(readFileSync(calls, 'utf-8').trim().split('\n')).toEqual(['THE TASK', 'THE TASK']); }); + /** + * Finding 4 (architect review, 2026-08-09) — #1267's sticky-fresh contract, + * enforced at the shell against a real store. + * + * "Clean exit → fresh rerun, no recovery" is a contract main states twice: in + * `buildLaunchLoop`'s docstring ("once a clean exit has moved the loop to fresh, + * a later crash restarts the *fresh* invocation, never the superseded session") + * and in `buildSessionLaunchLoop`, which enforces it BY IDENTITY — clean exit + * mints a new id, and the superseded one is never named again. + * + * kimi cannot mint an id on demand, and `-c` is cwd-scoped, so identity has to + * come from the store. The window that matters: 0.33.0+ mints NO session until + * the first message lands, so between a clean-exit relaunch and the first + * delivery, the newest session for the cwd is still the one the human ended. + * An existence-only guard resumes it — and the re-queued task lands in the + * conversation they walked away from. + */ + describe('a clean exit is sticky: a crash before the new conversation mints stays fresh', () => { + it('resumes nothing when the only session is the one the human just ended', () => { + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + // Clean exit records the newest id as superseded… + const superseded = probeId(worktree); + expect(superseded).toBe('session_A'); + // …and the crash lands in the pre-mint window, so the store is unchanged. + expect(decideBranch(superseded)).toBe('FRESH'); + + // The defect itself, demonstrated on the same bytes: the existence-only + // guard this replaced resumes here — `kimi -c` into the conversation the + // human ended, carrying the re-queued task with it. + expect(decideBranchLegacy()).toBe('RESUME'); + }); + + it('resumes again once the fresh conversation has minted a newer session', () => { + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + writeStoreSession('session_B', { + id: 'session_B', version: 2, cwd: worktree, updatedAt: 200, + }); + // Same superseded id, but the first message has now minted B. + expect(decideBranch('session_A')).toBe('RESUME'); + }); + + it('supersedes each conversation in turn across iterated clean exits', () => { + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + writeStoreSession('session_B', { + id: 'session_B', version: 2, cwd: worktree, updatedAt: 200, + }); + // Second quit supersedes B. The loop must NOT fall back to resuming A — + // that conversation was abandoned too, and it is not what `-c` targets. + expect(probeId(worktree)).toBe('session_B'); + expect(decideBranch('session_B')).toBe('FRESH'); + }); + + it('keeps entry semantics unchanged: nothing superseded yet', () => { + // Virgin worktree → fresh (and the role rides that launch). + expect(decideBranch(null)).toBe('FRESH'); + // A worktree that already holds a conversation → resumed, task not re-queued. + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + expect(decideBranch(null)).toBe('RESUME'); + }); + + it('routes a genuinely UNREADABLE store to FRESH even though a session exists', () => { + // Not the same as an absent store (pinned separately above). Here a session + // for this cwd DOES exist and would authorize `-c`, but the scan cannot run: + // replacing `sessions/` with a regular file makes readdirSync throw ENOTDIR + // deterministically, and unlike `chmod 000` it still fails when run as root. + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + const sessions = join(fakeHome, '.kimi-code', 'sessions'); + rmSync(sessions, { recursive: true, force: true }); + writeFileSync(sessions, 'not a directory', 'utf-8'); + expect(decideBranch('session_A')).toBe('FRESH'); + expect(decideBranch(null)).toBe('FRESH'); + }); + + it('ignores stdout the probe did not produce (status is what authorizes -c)', () => { + // CMAP 2026-08-09, claude F1 — a failure mode this delta INTRODUCED. Once + // the decision reads stdout, anything else writing there is read as "a + // session exists": a `node` shim on PATH, or NODE_OPTIONS preloading an + // instrumentation module that prints a banner. Against an EMPTY store that + // sends the loop to `kimi -c` with nothing to continue — which does not + // fail, it starts a session that never saw --agent-file. Silently roleless, + // the #929 class. The probe still exits 1; the guard must honor it. + const preload = join(fakeHome, 'noisy.cjs'); + writeFileSync(preload, 'console.log("hello-from-require");', 'utf-8'); + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: 'pir-1201', + }); + const fns = script.slice( + script.indexOf('codev_newest_session()'), + script.indexOf('codev_launch_fresh()'), + ); + const res = spawnSync('bash', ['-c', + `${fns}\nif codev_should_resume; then echo RESUME; else echo FRESH; fi\n`, + ], { + cwd: worktree, + env: { + ...process.env, + KIMI_CODE_HOME: join(fakeHome, '.kimi-code'), + NODE_OPTIONS: `--require ${preload}`, + }, + encoding: 'utf-8', + }); + expect(res.stdout.trim()).toBe('FRESH'); + }); + }); + + /** + * F4 (CMAP 2026-08-09, claude): the pieces were pinned, the COMPOSITION was not. + * + * `decideBranch` injects `codev_superseded_id` from the test, so nothing proved + * the generated clean-exit branch actually assigns it. A refactor that wrapped + * the assignment in a subshell — `( codev_superseded_id=$(...) )`, an ordinary + * bash footgun — or moved it below the relaunch would pass every other test here + * while the sticky-fresh contract was dead. The composition is where the bug + * was, so it is where the pin belongs: drive the REAL loop body with stubbed + * launches and read back which branch each iteration took. + */ + it('drives the real loop: clean exit then a pre-mint crash launches fresh TWICE', () => { + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: 'pir-1201', + }); + const log = join(fakeHome, 'branches.log'); + // Everything from the probe definition down, with the real `while` loop — + // only the two launches and the sleep are stubbed. `exit 0` on the third + // iteration ends the loop so the test terminates. + const body = script.slice(script.indexOf('codev_newest_session()')); + const harness = + `${body.replace(/^codev_launch_fresh\(\) \{[\s\S]*?^\}$/m, + `codev_launch_fresh() { echo fresh >> '${log}'; return $codev_next_status; }`) + .replace(/^codev_launch_resume\(\) \{[\s\S]*?^\}$/m, + `codev_launch_resume() { echo resume >> '${log}'; return $codev_next_status; }`)}`; + const driver = + 'codev_next_status=0\n' + // iteration 1: kimi exits cleanly + 'sleep() { :; }\n' + + 'codev_queue_task() { :; }\n'; + const res = spawnSync('bash', ['-c', + `${driver}\n${harness.replace('while true; do', + 'codev_iter=0\nwhile true; do\n codev_iter=$(( codev_iter + 1 ))\n' + + ' [ "$codev_iter" -eq 2 ] && codev_next_status=1\n' + + ' [ "$codev_iter" -ge 4 ] && exit 0\n')}`, + ], { + cwd: worktree, + // The Enter that gates the clean-exit relaunch. + input: '\n', + env: { ...process.env, KIMI_CODE_HOME: join(fakeHome, '.kimi-code') }, + encoding: 'utf-8', + timeout: 20000, + }); + expect(res.status, res.stderr).toBe(0); + const branches = readFileSync(log, 'utf-8').trim().split('\n'); + // 1: entry resumes (a session exists and nothing is superseded yet). + // 2: clean exit → fresh, and the branch retires session_A. + // 3: the crash lands pre-mint, so the ONLY session is the retired one → fresh. + expect(branches).toEqual(['resume', 'fresh', 'fresh']); + }); + it('does not execute a builder id containing shell metacharacters', () => { // claude F3 / codex #3: the recovery hints used to interpolate the id into a // double-quoted echo, where bash re-scans it — so `$(…)` in an id ran when the @@ -836,7 +1103,7 @@ describe('harness', () => { worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', taskFile: join(worktree, '.builder-prompt.txt'), builderId: evil, }); - const harnessFns = script.slice(script.indexOf('codev_builder_id='), script.indexOf('codev_has_session()')); + const harnessFns = script.slice(script.indexOf('codev_builder_id='), script.indexOf('codev_newest_session()')); // No `afx` on PATH → both recovery hints print, which is the vulnerable path. const res = spawnSync('bash', ['-c', `${harnessFns}\ncodev_queue_task\n`], { env: { ...process.env, PATH: '/usr/bin:/bin' }, encoding: 'utf-8' }); @@ -895,6 +1162,35 @@ describe('harness', () => { expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId).toBe('session_here'); }); + it('reads workDir when cwd is present but NOT a string', () => { + // CMAP 2026-08-09, claude F3a. The probe used `j.cwd ?? j.workDir`, which + // short-circuits on any non-null cwd — including a number — while + // readStateJson tests `typeof === 'string'` per field and falls through to + // workDir. A store that ever wrote a non-string cwd would have made the two + // disagree about which session is newest, which is the one thing this pair + // must never do now that the resume decision reads identity. + writeStoreSession('session_mixed', { + id: 'session_mixed', version: 2, cwd: 12345, workDir: worktree, updatedAt: 5, + }); + expect(runProbe(worktree)).toBe(true); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId) + .toBe('session_mixed'); + }); + + it('a trailing slash on a NONEXISTENT cwd does not manufacture a match', () => { + // CMAP 2026-08-09, claude F3b — the unsafe direction, and the reason the + // probe no longer pre-strips trailing slashes. realpathSync normalizes one + // away for a directory that exists (the case below), so stripping first + // bought nothing and cost fidelity: for a path that does NOT exist, the + // probe canonicalized `/ghost/` to `/ghost` while sameDir left it alone, so + // the probe would name a session discovery rejects — `kimi -c` into a + // conversation nobody verified, i.e. the roleless path. + const ghost = join(fakeHome, 'ghost'); + writeStoreSession('session_ghost', { id: 'session_ghost', cwd: `${ghost}/`, updatedAt: 1 }); + expect(runProbe(ghost)).toBe(false); + expect(KIMI_HARNESS.buildResume!(ghost, { homeDir: fakeHome })).toBeNull(); + }); + it('a cwd recorded with a trailing slash still matches', () => { writeStoreSession('session_slash', { id: 'session_slash', version: 2, cwd: `${worktree}/`, updatedAt: 1, diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index 280f130d6..7f2b26597 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -562,7 +562,7 @@ describe('spawn-worktree', () => { const script = findWrite('.builder-start.sh'); expect(script).toBeDefined(); - expect(script).toContain('codev_has_session'); + expect(script).toContain('codev_should_resume'); // The discovered id is NEVER baked into the script — the relaunch is the // documented cwd-scoped `-c`, so no undocumented id reaches generated bash. expect(script).not.toContain('session_prev-1'); @@ -581,7 +581,7 @@ describe('spawn-worktree', () => { 'PROMPT', 'ROLE', 'codev', ); const script = findWrite('.builder-start.sh'); - expect(script).not.toContain('codev_has_session'); + expect(script).not.toContain('codev_should_resume'); expect(script).not.toContain('--agent-file'); expect(createTerminalMock.mock.calls.at(-1)![0].seedKick).toBeUndefined(); }); diff --git a/packages/codev/src/agent-farm/utils/harness.ts b/packages/codev/src/agent-farm/utils/harness.ts index 1a6d1612b..e5a25bb3b 100644 --- a/packages/codev/src/agent-farm/utils/harness.ts +++ b/packages/codev/src/agent-farm/utils/harness.ts @@ -411,27 +411,55 @@ function kimiTuiCmd(baseCmd: string): string { * it — so "has the task landed yet?" and "is there anything to resume?" are the * same question, and this probe answers it directly from the store. * - * Fails CLOSED: any error (no store, unreadable dir, malformed JSON) exits - * non-zero and the loop relaunches fresh WITH the role, which is always safe. + * It prints the NEWEST resumable session id rather than a bare yes/no, because + * the loop needs identity, not existence, to honor #1267's sticky-fresh contract: + * after a clean exit the superseded id is recorded, and `-c` is only taken when + * the newest id has since CHANGED (see the launch script). Existence alone cannot + * distinguish "the fresh conversation has started" from "the conversation the user + * deliberately ended is still the only one here". The boolean uses derive from + * "printed something", so there is one probe and one mirror, not two snippets. * - * It mirrors {@link findLatestKimiSessionId} field for field — `cwd ?? workDir`, - * `sameDir`'s realpath tolerance, and `isResumable`'s archived / `session_` - * filters — because the two answer the same question in two languages and a - * divergence is a silent bug in EITHER direction: a probe that says yes where - * discovery says no sends `-c` down its roleless nothing-to-continue path, and a - * probe that says no where discovery says yes restarts a crashed builder with no - * context and re-queues its task. The generated snippet is pinned against fixture - * stores by a unit test that EXECUTES it and cross-checks both answers, so the - * mirroring cannot rot. + * Printing the newest id is only meaningful because `kimi -c` continues the NEWEST + * session for the cwd — measured on 0.34.0 with two live sessions in one directory, + * confirmed by both a content oracle and a store-identity oracle, no prompt and no + * new session minted (`codev/spikes/pir-1201-kimi-continue-newest-probe.mjs`). + * + * Fails CLOSED: any error (no store, unreadable dir, malformed JSON) prints + * nothing, and an empty answer routes the loop to a fresh launch WITH the role, + * which is always safe. + * + * It mirrors {@link findLatestKimiSessionId} field for field — `readStateJson`'s + * PER-FIELD `typeof` check on `cwd` then `workDir` (not `cwd ?? workDir`, which + * short-circuits on a non-string `cwd` where discovery falls through), + * `sameDir`'s realpath tolerance, `isResumable`'s archived / `session_` filters, + * and now `parseTimestamp`'s ranking — because the two answer the same question in + * two languages and a divergence is a silent bug in EITHER direction: a probe that + * names a session discovery would not sends `-c` down its roleless + * nothing-to-continue path, and a probe that says no where discovery says yes + * restarts a crashed builder with no context. Naming the WRONG session is the new + * third direction, and it is the one this finding is about. The generated snippet + * is pinned against fixture stores by a unit test that EXECUTES it and asserts the + * printed id equals discovery's, so the mirroring cannot rot. */ -const KIMI_HAS_SESSION_PROBE = +const KIMI_NEWEST_SESSION_PROBE = 'const {readdirSync,readFileSync,realpathSync}=require("fs"),{join}=require("path");' + 'const r=join(process.env.KIMI_CODE_HOME||join(require("os").homedir(),".kimi-code"),"sessions");' + - // Mirrors sameDir(): compare canonicalized paths, falling back to the literal - // when realpath fails, so a symlinked worktree or a trailing slash still matches. - 'const n=p=>{p=String(p).replace(/\\/+$/,"")||"/";try{return realpathSync(p)}catch{return p}};' + + // Mirrors realpathOrSelf(): canonicalize, falling back to the literal when + // realpath fails, so a symlinked worktree still matches. Deliberately does NOT + // pre-strip a trailing slash — realpathSync already normalizes one away for any + // directory that exists, and stripping first was the probe's only divergence + // from sameDir(): for a path that does NOT exist, `/ghost/` would canonicalize + // to `/ghost` here and stay `/ghost/` there, letting the probe name a session + // discovery would reject (the unsafe direction). + 'const n=p=>{try{return realpathSync(p)}catch{return p}};' + 'const a0=process.argv[1],c=n(a0);' + + // Mirrors parseTimestamp(): finite number as-is, string via Date.parse, anything + // else unparseable. Ranking must match findLatestKimiSessionId or the script and + // the TypeScript would disagree about WHICH session `-c` is about to continue. + 'const ts=v=>typeof v==="number"?(Number.isFinite(v)?v:null):' + + 'typeof v==="string"?(Number.isNaN(Date.parse(v))?null:Date.parse(v)):null;' + 'let ws=[];try{ws=readdirSync(r,{withFileTypes:true}).filter(e=>e.isDirectory())}catch{}' + + 'let bi=null,bt=-Infinity;' + 'for(const w of ws){let ss=[];' + // Each level gets its OWN try. A stray non-directory under sessions/ (a // .DS_Store) made readdirSync throw ENOTDIR into the single outer try, which @@ -440,9 +468,17 @@ const KIMI_HAS_SESSION_PROBE = 'try{ss=readdirSync(join(r,w.name),{withFileTypes:true})' + '.filter(e=>e.isDirectory()&&e.name.startsWith("session_"))}catch{continue}' + 'for(const s of ss){try{const j=JSON.parse(readFileSync(join(r,w.name,s.name,"state.json"),"utf8"));' + - 'if(j.archived===true)continue;const d=j.cwd??j.workDir;' + - 'if(typeof d==="string"&&(d===a0||n(d)===c))process.exit(0)}catch{}}}' + - 'process.exit(1)'; + // Per-FIELD typeof, exactly as readStateJson does. `j.cwd??j.workDir` diverged: + // a non-string `cwd` alongside a valid `workDir` short-circuits the fallback + // here while discovery still reads workDir, so the two disagreed on the winner. + 'if(j.archived===true)continue;' + + 'const d=typeof j.cwd==="string"?j.cwd:j.workDir;' + + 'if(typeof d!=="string"||(d!==a0&&n(d)!==c))continue;' + + // `?? -1` mirrors discovery: an unparseable timestamp ranks below every real + // epoch but above the -Infinity sentinel, so a lone malformed match still wins. + 'const k=ts(j.updatedAt)??-1;if(k>bt){bt=k;bi=s.name}}catch{}}}' + + 'if(bi===null)process.exit(1);' + + 'console.log(bi)'; export const KIMI_HARNESS: HarnessProvider = { buildRoleInjection: () => { @@ -579,18 +615,80 @@ codev_queue_task() { // Crash restart resumes the conversation (#1233's builder-side contract) via // the DOCUMENTED, cwd-scoped `-c` — no undocumented session id in the script. - // Guarded by codev_has_session because `-c` with nothing to continue does not - // fail: it starts a fresh session that never saw --agent-file, i.e. a - // ROLELESS builder (verified, 0.34.0). The guard fails closed, so the - // fallback is always the role-carrying fresh launch. + // Guarded because `-c` with nothing to continue does not fail: it starts a + // fresh session that never saw --agent-file, i.e. a ROLELESS builder + // (verified, 0.34.0). The guard fails closed, so the fallback is always the + // role-carrying fresh launch. + // + // The guard compares session IDENTITY, not mere existence, to honor #1267's + // sticky-fresh contract ("clean exit → fresh rerun, no recovery"). Because + // `-c` is cwd-scoped rather than id-pinned, existence alone leaves a real gap: + // a clean exit relaunches fresh, 0.33.0+ mints no session until the first + // message lands, and a crash inside that pre-mint window would find the + // just-abandoned conversation still the newest one for the cwd and continue + // IT — resurrecting exactly what the user walked away from, and delivering the + // re-queued task into it. claude's loop closes this by minting a new id and + // never naming the superseded one; kimi cannot mint on demand, so the loop + // records the superseded id at clean exit and refuses `-c` until the newest id + // differs. A crash AFTER the new conversation mints resumes normally. + // + // Two edges this deliberately does NOT cover, both traced and both accepted: + // + // - The superseded id lives in the loop's memory, so it does not survive the + // terminal being closed and re-created. That is the intended boundary, not + // an oversight: `afx spawn --resume` means "resume this builder", and entry + // semantics are unchanged — a worktree holding a conversation is resumed. + // (claude's equivalent survives only because its id is persisted for the + // `--resume` pin; kimi writes no session id to disk by design.) + // - If the store GC'd the just-superseded session while an OLDER abandoned one + // for the same cwd survived, the newest id would differ from the superseded + // one and `-c` would continue that older conversation. It requires a GC that + // drops the NEWEST session while keeping older ones — the opposite of any + // plausible retention policy — so it is recorded rather than engineered + // against; closing it would mean accumulating every superseded id. + // + // And one accepted COST, in the other direction: when the clean-exit probe + // fails outright, `codev_resume_blocked` refuses `-c` for the rest of this + // loop's life rather than risk resurrecting the ended conversation. A later + // crash then restarts fresh instead of continuing, losing conversation + // continuity (never the role, and never the task — the mailbox still holds it). + // It self-heals at the next clean exit, which re-establishes a baseline. return `#!/bin/bash cd '${shellEscapeSingleQuote(ctx.worktreePath)}' codev_fast_fail_secs="\${CODEV_LAUNCH_FAST_FAIL_SECS:-15}" ${queueTask} -codev_has_session() { - node -e '${KIMI_HAS_SESSION_PROBE}' "$PWD" 2>/dev/null +# Prints the newest resumable session id for this cwd, or nothing. Empty output +# (no store, unreadable store, malformed json, no match) means "do not resume" — +# the fail-closed direction, whose fallback is the role-carrying fresh launch. +codev_newest_session() { + node -e '${KIMI_NEWEST_SESSION_PROBE}' "$PWD" 2>/dev/null +} + +# The id of the conversation the human deliberately ended, recorded at clean exit. +# Empty until then, which is why the same predicate serves script entry: with +# nothing superseded, "newest differs from superseded" reduces to "one exists". +codev_superseded_id='' +# Set when a clean exit could not read the store: we then have no baseline, so a +# later session cannot be told apart from the one just ended. Refuse to resume +# until the next clean exit re-establishes one. Fresh always carries the role, so +# the cost is losing crash-resume continuity, never losing the role. +codev_resume_blocked=0 + +codev_should_resume() { + [ "$codev_resume_blocked" = 1 ] && return 1 + # BOTH signals. The status is what makes this fail closed: stdout alone would + # accept anything written to it by something other than the probe (a node + # wrapper on PATH, NODE_OPTIONS=--require preloading an instrumentation module + # that prints), and a non-empty answer against an empty store sends the loop to + # "kimi -c" with nothing to continue — which does not fail, it starts a session + # that never saw --agent-file, i.e. the silently roleless builder this whole + # guard exists to prevent. Declared first, assigned second: a combined + # "local x=$(cmd)" would mask the substitution's status behind local's own. + local codev_newest + codev_newest=$(codev_newest_session) || return 1 + [ -n "$codev_newest" ] && [ "$codev_newest" != "$codev_superseded_id" ] } codev_launch_fresh() { @@ -606,7 +704,7 @@ codev_launch_resume() { # Tower-side terminal re-create do the right thing without a second script # shape: a worktree that already holds a conversation is resumed (and the task # NOT re-queued); a virgin one starts fresh. -if codev_has_session; then +if codev_should_resume; then codev_launch=codev_launch_resume else codev_launch=codev_launch_fresh @@ -621,6 +719,23 @@ while true; do clear echo "Agent exited at your request. Press Enter to relaunch fresh, or close this terminal." read -r || exit 0 + # Retire the conversation the human just ended: until a NEW session mints, + # the crash branch must not treat this id as something to continue. Recorded + # after the Enter gate, while it is still the newest for this cwd — and after + # kimi has flushed state.json, rather than during its teardown. Re-recorded on + # every clean exit, so iterated quits supersede each conversation in turn. + # + # A FAILED probe here is not the same as an empty store: it means we could not + # read the baseline at all, and recording '' would leave the just-ended session + # comparing "different" on the next crash — resurrecting exactly what this + # branch exists to retire. So distinguish the two by status and block resume + # outright when the baseline is unknown. + if codev_prev_id=$(codev_newest_session); then + codev_superseded_id="$codev_prev_id" + codev_resume_blocked=0 + else + codev_resume_blocked=1 + fi codev_launch=codev_launch_fresh codev_task_queued=0 codev_fast_fails=0 @@ -649,10 +764,12 @@ while true; do fi codev_launch=codev_launch_fresh codev_fast_fails=0 - elif codev_has_session; then + elif codev_should_resume; then echo "Agent exited (code $status). Resuming the conversation in 2 seconds... (Ctrl+C to quit)" codev_launch=codev_launch_resume else + # Either nothing to continue, or the only thing to continue is the conversation + # the human ended — the pre-mint window after a clean exit. Fresh, both times. echo "Agent exited (code $status) before starting a conversation. Relaunching fresh in 2 seconds... (Ctrl+C to quit)" codev_launch=codev_launch_fresh fi From 781ca59f149cce2d6d210b14b88161cb6701ed5c Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sun, 9 Aug 2026 01:25:45 -0400 Subject: [PATCH 43/43] [Spec 1201] docs: record the sticky-fresh contract and finding 4's dispositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arch.md's kimi crash-resume section described an existence guard; it now describes the identity one, the measured fact it rests on (`kimi -c` continues the newest session for a cwd), and the two accepted residuals — the in-memory superseded id (contract parity with claude's per-process minted id) and a store GC that evicted newest-first. Plus the builder thread and the full CMAP disposition record: gemini APPROVE, codex and claude REQUEST_CHANGES, every finding accepted, including the blocking one that this round's own change introduced. --- .../1201-cmap-finding4-dispositions.md | 139 ++++++++++++++++++ codev/resources/arch.md | 2 +- codev/state/pir-1201_thread.md | 71 +++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-finding4-dispositions.md diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-finding4-dispositions.md b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-finding4-dispositions.md new file mode 100644 index 000000000..28a050645 --- /dev/null +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-finding4-dispositions.md @@ -0,0 +1,139 @@ +# CMAP dispositions — finding 4, kimi sticky-fresh crash-resume (2026-08-09) + +Round: the architect's finding 4 on PR #1203 — after a clean exit, a crash in the pre-mint boot +window makes `kimi -c` resume the conversation the human just ended (#1267's own motivating +defect class). Earlier rounds: `1201-cmap-postpivot-dispositions.md`, +`1201-cmap-architect-review-dispositions.md`. + +Verdicts on the delta: **gemini APPROVE · codex REQUEST_CHANGES · claude REQUEST_CHANGES**. +Both REQUEST_CHANGES were right, and they converged on the same blocking defect. Every finding +was accepted; none rejected. + +Scope fences respected: no PR comment, and the three parked maintainer decisions (trust +pre-write, 0.33.0 floor, write-guard parity) untouched. + +--- + +## Measurement first — the premise holds + +The fix (and the pre-existing resume design) assumes `kimi -c` continues the NEWEST session when +a cwd holds several. The existing continue-probe only covered the ZERO-session case, so this was +measured net-new on real kimi 0.34.0 +(`codev/spikes/pir-1201-kimi-continue-newest-probe.mjs`), with two independent oracles because +the model's own answer is not proof: + +- **content oracle** — sessions seeded with distinct codewords ALPHA (older) / BRAVO (newer); + `kimi -c` answered **BRAVO**. +- **identity oracle** — snapshot `updatedAt` for every session before and after; the `-c` turn + touched **only** `session_f06c…` (the newest), and **created no new session**. Exit 0, no + prompt. + +So identity comparison is well-defined, and the fallback ("document the residual instead") did +not apply. + +--- + +## The fix + +The inlined store probe now PRINTS the newest resumable session id instead of exiting 0/1; the +clean-exit branch records that id as superseded; the crash branch takes `-c` only once the +newest id differs. One probe, one mirror — the boolean uses derive from the same output. + +--- + +## codex #2 / claude F1 — BLOCKING: the guard read stdout and discarded exit status — **ACCEPTED** + +The delta moved the decision from `$?` onto stdout, so anything else writing to stdout is read +as "a session exists". claude **measured** it: with an empty store and +`NODE_OPTIONS=--require `, the probe printed a banner and exited 1, and the +script read RESUME. That lands on `kimi -c` with nothing to continue — which does not fail, it +starts a session that never saw `--agent-file`: a silently **roleless** builder, the #929 class +the entire guard exists to prevent. **A failure mode the delta introduced** — the pre-delta code +could not produce it. Vectors: `NODE_OPTIONS`, a `node` shim on PATH, corporate instrumentation +preloads. + +Fixed by consuming both signals, with the declaration split from the assignment so `local` does +not mask the substitution's status: + +```bash +local codev_newest +codev_newest=$(codev_newest_session) || return 1 +[ -n "$codev_newest" ] && [ "$codev_newest" != "$codev_superseded_id" ] +``` + +Pinned by a new test that reproduces the exact vector. + +## codex #1 / claude F2 — a transient probe failure at clean exit re-opens the gap — **ACCEPTED** + +The architect's sketch said "empty on any error — fail-closed", and my comment repeated it. Both +reviewers showed it is not: if the probe fails transiently (EMFILE, ENOMEM, fork failure, a +throwing preload) the branch records `''`, and the next crash sees the just-ended session as +"different from empty" → resumes it. The very bug the finding is about. + +claude's suggested mitigation (keep the previous value) only helps on *iterated* exits; the +first clean exit still records nothing. So the branch now distinguishes **failure** from **empty +store** by status and sets `codev_resume_blocked`, which refuses resume until the next clean +exit re-establishes a baseline. Accepted cost, documented in-code: a later crash restarts fresh +instead of continuing, losing conversation continuity — never the role (fresh always carries it) +and never the task (the mailbox still holds it). It self-heals at the next clean exit. + +## claude F3a / codex #4 — `j.cwd ?? j.workDir` is not the mirror — **ACCEPTED** + +Discovery's `readStateJson` tests `typeof === 'string'` **per field**; the probe's `??` +short-circuits on any non-null `cwd`, so `{cwd: 12345, workDir: }` was found by discovery +and missed by the probe. Fail-closed in direction, but it disproves the field-for-field claim +the docstring makes — and identity, not just existence, now rides on that claim. Probe changed +to per-field `typeof`; docstring corrected to stop naming `cwd ?? workDir` as the mirror; a +fixture added. + +## claude F3b — trailing-slash normalization diverged, in the UNSAFE direction — **ACCEPTED** + +The probe's `n()` stripped a trailing slash *before* `realpathSync`; `sameDir` does not. For a +path that does not exist, `/ghost/` canonicalized to `/ghost` in the probe and stayed `/ghost/` +in discovery — so the probe could name a session discovery rejects. claude called it unreachable +(the probe's argument is `$PWD`, which exists) and said record it. Removed instead: the strip +bought nothing, because `realpathSync` already normalizes a trailing slash away for any +directory that exists — which is exactly what the existing trailing-slash fixture covers, and it +still passes. Exact mirror beats documented exception. Fixture added for the ghost case. + +## claude F4 — the composition was never executed, only the pieces — **ACCEPTED** + +`decideBranch` injects `codev_superseded_id` from the test, so the only evidence the generated +clean-exit branch assigns it was a string match. A refactor wrapping that assignment in a +subshell — an ordinary bash footgun — would pass every test while the contract was dead. Added a +test that drives the **real `while` loop** with stubbed launches and a fed `read -r`, asserting +the branch sequence is `resume, fresh, fresh` (entry resumes; clean exit goes fresh and retires +the id; the pre-mint crash stays fresh). + +## claude F5 — "unreadable store" tested an ABSENT store — **ACCEPTED** + +The test never wrote a session, so `rmSync` removed nothing and it duplicated the +store-does-not-exist case. Rewritten: write a session that WOULD authorize `-c`, then replace +`sessions/` with a regular file for a deterministic ENOTDIR (root-proof, unlike `chmod 000`). + +## claude nits — **ACCEPTED** + +Restored the stronger `not.toContain('codev_launch_resume')`; the `afterClean` slice now bounds +on the branch's own two-space-indented `fi` (the earlier `\n\s*fi\n` stopped at the new nested +conditional — the same class of bug as the `"fine"` match it replaced). + +## codex edge notes — traced, documented, not engineered against + +- **Store GC drops the newest session** while an older abandoned one survives → `-c` reaches the + older one. Requires a retention policy that evicts newest-first. Recorded in-code. +- **`afx spawn --resume` / terminal re-create** resets the in-memory superseded id. Documented + as the intended boundary — and claude noted this is **contract parity**, not a kimi shortfall: + claude's minted id is equally per-process. +- **Two builders in one cwd** — not a real topology (one worktree per builder). + +--- + +## Verification + +- `pnpm build` clean; `tsc --noEmit` clean; generated script passes `bash -n`. +- Full suite **4915 passed / 48 skipped / 0 failed** (+9 on the round's starting 4906). +- Targeted suites (harness, harness-integration, spawn-worktree, kimi-session-discovery, + mailbox-pacing, render-gate) green. +- Non-vacuity is demonstrated rather than asserted: `decideBranchLegacy()` runs the pre-fix + existence-only predicate against the same store and the same generated probe, and the + regression test asserts it returns RESUME where the shipped guard returns FRESH. diff --git a/codev/resources/arch.md b/codev/resources/arch.md index 7efecdc28..9cc496982 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -334,7 +334,7 @@ A `codev doctor` audit (`lib/framework-ref-audit.ts`) flags shell-fetch of frame This replaced the original **seed-session bootstrap** (`kimi -p` seed → `session.resume_hint` capture → pinned `kimi -S ` loop → a sentinel-gated `BEGIN` kick written straight to the PTY). The pivot removed three undocumented surfaces (`resume_hint`, `-S` id pinning, `state.json.lastPrompt` delivery verification), deleted `servers/seed-kick.ts` outright, and stopped the role riding a **user turn** — the weaker-authority tradeoff that also deferred agy as an architect (#1063). It is a strictly smaller integration for a strictly stronger result. -**Crash resume uses the documented, cwd-scoped `kimi -c`** — no session id is ever baked into generated bash. The guard that makes this safe: `kimi -c` **does not fail when there is nothing to continue**. It prints `No sessions to continue under ""; starting a fresh session.` and starts one anyway — and that session never saw `--agent-file`, i.e. a silently **roleless** builder (the #929 hazard class; verified on 0.34.0). So the launch loop only takes `-c` after an inlined `node -e` store probe proves a session exists for this cwd, and the probe **fails closed**: any error (no store, unreadable dir, malformed JSON) exits non-zero and the loop relaunches fresh **with** the role, which is always safe. The probe answers "**would `kimi -c` continue it?**", not "does a directory exist": kimi lists a cwd's sessions before continuing one, and that listing drops **archived** sessions and ids it does not recognize — so a session we call resumable but kimi skips lands on the same roleless path. Both filters (`archived !== true`, `session_`-prefixed id) therefore apply in the probe *and* in `findLatestKimiSessionId`/`verifyKimiSessionOwnership`, and both err toward "not resumable", whose fallback is the role-carrying fresh launch. The probe is pinned by tests that execute it against fixture stores and cross-check it against `findLatestKimiSessionId`, so the hand-written snippet cannot drift from the TypeScript it mirrors — including the cases that once split them: a stray non-directory in the store (which aborted the whole scan via `ENOTDIR`, silently disabling resume machine-wide), a symlinked worktree, and a trailing slash on the recorded cwd. Entry is self-configuring on the same probe, so `afx spawn --resume` and a Tower-side terminal re-create need no second script shape — and a re-run never re-queues the task into a live conversation. A clean exit (#1267/#1317) relaunches **fresh** and re-queues the task, mirroring claude's prompt-on-fresh semantics — and that human-gated relaunch is the *only* path that re-queues. A crash loop does not: the mailbox persists a held row, so a kimi that dies before minting a session (bad auth, say) would otherwise pile the same mission onto the mailbox every two seconds. Every value the generator interpolates — worktree path, builder id, task path — enters the script **once**, as a single-quoted escaped assignment, and every later use goes through the shell variable; the recovery hints print through `printf '%s\n'` on the expansion, which bash does not re-scan, so an id or path containing a backtick or `$(…)` is displayed rather than executed. +**Crash resume uses the documented, cwd-scoped `kimi -c`** — no session id is ever baked into generated bash. The guard that makes this safe: `kimi -c` **does not fail when there is nothing to continue**. It prints `No sessions to continue under ""; starting a fresh session.` and starts one anyway — and that session never saw `--agent-file`, i.e. a silently **roleless** builder (the #929 hazard class; verified on 0.34.0). So the launch loop only takes `-c` after an inlined `node -e` store probe **names the newest resumable session** for this cwd, and the probe **fails closed**: any error (no store, unreadable dir, malformed JSON) prints nothing, and an empty answer relaunches fresh **with** the role, which is always safe. The probe answers "**would `kimi -c` continue it?**", not "does a directory exist": kimi lists a cwd's sessions before continuing one, and that listing drops **archived** sessions and ids it does not recognize — so a session we call resumable but kimi skips lands on the same roleless path. Both filters (`archived !== true`, `session_`-prefixed id) therefore apply in the probe *and* in `findLatestKimiSessionId`/`verifyKimiSessionOwnership`, and both err toward "not resumable", whose fallback is the role-carrying fresh launch. The probe is pinned by tests that execute it against fixture stores and assert the **printed id** equals `findLatestKimiSessionId`'s — identity, not just existence, since that is what the resume decision now turns on — so the hand-written snippet cannot drift from the TypeScript it mirrors — including the cases that once split them: a stray non-directory in the store (which aborted the whole scan via `ENOTDIR`, silently disabling resume machine-wide), a symlinked worktree, and a trailing slash on the recorded cwd. Entry is self-configuring on the same probe, so `afx spawn --resume` and a Tower-side terminal re-create need no second script shape — and a re-run never re-queues the task into a live conversation. A clean exit (#1267/#1317) relaunches **fresh** and re-queues the task, mirroring claude's prompt-on-fresh semantics — and that human-gated relaunch is the *only* path that re-queues. **Sticky-fresh is enforced by session identity, not by existence.** Because `-c` is cwd-scoped rather than id-pinned, and 0.33.0+ mints no session until the first message lands, a crash in the window between a clean-exit relaunch and the first delivery would otherwise find the just-ended conversation still the newest for the cwd and continue it — resurrecting exactly what the human walked away from, and delivering the re-queued task into it (#1267's own motivating defect class). So the clean-exit branch records the superseded id, and the crash branch takes `-c` only once the newest id **differs** from it; claude's loop gets the same guarantee for free by minting a new id and never naming the old one. This rests on a measured fact — `kimi -c` continues the **newest** session for a cwd, verified live on 0.34.0 with two sessions in one directory, by both a content oracle and a store-identity oracle, with no prompt and no new session minted. Two accepted residuals: the superseded id lives in the loop's memory, so closing and re-creating the terminal returns to plain entry semantics (`afx spawn --resume` means resume); and a store GC that dropped the newest session while keeping an older abandoned one would let `-c` reach that older conversation. A crash loop does not: the mailbox persists a held row, so a kimi that dies before minting a session (bad auth, say) would otherwise pile the same mission onto the mailbox every two seconds. Every value the generator interpolates — worktree path, builder id, task path — enters the script **once**, as a single-quoted escaped assignment, and every later use goes through the shell variable; the recovery hints print through `printf '%s\n'` on the expansion, which bash does not re-scan, so an id or path containing a backtick or `$(…)` is displayed rather than executed. **Message pacing is per-harness** (`servers/mailbox-wiring.ts` `resolvePacingForSession` + `message-write.ts` `pacing.enterDelayMs`): Kimi's paste-detection window swallows an Enter sent 80ms after the body (the default), so Kimi targets get a ~1s delayed Enter — bisected live (80/100ms fail; 120ms+ submit; pinned at 1000ms for ~9x margin, latency being the only cost). Resolution recovers the harness from the session's launch `command`, then from the generated `.builder-start.sh` (matching the command in **command position**, as `afx reset` does) — the same self-describing signal the render gate resolves. It is override-proof by construction: the script is generated *from* the resolved harness, so a `--builder-cmd kimi` spawn against a claude-configured workspace still reads `kimi`. This replaced a `.builder-kimi` **marker file**, which obliged every launch shape to remember to write one — an obligation the bare shape missed (found in PR #1203 review). Pacing is advisory and **total**: any failure degrades to default timing rather than throwing into the delivery path. The `/api/send --interrupt` bypass paces too (it writes body-then-Enter); `--escape` deliberately does not (it writes no text, and its behaviour on Kimi is unmeasured). diff --git a/codev/state/pir-1201_thread.md b/codev/state/pir-1201_thread.md index dbc5acaf6..ee7af9e1c 100644 --- a/codev/state/pir-1201_thread.md +++ b/codev/state/pir-1201_thread.md @@ -238,3 +238,74 @@ boolean>` — the next new detail is a compile error rather than a silent `false (+6 on the 4900 this round started from). No live demo re-run needed: the rule can only change verdicts for a composer past one interior row, and delivery targets the idle composer — measured at one row in every state, including mid-generation. + +--- + +## 2026-08-09 — finding 4: a clean exit that did not stick + +The architect re-verified the maintainer's exit contract against main's landed code and found a +real gap. #1267's contract is "clean exit → fresh rerun, no recovery", and claude's loop enforces +it **by identity**: a clean exit mints a new session id and the superseded one is never named +again. kimi cannot mint on demand and `kimi -c` is cwd-scoped, so identity was never pinned: + +1. human cleanly exits conversation A +2. Enter gate → fresh relaunch; kimi 0.33+ mints **no session** until the first message lands +3. kimi crashes in that pre-mint window +4. the old guard asked only "does *any* session exist for this cwd?", found A, and ran `kimi -c` + → continuing the conversation the human deliberately ended, with the re-queued task delivered + into it + +**Measured before building.** The whole design assumes `-c` continues the NEWEST session when a +cwd holds several — the existing probe only covered the zero-session case. Two live sessions in +one directory on 0.34.0, two independent oracles: content (codewords ALPHA/BRAVO → answered +BRAVO) and store identity (only the newest session's dir was touched; nothing new minted; exit 0, +no prompt). Premise holds, so the documented-residual fallback did not apply. + +The fix makes the probe answer **which** session rather than **whether** one exists; the +clean-exit branch records that id; the crash branch resumes only once the newest id differs. + +### CMAP: gemini APPROVE, codex REQUEST_CHANGES, claude REQUEST_CHANGES — and they were right + +**The blocking one is a defect I introduced, not one I inherited.** Moving the decision from +`$?` onto stdout meant anything *else* writing to stdout counted as "a session exists". Claude +measured it: empty store plus `NODE_OPTIONS=--require ` → probe prints a +banner, exits 1, script reads RESUME → `kimi -c` with nothing to continue → a session that never +saw `--agent-file`. A silently roleless builder — the exact #929 class this guard exists to +prevent, reintroduced by the guard's own upgrade. The pre-delta code could not produce it. Fixed +by consuming both signals (`codev_newest=$(...) || return 1`, declaration split from assignment +so `local` cannot mask the status). + +**The architect's sketch had one too, and both reviewers caught it.** It said record the id, +"empty on any error — fail-closed", and I repeated that in a comment. It isn't: a *transient* +probe failure records `''`, and the next crash then sees the ended session as "different from +empty" and resumes it. Now failure and empty-store are distinguished by status, and a failed +baseline blocks resume until the next clean exit re-establishes one. Costs crash-resume +continuity in that rare case; never the role, never the task. + +Two probe/discovery divergences also fell out, both pre-existing and both found by reading the +two implementations against each other rather than by testing: `j.cwd ?? j.workDir` short-circuits +on a non-string `cwd` where `readStateJson` falls through per-field; and the probe stripped a +trailing slash before `realpathSync` while `sameDir` does not — the unsafe direction, since a +nonexistent `/ghost/` would match in the probe and not in discovery. Removed the strip rather +than documenting it: `realpathSync` already normalizes a trailing slash for any directory that +exists, so it bought nothing and cost fidelity. Exact mirror beats documented exception. + +**Claude's sharpest test point:** the pieces were pinned, the composition never was. `decideBranch` +injected the superseded id from the test, so the only evidence the generated clean-exit branch +assigns it was a string match — and a refactor wrapping that assignment in a subshell would pass +everything while the contract was dead. There is now a test that drives the **real `while` loop** +with stubbed launches and asserts the branch sequence `resume, fresh, fresh`. + +Non-vacuity is demonstrated, not claimed: `decideBranchLegacy()` runs the pre-fix existence-only +predicate against the same store and the same generated probe, and the regression test asserts it +returns RESUME exactly where the shipped guard returns FRESH. + +**Residuals, written down rather than engineered against:** a store GC that dropped the newest +session while keeping an older abandoned one would let `-c` reach the older one (requires +newest-first eviction); and the superseded id is in-memory, so closing and re-creating the +terminal returns to plain entry semantics — which claude pointed out is contract *parity* with +claude's loop, whose minted id is equally per-process, not a kimi shortfall. + +**Verification:** build + `tsc --noEmit` clean; generated script passes `bash -n`; full suite +**4915 passed / 48 skipped / 0 failed**. Dispositions in +`codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-finding4-dispositions.md`.