Skip to content
Open
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
15 changes: 15 additions & 0 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool> (...)` 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__)`.
4 changes: 2 additions & 2 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
# Definition of done
This project ships **direct-PR**: you raise the PR yourself, without the no-mistakes pipeline.
Expand Down Expand Up @@ -208,7 +208,7 @@ When you believe it is complete, append \`done: {summary}\` to the status file a
Firstmate will then instruct you to run /no-mistakes to validate and ship a PR.

You drive no-mistakes by responding to its gates, not by implementing fixes.
Follow no-mistakes' own guidance for the mechanics: it loads when you invoke /no-mistakes, and \`no-mistakes axi run --help\` plus the \`help\` lines in each \`axi\` response are authoritative and version-matched to the installed binary.
Follow no-mistakes guidance for the mechanics: it loads when you invoke /no-mistakes, and \`no-mistakes axi run --help\` plus the \`help\` lines in each \`axi\` response are authoritative and version-matched to the installed binary.
Do not hand-edit, commit, or fix findings yourself while a run is active - the pipeline applies every fix.

Two firstmate-specific rules layer on top of that guidance:
Expand Down
5 changes: 4 additions & 1 deletion bin/fm-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ detect_own() {
local pid=$$ comm args
for _ in 1 2 3 4 5 6 7 8; do
comm=$(ps -o comm= -p "$pid" 2>/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.
Expand All @@ -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
Expand Down
72 changes: 59 additions & 13 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions bin/fm-tmux-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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> (...)" 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
Expand Down
68 changes: 68 additions & 0 deletions tests/fm-harness.test.sh
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions tests/fm-spawn-harness.test.sh
Original file line number Diff line number Diff line change
@@ -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
Loading