@ours.network/fleet 0.9.4 → 0.9.7

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 (59) hide show
  1. package/README.md +148 -30
  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 +41 -11
  6. package/dist/cli.js +238 -26
  7. package/dist/config.d.ts +39 -1
  8. package/dist/config.js +126 -3
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +34 -0
  12. package/dist/docs.js +309 -0
  13. package/dist/doctor.js +123 -21
  14. package/dist/harness/acp-agent.d.ts +11 -0
  15. package/dist/harness/acp-agent.js +27 -0
  16. package/dist/harness/claude-code.d.ts +39 -3
  17. package/dist/harness/claude-code.js +145 -13
  18. package/dist/harness/codex.d.ts +7 -1
  19. package/dist/harness/codex.js +89 -4
  20. package/dist/harness/registry.d.ts +2 -0
  21. package/dist/harness/registry.js +19 -0
  22. package/dist/harness/types.d.ts +59 -1
  23. package/dist/index.d.ts +6 -3
  24. package/dist/index.js +3 -1
  25. package/dist/isolation/bubblewrap.js +7 -1
  26. package/dist/isolation/policy.d.ts +34 -5
  27. package/dist/isolation/policy.js +114 -7
  28. package/dist/isolation/resources.d.ts +6 -3
  29. package/dist/isolation/resources.js +6 -3
  30. package/dist/isolation/types.d.ts +19 -1
  31. package/dist/monitor.d.ts +44 -2
  32. package/dist/monitor.js +177 -42
  33. package/dist/ops.d.ts +15 -2
  34. package/dist/ops.js +32 -9
  35. package/dist/permissions.d.ts +70 -0
  36. package/dist/permissions.js +97 -0
  37. package/dist/runner.d.ts +65 -2
  38. package/dist/runner.js +307 -32
  39. package/dist/session/acp.d.ts +70 -0
  40. package/dist/session/acp.js +364 -0
  41. package/dist/session/control.d.ts +89 -0
  42. package/dist/session/control.js +322 -0
  43. package/dist/session/events.d.ts +14 -0
  44. package/dist/session/events.js +67 -0
  45. package/dist/session/tmux.d.ts +27 -0
  46. package/dist/session/tmux.js +76 -0
  47. package/dist/session/types.d.ts +138 -0
  48. package/dist/session/types.js +42 -0
  49. package/dist/spawn.d.ts +32 -2
  50. package/dist/spawn.js +177 -16
  51. package/dist/supervisor/launchd.d.ts +50 -0
  52. package/dist/supervisor/launchd.js +121 -4
  53. package/dist/supervisor/none.js +22 -4
  54. package/dist/supervisor/systemd.d.ts +8 -1
  55. package/dist/supervisor/systemd.js +94 -4
  56. package/dist/supervisor/types.d.ts +36 -3
  57. package/dist/tmux.d.ts +34 -2
  58. package/dist/tmux.js +48 -11
  59. package/package.json +7 -2
