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

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
@@ -68,6 +68,8 @@ The state dir contract:
68
68
  | `.identity`, `.cwd`, `.session-id`, `.booted`, `.exit-status`, `.config-path` | supervisor | dot-marker state — session resume and boot bookkeeping |
69
69
  | `.monitor-state.json`, `.monitor-status` | supervisor monitor | atomic body-free cursor/pending state and health |
70
70
  | `.owner-channel-state.json` | owner-channel bridge | bounded wire-ID dedupe only; never message/reply plaintext |
71
+ | `.owner-channel-message-recovery.json` | owner-channel bridge | mode-0600 body-free message claim journal: wire ID, history sequence, and claim time only |
72
+ | `.owner-channel-attachment-recovery.json` | owner-channel bridge | mode-0600 attachment route journal; never filenames, paths, transcript text, or bytes |
71
73
  | `.owner-channel-binder.lock/`, `.owner-channel-binder.json` | owner-channel supervisor | mode-0600 role/identity + PID/start-marker ownership and release metadata; never mail plaintext or credentials |
72
74
  | `.session-events.jsonl`, `.control.sock`, `.control-token` | ACP backend | bounded typed console projection and private attachment control |
73
75
 
@@ -871,17 +873,21 @@ command path sits behind the authenticated owner-CID check: a non-owner sending
871
873
  `/force-restart` or `/model` is silently ignored exactly like any other
872
874
  unauthorized mail.
873
875
 
874
- Processed wire IDs are durably bounded for deduplication, while
875
- message and response bodies stay out of fleet state. Delivery is at-least-once
876
- across a crash (the bridge requeues fetched input before starting a turn); true
877
- exactly-once processing would require a leased claim/idempotency primitive in
878
- the ours daemon.
876
+ Handled wire IDs are durably bounded for deduplication, while message and
877
+ response bodies stay out of fleet state. Before consuming mail, fleet reads the
878
+ body-free oldest-first metadata from `listIncomingMessages`, atomically journals
879
+ the exact wire/sequence slice (at most 200), and calls `getMessages` with that
880
+ exact length. A returned wire/sequence set mismatch fails closed. After a crash,
881
+ journaled bodies are loaded only from persistent history with `getHistoryItem`;
882
+ the journal is pruned only after the existing durable owner-channel state marks
883
+ the wire handled. This provides at-least-once turn delivery without placing
884
+ message plaintext in fleet state.
879
885
 
880
886
  Inbound owner attachments use the same authenticated-CID and exact-wire routing
881
- boundary. Fleet first calls the metadata-only `list_incoming_files`, groups a
887
+ boundary. Fleet first calls the metadata-only `listIncomingFiles`, groups a
882
888
  file-only wake or a same-sender reply-linked text caption, and checks the enabled,
883
889
  count, declared MIME, per-file size, and total-size policy before retrieving any
884
- bytes. It then calls selective `get_files` only for the admitted wire IDs. An
890
+ bytes. It then calls selective `getFiles` only for the admitted wire IDs. An
885
891
  unauthorized sender is ignored without retrieval or reply. A rejected authorized
886
892
  request receives a bounded reason correlated to its file wire.
887
893
 
@@ -897,13 +903,15 @@ the agent must use the audio path rather than inventing text.
897
903
  Request files are removed after final delivery and stale directories are removed
898
904
  after `retention_ms`. A bounded mode-0600 recovery journal stores only owner CID
899
905
  and wire routing metadata—never filenames, paths, captions, transcripts, or file
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
902
- caption is replayed with its journaled processed files before the group is
903
- admitted or relayed. Conversation route state migrates from v1 to a bounded v2
906
+ bytes. If a selected file is already read when fleet restarts, fleet resolves
907
+ only that journaled wire with `getFileInfo` and retrieves its immutable blob with
908
+ `fetchFile`; a claimed managed-agent caption is loaded from message history and
909
+ rejoined with its journaled read files before the group is admitted or relayed.
910
+ Recovered voice files remain transcript-unavailable and are never sent through
911
+ a second transcription attempt. Conversation route state migrates from v1 to a
912
+ bounded v2
904
913
  source-wire index so a correlated group keeps the authenticated owner selected by
905
- its original request even after later owner traffic. Recovered voice is explicitly
906
- marked transcript-unavailable. Corrupt recovery state disables attachment
914
+ its original request even after later owner traffic. Corrupt recovery state disables attachment
907
915
  admission. The host must run an ours daemon compatible with SDK 2.0.1 whose
908
916
  typed incoming-file, selective retrieval, and file-fetch operations support
909
917
  these guarantees; `ours config show --json` reports `sttConfigured` without
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.18.0-nightly.4",
3
- "buildId": "35a3c2d21eb9",
4
- "commit": "1b5b2fa89f60b64b80877d23d45211fb970e4242",
2
+ "version": "0.18.0-nightly.6",
3
+ "buildId": "a6e98a19befa",
4
+ "commit": "9d10eff5b5b241926326894479810dbae7b026a2",
5
5
  "dirty": true,
6
- "builtAt": "2026-08-21T08:23:47.864Z",
6
+ "builtAt": "2026-08-21T22:28:07.994Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
package/dist/docs.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Keep this concise enough to place directly in an agent context. Unlike
5
5
  * Commander's per-command help, this describes how the pieces compose.
6
6
  */
