@ours.network/fleet 0.18.0-nightly.2 → 0.18.0-nightly.4

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
@@ -79,7 +79,7 @@ The state dir contract:
79
79
  | tmux | roles using `session: tmux` (the default) | `apt install tmux` / `brew install tmux` |
80
80
  | Node ≥ 22 | Claude roles using `session: acp` | required by the maintained Claude ACP adapter |
81
81
  | a harness CLI, logged in | the agent itself | e.g. Claude Code (`claude`) or Codex CLI (`codex`) |
82
- | `ours-mcp` daemon | identity + agent-to-agent messaging | `npm i -g @ours.network/mcp && ours-mcp start` |
82
+ | `ours` CLI + shared daemon | identity + agent-to-agent messaging | `npm i -g @ours.network/cli && ours daemon start` |
83
83
 
84
84
  Linux only: `ours-fleet init` enables *linger* so roles run without a login session
85
85
  and survive reboots. macOS: launchd agents start **at login** (no linger
@@ -640,13 +640,14 @@ agent's briefing tells it **not** to arm a native harness Monitor.
640
640
 
641
641
  With `monitor.mode: native`, ours-fleet does not start its supervisor monitor;
642
642
  the generated briefing instead instructs the harness to arm its own wake
643
- mechanism (`ours-mcp watch` for Claude Code, or the Codex
643
+ mechanism (the structured `ours api watch-notifications` JSONL stream for Claude Code,
644
+ or the Codex
644
645
  `arm_monitor`/`foreground_monitor` flow). The old `monitor.enabled: true|false`
645
646
  form remains accepted as a compatibility alias for `fleet|native`, respectively,
646
647
  but new configuration should use `mode`.
647
648
 
648
649
  `inject: full` (pushing message bodies
649
- inline) is on the roadmap and needs two new ours-mcp daemon endpoints; today all
650
+ inline) is on the roadmap and needs two new daemon endpoints; today all
650
651
  roles deliver `notification` lines and drain via `get_messages`.
651
652
 
652
653
  ACP stdio remains private to the persistent runner. `send`, `peek`, and the basic
@@ -874,7 +875,7 @@ Processed wire IDs are durably bounded for deduplication, while
874
875
  message and response bodies stay out of fleet state. Delivery is at-least-once
875
876
  across a crash (the bridge requeues fetched input before starting a turn); true
876
877
  exactly-once processing would require a leased claim/idempotency primitive in
877
- ours-mcp.
878
+ the ours daemon.
878
879
 
879
880
  Inbound owner attachments use the same authenticated-CID and exact-wire routing
880
881
  boundary. Fleet first calls the metadata-only `list_incoming_files`, groups a
@@ -885,7 +886,7 @@ unauthorized sender is ignored without retrieval or reply. A rejected authorized
885
886
  request receives a bounded reason correlated to its file wire.
886
887
 
887
888
  Retrieved files must be regular, non-symlink paths whose byte count and SHA-256
888
- match ours-mcp metadata. Fleet additionally checks content signatures against the
889
+ match typed SDK metadata. Fleet additionally checks content signatures against the
889
890
  declared MIME, sanitizes traversal/control characters from names, and copies each
890
891
  file into a random request-scoped directory at mode 0700 with files at mode 0600.
891
892
  The `[fleet-owner]` turn receives only bounded metadata, the private local paths,
@@ -896,17 +897,17 @@ the agent must use the audio path rather than inventing text.
896
897
  Request files are removed after final delivery and stale directories are removed
897
898
  after `retention_ms`. A bounded mode-0600 recovery journal stores only owner CID
898
899
  and wire routing metadata—never filenames, paths, captions, transcripts, or file
899
- bytes. If ours-mcp already marked a selected file processed when fleet restarts,
900
- fleet resumes only that journaled wire with `save_file`; a deferred managed-agent
900
+ bytes. If the daemon already marked a selected file processed when fleet restarts,
901
+ fleet resumes only that journaled wire with the SDK file-fetch operation; a deferred managed-agent
901
902
  caption is replayed with its journaled processed files before the group is
902
903
  admitted or relayed. Conversation route state migrates from v1 to a bounded v2
903
904
  source-wire index so a correlated group keeps the authenticated owner selected by
904
905
  its original request even after later owner traffic. Recovered voice is explicitly
905
906
  marked transcript-unavailable. Corrupt recovery state disables attachment
906
- admission. The host must run an ours-mcp version whose
907
- `list_incoming_files`, selective `get_files`, and `save_file` schemas support
908
- these guarantees; `ours-mcp voice-status --json` reports whether transcription
909
- is currently configured.
907
+ admission. The host must run an ours daemon compatible with SDK 2.0.1 whose
908
+ typed incoming-file, selective retrieval, and file-fetch operations support
909
+ these guarantees; `ours config show --json` reports `sttConfigured` without
910
+ revealing provider credentials.
910
911
 
911
912
  Managed-agent file egress is a separate trust direction. It retains the same
912
913
  metadata provenance checks, count/per-file/request byte caps, regular-file and
@@ -952,7 +953,7 @@ pass the legacy `ours-fleet spawn --monitor` flag) to record explicit persistent
952
953
  for the harness-native monitor, including supervised restarts. This does not choose the
953
954
  wake owner; use `monitor.mode: fleet|native` for that. In the standard-Codex fallback, the agent separately surfaces the
954
955
  `ours-codex` recommendation before asking to enter `foreground_monitor`. It never backgrounds
955
- `ours-mcp watch`, because a detached watch cannot wake a Codex turn. The foreground
956
+ the structured CLI watcher, because a detached process cannot wake a Codex turn. The foreground
956
957
  wait is re-entered after each handled message; `ours-codex` instead wakes the idle
957
958
  session through its App Server integration.
958
959
 
@@ -1036,7 +1037,7 @@ picks the most recently active session **in the role's `cwd`**. This works
1036
1037
  cleanly as long as each role has its own `cwd` (the common case); two roles
1037
1038
  sharing an identical `cwd` could have their resumes cross — give them distinct
1038
1039
  working directories if that matters. MCP and monitor wiring is provided by
