diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 8edddb71..c5faf1ab 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -116,3 +116,18 @@ 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-cli (VERIFIED 2026-06-28, kimi-cli 1.47.0) + +| Fact | Value | +|---|---| +| Busy-pane signature | Bullet-prefixed reasoning (`• `) and `Used (...)` lines while a tool call is in flight. | +| Exit command | None — the agent stops on its own and returns to the shell prompt. Send `C-c` to interrupt. | +| Interrupt | `C-c` (Control+c) via `tmux send-keys`. | +| Skill invocation | Natural language; `kimi-cli` has no verified slash/skill command syntax. | + +`kimi-cli` is launched with `-y` (auto-approve) and a prompt from the brief via `-p`. It runs interactively until the agent decides to finish, then exits to the shell prompt. +There is no native turn-end hook; firstmate relies on status-file writes and pane staleness for supervision. +`kimi-cli` is a Python script (`kimi_cli.__main__`); detection matches `kimi` and `kimi-cli` command names and `kimi_cli` in interpreter args. +The launch template uses a crewmate-specific `--mcp-config-file` to avoid the broken `gscServer` entry in the user's default `~/.kimi/mcp.json`. +Keep prompts as a single `-p` argument; the launch template reads the brief with `$(cat __BRIEF__)`. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index f5668cee..53386527 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -174,7 +174,7 @@ EOF case "$MODE" in direct-PR) SETUP2="" - RULE1='1. Never push to the default branch (push only your `fm/'"$ID"'` branch). Never merge a PR.' + RULE1="1. Never push to the default branch (push only your \`fm/$ID\` branch). Never merge a PR." DOD=$(cat </dev/null) || break - case "$(basename "$comm")" in + case "$(basename -- "$comm")" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; + kimi|kimi-cli) echo kimi-cli; return ;; + *kimi*) echo kimi-cli; return ;; pi) echo pi; return ;; node*|python*) # Bare interpreter: match the harness name in its script path. @@ -32,6 +34,7 @@ detect_own() { *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; + *kimi_cli*) echo kimi-cli; return ;; *" pi "*|*/pi) echo pi; return ;; esac ;; esac diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 38747d5c..6ef76277 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -37,6 +37,7 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" SUB_HOME_MARKER=".fm-secondmate-home" # shellcheck source=bin/fm-ff-lib.sh @@ -140,6 +141,7 @@ launch_template() { printf '%s' 'pi -e __PIEXT__ "$(cat __BRIEF__)"' fi ;; + kimi-cli) printf '%s' 'kimi-cli -y --mcp-config-file __MCP__ -p "$(cat __BRIEF__)"' ;; *) return 1 ;; esac } @@ -162,6 +164,49 @@ case "$ARG3" in ;; esac +# Generate a crewmate-specific MCP config for kimi-cli. If the user has provided +# $CONFIG/kimi-cli-mcp.json, use it. Otherwise filter ~/.kimi/mcp.json to drop +# servers whose command is missing/broken (e.g. a stale gscServer path), falling +# back to an empty server list when no source config exists. +generate_kimi_cli_mcp_config() { + local id=$1 dst src + dst="$STATE/$id.kimi-cli-mcp.json" + mkdir -p "$STATE" + if [ -f "$CONFIG/kimi-cli-mcp.json" ]; then + cp "$CONFIG/kimi-cli-mcp.json" "$dst" + printf '%s\n' "$dst" + return 0 + fi + src="${HOME:-}/.kimi/mcp.json" + if [ -f "$src" ] && command -v python3 >/dev/null 2>&1; then + python3 - "$src" "$dst" <<'PY' +import json, os, shutil, sys +src, dst = sys.argv[1], sys.argv[2] +with open(src) as f: + cfg = json.load(f) +servers = cfg.get('mcpServers', {}) +filtered = {} +for name, srv in servers.items(): + cmd = srv.get('command', '') + if not cmd: + continue + if os.path.isabs(cmd): + ok = os.path.isfile(cmd) and os.access(cmd, os.X_OK) + else: + ok = shutil.which(cmd) is not None + if ok: + filtered[name] = srv +with open(dst, 'w') as f: + json.dump({'mcpServers': filtered}, f, indent=2) +PY + else + printf '{"mcpServers": {}}\n' > "$dst" + fi + printf '%s\n' "$dst" +} + +[ "$HARNESS" = "kimi-cli" ] && KIMI_CLI_MCP=$(generate_kimi_cli_mcp_config "$ID") + secondmate_registry_value() { local id=$1 key=$2 reg line value reg="$DATA/secondmates.md" @@ -355,21 +400,20 @@ fi tmux new-window -d -t "$SES" -n "$W" -c "$PROJ_ABS" if [ "$KIND" != secondmate ]; then - tmux send-keys -t "$T" 'treehouse get' Enter - - # Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree. - for _ in $(seq 1 60); do - p=$(tmux display-message -p -t "$T" '#{pane_current_path}' 2>/dev/null || true) - if [ -n "$p" ] && [ "$p" != "$PROJ_ABS" ]; then - WT="$p" - break - fi - sleep 1 - done - if [ -z "$WT" ]; then - echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2 + # Acquire a durable leased worktree from treehouse. The --lease mode reserves + # the worktree in persistent state and prints its absolute path; it is never + # handed out to another get and never pruned, even with no process inside, + # until teardown calls 'treehouse return'. This avoids the subshell-mode race + # where a long-running crewmate's worktree can be reclaimed mid-flight. + if ! WT=$(cd "$PROJ_ABS" && treehouse get --lease 2>/dev/null); then + echo "error: treehouse get --lease failed for $PROJ_ABS; inspect window $T" >&2 + exit 1 + fi + if [ -z "$WT" ] || [ ! -d "$WT" ]; then + echo "error: treehouse get --lease returned invalid worktree '$WT' for $PROJ_ABS" >&2 exit 1 fi + tmux send-keys -t "$T" "cd $(shell_quote "$WT")" Enter # Isolation guard: refuse to launch unless WT is a genuine, ISOLATED worktree - # a real git worktree root, distinct from the project's primary checkout @@ -483,9 +527,11 @@ mkdir -p "$STATE" sq_brief=$(shell_quote "$BRIEF") sq_turnend=$(shell_quote "$TURNEND") sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts") +sq_mcp=$(shell_quote "${KIMI_CLI_MCP:-$CONFIG/kimi-cli-mcp.json}") LAUNCH=${LAUNCH//__BRIEF__/$sq_brief} LAUNCH=${LAUNCH//__TURNEND__/$sq_turnend} LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} +LAUNCH=${LAUNCH//__MCP__/$sq_mcp} if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 374e358b..b96cf075 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -35,8 +35,8 @@ # 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-cli: "• " / "Used ...". +FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|^• |Used (Shell|Read|Write|Edit|Grep|FetchURL|WebSearch|Bash)' # 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, diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 8879a8e8..4dcdaec5 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -86,8 +86,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-cli: bullet-prefixed reasoning ("• ") and "Used (...)" tool-call lines. +BUSY_REGEX=${FM_BUSY_REGEX:-'esc (to )?interrupt|Working\.\.\.|^• |Used (Shell|Read|Write|Edit|Grep|FetchURL|WebSearch|Bash)'} # Always-on wake triage: most wakes during a long crew validation are benign # (working: notes, bare turn-ended, a crew gone quiet mid-validation, a no-change # heartbeat). Rather than wake firstmate's LLM for each, this watcher classifies diff --git a/tests/fm-harness.test.sh b/tests/fm-harness.test.sh new file mode 100755 index 00000000..9d3239b1 --- /dev/null +++ b/tests/fm-harness.test.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-harness.sh harness detection and crew-harness resolution. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +HARNESS="$ROOT/bin/fm-harness.sh" +TMP_ROOT=$(fm_test_tmproot fm-harness) + +# Clear ambient overrides so the test owns the environment. +run_harness() { + FM_ROOT_OVERRIDE='' \ + FM_HOME="$TMP_ROOT" \ + FM_STATE_OVERRIDE='' \ + FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE='' \ + FM_CONFIG_OVERRIDE='' \ + "$HARNESS" "$@" 2>&1 +} + +# crew mode reads config/crew-harness when present. +test_crew_reads_config() { + local out + mkdir -p "$TMP_ROOT/config" + printf 'kimi-cli\n' > "$TMP_ROOT/config/crew-harness" + out=$(run_harness crew) + [ "$out" = "kimi-cli" ] || fail "crew harness should read config/crew-harness; got '$out'" + pass "crew harness resolves config/crew-harness" +} + +# crew mode falls back to detect_own when config/crew-harness is absent or 'default'. +test_crew_fallback_default() { + local out + mkdir -p "$TMP_ROOT/config" + printf 'default\n' > "$TMP_ROOT/config/crew-harness" + # We cannot easily fake the process tree here, but 'default' must not echo the literal word. + out=$(run_harness crew) + [ "$out" != "default" ] || fail "crew harness should resolve 'default', not echo it" + pass "crew harness resolves 'default' to detected harness" +} + +# Detection must recognise the kimi/kimi-cli ecosystem. +test_detect_kimi_cli_by_command() { + local fakebin out + fakebin=$(fm_fakebin "$TMP_ROOT") + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +# Fake ps that reports a kimi parent chain. +case "$*" in + *'-o comm= -p '*) + echo 'kimi' + ;; + *'-o ppid= -p '*) + echo '1' + ;; + *) exit 1 ;; +esac +SH + chmod +x "$fakebin/ps" + PATH="$fakebin:$PATH" out=$(run_harness) + [ "$out" = "kimi-cli" ] || fail "detect_own should map kimi to kimi-cli; got '$out'" + pass "detect_own maps 'kimi' process to kimi-cli harness" +} + +test_crew_reads_config +test_crew_fallback_default +test_detect_kimi_cli_by_command diff --git a/tests/fm-spawn-harness.test.sh b/tests/fm-spawn-harness.test.sh new file mode 100755 index 00000000..e38e72da --- /dev/null +++ b/tests/fm-spawn-harness.test.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Behavior tests for fm-spawn.sh harness launch templates. +# These exercise harness recognition only: each spawn attempt fails fast at the +# missing-brief check, reached before any tmux/treehouse side effect. +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-spawn-harness) + +run_spawn() { + FM_ROOT_OVERRIDE='' \ + FM_HOME="$TMP_ROOT" \ + FM_STATE_OVERRIDE='' \ + FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE='' \ + FM_CONFIG_OVERRIDE='' \ + FM_SPAWN_NO_GUARD=1 \ + "$SPAWN" "$@" 2>&1 +} + +# kimi-cli harness must be recognised and reach the missing-brief check. +test_kimi_cli_harness_recognised() { + local out status proj + proj="$TMP_ROOT/projects/fakeproj" + mkdir -p "$proj" "$TMP_ROOT/data/audit-harness-k3" + printf '# fake brief\n' > "$TMP_ROOT/data/audit-harness-k3/brief.md" + out=$(run_spawn audit-harness-k3 projects/fakeproj kimi-cli) + status=$? + [ "$status" -ne 0 ] || fail "missing treehouse/tmux should exit non-zero" + assert_not_contains "$out" "unknown harness" "kimi-cli should not be treated as unknown" + pass "kimi-cli harness is recognised by launch template" +} + +test_kimi_cli_harness_recognised