@ours.network/fleet 0.17.4 → 0.17.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
@@ -430,6 +430,11 @@ role's `harness_options`, so a fleet can set common Codex permission/profile def
430
430
  and override individual keys per role. `monitor` merges the same way — a role block
431
431
  overrides `defaults.monitor` key-by-key.
432
432
 
433
+ Every supervised role is a client of the operator-configured ours daemon. Fleet
434
+ forces `OURS_AUTOSTART=0` in both tmux and ACP harness processes, after role environment
435
+ overlays, so `env.OURS_AUTOSTART` cannot transfer shared daemon lifecycle ownership to an
436
+ agent. Operators and explicit installer/setup flows remain responsible for starting it.
437
+
433
438
  ### Scheduled agent loops
434
439
 
435
440
  Top-level `loops` schedule literal prompts from trusted local YAML. Enabled targets
@@ -520,10 +525,13 @@ native settings actually grant, against a fixed floor:
520
525
  those requests with nobody to see it. With `unattended: wait` it **warns**,
521
526
  since a human can still attach a console and answer.
522
527
 
523
- **Security meaning.** `ask` maps to Codex `untrusted` / Claude `default`,
524
- `auto` to Codex `on-request` / Claude `acceptEdits`, and `allow` to Codex
525
- `never` / Claude `bypassPermissions`, the non-interactive modes that actually
526
- permit the actions the role was authorized to take.
528
+ **Security meaning.** `ask` maps to Codex `untrusted` / Claude `default`.
529
+ `auto` selects Codex ACP `agent` (`on-request` + `workspace-write`) / Claude
530
+ `acceptEdits`. `allow` selects Codex ACP's fully non-interactive yolo mode,
531
+ reported by the adapter as `agent-full-access` (`never` +
532
+ `danger-full-access`); Claude uses `bypassPermissions`. For Codex tmux, where the
533
+ approval and sandbox flags remain independent, `auto` is `on-request` and
534
+ `allow` is `never` while `filesystem` still chooses the sandbox.
527
535
  `dontAsk` suppresses only the *prompt*, not the denial, which is why an
528
536
  `allow` role previously ran unable to do its job. Legacy `deny` is accepted
529
537
  only for compatibility and retains its conservative Codex `on-request` /
@@ -535,10 +543,14 @@ ACP exposes agent-specific session mode IDs and `session/set_mode`, but no
535
543
  portable permission-policy capability. Fleet uses that primitive where an
536
544
  adapter has a corresponding mode and otherwise translates at the adapter. The
537
545
  bundled Codex ACP adapter couples approval and sandboxing in its advertised
538
- mode IDs, so fleet preserves the selected sandbox preset and enforces approval
539
- independently on the app-server turn request. Thus `allow` + `workspace` is
540
- actually `never` + `workspace-write`, never `danger-full-access`. Live session
541
- metadata reports the normalized policy and the ACP sandbox-preset ID.
546
+ mode IDs. Consequently, neutral `allow` selects `agent-full-access` and widens
547
+ `filesystem: workspace` or `read-only` to `danger-full-access`; neutral `auto`
548
+ selects `agent` and uses `workspace-write` even when the neutral filesystem
549
+ value differs. An explicit `harness_options.sandbox` still wins and selects its
550
+ corresponding ACP preset; explicit native approval overrides also win. Fleet
551
+ reports a coupled-mode mismatch as approximate in `config`/`doctor`, and
552
+ per-role `isolation:` remains the boundary for an `allow` ACP role. Live session
553
+ metadata reports the normalized policy and the exact native mode selected.
542
554
 
543
555
  ### Isolation at creation time
544
556
 
@@ -1,5 +1,6 @@
1
1
  import { lstatSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { classifyActivity } from '../session/activity.js';
3
4
  import { controlRequest } from '../session/control.js';
4
5
  import { SessionControlError } from '../session/types.js';
5
6
  import { readExitRecord, readRestartLedger } from '../runner.js';
@@ -55,6 +56,10 @@ function sessionOverall(supervisor, session, restart, monitor, isolation, proble
55
56
  if (session.reachability === 'online'
56
57
  && (session.readiness === 'running' || session.readiness === 'awaiting_permission'))
57
58
  return 'busy';
59
+ // `readiness: idle` alone never justifies `ready`. A steered wake turn is
60
+ // invisible to readiness (FLEET-002), so observed activity outranks it.
61
+ if (session.reachability === 'online' && session.activity.state === 'active')
62
+ return 'busy';
58
63
  if (session.reachability === 'online' && session.readiness === 'idle')
59
64
  return 'ready';
60
65
  if (session.reachability === 'offline'
@@ -153,6 +158,7 @@ export class FleetQueryService {
153
158
  protocolVersion: snapshot.protocolVersion, features: snapshot.features,
154
159
  runtimeModel: snapshot.runtimeModel, reasoningEffort: snapshot.reasoningEffort,
155
160
  permissionMode: snapshot.permissionMode,
161
+ activity: classifyActivity(snapshot.activity),
156
162
  };
157
163
  }
158
164
  catch (error) {
@@ -164,6 +170,7 @@ export class FleetQueryService {
164
170
  : failure === 'control-unavailable' ? 'unavailable' : 'unknown',
165
171
  readiness: offline ? 'failed' : 'unknown',
166
172
  evidence: 'authoritative', lastError: clean(error.message),
173
+ activity: { state: 'unobservable' },
167
174
  };
168
175
  }
169
176
  }
@@ -174,18 +181,23 @@ export class FleetQueryService {
174
181
  backend: 'tmux', reachability: has ? 'online' : supervisor === 'stopped' ? 'offline' : 'unknown',
175
182
  readiness: has ? 'idle' : supervisor === 'running' ? 'starting' : 'failed',
176
183
  evidence: 'inferred',
184
+ // tmux exposes no agent-side evidence at all, and `readiness: idle`
185
+ // here is a pane-liveness inference, not an activity claim.
186
+ activity: { state: 'unobservable' },
177
187
  };
178
188
  }
179
189
  catch (error) {
180
190
  return {
181
191
  backend: 'tmux', reachability: 'unknown', readiness: 'unknown',
182
192
  evidence: 'inferred', lastError: clean(error.message),
193
+ activity: { state: 'unobservable' },
183
194
  };
184
195
  }
185
196
  }
186
197
  return {
187
198
  backend: 'unknown', reachability: supervisor === 'stopped' ? 'offline' : 'unknown',
188
199
  readiness: supervisor === 'stopped' ? 'failed' : 'unknown', evidence: 'inferred',
200
+ activity: { state: 'unobservable' },
189
201
  };
190
202
  }
191
203
  }
@@ -118,7 +118,9 @@ export class RoleCreationService {
118
118
  };
119
119
  const warnings = [];
120
120
  if (request.permissions.approval === 'allow')
121
- warnings.push('approval=allow maps to an elevated native permission mode');
121
+ warnings.push(effective.harness === 'codex' && effective.session === 'acp'
122
+ ? 'approval=allow maps to elevated Codex ACP agent-full-access and widens the native sandbox to danger-full-access'
123
+ : 'approval=allow maps to an elevated native permission mode');
122
124
  if (request.permissions.filesystem === 'unrestricted')
123
125
  warnings.push('filesystem=unrestricted grants access outside the workspace');
124
126
  if (request.permissions.unattended === 'wait')
@@ -72,6 +72,17 @@ export interface RoleStatus {
72
72
  runtimeModel?: SessionSnapshot['runtimeModel'];
73
73
  reasoningEffort?: SessionSnapshot['reasoningEffort'];
74
74
  permissionMode?: SessionSnapshot['permissionMode'];
75
+ /**
76
+ * Activity evidence, kept separate from `readiness` on purpose: `readiness`
77
+ * answers "is a fleet-tracked turn in flight" (the prompt-admission gate),
78
+ * NOT "is this agent working". `state` is the only field a human-facing
79
+ * surface may use to call a role idle or stalled.
80
+ */
81
+ activity: {
82
+ state: 'active' | 'quiet' | 'unobservable';
83
+ activeToolCalls?: number;
84
+ lastUpdateAt?: string;
85
+ };
75
86
  };
