diff --git a/codev-skeleton/resources/commands/agent-farm.md b/codev-skeleton/resources/commands/agent-farm.md index 44b39f107..6a753e6c9 100644 --- a/codev-skeleton/resources/commands/agent-farm.md +++ b/codev-skeleton/resources/commands/agent-farm.md @@ -886,6 +886,41 @@ 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`, `opencode`, `kimi`) or pinned +explicitly via `shell.builderHarness`. Example — Kimi Code CLI as the builder +(builder-only; requires kimi >= 0.33.0): + +```json +{ + "shell": { + "builder": "kimi" + } +} +``` + +Kimi takes no positional prompt, so a Kimi builder gets its role and its task +through two different channels: the role via `--agent-file` (an agent-definition +file written into the worktree, composed around kimi's `${base_prompt}` token so +it extends rather than replaces kimi's own system prompt), and the task via the +`afx send` mailbox, delivered onto a verified-empty composer by the render gate. +A crashed builder resumes with `kimi -c`, but only once a store probe confirms a +conversation exists for that worktree — `kimi -c` with nothing to continue +silently starts a fresh, roleless session, so the probe fails closed to a +role-carrying fresh launch instead. + +Two notes specific to Kimi builders. Spawning pre-records workspace trust for the +new worktree, because kimi 0.33.0+ opens on a "Trust this folder?" dialog that an +unattended builder cannot answer (trust gates only whether project-level MCP +servers load; it does not gate tool execution). And Kimi builders do NOT yet get +the worktree write-guard Claude builders have — kimi does have a blocking +`PreToolUse` hook seam, so parity is achievable follow-up work rather than a +permanent limitation. + +Architect use of kimi and opencode is unsupported (use claude or codex there). + ### Mailbox retention and escalation `afx send`'s mailbox (Spec 1313) has two Tower-global knobs under a `mailbox` key: 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/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-postpivot-dispositions.md b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-postpivot-dispositions.md new file mode 100644 index 000000000..2572f0033 --- /dev/null +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-postpivot-dispositions.md @@ -0,0 +1,147 @@ +# CMAP dispositions — post-pivot delta (2026-08-09) + +Three-way review of the design-pivot delta on PR #1203 (role → `--agent-file`, task → the +Spec 1313 mailbox, crash resume → guarded `kimi -c`), run after the `origin/main` merge at +`ae0d034a`. The brief asked reviewers to attack the shared `render-gate.ts` edit hardest, +per the architect's guardrail. + +**Verdicts: gemini APPROVE · codex REQUEST_CHANGES · claude REQUEST_CHANGES.** + +Both REQUEST_CHANGES verdicts were right, and they found the same two defects from opposite +directions. Neither was reachable from a happy-path live run — an empty composer and a clean +store both behave correctly, which is exactly why three passing demos missed them. + +--- + +## Accepted and fixed + +### 1. False CLEAN on a multi-row kimi composer — BLOCKING (claude F1) + +`KIMI_MARKER` matches `` │ > ``; `findMarkerRow` takes the **last** match; the scan started +**at** that row. A draft whose final line begins with `>` puts the marker on the *continuation* +row, leaving the real text above the scanned region → the composer classifies `clean` while +holding unsent input, and a queued message is typed on top of it. That is the corruption class +the gate exists to prevent. + +Claude reproduced it on a constructed screen and flagged that it had no live kimi to confirm +kimi's real multi-row geometry. **Measured on real kimi 0.34.0** (`pir-1201-kimi-gate-measure.mjs`, +extended for this): a two-line draft renders + +``` + ╭──────────── + │ > implement the whole feature + │ > + ╰──────────── +``` + +— exactly the shape, so the defect is real and reachable, not theoretical. + +**Fix:** optional `regionStartPatterns` on `GateProfile`, an *exclusive* upper bound (kimi: the +box top `` ╭─── ``). Exclusive matters: the box-top row's right corner `╮` is not an ignorable +glyph, and including that row held every idle composer forever — caught by the fixture suite +when the first attempt regressed `kimi-idle.clean`. + +Committed as fixtures from the live capture: `kimi-multiline-bare` (the false CLEAN itself), +`kimi-multiline`, `kimi-menu`, `kimi-picker` — the last two answering claude's "kimi ships 3 +fixtures where claude/codex ship menu and picker" point. + +**Claude's second input — a marker-matching row *below* the composer in a second box — is not +reachable in the shipped UI, measured:** kimi's `/` menu renders as unclosed `│` rows with no +`╰` beneath them, so any marker inside it yields `no-region-end` → held. Recorded rather than +"fixed", with the fixtures to show it. + +### 2. The store probe diverges from `findLatestKimiSessionId` — BLOCKING (codex #1, claude F2) + +Two reviewers, two directions, same root cause: the probe and the TypeScript are the same +question in two languages, and the cross-check test compared them against each other rather +than against kimi's continuation semantics — agreement between duplicated omissions. + +- **codex #1 (dangerous direction):** an `archived: true` session matched on cwd alone, so the + probe authorized `-c`; kimi excludes archived sessions from the listing `-c` continues from, + starts a fresh one, and that session never saw `--agent-file` → silently **roleless** builder. +- **claude F2 (safe direction, still harmful):** `readdirSync` on a stray non-directory threw + `ENOTDIR` into the single **outer** try, aborting the whole scan — one `.DS_Store` in + `~/.kimi-code/sessions/` disabled resume machine-wide, permanently and silently. Same for a + symlinked worktree and a trailing slash on the recorded cwd. + +**Fix:** both implementations now share one resumability predicate (`archived !== true`, +`session_`-prefixed id) and `sameDir`'s realpath tolerance; each directory level gets its own +`try`. Every listed case is now a test asserting **both** implementations. + +### 3. Unescaped interpolation in the generated script (codex #3, claude F3, gemini MINOR) + +All three flagged the same lines from different angles. The recovery hints interpolated +`builderId` / `taskFile` into double-quoted bash `echo`s, where bash re-scans them — so `$(…)` +in a builder id executed when the hint printed. `cd "${worktreePath}"` was unquoted too. + +**Fix:** every value enters the script once as a single-quoted escaped assignment; later uses go +through the shell variable, and hints print via `printf '%s\n'` on the expansion (bash does not +re-scan an expansion). Pinned by a test that runs the generated function with a metacharacter +id and asserts nothing executed. + +### 4. Crash loop re-queues the task indefinitely (codex #4) + +`codev_launch_fresh` queues the task, so a kimi dying before it mints a session re-queued the +same mission every ~2s. The mailbox *persists* a held row, so one enqueue suffices. + +**Fix:** a `codev_task_queued` guard, reset only on the human-gated clean-exit relaunch (which +is a deliberate new conversation and does want its task again). Pinned by driving the generated +function through three crash iterations plus a clean-exit relaunch against a stub `afx`. + +### 5. Drift probes report healthy forever after a migration (codex #5) + +Both probes returned `ok` if **any** record matched, so post-migration the old records hide +every new one — reporting healthy through exactly the rename the probe exists to catch. + +**Fix:** compare the newest conforming record against the newest non-conforming one; report +drift only when the bad one is *strictly* newer. Ties stay `ok` — my first attempt used +mixed units (`updatedAt` vs filesystem mtime) and made the verdict depend on directory +iteration order, which a test caught. + +### 6. Cleanups (claude F6, F7) + +- `buildRoleInjection`'s user-facing error and a `doctor.ts` comment still described the retired + seed-session bootstrap. Both now describe `--agent-file` and *why* it does not fit the + architect path (it needs a file written into the agent's directory; only the builder launch + path has that seam). +- `verifyKimi`: `spawnSync` returns `status: null` on spawn failure or timeout, and `null !== 0` + reported "kimi doctor reports config issues" — a false accusation against a healthy install on + a slow machine. Now distinguishes "learned nothing" from "reported a problem". + +### 7. Dangling evidence references (claude F5) + +`gate-profiles.ts` and `harness.ts` cite spike scripts that were untracked. The three +`pir-1201-kimi-*.mjs` probes ship in this PR, so the evidence chain resolves after merge. + +--- + +## Accepted as accurate, no code change + +- **claude:** the `markerSpanEnd` edit is a genuine no-op for claude/codex/agy — attacked and + held up. The docstring's "only narrow glyphs" premise is slightly wrong (U+3000 is `\s` *and* + wide), but that direction under-shoots the span → over-counts → holds. Safe both ways; codex + reached the same conclusion independently. +- **codex:** trust filename construction has no traversal issue; pacing resolution is total. + +## Maintainer decisions, not mine (both surfaced in the PR body) + +- **codex #2 — automatic workspace trust.** Codex argues `--yolo` governs tool approval while + workspace trust governs whether repository-controlled MCP processes load at all, so a fork-PR + branch could get its project MCP config loaded without a human decision. Claude reviewed the + same code and concluded the opposite (a `--yolo` builder in a Codev-created worktree already + holds strictly more authority). The disagreement is real and is a policy call, so it goes to + the maintainer with both arguments rather than being settled here. Kept fail-soft, drift-probed, + and dated in `arch.md`; the PR offers to cut it for one human keypress per Kimi builder. +- **claude F4 — no worktree write-guard for kimi builders.** Correct, and materially broader than + the trust question. Kimi *does* have a blocking `PreToolUse` hook seam, so parity is achievable + follow-up work; the PR asks whether it lands here or separately. + +--- + +## What this round says about the process + +The three passing live demos were not worthless — they proved the mechanism end to end — but +every defect above lives in a state the happy path does not produce. Two independent reviewers +converged on the same two blocking defects from opposite directions, and the live measurement +rig then settled which of claude's two proposed inputs was real. Review found them; measurement +sized them. diff --git a/codev/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. 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..7261541a4 --- /dev/null +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/status.yaml @@ -0,0 +1,30 @@ +id: '1201' +title: support-kimi-code-cli-as-a-bui +protocol: pir +phase: verified +plan_phases: [] +current_plan_phase: null +gates: + plan-approval: + status: approved + requested_at: '2026-07-18T23:06:14.780Z' + approved_at: '2026-07-18T23:13:52.402Z' + dev-approval: + status: approved + requested_at: '2026-07-18T23:43:32.608Z' + approved_at: '2026-07-19T00:42:00.609Z' + pr: + 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:55:25.203Z' +pr_history: + - phase: review + pr_number: 1203 + branch: builder/pir-1201 + created_at: '2026-07-19T00:45:29.121Z' +pr_ready_for_human: false diff --git a/codev/resources/arch.md b/codev/resources/arch.md index e94df2c2b..7efecdc28 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -293,7 +293,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=`. (The built-in `gemini` `GEMINI_SYSTEM_MD` provider was retired in #1338; retained-access users wire it back as a custom harness — see Supported Harnesses below.) +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=`. (The built-in `gemini` `GEMINI_SYSTEM_MD` provider was retired in #1338; retained-access users wire it back as a custom harness — see Supported Harnesses below.) Kimi has no system-prompt flag — builder-only, role delivered via `--agent-file` composed around `${base_prompt}` (Issue #1201, see the Kimi subsection below). **Three architect creation points** where role injection is applied: - `tower-instances.ts` → `launchInstance()` (new project activation) @@ -313,7 +313,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. **The built-in `gemini` harness is retired (#1338)** — Google ended consumer Gemini CLI access (2026-06-18), so `gemini` is no longer a supported built-in builder *or* architect. It **fails closed** at every spawn / launch / reconnect / clean-exit boundary with a retirement message (never a silent claude fallback), and `codev doctor` flags a persisted `gemini` builder/architect config. Retained-access users (Standard/Enterprise or API-key) can still run it only via an **explicit** custom `gemini` harness selected through `shell.builderHarness` / `shell.architectHarness` — a bare auto-detected `gemini` command stays retired; the custom harness reproduces the old `GEMINI_SYSTEM_MD` env injection. (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 opencode` 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. **The built-in `gemini` harness is retired (#1338)** — Google ended consumer Gemini CLI access (2026-06-18), so `gemini` is no longer a supported built-in builder *or* architect. It **fails closed** at every spawn / launch / reconnect / clean-exit boundary with a retirement message (never a silent claude fallback), and `codev doctor` flags a persisted `gemini` builder/architect config. Retained-access users (Standard/Enterprise or API-key) can still run it only via an **explicit** custom `gemini` harness selected through `shell.builderHarness` / `shell.architectHarness` — a bare auto-detected `gemini` command stays retired; the custom harness reproduces the old `GEMINI_SYSTEM_MD` env injection. (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 opencode` 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; role via `--agent-file`; 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. @@ -323,6 +323,30 @@ 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) + +**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.33.0** — `--agent-file` is the hard functional requirement (added 0.31.0), but every live measurement below was taken on the agent-core-v2 engine 0.33.0 made default, and the floor names the oldest version the evidence actually covers. + +**The role rides `--agent-file`; the task rides the mailbox.** Kimi has no system-prompt flag and takes **no positional prompt** (it exits 1), so the whole 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`). Two independent channels: + +- **Role** — `getWorktreeFiles` writes an agent-definition file (`.builder-role-agent.md`) next to the raw `.builder-role.md`, and the launch line passes `--agent-file `. The body wraps the role around **`${base_prompt}`**, the template token that interpolates kimi's own default system prompt, so the role **extends** rather than replaces it — the `claude --append-system-prompt` analogue. Verified on 0.34.0 in both `-p` and interactive TUI modes. +- **Task** — queued on the Spec 1313 **mailbox** (`afx send "$(cat .builder-prompt.txt)"`) from inside the fresh-launch path, and delivered by the render gate onto a verified-empty composer. Never a direct PTY write: a boot screen, a busy line, or the folder-trust dialog simply **holds** the message instead of corrupting or losing it. + +This replaced the original **seed-session bootstrap** (`kimi -p` seed → `session.resume_hint` capture → pinned `kimi -S ` loop → a sentinel-gated `BEGIN` kick written straight to the PTY). The pivot removed three undocumented surfaces (`resume_hint`, `-S` id pinning, `state.json.lastPrompt` delivery verification), deleted `servers/seed-kick.ts` outright, and stopped the role riding a **user turn** — the weaker-authority tradeoff that also deferred agy as an architect (#1063). It is a strictly smaller integration for a strictly stronger result. + +**Crash resume uses the documented, cwd-scoped `kimi -c`** — no session id is ever baked into generated bash. The guard that makes this safe: `kimi -c` **does not fail when there is nothing to continue**. It prints `No sessions to continue under ""; starting a fresh session.` and starts one anyway — and that session never saw `--agent-file`, i.e. a silently **roleless** builder (the #929 hazard class; verified on 0.34.0). So the launch loop only takes `-c` after an inlined `node -e` store probe proves a session exists for this cwd, and the probe **fails closed**: any error (no store, unreadable dir, malformed JSON) exits non-zero and the loop relaunches fresh **with** the role, which is always safe. The probe answers "**would `kimi -c` continue it?**", not "does a directory exist": kimi lists a cwd's sessions before continuing one, and that listing drops **archived** sessions and ids it does not recognize — so a session we call resumable but kimi skips lands on the same roleless path. Both filters (`archived !== true`, `session_`-prefixed id) therefore apply in the probe *and* in `findLatestKimiSessionId`/`verifyKimiSessionOwnership`, and both err toward "not resumable", whose fallback is the role-carrying fresh launch. The probe is pinned by tests that execute it against fixture stores and cross-check it against `findLatestKimiSessionId`, so the hand-written snippet cannot drift from the TypeScript it mirrors — including the cases that once split them: a stray non-directory in the store (which aborted the whole scan via `ENOTDIR`, silently disabling resume machine-wide), a symlinked worktree, and a trailing slash on the recorded cwd. Entry is self-configuring on the same probe, so `afx spawn --resume` and a Tower-side terminal re-create need no second script shape — and a re-run never re-queues the task into a live conversation. A clean exit (#1267/#1317) relaunches **fresh** and re-queues the task, mirroring claude's prompt-on-fresh semantics — and that human-gated relaunch is the *only* path that re-queues. A crash loop does not: the mailbox persists a held row, so a kimi that dies before minting a session (bad auth, say) would otherwise pile the same mission onto the mailbox every two seconds. Every value the generator interpolates — worktree path, builder id, task path — enters the script **once**, as a single-quoted escaped assignment, and every later use goes through the shell variable; the recovery hints print through `printf '%s\n'` on the expansion, which bash does not re-scan, so an id or path containing a backtick or `$(…)` is displayed rather than executed. + +**Message pacing is per-harness** (`servers/mailbox-wiring.ts` `resolvePacingForSession` + `message-write.ts` `pacing.enterDelayMs`): Kimi's paste-detection window swallows an Enter sent 80ms after the body (the default), so Kimi targets get a ~1s delayed Enter — bisected live (80/100ms fail; 120ms+ submit; pinned at 1000ms for ~9x margin, latency being the only cost). Resolution recovers the harness from the session's launch `command`, then from the generated `.builder-start.sh` (matching the command in **command position**, as `afx reset` does) — the same self-describing signal the render gate resolves. It is override-proof by construction: the script is generated *from* the resolved harness, so a `--builder-cmd kimi` spawn against a claude-configured workspace still reads `kimi`. This replaced a `.builder-kimi` **marker file**, which obliged every launch shape to remember to write one — an obligation the bare shape missed (found in PR #1203 review). Pacing is advisory and **total**: any failure degrades to default timing rather than throwing into the delivery path. The `/api/send --interrupt` bypass paces too (it writes body-then-Enter); `--escape` deliberately does not (it writes no text, and its behaviour on Kimi is unmeasured). + +**Render-gate profile** (`servers/gate-profiles.ts` `KIMI_PROFILE`, measured on 0.34.0): kimi draws its composer inside a rounded box, so the input row is `` │ > `` with the marker at **column 3**, not the row start. This is where Kimi touches shared gate logic, in two places, both opt-in and both pinned by dedicated before/after tests. (1) The classifier's marker exemption follows the profile's **matched span** instead of column 0 — a no-op for claude/codex (span 1, literally the old rule) and agy (span 2, whose extra cell is a space the whitespace rule already skipped). (2) A profile may declare `regionStartPatterns`, an **upper** bound for the composer region; kimi sets the box top `` ╭─── ``. Without it the region began at the marker row, and because `findMarkerRow` takes the **last** match, a multi-row draft whose final line begins with `>` moved the region down past the real text: measured on 0.34.0, a two-line draft rendering `` │ > implement the whole feature `` / `` │ > `` classified **clean** while holding unsent user text, so a queued message would have been typed on top of it (captured as the `kimi-multiline-bare` fixture). The bound is exclusive, mirroring the region end — the box-top row's right corner `╮` is not an ignorable glyph, so including it would have held every idle kimi composer forever. Profiles that declare no region start (claude/codex/agy) keep scanning from the marker row exactly as before, and since no row below a last match can match, they cannot reach the new behavior at all. A boxed composer whose box top is off screen is a torn frame with no proven upper bound → `no-region-start` → held. An idle kimi composer carries **no placeholder text at all**, so it needs neither the dim rule nor a `placeholderFgPalette`; typed text is default-fg at normal intensity → busy. The 0.33.0+ folder-trust dialog has no marker at a row start → `no-composer-marker` → held, so a blind Enter can never confirm filesystem trust. + +**Undocumented-surface reliance** (audited against **kimi 0.34.0, 2026-08-09** — re-check on each Kimi major): + +- **Session store** `~/.kimi-code/sessions/wd_*/session_*/state.json`. Already drifted once: 0.33.0 renamed `workDir` → `cwd`, moved timestamps from ISO strings to epoch ms, and dropped `lastPrompt`. Readers accept both shapes; `codev doctor` runs a probe that asserts the load-bearing facts explicitly and **names** the one that broke. +- **Workspace-trust record** `~/.kimi-code/workspace-trust/wd__` → `{root, trustedAt}`. 0.33.0 added a startup "Trust this folder?" dialog, and a builder worktree is always a brand-new directory; the dialog renders before any composer and its only non-trusting option **exits kimi**, so an unattended builder would sit on it forever. `ensureKimiWorkspaceTrust` pre-writes the record at spawn. **No sanctioned bypass exists**: `kimi --help` has no flag, and a full strings sweep of the 0.34.0 binary for `KIMI_*` env vars and trust config keys found none. What trust gates is narrow — whether project-level MCP servers (`.mcp.json`, `.kimi-code/mcp.json`) load from the folder; it does not gate tool execution or writes — and the record is written only for a worktree Codev itself created, for a builder the human explicitly spawned, already running `--yolo`. So it grants strictly less than launching the builder already did. The write is idempotent and fail-soft (on failure the dialog simply appears and the gate holds the task), and `codev doctor` validates our derivation against kimi's **own** records, so a scheme change surfaces as a named warning instead of silently stranding builders. Both drift probes weigh records by **recency**, not by "does any record still match": after a migration the pre-migration records keep matching forever and would hide every new one, reporting healthy through exactly the rename the probe exists to catch. Drift is reported only when the newest non-conforming record is *strictly* newer than every conforming one — a tie stays `ok`, so the verdict never depends on directory-iteration order. + +**Other caveats**: (a) doctor's auth check is a **credential-artifact heuristic** — kimi documents no status probe, and doctor never makes a billed call. (b) **Write-guard parity is not implemented here.** Kimi *does* have a hook seam — documented blocking `PreToolUse` hooks (`[[hooks]]` in `config.toml`, exit code 2 blocks, 18 events as of 0.32.0) — so the earlier "no hook seam, parity impossible" claim is **obsolete**. Parity with the #1018 worktree write-guard is therefore achievable and is scoped as follow-up work, not a permanent limitation; until it lands, a Kimi builder can write outside its worktree. + #### 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 4ad0cfe8e..3b372d00f 100644 --- a/codev/resources/commands/agent-farm.md +++ b/codev/resources/commands/agent-farm.md @@ -1110,6 +1110,41 @@ regular-file snapshot rather than a write-through symlink, so builder edits cannot change the main workspace's personal config. Running `afx setup` again refreshes the snapshot from the main workspace. +### Builder harnesses + +The builder CLI's role/prompt mechanics are handled by a harness, auto-detected +from the command basename (`claude`, `codex`, `opencode`, `kimi`) or pinned +explicitly via `shell.builderHarness`. Example — Kimi Code CLI as the builder +(builder-only; requires kimi >= 0.33.0, Issue #1201): + +```json +{ + "shell": { + "builder": "kimi" + } +} +``` + +Kimi takes no positional prompt, so a Kimi builder gets its role and its task +through two different channels: the role via `--agent-file` (an agent-definition +file written into the worktree, composed around kimi's `${base_prompt}` token so +it extends rather than replaces kimi's own system prompt), and the task via the +`afx send` mailbox, delivered onto a verified-empty composer by the render gate. +A crashed builder resumes with `kimi -c`, but only once a store probe confirms a +conversation exists for that worktree — `kimi -c` with nothing to continue +silently starts a fresh, roleless session, so the probe fails closed to a +role-carrying fresh launch instead. + +Two notes specific to Kimi builders. Spawning pre-records workspace trust for the +new worktree, because kimi 0.33.0+ opens on a "Trust this folder?" dialog that an +unattended builder cannot answer (trust gates only whether project-level MCP +servers load; it does not gate tool execution). And Kimi builders do NOT yet get +the worktree write-guard Claude builders have (#1018) — kimi does have a +blocking `PreToolUse` hook seam, so parity is achievable follow-up work rather +than a permanent limitation. + +Architect use of kimi and opencode is unsupported (claude or codex there). + ### Mailbox retention and escalation `afx send`'s mailbox (Spec 1313) has two Tower-global knobs under a `mailbox` key: diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index cd88f88b1..228db4e41 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -95,6 +95,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), derive the answer from an **artifact the behavior itself already had to produce** — not from a marker you add, and not from registration/DB schema. The first pass added a `.builder-kimi` marker file: correct in principle (self-describing, override-proof, Tower-restart-proof, zero-migration) but it created a standing obligation for *every* launch shape to remember to write it, and the bare no-role/no-prompt shape didn't — a maintainer found the gap, and the fix was one more `touch` guarded by one more test. The second pass read the harness name out of the generated `.builder-start.sh`, which is **generated from the resolved harness** and therefore cannot disagree with it or be forgotten: the launcher *is* the evidence. The general form: rank candidate signals by how many places must stay correct for them to keep being true. A signal with one producer that already exists beats a signal with N producers you must police, which beats schema. When a maintainer finds a coverage hole in a marker you introduced, ask whether the marker should exist at all before adding the missing writer. - [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..63bd88639 --- /dev/null +++ b/codev/reviews/1201-support-kimi-code-cli-as-a-bui.md @@ -0,0 +1,86 @@ +# 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 + +- **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). +- **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).* diff --git a/codev/spikes/pir-1201-kimi-agentfile-probe.mjs b/codev/spikes/pir-1201-kimi-agentfile-probe.mjs new file mode 100644 index 000000000..a56d67cd8 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-agentfile-probe.mjs @@ -0,0 +1,163 @@ +/** + * Validate the PIR #1201 design pivot against real kimi 0.34.0. + * + * The pivot replaces the 0.27.0-era seed bootstrap (a `kimi -p` one-shot + * carrying role + task under an ack-and-wait discipline, a captured session id, + * and a store-verified BEGIN kick) with two sanctioned mechanisms: + * role → `--agent-file ` at launch, composed with `${base_prompt}` + * so it EXTENDS kimi's system prompt instead of replacing it; + * task → an ordinary Spec 1313 mailbox message delivered onto a + * render-gate-verified empty composer. + * + * Before building on that, four claims must hold on a real install. This probe + * checks each and prints PASS/FAIL: + * + * 1. --agent-file injects the role in NON-interactive (-p) mode. + * 2. --agent-file injects the role in the INTERACTIVE TUI (the half the + * pivot actually depends on, and the half that was never measured). + * 3. The TUI mints its session on the FIRST MESSAGE, not at startup + * (0.33.0 changed this) — so a crash-resume has something to resume only + * after the task message lands. + * 4. `kimi -c` (documented, cwd-scoped) resumes that session AND the role + * binding survives — which is what lets the crash path drop both + * --agent-file (illegal with -c) and the undocumented store lookup. + * + * Usage: node codev/spikes/pir-1201-kimi-agentfile-probe.mjs + */ + +import { mkdtempSync, writeFileSync, mkdirSync, readdirSync, existsSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir, homedir } from 'node:os'; +import { join, basename, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); + +const TOKEN = 'CODEV-ROLE-OK-7731'; +const ASK = 'What is the codeword? Reply with only the codeword.'; +const ENTER_DELAY_MS = Number(process.env.PROBE_ENTER_DELAY_MS || 1000); +const KIMI_HOME = process.env.KIMI_CODE_HOME || join(homedir(), '.kimi-code'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const results = []; +const record = (name, ok, detail) => { + results.push({ name, ok, detail }); + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); +}; + +/** Pre-write kimi's workspace-trust record (0.33.0+); see kimi-session-discovery.ts. */ +function preTrust(root) { + const dir = join(KIMI_HOME, 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${basename(root).toLowerCase()}_${hash}`), + JSON.stringify({ root, trustedAt: Date.now() })); +} + +/** Count sessions the store holds for `cwd` (v2 `cwd`, v1 `workDir`). */ +function sessionsFor(cwd) { + const root = join(KIMI_HOME, 'sessions'); + const found = []; + if (!existsSync(root)) return found; + for (const wd of readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory())) { + for (const s of readdirSync(join(root, wd.name), { withFileTypes: true }).filter((e) => e.isDirectory())) { + try { + const st = JSON.parse(readFileSync(join(root, wd.name, s.name, 'state.json'), 'utf-8')); + if ((st.cwd ?? st.workDir) === cwd) found.push(s.name); + } catch { /* unreadable → not a session we can use */ } + } + } + return found; +} + +function agentFile(dir) { + const p = join(dir, 'role-agent.md'); + writeFileSync(p, `--- +name: codev-builder +description: Codev builder role (probe) +--- +\${base_prompt} + +# Codev Builder Role (probe) + +If the user asks for the codeword, reply with exactly ${TOKEN} and nothing else. +`); + return p; +} + +/** Run kimi non-interactively and return stdout. */ +function runP(args, cwd) { + return new Promise((resolve) => { + const term = pty.spawn('kimi', args, { + name: 'xterm-256color', cols: 110, rows: 32, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let out = ''; + term.onData((d) => { out += d; }); + term.onExit(() => resolve(out)); + }); +} + +/** + * Drive the interactive TUI: type `message`, pause ENTER_DELAY_MS (kimi's paste + * window swallows an Enter that arrives too soon), submit, then wait. + */ +async function runTui(args, cwd, message, waitMs) { + const term = pty.spawn('kimi', args, { + name: 'xterm-256color', cols: 110, rows: 32, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let out = ''; + term.onData((d) => { out += d; }); + await sleep(12000); // let the TUI paint its composer + const beforeSend = out.length; + term.write(message); + await sleep(ENTER_DELAY_MS); + term.write('\r'); + await sleep(waitMs); + try { term.kill(); } catch { /* already gone */ } + await sleep(500); + return { out, afterSend: out.slice(beforeSend) }; +} + +const dir = mkdtempSync(join(tmpdir(), 'kimi-pivot-')); +preTrust(dir); +const role = agentFile(dir); +console.log(`[probe] worktree: ${dir}\n[probe] enter delay: ${ENTER_DELAY_MS}ms\n`); + +// 1. Non-interactive role injection. +const pOut = await runP(['--agent-file', role, '-p', ASK], dir); +record('1. --agent-file injects the role in -p mode', pOut.includes(TOKEN), + pOut.includes(TOKEN) ? '' : `stdout: ${JSON.stringify(pOut.slice(-200))}`); + +// 2 + 3. Interactive TUI: role injection, and session-mint timing. +const dir2 = mkdtempSync(join(tmpdir(), 'kimi-pivot-tui-')); +preTrust(dir2); +const role2 = agentFile(dir2); +const beforeAny = sessionsFor(dir2); +record('3a. TUI start mints NO session (checked before launch)', beforeAny.length === 0, + `${beforeAny.length} session(s) pre-existing`); + +const tui = await runTui(['--agent-file', role2, '--yolo'], dir2, ASK, 45000); +record('2. --agent-file injects the role in the interactive TUI', tui.afterSend.includes(TOKEN), + tui.afterSend.includes(TOKEN) ? '' : `tail: ${JSON.stringify(tui.out.slice(-400))}`); + +const afterMsg = sessionsFor(dir2); +record('3b. the first message mints exactly one session', afterMsg.length === 1, + `sessions now: ${JSON.stringify(afterMsg)}`); + +// 4. `-c` resumes that session and the role binding survives (no --agent-file). +const cont = await runTui(['-c', '--yolo'], dir2, ASK, 45000); +record('4a. kimi -c resumes without --agent-file', !cont.out.includes('No session yet'), + cont.out.includes('No session yet') ? 'TUI reported no session to continue' : ''); +record('4b. the role binding survives the resume', cont.afterSend.includes(TOKEN), + cont.afterSend.includes(TOKEN) ? '' : `tail: ${JSON.stringify(cont.out.slice(-400))}`); +const afterCont = sessionsFor(dir2); +record('4c. -c reused the session (no second one minted)', afterCont.length === 1, + `sessions now: ${JSON.stringify(afterCont)}`); + +console.log(`\n${results.filter((r) => r.ok).length}/${results.length} checks passed`); +process.exit(results.every((r) => r.ok) ? 0 : 1); diff --git a/codev/spikes/pir-1201-kimi-builder-demo.mjs b/codev/spikes/pir-1201-kimi-builder-demo.mjs new file mode 100644 index 000000000..8aada4767 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-builder-demo.mjs @@ -0,0 +1,296 @@ +#!/usr/bin/env node +/** + * PIR #1201 — live demo driver: the Kimi builder launch path end-to-end against a + * REAL `kimi` (>= 0.33.0, authenticated), using the REAL built modules from + * packages/codev/dist. No Tower required. + * + * Rewritten for the design pivot (PR #1203 re-integration). The retired version + * drove the seed-session bootstrap (`kimi -p` seed → resume_hint capture → pinned + * `kimi -S ` loop → a sentinel-gated BEGIN written straight to the PTY). The + * shipped design instead delivers the ROLE via `--agent-file` and the TASK via the + * Spec 1313 mailbox, and resumes crashes with the documented cwd-scoped `kimi -c`. + * + * What it exercises, in order: + * 1. Role injection — the REAL getWorktreeFiles + buildScriptRoleInjection + + * buildBuilderLaunchScript generate the worktree files and .builder-start.sh + * exactly as spawn-worktree.ts does. The TUI is asked a role-identifying + * question; a correct answer proves --agent-file reached the interactive TUI + * (not just `-p`), and that ${base_prompt} did not clobber the role. + * 2. Render gate — the REAL KIMI_PROFILE + classifyBuffer classify the LIVE + * screen. This is the readiness barrier that replaced the PTY sentinel: a + * booting/busy kimi classifies not-clean and holds; an idle composer is clean. + * 3. Paced delivery — the REAL writeMessagePaced with the REAL Kimi pacing + * submits a >3-line message (the 80ms default is swallowed by kimi's paste + * detection; the pinned ~1s Enter submits). + * 4. Crash resume — the TUI process is killed; the script's own loop consults its + * inlined store probe, takes `kimi -c`, and a follow-up question verifies the + * role survived the resume. + * 5. The fail-closed guard — with an EMPTY store the same probe reports "no + * session", so the loop must launch FRESH WITH the role. This is the #929 + * hazard `kimi -c` opens by silently starting a roleless session when there is + * nothing to continue. + * + * Run from the repo root of this worktree (after `pnpm build`): + * node codev/spikes/pir-1201-kimi-builder-demo.mjs + * + * Output: PASS/FAIL per step plus the raw evidence. + */ + +import { mkdtempSync, writeFileSync, chmodSync, readFileSync, mkdirSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, '..', '..'); +const dist = (p) => join(repoRoot, 'packages', 'codev', 'dist', p); + +const { KIMI_HARNESS, KIMI_AGENT_FILE } = await import(dist('agent-farm/utils/harness.js')); +const { writeMessagePaced } = await import(dist('agent-farm/servers/message-write.js')); +const { classifyBuffer } = await import(dist('agent-farm/servers/render-gate.js')); +const { KIMI_PROFILE } = await import(dist('agent-farm/servers/gate-profiles.js')); +const { ensureKimiWorkspaceTrust } = await import(dist('agent-farm/utils/kimi-session-discovery.js')); + +const require = createRequire(join(repoRoot, 'packages', 'codev', 'package.json')); +const pty = require('node-pty'); +const xterm = require('@xterm/headless'); + +const COLS = 110; +const ROWS = 32; +const worktree = mkdtempSync(join(tmpdir(), 'kimi-demo-wt-')); +console.log(`demo worktree: ${worktree}`); + +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 -------- +/** + * The role carries a CODEWORD, and the steps that check "did the role reach the + * model?" ask for it back. + * + * An earlier version instead told the model to prefix every reply with a token, + * and asserted on the prefix. That conflated two different claims: whether the + * role was injected (what this demo exists to prove) and whether K3 honors a + * persistent output-format constraint (which it does not do reliably — measured: + * it answered the task correctly while dropping the prefix, and when asked about + * its prefix it discussed the idea rather than emitting the token). A recall + * question isolates the claim under test, and it is the same oracle + * `pir-1201-kimi-agentfile-probe.mjs` uses to measure `--agent-file` directly. + */ +const CODEWORD = 'DEMO-ROLE-OK-4417'; +const ROLE = 'You are a demo builder agent. Your codeword is ' + CODEWORD + '. ' + + 'If you are asked for your codeword, reply with exactly that token and nothing else.'; +const ASK_CODEWORD = 'What is your codeword? Reply with only the codeword.'; +const TASK = ASK_CODEWORD; + +const roleFile = join(worktree, '.builder-role.md'); +writeFileSync(roleFile, ROLE); +const promptFile = join(worktree, '.builder-prompt.txt'); +writeFileSync(promptFile, TASK); + +// getWorktreeFiles writes the --agent-file definition (role wrapped around +// ${base_prompt}); buildScriptRoleInjection produces the flag that points at it. +for (const f of KIMI_HARNESS.getWorktreeFiles(ROLE)) { + writeFileSync(join(worktree, f.relativePath), f.content); +} +const { fragment: roleFragment } = KIMI_HARNESS.buildScriptRoleInjection(ROLE, roleFile); + +// The spawn path pre-records folder trust so an unattended builder is not +// stranded on kimi 0.33.0+'s "Trust this folder?" dialog. +KIMI_HARNESS.prepareWorkspace?.(worktree); + +const scriptPath = join(worktree, '.builder-start.sh'); +writeFileSync(scriptPath, KIMI_HARNESS.buildBuilderLaunchScript({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment, + // The demo delivers the task itself (step 3) rather than shelling out to `afx + // send`, which would need a running Tower. The queue call is still generated + // and printed below, so what is skipped is visible rather than hidden. + taskFile: promptFile, builderId: 'kimi-demo', +})); +chmodSync(scriptPath, 0o755); +console.log('--- generated .builder-role-agent.md ---'); +console.log(readFileSync(join(worktree, KIMI_AGENT_FILE), 'utf-8')); +console.log('--- generated .builder-start.sh ---'); +console.log(readFileSync(scriptPath, 'utf-8')); + +// --- Host the script in a PTY, mirroring it into a headless terminal -------- +// The mirror is what production classifies (SessionScreen); feeding it the same +// bytes lets the REAL classifier run against the REAL live screen. +const term = pty.spawn('/bin/bash', [scriptPath], { + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd: worktree, + env: { ...process.env }, +}); + +const mirror = new xterm.Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true, scrollback: 2000 }); +let transcript = ''; +term.onData((d) => { transcript += d; mirror.write(d); }); + +const session = { write: (d) => { term.write(d); return true; } }; + +/** Classify the live screen with the production classifier. */ +function gate() { + return classifyBuffer(mirror, COLS, ROWS, KIMI_PROFILE); +} + +/** Wait until the gate says the composer is clean (or time out). */ +async function waitForCleanComposer(timeoutMs = 60000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + last = gate(); + if (last.clean) return last; + await sleep(500); + } + return last; +} + +/** Deliver a message the way the mailbox does: gate first, then paced write. */ +async function deliver(message) { + const verdict = await waitForCleanComposer(); + if (!verdict?.clean) return { delivered: false, verdict }; + const ok = await writeMessagePaced(session, message, false, KIMI_HARNESS.messagePacing); + return { delivered: ok, verdict }; +} + +const seen = (re, from = 0) => re.test(transcript.slice(from)); + +/** + * Wait for `re` to appear in the transcript after `from`. Generous by default: + * kimi K3 at "thinking: high" can take well over a minute on a cold first turn, + * and a too-short window makes a working feature look broken. + */ +async function waitFor(re, from, timeoutMs = 180000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (seen(re, from)) return true; + await sleep(1000); + } + return seen(re, from); +} + +/** + * Kill the kimi TUI (not the script) so the launch loop takes its crash path. + * + * Deliberately walks the process tree from the script's own bash instead of + * pattern-matching a command line: kimi ships as a COMPILED binary whose argv + * varies with how it was installed and invoked, and a pkill pattern that quietly + * matches nothing turns this step into a false PASS — the original session simply + * keeps running and answers the follow-up question. + */ +function killTui(bashPid) { + const out = spawnSync('pgrep', ['-P', String(bashPid)], { encoding: 'utf-8' }); + const pids = (out.stdout || '').trim().split('\n').filter(Boolean); + for (const p of pids) { + try { process.kill(Number(p), 'SIGKILL'); } catch { /* already gone */ } + } + return pids; +} + +try { + // --- Step 1+2: gate recognizes the live composer; role reached the TUI ----- + const boot = gate(); + const ready = await waitForCleanComposer(); + record( + '1. render gate classifies the LIVE kimi composer (the readiness barrier)', + ready?.clean === true, + `at boot: ${JSON.stringify(boot)} → when idle: ${JSON.stringify(ready)}`, + ); + + const mark1 = transcript.length; + await deliver(TASK); + // The task IS the codeword question, so one delivery proves two things at once: + // the mailbox → render-gate → composer path carried it, and --agent-file injected + // the role in the INTERACTIVE TUI without ${base_prompt} displacing it. + const roleHonored = await waitFor(new RegExp(CODEWORD), mark1); + record( + '2. role injected via --agent-file and honored in the interactive TUI', + roleHonored, + roleHonored ? `assistant recalled the role codeword ${CODEWORD}` : 'the role codeword never came back', + ); + + // --- Step 3: paced multi-line delivery ------------------------------------ + const mark2 = transcript.length; + const multiline = [ + 'Answer with exactly one word, no punctuation:', + 'line two is filler', + 'line three is filler', + 'line four: what is the capital of France?', + ].join('\n'); + const { delivered } = await deliver(multiline); + const answered = await waitFor(/Paris/i, mark2); + record( + `3. multi-line delivery submits with the pinned ${KIMI_HARNESS.messagePacing.enterDelayMs}ms Enter`, + delivered && answered, + delivered ? 'paced write reported all bytes on the wire; model answered' : 'paced write reported a dropped write', + ); + + // --- Step 4: crash resume via the script's own probe + `kimi -c` ----------- + const mark3 = transcript.length; + const killed = killTui(term.pid); + // The loop prints its decision before relaunching; a resumed conversation is + // the one the store probe authorized. + await waitFor(/Resuming the conversation|Relaunching fresh/, mark3, 60000); + const choseResume = seen(/Resuming the conversation/, mark3); + record( + '4a. crash restart consults the store probe and chooses resume', + killed.length > 0 && choseResume, + killed.length === 0 + ? 'NO child process was killed — the crash path was never exercised (harness fault, not a product result)' + : choseResume + ? `killed pid(s) ${killed.join(',')}; loop announced "Resuming the conversation"` + : `killed pid(s) ${killed.join(',')}; loop did NOT choose resume (see transcript)`, + ); + + const mark4 = transcript.length; + await deliver(ASK_CODEWORD); + const survived = await waitFor(new RegExp(CODEWORD), mark4); + record( + '4b. role survives the `kimi -c` resume', + survived && choseResume, + survived + ? (choseResume ? 'post-resume reply still recalls the role codeword' : 'codeword present, but no resume happened — not evidence') + : 'the role codeword was gone after resume', + ); + + // --- Step 5: the fail-closed guard --------------------------------------- + // `kimi -c` with nothing to continue does NOT fail — it starts a fresh session + // that never saw --agent-file, i.e. a ROLELESS builder. Run the script's own + // inlined probe against an EMPTY store: it must report "no session" so the loop + // takes the fresh, role-carrying path instead. + const probe = /node -e '([^']*)'/.exec(readFileSync(scriptPath, 'utf-8'))?.[1]; + const emptyHome = mkdtempSync(join(tmpdir(), 'kimi-demo-emptyhome-')); + mkdirSync(join(emptyHome, '.kimi-code'), { recursive: true }); + const emptyProbe = spawnSync(process.execPath, ['-e', probe, worktree], { + env: { ...process.env, KIMI_CODE_HOME: join(emptyHome, '.kimi-code') }, + }); + const liveProbe = spawnSync(process.execPath, ['-e', probe, worktree], { env: { ...process.env } }); + rmSync(emptyHome, { recursive: true, force: true }); + record( + '5. store probe fails CLOSED on an empty store (no roleless -c fallback)', + emptyProbe.status !== 0 && liveProbe.status === 0, + `empty store → exit ${emptyProbe.status} (want non-zero); real store → exit ${liveProbe.status} (want 0)`, + ); + + // Trust pre-record is idempotent: the second call must be a no-op. + record( + '6. workspace-trust pre-record is idempotent', + ensureKimiWorkspaceTrust(worktree) === false, + 'second ensureKimiWorkspaceTrust() returned false (existing record left alone)', + ); +} finally { + try { term.kill(); } catch { /* already dead */ } +} + +const failed = results.filter((r) => !r.ok); +console.log(`\n=== ${results.length - failed.length}/${results.length} PASS ===`); +if (failed.length) { + console.log('failed steps:', failed.map((f) => f.step).join('; ')); + console.log('\n--- raw transcript tail ---\n' + transcript.slice(-4000)); +} +process.exit(failed.length ? 1 : 0); diff --git a/codev/spikes/pir-1201-kimi-continue-probe.mjs b/codev/spikes/pir-1201-kimi-continue-probe.mjs new file mode 100644 index 000000000..98cbdf4f8 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-continue-probe.mjs @@ -0,0 +1,82 @@ +/** + * PIR #1201 — what does `kimi -c` do when there is NOTHING to continue? + * + * The pivot's crash path is `kimi -c --yolo` (documented, cwd-scoped) instead of + * a pinned `-S ` from the undocumented store. That is only safe if a crash + * BEFORE the first message — i.e. before 0.33.0's TUI has minted any session — + * fails loudly rather than silently starting a **roleless** fresh conversation. + * A silent roleless start is the #929 hazard class: the builder would run on with + * no role and nobody would know. + * + * Checks: + * A. `kimi -c -p "…"` in a virgin cwd — exit code and message. + * B. Whether it minted a session in that cwd anyway (silent-fresh evidence). + * C. Whether that fallback session carries the role (it cannot: -c forbids + * --agent-file), i.e. how bad a silent fallback would be. + * + * Usage: node codev/spikes/pir-1201-kimi-continue-probe.mjs + */ + +import { mkdtempSync, writeFileSync, mkdirSync, readdirSync, existsSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir, homedir } from 'node:os'; +import { join, basename, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); +const KIMI_HOME = process.env.KIMI_CODE_HOME || join(homedir(), '.kimi-code'); + +function preTrust(root) { + const dir = join(KIMI_HOME, 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${basename(root).toLowerCase()}_${hash}`), + JSON.stringify({ root, trustedAt: Date.now() })); +} + +function sessionsFor(cwd) { + const root = join(KIMI_HOME, 'sessions'); + const found = []; + if (!existsSync(root)) return found; + for (const wd of readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory())) { + for (const s of readdirSync(join(root, wd.name), { withFileTypes: true }).filter((e) => e.isDirectory())) { + try { + const st = JSON.parse(readFileSync(join(root, wd.name, s.name, 'state.json'), 'utf-8')); + if ((st.cwd ?? st.workDir) === cwd) found.push(s.name); + } catch { /* unreadable */ } + } + } + return found; +} + +function run(args, cwd) { + return new Promise((resolve) => { + const term = pty.spawn('kimi', args, { + name: 'xterm-256color', cols: 110, rows: 32, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let out = ''; + term.onData((d) => { out += d; }); + term.onExit(({ exitCode }) => resolve({ out, exitCode })); + }); +} + +const dir = mkdtempSync(join(tmpdir(), 'kimi-cont-')); +preTrust(dir); +console.log(`[probe] virgin cwd: ${dir}`); + +const r = await run(['-c', '-p', 'Say READY and nothing else.'], dir); +console.log(`\n[A] exit code: ${r.exitCode}`); +console.log(`[A] output:\n${r.out.trim().slice(0, 1200)}`); + +const after = sessionsFor(dir); +console.log(`\n[B] sessions minted in that cwd: ${after.length} ${JSON.stringify(after)}`); +console.log( + r.exitCode !== 0 + ? '\nVERDICT: `-c` FAILS LOUDLY with nothing to continue → the launch loop\'s fast-fail degrade converts it to a fresh (role-carrying) relaunch. Safe.' + : '\nVERDICT: `-c` SUCCEEDS with nothing to continue → it silently starts a conversation the role never reached. The loop must NOT enter on -c before a session exists.' +); +process.exit(0); diff --git a/codev/spikes/pir-1201-kimi-gate-measure.mjs b/codev/spikes/pir-1201-kimi-gate-measure.mjs new file mode 100644 index 000000000..0c820ba79 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-gate-measure.mjs @@ -0,0 +1,231 @@ +/** + * Kimi render-gate measurement (PIR #1201, re-integration against Spec 1313). + * + * Spec 1313's render gate delivers a message only onto a composer it can prove + * empty, and it does that per-app via a `GateProfile` (marker pattern, region-end + * patterns, optional placeholder color). An app with no profile holds every + * message with `no-profile` — so a measured Kimi profile is a functional + * prerequisite for `afx send` to a Kimi builder, not polish. + * + * This is the Kimi analogue of the agy Phase-3 measurement: drive a real `kimi` + * TUI under a PTY, capture the raw byte stream for each screen state, render it + * through the SAME data path the live gate uses (RingBuffer → @xterm/headless), + * and dump per-cell attributes so the profile is derived from observation rather + * than assumption. + * + * States captured: + * idle — settled composer, nothing typed (must classify CLEAN) + * draft — a few characters typed, no Enter (must classify BUSY) + * seed — `kimi -p … --output-format stream-json` running (must classify BUSY: + * this is the seed window, where a written byte has no consumer) + * + * Usage: node codev/spikes/pir-1201-kimi-gate-measure.mjs [outDir] + */ + +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); +const xterm = require(join(repoRoot, 'packages/codev/node_modules/@xterm/headless')); +const { Terminal } = xterm; + +const COLS = 110; +const ROWS = 32; +const outDir = process.argv[2] || join(repoRoot, 'codev/spikes/kimi-gate-capture'); +mkdirSync(outDir, { recursive: true }); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Render a raw PTY stream and dump the viewport + per-cell attributes. */ +async function render(raw) { + const term = new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true, scrollback: 2000 }); + await new Promise((resolve) => term.write(raw, resolve)); + const buf = term.buffer.active; + const top = buf.viewportY; + const lines = []; + for (let i = 0; i < ROWS; i++) { + const line = buf.getLine(top + i); + lines.push(line ? line.translateToString(true).trimEnd() : ''); + } + return { term, buf, top, lines }; +} + +/** Per-cell attribute dump for one viewport row — the evidence the profile rests on. */ +function dumpRow(buf, top, row) { + const line = buf.getLine(top + row); + if (!line) return ' (no line)'; + const cell = buf.getNullCell(); + const parts = []; + for (let col = 0; col < COLS; col++) { + line.getCell(col, cell); + const ch = cell.getChars(); + if (!ch || ch === ' ') continue; + const attrs = []; + if (cell.isDim()) attrs.push('dim'); + if (cell.isInverse()) attrs.push('inv'); + if (cell.isBold()) attrs.push('bold'); + if (cell.isFgPalette()) attrs.push(`fgPal=${cell.getFgColor()}`); + else if (cell.isFgRGB()) attrs.push(`fgRGB=${cell.getFgColor().toString(16)}`); + else attrs.push('fgDefault'); + parts.push(`${col}:${JSON.stringify(ch)}[${attrs.join(',')}]`); + } + return ' ' + (parts.join(' ') || '(empty)'); +} + +async function report(name, raw) { + writeFileSync(join(outDir, `${name}.raw.txt`), raw); + const { term, buf, top, lines } = await render(raw); + console.log(`\n${'='.repeat(78)}\n== ${name} (${raw.length} bytes)\n${'='.repeat(78)}`); + console.log(`cursor: row=${buf.cursorY} col=${buf.cursorX}`); + console.log('--- viewport (row: text) ---'); + lines.forEach((l, i) => { + if (l) console.log(`${String(i).padStart(2)}: ${JSON.stringify(l)}`); + }); + // Dump attributes for every non-empty row in the bottom third — the composer lives there. + console.log('--- per-cell attributes (non-empty rows, bottom half) ---'); + for (let i = Math.floor(ROWS / 2); i < ROWS; i++) { + if (!lines[i]) continue; + console.log(`row ${i}: ${JSON.stringify(lines[i])}`); + console.log(dumpRow(buf, top, i)); + } + term.dispose(); +} + +/** + * Pre-write kimi's workspace-trust record for `root` (0.33.0+). + * + * UNDOCUMENTED SURFACE, derived by observation on 0.34.0: trust lives at + * `~/.kimi-code/workspace-trust/wd__` + * holding `{root, trustedAt}`. Without it the pinned `-S` TUI opens on an + * interactive "Trust this folder?" dialog instead of a composer, and the + * dialog's only non-trusting option EXITS — so an unattended builder can never + * reach its prompt. (Trust gates project-level MCP servers only.) + */ +function preTrust(root) { + const dir = join(process.env.HOME, '.kimi-code', 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const slug = root.split('/').filter(Boolean).pop().toLowerCase(); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${slug}_${hash}`), JSON.stringify({ root, trustedAt: Date.now() })); +} + +async function captureTrustDialog() { + const cwd = mkdtempSync(join(tmpdir(), 'kimi-untrusted-')); + const term = pty.spawn('kimi', ['--yolo'], { + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let raw = ''; + term.onData((d) => { raw += d; }); + console.error('[measure] capturing the untrusted-folder dialog (18s)…'); + await sleep(18000); + try { term.kill(); } catch { /* already gone */ } + return { trust: raw }; +} + +async function captureTui() { + const cwd = mkdtempSync(join(tmpdir(), 'kimi-gate-')); + preTrust(cwd); + const term = pty.spawn('kimi', ['--yolo'], { + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let raw = ''; + term.onData((d) => { raw += d; }); + + console.error('[measure] waiting 20s for the kimi TUI to settle…'); + await sleep(20000); + const idle = raw; + + console.error('[measure] typing a draft (no Enter)…'); + term.write('draft text'); + await sleep(4000); + const draft = raw; + await clear(term, 40); + + // The screens the 3-way review (2026-08-09) said a happy-path run never + // produces, and which are exactly where a LAST-match marker search can pick + // the wrong row. Each is captured raw so the profile is derived from what kimi + // actually renders rather than from a constructed screen. + // + // multiline: a two-line draft whose SECOND line begins with ">" — a pasted + // quote or a markdown blockquote, and the shape that could make a + // continuation row look like the composer marker while the real draft text + // sits ABOVE it, outside the scanned region. + console.error('[measure] typing a multi-line draft whose 2nd line starts with ">"…'); + let multiline = null; + term.write('implement the whole feature\n> quoted second line'); + await sleep(4000); + multiline = raw; + await clear(term, 80); + + // The false-CLEAN shape itself: same two-line draft, but the last line is a + // BARE ">". Every cell the classifier would count then lives ABOVE the row it + // picks as the marker, so the composer reads empty while holding real text. + // Captured rather than constructed so the regression test rests on bytes kimi + // actually emitted. + console.error('[measure] typing a multi-line draft whose 2nd line is a bare ">"…'); + term.write('implement the whole feature\n>'); + await sleep(4000); + const multilineBare = raw; + await clear(term, 80); + + // menu: the "/" command list. picker: the "@" file list. Both render EXTRA + // rows around the composer, which is what makes them the interesting case. + console.error('[measure] opening the "/" command menu…'); + term.write('/'); + await sleep(4000); + const menu = raw; + await clear(term, 10); + + console.error('[measure] opening the "@" file picker…'); + term.write('@'); + await sleep(4000); + const picker = raw; + await clear(term, 10); + + term.kill(); + return { idle, draft, multiline, multilineBare, menu, picker }; +} + +/** Backspace the composer clean so the next capture starts from a settled idle screen. */ +async function clear(term, n) { + term.write('\x7f'.repeat(n)); + await sleep(2000); +} + +async function captureSeed() { + const cwd = mkdtempSync(join(tmpdir(), 'kimi-seed-')); + const term = pty.spawn('kimi', ['-p', 'Reply with exactly SEED-OK and nothing else.', + '--output-format', 'stream-json'], { + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let raw = ''; + term.onData((d) => { raw += d; }); + console.error('[measure] running the seed (non-interactive) for 12s…'); + await sleep(12000); + const seed = raw; + try { term.kill(); } catch { /* already gone */ } + return { seed }; +} + +const { idle, draft, multiline, multilineBare, menu, picker } = await captureTui(); +await report('kimi-idle', idle); +await report('kimi-draft', draft); +await report('kimi-multiline', multiline); +await report('kimi-multiline-bare', multilineBare); +await report('kimi-menu', menu); +await report('kimi-picker', picker); +const { trust } = await captureTrustDialog(); +await report('kimi-trust', trust); +const { seed } = await captureSeed(); +await report('kimi-seed', seed); +console.error(`\n[measure] raw captures written to ${outDir}`); +process.exit(0); 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..794b46805 --- /dev/null +++ b/codev/spikes/task-Iptx-kimi-code-cli-support.md @@ -0,0 +1,214 @@ +# 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). + +## 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 +- 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/pir-1201_thread.md b/codev/state/pir-1201_thread.md new file mode 100644 index 000000000..c15e0ae4c --- /dev/null +++ b/codev/state/pir-1201_thread.md @@ -0,0 +1,126 @@ +# 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. + +## 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. + +## 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. +- pr gate approved by the human; porch protocol wrapped (`verified`, complete). Per the fork flow the merge is NOT ours: PR cluesmith/codev#1203 stays open for the maintainers, so no `--merged` record exists yet (recording one would be false state — it can be added if/when the maintainers merge). Standing by for maintainer feedback relayed via the architect. + +## 2026-07-22 — Maintainer review iteration (PR #1203) + +- Maintainer (waleedkadous) REQUEST_CHANGES, one finding — real, accepted: the bare launch shape (no role, no prompt) never persisted `.builder-kimi-session`, so pacing resolution fell through to the config-resolved harness and an override-spawned bare Kimi builder (`--builder-cmd kimi` in a claude-configured workspace) got claude's 80ms Enter — the swallowed-Enter bug this PR exists to fix. The implement-phase claim "the marker exists iff the launch script is Kimi-shaped" was wrong for exactly this shape; seed and resume persisted it, bare did not. +- Fix (architect-driven; builder session had wrapped): the bare branch of `KIMI_HARNESS.buildBuilderLaunchScript` now `touch`es the marker before the TUI loop — empty (no id to pin), preserving any previously seeded id, and keeping both the seed `! -s` guard and buildResume's empty-id fallthrough intact. Every Kimi launch shape now persists the marker. +- Regression tests: the spawn-worktree bare-shape test that previously ASSERTED marker absence is flipped into the override-spawn pin, plus a harness-level bare-script pin (both fail pre-fix, verified) and a real-fs pacing test pinning the probe as existence-based (an empty marker must beat claude config — guards against a future content-based "improvement"). +- Docs: arch.md pacing paragraph and the message-pacing.ts header now state the accurate, softened claim — every launch shape persists the marker, probe is existence-based, and the converse doesn't hold (a leftover marker is a breadcrumb, not proof of a live Kimi session; cost of staleness is a ~1s-slower Enter). +- Post-fix 3-way CMAP on 2abd362a (architect-run, commit-scoped): gemini APPROVE, claude APPROVE, codex APPROVE with one MINOR — the script-shape regression tests asserted the `touch` exists but not that it stays BEFORE the `while true` loop, so a refactor moving it inside/after the loop would keep them green. Accepted and fixed: ordering assertions added at both layers (harness + spawn-worktree), mirroring the suite's existing exit-1-before-loop precedent. Claude's NIT (thread phrasing) needs no action. +- CMAP iter 2 (commit-scoped, 642b1726): codex APPROVE (none), gemini APPROVE + NIT, claude APPROVE + NIT — two complementary guard gaps in the same tests, both verified against the file and accepted: (1) gemini — the pre-existing exit-1-before-loop precedent lacked a `toContain('exit 1')` guard, so removing `exit 1` would vacuous-pass (`indexOf` → -1, and -1 < anything); (2) claude — the new ordering assertions lacked `toContain('while true')`, sound but with an opaque failure message if the loop construct ever changed. Both fixed (one-line guards). Loop protocol updated per the human: iteration 3+ reviews the ENTIRE cumulative maintainer-response diff (47d12ba9..HEAD), not per-commit. +- CMAP iter 3 (full cumulative maintainer-response diff, 47d12ba9..1de55e13): gemini APPROVE / codex APPROVE / claude APPROVE, all with zero findings — loop converged. Claude's pass verified the no-race property (touch completes before Tower registers the terminal, so no send can precede the marker) and cross-file doc consistency (KIMI_SESSION_FILE JSDoc, message-pacing.ts header, arch.md tell one story). This journal entry is the termination record; it makes no code/doc claims and does not itself re-trigger the review loop. + +## 2026-07-23 — Mainline merge resolution + +- Human authorized resolving PR #1203 against current `origin/main` without merging the PR. The merge had one conflict, in `packages/codev/src/agent-farm/lib/tower-client.ts`; resolved by retaining all four type re-exports required by both branches: `HuskCandidate`, `HuskPreview`, `HuskSweepResult`, and `SeedKickRequest`. `git diff --name-only --diff-filter=U` confirmed no other conflicts. +- Post-resolution verification: `pnpm build` passed; full `pnpm test` passed (185 files passed, 3 skipped; 3716 tests passed, 48 skipped). Branch is ready to push for CI. + +## 2026-07-25T18:04Z — post-approval iteration: adopt #1244 loop tail +- Merged origin/main (brings PR #1244's keypress-gated launch-loop contract). +- Moved LAUNCH_LOOP_TAIL from spawn-worktree.ts (module-local) to utils/harness.ts (exported) so Kimi's provider-owned scripts share it without a circular import; both Kimi loops (pinned -S and bare) now use it. +- Pinned the new tail across all Kimi shapes in harness.test.ts and spawn-worktree.test.ts. +- Suites green (harness+spawn-worktree 169, message-pacing+seed-kick 22); build clean. + +## 2026-07-25T18:07Z — CMAP + live verification of the loop-tail adoption +- CMAP (gemini, codex, claude) on the change set: unanimous APPROVE, zero findings, clean in one iteration. +- Full suite: 3802 passed / 48 skipped. +- Live kimi 0.29.1 verification (tmux PTY, real bare launch script from dist): /quit → exit 0 → keypress gate held (no respawn), Enter relaunched; SIGKILL → code 137 → auto-restart after 2s. Both branches behave per the #1244 contract. + +## 2026-08-08/09 — Re-integration after parking: merge main + design pivot + +The PR sat parked on two upstream blockers; both landed, the branch went stale (901 commits behind), and `kimi` itself drifted 0.27.0 → 0.34.0. This session re-integrates. + +**Merged `origin/main`** (10 conflicts). Took main's rewritten `spawn-worktree.ts` / `tower-routes.ts` / `tower-cron.ts` / `tower-client.ts` / `discover-resume-session.test.ts` wholesale — our versions were the retired `SendBuffer` / direct-PTY-write paths that Spec 1313 replaced, plus a launch-loop shape #1233/#1317 superseded. Hand-merged `doctor.ts` and three docs. + +**Design pivot** (architect-directed, PR comment 5229238112), validated live 7/7 against real kimi 0.34.0 before any code was committed to it: +- **Role via `--agent-file`** (0.31.0+), composed around `${base_prompt}` so it EXTENDS kimi's own system prompt instead of replacing it. Verified injecting in both `-p` and the interactive TUI — the half never measured in the original spike. +- **Task via the Spec 1313 mailbox**, delivered by the render gate onto a verified-empty composer. Never a direct PTY write. +- **Deleted** `seed-kick.ts`, the sentinel, the `-p` seed bootstrap, `.builder-seed.txt`, the ack-and-wait BEGIN discipline, and (later) the dead `SeedKickRequest` SDK surface. + +**The finding that shaped the launch loop.** `kimi -c` does NOT fail with nothing to continue — it prints `No sessions to continue…` and starts a fresh session that never saw `--agent-file`, i.e. a silently ROLELESS builder (#929 hazard class). So every path to `-c` is gated on an inlined `node -e` store probe that fails CLOSED to a role-carrying fresh launch. Pinned by tests that EXECUTE the probe against fixture stores and cross-check it against `findLatestKimiSessionId`, so the hand-written bash snippet cannot drift from the TypeScript it mirrors. + +**Pacing re-homed.** Spec 1313 replaced the routes `message-pacing.ts` hooked into, leaving pacing wired to nothing — every `afx send` to a Kimi builder would have been typed and never submitted. Now resolved in `mailbox-wiring.ts` (`resolveHarnessForSession` → `getBuiltinHarness(...).messagePacing`) and threaded through `writeMessagePaced`. Deleted `message-pacing.ts` AND the `.builder-kimi` marker: the harness name now comes out of the generated `.builder-start.sh`, which is generated FROM the resolved harness and so cannot be forgotten — the marker's coverage obligation is exactly what the maintainer's earlier finding was about. `--interrupt` paces too; `--escape` deliberately does not (writes no text; unmeasured on kimi). + +**Guardrail 1 (render-gate).** The one shared-code edit: the classifier's marker exemption follows the profile's matched span instead of column 0, because kimi's marker sits at column 3 inside a rounded box. Carries dedicated before/after pins — exact span per shipped profile (claude/codex 1 = literally the old rule, agy 2 whose extra cell is whitespace already skipped), a tightest-possible-draft test per profile proving no over-skip, and a direct demonstration that a span-2 kimi profile classifies the real idle capture `user-text` while the shipped span-4 one classifies it clean. Three REAL 0.34.0 captures added as fixtures (committed raw — they carry only throwaway `/tmp` paths, unlike the agy captures). **Flag this for CMAP.** + +**Guardrail 2 (trust).** No sanctioned bypass exists (audited 0.34.0: no `--help` flag; full strings sweep for `KIMI_*` env vars and trust config keys found nothing). Kept fail-soft, and added `inspectKimiTrustLayout` — it validates our undocumented `sha256(root)[:12]` derivation against kimi's OWN records, so a scheme change surfaces as a named `codev doctor` warning instead of silently stranding every new builder on the dialog. Doctor now reports the richer per-surface drift reasons; `kimiStoreLayoutLooksDrifted` deleted as production-dead. + +**Version floor raised 0.27.0 → 0.33.0.** `--agent-file` is the hard break (0.31.0), but every measurement here was taken on the agent-core-v2 engine 0.33.0 made default. Claiming 0.31–0.32 support would be unverified. Flagged in the PR as the maintainer's call. + +**Corrected an obsolete claim**: kimi DOES have a hook seam (blocking `PreToolUse`, `[[hooks]]` in config.toml, 18 events as of 0.32.0), so "#1018 write-guard parity impossible" was wrong. Docs now say parity is achievable follow-up work; the PR asks the maintainer whether it lands here or separately. + +Store drift also fixed (three renames, not one: `workDir`→`cwd`, ISO→epoch-ms timestamps, `lastPrompt` gone) with v1 back-compat retained. + +## 2026-08-09 — post-pivot CMAP round: two blocking defects, both fixed + +Collected the work left in flight at the context reset (nothing restarted — the demo and both +consultations were still alive and were allowed to finish). + +**CMAP: gemini APPROVE, codex REQUEST_CHANGES, claude REQUEST_CHANGES.** Both REQUEST_CHANGES +found the same two defects from opposite directions, and neither is reachable from a happy-path +run — an empty composer and a clean store both behave correctly, which is exactly why three +passing live demos missed them. Full dispositions in +`codev/projects/1201-*/1201-cmap-postpivot-dispositions.md`. + +1. **False CLEAN on a multi-row kimi composer (blocking).** kimi's marker `│ >` can match a + *continuation* row, and `findMarkerRow` takes the last match, so a draft whose final line + begins with `>` left the real text above the scanned region → clean verdict on a composer + holding unsent input. Claude reproduced it but had no live kimi to confirm the geometry; I + measured it — real 0.34.0 renders exactly that shape. Fixed with an optional, *exclusive* + `regionStartPatterns` upper bound (kimi: the box top). Exclusive was not cosmetic: my first + attempt included the box-top row, whose `╮` is not an ignorable glyph, and it held every idle + composer forever — the fixture suite caught it immediately. Claude's second proposed input (a + marker row inside a second box below the composer) is NOT reachable: measured, kimi's `/` menu + renders as unclosed `│` rows with no `╰`, so it yields `no-region-end` → held. Four new + fixtures from live capture: multiline-bare, multiline, menu, picker. +2. **Store probe diverged from the TypeScript (blocking).** codex found the dangerous direction + (an `archived` session authorized `-c`, which kimi then refuses to continue → fresh, roleless + session — the #929 class). Claude found the safe-but-harmful direction (one stray `.DS_Store` + threw ENOTDIR into the single outer try and disabled resume machine-wide, silently). The + cross-check test had been comparing two implementations of the same omissions. Both now share + one resumability predicate and per-level error handling, with every case asserted against both. +3. Plus: shell-metacharacter interpolation in the generated script (all three reviewers, from + different angles), unbounded task re-queueing in a crash loop, drift probes that report healthy + forever after a migration, and two stale seed-era strings. + +**The demo's role oracle was wrong, not the product.** Its two failures (steps 2 and 4b) were a +role that told the model to prefix every reply with a token — that measures K3's formatting +compliance, not role delivery. The live `--agent-file` probe passed 7/7 against a +production-identical agent file, including role survival across `kimi -c`. Rewrote the demo to +ask for a codeword instead (the same oracle the probe uses), with a comment saying why so nobody +restores the weaker one. + +**Verification:** `pnpm build` clean; full suite **4900 passed / 48 skipped / 0 failed**; live +demo **7/7** against real kimi 0.34.0, including the crash-resume claim that was withheld until +it passed. diff --git a/codev/state/task-Iptx_thread.md b/codev/state/task-Iptx_thread.md new file mode 100644 index 000000000..25e776f79 --- /dev/null +++ b/codev/state/task-Iptx_thread.md @@ -0,0 +1,29 @@ +# 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). + +## 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. 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/__tests__/config.test.ts b/packages/codev/src/agent-farm/__tests__/config.test.ts index 138034ffc..79f2175b0 100644 --- a/packages/codev/src/agent-farm/__tests__/config.test.ts +++ b/packages/codev/src/agent-farm/__tests__/config.test.ts @@ -156,6 +156,18 @@ describe('getArchitectHarness / getBuilderHarness override-awareness (#929)', () setCliOverrides({ builder: 'codex' }); 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/); + }); }); // Issue #1338 — the built-in gemini harness is retired. Every config path that diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/README.md b/packages/codev/src/agent-farm/__tests__/fixtures/gate/README.md index 144343a78..c51a5c186 100644 --- a/packages/codev/src/agent-farm/__tests__/fixtures/gate/README.md +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/README.md @@ -42,6 +42,22 @@ encodes the expected verdict: `-..txt`. (default-fg text counts), trust → busy (palette-12 option counts — a blind Enter never confirms filesystem trust). The raw measurement (with real render + per-cell fg attributes) is archived in the Phase 3 review. +- **kimi-idle.clean.txt, kimi-draft.busy.txt, kimi-trust.busy.txt** — **real captures** + from Kimi Code CLI **0.34.0** under a PTY at the same 110×32 the suite classifies at + (harness: `codev/spikes/pir-1201-kimi-gate-measure.mjs`, Issue #1201). Committed raw: + unlike the agy captures these embed no account identity — only throwaway `/tmp` + worktree paths. Measured facts they encode: kimi draws its composer inside a **rounded + box**, so the input row is `` │ > `` with the marker at **column 3**, not the row start + (hence its own `markerPattern`, and the classifier's marker exemption spanning the + matched region rather than column 0); an idle kimi composer carries **no placeholder + text at all** — just the marker and an inverse-space block cursor, which the whitespace + rule already skips — so kimi needs **neither** a dim rule nor a `placeholderFgPalette`; + typed text is **default-fg at normal intensity** → counted → busy; and the 0.33.0+ + **folder-trust dialog** has no marker at a row start → `no-composer-marker` → busy, so + a blind Enter can never confirm filesystem trust (the same guarantee agy's trust dialog + gets). The box bottom (`` ╰───╯ ``, indented one column) is kimi's sole region-end + pattern — the shared rule pattern requires the rule glyph to start the line and so + cannot bound it. - **wrapper-boot.busy.txt** — **synthetic** builder launch-loop screen (a born-dirty state with no composer marker). App-agnostic: no marker → busy under any profile. diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-draft.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-draft.busy.txt new file mode 100644 index 000000000..b7f038ccd --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-draft.busy.txt @@ -0,0 +1,31 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-gate-U26gXF │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-U26gXF ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-U26gXF ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ > draft text  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-U26gXF /theme to switch the terminal UI theme]8;;[?2026l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-idle.clean.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-idle.clean.txt new file mode 100644 index 000000000..5b8c33f6c --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-idle.clean.txt @@ -0,0 +1,29 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-gate-U26gXF │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-U26gXF ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-U26gXF ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-menu.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-menu.busy.txt new file mode 100644 index 000000000..fd48f31f2 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-menu.busy.txt @@ -0,0 +1,60 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-gate-6NoCq9 │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ > draft text  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 shift+enter: newline]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;;[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ > quoted second line  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ >  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ > /  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + │ → yolo Toggle YOLO mode: auto-approve tool actions, but the agent may still ask │]8;; + │  questions. │]8;; + │ model Switch LLM model │]8;; + │ permission Select permission mode │]8;; + │ plan Toggle plan mode │]8;; + │ settings Open TUI settings │]8;; + │  (1/48) │]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline-bare.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline-bare.busy.txt new file mode 100644 index 000000000..d74f0cd31 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline-bare.busy.txt @@ -0,0 +1,45 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-gate-6NoCq9 │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ > draft text  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 shift+enter: newline]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;;[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ > quoted second line  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ >  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline.busy.txt new file mode 100644 index 000000000..c8929e0d3 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-multiline.busy.txt @@ -0,0 +1,37 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-gate-6NoCq9 │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ > draft text  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 shift+enter: newline]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;;[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ > quoted second line  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;;[?2026l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-picker.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-picker.busy.txt new file mode 100644 index 000000000..339c3175b --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-picker.busy.txt @@ -0,0 +1,71 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-gate-6NoCq9 │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 /web: use the Web UI for a better experience]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ > draft text  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 shift+enter: newline]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;;[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ > quoted second line  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  │ > implement the whole feature │]8;; + │ >  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ > /  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + │ → yolo Toggle YOLO mode: auto-approve tool actions, but the agent may still ask │]8;; + │  questions. │]8;; + │ model Switch LLM model │]8;; + │ permission Select permission mode │]8;; + │ plan Toggle plan mode │]8;; + │ settings Open TUI settings │]8;; + │  (1/48) │]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-gate-6NoCq9 ctrl+c: cancel | /theme to switch the terminal UI theme]8;; + context: 0% (0/256k)]8;; + + + + + + +[?2026l[?25l[?2026h  │ > @  │]8;;[?2026l[?25l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-trust.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-trust.busy.txt new file mode 100644 index 000000000..371bd33f9 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-trust.busy.txt @@ -0,0 +1,16 @@ +]11;?[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h ────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;; +  Trust this folder?]8;; +  ↑↓ navigate · Enter select · Esc exit]8;; + ]8;; + /tmp/kimi-untrusted-61zNDq]8;; + ]8;; + Kimi Code loads project-level MCP servers (.mcp.json, .kimi-code/mcp.json) only in trusted folders. They]8;; + run as local processes on your machine.]8;; + ]8;; +  ❯ Trust this folder]8;; + Enable project MCP servers. Remembered for this folder.]8;; + ]8;; +  Don't trust]8;; + Exit Kimi Code. Asked again next launch.]8;; + ]8;; + ────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;;[?2026l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/harness.test.ts b/packages/codev/src/agent-farm/__tests__/harness.test.ts index 0ece26224..f2007f86b 100644 --- a/packages/codev/src/agent-farm/__tests__/harness.test.ts +++ b/packages/codev/src/agent-farm/__tests__/harness.test.ts @@ -1,8 +1,15 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, readFileSync, existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { CLAUDE_HARNESS, CODEX_HARNESS, OPENCODE_HARNESS, + KIMI_HARNESS, + KIMI_AGENT_FILE, + buildKimiAgentFile, buildCustomHarnessProvider, validateCustomHarnessConfig, resolveHarness, @@ -479,6 +486,511 @@ 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/); + }); + + // The pivot (PR #1203 re-integration): the role rides `--agent-file`, a real + // kimi 0.31.0+ flag, pointed at a file written next to `.builder-role.md`. + // It replaced a seed-session bootstrap that delivered the role as a user turn. + it('buildScriptRoleInjection points --agent-file at the worktree agent file', () => { + const { fragment, env } = KIMI_HARNESS.buildScriptRoleInjection(ROLE_CONTENT, ROLE_FILE); + expect(fragment).toBe(`--agent-file '/tmp/workspace/${KIMI_AGENT_FILE}'`); + expect(env).toEqual({}); + }); + + it('the agent file extends kimi\'s own system prompt rather than replacing it', () => { + const body = buildKimiAgentFile('ROLE BODY'); + // ${base_prompt} is the load-bearing token: it interpolates kimi's default + // system prompt, so the role is additive (the --append-system-prompt analogue). + // Without it the builder silently loses kimi's tool-use and safety preamble. + expect(body).toContain('${base_prompt}'); + expect(body).toContain('ROLE BODY'); + expect(body.indexOf('${base_prompt}')).toBeLessThan(body.indexOf('ROLE BODY')); + // Frontmatter is required by kimi's agent-definition format. + expect(body.startsWith('---\n')).toBe(true); + expect(body).toMatch(/^name:\s*\S+/m); + }); + + it('getWorktreeFiles writes the agent file only when there is a role', () => { + const withRole = KIMI_HARNESS.getWorktreeFiles!(ROLE_CONTENT); + expect(withRole).toEqual([ + { relativePath: KIMI_AGENT_FILE, content: buildKimiAgentFile(ROLE_CONTENT) }, + ]); + // No marker file any more: pacing reads the harness out of the generated + // .builder-start.sh, so nothing has to remember to write a breadcrumb. + expect(KIMI_HARNESS.getWorktreeFiles!(null)).toEqual([]); + }); + + // 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('buildBuilderLaunchScript', () => { + const ROLE_FRAGMENT = `--agent-file '/tmp/wt/${KIMI_AGENT_FILE}'`; + const ctxBase = { worktreePath: '/tmp/wt', baseCmd: 'kimi', roleFragment: ROLE_FRAGMENT }; + const taskCtx = { ...ctxBase, taskFile: '/tmp/wt/.builder-prompt.txt', builderId: 'pir-1201' }; + const bareCtx = { ...ctxBase, roleFragment: '', taskFile: null }; + + it('task-carrying: role via --agent-file, task via the mailbox — never a positional prompt', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!(taskCtx); + expect(script).toContain(ROLE_FRAGMENT); + expect(script).toContain('--yolo'); + // kimi takes no positional prompt, so the task rides the Spec 1313 mailbox + // and the render gate delivers it onto a verified-empty composer. + // + // The id and task path enter the script ONCE, as single-quoted assignments, + // and every later use goes through the shell variable — so a builder id or + // path containing a backtick or `$(…)` is never re-scanned as code, not even + // by the recovery hints (CMAP 2026-08-09). + expect(script).toContain("codev_builder_id='pir-1201'"); + expect(script).toContain("codev_task_file='/tmp/wt/.builder-prompt.txt'"); + expect(script).toContain('afx send "$codev_builder_id" "$(cat "$codev_task_file")"'); + // No interpolated value may appear inside a double-quoted echo/printf line, + // which is where bash WOULD re-scan it. + for (const line of script.split('\n').filter((l) => /^\s*(echo|printf)\b/.test(l))) { + expect(line).not.toContain('pir-1201'); + expect(line).not.toContain('/tmp/wt/.builder-prompt.txt'); + } + // The #929/#1062 regression class: never claude-shaped flags, and never a + // prompt appended as an argument (kimi exits 1 on both). Scoped to the lines + // that actually INVOKE kimi — the script's prose mentions `afx spawn --resume`, + // and a whole-script substring guard would trip on that instead of on a real + // mis-injection. + const kimiInvocations = script.split('\n').filter((l) => /^\s*kimi(\s|$)/.test(l)); + expect(kimiInvocations.length).toBeGreaterThan(0); + for (const line of kimiInvocations) { + expect(line).not.toContain('--append-system-prompt'); + expect(line).not.toContain('--resume'); + expect(line).not.toContain('$(cat'); + } + }); + + it('task-carrying: queues the task on a FRESH launch only, never on a resume', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!(taskCtx); + const fresh = script.indexOf('codev_launch_fresh() {'); + const resume = script.indexOf('codev_launch_resume() {'); + const queueCall = script.indexOf('codev_queue_task\n', fresh); + expect(fresh).toBeGreaterThan(-1); + expect(resume).toBeGreaterThan(-1); + // The only invocation of the queue helper sits inside the fresh branch, so a + // resumed conversation is never re-fed a task it has already been working on. + expect(queueCall).toBeGreaterThan(fresh); + expect(queueCall).toBeLessThan(resume); + }); + + // THE guard this design exists for (verified live on 0.34.0): `kimi -c` with + // nothing to continue does NOT fail — it prints "No sessions to continue…" and + // starts a fresh session that never saw --agent-file, i.e. a ROLELESS builder. + // Every path to `-c` must therefore be gated on a proven-existing session, and + // the gate must fail CLOSED to the role-carrying launch. + it('never reaches -c without proving a session exists (the roleless-fallback guard)', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!(taskCtx); + expect(script).toContain('codev_has_session'); + // Entry selects resume only under the probe... + expect(script).toMatch(/if codev_has_session; then\n\s*codev_launch=codev_launch_resume\n\s*else\n\s*codev_launch=codev_launch_fresh/); + // ...and so does the crash path; its else-branch is fresh, not resume. + expect(script).toMatch(/elif codev_has_session; then[\s\S]*?codev_launch=codev_launch_resume\n\s*else\n[\s\S]*?codev_launch=codev_launch_fresh/); + // `-c` appears ONLY inside codev_launch_resume, which only the probe selects. + const resumeBody = script.slice( + script.indexOf('codev_launch_resume() {'), + script.indexOf('}', script.indexOf('codev_launch_resume() {')), + ); + expect(resumeBody).toContain('-c'); + expect(script.match(/(^|\s)-c(\s|$)/gm)!.length).toBe(1); + // The probe itself must fail closed: its last act on any error is exit 1 + // (→ "no session" → fresh), never exit 0. + expect(script).toContain('process.exit(1)'); + }); + + it('bare shape (no role, no task): the plain loop every session-less harness gets', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!(bareCtx); + expect(script).toContain('kimi --yolo'); + expect(script).toContain('while true'); + // Nothing to pin and nothing to queue, so none of the state machine appears. + expect(script).not.toContain('codev_has_session'); + expect(script).not.toContain('afx send'); + expect(script).not.toContain('-c'); + }); + + // Pacing depends on this: `resolvePacingForSession` recovers the harness by + // reading .builder-start.sh and matching the command in COMMAND POSITION. If a + // refactor ever moved `kimi` off the start of its own line (or behind a `while` + // on the same line), pacing would silently fall back to the 80ms default and + // every `afx send` to this builder would be typed but never submitted. + it.each([ + ['task-carrying', taskCtx], + ['bare', bareCtx], + ] as const)('%s shape puts kimi in command position on its own line', (_name, ctx) => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!(ctx); + expect(script.split('\n').some((l) => /^\s*kimi(\s|$)/.test(l))).toBe(true); + }); + + // Bugfix #1241 / PR #1244: Kimi's provider-owned loops share the exit-code-gated + // tail — a deliberate exit 0 gates the relaunch on a keypress instead of blindly + // respawning; crashes keep the auto-restart. + it.each([ + ['task-carrying', taskCtx], + ['bare', bareCtx], + ] as const)('%s shape does not auto-restart on exit 0', (_name, ctx) => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!(ctx); + expect(script).toContain('status=$?'); + expect(script).toContain('if [ "$status" -eq 0 ]; then'); + expect(script).toContain('Press Enter to relaunch'); + expect(script).toContain('read -r || exit 0'); + }); + + // #1267/#1317: a clean exit relaunches FRESH (new conversation), matching + // claude's prompt-on-fresh semantics — which for kimi means re-queuing the task. + it('task-carrying: a clean exit relaunches fresh, not resumed', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!(taskCtx); + const cleanExit = script.indexOf('if [ "$status" -eq 0 ]; then'); + const afterClean = script.slice(cleanExit, script.indexOf('fi', cleanExit)); + expect(afterClean).toContain('codev_launch=codev_launch_fresh'); + expect(afterClean).not.toContain('codev_launch_resume'); + }); + + it('warns loudly but non-fatally when the task cannot be queued', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!(taskCtx); + // A missing afx / down Tower must not stop the builder from starting — it + // surfaces a recovery command instead. `return 0` keeps the launch going. + expect(script).toContain('WARNING'); + expect(script).toContain('is Tower running?'); + expect(script).toContain('return 0'); + }); + + it('does not duplicate --yolo when the user already passed it', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + ...bareCtx, baseCmd: 'kimi --yolo', + }); + expect(script.match(/--yolo/g)!.length).toBeGreaterThan(0); + expect(script).not.toContain('--yolo --yolo'); + }); + }); + + /** + * The crash-resume guard, executed for real rather than pattern-matched. + * + * `kimi -c` does NOT fail with nothing to continue — it starts a fresh session + * that never saw `--agent-file`, i.e. a silently ROLELESS builder (#929 hazard + * class, verified live on 0.34.0). The launch loop therefore only takes `-c` + * when this inlined `node -e` probe says a session exists for this cwd. + * + * The probe is a hand-written store scan living inside a bash heredoc, where a + * type checker cannot reach it and the store's shape has already drifted once + * (`workDir` → `cwd` in 0.33.0). So it is extracted from the generated script and + * RUN against fixture stores, and its verdict is checked against the TypeScript + * discovery it mirrors — if the two ever disagree, this fails instead of a + * builder silently losing its role in the field. + */ + describe('the inlined crash-resume session probe (KIMI_HAS_SESSION_PROBE)', () => { + let fakeHome: string; + let worktree: string; + + beforeEach(() => { + fakeHome = mkdtempSync(join(tmpdir(), 'kimi-probe-')); + worktree = join(fakeHome, 'worktree'); + mkdirSync(worktree, { recursive: true }); + }); + + afterEach(() => rmSync(fakeHome, { recursive: true, force: true })); + + /** The exact `node -e ''` snippet the generated script would run. */ + function extractProbe(): string { + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: '/tmp/wt/.builder-prompt.txt', builderId: 'pir-1201', + }); + const m = script.match(/node -e '([^']*)'/); + expect(m, 'the launch script must still inline a node probe').not.toBeNull(); + return m![1]; + } + + /** Run the probe exactly as the script does; true ⇔ exit 0 ⇔ "a session exists". */ + function runProbe(cwd: string): boolean { + const res = spawnSync(process.execPath, ['-e', extractProbe(), cwd], { + env: { ...process.env, KIMI_CODE_HOME: join(fakeHome, '.kimi-code') }, + }); + return res.status === 0; + } + + function writeStoreSession(sessionId: string, state: Record): void { + const dir = join(fakeHome, '.kimi-code', 'sessions', 'wd_x_000000000000', sessionId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'state.json'), JSON.stringify(state), 'utf-8'); + } + + // THE regression the whole guard exists for. + it('an EMPTY store reports no session, so the loop launches fresh WITH the role', () => { + expect(runProbe(worktree)).toBe(false); + // And the TypeScript discovery agrees — one answer, two implementations. + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })).toBeNull(); + }); + + it('a session recorded for this cwd reports true (v2 `cwd` shape)', () => { + writeStoreSession('session_here', { id: 'session_here', version: 2, cwd: worktree, updatedAt: 1 }); + expect(runProbe(worktree)).toBe(true); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId).toBe('session_here'); + }); + + it('tolerates the v1 `workDir` shape exactly as readStateJson does', () => { + writeStoreSession('session_v1', { workDir: worktree, updatedAt: '2026-07-18T10:00:00Z' }); + expect(runProbe(worktree)).toBe(true); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId).toBe('session_v1'); + }); + + it('a session for ANOTHER directory reports no session (never inherits a stranger\'s conversation)', () => { + writeStoreSession('session_elsewhere', { cwd: '/some/other/dir', updatedAt: 1 }); + expect(runProbe(worktree)).toBe(false); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })).toBeNull(); + }); + + it('fails CLOSED on a malformed store — a corrupt state.json must not authorize -c', () => { + writeStoreSession('session_junk', {}); + writeFileSync( + join(fakeHome, '.kimi-code', 'sessions', 'wd_x_000000000000', 'session_junk', 'state.json'), + '{ not json', + 'utf-8', + ); + expect(runProbe(worktree)).toBe(false); + }); + + it('fails CLOSED when the store does not exist at all', () => { + rmSync(join(fakeHome, '.kimi-code'), { recursive: true, force: true }); + expect(runProbe(worktree)).toBe(false); + }); + + it('queues the task ONCE across a crash-restart loop, and again after a clean-exit relaunch', () => { + // codex #4: codev_launch_fresh queues the task, and a kimi that dies before + // minting a session sends the loop back through fresh every 2s — so the same + // mission piled onto the mailbox indefinitely. The mailbox PERSISTS a held row, + // so one enqueue is enough; the human-gated clean-exit relaunch is the one + // deliberate new conversation that does want its task again. + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: 'pir-1201', + }); + writeFileSync(join(worktree, '.builder-prompt.txt'), 'THE TASK', 'utf-8'); + // Run the generated function bodies directly with a stub `afx` on PATH, + // driving the same state machine the loop does. + const bin = join(fakeHome, 'bin'); + mkdirSync(bin, { recursive: true }); + const calls = join(fakeHome, 'afx-calls.log'); + // `afx send ` → $3 is the task body. + writeFileSync(join(bin, 'afx'), `#!/bin/bash\necho "$3" >> '${calls}'\n`, { mode: 0o755 }); + const harnessFns = script.slice(script.indexOf('codev_builder_id='), script.indexOf('codev_has_session()')); + const res = spawnSync('bash', ['-c', + `${harnessFns}\n` + + // three crash-restart iterations, then a clean-exit relaunch + 'codev_queue_task; codev_queue_task; codev_queue_task\n' + + 'codev_task_queued=0\n' + + 'codev_queue_task\n', + ], { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, encoding: 'utf-8' }); + expect(res.status).toBe(0); + expect(readFileSync(calls, 'utf-8').trim().split('\n')).toEqual(['THE TASK', 'THE TASK']); + }); + + it('does not execute a builder id containing shell metacharacters', () => { + // claude F3 / codex #3: the recovery hints used to interpolate the id into a + // double-quoted echo, where bash re-scans it — so `$(…)` in an id ran when the + // hint printed. Proven at the shell, not by reading the string. + const evil = String.raw`pir-$(touch ${join(fakeHome, 'PWNED')})-\`touch ${join(fakeHome, 'PWNED2')}\``; + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: evil, + }); + const harnessFns = script.slice(script.indexOf('codev_builder_id='), script.indexOf('codev_has_session()')); + // No `afx` on PATH → both recovery hints print, which is the vulnerable path. + const res = spawnSync('bash', ['-c', `${harnessFns}\ncodev_queue_task\n`], + { env: { ...process.env, PATH: '/usr/bin:/bin' }, encoding: 'utf-8' }); + expect(res.status).toBe(0); + expect(existsSync(join(fakeHome, 'PWNED'))).toBe(false); + expect(existsSync(join(fakeHome, 'PWNED2'))).toBe(false); + // …and the id still reaches the human verbatim in the hint. + expect(res.stderr).toContain(evil); + }); + + // The divergences the 3-way review found (2026-08-09). Each asserts BOTH + // implementations, because the promise this block makes is that they agree — + // and every one of these used to be a case where they did not. + describe('the two implementations agree on the cases that used to split them', () => { + it('an ARCHIVED session does not authorize -c (kimi would not continue it)', () => { + // codex #1: `kimi -c` lists a cwd's sessions and drops archived ones, so + // resuming one silently starts a FRESH, roleless session. Existing on disk + // is not the same question as "kimi will continue it". + writeStoreSession('session_archived', { + id: 'session_archived', version: 2, cwd: worktree, updatedAt: 9, archived: true, + }); + expect(runProbe(worktree)).toBe(false); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })).toBeNull(); + }); + + it('an archived session does not mask a live one for the same cwd', () => { + writeStoreSession('session_archived', { + id: 'session_archived', version: 2, cwd: worktree, updatedAt: 99, archived: true, + }); + writeStoreSession('session_live', { + id: 'session_live', version: 2, cwd: worktree, updatedAt: 1, + }); + expect(runProbe(worktree)).toBe(true); + // …and the newer archived one must not win the recency race. + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId) + .toBe('session_live'); + }); + + it('a stray non-directory under sessions/ does not abort the whole scan', () => { + // claude F2: readdirSync on a file threw ENOTDIR into the single outer try, + // so ONE .DS_Store silently disabled resume for every worktree on the machine. + writeStoreSession('session_here', { id: 'session_here', version: 2, cwd: worktree, updatedAt: 1 }); + writeFileSync(join(fakeHome, '.kimi-code', 'sessions', '.DS_Store'), 'junk', 'utf-8'); + expect(runProbe(worktree)).toBe(true); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId).toBe('session_here'); + }); + + it('a stray non-directory INSIDE a wd bucket does not abort the scan either', () => { + writeStoreSession('session_here', { id: 'session_here', version: 2, cwd: worktree, updatedAt: 1 }); + writeFileSync( + join(fakeHome, '.kimi-code', 'sessions', 'wd_x_000000000000', 'index.jsonl'), + 'junk', + 'utf-8', + ); + expect(runProbe(worktree)).toBe(true); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId).toBe('session_here'); + }); + + it('a cwd recorded with a trailing slash still matches', () => { + writeStoreSession('session_slash', { + id: 'session_slash', version: 2, cwd: `${worktree}/`, updatedAt: 1, + }); + expect(runProbe(worktree)).toBe(true); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId).toBe('session_slash'); + }); + + it('a symlinked worktree path still matches (both sides realpath-tolerant)', () => { + const link = join(fakeHome, 'worktree-link'); + symlinkSync(worktree, link); + writeStoreSession('session_real', { id: 'session_real', version: 2, cwd: worktree, updatedAt: 1 }); + expect(runProbe(link)).toBe(true); + expect(KIMI_HARNESS.buildResume!(link, { homeDir: fakeHome })?.sessionId).toBe('session_real'); + }); + + it('a directory kimi would not recognize as a session id does not authorize -c', () => { + // The id `-c`'s listing filters on; an unrecognized directory is a drifted or + // stray one, and treating it as resumable is the roleless-fallback direction. + writeStoreSession('scratch_dir', { id: 'scratch_dir', version: 2, cwd: worktree, updatedAt: 1 }); + expect(runProbe(worktree)).toBe(false); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })).toBeNull(); + }); + }); + }); + + describe('buildResume', () => { + 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 }); + }); + + // v2 store shape (kimi 0.33.0+): `cwd` (was `workDir`) and epoch-ms timestamps + // (were ISO strings). Discovery tolerates both; these fixtures use the current one. + function writeStoreSession(sessionId: string, cwd: string, updatedAt: number): void { + const dir = join(fakeHome, '.kimi-code', 'sessions', 'wd_x_000000000000', sessionId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'state.json'), + JSON.stringify({ id: sessionId, version: 2, cwd, updatedAt }), + 'utf-8', + ); + } + + it('null when no store session exists for this worktree → fresh-with-role launch', () => { + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })).toBeNull(); + }); + + // The pivot shrank discovery to a single question — does a conversation exist for + // exactly this worktree? — and the ANSWER, not the id, is what the script uses: + // the relaunch runs the DOCUMENTED cwd-scoped `-c`, so no undocumented session id + // is ever baked into generated bash. The id still rides the return value because + // callers log it and spawn.ts reads null as "nothing to resume". + it('resumes with the documented cwd-scoped -c, never an undocumented -S ', () => { + writeStoreSession('session_abc-123', worktree, 1_760_000_000_000); + const resume = KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome }); + expect(resume).toEqual({ + sessionId: 'session_abc-123', + args: ['-c'], + scriptFragment: '-c', + }); + }); + + it('store scan picks the newest session recorded for exactly this worktree', () => { + writeStoreSession('session_older', worktree, 1_750_000_000_000); + writeStoreSession('session_newest', worktree, 1_760_000_000_000); + writeStoreSession('session_other-dir', '/elsewhere', 1_770_000_000_000); + const resume = KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome }); + expect(resume?.sessionId).toBe('session_newest'); + }); + + // #1145: a session recorded for a DIFFERENT cwd must never be resumed here, or a + // builder inherits an unrelated conversation. + it('ignores sessions recorded for another directory', () => { + writeStoreSession('session_elsewhere', '/some/other/worktree', 1_760_000_000_000); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })).toBeNull(); + }); + + // #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..59ef6e2c9 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/kimi-session-discovery.test.ts @@ -0,0 +1,329 @@ +/** + * Tests for Kimi session discovery via on-disk store introspection. + * + * Issue #1201 — Kimi Code CLI as a builder. Two UNDOCUMENTED surfaces, both + * observed on kimi 0.34.0: + * /sessions/wd_/session_/state.json + * v2 (0.33.0+): { id, version: 2, cwd, createdAt, updatedAt, … } + * v1 (≤ 0.32): { workDir, updatedAt (ISO), lastPrompt?, … } + * /workspace-trust/wd__ → { root, trustedAt } + * + * Every function is fail-soft: malformed fixtures must yield null/empty, never a + * throw, because all of this is read on the spawn path. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, utimesSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; + +import { + getKimiHome, + findLatestKimiSessionId, + verifyKimiSessionOwnership, + readKimiSessionState, + inspectKimiStoreLayout, + inspectKimiTrustLayout, + kimiTrustRecordPath, + ensureKimiWorkspaceTrust, +} 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'); + }); + + // Existing on disk is not the question — "would `kimi -c` continue it?" is. + // Kimi's cwd listing drops archived sessions and ids it does not recognize, and + // `-c` with nothing to continue does not fail: it starts a fresh session that + // never saw --agent-file, i.e. a silently roleless builder (#929 class). + it('skips an ARCHIVED session — kimi would not continue it', () => { + writeSession('session_archived', { cwd: '/wt', updatedAt: 5, archived: true }); + expect(findLatestKimiSessionId('/wt', opts())).toBeNull(); + }); + + it('prefers a live session over a NEWER archived one', () => { + writeSession('session_archived', { cwd: '/wt', updatedAt: 99, archived: true }); + writeSession('session_live', { cwd: '/wt', updatedAt: 1 }); + expect(findLatestKimiSessionId('/wt', opts())).toBe('session_live'); + }); + + it('skips a directory kimi would not recognize as a session id', () => { + writeSession('scratch-dir', { cwd: '/wt', updatedAt: 5 }); + expect(findLatestKimiSessionId('/wt', opts())).toBeNull(); + }); + + it('picks the newest by updatedAt among matches (across wd dirs)', () => { + writeSession('session_old', { workDir: '/wt', updatedAt: '2026-07-18T09:00:00Z' }, 'wd_a_111111111111'); + writeSession('session_new', { workDir: '/wt', updatedAt: '2026-07-18T11:00:00Z' }, 'wd_b_222222222222'); + 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', () => { + // Store v2 (kimi 0.33.0+, agent-core-v2): the working-directory field was renamed + // `workDir` → `cwd`, timestamps became epoch-ms NUMBERS instead of ISO strings, and + // `lastPrompt` was dropped entirely. Discovery normalizes all three. + it('returns cwd/updatedAt/version for a v2 session (epoch-ms timestamps)', () => { + writeSession('session_full', { + id: 'session_full', + version: 2, + cwd: '/wt', + updatedAt: 1_760_000_000_000, + }); + expect(readKimiSessionState('session_full', opts())).toEqual({ + cwd: '/wt', + updatedAt: 1_760_000_000_000, + version: 2, + archived: false, + }); + }); + + // Back-compat: a v1 store (kimi < 0.33.0) still reads, so an installed-but-not-yet + // upgraded kimi keeps resuming instead of silently starting fresh, roleless sessions. + it('accepts the v1 shape: workDir and an ISO timestamp, normalized to epoch ms', () => { + writeSession('session_v1', { workDir: '/wt', updatedAt: '2026-07-18T10:00:00Z' }); + expect(readKimiSessionState('session_v1', opts())).toEqual({ + cwd: '/wt', + updatedAt: Date.parse('2026-07-18T10:00:00Z'), + version: null, + archived: false, + }); + }); + + it('nulls optional fields that are absent', () => { + writeSession('session_sparse', { cwd: '/wt' }); + expect(readKimiSessionState('session_sparse', opts())).toEqual({ + cwd: '/wt', + updatedAt: null, + version: null, + archived: false, + }); + }); + + 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(); + }); + }); + + // Kimi ships weekly and has already renamed the store's working-directory field + // once (`workDir` → `cwd`, 0.33.0), which silently nulled every parse. The probe + // therefore asserts the load-bearing facts EXPLICITLY and names the first one that + // broke, so `codev doctor` can say which assumption failed instead of "something + // changed" — or, worse, degrade silently at spawn time. + describe('inspectKimiStoreLayout (doctor smoke probe)', () => { + it('empty when the store does not exist (fresh install is not drift)', () => { + expect(inspectKimiStoreLayout(opts())).toEqual({ status: 'empty' }); + }); + + it('ok when at least one session carries the load-bearing shape', () => { + writeSession('session_ok', { cwd: '/wt' }); + writeSession('session_bad', '###'); + expect(inspectKimiStoreLayout(opts())).toEqual({ status: 'ok', sampled: 1 }); + }); + + // The blind spot in "any session matches" (CMAP 2026-08-09, codex #5): after a + // store migration the pre-migration sessions keep matching forever, so the probe + // would report healthy through exactly the rename it was built to catch. + /** Recency is the session directory's mtime; pin it so the ordering is explicit. */ + const touchDir = (dir: string, epochSeconds: number) => utimesSync(dir, epochSeconds, epochSeconds); + + it('reports drift when the NEWEST session stopped matching but older ones still do', () => { + touchDir(writeSession('session_old', { cwd: '/wt' }), 1_000); + touchDir(writeSession('session_new', { someRenamedField: '/wt' }), 9_000); + const layout = inspectKimiStoreLayout(opts()); + expect(layout.status).toBe('drifted'); + expect(layout.status === 'drifted' && layout.reason).toMatch(/most recently written session/); + }); + + it('stays ok when the non-matching session is the OLDER one (a leftover, not a migration)', () => { + touchDir(writeSession('session_old', { someRenamedField: '/wt' }), 1_000); + touchDir(writeSession('session_new', { cwd: '/wt' }), 9_000); + expect(inspectKimiStoreLayout(opts())).toEqual({ status: 'ok', sampled: 1 }); + }); + + it('stays ok on a tie, so the verdict never depends on directory iteration order', () => { + touchDir(writeSession('session_a', { cwd: '/wt' }), 5_000); + touchDir(writeSession('session_b', { someRenamedField: '/wt' }), 5_000); + expect(inspectKimiStoreLayout(opts())).toEqual({ status: 'ok', sampled: 1 }); + }); + + it('names the working-directory field when no session carries one', () => { + writeSession('session_bad1', '###'); + writeSession('session_bad2', { noWorkDirKey: true }); + const layout = inspectKimiStoreLayout(opts()); + expect(layout.status).toBe('drifted'); + expect(layout.status === 'drifted' && layout.reason).toMatch(/working-directory field/); + }); + + // `kimi -S ` takes the directory basename; if that stops being + // `session_`, discovery returns ids the CLI would reject. + it('names the id scheme when session dirs lose the session_ prefix', () => { + writeSession('bare-uuid-1234', { cwd: '/wt' }); + const layout = inspectKimiStoreLayout(opts()); + expect(layout.status).toBe('drifted'); + expect(layout.status === 'drifted' && layout.reason).toMatch(/session_/); + }); + }); + + /** + * The trust-record probe validates OUR undocumented derivation against kimi's own + * records. If the scheme ever changes, the pre-write lands where kimi does not look: + * the write still "succeeds", the dialog reappears, and every unattended builder + * stalls on it. Silent by construction — hence the probe. + */ + describe('inspectKimiTrustLayout (undocumented trust-scheme probe)', () => { + function writeTrustRecord(fileName: string, root: string): void { + const dir = join(kimiHome, 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, fileName), JSON.stringify({ root, trustedAt: 1 }), 'utf-8'); + } + + it('empty when nothing has been trusted yet (or kimi predates the dialog)', () => { + expect(inspectKimiTrustLayout(opts())).toEqual({ status: 'empty' }); + }); + + it('ok when a record kimi wrote matches the name we would derive for its root', () => { + const root = '/tmp/some-worktree'; + writeTrustRecord(basename(kimiTrustRecordPath(root, opts())), root); + expect(inspectKimiTrustLayout(opts())).toEqual({ status: 'ok', sampled: 1 }); + }); + + it('drifted when records exist but none match the derived scheme', () => { + writeTrustRecord('wd_some-worktree_DIFFERENTHASH', '/tmp/some-worktree'); + const layout = inspectKimiTrustLayout(opts()); + expect(layout.status).toBe('drifted'); + expect(layout.status === 'drifted' && layout.reason).toMatch(/sha256\(root\)/); + }); + + it('ignores unreadable records rather than calling them drift', () => { + const dir = join(kimiHome, 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'not-json'), 'nope', 'utf-8'); + expect(inspectKimiTrustLayout(opts())).toEqual({ status: 'empty' }); + }); + + // The end-to-end property the pre-write depends on: what we WRITE is what the + // probe recognizes. If the derivation and the writer ever diverge, this fails. + it('agrees with what ensureKimiWorkspaceTrust actually writes', () => { + const root = mkdtempSync(join(tmpdir(), 'kimi-trust-root-')); + try { + expect(ensureKimiWorkspaceTrust(root, opts())).toBe(true); + expect(inspectKimiTrustLayout(opts())).toEqual({ status: 'ok', sampled: 1 }); + // Idempotent: a second call leaves the existing record alone. + expect(ensureKimiWorkspaceTrust(root, opts())).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/mailbox-pacing.test.ts b/packages/codev/src/agent-farm/__tests__/mailbox-pacing.test.ts new file mode 100644 index 000000000..53c3301f7 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/mailbox-pacing.test.ts @@ -0,0 +1,129 @@ +/** + * Per-harness message pacing on the mailbox delivery path (Issue #1201). + * + * Kimi's paste-detection window swallows an Enter sent 80ms after the message body + * (the message-write default), so a Kimi builder's mail is typed but never + * submitted unless delivery uses its ~1s Enter. Spec 1313 made `afx send` + * mailbox-first, which moved every delivery through `DeliveryPorts.writeMessage` — + * so that is where pacing is resolved. + * + * These tests replace the retired `message-pacing.test.ts`. The old design probed the + * worktree for a `.builder-kimi` marker, which obliged EVERY launch shape to remember + * to write one; the bare shape forgot, which is the bug PR #1203's maintainer review + * found. Pacing now reads the harness out of the generated `.builder-start.sh` — the + * same signal the render gate resolves, and one that cannot be forgotten because the + * launcher itself is the artifact. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { resolveHarnessForSession, resolvePacingForSession } from '../servers/mailbox-wiring.js'; +import { KIMI_HARNESS, CLAUDE_HARNESS } from '../utils/harness.js'; +import type { DeliverySession } from '../servers/mailbox-delivery.js'; + +/** A delivery session with only the fields pacing resolution reads. */ +function session(command: string, cwd: string): DeliverySession { + return { + bytesWritten: 0, + info: { cols: 110, rows: 32 }, + command, + launchArgs: [], + cwd, + writable: true, + write: () => true, + }; +} + +describe('mailbox pacing resolution (Issue #1201)', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'pacing-')); + }); + + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + /** Write a launch script the way spawn-worktree does. */ + function writeLaunchScript(body: string): void { + const p = join(dir, '.builder-start.sh'); + writeFileSync(p, body, 'utf-8'); + chmodSync(p, '755'); + } + + // A real builder's `command` is the SHELL running .builder-start.sh, never the + // agent — so the direct check misses and the launch script is what answers. + it('resolves kimi through the launch script for a wrapped builder', () => { + writeLaunchScript(KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: dir, baseCmd: 'kimi', roleFragment: "--agent-file '/x/role.md'", + taskFile: join(dir, '.builder-prompt.txt'), builderId: 'pir-1201', + })); + const s = session('/bin/bash', dir); + expect(resolveHarnessForSession(s)).toBe('kimi'); + expect(resolvePacingForSession(s)).toEqual(KIMI_HARNESS.messagePacing); + expect(resolvePacingForSession(s)?.enterDelayMs).toBeGreaterThanOrEqual(1000); + }); + + // The bare shape is the one the marker-file design missed (PR #1203 review): an + // override spawn with no role and no task. It must still pace as kimi. + it('resolves kimi for the BARE launch shape too — the shape the old marker probe missed', () => { + writeLaunchScript(KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: dir, baseCmd: 'kimi', roleFragment: '', taskFile: null, + })); + expect(resolvePacingForSession(session('/bin/bash', dir))).toEqual(KIMI_HARNESS.messagePacing); + }); + + // The override case the maintainer found: `--builder-cmd kimi` against a workspace + // whose config says claude. Resolution never consults config — only the generated + // script — so the override cannot be lost. + it('is override-proof: config is never consulted, only the generated script', () => { + writeLaunchScript('#!/bin/bash\ncd "/wt"\nwhile true; do\n kimi --yolo\ndone\n'); + expect(resolveHarnessForSession(session('/bin/bash', dir))).toBe('kimi'); + }); + + it('leaves claude builders on the message-write defaults', () => { + writeLaunchScript('#!/bin/bash\ncd "/wt"\nwhile true; do\n claude --dangerously-skip-permissions\ndone\n'); + const s = session('/bin/bash', dir); + expect(resolveHarnessForSession(s)).toBe('claude'); + expect(CLAUDE_HARNESS.messagePacing).toBeUndefined(); + expect(resolvePacingForSession(s)).toBeUndefined(); + }); + + it('resolves an unwrapped session straight from its command', () => { + expect(resolveHarnessForSession(session('kimi --yolo', dir))).toBe('kimi'); + expect(resolvePacingForSession(session('/opt/bin/kimi', dir))).toEqual(KIMI_HARNESS.messagePacing); + }); + + // Command position matters: the probe must not be fooled by a harness name that + // appears as an ARGUMENT. The kimi script's own session probe runs + // `node -e '…KIMI_CODE_HOME…'`, which names kimi inside a string. + it('matches on command position, not substrings inside arguments', () => { + writeLaunchScript('#!/bin/bash\nnode -e \'process.env.KIMI_CODE_HOME\'\nclaude\n'); + expect(resolveHarnessForSession(session('/bin/bash', dir))).toBe('claude'); + }); + + // Pacing is an optimization, never a precondition for delivery. A prior iteration + // of this feature caused a 500 on /api/send by not being total; every failure path + // must degrade to default timing instead of throwing into the delivery path. + it('is advisory and TOTAL — every failure path degrades to defaults, never throws', () => { + // No launch script at all. + expect(resolvePacingForSession(session('/bin/bash', dir))).toBeUndefined(); + // A cwd that does not exist. + expect(resolvePacingForSession(session('/bin/bash', '/nonexistent/path'))).toBeUndefined(); + // An unrecognized agent. + writeLaunchScript('#!/bin/bash\nsome-other-agent --flag\n'); + expect(resolvePacingForSession(session('/bin/bash', dir))).toBeUndefined(); + // A RETIRED built-in name still resolves as a name but has no provider — the + // lookup must return undefined rather than dereferencing nothing (#1338). + expect(resolveHarnessForSession(session('gemini', dir))).toBe('gemini'); + expect(resolvePacingForSession(session('gemini', dir))).toBeUndefined(); + }); + + // `getBuiltinHarness` uses an own-property check; a bare index would hand back + // Object.prototype members as bogus "providers" for a user-controlled name. + it('never treats an inherited Object key as a harness', () => { + expect(resolvePacingForSession(session('constructor', dir))).toBeUndefined(); + expect(resolvePacingForSession(session('toString', dir))).toBeUndefined(); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/render-gate.test.ts b/packages/codev/src/agent-farm/__tests__/render-gate.test.ts index 6779f291d..d2fff7d41 100644 --- a/packages/codev/src/agent-farm/__tests__/render-gate.test.ts +++ b/packages/codev/src/agent-farm/__tests__/render-gate.test.ts @@ -19,9 +19,9 @@ import { gunzipSync } from 'node:zlib'; import { fileURLToPath } from 'node:url'; import { RingBuffer } from '../../terminal/ring-buffer.js'; import { SessionScreen } from '../../terminal/session-screen.js'; -import { classifyScreen, classifyBuffer } from '../servers/render-gate.js'; +import { classifyScreen, classifyBuffer, markerSpanEnd } from '../servers/render-gate.js'; import type { RingSnapshot, GateProfile } from '../servers/render-gate.js'; -import { CLAUDE_PROFILE, CODEX_PROFILE, AGY_PROFILE, resolveProfile } from '../servers/gate-profiles.js'; +import { CLAUDE_PROFILE, CODEX_PROFILE, AGY_PROFILE, KIMI_PROFILE, resolveProfile } from '../servers/gate-profiles.js'; const COLS = 110; const ROWS = 32; @@ -51,13 +51,14 @@ const FIXTURE_DIR = fileURLToPath(new URL('./fixtures/gate', import.meta.url)); function profileForFixture(name: string): GateProfile { if (name.startsWith('codex')) return CODEX_PROFILE; if (name.startsWith('agy')) return AGY_PROFILE; + if (name.startsWith('kimi')) return KIMI_PROFILE; return CLAUDE_PROFILE; // claude-* and the marker-less wrapper/boot fixture } describe('render-gate — real captured fixtures (Spec 1313)', () => { const fixtures = readdirSync(FIXTURE_DIR).filter((f) => f.endsWith('.txt')).sort(); - it('the required states are all captured (claude+codex idle/draft/menu/picker, agy idle/draft/trust, wrapper/boot)', () => { + it('the required states are all captured (claude+codex+kimi idle/draft/menu/picker, agy+kimi trust, kimi multiline, wrapper/boot)', () => { for (const required of [ 'claude-idle.clean', 'claude-draft.busy', @@ -70,6 +71,17 @@ describe('render-gate — real captured fixtures (Spec 1313)', () => { 'agy-idle.clean', 'agy-draft.busy', 'agy-trust.busy', + 'kimi-idle.clean', + 'kimi-draft.busy', + 'kimi-trust.busy', + // The multi-row composer states. `kimi-multiline-bare` is the false-CLEAN + // this profile's regionStartPatterns exists to close — captured, not + // constructed — and menu/picker are the screen class where a LAST-match + // marker search is most likely to settle on the wrong row. + 'kimi-multiline.busy', + 'kimi-multiline-bare.busy', + 'kimi-menu.busy', + 'kimi-picker.busy', 'wrapper-boot.busy', ]) { expect(fixtures.some((f) => f.startsWith(required))).toBe(true); @@ -94,6 +106,133 @@ describe('render-gate — real captured fixtures (Spec 1313)', () => { }); }); +/** + * GUARDRAIL for the one cross-cutting edit Issue #1201 makes to shared, just-merged + * gate logic: the classifier's marker exemption moved from `col === 0` to + * `col < markerSpanEnd(...)`. Every OTHER app's verdicts must be bit-identical + * before and after. + * + * Two independent lines of evidence, because "the fixtures still pass" alone would + * not distinguish "unchanged" from "changed but not covered": + * + * 1. The exact span each shipped profile yields. That number IS the no-op argument: + * claude/codex get 1 (literally the old `col === 0`), agy gets 2 whose extra cell + * is the space in `> ` — already skipped one line earlier by the whitespace rule, + * which runs BEFORE the marker check. If any of these numbers ever moves, the + * no-op claim is void and this test says so. + * 2. Behavioural proof in the only direction that can cause harm. Over-skipping would + * swallow real user text and return a false CLEAN — the corruption the gate exists + * to prevent. So each profile is given the tightest possible draft: a single + * character in the first cell the exemption could wrongly reach. All must stay busy. + */ +describe('render-gate — marker-span exemption is a no-op for claude/codex/agy (Issue #1201 guardrail)', () => { + it('yields exactly the old column-0 span for claude and codex', () => { + // `^[❯›]` matches one cell at index 0 → markerEnd 1 → `col < 1` ≡ `col === 0`. + expect(markerSpanEnd('❯ ', CLAUDE_PROFILE.markerPattern)).toBe(1); + expect(markerSpanEnd('› ', CODEX_PROFILE.markerPattern)).toBe(1); + }); + + it('yields span 2 for agy, whose extra cell is the whitespace the classifier already skipped', () => { + // `^> ` matches TWO cells; cell 1 is a space by construction of the pattern, and the + // whitespace guard runs before the marker guard, so it was never counted either way. + expect(markerSpanEnd('> ', AGY_PROFILE.markerPattern)).toBe(2); + expect('> '[1]).toBe(' '); + }); + + it('yields span 4 for kimi — the column-3 marker the change exists for', () => { + expect(markerSpanEnd(' │ > ', KIMI_PROFILE.markerPattern)).toBe(4); + }); + + it('does not swallow a 1-char draft sitting in the first exempt-adjacent cell', async () => { + // claude/codex: the draft's `x` is at col 1, immediately past a span of 1. + for (const p of [CLAUDE_PROFILE, CODEX_PROFILE]) { + const marker = p === CLAUDE_PROFILE ? '❯' : '›'; + const snap = snapshotFromRaw(screen(`${marker}x`, '──────────────────────')); + expect(await classifyScreen(snap, p)).toMatchObject({ clean: false, detail: 'user-text' }); + } + // agy: `x` at col 2, immediately past a span of 2. + expect(await classifyScreen( + snapshotFromRaw(screen('> x', '──────────────────────')), AGY_PROFILE, + )).toMatchObject({ clean: false, detail: 'user-text' }); + }); + + it('never treats a non-space second cell as part of agy\'s marker (the span cannot over-reach)', async () => { + // `>x` does not match `^> ` at all, so it is not a marker row — the screen has no + // composer and is held. The exemption can therefore never reach a typed character: + // the only way to get span 2 is for cell 1 to BE a space. + expect(markerSpanEnd('>x', AGY_PROFILE.markerPattern)).toBe(1); // no match → the safe default + expect(await classifyScreen( + snapshotFromRaw(screen('>x', '──────────────────────')), AGY_PROFILE, + )).toMatchObject({ clean: false, detail: 'no-composer-marker' }); + }); + + it('is what makes kimi\'s idle composer clean — the `>` glyph is its only occupancy', async () => { + // Direct regression pin for the change's PURPOSE, against the real 0.34.0 capture. + // A profile identical to kimi's but whose marker stops before the `>` (span 2, the + // box edge only) leaves that glyph counted — exactly what the old column-0 rule did — + // and the genuinely-empty composer classifies `user-text`, i.e. holds its mail forever. + const raw = readFileSync(`${FIXTURE_DIR}/kimi-idle.clean.txt`, 'utf8'); + const shortMarker: GateProfile = { ...KIMI_PROFILE, markerPattern: /^\s*│/ }; + expect(await classifyScreen(snapshotFromRaw(raw), shortMarker)) + .toMatchObject({ clean: false, detail: 'user-text' }); + // With the shipped profile's full span, the same bytes are clean. + expect(await classifyScreen(snapshotFromRaw(raw), KIMI_PROFILE)) + .toMatchObject({ clean: true, detail: 'empty' }); + }); + + it('scans the WHOLE kimi composer box, so a draft above a bare-`>` row is still counted', async () => { + // The false-CLEAN found by the 3-way review (2026-08-09, claude F1), pinned against + // the real 0.34.0 capture rather than a constructed screen. kimi renders a two-line + // draft as `│ > implement the whole feature` / `│ >`; the second row matches the + // marker, findMarkerRow takes the LAST match, so scanning from the marker row left + // the real draft ABOVE the region and the composer read empty — a queued message + // would then have been typed on top of unsent user text. + const raw = readFileSync(`${FIXTURE_DIR}/kimi-multiline-bare.busy.txt`, 'utf8'); + expect(await classifyScreen(snapshotFromRaw(raw), KIMI_PROFILE)) + .toMatchObject({ clean: false, detail: 'user-text' }); + + // …and the fix is specifically the region start: the SAME bytes under a profile + // identical except that it declares no upper bound reproduce the old false CLEAN. + // If this ever stops classifying clean, the regionStartPatterns above is no longer + // what is protecting the composer, and this test has stopped testing the fix. + const { regionStartPatterns: _dropped, ...unbounded } = KIMI_PROFILE; + expect(await classifyScreen(snapshotFromRaw(raw), unbounded as GateProfile)) + .toMatchObject({ clean: true, detail: 'empty' }); + }); + + it('holds a boxed composer whose box top is off-screen instead of scanning a partial region', async () => { + // A marker row with no `╭` above it is a torn/mid-repaint frame for a boxed app. + // The region has no proven upper bound, so the safe answer is hold — the same call + // findRegionEnd already makes downward. + const snap = snapshotFromRaw(screen(' │ >', ' ╰────────────')); + expect(await classifyScreen(snap, KIMI_PROFILE)) + .toMatchObject({ clean: false, detail: 'no-region-start' }); + }); + + it('leaves claude/codex/agy on the marker row exactly as before (no region start declared)', async () => { + // The new upper bound is opt-in. These profiles declare none, so findRegionStart + // returns markerRow and the scan is byte-identical to the pre-change behavior — + // including that a row ABOVE the composer is never counted as draft text. + for (const p of [CLAUDE_PROFILE, CODEX_PROFILE, AGY_PROFILE]) { + expect(p.regionStartPatterns).toBeUndefined(); + } + // Behavioural half, on the two profiles whose marker survives screenLines' + // trimEnd on an empty composer (agy's `^> ` cannot — a bare `> ` row trims to + // `>` and stops matching, which is why its idle capture carries hint text). + // Text on the line ABOVE the composer is chat history, not a draft: still clean. + for (const [p, marker] of [[CLAUDE_PROFILE, '❯'], [CODEX_PROFILE, '›']] as const) { + const snap = snapshotFromRaw(screen('some earlier assistant output', marker, '──────────────────────')); + expect(await classifyScreen(snap, p)).toMatchObject({ clean: true, detail: 'empty' }); + } + }); + + it('ignores g/y regex state so a stateful profile pattern cannot alias a previous call', () => { + const sticky = /^\s*│\s*>/gy; + expect(markerSpanEnd(' │ > ', sticky)).toBe(4); + expect(markerSpanEnd(' │ > ', sticky)).toBe(4); // a lastIndex-carrying pattern would drift + }); +}); + describe('render-gate — synthetic branch coverage (Spec 1313)', () => { it('marker + dim placeholder only → clean', async () => { const snap = snapshotFromRaw(screen(`❯ ${DIM}Try "refactor doctor.ts"${RESET}`, '──────────────────────')); 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 6e35adac5..280f130d6 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -71,7 +71,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, KIMI_AGENT_FILE } from '../utils/harness.js'; const getBuilderHarnessMock = vi.fn(() => CLAUDE_HARNESS); const getWorktreeConfigMock = vi.fn(() => ({ symlinks: [], postSpawn: [], devCommand: null, devUrls: [] })); vi.mock('../utils/config.js', () => ({ @@ -483,6 +483,165 @@ describe('spawn-worktree', () => { }); }); + // ========================================================================= + // startBuilderSession — kimi provider-owned launch shape (Issue #1201) + // + // Kimi has no positional prompt and no launch-time session id to pin (both are + // what the generic loops assume), so the harness owns the whole script: the role + // rides `--agent-file`, the task is queued on the Spec 1313 mailbox, and the + // crash path resumes by cwd with the documented `-c`. The #929-class guard here: + // with the kimi harness resolved, no line that INVOKES kimi may carry + // --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; + } + + /** Every line that actually runs kimi (the only lines a mis-injection could hide in). */ + function kimiInvocations(script: string): string[] { + return script.split('\n').filter((l) => /^\s*kimi(\s|$)/.test(l)); + } + + it('fresh spawn: role via --agent-file, task queued on the mailbox, no seed bootstrap', 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(`--agent-file '/tmp/worktree/${KIMI_AGENT_FILE}'`); + expect(script).toContain("codev_builder_id='pir-k1'"); + expect(script).toContain('afx send "$codev_builder_id" "$(cat "$codev_task_file")"'); + // The retired seed-session bootstrap leaves no trace. + expect(script).not.toContain('stream-json'); + expect(script).not.toContain('__CODEV_KIMI_SEED_DONE__'); + expect(script).not.toContain('-S '); + + // #929/#1062 class, scoped to the invocation lines. + const invocations = kimiInvocations(script!); + expect(invocations.length).toBeGreaterThan(0); + for (const line of invocations) { + expect(line).not.toContain('--append-system-prompt'); + expect(line).not.toContain('--resume'); + expect(line).not.toContain('$(cat'); + } + + // The agent file carries the PORT-expanded role wrapped in kimi's format. + const agentFile = findWrite(KIMI_AGENT_FILE); + expect(agentFile).toContain(`ROLE ${DEFAULT_TOWER_PORT}`); + expect(agentFile).toContain('${base_prompt}'); + + // 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}`); + // No seed file, and Tower is never asked to arm a PTY-writing kick — the + // pivot deleted seed-kick entirely; delivery goes through the render gate. + expect(findWrite('.builder-seed.txt')).toBeUndefined(); + expect(createTerminalMock.mock.calls.at(-1)![0].seedKick).toBeUndefined(); + }); + + // The entry probe makes one script shape serve both cases, so `--resume` does not + // produce a DIFFERENT script — it produces the same self-configuring one, which + // resumes because the store already holds a session for this worktree. + it('resume: same self-configuring script; entry is gated on the store probe', 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: '-c' }, + ); + + const script = findWrite('.builder-start.sh'); + expect(script).toBeDefined(); + expect(script).toContain('codev_has_session'); + // The discovered id is NEVER baked into the script — the relaunch is the + // documented cwd-scoped `-c`, so no undocumented id reaches generated bash. + expect(script).not.toContain('session_prev-1'); + expect(script).not.toContain('stream-json'); + for (const line of kimiInvocations(script!)) { + expect(line).not.toContain('--append-system-prompt'); + expect(line).not.toContain('--resume'); + } + }); + + it('claude spawns are unaffected: no provider-owned script (regression)', async () => { + getBuilderHarnessMock.mockReturnValueOnce(CLAUDE_HARNESS); + await startBuilderSession( + { workspaceRoot: '/tmp/ws' } as any, + 'pir-k3', '/tmp/worktree', 'claude', + 'PROMPT', 'ROLE', 'codev', + ); + const script = findWrite('.builder-start.sh'); + expect(script).not.toContain('codev_has_session'); + expect(script).not.toContain('--agent-file'); + expect(createTerminalMock.mock.calls.at(-1)![0].seedKick).toBeUndefined(); + }); + }); + + describe('buildWorktreeLaunchScript (kimi harness — interactive mode)', () => { + it('role, no prompt → --agent-file loop with nothing queued (the operator drives it)', () => { + getBuilderHarnessMock.mockReturnValueOnce(KIMI_HARNESS); + const script = buildWorktreeLaunchScript( + '/tmp/worktree', 'kimi', { content: 'ROLE BODY', source: 'codev' }, '/tmp/ws', + ); + expect(script).toContain('--agent-file'); + expect(script).toContain('while true'); + expect(script).not.toContain('--append-system-prompt'); + // Worktree mode has no task, so nothing is put on the mailbox. + expect(script).not.toContain('afx send'); + expect(script).not.toContain('stream-json'); + }); + + it('no role, no prompt (override spawn) → the plain bare TUI loop', () => { + getBuilderHarnessMock.mockReturnValueOnce(KIMI_HARNESS); + const script = buildWorktreeLaunchScript('/tmp/worktree', 'kimi', null, '/tmp/ws'); + expect(script).toContain('kimi --yolo'); + expect(script).toContain('while true'); + expect(script).not.toContain('stream-json'); + expect(script).not.toContain('--agent-file'); + }); + + // Pacing regression, and the shape the maintainer's PR #1203 finding was about: + // an override spawn (`--builder-cmd kimi` in a claude-configured workspace) must + // still resolve Kimi's Enter timing. It now does so by NAMING kimi in command + // position in the generated script — the signal `resolvePacingForSession` reads — + // which is impossible to forget because the launcher itself carries it. The old + // design needed every shape to remember a separate marker file, and this exact + // shape forgot. + it.each([ + ['with role', { content: 'ROLE BODY', source: 'codev' }], + ['bare (override spawn)', null], + ] as const)('%s → kimi is in command position, so pacing resolves', (_name, role) => { + getBuilderHarnessMock.mockReturnValueOnce(KIMI_HARNESS); + const script = buildWorktreeLaunchScript('/tmp/worktree', 'kimi', role, '/tmp/ws'); + expect(script.split('\n').some((l) => /^\s*kimi(\s|$)/.test(l))).toBe(true); + }); + + // Bugfix #1241 / PR #1244: Kimi's provider-owned scripts share the same + // exit-code-gated loop tail as the generic shapes — deliberate exit 0 + // must NOT auto-respawn. + it.each([ + ['with role', { content: 'ROLE BODY', source: 'codev' }], + ['bare (override spawn)', null], + ] as const)('%s → script does not auto-restart on exit 0', (_name, role) => { + getBuilderHarnessMock.mockReturnValueOnce(KIMI_HARNESS); + const script = buildWorktreeLaunchScript('/tmp/worktree', 'kimi', role, '/tmp/ws'); + expect(script).toContain('status=$?'); + expect(script).toContain('if [ "$status" -eq 0 ]; then'); + expect(script).toContain('Press Enter to relaunch'); + expect(script).toContain('read -r || exit 0'); + }); + }); + // ========================================================================= // Collision Detection (unit-level) // ========================================================================= 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 5e0f11aae..56085e67e 100644 --- a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts +++ b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts @@ -102,6 +102,7 @@ vi.mock('../servers/tower-terminals.js', () => ({ getTerminalsForWorkspace: mockGetTerminalsForWorkspace, getRehydratedTerminalsEntry: mockGetRehydratedTerminalsEntry, isStartupReconcileSettled: mockIsStartupReconcileSettled, + getTerminalSessionById: vi.fn(() => null), })); vi.mock('../servers/tower-tunnel.js', () => ({ diff --git a/packages/codev/src/agent-farm/commands/spawn-worktree.ts b/packages/codev/src/agent-farm/commands/spawn-worktree.ts index 0369004b5..56b065939 100644 --- a/packages/codev/src/agent-farm/commands/spawn-worktree.ts +++ b/packages/codev/src/agent-farm/commands/spawn-worktree.ts @@ -28,7 +28,7 @@ import { globSync } from 'glob'; import type { Config, ProtocolDefinition } from '../types.js'; import { logger, fatal } from '../utils/logger.js'; import { getBuilderHarness, getWorktreeConfig } from '../utils/config.js'; -import { shellEscapeSingleQuote, type HarnessProvider } from '../utils/harness.js'; +import { shellEscapeSingleQuote, launchLoopTail, type HarnessProvider } from '../utils/harness.js'; import { defaultSessionOptions } from '../../terminal/index.js'; import { run, runStreaming, commandExists } from '../utils/shell.js'; import { fetchIssueOrThrow, type ForgeIssue } from '../../lib/github.js'; @@ -778,42 +778,6 @@ function installHarnessWorktreeFiles( } } -/** - * The tail shared by every builder launch loop, appended after the agent - * invocation inside `while true; do … done`. - * - * Issue #1241: exit code 0 is the user deliberately quitting (double Ctrl+C, - * `/quit`) — auto-respawning overrides that choice and forces them to race a - * second Ctrl+C into the sleep window, where a mistimed one lands in the fresh - * agent instead. It also feeds the #1224 class, where a respawn within ~2s - * collides with the dying predecessor's session lock. So a clean exit clears - * the screen and gates the relaunch on a keypress: recovery stays one keystroke - * away without anything happening on its own. Nonzero exits and signal deaths - * (bash reports those as 128+N) keep the historical auto-restart — that is what - * the loop is for. - * - * `read` failing means EOF on stdin, i.e. the terminal is gone; exit rather - * than spin the loop on an input that will never arrive. - * - * `onCleanExit` (Issue #1267) is an extra statement run just after the keypress, - * before the loop repeats — how the resume variant switches itself over to the - * fresh invocation. It sits *after* the `read`, so a terminal that went away - * (EOF → `exit 0`) never mutates state on its way out. - */ -function launchLoopTail(onCleanExit?: string): string { - const switchToFresh = onCleanExit ? `\n ${onCleanExit}` : ''; - return ` status=$? - if [ "$status" -eq 0 ]; then - clear - echo "Agent exited at your request. Press Enter to relaunch fresh, or close this terminal." - read -r || exit 0${switchToFresh} - continue - fi - echo "" - echo "Agent exited (code $status). Restarting in 2 seconds... (Ctrl+C to quit)" - sleep 2`; -} - /** * Build the `while true; do … done` launch loop for a builder script. * @@ -1105,6 +1069,29 @@ export async function startBuilderSession( logger.info(`Resuming session ${resume.sessionId.slice(0, 8)}…`); } + // Provider-owned launch shape (Issue #1201 — Kimi). Taken before the generic + // loops because the reasons for it are exactly what those loops assume away: + // a CLI with no positional prompt (the task is queued on the mailbox instead) + // and no launch-time session id to pin (the crash path resumes by cwd). Role + // injection, the prompt file, and the harness worktree files are all prepared + // above on this path too — the provider gets the same inputs, and its script + // decides the shape. + if (harness.buildBuilderLaunchScript) { + harness.prepareWorkspace?.(worktreePath); + const scriptContent = harness.buildBuilderLaunchScript({ + worktreePath, baseCmd, roleFragment, taskFile: promptFile, builderId, + }); + writeFileSync(scriptPath, scriptContent); + chmodSync(scriptPath, '755'); + logger.info('Creating PTY terminal session...'); + const { terminalId } = await createPtySession( + config, '/bin/bash', [scriptPath], worktreePath, + { workspacePath: config.workspaceRoot, type: 'builder', roleId: builderId }, + ); + logger.info(`Terminal session created: ${terminalId}`); + return { terminalId }; + } + const sessionForms = scriptSessionForms(harness); let loop: string; if (sessionForms) { @@ -1204,6 +1191,19 @@ export function buildWorktreeLaunchScript( installHarnessWorktreeFiles(harness, '', '', worktreePath); } + // Provider-owned launch shape (Issue #1201 — Kimi). Worktree mode has no + // initial task, so the provider gets `taskFile: null` and generates its plain + // loop: nothing is queued on the mailbox, and the operator drives the session + // by typing into it. + if (harness.buildBuilderLaunchScript) { + harness.prepareWorkspace?.(worktreePath); + return harness.buildBuilderLaunchScript({ + worktreePath, baseCmd, + roleFragment: role ? command.slice(baseCmd.length + 1) : '', + taskFile: null, + }); + } + // Worktree mode never enters on a resume, but the loop itself is // session-aware when the harness supports it (Issue #1233): crash restarts // resume the pinned conversation here too. There is no prompt file in this diff --git a/packages/codev/src/agent-farm/servers/gate-profiles.ts b/packages/codev/src/agent-farm/servers/gate-profiles.ts index d3fa73153..c6be69477 100644 --- a/packages/codev/src/agent-farm/servers/gate-profiles.ts +++ b/packages/codev/src/agent-farm/servers/gate-profiles.ts @@ -6,10 +6,12 @@ * layout change is a profile drift the smoke suite catches, never a silent * misdelivery — an unmatched marker classifies NOT clean. * - * Measured apps have a profile: claude, codex (spike g2), and agy (Spec 1313 + * Measured apps have a profile: claude, codex (spike g2), agy (Spec 1313 * Phase 3 measurement — its own marker `> ` and a color-keyed placeholder rule, - * because agy renders its idle hint in palette-8 gray, not SGR-dim). Everything - * else — gemini, opencode, an unknown binary, or a launch we can't identify — + * because agy renders its idle hint in palette-8 gray, not SGR-dim), and kimi + * (Issue #1201 measurement on 0.34.0 — a boxed composer whose marker is not at + * the row start). Everything else — gemini, opencode, an unknown binary, or a + * launch we can't identify — * resolves to `null`, and the caller holds the message with reason `no-profile`. * This is the strict app-identity table the spike mandates (constraint 10): we * deliberately do NOT reuse `resolveHarness`, whose claude fallback would make an @@ -90,10 +92,72 @@ export const AGY_PROFILE: GateProfile = { placeholderFgPalette: 8, }; +/** + * kimi (Kimi Code CLI 0.34.0) composer marker. Unlike claude/codex/agy, kimi + * draws its composer inside a rounded box, so the input row is + * `` │ > `` — a box edge, then the `>` prompt glyph at column 3, NOT at the row + * start. An anchored `^>` would never match it. (Measured, Issue #1201; capture + * harness: `codev/spikes/pir-1201-kimi-gate-measure.mjs`.) + */ +const KIMI_MARKER = /^\s*│\s*>/; + +/** + * The rounded box bottom that closes kimi's composer (`` ╰─────╯ ``, indented by + * one column). The shared {@link REGION_END_PATTERNS} cannot bound kimi: its rule + * pattern requires the line to *start* with the rule glyph, and kimi's starts with + * a space then `╰`. Without its own pattern every kimi screen would classify + * `no-region-end` and hold forever. + */ +const KIMI_REGION_END = [/^\s*╰[─━╌┄]{3,}/]; + +/** + * The rounded box TOP that opens kimi's composer (`` ╭─────╮ ``) — the region's + * upper bound, and the reason a multi-row kimi draft is scanned in full. + * + * kimi's composer grows downward: a two-line draft renders `│ > ` then + * `│ `. When line two begins with `>` (a pasted quote, a markdown + * blockquote) it matches {@link KIMI_MARKER} too, and since the classifier takes + * the LAST match, the region would start there and line one — real, unsent user + * text — would sit above it, uncounted. Measured on 0.34.0 (`kimi-multiline-bare` + * fixture): that screen classified `clean`, and a queued message would have been + * typed on top of the draft. Anchoring the region to the box top fixes it for any + * number of draft rows. + */ +const KIMI_REGION_START = [/^\s*╭[─━╌┄]{3,}/]; + +/** + * kimi composer profile (Issue #1201 — net-new measurement on 0.34.0, the same + * shape of live capture the agy Phase-3 profile rests on). + * + * Measured facts it encodes: + * - marker `│ >` at column 3 (see {@link KIMI_MARKER}); the classifier skips the + * matched span, not just column 0, which is why the `>` glyph is not counted + * as a draft; + * - an idle kimi composer carries **no placeholder text at all** — just the + * marker — so no `placeholderFgPalette` and no dim rule is needed (unlike + * claude/codex's dim placeholder and agy's palette-8 hint); + * - typed text renders **default-fg at normal intensity** → counted → busy; + * - the box chrome (`│ ╭ ╰ ─`) is already in the classifier's ignore set. + * + * Consequences that matter for delivery, both verified against real captures: + * the seed window (`kimi -p --output-format stream-json`, plain JSON lines) has + * no marker → `no-composer-marker` → held, which is the readiness barrier the + * original design built a PTY sentinel for; and the 0.33.0+ **folder-trust + * dialog** likewise has no marker at the row start → held, so a blind Enter can + * never confirm filesystem trust (the same guarantee agy's trust dialog gets). + */ +export const KIMI_PROFILE: GateProfile = { + app: 'kimi', + markerPattern: KIMI_MARKER, + regionStartPatterns: KIMI_REGION_START, + regionEndPatterns: KIMI_REGION_END, +}; + /** Registry keyed by the harness name `detectHarnessFromCommand` returns. */ const PROFILES_BY_HARNESS: Record = { claude: CLAUDE_PROFILE, codex: CODEX_PROFILE, + kimi: KIMI_PROFILE, }; /** diff --git a/packages/codev/src/agent-farm/servers/mailbox-delivery.ts b/packages/codev/src/agent-farm/servers/mailbox-delivery.ts index 096bf4447..18f894184 100644 --- a/packages/codev/src/agent-farm/servers/mailbox-delivery.ts +++ b/packages/codev/src/agent-farm/servers/mailbox-delivery.ts @@ -232,8 +232,9 @@ export interface DeliveryOutcome { /** * A gate outcome the render gate CANNOT bound to a decision — an unrecognized app * (`no-profile`) or a recognized app whose composer region can't be found - * (`no-region-end`/`no-composer-marker` = a drifted TUI layout or an unrenderable #1047 - * ring). A sustained streak of these means the mail will NEVER deliver on its own, so it + * (`no-region-end`/`no-region-start`/`no-composer-marker` = a drifted TUI layout or an + * unrenderable #1047 ring). A sustained streak of these means the mail will NEVER deliver + * on its own, so it * is the class {@link MailboxDrainer.recordStreak} escalates to liveness telemetry; a * `busy`/`user-text` streak is deliberately excluded (a human legitimately at the line). * Shared by `recordStreak` and the cooldown branch of {@link MailboxDrainer.tick} so a @@ -243,7 +244,10 @@ function isClassifierStuck( reason: MailboxReason | null, detail: GateVerdict['detail'] | undefined ): boolean { - return reason === 'no-profile' || detail === 'no-region-end' || detail === 'no-composer-marker'; + return reason === 'no-profile' + || detail === 'no-region-end' + || detail === 'no-region-start' + || detail === 'no-composer-marker'; } /** diff --git a/packages/codev/src/agent-farm/servers/mailbox-wiring.ts b/packages/codev/src/agent-farm/servers/mailbox-wiring.ts index f1b3ee817..21e473356 100644 --- a/packages/codev/src/agent-farm/servers/mailbox-wiring.ts +++ b/packages/codev/src/agent-farm/servers/mailbox-wiring.ts @@ -18,10 +18,11 @@ import { loadConfig } from '../../lib/config.js'; import { terminalDeliverySignals, type PtySession } from '../../terminal/pty-session.js'; import { getWorkspaceTerminals, getTerminalManager } from './tower-terminals.js'; import { broadcastMessage, resolveAgentInRegistry, isResolveError } from './tower-messages.js'; -import { writeMessagePaced } from './message-write.js'; +import { writeMessagePaced, type MessagePacing } from './message-write.js'; import { classifyBuffer, type GateProfile, type GateVerdict } from './render-gate.js'; import { resolveProfile } from './gate-profiles.js'; import { harnessFromLaunchScript, type ContextFsPort } from '../commands/reset/context.js'; +import { detectHarnessFromCommand, getBuiltinHarness } from '../utils/harness.js'; import { getGlobalDb } from '../db/index.js'; import { getArchitectByName } from '../state.js'; import { formatBuilderMessage } from '../utils/message-format.js'; @@ -167,6 +168,53 @@ export function resolveProfileForSession(session: DeliverySession): GateProfile return resolveProfile({ command: harness }); } +/** + * The built-in harness NAME behind a session, by the same two-step used for the + * classifier profile: the launch `command` first, then the launch script for the + * wrapped case (a builder's `command` is `.builder-start.sh`'s shell, not the + * agent). `null` when nothing recognizable is found. + * + * Deliberately a sibling of {@link resolveProfileForSession} rather than a shared + * root: that function resolves agy specially (agy is not a Codev *harness* — it + * has a gate profile but no `HarnessProvider`) and consults `launchArgs`, so + * folding the two would either widen the gate's strict identity rules or narrow + * this one. They answer related but different questions; the duplication is one + * cheap `.builder-start.sh` read per delivery, and the gate path is Spec 1313 + * code that must not be perturbed for a pacing feature. + */ +export function resolveHarnessForSession(session: DeliverySession): string | null { + return detectHarnessFromCommand(session.command) + ?? harnessFromLaunchScript(NODE_FS_PORT, session.cwd); +} + +/** + * Per-harness Enter timing for a delivery target (Issue #1201). + * + * Kimi's paste-detection window swallows an Enter sent 80ms after the body (the + * message-write default), so a Kimi builder needs ~1s or its mail is typed but + * never submitted. Resolution keys off the harness identity recovered from the + * launch script, which is the same self-describing signal the gate already + * resolves and is override-proof by construction: the script is GENERATED from + * the resolved harness, so a `--builder-cmd kimi` spawn against a + * claude-configured workspace still reads `kimi`. (This replaces the earlier + * `.builder-kimi` marker probe, which needed every launch shape to remember to + * write the marker — a coverage obligation that had already been missed once.) + * + * Advisory and TOTAL: pacing is an optimization, never a precondition for + * delivery, so every failure path (unreadable worktree, unknown/retired harness + * name, custom harness) degrades to the message-write defaults rather than + * throwing into the delivery path. A prior iteration of this feature caused a + * 500 on `/api/send` by not being total — that lesson is load-bearing here. + */ +export function resolvePacingForSession(session: DeliverySession): MessagePacing | undefined { + try { + const name = resolveHarnessForSession(session); + return name ? getBuiltinHarness(name)?.messagePacing : undefined; + } catch { + return undefined; + } +} + /** * Classify a session's CURRENT screen for the gate (Spec 1313 render-gate round 2). Reads the * session's persistent {@link SessionScreen} mirror — a bounded headless Terminal fed the @@ -212,7 +260,8 @@ export function makeDeliveryPorts(log: LogFn): DeliveryPorts { getSessionForAgent: (ws, agent) => resolveLiveSessionForAgent(ws, agent), resolveProfile: (session) => resolveProfileForSession(session), classify: (session, profile) => classifyAgentScreen(session, profile), - writeMessage: (session, msg, noEnter) => writeMessagePaced(session, msg, noEnter), + writeMessage: (session, msg, noEnter) => + writeMessagePaced(session, msg, noEnter, resolvePacingForSession(session)), broadcast: (frame) => broadcastDelivered(frame), onHeldStateChange: () => broadcastHeldStateChange(), onEscalation: (info) => broadcastEscalation(info), diff --git a/packages/codev/src/agent-farm/servers/message-write.ts b/packages/codev/src/agent-farm/servers/message-write.ts index e19f927fa..15d7c6c8b 100644 --- a/packages/codev/src/agent-farm/servers/message-write.ts +++ b/packages/codev/src/agent-farm/servers/message-write.ts @@ -57,6 +57,18 @@ export function writeEscapeToSession(session: WritableSession, noEnter: boolean) return ESCAPE_ENTER_DELAY_MS; } +/** + * 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). @@ -67,10 +79,12 @@ export function writeEscapeToSession(session: WritableSession, noEnter: boolean) * * @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'); @@ -81,7 +95,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); } @@ -103,7 +117,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; } @@ -129,9 +143,15 @@ export function writeMessageToSession( * Awaiting the promise is also what makes the per-agent write serializer's * completion-chaining real — the next delivery cannot begin until this submit * (Enter included) is entirely on the wire. + * + * `pacing` (Issue #1201) is the per-harness timing override; the mailbox wiring + * resolves it from the target session's harness. It only moves the Enter later, + * so the promise still resolves at whatever `doneMs` this call actually + * scheduled — a slower Enter is awaited, never raced. */ export function writeMessagePaced( session: WritableSession, message: string, noEnter: boolean, + pacing?: MessagePacing, ): Promise { let delivered = true; const tracked: WritableSession = { @@ -141,6 +161,6 @@ export function writeMessagePaced( return ok; }, }; - const doneMs = writeMessageToSession(tracked, message, noEnter); + const doneMs = writeMessageToSession(tracked, message, noEnter, 0, pacing); return new Promise((resolve) => setTimeout(() => resolve(delivered), doneMs)); } diff --git a/packages/codev/src/agent-farm/servers/render-gate.ts b/packages/codev/src/agent-farm/servers/render-gate.ts index 197e0dc65..7874ad749 100644 --- a/packages/codev/src/agent-farm/servers/render-gate.ts +++ b/packages/codev/src/agent-farm/servers/render-gate.ts @@ -107,6 +107,24 @@ export interface GateProfile { * below the composer is never counted as user text. */ regionEndPatterns: RegExp[]; + /** + * Optional UPPER bound for the composer region, for apps whose composer spans + * more than the marker row (Issue #1201 — kimi draws a multi-row rounded box). + * + * Without one, scanning starts AT the marker row, and since {@link findMarkerRow} + * takes the LAST matching row, any lower row that looks like a marker moves the + * region down past real draft text — which is then never counted, so a composer + * holding a draft classifies CLEAN. Measured on kimi 0.34.0: a two-line draft + * whose second line is a bare `>` renders `│ > ` / `│ >`, and the second + * row matches kimi's marker. + * + * Set it and the region instead starts at the nearest matching line ABOVE the + * marker row (kimi: the box top `╭───`), so the whole composer is scanned. + * Left unset — claude, codex, agy — the region starts at the marker row exactly + * as before, and since no row below a LAST match can match, those profiles + * cannot reach any of the new behavior. + */ + regionStartPatterns?: RegExp[]; /** * Optional per-app placeholder signal: a 16-color palette index whose cells are * treated as placeholder/hint chrome (ignored), NOT user text. This is the @@ -129,10 +147,11 @@ export interface GateVerdict { * reason). `no-composer-marker` = wrapper/boot/picker/unknown screen (or a torn * replay that dropped the marker); `no-region-end` = a marker with no rule/status * line beneath it to bound the composer (a partial/mid-repaint frame) — held - * rather than scanning into status chrome; `user-text` = a draft or menu occupies - * the composer; `empty` = clean. + * rather than scanning into status chrome; `no-region-start` = the mirror of that + * for a profile whose composer is a box (kimi), when the box TOP is not on screen; + * `user-text` = a draft or menu occupies the composer; `empty` = clean. */ - detail: 'no-composer-marker' | 'no-region-end' | 'user-text' | 'empty'; + detail: 'no-composer-marker' | 'no-region-end' | 'no-region-start' | 'user-text' | 'empty'; } /** @@ -166,6 +185,66 @@ function findMarkerRow(lines: string[], markerPattern: RegExp): number { return markerRow; } +/** + * End column (exclusive) of the composer marker on its own row — the span the + * classifier treats as chrome rather than user text. + * + * The marker is chrome *wherever the profile puts it*. claude/codex/agy anchor + * theirs at column 0, which the original column-0 skip covered; kimi renders its + * composer inside a rounded box, so its marker sits at column 3 (` │ > `) and a + * column-0 skip would count the `>` glyph as a draft — classifying a genuinely + * empty Kimi composer `user-text` forever, i.e. holding its mail forever + * (Issue #1201). Skipping the exact span the marker pattern matched covers every + * profile without a per-profile column constant, and is a no-op for the column-0 + * ones (their match starts at 0 and spans 1–2 cells, the second of which is a + * space that was already skipped as whitespace). + * + * The pattern is re-compiled without `g`/`y` so a stateful profile regex can + * never make this depend on a previous call's `lastIndex`. + * + * The returned string index is used as a CELL COLUMN. That holds for every + * profile because each marker pattern admits only narrow (single-column) glyphs + * before its end — `\s`, `│`, `❯`, `›`, `>` — so no wide/CJK cell can precede the + * match and shift string index away from column. A future profile whose marker + * can follow a wide glyph would break that identity and needs a cell-aware span. + * + * Exported for the Issue #1201 guardrail test, which pins the exact span each + * shipped profile yields — that number is the whole basis of the "no-op for + * claude/codex/agy" claim this change rests on. + */ +export function markerSpanEnd(line: string, pattern: RegExp): number { + const stateless = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, '')); + const m = stateless.exec(line); + return m ? m.index + m[0].length : 1; +} + +/** + * First row of the composer region: the row just below the nearest + * `regionStartPatterns` match above `markerRow`, or -1 when the profile declares + * one and none is on screen. + * + * A profile with no `regionStartPatterns` returns `markerRow` — the original + * behavior, byte for byte. + * + * The bound is EXCLUSIVE, mirroring `endRow`: the matched line is the composer's + * boundary, not part of it. That matters concretely — kimi's box top renders + * `╭────╮`, and its right corner `╮` is not in {@link IGNORE_CHARS}, so including + * that row would count the corner as user text and hold every idle kimi composer + * forever. Excluding it keeps the region to the rows that can actually hold a draft. + * + * -1 is deliberate and mirrors {@link findRegionEnd}: for an app whose composer is + * a box, a marker with no box top above it is a partial/mid-repaint frame, so the + * region has no proven UPPER bound. Scanning from the marker row anyway is exactly + * the false-CLEAN this bound exists to prevent, so the caller must hold instead. + */ +function findRegionStart(lines: string[], markerRow: number, startPatterns?: RegExp[]): number { + if (!startPatterns || startPatterns.length === 0) return markerRow; + for (let i = markerRow - 1; i >= 0; i--) { + if (startPatterns.some((p) => p.test(lines[i]))) return i + 1; + } + return -1; +} + /** * First region-ending row after the marker (the rule/status line beneath the * composer), or -1 when none is found. -1 means the composer has no proven lower @@ -280,7 +359,20 @@ export function classifyBuffer( // empty/dim, return a false CLEAN). return { clean: false, reason: 'busy', detail: 'no-region-end' }; } + const startRow = findRegionStart(lines, markerRow, profile.regionStartPatterns); + if (startRow === -1) { + // A boxed composer whose box top is not on screen: the region has no proven + // upper bound, so scanning would count only the tail of a draft that may + // continue above. Hold — the same fail-toward-hold call as `no-region-end`. + return { clean: false, reason: 'busy', detail: 'no-region-start' }; + } const top = buf.viewportY; + // Re-compiled without g/y for the same reason markerSpanEnd does it: a stateful + // profile regex must not let one row's match position affect the next row's test. + const markerTest = new RegExp( + profile.markerPattern.source, + profile.markerPattern.flags.replace(/[gy]/g, ''), + ); const cell = buf.getNullCell(); const probe = buf.getNullCell(); // scratch cell for the ghost-tail look-ahead (never clobbers `cell`) // Cursor position is viewport-relative (matching `row`, which indexes from `viewportY`). @@ -288,14 +380,23 @@ export function classifyBuffer( const cursorCol = buf.cursorX; let userCells = 0; - for (let row = markerRow; row < endRow; row++) { + for (let row = startRow; row < endRow; row++) { const line = buf.getLine(top + row); if (!line) continue; + // The marker is chrome on EVERY row that renders it, not just the row the + // search settled on: a multi-row composer repeats its box edge, and with a + // region that starts above `markerRow` those upper rows are now scanned. + // Rows that do not match contribute 0, so profiles without a region start — + // where the only marker-matching row in the region IS `markerRow` — keep the + // exact previous exemption. + const markerEnd = markerTest.test(lines[row]) + ? markerSpanEnd(lines[row], profile.markerPattern) + : 0; for (let col = 0; col < cols; col++) { line.getCell(col, cell); const ch = cell.getChars(); if (!ch || WHITESPACE.test(ch) || IGNORE_CHARS.has(ch)) continue; - if (row === markerRow && col === 0) continue; // the marker glyph itself + if (col < markerEnd) continue; // the marker glyph itself (see markerSpanEnd) if (cell.isDim()) continue; // placeholder / hint chrome renders dim (claude/codex) if ( profile.placeholderFgPalette !== undefined && diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 8220e20bc..09251aa15 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -50,7 +50,7 @@ import { handleCommandRoute, COMMAND_ROUTE } from './command-relay.js'; import { formatArchitectMessage, formatBuilderMessage } from '../utils/message-format.js'; import type { PtySession } from '../../terminal/pty-session.js'; import { writeMessageToSession, writeEscapeToSession } from './message-write.js'; -import { makeDeliveryPorts, getMailboxDrainer } from './mailbox-wiring.js'; +import { makeDeliveryPorts, getMailboxDrainer, resolvePacingForSession } from './mailbox-wiring.js'; import { deliverAgentMailSerialized, type DeliveryPorts } from './mailbox-delivery.js'; import { deliverCronMail, CRON_SENDER, type CronDeliveryResult } from './cron-delivery.js'; import { @@ -1919,6 +1919,11 @@ async function handleSend( // bypass — no gate, no mailbox row. ESC ends the running turn so already-queued // messages process; the trailing Enter (default) is what lets them through // (matching the verified recovery `afx send --raw "$(printf '\x1b')"`). + // + // Deliberately NOT per-harness paced (Issue #1201), unlike the interrupt path below: + // this route writes no text, and Kimi's swallowed-Enter behaviour is paste detection + // keyed to a preceding text burst. Unmeasured either way on Kimi, so it is left at the + // Spec 1273 timing rather than changed on a guess. if (escape) { // Awaited: the response must not claim delivery before the ESC and its // Enter have actually been written (Spec 1273 verify). @@ -1982,9 +1987,16 @@ async function handleSend( // disjoint lock); interrupt is the explicit gate-bypassing human action, and closing that // cross-path race would require the mailbox write edge to take this lock too (a separate, // larger change — flagged, not done here). + // Pacing (Issue #1201): the interrupt writes body-then-Enter exactly like a gated + // delivery, so it needs the same per-harness Enter timing — without it a Kimi target's + // interrupt text is typed and never submitted (paste detection swallows the Enter), + // which reads as a silently ignored human bypass. Resolution is advisory/total; a + // miss just means the default timing. await submitToSession(result.terminalId, () => { session.write('\x03'); // Ctrl+C - return writeMessageToSession(session, formattedMessage, noEnter, 100); + return writeMessageToSession( + session, formattedMessage, noEnter, 100, resolvePacingForSession(session), + ); }); broadcastMessage({ type: 'message', diff --git a/packages/codev/src/agent-farm/utils/harness.ts b/packages/codev/src/agent-farm/utils/harness.ts index 697584719..4184bee65 100644 --- a/packages/codev/src/agent-farm/utils/harness.ts +++ b/packages/codev/src/agent-farm/utils/harness.ts @@ -17,13 +17,49 @@ * @see codev/specs/591-af-workspace-failure-with-code.md */ +import { dirname, join } from 'node:path'; import { findLatestSessionId, verifySessionOwnership } from './claude-session-discovery.js'; +import { + findLatestKimiSessionId, + ensureKimiWorkspaceTrust, + 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; + /** + * The harness's own role fragment (`buildScriptRoleInjection().fragment`), or + * '' when the spawn carries no role. Passed in rather than recomputed so the + * provider-owned script and the generic shapes inject the role identically. + */ + roleFragment: string; + /** + * Absolute path to `.builder-prompt.txt`, or null when the spawn has no + * initial task (`afx spawn --worktree`). A provider whose CLI takes no + * positional prompt delivers this some other way — Kimi queues it on the + * mailbox — which is why it arrives as a path, not a baked-in string. + */ + taskFile: string | null; + /** + * The builder id, for a provider that must address this builder at runtime + * (Kimi queues its task with `afx send `). Absent in worktree mode. + */ + builderId?: string; +} + export interface HarnessProvider { /** * For Node spawn() call sites (architect.ts, tower-utils.ts). @@ -69,6 +105,19 @@ export interface HarnessProvider { content: string; }>; + /** + * Optional: one-time side effects a harness needs OUTSIDE the worktree before + * its first launch there (Issue #1201). Distinct from `getWorktreeFiles`, + * which can only write files inside the worktree. + * + * Kimi is the only implementer: 0.33.0 added a startup "Trust this folder?" + * dialog, and a builder worktree is always a new folder, so an unattended + * builder would sit on that dialog forever. It pre-records trust in kimi's + * own store. Implementations MUST be idempotent and fail-soft — a failure has + * to degrade to the CLI's normal behavior, never abort a spawn. + */ + prepareWorkspace?(worktreePath: string): void; + /** * Optional: conversation-session support, for agents whose CLI can pin and * resume a session by id (Issue #832). Harnesses that omit this are treated as @@ -133,6 +182,34 @@ 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} ""` shapes. + * + * Kimi is the only implementer, for two reasons the generic shapes cannot + * express: its CLI takes **no positional prompt** (so the task must reach it + * through the mailbox, queued by the script whenever a fresh conversation + * starts), and it mints conversation ids **server-side on the first message** + * (so there is no id to pin at launch and the crash path resumes with the + * cwd-scoped `-c` instead of `session.resumeScriptFragment`). + * + * A provider-owned script is still expected to honor the shared contract: + * clean exit → keypress-gated FRESH relaunch (#1267/#1317), crash → resume, + * repeated fast failures → degrade to fresh. Use {@link launchLoopTail} where + * the generic tail fits. + */ + buildBuilderLaunchScript?(ctx: BuilderLaunchScriptContext): 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 */ @@ -143,6 +220,49 @@ export interface CustomHarnessConfig { roleScriptEnv?: Record; } +/** + * The tail shared by every builder launch loop, appended after the agent + * invocation inside `while true; do … done`. + * + * Issue #1241: exit code 0 is the user deliberately quitting (double Ctrl+C, + * `/quit`) — auto-respawning overrides that choice and forces them to race a + * second Ctrl+C into the sleep window, where a mistimed one lands in the fresh + * agent instead. It also feeds the #1224 class, where a respawn within ~2s + * collides with the dying predecessor's session lock. So a clean exit clears + * the screen and gates the relaunch on a keypress: recovery stays one keystroke + * away without anything happening on its own. Nonzero exits and signal deaths + * (bash reports those as 128+N) keep the historical auto-restart — that is what + * the loop is for. + * + * `read` failing means EOF on stdin, i.e. the terminal is gone; exit rather + * than spin the loop on an input that will never arrive. + * + * `onCleanExit` (Issue #1267) is an extra statement run just after the keypress, + * before the loop repeats — how the resume variant switches itself over to the + * fresh invocation. It sits *after* the `read`, so a terminal that went away + * (EOF → `exit 0`) never mutates state on its way out. + * + * Lives here (not in spawn-worktree.ts, where it was introduced) so + * provider-owned launch scripts — currently Kimi's `buildBuilderLaunchScript` + * — share the exact same tail as the generic shapes without a circular import + * (spawn-worktree.ts already imports from this module). Issue #1201's first + * pass duplicated the tail into the Kimi loops and drifted from it the moment + * #1244 changed the contract; one definition is what stops that recurring. + */ +export function launchLoopTail(onCleanExit?: string): string { + const switchToFresh = onCleanExit ? `\n ${onCleanExit}` : ''; + return ` status=$? + if [ "$status" -eq 0 ]; then + clear + echo "Agent exited at your request. Press Enter to relaunch fresh, or close this terminal." + read -r || exit 0${switchToFresh} + continue + fi + echo "" + echo "Agent exited (code $status). Restarting in 2 seconds... (Ctrl+C to quit)" + sleep 2`; +} + // ============================================================================= // Built-in providers // ============================================================================= @@ -215,6 +335,309 @@ export const OPENCODE_HARNESS: HarnessProvider = { }]), }; +// ============================================================================= +// Kimi (Issue #1201 — builder-only) +// ============================================================================= + +/** + * The agent-definition file the Kimi builder launches with (`--agent-file`). + * Written into the worktree by {@link KIMI_HARNESS.getWorktreeFiles}; distinct + * from `.builder-role.md` (the raw role every harness writes) because kimi + * needs frontmatter and a template body around it. + */ +export const KIMI_AGENT_FILE = '.builder-role-agent.md'; + +/** + * Delayed-Enter timing for Kimi PTYs. Kimi's paste-detection window is longer + * 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; re-verified + * submitting on 0.34.0 (agent-core-v2). The only cost is submission latency, + * which is irrelevant for agent-to-agent messages. Applied via messagePacing. + */ +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 `--agent-file` body: kimi's agent-definition format is YAML + * frontmatter plus a system-prompt template. + * + * `${base_prompt}` is the load-bearing token — it interpolates kimi's own + * default system prompt, so the role EXTENDS the agent's instructions instead + * of replacing them (the `claude --append-system-prompt` analogue). Without it + * the builder would lose kimi's tool-use and safety preamble wholesale. + * Verified on 0.34.0 in both `-p` and interactive TUI mode + * (`codev/spikes/pir-1201-kimi-agentfile-probe.mjs`). + */ +export function buildKimiAgentFile(roleContent: string): string { + return `--- +name: codev-builder +description: Codev builder role, injected at spawn by Agent Farm. +--- +\${base_prompt} + +# Your Role + +${roleContent} +`; +} + +/** + * 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`; +} + +/** + * Runtime guard for the crash-resume path, emitted into the launch script. + * + * `kimi -c` does NOT fail when there is nothing to continue — it prints + * "No sessions to continue under ; starting a fresh session." and starts a + * fresh one anyway (verified, 0.34.0). That fresh session never saw + * `--agent-file` (illegal alongside `-c`), so it would run **roleless** — the + * #929 hazard class, silently. So the loop only takes the `-c` path once a + * session provably exists for this cwd. + * + * 0.33.0's TUI mints no session at startup (verified) — the FIRST MESSAGE mints + * it — so "has the task landed yet?" and "is there anything to resume?" are the + * same question, and this probe answers it directly from the store. + * + * Fails CLOSED: any error (no store, unreadable dir, malformed JSON) exits + * non-zero and the loop relaunches fresh WITH the role, which is always safe. + * + * It mirrors {@link findLatestKimiSessionId} field for field — `cwd ?? workDir`, + * `sameDir`'s realpath tolerance, and `isResumable`'s archived / `session_` + * filters — because the two answer the same question in two languages and a + * divergence is a silent bug in EITHER direction: a probe that says yes where + * discovery says no sends `-c` down its roleless nothing-to-continue path, and a + * probe that says no where discovery says yes restarts a crashed builder with no + * context and re-queues its task. The generated snippet is pinned against fixture + * stores by a unit test that EXECUTES it and cross-checks both answers, so the + * mirroring cannot rot. + */ +const KIMI_HAS_SESSION_PROBE = + 'const {readdirSync,readFileSync,realpathSync}=require("fs"),{join}=require("path");' + + 'const r=join(process.env.KIMI_CODE_HOME||join(require("os").homedir(),".kimi-code"),"sessions");' + + // Mirrors sameDir(): compare canonicalized paths, falling back to the literal + // when realpath fails, so a symlinked worktree or a trailing slash still matches. + 'const n=p=>{p=String(p).replace(/\\/+$/,"")||"/";try{return realpathSync(p)}catch{return p}};' + + 'const a0=process.argv[1],c=n(a0);' + + 'let ws=[];try{ws=readdirSync(r,{withFileTypes:true}).filter(e=>e.isDirectory())}catch{}' + + 'for(const w of ws){let ss=[];' + + // Each level gets its OWN try. A stray non-directory under sessions/ (a + // .DS_Store) made readdirSync throw ENOTDIR into the single outer try, which + // aborted the WHOLE scan — one junk file silently disabled resume for every + // worktree on the machine. + 'try{ss=readdirSync(join(r,w.name),{withFileTypes:true})' + + '.filter(e=>e.isDirectory()&&e.name.startsWith("session_"))}catch{continue}' + + 'for(const s of ss){try{const j=JSON.parse(readFileSync(join(r,w.name,s.name,"state.json"),"utf8"));' + + 'if(j.archived===true)continue;const d=j.cwd??j.workDir;' + + 'if(typeof d==="string"&&(d===a0||n(d)===c))process.exit(0)}catch{}}}' + + 'process.exit(1)'; + +export const KIMI_HARNESS: HarnessProvider = { + buildRoleInjection: () => { + throw new Error( + 'Kimi is only supported as a builder shell, not as an architect shell ' + + '(stage 2 — see issue #1201). Kimi takes no inline system-prompt argument: ' + + 'its role mechanism is "--agent-file ", which needs a file written ' + + 'into the agent\'s directory first — a seam only the builder launch path ' + + 'has. Configure a different shell for the architect ' + + '(e.g., "claude --dangerously-skip-permissions" or "codex").', + ); + }, + // Role rides `--agent-file` (kimi 0.31.0+), pointed at the agent-definition + // file getWorktreeFiles writes next to the raw role. `filePath` is + // `/.builder-role.md`, so its directory is the worktree. + buildScriptRoleInjection: (_content, filePath) => ({ + fragment: `--agent-file '${shellEscapeSingleQuote(join(dirname(filePath), KIMI_AGENT_FILE))}'`, + env: {}, + }), + + // One file: the `--agent-file` definition (role + ${base_prompt}), written next + // to the raw `.builder-role.md` every harness gets. A roleless spawn writes + // nothing — there is no Kimi-launch MARKER any more. The first pass had one + // (`.builder-kimi`) for Tower's pacing probe, and it obliged every launch shape + // to remember to write it — an obligation the bare shape missed, which cost a + // maintainer review cycle. Pacing now reads the harness out of the generated + // `.builder-start.sh` instead (see resolvePacingForSession in mailbox-wiring.ts): + // same override-proof answer, derived from an artifact that cannot be forgotten + // because the launcher itself is the artifact. + getWorktreeFiles: (roleContent) => ( + roleContent + ? [{ relativePath: KIMI_AGENT_FILE, content: buildKimiAgentFile(roleContent) }] + : [] + ), + + // Builder resume (afx spawn --resume). Discovery answers one question — does + // a conversation exist for exactly this worktree? — and the ANSWER, not the + // id, is what the script uses: the relaunch runs the documented cwd-scoped + // `kimi -c`, so no undocumented id is baked into the generated bash. The id + // still rides the return value because callers log it and `spawn.ts` treats a + // null as "nothing to resume" (→ a fresh, role-carrying launch). + // + // #1145 semantics hold: the store records each session's exact cwd, and a + // builder worktree belongs to one builder, so a match cannot be some other + // conversation the user happened to hold in the same directory. + buildResume: (absolutePath, opts) => { + const sessionId = findLatestKimiSessionId(absolutePath, kimiOpts(opts)); + if (!sessionId) return null; + return { + sessionId, + args: ['-c'], + scriptFragment: '-c', + }; + }, + + // 0.33.0's folder-trust dialog would block an unattended builder before its + // composer ever renders; pre-record trust for the worktree Codev just made. + // Idempotent and fail-soft — see ensureKimiWorkspaceTrust. + prepareWorkspace: (worktreePath) => { ensureKimiWorkspaceTrust(worktreePath); }, + + buildBuilderLaunchScript: (ctx) => { + const tuiCmd = kimiTuiCmd(ctx.baseCmd); + const fresh = ctx.roleFragment ? `${tuiCmd} ${ctx.roleFragment}` : tuiCmd; + + // Bare shape (no role, no task — `afx spawn --worktree`, or a spawn with + // neither): the plain loop every session-less harness gets, byte for byte. + // Nothing to pin, nothing to queue; a clean exit relaunches fresh because a + // roleless kimi launch IS fresh. Pacing still resolves for this shape: `kimi` + // sits in command position on its own line, which is what the launch-script + // harness probe matches on. + if (!ctx.taskFile) { + return `#!/bin/bash +cd '${shellEscapeSingleQuote(ctx.worktreePath)}' +while true; do + ${fresh} +${launchLoopTail()} +done +`; + } + + // Task-carrying shape. kimi takes no positional prompt, so the task cannot + // ride argv the way claude's does — it is queued on the Spec 1313 mailbox + // and delivered by the render gate onto a verified-empty composer. That is + // also why the queue call lives INSIDE the fresh launch: a fresh + // conversation needs the task re-delivered, and only the script knows when + // the loop starts one. It mirrors claude's prompt-on-fresh semantics + // exactly, including on a script re-run. + // + // Never a direct PTY write (Spec 1313 forbids it for message writers), so a + // busy line, a boot screen, or 0.33.0's folder-trust dialog simply holds the + // message instead of corrupting or losing it. + // Every interpolated value enters the script exactly once, inside a + // single-quoted assignment escaped by shellEscapeSingleQuote — never inside + // executable double-quoted text. The recovery hints then print the values + // through `printf '%s\n'` with the shell VARIABLE expanded, because bash does + // not re-scan an expansion for command substitution: a builder id or task + // path containing a backtick or `$(…)` is printed literally instead of being + // executed when the hint is shown (CMAP 2026-08-09, codex #3 / claude F3). + const queueTask = `codev_builder_id='${shellEscapeSingleQuote(ctx.builderId ?? '')}' +codev_task_file='${shellEscapeSingleQuote(ctx.taskFile)}' +# Set once the task is on the mailbox, so a crash-restart loop cannot enqueue the +# same mission every two seconds while kimi is failing to start (the mailbox +# PERSISTS a held row — it does not need re-queueing to survive). Reset only on +# the human-gated clean-exit relaunch below, which is a deliberate new +# conversation and does want its task again. +codev_task_queued=0 +codev_queue_task() { + [ "$codev_task_queued" = 1 ] && return 0 + if ! command -v afx >/dev/null 2>&1; then + printf '%s\\n' "WARNING: afx is not on PATH — the builder's task was not queued." >&2 + printf '%s\\n' " Queue it with: afx send $codev_builder_id \\"\\$(cat $codev_task_file)\\"" >&2 + return 0 + fi + if afx send "$codev_builder_id" "$(cat "$codev_task_file")" >/dev/null 2>&1; then + codev_task_queued=1 + return 0 + fi + printf '%s\\n' "WARNING: could not queue the builder's task (is Tower running?)." >&2 + printf '%s\\n' " Retry with: afx send $codev_builder_id \\"\\$(cat $codev_task_file)\\"" >&2 +}`; + + // Crash restart resumes the conversation (#1233's builder-side contract) via + // the DOCUMENTED, cwd-scoped `-c` — no undocumented session id in the script. + // Guarded by codev_has_session because `-c` with nothing to continue does not + // fail: it starts a fresh session that never saw --agent-file, i.e. a + // ROLELESS builder (verified, 0.34.0). The guard fails closed, so the + // fallback is always the role-carrying fresh launch. + return `#!/bin/bash +cd '${shellEscapeSingleQuote(ctx.worktreePath)}' +codev_fast_fail_secs="\${CODEV_LAUNCH_FAST_FAIL_SECS:-15}" + +${queueTask} + +codev_has_session() { + node -e '${KIMI_HAS_SESSION_PROBE}' "$PWD" 2>/dev/null +} + +codev_launch_fresh() { + codev_queue_task + ${fresh} +} + +codev_launch_resume() { + ${tuiCmd} -c +} + +# Entry is self-configuring, which is what makes 'afx spawn --resume' and a +# Tower-side terminal re-create do the right thing without a second script +# shape: a worktree that already holds a conversation is resumed (and the task +# NOT re-queued); a virgin one starts fresh. +if codev_has_session; then + codev_launch=codev_launch_resume +else + codev_launch=codev_launch_fresh +fi +codev_fast_fails=0 +while true; do + codev_started=$SECONDS + "$codev_launch" + status=$? + codev_elapsed=$(( SECONDS - codev_started )) + if [ "$status" -eq 0 ]; then + clear + echo "Agent exited at your request. Press Enter to relaunch fresh, or close this terminal." + read -r || exit 0 + codev_launch=codev_launch_fresh + codev_task_queued=0 + codev_fast_fails=0 + continue + fi + if [ "$codev_elapsed" -lt "$codev_fast_fail_secs" ]; then + codev_fast_fails=$(( codev_fast_fails + 1 )) + else + codev_fast_fails=0 + fi + echo "" + if [ "$codev_fast_fails" -ge 3 ]; then + echo "Agent failing immediately (code $status). Starting a fresh conversation with the original task in 2 seconds... (Ctrl+C to quit)" + codev_launch=codev_launch_fresh + codev_fast_fails=0 + elif codev_has_session; then + echo "Agent exited (code $status). Resuming the conversation in 2 seconds... (Ctrl+C to quit)" + codev_launch=codev_launch_resume + else + echo "Agent exited (code $status) before starting a conversation. Relaunching fresh in 2 seconds... (Ctrl+C to quit)" + codev_launch=codev_launch_fresh + fi + sleep 2 +done +`; + }, + + messagePacing: { enterDelayMs: KIMI_ENTER_DELAY_MS }, +}; + /** * Exported for Spec 1273: `afx reset` identifies a running builder's harness from * its launch script and must check `supportsContextReset` before typing into the @@ -224,6 +647,7 @@ export const BUILTIN_HARNESSES: Record = { claude: CLAUDE_HARNESS, codex: CODEX_HARNESS, opencode: OPENCODE_HARNESS, + kimi: KIMI_HARNESS, }; /** @@ -438,6 +862,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..8b454a7b0 --- /dev/null +++ b/packages/codev/src/agent-farm/utils/kimi-session-discovery.ts @@ -0,0 +1,466 @@ +// Discover Kimi Code CLI sessions for a given working directory by inspecting +// Kimi's on-disk session store, and record the workspace trust the pinned-TUI +// launch shape depends on. +// +// ⚠ 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 layouts beneath it. +// Everything below is observed behavior, re-verified against kimi 0.34.0: +// +// /sessions/wd__<12hex>/session_/state.json +// v2 (0.33.0+): { id: "session_", version: 2, cwd, createdAt, +// updatedAt, archived, agents, custom, lastTurnReason } +// v1 (<= 0.32): { createdAt, updatedAt, workDir, lastPrompt?, title, ... } +// +// /workspace-trust/wd__ +// { root, trustedAt } +// +// Kimi releases weekly and 0.33.0 renamed `workDir` → `cwd` and turned the +// timestamps from ISO strings into epoch milliseconds — a rename that silently +// nulled EVERY session parse (seed id-capture, ownership, resume). So the +// readers below accept both shapes, and `inspectKimiStoreLayout` asserts the +// load-bearing fields explicitly so the NEXT rename fails loudly in +// `codev doctor` instead of degrading to a roleless fresh spawn. +// +// Every function here is fail-soft: missing dirs, unreadable files, and +// malformed JSON yield null/false, never a throw. +// +// The intentionally omitted surface: `session_index.jsonl` (a global id → +// dir/cwd index). The directory scan below is the ground truth the index +// mirrors; reading only the tree keeps us on one undocumented surface, not two. + +import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs'; +import { realpathSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; +import { basename, join } from 'node:path'; + +export interface KimiSessionState { + /** The session's working directory (`cwd` on v2, `workDir` on v1). */ + cwd: string; + /** Epoch ms, normalized from either the v2 number or the v1 ISO string. */ + updatedAt: number | null; + /** Store schema version when present (v2 sessions carry `version: 2`). */ + version: number | null; + /** + * v2's `archived` flag. Load-bearing for resume: kimi excludes archived + * sessions from the cwd listing `-c` continues from, so treating one as + * resumable makes `kimi -c` silently start a FRESH, roleless session — the + * #929 hazard the crash path exists to avoid (CMAP 2026-08-09, codex #1). + */ + archived: boolean; +} + +export interface KimiDiscoveryOpts { + /** 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'); +} + +/** Modification time in epoch ms, or -Infinity when it can't be read (ranks oldest). */ +function mtimeOrNegInf(p: string): number { + try { + return statSync(p).mtimeMs; + } catch { + return -Infinity; + } +} + +/** Canonicalize a path for comparison; fall back to the input when realpath fails. */ +function realpathOrSelf(p: string): string { + try { + 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); +} + +/** + * Normalize a Kimi timestamp to epoch ms. 0.33.0 switched `createdAt`/`updatedAt` + * from ISO strings to numbers; both are accepted so a store holding sessions from + * either era still ranks correctly. + */ +function parseTimestamp(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const t = Date.parse(value); + return Number.isNaN(t) ? null : t; + } + return null; +} + +/** 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; + // v2 (0.33.0+) records `cwd`; v1 recorded `workDir`. Accepting both is what + // keeps a mixed-era store readable — and what stopped 0.33.0 from nulling + // every parse (the hard `workDir` filter this replaces). + const dir = typeof parsed.cwd === 'string' ? parsed.cwd + : typeof parsed.workDir === 'string' ? parsed.workDir + : null; + if (dir === null) return null; + return { + cwd: dir, + updatedAt: parseTimestamp(parsed.updatedAt), + version: typeof parsed.version === 'number' ? parsed.version : null, + archived: parsed.archived === true, + }; + } 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. + * + * The yielded `sessionId` is the directory basename, which is the full + * `session_` form on 0.33.0+ and matches the `id` field of state.json — + * the form `kimi -S` accepts. The builder launch path no longer uses `-S` (the + * crash path resumes with the documented cwd-scoped `-c`), but the id is still + * the store's identity and what {@link inspectKimiStoreLayout} asserts on. + */ +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) }; + } + } +} + +/** + * Would `kimi -c` actually continue this session? + * + * Existing on disk is NOT enough. Kimi lists a cwd's sessions before continuing + * one, and that listing drops archived sessions and ids it does not recognize — + * so a session we call resumable but kimi skips sends `-c` down its + * nothing-to-continue path, which does not fail: it starts a FRESH session that + * never saw `--agent-file`, i.e. a silently roleless builder (#929 class). + * + * Both filters therefore err toward "not resumable", whose fallback is the + * role-carrying fresh launch — always safe. Deliberately NOT folded into + * {@link iterateSessionDirs}: {@link inspectKimiStoreLayout} must keep seeing + * unrecognized ids, because reporting that drift is its entire job. + */ +function isResumable(sessionId: string, state: KimiSessionState): boolean { + return sessionId.startsWith('session_') && !state.archived; +} + +/** + * Return the session id of the most recent Kimi session whose recorded working + * directory is exactly `absolutePath` (realpath-tolerant) and that kimi would + * actually continue (see {@link isResumable}), or null when none exists. + * "Most recent" = max `updatedAt`; sessions with an unparseable timestamp rank + * oldest. + */ +export function findLatestKimiSessionId( + absolutePath: string, + 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.cwd, absolutePath)) continue; + if (!isResumable(sessionId, state)) continue; + // Unparseable timestamps rank below every real epoch (>= 0) but above the + // initial -Infinity sentinel, so a lone malformed match is still returned. + const rank = state.updatedAt ?? -1; + if (rank > bestTime) { + bestTime = rank; + bestId = sessionId; + } + } + return bestId; +} + +/** + * Verify that `sessionId` still has a session on disk whose recorded working + * directory 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); + // Same resumability filter discovery applies: an archived (or unrecognizably + // named) session exists on disk but is not one `kimi -c` will continue, and + // claiming ownership of it would hand the caller a resume that silently + // becomes a roleless fresh session. + return state !== null && sameDir(state.cwd, cwd) && isResumable(sessionId, state); +} + +/** + * Read the state.json of a session by id, or null when the session (or a + * parseable state.json) doesn't exist. Used by ownership verification 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; +} + +/** + * What a store-layout smoke probe found. `ok` means at least one session parsed + * AND carried the load-bearing shape; anything else names what drifted so + * `codev doctor` can say which assumption broke rather than "something changed". + */ +export type KimiStoreLayout = + | { status: 'ok'; sampled: number } + | { status: 'empty' } + | { status: 'drifted'; reason: string }; + +/** + * Assert the store shape this integration actually depends on (Issue #1201). + * + * Kimi ships weekly and has already renamed the working-directory field once + * (`workDir` → `cwd`, 0.33.0), which silently nulled every parse. So this probe + * checks the load-bearing facts EXPLICITLY — a parseable state.json, a + * working-directory field, and a `session_`-prefixed id matching what `-S` + * accepts — and names the first one that fails. A missing/empty store is not + * drift (fresh install). + */ +export function inspectKimiStoreLayout(opts?: KimiDiscoveryOpts): KimiStoreLayout { + const home = getKimiHome(opts); + if (!existsSync(join(home, 'sessions'))) return { status: 'empty' }; + + let sawSessionDir = false; + let sampled = 0; + let badId: string | null = null; + // "Some session still matches" is too weak a health signal for a store that + // migrates: after a rename the OLD sessions keep matching forever and hide every + // new one, so the probe would report ok through exactly the migration it exists + // to catch (CMAP 2026-08-09, codex #5). So track the newest conforming session + // against the newest non-conforming one and report drift only when the bad one is + // STRICTLY newer — a tie (same timestamp, or no timestamps at all) reports ok, + // because a doctor warning that depends on directory-iteration order would be + // worse than the blind spot it closes. + let newestGood = -Infinity; + let newestBad = -Infinity; + let newestBadReason: string | null = null; + for (const { sessionId, sessionDir } of iterateSessionDirs(home)) { + sawSessionDir = true; + const state = readStateJson(sessionDir); + // The id `-S` accepts is the directory basename; 0.33.0+ prefixes it. + const goodId = sessionId.startsWith('session_'); + // Directory mtime, for EVERY session — not `updatedAt`. A session whose + // state.json no longer parses has no `updatedAt` to offer, and mixing the two + // would compare a kimi timestamp against a filesystem one, which is how the + // drifted session always wins. One signal, same units, available for all. + const recency = mtimeOrNegInf(sessionDir); + if (state !== null && goodId) { + sampled++; + if (recency > newestGood) newestGood = recency; + continue; + } + if (state === null) { + if (recency > newestBad) { + newestBad = recency; + newestBadReason = `state.json for "${sessionId}" no longer parses into a working-directory field`; + } + continue; + } + badId ??= sessionId; + if (recency > newestBad) { + newestBad = recency; + newestBadReason = `session id "${sessionId}" is no longer "session_"`; + } + } + if (!sawSessionDir) return { status: 'empty' }; + if (sampled > 0) { + if (newestBad > newestGood && newestBadReason) { + return { + status: 'drifted', + reason: `the most recently written session no longer matches the shape this integration reads — ${newestBadReason}; older sessions still match, which is what a store migration looks like`, + }; + } + return { status: 'ok', sampled }; + } + if (badId) { + return { + status: 'drifted', + reason: `session ids are no longer "session_" (found "${badId}") — "kimi -S " may reject what discovery returns`, + }; + } + return { + status: 'drifted', + reason: 'no session state.json carries a working-directory field ("cwd", or legacy "workDir") — builder resume and ownership checks will degrade to fresh spawns', + }; +} + +/** + * Path of the workspace-trust record kimi (0.33.0+) keys off for `root`. + * + * ⚠ UNDOCUMENTED, derived by observation on 0.34.0 and verified end-to-end + * (writing this file makes the TUI open on a composer instead of the dialog): + * `wd__`. + */ +export function kimiTrustRecordPath(root: string, opts?: KimiDiscoveryOpts): string { + const slug = basename(root).toLowerCase(); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + return join(getKimiHome(opts), 'workspace-trust', `wd_${slug}_${hash}`); +} + +/** + * Smoke-probe the workspace-trust naming scheme (Issue #1201, guardrail 2). + * + * {@link ensureKimiWorkspaceTrust} writes a record whose FILENAME we derive from an + * undocumented hash scheme. If a Kimi update changes that scheme, our pre-write lands + * at a path kimi no longer reads: the dialog reappears, every unattended builder stalls + * on it, and nothing in the codebase notices — the write still "succeeds". + * + * So this validates our derivation against kimi's OWN records. Every file kimi wrote + * carries the `root` it was written for, which lets us recompute the expected filename + * and compare. Agreement on any record proves the scheme still holds; records present + * but none agreeing is exactly the drift that would strand builders. + * + * A missing/empty trust directory is not drift (nothing trusted yet, or kimi < 0.33.0 + * where no dialog exists) — the same fresh-install tolerance the store probe has. + */ +export function inspectKimiTrustLayout(opts?: KimiDiscoveryOpts): KimiStoreLayout { + const dir = join(getKimiHome(opts), 'workspace-trust'); + if (!existsSync(dir)) return { status: 'empty' }; + + let sawRecord = false; + let matched = 0; + let mismatchExample: string | null = null; + // Same recency rule as the store probe, and the same conservative tie-break: + // after a scheme change kimi's OLD records keep agreeing forever, so "any record + // matches" would report healthy through the exact migration this probe exists to + // catch. Drift is reported only when the newest DISAGREEING record is strictly + // newer than every agreeing one. + let newestAgreeing = -Infinity; + let newestMismatchTime = -Infinity; + let newestMismatch: string | null = null; + try { + for (const name of readdirSync(dir)) { + let root: unknown; + try { + root = (JSON.parse(readFileSync(join(dir, name), 'utf-8')) as { root?: unknown }).root; + } catch { + continue; // unreadable/!JSON — not evidence either way + } + if (typeof root !== 'string' || root.length === 0) continue; + sawRecord = true; + const agrees = basename(kimiTrustRecordPath(root, opts)) === name; + const mtime = mtimeOrNegInf(join(dir, name)); + if (agrees) { + matched++; + if (mtime > newestAgreeing) newestAgreeing = mtime; + } else { + mismatchExample ??= name; + if (mtime > newestMismatchTime) { + newestMismatchTime = mtime; + newestMismatch = name; + } + } + } + } catch { + return { status: 'empty' }; + } + + if (!sawRecord) return { status: 'empty' }; + if (newestMismatch && newestMismatchTime > newestAgreeing) { + return { + status: 'drifted', + reason: `the most recently written workspace-trust record ("${newestMismatch}") does not match the derived "wd__" scheme, though older records still do — that is what a naming-scheme change looks like, and it means pre-recording trust for new builder worktrees has already stopped working`, + }; + } + if (matched > 0) return { status: 'ok', sampled: matched }; + return { + status: 'drifted', + reason: `workspace-trust record names no longer match the derived "wd__" scheme (found "${mismatchExample}") — pre-recording trust for new builder worktrees will silently stop working, and unattended builders will stall on the "Trust this folder?" dialog`, + }; +} + +/** + * Pre-record workspace trust for a builder worktree (Issue #1201). + * + * WHY THIS EXISTS. kimi 0.33.0 added a startup "Trust this folder?" dialog, and + * a builder worktree is always a brand-new directory. The dialog renders BEFORE + * any composer, its only non-trusting option **exits kimi**, and there is no + * flag, env var, or config key to suppress it (audited against 0.34.0). So an + * unattended builder would sit on the dialog forever — its task message held by + * the render gate (correctly: no composer marker) until a human typed into the + * terminal. That defeats autonomous spawning outright. + * + * WHY IT IS SAFE. Trust gates exactly one thing — whether project-level MCP + * servers (`.mcp.json`, `.kimi-code/mcp.json`) are loaded from the folder. It + * does not gate tool execution or writes. The record is written ONLY for a + * worktree Codev itself created, for a builder the human explicitly spawned, + * which already runs with `--yolo` (auto-approved tool calls) — so this grants + * strictly less than what launching the builder already authorized, and never + * touches a directory the user did not hand us. + * + * Idempotent (an existing record is left alone) and fail-soft: on any error the + * dialog simply appears, the gate holds the task message, and the mailbox's + * escalation surfaces it — never a silent misdelivery. + * + * @returns true when a record was written, false when one already existed or the + * write failed. + */ +export function ensureKimiWorkspaceTrust(root: string, opts?: KimiDiscoveryOpts): boolean { + try { + const file = kimiTrustRecordPath(root, opts); + if (existsSync(file)) return false; + mkdirSync(join(getKimiHome(opts), 'workspace-trust'), { recursive: true }); + writeFileSync(file, JSON.stringify({ root, trustedAt: Date.now() })); + return true; + } catch { + return false; + } +} diff --git a/packages/codev/src/commands/doctor.ts b/packages/codev/src/commands/doctor.ts index 73013d78a..aefdd1d41 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, getRetirement } from '../agent-farm/utils/harness.js'; +import { getKimiHome, inspectKimiStoreLayout, inspectKimiTrustLayout } from '../agent-farm/utils/kimi-session-discovery.js'; +import { join } from 'node:path'; import { auditPrGates, formatPrGateWarning } from '../lib/pr-gate-audit.js'; import { auditStateFileIgnore } from '../lib/gitignore.js'; import { auditFrameworkRefs, formatFrameworkRefFinding, hasFrameworkOverrides } from '../lib/framework-ref-audit.js'; @@ -263,6 +265,32 @@ const AI_DEPENDENCIES: Dependency[] = [ linux: 'npm install -g opencode-ai', }, }, + // Kimi Code CLI (Issue #1201 — builder-only harness). + // + // The 0.33.0 floor is evidence-based, not conservative-by-default. The hard + // functional break is `--agent-file` (added 0.31.0): below it the builder role + // does not inject at all and the builder runs silently roleless. 0.31.0–0.32.x + // would nominally work, but every live measurement this integration rests on — + // the session-store shape, the folder-trust dialog and its record scheme, and + // the render-gate composer profile — was taken on the agent-core-v2 engine that + // 0.33.0 made the default. Claiming support for versions we never measured is + // exactly the kind of unverified claim that turns into a field bug, so the floor + // sits at the oldest version the evidence actually covers. + { + name: 'Kimi', + command: 'kimi', + versionArg: '--version', + versionExtract: (output: string) => { + const match = output.match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : null; + }, + minVersion: '0.33.0', + required: false, + installHint: { + macos: 'see https://www.kimi.com/code (Kimi Code CLI)', + linux: 'see https://www.kimi.com/code (Kimi Code CLI)', + }, + }, ]; /** @@ -449,6 +477,77 @@ 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.34.0) and + * point at `kimi login` otherwise. + * + * Also runs three 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: the builder integration reads the UNDOCUMENTED store + * to decide whether a crash restart may take `kimi -c`. Drift there degrades + * resume to fresh spawns. + * - Workspace-trust record naming: the builder spawn pre-writes a trust record + * (also undocumented) so an unattended builder is not stranded on 0.33.0+'s + * "Trust this folder?" dialog. Drift there strands every new builder. + * + * Both layout probes exist because these surfaces are undocumented and Kimi ships + * weekly — the store's working-directory field has ALREADY been renamed once. They + * turn a silent degradation into a named warning at `codev doctor` 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' }); + // `status` is null when the process never ran to completion (spawn failure, + // or the 10s timeout killing it by signal). That is "we learned nothing", + // not "config is broken" — reporting it as config issues would put a false + // failure in front of a user whose install is fine but whose machine is slow. + if (result.error || result.status === null) { + // nothing learned — stay silent rather than accuse a healthy install + } else if (result.status !== 0) { + notes.push('"kimi doctor" reports config issues (config check, not auth)'); + } + } catch { + // kimi doctor unavailable/timed out — skip the supplementary config check + } + + // Two undocumented surfaces this integration rides, each with its own probe that + // reports WHICH assumption broke rather than "something changed". Both tolerate a + // fresh install (nothing recorded yet) as not-drift. + const store = inspectKimiStoreLayout(); + if (store.status === 'drifted') notes.push(`session store: ${store.reason}`); + + const trust = inspectKimiTrustLayout(); + if (trust.status === 'drifted') notes.push(`workspace trust: ${trust.reason}`); + + 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'; /** @@ -742,8 +841,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'); @@ -762,6 +863,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)')) { @@ -853,6 +971,22 @@ export async function doctor(): Promise { // ever retired (RETIRED_HARNESSES is extensible). recommendation: `Set shell.architect / shell.architectHarness to "codex" or "claude --dangerously-skip-permissions" in .codev/config.json, or define a custom "${architect.name}" harness and select it explicitly via shell.architectHarness (a bare shell.architect command stays retired)`, }); + } else if (architect.name === 'kimi') { + // Issue #1201: kimi is builder-only. Its role mechanism is + // `--agent-file `, which needs a file written into the agent's + // directory first — a seam only the builder launch path has, so there is + // nothing to inject into a bare architect command. Architect support is + // stage 2. + console.log(''); + console.log(chalk.yellow(' ⚠') + ' Kimi is configured as architect shell — this is unsupported.'); + 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 (architect.name === 'codex') { // Issue #929: codex is a supported architect (config-driven). console.log('');