@ours.network/fleet 0.9.4 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -30
- package/dist/atomic-file.d.ts +30 -0
- package/dist/atomic-file.js +86 -0
- package/dist/briefing.d.ts +6 -0
- package/dist/briefing.js +41 -11
- package/dist/cli.js +238 -26
- package/dist/config.d.ts +39 -1
- package/dist/config.js +126 -3
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +34 -0
- package/dist/docs.js +309 -0
- package/dist/doctor.js +123 -21
- package/dist/harness/acp-agent.d.ts +11 -0
- package/dist/harness/acp-agent.js +27 -0
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +145 -13
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +89 -4
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +59 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/isolation/bubblewrap.js +7 -1
- package/dist/isolation/policy.d.ts +34 -5
- package/dist/isolation/policy.js +114 -7
- package/dist/isolation/resources.d.ts +6 -3
- package/dist/isolation/resources.js +6 -3
- package/dist/isolation/types.d.ts +19 -1
- package/dist/monitor.d.ts +44 -2
- package/dist/monitor.js +177 -42
- package/dist/ops.d.ts +15 -2
- package/dist/ops.js +32 -9
- package/dist/permissions.d.ts +70 -0
- package/dist/permissions.js +97 -0
- package/dist/runner.d.ts +65 -2
- package/dist/runner.js +307 -32
- package/dist/session/acp.d.ts +70 -0
- package/dist/session/acp.js +364 -0
- package/dist/session/control.d.ts +89 -0
- package/dist/session/control.js +322 -0
- package/dist/session/events.d.ts +14 -0
- package/dist/session/events.js +67 -0
- package/dist/session/tmux.d.ts +27 -0
- package/dist/session/tmux.js +76 -0
- package/dist/session/types.d.ts +138 -0
- package/dist/session/types.js +42 -0
- package/dist/spawn.d.ts +32 -2
- package/dist/spawn.js +177 -16
- package/dist/supervisor/launchd.d.ts +50 -0
- package/dist/supervisor/launchd.js +121 -4
- package/dist/supervisor/none.js +22 -4
- package/dist/supervisor/systemd.d.ts +8 -1
- package/dist/supervisor/systemd.js +94 -4
- package/dist/supervisor/types.d.ts +36 -3
- package/dist/tmux.d.ts +34 -2
- package/dist/tmux.js +48 -11
- package/package.json +7 -2
package/dist/cli.js
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
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
|
-
import { Tmux } from './tmux.js';
|
|
11
|
+
import { Tmux, tmuxArgs } from './tmux.js';
|
|
10
12
|
import { pickBackend } from './supervisor/index.js';
|
|
11
13
|
import { up, down, restartRoles, rmRole } from './ops.js';
|
|
12
|
-
import {
|
|
13
|
-
import { spawnPermanent, spawnTemp } from './spawn.js';
|
|
14
|
+
import { readRestartLedger, runSupervised, runTemp } from './runner.js';
|
|
15
|
+
import { lastProvenance, spawnPermanent, spawnTemp } from './spawn.js';
|
|
16
|
+
import { formatProvenance } from './creation.js';
|
|
14
17
|
import { doctor } from './doctor.js';
|
|
18
|
+
import { allWarnings, analyzeFleetPermissions, formatNative } from './permissions.js';
|
|
19
|
+
import { AI_DOCS } from './docs.js';
|
|
20
|
+
import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
|
|
21
|
+
import { SessionControlError } from './session/types.js';
|
|
15
22
|
import './harness/claude-code.js'; // registers the claude-code adapter
|
|
16
23
|
import './harness/codex.js'; // registers the codex adapter
|
|
17
24
|
// sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
|
|
@@ -36,10 +43,59 @@ const passthrough = (cmd, args) => new Promise(resolve => {
|
|
|
36
43
|
});
|
|
37
44
|
const program = new Command()
|
|
38
45
|
.name('ours-fleet')
|
|
39
|
-
.description('Fleet of persistent, identity-bound AI agents — harness
|
|
46
|
+
.description('Fleet of persistent, identity-bound AI agents — selectable harness and tmux/ACP sessions.')
|
|
40
47
|
.version(VERSION);
|
|
41
48
|
const cOpt = (cmd) => cmd.option('-c, --configuration <file>', 'config file (default: ~/fleet.yaml + ~/fleet.d/)');
|
|
42
49
|
const collect = (value, previous) => [...previous, value];
|
|
50
|
+
program.command('docs')
|
|
51
|
+
.alias('man')
|
|
52
|
+
.description('print the complete AI-friendly command and configuration reference')
|
|
53
|
+
.action(() => { process.stdout.write(AI_DOCS); });
|
|
54
|
+
function acpStateDir(name) {
|
|
55
|
+
const permanent = agentDir(name);
|
|
56
|
+
if (existsSync(controlSocketPath(permanent)))
|
|
57
|
+
return permanent;
|
|
58
|
+
const temp = agentDir(name, true);
|
|
59
|
+
if (existsSync(controlSocketPath(temp)))
|
|
60
|
+
return temp;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
function renderSessionEvent(event) {
|
|
64
|
+
switch (event.kind) {
|
|
65
|
+
case 'agent_text':
|
|
66
|
+
process.stdout.write(event.text ?? '');
|
|
67
|
+
break;
|
|
68
|
+
case 'thought': break;
|
|
69
|
+
case 'tool_call':
|
|
70
|
+
case 'tool_update':
|
|
71
|
+
console.log(`\n[${event.kind}] ${event.title ?? event.toolCallId ?? ''} ${event.status ?? ''}`.trimEnd());
|
|
72
|
+
break;
|
|
73
|
+
case 'permission':
|
|
74
|
+
if (event.status === 'completed') {
|
|
75
|
+
// A settled request. Automatic decisions are the ones nobody saw happen,
|
|
76
|
+
// so peek/attach must show what was decided and which policy decided it.
|
|
77
|
+
console.log(`\n[permission ${event.permissionId}] ${event.title ?? ''}`.trimEnd());
|
|
78
|
+
console.log(` ${event.decisionSource ?? 'manual'} decision: ${event.decision ?? 'unknown'}`
|
|
79
|
+
+ `${event.optionId ? ` (${event.optionId})` : ''}`
|
|
80
|
+
+ `${event.policy ? ` via ${event.policy}` : ''}`);
|
|
81
|
+
if (event.reason)
|
|
82
|
+
console.log(` reason: ${event.reason}`);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
console.log(`\n[permission ${event.permissionId}] ${event.title ?? ''}`);
|
|
86
|
+
for (const option of event.options ?? [])
|
|
87
|
+
console.log(` ${option.optionId}: ${option.name} (${option.kind})`);
|
|
88
|
+
console.log(' respond: /permit <permission-id> <option-id>');
|
|
89
|
+
break;
|
|
90
|
+
case 'turn_stop':
|
|
91
|
+
console.log(`\n[turn stopped: ${event.stopReason ?? 'unknown'}]`);
|
|
92
|
+
break;
|
|
93
|
+
case 'error':
|
|
94
|
+
console.error(`\n[error] ${event.text ?? ''}`);
|
|
95
|
+
break;
|
|
96
|
+
case 'state': break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
43
99
|
function parseCodexConfig(values) {
|
|
44
100
|
if (!values?.length)
|
|
45
101
|
return undefined;
|
|
@@ -64,10 +120,18 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
64
120
|
try {
|
|
65
121
|
const cfg = loadConfig(opts.configuration);
|
|
66
122
|
console.log(`config: ${cfg.files.join(' + ') || '(none)'}`);
|
|
123
|
+
const analyses = analyzeFleetPermissions(cfg.roles);
|
|
67
124
|
for (const r of cfg.roles) {
|
|
125
|
+
const perms = analyses.find(a => a.role === r.name);
|
|
68
126
|
console.log(`\n● ${r.name}`);
|
|
69
127
|
console.log(` harness: ${r.harness}`);
|
|
128
|
+
console.log(` session: ${r.session}`);
|
|
70
129
|
console.log(` identity: ${r.identity}`);
|
|
130
|
+
console.log(` permissions: approval=${r.permissions.approval} `
|
|
131
|
+
+ `filesystem=${r.permissions.filesystem} unattended=${r.permissions.unattended}`);
|
|
132
|
+
if (perms?.supported)
|
|
133
|
+
console.log(` native: ${formatNative(perms.native)}`
|
|
134
|
+
+ `${perms.exact ? '' : ' (not an exact representation)'}`);
|
|
71
135
|
console.log(` source: ${r.sourceFile}`);
|
|
72
136
|
if (r.cwd)
|
|
73
137
|
console.log(` cwd: ${r.cwd}`);
|
|
@@ -91,6 +155,8 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
91
155
|
console.log(` isolation: backend=${iso.backend ?? 'auto'} net=${iso.network ?? 'broker'} `
|
|
92
156
|
+ `on_unavailable=${iso.on_unavailable ?? 'warn'} caps=${caps}`);
|
|
93
157
|
}
|
|
158
|
+
for (const w of perms ? allWarnings(perms) : [])
|
|
159
|
+
console.log(` warning: ${w}`);
|
|
94
160
|
}
|
|
95
161
|
}
|
|
96
162
|
catch (e) {
|
|
@@ -133,33 +199,138 @@ cOpt(program.command('force-restart [names...]').description('re-sync + bounce F
|
|
|
133
199
|
die(e);
|
|
134
200
|
}
|
|
135
201
|
});
|
|
136
|
-
program.command('ls').description('list running
|
|
137
|
-
.action(async () => {
|
|
202
|
+
program.command('ls').description('list running fleet sessions')
|
|
203
|
+
.action(async () => {
|
|
204
|
+
// Each session has its own tmux server (#32), so there is no single server
|
|
205
|
+
// to ask: the known role names ARE the list of servers to poll.
|
|
206
|
+
const names = [];
|
|
207
|
+
const acp = [];
|
|
208
|
+
for (const root of [agentsRoot(), tmpRoot()]) {
|
|
209
|
+
if (!existsSync(root))
|
|
210
|
+
continue;
|
|
211
|
+
for (const name of readdirSync(root)) {
|
|
212
|
+
names.push(name);
|
|
213
|
+
const stateDir = joinPath(root, name);
|
|
214
|
+
if (!existsSync(controlSocketPath(stateDir)))
|
|
215
|
+
continue;
|
|
216
|
+
try {
|
|
217
|
+
const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
|
|
218
|
+
if (response.ok && response.result?.alive)
|
|
219
|
+
acp.push(`${name}: acp`);
|
|
220
|
+
}
|
|
221
|
+
catch { /* ignore stale sockets */ }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const tmux = await new Tmux().list(names);
|
|
225
|
+
console.log([tmux, ...acp].filter(Boolean).join('\n') || '(none)');
|
|
226
|
+
});
|
|
138
227
|
program.command('attach <name>').description('open the live console (Ctrl-b d to leave)')
|
|
139
|
-
.action(async (name) =>
|
|
228
|
+
.action(async (name) => {
|
|
229
|
+
const stateDir = acpStateDir(name);
|
|
230
|
+
if (!stateDir)
|
|
231
|
+
process.exit(await passthrough('tmux', tmuxArgs(name, ['attach', '-t', name])));
|
|
232
|
+
try {
|
|
233
|
+
const { socket, send } = await followControl(stateDir, message => {
|
|
234
|
+
if ('event' in message)
|
|
235
|
+
renderSessionEvent(message.event);
|
|
236
|
+
const result = message.result;
|
|
237
|
+
for (const event of result?.events ?? [])
|
|
238
|
+
renderSessionEvent(event);
|
|
239
|
+
if (message.ok === false)
|
|
240
|
+
console.error(`[control] ${String(message.error ?? 'request failed')}`);
|
|
241
|
+
});
|
|
242
|
+
console.log(`[attached to ${name} via ACP; type a prompt, /permit …, /interrupt, or /detach]`);
|
|
243
|
+
const input = createInterface({ input: process.stdin });
|
|
244
|
+
input.on('line', line => {
|
|
245
|
+
if (line === '/detach') {
|
|
246
|
+
input.close();
|
|
247
|
+
socket.end();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (line === '/interrupt') {
|
|
251
|
+
send({ command: 'interrupt' });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const permit = line.match(/^\/permit\s+(\S+)\s+(\S+)$/);
|
|
255
|
+
if (permit) {
|
|
256
|
+
send({ command: 'respond_permission', permissionId: permit[1], optionId: permit[2] });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (line.trim())
|
|
260
|
+
send({ command: 'submit_prompt', text: line });
|
|
261
|
+
});
|
|
262
|
+
await new Promise(resolve => socket.once('close', resolve));
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
die(e);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
/** Classify a raw tmux failure: only "no such session" proves the pane is gone. */
|
|
269
|
+
const asControlError = (e) => {
|
|
270
|
+
if (e instanceof SessionControlError)
|
|
271
|
+
return e;
|
|
272
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
273
|
+
return new SessionControlError(/can't find session|no server running|session not found/i.test(message) ? 'offline' : 'backend', message);
|
|
274
|
+
};
|
|
275
|
+
/**
|
|
276
|
+
* Report what actually went wrong, then say what it proves about the agent.
|
|
277
|
+
* The old handler replaced every failure — timeouts, socket errors, refusals —
|
|
278
|
+
* with "is not running", which is how an overseer came to restart busy agents.
|
|
279
|
+
*/
|
|
280
|
+
const controlFailure = (name, action, e, extra = '') => {
|
|
281
|
+
const err = asControlError(e);
|
|
282
|
+
return `${action} ${name}: ${err.message}\n ${livenessNote(err.kind, name)}${extra}`;
|
|
283
|
+
};
|
|
140
284
|
program.command('peek <name> [lines]').description('pane snapshot without attaching')
|
|
141
285
|
.action(async (name, lines) => {
|
|
142
286
|
try {
|
|
143
|
-
|
|
287
|
+
const stateDir = acpStateDir(name);
|
|
288
|
+
if (stateDir) {
|
|
289
|
+
const response = await controlRequest(stateDir, { command: 'follow', since: 0 });
|
|
290
|
+
if (!response.ok)
|
|
291
|
+
throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'peek failed');
|
|
292
|
+
const events = response.result?.events ?? [];
|
|
293
|
+
for (const event of events.slice(-(lines ? Number(lines) : 40)))
|
|
294
|
+
renderSessionEvent(event);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
console.log(await new Tmux().capture(name, lines ? Number(lines) : 40));
|
|
298
|
+
}
|
|
144
299
|
}
|
|
145
|
-
catch {
|
|
146
|
-
die(
|
|
300
|
+
catch (e) {
|
|
301
|
+
die(controlFailure(name, 'peek', e));
|
|
147
302
|
}
|
|
148
303
|
});
|
|
149
304
|
program.command('send <name> [text...]').description("type into the agent's console")
|
|
150
305
|
.option('--key <key>', 'send a raw key instead (Escape, Up, C-c, ...)')
|
|
151
306
|
.action(async (name, text, opts) => {
|
|
307
|
+
const stateDir = acpStateDir(name);
|
|
308
|
+
if (stateDir && opts.key)
|
|
309
|
+
die('--key is available only for tmux sessions');
|
|
310
|
+
if (!stateDir && !opts.key && !text?.length)
|
|
311
|
+
die('nothing to send: give text or --key');
|
|
312
|
+
if (stateDir && !text?.length)
|
|
313
|
+
die('nothing to send: give text');
|
|
152
314
|
try {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
await
|
|
156
|
-
|
|
157
|
-
|
|
315
|
+
if (stateDir) {
|
|
316
|
+
// Returns on queue acceptance: a turn already running is not a failure.
|
|
317
|
+
const response = await controlRequest(stateDir, { command: 'submit_prompt', text: text.join(' ') });
|
|
318
|
+
if (!response.ok)
|
|
319
|
+
throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'prompt rejected');
|
|
320
|
+
const queued = response.result;
|
|
321
|
+
console.log(queued?.queuedBehind
|
|
322
|
+
? `queued for ${name} behind ${queued.queuedBehind} running turn(s)`
|
|
323
|
+
: `queued for ${name}`);
|
|
324
|
+
}
|
|
325
|
+
else if (opts.key)
|
|
326
|
+
await new Tmux().sendKey(name, opts.key);
|
|
158
327
|
else
|
|
159
|
-
|
|
328
|
+
await new Tmux().sendText(name, text.join(' '));
|
|
160
329
|
}
|
|
161
|
-
catch {
|
|
162
|
-
die(
|
|
330
|
+
catch (e) {
|
|
331
|
+
die(controlFailure(name, 'send', e, asControlError(e).kind === 'timeout'
|
|
332
|
+
? '\n The prompt may already have been delivered — do not assume it was lost.'
|
|
333
|
+
: ''));
|
|
163
334
|
}
|
|
164
335
|
});
|
|
165
336
|
program.command('logs <name>').description('show the role log').option('-f, --follow', 'follow')
|
|
@@ -168,7 +339,30 @@ program.command('logs <name>').description('show the role log').option('-f, --fo
|
|
|
168
339
|
process.exit(await passthrough(cmd, args));
|
|
169
340
|
});
|
|
170
341
|
program.command('status <name>').description('unit/agent state')
|
|
171
|
-
.action(async (name) => {
|
|
342
|
+
.action(async (name) => {
|
|
343
|
+
console.log(await pickBackend().status(name));
|
|
344
|
+
// A held-down role looks like a healthy running unit from the outside — the
|
|
345
|
+
// runner is alive on purpose. Say so, with the reason and when (3.2).
|
|
346
|
+
const ledger = readRestartLedger(agentDir(name));
|
|
347
|
+
if (ledger.circuit === 'open')
|
|
348
|
+
console.log(`HELD DOWN since ${ledger.openedAt ?? ledger.updatedAt} after `
|
|
349
|
+
+ `${ledger.consecutiveImmediateFailures} immediate failures: ${ledger.lastReason}`
|
|
350
|
+
+ `\n release it with: ours-fleet restart ${name}`);
|
|
351
|
+
else if (ledger.consecutiveImmediateFailures > 0)
|
|
352
|
+
console.log(`restarts: ${ledger.consecutiveImmediateFailures} consecutive immediate `
|
|
353
|
+
+ `failures, next delay ${ledger.nextDelayMs}ms (${ledger.lastReason})`);
|
|
354
|
+
const stateDir = acpStateDir(name);
|
|
355
|
+
if (stateDir) {
|
|
356
|
+
try {
|
|
357
|
+
const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
|
|
358
|
+
if (response.ok)
|
|
359
|
+
console.log(`session: ${JSON.stringify(response.result)}`);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
console.log('session: acp control unavailable');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
});
|
|
172
366
|
cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fleet.d file if spawned)'))
|
|
173
367
|
.action(async (name, opts) => {
|
|
174
368
|
try {
|
|
@@ -179,14 +373,18 @@ cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fl
|
|
|
179
373
|
}
|
|
180
374
|
});
|
|
181
375
|
cOpt(program.command('spawn <name>').description('spawn a new agent (permanent by default)'))
|
|
182
|
-
.option('--temp', 'temporary:
|
|
376
|
+
.option('--temp', 'temporary: detached supervisor, auto-cleaned, gone on reboot')
|
|
183
377
|
.option('--harness <id>', 'harness adapter (default: defaults.harness)')
|
|
378
|
+
.option('--session <backend>', 'session backend: tmux|acp (default: defaults.session or tmux)')
|
|
184
379
|
.option('--mission <text>', 'one-line mission')
|
|
185
380
|
.option('--identity <name>', 'ours identity to bind (default: role name)')
|
|
186
381
|
.option('--cwd <dir>', 'working directory')
|
|
187
382
|
.option('--coordinator <name>', 'announce target')
|
|
188
383
|
.option('--model <id>', 'model id to launch on (e.g. claude-fable-5); default: launcher default')
|
|
189
384
|
.option('--permission-mode <mode>', 'harness permission mode (Codex: untrusted|on-request|never; Claude: native values)')
|
|
385
|
+
.option('--approval <mode>', 'common approval intent: ask|allow|deny')
|
|
386
|
+
.option('--filesystem <mode>', 'common filesystem intent: read-only|workspace|unrestricted')
|
|
387
|
+
.option('--unattended <mode>', 'permission behavior without a console: deny|wait')
|
|
190
388
|
.option('--sandbox <mode>', 'Codex sandbox: read-only|workspace-write|danger-full-access')
|
|
191
389
|
.option('--profile <name>', 'Codex profile file name ($CODEX_HOME/<name>.config.toml)')
|
|
192
390
|
.option('--launcher <mode>', 'Codex launcher: auto|ours-codex|codex (default: auto)')
|
|
@@ -196,16 +394,20 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
|
|
|
196
394
|
.option('--monitor', 'explicitly consent to arm this Codex role\'s ours mail monitor')
|
|
197
395
|
.option('--bio-file <file>', 'public bio (file)')
|
|
198
396
|
.option('--persona-file <file>', 'persona / operating contract (file)')
|
|
397
|
+
.option('--isolation-file <path>', 'file holding an isolation: mapping (same schema as fleet.yaml)')
|
|
199
398
|
.action(async (name, opts) => {
|
|
200
399
|
try {
|
|
201
400
|
const o = {
|
|
202
|
-
name, temp: opts.temp, harness: opts.harness, mission: opts.mission,
|
|
401
|
+
name, temp: opts.temp, harness: opts.harness, session: opts.session, mission: opts.mission,
|
|
203
402
|
identity: opts.identity, cwd: opts.cwd, coordinator: opts.coordinator,
|
|
204
403
|
model: opts.model,
|
|
205
|
-
permissionMode: opts.permissionMode,
|
|
404
|
+
permissionMode: opts.permissionMode, approval: opts.approval,
|
|
405
|
+
filesystem: opts.filesystem, unattended: opts.unattended,
|
|
406
|
+
sandbox: opts.sandbox, profile: opts.profile,
|
|
206
407
|
launcher: opts.launcher, search: opts.search,
|
|
207
408
|
codexConfig: parseCodexConfig(opts.codexConfig), addDirs: opts.addDir, monitor: opts.monitor,
|
|
208
|
-
bioFile: opts.bioFile, personaFile: opts.personaFile,
|
|
409
|
+
bioFile: opts.bioFile, personaFile: opts.personaFile,
|
|
410
|
+
isolationFile: opts.isolationFile, configPath: opts.configuration,
|
|
209
411
|
};
|
|
210
412
|
if (o.temp) {
|
|
211
413
|
const dir = await spawnTemp(o, binPath);
|
|
@@ -215,6 +417,14 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
|
|
|
215
417
|
const file = await spawnPermanent(o, deps());
|
|
216
418
|
console.log(`spawned '${name}' (config: ${file})`);
|
|
217
419
|
}
|
|
420
|
+
// The same provenance that was persisted, so what the operator reads now
|
|
421
|
+
// and what a reviewer reads later cannot disagree (6.6).
|
|
422
|
+
if (lastProvenance) {
|
|
423
|
+
console.log(` created by ${lastProvenance.command} v${lastProvenance.fleetVersion} `
|
|
424
|
+
+ `at ${lastProvenance.createdAt} (${lastProvenance.lifetime})`);
|
|
425
|
+
for (const line of formatProvenance(lastProvenance))
|
|
426
|
+
console.log(line);
|
|
427
|
+
}
|
|
218
428
|
console.log(`→ watch it: ours-fleet peek ${name} | attach: ours-fleet attach ${name}`);
|
|
219
429
|
}
|
|
220
430
|
catch (e) {
|
|
@@ -240,8 +450,10 @@ program.command('init').description('one-time host setup (units, dirs, linger)')
|
|
|
240
450
|
program.command('_run <name>', { hidden: true }).description('internal: supervisor entrypoint')
|
|
241
451
|
.option('-c, --configuration <file>')
|
|
242
452
|
.action(async (name, opts) => {
|
|
453
|
+
// The supervised loop, not a single session: restart policy lives here now,
|
|
454
|
+
// where it can count across attempts (3.2).
|
|
243
455
|
try {
|
|
244
|
-
await
|
|
456
|
+
await runSupervised(name, { configPath: opts.configuration });
|
|
245
457
|
}
|
|
246
458
|
catch (e) {
|
|
247
459
|
die(e);
|
package/dist/config.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IsolationConfig } from './isolation/types.js';
|
|
1
|
+
import type { IsolationConfig, WrapContext } from './isolation/types.js';
|
|
2
2
|
export interface OverseeEntry {
|
|
3
3
|
role: string;
|
|
4
4
|
interval: string;
|
|
@@ -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,15 @@ export interface RoleConfig {
|
|
|
46
67
|
export interface ResolvedRole extends RoleConfig {
|
|
47
68
|
name: string;
|
|
48
69
|
harness: string;
|
|
70
|
+
session: SessionBackendId;
|
|
71
|
+
permissions: CommonPermissions;
|
|
72
|
+
/**
|
|
73
|
+
* Whether `permissions:` was actually written by the operator (on the role or
|
|
74
|
+
* in defaults), as opposed to resolved from built-in defaults. A role that
|
|
75
|
+
* states its intent only once — neutrally OR natively — has nothing to
|
|
76
|
+
* contradict, and must not be warned at (2.4).
|
|
77
|
+
*/
|
|
78
|
+
permissionsDeclared: boolean;
|
|
49
79
|
identity: string;
|
|
50
80
|
sourceFile: string;
|
|
51
81
|
monitor: MonitorConfig;
|
|
@@ -60,8 +90,16 @@ export interface FleetConfig {
|
|
|
60
90
|
}
|
|
61
91
|
export declare class ConfigError extends Error {
|
|
62
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* The runtime facts the isolation resolver needs for a role. Single-sourced so
|
|
95
|
+
* config validation, doctor, and the runner all judge the SAME mount set — a
|
|
96
|
+
* policy checked against a different context than the one that launches is not
|
|
97
|
+
* a check at all.
|
|
98
|
+
*/
|
|
99
|
+
export declare function isolationContextFor(role: ResolvedRole): WrapContext;
|
|
63
100
|
/** Load ~/fleet.yaml (or an explicit path) merged with ~/fleet.d/*.yaml drop-ins. */
|
|
64
101
|
export declare function loadConfig(configPath?: string): FleetConfig;
|
|
102
|
+
export declare function resolvePermissions(defaults: unknown, role: Partial<CommonPermissions> | undefined, file?: string, name?: string): CommonPermissions;
|
|
65
103
|
/**
|
|
66
104
|
* Merge `defaults.monitor` under the role's own `monitor:` key-by-key, validate the
|
|
67
105
|
* result, and fill code-constant defaults (design §2). `defaults.monitor.enabled`
|
package/dist/config.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
|
-
import { defaultConfigPath, fleetDDir } from './paths.js';
|
|
5
|
-
import { validateIsolationConfig } from './isolation/policy.js';
|
|
4
|
+
import { agentDir, defaultConfigPath, fleetDDir, home } from './paths.js';
|
|
5
|
+
import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from './isolation/policy.js';
|
|
6
|
+
import { getAdapter } from './harness/registry.js';
|
|
6
7
|
/** The 8 content-free event types the ours daemon appends to notifications.log. */
|
|
7
8
|
export const NOTIFY_EVENT_TYPES = [
|
|
8
9
|
'message_received', 'file_received', 'sibling_contact_added', 'local_contact_request',
|
|
@@ -49,9 +50,40 @@ export function validateMonitorConfig(raw) {
|
|
|
49
50
|
}
|
|
50
51
|
export class ConfigError extends Error {
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The runtime facts the isolation resolver needs for a role. Single-sourced so
|
|
55
|
+
* config validation, doctor, and the runner all judge the SAME mount set — a
|
|
56
|
+
* policy checked against a different context than the one that launches is not
|
|
57
|
+
* a check at all.
|
|
58
|
+
*/
|
|
59
|
+
export function isolationContextFor(role) {
|
|
60
|
+
const stateDir = agentDir(role.name, role.__temp === true);
|
|
61
|
+
const runCwd = role.cwd ?? stateDir;
|
|
62
|
+
// Ask the harness how its host state splits (5.1). An adapter that declares
|
|
63
|
+
// none keeps the historical whole-home mount.
|
|
64
|
+
let split;
|
|
65
|
+
try {
|
|
66
|
+
split = getAdapter(role.harness).isolationPaths?.(role, { stateDir, runCwd });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
split = undefined;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
stateDir,
|
|
73
|
+
runCwd,
|
|
74
|
+
home: home(),
|
|
75
|
+
harness: role.harness,
|
|
76
|
+
additionalWriteDirs: role.harness === 'codex'
|
|
77
|
+
? (role.harness_options?.add_dirs ?? [])
|
|
78
|
+
: [],
|
|
79
|
+
harnessHome: split?.home,
|
|
80
|
+
harnessRuntimeDir: split?.home ? harnessRuntimeDir(stateDir, role.harness) : undefined,
|
|
81
|
+
harnessSharedPaths: split?.shared,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
52
84
|
const NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
53
85
|
const ROLE_KEYS = [
|
|
54
|
-
'harness', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
|
|
86
|
+
'harness', 'session', 'session_options', 'permissions', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
|
|
55
87
|
'briefing_file', 'model', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
|
|
56
88
|
'isolation', 'monitor',
|
|
57
89
|
];
|
|
@@ -107,6 +139,10 @@ export function loadConfig(configPath) {
|
|
|
107
139
|
if (bad.length)
|
|
108
140
|
throw new ConfigError(`${file}: role '${name}' has unknown key(s) ${bad.join(', ')}; allowed: ${ROLE_KEYS.join(', ')}`);
|
|
109
141
|
const isolation = r.isolation ?? defaults.isolation;
|
|
142
|
+
const session = resolveSession(r.session ?? defaults.session, file, name);
|
|
143
|
+
const sessionOptions = resolveSessionOptions(defaults.session_options, r.session_options, session, file, name);
|
|
144
|
+
const permissions = resolvePermissions(defaults.permissions, r.permissions, file, name);
|
|
145
|
+
const permissionsDeclared = r.permissions !== undefined || defaults.permissions !== undefined;
|
|
110
146
|
const defaultHarnessOptions = defaults.harness_options;
|
|
111
147
|
if (defaultHarnessOptions !== undefined
|
|
112
148
|
&& (typeof defaultHarnessOptions !== 'object' || defaultHarnessOptions === null
|
|
@@ -129,6 +165,10 @@ export function loadConfig(configPath) {
|
|
|
129
165
|
name,
|
|
130
166
|
sourceFile: file,
|
|
131
167
|
harness: r.harness ?? defaults.harness ?? 'claude-code',
|
|
168
|
+
session,
|
|
169
|
+
session_options: sessionOptions,
|
|
170
|
+
permissions,
|
|
171
|
+
permissionsDeclared,
|
|
132
172
|
identity: r.identity ?? name,
|
|
133
173
|
model: r.model ?? defaults.model,
|
|
134
174
|
max_tokens: r.max_tokens ?? defaults.max_tokens,
|
|
@@ -136,10 +176,93 @@ export function loadConfig(configPath) {
|
|
|
136
176
|
isolation,
|
|
137
177
|
monitor,
|
|
138
178
|
});
|
|
179
|
+
// Forbidden-path enforcement (5.2): a mount that would breach the policy
|
|
180
|
+
// is a configuration error, caught by `config` rather than at launch.
|
|
181
|
+
if (isolation !== undefined) {
|
|
182
|
+
const role = roles[roles.length - 1];
|
|
183
|
+
try {
|
|
184
|
+
resolveIsolation(isolation, isolationContextFor(role));
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
throw new ConfigError(`${file}: role '${name}' ${e.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
139
190
|
}
|
|
140
191
|
}
|
|
141
192
|
return { roles, vars, defaults, files, startStaggerMs };
|
|
142
193
|
}
|
|
194
|
+
function resolveSession(raw, file, name) {
|
|
195
|
+
const value = raw ?? 'tmux';
|
|
196
|
+
if (value !== 'tmux' && value !== 'acp')
|
|
197
|
+
throw new ConfigError(`${file}: role '${name}' session: must be one of: tmux, acp`);
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
function resolveSessionOptions(defaults, role, session, file, name) {
|
|
201
|
+
if (defaults !== undefined && !isPlainObject(defaults))
|
|
202
|
+
throw new ConfigError(`${file}: defaults.session_options must be a map`);
|
|
203
|
+
if (role !== undefined && !isPlainObject(role))
|
|
204
|
+
throw new ConfigError(`${file}: role '${name}' session_options must be a map`);
|
|
205
|
+
const merged = {
|
|
206
|
+
...(defaults ?? {}),
|
|
207
|
+
...(role ?? {}),
|
|
208
|
+
acp: {
|
|
209
|
+
...((defaults?.acp) ?? {}),
|
|
210
|
+
...(role?.acp ?? {}),
|
|
211
|
+
},
|
|
212
|
+
tmux: {
|
|
213
|
+
...((defaults?.tmux) ?? {}),
|
|
214
|
+
...(role?.tmux ?? {}),
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
const bad = Object.keys(merged).filter(k => k !== 'acp' && k !== 'tmux');
|
|
218
|
+
if (bad.length)
|
|
219
|
+
throw new ConfigError(`${file}: role '${name}' session_options: unknown key(s) ${bad.join(', ')}`);
|
|
220
|
+
if (!isPlainObject(merged.acp) || !isPlainObject(merged.tmux))
|
|
221
|
+
throw new ConfigError(`${file}: role '${name}' session_options.${session} must be a map`);
|
|
222
|
+
const acpBad = Object.keys(merged.acp).filter(k => k !== 'command');
|
|
223
|
+
const tmuxBad = Object.keys(merged.tmux).filter(k => k !== 'boot_grace_ms');
|
|
224
|
+
if (acpBad.length)
|
|
225
|
+
throw new ConfigError(`${file}: role '${name}' session_options.acp: unknown key(s) ${acpBad.join(', ')}`);
|
|
226
|
+
if (tmuxBad.length)
|
|
227
|
+
throw new ConfigError(`${file}: role '${name}' session_options.tmux: unknown key(s) ${tmuxBad.join(', ')}`);
|
|
228
|
+
const command = merged.acp.command;
|
|
229
|
+
if (command !== undefined
|
|
230
|
+
&& !(typeof command === 'string' && command.trim())
|
|
231
|
+
&& !(Array.isArray(command) && command.length > 0
|
|
232
|
+
&& command.every(v => typeof v === 'string' && v.length > 0)))
|
|
233
|
+
throw new ConfigError(`${file}: role '${name}' session_options.acp.command must be a non-empty string or string list`);
|
|
234
|
+
const grace = merged.tmux.boot_grace_ms;
|
|
235
|
+
if (grace !== undefined
|
|
236
|
+
&& (typeof grace !== 'number' || !Number.isFinite(grace) || grace < 0))
|
|
237
|
+
throw new ConfigError(`${file}: role '${name}' session_options.tmux.boot_grace_ms must be a non-negative number`);
|
|
238
|
+
return Object.keys(merged.acp).length || Object.keys(merged.tmux).length ? merged : undefined;
|
|
239
|
+
}
|
|
240
|
+
export function resolvePermissions(defaults, role, file = 'config', name = 'role') {
|
|
241
|
+
if (defaults !== undefined && !isPlainObject(defaults))
|
|
242
|
+
throw new ConfigError(`${file}: defaults.permissions must be a map`);
|
|
243
|
+
if (role !== undefined && !isPlainObject(role))
|
|
244
|
+
throw new ConfigError(`${file}: role '${name}' permissions must be a map`);
|
|
245
|
+
const merged = {
|
|
246
|
+
...(defaults ?? {}),
|
|
247
|
+
...(role ?? {}),
|
|
248
|
+
};
|
|
249
|
+
const allowed = ['approval', 'filesystem', 'unattended'];
|
|
250
|
+
const bad = Object.keys(merged).filter(k => !allowed.includes(k));
|
|
251
|
+
if (bad.length)
|
|
252
|
+
throw new ConfigError(`${file}: role '${name}' permissions: unknown key(s) ${bad.join(', ')}`);
|
|
253
|
+
if (merged.approval !== undefined && !['ask', 'allow', 'deny'].includes(merged.approval))
|
|
254
|
+
throw new ConfigError(`${file}: role '${name}' permissions.approval must be one of: ask, allow, deny`);
|
|
255
|
+
if (merged.filesystem !== undefined
|
|
256
|
+
&& !['read-only', 'workspace', 'unrestricted'].includes(merged.filesystem))
|
|
257
|
+
throw new ConfigError(`${file}: role '${name}' permissions.filesystem must be one of: read-only, workspace, unrestricted`);
|
|
258
|
+
if (merged.unattended !== undefined && !['deny', 'wait'].includes(merged.unattended))
|
|
259
|
+
throw new ConfigError(`${file}: role '${name}' permissions.unattended must be one of: deny, wait`);
|
|
260
|
+
return {
|
|
261
|
+
approval: merged.approval ?? 'ask',
|
|
262
|
+
filesystem: merged.filesystem ?? 'workspace',
|
|
263
|
+
unattended: merged.unattended ?? 'deny',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
143
266
|
/** Validate the top-level `start_stagger_ms` (supervisor launch spacing); default 0. */
|
|
144
267
|
function resolveStartStaggerMs(raw, base) {
|
|
145
268
|
if (raw === undefined || raw === null)
|