@ours.network/fleet 0.5.2 → 0.7.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.
package/README.md CHANGED
@@ -26,15 +26,15 @@ reality match the file.
26
26
 
27
27
  **Harness-agnostic by design.** The core never assumes a specific agent CLI; each
28
28
  harness is a small adapter (how to launch, how to resume, how to wire config).
29
- **Claude Code** is wired in, and the adapter interface is public — each
30
- additional harness (Codex CLI, Gemini CLI, OpenCode, …) is a small adapter. A
31
- single fleet can mix harnesses per role:
29
+ **Claude Code** and **Codex CLI** are wired in, and the adapter interface is
30
+ public — each additional harness (Gemini CLI, OpenCode, …) is a small adapter.
31
+ A single fleet can mix harnesses per role:
32
32
 
33
33
  ```yaml
34
34
  roles:
35
35
  Reviewer: # runs in Claude Code
36
36
  harness: claude-code
37
- Prototyper: # (future) runs elsewhere
37
+ Prototyper: # runs in Codex CLI
38
38
  harness: codex
39
39
  ```
40
40
 
@@ -70,7 +70,7 @@ The state dir contract:
70
70
  |---|---|---|
71
71
  | Node ≥ 20 | runs `ours-fleet` itself | nodejs.org, `apt`, or `brew` |
72
72
  | tmux | every role's console | `apt install tmux` / `brew install tmux` |
73
- | a harness CLI, logged in | the agent itself | e.g. Claude Code (`claude`) |
73
+ | a harness CLI, logged in | the agent itself | e.g. Claude Code (`claude`) or Codex CLI (`codex`) |
74
74
  | `ours-mcp` daemon | identity + agent-to-agent messaging | `npm i -g @ours.network/mcp && ours-mcp start` |
75
75
 
76
76
  Linux only: `ours-fleet init` enables *linger* so roles run without a login session
@@ -107,6 +107,11 @@ From the shell:
107
107
  ours-fleet spawn Worker --mission "own the worker repo" \
108
108
  --bio-file bio.md --persona-file persona.md --coordinator FleetCoordinator
109
109
  ours-fleet spawn --temp Scout --mission "one-off research" # gone on exit/reboot
