From f42ac9fce1264bc9609b6a90e16021063752675d Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Thu, 2 Jul 2026 22:53:33 -0400 Subject: [PATCH 01/19] add codex app visible thread ledger --- bin/fm-codex-app | 337 ++++++++++++++++++++++ bin/fm-codex-app-smoke-check.sh | 34 +++ tests/fm-codex-app-smoke-contract.test.sh | 44 +++ tests/fm-codex-app-state.test.sh | 116 ++++++++ 4 files changed, 531 insertions(+) create mode 100755 bin/fm-codex-app create mode 100755 bin/fm-codex-app-smoke-check.sh create mode 100755 tests/fm-codex-app-smoke-contract.test.sh create mode 100755 tests/fm-codex-app-state.test.sh diff --git a/bin/fm-codex-app b/bin/fm-codex-app new file mode 100755 index 00000000..3ed2b147 --- /dev/null +++ b/bin/fm-codex-app @@ -0,0 +1,337 @@ +#!/usr/bin/env node +// Firstmate Codex App visible-thread ledger. +// Visible Codex App threads are owned by the Codex Desktop host. This helper +// records local task state and refuses to masquerade as the host thread API. + +const fs = require("node:fs"); +const path = require("node:path"); + +function looksLikeFleetRoot(dir) { + return fs.existsSync(path.join(dir, "state")) + && fs.existsSync(path.join(dir, "data")) + && fs.existsSync(path.join(dir, "bin", "fm-codex-app")); +} + +function findRootFromCwd() { + let dir = process.cwd(); + while (true) { + if (looksLikeFleetRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +const ROOT = process.env.FM_ROOT || findRootFromCwd() || path.resolve(__dirname, ".."); +const HOME = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || ROOT; +const DATA = process.env.FM_DATA_OVERRIDE || path.join(HOME, "data"); +const STATE = process.env.FM_STATE_OVERRIDE || path.join(HOME, "state"); + +function usage() { + console.error(`usage: + fm-codex-app prepare + fm-codex-app record-thread [--turn-id ] [--worktree ] [--pending-worktree-id ] + fm-codex-app adopt-thread --kind [--thread-name ] [--mode ] [--yolo ] [--worktree ] [--turn-id ] [--pending-worktree-id ] [--brief ] + fm-codex-app record-pending + fm-codex-app record-capture + fm-codex-app mark-archived + fm-codex-app capture [lines] + fm-codex-app send + fm-codex-app interrupt + fm-codex-app archive + fm-codex-app status `); + process.exit(2); +} + +function ensureState() { + fs.mkdirSync(STATE, { recursive: true }); +} + +function metaPath(taskId) { + return path.join(STATE, `${taskId}.meta`); +} + +function parseMeta(content) { + const values = {}; + for (const line of content.split(/\r?\n/)) { + if (!line || line.startsWith("#")) continue; + const idx = line.indexOf("="); + if (idx === -1) continue; + values[line.slice(0, idx)] = line.slice(idx + 1); + } + return values; +} + +function readMeta(taskId) { + const file = metaPath(taskId); + try { + return parseMeta(fs.readFileSync(file, "utf8")); + } catch (error) { + if (error.code === "ENOENT") throw new Error(`no meta for task ${taskId} at ${file}`); + throw error; + } +} + +function ensureMetaExists(taskId, action) { + if (!fs.existsSync(metaPath(taskId))) { + throw new Error(`${action} requires existing meta for task ${taskId}; run prepare or adopt-thread first`); + } +} + +function writeMeta(taskId, values) { + ensureState(); + const file = metaPath(taskId); + let current = {}; + try { + current = parseMeta(fs.readFileSync(file, "utf8")); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + const next = Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null)); + const merged = { ...current, ...next }; + const body = Object.entries(merged) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + fs.writeFileSync(file, `${body}\n`); +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function resolveProjectMode(projectPath) { + const projectName = path.basename(String(projectPath || "").replace(/\/+$/, "")); + const fallback = { mode: "no-mistakes", yolo: "off" }; + if (!projectName) return fallback; + + const registry = path.join(DATA, "projects.md"); + let content; + try { + content = fs.readFileSync(registry, "utf8"); + } catch (error) { + if (error.code !== "ENOENT") throw error; + return fallback; + } + + const linePattern = new RegExp(`^-\\s+${escapeRegex(projectName)}(?:\\s+\\[([^\\]]+)\\])?\\s+-\\s+`); + for (const line of content.split(/\r?\n/)) { + const match = line.match(linePattern); + if (!match) continue; + const tokens = String(match[1] || "").trim().split(/\s+/).filter(Boolean); + let mode = "no-mistakes"; + let yolo = "off"; + if (tokens[0] && tokens[0] !== "+yolo") mode = tokens[0]; + if (tokens.includes("+yolo")) yolo = "on"; + if (!["no-mistakes", "direct-PR", "local-only"].includes(mode)) return fallback; + return { mode, yolo }; + } + return fallback; +} + +function taskForThread(threadId) { + let matches = []; + try { + matches = fs.readdirSync(STATE).filter((name) => name.endsWith(".meta")); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } + for (const name of matches) { + const taskId = name.slice(0, -5); + const meta = readMeta(taskId); + if (meta.thread_id === threadId) return { taskId, meta }; + } + return null; +} + +function parseFlags(args) { + const flags = {}; + for (let i = 0; i < args.length; i += 1) { + const key = args[i]; + if (!key.startsWith("--")) throw new Error(`unexpected argument '${key}'`); + const value = args[i + 1]; + if (!value || value.startsWith("--")) throw new Error(`missing value for ${key}`); + flags[key.slice(2).replace(/-/g, "_")] = value; + i += 1; + } + return flags; +} + +function lastLines(text, limit) { + const lines = String(text || "").replace(/\r?\n$/, "").split(/\r?\n/); + return lines.slice(Math.max(0, lines.length - limit)).join("\n"); +} + +function hostToolMessage(action, threadId, taskId) { + const suffix = taskId ? ` for task ${taskId}` : ""; + switch (action) { + case "capture": + return `Codex App thread ${threadId}${suffix} is app-owned. Use read_thread in Codex Desktop, then optionally cache the text with: bin/fm-codex-app record-capture ${taskId || ""} `; + case "send": + return `Codex App thread ${threadId}${suffix} is app-owned. Use send_message_to_thread(threadId=${threadId}, prompt=...) in Codex Desktop.`; + case "interrupt": + return `Codex App thread ${threadId}${suffix} is app-owned. Interrupt it from Codex Desktop, or use handoff_thread when moving a running thread.`; + case "archive": + return `Codex App thread ${threadId}${suffix} is app-owned. Use set_thread_archived(threadId=${threadId}, archived=true), then run: bin/fm-codex-app mark-archived ${taskId || ""}`; + default: + return `Codex App thread ${threadId}${suffix} is app-owned. Use the Codex Desktop thread tools.`; + } +} + +async function main() { + const [cmd, ...args] = process.argv.slice(2); + if (!cmd) usage(); + + if (cmd === "prepare") { + const [taskId, threadName, briefFile] = args; + if (!taskId || !threadName || !briefFile) usage(); + if (!fs.existsSync(briefFile)) throw new Error(`brief not found: ${briefFile}`); + writeMeta(taskId, { + backend: "codex-app", + window: threadName, + harness: "codex", + codex_app_thread_state: "pending", + codex_app_pending_action: "create_thread_or_fork_thread", + codex_app_brief: briefFile, + codex_app_transport: "visible-thread", + }); + console.log(`prepared_thread_name=${threadName}`); + console.log(`brief=${briefFile}`); + console.log("next=create_thread_or_fork_thread"); + return; + } + + if (cmd === "record-thread") { + const [taskId, threadId, ...rest] = args; + if (!taskId || !threadId) usage(); + ensureMetaExists(taskId, "record-thread"); + const flags = parseFlags(rest); + const existing = taskForThread(threadId); + if (existing && existing.taskId !== taskId) { + throw new Error(`thread ${threadId} is already recorded for task ${existing.taskId}`); + } + writeMeta(taskId, { + thread_id: threadId, + turn_id: flags.turn_id, + worktree: flags.worktree, + codex_app_pending_worktree_id: flags.pending_worktree_id, + codex_app_thread_state: "visible", + codex_app_pending_action: "none", + codex_app_transport: "visible-thread", + }); + console.log(`recorded_thread_id=${threadId}`); + return; + } + + if (cmd === "adopt-thread") { + const [taskId, threadId, projectPath, ...rest] = args; + if (!taskId || !threadId || !projectPath) usage(); + const flags = parseFlags(rest); + if (fs.existsSync(metaPath(taskId))) throw new Error(`task ${taskId} already has meta at ${metaPath(taskId)}`); + const existing = taskForThread(threadId); + if (existing) throw new Error(`thread ${threadId} is already recorded for task ${existing.taskId}`); + const kind = flags.kind; + if (kind !== "ship" && kind !== "scout") throw new Error("adopt-thread requires --kind ship or --kind scout"); + const projectMode = resolveProjectMode(projectPath); + writeMeta(taskId, { + backend: "codex-app", + window: flags.thread_name || `fm-${taskId}`, + worktree: flags.worktree || "", + project: projectPath, + harness: flags.harness || "codex", + kind, + mode: flags.mode || projectMode.mode, + yolo: flags.yolo || projectMode.yolo, + thread_id: threadId, + turn_id: flags.turn_id, + codex_app_pending_worktree_id: flags.pending_worktree_id, + codex_app_thread_state: "visible", + codex_app_pending_action: "none", + codex_app_transport: "visible-thread", + codex_app_brief: flags.brief, + }); + console.log(`adopted_thread_id=${threadId}`); + return; + } + + if (cmd === "record-pending") { + const [taskId, pendingWorktreeId] = args; + if (!taskId || !pendingWorktreeId) usage(); + ensureMetaExists(taskId, "record-pending"); + writeMeta(taskId, { + codex_app_pending_worktree_id: pendingWorktreeId, + codex_app_thread_state: "pending-worktree", + codex_app_pending_action: "await_thread_id", + codex_app_transport: "visible-thread", + }); + console.log(`recorded_pending_worktree_id=${pendingWorktreeId}`); + return; + } + + if (cmd === "record-capture") { + const [taskId, captureFile] = args; + if (!taskId || !captureFile) usage(); + ensureMetaExists(taskId, "record-capture"); + const text = captureFile === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(captureFile, "utf8"); + ensureState(); + fs.writeFileSync(path.join(STATE, `${taskId}.codex-app.capture`), text); + writeMeta(taskId, { codex_app_last_capture: new Date().toISOString() }); + console.log(`recorded_capture_task=${taskId}`); + return; + } + + if (cmd === "mark-archived") { + const [taskId] = args; + if (!taskId) usage(); + ensureMetaExists(taskId, "mark-archived"); + writeMeta(taskId, { + codex_app_archived: "1", + codex_app_thread_state: "archived", + codex_app_pending_action: "none", + }); + console.log(`marked_archived_task=${taskId}`); + return; + } + + if (cmd === "capture") { + const [threadId, linesArg] = args; + if (!threadId) usage(); + const found = taskForThread(threadId); + const limit = Number(linesArg || 40); + if (found) { + const file = path.join(STATE, `${found.taskId}.codex-app.capture`); + if (fs.existsSync(file)) { + console.log(lastLines(fs.readFileSync(file, "utf8"), Number.isFinite(limit) && limit > 0 ? limit : 40)); + return; + } + } + console.error(`error: ${hostToolMessage("capture", threadId, found?.taskId)}`); + process.exit(2); + } + + if (cmd === "status") { + const [threadId] = args; + if (!threadId) usage(); + const found = taskForThread(threadId); + if (!found) throw new Error(`thread ${threadId} is not recorded in ${STATE}`); + console.log(`status=${found.meta.codex_app_thread_state || "visible"}`); + if (found.meta.turn_id) console.log(`turn_id=${found.meta.turn_id}`); + return; + } + + if (cmd === "send" || cmd === "interrupt" || cmd === "archive") { + const [threadId] = args; + if (!threadId) usage(); + const found = taskForThread(threadId); + console.error(`error: ${hostToolMessage(cmd, threadId, found?.taskId)}`); + process.exit(2); + } + + usage(); +} + +main().catch((error) => { + console.error(`error: ${error.message}`); + process.exit(1); +}); diff --git a/bin/fm-codex-app-smoke-check.sh b/bin/fm-codex-app-smoke-check.sh new file mode 100755 index 00000000..17f7875e --- /dev/null +++ b/bin/fm-codex-app-smoke-check.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Validate a Codex App visible-thread smoke evidence transcript. +# Usage: fm-codex-app-smoke-check.sh +# The transcript is intentionally simple key-value evidence, so humans can paste +# it into reports and CI can reject headless app-server "success" as insufficient. +set -eu + +FILE=${1:-} +if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then + echo "usage: fm-codex-app-smoke-check.sh " >&2 + exit 2 +fi + +require() { + local key=$1 + if ! grep -Eq "^$key=(ok|yes|true|1)$" "$FILE"; then + echo "FAIL: missing $key=ok" >&2 + exit 1 + fi +} + +if grep -Eq '^(app_server_only|headless_only)=(ok|yes|true|1)$' "$FILE"; then + echo "FAIL: app-server/headless-only evidence is not a visible Codex App smoke" >&2 + exit 1 +fi + +require visible_thread +require list_threads +require read_thread +require send_message_to_thread +require archive +require restart_reconcile + +echo "codex-app visible smoke: passed" diff --git a/tests/fm-codex-app-smoke-contract.test.sh b/tests/fm-codex-app-smoke-contract.test.sh new file mode 100755 index 00000000..f75c4018 --- /dev/null +++ b/tests/fm-codex-app-smoke-contract.test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-codex-app-smoke.XXXXXX") +trap 'rm -rf "$TMP"' EXIT + +cat > "$TMP/pass.txt" < "$TMP/no-list.txt" <"$TMP/no-list.err"; then + echo "expected missing list_threads evidence to fail" >&2 + exit 1 +fi +grep -q 'missing list_threads=ok' "$TMP/no-list.err" + +cat > "$TMP/headless.txt" <"$TMP/headless.err"; then + echo "expected headless-only evidence to fail" >&2 + exit 1 +fi +grep -q 'headless-only evidence' "$TMP/headless.err" diff --git a/tests/fm-codex-app-state.test.sh b/tests/fm-codex-app-state.test.sh new file mode 100755 index 00000000..707dea54 --- /dev/null +++ b/tests/fm-codex-app-state.test.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-codex-app-state.XXXXXX") +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "$TMP/state" +ID=state-test +META="$TMP/state/$ID.meta" +cat > "$META" </dev/null +grep -qx 'thread_id=thread-1' "$META" +grep -qx 'turn_id=turn-1' "$META" +grep -qx 'codex_app_pending_worktree_id=pending-1' "$META" +grep -qx 'codex_app_thread_state=visible' "$META" +grep -qx 'codex_app_transport=visible-thread' "$META" + +PENDING_ID=pending-loss +PENDING_META="$TMP/state/$PENDING_ID.meta" +cat > "$PENDING_META" </dev/null +FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread "$PENDING_ID" thread-pending >/dev/null +grep -qx 'codex_app_pending_worktree_id=pending-2' "$PENDING_META" + +printf 'first\nsecond\nthird\n' | FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-capture "$ID" - >/dev/null +[ "$(FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" capture thread-1 2)" = "$(printf 'second\nthird')" ] + +FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" mark-archived "$ID" >/dev/null +grep -qx 'codex_app_archived=1' "$META" +grep -qx 'codex_app_thread_state=archived' "$META" + +cat > "$TMP/state/other.meta" < "$TMP/state/third.meta" <"$TMP/duplicate.err"; then + echo "expected duplicate thread record to fail" >&2 + exit 1 +fi +grep -q 'already recorded' "$TMP/duplicate.err" + +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread missing thread-missing 2>"$TMP/missing-meta.err"; then + echo "expected record-thread without prepared meta to fail" >&2 + exit 1 +fi +grep -q 'requires existing meta' "$TMP/missing-meta.err" + +mkdir -p "$TMP/data" +cat > "$TMP/data/projects.md" </dev/null +ADOPTED_META="$TMP/state/adopted.meta" +grep -qx 'backend=codex-app' "$ADOPTED_META" +grep -qx 'window=fm-adopted' "$ADOPTED_META" +grep -qx 'worktree=/tmp/wt' "$ADOPTED_META" +grep -qx 'project=/tmp/example' "$ADOPTED_META" +grep -qx 'harness=codex' "$ADOPTED_META" +grep -qx 'kind=scout' "$ADOPTED_META" +grep -qx 'mode=direct-PR' "$ADOPTED_META" +grep -qx 'yolo=on' "$ADOPTED_META" +grep -qx 'thread_id=thread-2' "$ADOPTED_META" +grep -qx 'codex_app_thread_state=visible' "$ADOPTED_META" +grep -qx 'codex_app_pending_action=none' "$ADOPTED_META" + +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted thread-3 /tmp/example --kind scout 2>"$TMP/duplicate-task.err"; then + echo "expected duplicate task adoption to fail" >&2 + exit 1 +fi +grep -q 'already has meta' "$TMP/duplicate-task.err" + +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted-other thread-2 /tmp/example --kind scout 2>"$TMP/duplicate-thread.err"; then + echo "expected duplicate thread adoption to fail" >&2 + exit 1 +fi +grep -q 'already recorded' "$TMP/duplicate-thread.err" + +OPS="$TMP/ops" +mkdir -p "$OPS/state" "$OPS/data" "$OPS/config" +ln -s "$ROOT/bin" "$OPS/bin" +SYMLINK_ID=symlink-root +cat > "$OPS/state/$SYMLINK_ID.meta" </dev/null ) +grep -qx 'codex_app_archived=1' "$OPS/state/$SYMLINK_ID.meta" From 779c50bb780ed86594e50a6da3043cc09e6f662e Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Thu, 2 Jul 2026 23:00:41 -0400 Subject: [PATCH 02/19] no-mistakes(review): add Codex App backend adapter --- bin/backends/codex-app.sh | 43 ++++++++++++++++++++++++++++++++ bin/fm-backend.sh | 20 +++++++++++++-- bin/fm-codex-app | 5 +++- bin/fm-send.sh | 2 +- tests/fm-backend.test.sh | 21 +++++++++++++++- tests/fm-codex-app-state.test.sh | 4 ++- 6 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 bin/backends/codex-app.sh diff --git a/bin/backends/codex-app.sh b/bin/backends/codex-app.sh new file mode 100644 index 00000000..c5e40dfc --- /dev/null +++ b/bin/backends/codex-app.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +fm_backend_codex_app_cmd() { + "$FM_BACKEND_LIB_DIR/fm-codex-app" "$@" +} + +fm_backend_codex_app_capture() { # + fm_backend_codex_app_cmd capture "$1" "$2" +} + +fm_backend_codex_app_send_key() { # + case "$2" in + C-c|Escape) + fm_backend_codex_app_cmd interrupt "$1" + ;; + *) + echo "error: Codex App thread $1 is app-owned. Send keys from Codex Desktop." >&2 + return 2 + ;; + esac +} + +fm_backend_codex_app_send_text_submit() { # + fm_backend_codex_app_cmd send "$1" "$2" >&2 || true + printf 'send-failed' +} + +fm_backend_codex_app_kill() { # + fm_backend_codex_app_cmd archive "$1" >&2 || true +} + +fm_backend_codex_app_busy_state() { # + local status + status=$(fm_backend_codex_app_cmd status "$1" 2>/dev/null | sed -n 's/^status=//p' | tail -1) || { printf 'unknown'; return 0; } + case "$status" in + archived) printf 'idle' ;; + *) printf 'unknown' ;; + esac +} + +fm_backend_codex_app_target_exists() { # + fm_backend_codex_app_cmd status "$1" >/dev/null 2>&1 +} diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 1aba16e9..277d0b02 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -41,7 +41,7 @@ FM_BACKEND_CONFIG_DIR="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" # data/fm-backend-design-d7/herdr-addendum.md) - verified against the real # v0.7.1/protocol-14 binary (data/fm-backend-design-d7/herdr-verification-p2.md) # but newer than tmux's long-proven default path. -FM_BACKEND_KNOWN="tmux herdr" +FM_BACKEND_KNOWN="tmux herdr codex-app" # fm_backend_is_known: 0 iff has a verified adapter. fm_backend_is_known() { # @@ -182,6 +182,13 @@ fm_backend_source() { # _FM_BACKEND_HERDR_SOURCED=1 fi ;; + codex-app) + if [ -z "${_FM_BACKEND_CODEX_APP_SOURCED:-}" ]; then + # shellcheck source=bin/backends/codex-app.sh + . "$FM_BACKEND_LIB_DIR/backends/codex-app.sh" + _FM_BACKEND_CODEX_APP_SOURCED=1 + fi + ;; esac } @@ -239,6 +246,7 @@ fm_backend_capture() { # case "$backend" in tmux) fm_backend_tmux_capture "$@" ;; herdr) fm_backend_herdr_capture "$@" ;; + codex-app) fm_backend_codex_app_capture "$@" ;; *) echo "error: no capture implementation for backend '$backend'" >&2; return 1 ;; esac } @@ -251,13 +259,14 @@ fm_backend_send_key() { # case "$backend" in tmux) fm_backend_tmux_send_key "$@" ;; herdr) fm_backend_herdr_send_key "$@" ;; + codex-app) fm_backend_codex_app_send_key "$@" ;; *) echo "error: no send-key implementation for backend '$backend'" >&2; return 1 ;; esac } # fm_backend_send_text_submit: type text once, then submit and verify, # retrying only the submission (never retyping). Echoes the verdict -# (empty|pending|unknown|send-failed for the tmux and herdr adapters). +# (empty|pending|unknown|send-failed for current adapters). fm_backend_send_text_submit() { # local backend=$1 shift @@ -265,6 +274,7 @@ fm_backend_send_text_submit() { # &2; return 1 ;; esac } @@ -279,6 +289,7 @@ fm_backend_kill() { # case "$backend" in tmux) fm_backend_tmux_kill "$@" ;; herdr) fm_backend_herdr_kill "$@" ;; + codex-app) fm_backend_codex_app_kill "$@" ;; *) echo "error: no kill implementation for backend '$backend'" >&2; return 1 ;; esac } @@ -296,6 +307,7 @@ fm_backend_busy_state() { # fm_backend_source "$backend" || { printf 'unknown'; return 0; } case "$backend" in herdr) fm_backend_herdr_busy_state "$@" ;; + codex-app) fm_backend_codex_app_busy_state "$@" ;; *) printf 'unknown' ;; esac } @@ -324,6 +336,10 @@ fm_backend_target_exists() { # [ -n "$session" ] && [ -n "$pane" ] && [ "$pane" != "$target" ] || return 1 HERDR_SESSION="$session" herdr pane get "$pane" >/dev/null 2>&1 ;; + codex-app) + fm_backend_source codex-app || return 1 + fm_backend_codex_app_target_exists "$target" + ;; *) return 1 ;; diff --git a/bin/fm-codex-app b/bin/fm-codex-app index 3ed2b147..e9848749 100755 --- a/bin/fm-codex-app +++ b/bin/fm-codex-app @@ -190,6 +190,7 @@ async function main() { writeMeta(taskId, { backend: "codex-app", window: threadName, + codex_app_thread_name: threadName, harness: "codex", codex_app_thread_state: "pending", codex_app_pending_action: "create_thread_or_fork_thread", @@ -212,6 +213,7 @@ async function main() { throw new Error(`thread ${threadId} is already recorded for task ${existing.taskId}`); } writeMeta(taskId, { + window: threadId, thread_id: threadId, turn_id: flags.turn_id, worktree: flags.worktree, @@ -236,7 +238,8 @@ async function main() { const projectMode = resolveProjectMode(projectPath); writeMeta(taskId, { backend: "codex-app", - window: flags.thread_name || `fm-${taskId}`, + window: threadId, + codex_app_thread_name: flags.thread_name || `fm-${taskId}`, worktree: flags.worktree || "", project: projectPath, harness: flags.harness || "codex", diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 0b847137..311d956c 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -107,7 +107,7 @@ else exit 1 ;; send-failed) - echo "error: text not sent to $T (tmux send-keys failed)" >&2 + echo "error: text not sent to $T (backend send failed)" >&2 exit 1 ;; esac diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 917b4b3c..d02667dd 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -200,10 +200,11 @@ test_backend_name_explicit_beats_detection() { test_backend_validate_refuses_unknown() { fm_backend_validate tmux 2>/dev/null || fail "fm_backend_validate should accept tmux" + fm_backend_validate codex-app 2>/dev/null || fail "fm_backend_validate should accept codex-app" local out out=$(fm_backend_validate zellij 2>&1) && fail "fm_backend_validate should refuse zellij (P1 has no such adapter)" assert_contains "$out" "unknown backend 'zellij'" "fm_backend_validate did not name the rejected backend" - pass "fm_backend_validate: tmux accepted, an unimplemented backend refused loudly" + pass "fm_backend_validate: tmux and codex-app accepted, an unimplemented backend refused loudly" } test_meta_get_and_backend_of_meta() { @@ -219,6 +220,23 @@ test_meta_get_and_backend_of_meta() { pass "fm_meta_get / fm_backend_of_meta: read key=value, default backend to tmux" } +test_codex_app_backend_cached_capture_and_liveness() { + local home=$TMP_ROOT/codex-app-backend-home meta out + mkdir -p "$home/state" "$home/data" + meta="$home/state/codex-task.meta" + fm_write_meta "$meta" "backend=codex-app" "window=thread-codex" "thread_id=thread-codex" "codex_app_thread_state=visible" + printf 'alpha\nbeta\ngamma\n' > "$home/state/codex-task.codex-app.capture" + + out=$(FM_HOME="$home" fm_backend_capture codex-app thread-codex 2) + [ "$out" = "$(printf 'beta\ngamma')" ] || fail "codex-app backend should capture cached thread text" + FM_HOME="$home" fm_backend_target_exists codex-app thread-codex \ + || fail "codex-app backend should report recorded thread ids as existing" + [ "$(FM_HOME="$home" fm_backend_busy_state codex-app thread-codex)" = unknown ] \ + || fail "codex-app backend visible threads should report unknown busy state" + + pass "codex-app backend: cached capture, liveness, and busy state dispatch" +} + test_resolve_selector_three_forms() { local state=$TMP_ROOT/resolve-state fakebin out mkdir -p "$state" @@ -652,6 +670,7 @@ test_backend_name_autodetect_notice test_backend_name_explicit_beats_detection test_backend_validate_refuses_unknown test_meta_get_and_backend_of_meta +test_codex_app_backend_cached_capture_and_liveness test_resolve_selector_three_forms test_backend_of_selector_matches_explicit_target_meta test_send_conformance_old_vs_new diff --git a/tests/fm-codex-app-state.test.sh b/tests/fm-codex-app-state.test.sh index 707dea54..bd300a8e 100755 --- a/tests/fm-codex-app-state.test.sh +++ b/tests/fm-codex-app-state.test.sh @@ -20,6 +20,7 @@ EOF FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread "$ID" thread-1 --turn-id turn-1 --pending-worktree-id pending-1 >/dev/null grep -qx 'thread_id=thread-1' "$META" +grep -qx 'window=thread-1' "$META" grep -qx 'turn_id=turn-1' "$META" grep -qx 'codex_app_pending_worktree_id=pending-1' "$META" grep -qx 'codex_app_thread_state=visible' "$META" @@ -80,7 +81,8 @@ EOF FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted thread-2 /tmp/example --kind scout --thread-name fm-adopted --worktree /tmp/wt >/dev/null ADOPTED_META="$TMP/state/adopted.meta" grep -qx 'backend=codex-app' "$ADOPTED_META" -grep -qx 'window=fm-adopted' "$ADOPTED_META" +grep -qx 'window=thread-2' "$ADOPTED_META" +grep -qx 'codex_app_thread_name=fm-adopted' "$ADOPTED_META" grep -qx 'worktree=/tmp/wt' "$ADOPTED_META" grep -qx 'project=/tmp/example' "$ADOPTED_META" grep -qx 'harness=codex' "$ADOPTED_META" From 3ee41bbe33a886de78823e9b94a3db1c543a55d1 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Thu, 2 Jul 2026 23:19:48 -0400 Subject: [PATCH 03/19] no-mistakes(test): avoid empty array nounset failures --- bin/fm-pr-merge.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 8ffb32cc..ae438a60 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -87,4 +87,8 @@ if ! caller_has_merge_method "$@"; then merge_args=(--squash) fi -gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]}" "$@" +if [ "${#merge_args[@]}" -gt 0 ]; then + gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]}" "$@" +else + gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "$@" +fi From e2dd565fa4ea9423ccdf8e988f7dde7f337f68e1 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Thu, 2 Jul 2026 23:30:59 -0400 Subject: [PATCH 04/19] no-mistakes(document): Sync Codex App backend docs --- AGENTS.md | 14 +++++--- CONTRIBUTING.md | 4 ++- README.md | 10 +++--- bin/backends/codex-app.sh | 3 ++ bin/fm-backend.sh | 17 +++++---- bin/fm-spawn.sh | 4 ++- bin/fm-watch.sh | 8 ++--- docs/architecture.md | 8 +++-- docs/codex-app-backend.md | 72 +++++++++++++++++++++++++++++++++++++++ docs/configuration.md | 8 +++-- docs/herdr-backend.md | 2 +- docs/scripts.md | 5 ++- 12 files changed, 126 insertions(+), 29 deletions(-) create mode 100644 docs/codex-app-backend.md diff --git a/AGENTS.md b/AGENTS.md index d657a01b..7d7da8e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "de 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/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; 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" = default tasks-axi backend, "manual" = force 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, herdr is a second, experimental backend (docs/herdr-backend.md); not inherited into secondmate homes +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, herdr is a second, experimental backend (docs/herdr-backend.md), and codex-app is a Codex Desktop visible-thread ledger (docs/codex-app-backend.md); not inherited into secondmate homes config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history @@ -92,7 +92,7 @@ state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown - .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a task on a non-default runtime backend also records backend= (absent means tmux, the verified reference backend; herdr is a second, experimental backend recording herdr_session=, herdr_workspace_id=, herdr_tab_id=, herdr_pane_id= too - docs/herdr-backend.md; bin/fm-backend.sh, section 8) (fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request= and x_request_ts= for an X-mention-originated task, section 14) + .meta written by fm-spawn for spawned tasks: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a task on a non-default runtime backend also records backend= (absent means tmux, the verified reference backend; herdr is a second, experimental backend recording herdr_session=, herdr_workspace_id=, herdr_tab_id=, herdr_pane_id= too - docs/herdr-backend.md; bin/fm-backend.sh, section 8). fm-codex-app prepare/adopt writes codex-app ledger meta including backend=codex-app, window=, project=, harness=, kind=, mode=, yolo=, thread_id= once known, codex_app_thread_name=, codex_app_thread_state=, codex_app_pending_action=, codex_app_transport=visible-thread, and optionally turn_id= or codex_app_pending_worktree_id= - docs/codex-app-backend.md. (fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request= and x_request_ts= for an X-mention-originated task, section 14) .check.sh optional slow poll you write per task (e.g. merged-PR check) x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) @@ -113,6 +113,8 @@ For the tmux backend, the task window is always named `fm-`. For the herdr backend, the task tab is labeled `fm-` and the recorded `window=` target is `:`. For the herdr backend, task tabs live inside the current firstmate home's workspace: `firstmate` for the primary, `2ndmate-` for a secondmate home. For a primary-launched `--secondmate` spawn, `fm-spawn.sh` resolves that workspace from the target secondmate home, not the primary home. +For the codex-app backend, `window=` is the Codex App thread id once recorded; until then `fm-codex-app prepare` records a pending visible-thread name and brief for Codex Desktop to create or fork. +Codex App threads are app-owned: use Codex Desktop to read, send, interrupt, or archive, then mirror results into firstmate state with `bin/fm-codex-app`. ## 3. Session start (run at every session start) @@ -308,7 +310,7 @@ Reconcile reality with your records before doing anything else, working from the Treat those status tails as wake-event history; when you need a live current-state read for a recorded direct report, use `bin/fm-crew-state.sh ` instead of inferring from the last status line. If older wake-event history matters, read the individual full status log named in the digest instead of bulk-reading every status file. 4. Use the `window=` values from the digest's `state/*.meta` entries as the live direct-report set, and read the digest's per-task `endpoint: alive|dead` line for each - that cheap check is already done; do not re-probe it yourself. - Do not sweep every `fm-*` tmux window or herdr tab across all sessions during recovery; another firstmate home's child endpoints may share that namespace and are not this home's orphans. + Do not sweep every `fm-*` tmux window, herdr tab, or Codex App thread across all sessions during recovery; another firstmate home's child endpoints may share that namespace and are not this home's orphans. 5. If the digest reports a recorded direct-report's endpoint as `dead` (or a meta has no `window=`), reconcile it through its meta as described below. 6. For meta with no window, or an endpoint the digest reported dead, reconcile by kind. For ordinary crewmates, check `treehouse status` in that project, salvage or report. @@ -323,7 +325,7 @@ Reconcile reality with your records before doing anything else, working from the 10. Having already handled the drained wakes from the digest, follow the section 8 watcher checklist through the digest's own closing reminder; if the lock was refused or `state/.afk` exists, follow the digest's no-direct-arm guidance. A firstmate restart must be a non-event. -All truth lives in each task's backend live-task inventory (tmux by hard default, or herdr when selected or auto-detected), state files, data/backlog.md, data/captain.md, data/learnings.md, data/secondmates.md, persistent secondmate homes, and treehouse; your conversation memory is a cache. +All truth lives in each task's backend live-task inventory (tmux by hard default, herdr when selected or auto-detected, or codex-app's recorded visible-thread ledger), state files, data/backlog.md, data/captain.md, data/learnings.md, data/secondmates.md, persistent secondmate homes, and treehouse; your conversation memory is a cache. ## 6. Project management @@ -490,6 +492,7 @@ bin/fm-spawn.sh projects/ grok # per-task harness override bin/fm-spawn.sh projects/ --harness codex --model gpt-5.5 --effort high # explicit profile axes bin/fm-spawn.sh projects/ --backend tmux # explicit runtime backend; tmux is the verified reference backend bin/fm-spawn.sh projects/ --backend herdr # experimental herdr backend (docs/herdr-backend.md); version-gates at spawn +bin/fm-codex-app adopt-thread projects/ --kind scout # record a Codex App visible thread owned by Codex Desktop bin/fm-spawn.sh projects/ --scout # scout task; records kind=scout in meta bin/fm-spawn.sh --secondmate # launch a registered persistent secondmate in its home bin/fm-spawn.sh --secondmate # launch or recover an explicit secondmate home @@ -501,6 +504,7 @@ If one pair fails, the rest still run and the batch exits non-zero. When `config/crew-dispatch.json` exists, include a shared `--harness` for every crewmate or scout batch after consulting the dispatch rules. The script resolves the harness (`fm-harness.sh crew` for crewmate/scout tasks only when `config/crew-dispatch.json` is absent, `fm-harness.sh secondmate` for `kind=secondmate`; section 4), resolves the runtime backend (`--backend`, then `FM_BACKEND`, then `config/backend`, then runtime auto-detection - the runtime firstmate itself is executing inside, from `$TMUX`/`HERDR_ENV=1` markers, nesting resolved innermost-first - then `tmux`; an auto-detected herdr spawn prints a loud stderr notice, auto-detected tmux stays silent), owns the verified launch templates, resolves the project's delivery mode (`fm-project-mode.sh`) for ship/scout tasks, and records `harness=`, `model=`, `effort=`, `kind=`, `mode=`, and `yolo=` in the task's meta; only a non-default runtime backend is recorded as `backend=` because absent means tmux. +Codex App visible threads are recorded or adopted with `bin/fm-codex-app`, not created as headless shell endpoints by `fm-spawn.sh`; the backend adapter deliberately refuses app-owned sends and points you back to Codex Desktop. A non-flag third argument containing whitespace is treated as a raw launch command (only for verifying new adapters). When `config/crew-dispatch.json` exists, the script refuses crewmate or scout launches without an explicit harness because firstmate must have already resolved the profile choice at intake. When `--model` or `--effort` is omitted, the corresponding meta value is `default` and no launch flag is passed for that axis, except that a `kind=secondmate` spawn can fill the omitted axis from the optional tokens in `config/secondmate-harness`. @@ -689,7 +693,7 @@ Heartbeats back off exponentially while they are the only wakes firing (600s dou Due per-task checks run before signal scanning so chatty crewmate status updates cannot starve slow polls like merge detection. Never rely on hooks or status files alone; when a heartbeat wake does reach you, the review of every window is mandatory and unconditional. -Each task's backend live-task inventory is the ground truth (tmux when `backend=` is absent; a task's meta may record a different `backend=` - herdr is the one other implemented, experimental backend today, docs/herdr-backend.md). +Each task's backend live-task inventory is the ground truth (tmux when `backend=` is absent; a task's meta may record a different `backend=` - herdr is the implemented experimental session backend, and codex-app is the implemented Codex Desktop visible-thread ledger). For `kind=secondmate`, an idle pane is healthy. A secondmate may be sitting on its own watcher with no visible pane changes, so parent supervision uses status writes plus heartbeat review, not pane-staleness. `fm-watch.sh` therefore skips stale-pane wakes for windows whose meta records `kind=secondmate`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2de74545..1832d5f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star Everything personal to one captain's fleet (`.env`, `data/`, `state/`, `config/`, `projects/`, `.no-mistakes/`) is gitignored; never commit it. The root `.tasks.toml` is tracked `tasks-axi` config for `data/backlog.md`; compatible `tasks-axi` is the default backend for routine backlog mutations. A local `config/backlog-backend=manual` opt-out forces hand-editing and stays gitignored. - A local `config/backend` file explicitly overrides runtime auto-detection for new task endpoints and stays gitignored; accepted values are `tmux` and experimental `herdr`. + A local `config/backend` file explicitly overrides runtime auto-detection for new task endpoints and stays gitignored; accepted values are `tmux`, experimental `herdr`, and the Codex App visible-thread ledger `codex-app`. It does not make `data/` tracked. - Helper scripts in `bin/` are plain bash. Each starts with a usage header comment; keep it accurate when you change behavior. @@ -90,6 +90,8 @@ tests/fm-teardown.test.sh # fm-teardown.sh landed-work safety an tests/fm-pr-merge.test.sh # fm-pr-merge.sh records pr= and available pr_head= before merging, parses PR URLs into gh-axi number/--repo calls, defaults to squash, preserves explicit merge methods, rejects malformed URLs and repo overrides, and propagates real merge failures tests/fm-crew-state.test.sh # fm-crew-state.sh current-state reconciliation: run-step authority including closed panes, stale needs-decision/blocked superseded by a resumed run, genuine-parked, cross-branch attribution, pane/status-log fallback, scout skip, torn-down/missing-meta graceful tests/fm-backend.test.sh # runtime-backend abstraction: fm-backend.sh selection/meta/dispatch helpers, and old-vs-new fake-tool command-log conformance for fm-send/fm-peek/fm-spawn/fm-teardown +tests/fm-codex-app-state.test.sh # Codex App visible-thread ledger state transitions: record/adopt thread ids, pending worktree ids, cached captures, archive markers, duplicate guards, and symlinked-root discovery +tests/fm-codex-app-smoke-contract.test.sh # Codex App visible-thread smoke evidence contract: requires Desktop-visible list/read/send/archive/restart evidence and rejects headless app-server-only proof tests/fm-backend-tmux-smoke.test.sh # real (private-socket) tmux smoke test for the tmux adapter: create/duplicate-refuse, send text + Enter, send literal + key, bounded capture, live-window resolve, kill tests/fm-backend-herdr.test.sh # fake herdr CLI unit tests for the experimental herdr adapter, including version/tool gates, target parsing, send/capture, native busy state, per-home workspace-label resolution, default-tab prune safety, and verified CLI bug workarounds tests/fm-backend-herdr-smoke.test.sh # real herdr adapter smoke test, skipped when herdr or jq is unavailable, using an isolated throwaway HERDR_SESSION and guarded session cleanup diff --git a/README.md b/README.md index 1bb8c961..eddec549 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ This is.. a directory that turns any agent into your firstmate, and you the capt ## Features - **One liaison** - you talk only to the first mate; it dispatches, supervises, escalates only real decisions, and reports plain outcomes. -- **A visible crew** - every crewmate works in its own tmux window or experimental herdr tab you can watch or type into; the first mate reconciles. +- **A visible crew** - every crewmate works in its own tmux window, experimental herdr tab, or recorded Codex App visible thread you can watch or type into; the first mate reconciles. - **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, so parallel work on one repo never collides. - **Two task shapes** - ship tasks deliver a change; scout tasks investigate, plan, reproduce, or audit and leave a report. - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. @@ -48,7 +48,7 @@ This is.. a directory that turns any agent into your firstmate, and you the capt - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you. - **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post one public-safe completion follow-up without changing non-X behavior; dry-run preview records would-be replies and dismissals locally before go-live. - **Guarded by construction** - the first mate is read-only over your projects outside guarded clone refreshes, safe branch pruning, and approved `local-only` fast-forward merges; crewmates make every project change behind your merge approval. -- **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr when selected or auto-detected); kill the session anytime and the next one reconciles and carries on. +- **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr when selected or auto-detected, or Codex App thread metadata when adopted); kill the session anytime and the next one reconciles and carries on. Full detail on every feature lives in [docs/architecture.md](docs/architecture.md). @@ -85,6 +85,7 @@ Outside tmux, default-backend crewmates land in a detached `firstmate` session y When firstmate is running natively inside herdr and no backend override is set, it auto-detects herdr, prints an opt-out notice, and spawns into the experimental herdr backend. With experimental herdr, attach to the selected `HERDR_SESSION` and switch between firstmate-home workspaces. The primary home uses `firstmate`; each secondmate home uses `2ndmate-`, with that home's task tabs inside its own space. +Codex App visible threads are app-owned: firstmate can record/adopt their thread ids, cache captures, and reconcile liveness, while sends, interrupts, and archive actions are performed from Codex Desktop. ## How It Works @@ -100,7 +101,7 @@ The primary home uses `firstmate`; each secondmate home uses `2ndmate- [] []` to durably pin that secondmate's launch profile. The runtime session-provider backend is selected from explicit `--backend`, `FM_BACKEND`, local `config/backend`, runtime auto-detection from `$TMUX` or `HERDR_ENV=1`, then the hard `tmux` default. -`tmux` is the verified reference backend, and `herdr` is experimental. +`tmux` is the verified reference backend, `herdr` is experimental, and `codex-app` is a visible-thread ledger for threads owned by Codex Desktop rather than a headless spawn surface. Secondmate homes inherit the primary's declared local config, including `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`, at launch, during the locked session-start bootstrap step, or during an explicit `bin/fm-config-push.sh` run, so their own crewmates, dispatch profiles, and backlog backend use the primary settings. When a routed request goes to a secondmate, firstmate marks it so the answer returns through status or a document pointer; direct typing into that secondmate window stays conversational. A presence-gated sub-supervisor (`/afk`) can self-handle routine events and batch only what matters while you step away. @@ -151,6 +152,7 @@ Agent-only reference skills live under `.agents/skills/` and are loaded by first - [docs/architecture.md](docs/architecture.md) - how the crew, supervision, worktrees, secondmates, and project modes work. - [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, optional X mode, the files you set, and harness support. - [docs/herdr-backend.md](docs/herdr-backend.md) - experimental herdr backend verification notes and known gaps. +- [docs/codex-app-backend.md](docs/codex-app-backend.md) - Codex App visible-thread ledger behavior and smoke-evidence contract. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. - [`AGENTS.md`](AGENTS.md) - firstmate's full operating manual for the orchestrator agent. - [CONTRIBUTING.md](CONTRIBUTING.md) - how to contribute, including the dev/test commands. diff --git a/bin/backends/codex-app.sh b/bin/backends/codex-app.sh index c5e40dfc..8cba6c6e 100644 --- a/bin/backends/codex-app.sh +++ b/bin/backends/codex-app.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Codex App visible-thread backend adapter. +# The Codex Desktop app owns thread IO; this adapter exposes firstmate's local +# ledger as a backend target and refuses operations that must happen in Desktop. fm_backend_codex_app_cmd() { "$FM_BACKEND_LIB_DIR/fm-codex-app" "$@" diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 277d0b02..c040a5de 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -12,12 +12,14 @@ # `config/backend`, and behind runtime auto-detection when firstmate itself is # running inside herdr with no explicit backend setting; see herdr-addendum.md and # data/fm-backend-design-d7/herdr-verification-p2.md for its empirical basis. +# The codex-app adapter is a visible-thread ledger for Codex Desktop-owned +# threads: it records/captures local state and refuses to impersonate the app. # # Compatibility contract: a task's meta may omit `backend=`; every reader here # treats that as `tmux` (fm_backend_of_meta), and fm-spawn.sh does not write # `backend=tmux` for a default-backend task, so existing and newly spawned -# default-path metas stay byte-identical. Only a task spawned on a non-tmux -# backend, currently experimental herdr, carries an explicit `backend=` line. +# default-path metas stay byte-identical. Only a task on a non-tmux backend +# carries an explicit `backend=` line. # # Event-source framing (herdr-addendum "Events as the core abstraction"): a # backend's supervision surface is conceptually an EVENT SOURCE - it produces @@ -40,7 +42,8 @@ FM_BACKEND_CONFIG_DIR="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" # section 4's harness-verification discipline. herdr is EXPERIMENTAL (P2; # data/fm-backend-design-d7/herdr-addendum.md) - verified against the real # v0.7.1/protocol-14 binary (data/fm-backend-design-d7/herdr-verification-p2.md) -# but newer than tmux's long-proven default path. +# but newer than tmux's long-proven default path. codex-app is app-owned visible +# thread state, not a headless task creator. FM_BACKEND_KNOWN="tmux herdr codex-app" # fm_backend_is_known: 0 iff has a verified adapter. @@ -297,7 +300,8 @@ fm_backend_kill() { # # fm_backend_busy_state: semantic busy/idle/unknown for backends that expose # native agent-state (herdr-addendum "busy state" row - the first backend # where this gets real semantics beyond pane-regex). Backends with no such -# primitive (tmux) report unknown. Callers own the fallback policy: fm-watch.sh +# primitive (tmux, visible codex-app threads before archive) report unknown. +# Callers own the fallback policy: fm-watch.sh # uses unknown as the cue for its pane-hash + FM_BUSY_REGEX detection, while # fm-crew-state.sh also corroborates native idle verdicts before treating a # no-run crew as not busy. @@ -318,8 +322,9 @@ fm_backend_busy_state() { # # going through fm_backend_herdr_target_ready (which auto-starts the herdr # server as a side effect via fm_backend_herdr_server_ensure - fine for an # operation that is about to use the pane, wrong for a passive liveness -# probe). A gone tmux window or an unqueryable herdr pane (server down, pane -# closed) both simply fail, which IS "does not exist" for this purpose. +# probe). A gone tmux window, an unqueryable herdr pane (server down, pane +# closed), or an unrecorded codex-app thread simply fail, which IS "does not +# exist" for this purpose. # Mirrors fm-crew-state.sh's pane_readable check; exists here as one shared # primitive so callers that only need a fast alive/dead read (recovery # digests, the session-start fleet digest) do not re-derive it inline. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 1c4352a6..0afefb55 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -13,7 +13,9 @@ # spawn. Without it, the script resolves FM_BACKEND, then config/backend, then # runtime auto-detection (the runtime firstmate itself is executing inside - # $TMUX or HERDR_ENV=1; bin/fm-backend.sh's fm_backend_detect), then tmux. -# Known backends are the reference tmux adapter and experimental herdr. An +# Known spawn-created backends are the reference tmux adapter and experimental +# herdr. codex-app is a visible-thread ledger for Codex Desktop-owned threads, +# recorded/adopted through bin/fm-codex-app instead of created here. An # auto-detected herdr spawn prints a loud stderr notice; auto-detected tmux # stays silent. Default tmux spawns do not write backend= to meta; absent # backend= means tmux. diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 81a09a90..ebd6e06c 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -437,10 +437,10 @@ EOF if [ "$h" = "$prev" ]; then n=$(( $(cat "$cf" 2>/dev/null || echo 0) + 1 )) echo "$n" > "$cf" - # Busy match: a backend's native semantic state when available (herdr), - # else the last 6 non-blank lines only (the TUI footer area, where every - # verified harness renders its busy indicator) so busy-looking strings - # in displayed content cannot suppress stale detection. + # Busy match: a backend's native semantic state when available, else the + # last 6 non-blank lines only (the TUI footer area, where every verified + # harness renders its busy indicator) so busy-looking strings in displayed + # content cannot suppress stale detection. if [ "$n" -ge 2 ] && ! window_is_busy "$w" "$tail40"; then # The pane is idle/stale at hash $h. Triage decides whether this wakes # firstmate. Detection itself is unchanged from above. diff --git a/docs/architecture.md b/docs/architecture.md index 9a233eb5..69d38cf3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,15 +39,17 @@ Its tmux supervisor injection path shares the same submit core used by the tmux The runtime backend is the session-provider layer below firstmate's scripts. It owns task endpoint creation, bounded capture, text/key sends, current-path reads for spawn-time worktree discovery, live-window fallback lookup, and endpoint teardown. -`bin/fm-backend.sh` centralizes backend selection, `state/.meta` helpers, selector resolution, and operation dispatch; `bin/backends/tmux.sh` is the verified reference adapter, and `bin/backends/herdr.sh` (P2) is an experimental second adapter. +`bin/fm-backend.sh` centralizes backend selection, `state/.meta` helpers, selector resolution, and operation dispatch; `bin/backends/tmux.sh` is the verified reference adapter, `bin/backends/herdr.sh` (P2) is an experimental second adapter, and `bin/backends/codex-app.sh` is a visible-thread ledger for Codex Desktop-owned threads. New spawns select a backend from `--backend`, then `FM_BACKEND`, then local `config/backend`, then runtime auto-detection from `$TMUX` or `HERDR_ENV=1`, then default `tmux`. Runtime auto-detection is innermost-first: `$TMUX` wins over `HERDR_ENV=1` when firstmate is inside tmux nested in herdr, auto-detected herdr prints a one-time opt-out notice, and auto-detected tmux stays silent. Unknown backend names fail loudly. For compatibility, default tmux tasks do not write `backend=tmux`; every reader treats a missing `backend=` field as `tmux`. -`fm-watch.sh` polls each window's backend for a busy state: tmux has no native primitive and always reports unknown, preserving its original pane-tail-regex detection unchanged; herdr's `agent.get` semantic state (working/idle/done/blocked) is consulted first for stale detection, with unknown native states falling back to the same regex. +Codex App thread tasks write `backend=codex-app` plus thread ledger fields; capture reads the last cached transcript and liveness checks the local thread record, while text sends, interrupts, and archives deliberately tell firstmate to use Codex Desktop's visible thread tools. +`fm-watch.sh` polls each window's backend for a busy state: tmux has no native primitive and always reports unknown, preserving its original pane-tail-regex detection unchanged; herdr's `agent.get` semantic state (working/idle/done/blocked) is consulted first for stale detection, and Codex App visible threads report `idle` only after firstmate has marked them archived. That poll loop is the default event source for backends with no native push events, so this stays an extraction of the abstraction rather than a watcher rewrite. Herdr is experimental and can be selected explicitly or by runtime auto-detection: treehouse remains the worktree provider for it exactly as it is for tmux (herdr is a session provider only), and its full verification - the container shape decision, created-vs-adopted default-tab prune safety, verified CLI facts, a verified small-`--lines` capture bug and its workaround, and known gaps - is recorded in `docs/herdr-backend.md`. Herdr's container shape is workspace-per-home plus tab-per-task: the primary home uses workspace label `firstmate`, secondmate homes use `2ndmate-`, and recovery/list-live scopes to the current `FM_HOME`'s workspace. +Codex App support is intentionally not a headless app-server integration; `bin/fm-codex-app` keeps firstmate's local state in sync with visible threads created, forked, read, messaged, and archived through Codex Desktop. ## Worktrees, not branches in your checkout @@ -185,7 +187,7 @@ The mechanics are owned by the `/updatefirstmate` skill and firstmate's operatin ## Restart-proof -Fleet state lives in each task's session-provider backend (tmux by hard default, herdr when selected or auto-detected), no-mistakes run records, status event logs, local markdown under `data/` including `data/captain.md` and `data/learnings.md`, and persistent secondmate homes. +Fleet state lives in each task's session-provider backend (tmux by hard default, herdr when selected or auto-detected, or Codex App thread ledger state when adopted), no-mistakes run records, status event logs, local markdown under `data/` including `data/captain.md` and `data/learnings.md`, and persistent secondmate homes. Use `/stow` before an intentional reset when the conversation may hold durable knowledge that has not yet been written to disk; after that, the next firstmate session can reconcile and carry on. ## Development notes diff --git a/docs/codex-app-backend.md b/docs/codex-app-backend.md new file mode 100644 index 00000000..e846f94a --- /dev/null +++ b/docs/codex-app-backend.md @@ -0,0 +1,72 @@ +# Codex App backend + +`codex-app` is a visible-thread ledger for Codex Desktop. +It is not a headless app-server transport and does not pretend to own the thread API. + +Codex App threads are created, forked, read, messaged, interrupted, and archived in Codex Desktop. +Firstmate records enough local state for its normal backend abstraction to reconcile those visible threads alongside tmux and herdr tasks. + +## State model + +Use `bin/fm-codex-app prepare ` before creating or forking a visible thread from a brief. +It writes pending task metadata with `backend=codex-app`, `window=`, `codex_app_thread_name=`, `codex_app_thread_state=pending`, `codex_app_pending_action=create_thread_or_fork_thread`, and `codex_app_transport=visible-thread`. + +After Codex Desktop has a real thread, record it with: + +```sh +bin/fm-codex-app record-thread [--turn-id ] [--worktree ] [--pending-worktree-id ] +``` + +That changes `window=` to the thread id, records `thread_id=`, sets `codex_app_thread_state=visible`, and clears the pending action. +`record-pending` stores a pending worktree id when Desktop has created a worktree request but the final thread id is not known yet. + +To bring an already-visible Desktop thread under firstmate supervision, use: + +```sh +bin/fm-codex-app adopt-thread --kind [--thread-name ] [--mode ] [--yolo ] [--worktree ] [--turn-id ] [--pending-worktree-id ] [--brief ] +``` + +Adoption refuses duplicate task ids and duplicate thread ids. +If `--mode` or `--yolo` is omitted, it resolves the project mode from `data/projects.md` and falls back to `no-mistakes`/`off`. + +## Backend operations + +`bin/backends/codex-app.sh` routes backend operations through `bin/fm-codex-app`. + +Capture is local and cached. +After reading a thread in Codex Desktop, cache the transcript with: + +```sh +bin/fm-codex-app record-capture +``` + +`fm-peek.sh` and `fm_backend_capture codex-app ` then read the tail of `state/.codex-app.capture`. +If no cached transcript exists, capture fails with a Desktop instruction instead of fabricating data. + +Text send, interrupt, and archive operations are app-owned. +The adapter exits with a clear instruction to use Codex Desktop, then mirror the result into firstmate state. +After archiving in Desktop, run: + +```sh +bin/fm-codex-app mark-archived +``` + +Archived threads report `status=archived`; the backend maps that to `idle`. +Visible or pending threads report unknown busy state, so supervision does not claim a live Desktop thread is idle without an explicit archived marker. + +## Smoke evidence + +`bin/fm-codex-app-smoke-check.sh ` validates simple key-value smoke evidence for this integration. +A passing transcript must include: + +```text +visible_thread=ok +list_threads=ok +read_thread=ok +send_message_to_thread=ok +archive=ok +restart_reconcile=ok +``` + +The checker rejects `app_server_only=ok` or `headless_only=ok`. +That is deliberate: a headless app-server proof does not verify the visible Codex App thread workflow firstmate relies on. diff --git a/docs/configuration.md b/docs/configuration.md index 5649c7a6..df0c8fcf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,13 +20,15 @@ The file format is unchanged in both modes; tasks-axi and manual edits produce t The runtime session-provider backend controls where task windows/endpoints are created, captured, sent to, watched, and killed. `tmux` is the verified reference backend; `herdr` is a second, experimental backend (see `docs/herdr-backend.md`) - treehouse remains the worktree provider for both, since herdr is a session provider only. -New spawns choose the backend in this order: explicit `fm-spawn.sh --backend `, then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX` or `HERDR_ENV=1`, then default `tmux`. +`codex-app` is a Codex Desktop visible-thread ledger (see `docs/codex-app-backend.md`): it records/adopts app-owned threads and cached captures, but does not create or drive a headless task endpoint. +Spawn-created session endpoints choose the backend in this order: explicit `fm-spawn.sh --backend `, then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX` or `HERDR_ENV=1`, then default `tmux`. If both runtime markers are present, `$TMUX` wins because tmux is the innermost surface firstmate is running on. Auto-detected herdr prints a stderr notice naming `config/backend` and `--backend tmux` as opt-outs; auto-detected tmux stays silent to preserve existing default behavior. -Any value other than `tmux` or `herdr` is rejected until another adapter is implemented and verified. +Any value other than `tmux`, `herdr`, or `codex-app` is rejected until another adapter is implemented and verified. A herdr spawn additionally version-gates against the installed `herdr` binary's protocol and requires `jq`, refusing loudly on an incompatible or missing installation. Task meta records `backend=` only for a non-default backend; an absent `backend=` means `tmux`, preserving existing default-path meta files. A herdr task additionally records `herdr_session=`, `herdr_workspace_id=`, `herdr_tab_id=`, and `herdr_pane_id=`. +Codex App thread records use `backend=codex-app`, `thread_id=`, `codex_app_thread_name=`, `codex_app_thread_state=`, `codex_app_pending_action=`, and `codex_app_transport=visible-thread`; cached reads live at `state/.codex-app.capture`. Herdr workspaces are derived from `FM_HOME`: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-`. Spawn, list-live, and recovery paths read that label from the active home, so a secondmate's own crewmates stay inside that secondmate home's herdr space. For normal herdr operations, `HERDR_SESSION` selects the named session, but destructive test cleanup must not rely on `HERDR_SESSION` alone. @@ -181,7 +183,7 @@ FM_STATE_OVERRIDE= # alternate state dir, mainly for tests FM_DATA_OVERRIDE= # alternate data dir, mainly for tests FM_PROJECTS_OVERRIDE= # alternate projects dir, mainly for tests FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests -FM_BACKEND= # optional runtime session-provider backend override for new spawns; tmux (reference) or herdr (experimental) +FM_BACKEND= # optional runtime session-provider backend override; tmux (reference), herdr (experimental), or codex-app (Codex Desktop visible-thread ledger, not a headless spawn surface) HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 86da7060..5857d0cf 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -19,7 +19,7 @@ When nothing is explicitly configured, `bin/fm-backend.sh`'s `fm_backend_detect` An auto-detected herdr spawn prints one loud stderr notice (set `config/backend` or pass `--backend tmux` to opt out). Auto-detecting tmux stays silent, since that reproduces today's unconfigured default byte-for-byte. Only when none of that resolves anything does firstmate fall back to the hard default, tmux. -Absent `backend=` in a task's meta always means `tmux`; only a herdr task ever carries an explicit `backend=herdr` line. +Absent `backend=` in a task's meta always means `tmux`; herdr tasks carry `backend=herdr`, while other non-tmux adapters such as `codex-app` carry their own explicit backend value. A herdr spawn refuses loudly if `herdr` or `jq` is missing, or if the installed herdr's protocol is older than the verified minimum (`fm_backend_herdr_version_check`). ## Worktree provider stays treehouse diff --git a/docs/scripts.md b/docs/scripts.md index 8f93a03d..b0d1e885 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -14,10 +14,13 @@ Each file also starts with a short header comment. | `fm-ensure-agents-md.sh` | Ensure project `AGENTS.md` is the real memory file and `CLAUDE.md` symlinks to it | | `fm-guard.sh` | Warn when the primary checkout is tangled, when queued wakes are pending, or when a stale or missing watcher needs a prominent banner; `FM_GUARD_READ_ONLY=1` keeps the alarms but suppresses drain, arm, and checkout repair commands | | `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects, initialize gates, and maintain `data/secondmates.md` | -| `fm-spawn.sh` | Spawn one task, several `id=repo` pairs, or a persistent secondmate with `--secondmate`; accepts concrete `--harness`, `--model`, `--effort`, and `--backend` axes; ship/scout spawns require an explicit resolved harness when dispatch profiles are active and an isolated treehouse worktree, install per-harness turn-end signaling, and secondmate spawns resolve the secondmate harness plus optional `config/secondmate-harness` model/effort tokens, locally sync the home, propagate declared inheritable config, and land herdr tabs in the target home's workspace before launch | +| `fm-spawn.sh` | Spawn one task, several `id=repo` pairs, or a persistent secondmate with `--secondmate`; accepts concrete `--harness`, `--model`, `--effort`, and spawn-created `--backend` axes; ship/scout spawns require an explicit resolved harness when dispatch profiles are active and an isolated treehouse worktree, install per-harness turn-end signaling, and secondmate spawns resolve the secondmate harness plus optional `config/secondmate-harness` model/effort tokens, locally sync the home, propagate declared inheritable config, and land herdr tabs in the target home's workspace before launch | | `fm-backend.sh` | Runtime session-provider backend selector with explicit/env/config/runtime auto-detection precedence, meta helper, selector resolver, and operation dispatcher; defaults absent `backend=` meta to `tmux`; `fm_backend_target_exists` is a cheap read-only alive/dead endpoint check that never starts a server or session | | `backends/tmux.sh` | Verified tmux session-provider adapter used by `fm-backend.sh`; owns create, send, capture, current-path, live-window, and kill primitives | | `backends/herdr.sh` | Experimental herdr session-provider adapter used by `fm-backend.sh`; owns version/tool gating, per-home workspace/tab creation, created-vs-adopted default-tab prune safety, session-scoped CLI calls, send, capture, native busy-state, current-path, label-based live discovery, and kill primitives | +| `backends/codex-app.sh` | Codex App visible-thread adapter used by `fm-backend.sh`; owns cached capture, local recorded-thread liveness, archived-state busy mapping, and refusal messages that route sends/interrupts/archive actions back through Codex Desktop | +| `fm-codex-app` | Local Codex App visible-thread ledger: prepare/adopt task meta, record thread ids, pending worktree ids, cached captures, archived state, and emit Desktop-tool instructions for app-owned operations | +| `fm-codex-app-smoke-check.sh` | Validate pasted Codex App visible-thread smoke evidence, requiring list/read/send/archive/restart reconciliation and rejecting headless app-server-only evidence | | `fm-config-push.sh` | Config-only mid-session push of declared inheritable local config into live secondmate homes; reports each item as pushed, unchanged, skipped, or error without fast-forwarding tracked files or nudging agents | | `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` | | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | From f71f6c4eac4383ccc3a41711b0889631c2b62022 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Thu, 2 Jul 2026 23:44:14 -0400 Subject: [PATCH 05/19] fix codex app ledger backend safety --- AGENTS.md | 6 ++--- CONTRIBUTING.md | 2 +- README.md | 4 +-- bin/fm-backend.sh | 19 ++++++++++++++ bin/fm-codex-app | 11 ++++++++ bin/fm-spawn.sh | 2 +- docs/architecture.md | 4 +-- docs/codex-app-backend.md | 7 ++++- docs/configuration.md | 6 +++-- tests/fm-backend.test.sh | 45 ++++++++++++++++++++++++++++++-- tests/fm-codex-app-state.test.sh | 24 +++++++++++++++++ 11 files changed, 116 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7d7da8e9..8593f9df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "de 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/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; 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" = default tasks-axi backend, "manual" = force 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, herdr is a second, experimental backend (docs/herdr-backend.md), and codex-app is a Codex Desktop visible-thread ledger (docs/codex-app-backend.md); not inherited into secondmate homes +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 and herdr is a second, experimental backend (docs/herdr-backend.md); codex-app is a Codex Desktop visible-thread ledger for prepared/adopted threads, not a selectable new-spawn backend until a full spawn lifecycle exists (docs/codex-app-backend.md); not inherited into secondmate homes config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history @@ -503,7 +503,7 @@ Dispatch several tasks in one call by passing `id=repo` pairs instead of a singl If one pair fails, the rest still run and the batch exits non-zero. When `config/crew-dispatch.json` exists, include a shared `--harness` for every crewmate or scout batch after consulting the dispatch rules. -The script resolves the harness (`fm-harness.sh crew` for crewmate/scout tasks only when `config/crew-dispatch.json` is absent, `fm-harness.sh secondmate` for `kind=secondmate`; section 4), resolves the runtime backend (`--backend`, then `FM_BACKEND`, then `config/backend`, then runtime auto-detection - the runtime firstmate itself is executing inside, from `$TMUX`/`HERDR_ENV=1` markers, nesting resolved innermost-first - then `tmux`; an auto-detected herdr spawn prints a loud stderr notice, auto-detected tmux stays silent), owns the verified launch templates, resolves the project's delivery mode (`fm-project-mode.sh`) for ship/scout tasks, and records `harness=`, `model=`, `effort=`, `kind=`, `mode=`, and `yolo=` in the task's meta; only a non-default runtime backend is recorded as `backend=` because absent means tmux. +The script resolves the harness (`fm-harness.sh crew` for crewmate/scout tasks only when `config/crew-dispatch.json` is absent, `fm-harness.sh secondmate` for `kind=secondmate`; section 4), resolves the runtime backend (`--backend`, then `FM_BACKEND`, then `config/backend`, then runtime auto-detection - the runtime firstmate itself is executing inside, from `$TMUX`/`HERDR_ENV=1` markers, nesting resolved innermost-first - then `tmux`; an auto-detected herdr spawn prints a loud stderr notice, auto-detected tmux stays silent), refuses non-spawnable metadata-only backends such as codex-app, owns the verified launch templates, resolves the project's delivery mode (`fm-project-mode.sh`) for ship/scout tasks, and records `harness=`, `model=`, `effort=`, `kind=`, `mode=`, and `yolo=` in the task's meta; only a non-default runtime backend is recorded as `backend=` because absent means tmux. Codex App visible threads are recorded or adopted with `bin/fm-codex-app`, not created as headless shell endpoints by `fm-spawn.sh`; the backend adapter deliberately refuses app-owned sends and points you back to Codex Desktop. A non-flag third argument containing whitespace is treated as a raw launch command (only for verifying new adapters). When `config/crew-dispatch.json` exists, the script refuses crewmate or scout launches without an explicit harness because firstmate must have already resolved the profile choice at intake. @@ -693,7 +693,7 @@ Heartbeats back off exponentially while they are the only wakes firing (600s dou Due per-task checks run before signal scanning so chatty crewmate status updates cannot starve slow polls like merge detection. Never rely on hooks or status files alone; when a heartbeat wake does reach you, the review of every window is mandatory and unconditional. -Each task's backend live-task inventory is the ground truth (tmux when `backend=` is absent; a task's meta may record a different `backend=` - herdr is the implemented experimental session backend, and codex-app is the implemented Codex Desktop visible-thread ledger). +Each task's backend live-task inventory is the ground truth (tmux when `backend=` is absent; a task's meta may record a different `backend=` - herdr is the implemented experimental session backend, and codex-app is the implemented Codex Desktop visible-thread ledger for prepared/adopted threads, not new spawns). For `kind=secondmate`, an idle pane is healthy. A secondmate may be sitting on its own watcher with no visible pane changes, so parent supervision uses status writes plus heartbeat review, not pane-staleness. `fm-watch.sh` therefore skips stale-pane wakes for windows whose meta records `kind=secondmate`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1832d5f0..0e96c230 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star Everything personal to one captain's fleet (`.env`, `data/`, `state/`, `config/`, `projects/`, `.no-mistakes/`) is gitignored; never commit it. The root `.tasks.toml` is tracked `tasks-axi` config for `data/backlog.md`; compatible `tasks-axi` is the default backend for routine backlog mutations. A local `config/backlog-backend=manual` opt-out forces hand-editing and stays gitignored. - A local `config/backend` file explicitly overrides runtime auto-detection for new task endpoints and stays gitignored; accepted values are `tmux`, experimental `herdr`, and the Codex App visible-thread ledger `codex-app`. + A local `config/backend` file explicitly overrides runtime auto-detection for new task endpoints and stays gitignored; accepted values for new spawns are `tmux` and experimental `herdr`. It does not make `data/` tracked. - Helper scripts in `bin/` are plain bash. Each starts with a usage header comment; keep it accurate when you change behavior. diff --git a/README.md b/README.md index eddec549..90661533 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,8 @@ Persistent secondmate homes are linked firstmate worktrees; locked session start Crewmate dispatch can stay on a static `config/crew-harness` or use optional natural-language profiles in local `config/crew-dispatch.json` to choose a per-task harness, model, and effort. When that profile file exists, crewmate and scout spawns must pass the resolved harness explicitly so `config/crew-harness` is not used as an unnoticed bypass. Secondmate launch can use a separate local `config/secondmate-harness`, whose first non-empty, non-comment line is parsed as ` [] []` to durably pin that secondmate's launch profile. -The runtime session-provider backend is selected from explicit `--backend`, `FM_BACKEND`, local `config/backend`, runtime auto-detection from `$TMUX` or `HERDR_ENV=1`, then the hard `tmux` default. -`tmux` is the verified reference backend, `herdr` is experimental, and `codex-app` is a visible-thread ledger for threads owned by Codex Desktop rather than a headless spawn surface. +The runtime session-provider backend for new spawns is selected from explicit `--backend`, `FM_BACKEND`, local `config/backend`, runtime auto-detection from `$TMUX` or `HERDR_ENV=1`, then the hard `tmux` default. +`tmux` is the verified reference backend and `herdr` is experimental; `codex-app` is only a visible-thread ledger for prepared or adopted Codex Desktop threads until a full spawn lifecycle exists. Secondmate homes inherit the primary's declared local config, including `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`, at launch, during the locked session-start bootstrap step, or during an explicit `bin/fm-config-push.sh` run, so their own crewmates, dispatch profiles, and backlog backend use the primary settings. When a routed request goes to a secondmate, firstmate marks it so the answer returns through status or a document pointer; direct typing into that secondmate window stays conversational. A presence-gated sub-supervisor (`/afk`) can self-handle routine events and batch only what matters while you step away. diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index c040a5de..af271c1a 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -45,6 +45,7 @@ FM_BACKEND_CONFIG_DIR="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" # but newer than tmux's long-proven default path. codex-app is app-owned visible # thread state, not a headless task creator. FM_BACKEND_KNOWN="tmux herdr codex-app" +FM_BACKEND_SPAWNABLE="tmux herdr" # fm_backend_is_known: 0 iff has a verified adapter. fm_backend_is_known() { # @@ -55,6 +56,14 @@ fm_backend_is_known() { # return 1 } +fm_backend_is_spawnable() { # + local name=$1 backend + for backend in $FM_BACKEND_SPAWNABLE; do + [ "$name" = "$backend" ] && return 0 + done + return 1 +} + # fm_backend_detect: detect the runtime firstmate itself is CURRENTLY executing # inside, from verified environment markers (mirrors bin/fm-harness.sh's # env-marker detection layer for harnesses). Prints the detected backend name @@ -122,6 +131,16 @@ fm_backend_validate() { # return 0 } +fm_backend_validate_spawnable() { # + local name=$1 + fm_backend_validate "$name" || return 1 + if ! fm_backend_is_spawnable "$name"; then + echo "error: backend '$name' cannot create new shell endpoints yet (spawnable: $FM_BACKEND_SPAWNABLE); use bin/fm-codex-app prepare/adopt-thread for Codex App visible threads" >&2 + return 1 + fi + return 0 +} + # fm_meta_get: the LAST value of `key=` in , or empty (never # errors) if the file or key is absent. Mirrors the ad hoc `grep '^key=' | # tail -1 | cut -d= -f2-` snippet every fm-*.sh script used to repeat inline. diff --git a/bin/fm-codex-app b/bin/fm-codex-app index e9848749..00c75695 100755 --- a/bin/fm-codex-app +++ b/bin/fm-codex-app @@ -78,6 +78,14 @@ function ensureMetaExists(taskId, action) { } } +function ensureCodexAppMeta(taskId, action) { + const meta = readMeta(taskId); + if (meta.backend !== "codex-app") { + throw new Error(`${action} requires backend=codex-app meta for task ${taskId}; refusing to overwrite ${meta.backend || "tmux/default"} routing`); + } + return meta; +} + function writeMeta(taskId, values) { ensureState(); const file = metaPath(taskId); @@ -207,6 +215,7 @@ async function main() { const [taskId, threadId, ...rest] = args; if (!taskId || !threadId) usage(); ensureMetaExists(taskId, "record-thread"); + ensureCodexAppMeta(taskId, "record-thread"); const flags = parseFlags(rest); const existing = taskForThread(threadId); if (existing && existing.taskId !== taskId) { @@ -308,6 +317,8 @@ async function main() { console.log(lastLines(fs.readFileSync(file, "utf8"), Number.isFinite(limit) && limit > 0 ? limit : 40)); return; } + console.log(""); + return; } console.error(`error: ${hostToolMessage("capture", threadId, found?.taskId)}`); process.exit(2); diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 0afefb55..91970152 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -150,7 +150,7 @@ if [ "$BACKEND_SET" -eq 1 ]; then else BACKEND=$(fm_backend_name) fi -fm_backend_validate "$BACKEND" || exit 1 +fm_backend_validate_spawnable "$BACKEND" || exit 1 fm_backend_source "$BACKEND" || exit 1 # Batch dispatch (see header): when the first positional is an `id=repo` pair, treat every diff --git a/docs/architecture.md b/docs/architecture.md index 69d38cf3..e95a34cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,11 +40,11 @@ Its tmux supervisor injection path shares the same submit core used by the tmux The runtime backend is the session-provider layer below firstmate's scripts. It owns task endpoint creation, bounded capture, text/key sends, current-path reads for spawn-time worktree discovery, live-window fallback lookup, and endpoint teardown. `bin/fm-backend.sh` centralizes backend selection, `state/.meta` helpers, selector resolution, and operation dispatch; `bin/backends/tmux.sh` is the verified reference adapter, `bin/backends/herdr.sh` (P2) is an experimental second adapter, and `bin/backends/codex-app.sh` is a visible-thread ledger for Codex Desktop-owned threads. -New spawns select a backend from `--backend`, then `FM_BACKEND`, then local `config/backend`, then runtime auto-detection from `$TMUX` or `HERDR_ENV=1`, then default `tmux`. +New spawns select a backend from `--backend`, then `FM_BACKEND`, then local `config/backend`, then runtime auto-detection from `$TMUX` or `HERDR_ENV=1`, then default `tmux`; only `tmux` and `herdr` are spawnable today. Runtime auto-detection is innermost-first: `$TMUX` wins over `HERDR_ENV=1` when firstmate is inside tmux nested in herdr, auto-detected herdr prints a one-time opt-out notice, and auto-detected tmux stays silent. Unknown backend names fail loudly. For compatibility, default tmux tasks do not write `backend=tmux`; every reader treats a missing `backend=` field as `tmux`. -Codex App thread tasks write `backend=codex-app` plus thread ledger fields; capture reads the last cached transcript and liveness checks the local thread record, while text sends, interrupts, and archives deliberately tell firstmate to use Codex Desktop's visible thread tools. +Codex App thread tasks write `backend=codex-app` plus thread ledger fields; capture reads the last cached transcript or returns empty output for an uncached recorded thread, and liveness checks the local thread record, while text sends, interrupts, and archives deliberately tell firstmate to use Codex Desktop's visible thread tools. `fm-watch.sh` polls each window's backend for a busy state: tmux has no native primitive and always reports unknown, preserving its original pane-tail-regex detection unchanged; herdr's `agent.get` semantic state (working/idle/done/blocked) is consulted first for stale detection, and Codex App visible threads report `idle` only after firstmate has marked them archived. That poll loop is the default event source for backends with no native push events, so this stays an extraction of the abstraction rather than a watcher rewrite. Herdr is experimental and can be selected explicitly or by runtime auto-detection: treehouse remains the worktree provider for it exactly as it is for tmux (herdr is a session provider only), and its full verification - the container shape decision, created-vs-adopted default-tab prune safety, verified CLI facts, a verified small-`--lines` capture bug and its workaround, and known gaps - is recorded in `docs/herdr-backend.md`. diff --git a/docs/codex-app-backend.md b/docs/codex-app-backend.md index e846f94a..cc5cf328 100644 --- a/docs/codex-app-backend.md +++ b/docs/codex-app-backend.md @@ -41,7 +41,9 @@ bin/fm-codex-app record-capture ``` `fm-peek.sh` and `fm_backend_capture codex-app ` then read the tail of `state/.codex-app.capture`. -If no cached transcript exists, capture fails with a Desktop instruction instead of fabricating data. +If no cached transcript exists for a recorded thread, capture succeeds with empty output. +That keeps passive liveness and peek-style readers from treating a visible Desktop thread as gone merely because firstmate has not cached a transcript yet. +An unrecorded thread id still fails with a Desktop instruction. Text send, interrupt, and archive operations are app-owned. The adapter exits with a clear instruction to use Codex Desktop, then mirror the result into firstmate state. @@ -54,6 +56,9 @@ bin/fm-codex-app mark-archived Archived threads report `status=archived`; the backend maps that to `idle`. Visible or pending threads report unknown busy state, so supervision does not claim a live Desktop thread is idle without an explicit archived marker. +`codex-app` is not selectable for new spawns through `--backend`, `FM_BACKEND`, or `config/backend` until a complete visible-thread spawn lifecycle exists. +Use `prepare`, `record-thread`, or `adopt-thread` to create codex-app metadata for Desktop-owned visible threads. + ## Smoke evidence `bin/fm-codex-app-smoke-check.sh ` validates simple key-value smoke evidence for this integration. diff --git a/docs/configuration.md b/docs/configuration.md index df0c8fcf..0aa70bc9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,9 +22,11 @@ The runtime session-provider backend controls where task windows/endpoints are c `tmux` is the verified reference backend; `herdr` is a second, experimental backend (see `docs/herdr-backend.md`) - treehouse remains the worktree provider for both, since herdr is a session provider only. `codex-app` is a Codex Desktop visible-thread ledger (see `docs/codex-app-backend.md`): it records/adopts app-owned threads and cached captures, but does not create or drive a headless task endpoint. Spawn-created session endpoints choose the backend in this order: explicit `fm-spawn.sh --backend `, then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX` or `HERDR_ENV=1`, then default `tmux`. +Only `tmux` and `herdr` are currently accepted for new spawns. If both runtime markers are present, `$TMUX` wins because tmux is the innermost surface firstmate is running on. Auto-detected herdr prints a stderr notice naming `config/backend` and `--backend tmux` as opt-outs; auto-detected tmux stays silent to preserve existing default behavior. -Any value other than `tmux`, `herdr`, or `codex-app` is rejected until another adapter is implemented and verified. +Any value other than `tmux`, `herdr`, or a metadata-only adapter such as `codex-app` is rejected until another adapter is implemented and verified. +Selecting `codex-app` through `--backend`, `FM_BACKEND`, or `config/backend` is rejected by `fm-spawn.sh` until a complete visible-thread spawn lifecycle exists; use `bin/fm-codex-app prepare` or `adopt-thread` instead. A herdr spawn additionally version-gates against the installed `herdr` binary's protocol and requires `jq`, refusing loudly on an incompatible or missing installation. Task meta records `backend=` only for a non-default backend; an absent `backend=` means `tmux`, preserving existing default-path meta files. A herdr task additionally records `herdr_session=`, `herdr_workspace_id=`, `herdr_tab_id=`, and `herdr_pane_id=`. @@ -183,7 +185,7 @@ FM_STATE_OVERRIDE= # alternate state dir, mainly for tests FM_DATA_OVERRIDE= # alternate data dir, mainly for tests FM_PROJECTS_OVERRIDE= # alternate projects dir, mainly for tests FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests -FM_BACKEND= # optional runtime session-provider backend override; tmux (reference), herdr (experimental), or codex-app (Codex Desktop visible-thread ledger, not a headless spawn surface) +FM_BACKEND= # optional runtime session-provider backend override for new spawns; tmux (reference) or herdr (experimental) HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index d02667dd..a2a31358 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -200,11 +200,16 @@ test_backend_name_explicit_beats_detection() { test_backend_validate_refuses_unknown() { fm_backend_validate tmux 2>/dev/null || fail "fm_backend_validate should accept tmux" - fm_backend_validate codex-app 2>/dev/null || fail "fm_backend_validate should accept codex-app" + fm_backend_validate codex-app 2>/dev/null || fail "fm_backend_validate should accept codex-app for adopted metadata" + if fm_backend_validate_spawnable codex-app 2>"$TMP_ROOT/codex-spawnable.err"; then + fail "fm_backend_validate_spawnable should refuse codex-app until spawn lifecycle exists" + fi + assert_contains "$(cat "$TMP_ROOT/codex-spawnable.err")" "cannot create new shell endpoints yet" \ + "codex-app spawn refusal did not explain the visible-thread path" local out out=$(fm_backend_validate zellij 2>&1) && fail "fm_backend_validate should refuse zellij (P1 has no such adapter)" assert_contains "$out" "unknown backend 'zellij'" "fm_backend_validate did not name the rejected backend" - pass "fm_backend_validate: tmux and codex-app accepted, an unimplemented backend refused loudly" + pass "fm_backend_validate: tmux/codex-app known, codex-app not spawnable, unknown refused loudly" } test_meta_get_and_backend_of_meta() { @@ -229,8 +234,13 @@ test_codex_app_backend_cached_capture_and_liveness() { out=$(FM_HOME="$home" fm_backend_capture codex-app thread-codex 2) [ "$out" = "$(printf 'beta\ngamma')" ] || fail "codex-app backend should capture cached thread text" + fm_write_meta "$home/state/codex-uncached.meta" "backend=codex-app" "window=thread-uncached" "thread_id=thread-uncached" "codex_app_thread_state=visible" + out=$(FM_HOME="$home" fm_backend_capture codex-app thread-uncached 2) + [ "$out" = "" ] || fail "codex-app backend should return empty capture for uncached recorded threads" FM_HOME="$home" fm_backend_target_exists codex-app thread-codex \ || fail "codex-app backend should report recorded thread ids as existing" + FM_HOME="$home" fm_backend_target_exists codex-app thread-uncached \ + || fail "codex-app backend should report uncached recorded thread ids as existing" [ "$(FM_HOME="$home" fm_backend_busy_state codex-app thread-codex)" = unknown ] \ || fail "codex-app backend visible threads should report unknown busy state" @@ -587,6 +597,36 @@ test_spawn_refuses_unknown_fm_backend_env() { pass "fm-spawn.sh honors FM_BACKEND and refuses an unimplemented value loudly" } +test_spawn_refuses_codex_app_selection() { + local out status config + + out=$(FM_ROOT_OVERRIDE='' FM_HOME='' FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE='' FM_CONFIG_OVERRIDE='' FM_SPAWN_NO_GUARD=1 \ + "$ROOT/bin/fm-spawn.sh" nope-codex-z1 projects/none claude --backend codex-app 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "fm-spawn --backend codex-app should refuse until spawn lifecycle exists" + assert_contains "$out" "cannot create new shell endpoints yet" "codex-app --backend refusal did not explain the restriction" + + out=$(FM_ROOT_OVERRIDE='' FM_HOME='' FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE='' FM_CONFIG_OVERRIDE='' FM_SPAWN_NO_GUARD=1 FM_BACKEND=codex-app \ + "$ROOT/bin/fm-spawn.sh" nope-codex-z2 projects/none claude 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "FM_BACKEND=codex-app should refuse until spawn lifecycle exists" + assert_contains "$out" "cannot create new shell endpoints yet" "codex-app FM_BACKEND refusal did not explain the restriction" + + config="$TMP_ROOT/codex-config-backend" + mkdir -p "$config" + printf 'codex-app\n' > "$config/backend" + out=$(FM_ROOT_OVERRIDE='' FM_HOME='' FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE='' FM_CONFIG_OVERRIDE="$config" FM_SPAWN_NO_GUARD=1 \ + "$ROOT/bin/fm-spawn.sh" nope-codex-z3 projects/none claude 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "config/backend=codex-app should refuse until spawn lifecycle exists" + assert_contains "$out" "cannot create new shell endpoints yet" "codex-app config/backend refusal did not explain the restriction" + + pass "fm-spawn.sh refuses codex-app from --backend, FM_BACKEND, and config/backend until spawn lifecycle exists" +} + test_spawn_default_backend_writes_no_meta_field() { local proj wt data id state config out proj="$TMP_ROOT/nobackend-project"; wt="$TMP_ROOT/nobackend-wt"; data="$TMP_ROOT/nobackend-data" @@ -679,6 +719,7 @@ test_spawn_conformance_old_vs_new test_teardown_conformance_old_vs_new test_spawn_refuses_unknown_backend_flag test_spawn_refuses_unknown_fm_backend_env +test_spawn_refuses_codex_app_selection test_spawn_default_backend_writes_no_meta_field test_spawn_explicit_backend_flag_beats_autodetect_herdr_env test_spawn_autodetect_nesting_resolves_tmux_silently diff --git a/tests/fm-codex-app-state.test.sh b/tests/fm-codex-app-state.test.sh index bd300a8e..c580617c 100755 --- a/tests/fm-codex-app-state.test.sh +++ b/tests/fm-codex-app-state.test.sh @@ -44,6 +44,16 @@ grep -qx 'codex_app_pending_worktree_id=pending-2' "$PENDING_META" printf 'first\nsecond\nthird\n' | FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-capture "$ID" - >/dev/null [ "$(FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" capture thread-1 2)" = "$(printf 'second\nthird')" ] +UNCACHED_ID=uncached-thread +UNCACHED_META="$TMP/state/$UNCACHED_ID.meta" +cat > "$UNCACHED_META" </dev/null grep -qx 'codex_app_archived=1' "$META" grep -qx 'codex_app_thread_state=archived' "$META" @@ -74,6 +84,20 @@ if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread missing thread-missing fi grep -q 'requires existing meta' "$TMP/missing-meta.err" +cat > "$TMP/state/not-codex.meta" <"$TMP/not-codex.err"; then + echo "expected record-thread on non-codex meta to fail" >&2 + exit 1 +fi +grep -q 'requires backend=codex-app meta' "$TMP/not-codex.err" + mkdir -p "$TMP/data" cat > "$TMP/data/projects.md" < Date: Thu, 2 Jul 2026 23:49:59 -0400 Subject: [PATCH 06/19] no-mistakes(review): guard Codex App teardown --- bin/backends/codex-app.sh | 11 ++++++++++- bin/fm-codex-app | 6 +++++- bin/fm-teardown.sh | 15 +++++++++++++++ tests/fm-backend.test.sh | 32 +++++++++++++++++++++++++++++++- tests/fm-codex-app-state.test.sh | 17 +++++++++++++++-- 5 files changed, 76 insertions(+), 5 deletions(-) diff --git a/bin/backends/codex-app.sh b/bin/backends/codex-app.sh index 8cba6c6e..8868584f 100644 --- a/bin/backends/codex-app.sh +++ b/bin/backends/codex-app.sh @@ -29,7 +29,16 @@ fm_backend_codex_app_send_text_submit() { # - fm_backend_codex_app_cmd archive "$1" >&2 || true + local status + status=$(fm_backend_codex_app_cmd status "$1" 2>/dev/null | sed -n 's/^status=//p' | tail -1) || { + echo "error: Codex App thread $1 is not archived in the firstmate ledger. Archive it in Codex Desktop, then run mark-archived." >&2 + return 2 + } + if [ "$status" = archived ]; then + return 0 + fi + echo "error: Codex App thread $1 is app-owned and still marked $status. Archive it in Codex Desktop, then run mark-archived." >&2 + return 2 } fm_backend_codex_app_busy_state() { # diff --git a/bin/fm-codex-app b/bin/fm-codex-app index 00c75695..acc80cc8 100755 --- a/bin/fm-codex-app +++ b/bin/fm-codex-app @@ -244,12 +244,16 @@ async function main() { if (existing) throw new Error(`thread ${threadId} is already recorded for task ${existing.taskId}`); const kind = flags.kind; if (kind !== "ship" && kind !== "scout") throw new Error("adopt-thread requires --kind ship or --kind scout"); + if (!flags.worktree) throw new Error("adopt-thread requires --worktree for ship and scout tasks"); + if (!fs.existsSync(flags.worktree) || !fs.statSync(flags.worktree).isDirectory()) { + throw new Error(`adopt-thread requires --worktree to name an existing directory: ${flags.worktree}`); + } const projectMode = resolveProjectMode(projectPath); writeMeta(taskId, { backend: "codex-app", window: threadId, codex_app_thread_name: flags.thread_name || `fm-${taskId}`, - worktree: flags.worktree || "", + worktree: flags.worktree, project: projectPath, harness: flags.harness || "codex", kind, diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index b65809b2..1e872b4b 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -94,6 +94,19 @@ meta_value() { grep "^$key=" "$meta" | cut -d= -f2- || true } +require_codex_app_archived_for_teardown() { + local archived state + [ "$BACKEND" = codex-app ] || return 0 + archived=$(meta_value "$META" codex_app_archived) + state=$(meta_value "$META" codex_app_thread_state) + if [ "$archived" = 1 ] && [ "$state" = archived ]; then + return 0 + fi + echo "REFUSED: Codex App task $ID is still app-owned and not marked archived in the ledger." >&2 + echo "Archive thread $T in Codex Desktop, then run bin/fm-codex-app mark-archived $ID before teardown." >&2 + exit 1 +} + remove_grok_turnend_auth() { local state_dir=$1 id=$2 token hooks_dir token=$(cat "$state_dir/$id.grok-turnend-token" 2>/dev/null || true) @@ -564,6 +577,8 @@ if [ "$KIND" = secondmate ]; then fi fi +require_codex_app_archived_for_teardown + if [ "$KIND" = secondmate ] && [ "$FORCE" != "--force" ]; then SUB_STATE="$HOME_PATH/state" if [ -d "$SUB_STATE" ]; then diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index a2a31358..30c8de56 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -243,8 +243,16 @@ test_codex_app_backend_cached_capture_and_liveness() { || fail "codex-app backend should report uncached recorded thread ids as existing" [ "$(FM_HOME="$home" fm_backend_busy_state codex-app thread-codex)" = unknown ] \ || fail "codex-app backend visible threads should report unknown busy state" + if FM_HOME="$home" fm_backend_kill codex-app thread-codex 2>"$home/kill-visible.err"; then + fail "codex-app backend kill should refuse visible, app-owned threads" + fi + assert_contains "$(cat "$home/kill-visible.err")" "still marked visible" \ + "codex-app backend kill refusal should explain that the ledger must be archived first" + printf 'codex_app_thread_state=archived\ncodex_app_archived=1\n' >> "$meta" + FM_HOME="$home" fm_backend_kill codex-app thread-codex \ + || fail "codex-app backend kill should accept already-archived ledger threads" - pass "codex-app backend: cached capture, liveness, and busy state dispatch" + pass "codex-app backend: cached capture, liveness, busy state, and archive-gated kill" } test_resolve_selector_three_forms() { @@ -573,6 +581,27 @@ test_teardown_conformance_old_vs_new() { pass "fm-teardown.sh: treehouse return + tmux kill-window command log is byte-identical old vs new for a scout task" } +test_teardown_refuses_unarchived_codex_app() { + local id state data config out status + id="codexteardownz1" + state="$TMP_ROOT/codex-teardown-state"; data="$TMP_ROOT/codex-teardown-data"; config="$TMP_ROOT/codex-teardown-config" + mkdir -p "$state" "$data" "$config" + fm_write_meta "$state/$id.meta" \ + "backend=codex-app" "window=thread-codex-teardown" "thread_id=thread-codex-teardown" \ + "project=$TMP_ROOT/codex-teardown-project" "harness=codex" "kind=ship" "mode=no-mistakes" "yolo=off" \ + "codex_app_thread_state=visible" + + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ + "$ROOT/bin/fm-teardown.sh" "$id" 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "fm-teardown.sh should refuse codex-app tasks that are not marked archived" + [ -f "$state/$id.meta" ] || fail "fm-teardown.sh must leave codex-app meta in place when refusing teardown" + assert_contains "$out" "not marked archived" \ + "codex-app teardown refusal did not explain the ledger archive requirement" + + pass "fm-teardown.sh: codex-app tasks must be marked archived before teardown" +} + # --- backend selection loudly refuses an unknown backend -------------------- test_spawn_refuses_unknown_backend_flag() { @@ -717,6 +746,7 @@ test_send_conformance_old_vs_new test_peek_conformance_old_vs_new test_spawn_conformance_old_vs_new test_teardown_conformance_old_vs_new +test_teardown_refuses_unarchived_codex_app test_spawn_refuses_unknown_backend_flag test_spawn_refuses_unknown_fm_backend_env test_spawn_refuses_codex_app_selection diff --git a/tests/fm-codex-app-state.test.sh b/tests/fm-codex-app-state.test.sh index c580617c..a5c1a533 100755 --- a/tests/fm-codex-app-state.test.sh +++ b/tests/fm-codex-app-state.test.sh @@ -102,12 +102,13 @@ mkdir -p "$TMP/data" cat > "$TMP/data/projects.md" </dev/null +mkdir -p "$TMP/wt" +FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted thread-2 /tmp/example --kind scout --thread-name fm-adopted --worktree "$TMP/wt" >/dev/null ADOPTED_META="$TMP/state/adopted.meta" grep -qx 'backend=codex-app' "$ADOPTED_META" grep -qx 'window=thread-2' "$ADOPTED_META" grep -qx 'codex_app_thread_name=fm-adopted' "$ADOPTED_META" -grep -qx 'worktree=/tmp/wt' "$ADOPTED_META" +grep -qx "worktree=$TMP/wt" "$ADOPTED_META" grep -qx 'project=/tmp/example' "$ADOPTED_META" grep -qx 'harness=codex' "$ADOPTED_META" grep -qx 'kind=scout' "$ADOPTED_META" @@ -117,6 +118,18 @@ grep -qx 'thread_id=thread-2' "$ADOPTED_META" grep -qx 'codex_app_thread_state=visible' "$ADOPTED_META" grep -qx 'codex_app_pending_action=none' "$ADOPTED_META" +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread no-worktree thread-no-worktree /tmp/example --kind scout 2>"$TMP/no-worktree.err"; then + echo "expected adoption without worktree to fail" >&2 + exit 1 +fi +grep -q 'requires --worktree' "$TMP/no-worktree.err" + +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread missing-worktree thread-missing-worktree /tmp/example --kind scout --worktree "$TMP/missing-wt" 2>"$TMP/missing-worktree.err"; then + echo "expected adoption with missing worktree to fail" >&2 + exit 1 +fi +grep -q 'existing directory' "$TMP/missing-worktree.err" + if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted thread-3 /tmp/example --kind scout 2>"$TMP/duplicate-task.err"; then echo "expected duplicate task adoption to fail" >&2 exit 1 From 4d344294dca6921dfaacf844cf1a7dff4eef9823 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 00:01:00 -0400 Subject: [PATCH 07/19] no-mistakes(document): Sync Codex App ledger docs --- AGENTS.md | 4 ++-- README.md | 2 +- bin/fm-codex-app | 2 +- bin/fm-crew-state.sh | 10 +++++----- bin/fm-peek.sh | 1 + bin/fm-send.sh | 3 ++- bin/fm-teardown.sh | 2 ++ bin/fm-watch.sh | 9 ++++----- docs/codex-app-backend.md | 10 ++++++++-- 9 files changed, 26 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8593f9df..9f0c93ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown - .meta written by fm-spawn for spawned tasks: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a task on a non-default runtime backend also records backend= (absent means tmux, the verified reference backend; herdr is a second, experimental backend recording herdr_session=, herdr_workspace_id=, herdr_tab_id=, herdr_pane_id= too - docs/herdr-backend.md; bin/fm-backend.sh, section 8). fm-codex-app prepare/adopt writes codex-app ledger meta including backend=codex-app, window=, project=, harness=, kind=, mode=, yolo=, thread_id= once known, codex_app_thread_name=, codex_app_thread_state=, codex_app_pending_action=, codex_app_transport=visible-thread, and optionally turn_id= or codex_app_pending_worktree_id= - docs/codex-app-backend.md. (fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request= and x_request_ts= for an X-mention-originated task, section 14) + .meta written by fm-spawn for spawned tasks: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a task on a non-default runtime backend also records backend= (absent means tmux, the verified reference backend; herdr is a second, experimental backend recording herdr_session=, herdr_workspace_id=, herdr_tab_id=, herdr_pane_id= too - docs/herdr-backend.md; bin/fm-backend.sh, section 8). fm-codex-app prepare writes pending codex-app ledger meta with backend=codex-app, window=, harness=codex, codex_app_thread_name=, codex_app_thread_state=, codex_app_pending_action=, codex_app_transport=visible-thread, and codex_app_brief=. fm-codex-app adopt-thread writes visible-thread meta including project=, worktree=, harness=, kind=, mode=, yolo=, thread_id=, and optionally turn_id=, codex_app_pending_worktree_id=, or codex_app_brief= - docs/codex-app-backend.md. (fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request= and x_request_ts= for an X-mention-originated task, section 14) .check.sh optional slow poll you write per task (e.g. merged-PR check) x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) @@ -492,7 +492,7 @@ bin/fm-spawn.sh projects/ grok # per-task harness override bin/fm-spawn.sh projects/ --harness codex --model gpt-5.5 --effort high # explicit profile axes bin/fm-spawn.sh projects/ --backend tmux # explicit runtime backend; tmux is the verified reference backend bin/fm-spawn.sh projects/ --backend herdr # experimental herdr backend (docs/herdr-backend.md); version-gates at spawn -bin/fm-codex-app adopt-thread projects/ --kind scout # record a Codex App visible thread owned by Codex Desktop +bin/fm-codex-app adopt-thread projects/ --kind scout --worktree # record a Codex App visible thread owned by Codex Desktop bin/fm-spawn.sh projects/ --scout # scout task; records kind=scout in meta bin/fm-spawn.sh --secondmate # launch a registered persistent secondmate in its home bin/fm-spawn.sh --secondmate # launch or recover an explicit secondmate home diff --git a/README.md b/README.md index 90661533..ec5a8363 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Outside tmux, default-backend crewmates land in a detached `firstmate` session y When firstmate is running natively inside herdr and no backend override is set, it auto-detects herdr, prints an opt-out notice, and spawns into the experimental herdr backend. With experimental herdr, attach to the selected `HERDR_SESSION` and switch between firstmate-home workspaces. The primary home uses `firstmate`; each secondmate home uses `2ndmate-`, with that home's task tabs inside its own space. -Codex App visible threads are app-owned: firstmate can record/adopt their thread ids, cache captures, and reconcile liveness, while sends, interrupts, and archive actions are performed from Codex Desktop. +Codex App visible threads are app-owned: firstmate can prepare or adopt their thread ids, cache captures, and reconcile liveness, while sends, interrupts, and archive actions are performed from Codex Desktop. ## How It Works diff --git a/bin/fm-codex-app b/bin/fm-codex-app index acc80cc8..72abc400 100755 --- a/bin/fm-codex-app +++ b/bin/fm-codex-app @@ -31,7 +31,7 @@ function usage() { console.error(`usage: fm-codex-app prepare fm-codex-app record-thread [--turn-id ] [--worktree ] [--pending-worktree-id ] - fm-codex-app adopt-thread --kind [--thread-name ] [--mode ] [--yolo ] [--worktree ] [--turn-id ] [--pending-worktree-id ] [--brief ] + fm-codex-app adopt-thread --kind --worktree [--thread-name ] [--harness ] [--mode ] [--yolo ] [--turn-id ] [--pending-worktree-id ] [--brief ] fm-codex-app record-pending fm-codex-app record-capture fm-codex-app mark-archived diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 29f4caf6..bb5390cd 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -28,7 +28,7 @@ # is flagged superseded. A genuinely parked run plus a needs-decision log # agree, and are reported as parked. # 4. No run for this crew (pre-validation, or kind=scout): fall back to the -# recorded backend's pane busy state, then the status log's last line. +# recorded backend's endpoint busy state, then the status log's last line. # 5. Missing meta or torn-down worktree: report unknown · none. If no run is # attributed to this crew, a dead window also reports unknown · none rather # than trusting a stale status log. @@ -121,7 +121,7 @@ LOG_VERB=$(log_verb_of "$LOG_LINE") # shell - so a finished crew whose window has closed still reports its run-step # state (e.g. done) instead of being masked as unknown. Backend-aware # (fm_backend_of_meta defaults absent backend= to tmux, the P1 contract): a -# herdr task is read through fm_backend_capture instead of a bare tmux probe. +# non-tmux task is read through fm_backend_capture instead of a bare tmux probe. TASK_BACKEND=$(fm_backend_of_meta "$META") pane_readable() { # case "$TASK_BACKEND" in @@ -130,9 +130,9 @@ pane_readable() { # esac } # crew_pane_is_busy: the busy-signature fallback, backend-aware the same way - -# fm_backend_busy_state's native semantic state (herdr's agent.get) when -# available, else the shared tmux pane-regex reader (fm_pane_is_busy, -# bin/fm-tmux-lib.sh) unchanged for tmux/unknown. +# fm_backend_busy_state's native semantic state when available, else the shared +# tmux pane-regex reader (fm_pane_is_busy, bin/fm-tmux-lib.sh) unchanged for +# tmux/unknown. # # `busy` alone is trusted outright. Both `idle` and unknown/unparseable fall # through to the shared tail-regex corroboration, NOT just unknown: herdr's diff --git a/bin/fm-peek.sh b/bin/fm-peek.sh index 7f320204..e154cdbb 100755 --- a/bin/fm-peek.sh +++ b/bin/fm-peek.sh @@ -3,6 +3,7 @@ # Usage: fm-peek.sh [lines=40] # may be a bare firstmate task name (fm-xyz), resolved through # this home's state/.meta, or an explicit backend target. +# Codex App targets print the cached transcript tail recorded with fm-codex-app. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 311d956c..d15ca08d 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -10,7 +10,8 @@ # Enter is positively confirmed (the text is still sitting in the composer after # all retries), fm-send exits NON-ZERO so the caller knows the steer did not land # instead of silently leaving an unsubmitted instruction (incident afk-invx-i5). -# Submission dispatches through the target's recorded backend; the tmux adapter +# Submission dispatches through the target's recorded backend; app-owned Codex +# App targets refuse sends and point back to Codex Desktop. The tmux adapter # shares its composer/submit core with the away-mode daemon via bin/fm-tmux-lib.sh. # Tune with FM_SEND_RETRIES (default 3) / FM_SEND_SLEEP (0.4). # Slash commands, and codex `$...` skill invocations resolved through harness diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 1e872b4b..b7580100 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -34,6 +34,8 @@ # leased home releases its durable treehouse lease so the pool slot is freed, # never left leased forever. If the treehouse return fails, teardown leaves the # leased home and state in place instead of hiding a still-held lease. +# Codex App tasks must be archived in Codex Desktop and marked with +# `bin/fm-codex-app mark-archived ` before teardown proceeds. # Usage: fm-teardown.sh [--force] # --force skips ordinary-task dirty and landed-work checks, skips scout report # checks, and discards secondmate child work for kind=secondmate. Only use it diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index ebd6e06c..00e2bfd3 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -41,9 +41,9 @@ mkdir -p "$STATE" # (capture, recorded windows, backend busy-state, and the BUSY_REGEX fallback) # synthesizes the signal/stale/check/heartbeat wake vocabulary for backends with # no native event push. tmux always reports unknown busy-state, preserving the -# original regex path. herdr contributes native semantic busy-state through the -# same poll loop until a future push subscription replaces this default source; -# see bin/fm-backend.sh and docs/herdr-backend.md. +# original regex path. herdr contributes native semantic busy-state, and codex-app +# reports idle only after the visible thread is marked archived in the ledger; +# see bin/fm-backend.sh plus docs/herdr-backend.md and docs/codex-app-backend.md. # shellcheck source=bin/fm-backend.sh . "$SCRIPT_DIR/fm-backend.sh" @@ -155,8 +155,7 @@ hash_pane() { # window_is_busy: 0 (busy) iff the task's harness is actively working. Prefers # a backend's native semantic busy state (fm_backend_busy_state - herdr's -# agent.get; herdr-addendum "busy state" row, "the first backend where -# fm_session_busy_state gets real semantics"); falls back to the existing +# agent.get or codex-app's archived marker); falls back to the existing # pane-tail regex ONLY when the backend reports unknown (tmux always does, so # its path is unchanged byte-for-byte). is the same bounded capture # already read for hashing, so this adds no extra backend calls on the diff --git a/docs/codex-app-backend.md b/docs/codex-app-backend.md index cc5cf328..4220c0d7 100644 --- a/docs/codex-app-backend.md +++ b/docs/codex-app-backend.md @@ -9,7 +9,7 @@ Firstmate records enough local state for its normal backend abstraction to recon ## State model Use `bin/fm-codex-app prepare ` before creating or forking a visible thread from a brief. -It writes pending task metadata with `backend=codex-app`, `window=`, `codex_app_thread_name=`, `codex_app_thread_state=pending`, `codex_app_pending_action=create_thread_or_fork_thread`, and `codex_app_transport=visible-thread`. +It writes pending task metadata with `backend=codex-app`, `window=`, `harness=codex`, `codex_app_thread_name=`, `codex_app_thread_state=pending`, `codex_app_pending_action=create_thread_or_fork_thread`, `codex_app_transport=visible-thread`, and `codex_app_brief=`. After Codex Desktop has a real thread, record it with: @@ -19,14 +19,17 @@ bin/fm-codex-app record-thread [--turn-id ] [--worktre That changes `window=` to the thread id, records `thread_id=`, sets `codex_app_thread_state=visible`, and clears the pending action. `record-pending` stores a pending worktree id when Desktop has created a worktree request but the final thread id is not known yet. +`record-thread` can also record `turn_id=`, `worktree=`, and `codex_app_pending_worktree_id=`. To bring an already-visible Desktop thread under firstmate supervision, use: ```sh -bin/fm-codex-app adopt-thread --kind [--thread-name ] [--mode ] [--yolo ] [--worktree ] [--turn-id ] [--pending-worktree-id ] [--brief ] +bin/fm-codex-app adopt-thread --kind --worktree [--thread-name ] [--harness ] [--mode ] [--yolo ] [--turn-id ] [--pending-worktree-id ] [--brief ] ``` Adoption refuses duplicate task ids and duplicate thread ids. +`--worktree` must name an existing directory for both ship and scout tasks. +When `--harness` is omitted, adoption records `harness=codex`. If `--mode` or `--yolo` is omitted, it resolves the project mode from `data/projects.md` and falls back to `no-mistakes`/`off`. ## Backend operations @@ -41,6 +44,7 @@ bin/fm-codex-app record-capture ``` `fm-peek.sh` and `fm_backend_capture codex-app ` then read the tail of `state/.codex-app.capture`. +Recording a capture also updates `codex_app_last_capture=` in the task meta. If no cached transcript exists for a recorded thread, capture succeeds with empty output. That keeps passive liveness and peek-style readers from treating a visible Desktop thread as gone merely because firstmate has not cached a transcript yet. An unrecorded thread id still fails with a Desktop instruction. @@ -55,6 +59,7 @@ bin/fm-codex-app mark-archived Archived threads report `status=archived`; the backend maps that to `idle`. Visible or pending threads report unknown busy state, so supervision does not claim a live Desktop thread is idle without an explicit archived marker. +`fm-teardown.sh` refuses codex-app tasks until the thread is archived in Desktop and marked archived in the ledger. `codex-app` is not selectable for new spawns through `--backend`, `FM_BACKEND`, or `config/backend` until a complete visible-thread spawn lifecycle exists. Use `prepare`, `record-thread`, or `adopt-thread` to create codex-app metadata for Desktop-owned visible threads. @@ -73,5 +78,6 @@ archive=ok restart_reconcile=ok ``` +Each required key also accepts `yes`, `true`, or `1`. The checker rejects `app_server_only=ok` or `headless_only=ok`. That is deliberate: a headless app-server proof does not verify the visible Codex App thread workflow firstmate relies on. From 2f0193ddfd574f871de6528299aadf86d8f08392 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 00:14:54 -0400 Subject: [PATCH 08/19] fix codex app visible thread invariants --- bin/fm-codex-app | 45 ++++++++++++++---- bin/fm-teardown.sh | 28 ++++++++++- docs/codex-app-backend.md | 6 ++- tests/fm-codex-app-state.test.sh | 67 +++++++++++++++++++++----- tests/fm-teardown.test.sh | 81 ++++++++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 23 deletions(-) diff --git a/bin/fm-codex-app b/bin/fm-codex-app index 72abc400..976fdf3a 100755 --- a/bin/fm-codex-app +++ b/bin/fm-codex-app @@ -30,7 +30,7 @@ const STATE = process.env.FM_STATE_OVERRIDE || path.join(HOME, "state"); function usage() { console.error(`usage: fm-codex-app prepare - fm-codex-app record-thread [--turn-id ] [--worktree ] [--pending-worktree-id ] + fm-codex-app record-thread --kind --project --worktree [--turn-id ] [--harness ] [--mode ] [--yolo ] [--pending-worktree-id ] fm-codex-app adopt-thread --kind --worktree [--thread-name ] [--harness ] [--mode ] [--yolo ] [--turn-id ] [--pending-worktree-id ] [--brief ] fm-codex-app record-pending fm-codex-app record-capture @@ -86,6 +86,20 @@ function ensureCodexAppMeta(taskId, action) { return meta; } +function validateProtectedTaskState(kind, projectPath, worktreePath, action) { + if (kind !== "ship" && kind !== "scout") { + throw new Error(`${action} requires --kind ship or --kind scout`); + } + if (!projectPath) throw new Error(`${action} requires project metadata for ${kind} tasks`); + if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) { + throw new Error(`${action} requires project to name an existing directory: ${projectPath}`); + } + if (!worktreePath) throw new Error(`${action} requires --worktree for ${kind} tasks`); + if (!fs.existsSync(worktreePath) || !fs.statSync(worktreePath).isDirectory()) { + throw new Error(`${action} requires --worktree to name an existing directory: ${worktreePath}`); + } +} + function writeMeta(taskId, values) { ensureState(); const file = metaPath(taskId); @@ -195,6 +209,16 @@ async function main() { const [taskId, threadName, briefFile] = args; if (!taskId || !threadName || !briefFile) usage(); if (!fs.existsSync(briefFile)) throw new Error(`brief not found: ${briefFile}`); + if (fs.existsSync(metaPath(taskId))) { + const meta = ensureCodexAppMeta(taskId, "prepare"); + const samePendingTask = !meta.thread_id + && (meta.codex_app_thread_state || "pending") === "pending" + && (meta.window === threadName || meta.codex_app_thread_name === threadName) + && meta.codex_app_brief === briefFile; + if (!samePendingTask) { + throw new Error(`prepare refuses existing meta for task ${taskId}; choose a new id or record/adopt the existing Codex App task`); + } + } writeMeta(taskId, { backend: "codex-app", window: threadName, @@ -215,8 +239,12 @@ async function main() { const [taskId, threadId, ...rest] = args; if (!taskId || !threadId) usage(); ensureMetaExists(taskId, "record-thread"); - ensureCodexAppMeta(taskId, "record-thread"); + const meta = ensureCodexAppMeta(taskId, "record-thread"); const flags = parseFlags(rest); + const kind = flags.kind || meta.kind; + const projectPath = flags.project || meta.project; + const worktreePath = flags.worktree || meta.worktree; + validateProtectedTaskState(kind, projectPath, worktreePath, "record-thread"); const existing = taskForThread(threadId); if (existing && existing.taskId !== taskId) { throw new Error(`thread ${threadId} is already recorded for task ${existing.taskId}`); @@ -225,7 +253,12 @@ async function main() { window: threadId, thread_id: threadId, turn_id: flags.turn_id, - worktree: flags.worktree, + project: projectPath, + worktree: worktreePath, + harness: flags.harness || meta.harness || "codex", + kind, + mode: flags.mode || meta.mode, + yolo: flags.yolo || meta.yolo, codex_app_pending_worktree_id: flags.pending_worktree_id, codex_app_thread_state: "visible", codex_app_pending_action: "none", @@ -243,11 +276,7 @@ async function main() { const existing = taskForThread(threadId); if (existing) throw new Error(`thread ${threadId} is already recorded for task ${existing.taskId}`); const kind = flags.kind; - if (kind !== "ship" && kind !== "scout") throw new Error("adopt-thread requires --kind ship or --kind scout"); - if (!flags.worktree) throw new Error("adopt-thread requires --worktree for ship and scout tasks"); - if (!fs.existsSync(flags.worktree) || !fs.statSync(flags.worktree).isDirectory()) { - throw new Error(`adopt-thread requires --worktree to name an existing directory: ${flags.worktree}`); - } + validateProtectedTaskState(kind, projectPath, flags.worktree, "adopt-thread"); const projectMode = resolveProjectMode(projectPath); writeMeta(taskId, { backend: "codex-app", diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index b7580100..c467a98a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -109,6 +109,27 @@ require_codex_app_archived_for_teardown() { exit 1 } +require_codex_app_teardown_state() { + [ "$BACKEND" = codex-app ] || return 0 + [ "$FORCE" != "--force" ] || return 0 + case "$KIND" in + ship|scout) + if [ -z "$PROJ" ] || [ ! -d "$PROJ" ]; then + echo "REFUSED: Codex App $KIND task $ID has no existing project directory recorded for teardown safety." >&2 + exit 1 + fi + if [ -z "$WT" ] || [ ! -d "$WT" ]; then + echo "REFUSED: Codex App $KIND task $ID has no existing worktree recorded for teardown safety." >&2 + exit 1 + fi + git -C "$WT" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + echo "REFUSED: Codex App $KIND task $ID worktree is not a git worktree: $WT" >&2 + exit 1 + } + ;; + esac +} + remove_grok_turnend_auth() { local state_dir=$1 id=$2 token hooks_dir token=$(cat "$state_dir/$id.grok-turnend-token" 2>/dev/null || true) @@ -580,6 +601,7 @@ if [ "$KIND" = secondmate ]; then fi require_codex_app_archived_for_teardown +require_codex_app_teardown_state if [ "$KIND" = secondmate ] && [ "$FORCE" != "--force" ]; then SUB_STATE="$HOME_PATH/state" @@ -675,7 +697,11 @@ if [ -d "$WT" ] && [ "$KIND" != secondmate ]; then ( cd "$PROJ" && treehouse return --force "$WT" ) fi -fm_backend_kill "$BACKEND" "$T" 2>/dev/null || true +if [ "$BACKEND" = codex-app ]; then + fm_backend_kill "$BACKEND" "$T" || exit 1 +else + fm_backend_kill "$BACKEND" "$T" 2>/dev/null || true +fi if [ "$KIND" = secondmate ]; then [ -n "$HOME_PATH" ] || HOME_PATH=$WT remove_firstmate_home "$HOME_PATH" "secondmate home" "$ID" diff --git a/docs/codex-app-backend.md b/docs/codex-app-backend.md index 4220c0d7..27ca80bd 100644 --- a/docs/codex-app-backend.md +++ b/docs/codex-app-backend.md @@ -14,12 +14,14 @@ It writes pending task metadata with `backend=codex-app`, `window=` After Codex Desktop has a real thread, record it with: ```sh -bin/fm-codex-app record-thread [--turn-id ] [--worktree ] [--pending-worktree-id ] +bin/fm-codex-app record-thread --kind --project --worktree [--turn-id ] [--harness ] [--mode ] [--yolo ] [--pending-worktree-id ] ``` That changes `window=` to the thread id, records `thread_id=`, sets `codex_app_thread_state=visible`, and clears the pending action. `record-pending` stores a pending worktree id when Desktop has created a worktree request but the final thread id is not known yet. -`record-thread` can also record `turn_id=`, `worktree=`, and `codex_app_pending_worktree_id=`. +`record-thread` requires protected task state: `kind=ship|scout`, an existing project directory, and an existing worktree directory must already be recorded in the prepared meta or supplied as flags. +That prevents a prepared visible thread from becoming a default ship task that teardown cannot validate for landed work or scout report delivery. +`prepare` refuses an existing task id unless the existing metadata is the same pending Codex App task, so it cannot overwrite a live route. To bring an already-visible Desktop thread under firstmate supervision, use: diff --git a/tests/fm-codex-app-state.test.sh b/tests/fm-codex-app-state.test.sh index a5c1a533..169b9806 100755 --- a/tests/fm-codex-app-state.test.sh +++ b/tests/fm-codex-app-state.test.sh @@ -6,12 +6,17 @@ TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-codex-app-state.XXXXXX") trap 'rm -rf "$TMP"' EXIT mkdir -p "$TMP/state" +PROJECT="$TMP/project" +mkdir -p "$PROJECT" +WT="$TMP/wt" +mkdir -p "$WT" ID=state-test META="$TMP/state/$ID.meta" cat > "$META" < "$PENDING_META" </dev/null grep -qx 'codex_app_archived=1' "$META" grep -qx 'codex_app_thread_state=archived' "$META" +BRIEF="$TMP/brief.md" +printf 'brief\n' > "$BRIEF" +FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" prepare prepared-thread fm-prepared "$BRIEF" >/dev/null +FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" prepare prepared-thread fm-prepared "$BRIEF" >/dev/null +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" prepare prepared-thread fm-other "$BRIEF" 2>"$TMP/prepare-overwrite.err"; then + echo "expected prepare to refuse changing existing pending meta" >&2 + exit 1 +fi +grep -q 'prepare refuses existing meta' "$TMP/prepare-overwrite.err" + +cat > "$TMP/state/live-task.meta" <"$TMP/prepare-live.err"; then + echo "expected prepare to refuse existing visible meta" >&2 + exit 1 +fi +grep -q 'prepare refuses existing meta' "$TMP/prepare-live.err" + cat > "$TMP/state/other.meta" < "$TMP/state/third.meta" < "$TMP/state/prepared-unsafe.meta" <"$TMP/unsafe-record.err"; then + echo "expected record-thread without protected task state to fail" >&2 + exit 1 +fi +grep -q 'requires --kind ship or --kind scout' "$TMP/unsafe-record.err" +FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread prepared-unsafe thread-safe --kind scout --project "$PROJECT" --worktree "$WT" >/dev/null +grep -qx 'kind=scout' "$TMP/state/prepared-unsafe.meta" +grep -qx "project=$PROJECT" "$TMP/state/prepared-unsafe.meta" +grep -qx "worktree=$WT" "$TMP/state/prepared-unsafe.meta" + mkdir -p "$TMP/data" cat > "$TMP/data/projects.md" </dev/null +FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted thread-2 "$PROJECT" --kind scout --thread-name fm-adopted --worktree "$WT" >/dev/null ADOPTED_META="$TMP/state/adopted.meta" grep -qx 'backend=codex-app' "$ADOPTED_META" grep -qx 'window=thread-2' "$ADOPTED_META" grep -qx 'codex_app_thread_name=fm-adopted' "$ADOPTED_META" -grep -qx "worktree=$TMP/wt" "$ADOPTED_META" -grep -qx 'project=/tmp/example' "$ADOPTED_META" +grep -qx "worktree=$WT" "$ADOPTED_META" +grep -qx "project=$PROJECT" "$ADOPTED_META" grep -qx 'harness=codex' "$ADOPTED_META" grep -qx 'kind=scout' "$ADOPTED_META" grep -qx 'mode=direct-PR' "$ADOPTED_META" @@ -118,25 +161,25 @@ grep -qx 'thread_id=thread-2' "$ADOPTED_META" grep -qx 'codex_app_thread_state=visible' "$ADOPTED_META" grep -qx 'codex_app_pending_action=none' "$ADOPTED_META" -if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread no-worktree thread-no-worktree /tmp/example --kind scout 2>"$TMP/no-worktree.err"; then +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread no-worktree thread-no-worktree "$PROJECT" --kind scout 2>"$TMP/no-worktree.err"; then echo "expected adoption without worktree to fail" >&2 exit 1 fi grep -q 'requires --worktree' "$TMP/no-worktree.err" -if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread missing-worktree thread-missing-worktree /tmp/example --kind scout --worktree "$TMP/missing-wt" 2>"$TMP/missing-worktree.err"; then +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread missing-worktree thread-missing-worktree "$PROJECT" --kind scout --worktree "$TMP/missing-wt" 2>"$TMP/missing-worktree.err"; then echo "expected adoption with missing worktree to fail" >&2 exit 1 fi grep -q 'existing directory' "$TMP/missing-worktree.err" -if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted thread-3 /tmp/example --kind scout 2>"$TMP/duplicate-task.err"; then +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted thread-3 "$PROJECT" --kind scout 2>"$TMP/duplicate-task.err"; then echo "expected duplicate task adoption to fail" >&2 exit 1 fi grep -q 'already has meta' "$TMP/duplicate-task.err" -if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted-other thread-2 /tmp/example --kind scout 2>"$TMP/duplicate-thread.err"; then +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted-other thread-2 "$PROJECT" --kind scout 2>"$TMP/duplicate-thread.err"; then echo "expected duplicate thread adoption to fail" >&2 exit 1 fi diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index a3e8b7ed..5d6a5c41 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -261,6 +261,7 @@ run_teardown() { local case_dir=$1; shift FM_ROOT_OVERRIDE="$ROOT" \ FM_STATE_OVERRIDE="$case_dir/state" \ + FM_DATA_OVERRIDE="${FM_DATA_OVERRIDE:-$case_dir/data}" \ FM_CONFIG_OVERRIDE="$case_dir/config" \ PATH="$case_dir/fakebin:$PATH" \ "$TEARDOWN" task-x1 "$@" @@ -655,6 +656,83 @@ test_gh_error_and_content_absent_refuses() { pass "gh lookup error with content not in default refuses (fail-safe)" } +test_codex_app_teardown_refuses_visible_thread() { + local case_dir rc + case_dir=$(make_case codex-visible) + fm_write_meta "$case_dir/state/task-x1.meta" \ + "backend=codex-app" \ + "window=thread-visible" \ + "thread_id=thread-visible" \ + "codex_app_thread_state=visible" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=scout" \ + "mode=no-mistakes" + mkdir -p "$case_dir/data/task-x1" + printf 'report\n' > "$case_dir/data/task-x1/report.md" + + set +e + FM_DATA_OVERRIDE="$case_dir/data" run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "codex-visible: teardown should refuse a visible Codex App thread" + grep -q 'not marked archived' "$case_dir/stderr" || fail "codex-visible: refusal did not require mark-archived" + [ -f "$case_dir/state/task-x1.meta" ] || fail "codex-visible: refused teardown removed meta" + pass "Codex App teardown refuses visible threads until mark-archived" +} + +test_codex_app_teardown_refuses_archived_missing_worktree() { + local case_dir rc + case_dir=$(make_case codex-missing-wt) + fm_write_meta "$case_dir/state/task-x1.meta" \ + "backend=codex-app" \ + "window=thread-archived" \ + "thread_id=thread-archived" \ + "codex_app_thread_state=archived" \ + "codex_app_archived=1" \ + "worktree=" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=no-mistakes" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "codex-missing-wt: teardown should refuse archived Codex App ship with no worktree" + grep -q 'no existing worktree' "$case_dir/stderr" || fail "codex-missing-wt: refusal did not cite missing worktree" + [ -f "$case_dir/state/task-x1.meta" ] || fail "codex-missing-wt: refused teardown removed meta" + pass "Codex App teardown refuses protected tasks without a real worktree" +} + +test_codex_app_teardown_allows_archived_scout_with_report() { + local case_dir rc + case_dir=$(make_case codex-archived-scout) + fm_write_meta "$case_dir/state/task-x1.meta" \ + "backend=codex-app" \ + "window=thread-archived" \ + "thread_id=thread-archived" \ + "codex_app_thread_state=archived" \ + "codex_app_archived=1" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=scout" \ + "mode=no-mistakes" + mkdir -p "$case_dir/data/task-x1" + printf 'report\n' > "$case_dir/data/task-x1/report.md" + + set +e + FM_DATA_OVERRIDE="$case_dir/data" run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "codex-archived-scout: teardown should allow archived Codex App scout with report" + [ ! -f "$case_dir/state/task-x1.meta" ] || fail "codex-archived-scout: successful teardown left meta behind" + pass "Codex App teardown allows archived scout tasks only after the report gate" +} + test_local_only_force_overrides_unpushed() { local case_dir rc case_dir=$(make_case force-override) @@ -690,3 +768,6 @@ test_content_in_default_fallback_allows test_content_fallback_refreshes_stale_origin_ref test_dirty_worktree_refuses test_gh_error_and_content_absent_refuses +test_codex_app_teardown_refuses_visible_thread +test_codex_app_teardown_refuses_archived_missing_worktree +test_codex_app_teardown_allows_archived_scout_with_report From 1545fd7e3832c3cfea0df7e2d83a94398164f3d9 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 00:21:43 -0400 Subject: [PATCH 09/19] no-mistakes(review): harden Codex App teardown safety --- bin/fm-codex-app | 55 ++++++++++++++++++++++++++++++++ bin/fm-teardown.sh | 16 ++++++++-- tests/fm-codex-app-state.test.sh | 24 ++++++++++++-- tests/fm-teardown.test.sh | 30 +++++++++++++++++ 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/bin/fm-codex-app b/bin/fm-codex-app index 976fdf3a..10e4fba0 100755 --- a/bin/fm-codex-app +++ b/bin/fm-codex-app @@ -5,6 +5,7 @@ const fs = require("node:fs"); const path = require("node:path"); +const childProcess = require("node:child_process"); function looksLikeFleetRoot(dir) { return fs.existsSync(path.join(dir, "state")) @@ -98,6 +99,60 @@ function validateProtectedTaskState(kind, projectPath, worktreePath, action) { if (!fs.existsSync(worktreePath) || !fs.statSync(worktreePath).isDirectory()) { throw new Error(`${action} requires --worktree to name an existing directory: ${worktreePath}`); } + validateDisposableWorktree(projectPath, worktreePath, action); +} + +function realDir(dir) { + return fs.realpathSync.native(dir); +} + +function gitOutput(cwd, args) { + return childProcess.execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); +} + +function validateDisposableWorktree(projectPath, worktreePath, action) { + let projectRoot; + let worktreeRoot; + try { + projectRoot = realDir(gitOutput(projectPath, ["rev-parse", "--show-toplevel"])); + } catch { + throw new Error(`${action} requires project to be a git worktree: ${projectPath}`); + } + try { + worktreeRoot = realDir(gitOutput(worktreePath, ["rev-parse", "--show-toplevel"])); + } catch { + throw new Error(`${action} requires --worktree to be a git worktree: ${worktreePath}`); + } + const expectedWorktree = realDir(worktreePath); + if (worktreeRoot !== expectedWorktree) { + throw new Error(`${action} requires --worktree to name a git worktree root: ${worktreePath}`); + } + if (worktreeRoot === projectRoot) { + throw new Error(`${action} requires --worktree to be distinct from the project checkout: ${worktreePath}`); + } + let listed; + try { + listed = childProcess.execFileSync("git", ["-C", projectPath, "-c", "core.quotePath=false", "worktree", "list", "--porcelain"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + throw new Error(`${action} requires --worktree to be registered with project: ${worktreePath}`); + } + const registered = listed + .split(/\r?\n/) + .filter((line) => line.startsWith("worktree ")) + .map((line) => line.slice("worktree ".length)) + .some((entry) => { + try { + return realDir(entry) === worktreeRoot; + } catch { + return false; + } + }); + if (!registered) { + throw new Error(`${action} requires --worktree to be a registered linked worktree for project: ${worktreePath}`); + } } function writeMeta(taskId, values) { diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index c467a98a..00efdd27 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -111,9 +111,9 @@ require_codex_app_archived_for_teardown() { require_codex_app_teardown_state() { [ "$BACKEND" = codex-app ] || return 0 - [ "$FORCE" != "--force" ] || return 0 case "$KIND" in ship|scout) + local proj_abs wt_abs if [ -z "$PROJ" ] || [ ! -d "$PROJ" ]; then echo "REFUSED: Codex App $KIND task $ID has no existing project directory recorded for teardown safety." >&2 exit 1 @@ -126,6 +126,16 @@ require_codex_app_teardown_state() { echo "REFUSED: Codex App $KIND task $ID worktree is not a git worktree: $WT" >&2 exit 1 } + proj_abs=$(removal_target_abs_path "$PROJ") + wt_abs=$(removal_target_abs_path "$WT") + if [ "$proj_abs" = "$wt_abs" ]; then + echo "REFUSED: Codex App $KIND task $ID worktree is the project checkout, not a disposable worktree: $WT" >&2 + exit 1 + fi + if ! worktree_registered_for_project "$PROJ" "$WT"; then + echo "REFUSED: Codex App $KIND task $ID worktree is not registered for the recorded project: $WT" >&2 + exit 1 + fi ;; esac } @@ -580,7 +590,7 @@ cleanup_firstmate_home_children() { fi fi remove_grok_turnend_auth "$sub_state" "$child_id" - 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" "$sub_state/$child_id.grok-turnend-token" + 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" "$sub_state/$child_id.grok-turnend-token" "$sub_state/$child_id.codex-app.capture" done } @@ -711,7 +721,7 @@ remove_grok_turnend_auth "$STATE" "$ID" # Remove the per-task temp root (/tmp/fm-/, incl. its gotmp/) recorded by spawn. # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" -rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" +rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" "$STATE/$ID.codex-app.capture" if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi diff --git a/tests/fm-codex-app-state.test.sh b/tests/fm-codex-app-state.test.sh index 169b9806..4bebe0c5 100755 --- a/tests/fm-codex-app-state.test.sh +++ b/tests/fm-codex-app-state.test.sh @@ -7,9 +7,10 @@ trap 'rm -rf "$TMP"' EXIT mkdir -p "$TMP/state" PROJECT="$TMP/project" -mkdir -p "$PROJECT" WT="$TMP/wt" -mkdir -p "$WT" +git init -q "$PROJECT" +git -C "$PROJECT" -c user.email=t@t -c user.name=t commit -q --allow-empty -m "baseline" +git -C "$PROJECT" worktree add -q -b fm/state-test "$WT" ID=state-test META="$TMP/state/$ID.meta" cat > "$META" <"$TMP/unsafe-project-record.err"; then + echo "expected record-thread with project checkout as worktree to fail" >&2 + exit 1 +fi +grep -q 'distinct from the project checkout' "$TMP/unsafe-project-record.err" FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread prepared-unsafe thread-safe --kind scout --project "$PROJECT" --worktree "$WT" >/dev/null grep -qx 'kind=scout' "$TMP/state/prepared-unsafe.meta" grep -qx "project=$PROJECT" "$TMP/state/prepared-unsafe.meta" @@ -173,6 +179,20 @@ if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread missing-worktree thread- fi grep -q 'existing directory' "$TMP/missing-worktree.err" +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread project-worktree thread-project-worktree "$PROJECT" --kind scout --worktree "$PROJECT" 2>"$TMP/project-worktree.err"; then + echo "expected adoption with project checkout as worktree to fail" >&2 + exit 1 +fi +grep -q 'distinct from the project checkout' "$TMP/project-worktree.err" + +PLAIN_CLONE="$TMP/plain-clone" +git clone -q "$PROJECT" "$PLAIN_CLONE" +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread plain-clone thread-plain-clone "$PROJECT" --kind scout --worktree "$PLAIN_CLONE" 2>"$TMP/plain-clone.err"; then + echo "expected adoption with plain clone worktree to fail" >&2 + exit 1 +fi +grep -q 'registered linked worktree' "$TMP/plain-clone.err" + if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted thread-3 "$PROJECT" --kind scout 2>"$TMP/duplicate-task.err"; then echo "expected duplicate task adoption to fail" >&2 exit 1 diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 5d6a5c41..e0a402b0 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -670,6 +670,7 @@ test_codex_app_teardown_refuses_visible_thread() { "mode=no-mistakes" mkdir -p "$case_dir/data/task-x1" printf 'report\n' > "$case_dir/data/task-x1/report.md" + printf 'cached transcript\n' > "$case_dir/state/task-x1.codex-app.capture" set +e FM_DATA_OVERRIDE="$case_dir/data" run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" @@ -707,6 +708,33 @@ test_codex_app_teardown_refuses_archived_missing_worktree() { pass "Codex App teardown refuses protected tasks without a real worktree" } +test_codex_app_teardown_refuses_project_checkout_worktree() { + local case_dir rc + case_dir=$(make_case codex-project-checkout) + fm_write_meta "$case_dir/state/task-x1.meta" \ + "backend=codex-app" \ + "window=thread-archived" \ + "thread_id=thread-archived" \ + "codex_app_thread_state=archived" \ + "codex_app_archived=1" \ + "worktree=$case_dir/project" \ + "project=$case_dir/project" \ + "kind=scout" \ + "mode=no-mistakes" + mkdir -p "$case_dir/data/task-x1" + printf 'report\n' > "$case_dir/data/task-x1/report.md" + + set +e + FM_DATA_OVERRIDE="$case_dir/data" run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "codex-project-checkout: teardown should refuse project checkout even under force" + grep -q 'worktree is the project checkout' "$case_dir/stderr" || fail "codex-project-checkout: refusal did not cite project checkout ownership" + [ -d "$case_dir/project" ] || fail "codex-project-checkout: refused teardown removed project checkout" + pass "Codex App teardown refuses project checkouts as disposable worktrees" +} + test_codex_app_teardown_allows_archived_scout_with_report() { local case_dir rc case_dir=$(make_case codex-archived-scout) @@ -730,6 +758,7 @@ test_codex_app_teardown_allows_archived_scout_with_report() { expect_code 0 "$rc" "codex-archived-scout: teardown should allow archived Codex App scout with report" [ ! -f "$case_dir/state/task-x1.meta" ] || fail "codex-archived-scout: successful teardown left meta behind" + [ ! -f "$case_dir/state/task-x1.codex-app.capture" ] || fail "codex-archived-scout: successful teardown left capture cache behind" pass "Codex App teardown allows archived scout tasks only after the report gate" } @@ -770,4 +799,5 @@ test_dirty_worktree_refuses test_gh_error_and_content_absent_refuses test_codex_app_teardown_refuses_visible_thread test_codex_app_teardown_refuses_archived_missing_worktree +test_codex_app_teardown_refuses_project_checkout_worktree test_codex_app_teardown_allows_archived_scout_with_report From 1f01b69cefa880014b663503e379e86cd204f82e Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 00:31:19 -0400 Subject: [PATCH 10/19] no-mistakes(document): sync Codex App docs --- CONTRIBUTING.md | 4 ++-- docs/codex-app-backend.md | 5 +++-- docs/scripts.md | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e96c230..ecee81d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,10 +86,10 @@ tests/fm-secondmate-sync.test.sh # local-HEAD secondmate sync, no-fetch tests/fm-secondmate-harness.test.sh # secondmate-vs-crewmate harness resolution, optional secondmate model/effort pins, primary-to-secondmate config inheritance, and config-push tests tests/fm-secondmate-lifecycle-e2e.test.sh # persistent secondmate routing, seeding, backlog handoff, spawn, recovery, teardown, and FM_HOME flow tests tests/fm-secondmate-safety.test.sh # secondmate home safety, idle charter, handoff validation, and teardown boundary tests -tests/fm-teardown.test.sh # fm-teardown.sh landed-work safety and reminder checks: fork-remote allow, squash/content landings, dirty and unlanded refusals, PR-head metadata, no-pr= branch discovery, tasks-axi/manual backlog reminder, --force override +tests/fm-teardown.test.sh # fm-teardown.sh landed-work safety and reminder checks: fork-remote allow, squash/content landings, dirty and unlanded refusals, PR-head metadata, no-pr= branch discovery, Codex App archive/worktree safety, tasks-axi/manual backlog reminder, --force override tests/fm-pr-merge.test.sh # fm-pr-merge.sh records pr= and available pr_head= before merging, parses PR URLs into gh-axi number/--repo calls, defaults to squash, preserves explicit merge methods, rejects malformed URLs and repo overrides, and propagates real merge failures tests/fm-crew-state.test.sh # fm-crew-state.sh current-state reconciliation: run-step authority including closed panes, stale needs-decision/blocked superseded by a resumed run, genuine-parked, cross-branch attribution, pane/status-log fallback, scout skip, torn-down/missing-meta graceful -tests/fm-backend.test.sh # runtime-backend abstraction: fm-backend.sh selection/meta/dispatch helpers, and old-vs-new fake-tool command-log conformance for fm-send/fm-peek/fm-spawn/fm-teardown +tests/fm-backend.test.sh # runtime-backend abstraction: fm-backend.sh selection/meta/dispatch helpers, Codex App cached capture/liveness gates, and old-vs-new fake-tool command-log conformance for fm-send/fm-peek/fm-spawn/fm-teardown tests/fm-codex-app-state.test.sh # Codex App visible-thread ledger state transitions: record/adopt thread ids, pending worktree ids, cached captures, archive markers, duplicate guards, and symlinked-root discovery tests/fm-codex-app-smoke-contract.test.sh # Codex App visible-thread smoke evidence contract: requires Desktop-visible list/read/send/archive/restart evidence and rejects headless app-server-only proof tests/fm-backend-tmux-smoke.test.sh # real (private-socket) tmux smoke test for the tmux adapter: create/duplicate-refuse, send text + Enter, send literal + key, bounded capture, live-window resolve, kill diff --git a/docs/codex-app-backend.md b/docs/codex-app-backend.md index 27ca80bd..e44ba5c4 100644 --- a/docs/codex-app-backend.md +++ b/docs/codex-app-backend.md @@ -19,7 +19,7 @@ bin/fm-codex-app record-thread --kind --proje That changes `window=` to the thread id, records `thread_id=`, sets `codex_app_thread_state=visible`, and clears the pending action. `record-pending` stores a pending worktree id when Desktop has created a worktree request but the final thread id is not known yet. -`record-thread` requires protected task state: `kind=ship|scout`, an existing project directory, and an existing worktree directory must already be recorded in the prepared meta or supplied as flags. +`record-thread` requires protected task state: `kind=ship|scout`, an existing git project directory, and an existing registered linked worktree root must already be recorded in the prepared meta or supplied as flags. That prevents a prepared visible thread from becoming a default ship task that teardown cannot validate for landed work or scout report delivery. `prepare` refuses an existing task id unless the existing metadata is the same pending Codex App task, so it cannot overwrite a live route. @@ -30,7 +30,7 @@ bin/fm-codex-app adopt-thread --kind Archived threads report `status=archived`; the backend maps that to `idle`. Visible or pending threads report unknown busy state, so supervision does not claim a live Desktop thread is idle without an explicit archived marker. `fm-teardown.sh` refuses codex-app tasks until the thread is archived in Desktop and marked archived in the ledger. +It also validates the recorded project and worktree before removal; `--force` does not bypass the Codex App archive marker or the project-checkout/registered-worktree safety checks. `codex-app` is not selectable for new spawns through `--backend`, `FM_BACKEND`, or `config/backend` until a complete visible-thread spawn lifecycle exists. Use `prepare`, `record-thread`, or `adopt-thread` to create codex-app metadata for Desktop-owned visible threads. diff --git a/docs/scripts.md b/docs/scripts.md index b0d1e885..97e9eadc 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -37,13 +37,13 @@ Each file also starts with a short header comment. | `fm-wake-drain.sh` | Atomically drain queued watcher wakes before handling supervision work, then run the watcher-liveness guard | | `fm-wake-lib.sh` | Shared durable wake queue and portable lock helpers sourced by the watcher, drain, arm, guard, and daemon | | `fm-classify-lib.sh` | Shared captain-relevant wake classifier sourced by the watcher and daemon, plus the watcher's provably-working predicate | -| `fm-send.sh` | Send one verified literal line (or `--key Escape`) through the target's recorded runtime backend; exits non-zero on confirmed swallowed Enter; bare `kind=secondmate` targets are marked as from-firstmate; slash commands and codex `$...` skill invocations get popup-settle before Enter; text sends pause `FM_SEND_SETTLE` seconds after success | +| `fm-send.sh` | Send one verified literal line (or `--key Escape`) through the target's recorded runtime backend; exits non-zero on confirmed swallowed Enter or app-owned Codex App sends; bare `kind=secondmate` targets are marked as from-firstmate; slash commands and codex `$...` skill invocations get popup-settle before Enter; text sends pause `FM_SEND_SETTLE` seconds after success | | `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, dim-ghost-aware and border-aware composer detection, and verified submit retry | -| `fm-peek.sh` | Print a bounded tail of a crewmate endpoint through the target's recorded runtime backend | +| `fm-peek.sh` | Print a bounded tail of a crewmate endpoint through the target's recorded runtime backend, including cached Codex App captures | | `fm-pr-check.sh` | Record `pr=` and GitHub's `pr_head=` when available for a PR-ready task, then arm the watcher's merge poll | | `fm-pr-merge.sh` | Require a full GitHub PR URL, record `pr=` and available `pr_head=` via `fm-pr-check.sh`, parse it into `gh-axi pr merge --repo /`, default to `--squash` unless a merge method is forwarded, and reject malformed URLs or repo overrides | | `fm-promote.sh` | Promote a scout task in place so it becomes a protected ship task | -| `fm-teardown.sh` | Return a clean, landed ship worktree or retire/release a secondmate home; requires scout reports, checks child work, removes firstmate-owned hook artifacts, and prints the backlog-backend reminder | +| `fm-teardown.sh` | Return a clean, landed ship worktree or retire/release a secondmate home; requires scout reports, checks child work, archive-marked Codex App tasks before ledger teardown, removes firstmate-owned hook artifacts, and prints the backlog-backend reminder | | `fm-harness.sh` | Detect the running harness; resolve the effective crewmate (`crew`) or secondmate-launch (`secondmate`) harness; expose optional `config/secondmate-harness` model and effort tokens with `secondmate-model` and `secondmate-effort` | | `fm-lock.sh` | Per-home firstmate session lock | | `fm-x-lib.sh` | Shared X-mode `.env`, alternate env-file, relay, dry-run config, reply-thread splitting, outbound image payloads, and task-to-X-request meta-link helpers | From 2218e1c005979aa8b132c0203de365ed0b1b9cc5 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 00:39:56 -0400 Subject: [PATCH 11/19] fix codex app external worktree teardown --- bin/fm-codex-app | 28 +++++++++++++++++++---- bin/fm-teardown.sh | 17 +++++++++++++- docs/codex-app-backend.md | 8 +++++-- tests/fm-codex-app-state.test.sh | 14 ++++++++++++ tests/fm-teardown.test.sh | 39 ++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 8 deletions(-) diff --git a/bin/fm-codex-app b/bin/fm-codex-app index 10e4fba0..50e2c7e8 100755 --- a/bin/fm-codex-app +++ b/bin/fm-codex-app @@ -31,8 +31,8 @@ const STATE = process.env.FM_STATE_OVERRIDE || path.join(HOME, "state"); function usage() { console.error(`usage: fm-codex-app prepare - fm-codex-app record-thread --kind --project --worktree [--turn-id ] [--harness ] [--mode ] [--yolo ] [--pending-worktree-id ] - fm-codex-app adopt-thread --kind --worktree [--thread-name ] [--harness ] [--mode ] [--yolo ] [--turn-id ] [--pending-worktree-id ] [--brief ] + fm-codex-app record-thread --kind --project --worktree [--worktree-owner external] [--turn-id ] [--harness ] [--mode ] [--yolo ] [--pending-worktree-id ] + fm-codex-app adopt-thread --kind --worktree [--worktree-owner external] [--thread-name ] [--harness ] [--mode ] [--yolo ] [--turn-id ] [--pending-worktree-id ] [--brief ] fm-codex-app record-pending fm-codex-app record-capture fm-codex-app mark-archived @@ -87,10 +87,13 @@ function ensureCodexAppMeta(taskId, action) { return meta; } -function validateProtectedTaskState(kind, projectPath, worktreePath, action) { +function validateProtectedTaskState(kind, projectPath, worktreePath, owner, action) { if (kind !== "ship" && kind !== "scout") { throw new Error(`${action} requires --kind ship or --kind scout`); } + if (owner !== "external") { + throw new Error(`${action} only supports --worktree-owner external in this ledger slice`); + } if (!projectPath) throw new Error(`${action} requires project metadata for ${kind} tasks`); if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) { throw new Error(`${action} requires project to name an existing directory: ${projectPath}`); @@ -100,6 +103,7 @@ function validateProtectedTaskState(kind, projectPath, worktreePath, action) { throw new Error(`${action} requires --worktree to name an existing directory: ${worktreePath}`); } validateDisposableWorktree(projectPath, worktreePath, action); + return owner; } function realDir(dir) { @@ -299,7 +303,13 @@ async function main() { const kind = flags.kind || meta.kind; const projectPath = flags.project || meta.project; const worktreePath = flags.worktree || meta.worktree; - validateProtectedTaskState(kind, projectPath, worktreePath, "record-thread"); + const worktreeOwner = validateProtectedTaskState( + kind, + projectPath, + worktreePath, + flags.worktree_owner || meta.codex_app_worktree_owner || "external", + "record-thread", + ); const existing = taskForThread(threadId); if (existing && existing.taskId !== taskId) { throw new Error(`thread ${threadId} is already recorded for task ${existing.taskId}`); @@ -310,6 +320,7 @@ async function main() { turn_id: flags.turn_id, project: projectPath, worktree: worktreePath, + codex_app_worktree_owner: worktreeOwner, harness: flags.harness || meta.harness || "codex", kind, mode: flags.mode || meta.mode, @@ -331,13 +342,20 @@ async function main() { const existing = taskForThread(threadId); if (existing) throw new Error(`thread ${threadId} is already recorded for task ${existing.taskId}`); const kind = flags.kind; - validateProtectedTaskState(kind, projectPath, flags.worktree, "adopt-thread"); + const worktreeOwner = validateProtectedTaskState( + kind, + projectPath, + flags.worktree, + flags.worktree_owner || "external", + "adopt-thread", + ); const projectMode = resolveProjectMode(projectPath); writeMeta(taskId, { backend: "codex-app", window: threadId, codex_app_thread_name: flags.thread_name || `fm-${taskId}`, worktree: flags.worktree, + codex_app_worktree_owner: worktreeOwner, project: projectPath, harness: flags.harness || "codex", kind, diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 00efdd27..c6865cb8 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -66,6 +66,7 @@ PROJ=$(grep '^project=' "$META" | cut -d= -f2-) BACKEND=$(fm_backend_of_meta "$META") HOME_PATH=$(grep '^home=' "$META" | cut -d= -f2- || true) PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) +CODEX_APP_WORKTREE_OWNER=$(grep '^codex_app_worktree_owner=' "$META" | tail -1 | cut -d= -f2- || true) # tasktmp is recorded by fm-spawn for tasks that set up a per-task temp root # (/tmp/fm-/); absent for tasks spawned before that change, so tolerate empty. TASK_TMP=$(grep '^tasktmp=' "$META" | cut -d= -f2- || true) @@ -114,6 +115,10 @@ require_codex_app_teardown_state() { case "$KIND" in ship|scout) local proj_abs wt_abs + if [ "$CODEX_APP_WORKTREE_OWNER" != external ]; then + echo "REFUSED: Codex App $KIND task $ID must record codex_app_worktree_owner=external before teardown can safely avoid treehouse return." >&2 + exit 1 + fi if [ -z "$PROJ" ] || [ ! -d "$PROJ" ]; then echo "REFUSED: Codex App $KIND task $ID has no existing project directory recorded for teardown safety." >&2 exit 1 @@ -140,6 +145,14 @@ require_codex_app_teardown_state() { esac } +should_return_worktree_to_treehouse() { + [ "$KIND" != secondmate ] || return 1 + if [ "$BACKEND" = codex-app ] && [ "$CODEX_APP_WORKTREE_OWNER" = external ]; then + return 1 + fi + return 0 +} + remove_grok_turnend_auth() { local state_dir=$1 id=$2 token hooks_dir token=$(cat "$state_dir/$id.grok-turnend-token" 2>/dev/null || true) @@ -692,7 +705,9 @@ if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then fi # Best-effort: drop the local task branch so the shared repo does not accumulate refs. -if [ -d "$WT" ] && [ "$KIND" != secondmate ]; then +# Codex App visible-thread worktrees are externally owned in this ledger slice, +# so firstmate clears its local ledger but never treehouse-returns that checkout. +if [ -d "$WT" ] && should_return_worktree_to_treehouse; then branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD) if [ "$branch" != "HEAD" ]; then if git -C "$WT" checkout --detach -q 2>/dev/null; then diff --git a/docs/codex-app-backend.md b/docs/codex-app-backend.md index e44ba5c4..1948f7da 100644 --- a/docs/codex-app-backend.md +++ b/docs/codex-app-backend.md @@ -14,23 +14,25 @@ It writes pending task metadata with `backend=codex-app`, `window=` After Codex Desktop has a real thread, record it with: ```sh -bin/fm-codex-app record-thread --kind --project --worktree [--turn-id ] [--harness ] [--mode ] [--yolo ] [--pending-worktree-id ] +bin/fm-codex-app record-thread --kind --project --worktree [--worktree-owner external] [--turn-id ] [--harness ] [--mode ] [--yolo ] [--pending-worktree-id ] ``` That changes `window=` to the thread id, records `thread_id=`, sets `codex_app_thread_state=visible`, and clears the pending action. `record-pending` stores a pending worktree id when Desktop has created a worktree request but the final thread id is not known yet. `record-thread` requires protected task state: `kind=ship|scout`, an existing git project directory, and an existing registered linked worktree root must already be recorded in the prepared meta or supplied as flags. That prevents a prepared visible thread from becoming a default ship task that teardown cannot validate for landed work or scout report delivery. +The ledger records `codex_app_worktree_owner=external`; this slice does not accept treehouse ownership for Codex App visible-thread worktrees because Desktop, not firstmate, owns the thread/worktree lifecycle. `prepare` refuses an existing task id unless the existing metadata is the same pending Codex App task, so it cannot overwrite a live route. To bring an already-visible Desktop thread under firstmate supervision, use: ```sh -bin/fm-codex-app adopt-thread --kind --worktree [--thread-name ] [--harness ] [--mode ] [--yolo ] [--turn-id ] [--pending-worktree-id ] [--brief ] +bin/fm-codex-app adopt-thread --kind --worktree [--worktree-owner external] [--thread-name ] [--harness ] [--mode ] [--yolo ] [--turn-id ] [--pending-worktree-id ] [--brief ] ``` Adoption refuses duplicate task ids and duplicate thread ids. `--worktree` must name an existing registered linked worktree root for the project, distinct from the project checkout, for both ship and scout tasks. +Adoption records `codex_app_worktree_owner=external`; unsupported owner values are refused. When `--harness` is omitted, adoption records `harness=codex`. If `--mode` or `--yolo` is omitted, it resolves the project mode from `data/projects.md` and falls back to `no-mistakes`/`off`. @@ -63,6 +65,8 @@ Archived threads report `status=archived`; the backend maps that to `idle`. Visible or pending threads report unknown busy state, so supervision does not claim a live Desktop thread is idle without an explicit archived marker. `fm-teardown.sh` refuses codex-app tasks until the thread is archived in Desktop and marked archived in the ledger. It also validates the recorded project and worktree before removal; `--force` does not bypass the Codex App archive marker or the project-checkout/registered-worktree safety checks. +Codex App metadata without `codex_app_worktree_owner=external` fails closed at teardown. +For external Codex App worktrees, teardown may clear firstmate's local ledger state after the normal archive/report/landed gates, but it deliberately skips branch deletion and `treehouse return --force`. `codex-app` is not selectable for new spawns through `--backend`, `FM_BACKEND`, or `config/backend` until a complete visible-thread spawn lifecycle exists. Use `prepare`, `record-thread`, or `adopt-thread` to create codex-app metadata for Desktop-owned visible threads. diff --git a/tests/fm-codex-app-state.test.sh b/tests/fm-codex-app-state.test.sh index 4bebe0c5..fc7d2990 100755 --- a/tests/fm-codex-app-state.test.sh +++ b/tests/fm-codex-app-state.test.sh @@ -28,6 +28,7 @@ FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread "$ID" thread-1 --turn-id t grep -qx 'thread_id=thread-1' "$META" grep -qx 'window=thread-1' "$META" grep -qx 'turn_id=turn-1' "$META" +grep -qx 'codex_app_worktree_owner=external' "$META" grep -qx 'codex_app_pending_worktree_id=pending-1' "$META" grep -qx 'codex_app_thread_state=visible' "$META" grep -qx 'codex_app_transport=visible-thread' "$META" @@ -143,10 +144,16 @@ if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread prepared-unsafe thread- exit 1 fi grep -q 'distinct from the project checkout' "$TMP/unsafe-project-record.err" +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread prepared-unsafe thread-owner --kind scout --project "$PROJECT" --worktree "$WT" --worktree-owner treehouse 2>"$TMP/unsafe-owner-record.err"; then + echo "expected record-thread with unsupported worktree owner to fail" >&2 + exit 1 +fi +grep -q 'only supports --worktree-owner external' "$TMP/unsafe-owner-record.err" FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread prepared-unsafe thread-safe --kind scout --project "$PROJECT" --worktree "$WT" >/dev/null grep -qx 'kind=scout' "$TMP/state/prepared-unsafe.meta" grep -qx "project=$PROJECT" "$TMP/state/prepared-unsafe.meta" grep -qx "worktree=$WT" "$TMP/state/prepared-unsafe.meta" +grep -qx 'codex_app_worktree_owner=external' "$TMP/state/prepared-unsafe.meta" mkdir -p "$TMP/data" cat > "$TMP/data/projects.md" <"$TMP/treehouse-owner.err"; then + echo "expected adoption with unsupported worktree owner to fail" >&2 + exit 1 +fi +grep -q 'only supports --worktree-owner external' "$TMP/treehouse-owner.err" + PLAIN_CLONE="$TMP/plain-clone" git clone -q "$PROJECT" "$PLAIN_CLONE" if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread plain-clone thread-plain-clone "$PROJECT" --kind scout --worktree "$PLAIN_CLONE" 2>"$TMP/plain-clone.err"; then diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index e0a402b0..7d40bc7a 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -664,6 +664,7 @@ test_codex_app_teardown_refuses_visible_thread() { "window=thread-visible" \ "thread_id=thread-visible" \ "codex_app_thread_state=visible" \ + "codex_app_worktree_owner=external" \ "worktree=$case_dir/wt" \ "project=$case_dir/project" \ "kind=scout" \ @@ -692,6 +693,7 @@ test_codex_app_teardown_refuses_archived_missing_worktree() { "thread_id=thread-archived" \ "codex_app_thread_state=archived" \ "codex_app_archived=1" \ + "codex_app_worktree_owner=external" \ "worktree=" \ "project=$case_dir/project" \ "kind=ship" \ @@ -717,6 +719,7 @@ test_codex_app_teardown_refuses_project_checkout_worktree() { "thread_id=thread-archived" \ "codex_app_thread_state=archived" \ "codex_app_archived=1" \ + "codex_app_worktree_owner=external" \ "worktree=$case_dir/project" \ "project=$case_dir/project" \ "kind=scout" \ @@ -735,6 +738,33 @@ test_codex_app_teardown_refuses_project_checkout_worktree() { pass "Codex App teardown refuses project checkouts as disposable worktrees" } +test_codex_app_teardown_refuses_missing_worktree_owner() { + local case_dir rc + case_dir=$(make_case codex-missing-owner) + fm_write_meta "$case_dir/state/task-x1.meta" \ + "backend=codex-app" \ + "window=thread-archived" \ + "thread_id=thread-archived" \ + "codex_app_thread_state=archived" \ + "codex_app_archived=1" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=scout" \ + "mode=no-mistakes" + mkdir -p "$case_dir/data/task-x1" + printf 'report\n' > "$case_dir/data/task-x1/report.md" + + set +e + FM_DATA_OVERRIDE="$case_dir/data" run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "codex-missing-owner: teardown should refuse Codex App meta without explicit worktree owner" + grep -q 'codex_app_worktree_owner=external' "$case_dir/stderr" || fail "codex-missing-owner: refusal did not cite missing external owner" + [ -f "$case_dir/state/task-x1.meta" ] || fail "codex-missing-owner: refused teardown removed meta" + pass "Codex App teardown fails closed when worktree ownership is missing" +} + test_codex_app_teardown_allows_archived_scout_with_report() { local case_dir rc case_dir=$(make_case codex-archived-scout) @@ -744,12 +774,19 @@ test_codex_app_teardown_allows_archived_scout_with_report() { "thread_id=thread-archived" \ "codex_app_thread_state=archived" \ "codex_app_archived=1" \ + "codex_app_worktree_owner=external" \ "worktree=$case_dir/wt" \ "project=$case_dir/project" \ "kind=scout" \ "mode=no-mistakes" mkdir -p "$case_dir/data/task-x1" printf 'report\n' > "$case_dir/data/task-x1/report.md" + cat > "$case_dir/fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +echo "treehouse should not be called for external Codex App worktrees" >&2 +exit 99 +SH + chmod +x "$case_dir/fakebin/treehouse" set +e FM_DATA_OVERRIDE="$case_dir/data" run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" @@ -759,6 +796,7 @@ test_codex_app_teardown_allows_archived_scout_with_report() { expect_code 0 "$rc" "codex-archived-scout: teardown should allow archived Codex App scout with report" [ ! -f "$case_dir/state/task-x1.meta" ] || fail "codex-archived-scout: successful teardown left meta behind" [ ! -f "$case_dir/state/task-x1.codex-app.capture" ] || fail "codex-archived-scout: successful teardown left capture cache behind" + [ -d "$case_dir/wt" ] || fail "codex-archived-scout: external worktree was removed" pass "Codex App teardown allows archived scout tasks only after the report gate" } @@ -800,4 +838,5 @@ test_gh_error_and_content_absent_refuses test_codex_app_teardown_refuses_visible_thread test_codex_app_teardown_refuses_archived_missing_worktree test_codex_app_teardown_refuses_project_checkout_worktree +test_codex_app_teardown_refuses_missing_worktree_owner test_codex_app_teardown_allows_archived_scout_with_report From 35080acb6c6aa3ad40cdc657389044000a1b53f1 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 00:44:11 -0400 Subject: [PATCH 12/19] no-mistakes(review): forward codex-app backend environment --- bin/backends/codex-app.sh | 6 +++++- tests/fm-backend.test.sh | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/bin/backends/codex-app.sh b/bin/backends/codex-app.sh index 8868584f..5c36e829 100644 --- a/bin/backends/codex-app.sh +++ b/bin/backends/codex-app.sh @@ -4,7 +4,11 @@ # ledger as a backend target and refuses operations that must happen in Desktop. fm_backend_codex_app_cmd() { - "$FM_BACKEND_LIB_DIR/fm-codex-app" "$@" + FM_ROOT="$FM_ROOT" \ + FM_HOME="$FM_HOME" \ + FM_STATE_OVERRIDE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" \ + FM_DATA_OVERRIDE="${FM_DATA_OVERRIDE:-$FM_HOME/data}" \ + "$FM_BACKEND_LIB_DIR/fm-codex-app" "$@" } fm_backend_codex_app_capture() { # diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 30c8de56..aa021c90 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -243,6 +243,21 @@ test_codex_app_backend_cached_capture_and_liveness() { || fail "codex-app backend should report uncached recorded thread ids as existing" [ "$(FM_HOME="$home" fm_backend_busy_state codex-app thread-codex)" = unknown ] \ || fail "codex-app backend visible threads should report unknown busy state" + ( + local routed_state routed_data routed_home routed_out + routed_home="$TMP_ROOT/codex-app-routed-home" + routed_state="$TMP_ROOT/codex-app-routed-state" + routed_data="$TMP_ROOT/codex-app-routed-data" + mkdir -p "$routed_home" "$routed_state" "$routed_data" + fm_write_meta "$routed_state/routed.meta" "backend=codex-app" "window=thread-routed" "thread_id=thread-routed" "codex_app_thread_state=visible" + printf 'one\ntwo\n' > "$routed_state/routed.codex-app.capture" + FM_HOME="$routed_home" + FM_STATE_OVERRIDE="$routed_state" + FM_DATA_OVERRIDE="$routed_data" + export -n FM_HOME FM_STATE_OVERRIDE FM_DATA_OVERRIDE 2>/dev/null || true + routed_out=$(fm_backend_capture codex-app thread-routed 1) + [ "$routed_out" = "two" ] || fail "codex-app backend should forward resolved unexported FM_HOME/FM_STATE_OVERRIDE/FM_DATA_OVERRIDE" + ) if FM_HOME="$home" fm_backend_kill codex-app thread-codex 2>"$home/kill-visible.err"; then fail "codex-app backend kill should refuse visible, app-owned threads" fi From d4b0da9f5de1487300e3c1e13ab7bdf1540ed899 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 00:54:03 -0400 Subject: [PATCH 13/19] no-mistakes(document): sync Codex docs --- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- docs/configuration.md | 2 +- tests/fm-backend.test.sh | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9f0c93ab..e039163e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown - .meta written by fm-spawn for spawned tasks: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a task on a non-default runtime backend also records backend= (absent means tmux, the verified reference backend; herdr is a second, experimental backend recording herdr_session=, herdr_workspace_id=, herdr_tab_id=, herdr_pane_id= too - docs/herdr-backend.md; bin/fm-backend.sh, section 8). fm-codex-app prepare writes pending codex-app ledger meta with backend=codex-app, window=, harness=codex, codex_app_thread_name=, codex_app_thread_state=, codex_app_pending_action=, codex_app_transport=visible-thread, and codex_app_brief=. fm-codex-app adopt-thread writes visible-thread meta including project=, worktree=, harness=, kind=, mode=, yolo=, thread_id=, and optionally turn_id=, codex_app_pending_worktree_id=, or codex_app_brief= - docs/codex-app-backend.md. (fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request= and x_request_ts= for an X-mention-originated task, section 14) + .meta written by fm-spawn for spawned tasks: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a task on a non-default runtime backend also records backend= (absent means tmux, the verified reference backend; herdr is a second, experimental backend recording herdr_session=, herdr_workspace_id=, herdr_tab_id=, herdr_pane_id= too - docs/herdr-backend.md; bin/fm-backend.sh, section 8). fm-codex-app prepare writes pending codex-app ledger meta with backend=codex-app, window=, harness=codex, codex_app_thread_name=, codex_app_thread_state=, codex_app_pending_action=, codex_app_transport=visible-thread, and codex_app_brief=. fm-codex-app record-thread or adopt-thread writes visible-thread meta including project=, worktree=, codex_app_worktree_owner=external, harness=, kind=, mode=, yolo=, thread_id=, and optionally turn_id=, codex_app_pending_worktree_id=, or codex_app_brief= - docs/codex-app-backend.md. (fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request= and x_request_ts= for an X-mention-originated task, section 14) .check.sh optional slow poll you write per task (e.g. merged-PR check) x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ecee81d0..afa3adfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ tests/fm-wake-daemon-lifecycle-e2e.test.sh # watcher + daemon lifecycle e2e: res tests/fm-composer-ghost.test.sh # dim-ghost stripping, ghost-only composer detection, and escape-free peek tests tests/fm-afk-inject-e2e.test.sh # private-socket end-to-end test of the afk injection path (partial-input deferral, swallowed-Enter retry) tests/fm-bootstrap.test.sh # bootstrap dependency, feature-probe, and crew-dispatch reporting tests -tests/fm-session-start.test.sh # fm-session-start.sh: ABSENT vs empty-vs-present digest files, lock-refusal read-only path skipping every mutating step, diagnostics-first section ordering, status-tail bounding, tmux/herdr endpoint liveness, and composition of the real fm-lock/fm-bootstrap/fm-wake-drain scripts +tests/fm-session-start.test.sh # fm-session-start.sh: ABSENT vs empty-vs-present digest files, lock-refusal read-only path skipping every mutating step, diagnostics-first section ordering, status-tail bounding, backend endpoint liveness, and composition of the real fm-lock/fm-bootstrap/fm-wake-drain scripts tests/fm-grok-harness.test.sh # grok adapter spawn hook, token guard, teardown cleanup, and session-lock detection tests tests/fm-fleet-sync.test.sh # project clone refresh: safe detached recovery, STUCK drift reports, benign skips, and bootstrap relay tests/fm-x-mode.test.sh # X-mode poll, inbox context round-trip, reply threading, dismiss, dry-run preview, and .env-presence activation tests diff --git a/docs/configuration.md b/docs/configuration.md index 0aa70bc9..aa00795f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -185,7 +185,7 @@ FM_STATE_OVERRIDE= # alternate state dir, mainly for tests FM_DATA_OVERRIDE= # alternate data dir, mainly for tests FM_PROJECTS_OVERRIDE= # alternate projects dir, mainly for tests FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests -FM_BACKEND= # optional runtime session-provider backend override for new spawns; tmux (reference) or herdr (experimental) +FM_BACKEND= # optional runtime session-provider backend override for new spawns; tmux (reference) or herdr (experimental); codex-app is ledger-only and rejected for spawns HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index aa021c90..50df243a 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -13,8 +13,8 @@ # diffs the two command logs byte-for-byte - the report's P1 checklist # item "run current main scripts and refactored scripts against the same # fake tools and compare command logs". -# 3. Asserts the new `--backend`/`FM_BACKEND` selection refuses an unknown -# backend loudly (tmux is the only verified adapter in P1). +# 3. Asserts backend selection refuses unknown adapters loudly and refuses +# metadata-only adapters such as codex-app for new spawns. # # fm-watch.sh's signal/stale/check/heartbeat wake-string contract is already # exercised end-to-end against this refactor by tests/fm-watch-triage.test.sh From 35a95e83290f01af5e08dd0e3b7af4e52283a170 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 00:56:32 -0400 Subject: [PATCH 14/19] no-mistakes(lint): Fix shell lint --- bin/backends/codex-app.sh | 7 +++++-- bin/fm-backend.sh | 2 ++ bin/fm-crew-state.sh | 2 ++ bin/fm-send.sh | 2 ++ bin/fm-spawn.sh | 3 +++ bin/fm-teardown.sh | 2 ++ bin/fm-watch.sh | 2 ++ tests/fm-backend.test.sh | 3 +++ tests/fm-teardown.test.sh | 1 + 9 files changed, 22 insertions(+), 2 deletions(-) diff --git a/bin/backends/codex-app.sh b/bin/backends/codex-app.sh index 5c36e829..d2554db0 100644 --- a/bin/backends/codex-app.sh +++ b/bin/backends/codex-app.sh @@ -4,10 +4,13 @@ # ledger as a backend target and refuses operations that must happen in Desktop. fm_backend_codex_app_cmd() { + local data_override state_override + state_override="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + data_override="${FM_DATA_OVERRIDE:-$FM_HOME/data}" FM_ROOT="$FM_ROOT" \ FM_HOME="$FM_HOME" \ - FM_STATE_OVERRIDE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" \ - FM_DATA_OVERRIDE="${FM_DATA_OVERRIDE:-$FM_HOME/data}" \ + FM_STATE_OVERRIDE="$state_override" \ + FM_DATA_OVERRIDE="$data_override" \ "$FM_BACKEND_LIB_DIR/fm-codex-app" "$@" } diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index af271c1a..2ec9564f 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -193,6 +193,7 @@ fm_backend_source() { # tmux) if [ -z "${_FM_BACKEND_TMUX_SOURCED:-}" ]; then # shellcheck source=bin/backends/tmux.sh + # shellcheck disable=SC1091 . "$FM_BACKEND_LIB_DIR/backends/tmux.sh" _FM_BACKEND_TMUX_SOURCED=1 fi @@ -200,6 +201,7 @@ fm_backend_source() { # herdr) if [ -z "${_FM_BACKEND_HERDR_SOURCED:-}" ]; then # shellcheck source=bin/backends/herdr.sh + # shellcheck disable=SC1091 . "$FM_BACKEND_LIB_DIR/backends/herdr.sh" _FM_BACKEND_HERDR_SOURCED=1 fi diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index bb5390cd..b93e3dd2 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -43,8 +43,10 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-tmux-lib.sh +# shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-tmux-lib.sh" # shellcheck source=bin/fm-backend.sh +# shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-backend.sh" ID=${1:-} diff --git a/bin/fm-send.sh b/bin/fm-send.sh index d15ca08d..ec2fe819 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -37,8 +37,10 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-backend.sh +# shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-marker-lib.sh +# shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-marker-lib.sh" "$SCRIPT_DIR/fm-guard.sh" || true diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 91970152..9c3b3610 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -80,10 +80,13 @@ PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" SUB_HOME_MARKER=".fm-secondmate-home" # shellcheck source=bin/fm-ff-lib.sh +# shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-ff-lib.sh" # shellcheck source=bin/fm-config-inherit-lib.sh +# shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-config-inherit-lib.sh" # shellcheck source=bin/fm-backend.sh +# shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-backend.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. diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index c6865cb8..25f6553f 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -51,8 +51,10 @@ CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" SECONDMATE_REG="$DATA/secondmates.md" SUB_HOME_MARKER=".fm-secondmate-home" # shellcheck source=bin/fm-tasks-axi-lib.sh +# shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # shellcheck source=bin/fm-backend.sh +# shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-backend.sh" "$FM_ROOT/bin/fm-guard.sh" || true ID=$1 diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 00e2bfd3..05f938f7 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -31,11 +31,13 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" mkdir -p "$STATE" # shellcheck source=bin/fm-wake-lib.sh +# shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-wake-lib.sh" # Shared wake classifier (captain-relevant verbs + signal/stale/heartbeat # predicates), the SAME library the away-mode daemon uses, so the triage policy # has one definition. # shellcheck source=bin/fm-classify-lib.sh +# shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-classify-lib.sh" # The DEFAULT EVENT SOURCE: this watcher's poll loop over the pull primitives # (capture, recorded windows, backend busy-state, and the BUSY_REGEX fallback) diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 50df243a..deba361f 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -27,10 +27,13 @@ set -u # shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" fm_git_identity fmtest fmtest@example.invalid # shellcheck source=bin/fm-backend.sh +# shellcheck disable=SC1091 +# shellcheck disable=SC2153 . "$ROOT/bin/fm-backend.sh" TMP_ROOT=$(fm_test_tmproot fm-backend-tests) diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 7d40bc7a..5bae2ce2 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -37,6 +37,7 @@ set -u # shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" fm_git_identity fmtest fmtest@example.invalid From 7397a41ba1737c1b1d77be62decec7ebd3afefb2 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 00:58:59 -0400 Subject: [PATCH 15/19] fix codex app record-thread project mode fallback --- bin/fm-codex-app | 5 +++-- tests/fm-codex-app-state.test.sh | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bin/fm-codex-app b/bin/fm-codex-app index 50e2c7e8..704cea4c 100755 --- a/bin/fm-codex-app +++ b/bin/fm-codex-app @@ -314,6 +314,7 @@ async function main() { if (existing && existing.taskId !== taskId) { throw new Error(`thread ${threadId} is already recorded for task ${existing.taskId}`); } + const projectMode = resolveProjectMode(projectPath); writeMeta(taskId, { window: threadId, thread_id: threadId, @@ -323,8 +324,8 @@ async function main() { codex_app_worktree_owner: worktreeOwner, harness: flags.harness || meta.harness || "codex", kind, - mode: flags.mode || meta.mode, - yolo: flags.yolo || meta.yolo, + mode: flags.mode || meta.mode || projectMode.mode, + yolo: flags.yolo || meta.yolo || projectMode.yolo, codex_app_pending_worktree_id: flags.pending_worktree_id, codex_app_thread_state: "visible", codex_app_pending_action: "none", diff --git a/tests/fm-codex-app-state.test.sh b/tests/fm-codex-app-state.test.sh index fc7d2990..d8d7bf08 100755 --- a/tests/fm-codex-app-state.test.sh +++ b/tests/fm-codex-app-state.test.sh @@ -159,6 +159,11 @@ mkdir -p "$TMP/data" cat > "$TMP/data/projects.md" </dev/null +FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread prepared-mode thread-prepared-mode --kind scout --project "$PROJECT" --worktree "$WT" >/dev/null +grep -qx 'mode=direct-PR' "$TMP/state/prepared-mode.meta" +grep -qx 'yolo=on' "$TMP/state/prepared-mode.meta" + FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread adopted thread-2 "$PROJECT" --kind scout --thread-name fm-adopted --worktree "$WT" >/dev/null ADOPTED_META="$TMP/state/adopted.meta" grep -qx 'backend=codex-app' "$ADOPTED_META" From ee822d2af965e431030b94ad0542503882b4a973 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 01:05:06 -0400 Subject: [PATCH 16/19] no-mistakes(review): prevent Codex App stale wakes --- bin/backends/codex-app.sh | 3 ++- docs/codex-app-backend.md | 2 +- tests/fm-backend.test.sh | 6 ++++-- tests/fm-watch-triage.test.sh | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/bin/backends/codex-app.sh b/bin/backends/codex-app.sh index d2554db0..4819e2f2 100644 --- a/bin/backends/codex-app.sh +++ b/bin/backends/codex-app.sh @@ -53,7 +53,8 @@ fm_backend_codex_app_busy_state() { # status=$(fm_backend_codex_app_cmd status "$1" 2>/dev/null | sed -n 's/^status=//p' | tail -1) || { printf 'unknown'; return 0; } case "$status" in archived) printf 'idle' ;; - *) printf 'unknown' ;; + visible|pending|pending-worktree) printf 'busy' ;; + *) printf 'busy' ;; esac } diff --git a/docs/codex-app-backend.md b/docs/codex-app-backend.md index 1948f7da..411f2bf6 100644 --- a/docs/codex-app-backend.md +++ b/docs/codex-app-backend.md @@ -62,7 +62,7 @@ bin/fm-codex-app mark-archived ``` Archived threads report `status=archived`; the backend maps that to `idle`. -Visible or pending threads report unknown busy state, so supervision does not claim a live Desktop thread is idle without an explicit archived marker. +Visible or pending threads report busy state, so stale-pane supervision stays out of app-owned Desktop threads until an explicit archived marker exists. `fm-teardown.sh` refuses codex-app tasks until the thread is archived in Desktop and marked archived in the ledger. It also validates the recorded project and worktree before removal; `--force` does not bypass the Codex App archive marker or the project-checkout/registered-worktree safety checks. Codex App metadata without `codex_app_worktree_owner=external` fails closed at teardown. diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index deba361f..7af295da 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -244,8 +244,8 @@ test_codex_app_backend_cached_capture_and_liveness() { || fail "codex-app backend should report recorded thread ids as existing" FM_HOME="$home" fm_backend_target_exists codex-app thread-uncached \ || fail "codex-app backend should report uncached recorded thread ids as existing" - [ "$(FM_HOME="$home" fm_backend_busy_state codex-app thread-codex)" = unknown ] \ - || fail "codex-app backend visible threads should report unknown busy state" + [ "$(FM_HOME="$home" fm_backend_busy_state codex-app thread-codex)" = busy ] \ + || fail "codex-app backend visible threads should report busy state" ( local routed_state routed_data routed_home routed_out routed_home="$TMP_ROOT/codex-app-routed-home" @@ -267,6 +267,8 @@ test_codex_app_backend_cached_capture_and_liveness() { assert_contains "$(cat "$home/kill-visible.err")" "still marked visible" \ "codex-app backend kill refusal should explain that the ledger must be archived first" printf 'codex_app_thread_state=archived\ncodex_app_archived=1\n' >> "$meta" + [ "$(FM_HOME="$home" fm_backend_busy_state codex-app thread-codex)" = idle ] \ + || fail "codex-app backend archived threads should report idle state" FM_HOME="$home" fm_backend_kill codex-app thread-codex \ || fail "codex-app backend kill should accept already-archived ledger threads" diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 04875dc2..f097d990 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -410,6 +410,39 @@ test_nonterminal_stale_not_working_surfaced() { pass "a not-provably-working non-terminal stale is surfaced immediately (never left to wait out the timer)" } +test_codex_app_visible_thread_skips_stale_wake() { + local dir state fakebin out capture_file window key pane_hash sig pid + dir=$(make_case codex-app-visible-stale); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$state/codex-visible.codex-app.capture" + window="thread-visible" + printf 'cached transcript\n' > "$capture_file" + fm_write_meta "$state/codex-visible.meta" \ + "backend=codex-app" \ + "window=$window" \ + "thread_id=$window" \ + "kind=ship" \ + "codex_app_thread_state=visible" + printf 'working: Desktop owns this visible thread\n' > "$state/codex-visible.status" + sig=$(seen_sig "$state/codex-visible.status"); printf '%s' "$sig" > "$state/.seen-codex-visible_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "cached transcript") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + export FM_FAKE_CREW_STATE='state: unknown · source: none · no current-state source available' + + PATH="$fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" \ + FM_STALE_ESCALATE_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "watcher surfaced a stale wake for a visible codex-app thread: $(cat "$out")" + fi + [ ! -s "$out" ] || fail "visible codex-app thread printed a wake reason" + [ ! -s "$state/.wake-queue" ] || fail "visible codex-app thread enqueued a wake" + [ ! -e "$state/.stale-$key" ] || fail "visible codex-app thread advanced stale suppressor" + reap "$pid" + pass "visible codex-app threads do not trip stale-pane wakes" +} + test_nonterminal_stale_repairs_missing_or_corrupt_timer() { local dir state fakebin out capture_file window key pane_hash sig pid since dir=$(make_case nonterminal-stale-timer-repair); state="$dir/state"; fakebin="$dir/fakebin" @@ -597,6 +630,7 @@ test_actionable_signal_surfaced test_terminal_stale_surfaced test_nonterminal_stale_provably_working_absorbed_then_escalated test_nonterminal_stale_not_working_surfaced +test_codex_app_visible_thread_skips_stale_wake test_nonterminal_stale_repairs_missing_or_corrupt_timer test_triage_log_size_cap_accepts_spaced_wc_counts test_heartbeat_no_change_absorbed From 1bc455033570c76fd01a231bd7e17f29cde75ec1 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 01:15:26 -0400 Subject: [PATCH 17/19] no-mistakes(document): Document Codex App ledger gaps --- CONTRIBUTING.md | 2 +- docs/codex-app-backend.md | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afa3adfe..8f8d1aec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star Test scripts and helpers in `tests/` are plain bash too. `shellcheck bin/*.sh bin/backends/*.sh tests/*.sh` must pass, and CI enforces it. - Changes to harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, busy signatures in `bin/fm-watch.sh` and `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. -- Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) need empirical adapter notes in the relevant docs, following `docs/herdr-backend.md` for non-tmux backends. +- Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) need empirical adapter notes in the relevant docs, following `docs/herdr-backend.md` for session adapters or `docs/codex-app-backend.md` for visible-thread ledger adapters. - In Markdown, put each full sentence on its own line. ## Development diff --git a/docs/codex-app-backend.md b/docs/codex-app-backend.md index 411f2bf6..42aa8d6c 100644 --- a/docs/codex-app-backend.md +++ b/docs/codex-app-backend.md @@ -18,7 +18,13 @@ bin/fm-codex-app record-thread --kind --proje ``` That changes `window=` to the thread id, records `thread_id=`, sets `codex_app_thread_state=visible`, and clears the pending action. -`record-pending` stores a pending worktree id when Desktop has created a worktree request but the final thread id is not known yet. +When Desktop starts a handoff before the final visible thread id is known, record the pending worktree request with: + +```sh +bin/fm-codex-app record-pending +``` + +This records `codex_app_pending_worktree_id=`, sets `codex_app_thread_state=pending-worktree`, and leaves `codex_app_pending_action=await_thread_id` until `record-thread` supplies the final thread id. `record-thread` requires protected task state: `kind=ship|scout`, an existing git project directory, and an existing registered linked worktree root must already be recorded in the prepared meta or supplied as flags. That prevents a prepared visible thread from becoming a default ship task that teardown cannot validate for landed work or scout report delivery. The ledger records `codex_app_worktree_owner=external`; this slice does not accept treehouse ownership for Codex App visible-thread worktrees because Desktop, not firstmate, owns the thread/worktree lifecycle. From 9f3d01560385962b9582a9d3a3468050f9c42e36 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 01:17:38 -0400 Subject: [PATCH 18/19] no-mistakes(lint): lint annotations fixed --- tests/fm-watch-triage.test.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index f097d990..93651c06 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -17,13 +17,16 @@ set -u # shellcheck source=tests/wake-helpers.sh +# shellcheck disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/wake-helpers.sh" # shellcheck source=bin/fm-classify-lib.sh +# shellcheck disable=SC1091 . "$ROOT/bin/fm-classify-lib.sh" WATCH="$ROOT/bin/fm-watch.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh" +# shellcheck disable=SC2034 # Consumed by make_case helpers sourced above. TMP_ROOT=$(fm_test_tmproot fm-watch-triage-tests) # Common watcher knobs: tight poll/grace, no check or heartbeat cadence unless a From 7fd90cf67039a7a361aa2378f920ac9eccbe0434 Mon Sep 17 00:00:00 2001 From: Stephen Brouhard Date: Fri, 3 Jul 2026 11:51:12 -0400 Subject: [PATCH 19/19] fix: drop hunks superseded upstream and align spawn-gate naming with sibling PR --- bin/fm-backend.sh | 24 ++++++++---------------- bin/fm-pr-merge.sh | 6 +----- bin/fm-spawn.sh | 2 +- tests/fm-backend.test.sh | 12 ++++++------ 4 files changed, 16 insertions(+), 28 deletions(-) diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 2ec9564f..61abd57f 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -45,7 +45,7 @@ FM_BACKEND_CONFIG_DIR="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" # but newer than tmux's long-proven default path. codex-app is app-owned visible # thread state, not a headless task creator. FM_BACKEND_KNOWN="tmux herdr codex-app" -FM_BACKEND_SPAWNABLE="tmux herdr" +FM_BACKEND_SPAWN="tmux herdr" # fm_backend_is_known: 0 iff has a verified adapter. fm_backend_is_known() { # @@ -56,14 +56,6 @@ fm_backend_is_known() { # return 1 } -fm_backend_is_spawnable() { # - local name=$1 backend - for backend in $FM_BACKEND_SPAWNABLE; do - [ "$name" = "$backend" ] && return 0 - done - return 1 -} - # fm_backend_detect: detect the runtime firstmate itself is CURRENTLY executing # inside, from verified environment markers (mirrors bin/fm-harness.sh's # env-marker detection layer for harnesses). Prints the detected backend name @@ -131,14 +123,14 @@ fm_backend_validate() { # return 0 } -fm_backend_validate_spawnable() { # - local name=$1 +fm_backend_validate_spawn() { # + local name=$1 backend fm_backend_validate "$name" || return 1 - if ! fm_backend_is_spawnable "$name"; then - echo "error: backend '$name' cannot create new shell endpoints yet (spawnable: $FM_BACKEND_SPAWNABLE); use bin/fm-codex-app prepare/adopt-thread for Codex App visible threads" >&2 - return 1 - fi - return 0 + for backend in $FM_BACKEND_SPAWN; do + [ "$name" = "$backend" ] && return 0 + done + echo "error: backend '$name' does not support task spawning yet (spawn-supported: $FM_BACKEND_SPAWN)" >&2 + return 1 } # fm_meta_get: the LAST value of `key=` in , or empty (never diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index ae438a60..8ffb32cc 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -87,8 +87,4 @@ if ! caller_has_merge_method "$@"; then merge_args=(--squash) fi -if [ "${#merge_args[@]}" -gt 0 ]; then - gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]}" "$@" -else - gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "$@" -fi +gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]}" "$@" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 9c3b3610..01b80859 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -153,7 +153,7 @@ if [ "$BACKEND_SET" -eq 1 ]; then else BACKEND=$(fm_backend_name) fi -fm_backend_validate_spawnable "$BACKEND" || exit 1 +fm_backend_validate_spawn "$BACKEND" || exit 1 fm_backend_source "$BACKEND" || exit 1 # Batch dispatch (see header): when the first positional is an `id=repo` pair, treat every diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 7af295da..0adbd2ca 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -204,10 +204,10 @@ test_backend_name_explicit_beats_detection() { test_backend_validate_refuses_unknown() { fm_backend_validate tmux 2>/dev/null || fail "fm_backend_validate should accept tmux" fm_backend_validate codex-app 2>/dev/null || fail "fm_backend_validate should accept codex-app for adopted metadata" - if fm_backend_validate_spawnable codex-app 2>"$TMP_ROOT/codex-spawnable.err"; then - fail "fm_backend_validate_spawnable should refuse codex-app until spawn lifecycle exists" + if fm_backend_validate_spawn codex-app 2>"$TMP_ROOT/codex-spawnable.err"; then + fail "fm_backend_validate_spawn should refuse codex-app until spawn lifecycle exists" fi - assert_contains "$(cat "$TMP_ROOT/codex-spawnable.err")" "cannot create new shell endpoints yet" \ + assert_contains "$(cat "$TMP_ROOT/codex-spawnable.err")" "does not support task spawning yet" \ "codex-app spawn refusal did not explain the visible-thread path" local out out=$(fm_backend_validate zellij 2>&1) && fail "fm_backend_validate should refuse zellij (P1 has no such adapter)" @@ -654,14 +654,14 @@ test_spawn_refuses_codex_app_selection() { "$ROOT/bin/fm-spawn.sh" nope-codex-z1 projects/none claude --backend codex-app 2>&1) status=$? [ "$status" -ne 0 ] || fail "fm-spawn --backend codex-app should refuse until spawn lifecycle exists" - assert_contains "$out" "cannot create new shell endpoints yet" "codex-app --backend refusal did not explain the restriction" + assert_contains "$out" "does not support task spawning yet" "codex-app --backend refusal did not explain the restriction" out=$(FM_ROOT_OVERRIDE='' FM_HOME='' FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ FM_PROJECTS_OVERRIDE='' FM_CONFIG_OVERRIDE='' FM_SPAWN_NO_GUARD=1 FM_BACKEND=codex-app \ "$ROOT/bin/fm-spawn.sh" nope-codex-z2 projects/none claude 2>&1) status=$? [ "$status" -ne 0 ] || fail "FM_BACKEND=codex-app should refuse until spawn lifecycle exists" - assert_contains "$out" "cannot create new shell endpoints yet" "codex-app FM_BACKEND refusal did not explain the restriction" + assert_contains "$out" "does not support task spawning yet" "codex-app FM_BACKEND refusal did not explain the restriction" config="$TMP_ROOT/codex-config-backend" mkdir -p "$config" @@ -671,7 +671,7 @@ test_spawn_refuses_codex_app_selection() { "$ROOT/bin/fm-spawn.sh" nope-codex-z3 projects/none claude 2>&1) status=$? [ "$status" -ne 0 ] || fail "config/backend=codex-app should refuse until spawn lifecycle exists" - assert_contains "$out" "cannot create new shell endpoints yet" "codex-app config/backend refusal did not explain the restriction" + assert_contains "$out" "does not support task spawning yet" "codex-app config/backend refusal did not explain the restriction" pass "fm-spawn.sh refuses codex-app from --backend, FM_BACKEND, and config/backend until spawn lifecycle exists" }