76
87
  restart: {
77
88
  circuit: 'closed' | 'open';
package/dist/briefing.js CHANGED
@@ -168,6 +168,11 @@ export function generateBriefing(role, v, opts) {
168
168
  L.push('Never translate any other failure into "dead". A busy agent, an unanswered control');
169
169
  L.push('plane and a confirmed stop look identical if you only look at one command.');
170
170
  L.push('');
171
+ L.push('`ours-fleet status` reports `session.readiness`, which is TURN OCCUPANCY only: a mail');
172
+ L.push('wake delivered by steering runs a whole turn with readiness pinned at `idle`. Read the');
173
+ L.push('`activity:` line beside it — `active` means the role is working — and never call a role');
174
+ L.push('idle or stalled from `readiness=idle` alone.');
175
+ L.push('');
171
176
  L.push('Then judge the console content: stuck on a prompt/menu/trust dialog → answer it directly');
172
177
  L.push('with `ours-fleet send <Name> "<text>"` (or `--key <K>` for raw keys); idle with work');
173
178
  L.push('assigned → nudge; actively working → do nothing, and do not mistake a long turn for a');
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.17.4",
3
- "buildId": "b94609824510",
4
- "commit": "094a6fcb714541b03404e0fb39605105e390510b",
2
+ "version": "0.17.6",
3
+ "buildId": "a2f7650e3a39",
4
+ "commit": "96df9112202bb3957d18d95bc4210c1cf49d867f",
5
5
  "dirty": false,
6
- "builtAt": "2026-08-14T21:36:46.539Z",
6
+ "builtAt": "2026-08-17T11:51:49.146Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
package/dist/cli.js CHANGED
@@ -25,8 +25,9 @@ import { stringify } from 'yaml';
25
25
  import { resolvedRolePlan } from './resolved-plan.js';
26
26
  import { creationBuildNote, formatProvenance, readProvenance } from './creation.js';
27
27
  import { doctor } from './doctor.js';
28
- import { allWarnings, analyzeFleetPermissions, formatNative } from './permissions.js';
28
+ import { allWarnings, analyzeFleetPermissions, effectivePermissionMode, formatNative, } from './permissions.js';
29
29
  import { AI_DOCS } from './docs.js';
30
+ import { classifyActivity, describeSessionState } from './session/activity.js';
30
31
  import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
31
32
  import { SessionControlError } from './session/types.js';
32
33
  import { readScheduledLoops, storedLoopHealth } from './loops/state.js';
@@ -363,8 +364,14 @@ program.command('ls').description('list running fleet sessions')
363
364
  continue;
364
365
  try {
365
366
  const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
366
- if (response.ok && response.result?.alive)
367
- acp.push(`${name}: acp`);
367
+ const result = response.result;
368
+ if (response.ok && result?.alive) {
369
+ // Activity, not readiness: an idle-readiness role may be running a
370
+ // steered wake turn (FLEET-002), so `ls` reports what was observed.
371
+ const observed = classifyActivity(result.activity);
372
+ acp.push(`${name}: acp${observed.state === 'active' ? ' (working)'
373
+ : observed.state === 'quiet' ? ' (no recent agent activity)' : ''}`);
374
+ }
368
375
  }
369
376
  catch { /* ignore stale sockets */ }
370
377
  }
@@ -523,8 +530,14 @@ program.command('status <name>').description('unit/agent state')
523
530
  if (stateDir) {
524
531
  try {
525
532
  const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
526
- if (response.ok)
533
+ if (response.ok) {
534
+ const snapshot = response.result;
527
535
  console.log(`session: ${JSON.stringify(response.result)}`);
536
+ // `readiness` is turn occupancy, never an activity claim: a steered
537
+ // wake turn runs to completion with readiness pinned at `idle`
538
+ // (FLEET-002). Say which question each field answers.
539
+ console.log(describeSessionState(snapshot.readiness, snapshot.activity));
540
+ }
528
541
  }
529
542
  catch {
530
543
  console.log('session: acp control unavailable');
@@ -1089,7 +1102,7 @@ cOpt(program.command('spawn [name]').description('spawn a new agent (permanent b
1089
1102
  .option('--coordinator <name>', 'announce target')
1090
1103
  .option('--model <id>', 'model id to launch on (e.g. claude-fable-5); default: launcher default')
1091
1104
  .option('--permission-mode <mode>', 'harness permission mode (Codex: untrusted|on-request|never; Claude: native values)')
1092
- .option('--approval <mode>', 'fleet permission mode: ask|auto|allow (deny is deprecated)')
1105
+ .option('--approval <mode>', 'fleet permission mode: ask|auto|allow (Codex ACP allow selects agent-full-access; deny is deprecated)')
1093
1106
  .option('--filesystem <mode>', 'common filesystem intent: read-only|workspace|unrestricted')
1094
1107
  .option('--unattended <mode>', 'permission behavior without a console: deny|wait')
1095
1108
  .option('--sandbox <mode>', 'Codex sandbox: read-only|workspace-write|danger-full-access')
@@ -1161,13 +1174,17 @@ cOpt(program.command('spawn [name]').description('spawn a new agent (permanent b
1161
1174
  console.log(`spawned ${result.lifetime} agent '${result.role}' through `
1162
1175
  + `${result.caller}'s fleet proxy (state: ${result.statePath})`);
1163
1176
  console.log(` ${result.harness}/${result.session}`
1164
- + `${result.model ? ` model=${result.model}` : ''}; `
1177
+ + `${result.model ? ` model=${result.model}` : ''}`
1178
+ + (result.permissionMode
1179
+ ? `; permission=${result.permissionMode.fleetMode} native=${result.permissionMode.nativeMode}`
1180
+ : '') + '; '
1165
1181
  + `monitor=${result.monitor.mode} interrupt=${result.monitor.interrupt}`);
1166
1182
  if (result.inherited.length)
1167
1183
  console.log(` inherited omitted defaults from ${result.caller}: ${result.inherited.join(', ')}`);
1168
1184
  console.log(`→ watch it: ours-fleet peek ${result.role} | attach: ours-fleet attach ${result.role}`);
1169
1185
  return;
1170
1186
  }
1187
+ const plannedPermissionMode = effectivePermissionMode(spawnDryRun(o).resolvedRole);
1171
1188
  if (o.temp) {
1172
1189
  const dir = await spawnTemp(o, binPath);
1173
1190
  console.log(`spawned temp agent '${roleName}' (state: ${dir}; gone on exit/reboot)`);
@@ -1185,6 +1202,8 @@ cOpt(program.command('spawn [name]').description('spawn a new agent (permanent b
1185
1202
  for (const line of formatProvenance(lastProvenance))
1186
1203
  console.log(line);
1187
1204
  }
1205
+ console.log(` permission=${plannedPermissionMode.fleetMode} `
1206
+ + `native=${plannedPermissionMode.nativeMode}`);
1188
1207
  console.log(`→ watch it: ours-fleet peek ${roleName} | attach: ours-fleet attach ${roleName}`);
1189
1208
  }
1190
1209
  catch (e) {
package/dist/docs.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Keep this concise enough to place directly in an agent context. Unlike
5
5
  * Commander's per-command help, this describes how the pieces compose.
6
6
  */
7
- export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\nours-fleet version [--json] # build identity, capabilities, every install on PATH\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\nThe CLI never writes the base file: `spawn` writes `~/fleet.d/Name.yaml`. The\nweb console does write it, as a whole document \u2014 its setup wizard and\nconfiguration editor may create, change or remove any top-level block, including\n`vars:`, `defaults:`, `roles:`, `watchdogs:` and `loops:`. Only the base\nfile may hold `defaults:`, `watchdogs:` and `loops:`; a fleet.d drop-in may\ndeclare `roles:` and nothing else. Unrecognised top-level keys are round-tripped\nuntouched. Console edits are applied as surgical splices against the file's exact\nbytes, so an unchanged save is byte-identical and lines outside the edit keep their\ncomments and spacing. One exception: changing the length of a block sequence\n(`watch:`, `oversee:`, `roles:`, `wake_sources:`) may replace that collection\nwholesale and drop inline comments written on its items; lines outside that\ncollection remain byte-preserved. Each save is revision-guarded, reviewed as a diff\nof the real file before anything is written, validated by the real loader, and\nbacked up next to the file first.\n\n## Build identity and install provenance\n\n`--version` prints a semver and nothing else, and a semver does NOT identify an\nartifact. Version bumps land in a release commit of their own, so every build cut\nbetween two releases carries the PREVIOUS version while already containing new\nbehaviour. One host ran two installs that both reported 0.16.0 \u2014 same version,\ndifferent build. One accepted `monitor.interrupt: after_tool`, the other\nrejected it as invalid. Their\n`dist/cli.js` were byte-identical \u2014 the divergence was in other modules.\n\nEvery build therefore stamps `dist/build-info.json` with a build id (first 12 hex\nof a sha256 over the rest of `dist/`), the commit it was cut from, and the\ncapability tokens the shipped code declares \u2014 for example\n`monitor.interrupt.after_tool`. Ask any executable what it is:\n\n```sh\nours-fleet version # ours-fleet 0.17.0+9f1c2a3b4d5e, capabilities, PATH installs\nours-fleet version --json # the same as machine-readable JSON, no environment values\n```\n\nRead a capability, never a version number, to decide whether a setting is\nsupported. When a build rejects a value it knows the name of, it says which\ncapability is missing and which build rejected it, because another install on the\nsame host may accept the identical file. `config` prints the build that resolved\nthe plan; `status <Name>` says so when the build reporting on a role is not the\none that created it (roles record their creating build in `creation.json`).\n\n`ours-fleet doctor` runs an `install` check that lists every `ours-fleet` on\nPATH plus the one executing, and FAILS when two installs share a semver but are\ndifferent builds, or when the running artifact is a DIFFERENT artifact from the\none PATH resolves to. A second prefix holding identical content is not a skew\nand is not reported. A PATH entry the shell would not execute \u2014 a directory, or\na file without its execute bit \u2014 is not counted as an install at all.\nInstalls built before this stamp existed report `+unknown`; they are compared by\nhashing their `dist/` instead, so two pre-provenance installs are still told\napart. To fix a flagged host, remove or update the stale install \u2014 do not rely on\nPATH order.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is IPv4-loopback-only by default. Both `localhost` and\n`127.0.0.1` are accepted locally. For an nginx/TLS reverse proxy, keep the\ndefault bind and declare the exact browser origin:\n\n`ours-fleet web install --public-origin https://fleet.example.com --password-file /secure/fleet-password`\n\nFleet reads the password file during setup and persists only a salted scrypt\nverifier. New browsers authenticate and retain rotating HttpOnly/SameSite\ntrusted-device credentials. If nginx already authenticates, the operator may\ndeliberately select `--no-password`; the CLI and browser warn that anyone\nreaching the origin can control the fleet. First setup requires an explicit\nchoice: `--password-file` or `--pairing` for protected access, or\n`--no-password` for intentional unprotected access.\n\nUse `--bind ADDRESS` only for an intentional direct listen. A non-loopback\nbind is rejected unless `--public-origin` is also present. Host/Origin checks\nuse the declaration and do not trust forwarded headers. Configure nginx to\nproxy HTTP and WebSocket upgrades to `127.0.0.1:49271` and terminate TLS;\nfleet accepts nginx's loopback upstream Host, so no Host rewrite is required.\nBrowser credentials add Secure for HTTPS, and `revoke-all` invalidates all\ntrusted devices. Role creation offers harness-scoped known-model choices\nwhile still accepting a typed model ID; blank explicitly uses the selected\nharness's own default.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] [Name | --role Name] \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|auto|allow \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes active state under `~/.ours-fleet/tmp` and starts an independent\ntransient supervisor (a collected systemd unit or submitted launchd job). It is\nnot enabled across reboot and does not die when the role that spawned it restarts.\nBoth lifetimes support `--session acp`. When a temporary role's bound identity\ncloses or its session ends, the supervisor, monitor and live roster entry retire\ntogether; state moves intact to `~/.ours-fleet/recovery/temporary` with a\ntermination record. Failed launches use the same archive rather than deleting\ntheir briefing, provenance, logs or partial supervisor metadata.\n\nNamed `down` and `rm` commands can target an exact state-backed temporary role\neven though it is absent from merged fleet YAML. The recorded transient unit/job\nis authoritative. Missing/incomplete ownership metadata is reconciled only from\nan exact `_run-temp <role>` process-table match: one match may be adopted, zero\nsettles as stopped, and ambiguity or an unreadable table fails closed. Launching\nrecords receive a bounded grace so a not-yet-registered transient unit cannot be\nmistaken for a stopped one. Stale recorded supervisors are reclaimed in bounded\nbatches by moving their state to the same recovery archive, never by blind deletion.\n\nTemporary-role identity bootstrap is capability-based. The generated briefing\nfirst tries to bind the exact assigned identity and preserves it when it already\nexists. If missing, it uses ours MCP `create_temporary_identity` when that tool\nis exposed, tying a newly-created identity to the connector session lifecycle;\nolder servers fall back to `create_identity`. Collisions and creation errors\nstop safely without force-adopting or deleting identity state. Permanent roles\nretain normal `create_identity` behavior.\n\nThe temporary supervisor treats its first positive identity observation as the\nlifecycle readiness gate: a cold harness may take as long as needed to read its\nbriefing and bind, without a fixed first-bind retirement timer. After readiness,\nonly sustained authoritative absence closes the role. Unreachable, malformed, or\nvalid-but-empty daemon indexes are ambiguous and reset closure debounce rather\nthan becoming cleanup authority.\n\nInside a managed ACP role, the same CLI automatically routes a real `spawn`\nthrough that role's authenticated supervisor control socket. `--role Name` is\naccepted as an alternative to the positional name, so a minimal delegated call\nis `ours-fleet spawn --role DeveloperX --temp`. The supervisor records the\ncalling role, performs creation, and only after success sends a structured\nspawn notice through the caller's owner channel when one is configured.\n\nOmitted harness, session, working directory, coordinator, neutral permissions,\nfleet monitor policy, and (when the harness is unchanged) model inherit from the\ncalling role. Explicit options always win. Selecting a different harness without\n`--model` leaves model selection to that harness/fleet defaults rather than\ncopying an incompatible caller model. This automatic proxy is a convenience and\nattribution mechanism, not an isolation boundary: an unrestricted role can still\ninvoke another binary path directly. Tmux roles and host/operator shells keep the\nordinary direct CLI behavior.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # false queues; true cancels; after_tool steers at an ACP tool boundary\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\nwatchdogs:\n nightwatch: # [A-Za-z0-9_-], must not collide with a role name\n coordinator: FleetCoordinator # required \u2014 where alerts go\n # everything below is optional\n enabled: true # default true; false = configured but never scheduled\n interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m\n watch: [Alice, CodexReviewer] # explicit lists are exact; omit for configured + live temp roles\n harness: claude-code # default: defaults.harness\n model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)\n session: acp # default: defaults.session\n identity: Watchdog-nightwatch # default: Watchdog-<name>\n timeout: 5m # default 5m; a run past this is killed and recorded as error\n keep_reports: 50 # default 50 reports retained per watchdog\n alert_cooldown: 60m # default 60m before the same finding alerts again\n prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract\n isolation: # optional; omitted means no OS sandbox, like an ordinary role\n backend: bubblewrap # when present, the ordinary role isolation schema applies\n network: broker\n fs: { read: [/opt/watch-data] }\n```\n\nA watchdog observes and reports; it never restarts, stops, spawns, or removes a\nrole, answers a pending permission, edits a workspace, or approves anything on\nthe owner's behalf. `watchdogs:` may appear only in the base config\n(`~/fleet.yaml` or `-c FILE`), not in `~/fleet.d/*.yaml` drop-ins.\nWatchdogs are not isolated by default. An explicit watchdog `isolation:` block\nuses the same policy schema as a role and is applied unchanged; declare every\nextra filesystem access required by a custom prompt there.\nWhen `watch:` is omitted, each run watches the configured roles plus temporary\nfleet roles that are live when the run starts. An explicit `watch:` list is\nnever augmented.\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|auto|allow`: portable permission policy. `deny` remains a\n deprecated, fail-closed compatibility alias for existing fleet files.\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `ask` maps to Codex `untrusted` and Claude `default`;\n`auto` maps to Codex `on-request` and Claude `acceptEdits`; and\n`approval: allow` maps to Codex `never` and Claude `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` becomes non-interactive. Legacy `deny` keeps\nits conservative Codex `on-request` / Claude `plan` translation. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nACP carries agent-advertised session mode IDs and `session/set_mode`, but those\nIDs are agent-specific and ACP defines no portable permission-policy capability.\nFleet therefore uses the ACP primitive where an adapter exposes a matching mode\nand otherwise performs the harness translation above. The bundled Codex ACP\nadapter couples approval and sandboxing in its advertised mode IDs, so fleet\nkeeps the selected sandbox preset and enforces the independently translated\napproval policy on the app-server turn request. For example, `allow` plus\n`workspace` is really `approval=never sandbox=workspace-write`; it is never\nwidened to `danger-full-access`. The live session reports both its effective\nnormalized mode and the ACP sandbox-preset ID.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. Set it to `after_tool` to preserve an active ACP tool (and any\npending permission), then steer the wake at the first tool-terminal boundary\nwithout cancellation. A hung boundary is bounded at 120 seconds and falls back\nto non-cancelling steering/queueing; adapters without authenticated tool events\nuse the same conservative fallback. Explicit human/control interrupts remain\nimmediate. The policy is content-blind because the supervisor cannot inspect\nencrypted message bodies. Message bodies are released only when the role calls\nthe ours `get_messages` tool.\n\nThe default is `false`. For a temporary role whose mission intentionally arrives\nafter its readiness announcement, set `mode: fleet` and `interrupt: true`\nexplicitly. The readiness announcement does not change the transport: the\nmission remains ordinary ours mail, fleet injects only the body-free wake, and\nthe role calls `get_messages` before acting. Every later configured wake uses\nthe same interruption policy.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Trusted owner channel\n\nAn ACP role may declare a separate, existing ours identity which fleet \u2014 never\nthe agent \u2014 binds:\n\n```yaml\nowner_channel:\n identity: Coordinator Owner Channel\n owners: [authenticated-owner-contact-cid]\n agent: authenticated-managed-agent-cid\n interrupt: false\n progress_interval_ms: 30000\n comments: true\n attachments:\n enabled: true\n max_files_per_request: 4\n max_file_bytes: 10485760\n max_request_bytes: 20971520\n retention_ms: 86400000\n allowed_mime: [application/pdf, text/plain, image/png, audio/ogg]\n```\n\nThis does not replace the role identity. Normal identity mail remains untrusted\npeer input: the agent reads it through `get_messages` and replies through\n`send_message`. Mail arriving on the dedicated channel from a CID in `owners`\nis injected as a direct `[fleet-owner]` prompt. Mail from the exact `agent`\nCID is forwarded as a new message to the latest authenticated owner conversation;\nits files may also be relayed through this channel. A reply reference selects the\nowner of that authenticated source wire instead of the latest conversation.\nEvery other CID is rejected and warned about without reflecting its body. Fleet sends\naccepted/queued/progress/interrupted/failure notices and routes the ACP turn's\nfinal assistant text back to the authenticated sender with its source wire ID.\nFor file replies, fleet injects a request-specific outbox path into the owner\nprompt. The agent copies completed artifacts there; fleet sends every regular\nfile from the channel identity with the same source wire ID and removes the\ntemporary outbox only after successful delivery. For proactive or in-turn agent\nattachments, the agent calls ours `send_file` to the channel identity and may\npair it with a reply-linked caption; fleet, not the agent, chooses the owner.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/comments [status|on|off]`, `/interrupt`, `/clear`,\n`/compact`, `/model <model-id>`, `/restart`, `/force-restart`, `/ls`,\n`/peek`, `/worklog`, and\n`/version`. Unknown or malformed commands answer with the help text instead of\nbeing forwarded; plain messages reach the agent unchanged. `/clear`,\n`/compact`, and `/model` are forwarded only when the role's bundled ACP\nadapter executes them locally (claude-code: all three; codex: `/compact`\nonly) and are otherwise refused with a notice, so slash text never reaches the\nmodel as a prompt.\n\nWhile a request runs, the agent's live ACP commentary is relayed as messages\nprefixed with the single stable label `\uD83D\uDFE1 Live update:`, so an owner can see\nexactly which messages the setting controls. `owner_channel.comments`\n(default `true`, so existing channels keep their current behavior) is the\nRESTART BASELINE; `/comments on|off` changes only the running session and is\ndeliberately not persisted, so a restart always returns to the checked-in\nconfiguration. `/comments status` reports the live value, the baseline, and\nwhether the backend emits live comments at all. Suppressing live comments never\nsuppresses receipts, progress notices, or the final answer.\n\nOwner documents, images, and voice messages use the same authenticated sender\nand source-wire boundary. Fleet inspects body-free metadata first and rejects\ndisabled, over-count, over-size, or disallowed-MIME requests before selective\nretrieval. Unauthorized CIDs are never retrieved or answered. Reply-linked text\nand files from the same sender become one ordered request; a file-only wake also\nstarts a turn. Retrieved bytes must match their structured size and SHA-256,\ntheir content signature must match the declared MIME, and symlinks or non-regular\npaths fail closed. Sanitized copies live only in a mode-0700 request directory as\nmode-0600 files and are removed after completion or bounded stale retention.\n\nVoice prompts include a bounded transcript only when ours-mcp reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\nfallback. Run `ours-mcp voice-status --json` to inspect the host configuration.\nA mode-0600 crash journal contains only authenticated CID and wire routing data;\nit never stores captions, filenames, paths, transcript text, or bytes. Journaled\npost-retrieval files resume selectively through `save_file`. A deferred agent\ncaption is replayed with its processed files before the group is admitted. Fleet\nresolves one authenticated owner route before retrieving bytes, admits every file\nbefore emitting the caption or any file, and sends every part to that same route.\nUnknown correlated routes remain queued without retrieval and receive one bounded\ncorrelated notice. Admission rejection consumes the whole group with one NACK;\nonce emission starts, a transport error becomes terminal uncertain delivery and\nthe group is never blind-retried. Bounded v2 source-wire routing state is migrated\nfrom v1 on read. Corrupt state disables attachment admission rather than weakening\nprovenance checks.\n\nThe channel identity must be unique and must not be a role identity. The bridge\npersists bounded wire IDs only, never message/reply plaintext, and requeues input\nbefore starting its turn for at-least-once crash recovery. It currently requires\n`session: acp`: tmux has no structured, turn-correlated final answer, and pane\nscraping cannot provide the same reliable reply guarantee.\n\n### Live contact and owner administration\n\nThe supervisor which is already running the ACP role remains the sole binder of\n`owner_channel.identity`. The CLI reaches that exact live `OwnerChannel`\nthrough the role's token-authenticated, mode-0600 Unix control socket for contact\ninspection and setup; it never starts another ours client and never force-binds:\n\nRapid supervised restart is serialized by a role-scoped single-binder lease.\nThe predecessor closes its authenticated control socket and MCP proxy before\nreleasing ownership. The replacement waits at most five seconds and retries the\ndaemon bind only when PID/start-marker metadata proves the holder was the same\nrole and owner-channel identity. Foreign, live, corrupt, or otherwise\nunverifiable ownership remains fail-closed; fleet never uses `force=true`.\n\nIf that matching predecessor misses the bound, its still-authenticated control\nroute may send one fixed, digest-deduplicated recovery notice through the latest\nauthenticated owner conversation (or the sole configured owner). Notice\nplaintext is never persisted. With no safe deterministic route fleet guesses no\nrecipient and leaves the actionable failure in the web console and role logs.\nThe remote recovery action is `/restart`; inspect repeated failures with\n`ours-fleet logs <Role>` or the web console.\n\n```sh\nours-fleet owner-channel contact list <Role>\nours-fleet owner-channel contact invite <Role> [--name <label>]\nours-fleet owner-channel contact add <Role> (--invite-file <path> | --invite-stdin) [--name <label>]\nours-fleet owner-channel owner list <Role>\nours-fleet owner-channel owner authorize <Role> <exact-64-hex-contact-cid>\nours-fleet owner-channel owner revoke <Role> <exact-64-hex-contact-cid>\n```\n\nContact establishment and owner authorization are separate security steps.\n`contact add` never authorizes: invite redemption is pending until the peer\nverifies it. Once `contact list` reports the established contact, authorize\nits exact immutable CID explicitly. Invite creation emits invite material only\non stdout; acceptance reads it from a file or stdin, not argv.\n\nConfigured `owners` remain the baseline. On legacy channels without `agent`,\nlive authorizations/revocations are an immediately effective, restart-persistent\noverlay. Managed-agent CID gating makes fleet configuration authoritative and\ndisables live owner mutation and direct control-socket sends. `owner list` labels\nbaseline versus dynamic entries and effective status. The atomic mode-0600 file\ncontains bounded CIDs and audit actions only. Corruption disables all effective\nowners and refuses mutation rather than resurrecting authority; revoking the\nlast effective owner is always refused.\n\nA missing/stopped role, tmux session, role without `owner_channel`, unavailable\nMCP client, or a role entering shutdown returns an actionable error with no\nside effects. Management uses no network listener and never logs or persists\ninvite material.\n\nFor any non-final message\u2014progress, blocker, suggestion, or later proactive note\u2014\nthe managed agent calls ordinary ours `send_message` to the channel identity.\nFleet checks only that the authenticated sender CID exactly equals `agent`, then\nforwards the text as a new message. There is no task/request/update type, phase,\nreply correlation, or owner recipient argument. A sole owner is the safe fallback;\nwith multiple owners and no inbound route history the relay fails closed. Devices\nsharing one identity share its CID; separate owner identities hand off the route\nwhen either sends channel mail. The ACP final is separate: fleet extracts it from\nthe completed turn and deterministically replies to the initiating owner wire.\n\nThe bounded mode-0600 route state stores CIDs, wire IDs, timestamps, delivery state,\nand hashes but never message plaintext. Unauthorized attempts produce a bounded\nCID-only owner warning; attempted bodies are neither reflected nor persisted.\n\nFor a mobile owner, establish the contact first, wait for peer verification,\nauthorize its exact CID, and revoke that same CID when access ends. The bounded\nmode-0600 CID overlay survives supervisor restart and remains fail-closed on\ncorruption. Update bodies remain memory-only. After a crash/restart, unfinished\ndeferred owner input follows the existing at-least-once replay path; the restarted\nsupervisor remains the sole binder.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
7
+ export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\nours-fleet version [--json] # build identity, capabilities, every install on PATH\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\nThe CLI never writes the base file: `spawn` writes `~/fleet.d/Name.yaml`. The\nweb console does write it, as a whole document \u2014 its setup wizard and\nconfiguration editor may create, change or remove any top-level block, including\n`vars:`, `defaults:`, `roles:`, `watchdogs:` and `loops:`. Only the base\nfile may hold `defaults:`, `watchdogs:` and `loops:`; a fleet.d drop-in may\ndeclare `roles:` and nothing else. Unrecognised top-level keys are round-tripped\nuntouched. Console edits are applied as surgical splices against the file's exact\nbytes, so an unchanged save is byte-identical and lines outside the edit keep their\ncomments and spacing. One exception: changing the length of a block sequence\n(`watch:`, `oversee:`, `roles:`, `wake_sources:`) may replace that collection\nwholesale and drop inline comments written on its items; lines outside that\ncollection remain byte-preserved. Each save is revision-guarded, reviewed as a diff\nof the real file before anything is written, validated by the real loader, and\nbacked up next to the file first.\n\n## Build identity and install provenance\n\n`--version` prints a semver and nothing else, and a semver does NOT identify an\nartifact. Version bumps land in a release commit of their own, so every build cut\nbetween two releases carries the PREVIOUS version while already containing new\nbehaviour. One host ran two installs that both reported 0.16.0 \u2014 same version,\ndifferent build. One accepted `monitor.interrupt: after_tool`, the other\nrejected it as invalid. Their\n`dist/cli.js` were byte-identical \u2014 the divergence was in other modules.\n\nEvery build therefore stamps `dist/build-info.json` with a build id (first 12 hex\nof a sha256 over the rest of `dist/`), the commit it was cut from, and the\ncapability tokens the shipped code declares \u2014 for example\n`monitor.interrupt.after_tool`. Ask any executable what it is:\n\n```sh\nours-fleet version # ours-fleet 0.17.0+9f1c2a3b4d5e, capabilities, PATH installs\nours-fleet version --json # the same as machine-readable JSON, no environment values\n```\n\nRead a capability, never a version number, to decide whether a setting is\nsupported. When a build rejects a value it knows the name of, it says which\ncapability is missing and which build rejected it, because another install on the\nsame host may accept the identical file. `config` prints the build that resolved\nthe plan; `status <Name>` says so when the build reporting on a role is not the\none that created it (roles record their creating build in `creation.json`).\n\n`ours-fleet doctor` runs an `install` check that lists every `ours-fleet` on\nPATH plus the one executing, and FAILS when two installs share a semver but are\ndifferent builds, or when the running artifact is a DIFFERENT artifact from the\none PATH resolves to. A second prefix holding identical content is not a skew\nand is not reported. A PATH entry the shell would not execute \u2014 a directory, or\na file without its execute bit \u2014 is not counted as an install at all.\nInstalls built before this stamp existed report `+unknown`; they are compared by\nhashing their `dist/` instead, so two pre-provenance installs are still told\napart. To fix a flagged host, remove or update the stale install \u2014 do not rely on\nPATH order.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is IPv4-loopback-only by default. Both `localhost` and\n`127.0.0.1` are accepted locally. For an nginx/TLS reverse proxy, keep the\ndefault bind and declare the exact browser origin:\n\n`ours-fleet web install --public-origin https://fleet.example.com --password-file /secure/fleet-password`\n\nFleet reads the password file during setup and persists only a salted scrypt\nverifier. New browsers authenticate and retain rotating HttpOnly/SameSite\ntrusted-device credentials. If nginx already authenticates, the operator may\ndeliberately select `--no-password`; the CLI and browser warn that anyone\nreaching the origin can control the fleet. First setup requires an explicit\nchoice: `--password-file` or `--pairing` for protected access, or\n`--no-password` for intentional unprotected access.\n\nUse `--bind ADDRESS` only for an intentional direct listen. A non-loopback\nbind is rejected unless `--public-origin` is also present. Host/Origin checks\nuse the declaration and do not trust forwarded headers. Configure nginx to\nproxy HTTP and WebSocket upgrades to `127.0.0.1:49271` and terminate TLS;\nfleet accepts nginx's loopback upstream Host, so no Host rewrite is required.\nBrowser credentials add Secure for HTTPS, and `revoke-all` invalidates all\ntrusted devices. Role creation offers harness-scoped known-model choices\nwhile still accepting a typed model ID; blank explicitly uses the selected\nharness's own default.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] [Name | --role Name] \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|auto|allow \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes active state under `~/.ours-fleet/tmp` and starts an independent\ntransient supervisor (a collected systemd unit or submitted launchd job). It is\nnot enabled across reboot and does not die when the role that spawned it restarts.\nBoth lifetimes support `--session acp`. When a temporary role's bound identity\ncloses or its session ends, the supervisor, monitor and live roster entry retire\ntogether; state moves intact to `~/.ours-fleet/recovery/temporary` with a\ntermination record. Failed launches use the same archive rather than deleting\ntheir briefing, provenance, logs or partial supervisor metadata.\n\nNamed `down` and `rm` commands can target an exact state-backed temporary role\neven though it is absent from merged fleet YAML. The recorded transient unit/job\nis authoritative. Missing/incomplete ownership metadata is reconciled only from\nan exact `_run-temp <role>` process-table match: one match may be adopted, zero\nsettles as stopped, and ambiguity or an unreadable table fails closed. Launching\nrecords receive a bounded grace so a not-yet-registered transient unit cannot be\nmistaken for a stopped one. Stale recorded supervisors are reclaimed in bounded\nbatches by moving their state to the same recovery archive, never by blind deletion.\n\nTemporary-role identity bootstrap is capability-based. The generated briefing\nfirst tries to bind the exact assigned identity and preserves it when it already\nexists. If missing, it uses ours MCP `create_temporary_identity` when that tool\nis exposed, tying a newly-created identity to the connector session lifecycle;\nolder servers fall back to `create_identity`. Collisions and creation errors\nstop safely without force-adopting or deleting identity state. Permanent roles\nretain normal `create_identity` behavior.\n\nThe temporary supervisor treats its first positive identity observation as the\nlifecycle readiness gate: a cold harness may take as long as needed to read its\nbriefing and bind, without a fixed first-bind retirement timer. After readiness,\nonly sustained authoritative absence closes the role. Unreachable, malformed, or\nvalid-but-empty daemon indexes are ambiguous and reset closure debounce rather\nthan becoming cleanup authority.\n\nInside a managed ACP role, the same CLI automatically routes a real `spawn`\nthrough that role's authenticated supervisor control socket. `--role Name` is\naccepted as an alternative to the positional name, so a minimal delegated call\nis `ours-fleet spawn --role DeveloperX --temp`. The supervisor records the\ncalling role, performs creation, and only after success sends a structured\nspawn notice through the caller's owner channel when one is configured.\n\nOmitted harness, session, working directory, coordinator, neutral permissions,\nfleet monitor policy, and (when the harness is unchanged) model inherit from the\ncalling role. Explicit options always win. Selecting a different harness without\n`--model` leaves model selection to that harness/fleet defaults rather than\ncopying an incompatible caller model. This automatic proxy is a convenience and\nattribution mechanism, not an isolation boundary: an unrestricted role can still\ninvoke another binary path directly. Tmux roles and host/operator shells keep the\nordinary direct CLI behavior.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # false queues; true cancels; after_tool steers at an ACP tool boundary\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\nwatchdogs:\n nightwatch: # [A-Za-z0-9_-], must not collide with a role name\n coordinator: FleetCoordinator # required \u2014 where alerts go\n # everything below is optional\n enabled: true # default true; false = configured but never scheduled\n interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m\n watch: [Alice, CodexReviewer] # explicit lists are exact; omit for configured + live temp roles\n harness: claude-code # default: defaults.harness\n model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)\n session: acp # default: defaults.session\n identity: Watchdog-nightwatch # default: Watchdog-<name>\n timeout: 5m # default 5m; a run past this is killed and recorded as error\n keep_reports: 50 # default 50 reports retained per watchdog\n alert_cooldown: 60m # default 60m before the same finding alerts again\n prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract\n isolation: # optional; omitted means no OS sandbox, like an ordinary role\n backend: bubblewrap # when present, the ordinary role isolation schema applies\n network: broker\n fs: { read: [/opt/watch-data] }\n```\n\nA watchdog observes and reports; it never restarts, stops, spawns, or removes a\nrole, answers a pending permission, edits a workspace, or approves anything on\nthe owner's behalf. `watchdogs:` may appear only in the base config\n(`~/fleet.yaml` or `-c FILE`), not in `~/fleet.d/*.yaml` drop-ins.\nWatchdogs are not isolated by default. An explicit watchdog `isolation:` block\nuses the same policy schema as a role and is applied unchanged; declare every\nextra filesystem access required by a custom prompt there.\nWhen `watch:` is omitted, each run watches the configured roles plus temporary\nfleet roles that are live when the run starts. An explicit `watch:` list is\nnever augmented.\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\nSupervised roles connect to the operator-configured ours daemon; they do not own its\nlifecycle. Fleet forces `OURS_AUTOSTART=0` in tmux and ACP child processes after role\nenvironment overlays. Start the shared daemon only through an explicit operator or\ninstaller/setup flow.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|auto|allow`: portable permission policy. `deny` remains a\n deprecated, fail-closed compatibility alias for existing fleet files.\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `ask` maps to Codex `untrusted` and Claude `default`.\n`auto` selects Codex ACP `agent` (`on-request` + `workspace-write`) and\nClaude `acceptEdits`. `approval: allow` selects Codex ACP's fully\nnon-interactive yolo mode, reported as `agent-full-access` (`never` +\n`danger-full-access`), and Claude `bypassPermissions`. Codex tmux retains\nindependent approval and sandbox flags: `auto` is `on-request`, `allow`\nis `never`, and `filesystem` still selects the sandbox. These modes genuinely\npermit the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` becomes non-interactive. Legacy `deny` keeps\nits conservative Codex `on-request` / Claude `plan` translation. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nACP carries agent-advertised session mode IDs and `session/set_mode`, but those\nIDs are agent-specific and ACP defines no portable permission-policy capability.\nFleet therefore uses the ACP primitive where an adapter exposes a matching mode\nand otherwise performs the harness translation above. The bundled Codex ACP\nadapter couples approval and sandboxing in its advertised mode IDs. Neutral\n`allow` therefore selects `agent-full-access` and widens `filesystem:\nworkspace` or `read-only` to `danger-full-access`; neutral `auto` selects\n`agent` and `workspace-write` even when the neutral filesystem value differs.\nAn explicit `harness_options.sandbox` selects its corresponding ACP preset and\nstill wins, as does an explicit native approval override. `config` and\n`doctor` report a coupled-mode mismatch as approximate. Use per-role\n`isolation:` as the outer boundary for an `allow` ACP role. The live session\nreports both its effective normalized mode and the exact native mode selected.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. Set it to `after_tool` to preserve an active ACP tool (and any\npending permission), then steer the wake at the first tool-terminal boundary\nwithout cancellation. A hung boundary is bounded at 120 seconds and falls back\nto non-cancelling steering/queueing; adapters without authenticated tool events\nuse the same conservative fallback. Explicit human/control interrupts remain\nimmediate. The policy is content-blind because the supervisor cannot inspect\nencrypted message bodies. Message bodies are released only when the role calls\nthe ours `get_messages` tool.\n\nThe default is `false`. For a temporary role whose mission intentionally arrives\nafter its readiness announcement, set `mode: fleet` and `interrupt: true`\nexplicitly. The readiness announcement does not change the transport: the\nmission remains ordinary ours mail, fleet injects only the body-free wake, and\nthe role calls `get_messages` before acting. Every later configured wake uses\nthe same interruption policy.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Trusted owner channel\n\nAn ACP role may declare a separate, existing ours identity which fleet \u2014 never\nthe agent \u2014 binds:\n\n```yaml\nowner_channel:\n identity: Coordinator Owner Channel\n owners: [authenticated-owner-contact-cid]\n agent: authenticated-managed-agent-cid\n interrupt: false\n progress_interval_ms: 30000\n comments: true\n attachments:\n enabled: true\n max_files_per_request: 4\n max_file_bytes: 10485760\n max_request_bytes: 20971520\n retention_ms: 86400000\n allowed_mime: [application/pdf, text/plain, image/png, audio/ogg]\n```\n\nThis does not replace the role identity. Normal identity mail remains untrusted\npeer input: the agent reads it through `get_messages` and replies through\n`send_message`. Mail arriving on the dedicated channel from a CID in `owners`\nis injected as a direct `[fleet-owner]` prompt. Mail from the exact `agent`\nCID is forwarded as a new message to the latest authenticated owner conversation;\nits files may also be relayed through this channel. A reply reference selects the\nowner of that authenticated source wire instead of the latest conversation.\nEvery other CID is rejected and warned about without reflecting its body. Fleet sends\naccepted/queued/progress/interrupted/failure notices and routes the ACP turn's\nfinal assistant text back to the authenticated sender with its source wire ID.\nFor file replies, fleet injects a request-specific outbox path into the owner\nprompt. The agent copies completed artifacts there; fleet sends every regular\nfile from the channel identity with the same source wire ID and removes the\ntemporary outbox only after successful delivery. For proactive or in-turn agent\nattachments, the agent calls ours `send_file` to the channel identity and may\npair it with a reply-linked caption; fleet, not the agent, chooses the owner.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/comments [status|on|off]`, `/interrupt`, `/clear`,\n`/compact`, `/model <model-id>`, `/restart`, `/force-restart`, `/ls`,\n`/peek`, `/worklog`, and\n`/version`. Unknown or malformed commands answer with the help text instead of\nbeing forwarded; plain messages reach the agent unchanged. `/clear`,\n`/compact`, and `/model` are forwarded only when the role's bundled ACP\nadapter executes them locally (claude-code: all three; codex: `/compact`\nonly) and are otherwise refused with a notice, so slash text never reaches the\nmodel as a prompt.\n\nWhile a request runs, the agent's live ACP commentary is relayed as messages\nprefixed with the single stable label `\uD83D\uDFE1 Live update:`, so an owner can see\nexactly which messages the setting controls. `owner_channel.comments`\n(default `true`, so existing channels keep their current behavior) is the\nRESTART BASELINE; `/comments on|off` changes only the running session and is\ndeliberately not persisted, so a restart always returns to the checked-in\nconfiguration. `/comments status` reports the live value, the baseline, and\nwhether the backend emits live comments at all. Suppressing live comments never\nsuppresses receipts, progress notices, or the final answer.\n\nOwner documents, images, and voice messages use the same authenticated sender\nand source-wire boundary. Fleet inspects body-free metadata first and rejects\ndisabled, over-count, over-size, or disallowed-MIME requests before selective\nretrieval. Unauthorized CIDs are never retrieved or answered. Reply-linked text\nand files from the same sender become one ordered request; a file-only wake also\nstarts a turn. Retrieved bytes must match their structured size and SHA-256,\ntheir content signature must match the declared MIME, and symlinks or non-regular\npaths fail closed. Sanitized copies live only in a mode-0700 request directory as\nmode-0600 files and are removed after completion or bounded stale retention.\n\nVoice prompts include a bounded transcript only when ours-mcp reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\nfallback. Run `ours-mcp voice-status --json` to inspect the host configuration.\nA mode-0600 crash journal contains only authenticated CID and wire routing data;\nit never stores captions, filenames, paths, transcript text, or bytes. Journaled\npost-retrieval files resume selectively through `save_file`. A deferred agent\ncaption is replayed with its processed files before the group is admitted. Fleet\nresolves one authenticated owner route before retrieving bytes, admits every file\nbefore emitting the caption or any file, and sends every part to that same route.\nUnknown correlated routes remain queued without retrieval and receive one bounded\ncorrelated notice. Admission rejection consumes the whole group with one NACK;\nonce emission starts, a transport error becomes terminal uncertain delivery and\nthe group is never blind-retried. Bounded v2 source-wire routing state is migrated\nfrom v1 on read. Corrupt state disables attachment admission rather than weakening\nprovenance checks.\n\nThe channel identity must be unique and must not be a role identity. The bridge\npersists bounded wire IDs only, never message/reply plaintext, and requeues input\nbefore starting its turn for at-least-once crash recovery. It currently requires\n`session: acp`: tmux has no structured, turn-correlated final answer, and pane\nscraping cannot provide the same reliable reply guarantee.\n\n### Live contact and owner administration\n\nThe supervisor which is already running the ACP role remains the sole binder of\n`owner_channel.identity`. The CLI reaches that exact live `OwnerChannel`\nthrough the role's token-authenticated, mode-0600 Unix control socket for contact\ninspection and setup; it never starts another ours client and never force-binds:\n\nRapid supervised restart is serialized by a role-scoped single-binder lease.\nThe predecessor closes its authenticated control socket and MCP proxy before\nreleasing ownership. The replacement waits at most five seconds and retries the\ndaemon bind only when PID/start-marker metadata proves the holder was the same\nrole and owner-channel identity. Foreign, live, corrupt, or otherwise\nunverifiable ownership remains fail-closed; fleet never uses `force=true`.\n\nIf that matching predecessor misses the bound, its still-authenticated control\nroute may send one fixed, digest-deduplicated recovery notice through the latest\nauthenticated owner conversation (or the sole configured owner). Notice\nplaintext is never persisted. With no safe deterministic route fleet guesses no\nrecipient and leaves the actionable failure in the web console and role logs.\nThe remote recovery action is `/restart`; inspect repeated failures with\n`ours-fleet logs <Role>` or the web console.\n\n```sh\nours-fleet owner-channel contact list <Role>\nours-fleet owner-channel contact invite <Role> [--name <label>]\nours-fleet owner-channel contact add <Role> (--invite-file <path> | --invite-stdin) [--name <label>]\nours-fleet owner-channel owner list <Role>\nours-fleet owner-channel owner authorize <Role> <exact-64-hex-contact-cid>\nours-fleet owner-channel owner revoke <Role> <exact-64-hex-contact-cid>\n```\n\nContact establishment and owner authorization are separate security steps.\n`contact add` never authorizes: invite redemption is pending until the peer\nverifies it. Once `contact list` reports the established contact, authorize\nits exact immutable CID explicitly. Invite creation emits invite material only\non stdout; acceptance reads it from a file or stdin, not argv.\n\nConfigured `owners` remain the baseline. On legacy channels without `agent`,\nlive authorizations/revocations are an immediately effective, restart-persistent\noverlay. Managed-agent CID gating makes fleet configuration authoritative and\ndisables live owner mutation and direct control-socket sends. `owner list` labels\nbaseline versus dynamic entries and effective status. The atomic mode-0600 file\ncontains bounded CIDs and audit actions only. Corruption disables all effective\nowners and refuses mutation rather than resurrecting authority; revoking the\nlast effective owner is always refused.\n\nA missing/stopped role, tmux session, role without `owner_channel`, unavailable\nMCP client, or a role entering shutdown returns an actionable error with no\nside effects. Management uses no network listener and never logs or persists\ninvite material.\n\nFor any non-final message\u2014progress, blocker, suggestion, or later proactive note\u2014\nthe managed agent calls ordinary ours `send_message` to the channel identity.\nFleet checks only that the authenticated sender CID exactly equals `agent`, then\nforwards the text as a new message. There is no task/request/update type, phase,\nreply correlation, or owner recipient argument. A sole owner is the safe fallback;\nwith multiple owners and no inbound route history the relay fails closed. Devices\nsharing one identity share its CID; separate owner identities hand off the route\nwhen either sends channel mail. The ACP final is separate: fleet extracts it from\nthe completed turn and deterministically replies to the initiating owner wire.\n\nThe bounded mode-0600 route state stores CIDs, wire IDs, timestamps, delivery state,\nand hashes but never message plaintext. Unauthorized attempts produce a bounded\nCID-only owner warning; attempted bodies are neither reflected nor persisted.\n\nFor a mobile owner, establish the contact first, wait for peer verification,\nauthorize its exact CID, and revoke that same CID when access ends. The bounded\nmode-0600 CID overlay survives supervisor restart and remains fail-closed on\ncorruption. Update bodies remain memory-only. After a crash/restart, unfinished\ndeferred owner input follows the existing at-least-once replay path; the restarted\nsupervisor remains the sole binder.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
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
@@ -314,6 +314,11 @@ Role values override defaults. \`\${name}\` substitutes entries from \`vars\`.
314
314
  Other role fields include \`max_tokens\`, \`autocompact_pct\`, and \`isolation\`.
315
315
  Use README.md for the complete isolation policy and resource-cap schema.
316
316
 
317
+ Supervised roles connect to the operator-configured ours daemon; they do not own its
318
+ lifecycle. Fleet forces \`OURS_AUTOSTART=0\` in tmux and ACP child processes after role
319
+ environment overlays. Start the shared daemon only through an explicit operator or
320
+ installer/setup flow.
321
+
317
322
  ## Permissions
318
323
 
319
324
  Prefer the harness-neutral \`permissions\` block:
@@ -399,10 +404,14 @@ permissions through its harness and check the result against a fixed floor:
399
404
  deny those requests with nobody to see it; with \`unattended: wait\` it warns,
400
405
  because a human can still attach and answer.
401
406
 
402
- Security meaning: \`ask\` maps to Codex \`untrusted\` and Claude \`default\`;
403
- \`auto\` maps to Codex \`on-request\` and Claude \`acceptEdits\`; and
404
- \`approval: allow\` maps to Codex \`never\` and Claude \`bypassPermissions\`,
405
- which genuinely permits the actions the role was authorized to take —
407
+ Security meaning: \`ask\` maps to Codex \`untrusted\` and Claude \`default\`.
408
+ \`auto\` selects Codex ACP \`agent\` (\`on-request\` + \`workspace-write\`) and
409
+ Claude \`acceptEdits\`. \`approval: allow\` selects Codex ACP's fully
410
+ non-interactive yolo mode, reported as \`agent-full-access\` (\`never\` +
411
+ \`danger-full-access\`), and Claude \`bypassPermissions\`. Codex tmux retains
412
+ independent approval and sandbox flags: \`auto\` is \`on-request\`, \`allow\`
413
+ is \`never\`, and \`filesystem\` still selects the sandbox. These modes genuinely
414
+ permit the actions the role was authorized to take —
406
415
  \`dontAsk\` only suppresses the prompt while still refusing the action. Nothing
407
416
  other than an explicit \`allow\` becomes non-interactive. Legacy \`deny\` keeps
408
417
  its conservative Codex \`on-request\` / Claude \`plan\` translation. \`allow\` is therefore a real grant and
@@ -413,12 +422,15 @@ ACP carries agent-advertised session mode IDs and \`session/set_mode\`, but thos
413
422
  IDs are agent-specific and ACP defines no portable permission-policy capability.
414
423
  Fleet therefore uses the ACP primitive where an adapter exposes a matching mode
415
424
  and otherwise performs the harness translation above. The bundled Codex ACP
416
- adapter couples approval and sandboxing in its advertised mode IDs, so fleet
417
- keeps the selected sandbox preset and enforces the independently translated
418
- approval policy on the app-server turn request. For example, \`allow\` plus
419
- \`workspace\` is really \`approval=never sandbox=workspace-write\`; it is never
420
- widened to \`danger-full-access\`. The live session reports both its effective
421
- normalized mode and the ACP sandbox-preset ID.
425
+ adapter couples approval and sandboxing in its advertised mode IDs. Neutral
426
+ \`allow\` therefore selects \`agent-full-access\` and widens \`filesystem:
427
+ workspace\` or \`read-only\` to \`danger-full-access\`; neutral \`auto\` selects
428
+ \`agent\` and \`workspace-write\` even when the neutral filesystem value differs.
429
+ An explicit \`harness_options.sandbox\` selects its corresponding ACP preset and
430
+ still wins, as does an explicit native approval override. \`config\` and
431
+ \`doctor\` report a coupled-mode mismatch as approximate. Use per-role
432
+ \`isolation:\` as the outer boundary for an \`allow\` ACP role. The live session
433
+ reports both its effective normalized mode and the exact native mode selected.
422
434
 
423
435
  See also: \`spawn --approval/--filesystem/--unattended\` set this intent at
424
436
  creation, and \`ours-fleet config\` prints each role's neutral settings, their
@@ -12,6 +12,11 @@ export interface ManagedFleetSpawnResult {
12
12
  session: 'tmux' | 'acp';
13
13
  model?: string;
14
14
  monitor: Pick<MonitorConfig, 'mode' | 'interrupt'>;
15
+ /** Adapter-resolved portable policy and exact native runtime mode. */
16
+ permissionMode?: {
17
+ fleetMode: 'ask' | 'auto' | 'allow';
18
+ nativeMode: string;
19
+ };
15
20
  inherited: string[];
16
21
  creationActionId: string;
17
22
  }
@@ -1,18 +1,23 @@
1
1
  import { createRequire } from 'node:module';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { dirname, resolve } from 'node:path';
2
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
4
  const require = createRequire(import.meta.url);
5
5
  export function resolveBundledAcpAgent(packageName, binName, fallbackCommand) {
6
6
  try {
7
7
  const manifestPath = require.resolve(`${packageName}/package.json`);
8
8
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
9
- const relative = typeof manifest.bin === 'string'
9
+ const declaredEntrypoint = typeof manifest.bin === 'string'
10
10
  ? manifest.bin
11
11
  : manifest.bin?.[binName];
12
- if (!relative)
12
+ if (!declaredEntrypoint)
13
13
  return { argv: [fallbackCommand], bundled: false };
14
- const entrypoint = resolve(dirname(manifestPath), relative);
15
- if (!existsSync(entrypoint))
14
+ const packageRoot = realpathSync(dirname(manifestPath));
15
+ const entrypoint = realpathSync(resolve(packageRoot, declaredEntrypoint));
16
+ const entrypointFromRoot = relative(packageRoot, entrypoint);
17
+ if (entrypointFromRoot === '..'
18
+ || entrypointFromRoot.startsWith(`..${sep}`)
19
+ || isAbsolute(entrypointFromRoot)
20
+ || !statSync(entrypoint).isFile())
16
21
  return { argv: [fallbackCommand], bundled: false };
17
22
  return {
18
23
  argv: [process.execPath, entrypoint], bundled: true, manifestPath,
@@ -1,10 +1,13 @@
1
1
  import { type Exec } from '../exec.js';
2
- import type { HarnessAdapter, UnattendedCapability } from './types.js';
2
+ import type { AcpLaunch, HarnessAdapter, UnattendedCapability } from './types.js';
3
+ import { type AcpAgentResolution } from './acp-agent.js';
3
4
  /**
4
5
  * What an unattended role can actually do under Codex's native settings.
5
6
  * `on-request` and `untrusted` stop to ask, and with no console attached that
6
7
  * request is refused rather than answered — so the role can only read.
7
8
  */
8
9
  export declare function codexCapabilities(approval: string, sandbox: string): UnattendedCapability[];
10
+ /** Bind launch argv and metadata provenance to one already-completed resolution. */
11
+ export declare function codexAcpLaunchForResolution(resolution: AcpAgentResolution): Pick<AcpLaunch, 'argv' | 'permissionMetadataSource'>;
9
12
  export declare function makeCodexAdapter(exec?: Exec): HarnessAdapter;
10
13
  export declare const codexAdapter: HarnessAdapter;
@@ -5,7 +5,7 @@ import { agentDir, home } from '../paths.js';
5
5
  import { realExec } from '../exec.js';
6
6
  import { registerAdapter } from './registry.js';
7
7
  import { harnessRuntimeDir } from '../isolation/policy.js';
8
- import { bundledAcpAgent, resolveBundledAcpAgent } from './acp-agent.js';
8
+ import { resolveBundledAcpAgent, } from './acp-agent.js';
9
9
  const OPTION_KEYS = [
10
10
  'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
11
11
  'monitor',
@@ -51,9 +51,7 @@ function sandboxMode(role) {
51
51
  throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
52
52
  return s;
53
53
  }
54
- /** codex-acp exposes the same sandbox postures as named ACP agent modes. */
55
- function acpAgentMode(role) {
56
- const sandbox = sandboxMode(role);
54
+ function modeForSandbox(sandbox) {
57
55
  if (sandbox === 'read-only')
58
56
  return 'read-only';
59
57
  if (sandbox === 'workspace-write')
@@ -62,6 +60,26 @@ function acpAgentMode(role) {
62
60
  return 'agent-full-access';
63
61
  return undefined;
64
62
  }
63
+ /**
64
+ * Resolve the coupled Codex ACP mode.
65
+ *
66
+ * The portable approval contract owns the default mode selection: `allow`
67
+ * means the adapter's fully non-interactive yolo preset and `auto` means its
68
+ * ordinary agent preset. This intentionally means that Codex ACP cannot retain
69
+ * an independent neutral filesystem posture for those two modes. An explicit
70
+ * native sandbox remains authoritative and selects its corresponding preset.
71
+ */
72
+ function acpAgentMode(role) {
73
+ const explicitSandbox = role.harness_options?.sandbox;
74
+ if (explicitSandbox != null)
75
+ return modeForSandbox(sandboxMode(role));
76
+ if (role.permissions?.approval === 'allow')
77
+ return 'agent-full-access';
78
+ if (role.permissions?.approval === 'auto')
79
+ return 'agent';
80
+ const sandbox = sandboxMode(role);
81
+ return modeForSandbox(sandbox);
82
+ }
65
83
  function acpModePermissions(mode) {
66
84
  if (mode === 'read-only')
67
85
  return { approval: 'on-request', sandbox: 'read-only' };
@@ -69,6 +87,10 @@ function acpModePermissions(mode) {
69
87
  return { approval: 'never', sandbox: 'danger-full-access' };
70
88
  return { approval: 'on-request', sandbox: 'workspace-write' };
71
89
  }
90
+ /** The sandbox Codex will actually receive from the selected coupled ACP mode. */
91
+ function acpRuntimeSandbox(role) {
92
+ return acpModePermissions(acpAgentMode(role)).sandbox;
93
+ }
72
94
  function fleetModeForApproval(nativeMode) {
73
95
  if (nativeMode === 'never')
74
96
  return 'allow';
@@ -102,6 +124,18 @@ function launcherMode(role) {
102
124
  function bundledCodexAcp() {
103
125
  return resolveBundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
104
126
  }
127
+ /** Bind launch argv and metadata provenance to one already-completed resolution. */
128
+ export function codexAcpLaunchForResolution(resolution) {
129
+ const permissionMetadataSource = resolution.bundled
130
+ && resolution.version === BUNDLED_CODEX_ACP_VERSION
131
+ && resolution.manifestPath !== undefined
132
+ ? 'codex-acp'
133
+ : undefined;
134
+ return {
135
+ argv: [...resolution.argv],
136
+ ...(permissionMetadataSource ? { permissionMetadataSource } : {}),
137
+ };
138
+ }
105
139
  function canOverrideBundledAcpApproval() {
106
140
  const resolution = bundledCodexAcp();
107
141
  return resolution.bundled && resolution.version === BUNDLED_CODEX_ACP_VERSION
@@ -147,7 +181,7 @@ function codexAcpEnvironment(role, dirs) {
147
181
  return {
148
182
  CODEX_PATH: command,
149
183
  [CODEX_PROXY_APPROVAL_ENV]: approvalPolicy(role) ?? 'on-request',
150
- [CODEX_PROXY_SANDBOX_ENV]: sandboxMode(role) ?? 'workspace-write',
184
+ [CODEX_PROXY_SANDBOX_ENV]: acpRuntimeSandbox(role),
151
185
  [CODEX_PROXY_MANIFEST_ENV]: resolution.manifestPath,
152
186
  ...(process.env.CODEX_PATH ? { [CODEX_PROXY_REAL_PATH_ENV]: process.env.CODEX_PATH } : {}),
153
187
  };
@@ -299,17 +333,30 @@ export function makeCodexAdapter(exec = realExec) {
299
333
  },
300
334
  buildAcpLaunch(role, prep) {
301
335
  const configured = role.session_options?.acp?.command;
336
+ // Resolve once: both argv and permission-metadata provenance must describe
337
+ // the same artifact. A bare PATH fallback is launchable for compatibility,
338
+ // but is never authenticated for protected-MCP auto-approval.
339
+ const resolved = configured == null
340
+ ? codexAcpLaunchForResolution(bundledCodexAcp())
341
+ : undefined;
302
342
  const argv = Array.isArray(configured)
303
343
  ? [...configured]
304
344
  : typeof configured === 'string'
305
345
  ? ['sh', '-c', configured]
306
- : bundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
346
+ : resolved.argv;
307
347
  const initialMode = acpAgentMode(role);
308
348
  return {
309
349
  argv,
310
350
  env: initialMode ? { ...prep.env, INITIAL_AGENT_MODE: initialMode } : prep.env,
351
+ ...(resolved?.permissionMetadataSource
352
+ ? { permissionMetadataSource: resolved.permissionMetadataSource } : {}),
311
353
  };
312
354
  },
355
+ // INITIAL_AGENT_MODE covers session/new in codex-acp; session/set_mode
356
+ // keeps resumed/loaded sessions and live status on the identical mode.
357
+ acpPermissionModeId(role) {
358
+ return acpAgentMode(role);
359
+ },
313
360
  isolationPaths(role, _dirs) {
314
361
  const codexHome = join(home(), '.codex');
315
362
  const profile = role.harness_options?.profile;
@@ -362,7 +409,9 @@ export function makeCodexAdapter(exec = realExec) {
362
409
  const mode = acpAgentMode(role) ?? 'agent';
363
410
  const configured = role.session_options?.acp?.command;
364
411
  const overrideAvailable = configured == null && canOverrideBundledAcpApproval();
365
- const actual = overrideAvailable ? { approval, sandbox } : acpModePermissions(mode);
412
+ const actual = overrideAvailable
413
+ ? { approval, sandbox: acpRuntimeSandbox(role) }
414
+ : acpModePermissions(mode);
366
415
  const exact = actual.approval === approval && actual.sandbox === sandbox;
367
416
  return {
368
417
  ...translated,
@@ -372,10 +421,9 @@ export function makeCodexAdapter(exec = realExec) {
372
421
  ? `custom ACP command cannot be verified against approval=${approval} sandbox=${sandbox}; `
373
422
  + `its '${mode}' mode is conservatively treated as approval=${actual.approval} `
374
423
  + `sandbox=${actual.sandbox}`
375
- : `codex-acp mode '${mode}' actually uses approval=${actual.approval} `
376
- + `sandbox=${actual.sandbox}, and the bundled ${BUNDLED_CODEX_ACP_VERSION} `
377
- + `app-server override is unavailable; this does not exactly represent `
378
- + `approval=${approval} sandbox=${sandbox}`],
424
+ : `Codex ACP mode '${mode}' couples approval and filesystem as `
425
+ + `approval=${actual.approval} sandbox=${actual.sandbox}; this does not exactly `
426
+ + `represent approval=${approval} sandbox=${sandbox}`],
379
427
  capabilities: codexCapabilities(actual.approval, actual.sandbox),
380
428
  };
381
429
  }
@@ -26,9 +26,9 @@ export interface Launch {
26
26
  argv: string[];
27
27
  env: Record<string, string>;
28
28
  }
29
- export interface AcpLaunch {
30
- argv: string[];
31
- env: Record<string, string>;
29
+ export interface AcpLaunch extends Launch {
30
+ /** Metadata vocabulary authenticated by the exact ACP artifact in argv. */
31
+ permissionMetadataSource?: 'codex-acp';
32
32
  }
33
33
  /**
34
34
  * The result of expressing neutral `permissions:` in a harness's own terms.
@@ -201,10 +201,13 @@ export class OwnerChannel {
201
201
  ? ' with interruption'
202
202
  : event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
203
203
  const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
204
+ const permission = event.permissionMode
205
+ ? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
206
+ : '';
204
207
  const inherited = event.inherited.length
205
208
  ? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
206
209
  await this.sendProactiveMessage(`🧑‍💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
207
- + `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
210
+ + `(${event.harness}/${event.session}${model}; ${monitor}${permission}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
208
211
  });
209
212
  this.managementTail = run.then(() => undefined, () => undefined);
210
213
  return run;
package/dist/runner.js CHANGED
@@ -59,10 +59,15 @@ const defaultDeps = () => ({
59
59
  },
60
60
  });
61
61
  const MONITOR_OWNER_FILE = '.monitor-owner';
62
+ /** Fleet roles consume the operator-owned daemon; a role session never starts it. */
63
+ const FLEET_OURS_AUTOSTART = '0';
62
64
  /** Environment injected only into the managed harness process. */
63
65
  export function managedFleetProxyEnv(role, stateDir) {
64
66
  return {
65
67
  ...(role.env ?? {}),
68
+ // This must win over both inherited/configured auto-start. ACP agents run
69
+ // directly rather than through ours-codex, so the runner owns this fence.
70
+ OURS_AUTOSTART: FLEET_OURS_AUTOSTART,
66
71
  [FLEET_PROXY_STATE_DIR_ENV]: stateDir,
67
72
  [FLEET_PROXY_CALLER_ENV]: role.name,
68
73
  };
@@ -106,11 +111,14 @@ async function executeManagedSpawn(caller, configPath, requested, log) {
106
111
  session: preview.session,
107
112
  ...(preview.model ? { model: preview.model } : {}),
108
113
  monitor: { mode: preview.monitor.mode, interrupt: preview.monitor.interrupt },
114
+ permissionMode: effectivePermissionMode(preview),
109
115
  inherited,
110
116
  creationActionId,
111
117
  };
112
118
  log(`[${caller.name}] managed fleet proxy spawned ${result.lifetime} role ${result.role} `
113
- + `harness=${result.harness} session=${result.session}`);
119
+ + `harness=${result.harness} session=${result.session} `
120
+ + `permission=${result.permissionMode.fleetMode} `
121
+ + `native=${result.permissionMode.nativeMode}`);
114
122
  return result;
115
123
  }
116
124
  /**
@@ -140,6 +148,10 @@ export function recordMonitorOwner(dir, owner) {
140
148
  export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = launch.argv) {
141
149
  const env = {
142
150
  PATH: process.env.PATH ?? '', COLORTERM: 'truecolor', ...launch.env, ...(roleEnv ?? {}),
151
+ // Tmux roles have the same daemon-client boundary as ACP roles. Keep this
152
+ // last so neither harness preparation nor a role env block can take over
153
+ // the shared daemon lifecycle.
154
+ OURS_AUTOSTART: FLEET_OURS_AUTOSTART,
143
155
  };
144
156
  // Interactive panes should advertise colour even when the supervisor itself
145
157
  // was launched with NO_COLOR. A role may still deliberately opt back in to
@@ -530,6 +542,10 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
530
542
  permissions: perms,
531
543
  modeId: adapter.acpPermissionModeId?.(role),
532
544
  permissionMode: effectivePermissionMode(role),
545
+ // Provenance travels with the exact ACP launch. Keeping it out of a
546
+ // role-only adapter hook prevents a PATH fallback or resolver skew from
547
+ // claiming metadata trust for an argv it did not authenticate.
548
+ permissionMetadataSource: launch.permissionMetadataSource,
533
549
  log: deps.log,
534
550
  });
535
551
  pid = acpSession.pid;
@@ -977,8 +993,16 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
977
993
  continue;
978
994
  }
979
995
  const fastFailSecs = fastFailSecsFor(name, opts.configPath);
980
- const immediate = result.elapsedSecs < fastFailSecs;
981
- if (!immediate) {
996
+ // The fast-fail boundary starts a recovery episode; it must not also be
997
+ // the boundary that declares recovery successful. Otherwise alternating
998
+ // 19s and 20s deaths erase one another forever. Require the configured
999
+ // number of fast-fail windows to survive before closing an active streak.
1000
+ // This hysteresis stays adapter-relative (100s for the current 20s/5-attempt
1001
+ // policy) and still lets a genuinely sustained session reset the breaker.
1002
+ const stableRecoverySecs = fastFailSecs * RESTART_FAIL_THRESHOLD;
1003
+ const recoveryFailed = result.elapsedSecs < fastFailSecs
1004
+ || (ledger.consecutiveImmediateFailures > 0 && result.elapsedSecs < stableRecoverySecs);
1005
+ if (!recoveryFailed) {
982
1006
  // A session that ran for a while is not a restart loop, whatever ended it.
983
1007
  writeRestartLedger(dir, {
984
1008
  ...emptyLedger(),
@@ -20,6 +20,8 @@ export interface AcpSessionOptions {
20
20
  modeId?: string;
21
21
  /** Adapter-resolved live permission policy; separate from ACP agent-specific session modes. */
22
22
  permissionMode?: NonNullable<SessionSnapshot['permissionMode']>;
23
+ /** Adapter-authenticated request-metadata vocabulary; never inferred from ACP `_meta`. */
24
+ permissionMetadataSource?: 'codex-acp';
23
25
  log(line: string): void;
24
26
  /** Test seam for the cancel-escalation grace period; production uses the default. */
25
27
  cancelGraceMs?: number;
@@ -58,6 +60,12 @@ export declare class AcpSession implements SessionHandle {
58
60
  private connection;
59
61
  private sessionId?;
60
62
  private readiness;
63
+ /**
64
+ * Last non-replayed session update from the agent. `readiness` cannot answer
65
+ * "is this agent working" for a steered turn (FLEET-002), and this is the
66
+ * evidence that can.
67
+ */
68
+ private lastUpdateAt?;
61
69
  private lastError?;
62
70
  private promptTail;
63
71
  private queueDepth;
@@ -185,6 +193,15 @@ export declare class AcpSession implements SessionHandle {
185
193
  */
186
194
  private settleAutomatically;
187
195
  private withinAutomaticBoundary;
196
+ /**
197
+ * Codex ACP 1.1.7 marks its protected MCP elicitation bridge on a locationless
198
+ * execute request. The marker is meaningful only together with the runner's
199
+ * independently supplied, adapter-authenticated metadata vocabulary and effective
200
+ * mode: an arbitrary ACP process cannot gain this path by copying `_meta` alone.
201
+ * Exact option ids/kinds bind recognition to the protected-MCP shape and keep
202
+ * malformed requests on the ordinary fail-closed path.
203
+ */
204
+ private isEffectiveCodexProtectedMcpApproval;
188
205
  private recordUpdate;
189
206
  /**
190
207
  * Codex ACP's phase extension is the only currently supported visibility
@@ -202,6 +202,12 @@ export class AcpSession {
202
202
  connection;
203
203
  sessionId;
204
204
  readiness = 'starting';
205
+ /**
206
+ * Last non-replayed session update from the agent. `readiness` cannot answer
207
+ * "is this agent working" for a steered turn (FLEET-002), and this is the
208
+ * evidence that can.
209
+ */
210
+ lastUpdateAt;
205
211
  lastError;
206
212
  promptTail = Promise.resolve();
207
213
  queueDepth = 0;
@@ -358,6 +364,10 @@ export class AcpSession {
358
364
  runtimeModel: this.runtimeModel,
359
365
  reasoningEffort: this.reasoningEffort,
360
366
  permissionMode: this.options.permissionMode,
367
+ activity: {
368
+ activeToolCalls: this.activeToolCalls.size,
369
+ ...(this.lastUpdateAt ? { lastUpdateAt: this.lastUpdateAt } : {}),
370
+ },
361
371
  };
362
372
  }
363
373
  toolCall(toolCallId) {
@@ -1062,6 +1072,17 @@ export class AcpSession {
1062
1072
  // Permission is part of the tool lifecycle. Reserve before any policy or
1063
1073
  // human decision so a monitor wake cannot slip between request and answer.
1064
1074
  this.reservePermission(toolCallId, permissionId);
1075
+ if (this.isEffectiveCodexProtectedMcpApproval(params)) {
1076
+ // Protected MCP approval is already the tool's narrow gate. Never turn
1077
+ // this one decision into an adapter-wide standing grant.
1078
+ const option = choose(['allow_once']);
1079
+ const response = this.settleAutomatically(params, option, 'allowed', 'permissionMode.fleetMode=allow', 'the trusted Codex adapter authenticated a protected MCP approval request');
1080
+ if (option)
1081
+ this.allowPermission(toolCallId, permissionId);
1082
+ else
1083
+ this.releasePermission(toolCallId, permissionId);
1084
+ return Promise.resolve(response);
1085
+ }
1065
1086
  if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
1066
1087
  const option = choose(['allow_always', 'allow_once']);
1067
1088
  const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`);
@@ -1188,7 +1209,30 @@ export class AcpSession {
1188
1209
  const cwd = resolve(this.options.cwd);
1189
1210
  return canonicallyWithin(cwd, locations.map(location => resolve(location.path)));
1190
1211
  }
1212
+ /**
1213
+ * Codex ACP 1.1.7 marks its protected MCP elicitation bridge on a locationless
1214
+ * execute request. The marker is meaningful only together with the runner's
1215
+ * independently supplied, adapter-authenticated metadata vocabulary and effective
1216
+ * mode: an arbitrary ACP process cannot gain this path by copying `_meta` alone.
1217
+ * Exact option ids/kinds bind recognition to the protected-MCP shape and keep
1218
+ * malformed requests on the ordinary fail-closed path.
1219
+ */
1220
+ isEffectiveCodexProtectedMcpApproval(params) {
1221
+ const locations = params.toolCall.locations ?? [];
1222
+ return this.options.permissionMetadataSource === 'codex-acp'
1223
+ && this.options.permissionMode?.fleetMode === 'allow'
1224
+ && params.toolCall.kind === 'execute'
1225
+ && params.toolCall.status === 'pending'
1226
+ && locations.length === 0
1227
+ && params._meta?.is_mcp_tool_approval === true
1228
+ && params.options.some(option => option.optionId === 'allow_once' && option.kind === 'allow_once')
1229
+ && params.options.some(option => option.optionId === 'decline' && option.kind === 'reject_once');
1230
+ }
1191
1231
  recordUpdate(update) {
1232
+ // Replayed history is not current activity: `session/load` would otherwise
1233
+ // make a cold session look like it had just been working.
1234
+ if (!this.replaying)
1235
+ this.lastUpdateAt = new Date().toISOString();
1192
1236
  const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
1193
1237
  const messagePhase = update.sessionUpdate === 'agent_message_chunk'
1194
1238
  ? this.codexMessagePhase(update) : undefined;
@@ -0,0 +1,31 @@
1
+ import type { SessionActivity } from './types.js';
2
+ /**
3
+ * How long after the agent's last session update it still counts as working.
4
+ *
5
+ * FLEET-002: a wake delivered by ACP steering answers `startedNewTurn` and runs
6
+ * an entire turn that fleet never receives a `session/prompt` response for (ACP
7
+ * has no turn-end session update), so `readiness` stays `idle` throughout. Tool
8
+ * reservations and update recency are the only activity evidence fleet holds.
9
+ * The trade-off is deliberate and one-directional: at worst a role reads busy
10
+ * for one window after it genuinely stopped, instead of reading ready — or
11
+ * being classified stalled — while it is executing tools.
12
+ */
13
+ export declare const ACTIVITY_WINDOW_MS = 60000;
14
+ export type ActivityState = 'active' | 'quiet' | 'unobservable';
15
+ export interface ObservedActivity {
16
+ state: ActivityState;
17
+ activeToolCalls?: number;
18
+ lastUpdateAt?: string;
19
+ }
20
+ /**
21
+ * Classify agent-side activity. `unobservable` is NOT `quiet`: a backend that
22
+ * cannot see the agent (tmux) has no evidence, and no evidence must never be
23
+ * reported as "doing nothing".
24
+ */
25
+ export declare function classifyActivity(activity: SessionActivity | undefined, now?: number): ObservedActivity;
26
+ /**
27
+ * One operator-facing line that never lets turn occupancy pose as liveness:
28
+ * the readiness value is labelled as the turn field it is, and the activity
29
+ * verdict is stated separately with the evidence behind it.
30
+ */
31
+ export declare function describeSessionState(readiness: string | undefined, activity: SessionActivity | undefined, now?: number): string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * How long after the agent's last session update it still counts as working.
3
+ *
4
+ * FLEET-002: a wake delivered by ACP steering answers `startedNewTurn` and runs
5
+ * an entire turn that fleet never receives a `session/prompt` response for (ACP
6
+ * has no turn-end session update), so `readiness` stays `idle` throughout. Tool
7
+ * reservations and update recency are the only activity evidence fleet holds.
8
+ * The trade-off is deliberate and one-directional: at worst a role reads busy
9
+ * for one window after it genuinely stopped, instead of reading ready — or
10
+ * being classified stalled — while it is executing tools.
11
+ */
12
+ export const ACTIVITY_WINDOW_MS = 60_000;
13
+ /**
14
+ * Classify agent-side activity. `unobservable` is NOT `quiet`: a backend that
15
+ * cannot see the agent (tmux) has no evidence, and no evidence must never be
16
+ * reported as "doing nothing".
17
+ */
18
+ export function classifyActivity(activity, now = Date.now()) {
19
+ if (!activity)
20
+ return { state: 'unobservable' };
21
+ const lastUpdate = activity.lastUpdateAt ? Date.parse(activity.lastUpdateAt) : NaN;
22
+ const recent = Number.isFinite(lastUpdate) && now - lastUpdate <= ACTIVITY_WINDOW_MS;
23
+ return {
24
+ state: activity.activeToolCalls > 0 || recent ? 'active' : 'quiet',
25
+ activeToolCalls: activity.activeToolCalls,
26
+ ...(activity.lastUpdateAt ? { lastUpdateAt: activity.lastUpdateAt } : {}),
27
+ };
28
+ }
29
+ /**
30
+ * One operator-facing line that never lets turn occupancy pose as liveness:
31
+ * the readiness value is labelled as the turn field it is, and the activity
32
+ * verdict is stated separately with the evidence behind it.
33
+ */
34
+ export function describeSessionState(readiness, activity, now = Date.now()) {
35
+ const observed = classifyActivity(activity, now);
36
+ const evidence = [];
37
+ if (observed.activeToolCalls)
38
+ evidence.push(`${observed.activeToolCalls} tool calls in flight`);
39
+ if (observed.lastUpdateAt) {
40
+ const age = Math.max(0, Math.round((now - Date.parse(observed.lastUpdateAt)) / 1000));
41
+ if (Number.isFinite(age))
42
+ evidence.push(`last agent update ${age}s ago`);
43
+ }
44
+ const detail = observed.state === 'unobservable'
45
+ ? 'no agent-side evidence on this backend'
46
+ : evidence.join(', ') || 'no updates yet';
47
+ return `turn: ${readiness ?? 'unknown'} (turn occupancy only) | activity: ${observed.state} (${detail})`;
48
+ }
@@ -1,5 +1,16 @@
1
1
  import type { SessionBackendId } from '../config.js';
2
2
  import type { ConversationEventV1, ConversationSnapshot, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
3
+ /**
4
+ * TURN OCCUPANCY, and nothing else: `idle` means no fleet-tracked turn is in
5
+ * flight, which is exactly the question `arbiter.tryScheduled` asks before it
6
+ * admits a prompt. It is NOT a claim that the agent is doing nothing — a wake
7
+ * delivered through the `_session/steering` extension answers `startedNewTurn`
8
+ * and runs a whole turn that fleet never gets a `session/prompt` response for
9
+ * (ACP has no turn-end session update), so `readiness` stays `idle` for its
10
+ * entire duration. Anything reporting activity or liveness to a human must
11
+ * corroborate with `SessionSnapshot.activity` instead of reading `idle` here as
12
+ * "not working".
13
+ */
3
14
  export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
4
15
  export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
5
16
  export type TurnCancellationSource = 'owner' | 'local-console' | 'fleet-monitor' | 'scheduled-loop' | 'shutdown';
@@ -170,6 +181,19 @@ export interface SessionSnapshot {
170
181
  /** Exact harness-native approval/permission mode used by this runner. */
171
182
  nativeMode: string;
172
183
  };
184
+ /**
185
+ * Observed agent activity, independent of turn occupancy: the evidence a
186
+ * human-facing surface needs before calling a role idle. Absent on backends
187
+ * that cannot observe the agent at all (tmux), which is itself honest — no
188
+ * evidence is not evidence of inactivity.
189
+ */
190
+ activity?: SessionActivity;
191
+ }
192
+ export interface SessionActivity {
193
+ /** ACP tool calls currently reserved (lifecycle open or permission pending). */
194
+ activeToolCalls: number;
195
+ /** When the agent last sent ANY session update, replay excluded. */
196
+ lastUpdateAt?: string;
173
197
  }
174
198
  export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'monitor_delivery' | 'turn_stop' | 'error';
175
199
  /** What a settled permission request resolved to. */
@@ -80,6 +80,13 @@ export function generateWatchdogBriefing(opts) {
80
80
  L.push('- `healthy` — alive, on-briefing, recent progress.');
81
81
  L.push('- `idle` — alive, nothing assigned or nothing to do. Not an anomaly.');
82
82
  L.push('- `stale` = no worklog append and no console progress for ≥ 3 intervals.');
83
+ L.push('');
84
+ L.push('`session.readiness` from `ours-fleet status` is TURN OCCUPANCY, not activity: a mail');
85
+ L.push('wake delivered by ACP steering runs an entire turn while readiness stays `idle`. Never');
86
+ L.push('report `idle` or `stale` from `readiness=idle` alone — corroborate with the');
87
+ L.push('`activity:` line of the same `status` output (`active` means the agent is working),');
88
+ L.push('the worklog, or `ours-fleet peek`. `activity: unobservable` is missing evidence, not');
89
+ L.push('an idle agent.');
83
90
  L.push('- `blocked` = waiting on a permission/prompt/modal longer than one interval.');
84
91
  L.push('- `off_briefing` — activity contradicts the briefing (wrong repo, out-of-scope work,');
85
92
  L.push(' ignored routine).');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.17.4",
3
+ "version": "0.17.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",