Skip to content
Merged
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: 2 additions & 0 deletions .agents/skills/operational-home-layout/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "de
config/claude-permission-mode optional one-token permission posture for every Claude worker launch: absent or "bypass" keeps --dangerously-skip-permissions, "auto" launches with --permission-mode auto; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Claude permission mode"
config/claude-account config/pi-account optional per-home worker account pin for Claude and Pi launches; LOCAL, gitignored, not inherited; absent keeps today's ambient account; present refuses a launch unless the pinned account resolves and is signed in (section 4 owns the refusal rule); see docs/configuration.md "Worker account pin"
config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Inherited by secondmate homes
config/project-capacity optional per-machine count of workers each named project admits at once, read from the root home by every local home; LOCAL, gitignored; see docs/configuration.md "Project capacity"
config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line ("<harness> [<model>] [<effort>]"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates)
config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = the configured tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10)
config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), herdr has its own required CI lane (docs/herdr-backend.md), while zellij, orca, and cmux remain experimental with no dedicated real-backend CI lane (docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning
Expand Down Expand Up @@ -92,6 +93,7 @@ state/ runtime records and signals; gitignored
tool-updates.check.sh generated watched-tool update poll shim and its .check-trust binding; present only after bin/fm-tool-update-check.sh arm; its report record .tool-updates is what keeps one pending update from being reported on every poll
mail.check.sh generated received-mail poll shim and its .check-trust binding; present only after bin/fm-mail-check.sh arm; report record .mail-check (mail schema: docs/configuration.md "Mail plane")
.mail-seen .mail-woken .mail-retry .mail-retry-pos .mail-turn .mail-seen.lock mail-plane poll cursor, emission journal, transient-fetch retry set, retry-scan position, contended-slot turn flag, and overlapping-poll lock; written only by bin/fm-mail.sh (mail schema: docs/configuration.md "Mail plane")
startup-growth.check.sh generated daily startup-growth poll shim and its .check-trust binding; present only after bin/fm-startup-growth-check.sh arm; its record .startup-growth-check holds the daily gate, the per-file growth baselines, and the last reported finding set, so removing it re-baselines growth silently and repeats a standing finding such as a budget overrun once (docs/configuration.md "Daily startup growth check")
pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh
procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (`process-event-sources` skill)
procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/project-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ The captain's request to create that local project authorizes this local initial
Run no-mistakes initialization only for `no-mistakes` and `no-mistakes-prod-only` projects:

```sh
cd projects/<name> && no-mistakes init && no-mistakes doctor
(cd projects/<name> && no-mistakes init && no-mistakes doctor)
```

Initialization configures the local gate and does not vendor a no-mistakes skill into the project.
Expand Down
28 changes: 20 additions & 8 deletions .pi/extensions/fm-calm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,28 +136,40 @@ export default function (pi: ExtensionAPI) {
// continuations, retries, or compaction that stay inside the same run.
let agentRunActive = false;
let workingShipShown = false;
let workingShipWidgetDisposed = false;
// One animation instance per extension lifetime. Hiding the working widget freezes
// this state; the next working period resumes it. session_start resets it so a fresh
// Pi session starts at the normal initial position. Never module-global.
const workingShipAnimation = createCalmWorkingShipAnimation();

// Single owner of Calm's working-row presentation choice. The widget is only created
// or removed on a real transition, so repeated starts cannot duplicate its timer.
// The slot is shared with standalone Pi Calm; the dispose signal prevents turning
// Firstmate Calm off from clearing a widget that the other extension installed.
const applyWorkingPresentation = (
ui: ExtensionUIContext,
forceStockVisibility = false,
): void => {
const showShip = agentRunActive && calmPresentationIsActive();
if (showShip !== workingShipShown) {
workingShipShown = showShip;
ui.setWidget(
CALM_WORKING_SHIP_WIDGET_KEY,
showShip
? (tui) => createCalmWorkingShipWidget(tui, workingShipAnimation)
: undefined,
);
ui.setWorkingVisible(!showShip);
} else if (forceStockVisibility && !showShip) {
if (showShip) {
ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, (tui) => {
workingShipWidgetDisposed = false;
const widget = createCalmWorkingShipWidget(tui, workingShipAnimation);
const dispose = widget.dispose;
widget.dispose = () => {
workingShipWidgetDisposed = true;
dispose();
};
return widget;
});
ui.setWorkingVisible(false);
} else if (!workingShipWidgetDisposed) {
ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, undefined);
ui.setWorkingVisible(true);
}
} else if (forceStockVisibility && !showShip && !workingShipWidgetDisposed) {
ui.setWorkingVisible(true);
}
};
Expand Down
7 changes: 6 additions & 1 deletion .pi/extensions/lib/fm-calm-working-ship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ const ANSI_FOREGROUND: Record<Exclude<CalmWorkingShipColor, "plain">, string> =
// Restores the default foreground so color never bleeds into padding or later frames.
const RESET = "\u001b[39m";

