From 3c6a15a58637dbc64fa0d48dabefc9c8b41f49a9 Mon Sep 17 00:00:00 2001 From: Chris Alatorre Date: Wed, 24 Jun 2026 23:52:12 -0700 Subject: [PATCH 1/6] feat: add kimi harness adapter Add kimi-code 0.19.2 as a verified harness adapter (claude/codex/opencode/pi/kimi). Mechanics (bin/): - fm-spawn.sh: `kimi --yolo` launch template; per-task KIMI_CODE_HOME at state/.kimi-home that symlinks ~/.kimi-code (sharing the captain's login) except config.toml, which is copied and gets a merged Stop hook; brief injected into the interactive composer because --prompt conflicts with --yolo. - fm-kimi-merge-hook.sh: safely append the turn-end Stop hook to the copied config. - fm-harness.sh: detect kimi by command name in process ancestry (no env marker). - fm-teardown.sh: remove state/.kimi-home (including secondmate children). - fm-watch.sh / fm-tmux-lib.sh: kimi busy signatures (thinking.../working...). - fm-tmux-lib.sh: fm_tmux_inject_brief + fm_tmux_composer_box_state. Brief submission is verified against the whole composer box, not the single cursor row, because kimi parks the cursor on a blank box row; Enter is retried through pi-tui cold start so a dropped first Enter no longer silently drops the brief. Knowledge: AGENTS.md section 4 kimi table; README prerequisites. Verified live (kimi-code 0.19.2): launch, login sharing, composer brief injection and submission, the Stop turn-end hook, and teardown cleanup. --- AGENTS.md | 16 +++ README.md | 2 +- bin/fm-harness.sh | 2 + bin/fm-kimi-merge-hook.sh | 46 +++++++ bin/fm-spawn.sh | 40 +++++- bin/fm-teardown.sh | 2 + bin/fm-tmux-lib.sh | 118 +++++++++++++++- bin/fm-watch.sh | 5 +- docs/configuration.md | 2 +- tests/fm-kimi-inject.test.sh | 222 +++++++++++++++++++++++++++++++ tests/fm-kimi-merge-hook.test.sh | 128 ++++++++++++++++++ tests/fm-kimi-spawn.test.sh | 218 ++++++++++++++++++++++++++++++ tests/fm-kimi-teardown.test.sh | 123 +++++++++++++++++ 13 files changed, 915 insertions(+), 9 deletions(-) create mode 100755 bin/fm-kimi-merge-hook.sh create mode 100755 tests/fm-kimi-inject.test.sh create mode 100755 tests/fm-kimi-merge-hook.test.sh create mode 100755 tests/fm-kimi-spawn.test.sh create mode 100755 tests/fm-kimi-teardown.test.sh diff --git a/AGENTS.md b/AGENTS.md index 80700a2..a02dec6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,22 @@ If `config/crew-harness` names an unverified one, tell the captain and fall back If the captain asks for a new harness, load `harness-adapters`, verify it empirically with a trivial supervised task, then commit the script and knowledge changes. Load `harness-adapters` before any spawn, recovery, trust-dialog handling, harness-specific skill invocation, interrupt, exit, resume, or adapter verification. +### kimi (VERIFIED 2026-06-25, kimi-code 0.19.2) + +| Fact | Value | +|---|---| +| Busy-pane signature | `thinking...` (reasoning) and `working...` (generating), each with a leading braille spinner | +| Exit commands | `/exit` and `/quit` | +| Interrupt | single Escape | +| Autonomy flag | `--yolo` | +| Skill invocation | `/` (e.g., `/no-mistakes`) | +| Turn-end hook | TOML `[[hooks]] event="Stop" command="touch "` | +| Brief injection | Must be typed into the interactive composer after launch; `--prompt` conflicts with `--yolo` | +| Per-task home | `state/.kimi-home/`; symlink everything from `~/.kimi-code` except `config.toml`; set `KIMI_CODE_HOME` | +| First-run auth | Captain must run `kimi login` so `~/.kimi-code` exists; no per-directory trust dialog observed | + +No verified environment marker was found for harness detection in v0.19.2; `bin/fm-harness.sh` matches the command name (`*kimi*`) in process ancestry. + ## 5. Recovery (run at every session start, after bootstrap) You may have been restarted mid-flight. diff --git a/README.md b/README.md index ceb14ff..77de50c 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m ## Quick Start -**Requirements:** a verified agent harness (claude, codex, opencode, or pi), git with GitHub auth, and tmux for the crew windows. +**Requirements:** a verified agent harness (claude, codex, opencode, pi, or kimi), git with GitHub auth, and tmux for the crew windows. The first mate detects and offers to install everything else. ```sh diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 703c9a6..eaff626 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -24,6 +24,7 @@ detect_own() { *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; + *kimi*) echo kimi; return ;; pi) echo pi; return ;; node*|python*) # Bare interpreter: match the harness name in its script path. @@ -32,6 +33,7 @@ detect_own() { *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; + *kimi*) echo kimi; return ;; *" pi "*|*/pi) echo pi; return ;; esac ;; esac diff --git a/bin/fm-kimi-merge-hook.sh b/bin/fm-kimi-merge-hook.sh new file mode 100755 index 0000000..bdeb32e --- /dev/null +++ b/bin/fm-kimi-merge-hook.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Safely add a Stop hook to a kimi config.toml. +# Usage: fm-kimi-merge-hook.sh +# Adds a [[hooks]] Stop entry that touches . +# Defensive behavior: +# - Ensures the file ends with a newline. +# - If the file already contains a [[hooks]] section, appends the Stop entry +# as another array-of-tables element (valid TOML). +# - If the file contains a bare [hooks] table, errors out (unsupported merge). +# - If a Stop hook with the same command is already present, does not duplicate it. +set -u + +if [ "$#" -ne 2 ]; then + echo "usage: fm-kimi-merge-hook.sh " >&2 + exit 2 +fi + +CONFIG=$1 +TURNEND=$2 + +[ -f "$CONFIG" ] || { echo "error: config file not found: $CONFIG" >&2; exit 1; } + +# Ensure the file ends with a newline so the appended TOML is well-formed. +if [ -s "$CONFIG" ]; then + if [ "$(tail -c 1 "$CONFIG" | od -An -tx1 | tr -d ' ')" != "0a" ]; then + printf '\n' >> "$CONFIG" + fi +fi + +# A bare [hooks] table conflicts with the [[hooks]] array-of-tables we need. +if grep -qE '^\[hooks\]' "$CONFIG"; then + echo "error: $CONFIG contains a bare [hooks] table; cannot merge Stop hook" >&2 + exit 1 +fi + +# Do not duplicate an identical Stop hook command. +# The command is distinctive (touch ), so its presence is enough. +if grep -qF "command = \"touch $TURNEND\"" "$CONFIG"; then + exit 0 +fi + +{ + printf '\n[[hooks]]\n' + printf 'event = "Stop"\n' + printf 'command = "touch %s"\n' "$TURNEND" +} >> "$CONFIG" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 38747d5..4707eab 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -5,7 +5,7 @@ # fm-spawn.sh [] [harness|launch-command] --secondmate # With no harness arg, the harness comes from fm-harness.sh crew (config/crew-harness, # falling back to firstmate's own harness). A bare adapter name (claude|codex| -# opencode|pi) overrides it for this spawn. A non-flag string containing whitespace +# opencode|pi|kimi) overrides it for this spawn. A non-flag string containing whitespace # is treated as a RAW launch command - the escape hatch for verifying new adapters. # --scout records kind=scout in the task's meta (report deliverable, scratch worktree; # see AGENTS.md task lifecycle); --secondmate records kind=secondmate and launches in a @@ -41,6 +41,8 @@ PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" SUB_HOME_MARKER=".fm-secondmate-home" # shellcheck source=bin/fm-ff-lib.sh . "$SCRIPT_DIR/fm-ff-lib.sh" +# shellcheck source=bin/fm-tmux-lib.sh +. "$SCRIPT_DIR/fm-tmux-lib.sh" # Skip the watcher guard when re-exec'd for one pair of a batch (FM_SPAWN_NO_GUARD is # set by the batch loop below), so the guard runs once for the batch, not once per pair. [ -n "${FM_SPAWN_NO_GUARD:-}" ] || "$FM_ROOT/bin/fm-guard.sh" || true @@ -88,7 +90,7 @@ FIRSTMATE_HOME= if [ "$KIND" = secondmate ]; then case "${POS[1]:-}" in - ''|claude|codex|opencode|pi) + ''|claude|codex|opencode|pi|kimi) ARG3=${POS[1]:-} ;; *' '*) @@ -140,6 +142,7 @@ launch_template() { printf '%s' 'pi -e __PIEXT__ "$(cat __BRIEF__)"' fi ;; + kimi) printf '%s' 'kimi --yolo' ;; *) return 1 ;; esac } @@ -446,6 +449,33 @@ EOF codex*) # codex: turn-end rides the launch command via -c notify=[...] and __TURNEND__. ;; + kimi*) + # kimi has no project-local config.toml; use a per-task KIMI_CODE_HOME that + # symlinks the captain's credentials/sessions but carries a copied config + # with a Stop hook that touches the turn-end marker. + CAPTAIN_HOME="${HOME:-}/.kimi-code" + if [ ! -d "$CAPTAIN_HOME" ]; then + echo "error: ~/.kimi-code not found; run 'kimi login' first" >&2 + exit 1 + fi + KIMI_HOME="$STATE/$ID.kimi-home" + rm -rf "$KIMI_HOME" + mkdir -p "$KIMI_HOME" + for entry in "$CAPTAIN_HOME"/*; do + [ -e "$entry" ] || continue + name=$(basename "$entry") + if [ "$name" = "config.toml" ]; then + cp -a "$entry" "$KIMI_HOME/config.toml" + else + ln -s "$entry" "$KIMI_HOME/$name" + fi + done + if [ ! -f "$KIMI_HOME/config.toml" ]; then + touch "$KIMI_HOME/config.toml" + fi + "$FM_ROOT/bin/fm-kimi-merge-hook.sh" "$KIMI_HOME/config.toml" "$TURNEND" || exit 1 + LAUNCH="KIMI_CODE_HOME=$(shell_quote "$KIMI_HOME") $LAUNCH" + ;; esac fi @@ -494,4 +524,10 @@ tmux send-keys -t "$T" -l "$LAUNCH" sleep 0.3 tmux send-keys -t "$T" Enter +if [ "$HARNESS" = kimi ]; then + # kimis's brief cannot ride the launch command (--prompt conflicts with --yolo), + # so inject it into the interactive composer once the TUI is ready. + fm_tmux_inject_brief "$T" "$BRIEF" || exit 1 +fi + echo "spawned $ID harness=$HARNESS kind=$KIND mode=$MODE yolo=$YOLO window=$T worktree=$WT" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ddd0a6d..317b2e5 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -380,6 +380,7 @@ cleanup_firstmate_home_children() { fi fi rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" "$sub_state/$child_id.check.sh" "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" + rm -rf "$sub_state/$child_id.kimi-home" done } @@ -480,6 +481,7 @@ if [ "$KIND" = secondmate ]; then remove_secondmate_registry_entry "$ID" fi rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" +rm -rf "$STATE/$ID.kimi-home" if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 374e358..6a6d4db 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -29,14 +29,17 @@ # # Per-harness override: FM_COMPOSER_IDLE_RE matches an empty composer after # dim-ghost and structural border stripping. FM_BUSY_REGEX overrides the busy -# footer set (mirrors fm-watch.sh / the daemon). +# footer set (mirrors fm-watch.sh / the daemon). FM_INJECT_SUBMIT_RETRIES and +# FM_INJECT_SUBMIT_SLEEP tune how long fm_tmux_inject_brief retries Enter while a +# cold-starting composer (e.g. kimi/pi-tui) drops the first keypresses. # # All functions are `set -u` and `set -e` safe (guarded tmux calls, explicit # returns) so they can be sourced into either context. # Busy footers per harness (mirror fm-watch.sh). claude/codex: "esc to -# interrupt"; opencode: "esc interrupt"; pi: "Working...". -FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.' +# interrupt"; opencode: "esc interrupt"; pi: "Working..."; kimi: +# "thinking..." / "working...". +FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|thinking\.\.\.|working\.\.\.' # fm_tmux_strip_ghost: remove dim/faint (ANSI SGR 2) styled runs from one captured # composer line, then drop any remaining escape sequences, leaving only the plain, @@ -190,3 +193,112 @@ fm_tmux_submit_core() { # sleep "$settle" fm_tmux_submit_enter_core "$target" "$retries" "$sleep_s" } + +# fm_tmux_composer_box_state: classify the agent's whole composer input BOX as +# empty|pending|unknown by scanning the bottom of the pane for the composer's +# prompt row, instead of trusting a single cursor-row read. +# +# Why this exists (kimi / pi-tui): fm_tmux_composer_state reads only the cursor_y +# row. kimi draws a multi-row composer box and parks the cursor on a BLANK +# continuation row below the prompt, so a single-row read reports "empty" while +# the typed brief still sits on the prompt row above. Used to verify a brief +# actually submitted — where a false "empty" silently drops the brief — so it must +# find the prompt row wherever it is, not assume it is the cursor line. +# +# The prompt row is the last captured line that, after dropping dim ghost text and +# box borders, begins with a composer prompt glyph (">" or "❯"). Text after the +# glyph is pending input; just the glyph is an empty composer. If no such row is +# found (the box has not rendered yet) the state is unknown, so a not-yet-ready +# pane is never mistaken for a ready, empty one. +fm_tmux_composer_box_state() { # -> empty|pending|unknown + local target=$1 raw cleaned line stripped rest found=0 state=unknown + raw=$(tmux capture-pane -e -p -t "$target" -S -12 2>/dev/null) || { printf 'unknown'; return 0; } + cleaned=$(printf '%s\n' "$raw" | fm_tmux_strip_ghost) + while IFS= read -r line; do + stripped=${line//│/} + stripped=${stripped//┃/} + stripped=${stripped//|/} + stripped="${stripped#"${stripped%%[![:space:]]*}"}" + stripped="${stripped%"${stripped##*[![:space:]]}"}" + case "$stripped" in + '>'*|'❯'*) + found=1 + rest=$stripped + rest=${rest#'>'} + rest=${rest#'❯'} + rest="${rest#"${rest%%[![:space:]]*}"}" + if [ -n "$rest" ]; then state=pending; else state=empty; fi + ;; + esac + done < [] +# tmux target (session:window) +# path to the brief to inject +# seconds to wait for a ready composer (default 30) +# Returns 0 on success, 1 if the composer never becomes ready or the brief cannot be sent. +# Multi-line briefs are pasted via tmux paste-buffer; single-line briefs use send-keys -l. +fm_tmux_inject_brief() { # [] + local target=$1 brief=$2 timeout=${3:-30} waited=0 line_count + local attempts sleep_s i box + # Wait for the actual composer BOX to render empty, not just for the cursor row + # to read blank. On a cold-starting pane the cursor line is blank before the TUI + # draws its composer, which a single-row check mistakes for a ready, empty + # composer and injects into a pane that cannot yet accept input. + while [ "$waited" -lt "$timeout" ]; do + [ "$(fm_tmux_composer_box_state "$target")" = empty ] && break + sleep 1 + waited=$((waited + 1)) + done + if [ "$waited" -ge "$timeout" ]; then + printf 'error: composer never became ready for brief injection in %s\n' "$target" >&2 + return 1 + fi + line_count=$(awk 'END { print NR }' "$brief" 2>/dev/null || echo 0) + if [ "$line_count" -gt 1 ]; then + # Multi-line: paste so internal newlines do not submit early. + if ! tmux load-buffer -b __fm_brief - < "$brief" 2>/dev/null; then + printf 'error: failed to load brief into tmux paste buffer\n' >&2 + return 1 + fi + if ! tmux paste-buffer -b __fm_brief -t "$target" 2>/dev/null; then + printf 'error: failed to paste brief into %s\n' "$target" >&2 + return 1 + fi + else + # Single-line: type literally. + if ! tmux send-keys -t "$target" -l "$(cat "$brief")" 2>/dev/null; then + printf 'error: failed to send brief to %s\n' "$target" >&2 + return 1 + fi + fi + # Submit and verify the brief actually LEFT the composer. Two kimi-specific + # hazards make a single Enter + cursor-row check unreliable: + # 1. kimi parks the cursor on a blank composer row, so emptiness must be judged + # against the whole composer box (fm_tmux_composer_box_state), not the cursor + # line — otherwise a still-full composer reads as "submitted" and the brief + # is silently dropped while spawn reports success. + # 2. kimi (pi-tui) can drop the first Enter(s) during cold start, so Enter is + # retried (only — never retyped, which would duplicate the brief) until the + # box clears or the window ends. + attempts=${FM_INJECT_SUBMIT_RETRIES:-30} + sleep_s=${FM_INJECT_SUBMIT_SLEEP:-0.5} + i=0 + while :; do + tmux send-keys -t "$target" Enter 2>/dev/null || true + sleep "$sleep_s" + box=$(fm_tmux_composer_box_state "$target") + [ "$box" = empty ] && return 0 + i=$((i + 1)) + if [ "$i" -ge "$attempts" ]; then + printf 'error: brief typed but composer never cleared (submit not confirmed) in %s\n' "$target" >&2 + return 1 + fi + done +} diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index bd03bec..726604f 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -75,8 +75,9 @@ SIGNAL_GRACE=${FM_SIGNAL_GRACE:-30} # seconds to linger after a signal so trai # signals (a status write, then the same turn's # turn-end hook) coalesce into one wake # Busy signatures per harness, OR-ed. Extend via env when new adapters are verified. -# claude/codex: "esc to interrupt"; opencode: "esc interrupt"; pi: "Working..." -BUSY_REGEX=${FM_BUSY_REGEX:-'esc (to )?interrupt|Working\.\.\.'} +# claude/codex: "esc to interrupt"; opencode: "esc interrupt"; pi: "Working..."; +# kimi: "thinking..." / "working..." +BUSY_REGEX=${FM_BUSY_REGEX:-'esc (to )?interrupt|Working\.\.\.|thinking\.\.\.|working\.\.\.'} hash_pane() { if command -v md5 >/dev/null 2>&1; then md5 -q; else md5sum | cut -d' ' -f1; fi diff --git a/docs/configuration.md b/docs/configuration.md index 5112407..0f79e78 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,7 +39,7 @@ When `FM_HOME` is unset, it also behaves as the old whole-root override. ## Harness support -claude, codex, opencode, and pi are all empirically verified; new harnesses get verified through a supervised trial task before joining the set. +claude, codex, opencode, pi, and kimi are all empirically verified; new harnesses get verified through a supervised trial task before joining the set. The verified adapter knowledge - busy signatures, interrupt and exit commands, skill-invocation syntax, and per-harness quirks - lives in [`.agents/skills/harness-adapters/SKILL.md`](../.agents/skills/harness-adapters/SKILL.md). Launch mechanics, including the verified command templates, live in [`bin/fm-spawn.sh`](../bin/fm-spawn.sh). diff --git a/tests/fm-kimi-inject.test.sh b/tests/fm-kimi-inject.test.sh new file mode 100755 index 0000000..f14ecbd --- /dev/null +++ b/tests/fm-kimi-inject.test.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# Tests for fm_tmux_inject_brief in bin/fm-tmux-lib.sh. +# +# Uses a fake tmux to verify single-line vs multi-line brief injection and timeout +# behavior without a real agent or tmux server. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LIB="$ROOT/bin/fm-tmux-lib.sh" + +# shellcheck source=bin/fm-tmux-lib.sh +. "$LIB" + +TMP_ROOT= +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } +cleanup() { [ -n "${TMP_ROOT:-}" ] && rm -rf "$TMP_ROOT"; } +trap cleanup EXIT + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-kimi-inject.XXXXXX") + +# Build a fake tmux that records every send-keys/load-buffer/paste-buffer call +# and reads composer state from FM_FAKE_STATE (empty|pending|unknown). +make_fake_tmux() { # + local dir=$1 fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/tmux" <> "$dir/tmux.log"; } +case "\${1:-}" in + display-message) + for a in "\$@"; do + case "\$a" in *cursor_y*) printf '%s\n' "\${FM_FAKE_CY:-0}"; exit 0 ;; esac + done + printf 'faketarget\n'; exit 0 ;; + capture-pane) + case "\${FM_FAKE_STATE:-empty}" in + empty) printf '\\xe2\\x94\\x82 > \\xe2\\x94\\x82\\n' ;; + pending) printf '\\xe2\\x9d\\xaf hello\\n' ;; + *) printf '\\n' ;; + esac + exit 0 ;; + send-keys) + log "send-keys \$*" + # Strip -t and -l; record the literal text if present. + shift + target= + literal=0 + text= + while [ "\$#" -gt 0 ]; do + case "\$1" in + -t) shift; target=\$1 ;; + -l) literal=1 ;; + Enter) log "send-keys-enter target=\$target" ;; + *) text="\$1" ;; + esac + shift + done + if [ "\$literal" -eq 1 ] && [ -n "\$text" ]; then + printf '%s' "\$text" > "$dir/sent-literal.txt" + fi + exit 0 ;; + load-buffer) + log "load-buffer \$*" + cat > "$dir/paste-buffer.txt" + exit 0 ;; + paste-buffer) + log "paste-buffer \$*" + exit 0 ;; + list-windows) exit 0 ;; +esac +exit 1 +SH + chmod +x "$fb/tmux" + printf '%s\n' "$fb" +} + +# Build a fake tmux that models kimi's real composer: a multi-row box where typed +# text sits on the PROMPT row while the cursor parks on a blank continuation row, +# and the first Enters are dropped (pi-tui cold start). The composer only clears +# after FM_FAKE_ENTERS_REQUIRED Enters. This is the shape that made a single-row +# (cursor-line) emptiness check falsely report "submitted" after one dropped Enter, +# silently dropping the brief. Injection must instead judge the whole box and retry +# Enter until it actually clears. +make_fake_tmux_kimi() { # + local dir=$1 fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/tmux" < the brief text \\xe2\\x94\\x82\\n' + printf '\\xe2\\x94\\x82 \\xe2\\x94\\x82\\n' + else + printf '\\xe2\\x94\\x82 > \\xe2\\x94\\x82\\n' + printf '\\xe2\\x94\\x82 \\xe2\\x94\\x82\\n' + fi + exit 0 ;; + send-keys) + shift + literal=0; text= + while [ "\$#" -gt 0 ]; do + case "\$1" in + -t) shift ;; + -l) literal=1 ;; + Enter) + n=0; [ -f "\$DIR/enters.txt" ] && n=\$(cat "\$DIR/enters.txt") + printf '%s' "\$((n + 1))" > "\$DIR/enters.txt" ;; + *) text="\$1" ;; + esac + shift + done + if [ "\$literal" -eq 1 ] && [ -n "\$text" ]; then printf '%s' "\$text" > "\$DIR/typed.txt"; fi + exit 0 ;; + list-windows) exit 0 ;; +esac +exit 1 +SH + chmod +x "$fb/tmux" + printf '%s\n' "$fb" +} + +test_retries_enter_until_multirow_composer_clears() { + local dir fb brief enters + dir="$TMP_ROOT/multirow" + mkdir -p "$dir" + fb=$(make_fake_tmux_kimi "$dir") + brief="$dir/brief.md" + printf 'the brief text' > "$brief" + + PATH="$fb:$PATH" FM_FAKE_ENTERS_REQUIRED=3 FM_INJECT_SUBMIT_SLEEP=0.05 \ + fm_tmux_inject_brief "sess:win" "$brief" || fail "injection failed on multi-row composer" + + [ -f "$dir/typed.txt" ] || fail "brief was never typed" + enters=$(cat "$dir/enters.txt" 2>/dev/null || echo 0) + # The old single-row check would have stopped after one (dropped) Enter and + # falsely reported success. The box-aware check must retry until the box clears. + [ "$enters" -ge 3 ] || fail "Enter was not retried until the composer cleared (sent $enters)" + pass "injection retries Enter until kimi's multi-row composer actually clears" +} + +test_single_line_uses_send_keys() { + local dir fb brief + dir="$TMP_ROOT/single" + mkdir -p "$dir" + fb=$(make_fake_tmux "$dir") + brief="$dir/brief.md" + printf 'fix the flaky login test' > "$brief" + + PATH="$fb:$PATH" FM_FAKE_STATE=empty FM_FAKE_CY=0 \ + fm_tmux_inject_brief "sess:win" "$brief" || fail "single-line injection failed" + + [ -f "$dir/sent-literal.txt" ] || fail "single-line brief was not sent with send-keys -l" + [ "$(cat "$dir/sent-literal.txt")" = "fix the flaky login test" ] || fail "sent literal did not match brief" + grep -q 'send-keys-enter' "$dir/tmux.log" || fail "Enter was not sent after single-line brief" + pass "single-line brief uses send-keys -l + Enter" +} + +test_multi_line_uses_paste_buffer() { + local dir fb brief + dir="$TMP_ROOT/multi" + mkdir -p "$dir" + fb=$(make_fake_tmux "$dir") + brief="$dir/brief.md" + printf 'line one\nline two\nline three\n' > "$brief" + + PATH="$fb:$PATH" FM_FAKE_STATE=empty FM_FAKE_CY=0 \ + fm_tmux_inject_brief "sess:win" "$brief" || fail "multi-line injection failed" + + [ -f "$dir/paste-buffer.txt" ] || fail "multi-line brief was not loaded into paste buffer" + [ "$(cat "$dir/paste-buffer.txt")" = "$(cat "$brief")" ] || fail "paste buffer did not match brief" + grep -q 'load-buffer' "$dir/tmux.log" || fail "load-buffer was not called" + grep -q 'paste-buffer' "$dir/tmux.log" || fail "paste-buffer was not called" + grep -q 'send-keys-enter' "$dir/tmux.log" || fail "Enter was not sent after paste" + ! [ -f "$dir/sent-literal.txt" ] || fail "multi-line brief wrongly used send-keys -l" + pass "multi-line brief uses load-buffer + paste-buffer + Enter" +} + +test_times_out_when_composer_never_ready() { + local dir fb brief rc + dir="$TMP_ROOT/timeout" + mkdir -p "$dir" + fb=$(make_fake_tmux "$dir") + brief="$dir/brief.md" + printf 'brief text' > "$brief" + + set +e + PATH="$fb:$PATH" FM_FAKE_STATE=pending FM_FAKE_CY=0 \ + fm_tmux_inject_brief "sess:win" "$brief" 2 2>"$dir/err" + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "injection should have timed out" + grep -q 'never became ready' "$dir/err" || fail "timeout error message missing" + pass "injection times out when composer never becomes ready" +} + +test_single_line_uses_send_keys +test_multi_line_uses_paste_buffer +test_retries_enter_until_multirow_composer_clears +test_times_out_when_composer_never_ready diff --git a/tests/fm-kimi-merge-hook.test.sh b/tests/fm-kimi-merge-hook.test.sh new file mode 100755 index 0000000..4c9e692 --- /dev/null +++ b/tests/fm-kimi-merge-hook.test.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Tests for bin/fm-kimi-merge-hook.sh. +# +# Covers safe TOML merging of a Stop hook into a copied kimi config.toml. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MERGE="$ROOT/bin/fm-kimi-merge-hook.sh" +TMP_ROOT= + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok - %s\n' "$1" +} + +cleanup() { + if [ -n "${TMP_ROOT:-}" ]; then + rm -rf "$TMP_ROOT" + fi +} + +trap cleanup EXIT + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-kimi-merge-hook.XXXXXX") + +test_appends_stop_hook_to_plain_config() { + local config turnend + config="$TMP_ROOT/plain-config.toml" + turnend="$TMP_ROOT/state/task-x1.turn-ended" + printf '%s\n' 'theme = "dark"' > "$config" + + "$MERGE" "$config" "$turnend" || fail "merge failed on plain config" + + grep -qF 'event = "Stop"' "$config" || fail "Stop event missing" + grep -qF "command = \"touch $turnend\"" "$config" || fail "Stop command missing" + grep -qF '[[hooks]]' "$config" || fail "[[hooks]] section missing" + pass "appends Stop hook to a plain config" +} + +test_does_not_duplicate_identical_stop_hook() { + local config turnend hooks_before hooks_after + config="$TMP_ROOT/dedup-config.toml" + turnend="$TMP_ROOT/state/task-x2.turn-ended" + printf '%s\n' 'theme = "dark"' > "$config" + + "$MERGE" "$config" "$turnend" || fail "first merge failed" + hooks_before=$(grep -cF '[[hooks]]' "$config") + "$MERGE" "$config" "$turnend" || fail "second merge failed" + hooks_after=$(grep -cF '[[hooks]]' "$config") + + [ "$hooks_before" -eq "$hooks_after" ] || fail "duplicate Stop hook added (count changed)" + pass "does not duplicate an identical Stop hook" +} + +test_errors_on_bare_hooks_table() { + local config turnend rc + config="$TMP_ROOT/bare-table-config.toml" + turnend="$TMP_ROOT/state/task-x3.turn-ended" + printf '%s\n' '[hooks]' 'event = "Stop"' "command = \"touch $turnend\"" > "$config" + + set +e + "$MERGE" "$config" "$turnend" >/dev/null 2>&1 + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "merge succeeded on a bare [hooks] table" + pass "errors on a config with a bare [hooks] table" +} + +test_appends_to_existing_hooks_array() { + local config turnend + config="$TMP_ROOT/array-config.toml" + turnend="$TMP_ROOT/state/task-x4.turn-ended" + cat > "$config" <<'EOF' +theme = "dark" + +[[hooks]] +event = "Start" +command = "echo start" +EOF + + "$MERGE" "$config" "$turnend" || fail "merge failed on existing hooks array" + + grep -cF '[[hooks]]' "$config" | grep -q '^2$' || fail "expected two [[hooks]] blocks" + grep -qF 'event = "Stop"' "$config" || fail "Stop event missing" + pass "appends Stop hook to an existing [[hooks]] array" +} + +test_ensures_trailing_newline() { + local config turnend last + config="$TMP_ROOT/no-newline-config.toml" + turnend="$TMP_ROOT/state/task-x5.turn-ended" + printf 'theme = "dark"' > "$config" # no trailing newline + + "$MERGE" "$config" "$turnend" || fail "merge failed on config without trailing newline" + + last=$(tail -c 1 "$config" | od -An -tx1 | tr -d ' ') + [ "$last" = "0a" ] || fail "config did not end with a newline after merge" + pass "ensures config ends with a newline" +} + +test_appends_distinct_stop_command() { + local config turnend hooks_count + config="$TMP_ROOT/other-stop-config.toml" + turnend="$TMP_ROOT/state/task-x6.turn-ended" + cat > "$config" <<'EOF' +[[hooks]] +event = "Stop" +command = "echo other" +EOF + + "$MERGE" "$config" "$turnend" || fail "merge failed with existing different Stop hook" + hooks_count=$(grep -cF '[[hooks]]' "$config") + [ "$hooks_count" -eq 2 ] || fail "expected two [[hooks]] blocks, got $hooks_count" + grep -qF "command = \"touch $turnend\"" "$config" || fail "new touch command missing" + pass "adds a Stop hook when an existing Stop hook has a different command" +} + +test_appends_stop_hook_to_plain_config +test_does_not_duplicate_identical_stop_hook +test_errors_on_bare_hooks_table +test_appends_to_existing_hooks_array +test_ensures_trailing_newline +test_appends_distinct_stop_command diff --git a/tests/fm-kimi-spawn.test.sh b/tests/fm-kimi-spawn.test.sh new file mode 100755 index 0000000..b675d1d --- /dev/null +++ b/tests/fm-kimi-spawn.test.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# Behavior tests for fm-spawn.sh with the kimi harness. +# +# These mock tmux (no real windows/server) and exercise the full single-task spawn +# path: launch template, per-task KIMI_CODE_HOME creation, Stop-hook merge, and +# the missing-auth failure. No real kimi, tmux, treehouse, or network is needed. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT= + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } +cleanup() { [ -n "${TMP_ROOT:-}" ] && rm -rf "$TMP_ROOT"; } +trap cleanup EXIT + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-kimi-spawn.XXXXXX") + +# Build a fake tmux that records send-keys and returns a worktree path for the +# pane-current-path poll. Composer state for brief injection is empty by default. +make_fake_tmux() { # + local dir=$1 session=$2 worktree=$3 log + local fb="$dir/fakebin" + log="$dir/tmux.log" + mkdir -p "$fb" + cat > "$fb/tmux" <> "$log"; } +SEEN_PATH=0 +PCP_COUNT=0 +case "\${1:-}" in + display-message) + shift + target= + fmt= + while [ "\$#" -gt 0 ]; do + case "\$1" in + -t) shift; target=\$1 ;; + -p) : ;; + *) fmt=\$1 ;; + esac + shift + done + case "\$fmt" in + '#S'|'#{session_name}') + printf '%s\n' "$session" ;; + '#{window_name}') + printf '\n' ;; + '#{pane_current_path}') + # Return project path on first call, then worktree path forever. + PCP_COUNT=\$((PCP_COUNT + 1)) + if [ "\$PCP_COUNT" -eq 1 ]; then + printf '%s\n' "\${FM_FAKE_PROJ_ABS:-$worktree}" + else + printf '%s\n' "$worktree" + fi ;; + '#{cursor_y}') + printf '0\n' ;; + *) printf '\n' ;; + esac + exit 0 ;; + list-windows) + printf '\n'; exit 0 ;; + has-session) + # Pretend the dedicated session does not exist so spawn creates it. + exit 1 ;; + new-session) + log "new-session \$*" + exit 0 ;; + new-window) + log "new-window \$*" + exit 0 ;; + capture-pane) + # Empty bordered composer for brief injection. + printf '\\xe2\\x94\\x82 > \\xe2\\x94\\x82\\n' + exit 0 ;; + send-keys) + log "send-keys \$*" + shift + target= + literal=0 + text= + while [ "\$#" -gt 0 ]; do + case "\$1" in + -t) shift; target=\$1 ;; + -l) literal=1 ;; + Enter) log "send-keys-enter target=\$target" ;; + *) + if [ -n "\$text" ]; then + text="\$text \$1" + else + text="\$1" + fi ;; + esac + shift + done + # Capture only the FIRST literal send-keys: the launch command. A kimi spawn + # also injects the brief via a later 'send-keys -l' into the composer, which + # must not clobber the launch command we assert on here (brief injection is + # covered by fm-kimi-inject.test.sh). + if [ "\$literal" -eq 1 ] && [ -n "\$text" ] && [ ! -f "$dir/launch.txt" ]; then + printf '%s' "\$text" > "$dir/launch.txt" + fi + exit 0 ;; + load-buffer) + log "load-buffer \$*" + cat > "$dir/paste.txt" + exit 0 ;; + paste-buffer) + log "paste-buffer \$*" + exit 0 ;; +esac +exit 1 +SH + chmod +x "$fb/tmux" + printf '%s\n' "$fb" +} + +# Set up a minimal firstmate home, project, and brief. +make_home() { + local home=$1 + mkdir -p "$home/data/test-id" "$home/state" "$home/projects/foo" + printf 'implement the thing\n' > "$home/data/test-id/brief.md" +} + +run_spawn() { + local home=$1 fb=$2 + HOME="$home/captain" \ + FM_ROOT_OVERRIDE='' \ + FM_HOME="$home" \ + FM_STATE_OVERRIDE='' \ + FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE='' \ + FM_CONFIG_OVERRIDE='' \ + FM_SPAWN_NO_GUARD=1 \ + PATH="$fb:$PATH" \ + "$SPAWN" test-id projects/foo kimi 2>&1 +} + +test_launch_template_is_kimi_yolo() { + local home fb out worktree + home="$TMP_ROOT/launch" + worktree="$home/wt" + mkdir -p "$worktree" "$home/captain/.kimi-code" + make_home "$home" + fb=$(make_fake_tmux "$home" testsession "$worktree") + + out=$(run_spawn "$home" "$fb") || fail "spawn failed: $out" + grep -qF 'kimi --yolo' "$home/launch.txt" || fail "launch command missing 'kimi --yolo'" + if grep -qF '__BRIEF__' "$home/launch.txt"; then + fail "launch command contains __BRIEF__ placeholder" + fi + printf '%s\n' "$out" | grep -qF 'harness=kimi' || fail "spawn output missing harness=kimi" + pass "launch template is 'kimi --yolo' with no __BRIEF__ placeholder" +} + +test_per_task_home_symlinks_everything_except_config() { + local home fb out worktree + home="$TMP_ROOT/home" + worktree="$home/wt" + mkdir -p "$worktree" "$home/captain/.kimi-code/credentials" "$home/captain/.kimi-code/oauth" + printf 'theme = "dark"\n' > "$home/captain/.kimi-code/config.toml" + make_home "$home" + fb=$(make_fake_tmux "$home" testsession "$worktree") + + out=$(run_spawn "$home" "$fb") || fail "spawn failed: $out" + + [ -d "$home/state/test-id.kimi-home" ] || fail "per-task KIMI_CODE_HOME missing" + [ -L "$home/state/test-id.kimi-home/credentials" ] || fail "credentials not symlinked" + [ -L "$home/state/test-id.kimi-home/oauth" ] || fail "oauth not symlinked" + [ -f "$home/state/test-id.kimi-home/config.toml" ] || fail "config.toml not copied" + [ ! -L "$home/state/test-id.kimi-home/config.toml" ] || fail "config.toml is a symlink (must be a copy)" + pass "per-task home symlinks credentials/oauth and copies config.toml" +} + +test_copied_config_contains_stop_hook() { + local home fb out worktree + home="$TMP_ROOT/hook" + worktree="$home/wt" + mkdir -p "$worktree" "$home/captain/.kimi-code" + printf 'theme = "dark"\n' > "$home/captain/.kimi-code/config.toml" + make_home "$home" + fb=$(make_fake_tmux "$home" testsession "$worktree") + + out=$(run_spawn "$home" "$fb") || fail "spawn failed: $out" + + grep -qF 'event = "Stop"' "$home/state/test-id.kimi-home/config.toml" \ + || fail "Stop event missing in copied config" + grep -qF "command = \"touch $home/state/test-id.turn-ended\"" "$home/state/test-id.kimi-home/config.toml" \ + || fail "Stop command missing or wrong turnend path" + pass "copied config.toml contains the Stop hook" +} + +test_spawn_fails_when_kimi_code_home_missing() { + local home fb out rc worktree + home="$TMP_ROOT/noauth" + worktree="$home/wt" + mkdir -p "$worktree" + # Deliberately do NOT create $home/captain/.kimi-code + make_home "$home" + fb=$(make_fake_tmux "$home" testsession "$worktree") + + set +e + out=$(run_spawn "$home" "$fb" 2>&1) + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "spawn should fail when ~/.kimi-code is missing" + printf '%s\n' "$out" | grep -qF "run 'kimi login' first" || fail "missing helpful error message" + pass "spawn fails fast when ~/.kimi-code is missing" +} + +test_launch_template_is_kimi_yolo +test_per_task_home_symlinks_everything_except_config +test_copied_config_contains_stop_hook +test_spawn_fails_when_kimi_code_home_missing diff --git a/tests/fm-kimi-teardown.test.sh b/tests/fm-kimi-teardown.test.sh new file mode 100755 index 0000000..761a46f --- /dev/null +++ b/tests/fm-kimi-teardown.test.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Tests for fm-teardown.sh cleanup of kimi per-task homes. +# +# Verifies teardown removes state/.kimi-home/ and that secondmate child +# teardown removes nested state/.kimi-home/. No real kimi or tmux. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEARDOWN="$ROOT/bin/fm-teardown.sh" +TMP_ROOT= + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } +cleanup() { [ -n "${TMP_ROOT:-}" ] && rm -rf "$TMP_ROOT"; } +trap cleanup EXIT + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-kimi-teardown.XXXXXX") + +make_case() { + local name=$1 case_dir fakebin + case_dir="$TMP_ROOT/$name" + fakebin="$case_dir/fakebin" + mkdir -p "$case_dir/state" "$fakebin" + + cat > "$fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/treehouse" "$fakebin/tmux" + + touch "$case_dir/state/.last-watcher-beat" + printf '%s\n' "$case_dir" +} + +write_meta() { + local case_dir=$1 kind=${2:-ship} + cat > "$case_dir/state/task-x1.meta" < "$case_dir/state/task-x1.kimi-home/config.toml" + + run_teardown "$case_dir" || fail "teardown failed" + [ ! -e "$case_dir/state/task-x1.kimi-home" ] || fail "kimi-home directory still exists after teardown" + pass "teardown removes state/.kimi-home/" +} + +test_secondmate_child_teardown_removes_nested_kimi_home() { + local case_dir parent_home child_home + case_dir=$(make_case secondmate-child) + parent_home="$case_dir/parent-secondmate" + child_home="$case_dir/child-secondmate" + + mkdir -p "$parent_home" "$child_home" + # Parent marker must match the parent id. + printf 'parent-x1\n' > "$parent_home/.fm-secondmate-home" + # Child marker must match the child id. + printf 'child-x2\n' > "$child_home/.fm-secondmate-home" + # Minimal firstmate home structure for both. + for h in "$parent_home" "$child_home"; do + mkdir -p "$h/bin" "$h/data" "$h/state" "$h/config" "$h/projects" + touch "$h/AGENTS.md" + done + + # Parent secondmate meta + cat > "$case_dir/state/parent-x1.meta" < "$parent_home/state/child-x2.kimi-home/config.toml" + cat > "$parent_home/state/child-x2.meta" <.kimi-home/" +} + +test_teardown_removes_kimi_home +test_secondmate_child_teardown_removes_nested_kimi_home From cf51e410bbf6bb8a77d4fd0adb2683c6cbe6d907 Mon Sep 17 00:00:00 2001 From: Chris Alatorre Date: Thu, 25 Jun 2026 00:07:52 -0700 Subject: [PATCH 2/6] no-mistakes(review): quote kimi turn-end hook path and link dotfiles into kimi home --- bin/fm-kimi-merge-hook.sh | 4 ++-- bin/fm-spawn.sh | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/fm-kimi-merge-hook.sh b/bin/fm-kimi-merge-hook.sh index bdeb32e..2cff665 100755 --- a/bin/fm-kimi-merge-hook.sh +++ b/bin/fm-kimi-merge-hook.sh @@ -35,12 +35,12 @@ fi # Do not duplicate an identical Stop hook command. # The command is distinctive (touch ), so its presence is enough. -if grep -qF "command = \"touch $TURNEND\"" "$CONFIG"; then +if grep -qF "command = \"touch '$TURNEND'\"" "$CONFIG"; then exit 0 fi { printf '\n[[hooks]]\n' printf 'event = "Stop"\n' - printf 'command = "touch %s"\n' "$TURNEND" + printf "command = \"touch '%s'\"\n" "$TURNEND" } >> "$CONFIG" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 4707eab..3286347 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -461,7 +461,7 @@ EOF KIMI_HOME="$STATE/$ID.kimi-home" rm -rf "$KIMI_HOME" mkdir -p "$KIMI_HOME" - for entry in "$CAPTAIN_HOME"/*; do + while IFS= read -r entry; do [ -e "$entry" ] || continue name=$(basename "$entry") if [ "$name" = "config.toml" ]; then @@ -469,7 +469,7 @@ EOF else ln -s "$entry" "$KIMI_HOME/$name" fi - done + done < <(find "$CAPTAIN_HOME" -mindepth 1 -maxdepth 1) if [ ! -f "$KIMI_HOME/config.toml" ]; then touch "$KIMI_HOME/config.toml" fi From 49dbff25be0df8d738ebd60bdf2aa530edcdfcdc Mon Sep 17 00:00:00 2001 From: Chris Alatorre Date: Thu, 25 Jun 2026 00:16:47 -0700 Subject: [PATCH 3/6] no-mistakes(review): copy kimi config by content to avoid mutating captain config via symlink --- bin/fm-spawn.sh | 2 +- tests/fm-kimi-merge-hook.test.sh | 4 ++-- tests/fm-kimi-spawn.test.sh | 26 +++++++++++++++++++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 3286347..a339d46 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -465,7 +465,7 @@ EOF [ -e "$entry" ] || continue name=$(basename "$entry") if [ "$name" = "config.toml" ]; then - cp -a "$entry" "$KIMI_HOME/config.toml" + cat "$entry" > "$KIMI_HOME/config.toml" else ln -s "$entry" "$KIMI_HOME/$name" fi diff --git a/tests/fm-kimi-merge-hook.test.sh b/tests/fm-kimi-merge-hook.test.sh index 4c9e692..fcc12c2 100755 --- a/tests/fm-kimi-merge-hook.test.sh +++ b/tests/fm-kimi-merge-hook.test.sh @@ -36,7 +36,7 @@ test_appends_stop_hook_to_plain_config() { "$MERGE" "$config" "$turnend" || fail "merge failed on plain config" grep -qF 'event = "Stop"' "$config" || fail "Stop event missing" - grep -qF "command = \"touch $turnend\"" "$config" || fail "Stop command missing" + grep -qF "command = \"touch '$turnend'\"" "$config" || fail "Stop command missing" grep -qF '[[hooks]]' "$config" || fail "[[hooks]] section missing" pass "appends Stop hook to a plain config" } @@ -116,7 +116,7 @@ EOF "$MERGE" "$config" "$turnend" || fail "merge failed with existing different Stop hook" hooks_count=$(grep -cF '[[hooks]]' "$config") [ "$hooks_count" -eq 2 ] || fail "expected two [[hooks]] blocks, got $hooks_count" - grep -qF "command = \"touch $turnend\"" "$config" || fail "new touch command missing" + grep -qF "command = \"touch '$turnend'\"" "$config" || fail "new touch command missing" pass "adds a Stop hook when an existing Stop hook has a different command" } diff --git a/tests/fm-kimi-spawn.test.sh b/tests/fm-kimi-spawn.test.sh index b675d1d..2b35556 100755 --- a/tests/fm-kimi-spawn.test.sh +++ b/tests/fm-kimi-spawn.test.sh @@ -188,11 +188,34 @@ test_copied_config_contains_stop_hook() { grep -qF 'event = "Stop"' "$home/state/test-id.kimi-home/config.toml" \ || fail "Stop event missing in copied config" - grep -qF "command = \"touch $home/state/test-id.turn-ended\"" "$home/state/test-id.kimi-home/config.toml" \ + grep -qF "command = \"touch '$home/state/test-id.turn-ended'\"" "$home/state/test-id.kimi-home/config.toml" \ || fail "Stop command missing or wrong turnend path" pass "copied config.toml contains the Stop hook" } +test_symlinked_source_config_is_copied_not_followed() { + local home fb out worktree real + home="$TMP_ROOT/symcfg" + worktree="$home/wt" + mkdir -p "$worktree" "$home/captain/.kimi-code" + real="$home/dotfiles/config.toml" + mkdir -p "$home/dotfiles" + printf 'theme = "dark"\n' > "$real" + ln -s "$real" "$home/captain/.kimi-code/config.toml" + make_home "$home" + fb=$(make_fake_tmux "$home" testsession "$worktree") + + out=$(run_spawn "$home" "$fb") || fail "spawn failed: $out" + + [ ! -L "$home/state/test-id.kimi-home/config.toml" ] \ + || fail "config.toml is a symlink (must be a standalone copy)" + grep -qF 'event = "Stop"' "$home/state/test-id.kimi-home/config.toml" \ + || fail "Stop hook missing from per-task config" + grep -qF 'event = "Stop"' "$real" \ + && fail "captain's real config was mutated through the symlink" + pass "symlinked source config is copied, not written through" +} + test_spawn_fails_when_kimi_code_home_missing() { local home fb out rc worktree home="$TMP_ROOT/noauth" @@ -215,4 +238,5 @@ test_spawn_fails_when_kimi_code_home_missing() { test_launch_template_is_kimi_yolo test_per_task_home_symlinks_everything_except_config test_copied_config_contains_stop_hook +test_symlinked_source_config_is_copied_not_followed test_spawn_fails_when_kimi_code_home_missing From 03fcc71c77d3f2a6818d13066b6e3d0d8d6a35ca Mon Sep 17 00:00:00 2001 From: Chris Alatorre Date: Thu, 25 Jun 2026 00:21:00 -0700 Subject: [PATCH 4/6] no-mistakes(review): use per-spawn tmux buffer name for brief injection and delete on paste --- bin/fm-tmux-lib.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 6a6d4db..347d13d 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -246,7 +246,7 @@ EOF # Multi-line briefs are pasted via tmux paste-buffer; single-line briefs use send-keys -l. fm_tmux_inject_brief() { # [] local target=$1 brief=$2 timeout=${3:-30} waited=0 line_count - local attempts sleep_s i box + local attempts sleep_s i box buf # Wait for the actual composer BOX to render empty, not just for the cursor row # to read blank. On a cold-starting pane the cursor line is blank before the TUI # draws its composer, which a single-row check mistakes for a ready, empty @@ -262,13 +262,18 @@ fm_tmux_inject_brief() { # [] fi line_count=$(awk 'END { print NR }' "$brief" 2>/dev/null || echo 0) if [ "$line_count" -gt 1 ]; then - # Multi-line: paste so internal newlines do not submit early. - if ! tmux load-buffer -b __fm_brief - < "$brief" 2>/dev/null; then + # Multi-line: paste so internal newlines do not submit early. Use a buffer + # name unique to this target and process so a concurrent spawn on the same + # tmux server cannot overwrite it mid-inject, and delete it on paste so the + # brief text does not linger in a server-global buffer. + buf="__fm_brief_$(printf '%s_%s' "$target" "$$" | tr -c 'A-Za-z0-9_' '_')" + if ! tmux load-buffer -b "$buf" - < "$brief" 2>/dev/null; then printf 'error: failed to load brief into tmux paste buffer\n' >&2 return 1 fi - if ! tmux paste-buffer -b __fm_brief -t "$target" 2>/dev/null; then + if ! tmux paste-buffer -d -b "$buf" -t "$target" 2>/dev/null; then printf 'error: failed to paste brief into %s\n' "$target" >&2 + tmux delete-buffer -b "$buf" 2>/dev/null || true return 1 fi else From 42242422539a3abfa2400b93b0fe73eca0973aab Mon Sep 17 00:00:00 2001 From: Chris Alatorre Date: Thu, 25 Jun 2026 07:46:36 -0700 Subject: [PATCH 5/6] no-mistakes(review): accept busy pane as kimi brief submit confirmation --- bin/fm-tmux-lib.sh | 7 ++++ tests/fm-kimi-inject.test.sh | 76 ++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 347d13d..cd87dd4 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -292,6 +292,12 @@ fm_tmux_inject_brief() { # [] # 2. kimi (pi-tui) can drop the first Enter(s) during cold start, so Enter is # retried (only — never retyped, which would duplicate the brief) until the # box clears or the window ends. + # 3. After a successful submit kimi can replace the composer prompt row with a + # busy/processing view, so the box read is "unknown" rather than "empty" + # even though the brief already left the composer. A busy pane is therefore + # an equally valid submit confirmation — but only when the box is not still + # "pending" (real text on the prompt row), where the Enter was genuinely + # swallowed and must be retried. attempts=${FM_INJECT_SUBMIT_RETRIES:-30} sleep_s=${FM_INJECT_SUBMIT_SLEEP:-0.5} i=0 @@ -300,6 +306,7 @@ fm_tmux_inject_brief() { # [] sleep "$sleep_s" box=$(fm_tmux_composer_box_state "$target") [ "$box" = empty ] && return 0 + if [ "$box" != pending ] && fm_pane_is_busy "$target"; then return 0; fi i=$((i + 1)) if [ "$i" -ge "$attempts" ]; then printf 'error: brief typed but composer never cleared (submit not confirmed) in %s\n' "$target" >&2 diff --git a/tests/fm-kimi-inject.test.sh b/tests/fm-kimi-inject.test.sh index f14ecbd..1d79c29 100755 --- a/tests/fm-kimi-inject.test.sh +++ b/tests/fm-kimi-inject.test.sh @@ -160,6 +160,81 @@ test_retries_enter_until_multirow_composer_clears() { pass "injection retries Enter until kimi's multi-row composer actually clears" } +# Build a fake tmux that models a kimi submit where the composer prompt row is +# replaced by a busy/processing view once the brief is submitted: after the brief +# is typed and Enter lands, the box scan no longer shows a prompt glyph (box state +# reads "unknown"), while the pane tail shows a busy footer. A submit-confirmation +# that demands box=="empty" would exhaust its retries and falsely abort the spawn; +# a busy pane is an equally valid confirmation that the brief left the composer. +make_fake_tmux_kimi_busy() { # + local dir=$1 fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/tmux" < \\xe2\\x94\\x82\\n' + exit 0 ;; + send-keys) + shift + literal=0; text= + while [ "\$#" -gt 0 ]; do + case "\$1" in + -t) shift ;; + -l) literal=1 ;; + Enter) + n=0; [ -f "\$DIR/enters.txt" ] && n=\$(cat "\$DIR/enters.txt") + printf '%s' "\$((n + 1))" > "\$DIR/enters.txt" ;; + *) text="\$1" ;; + esac + shift + done + if [ "\$literal" -eq 1 ] && [ -n "\$text" ]; then printf '%s' "\$text" > "\$DIR/typed.txt"; fi + exit 0 ;; + list-windows) exit 0 ;; +esac +exit 1 +SH + chmod +x "$fb/tmux" + printf '%s\n' "$fb" +} + +test_busy_pane_confirms_submit() { + local dir fb brief enters + dir="$TMP_ROOT/busy" + mkdir -p "$dir" + fb=$(make_fake_tmux_kimi_busy "$dir") + brief="$dir/brief.md" + printf 'the brief text' > "$brief" + + PATH="$fb:$PATH" FM_INJECT_SUBMIT_SLEEP=0.05 \ + fm_tmux_inject_brief "sess:win" "$brief" \ + || fail "injection should succeed when the pane goes busy after submit" + + [ -f "$dir/typed.txt" ] || fail "brief was never typed" + enters=$(cat "$dir/enters.txt" 2>/dev/null || echo 0) + # One Enter submits and the pane goes busy; the loop must not keep hammering Enter. + [ "$enters" -le 2 ] || fail "Enter kept retrying despite a busy submit confirmation (sent $enters)" + pass "busy pane after submit confirms the brief left the composer" +} + test_single_line_uses_send_keys() { local dir fb brief dir="$TMP_ROOT/single" @@ -219,4 +294,5 @@ test_times_out_when_composer_never_ready() { test_single_line_uses_send_keys test_multi_line_uses_paste_buffer test_retries_enter_until_multirow_composer_clears +test_busy_pane_confirms_submit test_times_out_when_composer_never_ready From e83f9f670d117522bfe58e9f1140e6901a87aa92 Mon Sep 17 00:00:00 2001 From: Chris Alatorre Date: Thu, 25 Jun 2026 21:34:38 -0700 Subject: [PATCH 6/6] no-mistakes(document): sync docs with kimi harness adapter addition --- .agents/skills/afk/SKILL.md | 2 +- .agents/skills/harness-adapters/SKILL.md | 23 ++++++++++++++++++++++- bin/fm-harness.sh | 2 +- docs/configuration.md | 4 +++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 4ed1b0d..f102293 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -72,7 +72,7 @@ separator, 0x1f), invisible and untypable. This is how firstmate tells a daemon escalation apart from a real message in the same pane. The marker travels with the message text; it does not rely on harness-level typed-vs-injected detection (which is not portable across claude, codex, -opencode, and pi). +opencode, pi, and kimi). ## Busy-guard and composer guard diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 554a37a..67d2c26 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -1,6 +1,6 @@ --- name: harness-adapters -description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, and pi. +description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, and kimi. user-invocable: false --- @@ -40,6 +40,7 @@ Natural language is acceptable if uncertain. - codex: `$`, for example `$no-mistakes`; `/` is claude-only and codex rejects it as "Unrecognized command". - opencode: no separate verified skill invocation beyond normal slash-command behavior; use natural language if the exact skill command is uncertain. - pi: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. +- kimi: `/`, for example `/no-mistakes`. ## claude (VERIFIED) @@ -110,3 +111,23 @@ The decision persists per path in `~/.pi/agent/trust.json`, so later spawns in t `fm-spawn` keeps the turn-end extension in `state/`, outside the worktree, because project-local extension files make the trust gate strictly worse and pollute the project. The extension must listen for pi's `turn_end` event, not `agent_end`, so the watcher wakes after each completed turn instead of only when the whole agent run exits. Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection env marker. + +## kimi (VERIFIED 2026-06-25, kimi-code 0.19.2) + +| Fact | Value | +|---|---| +| Busy-pane signature | `thinking...` (reasoning) and `working...` (generating), each with a leading braille spinner | +| Exit command | `/exit` (or `/quit`) | +| Interrupt | single Escape | +| Skill invocation | `/` (e.g. `/no-mistakes`) | + +No per-directory trust dialog was observed. +First-run auth is the captain's: they must run `kimi login` once so `~/.kimi-code` exists, or `fm-spawn` aborts with `error: ~/.kimi-code not found; run 'kimi login' first`. + +kimi has no project-local config, so `fm-spawn` builds a per-task `KIMI_CODE_HOME` at `state/.kimi-home` that symlinks everything from `~/.kimi-code` except `config.toml`, which it copies and amends with a `Stop` hook that touches the turn-end marker. +`fm-teardown` removes that directory. + +The brief cannot ride the launch command because `--prompt` conflicts with `--yolo`, so `fm-spawn` injects it into the interactive composer after launch via `fm_tmux_inject_brief` (`bin/fm-tmux-lib.sh`). +That helper waits for the multi-row composer box to render empty, pastes multi-line briefs, and retries Enter (never retypes) while the cold-starting TUI drops the first keypresses; a busy pane counts as a valid submit confirmation. + +No verified env marker exists for detection in v0.19.2; `bin/fm-harness.sh` matches the command name (`*kimi*`) in process ancestry. diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index eaff626..673a1c8 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Detect the agent harness this process tree runs on. -# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|unknown +# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|kimi|unknown # fm-harness.sh crew print the effective crewmate harness # (config/crew-harness; "default" resolves to own) # Detection layers: verified environment markers first, then process ancestry. diff --git a/docs/configuration.md b/docs/configuration.md index 0f79e78..42fc9c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -74,11 +74,13 @@ FM_WATCHER_STALE_GRACE=300 # defaults to FM_GUARD_GRACE; seconds a live watche FM_SIGNAL_GRACE=30 # seconds to coalesce nearby status and turn-end signals into one wake FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT=20 # seconds allowed for bootstrap's best-effort clone refresh FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone -FM_BUSY_REGEX='esc (to )?interrupt|Working\.\.\.' # busy-pane signatures, shared by watcher and tmux helper +FM_BUSY_REGEX='esc (to )?interrupt|Working\.\.\.|thinking\.\.\.|working\.\.\.' # busy-pane signatures, shared by watcher and tmux helper FM_COMPOSER_IDLE_RE= # optional empty-composer regex, applied after dim-ghost and border stripping FM_SEND_RETRIES=3 # fm-send Enter-retry attempts after typing the line once FM_SEND_SLEEP=0.4 # seconds between fm-send submit checks FM_SEND_SETTLE=1 # seconds fm-send waits after a successful text submit; 0 disables +FM_INJECT_SUBMIT_RETRIES=30 # spawn brief-injection Enter-retry attempts while a cold composer drops keypresses (e.g. kimi) +FM_INJECT_SUBMIT_SLEEP=0.5 # seconds between brief-injection submit checks # sub-supervisor (bin/fm-supervise-daemon.sh); presence-gated via /afk FM_SUPERVISOR_TARGET=firstmate:0 # supervisor tmux target (override; auto-discovers from $TMUX_PANE) FM_INJECT_SKIP=heartbeat # |-prefixes force-self-handled bypassing classification; empty disables