@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.
Files changed (59) hide show
  1. package/README.md +148 -30
  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 +238 -26
  7. package/dist/config.d.ts +39 -1
  8. package/dist/config.js +126 -3
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +34 -0
  12. package/dist/docs.js +309 -0
  13. package/dist/doctor.js +123 -21
  14. package/dist/harness/acp-agent.d.ts +11 -0
  15. package/dist/harness/acp-agent.js +27 -0
  16. package/dist/harness/claude-code.d.ts +39 -3
  17. package/dist/harness/claude-code.js +145 -13
  18. package/dist/harness/codex.d.ts +7 -1
  19. package/dist/harness/codex.js +89 -4
  20. package/dist/harness/registry.d.ts +2 -0
  21. package/dist/harness/registry.js +19 -0
  22. package/dist/harness/types.d.ts +59 -1
  23. package/dist/index.d.ts +6 -3
  24. package/dist/index.js +3 -1
  25. package/dist/isolation/bubblewrap.js +7 -1
  26. package/dist/isolation/policy.d.ts +34 -5
  27. package/dist/isolation/policy.js +114 -7
  28. package/dist/isolation/resources.d.ts +6 -3
  29. package/dist/isolation/resources.js +6 -3
  30. package/dist/isolation/types.d.ts +19 -1
  31. package/dist/monitor.d.ts +44 -2
  32. package/dist/monitor.js +177 -42
  33. package/dist/ops.d.ts +15 -2
  34. package/dist/ops.js +32 -9
  35. package/dist/permissions.d.ts +70 -0
  36. package/dist/permissions.js +97 -0
  37. package/dist/runner.d.ts +65 -2
  38. package/dist/runner.js +307 -32
  39. package/dist/session/acp.d.ts +70 -0
  40. package/dist/session/acp.js +364 -0
  41. package/dist/session/control.d.ts +89 -0
  42. package/dist/session/control.js +322 -0
  43. package/dist/session/events.d.ts +14 -0
  44. package/dist/session/events.js +67 -0
  45. package/dist/session/tmux.d.ts +27 -0
  46. package/dist/session/tmux.js +76 -0
  47. package/dist/session/types.d.ts +138 -0
  48. package/dist/session/types.js +42 -0
  49. package/dist/spawn.d.ts +32 -2
  50. package/dist/spawn.js +177 -16
  51. package/dist/supervisor/launchd.d.ts +50 -0
  52. package/dist/supervisor/launchd.js +121 -4
  53. package/dist/supervisor/none.js +22 -4
  54. package/dist/supervisor/systemd.d.ts +8 -1
  55. package/dist/supervisor/systemd.js +94 -4
  56. package/dist/supervisor/types.d.ts +36 -3
  57. package/dist/tmux.d.ts +34 -2
  58. package/dist/tmux.js +48 -11
  59. package/package.json +7 -2
