Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,33 @@ The adapter therefore runs the shared predicate and, when it returns 2, forces o
It does not pass `--permission-mode`, so the passive hook cannot escalate the primary session's tool permissions.
Project-local Grok hooks require folder trust, verified with launch-time `--trust`; if the primary firstmate checkout is not trusted for Grok hooks, this primary guard fails open and `fm-guard.sh` remains the next-command alarm.
Grok's primary watcher protocol is Claude-shaped background-notify around `bin/fm-watch-arm.sh`; the passive Stop hook is only a backstop for blind turn ends.

## kimi (VERIFIED 2026-07-17, kimi-cli 1.49.0, CREWMATE/SECONDMATE ONLY)

Kimi Code CLI (`kimi`), Moonshot AI's coding agent, installed via `curl -LsSf https://code.kimi.com/install.sh | bash` (wraps `uv tool install`) and authenticated once per machine with `kimi login` (a device-code OAuth flow the captain completes interactively; the credential is never something firstmate can obtain itself).
This verification pass covers kimi as a crewmate/secondmate harness only.
It has no primary turn-end guard, no PreToolUse seatbelt, and no primary watcher protocol, so it is not usable as firstmate's own primary harness until a separate pass adds those.

| Fact | Value |
|---|---|
| Busy-pane signature | none stable. A rotating moon-phase spinner glyph (`🌑🌒🌓🌔🌕🌖🌗🌘`) is the only visual busy cue, and the footer keybind hints rotate on a fixed cadence independent of busy/idle state, so there is no ASCII text pattern to add to `FM_BUSY_REGEX`. Supervision instead relies entirely on firstmate's Layer 1 hash-based staleness detection (`bin/fm-watch.sh`): the spinner animates continuously even through a real blocking tool call (verified with a live 12-second `sleep`, pane hash changed on every 1.5s poll), so the pane never presents two consecutive identical hashes while kimi is genuinely working. |
| Exit command | `/exit` (verified) or `Ctrl-D` (documented in-app, not separately verified). |
| Interrupt | single `Ctrl-C` (verified: cleanly kills the running tool and the turn with no further output, composer returns to `empty`). `Esc` was not tested. |
| Skill invocation | `/<skill>` (e.g. `/no-mistakes`), same form as claude and grok. `--skills-dir <dir>` (repeatable) and the config file's `extra_skill_dirs` add custom skill directories such as firstmate's `.agents/skills/`; discovery was observed via `/help`'s slash-command listing but a firstmate skill was not actually invoked end to end. |
| Autonomy | `--yolo` (aliases `--yes`, `-y`) auto-approves every tool call, verified to run fully unattended through a real shell tool execution. `--afk` is added alongside it because it additionally auto-dismisses `AskUserQuestion` prompts, a distinct UI gate `--yolo` does not document covering, and no one is present to answer one on a firstmate-launched crewmate. |
| Model flag | `--model <name>` / `-m` (verified via `--help`; not exercised with a non-default model). |
| Effort flag | none. `--thinking` / `--no-thinking` is a boolean toggle, not the five-level low/medium/high/xhigh/max scale firstmate's profile axis expects, so `fm-spawn` omits an effort flag for kimi entirely rather than mapping onto it. |
| Resume | `kimi -r <session-id>` (or bare `-S`/`--session` to interactively pick one) reopens a session with full prior history intact, verified by resuming a session mid-way through an interrupted tool call and seeing the interrupted state reflected correctly. `--continue` / `-C` resumes the most recent session for the current working directory. |
| Env marker | none observed; a live tool call's own `env` showed no `KIMI_*` or `MOONSHOT_*` variable. Not a gap for this crewmate/secondmate-only scope: `fm-harness.sh`'s env-marker detection only matters for firstmate's own primary-harness self-detection, which kimi is not verified for, and every crewmate/secondmate use already carries `harness=kimi` in `state/<id>.meta`. |
| Trust dialog | none observed on first launch in a fresh, never-before-used worktree; the welcome banner renders directly into an empty composer. |
| tmux liveness probe | kimi's foreground process reads as a generic `python3.13` (verified via `#{pane_current_command}`), the same "wrapped by a generic interpreter" gap `bin/backends/tmux.sh` already documents for pi, so `fm_backend_tmux_agent_alive` correctly falls through to `unknown` rather than `alive` for a live kimi pane; no code change was needed there. A bare-shell `dead` reading is unaffected (it only checks for `zsh`/`bash`/etc) and was added to `bin/fm-bootstrap.sh`'s SECONDMATE_LIVENESS trusted-harness list alongside pi. |

