diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 1acfbfaa3..e9e5e8aa8 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints any diagnostic or capability line - MISSING, NEEDS_GH_AUTH, TANGLE, CREW_HARNESS_OVERRIDE, CREW_DISPATCH, FLEET_SYNC, SECONDMATE_SYNC, SECONDMATE_LIVENESS, TASKS_AXI, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one. + Use whenever the session-start digest's bootstrap section prints any diagnostic or capability line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_HARNESS_OVERRIDE, CREW_DISPATCH, FLEET_SYNC, SECONDMATE_SYNC, SECONDMATE_LIVENESS, TASKS_AXI, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one. A silent bootstrap section means all good and needs no skill load. user-invocable: false metadata: @@ -20,6 +20,8 @@ The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then For `no-mistakes`, this also covers an installed version older than 1.31.2, because crewmate validation briefs delegate gate mechanics to no-mistakes' version-matched guidance. For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the `TASKS_AXI: available` capability line, not this missing-tool report. For `quota-axi`, bootstrap requires it because crew-dispatch `quota-balanced` may call it; `bin/fm-dispatch-select.sh` still degrades at runtime when quota data is unavailable. +- `MISSING_MANUAL: (instructions: )` - tell the captain why the tool is required and give them the printed instructions URL, but do not pass the tool to `bin/fm-bootstrap.sh install`; wait for the captain to complete the manual installation, then rerun session start to confirm the dependency is present. +- `BACKEND_INVALID: (known: )` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends. - `NEEDS_GH_AUTH` - ask the captain to run `! gh auth login` (interactive; you cannot run it for them). - `TANGLE: ` - the primary checkout is stranded on a feature branch instead of its default branch; `AGENTS.md` section 8 explains why this guard exists and what it protects. The work is safe on that branch ref; restore the primary to its default branch with the printed `git -C checkout `, then re-validate that branch in a proper worktree. @@ -39,7 +41,7 @@ The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then - `TASKS_AXI: available` - a default-backend capability fact, not a problem; record it silently and use `AGENTS.md` section 10 for backlog mutations. It prints only when `config/backlog-backend` is absent or set to `tasks-axi` and the shared compatibility probe passes (`docs/configuration.md` "Backlog backend"). If the backend is not opted out and `tasks-axi` is missing or incompatible, bootstrap reports the `MISSING: tasks-axi` line but firstmate still hand-edits routine backlog updates and never blocks work. - If `config/backlog-backend=manual`, firstmate hand-edits routine backlog updates and bootstrap does not suggest installing `tasks-axi`. + If `config/backlog-backend=manual`, firstmate hand-edits routine backlog updates and suppresses only this capability line; a missing or incompatible `tasks-axi` is still handled by the `MISSING` rule above. - `NUDGE_SECONDMATES: fm-...` - the secondmate sweep fast-forwarded one or more *running* secondmate homes to firstmate's current version and their instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) actually changed; send a one-line re-read nudge with `FM_HOME= bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.'` unless `FM_HOME` is already set to the active firstmate home. This mirrors `/updatefirstmate`'s `nudge-secondmates:` report: it is a gentle steer, never an interruption, and the fast-forward already landed safely. A secondmate that was skipped, already current, or whose advance changed no instructions is not listed and must not be disturbed. diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 427a7432d..02502827b 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -50,9 +50,10 @@ How the reply lands depends on whether the work finishes during this turn: - **Work that spawns a real, longer-running job** (dispatching a crewmate, a scout investigation, a ship task) cannot report an outcome yet, so it follows **acknowledge first -> act -> follow up on completion**: 1. **Acknowledge first.** Post an immediate, public-safe reply that you have the captain's order and are on it (the normal answer endpoint, via `bin/fm-x-reply.sh`). This is the legitimate, work-backed version of "aye, will do": it is paired with actually starting the work in the same turn, never a promise left empty. 2. **Act.** Dispatch the work through the normal lifecycle right away. - 3. **Link it for the follow-up, before clearing the inbox.** Associate the spawned task with this mention so completion follow-ups can be posted later: `bin/fm-x-link.sh ` (records the request id, a timestamp, a follow-up counter, and reply-platform context). + 3. **Link it for the follow-up, before clearing the inbox.** Associate the spawned task with this mention so completion follow-ups can be posted later: `bin/fm-x-link.sh ` (records the request id, a timestamp, a follow-up counter, and reply platform/budget context). Do this right after the task is spawned, and always **before** removing the inbox file (step 2f). - `bin/fm-x-link.sh` reads the mention's platform from the still-present inbox payload, so linking before cleanup is what keeps a longer Discord follow-up on the Discord budget instead of the X 280-char one; if the inbox is already gone it falls back to an authoritative relay lookup by request_id and, failing even that, warns loudly - but the local link-before-cleanup order is the fast, correct path, so keep it. + Linking before cleanup lets `bin/fm-x-link.sh` copy the context directly from the inbox, while the durable per-request context recorded by the poll preserves it independently for delayed and concurrent follow-ups. + The exact resolution and fail-safe posting contract is owned by `docs/configuration.md`. If a recovery respawns the same relay request onto a successor task, relink with the paired `--carry-count --carry-ts ` flags plus any prior `x_platform=` and `x_reply_max_chars=` as `--carry-platform --carry-max ` so the successor keeps the consumed follow-up count, original 7-day window, and reply split budget. 4. **Follow up on genuine milestones, sparingly.** Firstmate gets up to **three** follow-ups per mention, within a 7-day window, chained in the same thread - spend them only on changes the captain would actually want to hear about (e.g. investigation done and a build started, work shipped or ready, or the task failing), never on routine internal churn. The task's final outcome - shipped / reported / merged / failed - is always posted with `--final`, which clears the link regardless of how many follow-ups remain. @@ -137,7 +138,8 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin c. **Act on an actionable request through the normal lifecycle.** Treat it exactly as a captain prompt typed in session: run ordinary intake (resolve the project), then file the backlog item, dispatch a crewmate, start a scout, or ship through the gate - whatever the request calls for. **Destructive, irreversible, or security-sensitive work is the exception** (X mode is a public, relayed channel and does not carry full in-session trust): do not execute it from the mention. Flag it to the captain through the normal trusted channel first - the same carve-out as `yolo` (AGENTS.md §1, §7) - act only on the captain's word, and in step 2d say only that it has been flagged for the captain. **If the request spawned a real, longer-running task** (you ran `bin/fm-spawn.sh`), link that task to this mention so milestone and completion follow-ups can be posted: `bin/fm-x-link.sh `. - **Link here, in step 2c, before the step 2f inbox cleanup** - `bin/fm-x-link.sh` reads the mention's reply platform from the still-present inbox payload, so linking after the file is removed strands a longer Discord follow-up on the X 280-char budget (it then falls back to a relay lookup and, failing that, a loud warning, but the correct order avoids both). + **Link here, in step 2c, before the step 2f inbox cleanup** - `bin/fm-x-link.sh` can copy both the mention's reply platform and explicit budget from the still-present inbox payload without a relay lookup. + If that local context is incomplete it uses the durable resolution contract in `docs/configuration.md` and warns loudly, while the follow-up path refuses to post unless both values can be resolved authoritatively. Then step 2d's reply is an **acknowledgement** ("on it, captain"), and genuine milestone updates plus the final outcome come later as follow-ups (see "Completion follow-up" below), with the terminal one posted using `--final`. If the work completed in this turn (a backlog item filed, a question answered), there is no task to link and step 2d reports the outcome directly. d. **Compose the reply.** For a **question**, answer `.text` from the fleet state gathered in step 1. For an **actionable request that completed now**, report the outcome of step 2c (what was done, or - for escalated work - that it has been flagged for the captain). For an **actionable request that spawned a linked task**, acknowledge that you have the order and are on it - milestone updates and the final outcome follow later as completion follow-ups, so do not promise a result you do not yet have. Either way keep it short, in firstmate's voice, and public-safe. @@ -163,7 +165,7 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin It posts nothing, stops the re-offer, and prevents the offline auto-reply; it echoes the `request_id` and exits 0 on success (it honors `FMX_DRY_RUN` like `bin/fm-x-reply.sh`, recording the would-be dismiss to `state/x-outbox/` instead of posting). Do **not** call `bin/fm-x-reply.sh` for a skip. f. **On success (a posted reply, or a relay dismiss for a skip), remove that inbox file:** `rm -f state/x-inbox/.json` (and your temporary reply file). This is the local idempotency guard - a cleared file is never answered twice. - For an acknowledged actionable request that spawned a task, this cleanup comes **after** the step 2c link, never before: `bin/fm-x-link.sh` reads the reply platform from this inbox payload, so removing it first would strand the follow-up on the wrong split budget. + For an acknowledged actionable request that spawned a task, this cleanup comes **after** the step 2c link, never before, so the link can copy the reply platform and budget directly from the inbox payload. g. **On failure** (a non-zero exit from `bin/fm-x-reply.sh` or `bin/fm-x-dismiss.sh`), leave that inbox file in place, move on to the next, and do not retry blindly. If you had already acted on this mention in step 2c before the post failed, do **not** redo that work on a later drain - check whether it is already done (e.g. the backlog item exists, the crewmate is already running) and only retry the reply. If a reply or dismiss fails twice, surface it to the captain as a blocker with the stderr detail; for live post failures include the relay's HTTP status when available. @@ -197,6 +199,7 @@ This skill's own responsibility during the mention-handling turn is linking the When the update carries one real visual artifact, add `--image `; the helper forwards it to `bin/fm-x-reply.sh --followup` so the same image contract used for ordinary replies applies here too. - On a terminal wake (PR merged / scout report / local merge / failed), firstmate posts the task's **final** outcome ("done, here's the result"; for a failure, an honest "this one didn't pan out") with `bin/fm-x-followup.sh --final --text-file `, which always clears the link after that post regardless of how many follow-ups remain under the cap. - Every follow-up is held to the exact same public-safety bar as every reply here: outcomes only, no task ids, internals, captain-private material, or secrets. Past the window, past the cap, or on the relay's own rejection of an exhausted binding, a follow-up attempt is skipped silently and the link is cleared - never treated as a failure worth retrying. +- If either a follow-up's platform or explicit budget cannot be authoritatively resolved from per-request context, inbox payload, or relay answer, `bin/fm-x-followup.sh` does NOT post it: the fail-safe holds it (the link is kept, exit non-zero) rather than use a local default. This is a retryable hold - a later milestone wake retries it once both values are recoverable. ## Notes diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 704f457d3..e9e12f790 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -49,9 +49,9 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. ## Primary turn-end guard -Every verified primary harness has an empirically validated hook path for the "no turn ends blind" guard. +Claude, Codex, OpenCode, and Grok have hook paths for the "no turn ends blind" guard. `claude` and `codex` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. -`opencode`, `pi`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. +`opencode` and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. The exact hook files, commands, validation transcripts, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. When changing any primary turn-end hook, validate the real harness behavior in a scratch project or throwaway home before trusting it, then update that doc and the relevant concise fact below. @@ -70,20 +70,20 @@ Do not substitute another harness's wait shape when resuming supervision. Claude and Grok use tracked background-notify cycles around `bin/fm-watch-arm.sh`. Codex uses bounded foreground checkpoints through `bin/fm-watch-checkpoint.sh` because Codex cannot reason while a foreground tool call is running. OpenCode uses `.opencode/plugins/fm-primary-watch-arm.js`, which coordinates with the turn-end guard plugin and wakes the TUI with `client.session.promptAsync`. -Pi uses the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions Pi auto-discovers once trusted. +Pi uses the tracked `.pi/extensions/fm-primary-pi-watch.ts`, the only project-local Pi extension, which Pi auto-discovers once trusted. When changing any primary watcher adapter, update `docs/supervision-protocols/`, `docs/turnend-guard.md` if a shared idle or turn-end hook changed, and the relevant concise fact below. ## Launch profile axes `bin/fm-spawn.sh` accepts concrete `--harness`, `--model`, and `--effort` values chosen by firstmate at intake. Do not make the shell scripts parse or match natural-language dispatch rules. -The supported launch-profile flags below were verified locally on 2026-06-30 with each CLI's help and parser path. +The supported launch-profile flags below are verified locally; each row records its evidence. | Harness | Model flag | Effort flag | Notes | |---|---|---|---| | claude | `--model ` | `--effort ` | Verified on Claude Code 2.1.196. | | codex | `--model ` | `-c 'model_reasoning_effort=""'` | Verified on codex-cli 0.142.1. The installed binary schema contains `model_reasoning_effort`, the active config uses it, and the bundled model catalog advertises only low/medium/high/xhigh. `max` is omitted. | -| grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.73. `--effort` parses too, but firstmate's profile axis is reasoning effort. `--reasoning-effort max` is rejected, so `max` is omitted. | +| grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.99 (2026-07-13). `--effort` is an alias, but firstmate's profile axis is reasoning effort. As of 0.2.99 the ceiling is `high`; both `xhigh` and `max` are rejected with `use one of: high, medium, low`, so firstmate omits them. | | pi | `--model ` | `--thinking ` | Verified on pi 0.80.2. `max` prints an invalid-thinking warning, so firstmate omits Pi effort when the requested effort is `max`. | | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | @@ -152,9 +152,12 @@ Directory trust dialog on first run per repo root: "Do you trust the contents of Accept with Enter. The decision persists for the repo, so later worktrees of the same project skip it. -Resume after exit with `codex resume `. +Resume after exit with `env -u CODEX_HOME codex resume `. The session id is printed on quit. +`bin/fm-spawn.sh`'s verified Codex template owns the launch boundary for crewmates, scouts, batches, and secondmates; caller-supplied raw launch commands remain caller-owned escape hatches. +Use the same process boundary when manually resuming or recovering a Codex direct report. + **Primary-session guard fact (verified 2026-07-08, codex-cli 0.142.1).** The firstmate PRIMARY's own `.codex/hooks.json` registers a Stop hook that pipes Codex's Stop payload to `bin/fm-turnend-guard.sh`. Codex Stop hooks block on exit 2 and expose `stop_hook_active` for the same one-block loop safety Claude uses. @@ -203,18 +206,17 @@ The decision persists per path in `~/.pi/agent/trust.json`, so later spawns in t The extension must listen for pi's `turn_end` event, not `agent_end`, so the watcher wakes after each completed turn instead of only when the whole agent run exits. Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection env marker. -**Primary-session guard fact (verified 2026-07-09, Pi 0.80.5).** -The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens for logical-run `agent_settled`, not per-tool-loop `turn_end`, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one guarded follow-up when `bin/fm-turnend-guard.sh` returns 2. -Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing. -Pi's primary watcher protocol also requires the tracked `.pi/extensions/fm-primary-pi-watch.ts` extension, same trust-once discovery as the turn-end guard. -The model arms through `fm_watch_arm_pi`, never a foreground bash arm; the watcher tool result and clean-exit fallback are owned by `docs/supervision-protocols/pi.md`. -`bin/fm-session-start.sh` reports when the live Pi session has not loaded both the turn-end guard and watcher extensions, and points at plain `pi` after project trust as the fix, with `-e` as a trust-free fallback. -When a secondmate is launched on Pi, `fm-spawn.sh --secondmate` launches Pi with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. +**Primary-session watcher fact (verified 2026-07-10, Pi 0.80.6).** +The firstmate PRIMARY's own `.pi/extensions/fm-primary-pi-watch.ts` automatically owns startup arm, actionable watcher wake delivery, re-arm, shutdown, and the PreToolUse watcher-arm seatbelt without a turn-end hook or user-role injection. +The model uses `fm_watch_arm_pi` only for manual recovery, never a foreground bash arm; the watcher tool result and clean-exit fallback are owned by `docs/supervision-protocols/pi.md`. +`bin/fm-session-start.sh` reports when the live Pi session has not loaded the watcher extension, and points at an outside-the-composer restart with one-run `--approve -e ` so the extension loads without a saved trust decision. +When a secondmate is launched on Pi, `fm-spawn.sh --secondmate` launches Pi with one-run `--approve` and `-e .pi/extensions/fm-primary-pi-watch.ts`, already present in the secondmate home's git worktree. -## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit behavior re-verified 2026-07-03, grok 0.2.82) +## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit re-verified 2026-07-03 on 0.2.82; reasoning-effort ceiling re-verified 2026-07-13 on 0.2.99) Grok Build TUI (`grok`), a Claude-Code-compatible CLI from xAI. Launch with a positional prompt: `grok --always-approve "$(cat )"`. +For Grok's supported reasoning-effort values and omission behavior, see the [launch-profile-axes table](#launch-profile-axes). | Fact | Value | |---|---| diff --git a/.github/workflows/agent-os-image.yml b/.github/workflows/agent-os-image.yml index 5e27e5b80..8413bf38f 100644 --- a/.github/workflows/agent-os-image.yml +++ b/.github/workflows/agent-os-image.yml @@ -104,6 +104,39 @@ jobs: persist-credentials: false ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify source closure + run: | + set -euo pipefail + tests/agent-os-source-provenance.test.sh + + - name: Record immutable source evidence + run: | + set -euo pipefail + commit=$(git rev-parse HEAD) + tree=$(git rev-parse 'HEAD^{tree}') + archive_sha256=$(git archive --format=tar HEAD | sha256sum | awk '{print $1}') + jq -n \ + --arg repository "https://github.com/akua-dev/agent-os" \ + --arg commit "$commit" \ + --arg tree "$tree" \ + --arg archive_sha256 "$archive_sha256" \ + '{repository:$repository,commit:$commit,tree:$tree,git_archive_sha256:$archive_sha256}' \ + > "$RUNNER_TEMP/agent-os-source-evidence.json" + { + echo '### Agent OS source evidence' + echo + echo "Commit: \`$commit\`" + echo "Tree: \`$tree\`" + echo "Stable git archive SHA-256: \`$archive_sha256\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload source evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: agent-os-source-evidence-${{ github.sha }} + path: ${{ runner.temp }}/agent-os-source-evidence.json + if-no-files-found: error + - name: Prepare exact source bundle if: ${{ !startsWith(github.ref, 'refs/tags/') }} id: source @@ -947,17 +980,20 @@ jobs: push: true tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - provenance: mode=max - sbom: true build-args: | + SOURCE_REPOSITORY=https://github.com/akua-dev/agent-os + SOURCE_REVISION=${{ github.sha }} + SOURCE_VERSION=${{ steps.metadata.outputs.version }} AGENT_OS_SOURCE_COMMIT=${{ steps.main_source.outputs.commit }} AGENT_OS_SOURCE_TREE=${{ steps.main_source.outputs.tree }} AGENT_OS_SOURCE_BRANCH=${{ steps.main_source.outputs.branch }} AGENT_OS_SOURCE_ORIGIN=${{ steps.main_source.outputs.origin }} AGENT_OS_SOURCE_MODE=${{ steps.main_source.outputs.mode }} AGENT_OS_SOURCE_REF=${{ steps.main_source.outputs.ref }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true - name: Promote exact verified release candidate if: startsWith(github.ref, 'refs/tags/') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbdd17f6c..d490ea8fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,17 @@ jobs: set -eu bun install --frozen-lockfile --ignore-scripts bun run check + - name: Install the Pi extension type contract + run: | + set -eu + prefix="$RUNNER_TEMP/pi-types" + npm install --prefix "$prefix" --ignore-scripts --no-audit --no-fund \ + typescript@5.9.3 \ + @types/node@24 \ + typebox@1.1.38 \ + @earendil-works/pi-coding-agent@0.80.6 + printf '%s\n' "$prefix/node_modules/.bin" >> "$GITHUB_PATH" + printf 'FM_PI_PACKAGE_DIR=%s\n' "$prefix/node_modules/@earendil-works/pi-coding-agent" >> "$GITHUB_ENV" - run: | set -eu for test_script in tests/*.test.sh; do diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 644f939ce..9ab40363e 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -1,7 +1,7 @@ // Firstmate primary watcher bridge for Pi. -import { spawn, spawnSync } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -13,20 +13,155 @@ type ArmResult = { }; type LockOwnership = "owned" | "missing" | "other"; +type WakeKind = "actionable" | "failure"; +type WatcherStatus = "offline" | "watching" | "handling wake" | "attention"; +type StatusUi = { setStatus(key: string, text: string | undefined): void }; + +type WakeDetails = { + generation: number; + kind: WakeKind; + reason: string; + exitCode: number | null; + signal: NodeJS.Signals | null; + truncated: boolean; + stdoutTruncated: boolean; + stderrTruncated: boolean; +}; + +type WakeSender = (message: string, details: WakeDetails) => Promise; + +type StatusClient = WakeSender & { + token: symbol; + ui: StatusUi | null; + active: boolean; + sendWake: WakeSender; + rearm: () => Promise; +}; + +type PendingWake = { + message: string; + details: WakeDetails; +}; + +type ArmRecord = { + child: ChildProcess; + cadence: string; + generation: number; + intentionalStopReason: string; + settled: boolean; + stdout: string; + stderr: string; + stdoutPending: string; + stderrPending: string; + stdoutTruncated: boolean; + stderrTruncated: boolean; + actionable: string; + failureHint: string; +}; + +type ArmCoordinator = { + current: ArmRecord | null; + generation: number; + sequence: number; + visibleStatus: WatcherStatus; + startPromise: Promise | null; + startCancelled: boolean; + shuttingDown: boolean; + shutdownPromise: Promise | null; + shutdownToken: symbol | null; + clients: Map; + pendingWake: PendingWake | null; + exitListener?: () => void; +}; + +type CoordinatorHost = typeof globalThis & { + __firstmatePiWatchCoordinators?: Map; +}; const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); -const root = resolve(extensionDir, "../.."); -const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; -const fmRoot = process.env.FM_ROOT_OVERRIDE || root; +const root = realpathSync(resolve(extensionDir, "../..")); +const fmHome = resolve(process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root); +const fmRoot = resolve(process.env.FM_ROOT_OVERRIDE || root); const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`; const armScript = `${fmRoot}/bin/fm-watch-arm.sh`; +const lockScript = `${fmRoot}/bin/fm-lock.sh`; const marker = `${state}/.pi-watch-extension-loaded`; const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; +const MAX_CAPTURE_BYTES = 16 * 1024; +const MAX_PENDING_LINE_BYTES = 4 * 1024; +const FIRSTMATE_PI_WATCHER_STATUS_KEY = "firstmate-pi-watcher" as const; +const WATCHER_STATUS_TEXT: Record = { + offline: "offline", + watching: "watching", + "handling wake": "handling wake", + attention: "attention", +}; +const requestedStopGrace = Number(process.env.FM_PI_WATCH_STOP_GRACE_MS ?? "1000"); +const STOP_GRACE_MS = Number.isFinite(requestedStopGrace) && requestedStopGrace >= 0 ? requestedStopGrace : 1000; +const STOP_KILL_GRACE_MS = 500; +const AWAY_SUSPENSION_LINE = "watcher: suspended - away mode owns supervision"; +const coordinatorHost = globalThis as CoordinatorHost; +const coordinators = coordinatorHost.__firstmatePiWatchCoordinators ??= new Map(); + +function supervisingHome(): boolean { + if (existsSync(`${root}/.fm-secondmate-home`)) return true; + if (!existsSync(`${root}/AGENTS.md`) || !existsSync(`${root}/bin`)) return false; + if (process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE) { + try { + if (realpathSync(fmHome) === root) return true; + } catch {} + } + const gitDir = spawnSync("git", ["-C", root, "rev-parse", "--git-dir"], { encoding: "utf8" }); + const commonDir = spawnSync("git", ["-C", root, "rev-parse", "--git-common-dir"], { encoding: "utf8" }); + if (gitDir.status !== 0 || commonDir.status !== 0) return false; + return gitDir.stdout.trim() === commonDir.stdout.trim(); +} + +function coordinatorForHome(): ArmCoordinator { + const existing = coordinators.get(fmHome); + if (existing) { + existing.visibleStatus ??= "offline"; + if (existing.clients.size === 0) existing.visibleStatus = "offline"; + existing.shutdownPromise ??= null; + existing.shutdownToken ??= null; + if (!existing.shutdownPromise) { + existing.shuttingDown = false; + existing.startCancelled = false; + } + return existing; + } + const coordinator: ArmCoordinator = { + current: null, + generation: 0, + sequence: 0, + visibleStatus: "offline", + startPromise: null, + startCancelled: false, + shuttingDown: false, + shutdownPromise: null, + shutdownToken: null, + clients: new Map(), + pendingWake: null, + }; + coordinators.set(fmHome, coordinator); + return coordinator; +} + +function writeClientStatus(client: StatusClient, status: WatcherStatus | undefined): void { + if (!client.active || !client.ui) return; + client.ui.setStatus( + FIRSTMATE_PI_WATCHER_STATUS_KEY, + status === undefined ? undefined : WATCHER_STATUS_TEXT[status], + ); +} -let child: any = null; -let seq = 0; +function publishStatus(coordinator: ArmCoordinator, status: WatcherStatus): void { + if (coordinator.shuttingDown) return; + coordinator.visibleStatus = status; + for (const client of coordinator.clients.values()) writeClientStatus(client, status); +} function parentPid(pid: string): string { const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" }); @@ -60,108 +195,559 @@ function lockOwnership(): LockOwnership { return pidAlive(lockPid) ? "other" : "missing"; } -function sessionOwnsLock(): boolean { - return lockOwnership() === "owned"; +function claimSessionLock(): Promise { + return new Promise((resolvePromise) => { + const child = spawn(lockScript, [], { + cwd: fmRoot, + env: { + ...process.env, + FM_HOME: fmHome, + FM_ROOT_OVERRIDE: fmRoot, + FM_STATE_OVERRIDE: state, + }, + stdio: "ignore", + }); + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + resolvePromise(); + }; + child.on("error", settle); + child.on("close", settle); + }); +} + +function canonicalLockIsStale(): boolean { + const result = spawnSync(lockScript, ["status"], { + cwd: fmRoot, + env: { + ...process.env, + FM_HOME: fmHome, + FM_ROOT_OVERRIDE: fmRoot, + FM_STATE_OVERRIDE: state, + }, + encoding: "utf8", + }); + return result.status === 0 && /^lock: stale\b/.test(result.stdout.trim()); } function markLoaded(): void { - if (lockOwnership() === "other") return; + if (lockOwnership() === "other" && !canonicalLockIsStale()) return; mkdirSync(state, { recursive: true }); writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); } -function actionableLine(output: string): string { - const lines = output.split(/\r?\n/); - return lines.find((line) => /^(signal:|stale:|check:|heartbeat($|:))/.test(line)) || ""; +function cadenceFingerprint(): string { + let interval = process.env.FM_CHECK_INTERVAL || "300"; + try { + const contents = readFileSync(`${config}/x-mode.env`, "utf8"); + for (const line of contents.split(/\r?\n/)) { + const match = line.match(/^\s*(?:export\s+)?FM_CHECK_INTERVAL\s*=\s*(?:"([0-9]+)"|'([0-9]+)'|([0-9]+))\s*(?:#.*)?$/); + if (match) interval = match[1] || match[2] || match[3]; + } + } catch {} + return `FM_CHECK_INTERVAL=${interval}`; +} + +function actionableLine(line: string): string { + return /^(signal:|stale:|check:|heartbeat($|:))/.test(line) ? line : ""; +} + +function appendBoundedTail(current: string, text: string): { value: string; truncated: boolean } { + const combined = Buffer.from(current + text); + if (combined.byteLength <= MAX_CAPTURE_BYTES) return { value: combined.toString(), truncated: false }; + return { + value: combined.subarray(combined.byteLength - MAX_CAPTURE_BYTES).toString(), + truncated: true, + }; +} + +function inspectOutputLine(record: ArmRecord, line: string): void { + const lineBuffer = Buffer.from(line); + const boundedLine = lineBuffer.byteLength > MAX_PENDING_LINE_BYTES + ? lineBuffer.subarray(0, MAX_PENDING_LINE_BYTES).toString() + : line; + if (!record.actionable) record.actionable = actionableLine(boundedLine); + if (!record.failureHint && (/^watcher: healthy\b/.test(boundedLine) || /^watcher: FAILED/.test(boundedLine))) { + record.failureHint = boundedLine; + } + if (boundedLine === AWAY_SUSPENSION_LINE && !record.intentionalStopReason) { + record.intentionalStopReason = "away-mode"; + } } -function failureLine(stdout: string, stderr: string, code: number | null): string { - const combined = `${stdout}\n${stderr}`.trim(); - const healthy = combined.split(/\r?\n/).find((line) => /^watcher: healthy\b/.test(line)); - if (healthy) return `watcher: FAILED - Pi extension arm child found an external healthy watcher instead of owning wake delivery\n${healthy}`; - const failed = combined.split(/\r?\n/).find((line) => /^watcher: FAILED/.test(line)); - if (failed) return failed; - if (code && code !== 0) return `watcher: FAILED - fm-watch-arm.sh exited ${code}${combined ? `\n${combined}` : ""}`; - return ""; +function clientOwnsGeneration( + coordinator: ArmCoordinator, + client: StatusClient, + generation: number, +): boolean { + return coordinator.generation === generation + && !coordinator.shuttingDown + && client.active + && coordinator.clients.get(client.token) === client; +} + +function captureOutput(record: ArmRecord, stream: "stdout" | "stderr", chunk: Buffer): void { + const text = chunk.toString(); + const tail = appendBoundedTail(record[stream], text); + record[stream] = tail.value; + const truncatedKey = stream === "stdout" ? "stdoutTruncated" : "stderrTruncated"; + record[truncatedKey] ||= tail.truncated; + + const pendingKey = stream === "stdout" ? "stdoutPending" : "stderrPending"; + const lines = `${record[pendingKey]}${text}`.split(/\r?\n/); + record[pendingKey] = lines.pop() ?? ""; + for (const line of lines) inspectOutputLine(record, line); + if (Buffer.byteLength(record[pendingKey]) > MAX_PENDING_LINE_BYTES) { + inspectOutputLine(record, record[pendingKey]); + record[pendingKey] = Buffer.from(record[pendingKey]).subarray(0, MAX_PENDING_LINE_BYTES).toString(); + record[truncatedKey] = true; + } +} + +function failureLine(record: ArmRecord, code: number | null, signal: NodeJS.Signals | null): string { + const combined = `${record.stdout}\n${record.stderr}`.trim(); + if (/^watcher: healthy\b/.test(record.failureHint)) { + return `watcher: FAILED - Pi extension arm child found an external healthy watcher instead of owning wake delivery\n${record.failureHint}`; + } + if (/^watcher: FAILED/.test(record.failureHint)) return record.failureHint; + if (signal) return `watcher: FAILED - fm-watch-arm.sh terminated by ${signal}${combined ? `\n${combined}` : ""}`; + if (code !== null && code !== 0) return `watcher: FAILED - fm-watch-arm.sh exited ${code}${combined ? `\n${combined}` : ""}`; + return `watcher: FAILED - fm-watch-arm.sh exited unexpectedly with code ${code ?? "unknown"}${combined ? `\n${combined}` : ""}`; +} + +function settleArm( + coordinator: ArmCoordinator, + record: ArmRecord, + code: number | null, + signal: NodeJS.Signals | null, + error?: Error, +): void { + if (record.settled) return; + record.settled = true; + inspectOutputLine(record, record.stdoutPending); + inspectOutputLine(record, record.stderrPending); + const ownsGeneration = coordinator.current === record && coordinator.generation === record.generation; + if (ownsGeneration) { + coordinator.current = null; + } + const message = error + ? `watcher: FAILED - Pi extension arm child ${record.generation} failed: ${error.message}` + : record.actionable || failureLine(record, code, signal); + const kind: WakeKind = record.actionable && !error ? "actionable" : "failure"; + const details: WakeDetails = { + generation: record.generation, + kind, + reason: message, + exitCode: code, + signal, + truncated: record.stdoutTruncated || record.stderrTruncated, + stdoutTruncated: record.stdoutTruncated, + stderrTruncated: record.stderrTruncated, + }; + const awayModeActive = existsSync(`${state}/.afk`); + const shouldDefer = kind === "actionable" + ? awayModeActive || Boolean(record.intentionalStopReason) + : awayModeActive && !record.intentionalStopReason; + if (shouldDefer) { + coordinator.pendingWake = { message, details }; + return; + } + if (ownsGeneration && record.intentionalStopReason === "away-mode") { + publishStatus(coordinator, "offline"); + } + if (!ownsGeneration || coordinator.shuttingDown || record.intentionalStopReason) return; + if (kind === "failure") publishStatus(coordinator, "attention"); + const activeClient = [...coordinator.clients.values()].reverse().find((candidate) => candidate.active); + if (!activeClient) return; + void (async () => { + try { + await activeClient.sendWake(message, details); + } catch { + if (!clientOwnsGeneration(coordinator, activeClient, record.generation)) return; + coordinator.pendingWake = { message, details }; + publishStatus(coordinator, "attention"); + if (kind === "actionable") { + void activeClient.rearm().catch(() => undefined); + } + return; + } + if (kind !== "actionable" || !clientOwnsGeneration(coordinator, activeClient, record.generation)) return; + publishStatus(coordinator, "handling wake"); + void activeClient.rearm().catch(() => { + if (clientOwnsGeneration(coordinator, activeClient, record.generation)) { + publishStatus(coordinator, "attention"); + } + }); + })(); +} + +function signalArm(record: ArmRecord, signal: NodeJS.Signals): void { + const pid = record.child.pid; + if (pid) { + try { + process.kill(-pid, signal); + return; + } catch { + // Fall back to the direct child only when group signaling is unavailable. + } + } + try { + record.child.kill(signal); + } catch { + // The process may already be gone; settlement remains event-driven or bounded below. + } +} + +function processGroupAlive(record: ArmRecord): boolean { + const pid = record.child.pid; + if (!pid) return false; + try { + process.kill(-pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +function stopsWithin(record: ArmRecord, milliseconds: number): Promise { + return new Promise((resolvePromise) => { + const deadline = Date.now() + milliseconds; + const check = () => { + if (record.settled && !processGroupAlive(record)) { + resolvePromise(true); + return; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) { + resolvePromise(false); + return; + } + setTimeout(check, Math.min(10, remaining)); + }; + check(); + }); +} + +async function stopArmRecord( + coordinator: ArmCoordinator, + record: ArmRecord, + reason: string, +): Promise { + try { + if (!record.intentionalStopReason) record.intentionalStopReason = reason; + signalArm(record, "SIGTERM"); + if (!(await stopsWithin(record, STOP_GRACE_MS))) { + signalArm(record, "SIGKILL"); + if (!(await stopsWithin(record, STOP_KILL_GRACE_MS))) { + if (!record.settled) settleArm(coordinator, record, null, "SIGKILL"); + if (processGroupAlive(record)) { + throw new Error(`watcher: FAILED - process group ${record.child.pid} survived SIGKILL`); + } + } + } + } finally { + if (coordinator.current === record) { + coordinator.current = null; + } + } +} + +function stopArmOnProcessExit(coordinator: ArmCoordinator): void { + const record = coordinator.current; + if (!record) return; + if (!record.intentionalStopReason) record.intentionalStopReason = "process-exit"; + signalArm(record, "SIGTERM"); + signalArm(record, "SIGKILL"); +} + +function runChecker(script: string, command: string): Promise<{ code: number; stderr: string }> { + return new Promise((resolveResult) => { + const child = spawn(`${fmRoot}/bin/${script}`, ["--command", command], { + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", () => resolveResult({ code: 0, stderr: "" })); + child.on("close", (code) => resolveResult({ code: code ?? 0, stderr })); + }); +} + +function runPretoolCheck(command: string): Promise<{ code: number; stderr: string }> { + return runChecker("fm-arm-pretool-check.sh", command); +} + +function runCdCheck(command: string): Promise<{ code: number; stderr: string }> { + return runChecker("fm-cd-pretool-check.sh", command); } export default function (pi: ExtensionAPI) { - function stopArm(): void { - if (child) child.kill("SIGTERM"); - child = null; + if (!supervisingHome()) return; + const coordinator = coordinatorForHome(); + + async function sendWake(message: string, details: WakeDetails): Promise { + pi.sendMessage( + { + customType: "firstmate-watcher-wake", + content: `FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first, handle the queued wake, then resume Pi supervision.`, + display: true, + details, + }, + { deliverAs: "followUp", triggerTurn: true }, + ); } + const client: StatusClient = Object.assign(sendWake, { + token: Symbol("pi-watch-extension-client"), + ui: null as StatusUi | null, + active: true, + sendWake, + rearm: startArm, + }); + coordinator.clients.set(client.token, client); + const cleanupOnProcessExit = () => { - stopArm(); + for (const activeClient of coordinator.clients.values()) { + writeClientStatus(activeClient, undefined); + activeClient.active = false; + } + coordinator.clients.clear(); + coordinator.shuttingDown = true; + coordinator.startCancelled = true; + coordinator.generation += 1; + stopArmOnProcessExit(coordinator); }; - process.once("exit", cleanupOnProcessExit); + if (!coordinator.exitListener) { + coordinator.exitListener = cleanupOnProcessExit; + process.once("exit", cleanupOnProcessExit); + } - async function sendWake(message: string) { - await pi.sendUserMessage( - `FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first, handle the queued wake, then resume Pi supervision.`, - { deliverAs: "followUp" }, - ); + async function suspendForAwayMode(): Promise { + if (!existsSync(`${state}/.afk`)) return null; + const record = coordinator.current; + if (record) await stopArmRecord(coordinator, record, "away-mode"); + if (!existsSync(`${state}/.afk`)) return null; + publishStatus(coordinator, "offline"); + return { ok: true, message: "watcher: suspended - away mode owns supervision" }; } - function startArm(): ArmResult { - if (!sessionOwnsLock()) return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; + async function startArmOnce(): Promise { + const initialSuspension = await suspendForAwayMode(); + if (initialSuspension) return initialSuspension; + if (lockOwnership() !== "owned") await claimSessionLock(); + if (coordinator.startCancelled || coordinator.shuttingDown || coordinator.clients.size === 0) { + return { ok: false, message: "watcher: not started - Pi extension session shut down" }; + } + if (lockOwnership() !== "owned") { + publishStatus(coordinator, "attention"); + return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; + } + const lockedSuspension = await suspendForAwayMode(); + if (lockedSuspension) return lockedSuspension; markLoaded(); - if (child) return { ok: true, message: "watcher: healthy - Pi extension already has an arm child" }; - const id = ++seq; + const cadence = cadenceFingerprint(); + if (coordinator.current?.cadence === cadence && !coordinator.current.settled) { + return { ok: true, message: "watcher: healthy - Pi extension already has an arm child" }; + } + if (coordinator.current) await stopArmRecord(coordinator, coordinator.current, "cadence-change"); + if (coordinator.startCancelled || coordinator.shuttingDown || coordinator.clients.size === 0) { + return { ok: false, message: "watcher: not started - Pi extension session shut down" }; + } + const postCleanupSuspension = await suspendForAwayMode(); + if (postCleanupSuspension) return postCleanupSuspension; + const pendingWake = coordinator.pendingWake; + if (pendingWake) { + const activeClient = [...coordinator.clients.values()].reverse().find((candidate) => candidate.active); + if (activeClient) { + try { + await activeClient.sendWake(pendingWake.message, pendingWake.details); + } catch (error) { + publishStatus(coordinator, "attention"); + return { + ok: false, + message: error instanceof Error + ? `watcher: FAILED - pending wake delivery failed: ${error.message}` + : "watcher: FAILED - pending wake delivery failed", + }; + } + if (coordinator.pendingWake === pendingWake) coordinator.pendingWake = null; + publishStatus(coordinator, "handling wake"); + } + } + if (coordinator.startCancelled || coordinator.shuttingDown || coordinator.clients.size === 0) { + return { ok: false, message: "watcher: not started - Pi extension session shut down" }; + } + const preSpawnSuspension = await suspendForAwayMode(); + if (preSpawnSuspension) return preSpawnSuspension; + + const id = ++coordinator.sequence; + const generation = ++coordinator.generation; const env = { ...process.env, FM_HOME: fmHome, FM_ROOT_OVERRIDE: fmRoot, + FM_STATE_OVERRIDE: state, FM_CONFIG_OVERRIDE: config, FM_WATCH_ARM_SCRIPT: armScript, }; - child = spawn("bash", ["-lc", "config_dir=\"${FM_CONFIG_OVERRIDE:-$FM_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$FM_WATCH_ARM_SCRIPT\" --restart"], { + const launchCommand = "state_dir=\"${FM_STATE_OVERRIDE:-$FM_HOME/state}\"; if [ -f \"$state_dir/.afk\" ]; then printf '%s\\n' '" + + AWAY_SUSPENSION_LINE + + "'; exit 0; fi; config_dir=\"${FM_CONFIG_OVERRIDE:-$FM_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$FM_WATCH_ARM_SCRIPT\" --restart"; + const child = spawn("bash", ["-lc", launchCommand], { cwd: fmRoot, env, + detached: true, stdio: ["ignore", "pipe", "pipe"], }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); + const record: ArmRecord = { + child, + cadence, + generation, + intentionalStopReason: "", + settled: false, + stdout: "", + stderr: "", + stdoutPending: "", + stderrPending: "", + stdoutTruncated: false, + stderrTruncated: false, + actionable: "", + failureHint: "", + }; + coordinator.current = record; + publishStatus(coordinator, "watching"); + child.stdout?.on("data", (chunk: Buffer) => { + captureOutput(record, "stdout", chunk); }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); + child.stderr?.on("data", (chunk: Buffer) => { + captureOutput(record, "stderr", chunk); + }); + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { + settleArm(coordinator, record, code, signal); + }); + child.on("error", (error: Error) => { + settleArm(coordinator, record, null, null, error); }); - child.on("close", async (code: number | null) => { - child = null; - const reason = actionableLine(`${stdout}\n${stderr}`); - const failure = reason ? "" : failureLine(stdout, stderr, code); - if (!reason && !failure) return; + return { ok: true, message: `watcher: started Pi extension arm child ${id}` }; + } + + async function startArm(): Promise { + const pendingShutdown = coordinator.shutdownPromise; + if (pendingShutdown) { try { - await sendWake(reason || failure); - } catch { - // Pi owns delivery errors; fail open so the extension never wedges the session. + await pendingShutdown; + } catch (error) { + if (client.active && coordinator.clients.get(client.token) === client) { + publishStatus(coordinator, "attention"); + } + return { + ok: false, + message: error instanceof Error + ? error.message + : "watcher: FAILED - Pi extension reload cleanup failed", + }; } + } + if (!client.active || coordinator.clients.get(client.token) !== client) { + return { ok: false, message: "watcher: not started - Pi extension session shut down" }; + } + if (coordinator.startPromise) return coordinator.startPromise; + if (coordinator.shuttingDown || coordinator.clients.size === 0) { + return Promise.resolve({ ok: false, message: "watcher: not started - Pi extension session shut down" }); + } + coordinator.startCancelled = false; + let startPromise: Promise; + startPromise = startArmOnce().finally(() => { + if (coordinator.startPromise !== startPromise) return; + coordinator.startPromise = null; }); - child.on("error", async (error: Error) => { - child = null; + coordinator.startPromise = startPromise; + return startPromise; + } + + pi.on?.("session_start", async (_event, ctx) => { + client.ui = ctx.ui; + writeClientStatus(client, coordinator.visibleStatus); + markLoaded(); + await startArm(); + }); + + async function shutdownClient(reason: string): Promise { + if (!client.active) return; + writeClientStatus(client, undefined); + client.active = false; + coordinator.clients.delete(client.token); + if (coordinator.clients.size > 0) return; + if (coordinator.shutdownPromise) { + await coordinator.shutdownPromise; + return; + } + + coordinator.shuttingDown = true; + coordinator.startCancelled = true; + const record = coordinator.current; + if (record && !record.intentionalStopReason) record.intentionalStopReason = reason; + coordinator.generation += 1; + + const pendingStart = coordinator.startPromise; + const exitListener = coordinator.exitListener; + const shutdownToken = Symbol("pi-watch-extension-shutdown"); + coordinator.shutdownToken = shutdownToken; + const shutdownPromise = Promise.resolve().then(async () => { try { - await sendWake(`watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`); - } catch { - // Fail open. + if (pendingStart) await pendingStart.catch(() => undefined); + if (coordinator.shutdownToken !== shutdownToken) return; + const currentRecord = coordinator.current; + if (currentRecord) await stopArmRecord(coordinator, currentRecord, reason); + if (coordinator.shutdownToken !== shutdownToken) return; + } finally { + if (coordinator.shutdownToken === shutdownToken) { + if (coordinator.clients.size === 0) { + if (exitListener && coordinator.exitListener === exitListener) { + process.off("exit", exitListener); + coordinator.exitListener = undefined; + } + } else { + coordinator.shuttingDown = false; + coordinator.startCancelled = false; + } + coordinator.shutdownToken = null; + coordinator.shutdownPromise = null; + } } }); - return { ok: true, message: `watcher: started Pi extension arm child ${id}` }; + coordinator.shutdownPromise = shutdownPromise; + await shutdownPromise; } - pi.on?.("session_start", () => { - markLoaded(); + pi.on?.("session_shutdown", async (event) => { + await shutdownClient(event.reason || "session-shutdown"); }); - pi.on?.("session_shutdown", () => { - stopArm(); - process.off("exit", cleanupOnProcessExit); + + pi.on("tool_call", async (event) => { + if (event.type !== "tool_call" || event.toolName !== "bash") return {}; + const command = String((event.input as { command?: unknown })?.command ?? ""); + if (!command) return {}; + const cdResult = await runCdCheck(command); + if (cdResult.code === 2) { + return { block: true, reason: cdResult.stderr.trim() || "denied by the cd-guard PreToolUse seatbelt" }; + } + const result = await runPretoolCheck(command); + if (result.code !== 2) return {}; + return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" }; + }); + + pi.on("tool_execution_end", async () => { + await startArm(); }); pi.registerCommand?.("fm-watch-arm-pi", { description: "Arm firstmate watcher supervision through the Pi extension instead of foreground bash.", handler: async (_args, ctx) => { - const result = startArm(); + const result = await startArm(); ctx.ui.notify(result.message, result.ok ? "info" : "warning"); }, }); @@ -176,7 +762,7 @@ export default function (pi: ExtensionAPI) { ], parameters: Type.Object({}), execute: async () => { - const result = startArm(); + const result = await startArm(); return { content: [{ type: "text", text: result.message }], details: result, diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts deleted file mode 100644 index fe90145a1..000000000 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -let guardFollowupActive = false; - -type LockOwnership = "owned" | "missing" | "other"; - -const extensionFile = fileURLToPath(import.meta.url); -const extensionDir = dirname(extensionFile); -const root = resolve(extensionDir, "../.."); -const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; -const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; -const marker = `${state}/.pi-turnend-extension-loaded`; -const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; - -function parentPid(pid: string): string { - const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" }); - if (result.status !== 0) return ""; - return result.stdout.trim(); -} - -function pidAlive(pid: string): boolean { - try { - process.kill(Number(pid), 0); - return true; - } catch { - return false; - } -} - -function lockOwnership(): LockOwnership { - let lockPid = ""; - try { - lockPid = readFileSync(`${state}/.lock`, "utf8").trim(); - } catch { - return "missing"; - } - if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other"; - let pid = String(process.pid); - for (let i = 0; i < 8; i += 1) { - if (pid === lockPid) return "owned"; - pid = parentPid(pid); - if (!pid || pid === "1") break; - } - return pidAlive(lockPid) ? "other" : "missing"; -} - -function markLoaded(): void { - if (lockOwnership() === "other") return; - mkdirSync(state, { recursive: true }); - writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); -} - -function runGuard(): Promise<{ code: number; stderr: string }> { - return new Promise((resolveResult) => { - const child = spawn(`${root}/bin/fm-turnend-guard.sh`, { - stdio: ["pipe", "ignore", "pipe"], - }); - let stderr = ""; - child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); - }); - child.on("error", () => resolveResult({ code: 0, stderr: "" })); - child.on("close", (code) => resolveResult({ code: code ?? 0, stderr })); - child.stdin.end('{"stop_hook_active":false}'); - }); -} - -// PreToolUse seatbelts (bin/fm-arm-pretool-check.sh, docs/arm-pretool-check.md; -// bin/fm-cd-pretool-check.sh, docs/cd-guard.md). Both piggyback on this same -// extension file rather than separate ones so no extra Pi -e flag is needed at -// launch - the primary already loads this file for the turn-end guard, and -// pi.on("tool_call", ...) can block (verified 2026-07-09 against pi 0.80.5: -// returning {block: true} prevents the bash command from running). Each owner -// script owns its own decision and is inert outside the real primary checkout. -function runChecker(script: string, command: string): Promise<{ code: number; stderr: string }> { - return new Promise((resolveResult) => { - const child = spawn(`${root}/bin/${script}`, ["--command", command], { - stdio: ["ignore", "ignore", "pipe"], - }); - let stderr = ""; - child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); - }); - child.on("error", () => resolveResult({ code: 0, stderr: "" })); - child.on("close", (code) => resolveResult({ code: code ?? 0, stderr })); - }); -} - -function runPretoolCheck(command: string): Promise<{ code: number; stderr: string }> { - return runChecker("fm-arm-pretool-check.sh", command); -} - -function runCdCheck(command: string): Promise<{ code: number; stderr: string }> { - return runChecker("fm-cd-pretool-check.sh", command); -} - -export default function (pi: ExtensionAPI) { - pi.on?.("session_start", () => { - markLoaded(); - }); - - pi.on("tool_call", async (event) => { - if (event.type !== "tool_call" || event.toolName !== "bash") return {}; - const command = String((event.input as { command?: unknown })?.command ?? ""); - if (!command) return {}; - const cdResult = await runCdCheck(command); - if (cdResult.code === 2) { - return { block: true, reason: cdResult.stderr.trim() || "denied by the cd-guard PreToolUse seatbelt" }; - } - const result = await runPretoolCheck(command); - if (result.code !== 2) return {}; - return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" }; - }); - - pi.on("agent_settled", async () => { - if (guardFollowupActive) { - guardFollowupActive = false; - return; - } - - const result = await runGuard(); - if (result.code !== 2) return; - - guardFollowupActive = true; - try { - await pi.sendUserMessage( - "TURN WOULD END BLIND - supervision is off. " + - "Resume supervision according to the session-start operating block before ending the turn.\n\n" + - result.stderr, - { deliverAs: "followUp" }, - ); - } catch { - guardFollowupActive = false; - } - }); - - markLoaded(); -} diff --git a/AGENTS.md b/AGENTS.md index de07cc933..91d9eacfb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,7 @@ state/ volatile runtime signals; gitignored .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) + x-context/ generated X-mode durable per-request reply context (platform/budget), keyed by request_id; survives inbox cleanup so a delayed follow-up recovers the original platform (section 14; bin/fm-x-lib.sh) x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) x-poll.error generated X-mode relay diagnostic dedupe marker .wake-queue durable queued wakes: epochseqkindkeypayload @@ -620,7 +621,8 @@ If a crewmate sent to work firstmate-on-itself branches or commits in the primar Only a named non-default branch checked out in the primary alarms: detached HEAD (the legitimate resting state of crewmate worktrees and secondmate homes) and the default branch never do. The same assertion runs at session start as the bootstrap `TANGLE:` line (handled via `bootstrap-diagnostics`), and two upstream guards prevent the tangle: `fm-spawn`'s isolated-worktree assertion and the ship brief's opening isolation check (section 11). -On every verified primary harness, "no turn ends blind" has a structural backstop beyond the pull-based banner: `bin/fm-turnend-guard.sh` blocks the turn end (or forces one bounded follow-up on passive harnesses) when tasks are in flight without a live identity-matched watcher lock and fresh beacon, fires only in the actual primary checkout, and stays silent when supervision is healthy. +On Claude, Codex, OpenCode, and Grok, "no turn ends blind" has a structural backstop beyond the pull-based banner: `bin/fm-turnend-guard.sh` blocks the turn end (or forces one bounded follow-up on passive harnesses) when tasks are in flight without a live identity-matched watcher lock and fresh beacon, guards both the main primary and a secondmate's own primary session, and stays silent when supervision is healthy. +Pi remains watcher-only; its tracked extension owns the automatic watcher lifecycle described in `docs/supervision-protocols/pi.md`. `docs/turnend-guard.md` owns the per-harness hook mechanisms, empirical validation, scoping details, and documented fail-open tradeoffs. Watcher liveness is harness-aware. Do not assume one primary harness can use another harness's foreground or background shape. @@ -763,7 +765,7 @@ That skill owns the source-policy split: mutable checkouts update by guarded fas These skills are not captain-invocable; they are conditional operating references you must load at the trigger points below. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints any diagnostic or capability line (`MISSING:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_HARNESS_OVERRIDE:`, `CREW_DISPATCH:`, `FLEET_SYNC:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `TASKS_AXI:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence needs no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints any diagnostic or capability line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_HARNESS_OVERRIDE:`, `CREW_DISPATCH:`, `FLEET_SYNC:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `TASKS_AXI:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence needs no load. - `harness-adapters` - load 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. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. - `stuck-crewmate-recovery` - load after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. diff --git a/Dockerfile b/Dockerfile index 31b37ddcb..1aab7d343 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,6 +72,15 @@ COPY image/debian.sources /etc/apt/sources.list.d/debian.sources RUN echo "9767ac71230276e282fdb39a087c889a277835b47751a0c0e5a9da0e8352e289 /etc/apt/sources.list.d/debian.sources" | sha256sum -c - +ARG SOURCE_REPOSITORY=https://github.com/akua-dev/agent-os +ARG SOURCE_REVISION +ARG SOURCE_VERSION=dev + +LABEL org.opencontainers.image.source=$SOURCE_REPOSITORY \ + org.opencontainers.image.revision=$SOURCE_REVISION \ + org.opencontainers.image.version=$SOURCE_VERSION \ + org.opencontainers.image.licenses=MIT + RUN apt-get update \ && apt-get install -y --no-install-recommends \ bash \ @@ -233,7 +242,9 @@ COPY --from=source-bootstrap /opt/agent-os-source.origin /opt/agent-os-source.or COPY --from=source-bootstrap /opt/agent-os-source.mode /opt/agent-os-source.mode COPY --from=source-bootstrap /opt/agent-os-source.ref /opt/agent-os-source.ref -RUN install -D -m 0644 /opt/agent-os/THIRD_PARTY_NOTICES.md /usr/share/doc/agent-os/THIRD_PARTY_NOTICES.md \ +RUN install -D -m 0644 /opt/agent-os/LICENSE /usr/share/doc/agent-os/LICENSE \ + && install -D -m 0644 /opt/agent-os/SOURCE_PROVENANCE.json /usr/share/doc/agent-os/SOURCE_PROVENANCE.json \ + && install -D -m 0644 /opt/agent-os/THIRD_PARTY_NOTICES.md /usr/share/doc/agent-os/THIRD_PARTY_NOTICES.md \ && install -D -m 0644 /opt/agent-os/THIRD_PARTY_SOURCES.md /usr/share/doc/agent-os/THIRD_PARTY_SOURCES.md RUN cd /opt/agent-os/tools/agent-os \ diff --git a/README.md b/README.md index c041b6de9..ae50fc543 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,14 @@ This is how small teams build and operate entire companies with agents — the p firstmate is not a model, not a harness, not a skill, not an MCP server, and not a CLI. firstmate is an agent distro for running a crew of agents. An agent distro is a portable directory of instructions, skills, tooling, policies, and state conventions that turns a general-purpose agent into a specialized one. -There is no app to install: the cloned repo is the distro - `AGENTS.md`, bundled firstmate skills, and helper scripts that any terminal coding agent can follow. +For local terminal use, there is no separate app to install: the cloned repo is the distro - `AGENTS.md`, bundled firstmate skills, and helper scripts that any terminal coding agent can follow. Launching a supported harness inside it instantiates your first mate - and makes you the captain. -This Agent OS fork also packages the distro for Kubernetes: one persistent -first mate can allocate isolated, persistent crewmate containers using ordinary -`kubectl`, while Herdr keeps every agent terminal visible. Follow the -[Agent OS Kubernetes quickstart](docs/kubernetes.md) to render the one public -package for any conformant cluster. OrbStack is a local test profile of that -same package, not a separate installer. +This Agent OS fork also packages the distro for Kubernetes. +One persistent first mate can allocate isolated, persistent crewmate containers using ordinary `kubectl`, while Herdr keeps every agent terminal visible. +Follow the [Agent OS Kubernetes quickstart](docs/kubernetes.md) to render the one public package for any conformant cluster. +OrbStack is a local test profile of that same package, not a separate installer. +`SOURCE_PROVENANCE.json` records the exact source inputs, licenses, merge order, and exclusions for this distro. Agent OS works on Kubernetes and is better with Akua. The portable core can be built and tested on local OrbStack without an Akua account, API key, managed control plane, or Akua-hosted worker, and is intended for any conformant Kubernetes cluster. @@ -64,7 +63,7 @@ Akua adds the preferred credential-to-cluster bootstrap, managed capacity, ident - **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. - **Optional secondmates** - opt in to persistent domain supervisors that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. -- **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is in flight and supervision is not live. +- **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; Claude Code, Codex, OpenCode, and Grok also get a turn-end backstop that blocks or follows up on a blind stop when work is in flight and supervision is not live, while Pi remains watcher-only. - **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 up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome 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 or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on. @@ -77,7 +76,7 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m - A verified agent harness: Claude Code, Grok, Pi, Codex, or OpenCode. - Git and the GitHub CLI, authenticated through `gh auth login`. -- tmux, for the reference session backend. +- The selected runtime backend's tools; tmux is the default and reference backend, while [`docs/configuration.md`](docs/configuration.md#toolchain) owns the requirements for every backend. The first mate detects and offers to install everything else. @@ -85,7 +84,7 @@ The first mate detects and offers to install everything else. **Claude Code, Grok, and Pi are equal co-primary recommendations** for running the primary firstmate session. Claude Code and Grok use background-notify wake cycles; Pi uses its tracked primary watcher extension. -All three have verified turn-end guard paths when launched with their documented setup. +Claude Code and Grok also have verified turn-end guard paths, while Pi supervision remains watcher-only. Pick whichever one matches your subscription and workflow. Codex and OpenCode are also verified and supported as primary harnesses; Codex uses bounded foreground checkpoints, and OpenCode uses a TUI plugin, so both carry more harness-specific supervision tradeoffs than the three co-primaries. @@ -119,7 +118,7 @@ pi ``` For Grok, `--trust` is needed once per clone so project hooks and the turn-end guard load; `/hooks-trust` inside Grok works too. -For Pi, approve the project trust prompt once per clone on first launch so both tracked `.pi/extensions/*.ts` files auto-load. +For Pi, approve the project trust prompt once per clone on first launch so the tracked watcher extension auto-loads. ### Talk to it @@ -199,7 +198,7 @@ Firstmate's skills live in two separate places with different audiences: ## Documentation -- [docs/kubernetes.md](docs/kubernetes.md) - run the Agent OS controller and isolated crewmates on local OrbStack Kubernetes. +- [docs/kubernetes.md](docs/kubernetes.md) - install a persistent Firstmate and isolated crewmates on Kubernetes, with OrbStack as the local test profile. - [docs/acceptance.md](docs/acceptance.md) - requirement-to-evidence ledger for the full distributed Agent OS proof. - [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. @@ -210,7 +209,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/orca-backend.md](docs/orca-backend.md) - setup guide for the experimental Orca backend, plus its lifecycle notes and known gaps. - [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, plus its verification notes and known gaps. - [docs/codex-app-backend.md](docs/codex-app-backend.md) - Codex App backend boundary, evidence, and rollout contract. -- [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: verified per-harness hook mechanisms, scoping, loop safety, and fail-open tradeoffs. +- [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: hook mechanisms, scoping, loop safety, and fail-open tradeoffs. - [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi, Grok, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. - [`AGENTS.md`](AGENTS.md) - the distro's core instruction file and the first mate's full operating manual. diff --git a/SOURCE_PROVENANCE.json b/SOURCE_PROVENANCE.json new file mode 100644 index 000000000..220af4dc8 --- /dev/null +++ b/SOURCE_PROVENANCE.json @@ -0,0 +1,120 @@ +{ + "schema_version": 1, + "package": { + "name": "Agent OS (Firstmate)", + "repository": "https://github.com/akua-dev/agent-os", + "license": "MIT", + "source_archive": "git archive --format=tar ", + "self_referential_commit_fields": false + }, + "inputs": [ + { + "name": "package-precursor", + "repository": "https://github.com/akua-dev/agent-os", + "commit": "07c48eeeee162bdd40b5a43537c21b989a65b260", + "integration": "base", + "license": "MIT" + }, + { + "name": "agent-os-firstmate-sync", + "repository": "https://github.com/akua-dev/agent-os", + "commit": "a318580a88c08c0aa2e270ae8ea0f0f02b5ea640", + "integration": "merge", + "license": "MIT" + }, + { + "name": "firstmate-upstream", + "repository": "https://github.com/kunchenguid/firstmate", + "commit": "8c0d9eb878bc8c2ec4c11bf71dd819f626875bf6", + "integration": "merge", + "license": "MIT" + }, + { + "name": "firstmate-pr-442", + "repository": "https://github.com/kunchenguid/firstmate/pull/442", + "commit": "9b4d65da33b7a14a2ad77c2048186f1e6d7a56e1", + "integration": "merge", + "license": "MIT" + }, + { + "name": "firstmate-pr-521", + "repository": "https://github.com/kunchenguid/firstmate/pull/521", + "commit": "86cc8769d1c34251cae821059b79fbdc1e5fc5e4", + "integration": "merge", + "license": "MIT" + }, + { + "name": "zellij-superseder", + "repository": "https://github.com/robinbraemer/firstmate", + "commit": "d47b44c8ed5b966f425ddd5e6a0dcbc1b9714e9a", + "integration": "merge", + "license": "MIT", + "acquisition": "committed object from the author's local repository; preserved by this integration ancestry" + } + ], + "merge_order": [ + "a318580a88c08c0aa2e270ae8ea0f0f02b5ea640", + "8c0d9eb878bc8c2ec4c11bf71dd819f626875bf6", + "9b4d65da33b7a14a2ad77c2048186f1e6d7a56e1", + "86cc8769d1c34251cae821059b79fbdc1e5fc5e4", + "d47b44c8ed5b966f425ddd5e6a0dcbc1b9714e9a" + ], + "exclusions": { + "commits": [ + { + "name": "firstmate-pr-528", + "commit": "e204ec1671fa698a3d8a0eb9a0ac8c8bd87f47fa", + "reason": "test-only; not an ancestor, dependency, or blocker" + }, + { + "name": "firstmate-pr-346", + "commit": "787211f52fa536c916d1b2cc7c20a7b6d73d2c42", + "reason": "closed and superseded" + }, + { + "name": "firstmate-pr-355", + "commit": "6914e14071d18af33d336e512f2573c62b74ce7f", + "reason": "closed and superseded" + } + ], + "paths": [ + ".env", + "config", + "data", + "state", + "projects", + "worktrees", + ".worktrees", + ".no-mistakes", + ".repos", + "node_modules" + ], + "classes": [ + "credential-bearing and untracked harness homes", + "dirty local branches and stashes", + "private Cortex material", + "Herdr source and private or commercial artifacts", + "package caches and operational fleet state" + ] + }, + "third_party_boundaries": [ + { + "name": "Herdr", + "license": "AGPL-3.0-or-later", + "relationship": "separate unmodified executable", + "notice": "THIRD_PARTY_SOURCES.md" + }, + { + "name": "Akua", + "license": "Apache-2.0", + "relationship": "separate unmodified executable", + "notice": "THIRD_PARTY_NOTICES.md" + }, + { + "name": "K9s", + "license": "Apache-2.0", + "relationship": "separate unmodified executable", + "notice": "THIRD_PARTY_NOTICES.md" + } + ] +} diff --git a/bin/agent-os-kubernetes-cas.sh b/bin/agent-os-kubernetes-cas.sh new file mode 100644 index 000000000..e14875b63 --- /dev/null +++ b/bin/agent-os-kubernetes-cas.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +agent_os_identity_token() { + local identity=$1 expected=$2 resource=$3 name=$4 uid rest resource_version ownership + uid=${identity%%|*} + [ -n "$uid" ] || return 0 + rest=${identity#*|} + resource_version=${rest%%|*} + ownership=${rest#*|} + if [ -z "$resource_version" ] || [ "$ownership" != "$expected" ]; then + echo "error: refusing to operate on unowned $resource/$name (identity: $ownership)" >&2 + return 1 + fi + printf '%s|%s\n' "$uid" "$resource_version" +} + +agent_os_cas_manifest() { + local source=$1 target=$2 token=$3 uid resource_version + uid=${token%%|*} + resource_version=${token#*|} + awk -v uid="$uid" -v resource_version="$resource_version" ' + /^metadata:$/ { + print + print " uid: \"" uid "\"" + print " resourceVersion: \"" resource_version "\"" + metadata = 1 + found = 1 + next + } + metadata && /^ (uid|resourceVersion):/ { next } + metadata && !/^ / && !/^[[:space:]]*$/ { metadata = 0 } + { print } + END { if (!found) exit 1 } + ' "$source" > "$target" +} + +agent_os_reconcile() { + local scope=$1 resource=$2 name=$3 manifest=$4 token=$5 cas_manifest + if [ -z "$token" ]; then + if [ "$scope" = namespaced ]; then + "$KUBECTL" "${KUBECTL_ARGS[@]}" -n "$NAMESPACE" create -f "$manifest" + else + "$KUBECTL" "${KUBECTL_ARGS[@]}" create -f "$manifest" + fi + return + fi + cas_manifest="$OUT/cas-${resource}-${name}.yaml" + agent_os_cas_manifest "$manifest" "$cas_manifest" "$token" + if [ "$scope" = namespaced ]; then + "$KUBECTL" "${KUBECTL_ARGS[@]}" -n "$NAMESPACE" patch "$resource" "$name" \ + --type=merge --patch-file="$cas_manifest" + else + "$KUBECTL" "${KUBECTL_ARGS[@]}" patch "$resource" "$name" \ + --type=merge --patch-file="$cas_manifest" + fi +} + +agent_os_resource_uri() { + local scope=$1 resource=$2 name=$3 + if [ "$scope" = namespaced ]; then + case "$resource" in + pod) printf '/api/v1/namespaces/%s/pods/%s\n' "$NAMESPACE" "$name" ;; + pvc) printf '/api/v1/namespaces/%s/persistentvolumeclaims/%s\n' "$NAMESPACE" "$name" ;; + service) printf '/api/v1/namespaces/%s/services/%s\n' "$NAMESPACE" "$name" ;; + serviceaccount) printf '/api/v1/namespaces/%s/serviceaccounts/%s\n' "$NAMESPACE" "$name" ;; + statefulset) printf '/apis/apps/v1/namespaces/%s/statefulsets/%s\n' "$NAMESPACE" "$name" ;; + role) printf '/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles/%s\n' "$NAMESPACE" "$name" ;; + rolebinding) printf '/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s\n' "$NAMESPACE" "$name" ;; + *) echo "error: unsupported namespaced resource: $resource" >&2; return 2 ;; + esac + else + case "$resource" in + namespace) printf '/api/v1/namespaces/%s\n' "$name" ;; + clusterrolebinding) printf '/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/%s\n' "$name" ;; + *) echo "error: unsupported cluster resource: $resource" >&2; return 2 ;; + esac + fi +} + +agent_os_delete_preconditioned() { + local scope=$1 resource=$2 name=$3 token=$4 uid resource_version uri timeout + [ -n "$token" ] || return 0 + timeout=${AGENT_OS_DELETE_TIMEOUT_SECONDS:-120} + case "$timeout" in + ''|*[!0-9]*) + echo "error: AGENT_OS_DELETE_TIMEOUT_SECONDS must be a non-negative integer" >&2 + return 2 + ;; + esac + uid=${token%%|*} + resource_version=${token#*|} + uri=$(agent_os_resource_uri "$scope" "$resource" "$name") + printf '{"apiVersion":"v1","kind":"DeleteOptions","preconditions":{"uid":"%s","resourceVersion":"%s"}}\n' \ + "$uid" "$resource_version" | "$KUBECTL" "${KUBECTL_ARGS[@]}" delete --raw="$uri" -f - + agent_os_wait_for_uid_gone "$scope" "$resource" "$name" "$uid" "$timeout" +} + +agent_os_current_uid() { + local scope=$1 resource=$2 name=$3 + if [ "$scope" = namespaced ]; then + "$KUBECTL" "${KUBECTL_ARGS[@]}" -n "$NAMESPACE" get "$resource" "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.uid}' + else + "$KUBECTL" "${KUBECTL_ARGS[@]}" get "$resource" "$name" --ignore-not-found \ + -o 'jsonpath={.metadata.uid}' + fi +} + +agent_os_wait_for_uid_gone() { + local scope=$1 resource=$2 name=$3 uid=$4 timeout=$5 deadline current_uid + deadline=$((SECONDS + timeout)) + while :; do + current_uid=$(agent_os_current_uid "$scope" "$resource" "$name") + [ "$current_uid" = "$uid" ] || return 0 + if [ "$SECONDS" -ge "$deadline" ]; then + echo "error: timed out waiting for $resource/$name UID $uid to disappear" >&2 + return 1 + fi + sleep 1 + done +} diff --git a/bin/agent-os-source-context.sh b/bin/agent-os-source-context.sh new file mode 100755 index 000000000..f89680ded --- /dev/null +++ b/bin/agent-os-source-context.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Build a credential-free source context from one committed Git tree. +# Usage: bin/agent-os-source-context.sh [] +set -euo pipefail + +usage() { + echo "usage: $0 []" >&2 + exit 2 +} + +[ "$#" -ge 1 ] && [ "$#" -le 2 ] || usage + +destination=$1 +revision=${2:-HEAD} +script_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +repository=${AGENT_OS_SOURCE_REPO:-$script_root} + +commit=$(git -C "$repository" rev-parse --verify "$revision^{commit}") + +if [ -e "$destination" ]; then + [ -d "$destination" ] || { echo "error: destination exists and is not a directory: $destination" >&2; exit 1; } + [ -z "$(find "$destination" -mindepth 1 -maxdepth 1 -print -quit)" ] \ + || { echo "error: destination must be empty: $destination" >&2; exit 1; } +else + mkdir -p "$destination" +fi + +git -C "$repository" archive --format=tar "$commit" | tar -xf - -C "$destination" + +printf 'source_commit=%s\n' "$commit" +printf 'source_tree=%s\n' "$(git -C "$repository" rev-parse "$commit^{tree}")" diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 873b06685..69dc0b53b 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -99,8 +99,9 @@ # command 'auth'" reply (cli/cmux.swift, authenticateSocketClientIfNeeded). # # Requires: cmux (CLI, bundled inside cmux.app - not guaranteed to be on PATH; -# see fm_backend_cmux_bin), jq (JSON parsing). Both are gated behind selecting -# this backend; bin/fm-bootstrap.sh's core tool list is unaffected. +# see fm_backend_cmux_bin), jq (JSON parsing). Bootstrap detects these through +# fm_backend_required_tools only when cmux is the resolved backend; this adapter +# also gates them again before spawning. # FM_HOME fallback: every real caller already sets FM_HOME as a global before # sourcing fm-backend.sh (which sources this file); this exists only so this diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index fbf394518..7874988b4 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -34,8 +34,9 @@ # verification doc) uses LABEL matching (fm- tab labels), never trusts a # stored pane id blindly: fm_backend_herdr_list_live. # -# Requires: herdr (CLI + socket), jq (JSON parsing). Both are gated behind -# selecting this backend; bin/fm-bootstrap.sh's core tool list is unaffected. +# Requires: herdr (CLI + socket), jq (JSON parsing). Bootstrap detects these +# through fm_backend_required_tools only when herdr is the resolved backend; +# this adapter also gates them again before spawning. # FM_HOME fallback: every real caller (fm-spawn.sh, fm-peek.sh, fm-send.sh, # fm-teardown.sh, fm-watch.sh, fm-crew-state.sh) already sets FM_HOME as a diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 02fc5313f..301b917ea 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -39,17 +39,17 @@ # posture. Moving/relocating a firstmate installation changes its tag # (acceptable - recorded worktree paths do not survive a move either). # -# Empirical verification (real zellij 0.44.0, macOS aarch64, 2026-07-02; -# docs/zellij-backend.md has the full evidence log) resolved every "gaps to -# verify" item in the design report, plus additional real findings not -# anticipated by the report: +# Empirical verification is recorded in docs/zellij-backend.md. +# It resolved every design-report verification gap and records the additional +# implementation findings summarized below: # # 1. dump-screen on a background session with NO attached client: WORKS. # 2. Key names: Enter -> "Enter", Escape -> "Esc" (NOT "Escape"), Ctrl-C -> # "Ctrl c" as ONE shell argument with an embedded space (NOT two argv # words, NOT "C-c" or "Ctrl+c" - all verified to fail). -# 3. `new-tab --cwd --name` DOES return the created tab's bare integer id on -# stdout, exactly as documented. +# 3. `new-tab --cwd --name` usually returns the created tab's bare integer id +# on stdout, but can succeed with empty stdout after a tab is closed and +# recreated. The adapter recovers that id by the unique scoped tab name. # 4. `list-panes --json`'s `pane_cwd` reflects a `cd` run DIRECTLY in the # pane's own top-level shell within one poll (<0.3s) - but does NOT # reflect a `cd` performed by a NESTED SUBSHELL the pane's shell @@ -101,8 +101,9 @@ # `close-tab-by-id`, which verified cleanly removes a live tab (pane and # all) in one call - never a separate close-pane first. # -# Requires: zellij (CLI), jq (JSON parsing). Both are gated behind selecting -# this backend; bin/fm-bootstrap.sh's core tool list is unaffected. +# Requires: zellij (CLI), jq (JSON parsing). Bootstrap detects these through +# fm_backend_required_tools only when zellij is the resolved backend; this +# adapter also gates them again before spawning. # FM_HOME fallback: every real caller already sets FM_HOME as a global before # sourcing fm-backend.sh (which sources this file); this exists only so this @@ -323,9 +324,36 @@ fm_backend_zellij_tab_matches_label() { #