@ours.network/fleet 0.9.5 → 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.
Files changed (53) hide show
  1. package/README.md +101 -0
  2. package/dist/atomic-file.d.ts +30 -0
  3. package/dist/atomic-file.js +86 -0
  4. package/dist/briefing.d.ts +6 -0
  5. package/dist/briefing.js +41 -11
  6. package/dist/cli.js +95 -21
  7. package/dist/config.d.ts +15 -1
  8. package/dist/config.js +47 -2
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +28 -1
  12. package/dist/docs.js +132 -0
  13. package/dist/doctor.js +74 -16
  14. package/dist/harness/claude-code.d.ts +39 -3
  15. package/dist/harness/claude-code.js +126 -24
  16. package/dist/harness/codex.d.ts +7 -1
  17. package/dist/harness/codex.js +57 -10
  18. package/dist/harness/registry.d.ts +2 -0
  19. package/dist/harness/registry.js +19 -0
  20. package/dist/harness/types.d.ts +50 -3
  21. package/dist/isolation/bubblewrap.js +7 -1
  22. package/dist/isolation/policy.d.ts +34 -5
  23. package/dist/isolation/policy.js +114 -7
  24. package/dist/isolation/resources.d.ts +6 -3
  25. package/dist/isolation/resources.js +6 -3
  26. package/dist/isolation/types.d.ts +19 -1
  27. package/dist/monitor.d.ts +30 -3
  28. package/dist/monitor.js +63 -25
  29. package/dist/ops.d.ts +15 -2
  30. package/dist/ops.js +32 -9
  31. package/dist/permissions.d.ts +70 -0
  32. package/dist/permissions.js +97 -0
  33. package/dist/runner.d.ts +65 -2
  34. package/dist/runner.js +239 -19
  35. package/dist/session/acp.d.ts +22 -1
  36. package/dist/session/acp.js +110 -26
  37. package/dist/session/control.d.ts +49 -1
  38. package/dist/session/control.js +116 -12
  39. package/dist/session/tmux.d.ts +8 -1
  40. package/dist/session/tmux.js +34 -4
  41. package/dist/session/types.d.ts +92 -1
  42. package/dist/session/types.js +42 -1
  43. package/dist/spawn.d.ts +27 -2
  44. package/dist/spawn.js +153 -15
  45. package/dist/supervisor/launchd.d.ts +50 -0
  46. package/dist/supervisor/launchd.js +121 -4
  47. package/dist/supervisor/none.js +22 -4
  48. package/dist/supervisor/systemd.d.ts +8 -1
  49. package/dist/supervisor/systemd.js +94 -4
  50. package/dist/supervisor/types.d.ts +36 -3
  51. package/dist/tmux.d.ts +34 -2
  52. package/dist/tmux.js +48 -11
  53. 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
+ }
@@ -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
- L.push(`2. BIND your predefined ours identity: call the **${v.bindTool}** tool with`);
26
- L.push(` name "${id}" force=true (search the deferred tool registry first if needed).`);
27
- L.push(` - If no such identity exists yet, call **${v.createTool}** name "${id}" once`);
28
- L.push(' to mint it, then you are bound. Re-binding your OWN identity is always allowed.');
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
- L.push(` via \`tmux attach -t ${role.name}\` or by messaging "${id}".`);
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): on each tick, run');
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('and judge the console: stuck on a prompt/menu/trust dialog → answer it directly with');
65
- L.push('`ours-fleet send <Name> "<text>"` (or `--key <K>` for raw keys); crashed to a shell ');
66
- L.push('investigate and restart; idle with work assigned nudge; healthy do nothing.');
67
- L.push('Escalate over ours messaging only when you cannot resolve it yourself.');
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 { runOnce, runTemp } from './runner.js';
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
- const tmux = await new Tmux().list();
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(`'${name}' is not running; try: ours-fleet status ${name}`);
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
- if (opts.key)
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 Error(response.error ?? 'prompt rejected');
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
- die('nothing to send: give text or --key');
328
+ await new Tmux().sendText(name, text.join(' '));
279
329
  }
280
- catch {
281
- die(`'${name}' is not running; try: ours-fleet status ${name}`);
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, configPath: opts.configuration,
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 runOnce(name, { configPath: opts.configuration });
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;