@@ -0,0 +1,179 @@
1
+ import { type LockDeps } from './atomic-file.js';
2
+ import { type FetchLike } from './monitor.js';
3
+ export type ReservationKind = 'role' | 'identity';
4
+ export interface Reservation {
5
+ kind: ReservationKind;
6
+ name: string;
7
+ }
8
+ export declare class CreationConflictError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ /**
12
+ * Artifacts a transaction created, newest last. Rollback walks this in reverse,
13
+ * and may only delete what THIS transaction made — an object that already
14
+ * existed is never touched.
15
+ */
16
+ export interface JournalEntry {
17
+ stage: string;
18
+ undo(): void | Promise<void>;
19
+ }
20
+ export interface CreationDeps {
21
+ lock?: LockDeps;
22
+ log?(line: string): void;
23
+ /** Reserve the ours identity name. Injectable so tests need no daemon. */
24
+ identityRegistry?: IdentityRegistry;
25
+ /** Verify/create the ours identity. Injectable so tests need no daemon. */
26
+ identityProvisioner?: IdentityProvisioner;
27
+ }
28
+ /**
29
+ * The contract the ours daemon must satisfy for identity names to be reserved
30
+ * atomically across ALL of its clients, not just across fleet processes.
31
+ *
32
+ * `check-then-create` is not atomic across processes, which is the whole point:
33
+ * two spawns can both observe a free identity name and both create it. The
34
+ * daemon is the only component that sees every client, so only the daemon can
35
+ * make the reservation authoritative.
36
+ */
37
+ export interface IdentityRegistry {
38
+ /** Claim `name`. Returns false if it is already taken or reserved. */
39
+ reserve(name: string): Promise<boolean>;
40
+ /** Give the claim back (rollback). Must be safe to call on an unheld name. */
41
+ release(name: string): Promise<void>;
42
+ }
43
+ /**
44
+ * Host-local identity reservation: atomic across every ours-fleet process on
45
+ * this host, because it is taken under the same host-wide creation lock as the
46
+ * role name.
47
+ *
48
+ * It is NOT atomic against other clients of the same ours daemon — another tool
49
+ * creating the identity between our reservation and our creation would still
50
+ * win. Closing that needs a reserve/commit/release operation in the daemon
51
+ * itself; see the release notes.
52
+ */
53
+ export declare const hostLocalIdentityRegistry: IdentityRegistry;
54
+ export interface CreationTransaction {
55
+ /** Record an artifact this transaction created, with how to undo it. */
56
+ record(entry: JournalEntry): void;
57
+ /** Stages recorded so far, in order. */
58
+ readonly stages: string[];
59
+ }
60
+ /**
61
+ * Run `body` inside a creation transaction.
62
+ *
63
+ * Under one host-wide lock: both names are reserved, then `body` builds the
64
+ * role. If anything throws, every recorded stage is undone in reverse order and
65
+ * both reservations are released, so the names can be reused immediately. On
66
+ * success the reservations are released too — the role's own config and state
67
+ * are the durable record from then on.
68
+ *
69
+ * Rollback errors are collected and reported, never allowed to hide the failure
70
+ * that caused the rollback.
71
+ */
72
+ export declare function withCreationTransaction<T>(names: {
73
+ role: string;
74
+ identity: string;
75
+ }, body: (tx: CreationTransaction) => Promise<T>, deps?: CreationDeps): Promise<T>;
76
+ /** Forget reservations left behind by a process that died mid-transaction. */
77
+ export declare function clearStaleReservations(olderThanMs?: number, now?: number): number;
78
+ /**
79
+ * Identity provisioning (7.3). The fleet must know — before the harness starts
80
+ * — whether the role's identity exists, and create it when it does not.
81
+ *
82
+ * `exists()` is answerable today: the daemon's authenticated `/identities`
83
+ * endpoint is already used by doctor. `create()` is NOT: `ours-mcp` exposes only
84
+ * `create-root`, and role identities are minted through the MCP `create_identity`
85
+ * tool inside an agent session. So creation is a seam, injected by whoever can
86
+ * satisfy it, and its absence is reported rather than papered over.
87
+ */
88
+ export interface IdentityProvisioner {
89
+ /** Does this identity exist? `unknown` when the daemon could not be asked. */
90
+ exists(name: string): Promise<boolean | 'unknown'>;
91
+ /** Create it, publishing bio/persona through the same path. Absent = cannot. */
92
+ create?(name: string, profile: {
93
+ bio?: string;
94
+ persona?: string;
95
+ }): Promise<void>;
96
+ /**
97
+ * Undo a `create` during rollback. Only ever called for an identity THIS
98
+ * transaction created; absent means "cannot", and the orphan is reported.
99
+ */
100
+ remove?(name: string): Promise<void>;
101
+ }
102
+ export type IdentityGuarantee = {
103
+ state: 'verified';
104
+ detail: string;
105
+ } | {
106
+ state: 'created';
107
+ detail: string;
108
+ } | {
109
+ state: 'unverified';
110
+ detail: string;
111
+ };
112
+ /**
113
+ * Establish the identity before the role's service is enabled.
114
+ *
115
+ * Returns what was actually GUARANTEED, so the generated briefing can say
116
+ * something true instead of asserting a "predefined" identity nobody checked —
117
+ * the failure a real agent hit on its first boot, having been told to bind an
118
+ * identity that did not exist.
119
+ */
120
+ export declare function ensureIdentity(name: string, profile: {
121
+ bio?: string;
122
+ persona?: string;
123
+ }, provisioner: IdentityProvisioner | undefined, log?: (line: string) => void): Promise<IdentityGuarantee>;
124
+ /**
125
+ * Ask the running ours daemon whether an identity exists, over the same
126
+ * authenticated endpoint doctor already probes. Answers `unknown` rather than
127
+ * guessing when the daemon cannot be reached — an unreachable daemon is not
128
+ * evidence that the identity is missing.
129
+ *
130
+ * It deliberately has no `create()`: role identities are minted through the MCP
131
+ * `create_identity` tool, and inventing a daemon endpoint we cannot test is the
132
+ * failure mode this release exists to stop.
133
+ */
134
+ export declare function daemonIdentityProvisioner(env?: NodeJS.ProcessEnv, fetchImpl?: FetchLike): IdentityProvisioner;
135
+ /** Atomically write a role's fleet.d file, journalling it for rollback. */
136
+ export declare function writeRoleFile(tx: CreationTransaction, file: string, contents: string): void;
137
+ /** Where a setting's effective value came from. */
138
+ export type ProvenanceSource = 'cli' | 'fleet-default' | 'built-in';
139
+ export interface ProvenanceEntry {
140
+ value: unknown;
141
+ source: ProvenanceSource;
142
+ }
143
+ export interface CreationProvenance {
144
+ version: 1;
145
+ /** The command that created the role, without its arguments. */
146
+ command: string;
147
+ fleetVersion: string;
148
+ createdAt: string;
149
+ lifetime: 'permanent' | 'temporary';
150
+ role: string;
151
+ /** Effective settings, each tagged with where its value came from. */
152
+ settings: Record<string, ProvenanceEntry>;
153
+ }
154
+ export declare const CREATION_PROVENANCE_FILE = "creation.json";
155
+ /**
156
+ * Record HOW a role was created, so nobody has to remember.
157
+ *
158
+ * Six months on, "why does this role have `approval: allow`?" is unanswerable:
159
+ * the resolved config shows the value but not whether an operator typed it, a
160
+ * fleet default supplied it, or it fell through to a built-in. Those have very
161
+ * different implications for whether it is safe to change.
162
+ *
163
+ * Deliberately excluded: `env`, `bio`, `persona`, and `harness_options`. The
164
+ * first two can carry credentials, and this file exists to be read — it must
165
+ * never become a place secrets accumulate.
166
+ */
167
+ export declare function buildProvenance(o: {
168
+ role: string;
169
+ lifetime: 'permanent' | 'temporary';
170
+ fleetVersion: string;
171
+ now?: Date;
172
+ settings: Record<string, ProvenanceEntry>;
173
+ }): CreationProvenance;
174
+ /** Write the provenance record atomically, before the role is started. */
175
+ export declare function writeProvenance(stateDir: string, p: CreationProvenance): void;
176
+ /** One concise line per non-built-in setting, for the post-creation summary. */
177
+ export declare function formatProvenance(p: CreationProvenance): string[];
178
+ /** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
179
+ export declare function provenanceOf(cliValue: unknown, fleetDefault: unknown, builtIn?: unknown): ProvenanceEntry;
@@ -0,0 +1,254 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { replaceFileAtomically, withFileLock } from './atomic-file.js';
4
+ import { stateRoot } from './paths.js';
5
+ import { resolveEndpoint } from './monitor.js';
6
+ /**
7
+ * One creation transaction: role name and ours identity reserved together,
8
+ * every artifact journalled, and everything undone in reverse on failure.
9
+ *
10
+ * The problem this replaces is a check followed by a create. `assertNameFree()`
11
+ * read the config and the agent dirs, returned, and only then did the caller
12
+ * start writing — so two concurrent spawns of the same name both passed the
13
+ * check, both wrote, and the second silently overwrote the first's fleet.d file
14
+ * while inheriting its half-built state. Nothing was atomic and nothing was
15
+ * undone.
16
+ */
17
+ /** Where host-wide creation state lives. One directory, so it is easy to inspect. */
18
+ const creationRoot = () => join(stateRoot(), 'creation');
19
+ const creationLock = () => join(creationRoot(), '.lock');
20
+ const reservationsDir = () => join(creationRoot(), 'reservations');
21
+ /** A reservation is one file; its existence IS the claim. */
22
+ const reservationPath = (kind, name) => join(reservationsDir(), `${kind}-${encodeURIComponent(name)}`);
23
+ export class CreationConflictError extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'CreationConflictError';
27
+ }
28
+ }
29
+ /**
30
+ * Host-local identity reservation: atomic across every ours-fleet process on
31
+ * this host, because it is taken under the same host-wide creation lock as the
32
+ * role name.
33
+ *
34
+ * It is NOT atomic against other clients of the same ours daemon — another tool
35
+ * creating the identity between our reservation and our creation would still
36
+ * win. Closing that needs a reserve/commit/release operation in the daemon
37
+ * itself; see the release notes.
38
+ */
39
+ export const hostLocalIdentityRegistry = {
40
+ async reserve(name) {
41
+ const p = reservationPath('identity', name);
42
+ if (existsSync(p))
43
+ return false;
44
+ mkdirSync(reservationsDir(), { recursive: true });
45
+ writeFileSync(p, `${process.pid} ${new Date().toISOString()}\n`);
46
+ return true;
47
+ },
48
+ async release(name) {
49
+ rmSync(reservationPath('identity', name), { force: true });
50
+ },
51
+ };
52
+ /** Is this role name already reserved by an in-flight transaction? */
53
+ const roleReserved = (name) => existsSync(reservationPath('role', name));
54
+ /**
55
+ * Run `body` inside a creation transaction.
56
+ *
57
+ * Under one host-wide lock: both names are reserved, then `body` builds the
58
+ * role. If anything throws, every recorded stage is undone in reverse order and
59
+ * both reservations are released, so the names can be reused immediately. On
60
+ * success the reservations are released too — the role's own config and state
61
+ * are the durable record from then on.
62
+ *
63
+ * Rollback errors are collected and reported, never allowed to hide the failure
64
+ * that caused the rollback.
65
+ */
66
+ export async function withCreationTransaction(names, body, deps = {}) {
67
+ const log = deps.log ?? (() => { });
68
+ const registry = deps.identityRegistry ?? hostLocalIdentityRegistry;
69
+ mkdirSync(creationRoot(), { recursive: true });
70
+ const journal = [];
71
+ const tx = {
72
+ record: entry => { journal.push(entry); },
73
+ get stages() { return journal.map(e => e.stage); },
74
+ };
75
+ // Both names, one boundary. Reserving the role and then the identity without
76
+ // a shared lock would let two spawns each win one.
77
+ const held = await withFileLock(creationLock(), async () => {
78
+ if (roleReserved(names.role))
79
+ throw new CreationConflictError(`role '${names.role}' is being created by another process right now`);
80
+ if (!await registry.reserve(names.identity))
81
+ throw new CreationConflictError(`ours identity '${names.identity}' is already taken or being created right now`);
82
+ mkdirSync(reservationsDir(), { recursive: true });
83
+ writeFileSync(reservationPath('role', names.role), `${process.pid} ${new Date().toISOString()}\n`);
84
+ return true;
85
+ }, deps.lock);
86
+ if (!held)
87
+ throw new CreationConflictError('could not take the creation lock');
88
+ const releaseAll = async () => {
89
+ rmSync(reservationPath('role', names.role), { force: true });
90
+ await registry.release(names.identity).catch(() => undefined);
91
+ };
92
+ try {
93
+ const result = await body(tx);
94
+ await releaseAll();
95
+ return result;
96
+ }
97
+ catch (error) {
98
+ const rollbackFailures = [];
99
+ for (const entry of [...journal].reverse()) {
100
+ try {
101
+ await entry.undo();
102
+ }
103
+ catch (e) {
104
+ rollbackFailures.push(`${entry.stage}: ${e.message}`);
105
+ }
106
+ }
107
+ await releaseAll();
108
+ const original = error instanceof Error ? error : new Error(String(error));
109
+ if (rollbackFailures.length) {
110
+ log(`creation rollback incomplete: ${rollbackFailures.join('; ')}`);
111
+ original.message += ` (rollback also failed: ${rollbackFailures.join('; ')})`;
112
+ }
113
+ throw original;
114
+ }
115
+ }
116
+ /** Forget reservations left behind by a process that died mid-transaction. */
117
+ export function clearStaleReservations(olderThanMs = 60_000, now = Date.now()) {
118
+ const dir = reservationsDir();
119
+ if (!existsSync(dir))
120
+ return 0;
121
+ let cleared = 0;
122
+ for (const f of readdirSyncSafe(dir)) {
123
+ const p = join(dir, f);
124
+ try {
125
+ const stamp = Date.parse(readFileSync(p, 'utf8').trim().split(/\s+/)[1] ?? '');
126
+ if (Number.isFinite(stamp) && now - stamp > olderThanMs) {
127
+ rmSync(p, { force: true });
128
+ cleared++;
129
+ }
130
+ }
131
+ catch { /* unreadable: leave it for a human */ }
132
+ }
133
+ return cleared;
134
+ }
135
+ function readdirSyncSafe(dir) {
136
+ try {
137
+ return readdirSync(dir);
138
+ }
139
+ catch {
140
+ return [];
141
+ }
142
+ }
143
+ /**
144
+ * Establish the identity before the role's service is enabled.
145
+ *
146
+ * Returns what was actually GUARANTEED, so the generated briefing can say
147
+ * something true instead of asserting a "predefined" identity nobody checked —
148
+ * the failure a real agent hit on its first boot, having been told to bind an
149
+ * identity that did not exist.
150
+ */
151
+ export async function ensureIdentity(name, profile, provisioner, log = () => { }) {
152
+ if (!provisioner)
153
+ return { state: 'unverified', detail: 'no identity provisioner is configured' };
154
+ let present;
155
+ try {
156
+ present = await provisioner.exists(name);
157
+ }
158
+ catch (e) {
159
+ present = 'unknown';
160
+ log(`identity '${name}': could not be verified (${e.message})`);
161
+ }
162
+ if (present === true)
163
+ return { state: 'verified', detail: 'the ours daemon reports it exists' };
164
+ if (present === 'unknown')
165
+ return { state: 'unverified', detail: 'the ours daemon could not be asked' };
166
+ if (!provisioner.create) {
167
+ // Loud, and named. The briefing will tell the agent to mint it — which is
168
+ // what actually happens today — but nobody is told it was "predefined".
169
+ log(`identity '${name}' does not exist and this host cannot create one automatically — `
170
+ + `the role will be told to mint it on first boot. Create it in advance to avoid that.`);
171
+ return { state: 'unverified', detail: 'it does not exist and cannot be created here' };
172
+ }
173
+ await provisioner.create(name, profile);
174
+ return { state: 'created', detail: 'created during spawn, with its bio and persona published' };
175
+ }
176
+ /**
177
+ * Ask the running ours daemon whether an identity exists, over the same
178
+ * authenticated endpoint doctor already probes. Answers `unknown` rather than
179
+ * guessing when the daemon cannot be reached — an unreachable daemon is not
180
+ * evidence that the identity is missing.
181
+ *
182
+ * It deliberately has no `create()`: role identities are minted through the MCP
183
+ * `create_identity` tool, and inventing a daemon endpoint we cannot test is the
184
+ * failure mode this release exists to stop.
185
+ */
186
+ export function daemonIdentityProvisioner(env = process.env, fetchImpl = (u, i) => globalThis.fetch(u, i)) {
187
+ return {
188
+ async exists(name) {
189
+ const ep = resolveEndpoint(env);
190
+ const resp = await fetchImpl(`${ep.origin}/identities`, { headers: ep.headers });
191
+ if (!resp.ok)
192
+ return 'unknown';
193
+ const body = await resp.json();
194
+ if (!Array.isArray(body.identities))
195
+ return 'unknown';
196
+ return body.identities.some(i => (typeof i === 'string' ? i : i?.name) === name);
197
+ },
198
+ };
199
+ }
200
+ /** Atomically write a role's fleet.d file, journalling it for rollback. */
201
+ export function writeRoleFile(tx, file, contents) {
202
+ const existed = existsSync(file);
203
+ replaceFileAtomically(file, contents, 0o644);
204
+ tx.record({
205
+ stage: `fleet.d file ${file}`,
206
+ // Only remove what THIS transaction created; never delete a file the
207
+ // operator already had.
208
+ undo: () => { if (!existed)
209
+ rmSync(file, { force: true }); },
210
+ });
211
+ }
212
+ export const CREATION_PROVENANCE_FILE = 'creation.json';
213
+ /**
214
+ * Record HOW a role was created, so nobody has to remember.
215
+ *
216
+ * Six months on, "why does this role have `approval: allow`?" is unanswerable:
217
+ * the resolved config shows the value but not whether an operator typed it, a
218
+ * fleet default supplied it, or it fell through to a built-in. Those have very
219
+ * different implications for whether it is safe to change.
220
+ *
221
+ * Deliberately excluded: `env`, `bio`, `persona`, and `harness_options`. The
222
+ * first two can carry credentials, and this file exists to be read — it must
223
+ * never become a place secrets accumulate.
224
+ */
225
+ export function buildProvenance(o) {
226
+ return {
227
+ version: 1,
228
+ command: 'ours-fleet spawn',
229
+ fleetVersion: o.fleetVersion,
230
+ createdAt: (o.now ?? new Date()).toISOString(),
231
+ lifetime: o.lifetime,
232
+ role: o.role,
233
+ settings: o.settings,
234
+ };
235
+ }
236
+ /** Write the provenance record atomically, before the role is started. */
237
+ export function writeProvenance(stateDir, p) {
238
+ replaceFileAtomically(join(stateDir, CREATION_PROVENANCE_FILE), JSON.stringify(p, null, 2) + '\n', 0o600);
239
+ }
240
+ /** One concise line per non-built-in setting, for the post-creation summary. */
241
+ export function formatProvenance(p) {
242
+ const mark = { cli: 'explicit', 'fleet-default': 'fleet default', 'built-in': 'built-in' };
243
+ return Object.entries(p.settings)
244
+ .filter(([, e]) => e.value !== undefined)
245
+ .map(([k, e]) => ` ${k.padEnd(12)} ${String(e.value)} (${mark[e.source]})`);
246
+ }
247
+ /** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
248
+ export function provenanceOf(cliValue, fleetDefault, builtIn) {
249
+ if (cliValue !== undefined && cliValue !== null && cliValue !== '')
250
+ return { value: cliValue, source: 'cli' };
251
+ if (fleetDefault !== undefined && fleetDefault !== null)
252
+ return { value: fleetDefault, source: 'fleet-default' };
253
+ return { value: builtIn, source: 'built-in' };
254
+ }
package/dist/docs.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Stable, AI-friendly CLI and configuration reference.
3
+ *
4
+ * Keep this concise enough to place directly in an agent context. Unlike
5
+ * Commander's per-command help, this describes how the pieces compose.
6
+ */
7
+ export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and `--monitor`. Run `ours-fleet help spawn` for exact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n enabled: true\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n enabled: true\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\n```\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\nThe supervisor monitor is enabled by default. It consumes body-free daemon\nevents and advances its durable cursor only after delivery is accepted. ACP uses\na structured `session/prompt`; tmux uses verified console injection. Message\nbodies are released only when the role calls the ours `get_messages` tool.\n\nSet `monitor.enabled: false` only to retain legacy in-session monitoring.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n";
8
+ /**
9
+ * What every shipped spawn-skill variant must say, and must not say (7.1).
10
+ *
11
+ * The skills are separate markdown files in two published plugins, written for
12
+ * two different harnesses, so they cannot literally be one file. This is the
13
+ * source of truth they are all written from, and a test holds each variant to
14
+ * it — including the CLI reference above, so a skill and \`ours-fleet docs\`
15
+ * cannot name different permission settings.
16
+ *
17
+ * \`forbidden\` is the more important half. The old skills prescribed
18
+ * \`--approval ask --filesystem workspace --unattended deny\` as a blanket
19
+ * default while also telling the agent to stop at a failed doctor check — and
20
+ * that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
21
+ * unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
22
+ * fatal. Following the skill produced a role the CLI then refused.
23
+ */
24
+ export declare const SPAWN_SKILL_CONTRACT: {
25
+ /** Substrings every variant must contain (whitespace-normalised). */
26
+ readonly required: readonly ["ours-fleet docs", "ours-fleet doctor", "dontAsk", "bypassPermissions", "unattended capability floor", "unattended floor:", "--approval allow", "--unattended wait", "--isolation-file"];
27
+ /**
28
+ * Substrings no variant may contain. Deliberately short: the real guard is
29
+ * the acceptance test, which runs every spawn command a variant prints
30
+ * through the same analysis `doctor` uses and fails if doctor would fail it.
31
+ * This list only pins the specific claim that was wrong.
32
+ */
33
+ readonly forbidden: readonly ["--approval ask --filesystem workspace --unattended deny", "[TODO:"];
34
+ };