package/dist/docs.js ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Stable, AI-friendly CLI and configuration reference.
3
+ *
4
+ * Keep this concise enough to place directly in an agent context. Unlike
5
+ * Commander's per-command help, this describes how the pieces compose.
6
+ */
7
+ export const AI_DOCS = `# ours-fleet reference
8
+
9
+ ours-fleet runs persistent or temporary, identity-bound AI roles. A role selects
10
+ a harness independently from its session backend:
11
+
12
+ - harness: \`claude-code\` or \`codex\`
13
+ - session: \`tmux\` (default) or \`acp\`
14
+ - lifetime: permanent (supervised, restartable) or \`spawn --temp\`
15
+
16
+ ## Discover and validate
17
+
18
+ \`\`\`sh
19
+ ours-fleet docs # this complete reference (\`man\` is an alias)
20
+ ours-fleet help <command> # exact flags for one command
21
+ ours-fleet config [-c FILE] # validate and print the merged plan; no changes
22
+ ours-fleet doctor [-c FILE] [--harness codex|claude-code]
23
+ \`\`\`
24
+
25
+ Default configuration is \`~/fleet.yaml\` plus sorted \`~/fleet.d/*.yaml\` role
26
+ drop-ins. An explicit \`-c FILE\` replaces \`~/fleet.yaml\`; fleet.d still adds
27
+ roles. Validate with \`config\` and \`doctor\` before starting or restarting.
28
+
29
+ ## Lifecycle and console commands
30
+
31
+ \`\`\`sh
32
+ ours-fleet init
33
+ ours-fleet up|down [Name...]
34
+ ours-fleet restart [Name...] # preserve/resume harness context
35
+ ours-fleet force-restart [Name...] # fresh context; briefing is reloaded
36
+ ours-fleet ls
37
+ ours-fleet status|peek|attach|logs Name
38
+ ours-fleet logs -f Name
39
+ ours-fleet send Name "prompt"
40
+ ours-fleet send Name --key Enter # tmux only
41
+ ours-fleet rm Name
42
+ \`\`\`
43
+
44
+ \`peek\`, \`attach\`, and text \`send\` work with tmux and ACP. ACP attachment
45
+ also accepts \`/permit <permission-id> <option-id>\`, \`/interrupt\`, and
46
+ \`/detach\`. Raw \`--key\` input is tmux-only.
47
+
48
+ ## Spawn
49
+
50
+ \`\`\`sh
51
+ ours-fleet spawn [--temp] Name \\
52
+ --harness codex|claude-code --session tmux|acp \\
53
+ --mission "one line" --cwd /absolute/path --identity Identity \\
54
+ --coordinator Coordinator --model MODEL \\
55
+ --approval ask|allow|deny \\
56
+ --filesystem read-only|workspace|unrestricted \\
57
+ --unattended deny|wait \\
58
+ --bio-file /path/bio.md --persona-file /path/persona.md
59
+ \`\`\`
60
+
61
+ Permanent spawn writes \`~/fleet.d/Name.yaml\` and starts a supervised role.
62
+ \`--temp\` writes ephemeral state, starts a detached supervisor, and removes the
63
+ role after exit/reboot. Both lifetimes support \`--session acp\`.
64
+
65
+ Codex-specific spawn flags: \`--sandbox\`, \`--permission-mode\`, \`--launcher\`,
66
+ \`--profile\`, \`--search\`, repeatable \`--codex-config key=value\`, repeatable
67
+ \`--add-dir\`, and \`--monitor\`. Run \`ours-fleet help spawn\` for exact values.
68
+
69
+ ## fleet.yaml
70
+
71
+ \`\`\`yaml
72
+ vars:
73
+ work_root: /home/me/work
74
+ start_stagger_ms: 0
75
+ defaults:
76
+ harness: codex
77
+ session: acp
78
+ model: gpt-model-id
79
+ permissions:
80
+ approval: ask
81
+ filesystem: workspace
82
+ unattended: deny
83
+ monitor:
84
+ enabled: true
85
+ roles:
86
+ Coordinator:
87
+ harness: codex
88
+ session: acp
89
+ identity: Coordinator
90
+ cwd: \${work_root}/project
91
+ mission: Coordinate work and delegate implementation.
92
+ model: gpt-model-id
93
+ permissions:
94
+ approval: ask
95
+ filesystem: workspace
96
+ unattended: deny
97
+ session_options: # advanced overrides; normally omit
98
+ # acp:
99
+ # command: [/custom/codex-acp, --flag]
100
+ tmux:
101
+ boot_grace_ms: 10000
102
+ monitor:
103
+ enabled: true
104
+ wake_sources: [message_received, file_received, local_contact_request, pending_message]
105
+ batch_ms: 2000
106
+ inject: notification
107
+ turn_fail_threshold: 3
108
+ harness_options:
109
+ launcher: auto
110
+ sandbox: workspace-write
111
+ approval: on-request
112
+ search: false
113
+ profile: fleet
114
+ add_dirs: [/data/shared]
115
+ config:
116
+ model_reasoning_effort: high
117
+ bio: Public role card and when peers should engage it.
118
+ persona: Local operating contract, boundaries, and escalation policy.
119
+ briefing_file: /absolute/custom-briefing.md
120
+ coordinator: AnotherCoordinator
121
+ env:
122
+ KEY: value
123
+ oversee:
124
+ - { role: Worker, interval: 5m }
125
+ \`\`\`
126
+
127
+ Role values override defaults. \`\${name}\` substitutes entries from \`vars\`.
128
+ Other role fields include \`max_tokens\`, \`autocompact_pct\`, and \`isolation\`.
129
+ Use README.md for the complete isolation policy and resource-cap schema.
130
+
131
+ ## Permissions
132
+
133
+ Prefer the harness-neutral \`permissions\` block:
134
+
135
+ - \`approval: ask|allow|deny\`: whether actions may request or receive approval
136
+ - \`filesystem: read-only|workspace|unrestricted\`: filesystem intent
137
+ - \`unattended: deny|wait\`: what ACP does when no console can answer a request
138
+
139
+ The backend translates this common intent. Harness-native settings in
140
+ \`harness_options\` take precedence where supplied. Do not choose
141
+ \`allow\`/\`unrestricted\`, Codex \`never\`/\`danger-full-access\`, or Claude
142
+ \`bypassPermissions\` without explicit authorization.
143
+
144
+ ### Creation-time isolation
145
+
146
+ \`ours-fleet spawn --isolation-file <path>\` supplies a role's sandbox policy at
147
+ creation, so the FIRST launch is already confined — a role that only gains
148
+ \`isolation:\` on a later \`up\` ran unsandboxed until then.
149
+
150
+ The file holds exactly the \`isolation:\` mapping documented above and nothing
151
+ else — the same schema, validated by the same code, so a policy written here
152
+ cannot mean something different from the identical block in fleet.yaml:
153
+
154
+ \`\`\`yaml
155
+ network: deny
156
+ fs:
157
+ read: [/opt/reference]
158
+ resources:
159
+ mem: 2G
160
+ \`\`\`
161
+
162
+ Invalid files are rejected before anything is created: no config, no state
163
+ directory, no identity reservation. Works for both permanent and \`--temp\` roles.
164
+
165
+ ### Never-prompt failure
166
+
167
+ The failure this section exists to prevent leaves no error message anywhere.
168
+
169
+ An unattended role has no console. When the harness needs a permission decision
170
+ there is nobody to ask, so the request is refused INSIDE the harness — no
171
+ prompt, no error, no log line. The agent simply does less than its briefing told
172
+ it to, reports success, and nothing distinguishes that from having done the
173
+ work. Two settings produce it:
174
+
175
+ 1. a permission mode that suppresses the prompt without granting the action
176
+ (Claude \`dontAsk\`, which is why neutral \`allow\` maps to
177
+ \`bypassPermissions\` instead); and
178
+ 2. \`unattended: deny\`, which refuses every request that reaches it.
179
+
180
+ **Automatic decisions are now recorded.** Every permission request decided
181
+ without a human emits a completed event into
182
+ \`~/.ours-fleet/agents/<Name>/.session-events.jsonl\` carrying the decision,
183
+ whether policy or a person made it, the policy that produced it
184
+ (\`permissions.unattended=deny\` vs \`permissions.approval=deny\`/\`=allow\`),
185
+ the reason, and the option selected. \`ours-fleet peek\` and \`attach\` render
186
+ them. Automatic denial asks for a one-shot rejection, never a standing one, so a
187
+ single unattended refusal cannot disable a tool for the rest of the session.
188
+
189
+ A role that can auto-deny logs one line at startup saying so.
190
+
191
+ To detect an under-permissioned role BEFORE it runs, use the capability floor
192
+ below: \`ours-fleet doctor\` fails such a role rather than letting it discover
193
+ the problem silently at work.
194
+
195
+ ### The unattended capability floor
196
+
197
+ An unattended role has no console, so a permission request cannot be answered —
198
+ it is refused, silently, inside the harness. The agent then does less than it
199
+ was told to and reports no error. To make that visible before launch,
200
+ \`ours-fleet config\` and \`ours-fleet doctor\` resolve each role's neutral
201
+ permissions through its harness and check the result against a fixed floor:
202
+
203
+ - \`read-state\` — read its briefing, ROUTINES.md, and WORKLOG.md
204
+ - \`write-state\` — append its WORKLOG and its own state files
205
+ - \`messaging\` — bind its identity, send and receive ours mail
206
+ - \`monitor\` — arm and observe its mail monitor
207
+ - \`workspace-edit\` — edit and test files in its working directory
208
+ - \`status-commands\` — run the inspection commands its briefing prescribes
209
+
210
+ \`doctor\` reports this per role as \`unattended floor: <Role>\`. A role with
211
+ \`unattended: deny\` that cannot meet the floor FAILS doctor, because it will
212
+ deny those requests with nobody to see it; with \`unattended: wait\` it warns,
213
+ because a human can still attach and answer.
214
+
215
+ Security meaning: \`approval: allow\` maps to Claude's \`bypassPermissions\`,
216
+ which genuinely permits the actions the role was authorized to take —
217
+ \`dontAsk\` only suppresses the prompt while still refusing the action. Nothing
218
+ other than an explicit \`allow\` is elevated: \`ask\` stays on Claude's default
219
+ mode and \`deny\` maps to \`plan\`. \`allow\` is therefore a real grant and
220
+ requires explicit authorization; per-role \`isolation:\` remains the outer
221
+ boundary that a permission mode cannot cross.
222
+
223
+ See also: \`spawn --approval/--filesystem/--unattended\` set this intent at
224
+ creation, and \`ours-fleet config\` prints each role's neutral settings, their
225
+ native translation, and any warning — the same text \`doctor\` reports.
226
+
227
+ Claude \`harness_options\`: \`permission_mode\` (default, acceptEdits, plan,
228
+ dontAsk, bypassPermissions), \`plugins\`, \`mem_palace\`, and
229
+ \`mem_palace_midsession_autosave\`.
230
+
231
+ Codex \`harness_options\`: \`launcher\` (auto, ours-codex, codex), \`sandbox\`
232
+ (read-only, workspace-write, danger-full-access), \`approval\` or
233
+ \`permission_mode\` (untrusted, on-request, never), \`profile\`, \`search\`,
234
+ \`config\`, \`add_dirs\`, and \`monitor\`.
235
+
236
+ ## ACP adapters
237
+
238
+ The maintained \`@agentclientprotocol/codex-acp\` and
239
+ \`@agentclientprotocol/claude-agent-acp\` runtimes are bundled automatically as
240
+ optional ours-fleet dependencies. The supervisor resolves their executable
241
+ entrypoints internally, so default ACP roles do not depend on global PATH.
242
+ The maintained Claude adapter requires Node 22; tmux and Codex ACP continue to
243
+ work on the ours-fleet core minimum of Node 20.
244
+
245
+ Override an adapter only when necessary with \`session_options.acp.command\`
246
+ (string or argv list). If optional dependencies were deliberately omitted,
247
+ ours-fleet falls back to a compatible globally installed \`codex-acp\` or
248
+ \`claude-agent-acp\`. \`ours-fleet doctor -c FILE\` verifies the resolved adapter.
249
+
250
+ ## Reliable mail wake
251
+
252
+ The supervisor monitor is enabled by default. It consumes body-free daemon
253
+ events and advances its durable cursor only after delivery is accepted. ACP uses
254
+ a structured \`session/prompt\`; tmux uses verified console injection. Message
255
+ bodies are released only when the role calls the ours \`get_messages\` tool.
256
+
257
+ Set \`monitor.enabled: false\` only to retain legacy in-session monitoring.
258
+ Inspect \`ours-fleet status Name\`, \`peek Name\`, role logs, and
259
+ \`~/.ours-fleet/agents/Name/.monitor-status\` when diagnosing delivery.
260
+ `;
261
+ /**
262
+ * What every shipped spawn-skill variant must say, and must not say (7.1).
263
+ *
264
+ * The skills are separate markdown files in two published plugins, written for
265
+ * two different harnesses, so they cannot literally be one file. This is the
266
+ * source of truth they are all written from, and a test holds each variant to
267
+ * it — including the CLI reference above, so a skill and \`ours-fleet docs\`
268
+ * cannot name different permission settings.
269
+ *
270
+ * \`forbidden\` is the more important half. The old skills prescribed
271
+ * \`--approval ask --filesystem workspace --unattended deny\` as a blanket
272
+ * default while also telling the agent to stop at a failed doctor check — and
273
+ * that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
274
+ * unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
275
+ * fatal. Following the skill produced a role the CLI then refused.
276
+ */
277
+ export const SPAWN_SKILL_CONTRACT = {
278
+ /** Substrings every variant must contain (whitespace-normalised). */
279
+ required: [
280
+ // The installed reference is authoritative and must actually be read.
281
+ 'ours-fleet docs',
282
+ 'ours-fleet doctor',
283
+ // Trap 1: a mode that suppresses the prompt without granting the action.
284
+ 'dontAsk',
285
+ 'bypassPermissions',
286
+ // Trap 2: the floor, and the command that reports it before launch.
287
+ 'unattended capability floor',
288
+ 'unattended floor:',
289
+ // The only intent that clears the floor, and the honest alternative.
290
+ '--approval allow',
291
+ '--unattended wait',
292
+ // Creation-time isolation (6.3) — the one new operator input this release adds.
293
+ '--isolation-file',
294
+ ],
295
+ /**
296
+ * Substrings no variant may contain. Deliberately short: the real guard is
297
+ * the acceptance test, which runs every spawn command a variant prints
298
+ * through the same analysis `doctor` uses and fails if doctor would fail it.
299
+ * This list only pins the specific claim that was wrong.
300
+ */
301
+ forbidden: [
302
+ // The contradictory blanket default both variants used to prescribe.
303
+ // `ask` grants an unattended role only `read-state`, and `deny` makes the
304
+ // shortfall a doctor FAILURE — so the skill told you to build a role the
305
+ // CLI then refused, in the same breath as telling you to trust doctor.
306
+ '--approval ask --filesystem workspace --unattended deny',
307
+ '[TODO:',
308
+ ],
309
+ };
package/dist/doctor.js CHANGED
@@ -1,9 +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';
6
- import { agentDir, home, deriveXdgRuntimeDir } from './paths.js';
4
+ import { isolationContextFor, loadConfig } from './config.js';
5
+ import { getAdapter, productionAdapters } from './harness/registry.js';
6
+ import { analyzeFleetPermissions, formatNative } from './permissions.js';
7
+ import { resolveBundledAcpAgent } from './harness/acp-agent.js';
8
+ import { deriveXdgRuntimeDir } from './paths.js';
7
9
  import { resolveIsolation } from './isolation/policy.js';
