@ours.network/fleet 0.10.0-nightly.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +138 -21
  2. package/dist/atomic-file.d.ts +30 -0
  3. package/dist/atomic-file.js +86 -0
  4. package/dist/briefing.d.ts +6 -0
  5. package/dist/briefing.js +43 -13
  6. package/dist/cli.js +98 -22
  7. package/dist/config.d.ts +24 -3
  8. package/dist/config.js +84 -11
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +28 -1
  12. package/dist/docs.js +155 -8
  13. package/dist/doctor.js +75 -17
  14. package/dist/harness/claude-code.d.ts +39 -3
  15. package/dist/harness/claude-code.js +128 -26
  16. package/dist/harness/codex.d.ts +7 -1
  17. package/dist/harness/codex.js +58 -11
  18. package/dist/harness/registry.d.ts +2 -0
  19. package/dist/harness/registry.js +19 -0
  20. package/dist/harness/types.d.ts +51 -4
  21. package/dist/isolation/bubblewrap.js +7 -1
  22. package/dist/isolation/policy.d.ts +34 -5
  23. package/dist/isolation/policy.js +114 -7
  24. package/dist/isolation/resources.d.ts +6 -3
  25. package/dist/isolation/resources.js +6 -3
  26. package/dist/isolation/types.d.ts +19 -1
  27. package/dist/monitor.d.ts +33 -4
  28. package/dist/monitor.js +150 -32
  29. package/dist/ops.d.ts +15 -2
  30. package/dist/ops.js +32 -9
  31. package/dist/permissions.d.ts +70 -0
  32. package/dist/permissions.js +97 -0
  33. package/dist/runner.d.ts +65 -2
  34. package/dist/runner.js +262 -27
  35. package/dist/session/acp.d.ts +25 -2
  36. package/dist/session/acp.js +143 -26
  37. package/dist/session/control.d.ts +49 -1
  38. package/dist/session/control.js +116 -12
  39. package/dist/session/tmux.d.ts +9 -2
  40. package/dist/session/tmux.js +36 -4
  41. package/dist/session/types.d.ts +99 -2
  42. package/dist/session/types.js +42 -1
  43. package/dist/spawn.d.ts +27 -2
  44. package/dist/spawn.js +153 -15
  45. package/dist/supervisor/launchd.d.ts +50 -0
  46. package/dist/supervisor/launchd.js +121 -4
  47. package/dist/supervisor/none.js +22 -4
  48. package/dist/supervisor/systemd.d.ts +8 -1
  49. package/dist/supervisor/systemd.js +94 -4
  50. package/dist/supervisor/types.d.ts +36 -3
  51. package/dist/tmux.d.ts +34 -2
  52. package/dist/tmux.js +48 -11
  53. package/package.json +1 -1
