@ours.network/fleet 0.9.3 → 0.9.5
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 +52 -30
- package/dist/cli.js +153 -15
- package/dist/config.d.ts +24 -0
- package/dist/config.js +79 -1
- package/dist/docs.d.ts +7 -0
- package/dist/docs.js +177 -0
- package/dist/doctor.js +50 -6
- package/dist/harness/acp-agent.d.ts +11 -0
- package/dist/harness/acp-agent.js +27 -0
- package/dist/harness/claude-code.js +31 -1
- package/dist/harness/codex.js +40 -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 +35 -5
- package/dist/monitor.js +187 -33
- 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 +7 -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
|
+
| Node ≥ 22 | Claude roles using `session: acp` | required by the maintained Claude ACP adapter |
|
|
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
|
|
|
@@ -87,6 +91,12 @@ ours-fleet init # units/dirs/linger for this user
|
|
|
87
91
|
ours-fleet doctor # verifies everything above, with actionable messages
|
|
88
92
|
```
|
|
89
93
|
|
|
94
|
+
The maintained Codex and Claude ACP adapters install as optional dependencies of
|
|
95
|
+
`ours-fleet` and are resolved internally; users do not install adapter commands
|
|
96
|
+
or add them to `PATH`. An explicit `session_options.acp.command` remains
|
|
97
|
+
available for custom adapters. On Node 20–21, tmux and Codex ACP remain
|
|
98
|
+
available, while maintained Claude ACP requires upgrading to Node 22.
|
|
99
|
+
|
|
90
100
|
Each OS user manages their own fleet — to host roles under a sandboxed account,
|
|
91
101
|
become that account and repeat.
|
|
92
102
|
|
|
@@ -112,28 +122,18 @@ ours-fleet spawn --temp Scout --mission "one-off research" # gone on exit/rebo
|
|
|
112
122
|
|
|
113
123
|
# Codex role: ours-codex is preferred automatically; plain codex is the fallback
|
|
114
124
|
ours-fleet spawn Coder --harness codex --model gpt-5.4 \
|
|
115
|
-
--
|
|
125
|
+
--session acp --approval ask --filesystem workspace \
|
|
116
126
|
--profile fleet --search --monitor --coordinator FleetCoordinator
|
|
117
127
|
```
|
|
118
128
|
|
|
119
129
|
Permanent spawns are written to `~/fleet.d/<Name>.yaml` — your hand-written
|
|
120
130
|
`~/fleet.yaml` is **never** machine-edited. `ours-fleet rm <Name>` unspawns.
|
|
121
131
|
|
|
122
|
-
From inside Claude Code
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
From inside Codex, install the native fleet plugin:
|
|
128
|
-
|
|
129
|
-
```sh
|
|
130
|
-
npm i -g @ours.network/fleet-codex
|
|
131
|
-
ours-fleet-codex-install
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
Start a new Codex session and say **"spawn an ours agent …"**. The bundled skill
|
|
135
|
-
walks through lifetime, model, sandbox, approval policy, profile, launcher, and
|
|
136
|
-
mail-monitor consent, then verifies the real tmux session and offers oversight.
|
|
132
|
+
From inside Claude Code, Codex, or Hermes with the core `ours` plugin installed,
|
|
133
|
+
say **"spawn an ours agent …"**. The core skill checks for `ours-fleet`, installs
|
|
134
|
+
and initializes it when absent, then consults `ours-fleet docs` for the exact
|
|
135
|
+
version-matched workflow. The older fleet-specific harness packages remain
|
|
136
|
+
published for compatibility but are no longer required or installed by default.
|
|
137
137
|
|
|
138
138
|
## Oversight ("keep an eye")
|
|
139
139
|
|
|
@@ -161,11 +161,12 @@ roles:
|
|
|
161
161
|
## Command reference
|
|
162
162
|
|
|
163
163
|
```
|
|
164
|
+
ours-fleet docs | man AI-friendly complete reference
|
|
164
165
|
ours-fleet up|down|restart|force-restart [-c FILE] [Name...]
|
|
165
166
|
ours-fleet config [-c FILE] validate + print merged plan
|
|
166
167
|
ours-fleet ls | attach | peek | logs [-f] | status <Name>
|
|
167
168
|
ours-fleet send <Name> "text" | --key <K>
|
|
168
|
-
ours-fleet spawn [--temp] <Name> [--harness --mission --model --
|
|
169
|
+
ours-fleet spawn [--temp] <Name> [--harness --session --mission --model --approval ...]
|
|
169
170
|
ours-fleet rm <Name>
|
|
170
171
|
ours-fleet doctor [--harness H]
|
|
171
172
|
ours-fleet init
|
|
@@ -183,6 +184,11 @@ vars: { work_root: /home/me/work } # ${var} substitution anywhere below
|
|
|
183
184
|
start_stagger_ms: 0 # delay between agent LAUNCHES (host-wide, ms); 0 = no stagger
|
|
184
185
|
defaults:
|
|
185
186
|
harness: claude-code # for roles that don't set one
|
|
187
|
+
session: tmux # tmux (default) | acp
|
|
188
|
+
permissions: # common intent, translated by each harness/backend
|
|
189
|
+
approval: ask # ask | allow | deny
|
|
190
|
+
filesystem: workspace # read-only | workspace | unrestricted
|
|
191
|
+
unattended: deny # deny | wait
|
|
186
192
|
model: claude-fable-5 # default model for roles that don't set one (per-role model / --model wins)
|
|
187
193
|
max_tokens: 500000 # session cap (harness-interpreted)
|
|
188
194
|
monitor: # supervisor-owned mail wake (fleet-wide default)
|
|
@@ -190,6 +196,10 @@ defaults:
|
|
|
190
196
|
roles:
|
|
191
197
|
Name: # [A-Za-z0-9_-]+
|
|
192
198
|
harness: claude-code
|
|
199
|
+
session: acp # one flag selects ACP; omit for tmux
|
|
200
|
+
session_options:
|
|
201
|
+
acp:
|
|
202
|
+
command: claude-agent-acp # optional advanced override
|
|
193
203
|
identity: "Display Name" # ours identity to bind (default: Name)
|
|
194
204
|
cwd: ${work_root}/repo # where the harness process runs
|
|
195
205
|
coordinator: FleetCoordinator # announce target on boot
|
|
@@ -236,7 +246,9 @@ roles:
|
|
|
236
246
|
|
|
237
247
|
Merge order: `fleet.yaml` ← `fleet.d/*.yaml`; a duplicate role name is a hard
|
|
238
248
|
error naming both files. Identities and roles are decoupled — removing a role
|
|
239
|
-
never deletes an identity. `
|
|
249
|
+
never deletes an identity. `session` is independent of `harness`, so changing a
|
|
250
|
+
role from tmux to ACP does not change its identity, mission, monitor, or permission
|
|
251
|
+
contract. `defaults.harness_options` is shallow-merged with each
|
|
240
252
|
role's `harness_options`, so a fleet can set common Codex permission/profile defaults
|
|
241
253
|
and override individual keys per role. `monitor` merges the same way — a role block
|
|
242
254
|
overrides `defaults.monitor` key-by-key.
|
|
@@ -263,24 +275,34 @@ their boots ~4 s apart instead of firing all seven at once.
|
|
|
263
275
|
|
|
264
276
|
With `monitor.enabled` (the default), the **supervisor** delivers a role's mail
|
|
265
277
|
wakes: the per-role runner long-polls the ours daemon's notification API and
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
278
|
+
submits a single `[fleet-monitor] N new messages from … — run get_messages` prompt
|
|
279
|
+
through the selected backend. ACP uses structured `session/prompt`; tmux uses
|
|
280
|
+
verified console input. It primes the notification cursor *before* the session launches
|
|
269
281
|
(no missed arrivals), cannot be orphaned or left deaf-but-armed, and writes its
|
|
270
282
|
health to `<agentDir>/.monitor-status` (`armed | degraded | failed`), surfaced in
|
|
271
|
-
`ours-fleet status`/`doctor`.
|
|
283
|
+
`ours-fleet status`/`doctor`. Injection is held while the pane shows a modal dialog,
|
|
284
|
+
so an injected wake can never answer a trust/permission prompt; if the dialog is
|
|
285
|
+
still up after 2 minutes the monitor gives up on that wake and records
|
|
286
|
+
`degraded: modal wedge …` instead of waiting silently forever (the mail stays
|
|
287
|
+
queued and its cursor is not committed until a later delivery is accepted). The agent's
|
|
288
|
+
briefing tells it **not** to arm an
|
|
272
289
|
in-session Monitor. Set `monitor.enabled: false` to keep the legacy behavior where
|
|
273
290
|
the agent arms its own `ours-mcp watch`. `inject: full` (pushing message bodies
|
|
274
291
|
inline) is on the roadmap and needs two new ours-mcp daemon endpoints; today all
|
|
275
292
|
roles deliver `notification` lines and drain via `get_messages`.
|
|
276
293
|
|
|
294
|
+
ACP stdio remains private to the persistent runner. `send`, `peek`, and the basic
|
|
295
|
+
ACP `attach` console use a private, authenticated per-role control socket with
|
|
296
|
+
typed replayable events. This is also the stable extension boundary for a richer
|
|
297
|
+
console later; no terminal UI is part of the monitor or session backend.
|
|
298
|
+
|
|
277
299
|
## Codex roles
|
|
278
300
|
|
|
279
|
-
Install Codex, the native ours plugin, and the fleet
|
|
301
|
+
Install Codex, the native ours plugin, and the fleet CLI once on the fleet host:
|
|
280
302
|
|
|
281
303
|
```sh
|
|
282
|
-
npm i -g @ours.network/fleet
|
|
283
|
-
ours-fleet
|
|
304
|
+
npm i -g @ours.network/fleet
|
|
305
|
+
ours-fleet init
|
|
284
306
|
ours-fleet doctor --harness codex
|
|
285
307
|
```
|
|
286
308
|
|
|
@@ -382,7 +404,7 @@ cleanly as long as each role has its own `cwd` (the common case); two roles
|
|
|
382
404
|
sharing an identical `cwd` could have their resumes cross — give them distinct
|
|
383
405
|
working directories if that matters. MCP and monitor wiring is provided by
|
|
384
406
|
[`@ours.network/codex`](https://github.com/adapt-toolkit/ours-mcp/tree/main/packages/codex);
|
|
385
|
-
the
|
|
407
|
+
the core ours skill discovers fleet behavior through `ours-fleet docs`.
|
|
386
408
|
`ours-fleet doctor --harness codex` verifies the CLI, ours plugin, and enhanced launcher/fallback.
|
|
387
409
|
|
|
388
410
|
## Learn more
|
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,8 @@ 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 { AI_DOCS } from './docs.js';
|
|
18
|
+
import { controlRequest, controlSocketPath, followControl, } from './session/control.js';
|
|
15
19
|
import './harness/claude-code.js'; // registers the claude-code adapter
|
|
16
20
|
import './harness/codex.js'; // registers the codex adapter
|
|
17
21
|
// sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
|
|
@@ -36,10 +40,48 @@ const passthrough = (cmd, args) => new Promise(resolve => {
|
|
|
36
40
|
});
|
|
37
41
|
const program = new Command()
|
|
38
42
|
.name('ours-fleet')
|
|
39
|
-
.description('Fleet of persistent, identity-bound AI agents — harness
|
|
43
|
+
.description('Fleet of persistent, identity-bound AI agents — selectable harness and tmux/ACP sessions.')
|
|
40
44
|
.version(VERSION);
|
|
41
45
|
const cOpt = (cmd) => cmd.option('-c, --configuration <file>', 'config file (default: ~/fleet.yaml + ~/fleet.d/)');
|
|
42
46
|
const collect = (value, previous) => [...previous, value];
|
|
47
|
+
program.command('docs')
|
|
48
|
+
.alias('man')
|
|
49
|
+
.description('print the complete AI-friendly command and configuration reference')
|
|
50
|
+
.action(() => { process.stdout.write(AI_DOCS); });
|
|
51
|
+
function acpStateDir(name) {
|
|
52
|
+
const permanent = agentDir(name);
|
|
53
|
+
if (existsSync(controlSocketPath(permanent)))
|
|
54
|
+
return permanent;
|
|
55
|
+
const temp = agentDir(name, true);
|
|
56
|
+
if (existsSync(controlSocketPath(temp)))
|
|
57
|
+
return temp;
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
function renderSessionEvent(event) {
|
|
61
|
+
switch (event.kind) {
|
|
62
|
+
case 'agent_text':
|
|
63
|
+
process.stdout.write(event.text ?? '');
|
|
64
|
+
break;
|
|
65
|
+
case 'thought': break;
|
|
66
|
+
case 'tool_call':
|
|
67
|
+
case 'tool_update':
|
|
68
|
+
console.log(`\n[${event.kind}] ${event.title ?? event.toolCallId ?? ''} ${event.status ?? ''}`.trimEnd());
|
|
69
|
+
break;
|
|
70
|
+
case 'permission':
|
|
71
|
+
console.log(`\n[permission ${event.permissionId}] ${event.title ?? ''}`);
|
|
72
|
+
for (const option of event.options ?? [])
|
|
73
|
+
console.log(` ${option.optionId}: ${option.name} (${option.kind})`);
|
|
74
|
+
console.log(' respond: /permit <permission-id> <option-id>');
|
|
75
|
+
break;
|
|
76
|
+
case 'turn_stop':
|
|
77
|
+
console.log(`\n[turn stopped: ${event.stopReason ?? 'unknown'}]`);
|
|
78
|
+
break;
|
|
79
|
+
case 'error':
|
|
80
|
+
console.error(`\n[error] ${event.text ?? ''}`);
|
|
81
|
+
break;
|
|
82
|
+
case 'state': break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
43
85
|
function parseCodexConfig(values) {
|
|
44
86
|
if (!values?.length)
|
|
45
87
|
return undefined;
|
|
@@ -67,6 +109,7 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
67
109
|
for (const r of cfg.roles) {
|
|
68
110
|
console.log(`\n● ${r.name}`);
|
|
69
111
|
console.log(` harness: ${r.harness}`);
|
|
112
|
+
console.log(` session: ${r.session}`);
|
|
70
113
|
console.log(` identity: ${r.identity}`);
|
|
71
114
|
console.log(` source: ${r.sourceFile}`);
|
|
72
115
|
if (r.cwd)
|
|
@@ -133,14 +176,81 @@ cOpt(program.command('force-restart [names...]').description('re-sync + bounce F
|
|
|
133
176
|
die(e);
|
|
134
177
|
}
|
|
135
178
|
});
|
|
136
|
-
program.command('ls').description('list running
|
|
137
|
-
.action(async () => {
|
|
179
|
+
program.command('ls').description('list running fleet sessions')
|
|
180
|
+
.action(async () => {
|
|
181
|
+
const tmux = await new Tmux().list();
|
|
182
|
+
const acp = [];
|
|
183
|
+
for (const root of [agentsRoot(), tmpRoot()]) {
|
|
184
|
+
if (!existsSync(root))
|
|
185
|
+
continue;
|
|
186
|
+
for (const name of readdirSync(root)) {
|
|
187
|
+
const stateDir = joinPath(root, name);
|
|
188
|
+
if (!existsSync(controlSocketPath(stateDir)))
|
|
189
|
+
continue;
|
|
190
|
+
try {
|
|
191
|
+
const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
|
|
192
|
+
if (response.ok && response.result?.alive)
|
|
193
|
+
acp.push(`${name}: acp`);
|
|
194
|
+
}
|
|
195
|
+
catch { /* ignore stale sockets */ }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
console.log([tmux, ...acp].filter(Boolean).join('\n') || '(none)');
|
|
199
|
+
});
|
|
138
200
|
program.command('attach <name>').description('open the live console (Ctrl-b d to leave)')
|
|
139
|
-
.action(async (name) =>
|
|
201
|
+
.action(async (name) => {
|
|
202
|
+
const stateDir = acpStateDir(name);
|
|
203
|
+
if (!stateDir)
|
|
204
|
+
process.exit(await passthrough('tmux', ['attach', '-t', name]));
|
|
205
|
+
try {
|
|
206
|
+
const { socket, send } = await followControl(stateDir, message => {
|
|
207
|
+
if ('event' in message)
|
|
208
|
+
renderSessionEvent(message.event);
|
|
209
|
+
const result = message.result;
|
|
210
|
+
for (const event of result?.events ?? [])
|
|
211
|
+
renderSessionEvent(event);
|
|
212
|
+
if (message.ok === false)
|
|
213
|
+
console.error(`[control] ${String(message.error ?? 'request failed')}`);
|
|
214
|
+
});
|
|
215
|
+
console.log(`[attached to ${name} via ACP; type a prompt, /permit …, /interrupt, or /detach]`);
|
|
216
|
+
const input = createInterface({ input: process.stdin });
|
|
217
|
+
input.on('line', line => {
|
|
218
|
+
if (line === '/detach') {
|
|
219
|
+
input.close();
|
|
220
|
+
socket.end();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (line === '/interrupt') {
|
|
224
|
+
send({ command: 'interrupt' });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const permit = line.match(/^\/permit\s+(\S+)\s+(\S+)$/);
|
|
228
|
+
if (permit) {
|
|
229
|
+
send({ command: 'respond_permission', permissionId: permit[1], optionId: permit[2] });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (line.trim())
|
|
233
|
+
send({ command: 'submit_prompt', text: line });
|
|
234
|
+
});
|
|
235
|
+
await new Promise(resolve => socket.once('close', resolve));
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
die(e);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
140
241
|
program.command('peek <name> [lines]').description('pane snapshot without attaching')
|
|
141
242
|
.action(async (name, lines) => {
|
|
142
243
|
try {
|
|
143
|
-
|
|
244
|
+
const stateDir = acpStateDir(name);
|
|
245
|
+
if (stateDir) {
|
|
246
|
+
const response = await controlRequest(stateDir, { command: 'follow', since: 0 });
|
|
247
|
+
const events = response.result?.events ?? [];
|
|
248
|
+
for (const event of events.slice(-(lines ? Number(lines) : 40)))
|
|
249
|
+
renderSessionEvent(event);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
console.log(await new Tmux().capture(name, lines ? Number(lines) : 40));
|
|
253
|
+
}
|
|
144
254
|
}
|
|
145
255
|
catch {
|
|
146
256
|
die(`'${name}' is not running; try: ours-fleet status ${name}`);
|
|
@@ -150,11 +260,20 @@ program.command('send <name> [text...]').description("type into the agent's cons
|
|
|
150
260
|
.option('--key <key>', 'send a raw key instead (Escape, Up, C-c, ...)')
|
|
151
261
|
.action(async (name, text, opts) => {
|
|
152
262
|
try {
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
155
|
-
|
|
263
|
+
const stateDir = acpStateDir(name);
|
|
264
|
+
if (stateDir) {
|
|
265
|
+
if (opts.key)
|
|
266
|
+
die('--key is available only for tmux sessions');
|
|
267
|
+
if (!text?.length)
|
|
268
|
+
die('nothing to send: give text');
|
|
269
|
+
const response = await controlRequest(stateDir, { command: 'submit_prompt', text: text.join(' ') });
|
|
270
|
+
if (!response.ok)
|
|
271
|
+
throw new Error(response.error ?? 'prompt rejected');
|
|
272
|
+
}
|
|
273
|
+
else if (opts.key)
|
|
274
|
+
await new Tmux().sendKey(name, opts.key);
|
|
156
275
|
else if (text?.length)
|
|
157
|
-
await
|
|
276
|
+
await new Tmux().sendText(name, text.join(' '));
|
|
158
277
|
else
|
|
159
278
|
die('nothing to send: give text or --key');
|
|
160
279
|
}
|
|
@@ -168,7 +287,20 @@ program.command('logs <name>').description('show the role log').option('-f, --fo
|
|
|
168
287
|
process.exit(await passthrough(cmd, args));
|
|
169
288
|
});
|
|
170
289
|
program.command('status <name>').description('unit/agent state')
|
|
171
|
-
.action(async (name) => {
|
|
290
|
+
.action(async (name) => {
|
|
291
|
+
console.log(await pickBackend().status(name));
|
|
292
|
+
const stateDir = acpStateDir(name);
|
|
293
|
+
if (stateDir) {
|
|
294
|
+
try {
|
|
295
|
+
const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
|
|
296
|
+
if (response.ok)
|
|
297
|
+
console.log(`session: ${JSON.stringify(response.result)}`);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
console.log('session: acp control unavailable');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
172
304
|
cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fleet.d file if spawned)'))
|
|
173
305
|
.action(async (name, opts) => {
|
|
174
306
|
try {
|
|
@@ -179,14 +311,18 @@ cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fl
|
|
|
179
311
|
}
|
|
180
312
|
});
|
|
181
313
|
cOpt(program.command('spawn <name>').description('spawn a new agent (permanent by default)'))
|
|
182
|
-
.option('--temp', 'temporary:
|
|
314
|
+
.option('--temp', 'temporary: detached supervisor, auto-cleaned, gone on reboot')
|
|
183
315
|
.option('--harness <id>', 'harness adapter (default: defaults.harness)')
|
|
316
|
+
.option('--session <backend>', 'session backend: tmux|acp (default: defaults.session or tmux)')
|
|
184
317
|
.option('--mission <text>', 'one-line mission')
|
|
185
318
|
.option('--identity <name>', 'ours identity to bind (default: role name)')
|
|
186
319
|
.option('--cwd <dir>', 'working directory')
|
|
187
320
|
.option('--coordinator <name>', 'announce target')
|
|
188
321
|
.option('--model <id>', 'model id to launch on (e.g. claude-fable-5); default: launcher default')
|
|
189
322
|
.option('--permission-mode <mode>', 'harness permission mode (Codex: untrusted|on-request|never; Claude: native values)')
|
|
323
|
+
.option('--approval <mode>', 'common approval intent: ask|allow|deny')
|
|
324
|
+
.option('--filesystem <mode>', 'common filesystem intent: read-only|workspace|unrestricted')
|
|
325
|
+
.option('--unattended <mode>', 'permission behavior without a console: deny|wait')
|
|
190
326
|
.option('--sandbox <mode>', 'Codex sandbox: read-only|workspace-write|danger-full-access')
|
|
191
327
|
.option('--profile <name>', 'Codex profile file name ($CODEX_HOME/<name>.config.toml)')
|
|
192
328
|
.option('--launcher <mode>', 'Codex launcher: auto|ours-codex|codex (default: auto)')
|
|
@@ -199,10 +335,12 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
|
|
|
199
335
|
.action(async (name, opts) => {
|
|
200
336
|
try {
|
|
201
337
|
const o = {
|
|
202
|
-
name, temp: opts.temp, harness: opts.harness, mission: opts.mission,
|
|
338
|
+
name, temp: opts.temp, harness: opts.harness, session: opts.session, mission: opts.mission,
|
|
203
339
|
identity: opts.identity, cwd: opts.cwd, coordinator: opts.coordinator,
|
|
204
340
|
model: opts.model,
|
|
205
|
-
permissionMode: opts.permissionMode,
|
|
341
|
+
permissionMode: opts.permissionMode, approval: opts.approval,
|
|
342
|
+
filesystem: opts.filesystem, unattended: opts.unattended,
|
|
343
|
+
sandbox: opts.sandbox, profile: opts.profile,
|
|
206
344
|
launcher: opts.launcher, search: opts.search,
|
|
207
345
|
codexConfig: parseCodexConfig(opts.codexConfig), addDirs: opts.addDir, monitor: opts.monitor,
|
|
208
346
|
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/docs.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
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 declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and `--monitor`. Run `ours-fleet help spawn` for exact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n enabled: true\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n enabled: true\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\n```\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\nThe supervisor monitor is enabled by default. It consumes body-free daemon\nevents and advances its durable cursor only after delivery is accepted. ACP uses\na structured `session/prompt`; tmux uses verified console injection. Message\nbodies are released only when the role calls the ours `get_messages` tool.\n\nSet `monitor.enabled: false` only to retain legacy in-session monitoring.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n";
|