110
+
111
+ # Codex role: ours-codex is preferred automatically; plain codex is the fallback
112
+ ours-fleet spawn Coder --harness codex --model gpt-5.4 \
113
+ --permission-mode on-request --sandbox workspace-write \
114
+ --profile fleet --search --monitor --coordinator FleetCoordinator
110
115
  ```
111
116
 
112
117
  Permanent spawns are written to `~/fleet.d/<Name>.yaml` — your hand-written
@@ -117,6 +122,17 @@ From inside Claude Code: install the `ours-fleet` plugin (ships in this repo und
117
122
  temp-vs-permanent, co-drafts the bio and persona with you, spawns, and arms
118
123
  oversight.
119
124
 
125
+ From inside Codex, install the native fleet plugin:
126
+
127
+ ```sh
128
+ npm i -g @ours.network/fleet-codex
129
+ ours-fleet-codex-install
130
+ ```
131
+
132
+ Start a new Codex session and say **"spawn an ours agent …"**. The bundled skill
133
+ walks through lifetime, model, sandbox, approval policy, profile, launcher, and
134
+ mail-monitor consent, then verifies the real tmux session and offers oversight.
135
+
120
136
  ## Oversight ("keep an eye")
121
137
 
122
138
  Spawned agents are subagents; their spawner (or any assigned overseer) checks on
@@ -147,7 +163,7 @@ ours-fleet up|down|restart|force-restart [-c FILE] [Name...]
147
163
  ours-fleet config [-c FILE] validate + print merged plan
148
164
  ours-fleet ls | attach | peek | logs [-f] | status <Name>
149
165
  ours-fleet send <Name> "text" | --key <K>
150
- ours-fleet spawn [--temp] <Name> [--mission --model --bio-file --persona-file ...]
166
+ ours-fleet spawn [--temp] <Name> [--harness --mission --model --permission-mode ...]
151
167
  ours-fleet rm <Name>
152
168
  ours-fleet doctor [--harness H]
153
169
  ours-fleet init
@@ -179,6 +195,18 @@ roles:
179
195
  # mem_palace: false # claude-code: disable memory plugin
180
196
  # permission_mode: dontAsk # claude-code: launch permission mode —
181
197
  # one of default | acceptEdits | plan | dontAsk | bypassPermissions
198
+ # sandbox: workspace-write # codex: --sandbox —
199
+ # one of read-only | workspace-write | danger-full-access
200
+ # approval: never # codex: --ask-for-approval —
201
+ # one of untrusted | on-request | never
202
+ # permission_mode: never # codex alias for approval
203
+ # launcher: auto # codex: auto | ours-codex | codex
204
+ # profile: fleet # codex: $CODEX_HOME/fleet.config.toml
205
+ # search: true # codex: enable --search (live web search)
206
+ # add_dirs: [/data/shared] # codex: repeat --add-dir
207
+ # monitor: true # explicit persistent consent to arm mail wake
208
+ # config: # codex: repeat --config key=<TOML value>
209
+ # model_reasoning_effort: high
182
210
  isolation: # OS-level sandbox (additive; omit = today's behavior)
183
211
  backend: auto # auto | bubblewrap | podman | none (default auto)
184
212
  on_unavailable: warn # warn (un-isolated + marker) | strict (refuse) (default warn)
@@ -190,7 +218,65 @@ roles:
190
218
 
191
219
  Merge order: `fleet.yaml` ← `fleet.d/*.yaml`; a duplicate role name is a hard
192
220
  error naming both files. Identities and roles are decoupled — removing a role
193
- never deletes an identity.
221
+ never deletes an identity. `defaults.harness_options` is shallow-merged with each
222
+ role's `harness_options`, so a fleet can set common Codex permission/profile defaults
223
+ and override individual keys per role.
224
+
225
+ ## Codex roles
226
+
227
+ Install Codex, the native ours plugin, and the fleet skills once on the fleet host:
228
+
229
+ ```sh
230
+ npm i -g @ours.network/fleet-codex
231
+ ours-fleet-codex-install
232
+ ours-fleet doctor --harness codex
233
+ ```
234
+
235
+ A role with `harness: codex` resolves its launcher at every supervised start:
236
+
237
+ 1. `harness_options.launcher: auto` (the default) uses `ours-codex` when it is on
238
+ `PATH`, giving the role session-scoped background mail wake.
239
+ 2. If `ours-codex` is absent, it launches ordinary `codex`; the native ours plugin's
240
+ blocking `foreground_monitor` is used as the supported fallback.
241
+ 3. `launcher: ours-codex` makes the enhanced launcher mandatory and fails clearly if
242
+ it is missing; `launcher: codex` explicitly selects standard mode.
243
+
244
+ Monitoring remains consent-first. By default the generated briefing asks in the role's
245
+ console before calling `arm_monitor`. Set `harness_options.monitor: true` (or pass
246
+ `ours-fleet spawn --monitor`) to record explicit persistent consent for that role, including
247
+ supervised restarts. In the standard-Codex fallback, the agent separately surfaces the
248
+ `ours-codex` recommendation before asking to enter `foreground_monitor`. It never backgrounds
249
+ `ours-mcp watch`, because a detached watch cannot wake a Codex turn. The foreground
250
+ wait is re-entered after each handled message; `ours-codex` instead wakes the idle
251
+ session through its App Server integration.
252
+
253
+ The main Codex controls needed by fleet roles are available declaratively and when spawning:
254
+
255
+ ```yaml
256
+ defaults:
257
+ harness: codex
258
+ model: gpt-5.4
259
+ harness_options:
260
+ launcher: auto
261
+ profile: fleet # $CODEX_HOME/fleet.config.toml
262
+ sandbox: workspace-write
263
+ approval: on-request
264
+ monitor: true # explicit consent for unattended mail wake
265
+ search: true
266
+ add_dirs: [/data/shared]
267
+ config:
268
+ model_reasoning_effort: high
269
+
270
+ roles:
271
+ Reviewer:
272
+ harness_options:
273
+ sandbox: read-only # overrides just this default key
274
+ ```
275
+
276
+ Equivalent one-off/permanent spawn controls include `--model`, `--permission-mode`,
277
+ `--sandbox`, `--profile`, `--launcher`, `--search`, `--monitor`, repeatable
278
+ `--codex-config key=value`, and repeatable `--add-dir`. Use `env.OURS_PORT`/`env.OURS_CONFIG` for a
279
+ role-specific ours daemon, or configure the host default in `~/.ours/config.json`.
194
280
 
195
281
  ## Agent isolation
196
282
 
@@ -212,6 +298,8 @@ all invisible, ours messaging still works, no hard resource caps.
212
298
  unshares the network; `broker` keeps host networking so the loopback ours daemon
213
299
  stays reachable — full broker egress-hardening is a follow-up.
214
300
  - **`fs.read` / `fs.write`** — extra read-only / read-write binds on top of the durable set.
301
+ The durable harness credentials are selected automatically: `~/.claude*` for Claude Code,
302
+ or `~/.codex` plus read-only `~/.agents` for Codex.
215
303
  - **`resources`** — `mem` (→ `MemoryMax` + `MemorySwapMax=0`, a hard OOM bound),
216
304
  `cpu` cores (→ `CPUQuota`), `pids` (→ `TasksMax`). CPU degrades to a warning if the
217
305
  cpu cgroup controller isn't delegated (mem/pids still enforced).
@@ -233,7 +321,17 @@ npm run build
233
321
 
234
322
  Adding a harness = implementing `HarnessAdapter`
235
323
  (`src/harness/types.ts`) and registering it — see `src/harness/claude-code.ts`
236
- for the reference implementation.
324
+ and `src/harness/codex.ts` for reference implementations.
325
+
326
+ **Codex CLI resume note.** Codex assigns its own session id (there's no `--session-id`
327
+ equivalent to pin at creation), so resume uses `codex resume --last`, which
328
+ picks the most recently active session **in the role's `cwd`**. This works
329
+ cleanly as long as each role has its own `cwd` (the common case); two roles
330
+ sharing an identical `cwd` could have their resumes cross — give them distinct
331
+ working directories if that matters. MCP and monitor wiring is provided by
332
+ [`@ours.network/codex`](https://github.com/adapt-toolkit/ours-mcp/tree/main/packages/codex);
333
+ the native spawn/oversight skills are provided by `@ours.network/fleet-codex`.
334
+ `ours-fleet doctor --harness codex` verifies the CLI, ours plugin, and enhanced launcher/fallback.
237
335
 
238
336
  ## Learn more
239
337
 
package/dist/briefing.js CHANGED
@@ -34,14 +34,16 @@ export function generateBriefing(role, v, opts) {
34
34
  : ' with a 1–2 sentence summary of your Charter above. Skip if it already matches.');
35
35
  L.push(`5. SET your **persona** (local operating contract, never shared in invites) via`);
36
36
  L.push(` **${v.setPersonaTool}** with the **Charter** section above, verbatim. Skip if it matches.`);
37
- L.push(`6. ${v.monitorInstruction(id)}`);
37
+ L.push(`6. ${v.monitorInstruction(id, role)}`);
38
38
  if (role.coordinator) {
39
39
  L.push(`7. ANNOUNCE yourself: call **${v.sendTool}** to contact "${role.coordinator}" with text:`);
40
- L.push(` "${role.name} online — identity '${id}' bound, monitor armed, ready."`);
41
- L.push(`8. Await messages. When the monitor wakes you, call **${v.getMessagesTool}**, act, reply.`);
40
+ L.push(` "${role.name} online — identity '${id}' bound, ready."`);
41
+ L.push(`8. Await messages. When the monitor wakes you (or the owner requests a manual check),`);
42
+ L.push(` call **${v.getMessagesTool}**, act, and reply.`);
42
43
  }
43
44
  else {
44
- L.push(`7. Await messages. When the monitor wakes you, call **${v.getMessagesTool}**, act on them,`);
45
+ L.push(`7. Await messages. When the monitor wakes you (or the owner requests a manual check),`);
46
+ L.push(` call **${v.getMessagesTool}**, act on them,`);
45
47
  L.push(` and reply with ${v.sendTool}. No coordinator is configured — the owner drives you`);
46
48
  L.push(` via \`tmux attach -t ${role.name}\` or by messaging "${id}".`);
47
49
  }
@@ -67,8 +69,8 @@ export function generateBriefing(role, v, opts) {
67
69
  L.push('on messages, timers, or prompts — and follow it for recurring or scheduled work. It may');
68
70
  L.push('change between wakes without a restart; treat the file, not your memory of it, as current.');
69
71
  L.push('', '## On restart (you run under a supervised launcher)');
70
- L.push(`On restart, WITHOUT asking: re-bind (**${v.bindTool}** name "${id}" force=true),`);
71
- L.push(`re-arm the \`${v.watchCommand(id)}\` monitor, then continue from your WORKLOG.`);
72
+ L.push(`On restart, WITHOUT asking: re-bind (**${v.bindTool}** name "${id}" force=true), then`);
73
+ L.push(`${v.monitorInstruction(id, role)} Then continue from your WORKLOG.`);
72
74
  L.push('Do not blindly re-run whatever may have crashed you.');
73
75
  L.push('', '## House rules');
74
76
  L.push('- Never broad `rm -rf` on home/critical paths; quote globs; use explicit paths.');
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
2
  import './harness/claude-code.js';
3
+ import './harness/codex.js';
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { runOnce, runTemp } from './runner.js';
13
13
  import { spawnPermanent, spawnTemp } from './spawn.js';
14
14
  import { doctor } from './doctor.js';
15
15
  import './harness/claude-code.js'; // registers the claude-code adapter
16
+ import './harness/codex.js'; // registers the codex adapter
16
17
  // sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
17
18
  // --user child (supervisor commands, logs, doctor). Derive it before dispatch. (#9)
18
19
  deriveXdgRuntimeDir();
@@ -39,6 +40,26 @@ const program = new Command()
39
40
  .description('Fleet of persistent, identity-bound AI agents — harness-agnostic, tmux + systemd/launchd.')
40
41
  .version(VERSION);
41
42
  const cOpt = (cmd) => cmd.option('-c, --configuration <file>', 'config file (default: ~/fleet.yaml + ~/fleet.d/)');
43
+ const collect = (value, previous) => [...previous, value];
44
+ function parseCodexConfig(values) {
45
+ if (!values?.length)
46
+ return undefined;
47
+ const out = {};
48
+ for (const raw of values) {
49
+ const i = raw.indexOf('=');
50
+ if (i < 1)
51
+ throw new Error(`invalid --codex-config '${raw}'; expected key=value`);
52
+ const key = raw.slice(0, i);
53
+ const value = raw.slice(i + 1);
54
+ if (value === 'true' || value === 'false')
55
+ out[key] = value === 'true';
56
+ else if (value.trim() !== '' && Number.isFinite(Number(value)))
57
+ out[key] = Number(value);
58
+ else
59
+ out[key] = value;
60
+ }
61
+ return out;
62
+ }
42
63
  cOpt(program.command('config').description('validate + print the merged plan (no side effects)'))
43
64
  .action(opts => {
44
65
  try {
@@ -53,6 +74,8 @@ cOpt(program.command('config').description('validate + print the merged plan (no
53
74
  console.log(` cwd: ${r.cwd}`);
54
75
  if (r.model)
55
76
  console.log(` model: ${r.model}`);
77
+ if (r.harness_options && Object.keys(r.harness_options).length)
78
+ console.log(` options: ${JSON.stringify(r.harness_options)}`);
56
79
  if (r.coordinator)
57
80
  console.log(` coordinator: ${r.coordinator}`);
58
81
  if (r.mission)
@@ -164,16 +187,27 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
164
187
  .option('--cwd <dir>', 'working directory')
165
188
  .option('--coordinator <name>', 'announce target')
166
189
  .option('--model <id>', 'model id to launch on (e.g. claude-fable-5); default: launcher default')
190
+ .option('--permission-mode <mode>', 'harness permission mode (Codex: untrusted|on-request|never; Claude: native values)')
191
+ .option('--sandbox <mode>', 'Codex sandbox: read-only|workspace-write|danger-full-access')
192
+ .option('--profile <name>', 'Codex profile file name ($CODEX_HOME/<name>.config.toml)')
193
+ .option('--launcher <mode>', 'Codex launcher: auto|ours-codex|codex (default: auto)')
194
+ .option('--search', 'enable Codex live web search')
195
+ .option('--codex-config <key=value>', 'Codex config override (repeatable)', collect, [])
196
+ .option('--add-dir <dir>', 'additional Codex writable directory (repeatable)', collect, [])
197
+ .option('--monitor', 'explicitly consent to arm this Codex role\'s ours mail monitor')
167
198
  .option('--bio-file <file>', 'public bio (file)')
168
199
  .option('--persona-file <file>', 'persona / operating contract (file)')
169
200
  .action(async (name, opts) => {
170
- const o = {
171
- name, temp: opts.temp, harness: opts.harness, mission: opts.mission,
172
- identity: opts.identity, cwd: opts.cwd, coordinator: opts.coordinator,
173
- model: opts.model,
174
- bioFile: opts.bioFile, personaFile: opts.personaFile, configPath: opts.configuration,
175
- };
176
201
  try {
202
+ const o = {
203
+ name, temp: opts.temp, harness: opts.harness, mission: opts.mission,
204
+ identity: opts.identity, cwd: opts.cwd, coordinator: opts.coordinator,
205
+ model: opts.model,
206
+ permissionMode: opts.permissionMode, sandbox: opts.sandbox, profile: opts.profile,
207
+ launcher: opts.launcher, search: opts.search,
208
+ codexConfig: parseCodexConfig(opts.codexConfig), addDirs: opts.addDir, monitor: opts.monitor,
209
+ bioFile: opts.bioFile, personaFile: opts.personaFile, configPath: opts.configuration,
210
+ };
177
211
  if (o.temp) {
178
212
  const dir = await spawnTemp(o, binPath);
179
213
  console.log(`spawned temp agent '${name}' (state: ${dir}; gone on exit/reboot)`);
package/dist/config.js CHANGED
@@ -62,6 +62,17 @@ export function loadConfig(configPath) {
62
62
  if (bad.length)
63
63
  throw new ConfigError(`${file}: role '${name}' has unknown key(s) ${bad.join(', ')}; allowed: ${ROLE_KEYS.join(', ')}`);
64
64
  const isolation = r.isolation ?? defaults.isolation;
65
+ const defaultHarnessOptions = defaults.harness_options;
66
+ if (defaultHarnessOptions !== undefined
67
+ && (typeof defaultHarnessOptions !== 'object' || defaultHarnessOptions === null
68
+ || Array.isArray(defaultHarnessOptions)))
69
+ throw new ConfigError(`${base}: defaults.harness_options must be a map`);
70
+ const harnessOptions = defaultHarnessOptions === undefined && r.harness_options === undefined
71
+ ? undefined
72
+ : {
73
+ ...(defaultHarnessOptions ?? {}),
74
+ ...(r.harness_options ?? {}),
75
+ };
65
76
  if (isolation !== undefined) {
66
77
  const problems = validateIsolationConfig(isolation);
67
78
  if (problems.length)
@@ -75,6 +86,7 @@ export function loadConfig(configPath) {
75
86
  identity: r.identity ?? name,
76
87
  model: r.model ?? defaults.model,
77
88
  max_tokens: r.max_tokens ?? defaults.max_tokens,
89
+ harness_options: harnessOptions,
78
90
  isolation,
79
91
  });
80
92
  }
package/dist/doctor.js CHANGED
@@ -80,7 +80,12 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
80
80
  checks.push({ name: 'isolation: cgroup delegation', ok: true, detail: cgroupDelegationDetail() });
81
81
  for (const r of roles.filter(r => r.isolation)) {
82
82
  const stateDir = agentDir(r.name);
83
- const policy = resolveIsolation(r.isolation, { stateDir, runCwd: r.cwd ?? stateDir, home: home() });
83
+ const policy = resolveIsolation(r.isolation, {
84
+ stateDir, runCwd: r.cwd ?? stateDir, home: home(), harness: r.harness,
85
+ additionalWriteDirs: r.harness === 'codex'
86
+ ? (r.harness_options?.add_dirs ?? [])
87
+ : [],
88
+ });
84
89
  const caps = [
85
90
  policy.resources.mem && `mem=${policy.resources.mem}`,
86
91
  policy.resources.cpu && `cpu=${policy.resources.cpu}`,
@@ -96,7 +96,7 @@ export function makeClaudeCodeAdapter(exec = realExec) {
96
96
  const argv = mode === 'fresh'
97
97
  ? [...base, '--session-id', s.sessionId, `Read and follow ${join(stateDir, 'briefing.md')} now.`]
98
98
  : [...base, '--resume', s.sessionId,
99
- this.vocabulary.restartPrompt(role.identity, join(stateDir, 'WORKLOG.md'))];
99
+ this.vocabulary.restartPrompt(role.identity, join(stateDir, 'WORKLOG.md'), role)];
100
100
  return { argv, env: prep.env };
101
101
  },
102
102
  vocabulary: {
@@ -0,0 +1,4 @@
1
+ import { type Exec } from '../exec.js';
2
+ import type { HarnessAdapter } from './types.js';
3
+ export declare function makeCodexAdapter(exec?: Exec): HarnessAdapter;
4
+ export declare const codexAdapter: HarnessAdapter;
@@ -0,0 +1,223 @@
1
+ import { join } from 'node:path';
2
+ import { agentDir } from '../paths.js';
3
+ import { realExec } from '../exec.js';
4
+ import { registerAdapter } from './registry.js';
5
+ const OPTION_KEYS = [
6
+ 'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
7
+ 'monitor',
8
+ ];
9
+ const LAUNCHERS = ['auto', 'ours-codex', 'codex'];
10
+ /** Codex CLI's accepted `--sandbox` values. */
11
+ const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
12
+ /** Codex CLI's accepted `--ask-for-approval` values. */
13
+ const APPROVAL_POLICIES = ['untrusted', 'on-request', 'never'];
14
+ /** Resolve & validate the per-role sandbox mode, throwing on an unknown value. */
15
+ function sandboxMode(role) {
16
+ const s = role.harness_options?.sandbox;
17
+ if (s == null)
18
+ return undefined;
19
+ if (!SANDBOX_MODES.includes(s))
20
+ throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
21
+ return s;
22
+ }
23
+ /** Resolve & validate the per-role approval policy, throwing on an unknown value. */
24
+ function approvalPolicy(role) {
25
+ const o = role.harness_options;
26
+ const a = o?.approval ?? o?.permission_mode;
27
+ if (a == null)
28
+ return undefined;
29
+ if (!APPROVAL_POLICIES.includes(a))
30
+ throw new Error(`invalid harness_options.approval "${a}"; allowed: ${APPROVAL_POLICIES.join(', ')}`);
31
+ return a;
32
+ }
33
+ function launcherMode(role) {
34
+ return role.harness_options?.launcher ?? 'auto';
35
+ }
36
+ function encodeTomlValue(value) {
37
+ if (typeof value === 'string')
38
+ return JSON.stringify(value);
39
+ if (typeof value === 'boolean')
40
+ return String(value);
41
+ if (typeof value === 'number' && Number.isFinite(value))
42
+ return String(value);
43
+ if (Array.isArray(value) && value.every(v => ['string', 'boolean', 'number'].includes(typeof v)))
44
+ return `[${value.map(encodeTomlValue).join(', ')}]`;
45
+ throw new Error('must be a string, finite number, boolean, or array of those values');
46
+ }
47
+ /** Flags shared by fresh launch and resume: model, sandbox, approval, search. */
48
+ function commonFlags(role) {
49
+ const o = (role.harness_options ?? {});
50
+ const search = o.search === true;
51
+ const sm = sandboxMode(role);
52
+ const ap = approvalPolicy(role);
53
+ return [
54
+ ...(role.model ? ['--model', role.model] : []),
55
+ ...(o.profile ? ['--profile', o.profile] : []),
56
+ ...(sm ? ['--sandbox', sm] : []),
57
+ ...(ap ? ['--ask-for-approval', ap] : []),
58
+ ...(search ? ['--search'] : []),
59
+ ...(o.add_dirs ?? []).flatMap(dir => ['--add-dir', dir]),
60
+ ...Object.entries(o.config ?? {}).flatMap(([key, value]) => ['--config', `${key}=${encodeTomlValue(value)}`]),
61
+ ];
62
+ }
63
+ async function commandAvailable(command, exec) {
64
+ const r = await exec('sh', ['-c', `command -v ${command} >/dev/null 2>&1`]);
65
+ return r.code === 0;
66
+ }
67
+ function hasInstalledOursPlugin(output) {
68
+ try {
69
+ const value = JSON.parse(output);
70
+ return (value.installed ?? []).some(plugin => (plugin.name === 'ours' || (typeof plugin.pluginId === 'string' && plugin.pluginId.startsWith('ours@')))
71
+ && plugin.installed === true
72
+ && plugin.enabled === true);
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ export function makeCodexAdapter(exec = realExec) {
79
+ return {
80
+ id: 'codex',
81
+ supportsResume: true,
82
+ async checkPrereqs() {
83
+ const [r, hasOursCodex, plugins] = await Promise.all([
84
+ exec('codex', ['--version']),
85
+ commandAvailable('ours-codex', exec),
86
+ exec('codex', ['plugin', 'list', '--json']),
87
+ ]);
88
+ const ok = r.code === 0;
89
+ const hasOursPlugin = plugins.code === 0 && hasInstalledOursPlugin(plugins.stdout);
90
+ return {
91
+ ok: ok && hasOursPlugin,
92
+ checks: [
93
+ {
94
+ name: 'codex',
95
+ ok,
96
+ detail: ok ? r.stdout.trim() : 'codex CLI not found on PATH — install the Codex CLI and log in',
97
+ },
98
+ {
99
+ name: 'ours-codex',
100
+ // Optional by design: plain Codex is the supported fallback.
101
+ ok: true,
102
+ detail: hasOursCodex
103
+ ? 'available — fleet roles use native background mail wake'
104
+ : 'not found — fleet roles fall back to codex with foreground monitoring; install @ours.network/codex for background wake',
105
+ },
106
+ {
107
+ name: 'ours plugin',
108
+ ok: hasOursPlugin,
109
+ detail: hasOursPlugin
110
+ ? 'installed and enabled — ours tools and monitor tools are available'
111
+ : 'not installed — run: npm i -g @ours.network/codex && ours-codex-install',
112
+ },
113
+ ],
114
+ };
115
+ },
116
+ validateOptions(opts) {
117
+ if (opts == null)
118
+ return [];
119
+ if (typeof opts !== 'object' || Array.isArray(opts))
120
+ return [{ path: 'harness_options', message: 'must be a map' }];
121
+ const errs = Object.keys(opts)
122
+ .filter(k => !OPTION_KEYS.includes(k))
123
+ .map(k => ({ path: `harness_options.${k}`, message: `unknown option; allowed: ${OPTION_KEYS.join(', ')}` }));
124
+ const o = opts;
125
+ if (o.launcher != null && !LAUNCHERS.includes(o.launcher))
126
+ errs.push({ path: 'harness_options.launcher', message: `must be one of: ${LAUNCHERS.join(', ')}` });
127
+ if (o.sandbox != null && !SANDBOX_MODES.includes(o.sandbox))
128
+ errs.push({ path: 'harness_options.sandbox', message: `must be one of: ${SANDBOX_MODES.join(', ')}` });
129
+ if (o.approval != null && !APPROVAL_POLICIES.includes(o.approval))
130
+ errs.push({ path: 'harness_options.approval', message: `must be one of: ${APPROVAL_POLICIES.join(', ')}` });
131
+ if (o.permission_mode != null && !APPROVAL_POLICIES.includes(o.permission_mode))
132
+ errs.push({ path: 'harness_options.permission_mode', message: `must be one of: ${APPROVAL_POLICIES.join(', ')}` });
133
+ if (o.approval != null && o.permission_mode != null && o.approval !== o.permission_mode)
134
+ errs.push({ path: 'harness_options.permission_mode', message: 'conflicts with harness_options.approval' });
135
+ if (o.search != null && typeof o.search !== 'boolean')
136
+ errs.push({ path: 'harness_options.search', message: 'must be a boolean' });
137
+ if (o.monitor != null && typeof o.monitor !== 'boolean')
138
+ errs.push({ path: 'harness_options.monitor', message: 'must be a boolean' });
139
+ if (o.profile != null && (typeof o.profile !== 'string' || !o.profile.trim()))
140
+ errs.push({ path: 'harness_options.profile', message: 'must be a non-empty profile name' });
141
+ if (o.add_dirs != null && (!Array.isArray(o.add_dirs) || o.add_dirs.some(v => typeof v !== 'string' || !v)))
142
+ errs.push({ path: 'harness_options.add_dirs', message: 'must be an array of non-empty paths' });
143
+ if (o.config != null) {
144
+ if (typeof o.config !== 'object' || Array.isArray(o.config))
145
+ errs.push({ path: 'harness_options.config', message: 'must be a map of Codex config keys to TOML scalar/array values' });
146
+ else
147
+ for (const [key, value] of Object.entries(o.config)) {
148
+ try {
149
+ encodeTomlValue(value);
150
+ }
151
+ catch (e) {
152
+ errs.push({ path: `harness_options.config.${key}`, message: e.message });
153
+ }
154
+ }
155
+ }
156
+ return errs;
157
+ },
158
+ async prepareSession(role, _dirs) {
159
+ const requested = launcherMode(role);
160
+ const hasOursCodex = await commandAvailable('ours-codex', exec);
161
+ if (requested === 'ours-codex' && !hasOursCodex)
162
+ throw new Error('harness_options.launcher is ours-codex, but ours-codex is not on PATH; install @ours.network/codex or use launcher: auto');
163
+ const command = requested === 'codex' ? 'codex' : hasOursCodex ? 'ours-codex' : 'codex';
164
+ return { argv: [], env: {}, command };
165
+ },
166
+ buildLaunch(role, mode, _s, prep) {
167
+ const stateDir = roleStateDir(role);
168
+ const flags = commonFlags(role);
169
+ const command = prep.command ?? 'codex';
170
+ const argv = mode === 'fresh'
171
+ ? [command, ...flags, ...prep.argv, `Read and follow ${join(stateDir, 'briefing.md')} now.`]
172
+ : [command, 'resume', '--last', ...flags, ...prep.argv,
173
+ this.vocabulary.restartPrompt(role.identity, join(stateDir, 'WORKLOG.md'), role)];
174
+ return { argv, env: prep.env };
175
+ },
176
+ vocabulary: {
177
+ bindTool: 'choose_identity',
178
+ createTool: 'create_identity',
179
+ setBioTool: 'set_bio',
180
+ setPersonaTool: 'set_persona',
181
+ currentIdentityTool: 'current_identity',
182
+ sendTool: 'send_message',
183
+ getMessagesTool: 'get_messages',
184
+ watchCommand: id => `ours-mcp watch "${id}"`,
185
+ monitorInstruction: (id, configuredRole) => {
186
+ const consented = configuredRole?.harness_options?.monitor === true;
187
+ const consent = consented
188
+ ? `The fleet owner explicitly consented in configuration with \`harness_options.monitor: true\`. ` +
189
+ `Call **arm_monitor** for identity "${id}" after binding. `
190
+ : `Ask the fleet owner in this console whether to arm mail monitoring for "${id}". ` +
191
+ `Do not call **arm_monitor** until they explicitly say yes; if they decline, leave it ` +
192
+ `disarmed and check mail only when asked. `;
193
+ return consent +
194
+ `Under \`ours-codex\` this arms session-scoped background wake. If the tool reports that ` +
195
+ `only standard Codex is available, surface its \`ours-codex\` recommendation and ask ` +
196
+ `separately before calling **foreground_monitor**; that blocking wait is the supported ` +
197
+ `fallback. After each arrival, call **get_messages**, handle the mail, and re-enter ` +
198
+ `**foreground_monitor** while the approved monitoring session remains armed.`;
199
+ },
200
+ launchNote: name => `You were launched as the fleet role \`${name}\` under a Codex session. Confirm you are running.`,
201
+ restartPrompt: (id, worklog, configuredRole) => {
202
+ const consented = configuredRole?.harness_options?.monitor === true;
203
+ return `Session restarted. Re-bind your ours identity now (choose_identity name "${id}" force=true), ` +
204
+ (consented
205
+ ? `then call arm_monitor for "${id}"; monitor consent is persisted in fleet configuration. `
206
+ : `then ask the fleet owner before arming monitoring for "${id}". `) +
207
+ `If this is the native-codex fallback, follow the tool's foreground_monitor consent flow and ` +
208
+ `re-enter it after handling each message. Continue from ${worklog}. Do not re-run whatever crashed you.`;
209
+ },
210
+ },
211
+ exitPolicy: { cleanExitIsFresh: true, fastFailSecs: 20 },
212
+ };
213
+ }
214
+ // The adapter needs the state dir for briefing/worklog paths in launch prompts.
215
+ // Roles' state dirs are canonical: agentDir(name) — temp roles carry their dir in cwd handling
216
+ // by the runner, which passes dirs to prepareSession; buildLaunch derives from the same rule.
217
+ function roleStateDir(role) {
218
+ // Temp roles are marked by the runner via a private field to keep the interface small.
219
+ const temp = role.__temp === true;
220
+ return agentDir(role.name, temp);
221
+ }
222
+ export const codexAdapter = makeCodexAdapter();
223
+ registerAdapter(codexAdapter);
@@ -15,10 +15,12 @@ export interface RoleDirs {
15
15
  export interface SessionState {
16
16
  sessionId: string;
17
17
  }
18
- /** Extra argv/env contributed by prepareSession (overlays, trust, limits). */
18
+ /** Extra command/argv/env contributed by prepareSession (overlays, trust, limits). */
19
19
  export interface SessionPrep {
20
20
  argv: string[];
21
21
  env: Record<string, string>;
22
+ /** Optional launcher selected after runtime prerequisite probing. */
23
+ command?: string;
22
24
  }
23
25
  export interface Launch {
24
26
  argv: string[];
@@ -34,9 +36,9 @@ export interface BriefingVocab {
34
36
  sendTool: string;
35
37
  getMessagesTool: string;
36
38
  watchCommand(identity: string): string;
37
- monitorInstruction(identity: string): string;
39
+ monitorInstruction(identity: string, role?: ResolvedRole): string;
38
40
  launchNote(name: string): string;
39
- restartPrompt(identity: string, worklogPath: string): string;
41
+ restartPrompt(identity: string, worklogPath: string, role?: ResolvedRole): string;
40
42
  }
41
43
  export interface ExitPolicy {
42
44
  cleanExitIsFresh: boolean;
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry } from './conf
3
3
  export type { HarnessAdapter, BriefingVocab, ExitPolicy, PrereqReport, PrereqCheck, SessionPrep, SessionState, Launch, RoleDirs, ValidationError, } from './harness/types.js';
4
4
  export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
5
5
  export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
6
+ export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
6
7
  export { generateBriefing } from './briefing.js';
7
8
  export { pickBackend } from './supervisor/index.js';
8
9
  export type { SupervisorBackend } from './supervisor/types.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { loadConfig, findRole, ConfigError } from './config.js';
2
2
  export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
3
3
  export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
4
+ export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
4
5
  export { generateBriefing } from './briefing.js';
5
6
  export { pickBackend } from './supervisor/index.js';
6
7
  export { up, down, restartRoles, rmRole, applyRole } from './ops.js';
@@ -75,11 +75,20 @@ export function resolveIsolation(cfg, ctx) {
75
75
  mounts.push({ src: p, dst: p, mode: 'rw' }); };
76
76
  const addRo = (p) => { if (!mounts.some(m => m.src === p))
77
77
  mounts.push({ src: p, dst: p, mode: 'ro' }); };
78
- // Durable set (always present, rw): state dir, cwd, Claude config.
78
+ // Durable set: state dir + cwd, then only the active harness's config/auth roots.
79
79
  addRw(stateDir);
80
80
  addRw(runCwd);
81
- addRw(join(home, '.claude'));
82
- addRw(join(home, '.claude.json'));
81
+ if (ctx.harness === 'codex') {
82
+ addRw(join(home, '.codex'));
83
+ addRo(join(home, '.agents'));
84
+ }
85
+ else {
86
+ // Preserve the historical default for callers that predate harness-aware contexts.
87
+ addRw(join(home, '.claude'));
88
+ addRw(join(home, '.claude.json'));
89
+ }
90
+ for (const dir of ctx.additionalWriteDirs ?? [])
91
+ addRw(dir);
83
92
  // Declared fs extras.
84
93
  for (const p of cfg.fs?.write ?? [])
85
94
  addRw(p);
@@ -46,6 +46,10 @@ export interface WrapContext {
46
46
  stateDir: string;
47
47
  runCwd: string;
48
48
  home: string;
49
+ /** Harness selects the minimum credential/config mounts needed by that CLI. */
50
+ harness?: string;
51
+ /** Harness-declared writable roots (for example Codex --add-dir). */
52
+ additionalWriteDirs?: string[];
49
53
  brokerEndpoint?: string;
50
54
  }
51
55
  /**
package/dist/runner.js CHANGED
@@ -72,7 +72,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
72
72
  // env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
73
73
  let paneArgv = launch.argv;
74
74
  if (role.isolation) {
75
- const ctx = { stateDir: dir, runCwd, home: home() };
75
+ const addDirs = role.harness === 'codex'
76
+ ? (role.harness_options?.add_dirs ?? [])
77
+ : [];
78
+ const ctx = {
79
+ stateDir: dir, runCwd, home: home(), harness: role.harness, additionalWriteDirs: addDirs,
80
+ };
76
81
  const policy = resolveIsolation(role.isolation, ctx);
77
82
  const sel = await selectIsolationBackend(policy, deps.exec); // throws on strict + unavailable
78
83
  const degradedMarker = join(dir, '.isolation-degraded');
package/dist/spawn.d.ts CHANGED
@@ -8,6 +8,14 @@ export interface SpawnOpts {
8
8
  cwd?: string;
9
9
  coordinator?: string;
10
10
  model?: string;
11
+ permissionMode?: string;
12
+ sandbox?: string;
13
+ profile?: string;
14
+ launcher?: string;
15
+ search?: boolean;
16
+ codexConfig?: Record<string, string | number | boolean>;
17
+ addDirs?: string[];
18
+ monitor?: boolean;
11
19
  bioFile?: string;
12
20
  personaFile?: string;
13
21
  overseeInterval?: string;
package/dist/spawn.js CHANGED
@@ -5,7 +5,7 @@ import { stringify } from 'yaml';
5
5
  import { agentDir, fleetDDir } from './paths.js';
6
6
  import { loadConfig } from './config.js';
7
7
  import { applyRole, up } from './ops.js';
8
- function roleFromOpts(o) {
8
+ function roleFromOpts(o, defaultHarness) {
9
9
  const r = {};
10
10
  if (o.harness)
11
11
  r.harness = o.harness;
@@ -19,6 +19,26 @@ function roleFromOpts(o) {
19
19
  r.mission = o.mission;
20
20
  if (o.model?.trim())
21
21
  r.model = o.model.trim();
22
+ const harness = o.harness ?? defaultHarness;
23
+ const harnessOptions = {};
24
+ if (o.permissionMode)
25
+ harnessOptions[harness === 'claude-code' ? 'permission_mode' : 'approval'] = o.permissionMode;
26
+ if (o.sandbox)
27
+ harnessOptions.sandbox = o.sandbox;
28
+ if (o.profile)
29
+ harnessOptions.profile = o.profile;
30
+ if (o.launcher)
31
+ harnessOptions.launcher = o.launcher;
32
+ if (o.search === true)
33
+ harnessOptions.search = true;
34
+ if (o.codexConfig && Object.keys(o.codexConfig).length)
35
+ harnessOptions.config = o.codexConfig;
36
+ if (o.addDirs?.length)
37
+ harnessOptions.add_dirs = o.addDirs;
38
+ if (o.monitor === true)
39
+ harnessOptions.monitor = true;
40
+ if (Object.keys(harnessOptions).length)
41
+ r.harness_options = harnessOptions;
22
42
  if (o.bioFile)
23
43
  r.bio = readFileSync(o.bioFile, 'utf8').trim();
24
44
  if (o.personaFile)
@@ -35,9 +55,12 @@ function assertNameFree(o) {
35
55
  /** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
36
56
  export async function spawnPermanent(o, deps) {
37
57
  assertNameFree(o);
58
+ const cfg = loadConfig(o.configPath);
38
59
  mkdirSync(fleetDDir(), { recursive: true });
39
60
  const file = join(fleetDDir(), `${o.name}.yaml`);
40
- writeFileSync(file, stringify({ roles: { [o.name]: roleFromOpts(o) } }));
61
+ writeFileSync(file, stringify({
62
+ roles: { [o.name]: roleFromOpts(o, cfg.defaults.harness) },
63
+ }));
41
64
  await up(loadConfig(o.configPath), [o.name], deps);
42
65
  return file;
43
66
  }
@@ -54,12 +77,19 @@ const detachedSupervisor = (binPath, args, dir) => {
54
77
  export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
55
78
  assertNameFree(o);
56
79
  const cfg = loadConfig(o.configPath);
80
+ const defaultHarness = cfg.defaults.harness;
81
+ const fromOpts = roleFromOpts(o, defaultHarness);
82
+ const mergedHarnessOptions = {
83
+ ...(cfg.defaults.harness_options ?? {}),
84
+ ...(fromOpts.harness_options ?? {}),
85
+ };
57
86
  const role = {
58
- ...roleFromOpts(o),
87
+ ...fromOpts,
59
88
  name: o.name,
60
- harness: o.harness ?? cfg.defaults.harness ?? 'claude-code',
89
+ harness: o.harness ?? defaultHarness ?? 'claude-code',
61
90
  identity: o.identity ?? o.name,
62
91
  model: o.model?.trim() || cfg.defaults.model,
92
+ harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
63
93
  sourceFile: '(temp)',
64
94
  };
65
95
  const dir = applyRole(role, { temp: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux consoles, systemd/launchd supervision, ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",