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 .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Cross-platform line endings.
# firstmate runs on macOS, Linux, and Windows (Git Bash + WezTerm). Shell
# scripts and config MUST use LF or they break on macOS/Linux/CI (a CRLF in a
# `#!/usr/bin/env bash` line is a hard error). Normalize text to LF on checkout
# regardless of a contributor's core.autocrlf setting. Tracked symlinks
# (CLAUDE.md, .claude/skills) are mode 120000 and are not affected by these
# text/eol attributes.
* text=auto eol=lf

# Binary assets must never be normalized.
*.jpg binary
*.jpeg binary
*.png binary
*.gif binary
*.ico binary
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ README.md public overview and development notes
.tasks.toml tracked tasks-axi markdown backend config; drives backlog mutations when a compatible tasks-axi is on PATH (section 10), otherwise inert
.agents/skills/ shared skills, committed
.claude/skills symlink to .agents/skills for claude compatibility
bin/ helper scripts, committed; read each script's header before first use
bin/ helper scripts, committed, including fm-mux.sh for the terminal-multiplexer abstraction (tmux on macOS/Linux, wezterm on Windows) and fm-proc.sh for portable process/ancestry introspection (incl. Git Bash/MSYS where `ps -o` is unavailable); read each script's header before first use
config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "default" = same as firstmate
data/ personal fleet records; LOCAL, gitignored as a whole
backlog.md task queue, dependencies, history
Expand Down Expand Up @@ -139,6 +139,12 @@ If the captain names a different crewmate harness at bootstrap or later, write i

## 4. Harness adapters

