@ours.network/fleet 1.1.0-nightly.5 → 1.1.0-nightly.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.
@@ -0,0 +1,131 @@
1
+ export type FleetCommandDecision = 'allow' | 'deny' | 'unsupported';
2
+ export type FleetCommandOutcomeClass = 'success' | 'validation' | 'denied' | 'runtime' | 'timeout' | 'proxy' | 'delivery';
3
+ export declare class FleetCliExit extends Error {
4
+ readonly exitCode: number;
5
+ readonly outcomeClass: FleetCommandOutcomeClass;
6
+ readonly effect: 'not_started' | 'completed' | 'unknown';
7
+ constructor(exitCode: number, outcomeClass?: FleetCommandOutcomeClass, effect?: 'not_started' | 'completed' | 'unknown');
8
+ }
9
+ export type FleetAuditPresentation = {
10
+ kind: 'agent_started';
11
+ name: string;
12
+ lifetime: 'permanent' | 'temporary';
13
+ brain: string;
14
+ role: string;
15
+ harness: string;
16
+ session: 'acp';
17
+ model?: string;
18
+ permissions?: string;
19
+ parent: string;
20
+ actionId: string;
21
+ inherited: string[];
22
+ } | {
23
+ kind: 'task';
24
+ operation: string;
25
+ id: string;
26
+ title?: string;
27
+ previousState?: string;
28
+ newState: string;
29
+ template?: string;
30
+ roomId?: string;
31
+ agents: Array<{
32
+ name: string;
33
+ brain?: string;
34
+ role: string;
35
+ }>;
36
+ } | {
37
+ kind: 'room';
38
+ operation: string;
39
+ id: string;
40
+ previousState?: string;
41
+ newState: string;
42
+ template?: string;
43
+ participants: Array<{
44
+ name: string;
45
+ role: string;
46
+ }>;
47
+ };
48
+ export declare function beginFleetAuditCollection(): void;
49
+ export declare function recordFleetAuditResource(kind: 'agent' | 'task' | 'room', id: string | undefined): void;
50
+ export declare function recordFleetAuditPresentation(value: FleetAuditPresentation): void;
51
+ export declare function recordFleetAuditFailure(failure: {
52
+ class: FleetCommandOutcomeClass;
53
+ effect: 'not_started' | 'completed' | 'unknown';
54
+ }): void;
55
+ export declare function consumeFleetAuditCollection(): {
56
+ resourceIds?: Record<string, string>;
57
+ presentation?: FleetAuditPresentation;
58
+ failure?: {
59
+ class: FleetCommandOutcomeClass;
60
+ effect: 'not_started' | 'completed' | 'unknown';
61
+ };
62
+ };
63
+ export interface FleetCommandClassification {
64
+ command: string;
65
+ route: string;
66
+ decision: FleetCommandDecision;
67
+ }
68
+ export interface FleetAuditAttempt {
69
+ version: 1;
70
+ correlationId: string;
71
+ requestId: string;
72
+ caller: string;
73
+ invokedAt: string;
74
+ classification: FleetCommandClassification;
75
+ argv: string[];
76
+ invocation: 'sending' | 'delivered' | 'uncertain';
77
+ outcome?: {
78
+ completedAt: string;
79
+ class: FleetCommandOutcomeClass;
80
+ exitCode?: number;
81
+ effect: 'not_started' | 'completed' | 'unknown';
82
+ delivery: 'sending' | 'delivered' | 'uncertain';
83
+ resourceIds?: Record<string, string>;
84
+ presentation?: FleetAuditPresentation;
85
+ };
86
+ }
87
+ export declare const fleetProxyCommandInventory: Readonly<{
88
+ [k: string]: string[];
89
+ }>;
90
+ export declare const fleetProxyTopLevelInventory: Readonly<{
91
+ safeRead: string[];
92
+ agent: string[];
93
+ denied: string[];
94
+ hidden: string[];
95
+ aliases: string[];
96
+ }>;
97
+ /** Classify before Commander parsing. Unknown and internal paths fail closed. */
98
+ export declare function classifyFleetArgv(argv: readonly string[]): FleetCommandClassification;
99
+ /** Faithful ordered argv with deterministic structural redaction. */
100
+ export declare function redactFleetArgv(argv: readonly string[]): string[];
101
+ /** Durable write-once attempt ledger; contains redacted argv only. */
102
+ export declare class FleetCommandAuditStore {
103
+ private readonly path;
104
+ private readonly deps;
105
+ private attempts;
106
+ constructor(path: string, deps?: {
107
+ now(): Date;
108
+ uuid(): string;
109
+ });
110
+ list(): readonly FleetAuditAttempt[];
111
+ begin(requestId: string, caller: string, argv: readonly string[]): FleetAuditAttempt;
112
+ invocation(correlationId: string, caller: string, delivery: 'delivered' | 'uncertain'): FleetAuditAttempt;
113
+ finish(correlationId: string, caller: string, outcome: Omit<NonNullable<FleetAuditAttempt['outcome']>, 'completedAt' | 'delivery'>): FleetAuditAttempt;
114
+ outcome(correlationId: string, caller: string, delivery: 'delivered' | 'uncertain'): FleetAuditAttempt;
115
+ private owned;
116
+ private persist;
117
+ }
118
+ export declare function validateFleetAuditBegin(value: unknown): asserts value is {
119
+ requestId: string;
120
+ argv: string[];
121
+ };
122
+ export declare function validateFleetAuditFinish(value: unknown): asserts value is {
123
+ correlationId: string;
124
+ class: FleetCommandOutcomeClass;
125
+ exitCode?: number;
126
+ effect: 'not_started' | 'completed' | 'unknown';
127
+ resourceIds?: Record<string, string>;
128
+ presentation?: FleetAuditPresentation;
129
+ };
130
+ export declare function renderFleetAuditInvocation(attempt: FleetAuditAttempt): string;
131
+ export declare function renderFleetAuditOutcome(attempt: FleetAuditAttempt): string;
@@ -0,0 +1,375 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { replaceFileAtomically } from './atomic-file.js';
4
+ import { isSensitiveConfigKey } from './sensitive-config.js';
5
+ export class FleetCliExit extends Error {
6
+ exitCode;
7
+ outcomeClass;
8
+ effect;
9
+ constructor(exitCode, outcomeClass = 'runtime', effect = 'unknown') {
10
+ super(`fleet CLI exited ${exitCode}`);
11
+ this.exitCode = exitCode;
12
+ this.outcomeClass = outcomeClass;
13
+ this.effect = effect;
14
+ }
15
+ }
16
+ let collection;
17
+ export function beginFleetAuditCollection() { collection = { resourceIds: {} }; }
18
+ export function recordFleetAuditResource(kind, id) {
19
+ if (id && collection)
20
+ collection.resourceIds[kind] = id;
21
+ }
22
+ export function recordFleetAuditPresentation(value) {
23
+ if (collection)
24
+ collection.presentation = structuredClone(value);
25
+ }
26
+ export function recordFleetAuditFailure(failure) { if (collection)
27
+ collection.failure = failure; }
28
+ export function consumeFleetAuditCollection() {
29
+ const current = collection;
30
+ collection = undefined;
31
+ return {
32
+ ...(current && Object.keys(current.resourceIds).length ? { resourceIds: current.resourceIds } : {}),
33
+ ...(current?.presentation ? { presentation: current.presentation } : {}),
34
+ ...(current?.failure ? { failure: current.failure } : {}),
35
+ };
36
+ }
37
+ const SAFE_READ = new Set(['docs', 'version', 'config', 'ls', 'peek', 'logs', 'status', 'doctor']);
38
+ const AGENT_SURFACES = {
39
+ spawn: new Set(['<none>']),
40
+ template: new Set(['list', 'show', 'validate']),
41
+ task: new Set(['create', 'list', 'lists', 'list-create', 'list-rename', 'list-delete', 'move',
42
+ 'show', 'start', 'block', 'unblock', 'review', 'done', 'cancel', 'delete', 'recover', 'work', 'finish']),
43
+ room: new Set(['create', 'list', 'show', 'open', 'members', 'delete', 'close', 'recover']),
44
+ };
45
+ export const fleetProxyCommandInventory = Object.freeze(Object.fromEntries(Object.entries(AGENT_SURFACES).map(([surface, commands]) => [surface, [...commands]])));
46
+ const DENIED = new Set([
47
+ 'up', 'down', 'restart', 'force-restart', 'attach', 'send', 'loops', 'owner-channel',
48
+ 'watchdog-run', 'watchdog-report', 'rm', 'init', 'web',
49
+ ]);
50
+ export const fleetProxyTopLevelInventory = Object.freeze({
51
+ safeRead: [...SAFE_READ], agent: Object.keys(AGENT_SURFACES), denied: [...DENIED],
52
+ hidden: ['_run', '_run-temp', '_run-watchdog', '_run-watchdogs'], aliases: ['man'],
53
+ });
54
+ const globalValueOptions = new Set(['-c', '--configuration']);
55
+ /** Classify before Commander parsing. Unknown and internal paths fail closed. */
56
+ export function classifyFleetArgv(argv) {
57
+ let command = '';
58
+ let commandIndex = -1;
59
+ for (let index = 0; index < argv.length; index++) {
60
+ const arg = argv[index];
61
+ if (arg === '--') {
62
+ command = argv[index + 1] ?? '';
63
+ commandIndex = index + 1;
64
+ break;
65
+ }
66
+ if (globalValueOptions.has(arg)) {
67
+ index++;
68
+ continue;
69
+ }
70
+ if (arg.startsWith('--configuration=') || arg.startsWith('-c='))
71
+ continue;
72
+ if (arg.startsWith('-c') && arg.length > 2)
73
+ continue;
74
+ if (arg === '--help' || arg === '-h' || arg === '--version' || arg === '-V')
75
+ return { command: arg, route: 'supervisor-proxy/read-only', decision: 'allow' };
76
+ if (!arg.startsWith('-')) {
77
+ command = arg;
78
+ commandIndex = index;
79
+ break;
80
+ }
81
+ }
82
+ if (!command)
83
+ return { command: '<none>', route: 'supervisor-proxy/unsupported', decision: 'unsupported' };
84
+ if (command.startsWith('_'))
85
+ return { command, route: 'supervisor-proxy/internal', decision: 'deny' };
86
+ if (SAFE_READ.has(command) || command === 'man')
87
+ return { command, route: 'supervisor-proxy/read-only', decision: 'allow' };
88
+ if (AGENT_SURFACES[command]) {
89
+ const sub = argv[commandIndex + 1] ?? '<none>';
90
+ if (sub === '--help' || sub === '-h')
91
+ return { command: `${command} ${sub}`, route: 'supervisor-proxy/read-only', decision: 'allow' };
92
+ if (sub.startsWith('_'))
93
+ return { command: `${command} ${sub}`, route: 'supervisor-proxy/internal', decision: 'deny' };
94
+ if (command !== 'spawn' && !AGENT_SURFACES[command].has(sub))
95
+ return { command: `${command} ${sub}`, route: 'supervisor-proxy/unsupported', decision: 'unsupported' };
96
+ return { command: sub.startsWith('-') ? command : `${command} ${sub}`,
97
+ route: `supervisor-proxy/${command}`, decision: 'allow' };
98
+ }
99
+ if (DENIED.has(command))
100
+ return { command, route: 'supervisor-proxy/permission-denied', decision: 'deny' };
101
+ return { command, route: 'supervisor-proxy/unsupported', decision: 'unsupported' };
102
+ }
103
+ const marker = (value) => value === '' ? '[REDACTED:empty]' : '[REDACTED:value]';
104
+ const sensitiveValueFlags = new Set([
105
+ '--identity', '--invite', '--token', '--api-token', '--password', '--password-file',
106
+ '--env', '--brief', '--brief-file', '--bio-file', '--persona-file', '--isolation-file',
107
+ '--configuration', '-c', '--public-invite', '--public-invite-file', '--invite-file',
108
+ '--summary-file', '--text', '--message', '--summary', '--reason', '--goal', '--cwd',
109
+ '--identity-cid', '--owner-cid', '--contact-cid', '--codex-config', '--add-dir',
110
+ ]);
111
+ function redactUrl(value) {
112
+ try {
113
+ const url = new URL(value);
114
+ let changed = false;
115
+ if (url.username || url.password) {
116
+ url.username = 'REDACTED';
117
+ url.password = 'REDACTED';
118
+ changed = true;
119
+ }
120
+ for (const key of [...url.searchParams.keys()])
121
+ if (isSensitiveConfigKey(key)) {
122
+ url.searchParams.set(key, marker(url.searchParams.get(key) ?? ''));
123
+ changed = true;
124
+ }
125
+ return changed ? url.toString() : value;
126
+ }
127
+ catch {
128
+ return value;
129
+ }
130
+ }
131
+ function redactInline(value) {
132
+ if (!value.startsWith('inline:'))
133
+ return redactUrl(value);
134
+ const source = value.slice('inline:'.length);
135
+ try {
136
+ const parsed = JSON.parse(source);
137
+ const walk = (node, key) => {
138
+ if (key && isSensitiveConfigKey(key))
139
+ return typeof node === 'string' ? marker(node) : '[REDACTED:value]';
140
+ if (Array.isArray(node))
141
+ return node.map(child => walk(child));
142
+ if (node && typeof node === 'object')
143
+ return Object.fromEntries(Object.entries(node).map(([childKey, child]) => [childKey, walk(child, childKey)]));
144
+ return typeof node === 'string' ? redactUrl(node) : node;
145
+ };
146
+ return `inline:${JSON.stringify(walk(parsed))}`;
147
+ }
148
+ catch {
149
+ return 'inline:[REDACTED:invalid-definition]';
150
+ }
151
+ }
152
+ /** Faithful ordered argv with deterministic structural redaction. */
153
+ export function redactFleetArgv(argv) {
154
+ const result = [...argv];
155
+ for (let index = 0; index < result.length; index++) {
156
+ const arg = result[index];
157
+ if (arg.startsWith('-c=') || (arg.startsWith('-c') && arg.length > 2 && !arg.startsWith('--'))) {
158
+ const prefix = arg.startsWith('-c=') ? '-c=' : '-c';
159
+ result[index] = `${prefix}${marker(arg.slice(prefix.length))}`;
160
+ continue;
161
+ }
162
+ const equal = /^(--[^=]+)=(.*)$/su.exec(arg);
163
+ if (equal && sensitiveValueFlags.has(equal[1])) {
164
+ result[index] = `${equal[1]}=${marker(equal[2])}`;
165
+ continue;
166
+ }
167
+ if (sensitiveValueFlags.has(arg) && index + 1 < result.length) {
168
+ result[index + 1] = marker(result[index + 1]);
169
+ index++;
170
+ continue;
171
+ }
172
+ result[index] = redactInline(arg);
173
+ }
174
+ // `send <role> <text...>` has positional content. Locate it using the same
175
+ // global-option scan as classification, never absolute argv offsets.
176
+ let commandIndex = -1;
177
+ for (let index = 0; index < result.length; index++) {
178
+ const arg = result[index];
179
+ if (globalValueOptions.has(arg)) {
180
+ index++;
181
+ continue;
182
+ }
183
+ if (arg.startsWith('--configuration=') || arg.startsWith('-c=') || (arg.startsWith('-c') && arg.length > 2))
184
+ continue;
185
+ if (!arg.startsWith('-')) {
186
+ commandIndex = index;
187
+ break;
188
+ }
189
+ }
190
+ if (result[commandIndex] === 'send')
191
+ for (let index = commandIndex + 2; index < result.length; index++)
192
+ result[index] = marker(result[index]);
193
+ return result;
194
+ }
195
+ /** Durable write-once attempt ledger; contains redacted argv only. */
196
+ export class FleetCommandAuditStore {
197
+ path;
198
+ deps;
199
+ attempts = [];
200
+ constructor(path, deps = { now: () => new Date(), uuid: () => randomUUID() }) {
201
+ this.path = path;
202
+ this.deps = deps;
203
+ if (!existsSync(path))
204
+ return;
205
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
206
+ if (parsed.version !== 1 || !Array.isArray(parsed.attempts))
207
+ throw new Error('invalid fleet command audit ledger');
208
+ this.attempts = parsed.attempts;
209
+ let recovered = false;
210
+ for (const attempt of this.attempts) {
211
+ if (attempt.invocation === 'sending') {
212
+ attempt.invocation = 'uncertain';
213
+ recovered = true;
214
+ }
215
+ if (attempt.outcome?.delivery === 'sending') {
216
+ attempt.outcome.delivery = 'uncertain';
217
+ recovered = true;
218
+ }
219
+ }
220
+ if (recovered)
221
+ this.persist();
222
+ }
223
+ list() { return this.attempts.map(item => structuredClone(item)); }
224
+ begin(requestId, caller, argv) {
225
+ const existing = this.attempts.find(item => item.caller === caller && item.requestId === requestId);
226
+ if (existing) {
227
+ if (JSON.stringify(existing.argv) !== JSON.stringify(redactFleetArgv(argv)))
228
+ throw new Error('fleet command request ID was reused with different argv');
229
+ return structuredClone(existing);
230
+ }
231
+ const attempt = { version: 1, correlationId: this.deps.uuid(), requestId,
232
+ caller, invokedAt: this.deps.now().toISOString(), classification: classifyFleetArgv(argv),
233
+ argv: redactFleetArgv(argv), invocation: 'sending' };
234
+ this.attempts.push(attempt);
235
+ this.persist();
236
+ return structuredClone(attempt);
237
+ }
238
+ invocation(correlationId, caller, delivery) {
239
+ const attempt = this.owned(correlationId, caller);
240
+ if (attempt.invocation !== 'sending' && attempt.invocation !== delivery)
241
+ throw new Error('conflicting fleet command invocation delivery state');
242
+ attempt.invocation = delivery;
243
+ this.persist();
244
+ return structuredClone(attempt);
245
+ }
246
+ finish(correlationId, caller, outcome) {
247
+ const attempt = this.owned(correlationId, caller);
248
+ if (!attempt.outcome)
249
+ attempt.outcome = { ...outcome, completedAt: this.deps.now().toISOString(), delivery: 'sending' };
250
+ else {
251
+ const prior = { class: attempt.outcome.class, effect: attempt.outcome.effect,
252
+ ...(attempt.outcome.exitCode === undefined ? {} : { exitCode: attempt.outcome.exitCode }),
253
+ ...(attempt.outcome.resourceIds ? { resourceIds: attempt.outcome.resourceIds } : {}),
254
+ ...(attempt.outcome.presentation ? { presentation: attempt.outcome.presentation } : {}) };
255
+ if (JSON.stringify(prior) !== JSON.stringify(outcome))
256
+ throw new Error('conflicting fleet command outcome for existing correlation');
257
+ }
258
+ this.persist();
259
+ return structuredClone(attempt);
260
+ }
261
+ outcome(correlationId, caller, delivery) {
262
+ const attempt = this.owned(correlationId, caller);
263
+ if (!attempt.outcome)
264
+ throw new Error('fleet command audit outcome was not recorded');
265
+ if (attempt.outcome.delivery !== 'sending' && attempt.outcome.delivery !== delivery)
266
+ throw new Error('conflicting fleet command outcome delivery state');
267
+ attempt.outcome.delivery = delivery;
268
+ this.persist();
269
+ return structuredClone(attempt);
270
+ }
271
+ owned(correlationId, caller) {
272
+ const attempt = this.attempts.find(item => item.correlationId === correlationId);
273
+ if (!attempt || attempt.caller !== caller)
274
+ throw new Error('unknown or cross-role fleet command correlation');
275
+ return attempt;
276
+ }
277
+ persist() {
278
+ replaceFileAtomically(this.path, `${JSON.stringify({ version: 1, attempts: this.attempts })}\n`, 0o600);
279
+ }
280
+ }
281
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
282
+ const SAFE_RESOURCE_ID = /^[\p{L}\p{N}][\p{L}\p{N}._:@-]{0,159}$/u;
283
+ const OUTCOMES = new Set(['success', 'validation', 'denied', 'runtime', 'timeout', 'proxy', 'delivery']);
284
+ const EFFECTS = new Set(['not_started', 'completed', 'unknown']);
285
+ export function validateFleetAuditBegin(value) {
286
+ const input = value;
287
+ if (!input || Object.keys(input).some(key => !['requestId', 'argv'].includes(key))
288
+ || typeof input.requestId !== 'string' || !UUID.test(input.requestId)
289
+ || !Array.isArray(input.argv) || input.argv.length > 256
290
+ || !input.argv.every(arg => typeof arg === 'string' && Buffer.byteLength(arg) <= 16_384)
291
+ || Buffer.byteLength(JSON.stringify(input.argv)) > 48 * 1024)
292
+ throw new Error('invalid fleet audit begin fields');
293
+ }
294
+ export function validateFleetAuditFinish(value) {
295
+ const input = value;
296
+ const allowed = ['correlationId', 'class', 'exitCode', 'effect', 'resourceIds', 'presentation'];
297
+ const resources = input?.resourceIds;
298
+ if (!input || Object.keys(input).some(key => !allowed.includes(key))
299
+ || typeof input.correlationId !== 'string' || !UUID.test(input.correlationId)
300
+ || !OUTCOMES.has(input.class) || !EFFECTS.has(String(input.effect))
301
+ || (input.exitCode !== undefined && (!Number.isSafeInteger(input.exitCode) || Number(input.exitCode) < 0 || Number(input.exitCode) > 255))
302
+ || (resources !== undefined && (!resources || typeof resources !== 'object' || Array.isArray(resources)
303
+ || Object.entries(resources).some(([key, id]) => !['agent', 'task', 'room'].includes(key)
304
+ || typeof id !== 'string' || !SAFE_RESOURCE_ID.test(id))))
305
+ || (input.presentation !== undefined && !validPresentation(input.presentation)))
306
+ throw new Error('invalid fleet audit finish fields');
307
+ }
308
+ function validPresentation(value) {
309
+ if (!value || typeof value !== 'object' || Array.isArray(value))
310
+ return false;
311
+ const p = value;
312
+ const safe = (v) => typeof v === 'string' && SAFE_RESOURCE_ID.test(v);
313
+ const text = (v) => typeof v === 'string' && v.length <= 256
314
+ && !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(v);
315
+ if (p.kind === 'agent_started')
316
+ return safe(p.name) && ['permanent', 'temporary'].includes(String(p.lifetime))
317
+ && text(p.brain) && text(p.role) && safe(p.harness) && p.session === 'acp' && safe(p.parent) && safe(p.actionId)
318
+ && (p.model === undefined || text(p.model)) && (p.permissions === undefined || text(p.permissions))
319
+ && Array.isArray(p.inherited) && p.inherited.every(text);
320
+ if (p.kind === 'task')
321
+ return safe(p.operation) && safe(p.id) && safe(p.newState)
322
+ && (p.title === undefined || text(p.title)) && (p.previousState === undefined || safe(p.previousState))
323
+ && (p.template === undefined || text(p.template)) && (p.roomId === undefined || safe(p.roomId))
324
+ && Array.isArray(p.agents) && p.agents.length <= 64
325
+ && p.agents.every(a => a && typeof a === 'object' && safe(a.name)
326
+ && text(a.role)
327
+ && (a.brain === undefined || text(a.brain)));
328
+ if (p.kind === 'room')
329
+ return safe(p.operation) && safe(p.id) && safe(p.newState)
330
+ && (p.previousState === undefined || safe(p.previousState))
331
+ && (p.template === undefined || text(p.template))
332
+ && Array.isArray(p.participants) && p.participants.length <= 64
333
+ && p.participants.every(a => a && typeof a === 'object' && safe(a.name)
334
+ && text(a.role));
335
+ return false;
336
+ }
337
+ export function renderFleetAuditInvocation(attempt) {
338
+ return ['🧾 Fleet command invoked', `Correlation: ${attempt.correlationId}`, `Agent: ${attempt.caller}`,
339
+ `Route: ${attempt.classification.route}`, `Fleet action: ${attempt.classification.command}`,
340
+ `Decision: ${attempt.classification.decision}`, `Invoked: ${attempt.invokedAt}`,
341
+ `Raw argv (redacted): ${JSON.stringify(attempt.argv)}`].join('\n');
342
+ }
343
+ export function renderFleetAuditOutcome(attempt) {
344
+ if (!attempt.outcome)
345
+ throw new Error('fleet command audit outcome is absent');
346
+ return ['🧾 Fleet command outcome', `Correlation: ${attempt.correlationId}`, `Agent: ${attempt.caller}`,
347
+ `Route: ${attempt.classification.route}`, `Fleet action: ${attempt.classification.command}`,
348
+ `Result: ${attempt.outcome.class}`, `Effect: ${attempt.outcome.effect}`,
349
+ ...(attempt.outcome.exitCode === undefined ? [] : [`Exit: ${attempt.outcome.exitCode}`]),
350
+ ...Object.entries(attempt.outcome.resourceIds ?? {}).sort(([a], [b]) => a.localeCompare(b))
351
+ .map(([kind, id]) => `${kind[0]?.toUpperCase()}${kind.slice(1)} ID: ${id}`),
352
+ `Completed: ${attempt.outcome.completedAt}`, `Raw argv (redacted): ${JSON.stringify(attempt.argv)}`,
353
+ ...renderPresentation(attempt.outcome.presentation)].join('\n');
354
+ }
355
+ function renderPresentation(value) {
356
+ if (!value)
357
+ return [];
358
+ if (value.kind === 'agent_started')
359
+ return ['Structured result: Agent started', `Agent: ${value.name}`,
360
+ `Brain: ${value.brain}`, `Role: ${value.role}`,
361
+ `Runtime: ${value.harness}/${value.session}${value.model ? ` model=${value.model}` : ''}`,
362
+ ...(value.permissions ? [`Permissions: ${value.permissions}`] : []), `Parent: ${value.parent}`,
363
+ `Agent action ID: ${value.actionId}`,
364
+ `Inheritance: ${value.inherited.length ? value.inherited.join(', ') : 'none'}`];
365
+ if (value.kind === 'task')
366
+ return ['Structured result: Task', `Operation: ${value.operation}`,
367
+ `Task: ${value.title ? `${value.title} (` : ''}${value.id}${value.title ? ')' : ''}`,
368
+ `Status: ${value.previousState ?? 'unresolved'} -> ${value.newState}`,
369
+ ...(value.template ? [`Template: ${value.template}`] : []), ...(value.roomId ? [`Room: ${value.roomId}`] : []),
370
+ `Responsible Agents: ${value.agents.length ? value.agents.map(a => `${a.name} [Brain ${a.brain ?? 'unresolved'}; Role ${a.role}]`).join('; ') : 'none'}`];
371
+ return ['Structured result: Room', `Operation: ${value.operation}`, `Room: ${value.id}`,
372
+ `Status: ${value.previousState ?? 'unresolved'} -> ${value.newState}`,
373
+ ...(value.template ? [`Template: ${value.template}`] : []),
374
+ `Participants: ${value.participants.length ? value.participants.map(a => `${a.name} [Role ${a.role}]`).join('; ') : 'none'}`];
375
+ }
@@ -19,6 +19,8 @@ export interface ManagedFleetSpawnResult {
19
19
  };
20
20
  inherited: string[];
21
21
  creationActionId: string;
22
+ brainSummary: string;
23
+ roleSummary: string;
22
24
  }
