From 7585219d709c03f3baa6b7f25701ceaae15eae00 Mon Sep 17 00:00:00 2001 From: Mohid Makhdoomi Date: Sat, 18 Jul 2026 18:14:19 -0400 Subject: [PATCH 01/28] [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/28] [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/28] [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/28] 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/28] [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/28] 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/28] 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/28] 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/28] [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/28] [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/28] [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/28] [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/28] [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/28] [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/28] [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/28] 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/28] 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/28] 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/28] [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/28] 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/28] 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/28] [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/28] 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/28] [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/28] 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/28] 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/28] [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/28] [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.