1039
- [`@ours.network/codex`](https://github.com/adapt-toolkit/ours-mcp/tree/main/packages/codex);
1040
+ [`@ours.network/codex`](https://www.npmjs.com/package/@ours.network/codex);
1040
1041
  the core ours skill discovers fleet behavior through `ours-fleet docs`.
1041
1042
  `ours-fleet doctor --harness codex` verifies the CLI, ours plugin, and enhanced launcher/fallback.
1042
1043
 
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.18.0-nightly.2",
3
- "buildId": "3ca1204392e7",
4
- "commit": "8da7b56012fd83c164332cc7c39ccaf7c4f13cbb",
2
+ "version": "0.18.0-nightly.4",
3
+ "buildId": "35a3c2d21eb9",
4
+ "commit": "1b5b2fa89f60b64b80877d23d45211fb970e4242",
5
5
  "dirty": true,
6
- "builtAt": "2026-08-17T19:38:25.144Z",
6
+ "builtAt": "2026-08-21T08:23:47.864Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
@@ -1,5 +1,5 @@
1
+ import { type AttachOursClientOptions, type OursClient } from '@ours.network/sdk/client';
1
2
  import { type LockDeps } from './atomic-file.js';
2
- import { type FetchLike } from './monitor.js';
3
3
  export type ReservationKind = 'role' | 'identity';
4
4
  export interface Reservation {
5
5
  kind: ReservationKind;
@@ -82,11 +82,10 @@ export declare function clearStaleReservations(olderThanMs?: number, now?: numbe
82
82
  * Identity provisioning (7.3). The fleet must know — before the harness starts
83
83
  * — whether the role's identity exists, and create it when it does not.
84
84
  *
85
- * `exists()` is answerable today: the daemon's authenticated `/identities`
86
- * endpoint is already used by doctor. `create()` is NOT: `ours-mcp` exposes only
87
- * `create-root`, and role identities are minted through the MCP `create_identity`
88
- * tool inside an agent session. So creation is a seam, injected by whoever can
89
- * satisfy it, and its absence is reported rather than papered over.
85
+ * `exists()` is answered through the typed SDK daemon inventory. `create()` is
86
+ * intentionally still a seam: fleet preserves application-owned identity
87
+ * creation/binding, especially the session-owned lifetime of temporary roles.
88
+ * Its absence is reported rather than papered over.
90
89
  */
91
90
  export interface IdentityProvisioner {
92
91
  /** Does this identity exist? `unknown` when the daemon could not be asked. */
@@ -128,16 +127,17 @@ export declare function ensureIdentity(name: string, profile: {
128
127
  persona?: string;
129
128
  }, provisioner: IdentityProvisioner | undefined, log?: (line: string) => void): Promise<IdentityGuarantee>;
130
129
  /**
131
- * Ask the running ours daemon whether an identity exists, over the same
132
- * authenticated endpoint doctor already probes. Answers `unknown` rather than
133
- * guessing when the daemon cannot be reached — an unreachable daemon is not
134
- * evidence that the identity is missing.
130
+ * Ask the running ours daemon whether an identity exists through SDK 2's
131
+ * coherence-checking attach path. Answers `unknown` rather than guessing when
132
+ * the daemon cannot be reached — an unreachable daemon is not evidence that
133
+ * the identity is missing.
135
134
  *
136
- * It deliberately has no `create()`: role identities are minted through the MCP
137
- * `create_identity` tool, and inventing a daemon endpoint we cannot test is the
138
- * failure mode this release exists to stop.
135
+ * It deliberately has no `create()`: roles still own identity selection and
136
+ * binding inside their application session, and temporary identities must stay
137
+ * owned by that session so daemon cleanup semantics remain intact.
139
138
  */
140
- export declare function daemonIdentityProvisioner(env?: NodeJS.ProcessEnv, fetchImpl?: FetchLike): IdentityProvisioner;
139
+ type IdentityInventoryClient = Pick<OursClient, 'identities'>;
140
+ export declare function daemonIdentityProvisioner(env?: NodeJS.ProcessEnv, attachClient?: (options: AttachOursClientOptions) => Promise<IdentityInventoryClient>): IdentityProvisioner;
141
141
  /** Atomically write a role's fleet.d file, journalling it for rollback. */
142
142
  export declare function writeRoleFile(tx: CreationTransaction, file: string, contents: string): void;
143
143
  /** Where a setting's effective value came from. */
@@ -207,3 +207,4 @@ export declare function creationBuildNote(p: CreationProvenance): string | undef
207
207
  export declare function formatProvenance(p: CreationProvenance): string[];
208
208
  /** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
209
209
  export declare function provenanceOf(cliValue: unknown, fleetDefault: unknown, builtIn?: unknown): ProvenanceEntry;
210
+ export {};
package/dist/creation.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { attachOursClient, } from '@ours.network/sdk/client';
3
4
  import { replaceFileAtomically, withFileLock } from './atomic-file.js';
4
5
  import { stateRoot } from './paths.js';
5
- import { resolveEndpoint } from './monitor.js';
6
6
  import { buildInfo, UNKNOWN_BUILD } from './provenance.js';
7
7
  /**
8
8
  * One creation transaction: role name and ours identity reserved together,
@@ -189,27 +189,21 @@ export async function ensureIdentity(name, profile, provisioner, log = () => { }
189
189
  detail: 'created during spawn, with its bio and persona published',
190
190
  };
191
191
  }
192
- /**
193
- * Ask the running ours daemon whether an identity exists, over the same
194
- * authenticated endpoint doctor already probes. Answers `unknown` rather than
195
- * guessing when the daemon cannot be reached — an unreachable daemon is not
196
- * evidence that the identity is missing.
197
- *
198
- * It deliberately has no `create()`: role identities are minted through the MCP
199
- * `create_identity` tool, and inventing a daemon endpoint we cannot test is the
200
- * failure mode this release exists to stop.
201
- */
202
- export function daemonIdentityProvisioner(env = process.env, fetchImpl = (u, i) => globalThis.fetch(u, i)) {
192
+ export function daemonIdentityProvisioner(env = process.env, attachClient = attachOursClient) {
203
193
  return {
204
194
  async exists(name) {
205
- const ep = resolveEndpoint(env);
206
- const resp = await fetchImpl(`${ep.origin}/identities`, { headers: ep.headers });
207
- if (!resp.ok)
208
- return 'unknown';
209
- const body = await resp.json();
210
- if (!Array.isArray(body.identities))
195
+ try {
196
+ const client = await attachClient({
197
+ env, leaseToken: `ours-fleet-identity-preflight-${process.pid}`, clientPid: process.pid,
198
+ });
199
+ const identities = await client.identities();
200
+ if (!Array.isArray(identities))
201
+ return 'unknown';
202
+ return identities.some(identity => identity.name === name);
203
+ }
204
+ catch {
211
205
  return 'unknown';
212
- return body.identities.some(i => (typeof i === 'string' ? i : i?.name) === name);
206
+ }
213
207
  },
214
208
  };
215
209
  }
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\nTemporary-role identity bootstrap is capability-based. The generated briefing\nfirst tries to bind the exact assigned identity and preserves it when it already\nexists. If missing, it uses ours MCP `create_temporary_identity` when that tool\nis exposed, tying a newly-created identity to the connector session lifecycle;\nolder servers fall back to `create_identity`. Collisions and creation errors\nstop safely without force-adopting or deleting identity state. Permanent roles\nretain normal `create_identity` behavior.\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 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|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` maps to Codex `on-request` and Claude `acceptEdits`; and\n`approval: allow` maps to Codex `never` and Claude `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` 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, so fleet\nkeeps the selected sandbox preset and enforces the independently translated\napproval policy on the app-server turn request. For example, `allow` plus\n`workspace` is really `approval=never sandbox=workspace-write`; it is never\nwidened to `danger-full-access`. The live session reports both its effective\nnormalized mode and the ACP sandbox-preset ID.\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. 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, 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 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\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, 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. For proactive or in-turn agent\nattachments, the agent calls ours `send_file` to the channel identity and may\npair it with a reply-linked caption; fleet, not the agent, chooses the owner.\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 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`. A deferred agent\ncaption is replayed with its processed files 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\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]\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\nTemporary-role identity bootstrap is capability-based. The generated briefing\nfirst tries to bind the exact assigned identity and preserves it when it already\nexists. If missing, it uses ours MCP `create_temporary_identity` when that tool\nis exposed, tying a newly-created identity to the connector session lifecycle;\nolder servers fall back to `create_identity`. Collisions and creation errors\nstop safely without force-adopting or deleting identity state. Permanent roles\nretain normal `create_identity` behavior.\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 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|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` maps to Codex `on-request` and Claude `acceptEdits`; and\n`approval: allow` maps to Codex `never` and Claude `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` 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, so fleet\nkeeps the selected sandbox preset and enforces the independently translated\napproval policy on the app-server turn request. For example, `allow` plus\n`workspace` is really `approval=never sandbox=workspace-write`; it is never\nwidened to `danger-full-access`. The live session reports both its effective\nnormalized mode and the ACP sandbox-preset ID.\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. 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, 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 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\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, 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. For proactive or in-turn agent\nattachments, the agent calls ours `send_file` to the channel identity and may\npair it with a reply-linked caption; fleet, not the agent, chooses the owner.\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\nfallback. Run `ours config show --json` and inspect `sttConfigured` without\nrevealing provider credentials.\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`. A deferred agent\ncaption is replayed with its processed files 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\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
@@ -552,9 +552,10 @@ their content signature must match the declared MIME, and symlinks or non-regula
552
552
  paths fail closed. Sanitized copies live only in a mode-0700 request directory as
553
553
  mode-0600 files and are removed after completion or bounded stale retention.
554
554
 
555
- Voice prompts include a bounded transcript only when ours-mcp reports success.
555
+ Voice prompts include a bounded transcript only when typed daemon metadata reports success.
556
556
  Failure or unavailability is explicit and preserves the private audio path as the
557
- fallback. Run \`ours-mcp voice-status --json\` to inspect the host configuration.
557
+ fallback. Run \`ours config show --json\` and inspect \`sttConfigured\` without
558
+ revealing provider credentials.
558
559
  A mode-0600 crash journal contains only authenticated CID and wire routing data;
559
560
  it never stores captions, filenames, paths, transcript text, or bytes. Journaled
560
561
  post-retrieval files resume selectively through \`save_file\`. A deferred agent
package/dist/doctor.js CHANGED
@@ -112,16 +112,39 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
112
112
  detail: tmux.code === 0 ? tmux.stdout.trim() : 'not found — apt install tmux / brew install tmux',
113
113
  });
114
114
  }
115
- const mcp = await exec('ours-mcp', ['--version']);
115
+ const ours = await exec('ours', ['version', '--json']);
116
+ let oursVersion;
117
+ if (ours.code === 0) {
118
+ try {
119
+ const value = JSON.parse(ours.stdout);
120
+ if (value.name === '@ours.network/cli' && typeof value.version === 'string')
121
+ oursVersion = value.version;
122
+ }
123
+ catch { /* malformed structured output is a failed prerequisite below */ }
124
+ }
116
125
  checks.push({
117
- name: 'ours-mcp', ok: mcp.code === 0,
118
- detail: mcp.code === 0 ? mcp.stdout.trim() : 'not found — npm i -g @ours.network/mcp',
126
+ name: 'ours CLI', ok: oursVersion !== undefined,
127
+ detail: oursVersion
128
+ ? `@ours.network/cli ${oursVersion}`
129
+ : 'not found or invalid structured output — npm i -g @ours.network/cli',
119
130
  });
120
- if (mcp.code === 0) {
121
- const st = await exec('ours-mcp', ['status']);
131
+ if (oursVersion !== undefined) {
132
+ const st = await exec('ours', ['daemon', 'status', '--json']);
133
+ let state;
134
+ try {
135
+ const value = JSON.parse(st.stdout);
136
+ if (typeof value.state === 'string')
137
+ state = value.state;
138
+ }
139
+ catch { /* malformed structured output fails closed */ }
140
+ const running = st.code === 0 && state === 'running';
122
141
  checks.push({
123
- name: 'ours-mcp daemon', ok: st.code === 0,
124
- detail: st.code === 0 ? 'running' : 'not running — start it with: ours-mcp start',
142
+ name: 'ours daemon', ok: running,
143
+ detail: running
144
+ ? 'running'
145
+ : state === 'stopped' || st.code === 3
146
+ ? 'not running — start it with: ours daemon start'
147
+ : 'status check returned invalid structured output — run: ours daemon status --json',
125
148
  });
126
149
  }
127
150
  if (platform === 'linux') {
@@ -288,7 +311,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
288
311
  }
289
312
  catch (e) {
290
313
  detail = `unreachable on :${endpoint.port} — monitored roles run degraded until it is up ` +
291
- `(start it: ours-mcp start) [${e?.message ?? e}]`;
314
+ `(start it: ours daemon start) [${e?.message ?? e}]`;
292
315
  }
293
316
  checks.push({ name: checkName, ok, detail });
294
317
  }
@@ -127,8 +127,11 @@ export async function pretrust(dir, deps = {}) {
127
127
  * it must be the Monitor TOOL, not a background Bash task (which never wakes the
128
128
  * agent on output → an armed-looking but deaf monitor).
129
129
  */
130
+ const shellQuote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
131
+ const watchCommand = (id) => 'ours api watch-notifications --input '
132
+ + shellQuote(JSON.stringify({ identity: id, since: 'tip' })) + ' --json';
130
133
  const armMonitor = (id) => 'arm a **persistent Monitor** (the Monitor TOOL — NOT a background Bash command; a ' +
131
- `background Bash task never wakes you on output) running \`ours-mcp watch "${id}"\` ` +
134
+ `background Bash task never wakes you on output) running \`${watchCommand(id)}\` ` +
132
135
  'so inbound ours mail wakes you';
133
136
  export function makeClaudeCodeAdapter(exec = realExec) {
134
137
  return {
@@ -282,7 +285,6 @@ export function makeClaudeCodeAdapter(exec = realExec) {
282
285
  currentIdentityTool: 'current_identity',
283
286
  sendTool: 'send_message',
284
287
  getMessagesTool: 'get_messages',
285
- watchCommand: id => `ours-mcp watch "${id}"`,
286
288
  monitorInstruction: id => {
287
289
  const m = armMonitor(id);
288
290
  return `${m.charAt(0).toUpperCase()}${m.slice(1)}.`;
@@ -411,7 +411,6 @@ export function makeCodexAdapter(exec = realExec) {
411
411
  currentIdentityTool: 'current_identity',
412
412
  sendTool: 'send_message',
413
413
  getMessagesTool: 'get_messages',
414
- watchCommand: id => `ours-mcp watch "${id}"`,
415
414
  monitorInstruction: (id, configuredRole) => {
416
415
  const consented = configuredRole?.harness_options?.monitor === true;
417
416
  const consent = consented
@@ -67,7 +67,6 @@ export interface BriefingVocab {
67
67
  currentIdentityTool: string;
68
68
  sendTool: string;
69
69
  getMessagesTool: string;
70
- watchCommand(identity: string): string;
71
70
  monitorInstruction(identity: string, role?: ResolvedRole): string;
72
71
  /** Wake-source wording for a role whose monitor is supervisor-owned (monitor.mode=fleet). */
73
72
  supervisedWakeNote(identity: string, role?: ResolvedRole): string;
package/dist/monitor.js CHANGED
@@ -42,7 +42,7 @@ class AuthError extends Error {
42
42
  }
43
43
  /** Path to the daemon config the MCP client uses: OURS_CONFIG ?? real ~/.ours/config.json. */
44
44
  const daemonConfigPath = (env) => env.OURS_CONFIG ?? join(homedir(), '.ours', 'config.json');
45
- /** Match ours-mcp's env integer semantics: parseInt, invalid → absent. */
45
+ /** Preserve the daemon's legacy env integer semantics: parseInt, invalid → absent. */
46
46
  function envInt(env, name) {
47
47
  const raw = env[name];
48
48
  if (raw === undefined)
@@ -1,6 +1,4 @@
1
- import { type ChildProcessWithoutNullStreams } from 'node:child_process';
2
1
  import { type OwnerChannelConfig } from '../config.js';
3
- import { type FetchLike } from '../monitor.js';
4
2
  import { type SessionHandle } from '../session/types.js';
5
3
  import { type OwnerFleetOps } from './commands.js';
6
4
  import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
@@ -17,19 +15,8 @@ export interface OwnerChannelOptions {
17
15
  session: SessionHandle;
18
16
  stateDir: string;
19
17
  env?: Record<string, string>;
20
- /**
21
- * `ours-mcp` binary for the legacy `watch` child process only. Daemon
22
- * operations no longer go through it; they use the ours SDK client.
23
- */
24
- command?: string;
25
18
  log(line: string): void;
26
19
  client?: OursOps;
27
- /** Legacy child-process test seam; production uses the direct notification API. */
28
- watch?: (identity: string) => ChildProcessWithoutNullStreams;
29
- /** Test seam for the production direct notification long-poll. */
30
- watchFetch?: FetchLike;
31
- /** Test seam for the long-poll stall bound; production uses OWNER_WATCH_STALL_MS. */
32
- watchStallMs?: number;
33
20
  /** Test seam; production uses the detached ours-fleet CLI (`fleetCliOps`). */
34
21
  fleet?: OwnerFleetOps;
35
22
  /** Forwarded to fleet CLI invocations spawned for owner commands. */
@@ -165,7 +152,6 @@ export declare class OwnerChannel implements OwnerChannelHandle {
165
152
  private readonly commentsBaseline;
166
153
  private commentsEnabled;
167
154
  private stopping;
168
- private watchProcess?;
169
155
  private watchTask?;
170
156
  private watchAbort?;
171
157
  private drainTask?;
@@ -273,15 +259,12 @@ export declare class OwnerChannel implements OwnerChannelHandle {
273
259
  private progressPhase;
274
260
  private watchLoop;
275
261
  /**
276
- * `recovered` distinguishes a first-ever start (no state, nothing lost) from a
277
- * cursor we HAD and can no longer read. Only the latter is a recovery, and the
278
- * caller needs to know because the reason it reports is the only evidence a
279
- * durable cursor was ever lost.
262
+ * `recovered` distinguishes a first-ever start from unreadable persisted
263
+ * diagnostics. Notification correctness does not depend on this state: every
264
+ * establishment drains and then replays SDK hints from offset zero.
280
265
  */
281
266
  private readWatchState;
282
267
  private writeWatchState;
283
- /** Compatibility path for injected child-process tests; production is direct. */
284
- private legacyWatchLoop;
285
268
  private errorText;
286
269
  private logError;
287
270
  }
@@ -1,12 +1,9 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { createHash } from 'node:crypto';
3
2
  import { existsSync, readFileSync } from 'node:fs';
4
3
  import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
5
- import { createInterface } from 'node:readline';
6
4
  import { join } from 'node:path';
7
5
  import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
8
6
  import { replaceFileAtomically } from '../atomic-file.js';
9
- import { resolveEndpoint } from '../monitor.js';
10
7
  import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, interruptOutcome, } from '../session/types.js';
11
8
  import { VERSION } from '../version.js';
12
9
  import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
@@ -28,15 +25,7 @@ const COMMENTARY_MAX_CHARS = 1_600;
28
25
  const COMMENTARY_MAX_BYTES = 6_400;
29
26
  const COMMENTARY_MAX_UPDATES = 32;
30
27
  const COMMENTARY_DEDUPE_LIMIT = 512;
31
- const OWNER_WATCH_STALL_MS = 120_000;
32
28
  const OWNER_WATCH_BACKOFF_MAX_MS = 30_000;
33
- /**
34
- * Credentials that are wrong now are wrong on the next attempt too. Retrying a
35
- * permanent 401 forever burns the daemon and hides the real fault behind an
36
- * endless reconnect log, so the watch stops after this many consecutive auth
37
- * rejections and records a terminal reason instead.
38
- */
39
- const OWNER_WATCH_AUTH_FATAL_ATTEMPTS = 5;
40
29
  /** A relay attempt that failed only because no owner route exists yet. */
41
30
  class RelayUnroutableError extends Error {
42
31
  }
@@ -70,7 +59,6 @@ export class OwnerChannel {
70
59
  commentsBaseline;
71
60
  commentsEnabled;
72
61
  stopping = false;
73
- watchProcess;
74
62
  watchTask;
75
63
  watchAbort;
76
64
  drainTask;
@@ -154,9 +142,10 @@ export class OwnerChannel {
154
142
  void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
155
143
  }
156
144
  this.ready = true;
157
- this.watchTask = this.options.watch ? this.legacyWatchLoop() : this.watchLoop();
158
145
  // Do not make role startup wait for an old owner request to finish a turn.
159
- void this.drain().catch(error => this.logError('initial drain failed', error));
146
+ // watchLoop itself drains before every establishment, including this first
147
+ // one, so there is no drain-to-tip race.
148
+ this.watchTask = this.watchLoop();
160
149
  }
161
150
  drain() {
162
151
  this.drainRequested = true;
@@ -173,13 +162,8 @@ export class OwnerChannel {
173
162
  async close() {
174
163
  this.stopping = true;
175
164
  this.ready = false;
176
- const watch = this.watchProcess;
177
- this.watchProcess = undefined;
178
- if (watch && watch.exitCode === null)
179
- watch.kill('SIGTERM');
180
165
  this.watchAbort?.abort();
181
- if (!this.options.watch)
182
- await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
166
+ await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
183
167
  this.watchTask = undefined;
184
168
  await this.managementTail;
185
169
  try {
@@ -1583,90 +1567,65 @@ export class OwnerChannel {
1583
1567
  return undefined;
1584
1568
  }
1585
1569
  async watchLoop() {
1586
- const endpoint = resolveEndpoint({ ...process.env, ...(this.options.env ?? {}) });
1587
- const fetch = this.options.watchFetch
1588
- ?? ((url, init) => globalThis.fetch(url, init));
1589
1570
  const sleep = this.options.binderDeps?.sleep
1590
1571
  ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
1591
1572
  const restored = this.readWatchState();
1592
1573
  let state = restored.state;
1593
- // An unreadable cursor is the ONLY reason to restart at the tip. Say so on
1594
- // the next successful connect, and drain first: everything the lost cursor
1595
- // would have pointed at is still in the inbox, which is the authority.
1596
- let recovering = restored.recovered;
1597
- let cursor = state?.cursor ?? 'tip';
1574
+ if (restored.recovered)
1575
+ state = this.writeWatchState(state, 'OWNER_WATCH_STATE_RECOVERED');
1598
1576
  let delayMs = 1_000;
1599
- let authFailures = 0;
1600
- if (recovering)
1601
- await this.drain().catch(error => this.logError('cursor recovery drain failed', error));
1577
+ let attempts = 0;
1602
1578
  while (!this.stopping) {
1603
1579
  const ctrl = new AbortController();
1604
1580
  this.watchAbort = ctrl;
1605
- let stalled = false;
1606
- let authRejected = false;
1607
- const timer = setTimeout(() => { stalled = true; ctrl.abort(); }, this.options.watchStallMs ?? OWNER_WATCH_STALL_MS);
1608
- timer.unref?.();
1609
1581
  try {
1610
- const response = await fetch(`${endpoint.url(this.options.config.identity)}?since=${cursor}`, { headers: endpoint.headers, signal: ctrl.signal });
1611
- if (response.status === 401) {
1612
- authRejected = true;
1613
- authFailures++;
1614
- const at = cursor === 'tip' ? state?.cursor ?? 0 : cursor;
1615
- if (authFailures >= OWNER_WATCH_AUTH_FATAL_ATTEMPTS) {
1616
- state = this.writeWatchState(state, at, 'OWNER_WATCH_AUTH_FATAL', true);
1617
- this.options.log(`[${this.options.role}] owner watch stopped `
1618
- + `reason=OWNER_WATCH_AUTH_FATAL after ${authFailures} consecutive HTTP 401 responses; `
1619
- + 'owner notifications require re-authorization and will not be retried');
1582
+ // This drain is unconditional at EVERY establishment. Starting the SDK
1583
+ // stream at 0 then replays notification hints instead of tip-priming,
1584
+ // so mail arriving after the drain but before the first request cannot
1585
+ // fall into a gap. The inbox is authoritative and its durable wire-ID
1586
+ // dedupe makes replayed hints harmless.
1587
+ await this.drain();
1588
+ if (this.stopping)
1589
+ return;
1590
+ state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTING', { reconnected: attempts > 0 });
1591
+ attempts++;
1592
+ for await (const _event of this.client.watchNotifications(this.options.config.identity, { since: 0, signal: ctrl.signal })) {
1593
+ if (this.stopping)
1620
1594
  return;
1621
- }
1622
- state = this.writeWatchState(state, at, 'OWNER_WATCH_AUTH_FAILED', true);
1623
- throw new Error('OWNER_WATCH_AUTH_FAILED: daemon rejected notification credentials');
1624
- }
1625
- if (!response.ok)
1626
- throw new Error(`daemon returned HTTP ${response.status}`);
1627
- const body = await response.json();
1628
- const next = typeof body.cursor === 'number' ? body.cursor : cursor === 'tip' ? 0 : cursor;
1629
- const reconnect = (state?.consecutiveFailures ?? 0) > 0;
1630
- state = this.writeWatchState(state, next, recovering ? 'OWNER_WATCH_CURSOR_RECOVERED' : 'OWNER_WATCH_CONNECTED', false, reconnect);
1631
- recovering = false;
1632
- authFailures = 0;
1633
- cursor = next;
1634
- delayMs = 1_000;
1635
- // Notification events are content-free hints. The inbox remains the
1636
- // authority and its wire-level durable dedupe prevents duplicate turns.
1637
- if ((body.events?.length ?? 0) > 0)
1595
+ state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTED', { resetFailures: true });
1596
+ delayMs = 1_000;
1597
+ // Notification events contain no bodies and are only wake hints.
1598
+ // Draining is idempotent at the turn boundary because wire IDs are
1599
+ // recorded before a managed request is dispatched.
1638
1600
  await this.drain();
1601
+ }
1602
+ if (!this.stopping)
1603
+ throw new Error('ours SDK notification stream ended');
1639
1604
  }
1640
1605
  catch (error) {
1641
1606
  if (this.stopping)
1642
1607
  return;
1643
- const reason = stalled
1644
- ? 'OWNER_WATCH_STALLED' : 'OWNER_WATCH_STREAM_ERROR';
1645
- const current = cursor === 'tip' ? state?.cursor ?? 0 : cursor;
1646
- cursor = current;
1647
- // Only THIS iteration's auth rejection is already written. A stale
1648
- // AUTH_FAILED from an earlier attempt must never suppress the cursor,
1649
- // the failure counter, or a later STALLED transition.
1650
- if (!authRejected)
1651
- state = this.writeWatchState(state, current, reason, true);
1608
+ // SDK 2 deliberately hides transport status behind its typed stream.
1609
+ // Do not parse error prose to rediscover it: every failure follows the
1610
+ // same capped retry path forever, and the pre-establishment drain makes
1611
+ // that retry correctness-preserving.
1612
+ state = this.writeWatchState(state, 'OWNER_WATCH_STREAM_ERROR', { failed: true });
1652
1613
  this.options.log(`[${this.options.role}] owner watch reconnect `
1653
- + `reason=${authRejected ? 'OWNER_WATCH_AUTH_FAILED' : reason} `
1654
- + `delay_ms=${delayMs} cursor=${current}`);
1614
+ + `reason=OWNER_WATCH_STREAM_ERROR delay_ms=${delayMs} `
1615
+ + `failures=${state.consecutiveFailures}: ${this.errorText(error)}`);
1655
1616
  await sleep(delayMs);
1656
1617
  delayMs = Math.min(delayMs * 2, OWNER_WATCH_BACKOFF_MAX_MS);
1657
1618
  }
1658
1619
  finally {
1659
- clearTimeout(timer);
1660
1620
  if (this.watchAbort === ctrl)
1661
1621
  this.watchAbort = undefined;
1662
1622
  }
1663
1623
  }
1664
1624
  }
1665
1625
  /**
1666
- * `recovered` distinguishes a first-ever start (no state, nothing lost) from a
1667
- * cursor we HAD and can no longer read. Only the latter is a recovery, and the
1668
- * caller needs to know because the reason it reports is the only evidence a
1669
- * durable cursor was ever lost.
1626
+ * `recovered` distinguishes a first-ever start from unreadable persisted
1627
+ * diagnostics. Notification correctness does not depend on this state: every
1628
+ * establishment drains and then replays SDK hints from offset zero.
1670
1629
  */
1671
1630
  readWatchState() {
1672
1631
  const path = join(this.options.stateDir, '.owner-channel-watch.json');
@@ -1674,76 +1633,43 @@ export class OwnerChannel {
1674
1633
  return { recovered: false };
1675
1634
  try {
1676
1635
  const value = JSON.parse(readFileSync(path, 'utf8'));
1677
- if (value.version !== 1 || !Number.isSafeInteger(value.cursor) || value.cursor < 0
1636
+ if ((value.version !== 1 && value.version !== 2)
1678
1637
  || !Number.isSafeInteger(value.reconnects) || value.reconnects < 0
1679
1638
  || !Number.isSafeInteger(value.consecutiveFailures) || value.consecutiveFailures < 0)
1680
1639
  throw new Error('invalid owner watch state');
1681
- return { state: value, recovered: false };
1640
+ const reasons = new Set([
1641
+ 'OWNER_WATCH_CONNECTING', 'OWNER_WATCH_CONNECTED',
1642
+ 'OWNER_WATCH_STREAM_ERROR', 'OWNER_WATCH_STATE_RECOVERED',
1643
+ ]);
1644
+ return { state: {
1645
+ version: 2,
1646
+ reconnects: value.reconnects,
1647
+ consecutiveFailures: value.consecutiveFailures,
1648
+ reason: reasons.has(value.reason)
1649
+ ? value.reason : 'OWNER_WATCH_CONNECTING',
1650
+ updatedAt: typeof value.updatedAt === 'string'
1651
+ ? value.updatedAt : new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
1652
+ }, recovered: false };
1682
1653
  }
1683
1654
  catch {
1684
1655
  this.options.log(`[${this.options.role}] owner watch `
1685
- + 'reason=OWNER_WATCH_CURSOR_RECOVERED invalid cursor state; draining inbox and starting at tip');
1656
+ + 'reason=OWNER_WATCH_STATE_RECOVERED invalid persisted counters; restarting safely');
1686
1657
  return { recovered: true };
1687
1658
  }
1688
1659
  }
1689
- writeWatchState(previous, cursor, reason, failed, reconnected = false) {
1660
+ writeWatchState(previous, reason, options = {}) {
1690
1661
  const state = {
1691
- version: 1,
1692
- cursor,
1693
- reconnects: (previous?.reconnects ?? 0) + (reconnected ? 1 : 0),
1694
- consecutiveFailures: failed ? (previous?.consecutiveFailures ?? 0) + 1 : 0,
1662
+ version: 2,
1663
+ reconnects: (previous?.reconnects ?? 0) + (options.reconnected ? 1 : 0),
1664
+ consecutiveFailures: options.failed
1665
+ ? (previous?.consecutiveFailures ?? 0) + 1
1666
+ : options.resetFailures ? 0 : previous?.consecutiveFailures ?? 0,
1695
1667
  reason,
1696
1668
  updatedAt: new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
1697
1669
  };
1698
1670
  replaceFileAtomically(join(this.options.stateDir, '.owner-channel-watch.json'), `${JSON.stringify(state)}\n`, 0o600);
1699
1671
  return state;
1700
1672
  }
1701
- /** Compatibility path for injected child-process tests; production is direct. */
1702
- async legacyWatchLoop() {
1703
- let delayMs = 1_000;
1704
- while (!this.stopping) {
1705
- try {
1706
- const child = this.options.watch?.(this.options.config.identity) ?? spawn(this.options.command ?? 'ours-mcp', ['watch', this.options.config.identity], {
1707
- env: { ...process.env, ...(this.options.env ?? {}) }, stdio: ['pipe', 'pipe', 'pipe'],
1708
- });
1709
- this.watchProcess = child;
1710
- await new Promise((resolve, reject) => {
1711
- if (child.pid) {
1712
- resolve();
1713
- return;
1714
- }
1715
- child.once('spawn', resolve);
1716
- child.once('error', reject);
1717
- });
1718
- createInterface({ input: child.stderr }).on('line', line => this.options.log(`[${this.options.role}] owner watch: ${line}`));
1719
- delayMs = 1_000;
1720
- // Drain at every (re)attachment, not only after a future notification:
1721
- // a failed send/turn leaves the input deferred and may not emit another
1722
- // watch line by itself.
1723
- await this.drain();
1724
- for await (const _line of createInterface({ input: child.stdout })) {
1725
- if (this.stopping)
1726
- break;
1727
- await this.drain();
1728
- }
1729
- if (!this.stopping)
1730
- throw new Error('watch exited');
1731
- }
1732
- catch (error) {
1733
- if (!this.stopping) {
1734
- this.logError('watch failed; retrying', error);
1735
- await new Promise(resolve => setTimeout(resolve, delayMs));
1736
- delayMs = Math.min(delayMs * 2, 30_000);
1737
- }
1738
- }
1739
- finally {
1740
- const child = this.watchProcess;
1741
- this.watchProcess = undefined;
1742
- if (child && child.exitCode === null)
1743
- child.kill('SIGTERM');
1744
- }
1745
- }
1746
- }
1747
1673
  errorText(error) {
1748
1674
  return error?.message ?? String(error);
1749
1675
  }
@@ -1,4 +1,4 @@
1
- import { OursClient, type OursClientOptions } from '@ours.network/sdk/client';
1
+ import { OursClient, type AttachOursClientOptions, type NotificationEvent } from '@ours.network/sdk/client';
2
2
  /** Any failure of a daemon operation. Never carries a message body or a token. */
3
3
  export declare class OursDaemonError extends Error {
4
4
  }
@@ -19,11 +19,14 @@ export type OursInboundMessage = OursMessagesPayload['messages'][number];
19
19
  export type OursIncomingFile = Res<'listIncomingFiles'>[number];
20
20
  export type OursRetrievedFiles = Res<'getFiles'>;
21
21
  export type OursRetrievedFile = OursRetrievedFiles['files'][number];
22
+ export type OursNotificationEvent = NotificationEvent;
23
+ export declare class OursWatchDeadlineError extends OursDaemonError {
24
+ }
22
25
  /**
23
26
  * The daemon operations the owner channel needs, one typed method each.
24
27
  *
25
28
  * This interface deliberately has no generic `callTool(name, args): unknown`
26
- * escape hatch. The MCP surface had one, and because ours-mcp answers every
29
+ * escape hatch. The legacy MCP connector answered every
27
30
  * tool with `{content:[{type:'text',...}]}` and no `structuredContent`, the
28
31
  * transport fell back to returning the daemon's English sentence — which the
29
32
  * channel then pattern-matched (an invite blob sliced out of a prose sentence,
@@ -43,6 +46,10 @@ export interface OursOps {
43
46
  }): Promise<OursAddContactResult>;
44
47
  getMessages(): Promise<OursMessagesPayload>;
45
48
  deferMessages(msgIds: number[]): Promise<void>;
49
+ watchNotifications(identity: string, options?: {
50
+ since?: number | 'tip';
51
+ signal?: AbortSignal;
52
+ }): AsyncGenerator<OursNotificationEvent, void, undefined>;
46
53
  listIncomingFiles(): Promise<OursIncomingFile[]>;
47
54
  getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
48
55
  /**
@@ -75,15 +82,19 @@ export declare function oursErrorCode(error: unknown): string | undefined;
75
82
  /** The identity is bound by another live session; a predecessor may still be releasing it. */
76
83
  export declare const OURS_BOUND_ELSEWHERE = "BOUND_ELSEWHERE";
77
84
  export interface OursSdkClientDeps {
78
- /** Test seam; production builds an `OursClient` from the resolved endpoint. */
79
- createClient?(options: OursClientOptions): OursClient;
85
+ /** Test seam; production uses the SDK's coherence-checking application attach path. */
86
+ attachClient?(options: AttachOursClientOptions): OursClient | Promise<OursClient>;
87
+ /** Underlying transport and deadline seams for deterministic half-open tests. */
88
+ fetch?: typeof globalThis.fetch;
89
+ notificationRequestDeadlineMs?: number;
90
+ readFile?(path: string): Promise<Uint8Array>;
80
91
  }
81
92
  /**
82
93
  * The owner-channel's daemon client: one `OursClient` over the local ours HTTP
83
94
  * API, owning exactly one identity binding.
84
95
  *
85
96
  * Lease lifetime. The lease token IS the session, so each channel instance mints
86
- * its own and hands it back in `close()`. That replaces the `ours-mcp proxy`
97
+ * its own and hands it back in `close()`. That replaces the connector proxy's
87
98
  * shell-PID fence, which existed because a supervised attempt had to make its
88
99
  * lease reclaimable while the supervisor itself stayed alive: an explicit
89
100
  * release does that deterministically, and `clientPid` still covers the case
@@ -106,6 +117,10 @@ export declare class OursSdkClient implements OursOps {
106
117
  }): Promise<OursAddContactResult>;
107
118
  getMessages(): Promise<OursMessagesPayload>;
108
119
  deferMessages(msgIds: number[]): Promise<void>;
120
+ watchNotifications(identity: string, options?: {
121
+ since?: number | 'tip';
122
+ signal?: AbortSignal;
123
+ }): AsyncGenerator<OursNotificationEvent, void, undefined>;
109
124
  listIncomingFiles(): Promise<OursIncomingFile[]>;
110
125
  getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
111
126
  fetchFile(wireId: string): Promise<Uint8Array>;
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { OursClient, OursError } from '@ours.network/sdk/client';
3
- import { resolveApiToken, resolveEndpoint } from '../monitor.js';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { extname } from 'node:path';
4
+ import { OursError, attachOursClient, } from '@ours.network/sdk/client';
4
5
  /** Any failure of a daemon operation. Never carries a message body or a token. */
5
6
  export class OursDaemonError extends Error {
6
7
  }
@@ -12,6 +13,73 @@ export class OursDaemonError extends Error {
12
13
  */
13
14
  export class OursSendRefusedError extends OursDaemonError {
14
15
  }
16
+ // The daemon normally returns a quiet long-poll within 25 seconds. Keep the
17
+ // client-side fence comfortably above it so ordinary quiet periods do not
18
+ // recycle a healthy stream, while a half-open socket still heals on its own.
19
+ const NOTIFICATION_REQUEST_DEADLINE_MS = 120_000;
20
+ // SDK sendFile({path}) inferred these common types before reading the path in
21
+ // the daemon process. Staged uploads move that read into fleet, so preserve the
22
+ // same advertised MIME rather than silently turning every attachment into an
23
+ // octet stream. Importing the SDK root just for its helper would also pull the
24
+ // daemon runtime into this client-only process.
25
+ const FILE_MIME_BY_EXTENSION = {
26
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
27
+ '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon',
28
+ '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',
29
+ '.json': 'application/json', '.csv': 'text/csv', '.html': 'text/html',
30
+ '.xml': 'application/xml', '.zip': 'application/zip', '.gz': 'application/gzip',
31
+ '.tar': 'application/x-tar', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
32
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime',
33
+ };
34
+ function mimeFromFilename(filename) {
35
+ return FILE_MIME_BY_EXTENSION[extname(filename).toLowerCase()] ?? 'application/octet-stream';
36
+ }
37
+ export class OursWatchDeadlineError extends OursDaemonError {
38
+ }
39
+ function notificationRequest(input) {
40
+ const url = typeof input === 'string' || input instanceof URL ? String(input) : input.url;
41
+ return /\/identities\/[^/]+\/notifications(?:\?|$)/.test(url);
42
+ }
43
+ function notificationDeadlineFetch(fetchImpl, deadlineMs) {
44
+ return async (input, init) => {
45
+ if (!notificationRequest(input))
46
+ return fetchImpl(input, init);
47
+ const ctrl = new AbortController();
48
+ const upstream = init?.signal;
49
+ let rejectFence;
50
+ const fence = new Promise((_resolve, reject) => { rejectFence = reject; });
51
+ const abortUpstream = () => {
52
+ const reason = upstream?.reason
53
+ ?? new DOMException('ours notification request aborted', 'AbortError');
54
+ ctrl.abort(reason);
55
+ // Shutdown must not wait for the deadline when a custom fetch ignores
56
+ // AbortSignal either.
57
+ rejectFence(reason);
58
+ };
59
+ if (upstream?.aborted)
60
+ abortUpstream();
61
+ else
62
+ upstream?.addEventListener('abort', abortUpstream, { once: true });
63
+ const timer = setTimeout(() => {
64
+ const error = new OursWatchDeadlineError('ours notification request deadline exceeded');
65
+ ctrl.abort(error);
66
+ // Do not rely on a custom or half-open fetch implementation to honor
67
+ // AbortSignal: the fence itself must always settle the request.
68
+ rejectFence(error);
69
+ }, deadlineMs);
70
+ timer.unref?.();
71
+ try {
72
+ return await Promise.race([
73
+ fetchImpl(input, { ...init, signal: ctrl.signal }),
74
+ fence,
75
+ ]);
76
+ }
77
+ finally {
78
+ clearTimeout(timer);
79
+ upstream?.removeEventListener('abort', abortUpstream);
80
+ }
81
+ };
82
+ }
15
83
  /**
16
84
  * The typed error code of a daemon operation, or undefined when the failure was
17
85
  * not one (transport, abort, programming error). `instanceof` is checked first;
@@ -35,7 +103,7 @@ export const OURS_BOUND_ELSEWHERE = 'BOUND_ELSEWHERE';
35
103
  * API, owning exactly one identity binding.
36
104
  *
37
105
  * Lease lifetime. The lease token IS the session, so each channel instance mints
38
- * its own and hands it back in `close()`. That replaces the `ours-mcp proxy`
106
+ * its own and hands it back in `close()`. That replaces the connector proxy's
39
107
  * shell-PID fence, which existed because a supervised attempt had to make its
40
108
  * lease reclaimable while the supervisor itself stayed alive: an explicit
41
109
  * release does that deterministically, and `clientPid` still covers the case
@@ -56,17 +124,16 @@ export class OursSdkClient {
56
124
  if (this.client)
57
125
  return;
58
126
  const environment = { ...process.env, ...this.env };
59
- // Reuse fleet's own daemon resolution so this client and the notification
60
- // watch loop can never disagree about which daemon they are talking to.
61
- const endpoint = resolveEndpoint(environment);
62
- const apiToken = resolveApiToken(environment);
127
+ // SDK 2's supported application path resolves endpoint, state root, and
128
+ // token as one coherent selection, proves the daemon's state root before
129
+ // sending credentials, and only then constructs the client.
63
130
  const options = {
64
- url: endpoint.origin,
131
+ env: environment,
65
132
  leaseToken: this.leaseToken,
66
133
  clientPid: process.pid,
67
- ...(apiToken ? { apiToken } : {}),
134
+ fetch: notificationDeadlineFetch(this.deps.fetch ?? globalThis.fetch, this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS),
68
135
  };
69
- this.client = this.deps.createClient?.(options) ?? new OursClient(options);
136
+ this.client = await (this.deps.attachClient?.(options) ?? attachOursClient(options));
70
137
  }
71
138
  async bindIdentity(name) {
72
139
  // force is pinned off: the owner channel never evicts another live session
@@ -88,6 +155,9 @@ export class OursSdkClient {
88
155
  async deferMessages(msgIds) {
89
156
  await this.ops().deferMessages({ msg_ids: msgIds });
90
157
  }
158
+ watchNotifications(identity, options) {
159
+ return this.ops().watchNotifications(identity, options);
160
+ }
91
161
  async listIncomingFiles() {
92
162
  return this.ops().listIncomingFiles();
93
163
  }
@@ -106,7 +176,7 @@ export class OursSdkClient {
106
176
  contact: a.contact, text: a.text,
107
177
  ...(a.replyToWireId ? { reply_to_wire_id: a.replyToWireId } : {}),
108
178
  });
109
- // Parity with ours-mcp 0.16.0: only `refused` was a tool error. `migrating`,
179
+ // Legacy parity: only `refused` was a tool error. `migrating`,
110
180
  // `deferred` and `e2e` are accepted-and-queued outcomes that it reported as
111
181
  // success, so they must not become failures here.
112
182
  if (verdict.kind === 'refused')
@@ -114,11 +184,18 @@ export class OursSdkClient {
114
184
  + 're-established after an upgrade; it was not sent and not downgraded');
115
185
  }
116
186
  async sendFile(a) {
187
+ // The shared daemon may be owned by a different OS process/user and must
188
+ // never be expected to open fleet-private outbox paths. SDK 2 stages bytes
189
+ // read by THIS process, then sends only the opaque upload receipt.
190
+ const bytes = await (this.deps.readFile?.(a.path) ?? readFile(a.path));
191
+ const staged = await this.ops().uploadFile(bytes, {
192
+ filename: a.filename, mime: mimeFromFilename(a.filename),
193
+ });
117
194
  const verdict = await this.ops().sendFile({
118
- contact: a.contact, path: a.path, filename: a.filename,
195
+ contact: a.contact, upload_id: staged.upload_id, filename: a.filename,
119
196
  ...(a.replyToWireId ? { reply_to_wire_id: a.replyToWireId } : {}),
120
197
  });
121
- // Parity with ours-mcp 0.16.0, which treated `migrating` as an error for
198
+ // Legacy parity treated `migrating` as an error for
122
199
  // files and as success for messages: files are not auto-queued behind a
123
200
  // migration, so "queued" would be a false delivery claim.
124
201
  if (verdict.kind === 'refused' || verdict.kind === 'migrating')
@@ -64,7 +64,7 @@ export function makeSystemdBackend(exec = realExec) {
64
64
  const unitDir = join(home(), '.config', 'systemd', 'user');
65
65
  // Lingering user units often start before a login shell imports its PATH.
66
66
  // Pin the Node runtime and persist the install-time PATH so the runner and
67
- // children such as `ours-mcp proxy` resolve the same tools after reboot.
67
+ // structured operator CLI resolve the same tools after reboot.
68
68
  const servicePath = [...new Set([
69
69
  dirname(process.execPath),
70
70
  ...(process.env.PATH ?? '').split(delimiter),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.18.0-nightly.2",
3
+ "version": "0.18.0-nightly.4",
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",
@@ -38,7 +38,8 @@
38
38
  "@agentclientprotocol/sdk": "^1.3.0",
39
39
  "@fastify/static": "^10.1.2",
40
40
  "@fastify/websocket": "^11.2.0",
41
- "@ours.network/sdk": "1.3.1",
41
+ "@ours.network/cli": "1.0.1",
42
+ "@ours.network/sdk": "2.0.1",
42
43
  "@xterm/addon-fit": "0.10.0",
43
44
  "@xterm/addon-serialize": "0.13.0",
44
45
  "@xterm/headless": "5.5.0",