23
25
  /**
24
26
  * Fill only omitted spawn settings from the live caller. Explicit agent choices
@@ -2,6 +2,7 @@ import { type OwnerChannelConfig } from '../config.js';
2
2
  import { type AgentSession } from '../session/types.js';
3
3
  import { type OwnerFleetOps } from './commands.js';
4
4
  import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
5
+ import { type FleetAuditAttempt, type FleetAuditPresentation, type FleetCommandOutcomeClass } from '../fleet-command-audit.js';
5
6
  import { type OursOps } from './ours-client.js';
6
7
  import { type OwnerUpdatePhase } from './notices.js';
7
8
  import { type OwnerEntry } from './state.js';
@@ -27,14 +28,44 @@ export interface OwnerChannelOptions {
27
28
  binderDeps?: OwnerBinderDeps;
28
29
  /** Pre-acquired by the runner so the predecessor control socket remains reachable while waiting. */
29
30
  binderLease?: OwnerBinderLease;
31
+ recoveryDeps?: {
32
+ now(): number;
33
+ setTimer(fn: () => void, ms: number): ReturnType<typeof setTimeout>;
34
+ clearTimer(timer: ReturnType<typeof setTimeout>): void;
35
+ deadlineMs?: number;
36
+ };
30
37
  }
31
38
  export interface OwnerChannelHandle {
32
39
  start(): Promise<void>;
33
40
  drain(): Promise<void>;
34
41
  close(): Promise<void>;
42
+ /** Reattach this same supervisor-owned channel after a daemon generation change. */
43
+ recover?(epoch: string): Promise<void>;
44
+ /** False when shutdown skipped client disposal because quiescence was unproven. */
45
+ binderReleaseSafe?(): boolean;
35
46
  manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
36
47
  /** Fleet-owned deterministic lifecycle notice; absent on legacy test doubles. */
37
48
  notifyFleetSpawn?(event: ManagedFleetSpawnResult): Promise<void>;
49
+ beginFleetCommandAudit?(requestId: string, argv: string[]): Promise<FleetAuditAttempt>;
50
+ finishFleetCommandAudit?(input: {
51
+ correlationId: string;
52
+ class: FleetCommandOutcomeClass;
53
+ exitCode?: number;
54
+ effect: 'not_started' | 'completed' | 'unknown';
55
+ resourceIds?: Record<string, string>;
56
+ presentation?: FleetAuditPresentation;
57
+ }): Promise<FleetAuditAttempt>;
58
+ }
59
+ export declare const OWNER_RECOVERY_QUEUE_CAPACITY = 32;
60
+ export declare const OWNER_RECOVERY_DEGRADED = "OWNER_RECOVERY_DEGRADED";
61
+ export declare class OwnerRecoveryDegradedError extends Error {
62
+ readonly code = "OWNER_RECOVERY_DEGRADED";
63
+ constructor();
64
+ }
65
+ export declare class OwnerRecoveryTimeoutError extends Error {
66
+ readonly stage: string;
67
+ readonly code = "OWNER_RECOVERY_DEGRADED";
68
+ constructor(stage: string);
38
69
  }