The crew lives in terminal-multiplexer windows.
All multiplexer interaction (create window, send keys, capture pane, read cwd, kill) goes through `bin/fm-mux.sh`, which has two backends: `tmux` on macOS/Linux and `wezterm` on Windows (tmux has no Windows port, so firstmate drives WezTerm's CLI and crewmates appear as WezTerm tabs).
The backend auto-selects (prefer `$TMUX`, then a tmux binary, then WezTerm); `FM_MUX` forces it.
Never call `tmux` directly from a script; call the `fm_mux_*` verbs so both platforms work.
Window handles are opaque: `session:window` for tmux, `wezterm:<pane_id>` for wezterm, stored verbatim in `state/<id>.meta`.

Crewmates default to the same harness you are running on.
The captain may override this at any time, typically at bootstrap: record the choice in `config/crew-harness` (a single adapter name; absent or `default` means mirror your own harness).
The recorded harness is used for every dispatch until changed; a per-task instruction from the captain ("run this one on codex") overrides it for that dispatch only.
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star
Each starts with a usage header comment; keep it accurate when you change behavior.
Test scripts and helpers in `tests/` are plain bash too.
`shellcheck bin/*.sh tests/*.sh` must pass, and CI enforces it.
- firstmate runs on macOS, Linux, and Windows (Git Bash + WezTerm).
All terminal-multiplexer interaction goes through `bin/fm-mux.sh` (a `tmux` backend and a `wezterm` backend); never call `tmux` directly from another script.
Process/ancestry introspection goes through `bin/fm-proc.sh`, which works on MSYS where `ps -o` is unavailable.
Pin a backend in tests with `FM_MUX=tmux` (or `wezterm`).
- All tracked text, especially shell scripts, must use LF line endings (`.gitattributes` enforces this); a CRLF in a `#!/usr/bin/env bash` line breaks the script on macOS/Linux/CI.
- Changes to harness adapters (launch templates in `bin/fm-spawn.sh`, facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone.
- In Markdown, put each full sentence on its own line.

Expand All @@ -59,6 +64,7 @@ Check and test the toolbelt before pushing:
bash -n bin/*.sh # syntax-check the toolbelt
shellcheck bin/*.sh tests/*.sh # lint the toolbelt and behavior tests; CI enforces this
for test_script in tests/*.test.sh; do "$test_script"; done # behavior tests, matching CI
tests/fm-mux.test.sh # multiplexer abstraction: tmux + wezterm backends (fakes; no real multiplexer needed)
tests/fm-wake-queue.test.sh # durable wake queue losslessness, catch-up, double-drain, and duplicate-collapse tests
tests/fm-watcher-lock.test.sh # watcher singleton, lock-race, watch-arm liveness, and guard-warning tests
tests/fm-daemon.test.sh # sub-supervisor classifier, /afk presence-gating, max-defer, composer, and fm-send submit tests
Expand Down
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<h1 align="center">firstmate</h1>
<p align="center">
<a
href="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue?style=flat-square"
href="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=flat-square"
><img
alt="Platform"
src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue?style=flat-square"
src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=flat-square"
/></a>
<a href="https://x.com/kunchenguid"
><img
Expand All @@ -30,7 +30,7 @@ You can run one coding agent easily.
But the moment you want three project tasks done in parallel - fixes, investigations, plans, audits - you become a tab-juggler: babysitting sessions, copy-pasting context between repos, forgetting which terminal had the failing test.

firstmate flips the model.
You talk to a single agent - the first mate - and it runs the crew for you: spawning autonomous agents in tmux windows, giving each a clean git worktree, supervising them to completion, and handing you finished PRs, approved local merges, or standalone investigation reports.
You talk to a single agent - the first mate - and it runs the crew for you: spawning autonomous agents in multiplexer windows (tmux on macOS/Linux, WezTerm tabs on Windows), giving each a clean git worktree, supervising them to completion, and handing you finished PRs, approved local merges, or standalone investigation reports.
For larger fleets, you can opt in to persistent secondmates: domain supervisors that are still ordinary direct reports, but run from their own isolated firstmate homes.
There is no app to install; the orchestrator is `AGENTS.md`, bundled skills, and helper scripts that any terminal coding agent can follow.

Expand All @@ -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, or pi), git with GitHub auth, and a terminal multiplexer for the crew windows (tmux on macOS/Linux, or WezTerm on Windows).
The first mate detects and offers to install everything else.

```sh
Expand All @@ -68,8 +68,8 @@ Then just talk:
> ahoy! look at my github project xyz, then fix the flaky login test and add dark mode

# firstmate checks its toolchain (asking your consent before installing anything),
# clones the project under projects/, and spawns two crewmates in tmux windows
# fm-fix-login-k3 and fm-dark-mode-p7.
# clones the project under projects/, and spawns two crewmates in multiplexer
# windows fm-fix-login-k3 and fm-dark-mode-p7.
# Minutes later:

PR ready for review, captain: https://github.com/you/xyz/pull/42
Expand All @@ -78,8 +78,8 @@ Then just talk:
> alright merge it
```

Run it inside tmux for the best experience: launching your harness from inside tmux puts every crewmate window in your own session, where you can watch the crew work in real time or type into any window to intervene.
Outside tmux, crewmates land in a detached `firstmate` session you can attach to.
Run it inside your multiplexer for the best experience - tmux on macOS/Linux, or WezTerm on Windows (tmux has no Windows port, so firstmate drives WezTerm's CLI there): launching your harness from inside it puts every crewmate window in your own session, where you can watch the crew work in real time or type into any window to intervene.
Outside it, crewmates land in a detached `firstmate` session you can attach to.

## How It Works

Expand All @@ -92,10 +92,10 @@ Outside tmux, crewmates land in a detached `firstmate` session you can attach to
│ reads projects/ + firstmate routes │
│ writes guarded backlog/briefs/state │
└──┬──────────────┬───────────────┬───┘
tmux send-keys / status files │
fm-mux send / status files
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│fm-task1│ │fm-task2│ ... │fm-taskN│ tmux windows you can watch
│fm-task1│ │fm-task2│ ... │fm-taskN│ multiplexer windows/tabs you watch
│crewmate│ │crewmate│ │crewmate│ one autonomous agent each
└───┬────┘ └───┬────┘ └───┬────┘
▼ ▼ ▼
Expand Down
21 changes: 19 additions & 2 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,23 @@ secondmate_sync() {

install_cmd() {
case "$1" in
tmux|node|gh) echo "brew install $1 # or the platform's package manager" ;;
node|gh) echo "brew install $1 # or the platform's package manager" ;;
tmux) echo "brew install tmux # or the platform's package manager" ;;
wezterm) echo "winget install wez.wezterm # or https://wezfurlong.org/wezterm/install/windows.html" ;;
treehouse) echo "curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh" ;;
no-mistakes) echo "curl -fsSL https://raw.githubusercontent.com/kunchenguid/no-mistakes/main/docs/install.sh | sh" ;;
gh-axi|chrome-devtools-axi|lavish-axi) echo "npm install -g $1 && $1 setup hooks" ;;
*) return 1 ;;
esac
}

TOOLS="tmux node gh treehouse no-mistakes gh-axi chrome-devtools-axi lavish-axi"
# A terminal multiplexer is required to run the crew in watchable windows.
# tmux on macOS/Linux; WezTerm's multiplexer CLI on Windows (no tmux port).
mux_present() {
command -v tmux >/dev/null 2>&1 || command -v "${FM_WEZTERM:-wezterm}" >/dev/null 2>&1
}

TOOLS="node gh treehouse no-mistakes gh-axi chrome-devtools-axi lavish-axi"

treehouse_supports_lease() {
treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)'
Expand All @@ -151,6 +159,15 @@ done
if command -v treehouse >/dev/null 2>&1 && ! treehouse_supports_lease; then
echo "MISSING: treehouse (install: $(install_cmd treehouse))"
fi
if ! mux_present; then
# Emit a token install_cmd / `fm-bootstrap.sh install <token>` understands,
# chosen per platform: wezterm on Windows (no tmux port), tmux elsewhere.
case "$(uname -s 2>/dev/null)" in
MSYS*|MINGW*|CYGWIN*) mux_tool=wezterm ;;
*) mux_tool=tmux ;;
esac
echo "MISSING: $mux_tool (install: $(install_cmd "$mux_tool"))"
fi
gh auth status >/dev/null 2>&1 || echo "NEEDS_GH_AUTH"
# Worktree-tangle check: the firstmate primary checkout (FM_ROOT) must sit on its
# default branch, not a feature branch (see fm-tangle-lib.sh). Scoped to the
Expand Down
32 changes: 17 additions & 15 deletions bin/fm-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,34 +12,36 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"

# shellcheck source=bin/fm-proc.sh
. "$SCRIPT_DIR/fm-proc.sh"

detect_own() {
# Layer 1: environment markers for verified harnesses.
# Layer 1: environment markers for verified harnesses (OS-independent).
[ "${CLAUDECODE:-}" = "1" ] && { echo claude; return; }
[ "${PI_CODING_AGENT:-}" = "true" ] && { echo pi; return; }
# Layer 2: walk the parent chain and match the command name.
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
# Layer 2: walk the ancestry and match the command name (or, for a bare
# interpreter like node/python, the harness name in its argv). fm_proc_ancestry
# walks the Windows process tree on MSYS, the OS process tree elsewhere.
local pid comm args base
while IFS="$(printf '\t')" read -r pid comm args; do
[ -n "$comm" ] || continue
base=$(basename "$comm")
case "$base" in
*claude*) echo claude; return ;;
*codex*) echo codex; return ;;
*opencode*) echo opencode; return ;;
pi) echo pi; return ;;
pi|pi.exe) echo pi; return ;;
node*|python*)
# Bare interpreter: match the harness name in its script path.
args=$(ps -o args= -p "$pid" 2>/dev/null)
case "$args" in
*claude*) echo claude; return ;;
*codex*) echo codex; return ;;
*opencode*) echo opencode; return ;;
*" pi "*|*/pi) echo pi; return ;;
*" pi "*|*/pi|*\\pi|*\\pi.exe) echo pi; return ;;
esac ;;
esac
pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
if [ -z "$pid" ] || [ "$pid" -le 1 ]; then
break
fi
done
done <<EOF
$(fm_proc_ancestry "$$")
EOF
echo unknown
}

