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
2 changes: 1 addition & 1 deletion .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 22 additions & 1 deletion .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
---

Expand Down Expand Up @@ -40,6 +40,7 @@ Natural language is acceptable if uncertain.
- codex: `$<skill>`, for example `$no-mistakes`; `/<skill>` 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: `/<skill>`, for example `/no-mistakes`.

## claude (VERIFIED)

Expand Down Expand Up @@ -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 | `/<skill>` (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/<id>.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.
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | `/<skill>` (e.g., `/no-mistakes`) |
| Turn-end hook | TOML `[[hooks]] event="Stop" command="touch <turnend>"` |
| Brief injection | Must be typed into the interactive composer after launch; `--prompt` conflicts with `--yolo` |
| Per-task home | `state/<id>.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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion bin/fm-harness.sh
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down
46 changes: 46 additions & 0 deletions bin/fm-kimi-merge-hook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Safely add a Stop hook to a kimi config.toml.
# Usage: fm-kimi-merge-hook.sh <config.toml> <turnend-file>
# Adds a [[hooks]] Stop entry that touches <turnend-file>.
# 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 <config.toml> <turnend-file>" >&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 <turnend>), 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"
40 changes: 38 additions & 2 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# fm-spawn.sh <task-id> [<firstmate-home>] [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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:-}
;;
*' '*)
Expand Down Expand Up @@ -140,6 +142,7 @@ launch_template() {
printf '%s' 'pi -e __PIEXT__ "$(cat __BRIEF__)"'
fi
;;
kimi) printf '%s' 'kimi --yolo' ;;
*) return 1 ;;
esac
}
Expand Down Expand Up @@ -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"
while IFS= read -r entry; do
[ -e "$entry" ] || continue
name=$(basename "$entry")
if [ "$name" = "config.toml" ]; then
cat "$entry" > "$KIMI_HOME/config.toml"
else
ln -s "$entry" "$KIMI_HOME/$name"
fi
done < <(find "$CAPTAIN_HOME" -mindepth 1 -maxdepth 1)
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

Expand Down Expand Up @@ -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"
2 changes: 2 additions & 0 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
130 changes: 127 additions & 3 deletions bin/fm-tmux-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -190,3 +193,124 @@ fm_tmux_submit_core() { # <target> <text> <retries> <enter-sleep> <settle>
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() { # <target> -> 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 <<EOF
$cleaned
EOF
[ "$found" -eq 1 ] || { printf 'unknown'; return 0; }
printf '%s' "$state"
}

# fm_tmux_inject_brief: inject a brief file into an interactive agent composer.
# Usage: fm_tmux_inject_brief <target> <brief-file> [<timeout>]
# <target> tmux target (session:window)
# <brief-file> path to the brief to inject
# <timeout> 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() { # <target> <brief-file> [<timeout>]
local target=$1 brief=$2 timeout=${3:-30} waited=0 line_count
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
# 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. 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 -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
# 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.
# 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
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
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
return 1
fi
done
}
Loading