diff --git a/.agents/skills/agent-skill-trigger-index/SKILL.md b/.agents/skills/agent-skill-trigger-index/SKILL.md index 6e70321cb3f..be753519bd9 100644 --- a/.agents/skills/agent-skill-trigger-index/SKILL.md +++ b/.agents/skills/agent-skill-trigger-index/SKILL.md @@ -26,3 +26,4 @@ These skills are not captain-invocable; load them only at their precise triggers - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the Relay configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for a Relay-linked task before posting its completion follow-up; relevant only when Relay is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. +- `human-text-discipline` - load before writing or editing a pull request body, a commit message, or a captain-facing chat message; it owns the checkable AI-tell list for text a human reads and does not restate section 9. diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 80a00e90f7c..597098e4249 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -66,7 +66,7 @@ When any diagnostic needs captain attention, report the plain consequence and re Neither copy is a safe winner: union-merge them into this home's file by task id, resolve each conflicting id to its most recent real transition, check this home's archive before treating a missing Done row as lost, and verify the merged id set equals the union of both inputs before installing it. Then move the code-root file aside rather than deleting it, tell the captain which rows were recovered, and run every later backlog command through `bin/fm-tasks-axi.sh`; re-linking the code-root copy is never the fix, because the next cwd-relative tasks-axi write replaces the link again. - `SECONDMATE_SYNC: secondmate : skipped: ` - secondmate convergence left a live home on its existing checkout because the home was dirty, diverged, unsafe, on the wrong branch, missing its placement-specific target commit, unreachable, or otherwise not fast-forwardable, or because inherited local-material propagation failed; bootstrap continued, but inspect the reason because the secondmate's tracked instructions, inherited settings, or shared captain preferences may be stale after a primary update. -- `SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed after : ` - the session-start liveness sweep could not guarantee that the registered secondmate is running a real agent process. +- `SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed after : |gap: ` - the session-start liveness sweep could not guarantee that the registered secondmate is running a real agent process; a `gap:` line means a registered secondmate had no usable task record and relaunching it from the registry also failed. Investigate the reason because that secondmate is not guaranteed live. - `SECONDMATE_HANDOFF: secondmate : pending delivery: item(s)` - queued work has already left the main dispatchable backlog and remains safe in the named remote route's backlog-format outbox because backlog receipt or local outbox cleanup has not completed; [`bin/fm-backlog-handoff.sh`](../../../bin/fm-backlog-handoff.sh) owns the release contract. Preserve that outbox and rerun `bin/fm-backlog-handoff.sh --resume-pending` after the route, receipt, or cleanup problem is resolved; never re-add or dispatch the items from the main backlog. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 2348f12a87d..5997b5322b5 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -3,7 +3,7 @@ name: harness-adapters description: >- Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, kimi, cursor, gemini, muse, rovo, omp, agy, and devin. + Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, kimi, cursor, gemini, muse, rovo, omp, agy, devin, cline, and openhands. user-invocable: false metadata: internal: true @@ -35,7 +35,7 @@ For recovery and control, use the exact `harness=` in `state/.meta`; never i Deliver lifecycle actions only through `../../../bin/fm-control.sh interrupt|exit|relaunch`. Never type an interrupt key or exit command through `fm-send`, where routing-marked lifecycle text becomes chat. Trust handling is complete only when inspection proves the target started processing its instructions; delivery success alone is not proof. -Muse, Gemini, AGY, and Devin are verified only for crewmate and scout work, never a secondmate or primary. +Muse, Gemini, AGY, Devin, Cline, and OpenHands are verified only for crewmate and scout work, never a secondmate or primary. ## Detection @@ -96,7 +96,9 @@ A new tool remains undispatchable until the `verify` plan, its harness entry, ev "rovo": "references/harness/rovo.md", "omp": "references/harness/omp.md", "agy": "references/harness/agy.md", - "devin": "references/harness/devin.md" + "devin": "references/harness/devin.md", + "cline": "references/harness/cline.md", + "openhands": "references/harness/openhands.md" } } ``` diff --git a/.agents/skills/harness-adapters/references/harness/agy.md b/.agents/skills/harness-adapters/references/harness/agy.md index 406dcb1b070..fe560ac95e0 100644 --- a/.agents/skills/harness-adapters/references/harness/agy.md +++ b/.agents/skills/harness-adapters/references/harness/agy.md @@ -21,7 +21,7 @@ Verified as a CREWMATE and SCOUT adapter only; `../../../../../bin/fm-spawn.sh` | Resume | `--continue` and `--conversation` exist but carry no verified pane-resume contract; use deterministic relaunch. | | Model | `--model ` with the bare catalog id from `agy models` (for example `gemini-3.8-flash-high`); `bin/fm-spawn.sh` refuses a requested id a reachable listing omits. The listing is a remote fetch, so the probe runs stdin-detached under the shared hard bound and an unreachable or hung listing launches unvalidated with a notice. | | Effort | `--effort low\|medium\|high`; `xhigh` and `max` stay in task metadata under the record-and-omit contract. | -| Composer | Borderless bare `>` row, which the shared classifier reads as `unknown` under the dead-shell rule, never `empty`; steering confirms delivery through native agent-state and the delivery footer instead, the cursor precedent. | +| Composer | Borderless bare `>` row pinned above a full-width `─` rule. The shared classifier reads it `empty` only with a live agy identity (tmux foreground process, herdr `agent get`), and `unknown`/`pending` otherwise, so the dead-shell rule still guards every other pane; `bin/fm-control.sh exit` needs that identity proof to type `/quit`. Steering still confirms delivery through native agent-state and the delivery footer. | ## Trust, and where the decision persists diff --git a/.agents/skills/harness-adapters/references/harness/claude.md b/.agents/skills/harness-adapters/references/harness/claude.md index 8ff81adf3d0..6765b06bfb5 100644 --- a/.agents/skills/harness-adapters/references/harness/claude.md +++ b/.agents/skills/harness-adapters/references/harness/claude.md @@ -13,6 +13,7 @@ Busy hooks verified 2026-07-28 on Claude Code 2.1.220. | Model | `--model `; discover through the interactive `/model` picker, with alias or full-name shape documented by `claude --help`. | | Effort | `--effort `, verified on 2.1.196. | | Permissions | `--dangerously-skip-permissions` by default, or `--permission-mode auto` when `config/claude-permission-mode` is `auto`; the `auto` shape verified on 2.1.269. See [`Claude permission mode`](../../../../../docs/configuration.md#claude-permission-mode-configclaude-permission-mode) for the launch grant and configuration. | +| Config seat | `--claude-config-dir ` on `fm-spawn.sh` picks the `CLAUDE_CONFIG_DIR` this one claude spawn's pane resolves into, validated and recorded in the task's own meta and reused whenever that same task spawns again, so two claude lanes can sit on different accounts at once; `fm-spawn.sh --help` owns the exact contract. | ## Workspace trust @@ -29,6 +30,8 @@ When the project entry instead already carries an explicit decline (`hasClaudeMd Both flags `false` is Claude Code's default entry for a project never asked, not a decline, and is treated like an absent flag: trust registers and the import dialog still renders. The why-two-entries mechanism and the consent-gating logic live in the script's own header comment, which is the one owner for that contract; the fact worth repeating here is that `../../../bin/fm-spawn.sh` refuses the spawn when the trust flag fails to land, rather than launching a worker that would wedge on that dialog. +A seated spawn (`--claude-config-dir`, see "Config seat" above) passes that same directory as `CLAUDE_CONFIG_DIR` to this registration call, so trust always lands in the exact store the launched process itself reads - never firstmate's own ambient store. + Never try to answer either dialog with a key. Firstmate's key plane carries only Enter, Escape, and C-c with no arrow navigation, so it cannot move a dialog's selection at all, and both dialogs render with the cursor on their declining option, which means a sent Enter ends the session instead of accepting. A visible trust dialog means pre-registration did not take effect (or the project entry already carries an explicit decline) - inspect the store and the spawn's error output rather than sending keys. @@ -43,8 +46,19 @@ Never send Enter to that one either: it was observed rendering in the same shape Firstmate cannot move a selection with Enter, Escape, and C-c alone, so it cannot accept this dialog at all, and an operator accepts it once per machine instead. Inspect the pane to identify which dialog is on screen, and report it rather than answering it. A launch under `config/claude-permission-mode=auto` never meets the bypass confirmation, because it does not request bypass mode: on 2.1.269 `claude --permission-mode auto` reached the composer directly with the footer `⏵⏵ auto mode on (shift+tab to cycle)`, so a captain who refuses the bypass dialog selects `auto` there instead of accepting it. +That setting is fleet-wide rather than per seat (the Permissions row above names its owner), so choosing `auto` to avoid the dialog for one seat moves every claude lane in that home with it. The workspace-trust dialog is unaffected by the permission mode and still needs the pre-registration above. +### Preparing a config seat + +Preparing a seat for `--claude-config-dir` (see "Config seat" above) is two interactive steps, not one, and the operator does both by hand before the first seated spawn. +First, log the account in for that store: `CLAUDE_CONFIG_DIR= claude`. +Second, accept that store's own bypass-permissions confirmation, which the login alone never raises - only `--dangerously-skip-permissions` asks for it - so the one command that reaches both in a single sitting is `CLAUDE_CONFIG_DIR= claude --dangerously-skip-permissions`, accepting whatever it shows. +A seat that was only logged into is the trap: it holds a `.claude.json`, so `fm-spawn.sh`'s seat check accepts it, and the pane then wedges on the bypass dialog that firstmate cannot answer, which the supervisor reads as a stuck agent. +`../../../../../docs/verification/runtime-backends.md` records that exact counter-case: an isolated `CLAUDE_CONFIG_DIR` holding only a copied `.claude.json` cleared the trust dialog and then surfaced the bypass warning. +A captain who will not accept that dialog for a seat runs the whole home under `config/claude-permission-mode=auto` instead, with the fleet-wide consequence stated above; there is no per-seat way to decline it. +`fm-spawn.sh` cannot verify either step: no record of the bypass acceptance was found in `.claude.json` on 2.1.278 (key names only, top level and `projects.`, where `hasTrustDialogAccepted` and the import-consent flags do live), and whether Claude Code records it elsewhere was not checked, so the seat check confirms only that a store exists at that path. + ## Composer ghost Completed turns can render dim predicted text inside an empty composer, indistinguishable in plain `tmux capture-pane`. diff --git a/.agents/skills/harness-adapters/references/harness/cline.md b/.agents/skills/harness-adapters/references/harness/cline.md new file mode 100644 index 00000000000..67d15350030 --- /dev/null +++ b/.agents/skills/harness-adapters/references/harness/cline.md @@ -0,0 +1,55 @@ +# Cline CLI + +Cline's `cline` TUI, verified end to end on 2026-09-16 with cline 3.0.62 on Linux through the tmux and Herdr backends. +Verified as a CREWMATE and SCOUT adapter only; `../../../../../bin/fm-spawn.sh` refuses a secondmate launch on it because `../../../../../docs/supervision-protocols/` carries no cline wake protocol. +`../../../../../docs/verification/cline.md` owns how every fact below was established and what is still unproven. + +## Operating facts + +| Fact | Value | +|---|---| +| Binary | `cline` from `PATH`, refused if absent. The installed launcher is a Node wrapper that spawns the long-lived agent as a native binary whose live process name is exactly `.cline` (verified: `ps -o comm=` reports `.cline` with `argv[0]` `.cline`). | +| Launch | `cline -i -c --auto-approve true -m / --thinking `, launched BARE and given its brief only after the readiness gate below. `--model` takes the full `/` id (`cline-pass/deepseek-v4-flash`); cline derives the provider from the prefix, so no separate `-P` is passed. | +| Brief delivery | Launch-then-send, the kimi/rovo shape, because cline's one-time "Introducing Cline Desktop" first-run splash consumes the first submitted line; the gate dismisses the splash with Escape, waits for cline's `Auto-approve` status row, then types the absolute brief pointer once and confirms delivery from the recorded `busy cline-hook` state (never by re-driving Enter). | +| Busy state | Workspace hook config files under `.cline/hooks` (source `cline-hook` in `../../../../../bin/fm-busy-lib.sh`): `TaskStart` opens a turn; `TaskComplete`, `TaskCancel`, `TaskError`, and `SessionShutdown` all close it. `../../../../../bin/fm-spawn.sh` arms the busy generation, writes the files before launch, and excludes `.cline/` from git's view. | +| Rendered tail | The in-transcript busy row reads `⠸ Thinking... (esc to cancel)`; when the turn ends the same row is rewritten as `▶ Thinking:` with the token gone. The bottom status row (`⏵⏵ Auto-approve all enabled (Shift+Tab)`) does NOT change between busy and idle, so it is not a signal. | +| Turn end | The `TaskComplete` hook touches `state/.turn-ended` (the watcher NOTIFICATION) in addition to closing the busy record. | +| Exit | `/exit`, one Enter; the process exits. | +| Interrupt | Single `Escape`, which stops the running turn and leaves the composer at its `Ask anything...` placeholder with no prompt repollution, so no clear key follows. | +| Skill | No verified slash-skill form for injected instructions; use natural language. | +| Autonomy | `--auto-approve true` (cline's documented default, passed explicitly) auto-approves every tool call for the run. | +| Marker | None; a live TUI carries no cline-identity variable. Detected by ancestry alone. | +| Resume | `--id ` resumes an existing session and `cline history --json` lists session ids, but no verified pane-resume contract exists; use deterministic relaunch. | +| Model | `-m /`; a value without `/` is refused by cline with `invalid model format. Expected format: modelType/model`. `bin/fm-spawn.sh` passes the id through unchanged. | +| Effort | `--thinking none\|low\|medium\|high\|xhigh`; `max` stays in task metadata under the record-and-omit contract. | +| Composer | A bordered composer with the bare agent glyph `❯` and a muted-truecolor idle placeholder (`Ask anything...`, or the fresh-session `What can I do for you?`). Both placeholders are in `../../../../../bin/fm-composer-lib.sh`'s fleet-wide idle set, but cline renders them at ~135.5 perceived luminance, just above the shared ghost-luma ceiling of 128, so on the styled tmux/herdr captures an idle cline composer classifies `pending`, never `empty`. This is the same known gap `../../verification/rovo.md` documents; cline readiness/delivery therefore lead with the `Auto-approve` status row and the recorded busy hook, and cline steering relies on the shared queued-Enter busy conversion. | + +## Trust, dialogs, and the first-run splash + +Cline was not observed to gate a fresh worktree behind a folder-trust dialog, and no launch flag or trust store was needed; `--auto-approve true` covers tool approval. +The one first-run obstacle is the "Introducing Cline Desktop" splash, which renders only until it is dismissed once per profile and, if present, swallows the first submitted line. +The readiness gate (`cline_wait_for_ready` in `../../../../../bin/fm-spawn.sh`) detects the splash text and sends one Escape before polling for the idle composer, so a fresh-profile worker cannot lose its brief. + +## Credential precondition + +A verified cline worker ran under a signed-in ClinePass subscription with no key export. +Authorize with `cline auth -p cline-pass` (or the positional `cline auth cline-pass`) on a TTY; the credential lands in `~/.cline/data/settings/providers.json`. +The unauthenticated failure mode was not observed, so treat any auth prompt or refusal as a credential blocker under `../../../../../AGENTS.md` section 9, fix the environment, and retire the endpoint rather than typing into it. + +## Detection + +Detected by ancestry alone: `../../../../../bin/fm-harness.sh` matches the anchored process name `.cline` (and, as a backup for the node wrapper, the anchored script-path fragments `/bin/cline` and `@cline/cli`). +No environment marker is promoted: cline publishes none, and it does not clear an inherited `CLAUDECODE`, so `../../../../../bin/fm-spawn.sh` clears the foreign primary markers at the launch boundary for the same reason cursor and muse do. +cline is deliberately absent from the session-lock name vocabulary in `../../../../../bin/fm-session-lock-lib.sh`, where muse, gemini, rovo, and agy are also absent: a crewmate-only adapter must never own a home session lock. + +## Worker busy state and turn end + +`../../../../../bin/fm-spawn.sh` arms the busy generation with a seed record of `idle/fm-spawn` (the bare launch is not a submitted turn), then writes the five `.cline/hooks` files before launch. +`TaskStart` writes `busy cline-hook` when the brief pointer is submitted, so spawn delivery confirmation reads the same recorded state the supervisor later reads; the `(esc to cancel)` rendered token is only a fallback for a pane whose hook has not landed. +Teardown and relaunch retire the hook files through `fm_control_harness_wiring_paths` in `../../../../../bin/fm-control-lib.sh`. + +## Primary integration + +Unsupported and unverified. +`../../../../../docs/supervision-protocols/` carries no cline protocol, no turn-end guard adapter exists for it, and this adapter verified only the crewmate-side launch, busy state, interrupt, and exit. +`references/common/primary-hooks.md`'s unsupported-boundary rule applies: never invent a wake protocol from a similar TUI. diff --git a/.agents/skills/harness-adapters/references/harness/opencode.md b/.agents/skills/harness-adapters/references/harness/opencode.md index dd4c8b2ad27..0be6f85bcb2 100644 --- a/.agents/skills/harness-adapters/references/harness/opencode.md +++ b/.agents/skills/harness-adapters/references/harness/opencode.md @@ -1,6 +1,7 @@ # OpenCode Verified on 2026-06-11 across versions 1.15.7 through 1.17.6, with busy-queue behavior re-verified on 2026-07-20 using 1.18.4. +OpenCode 2.0.19 launch shape re-verified 2026-09-29 on this host (`fm-spawn.sh` + one live scout). ## Operating facts @@ -11,8 +12,8 @@ Verified on 2026-06-11 across versions 1.15.7 through 1.17.6, with busy-queue be | Interrupt | Double Escape; it is known to be flaky while a long shell command runs, so use `../../../bin/fm-control.sh relaunch` for a wedged pane. | | Skill invocation | No separate verified form beyond normal slash-command behavior; use natural language when the exact command is uncertain. | | Resume | Relaunch with `--continue` to resume the most recent session for the current directory, then send the next instruction after the TUI is ready because `--prompt` does not auto-submit alongside `--continue`. | -| Model flag | `--model `. | -| Effort flag | None for Firstmate's interactive `opencode --prompt` launch; `opencode run` has `--variant`, but that is not this path. The effort instead rides the launch's `OPENCODE_CONFIG_CONTENT` JSON as the `build` agent's `variant` keyed to the resolved model, the config schema's per-model reasoning-effort field verified on 1.18.32. It is emitted only when the resolved model's provider is known to expose that effort as a variant (`anthropic/*`: high, max; `openai/*`: low, medium, high, xhigh); with no model resolved, another provider, or an effort outside its family's list, the variant is omitted and the permission-only launch is unchanged. | +| Model flag | OpenCode 1.x: `--model ` on the interactive `opencode --prompt` launch. OpenCode 2.x: no top-level `--model`; the resolved model is written as a top-level `"model"` field in `OPENCODE_CONFIG_CONTENT`, and the launch adds `--standalone` so that JSON is honored off the shared background service (verified 2.0.19; `agent.build.model` is ignored on 2.0.19). | +| Effort flag | None for Firstmate's interactive `opencode --prompt` launch; `opencode run` has `--variant`, but that is not this path. On OpenCode 1.x the effort instead rides the launch's `OPENCODE_CONFIG_CONTENT` JSON as the `build` agent's `variant` keyed to the resolved model, the config schema's per-model reasoning-effort field verified on 1.18.32. It is emitted only when the resolved model's provider is known to expose that effort as a variant (`anthropic/*`: high, max; `openai/*`: low, medium, high, xhigh); with no model resolved, another provider, or an effort outside its family's list, the variant is omitted and the permission-only launch is unchanged. On OpenCode 2.x the effort is recorded in task metadata but omitted from the launch, because `agent.build.model` is ignored there and variant honor on the interactive TUI path is unproved. | | Model discovery | Run `opencode models [provider]` to list available provider/model identifiers. | | Trust dialog | None. | | Marker | None; OpenCode publishes no identity marker, so `../../../bin/fm-harness.sh` identifies it from process ancestry. | @@ -43,3 +44,15 @@ On native Windows, the operational-input adapter runs its Bash helper through `b The companion `.opencode/plugins/fm-primary-watch-arm.js` owns normal TUI watcher supervision, wakes it with `client.session.promptAsync`, and coordinates with the guard before a blind-turn follow-up. The PreToolUse-equivalent watcher-arm seatbelt blocks by throwing from `tool.execute.before`. + +## OpenCode 2.x launch verification (2026-09-29) + +Environment: `opencode v2.0.19`, `bin/fm-spawn.sh` on the task host. + +Before: `opencode --model '…' --prompt '…'` fails with `Unrecognized flag: --model in command opencode`. + +After: `OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"},"model":"openrouter/stealth/space-bunny-alpha"}' opencode --standalone --prompt '…'` via `fm-spawn.sh`; one supervised scout completed a trivial brief on that model. + +Permission block: the same JSON with `"permission":{"*":"allow"}` auto-approves tools under `--standalone`. + +Version gate: when `opencode --version` reports major 1, fm-spawn keeps the 1.x shape (`--model`, no `--standalone`). diff --git a/.agents/skills/harness-adapters/references/harness/openhands.md b/.agents/skills/harness-adapters/references/harness/openhands.md new file mode 100644 index 00000000000..aef6ec0424d --- /dev/null +++ b/.agents/skills/harness-adapters/references/harness/openhands.md @@ -0,0 +1,56 @@ +# OpenHands CLI + +OpenHands's `openhands` TUI, verified end to end on 2026-09-20 with OpenHands CLI 1.16.0 (SDK v1.21.0) on Linux through tmux. +Verified as a CREWMATE and SCOUT adapter only; `../../../../../bin/fm-spawn.sh` refuses a secondmate launch on it because `../../../../../docs/supervision-protocols/` carries no openhands wake protocol. +`../../../../../docs/verification/openhands.md` owns how every fact below was established and what is still unproven. + +## Operating facts + +| Fact | Value | +|---|---| +| Binary | Absolute `openhands` from `PATH`, refused if absent. The installed CLI is a Python entrypoint; `ps -o comm=` on Linux still reports the live process name `openhands` (verified, CLI 1.16.0). | +| Launch | Foreign markers cleared, a writable `HOME` and `OPENHANDS_PERSISTENCE_DIR` so the profile store is not the operator's possibly root-owned `~/.openhands`, `OPENHANDS_WORK_DIR` pinned to the worktree, `LLM_MODEL` and `LLM_API_KEY` supplied through a firstmate-owned env file, then `openhands --override-with-envs --always-approve --exit-without-confirmation -f `. The brief auto-submits; no extra Enter. | +| Busy state | No firstmate-owned hook writer, so nothing is armed and no record is seeded. `fm_busy_openhands_tail_busy` matches the pinned `ESC: pause` token in the working status line. | +| Rendered tail | A busy turn pins `Working (s • ESC: pause)` above the composer, with a braille spinner. Idle replaces that row with a blank status line. `Working` alone is not a signal (Pi already owns that word). | +| Turn end | No turn-end hook or notification touch exists; completion arrives through the worker status protocol. | +| Exit | `/exit` plus Enter, with `--exit-without-confirmation` so the "Terminate session?" modal never appears. A slash-command completion popup can swallow the first Enter; the control plane already retries. Ctrl+C also exits under that flag (verified live). | +| Interrupt | Single `Escape`, which prints "Pausing conversation" and leaves the idle composer showing only its placeholder, so no clear key follows. | +| Skill | No verified slash-skill form; use natural language. `/help` lists OpenHands's own commands. | +| Autonomy | `--always-approve` (`--yolo`) auto-approves tool calls for the run. | +| Marker | None. A live TUI publishes no `OPENHANDS_*` identity variable; `OPENHANDS_PERSISTENCE_DIR` is a config path, not an identity. Inherited `GROK_AGENT=1` was observed on a live process and is cleared at launch. | +| Resume | `--resume` and `--last` exist but carry no verified pane-resume contract; use deterministic relaunch. | +| Model | No `--model` flag. The LiteLLM id is exported as `LLM_MODEL` with `--override-with-envs` (for example `fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash`). | +| Effort | No verified interactive effort flag; the requested axis stays in task metadata under the record-and-omit contract. | +| Composer | Bordered input whose idle placeholder is `Type your message, @mention a file, or / for commands`. | + +## Credential precondition + +A verified openhands worker ran with `LLM_API_KEY` and `LLM_MODEL` supplied through `--override-with-envs`. +`bin/fm-spawn.sh` takes `LLM_API_KEY` from the environment, or from `$FM_HOME/config/openhands-llm.env` when that file has an `LLM_API_KEY=` line, and refuses the spawn when neither source has a key. +`--override-with-envs` also requires `LLM_MODEL`; a spawn without `--model` and without `LLM_MODEL` in the environment is refused. +The unauthenticated TUI wizard was not used as a handled dialog: missing credentials are a fail-loud blocker. + +## Writable HOME + +`Path.home() / ".openhands" / "profiles"` is hardcoded in the SDK profile store and ignores `OPENHANDS_PERSISTENCE_DIR`. +A root-owned `~/.openhands` therefore crashes every launch with `PermissionError` even when persistence is redirected. +The spawn always uses a firstmate-owned per-task `HOME` under `state/.openhands-home`, with identity symlinks (`.ssh`, `.gitconfig`, `.config`, `.local`, `.git-credentials`) back to the operator home so git and `gh` keep working, and sets `OPENHANDS_PERSISTENCE_DIR` and `OPENHANDS_WORK_DIR` beside it. + +## Detection + +Detected by ancestry alone: `../../../../../bin/fm-harness.sh` matches the anchored process name `openhands`, never `*openhands*`. +A Python-interpreter fallback matches a script path whose last component is exactly `openhands`. +No environment marker is promoted. +openhands is deliberately absent from the session-lock name vocabulary in `../../../../../bin/fm-session-lock-lib.sh`, where muse, gemini, rovo, and agy are also absent: a crewmate-only adapter must never own a home session lock. + +## Worker busy state and turn end + +`../../../../../bin/fm-spawn.sh` arms no busy generation for openhands and writes no sidecar, exactly because no writer could ever clear a seeded record. +`fm_busy_openhands_tail_busy` matches the pinned `ESC: pause` token, hardcoded with no environment override, and `fm_busy_classify` reports `unknown openhands-regex` rather than idle when it is absent, because a long turn can scroll the marker out of the captured tail. +Teardown removes the per-task env file, persistence directory, and throwaway HOME. + +## Primary integration + +Unsupported and unverified. +`../../../../../docs/supervision-protocols/` carries no openhands protocol, no turn-end guard adapter exists for it, and this adapter verified only the crewmate-side launch, busy state, interrupt, and exit. +`references/common/primary-hooks.md`'s unsupported-boundary rule applies: never invent a wake protocol from a similar TUI. diff --git a/.agents/skills/human-text-discipline/SKILL.md b/.agents/skills/human-text-discipline/SKILL.md new file mode 100644 index 00000000000..10b14487e35 --- /dev/null +++ b/.agents/skills/human-text-discipline/SKILL.md @@ -0,0 +1,115 @@ +--- +name: human-text-discipline +description: >- + Agent-only writing discipline for text a human reads for its own sake. + Use before writing or editing a pull request body, a commit message, or a captain-facing chat message. + Owns the checkable list of AI tells and their fixes, plus the positive target that keeps corrected prose from reading as sterile. +user-invocable: false +metadata: + internal: true +--- + +# human-text-discipline + +Load this before writing or editing text a person reads directly: a pull request body, a commit message, or a captain-facing message. +Its companion is `agent-doc-discipline`, which disciplines documents an agent consumes to act; that skill's test is that a fresh session can act on the document, while this skill's test is that a person reads the text as written by a person for them. +It is not a second owner of `AGENTS.md` section 9. +Section 9 owns what a captain-facing message must contain and how internal terms are translated. +This skill owns how the prose reads once that content is decided. +Where they meet, section 9 decides substance and this skill decides wording, and neither restates the other. + +Not for documents an agent reads: those follow `agent-doc-discipline`. +Not for maintained project prose: those follow the audience owner in `docs/documentation-audiences.md`. + +## The other failure: sterile prose + +Removing tells is half the job. +Flat, voiceless text is as obviously machine-made as purple text, so the rewrite must also add a human signal. +Four moves carry most of it: + +- Hold an opinion instead of neutrally listing both sides. +- Vary sentence length: a short sentence, then a longer one that takes its time. +- Admit complexity, because "impressive and a little unsettling" beats "impressive". +- Be specific, because a name, a number, or a concrete detail is the strongest human signal there is. + +## The tells + +Each entry names the observable pattern and the fix. +Scan for the pattern, apply the fix, then confirm the fix did not flatten the sentence. + +### Content + +1. Puffery: "pivotal moment", "testament to", "evolving landscape", "setting the stage", "indelible mark". + Delete it and state what actually happened. +2. Promotional adjectives used as praise: "vibrant", "groundbreaking", "renowned", "seamless", "robust", "cutting-edge". + Replace with a neutral description or a number. +3. Superficial "-ing" tails: a comma followed by "highlighting", "ensuring", "showcasing", "reflecting", or "fostering" with no fact after it. + Cut the tail, or expand it into the concrete fact it gestures at. +4. Vague attribution: "experts believe", "industry reports suggest", "it is widely regarded". + Name the source or delete the claim. +5. Formulaic framing: "Despite the challenges, X continues to thrive". + Replace it with the specific facts behind the framing. +6. Generic conclusion: "The future looks bright", "This is a big step forward". + State the specific next step or result, or delete the sentence. + +### Diction + +7. Fancy ways to say "is": "serves as", "stands as", "boasts", "features", "represents". + Use "is" or "has". +8. "Not just X but Y", and its cousin "It is not merely X, it is Y". + State the point directly instead of staging it as a contrast. +9. AI vocabulary: additionally, crucial, delve, enhance, foster, garner, interplay, intricate, landscape (abstract), pivotal, showcase, tapestry, testament, underscore, vibrant. + Replace with the plain word a person would say. +10. Abstract metaphor nouns: substrate, wedge, vector, locus, nexus, primitive, harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding, flywheel, north star. + Use the concrete word the metaphor stands for. +11. A feeling instead of a fact: "the database stays close at hand". + Name the mechanism or the number the reader can act on. +12. A weak verb propped up by an adverb: "significantly improves", "runs quickly". + Use a stronger verb or the measured number. +13. Hedging: "could potentially possibly", "it might be argued that". + Reduce it to the single honest word, such as "may". +14. Filler: "in order to", "due to the fact that", "it is important to note that". + Use "to", "because", or delete it. +15. Synonym cycling: protagonist, main character, central figure, hero all in one paragraph. + Pick one word and repeat it. +16. The plain word: "utilize" becomes "use", "leverage" becomes "use", "facilitate" becomes "help", "numerous" becomes "many", "in the event that" becomes "if". + The fancier synonym is rarely clearer. + +### Structure + +17. Forced rule of three: ideas padded or trimmed to arrive in threes. + Use the natural number, even when that is two or four. +18. False ranges: "from X to Y" where X and Y are not on a meaningful scale. + List the items directly. +19. Dense sentences the reader must backtrack to parse. + Split into two sentences or drop a clause, one idea per sentence. +20. Passive voice that hides a known actor: "queries are validated". + Name the actor: "the compiler validates queries". + Passive stays only when the actor is unknown or genuinely irrelevant. + +### Mechanics + +21. An em dash as punctuation. + End the sentence or use a comma, never a dash, an en dash, or parentheses as a substitute. +22. A colon as a mid-sentence connector: "If you are coming from X: instead, do Y". + Rewrite so the point stands on its own without the comparison framing. +23. Boldface on every proper noun or acronym. + Bold only what a scanner must find. +24. Inline-header list items whose bold label restates the line: "**Performance:** Performance improved". + Convert them to prose; a bold lead-in that names the item and is followed by genuinely new detail is fine, not a tell. +25. Title case headings. + Use sentence case. +26. Decorative emojis in headings or bullets. + Remove them. +27. Curly quotes. + Use straight quotes. +28. Chatbot phrases: "Of course!", "I hope this helps!", "Let me know if you need anything else". + Remove them. +29. Sycophantic openers: "Great question!", "You are absolutely right!". + Respond directly to the substance. + +## Before you send + +Ask one audit question: what still makes this obviously machine-written? +Walk the tells above once more against the answer. +Then confirm the positive side survived the edit: at least one opinion, at least one sentence that breaks the rhythm, and at least one specific name or number where generality was possible. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index c7c81d59628..d13b94357f4 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -221,6 +221,7 @@ bin/fm-spawn.sh --secondmate Use the recorded `home=` in meta. If meta is missing but `data/secondmates.md` still registers the secondmate, respawn from the registry entry and its persistent home. +The locked session-start liveness sweep performs exactly this recovery for every secondmate registered in `data/secondmates.md`, including one with no `state/.meta` record or a record with no endpoint; when it cannot relaunch one, it names that secondmate as an explicit `SECONDMATE_LIVENESS:` gap line rather than passing over it. For a remote route, the same command probes and relaunches only on the configured host. An SSH transport failure or unreadable remote endpoint remains unknown and must be reconciled on that host; never launch a local replacement. `stuck-crewmate-recovery`'s remote-secondmate note owns why the endpoint-dead and send-failed verdicts that seem to justify this are themselves unreliable. diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index ffef22777f0..459c9ff8196 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -67,6 +67,13 @@ Never restart, stop, or update the shared daemon on a crewmate's claim. It is one instance serving every lane and home, so a restart kills other lanes' in-flight runs. Only positive socket refusal or absence is a daemon-down finding; escalate that finding, or a failed run record that names a daemon error, to the captain. +## A worker parked on a provider quota wall + +`bin/fm-crew-state.sh` reports `state: quota` when a live harness is stalled on a provider usage-limit retry modal instead of advancing: the process is alive and painting, but the submitted turn cannot run. +Treat it as neither a wedge nor a declared external wait. +The retry modal leaves the composer unreadable, so an in-place `fm-control.sh relaunch` cannot fix it and a fresh worker on the same provider would hit the same wall. +Preserve the worktree and its unlanded work, and bring the work back under a new task id chosen for a provider with headroom rather than relaunching in place; a provider limit is not something the fleet can clear, so escalate it to the captain when it blocks delivery. + ## Live-endpoint escalation Escalate in order: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfc7127da47..bd3113e69a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -446,8 +446,8 @@ jobs: snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) printf '%s\n' "$snapshot_output" snapshot_count=$(printf '%s\n' "$snapshot_output" | grep -c '^ok - ') - [ "$snapshot_count" -eq 18 ] || { - echo "::error::expected 18 snapshot/fleet-view tests, got $snapshot_count" + [ "$snapshot_count" -eq 19 ] || { + echo "::error::expected 19 snapshot/fleet-view tests, got $snapshot_count" exit 1 } diff --git a/.gitignore b/.gitignore index 3eece43c35f..61b2f2ac3f1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ state/ data/ scratchpad* .no-mistakes/ +.omc/ .lavish/ .fm-secondmate-home .fm-secondmate-parent diff --git a/.pi/extensions/lib/fm-calm-visibility.ts b/.pi/extensions/lib/fm-calm-visibility.ts index bbd50efea0d..d3c73bfda19 100644 --- a/.pi/extensions/lib/fm-calm-visibility.ts +++ b/.pi/extensions/lib/fm-calm-visibility.ts @@ -83,7 +83,12 @@ export function calmPresentationIsActive(): boolean { } export function calmPresentationHides(itemClass: CalmTranscriptClass): boolean { - return calm && !stockExportRendering && !calmTranscriptClassIsVisible(itemClass); + if (!calm || calmTranscriptClassIsVisible(itemClass)) return false; + // /export briefly forces stock tool rendering for data fidelity, but Firstmate + // operational rows must stay out of the conversation surface (messages pane). + // They remain in session/tree data; only the transcript presentation is hidden. + if (itemClass === "synthetic-user" || itemClass === "synthetic-assistant") return true; + return !stockExportRendering; } export function registerFirstmateSyntheticPresentation(pi: ExtensionAPI): void { diff --git a/AGENTS.md b/AGENTS.md index 507a7f51498..673c86aba41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ Hard rules, in priority order: You may maintain this repo's private operational state directly. Shared tracked material is `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.toml`, `.github/workflows/`, `bin/`, `.agents/skills/`, and public `skills/`. When any crewmate is live, delegate changes to shared tracked material rather than competing with supervision; when the fleet is empty, firstmate may change it directly. -This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` are captain-private and gitignored. +This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, `.no-mistakes/`, and `.omc/` are captain-private and gitignored. Ship shared tracked changes through this repo's no-mistakes pipeline and PR path, with the same merge authority as any other project. Never add an agent name as a commit co-author. Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and compatible `lavish-axi` for visual decisions or reports; consult current help rather than memorizing flags. @@ -87,7 +87,7 @@ Load `session-start-recovery` when the digest reports unfinished checks, actiona ## 4. Harness and runtime dispatch - Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. -- The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, `cursor`, and `omp`, plus `muse`, `gemini`, `rovo`, `agy`, and `devin` for crewmates and scouts only; never dispatch on an unverified adapter. +- The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, `cursor`, and `omp`, plus `muse`, `gemini`, `rovo`, `agy`, `devin`, `cline`, and `openhands` for crewmates and scouts only; never dispatch on an unverified adapter. - If static `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. - Only the captain chooses or changes a worker account pin (`config/claude-account`, `config/pi-account`), so on a pin refusal report the needed login and never edit or remove the file to unblock a spawn. @@ -105,6 +105,7 @@ Break genuine evidence ties without array-order or harness bias. `quota-axi` owns how model or product windows relate to bounding account windows and remains data-only. Load `quota-array-dispatch` before choosing among a matched profile array; that skill is the single owner of the TOON-first spendPriority selection procedure. Run `bin/fm-dispatch-resolve.sh` directly on the written brief in the same turn, with no preflight, and on `clear` pass its `profile:` line to `fm-spawn` unless you state a reason to override; `ambiguous`, `escalate`, `error`, and off all mean the intake above, unchanged (contract: `docs/configuration.md` "Typed dispatch resolution"). +Read current per-provider lane load with `bin/fm-provider-load.sh` before choosing a candidate, and treat a provider at its cap as a re-assignment trigger: `bin/fm-spawn.sh` refuses a dispatch that would exceed a provider's configured cap, and the model string decides which provider a candidate bills against. The generic effort fallback and its precedence are owned by `harness-adapters`: explicit captain and standing configured effort win; otherwise use low for well-understood explicit work, xhigh for ambiguous investigation or design, intermediate levels proportionally, and never max without explicit captain preference. Do not add model-specific versions of that policy. @@ -205,6 +206,7 @@ The spawn must resolve a genuine isolated task worktree distinct from the primar When the configured tasks-axi backlog gate applies, the spawn itself moves the work item to In flight and refuses rather than dispatching work this home has no item for, so recording the dispatch is never a separate step to remember; a manual-backend home retains the hand-editing contract in `docs/configuration.md`. After spawning, confirm the worker is processing the brief and handle any trust dialog through `harness-adapters`. A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item. +Before dispatching a lane against a shared external target - an existing PR or issue, or a declared file area - claim it with `bin/fm-spawn.sh --claim ` so a second home on this machine refuses rather than racing it; the claim record, root, canonical keys, and staleness rule are owned by `docs/configuration.md` "Cross-home work claims". Steer a worker with ordinary text through fail-closed `fm-send`: the message becomes a durable record in the task's steering inbox (multi-line text is legal, local and remote alike) and the worker's terminal receives only a constant doorbell line, with the watcher re-ringing an unacknowledged local message and escalating a stuck one (`bin/fm-task-inbox-lib.sh`; `bin/fm-send.sh` owns the typed-plane carve-outs). A remote secondmate steer rides the same durable-inbox model through the remote transport; after an unconfirmed delivery, only the exact `FM_PENDING_REPLY_EXISTING_CORR=` resend command printed by `fm-send` is safe because it preserves the request body for remote enqueue deduplication (`bin/fm-send.sh` header). @@ -356,6 +358,8 @@ Reach the captain immediately for: - Whenever a PR is mentioned, and for any review or merge ask, include the PR's full `https://...` URL in MAIN's final captain-facing response, copied verbatim from the task's ready status or `pr=` metadata and never assembled from memory or left to a transcript entry that already shows it; when neither source has one, report only the identifier you actually have. - Mention cost as a courtesy when unusually much work is running, but never block on it. +Load `human-text-discipline` before writing a PR body, a commit message, or any captain-facing message; it owns the checkable AI-tell list for text a person reads, and this section stays the owner of what those messages must contain. + ## 10. Backlog contract The configured `tasks-axi` backend is the durable queue; the tracked default is `data/backlog.md`. @@ -382,6 +386,7 @@ Preserve durable structured identifiers, dependencies, and completion artifact l Use its scaffold as the contract, then fill `## Captain's intent` (`{TASK}`) with the captain's own ask and any boundary the captain stated, plus the context needed to read it, including the substance of any report, decision, or PR the ask refers to; never widen the ask there into a general goal or an enumerated coverage list, because the reviewer treats that subsection as acceptance criteria. Fill `## Firstmate spec` (`{FIRSTMATE_SPEC}`) with only the build instructions that ask requires, naming what stays out of scope when the ask is narrow; a generalization, consistency sweep, or extra hardening the captain did not ask for is follow-up work to note, not scope to add. `bin/fm-dod-lib.sh` owns intent authoring without added speaker labels or direct address, its provenance markers, what a no-mistakes worker may pass as `--intent`, and the string's self-sufficiency rule. +`bin/fm-dod-lib.sh` also owns the definition of done's before/after evidence-pair requirement for any change with an observable surface. Keep additions task-specific rather than repeating lifecycle instructions, and alter generated sections only when the task genuinely differs from the standard shape. Every ship brief must retain the worktree-isolation assertion and stop if launched in the primary checkout. diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index cf908fa11e8..27137eaabb8 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -93,6 +93,12 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" # shellcheck source=bin/fm-agent-process-lib.sh . "$FM_BACKEND_HERDR_ROOT/bin/fm-agent-process-lib.sh" +# The repo-wide bounded runner (bin/fm-timeout-lib.sh), the single owner of how +# this repo bounds a subprocess. Every synchronous Herdr CLI call below runs +# through it; see fm_backend_herdr_bounded. +# shellcheck source=bin/fm-timeout-lib.sh +. "$FM_BACKEND_HERDR_ROOT/bin/fm-timeout-lib.sh" + FM_BACKEND_HERDR_MIN_PROTOCOL=14 # events.subscribe (the native pane.agent_status_changed push stream) and its # subscription_event schema first shipped at protocol 16 (verified: herdr @@ -369,6 +375,23 @@ fm_backend_herdr_workspace_label() { printf 'firstmate' } +# FM_BACKEND_HERDR_CLI_TIMEOUT: the hard per-call bound in whole seconds on +# every synchronous Herdr CLI read or write. Without it a wedged server or a +# hung pane read blocks the supervisor that made the call forever and leaks the +# shell it ran in, one per probe. The long-lived `server` launch is exempt: its +# whole purpose is to outlive the call. Invalid or zero values fall back to 10. +FM_BACKEND_HERDR_CLI_TIMEOUT=${FM_BACKEND_HERDR_CLI_TIMEOUT:-10} + +# fm_backend_herdr_bounded: run under the adapter's hard bound via +# the repo-wide bounded runner (bin/fm-timeout-lib.sh). Returns the command's +# own status, or 124 when the bound fires, and kills the whole child process +# group so a hung herdr and anything it spawned cannot outlive the call. +fm_backend_herdr_bounded() { # + local bound=$FM_BACKEND_HERDR_CLI_TIMEOUT + case "$bound" in ''|*[!0-9]*|0) bound=10 ;; esac + fm_run_timed "$bound" "$@" +} + # fm_backend_herdr_cli: run `herdr ` scoped to , setting # BOTH the HERDR_SESSION env var AND appending a trailing `--session ` # CLI flag. Verified empirically (docs/herdr-backend.md "Session targeting: the @@ -393,21 +416,24 @@ fm_backend_herdr_cli() { # # stderr is buffered (stdout streams untouched) so a protocol_mismatch # refusal can be recognized and retried once on a compatible client; see # "client selection" below. A failed command's stderr is replayed verbatim. - # The long-lived `server` launch is exec'd straight through: buffering its - # stderr would hold this call open for the server's whole lifetime. + # Every call below runs under fm_backend_herdr_bounded (the + # FM_BACKEND_HERDR_CLI_TIMEOUT contract) so a hung server cannot wedge the + # caller. The long-lived `server` launch is the one exemption: it is exec'd + # straight through, because buffering its stderr would hold this call open + # for the server's whole lifetime and a bound would kill the server. if [ "${1:-}" = server ]; then HERDR_SESSION="$session" "$client_bin" "$@" --session "$session" return $? fi failed_bin=$client_bin - { err=$(HERDR_SESSION="$session" "$failed_bin" "$@" --session "$session" 2>&1 1>&3 3>&-) || rc=$?; } 3>&1 + { err=$(fm_backend_herdr_bounded env HERDR_SESSION="$session" "$failed_bin" "$@" --session "$session" 2>&1 1>&3 3>&-) || rc=$?; } 3>&1 if [ "$rc" -ne 0 ]; then case "$err" in *protocol_mismatch*) fm_backend_herdr_client_select "$session" force selected_bin=$(fm_backend_herdr_bin) if [ "$selected_bin" != "$failed_bin" ]; then - HERDR_SESSION="$session" "$selected_bin" "$@" --session "$session" + fm_backend_herdr_bounded env HERDR_SESSION="$session" "$selected_bin" "$@" --session "$session" return $? fi ;; @@ -468,7 +494,7 @@ fm_backend_herdr_client_candidates() { # client did not report. Never fails. fm_backend_herdr_client_status() { # local bin=$1 session=$2 out - out=$(HERDR_SESSION="$session" "$bin" status --json --session "$session" 2>/dev/null) || out= + out=$(fm_backend_herdr_bounded env HERDR_SESSION="$session" "$bin" status --json --session "$session" 2>/dev/null) || out= printf '%s' "$out" | jq -r ' [ (if (.server | type) == "object" and .server.running != null then (.server.running | tostring) else "" end), (if (.server | type) == "object" and (.server | has("compatible")) @@ -520,7 +546,7 @@ fm_backend_herdr_tool_check() { fm_backend_herdr_version_check() { fm_backend_herdr_tool_check || return 1 local status protocol version - status=$(herdr status --json 2>/dev/null) || { echo "error: 'herdr status --json' failed; is herdr installed correctly?" >&2; return 1; } + status=$(fm_backend_herdr_bounded herdr status --json 2>/dev/null) || { echo "error: 'herdr status --json' failed; is herdr installed correctly?" >&2; return 1; } protocol=$(printf '%s' "$status" | jq -r '.client.protocol // empty' 2>/dev/null) version=$(printf '%s' "$status" | jq -r '.client.version // empty' 2>/dev/null) case "$protocol" in @@ -2065,6 +2091,41 @@ fm_backend_herdr_workspace_presence_state() { # esac } +# fm_backend_herdr_projection_workspace_remove_focus_preserving: confirm one +# disposable projected workspace is gone, closing its remaining panes through +# the existing focus-preserving pane close when it is not. The recorded task +# pane's own close removes the emptied workspace, but the recorded pane can +# already be gone (a restored husk, a server restart) while the workspace's +# saved layout survives; that close then fails on a nonexistent pane and the +# workspace is left for the next server restart to resurrect as a live agent in +# the wrong directory. This closes whatever panes the workspace still holds and +# then requires structured absence. A pane holding a live or unknown agent +# refuses rather than closing it, and `workspace close` is never called (Herdr +# 0.7.5 steals focus on an emptying close). Returns 0 only when the workspace is +# confirmed gone. +fm_backend_herdr_projection_workspace_remove_focus_preserving() { # + local session=$1 workspace=$2 presence panes pane state + [ -n "$session" ] && [ -n "$workspace" ] || return 1 + presence=$(fm_backend_herdr_workspace_presence_state "$session" "$workspace") + [ "$presence" = dead ] && return 0 + [ "$presence" = present ] || return 1 + panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$workspace" 2>/dev/null) || return 1 + panes=$(printf '%s' "$panes" | jq -r '.result.panes[]?.pane_id // empty' 2>/dev/null) || return 1 + while IFS= read -r pane; do + [ -n "$pane" ] || continue + state=$(fm_backend_herdr_pane_agent_state "$session" "$pane") + case "$state" in + dead|no-agent) ;; + *) return 1 ;; + esac + fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$pane" "$state" || return 1 + done < @@ -3762,7 +3823,7 @@ fm_backend_herdr_pane_for_tab() { # # normally carry meta), best-effort. fm_backend_herdr_resolve_bare_selector() { # local name=$1 sessions session tabs tab_id wsid pane_id - sessions=$(herdr session list --json 2>/dev/null | jq -r '.sessions[]? | select(.running == true) | .name' 2>/dev/null) + sessions=$(fm_backend_herdr_bounded herdr session list --json 2>/dev/null | jq -r '.sessions[]? | select(.running == true) | .name' 2>/dev/null) while IFS= read -r session; do [ -n "$session" ] || continue tabs=$(fm_backend_herdr_cli "$session" tab list 2>/dev/null) || continue @@ -3826,7 +3887,7 @@ fm_backend_herdr_list_live() { # # ~/.config/herdr/sessions//herdr.sock). Empty on any failure. fm_backend_herdr_socket_path() { # local session=$1 - herdr session list --json 2>/dev/null \ + fm_backend_herdr_bounded herdr session list --json 2>/dev/null \ | jq -r --arg name "$session" '.sessions[]? | select(.name == $name) | .socket_path // empty' 2>/dev/null \ | head -1 } @@ -3850,10 +3911,10 @@ fm_backend_herdr_events_capable() { # if [ -z "${FM_BACKEND_HERDR_EVENT_READER:-}" ]; then command -v python3 >/dev/null 2>&1 || return 1 fi - protocol=$(herdr status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) + protocol=$(fm_backend_herdr_bounded herdr status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) case "$protocol" in ''|*[!0-9]*) return 1 ;; esac [ "$protocol" -ge "$FM_BACKEND_HERDR_MIN_EVENTS_PROTOCOL" ] || return 1 - schema=$(herdr api schema --json 2>/dev/null) || return 1 + schema=$(fm_backend_herdr_bounded herdr api schema --json 2>/dev/null) || return 1 printf '%s' "$schema" | grep -Fq 'events.subscribe' || return 1 printf '%s' "$schema" | grep -Fq 'pane.agent_status_changed' || return 1 return 0 diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index 99e6bc86ac7..912255a5e94 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -193,7 +193,7 @@ scan_open_blockers() { # -> tab-separated blocker rows STATUS_SCAN_ERROR=$status return 1 fi - while IFS="$(printf '\t')" read -r key verb summary; do + while IFS=$'\t' read -r key verb summary; do [ "$verb" = blocked ] || continue clean_summary=$(printf '%s' "$summary" | clean_field) printf 'blocker\t%s\t%s\t%s\n' "$id" "$key" "$clean_summary" @@ -241,7 +241,7 @@ write_gate() { # print_evidence() { # local file=$1 kind text - while IFS="$(printf '\t')" read -r tag kind text; do + while IFS=$'\t' read -r tag kind text; do [ "$tag" = evidence ] || continue printf 'catch-up %s: %s\n' "$kind" "$text" done < "$file" @@ -249,7 +249,7 @@ print_evidence() { # print_blockers() { # local file=$1 tag id key summary - while IFS="$(printf '\t')" read -r tag id key summary; do + while IFS=$'\t' read -r tag id key summary; do [ "$tag" = blocker ] || continue printf 'firstmate-actionable blocker: %s [key=%s] %s\n' "$id" "$key" "$summary" done < "$file" @@ -267,7 +267,7 @@ clear_delivery_artifacts() { # gate was retained for open blockers alone. gate_retention_reasons() { # local file=$1 tag kind text - while IFS="$(printf '\t')" read -r tag kind text; do + while IFS=$'\t' read -r tag kind text; do [ "$tag" = evidence ] && [ "$kind" = lifecycle ] || continue printf '%s\n' "$text" done < "$file" @@ -604,7 +604,7 @@ render_return_brief() { # [argv0] -> agent|shell|other # way (verified, devin 3000.11.1: comm=devin), so a `*devin*` glob never # claims an unrelated command. agy|devin) printf 'agent' ;; + # cline (Cline CLI) launches its agent as a native binary whose live process + # name is exactly `.cline` (verified, cline 3.0.62). Anchored, never + # *cline*, so an unrelated command cannot be misread as this harness. + .cline|cline) printf 'agent' ;; + # openhands is anchored for the same reason: its live process name is the + # bare word `openhands` (verified, CLI 1.16.0), and a glob would claim a + # path or argument containing `.openhands`. + openhands) printf 'agent' ;; zsh|bash|sh|dash|ash|ksh|mksh|tcsh|csh|fish) printf 'shell' ;; *) if fm_harness_path_name "$path" >/dev/null || fm_harness_path_name "$argv0" >/dev/null; then diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index f4fdde29436..2c01ca88884 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -362,14 +362,38 @@ fm_backend_target_of_meta() { # [ -n "$window" ] && printf '%s' "$window" } +# fm_backend_meta_endpoint_cleared_value: the single explicit +# `endpoint_cleared=` stamp a record may carry once its endpoint is +# already gone (a workspace or pane closed by an earlier cleanup, or an agent +# that died to a provider cap). Absent, empty, duplicated, or malformed returns +# 1 so an ambiguous stamp is never mistaken for a confirmed cleared endpoint. +fm_backend_meta_endpoint_cleared_value() { # + local meta=$1 count value + count=$(grep -c '^endpoint_cleared=' "$meta" 2>/dev/null || true) + [ "$count" -eq 1 ] || return 1 + value=$(grep '^endpoint_cleared=' "$meta" | cut -d= -f2-) + [ -n "$value" ] || return 1 + case "$value" in *$'\n'*|*$'\r'*|*$'\t'*) return 1 ;; esac + printf '%s' "$value" +} + # fm_backend_validate_task_endpoint: validate a task cleanup record entirely # from its durable metadata before any runtime command or cleanup mutation. # The validation binds the exact task id, selected backend, target, project, # and worktree. New non-tmux records carry endpoint_task_id because their # opaque runtime ids do not encode the task label. Legacy tmux records remain # valid only when their window name itself is exactly fm-. +# With --allow-cleared, a record whose window is absent but which carries one +# explicit endpoint_cleared stamp is accepted as an agent-less cleared endpoint +# instead of refused: there is no live endpoint left to validate structurally, +# and the stamp is the stronger agent-less evidence (a window may be dead while +# an agent is gone; a cleared stamp records a close already performed). The +# cleared contract sets FM_BACKEND_VALIDATED_ENDPOINT_CLEARED to the reason and +# leaves FM_BACKEND_VALIDATED_TARGET empty; every caller that needs to operate +# on a live endpoint must therefore stay strict and must not pass the flag. # On success, sets FM_BACKEND_VALIDATED_BACKEND and -# FM_BACKEND_VALIDATED_TARGET. On failure, prints one refusal and returns 1. +# FM_BACKEND_VALIDATED_TARGET (and FM_BACKEND_VALIDATED_ENDPOINT_CLEARED when +# cleared). On failure, prints one refusal and returns 1. fm_backend_meta_exact_value() { # local meta=$1 key=$2 count value count=$(grep -c "^$key=" "$meta" 2>/dev/null || true) @@ -404,11 +428,12 @@ fm_backend_orca_worktree_id_valid() { # esac } -fm_backend_validate_task_endpoint() { # - local meta=$1 id=$2 backend_count backend window worktree project binding_count binding +fm_backend_validate_task_endpoint() { # [--allow-cleared] + local meta=$1 id=$2 allow_cleared=${3:-} backend_count backend window cleared worktree project binding_count binding local session pane recorded_session workspace tab terminal worktree_id surface FM_BACKEND_VALIDATED_BACKEND= FM_BACKEND_VALIDATED_TARGET= + FM_BACKEND_VALIDATED_ENDPOINT_CLEARED= [ -f "$meta" ] && [ ! -L "$meta" ] || { echo "REFUSED: task $id has no regular endpoint metadata at $meta; preserving task state." >&2 return 1 @@ -417,10 +442,15 @@ fm_backend_validate_task_endpoint() { # echo "REFUSED: task endpoint identity has an invalid task id; preserving task state." >&2 return 1 esac - window=$(fm_backend_meta_exact_value "$meta" window) || { + window=$(fm_backend_meta_exact_value "$meta" window) || window= + cleared= + if [ -z "$window" ] && [ "$allow_cleared" = --allow-cleared ]; then + cleared=$(fm_backend_meta_endpoint_cleared_value "$meta") || cleared= + fi + if [ -z "$window" ] && [ -z "$cleared" ]; then echo "REFUSED: task $id has a missing, empty, or ambiguous window endpoint; preserving task state." >&2 return 1 - } + fi worktree=$(fm_backend_meta_exact_value "$meta" worktree) || { echo "REFUSED: task $id has a missing, empty, or ambiguous worktree identity; preserving task state." >&2 return 1 @@ -462,6 +492,14 @@ fm_backend_validate_task_endpoint() { # return 1 fi + if [ -n "$cleared" ]; then + # shellcheck disable=SC2034 # Output globals are consumed by sourcing callers. + FM_BACKEND_VALIDATED_ENDPOINT_CLEARED=$cleared + FM_BACKEND_VALIDATED_BACKEND=$backend + FM_BACKEND_VALIDATED_TARGET= + return 0 + fi + case "$backend" in tmux) session=${window%%:*} diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 77dfde889d5..26c7a7f22d4 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -20,7 +20,7 @@ # "SECONDMATE_SYNC: secondmate : skipped: ", # "NUDGE_SECONDMATES: secondmate : send failed: ", # "BOOTSTRAP_INFO: nudged fm- with ''", -# "SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed after : ", +# "SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed after : |gap: ", # "SECONDMATE_HANDOFF: secondmate : pending delivery: item(s)", # "FMX: X mode on ..." or "FMX: X mode off ...". # When a RUNNING secondmate home is fast-forwarded, its target is @@ -48,6 +48,11 @@ # fm_backend_agent_state: skipped distinguishes an existing ambiguous # process, an unreadable target, and an unverified backend; respawn # failed names whether the endpoint was missing or agent-less. +# The sweep accounts for every secondmate registered in +# data/secondmates.md, not only those with a state/.meta record: a +# registered secondmate with no record, or a record with no endpoint, +# is relaunched from the registry, and one that cannot be recovered is +# named with an explicit `gap:` line rather than omitted. # Already-live and successfully relaunched secondmates are silent # unless FM_BOOTSTRAP_VERBOSE_FACTS=1 requests BOOTSTRAP_INFO facts. # A TANGLE line means the firstmate primary checkout (FM_ROOT) is stranded @@ -694,6 +699,39 @@ report_relaunch() { # echo "BOOTSTRAP_INFO: secondmate $1 relaunched after $2 ($3)" } +# Registered secondmate ids from data/secondmates.md, in file order. The +# registry is the durable authority for WHICH secondmates exist; state/.meta +# is only the endpoint record for one that is currently running. +secondmate_registered_ids() { # + local reg=$1 line id + [ -f "$reg" ] && [ ! -L "$reg" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + '- '*) ;; + *) continue ;; + esac + id=${line#- } + id=${id%% *} + case "$id" in '' | *[!A-Za-z0-9._-]*) continue ;; esac + printf '%s\n' "$id" + done < "$reg" +} + +# Every id the sweep must account for: registered secondmates first, then any +# kind=secondmate endpoint record not already covered. The caller deduplicates, +# so a running registered secondmate is probed exactly once. +secondmate_liveness_ids() { # + local state=$1 registry=$2 meta id + secondmate_registered_ids "$registry" + [ -d "$state" ] || return 0 + for meta in "$state"/*.meta; do + [ -f "$meta" ] || continue + grep -q '^kind=secondmate$' "$meta" 2>/dev/null || continue + id=$(basename "$meta" .meta) + printf '%s\n' "$id" + done +} + secondmate_liveness_sweep() { # Idempotent secondmate liveness guarantee at session start; the watcher's # secondmate_liveness_tick owns the same guarantee mid-session. The detailed @@ -703,54 +741,92 @@ secondmate_liveness_sweep() { # existing ambiguous processes and every transiently unreadable target while # adding the missing-session path the original bare-shell and Herdr-husk sweep # lacked. - # A meta with no window remains owned by secondmate-provisioning recovery. - # Secondmate homes never contain kind=secondmate meta, so this is naturally a - # primary-only no-op there. The probe/relaunch mechanics live in - # bin/fm-secondmate-liveness-lib.sh; this sweep keeps the reporting. + # Every REGISTERED secondmate is accounted for: the walk covers + # data/secondmates.md plus state/.meta, so a secondmate whose record is + # missing or has no endpoint is relaunched from its registry entry + # (secondmate_liveness_recover_from_registry); one that cannot be recovered + # is named as an explicit `gap:` line rather than omitted. + # Secondmate homes never contain kind=secondmate meta AND never register + # secondmates, so this is naturally a primary-only no-op there. The + # probe/relaunch mechanics live in bin/fm-secondmate-liveness-lib.sh; this + # sweep keeps the reporting. [ -d "$STATE" ] || return 0 - local meta id remote_host label __fm_timing_stamp parallel=0 + local meta id remote_host label parallel=0 SECONDMATE_RESPAWNED_IDS="" if bootstrap_parallel_begin; then parallel=1 fi - for meta in "$STATE"/*.meta; do - [ -f "$meta" ] || continue - grep -q '^kind=secondmate$' "$meta" 2>/dev/null || continue - # Identity for the timing record is read here, in the loop, so the per-meta - # body below keeps its single-exit-per-outcome shape. - id=$(basename "$meta" .meta) - remote_host=$(fm_meta_get "$meta" remote_host) + while IFS= read -r id; do + [ -n "$id" ] || continue + meta="$STATE/$id.meta" + if [ -f "$meta" ]; then + grep -q '^kind=secondmate$' "$meta" 2>/dev/null || meta= + else + meta= + fi label=$id - [ -z "$remote_host" ] || label="$id@$remote_host" + if [ -n "$meta" ]; then + remote_host=$(fm_meta_get "$meta" remote_host) + [ -z "$remote_host" ] || label="$id@$remote_host" + fi if [ "$parallel" -eq 1 ]; then - bootstrap_parallel_spawn secondmate_liveness_one_timed "$meta" "$id" "$label" + bootstrap_parallel_spawn secondmate_liveness_one_timed "$id" "$meta" "$label" else - secondmate_liveness_one_timed "$meta" "$id" "$label" + secondmate_liveness_one_timed "$id" "$meta" "$label" fi - done + done < <(secondmate_liveness_ids "$STATE" "$DATA/secondmates.md" | awk '!seen[$0]++') [ "$parallel" -eq 0 ] || bootstrap_parallel_finish return 0 } -secondmate_liveness_one_timed() { #