**Brief injection is structurally different from every other verified harness.**
kimi has no positional initial-prompt argument (`kimi [OPTIONS] [COMMAND] [ARGS]...` only accepts trailing `ARGS` for a `COMMAND`, not a bare prompt) and its only two ways to seed an initial message both end the process instead of leaving it interactive: `-p`/`--prompt`/`--command` runs the given text and exits when the turn completes (verified: the requested file was written correctly, then the whole tmux pane closed), and piping content over stdin does not even run the task (verified: the piped brief produced no output and the pane closed almost immediately, consistent with kimi refusing non-tty stdin for interactive input).
`fm-spawn`'s `launch_template` therefore starts kimi bare (`kimi --yolo --afk __MODELFLAG__`, no brief on the command line), and a dedicated post-launch step polls `fm_backend_composer_state` until it reports `empty` (welcome banner rendered, composer ready), then calls the same shared `fm_backend_send_text_submit` primitive `fm-send.sh` uses for an ordinary steer to type the full brief in as the first message.
This was verified to buffer a real multi-line brief correctly with no premature submission on an embedded newline (three lines typed via `tmux send-keys -l` with a literal `\n`, composer classified `pending` with all three lines visible, then one combined message processed by the model on submit) and to classify idle/pending/busy states correctly with the existing shared composer classifier, so no changes were needed in `fm-tmux-lib.sh` or `fm-composer-lib.sh` for kimi.
This mechanic is tmux-verified only; the herdr/zellij/orca/cmux backends were not exercised for kimi, though `fm_backend_composer_state` and `fm_backend_send_text_submit` are the same generic dispatchers those backends already implement for steering.

**Known follow-up: native hooks were not wired.**
kimi's `config.toml` has a top-level `hooks = []` array and an in-app `/hooks` command ("Add [] sections to your config.toml to set up hooks"), suggesting a real Stop-equivalent hook analogous to grok's or codex's is possible, which would give firstmate a precise per-turn wake instead of relying solely on the hash-based staleness detection above.
The exact hook-entry schema (event names, available template variables, working directory) was not reverse-engineered in this pass; consult `https://moonshotai.github.io/kimi-cli/` before attempting it.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ A silent bootstrap section needs no action; for any printed diagnostic or capabi
## 4. Harness and runtime dispatch

Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification.
The verified harnesses are `claude`, `codex`, `opencode`, `pi`, and `grok`; never dispatch on an unverified adapter.
The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `grok`, and `kimi`; never dispatch on an unverified adapter.
If configured harness data names an unverified adapter, report it and fall back only to a verified adapter rather than launching it.

`docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-dispatch-select.sh` owns selector mechanics, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation.
Expand Down
5 changes: 3 additions & 2 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ secondmate_liveness_sweep() {
[ -n "$target" ] || target="$window"
verdict=$(fm_backend_agent_alive "$backend" "$target" 2>/dev/null) || verdict="unknown"
case "$harness" in
claude|codex|opencode|pi|grok) ;;
claude|codex|opencode|pi|grok|kimi) ;;
*) [ "$verdict" = dead ] && verdict=unknown ;;
esac
case "$verdict" in
Expand Down Expand Up @@ -500,7 +500,7 @@ crew_dispatch_validate() {
return 0
fi
err=$(jq -r '
def verified($h): ["claude","codex","opencode","pi","grok"] | index($h);
def verified($h): ["claude","codex","opencode","pi","grok","kimi"] | index($h);
def effort_ok($h; $e):
if $e == null then true
elif ($e | type) != "string" then false
Expand All @@ -509,6 +509,7 @@ crew_dispatch_validate() {
elif $h == "grok" then (["low","medium","high"] | index($e))
elif $h == "pi" then (["low","medium","high","xhigh","max"] | index($e))
elif $h == "opencode" then false
elif $h == "kimi" then false
else true
end;
def use_profiles($u):
Expand Down
44 changes: 41 additions & 3 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
# profile consultation. A --secondmate spawn is exempt and resolves the SECONDMATE
# harness (config/secondmate-harness -> config/crew-harness -> own), so the
# secondmate-vs-crewmate split is DURABLE across every respawn (recovery,
# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|grok)
# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|grok|kimi)
# overrides it for this spawn (either kind). A non-flag string containing
# whitespace is treated as a RAW launch command - the escape hatch for verifying
# new adapters.
Expand Down Expand Up @@ -284,7 +284,7 @@ FIRSTMATE_HOME=

if [ "$KIND" = secondmate ]; then
case "${POS[1]:-}" in
''|claude|codex|opencode|pi|grok)
''|claude|codex|opencode|pi|grok|kimi)
ARG3=${POS[1]:-}
;;
*' '*)
Expand Down Expand Up @@ -345,6 +345,15 @@ launch_template() {
# launch command - it is a Stop-event hook installed below (global hook +
# per-task pointer), so the template is identical for ship/scout/secondmate.
grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;;
# kimi (Kimi Code CLI): has no positional or stdin initial-prompt injection
# that keeps the interactive session alive - -p/--prompt and piped stdin
# both run one-shot and exit the process (verified; see the harness-adapters
# skill). The launch command therefore starts a bare interactive session;
# the brief is typed into the composer as the first message by the
# post-launch injection step below, once the pane reports composer_state
# "empty". --afk auto-dismisses AskUserQuestion on top of --yolo's tool
# auto-approval, since no one is present to answer either prompt.
kimi) printf '%s' 'kimi --yolo --afk __MODELFLAG__' ;;
*) return 1 ;;
esac
}
Expand Down Expand Up @@ -432,7 +441,7 @@ model_flag_for_harness() {
local harness=$1 model=$2
[ -n "$model" ] && [ "$model" != default ] || return 0
case "$harness" in
claude|codex|opencode|pi|grok)
claude|codex|opencode|pi|grok|kimi)
printf -- '--model %s ' "$(shell_quote "$model")"
;;
esac
Expand Down Expand Up @@ -1058,4 +1067,33 @@ spawn_send_literal "$T" "$LAUNCH"
sleep 0.3
spawn_send_key "$T" Enter

# kimi has no launch-command brief injection (see launch_template above): type
# the brief into the composer as the first message once the TUI has rendered
# its empty composer, using the same shared submit primitive fm-send uses for
# an ordinary steer. Verified 2026-07-17 (kimi-cli 1.49.0): a multi-line brief
# buffers correctly without premature submission, and the composer classifier
# already reads kimi's idle/pending states correctly with no per-harness
# override, so no changes were needed in fm-tmux-lib.sh or fm-composer-lib.sh.
if [ "$HARNESS" = kimi ]; then
kimi_ready=
for _ in $(seq 1 30); do
if [ "$(fm_backend_composer_state "$BACKEND" "$T" 2>/dev/null)" = empty ]; then
kimi_ready=1
break
fi
sleep 0.5
done
[ -n "$kimi_ready" ] || { echo "error: kimi composer did not become ready within 15s; inspect window $T" >&2; exit 1; }
verdict=$(fm_backend_send_text_submit "$BACKEND" "$T" "$(cat "$BRIEF")" 5 0.5 0.3) || {
echo "error: failed to send brief to kimi window $T" >&2
exit 1
}
case "$verdict" in
pending|send-failed)
echo "error: brief not delivered to kimi window $T (verdict=$verdict); inspect and steer manually" >&2
exit 1
;;
esac
fi