Expand Down
38 changes: 23 additions & 15 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,40 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
LOCK="$STATE/.lock"
mkdir -p "$STATE"

# shellcheck source=bin/fm-proc.sh
. "$SCRIPT_DIR/fm-proc.sh"

# Known harness command names; extend when a new adapter is verified.
HARNESS_RE='claude|codex|opencode|^pi$'
# pi is anchored (so it does not match substrings like "api"/"raspi") but must
# also accept the Windows process name pi.exe, matching fm-harness.sh - as a bare
# basename and as "basename args" (the form holder_alive greps).
HARNESS_RE='claude|codex|opencode|(^|[\\/])pi(\.exe)?($|[[:space:]])'

harness_pid() {
local pid=$$ comm args
for _ in 1 2 3 4 5 6 7 8; do
comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1
args=$(ps -o args= -p "$pid" 2>/dev/null)
if printf '%s' "$(basename "$comm")" | grep -qE "$HARNESS_RE"; then
local pid comm args base
while IFS="$(printf '\t')" read -r pid comm args; do
[ -n "$pid" ] || continue
base=$(basename "$comm")
if printf '%s' "$base" | grep -qE "$HARNESS_RE"; then
echo "$pid"; return 0
fi
# Bare interpreter (e.g. node): match the harness name in its script path.
case "$comm" in
# Bare interpreter (e.g. node): match the harness name in its argv.
case "$base" in
*node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { echo "$pid"; return 0; } ;;
esac
pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
[ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1
done
done <<EOF
$(fm_proc_ancestry "$$")
EOF
return 1
}

holder_alive() { # true if $1 is a live process that looks like a harness
local pid=$1 comm
kill -0 "$pid" 2>/dev/null || return 1
comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1
printf '%s' "$(basename "$comm") $(ps -o args= -p "$pid" 2>/dev/null)" | grep -qE "$HARNESS_RE"
local pid=$1 info comm args
fm_proc_alive "$pid" || return 1
info=$(fm_proc_info "$pid") || return 1
comm=$(printf '%s' "$info" | cut -f1)
args=$(printf '%s' "$info" | cut -f2-)
printf '%s %s' "$(basename "$comm")" "$args" | grep -qE "$HARNESS_RE"
}

if [ "${1:-}" = "status" ]; then
Expand Down
Loading