@ours.network/fleet 0.9.4 → 0.10.0-nightly.2
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 +31 -11
- package/dist/cli.js +146 -13
- package/dist/config.d.ts +24 -0
- package/dist/config.js +79 -1
- package/dist/doctor.js +35 -6
- package/dist/harness/claude-code.js +30 -1
- package/dist/harness/codex.js +39 -2
- package/dist/harness/types.d.ts +12 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/monitor.d.ts +16 -1
- package/dist/monitor.js +121 -24
- package/dist/runner.js +73 -18
- package/dist/session/acp.d.ts +49 -0
- package/dist/session/acp.js +280 -0
- package/dist/session/control.d.ts +41 -0
- package/dist/session/control.js +218 -0
- package/dist/session/events.d.ts +14 -0
- package/dist/session/events.js +67 -0
- package/dist/session/tmux.d.ts +20 -0
- package/dist/session/tmux.js +46 -0
- package/dist/session/types.d.ts +47 -0
- package/dist/session/types.js +1 -0
- package/dist/spawn.d.ts +5 -0
- package/dist/spawn.js +24 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@ harnesses — from one declarative file.**
|
|
|
8
8
|
An AI coding agent in a terminal dies when you close the laptop. `ours-fleet`
|
|
9
9
|
turns such sessions into **roles**: long-lived agents that
|
|
10
10
|
|
|
11
|
-
- **
|
|
12
|
-
|
|
11
|
+
- **run through a selectable session backend** — existing detached tmux consoles
|
|
12
|
+
or structured ACP sessions — which you can attach to, peek at, or prompt,
|
|
13
13
|
- are **supervised** — systemd (Linux) or launchd (macOS) restarts them on crash
|
|
14
14
|
and brings them back after a reboot,
|
|
15
15
|
- **resume their context** across restarts (when the harness supports it),
|
|
@@ -44,7 +44,8 @@ roles:
|
|
|
44
44
|
~/fleet.yaml + ~/fleet.d/*.yaml your declaration
|
|
45
45
|
│ ours-fleet up
|
|
46
46
|
▼
|
|
47
|
-
|
|
47
|
+
briefing.md per role ──► tmux session ──► harness CLI (claude …)
|
|
48
|
+
└─► ACP client ──► ACP agent (codex-acp …)
|
|
48
49
|
▲ │
|
|
49
50
|
systemd --user / launchd ───────┘ restart on crash, start at boot/login
|
|
50
51
|
```
|
|
@@ -65,13 +66,16 @@ The state dir contract:
|
|
|
65
66
|
| `WORKLOG.md` | the agent | seeded empty, agent-appended; survives restarts |
|
|
66
67
|
| `ROUTINES.md` | operator / agent | **optional** recurring-work instructions; re-read at the start of every wake, hot-editable **without a restart**; absence means "no routines" |
|
|
67
68
|
| `.identity`, `.cwd`, `.session-id`, `.booted`, `.exit-status`, `.config-path` | supervisor | dot-marker state — session resume and boot bookkeeping |
|
|
69
|
+
| `.monitor-state.json`, `.monitor-status` | supervisor monitor | atomic body-free cursor/pending state and health |
|
|
70
|
+
| `.session-events.jsonl`, `.control.sock`, `.control-token` | ACP backend | bounded typed console projection and private attachment control |
|
|
68
71
|
|
|
69
72
|
## Prerequisites
|
|
70
73
|
|
|
71
74
|
| What | Why | Install |
|
|
72
75
|
|---|---|---|
|
|
73
76
|
| Node ≥ 20 | runs `ours-fleet` itself | nodejs.org, `apt`, or `brew` |
|
|
74
|
-
| tmux |
|
|
77
|
+
| tmux | roles using `session: tmux` (the default) | `apt install tmux` / `brew install tmux` |
|
|
78
|
+
| an ACP adapter | roles using `session: acp` | `codex-acp` or `claude-agent-acp` |
|
|
75
79
|
| a harness CLI, logged in | the agent itself | e.g. Claude Code (`claude`) or Codex CLI (`codex`) |
|
|
76
80
|
| `ours-mcp` daemon | identity + agent-to-agent messaging | `npm i -g @ours.network/mcp && ours-mcp start` |
|
|
77
81
|
|
|
@@ -112,7 +116,7 @@ ours-fleet spawn --temp Scout --mission "one-off research" # gone on exit/rebo
|
|
|
112
116
|
|
|
113
117
|
# Codex role: ours-codex is preferred automatically; plain codex is the fallback
|
|
114
118
|
ours-fleet spawn Coder --harness codex --model gpt-5.4 \
|
|
115
|
-
--
|
|
119
|
+
--session acp --approval ask --filesystem workspace \
|
|
116
120
|
--profile fleet --search --monitor --coordinator FleetCoordinator
|
|
117
121
|
```
|
|
118
122
|
|
|
@@ -165,7 +169,7 @@ ours-fleet up|down|restart|force-restart [-c FILE] [Name...]
|
|
|
165
169
|
ours-fleet config [-c FILE] validate + print merged plan
|
|
166
170
|
ours-fleet ls | attach | peek | logs [-f] | status <Name>
|
|
167
171
|
ours-fleet send <Name> "text" | --key <K>
|
|
168
|
-
ours-fleet spawn [--temp] <Name> [--harness --mission --model --
|
|
172
|
+
ours-fleet spawn [--temp] <Name> [--harness --session --mission --model --approval ...]
|
|
169
173
|
ours-fleet rm <Name>
|
|
170
174
|
ours-fleet doctor [--harness H]
|
|
171
175
|
ours-fleet init
|
|
@@ -183,6 +187,11 @@ vars: { work_root: /home/me/work } # ${var} substitution anywhere below
|
|
|
183
187
|
start_stagger_ms: 0 # delay between agent LAUNCHES (host-wide, ms); 0 = no stagger
|
|
184
188
|
defaults:
|
|
185
189
|
harness: claude-code # for roles that don't set one
|
|
190
|
+
session: tmux # tmux (default) | acp
|
|
191
|
+
permissions: # common intent, translated by each harness/backend
|
|
192
|
+
approval: ask # ask | allow | deny
|
|
193
|
+
filesystem: workspace # read-only | workspace | unrestricted
|
|
194
|
+
unattended: deny # deny | wait
|
|
186
195
|
model: claude-fable-5 # default model for roles that don't set one (per-role model / --model wins)
|
|
187
196
|
max_tokens: 500000 # session cap (harness-interpreted)
|
|
188
197
|
monitor: # supervisor-owned mail wake (fleet-wide default)
|
|
@@ -190,6 +199,10 @@ defaults:
|
|
|
190
199
|
roles:
|
|
191
200
|
Name: # [A-Za-z0-9_-]+
|
|
192
201
|
harness: claude-code
|
|
202
|
+
session: acp # one flag selects ACP; omit for tmux
|
|
203
|
+
session_options:
|
|
204
|
+
acp:
|
|
205
|
+
command: claude-agent-acp # optional advanced override
|
|
193
206
|
identity: "Display Name" # ours identity to bind (default: Name)
|
|
194
207
|
cwd: ${work_root}/repo # where the harness process runs
|
|
195
208
|
coordinator: FleetCoordinator # announce target on boot
|
|
@@ -236,7 +249,9 @@ roles:
|
|
|
236
249
|
|
|
237
250
|
Merge order: `fleet.yaml` ← `fleet.d/*.yaml`; a duplicate role name is a hard
|
|
238
251
|
error naming both files. Identities and roles are decoupled — removing a role
|
|
239
|
-
never deletes an identity. `
|
|
252
|
+
never deletes an identity. `session` is independent of `harness`, so changing a
|
|
253
|
+
role from tmux to ACP does not change its identity, mission, monitor, or permission
|
|
254
|
+
contract. `defaults.harness_options` is shallow-merged with each
|
|
240
255
|
role's `harness_options`, so a fleet can set common Codex permission/profile defaults
|
|
241
256
|
and override individual keys per role. `monitor` merges the same way — a role block
|
|
242
257
|
overrides `defaults.monitor` key-by-key.
|
|
@@ -263,22 +278,27 @@ their boots ~4 s apart instead of firing all seven at once.
|
|
|
263
278
|
|
|
264
279
|
With `monitor.enabled` (the default), the **supervisor** delivers a role's mail
|
|
265
280
|
wakes: the per-role runner long-polls the ours daemon's notification API and
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
281
|
+
submits a single `[fleet-monitor] N new messages from … — run get_messages` prompt
|
|
282
|
+
through the selected backend. ACP uses structured `session/prompt`; tmux uses
|
|
283
|
+
verified console input. It primes the notification cursor *before* the session launches
|
|
269
284
|
(no missed arrivals), cannot be orphaned or left deaf-but-armed, and writes its
|
|
270
285
|
health to `<agentDir>/.monitor-status` (`armed | degraded | failed`), surfaced in
|
|
271
286
|
`ours-fleet status`/`doctor`. Injection is held while the pane shows a modal dialog,
|
|
272
287
|
so an injected wake can never answer a trust/permission prompt; if the dialog is
|
|
273
288
|
still up after 2 minutes the monitor gives up on that wake and records
|
|
274
289
|
`degraded: modal wedge …` instead of waiting silently forever (the mail stays
|
|
275
|
-
queued
|
|
290
|
+
queued and its cursor is not committed until a later delivery is accepted). The agent's
|
|
276
291
|
briefing tells it **not** to arm an
|
|
277
292
|
in-session Monitor. Set `monitor.enabled: false` to keep the legacy behavior where
|
|
278
293
|
the agent arms its own `ours-mcp watch`. `inject: full` (pushing message bodies
|
|
279
294
|
inline) is on the roadmap and needs two new ours-mcp daemon endpoints; today all
|
|
280
295
|
roles deliver `notification` lines and drain via `get_messages`.
|
|
281
296
|
|
|
297
|
+
ACP stdio remains private to the persistent runner. `send`, `peek`, and the basic
|
|
298
|
+
ACP `attach` console use a private, authenticated per-role control socket with
|
|
299
|
+
typed replayable events. This is also the stable extension boundary for a richer
|
|
300
|
+
console later; no terminal UI is part of the monitor or session backend.
|
|
301
|
+
|
|
282
302
|
## Codex roles
|
|
283
303
|
|
|
284
304
|
Install Codex, the native ours plugin, and the fleet skills once on the fleet host:
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn as spawnChild } from 'node:child_process';
|
|
3
|
-
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { join as joinPath } from 'node:path';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
5
7
|
import { Command } from 'commander';
|
|
6
8
|
import { VERSION } from './version.js';
|
|
7
|
-
import { agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir } from './paths.js';
|
|
9
|
+
import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir } from './paths.js';
|
|
8
10
|
import { loadConfig } from './config.js';
|
|
9
11
|
import { Tmux } from './tmux.js';
|
|
10
12
|
import { pickBackend } from './supervisor/index.js';
|
|
@@ -12,6 +14,7 @@ import { up, down, restartRoles, rmRole } from './ops.js';
|
|
|
12
14
|
import { runOnce, runTemp } from './runner.js';
|
|
13
15
|
import { spawnPermanent, spawnTemp } from './spawn.js';
|
|
14
16
|
import { doctor } from './doctor.js';
|
|
17
|
+
import { controlRequest, controlSocketPath, followControl, } from './session/control.js';
|
|
15
18
|
import './harness/claude-code.js'; // registers the claude-code adapter
|
|
16
19
|
import './harness/codex.js'; // registers the codex adapter
|
|
17
20
|
// sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
|
|
@@ -40,6 +43,40 @@ const program = new Command()
|
|
|
40
43
|
.version(VERSION);
|
|
41
44
|
const cOpt = (cmd) => cmd.option('-c, --configuration <file>', 'config file (default: ~/fleet.yaml + ~/fleet.d/)');
|
|
42
45
|
const collect = (value, previous) => [...previous, value];
|
|
46
|
+
function acpStateDir(name) {
|
|
47
|
+
const permanent = agentDir(name);
|
|
48
|
+
if (existsSync(controlSocketPath(permanent)))
|
|
49
|
+
return permanent;
|
|
50
|
+
const temp = agentDir(name, true);
|
|
51
|
+
if (existsSync(controlSocketPath(temp)))
|
|
52
|
+
return temp;
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
function renderSessionEvent(event) {
|
|
56
|
+
switch (event.kind) {
|
|
57
|
+
case 'agent_text':
|
|
58
|
+
process.stdout.write(event.text ?? '');
|
|
59
|
+
break;
|
|
60
|
+
case 'thought': break;
|
|
61
|
+
case 'tool_call':
|
|
62
|
+
case 'tool_update':
|
|
63
|
+
console.log(`\n[${event.kind}] ${event.title ?? event.toolCallId ?? ''} ${event.status ?? ''}`.trimEnd());
|
|
64
|
+
break;
|
|
65
|
+
case 'permission':
|
|
66
|
+
console.log(`\n[permission ${event.permissionId}] ${event.title ?? ''}`);
|
|
67
|
+
for (const option of event.options ?? [])
|
|
68
|
+
console.log(` ${option.optionId}: ${option.name} (${option.kind})`);
|
|
69
|
+
console.log(' respond: /permit <permission-id> <option-id>');
|
|
70
|
+
break;
|
|
71
|
+
case 'turn_stop':
|
|
72
|
+
console.log(`\n[turn stopped: ${event.stopReason ?? 'unknown'}]`);
|
|
73
|
+
break;
|
|
74
|
+
case 'error':
|
|
75
|
+
console.error(`\n[error] ${event.text ?? ''}`);
|
|
76
|
+
break;
|
|
77
|
+
case 'state': break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
43
80
|
function parseCodexConfig(values) {
|
|
44
81
|
if (!values?.length)
|
|
45
82
|
return undefined;
|
|
@@ -67,6 +104,7 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
67
104
|
for (const r of cfg.roles) {
|
|
68
105
|
console.log(`\n● ${r.name}`);
|
|
69
106
|
console.log(` harness: ${r.harness}`);
|
|
107
|
+
console.log(` session: ${r.session}`);
|
|
70
108
|
console.log(` identity: ${r.identity}`);
|
|
71
109
|
console.log(` source: ${r.sourceFile}`);
|
|
72
110
|
if (r.cwd)
|
|
@@ -133,14 +171,81 @@ cOpt(program.command('force-restart [names...]').description('re-sync + bounce F
|
|
|
133
171
|
die(e);
|
|
134
172
|
}
|
|
135
173
|
});
|
|
136
|
-
program.command('ls').description('list running
|
|
137
|
-
.action(async () => {
|
|
174
|
+
program.command('ls').description('list running fleet sessions')
|
|
175
|
+
.action(async () => {
|
|
176
|
+
const tmux = await new Tmux().list();
|
|
177
|
+
const acp = [];
|
|
178
|
+
for (const root of [agentsRoot(), tmpRoot()]) {
|
|
179
|
+
if (!existsSync(root))
|
|
180
|
+
continue;
|
|
181
|
+
for (const name of readdirSync(root)) {
|
|
182
|
+
const stateDir = joinPath(root, name);
|
|
183
|
+
if (!existsSync(controlSocketPath(stateDir)))
|
|
184
|
+
continue;
|
|
185
|
+
try {
|
|
186
|
+
const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
|
|
187
|
+
if (response.ok && response.result?.alive)
|
|
188
|
+
acp.push(`${name}: acp`);
|
|
189
|
+
}
|
|
190
|
+
catch { /* ignore stale sockets */ }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
console.log([tmux, ...acp].filter(Boolean).join('\n') || '(none)');
|
|
194
|
+
});
|
|
138
195
|
program.command('attach <name>').description('open the live console (Ctrl-b d to leave)')
|
|
139
|
-
.action(async (name) =>
|
|
196
|
+
.action(async (name) => {
|
|
197
|
+
const stateDir = acpStateDir(name);
|
|
198
|
+
if (!stateDir)
|
|
199
|
+
process.exit(await passthrough('tmux', ['attach', '-t', name]));
|
|
200
|
+
try {
|
|
201
|
+
const { socket, send } = await followControl(stateDir, message => {
|
|
202
|
+
if ('event' in message)
|
|
203
|
+
renderSessionEvent(message.event);
|
|
204
|
+
const result = message.result;
|
|
205
|
+
for (const event of result?.events ?? [])
|
|
206
|
+
renderSessionEvent(event);
|
|
207
|
+
if (message.ok === false)
|
|
208
|
+
console.error(`[control] ${String(message.error ?? 'request failed')}`);
|
|
209
|
+
});
|
|
210
|
+
console.log(`[attached to ${name} via ACP; type a prompt, /permit …, /interrupt, or /detach]`);
|
|
211
|
+
const input = createInterface({ input: process.stdin });
|
|
212
|
+
input.on('line', line => {
|
|
213
|
+
if (line === '/detach') {
|
|
214
|
+
input.close();
|
|
215
|
+
socket.end();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (line === '/interrupt') {
|
|
219
|
+
send({ command: 'interrupt' });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const permit = line.match(/^\/permit\s+(\S+)\s+(\S+)$/);
|
|
223
|
+
if (permit) {
|
|
224
|
+
send({ command: 'respond_permission', permissionId: permit[1], optionId: permit[2] });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (line.trim())
|
|
228
|
+
send({ command: 'submit_prompt', text: line });
|
|
229
|
+
});
|
|
230
|
+
await new Promise(resolve => socket.once('close', resolve));
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
die(e);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
140
236
|
program.command('peek <name> [lines]').description('pane snapshot without attaching')
|
|
141
237
|
.action(async (name, lines) => {
|
|
142
238
|
try {
|
|
143
|
-
|
|
239
|
+
const stateDir = acpStateDir(name);
|
|
240
|
+
if (stateDir) {
|
|
241
|
+
const response = await controlRequest(stateDir, { command: 'follow', since: 0 });
|
|
242
|
+
const events = response.result?.events ?? [];
|
|
243
|
+
for (const event of events.slice(-(lines ? Number(lines) : 40)))
|
|
244
|
+
renderSessionEvent(event);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
console.log(await new Tmux().capture(name, lines ? Number(lines) : 40));
|
|
248
|
+
}
|
|
144
249
|
}
|
|
145
250
|
catch {
|
|
146
251
|
die(`'${name}' is not running; try: ours-fleet status ${name}`);
|
|
@@ -150,11 +255,20 @@ program.command('send <name> [text...]').description("type into the agent's cons
|
|
|
150
255
|
.option('--key <key>', 'send a raw key instead (Escape, Up, C-c, ...)')
|
|
151
256
|
.action(async (name, text, opts) => {
|
|
152
257
|
try {
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
155
|
-
|
|
258
|
+
const stateDir = acpStateDir(name);
|
|
259
|
+
if (stateDir) {
|
|
260
|
+
if (opts.key)
|
|
261
|
+
die('--key is available only for tmux sessions');
|
|
262
|
+
if (!text?.length)
|
|
263
|
+
die('nothing to send: give text');
|
|
264
|
+
const response = await controlRequest(stateDir, { command: 'submit_prompt', text: text.join(' ') });
|
|
265
|
+
if (!response.ok)
|
|
266
|
+
throw new Error(response.error ?? 'prompt rejected');
|
|
267
|
+
}
|
|
268
|
+
else if (opts.key)
|
|
269
|
+
await new Tmux().sendKey(name, opts.key);
|
|
156
270
|
else if (text?.length)
|
|
157
|
-
await
|
|
271
|
+
await new Tmux().sendText(name, text.join(' '));
|
|
158
272
|
else
|
|
159
273
|
die('nothing to send: give text or --key');
|
|
160
274
|
}
|
|
@@ -168,7 +282,20 @@ program.command('logs <name>').description('show the role log').option('-f, --fo
|
|
|
168
282
|
process.exit(await passthrough(cmd, args));
|
|
169
283
|
});
|
|
170
284
|
program.command('status <name>').description('unit/agent state')
|
|
171
|
-
.action(async (name) => {
|
|
285
|
+
.action(async (name) => {
|
|
286
|
+
console.log(await pickBackend().status(name));
|
|
287
|
+
const stateDir = acpStateDir(name);
|
|
288
|
+
if (stateDir) {
|
|
289
|
+
try {
|
|
290
|
+
const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
|
|
291
|
+
if (response.ok)
|
|
292
|
+
console.log(`session: ${JSON.stringify(response.result)}`);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
console.log('session: acp control unavailable');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
});
|
|
172
299
|
cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fleet.d file if spawned)'))
|
|
173
300
|
.action(async (name, opts) => {
|
|
174
301
|
try {
|
|
@@ -181,12 +308,16 @@ cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fl
|
|
|
181
308
|
cOpt(program.command('spawn <name>').description('spawn a new agent (permanent by default)'))
|
|
182
309
|
.option('--temp', 'temporary: plain tmux, auto-cleaned, gone on reboot')
|
|
183
310
|
.option('--harness <id>', 'harness adapter (default: defaults.harness)')
|
|
311
|
+
.option('--session <backend>', 'session backend: tmux|acp (default: defaults.session or tmux)')
|
|
184
312
|
.option('--mission <text>', 'one-line mission')
|
|
185
313
|
.option('--identity <name>', 'ours identity to bind (default: role name)')
|
|
186
314
|
.option('--cwd <dir>', 'working directory')
|
|
187
315
|
.option('--coordinator <name>', 'announce target')
|
|
188
316
|
.option('--model <id>', 'model id to launch on (e.g. claude-fable-5); default: launcher default')
|
|
189
317
|
.option('--permission-mode <mode>', 'harness permission mode (Codex: untrusted|on-request|never; Claude: native values)')
|
|
318
|
+
.option('--approval <mode>', 'common approval intent: ask|allow|deny')
|
|
319
|
+
.option('--filesystem <mode>', 'common filesystem intent: read-only|workspace|unrestricted')
|
|
320
|
+
.option('--unattended <mode>', 'permission behavior without a console: deny|wait')
|
|
190
321
|
.option('--sandbox <mode>', 'Codex sandbox: read-only|workspace-write|danger-full-access')
|
|
191
322
|
.option('--profile <name>', 'Codex profile file name ($CODEX_HOME/<name>.config.toml)')
|
|
192
323
|
.option('--launcher <mode>', 'Codex launcher: auto|ours-codex|codex (default: auto)')
|
|
@@ -199,10 +330,12 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
|
|
|
199
330
|
.action(async (name, opts) => {
|
|
200
331
|
try {
|
|
201
332
|
const o = {
|
|
202
|
-
name, temp: opts.temp, harness: opts.harness, mission: opts.mission,
|
|
333
|
+
name, temp: opts.temp, harness: opts.harness, session: opts.session, mission: opts.mission,
|
|
203
334
|
identity: opts.identity, cwd: opts.cwd, coordinator: opts.coordinator,
|
|
204
335
|
model: opts.model,
|
|
205
|
-
permissionMode: opts.permissionMode,
|
|
336
|
+
permissionMode: opts.permissionMode, approval: opts.approval,
|
|
337
|
+
filesystem: opts.filesystem, unattended: opts.unattended,
|
|
338
|
+
sandbox: opts.sandbox, profile: opts.profile,
|
|
206
339
|
launcher: opts.launcher, search: opts.search,
|
|
207
340
|
codexConfig: parseCodexConfig(opts.codexConfig), addDirs: opts.addDir, monitor: opts.monitor,
|
|
208
341
|
bioFile: opts.bioFile, personaFile: opts.personaFile, configPath: opts.configuration,
|
package/dist/config.d.ts
CHANGED
|
@@ -7,6 +7,24 @@ export interface OverseeEntry {
|
|
|
7
7
|
export declare const NOTIFY_EVENT_TYPES: readonly ["message_received", "file_received", "sibling_contact_added", "local_contact_request", "pending_message", "contact_restored", "inbound_error", "state_import_failed"];
|
|
8
8
|
export type NotifyEventType = (typeof NOTIFY_EVENT_TYPES)[number];
|
|
9
9
|
export type InjectMode = 'notification' | 'full';
|
|
10
|
+
export type SessionBackendId = 'tmux' | 'acp';
|
|
11
|
+
export type ApprovalMode = 'ask' | 'allow' | 'deny';
|
|
12
|
+
export type FilesystemMode = 'read-only' | 'workspace' | 'unrestricted';
|
|
13
|
+
export type UnattendedMode = 'deny' | 'wait';
|
|
14
|
+
export interface CommonPermissions {
|
|
15
|
+
approval: ApprovalMode;
|
|
16
|
+
filesystem: FilesystemMode;
|
|
17
|
+
unattended: UnattendedMode;
|
|
18
|
+
}
|
|
19
|
+
export interface SessionOptions {
|
|
20
|
+
acp?: {
|
|
21
|
+
/** ACP agent command and arguments. Defaults are supplied by the harness adapter. */
|
|
22
|
+
command?: string | string[];
|
|
23
|
+
};
|
|
24
|
+
tmux?: {
|
|
25
|
+
boot_grace_ms?: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
10
28
|
/** Resolved per-role supervisor-monitor config (see DESIGN-external-monitor §2). */
|
|
11
29
|
export interface MonitorConfig {
|
|
12
30
|
enabled: boolean;
|
|
@@ -27,6 +45,9 @@ export declare const DEFAULT_WAKE_SOURCES: NotifyEventType[];
|
|
|
27
45
|
export declare function validateMonitorConfig(raw: unknown): string[];
|
|
28
46
|
export interface RoleConfig {
|
|
29
47
|
harness?: string;
|
|
48
|
+
session?: SessionBackendId;
|
|
49
|
+
session_options?: SessionOptions;
|
|
50
|
+
permissions?: Partial<CommonPermissions>;
|
|
30
51
|
identity?: string;
|
|
31
52
|
cwd?: string;
|
|
32
53
|
coordinator?: string;
|
|
@@ -46,6 +67,8 @@ export interface RoleConfig {
|
|
|
46
67
|
export interface ResolvedRole extends RoleConfig {
|
|
47
68
|
name: string;
|
|
48
69
|
harness: string;
|
|
70
|
+
session: SessionBackendId;
|
|
71
|
+
permissions: CommonPermissions;
|
|
49
72
|
identity: string;
|
|
50
73
|
sourceFile: string;
|
|
51
74
|
monitor: MonitorConfig;
|
|
@@ -62,6 +85,7 @@ export declare class ConfigError extends Error {
|
|
|
62
85
|
}
|
|
63
86
|
/** Load ~/fleet.yaml (or an explicit path) merged with ~/fleet.d/*.yaml drop-ins. */
|
|
64
87
|
export declare function loadConfig(configPath?: string): FleetConfig;
|
|
88
|
+
export declare function resolvePermissions(defaults: unknown, role: Partial<CommonPermissions> | undefined, file?: string, name?: string): CommonPermissions;
|
|
65
89
|
/**
|
|
66
90
|
* Merge `defaults.monitor` under the role's own `monitor:` key-by-key, validate the
|
|
67
91
|
* result, and fill code-constant defaults (design §2). `defaults.monitor.enabled`
|
package/dist/config.js
CHANGED
|
@@ -51,7 +51,7 @@ export class ConfigError extends Error {
|
|
|
51
51
|
}
|
|
52
52
|
const NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
53
53
|
const ROLE_KEYS = [
|
|
54
|
-
'harness', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
|
|
54
|
+
'harness', 'session', 'session_options', 'permissions', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
|
|
55
55
|
'briefing_file', 'model', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
|
|
56
56
|
'isolation', 'monitor',
|
|
57
57
|
];
|
|
@@ -107,6 +107,9 @@ export function loadConfig(configPath) {
|
|
|
107
107
|
if (bad.length)
|
|
108
108
|
throw new ConfigError(`${file}: role '${name}' has unknown key(s) ${bad.join(', ')}; allowed: ${ROLE_KEYS.join(', ')}`);
|
|
109
109
|
const isolation = r.isolation ?? defaults.isolation;
|
|
110
|
+
const session = resolveSession(r.session ?? defaults.session, file, name);
|
|
111
|
+
const sessionOptions = resolveSessionOptions(defaults.session_options, r.session_options, session, file, name);
|
|
112
|
+
const permissions = resolvePermissions(defaults.permissions, r.permissions, file, name);
|
|
110
113
|
const defaultHarnessOptions = defaults.harness_options;
|
|
111
114
|
if (defaultHarnessOptions !== undefined
|
|
112
115
|
&& (typeof defaultHarnessOptions !== 'object' || defaultHarnessOptions === null
|
|
@@ -129,6 +132,9 @@ export function loadConfig(configPath) {
|
|
|
129
132
|
name,
|
|
130
133
|
sourceFile: file,
|
|
131
134
|
harness: r.harness ?? defaults.harness ?? 'claude-code',
|
|
135
|
+
session,
|
|
136
|
+
session_options: sessionOptions,
|
|
137
|
+
permissions,
|
|
132
138
|
identity: r.identity ?? name,
|
|
133
139
|
model: r.model ?? defaults.model,
|
|
134
140
|
max_tokens: r.max_tokens ?? defaults.max_tokens,
|
|
@@ -140,6 +146,78 @@ export function loadConfig(configPath) {
|
|
|
140
146
|
}
|
|
141
147
|
return { roles, vars, defaults, files, startStaggerMs };
|
|
142
148
|
}
|
|
149
|
+
function resolveSession(raw, file, name) {
|
|
150
|
+
const value = raw ?? 'tmux';
|
|
151
|
+
if (value !== 'tmux' && value !== 'acp')
|
|
152
|
+
throw new ConfigError(`${file}: role '${name}' session: must be one of: tmux, acp`);
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
function resolveSessionOptions(defaults, role, session, file, name) {
|
|
156
|
+
if (defaults !== undefined && !isPlainObject(defaults))
|
|
157
|
+
throw new ConfigError(`${file}: defaults.session_options must be a map`);
|
|
158
|
+
if (role !== undefined && !isPlainObject(role))
|
|
159
|
+
throw new ConfigError(`${file}: role '${name}' session_options must be a map`);
|
|
160
|
+
const merged = {
|
|
161
|
+
...(defaults ?? {}),
|
|
162
|
+
...(role ?? {}),
|
|
163
|
+
acp: {
|
|
164
|
+
...((defaults?.acp) ?? {}),
|
|
165
|
+
...(role?.acp ?? {}),
|
|
166
|
+
},
|
|
167
|
+
tmux: {
|
|
168
|
+
...((defaults?.tmux) ?? {}),
|
|
169
|
+
...(role?.tmux ?? {}),
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
const bad = Object.keys(merged).filter(k => k !== 'acp' && k !== 'tmux');
|
|
173
|
+
if (bad.length)
|
|
174
|
+
throw new ConfigError(`${file}: role '${name}' session_options: unknown key(s) ${bad.join(', ')}`);
|
|
175
|
+
if (!isPlainObject(merged.acp) || !isPlainObject(merged.tmux))
|
|
176
|
+
throw new ConfigError(`${file}: role '${name}' session_options.${session} must be a map`);
|
|
177
|
+
const acpBad = Object.keys(merged.acp).filter(k => k !== 'command');
|
|
178
|
+
const tmuxBad = Object.keys(merged.tmux).filter(k => k !== 'boot_grace_ms');
|
|
179
|
+
if (acpBad.length)
|
|
180
|
+
throw new ConfigError(`${file}: role '${name}' session_options.acp: unknown key(s) ${acpBad.join(', ')}`);
|
|
181
|
+
if (tmuxBad.length)
|
|
182
|
+
throw new ConfigError(`${file}: role '${name}' session_options.tmux: unknown key(s) ${tmuxBad.join(', ')}`);
|
|
183
|
+
const command = merged.acp.command;
|
|
184
|
+
if (command !== undefined
|
|
185
|
+
&& !(typeof command === 'string' && command.trim())
|
|
186
|
+
&& !(Array.isArray(command) && command.length > 0
|
|
187
|
+
&& command.every(v => typeof v === 'string' && v.length > 0)))
|
|
188
|
+
throw new ConfigError(`${file}: role '${name}' session_options.acp.command must be a non-empty string or string list`);
|
|
189
|
+
const grace = merged.tmux.boot_grace_ms;
|
|
190
|
+
if (grace !== undefined
|
|
191
|
+
&& (typeof grace !== 'number' || !Number.isFinite(grace) || grace < 0))
|
|
192
|
+
throw new ConfigError(`${file}: role '${name}' session_options.tmux.boot_grace_ms must be a non-negative number`);
|
|
193
|
+
return Object.keys(merged.acp).length || Object.keys(merged.tmux).length ? merged : undefined;
|
|
194
|
+
}
|
|
195
|
+
export function resolvePermissions(defaults, role, file = 'config', name = 'role') {
|
|
196
|
+
if (defaults !== undefined && !isPlainObject(defaults))
|
|
197
|
+
throw new ConfigError(`${file}: defaults.permissions must be a map`);
|
|
198
|
+
if (role !== undefined && !isPlainObject(role))
|
|
199
|
+
throw new ConfigError(`${file}: role '${name}' permissions must be a map`);
|
|
200
|
+
const merged = {
|
|
201
|
+
...(defaults ?? {}),
|
|
202
|
+
...(role ?? {}),
|
|
203
|
+
};
|
|
204
|
+
const allowed = ['approval', 'filesystem', 'unattended'];
|
|
205
|
+
const bad = Object.keys(merged).filter(k => !allowed.includes(k));
|
|
206
|
+
if (bad.length)
|
|
207
|
+
throw new ConfigError(`${file}: role '${name}' permissions: unknown key(s) ${bad.join(', ')}`);
|
|
208
|
+
if (merged.approval !== undefined && !['ask', 'allow', 'deny'].includes(merged.approval))
|
|
209
|
+
throw new ConfigError(`${file}: role '${name}' permissions.approval must be one of: ask, allow, deny`);
|
|
210
|
+
if (merged.filesystem !== undefined
|
|
211
|
+
&& !['read-only', 'workspace', 'unrestricted'].includes(merged.filesystem))
|
|
212
|
+
throw new ConfigError(`${file}: role '${name}' permissions.filesystem must be one of: read-only, workspace, unrestricted`);
|
|
213
|
+
if (merged.unattended !== undefined && !['deny', 'wait'].includes(merged.unattended))
|
|
214
|
+
throw new ConfigError(`${file}: role '${name}' permissions.unattended must be one of: deny, wait`);
|
|
215
|
+
return {
|
|
216
|
+
approval: merged.approval ?? 'ask',
|
|
217
|
+
filesystem: merged.filesystem ?? 'workspace',
|
|
218
|
+
unattended: merged.unattended ?? 'deny',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
143
221
|
/** Validate the top-level `start_stagger_ms` (supervisor launch spacing); default 0. */
|
|
144
222
|
function resolveStartStaggerMs(raw, base) {
|
|
145
223
|
if (raw === undefined || raw === null)
|
package/dist/doctor.js
CHANGED
|
@@ -43,11 +43,14 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
43
43
|
name: 'node', ok: major >= 20,
|
|
44
44
|
detail: major >= 20 ? `v${process.versions.node}` : `v${process.versions.node} — need >= 20`,
|
|
45
45
|
});
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
const roles = loadConfigSafe(opts.configPath);
|
|
47
|
+
if (roles.length === 0 || roles.some(role => (role.session ?? 'tmux') === 'tmux')) {
|
|
48
|
+
const tmux = await exec('tmux', ['-V']);
|
|
49
|
+
checks.push({
|
|
50
|
+
name: 'tmux', ok: tmux.code === 0,
|
|
51
|
+
detail: tmux.code === 0 ? tmux.stdout.trim() : 'not found — apt install tmux / brew install tmux',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
51
54
|
const mcp = await exec('ours-mcp', ['--version']);
|
|
52
55
|
checks.push({
|
|
53
56
|
name: 'ours-mcp', ok: mcp.code === 0,
|
|
@@ -84,7 +87,6 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
84
87
|
// opt-in per role (OQ-1), so a missing bwrap must not fail doctor for fleets that
|
|
85
88
|
// don't use it. Only a role that DECLARES isolation and cannot get it under
|
|
86
89
|
// `strict` is a hard failure.
|
|
87
|
-
const roles = loadConfigSafe(opts.configPath);
|
|
88
90
|
const bw = await makeBubblewrapBackend(exec).available();
|
|
89
91
|
checks.push({
|
|
90
92
|
name: 'isolation: bubblewrap', ok: true,
|
|
@@ -169,6 +171,33 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
169
171
|
checks.push({ name: h, ok: false, detail: e.message });
|
|
170
172
|
}
|
|
171
173
|
}
|
|
174
|
+
for (const role of roles.filter(role => role.session === 'acp')) {
|
|
175
|
+
const configured = role.session_options?.acp?.command;
|
|
176
|
+
const command = Array.isArray(configured)
|
|
177
|
+
? configured[0]
|
|
178
|
+
: typeof configured === 'string'
|
|
179
|
+
? configured.trim().split(/\s+/)[0]
|
|
180
|
+
: role.harness === 'codex'
|
|
181
|
+
? 'codex-acp'
|
|
182
|
+
: role.harness === 'claude-code'
|
|
183
|
+
? 'claude-agent-acp'
|
|
184
|
+
: '';
|
|
185
|
+
if (!command) {
|
|
186
|
+
checks.push({
|
|
187
|
+
name: `acp: ${role.name}`, ok: false,
|
|
188
|
+
detail: `harness '${role.harness}' has no default ACP agent; set session_options.acp.command`,
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const result = await exec('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', 'sh', command]);
|
|
193
|
+
checks.push({
|
|
194
|
+
name: `acp: ${role.name}`,
|
|
195
|
+
ok: result.code === 0,
|
|
196
|
+
detail: result.code === 0
|
|
197
|
+
? `${command} available`
|
|
198
|
+
: `${command} not found or failed — install the ACP adapter or set session_options.acp.command`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
172
201
|
return { ok: checks.every(c => c.ok), checks };
|
|
173
202
|
}
|
|
174
203
|
function loadConfigSafe(configPath) {
|
|
@@ -9,8 +9,13 @@ const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPe
|
|
|
9
9
|
/** Resolve & validate the per-role permission mode, throwing on an unknown value. */
|
|
10
10
|
function permissionMode(role) {
|
|
11
11
|
const pm = role.harness_options?.permission_mode;
|
|
12
|
-
if (pm == null)
|
|
12
|
+
if (pm == null) {
|
|
13
|
+
if (role.permissions?.approval === 'allow')
|
|
14
|
+
return 'dontAsk';
|
|
15
|
+
if (role.permissions?.approval === 'deny')
|
|
16
|
+
return 'plan';
|
|
13
17
|
return undefined;
|
|
18
|
+
}
|
|
14
19
|
if (!PERMISSION_MODES.includes(pm))
|
|
15
20
|
throw new Error(`invalid harness_options.permission_mode "${pm}"; allowed: ${PERMISSION_MODES.join(', ')}`);
|
|
16
21
|
return pm;
|
|
@@ -108,6 +113,30 @@ export function makeClaudeCodeAdapter(exec = realExec) {
|
|
|
108
113
|
this.vocabulary.restartPrompt(role.identity, join(stateDir, 'WORKLOG.md'), role)];
|
|
109
114
|
return { argv, env: prep.env };
|
|
110
115
|
},
|
|
116
|
+
buildAcpLaunch(role, prep) {
|
|
117
|
+
const configured = role.session_options?.acp?.command;
|
|
118
|
+
const argv = Array.isArray(configured)
|
|
119
|
+
? [...configured]
|
|
120
|
+
: typeof configured === 'string'
|
|
121
|
+
? ['sh', '-c', configured]
|
|
122
|
+
: ['claude-agent-acp'];
|
|
123
|
+
return { argv, env: prep.env };
|
|
124
|
+
},
|
|
125
|
+
translatePermissions(permissions) {
|
|
126
|
+
const native = permissions.approval === 'allow'
|
|
127
|
+
? 'dontAsk'
|
|
128
|
+
: permissions.approval === 'deny'
|
|
129
|
+
? 'plan'
|
|
130
|
+
: 'default';
|
|
131
|
+
const exact = permissions.filesystem === 'workspace' && permissions.approval === 'ask';
|
|
132
|
+
return {
|
|
133
|
+
native: { permission_mode: native },
|
|
134
|
+
exact,
|
|
135
|
+
warnings: exact ? [] : [
|
|
136
|
+
'Claude permission modes do not exactly represent independent approval and filesystem intent; fleet isolation remains the outer boundary',
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
},
|
|
111
140
|
vocabulary: {
|
|
112
141
|
bindTool: 'choose_identity',
|
|
113
142
|
createTool: 'create_identity',
|