@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.
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 +145 -30
  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
@@ -1,10 +1,81 @@
1
1
  import type { SessionBackendId } from '../config.js';
2
2
  export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
3
+ export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
4
+ /**
5
+ * Two independent facts about one turn, deliberately kept apart:
6
+ *
7
+ * - `accepted` — the live session took responsibility for the prompt. It says
8
+ * nothing about what the agent then did with it.
9
+ * - `outcome` / `succeeded` — how the turn TERMINATED. Only `completed` is a
10
+ * terminal success. A refusal or a cancellation is a prompt that was
11
+ * delivered and then not carried out; every caller that needs the work
12
+ * actually done (mail delivery, role startup) must treat it as a failure.
13
+ *
14
+ * Collapsing the two is what let a refused wake commit its notification cursor
15
+ * and a refused startup prompt log the role as up.
16
+ */
3
17
  export interface TurnResult {
4
18
  accepted: boolean;
5
- outcome: 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
19
+ outcome: TurnOutcome;
20
+ succeeded: boolean;
6
21
  detail?: string;
7
22
  }
23
+ /**
24
+ * Why a control operation failed. The distinctions exist because collapsing
25
+ * them is what made a busy agent look dead: only `offline` is evidence that the
26
+ * session is gone, and `timeout` explicitly does NOT say the prompt was lost.
27
+ */
28
+ export type ControlFailureKind = 'offline' | 'control-unavailable' | 'timeout' | 'rejected' | 'backend';
29
+ export declare class SessionControlError extends Error {
30
+ readonly kind: ControlFailureKind;
31
+ constructor(kind: ControlFailureKind, message: string);
32
+ }
33
+ /**
34
+ * A prompt the live session has taken responsibility for. Interactive callers
35
+ * stop here: the session has the prompt, and waiting for the turn to finish is
36
+ * a different question with a different, much longer, timescale.
37
+ */
38
+ export interface QueuedPrompt {
39
+ promptId: string;
40
+ /** Turns already queued ahead of this one. 0 means it starts immediately. */
41
+ queuedBehind: number;
42
+ /** The turn's terminal result. Never rejects. */
43
+ completion: Promise<TurnResult>;
44
+ }
45
+ /**
46
+ * How a session's process ended.
47
+ *
48
+ * `unknown` is the honest answer when no evidence was recorded — the previous
49
+ * code wrote the word `crash` there, asserting a failure it had not observed.
50
+ * `session-destroyed` (the console was torn down out from under a live process)
51
+ * and `program-exit` (the program decided to leave) are different events and
52
+ * must not collapse into one another, because they imply different next starts.
53
+ */
54
+ export type ExitClass = 'clean' | 'program-exit' | 'signal' | 'session-destroyed' | 'unknown';
55
+ export interface ExitRecord {
56
+ version: 1;
57
+ class: ExitClass;
58
+ /** Exit code, when the program exited of its own accord. */
59
+ code?: number;
60
+ /** Signal that killed it, when one did. */
61
+ signal?: string;
62
+ /** Raw wait status as the pane shell saw it (tmux only). */
63
+ status?: number;
64
+ at?: string;
65
+ /** One line an operator can read. */
66
+ detail: string;
67
+ }
68
+ /**
69
+ * Classify a shell `$?`. Above 128 the shell is reporting 128+signal — the only
70
+ * signal evidence a pane wrapper can give us.
71
+ */
72
+ export declare function classifyShellStatus(status: number): ExitRecord;
73
+ /** Classify a child process exit reported directly by node. */
74
+ export declare function classifyChildExit(code: number | null, signal: string | null): ExitRecord;
75
+ /** The single definition of terminal success. Nothing else may re-derive it. */
76
+ export declare const isTerminalSuccess: (outcome: TurnOutcome) => boolean;
77
+ /** Build a TurnResult with `succeeded` always consistent with `outcome`. */
78
+ export declare function turnResult(accepted: boolean, outcome: TurnOutcome, detail?: string): TurnResult;
8
79
  export interface SessionSnapshot {
9
80
  backend: SessionBackendId;
10
81
  alive: boolean;
@@ -14,6 +85,8 @@ export interface SessionSnapshot {
14
85
  pendingPermissionId?: string;
15
86
  }
16
87
  export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'turn_stop' | 'error';
88
+ /** What a settled permission request resolved to. */
89
+ export type PermissionDecision = 'allowed' | 'denied' | 'cancelled';
17
90
  export interface SessionEvent {
18
91
  version: 1;
19
92
  seq: number;
@@ -31,17 +104,35 @@ export interface SessionEvent {
31
104
  name: string;
32
105
  kind: string;
33
106
  }>;
107
+ /** What was decided. */
108
+ decision?: PermissionDecision;
109
+ /** Whether policy decided it, or a human answered the prompt. */
110
+ decisionSource?: 'automatic' | 'manual';
111
+ /** The configured policy that produced an automatic decision. */
112
+ policy?: string;
113
+ /** Why, in one human-readable line. */
114
+ reason?: string;
115
+ /** The option actually selected, when one was. */
116
+ optionId?: string;
34
117
  }
35
118
  export interface SessionHandle {
36
119
  readonly backend: SessionBackendId;
37
120
  readonly pid: number;
38
121
  isAlive(): boolean;
39
122
  snapshot(): SessionSnapshot;
123
+ /**
124
+ * Hand the session a prompt and return as soon as it has accepted
125
+ * responsibility for it. Throws `SessionControlError` if it cannot.
126
+ */
127
+ queuePrompt(text: string): Promise<QueuedPrompt>;
128
+ /** Queue a prompt and wait for its terminal result. */
40
129
  submitPrompt(text: string): Promise<TurnResult>;
41
130
  interrupt(): Promise<void>;
42
131
  respondPermission(permissionId: string, optionId: string): boolean;
43
132
  eventsSince(seq: number): SessionEvent[];
44
133
  subscribe(listener: (event: SessionEvent) => void): () => void;
45
134
  setControllerAttached(attached: boolean): void;
135
+ /** How the backing process ended, or null while it is still running. */
136
+ exitResult(): ExitRecord | null;
46
137
  close(): Promise<void>;
47
138
  }
@@ -1 +1,42 @@
1
- export {};
1
+ export class SessionControlError extends Error {
2
+ kind;
3
+ constructor(kind, message) {
4
+ super(message);
5
+ this.kind = kind;
6
+ this.name = 'SessionControlError';
7
+ }
8
+ }
9
+ /**
10
+ * Classify a shell `$?`. Above 128 the shell is reporting 128+signal — the only
11
+ * signal evidence a pane wrapper can give us.
12
+ */
13
+ export function classifyShellStatus(status) {
14
+ if (!Number.isFinite(status))
15
+ return { version: 1, class: 'unknown', detail: 'pane wrote an unreadable exit status' };
16
+ if (status === 0)
17
+ return { version: 1, class: 'clean', code: 0, status, detail: 'exited cleanly (code 0)' };
18
+ if (status > 128) {
19
+ const signal = status - 128;
20
+ return {
21
+ version: 1, class: 'signal', signal: `SIG${signal}`, status,
22
+ detail: `killed by signal ${signal} (shell status ${status})`,
23
+ };
24
+ }
25
+ return { version: 1, class: 'program-exit', code: status, status, detail: `exited with code ${status}` };
26
+ }
27
+ /** Classify a child process exit reported directly by node. */
28
+ export function classifyChildExit(code, signal) {
29
+ if (signal)
30
+ return { version: 1, class: 'signal', signal, detail: `killed by ${signal}` };
31
+ if (code === 0)
32
+ return { version: 1, class: 'clean', code: 0, detail: 'exited cleanly (code 0)' };
33
+ if (code === null)
34
+ return { version: 1, class: 'unknown', detail: 'the process ended with neither a code nor a signal' };
35
+ return { version: 1, class: 'program-exit', code, detail: `exited with code ${code}` };
36
+ }
37
+ /** The single definition of terminal success. Nothing else may re-derive it. */
38
+ export const isTerminalSuccess = (outcome) => outcome === 'completed';
39
+ /** Build a TurnResult with `succeeded` always consistent with `outcome`. */
40
+ export function turnResult(accepted, outcome, detail) {
41
+ return { accepted, outcome, succeeded: isTerminalSuccess(outcome), detail };
42
+ }
package/dist/spawn.d.ts CHANGED
@@ -1,5 +1,12 @@
1
+ import type { IsolationConfig } from './isolation/types.js';
1
2
  import { type ApprovalMode, type FilesystemMode, type SessionBackendId, type UnattendedMode } from './config.js';
2
3
  import { type OpsDeps } from './ops.js';
4
+ import { type CreationDeps, type CreationProvenance } from './creation.js';
5
+ /**
6
+ * The provenance record written by the most recent spawn in this process, so
7
+ * the CLI can print the same summary it persisted rather than rebuilding it.
8
+ */
9
+ export declare let lastProvenance: CreationProvenance | undefined;
3
10
  export interface SpawnOpts {
4
11
  name: string;
5
12
  temp?: boolean;
@@ -23,12 +30,30 @@ export interface SpawnOpts {
23
30
  monitor?: boolean;
24
31
  bioFile?: string;
25
32
  personaFile?: string;
33
+ /**
34
+ * Path to a file holding exactly the existing `isolation:` mapping — the same
35
+ * schema fleet.yaml uses, not a second policy language. The ONE new operator
36
+ * input in this release (6.3).
37
+ */
38
+ isolationFile?: string;
26
39
  overseeInterval?: string;
27
40
  configPath?: string;
28
41
  }
42
+ /**
43
+ * Read and validate an `--isolation-file`. The file is the existing
44
+ * `isolation:` mapping and nothing else — the same schema, the same validator
45
+ * (`validateIsolationConfig`), so a policy written here cannot mean something
46
+ * different from the identical block in fleet.yaml.
47
+ *
48
+ * Called BEFORE the creation transaction reserves anything: an invalid file
49
+ * must fail before any artifact exists.
50
+ */
51
+ export declare function readIsolationFile(path: string): IsolationConfig;
52
+ /** The ours identity a spawn will bind: explicit, else the role name. */
53
+ export declare const effectiveIdentity: (o: SpawnOpts) => string;
29
54
  /** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
30
- export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps): Promise<string>;
55
+ export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps, creation?: CreationDeps): Promise<string>;
31
56
  /** Launches the detached temp supervisor (`_run-temp <name>`). Injectable for tests. */
32
57
  export type SupervisorLauncher = (binPath: string, args: string[], dir: string) => void;
33
58
  /** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
34
- export declare function spawnTemp(o: SpawnOpts, binPath: string, launch?: SupervisorLauncher): Promise<string>;
59
+ export declare function spawnTemp(o: SpawnOpts, binPath: string, launch?: SupervisorLauncher, creation?: CreationDeps): Promise<string>;
package/dist/spawn.js CHANGED
@@ -1,11 +1,19 @@
1
1
  import { spawn as spawnChild } from 'node:child_process';
2
- import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { stringify } from 'yaml';
4
+ import { parse, stringify } from 'yaml';
5
5
  import { agentDir, fleetDDir } from './paths.js';
6
+ import { validateIsolationConfig } from './isolation/policy.js';
6
7
  import { loadConfig, resolveMonitorConfig, resolvePermissions, } from './config.js';
7
8
  import { applyRole, up } from './ops.js';
8
9
  import { START_STAGGER_FILE } from './runner.js';
10
+ import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
11
+ import { VERSION } from './version.js';
12
+ /**
13
+ * The provenance record written by the most recent spawn in this process, so
14
+ * the CLI can print the same summary it persisted rather than rebuilding it.
15
+ */
16
+ export let lastProvenance;
9
17
  function roleFromOpts(o, defaultHarness) {
10
18
  const r = {};
11
19
  if (o.harness)
@@ -53,8 +61,35 @@ function roleFromOpts(o, defaultHarness) {
53
61
  r.bio = readFileSync(o.bioFile, 'utf8').trim();
54
62
  if (o.personaFile)
55
63
  r.persona = readFileSync(o.personaFile, 'utf8').trim();
64
+ if (o.isolationFile)
65
+ r.isolation = readIsolationFile(o.isolationFile);
56
66
  return r;
57
67
  }
68
+ /**
69
+ * Read and validate an `--isolation-file`. The file is the existing
70
+ * `isolation:` mapping and nothing else — the same schema, the same validator
71
+ * (`validateIsolationConfig`), so a policy written here cannot mean something
72
+ * different from the identical block in fleet.yaml.
73
+ *
74
+ * Called BEFORE the creation transaction reserves anything: an invalid file
75
+ * must fail before any artifact exists.
76
+ */
77
+ export function readIsolationFile(path) {
78
+ let raw;
79
+ try {
80
+ raw = parse(readFileSync(path, 'utf8'));
81
+ }
82
+ catch (e) {
83
+ throw new Error(`--isolation-file ${path}: ${e.message}`);
84
+ }
85
+ // A file holding only comments parses to null; treat it as an empty policy,
86
+ // which is a meaningful request ("sandbox me with defaults").
87
+ const cfg = (raw ?? {});
88
+ const problems = validateIsolationConfig(cfg);
89
+ if (problems.length)
90
+ throw new Error(`--isolation-file ${path}: ${problems.join('; ')}`);
91
+ return cfg;
92
+ }
58
93
  function validateSpawnOpts(o) {
59
94
  if (o.session && !['tmux', 'acp'].includes(o.session))
60
95
  throw new Error(`invalid --session '${o.session}'; allowed: tmux, acp`);
@@ -65,6 +100,12 @@ function validateSpawnOpts(o) {
65
100
  if (o.unattended && !['deny', 'wait'].includes(o.unattended))
66
101
  throw new Error(`invalid --unattended '${o.unattended}'; allowed: deny, wait`);
67
102
  }
103
+ /**
104
+ * Reject names that are already USED. This is a precondition, not a claim: it
105
+ * runs INSIDE the creation transaction, after both names are reserved, so the
106
+ * gap between checking and creating that let two spawns both succeed is closed
107
+ * by the reservation rather than by this function.
108
+ */
68
109
  function assertNameFree(o) {
69
110
  const cfg = loadConfig(o.configPath);
70
111
  if (cfg.roles.some(r => r.name === o.name))
@@ -72,18 +113,96 @@ function assertNameFree(o) {
72
113
  if (existsSync(agentDir(o.name)) || existsSync(agentDir(o.name, true)))
73
114
  throw new Error(`agent dir for '${o.name}' already exists — pick another name or 'ours-fleet rm ${o.name}'`);
74
115
  }
116
+ /** The ours identity a spawn will bind: explicit, else the role name. */
117
+ export const effectiveIdentity = (o) => o.identity ?? o.name;
118
+ /**
119
+ * Which settings came from the operator, from fleet defaults, or from a
120
+ * built-in (6.6). Built while the options are still separable — once they are
121
+ * merged into a ResolvedRole the distinction is gone.
122
+ *
123
+ * `env`, `bio`, `persona` and `harness_options` are deliberately absent: the
124
+ * record exists to be read, and must not become a place credentials collect.
125
+ */
126
+ function provenanceSettings(o, defaults) {
127
+ const perms = (defaults.permissions ?? {});
128
+ return {
129
+ harness: provenanceOf(o.harness, defaults.harness, 'claude-code'),
130
+ session: provenanceOf(o.session, defaults.session, 'tmux'),
131
+ identity: o.identity
132
+ ? { value: o.identity, source: 'cli' }
133
+ : { value: o.name, source: 'built-in' }, // defaults to the role name
134
+ cwd: provenanceOf(o.cwd, undefined, undefined),
135
+ model: provenanceOf(o.model?.trim(), defaults.model, undefined),
136
+ coordinator: provenanceOf(o.coordinator, undefined, undefined),
137
+ approval: provenanceOf(o.approval, perms.approval, 'ask'),
138
+ filesystem: provenanceOf(o.filesystem, perms.filesystem, 'workspace'),
139
+ unattended: provenanceOf(o.unattended, perms.unattended, 'deny'),
140
+ isolation: o.isolationFile
141
+ ? { value: 'declared via --isolation-file', source: 'cli' }
142
+ : { value: defaults.isolation ? 'from fleet defaults' : undefined, source: defaults.isolation ? 'fleet-default' : 'built-in' },
143
+ };
144
+ }
75
145
  /** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
76
- export async function spawnPermanent(o, deps) {
146
+ export async function spawnPermanent(o, deps, creation = {}) {
77
147
  validateSpawnOpts(o);
78
- assertNameFree(o);
79
- const cfg = loadConfig(o.configPath);
80
- mkdirSync(fleetDDir(), { recursive: true });
81
- const file = join(fleetDDir(), `${o.name}.yaml`);
82
- writeFileSync(file, stringify({
83
- roles: { [o.name]: roleFromOpts(o, cfg.defaults.harness) },
84
- }));
85
- await up(loadConfig(o.configPath), [o.name], deps, o.configPath);
86
- return file;
148
+ if (o.isolationFile)
149
+ readIsolationFile(o.isolationFile); // fail before reserving
150
+ // Name AND identity reserved together, before anything is written or started
151
+ // (6.4). A loser of the race creates no config, no state, no service.
152
+ return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
153
+ assertNameFree(o);
154
+ const cfg = loadConfig(o.configPath);
155
+ // Establish the identity BEFORE the service is enabled (7.3), and record
156
+ // what was actually guaranteed so the briefing can say something true.
157
+ const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
158
+ persona: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined }, creation.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
159
+ if (guarantee.state === 'created')
160
+ // We minted it; a failed creation must not leave an orphan identity
161
+ // behind. Only ever removes an identity THIS transaction created.
162
+ tx.record({
163
+ stage: `ours identity ${effectiveIdentity(o)}`,
164
+ undo: async () => {
165
+ await creation.identityProvisioner?.remove?.(effectiveIdentity(o));
166
+ },
167
+ });
168
+ mkdirSync(fleetDDir(), { recursive: true });
169
+ const file = join(fleetDDir(), `${o.name}.yaml`);
170
+ writeRoleFile(tx, file, stringify({
171
+ roles: { [o.name]: roleFromOpts(o, cfg.defaults.harness) },
172
+ }));
173
+ // `up` materialises the state dir and registers the service. Journal the
174
+ // dir before it exists so a failure leaves the name genuinely reusable
175
+ // rather than blocked by a half-built directory.
176
+ const stateDir = agentDir(o.name);
177
+ const stateExisted = existsSync(stateDir);
178
+ tx.record({
179
+ stage: `state dir ${stateDir}`,
180
+ undo: () => { if (!stateExisted)
181
+ rmSync(stateDir, { recursive: true, force: true }); },
182
+ });
183
+ // Journal the service registration BEFORE it happens, and undo only the
184
+ // registrations this transaction actually created (6.2). `registered` is
185
+ // filled by `up`'s onInstalled hook at the moment each registration is
186
+ // made — not from its return value, which never arrives when `up` throws
187
+ // after registering.
188
+ const registered = [];
189
+ tx.record({
190
+ stage: `service registration for ${o.name}`,
191
+ undo: async () => { for (const n of registered)
192
+ await deps.backend.uninstall(n); },
193
+ });
194
+ // Provenance is written BEFORE the role starts, so a role that fails to
195
+ // launch still records how it was asked for (6.6).
196
+ const provenance = buildProvenance({
197
+ role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
198
+ settings: provenanceSettings(o, cfg.defaults),
199
+ });
200
+ mkdirSync(agentDir(o.name), { recursive: true });
201
+ writeProvenance(agentDir(o.name), provenance);
202
+ await up(loadConfig(o.configPath), [o.name], { ...deps, onInstalled: outcome => registered.push(outcome.role) }, o.configPath, guarantee.state);
203
+ lastProvenance = provenance;
204
+ return file;
205
+ }, creation);
87
206
  }
88
207
  const detachedSupervisor = (binPath, args, dir) => {
89
208
  // Log to the temp dir; the fd stays valid even after runTemp removes the dir.
@@ -95,8 +214,19 @@ const detachedSupervisor = (binPath, args, dir) => {
95
214
  child.unref();
96
215
  };
97
216
  /** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
98
- export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
217
+ export async function spawnTemp(o, binPath, launch = detachedSupervisor, creation = {}) {
99
218
  validateSpawnOpts(o);
219
+ if (o.isolationFile)
220
+ readIsolationFile(o.isolationFile); // fail before reserving
221
+ // Temporary roles go through the SAME reservation boundary as permanent ones
222
+ // (6.4): a temp agent competes for the same names.
223
+ return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
224
+ const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
225
+ persona: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined }, creation.identityProvisioner ?? daemonIdentityProvisioner(), creation.log);
226
+ return spawnTempInner(o, binPath, launch, tx, guarantee);
227
+ }, creation);
228
+ }
229
+ async function spawnTempInner(o, binPath, launch, tx, guarantee) {
100
230
  assertNameFree(o);
101
231
  const cfg = loadConfig(o.configPath);
102
232
  const defaultHarness = cfg.defaults.harness;
@@ -106,7 +236,7 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
106
236
  ...(fromOpts.harness_options ?? {}),
107
237
  };
108
238
  const role = {
109
- ...fromOpts,
239
+ ...fromOpts, // includes `isolation` when --isolation-file was given
110
240
  name: o.name,
111
241
  harness: o.harness ?? defaultHarness ?? 'claude-code',
112
242
  session: o.session ?? cfg.defaults.session ?? 'tmux',
@@ -114,11 +244,19 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
114
244
  model: o.model?.trim() || cfg.defaults.model,
115
245
  harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
116
246
  permissions: resolvePermissions(cfg.defaults.permissions, fromOpts.permissions),
247
+ permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
117
248
  // Temp agents inherit the fleet-wide monitor defaults via the snapshot (design §2).
118
249
  monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
119
250
  sourceFile: '(temp)',
120
251
  };
121
- const dir = applyRole(role, { temp: true });
252
+ const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
253
+ const provenance = buildProvenance({
254
+ role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
255
+ settings: provenanceSettings(o, cfg.defaults),
256
+ });
257
+ writeProvenance(dir, provenance);
258
+ lastProvenance = provenance;
259
+ tx.record({ stage: `temp state dir ${dir}`, undo: () => rmSync(dir, { recursive: true, force: true }) });
122
260
  writeFileSync(join(dir, 'role.yaml'), stringify(role));
123
261
  // Snapshot the fleet start-stagger so the detached temp supervisor (no config path
124
262
  // threaded through it) honors the same launch gate — a burst of temp spawns spaces
@@ -1,4 +1,54 @@
1
1
  import { type Exec } from '../exec.js';
2
2
  import type { SupervisorBackend } from './types.js';
3
3
  export declare const labelFor: (name: string) => string;
4
+ /**
5
+ * What `launchctl print` said about a job, parsed ONCE so that the two questions
6
+ * asked of it cannot drift apart. They are not the same question:
7
+ *
8
+ * - `liveness` asks "does this role's context still exist" — a loaded job counts,
9
+ * including one waiting between KeepAlive restarts (1.1);
10
+ * - `install` asks "did the job I just bootstrapped actually START" — for which a
11
+ * job that is loaded, not running, and has already exited once is a failure.
12
+ *
13
+ * On systemd one `ActiveState` answers both. Here the answers differ, so what is
14
+ * shared is the READING of launchd's output, not its classification.
15
+ */
16
+ export interface LaunchdJob {
17
+ /** `launchctl print` exited 0 — the job is loaded in the domain. */
18
+ loaded: boolean;
19
+ /** launchd's own `state = …`, e.g. `running`, `waiting`, `not running`. */
20
+ state?: string;
21
+ /**
22
+ * `last exit code|status|reason = …`. Present only once the program has RUN
23
+ * and exited — which is what separates "died" from "has not started yet".
24
+ */
25
+ lastExit?: string;
26
+ /** The domain has no such service: a definite negative, not a failed probe. */
27
+ notFound: boolean;
28
+ /** Why the probe itself could not be read, when it could not. */
29
+ failure?: string;
30
+ }
31
+ /**
32
+ * Did the job we just bootstrapped actually start?
33
+ *
34
+ * `launchctl bootstrap` exits 0 once the job is LOADED. With `RunAtLoad` the
35
+ * program then starts asynchronously, so a zero exit is a statement about the
36
+ * load, not about the program — the same shape of lie that `systemctl enable
37
+ * --now` tells on systemd 255, where the exit code is 0 and the unit is dead.
38
+ *
39
+ * Only a DEFINITE stop counts as a failed start, exactly as on systemd:
40
+ *
41
+ * - not loaded at all, though bootstrap said it worked → definite;
42
+ * - loaded, `state` is not running, AND launchd already has an exit status for
43
+ * it → it ran and died → definite;
44
+ * - loaded and running, or waiting for a KeepAlive restart, or not running with
45
+ * nothing exited yet (it simply has not been spawned yet — the asynchrony
46
+ * RunAtLoad introduces) → NOT a failure;
47
+ * - an unreadable probe → `unknown`, never a failure (1.1). launchd may be fine
48
+ * and the tool merely unable to answer.
49
+ */
50
+ export declare function classifyStart(job: LaunchdJob): {
51
+ started: 'yes' | 'no' | 'unknown';
52
+ detail: string;
53
+ };
4
54
  export declare function makeLaunchdBackend(exec?: Exec, uid?: number): SupervisorBackend;