@ours.network/fleet 0.14.1 → 0.15.1

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,7 @@ 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-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 |
71
72
  | `.session-events.jsonl`, `.control.sock`, `.control-token` | ACP backend | bounded typed console projection and private attachment control |
72
73
 
73
74
  ## Prerequisites
@@ -202,6 +203,16 @@ authenticated identity existence check and reports verified, missing, or
202
203
  unknown evidence; a newly launched harness follows its generated first-boot
203
204
  instructions to choose or create and bind the identity. The console never
204
205
  claims that the host created an identity and never deletes one.
206
+
207
+ For a temporary role, those first-boot instructions preserve and bind an
208
+ existing identity when one is present. If the assigned identity is missing,
209
+ the role capability-detects the ours MCP `create_temporary_identity` tool and
210
+ uses it when available, so the newly created identity is owned and cleaned up
211
+ by that connector session lifecycle. Older ours servers remain compatible via
212
+ `create_identity`. A collision or creation error stops for operator review;
213
+ fleet never force-adopts or deletes identity state. Permanent roles continue to
214
+ use normal `create_identity` bootstrap behavior.
215
+
205
216
  `node-pty` is optional: if its native module cannot load, ACP and all
206
217
  non-terminal features remain available and tmux Terminal is disabled with a
207
218
  diagnostic.
@@ -573,6 +584,22 @@ channel identity. Add it to the control plane just like another contact, then
573
584
  message it directly.
574
585
 
575
586
  The running supervisor remains the only process which binds that identity.
587
+ Rapid supervised restart uses a role-scoped single-binder lease. The old
588
+ supervisor closes its MCP proxy and authenticated control socket before
589
+ releasing the lease; the replacement waits a bounded five seconds and retries a
590
+ daemon bind only when the lease proves that holder was the same role and channel
591
+ identity. A foreign, live, or unverifiable holder remains fail-closed and is
592
+ never evicted with `force=true`.
593
+
594
+ If the matching predecessor does not release within that bound, the replacement
595
+ asks the predecessor's still-authenticated control socket to emit one fixed
596
+ recovery notice. The predecessor uses only its existing latest-owner route (or
597
+ the sole configured owner), deduplicates the notice durably by digest, and stores
598
+ no notice plaintext. When no unambiguous authenticated owner route exists, no
599
+ recipient is guessed: the failure remains in the web console and role logs. The
600
+ remote recovery action is `/restart`; repeated failures should be inspected with
601
+ `ours-fleet logs <Role>` or the web console.
602
+
576
603
  Operators manage it, and an active agent turn emits bounded updates, through the
577
604
  role's authenticated Unix control socket:
578
605
 
@@ -12,6 +12,8 @@ export interface BriefingOpts {
12
12
  * knowledge must not claim one.
13
13
  */
14
14
  identityGuarantee?: 'verified' | 'created' | 'unverified';
15
+ /** Temporary spawn whose newly-created identity should share the session lifecycle. */
16
+ temporaryIdentity?: boolean;
15
17
  }
16
18
  /** Render a role's briefing.md: narrative (or curated body) + mechanical boot steps. */
17
19
  export declare function generateBriefing(role: ResolvedRole, v: BriefingVocab, opts: BriefingOpts): string;
package/dist/briefing.js CHANGED
@@ -1,12 +1,24 @@
1
1
  import { userInfo } from 'node:os';
2
2
  import { oversightTaxonomyLines } from './session/control.js';
3
+ function temporaryIdentityFallback(id, v) {
4
+ return [
5
+ ` First inspect the available/deferred ours MCP tools for **${v.temporaryCreateTool}**.`,
6
+ ` If exposed, call **${v.temporaryCreateTool}** with name "${id}". It binds`,
7
+ ' automatically and the ours connector owns its cleanup when this session lifecycle ends.',
8
+ ` If that tool is absent (an older server), fall back to **${v.createTool}** with`,
9
+ ` name "${id}" so startup remains compatible. If either creation call reports a`,
10
+ ' collision or any other error, STOP and report it; do not force-bind, retry under a',
11
+ ' different name, remove an identity, or delete identity state.',
12
+ ];
13
+ }
3
14
  /** Render a role's briefing.md: narrative (or curated body) + mechanical boot steps. */
