diff --git a/codev-skeleton/resources/commands/agent-farm.md b/codev-skeleton/resources/commands/agent-farm.md index a8231b9308..7d3973688e 100644 --- a/codev-skeleton/resources/commands/agent-farm.md +++ b/codev-skeleton/resources/commands/agent-farm.md @@ -899,6 +899,82 @@ 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. + +#### Workspace trust for Kimi builders (opt-in, default off) + +kimi 0.33.0+ opens on a "Trust this folder?" dialog, and a builder worktree is +always a new folder — so an unattended Kimi builder stalls on a dialog it cannot +answer. Codev can pre-record trust in kimi's own store, but **only when you ask +for it**: + +```json +{ + "harnessOptions": { + "kimi": { + "autoTrustWorkspace": true + } + } +} +``` + +**The default is `false`, and it is off for a reason.** Folder trust gates exactly +one thing: whether kimi loads MCP servers **defined by the folder itself** +(`.mcp.json`, `.kimi-code/mcp.json`). That is a different boundary from the +`--yolo` tool auto-approval a Kimi builder already runs with — auto-approving +tool calls and letting a checkout introduce new tool-providing processes are not +the same permission. Spawning a builder onto a contributor branch is an ordinary +workflow, so this decision is yours to make rather than one Codev makes quietly +on your behalf. + +Even with the opt-in set, the pre-write is **refused** for any worktree that +contains `.mcp.json` or `.kimi-code/mcp.json`. That is the one case where the +trust decision actually grants something, so it is the one case a human answers. +The log line names which file caused the refusal. + +**Consequence worth planning around:** if your repository ships a root +`.mcp.json`, every Kimi builder worktree hits that refusal, so unattended Kimi +spawning will not work until a human trusts each folder once. On any refusal +nothing is lost — kimi shows its dialog, and the builder's queued task is *held* +by the render gate (never misdelivered) and surfaces through the mailbox's +escalation telemetry. + +Note `harnessOptions` is a separate key from `harness`, which defines *custom* +harnesses; settings for built-in harnesses go under `harnessOptions`. + +#### No write-guard for Kimi builders yet + +Kimi builders do NOT get the worktree write-guard Claude builders have, so a Kimi +builder can write into the main checkout. kimi does document a blocking +`PreToolUse` hook seam, so parity is achievable follow-up work rather than a +permanent limitation — but it is not in place today, and that is worth weighing +before running Kimi builders unattended. + +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 0000000000..39e62d9cec --- /dev/null +++ b/codev/plans/1201-support-kimi-code-cli-as-a-bui.md @@ -0,0 +1,159 @@ +# PIR Plan: Support Kimi Code CLI as a builder + +**Issue**: cluesmith/codev#1201 · **PR**: cluesmith/codev#1203 +**Re-planned**: 2026-09-04 under #1620, against `main` after 1,606 commits of convergence. + +> **Why this file was rewritten.** The originally-approved plan described a **seed-session bootstrap**: a `kimi -p "" --output-format stream-json` one-shot whose captured session id pinned a `kimi -S --yolo` TUI loop, with a sentinel-gated, store-verified `BEGIN` kick written straight to the PTY, plus a `message-pacing.ts` module and a `.builder-kimi-session` marker file. **None of that is in the branch.** It was retired by the 2026-08-09 design pivot (PR #1203 comment thread) once kimi 0.31.0's `--agent-file` and Spec 1313's mailbox made both halves — role and task — expressible through sanctioned seams. Approving the old text would approve a design the code does not implement, so this document now describes the shipped architecture, amended by the #1620 security decisions. Deleted outright: `seed-kick.ts`, `.builder-seed.txt`, the ack-and-wait discipline, the `lastPrompt` dependency (which kimi 0.33.0 broke anyway), `message-pacing.ts`, and the `.builder-kimi` marker. + +**Scope fence** (unchanged): the builder-MVI checklist in #1201. Kimi as an **architect** is out of scope and fails loudly. No ACP / `kimi server` adapter. No changes to `tower-utils` / `tower-instances` / `tower-terminals` / `session-manager` / `architect.ts`. + +**Evidence rule**: documented claims cite Kimi's CLI reference. The session store layout (`sessions//session_/state.json`) and the workspace-trust record naming (`workspace-trust/wd__`) are **undocumented, observed** surfaces — pinned behind a version floor and two `codev doctor` drift probes that fail loudly when either scheme moves. + +--- + +## Understanding + +Configuring `shell.builder: "kimi"` (or `builderHarness: "kimi"`, or `--builder-cmd kimi`) produced a broken builder: + +1. `detectHarnessFromCommand('kimi')` did not recognise `kimi`, so `resolveHarness` fell through to `CLAUDE_HARNESS` — the #1062 false-Claude fallthrough. +2. The false Claude harness generated a script appending `--append-system-prompt "$(cat role)"` **and** a positional prompt. Kimi rejects both, so the `while true` loop restarted into the same failure forever. +3. It also exposed Claude's `buildResume`, so a stale Claude `.jsonl` for the worktree path could route `--resume ` into `kimi` — the pre-#929 crash-loop class. + +Kimi's CLI differs from every harness Codev supported before in three ways that the generic launch shapes cannot express: + +- **No positional prompt.** The task cannot ride argv the way Claude's does. +- **Server-side session ids, minted on the first message.** There is no id to pin at launch, so `session.newSessionScriptFragment` (#1233) has nothing to mint. +- **A startup folder-trust dialog** (0.33.0+) that renders *before* any composer and whose only non-trusting option exits kimi. + +## Proposed Change + +### 1. Harness identity — `utils/harness.ts` + +`detectHarnessFromCommand` recognises a `kimi` basename, which alone kills the #1062 fallthrough. `KIMI_HARNESS: HarnessProvider`: + +- **`buildRoleInjection` throws.** Kimi is builder-only; the message names the reason (kimi's role mechanism is `--agent-file `, which needs a file written into the agent's directory — a seam only the builder launch path has) and points at claude/codex for the architect. The OpenCode precedent. +- **`buildScriptRoleInjection`** returns `--agent-file '/.builder-role-agent.md'`. +- **`getWorktreeFiles`** writes that agent-definition file — YAML frontmatter plus a body opening with `${base_prompt}`. That token is load-bearing: it interpolates kimi's own default system prompt so the role **extends** rather than replaces it (the `--append-system-prompt` analogue). A roleless spawn writes nothing. +- **`messagePacing: { enterDelayMs: 1000 }`.** Kimi's paste-detection window swallows an Enter arriving too soon after the body. Bisected live on 0.27.0 (POC probe-10 method): **80 ms and 100 ms are swallowed and never submit**; 120, 250, 500 and 1000 ms all submit — threshold ≈ 100–120 ms. Pinned at 1000 ms for ~9× margin, re-verified submitting on 0.34.0 under agent-core-v2; the only cost is submission latency, which is irrelevant agent-to-agent. The override governs **both** Enter sites: `SIMPLE_ENTER_DELAY_MS` (50 ms, short frames) and `PASTE_ENTER_DELAY_MS` (80 ms, long frames since #1567) — the latter matters because 80 ms is the first row of that bisect, i.e. a value measured safe on claude and codex and measured *fatal* on Kimi. +- **`prepareWorkspace`** — the trust record, item 4. +- **`buildBuilderLaunchScript`** — item 2. +- **`buildResume`** answers one question (does a conversation exist for exactly this worktree?) and the *answer*, not the id, is what the script uses: it returns `args: ['-c']` / `scriptFragment: '-c'`, so no undocumented id is ever baked into generated bash. The id rides the return value only because callers log it and a `null` means "nothing to resume". +- **No `session` block.** The stored-UUID contract requires minting an id at spawn, which Kimi cannot do. + +`launchLoopTail` moves from `spawn-worktree.ts` into `harness.ts` (exported, unchanged byte for byte) so the provider-owned script can share it. `spawn-worktree.ts` already imports from `harness.ts`, so this is the acyclic direction. + +### 2. Provider-owned launch script + +`startBuilderSession` and `buildWorktreeLaunchScript` branch to `harness.buildBuilderLaunchScript` when present; every existing harness keeps the generic shapes untouched. Three generated shapes: + +**Bare** (`afx spawn --worktree`, or no role and no task) — the plain loop, byte for byte what any session-less harness gets. A roleless kimi launch *is* fresh, so a clean exit relaunches fresh. + +**Task-carrying** — the interesting one: + +- **Task delivery rides the mailbox.** The script calls `afx send "$(cat .builder-prompt.txt)"` on each *fresh* launch. It is never a direct PTY write, which Spec 1313 forbids for message writers — so a busy line, a boot screen, or the folder-trust dialog simply **holds** the message instead of corrupting or losing it. It lives inside the fresh path because only the script knows when a new conversation starts, mirroring claude's prompt-on-fresh semantics. +- **`codev_task_queued` guards the crash loop.** Set once the row is on the mailbox, so a builder failing to start cannot re-enqueue the same mission every two seconds (the mailbox *persists* a held row; it does not need re-queueing to survive). Reset only on the human-gated clean-exit relaunch, which is a deliberate new conversation and does want its task again. **Accepted tradeoff in the other direction:** the reset assumes the first row was delivered, which is the common case but not a guarantee — quit at a screen that never rendered a composer and the reset queues a second identical row. One mission, stated twice, recoverable by reading; the alternative (de-duplication) needs either a delivery receipt the script cannot see or a mailbox-side identity check. +- **Every interpolated value enters the script exactly once**, inside a single-quoted assignment escaped by `shellEscapeSingleQuote`, never inside executable double-quoted text. Recovery hints print through `printf '%s\n'` with the shell *variable* expanded, because bash does not re-scan an expansion for command substitution — so a builder id or task path containing a backtick or `$(…)` is printed literally rather than executed. +- **`afx` missing from PATH, or Tower down**, prints a warning with the exact recovery command and continues. Queueing is best-effort; it never aborts a launch. + +**Crash resume is guarded `kimi -c`** (documented, cwd-scoped) rather than an undocumented id: + +- `kimi -c` with nothing to continue **does not fail** — it starts a fresh session that never saw `--agent-file`, i.e. a silently **roleless** builder. So the loop only takes `-c` once an inlined node probe proves a session exists for this cwd. The probe is checked on **both** stdout and exit status: stdout alone would accept anything written by a `node` wrapper on PATH or a `NODE_OPTIONS=--require` preload. +- The probe compares session **identity**, not existence, to honour #1267's sticky-fresh contract. Because `-c` is cwd-scoped, existence alone leaves a real gap: a clean exit relaunches fresh, 0.33.0+ mints no session until the first message lands, and a crash inside that pre-mint window would find the just-abandoned conversation still newest and continue *it* — resurrecting exactly what the user walked away from. The loop records the superseded id at clean exit and refuses `-c` until the newest id differs. +- The probe **mirrors `findLatestKimiSessionId` field for field** — per-field `typeof` on `cwd` then `workDir` (not `cwd ?? workDir`, which short-circuits on a non-string `cwd`), realpath tolerance, archived/`session_` filters, and timestamp ranking — because a divergence is a silent bug in either direction. A unit test **executes** the generated snippet against fixture stores and asserts its answer equals discovery's, so the mirroring cannot rot. +- **Fails closed**: any error prints nothing and routes the loop to the fresh, role-carrying launch. Two edges are traced and accepted (the superseded id lives in loop memory, so it does not survive terminal re-creation; a GC that drops the *newest* session while keeping older ones would let `-c` continue an older conversation). + +### 3. Render-gate composer profile — `servers/gate-profiles.ts`, `servers/render-gate.ts` + +Under Spec 1313, delivery only happens for a **measured** harness; an unknown one holds every message with `no-profile`. So a Kimi profile is a functional prerequisite, not polish. Kimi breaks two assumptions the existing profiles share: + +- **Its marker is not at the row start.** Kimi draws a rounded box, so the input row is `│ > `, marker at column 3. `KIMI_MARKER = /^\s*│\s*>/`, and the classifier's chrome exemption becomes "skip the exact span the marker pattern matched" (`markerSpanEnd`) rather than "skip column 0" — a no-op for claude/codex/agy, whose matches start at 0. A guardrail test pins the exact span each shipped profile yields, because that number is the whole basis of the no-op claim. +- **Its composer spans more than the marker row.** `regionStartPatterns` (`/^\s*╭[─━╌┄]{3,}/`) gives the region a proven **upper** bound; without one, last-match-wins moves the region *below* real draft text and a composer holding a draft classifies `clean`. The bound is exclusive, because the box top's `╮` corner is not in the ignore set. A profile that declares a region start but has none on screen yields the new detail `no-region-start` and holds — the mirror of `no-region-end`. + +`KIMI_REGION_END = [/^\s*╰[─━╌┄]{3,}/]`, because the shared rule patterns require the line to *start* with the rule glyph and kimi's starts with a space. + +**`growsWithDraft`** is a separate opt-in that arms the `multi-row-draft` rule: one draft shape has *zero* countable cells (type a newline then `>`, and every cell is whitespace, box chrome, or an exempted marker), so shape is the only evidence left. It is sound only because box growth is **exclusive to multi-line drafts**, measured rather than assumed — idle, single-line draft, `/` menu, `@` picker, mid-generation at 5 s and 13 s, shift+tab mode chrome, a draft typed while working, and the post-reply steady state **all** hold at one interior row. The steady state is the load-bearing measurement: growth on a composer that has already carried a turn would hold every later message forever, a liveness bug rather than a fail-safe one. It is deliberately kept separate from `regionStartPatterns` because the shipped `codex-idle.clean.txt` capture is a genuinely *empty* composer spanning two interior rows — fold the two together and the day anyone declares a region start for codex, codex mail stops delivering silently. + +The profile deliberately sets **no** `markerFgPalette` and **no** `placeholderFgPalette`: an idle Kimi composer carries no placeholder text at all, and the palette anchor is not needed once the region is bounded. Two consequences verified against real captures: the trust dialog has no marker → `no-composer-marker` → held, so a blind Enter can never confirm filesystem trust; and `!` bash mode replaces the `>` glyph → also held, correctly, because there is unsent input on that row. + +**Both new details escalate.** `no-region-start` and `multi-row-draft` are added to `MailboxGateDetail`, to the schema column comment, and to `isUnverifiableVerdict` in `packages/sdk/src/hold-verdict.ts` — the single definition of "will this hold clear on its own?", which `mailbox-delivery.ts`'s `isClassifierStuck` delegates to. There is deliberately **no** second copy of that rule: an escalation policy and an operator-facing remedy that disagree about the same row is the failure mode the sharing exists to prevent. A test enumerates `GateVerdict['detail']` and asserts every value is classified, so the union cannot grow silently. + +### 4. Workspace trust — `utils/kimi-session-discovery.ts` *(amended by #1620)* + +kimi 0.33.0 opens on a "Trust this folder?" dialog, and a builder worktree is always a brand-new directory. There is no flag, env var, or config key to suppress it, so an unattended builder would sit there forever. Codev can pre-record trust in kimi's own store (`workspace-trust/wd__`, derived by observation and verified end-to-end). + +Trust gates exactly one thing: whether **project-level MCP servers** (`.mcp.json`, `.kimi-code/mcp.json`) load from the folder. It does not gate tool execution or writes. That is a narrow grant — but it is still a capability grant made on the user's behalf, so `ensureKimiWorkspaceTrust` refuses in two independent cases and returns a structured `KimiTrustDecision` so callers can log *which*: + +- **`not-opted-in`** — the default. The pre-write happens only when `.codev/config.json` sets `harnessOptions.kimi.autoTrustWorkspace: true`. +- **`project-mcp-config`** — the worktree contains `.mcp.json` or `.kimi-code/mcp.json`. This is the one case where the trust decision is genuinely load-bearing, so it is the one case a human must make, opt-in or not. + +On either refusal the dialog appears, the render gate holds the task message with `no-composer-marker` (in the escalation class, so it surfaces rather than hanging silently), and the log names the reason. Also `already-trusted` (idempotent — an existing record is left alone) and `write-failed` (fail-soft: a failure degrades to the CLI's normal behaviour, never aborts a spawn). + +**Consequence, documented rather than footnoted:** a repository shipping a root `.mcp.json` hits the second refusal on *every* Kimi builder worktree, so unattended Kimi spawning does not work there until a human trusts the folder once. + +`HarnessProvider.prepareWorkspace` takes `(worktreePath, { autoTrustWorkspace })`; both call sites resolve the flag from config. `harnessOptions` is a **new** config namespace, separately typed and validated — deliberately not `harness.kimi`, because `harness.*` is the custom-harness-definition namespace whose load-time validator hard-requires `roleArgs`/`roleScriptFragment` (a settings-shaped entry there would throw at config load and break unrelated commands), and because built-ins already win resolution, making `harness.kimi` dead config. + +### 5. Version floor and drift probes — `commands/doctor.ts` + +Floor **0.33.0**, evidence-based rather than conservative-by-default: 0.33.0 made the agent-core-v2 engine the default, renamed `state.json`'s `workDir` → `cwd`, prefixed session ids `session_`, stopped minting a session at TUI startup, and added the trust dialog. Everything below it is an engine this integration never measured. **Never lowered.** + +`codev doctor` reports kimi presence, the version gate, an auth heuristic, and two drift probes that exist because the surfaces they watch are undocumented: + +- **Store layout** — session ids still `session_`, `state.json` still carries a working-directory field. Both probes use the same conservative recency tie-break: drift is reported only when the newest *disagreeing* record is strictly newer than every agreeing one, because after a scheme change the old records keep agreeing forever. +- **Trust-record naming** — recompute the expected filename from the `root` each of kimi's own records carries and compare. If the scheme moves, the pre-write lands where kimi no longer reads, the dialog reappears, and nothing else in the codebase would notice: the write still "succeeds". + +Configuring kimi as the **architect** warns, matching the opencode/gemini precedent. + +### 6. Per-harness Enter pacing on the delivery path + +`MessagePacing { enterDelayMs? }` overrides message-write's default Enter delay for both the simple and the paced branches; all other timing is unchanged, and the override only moves the Enter *later*, so `submitMessagePaced`'s promise still resolves after the Enter is on the wire. Resolution keys off the harness recovered from the generated `.builder-start.sh` — the same self-describing signal the gate resolves, and override-proof by construction because the script is *generated from* the resolved harness, so `--builder-cmd kimi` against a claude-configured workspace still reads `kimi`. (This replaced an earlier `.builder-kimi` marker file, whose every launch shape had to remember to write it — an obligation one shape missed.) + +Resolution is **advisory and total**: unreadable worktree, unknown or retired harness, custom harness — every failure path degrades to the defaults rather than throwing into the delivery path. An earlier iteration 500'd `/api/send` by not being total; that lesson is load-bearing here. + +Applied at the mailbox `writeMessage` binding (which covers cron delivery too, since it writes through the same port) and on the `--interrupt` path, which writes body-then-Enter exactly like a gated delivery. Since Issue #1567 a long frame is written as one bracketed paste with the Enter outside it, so the override displaces `PASTE_ENTER_DELAY_MS` (80 ms) on that branch — the exact delay Kimi's bisect showed is swallowed — as well as `SIMPLE_ENTER_DELAY_MS` on the short branch. + +Kimi takes the **default `BRACKETED_PASTE`** write strategy (owner decision, 2026-09-08): only agy opts out, and `writeStrategyForApp` is not modified by this work. Whether Kimi honours the mode is measured on a live CLI rather than assumed either way — see the Test Plan. **Not** applied to `--escape`, which writes no text: Kimi's swallowed-Enter behaviour is paste detection keyed to a preceding text burst, and pacing it either way is unmeasured, so it stays at the Spec 1273 timing rather than changing on a guess. + +### 7. Out of scope, fenced + +Kimi as architect (stage 2). ACP / `kimi server`. `PreToolUse` write-guard parity for Kimi builders (#1018 class — kimi *does* now document blocking hooks, so this is a real follow-up, not an impossibility; the stale "no hook seam" claim is corrected in the docs). A `codev doctor` premise probe for the box-growth assumption. Echo-verification tolerance beyond measurement (#1578). + +--- + +## Files to Change + +- `packages/codev/src/agent-farm/utils/harness.ts` — `KIMI_HARNESS`, detection, `BuilderLaunchScriptContext`, `buildBuilderLaunchScript` / `prepareWorkspace` / `messagePacing` capabilities, `buildKimiAgentFile`, the resume probe, relocated `launchLoopTail` +- `packages/codev/src/agent-farm/utils/kimi-session-discovery.ts` — store scan, ownership verify, state reader, trust record + refusals, both drift probes (all fail-soft, `KIMI_CODE_HOME`-aware) +- `packages/codev/src/agent-farm/commands/spawn-worktree.ts` — provider-owned script branch in both entry points; resolve and pass `autoTrustWorkspace` +- `packages/codev/src/agent-farm/servers/gate-profiles.ts` — `KIMI_PROFILE` + registry +- `packages/codev/src/agent-farm/servers/render-gate.ts` — `regionStartPatterns`, `growsWithDraft`, `markerSpanEnd`, `findRegionStart`, per-row marker exemption, the two new details +- `packages/sdk/src/hold-verdict.ts` — `isUnverifiableVerdict` classifies both new details +- `packages/codev/src/agent-farm/db/types.ts`, `db/schema.ts` — `MailboxGateDetail` union + column comment +- `packages/codev/src/agent-farm/servers/message-write.ts` — `MessagePacing`, threaded through `writeMessageToSession` and `submitMessagePaced` +- `packages/codev/src/agent-farm/servers/mailbox-wiring.ts` — `resolveHarnessForSession`, `resolvePacingForSession`, pacing at the `writeMessage` binding +- `packages/codev/src/agent-farm/servers/mailbox-delivery.ts` — no local stuck-verdict fork; `isClassifierStuck` delegates +- `packages/codev/src/agent-farm/servers/tower-routes.ts` — pacing on `--interrupt`; `--escape` deliberately unpaced +- `packages/codev/src/agent-farm/types.ts`, `packages/codev/src/lib/config.ts` — `harnessOptions`, typed and validated at load +- `packages/codev/src/commands/doctor.ts` — presence, 0.33.0 floor, auth heuristic, both drift probes, architect warning +- Tests: `kimi-session-discovery.test.ts`, `mailbox-pacing.test.ts` (new); `harness.test.ts`, `render-gate.test.ts`, `spawn-worktree.test.ts`, `config.test.ts`, `bugfix-584-send-multiline-pacing.test.ts`, `tower-routes.test.ts` (extended); `__tests__/fixtures/gate/kimi-*.txt` (eight captures + README) +- Docs: `codev/resources/arch.md` (Kimi subsection), `codev/resources/commands/agent-farm.md` + `codev-skeleton/` mirror +- `codev/spikes/pir-1201-kimi-*.mjs` — the measurement spikes and the runnable live-demo driver + +## Risks & Alternatives Considered + +- **Risk — undocumented surfaces (store layout, trust-record naming) move under us.** Two doctor drift probes with a conservative recency tie-break, a version floor, and fail-closed readers everywhere. Direction of failure is always "fall back to a fresh, role-carrying launch" or "hold the message", never "deliver onto a screen we failed to understand". +- **Risk — the box-growth premise drifts and `multi-row-draft` becomes permanent.** It escalates (item 3), and a doctor premise probe is filed as a follow-up. +- **Risk — Kimi ships weekly and re-drifts.** Realised once already (0.27.0 → 0.34.0 → 0.41.0). Mitigated by the probes, not by pinning. +- **Alternative rejected — the seed-session bootstrap** (the original plan). Necessary when 0.27.0 had no role flag and no way to deliver a task; obsolete once `--agent-file` and the mailbox existed. It also required a direct PTY write, which Spec 1313 forbids. +- **Alternative rejected — pin the resumable session by id instead of `kimi -c`.** Would bake an undocumented id into generated bash, and kimi mints ids server-side on the first message, so there is nothing to pin at launch. +- **Alternative rejected — trust pre-write default-on with an opt-out.** Preserves unattended spawning out of the box, and silently grants a capability the user never chose. Default-off is the only direction whose failure mode is an inconvenience rather than a security event. +- **Alternative rejected — a `.builder-kimi` marker for pacing resolution.** Obliged every launch shape to remember to write it; one shape missed, and it cost a maintainer review cycle. + +## Test Plan + +**Unit** — `harness.test.ts` (all three generated script shapes: task queueing before the loop, the `-c` guard's both-signals check, the superseded-id comparison, `launchLoopTail` interpolation, architect-use throw, agent-file `${base_prompt}` composition); `kimi-session-discovery.test.ts` (store scan against fixture stores, per-field `cwd`/`workDir`, realpath tolerance, archived filter, ranking, both drift probes, and the six trust-decision cases); a test that **executes** the generated resume probe and asserts it agrees with `findLatestKimiSessionId`; `render-gate.test.ts` against the eight Kimi fixtures plus the `markerSpanEnd` guardrail for every shipped profile; `mailbox-pacing.test.ts` (pacing resolves from the launch script, is total on every failure path, and reaches the Enter through `submitMessagePaced`); `config.test.ts` (`harnessOptions` parse, default, rejection); the `hold-verdict` exhaustiveness test. The **#929 class** is covered from four angles: `kimi` + a stale Claude `.jsonl` can never yield `--resume ` or `--append-system-prompt` — harness `buildResume`, `discoverResumeSession`, config/override resolution, and generated-script assertions. + +**Build + suites** — `pnpm build` clean; full `pnpm test` green, including the `codev-core` / `codev-sdk` boundary tests (the `hold-verdict.ts` edit touches the SDK). + +**Live demo** (against a real authenticated kimi ≥ 0.33.0). Run by @mohidmakhdoomi, not by the #1620 re-plan lane — the human confirmed on 2026-09-05 that no authenticated Kimi is available maintainer-side, so the Kimi-facing evidence comes from the contributor and attaches to PR #1203. `node codev/spikes/pir-1201-kimi-builder-demo.mjs`, nine scenarios: gate classifies the live composer; role honoured via `--agent-file` in the interactive TUI; multi-line delivery submits at the pinned Enter delay; crash restart consults the store probe and chooses resume; role survives the `-c` resume; the probe fails closed on an empty store; trust pre-record is idempotent; **an opted-out spawn writes no record**; **a worktree carrying `.mcp.json` writes no record and logs the refusal.** Plus the full Tower path (`afx spawn --builder-cmd kimi`, `afx send` with a multi-line body, `codev doctor`). + +**Measured facts and who measured them.** The render-gate profile, the `growsWithDraft` box-growth premise, the 1000 ms Enter delay, the trust-record naming scheme and the `kimi -c` newest-session semantics were all measured on **kimi 0.34.0** (2026-08). Kimi's behaviour under the #1573/#1584 echo-verification path is **not yet measured on any version**. Both are re-verified by @mohidmakhdoomi before merge; a contradiction returns to the maintainer lane rather than shipping. diff --git a/codev/plans/1620-re-plan-pr-1203-kimi-harness-a.md b/codev/plans/1620-re-plan-pr-1203-kimi-harness-a.md new file mode 100644 index 0000000000..cf8e4856de --- /dev/null +++ b/codev/plans/1620-re-plan-pr-1203-kimi-harness-a.md @@ -0,0 +1,444 @@ +# PIR Plan: Re-plan PR #1203 (Kimi harness) against converged main + +**Issue**: cluesmith/codev#1620 +**PR under repair**: cluesmith/codev#1203 — "Support Kimi Code CLI as a builder (PIR #1201)", author @mohidmakhdoomi +**Branch**: `builder/pir-1201` (Mohid's fork, `maintainerCanModify=true`). Merge-only — **never** rebase or squash; his commits and authorship stay intact and this lane adds commits on top. +**Companion artifact**: `codev/plans/1201-support-kimi-code-cli-as-a-bui.md` is rewritten in this same phase (issue item 4) and is part of what the `plan-approval` gate approves. + +--- + +## Understanding + +PR #1203 was reported green on 2026-08-09 and then sat un-re-reviewed for 26 days. In that window `main` advanced **1,606 commits** past the merge base (`4983ea83`), and three of the PR's four core seams were rebuilt underneath it. GitHub reports the PR `CONFLICTING`. The 2026-09-04 3-way integration review found the *design* sound and the *branch* un-mergeable; the owner's decision is that maintainers execute the re-plan on Mohid's branch. + +### What actually diverged (verified file-by-file, not inferred from the conflict list) + +`git merge-base origin/main pr1203` = `4983ea83`. 17 paths changed on both sides. Sorted by how much real thinking each needs: + +**Trivial (a comment rename `afx reset` → `afx refresh`, nothing else):** +- `packages/codev/src/agent-farm/commands/spawn-worktree.ts` — 1 line +- `packages/codev/src/agent-farm/utils/harness.ts` — 3 lines +- `packages/codev/src/commands/doctor.ts` — 2 lines + +**Semantic re-derivation required — the write edge, `packages/codev/src/agent-farm/servers/message-write.ts`:** +Main replaced `writeMessagePaced(session, msg, noEnter) → Promise` with `submitMessagePaced(session, msg, noEnter, precheck, clock?) → Promise>` (#1365 / PR #1492). It now takes the per-terminal `submitToSession` lock, runs the caller's precheck **inside** the lock, and reports a 5-way result (`written`/`dropped`/`preempted`/`contended`/`aborted`). The PR's `MessagePacing` override rode the old function's tail call to `writeMessageToSession`; that call is still there (`message-write.ts`, inside `trySubmitToSession`), so the override re-homes cleanly — but the port signature, the binding in `mailbox-wiring.ts`, and every test that stubbed `writeMessage` all moved. + +**Semantic re-derivation required — gate verdict details, `packages/sdk/src/hold-verdict.ts` (#1482 / PR #1604):** +Main extracted `formatVerdict` + `isUnverifiableVerdict` into the SDK as the *single* definition of "will this hold clear on its own?", and `mailbox-delivery.ts`'s `isClassifierStuck` now delegates to it (its JSDoc explicitly forbids a second copy). The PR, written before that landed, re-forked the predicate as a local `CLASSIFIER_STUCK_DETAILS` record. That fork must be deleted. Kimi's two new details (`no-region-start`, `multi-row-draft`) must be added to `MailboxGateDetail` (`db/types.ts:115`), to the `schema.ts:270` column comment, and to `isUnverifiableVerdict`. + +**Semantic re-derivation required — marker anchoring, `packages/codev/src/agent-farm/servers/render-gate.ts` (#1474 / PR #1491):** +Main rewrote `findMarkerRow` from `(lines, markerPattern)` to `(lines, profile, buf, top, cursorRow, cell)` and added two `GateProfile` anchor fields — `markerRequiresCursorRow` and `markerFgPalette` — because agy's `> ` also matches its slash-menu cursor and its per-turn transcript echo. It also hoisted `top` / `cell` / `cursorRow` **above** the marker call and fixed `cursorRow` to be viewport-relative (`baseY + cursorY - top`). The PR adds `regionStartPatterns`, `growsWithDraft`, `markerSpanEnd`, `findRegionStart`, and a per-row marker exemption — and computes `top`/`cell`/`cursorRow` in the old place. Textually that is one conflict hunk; semantically the two changes are orthogonal and compose, with one trap called out under *Risks* below. + +**Semantic re-derivation required — `mailbox-wiring.ts` / `mailbox-delivery.ts` / `tower-routes.ts`:** +Main added echo verification (#1573: `bufferLines`, `watchEchoOnScreen`, `normalizeForEcho`, `echoNeedle`) and the `delivered-unverified` commit-then-report policy (#1584). The PR's `resolvePacingForSession` binding and its `--interrupt`-path pacing both still have homes; the surrounding code moved. + +**No divergence at all:** `kimi-session-discovery.ts` (new file), the gate fixtures, and the doc files conflict only on adjacent-line churn. + +### Two claims in the issue that do not survive checking + +1. **"Drop `launchLoopTail` changes already on main."** Main still defines `launchLoopTail` module-locally in `spawn-worktree.ts:803`, byte-identical to the PR's relocated copy (diffed). The PR does not *change* the tail — it *moves* it into `utils/harness.ts` and exports it, because `KIMI_HARNESS.buildBuilderLaunchScript` needs it and `spawn-worktree.ts` already imports from `harness.ts` (so the move is the acyclic direction). **Plan: keep the relocation**, since dropping it would break the Kimi provider script. Flagged here rather than silently ignored. +2. **"kimi ≥ 0.33.0 … the branch was measured at 0.34.0 and it ships weekly."** Latest on npm today is **`@moonshot-ai/kimi-code@0.41.0`** — seven minors past the measured version. And `kimi` is **not installed on this machine** and there is no `~/.kimi-code` and no Moonshot credential in the environment. The 2026-09-05 human decision is that this lane does **not** re-measure: no authenticated Kimi here, no credentials to supply, so items 5 and 6 go to @mohidmakhdoomi (see *What this lane does not do*). + +--- + +## Proposed Change + +Seven work items. **Items 1–4 and 7 are this lane's deliverable and ship in full.** Items 5–6 need an authenticated Kimi, which is not available here, and are handed to @mohidmakhdoomi (below). + +### 1. Merge `origin/main` into `builder/pir-1201` + +`git merge origin/main` on the branch, one merge commit, no rebase, no squash, no force-push. Conflicts resolved as *re-derivations*, not as textual picks — each of the five semantic files gets its own commit on top of the merge so the diff is reviewable seam by seam. + +### 2. Re-derive the delivery path against converged main + +**2a. Write edge (`message-write.ts`) — re-derived twice now (#1365, then #1567/PR #1644).** +The PR's `MessagePacing` seam survives, but nothing around it does. Verified against `origin/main` +after PR #1644 landed: + +- `writeMessageToSession(session, message, noEnter, delayOffset, strategy)` — the 5th parameter is + now `strategy: WriteStrategy`, which is exactly where the PR wanted to put `pacing`. Pacing + becomes the 6th; on `submitMessagePaced(session, message, noEnter, precheck, clock?, strategy?)` + it becomes the 7th. +- The per-line loop the PR patched is gone. There are still exactly **two** Enter delays to + override, so the seam is unchanged in shape: `SIMPLE_ENTER_DELAY_MS` (50 ms, short frames) and — + where `PACED_ENTER_DELAY_MS` used to be — **`PASTE_ENTER_DELAY_MS` (80 ms)**, after the paste's + closing marker. + +**And that 80 ms is the whole reason this seam exists.** The numbers, because this is the kind of +collision that gets re-broken by the next person who does not have them in front of them: + +| Enter delay after the body | Kimi 0.27.0 | Source | +|---|---|---| +| **80 ms** | **swallowed — never submits** | #1201 live bisect (POC probe-10 method) | +| **100 ms** | **swallowed — never submits** | same | +| 120 ms | submits | same | +| 250 / 500 / 1000 ms | submits | same | + +Threshold ≈ 100–120 ms. `KIMI_ENTER_DELAY_MS` is pinned at **1000 ms** for ~9× margin (the only +cost is submission latency, irrelevant agent-to-agent), and re-verified submitting on 0.34.0 under +agent-core-v2. + +`PASTE_ENTER_DELAY_MS` is **80 ms** — the first row of that table. It was measured at 0/29 losses +on claude 2.1.263 and codex 0.146.0, which is sound evidence for those two and says nothing about a +CLI whose paste-detection window is the reason this seam was built. So the new long-frame Enter +lands on *exactly* Kimi's known failure value: without the override on that branch, every +multi-line message to a Kimi builder is typed and never submitted — the original #1201 symptom, +reintroduced by a change that had no reason to know Kimi exists. `MessagePacing.enterDelayMs` must +therefore govern **both** Enter sites, not just the short-frame one it was originally written for. +The unit test asserts it on both branches; a test that only covered the short frame would pass +while the feature was broken for every real message. + +**2a-bis. Kimi takes the default `BRACKETED_PASTE` strategy — owner decision, 2026-09-08.** +`writeStrategyForApp` is left exactly as `main` has it: `PLAIN_CHUNKED` for `'agy'`, +`BRACKETED_PASTE` for everything else, Kimi included. **This lane makes no change to that +function.** + +Recorded because it was a decision, not an oversight. This plan previously proposed listing Kimi +alongside agy as unmeasured. The owner weighed it and declined: an unhonoured paste marker is stray +text in a composer, the risk is acceptable, and Mohid's checklist **step 8** measures the real +behaviour on a live Kimi — better evidence than a defensive default that might never have been +revisited. Issue #1653 (making bracketed paste opt-in per measured app) is closed on the same +reasoning; nothing here should be read as reopening it. + +One note so that step 8 is diagnostic rather than impressionistic: `framePieces` converts +`\n` → `\r` *inside* the bracket, so if a TUI does not honour bracketed paste the observable symptom +is not only stray `[200~` text — it is **one message arriving as N separate submissions**, one per +line. Step 8 asks for that specifically. If it shows up, the fix is one line +(`writeStrategyForApp` gains `'kimi'`) with live evidence behind it. + +**2b. Delivery port + binding.** `DeliveryPorts.writeMessage` keeps main's current shape — `(session, msg, noEnter, precheck, strategy)`, the strategy resolved in `mailbox-delivery.ts:744` from `writeStrategyForApp(profile.app)`. Pacing is resolved by the *binding* instead, because it is a property of the target session and no unit fake should have to know about it: +```ts +writeMessage: (session, msg, noEnter, precheck, strategy) => + submitMessagePaced(session, msg, noEnter, precheck, undefined, strategy, resolvePacingForSession(session)), +``` +`resolvePacingForSession` and `resolveHarnessForSession` move over from the PR unchanged — they already read the harness out of the generated `.builder-start.sh`, are total (every failure degrades to the defaults), and cover cron delivery for free because `cron-delivery.ts` writes through the same `DeliveryPorts` seam. + +**2c. `--interrupt` path (`tower-routes.ts`).** Re-derive onto main's shape (the `submitToSession(result.terminalId, …)` block, now ~line 2193): `writeMessageToSession(session, formattedMessage, noEnter, 100, resolvePacingForSession(session))`. Keep the PR's comment on the `escape` branch explaining why a bare ESC is deliberately *not* paced. + +**2d. Gate details — delete the fork, delegate.** Remove `CLASSIFIER_STUCK_DETAILS` from `mailbox-delivery.ts` entirely; `isClassifierStuck` stays as main wrote it, a one-line delegation to `isUnverifiableVerdict`. Add both new details to `MailboxGateDetail`, to the `schema.ts` column comment, and to `isUnverifiableVerdict`: +- `no-region-start` → **unverifiable (escalates)**. A boxed composer with no box top on screen is a torn frame or a drifted profile; it never clears on its own. Uncontroversial. +- `multi-row-draft` → **unverifiable (escalates)**, per the issue's explicit instruction that a stuck Kimi hold "must escalate, never render as *a human at the line*". **This reverses the PR's choice**, and the reversal is defensible on its own terms: every other detail is a *cell count*, and `multi-row-draft` is the one verdict the classifier reaches when it *could not count* and inferred from box geometry instead. "The classifier could not verify this" is the more truthful rendering of that, and a streak of it is exactly the drift signal `recordStreak` exists to surface. **Cost, stated plainly:** a human genuinely sitting on a multi-line Kimi draft for `LIVENESS_STREAK_THRESHOLD` consecutive backstop ticks now contributes to a liveness streak. `surfaceLiveness` only alarms on *recent output*, which suppresses most of that — and, by the same token, partially suppresses the drift alarm too. That residual is why the issue files a `codev doctor` premise probe as a follow-up. **This is a decision the gate can flip**; the alternative (PR's original) is a one-line change. +- Replace the PR's compile-time exhaustiveness trick with a **test** in the codev package that enumerates `GateVerdict['detail']` and asserts every value is classified by `isUnverifiableVerdict`. That preserves what the fork was actually buying (the union cannot grow silently) without a second copy of the rule in a second package. + +**2e. Marker anchoring (`render-gate.ts`).** Take main's `findMarkerRow` and its hoisted `top`/`cell`/`cursorRow` as the base; layer the PR's `regionStartPatterns` / `findRegionStart` / `markerSpanEnd` / per-row marker exemption / `growsWithDraft` on top. They are orthogonal: the anchors decide *which row is the marker*, the region start decides *where the scan begins*. Two specifics: +- **Do not give `KIMI_PROFILE` a `markerFgPalette`.** Main's implementation reads `line.getCell(0, cell)` — a hardcoded column 0, correct for agy (`^>`) and wrong for Kimi (`│ >`, column 3). Rather than leave that trap next to the first profile whose marker is not at column 0, generalize the anchor to read the cell at the marker match's **start** column (a `markerSpanStart` sibling of `markerSpanEnd`). Behaviour for agy/claude/codex is unchanged (their matches start at 0); the assertion is pinned by a test. +- **`markerRequiresCursorRow` for Kimi is a measurement question, not a design one.** `regionStartPatterns` already fixes the last-match hazard the PR documented, so the anchor is not required. It will be adopted only if the item-5 captures show spurious `│ >` rows on a live screen. Recorded here so the decision is visible either way. + +**2f. Drop the PR's `render-gate.test.ts` / `spawn-worktree.test.ts` / `harness.test.ts` stubs that model the old write edge**, and re-express those assertions against `submitMessagePaced`'s result union. + +**2g. Task queueing races builder registration — a real defect, found chasing claude's §7.** +The generated Kimi script queues the task with `afx send "$(cat .builder-prompt.txt)"`, +run with cwd inside the worktree. Two problems, both verified against main: + +- **The race.** `spawn.ts` calls `upsertBuilder` **after** `startBuilderSession` returns (`spawn.ts:482` + then `:488`), but the script's first act is that `afx send`. `detectCurrentBuilderId()` resolves the + *sender* from cwd and **throws** `BuilderIdResolutionError` when no builder row exists yet + (`commands/send.ts:167` — "Refusing to send with an unverified identity"), which `fatal()`s the CLI. + The script's `if afx send …; then` then fails, prints its warning, and **does not retry within that + launch** — so the builder starts with a role and no mission, and the only trace is one line in the + PTY. Today this is saved solely by node's startup latency exceeding one local HTTP round-trip. + **Fix: give `codev_queue_task` a bounded retry** (~30 s, a few seconds apart) before it warns. + Script-local, no change to the shared spawn path. Reordering `upsertBuilder` ahead of + `startBuilderSession` is the tempting root fix and is **rejected**: the row carries `terminalId`, + which only exists after the session starts, so it would mean two upserts on the path every harness + shares — real blast radius to fix a Kimi-only symptom. +- **The attribution.** Sender resolves to the builder's *own* id, so the task arrives framed + `### [BUILDER MESSAGE → | …] ###` — a builder's opening mission presented as a peer + message from itself. There is no self-send guard anywhere in `handleSend`. `.builder-prompt.txt` is + already a fully-framed spawn prompt, so **plan: pass `--raw`** and let it arrive as itself. Echo + verification still works — `watchEchoOnScreen` compares against a pre-write *count*, so a stable + first line is fine. This is verified end-to-end at the dev-approval gate rather than asserted; if + `--raw` reads badly, the fallback is a small explicit-attribution flag on `afx send`. + +### 3. Workspace-trust pre-write — the security change (#1328 class) + +`ensureKimiWorkspaceTrust` (`utils/kimi-session-discovery.ts:456`) currently writes a trust record for any worktree, unconditionally, whenever a Kimi builder spawns. Kimi's folder trust gates exactly one thing — whether **project-level MCP servers** load from the folder — so writing it blind is a silent grant of "load whatever MCP servers this checkout ships" on the user's behalf. Two independent refusals, both defaulting to *do nothing*: + +**3a. Refuse when the worktree carries project-level MCP config.** Scan `/.mcp.json` and `/.kimi-code/mcp.json`. If either exists, do not write; log which file caused the refusal and what the consequence is (the dialog will appear; the render gate will hold the task message with `no-composer-marker`, which is in the escalation class, so it surfaces rather than hanging silently). This is the one case where the trust decision is genuinely load-bearing, so it is the one case a human must make. + +**3b. Refuse unless explicitly opted in.** Default **false**. Recommended key — `.codev/config.json`: +```json +{ "harnessOptions": { "kimi": { "autoTrustWorkspace": true } } } +``` +> **Why not `harness.kimi.autoTrustWorkspace`, which is what the issue names as an example.** `harness.*` is already the *custom harness definition* namespace: `lib/config.ts:337-341` runs `validateCustomHarnessConfig` over **every** entry at load time, and that validator hard-requires `roleArgs` and `roleScriptFragment`. `harness.kimi: { autoTrustWorkspace: true }` would throw at config load and break unrelated commands (`afx status`, everything). And because `resolveHarness` gives built-ins priority, a `harness.kimi` entry is *already* dead config — overloading it would make a security opt-in live in a namespace where a neighbouring key is silently ignored. `harnessOptions` is a new, separately-typed, separately-validated namespace with none of that. **The issue wrote "e.g.", so this is a gate decision, not a deviation** — say the word and it becomes `harness.kimi.autoTrustWorkspace` with a carve-out in the validator. + +**3c. Shape of the change.** `ensureKimiWorkspaceTrust` returns a decision rather than a bare boolean, so callers can log *why* and tests can assert each refusal distinctly: +```ts +export type KimiTrustDecision = + | { wrote: true } + | { wrote: false; reason: 'already-trusted' | 'not-opted-in' | 'project-mcp-config' | 'write-failed'; detail?: string }; +``` +`HarnessProvider.prepareWorkspace` widens to `prepareWorkspace?(worktreePath: string, opts: { autoTrustWorkspace: boolean }): void`. Both call sites resolve the flag from config: `startBuilderSession` already holds `config`; `buildWorktreeLaunchScript` holds `workspaceRoot`. Fail-soft is preserved throughout — a refusal is never a spawn failure. + +**3d. Documented consequence, because it is not small.** A repository that ships a root `.mcp.json` will hit 3a on **every** Kimi builder worktree, so unattended Kimi spawning does not work there until a human trusts the folder once. That is the correct posture and it goes in the docs, not in a footnote. + +**3e. Docs + tests.** `codev/resources/commands/agent-farm.md:1116-1146` and its `codev-skeleton/` mirror (both trees, per the dual-tree rule) get the opt-in, the default, and 3d. Tests cover: opted-out → no write; opted-in + `.mcp.json` → no write, reason `project-mcp-config`; opted-in + `.kimi-code/mcp.json` → same; opted-in + clean worktree → writes; existing record → `already-trusted`; unwritable home → `write-failed`, no throw. + +### 4. Rewrite `codev/plans/1201-…md` to the shipped architecture + +The approved 1201 plan still describes the **retired** seed-session design — `kimi -p` bootstrap, `.builder-seed.txt`, captured session id, `kimi -S ` loop, a sentinel-gated store-verified `BEGIN` PTY kick, and `message-pacing.ts`. None of that is in the branch; the 2026-08-09 pivot replaced it. Rewritten to what the code does: **mailbox task delivery** (the script calls `afx send ` on each fresh launch; no PTY write, so Spec 1313 holds), **`--agent-file` role injection** composed around `${base_prompt}`, **guarded `kimi -c` resume** (store probe on both stdout *and* exit status, superseded-id comparison for #1267 sticky-fresh), the **trust record** as amended by item 3, the **render-gate Kimi profile**, per-harness **Enter pacing**, and the **0.33.0 version floor**. This file is committed in the plan phase and approved together with this one. + +### 5–6. Live re-measurement and the demo re-run — **handed to @mohidmakhdoomi** + +Human decision, 2026-09-05: there is no authenticated Kimi available on this side and no +credentials to supply, so **this lane does not run items 5 and 6.** They go to Mohid, who has an +authenticated Kimi and ran the original 7/7 demo; the architect has asked him on PR #1203 to +review our commits and run both on his side. His evidence attaches to PR #1203. + +What that changes for *our* work — stated here because it is not free: + +- **`KIMI_PROFILE` ships on 0.34.0-era measurement**, not on a fresh capture. The eight + `fixtures/gate/kimi-*.txt` captures stay as Mohid recorded them, and the `growsWithDraft` + premise — the load-bearing "the box grows a row only when the draft gains a line" claim — is + **re-verified by him, not by us.** Where the code asserts a measured fact, the comment says + which version measured it. +- **`markerRequiresCursorRow` is not adopted for Kimi.** The plan previously made it conditional + on new captures; with no captures, the honest answer is "not adopted, and here is the question + someone must answer" — it goes on Mohid's checklist rather than being guessed at. +- **Kimi's echo behaviour under #1573/#1584 stays unmeasured by us.** No code change is needed for + safety: #1584 already commits the delivery first and reports `delivered-unverified` + + `markEscalatedDelivered` + `onUnverifiedDelivery`, so an unconfirmed Kimi delivery can never + loop. What is missing is the *number*, and Mohid supplies it. +- **The burden shifts onto proving no regression for the measured harnesses.** We are editing + `render-gate.ts`, `message-write.ts` and `hold-verdict.ts` — code that carries claude, codex and + agy delivery for every user — without being able to exercise the one harness the change is *for*. + So "claude/codex/agy behave identically" stops being a footnote and becomes the primary thing our + own dev-approval gate proves (see Test Plan). + + +### 7. Close the loop on the PR + +- CMAP (gemini + codex + claude, parallel, background) after the implementation commits and again after the tests, per the repo's consultation rule. +- `codev/reviews/1620-…md` records every KEY_ISSUE from the 2026-09-04 3-way review as **addressed** or **explicitly dispositioned**. *I do not have the raw lane output* — the issue body's scope items 1–6 are its distillation, and I will work from those unless the architect hands me the transcript. Asked at the gate. +- Update `codev/resources/arch.md`'s Kimi subsection to the shipped design (it still describes the seed bootstrap) and route new lessons by tier. +- A courteous comment on PR #1203 summarising exactly what changed and why, crediting Mohid's + original work, plus a rewritten PR description — both **drafted to `/tmp` for human approval, not + posted by this lane** (see *Outward communication*). +- Follow-up issues filed **before merge, not open-ended**, each referencing #1203: `PreToolUse` + write-guard parity for Kimi builders (#1018 class); a `codev doctor` premise probe for the + box-growth assumption; Kimi echo-verification tolerance (#1578). +- **The write-guard gap is bounded by both lanes that raised it.** codex accepts it as follow-up + *"if maintainers explicitly accept that limitation"* — so the review doc records that acceptance + explicitly, in the maintainer's words, rather than implying it. claude is stricter: if it is + follow-up, it *"should gate documenting kimi as supported, not be open-ended"* — so the + `agent-farm.md` paragraph (both trees) states the gap **where Kimi is documented as supported**, + with the follow-up issue number, and the stale *"kimi has no documented hook seam"* claim is + corrected (kimi has documented blocking `PreToolUse` hooks since 0.32.0, which is what makes the + follow-up achievable rather than impossible). +- **Echo-verification cost recorded, not discovered later.** claude's §6 computes it: `enterDelayMs` + 1000 plus two 600 ms verify windows makes a Kimi `afx send` cost ~2.2 s worst case, and every + message may report `delivered-unverified`. @mohidmakhdoomi measures whether it actually does (item 7 + checklist step 4); either way the number goes in the review doc as an accepted cost, so nobody + reads the flag as a fault. + +--- +- **A handoff checklist for @mohidmakhdoomi** — specific enough that his round is one pass, not a + negotiation. **This lane drafts it to `/tmp` and does not post it** (see *Outward communication* + below); the human approves it and it goes out over the architect's account: + + ```bash + gh pr checkout 1203 && git pull # our merge + commits are on your branch + pnpm install && pnpm build && pnpm test # expect green before touching kimi + npm i -g @moonshot-ai/kimi-code # 0.41.0 today; floor stays 0.33.0 + kimi --version # record it — it goes in the evidence + ``` + + 1. **Re-capture the eight gate fixtures** on your Kimi version, into + `packages/codev/src/agent-farm/__tests__/fixtures/gate/`: `kimi-idle.clean.txt`, + `kimi-draft.busy.txt`, `kimi-multiline.busy.txt`, `kimi-multiline-bare.busy.txt`, + `kimi-newline-bare.busy.txt`, `kimi-menu.busy.txt`, `kimi-picker.busy.txt`, + `kimi-trust.busy.txt` — plus the version stamp in that directory's `README.md`. + Driver: `node codev/spikes/pir-1201-kimi-gate-measure.mjs`. + 2. **Re-verify the `growsWithDraft` premise** — `node codev/spikes/pir-1201-kimi-box-growth.mjs` + and `node codev/spikes/pir-1201-kimi-working-states.mjs`. The load-bearing row is the + **post-reply steady state**: if a composer that has already carried a turn grows past one + interior row, the rule holds every later message forever and **must not ship** as-is. Idle, + single-line draft, `/` menu, `@` picker, mid-generation, shift+tab chrome and a + draft-while-working must all sit at exactly one interior row. + 3. **Answer the one question we could not**: on a live screen, does anything *other* than the + composer match `/^\s*│\s*>/`? If yes, `KIMI_PROFILE` should take + `markerRequiresCursorRow: true` (the #1474 anchor) and we will add it. If no, say so and we + record that the region bound alone is sufficient. + 4. **Measure verified delivery (#1573/#1584)** — the genuinely new one, and the reason this is + not just a demo re-run. Drive a real Kimi through the *production* paced write and echo + verification (`submitMessagePaced` → `watchEchoOnScreen`) and record what Kimi's composer and + transcript do to the `### [ARCHITECT INSTRUCTION | ] ###` header. claude eats the `###` + as a markdown H3 and `normalizeForEcho` absorbs that; Kimi is unmeasured. Report: does the + needle confirm, on which sample, and what is the wall-clock cost per `afx send` (we predict + ~2.2 s worst case — 1000 ms Enter + two 600 ms windows). **A negative is a fine outcome**, not + a failure: #1584 commits first and reports `delivered-unverified`, so it can never loop. We + just need to know, so the docs can say it rather than operators discovering it. + 5. **Run the demo driver** — `node codev/spikes/pir-1201-kimi-builder-demo.mjs`. Now **9** + scenarios: the original 7, plus **6b** (an opted-out spawn writes no trust record) and **6c** + (a worktree carrying `.mcp.json` writes no record and logs `project-mcp-config`). Scenario 6 + changed shape — `ensureKimiWorkspaceTrust` returns a `KimiTrustDecision`, not a boolean. + 6. **Exercise the spawn-race retry** (item 2g): spawn a Kimi builder and confirm the task + actually arrives. If you can, start it with Tower under load so `codev_queue_task`'s first + attempt loses the race — the retry should win and the task should still land. + 8. **Does Kimi honour bracketed paste?** New since your PR (#1567 / PR #1644): long `afx send` + bodies go out as one bracketed paste, and every harness except agy takes that path — Kimi + included. The diagnostic symptom if Kimi does *not* honour it is not just stray `[200~` text: + `framePieces` turns `\n` into `\r` inside the bracket, so an un-honoured paste arrives as + **N separate submissions, one per line**. Send a >4-line message and tell us which you see — + one message, or several. Either answer is useful; if it is several, the fix is one line with + your evidence behind it. + 7. **Evidence** → `codev/evidence/1620-kimi-measurement/`, committed to the branch: the Kimi + version, raw captures, the demo driver's full output, and the verified-delivery numbers. + A PR comment with the headline results is enough for us to finish the review doc. + + Anything that fails, tell us and we fix it on this side — you should not have to touch the + TypeScript. + +## KEY_ISSUES disposition (2026-09-04 3-way review) + +Raw lane output received from the architect after the plan was drafted. Every KEY_ISSUE from all +three lanes, and where this plan answers it. This table is the skeleton of the review doc. + +| Lane | KEY_ISSUE | Where answered | +|---|---|---| +| gemini | *(none — APPROVE)* | Its two integration notes (0.33.0 floor is correct; write-guard as follow-up is appropriate) are honoured in the 1201 plan's floor section and in item 7. | +| codex | Trust pre-write silently enables repo-controlled MCP servers; refuse on project MCP config, preferably behind an explicit opt-in | **Item 3** — both refusals, opt-in defaulting to off | +| codex | The approved plan no longer describes the implementation | **Item 4** — `codev/plans/1201-…md` rewritten this phase, re-approved at this gate | +| codex | Write-guard limitation acceptable as follow-up *only if maintainers explicitly accept it* | **Item 7** — acceptance recorded in the maintainer's own words in the review doc | +| claude | Branch `CONFLICTING`, 1,603 behind; `writeMessagePaced`, `findMarkerRow`, `launchLoopTail` all moved | **Items 1, 2a, 2e** (and the correction: `launchLoopTail` did *not* move on main — the PR relocates it, and that relocation is kept, which claude's own integration notes also recommend) | +| claude | New `GateVerdict` details bypass #1482: absent from `MailboxGateDetail` / `isUnverifiableVerdict`, predicate re-forked locally | **Item 2d** — fork deleted, both details added in all three places, plus an exhaustiveness test | +| claude | `multi-row-draft` holds on geometry but is excluded from the stuck set; no doctor probe covers the box-growth premise | **Item 2d** — we take the *escalate* branch of claude's own "either escalate or add a doctor probe"; the probe is filed as a follow-up. Note this supersedes the `multi-row-draft → false` parenthetical in claude's §2, which its §3 then argues against. | +| claude | Trust pre-write should refuse when the worktree carries project-level MCP config | **Item 3a** | +| claude | No `PreToolUse` write guard while kimi is documented as supported | **Item 7** — follow-up filed before merge and referenced from the docs *where kimi is documented as supported*, per claude's own bound | +| claude | Kimi echo behaviour unmeasured against #1573/#1584; the 7/7 demo predates that path | **Item 7 checklist step 4** — @mohidmakhdoomi measures it; the ~2.2 s predicted cost and his result both go in the review doc | +| claude | §7 *(not a KEY_ISSUE, but it found a real one)* — confirm what a builder self-send attributes to | **Item 2g** — chasing it surfaced an unguarded race that can drop a Kimi builder's task entirely | + +--- + +## Outward communication + +**Standing rule (architect, 2026-09-05): this lane does not post to PR #1203 or any thread of +@mohidmakhdoomi's.** Every outward artefact — the handoff checklist, the rewritten PR description, +the summary comment crediting his work — is **drafted to a `/tmp/pir-1620-*.md` file and handed to +the architect**, and the human approves each one before it goes out. Nothing this lane writes +reaches an external contributor without that approval. + +Two things this rule does **not** cover, so they are not left ambiguous: + +- **Commits pushed to `builder/pir-1201` continue**, because that is the deliverable the architect + set out (build on Mohid's branch, merge-only, no rebase). They are the work, not a message. +- **Reading** the PR and its threads (`gh pr view`, `gh pr diff`, `gh pr checkout`) continues — it + is how the lane stays correct. + +Recorded here rather than only in the thread log so a future reader of this plan cannot mistake +"post the checklist on #1203" for an instruction to this lane. + +--- + +## What this lane does not do + +**Items 5 and 6 are not ours.** Human decision, 2026-09-05: no authenticated Kimi here and no +credentials to supply, so live re-measurement and the demo re-run are handed to +@mohidmakhdoomi, whose evidence attaches to PR #1203. + +Consequences, recorded so that nobody has to reconstruct them later: + +- **We do not claim the feature was live-verified.** The review doc says so in plain words: this + lane merged, re-derived, secured and re-planned the work, and did **not** run Kimi. It also names + the drift — the branch was measured on **0.34.0**; latest is **0.41.0**, seven minors on, which is + the same failure mode this lane exists to repair, recurring. +- **Our `dev-approval` gate is scoped to what is verifiable without Kimi**: config parsing, `afx + status` and the rest of the CLI unaffected, the generated launch scripts for every *existing* + harness byte-identical, and the full suite green. See Test Plan. +- **The Kimi-side acceptance bar moves to Mohid's round.** If his results contradict a measured + claim in the code — the `growsWithDraft` premise above all — the fix comes back to this lane + before merge. That is a real possibility, not a formality. + +--- + +## Files to Change + +| Path | Change | +|---|---| +| `packages/codev/src/agent-farm/servers/message-write.ts` | `MessagePacing`; `pacing?` re-derived onto the post-#1567 signatures (6th arg on `writeMessageToSession`, 7th on `submitMessagePaced`), overriding `SIMPLE_ENTER_DELAY_MS` and `PASTE_ENTER_DELAY_MS`. `writeStrategyForApp` is **not** touched — Kimi takes the default bracketed-paste strategy per the 2026-09-08 owner decision | +| `packages/codev/src/agent-farm/servers/mailbox-wiring.ts` | `resolveHarnessForSession` / `resolvePacingForSession`; pacing threaded into the `writeMessage` binding | +| `packages/codev/src/agent-farm/servers/mailbox-delivery.ts` | delete `CLASSIFIER_STUCK_DETAILS`; keep main's delegating `isClassifierStuck` | +| `packages/sdk/src/hold-verdict.ts` | `isUnverifiableVerdict` gains `no-region-start`, `multi-row-draft` | +| `packages/codev/src/agent-farm/db/types.ts:115` · `db/schema.ts:270` | `MailboxGateDetail` union + column comment gain both details | +| `packages/codev/src/agent-farm/servers/render-gate.ts` | `regionStartPatterns`, `growsWithDraft`, `markerSpanEnd`/`markerSpanStart`, `findRegionStart`, per-row marker exemption, `multi-row-draft` — layered on main's anchored `findMarkerRow`; `markerFgPalette` generalized off column 0 | +| `packages/codev/src/agent-farm/servers/gate-profiles.ts` | `KIMI_PROFILE` + registry entry | +| `packages/codev/src/agent-farm/servers/tower-routes.ts` | pacing on the `--interrupt` write; `escape` left unpaced with the reason | +| `packages/codev/src/agent-farm/utils/kimi-session-discovery.ts:456` | `ensureKimiWorkspaceTrust` → `KimiTrustDecision`; MCP-config refusal; opt-in gate | +| `packages/codev/src/agent-farm/utils/harness.ts` | `KIMI_HARNESS`; `launchLoopTail` relocated + exported; `prepareWorkspace` signature widened | +| `packages/codev/src/agent-farm/commands/spawn-worktree.ts` | provider-owned launch branch; resolve + pass `autoTrustWorkspace` | +| `packages/codev/src/agent-farm/types.ts` · `src/lib/config.ts` | `harnessOptions` block, typed + validated at load | +| `packages/codev/src/commands/doctor.ts` | Kimi presence / 0.33.0 floor / store + trust drift probes (PR, re-derived) | +| `codev/resources/commands/agent-farm.md` + `codev-skeleton/` mirror | trust opt-in, default, and the `.mcp.json` consequence | +| `codev/resources/arch.md` | Kimi subsection rewritten off the seed design | +| `codev/plans/1201-support-kimi-code-cli-as-a-bui.md` | rewritten to the shipped architecture (this phase) | +| `codev/spikes/pir-1201-kimi-builder-demo.mjs` | scenario 6 updated; 6b / 6c added | +| `__tests__/fixtures/gate/kimi-*.txt` | **unchanged by this lane** — 0.34.0 captures; re-captured by @mohidmakhdoomi (item 7 checklist step 1) | +| tests: `harness.test.ts`, `render-gate.test.ts`, `spawn-worktree.test.ts`, `mailbox-pacing.test.ts`, `kimi-session-discovery.test.ts`, `config.test.ts`, + new `hold-verdict` exhaustiveness test | | + +--- + +## Risks & Alternatives Considered + +- **Risk — a 1,606-commit merge hides a semantic break behind a clean textual resolution.** Mitigation: the five semantic files each get their own post-merge commit with the reasoning in the message, and CMAP runs on the delta with the render-gate edit flagged for hardest scrutiny (the same instruction the 2026-08-09 round used). +- **Risk — `markerFgPalette`'s hardcoded `getCell(0)`.** Latent today (only agy uses it, marker at column 0), a live bug the moment anyone gives Kimi a palette anchor. Generalizing it now costs one helper and one test; leaving it is a trap laid directly under the first non-column-0 profile. +- **Risk — `multi-row-draft` escalation false-alarms on a real human draft.** Accepted with the cost stated in 2d, and flagged as gate-reversible in one line. +- **Risk — the 0.34.0 → 0.41.0 drift repeats the exact failure this lane exists to fix.** Nothing prevents another seven-minor gap. Mitigation is not a code change: the doctor drift probes (store layout + trust-record naming) already fail loudly on a scheme change, and the follow-up premise probe covers the box-growth assumption. +- **Risk — we ship a Kimi feature none of us ran.** The measured claims in the code (the + `growsWithDraft` box-growth premise above all) rest on 0.34.0 captures; latest is 0.41.0. This is + the *same* staleness that made PR #1203 un-mergeable, and no code change removes it. Mitigation is + procedural and partial, and worth naming as such: Mohid re-verifies before merge (item 7 + checklist), the doctor drift probes fail loudly if the store or trust schemes move, and the + box-growth premise gets its own doctor probe as a filed follow-up. If Mohid's round contradicts a + measured claim, the fix returns to this lane rather than shipping. +- **Alternative rejected — cherry-pick Mohid's work onto a fresh maintainer branch.** Cleaner diff, but it drops his authorship and abandons PR #1203, which the owner decision explicitly forbids. +- **Alternative rejected — `harness.kimi.autoTrustWorkspace`.** Reasons under 3b. +- **Alternative rejected — make trust pre-write default-on with an opt-*out*.** Preserves unattended spawning out of the box, and is exactly the "silently grant a capability the user never chose" shape #1328 is named for. Default-off is the only direction where the failure mode is an inconvenience rather than a security event. + +--- + +## Test Plan + +**Unit (vitest):** +- Pacing survives the lock AND the paste path: `submitMessagePaced` with a Kimi `MessagePacing` + schedules Enter at 1000 ms on **both** frame shapes — the short single-write branch and the long + chunked branch, where it must displace `PASTE_ENTER_DELAY_MS` (80 ms, the value Kimi's own bisect + showed gets swallowed). Still resolves only after the Enter, still reports `written`; `contended` + and `aborted` are unaffected by pacing. +- `writeStrategyForApp` is unchanged and still returns `BRACKETED_PASTE` for `'kimi'` — asserted, so + the 2026-09-08 owner decision is pinned rather than left to drift, and so a future edit to that + function has to be deliberate about Kimi. +- `resolvePacingForSession` is total: unreadable worktree, unknown harness, retired harness, custom harness → `undefined`, never a throw. +- `isUnverifiableVerdict` exhaustiveness: every `GateVerdict['detail']` value is classified; `no-region-start` and `multi-row-draft` escalate; `user-text` and `empty` do not. +- Render gate against the Kimi fixtures: idle → `clean`; single-line draft → `user-text`; newline-then-`>` draft → `multi-row-draft`; box top off-screen → `no-region-start`; trust dialog → `no-composer-marker`; `/` menu and `@` picker → busy. Plus the guardrail pinning `markerSpanEnd` for **every** shipped profile (that number is what "no-op for claude/codex/agy" rests on) and the new `markerSpanStart` palette-anchor test. +- Trust: the six cases in 3e. +- Config: `harnessOptions` parses, defaults false, rejects a non-boolean, and a legacy config with no block behaves as opted-out. + +**Build + suites:** `pnpm build` clean; full `pnpm test` green including the `codev-core`/`codev-sdk` boundary tests (the `hold-verdict.ts` edit touches the SDK, so the isolation tests are load-bearing here). + +**Manual, at `dev-approval` — deliberately all non-Kimi.** With no Kimi on this side, the thing +our gate can actually prove is that a change *for* Kimi did not move anything *else*. That is the +larger risk anyway: `render-gate.ts`, `message-write.ts` and `hold-verdict.ts` carry claude, codex +and agy delivery for every user. + +1. **The measured harnesses are untouched.** Generate a builder launch script for claude, codex + and opencode before and after the change and diff them — **byte-identical**, or the change is + wrong. (`markerSpanEnd` is a no-op only because every existing marker matches at column 0 with a + 1–2 cell span; the guardrail test pins that number per profile, and this is its manual mirror.) +2. **Live delivery to a claude builder still works end-to-end** — `afx spawn`, then `afx send` + with a multi-line body: it arrives as one submitted message and the log says `delivered`, not + `delivered-unverified`. This is the regression that would matter most and the one a green suite + is least likely to catch (the #1573 echo path is timing-dependent). +3. **A held row still renders correctly.** Put a claude builder's composer in a draft state, send + to it, and check `afx inbox`: the hold reads `busy:user-text`, *not* an unverifiable verdict. + Proves the `isUnverifiableVerdict` edit did not widen the escalation class for existing details. +4. **Config**: with no `harnessOptions` block, `afx status`, `afx spawn --help` and `codev doctor` + behave exactly as before; with a malformed one, the failure is loud and names the key. +5. **`codev doctor` with kimi absent** (the state of this machine) degrades cleanly — reports kimi + not installed, does not throw, and does not fail the run. +6. Full `pnpm build` + `pnpm test` green, including the `codev-core` / `codev-sdk` boundary tests. + +**Kimi-side verification is @mohidmakhdoomi's round**, per the checklist in item 7 — the nine demo +scenarios, the fixture re-capture, the `growsWithDraft` re-verification, and the verified-delivery +measurement. This lane does not sign off on those and the review doc says so. + +**Cross-platform:** macOS only. Kimi's store and trust paths are `$HOME`-relative and the code already routes them through `KIMI_CODE_HOME`, so the tests are platform-independent; the live demo is not re-run on Linux/Windows and that is stated rather than implied. diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-architect-review-dispositions.md b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-architect-review-dispositions.md new file mode 100644 index 0000000000..9113d7bd01 --- /dev/null +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-architect-review-dispositions.md @@ -0,0 +1,133 @@ +# CMAP dispositions — architect integration review follow-up (2026-08-09) + +Round: the architect's three non-blocking findings on PR #1203 at head `4a7e2afe`, plus the +3-way review of the resulting delta. Prior round's dispositions are in +`1201-cmap-postpivot-dispositions.md`; this file covers only this delta. + +Verdicts on the delta: **gemini APPROVE · codex REQUEST_CHANGES · claude APPROVE-with-changes**. +Every finding from both non-approving reviews was accepted. Nothing was rejected. + +Scope note: a mid-round architect message fenced the PR's three open maintainer decisions (trust +pre-write, 0.33.0 version floor, write-guard parity as follow-up). None were touched. + +--- + +## Architect finding 1 — residual false-CLEAN for an all-exempt draft + +**Measure-first, per instruction. The premise holds**, so the rule was implemented rather than +documented as a residual. + +Measured on real kimi 0.34.0 (`codev/spikes/pir-1201-kimi-box-growth.mjs`), interior rows = +`endRow - startRow`, the rows the classifier actually scans: + +| state | interior rows | +|---|---| +| idle | 1 | +| single-line draft | 1 | +| `/` command menu | 1 | +| `@` file picker | 1 | +| post-reply steady state | 1 | +| newline + bare `>` | **2** | +| newline only | **2** | +| long soft-wrapped single line | **2** (carries text → already busy; verdict unchanged) | + +The steady-state row is the load-bearing one: growth on a composer that has already carried a +turn would hold every later message forever — a liveness failure, which is worse than the +fail-safe direction the gate normally errs toward. + +The review then surfaced a class the spike had not enumerated (claude Q2), so it was measured +too (`pir-1201-kimi-working-states.mjs`): mid-generation at 5s and 13s, shift+tab mode chrome, +and a draft typed while the agent is working are **all one interior row**. So the rule does not +convert "deliver while busy" into "hold until idle". (`!` bash mode classifies +`no-composer-marker` and holds — pre-existing, fail-safe, and correct: there is unsent input on +that row.) + +### Deviation from the suggested implementation + +The architect's sketch short-circuited on geometry **before** the cell scan. Implemented that +way it changed an existing fixture's verdict detail — `kimi-multiline-bare` went from +`user-text` to `multi-row-draft`, because that draft is also multi-row — which would have +retired what the older guardrail test was actually testing and demoted the cell scan from +ground truth to dead weight on every multi-row screen. Moved **after** the scan: `userCells > 0` +still wins and still reports `user-text`; `multi-row-draft` is reserved for the case the count +is blind to. Every pre-existing fixture verdict is unchanged. claude independently confirmed +this ordering is not just preferable but *enforced* by the existing assertion at +`render-gate.test.ts:194`. + +--- + +## codex #1 / claude Q5+F1 — arming coupled to `regionStartPatterns` — **ACCEPTED** + +Both reviewers independently flagged that arming the rule off `regionStartPatterns` overloads a +field that means "the composer has an upper boundary" with an unrelated claim ("box height +tracks draft lines"). + +claude supplied evidence that makes this concrete rather than stylistic, **which I verified +myself** with a geometry probe over every shipped fixture: `codex-idle.clean.txt` — a real, +captured, genuinely **empty** codex composer — already spans **two interior rows** +(`marker=18 start=18 end=20`). The rule's geometric predicate is *already true* on a screen that +must stay clean; only the arming gate stands between that capture and codex mail being held +forever. The day anyone declared a region start for codex (a header bound, a boxed redesign), +delivery would die silently. + +Decoupled into an explicit profile field, `growsWithDraft?: true`, set only on `KIMI_PROFILE`. +The rule now requires **both**: `growsWithDraft` (the measured promise) and `hasRegionStart` +(what makes the arithmetic mean "interior rows" at all). codex proposed +`maxCleanInteriorRows?: number` instead; chose the boolean because it encodes the *measured +premise* rather than a tunable number, and a wrong threshold under it is caught by the app's own +idle fixture, which must classify clean. Pinned by three tests, all now built on codex's real +capture rather than a constructed screen: inert when neither field is set, inert with either one +alone, and armed only with both. + +## codex #2 / claude F4 — fast-fail hint not universally accurate — **ACCEPTED** + +My reworded echo asserted unconditionally that "an undelivered task is still queued on the +mailbox". False in a reachable third case: `codev_task_queued` is set only on a **successful** +`afx send`, so if afx is off PATH or Tower is down the flag is still 0, nothing is queued, and +the fresh relaunch really does retry it. The hint now branches on `[ "$codev_task_queued" = 1 ]` +and states the truth in both cases. Behavior still unchanged; this was a message-accuracy fix on +top of a message-accuracy fix. + +## codex #3 / claude F5 — tradeoff comment overstates delivery — **ACCEPTED** + +"true whenever the operator saw a composer to /quit from" is too strong: seeing a composer is +necessary, not sufficient — the gate also has to have polled it empty at least once. Softened, +and the quit-before-delivery race is now named alongside the trust-dialog case. + +## claude F2 — `isClassifierStuck` silently omitted the new detail — **ACCEPTED** + +`mailbox-delivery.ts` enumerated stuck details as a closed `||` chain, so widening +`GateVerdict['detail']` did not force a decision. claude checked the resulting behavior and it +was *right* (excluding `multi-row-draft` is correct — it is a human on a draft, and the +premise-failure reading carries no recent output, which `surfaceLiveness` requires to alarm), +but it read as an oversight. Replaced with a `Record` map, so +the next new detail is a **compile error** rather than a silent `false`, and documented why +`multi-row-draft` sits on the excluded side. + +## claude F3 + codex test note — the differential used constructed screens — **ACCEPTED** + +codex noted the armed/unarmed halves were "not literally the same bytes despite the comment"; +claude asked for the real codex-idle geometry to be cited. Both are answered by the same change: +the test now classifies the **actual `codex-idle.clean.txt` capture** under four profile +variants — shipped, armed, bounded-only, grows-only — so the differential runs on identical real +bytes and the hazard is demonstrated rather than described. + +## claude Q1 nit — non-null assertion — **ACCEPTED** + +`hasRegionStart` is now a type predicate (`patterns is RegExp[]`), so the `startPatterns!` +assertion is gone and the narrowing is checked rather than conventional. (Applied before +claude's review landed; it had read the pre-edit file.) + +--- + +## Verification + +- `pnpm build` clean; `tsc --noEmit` clean. +- Full suite **4906 passed / 48 skipped / 0 failed** (+6 on the pre-round 4900: five new tests + and one new fixture). +- Targeted suites — render-gate, harness, harness-integration, spawn-worktree, mailbox-pacing, + kimi-session-discovery — green. +- Two new live measurements against real kimi 0.34.0, both committed as reproducible spikes. +- No live demo re-run: the rule can only alter verdicts for a kimi composer past one interior + row, and delivery targets the idle composer, measured at one row in every state including + mid-generation. diff --git a/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-finding4-dispositions.md b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-finding4-dispositions.md new file mode 100644 index 0000000000..28a0506456 --- /dev/null +++ b/codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-finding4-dispositions.md @@ -0,0 +1,139 @@ +# CMAP dispositions — finding 4, kimi sticky-fresh crash-resume (2026-08-09) + +Round: the architect's finding 4 on PR #1203 — after a clean exit, a crash in the pre-mint boot +window makes `kimi -c` resume the conversation the human just ended (#1267's own motivating +defect class). Earlier rounds: `1201-cmap-postpivot-dispositions.md`, +`1201-cmap-architect-review-dispositions.md`. + +Verdicts on the delta: **gemini APPROVE · codex REQUEST_CHANGES · claude REQUEST_CHANGES**. +Both REQUEST_CHANGES were right, and they converged on the same blocking defect. Every finding +was accepted; none rejected. + +Scope fences respected: no PR comment, and the three parked maintainer decisions (trust +pre-write, 0.33.0 floor, write-guard parity) untouched. + +--- + +## Measurement first — the premise holds + +The fix (and the pre-existing resume design) assumes `kimi -c` continues the NEWEST session when +a cwd holds several. The existing continue-probe only covered the ZERO-session case, so this was +measured net-new on real kimi 0.34.0 +(`codev/spikes/pir-1201-kimi-continue-newest-probe.mjs`), with two independent oracles because +the model's own answer is not proof: + +- **content oracle** — sessions seeded with distinct codewords ALPHA (older) / BRAVO (newer); + `kimi -c` answered **BRAVO**. +- **identity oracle** — snapshot `updatedAt` for every session before and after; the `-c` turn + touched **only** `session_f06c…` (the newest), and **created no new session**. Exit 0, no + prompt. + +So identity comparison is well-defined, and the fallback ("document the residual instead") did +not apply. + +--- + +## The fix + +The inlined store probe now PRINTS the newest resumable session id instead of exiting 0/1; the +clean-exit branch records that id as superseded; the crash branch takes `-c` only once the +newest id differs. One probe, one mirror — the boolean uses derive from the same output. + +--- + +## codex #2 / claude F1 — BLOCKING: the guard read stdout and discarded exit status — **ACCEPTED** + +The delta moved the decision from `$?` onto stdout, so anything else writing to stdout is read +as "a session exists". claude **measured** it: with an empty store and +`NODE_OPTIONS=--require `, the probe printed a banner and exited 1, and the +script read RESUME. That lands on `kimi -c` with nothing to continue — which does not fail, it +starts a session that never saw `--agent-file`: a silently **roleless** builder, the #929 class +the entire guard exists to prevent. **A failure mode the delta introduced** — the pre-delta code +could not produce it. Vectors: `NODE_OPTIONS`, a `node` shim on PATH, corporate instrumentation +preloads. + +Fixed by consuming both signals, with the declaration split from the assignment so `local` does +not mask the substitution's status: + +```bash +local codev_newest +codev_newest=$(codev_newest_session) || return 1 +[ -n "$codev_newest" ] && [ "$codev_newest" != "$codev_superseded_id" ] +``` + +Pinned by a new test that reproduces the exact vector. + +## codex #1 / claude F2 — a transient probe failure at clean exit re-opens the gap — **ACCEPTED** + +The architect's sketch said "empty on any error — fail-closed", and my comment repeated it. Both +reviewers showed it is not: if the probe fails transiently (EMFILE, ENOMEM, fork failure, a +throwing preload) the branch records `''`, and the next crash sees the just-ended session as +"different from empty" → resumes it. The very bug the finding is about. + +claude's suggested mitigation (keep the previous value) only helps on *iterated* exits; the +first clean exit still records nothing. So the branch now distinguishes **failure** from **empty +store** by status and sets `codev_resume_blocked`, which refuses resume until the next clean +exit re-establishes a baseline. Accepted cost, documented in-code: a later crash restarts fresh +instead of continuing, losing conversation continuity — never the role (fresh always carries it) +and never the task (the mailbox still holds it). It self-heals at the next clean exit. + +## claude F3a / codex #4 — `j.cwd ?? j.workDir` is not the mirror — **ACCEPTED** + +Discovery's `readStateJson` tests `typeof === 'string'` **per field**; the probe's `??` +short-circuits on any non-null `cwd`, so `{cwd: 12345, workDir: }` was found by discovery +and missed by the probe. Fail-closed in direction, but it disproves the field-for-field claim +the docstring makes — and identity, not just existence, now rides on that claim. Probe changed +to per-field `typeof`; docstring corrected to stop naming `cwd ?? workDir` as the mirror; a +fixture added. + +## claude F3b — trailing-slash normalization diverged, in the UNSAFE direction — **ACCEPTED** + +The probe's `n()` stripped a trailing slash *before* `realpathSync`; `sameDir` does not. For a +path that does not exist, `/ghost/` canonicalized to `/ghost` in the probe and stayed `/ghost/` +in discovery — so the probe could name a session discovery rejects. claude called it unreachable +(the probe's argument is `$PWD`, which exists) and said record it. Removed instead: the strip +bought nothing, because `realpathSync` already normalizes a trailing slash away for any +directory that exists — which is exactly what the existing trailing-slash fixture covers, and it +still passes. Exact mirror beats documented exception. Fixture added for the ghost case. + +## claude F4 — the composition was never executed, only the pieces — **ACCEPTED** + +`decideBranch` injects `codev_superseded_id` from the test, so the only evidence the generated +clean-exit branch assigns it was a string match. A refactor wrapping that assignment in a +subshell — an ordinary bash footgun — would pass every test while the contract was dead. Added a +test that drives the **real `while` loop** with stubbed launches and a fed `read -r`, asserting +the branch sequence is `resume, fresh, fresh` (entry resumes; clean exit goes fresh and retires +the id; the pre-mint crash stays fresh). + +## claude F5 — "unreadable store" tested an ABSENT store — **ACCEPTED** + +The test never wrote a session, so `rmSync` removed nothing and it duplicated the +store-does-not-exist case. Rewritten: write a session that WOULD authorize `-c`, then replace +`sessions/` with a regular file for a deterministic ENOTDIR (root-proof, unlike `chmod 000`). + +## claude nits — **ACCEPTED** + +Restored the stronger `not.toContain('codev_launch_resume')`; the `afterClean` slice now bounds +on the branch's own two-space-indented `fi` (the earlier `\n\s*fi\n` stopped at the new nested +conditional — the same class of bug as the `"fine"` match it replaced). + +## codex edge notes — traced, documented, not engineered against + +- **Store GC drops the newest session** while an older abandoned one survives → `-c` reaches the + older one. Requires a retention policy that evicts newest-first. Recorded in-code. +- **`afx spawn --resume` / terminal re-create** resets the in-memory superseded id. Documented + as the intended boundary — and claude noted this is **contract parity**, not a kimi shortfall: + claude's minted id is equally per-process. +- **Two builders in one cwd** — not a real topology (one worktree per builder). + +--- + +## Verification + +- `pnpm build` clean; `tsc --noEmit` clean; generated script passes `bash -n`. +- Full suite **4915 passed / 48 skipped / 0 failed** (+9 on the round's starting 4906). +- Targeted suites (harness, harness-integration, spawn-worktree, kimi-session-discovery, + mailbox-pacing, render-gate) green. +- Non-vacuity is demonstrated rather than asserted: `decideBranchLegacy()` runs the pre-fix + existence-only predicate against the same store and the same generated probe, and the + regression test asserts it returns RESUME where the shipped guard returns FRESH. diff --git a/codev/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 0000000000..2572f00337 --- /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 0000000000..94c523ee14 --- /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 0000000000..7261541a47 --- /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/projects/1620-re-plan-pr-1203-kimi-harness-a/status.yaml b/codev/projects/1620-re-plan-pr-1203-kimi-harness-a/status.yaml new file mode 100644 index 0000000000..0f72f7db8b --- /dev/null +++ b/codev/projects/1620-re-plan-pr-1203-kimi-harness-a/status.yaml @@ -0,0 +1,20 @@ +id: '1620' +title: re-plan-pr-1203-kimi-harness-a +protocol: pir +phase: implement +plan_phases: [] +current_plan_phase: null +gates: + plan-approval: + status: approved + requested_at: '2026-09-05T00:13:49.634Z' + approved_at: '2026-09-08T11:04:33.805Z' + dev-approval: + status: pending + pr: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-09-04T23:58:54.479Z' +updated_at: '2026-09-08T11:04:35.923Z' diff --git a/codev/resources/arch.md b/codev/resources/arch.md index e8ac55f0a9..a8c8859f98 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -299,7 +299,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) @@ -319,7 +319,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. @@ -329,6 +329,32 @@ 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 --raw "$(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. Two details of that call earn their place. **`--raw`**: the script runs inside the worktree, so `afx` resolves the sender as this same builder — a self-send, which without `--raw` would wrap the spawn prompt in `### [BUILDER MESSAGE → ] ###`, an opening mission framed as a peer message from itself. `.builder-prompt.txt` is already fully framed, so it is delivered as itself. **A bounded retry** (30s, `CODEV_TASK_QUEUE_DEADLINE_SECS`): `spawn.ts` creates the session *before* it writes the builder's row, and `detectCurrentBuilderId()` throws while that row is missing (#1094's anti-spoofing guard), so a first attempt can legitimately lose that race. Before the retry it warned once and never tried again — a builder with a role and no mission, prevented only by node's startup latency exceeding a local HTTP round-trip. Reordering `upsertBuilder` was rejected: the row carries `terminal_id`, which does not exist until the session is created, so it would mean two upserts on the path every harness shares (Issue #1620). + +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 **names the newest resumable session** for this cwd, and the probe **fails closed**: any error (no store, unreadable dir, malformed JSON) prints nothing, and an empty answer relaunches fresh **with** the role, which is always safe. The probe answers "**would `kimi -c` continue it?**", not "does a directory exist": kimi lists a cwd's sessions before continuing one, and that listing drops **archived** sessions and ids it does not recognize — so a session we call resumable but kimi skips lands on the same roleless path. Both filters (`archived !== true`, `session_`-prefixed id) therefore apply in the probe *and* in `findLatestKimiSessionId`/`verifyKimiSessionOwnership`, and both err toward "not resumable", whose fallback is the role-carrying fresh launch. The probe is pinned by tests that execute it against fixture stores and assert the **printed id** equals `findLatestKimiSessionId`'s — identity, not just existence, since that is what the resume decision now turns on — so the hand-written snippet cannot drift from the TypeScript it mirrors — including the cases that once split them: a stray non-directory in the store (which aborted the whole scan via `ENOTDIR`, silently disabling resume machine-wide), a symlinked worktree, and a trailing slash on the recorded cwd. Entry is self-configuring on the same probe, so `afx spawn --resume` and a Tower-side terminal re-create need no second script shape — and a re-run never re-queues the task into a live conversation. A clean exit (#1267/#1317) relaunches **fresh** and re-queues the task, mirroring claude's prompt-on-fresh semantics — and that human-gated relaunch is the *only* path that re-queues. **Sticky-fresh is enforced by session identity, not by existence.** Because `-c` is cwd-scoped rather than id-pinned, and 0.33.0+ mints no session until the first message lands, a crash in the window between a clean-exit relaunch and the first delivery would otherwise find the just-ended conversation still the newest for the cwd and continue it — resurrecting exactly what the human walked away from, and delivering the re-queued task into it (#1267's own motivating defect class). So the clean-exit branch records the superseded id, and the crash branch takes `-c` only once the newest id **differs** from it; claude's loop gets the same guarantee for free by minting a new id and never naming the old one. This rests on a measured fact — `kimi -c` continues the **newest** session for a cwd, verified live on 0.34.0 with two sessions in one directory, by both a content oracle and a store-identity oracle, with no prompt and no new session minted. Two accepted residuals: the superseded id lives in the loop's memory, so closing and re-creating the terminal returns to plain entry semantics (`afx spawn --resume` means resume); and a store GC that dropped the newest session while keeping an older abandoned one would let `-c` reach that older conversation. A crash loop does not: the mailbox persists a held row, so a kimi that dies before minting a session (bad auth, say) would otherwise pile the same mission onto the mailbox every two seconds. Every value the generator interpolates — worktree path, builder id, task path — enters the script **once**, as a single-quoted escaped assignment, and every later use goes through the shell variable; the recovery hints print through `printf '%s\n'` on the expansion, which bash does not re-scan, so an id or path containing a backtick or `$(…)` is displayed rather than executed. + +**Message pacing is per-harness** (`servers/mailbox-wiring.ts` `resolvePacingForSession` + `message-write.ts` `pacing.enterDelayMs`): Kimi's paste-detection window swallows an Enter sent 80ms after the body, so Kimi targets get a ~1s delayed Enter — bisected live (80/100ms fail and never submit; 120/250/500/1000ms submit; threshold ≈100–120ms; pinned at 1000ms for ~9x margin, latency being the only cost; re-verified submitting on 0.34.0). The override governs **both** Enter sites: `SIMPLE_ENTER_DELAY_MS` (50ms, short frames) and `PASTE_ENTER_DELAY_MS` (80ms, long frames since Issue #1567 moved them onto a bracketed paste). The second is the load-bearing one and is easy to lose in a refactor: 80ms is the *first row of Kimi's own bisect* — a value measured at 0/29 losses on claude 2.1.263 and codex 0.146.0 and measured fatal on Kimi — and since a formatted `afx send` is almost always ≥4 lines, the long branch is the one real messages take. Kimi otherwise takes the default `BRACKETED_PASTE` write strategy (owner decision, 2026-09-08); whether it honours the mode is measured on a live CLI rather than assumed. 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. (3) A profile may declare `growsWithDraft`, arming the **`multi-row-draft`** rule: one kimi draft shape has *zero countable cells* — type a newline then `>` and every cell is whitespace, box chrome, or the span-exempted marker — so bounding the region correctly does not help and the composer's **shape** is the only evidence left. Sound only because box growth was **measured** to be exclusive to multi-line drafts on 0.34.0 (idle, single-line draft, `/` menu, `@` picker, mid-generation, mode chrome, a draft typed while working, and — the load-bearing one — the post-reply steady state all hold at one interior row; growth on a composer that had already carried a turn would hold every later message forever, a liveness bug rather than a fail-safe one). Deliberately a separate opt-in from `regionStartPatterns`, because the shipped `codex-idle.clean.txt` is a genuinely empty composer spanning two interior rows: folding the two together would stop codex mail the day anyone declared a region start for it. + +**Both new details ESCALATE** (Issue #1620). `no-region-start` and `multi-row-draft` join `no-composer-marker` / `no-region-end` / `no-profile` in `isUnverifiableVerdict` (`packages/sdk/src/hold-verdict.ts`) — the single definition of "will this hold clear on its own?", which `mailbox-delivery.ts`'s `isClassifierStuck` delegates to and which `afx inbox`, `afx send`, the dashboard and the VS Code toast all render from. `no-region-start` is the plain mirror of `no-region-end`. `multi-row-draft` is the contested one and is deliberate: every *other* detail is a cell **count**, and this is the one verdict reached when the classifier **could not count** and inferred from geometry — so "could not verify" is the truthful rendering, and a streak of it is precisely the signal that the measured box-growth premise has failed on a newer kimi. Accepted cost, stated so it is not rediscovered as a bug: a human genuinely sitting on a multi-line kimi draft contributes to a liveness streak; `surfaceLiveness` only alarms on recent output, which suppresses most of that and, symmetrically, part of the drift case — hence a `codev doctor` premise probe for box growth is tracked as follow-up. Issue #1201's first pass instead re-forked the predicate locally as a `Record` keyed on the union; that copy was deleted (two definitions of this rule is one edit away from an escalation policy and an operator-facing remedy disagreeing about the same row), and the compile-time exhaustiveness it bought is preserved as a type-level tripwire beside `isClassifierStuck` — in **source**, because this package excludes the `__tests__` glob from `tsc`, so a `satisfies` in a test file compiles nothing. 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` can pre-write the record at spawn — **but only behind two independent gates (Issue #1620, the #1328 class)**. **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. The original design reasoned from that narrowness to an unconditional pre-write ("strictly less than the `--yolo` the builder already runs with"), and that reasoning does not hold: auto-approving *tool calls* and permitting a checkout to *introduce new tool-providing processes* are separate boundaries, and spawning a builder onto a contributor branch is an ordinary flow here. So the pre-write now requires (a) an explicit `.codev/config.json` opt-in — `harnessOptions.kimi.autoTrustWorkspace`, **default false**, in a namespace deliberately distinct from `harness` (whose entries are validated at load against the custom-harness shape, so a settings key there would throw during `loadConfig`) — and (b) the absence of project-level MCP config in the worktree, checked by existence rather than by parsing, since a folder shipping a *broken* `.mcp.json` is still a folder defining servers. The MCP refusal wins even when opted in, and is evaluated first so the log states the strongest true reason. `ensureKimiWorkspaceTrust` returns a `KimiTrustDecision` (`already-trusted` / `not-opted-in` / `project-mcp-config` / `write-failed`) rather than a boolean, because "no record was written" otherwise conflates a deliberate refusal with a failure, and an operator debugging a stalled builder needs to know which; every outcome is logged at the call site. Consequence, deliberate: a repository shipping a root `.mcp.json` hits refusal (b) on every Kimi worktree, so unattended Kimi spawning there needs one interactive trust. The write is idempotent and fail-soft (on any refusal or failure the dialog simply appears and the gate holds the task — `no-composer-marker`, which is in the escalation class, so it surfaces rather than hanging silently), 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 b5812db049..d70e6d7154 100644 --- a/codev/resources/commands/agent-farm.md +++ b/codev/resources/commands/agent-farm.md @@ -1129,6 +1129,82 @@ 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. + +#### Workspace trust for Kimi builders (opt-in, default off) + +kimi 0.33.0+ opens on a "Trust this folder?" dialog, and a builder worktree is +always a new folder — so an unattended Kimi builder stalls on a dialog it cannot +answer. Codev can pre-record trust in kimi's own store, but **only when you ask +for it**: + +```json +{ + "harnessOptions": { + "kimi": { + "autoTrustWorkspace": true + } + } +} +``` + +**The default is `false`, and it is off for a reason.** Folder trust gates exactly +one thing: whether kimi loads MCP servers **defined by the folder itself** +(`.mcp.json`, `.kimi-code/mcp.json`). That is a different boundary from the +`--yolo` tool auto-approval a Kimi builder already runs with — auto-approving +tool calls and letting a checkout introduce new tool-providing processes are not +the same permission. Spawning a builder onto a contributor branch is an ordinary +workflow, so this decision is yours to make rather than one Codev makes quietly +on your behalf. + +Even with the opt-in set, the pre-write is **refused** for any worktree that +contains `.mcp.json` or `.kimi-code/mcp.json`. That is the one case where the +trust decision actually grants something, so it is the one case a human answers. +The log line names which file caused the refusal. + +**Consequence worth planning around:** if your repository ships a root +`.mcp.json`, every Kimi builder worktree hits that refusal, so unattended Kimi +spawning will not work until a human trusts each folder once. On any refusal +nothing is lost — kimi shows its dialog, and the builder's queued task is *held* +by the render gate (never misdelivered) and surfaces through the mailbox's +escalation telemetry. + +Note `harnessOptions` is a separate key from `harness`, which defines *custom* +harnesses; settings for built-in harnesses go under `harnessOptions`. + +#### No write-guard for Kimi builders yet + +Kimi builders do NOT get the worktree write-guard Claude builders have (#1018), +so a Kimi builder can write into the main checkout. kimi does document a blocking +`PreToolUse` hook seam, so parity is achievable follow-up work rather than a +permanent limitation — but it is not in place today, and that is worth weighing +before running Kimi builders unattended. + +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 d35bd5fcd9..73086ce4cd 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -115,6 +115,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 #1497] A name that is only a *caption* can carry a convenience fallback safely; the moment that same name becomes an *address* — a cache key, a lookup key, a routing target — the fallback turns into a misdelivery bug. `codev.openArchitectTerminal` resolved a non-live `main` to `architects[0]` but flowed the *requested* name `'main'` onward, and in `terminal-manager.ts` that name keys both the terminal cache (`architect:${name}`) and the `injectArchitectText` lookup: the wrong architect was cached under `architect:main`, wore the unqualified "Codev: Architect" label (the exact form that denotes `main`), and captured any text later injected at `main`. Two structural defenses, both applied: (1) flow the *resolved occupant's own name* onward, never the requested name, so key/label/injection all address the real occupant even if a fallback is ever reintroduced; (2) drop the `|| 'main'` fallback whose *entire realized behaviour was the failure window* — it was only ever consulted when `main` was absent from the roster, so it served no healthy case and removing it cost nothing legitimate. The same `|| 'main'`-as-address shape recurs (sibling lane pir-1494's approval-relay refusal; Tower `role_id || 'main'` #1214; owner-fallback #1406): audit any `|| 'main'` / `?? ` where the resolved value then *keys an action*, not just a display. Divergence note worth recording: the terminal path here and the approval-relay both **refuse** rather than substitute — a request for `main` that can't be honoured is answered by saying so, not by handing over a different recipient — so the two lanes deliberately did **not** diverge. Sibling to #1139 (resolved-name-must-flow-to-every-consumer). - [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. @@ -298,6 +300,24 @@ Generalizable wisdom extracted from review documents, ordered by impact. Updated - [From #1482] A green typecheck proves less than it looks if the tsconfig excludes the directory. `apps/web`'s `include` is `["src", "vite.config.ts"]`, so a test fixture omitting a newly-required field compiled fine and would have kept compiling until someone widened the include. - [From #1475] **A suite that builds its own fixtures cannot tell you what the real input looks like.** 4944 unit tests exercised the identity path and all passed while the feature did the opposite of its purpose for every architect session in production — because every fixture's argv was one *I* wrote, and none resembled the multi-KB `--append-system-prompt` a real architect launches with. Scripting the plan's manual steps against a real (isolated, private-port, own-test-DB) server with real subprocesses found it in one run. Two habits follow: capture one **real** input (log the actual argv/payload once) and pin a test to it; and when a protocol's gate is "does it work in production", make the evidence *run the production path* — the unit suite is a claim about your fixtures, not about the world. See `packages/codev/scripts/pir-1475-dev-approval-evidence.mts` and `codev/evidence/1475-dev-approval-transcript.txt` for a reusable shape (spawn the built server on a private port with `NODE_ENV=test` + a scoped test DB, refuse to run against a port you did not bind, assert the shared live instance is untouched before and after). +- [From #1620] **A suite that pins only the cheap branch can stay green while the feature is broken + on the branch real inputs take.** The Kimi Enter-delay override was covered by tests that sent + `'BEGIN'` — a short frame. Every formatted `afx send` is ≥4 lines and takes the *long* frame path, + which #1567 had since given its own Enter delay (`PASTE_ENTER_DELAY_MS`, 80 ms) — and 80 ms is the + exact value Kimi's own bisect showed gets swallowed. So the shipped tests would have passed while + every real message to a Kimi builder was typed and never submitted. When a code path forks on + input *shape* (short/long, one/many, empty/full), ask which branch production actually takes and + pin that one first; a fixture chosen for brevity is usually the branch nobody uses. + +- [From #1620] **A test whose outcome depends on two filesystem operations landing in the same + timestamp tick is platform-dependent, not flaky — and it will look like neither.** A probe that + reports drift when the *newest* record is malformed was tested by writing a good record then a bad + one and expecting "ok" — which only holds when both `mkdir`s share an mtime. It passed on the + contributor's filesystem and failed 5/5 on APFS, where `mtimeMs` is sub-millisecond. Two habits: + when a test's meaning depends on ordering, set the timestamps explicitly (`utimesSync`) rather + than relying on write order; and when a failure looks flaky, run it several times before reaching + for the flaky label — deterministic-on-this-machine is a different bug with a different fix. + - [From #1049] Split webview/UI logic into a pure, `vscode`-free core and a thin host adapter so the load-bearing logic unit-tests without a VS Code host (mirrors #907/#1497: importing anything that value-imports `vscode` in a node vitest env fails to resolve). The resolver, surface-derivation, pill-model, and message-validation for the contextual panel are pure modules with no `vscode` import; a **source-scan test** (read the file, assert its only imports are the sibling types) keeps them pure — a guard cheaper than a runtime purity harness. The `vscode`-touching reader/provider is then covered by an integration test that mocks `vscode` via `vi.mock`. - [From #1497] A vitest unit test that (transitively) value-imports a workspace package needs that package's `dist` built first — vite resolves the runtime `exports.default → ./dist`, not the TS source. Type-only imports are elided, which is why most vscode `__tests__` never hit this and why the pre-existing `terminal-manager.test.ts` retreated to source-string assertions ("constructing a full `TerminalManager` requires heavyweight deps"). But a *behavioural* capture from a class buried behind such imports is viable: importing the real `TerminalManager` (it value-imports `@cluesmith/codev-types` + `codev-sdk` via `terminal-adapter`) worked once `codev-types`/`codev-sdk` were built, letting the test assert real `injectArchitectText` `sendText` routing instead of a source regex. It passes in CI because `test.yml` runs `pnpm build` before vitest; locally, build the deps first. Companion to #907 (esbuild's `default → ./dist` condition needs the package built) — the same dist-before-consume rule, on the vitest side. - [From #1401] **A guard is not a guard until you have watched it fail.** Two variants bit in one @@ -310,6 +330,13 @@ Generalizable wisdom extracted from review documents, ordered by impact. Updated also meant `pnpm build` never compiled it and **no CI job invoked it** — it protected nothing while looking like protection. Before trusting any guard, break the thing it guards and watch the build go red; then check something in CI actually runs it. + (c) [#1620] The same trap one directory in: a `satisfies Record` written in a + `__tests__` file, in a package whose tsconfig `exclude` lists the `__tests__` glob. It is + *inside* `src/`, so (b)'s "outside src/" heuristic does not catch it, and vitest transpiles + without typechecking — so the annotation compiles nowhere and enforces nothing while reading + exactly like a compile-time guarantee. Exhaustiveness guards belong in a file the build + actually typechecks; the *runtime* assertions can stay in the test. Checked by widening the + union and watching `tsc` go red, per this lesson's own rule. - [From #1401] Green signals can hide a red one. `check-types` was failing on a newly added Playwright spec while the unit and browser suites both passed, because Playwright transpiles per file with no project-wide type check. Run `check-types` after the last **file** is added, 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 0000000000..2238b41e28 --- /dev/null +++ b/codev/reviews/1201-support-kimi-code-cli-as-a-bui.md @@ -0,0 +1,133 @@ +# PIR Review: Support Kimi Code CLI as a builder + +Fixes #1201 + +> **Rewritten 2026-09-08 under #1620.** This document described the **retired** seed-session +> design — a `kimi -p` bootstrap, a captured session id pinning `kimi -S `, a sentinel-gated +> store-verified `BEGIN` kick written straight to the PTY, `seed-kick.ts`, `message-pacing.ts`, +> `.builder-seed.txt`. None of that is in the branch; the 2026-08-09 design pivot replaced it once +> kimi 0.31.0's `--agent-file` and Spec 1313's mailbox gave role and task sanctioned homes. The +> plan was rewritten for the same reason. A review artifact that describes code which does not +> exist is worse than no artifact — it is a confident wrong answer for whoever reads it next — so +> it now describes what shipped, including the #1620 amendments made by maintainers on top of +> @mohidmakhdoomi's work. + +## Summary + +Adds the Kimi Code CLI (`kimi`, **≥ 0.33.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. + +Kimi differs from every previously supported harness in three ways the generic launch shapes cannot +express: **no positional prompt**, **server-side session ids minted on the first message**, and a +**startup folder-trust dialog** that renders before any composer. So the launch shape is +provider-owned (`HarnessProvider.buildBuilderLaunchScript`), and role and task travel separately: + +- **Role** → `--agent-file`, an agent-definition file written into the worktree and composed around + kimi's `${base_prompt}` token so it *extends* rather than replaces kimi's own system prompt. +- **Task** → an ordinary first message queued on the Spec 1313 **mailbox** and delivered by the + render gate onto a verified-empty composer. Never a direct PTY write, which the mailbox contract + forbids — so a busy line, a boot screen, or the trust dialog **holds** the message rather than + corrupting or losing it. + +Crash restart resumes with the documented, cwd-scoped **`kimi -c`**, guarded by a store probe that +fails closed (`kimi -c` with nothing to continue does not fail — it starts a fresh session that +never saw `--agent-file`, i.e. a silently roleless builder). Kimi as an *architect* is out of scope +and fails loudly. + +## Files Changed + +- `packages/codev/src/agent-farm/utils/harness.ts` — `KIMI_HARNESS`, detection, + `buildBuilderLaunchScript` / `prepareWorkspace` / `messagePacing` capabilities, the agent-file + composer, the inlined resume probe, and `launchLoopTail` relocated here (exported) so the + provider-owned script can share it +- `packages/codev/src/agent-farm/utils/kimi-session-discovery.ts` (new) — store scan, ownership + verification, state reader, the trust record and its refusals, and both drift probes; all + fail-soft and `KIMI_CODE_HOME`-aware +- `packages/codev/src/agent-farm/commands/spawn-worktree.ts` — provider-owned script branch in both + entry points; resolves and passes the trust opt-in +- `packages/codev/src/agent-farm/servers/gate-profiles.ts` — `KIMI_PROFILE` and registry entry +- `packages/codev/src/agent-farm/servers/render-gate.ts` — `regionStartPatterns`, `growsWithDraft`, + `markerSpanEnd` / `markerSpanStart`, `findRegionStart`, the per-row marker exemption, and the two + new verdict details +- `packages/sdk/src/hold-verdict.ts`, `db/types.ts`, `db/schema.ts` — both new details in the + shared predicate, the persisted union, and the column comment +- `packages/codev/src/agent-farm/servers/message-write.ts` — `MessagePacing`, threaded through + `writeMessageToSession` and `submitMessagePaced`, overriding **both** Enter delays +- `packages/codev/src/agent-farm/servers/mailbox-wiring.ts` — `resolveHarnessForSession`, + `resolvePacingForSession`, and pacing at the `writeMessage` binding (which covers cron delivery, + since it writes through the same port) +- `packages/codev/src/agent-farm/servers/tower-routes.ts` — pacing on the `--interrupt` write; + `--escape` deliberately unpaced, with the reason recorded +- `packages/codev/src/agent-farm/types.ts`, `packages/codev/src/lib/config.ts` — the + `harnessOptions` namespace, typed and validated at load +- `packages/codev/src/commands/doctor.ts` — kimi presence, the 0.33.0 floor, an auth heuristic, the + store and trust drift probes, and the architect-use warning +- Tests across `harness.test.ts`, `render-gate.test.ts`, `spawn-worktree.test.ts`, + `kimi-session-discovery.test.ts`, `mailbox-pacing.test.ts`, `config.test.ts`, + `bugfix-584-send-multiline-pacing.test.ts`, `hold-verdict-exhaustive.test.ts`, plus eight real + `fixtures/gate/kimi-*.txt` captures +- Docs: `codev/resources/arch.md`, and `codev/resources/commands/agent-farm.md` mirrored into + `codev-skeleton/` +- `codev/spikes/pir-1201-kimi-*.mjs` — the measurement spikes and the runnable live-demo driver + +## Test Results + +- `pnpm build`: clean. +- `pnpm test`: **5,940 passed, 48 skipped, 0 failed** (as of the #1620 merge into converged `main`). +- **#929-class regression covered from four angles**: `kimi` plus a stale Claude `.jsonl` can never + yield `--resume ` or `--append-system-prompt` — harness `buildResume`, + `discoverResumeSession`, config/override resolution, and generated-script assertions. +- The generated resume probe is pinned by a test that **executes** it against fixture stores and + asserts its printed id equals `findLatestKimiSessionId`'s, so the hand-written snippet cannot + drift from the TypeScript it mirrors. +- Every generated launch-script shape is parsed by a real `bash -n` — generated shell is the one + artifact here no type checker reads. + +**Live validation status.** The original 7/7 demo ran against real kimi **0.27.0/0.34.0** in +2026-07/08. It has **not** been re-run since: the #1620 maintainer lane has no authenticated Kimi, +and re-measurement plus the now-nine-scenario demo are handed back to @mohidmakhdoomi (checklist on +PR #1203). Latest kimi is **0.41.0**. Treat every measured claim below as carrying its version. + +## Architecture Updates + +Routed to the **COLD** tier (`codev/resources/arch.md`), a dedicated "Kimi Builder Harness" +subsection under Agent Farm Internals: builder-only status, `--agent-file` role injection, mailbox +task delivery, the guarded `kimi -c` resume and its sticky-fresh identity check, per-harness Enter +pacing across both Enter sites, the render-gate profile including `growsWithDraft` / +`multi-row-draft` and the escalation decision, the gated workspace-trust record, the 0.33.0 floor, +and the undocumented-surface audit with its two drift probes. + +No **HOT** tier change: kimi support is subsystem detail, not a top-10 always-on system-shape fact. + +## Lessons Learned Updates + +Routed to the **COLD** tier (`codev/resources/lessons-learned.md`): + +1. *Advisory decorators on critical paths must be failure-total* — a narrow `try`/`catch` in the + pacing resolver let a mocked-out dependency 500 every `/api/send`. The whole body now degrades + to defaults. +2. *Prefer a self-describing artifact the launcher already generates over a marker file every + launch shape must remember to write* — the `.builder-kimi` marker was missed by one shape and + cost a review cycle; pacing now reads the harness out of the generated `.builder-start.sh`. + +## Things to Look At During PR Review + +- **The trust pre-write is gated** (#1620): opt-in via `harnessOptions.kimi.autoTrustWorkspace` + (default false), and refused outright for any worktree shipping `.mcp.json` or + `.kimi-code/mcp.json`. The earlier "grants strictly less than `--yolo`" argument holds for tool + execution and not for what trust actually controls — loading folder-defined MCP servers. +- **`multi-row-draft` escalates** (#1620), reversing this branch's original classification. It is + the one verdict reached when the classifier could not count cells and inferred from box geometry. +- **The `growsWithDraft` premise is load-bearing and version-pinned.** If a post-reply steady-state + composer ever grows past one interior row, the rule holds every later message forever. Measured on + 0.34.0; re-verification is the contributor's checklist step 2. +- **Undocumented-surface reliance is deliberately narrow** — discovery scans only + `sessions/*/*/state.json`, and both it and the trust-record naming carry `codev doctor` drift + probes that weigh records by recency, so a store migration surfaces instead of hiding. +- **`kimiTuiCmd` appends `--yolo`** unless the user already passed it; `--auto` is deliberately never + used (documented conflict, and it suppresses the agent→user questions the gate workflow needs). +- **Kimi builders have NO write-guard** (#1018 class). kimi *does* document blocking `PreToolUse` + hooks since 0.32.0, so parity is achievable follow-up rather than a permanent limitation — but it + is not in place today. diff --git a/codev/reviews/1620-re-plan-pr-1203-kimi-harness-a.md b/codev/reviews/1620-re-plan-pr-1203-kimi-harness-a.md new file mode 100644 index 0000000000..64c3176198 --- /dev/null +++ b/codev/reviews/1620-re-plan-pr-1203-kimi-harness-a.md @@ -0,0 +1,303 @@ +# PIR Review: Re-plan PR #1203 (Kimi harness) against converged main + +**Issue**: cluesmith/codev#1620 · **PR**: cluesmith/codev#1203 (author @mohidmakhdoomi) +**Branch**: `builder/pir-1201` — merged, never rebased or squashed. + +## Summary + +PR #1203 was complete and green on 2026-08-09, then sat un-re-reviewed for 26 days while `main` +advanced 1,606 commits and rebuilt three of its four core seams. The 2026-09-04 3-way review found +the design sound and the branch un-mergeable. This lane merged `main` in, re-derived every moved +seam, closed a security gap and a latent spawn-race, rewrote the stale plan, and left the Kimi-side +live verification to the original contributor. + +**PR #1203 is now `mergeable: true`** (GitHub reports `blocked` only in the review-required sense). +Full suite: **5,940 passed, 0 failed**. + +## What this lane did NOT do — read this before merging + +**Nobody here ran Kimi.** There is no authenticated Kimi CLI on the maintainer side and no +credentials to supply (human decision, 2026-09-05). So: + +- The Kimi render-gate profile, the `growsWithDraft` box-growth premise, the 1000 ms Enter delay, + the trust-record naming scheme, and the `kimi -c` newest-session semantics all still rest on + **0.34.0** measurements taken 2026-08. Latest is **0.41.0** — seven minors, and 0.33.0 was itself + an engine change. **This is the same staleness that made #1203 un-mergeable, recurring.** +- Kimi's behaviour under #1573/#1584 echo verification is **unmeasured on any version**. +- Whether Kimi honours bracketed paste (#1567) is **unmeasured**. + +Re-measurement and the live demo are handed to @mohidmakhdoomi, whose evidence attaches to +PR #1203. An eight-step checklist is in the plan. **Checklist step 2 can block the merge**: if a +post-reply steady-state composer grows past one interior row on a current Kimi, the +`multi-row-draft` rule holds every later message forever — a liveness bug, not a fail-safe one — +and must not ship as written. + +Our own `dev-approval` was therefore scoped to what *is* verifiable without Kimi: that a change +made **for** Kimi moved nothing **else**. That is the larger risk anyway — `render-gate.ts`, +`message-write.ts` and `hold-verdict.ts` carry claude, codex and agy delivery for every user. + +## Files Changed + +`git diff --stat $(git merge-base main HEAD)..HEAD` — **59 files, +8,551 / −83**, of which the +great majority is @mohidmakhdoomi's original work carried through the merge unchanged. What *this* +lane touched: + +| Path | Change | +|---|---| +| `servers/message-write.ts` | `MessagePacing` re-derived onto the post-#1567 signatures; the override now governs `SIMPLE_ENTER_DELAY_MS` **and** `PASTE_ENTER_DELAY_MS` | +| `servers/mailbox-wiring.ts` | pacing threaded through the `writeMessage` binding (7th arg, after `strategy`) | +| `servers/mailbox-delivery.ts` | local `CLASSIFIER_STUCK_DETAILS` fork deleted; type-level exhaustiveness tripwire added beside `isClassifierStuck` | +| `servers/tower-routes.ts` | pacing on the `--interrupt` write; `--escape` left unpaced with the reason | +| `servers/render-gate.ts` | `markerSpanStart` (named-group glyph lookup) + `markerFgPalette` generalized off `getCell(0)`; region-start work layered onto #1474's anchored `findMarkerRow` | +| `servers/gate-profiles.ts` | `KIMI_MARKER` glyph wrapped in `(?…)` | +| `sdk/src/hold-verdict.ts` · `db/types.ts` · `db/schema.ts` | `no-region-start` + `multi-row-draft` in all three | +| `utils/kimi-session-discovery.ts` | `KimiTrustDecision`; the MCP refusal; the opt-in gate | +| `utils/harness.ts` | `prepareWorkspace` widened; every trust outcome logged; bounded queue retry; `--raw` task send | +| `commands/spawn-worktree.ts` | consent resolved from config and passed to `prepareWorkspace` | +| `agent-farm/types.ts` · `lib/config.ts` | the `harnessOptions` namespace, validated at load; `kimiAutoTrustWorkspace` | +| tests | `hold-verdict-exhaustive.test.ts` (new); extended `harness`, `render-gate`, `spawn-worktree`, `kimi-session-discovery`, `mailbox-pacing`, `bugfix-584-…`, `bugfix-1567-…`, `src/__tests__/config` | +| docs | `codev/resources/arch.md`; `commands/agent-farm.md` **mirrored into `codev-skeleton/`**; both 1201 artifacts rewritten | +| `spikes/pir-1201-kimi-builder-demo.mjs` | scenario 6 reshaped, 6b/6c added, spawn opts in explicitly | + +## Commits + +Merge-only on top of @mohidmakhdoomi's 47; nothing rebased or squashed. + +- `26245f2ad` Plan draft · `34886ba44` revised against the raw lane output · `422d9b629` Kimi work + handed to the contributor · `acbde9049` no-outward-posts rule +- `f0eaa98ee` re-derive the pacing seam onto the post-#1567 write edge · `6a80d4f02` state the + bisect numbers · `8827b176b` owner decision on bracketed paste +- **`7e7b5d236` Merge origin/main into builder/pir-1201** · `c80437eb4` fix the five suites the + merge broke, and one it exposed +- `eaf02fdea` gate the trust pre-write · `721182d21` close the task-queue race, `--raw` send, docs +- `41955b4fd` arch.md · `e5b212c48` review docs (incl. rewriting 1201's stale one) · `d1ff28dc2` + lessons +- `a0c8d26cc` test that pacing is *wired* · `83f921662` fix `markerSpanStart`, which was inert + +## How to Test Locally + +All of this is **non-Kimi on purpose** — see *What this lane did NOT do*. The Kimi-facing steps are +the contributor's checklist. + +```bash +gh pr checkout 1203 && pnpm install && pnpm build && pnpm test # expect 5,951 passed / 0 failed +``` + +1. **The measured harnesses are untouched.** Generate a builder launch script for claude and codex + before and after this branch and diff them — byte-identical, or the change is wrong. This is the + manual mirror of the `markerSpanEnd` guardrail. +2. **Live delivery to a claude builder still works.** `pnpm -w run local-install`, then from the + main workspace root `afx spawn --task "…"`, then `afx send ` with a >4-line body: it + arrives as one submitted message and the log says `delivered`, not `delivered-unverified`. This + is the regression a green suite is least likely to catch, because the #1573 echo path is + timing-dependent. +3. **A held row still reads correctly.** Put a claude builder's composer in a draft state, send to + it, and check `afx inbox`: `busy:user-text`, **not** an unverifiable verdict — proving the + `isUnverifiableVerdict` edit did not widen the escalation class for existing details. +4. **Config.** With no `harnessOptions` block, `afx status` / `afx spawn --help` / `codev doctor` + behave exactly as before. With `{"harnessOptions":{"kimi":{"autoTrustWorkspace":"yes"}}}` in + `.codev/config.json`, the very next command fails loudly and names the key — then remove it. +5. **`codev doctor` with kimi absent** (the state of this machine) degrades cleanly: reports kimi + not installed, does not throw, does not fail the run. +6. **The generated Kimi script is valid shell even without Kimi installed** — + `pnpm vitest run src/agent-farm/__tests__/harness.test.ts` parses every shape with `bash -n`. + +## KEY_ISSUES disposition — 2026-09-04 three-way review + +| Lane | KEY_ISSUE | Disposition | +|---|---|---| +| gemini | *(none — APPROVE)* | Both integration notes honoured: the 0.33.0 floor is unchanged, and the write-guard gap is scoped as follow-up **with the bound both other lanes asked for** (below). | +| codex | Trust pre-write silently enables repo-controlled MCP servers | **Fixed.** Two independent refusals; see *Workspace trust* below. | +| codex | The approved plan no longer describes the implementation | **Fixed.** `codev/plans/1201-…md` rewritten to the shipped architecture and re-approved by the human at the `plan-approval` gate on 2026-09-08. | +| codex | Write-guard limitation acceptable *only if maintainers explicitly accept it* | **Accepted explicitly** — see *Write-guard* below. | +| claude | Branch CONFLICTING, 1,603 behind; `writeMessagePaced`, `findMarkerRow`, `launchLoopTail` all moved | **Fixed** for the first two. The third was a **misreading**, verified: `launchLoopTail` is still module-local on `main` at `spawn-worktree.ts:803`, byte-identical to the branch's copy. The PR *relocates* it into `harness.ts` so the provider script can share it — which claude's own integration notes recommend keeping. Kept. | +| claude | New `GateVerdict` details bypass #1482's consolidation | **Fixed.** Local fork deleted; both details added to `MailboxGateDetail`, the schema comment, and `isUnverifiableVerdict`. | +| claude | `multi-row-draft` excluded from the stuck set, so the rule's own failure mode is silent | **Fixed by escalating it** — the first branch of claude's own "escalate *or* add a doctor probe". Note this **supersedes** the `multi-row-draft → false` parenthetical in the same lane's §2, which its §3 then argues against. The doctor premise probe is filed as follow-up. | +| claude | Trust pre-write should refuse on project-level MCP config | **Fixed.** | +| claude | No `PreToolUse` write guard while kimi is documented as supported | **Bounded follow-up** — see below. | +| claude | Kimi echo behaviour unmeasured against #1573/#1584 | **Handed to the contributor** (checklist step 4). The predicted cost is recorded below. | +| claude | §7, filed as "smaller": confirm what a builder self-send attributes to | **Chasing it found a real defect.** See *The spawn race*. | + +## What changed + +### The delivery path, re-derived twice + +`main` moved the write edge under this branch **twice**: #1365 replaced `writeMessagePaced` with +`submitMessagePaced` (per-terminal lock, in-lock precheck, five-way result), and then #1567/PR #1644 +replaced per-line pacing with bracketed-paste chunking — taking the 5th parameter slot the branch's +`pacing` occupied. `MessagePacing` survives, re-homed as the 6th/7th parameter. + +**The subtle part, and the reason the seam still earns its place.** #1567 gave long frames their own +Enter delay, `PASTE_ENTER_DELAY_MS = 80`, measured at 0/29 losses on claude 2.1.263 and codex +0.146.0. Kimi's own bisect: **80 ms and 100 ms are swallowed and never submit**; 120/250/500/1000 ms +submit. The new default lands *exactly* on Kimi's measured failure value — the original #1201 symptom, +reintroduced by a change that had no reason to know Kimi exists. The override now governs **both** +Enter sites, and the tests pin both: a formatted `afx send` is almost always ≥4 lines, so the long +branch is the one real messages take, and short-frame-only coverage would have stayed green while +the feature was broken for every message anyone sends. + +Kimi keeps the default `BRACKETED_PASTE` strategy (owner decision, 2026-09-08). This lane proposed +opting it out until measured and was overruled; the decision is recorded in the plan and pinned by a +test so a later edit to `writeStrategyForApp` has to be deliberate about Kimi. + +### Gate details and the deleted fork + +The branch re-forked #1482's escalation predicate as a local `Record`. Deleted — two definitions of +"will this hold clear on its own?" is one edit away from an escalation policy and an operator-facing +remedy disagreeing about the same row. `no-region-start` and `multi-row-draft` now land in +`MailboxGateDetail`, the schema comment, and `isUnverifiableVerdict`. + +**`multi-row-draft` escalates, and that reverses the branch's choice.** It is defensible on its own +terms: every other detail is a cell *count*, and this is the one verdict reached when the classifier +*could not count* and inferred from box geometry — "could not verify" is the truthful rendering, and +a streak of it is exactly the drift signal. **Accepted cost:** a human genuinely sitting on a +multi-line Kimi draft contributes to a liveness streak. `surfaceLiveness` only alarms on recent +output, which suppresses most of that and, symmetrically, part of the drift case — hence the doctor +premise probe as follow-up. + +The fork's *real* value — a compile error when the union grows — is preserved as a type-level +tripwire beside `isClassifierStuck`, in **source**. It cannot live in the test: this package excludes +the `__tests__` glob from `tsc`, so a `satisfies` there compiles nothing and would have read like a +guarantee while enforcing none. Verified by temporarily widening `GateVerdict['detail']`: both +assertions fail with a message naming where to classify it. + +### Marker anchoring, and a latent bug found on the way + +#1474's cursor/palette anchors and the branch's region-start bounding are orthogonal; both kept. +While reconciling them: `markerFgPalette` read `line.getCell(0, cell)` — a hardcoded column 0, +correct for every marker anchored at the row start and wrong for the first profile whose marker is +not, which is exactly what Kimi's boxed `│ >` (column 3) is. Latent today, a live bug the moment +anyone gave Kimi a palette anchor. Generalized to the marker match's start column, with a test. + +### Workspace trust — the security change + +`ensureKimiWorkspaceTrust` wrote a record for any worktree, unconditionally, on every Kimi spawn. +The original argument was that this grants strictly less than the `--yolo` the builder already runs +with. That holds for *tool execution* and not for what trust actually controls: whether kimi loads +MCP servers **defined by the folder**. Two independent refusals, both defaulting to doing nothing: + +- **`project-mcp-config`** — the worktree ships `.mcp.json` or `.kimi-code/mcp.json`. Refused even + when opted in. Existence only; the file is never parsed, because a folder shipping a *broken* + `.mcp.json` is still a folder defining servers. +- **`not-opted-in`** — the default. `harnessOptions.kimi.autoTrustWorkspace`, a new namespace + deliberately separate from `harness` (whose entries are validated at load against a shape + requiring `roleArgs`/`roleScriptFragment`, so a settings key there would throw during `loadConfig` + and break unrelated commands; and built-ins win resolution, making `harness.kimi` inert config). + +The MCP check runs **first** so the log states the strongest true reason — someone who *has* opted +in needs to hear "this worktree ships MCP config", not "you did not opt in", which would be false. +The return is a `KimiTrustDecision`, not a boolean, because "no record written" otherwise conflates +a deliberate refusal with a failure. + +**Consequence, deliberate:** a repo shipping a root `.mcp.json` hits the refusal on every Kimi +worktree, so unattended Kimi spawning there needs one interactive trust. Documented in both trees. + +### The spawn race — a real defect behind a "smaller" review note + +claude's §7 asked only what a builder self-send attributes to. Chasing it found worse: + +The generated script queues its task with `afx send` from inside the worktree. `spawn.ts` starts the +session (`spawn.ts:482`) and only **then** registers the builder row (`:488`), while +`detectCurrentBuilderId()` **throws** when that row is missing (`send.ts:167`, the #1094 +anti-spoofing guard). Lose that race and afx fatals, the script warned once and **never retried +within that launch** — a builder up with a role and no mission, the only trace a line in its own +pane. The sole thing preventing it was node's startup latency exceeding one local HTTP round-trip. + +Fixed with a bounded retry (30 s, `CODEV_TASK_QUEUE_DEADLINE_SECS`). Reordering `upsertBuilder` was +the tempting root fix and is rejected: the row carries `terminal_id`, which does not exist until the +session is created, so it would mean two upserts on the path every harness shares. + +The attribution question was real too: the sender resolves to the builder's **own** id, so the +opening mission arrived framed `### [BUILDER MESSAGE → ] ###`, and there is no self-send +guard in `handleSend`. Now sent `--raw` — `.builder-prompt.txt` is already a fully framed prompt. + +### Write-guard: the follow-up, and its bound + +Kimi builders have no `PreToolUse` write-guard (#1018 class), so a Kimi builder can write into the +main checkout. **Maintainers explicitly accept this as follow-up rather than a blocker** — codex's +stated condition. claude's stricter condition is also met: the gap is now stated **where kimi is +documented as supported**, in both doc trees, and the follow-up issue is filed before merge rather +than left open-ended. The branch's earlier "no documented hook seam, parity impossible" claim was +obsolete and is corrected: kimi has documented blocking `PreToolUse` hooks since 0.32.0, which is +what makes parity achievable. + +### Echo verification cost + +Predicted, not measured: `enterDelayMs` 1000 plus two 600 ms verify windows makes a Kimi `afx send` +cost ~2.2 s worst case, and Kimi deliveries may report `delivered-unverified` on every message. +**That is not a fault** — #1584 commits the delivery first and reports rather than retrying, so an +unconfirmed Kimi delivery can never loop. Contributor checklist step 4 supplies the real number. + +## Test Results + +- `pnpm build`: clean. +- `pnpm test`: **5,940 passed, 48 skipped, 0 failed.** + +Three suites broke during the merge, and one is worth recording because it was **not** the merge's +doing. `kimi-session-discovery`'s "ok when at least one session carries the load-bearing shape" +wrote a good session then a bad one and expected `ok` — but the probe *deliberately* reports drift +when the newest session is the broken one. It only ever passed where two `mkdir` mtimes tied, so it +was platform-dependent all along: 5/5 failures on APFS, where `mtimeMs` is sub-millisecond. Both the +test and its implementation are byte-identical to the pre-merge branch. Fixed with the explicit +`touchDir` ordering the very next test in the same `describe` already uses. + +New coverage worth naming: +- The pacing override on **both** frame branches (see above for why short-only would have lied). +- `bash -n` parsing of every generated launch-script shape. Generated shell is the one artifact here + no type checker reads, and this change hit exactly that twice: a backtick in a shell comment + closed the TypeScript template literal, and an unescaped `${…}` would have been interpolated by JS. +- The spawn race as **behaviour**, not script text: a stub `afx` that fails until a sentinel appears + reproduces the lost race; a second that never succeeds pins the fail-soft give-up path. +- The Issue #1201 span guardrail's agy case, which #1474 had silently disabled — its synthetic screen + stopped qualifying as a marker row, so the assertion passed for the wrong reason. Restored with a + helper that satisfies the anchors. The sibling `>x` test had the same defect and also passed. + +## Flaky Tests + +None. The one intermittent-looking failure (`inspectKimiStoreLayout`) proved deterministic on this +platform and was a real test defect, not flake — see above. + +## Architecture Updates + +Routed to the **COLD** tier (`codev/resources/arch.md`), three passages in the Kimi subsection: the +trust paragraph (the old "strictly less than `--yolo`" reasoning is now stated *and* rebutted in +place, since the conclusion changed but the narrowness that motivated it did not); the pacing +paragraph (both Enter sites, with the full bisect numbers beside `PASTE_ENTER_DELAY_MS`'s own +provenance, so the collision is unmissable next time); and the render-gate paragraph, which was +missing `growsWithDraft`, `multi-row-draft`, and the escalation decision entirely. + +No **HOT** tier change. Kimi support is subsystem detail; the existing hot facts already cover the +decision surface this touches, and the cap is full of broader rules that would beat these on +displacement. + +## Lessons Learned Updates + +Routed to the **COLD** tier (`codev/resources/lessons-learned.md`): + +1. *A suite that pins only the cheap branch can stay green while the feature is broken on the + branch real inputs take.* The pacing suite pinned a short frame; every formatted `afx send` is + ≥4 lines and takes the long one, whose Enter delay is exactly the value Kimi swallows. +2. *A test whose outcome depends on two filesystem operations landing in the same timestamp tick is + platform-dependent, not flaky* — it passes on one filesystem and fails 5/5 on another, and the + two have different fixes. + +The third finding — that a `satisfies` in a `__tests__` file enforces nothing when the package +excludes that glob from `tsc` — is **not** a new lesson. #1401 already records it as "a guard is not +a guard until you have watched it fail", including the variant where a type-test file sits somewhere +the build never compiles. What was new is only that the same trap survives one directory *inside* +`src/`, where #1401's "outside src/" heuristic does not catch it, so that entry gained a clause (c) +rather than a duplicate. Its own rule was followed: the union was widened and `tsc` watched to go +red before the guard was trusted. + +No **HOT** tier change: all of this is testing-practice reference material, not always-on +cross-cutting rules of the caliber currently occupying the cap. + +## Things to Look At During PR Review + +- **The `multi-row-draft` escalation decision** — reverses the contributor's choice; one line either + way, and the cost is stated above. +- **`harnessOptions` as a new config namespace** — the alternative (`harness.kimi.…`, as the issue + originally suggested) throws at config load; reasoning in the plan. +- **The bounded retry's 30 s deadline** — long enough for the race, and it delays nothing on the + happy path since the first attempt normally succeeds. +- **Everything Kimi-facing is unverified by us.** The contributor's round is not a formality. 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 0000000000..a56d67cd87 --- /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-box-growth.mjs b/codev/spikes/pir-1201-kimi-box-growth.mjs new file mode 100644 index 0000000000..0fbde5c9d8 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-box-growth.mjs @@ -0,0 +1,239 @@ +/** + * Kimi composer box-growth measurement (PIR #1201, architect review finding 1). + * + * QUESTION THIS ANSWERS: does kimi's composer box ever grow past ONE interior row + * for a reason other than a multi-line draft? + * + * Why it matters. `classifyBuffer` counts *cells* inside the composer region and + * calls a zero-cell region CLEAN. Kimi's per-row marker exemption makes that + * unsound for one draft shape: enter Shift+Enter then `>` and the screen is + * + * │ > <- row 1: empty + * │ > <- row 2: matches KIMI_MARKER, so its `>` is span-exempted + * + * Every cell is either whitespace or exempt chrome → userCells 0 → CLEAN → held + * mail is typed on top of unsent user input, the exact merge Spec 1313 exists to + * prevent. + * + * The proposed fix reads GEOMETRY rather than cells: for a profile that declares + * `regionStartPatterns` (kimi alone), a region spanning more than one interior row + * is a multi-line draft by construction → busy regardless of what the cells say. + * That is only sound if box growth is *exclusive* to multi-line drafts — a + * non-draft state that grows the box would hold delivery forever (a liveness bug, + * not a fail-safe one). Hence: measure before implementing. + * + * Geometry reported per state matches the classifier's own bounds exactly: + * startRow = (last box-top row) + 1 ... findRegionStart + * endRow = first box-bottom row after the LAST marker row ... findRegionEnd + * interior = endRow - startRow ... the rows classifyBuffer scans + * + * States captured (the architect's required list, plus the two that decide liveness): + * idle settled composer, nothing typed expect interior 1 + * draft short single-line draft expect interior 1 + * wrap one long line, no spaces, soft-wrapped informational: if this + * grows the box it carries text and is busy either way + * menu the "/" command list expect interior 1 + * picker the "@" file picker expect interior 1 + * newline-bare Shift+Enter then ">" (THE false-CLEAN) expect interior 2 + * newline-only Shift+Enter, nothing else expect interior 2 + * after-response idle again after a real reply expect interior 1 — this + * is the builder's steady state; growth here would hold forever + * + * Usage: node codev/spikes/pir-1201-kimi-box-growth.mjs [outDir] + */ + +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); +const xterm = require(join(repoRoot, 'packages/codev/node_modules/@xterm/headless')); +const { Terminal } = xterm; + +// The same 110x32 the suite classifies at, so geometry here IS geometry there. +const COLS = 110; +const ROWS = 32; +const outDir = process.argv[2] || join(repoRoot, 'codev/spikes/kimi-gate-capture'); +mkdirSync(outDir, { recursive: true }); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Mirrors of the production profile's patterns (gate-profiles.ts). Kept literal +// rather than imported so this spike stays a standalone observation of kimi, not +// a test of our own code. +const MARKER = /^\s*│\s*>/; +const BOX_TOP = /^\s*╭[─━╌┄]{3,}/; +const BOX_BOTTOM = /^\s*╰[─━╌┄]{3,}/; + +async function render(raw) { + const term = new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true, scrollback: 2000 }); + await new Promise((resolve) => term.write(raw, resolve)); + const buf = term.buffer.active; + const top = buf.viewportY; + const lines = []; + for (let i = 0; i < ROWS; i++) { + const line = buf.getLine(top + i); + lines.push(line ? line.translateToString(true).trimEnd() : ''); + } + return { term, lines }; +} + +/** Reproduce the classifier's region bounds and report the interior row count. */ +function geometry(lines) { + let markerRow = -1; + for (let i = 0; i < lines.length; i++) if (MARKER.test(lines[i])) markerRow = i; // LAST match + if (markerRow === -1) return { markerRow, verdict: 'no-composer-marker' }; + + let endRow = -1; + for (let i = markerRow + 1; i < lines.length; i++) { + if (BOX_BOTTOM.test(lines[i])) { endRow = i; break; } + } + if (endRow === -1) return { markerRow, verdict: 'no-region-end' }; + + let startRow = -1; + for (let i = markerRow - 1; i >= 0; i--) { + if (BOX_TOP.test(lines[i])) { startRow = i + 1; break; } + } + if (startRow === -1) return { markerRow, endRow, verdict: 'no-region-start' }; + + return { markerRow, startRow, endRow, interior: endRow - startRow, verdict: 'scanned' }; +} + +const results = []; + +async function report(name, raw, expectation) { + writeFileSync(join(outDir, `growth-${name}.raw.txt`), raw); + const { term, lines } = await render(raw); + const g = geometry(lines); + results.push({ name, ...g, expectation }); + console.log(`\n${'='.repeat(78)}\n== ${name} (${raw.length} bytes) — expected: ${expectation}\n${'='.repeat(78)}`); + console.log(`geometry: ${JSON.stringify(g)}`); + console.log('--- composer rows (the region and its bounds) ---'); + const from = g.startRow !== undefined ? g.startRow - 1 : Math.floor(ROWS / 2); + const to = g.endRow !== undefined ? g.endRow + 1 : ROWS - 1; + for (let i = Math.max(0, from); i <= Math.min(ROWS - 1, to); i++) { + const tag = g.startRow !== undefined && i >= g.startRow && i < g.endRow ? ' <== SCANNED' : ''; + console.log(`${String(i).padStart(2)}: ${JSON.stringify(lines[i])}${tag}`); + } + term.dispose(); +} + +/** + * Pre-write kimi's workspace-trust record (0.33.0+) so the TUI opens on a composer + * rather than the interactive trust dialog. Undocumented surface, derived by + * observation on 0.34.0 — see the harness spike for the full note. + */ +function preTrust(root) { + const dir = join(process.env.HOME, '.kimi-code', 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const slug = root.split('/').filter(Boolean).pop().toLowerCase(); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${slug}_${hash}`), JSON.stringify({ root, trustedAt: Date.now() })); +} + +/** Backspace the composer clean so the next capture starts from a settled idle screen. */ +async function clear(term, n) { + term.write('\x7f'.repeat(n)); + await sleep(2500); +} + +async function capture() { + const cwd = mkdtempSync(join(tmpdir(), 'kimi-growth-')); + preTrust(cwd); + const term = pty.spawn('kimi', ['--yolo'], { + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let raw = ''; + term.onData((d) => { raw += d; }); + + console.error('[growth] waiting 20s for the kimi TUI to settle…'); + await sleep(20000); + const idle = raw; + + console.error('[growth] short single-line draft…'); + term.write('draft text'); + await sleep(4000); + const draft = raw; + await clear(term, 40); + + // No spaces: forces a hard soft-wrap rather than a word-boundary break, which is + // the shape most likely to add an interior row without a newline in the draft. + console.error('[growth] long single line (soft wrap)…'); + term.write('x'.repeat(180)); + await sleep(4000); + const wrap = raw; + await clear(term, 260); + + console.error('[growth] "/" command menu…'); + term.write('/'); + await sleep(4000); + const menu = raw; + await clear(term, 10); + + console.error('[growth] "@" file picker…'); + term.write('@'); + await sleep(4000); + const picker = raw; + await clear(term, 10); + + // THE false-CLEAN shape: newline first (so row 1 is empty), then a bare ">" on + // row 2 — which matches the marker pattern and so is span-exempted as chrome. + console.error('[growth] newline then bare ">" (the false-CLEAN shape)…'); + term.write('\n>'); + await sleep(4000); + const newlineBare = raw; + await clear(term, 20); + + console.error('[growth] newline only…'); + term.write('\n'); + await sleep(4000); + const newlineOnly = raw; + await clear(term, 20); + + // The builder's STEADY state: a composer that has already carried a turn. If the + // box stays grown here, the geometry rule would hold every later message forever. + console.error('[growth] submitting a real prompt, then measuring idle-after-response (45s)…'); + term.write('Reply with exactly OK and nothing else.'); + await sleep(1200); + term.write('\r'); + await sleep(45000); + const afterResponse = raw; + + term.kill(); + return { idle, draft, wrap, menu, picker, newlineBare, newlineOnly, afterResponse }; +} + +const s = await capture(); +await report('idle', s.idle, 'interior 1'); +await report('draft', s.draft, 'interior 1'); +await report('wrap', s.wrap, 'informational (text-bearing either way)'); +await report('menu', s.menu, 'interior 1'); +await report('picker', s.picker, 'interior 1'); +await report('newline-bare', s.newlineBare, 'interior 2 (the false-CLEAN shape)'); +await report('newline-only', s.newlineOnly, 'interior 2'); +await report('after-response', s.afterResponse, 'interior 1 (steady state — growth here = permanent hold)'); + +console.log(`\n${'='.repeat(78)}\n== VERDICT TABLE\n${'='.repeat(78)}`); +for (const r of results) { + console.log( + `${r.name.padEnd(16)} interior=${String(r.interior ?? '-').padEnd(3)} ` + + `verdict=${(r.verdict ?? '-').padEnd(18)} expected: ${r.expectation}` + ); +} +const drafts = new Set(['newline-bare', 'newline-only']); +const grewWithoutDraft = results.filter( + (r) => r.interior !== undefined && r.interior > 1 && !drafts.has(r.name) && r.name !== 'wrap' +); +console.log( + grewWithoutDraft.length === 0 + ? '\nPREMISE HOLDS: only multi-line drafts grew the box. The geometry rule is safe.' + : `\nPREMISE CONTRADICTED by: ${grewWithoutDraft.map((r) => r.name).join(', ')} — do NOT implement; document the residual.` +); +console.error(`\n[growth] raw captures written to ${outDir}`); +process.exit(0); diff --git a/codev/spikes/pir-1201-kimi-builder-demo.mjs b/codev/spikes/pir-1201-kimi-builder-demo.mjs new file mode 100644 index 0000000000..9131136239 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-builder-demo.mjs @@ -0,0 +1,334 @@ +#!/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. +// +// The opt-in is passed EXPLICITLY (Issue #1620). It is off by default now, and the driver has +// to stand in for an operator who has turned it on in .codev/config.json — without it kimi would +// open on the dialog, no composer would ever render, and step 1 would hang rather than fail with +// a useful message. Steps 6b/6c below exercise the refusal paths on their own throwaway dirs. +KIMI_HARNESS.prepareWorkspace?.(worktree, { autoTrustWorkspace: true }); + +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)`, + ); + + // --- Step 6: workspace trust — idempotence and both security refusals --------- + // + // Issue #1620 turned the unconditional pre-write into a gated one, and changed the return + // from a boolean to a KimiTrustDecision so the CALLER can log which of four things happened. + // All three checks below assert the REASON, not merely that no record appeared: an operator + // debugging a builder stalled on the trust dialog has a completely different next move for + // "you did not opt in" than for "this worktree ships .mcp.json", and a check that only looked + // for absence would pass even if the two were swapped. + const optedIn = { autoTrustWorkspace: true }; + + // The worktree was trusted during spawn (opted in, no MCP config), so this is the no-op path. + const second = ensureKimiWorkspaceTrust(worktree, optedIn); + record( + '6. workspace-trust pre-record is idempotent', + second.wrote === false && second.reason === 'already-trusted', + `second ensureKimiWorkspaceTrust() → ${JSON.stringify(second)} (want already-trusted)`, + ); + + // 6b: consent is required. A fresh directory, opted OUT, must get nothing. + const untrusted = mkdtempSync(join(tmpdir(), 'kimi-demo-optout-')); + const optedOut = ensureKimiWorkspaceTrust(untrusted); + record( + '6b. no trust record without an explicit opt-in', + optedOut.wrote === false && optedOut.reason === 'not-opted-in', + `ensureKimiWorkspaceTrust() with no opt-in → ${JSON.stringify(optedOut)} (want not-opted-in)`, + ); + rmSync(untrusted, { recursive: true, force: true }); + + // 6c: the security refusal proper. Opted IN, but the worktree ships project-level MCP config — + // which is exactly what folder trust gates — so a human must answer the dialog. + const withMcp = mkdtempSync(join(tmpdir(), 'kimi-demo-mcp-')); + writeFileSync(join(withMcp, '.mcp.json'), '{"mcpServers":{}}'); + const refused = ensureKimiWorkspaceTrust(withMcp, optedIn); + record( + '6c. opted-in trust is REFUSED for a worktree carrying project MCP config', + refused.wrote === false && refused.reason === 'project-mcp-config', + `ensureKimiWorkspaceTrust() on a worktree with .mcp.json → ${JSON.stringify(refused)} (want project-mcp-config)`, + ); + rmSync(withMcp, { recursive: true, force: true }); +} 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-newest-probe.mjs b/codev/spikes/pir-1201-kimi-continue-newest-probe.mjs new file mode 100644 index 0000000000..d5f9aec4f8 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-continue-newest-probe.mjs @@ -0,0 +1,129 @@ +/** + * PIR #1201 — does `kimi -c` continue the NEWEST session when a cwd has several? + * + * The whole crash-resume design rests on this. `kimi -c` is cwd-scoped, not + * id-pinned, so "resume the conversation" is only well-defined if `-c` picks the + * most recently updated session deterministically and without prompting. Finding 4 + * (architect review, 2026-08-09) additionally proposes comparing session IDENTITY + * across a clean exit to keep a superseded conversation from being resurrected — + * that comparison is meaningless unless `-c` targets the newest. + * + * The sibling probe (`pir-1201-kimi-continue-probe.mjs`) answered the *zero*-session + * case (what `-c` does with nothing to continue). This answers the *many* case. + * + * Two independent oracles, because the model's answer alone is not proof: + * CONTENT — each session is seeded with a distinct codeword; ask `-c` which one + * it was told, and see which session's memory answered. + * IDENTITY — snapshot every session's updatedAt before and after, and see which + * session directory the `-c` turn actually landed in. This is the + * authoritative one: it reads the store rather than trusting the model. + * + * Usage: node codev/spikes/pir-1201-kimi-continue-newest-probe.mjs + */ + +import { mkdtempSync, writeFileSync, mkdirSync, readdirSync, existsSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir, homedir } from 'node:os'; +import { join, basename, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); +const KIMI_HOME = process.env.KIMI_CODE_HOME || join(homedir(), '.kimi-code'); + +function preTrust(root) { + const dir = join(KIMI_HOME, 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${basename(root).toLowerCase()}_${hash}`), + JSON.stringify({ root, trustedAt: Date.now() })); +} + +/** Every session recorded for `cwd`, with the fields discovery ranks on. */ +function sessionsFor(cwd) { + const root = join(KIMI_HOME, 'sessions'); + const found = []; + if (!existsSync(root)) return found; + for (const wd of readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory())) { + let entries = []; + try { + entries = readdirSync(join(root, wd.name), { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith('session_')); + } catch { continue; } + for (const s of entries) { + try { + const st = JSON.parse(readFileSync(join(root, wd.name, s.name, 'state.json'), 'utf-8')); + if ((st.cwd ?? st.workDir) === cwd) { + found.push({ id: s.name, updatedAt: st.updatedAt ?? null, archived: st.archived === true }); + } + } catch { /* unreadable */ } + } + } + return found.sort((a, b) => (b.updatedAt ?? -1) - (a.updatedAt ?? -1)); +} + +function run(args, cwd, ms = 60000) { + return new Promise((resolve) => { + const term = pty.spawn('kimi', args, { + name: 'xterm-256color', cols: 110, rows: 32, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + let out = ''; + let done = false; + const finish = (code) => { if (!done) { done = true; resolve({ out, code }); } }; + term.onData((d) => { out += d; }); + term.onExit(({ exitCode }) => finish(exitCode)); + setTimeout(() => { try { term.kill(); } catch { /* gone */ } finish(-1); }, ms); + }); +} + +const cwd = mkdtempSync(join(tmpdir(), 'kimi-newest-')); +preTrust(cwd); +console.log(`cwd: ${cwd}\n`); + +console.error('[probe] seeding session A (codeword ALPHA)…'); +await run(['-p', 'Remember this codeword: ALPHA. Reply with only: OK'], cwd); +const afterA = sessionsFor(cwd); +console.log(`after A: ${JSON.stringify(afterA)}`); + +// A visible gap so updatedAt ordering is unambiguous rather than a same-millisecond tie. +await new Promise((r) => setTimeout(r, 3000)); + +console.error('[probe] seeding session B (codeword BRAVO)…'); +await run(['-p', 'Remember this codeword: BRAVO. Reply with only: OK'], cwd); +const afterB = sessionsFor(cwd); +console.log(`after B: ${JSON.stringify(afterB)}`); + +if (afterB.length < 2) { + console.log('\nINCONCLUSIVE: the cwd does not hold two sessions; cannot test the many case.'); + process.exit(2); +} +const newest = afterB[0].id; +const older = afterB[afterB.length - 1].id; +console.log(`\nnewest by updatedAt = ${newest}\noldest = ${older}`); + +console.error('[probe] running `kimi -c` and asking which codeword it holds…'); +const before = new Map(afterB.map((s) => [s.id, s.updatedAt])); +const cont = await run(['-c', '-p', 'Which codeword were you told to remember? Reply with only that word.'], cwd); +const afterC = sessionsFor(cwd); + +const answer = cont.out.includes('BRAVO') ? 'BRAVO' : cont.out.includes('ALPHA') ? 'ALPHA' : '(neither)'; +const touched = afterC.filter((s) => (s.updatedAt ?? -1) > (before.get(s.id) ?? -1)).map((s) => s.id); +const created = afterC.filter((s) => !before.has(s.id)).map((s) => s.id); + +console.log(`\nexit code : ${cont.code}`); +console.log(`CONTENT oracle : ${answer} (BRAVO = newest, ALPHA = oldest)`); +console.log(`IDENTITY oracle : touched=${JSON.stringify(touched)} created=${JSON.stringify(created)}`); +console.log(`after -c : ${JSON.stringify(afterC)}`); + +const continuedNewest = touched.includes(newest) && created.length === 0; +console.log( + '\n' + (continuedNewest && answer === 'BRAVO' + ? 'PREMISE HOLDS: `kimi -c` continued the NEWEST session, no prompt, no new session minted.' + : created.length > 0 + ? `PREMISE BROKEN: \`kimi -c\` MINTED a new session (${created.join(',')}) instead of continuing one.` + : `PREMISE BROKEN or AMBIGUOUS: content=${answer}, touched=${JSON.stringify(touched)}, expected newest=${newest}.`) +); +process.exit(0); diff --git a/codev/spikes/pir-1201-kimi-continue-probe.mjs b/codev/spikes/pir-1201-kimi-continue-probe.mjs new file mode 100644 index 0000000000..98cbdf4f8d --- /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 0000000000..0c820ba796 --- /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/pir-1201-kimi-working-states.mjs b/codev/spikes/pir-1201-kimi-working-states.mjs new file mode 100644 index 0000000000..8fe5df12c6 --- /dev/null +++ b/codev/spikes/pir-1201-kimi-working-states.mjs @@ -0,0 +1,139 @@ +/** + * Kimi composer geometry while the agent is WORKING (PIR #1201, CMAP 2026-08-09). + * + * The box-growth measurement (`pir-1201-kimi-box-growth.mjs`) covered idle, drafts, + * menus, pickers and the post-reply steady state, and the multi-row-draft rule rests + * on it. The 3-way review flagged one class it did not enumerate: the composer WHILE + * the agent is generating (spinner / "esc to interrupt" / queued-message indicator), + * and the mode chrome (shift+tab mode cycle, `!` bash mode). + * + * If any of those grow the box past one interior row while carrying no countable + * cells, mail to a WORKING kimi builder would be held until it goes idle — bounded and + * self-healing, but a behavior change nothing documents. This probe answers it with + * bytes instead of reasoning. + * + * Usage: node codev/spikes/pir-1201-kimi-working-states.mjs + */ + +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pty = require(join(repoRoot, 'packages/codev/node_modules/node-pty')); +const { Terminal } = require(join(repoRoot, 'packages/codev/node_modules/@xterm/headless')); + +const COLS = 110, ROWS = 32; +const outDir = join(repoRoot, 'codev/spikes/kimi-gate-capture'); +mkdirSync(outDir, { recursive: true }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const MARKER = /^\s*│\s*>/; +const BOX_TOP = /^\s*╭[─━╌┄]{3,}/; +const BOX_BOTTOM = /^\s*╰[─━╌┄]{3,}/; + +async function geometry(raw) { + const term = new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true, scrollback: 2000 }); + await new Promise((r) => term.write(raw, r)); + const buf = term.buffer.active; + const top = buf.viewportY; + const lines = []; + for (let i = 0; i < ROWS; i++) { + const l = buf.getLine(top + i); + lines.push(l ? l.translateToString(true).trimEnd() : ''); + } + let markerRow = -1; + for (let i = 0; i < ROWS; i++) if (MARKER.test(lines[i])) markerRow = i; + if (markerRow === -1) { term.dispose(); return { verdict: 'no-composer-marker', lines }; } + let endRow = -1; + for (let i = markerRow + 1; i < ROWS; i++) if (BOX_BOTTOM.test(lines[i])) { endRow = i; break; } + if (endRow === -1) { term.dispose(); return { verdict: 'no-region-end', lines }; } + let startRow = -1; + for (let i = markerRow - 1; i >= 0; i--) if (BOX_TOP.test(lines[i])) { startRow = i + 1; break; } + term.dispose(); + if (startRow === -1) return { verdict: 'no-region-start', lines }; + return { verdict: 'scanned', interior: endRow - startRow, startRow, endRow, lines }; +} + +function preTrust(root) { + const dir = join(process.env.HOME, '.kimi-code', 'workspace-trust'); + mkdirSync(dir, { recursive: true }); + const slug = root.split('/').filter(Boolean).pop().toLowerCase(); + const hash = createHash('sha256').update(root).digest('hex').slice(0, 12); + writeFileSync(join(dir, `wd_${slug}_${hash}`), JSON.stringify({ root, trustedAt: Date.now() })); +} + +const results = []; +async function record(name, raw, note) { + writeFileSync(join(outDir, `working-${name}.raw.txt`), raw); + const g = await geometry(raw); + results.push({ name, ...g, note }); + console.log(`\n${'='.repeat(74)}\n== ${name} — ${note}\n${'='.repeat(74)}`); + console.log(`verdict=${g.verdict} interior=${g.interior ?? '-'}`); + const from = g.startRow !== undefined ? g.startRow - 1 : ROWS - 8; + const to = g.endRow !== undefined ? g.endRow + 1 : ROWS - 1; + for (let i = Math.max(0, from); i <= Math.min(ROWS - 1, to); i++) { + if (g.lines[i]) console.log(`${String(i).padStart(2)}: ${JSON.stringify(g.lines[i])}`); + } +} + +const cwd = mkdtempSync(join(tmpdir(), 'kimi-working-')); +preTrust(cwd); +const term = pty.spawn('kimi', ['--yolo'], { + name: 'xterm-256color', cols: COLS, rows: ROWS, cwd, + env: { ...process.env, TERM: 'xterm-256color' }, +}); +let raw = ''; +term.onData((d) => { raw += d; }); + +console.error('[working] settling (20s)…'); +await sleep(20000); + +// Mode chrome first, while nothing is running. +console.error('[working] shift+tab mode cycle…'); +term.write('\x1b[Z'); +await sleep(3500); +await record('mode-cycle', raw, 'after shift+tab (mode chrome)'); + +console.error('[working] "!" bash mode…'); +term.write('!'); +await sleep(3500); +await record('bash-mode', raw, 'after "!" (bash mode)'); +term.write('\x7f'.repeat(5)); +await sleep(2500); + +// A prompt long enough to observe MID-generation rather than only the settled end. +console.error('[working] submitting a long-running prompt…'); +term.write('Count from 1 to 40, one number per line, each with a brief comment.'); +await sleep(1200); +term.write('\r'); + +await sleep(5000); +await record('generating-early', raw, 'MID-generation, ~5s after submit'); +await sleep(8000); +await record('generating-mid', raw, 'MID-generation, ~13s after submit'); + +// A message typed WHILE the agent works — kimi queues it; does the box grow? +console.error('[working] typing while the agent is still working…'); +term.write('queued while working'); +await sleep(4000); +await record('queued-while-working', raw, 'draft typed during generation'); + +term.kill(); + +console.log(`\n${'='.repeat(74)}\n== VERDICT TABLE\n${'='.repeat(74)}`); +for (const r of results) { + console.log(`${r.name.padEnd(24)} verdict=${String(r.verdict).padEnd(18)} interior=${r.interior ?? '-'} (${r.note})`); +} +// A grown box with no countable cells is the only shape that would newly hold mail. +const risky = results.filter((r) => r.verdict === 'scanned' && r.interior > 1 && r.name !== 'queued-while-working'); +console.log( + risky.length === 0 + ? '\nNO NEW HOLD: no working/mode state grew the box. The rule changes nothing for a working builder.' + : `\nBEHAVIOR CHANGE: ${risky.map((r) => r.name).join(', ')} grow the box — mail to a working builder would be held until idle.` +); +process.exit(0); diff --git a/codev/spikes/task-Iptx-kimi-code-cli-support.md b/codev/spikes/task-Iptx-kimi-code-cli-support.md new file mode 100644 index 0000000000..794b468054 --- /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 0000000000..ca1e2090eb --- /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 0000000000..ee7af9e1ca --- /dev/null +++ b/codev/state/pir-1201_thread.md @@ -0,0 +1,311 @@ +# 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. + +--- + +## 2026-08-09 — architect integration review, three findings + +The architect reviewed the PR at head `4a7e2afe` and returned three non-blocking findings. None +of them touch the three decisions parked for the upstream maintainer (trust pre-write, 0.33.0 +floor, write-guard parity as follow-up) — a later architect message fenced those explicitly, and +this round left all three exactly as the branch already implements them. + +**Finding 1 was measure-first, and the measurement is the interesting part.** The claim: a +residual false-CLEAN survives the `regionStartPatterns` fix. Enter a newline and then `>` and +kimi renders `│ > ` / `│ >` — row one empty, row two matching `KIMI_MARKER` so its `>` is +span-exempted as chrome. Every cell is whitespace, box chrome, or an exempted marker, so +`userCells` is 0 and the composer reads CLEAN *while holding unsent user input*. Bounding the +region correctly does not help: the draft is real but literally uncountable. That is the +corruption direction, so it mattered. + +The proposed fix reads the composer's **shape** instead — a boxed region spanning more than one +interior row is a multi-line draft by construction. Sound only if box growth is exclusive to +multi-line drafts, which is a claim about kimi, not about our code. So I measured it before +writing it (`codev/spikes/pir-1201-kimi-box-growth.mjs`, real kimi 0.34.0): + +| state | interior rows | +|---|---| +| idle | 1 | +| single-line draft | 1 | +| `/` menu | 1 | +| `@` picker | 1 | +| **post-reply steady state** | **1** | +| newline + bare `>` | 2 | +| newline only | 2 | +| long soft-wrapped single line | 2 | + +**Premise holds.** The steady-state row is the load-bearing one: growth on a composer that has +already carried a turn would hold every later message forever — a liveness bug, which is worse +than the fail-safe direction. The soft-wrap case grows the box too, but it carries text and was +already busy, so its verdict is unchanged. + +**One design correction I made against the suggestion.** Implemented as suggested — short-circuit +before the cell scan — the rule changed an *existing* fixture's verdict detail +(`kimi-multiline-bare` went from `user-text` to `multi-row-draft`), because that draft is also +multi-row. That would have masked the cell scan's ground-truth role and quietly retired what the +older guardrail test was actually testing. Moved the rule to **after** the scan: `userCells > 0` +still wins and still reports `user-text`, and `multi-row-draft` is reserved for exactly the case +the count is blind to. Every pre-existing fixture verdict is unchanged. + +The arming gate is a shared `hasRegionStart` predicate used by **both** `findRegionStart` and the +rule, so an empty-pattern array cannot be read as "bounded" by one and "unbounded" by the other — +that divergence would fire the rule on claude/codex, whose composer legitimately sits several rows +above its rule line. Pinned with an armed/unarmed differential on identical bytes, so deleting the +rule outright fails the inertness test rather than silently passing it. + +**Findings 2 and 3 were wording/comment only.** The fast-fail echo claimed to restart "with the +original task", but that branch leaves `codev_task_queued` set, so nothing is re-queued — correct +behavior (an undelivered row persists on the mailbox), wrong message. Reworded, and the operator +is now told which of the two cases they are in. Finding 3 records the accepted tradeoff in the +other direction: the clean-exit branch *does* reset the flag, so if the first row was never +delivered (quit at the trust dialog before a composer ever rendered) the mailbox ends up holding +the same mission twice. Documented rather than fixed, deliberately. + +**Verification:** `pnpm build` clean; full suite **4904 passed / 48 skipped / 0 failed** (+4 = +three new tests and one new fixture); targeted suites (render-gate, harness, harness-integration, +spawn-worktree, mailbox-pacing, kimi-session-discovery) 327 passed. + +### The CMAP on that delta found something better than what I built + +gemini APPROVE, codex REQUEST_CHANGES, claude APPROVE-with-changes. Every finding from both +non-approving reviews was accepted; nothing was rejected. Full dispositions: +`codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-architect-review-dispositions.md`. + +**The one that mattered.** Both codex and claude independently attacked the same thing: I armed +the geometry rule off `regionStartPatterns`, overloading a field that means "this composer has an +upper boundary" with an unrelated claim, "this composer's height tracks draft lines". They +coincide for kimi. claude then produced evidence that this is not stylistic, and I verified it +myself with a geometry probe over every shipped fixture rather than taking it on trust: + +**`codex-idle.clean.txt` — a real, captured, genuinely EMPTY codex composer — already spans two +interior rows** (`marker=18 start=18 end=20`). The rule's geometric predicate is *already true* +on a screen that must stay clean. Only the arming gate stood between that capture and codex mail +being held forever, and the day anyone declared a region start for codex — a header bound, a +boxed redesign — delivery would have died silently. That is the failure mode I was trying to +prevent for kimi, sitting one field declaration away for a different app. + +Decoupled into an explicit `growsWithDraft?: true`, set only on `KIMI_PROFILE`. The rule now +requires both: the measured promise *and* the bound that makes the arithmetic mean "interior +rows". codex wanted `maxCleanInteriorRows?: number` instead; I chose the boolean because it +encodes the measured premise rather than a tunable number, and a wrong threshold under it gets +caught by the app's own idle fixture. The three inertness tests now run on codex's **real** +capture under four profile variants, so the hazard is demonstrated on identical bytes rather than +described on a screen I invented. + +**Claude also found a gap in my measurement, so I measured it.** The spike had not enumerated the +composer *while the agent is generating* — if the box grew there, "deliver while busy" would have +silently become "hold until idle". It does not: mid-generation at 5s and 13s, shift+tab mode +chrome, and a draft typed during generation are all one interior row +(`pir-1201-kimi-working-states.mjs`). Claude asked for a line documenting what wasn't measured; a +measurement is a better answer than a caveat. + +**Two accuracy bugs in my own prose, both real.** The reworded fast-fail hint asserted +unconditionally that a task was still queued — false when `afx send` never succeeded, since the +flag is only set on success, and in that case the fresh launch genuinely does retry. Now branches +on the flag. And "delivered whenever the operator saw a composer" was too strong: seeing a +composer is necessary, not sufficient, since the gate also has to have polled it empty. A +message-accuracy fix on top of a message-accuracy fix, which is a fair thing to have been caught on. + +**One silent-omission class fixed:** `isClassifierStuck` enumerated details as a closed `||` +chain, so widening the union never forced a decision. Now a `Record` — the next new detail is a compile error rather than a silent `false`. + +**Verification:** build + `tsc --noEmit` clean; full suite **4906 passed / 48 skipped / 0 failed** +(+6 on the 4900 this round started from). No live demo re-run needed: the rule can only change +verdicts for a composer past one interior row, and delivery targets the idle composer — measured +at one row in every state, including mid-generation. + +--- + +## 2026-08-09 — finding 4: a clean exit that did not stick + +The architect re-verified the maintainer's exit contract against main's landed code and found a +real gap. #1267's contract is "clean exit → fresh rerun, no recovery", and claude's loop enforces +it **by identity**: a clean exit mints a new session id and the superseded one is never named +again. kimi cannot mint on demand and `kimi -c` is cwd-scoped, so identity was never pinned: + +1. human cleanly exits conversation A +2. Enter gate → fresh relaunch; kimi 0.33+ mints **no session** until the first message lands +3. kimi crashes in that pre-mint window +4. the old guard asked only "does *any* session exist for this cwd?", found A, and ran `kimi -c` + → continuing the conversation the human deliberately ended, with the re-queued task delivered + into it + +**Measured before building.** The whole design assumes `-c` continues the NEWEST session when a +cwd holds several — the existing probe only covered the zero-session case. Two live sessions in +one directory on 0.34.0, two independent oracles: content (codewords ALPHA/BRAVO → answered +BRAVO) and store identity (only the newest session's dir was touched; nothing new minted; exit 0, +no prompt). Premise holds, so the documented-residual fallback did not apply. + +The fix makes the probe answer **which** session rather than **whether** one exists; the +clean-exit branch records that id; the crash branch resumes only once the newest id differs. + +### CMAP: gemini APPROVE, codex REQUEST_CHANGES, claude REQUEST_CHANGES — and they were right + +**The blocking one is a defect I introduced, not one I inherited.** Moving the decision from +`$?` onto stdout meant anything *else* writing to stdout counted as "a session exists". Claude +measured it: empty store plus `NODE_OPTIONS=--require ` → probe prints a +banner, exits 1, script reads RESUME → `kimi -c` with nothing to continue → a session that never +saw `--agent-file`. A silently roleless builder — the exact #929 class this guard exists to +prevent, reintroduced by the guard's own upgrade. The pre-delta code could not produce it. Fixed +by consuming both signals (`codev_newest=$(...) || return 1`, declaration split from assignment +so `local` cannot mask the status). + +**The architect's sketch had one too, and both reviewers caught it.** It said record the id, +"empty on any error — fail-closed", and I repeated that in a comment. It isn't: a *transient* +probe failure records `''`, and the next crash then sees the ended session as "different from +empty" and resumes it. Now failure and empty-store are distinguished by status, and a failed +baseline blocks resume until the next clean exit re-establishes one. Costs crash-resume +continuity in that rare case; never the role, never the task. + +Two probe/discovery divergences also fell out, both pre-existing and both found by reading the +two implementations against each other rather than by testing: `j.cwd ?? j.workDir` short-circuits +on a non-string `cwd` where `readStateJson` falls through per-field; and the probe stripped a +trailing slash before `realpathSync` while `sameDir` does not — the unsafe direction, since a +nonexistent `/ghost/` would match in the probe and not in discovery. Removed the strip rather +than documenting it: `realpathSync` already normalizes a trailing slash for any directory that +exists, so it bought nothing and cost fidelity. Exact mirror beats documented exception. + +**Claude's sharpest test point:** the pieces were pinned, the composition never was. `decideBranch` +injected the superseded id from the test, so the only evidence the generated clean-exit branch +assigns it was a string match — and a refactor wrapping that assignment in a subshell would pass +everything while the contract was dead. There is now a test that drives the **real `while` loop** +with stubbed launches and asserts the branch sequence `resume, fresh, fresh`. + +Non-vacuity is demonstrated, not claimed: `decideBranchLegacy()` runs the pre-fix existence-only +predicate against the same store and the same generated probe, and the regression test asserts it +returns RESUME exactly where the shipped guard returns FRESH. + +**Residuals, written down rather than engineered against:** a store GC that dropped the newest +session while keeping an older abandoned one would let `-c` reach the older one (requires +newest-first eviction); and the superseded id is in-memory, so closing and re-creating the +terminal returns to plain entry semantics — which claude pointed out is contract *parity* with +claude's loop, whose minted id is equally per-process, not a kimi shortfall. + +**Verification:** build + `tsc --noEmit` clean; generated script passes `bash -n`; full suite +**4915 passed / 48 skipped / 0 failed**. Dispositions in +`codev/projects/1201-support-kimi-code-cli-as-a-bui/1201-cmap-finding4-dispositions.md`. diff --git a/codev/state/pir-1620_thread.md b/codev/state/pir-1620_thread.md new file mode 100644 index 0000000000..889ca06a38 --- /dev/null +++ b/codev/state/pir-1620_thread.md @@ -0,0 +1,355 @@ +# Builder thread — pir-1620 (re-plan PR #1203, Kimi harness) + +## 2026-09-04 — plan phase + +**Branch setup.** Spawned on `builder/pir-1620`, but the deliverable is PR #1203 becoming +mergeable, so per the architect's note I ran `gh pr checkout 1203` in this worktree → now on +`builder/pir-1201` (Mohid's fork, `maintainerCanModify=true`, push remote configured by `gh`). +The porch init commit (`2b0d8db12`, `status.yaml` for 1620) lived only on `builder/pir-1620`, +so I cherry-picked it across; `porch status 1620` works again. **No rebase, no squash** — +Mohid's 47 commits are untouched and the `origin/main` merge happens in the implement phase. + +**Divergence, measured rather than assumed.** merge-base is `4983ea83`; main is 1,606 commits +ahead, the PR 47. 17 paths changed on both sides, but only five need real thinking: + +- `message-write.ts` — `writeMessagePaced` → `submitMessagePaced` (#1365). The PR's pacing + override rode the old function; `writeMessageToSession` survived underneath, so it re-homes. +- `hold-verdict.ts` (sdk) — #1482 made `isUnverifiableVerdict` the single definition of + "will this clear on its own?"; the PR re-forked it locally. Fork gets deleted. +- `render-gate.ts` — #1474 rewrote `findMarkerRow` with cursor-row/palette anchors and hoisted + `top`/`cell`/`cursorRow`. The PR's region-start work is orthogonal and composes. +- `mailbox-wiring.ts` / `mailbox-delivery.ts` — echo verification (#1573) and the + commit-then-report `delivered-unverified` policy (#1584) landed around the PR's seams. +- `tower-routes.ts` — 463+/95− on main; the `--interrupt` block still has the same shape. + +`spawn-worktree.ts` / `harness.ts` / `doctor.ts` diverged by an `afx reset` → `afx refresh` +comment rename only. `kimi-session-discovery.ts` is net-new. So the merge is smaller than +"CONFLICTING in 7 files" suggests. + +**Two issue claims that did not survive checking** (both flagged in the plan, neither silently +ignored): + +1. *"Drop `launchLoopTail` changes already on main."* Main still has it module-local in + `spawn-worktree.ts:803`, byte-identical to the PR's relocated copy (diffed). The PR *moves* + it into `harness.ts` and exports it because the Kimi provider script needs it. Keeping the + move — dropping it breaks the feature. +2. *"the branch was measured at 0.34.0."* Latest npm `@moonshot-ai/kimi-code` is **0.41.0** — + seven minors of drift, i.e. the same failure this lane exists to repair, again. + +**Blocker raised at the gate.** `kimi` is not installed here, `~/.kimi-code` does not exist, +and there is no Moonshot credential in the environment. Installing is one npm command; +**authenticating is not something I can do.** Issue items 3 (re-measure under verified +delivery) and 5 (7-scenario live demo) are blocked on that. Everything else — merge, +re-derivation, the trust security change, the plan rewrite, all unit tests — is unblocked and +will be delivered in full. + +**Two design calls I want the human to see rather than discover.** + +- **`multi-row-draft` escalates.** The PR classified it as "a human at the line"; the issue + says a stuck Kimi hold must escalate. I went with the issue, and I think it is right on its + own terms: every other detail is a cell count, and `multi-row-draft` is the verdict reached + when the classifier *could not* count and inferred from box geometry. Cost: a human sitting + on a real multi-line draft contributes to a liveness streak. One-line reversible. +- **`harnessOptions.kimi.autoTrustWorkspace`, not `harness.kimi.…`.** `harness.*` is the + custom-harness namespace and `lib/config.ts:337` validates every entry with a validator that + hard-requires `roleArgs`/`roleScriptFragment` — a settings-shaped entry there throws at config + load and breaks `afx status` and everything else. Built-ins also win resolution, so + `harness.kimi` is already dead config. The issue said "e.g.", so this is a gate decision. + +**Also spotted, fixing as part of the merge:** main's `markerFgPalette` anchor reads +`line.getCell(0, cell)` — a hardcoded column 0. Correct for agy (`^>`), wrong for the first +profile whose marker is not at column 0, which is exactly what Kimi is (`│ >`, column 3). +Latent today; a trap laid under the next person. Generalizing it to the marker match's start +column, with a test. + +**Artifacts written this phase:** `codev/plans/1620-re-plan-pr-1203-kimi-harness-a.md` (this +lane) and a full rewrite of `codev/plans/1201-support-kimi-code-cli-as-a-bui.md` — the approved +1201 plan still described the retired seed/PTY-kick design, so it described nothing the branch +implements. Both go to the `plan-approval` gate together. + +**Open question for the architect:** I do not have the raw 2026-09-04 3-way lane output, only +the issue body's distillation of it. Working from the distillation unless the transcript turns up. + +## 2026-09-04 (later) — raw CMAP lanes received, plan revised + +Architect supplied the three raw lanes (`/tmp/pir-1620-cmap-1203-{gemini,codex,claude}.md`) and +confirmed four of my calls: keep the `launchLoopTail` relocation (his issue line was a misread of +the claude lane, which actually recommends keeping the move); `harnessOptions.kimi.autoTrustWorkspace` +accepted; `multi-row-draft` escalating accepted; the `markerFgPalette` `getCell(0)` generalization +approved as part of the merge. + +Auditing my plan against the raw KEY_ISSUES (rather than the issue body's distillation) found +**four gaps**, now closed: + +1. **A real defect, found chasing claude's §7** — "confirm what a builder self-send attributes to". + The generated script queues the task with `afx send ` from inside the worktree. + `spawn.ts:482` starts the session and only *then* `spawn.ts:488` calls `upsertBuilder`, while + `detectCurrentBuilderId()` **throws** when no builder row exists yet (`send.ts:167`). Lose that + race and the CLI `fatal`s, the script's `if afx send` fails, it warns once and does **not retry + within that launch** — a Kimi builder starts with a role and no mission. Today only node's + startup latency saves it. Fix is a bounded retry in `codev_queue_task`; reordering `upsertBuilder` + is rejected (the row carries `terminalId`, so it would mean two upserts on the path every harness + shares). Separately the sender resolves to the builder's own id, so the task arrives framed as a + peer message from itself — `--raw` instead, verified at the gate. +2. **Write-guard follow-up must be bounded.** codex accepts it "if maintainers explicitly accept + that limitation"; claude says it "should gate documenting kimi as supported, not be open-ended". + So: file the issue before merge, reference it from the docs *where kimi is documented as + supported*, correct the stale "no hook seam" claim, and record the maintainer's acceptance in the + maintainer's own words. +3. **Echo-verification cost** — claude's §6 computes ~2.2 s per Kimi `afx send` (1000 ms Enter + two + 600 ms windows) with possible `delivered-unverified` on every message. Recorded as an accepted + cost rather than discovered post-merge. +4. **A disposition table** naming every KEY_ISSUE from all three lanes and where the plan answers it — + the skeleton of the review doc, since the acceptance bar is "addressed or explicitly dispositioned". + +One conflict inside the claude lane worth recording: its §2 asserts `multi-row-draft → false` in +`isUnverifiableVerdict`, while its §3 argues that leaving it out of the stuck set makes the rule's +own failure mode silent and permanent, and asks for escalation *or* a doctor probe. We take the +escalate branch, which the architect confirmed. Noted in the table so nobody reads §2 as unaddressed. + +Still waiting on the human for: plan approval, and the Kimi-credentials decision (items 3+5). + +## 2026-09-05 — human decision on Kimi: items 5+6 handed to Mohid + +No authenticated Kimi maintainer-side and no credentials to give. Human's call: this lane does not +run the re-measurement or the live demo; both go to @mohidmakhdoomi, who has an authenticated Kimi +and ran the original 7/7. Plan revised accordingly (still pre-approval — no `porch approve`). + +What the revision actually changed, beyond deleting two work items: + +- **Item 5–6 became a handoff with its consequences spelled out.** `KIMI_PROFILE` now ships on + 0.34.0-era measurement rather than a fresh capture; `markerRequiresCursorRow` is *not* adopted + (previously conditional on captures we now can't take — so it becomes a question on Mohid's + checklist instead of a guess); Kimi's echo behaviour stays unmeasured by us. +- **Our dev-approval gate re-scoped, and it is a better gate for it.** With no Kimi to exercise, the + thing worth proving is that a change *for* Kimi moved nothing *else* — `render-gate.ts`, + `message-write.ts` and `hold-verdict.ts` carry claude/codex/agy delivery for every user. Six + non-Kimi steps now, the load-bearing ones being: generated launch scripts for the existing + harnesses must be **byte-identical**, live claude delivery still logs `delivered` (not + `delivered-unverified` — the #1573 echo path is timing-dependent and least likely to be caught by + a green suite), and a claude draft still holds as `busy:user-text` rather than an unverifiable + verdict (proves the `isUnverifiableVerdict` edit didn't widen the escalation class). +- **A seven-step checklist for Mohid** in item 7, to be posted on #1203 when the implementation + commits land: exact commands, the eight fixture filenames, which spike drives each measurement, + the one question we cannot answer (does anything but the composer match `/^\s*│\s*>/`?), and + where evidence goes (`codev/evidence/1620-kimi-measurement/`). Step 2 is flagged as the one that + can block the merge: if a post-reply steady-state composer grows past one interior row, the + `growsWithDraft` rule holds every later message forever and must not ship. +- **A new risk, named rather than buried**: we ship a Kimi feature none of us ran, on measurements + seven minors old — the same staleness that made #1203 un-mergeable, recurring. Mitigation is + procedural and partial and the plan says so. + +The review doc will state plainly that live re-verification was not performed by this lane, and +name the 0.34.0 → 0.41.0 drift. + +## 2026-09-05 — standing rule: no outward posts from this lane + +Architect standing rule: do not post to PR #1203 or any of @mohidmakhdoomi's threads. Every outward +artefact — handoff checklist, PR description, summary comment — is drafted to `/tmp` and the human +approves it before it goes out. + +Verified I had not already done so: `gh pr view 1203 --comments` and `--json reviews` show the last +entries are Mohid's (2026-08-09) and Waleed's (2026-09-04, 2026-09-05). My `gh` use has been +read-only (`pr view`, `pr diff`, `pr checkout`); the only writes were the commits the architect +asked for on the branch. + +Folded the rule into the plan as its own **Outward communication** section rather than leaving it in +this log, because item 7 previously said the checklist would be "posted as a PR comment the moment +the implementation commits are pushed" — a future reader could have taken that as an instruction to +this lane. The section also names the two things the rule does *not* cover, so they are not +ambiguous later: pushing commits to `builder/pir-1201` continues (that is the deliverable, not a +message), and reading the PR continues. + +First outward draft is ready for approval: `/tmp/pir-1620-draft-mohid-checklist.md` — the seven-step +handoff. Written to be received by a contributor whose work sat for 26 days through no fault of his: +opens by owning the delay, states plainly that the live verification is a request rather than an +assignment, flags step 2 as able to block the merge, says a negative echo result is a good outcome +rather than a failure, and surfaces the two places we reversed his judgement (`multi-row-draft` +escalation, the trust refusals) as things to argue with rather than as decisions handed down. + +## 2026-09-08 — main moved again mid-gate (#1567/PR #1644); item 2a re-derived a second time + +Architect status note (also a long-send field test — replied `long-send intact, 3 items`). Verified +both landed changes against `origin/main` rather than taking the summary on trust, and item 1 turned +out to matter more than "re-derive on top of `framePieces()`" suggests. + +**The write edge changed under us for the second time.** `writeMessageToSession`'s 5th parameter is +now `strategy: WriteStrategy` — exactly the slot the PR wanted for `pacing`. Pacing moves to 6th +there and 7th on `submitMessagePaced`. There are still exactly two Enter delays to override, so the +seam's shape survives; what changed is which constants. + +**And one of them is a trap.** The long-frame Enter is now `PASTE_ENTER_DELAY_MS = 80`, measured +"0/29 losses" on claude and codex. Kimi's own bisect was: **80 ms and 100 ms swallowed**, 120 ms+ +submit. So the new default lands precisely on Kimi's measured failure point — every multi-line +message typed and never submitted, the original #1201 symptom reintroduced by a change with no +reason to know Kimi exists. The `enterDelayMs` seam is now load-bearing on a branch it was never +written for. + +**Worse, and the reason I added a step to the handoff:** `writeStrategyForApp` returns +`PLAIN_CHUNKED` for `'agy'` and `BRACKETED_PASTE` for everything else. That default is opt-**out**, +so registering `KIMI_PROFILE` silently opts Kimi into bracketed paste on a CLI nobody has tested it +against. If Kimi does not implement the mode the failure is not slow, it is corrupt: the +`\x1b[200~` markers land as literal composer text, and `framePieces` converts `\n` → `\r` inside the +bracket, so every line submits as its own message — the #584 class, worse than before it was fixed. + +So Kimi joins agy in `PLAIN_CHUNKED` until measured. Not a new policy — it is what that function's +own doc comment already says ("a harness that has not been measured can opt out"); the only change +is admitting Kimi is one of the unmeasured ones. One line, reversible with evidence, and it fails +toward the behaviour Mohid's 7/7 actually validated. + +Checklist for Mohid is now eight steps. Item 2 of the note (`OverviewCache`/#1652) touches no file +this branch does; noted for the post-merge full-suite run. Item 3 (GraphQL exhaustion) — used git +and `gh pr view` sparingly; the reads I needed were local. + +Still at plan-approval. `porch approve` not run. + +## 2026-09-08 (later) — both findings accepted; bisect numbers now in the plans + +Architect accepted both: Kimi → `PLAIN_CHUNKED` until measured, and `enterDelayMs` must govern the +paste path's Enter too. Asked for the bisect numbers to be stated in the plan, which was the right +correction — I had them as prose ("80 and 100 swallowed") and prose is what gets skimmed. + +Both plans now carry the table explicitly: 80 ms and 100 ms **swallowed, never submit**; 120 / 250 / +500 / 1000 ms submit; threshold ≈ 100–120 ms; pinned at 1000 ms for ~9× margin, re-verified on +0.34.0. Stated alongside it that `PASTE_ENTER_DELAY_MS = 80` was measured 0/29 losses on claude +2.1.263 and codex 0.146.0 — sound evidence for those two, and silent about the one CLI whose +paste-detection window is the reason the seam exists. The point of putting the numbers next to each +other is that the collision is then unmissable to whoever touches this next. + +Also noted in the plan: the unit test must assert the override on **both** frame branches. A test +covering only the short frame would pass while the feature was broken for every real message — +which is most of them, since a formatted `afx send` is almost always ≥4 lines. + +The opt-out-default hazard is the architect's follow-up, explicitly **not** folded into this PR. +Recorded as a fenced note in the plan next to 2a-bis so a later reader does not "helpfully" widen +the diff: fixing the default touches every harness's write path, and this lane's job is to stop +being the thing that broke, not to redesign the mechanism. + +Still at plan-approval. `porch approve` not run. + +## 2026-09-08 (later still) — owner reverses the PLAIN_CHUNKED proposal; implementing as decided + +Owner decision: bracketed paste stays the default for every harness, Kimi included. `#1653` closed +on the same reasoning. I raised the concern, the owner weighed it and declined, so that is the +decision and this lane implements it — `writeStrategyForApp` is now **untouched** by this work. + +Removed from both plans. What replaced it is a short record of the decision rather than silence, +because "Kimi is not in the opt-out list" reads identically whether it was considered or missed, and +the next person deserves to know which. The Test Plan now *asserts* `writeStrategyForApp('kimi') === +BRACKETED_PASTE` — pinning the decision so a later edit to that function has to be deliberate about +Kimi, instead of the absence of a test making it look unconsidered. + +One factual note I recorded once and am not relitigating: the owner's rationale is "unhonoured +markers are harmless stray text", which covers one of the two mechanisms. The other is that +`framePieces` converts `\n` → `\r` *inside* the bracket — if the mode is not honoured those are +Enter keypresses, so one message arrives as N submissions, one per line. That is not stray text. +It does not change the decision (step 8 measures it on a live Kimi either way), but it does change +what "measure it" means, so both the plan and Mohid's step 8 now ask for the specific symptom — +"one message or several?" — rather than "does it look right?". A vague question would have come +back with a vague answer and we would have learned nothing. + +Kept, as instructed: the `enterDelayMs` override governing the paste path's Enter, and the bisect +table. + +Still at plan-approval. `porch approve` not run. + +## 2026-09-08 — plan APPROVED; merge done, suite green, PR mergeable + +Human approved both plans (relayed by the architect). Ran `porch approve 1620 plan-approval +--a-human-explicitly-approved-this`; now in the implement phase. + +**Merged `origin/main` into `builder/pir-1201`** — merge, not rebase; Mohid's 47 commits untouched. +1,606 commits since the merge base. Seven conflicts, exactly the five semantic files the plan +predicted plus two test/fixture files. `gh api` now reports **`mergeable: true`** (state `blocked` += review required), so the headline acceptance criterion is met. + +Resolutions of note: + +- **message-write.ts** — the write edge had moved *twice*: #1365 (lock + in-lock precheck) and + #1567 (bracketed paste, which took the 5th parameter slot for `strategy`). `MessagePacing` + re-homed to 6th/7th and now overrides both Enter sites. +- **mailbox-delivery.ts** — deleted the branch's local `CLASSIFIER_STUCK_DETAILS` fork; delegation + restored. Its exhaustiveness value moves to a test rather than a second copy of the rule. +- **render-gate.ts** — #1474's anchors and the branch's region bounding are orthogonal; kept both. + Generalized `markerFgPalette` off `getCell(0)` as approved. + +**Full suite: 5916 passed, 0 failed.** Getting there took three fixes, and the third is worth +recording: + +1. The pacing tests used the old argument order and asserted per-line timings that no longer + exist. Rewrote them and **widened** the coverage — the override is now pinned on the + long/bracketed branch too. The old short-frame-only coverage would have stayed green while the + feature was broken for every real message, since a formatted `afx send` is almost always ≥4 + lines. That is the exact shape of test that lets a regression ship. +2. The Issue #1201 span guardrail's synthetic agy screen stopped qualifying as a marker row once + #1474 added cursor/palette anchors. Rather than delete the agy case I added an `agyScreen` + helper that satisfies the anchors (SGR-94 glyph + explicit CUP to park the cursor on the + composer row, which sits *above* its bounding rule). Applied it to the sibling `>x` test as + well — that one still *passed*, but for the wrong reason: it would have failed the anchors + before ever exercising the pattern it claims to test. +3. **A pre-existing defect on the branch, not something the merge caused** — verified by diffing + both files against the pre-merge commit: unchanged. `inspectKimiStoreLayout`'s "ok when at + least one session carries the load-bearing shape" wrote a good session then a bad one and + expected `ok`, but the probe *deliberately* reports drift when the newest session is the broken + one. It only ever passed where the two `mkdir` mtimes tied — so it was platform-dependent all + along, and fails 5/5 on APFS where `mtimeMs` is sub-millisecond. Fixed with the explicit + `touchDir` ordering the *very next test in the same describe* already uses; its doc now says + why every test in that block must. + +Also caught a merge slip of my own: keeping both sides of the `GateProfile` conflict dropped the +`/**` that opened main's `markerRequiresCursorRow` doc block. The build caught it immediately. + +Next: item 3 (trust refusals + config opt-in), item 2g (spawn-race retry), the exhaustiveness test, +docs, then CMAP. + +## 2026-09-08 — implement phase: merge, security, race, and a CMAP round that caught me + +Plan approved and `porch approve` run. Implementation landed across nine commits on top of the +merge. Full suite **5,965 passed / 0 failed**; PR #1203 reports `mergeable: true`. + +**CMAP (gemini / codex / claude, `--type impl`) — all three found something real.** Two false +starts first: the runner needs `--protocol pir` (the impl template lives under the protocol, not +`codev/consult-types/`) and `--issue`/`--project-id`, since this worktree carries ~100 project dirs +and auto-detect refused to guess. + +- **gemini (REQUEST_CHANGES)**: `codev/reviews/1201-*.md` was stale in exactly the way the 1201 + *plan* was — still describing `seed-kick.ts`, `message-pacing.ts`, `.builder-seed.txt`. The issue's + scope named only the plan; the same argument obviously applies to the review. Rewritten. A review + artifact describing code that is not there is worse than none: it is a confident wrong answer. +- **codex (REQUEST_CHANGES)**: **`markerSpanStart` was inert.** `KIMI_MARKER` is anchored, so + `exec().index` is always 0 — meaning my "generalization" returned precisely what the hardcoded + `getCell(0)` returned, and the trap I had *documented as removed* was still armed. Verified with a + one-liner before fixing. This is the finding I most needed and least expected: I had written a + confident comment about fixing a latent bug and shipped a no-op. + - Fixing it produced a second lesson immediately. My first fix used capture group 1 — and agy's + `/^>(\s|$)/` already *has* a group 1, its separator. Sixteen agy tests went red at once because + the anchor began sampling the space after the marker. Switched to a **named** group + `(?…)`, which cannot collide with an incidental one. My own new test caught it, which is + the first time this session a test I wrote paid for itself within a minute. + - Also: review doc missing `Files Changed` / `Commits` / `How to Test Locally`; follow-up issue + numbers absent. Both addressed (issues drafted, awaiting approval to file). +- **claude (REQUEST_CHANGES)**: three tests the plan's own Test Plan mandated and I had not written + — `harnessOptions` validator coverage (zero, on a *security* opt-in whose validator throws inside + `loadConfig`), `writeStrategyForApp('kimi')` (the owner's bracketed-paste decision was reversible + with a green suite), and `markerSpanStart` (imported, unused — dead import). All added. It also + caught my `hold-verdict-exhaustive.test.ts` header claiming its `satisfies` "fails to compile", + which **contradicts what I had correctly documented in `mailbox-delivery.ts` in the same commit**. + Corrected, and the correction left visible in the file rather than quietly reworded — a comment + that overstates a guard stops the next person looking for a real one. + +**What I added unprompted, and would defend:** a test that pacing is *wired* through +`makeDeliveryPorts().writeMessage`, not merely resolvable. All eight existing pacing tests call the +resolver directly, so every one of them stayed green through *both* times this seam silently came +unwired (#1365 moving the parameter list, #1567 inserting `strategy` into pacing's slot). Verified +by removing the argument and watching it go red. + +**Verification discipline I held to throughout:** every guard was proven by breaking the thing it +guards — the exhaustiveness tripwire (widened the union), the wiring test (unwired the binding), the +`markerSpanStart` tests (reverted to the inert version, 3 red). That is lessons-learned #1401's own +rule, and it is the reason the inert `markerSpanStart` is the *only* thing that got past me: it was +the one guard I did not test that way before claiming it worked. + +**Outward drafts awaiting approval** (nothing posted — standing rule): +`/tmp/pir-1620-draft-mohid-checklist.md`, `-pr-comment.md`, `-pr-description.md`, +`-followup-issues.md`. The docs and review currently say the follow-ups are "filed before merge"; +they need real numbers before #1203 merges. diff --git a/codev/state/task-Iptx_thread.md b/codev/state/task-Iptx_thread.md new file mode 100644 index 0000000000..25e776f790 --- /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/__tests__/config.test.ts b/packages/codev/src/__tests__/config.test.ts index c16b54a18c..0b42982287 100644 --- a/packages/codev/src/__tests__/config.test.ts +++ b/packages/codev/src/__tests__/config.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { deepMerge, loadConfig, resolveProjectConfigPath, resolveLocalConfigPath } from '../lib/config.js'; +import { deepMerge, loadConfig, resolveProjectConfigPath, resolveLocalConfigPath, validateHarnessOptions, kimiAutoTrustWorkspace } from '../lib/config.js'; import { getActivityHooks } from '../agent-farm/utils/config.js'; // Helpers @@ -324,3 +324,104 @@ describe('getActivityHooks (trusted personal layers only — never the committed expect(getActivityHooks(tmpDir).hooks).toEqual([]); }); }); + +/** + * `harnessOptions` (Issue #1620) — the namespace carrying the kimi workspace-trust opt-in. + * + * This block exists because the option gates a **capability grant**, and the two failure + * directions are not symmetric. A typo that silently reads as `false` is a puzzled operator; one + * that silently reads as `true` is a permission nobody granted. So the validator is strict about + * unknown keys and non-booleans, and `kimiAutoTrustWorkspace` fails **closed** on anything it + * cannot read — including a config file broken for entirely unrelated reasons. + */ +describe('harnessOptions — the kimi workspace-trust opt-in (Issue #1620)', () => { + describe('validateHarnessOptions', () => { + it('accepts an absent block, an empty block, and both boolean values', () => { + expect(() => validateHarnessOptions(undefined)).not.toThrow(); + expect(() => validateHarnessOptions({})).not.toThrow(); + expect(() => validateHarnessOptions({ kimi: {} })).not.toThrow(); + expect(() => validateHarnessOptions({ kimi: { autoTrustWorkspace: true } })).not.toThrow(); + expect(() => validateHarnessOptions({ kimi: { autoTrustWorkspace: false } })).not.toThrow(); + }); + + it('rejects an unknown harness, and points at the right namespace', () => { + // `harness` (custom DEFINITIONS) and `harnessOptions` (built-in SETTINGS) are easy to + // confuse, so the error says which is which rather than only that the key is wrong. + expect(() => validateHarnessOptions({ claude: { autoTrustWorkspace: true } })) + .toThrow(/harnessOptions\.claude.*unknown harness/s); + expect(() => validateHarnessOptions({ claude: {} })).toThrow(/"harness", not "harnessOptions"/); + }); + + it('rejects an unknown option under kimi rather than ignoring it', () => { + // The whole point: a misspelled security flag must not silently mean "off". + expect(() => validateHarnessOptions({ kimi: { autoTrustWorkspac: true } })) + .toThrow(/harnessOptions\.kimi\.autoTrustWorkspac.*unknown option/s); + }); + + it('rejects a non-boolean rather than coercing it', () => { + for (const value of ['true', 1, null, {}, []]) { + expect(() => validateHarnessOptions({ kimi: { autoTrustWorkspace: value } })) + .toThrow(/must be a boolean/); + } + }); + + it('rejects non-object shapes at both levels', () => { + expect(() => validateHarnessOptions('yes')).toThrow(/expected an object, got string/); + expect(() => validateHarnessOptions([])).toThrow(/expected an object, got array/); + expect(() => validateHarnessOptions({ kimi: 'true' })).toThrow(/kimi.*expected an object, got string/s); + expect(() => validateHarnessOptions({ kimi: [] })).toThrow(/kimi.*expected an object, got array/s); + }); + }); + + describe('loadConfig integration', () => { + it('parses the block through the normal config path', () => { + writeProjectConfig(tmpDir, { harnessOptions: { kimi: { autoTrustWorkspace: true } } }); + expect(loadConfig(tmpDir).harnessOptions?.kimi?.autoTrustWorkspace).toBe(true); + }); + + it('fails LOAD, not the eventual spawn, on a malformed block', () => { + // Deliberately fail-fast: a bad security opt-in must surface on the next command, not when + // a kimi builder is finally spawned days later. This matches how a malformed `harness` + // block already behaves. + writeProjectConfig(tmpDir, { harnessOptions: { kimi: { autoTrustWorkspace: 'yes' } } }); + expect(() => loadConfig(tmpDir)).toThrow(/must be a boolean/); + }); + + it('a config with no harnessOptions block is simply absent, not an error', () => { + writeProjectConfig(tmpDir, { shell: { builder: 'kimi' } }); + expect(loadConfig(tmpDir).harnessOptions).toBeUndefined(); + }); + }); + + describe('kimiAutoTrustWorkspace', () => { + it('is false for a legacy config with no block — silence grants nothing', () => { + writeProjectConfig(tmpDir, { shell: { builder: 'kimi' } }); + expect(kimiAutoTrustWorkspace(tmpDir)).toBe(false); + }); + + it('is false when no config exists at all', () => { + expect(kimiAutoTrustWorkspace(tmpDir)).toBe(false); + }); + + it('is true only for an explicit true', () => { + writeProjectConfig(tmpDir, { harnessOptions: { kimi: { autoTrustWorkspace: true } } }); + expect(kimiAutoTrustWorkspace(tmpDir)).toBe(true); + }); + + it('is false for an explicit false, and for an empty kimi block', () => { + writeProjectConfig(tmpDir, { harnessOptions: { kimi: { autoTrustWorkspace: false } } }); + expect(kimiAutoTrustWorkspace(tmpDir)).toBe(false); + writeProjectConfig(tmpDir, { harnessOptions: { kimi: {} } }); + expect(kimiAutoTrustWorkspace(tmpDir)).toBe(false); + }); + + it('fails CLOSED when the config cannot be read at all', () => { + // A config broken for unrelated reasons must not be what decides we may grant trust. The + // breakage still surfaces loudly through every other `loadConfig` caller; this one reader + // answers "no" rather than propagating. + fs.mkdirSync(path.join(tmpDir, '.codev'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.codev', 'config.json'), '{ not json', 'utf-8'); + expect(kimiAutoTrustWorkspace(tmpDir)).toBe(false); + }); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/bugfix-1567-bracketed-paste-write.test.ts b/packages/codev/src/agent-farm/__tests__/bugfix-1567-bracketed-paste-write.test.ts index de275a3883..f8b2c26198 100644 --- a/packages/codev/src/agent-farm/__tests__/bugfix-1567-bracketed-paste-write.test.ts +++ b/packages/codev/src/agent-farm/__tests__/bugfix-1567-bracketed-paste-write.test.ts @@ -206,6 +206,22 @@ describe('Issue #1567 — the write edge never hands the PTY more than its input expect(writeStrategyForApp(undefined)).toBe(BRACKETED_PASTE); }); + // Issue #1620 pinned separately, because it is a DECISION rather than a measurement. + // + // The kimi lane proposed opting kimi out until someone had measured whether its TUI honours + // bracketed paste — an un-honoured bracket does not merely look wrong, it puts literal + // `\x1b[200~` in the composer and, because `framePieces` turns `\n` into `\r` inside the + // bracket, submits every line as its own message. The owner weighed that and declined + // (2026-09-08): the residual is acceptable and a live measurement beats a defensive default. + // + // Asserted rather than left implicit because "kimi is absent from the opt-out list" reads + // identically whether it was considered or overlooked. With this here, a future edit to + // `writeStrategyForApp` has to be deliberate about kimi instead of inheriting the default by + // silence — which is exactly how kimi would have inherited it in the first place. + it('strategy by app: kimi takes the bracketed default — an owner decision, not an oversight', () => { + expect(writeStrategyForApp('kimi')).toBe(BRACKETED_PASTE); + }); + it('submitMessagePaced forwards the strategy to the write', async () => { resetSubmissionChains(); const bracketed = makeSession(); 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 4243a9dd26..dd73bfa5a1 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 @@ -16,6 +16,8 @@ import { PASTE_BEGIN, PASTE_END, PASTE_ENTER_DELAY_MS, + BRACKETED_PASTE, + PLAIN_CHUNKED, } from '../servers/message-write.js'; /** The one-piece bracketed form of a short multi-line body (Issue #1567). */ @@ -182,4 +184,85 @@ 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)', () => { + // The override exists because Kimi's paste-detection window swallows an Enter sent too + // soon after the body: bisected live on 0.27.0, 80 ms and 100 ms never submit; 120 ms+ do. + // Both Enter sites are covered on purpose. Issue #1567 moved long frames onto their own + // delay (PASTE_ENTER_DELAY_MS = 80) — which is the FIRST ROW of that bisect — and a + // formatted `afx send` is almost always >= 4 lines, so the long branch is the one a real + // message takes. A suite that only pinned the short branch would stay green while the + // feature was broken for every message anyone actually sends. + + it('short frame: Enter waits for the overridden delay, not SIMPLE_ENTER_DELAY_MS', () => { + const session = makeSession(); + const msg = 'BEGIN'; + + const endTime = writeMessageToSession(session, msg, false, 0, BRACKETED_PASTE, { enterDelayMs: 1000 }); + expect(endTime).toBe(1000); + + // The 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('long frame (bracketed): the override displaces PASTE_ENTER_DELAY_MS', () => { + const session = makeSession(); + const msg = 'line1\nline2\nline3\nline4'; + + // Short enough to bracket into a single piece, so the last piece lands at t=0 and the + // Enter is the only thing the override moves. + const endTime = writeMessageToSession(session, msg, false, 0, BRACKETED_PASTE, { enterDelayMs: 1000 }); + expect(endTime).toBe(1000); + + // PASTE_ENTER_DELAY_MS is 80 — precisely the delay Kimi eats. Nothing may submit here. + vi.advanceTimersByTime(PASTE_ENTER_DELAY_MS); + expect(session.writeCalls).toEqual([pasted(msg)]); + + vi.advanceTimersByTime(1000 - PASTE_ENTER_DELAY_MS); + expect(session.writeCalls).toEqual([pasted(msg), '\r']); + }); + + it('long frame (plain-chunked): the override applies on the opted-out strategy too', () => { + const session = makeSession(); + const msg = 'line1\nline2\nline3\nline4'; + + // Per-line pacing: four pieces 10 ms apart, so the last lands at 30 ms. + const endTime = writeMessageToSession(session, msg, false, 0, PLAIN_CHUNKED, { enterDelayMs: 1000 }); + expect(endTime).toBe(30 + 1000); + + vi.advanceTimersByTime(30 + PASTE_ENTER_DELAY_MS); + expect(session.writeCalls).not.toContain('\r'); + + vi.advanceTimersByTime(1000 - PASTE_ENTER_DELAY_MS); + expect(session.writeCalls).toContain('\r'); + }); + + it('no pacing argument → default delays unchanged, on both branches (regression)', () => { + const session = makeSession(); + expect(writeMessageToSession(session, 'hi', false)).toBe(50); + // Long frame, default bracketed strategy: one piece at t=0, Enter at PASTE_ENTER_DELAY_MS. + const paced = makeSession(); + expect(writeMessageToSession(paced, 'a\nb\nc\nd', false)).toBe(PASTE_ENTER_DELAY_MS); + // And on the opted-out strategy: last of four pieces at 30 ms, then the same delay. + const plain = makeSession(); + expect(writeMessageToSession(plain, 'a\nb\nc\nd', false, 0, PLAIN_CHUNKED)).toBe(30 + PASTE_ENTER_DELAY_MS); + }); + + it('noEnter suppresses the Enter even with an override', () => { + const session = makeSession(); + writeMessageToSession(session, 'BEGIN', true, 0, BRACKETED_PASTE, { 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 138034ffc9..79f2175b0d 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 5ff71c2c1b..0a7d3098fc 100644 --- a/packages/codev/src/agent-farm/__tests__/fixtures/gate/README.md +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/README.md @@ -56,6 +56,51 @@ encodes the expected verdict: `-..txt`. normal intensity (dim=0); user-typed text is **default-fg**; the transcript echo of a submitted turn is **palette-4**; and in every settled state the **cursor rests on the composer row**. Markdown blockquotes render as `│`, not `> `. +- **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. +- **kimi-multiline.busy.txt, kimi-multiline-bare.busy.txt, kimi-newline-bare.busy.txt, + kimi-menu.busy.txt, kimi-picker.busy.txt** — **real captures** (0.34.0, same harness) + of the multi-row composer states, which is where a LAST-match marker search goes + wrong. kimi renders a two-line draft as `` │ > `` / `` │ ``, so + a continuation row beginning with `>` matches the marker too and the search settles + on it, leaving line one *above* the scanned region. `kimi-multiline-bare` is that + false-CLEAN with a real draft above the bare `>` — closed by the profile's + `regionStartPatterns` (anchor the region to the box top). `kimi-newline-bare` is the + residual the region bound alone cannot close (architect review, 2026-08-09): a + newline then `>` renders `` │ > `` / `` │ > `` — row one empty, row two's `>` + span-exempted as chrome — so the draft is real but has **zero countable cells** no + matter how the region is bounded. It is held on the composer's *shape* instead + (`multi-row-draft`), which is sound because box growth was **measured** to be + exclusive to multi-line drafts on 0.34.0 (harness: + `codev/spikes/pir-1201-kimi-box-growth.mjs` — idle, single-line draft, `/` menu, `@` + picker and the post-reply steady state all hold at one interior row; and + `pir-1201-kimi-working-states.mjs` — mid-generation, mode chrome, and a draft typed + while the agent works, likewise one row). The menu and picker captures pin that kimi + draws those lists *outside* the box, below its bottom rule, so neither grows the + scanned region. The rule is armed by the profile's `growsWithDraft` flag, **not** by + `regionStartPatterns`: `codex-idle.clean.txt` is a real, genuinely empty composer that + already spans two interior rows, so arming on the scan bound alone would hold codex + mail forever the day codex declared one. + + > **Capture version, and its status.** The kimi fixtures above were measured on **0.34.0** + > (2026-08). They have NOT been re-captured against a newer CLI by the maintainer lane — + > see `codev/plans/1620-re-plan-pr-1203-kimi-harness-a.md`, which hands re-capture and the + > `growsWithDraft` re-verification to the original contributor. Treat the version as part of + > the claim: kimi ships roughly weekly, and 0.33.0 was itself an engine change. - **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 0000000000..b7f038ccdb --- /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 0000000000..5b8c33f6c4 --- /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 0000000000..fd48f31f2a --- /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 0000000000..d74f0cd315 --- /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 0000000000..c8929e0d3b --- /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-newline-bare.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-newline-bare.busy.txt new file mode 100644 index 0000000000..8d7ea8502d --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-newline-bare.busy.txt @@ -0,0 +1,69 @@ +]11;?[?2026h ]8;;[?2026l[?25l[?2004h[>7u[?u[?25l[?1004h[?2031h]11;?[?996n[?2026h + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ │]8;; + │ ▐█▛█▛█▌ Welcome to Kimi Code! │]8;; + │ ▐█████▌ Send /help for help information. │]8;; + │ │]8;; + │ Directory: /tmp/kimi-growth-PAHkwx │]8;; + │ Session:  │]8;; + │ Model: K3-256k │]8;; + │ Version: 0.34.0 │]8;; + │ │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx /model: switch model]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ✦ Use Kimi K3 with High thinking effort - for the best balance between token spend and capability]8;; + Run /model to switch to K3 and set thinking effort to High]8;; + ]8;; + No session yet — one will be created on your first message. ]8;; + ]8;; + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ > draft text  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;;[?2026l[?25l[?2026h  │ > xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │]8;; + │ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;; + context: 0% (0/256k)]8;; +[?2026l[?25l[?2026h  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ > /  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + │ → yolo Toggle YOLO mode: auto-approve tool actions, but the agent may still ask │]8;; + │  questions. │]8;; + │ model Switch LLM model │]8;; + │ permission Select permission mode │]8;; + │ plan Toggle plan mode │]8;; + │ settings Open TUI settings │]8;; + │  (1/48) │]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;;[?2026l[?25l[?2026h  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; + │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx ask Kimi to schedule tasks, e.g. "remind me at 5pm"]8;; + context: 0% (0/256k)]8;; + + + + + + +[?2026l[?25l[?2026h  │ > @  │]8;;[?2026l[?25l[?25l[?2026h  │ >   │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;;[?2026l[?25l[?2026h  │ > │]8;; + │ >  │]8;; + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; + yolo K3-256k thinking: high /tmp/kimi-growth-PAHkwx]8;; + context: 0% (0/256k)]8;;[?2026l[?25l \ No newline at end of file diff --git a/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-picker.busy.txt b/packages/codev/src/agent-farm/__tests__/fixtures/gate/kimi-picker.busy.txt new file mode 100644 index 0000000000..339c3175b3 --- /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 0000000000..371bd33f91 --- /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 0ece26224f..ab99e5813f 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,906 @@ 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 }; + + // Generated shell is the one artifact in this file that no type checker reads. Every + // shape gets parsed by a real bash so a stray backtick, an unbalanced quote, or a `${}` + // the TypeScript template literal ate cannot ship — the failure would otherwise appear as + // a builder terminal that dies instantly at spawn, far from its cause. + it.each([ + ['task-carrying', () => KIMI_HARNESS.buildBuilderLaunchScript!(taskCtx)], + ['bare', () => KIMI_HARNESS.buildBuilderLaunchScript!(bareCtx)], + ])('%s: the generated script is syntactically valid bash', (_name, build) => { + const file = join(mkdtempSync(join(tmpdir(), 'kimi-script-')), 'launch.sh'); + writeFileSync(file, build(), 'utf-8'); + const parsed = spawnSync('bash', ['-n', file], { encoding: 'utf-8' }); + expect(parsed.stderr).toBe(''); + expect(parsed.status).toBe(0); + }); + + // Issue #1620. spawn.ts creates this session and only THEN writes the builder's row, while + // `afx send` resolves its sender from cwd and THROWS when that row is missing (#1094). Lose + // the race and the old single-shot queue warned once and never retried — a builder with a + // role and no mission. Only node's startup latency was preventing it. + it('task-carrying: retries queueing rather than giving up on the first failure', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!(taskCtx); + expect(script).toContain('codev_queue_deadline_secs'); + expect(script).toMatch(/while :; do[\s\S]*afx send --raw[\s\S]*sleep 2[\s\S]*done/); + // The warning must be reachable only AFTER the deadline, never on the first attempt. + expect(script).toMatch(/could not queue the builder's task after/); + }); + + // The sender resolves to the builder's OWN id (this script runs inside its worktree), so + // without --raw the opening mission arrives framed as a peer message from itself. + it('task-carrying: queues the task raw, so the spawn prompt is not wrapped as a self-send', () => { + const script = KIMI_HARNESS.buildBuilderLaunchScript!(taskCtx); + expect(script).toContain('afx send --raw "$codev_builder_id"'); + expect(script).not.toMatch(/afx send "\$codev_builder_id"/); + }); + + 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 --raw "$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_should_resume'); + // Entry selects resume only under the probe... + expect(script).toMatch(/if codev_should_resume; then\n\s*codev_launch=codev_launch_resume\n\s*else\n\s*codev_launch=codev_launch_fresh/); + // ...and so does the crash path; its else-branch is fresh, not resume. + expect(script).toMatch(/elif codev_should_resume; then[\s\S]*?codev_launch=codev_launch_resume\n\s*else\n[\s\S]*?codev_launch=codev_launch_fresh/); + // `-c` appears ONLY inside codev_launch_resume, which only the probe selects. + const resumeBody = script.slice( + script.indexOf('codev_launch_resume() {'), + 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_should_resume'); + expect(script).not.toContain('codev_superseded_id'); + 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'); + // Bound the branch on ITS OWN closing `fi` — the one at the loop body's two- + // space indent. Matching the next two letters "fi" stops inside prose + // ("fine", "confirm"); matching any indented `fi` stops at the nested + // baseline-capture conditional. Either way the slice silently shrinks and the + // assertions below stop inspecting the branch they name. + const close = script.slice(cleanExit).search(/\n {2}fi\n/); + const afterClean = script.slice(cleanExit, cleanExit + close); + expect(afterClean).toContain('codev_launch=codev_launch_fresh'); + expect(afterClean).not.toContain('codev_launch_resume'); + // Finding 4: relaunching fresh is not enough on its own — `-c` is cwd-scoped, + // so the branch must also RETIRE the ended conversation by id, or a crash in + // the pre-mint window resumes it right back. The behavioral half of this pin + // is the sticky-fresh block below, which runs the decision at the shell. + expect(afterClean).toContain('codev_superseded_id="$codev_prev_id"'); + }); + + it('warns loudly but non-fatally when the task cannot be queued', () => { + 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. + * + * Since Finding 4 the probe answers WHICH session, not merely whether one exists, + * so every case here also asserts the printed id against discovery's (see + * `runProbe`) — identity is what the sticky-fresh contract turns on. + */ + describe('the inlined crash-resume session probe (KIMI_NEWEST_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 ⇔ it named a session. + * + * Also cross-checks, on every call, that the PRINTED id is exactly what + * `findLatestKimiSessionId` would return. Asserting inside the helper rather + * than per-test is deliberate: the identity claim then rides every case in + * this block — archived, junk, symlink, trailing slash — instead of only the + * cases someone remembered to extend. Since Finding 4 the loop resumes on + * WHICH session is newest, so a probe that agrees about existence but + * disagrees about identity would silently defeat the sticky-fresh contract. + */ + function runProbe(cwd: string): boolean { + const res = spawnSync(process.execPath, ['-e', extractProbe(), cwd], { + env: { ...process.env, KIMI_CODE_HOME: join(fakeHome, '.kimi-code') }, + encoding: 'utf-8', + }); + const printed = (res.stdout ?? '').trim(); + const discovered = KIMI_HARNESS.buildResume!(cwd, { homeDir: fakeHome })?.sessionId ?? null; + expect(printed || null, 'probe must name the same session discovery does').toBe(discovered); + // Exit status and output must agree, since the script reads emptiness. + expect(res.status === 0).toBe(printed !== ''); + return printed !== ''; + } + + /** The id the generated script would treat as "the conversation to continue". */ + function probeId(cwd: string): string { + const res = spawnSync(process.execPath, ['-e', extractProbe(), cwd], { + env: { ...process.env, KIMI_CODE_HOME: join(fakeHome, '.kimi-code') }, + encoding: 'utf-8', + }); + return (res.stdout ?? '').trim(); + } + + /** + * Run the generated resume DECISION at the shell, with a real store underneath. + * `superseded` is what a clean exit would have recorded. Returns the branch the + * loop takes: RESUME (`kimi -c`) or FRESH (role-carrying new conversation). + */ + function decideBranch(superseded: string | null): string { + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: 'pir-1201', + }); + // The three generated pieces the decision is made of, run verbatim. + const fns = script.slice( + script.indexOf('codev_newest_session()'), + script.indexOf('codev_launch_fresh()'), + ); + const res = spawnSync('bash', ['-c', + `${fns}\n` + + (superseded === null ? '' : `codev_superseded_id='${superseded}'\n`) + + 'if codev_should_resume; then echo RESUME; else echo FRESH; fi\n', + ], { + cwd: worktree, + env: { ...process.env, KIMI_CODE_HOME: join(fakeHome, '.kimi-code') }, + encoding: 'utf-8', + }); + expect(res.status, res.stderr).toBe(0); + return res.stdout.trim(); + } + + /** + * The PRE-fix predicate — "does any session exist for this cwd?" — run against + * the same generated probe and the same store. Exists so the regression tests + * can show the defect rather than assert its absence: if this ever returns the + * same branch as `decideBranch` on the sticky-fresh fixture, the new guard has + * stopped doing anything and those tests have gone vacuous. + */ + function decideBranchLegacy(): string { + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: 'pir-1201', + }); + const fns = script.slice( + script.indexOf('codev_newest_session()'), + script.indexOf('codev_launch_fresh()'), + ); + const res = spawnSync('bash', ['-c', + `${fns}\n` + + 'if [ -n "$(codev_newest_session)" ]; then echo RESUME; else echo FRESH; fi\n', + ], { + cwd: worktree, + env: { ...process.env, KIMI_CODE_HOME: join(fakeHome, '.kimi-code') }, + encoding: 'utf-8', + }); + expect(res.status, res.stderr).toBe(0); + return res.stdout.trim(); + } + + function writeStoreSession(sessionId: string, state: Record): void { + 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('recovers when the first queue attempt loses the builder-registration race', () => { + // Issue #1620, the behaviour rather than the script text. spawn.ts starts this session + // and only THEN writes the builder row; `afx send` resolves its sender from cwd and + // exits non-zero while that row is missing (#1094). The stub reproduces exactly that: + // it fails until a sentinel appears, standing in for upsertBuilder landing. + // + // Pre-#1620 this warned once and returned, leaving a builder with a role and no + // mission. The only thing that made it survive in practice was node's startup latency + // beating a local HTTP round-trip — which is not a guarantee, it is a coincidence. + 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'); + const bin = join(fakeHome, 'bin'); + mkdirSync(bin, { recursive: true }); + const calls = join(fakeHome, 'race-calls.log'); + const registered = join(fakeHome, 'builder-registered'); + writeFileSync(join(bin, 'afx'), [ + '#!/bin/bash', + // Not yet registered → exit 1, the way the real CLI fatals on an unresolvable sender. + `[ -f '${registered}' ] || { touch '${registered}'; exit 1; }`, + `echo "\${@: -1}" >> '${calls}'`, + ].join('\n'), { mode: 0o755 }); + + const harnessFns = script.slice(script.indexOf('codev_builder_id='), script.indexOf('codev_newest_session()')); + const res = spawnSync('bash', ['-c', `${harnessFns}\ncodev_queue_task\necho "queued=$codev_task_queued"`], { + env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, encoding: 'utf-8', + }); + expect(res.status).toBe(0); + // The retry won: the task is on the mailbox and the flag is set, so a later fresh + // relaunch will not enqueue it a second time. + expect(readFileSync(calls, 'utf-8').trim().split('\n')).toEqual(['THE TASK']); + expect(res.stdout).toContain('queued=1'); + // And no scary warning, because nothing was actually lost. + expect(res.stderr).not.toContain('could not queue'); + }, 20_000); + + it('gives up loudly, and only after the deadline, when the send never succeeds', () => { + 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'); + const bin = join(fakeHome, 'bin'); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(bin, 'afx'), '#!/bin/bash\nexit 1\n', { mode: 0o755 }); + + const harnessFns = script.slice(script.indexOf('codev_builder_id='), script.indexOf('codev_newest_session()')); + // Deadline shortened via the documented env var so the suite does not wait 30 s. + const res = spawnSync('bash', ['-c', `${harnessFns}\ncodev_queue_task\necho "queued=$codev_task_queued"`], { + env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, CODEV_TASK_QUEUE_DEADLINE_SECS: '2' }, + encoding: 'utf-8', + }); + expect(res.status).toBe(0); // fail-soft: never aborts the launch + expect(res.stdout).toContain('queued=0'); // and never claims success + expect(res.stderr).toContain('could not queue'); + expect(res.stderr).toContain('afx send --raw'); // the recovery command is printed + }, 20_000); + + 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 --raw ` → the body is the LAST argument. Read it as + // `${@: -1}` rather than a fixed position, so this stub keeps testing the queueing + // state machine rather than breaking every time a flag is added to the send (Issue #1620 + // added --raw, and a positional stub is how that turned into a red test with nothing + // actually wrong). + writeFileSync(join(bin, 'afx'), `#!/bin/bash\necho "\${@: -1}" >> '${calls}'\n`, { mode: 0o755 }); + const harnessFns = script.slice(script.indexOf('codev_builder_id='), script.indexOf('codev_newest_session()')); + const res = spawnSync('bash', ['-c', + `${harnessFns}\n` + + // three crash-restart iterations, then a clean-exit relaunch + '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']); + }); + + /** + * Finding 4 (architect review, 2026-08-09) — #1267's sticky-fresh contract, + * enforced at the shell against a real store. + * + * "Clean exit → fresh rerun, no recovery" is a contract main states twice: in + * `buildLaunchLoop`'s docstring ("once a clean exit has moved the loop to fresh, + * a later crash restarts the *fresh* invocation, never the superseded session") + * and in `buildSessionLaunchLoop`, which enforces it BY IDENTITY — clean exit + * mints a new id, and the superseded one is never named again. + * + * kimi cannot mint an id on demand, and `-c` is cwd-scoped, so identity has to + * come from the store. The window that matters: 0.33.0+ mints NO session until + * the first message lands, so between a clean-exit relaunch and the first + * delivery, the newest session for the cwd is still the one the human ended. + * An existence-only guard resumes it — and the re-queued task lands in the + * conversation they walked away from. + */ + describe('a clean exit is sticky: a crash before the new conversation mints stays fresh', () => { + it('resumes nothing when the only session is the one the human just ended', () => { + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + // Clean exit records the newest id as superseded… + const superseded = probeId(worktree); + expect(superseded).toBe('session_A'); + // …and the crash lands in the pre-mint window, so the store is unchanged. + expect(decideBranch(superseded)).toBe('FRESH'); + + // The defect itself, demonstrated on the same bytes: the existence-only + // guard this replaced resumes here — `kimi -c` into the conversation the + // human ended, carrying the re-queued task with it. + expect(decideBranchLegacy()).toBe('RESUME'); + }); + + it('resumes again once the fresh conversation has minted a newer session', () => { + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + writeStoreSession('session_B', { + id: 'session_B', version: 2, cwd: worktree, updatedAt: 200, + }); + // Same superseded id, but the first message has now minted B. + expect(decideBranch('session_A')).toBe('RESUME'); + }); + + it('supersedes each conversation in turn across iterated clean exits', () => { + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + writeStoreSession('session_B', { + id: 'session_B', version: 2, cwd: worktree, updatedAt: 200, + }); + // Second quit supersedes B. The loop must NOT fall back to resuming A — + // that conversation was abandoned too, and it is not what `-c` targets. + expect(probeId(worktree)).toBe('session_B'); + expect(decideBranch('session_B')).toBe('FRESH'); + }); + + it('keeps entry semantics unchanged: nothing superseded yet', () => { + // Virgin worktree → fresh (and the role rides that launch). + expect(decideBranch(null)).toBe('FRESH'); + // A worktree that already holds a conversation → resumed, task not re-queued. + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + expect(decideBranch(null)).toBe('RESUME'); + }); + + it('routes a genuinely UNREADABLE store to FRESH even though a session exists', () => { + // Not the same as an absent store (pinned separately above). Here a session + // for this cwd DOES exist and would authorize `-c`, but the scan cannot run: + // replacing `sessions/` with a regular file makes readdirSync throw ENOTDIR + // deterministically, and unlike `chmod 000` it still fails when run as root. + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + const sessions = join(fakeHome, '.kimi-code', 'sessions'); + rmSync(sessions, { recursive: true, force: true }); + writeFileSync(sessions, 'not a directory', 'utf-8'); + expect(decideBranch('session_A')).toBe('FRESH'); + expect(decideBranch(null)).toBe('FRESH'); + }); + + it('ignores stdout the probe did not produce (status is what authorizes -c)', () => { + // CMAP 2026-08-09, claude F1 — a failure mode this delta INTRODUCED. Once + // the decision reads stdout, anything else writing there is read as "a + // session exists": a `node` shim on PATH, or NODE_OPTIONS preloading an + // instrumentation module that prints a banner. Against an EMPTY store that + // sends the loop to `kimi -c` with nothing to continue — which does not + // fail, it starts a session that never saw --agent-file. Silently roleless, + // the #929 class. The probe still exits 1; the guard must honor it. + const preload = join(fakeHome, 'noisy.cjs'); + writeFileSync(preload, 'console.log("hello-from-require");', 'utf-8'); + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: 'pir-1201', + }); + const fns = script.slice( + script.indexOf('codev_newest_session()'), + script.indexOf('codev_launch_fresh()'), + ); + const res = spawnSync('bash', ['-c', + `${fns}\nif codev_should_resume; then echo RESUME; else echo FRESH; fi\n`, + ], { + cwd: worktree, + env: { + ...process.env, + KIMI_CODE_HOME: join(fakeHome, '.kimi-code'), + NODE_OPTIONS: `--require ${preload}`, + }, + encoding: 'utf-8', + }); + expect(res.stdout.trim()).toBe('FRESH'); + }); + }); + + /** + * F4 (CMAP 2026-08-09, claude): the pieces were pinned, the COMPOSITION was not. + * + * `decideBranch` injects `codev_superseded_id` from the test, so nothing proved + * the generated clean-exit branch actually assigns it. A refactor that wrapped + * the assignment in a subshell — `( codev_superseded_id=$(...) )`, an ordinary + * bash footgun — or moved it below the relaunch would pass every other test here + * while the sticky-fresh contract was dead. The composition is where the bug + * was, so it is where the pin belongs: drive the REAL loop body with stubbed + * launches and read back which branch each iteration took. + */ + it('drives the real loop: clean exit then a pre-mint crash launches fresh TWICE', () => { + writeStoreSession('session_A', { + id: 'session_A', version: 2, cwd: worktree, updatedAt: 100, + }); + const script = KIMI_HARNESS.buildBuilderLaunchScript!({ + worktreePath: worktree, baseCmd: 'kimi', roleFragment: '--agent-file x', + taskFile: join(worktree, '.builder-prompt.txt'), builderId: 'pir-1201', + }); + const log = join(fakeHome, 'branches.log'); + // Everything from the probe definition down, with the real `while` loop — + // only the two launches and the sleep are stubbed. `exit 0` on the third + // iteration ends the loop so the test terminates. + const body = script.slice(script.indexOf('codev_newest_session()')); + const harness = + `${body.replace(/^codev_launch_fresh\(\) \{[\s\S]*?^\}$/m, + `codev_launch_fresh() { echo fresh >> '${log}'; return $codev_next_status; }`) + .replace(/^codev_launch_resume\(\) \{[\s\S]*?^\}$/m, + `codev_launch_resume() { echo resume >> '${log}'; return $codev_next_status; }`)}`; + const driver = + 'codev_next_status=0\n' + // iteration 1: kimi exits cleanly + 'sleep() { :; }\n' + + 'codev_queue_task() { :; }\n'; + const res = spawnSync('bash', ['-c', + `${driver}\n${harness.replace('while true; do', + 'codev_iter=0\nwhile true; do\n codev_iter=$(( codev_iter + 1 ))\n' + + ' [ "$codev_iter" -eq 2 ] && codev_next_status=1\n' + + ' [ "$codev_iter" -ge 4 ] && exit 0\n')}`, + ], { + cwd: worktree, + // The Enter that gates the clean-exit relaunch. + input: '\n', + env: { ...process.env, KIMI_CODE_HOME: join(fakeHome, '.kimi-code') }, + encoding: 'utf-8', + timeout: 20000, + }); + expect(res.status, res.stderr).toBe(0); + const branches = readFileSync(log, 'utf-8').trim().split('\n'); + // 1: entry resumes (a session exists and nothing is superseded yet). + // 2: clean exit → fresh, and the branch retires session_A. + // 3: the crash lands pre-mint, so the ONLY session is the retired one → fresh. + expect(branches).toEqual(['resume', 'fresh', 'fresh']); + }); + + it('does not execute a builder id containing shell metacharacters', () => { + // claude F3 / codex #3: the recovery hints used to interpolate the id into a + // double-quoted echo, where bash re-scans it — so `$(…)` in an id ran when the + // 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_newest_session()')); + // No `afx` on PATH → both recovery hints print, which is the vulnerable path. + const res = spawnSync('bash', ['-c', `${harnessFns}\ncodev_queue_task\n`], + { env: { ...process.env, PATH: '/usr/bin:/bin' }, encoding: 'utf-8' }); + 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('reads workDir when cwd is present but NOT a string', () => { + // CMAP 2026-08-09, claude F3a. The probe used `j.cwd ?? j.workDir`, which + // short-circuits on any non-null cwd — including a number — while + // readStateJson tests `typeof === 'string'` per field and falls through to + // workDir. A store that ever wrote a non-string cwd would have made the two + // disagree about which session is newest, which is the one thing this pair + // must never do now that the resume decision reads identity. + writeStoreSession('session_mixed', { + id: 'session_mixed', version: 2, cwd: 12345, workDir: worktree, updatedAt: 5, + }); + expect(runProbe(worktree)).toBe(true); + expect(KIMI_HARNESS.buildResume!(worktree, { homeDir: fakeHome })?.sessionId) + .toBe('session_mixed'); + }); + + it('a trailing slash on a NONEXISTENT cwd does not manufacture a match', () => { + // CMAP 2026-08-09, claude F3b — the unsafe direction, and the reason the + // probe no longer pre-strips trailing slashes. realpathSync normalizes one + // away for a directory that exists (the case below), so stripping first + // bought nothing and cost fidelity: for a path that does NOT exist, the + // probe canonicalized `/ghost/` to `/ghost` while sameDir left it alone, so + // the probe would name a session discovery rejects — `kimi -c` into a + // conversation nobody verified, i.e. the roleless path. + const ghost = join(fakeHome, 'ghost'); + writeStoreSession('session_ghost', { id: 'session_ghost', cwd: `${ghost}/`, updatedAt: 1 }); + expect(runProbe(ghost)).toBe(false); + expect(KIMI_HARNESS.buildResume!(ghost, { homeDir: fakeHome })).toBeNull(); + }); + + it('a cwd recorded with a trailing slash still matches', () => { + writeStoreSession('session_slash', { + id: 'session_slash', version: 2, cwd: `${worktree}/`, updatedAt: 1, + }); + 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__/hold-verdict-exhaustive.test.ts b/packages/codev/src/agent-farm/__tests__/hold-verdict-exhaustive.test.ts new file mode 100644 index 0000000000..d6982c4cec --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/hold-verdict-exhaustive.test.ts @@ -0,0 +1,91 @@ +/** + * Every `GateVerdict['detail']` is classified by `isUnverifiableVerdict` (Issue #1620). + * + * WHY THIS FILE EXISTS. Issue #1201 originally shipped its own copy of the escalation rule — a + * `Record` in `mailbox-delivery.ts` — for one good reason: a + * `Record` keyed on the union is a COMPILE error when the union grows, so a new detail cannot + * slip in unclassified. But #1482 had meanwhile made `isUnverifiableVerdict` the single + * definition of "will this hold clear on its own?", shared by the escalation policy, `afx + * inbox`, `afx send`, the dashboard and the VS Code toast — and two copies of that rule are one + * edit away from an escalation policy and an operator-facing remedy disagreeing about the same + * row. + * + * So the copy was deleted and the property it bought is split in two. The compile-time half is a + * type-level tripwire in `mailbox-delivery.ts` — in SOURCE, because this package's tsconfig + * excludes the `__tests__` glob and vitest transpiles without typechecking, so the `satisfies` + * below is checked by NOTHING and enforces NOTHING on its own. It is kept purely as documentation + * of intent next to the values it annotates; do not mistake it for the guard. The runtime half — + * the actual answers — is this file. + * + * (An earlier draft of this header claimed the `satisfies` "fails to compile if the union and the + * list disagree". It does not, for exactly the reason above. Left recorded rather than quietly + * corrected, because a comment that overstates a guard is worse than no comment: it stops the + * next person from looking for a real one.) + */ +import { describe, it, expect } from 'vitest'; +import { isUnverifiableVerdict } from '@cluesmith/codev-sdk/hold-verdict'; +import type { GateVerdict } from '../servers/render-gate.js'; +import type { MailboxGateDetail } from '../db/types.js'; + +/** + * Every detail the classifier can return, and whether a hold carrying it can clear on its own. + * + * The `satisfies` is documentation, not enforcement (see the file header — tests are excluded + * from `tsc` here). The enforcement lives in `mailbox-delivery.ts`; what this table provides is + * the ANSWERS, asserted at runtime below. + */ +const EXPECTED = { + // Unverifiable — a drifted profile or a torn frame. Never clears on its own; escalates. + 'no-composer-marker': true, + 'no-region-end': true, + 'no-region-start': true, + // Held on SHAPE because the cells were uncountable — the classifier did not verify anything + // here, it inferred from box geometry. See the reasoning on `isUnverifiableVerdict`. + 'multi-row-draft': true, + // A human is at the line. Clears when they send or clear the draft. + 'user-text': false, + // Clean; never a hold at all. + empty: false, +} satisfies Record; + +describe('isUnverifiableVerdict covers every gate detail (Issue #1620)', () => { + it.each(Object.entries(EXPECTED))('classifies %s', (detail, unverifiable) => { + expect(isUnverifiableVerdict('busy', detail)).toBe(unverifiable); + }); + + it('treats no-profile as unverifiable regardless of detail', () => { + // The app was never recognized, so there is no composer to have a verdict about. + expect(isUnverifiableVerdict('no-profile', null)).toBe(true); + expect(isUnverifiableVerdict('no-profile', 'user-text')).toBe(true); + }); + + it('does not escalate no-live-pty — there is no session, not a broken one', () => { + expect(isUnverifiableVerdict('no-live-pty', null)).toBe(false); + }); + + it('is total on values it has never seen', () => { + // It reads these out of JSON, so a row written by a newer Tower can carry anything. An + // unknown value must read as "not a known defect" rather than throw on an operator surface. + expect(isUnverifiableVerdict('busy', 'some-future-detail')).toBe(false); + expect(isUnverifiableVerdict(null, null)).toBe(false); + expect(isUnverifiableVerdict(undefined, undefined)).toBe(false); + }); + + it('every DB-persistable detail is one the predicate knows about', () => { + // `MailboxGateDetail` is what the mailbox column stores and `GateVerdict['detail']` is what + // the classifier produces. They are declared separately, in different modules, and a detail + // that exists in one but not the other is the exact divergence #1482 was filed for: the + // classifier would emit a value the column's type forbids and the operator surfaces would + // describe it wrongly. Assert the persistable set is covered by the table above. + const persistable: MailboxGateDetail[] = [ + 'user-text', + 'no-region-end', + 'no-region-start', + 'no-composer-marker', + 'multi-row-draft', + ]; + for (const detail of persistable) { + expect(Object.keys(EXPECTED)).toContain(detail); + } + }); +}); 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 0000000000..9e8d9c18f1 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/kimi-session-discovery.test.ts @@ -0,0 +1,432 @@ +/** + * 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, readdirSync, 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' }); + }); + + /** + * Recency is the session directory's mtime; pin it so the ordering is explicit. + * + * Every test in this describe MUST use it rather than relying on write order. The probe's + * verdict turns on which session is NEWEST, and two `mkdirSync` calls in the same tick may + * or may not produce distinguishable mtimes depending on the filesystem's timestamp + * granularity — so an implicitly-ordered test passes or fails by platform, not by + * behaviour. This one did exactly that: written as two bare `writeSession` calls it passed + * wherever the two directories tied and failed 5/5 on APFS, where `mtimeMs` is sub- + * millisecond and the bad session was therefore always the newer one. + */ + const touchDir = (dir: string, epochSeconds: number) => utimesSync(dir, epochSeconds, epochSeconds); + + it('ok when at least one session carries the load-bearing shape', () => { + // The GOOD session is the newest — the case this test is named for. A store whose newest + // session is the broken one is drift, and is the test below. + touchDir(writeSession('session_bad', '###'), 1_000); + touchDir(writeSession('session_ok', { cwd: '/wt' }), 9_000); + 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. + 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 { + const optedIn = { ...opts(), autoTrustWorkspace: true }; + expect(ensureKimiWorkspaceTrust(root, optedIn)).toEqual({ wrote: true }); + expect(inspectKimiTrustLayout(opts())).toEqual({ status: 'ok', sampled: 1 }); + // Idempotent: a second call leaves the existing record alone. + expect(ensureKimiWorkspaceTrust(root, optedIn)) + .toEqual({ wrote: false, reason: 'already-trusted' }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + + /** + * The two refusals (Issue #1620, the #1328 class). + * + * kimi's folder trust gates exactly one thing: whether MCP servers DEFINED BY THE FOLDER are + * loaded. That is a different boundary from the `--yolo` tool auto-approval a builder already + * runs with, so pre-recording it is a capability grant and needs consent — and, where the + * folder actually ships such config, needs a human regardless of consent. + * + * Both refusals are asserted by REASON, not merely by "no record appeared". An operator + * debugging a builder stalled on the trust dialog has a completely different next move for + * "you did not opt in" than for "this worktree ships .mcp.json", and a test that only checked + * for absence would pass if the two were ever swapped. + */ + describe('ensureKimiWorkspaceTrust — the security refusals (Issue #1620)', () => { + /** Opted IN. The bare `opts()` used elsewhere deliberately is not, so it reads as consent. */ + const trustOpts = () => ({ ...opts(), autoTrustWorkspace: true }); + + let root: string; + beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'kimi-trust-root-')); }); + afterEach(() => { rmSync(root, { recursive: true, force: true }); }); + + const recordCount = (): number => { + try { + return readdirSync(join(kimiHome, 'workspace-trust')).length; + } catch { + return 0; + } + }; + + it('refuses without an explicit opt-in — silence grants nothing', () => { + expect(ensureKimiWorkspaceTrust(root, opts())) + .toMatchObject({ wrote: false, reason: 'not-opted-in' }); + expect(recordCount()).toBe(0); + }); + + it('treats an explicit false exactly like an absent option', () => { + expect(ensureKimiWorkspaceTrust(root, { ...opts(), autoTrustWorkspace: false })) + .toMatchObject({ wrote: false, reason: 'not-opted-in' }); + expect(recordCount()).toBe(0); + }); + + it('refuses a worktree carrying .mcp.json, even when opted in', () => { + writeFileSync(join(root, '.mcp.json'), '{"mcpServers":{}}', 'utf-8'); + const decision = ensureKimiWorkspaceTrust(root, trustOpts()); + expect(decision).toMatchObject({ wrote: false, reason: 'project-mcp-config' }); + expect(decision.wrote === false && decision.detail).toContain('.mcp.json'); + expect(recordCount()).toBe(0); + }); + + it('refuses a worktree carrying .kimi-code/mcp.json, even when opted in', () => { + mkdirSync(join(root, '.kimi-code'), { recursive: true }); + writeFileSync(join(root, '.kimi-code', 'mcp.json'), '{}', 'utf-8'); + expect(ensureKimiWorkspaceTrust(root, trustOpts())) + .toMatchObject({ wrote: false, reason: 'project-mcp-config' }); + expect(recordCount()).toBe(0); + }); + + it('refuses on MCP config even when the file is unparseable — presence is the signal', () => { + // The file is never read. A folder shipping a BROKEN .mcp.json is still a folder trying to + // define servers, and a parse error must not be what decides we may trust it. + writeFileSync(join(root, '.mcp.json'), 'not json at all', 'utf-8'); + expect(ensureKimiWorkspaceTrust(root, trustOpts())) + .toMatchObject({ wrote: false, reason: 'project-mcp-config' }); + expect(recordCount()).toBe(0); + }); + + it('reports the MCP refusal ahead of the opt-in one, so the log states the strongest reason', () => { + // Not opted in AND carrying MCP config. "You did not opt in" would be true but misleading: + // it implies opting in would fix it, and it would not. + writeFileSync(join(root, '.mcp.json'), '{}', 'utf-8'); + expect(ensureKimiWorkspaceTrust(root, opts())) + .toMatchObject({ wrote: false, reason: 'project-mcp-config' }); + }); + + it('writes when opted in and the worktree ships no MCP config', () => { + expect(ensureKimiWorkspaceTrust(root, trustOpts())).toEqual({ wrote: true }); + expect(recordCount()).toBe(1); + }); + + it('is fail-soft on an unwritable store — reports, never throws', () => { + // A trust-store failure must degrade to "kimi shows its dialog", never abort a spawn. + const blocked = join(kimiHome, 'workspace-trust'); + mkdirSync(kimiHome, { recursive: true }); + writeFileSync(blocked, 'I am a file where a directory should be', 'utf-8'); + expect(ensureKimiWorkspaceTrust(root, trustOpts())) + .toMatchObject({ wrote: false, reason: 'write-failed' }); + }); + }); +}); 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 0000000000..b0b381229c --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/mailbox-pacing.test.ts @@ -0,0 +1,197 @@ +/** + * 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, makeDeliveryPorts } from '../servers/mailbox-wiring.js'; +import { KIMI_HARNESS, CLAUDE_HARNESS } from '../utils/harness.js'; +import type { DeliverySession } from '../servers/mailbox-delivery.js'; +import { BRACKETED_PASTE } from '../servers/message-write.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(); + }); +}); + +/** + * The resolver being correct is not the same as the resolver being WIRED. + * + * That distinction is not theoretical here: this seam has silently come unwired twice. Issue + * #1365 replaced `writeMessagePaced` with `submitMessagePaced` and moved the parameter list; + * Issue #1567 then inserted `strategy` into the exact slot pacing had occupied. Both times every + * test in the block above kept passing, because they all call `resolvePacingForSession` directly + * and none of them go through the port the delivery path actually uses. + * + * So this exercises the real binding — `makeDeliveryPorts().writeMessage` — and observes the wire, + * not an argument. It costs about a second of real time, which is the price of testing a delay. + */ +describe('pacing is wired into the delivery port, not merely resolvable', () => { + let dir: string; + + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'pacing-wire-')); }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + /** A session that records when each write reached the "PTY". */ + function recordingSession(cwd: string) { + const writes: Array<{ data: string; at: number }> = []; + const s = { + id: 'terminal-under-test', + bytesWritten: 0, + info: { cols: 110, rows: 32 }, + command: '/bin/bash .builder-start.sh', + launchArgs: [], + cwd, + writable: true, + lastDataAt: 0, + write: (data: string) => { writes.push({ data, at: Date.now() }); return true; }, + }; + return { session: s as unknown as DeliverySession, writes }; + } + + /** Gap between the last body write and the Enter that submits it. */ + async function enterGapMs(cwd: string): Promise { + const { session: sess, writes } = recordingSession(cwd); + const ports = makeDeliveryPorts(() => {}); + // A long frame — the branch a real formatted `afx send` takes, and the one whose Enter + // delay (PASTE_ENTER_DELAY_MS = 80) is exactly the value Kimi swallows. + const body = 'line one\nline two\nline three\nline four'; + const result = await ports.writeMessage(sess, body, false, () => null, BRACKETED_PASTE); + expect(result).toEqual({ status: 'written' }); + const enter = writes.at(-1)!; + expect(enter.data).toBe('\r'); + return enter.at - writes[writes.length - 2]!.at; + } + + it('a kimi builder gets its ~1s Enter through the real port', async () => { + writeFileSync(join(dir, '.builder-start.sh'), '#!/bin/bash\nwhile true; do\n kimi --yolo\ndone\n', 'utf-8'); + chmodSync(join(dir, '.builder-start.sh'), '755'); + const gap = await enterGapMs(dir); + // Pinned generously against timer jitter: the point is 1000 vs 80, not the exact value. + expect(gap).toBeGreaterThan(500); + expect(KIMI_HARNESS.messagePacing?.enterDelayMs).toBe(1000); + }, 20_000); + + it('a claude builder is left on the default, so pacing is targeted and not global', async () => { + writeFileSync(join(dir, '.builder-start.sh'), '#!/bin/bash\nwhile true; do\n claude --foo\ndone\n', 'utf-8'); + chmodSync(join(dir, '.builder-start.sh'), '755'); + const gap = await enterGapMs(dir); + expect(gap).toBeLessThan(400); + expect(CLAUDE_HARNESS.messagePacing).toBeUndefined(); + }, 20_000); +}); 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 bca2ed7859..69ce915938 100644 --- a/packages/codev/src/agent-farm/__tests__/render-gate.test.ts +++ b/packages/codev/src/agent-farm/__tests__/render-gate.test.ts @@ -21,9 +21,9 @@ import xtermHeadless from '@xterm/headless'; import type { Terminal as HeadlessTerminal } from '@xterm/headless'; 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, markerSpanStart } from '../servers/render-gate.js'; import type { RingSnapshot, GateProfile, GateVerdict } 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'; // Default-imported for the same CJS-interop reason `render-gate.ts` documents (the named export // is not statically analyzable); the negative control below builds its own throwaway terminal. @@ -63,18 +63,36 @@ function screen(...lines: string[]): string { return lines.map((l) => l + '\r\n').join(''); } +/** + * A synthetic screen that satisfies agy's #1474 marker ANCHORS, not just its text pattern. + * + * Since #1474 `AGY_PROFILE` demands two things a plain `screen()` cannot express: the marker + * glyph must render in palette 12 (SGR 94 — bright blue), and the marker row must hold the + * cursor. So the rows are written normally, then the cursor is parked back on row 1 with an + * explicit CUP, because the composer row is ABOVE the rule line that bounds it. + * + * Without this, a synthetic agy screen classifies `no-composer-marker` for a reason that has + * nothing to do with what the test is asking about — which is how the Issue #1201 span + * guardrail below silently stopped testing agy at all when #1474 landed. + */ +function agyScreen(markerRow: string, cursorCol: number, ...rest: string[]): string { + const colored = markerRow.replace(/^>/, '\x1b[94m>\x1b[0m'); + return [colored, ...rest].map((l) => l + '\r\n').join('') + `\x1b[1;${cursorCol}H`; +} + 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/bare-marker/draft/menu/trust/turn-echo/torn, wrapper/boot)', () => { + it('the required states are all captured (claude+codex+kimi idle/draft/menu/picker, agy idle/bare-marker/draft/menu/trust/turn-echo/torn, agy+kimi trust, kimi multiline, wrapper/boot)', () => { for (const required of [ 'claude-idle.clean', 'claude-draft.busy', @@ -92,6 +110,21 @@ describe('render-gate — real captured fixtures (Spec 1313)', () => { 'agy-baremarker.clean', 'agy-turn-echo.clean', 'agy-torn-echo.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', + // The all-exempt draft: every row is whitespace or whitespace+`>`, so no + // amount of correct region bounding produces a countable cell. Held on the + // region's SHAPE instead (see the multi-row-draft rule). + 'kimi-newline-bare.busy', + 'kimi-menu.busy', + 'kimi-picker.busy', 'wrapper-boot.busy', ]) { expect(fixtures.some((f) => f.startsWith(required))).toBe(true); @@ -163,6 +196,278 @@ 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); + }); + + // The test that would have caught the inert first version of markerSpanStart. It returned + // `m.index`, which for kimi's anchored `^\s*│\s*(>)` is 0 — so a `markerFgPalette` anchor would + // have sampled the leading SPACE, i.e. the column-0 trap the helper was written to remove, still + // armed and now with a comment claiming otherwise. Assert the CHARACTER, not the number: an + // index is only meaningful against the glyph it is supposed to land on. + it.each([ + ['claude', CLAUDE_PROFILE, '❯ ', '❯'], + ['codex', CODEX_PROFILE, '› ', '›'], + ['agy', AGY_PROFILE, '> ', '>'], + ['agy (bare, no-hint mode)', AGY_PROFILE, '>', '>'], + ['kimi (marker at column 3)', KIMI_PROFILE, ' │ > ', '>'], + ])('markerSpanStart lands on %s’s actual marker glyph', (_name, profile, line, glyph) => { + expect(line[markerSpanStart(line, profile.markerPattern)]).toBe(glyph); + }); + + // End-to-end proof, not just an index: give kimi a palette anchor and confirm the classifier + // still finds its composer. This is the check that distinguishes a working generalization from + // the inert one — with `getCell(0)` (or `m.index`, which is also 0 here) the anchor samples the + // leading SPACE, the palette test fails, and a perfectly good kimi composer classifies + // `no-composer-marker` and holds its mail forever. + it('a kimi markerFgPalette anchor samples the `>` glyph, not the box edge', async () => { + // Rendered columns: 0 = ' ', 1 = '│', 2 = ' ', 3 = '>' (palette 12, SGR 94). + const boxed = [ + ' ╭──────────────────────╮', + ' │ \x1b[94m>\x1b[0m', + ' ╰──────────────────────╯', + ].map((l) => l + '\r\n').join(''); + const anchored: GateProfile = { ...KIMI_PROFILE, markerFgPalette: 12 }; + expect(await classifyScreen(snapshotFromRaw(boxed), anchored)) + .toMatchObject({ clean: true, detail: 'empty' }); + }); + + it('that anchor rejects a screen whose glyph renders in the WRONG palette', async () => { + // The other half: the anchor must actually discriminate, or the test above would pass for a + // helper that ignored colour entirely. + const boxed = [ + ' ╭──────────────────────╮', + ' │ \x1b[91m>\x1b[0m', // palette 9, not 12 + ' ╰──────────────────────╯', + ].map((l) => l + '\r\n').join(''); + const anchored: GateProfile = { ...KIMI_PROFILE, markerFgPalette: 12 }; + expect(await classifyScreen(snapshotFromRaw(boxed), anchored)) + .toMatchObject({ clean: false, detail: 'no-composer-marker' }); + }); + + it('markerSpanStart is unchanged for markers anchored at their own glyph', () => { + // No capture group → falls back to the match index, which IS the glyph for these. + expect(markerSpanStart('❯ ', CLAUDE_PROFILE.markerPattern)).toBe(0); + expect(markerSpanStart('> ', AGY_PROFILE.markerPattern)).toBe(0); + // kimi is the one that needed the group. + expect(markerSpanStart(' │ > ', KIMI_PROFILE.markerPattern)).toBe(3); + }); + + it('adding the kimi capture group did not move its span END', () => { + // A capture group does not change m[0], so the chrome exemption is untouched — worth + // pinning, because the two helpers read the same pattern for different purposes. + 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. Built with the #1474 anchors satisfied + // (palette-12 marker glyph, cursor on the composer row) so the assertion is about the SPAN + // and not about the anchors rejecting a synthetic screen. + expect(await classifyScreen( + snapshotFromRaw(agyScreen('> x', 4, '──────────────────────')), 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 + // Built with the anchors SATISFIED, so the hold can only be the pattern's doing. A plain + // synthetic screen would also report `no-composer-marker`, but for the wrong reason — it + // would fail the cursor/palette anchors first and never exercise the pattern at all. + expect(await classifyScreen( + snapshotFromRaw(agyScreen('>x', 3, '──────────────────────')), 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 an all-exempt multi-row kimi draft, which no cell count can catch', async () => { + // Architect review 2026-08-09, finding 1. Enter a newline then `>` and kimi renders + // `│ > ` / `│ >`: row 1 is empty, row 2 matches the marker so its `>` is + // span-exempted as chrome. userCells is 0 with the region bounded exactly right — + // the cell count is simply blind here, and a queued message would be typed on top + // of the unsent draft. Real 0.34.0 capture, not a constructed screen. + const raw = readFileSync(`${FIXTURE_DIR}/kimi-newline-bare.busy.txt`, 'utf8'); + expect(await classifyScreen(snapshotFromRaw(raw), KIMI_PROFILE)) + .toMatchObject({ clean: false, detail: 'multi-row-draft' }); + + // …and the before/after half: the SAME bytes under a profile identical except that + // it declares no upper bound reproduce the false CLEAN. This pins that the rule is + // what protects this screen — if it ever stops classifying clean, the fixture has + // drifted and this test has stopped testing the fix. + const { regionStartPatterns: _dropped, ...unbounded } = KIMI_PROFILE; + expect(await classifyScreen(snapshotFromRaw(raw), unbounded as GateProfile)) + .toMatchObject({ clean: true, detail: 'empty' }); + }); + + it('leaves the multi-row rule inert for the profiles that do not opt in', async () => { + // This is NOT a hypothetical shape. codex's real, captured, genuinely-EMPTY composer + // spans TWO interior rows under its shipped profile (measured across every fixture: + // codex-idle marker=18 start=18 end=20), so the rule's geometric predicate is + // already true on a screen that must stay clean. The opt-in gates are the only + // thing standing between that capture and codex mail being held forever — which is + // exactly why arming lives in its own field rather than riding regionStartPatterns. + const codexIdle = readFileSync(`${FIXTURE_DIR}/codex-idle.clean.txt`, 'utf8'); + expect(await classifyScreen(snapshotFromRaw(codexIdle), CODEX_PROFILE)) + .toMatchObject({ clean: true, detail: 'empty' }); + + // The differential, on the SAME BYTES: opt the profile in and that identical empty + // screen flips to busy. Without this half, deleting the rule outright would leave + // the inertness assertion above still passing. + const armed: GateProfile = { + ...CODEX_PROFILE, + regionStartPatterns: [/^\s*$/], // any blank line above the marker bounds the region + growsWithDraft: true, + }; + expect(await classifyScreen(snapshotFromRaw(codexIdle), armed)) + .toMatchObject({ clean: false, detail: 'multi-row-draft' }); + }); + + it('needs BOTH opt-ins: a region start alone never arms the rule', async () => { + // The decoupling itself (CMAP 2026-08-09, codex #1 / claude Q5). A profile that + // bounds its scan but makes no claim about box growth must classify exactly as it + // did before this rule existed — otherwise declaring a region start for an + // unrelated reason (a header line, a boxed redesign) is a silent delivery outage. + const codexIdle = readFileSync(`${FIXTURE_DIR}/codex-idle.clean.txt`, 'utf8'); + const boundedOnly: GateProfile = { ...CODEX_PROFILE, regionStartPatterns: [/^\s*$/] }; + expect(await classifyScreen(snapshotFromRaw(codexIdle), boundedOnly)) + .toMatchObject({ clean: true, detail: 'empty' }); + + // …and the converse: growsWithDraft without a region start is inert too, because + // `endRow - startRow` would then measure the distance to the status line rather + // than the composer's height — a number the rule has no business reading. + const growsOnly: GateProfile = { ...CODEX_PROFILE, growsWithDraft: true }; + expect(await classifyScreen(snapshotFromRaw(codexIdle), growsOnly)) + .toMatchObject({ clean: true, detail: 'empty' }); + }); + + it('treats an EMPTY regionStartPatterns array as unbounded in both places that read it', async () => { + // The drift the shared hasRegionStart predicate exists to prevent. findRegionStart + // falls back to `startRow = markerRow` for an empty array; if the rule instead read + // it as "bounded" (a plain truthiness check on the array would), the two would + // disagree and the rule would fire on a region it never bounded. + const codexIdle = readFileSync(`${FIXTURE_DIR}/codex-idle.clean.txt`, 'utf8'); + const armed: GateProfile = { ...CODEX_PROFILE, regionStartPatterns: [], growsWithDraft: true }; + expect(await classifyScreen(snapshotFromRaw(codexIdle), armed)) + .toMatchObject({ clean: true, detail: 'empty' }); + }); + + it('kimi is the only shipped profile that opts into the rule, and it declares both fields', async () => { + for (const p of [CLAUDE_PROFILE, CODEX_PROFILE, AGY_PROFILE]) { + expect(p.growsWithDraft).toBeUndefined(); + } + expect(KIMI_PROFILE.growsWithDraft).toBe(true); + // growsWithDraft is meaningless without a box top to measure height from, so the + // two must be declared together. Pinned as an invariant rather than a convention. + expect(KIMI_PROFILE.regionStartPatterns?.length).toBeGreaterThan(0); + }); + + it('holds a boxed composer whose box top is off-screen instead of scanning a partial region', async () => { + // A marker row with no `╭` above it is a torn/mid-repaint frame for a boxed app. + // The region has no proven upper bound, so the safe answer is hold — the same call + // 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 7424b8675f..31222951d2 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 --raw "$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_should_resume'); + // The discovered id is NEVER baked into the script — the relaunch is the + // documented cwd-scoped `-c`, so no undocumented id reaches generated bash. + expect(script).not.toContain('session_prev-1'); + 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_should_resume'); + 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 3f82bcaa19..4042fa75c2 100644 --- a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts +++ b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts @@ -104,6 +104,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 471cd4bc85..81e9a675f4 100644 --- a/packages/codev/src/agent-farm/commands/spawn-worktree.ts +++ b/packages/codev/src/agent-farm/commands/spawn-worktree.ts @@ -28,7 +28,8 @@ 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 { kimiAutoTrustWorkspace } from '../../lib/config.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 +779,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 +1070,33 @@ 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) { + // Issue #1620: consent is resolved HERE, from the workspace being spawned into, and passed + // in — the provider never reads config itself. Default false. + harness.prepareWorkspace?.(worktreePath, { + autoTrustWorkspace: kimiAutoTrustWorkspace(config.workspaceRoot), + }); + 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 +1196,21 @@ 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, { + autoTrustWorkspace: kimiAutoTrustWorkspace(workspaceRoot), + }); + 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/db/schema.ts b/packages/codev/src/agent-farm/db/schema.ts index 5863355487..39228157d4 100644 --- a/packages/codev/src/agent-farm/db/schema.ts +++ b/packages/codev/src/agent-farm/db/schema.ts @@ -267,7 +267,7 @@ CREATE TABLE IF NOT EXISTS mailbox ( status TEXT NOT NULL DEFAULT 'held' CHECK(status IN ('held', 'delivered', 'superseded', 'dismissed')), reason TEXT CHECK(reason IN ('busy', 'no-profile', 'no-live-pty')), -- why-held; null once delivered - detail TEXT, -- Issue #1482: the gate verdict's detail behind a busy hold ('user-text' = a human at the line; 'no-region-end'/'no-composer-marker' = the classifier could not verify). Null for non-gate holds and once delivered. NO CHECK on purpose -- SQLite cannot ALTER one in, so migration v18 could not match it and a fresh install would diverge from an upgraded one; the value set is enforced in TypeScript (MailboxGateDetail) + detail TEXT, -- Issue #1482: the gate verdict's detail behind a busy hold ('user-text' = a human at the line; 'no-region-end'/'no-region-start'/'no-composer-marker'/'multi-row-draft' = the classifier could not verify). Null for non-gate holds and once delivered. NO CHECK on purpose -- SQLite cannot ALTER one in, so migration v18 could not match it and a fresh install would diverge from an upgraded one; the value set is enforced in TypeScript (MailboxGateDetail) supersede_key TEXT, -- cron-only; null for direct sends escalated INTEGER NOT NULL DEFAULT 0, -- set once escalation age crossed (visibility only) not_before INTEGER, -- epoch ms; delayed-send due time (Spec 1313 round 3, --delay). null = deliver ASAP; a row is deliverable only when not_before IS NULL OR not_before <= now diff --git a/packages/codev/src/agent-farm/db/types.ts b/packages/codev/src/agent-farm/db/types.ts index c329e925e4..02a3eb35a2 100644 --- a/packages/codev/src/agent-farm/db/types.ts +++ b/packages/codev/src/agent-farm/db/types.ts @@ -112,7 +112,12 @@ export type MailboxReason = 'busy' | 'no-profile' | 'no-live-pty'; * The DB column carries NO CHECK constraint (see `GLOBAL_SCHEMA` / migration v18); this type * is the enforcement. */ -export type MailboxGateDetail = 'user-text' | 'no-region-end' | 'no-composer-marker'; +export type MailboxGateDetail = + | 'user-text' + | 'no-region-end' + | 'no-region-start' // Issue #1201: a boxed composer (kimi) whose box TOP is off screen + | 'no-composer-marker' + | 'multi-row-draft'; // Issue #1201: a boxed composer grown past one interior row — held on SHAPE /** * Database row type for the mailbox table (Spec 1313). diff --git a/packages/codev/src/agent-farm/servers/gate-profiles.ts b/packages/codev/src/agent-farm/servers/gate-profiles.ts index eb0b93a2f0..56319fe0d8 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 @@ -122,10 +124,109 @@ export const AGY_PROFILE: GateProfile = { markerFgPalette: 12, }; +/** + * 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`.) + * + * The `>` sits in the **named group `(?…)`** deliberately, and the group is load-bearing + * rather than cosmetic: it is how `markerSpanStart` finds the glyph's COLUMN for a + * `markerFgPalette` anchor. Without it the match begins at column 0 and such an anchor would + * sample the leading space instead of the marker. Any future profile whose glyph is not at its + * match start must name it the same way — and it must be NAMED, because agy's pattern already + * carries an incidental positional group for its separator. + */ +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. + * + * This bounds the SCAN. The residual case it cannot close is armed separately by + * `growsWithDraft` on the profile below — a draft whose every + * row is whitespace or whitespace+`>` (enter a newline, then `>`) has zero countable + * cells no matter how correctly the region is bounded, because the second row + * matches {@link KIMI_MARKER} and its `>` is span-exempted as chrome. Shape, not + * cells, is the only evidence left — so a region grown past one interior row is held. + * + * That rule is sound only because box growth is EXCLUSIVE to multi-line drafts, which + * was measured on real kimi 0.34.0 rather than assumed + * (`codev/spikes/pir-1201-kimi-box-growth.mjs`): idle, a single-line draft, the `/` + * menu, the `@` picker, and the post-reply steady state all hold at exactly one + * interior row; only the newline drafts grow to two. The steady-state result is the + * load-bearing one — growth on a composer that has already carried a turn would hold + * every later message forever, a liveness bug rather than a fail-safe one. (A long + * soft-wrapped single line grows the box too, but it carries text and was already + * busy, so its verdict is unchanged.) + * + * The WORKING states were measured separately, because a rule that reads shape could + * otherwise turn "deliver while the agent is busy" into "hold until it goes idle" + * without anyone noticing (`pir-1201-kimi-working-states.mjs`, CMAP 2026-08-09 + * claude Q2): mid-generation at 5s and 13s, the shift+tab mode chrome, and a draft + * typed while the agent is still working ALL hold at one interior row. So the rule + * changes nothing for a working builder. (`!` bash mode replaces the `>` glyph, so it + * classifies `no-composer-marker` and holds — pre-existing, fail-safe, and correct: + * there is unsent input on that row.) + */ +const KIMI_REGION_START = [/^\s*╭[─━╌┄]{3,}/]; + +/** + * 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, + // Measured, not assumed: kimi's box grows a row only when the draft gains a line. + // See KIMI_REGION_START above for the state-by-state table and why the post-reply + // steady state is the load-bearing row. kimi is the only profile that sets this. + growsWithDraft: true, +}; + /** Registry keyed by the harness name `detectHarnessFromCommand` returns. */ 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 3079706aab..79d8925dd0 100644 --- a/packages/codev/src/agent-farm/servers/mailbox-delivery.ts +++ b/packages/codev/src/agent-farm/servers/mailbox-delivery.ts @@ -386,23 +386,81 @@ 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 - * 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 + * (`no-profile`) or a recognized app whose composer region cannot be resolved to a cell count + * (`no-region-end` / `no-region-start` / `no-composer-marker` = a drifted TUI layout or an + * unrenderable #1047 ring; `multi-row-draft` = a boxed composer the classifier could not count + * and had to judge by SHAPE). 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 * skipped tick and a real pass agree on what counts as classifier-stuck (CMAP round 3). * * This is the POLICY-side name for the same question `isUnverifiableVerdict` answers for the * presentation surfaces, so it delegates rather than restating the rule (maintainer review, * PR #1604). Two copies of "which verdicts never clear on their own" is one edit away from an - * escalation policy and an operator-facing remedy disagreeing about the same row. The wrapper - * is kept rather than collapsed to a single function because the two callers want different - * types: this one is typed on the DB/gate unions and reads naturally beside the escalation - * policy it serves, while the shared predicate takes the plain strings the CLI, the dashboard - * and the VS Code toast actually hold. + * escalation policy and an operator-facing remedy disagreeing about the same row — which is why + * Issue #1201's first pass, a local `CLASSIFIER_STUCK_DETAILS` record written before #1482 + * landed, was deleted rather than merged. The exhaustiveness that record bought is preserved as + * a TEST (`hold-verdict-exhaustive.test.ts`) that enumerates `GateVerdict['detail']` and fails + * when a new member goes unclassified, so the union still cannot grow silently. + * + * The wrapper is kept rather than collapsed to a single function because the two callers want + * different types: this one is typed on the DB/gate unions and reads naturally beside the + * escalation policy it serves, while the shared predicate takes the plain strings the CLI, the + * dashboard and the VS Code toast actually hold. + */ +/** + * Compile-time tripwire for the gate-detail union (Issue #1620). + * + * `isClassifierStuck` delegates to `isUnverifiableVerdict`, which lives in the SDK and is typed + * on `string | null` on purpose (the CLI reads these values back out of JSON). That typing is + * right, and it costs the one thing the deleted `CLASSIFIER_STUCK_DETAILS` record used to buy: + * a `Record` keyed on the union stops compiling when the union grows, so a new detail could not + * slip through unclassified. + * + * This restores that guard without restoring a second copy of the POLICY. The list below says + * "every one of these was reviewed against the escalation rule", not "here is the answer" — the + * answer stays in exactly one place. The runtime assertions on those answers live in + * `__tests__/hold-verdict-exhaustive.test.ts`, and this half has to be HERE rather than there + * because the `__tests__` glob is excluded from `tsc` (see this package's tsconfig `exclude`), + * which makes a `satisfies` in a test file decorative — it would have read like a guarantee and + * enforced nothing. + * + * Adding a member to `GateVerdict['detail']` breaks this until it is listed, at which point the + * next question is unavoidable: does it escalate? Removing one breaks it too, so the list cannot + * rot. */ +type ReviewedGateDetail = + | 'no-composer-marker' + | 'no-region-end' + | 'no-region-start' + | 'multi-row-draft' + | 'user-text' + | 'empty'; + +type AssertGateDetailsReviewed = + [GateVerdict['detail']] extends [ReviewedGateDetail] + ? [ReviewedGateDetail] extends [GateVerdict['detail']] + ? true + : ['ReviewedGateDetail lists a detail GateVerdict no longer has — remove it, and check isUnverifiableVerdict'] + : ['GateVerdict gained a detail — list it in ReviewedGateDetail and classify it in isUnverifiableVerdict (packages/sdk/src/hold-verdict.ts)']; + +/** + * And the same guard across the module boundary: `MailboxGateDetail` (what the mailbox COLUMN + * stores) and `GateVerdict['detail']` (what the classifier PRODUCES) are declared in different + * files and can drift apart. A detail the classifier emits that the column's type forbids is + * precisely the divergence #1482 was filed for. + */ +type AssertPersistableMatchesClassifier = + [MailboxGateDetail] extends [GateVerdict['detail']] + ? [Exclude] extends [MailboxGateDetail] + ? true + : ['the classifier can emit a hold detail MailboxGateDetail cannot store'] + : ['MailboxGateDetail allows a value the classifier never produces']; + +const gateDetailUnionsAgree: [AssertGateDetailsReviewed, AssertPersistableMatchesClassifier] = [true, true]; +void gateDetailUnionsAgree; + function isClassifierStuck( reason: MailboxReason | null, detail: GateVerdict['detail'] | undefined diff --git a/packages/codev/src/agent-farm/servers/mailbox-wiring.ts b/packages/codev/src/agent-farm/servers/mailbox-wiring.ts index 396295a2e0..877242dc4a 100644 --- a/packages/codev/src/agent-farm/servers/mailbox-wiring.ts +++ b/packages/codev/src/agent-farm/servers/mailbox-wiring.ts @@ -18,7 +18,7 @@ import { terminalDeliverySignals, type PtySession } from '../../terminal/pty-ses import type { SessionScreen } from '../../terminal/session-screen.js'; import { getWorkspaceTerminals, getTerminalManager } from './tower-terminals.js'; import { broadcastMessage, resolveAgentInRegistry, isResolveError } from './tower-messages.js'; -import { submitMessagePaced } from './message-write.js'; +import { submitMessagePaced, type MessagePacing } from './message-write.js'; import { bufferLines, classifyBuffer, type GateProfile, type GateVerdict } from './render-gate.js'; import { resolveProfile } from './gate-profiles.js'; import { @@ -26,6 +26,7 @@ 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'; @@ -169,6 +170,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 @@ -306,8 +354,15 @@ export function makeDeliveryPorts(log: LogFn): DeliveryPorts { // LEAF inside the per-agent serializer, so a gated delivery and a concurrent // `--interrupt`/`--escape` can no longer interleave. The precheck is the delivery // module's, re-run inside that lock. + // + // Issue #1201: per-harness Enter pacing is resolved HERE rather than passed through the + // port, because it is a property of the target session that no unit fake should have to + // know about — unlike `strategy`, which the delivery module derives from the gate profile + // it already holds. Resolution is total: every failure path degrades to the defaults. writeMessage: (session, msg, noEnter, precheck, strategy) => - submitMessagePaced(session, msg, noEnter, precheck, undefined, strategy), + submitMessagePaced( + session, msg, noEnter, precheck, undefined, strategy, resolvePacingForSession(session), + ), // Issue #1573: the only end-to-end evidence that the bytes reached the terminal — opened // before the write. Issue #1584: consulted AFTER the row is marked delivered, because the // commit is what makes a completed write un-repeatable; this only decides what we report. diff --git a/packages/codev/src/agent-farm/servers/message-write.ts b/packages/codev/src/agent-farm/servers/message-write.ts index f703aaf6bb..2a0684c547 100644 --- a/packages/codev/src/agent-farm/servers/message-write.ts +++ b/packages/codev/src/agent-farm/servers/message-write.ts @@ -170,6 +170,29 @@ 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 shared defaults assume. Kimi is the + * measured case: bisected live on 0.27.0, an Enter arriving **80 ms or 100 ms** after the body is + * swallowed and the message never submits; 120, 250, 500 and 1000 ms all submit (threshold + * ≈ 100–120 ms). `KIMI_ENTER_DELAY_MS` is pinned at 1000 ms for ~9x margin and re-verified + * submitting on 0.34.0 under agent-core-v2. + * + * When set, `enterDelayMs` replaces **both** default Enter delays — {@link SIMPLE_ENTER_DELAY_MS} + * on the short-frame branch and {@link PASTE_ENTER_DELAY_MS} on the long-frame one. Covering both + * is not tidiness: since Issue #1567 a long frame's Enter fires at `PASTE_ENTER_DELAY_MS` = 80 ms, + * which is the *first row of Kimi's bisect* — a value measured at 0/29 losses on claude 2.1.263 + * and codex 0.146.0, and measured fatal on Kimi. Overriding only the short branch would leave + * every real `afx send` broken, since a formatted message is almost always >= 4 lines. + * + * Nothing else moves: chunking, thresholds, the write strategy and the inter-piece gap are all + * unchanged, and the override only ever delays the Enter, never advances it. + */ +export interface MessagePacing { + enterDelayMs?: number; +} + /** * Write a message to a PTY session (Bugfix #584, Issue #1567). * @@ -183,6 +206,7 @@ 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( @@ -191,6 +215,7 @@ export function writeMessageToSession( noEnter: boolean, delayOffset = 0, strategy: WriteStrategy = BRACKETED_PASTE, + pacing?: MessagePacing, ): number { if (!isLongFrame(message)) { if (delayOffset === 0) { @@ -198,7 +223,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); } @@ -218,7 +243,11 @@ export function writeMessageToSession( const lastPieceTime = delayOffset + (pieces.length - 1) * gap; if (noEnter) return lastPieceTime; - const enterTime = lastPieceTime + PASTE_ENTER_DELAY_MS; + // Issue #1201: the per-harness override applies HERE too, not just on the short branch above. + // PASTE_ENTER_DELAY_MS is 80 ms — measured 0/29 losses on claude 2.1.263 and codex 0.146.0, and + // measured SWALLOWED on Kimi (bisect: 80 and 100 fail, 120+ submit). A formatted `afx send` is + // almost always >= 4 lines, so THIS is the branch a real message takes. See {@link MessagePacing}. + const enterTime = lastPieceTime + (pacing?.enterDelayMs ?? PASTE_ENTER_DELAY_MS); setTimeout(() => session.write('\r'), enterTime); return enterTime; } @@ -277,6 +306,7 @@ export async function submitMessagePaced( precheck: () => A | null, clock?: SubmitClock, strategy: WriteStrategy = BRACKETED_PASTE, + pacing?: MessagePacing, ): Promise> { // Fail LOUD on a missing id rather than keying the lock on `undefined`. Sessions reach // this through structurally-typed ports, so a double without an id compiles fine and @@ -311,7 +341,7 @@ export async function submitMessagePaced( () => { abort = precheck(); if (abort !== null) return 0; // refused in-lock: not one byte goes out - return writeMessageToSession(tracked, message, noEnter, 0, strategy); + return writeMessageToSession(tracked, message, noEnter, 0, strategy, pacing); }, clock, ); diff --git a/packages/codev/src/agent-farm/servers/render-gate.ts b/packages/codev/src/agent-farm/servers/render-gate.ts index 7f23bf80e4..fc3c7e06e3 100644 --- a/packages/codev/src/agent-farm/servers/render-gate.ts +++ b/packages/codev/src/agent-farm/servers/render-gate.ts @@ -110,6 +110,51 @@ 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. + * + * Bounds the SCAN only. It does not arm the multi-row-draft rule — see + * {@link growsWithDraft}, which is deliberately a separate opt-in. + */ + regionStartPatterns?: RegExp[]; + /** + * Declares a MEASURED property of this app's composer: its box grows a row only + * when the draft gains a line, so a region taller than one interior row proves + * unsent input. Arms the multi-row-draft rule in {@link classifyBuffer}, which is + * the only thing that catches a draft with zero countable cells (kimi: a newline + * then `>`, whose every cell is whitespace, box chrome, or an exempted marker). + * + * Set this ONLY from a live measurement covering the app's idle screen, its menus + * and pickers, and — the one that matters most — its steady state after a reply. + * The rule holds mail on shape alone, so an app that grows its box for any reason + * other than a draft line would hold every message forever: a liveness failure, + * not the fail-safe direction. For kimi 0.34.0 that measurement is + * `codev/spikes/pir-1201-kimi-box-growth.mjs`. + * + * Kept separate from {@link regionStartPatterns} because the two are unrelated + * properties that merely coincide for kimi, and the hazard is concrete rather than + * theoretical: the shipped `codex-idle.clean.txt` capture — a real, genuinely EMPTY + * codex composer — already spans two interior rows. Were arming folded into + * `regionStartPatterns`, the day anyone declared one for codex (a header bound, a + * boxed redesign) codex mail would stop delivering, silently. Requires + * `regionStartPatterns` to be set as well, since without a box top the row count + * measures distance to the status line rather than the composer's height. + */ + growsWithDraft?: true; /** * Optional per-app marker anchor: when true, the marker row must ALSO be the row * holding the buffer cursor. `markerPattern` alone is a text test, and a text test @@ -128,6 +173,10 @@ export interface GateProfile { * signal that separates agy's composer marker (palette 12, bright blue) from its * transcript echo of a submitted turn (palette 4 — measured, #1474). Left unset, the * marker cell's color is not examined. + * + * The cell sampled is the one at the marker MATCH's start column ({@link markerSpanStart}), + * not column 0 — so this anchor stays correct for a profile whose marker is not at the row + * start, which kimi's boxed `│ > ` is (Issue #1201). */ markerFgPalette?: number; /** @@ -152,10 +201,19 @@ 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; + * `multi-row-draft` = a boxed composer grown past one interior row, i.e. a + * multi-line draft, held on SHAPE because its cells can all be exempt chrome; + * `user-text` = a draft or menu occupies the composer; `empty` = clean. */ - detail: 'no-composer-marker' | 'no-region-end' | 'user-text' | 'empty'; + detail: + | 'no-composer-marker' + | 'no-region-end' + | 'no-region-start' + | 'multi-row-draft' + | 'user-text' + | 'empty'; } /** @@ -237,7 +295,10 @@ function findMarkerRow( if (profile.markerFgPalette !== undefined) { const line = buf.getLine(top + i); if (!line) continue; - line.getCell(0, cell); + // The marker GLYPH's own cell, wherever the profile puts it — not column 0. See + // {@link markerSpanStart}: a column-0 read is right for every marker anchored at the row + // start and wrong for a boxed composer like kimi's, whose `>` is at column 3. + line.getCell(markerSpanStart(lines[i], profile.markerPattern), cell); if (!cell.isFgPalette() || cell.getFgColor() !== profile.markerFgPalette) continue; } markerRow = i; @@ -245,6 +306,114 @@ function findMarkerRow( return markerRow; } +/** + * Column of the composer marker's GLYPH on its own row — the cell whose colour a + * `markerFgPalette` anchor examines. + * + * Not the match's start. Those coincide only for a pattern anchored directly at the glyph + * (claude `^❯`, codex `^›`, agy `^>`), and the first profile where they diverge is exactly the + * one this function exists for: kimi's marker is `/^\s*│\s*(>)/`, which matches from column 0 + * while its `>` sits at column 3. Returning `m.index` there samples a leading space — which is + * how the first version of this helper "generalized" the anchor off `getCell(0)` and changed + * nothing at all (caught in review; the trap it claimed to remove was still armed). + * + * So the glyph is identified EXPLICITLY, by a **named** group `(?…)`. Named rather than + * positional for a concrete reason, not tidiness: the first attempt used capture group 1, and + * agy's marker `/^>(\s|$)/` already had a group 1 — its trailing SEPARATOR. Every agy fixture + * went red at once, because the anchor started sampling the space after the marker instead of the + * marker. A positional convention collides with any incidental group; a named one cannot. + * Patterns with no `glyph` group keep `m.index`, correct for every marker anchored at its own + * glyph. The `d` flag is added on the fly for `indices`, and `g`/`y` stripped so a stateful + * profile regex cannot make the answer depend on a previous call's `lastIndex`. + * + * The same narrow-glyph argument {@link markerSpanEnd} rests on applies: every shipped marker + * pattern admits only single-column glyphs before its group, so a string index is a cell column. + * `0` when the pattern does not match, which cannot happen on a row that already passed the + * text test. + */ +export function markerSpanStart(line: string, pattern: RegExp): number { + const flags = pattern.flags.replace(/[gy]/g, ''); + const stateless = new RegExp(pattern.source, flags.includes('d') ? flags : flags + 'd'); + const m = stateless.exec(line); + if (!m) return 0; + const glyph = m.indices?.groups?.glyph; + return glyph ? glyph[0] : m.index; +} + +/** + * 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 (!hasRegionStart(startPatterns)) return markerRow; + for (let i = markerRow - 1; i >= 0; i--) { + if (startPatterns.some((p) => p.test(lines[i]))) return i + 1; + } + return -1; +} + +/** + * Does this profile declare a proven UPPER bound for its composer region? + * + * Shared by {@link findRegionStart} and the multi-row-draft rule in + * {@link classifyBuffer} on purpose: both must agree on what "bounded" means. If + * they disagreed, a profile with an empty pattern array would fall back to + * `startRow = markerRow` while still being treated as bounded — and the row-count + * rule would then fire on claude/codex, whose composer legitimately sits more than + * one row above its rule line. + */ +function hasRegionStart(patterns?: RegExp[]): patterns is RegExp[] { + return patterns !== undefined && patterns.length > 0; +} + /** * First region-ending row after the marker (the rule/status line beneath the * composer), or -1 when none is found. -1 means the composer has no proven lower @@ -370,16 +539,38 @@ 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' }; + } + // 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, ''), + ); 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 && @@ -395,9 +586,47 @@ export function classifyBuffer( } } - return userCells === 0 - ? { clean: true, detail: 'empty' } - : { clean: false, reason: 'busy', detail: 'user-text' }; + if (userCells > 0) return { clean: false, reason: 'busy', detail: 'user-text' }; + + // Zero countable cells is NOT yet proof of an empty composer. One draft shape has no + // countable cells at all: type a newline and then `>` and kimi renders + // + // │ > <- row 1, empty + // │ > <- row 2, matches the marker, so its `>` is span-exempted as chrome + // + // every cell being whitespace, box chrome, or an exempted marker. Bounding the region + // correctly does not help — the draft is real but literally uncountable — so the last + // evidence available is the composer's SHAPE. For a boxed composer the box grows a row + // only when the draft gains a line, so a region spanning more than one interior row is + // positive evidence of unsent input. Generalizes to any draft whose rows are all + // whitespace or whitespace+`>`. + // + // Sound only because box growth is EXCLUSIVE to multi-line drafts, which was measured + // on real kimi 0.34.0 rather than assumed (`codev/spikes/pir-1201-kimi-box-growth.mjs`): + // idle, a single-line draft, the `/` menu, the `@` picker and the post-reply steady + // state all hold at one interior row; only the newline drafts grow to two. The steady + // state is the load-bearing measurement — growth on a composer that has already carried + // a turn would hold every later message forever, a liveness bug rather than a fail-safe + // one. + // + // Placed AFTER the scan, not before it, so the cell count keeps its ground-truth role: + // a text-bearing multi-row draft still reports `user-text`, and this detail is reserved + // for the case the count is blind to. + // + // Two conditions, both required and each carrying its own half of the meaning: + // `growsWithDraft` is the app's MEASURED promise that box height tracks draft lines, + // and `hasRegionStart` is what makes `endRow - startRow` mean "interior rows" at all. + // Neither alone is sufficient, and the second is not academic — the shipped + // `codex-idle.clean.txt` capture is a genuinely EMPTY composer spanning two interior + // rows, so an unbounded profile reaching this line would hold real mail forever. + if ( + profile.growsWithDraft && + hasRegionStart(profile.regionStartPatterns) && + endRow - startRow > 1 + ) { + return { clean: false, reason: 'busy', detail: 'multi-row-draft' }; + } + return { clean: true, detail: 'empty' }; } /** diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index e681d8524e..8a4cad19f3 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -63,7 +63,12 @@ import { } from '../utils/message-format.js'; import type { PtySession } from '../../terminal/pty-session.js'; import { writeMessageToSession, writeEscapeToSession, writeStrategyForApp } from './message-write.js'; -import { makeDeliveryPorts, getMailboxDrainer, resolveProfileForSession } from './mailbox-wiring.js'; +import { + makeDeliveryPorts, + getMailboxDrainer, + resolveProfileForSession, + resolvePacingForSession, +} from './mailbox-wiring.js'; import { deliverAgentMailSerialized, type DeliveryOutcome, type DeliveryPorts } from './mailbox-delivery.js'; import { deliverCronMail, CRON_SENDER, type CronDeliveryResult } from './cron-delivery.js'; import { @@ -2108,6 +2113,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). @@ -2202,12 +2212,19 @@ async function handleSend( // Issue #1567: the same per-harness write strategy the gated path uses — an // interrupt to an opted-out harness must not be bracketed just because it bypassed // the gate. + // 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 (its paste-detection window swallows an Enter at the + // default 80/50 ms), which reads to a human as a silently ignored bypass — the worst + // failure mode for the one path that exists to override the gate. Resolution is + // advisory and total; a miss just means the default timing. return writeMessageToSession( session, formattedMessage, noEnter, 100, writeStrategyForApp(resolveProfileForSession(session)?.app), + resolvePacingForSession(session), ); }, undefined, diff --git a/packages/codev/src/agent-farm/types.ts b/packages/codev/src/agent-farm/types.ts index 10ef4eb725..ab5711e75f 100644 --- a/packages/codev/src/agent-farm/types.ts +++ b/packages/codev/src/agent-farm/types.ts @@ -258,6 +258,30 @@ export interface UserConfig { builderHarness?: string; shell?: string | string[]; }; + /** + * Per-harness SETTINGS for BUILT-IN harnesses (Issue #1620). + * + * Deliberately a separate namespace from `harness` below, which defines CUSTOM harnesses and + * whose every entry is validated at config load against a shape requiring `roleArgs` and + * `roleScriptFragment`. A settings-shaped entry there would throw during `loadConfig` and take + * unrelated commands down with it — and, because `resolveHarness` gives built-ins priority, a + * `harness.kimi` block is already inert config. A security opt-in does not belong in a + * namespace where the neighbouring key is silently ignored. + */ + harnessOptions?: { + kimi?: { + /** + * Pre-record kimi's workspace trust for builder worktrees Codev creates, so an unattended + * builder is not stranded on the 0.33.0+ "Trust this folder?" dialog. + * + * **Default false, and off means off.** Trust is exactly what gates loading MCP servers + * defined by the folder itself, which is a different boundary from the `--yolo` tool + * auto-approval a builder already runs with. Even when true, the pre-write is refused for + * any worktree that ships `.mcp.json` or `.kimi-code/mcp.json`. + */ + autoTrustWorkspace?: boolean; + }; + }; /** Custom harness provider definitions. Keys are harness names, values define role injection. */ harness?: Record`). Absent in worktree mode. + */ + builderId?: string; +} + export interface HarnessProvider { /** * For Node spawn() call sites (architect.ts, tower-utils.ts). @@ -69,6 +106,25 @@ 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. + * + * `opts.autoTrustWorkspace` (Issue #1620) is the operator's explicit consent, resolved from + * `.codev/config.json` by the CALLER rather than read here: a provider that loaded config + * itself would be untestable without a filesystem, and — more to the point — the consent + * decision belongs to the spawn path that knows which workspace it is spawning into. Absent + * or false means the side effect does not happen. + */ + prepareWorkspace?(worktreePath: string, opts?: { autoTrustWorkspace?: boolean }): 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 +189,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 +227,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 +342,495 @@ 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. + * + * It prints the NEWEST resumable session id rather than a bare yes/no, because + * the loop needs identity, not existence, to honor #1267's sticky-fresh contract: + * after a clean exit the superseded id is recorded, and `-c` is only taken when + * the newest id has since CHANGED (see the launch script). Existence alone cannot + * distinguish "the fresh conversation has started" from "the conversation the user + * deliberately ended is still the only one here". The boolean uses derive from + * "printed something", so there is one probe and one mirror, not two snippets. + * + * Printing the newest id is only meaningful because `kimi -c` continues the NEWEST + * session for the cwd — measured on 0.34.0 with two live sessions in one directory, + * confirmed by both a content oracle and a store-identity oracle, no prompt and no + * new session minted (`codev/spikes/pir-1201-kimi-continue-newest-probe.mjs`). + * + * Fails CLOSED: any error (no store, unreadable dir, malformed JSON) prints + * nothing, and an empty answer routes the loop to a fresh launch WITH the role, + * which is always safe. + * + * It mirrors {@link findLatestKimiSessionId} field for field — `readStateJson`'s + * PER-FIELD `typeof` check on `cwd` then `workDir` (not `cwd ?? workDir`, which + * short-circuits on a non-string `cwd` where discovery falls through), + * `sameDir`'s realpath tolerance, `isResumable`'s archived / `session_` filters, + * and now `parseTimestamp`'s ranking — because the two answer the same question in + * two languages and a divergence is a silent bug in EITHER direction: a probe that + * names a session discovery would not sends `-c` down its roleless + * nothing-to-continue path, and a probe that says no where discovery says yes + * restarts a crashed builder with no context. Naming the WRONG session is the new + * third direction, and it is the one this finding is about. The generated snippet + * is pinned against fixture stores by a unit test that EXECUTES it and asserts the + * printed id equals discovery's, so the mirroring cannot rot. + */ +const KIMI_NEWEST_SESSION_PROBE = + 'const {readdirSync,readFileSync,realpathSync}=require("fs"),{join}=require("path");' + + 'const r=join(process.env.KIMI_CODE_HOME||join(require("os").homedir(),".kimi-code"),"sessions");' + + // Mirrors realpathOrSelf(): canonicalize, falling back to the literal when + // realpath fails, so a symlinked worktree still matches. Deliberately does NOT + // pre-strip a trailing slash — realpathSync already normalizes one away for any + // directory that exists, and stripping first was the probe's only divergence + // from sameDir(): for a path that does NOT exist, `/ghost/` would canonicalize + // to `/ghost` here and stay `/ghost/` there, letting the probe name a session + // discovery would reject (the unsafe direction). + 'const n=p=>{try{return realpathSync(p)}catch{return p}};' + + 'const a0=process.argv[1],c=n(a0);' + + // Mirrors parseTimestamp(): finite number as-is, string via Date.parse, anything + // else unparseable. Ranking must match findLatestKimiSessionId or the script and + // the TypeScript would disagree about WHICH session `-c` is about to continue. + 'const ts=v=>typeof v==="number"?(Number.isFinite(v)?v:null):' + + 'typeof v==="string"?(Number.isNaN(Date.parse(v))?null:Date.parse(v)):null;' + + 'let ws=[];try{ws=readdirSync(r,{withFileTypes:true}).filter(e=>e.isDirectory())}catch{}' + + 'let bi=null,bt=-Infinity;' + + 'for(const w of ws){let ss=[];' + + // Each level gets its OWN try. A stray non-directory under sessions/ (a + // .DS_Store) made readdirSync throw ENOTDIR into the single outer try, which + // 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"));' + + // Per-FIELD typeof, exactly as readStateJson does. `j.cwd??j.workDir` diverged: + // a non-string `cwd` alongside a valid `workDir` short-circuits the fallback + // here while discovery still reads workDir, so the two disagreed on the winner. + 'if(j.archived===true)continue;' + + 'const d=typeof j.cwd==="string"?j.cwd:j.workDir;' + + 'if(typeof d!=="string"||(d!==a0&&n(d)!==c))continue;' + + // `?? -1` mirrors discovery: an unparseable timestamp ranks below every real + // epoch but above the -Infinity sentinel, so a lone malformed match still wins. + 'const k=ts(j.updatedAt)??-1;if(k>bt){bt=k;bi=s.name}}catch{}}}' + + 'if(bi===null)process.exit(1);' + + 'console.log(bi)'; + +export const KIMI_HARNESS: HarnessProvider = { + buildRoleInjection: () => { + 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 — but only with explicit consent + // and only when the worktree ships no project-level MCP config (Issue #1620; see + // ensureKimiWorkspaceTrust for why those are separate boundaries from `--yolo`). + // + // Every outcome is LOGGED, including the refusals. A builder stalled on the trust dialog is + // otherwise indistinguishable from a builder stalled on anything else, and the operator's next + // move differs completely between "you did not opt in" and "this worktree ships .mcp.json". + prepareWorkspace: (worktreePath, opts) => { + const decision = ensureKimiWorkspaceTrust(worktreePath, { + autoTrustWorkspace: opts?.autoTrustWorkspace === true, + }); + if (decision.wrote) { + logger.info(`kimi: pre-recorded workspace trust for ${worktreePath}`); + return; + } + if (decision.reason === 'already-trusted') return; // idempotent no-op, not worth a line + const level = decision.reason === 'write-failed' ? 'warn' : 'info'; + logger[level]( + `kimi: did NOT pre-record workspace trust (${decision.reason})` + + (decision.detail ? ` — ${decision.detail}` : ''), + ); + }, + + 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. +# +# ACCEPTED TRADEOFF in the other direction (architect review, 2026-08-09). The +# clean-exit reset assumes the first row was DELIVERED — the common case, but not +# a guarantee. Seeing a composer is necessary, not sufficient: the gate also has to +# have polled it EMPTY at least once. So the row can still be held if they quit at +# a screen that never rendered a composer (the 0.33.0 folder-trust dialog is the +# realistic case), or if they typed into the composer and quit within a couple of +# backstop ticks. The reset then queues a second identical row and both eventually +# deliver — one mission, stated twice. Left as-is deliberately: +# de-duplicating means either a delivery receipt the script cannot see or a +# mailbox-side identity check, and the failure is a duplicated instruction to an +# agent that has not started yet — recoverable by reading, unlike the crash-loop +# direction above, which floods a mailbox no one is draining. +codev_task_queued=0 +# How long to keep trying to queue the task before giving up and warning (Issue #1620). +# +# THE RACE THIS CLOSES. spawn.ts starts this session and only THEN calls upsertBuilder, while +# 'afx send' resolves its SENDER from cwd via detectCurrentBuilderId(), which THROWS when the +# builder has no row yet ("Refusing to send with an unverified identity" -- the #1094 +# anti-spoofing guard). Lose that race and afx exits non-zero, the branch below warns once, and +# nothing retries within this launch: the builder comes up with a role and no mission, and the +# only trace is a line in this pane. Before this loop, the sole thing preventing that was node's +# startup latency exceeding one local HTTP round-trip. +# +# A retry here rather than reordering the spawn path: the builder row carries terminal_id, which +# does not exist until the session is created, so hoisting upsertBuilder would mean two upserts +# on the path EVERY harness shares -- real blast radius for a kimi-only symptom. +codev_queue_deadline_secs="\${CODEV_TASK_QUEUE_DEADLINE_SECS:-30}" +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 --raw $codev_builder_id \\"\\$(cat $codev_task_file)\\"" >&2 + return 0 + fi + codev_waited=0 + while :; do + # --raw: this script runs INSIDE the worktree, so afx resolves the sender as this same + # builder id -- a self-send. Without --raw the spawn prompt would arrive wrapped in + # "### [BUILDER MESSAGE -> ] ###", an opening mission framed as a peer message + # from itself. .builder-prompt.txt is already a fully framed prompt; deliver it as itself. + if afx send --raw "$codev_builder_id" "$(cat "$codev_task_file")" >/dev/null 2>&1; then + codev_task_queued=1 + return 0 + fi + [ "$codev_waited" -ge "$codev_queue_deadline_secs" ] && break + sleep 2 + codev_waited=$((codev_waited + 2)) + done + printf '%s\\n' "WARNING: could not queue the builder's task after \${codev_queue_deadline_secs}s (is Tower running?)." >&2 + printf '%s\\n' " Retry with: afx send --raw $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 because `-c` with nothing to continue does not fail: it starts a + // fresh session that never saw --agent-file, i.e. a ROLELESS builder + // (verified, 0.34.0). The guard fails closed, so the fallback is always the + // role-carrying fresh launch. + // + // The guard compares session IDENTITY, not mere existence, to honor #1267's + // sticky-fresh contract ("clean exit → fresh rerun, no recovery"). Because + // `-c` is cwd-scoped rather than id-pinned, existence alone leaves a real gap: + // a clean exit relaunches fresh, 0.33.0+ mints no session until the first + // message lands, and a crash inside that pre-mint window would find the + // just-abandoned conversation still the newest one for the cwd and continue + // IT — resurrecting exactly what the user walked away from, and delivering the + // re-queued task into it. claude's loop closes this by minting a new id and + // never naming the superseded one; kimi cannot mint on demand, so the loop + // records the superseded id at clean exit and refuses `-c` until the newest id + // differs. A crash AFTER the new conversation mints resumes normally. + // + // Two edges this deliberately does NOT cover, both traced and both accepted: + // + // - The superseded id lives in the loop's memory, so it does not survive the + // terminal being closed and re-created. That is the intended boundary, not + // an oversight: `afx spawn --resume` means "resume this builder", and entry + // semantics are unchanged — a worktree holding a conversation is resumed. + // (claude's equivalent survives only because its id is persisted for the + // `--resume` pin; kimi writes no session id to disk by design.) + // - If the store GC'd the just-superseded session while an OLDER abandoned one + // for the same cwd survived, the newest id would differ from the superseded + // one and `-c` would continue that older conversation. It requires a GC that + // drops the NEWEST session while keeping older ones — the opposite of any + // plausible retention policy — so it is recorded rather than engineered + // against; closing it would mean accumulating every superseded id. + // + // And one accepted COST, in the other direction: when the clean-exit probe + // fails outright, `codev_resume_blocked` refuses `-c` for the rest of this + // loop's life rather than risk resurrecting the ended conversation. A later + // crash then restarts fresh instead of continuing, losing conversation + // continuity (never the role, and never the task — the mailbox still holds it). + // It self-heals at the next clean exit, which re-establishes a baseline. + return `#!/bin/bash +cd '${shellEscapeSingleQuote(ctx.worktreePath)}' +codev_fast_fail_secs="\${CODEV_LAUNCH_FAST_FAIL_SECS:-15}" + +${queueTask} + +# Prints the newest resumable session id for this cwd, or nothing. Empty output +# (no store, unreadable store, malformed json, no match) means "do not resume" — +# the fail-closed direction, whose fallback is the role-carrying fresh launch. +codev_newest_session() { + node -e '${KIMI_NEWEST_SESSION_PROBE}' "$PWD" 2>/dev/null +} + +# The id of the conversation the human deliberately ended, recorded at clean exit. +# Empty until then, which is why the same predicate serves script entry: with +# nothing superseded, "newest differs from superseded" reduces to "one exists". +codev_superseded_id='' +# Set when a clean exit could not read the store: we then have no baseline, so a +# later session cannot be told apart from the one just ended. Refuse to resume +# until the next clean exit re-establishes one. Fresh always carries the role, so +# the cost is losing crash-resume continuity, never losing the role. +codev_resume_blocked=0 + +codev_should_resume() { + [ "$codev_resume_blocked" = 1 ] && return 1 + # BOTH signals. The status is what makes this fail closed: stdout alone would + # accept anything written to it by something other than the probe (a node + # wrapper on PATH, NODE_OPTIONS=--require preloading an instrumentation module + # that prints), and a non-empty answer against an empty store sends the loop to + # "kimi -c" with nothing to continue — which does not fail, it starts a session + # that never saw --agent-file, i.e. the silently roleless builder this whole + # guard exists to prevent. Declared first, assigned second: a combined + # "local x=$(cmd)" would mask the substitution's status behind local's own. + local codev_newest + codev_newest=$(codev_newest_session) || return 1 + [ -n "$codev_newest" ] && [ "$codev_newest" != "$codev_superseded_id" ] +} + +codev_launch_fresh() { + 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_should_resume; 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 + # Retire the conversation the human just ended: until a NEW session mints, + # the crash branch must not treat this id as something to continue. Recorded + # after the Enter gate, while it is still the newest for this cwd — and after + # kimi has flushed state.json, rather than during its teardown. Re-recorded on + # every clean exit, so iterated quits supersede each conversation in turn. + # + # A FAILED probe here is not the same as an empty store: it means we could not + # read the baseline at all, and recording '' would leave the just-ended session + # comparing "different" on the next crash — resurrecting exactly what this + # branch exists to retire. So distinguish the two by status and block resume + # outright when the baseline is unknown. + if codev_prev_id=$(codev_newest_session); then + codev_superseded_id="$codev_prev_id" + codev_resume_blocked=0 + else + codev_resume_blocked=1 + fi + codev_launch=codev_launch_fresh + codev_task_queued=0 + codev_fast_fails=0 + 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 + # Deliberately does NOT say "with the original task": this branch does not reset + # codev_task_queued, so if the task DID reach the mailbox, codev_queue_task + # early-returns and nothing is re-queued. That is the correct behavior (an + # undelivered row PERSISTS on the mailbox; re-queueing would duplicate it) — but + # the operator has to be told which case they are in, and there are two, because + # the flag is only set on a SUCCESSFUL afx send. If queueing never succeeded (afx + # off PATH, Tower down) the flag is still 0 and the fresh launch below really does + # retry it, so an unconditional "still queued" would be a lie. + echo "Agent failing immediately (code $status). Starting a fresh conversation in 2 seconds... (Ctrl+C to quit)" + if [ "$codev_task_queued" = 1 ]; then + echo " The task is on the mailbox and is not re-queued; if it already reached the dead session, re-send it with 'afx send'." + else + echo " The task was never queued (see the warning above) — the fresh conversation will retry it." + fi + codev_launch=codev_launch_fresh + codev_fast_fails=0 + elif codev_should_resume; then + echo "Agent exited (code $status). Resuming the conversation in 2 seconds... (Ctrl+C to quit)" + codev_launch=codev_launch_resume + else + # Either nothing to continue, or the only thing to continue is the conversation + # the human ended — the pre-mint window after a clean exit. Fresh, both times. + echo "Agent exited (code $status) before starting a conversation. Relaunching fresh in 2 seconds... (Ctrl+C to quit)" + codev_launch=codev_launch_fresh + fi + sleep 2 +done +`; + }, + + messagePacing: { enterDelayMs: KIMI_ENTER_DELAY_MS }, +}; + /** * Exported for Spec 1273: `afx refresh` identifies a running builder's harness from * its launch script and must check `supportsContextReset` before typing into the @@ -224,6 +840,7 @@ export const BUILTIN_HARNESSES: Record = { claude: CLAUDE_HARNESS, codex: CODEX_HARNESS, opencode: OPENCODE_HARNESS, + kimi: KIMI_HARNESS, }; /** @@ -438,6 +1055,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 0000000000..9e66197cfb --- /dev/null +++ b/packages/codev/src/agent-farm/utils/kimi-session-discovery.ts @@ -0,0 +1,579 @@ +// 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`, + }; +} + +/** + * Project-level MCP config files kimi's folder trust actually gates. + * + * Trust decides ONE thing: whether kimi loads MCP servers defined by the folder itself. These + * are the two paths it reads them from, so their presence is what turns "pre-record trust" from + * a convenience into a capability grant. + * + * Deliberately a literal list rather than a glob: a wrong answer here fails in the unsafe + * direction (we pre-trust a worktree that ships servers), so the list should grow only from a + * documented kimi surface, never from a guess about where config *might* live. + */ +const KIMI_PROJECT_MCP_FILES = ['.mcp.json', join('.kimi-code', 'mcp.json')] as const; + +/** + * Why {@link ensureKimiWorkspaceTrust} did or did not write a trust record. + * + * A structured result rather than a boolean because the caller has to be able to LOG which + * refusal happened — "no record was written" covers a deliberate security refusal, an + * already-trusted worktree, and a failed write, and an operator debugging a builder stalled on + * the trust dialog needs to know which of the three they are looking at. + */ +export type KimiTrustDecision = + /** A record was written; kimi will open on a composer instead of the dialog. */ + | { wrote: true } + /** + * No record was written. `reason` is which of the four cases applies: + * + * - `not-opted-in` — the default. Pre-writing trust is off unless `.codev/config.json` sets + * `harnessOptions.kimi.autoTrustWorkspace`. + * - `project-mcp-config` — the worktree ships project-level MCP config, so trusting it grants + * "load these servers". Refused even when opted in: this is the one case where the trust + * decision is load-bearing, so it is the one case a human has to make. + * - `already-trusted` — idempotent no-op, an existing record is never rewritten. + * - `write-failed` — the store was unwritable. Fail-soft; never aborts a spawn. + */ + | { + wrote: false; + reason: 'not-opted-in' | 'project-mcp-config' | 'already-trusted' | 'write-failed'; + /** Human-readable specifics for the log line (e.g. which MCP file was found). */ + detail?: string; + }; + +/** Options for {@link ensureKimiWorkspaceTrust}. */ +export interface KimiTrustOpts extends KimiDiscoveryOpts { + /** + * Has the operator explicitly opted this workspace into automatic trust + * (`harnessOptions.kimi.autoTrustWorkspace` in `.codev/config.json`)? + * + * Defaults to **false**, and the default is the point: absent configuration must mean "do not + * grant anything", not "grant it quietly". A missing/omitted option and an explicit `false` + * are the same answer. + */ + autoTrustWorkspace?: boolean; +} + +/** + * The first project-level MCP config present in `root`, or null. + * + * Existence only — the file is never read or parsed. An unreadable or malformed `.mcp.json` is + * still a folder that is trying to define servers, and a parse error must not be the thing that + * decides we may trust it. + */ +function projectMcpConfigIn(root: string): string | null { + for (const rel of KIMI_PROJECT_MCP_FILES) { + try { + if (existsSync(join(root, rel))) return rel; + } catch { + // An unstattable path is not evidence of absence, but it is not evidence of presence + // either; keep looking and let the remaining checks decide. + } + } + return null; +} + +/** + * Pre-record workspace trust for a builder worktree (Issue #1201), if — and only if — both + * safety conditions hold. + * + * 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 NEVERTHELESS GATED (Issue #1620, the #1328 class). The original argument was that + * trust grants strictly less than `--yolo`, which the builder already runs with. That is true of + * *tool execution* and false of the thing trust actually controls: whether kimi loads MCP servers + * **defined by the folder**. Auto-approving tool calls and permitting a checkout to introduce new + * tool-providing processes are separate boundaries, and spawning a builder onto a contributor + * branch is a normal flow in this repository. So: + * + * 1. **Opt-in required** (`autoTrustWorkspace`, default false). Silence grants nothing. + * 2. **Refused outright when the worktree ships project-level MCP config**, opt-in or not — + * the one case where the decision has teeth is the one case a human makes. + * + * On any refusal the dialog simply appears: kimi shows it, the render gate classifies + * `no-composer-marker` and HOLDS the task message (never misdelivers it), and because that detail + * is in the escalation class the hold surfaces through the mailbox's liveness telemetry rather + * than hanging silently. The caller is expected to log the returned reason. + * + * CONSEQUENCE, worth stating plainly: a repository that ships a root `.mcp.json` hits rule 2 on + * every Kimi builder worktree, so unattended Kimi spawning does not work there until a human + * trusts the folder once. That is the intended posture, not an oversight. + * + * Idempotent (an existing record is left alone) and fail-soft (a write error is reported, never + * thrown) — a failure here must degrade to the CLI's normal behavior, never abort a spawn. + */ +export function ensureKimiWorkspaceTrust(root: string, opts?: KimiTrustOpts): KimiTrustDecision { + // Order matters: the security refusal is evaluated BEFORE the opt-in, so the log tells the + // operator the strongest true reason. Someone who has opted in and still sees no record needs + // to hear "this worktree ships MCP config", not "you did not opt in" — which would be false. + const mcp = projectMcpConfigIn(root); + if (mcp !== null) { + return { + wrote: false, + reason: 'project-mcp-config', + detail: + `${root} contains ${mcp}; kimi's folder trust is exactly what gates loading ` + + `project-defined MCP servers, so this decision is left to a human. kimi will show its ` + + `"Trust this folder?" dialog and the builder's task will be HELD (not lost) until then.`, + }; + } + + if (opts?.autoTrustWorkspace !== true) { + return { + wrote: false, + reason: 'not-opted-in', + detail: + 'automatic workspace trust is off by default; set harnessOptions.kimi.autoTrustWorkspace ' + + 'to true in .codev/config.json to pre-record trust for builder worktrees Codev creates.', + }; + } + + let file: string; + try { + file = kimiTrustRecordPath(root, opts); + if (existsSync(file)) return { wrote: false, reason: 'already-trusted' }; + } catch (err) { + return { wrote: false, reason: 'write-failed', detail: String(err) }; + } + + try { + mkdirSync(join(getKimiHome(opts), 'workspace-trust'), { recursive: true }); + writeFileSync(file, JSON.stringify({ root, trustedAt: Date.now() })); + return { wrote: true }; + } catch (err) { + return { wrote: false, reason: 'write-failed', detail: String(err) }; + } +} diff --git a/packages/codev/src/commands/doctor.ts b/packages/codev/src/commands/doctor.ts index 2e9dc57c54..1bc0e28e5b 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'; @@ -415,6 +417,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)', + }, + }, ]; /** @@ -601,6 +629,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'; /** @@ -894,8 +993,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'); @@ -914,6 +1015,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)')) { @@ -1005,6 +1123,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(''); diff --git a/packages/codev/src/lib/config.ts b/packages/codev/src/lib/config.ts index 2acd066874..3d25f20872 100644 --- a/packages/codev/src/lib/config.ts +++ b/packages/codev/src/lib/config.ts @@ -15,7 +15,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { homedir } from 'node:os'; -import { getFrameworkCacheDir as _getFrameworkCacheDir } from './skeleton.js'; +import { getFrameworkCacheDir as _getFrameworkCacheDir, findWorkspaceRoot } from './skeleton.js'; import { validateCustomHarnessConfig } from '../agent-farm/utils/harness.js'; import { validateConsultModels, @@ -38,6 +38,18 @@ export interface CodevConfig { builderHarness?: string; shell?: string | string[]; }; + /** + * Per-harness SETTINGS for BUILT-IN harnesses (Issue #1620). Separate from `harness` below, + * which defines CUSTOM harnesses and is validated at load against a shape requiring + * `roleArgs`/`roleScriptFragment` — a settings entry there would throw during `loadConfig`. + * See the identical block on `UserConfig` for the full reasoning. + */ + harnessOptions?: { + kimi?: { + /** Pre-record kimi workspace trust for Codev-created builder worktrees. Default false. */ + autoTrustWorkspace?: boolean; + }; + }; /** Custom harness provider definitions. Keys are harness names, values define role injection. */ harness?: Record)) { + if (!known.has(key)) { + throw new Error( + `Config "harnessOptions.${key}": unknown harness. Known: ${[...known].join(', ')}. ` + + `(Custom harness DEFINITIONS go under "harness", not "harnessOptions".)`, + ); + } + } + const kimi = (options as { kimi?: unknown }).kimi; + if (kimi === undefined) return; + if (typeof kimi !== 'object' || kimi === null || Array.isArray(kimi)) { + throw new Error(`Config "harnessOptions.kimi": expected an object, got ${Array.isArray(kimi) ? 'array' : typeof kimi}`); + } + const knownKimi = new Set(['autoTrustWorkspace']); + for (const [key, value] of Object.entries(kimi as Record)) { + if (!knownKimi.has(key)) { + throw new Error( + `Config "harnessOptions.kimi.${key}": unknown option. Known: ${[...knownKimi].join(', ')}.`, + ); + } + if (typeof value !== 'boolean') { + throw new Error( + `Config "harnessOptions.kimi.${key}": must be a boolean, got ${typeof value}. ` + + `This option gates a capability grant, so a non-boolean is rejected rather than coerced.`, + ); + } + } +} + +/** + * Is automatic kimi workspace-trust pre-recording enabled for this workspace (Issue #1620)? + * + * The single reader of that setting, so callers cannot each re-derive the default. Absent + * config, an absent block, and an explicit `false` all mean the same thing: **no**. + */ +export function kimiAutoTrustWorkspace(workspaceRoot?: string): boolean { + try { + // Same root resolution the harness lookup uses, so consent is read from the SAME config the + // spawn is configured by. A caller with no explicit root (worktree mode) must not silently + // read a different workspace's answer. + const root = workspaceRoot || findWorkspaceRoot(); + return loadConfig(root).harnessOptions?.kimi?.autoTrustWorkspace === true; + } catch { + // A config this broken will fail loudly elsewhere; it must not be the thing that decides + // we may grant trust. Unreadable config means the default, and the default is no. + return false; + } +} + /** * Report which config file supplied a given key path, for diagnostics. * diff --git a/packages/sdk/src/hold-verdict.ts b/packages/sdk/src/hold-verdict.ts index 4734b06a69..1aba1dcc7c 100644 --- a/packages/sdk/src/hold-verdict.ts +++ b/packages/sdk/src/hold-verdict.ts @@ -34,10 +34,27 @@ export function formatVerdict( /** * Is this verdict one the classifier could not resolve (Issue #1482)? * - * True for the defect class — `no-profile` (the app is unrecognized) and the two - * can't-verify details — and false for `user-text` (a human at the line) and for - * `no-live-pty` (no session at all). This is the "will it clear on its own?" question, and - * the answer decides which remedy an operator should reach for. + * True for the defect class — `no-profile` (the app is unrecognized) and the can't-verify + * details — and false for `user-text` (a human at the line) and for `no-live-pty` (no session + * at all). This is the "will it clear on its own?" question, and the answer decides which + * remedy an operator should reach for. + * + * Issue #1201 added two details, both for kimi's boxed composer: + * + * - `no-region-start` is the exact mirror of `no-region-end` — a composer whose box TOP is + * not on screen has no proven upper bound, so it is a torn frame or a drifted profile. + * Unverifiable for the same reason, with the same remedy. + * + * - `multi-row-draft` is the contested one, and it is TRUE deliberately. Every other detail + * is a cell COUNT; this is the one verdict the classifier reaches when it could not count + * (a draft of a newline then `>` has zero countable cells — all whitespace, box chrome, or + * an exempted marker) and had to infer from box GEOMETRY instead. "The classifier could not + * verify this" is therefore the truthful rendering, and a sustained streak of it is exactly + * the drift signal that the measured box-growth premise has failed on a newer kimi. + * Accepted cost, stated so nobody rediscovers it as a bug: a human genuinely sitting on a + * multi-line kimi draft contributes to a liveness streak. `surfaceLiveness` only alarms on + * recent output, which suppresses most of that — and, symmetrically, part of the drift case + * too, which is why a `codev doctor` premise probe for box growth is tracked separately. * * The delivery module's `isClassifierStuck` DELEGATES to this — it is a thin wrapper typed on * the DB/gate unions, kept because it reads naturally beside the escalation policy it serves. @@ -49,5 +66,11 @@ export function isUnverifiableVerdict( reason: string | null | undefined, detail: string | null | 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' || + detail === 'multi-row-draft' + ); }