@@ -0,0 +1,254 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { replaceFileAtomically, withFileLock } from './atomic-file.js';
4
+ import { stateRoot } from './paths.js';
5
+ import { resolveEndpoint } from './monitor.js';
6
+ /**
7
+ * One creation transaction: role name and ours identity reserved together,
8
+ * every artifact journalled, and everything undone in reverse on failure.
9
+ *
10
+ * The problem this replaces is a check followed by a create. `assertNameFree()`
11
+ * read the config and the agent dirs, returned, and only then did the caller
12
+ * start writing — so two concurrent spawns of the same name both passed the
13
+ * check, both wrote, and the second silently overwrote the first's fleet.d file
14
+ * while inheriting its half-built state. Nothing was atomic and nothing was
15
+ * undone.
16
+ */
17
+ /** Where host-wide creation state lives. One directory, so it is easy to inspect. */
18
+ const creationRoot = () => join(stateRoot(), 'creation');
19
+ const creationLock = () => join(creationRoot(), '.lock');
20
+ const reservationsDir = () => join(creationRoot(), 'reservations');
21
+ /** A reservation is one file; its existence IS the claim. */
22
+ const reservationPath = (kind, name) => join(reservationsDir(), `${kind}-${encodeURIComponent(name)}`);
23
+ export class CreationConflictError extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'CreationConflictError';
27
+ }
28
+ }
29
+ /**
30
+ * Host-local identity reservation: atomic across every ours-fleet process on
31
+ * this host, because it is taken under the same host-wide creation lock as the
32
+ * role name.
33
+ *
34
+ * It is NOT atomic against other clients of the same ours daemon — another tool
35
+ * creating the identity between our reservation and our creation would still
36
+ * win. Closing that needs a reserve/commit/release operation in the daemon
37
+ * itself; see the release notes.
38
+ */
39
+ export const hostLocalIdentityRegistry = {
40
+ async reserve(name) {
41
+ const p = reservationPath('identity', name);
42
+ if (existsSync(p))
43
+ return false;
44
+ mkdirSync(reservationsDir(), { recursive: true });
45
+ writeFileSync(p, `${process.pid} ${new Date().toISOString()}\n`);
46
+ return true;
47
+ },
48
+ async release(name) {
49
+ rmSync(reservationPath('identity', name), { force: true });
50
+ },
51
+ };
52
+ /** Is this role name already reserved by an in-flight transaction? */
53
+ const roleReserved = (name) => existsSync(reservationPath('role', name));
54
+ /**
55
+ * Run `body` inside a creation transaction.
56
+ *
57
+ * Under one host-wide lock: both names are reserved, then `body` builds the
58
+ * role. If anything throws, every recorded stage is undone in reverse order and
59
+ * both reservations are released, so the names can be reused immediately. On
60
+ * success the reservations are released too — the role's own config and state
61
+ * are the durable record from then on.
62
+ *
63
+ * Rollback errors are collected and reported, never allowed to hide the failure
64
+ * that caused the rollback.
65
+ */
66
+ export async function withCreationTransaction(names, body, deps = {}) {
67
+ const log = deps.log ?? (() => { });
68
+ const registry = deps.identityRegistry ?? hostLocalIdentityRegistry;
69
+ mkdirSync(creationRoot(), { recursive: true });
70
+ const journal = [];
71
+ const tx = {
72
+ record: entry => { journal.push(entry); },
73
+ get stages() { return journal.map(e => e.stage); },
74
+ };
75
+ // Both names, one boundary. Reserving the role and then the identity without
76
+ // a shared lock would let two spawns each win one.
77
+ const held = await withFileLock(creationLock(), async () => {
78
+ if (roleReserved(names.role))
79
+ throw new CreationConflictError(`role '${names.role}' is being created by another process right now`);
80
+ if (!await registry.reserve(names.identity))
81
+ throw new CreationConflictError(`ours identity '${names.identity}' is already taken or being created right now`);
82
+ mkdirSync(reservationsDir(), { recursive: true });
83
+ writeFileSync(reservationPath('role', names.role), `${process.pid} ${new Date().toISOString()}\n`);
84
+ return true;
85
+ }, deps.lock);
86
+ if (!held)
87
+ throw new CreationConflictError('could not take the creation lock');
88
+ const releaseAll = async () => {
89
+ rmSync(reservationPath('role', names.role), { force: true });
90
+ await registry.release(names.identity).catch(() => undefined);
91
+ };
92
+ try {
93
+ const result = await body(tx);
94
+ await releaseAll();
95
+ return result;
96
+ }
97
+ catch (error) {
98
+ const rollbackFailures = [];
99
+ for (const entry of [...journal].reverse()) {
100
+ try {
101
+ await entry.undo();
102
+ }
103
+ catch (e) {
104
+ rollbackFailures.push(`${entry.stage}: ${e.message}`);
105
+ }
106
+ }
107
+ await releaseAll();
108
+ const original = error instanceof Error ? error : new Error(String(error));
109
+ if (rollbackFailures.length) {
110
+ log(`creation rollback incomplete: ${rollbackFailures.join('; ')}`);
111
+ original.message += ` (rollback also failed: ${rollbackFailures.join('; ')})`;
112
+ }
113
+ throw original;
114
+ }
115
+ }
116
+ /** Forget reservations left behind by a process that died mid-transaction. */
117
+ export function clearStaleReservations(olderThanMs = 60_000, now = Date.now()) {
118
+ const dir = reservationsDir();
119
+ if (!existsSync(dir))
120
+ return 0;
121
+ let cleared = 0;
122
+ for (const f of readdirSyncSafe(dir)) {
123
+ const p = join(dir, f);
124
+ try {
125
+ const stamp = Date.parse(readFileSync(p, 'utf8').trim().split(/\s+/)[1] ?? '');
126
+ if (Number.isFinite(stamp) && now - stamp > olderThanMs) {
127
+ rmSync(p, { force: true });
128
+ cleared++;
129
+ }
130
+ }
131
+ catch { /* unreadable: leave it for a human */ }
132
+ }
133
+ return cleared;
134
+ }
135
+ function readdirSyncSafe(dir) {
136
+ try {
137
+ return readdirSync(dir);
138
+ }
139
+ catch {
140
+ return [];
141
+ }
142
+ }
143
+ /**
144
+ * Establish the identity before the role's service is enabled.
145
+ *
146
+ * Returns what was actually GUARANTEED, so the generated briefing can say
147
+ * something true instead of asserting a "predefined" identity nobody checked —
148
+ * the failure a real agent hit on its first boot, having been told to bind an
149
+ * identity that did not exist.
150
+ */
151
+ export async function ensureIdentity(name, profile, provisioner, log = () => { }) {
152
+ if (!provisioner)
153
+ return { state: 'unverified', detail: 'no identity provisioner is configured' };
154
+ let present;
155
+ try {
156
+ present = await provisioner.exists(name);
157
+ }
158
+ catch (e) {
159
+ present = 'unknown';
160
+ log(`identity '${name}': could not be verified (${e.message})`);
161
+ }
162
+ if (present === true)
163
+ return { state: 'verified', detail: 'the ours daemon reports it exists' };
164
+ if (present === 'unknown')
165
+ return { state: 'unverified', detail: 'the ours daemon could not be asked' };
166
+ if (!provisioner.create) {
167
+ // Loud, and named. The briefing will tell the agent to mint it — which is
168
+ // what actually happens today — but nobody is told it was "predefined".
169
+ log(`identity '${name}' does not exist and this host cannot create one automatically — `
170
+ + `the role will be told to mint it on first boot. Create it in advance to avoid that.`);
171
+ return { state: 'unverified', detail: 'it does not exist and cannot be created here' };
172
+ }
173
+ await provisioner.create(name, profile);
174
+ return { state: 'created', detail: 'created during spawn, with its bio and persona published' };
175
+ }
176
+ /**
177
+ * Ask the running ours daemon whether an identity exists, over the same
178
+ * authenticated endpoint doctor already probes. Answers `unknown` rather than
179
+ * guessing when the daemon cannot be reached — an unreachable daemon is not
180
+ * evidence that the identity is missing.
181
+ *
182
+ * It deliberately has no `create()`: role identities are minted through the MCP
183
+ * `create_identity` tool, and inventing a daemon endpoint we cannot test is the
184
+ * failure mode this release exists to stop.
185
+ */
186
+ export function daemonIdentityProvisioner(env = process.env, fetchImpl = (u, i) => globalThis.fetch(u, i)) {
187
+ return {
188
+ async exists(name) {
189
+ const ep = resolveEndpoint(env);
190
+ const resp = await fetchImpl(`${ep.origin}/identities`, { headers: ep.headers });
191
+ if (!resp.ok)
192
+ return 'unknown';
193
+ const body = await resp.json();
194
+ if (!Array.isArray(body.identities))
195
+ return 'unknown';
196
+ return body.identities.some(i => (typeof i === 'string' ? i : i?.name) === name);
197
+ },
198
+ };
199
+ }
200
+ /** Atomically write a role's fleet.d file, journalling it for rollback. */
201
+ export function writeRoleFile(tx, file, contents) {
202
+ const existed = existsSync(file);
203
+ replaceFileAtomically(file, contents, 0o644);
204
+ tx.record({
205
+ stage: `fleet.d file ${file}`,
206
+ // Only remove what THIS transaction created; never delete a file the
207
+ // operator already had.
208
+ undo: () => { if (!existed)
209
+ rmSync(file, { force: true }); },
210
+ });
211
+ }
212
+ export const CREATION_PROVENANCE_FILE = 'creation.json';
213
+ /**
214
+ * Record HOW a role was created, so nobody has to remember.
215
+ *
216
+ * Six months on, "why does this role have `approval: allow`?" is unanswerable:
217
+ * the resolved config shows the value but not whether an operator typed it, a
218
+ * fleet default supplied it, or it fell through to a built-in. Those have very
219
+ * different implications for whether it is safe to change.
220
+ *
221
+ * Deliberately excluded: `env`, `bio`, `persona`, and `harness_options`. The
222
+ * first two can carry credentials, and this file exists to be read — it must
223
+ * never become a place secrets accumulate.
224
+ */
225
+ export function buildProvenance(o) {
226
+ return {
227
+ version: 1,
228
+ command: 'ours-fleet spawn',
229
+ fleetVersion: o.fleetVersion,
230
+ createdAt: (o.now ?? new Date()).toISOString(),
231
+ lifetime: o.lifetime,
232
+ role: o.role,
233
+ settings: o.settings,
234
+ };
235
+ }
236
+ /** Write the provenance record atomically, before the role is started. */
237
+ export function writeProvenance(stateDir, p) {
238
+ replaceFileAtomically(join(stateDir, CREATION_PROVENANCE_FILE), JSON.stringify(p, null, 2) + '\n', 0o600);
239
+ }
240
+ /** One concise line per non-built-in setting, for the post-creation summary. */
241
+ export function formatProvenance(p) {
242
+ const mark = { cli: 'explicit', 'fleet-default': 'fleet default', 'built-in': 'built-in' };
243
+ return Object.entries(p.settings)
244
+ .filter(([, e]) => e.value !== undefined)
245
+ .map(([k, e]) => ` ${k.padEnd(12)} ${String(e.value)} (${mark[e.source]})`);
246
+ }
247
+ /** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
248
+ export function provenanceOf(cliValue, fleetDefault, builtIn) {
249
+ if (cliValue !== undefined && cliValue !== null && cliValue !== '')
250
+ return { value: cliValue, source: 'cli' };
251
+ if (fleetDefault !== undefined && fleetDefault !== null)
252
+ return { value: fleetDefault, source: 'fleet-default' };
253
+ return { value: builtIn, source: 'built-in' };
254
+ }
package/dist/docs.d.ts CHANGED
@@ -4,4 +4,31 @@
4
4
  * Keep this concise enough to place directly in an agent context. Unlike
5
5
  * Commander's per-command help, this describes how the pieces compose.
6
6
  */
