@ours.network/fleet 0.13.3 → 0.14.0

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
@@ -644,8 +644,58 @@ The two paths are deliberately simultaneous and have different authority:
644
644
  turn's final assistant text, and sends that final back to the exact initiating
645
645
  owner with `reply_to_wire_id`.
646
646
 
647
- Exact `/status` and `/interrupt` messages are supervisor commands and never
648
- enter the model. Processed wire IDs are durably bounded for deduplication, while
647
+ #### Deterministic owner commands
648
+
649
+ Any owner message whose trimmed text starts with `/` is a command attempt: it is
650
+ handled by the fleet supervisor itself and never becomes an agent prompt.
651
+ Unknown or malformed commands (wrong arguments included) answer with the help
652
+ text instead of being forwarded; messages without a leading `/` reach the agent
653
+ unchanged. The registry in `src/owner-channel/commands.ts` is the single source
654
+ of truth — `/help` renders exactly that table, so adding an entry there is the
655
+ whole registration step for a new command.
656
+
657
+ | Command | Effect |
658
+ | --- | --- |
659
+ | `/help` (alias `/commands`) | list all deterministic owner-channel commands |
660
+ | `/status` | report the agent's session state |
661
+ | `/interrupt` | cancel the agent's active turn |
662
+ | `/clear` | clear the agent's session context |
663
+ | `/compact` | compact the agent's session context |
664
+ | `/model <model-id>` | switch the model the agent runs on |
665
+ | `/restart` | restart the agent, resuming its context |
666
+ | `/force-restart` | restart the agent FRESH (context wiped) |
667
+ | `/ls` | list running fleet sessions |
668
+ | `/peek` | summarize recent session activity (event shapes only, no content) |
669
+ | `/worklog` | tail the agent's worklog |
670
+ | `/version` | report the fleet version |
671
+
672
+ Implementation strategies differ but every command is deterministic:
673
+
674
+ - `/help`, `/status`, `/interrupt`, `/peek`, `/worklog`, and `/version` are
675
+ answered by the supervisor directly. `/peek` deliberately reports event
676
+ *shapes* (kind, tool title, status) and never thought, agent-text, or tool
677
+ output bodies.
678
+ - `/clear`, `/compact`, and `/model` deliver the raw slash text to the agent
679
+ harness, but only when the bundled ACP adapter for the role's harness
680
+ verifiably executes that command locally (pinned per harness in
681
+ `HARNESS_LOCAL_COMMANDS`): `claude-code` runs all three as Claude SDK
682
+ builtins; `codex` runs only `/compact` — `/clear` and `/model` are not
683
+ codex-acp builtins and would fall through to the model as an ordinary
684
+ prompt, so they answer with a truthful refusal instead of being forwarded.
685
+ When forwarded, fleet sends a `⏳` acceptance notice and reports the turn's
686
+ outcome on the same wire.
687
+ - `/restart` and `/force-restart` confirm to the owner and durably mark the
688
+ message handled FIRST, then invoke the detached `ours-fleet restart` /
689
+ `force-restart` CLI — a successful bounce kills the supervisor process, so
690
+ nothing can be sent afterwards. `/ls` captures the CLI listing.
691
+
692
+ Commands act only on the role whose channel received them (the restart target
693
+ and session are fixed by the channel, never by message content), and the entire
694
+ command path sits behind the authenticated owner-CID check: a non-owner sending
695
+ `/force-restart` or `/model` is silently ignored exactly like any other
696
+ unauthorized mail.
697
+
698
+ Processed wire IDs are durably bounded for deduplication, while
649
699
  message and response bodies stay out of fleet state. Delivery is at-least-once
650
700
  across a crash (the bridge requeues fetched input before starting a turn); true