4
15
  export function generateBriefing(role, v, opts) {
5
16
  const L = [];
6
17
  const id = role.identity;
7
18
  const hostUser = userInfo().username;
8
19
  L.push(`# ${role.name} — Role Briefing`, '');
9
- L.push(`You are **${role.name}** (ours identity: **${id}**), a persistent agent on this`);
20
+ const lifetime = opts.temporaryIdentity ? 'temporary' : 'persistent';
21
+ L.push(`You are **${role.name}** (ours identity: **${id}**), a ${lifetime} agent on this`);
10
22
  L.push(`host, running as the \`${hostUser}\` user.`);
11
23
  if (opts.briefingBody) {
12
24
  L.push('', opts.briefingBody.trim());
@@ -27,7 +39,24 @@ export function generateBriefing(role, v, opts) {
27
39
  // "predefined" identity that nobody checked is how an agent ends up improvising
28
40
  // its own infrastructure on first boot.
29
41
  const guarantee = opts.identityGuarantee ?? 'unverified';
30
- if (guarantee === 'unverified') {
42
+ if (opts.temporaryIdentity) {
43
+ L.push(`2. BIND the exact ours identity assigned to this role: call **${v.bindTool}** with`);
44
+ L.push(` name "${id}" without force (search the deferred tool registry first if needed).`);
45
+ L.push(' If another live session owns it, STOP and report the collision; do not evict it.');
46
+ L.push(' - If binding succeeds, it is a pre-existing identity: preserve it as user-owned.');
47
+ L.push(' Never close, remove, replace, or otherwise convert it to a temporary identity.');
48
+ if (guarantee === 'unverified') {
49
+ L.push(' - This identity was NOT verified when your role was created. If and only if binding');
50
+ L.push(' reports that no such identity exists:');
51
+ }
52
+ else {
53
+ L.push(` - It was ${guarantee === 'created' ? 'created' : 'verified to exist'} when your role`);
54
+ L.push(' was created. If it unexpectedly reports that no such identity exists, report the');
55
+ L.push(' discrepancy, then use this compatibility path:');
56
+ }
57
+ L.push(...temporaryIdentityFallback(id, v));
58
+ }
59
+ else if (guarantee === 'unverified') {
31
60
  L.push(`2. BIND your ours identity: call the **${v.bindTool}** tool with`);
32
61
  L.push(` name "${id}" force=true (search the deferred tool registry first if needed).`);
33
62
  L.push(` - This identity was NOT verified when your role was created, so it may not exist.`);
@@ -137,7 +166,17 @@ export function generateBriefing(role, v, opts) {
137
166
  L.push('on messages, timers, or prompts — and follow it for recurring or scheduled work. It may');
138
167
  L.push('change between wakes without a restart; treat the file, not your memory of it, as current.');
139
168
  L.push('', '## On restart (you run under a supervised launcher)');
140
- L.push(`On restart, WITHOUT asking: re-bind (**${v.bindTool}** name "${id}" force=true), then`);
169
+ if (opts.temporaryIdentity) {
170
+ L.push(`On restart, WITHOUT asking: try to re-bind (**${v.bindTool}** name "${id}", no force).`);
171
+ L.push('If it no longer exists, that is expected when the previous session-owned temporary');
172
+ L.push('identity was cleaned up. Re-run the same capability check from step 2: use');
173
+ L.push(`**${v.temporaryCreateTool}** with name "${id}" when exposed, otherwise fall back to`);
174
+ L.push(`**${v.createTool}** for an older server. On collision or any other creation error, STOP`);
175
+ L.push('and report it without deleting or force-adopting anything. Then');
176
+ }
177
+ else {
178
+ L.push(`On restart, WITHOUT asking: re-bind (**${v.bindTool}** name "${id}" force=true), then`);
179
+ }
141
180
  L.push(`${wakeNote} Then continue from your WORKLOG.`);
142
181
  L.push('Do not blindly re-run whatever may have crashed you.');
143
182
  L.push('', '## House rules');
package/dist/docs.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Keep this concise enough to place directly in an agent context. Unlike
5
5
  * Commander's per-command help, this describes how the pieces compose.
6
6
  */
7
- export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is 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 \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\nwatchdogs:\n nightwatch: # [A-Za-z0-9_-], must not collide with a role name\n coordinator: FleetCoordinator # required \u2014 where alerts go\n # everything below is optional\n enabled: true # default true; false = configured but never scheduled\n interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m\n watch: [Alice, CodexReviewer] # explicit lists are exact; omit for configured + live temp roles\n harness: claude-code # default: defaults.harness\n model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)\n session: acp # default: defaults.session\n identity: Watchdog-nightwatch # default: Watchdog-<name>\n timeout: 5m # default 5m; a run past this is killed and recorded as error\n keep_reports: 50 # default 50 reports retained per watchdog\n alert_cooldown: 60m # default 60m before the same finding alerts again\n prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract\n isolation: # optional; omitted means no OS sandbox, like an ordinary role\n backend: bubblewrap # when present, the ordinary role isolation schema applies\n network: broker\n fs: { read: [/opt/watch-data] }\n```\n\nA watchdog observes and reports; it never restarts, stops, spawns, or removes a\nrole, answers a pending permission, edits a workspace, or approves anything on\nthe owner's behalf. `watchdogs:` may appear only in the base config\n(`~/fleet.yaml` or `-c FILE`), not in `~/fleet.d/*.yaml` drop-ins.\nWatchdogs are not isolated by default. An explicit watchdog `isolation:` block\nuses the same policy schema as a role and is applied unchanged; declare every\nextra filesystem access required by a custom prompt there.\nWhen `watch:` is omitted, each run watches the configured roles plus temporary\nfleet roles that are live when the run starts. An explicit `watch:` list is\nnever augmented.\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\nThe default is `false`. For a temporary role whose mission intentionally arrives\nafter its readiness announcement, set `mode: fleet` and `interrupt: true`\nexplicitly. The readiness announcement does not change the transport: the\nmission remains ordinary ours mail, fleet injects only the body-free wake, and\nthe role calls `get_messages` before acting. Every later configured wake uses\nthe same interruption policy.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Trusted owner channel\n\nAn ACP role may declare a separate, existing ours identity which fleet \u2014 never\nthe agent \u2014 binds:\n\n```yaml\nowner_channel:\n identity: Coordinator Owner Channel\n owners: [authenticated-owner-contact-cid]\n agent: authenticated-managed-agent-cid\n interrupt: false\n progress_interval_ms: 30000\n attachments:\n enabled: true\n max_files_per_request: 4\n max_file_bytes: 10485760\n max_request_bytes: 20971520\n retention_ms: 86400000\n allowed_mime: [application/pdf, text/plain, image/png, audio/ogg]\n```\n\nThis does not replace the role identity. Normal identity mail remains untrusted\npeer input: the agent reads it through `get_messages` and replies through\n`send_message`. Mail arriving on the dedicated channel from a CID in `owners`\nis injected as a direct `[fleet-owner]` prompt. Mail from the exact `agent`\nCID is forwarded as a new message to the latest authenticated owner conversation.\nEvery other CID is rejected and warned about without reflecting its body. Fleet sends\naccepted/queued/progress/interrupted/failure notices and routes the ACP turn's\nfinal assistant text back to the authenticated sender with its source wire ID.\nFor file replies, fleet injects a request-specific outbox path into the owner\nprompt. The agent copies completed artifacts there; fleet sends every regular\nfile from the channel identity with the same source wire ID and removes the\ntemporary outbox only after successful delivery. The agent never chooses an owner\nrecipient or calls ours `send_file` for an owner-channel response.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/interrupt`, `/clear`, `/compact`, `/model <model-id>`,\n`/restart`, `/force-restart`, `/ls`, `/peek`, `/worklog`, and\n`/version`. Unknown or malformed commands answer with the help text instead of\nbeing forwarded; plain messages reach the agent unchanged. `/clear`,\n`/compact`, and `/model` are forwarded only when the role's bundled ACP\nadapter executes them locally (claude-code: all three; codex: `/compact`\nonly) and are otherwise refused with a notice, so slash text never reaches the\nmodel as a prompt.\n\nOwner documents, images, and voice messages use the same authenticated sender\nand source-wire boundary. Fleet inspects body-free metadata first and rejects\ndisabled, over-count, over-size, or disallowed-MIME requests before selective\nretrieval. Unauthorized CIDs are never retrieved or answered. Reply-linked text\nand files from the same sender become one ordered request; a file-only wake also\nstarts a turn. Retrieved bytes must match their structured size and SHA-256,\ntheir content signature must match the declared MIME, and symlinks or non-regular\npaths fail closed. Sanitized copies live only in a mode-0700 request directory as\nmode-0600 files and are removed after completion or bounded stale retention.\n\nVoice prompts include a bounded transcript only when ours-mcp reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\nfallback. Run `ours-mcp voice-status --json` to inspect the host configuration.\nA mode-0600 crash journal contains only authenticated CID and wire routing data;\nit never stores captions, filenames, paths, transcript text, or bytes. Journaled\npost-retrieval files resume selectively through `save_file`; corrupt state\ndisables attachment admission rather than weakening provenance checks.\n\nThe channel identity must be unique and must not be a role identity. The bridge\npersists bounded wire IDs only, never message/reply plaintext, and requeues input\nbefore starting its turn for at-least-once crash recovery. It currently requires\n`session: acp`: tmux has no structured, turn-correlated final answer, and pane\nscraping cannot provide the same reliable reply guarantee.\n\n### Live contact and owner administration\n\nThe supervisor which is already running the ACP role remains the sole binder of\n`owner_channel.identity`. The CLI reaches that exact live `OwnerChannel`\nthrough the role's token-authenticated, mode-0600 Unix control socket for contact\ninspection and setup; it never starts another ours client and never force-binds:\n\n```sh\nours-fleet owner-channel contact list <Role>\nours-fleet owner-channel contact invite <Role> [--name <label>]\nours-fleet owner-channel contact add <Role> (--invite-file <path> | --invite-stdin) [--name <label>]\nours-fleet owner-channel owner list <Role>\nours-fleet owner-channel owner authorize <Role> <exact-64-hex-contact-cid>\nours-fleet owner-channel owner revoke <Role> <exact-64-hex-contact-cid>\n```\n\nContact establishment and owner authorization are separate security steps.\n`contact add` never authorizes: invite redemption is pending until the peer\nverifies it. Once `contact list` reports the established contact, authorize\nits exact immutable CID explicitly. Invite creation emits invite material only\non stdout; acceptance reads it from a file or stdin, not argv.\n\nConfigured `owners` remain the baseline. On legacy channels without `agent`,\nlive authorizations/revocations are an immediately effective, restart-persistent\noverlay. Managed-agent CID gating makes fleet configuration authoritative and\ndisables live owner mutation and direct control-socket sends. `owner list` labels\nbaseline versus dynamic entries and effective status. The atomic mode-0600 file\ncontains bounded CIDs and audit actions only. Corruption disables all effective\nowners and refuses mutation rather than resurrecting authority; revoking the\nlast effective owner is always refused.\n\nA missing/stopped role, tmux session, role without `owner_channel`, unavailable\nMCP client, or a role entering shutdown returns an actionable error with no\nside effects. Management uses no network listener and never logs or persists\ninvite material.\n\nFor any non-final message\u2014progress, blocker, suggestion, or later proactive note\u2014\nthe managed agent calls ordinary ours `send_message` to the channel identity.\nFleet checks only that the authenticated sender CID exactly equals `agent`, then\nforwards the text as a new message. There is no task/request/update type, phase,\nreply correlation, or owner recipient argument. A sole owner is the safe fallback;\nwith multiple owners and no inbound route history the relay fails closed. Devices\nsharing one identity share its CID; separate owner identities hand off the route\nwhen either sends channel mail. The ACP final is separate: fleet extracts it from\nthe completed turn and deterministically replies to the initiating owner wire.\n\nThe bounded mode-0600 route state stores CIDs, wire IDs, timestamps, delivery state,\nand hashes but never message plaintext. Unauthorized attempts produce a bounded\nCID-only owner warning; attempted bodies are neither reflected nor persisted.\n\nFor a mobile owner, establish the contact first, wait for peer verification,\nauthorize its exact CID, and revoke that same CID when access ends. The bounded\nmode-0600 CID overlay survives supervisor restart and remains fail-closed on\ncorruption. Update bodies remain memory-only. After a crash/restart, unfinished\ndeferred owner input follows the existing at-least-once replay path; the restarted\nsupervisor remains the sole binder.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
7
+ export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is 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 \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\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\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\nwatchdogs:\n nightwatch: # [A-Za-z0-9_-], must not collide with a role name\n coordinator: FleetCoordinator # required \u2014 where alerts go\n # everything below is optional\n enabled: true # default true; false = configured but never scheduled\n interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m\n watch: [Alice, CodexReviewer] # explicit lists are exact; omit for configured + live temp roles\n harness: claude-code # default: defaults.harness\n model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)\n session: acp # default: defaults.session\n identity: Watchdog-nightwatch # default: Watchdog-<name>\n timeout: 5m # default 5m; a run past this is killed and recorded as error\n keep_reports: 50 # default 50 reports retained per watchdog\n alert_cooldown: 60m # default 60m before the same finding alerts again\n prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract\n isolation: # optional; omitted means no OS sandbox, like an ordinary role\n backend: bubblewrap # when present, the ordinary role isolation schema applies\n network: broker\n fs: { read: [/opt/watch-data] }\n```\n\nA watchdog observes and reports; it never restarts, stops, spawns, or removes a\nrole, answers a pending permission, edits a workspace, or approves anything on\nthe owner's behalf. `watchdogs:` may appear only in the base config\n(`~/fleet.yaml` or `-c FILE`), not in `~/fleet.d/*.yaml` drop-ins.\nWatchdogs are not isolated by default. An explicit watchdog `isolation:` block\nuses the same policy schema as a role and is applied unchanged; declare every\nextra filesystem access required by a custom prompt there.\nWhen `watch:` is omitted, each run watches the configured roles plus temporary\nfleet roles that are live when the run starts. An explicit `watch:` list is\nnever augmented.\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\nThe default is `false`. For a temporary role whose mission intentionally arrives\nafter its readiness announcement, set `mode: fleet` and `interrupt: true`\nexplicitly. The readiness announcement does not change the transport: the\nmission remains ordinary ours mail, fleet injects only the body-free wake, and\nthe role calls `get_messages` before acting. Every later configured wake uses\nthe same interruption policy.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Trusted owner channel\n\nAn ACP role may declare a separate, existing ours identity which fleet \u2014 never\nthe agent \u2014 binds:\n\n```yaml\nowner_channel:\n identity: Coordinator Owner Channel\n owners: [authenticated-owner-contact-cid]\n agent: authenticated-managed-agent-cid\n interrupt: false\n progress_interval_ms: 30000\n attachments:\n enabled: true\n max_files_per_request: 4\n max_file_bytes: 10485760\n max_request_bytes: 20971520\n retention_ms: 86400000\n allowed_mime: [application/pdf, text/plain, image/png, audio/ogg]\n```\n\nThis does not replace the role identity. Normal identity mail remains untrusted\npeer input: the agent reads it through `get_messages` and replies through\n`send_message`. Mail arriving on the dedicated channel from a CID in `owners`\nis injected as a direct `[fleet-owner]` prompt. Mail from the exact `agent`\nCID is forwarded as a new message to the latest authenticated owner conversation.\nEvery other CID is rejected and warned about without reflecting its body. Fleet sends\naccepted/queued/progress/interrupted/failure notices and routes the ACP turn's\nfinal assistant text back to the authenticated sender with its source wire ID.\nFor file replies, fleet injects a request-specific outbox path into the owner\nprompt. The agent copies completed artifacts there; fleet sends every regular\nfile from the channel identity with the same source wire ID and removes the\ntemporary outbox only after successful delivery. The agent never chooses an owner\nrecipient or calls ours `send_file` for an owner-channel response.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/interrupt`, `/clear`, `/compact`, `/model <model-id>`,\n`/restart`, `/force-restart`, `/ls`, `/peek`, `/worklog`, and\n`/version`. Unknown or malformed commands answer with the help text instead of\nbeing forwarded; plain messages reach the agent unchanged. `/clear`,\n`/compact`, and `/model` are forwarded only when the role's bundled ACP\nadapter executes them locally (claude-code: all three; codex: `/compact`\nonly) and are otherwise refused with a notice, so slash text never reaches the\nmodel as a prompt.\n\nOwner documents, images, and voice messages use the same authenticated sender\nand source-wire boundary. Fleet inspects body-free metadata first and rejects\ndisabled, over-count, over-size, or disallowed-MIME requests before selective\nretrieval. Unauthorized CIDs are never retrieved or answered. Reply-linked text\nand files from the same sender become one ordered request; a file-only wake also\nstarts a turn. Retrieved bytes must match their structured size and SHA-256,\ntheir content signature must match the declared MIME, and symlinks or non-regular\npaths fail closed. Sanitized copies live only in a mode-0700 request directory as\nmode-0600 files and are removed after completion or bounded stale retention.\n\nVoice prompts include a bounded transcript only when ours-mcp reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\nfallback. Run `ours-mcp voice-status --json` to inspect the host configuration.\nA mode-0600 crash journal contains only authenticated CID and wire routing data;\nit never stores captions, filenames, paths, transcript text, or bytes. Journaled\npost-retrieval files resume selectively through `save_file`; corrupt state\ndisables attachment admission rather than weakening provenance checks.\n\nThe channel identity must be unique and must not be a role identity. The bridge\npersists bounded wire IDs only, never message/reply plaintext, and requeues input\nbefore starting its turn for at-least-once crash recovery. It currently requires\n`session: acp`: tmux has no structured, turn-correlated final answer, and pane\nscraping cannot provide the same reliable reply guarantee.\n\n### Live contact and owner administration\n\nThe supervisor which is already running the ACP role remains the sole binder of\n`owner_channel.identity`. The CLI reaches that exact live `OwnerChannel`\nthrough the role's token-authenticated, mode-0600 Unix control socket for contact\ninspection and setup; it never starts another ours client and never force-binds:\n\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
@@ -115,6 +115,14 @@ Permanent spawn writes \`~/fleet.d/Name.yaml\` and starts a supervised role.
115
115
  \`--temp\` writes ephemeral state, starts a detached supervisor, and removes the
116
116
  role after exit/reboot. Both lifetimes support \`--session acp\`.
117
117
 
118
+ Temporary-role identity bootstrap is capability-based. The generated briefing
119
+ first tries to bind the exact assigned identity and preserves it when it already
120
+ exists. If missing, it uses ours MCP \`create_temporary_identity\` when that tool
121
+ is exposed, tying a newly-created identity to the connector session lifecycle;
122
+ older servers fall back to \`create_identity\`. Collisions and creation errors
123
+ stop safely without force-adopting or deleting identity state. Permanent roles
124
+ retain normal \`create_identity\` behavior.
125
+
118
126
  Codex-specific spawn flags: \`--sandbox\`, \`--permission-mode\`, \`--launcher\`,
119
127
  \`--profile\`, \`--search\`, repeatable \`--codex-config key=value\`, repeatable
120
128
  \`--add-dir\`, and legacy \`--monitor\` (consent for the native Codex monitor,
@@ -439,6 +447,21 @@ The supervisor which is already running the ACP role remains the sole binder of
439
447
  through the role's token-authenticated, mode-0600 Unix control socket for contact
440
448
  inspection and setup; it never starts another ours client and never force-binds:
441
449
 
450
+ Rapid supervised restart is serialized by a role-scoped single-binder lease.
451
+ The predecessor closes its authenticated control socket and MCP proxy before
452
+ releasing ownership. The replacement waits at most five seconds and retries the
453
+ daemon bind only when PID/start-marker metadata proves the holder was the same
454
+ role and owner-channel identity. Foreign, live, corrupt, or otherwise
455
+ unverifiable ownership remains fail-closed; fleet never uses \`force=true\`.
456
+
457
+ If that matching predecessor misses the bound, its still-authenticated control
458
+ route may send one fixed, digest-deduplicated recovery notice through the latest
459
+ authenticated owner conversation (or the sole configured owner). Notice
460
+ plaintext is never persisted. With no safe deterministic route fleet guesses no
461
+ recipient and leaves the actionable failure in the web console and role logs.
462
+ The remote recovery action is \`/restart\`; inspect repeated failures with
463
+ \`ours-fleet logs <Role>\` or the web console.
464
+
442
465
  \`\`\`sh
443
466
  ours-fleet owner-channel contact list <Role>
444
467
  ours-fleet owner-channel contact invite <Role> [--name <label>]
@@ -259,6 +259,7 @@ export function makeClaudeCodeAdapter(exec = realExec) {
259
259
  vocabulary: {
260
260
  bindTool: 'choose_identity',
261
261
  createTool: 'create_identity',
262
+ temporaryCreateTool: 'create_temporary_identity',
262
263
  setBioTool: 'set_bio',
263
264
  setPersonaTool: 'set_persona',
264
265
  currentIdentityTool: 'current_identity',
@@ -288,6 +288,7 @@ export function makeCodexAdapter(exec = realExec) {
288
288
  vocabulary: {
289
289
  bindTool: 'choose_identity',
290
290
  createTool: 'create_identity',
291
+ temporaryCreateTool: 'create_temporary_identity',
291
292
  setBioTool: 'set_bio',
292
293
  setPersonaTool: 'set_persona',
293
294
  currentIdentityTool: 'current_identity',
@@ -60,6 +60,8 @@ export type PermissionTranslation = {
60
60
  export interface BriefingVocab {
61
61
  bindTool: string;
62
62
  createTool: string;
63
+ /** Session-scoped identity creation, capability-detected by temporary roles. */
64
+ temporaryCreateTool: string;
63
65
  setBioTool: string;
64
66
  setPersonaTool: string;
65
67
  currentIdentityTool: string;
package/dist/ops.js CHANGED
@@ -38,6 +38,7 @@ export function applyRole(role, opts = {}) {
38
38
  stateDir: dir, worklogPath: join(dir, 'WORKLOG.md'),
39
39
  routinesPath: join(dir, 'ROUTINES.md'), briefingBody,
40
40
  identityGuarantee: opts.identityGuarantee,
41
+ temporaryIdentity: opts.temp === true,
41
42
  }));
42
43
  if (opts.fresh)
43
44
  for (const f of ['.booted', '.session-id', '.exit-status'])
@@ -0,0 +1,24 @@
1
+ export declare const OWNER_BIND_HANDOFF_TIMEOUT_MS = 5000;
2
+ export interface OwnerBinderDeps {
3
+ now?(): number;
4
+ sleep?(ms: number): Promise<void>;
5
+ alive?(pid: number): boolean;
6
+ processMarker?(pid: number): string | undefined;
7
+ /** Test seam for synchronizing contenders after stale-owner validation. */
8
+ beforeReclaim?(): Promise<void>;
9
+ }
10
+ export interface OwnerBinderLease {
11
+ /** True only when a prior binder for this exact role and identity was observed. */
12
+ inherited: boolean;
13
+ release(): void;
14
+ }
15
+ export declare class OwnerBinderConflictError extends Error {
16
+ }
17
+ export declare class OwnerBinderHandoffTimeoutError extends Error {
18
+ }
19
+ /**
20
+ * Serialize the one supervisor-owned binder for a role. A live matching holder
21
+ * is an overlapping predecessor and gets a bounded handoff window. A holder
22
+ * for any other role/identity, or unverifiable metadata, remains fail-closed.
23
+ */
24
+ export declare function acquireOwnerBinderLease(stateDir: string, role: string, identity: string, deps?: OwnerBinderDeps, timeoutMs?: number): Promise<OwnerBinderLease>;
@@ -0,0 +1,224 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { replaceFileAtomically } from '../atomic-file.js';
5
+ export const OWNER_BIND_HANDOFF_TIMEOUT_MS = 5_000;
6
+ const OWNER_BIND_POLL_MS = 50;
7
+ const LOCK_DIR = '.owner-channel-binder.lock';
8
+ const RECLAIM_DIR = '.owner-channel-binder.reclaim.lock';
9
+ const LAST_OWNER_FILE = '.owner-channel-binder.json';
10
+ export class OwnerBinderConflictError extends Error {
11
+ }
12
+ export class OwnerBinderHandoffTimeoutError extends Error {
13
+ }
14
+ const defaultAlive = (pid) => {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ };
23
+ /** Linux PID-reuse fence. Other platforms return undefined and stay conservative. */
24
+ const defaultProcessMarker = (pid) => {
25
+ try {
26
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
27
+ const end = stat.lastIndexOf(')');
28
+ return stat.slice(end + 2).split(/\s+/)[19];
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ };
34
+ function parseOwner(path) {
35
+ let value;
36
+ try {
37
+ value = JSON.parse(readFileSync(path, 'utf8'));
38
+ }
39
+ catch {
40
+ throw new OwnerBinderConflictError('owner-channel binder ownership cannot be verified safely');
41
+ }
42
+ const owner = value;
43
+ if (owner.version !== 1 || typeof owner.role !== 'string' || typeof owner.identity !== 'string'
44
+ || !Number.isSafeInteger(owner.pid) || Number(owner.pid) < 2
45
+ || typeof owner.instance !== 'string' || !owner.instance
46
+ || !Number.isFinite(owner.acquiredAt))
47
+ throw new OwnerBinderConflictError('owner-channel binder ownership cannot be verified safely');
48
+ return owner;
49
+ }
50
+ function sameOwner(a, b) {
51
+ return a.instance === b.instance && a.pid === b.pid && a.role === b.role
52
+ && a.identity === b.identity;
53
+ }
54
+ /**
55
+ * Serialize the one supervisor-owned binder for a role. A live matching holder
56
+ * is an overlapping predecessor and gets a bounded handoff window. A holder
57
+ * for any other role/identity, or unverifiable metadata, remains fail-closed.
58
+ */
59
+ export async function acquireOwnerBinderLease(stateDir, role, identity, deps = {}, timeoutMs = OWNER_BIND_HANDOFF_TIMEOUT_MS) {
60
+ const now = deps.now ?? (() => Date.now());
61
+ const sleep = deps.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
62
+ const alive = deps.alive ?? defaultAlive;
63
+ const processMarker = deps.processMarker ?? defaultProcessMarker;
64
+ const lockDir = join(stateDir, LOCK_DIR);
65
+ const reclaimDir = join(stateDir, RECLAIM_DIR);
66
+ const ownerPath = join(lockDir, 'owner.json');
67
+ const releasedPath = join(stateDir, LAST_OWNER_FILE);
68
+ const startedAt = now();
69
+ let inherited = false;
70
+ const ours = {
71
+ version: 1, role, identity, pid: process.pid,
72
+ ...(processMarker(process.pid) ? { marker: processMarker(process.pid) } : {}),
73
+ instance: randomUUID(), acquiredAt: startedAt,
74
+ };
75
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
76
+ for (;;) {
77
+ try {
78
+ mkdirSync(lockDir, { mode: 0o700 });
79
+ writeFileSync(ownerPath, JSON.stringify(ours) + '\n', { mode: 0o600 });
80
+ // A reclaimer may have installed its gate after our mkdir. It will
81
+ // re-check the canonical owner before claiming, while we withdraw our
82
+ // own raced acquisition before returning.
83
+ if (existsSync(reclaimDir)) {
84
+ const check = parseOwner(ownerPath);
85
+ if (!sameOwner(check, ours))
86
+ throw new OwnerBinderConflictError('owner-channel binder ownership changed during acquisition');
87
+ rmSync(lockDir, { recursive: true, force: true });
88
+ if (now() - startedAt >= timeoutMs)
89
+ throw new OwnerBinderHandoffTimeoutError(`owner-channel binder reclaim did not complete within ${timeoutMs}ms`);
90
+ await sleep(Math.min(OWNER_BIND_POLL_MS, Math.max(1, timeoutMs - (now() - startedAt))));
91
+ continue;
92
+ }
93
+ break;
94
+ }
95
+ catch (error) {
96
+ if (error.code !== 'EEXIST')
97
+ throw error;
98
+ let current;
99
+ try {
100
+ current = parseOwner(ownerPath);
101
+ }
102
+ catch (readError) {
103
+ // The winning process may be between atomic mkdir and its tiny metadata write.
104
+ if (now() - startedAt < OWNER_BIND_POLL_MS * 2) {
105
+ await sleep(OWNER_BIND_POLL_MS);
106
+ continue;
107
+ }
108
+ throw readError;
109
+ }
110
+ if (current.role !== role || current.identity !== identity)
111
+ throw new OwnerBinderConflictError(`owner-channel identity '${identity}' is reserved by foreign binder `
112
+ + `'${current.role}' for '${current.identity}'`);
113
+ inherited = true;
114
+ const currentMarker = processMarker(current.pid);
115
+ const stale = !alive(current.pid)
116
+ || Boolean(current.marker && currentMarker && current.marker !== currentMarker);
117
+ if (stale) {
118
+ await deps.beforeReclaim?.();
119
+ try {
120
+ mkdirSync(reclaimDir, { mode: 0o700 });
121
+ }
122
+ catch (gateError) {
123
+ if (gateError.code === 'EEXIST') {
124
+ await sleep(OWNER_BIND_POLL_MS);
125
+ continue;
126
+ }
127
+ throw gateError;
128
+ }
129
+ const tombstone = `${lockDir}.reclaim-${ours.instance}`;
130
+ let acquiredFromClaim = false;
131
+ try {
132
+ const check = parseOwner(ownerPath);
133
+ if (!sameOwner(current, check))
134
+ continue;
135
+ try {
136
+ renameSync(lockDir, tombstone);
137
+ }
138
+ catch (claimError) {
139
+ if (claimError.code === 'ENOENT')
140
+ continue;
141
+ throw claimError;
142
+ }
143
+ let claimed;
144
+ try {
145
+ claimed = parseOwner(join(tombstone, 'owner.json'));
146
+ }
147
+ catch (claimError) {
148
+ // Preserve an unverifiable claim. Restore it only if nobody has
149
+ // already acquired the canonical path; otherwise fail closed.
150
+ try {
151
+ renameSync(tombstone, lockDir);
152
+ }
153
+ catch { /* keep the claim quarantined */ }
154
+ throw claimError;
155
+ }
156
+ if (!sameOwner(current, claimed)) {
157
+ // Never delete a replacement: put it back when possible, otherwise
158
+ // retain the unique tombstone as the fail-closed claimed record.
159
+ try {
160
+ renameSync(tombstone, lockDir);
161
+ }
162
+ catch { /* keep the claim quarantined */ }
163
+ throw new OwnerBinderConflictError('owner-channel binder changed while stale ownership was being claimed');
164
+ }
165
+ rmSync(tombstone, { recursive: true, force: true });
166
+ // Keep the gate until our replacement is durable. Any acquisition
167
+ // that raced the rename observes the gate and withdraws itself.
168
+ for (;;) {
169
+ try {
170
+ mkdirSync(lockDir, { mode: 0o700 });
171
+ writeFileSync(ownerPath, JSON.stringify(ours) + '\n', { mode: 0o600 });
172
+ acquiredFromClaim = true;
173
+ break;
174
+ }
175
+ catch (replacementError) {
176
+ if (replacementError.code !== 'EEXIST')
177
+ throw replacementError;
178
+ if (now() - startedAt >= timeoutMs)
179
+ throw new OwnerBinderHandoffTimeoutError(`could not complete stale owner-channel binder claim within ${timeoutMs}ms`);
180
+ await sleep(1);
181
+ }
182
+ }
183
+ }
184
+ finally {
185
+ rmSync(reclaimDir, { recursive: true, force: true });
186
+ }
187
+ if (acquiredFromClaim)
188
+ break;
189
+ continue;
190
+ }
191
+ if (now() - startedAt >= timeoutMs)
192
+ throw new OwnerBinderHandoffTimeoutError(`previous '${role}' supervisor still owns owner-channel identity '${identity}' `
193
+ + `after ${timeoutMs}ms bounded handoff`);
194
+ await sleep(Math.min(OWNER_BIND_POLL_MS, Math.max(1, timeoutMs - (now() - startedAt))));
195
+ }
196
+ }
197
+ try {
198
+ const released = JSON.parse(readFileSync(releasedPath, 'utf8'));
199
+ if (released.version === 1 && released.role === role && released.identity === identity
200
+ && Number.isFinite(released.releasedAt)
201
+ && now() - Number(released.releasedAt) <= timeoutMs * 2)
202
+ inherited = true;
203
+ }
204
+ catch { /* absence/corruption cannot grant recovery authority */ }
205
+ let released = false;
206
+ return {
207
+ inherited,
208
+ release() {
209
+ if (released)
210
+ return;
211
+ released = true;
212
+ try {
213
+ const current = parseOwner(ownerPath);
214
+ if (!sameOwner(current, ours))
215
+ return;
216
+ replaceFileAtomically(releasedPath, JSON.stringify({
217
+ ...ours, releasedAt: now(),
218
+ }) + '\n', 0o600);
219
+ rmSync(lockDir, { recursive: true, force: true });
220
+ }
221
+ catch { /* never remove ownership which cannot be proven to be ours */ }
222
+ },
223
+ };
224
+ }
@@ -6,6 +6,7 @@ import { type OursToolClient } from './mcp.js';
6
6
  import { type OwnerUpdatePhase } from './notices.js';
7
7
  import { type OwnerEntry } from './state.js';
8
8
  import { type OwnerTaskPhase } from './tasks.js';
9
+ import { type OwnerBinderDeps, type OwnerBinderLease } from './binder.js';
9
10
  export interface OwnerChannelOptions {
10
11
  role: string;
11
12
  /** Harness id of the role (e.g. 'claude-code', 'codex'); gates which slash commands may be forwarded. */
@@ -23,6 +24,10 @@ export interface OwnerChannelOptions {
23
24
  fleet?: OwnerFleetOps;
24
25
  /** Forwarded to fleet CLI invocations spawned for owner commands. */
25
26
  configPath?: string;
27
+ /** Deterministic clock/process seams for binder handoff tests. */
28
+ binderDeps?: OwnerBinderDeps;
29
+ /** Pre-acquired by the runner so the predecessor control socket remains reachable while waiting. */
30
+ binderLease?: OwnerBinderLease;
26
31
  }
27
32
  export interface OwnerChannelHandle {
28
33
  start(): Promise<void>;
@@ -60,6 +65,8 @@ export type OwnerChannelManagementRequest = {
60
65
  taskId: string;
61
66
  phase: OwnerTaskPhase;
62
67
  message: string;
68
+ } | {
69
+ action: 'startup_failure';
63
70
  };
64
71
  export type OwnerChannelManagementResult = {
65
72
  action: 'contact_list';
@@ -95,6 +102,9 @@ export type OwnerChannelManagementResult = {
95
102
  phase: OwnerTaskPhase;
96
103
  sequence: number;
97
104
  state: 'open' | 'closed';
105
+ } | {
106
+ action: 'startup_failure';
107
+ status: 'delivered' | 'duplicate';
98
108
  };
99
109
  export type { OwnerUpdatePhase } from './notices.js';
100
110
  export interface OwnerContact {
@@ -137,6 +147,8 @@ export declare class OwnerChannel implements OwnerChannelHandle {
137
147
  private readonly activeRequests;
138
148
  private managementTail;
139
149
  private ready;
150
+ private binder?;
151
+ private binderOwnedInternally;
140
152
  private readonly fleetOps;
141
153
  constructor(options: OwnerChannelOptions);
142
154
  start(): Promise<void>;
@@ -11,6 +11,7 @@ import { ownerNotices } from './notices.js';
11
11
  import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
12
12
  import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
13
13
  import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, } from './attachments.js';
14
+ import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
14
15
  const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
15
16
  const OWNER_UPDATE_MAX_COUNT = 20;
16
17
  const OWNER_UPDATE_MAX_CHARS = 280;
@@ -51,6 +52,8 @@ export class OwnerChannel {
51
52
  activeRequests = new Map();
52
53
  managementTail = Promise.resolve();
53
54
  ready = false;
55
+ binder;
56
+ binderOwnedInternally = false;
54
57
  fleetOps;
55
58
  constructor(options) {
56
59
  this.options = options;
@@ -79,8 +82,36 @@ export class OwnerChannel {
79
82
  }
80
83
  async start() {
81
84
  this.stopping = false;
82
- await this.client.start();
83
- await this.client.callTool('choose_identity', { name: this.options.config.identity });
85
+ this.binderOwnedInternally = !this.options.binderLease;
86
+ this.binder = this.options.binderLease ?? await acquireOwnerBinderLease(this.options.stateDir, this.options.role, this.options.config.identity, this.options.binderDeps);
87
+ try {
88
+ await this.client.start();
89
+ const now = this.options.binderDeps?.now ?? (() => Date.now());
90
+ const sleep = this.options.binderDeps?.sleep
91
+ ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
92
+ const bindStartedAt = now();
93
+ for (;;) {
94
+ try {
95
+ await this.client.callTool('choose_identity', { name: this.options.config.identity });
96
+ break;
97
+ }
98
+ catch (error) {
99
+ const message = error?.message ?? String(error);
100
+ const liveConflict = /currently bound to another live session/i.test(message);
101
+ if (!this.binder.inherited || !liveConflict
102
+ || now() - bindStartedAt >= OWNER_BIND_HANDOFF_TIMEOUT_MS)
103
+ throw error;
104
+ await sleep(Math.min(50, Math.max(1, OWNER_BIND_HANDOFF_TIMEOUT_MS - (now() - bindStartedAt))));
105
+ }
106
+ }
107
+ }
108
+ catch (error) {
109
+ await this.client.close().catch(closeError => this.logError('startup client close failed', closeError));
110
+ if (this.binderOwnedInternally)
111
+ this.binder.release();
112
+ this.binder = undefined;
113
+ throw error;
114
+ }
84
115
  if (this.authorizationIntegrity().ok && this.tasks.integrity().ok)
85
116
  this.tasks.cleanup(Date.now(), this.effectiveOwners());
86
117
  if (this.attachmentRecovery.integrity()) {
@@ -112,7 +143,14 @@ export class OwnerChannel {
112
143
  if (watch && watch.exitCode === null)
113
144
  watch.kill('SIGTERM');
114
145
  await this.managementTail;
115
- await this.client.close();
146
+ try {
147
+ await this.client.close();
148
+ }
149
+ finally {
150
+ if (this.binderOwnedInternally)
151
+ this.binder?.release();
152
+ this.binder = undefined;
153
+ }
116
154
  }
117
155
  manage(request) {
118
156
  const run = this.managementTail.then(() => this.manageNow(request));
@@ -208,6 +246,18 @@ export class OwnerChannel {
208
246
  if (this.options.config.agent)
209
247
  throw new Error('direct task reports are disabled; the managed agent must message its owner-channel identity');
210
248
  return this.sendOwnerTaskReport(request);
249
+ case 'startup_failure': {
250
+ const message = ownerNotices.startupHandoffFailed(this.options.role, this.options.config.identity);
251
+ try {
252
+ await this.sendProactiveMessage(message);
253
+ }
254
+ catch (error) {
255
+ if (error instanceof DuplicateSendError)
256
+ return { action: request.action, status: 'duplicate' };
257
+ throw error;
258
+ }
259
+ return { action: request.action, status: 'delivered' };
260
+ }
211
261
  default:
212
262
  throw new Error('unknown owner-channel management action');
213
263
  }
@@ -70,6 +70,16 @@ export class OursMcpClient {
70
70
  this.child = undefined;
71
71
  if (!child || child.exitCode !== null)
72
72
  return;
73
+ // EOF lets the proxy close its HTTP MCP transport and release the daemon
74
+ // lease explicitly. SIGTERM used to leave that release racing the next
75
+ // supervised start, which is the owner-channel collision this path guards.
76
+ child.stdin.end();
77
+ const exited = await new Promise(resolve => {
78
+ const timer = setTimeout(() => resolve(false), 1_000);
79
+ child.once('exit', () => { clearTimeout(timer); resolve(true); });
80
+ });
81
+ if (exited || child.exitCode !== null)
82
+ return;
73
83
  child.kill('SIGTERM');
74
84
  await new Promise(resolve => {
75
85
  const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 5_000);
@@ -14,6 +14,7 @@ export declare const ownerNotices: {
14
14
  commandFailed: (command: string) => string;
15
15
  commandUnsupported: (command: string, harness: string) => string;
16
16
  restarting: (role: string, command: string, mode: "keep" | "fresh") => string;
17
+ startupHandoffFailed: (role: string, identity: string) => string;
17
18
  attachmentRejected: (reason: string) => string;
18
19
  attachmentFailed: () => string;
19
20
  deliveryFailed: (role: string) => string;
@@ -39,6 +39,9 @@ export const ownerNotices = {
39
39
  restarting: (role, command, mode) => `ℹ️ ${command} accepted — restarting ${role} ${mode === 'fresh'
40
40
  ? 'FRESH (context wiped)' : '(context resumes)'}. `
41
41
  + 'The channel goes quiet during the restart and resumes when the agent is back.',
42
+ startupHandoffFailed: (role, identity) => `⚠️ ${role} owner channel could not take over '${identity}' from its previous supervisor. `
43
+ + `Recovery: send /restart to retry the supervised handoff; if this repeats, inspect the web `
44
+ + `console or run ours-fleet logs ${role}.`,
42
45
  attachmentRejected: (reason) => `⚠️ Attachment rejected: ${reason}.`,
43
46
  attachmentFailed: () => '⚠️ Could not securely retrieve or admit this attachment request.',
44
47
  deliveryFailed: (role) => `⚠️ Could not deliver this request to ${role}.`,
package/dist/runner.d.ts CHANGED
@@ -5,6 +5,7 @@ import { type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.
5
5
  import { type Exec } from './exec.js';
6
6
  import type { ExitRecord } from './session/types.js';
7
7
  import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-channel/channel.js';
8
+ import { type OwnerBinderLease } from './owner-channel/binder.js';
8
9
  export interface RunnerDeps {
9
10
  tmux: Tmux;
10
11
  exec: Exec;
@@ -19,6 +20,10 @@ export interface RunnerDeps {
19
20
  createMonitor(opts: MonitorOpts): MonitorHandle;
20
21
  /** Construct trusted owner ingress (injectable for lifecycle tests). */
21
22
  createOwnerChannel(opts: OwnerChannelOptions): OwnerChannelHandle;
23
+ /** Acquire the cross-process owner-channel binder lease before replacing the control socket. */
24
+ acquireOwnerBinder(stateDir: string, role: string, identity: string): Promise<OwnerBinderLease>;
25
+ /** Ask the still-authenticated predecessor to emit the fixed recovery notice. */
26
+ reportOwnerStartupFailure(stateDir: string): Promise<'delivered' | 'duplicate'>;
22
27
  /** Lets a test (or a shutdown path) end the supervised restart loop. */
23
28
  shouldStop?(): boolean;
24
29
  }
package/dist/runner.js CHANGED
@@ -13,12 +13,13 @@ import { selectIsolationBackend } from './isolation/registry.js';
13
13
  import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
14
14
  import { resolveLaunchRuntime } from './isolation/runtime.js';
15
15
  import { AcpSession } from './session/acp.js';
16
- import { RoleControlServer } from './session/control.js';
16
+ import { controlRequest, RoleControlServer } from './session/control.js';
17
17
  import { TmuxSession } from './session/tmux.js';
18
18
  import { classifyShellStatus } from './session/types.js';
19
19
  import { effectiveModelForRole, modelRecoveryHeld, reconcileModelRecovery, recordModelFailure, classifyFailureText, } from './model-recovery.js';
20
20
  import { rotateWorklog } from './worklog.js';
21
21
  import { OwnerChannel } from './owner-channel/channel.js';
22
+ import { acquireOwnerBinderLease, OwnerBinderHandoffTimeoutError, } from './owner-channel/binder.js';
22
23
  import { RoleTurnArbiter } from './session/arbiter.js';
23
24
  import { ScheduledLoopManager, } from './loops/manager.js';
24
25
  const defaultDeps = () => ({
@@ -38,6 +39,19 @@ const defaultDeps = () => ({
38
39
  fetch: (url, init) => globalThis.fetch(url, init),
39
40
  createMonitor: opts => createMonitor(opts),
40
41
  createOwnerChannel: opts => new OwnerChannel(opts),
42
+ acquireOwnerBinder: (stateDir, role, identity) => acquireOwnerBinderLease(stateDir, role, identity),
43
+ reportOwnerStartupFailure: async (stateDir) => {
44
+ const response = await controlRequest(stateDir, {
45
+ command: 'owner_channel_manage', ownerChannel: { action: 'startup_failure' },
46
+ }, 2_000);
47
+ if (!response.ok)
48
+ throw new Error(response.error ?? 'prior owner channel refused startup notice');
49
+ const result = response.result;
50
+ if (result?.action !== 'startup_failure'
51
+ || (result.status !== 'delivered' && result.status !== 'duplicate'))
52
+ throw new Error('prior owner channel returned an invalid startup notice result');
53
+ return result.status;
54
+ },
41
55
  });
42
56
  const MONITOR_OWNER_FILE = '.monitor-owner';
43
57
  /**
@@ -420,6 +434,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
420
434
  let monitorLoop;
421
435
  let acpStartupComplete = false;
422
436
  let ownerChannel;
437
+ let ownerBinder;
423
438
  let loopManager;
424
439
  let arbiter;
425
440
  let reloadLoopConfig;
@@ -455,8 +470,37 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
455
470
  if (evidence)
456
471
  resolvedMonitorDeps.onFailureEvidence?.(evidence);
457
472
  });
473
+ if (role.owner_channel) {
474
+ try {
475
+ ownerBinder = await deps.acquireOwnerBinder(dir, name, role.owner_channel.identity);
476
+ }
477
+ catch (error) {
478
+ if (error instanceof OwnerBinderHandoffTimeoutError) {
479
+ try {
480
+ const status = await deps.reportOwnerStartupFailure(dir);
481
+ deps.log(`[${name}] owner channel startup recovery notice ${status} by authenticated predecessor`);
482
+ }
483
+ catch (notifyError) {
484
+ deps.log(`[${name}] owner channel startup recovery notice unavailable: `
485
+ + `${notifyError?.message ?? String(notifyError)}`);
486
+ }
487
+ }
488
+ await acpSession.close();
489
+ unsubscribeRecovery?.();
490
+ throw new Error(`[${name}] owner channel failed to start: `
491
+ + `${error?.message ?? String(error)}`);
492
+ }
493
+ }
458
494
  control = new RoleControlServer(dir, arbiter, deps.log);
459
- await control.start();
495
+ try {
496
+ await control.start();
497
+ }
498
+ catch (error) {
499
+ ownerBinder?.release();
500
+ await acpSession.close();
501
+ unsubscribeRecovery?.();
502
+ throw error;
503
+ }
460
504
  resolvedMonitorDeps.delivery = {
461
505
  // A wake is only delivered when its turn TERMINATES successfully. A
462
506
  // refusal or a cancellation reached the agent and was not acted on, so
@@ -496,6 +540,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
496
540
  if (!started.succeeded) {
497
541
  monitor?.stop();
498
542
  await control.close();
543
+ ownerBinder?.release();
499
544
  await acpSession.close();
500
545
  unsubscribeRecovery?.();
501
546
  if (modelRecovery) {
@@ -526,6 +571,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
526
571
  stateDir: dir,
527
572
  env: role.env,
528
573
  log: deps.log,
574
+ ...(ownerBinder ? { binderLease: ownerBinder } : {}),
529
575
  ...(configPath ? { configPath } : {}),
530
576
  });
531
577
  try {
@@ -535,14 +581,17 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
535
581
  monitor?.stop();
536
582
  if (monitorLoop)
537
583
  await monitorLoop;
584
+ await ownerChannel.close().catch(() => undefined);
538
585
  await control.close();
586
+ ownerBinder?.release();
539
587
  await acpSession.close();
540
588
  unsubscribeRecovery?.();
541
589
  throw new Error(`[${name}] owner channel failed to start: `
542
590
  + `${error?.message ?? String(error)}`);
543
591
  }
544
- control.setOwnerChannel(ownerChannel);
545
592
  }
593
+ if (ownerChannel)
594
+ control.setOwnerChannel(ownerChannel);
546
595
  reloadLoopConfig = async () => {
547
596
  const nextRole = findRole(loadConfig(configPath), name);
548
597
  const definitions = nextRole.loops ?? [];
@@ -628,17 +677,21 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
628
677
  await loopManager.stop();
629
678
  }
630
679
  control?.setConfigReloader(undefined);
631
- if (ownerChannel) {
632
- control?.setOwnerChannel(undefined);
633
- await ownerChannel.close();
680
+ // Close the authenticated control route before releasing the binder lease;
681
+ // otherwise the predecessor can unlink the replacement's new socket.
682
+ if (control) {
683
+ control.setOwnerChannel(undefined);
684
+ await control.close();
685
+ control = undefined;
634
686
  }
687
+ if (ownerChannel)
688
+ await ownerChannel.close();
689
+ ownerBinder?.release();
635
690
  if (monitor) {
636
691
  monitor.stop();
637
692
  await monitorLoop;
638
693
  }
639
694
  unsubscribeRecovery?.();
640
- if (control)
641
- await control.close();
642
695
  if (acpSession)
643
696
  await acpSession.close();
644
697
  const elapsed = (deps.now() - start) / 1000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.14.1",
3
+ "version": "0.15.1",
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",