@ours.network/fleet 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -454,10 +454,10 @@ role's `harness_options`, so a fleet can set common Codex permission/profile def
454
454
  and override individual keys per role. `monitor` merges the same way — a role block
455
455
  overrides `defaults.monitor` key-by-key.
456
456
 
457
- Every supervised role is a client of the operator-configured ours daemon. Fleet
458
- forces `OURS_AUTOSTART=0` in both tmux and ACP harness processes, after role environment
459
- overlays, so `env.OURS_AUTOSTART` cannot transfer shared daemon lifecycle ownership to an
460
- agent. Operators and explicit installer/setup flows remain responsible for starting it.
457
+ Every supervised role is a client of the operator-configured ours daemon. Fleet strips the
458
+ obsolete, presence-sensitive `OURS_AUTOSTART` variable from both tmux and ACP harness
459
+ processes. `ours-mcp proxy` is client-only and never starts a daemon; operators and explicit
460
+ installer/setup flows remain responsible for starting it.
461
461
 
462
462
  ### Rooms and tasks
463
463
 
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.0.2",
3
- "buildId": "130a4cf27fc0",
4
- "commit": "24c4ae325fc37fb929ef68342ad603ee9dc1692a",
2
+ "version": "1.0.3",
3
+ "buildId": "15d1045185cb",
4
+ "commit": "d73334a7972745475a038c953258dd079d4e6e48",
5
5
  "dirty": false,
6
- "builtAt": "2026-08-25T13:12:24.502Z",
6
+ "builtAt": "2026-08-25T19:23:08.031Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
package/dist/docs.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Keep this concise enough to place directly in an agent context. Unlike
5
5
  * Commander's per-command help, this describes how the pieces compose.
6
6
  */