8
10
  import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
9
11
  import { authResolutionHint, resolveEndpoint, } from './monitor.js';
@@ -43,11 +45,24 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
43
45
  name: 'node', ok: major >= 20,
44
46
  detail: major >= 20 ? `v${process.versions.node}` : `v${process.versions.node} — need >= 20`,
45
47
  });
46
- const tmux = await exec('tmux', ['-V']);
47
- checks.push({
48
- name: 'tmux', ok: tmux.code === 0,
49
- detail: tmux.code === 0 ? tmux.stdout.trim() : 'not found — apt install tmux / brew install tmux',
50
- });
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' });
59
+ if (roles.length === 0 || roles.some(role => (role.session ?? 'tmux') === 'tmux')) {
60
+ const tmux = await exec('tmux', ['-V']);
61
+ checks.push({
62
+ name: 'tmux', ok: tmux.code === 0,
63
+ detail: tmux.code === 0 ? tmux.stdout.trim() : 'not found — apt install tmux / brew install tmux',
64
+ });
65
+ }
51
66
  const mcp = await exec('ours-mcp', ['--version']);
52
67
  checks.push({
53
68
  name: 'ours-mcp', ok: mcp.code === 0,
@@ -80,11 +95,49 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
80
95
  : `no XDG_RUNTIME_DIR and /run/user/<uid> missing — systemctl --user cannot reach the user manager; enable linger: sudo loginctl enable-linger ${user}`,
81
96
  });