7
- export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\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## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and `--monitor`. Run `ours-fleet help spawn` for exact 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 enabled: true\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 enabled: true\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 }\n```\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\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\nThe supervisor monitor is enabled by default. It consumes body-free daemon\nevents and advances its durable cursor only after delivery is accepted. ACP uses\na structured `session/prompt`; tmux uses verified console injection. Message\nbodies are released only when the role calls the ours `get_messages` tool.\n\nSet `monitor.enabled: false` only to retain legacy in-session monitoring.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n";
7
+ export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\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## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\n```\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\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";
8
+ /**
9
+ * What every shipped spawn-skill variant must say, and must not say (7.1).
10
+ *
11
+ * The skills are separate markdown files in two published plugins, written for
12
+ * two different harnesses, so they cannot literally be one file. This is the
13
+ * source of truth they are all written from, and a test holds each variant to
14
+ * it — including the CLI reference above, so a skill and \`ours-fleet docs\`
15
+ * cannot name different permission settings.
16
+ *
17
+ * \`forbidden\` is the more important half. The old skills prescribed
18
+ * \`--approval ask --filesystem workspace --unattended deny\` as a blanket
19
+ * default while also telling the agent to stop at a failed doctor check — and
20
+ * that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
21
+ * unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
22
+ * fatal. Following the skill produced a role the CLI then refused.
23
+ */
24
+ export declare const SPAWN_SKILL_CONTRACT: {
25
+ /** Substrings every variant must contain (whitespace-normalised). */
26
+ readonly required: readonly ["ours-fleet docs", "ours-fleet doctor", "dontAsk", "bypassPermissions", "unattended capability floor", "unattended floor:", "--approval allow", "--unattended wait", "--isolation-file"];
27
+ /**
28
+ * Substrings no variant may contain. Deliberately short: the real guard is
29
+ * the acceptance test, which runs every spawn command a variant prints
30
+ * through the same analysis `doctor` uses and fails if doctor would fail it.
31
+ * This list only pins the specific claim that was wrong.
32
+ */
33
+ readonly forbidden: readonly ["--approval ask --filesystem workspace --unattended deny", "[TODO:"];
34
+ };
package/dist/docs.js CHANGED
@@ -64,7 +64,9 @@ role after exit/reboot. Both lifetimes support \`--session acp\`.
64
64
 
65
65
  Codex-specific spawn flags: \`--sandbox\`, \`--permission-mode\`, \`--launcher\`,
66
66
  \`--profile\`, \`--search\`, repeatable \`--codex-config key=value\`, repeatable
67
- \`--add-dir\`, and \`--monitor\`. Run \`ours-fleet help spawn\` for exact values.
67
+ \`--add-dir\`, and legacy \`--monitor\` (consent for the native Codex monitor,
68
+ not the \`monitor.mode\` wake-owner selector). Run \`ours-fleet help spawn\` for
69
+ exact values.
68
70
 
69
71
  ## fleet.yaml
70
72
 
@@ -81,7 +83,7 @@ defaults:
81
83
  filesystem: workspace
82
84
  unattended: deny
83
85
  monitor:
84
- enabled: true
86
+ mode: fleet # fleet (default) | native
85
87
  roles:
86
88
  Coordinator:
87
89
  harness: codex
@@ -100,7 +102,8 @@ roles:
100
102
  tmux:
101
103
  boot_grace_ms: 10000
102
104
  monitor:
103
- enabled: true
105
+ mode: fleet # fleet supervisor | native harness monitor
106
+ interrupt: false # true cancels active work before every configured wake
104
107
  wake_sources: [message_received, file_received, local_contact_request, pending_message]
105
108
  batch_ms: 2000
106
109
  inject: notification
@@ -141,6 +144,89 @@ The backend translates this common intent. Harness-native settings in
141
144
  \`allow\`/\`unrestricted\`, Codex \`never\`/\`danger-full-access\`, or Claude
142
145
  \`bypassPermissions\` without explicit authorization.
143
146
 
147
+ ### Creation-time isolation
148
+
149
+ \`ours-fleet spawn --isolation-file <path>\` supplies a role's sandbox policy at
150
+ creation, so the FIRST launch is already confined — a role that only gains
151
+ \`isolation:\` on a later \`up\` ran unsandboxed until then.
152
+
153
+ The file holds exactly the \`isolation:\` mapping documented above and nothing
154
+ else — the same schema, validated by the same code, so a policy written here
155
+ cannot mean something different from the identical block in fleet.yaml:
156
+
157
+ \`\`\`yaml
158
+ network: deny
159
+ fs:
160
+ read: [/opt/reference]
161
+ resources:
162
+ mem: 2G
163
+ \`\`\`
164
+
165
+ Invalid files are rejected before anything is created: no config, no state
166
+ directory, no identity reservation. Works for both permanent and \`--temp\` roles.
167
+
168
+ ### Never-prompt failure
169
+
170
+ The failure this section exists to prevent leaves no error message anywhere.
171
+
172
+ An unattended role has no console. When the harness needs a permission decision
173
+ there is nobody to ask, so the request is refused INSIDE the harness — no
174
+ prompt, no error, no log line. The agent simply does less than its briefing told
175
+ it to, reports success, and nothing distinguishes that from having done the
176
+ work. Two settings produce it:
177
+
178
+ 1. a permission mode that suppresses the prompt without granting the action
179
+ (Claude \`dontAsk\`, which is why neutral \`allow\` maps to
180
+ \`bypassPermissions\` instead); and
181
+ 2. \`unattended: deny\`, which refuses every request that reaches it.
182
+
183
+ **Automatic decisions are now recorded.** Every permission request decided
184
+ without a human emits a completed event into
185
+ \`~/.ours-fleet/agents/<Name>/.session-events.jsonl\` carrying the decision,
186
+ whether policy or a person made it, the policy that produced it
187
+ (\`permissions.unattended=deny\` vs \`permissions.approval=deny\`/\`=allow\`),
188
+ the reason, and the option selected. \`ours-fleet peek\` and \`attach\` render
189
+ them. Automatic denial asks for a one-shot rejection, never a standing one, so a
190
+ single unattended refusal cannot disable a tool for the rest of the session.
191
+
192
+ A role that can auto-deny logs one line at startup saying so.
193
+
194
+ To detect an under-permissioned role BEFORE it runs, use the capability floor
195
+ below: \`ours-fleet doctor\` fails such a role rather than letting it discover
196
+ the problem silently at work.
197
+
198
+ ### The unattended capability floor
199
+
200
+ An unattended role has no console, so a permission request cannot be answered —
201
+ it is refused, silently, inside the harness. The agent then does less than it
202
+ was told to and reports no error. To make that visible before launch,
203
+ \`ours-fleet config\` and \`ours-fleet doctor\` resolve each role's neutral
204
+ permissions through its harness and check the result against a fixed floor:
205
+
206
+ - \`read-state\` — read its briefing, ROUTINES.md, and WORKLOG.md
207
+ - \`write-state\` — append its WORKLOG and its own state files
208
+ - \`messaging\` — bind its identity, send and receive ours mail
209
+ - \`monitor\` — arm and observe its mail monitor
210
+ - \`workspace-edit\` — edit and test files in its working directory
211
+ - \`status-commands\` — run the inspection commands its briefing prescribes
212
+
213
+ \`doctor\` reports this per role as \`unattended floor: <Role>\`. A role with
214
+ \`unattended: deny\` that cannot meet the floor FAILS doctor, because it will
215
+ deny those requests with nobody to see it; with \`unattended: wait\` it warns,
216
+ because a human can still attach and answer.
217
+
218
+ Security meaning: \`approval: allow\` maps to Claude's \`bypassPermissions\`,
219
+ which genuinely permits the actions the role was authorized to take —
220
+ \`dontAsk\` only suppresses the prompt while still refusing the action. Nothing
221
+ other than an explicit \`allow\` is elevated: \`ask\` stays on Claude's default
222
+ mode and \`deny\` maps to \`plan\`. \`allow\` is therefore a real grant and
223
+ requires explicit authorization; per-role \`isolation:\` remains the outer
224
+ boundary that a permission mode cannot cross.
225
+
226
+ See also: \`spawn --approval/--filesystem/--unattended\` set this intent at
227
+ creation, and \`ours-fleet config\` prints each role's neutral settings, their
228
+ native translation, and any warning — the same text \`doctor\` reports.
229
+
144
230
  Claude \`harness_options\`: \`permission_mode\` (default, acceptEdits, plan,
145
231
  dontAsk, bypassPermissions), \`plugins\`, \`mem_palace\`, and
146
232
  \`mem_palace_midsession_autosave\`.
@@ -166,12 +252,73 @@ ours-fleet falls back to a compatible globally installed \`codex-acp\` or
166
252
 
167
253
  ## Reliable mail wake
168
254
 
169
- The supervisor monitor is enabled by default. It consumes body-free daemon
170
- events and advances its durable cursor only after delivery is accepted. ACP uses
171
- a structured \`session/prompt\`; tmux uses verified console injection. Message
172
- bodies are released only when the role calls the ours \`get_messages\` tool.
255
+ \`monitor.mode\` selects exactly one wake owner:
173
256
 
174
- Set \`monitor.enabled: false\` only to retain legacy in-session monitoring.
257
+ - \`fleet\` (default): the ours-fleet supervisor consumes body-free daemon
258
+ events and advances its durable cursor only after delivery is accepted. ACP
259
+ uses live steering when supported and falls back to structured
260
+ \`session/prompt\`; tmux uses verified console injection.
261
+ - \`native\`: ours-fleet starts no supervisor monitor; the generated briefing
262
+ instructs Claude Code or Codex to arm its harness-native wake mechanism.
263
+
264
+ Set \`monitor.interrupt: true\` in fleet mode to cancel active work before every
265
+ configured wake. The policy is content-blind because the supervisor cannot
266
+ inspect encrypted message bodies. Message bodies are released only when the
267
+ role calls the ours \`get_messages\` tool.
268
+
269
+ Legacy \`monitor.enabled: true|false\` remains accepted as an alias for
270
+ \`mode: fleet|native\`; use \`mode\` in new configuration. Codex's separate
271
+ \`harness_options.monitor: true\` is native-monitor consent, not monitor-owner
272
+ selection.
175
273
  Inspect \`ours-fleet status Name\`, \`peek Name\`, role logs, and
176
274
  \`~/.ours-fleet/agents/Name/.monitor-status\` when diagnosing delivery.
177
275
  `;