echo "spawned $ID harness=$HARNESS kind=$KIND mode=$MODE yolo=$YOLO window=$META_WINDOW worktree=$WT"
5 changes: 3 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,11 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa

## Harness support

claude, codex, opencode, pi, and grok are all empirically verified; new harnesses get verified through a supervised trial task before joining the set.
claude, codex, opencode, pi, and grok are all empirically verified as primary harnesses as well as crewmate/scout/secondmate harnesses; new harnesses get verified through a supervised trial task before joining the set.
kimi is empirically verified as a crewmate/secondmate harness only; it has no primary turn-end guard, PreToolUse seatbelt, or watcher protocol yet.
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).
Primary-session turn-end guard integrations for verified harnesses are tracked as repo-level hook files and documented in [`docs/turnend-guard.md`](turnend-guard.md).
Primary-session turn-end guard integrations for verified primary harnesses are tracked as repo-level hook files and documented in [`docs/turnend-guard.md`](turnend-guard.md).
Primary-session watcher wake protocols are rendered at session start by [`bin/fm-supervision-instructions.sh`](../bin/fm-supervision-instructions.sh) from [`docs/supervision-protocols/`](supervision-protocols/).
Claude and Grok use background-notify cycles, Codex uses bounded foreground checkpoints, Pi uses its two tracked primary extensions, and OpenCode uses its TUI plugin.
`config/crew-harness` is a local, gitignored file containing one adapter name for crewmate and scout launches.
Expand Down
124 changes: 124 additions & 0 deletions tests/fm-kimi-harness.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Behavior tests for the kimi harness's post-launch brief injection: kimi has
# no positional/stdin initial-prompt injection that keeps its interactive
# session alive (see .agents/skills/harness-adapters/SKILL.md), so fm-spawn.sh
# starts it bare and types the brief into the composer once it reports ready.
set -u

# shellcheck source=tests/lib.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

SPAWN="$ROOT/bin/fm-spawn.sh"
TMP_ROOT=$(fm_test_tmproot fm-kimi-harness)

# make_kimi_fakebin: a fake tmux that logs every invocation (one line per call,
# space-joined args) to <dir>/calls.log, answers #{pane_current_path} with
# FM_FAKE_PANE_PATH (ends the treehouse-get wait immediately, matching the
# grok/pi harness tests' pattern), and answers the composer-state pair
# (#{cursor_y} + capture-pane -e -S -E on that row) as an always-empty
# composer, so fm_tmux_composer_state reads "empty" both before typing and
# right after the injected Enter, needing no retry.
make_kimi_fakebin() {
local dir=$1 fakebin log
fakebin=$(fm_fakebin "$dir")
log="$dir/calls.log"
: > "$log"
cat > "$fakebin/tmux" <<SH
#!/usr/bin/env bash
set -u
printf '%s\n' "\$*" >> "$log"
case "\$*" in
*"#{pane_current_path}"*) printf '%s\n' "\${FM_FAKE_PANE_PATH:-}"; exit 0 ;;
*"#{cursor_y}"*) printf '7\n'; exit 0 ;;
*"capture-pane -e"*"-S 7 -E 7"*) printf '\n'; exit 0 ;;
*"capture-pane"*) printf '\n'; exit 0 ;;
esac
case "\${1:-}" in
display-message) printf 'firstmate\n'; exit 0 ;;
list-windows) exit 0 ;;
has-session|new-session|new-window|send-keys|kill-window) exit 0 ;;
esac
exit 0
SH
chmod +x "$fakebin/tmux"
fm_fake_exit0 "$fakebin" treehouse gh-axi gh
printf '%s\n' "$fakebin"
}