39
70
  export type OwnerChannelManagementRequest = {
40
71
  action: 'contact_list';
@@ -162,17 +193,42 @@ export declare class OwnerChannel implements OwnerChannelHandle {
162
193
  private readonly completionTasks;
163
194
  private readonly activeRequests;
164
195
  private managementTail;
196
+ private recoveryEpoch?;
197
+ private recoveryTask?;
198
+ private recoveryToken;
199
+ private recoveryQueued;
200
+ private watchGeneration;
201
+ /** A timed-out client operation that must settle before replacement is safe. */
202
+ private recoveryQuiescence?;
203
+ private releaseSafe;
165
204
  private ready;
205
+ private startedOnce;
206
+ private shutdownDegraded;
166
207
  private binder?;
167
208
  private binderOwnedInternally;
168
209
  private readonly fleetOps;
169
210
  private readonly prepareRestart;
211
+ private readonly commandAudits;
170
212
  constructor(options: OwnerChannelOptions);
171
213
  start(): Promise<void>;
172
214
  drain(): Promise<void>;
173
215
  close(): Promise<void>;
216
+ binderReleaseSafe(): boolean;
174
217
  manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
175
218
  notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
219
+ beginFleetCommandAudit(requestId: string, argv: string[]): Promise<FleetAuditAttempt>;
220
+ finishFleetCommandAudit(input: {
221
+ correlationId: string;
222
+ class: FleetCommandOutcomeClass;
223
+ exitCode?: number;
224
+ effect: 'not_started' | 'completed' | 'unknown';
225
+ resourceIds?: Record<string, string>;
226
+ presentation?: FleetAuditPresentation;
227
+ }): Promise<FleetAuditAttempt>;
228
+ recover(epoch: string): Promise<void>;
229
+ private recoveryStage;
230
+ private writeShutdownState;
231
+ private queueManagement;
176
232
  private manageNow;
177
233
  /**
178
234
  * The daemon reports established contacts and pending introductions as two