7
- export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\nours-fleet version [--json] # build identity, capabilities, every install on PATH\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\nThe CLI never writes the base file: `spawn` writes `~/fleet.d/Name.yaml`. The\nweb console does write it, as a whole document \u2014 its setup wizard and\nconfiguration editor may create, change or remove any top-level block, including\n`vars:`, `defaults:`, `roles:`, `watchdogs:` and `loops:`. Only the base\nfile may hold `defaults:`, `watchdogs:` and `loops:`; a fleet.d drop-in may\ndeclare `roles:` and nothing else. Unrecognised top-level keys are round-tripped\nuntouched. Console edits are applied as surgical splices against the file's exact\nbytes, so an unchanged save is byte-identical and lines outside the edit keep their\ncomments and spacing. One exception: changing the length of a block sequence\n(`watch:`, `oversee:`, `roles:`, `wake_sources:`) may replace that collection\nwholesale and drop inline comments written on its items; lines outside that\ncollection remain byte-preserved. Each save is revision-guarded, reviewed as a diff\nof the real file before anything is written, validated by the real loader, and\nbacked up next to the file first.\n\n## Build identity and install provenance\n\n`--version` prints a semver and nothing else, and a semver does NOT identify an\nartifact. Version bumps land in a release commit of their own, so every build cut\nbetween two releases carries the PREVIOUS version while already containing new\nbehaviour. One host ran two installs that both reported 0.16.0 \u2014 same version,\ndifferent build. One accepted `monitor.interrupt: after_tool`, the other\nrejected it as invalid. Their\n`dist/cli.js` were byte-identical \u2014 the divergence was in other modules.\n\nEvery build therefore stamps `dist/build-info.json` with a build id (first 12 hex\nof a sha256 over the rest of `dist/`), the commit it was cut from, and the\ncapability tokens the shipped code declares \u2014 for example\n`monitor.interrupt.after_tool`. Ask any executable what it is:\n\n```sh\nours-fleet version # ours-fleet 0.17.0+9f1c2a3b4d5e, capabilities, PATH installs\nours-fleet version --json # the same as machine-readable JSON, no environment values\n```\n\nRead a capability, never a version number, to decide whether a setting is\nsupported. When a build rejects a value it knows the name of, it says which\ncapability is missing and which build rejected it, because another install on the\nsame host may accept the identical file. `config` prints the build that resolved\nthe plan; `status <Name>` says so when the build reporting on a role is not the\none that created it (roles record their creating build in `creation.json`).\n\n`ours-fleet doctor` runs an `install` check that lists every `ours-fleet` on\nPATH plus the one executing, and FAILS when two installs share a semver but are\ndifferent builds, or when the running artifact is a DIFFERENT artifact from the\none PATH resolves to. A second prefix holding identical content is not a skew\nand is not reported. A PATH entry the shell would not execute \u2014 a directory, or\na file without its execute bit \u2014 is not counted as an install at all.\nInstalls built before this stamp existed report `+unknown`; they are compared by\nhashing their `dist/` instead, so two pre-provenance installs are still told\napart. To fix a flagged host, remove or update the stale install \u2014 do not rely on\nPATH order.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is IPv4-loopback-only by default. Both `localhost` and\n`127.0.0.1` are accepted locally. For an nginx/TLS reverse proxy, keep the\ndefault bind and declare the exact browser origin:\n\n`ours-fleet web install --public-origin https://fleet.example.com --password-file /secure/fleet-password`\n\nFleet reads the password file during setup and persists only a salted scrypt\nverifier. New browsers authenticate and retain rotating HttpOnly/SameSite\ntrusted-device credentials. If nginx already authenticates, the operator may\ndeliberately select `--no-password`; the CLI and browser warn that anyone\nreaching the origin can control the fleet. First setup requires an explicit\nchoice: `--password-file` or `--pairing` for protected access, or\n`--no-password` for intentional unprotected access.\n\nUse `--bind ADDRESS` only for an intentional direct listen. A non-loopback\nbind is rejected unless `--public-origin` is also present. Host/Origin checks\nuse the declaration and do not trust forwarded headers. Configure nginx to\nproxy HTTP and WebSocket upgrades to `127.0.0.1:49271` and terminate TLS;\nfleet accepts nginx's loopback upstream Host, so no Host rewrite is required.\nBrowser credentials add Secure for HTTPS, and `revoke-all` invalidates all\ntrusted devices. Role creation offers harness-scoped known-model choices\nwhile still accepting a typed model ID; blank explicitly uses the selected\nharness's own default.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] [Name | --role Name] \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|auto|allow \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes active state under `~/.ours-fleet/tmp` and starts an independent\ntransient supervisor (a collected systemd unit or submitted launchd job). It is\nnot enabled across reboot and does not die when the role that spawned it restarts.\nBoth lifetimes support `--session acp`. When a temporary role's bound identity\ncloses or its session ends, the supervisor, monitor and live roster entry retire\ntogether; state moves intact to `~/.ours-fleet/recovery/temporary` with a\ntermination record. Failed launches use the same archive rather than deleting\ntheir briefing, provenance, logs or partial supervisor metadata.\n\nNamed `down` and `rm` commands can target an exact state-backed temporary role\neven though it is absent from merged fleet YAML. The recorded transient unit/job\nis authoritative. Missing/incomplete ownership metadata is reconciled only from\nan exact `_run-temp <role>` process-table match: one match may be adopted, zero\nsettles as stopped, and ambiguity or an unreadable table fails closed. Launching\nrecords receive a bounded grace so a not-yet-registered transient unit cannot be\nmistaken for a stopped one. Stale recorded supervisors are reclaimed in bounded\nbatches by moving their state to the same recovery archive, never by blind deletion.\n\nEvery temporary role creates a new session-owned identity by calling ours MCP\n`create_temporary_identity` with its exact assigned name. It never binds a\npre-existing identity and never falls back to permanent `create_identity`.\nFleet does not inspect, preserve, or provision an ours identity for temporary\nspawn; creation belongs exclusively to the launched temporary agent session.\nCollisions, missing tool support, and creation errors stop safely without\nforce-adopting or deleting identity state. Permanent roles\nare provisioned by fleet before launch and never delegate normal identity\ncreation to the harness.\n\nThe temporary supervisor treats its first positive identity observation as the\nlifecycle readiness gate: a cold harness may take as long as needed to read its\nbriefing and bind, without a fixed first-bind retirement timer. After readiness,\nonly sustained authoritative absence closes the role. Unreachable, malformed, or\nvalid-but-empty daemon indexes are ambiguous and reset closure debounce rather\nthan becoming cleanup authority.\n\nInside a managed ACP role, the same CLI automatically routes a real `spawn`\nthrough that role's authenticated supervisor control socket. `--role Name` is\naccepted as an alternative to the positional name, so a minimal delegated call\nis `ours-fleet spawn --role DeveloperX --temp`. The supervisor records the\ncalling role, performs creation, and only after success sends a structured\nspawn notice through the caller's owner channel when one is configured.\n\nOmitted harness, session, working directory, coordinator, neutral permissions,\nfleet monitor policy, and (when the harness is unchanged) model inherit from the\ncalling role. Explicit options always win. Selecting a different harness without\n`--model` leaves model selection to that harness/fleet defaults rather than\ncopying an incompatible caller model. This automatic proxy is a convenience and\nattribution mechanism, not an isolation boundary: an unrestricted role can still\ninvoke another binary path directly. Tmux roles and host/operator shells keep the\nordinary direct CLI behavior.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # false queues; true cancels; after_tool steers at an ACP tool boundary\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n mcp_servers: # claude-code: per-role MCP servers, additive by default\n ours: { command: ours-mcp, args: [proxy] }\n mcp_servers_only: false # true = ONLY these; drops user/project/plugin servers\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\nwatchdogs:\n nightwatch: # [A-Za-z0-9_-], must not collide with a role name\n coordinator: FleetCoordinator # required \u2014 where alerts go\n # everything below is optional\n enabled: true # default true; false = configured but never scheduled\n interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m\n watch: [Alice, CodexReviewer] # explicit lists are exact; omit for configured + live temp roles\n harness: claude-code # default: defaults.harness\n model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)\n session: acp # default: defaults.session\n identity: Watchdog-nightwatch # default: Watchdog-<name>\n timeout: 5m # default 5m; a run past this is killed and recorded as error\n keep_reports: 50 # default 50 reports retained per watchdog\n alert_cooldown: 60m # default 60m before the same finding alerts again\n prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract\n isolation: # optional; omitted means no OS sandbox, like an ordinary role\n backend: bubblewrap # when present, the ordinary role isolation schema applies\n network: broker\n fs: { read: [/opt/watch-data] }\n```\n\nA watchdog observes and reports; it never restarts, stops, spawns, or removes a\nrole, answers a pending permission, edits a workspace, or approves anything on\nthe owner's behalf. `watchdogs:` may appear only in the base config\n(`~/fleet.yaml` or `-c FILE`), not in `~/fleet.d/*.yaml` drop-ins.\nWatchdogs are not isolated by default. An explicit watchdog `isolation:` block\nuses the same policy schema as a role and is applied unchanged; declare every\nextra filesystem access required by a custom prompt there.\nWhen `watch:` is omitted, each run watches the configured roles plus temporary\nfleet roles that are live when the run starts. An explicit `watch:` list is\nnever augmented.\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\nSupervised roles connect to the operator-configured ours daemon; they do not own its\nlifecycle. Fleet forces `OURS_AUTOSTART=0` in tmux and ACP child processes after role\nenvironment overlays. Start the shared daemon only through an explicit operator or\ninstaller/setup flow.\n\n## Rooms and tasks\n\nRooms always use `ours-cowork`; there is no room-provider selector. Configure\nthe cowork daemon connection and room owner directly:\n\n```yaml\nrooms:\n cowork:\n config: /home/me/.ours-cowork/config.json\n owner:\n expected_cid: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n public_invite_file: /home/me/.ours-fleet/owner-room-invite.txt\n defaults:\n template: team\n attach_owner: true\n close_when_task_done: true\ntasks:\n default_room_template: team\n create_mode: start\n close_room_on_done: true\n```\n\nFleet launches each template member with a dedicated one-time Cowork invite.\nThe generated temporary-agent briefing contains the exact identity name, invite,\nCowork role, and task. The agent creates that identity itself with ours MCP\n`create_temporary_identity`, accepts the invite with `add_contact`, and starts\nwork immediately. Fleet activates the room from Cowork's authenticated seat; there\nis no briefing hash, startup ACK, or separate role-briefing readiness gate.\n\nHuman task and room results use the same compact Markdown presentation in the\nCLI and authenticated owner channel: a short heading, icon-plus-word status,\ncode-formatted identifiers, bounded summaries, and actionable recovery or error\nsteps. Untrusted prose is context-escaped and control characters are neutralized;\nMessenger-bound results are capped at 3,500 Unicode code points and 12,000 UTF-8\nbytes with structural omission notices. `--json` bypasses this presentation layer\nand retains the versioned machine schema and serialization order.\n\nOlder prerelease files with the exact legacy `provider: cowork` key under\n`rooms:` still load, but the key is ignored and omitted from resolved\nconfiguration. Remove it when editing the file. Any other legacy value is an\nerror. The optional `rooms.owner.provider` setting is separate and defaults to\n`messenger-server`.\n\nFinish and Delete are distinct terminal task actions:\n\n`ours-fleet task finish <id>` moves an active or review task to `done` and\ndeletes its associated Cowork room after retiring its members. The room then\ndisappears from normal Fleet and Cowork views; its brief, messages, repository\nreferences, and attachments are not retained as an inspectable archive.\nThe prerelease configuration names `tasks.close_room_on_done` and\n`rooms.defaults.close_when_task_done` are retained for compatibility, but\n`true` now means this close-then-delete behavior.\n\n`ours-fleet room delete <id> <id>` is the canonical destructive room command.\n`room close <id> <id>` remains a deprecated alias with identical deletion\nsemantics. Older prerelease `closed` room records are deleted directly the next\ntime `room list` reconciles Fleet with Cowork.\n\n`ours-fleet task delete <id> <id>` removes only a `done` task's Fleet backlog\nrecord. The exact task ID is required twice for confirmation. Delete rejects every\nother task state, never changes any room state, and reports an already-missing\ntask as an idempotent no-op. The Owner-channel equivalent is\n`/task delete <id> <id>`.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|auto|allow`: portable permission policy. `deny` remains a\n deprecated, fail-closed compatibility alias for existing fleet files.\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `ask` maps to Codex `untrusted` and Claude `default`.\n`auto` selects Codex ACP `agent` (`on-request` + `workspace-write`) and\nClaude `acceptEdits`. `approval: allow` selects Codex ACP's fully\nnon-interactive yolo mode, reported as `agent-full-access` (`never` +\n`danger-full-access`), and Claude `bypassPermissions`. Codex tmux retains\nindependent approval and sandbox flags: `auto` is `on-request`, `allow`\nis `never`, and `filesystem` still selects the sandbox. These modes genuinely\npermit the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` becomes non-interactive. Legacy `deny` keeps\nits conservative Codex `on-request` / Claude `plan` translation. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nACP carries agent-advertised session mode IDs and `session/set_mode`, but those\nIDs are agent-specific and ACP defines no portable permission-policy capability.\nFleet therefore uses the ACP primitive where an adapter exposes a matching mode\nand otherwise performs the harness translation above. The bundled Codex ACP\nadapter couples approval and sandboxing in its advertised mode IDs. Neutral\n`allow` therefore selects `agent-full-access` and widens `filesystem:\nworkspace` or `read-only` to `danger-full-access`; neutral `auto` selects\n`agent` and `workspace-write` even when the neutral filesystem value differs.\nAn explicit `harness_options.sandbox` selects its corresponding ACP preset and\nstill wins, as does an explicit native approval override. `config` and\n`doctor` report a coupled-mode mismatch as approximate. Use per-role\n`isolation:` as the outer boundary for an `allow` ACP role. The live session\nreports both its effective normalized mode and the exact native mode selected.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`,\n`mem_palace_midsession_autosave`, `mcp_servers` and `mcp_servers_only`.\n\n`mcp_servers` declares MCP servers for the role, in `.mcp.json`'s own shape\n(a map of name to `{ command, args, env }`, or `{ type: http|sse, url,\nheaders }`). By default they are ADDED to whatever the OS user running the role\nalready has configured, on both session types: tmux passes `--mcp-config`, and\nACP sends them in `session/new`.\n\nWhen `mcp_servers` is absent, Fleet omits ACP's `mcpServers` field so the\nagent keeps its inherited servers. An explicit empty ACP server array is different:\nit is forwarded as `[]` and disables every inherited server.\n\n`mcp_servers_only: true` makes the declared set EXCLUSIVE \u2014 `--strict-mcp-config`\non tmux, `strictMcpConfig` on ACP. It is all-or-nothing and it ignores every\nother MCP configuration: project `.mcp.json`, user settings, and **plugins**.\nThe ours connector is normally installed as a plugin, so a strict role that does\nnot re-declare it has no `send_message` and no `get_messages` \u2014 it cannot even\nreport that it has gone mute. Fleet therefore refuses a strict role whose\n`mcp_servers` does not name the connector; declare it explicitly, e.g.\n`ours: { command: ours-mcp, args: [proxy] }`.\n\nBoth options, and `plugins`, reach an ACP session through the bundled Claude ACP\nagent's `_meta` vocabulary. A role that sets `session_options.acp.command` runs\nan agent fleet did not choose and cannot be promised them, so that combination is\nrefused at validation rather than accepted and dropped. This narrows a role's\ntool surface; it does not stop the harness deferring tool schemas, which is the\nharness's own decision.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. Set it to `after_tool` to preserve an active ACP tool (and any\npending permission), then steer the wake at the first tool-terminal boundary\nwithout cancellation. A hung boundary is bounded at 120 seconds and falls back\nto non-cancelling steering/queueing; adapters without authenticated tool events\nuse the same conservative fallback. Explicit human/control interrupts remain\nimmediate. The policy is content-blind because the supervisor cannot inspect\nencrypted message bodies. Message bodies are released only when the role calls\nthe ours `get_messages` tool.\n\nThe default is `false`. For a temporary role whose mission intentionally arrives\nafter its readiness announcement, set `mode: fleet` and `interrupt: true`\nexplicitly. The readiness announcement does not change the transport: the\nmission remains ordinary ours mail, fleet injects only the body-free wake, and\nthe role calls `get_messages` before acting. Every later configured wake uses\nthe same interruption policy.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Trusted owner channel\n\nAn ACP role may declare a separate ours identity which fleet \u2014 never the agent \u2014\ncreates when missing and binds:\n\n```yaml\nowner_channel:\n identity: Coordinator Owner Channel\n owners: [authenticated-owner-contact-cid]\n agent: authenticated-managed-agent-cid\n interrupt: false\n progress_interval_ms: 30000\n comments: true\n attachments:\n enabled: true\n max_files_per_request: 4\n max_file_bytes: 10485760\n max_request_bytes: 20971520\n retention_ms: 86400000\n allowed_mime: [application/pdf, text/plain, image/png, audio/ogg]\n```\n\nPermanent role identities are also reconciled before launch. Fleet creates a\nmissing role identity with local exposure and local auto-accept enabled. A\nmissing owner-channel identity uses the safer inverse policy: both are disabled.\nThe short provisioning lease is released before the agent or channel binds.\nTemporary role identities remain connector-owned because their creating lease\ndefines their cleanup lifetime.\n\nThis does not replace the role identity. Normal identity mail remains untrusted\npeer input: the agent reads it through `get_messages` and replies through\n`send_message`. Mail arriving on the dedicated channel from a CID in `owners`\nis injected as a direct `[fleet-owner]` prompt. Mail from the exact `agent`\nCID is forwarded as a new message to the latest authenticated owner conversation;\nits files may also be relayed through this channel. A reply reference selects the\nowner of that authenticated source wire instead of the latest conversation.\nEvery other CID is rejected and warned about without reflecting its body. Fleet sends\naccepted/queued/progress/interrupted/failure notices and routes the ACP turn's\nfinal assistant text back to the authenticated sender with its source wire ID.\nFor file replies of every kind \u2014 a response artifact, a proactive note, or an\nin-turn attachment \u2014 the agent calls ours `send_file` to the channel identity\nand may pair it with a reply-linked caption; fleet, not the agent, chooses the\nowner. That is the only delivery route an agent is given: a tool call either\ndelivers or reports an error, where a file written to disk does neither.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/comments [status|on|off]`, `/interrupt`, `/clear`,\n`/compact`, `/model <model-id>`, `/restart`, `/force-restart`, `/ls`,\n`/peek`, `/worklog`, and\n`/version`. Unknown or malformed commands answer with the help text instead of\nbeing forwarded; plain messages reach the agent unchanged. `/clear`,\n`/compact`, and `/model` are forwarded only when the role's bundled ACP\nadapter executes them locally (claude-code: all three; codex: `/compact`\nonly) and are otherwise refused with a notice, so slash text never reaches the\nmodel as a prompt.\n\nWhile a request runs, the agent's live ACP commentary is relayed as messages\nprefixed with the single stable label `\uD83D\uDFE1 Live update:`, so an owner can see\nexactly which messages the setting controls. `owner_channel.comments`\n(default `true`, so existing channels keep their current behavior) is the\nRESTART BASELINE; `/comments on|off` changes only the running session and is\ndeliberately not persisted, so a restart always returns to the checked-in\nconfiguration. `/comments status` reports the live value, the baseline, and\nwhether the backend emits live comments at all. Suppressing live comments never\nsuppresses receipts, progress notices, or the final answer.\n\nOwner documents, images, and voice messages use the same authenticated sender\nand source-wire boundary. Fleet inspects body-free metadata first and rejects\ndisabled, over-count, over-size, or disallowed-MIME requests before selective\nretrieval. Unauthorized CIDs are never retrieved or answered. Reply-linked text\nand files from the same sender become one ordered request; a file-only wake also\nstarts a turn. Retrieved bytes must match their structured size and SHA-256,\ntheir content signature must match the declared MIME, and symlinks or non-regular\npaths fail closed. Sanitized copies live only in a mode-0700 request directory as\nmode-0600 files and are removed after completion or bounded stale retention.\n\nVoice prompts include a bounded transcript only when typed daemon metadata reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\ninput for direct review. Run `ours config show --json` and inspect `sttConfigured` without\nrevealing provider credentials.\nA mode-0600 message claim journal stores only wire ID, persistent-history\nsequence, and claim time. Fleet journals the exact body-free oldest-first slice\nbefore calling `getMessages` with that slice length, rejects a returned set\nmismatch, and loads a crash-recovered body only through `getHistoryItem`.\nThe attachment crash journal contains only authenticated CID and wire routing\ndata; it never stores captions, filenames, paths, transcript text, or bytes.\nJournaled read files resume through `getFileInfo` and `fetchFile`. A claimed\nagent caption is loaded from history and rejoined before the group is admitted. Fleet\nresolves one authenticated owner route before retrieving bytes, admits every file\nbefore emitting the caption or any file, and sends every part to that same route.\nUnknown correlated routes remain queued without retrieval and receive one bounded\ncorrelated notice. Admission rejection consumes the whole group with one NACK;\nonce emission starts, a transport error becomes terminal uncertain delivery and\nthe group is never blind-retried. Bounded v2 source-wire routing state is migrated\nfrom v1 on read. Corrupt state disables attachment admission rather than weakening\nprovenance checks.\n\nThe channel identity must be unique and must not be a role identity. The bridge\npersists bounded wire IDs only, never message/reply plaintext, and requeues input\nbefore starting its turn for at-least-once crash recovery. It currently requires\n`session: acp`: tmux has no structured, turn-correlated final answer, and pane\nscraping cannot provide the same reliable reply guarantee.\n\n### Live contact and owner administration\n\nThe supervisor which is already running the ACP role remains the sole binder of\n`owner_channel.identity`. The CLI reaches that exact live `OwnerChannel`\nthrough the role's token-authenticated, mode-0600 Unix control socket for contact\ninspection and setup; it never starts another ours client and never force-binds:\n\nRapid supervised restart is serialized by a role-scoped single-binder lease.\nThe predecessor closes its authenticated control socket and MCP proxy before\nreleasing ownership. The replacement waits at most five seconds and retries the\ndaemon bind only when PID/start-marker metadata proves the holder was the same\nrole and owner-channel identity. Foreign, live, corrupt, or otherwise\nunverifiable ownership remains fail-closed; fleet never uses `force=true`.\n\nIf that matching predecessor misses the bound, its still-authenticated control\nroute may send one fixed, digest-deduplicated recovery notice through the latest\nauthenticated owner conversation (or the sole configured owner). Notice\nplaintext is never persisted. With no safe deterministic route fleet guesses no\nrecipient and leaves the actionable failure in the web console and role logs.\nThe remote recovery action is `/restart`; inspect repeated failures with\n`ours-fleet logs <Role>` or the web console.\n\n```sh\nours-fleet owner-channel contact list <Role>\nours-fleet owner-channel contact invite <Role> [--name <label>]\nours-fleet owner-channel contact add <Role> (--invite-file <path> | --invite-stdin) [--name <label>]\nours-fleet owner-channel owner list <Role>\nours-fleet owner-channel owner authorize <Role> <exact-64-hex-contact-cid>\nours-fleet owner-channel owner revoke <Role> <exact-64-hex-contact-cid>\n```\n\nContact establishment and owner authorization are separate security steps.\n`contact add` never authorizes: invite redemption is pending until the peer\nverifies it. Once `contact list` reports the established contact, authorize\nits exact immutable CID explicitly. Invite creation emits invite material only\non stdout; acceptance reads it from a file or stdin, not argv.\n\nConfigured `owners` remain the baseline. On legacy channels without `agent`,\nlive authorizations/revocations are an immediately effective, restart-persistent\noverlay. Managed-agent CID gating makes fleet configuration authoritative and\ndisables live owner mutation and direct control-socket sends. `owner list` labels\nbaseline versus dynamic entries and effective status. The atomic mode-0600 file\ncontains bounded CIDs and audit actions only. Corruption disables all effective\nowners and refuses mutation rather than resurrecting authority; revoking the\nlast effective owner is always refused.\n\nA missing/stopped role, tmux session, role without `owner_channel`, unavailable\nMCP client, or a role entering shutdown returns an actionable error with no\nside effects. Management uses no network listener and never logs or persists\ninvite material.\n\nFor any non-final message\u2014progress, blocker, suggestion, or later proactive note\u2014\nthe managed agent calls ordinary ours `send_message` to the channel identity.\nFleet checks only that the authenticated sender CID exactly equals `agent`, then\nforwards the text as a new message. There is no task/request/update type, phase,\nreply correlation, or owner recipient argument. A sole owner is the safe fallback;\nwith multiple owners and no inbound route history the relay fails closed. Devices\nsharing one identity share its CID; separate owner identities hand off the route\nwhen either sends channel mail. The ACP final is separate: fleet extracts it from\nthe completed turn and deterministically replies to the initiating owner wire.\n\nThe bounded mode-0600 route state stores CIDs, wire IDs, timestamps, delivery state,\nand hashes but never message plaintext. Unauthorized attempts produce a bounded\nCID-only owner warning; attempted bodies are neither reflected nor persisted.\n\nFor a mobile owner, establish the contact first, wait for peer verification,\nauthorize its exact CID, and revoke that same CID when access ends. The bounded\nmode-0600 CID overlay survives supervisor restart and remains fail-closed on\ncorruption. Update bodies remain memory-only. After a crash/restart, unfinished\ndeferred owner input follows the existing at-least-once replay path; the restarted\nsupervisor remains the sole binder.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nWORKLOG rotation is enabled by default with\n`worklog: { max_kb: 1024, keep_tail_kb: 256, max_archives: 12 }`. Maps may\noverride individual values; `worklog: false` on a role or in defaults opts out.\nFleet rotates only at that role's launch/resume lifecycle boundary. Concurrent\nchanges defer rotation. The active file keeps a bounded UTF-8 tail and advances\nto a line boundary when a complete line fits. If one logical line alone exceeds\nthe budget, its newest suffix remains and the rotation manifest records the\nmid-line start and omitted byte count. The complete prior inode receives a\ncollision-safe UTC archive name, and\n`.worklog-rotation.json` records restart provenance. `max_archives` bounds\nrecent archives beside WORKLOG.md; older complete archives move to\n`WORKLOG.archives/` without deletion. All archives share the role's sensitive\nstate boundary. Fleet refuses a symlinked/non-regular live log or a symlinked\ncold-archive boundary before replacing the live path and best-effort removes a\nduplicate publication left by a detected failure while the original inode is\nstill available. The manifest records SHA-256 digests for the archive and live\nbytes observed when it is written. These checks address ordinary path hazards,\nnot intentional path mutation by a malicious concurrent process with the same\nUnix authority; that is outside the threat model and requires OS-level isolation.\n\nACP tool diffs are bounded before entering web conversation events. Existing\nsmall before/after diffs are unchanged. Oversized whole-file snapshots are\nreduced to the actual changed region plus path, operation, original byte counts,\ndigest, and omission metadata. Each retained side is a newest-content UTF-8 tail\nof at most 64 KiB, advanced to a line boundary when a complete line fits. An\noverlong single line keeps its newest suffix and explicitly records a mid-line\nstart. Paths retain at most a 4 KiB suffix with byte count, digest, and omitted\nprefix metadata; the complete normalized update is capped at 320 KiB. A large\nappend therefore retains current appended content, not the historical prefix.\nThe live web-console transcript includes only the current runner generation and\nexcludes adapter session/load replay. Replayed events remain durable with\nagent_replay provenance for diagnosis and recovery.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
7
+ export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\nours-fleet version [--json] # build identity, capabilities, every install on PATH\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\nThe CLI never writes the base file: `spawn` writes `~/fleet.d/Name.yaml`. The\nweb console does write it, as a whole document \u2014 its setup wizard and\nconfiguration editor may create, change or remove any top-level block, including\n`vars:`, `defaults:`, `roles:`, `watchdogs:` and `loops:`. Only the base\nfile may hold `defaults:`, `watchdogs:` and `loops:`; a fleet.d drop-in may\ndeclare `roles:` and nothing else. Unrecognised top-level keys are round-tripped\nuntouched. Console edits are applied as surgical splices against the file's exact\nbytes, so an unchanged save is byte-identical and lines outside the edit keep their\ncomments and spacing. One exception: changing the length of a block sequence\n(`watch:`, `oversee:`, `roles:`, `wake_sources:`) may replace that collection\nwholesale and drop inline comments written on its items; lines outside that\ncollection remain byte-preserved. Each save is revision-guarded, reviewed as a diff\nof the real file before anything is written, validated by the real loader, and\nbacked up next to the file first.\n\n## Build identity and install provenance\n\n`--version` prints a semver and nothing else, and a semver does NOT identify an\nartifact. Version bumps land in a release commit of their own, so every build cut\nbetween two releases carries the PREVIOUS version while already containing new\nbehaviour. One host ran two installs that both reported 0.16.0 \u2014 same version,\ndifferent build. One accepted `monitor.interrupt: after_tool`, the other\nrejected it as invalid. Their\n`dist/cli.js` were byte-identical \u2014 the divergence was in other modules.\n\nEvery build therefore stamps `dist/build-info.json` with a build id (first 12 hex\nof a sha256 over the rest of `dist/`), the commit it was cut from, and the\ncapability tokens the shipped code declares \u2014 for example\n`monitor.interrupt.after_tool`. Ask any executable what it is:\n\n```sh\nours-fleet version # ours-fleet 0.17.0+9f1c2a3b4d5e, capabilities, PATH installs\nours-fleet version --json # the same as machine-readable JSON, no environment values\n```\n\nRead a capability, never a version number, to decide whether a setting is\nsupported. When a build rejects a value it knows the name of, it says which\ncapability is missing and which build rejected it, because another install on the\nsame host may accept the identical file. `config` prints the build that resolved\nthe plan; `status <Name>` says so when the build reporting on a role is not the\none that created it (roles record their creating build in `creation.json`).\n\n`ours-fleet doctor` runs an `install` check that lists every `ours-fleet` on\nPATH plus the one executing, and FAILS when two installs share a semver but are\ndifferent builds, or when the running artifact is a DIFFERENT artifact from the\none PATH resolves to. A second prefix holding identical content is not a skew\nand is not reported. A PATH entry the shell would not execute \u2014 a directory, or\na file without its execute bit \u2014 is not counted as an install at all.\nInstalls built before this stamp existed report `+unknown`; they are compared by\nhashing their `dist/` instead, so two pre-provenance installs are still told\napart. To fix a flagged host, remove or update the stale install \u2014 do not rely on\nPATH order.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is IPv4-loopback-only by default. Both `localhost` and\n`127.0.0.1` are accepted locally. For an nginx/TLS reverse proxy, keep the\ndefault bind and declare the exact browser origin:\n\n`ours-fleet web install --public-origin https://fleet.example.com --password-file /secure/fleet-password`\n\nFleet reads the password file during setup and persists only a salted scrypt\nverifier. New browsers authenticate and retain rotating HttpOnly/SameSite\ntrusted-device credentials. If nginx already authenticates, the operator may\ndeliberately select `--no-password`; the CLI and browser warn that anyone\nreaching the origin can control the fleet. First setup requires an explicit\nchoice: `--password-file` or `--pairing` for protected access, or\n`--no-password` for intentional unprotected access.\n\nUse `--bind ADDRESS` only for an intentional direct listen. A non-loopback\nbind is rejected unless `--public-origin` is also present. Host/Origin checks\nuse the declaration and do not trust forwarded headers. Configure nginx to\nproxy HTTP and WebSocket upgrades to `127.0.0.1:49271` and terminate TLS;\nfleet accepts nginx's loopback upstream Host, so no Host rewrite is required.\nBrowser credentials add Secure for HTTPS, and `revoke-all` invalidates all\ntrusted devices. Role creation offers harness-scoped known-model choices\nwhile still accepting a typed model ID; blank explicitly uses the selected\nharness's own default.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] [Name | --role Name] \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|auto|allow \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes active state under `~/.ours-fleet/tmp` and starts an independent\ntransient supervisor (a collected systemd unit or submitted launchd job). It is\nnot enabled across reboot and does not die when the role that spawned it restarts.\nBoth lifetimes support `--session acp`. When a temporary role's bound identity\ncloses or its session ends, the supervisor, monitor and live roster entry retire\ntogether; state moves intact to `~/.ours-fleet/recovery/temporary` with a\ntermination record. Failed launches use the same archive rather than deleting\ntheir briefing, provenance, logs or partial supervisor metadata.\n\nNamed `down` and `rm` commands can target an exact state-backed temporary role\neven though it is absent from merged fleet YAML. The recorded transient unit/job\nis authoritative. Missing/incomplete ownership metadata is reconciled only from\nan exact `_run-temp <role>` process-table match: one match may be adopted, zero\nsettles as stopped, and ambiguity or an unreadable table fails closed. Launching\nrecords receive a bounded grace so a not-yet-registered transient unit cannot be\nmistaken for a stopped one. Stale recorded supervisors are reclaimed in bounded\nbatches by moving their state to the same recovery archive, never by blind deletion.\n\nEvery temporary role creates a new session-owned identity by calling ours MCP\n`create_temporary_identity` with its exact assigned name. It never binds a\npre-existing identity and never falls back to permanent `create_identity`.\nFleet does not inspect, preserve, or provision an ours identity for temporary\nspawn; creation belongs exclusively to the launched temporary agent session.\nCollisions, missing tool support, and creation errors stop safely without\nforce-adopting or deleting identity state. Permanent roles\nare provisioned by fleet before launch and never delegate normal identity\ncreation to the harness.\n\nThe temporary supervisor treats its first positive identity observation as the\nlifecycle readiness gate: a cold harness may take as long as needed to read its\nbriefing and bind, without a fixed first-bind retirement timer. After readiness,\nonly sustained authoritative absence closes the role. Unreachable, malformed, or\nvalid-but-empty daemon indexes are ambiguous and reset closure debounce rather\nthan becoming cleanup authority.\n\nInside a managed ACP role, the same CLI automatically routes a real `spawn`\nthrough that role's authenticated supervisor control socket. `--role Name` is\naccepted as an alternative to the positional name, so a minimal delegated call\nis `ours-fleet spawn --role DeveloperX --temp`. The supervisor records the\ncalling role, performs creation, and only after success sends a structured\nspawn notice through the caller's owner channel when one is configured.\n\nOmitted harness, session, working directory, coordinator, neutral permissions,\nfleet monitor policy, and (when the harness is unchanged) model inherit from the\ncalling role. Explicit options always win. Selecting a different harness without\n`--model` leaves model selection to that harness/fleet defaults rather than\ncopying an incompatible caller model. This automatic proxy is a convenience and\nattribution mechanism, not an isolation boundary: an unrestricted role can still\ninvoke another binary path directly. Tmux roles and host/operator shells keep the\nordinary direct CLI behavior.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # false queues; true cancels; after_tool steers at an ACP tool boundary\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n mcp_servers: # claude-code: per-role MCP servers, additive by default\n ours: { command: ours-mcp, args: [proxy] }\n mcp_servers_only: false # true = ONLY these; drops user/project/plugin servers\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\nwatchdogs:\n nightwatch: # [A-Za-z0-9_-], must not collide with a role name\n coordinator: FleetCoordinator # required \u2014 where alerts go\n # everything below is optional\n enabled: true # default true; false = configured but never scheduled\n interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m\n watch: [Alice, CodexReviewer] # explicit lists are exact; omit for configured + live temp roles\n harness: claude-code # default: defaults.harness\n model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)\n session: acp # default: defaults.session\n identity: Watchdog-nightwatch # default: Watchdog-<name>\n timeout: 5m # default 5m; a run past this is killed and recorded as error\n keep_reports: 50 # default 50 reports retained per watchdog\n alert_cooldown: 60m # default 60m before the same finding alerts again\n prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract\n isolation: # optional; omitted means no OS sandbox, like an ordinary role\n backend: bubblewrap # when present, the ordinary role isolation schema applies\n network: broker\n fs: { read: [/opt/watch-data] }\n```\n\nA watchdog observes and reports; it never restarts, stops, spawns, or removes a\nrole, answers a pending permission, edits a workspace, or approves anything on\nthe owner's behalf. `watchdogs:` may appear only in the base config\n(`~/fleet.yaml` or `-c FILE`), not in `~/fleet.d/*.yaml` drop-ins.\nWatchdogs are not isolated by default. An explicit watchdog `isolation:` block\nuses the same policy schema as a role and is applied unchanged; declare every\nextra filesystem access required by a custom prompt there.\nWhen `watch:` is omitted, each run watches the configured roles plus temporary\nfleet roles that are live when the run starts. An explicit `watch:` list is\nnever augmented.\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\nSupervised roles connect to the operator-configured ours daemon; they do not own its\nlifecycle. Fleet strips the obsolete, presence-sensitive `OURS_AUTOSTART` variable from\ntmux and ACP children; `ours-mcp proxy` is client-only and never starts a daemon. Start\nthe shared daemon only through an explicit operator or installer/setup flow.\n\n## Rooms and tasks\n\nRooms always use `ours-cowork`; there is no room-provider selector. Configure\nthe cowork daemon connection and room owner directly:\n\n```yaml\nrooms:\n cowork:\n config: /home/me/.ours-cowork/config.json\n owner:\n expected_cid: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n public_invite_file: /home/me/.ours-fleet/owner-room-invite.txt\n defaults:\n template: team\n attach_owner: true\n close_when_task_done: true\ntasks:\n default_room_template: team\n create_mode: start\n close_room_on_done: true\n```\n\nFleet launches each template member with a dedicated one-time Cowork invite.\nThe generated temporary-agent briefing contains the exact identity name, invite,\nCowork role, and task. The agent creates that identity itself with ours MCP\n`create_temporary_identity`, accepts the invite with `add_contact`, and starts\nwork immediately. Fleet activates the room from Cowork's authenticated seat; there\nis no briefing hash, startup ACK, or separate role-briefing readiness gate.\n\nHuman task and room results use the same compact Markdown presentation in the\nCLI and authenticated owner channel: a short heading, icon-plus-word status,\ncode-formatted identifiers, bounded summaries, and actionable recovery or error\nsteps. Untrusted prose is context-escaped and control characters are neutralized;\nMessenger-bound results are capped at 3,500 Unicode code points and 12,000 UTF-8\nbytes with structural omission notices. `--json` bypasses this presentation layer\nand retains the versioned machine schema and serialization order.\n\nOlder prerelease files with the exact legacy `provider: cowork` key under\n`rooms:` still load, but the key is ignored and omitted from resolved\nconfiguration. Remove it when editing the file. Any other legacy value is an\nerror. The optional `rooms.owner.provider` setting is separate and defaults to\n`messenger-server`.\n\nFinish and Delete are distinct terminal task actions:\n\n`ours-fleet task finish <id>` moves an active or review task to `done` and\ndeletes its associated Cowork room after retiring its members. The room then\ndisappears from normal Fleet and Cowork views; its brief, messages, repository\nreferences, and attachments are not retained as an inspectable archive.\nThe prerelease configuration names `tasks.close_room_on_done` and\n`rooms.defaults.close_when_task_done` are retained for compatibility, but\n`true` now means this close-then-delete behavior.\n\n`ours-fleet room delete <id> <id>` is the canonical destructive room command.\n`room close <id> <id>` remains a deprecated alias with identical deletion\nsemantics. Older prerelease `closed` room records are deleted directly the next\ntime `room list` reconciles Fleet with Cowork.\n\n`ours-fleet task delete <id> <id>` removes only a `done` task's Fleet backlog\nrecord. The exact task ID is required twice for confirmation. Delete rejects every\nother task state, never changes any room state, and reports an already-missing\ntask as an idempotent no-op. The Owner-channel equivalent is\n`/task delete <id> <id>`.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|auto|allow`: portable permission policy. `deny` remains a\n deprecated, fail-closed compatibility alias for existing fleet files.\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `ask` maps to Codex `untrusted` and Claude `default`.\n`auto` selects Codex ACP `agent` (`on-request` + `workspace-write`) and\nClaude `acceptEdits`. `approval: allow` selects Codex ACP's fully\nnon-interactive yolo mode, reported as `agent-full-access` (`never` +\n`danger-full-access`), and Claude `bypassPermissions`. Codex tmux retains\nindependent approval and sandbox flags: `auto` is `on-request`, `allow`\nis `never`, and `filesystem` still selects the sandbox. These modes genuinely\npermit the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` becomes non-interactive. Legacy `deny` keeps\nits conservative Codex `on-request` / Claude `plan` translation. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nACP carries agent-advertised session mode IDs and `session/set_mode`, but those\nIDs are agent-specific and ACP defines no portable permission-policy capability.\nFleet therefore uses the ACP primitive where an adapter exposes a matching mode\nand otherwise performs the harness translation above. The bundled Codex ACP\nadapter couples approval and sandboxing in its advertised mode IDs. Neutral\n`allow` therefore selects `agent-full-access` and widens `filesystem:\nworkspace` or `read-only` to `danger-full-access`; neutral `auto` selects\n`agent` and `workspace-write` even when the neutral filesystem value differs.\nAn explicit `harness_options.sandbox` selects its corresponding ACP preset and\nstill wins, as does an explicit native approval override. `config` and\n`doctor` report a coupled-mode mismatch as approximate. Use per-role\n`isolation:` as the outer boundary for an `allow` ACP role. The live session\nreports both its effective normalized mode and the exact native mode selected.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`,\n`mem_palace_midsession_autosave`, `mcp_servers` and `mcp_servers_only`.\n\n`mcp_servers` declares MCP servers for the role, in `.mcp.json`'s own shape\n(a map of name to `{ command, args, env }`, or `{ type: http|sse, url,\nheaders }`). By default they are ADDED to whatever the OS user running the role\nalready has configured, on both session types: tmux passes `--mcp-config`, and\nACP sends them in `session/new`.\n\nWhen `mcp_servers` is absent, Fleet sends ACP's protocol-required empty\n`mcpServers` array without an exclusive override, so the agent keeps its inherited\nservers. An explicitly empty configured set is different: Fleet preserves that intent\nthrough the bundled adapter's compatibility path and disables every inherited server.\n\n`mcp_servers_only: true` makes the declared set EXCLUSIVE \u2014 `--strict-mcp-config`\non tmux, `strictMcpConfig` on ACP. It is all-or-nothing and it ignores every\nother MCP configuration: project `.mcp.json`, user settings, and **plugins**.\nThe ours connector is normally installed as a plugin, so a strict role that does\nnot re-declare it has no `send_message` and no `get_messages` \u2014 it cannot even\nreport that it has gone mute. Fleet therefore refuses a strict role whose\n`mcp_servers` does not name the connector; declare it explicitly, e.g.\n`ours: { command: ours-mcp, args: [proxy] }`.\n\nBoth options, and `plugins`, reach an ACP session through the bundled Claude ACP\nagent's `_meta` vocabulary. A role that sets `session_options.acp.command` runs\nan agent fleet did not choose and cannot be promised them, so that combination is\nrefused at validation rather than accepted and dropped. This narrows a role's\ntool surface; it does not stop the harness deferring tool schemas, which is the\nharness's own decision.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. Set it to `after_tool` to preserve an active ACP tool (and any\npending permission), then steer the wake at the first tool-terminal boundary\nwithout cancellation. A hung boundary is bounded at 120 seconds and falls back\nto non-cancelling steering/queueing; adapters without authenticated tool events\nuse the same conservative fallback. Explicit human/control interrupts remain\nimmediate. The policy is content-blind because the supervisor cannot inspect\nencrypted message bodies. Message bodies are released only when the role calls\nthe ours `get_messages` tool.\n\nThe default is `false`. For a temporary role whose mission intentionally arrives\nafter its readiness announcement, set `mode: fleet` and `interrupt: true`\nexplicitly. The readiness announcement does not change the transport: the\nmission remains ordinary ours mail, fleet injects only the body-free wake, and\nthe role calls `get_messages` before acting. Every later configured wake uses\nthe same interruption policy.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Trusted owner channel\n\nAn ACP role may declare a separate ours identity which fleet \u2014 never the agent \u2014\ncreates when missing and binds:\n\n```yaml\nowner_channel:\n identity: Coordinator Owner Channel\n owners: [authenticated-owner-contact-cid]\n agent: authenticated-managed-agent-cid\n interrupt: false\n progress_interval_ms: 30000\n comments: true\n attachments:\n enabled: true\n max_files_per_request: 4\n max_file_bytes: 10485760\n max_request_bytes: 20971520\n retention_ms: 86400000\n allowed_mime: [application/pdf, text/plain, image/png, audio/ogg]\n```\n\nPermanent role identities are also reconciled before launch. Fleet creates a\nmissing role identity with local exposure and local auto-accept enabled. A\nmissing owner-channel identity uses the safer inverse policy: both are disabled.\nThe short provisioning lease is released before the agent or channel binds.\nTemporary role identities remain connector-owned because their creating lease\ndefines their cleanup lifetime.\n\nThis does not replace the role identity. Normal identity mail remains untrusted\npeer input: the agent reads it through `get_messages` and replies through\n`send_message`. Mail arriving on the dedicated channel from a CID in `owners`\nis injected as a direct `[fleet-owner]` prompt. Mail from the exact `agent`\nCID is forwarded as a new message to the latest authenticated owner conversation;\nits files may also be relayed through this channel. A reply reference selects the\nowner of that authenticated source wire instead of the latest conversation.\nEvery other CID is rejected and warned about without reflecting its body. Fleet sends\naccepted/queued/progress/interrupted/failure notices and routes the ACP turn's\nfinal assistant text back to the authenticated sender with its source wire ID.\nFor file replies of every kind \u2014 a response artifact, a proactive note, or an\nin-turn attachment \u2014 the agent calls ours `send_file` to the channel identity\nand may pair it with a reply-linked caption; fleet, not the agent, chooses the\nowner. That is the only delivery route an agent is given: a tool call either\ndelivers or reports an error, where a file written to disk does neither.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/comments [status|on|off]`, `/interrupt`, `/clear`,\n`/compact`, `/model <model-id>`, `/restart`, `/force-restart`, `/ls`,\n`/peek`, `/worklog`, and\n`/version`. Unknown or malformed commands answer with the help text instead of\nbeing forwarded; plain messages reach the agent unchanged. `/clear`,\n`/compact`, and `/model` are forwarded only when the role's bundled ACP\nadapter executes them locally (claude-code: all three; codex: `/compact`\nonly) and are otherwise refused with a notice, so slash text never reaches the\nmodel as a prompt.\n\nWhile a request runs, the agent's live ACP commentary is relayed as messages\nprefixed with the single stable label `\uD83D\uDFE1 Live update:`, so an owner can see\nexactly which messages the setting controls. `owner_channel.comments`\n(default `true`, so existing channels keep their current behavior) is the\nRESTART BASELINE; `/comments on|off` changes only the running session and is\ndeliberately not persisted, so a restart always returns to the checked-in\nconfiguration. `/comments status` reports the live value, the baseline, and\nwhether the backend emits live comments at all. Suppressing live comments never\nsuppresses receipts, progress notices, or the final answer.\n\nOwner documents, images, and voice messages use the same authenticated sender\nand source-wire boundary. Fleet inspects body-free metadata first and rejects\ndisabled, over-count, over-size, or disallowed-MIME requests before selective\nretrieval. Unauthorized CIDs are never retrieved or answered. Reply-linked text\nand files from the same sender become one ordered request; a file-only wake also\nstarts a turn. Retrieved bytes must match their structured size and SHA-256,\ntheir content signature must match the declared MIME, and symlinks or non-regular\npaths fail closed. Sanitized copies live only in a mode-0700 request directory as\nmode-0600 files and are removed after completion or bounded stale retention.\n\nVoice prompts include a bounded transcript only when typed daemon metadata reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\ninput for direct review. Run `ours config show --json` and inspect `sttConfigured` without\nrevealing provider credentials.\nA mode-0600 message claim journal stores only wire ID, persistent-history\nsequence, and claim time. Fleet journals the exact body-free oldest-first slice\nbefore calling `getMessages` with that slice length, rejects a returned set\nmismatch, and loads a crash-recovered body only through `getHistoryItem`.\nThe attachment crash journal contains only authenticated CID and wire routing\ndata; it never stores captions, filenames, paths, transcript text, or bytes.\nJournaled read files resume through `getFileInfo` and `fetchFile`. A claimed\nagent caption is loaded from history and rejoined before the group is admitted. Fleet\nresolves one authenticated owner route before retrieving bytes, admits every file\nbefore emitting the caption or any file, and sends every part to that same route.\nUnknown correlated routes remain queued without retrieval and receive one bounded\ncorrelated notice. Admission rejection consumes the whole group with one NACK;\nonce emission starts, a transport error becomes terminal uncertain delivery and\nthe group is never blind-retried. Bounded v2 source-wire routing state is migrated\nfrom v1 on read. Corrupt state disables attachment admission rather than weakening\nprovenance checks.\n\nThe channel identity must be unique and must not be a role identity. The bridge\npersists bounded wire IDs only, never message/reply plaintext, and requeues input\nbefore starting its turn for at-least-once crash recovery. It currently requires\n`session: acp`: tmux has no structured, turn-correlated final answer, and pane\nscraping cannot provide the same reliable reply guarantee.\n\n### Live contact and owner administration\n\nThe supervisor which is already running the ACP role remains the sole binder of\n`owner_channel.identity`. The CLI reaches that exact live `OwnerChannel`\nthrough the role's token-authenticated, mode-0600 Unix control socket for contact\ninspection and setup; it never starts another ours client and never force-binds:\n\nRapid supervised restart is serialized by a role-scoped single-binder lease.\nThe predecessor closes its authenticated control socket and MCP proxy before\nreleasing ownership. The replacement waits at most five seconds and retries the\ndaemon bind only when PID/start-marker metadata proves the holder was the same\nrole and owner-channel identity. Foreign, live, corrupt, or otherwise\nunverifiable ownership remains fail-closed; fleet never uses `force=true`.\n\nIf that matching predecessor misses the bound, its still-authenticated control\nroute may send one fixed, digest-deduplicated recovery notice through the latest\nauthenticated owner conversation (or the sole configured owner). Notice\nplaintext is never persisted. With no safe deterministic route fleet guesses no\nrecipient and leaves the actionable failure in the web console and role logs.\nThe remote recovery action is `/restart`; inspect repeated failures with\n`ours-fleet logs <Role>` or the web console.\n\n```sh\nours-fleet owner-channel contact list <Role>\nours-fleet owner-channel contact invite <Role> [--name <label>]\nours-fleet owner-channel contact add <Role> (--invite-file <path> | --invite-stdin) [--name <label>]\nours-fleet owner-channel owner list <Role>\nours-fleet owner-channel owner authorize <Role> <exact-64-hex-contact-cid>\nours-fleet owner-channel owner revoke <Role> <exact-64-hex-contact-cid>\n```\n\nContact establishment and owner authorization are separate security steps.\n`contact add` never authorizes: invite redemption is pending until the peer\nverifies it. Once `contact list` reports the established contact, authorize\nits exact immutable CID explicitly. Invite creation emits invite material only\non stdout; acceptance reads it from a file or stdin, not argv.\n\nConfigured `owners` remain the baseline. On legacy channels without `agent`,\nlive authorizations/revocations are an immediately effective, restart-persistent\noverlay. Managed-agent CID gating makes fleet configuration authoritative and\ndisables live owner mutation and direct control-socket sends. `owner list` labels\nbaseline versus dynamic entries and effective status. The atomic mode-0600 file\ncontains bounded CIDs and audit actions only. Corruption disables all effective\nowners and refuses mutation rather than resurrecting authority; revoking the\nlast effective owner is always refused.\n\nA missing/stopped role, tmux session, role without `owner_channel`, unavailable\nMCP client, or a role entering shutdown returns an actionable error with no\nside effects. Management uses no network listener and never logs or persists\ninvite material.\n\nFor any non-final message\u2014progress, blocker, suggestion, or later proactive note\u2014\nthe managed agent calls ordinary ours `send_message` to the channel identity.\nFleet checks only that the authenticated sender CID exactly equals `agent`, then\nforwards the text as a new message. There is no task/request/update type, phase,\nreply correlation, or owner recipient argument. A sole owner is the safe fallback;\nwith multiple owners and no inbound route history the relay fails closed. Devices\nsharing one identity share its CID; separate owner identities hand off the route\nwhen either sends channel mail. The ACP final is separate: fleet extracts it from\nthe completed turn and deterministically replies to the initiating owner wire.\n\nThe bounded mode-0600 route state stores CIDs, wire IDs, timestamps, delivery state,\nand hashes but never message plaintext. Unauthorized attempts produce a bounded\nCID-only owner warning; attempted bodies are neither reflected nor persisted.\n\nFor a mobile owner, establish the contact first, wait for peer verification,\nauthorize its exact CID, and revoke that same CID when access ends. The bounded\nmode-0600 CID overlay survives supervisor restart and remains fail-closed on\ncorruption. Update bodies remain memory-only. After a crash/restart, unfinished\ndeferred owner input follows the existing at-least-once replay path; the restarted\nsupervisor remains the sole binder.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nWORKLOG rotation is enabled by default with\n`worklog: { max_kb: 1024, keep_tail_kb: 256, max_archives: 12 }`. Maps may\noverride individual values; `worklog: false` on a role or in defaults opts out.\nFleet rotates only at that role's launch/resume lifecycle boundary. Concurrent\nchanges defer rotation. The active file keeps a bounded UTF-8 tail and advances\nto a line boundary when a complete line fits. If one logical line alone exceeds\nthe budget, its newest suffix remains and the rotation manifest records the\nmid-line start and omitted byte count. The complete prior inode receives a\ncollision-safe UTC archive name, and\n`.worklog-rotation.json` records restart provenance. `max_archives` bounds\nrecent archives beside WORKLOG.md; older complete archives move to\n`WORKLOG.archives/` without deletion. All archives share the role's sensitive\nstate boundary. Fleet refuses a symlinked/non-regular live log or a symlinked\ncold-archive boundary before replacing the live path and best-effort removes a\nduplicate publication left by a detected failure while the original inode is\nstill available. The manifest records SHA-256 digests for the archive and live\nbytes observed when it is written. These checks address ordinary path hazards,\nnot intentional path mutation by a malicious concurrent process with the same\nUnix authority; that is outside the threat model and requires OS-level isolation.\n\nACP tool diffs are bounded before entering web conversation events. Existing\nsmall before/after diffs are unchanged. Oversized whole-file snapshots are\nreduced to the actual changed region plus path, operation, original byte counts,\ndigest, and omission metadata. Each retained side is a newest-content UTF-8 tail\nof at most 64 KiB, advanced to a line boundary when a complete line fits. An\noverlong single line keeps its newest suffix and explicitly records a mid-line\nstart. Paths retain at most a 4 KiB suffix with byte count, digest, and omitted\nprefix metadata; the complete normalized update is capped at 320 KiB. A large\nappend therefore retains current appended content, not the historical prefix.\nThe live web-console transcript includes only the current runner generation and\nexcludes adapter session/load replay. Replayed events remain durable with\nagent_replay provenance for diagnosis and recovery.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
8
8
  /**
9
9
  * What every shipped spawn-skill variant must say, and must not say.
10
10
  *
package/dist/docs.js CHANGED
@@ -320,9 +320,9 @@ Other role fields include \`max_tokens\`, \`autocompact_pct\`, and \`isolation\`
320
320
  Use README.md for the complete isolation policy and resource-cap schema.
321
321
 
322
322
  Supervised roles connect to the operator-configured ours daemon; they do not own its
323
- lifecycle. Fleet forces \`OURS_AUTOSTART=0\` in tmux and ACP child processes after role
324
- environment overlays. Start the shared daemon only through an explicit operator or
325
- installer/setup flow.
323
+ lifecycle. Fleet strips the obsolete, presence-sensitive \`OURS_AUTOSTART\` variable from
324
+ tmux and ACP children; \`ours-mcp proxy\` is client-only and never starts a daemon. Start
325
+ the shared daemon only through an explicit operator or installer/setup flow.
326
326
 
327
327
  ## Rooms and tasks
328
328
 
@@ -515,9 +515,10 @@ headers }\`). By default they are ADDED to whatever the OS user running the role
515
515
  already has configured, on both session types: tmux passes \`--mcp-config\`, and
516
516
  ACP sends them in \`session/new\`.
517
517
 
518
- When \`mcp_servers\` is absent, Fleet omits ACP's \`mcpServers\` field so the
519
- agent keeps its inherited servers. An explicit empty ACP server array is different:
520
- it is forwarded as \`[]\` and disables every inherited server.
518
+ When \`mcp_servers\` is absent, Fleet sends ACP's protocol-required empty
519
+ \`mcpServers\` array without an exclusive override, so the agent keeps its inherited
520
+ servers. An explicitly empty configured set is different: Fleet preserves that intent
521
+ through the bundled adapter's compatibility path and disables every inherited server.
521
522
 
522
523
  \`mcp_servers_only: true\` makes the declared set EXCLUSIVE — \`--strict-mcp-config\`
523
524
  on tmux, \`strictMcpConfig\` on ACP. It is all-or-nothing and it ignores every
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
2
  /** Transform one app-server NDJSON request; malformed input passes through. */