651
701
  exactly-once processing would require a leased claim/idempotency primitive in
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]\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\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 intentionally IPv4-loopback-only. It has no LAN/Internet host,\nproxy, TLS, or remote-access mode. Do not expose port 49271 through a reverse\nproxy. Browser credentials are HttpOnly/SameSite, and `revoke-all` invalidates\nall trusted 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 \\\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|allow|deny \\\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 ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\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 # true cancels active work before every configured wake\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 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\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\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: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits 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` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\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`, and\n`mem_palace_midsession_autosave`.\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. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the 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, existing ours identity which fleet \u2014 never\nthe agent \u2014 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 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\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.\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, fleet injects a request-specific outbox path into the owner\nprompt. The agent copies completed artifacts there; fleet sends every regular\nfile from the channel identity with the same source wire ID and removes the\ntemporary outbox only after successful delivery. The agent never chooses an owner\nrecipient or calls ours `send_file` for an owner-channel response.\nExact `/status` and `/interrupt` commands bypass the model.\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 ours-mcp reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\nfallback. Run `ours-mcp voice-status --json` to inspect the host configuration.\nA mode-0600 crash journal contains only authenticated CID and wire routing data;\nit never stores captions, filenames, paths, transcript text, or bytes. Journaled\npost-retrieval files resume selectively through `save_file`; corrupt state\ndisables attachment admission rather than weakening provenance 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\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\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\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]\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\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 intentionally IPv4-loopback-only. It has no LAN/Internet host,\nproxy, TLS, or remote-access mode. Do not expose port 49271 through a reverse\nproxy. Browser credentials are HttpOnly/SameSite, and `revoke-all` invalidates\nall trusted 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 \\\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|allow|deny \\\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 ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\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 # true cancels active work before every configured wake\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 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\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\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: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits 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` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\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`, and\n`mem_palace_midsession_autosave`.\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. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the 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, existing ours identity which fleet \u2014 never\nthe agent \u2014 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 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\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.\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, fleet injects a request-specific outbox path into the owner\nprompt. The agent copies completed artifacts there; fleet sends every regular\nfile from the channel identity with the same source wire ID and removes the\ntemporary outbox only after successful delivery. The agent never chooses an owner\nrecipient or calls ours `send_file` for an owner-channel response.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/interrupt`, `/clear`, `/compact`, `/model <model-id>`,\n`/restart`, `/force-restart`, `/ls`, `/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\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 ours-mcp reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\nfallback. Run `ours-mcp voice-status --json` to inspect the host configuration.\nA mode-0600 crash journal contains only authenticated CID and wire routing data;\nit never stores captions, filenames, paths, transcript text, or bytes. Journaled\npost-retrieval files resume selectively through `save_file`; corrupt state\ndisables attachment admission rather than weakening provenance 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\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\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\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 (7.1).
10
10
  *
package/dist/docs.js CHANGED
@@ -380,7 +380,16 @@ prompt. The agent copies completed artifacts there; fleet sends every regular
380
380
  file from the channel identity with the same source wire ID and removes the
381
381
  temporary outbox only after successful delivery. The agent never chooses an owner