82
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
+ }
83
137
  // Isolation reporting (AC-9). Backend availability is advisory — isolation is
84
138
  // opt-in per role (OQ-1), so a missing bwrap must not fail doctor for fleets that
85
139
  // don't use it. Only a role that DECLARES isolation and cannot get it under
86
140
  // `strict` is a hard failure.
87
- const roles = loadConfigSafe(opts.configPath);
88
141
  const bw = await makeBubblewrapBackend(exec).available();
89
142
  checks.push({
90
143
  name: 'isolation: bubblewrap', ok: true,
@@ -95,13 +148,15 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
95
148
  if (platform === 'linux')
96
149
  checks.push({ name: 'isolation: cgroup delegation', ok: true, detail: cgroupDelegationDetail() });
97
150
  for (const r of roles.filter(r => r.isolation)) {
98
- const stateDir = agentDir(r.name);
99
- const policy = resolveIsolation(r.isolation, {
100
- stateDir, runCwd: r.cwd ?? stateDir, home: home(), harness: r.harness,
101
- additionalWriteDirs: r.harness === 'codex'
102
- ? (r.harness_options?.add_dirs ?? [])
103
- : [],
104
- });
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
+ }
105
160
  const caps = [
106
161
  policy.resources.mem && `mem=${policy.resources.mem}`,
107
162
  policy.resources.cpu && `cpu=${policy.resources.cpu}`,
@@ -157,9 +212,14 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
157
212
  }
158
213
  checks.push({ name: checkName, ok, detail });
159
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.
160
218
  const harnesses = opts.harness
161
219
  ? [opts.harness]
162
- : [...new Set(roles.map(r => r.harness))];
220
+ : loaded.ok
221
+ ? [...new Set(roles.map(r => r.harness))]
222
+ : productionAdapters();
163
223
  for (const h of harnesses) {
164
224
  try {
165
225
  const rep = await getAdapter(h).checkPrereqs();
@@ -169,13 +229,55 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
169
229
  checks.push({ name: h, ok: false, detail: e.message });
170
230
  }
171
231
  }
232
+ for (const role of roles.filter(role => role.session === 'acp')) {
233
+ const configured = role.session_options?.acp?.command;
234
+ const bundled = configured == null
235
+ ? role.harness === 'codex'
236
+ ? resolveBundledAcpAgent('@agentclientprotocol/codex-acp', 'codex-acp', 'codex-acp')
237
+ : role.harness === 'claude-code'
238
+ ? resolveBundledAcpAgent('@agentclientprotocol/claude-agent-acp', 'claude-agent-acp', 'claude-agent-acp')
239
+ : undefined
240
+ : undefined;
241
+ const command = Array.isArray(configured)
242
+ ? configured[0]
243
+ : typeof configured === 'string'
244
+ ? configured.trim().split(/\s+/)[0]
245
+ : role.harness === 'codex'
246
+ ? 'codex-acp'
247
+ : role.harness === 'claude-code'
248
+ ? 'claude-agent-acp'
249
+ : '';
250
+ if (!command) {
251
+ checks.push({
252
+ name: `acp: ${role.name}`, ok: false,
253
+ detail: `harness '${role.harness}' has no default ACP agent; set session_options.acp.command`,
254
+ });
255
+ continue;
256
+ }
257
+ if (bundled?.bundled) {
258
+ checks.push({
259
+ name: `acp: ${role.name}`, ok: true,
260
+ detail: `${command} bundled with ours-fleet`,
261
+ });
262
+ continue;
263
+ }
264
+ const result = await exec('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', 'sh', command]);
265
+ checks.push({
266
+ name: `acp: ${role.name}`,
267
+ ok: result.code === 0,
268
+ detail: result.code === 0
269
+ ? `${command} available`
270
+ : `${command} not found or failed — install the ACP adapter or set session_options.acp.command`,
271
+ });
272
+ }
172
273
  return { ok: checks.every(c => c.ok), checks };
173
274
  }
