@ours.network/fleet 0.9.5 → 0.9.8
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 +101 -0
- 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 +95 -21
- package/dist/config.d.ts +15 -1
- package/dist/config.js +47 -2
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +28 -1
- package/dist/docs.js +132 -0
- package/dist/doctor.js +74 -16
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +126 -24
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +57 -10
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +50 -3
- 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 +30 -3
- package/dist/monitor.js +145 -30
- 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 +239 -19
- package/dist/session/acp.d.ts +22 -1
- package/dist/session/acp.js +110 -26
- package/dist/session/control.d.ts +49 -1
- package/dist/session/control.js +116 -12
- package/dist/session/tmux.d.ts +8 -1
- package/dist/session/tmux.js +34 -4
- package/dist/session/types.d.ts +92 -1
- package/dist/session/types.js +42 -1
- package/dist/spawn.d.ts +27 -2
- package/dist/spawn.js +153 -15
- 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 +1 -1
package/README.md
CHANGED
|
@@ -253,6 +253,107 @@ role's `harness_options`, so a fleet can set common Codex permission/profile def
|
|
|
253
253
|
and override individual keys per role. `monitor` merges the same way — a role block
|
|
254
254
|
overrides `defaults.monitor` key-by-key.
|
|
255
255
|
|
|
256
|
+
### Never-prompt failure
|
|
257
|
+
|
|
258
|
+
An unattended role has no console, so a permission request has nobody to answer
|
|
259
|
+
it and is refused **inside the harness** — no prompt, no error, no log line. The
|
|
260
|
+
agent does less than its briefing told it to, reports success, and nothing
|
|
261
|
+
distinguishes that from having done the work. It is caused by a permission mode
|
|
262
|
+
that suppresses the prompt without granting the action (Claude `dontAsk`), or by
|
|
263
|
+
`unattended: deny`.
|
|
264
|
+
|
|
265
|
+
Automatic decisions are now recorded rather than invisible. Every permission
|
|
266
|
+
decided without a human is written to
|
|
267
|
+
`~/.ours-fleet/agents/<Name>/.session-events.jsonl` with the decision, whether
|
|
268
|
+
policy or a person made it, which policy produced it, the reason, and the option
|
|
269
|
+
chosen — and `ours-fleet peek`/`attach` render them. Automatic denial always
|
|
270
|
+
asks for a one-shot rejection, never a standing one, so one unattended refusal
|
|
271
|
+
cannot disable a tool for the rest of the session. A role that can auto-deny
|
|
272
|
+
says so once at startup.
|
|
273
|
+
|
|
274
|
+
To catch this **before** a role runs, see the capability floor below —
|
|
275
|
+
`ours-fleet doctor` fails an under-permissioned unattended role rather than
|
|
276
|
+
letting it discover the problem silently.
|
|
277
|
+
|
|
278
|
+
### The unattended capability floor
|
|
279
|
+
|
|
280
|
+
A fleet role runs with no console attached, so a permission request has nobody
|
|
281
|
+
to answer it and is refused inside the harness — silently. The agent then does
|
|
282
|
+
less than its briefing told it to and reports no error at all.
|
|
283
|
+
|
|
284
|
+
`ours-fleet config` and `ours-fleet doctor` therefore resolve each role's
|
|
285
|
+
neutral `permissions:` through its harness adapter and check what the resulting
|
|
286
|
+
native settings actually grant, against a fixed floor:
|
|
287
|
+
|
|
288
|
+
| capability | what the role must be able to do |
|
|
289
|
+
| --- | --- |
|
|
290
|
+
| `read-state` | read its briefing, `ROUTINES.md`, and `WORKLOG.md` |
|
|
291
|
+
| `write-state` | append its `WORKLOG.md` and its own state files |
|
|
292
|
+
| `messaging` | bind its identity, send and receive ours mail |
|
|
293
|
+
| `monitor` | arm and observe its mail monitor |
|
|
294
|
+
| `workspace-edit` | edit and test files in its working directory |
|
|
295
|
+
| `status-commands` | run the inspection commands its briefing prescribes |
|
|
296
|
+
|
|
297
|
+
`doctor` reports this per role as `unattended floor: <Role>`. A role configured
|
|
298
|
+
`unattended: deny` that cannot meet the floor **fails** doctor — it would deny
|
|
299
|
+
those requests with nobody to see it. With `unattended: wait` it **warns**,
|
|
300
|
+
since a human can still attach a console and answer.
|
|
301
|
+
|
|
302
|
+
**Security meaning.** `approval: allow` maps to Claude's `bypassPermissions`,
|
|
303
|
+
the mode that actually permits the actions the role was authorized to take.
|
|
304
|
+
`dontAsk` suppresses only the *prompt*, not the denial, which is why an
|
|
305
|
+
`allow` role previously ran unable to do its job. Nothing but an explicit
|
|
306
|
+
`allow` is elevated: `ask` keeps Claude's default mode and `deny` maps to
|
|
307
|
+
`plan`. `allow` is a real grant — give it deliberately, and keep per-role
|
|
308
|
+
[`isolation:`](#agent-isolation) as the outer boundary, which no permission
|
|
309
|
+
mode can cross.
|
|
310
|
+
|
|
311
|
+
### Isolation at creation time
|
|
312
|
+
|
|
313
|
+
```sh
|
|
314
|
+
ours-fleet spawn Sec --isolation-file policy.yaml
|
|
315
|
+
ours-fleet spawn --temp Scout --isolation-file policy.yaml
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
`--isolation-file` supplies the role's sandbox policy when it is created, so its
|
|
319
|
+
**first** launch is already confined. Without it a role gains `isolation:` only when
|
|
320
|
+
you edit `fleet.yaml` and run `up`, and everything before that ran unsandboxed.
|
|
321
|
+
|
|
322
|
+
The file contains exactly the [`isolation:` mapping](#agent-isolation) — the same schema,
|
|
323
|
+
validated by the same code, so it cannot mean something different from the identical block
|
|
324
|
+
in `fleet.yaml`:
|
|
325
|
+
|
|
326
|
+
```yaml
|
|
327
|
+
network: deny
|
|
328
|
+
fs:
|
|
329
|
+
read: [/opt/reference]
|
|
330
|
+
resources:
|
|
331
|
+
mem: 2G
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
An invalid file is rejected before anything is created — no config, no state directory, no
|
|
335
|
+
identity reservation.
|
|
336
|
+
|
|
337
|
+
### Sandboxed credentials and configuration
|
|
338
|
+
|
|
339
|
+
A sandboxed role gets a **per-role writable harness home** under its own state directory
|
|
340
|
+
(`<state>/harness/<harness>/`), so its sessions, history and caches are its own and are
|
|
341
|
+
invisible to every other role. The **shared** credentials, global instructions and
|
|
342
|
+
configuration are layered back read-only:
|
|
343
|
+
|
|
344
|
+
| Harness | Read-only (shared) | Per-role writable |
|
|
345
|
+
| --- | --- | --- |
|
|
346
|
+
| `claude-code` | `~/.claude.json`, `~/.claude/CLAUDE.md`, `settings.json`, `plugins/` | everything else under `~/.claude` |
|
|
347
|
+
| `codex` | `~/.codex/auth.json`, `config.toml`, `AGENTS.md`, `plugins/`, `~/.agents` | everything else under `~/.codex` |
|
|
348
|
+
|
|
349
|
+
An agent can read the credentials it needs and cannot rewrite them, cannot edit the
|
|
350
|
+
instructions every role shares, and cannot alter a peer's configuration. Claude pre-trust
|
|
351
|
+
stays a host-side step performed by the fleet.
|
|
352
|
+
|
|
353
|
+
The forbidden-path list is enforced, not advisory: a role that asks for `~/.ssh`, the ours
|
|
354
|
+
key store, a sibling's state directory — or a parent directory that would expose one, or a
|
|
355
|
+
symlink to one — is refused by `ours-fleet config` before it can launch.
|
|
356
|
+
|
|
256
357
|
### Start staggering
|
|
257
358
|
|
|
258
359
|
`start_stagger_ms` (top-level, host-wide, default `0`) spaces out agent **launches**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process file protection: a lock, and a replace that cannot leave a
|
|
3
|
+
* partial file behind.
|
|
4
|
+
*
|
|
5
|
+
* Shared state written with plain `readFileSync` → mutate → `writeFileSync` has
|
|
6
|
+
* two failure modes that only appear under concurrency, which is exactly the
|
|
7
|
+
* condition a fleet creates: two starts interleave and one silently discards
|
|
8
|
+
* the other's entry, or a crash mid-write truncates the last good file.
|
|
9
|
+
*/
|
|
10
|
+
export interface LockDeps {
|
|
11
|
+
now?(): number;
|
|
12
|
+
sleep?(ms: number): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Run `fn` holding a cross-process lock. `mkdir` is atomic on every platform we
|
|
16
|
+
* support, so the directory's existence IS the lock; the timestamp inside lets a
|
|
17
|
+
* crashed holder's lock be broken rather than deadlocking the fleet forever.
|
|
18
|
+
* Same strategy as the runner's launch gate, factored out so both use one.
|
|
19
|
+
*
|
|
20
|
+
* The lock is always released, including when `fn` throws.
|
|
21
|
+
*/
|
|
22
|
+
export declare function withFileLock<T>(lockPath: string, fn: () => T | Promise<T>, deps?: LockDeps, staleMs?: number): Promise<T>;
|
|
23
|
+
/**
|
|
24
|
+
* Replace a file's contents atomically: write a temp file in the SAME directory
|
|
25
|
+
* (so the rename cannot cross a filesystem boundary), fsync it, then rename over
|
|
26
|
+
* the target. A reader either sees the old file or the new one — never a
|
|
27
|
+
* half-written one — and an interrupted write leaves the previous contents
|
|
28
|
+
* intact.
|
|
29
|
+
*/
|
|
30
|
+
export declare function replaceFileAtomically(path: string, contents: string, mode?: number): void;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync, } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
const DEFAULT_STALE_MS = 10_000;
|
|
5
|
+
const POLL_MS = 25;
|
|
6
|
+
/**
|
|
7
|
+
* Run `fn` holding a cross-process lock. `mkdir` is atomic on every platform we
|
|
8
|
+
* support, so the directory's existence IS the lock; the timestamp inside lets a
|
|
9
|
+
* crashed holder's lock be broken rather than deadlocking the fleet forever.
|
|
10
|
+
* Same strategy as the runner's launch gate, factored out so both use one.
|
|
11
|
+
*
|
|
12
|
+
* The lock is always released, including when `fn` throws.
|
|
13
|
+
*/
|
|
14
|
+
export async function withFileLock(lockPath, fn, deps = {}, staleMs = DEFAULT_STALE_MS) {
|
|
15
|
+
const now = deps.now ?? (() => Date.now());
|
|
16
|
+
const sleep = deps.sleep ?? ((ms) => new Promise(r => { setTimeout(r, ms); }));
|
|
17
|
+
const stampPath = join(lockPath, 'ts');
|
|
18
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
19
|
+
for (let waited = 0;;) {
|
|
20
|
+
try {
|
|
21
|
+
mkdirSync(lockPath);
|
|
22
|
+
writeFileSync(stampPath, String(now()));
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (e.code !== 'EEXIST')
|
|
27
|
+
throw e;
|
|
28
|
+
let held = null;
|
|
29
|
+
try {
|
|
30
|
+
const n = parseInt(readFileSync(stampPath, 'utf8').trim(), 10);
|
|
31
|
+
held = Number.isFinite(n) ? n : null;
|
|
32
|
+
}
|
|
33
|
+
catch { /* holder died between mkdir and stamp */ }
|
|
34
|
+
if ((held !== null && now() - held > staleMs) || waited > staleMs * 2) {
|
|
35
|
+
rmSync(lockPath, { recursive: true, force: true }); // break a dead holder's lock
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
await sleep(POLL_MS);
|
|
39
|
+
waited += POLL_MS;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return await fn();
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Replace a file's contents atomically: write a temp file in the SAME directory
|
|
51
|
+
* (so the rename cannot cross a filesystem boundary), fsync it, then rename over
|
|
52
|
+
* the target. A reader either sees the old file or the new one — never a
|
|
53
|
+
* half-written one — and an interrupted write leaves the previous contents
|
|
54
|
+
* intact.
|
|
55
|
+
*/
|
|
56
|
+
export function replaceFileAtomically(path, contents, mode = 0o600) {
|
|
57
|
+
const dir = dirname(path);
|
|
58
|
+
mkdirSync(dir, { recursive: true });
|
|
59
|
+
const tmp = join(dir, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
60
|
+
const fd = openSync(tmp, 'w', mode);
|
|
61
|
+
try {
|
|
62
|
+
writeSync(fd, contents);
|
|
63
|
+
fsyncSync(fd); // the bytes are on disk before anything points at them
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
renameSync(tmp, path);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
rmSync(tmp, { force: true });
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
// Best-effort: make the rename itself durable. Not supported everywhere.
|
|
76
|
+
try {
|
|
77
|
+
const dirFd = openSync(dir, 'r');
|
|
78
|
+
try {
|
|
79
|
+
fsyncSync(dirFd);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
closeSync(dirFd);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch { /* platform does not allow fsync on a directory */ }
|
|
86
|
+
}
|
package/dist/briefing.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ export interface BriefingOpts {
|
|
|
6
6
|
routinesPath: string;
|
|
7
7
|
/** Curated body (from briefing_file) replacing the narrative sections. */
|
|
8
8
|
briefingBody?: string;
|
|
9
|
+
/**
|
|
10
|
+
* What spawn actually established about the role's ours identity (7.3).
|
|
11
|
+
* Defaults to `unverified`, because a briefing generated without that
|
|
12
|
+
* knowledge must not claim one.
|
|
13
|
+
*/
|
|
14
|
+
identityGuarantee?: 'verified' | 'created' | 'unverified';
|
|
9
15
|
}
|
|
10
16
|
/** Render a role's briefing.md: narrative (or curated body) + mechanical boot steps. */
|
|
11
17
|
export declare function generateBriefing(role: ResolvedRole, v: BriefingVocab, opts: BriefingOpts): string;
|
package/dist/briefing.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { userInfo } from 'node:os';
|
|
2
|
+
import { oversightTaxonomyLines } from './session/control.js';
|
|
2
3
|
/** Render a role's briefing.md: narrative (or curated body) + mechanical boot steps. */
|
|
3
4
|
export function generateBriefing(role, v, opts) {
|
|
4
5
|
const L = [];
|
|
@@ -22,10 +23,25 @@ export function generateBriefing(role, v, opts) {
|
|
|
22
23
|
}
|
|
23
24
|
L.push('', '## Do these NOW, in order');
|
|
24
25
|
L.push(`1. ${v.launchNote(role.name)}`);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// What this says depends on what spawn actually VERIFIED (7.3). Asserting a
|
|
27
|
+
// "predefined" identity that nobody checked is how an agent ends up improvising
|
|
28
|
+
// its own infrastructure on first boot.
|
|
29
|
+
const guarantee = opts.identityGuarantee ?? 'unverified';
|
|
30
|
+
if (guarantee === 'unverified') {
|
|
31
|
+
L.push(`2. BIND your ours identity: call the **${v.bindTool}** tool with`);
|
|
32
|
+
L.push(` name "${id}" force=true (search the deferred tool registry first if needed).`);
|
|
33
|
+
L.push(` - This identity was NOT verified when your role was created, so it may not exist.`);
|
|
34
|
+
L.push(` If binding reports no such identity, call **${v.createTool}** name "${id}" once`);
|
|
35
|
+
L.push(' to mint it, then you are bound. Re-binding your OWN identity is always allowed.');
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
L.push(`2. BIND your ours identity: call the **${v.bindTool}** tool with`);
|
|
39
|
+
L.push(` name "${id}" force=true (search the deferred tool registry first if needed).`);
|
|
40
|
+
L.push(` - It was ${guarantee === 'created' ? 'created' : 'verified to exist'} when your role`);
|
|
41
|
+
L.push(' was created, so binding should succeed. If it unexpectedly reports no such');
|
|
42
|
+
L.push(` identity, call **${v.createTool}** name "${id}" once and report the discrepancy —`);
|
|
43
|
+
L.push(' something removed it after your role was created.');
|
|
44
|
+
}
|
|
29
45
|
L.push(`3. RECONCILE your profile (idempotent): call **${v.currentIdentityTool}** and read your`);
|
|
30
46
|
L.push(' current bio and persona, so you only write below when they actually differ.');
|
|
31
47
|
L.push(`4. PUBLISH your public **bio** via **${v.setBioTool}**`);
|
|
@@ -50,7 +66,10 @@ export function generateBriefing(role, v, opts) {
|
|
|
50
66
|
L.push(`7. Await messages. When the monitor wakes you (or the owner requests a manual check),`);
|
|
51
67
|
L.push(` call **${v.getMessagesTool}**, act on them,`);
|
|
52
68
|
L.push(` and reply with ${v.sendTool}. No coordinator is configured — the owner drives you`);
|
|
53
|
-
|
|
69
|
+
// NOT `tmux attach -t <name>`: each role's pane lives on its own tmux
|
|
70
|
+
// socket (#32), so a bare attach finds no server. `ours-fleet attach`
|
|
71
|
+
// addresses the right one.
|
|
72
|
+
L.push(` via \`ours-fleet attach ${role.name}\` or by messaging "${id}".`);
|
|
54
73
|
}
|
|
55
74
|
if (role.oversee?.length) {
|
|
56
75
|
L.push('', '## Oversight assignments');
|
|
@@ -58,13 +77,24 @@ export function generateBriefing(role, v, opts) {
|
|
|
58
77
|
for (const o of role.oversee)
|
|
59
78
|
L.push(`- **${o.role}** — check every ${o.interval}`);
|
|
60
79
|
L.push('');
|
|
61
|
-
L.push('Procedure (see also the oversee-agents skill if available)
|
|
80
|
+
L.push('Procedure (see also the oversee-agents skill if available). On each tick, for each ward');
|
|
81
|
+
L.push('run BOTH — they answer different questions:');
|
|
62
82
|
for (const o of role.oversee)
|
|
63
|
-
L.push(`\`ours-fleet peek ${o.role}\``);
|
|
64
|
-
L.push('
|
|
65
|
-
L.push('
|
|
66
|
-
L.push('
|
|
67
|
-
L.push('
|
|
83
|
+
L.push(`\`ours-fleet status ${o.role}\` then \`ours-fleet peek ${o.role}\``);
|
|
84
|
+
L.push('');
|
|
85
|
+
L.push('**One console command is not a liveness verdict.** A `peek` or `send` that fails tells');
|
|
86
|
+
L.push('you what happened to YOUR REQUEST, and only one of its outcomes says the agent is gone.');
|
|
87
|
+
L.push('Read the result you actually got:');
|
|
88
|
+
L.push('');
|
|
89
|
+
L.push(...oversightTaxonomyLines());
|
|
90
|
+
L.push('');
|
|
91
|
+
L.push('Never translate any other failure into "dead". A busy agent, an unanswered control');
|
|
92
|
+
L.push('plane and a confirmed stop look identical if you only look at one command.');
|
|
93
|
+
L.push('');
|
|
94
|
+
L.push('Then judge the console content: stuck on a prompt/menu/trust dialog → answer it directly');
|
|
95
|
+
L.push('with `ours-fleet send <Name> "<text>"` (or `--key <K>` for raw keys); idle with work');
|
|
96
|
+
L.push('assigned → nudge; actively working → do nothing, and do not mistake a long turn for a');
|
|
97
|
+
L.push('stall. Escalate over ours messaging only when you cannot resolve it yourself.');
|
|
68
98
|
}
|
|
69
99
|
L.push('', '## Durable log');
|
|
70
100
|
L.push(`Append important commands / decisions / results to \`${opts.worklogPath}\` as you go —`);
|
package/dist/cli.js
CHANGED
|
@@ -8,14 +8,17 @@ import { Command } from 'commander';
|
|
|
8
8
|
import { VERSION } from './version.js';
|
|
9
9
|
import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir } from './paths.js';
|
|
10
10
|
import { loadConfig } from './config.js';
|
|
11
|
-
import { Tmux } from './tmux.js';
|
|
11
|
+
import { Tmux, tmuxArgs } from './tmux.js';
|
|
12
12
|
import { pickBackend } from './supervisor/index.js';
|
|
13
13
|
import { up, down, restartRoles, rmRole } from './ops.js';
|
|
14
|
-
import {
|
|
15
|
-
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';
|
|
16
17
|
import { doctor } from './doctor.js';
|
|
18
|
+
import { allWarnings, analyzeFleetPermissions, formatNative } from './permissions.js';
|
|
17
19
|
import { AI_DOCS } from './docs.js';
|
|
18
|
-
import { controlRequest, controlSocketPath, followControl, } from './session/control.js';
|
|
20
|
+
import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
|
|
21
|
+
import { SessionControlError } from './session/types.js';
|
|
19
22
|
import './harness/claude-code.js'; // registers the claude-code adapter
|
|
20
23
|
import './harness/codex.js'; // registers the codex adapter
|
|
21
24
|
// sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
|
|
@@ -68,6 +71,17 @@ function renderSessionEvent(event) {
|
|
|
68
71
|
console.log(`\n[${event.kind}] ${event.title ?? event.toolCallId ?? ''} ${event.status ?? ''}`.trimEnd());
|
|
69
72
|
break;
|
|
70
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
|
+
}
|
|
71
85
|
console.log(`\n[permission ${event.permissionId}] ${event.title ?? ''}`);
|
|
72
86
|
for (const option of event.options ?? [])
|
|
73
87
|
console.log(` ${option.optionId}: ${option.name} (${option.kind})`);
|
|
@@ -106,11 +120,18 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
106
120
|
try {
|
|
107
121
|
const cfg = loadConfig(opts.configuration);
|
|
108
122
|
console.log(`config: ${cfg.files.join(' + ') || '(none)'}`);
|
|
123
|
+
const analyses = analyzeFleetPermissions(cfg.roles);
|
|
109
124
|
for (const r of cfg.roles) {
|
|
125
|
+
const perms = analyses.find(a => a.role === r.name);
|
|
110
126
|
console.log(`\n● ${r.name}`);
|
|
111
127
|
console.log(` harness: ${r.harness}`);
|
|
112
128
|
console.log(` session: ${r.session}`);
|
|
113
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)'}`);
|
|
114
135
|
console.log(` source: ${r.sourceFile}`);
|
|
115
136
|
if (r.cwd)
|
|
116
137
|
console.log(` cwd: ${r.cwd}`);
|
|
@@ -134,6 +155,8 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
134
155
|
console.log(` isolation: backend=${iso.backend ?? 'auto'} net=${iso.network ?? 'broker'} `
|
|
135
156
|
+ `on_unavailable=${iso.on_unavailable ?? 'warn'} caps=${caps}`);
|
|
136
157
|
}
|
|
158
|
+
for (const w of perms ? allWarnings(perms) : [])
|
|
159
|
+
console.log(` warning: ${w}`);
|
|
137
160
|
}
|
|
138
161
|
}
|
|
139
162
|
catch (e) {
|
|
@@ -178,12 +201,15 @@ cOpt(program.command('force-restart [names...]').description('re-sync + bounce F
|
|
|
178
201
|
});
|
|
179
202
|
program.command('ls').description('list running fleet sessions')
|
|
180
203
|
.action(async () => {
|
|
181
|
-
|
|
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 = [];
|
|
182
207
|
const acp = [];
|
|
183
208
|
for (const root of [agentsRoot(), tmpRoot()]) {
|
|
184
209
|
if (!existsSync(root))
|
|
185
210
|
continue;
|
|
186
211
|
for (const name of readdirSync(root)) {
|
|
212
|
+
names.push(name);
|
|
187
213
|
const stateDir = joinPath(root, name);
|
|
188
214
|
if (!existsSync(controlSocketPath(stateDir)))
|
|
189
215
|
continue;
|
|
@@ -195,13 +221,14 @@ program.command('ls').description('list running fleet sessions')
|
|
|
195
221
|
catch { /* ignore stale sockets */ }
|
|
196
222
|
}
|
|
197
223
|
}
|
|
224
|
+
const tmux = await new Tmux().list(names);
|
|
198
225
|
console.log([tmux, ...acp].filter(Boolean).join('\n') || '(none)');
|
|
199
226
|
});
|
|
200
227
|
program.command('attach <name>').description('open the live console (Ctrl-b d to leave)')
|
|
201
228
|
.action(async (name) => {
|
|
202
229
|
const stateDir = acpStateDir(name);
|
|
203
230
|
if (!stateDir)
|
|
204
|
-
process.exit(await passthrough('tmux', ['attach', '-t', name]));
|
|
231
|
+
process.exit(await passthrough('tmux', tmuxArgs(name, ['attach', '-t', name])));
|
|
205
232
|
try {
|
|
206
233
|
const { socket, send } = await followControl(stateDir, message => {
|
|
207
234
|
if ('event' in message)
|
|
@@ -238,12 +265,30 @@ program.command('attach <name>').description('open the live console (Ctrl-b d to
|
|
|
238
265
|
die(e);
|
|
239
266
|
}
|
|
240
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
|
+
};
|
|
241
284
|
program.command('peek <name> [lines]').description('pane snapshot without attaching')
|
|
242
285
|
.action(async (name, lines) => {
|
|
243
286
|
try {
|
|
244
287
|
const stateDir = acpStateDir(name);
|
|
245
288
|
if (stateDir) {
|
|
246
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');
|
|
247
292
|
const events = response.result?.events ?? [];
|
|
248
293
|
for (const event of events.slice(-(lines ? Number(lines) : 40)))
|
|
249
294
|
renderSessionEvent(event);
|
|
@@ -252,33 +297,40 @@ program.command('peek <name> [lines]').description('pane snapshot without attach
|
|
|
252
297
|
console.log(await new Tmux().capture(name, lines ? Number(lines) : 40));
|
|
253
298
|
}
|
|
254
299
|
}
|
|
255
|
-
catch {
|
|
256
|
-
die(
|
|
300
|
+
catch (e) {
|
|
301
|
+
die(controlFailure(name, 'peek', e));
|
|
257
302
|
}
|
|
258
303
|
});
|
|
259
304
|
program.command('send <name> [text...]').description("type into the agent's console")
|
|
260
305
|
.option('--key <key>', 'send a raw key instead (Escape, Up, C-c, ...)')
|
|
261
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');
|
|
262
314
|
try {
|
|
263
|
-
const stateDir = acpStateDir(name);
|
|
264
315
|
if (stateDir) {
|
|
265
|
-
|
|
266
|
-
die('--key is available only for tmux sessions');
|
|
267
|
-
if (!text?.length)
|
|
268
|
-
die('nothing to send: give text');
|
|
316
|
+
// Returns on queue acceptance: a turn already running is not a failure.
|
|
269
317
|
const response = await controlRequest(stateDir, { command: 'submit_prompt', text: text.join(' ') });
|
|
270
318
|
if (!response.ok)
|
|
271
|
-
throw new
|
|
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}`);
|
|
272
324
|
}
|
|
273
325
|
else if (opts.key)
|
|
274
326
|
await new Tmux().sendKey(name, opts.key);
|
|
275
|
-
else if (text?.length)
|
|
276
|
-
await new Tmux().sendText(name, text.join(' '));
|
|
277
327
|
else
|
|
278
|
-
|
|
328
|
+
await new Tmux().sendText(name, text.join(' '));
|
|
279
329
|
}
|
|
280
|
-
catch {
|
|
281
|
-
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
|
+
: ''));
|
|
282
334
|
}
|
|
283
335
|
});
|
|
284
336
|
program.command('logs <name>').description('show the role log').option('-f, --follow', 'follow')
|
|
@@ -289,6 +341,16 @@ program.command('logs <name>').description('show the role log').option('-f, --fo
|
|
|
289
341
|
program.command('status <name>').description('unit/agent state')
|
|
290
342
|
.action(async (name) => {
|
|
291
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})`);
|
|
292
354
|
const stateDir = acpStateDir(name);
|
|
293
355
|
if (stateDir) {
|
|
294
356
|
try {
|
|
@@ -332,6 +394,7 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
|
|
|
332
394
|
.option('--monitor', 'explicitly consent to arm this Codex role\'s ours mail monitor')
|
|
333
395
|
.option('--bio-file <file>', 'public bio (file)')
|
|
334
396
|
.option('--persona-file <file>', 'persona / operating contract (file)')
|
|
397
|
+
.option('--isolation-file <path>', 'file holding an isolation: mapping (same schema as fleet.yaml)')
|
|
335
398
|
.action(async (name, opts) => {
|
|
336
399
|
try {
|
|
337
400
|
const o = {
|
|
@@ -343,7 +406,8 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
|
|
|
343
406
|
sandbox: opts.sandbox, profile: opts.profile,
|
|
344
407
|
launcher: opts.launcher, search: opts.search,
|
|
345
408
|
codexConfig: parseCodexConfig(opts.codexConfig), addDirs: opts.addDir, monitor: opts.monitor,
|
|
346
|
-
bioFile: opts.bioFile, personaFile: opts.personaFile,
|
|
409
|
+
bioFile: opts.bioFile, personaFile: opts.personaFile,
|
|
410
|
+
isolationFile: opts.isolationFile, configPath: opts.configuration,
|
|
347
411
|
};
|
|
348
412
|
if (o.temp) {
|
|
349
413
|
const dir = await spawnTemp(o, binPath);
|
|
@@ -353,6 +417,14 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
|
|
|
353
417
|
const file = await spawnPermanent(o, deps());
|
|
354
418
|
console.log(`spawned '${name}' (config: ${file})`);
|
|
355
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
|
+
}
|
|
356
428
|
console.log(`→ watch it: ours-fleet peek ${name} | attach: ours-fleet attach ${name}`);
|
|
357
429
|
}
|
|
358
430
|
catch (e) {
|
|
@@ -378,8 +450,10 @@ program.command('init').description('one-time host setup (units, dirs, linger)')
|
|
|
378
450
|
program.command('_run <name>', { hidden: true }).description('internal: supervisor entrypoint')
|
|
379
451
|
.option('-c, --configuration <file>')
|
|
380
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).
|
|
381
455
|
try {
|
|
382
|
-
await
|
|
456
|
+
await runSupervised(name, { configPath: opts.configuration });
|
|
383
457
|
}
|
|
384
458
|
catch (e) {
|
|
385
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;
|
|
@@ -69,6 +69,13 @@ export interface ResolvedRole extends RoleConfig {
|
|
|
69
69
|
harness: string;
|
|
70
70
|
session: SessionBackendId;
|
|
71
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;
|
|
72
79
|
identity: string;
|
|
73
80
|
sourceFile: string;
|
|
74
81
|
monitor: MonitorConfig;
|
|
@@ -83,6 +90,13 @@ export interface FleetConfig {
|
|
|
83
90
|
}
|
|
84
91
|
export declare class ConfigError extends Error {
|
|
85
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;
|
|
86
100
|
/** Load ~/fleet.yaml (or an explicit path) merged with ~/fleet.d/*.yaml drop-ins. */
|
|
87
101
|
export declare function loadConfig(configPath?: string): FleetConfig;
|
|
88
102
|
export declare function resolvePermissions(defaults: unknown, role: Partial<CommonPermissions> | undefined, file?: string, name?: string): CommonPermissions;
|