382
382
  recipient or calls ours \`send_file\` for an owner-channel response.
383
- Exact \`/status\` and \`/interrupt\` commands bypass the model.
383
+ Owner messages whose trimmed text starts with \`/\` are deterministic
384
+ supervisor commands and never enter the model: \`/help\` (alias \`/commands\`),
385
+ \`/status\`, \`/interrupt\`, \`/clear\`, \`/compact\`, \`/model <model-id>\`,
386
+ \`/restart\`, \`/force-restart\`, \`/ls\`, \`/peek\`, \`/worklog\`, and
387
+ \`/version\`. Unknown or malformed commands answer with the help text instead of
388
+ being forwarded; plain messages reach the agent unchanged. \`/clear\`,
389
+ \`/compact\`, and \`/model\` are forwarded only when the role's bundled ACP
390
+ adapter executes them locally (claude-code: all three; codex: \`/compact\`
391
+ only) and are otherwise refused with a notice, so slash text never reaches the
392
+ model as a prompt.
384
393
 
385
394
  Owner documents, images, and voice messages use the same authenticated sender
386
395
  and source-wire boundary. Fleet inspects body-free metadata first and rejects
@@ -1,12 +1,15 @@
1
1
  import { type ChildProcessWithoutNullStreams } from 'node:child_process';
2
2
  import { type OwnerChannelConfig } from '../config.js';
3
3
  import type { SessionHandle } from '../session/types.js';
4
+ import { type OwnerFleetOps } from './commands.js';
4
5
  import { type OursToolClient } from './mcp.js';
5
6
  import { type OwnerUpdatePhase } from './notices.js';
6
7
  import { type OwnerEntry } from './state.js';
7
8
  import { type OwnerTaskPhase } from './tasks.js';
8
9
  export interface OwnerChannelOptions {
9
10
  role: string;
11
+ /** Harness id of the role (e.g. 'claude-code', 'codex'); gates which slash commands may be forwarded. */
12
+ harness: string;
10
13
  config: OwnerChannelConfig;
11
14
  session: SessionHandle;
12
15
  stateDir: string;
@@ -16,6 +19,10 @@ export interface OwnerChannelOptions {
16
19
  client?: OursToolClient;
17
20
  /** Test seam; production uses `ours-mcp watch <identity>`. */
18
21
  watch?: (identity: string) => ChildProcessWithoutNullStreams;
22
+ /** Test seam; production uses the detached ours-fleet CLI (`fleetCliOps`). */
23
+ fleet?: OwnerFleetOps;
24
+ /** Forwarded to fleet CLI invocations spawned for owner commands. */
25
+ configPath?: string;
19
26
  }
20
27
  export interface OwnerChannelHandle {
21
28
  start(): Promise<void>;
@@ -130,6 +137,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
130
137
  private readonly activeRequests;
131
138
  private managementTail;
132
139
  private ready;
140
+ private readonly fleetOps;
133
141
  constructor(options: OwnerChannelOptions);
134
142
  start(): Promise<void>;
135
143
  drain(): Promise<void>;
@@ -152,6 +160,25 @@ export declare class OwnerChannel implements OwnerChannelHandle {
152
160
  private attachmentGroups;
153
161
  private handleAttachmentGroup;
154
162
  private handle;
163
+ /**
164
+ * Deterministic command path: the message never becomes an agent prompt.
165
+ * Authorization already happened — the managed-agent relay branch and the
166
+ * owner-CID check in handle() both run before dispatch, so only an
167
+ * authenticated owner reaches this: neither ordinary peers nor the managed
168
+ * agent itself can execute /force-restart, /model, or any other command.
169
+ */
170
+ private handleCommand;
171
+ /** Queue raw slash text to the harness and report the turn's outcome. */
172
+ private runHarnessCommand;
173
+ /**
174
+ * Confirmation and the durable wire record must both land BEFORE the fleet
175
+ * CLI is asked to bounce this very process; neither can happen afterwards.
176
+ */
177
+ private restartSelf;
178
+ /** Code-point-safe tail of the worklog, or undefined when there is none. */
179
+ private readWorklogTail;
180
+ /** Bound harness-command output to a single outbound message. */
181
+ private commandOutput;
155
182
  private acceptedSender;
156
183
  private isAgentSender;
157
184
  private isEffectiveOwner;
@@ -1,9 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
- import { mkdir, readdir, rm } from 'node:fs/promises';
3
+ import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
4
4
  import { createInterface } from 'node:readline';
5
5
  import { join } from 'node:path';
6
6
  import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
7
+ import { VERSION } from '../version.js';
8
+ import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
7
9
  import { OursMcpClient } from './mcp.js';
8
10
  import { ownerNotices } from './notices.js';
9
11
  import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
@@ -49,9 +51,11 @@ export class OwnerChannel {
49
51
  activeRequests = new Map();
50
52
  managementTail = Promise.resolve();
51
53
  ready = false;
54
+ fleetOps;
52
55
  constructor(options) {
53
56
  this.options = options;
54
57
  this.client = options.client ?? new OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
58
+ this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
55
59
  this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
56
60
  this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
57
61
  this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
@@ -692,24 +696,8 @@ export class OwnerChannel {
692
696
  this.logError('owner conversation route update failed', error);
693
697
  }
694
698
  const text = String(message.text ?? '').trim();
695
- if (text.toLowerCase() === '/status') {
696
- const snapshot = this.options.session.snapshot();
697
- await this.send(sender.id, ownerNotices.status(this.options.role, snapshot), wireId);
698
- this.state.remember(wireId);
699
- return true;
700
- }
701
- if (text.toLowerCase() === '/interrupt') {
702
- try {
703
- await this.options.session.interrupt('owner');
704
- }
705
- catch (error) {
706
- this.logError('interrupt failed', error);
707
- await this.send(sender.id, ownerNotices.interruptFailed(this.options.role), wireId);
708
- this.state.remember(wireId);
709
- return true;
710
- }
711
- await this.send(sender.id, ownerNotices.interrupted(this.options.role), wireId);
712
- this.state.remember(wireId);
699
+ if (isOwnerCommandText(text)) {
700
+ await this.handleCommand(sender, text, wireId);
713
701
  return true;
714
702
  }
715
703
  const requestId = this.requestId(wireId);
@@ -760,6 +748,94 @@ export class OwnerChannel {
760
748
  this.completionTasks.add(task);
761
749
  return true;
762
750
  }
751
+ /**
752
+ * Deterministic command path: the message never becomes an agent prompt.
753
+ * Authorization already happened — the managed-agent relay branch and the
754
+ * owner-CID check in handle() both run before dispatch, so only an
755
+ * authenticated owner reaches this: neither ordinary peers nor the managed
756
+ * agent itself can execute /force-restart, /model, or any other command.
757
+ */
758
+ async handleCommand(sender, text, wireId) {
759
+ const ctx = {
760
+ role: this.options.role,
761
+ harness: this.options.harness,
762
+ version: VERSION,
763
+ snapshot: () => this.options.session.snapshot(),
764
+ interrupt: () => this.options.session.interrupt('owner'),
765
+ runHarnessCommand: command => this.runHarnessCommand(sender, command, wireId),
766
+ restart: mode => this.restartSelf(sender, mode, wireId),
767
+ fleetList: () => this.fleetOps.list(),
768
+ recentEvents: limit => this.options.session.eventsSince(0).slice(-limit),
769
+ readWorklogTail: maxChars => this.readWorklogTail(maxChars),
770
+ reply: async (replyText) => { await this.send(sender.id, replyText, wireId); },
771
+ };
772
+ try {
773
+ await dispatchOwnerCommand(text, ctx);
774
+ }
775
+ catch (error) {
776
+ this.logError(`owner command failed (${text.split(/\s+/, 1)[0]})`, error);
777
+ }
778
+ // Harness commands own their wire until the queued turn settles; everything
779
+ // else is complete now and must never replay.
780
+ if (!this.inFlight.has(wireId))
781
+ this.state.remember(wireId);
782
+ }
783
+ /** Queue raw slash text to the harness and report the turn's outcome. */
784
+ async runHarnessCommand(sender, command, wireId) {
785
+ const requestId = this.requestId(wireId);
786
+ const queued = await this.options.session.queuePrompt(command, {
787
+ origin: { kind: 'owner', requestId },
788
+ });
789
+ this.inFlight.add(wireId);
790
+ const receipt = this.send(sender.id, ownerNotices.commandStarted(command), wireId)
791
+ .then(() => undefined)
792
+ .catch(error => this.logError(`command ${command} acceptance notice failed`, error));
793
+ const task = queued.completion.then(async (result) => {
794
+ await receipt;
795
+ const output = result.succeeded ? this.commandOutput(result.output) : undefined;
796
+ await this.send(sender.id, ownerNotices.commandOutcome(command, result.outcome, output), wireId);
797
+ this.state.remember(wireId);
798
+ }).catch(error => this.logError(`command ${command} completion failed`, error))
799
+ .finally(() => {
800
+ this.inFlight.delete(wireId);
801
+ this.completionTasks.delete(task);
802
+ if (!this.stopping)
803
+ void this.drain().catch(error => this.logError('completion drain failed', error));
804
+ });
805
+ this.completionTasks.add(task);
806
+ }
807
+ /**
808
+ * Confirmation and the durable wire record must both land BEFORE the fleet
809
+ * CLI is asked to bounce this very process; neither can happen afterwards.
810
+ */
811
+ async restartSelf(sender, mode, wireId) {
812
+ const command = mode === 'fresh' ? '/force-restart' : '/restart';
813
+ await this.send(sender.id, ownerNotices.restarting(this.options.role, command, mode), wireId);
814
+ this.state.remember(wireId);
815
+ this.options.log(`[${this.options.role}] owner requested ${command}`);
816
+ await this.fleetOps.restart(mode);
817
+ }
818
+ /** Code-point-safe tail of the worklog, or undefined when there is none. */
819
+ async readWorklogTail(maxChars) {
820
+ try {
821
+ const content = (await readFile(join(this.options.stateDir, 'WORKLOG.md'), 'utf8')).trim();
822
+ if (!content)
823
+ return undefined;
824
+ const points = Array.from(content);
825
+ return points.length <= maxChars ? content : `…${points.slice(-maxChars).join('')}`;
826
+ }
827
+ catch {
828
+ return undefined;
829
+ }
830
+ }
831
+ /** Bound harness-command output to a single outbound message. */
832
+ commandOutput(output) {
833
+ const trimmed = output?.trim();
834
+ if (!trimmed)
835
+ return undefined;
836
+ const points = Array.from(trimmed);
837
+ return points.length <= 7_000 ? trimmed : `${points.slice(0, 7_000).join('')}…`;
838
+ }
763
839
  acceptedSender(cid) {
764
840
  return this.isAgentSender(cid) || this.isEffectiveOwner(cid);
765
841
  }
@@ -0,0 +1,79 @@
1
+ import type { SessionEvent, SessionSnapshot } from '../session/types.js';
2
+ /**
3
+ * Fleet-level effects a deterministic owner command may trigger. Production
4
+ * uses the detached CLI (`fleetCliOps`); tests inject fakes so no command can
5
+ * ever bounce a real service from the suite.
6
+ */
7
+ export interface OwnerFleetOps {
8
+ /** `ours-fleet restart` (keep) or `ours-fleet force-restart` (fresh) of this role. */
9
+ restart(mode: 'keep' | 'fresh'): Promise<void>;
10
+ /** `ours-fleet ls` output. */
11
+ list(): Promise<string>;
12
+ }
13
+ /**
14
+ * The narrow capability surface a command executor sees. Everything here is
15
+ * already scoped to the one role whose channel received the message; commands
16
+ * cannot name another agent or another recipient.
17
+ */
18
+ export interface OwnerCommandContext {
19
+ role: string;
20
+ /** Harness id of the role (e.g. 'claude-code', 'codex'); gates forwarding. */
21
+ harness: string;
22
+ version: string;
23
+ snapshot(): SessionSnapshot;
24
+ interrupt(): Promise<void>;
25
+ /**
26
+ * Deliver raw slash text to the agent harness. Only commands the bundled
27
+ * ACP adapter for `harness` verifiably executes locally may be forwarded
28
+ * (see HARNESS_LOCAL_COMMANDS); anything else would reach the model as an
29
+ * ordinary prompt. The channel sends the acceptance and outcome notices
30
+ * itself.
31
+ */
32
+ runHarnessCommand(command: string): Promise<void>;
33
+ restart(mode: 'keep' | 'fresh'): Promise<void>;
34
+ fleetList(): Promise<string>;
35
+ recentEvents(limit: number): SessionEvent[];
36
+ readWorklogTail(maxChars: number): Promise<string | undefined>;
37
+ reply(text: string): Promise<void>;
38
+ }
39
+ export interface OwnerCommand {
40
+ /** Primary name without the leading slash. */
41
+ name: string;
42
+ aliases?: string[];
43
+ /** Shown in help; defaults to `/<name>`. */
44
+ usage?: string;
45
+ /** One-line description shown in help. */
46
+ summary: string;
47
+ execute(ctx: OwnerCommandContext, args: string): Promise<void>;
48
+ }
49
+ /**
50
+ * Commands each harness's bundled ACP adapter verifiably executes locally,
51
+ * pinned by test/acp-adapter-commands.test.ts against the shipped adapter
52
+ * artifacts. claude-agent-acp routes slash commands into the Claude SDK,
53
+ * which runs its builtins (/clear, /compact, /model) without a model turn;
54
+ * codex-acp intercepts only /compact — /clear and /model are not builtins
55
+ * and would fall through into sendPrompt, i.e. reach the model as an
56
+ * ordinary prompt. Unlisted harnesses forward nothing.
57
+ */
58
+ export declare const HARNESS_LOCAL_COMMANDS: Record<string, readonly string[]>;
59
+ /**
60
+ * The single source of truth for the deterministic owner-channel command set:
61
+ * /help renders exactly this table, so adding an entry here is the whole
62
+ * registration step for a new command.
63
+ */
64
+ export declare const ownerCommands: OwnerCommand[];
65
+ /** Trimmed slash-prefixed text is a command attempt and is never forwarded. */
66
+ export declare const isOwnerCommandText: (text: string) => boolean;
67
+ export declare function ownerCommandHelp(error?: string): string;
68
+ /**
69
+ * Execute one authenticated owner command. `text` must already be trimmed,
70
+ * slash-prefixed, and from an authorized owner CID — the channel enforces the
71
+ * authority boundary before dispatch ever sees the message.
72
+ */
73
+ export declare function dispatchOwnerCommand(text: string, ctx: OwnerCommandContext): Promise<void>;
74
+ /**
75
+ * Production fleet effects: the detached ours-fleet CLI. The restart child is
76
+ * detached and unreferenced because a successful restart kills this very
77
+ * process; the reply and the durable wire record must already be on disk.
78
+ */
79
+ export declare function fleetCliOps(role: string, configPath?: string): OwnerFleetOps;
@@ -0,0 +1,183 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { ownerNotices } from './notices.js';
3
+ /** A malformed invocation; the dispatcher answers it with annotated help. */
4
+ class OwnerCommandUsageError extends Error {
5
+ }
6
+ const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
7
+ const REPLY_MAX_CHARS = 3_500;
8
+ const strip = (value, max) => String(value).replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, max);
9
+ /** Like `strip`, but keeps newlines so multi-line listings stay readable. */
10
+ const stripMultiline = (value, max) => String(value).replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ').slice(0, max);
11
+ /**
12
+ * Commands each harness's bundled ACP adapter verifiably executes locally,
13
+ * pinned by test/acp-adapter-commands.test.ts against the shipped adapter
14
+ * artifacts. claude-agent-acp routes slash commands into the Claude SDK,
15
+ * which runs its builtins (/clear, /compact, /model) without a model turn;
16
+ * codex-acp intercepts only /compact — /clear and /model are not builtins
17
+ * and would fall through into sendPrompt, i.e. reach the model as an
18
+ * ordinary prompt. Unlisted harnesses forward nothing.
19
+ */
20
+ export const HARNESS_LOCAL_COMMANDS = {
21
+ 'claude-code': ['clear', 'compact', 'model'],
22
+ codex: ['compact'],
23
+ };
24
+ /**
25
+ * Forward raw slash text to the harness only when the bundled adapter for
26
+ * this role's harness verifiably executes it locally; otherwise answer with
27
+ * a truthful refusal so the text can never reach the model as a prompt.
28
+ */
29
+ const forwardHarnessCommand = (ctx, raw) => {
30
+ const name = raw.slice(1).split(/\s+/, 1)[0];
31
+ if (!(HARNESS_LOCAL_COMMANDS[ctx.harness] ?? []).includes(name))
32
+ return ctx.reply(ownerNotices.commandUnsupported(`/${name}`, ctx.harness));
33
+ return ctx.runHarnessCommand(raw);
34
+ };
35
+ /** Keep the LAST characters — tails are more useful than heads for logs. */
36
+ const tail = (value, max) => {
37
+ const points = Array.from(value);
38
+ return points.length <= max ? value : `…${points.slice(-max).join('')}`;
39
+ };
40
+ const noArgs = (usage, run) => async (ctx, args) => {
41
+ if (args)
42
+ throw new OwnerCommandUsageError(`${usage} takes no arguments`);
43
+ await run(ctx);
44
+ };
45
+ /**
46
+ * The single source of truth for the deterministic owner-channel command set:
47
+ * /help renders exactly this table, so adding an entry here is the whole
48
+ * registration step for a new command.
49
+ */
50
+ export const ownerCommands = [
51
+ {
52
+ name: 'help', aliases: ['commands'],
53
+ summary: 'list all deterministic owner-channel commands (alias: /commands)',
54
+ execute: async (ctx) => ctx.reply(ownerCommandHelp()),
55
+ },
56
+ {
57
+ name: 'status', summary: "report the agent's session state",
58
+ execute: noArgs('/status', async (ctx) => ctx.reply(ownerNotices.status(ctx.role, ctx.snapshot()))),
59
+ },
60
+ {
61
+ name: 'interrupt', summary: "cancel the agent's active turn",
62
+ execute: noArgs('/interrupt', async (ctx) => {
63
+ try {
64
+ await ctx.interrupt();
65
+ }
66
+ catch {
67
+ return ctx.reply(ownerNotices.interruptFailed(ctx.role));
68
+ }
69
+ await ctx.reply(ownerNotices.interrupted(ctx.role));
70
+ }),
71
+ },
72
+ {
73
+ name: 'clear', summary: "clear the agent's session context",
74
+ execute: noArgs('/clear', ctx => forwardHarnessCommand(ctx, '/clear')),
75
+ },
76
+ {
77
+ name: 'compact', summary: "compact the agent's session context",
78
+ execute: noArgs('/compact', ctx => forwardHarnessCommand(ctx, '/compact')),
79
+ },
80
+ {
81
+ name: 'model', usage: '/model <model-id>',
82
+ summary: 'switch the model the agent runs on',
83
+ execute: async (ctx, args) => {
84
+ if (!args)
85
+ throw new OwnerCommandUsageError('usage: /model <model-id>');
86
+ if (!MODEL_ID.test(args))
87
+ throw new OwnerCommandUsageError('model id must be alphanumeric with . _ : - only');
88
+ await forwardHarnessCommand(ctx, `/model ${args}`);
89
+ },
90
+ },
91
+ {
92
+ name: 'restart', summary: 'restart the agent, resuming its context',
93
+ execute: noArgs('/restart', ctx => ctx.restart('keep')),
94
+ },
95
+ {
96
+ name: 'force-restart', summary: 'restart the agent FRESH (context wiped)',
97
+ execute: noArgs('/force-restart', ctx => ctx.restart('fresh')),
98
+ },
99
+ {
100
+ name: 'ls', summary: 'list running fleet sessions',
101
+ execute: noArgs('/ls', async (ctx) => ctx.reply(`📊 Fleet sessions:\n${tail(stripMultiline(await ctx.fleetList(), 10_000), REPLY_MAX_CHARS)}`)),
102
+ },
103
+ {
104
+ name: 'peek', summary: 'summarize recent session activity (event shapes only, no content)',
105
+ execute: noArgs('/peek', async (ctx) => {
106
+ const lines = ctx.recentEvents(20).map(event => ['·', event.kind,
107
+ ...(event.title !== undefined ? [strip(event.title, 80)] : []),
108
+ ...(event.status !== undefined ? [`(${strip(event.status, 40)})`] : []),
109
+ ...(event.stopReason !== undefined ? [`(${strip(event.stopReason, 40)})`] : []),
110
+ ].join(' '));
111
+ await ctx.reply(lines.length
112
+ ? tail(`📊 Recent activity for ${ctx.role}:\n${lines.join('\n')}`, REPLY_MAX_CHARS)
113
+ : `📊 No recent session activity recorded for ${ctx.role}.`);
114
+ }),
115
+ },
116
+ {
117
+ name: 'worklog', summary: "tail the agent's worklog",
118
+ execute: noArgs('/worklog', async (ctx) => {
119
+ const worklog = await ctx.readWorklogTail(REPLY_MAX_CHARS);
120
+ await ctx.reply(worklog
121
+ ? `📊 Worklog tail for ${ctx.role}:\n${worklog}`
122
+ : `ℹ️ No worklog found for ${ctx.role}.`);
123
+ }),
124
+ },
125
+ {
126
+ name: 'version', summary: 'report the fleet version',
127
+ execute: noArgs('/version', async (ctx) => ctx.reply(`ℹ️ ours-fleet ${ctx.version}`)),
128
+ },
129
+ ];
130
+ /** Trimmed slash-prefixed text is a command attempt and is never forwarded. */
131
+ export const isOwnerCommandText = (text) => text.trim().startsWith('/');
132
+ export function ownerCommandHelp(error) {
133
+ const table = ownerCommands
134
+ .map(command => `${command.usage ?? `/${command.name}`} — ${command.summary}`)
135
+ .join('\n');
136
+ return `${error ? `⚠️ ${error}\n\n` : ''}🧭 Deterministic owner-channel commands `
137
+ + '(handled by fleet; never sent to the agent as a prompt):\n'
138
+ + `${table}\n`
139
+ + 'Messages without a leading "/" reach the agent unchanged. '
140
+ + 'Unknown or malformed commands return this help.';
141
+ }
142
+ /**
143
+ * Execute one authenticated owner command. `text` must already be trimmed,
144
+ * slash-prefixed, and from an authorized owner CID — the channel enforces the
145
+ * authority boundary before dispatch ever sees the message.
146
+ */
147
+ export async function dispatchOwnerCommand(text, ctx) {
148
+ const trimmed = text.trim();
149
+ const token = trimmed.split(/\s+/, 1)[0];
150
+ const name = token.slice(1).toLowerCase();
151
+ const args = trimmed.slice(token.length).trim();
152
+ const command = ownerCommands.find(entry => entry.name === name || entry.aliases?.includes(name));
153
+ if (!command)
154
+ return ctx.reply(ownerCommandHelp(`unknown command ${strip(token, 60)}`));
155
+ try {
156
+ await command.execute(ctx, args);
157
+ }
158
+ catch (error) {
159
+ if (error instanceof OwnerCommandUsageError)
160
+ return ctx.reply(ownerCommandHelp(error.message));
161
+ // The failure notice carries no internal detail; the channel logs it.
162
+ await ctx.reply(ownerNotices.commandFailed(command.usage ?? `/${command.name}`));
163
+ throw error;
164
+ }
165
+ }
166
+ /**
167
+ * Production fleet effects: the detached ours-fleet CLI. The restart child is
168
+ * detached and unreferenced because a successful restart kills this very
169
+ * process; the reply and the durable wire record must already be on disk.
170
+ */
171
+ export function fleetCliOps(role, configPath) {
172
+ const cli = (args) => [process.argv[1], ...args, ...(configPath ? ['-c', configPath] : [])];
173
+ return {
174
+ restart: mode => new Promise((resolve, reject) => {
175
+ const child = spawn(process.execPath, cli([mode === 'fresh' ? 'force-restart' : 'restart', role]), { detached: true, stdio: 'ignore' });
176
+ child.once('error', reject);
177
+ child.once('spawn', () => { child.unref(); resolve(); });
178
+ }),
179
+ list: () => new Promise((resolve, reject) => {
180
+ execFile(process.execPath, [process.argv[1], 'ls'], { timeout: 15_000, maxBuffer: 256 * 1024 }, (error, stdout) => error ? reject(error) : resolve(String(stdout).trim()));
181
+ }),
182
+ };
183
+ }
@@ -9,6 +9,11 @@ export declare const ownerNotices: {
9
9
  status: (role: string, snapshot: SessionSnapshot) => string;
10
10
  interrupted: (role: string) => string;
11
11
  interruptFailed: (role: string) => string;
12
+ commandStarted: (command: string) => string;
13
+ commandOutcome: (command: string, outcome: TurnOutcome, output?: string) => string;
14
+ commandFailed: (command: string) => string;
15
+ commandUnsupported: (command: string, harness: string) => string;
16
+ restarting: (role: string, command: string, mode: "keep" | "fresh") => string;
12
17
  attachmentRejected: (reason: string) => string;
13
18
  attachmentFailed: () => string;
14
19
  deliveryFailed: (role: string) => string;
@@ -22,6 +22,23 @@ export const ownerNotices = {
22
22
  status: (role, snapshot) => `📊 ${role} status: ${snapshot.readiness}; session is ${snapshot.alive ? 'online' : 'offline'}.`,
23
23
  interrupted: (role) => `🛑 Interrupt sent to ${role}'s active turn.`,