276
+ /**
277
+ * What every shipped spawn-skill variant must say, and must not say (7.1).
278
+ *
279
+ * The skills are separate markdown files in two published plugins, written for
280
+ * two different harnesses, so they cannot literally be one file. This is the
281
+ * source of truth they are all written from, and a test holds each variant to
282
+ * it — including the CLI reference above, so a skill and \`ours-fleet docs\`
283
+ * cannot name different permission settings.
284
+ *
285
+ * \`forbidden\` is the more important half. The old skills prescribed
286
+ * \`--approval ask --filesystem workspace --unattended deny\` as a blanket
287
+ * default while also telling the agent to stop at a failed doctor check — and
288
+ * that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
289
+ * unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
290
+ * fatal. Following the skill produced a role the CLI then refused.
291
+ */
292
+ export const SPAWN_SKILL_CONTRACT = {
293
+ /** Substrings every variant must contain (whitespace-normalised). */
294
+ required: [
295
+ // The installed reference is authoritative and must actually be read.
296
+ 'ours-fleet docs',
297
+ 'ours-fleet doctor',
298
+ // Trap 1: a mode that suppresses the prompt without granting the action.
299
+ 'dontAsk',
300
+ 'bypassPermissions',
301
+ // Trap 2: the floor, and the command that reports it before launch.
302
+ 'unattended capability floor',
303
+ 'unattended floor:',
304
+ // The only intent that clears the floor, and the honest alternative.
305
+ '--approval allow',
306
+ '--unattended wait',
307
+ // Creation-time isolation (6.3) — the one new operator input this release adds.
308
+ '--isolation-file',
309
+ ],
310
+ /**
311
+ * Substrings no variant may contain. Deliberately short: the real guard is
312
+ * the acceptance test, which runs every spawn command a variant prints
313
+ * through the same analysis `doctor` uses and fails if doctor would fail it.
314
+ * This list only pins the specific claim that was wrong.
315
+ */
316
+ forbidden: [
317
+ // The contradictory blanket default both variants used to prescribe.
318
+ // `ask` grants an unattended role only `read-state`, and `deny` makes the
319
+ // shortfall a doctor FAILURE — so the skill told you to build a role the
320
+ // CLI then refused, in the same breath as telling you to trust doctor.
321
+ '--approval ask --filesystem workspace --unattended deny',
322
+ '[TODO:',
323
+ ],
324
+ };
package/dist/doctor.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { userInfo } from 'node:os';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { realExec } from './exec.js';
4
- import { loadConfig } from './config.js';
5
- import { getAdapter } from './harness/registry.js';
4
+ import { isolationContextFor, loadConfig } from './config.js';
5
+ import { getAdapter, productionAdapters } from './harness/registry.js';
6
+ import { analyzeFleetPermissions, formatNative } from './permissions.js';
6
7
  import { resolveBundledAcpAgent } from './harness/acp-agent.js';