7
- export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\nours-fleet version [--json] # build identity, capabilities, every install on PATH\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\nThe CLI never writes the base file: `spawn` writes `~/fleet.d/Name.yaml`. The\nweb console does write it, as a whole document \u2014 its setup wizard and\nconfiguration editor may create, change or remove any top-level block, including\n`vars:`, `defaults:`, `roles:`, `watchdogs:` and `loops:`. Only the base\nfile may hold `defaults:`, `watchdogs:` and `loops:`; a fleet.d drop-in may\ndeclare `roles:` and nothing else. Unrecognised top-level keys are round-tripped\nuntouched. Console edits are applied as surgical splices against the file's exact\nbytes, so an unchanged save is byte-identical and lines outside the edit keep their\ncomments and spacing. One exception: changing the length of a block sequence\n(`watch:`, `oversee:`, `roles:`, `wake_sources:`) may replace that collection\nwholesale and drop inline comments written on its items; lines outside that\ncollection remain byte-preserved. Each save is revision-guarded, reviewed as a diff\nof the real file before anything is written, validated by the real loader, and\nbacked up next to the file first.\n\n## Build identity and install provenance\n\n`--version` prints a semver and nothing else, and a semver does NOT identify an\nartifact. Version bumps land in a release commit of their own, so every build cut\nbetween two releases carries the PREVIOUS version while already containing new\nbehaviour. One host ran two installs that both reported 0.16.0 \u2014 same version,\ndifferent build. One accepted `monitor.interrupt: after_tool`, the other\nrejected it as invalid. Their\n`dist/cli.js` were byte-identical \u2014 the divergence was in other modules.\n\nEvery build therefore stamps `dist/build-info.json` with a build id (first 12 hex\nof a sha256 over the rest of `dist/`), the commit it was cut from, and the\ncapability tokens the shipped code declares \u2014 for example\n`monitor.interrupt.after_tool`. Ask any executable what it is:\n\n```sh\nours-fleet version # ours-fleet 0.17.0+9f1c2a3b4d5e, capabilities, PATH installs\nours-fleet version --json # the same as machine-readable JSON, no environment values\n```\n\nRead a capability, never a version number, to decide whether a setting is\nsupported. When a build rejects a value it knows the name of, it says which\ncapability is missing and which build rejected it, because another install on the\nsame host may accept the identical file. `config` prints the build that resolved\nthe plan; `status <Name>` says so when the build reporting on a role is not the\none that created it (roles record their creating build in `creation.json`).\n\n`ours-fleet doctor` runs an `install` check that lists every `ours-fleet` on\nPATH plus the one executing, and FAILS when two installs share a semver but are\ndifferent builds, or when the running artifact is a DIFFERENT artifact from the\none PATH resolves to. A second prefix holding identical content is not a skew\nand is not reported. A PATH entry the shell would not execute \u2014 a directory, or\na file without its execute bit \u2014 is not counted as an install at all.\nInstalls built before this stamp existed report `+unknown`; they are compared by\nhashing their `dist/` instead, so two pre-provenance installs are still told\napart. To fix a flagged host, remove or update the stale install \u2014 do not rely on\nPATH order.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is IPv4-loopback-only by default. Both `localhost` and\n`127.0.0.1` are accepted locally. For an nginx/TLS reverse proxy, keep the\ndefault bind and declare the exact browser origin:\n\n`ours-fleet web install --public-origin https://fleet.example.com --password-file /secure/fleet-password`\n\nFleet reads the password file during setup and persists only a salted scrypt\nverifier. New browsers authenticate and retain rotating HttpOnly/SameSite\ntrusted-device credentials. If nginx already authenticates, the operator may\ndeliberately select `--no-password`; the CLI and browser warn that anyone\nreaching the origin can control the fleet. First setup requires an explicit\nchoice: `--password-file` or `--pairing` for protected access, or\n`--no-password` for intentional unprotected access.\n\nUse `--bind ADDRESS` only for an intentional direct listen. A non-loopback\nbind is rejected unless `--public-origin` is also present. Host/Origin checks\nuse the declaration and do not trust forwarded headers. Configure nginx to\nproxy HTTP and WebSocket upgrades to `127.0.0.1:49271` and terminate TLS;\nfleet accepts nginx's loopback upstream Host, so no Host rewrite is required.\nBrowser credentials add Secure for HTTPS, and `revoke-all` invalidates all\ntrusted devices. Role creation offers harness-scoped known-model choices\nwhile still accepting a typed model ID; blank explicitly uses the selected\nharness's own default.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] [Name | --role Name] \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|auto|allow \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes active state under `~/.ours-fleet/tmp` and starts an independent\ntransient supervisor (a collected systemd unit or submitted launchd job). It is\nnot enabled across reboot and does not die when the role that spawned it restarts.\nBoth lifetimes support `--session acp`. When a temporary role's bound identity\ncloses or its session ends, the supervisor, monitor and live roster entry retire\ntogether; state moves intact to `~/.ours-fleet/recovery/temporary` with a\ntermination record. Failed launches use the same archive rather than deleting\ntheir briefing, provenance, logs or partial supervisor metadata.\n\nNamed `down` and `rm` commands can target an exact state-backed temporary role\neven though it is absent from merged fleet YAML. The recorded transient unit/job\nis authoritative. Missing/incomplete ownership metadata is reconciled only from\nan exact `_run-temp <role>` process-table match: one match may be adopted, zero\nsettles as stopped, and ambiguity or an unreadable table fails closed. Launching\nrecords receive a bounded grace so a not-yet-registered transient unit cannot be\nmistaken for a stopped one. Stale recorded supervisors are reclaimed in bounded\nbatches by moving their state to the same recovery archive, never by blind deletion.\n\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";
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\ninput for direct review. Run `ours config show --json` and inspect `sttConfigured` without\nrevealing provider credentials.\nA mode-0600 message claim journal stores only wire ID, persistent-history\nsequence, and claim time. Fleet journals the exact body-free oldest-first slice\nbefore calling `getMessages` with that slice length, rejects a returned set\nmismatch, and loads a crash-recovered body only through `getHistoryItem`.\nThe attachment crash journal contains only authenticated CID and wire routing\ndata; it never stores captions, filenames, paths, transcript text, or bytes.\nJournaled read files resume through `getFileInfo` and `fetchFile`. A claimed\nagent caption is loaded from history and rejoined before the group is admitted. Fleet\nresolves one authenticated owner route before retrieving bytes, admits every file\nbefore emitting the caption or any file, and sends every part to that same route.\nUnknown correlated routes remain queued without retrieval and receive one bounded\ncorrelated notice. Admission rejection consumes the whole group with one NACK;\nonce emission starts, a transport error becomes terminal uncertain delivery and\nthe group is never blind-retried. Bounded v2 source-wire routing state is migrated\nfrom v1 on read. Corrupt state disables attachment admission rather than weakening\nprovenance checks.\n\nThe channel identity must be unique and must not be a role identity. The bridge\npersists bounded wire IDs only, never message/reply plaintext, and requeues input\nbefore starting its turn for at-least-once crash recovery. It currently requires\n`session: acp`: tmux has no structured, turn-correlated final answer, and pane\nscraping cannot provide the same reliable reply guarantee.\n\n### Live contact and owner administration\n\nThe supervisor which is already running the ACP role remains the sole binder of\n`owner_channel.identity`. The CLI reaches that exact live `OwnerChannel`\nthrough the role's token-authenticated, mode-0600 Unix control socket for contact\ninspection and setup; it never starts another ours client and never force-binds:\n\nRapid supervised restart is serialized by a role-scoped single-binder lease.\nThe predecessor closes its authenticated control socket and MCP proxy before\nreleasing ownership. The replacement waits at most five seconds and retries the\ndaemon bind only when PID/start-marker metadata proves the holder was the same\nrole and owner-channel identity. Foreign, live, corrupt, or otherwise\nunverifiable ownership remains fail-closed; fleet never uses `force=true`.\n\nIf that matching predecessor misses the bound, its still-authenticated control\nroute may send one fixed, digest-deduplicated recovery notice through the latest\nauthenticated owner conversation (or the sole configured owner). Notice\nplaintext is never persisted. With no safe deterministic route fleet guesses no\nrecipient and leaves the actionable failure in the web console and role logs.\nThe remote recovery action is `/restart`; inspect repeated failures with\n`ours-fleet logs <Role>` or the web console.\n\n```sh\nours-fleet owner-channel contact list <Role>\nours-fleet owner-channel contact invite <Role> [--name <label>]\nours-fleet owner-channel contact add <Role> (--invite-file <path> | --invite-stdin) [--name <label>]\nours-fleet owner-channel owner list <Role>\nours-fleet owner-channel owner authorize <Role> <exact-64-hex-contact-cid>\nours-fleet owner-channel owner revoke <Role> <exact-64-hex-contact-cid>\n```\n\nContact establishment and owner authorization are separate security steps.\n`contact add` never authorizes: invite redemption is pending until the peer\nverifies it. Once `contact list` reports the established contact, authorize\nits exact immutable CID explicitly. Invite creation emits invite material only\non stdout; acceptance reads it from a file or stdin, not argv.\n\nConfigured `owners` remain the baseline. On legacy channels without `agent`,\nlive authorizations/revocations are an immediately effective, restart-persistent\noverlay. Managed-agent CID gating makes fleet configuration authoritative and\ndisables live owner mutation and direct control-socket sends. `owner list` labels\nbaseline versus dynamic entries and effective status. The atomic mode-0600 file\ncontains bounded CIDs and audit actions only. Corruption disables all effective\nowners and refuses mutation rather than resurrecting authority; revoking the\nlast effective owner is always refused.\n\nA missing/stopped role, tmux session, role without `owner_channel`, unavailable\nMCP client, or a role entering shutdown returns an actionable error with no\nside effects. Management uses no network listener and never logs or persists\ninvite material.\n\nFor any non-final message\u2014progress, blocker, suggestion, or later proactive note\u2014\nthe managed agent calls ordinary ours `send_message` to the channel identity.\nFleet checks only that the authenticated sender CID exactly equals `agent`, then\nforwards the text as a new message. There is no task/request/update type, phase,\nreply correlation, or owner recipient argument. A sole owner is the safe fallback;\nwith multiple owners and no inbound route history the relay fails closed. Devices\nsharing one identity share its CID; separate owner identities hand off the route\nwhen either sends channel mail. The ACP final is separate: fleet extracts it from\nthe completed turn and deterministically replies to the initiating owner wire.\n\nThe bounded mode-0600 route state stores CIDs, wire IDs, timestamps, delivery state,\nand hashes but never message plaintext. Unauthorized attempts produce a bounded\nCID-only owner warning; attempted bodies are neither reflected nor persisted.\n\nFor a mobile owner, establish the contact first, wait for peer verification,\nauthorize its exact CID, and revoke that same CID when access ends. The bounded\nmode-0600 CID overlay survives supervisor restart and remains fail-closed on\ncorruption. Update bodies remain memory-only. After a crash/restart, unfinished\ndeferred owner input follows the existing at-least-once replay path; the restarted\nsupervisor remains the sole binder.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\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
@@ -554,12 +554,16 @@ mode-0600 files and are removed after completion or bounded stale retention.
554
554
 