174
- function loadConfigSafe(configPath) {
275
+ function loadConfigResult(configPath) {
175
276
  try {
176
- return loadConfig(configPath).roles;
277
+ const cfg = loadConfig(configPath);
278
+ return { ok: true, roles: cfg.roles, files: cfg.files };
177
279
  }
178
- catch {
179
- return [];
280
+ catch (e) {
281
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
180
282
  }
181
283
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Resolve an ACP agent shipped as an ours-fleet dependency. Running the JS
3
+ * entrypoint through this process's Node avoids depending on npm exposing a
4
+ * transitive dependency's bin on the user's global PATH.
5
+ */
6
+ export interface AcpAgentResolution {
7
+ argv: string[];
8
+ bundled: boolean;
9
+ }
10
+ export declare function resolveBundledAcpAgent(packageName: string, binName: string, fallbackCommand: string): AcpAgentResolution;
11
+ export declare function bundledAcpAgent(packageName: string, binName: string, fallbackCommand: string): string[];
@@ -0,0 +1,27 @@
1
+ import { createRequire } from 'node:module';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ const require = createRequire(import.meta.url);
5
+ export function resolveBundledAcpAgent(packageName, binName, fallbackCommand) {
6
+ try {
7
+ const manifestPath = require.resolve(`${packageName}/package.json`);
8
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
9
+ const relative = typeof manifest.bin === 'string'
10
+ ? manifest.bin
11
+ : manifest.bin?.[binName];
12
+ if (!relative)
13
+ return { argv: [fallbackCommand], bundled: false };
14
+ const entrypoint = resolve(dirname(manifestPath), relative);
15
+ if (!existsSync(entrypoint))
16
+ return { argv: [fallbackCommand], bundled: false };
17
+ return { argv: [process.execPath, entrypoint], bundled: true };
18
+ }
19
+ catch {
20
+ // Supports development installs that intentionally omit optional
21
+ // dependencies and existing hosts with a globally installed adapter.
22
+ return { argv: [fallbackCommand], bundled: false };
23
+ }
24
+ }
25
+ export function bundledAcpAgent(packageName, binName, fallbackCommand) {
26
+ return resolveBundledAcpAgent(packageName, binName, fallbackCommand).argv;
27
+ }
@@ -1,8 +1,44 @@
1
1
  import { type Exec } from '../exec.js';
2
2
  import type { ResolvedRole } from '../config.js';
3
- import type { HarnessAdapter } from './types.js';
3
+ import type { HarnessAdapter, UnattendedCapability } from './types.js';
4
+ import { type LockDeps } from '../atomic-file.js';
5
+ /**
6
+ * Neutral approval → native Claude mode. The ONE definition, shared by launch
7
+ * and by translation so the two can never disagree about what a role will run
8
+ * with.
9
+ *
10
+ * `allow` maps to `bypassPermissions`, not `dontAsk`. `dontAsk` suppresses the
11
+ * PROMPT, not the denial: an unattended role configured with the operator's
12
+ * explicit `approval: allow` was silently refused the actions it was told to
13
+ * take, with no prompt and no error to show for it. Only an explicit `allow`
14
+ * gets this; `ask` and `deny` are never elevated.
15
+ */
16
+ export declare function nativePermissionMode(approval: ResolvedRole['permissions']['approval']): string | undefined;
17
+ /**
18
+ * What an unattended role can actually do under a native mode. Derived from the
19
+ * NATIVE mode, so an operator's explicit `harness_options.permission_mode`
20
+ * override is judged on what it really grants.
21
+ */
22
+ export declare function claudeCapabilities(mode: string | undefined, filesystem: ResolvedRole['permissions']['filesystem']): UnattendedCapability[];
4
23
  export declare function autocompactPct(role: ResolvedRole): number;
5
- /** Pre-trust a dir in ~/.claude.json so the first launch never blocks on the trust dialog. */
6
- export declare function pretrust(dir: string): void;
24
+ /**
25
+ * Pre-trust a dir in ~/.claude.json so the first launch never blocks on the
26
+ * trust dialog.
27
+ *
28
+ * `~/.claude.json` is SHARED by every role and by the operator's own Claude
29
+ * Code. The previous read-modify-write held nothing while it worked, so two
30
+ * roles starting together interleaved and one silently lost its trust entry —
31
+ * and that role then blocked on the dialog it was supposed to be spared,
32
+ * unattended, with nobody to answer it. A crash mid-write truncated the file
33
+ * for everyone.
34
+ *
35
+ * Now: take a cross-process lock, re-read inside it, merge ONLY this project's
36
+ * entry so unrelated operator state survives untouched, and replace the file
37
+ * atomically. Never fatal — a role that cannot be pre-trusted still launches.
38
+ */
39
+ export declare function pretrust(dir: string, deps?: {
40
+ log?(line: string): void;
41
+ lock?: LockDeps;
42
+ }): Promise<void>;
7
43
  export declare function makeClaudeCodeAdapter(exec?: Exec): HarnessAdapter;
8
44
  export declare const claudeCodeAdapter: HarnessAdapter;