7
- import { agentDir, home, deriveXdgRuntimeDir } from './paths.js';
8
+ import { deriveXdgRuntimeDir } from './paths.js';
8
9
  import { resolveIsolation } from './isolation/policy.js';
9
10
  import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
10
11
  import { authResolutionHint, resolveEndpoint, } from './monitor.js';
@@ -24,7 +25,7 @@ function cgroupDelegationDetail() {
24
25
  /** Resolve and deduplicate the effective daemon profiles used by monitored roles. */
25
26
  function resolveMonitorProfiles(roles) {
26
27
  const profiles = [];
27
- for (const role of roles.filter(r => r.monitor?.enabled)) {
28
+ for (const role of roles.filter(r => r.monitor?.mode === 'fleet')) {
28
29
  const endpoint = resolveEndpoint({ ...process.env, ...(role.env ?? {}) });
29
30
  const token = endpoint.headers['x-ours-api-token'];
30
31
  const existing = profiles.find(p => p.endpoint.origin === endpoint.origin
@@ -44,7 +45,17 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
44
45
  name: 'node', ok: major >= 20,
45
46
  detail: major >= 20 ? `v${process.versions.node}` : `v${process.versions.node} — need >= 20`,
46
47
  });
47
- const roles = loadConfigSafe(opts.configPath);
48
+ // The configuration is a checked prerequisite in its own right. A config the
49
+ // `config` command rejects must fail here too, with the same cause — while the
50
+ // host checks below still run, because they are what the operator needs next.
51
+ const loaded = loadConfigResult(opts.configPath);
52
+ const roles = loaded.ok ? loaded.roles : [];
53
+ checks.push(loaded.ok
54
+ ? { name: 'config', ok: true, detail: loaded.files.join(' + ') || '(none — no fleet.yaml or fleet.d)' }
55
+ : { name: 'config', ok: false, detail: loaded.error });
56
+ checks.push(loaded.ok
57
+ ? { name: 'roles', ok: true, detail: `${roles.length} configured` }
58
+ : { name: 'roles', ok: false, detail: 'unknown — the configuration did not load' });
48
59
  if (roles.length === 0 || roles.some(role => (role.session ?? 'tmux') === 'tmux')) {
49
60
  const tmux = await exec('tmux', ['-V']);
50
61
  checks.push({
@@ -84,6 +95,45 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
84
95
  : `no XDG_RUNTIME_DIR and /run/user/<uid> missing — systemctl --user cannot reach the user manager; enable linger: sudo loginctl enable-linger ${user}`,
85
96
  });
86
97
  }
98
+ // Per-role permission translation (2.3). Rendered from the same analysis the
99
+ // `config` command prints, so the two commands cannot disagree.
100
+ for (const analysis of analyzeFleetPermissions(roles)) {
101
+ if (!analysis.supported) {
102
+ checks.push({
103
+ name: `permissions: ${analysis.role}`, ok: false,
104
+ detail: analysis.warnings.join('; '),
105
+ });
106
+ continue;
107
+ }
108
+ const p = analysis.permissions;
109
+ const summary = `approval=${p.approval} filesystem=${p.filesystem} unattended=${p.unattended}`
110
+ + ` -> ${formatNative(analysis.native)}`;
111
+ checks.push({
112
+ name: `permissions: ${analysis.role}`, ok: true,
113
+ detail: analysis.warnings.length
114
+ ? `${summary} — ${analysis.warnings.join('; ')}`
115
+ : `${summary} (exact)`,
116
+ });
117
+ // A role that states its permission intent twice, in two disagreeing places
118
+ // (2.4). Quiet when there is a single source of intent.
119
+ for (const conflict of analysis.conflicts ?? [])
120
+ checks.push({
121
+ name: `permission conflict: ${analysis.role}`, ok: true, detail: conflict.warning,
122
+ });
123
+ // The floor is checked BEFORE start (2.1): an under-permissioned unattended
124
+ // role never reports its own failure, because the denial happens inside the
125
+ // harness with nobody attached to see it.
126
+ const floor = analysis.floor;
127
+ checks.push({
128
+ name: `unattended floor: ${analysis.role}`,
129
+ ok: floor.meets || analysis.floorSeverity !== 'fail',
130
+ detail: floor.meets
131
+ ? `grants ${analysis.capabilities.join(', ')}`
132
+ : `MISSING ${floor.missing.join(', ')} — with unattended=${p.unattended} these requests will `
133
+ + `${p.unattended === 'deny' ? 'be denied silently' : 'block the turn'}; `
134
+ + `grants only ${analysis.capabilities.join(', ') || '(nothing)'}`,
135
+ });
136
+ }
87
137
  // Isolation reporting (AC-9). Backend availability is advisory — isolation is
88
138
  // opt-in per role (OQ-1), so a missing bwrap must not fail doctor for fleets that
89
139
  // don't use it. Only a role that DECLARES isolation and cannot get it under
@@ -98,13 +148,15 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
98
148
  if (platform === 'linux')
99
149
  checks.push({ name: 'isolation: cgroup delegation', ok: true, detail: cgroupDelegationDetail() });
100
150
  for (const r of roles.filter(r => r.isolation)) {
101
- const stateDir = agentDir(r.name);
102
- const policy = resolveIsolation(r.isolation, {
103
- stateDir, runCwd: r.cwd ?? stateDir, home: home(), harness: r.harness,
104
- additionalWriteDirs: r.harness === 'codex'
105
- ? (r.harness_options?.add_dirs ?? [])
106
- : [],
107
- });
151
+ // A refused mount is a launch-blocking policy error (5.2), not a warning.
152
+ let policy;
153
+ try {
154
+ policy = resolveIsolation(r.isolation, isolationContextFor(r));
155
+ }
156
+ catch (e) {
157
+ checks.push({ name: `isolation: ${r.name}`, ok: false, detail: e.message });
158
+ continue;
159
+ }
108
160
  const caps = [
109
161
  policy.resources.mem && `mem=${policy.resources.mem}`,
110
162
  policy.resources.cpu && `cpu=${policy.resources.cpu}`,
@@ -160,9 +212,14 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
160
212
  }
161
213
  checks.push({ name: checkName, ok, detail });
162
214
  }
215
+ // A broken config resolves no roles and therefore no harnesses. Without a
216
+ // fallback the AI CLI prerequisites would simply vanish from the report at
217
+ // exactly the moment the operator is trying to work out what is wrong.
163
218
  const harnesses = opts.harness
164
219
  ? [opts.harness]
165
- : [...new Set(roles.map(r => r.harness))];
220
+ : loaded.ok
221
+ ? [...new Set(roles.map(r => r.harness))]
222
+ : productionAdapters();
166
223
  for (const h of harnesses) {
167
224
  try {
168
225
  const rep = await getAdapter(h).checkPrereqs();
@@ -215,11 +272,12 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
215
272
  }
216
273
  return { ok: checks.every(c => c.ok), checks };
217
274
  }
218
- function loadConfigSafe(configPath) {
275
+ function loadConfigResult(configPath) {
219
276
  try {
220
- return loadConfig(configPath).roles;
277
+ const cfg = loadConfig(configPath);
278
+ return { ok: true, roles: cfg.roles, files: cfg.files };
221
279
  }
222
- catch {
223
- return [];
280
+ catch (e) {
281
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
224
282
  }
225
283
  }