diff --git a/AGENTS.md b/AGENTS.md index d657a01b..e039163e 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 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 @@ -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 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) @@ -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 --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 @@ -500,7 +503,8 @@ 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. 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 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 2de74545..8f8d1aec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,14 +38,14 @@ 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 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. 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 @@ -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 @@ -86,10 +86,12 @@ 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 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..ec5a8363 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 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 @@ -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. +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. @@ -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 new file mode 100644 index 00000000..4819e2f2 --- /dev/null +++ b/bin/backends/codex-app.sh @@ -0,0 +1,63 @@ +#!/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() { + 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="$state_override" \ + FM_DATA_OVERRIDE="$data_override" \ + "$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() { # + 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() { # + 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' ;; + visible|pending|pending-worktree) printf 'busy' ;; + *) printf 'busy' ;; + 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..61abd57f 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,8 +42,10 @@ 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. -FM_BACKEND_KNOWN="tmux herdr" +# 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_SPAWN="tmux herdr" # fm_backend_is_known: 0 iff has a verified adapter. fm_backend_is_known() { # @@ -119,6 +123,16 @@ fm_backend_validate() { # return 0 } +fm_backend_validate_spawn() { # + local name=$1 backend + fm_backend_validate "$name" || return 1 + 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 # 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. @@ -171,6 +185,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 @@ -178,10 +193,18 @@ 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 ;; + 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 +262,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 +275,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 +290,7 @@ fm_backend_send_text_submit() { # &2; return 1 ;; esac } @@ -279,6 +305,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 } @@ -286,7 +313,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. @@ -296,6 +324,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 } @@ -306,8 +335,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. @@ -324,6 +354,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 new file mode 100755 index 00000000..704cea4c --- /dev/null +++ b/bin/fm-codex-app @@ -0,0 +1,458 @@ +#!/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"); +const childProcess = require("node:child_process"); + +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 --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 + 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 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 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}`); + } + 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}`); + } + validateDisposableWorktree(projectPath, worktreePath, action); + return owner; +} + +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) { + 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}`); + 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, + codex_app_thread_name: 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 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; + 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}`); + } + const projectMode = resolveProjectMode(projectPath); + writeMeta(taskId, { + window: threadId, + thread_id: threadId, + 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 || 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", + 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; + 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, + 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.log(""); + 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/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 29f4caf6..b93e3dd2 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. @@ -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:-} @@ -121,7 +123,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 +132,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 0b847137..ec2fe819 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 @@ -36,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 @@ -107,7 +110,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/bin/fm-spawn.sh b/bin/fm-spawn.sh index 1c4352a6..01b80859 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. @@ -78,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. @@ -148,7 +153,7 @@ if [ "$BACKEND_SET" -eq 1 ]; then else BACKEND=$(fm_backend_name) fi -fm_backend_validate "$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/bin/fm-teardown.sh b/bin/fm-teardown.sh index b65809b2..25f6553f 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 @@ -49,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 @@ -64,6 +68,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) @@ -94,6 +99,62 @@ 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 +} + +require_codex_app_teardown_state() { + [ "$BACKEND" = codex-app ] || return 0 + 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 + 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 + } + 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 +} + +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) @@ -544,7 +605,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 } @@ -564,6 +625,9 @@ if [ "$KIND" = secondmate ]; then fi fi +require_codex_app_archived_for_teardown +require_codex_app_teardown_state + if [ "$KIND" = secondmate ] && [ "$FORCE" != "--force" ]; then SUB_STATE="$HOME_PATH/state" if [ -d "$SUB_STATE" ]; then @@ -643,7 +707,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 @@ -658,7 +724,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" @@ -668,7 +738,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/bin/fm-watch.sh b/bin/fm-watch.sh index 81a09a90..05f938f7 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -31,19 +31,21 @@ 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) # 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 +157,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 @@ -437,10 +438,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..e95a34cb 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. -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`. +`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`; 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`. -`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 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`. 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..42aa8d6c --- /dev/null +++ b/docs/codex-app-backend.md @@ -0,0 +1,96 @@ +# 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=`, `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: + +```sh +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. +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. +`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 [--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`. + +## 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`. +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. + +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 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. +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. + +## 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 +``` + +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. diff --git a/docs/configuration.md b/docs/configuration.md index 5649c7a6..aa00795f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,13 +20,17 @@ 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`. +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` or `herdr` 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=`. +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 +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/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..97e9eadc 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 | @@ -34,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 | diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 917b4b3c..0adbd2ca 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 @@ -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) @@ -200,10 +203,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 for adopted metadata" + 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")" "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)" 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/codex-app known, codex-app not spawnable, unknown refused loudly" } test_meta_get_and_backend_of_meta() { @@ -219,6 +228,53 @@ 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_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)" = 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" + 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 + 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" + + pass "codex-app backend: cached capture, liveness, busy state, and archive-gated kill" +} + test_resolve_selector_three_forms() { local state=$TMP_ROOT/resolve-state fakebin out mkdir -p "$state" @@ -545,6 +601,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() { @@ -569,6 +646,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" "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" "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" + 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" "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" +} + 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" @@ -652,14 +759,17 @@ 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 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 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-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..d8d7bf08 --- /dev/null +++ b/tests/fm-codex-app-state.test.sh @@ -0,0 +1,237 @@ +#!/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" +PROJECT="$TMP/project" +WT="$TMP/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" </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_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" + +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')" ] + +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" + +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/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" + +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" + +cat > "$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" +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" record-thread prepared-unsafe thread-project --kind scout --project "$PROJECT" --worktree "$PROJECT" 2>"$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" +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" </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" +grep -qx 'window=thread-2' "$ADOPTED_META" +grep -qx 'codex_app_thread_name=fm-adopted' "$ADOPTED_META" +grep -qx "worktree=$WT" "$ADOPTED_META" +grep -qx 'codex_app_worktree_owner=external' "$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" +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 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 "$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 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" + +if FM_ROOT="$TMP" "$ROOT/bin/fm-codex-app" adopt-thread treehouse-owner thread-treehouse-owner "$PROJECT" --kind scout --worktree "$WT" --worktree-owner treehouse 2>"$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 + 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 +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 "$PROJECT" --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" diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index a3e8b7ed..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 @@ -261,6 +262,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 +657,150 @@ 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" \ + "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" + 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" + 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" \ + "codex_app_worktree_owner=external" \ + "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_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" \ + "codex_app_worktree_owner=external" \ + "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_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) + 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" \ + "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" + 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" + [ ! -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" +} + test_local_only_force_overrides_unpushed() { local case_dir rc case_dir=$(make_case force-override) @@ -690,3 +836,8 @@ 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_refuses_project_checkout_worktree +test_codex_app_teardown_refuses_missing_worktree_owner +test_codex_app_teardown_allows_archived_scout_with_report diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 04875dc2..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 @@ -410,6 +413,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 +633,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