make_kimi_case() {
local name=$1 case_dir home proj wt fakebin id
case_dir="$TMP_ROOT/$name"
home="$case_dir/home"
proj="$case_dir/project"
wt="$case_dir/wt"
fakebin=$(make_kimi_fakebin "$case_dir/fake")
id="kimi-$name-x1"
mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config"
printf 'KIMI_TEST_BRIEF_CONTENT_MARKER\nsecond line of the brief\n' > "$home/data/$id/brief.md"
fm_git_worktree "$proj" "$wt" "fm/$id"
touch "$home/state/.last-watcher-beat"
printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin|$id"
}

run_kimi_spawn() {
local home=$1 proj=$2 wt=$3 fakebin=$4
FM_ROOT_OVERRIDE='' FM_HOME="$home" \
FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \
FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \
FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \
PATH="$fakebin:$PATH" \
"$SPAWN" "$id" "$proj" kimi 2>&1
}

test_kimi_spawn_injects_brief_after_bare_launch() {
local rec case_dir home proj wt fakebin id out status log launch_line brief_line
rec=$(make_kimi_case inject)
IFS='|' read -r case_dir home proj wt fakebin id <<EOF
$rec
EOF
out=$(run_kimi_spawn "$home" "$proj" "$wt" "$fakebin")
status=$?
expect_code 0 "$status" "kimi spawn should succeed"
assert_contains "$out" "spawned $id harness=kimi" "kimi spawn did not report success"

log="$(dirname "$fakebin")/calls.log"
assert_present "$log" "fake tmux call log was not written"

launch_line=$(grep -n -- "send-keys.*-l.*kimi --yolo --afk" "$log" | head -1 | cut -d: -f1)
[ -n "$launch_line" ] || fail "kimi launch command was never typed into the pane"$'\n'"--- log ---"$'\n'"$(cat "$log")"

brief_line=$(grep -n -- "send-keys.*-l.*KIMI_TEST_BRIEF_CONTENT_MARKER" "$log" | head -1 | cut -d: -f1)
[ -n "$brief_line" ] || fail "kimi brief was never typed into the composer as the first message"$'\n'"--- log ---"$'\n'"$(cat "$log")"

[ "$brief_line" -gt "$launch_line" ] \
|| fail "kimi brief was typed before (or same call as) the launch command, not after it started (launch=$launch_line brief=$brief_line)"

assert_no_grep '__BRIEF__' "$log" "an unsubstituted __BRIEF__ placeholder reached the pane"
pass "kimi spawn starts bare then injects the brief into the composer as the first message"
}

test_kimi_launch_has_no_positional_brief() {
local rec case_dir home proj wt fakebin id out status log brief_call
rec=$(make_kimi_case no-positional)
IFS='|' read -r case_dir home proj wt fakebin id <<EOF
$rec
EOF
out=$(run_kimi_spawn "$home" "$proj" "$wt" "$fakebin")
expect_code 0 $? "kimi spawn should succeed"
log="$(dirname "$fakebin")/calls.log"

# The LAUNCH line itself (the one containing "kimi --yolo --afk") must not
# also carry the brief content on the same tmux call: kimi's launch command
# never embeds the brief (unlike claude/grok/pi's "$(cat __BRIEF__)"
# positional pattern), because -p/stdin injection exits the process instead
# of leaving it interactive (see the harness-adapters skill).
brief_call=$(grep -F 'KIMI_TEST_BRIEF_CONTENT_MARKER' "$log" || true)
[ -n "$brief_call" ] || fail "brief injection call not found in log"$'\n'"--- log ---"$'\n'"$(cat "$log")"
case "$brief_call" in
*'kimi --yolo --afk'*) fail "the launch command and the brief injection landed in the same tmux call: $brief_call" ;;
esac
pass "kimi's launch command and its brief injection are two distinct tmux calls"
}

test_kimi_spawn_injects_brief_after_bare_launch
test_kimi_launch_has_no_positional_brief