24
24
  interruptFailed: (role) => `⚠️ Could not interrupt ${role}'s active turn.`,
25
+ commandStarted: (command) => `⏳ Running ${command} — the result will follow in this channel.`,
26
+ commandOutcome: (command, outcome, output) => {
27
+ switch (outcome) {
28
+ case 'completed': return `✅ ${command} completed.${output ? `\n${output}` : ''}`;
29
+ case 'cancelled': return `🛑 ${command} was cancelled before completion.`;
30
+ case 'refused': return `⚠️ ${command} was declined by the agent harness.`;
31
+ case 'failed': return `⚠️ ${command} failed before completion.`;
32
+ case 'inconclusive': return `⚠️ ${command} ended without a confirmed completion.`;
33
+ }
34
+ },
35
+ commandFailed: (command) => `⚠️ ${command} could not be executed.`,
36
+ commandUnsupported: (command, harness) => `⚠️ ${command} is not supported on the '${harness}' harness: its bundled ACP `
37
+ + 'adapter does not execute it locally, so forwarding it would deliver the '
38
+ + 'text to the model as an ordinary prompt. Nothing was forwarded.',
39
+ restarting: (role, command, mode) => `ℹ️ ${command} accepted — restarting ${role} ${mode === 'fresh'
40
+ ? 'FRESH (context wiped)' : '(context resumes)'}. `
41
+ + 'The channel goes quiet during the restart and resumes when the agent is back.',
25
42
  attachmentRejected: (reason) => `⚠️ Attachment rejected: ${reason}.`,
26
43
  attachmentFailed: () => '⚠️ Could not securely retrieve or admit this attachment request.',
27
44
  deliveryFailed: (role) => `⚠️ Could not deliver this request to ${role}.`,
package/dist/runner.js CHANGED
@@ -520,11 +520,13 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
520
520
  if (role.owner_channel) {
521
521
  ownerChannel = deps.createOwnerChannel({
522
522
  role: name,
523
+ harness: role.harness,
523
524
  config: role.owner_channel,
524
525
  session: arbiter,
525
526
  stateDir: dir,
526
527
  env: role.env,
527
528
  log: deps.log,
529
+ ...(configPath ? { configPath } : {}),
528
530
  });
529
531
  try {
530
532
  await ownerChannel.start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.13.3",
3
+ "version": "0.14.0",
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",