555
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 config show --json\` and inspect \`sttConfigured\` without
557
+ input for direct review. Run \`ours config show --json\` and inspect \`sttConfigured\` without
558
558
  revealing provider credentials.
559
- A mode-0600 crash journal contains only authenticated CID and wire routing data;
560
- it never stores captions, filenames, paths, transcript text, or bytes. Journaled
561
- post-retrieval files resume selectively through \`save_file\`. A deferred agent
562
- caption is replayed with its processed files before the group is admitted. Fleet
559
+ A mode-0600 message claim journal stores only wire ID, persistent-history
560
+ sequence, and claim time. Fleet journals the exact body-free oldest-first slice
561
+ before calling \`getMessages\` with that slice length, rejects a returned set
562
+ mismatch, and loads a crash-recovered body only through \`getHistoryItem\`.
563
+ The attachment crash journal contains only authenticated CID and wire routing
564
+ data; it never stores captions, filenames, paths, transcript text, or bytes.
565
+ Journaled read files resume through \`getFileInfo\` and \`fetchFile\`. A claimed
566
+ agent caption is loaded from history and rejoined before the group is admitted. Fleet
563
567
  resolves one authenticated owner route before retrieving bytes, admits every file
564
568
  before emitting the caption or any file, and sends every part to that same route.
565
569
  Unknown correlated routes remain queued without retrieval and receive one bounded
package/dist/doctor.d.ts CHANGED
@@ -1,7 +1,10 @@
1
+ import { type AttachOursClientOptions, type OursClient } from '@ours.network/sdk/client';
1
2
  import { type Exec } from './exec.js';
2
3
  import type { YamlMode } from './config-yaml.js';
3
4
  import { type FetchLike } from './monitor.js';
4
5
  import type { PrereqReport } from './harness/types.js';
6
+ type DoctorDaemonClient = Pick<OursClient, 'version'>;
7
+ type AttachDoctorDaemon = (options: AttachOursClientOptions) => Promise<DoctorDaemonClient>;
5
8
  /** Host-level + per-harness prerequisite report with actionable messages. */
6
9
  export declare function doctor(opts?: {
7
10
  harness?: string;
@@ -12,4 +15,5 @@ export declare function doctor(opts?: {
12
15
  path?: string;
13
16
  argv1?: string;
14
17
  };
15
- }, exec?: Exec, platform?: NodeJS.Platform, fetchImpl?: FetchLike): Promise<PrereqReport>;
18
+ }, exec?: Exec, platform?: NodeJS.Platform, fetchImpl?: FetchLike, attachDaemon?: AttachDoctorDaemon): Promise<PrereqReport>;
19
+ export {};
package/dist/doctor.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { userInfo } from 'node:os';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
+ import { attachOursClient, } from '@ours.network/sdk/client';
3
4
  import { realExec } from './exec.js';
4
5
  import { isolationContextFor, loadConfig } from './config.js';
5
6
  import { getAdapter, productionAdapters } from './harness/registry.js';
@@ -76,7 +77,7 @@ function installChecks(scan) {
76
77
  ];
77
78
  }
78
79
  /** Host-level + per-harness prerequisite report with actionable messages. */
79
- export async function doctor(opts = {}, exec = realExec, platform = process.platform, fetchImpl = (u, i) => globalThis.fetch(u, i)) {
80
+ export async function doctor(opts = {}, exec = realExec, platform = process.platform, fetchImpl = (u, i) => globalThis.fetch(u, i), attachDaemon = attachOursClient) {
80
81
  const checks = [];
81
82
  // First: which artifact is producing this report. Everything below is only as
82
83
  // trustworthy as the answer, and a second install with the same semver and
@@ -112,39 +113,22 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
112
113
  detail: tmux.code === 0 ? tmux.stdout.trim() : 'not found — apt install tmux / brew install tmux',
113
114
  });
114
115
  }
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 */ }
116
+ try {
117
+ const client = await attachDaemon({
118
+ env: process.env,
119
+ leaseToken: `ours-fleet-doctor-${process.pid}`,
120
+ clientPid: process.pid,
121
+ });
122
+ const info = await client.version();
123
+ if (info.name !== 'ours' || typeof info.version !== 'string')
124
+ throw new Error('the selected endpoint did not return a valid ours daemon identity');
125
+ checks.push({ name: 'ours daemon', ok: true, detail: `running (${info.version})` });
124
126
  }
125
- checks.push({
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',
130
- });
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';
127
+ catch (error) {
141
128
  checks.push({
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',
129
+ name: 'ours daemon', ok: false,
130
+ detail: `not reachable through the SDK — start it with: ours daemon start `
131
+ + `[${error?.message ?? String(error)}]`,
148
132
  });
149
133
  }
150
134
  if (platform === 'linux') {
@@ -133,6 +133,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
133
133
  private readonly authorizations;
134
134
  private readonly conversations;
135
135
  private readonly tasks;
136
+ private readonly messageRecovery;
136
137
  private readonly attachmentRecovery;
137
138
  private readonly attachmentConfig;
138
139
  private readonly attachmentRoot;
@@ -141,7 +142,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
141
142
  * (a crash must replay them) but must not be queued twice while live.
142
143
  */
143
144
  private readonly inFlight;
144
- /** Wires already NACKed to the managed agent, so a deferred replay stays quiet. */
145
+ /** Wires already NACKed to the managed agent, so a history replay stays quiet. */
145
146
  private readonly relayNacks;
146
147
  /**
147
148
  * fleet.yaml declares the restart baseline; `/comments on|off` changes only
@@ -189,6 +190,15 @@ export declare class OwnerChannel implements OwnerChannelHandle {
189
190
  private safeTaskReport;
190
191
  private safeProactiveMessage;
191
192
  private drainAll;
193
+ /**
194
+ * Claim the exact oldest unread SQLite batch before marking it read.
195
+ * The journal contains only wire IDs and sequence numbers; bodies remain in
196
+ * the daemon's persistent history and are recovered with getHistoryItem.
197
+ */
198
+ private claimMessages;
199
+ private messageClaim;
200
+ private historyMessage;
201
+ private attachmentMetadata;
192
202
  private attachmentGroups;
193
203
  private handleAttachmentGroup;
194
204
  private handle;
@@ -226,7 +236,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
226
236
  private managedAttachmentReplyWire;
227
237
  /**
228
238
  * One bounded NACK per wire: an unroutable or refused relay must be visible
229
- * to the authenticated agent, while its deferred replays stay quiet. NACK
239
+ * to the authenticated agent, while its history replays stay quiet. NACK
230
240
  * delivery is best-effort — it must never make the failure worse.
231
241
  */
232
242
  private nackManagedAgent;
@@ -13,6 +13,7 @@ import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerCo
13
13
  import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
14
14
  import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, writeRecoveredAttachment, } from './attachments.js';
15
15
  import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
16
+ import { MessageRecoveryState } from './message-recovery.js';
16
17
  const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
17
18
  const OWNER_UPDATE_MAX_COUNT = 20;
18
19
  const OWNER_UPDATE_MAX_CHARS = 280;
@@ -26,6 +27,7 @@ const COMMENTARY_MAX_BYTES = 6_400;
26
27
  const COMMENTARY_MAX_UPDATES = 32;
27
28
  const COMMENTARY_DEDUPE_LIMIT = 512;
28
29
  const OWNER_WATCH_BACKOFF_MAX_MS = 30_000;
30
+ const OWNER_MESSAGE_BATCH_LIMIT = 200;
29
31
  /** A relay attempt that failed only because no owner route exists yet. */
30
32
  class RelayUnroutableError extends Error {
31
33
  }
@@ -40,6 +42,7 @@ export class OwnerChannel {
40
42
  authorizations;
41
43
  conversations;
42
44
  tasks;
45
+ messageRecovery;
43
46
  attachmentRecovery;
44
47
  attachmentConfig;
45
48
  attachmentRoot;
@@ -48,7 +51,7 @@ export class OwnerChannel {
48
51
  * (a crash must replay them) but must not be queued twice while live.
49
52
  */
50
53
  inFlight = new Set();
51
- /** Wires already NACKed to the managed agent, so a deferred replay stays quiet. */
54
+ /** Wires already NACKed to the managed agent, so a history replay stays quiet. */
52
55
  relayNacks = new Set();
53
56
  /**
54
57
  * fleet.yaml declares the restart baseline; `/comments on|off` changes only
@@ -78,6 +81,7 @@ export class OwnerChannel {
78
81
  this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
79
82
  this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
80
83
  this.tasks = new OwnerTaskState(join(options.stateDir, '.owner-channel-tasks.json'));
84
+ this.messageRecovery = new MessageRecoveryState(join(options.stateDir, '.owner-channel-message-recovery.json'));
81
85
  this.attachmentRecovery = new AttachmentRecoveryState(join(options.stateDir, '.owner-channel-attachment-recovery.json'));
82
86
  this.attachmentRoot = join(options.stateDir, '.owner-channel-inbox');
83
87
  // An absent key is a pre-`comments` configuration, which relayed live
@@ -96,6 +100,8 @@ export class OwnerChannel {
96
100
  options.log(`[${options.role}] owner conversation state corrupt; proactive messages disabled`);
97
101
  if (!this.tasks.integrity().ok)
98
102
  options.log(`[${options.role}] owner task state corrupt; proactive reports disabled`);
103
+ if (!this.messageRecovery.integrity())
104
+ options.log(`[${options.role}] owner message recovery state corrupt; message intake disabled`);
99
105
  if (!this.attachmentRecovery.integrity())
100
106
  options.log(`[${options.role}] owner attachment recovery state corrupt; attachments disabled`);
101
107
  }
@@ -517,60 +523,145 @@ export class OwnerChannel {
517
523
  return message;
518
524
  }
519
525
  async drainAll() {
520
- // A finite cap protects the supervisor if a broken daemon repeats unread
521
- // messages forever. A watch notification will resume draining later.
526
+ // A finite cap protects the supervisor if a broken daemon never advances
527
+ // its oldest-first unread batch. A watch hint will resume draining later.
522
528
  for (let pass = 0; pass < 100 && !this.stopping; pass++) {
523
- const [payload, fileResult] = await Promise.all([
524
- this.client.getMessages(),
529
+ const [claimed, fileResult] = await Promise.all([
530
+ this.claimMessages(),
525
531
  this.client.listIncomingFiles()
526
532
  .catch(error => {
527
533
  this.logError('attachment metadata inspection unavailable', error);
528
534
  return undefined;
529
535
  }),
530
536
  ]);
531
- const messages = Array.isArray(payload?.messages)
532
- ? payload.messages.filter(message => message && typeof message === 'object')
533
- : [];
534
- let files = [];
535
- try {
536
- files = parseIncomingAttachments(fileResult);
537
- }
538
- catch (error) {
539
- this.logError('attachment metadata inspection unavailable', error);
540
- }
541
537
  const pending = this.attachmentRecovery.integrity() ? this.attachmentRecovery.list() : [];
538
+ let files = await this.attachmentMetadata(fileResult, pending);
542
539
  const pendingWires = new Set(pending.flatMap(item => item.fileWireIds));
543
540
  files = files.filter(file => (file.status === 'unread' || pendingWires.has(file.wireId))
544
541
  && !this.state.has(file.wireId));
545
- if (!messages.length && !files.length)
542
+ if (!claimed.messages.length && !files.length && claimed.remaining === 0)
546
543
  return;
547
- // get_messages marks the batch processed. Requeue allowed, unhandled
548
- // inputs before executing them so a mid-turn process crash can replay.
549
- const deferred = messages.filter(message => {
550
- const wireId = this.wireId(message);
551
- return wireId && !this.state.has(wireId)
552
- && this.acceptedSender(this.sender(message).id)
553
- && Number.isInteger(message.msg_id);
554
- }).map(message => message.msg_id);
555
- if (deferred.length)
556
- await this.client.deferMessages(deferred);
557
544
  let advanced = false;
558
545
  const consumedMessages = new Set();
559
- const groups = this.attachmentGroups(files, messages, pending, consumedMessages);
546
+ const groups = this.attachmentGroups(files, claimed.messages, pending, consumedMessages);
560
547
  for (const group of groups)
561
548
  advanced = await this.handleAttachmentGroup(group) || advanced;
562
- for (const message of messages) {
549
+ for (const message of claimed.messages) {
563
550
  if (!consumedMessages.has(message))
564
551
  advanced = await this.handle(message) || advanced;
565
552
  }
566
- // Deferred in-flight messages are intentionally visible again until
567
- // their correlated response is delivered. Do not spin on those replay
568
- // copies; a new watch event or completion-triggered drain will resume.
569
- if (!advanced)
553
+ this.messageRecovery.pruneHandled(wireId => this.state.has(wireId));
554
+ // Journaled in-flight messages remain history-recoverable until their
555
+ // correlated response is delivered. Do not spin on those copies once the
556
+ // unread SQLite queue itself is empty; completion triggers another drain.
557
+ if (!advanced && claimed.remaining === 0)
570
558
  return;
571
559
  }
572
560
  this.options.log(`[${this.options.role}] owner channel drain capped at 100 batches`);
573
561
  }
562
+ /**
563
+ * Claim the exact oldest unread SQLite batch before marking it read.
564
+ * The journal contains only wire IDs and sequence numbers; bodies remain in
565
+ * the daemon's persistent history and are recovered with getHistoryItem.
566
+ */
567
+ async claimMessages() {
568
+ if (!this.messageRecovery.integrity())
569
+ throw new Error('message recovery state is corrupt; refusing owner message intake');
570
+ this.messageRecovery.pruneHandled(wireId => this.state.has(wireId));
571
+ const recovered = [];
572
+ for (const claim of this.messageRecovery.list()) {
573
+ const item = await this.client.getHistoryItem(claim.wireId);
574
+ if (!item)
575
+ throw new Error(`journaled owner message ${claim.wireId} is missing from persistent history`);
576
+ recovered.push(this.historyMessage(item, claim));
577
+ }
578
+ const listed = await this.client.listIncomingMessages();
579
+ if (!Array.isArray(listed))
580
+ throw new Error('the ours daemon returned invalid unread message metadata');
581
+ const preflight = listed.slice(0, OWNER_MESSAGE_BATCH_LIMIT);
582
+ const now = Date.now();
583
+ const claims = preflight.map(item => this.messageClaim(item, now));
584
+ const claimKeys = new Set(claims.map(item => `${item.seq}\0${item.wireId}`));
585
+ if (claimKeys.size !== claims.length)
586
+ throw new Error('the ours daemon returned duplicate unread message metadata');
587
+ this.messageRecovery.claim(claims);
588
+ let fresh = [];
589
+ let remaining = 0;
590
+ // SDK batchLimit rejects zero. An empty preflight is a read-only drain.
591
+ if (claims.length) {
592
+ const payload = await this.client.getMessages(claims.length);
593
+ if (!payload || !Array.isArray(payload.messages)
594
+ || !Number.isSafeInteger(payload.remaining) || payload.remaining < 0)
595
+ throw new Error('the ours daemon returned an invalid claimed message batch');
596
+ fresh = payload.messages.map(message => this.historyMessage(message));
597
+ remaining = payload.remaining;
598
+ const expected = claimKeys;
599
+ const actual = new Set(fresh.map(item => `${item.seq}\0${item.wire_id}`));
600
+ if (fresh.length !== claims.length || actual.size !== fresh.length
601
+ || actual.size !== expected.size
602
+ || [...expected].some(item => !actual.has(item)))
603
+ throw new Error('the ours daemon claimed a different message batch than fleet journaled');
604
+ }
605
+ const merged = new Map();
606
+ for (const message of [...recovered, ...fresh]) {
607
+ const wireId = this.wireId(message);
608
+ const previous = merged.get(wireId);
609
+ if (previous && previous.seq !== message.seq)
610
+ throw new Error('persistent message history changed sequence during recovery');
611
+ merged.set(wireId, message);
612
+ }
613
+ return {
614
+ messages: [...merged.values()].sort((a, b) => a.seq - b.seq),
615
+ remaining,
616
+ };
617
+ }
618
+ messageClaim(message, claimedAt) {
619
+ const value = message;
620
+ const wireId = String(value.wire_id ?? '').trim();
621
+ const seq = Number(value.seq);
622
+ if (!wireId || !Number.isSafeInteger(seq) || seq < 1
623
+ || value.status !== 'unread' || value.inbox_state !== 'unread')
624
+ throw new Error('the ours daemon returned malformed unread message metadata');
625
+ return { wireId, seq, claimedAt };
626
+ }
627
+ historyMessage(message, claim) {
628
+ const value = message;
629
+ const wireId = String(value.wire_id ?? '').trim();
630
+ const seq = Number(value.seq);
631
+ const direction = String(value.direction ?? '');
632
+ if (!wireId || !Number.isSafeInteger(seq) || seq < 1 || direction !== 'in'
633
+ || (claim && (claim.wireId !== wireId || claim.seq !== seq)))
634
+ throw new Error('persistent owner message metadata mismatched its recovery claim');
635
+ return message;
636
+ }
637
+ async attachmentMetadata(unread, pending) {
638
+ const raw = Array.isArray(unread) ? [...unread] : [];
639
+ const present = new Set(raw.map(file => String(file.wire_id ?? '')));
640
+ for (const recovery of pending) {
641
+ for (const wireId of recovery.fileWireIds) {
642
+ if (present.has(wireId))
643
+ continue;
644
+ const item = await this.client.getFileInfo(wireId);
645
+ if (!item)
646
+ throw new Error(`journaled owner file ${wireId} is missing from persistent history`);
647
+ if (item.wire_id !== wireId || item.direction !== 'in'
648
+ || item.inbox_state !== 'read' || item.status !== 'read')
649
+ throw new Error(`journaled owner file ${wireId} mismatched persistent history`);
650
+ raw.push(item);
651
+ present.add(wireId);
652
+ }
653
+ }
654
+ const files = parseIncomingAttachments(raw);
655
+ const byWire = new Map(files.map(file => [file.wireId, file]));
656
+ for (const recovery of pending) {
657
+ for (const wireId of recovery.fileWireIds) {
658
+ const file = byWire.get(wireId);
659
+ if (!file || file.senderId !== recovery.contact)
660
+ throw new Error(`journaled owner file ${wireId} failed recovery provenance validation`);
661
+ }
662
+ }
663
+ return files;
664
+ }
574
665
  attachmentGroups(files, messages, pending, consumed) {
575
666
  const groups = [];
576
667
  const used = new Set();
@@ -595,9 +686,9 @@ export class OwnerChannel {
595
686
  ? undefined : messageByWire.get(recovery.originWireId);
596
687
  if (caption && this.sender(caption).id !== recovery.contact)
597
688
  continue;
598
- // A managed-agent caption is deferred before retrieval. If recovery sees
599
- // the processed file before that body is replayed, keep the file reserved
600
- // by the journal until both halves are present again.
689
+ // A managed-agent caption is claimed before its inbox row becomes read.
690
+ // If recovery sees a read file before that body is recovered from history,
691
+ // keep the file reserved by the journal until both halves are present.
601
692
  if (!caption && !recovery.fileWireIds.includes(recovery.originWireId))
602
693
  continue;
603
694
  if (caption)
@@ -682,13 +773,13 @@ export class OwnerChannel {
682
773
  this.attachmentRecovery.add(recovery);
683
774
  requestDir = await prepareAttachmentDirectory(this.attachmentRoot, requestId);
684
775
  const unread = group.files.filter(file => file.status === 'unread');
685
- const processed = group.files.filter(file => file.status !== 'unread');
776
+ const historyRecovered = group.files.filter(file => file.status !== 'unread');
686
777
  const retrieved = unread.length
687
778
  ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
688
779
  : [];
689
- for (const file of processed) {
780
+ for (const file of historyRecovered) {
690
781
  if (!group.recovery)
691
- throw new Error('unexpected processed attachment without recovery route');
782
+ throw new Error('unexpected read attachment without recovery route');
692
783
  const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
693
784
  retrieved.push(await recoveredAttachment(file, recoveryPath));
694
785
  }
@@ -781,9 +872,9 @@ export class OwnerChannel {
781
872
  this.options.log(`[${this.options.role}] managed-agent relay replay of a delivered wire consumed`);
782
873
  }
783
874
  else if (error instanceof RelayUnroutableError) {
784
- // No owner route exists yet. Leave the wire deferred and unconsumed
785
- // so the daemon replays it after the first owner contact, and tell
786
- // the authenticated agent once so the wait is never silent.
875
+ // No owner route exists yet. Leave the wire journaled and unconsumed
876
+ // so persistent history replays it after the first owner contact, and
877
+ // tell the authenticated agent once so the wait is never silent.
787
878
  this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
788
879
  + `route yet; message stays queued: ${this.errorText(error)}`);
789
880
  await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayQueued());
@@ -837,9 +928,9 @@ export class OwnerChannel {
837
928
  await rm(outbox, { recursive: true, force: true });
838
929
  if (error instanceof SessionControlError
839
930
  && error.reasonCode === ACP_CANCEL_DEADLINE_EXCEEDED) {
840
- // drainAll deferred this authenticated message before delivery. The
931
+ // drainAll journaled this authenticated message before delivery. The
841
932
  // adapter generation is terminating, so leave the wire unhandled and
842
- // body-free: the resumed owner channel will replay it exactly once.
933
+ // body-free: the resumed owner channel recovers it exactly once.
843
934
  this.options.log(`[${this.options.role}] owner request ${requestId.slice(0, 12)} `
844
935
  + `held for adapter resume reason=${ACP_CANCEL_DEADLINE_EXCEEDED}`);
845
936
  return false;
@@ -1072,13 +1163,13 @@ export class OwnerChannel {
1072
1163
  this.attachmentRecovery.add(recovery);
1073
1164
  requestDir = await prepareAttachmentDirectory(this.attachmentRoot, transactionId);
1074
1165
  const unread = group.files.filter(file => file.status === 'unread');
1075
- const processed = group.files.filter(file => file.status !== 'unread');
1166
+ const historyRecovered = group.files.filter(file => file.status !== 'unread');
1076
1167
  const retrieved = unread.length
1077
1168
  ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1078
1169
  : [];
1079
- for (const file of processed) {
1170
+ for (const file of historyRecovered) {
1080
1171
  if (!group.recovery)
1081
- throw new Error('unexpected processed attachment without recovery route');
1172
+ throw new Error('unexpected read attachment without recovery route');
1082
1173
  const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
1083
1174
  retrieved.push(await recoveredAttachment(file, recoveryPath));
1084
1175
  }
@@ -1170,7 +1261,7 @@ export class OwnerChannel {
1170
1261
  }
1171
1262
  /**
1172
1263
  * One bounded NACK per wire: an unroutable or refused relay must be visible
1173
- * to the authenticated agent, while its deferred replays stay quiet. NACK
1264
+ * to the authenticated agent, while its history replays stay quiet. NACK
1174
1265
  * delivery is best-effort — it must never make the failure worse.
1175
1266
  */
1176
1267
  async nackManagedAgent(contact, replyTo, wireId, notice) {
@@ -1582,8 +1673,8 @@ export class OwnerChannel {
1582
1673
  // This drain is unconditional at EVERY establishment. Starting the SDK
1583
1674
  // stream at 0 then replays notification hints instead of tip-priming,
1584
1675
  // 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.
1676
+ // fall into a gap. Persistent history plus fleet's body-free claim
1677
+ // journal is authoritative; durable wire-ID dedupe makes hints harmless.
1587
1678
  await this.drain();
1588
1679
  if (this.stopping)
1589
1680
  return;
@@ -0,0 +1,25 @@
1
+ export interface PendingMessageClaim {
2
+ wireId: string;
3
+ seq: number;
4
+ claimedAt: number;
5
+ }
6
+ /**
7
+ * Body-free crash journal for the SQLite getMessages read boundary.
8
+ *
9
+ * A claim lands before getMessages marks its exact oldest-first batch read.
10
+ * After a crash, getHistoryItem can therefore recover the body by wire ID
11
+ * without fleet ever duplicating message plaintext in its own state.
12
+ */
13
+ export declare class MessageRecoveryState {
14
+ private readonly path;
15
+ private readonly limit;
16
+ private pending;
17
+ private corrupt;
18
+ constructor(path: string, limit?: number);
19
+ integrity(): boolean;
20
+ list(): PendingMessageClaim[];
21
+ claim(items: PendingMessageClaim[]): void;
22
+ pruneHandled(handled: (wireId: string) => boolean): number;
23
+ private assertHealthy;
24
+ private persist;
25
+ }
@@ -0,0 +1,114 @@
1
+ import { chmodSync, existsSync, readFileSync } from 'node:fs';
2
+ import { replaceFileAtomically } from '../atomic-file.js';
3
+ const MAX_PENDING_MESSAGES = 5_000;
4
+ const MAX_WIRE_ID_CHARS = 1_024;
5
+ /**
6
+ * Body-free crash journal for the SQLite getMessages read boundary.
7
+ *
8
+ * A claim lands before getMessages marks its exact oldest-first batch read.
9
+ * After a crash, getHistoryItem can therefore recover the body by wire ID
10
+ * without fleet ever duplicating message plaintext in its own state.
11
+ */
12
+ export class MessageRecoveryState {
13
+ path;
14
+ limit;
15
+ pending = [];
16
+ corrupt = false;
17
+ constructor(path, limit = MAX_PENDING_MESSAGES) {
18
+ this.path = path;
19
+ this.limit = limit;
20
+ if (!existsSync(path))
21
+ return;
22
+ try {
23
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
24
+ if (raw.version !== 1 || !Array.isArray(raw.pending)
25
+ || raw.pending.length > limit || !raw.pending.every(validClaim)
26
+ || new Set(raw.pending.map(item => item.wireId)).size !== raw.pending.length)
27
+ throw new Error('invalid message recovery state');
28
+ this.pending = raw.pending.map(item => ({ ...item }));
29
+ chmodSync(path, 0o600);
30
+ }
31
+ catch {
32
+ this.corrupt = true;
33
+ this.pending = [];
34
+ try {
35
+ chmodSync(path, 0o600);
36
+ }
37
+ catch { /* retain the evidence */ }
38
+ }
39
+ }
40
+ integrity() { return !this.corrupt; }
41
+ list() {
42
+ this.assertHealthy();
43
+ return this.pending.map(item => ({ ...item }));
44
+ }
45
+ claim(items) {
46
+ this.assertHealthy();
47
+ if (!items.length)
48
+ return;
49
+ if (!items.every(validClaim))
50
+ throw new Error('invalid message recovery claim');
51
+ const next = this.pending.map(item => ({ ...item }));
52
+ const byWire = new Map(next.map(item => [item.wireId, item]));
53
+ for (const item of items) {
54
+ const existing = byWire.get(item.wireId);
55
+ if (existing) {
56
+ if (existing.seq !== item.seq)
57
+ throw new Error('message recovery wire ID changed sequence');
58
+ continue;
59
+ }
60
+ if (next.length >= this.limit)
61
+ throw new Error('too many pending message recoveries');
62
+ const copy = { ...item };
63
+ next.push(copy);
64
+ byWire.set(copy.wireId, copy);
65
+ }
66
+ if (next.length === this.pending.length)
67
+ return;
68
+ const previous = this.pending;
69
+ this.pending = next;
70
+ try {
71
+ this.persist();
72
+ }
73
+ catch (error) {
74
+ this.pending = previous;
75
+ throw error;
76
+ }
77
+ }
78
+ pruneHandled(handled) {
79
+ this.assertHealthy();
80
+ const next = this.pending.filter(item => !handled(item.wireId));
81
+ const removed = this.pending.length - next.length;
82
+ if (!removed)
83
+ return 0;
84
+ const previous = this.pending;
85
+ this.pending = next;
86
+ try {
87
+ this.persist();
88
+ }
89
+ catch (error) {
90
+ this.pending = previous;
91
+ throw error;
92
+ }
93
+ return removed;
94
+ }
95
+ assertHealthy() {
96
+ if (this.corrupt)
97
+ throw new Error('message recovery state is corrupt');
98
+ }
99
+ persist() {
100
+ replaceFileAtomically(this.path, JSON.stringify({
101
+ version: 1, pending: this.pending,
102
+ }) + '\n', 0o600);
103
+ chmodSync(this.path, 0o600);
104
+ }
105
+ }
106
+ function validClaim(value) {
107
+ if (!value || typeof value !== 'object')
108
+ return false;
109
+ const item = value;
110
+ return typeof item.wireId === 'string' && item.wireId.length >= 1
111
+ && Array.from(item.wireId).length <= MAX_WIRE_ID_CHARS
112
+ && Number.isSafeInteger(item.seq) && item.seq >= 1
113
+ && Number.isSafeInteger(item.claimedAt) && item.claimedAt >= 0;
114
+ }
@@ -14,11 +14,14 @@ type Res<M extends keyof OursClient> = OursClient[M] extends (...args: never[])
14
14
  export type OursContactsView = Res<'listContacts'>;
15
15
  export type OursInviteResult = Res<'generateInvite'>;
16
16
  export type OursAddContactResult = Res<'addContact'>;
17
+ export type OursIncomingMessage = Res<'listIncomingMessages'>[number];
17
18
  export type OursMessagesPayload = Res<'getMessages'>;
18
19
  export type OursInboundMessage = OursMessagesPayload['messages'][number];
20
+ export type OursHistoryMessage = NonNullable<Res<'getHistoryItem'>>;
19
21
  export type OursIncomingFile = Res<'listIncomingFiles'>[number];
20
22
  export type OursRetrievedFiles = Res<'getFiles'>;
21
23
  export type OursRetrievedFile = OursRetrievedFiles['files'][number];
24
+ export type OursHistoryFile = NonNullable<Res<'getFileInfo'>>;
22
25
  export type OursNotificationEvent = NotificationEvent;
23
26
  export declare class OursWatchDeadlineError extends OursDaemonError {
24
27
  }
@@ -44,13 +47,15 @@ export interface OursOps {
44
47
  invite: string;
45
48
  name?: string;
46
49
  }): Promise<OursAddContactResult>;
47
- getMessages(): Promise<OursMessagesPayload>;
48
- deferMessages(msgIds: number[]): Promise<void>;
50
+ listIncomingMessages(): Promise<OursIncomingMessage[]>;
51
+ getMessages(limit: number): Promise<OursMessagesPayload>;
52
+ getHistoryItem(wireId: string): Promise<OursHistoryMessage | null>;
49
53
  watchNotifications(identity: string, options?: {
50
54
  since?: number | 'tip';
51
55
  signal?: AbortSignal;
52
56
  }): AsyncGenerator<OursNotificationEvent, void, undefined>;
53
57
  listIncomingFiles(): Promise<OursIncomingFile[]>;
58
+ getFileInfo(wireId: string): Promise<OursHistoryFile | null>;
54
59
  getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
55
60
  /**
56
61
  * The bytes of an already-retrieved file. Transport only — the caller owns
@@ -115,13 +120,15 @@ export declare class OursSdkClient implements OursOps {
115
120
  invite: string;
116
121
  name?: string;
117
122
  }): Promise<OursAddContactResult>;
118
- getMessages(): Promise<OursMessagesPayload>;
119
- deferMessages(msgIds: number[]): Promise<void>;
123
+ listIncomingMessages(): Promise<OursIncomingMessage[]>;
124
+ getMessages(limit: number): Promise<OursMessagesPayload>;
125
+ getHistoryItem(wireId: string): Promise<OursHistoryMessage | null>;
120
126
  watchNotifications(identity: string, options?: {
121
127
  since?: number | 'tip';
122
128
  signal?: AbortSignal;
123
129
  }): AsyncGenerator<OursNotificationEvent, void, undefined>;
124
130
  listIncomingFiles(): Promise<OursIncomingFile[]>;
131
+ getFileInfo(wireId: string): Promise<OursHistoryFile | null>;
125
132
  getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
126
133
  fetchFile(wireId: string): Promise<Uint8Array>;
127
134
  sendMessage(a: {
@@ -149,11 +149,14 @@ export class OursSdkClient {
149
149
  async addContact(a) {
150
150
  return this.ops().addContact({ invite: a.invite, ...(a.name ? { name: a.name } : {}) });
151
151
  }
152
- async getMessages() {
153
- return this.ops().getMessages();
152
+ async listIncomingMessages() {
153
+ return this.ops().listIncomingMessages();
154
154
  }
155
- async deferMessages(msgIds) {
156
- await this.ops().deferMessages({ msg_ids: msgIds });
155
+ async getMessages(limit) {
156
+ return this.ops().getMessages({ limit });
157
+ }
158
+ async getHistoryItem(wireId) {
159
+ return this.ops().getHistoryItem({ wire_id: wireId });
157
160
  }
158
161
  watchNotifications(identity, options) {
159
162
  return this.ops().watchNotifications(identity, options);
@@ -161,6 +164,9 @@ export class OursSdkClient {
161
164
  async listIncomingFiles() {
162
165
  return this.ops().listIncomingFiles();
163
166
  }
167
+ async getFileInfo(wireId) {
168
+ return this.ops().getFileInfo({ wire_id: wireId });
169
+ }
164
170
  async getFiles(wireIds) {
165
171
  return this.ops().getFiles({ wire_ids: wireIds });
166
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.18.0-nightly.4",
3
+ "version": "0.18.0-nightly.6",
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",
@@ -39,7 +39,7 @@
39
39
  "@fastify/static": "^10.1.2",
40
40
  "@fastify/websocket": "^11.2.0",
41
41
  "@ours.network/cli": "1.0.1",
42
- "@ours.network/sdk": "2.0.1",
42
+ "@ours.network/sdk": "3.0.1",
43
43
  "@xterm/addon-fit": "0.10.0",
44
44
  "@xterm/addon-serialize": "0.13.0",
45
45
  "@xterm/headless": "5.5.0",