3
- export declare function rewriteCodexAppServerRequest(line: string, approval: string, expectedSandbox: string): string;
3
+ export declare function rewriteCodexAppServerRequest(line: string, approval: string, expectedSandbox: string, disableInheritedMcp?: boolean): string;
4
4
  export declare function runCodexAppServerProxy(): void;
@@ -8,6 +8,7 @@ const APPROVAL_ENV = 'OURS_FLEET_CODEX_APPROVAL';
8
8
  const SANDBOX_ENV = 'OURS_FLEET_CODEX_SANDBOX';
9
9
  const REAL_CODEX_ENV = 'OURS_FLEET_REAL_CODEX_PATH';
10
10
  const ACP_MANIFEST_ENV = 'OURS_FLEET_CODEX_ACP_MANIFEST';
11
+ const DISABLE_INHERITED_MCP_ENV = 'OURS_FLEET_CODEX_DISABLE_INHERITED_MCP';
11
12
  const APPROVAL_POLICIES = new Set(['untrusted', 'on-request', 'never']);
12
13
  const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
13
14
  const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -27,19 +28,30 @@ function sandboxMode(policy) {
27
28
  * Fleet keeps the preset's sandbox policy, but restores the independently
28
29
  * configured approval policy on the request Codex actually executes.
29
30
  */
30
- function rewriteMessage(value, approval, expectedSandbox) {
31
+ function rewriteMessage(value, approval, expectedSandbox, disableInheritedMcp) {
31
32
  if (Array.isArray(value))
32
- return value.map(candidate => rewriteMessage(candidate, approval, expectedSandbox));
33
- if (!isObject(value) || value.method !== 'turn/start' || !isObject(value.params))
33
+ return value.map(candidate => rewriteMessage(candidate, approval, expectedSandbox, disableInheritedMcp));
34
+ if (!isObject(value) || !isObject(value.params))
34
35
  return value;
35
- if (sandboxMode(value.params.sandboxPolicy) !== expectedSandbox)
36
- return value;
37
- return { ...value, params: { ...value.params, approvalPolicy: approval } };
36
+ if (value.method === 'turn/start') {
37
+ if (sandboxMode(value.params.sandboxPolicy) !== expectedSandbox)
38
+ return value;
39
+ return { ...value, params: { ...value.params, approvalPolicy: approval } };
40
+ }
41
+ if (disableInheritedMcp
42
+ && (value.method === 'thread/start' || value.method === 'thread/resume')) {
43
+ const config = isObject(value.params.config) ? value.params.config : {};
44
+ return {
45
+ ...value,
46
+ params: { ...value.params, config: { ...config, mcp_servers: {} } },
47
+ };
48
+ }
49
+ return value;
38
50
  }
39
51
  /** Transform one app-server NDJSON request; malformed input passes through. */
40
- export function rewriteCodexAppServerRequest(line, approval, expectedSandbox) {
52
+ export function rewriteCodexAppServerRequest(line, approval, expectedSandbox, disableInheritedMcp = false) {
41
53
  try {
42
- return JSON.stringify(rewriteMessage(JSON.parse(line), approval, expectedSandbox));
54
+ return JSON.stringify(rewriteMessage(JSON.parse(line), approval, expectedSandbox, disableInheritedMcp));
43
55
  }
44
56
  catch {
45
57
  return line;
@@ -70,6 +82,7 @@ export function runCodexAppServerProxy() {
70
82
  throw new Error('the fleet Codex proxy may only be launched as CODEX_PATH app-server');
71
83
  const approval = requiredChoice(APPROVAL_ENV, APPROVAL_POLICIES);
72
84
  const expectedSandbox = requiredChoice(SANDBOX_ENV, SANDBOX_MODES);
85
+ const disableInheritedMcp = process.env[DISABLE_INHERITED_MCP_ENV] === '1';
73
86
  const launch = realCodexLaunch();
74
87
  const env = { ...process.env };
75
88
  delete env.CODEX_PATH;
@@ -77,6 +90,7 @@ export function runCodexAppServerProxy() {
77
90
  delete env[SANDBOX_ENV];
78
91
  delete env[REAL_CODEX_ENV];
79
92
  delete env[ACP_MANIFEST_ENV];
93
+ delete env[DISABLE_INHERITED_MCP_ENV];
80
94
  const child = spawn(launch.command, launch.args, {
81
95
  env, shell: launch.shell, stdio: ['pipe', 'pipe', 'pipe'],
82
96
  });
@@ -105,7 +119,7 @@ export function runCodexAppServerProxy() {
105
119
  input.on('line', line => {
106
120
  if (finished)
107
121
  return;
108
- const output = rewriteCodexAppServerRequest(line, approval, expectedSandbox) + '\n';
122
+ const output = rewriteCodexAppServerRequest(line, approval, expectedSandbox, disableInheritedMcp) + '\n';
109
123
  if (!child.stdin.write(output)) {
110
124
  input.pause();
111
125
  child.stdin.once('drain', () => {
@@ -141,8 +141,10 @@ export interface HarnessAdapter {
141
141
  acpPermissionModeId?(role: ResolvedRole): string | undefined;
142
142
  /**
143
143
  * The MCP servers this role declares, for the `mcpServers` array of ACP's
144
- * `session/new` / `resume` / `load`. `undefined` preserves the agent's
145
- * inherited configuration; an explicit empty array disables all servers.
144
+ * `session/new` / `resume` / `load`. ACP requires an array on the wire, so
145
+ * `undefined` is encoded as `[]` while the authenticated bundled adapter is
146
+ * left to preserve its inherited configuration. An explicit empty array uses
147
+ * that adapter's compatibility path to disable all inherited servers.
146
148
  */
147
149
  acpMcpServers?(role: ResolvedRole): AcpMcpServer[] | undefined;
148
150
  /**
@@ -5,6 +5,9 @@ import { advanceSaga, setSagaError, updateMemberSeats, updateMemberStartup, acti
5
5
  import { activateTask, updateTaskMembers, blockTask, unblockTask, getTask, } from './task-state.js';
6
6
  import { spawnTemp } from '../spawn.js';
7
7
  import { findRole } from '../config.js';
8
+ import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from '../fleet-proxy.js';
9
+ import { controlRequest } from '../session/control.js';
10
+ import { SessionControlError } from '../session/types.js';
8
11
  import { closeManagedRoom } from './close.js';
9
12
  import { buildRoomMemberTask, sha256Text } from './member-startup.js';
10
13
  import { agentDir } from '../paths.js';
@@ -18,6 +21,28 @@ export function getBinPath() {
18
21
  return process.argv[1];
19
22
  }
20
23
  }
24
+ async function spawnRoomMember(options, binPath) {
25
+ const stateDir = process.env[FLEET_PROXY_STATE_DIR_ENV];
26
+ if (!stateDir)
27
+ return {
28
+ statePath: await spawnTemp(options, binPath),
29
+ creationActionId: options.creationActionId,
30
+ };
31
+ const response = await controlRequest(stateDir, { command: 'fleet_spawn', spawn: options }, 10 * 60_000);
32
+ if (!response.ok) {
33
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'managed room member spawn failed');
34
+ }
35
+ const result = response.result;
36
+ const expectedCaller = process.env[FLEET_PROXY_CALLER_ENV];
37
+ if (expectedCaller && result.caller !== expectedCaller) {
38
+ throw new Error(`fleet proxy caller mismatch: expected '${expectedCaller}', got '${result.caller}'`);
39
+ }
40
+ return {
41
+ statePath: result.statePath,
42
+ creationActionId: result.creationActionId,
43
+ callerRole: result.caller,
44
+ };
45
+ }
21
46
  function shortId(id) { return id.slice(0, 8); }
22
47
  function expandMembers(template, prefix) {
23
48
  const result = [];
@@ -40,11 +65,15 @@ function settingsFor(member, cfg) {
40
65
  refRole = findRole(cfg, member.roleRef);
41
66
  }
42
67
  catch { /* no ref role */ }
68
+ const permissions = member.overrides?.permissions;
43
69
  return {
44
70
  model: member.overrides?.model ?? refRole?.model,
45
71
  harness: member.overrides?.harness ?? refRole?.harness,
46
72
  cwd: member.overrides?.cwd ?? refRole?.cwd,
47
73
  persona: member.overrides?.persona ?? refRole?.persona,
74
+ approval: permissions?.approval,
75
+ filesystem: permissions?.filesystem,
76
+ unattended: permissions?.unattended,
48
77
  };
49
78
  }
50
79
  function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCid) {
@@ -96,9 +125,24 @@ async function retainRunningLaunch(input) {
96
125
  .find(candidate => candidate.role_name === member.name);
97
126
  const dir = agentDir(member.name, true);
98
127
  const taskSha = sha256Text(task);
99
- if ((seat.launch?.state === 'intent' || seat.launch?.state === 'launched') && existsSync(dir)) {
128
+ if ((seat.launch?.state === 'intent' || seat.launch?.state === 'launched'
129
+ || seat.launch?.state === 'failed') && existsSync(dir)) {
100
130
  if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
101
- throw new Error(`existing launch for ${member.name} does not match its durable intent`);
131
+ const provenance = readProvenance(dir);
132
+ const adoptable = (seat.launch.state === 'intent' || seat.launch.state === 'failed')
133
+ && Boolean(seat.launch.caller_role)
134
+ && provenance?.surface === 'agent'
135
+ && provenance.callerRole === seat.launch.caller_role
136
+ && typeof provenance.creationActionId === 'string'
137
+ && launchMatches(dir, member, provenance.creationActionId, taskSha, provision.roomId, roomIdentityCid, seat.invite_id);
138
+ if (!adoptable)
139
+ throw new Error(`existing launch for ${member.name} does not match its durable intent`);
140
+ updateMemberStartup(provision.roomId, member.name, { launch: {
141
+ ...seat.launch, state: 'intent', action_id: provenance.creationActionId,
142
+ updated_at: new Date().toISOString(),
143
+ } });
144
+ seat = getRoomRecord(provision.roomId).member_seats
145
+ .find(candidate => candidate.role_name === member.name);
102
146
  }
103
147
  const supervisor = readTempSupervisor(dir);
104
148
  if (!supervisor || supervisor.role !== member.name)
@@ -106,16 +150,17 @@ async function retainRunningLaunch(input) {
106
150
  const live = await tempSupervisorLiveness(dir);
107
151
  if (live === 'unknown')
108
152
  throw new Error(`existing launch for ${member.name} has unknown liveness`);
153
+ const retainedLaunch = seat.launch;
109
154
  if (live === 'running') {
110
155
  updateMemberStartup(provision.roomId, member.name, { launch: {
111
- ...seat.launch, state: 'launched', launch_id: supervisor.launchId,
156
+ ...retainedLaunch, state: 'launched', launch_id: supervisor.launchId,
112
157
  updated_at: new Date().toISOString(),
113
158
  } });
114
159
  return true;
115
160
  }
116
161
  await secureStoppedTempArchive(member.name, supervisor.launchId);
117
162
  updateMemberStartup(provision.roomId, member.name, { launch: {
118
- ...seat.launch, state: 'stopped', launch_id: supervisor.launchId,
163
+ ...retainedLaunch, state: 'stopped', launch_id: supervisor.launchId,
119
164
  updated_at: new Date().toISOString(),
120
165
  } });
121
166
  return false;
@@ -151,14 +196,18 @@ async function launchMember(input) {
151
196
  const seat = getRoomRecord(provision.roomId).member_seats
152
197
  .find(candidate => candidate.role_name === member.name);
153
198
  const actionId = randomUUID();
199
+ let effectiveActionId = actionId;
154
200
  const attempt = (seat.launch?.attempt ?? 0) + 1;
155
201
  const taskSha = sha256Text(startup.task);
202
+ const proxyCaller = process.env[FLEET_PROXY_STATE_DIR_ENV]
203
+ ? process.env[FLEET_PROXY_CALLER_ENV] : undefined;
156
204
  updateMemberStartup(provision.roomId, member.name, { launch: {
157
205
  state: 'intent', attempt, action_id: actionId, mission_sha256: taskSha,
206
+ ...(proxyCaller ? { caller_role: proxyCaller } : {}),
158
207
  updated_at: new Date().toISOString(),
159
208
  } });
160
209
  try {
161
- const launchedDir = await spawnTemp({
210
+ const launched = await spawnRoomMember({
162
211
  name: member.name,
163
212
  temp: true,
164
213
  identity: member.name,
@@ -166,22 +215,36 @@ async function launchMember(input) {
166
215
  model: settings.model,
167
216
  harness: settings.harness,
168
217
  cwd: settings.cwd,
218
+ approval: settings.approval,
219
+ filesystem: settings.filesystem,
220
+ unattended: settings.unattended,
169
221
  surface: 'agent',
170
222
  creationActionId: actionId,
171
223
  roomMemberStartup: startup,
172
224
  }, provision.binPath);
225
+ const launchedDir = launched.statePath;
226
+ effectiveActionId = launched.creationActionId;
227
+ if (launched.creationActionId !== actionId) {
228
+ updateMemberStartup(provision.roomId, member.name, { launch: {
229
+ state: 'intent', attempt, action_id: launched.creationActionId,
230
+ mission_sha256: taskSha, updated_at: new Date().toISOString(),
231
+ ...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
232
+ } });
233
+ }
173
234
  const supervisor = readTempSupervisor(launchedDir);
174
- if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, actionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
235
+ if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
175
236
  throw new Error(`new launch for ${member.name} did not persist matching provenance`);
176
237
  }
177
238
  updateMemberStartup(provision.roomId, member.name, { launch: {
178
- state: 'launched', attempt, action_id: actionId, mission_sha256: taskSha,
239
+ state: 'launched', attempt, action_id: launched.creationActionId, mission_sha256: taskSha,
240
+ ...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
179
241
  launch_id: supervisor.launchId, updated_at: new Date().toISOString(),
180
242
  } });
181
243
  }
182
244
  catch (error) {
183
245
  updateMemberStartup(provision.roomId, member.name, { launch: {
184
- state: 'failed', attempt, action_id: actionId, mission_sha256: taskSha,
246
+ state: 'failed', attempt, action_id: effectiveActionId, mission_sha256: taskSha,
247
+ ...(proxyCaller ? { caller_role: proxyCaller } : {}),
185
248
  updated_at: new Date().toISOString(),
186
249
  error: error instanceof Error ? error.message : String(error),
187
250
  } });
@@ -87,6 +87,8 @@ export interface RoomMemberLaunchState {
87
87
  state: 'pending' | 'intent' | 'launched' | 'stopped' | 'failed';
88
88
  attempt: number;
89
89
  action_id?: string;
90
+ /** Expected authenticated proxy caller while adopting a post-spawn crash. */
91
+ caller_role?: string;
90
92
  mission_sha256?: string;
91
93
  launch_id?: string;
92
94
  updated_at: string;
package/dist/runner.js CHANGED
@@ -61,15 +61,15 @@ const defaultDeps = () => ({
61
61
  },
62
62
  });
63
63
  const MONITOR_OWNER_FILE = '.monitor-owner';
64
- /** Fleet roles consume the operator-owned daemon; a role session never starts it. */
65
- const FLEET_OURS_AUTOSTART = '0';
64
+ const OBSOLETE_OURS_AUTOSTART_ENV = 'OURS_AUTOSTART';
66
65
  /** Environment injected only into the managed harness process. */
67
66
  export function managedFleetProxyEnv(role, stateDir) {
67
+ const roleEnv = { ...(role.env ?? {}) };
68
+ // ours-mcp 1.0 treats presence (even "0") as a fatal legacy lifecycle mode.
69
+ // Managed children are daemon clients; the proxy itself never starts one.
70
+ delete roleEnv[OBSOLETE_OURS_AUTOSTART_ENV];
68
71
  return {
69
- ...(role.env ?? {}),
70
- // This must win over both inherited/configured auto-start. ACP agents run
71
- // directly rather than through ours-codex, so the runner owns this fence.
72
- OURS_AUTOSTART: FLEET_OURS_AUTOSTART,
72
+ ...roleEnv,
73
73
  [FLEET_PROXY_STATE_DIR_ENV]: stateDir,
74
74
  [FLEET_PROXY_CALLER_ENV]: role.name,
75
75
  };
@@ -166,17 +166,14 @@ export function recordMonitorOwner(dir, owner) {
166
166
  export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = launch.argv) {
167
167
  const env = {
168
168
  PATH: process.env.PATH ?? '', COLORTERM: 'truecolor', ...launch.env, ...(roleEnv ?? {}),
169
- // Tmux roles have the same daemon-client boundary as ACP roles. Keep this
170
- // last so neither harness preparation nor a role env block can take over
171
- // the shared daemon lifecycle.
172
- OURS_AUTOSTART: FLEET_OURS_AUTOSTART,
173
169
  };
170
+ delete env[OBSOLETE_OURS_AUTOSTART_ENV];
174
171
  // Interactive panes should advertise colour even when the supervisor itself
175
172
  // was launched with NO_COLOR. A role may still deliberately opt back in to
176
173
  // NO_COLOR (or replace COLORTERM) through its explicit env block.
177
174
  const unsetNoColor = Object.prototype.hasOwnProperty.call(roleEnv ?? {}, 'NO_COLOR')
178
175
  ? '' : '-u NO_COLOR ';
179
- const envPfx = 'env ' + unsetNoColor
176
+ const envPfx = 'env -u OURS_AUTOSTART ' + unsetNoColor
180
177
  + Object.entries(env).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
181
178
  const cmd = paneArgv.map(shq).join(' ');
182
179
  // Write a structured record, not a bare number: the wait status alone cannot
@@ -645,6 +642,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
645
642
  // role-only adapter hook prevents a PATH fallback or resolver skew from
646
643
  // claiming metadata trust for an argv it did not authenticate.
647
644
  permissionMetadataSource: launch.permissionMetadataSource,
645
+ scrubObsoleteOursAutostart: true,
648
646
  log: deps.log,
649
647
  });
650
648
  pid = acpSession.pid;
@@ -21,6 +21,8 @@ export declare const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120000;
21
21
  * live role.
22
22
  */
23
23
  export declare const STEERING_OCCUPANCY_IDLE_MS = 150000;
24
+ /** Consumed only by Fleet's authenticated bundled-Codex app-server proxy. */
25
+ export declare const CODEX_DISABLE_INHERITED_MCP_ENV = "OURS_FLEET_CODEX_DISABLE_INHERITED_MCP";
24
26
  /** Server-generated typed provenance followed by the exact human-authored body. */
25
27
  export declare function promptContentBlocks(text: string, origin?: PromptOrigin): acp.ContentBlock[];
26
28
  export declare function runtimeSelector(options: acp.SessionConfigOption[] | null | undefined, category: string): RuntimeSelectorMetadata | undefined;
@@ -38,10 +40,13 @@ export interface AcpSessionOptions {
38
40
  permissionMode?: NonNullable<SessionSnapshot['permissionMode']>;
39
41
  /** Adapter-authenticated request-metadata vocabulary; never inferred from ACP `_meta`. */
40
42
  permissionMetadataSource?: 'codex-acp';
43
+ /** Fleet-managed ours proxies must never receive the obsolete presence-sensitive flag. */
44
+ scrubObsoleteOursAutostart?: boolean;
41
45
  /**
42
46
  * MCP servers the ROLE declares, for every session/new, resume and load.
43
- * Omitted preserves inherited configuration; an explicit empty array disables
44
- * every inherited server.
47
+ * Omitted preserves inherited configuration (encoded as ACP's required `[]`);
48
+ * an explicit empty array disables every inherited server through the
49
+ * authenticated bundled-adapter compatibility path.
45
50
  */
46
51
  mcpServers?: AcpMcpServer[];
47
52
  /**
@@ -258,7 +263,7 @@ export declare class AcpSession implements SessionHandle {
258
263
  private settlePendingAutomatically;
259
264
  exitResult(): ExitRecord | null;
260
265
  close(): Promise<void>;
261
- /** Preserve inheritance on omission; carry even an explicit empty override. */
266
+ /** ACP requires the field. Bundled agents treat [] as no client-added servers. */
262
267
  private declaredMcpServers;
263
268
  private initialize;
264
269
  private captureRuntimeMetadata;
@@ -32,6 +32,8 @@ export const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120_000;
32
32
  */
33
33
  export const STEERING_OCCUPANCY_IDLE_MS = 150_000;
34
34
  const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
35
+ /** Consumed only by Fleet's authenticated bundled-Codex app-server proxy. */
36
+ export const CODEX_DISABLE_INHERITED_MCP_ENV = 'OURS_FLEET_CODEX_DISABLE_INHERITED_MCP';
35
37
  const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
36
38
  const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
37
39
  const MAX_CANONICAL_SYMLINK_DEPTH = 40;
@@ -304,9 +306,25 @@ export class AcpSession {
304
306
  static async start(options) {
305
307
  if (!options.argv.length)
306
308
  throw new Error('ACP agent command is empty');
309
+ const disableInheritedCodexMcp = options.permissionMetadataSource === 'codex-acp'
310
+ && options.mcpServers !== undefined && options.mcpServers.length === 0;
311
+ // Obsolete ours-mcp lifecycle flags are presence-sensitive. The shared
312
+ // daemon remains operator-owned; managed ACP children are clients only.
313
+ const childEnv = {
314
+ ...process.env,
315
+ ...options.env,
316
+ };
317
+ if (options.scrubObsoleteOursAutostart)
318
+ delete childEnv.OURS_AUTOSTART;
319
+ Object.assign(childEnv,
320
+ // Write both states for the authenticated proxy: a stale ambient `1`
321
+ // must never leak explicit-empty semantics into a later inherited role.
322
+ options.permissionMetadataSource === 'codex-acp'
323
+ ? { [CODEX_DISABLE_INHERITED_MCP_ENV]: disableInheritedCodexMcp ? '1' : '0' }
324
+ : {});
307
325
  const child = spawn(options.argv[0], options.argv.slice(1), {
308
326
  cwd: options.cwd,
309
- env: { ...process.env, ...options.env },
327
+ env: childEnv,
310
328
  stdio: ['pipe', 'pipe', 'pipe'],
311
329
  });
312
330
  await new Promise((resolve, reject) => {
@@ -991,11 +1009,9 @@ export class AcpSession {
991
1009
  });
992
1010
  this.conversation.close();
993
1011
  }
994
- /** Preserve inheritance on omission; carry even an explicit empty override. */
1012
+ /** ACP requires the field. Bundled agents treat [] as no client-added servers. */
995
1013
  declaredMcpServers() {
996
- return this.options.mcpServers === undefined
997
- ? {}
998
- : { mcpServers: this.options.mcpServers };
1014
+ return this.options.mcpServers ?? [];
999
1015
  }
1000
1016
  async initialize() {
1001
1017
  const initialized = await this.connection.agent.request(acp.methods.agent.initialize, {
@@ -1016,7 +1032,7 @@ export class AcpSession {
1016
1032
  const resumed = await this.connection.agent.request(acp.methods.agent.session.resume, {
1017
1033
  sessionId: persisted,
1018
1034
  cwd: this.options.cwd,
1019
- ...this.declaredMcpServers(),
1035
+ mcpServers: this.declaredMcpServers(),
1020
1036
  });
1021
1037
  this.captureRuntimeMetadata(resumed.configOptions);
1022
1038
  this.sessionId = persisted;
@@ -1029,7 +1045,7 @@ export class AcpSession {
1029
1045
  const loaded = await this.connection.agent.request(acp.methods.agent.session.load, {
1030
1046
  sessionId: persisted,
1031
1047
  cwd: this.options.cwd,
1032
- ...this.declaredMcpServers(),
1048
+ mcpServers: this.declaredMcpServers(),
1033
1049
  });
1034
1050
  this.captureRuntimeMetadata(loaded.configOptions);
1035
1051
  }
@@ -1041,7 +1057,7 @@ export class AcpSession {
1041
1057
  else {
1042
1058
  const created = await this.connection.agent.request(acp.methods.agent.session.new, {
1043
1059
  cwd: this.options.cwd,
1044
- ...this.declaredMcpServers(),
1060
+ mcpServers: this.declaredMcpServers(),
1045
1061
  ...(this.options.sessionMeta ? { _meta: this.options.sessionMeta } : {}),
1046
1062
  });
1047
1063
  this.sessionId = created.sessionId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",