export const CALM_WORKING_SHIP_WIDGET_KEY = "firstmate-calm-working-ship";
// The working-row widget slot is deliberately shared with the standalone Pi Calm
// extension, which installs its boat under the same "calm-working-ship" key. Pi
// replaces widgets under one key, so a session that loads both Calms renders a
// single boat and a session loading either alone is unchanged. Rename the slot
// in both implementations together, or dual-install sessions duplicate the boat.
export const CALM_WORKING_SHIP_WIDGET_KEY = "calm-working-ship";

export type CalmWorkingShipAnimation = Omit<CalmWorkingShipSprite, "frame"> & {
/** Render one frame that exactly fits `width`, clamping the track to it first. */
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ Tracked files hold shared instructions and tooling; `data/` holds durable privat

Load `operational-home-layout` when locating, interpreting, or changing Firstmate home, config, data, state, project, or generated runtime paths.


A `state/<id>.status` line is a wake event, not current-state truth; `bin/fm-crew-state.sh` owns current-state reconciliation.
Treat `data/captain.md` as the domain-local record of captain preferences, optional `data/captain-shared.md` as the main-authoritative shared captain-preference file for secondmate inheritance, and `data/learnings.md` as curated home-local knowledge, regardless of harness memory.

Expand Down Expand Up @@ -197,6 +196,7 @@ An unregistered project or absent registry resolves to `no-mistakes` with yolo o
Record the resulting mode, `yolo` merge posture, and the one-line reason for any deviation in the backlog item note.

Treat file or subsystem overlap as a risk signal rather than an automatic reason to wait, and dispatch isolated work immediately with no concurrency cap when each change can be independently implemented and validated and the selected delivery path can reconcile ordinary rebases or conflicts.
A project's declared machine capacity (`config/project-capacity`) still bounds that dispatch: a spawn beyond it exits 75 without launching, and its item stays queued rather than blocked.
Serialize only for a true semantic dependency, shared mutable external state, incompatible concurrent migration, or another concrete condition that makes independent progress or reconciliation unsafe; same-file editing alone is insufficient, and genuine blockers remain durable.
Write the task-specific brief under section 11 before spawning.
Fill the task subsections according to section 11.
Expand Down Expand Up @@ -369,7 +369,7 @@ A decision is simply a task held for the captain: create the task with `bin/fm-t
When a main-side thread such as a pending captain decision or relay reminder is worth durable tracking, file it as its own work item and hold it through that wrapper.
Captain calls discovered by investigations or visual reviews follow `captain-hold-lifecycle`, which owns their completion gate and recorded-answer rules.
When the automatic transition gate applies, dispatch and completion move the item themselves - `bin/fm-spawn.sh` and `bin/fm-teardown.sh` own those transitions and refuse rather than report success without them - so what remains yours is filing the item before dispatch, recording decisions, and keeping notes current; `docs/configuration.md` owns gate applicability and the manual-backend exception.
Re-evaluate queued work after every teardown and heartbeat, dispatching items only when dependencies and time gates have cleared.
Re-evaluate queued work after every teardown and heartbeat, and also after a recorded PR-ready handoff when `config/project-capacity` caps that project, dispatching items only when dependencies, time gates, and project capacity have cleared.

- `.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema, compatibility, retention, and routine command syntax.
- Use compatible `tasks-axi` when the configured backend selects it, always through `bin/fm-tasks-axi.sh` so the call reaches this home's backlog from any directory, and the documented manual path otherwise; keep only the configured recent Done entries.
Expand Down
9 changes: 9 additions & 0 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3487,7 +3487,12 @@ fm_backend_herdr_send_text_submit() { # <target> <text> <retries> <enter-sleep>
esac
# Native stayed idle. Composer empty is positive delivery (a landed
# Claude turn that never flipped agent_status). Proven pending retries.
# A picker that classifies pending must not receive that retry.
verdict=$(fm_backend_herdr_composer_state "$target")
if fm_composer_blocking_dialog_noted >/dev/null; then
printf 'unknown'
return 0
fi
case "$verdict" in
empty) printf 'empty'; return 0 ;;
pending|pending-unproven) ;;
Expand All @@ -3496,6 +3501,10 @@ fm_backend_herdr_send_text_submit() { # <target> <text> <retries> <enter-sleep>
else
sleep "$sleep_s"
verdict=$(fm_backend_herdr_composer_state "$target")
if fm_composer_blocking_dialog_noted >/dev/null; then
printf 'unknown'
return 0
fi
if [ "$verdict" = pending ] && [ "$raw_status" != working ] \
&& [ "$footer_baseline" = idle ] \
&& [ "$(fm_backend_herdr_rendered_busy_state "$target")" = busy ]; then
Expand Down
39 changes: 32 additions & 7 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -811,18 +811,43 @@ fm_backend_send_key() { # <backend> <target> <key> [expected-label]
# fm_backend_send_text_submit: type text once, then submit and verify,
# retrying only the submission (never retyping). Echoes the backend's
# proof-carrying verdict; callers require exact empty for confirmed delivery.
# A pane that already shows the recognised dialog is refused before any
# adapter types, so that submit neither types the text nor sends Enter.
fm_backend_send_text_submit() { # <backend> <target> <text> <retries> <enter-sleep> <settle> [expected-label]
local backend=$1
local backend=$1 rc=0 target label dialog
shift
target=$1
label=${6:-}
fm_backend_source "$backend" || return 1
# Every Enter loop below reads the dialog sink, so it must exist before
# any adapter types: a sink that fails here leaves the composer untouched.
fm_composer_dialog_sink_prepare || {
echo "error: the dialog check for a $backend submit could not be recorded" >&2
return 1
}
# One composer read after the sink exists and before the adapter types.
# The classify writes the sink; a named dialog means the next Enter would
# answer it.
if [ -n "$label" ]; then
fm_backend_composer_state "$backend" "$target" "$label" >/dev/null || true
else
fm_backend_composer_state "$backend" "$target" >/dev/null || true
fi
if dialog=$(fm_composer_blocking_dialog_noted); then
fm_composer_dialog_sink_release
echo "error: blocked on a prompt: $dialog" >&2
return 1
fi
case "$backend" in
tmux) fm_backend_tmux_send_text_submit "$@" ;;
herdr) fm_backend_herdr_send_text_submit "$@" ;;
zellij) fm_backend_zellij_send_text_submit "$@" ;;
orca) fm_backend_orca_send_text_submit "$@" ;;
cmux) fm_backend_cmux_send_text_submit "$@" ;;
*) echo "error: no send-text implementation for backend '$backend'" >&2; return 1 ;;
tmux) fm_backend_tmux_send_text_submit "$@" || rc=$? ;;
herdr) fm_backend_herdr_send_text_submit "$@" || rc=$? ;;
zellij) fm_backend_zellij_send_text_submit "$@" || rc=$? ;;
orca) fm_backend_orca_send_text_submit "$@" || rc=$? ;;
cmux) fm_backend_cmux_send_text_submit "$@" || rc=$? ;;
*) echo "error: no send-text implementation for backend '$backend'" >&2; rc=1 ;;
esac
fm_composer_dialog_sink_release
return "$rc"
}

# fm_backend_kill: remove the task's session endpoint. An already-gone target
Expand Down
76 changes: 76 additions & 0 deletions bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1671,9 +1671,80 @@ EOF
printf '%s\n' "$joined" | LC_ALL=C awk '{$1=$1; printf "%s", $0}'
}

# fm_composer_blocking_dialog: name a screen whose next Enter would answer it.
# Prints the name and returns 0 only for the recorded structure of one dialog:
# the heading on its own line, then its selected row alone on a row, with the
# recorded footer as the last non-blank row. A heading buried in a sentence,
# or a last line that only starts with the same words, is not that dialog.
# The strings alone are not enough, because a diff, a note, or a test fixture
# on the pane can quote all of them above a normal composer. A miss returns 1
# and prints nothing.
# Recorded 2026-10-05 on Claude Code 2.1.289: /exit while a background shell
# is still running opens this picker, and its selected row is Exit and stop tasks.
fm_composer_blocking_dialog() { # <screen> -> dialog name
local screen=${1-}
[ -n "$screen" ] || return 1
if printf '%s\n' "$screen" | fm_composer_strip_ansi | LC_ALL=C awk '
/^[ \t]*Background work is running[ \t\r]*$/ { heading = 1 }
heading && /^[ \t]*❯ 1\. Exit and stop tasks[ \t\r]*$/ { selected = 1 }
/[^ \t\r]/ { last = $0 }
END { exit !(selected && last ~ /^[ \t]*Enter to confirm · Esc to cancel[ \t\r]*$/) }
'; then
printf '%s' 'Claude background-task exit picker'
return 0
fi
return 1
}

# A command substitution drops a shell variable, and every composer read runs
# inside one. The name is therefore written to FM_COMPOSER_DIALOG_SINK when
# that path is set. The classifier verdict is unchanged. When the sink is
# unset the name would be discarded, so the match is skipped.
fm_composer_note_blocking_dialog() { # <screen>
local name=
[ -n "${FM_COMPOSER_DIALOG_SINK:-}" ] || return 1
if name=$(fm_composer_blocking_dialog "$1"); then
printf '%s' "$name" > "$FM_COMPOSER_DIALOG_SINK" || return 1
return 0
fi
: > "$FM_COMPOSER_DIALOG_SINK" || return 1
return 1
}

# fm_composer_blocking_dialog_noted: print the name the latest classify wrote
# to the sink. Returns 1 when the sink is unset or empty.
fm_composer_blocking_dialog_noted() {
[ -n "${FM_COMPOSER_DIALOG_SINK:-}" ] || return 1
[ -s "$FM_COMPOSER_DIALOG_SINK" ] || return 1
cat "$FM_COMPOSER_DIALOG_SINK"
}

# Empty the sink, creating it when the caller has not. Sets
# FM_COMPOSER_DIALOG_OWNED=1 only for a sink this call created, so a caller
# that shares the path can still read the name after the release.
fm_composer_dialog_sink_prepare() {
FM_COMPOSER_DIALOG_OWNED=0
if [ -z "${FM_COMPOSER_DIALOG_SINK:-}" ]; then
FM_COMPOSER_DIALOG_SINK=$(mktemp "${TMPDIR:-/tmp}/fm-composer-dialog.XXXXXX") || return 1
FM_COMPOSER_DIALOG_OWNED=1
return 0
fi
: > "$FM_COMPOSER_DIALOG_SINK"
}

fm_composer_dialog_sink_release() {
if [ "${FM_COMPOSER_DIALOG_OWNED:-}" = 1 ]; then
rm -f "$FM_COMPOSER_DIALOG_SINK"
FM_COMPOSER_DIALOG_SINK=
FM_COMPOSER_DIALOG_OWNED=0
fi
}

fm_composer_classify_screen() { # <caps> <screen> [cursor_row] [identity]
local caps=$1 screen=$2 cy=${3:-} identity=${4:-}
local styled=0 cursor=0 has_identity=0 kv plain
# Note the dialog before any early return so a pending picker is still named.
fm_composer_note_blocking_dialog "$screen" || true
while IFS= read -r kv; do
case "$kv" in
styled=1) styled=1 ;;
Expand Down Expand Up @@ -1795,6 +1866,11 @@ fm_composer_submit_retry_core() { # <send-key-fn> <state-fn> <target> <retries>
"$send_key_fn" "$target" Enter "$expected_label" || true
sleep "$sleep_s"
state=$("$state_fn" "$target" "$expected_label")
# The first Enter can open a picker. A later Enter would confirm it.
if fm_composer_blocking_dialog_noted >/dev/null; then
printf 'unknown'
return 0
fi
case "$state" in
pending|pending-unproven) ;;
*) printf '%s' "$state"; return 0 ;;
Expand Down
Loading
Loading