@ours.network/fleet 0.11.1 → 0.13.0

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 (43) hide show
  1. package/README.md +225 -0
  2. package/dist/briefing.js +25 -0
  3. package/dist/cli.js +408 -1
  4. package/dist/config.d.ts +31 -1
  5. package/dist/config.js +123 -2
  6. package/dist/docs.d.ts +1 -1
  7. package/dist/docs.js +143 -0
  8. package/dist/duration.js +7 -3
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +1 -0
  11. package/dist/loops/config.d.ts +30 -0
  12. package/dist/loops/config.js +135 -0
  13. package/dist/loops/manager.d.ts +48 -0
  14. package/dist/loops/manager.js +237 -0
  15. package/dist/loops/state.d.ts +54 -0
  16. package/dist/loops/state.js +148 -0
  17. package/dist/monitor.js +26 -2
  18. package/dist/owner-channel/attachments.d.ts +74 -0
  19. package/dist/owner-channel/attachments.js +378 -0
  20. package/dist/owner-channel/channel.d.ts +167 -0
  21. package/dist/owner-channel/channel.js +874 -0
  22. package/dist/owner-channel/mcp.d.ts +24 -0
  23. package/dist/owner-channel/mcp.js +123 -0
  24. package/dist/owner-channel/notices.d.ts +21 -0
  25. package/dist/owner-channel/notices.js +66 -0
  26. package/dist/owner-channel/state.d.ts +44 -0
  27. package/dist/owner-channel/state.js +184 -0
  28. package/dist/owner-channel/tasks.d.ts +62 -0
  29. package/dist/owner-channel/tasks.js +246 -0
  30. package/dist/resolved-plan.js +12 -0
  31. package/dist/runner.d.ts +3 -0
  32. package/dist/runner.js +112 -5
  33. package/dist/session/acp.d.ts +4 -2
  34. package/dist/session/acp.js +82 -25
  35. package/dist/session/arbiter.d.ts +42 -0
  36. package/dist/session/arbiter.js +72 -0
  37. package/dist/session/control.d.ts +12 -1
  38. package/dist/session/control.js +56 -3
  39. package/dist/session/types.d.ts +28 -2
  40. package/dist/session/types.js +5 -2
  41. package/dist/spawn.js +7 -3
  42. package/dist/supervisor/systemd.js +12 -2
  43. package/package.json +1 -1
@@ -0,0 +1,874 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { mkdir, readdir, rm } from 'node:fs/promises';
4
+ import { createInterface } from 'node:readline';
5
+ import { join } from 'node:path';
6
+ import { DEFAULT_OWNER_ATTACHMENT_MIME, } from '../config.js';
7
+ import { OursMcpClient } from './mcp.js';
8
+ import { ownerNotices } from './notices.js';
9
+ import { OwnerAuthorizationState, OwnerChannelState } from './state.js';
10
+ import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
11
+ import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, } from './attachments.js';
12
+ const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
13
+ const OWNER_UPDATE_MAX_COUNT = 20;
14
+ const OWNER_UPDATE_MAX_CHARS = 280;
15
+ const OWNER_UPDATE_MAX_BYTES = 1_024;
16
+ /**
17
+ * Fleet-owned trusted ingress. The agent never binds this identity and never
18
+ * chooses its reply recipient; both are fixed from authenticated message data.
19
+ */
20
+ export class OwnerChannel {
21
+ options;
22
+ client;
23
+ state;
24
+ authorizations;
25
+ tasks;
26
+ attachmentRecovery;
27
+ attachmentConfig;
28
+ attachmentRoot;
29
+ /**
30
+ * Wire IDs whose turn is still running. They stay OUT of the durable state
31
+ * (a crash must replay them) but must not be queued twice while live.
32
+ */
33
+ inFlight = new Set();
34
+ stopping = false;
35
+ watchProcess;
36
+ watchTask;
37
+ drainTask;
38
+ drainRequested = false;
39
+ completionTasks = new Set();
40
+ activeRequests = new Map();
41
+ managementTail = Promise.resolve();
42
+ ready = false;
43
+ constructor(options) {
44
+ this.options = options;
45
+ this.client = options.client ?? new OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
46
+ this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
47
+ this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
48
+ this.tasks = new OwnerTaskState(join(options.stateDir, '.owner-channel-tasks.json'));
49
+ this.attachmentRecovery = new AttachmentRecoveryState(join(options.stateDir, '.owner-channel-attachment-recovery.json'));
50
+ this.attachmentRoot = join(options.stateDir, '.owner-channel-inbox');
51
+ this.attachmentConfig = options.config.attachments ?? {
52
+ enabled: true, max_files_per_request: 4, max_file_bytes: 10 * 1024 * 1024,
53
+ max_request_bytes: 20 * 1024 * 1024, retention_ms: 24 * 60 * 60 * 1_000,
54
+ allowed_mime: [...DEFAULT_OWNER_ATTACHMENT_MIME],
55
+ };
56
+ const integrity = this.authorizations.integrity();
57
+ if (!integrity.ok)
58
+ options.log(`[${options.role}] owner authorization state corrupt; all owner mail disabled`);
59
+ if (!this.tasks.integrity().ok)
60
+ options.log(`[${options.role}] owner task state corrupt; proactive reports disabled`);
61
+ if (!this.attachmentRecovery.integrity())
62
+ options.log(`[${options.role}] owner attachment recovery state corrupt; attachments disabled`);
63
+ }
64
+ async start() {
65
+ this.stopping = false;
66
+ await this.client.start();
67
+ await this.client.callTool('choose_identity', { name: this.options.config.identity });
68
+ if (this.authorizations.integrity().ok && this.tasks.integrity().ok)
69
+ this.tasks.cleanup(Date.now(), this.authorizations.effective());
70
+ if (this.attachmentRecovery.integrity()) {
71
+ this.attachmentRecovery.cleanup(Date.now(), this.attachmentConfig.retention_ms);
72
+ void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
73
+ }
74
+ this.ready = true;
75
+ this.watchTask = this.watchLoop();
76
+ // Do not make role startup wait for an old owner request to finish a turn.
77
+ void this.drain().catch(error => this.logError('initial drain failed', error));
78
+ }
79
+ drain() {
80
+ this.drainRequested = true;
81
+ if (this.drainTask)
82
+ return this.drainTask;
83
+ this.drainTask = (async () => {
84
+ while (this.drainRequested && !this.stopping) {
85
+ this.drainRequested = false;
86
+ await this.drainAll();
87
+ }
88
+ })().finally(() => { this.drainTask = undefined; });
89
+ return this.drainTask;
90
+ }
91
+ async close() {
92
+ this.stopping = true;
93
+ this.ready = false;
94
+ const watch = this.watchProcess;
95
+ this.watchProcess = undefined;
96
+ if (watch && watch.exitCode === null)
97
+ watch.kill('SIGTERM');
98
+ await this.managementTail;
99
+ await this.client.close();
100
+ }
101
+ manage(request) {
102
+ const run = this.managementTail.then(() => this.manageNow(request));
103
+ this.managementTail = run.then(() => undefined, () => undefined);
104
+ return run;
105
+ }
106
+ async manageNow(request) {
107
+ if (!this.ready || this.stopping)
108
+ throw new Error('owner-channel MCP client is unavailable');
109
+ switch (request.action) {
110
+ case 'contact_list':
111
+ return { action: request.action, contacts: await this.contacts() };
112
+ case 'contact_invite': {
113
+ this.assertLabel(request.name);
114
+ const raw = await this.client.callTool('generate_invite', request.name ? { name: request.name } : {});
115
+ const invite = typeof raw === 'string' ? raw : String(raw?.invite ?? '');
116
+ if (!invite)
117
+ throw new Error('ours-mcp returned no invite');
118
+ return { action: request.action, invite };
119
+ }
120
+ case 'contact_add': {
121
+ if (typeof request.invite !== 'string' || !request.invite)
122
+ throw new Error('invite is required');
123
+ if (Buffer.byteLength(request.invite) > 48 * 1024)
124
+ throw new Error('invite exceeds 49152 bytes');
125
+ this.assertLabel(request.name);
126
+ let raw;
127
+ try {
128
+ raw = await this.client.callTool('add_contact', {
129
+ invite: request.invite, ...(request.name ? { name: request.name } : {}),
130
+ });
131
+ }
132
+ catch {
133
+ // Daemon errors are not allowed to reflect invite material through
134
+ // the control response, CLI stderr, or supervisor logs.
135
+ throw new Error('ours-mcp could not accept the contact invite');
136
+ }
137
+ return { action: request.action, status: 'pending', contact: this.contact(raw) };
138
+ }
139
+ case 'owner_list':
140
+ return {
141
+ action: request.action, integrity: this.authorizations.integrity(),
142
+ owners: this.authorizations.entries(),
143
+ };
144
+ case 'owner_authorize': {
145
+ this.assertCid(request.cid);
146
+ if (this.authorizations.effective().has(request.cid))
147
+ throw new Error(`owner '${request.cid}' is already authorized`);
148
+ const contacts = await this.contacts();
149
+ if (!contacts.some(contact => contact.cid === request.cid
150
+ && ['established', 'active', 'connected'].includes(contact.status.toLowerCase())))
151
+ throw new Error(`cannot authorize unknown or pending contact CID '${request.cid}'`);
152
+ return { action: request.action, owner: this.authorizations.authorize(request.cid) };
153
+ }
154
+ case 'owner_revoke':
155
+ this.assertCid(request.cid);
156
+ {
157
+ const owner = this.authorizations.revoke(request.cid);
158
+ let revokedTasks = 0;
159
+ try {
160
+ revokedTasks = this.tasks.revoke(request.cid);
161
+ }
162
+ catch (error) {
163
+ this.logError('owner task revocation cleanup failed', error);
164
+ }
165
+ if (revokedTasks)
166
+ this.options.log(`[${this.options.role}] owner tasks revoked count=${revokedTasks}`);
167
+ return { action: request.action, owner };
168
+ }
169
+ case 'request_update':
170
+ return this.sendOwnerUpdate(request);
171
+ case 'task_open':
172
+ return this.openOwnerTask(request.requestId);
173
+ case 'task_report':
174
+ return this.sendOwnerTaskReport(request);
175
+ default:
176
+ throw new Error('unknown owner-channel management action');
177
+ }
178
+ }
179
+ async contacts() {
180
+ const raw = await this.client.callTool('list_contacts');
181
+ const values = Array.isArray(raw) ? raw : raw?.contacts;
182
+ if (!Array.isArray(values))
183
+ return [];
184
+ return values.map(value => this.contact(value)).filter((v) => Boolean(v))
185
+ .sort((a, b) => a.cid.localeCompare(b.cid));
186
+ }
187
+ contact(raw) {
188
+ if (!raw || typeof raw !== 'object')
189
+ return undefined;
190
+ const value = raw;
191
+ const cid = String(value.cid ?? value.id ?? value.container_id ?? value.containerId ?? '');
192
+ if (!/^[A-Fa-f0-9]{64}$/.test(cid))
193
+ return undefined;
194
+ const humanRaw = value.human ?? value.root;
195
+ const human = humanRaw && typeof humanRaw === 'object' ? humanRaw : undefined;
196
+ return {
197
+ cid,
198
+ name: this.safeMetadata(value.name ?? value.display_name ?? cid),
199
+ status: this.safeMetadata(value.status ?? 'established'),
200
+ ...(typeof value.kind === 'string' ? { kind: this.safeMetadata(value.kind) } : {}),
201
+ ...(human ? { human: {
202
+ ...(typeof (human.cid ?? human.id) === 'string'
203
+ && /^[A-Fa-f0-9]{64}$/.test(String(human.cid ?? human.id))
204
+ ? { cid: String(human.cid ?? human.id) } : {}),
205
+ ...(human.name ? { name: this.safeMetadata(human.name) } : {}),
206
+ } } : {}),
207
+ };
208
+ }
209
+ assertCid(cid) {
210
+ if (typeof cid !== 'string' || !/^[A-Fa-f0-9]{64}$/.test(cid))
211
+ throw new Error('contact CID must be exactly 64 hexadecimal characters');
212
+ }
213
+ assertLabel(label) {
214
+ if (label !== undefined && (typeof label !== 'string' || !label.trim() || label.length > 200
215
+ || /[\u0000-\u001f\u007f]/.test(label)))
216
+ throw new Error('contact label must be 1-200 characters without control characters');
217
+ }
218
+ safeMetadata(value) {
219
+ return String(value).replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 200);
220
+ }
221
+ async sendOwnerUpdate(request) {
222
+ if (!/^[a-f0-9]{64}$/.test(request.requestId))
223
+ throw new Error('owner update request ID must be exactly 64 lowercase hexadecimal characters');
224
+ if (!['working', 'approval', 'blocked'].includes(request.phase))
225
+ throw new Error('owner update phase must be working, approval, or blocked');
226
+ const active = this.activeRequests.get(request.requestId);
227
+ if (!active || active.finalizing)
228
+ throw new Error('owner request is not active or is already finalizing');
229
+ const message = this.safeOwnerUpdate(request.message);
230
+ const digest = createHash('sha256').update(`${request.phase}\0${message}`).digest('hex');
231
+ if (active.updateDigests.has(digest))
232
+ throw new Error('duplicate owner update refused');
233
+ if (active.updateCount >= OWNER_UPDATE_MAX_COUNT)
234
+ throw new Error(`owner request is limited to ${OWNER_UPDATE_MAX_COUNT} authored updates`);
235
+ const now = Date.now();
236
+ if (active.lastUpdateAt !== undefined && now - active.lastUpdateAt < OWNER_UPDATE_MIN_INTERVAL_MS)
237
+ throw new Error(`owner updates are rate-limited to one every ${OWNER_UPDATE_MIN_INTERVAL_MS}ms`);
238
+ active.updateDigests.add(digest);
239
+ active.updateCount++;
240
+ active.lastUpdateAt = now;
241
+ const sequence = active.updateCount;
242
+ const notice = ownerNotices.authoredUpdate(request.phase, message);
243
+ const send = active.outboundTail.then(async () => {
244
+ await this.send(active.contact, notice, active.wireId);
245
+ this.options.log(`[${this.options.role}] owner update ${active.requestId.slice(0, 12)} `
246
+ + `phase=${request.phase} chars=${Array.from(message).length} sequence=${sequence} sent`);
247
+ });
248
+ active.outboundTail = send.catch(error => {
249
+ this.logError(`owner update ${active.requestId.slice(0, 12)} delivery failed`, error);
250
+ });
251
+ await send;
252
+ return { action: request.action, requestId: request.requestId, sequence };
253
+ }
254
+ openOwnerTask(requestId) {
255
+ if (!/^[a-f0-9]{64}$/.test(requestId))
256
+ throw new Error('owner task request ID must be exactly 64 lowercase hexadecimal characters');
257
+ const active = this.activeRequests.get(requestId);
258
+ if (!active || active.finalizing)
259
+ throw new Error('owner task can be opened only for a currently active owner request');
260
+ if (!this.authorizations.effective().has(active.contact))
261
+ throw new Error('originating owner is no longer authorized');
262
+ const task = this.tasks.open({
263
+ requestId, contact: active.contact, wireId: active.wireId,
264
+ });
265
+ this.options.log(`[${this.options.role}] owner task ${ownerTaskAuditId(task.id)} opened `
266
+ + `request=${ownerTaskAuditId(requestId)} expires=${new Date(task.expiresAt).toISOString()}`);
267
+ return { action: 'task_open', taskId: task.id, expiresAt: new Date(task.expiresAt).toISOString() };
268
+ }
269
+ async sendOwnerTaskReport(request) {
270
+ if (!['progress', 'done', 'blocked'].includes(request.phase))
271
+ throw new Error('owner task report phase must be progress, done, or blocked');
272
+ const message = this.safeTaskReport(request.message);
273
+ const now = Date.now();
274
+ const task = this.tasks.route(request.taskId, now);
275
+ if (this.activeRequests.has(task.requestId))
276
+ throw new Error('owner task reports are allowed only after the originating request has finalized');
277
+ if (!this.authorizations.integrity().ok)
278
+ throw new Error('owner authorization state is corrupt; proactive reports are disabled');
279
+ if (!this.authorizations.effective().has(task.contact)) {
280
+ this.tasks.revoke(task.contact, now);
281
+ throw new Error('originating owner is no longer authorized; task revoked');
282
+ }
283
+ const chars = Array.from(message).length;
284
+ const bytes = Buffer.byteLength(message);
285
+ const digest = ownerTaskDigest(request.phase, message);
286
+ const sending = this.tasks.beginReport(request.taskId, request.phase, digest, chars, bytes, now);
287
+ const taskAudit = ownerTaskAuditId(request.taskId);
288
+ const sequence = sending.sequence + 1;
289
+ this.options.log(`[${this.options.role}] owner task ${taskAudit} report phase=${request.phase} `
290
+ + `chars=${chars} bytes=${bytes} sequence=${sequence} sending`);
291
+ try {
292
+ await this.send(task.contact, ownerNotices.taskReport(request.phase, message), task.wireId);
293
+ }
294
+ catch {
295
+ try {
296
+ this.tasks.uncertain(request.taskId, digest);
297
+ }
298
+ catch (stateError) {
299
+ this.logError(`owner task ${taskAudit} uncertainty persist failed`, stateError);
300
+ }
301
+ this.options.log(`[${this.options.role}] owner task ${taskAudit} report phase=${request.phase} `
302
+ + `chars=${chars} bytes=${bytes} sequence=${sequence} result=uncertain`);
303
+ throw new Error('owner task report delivery outcome is uncertain; it was not retried');
304
+ }
305
+ const terminal = request.phase !== 'progress';
306
+ let deliveredSequence;
307
+ try {
308
+ deliveredSequence = this.tasks.delivered(request.taskId, digest, terminal, Date.now());
309
+ }
310
+ catch (error) {
311
+ this.logError(`owner task ${taskAudit} delivery commit failed`, error);
312
+ throw new Error('owner task report was sent but its durable delivery result is uncertain; do not retry');
313
+ }
314
+ this.options.log(`[${this.options.role}] owner task ${taskAudit} report phase=${request.phase} `
315
+ + `chars=${chars} bytes=${bytes} sequence=${deliveredSequence} result=delivered`);
316
+ return {
317
+ action: 'task_report', taskId: request.taskId, phase: request.phase,
318
+ sequence: deliveredSequence, state: terminal ? 'closed' : 'open',
319
+ };
320
+ }
321
+ safeOwnerUpdate(value) {
322
+ if (typeof value !== 'string')
323
+ throw new Error('owner update message must be text');
324
+ const message = value.trim().normalize('NFC');
325
+ if (!message)
326
+ throw new Error('owner update message is empty');
327
+ if (Array.from(message).length > OWNER_UPDATE_MAX_CHARS
328
+ || Buffer.byteLength(message) > OWNER_UPDATE_MAX_BYTES)
329
+ throw new Error(`owner update exceeds ${OWNER_UPDATE_MAX_CHARS} characters or ${OWNER_UPDATE_MAX_BYTES} bytes`);
330
+ if (/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(message))
331
+ throw new Error('owner update must be one line without control or direction-override characters');
332
+ if (/```|-----BEGIN [A-Z ]*PRIVATE KEY-----|(?:api[_ -]?key|access[_ -]?token|authorization|password|secret)\s*[:=]|(?:chain of thought|private reasoning|internal reasoning)|^(?:stdout|stderr|tool (?:output|result)|command):/iu.test(message))
333
+ throw new Error('owner update appears to contain unsafe reasoning, secret, or raw tool content');
334
+ return message;
335
+ }
336
+ safeTaskReport(value) {
337
+ const message = this.safeOwnerUpdate(value);
338
+ if (/[.!?](?:["')\]]*)\s+\S/u.test(message))
339
+ throw new Error('owner task report must contain exactly one plain-text sentence');
340
+ return message;
341
+ }
342
+ async drainAll() {
343
+ // A finite cap protects the supervisor if a broken daemon repeats unread
344
+ // messages forever. A watch notification will resume draining later.
345
+ for (let pass = 0; pass < 100 && !this.stopping; pass++) {
346
+ const [raw, fileResult] = await Promise.all([
347
+ this.client.callTool('get_messages'),
348
+ this.client.callTool('list_incoming_files')
349
+ .catch(error => {
350
+ this.logError('attachment metadata inspection unavailable', error);
351
+ return undefined;
352
+ }),
353
+ ]);
354
+ const messages = Array.isArray(raw?.messages)
355
+ ? raw.messages.filter(message => message && typeof message === 'object')
356
+ : [];
357
+ let files = [];
358
+ try {
359
+ files = parseIncomingAttachments(fileResult);
360
+ }
361
+ catch (error) {
362
+ this.logError('attachment metadata inspection unavailable', error);
363
+ }
364
+ const pending = this.attachmentRecovery.integrity() ? this.attachmentRecovery.list() : [];
365
+ const pendingWires = new Set(pending.flatMap(item => item.fileWireIds));
366
+ files = files.filter(file => (file.status === 'unread' || pendingWires.has(file.wireId))
367
+ && !this.state.has(file.wireId));
368
+ if (!messages.length && !files.length)
369
+ return;
370
+ // get_messages marks the batch processed. Requeue allowed, unhandled
371
+ // inputs before executing them so a mid-turn process crash can replay.
372
+ const deferred = messages.filter(message => {
373
+ const wireId = this.wireId(message);
374
+ return wireId && !this.state.has(wireId)
375
+ && this.authorizations.effective().has(this.sender(message).id)
376
+ && Number.isInteger(message.msg_id);
377
+ }).map(message => message.msg_id);
378
+ if (deferred.length)
379
+ await this.client.callTool('defer_messages', { msg_ids: deferred });
380
+ let advanced = false;
381
+ const consumedMessages = new Set();
382
+ const groups = this.attachmentGroups(files, messages, pending, consumedMessages);
383
+ for (const group of groups)
384
+ advanced = await this.handleAttachmentGroup(group) || advanced;
385
+ for (const message of messages) {
386
+ if (!consumedMessages.has(message))
387
+ advanced = await this.handle(message) || advanced;
388
+ }
389
+ // Deferred in-flight messages are intentionally visible again until
390
+ // their correlated response is delivered. Do not spin on those replay
391
+ // copies; a new watch event or completion-triggered drain will resume.
392
+ if (!advanced)
393
+ return;
394
+ }
395
+ this.options.log(`[${this.options.role}] owner channel drain capped at 100 batches`);
396
+ }
397
+ attachmentGroups(files, messages, pending, consumed) {
398
+ const groups = [];
399
+ const used = new Set();
400
+ const byWire = new Map(files.map(file => [file.wireId, file]));
401
+ const messageByWire = new Map(messages.map(message => [this.wireId(message), message]));
402
+ for (const recovery of pending) {
403
+ if (this.state.has(recovery.originWireId)) {
404
+ try {
405
+ this.attachmentRecovery.remove(recovery.id);
406
+ }
407
+ catch { }
408
+ continue;
409
+ }
410
+ const recovered = recovery.fileWireIds.map(wire => byWire.get(wire));
411
+ if (recovered.some(file => !file))
412
+ continue;
413
+ const exact = recovered;
414
+ if (exact.some(file => file.senderId !== recovery.contact))
415
+ continue;
416
+ exact.forEach(file => used.add(file.wireId));
417
+ groups.push({ files: exact, recovery });
418
+ }
419
+ for (const file of files) {
420
+ if (used.has(file.wireId))
421
+ continue;
422
+ let caption;
423
+ const replyTarget = file.replyTo?.wire_id;
424
+ if (replyTarget) {
425
+ const candidate = messageByWire.get(replyTarget);
426
+ if (candidate && this.sender(candidate).id === file.senderId)
427
+ caption = candidate;
428
+ }
429
+ caption ??= messages.find(message => this.sender(message).id === file.senderId && message.reply_to?.wire_id === file.wireId);
430
+ const related = files.filter(other => !used.has(other.wireId)
431
+ && other.senderId === file.senderId
432
+ && ((caption && other.replyTo?.wire_id === this.wireId(caption))
433
+ || other.replyTo?.wire_id === file.wireId || file.replyTo?.wire_id === other.wireId));
434
+ const selected = related.length ? related : [file];
435
+ selected.forEach(item => used.add(item.wireId));
436
+ if (caption)
437
+ consumed.add(caption);
438
+ groups.push({ files: selected, ...(caption ? { caption } : {}) });
439
+ }
440
+ return groups;
441
+ }
442
+ async handleAttachmentGroup(group) {
443
+ if (!group.files.length)
444
+ return false;
445
+ const originWireId = group.recovery?.originWireId ?? group.files[0].wireId;
446
+ const handledWireIds = [...new Set([
447
+ ...group.files.map(file => file.wireId),
448
+ ...(group.caption ? [this.wireId(group.caption)] : []),
449
+ ].filter(Boolean))];
450
+ if (handledWireIds.some(wire => this.inFlight.has(wire)))
451
+ return false;
452
+ const sender = { id: group.files[0].senderId, name: group.files[0].senderName };
453
+ if (group.files.some(file => file.senderId !== sender.id)
454
+ || !this.authorizations.effective().has(sender.id)) {
455
+ this.options.log(`[${this.options.role}] owner channel ignored unauthorized attachment sender ${sender.id}`);
456
+ for (const wire of handledWireIds)
457
+ this.state.remember(wire);
458
+ if (group.recovery)
459
+ try {
460
+ this.attachmentRecovery.remove(group.recovery.id);
461
+ }
462
+ catch { }
463
+ return true;
464
+ }
465
+ const rejection = !this.attachmentRecovery.integrity()
466
+ ? 'attachment recovery state is unavailable'
467
+ : validateAttachmentSelection(group.files, this.attachmentConfig);
468
+ if (rejection) {
469
+ await this.send(sender.id, ownerNotices.attachmentRejected(rejection), originWireId);
470
+ for (const wire of handledWireIds)
471
+ this.state.remember(wire);
472
+ return true;
473
+ }
474
+ const requestId = this.requestId(originWireId);
475
+ const recovery = group.recovery ?? {
476
+ id: requestId, contact: sender.id, originWireId,
477
+ fileWireIds: group.files.map(file => file.wireId), createdAt: Date.now(),
478
+ };
479
+ let requestDir;
480
+ let outbox;
481
+ try {
482
+ if (!group.recovery)
483
+ this.attachmentRecovery.add(recovery);
484
+ requestDir = await prepareAttachmentDirectory(this.attachmentRoot, requestId);
485
+ const unread = group.files.filter(file => file.status === 'unread');
486
+ const processed = group.files.filter(file => file.status !== 'unread');
487
+ const retrieved = unread.length
488
+ ? parseRetrievedAttachments(await this.client.callTool('get_files', {
489
+ wire_ids: unread.map(file => file.wireId),
490
+ }), unread)
491
+ : [];
492
+ for (const file of processed) {
493
+ if (!group.recovery)
494
+ throw new Error('unexpected processed attachment without recovery route');
495
+ const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
496
+ await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
497
+ retrieved.push(await recoveredAttachment(file, recoveryPath));
498
+ }
499
+ const order = new Map(group.files.map((file, index) => [file.wireId, index]));
500
+ retrieved.sort((a, b) => order.get(a.wireId) - order.get(b.wireId));
501
+ const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig);
502
+ outbox = this.outboxDir(originWireId);
503
+ await mkdir(outbox, { recursive: true, mode: 0o700 });
504
+ const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
505
+ const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, outbox, admitted, group.caption), {
506
+ interrupt: this.options.config.interrupt,
507
+ ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
508
+ origin: { kind: 'owner', requestId },
509
+ });
510
+ const accepted = this.options.config.interrupt
511
+ ? ownerNotices.receivedInterrupting()
512
+ : queued.queuedBehind > 0
513
+ ? ownerNotices.receivedQueued(queued.queuedBehind)
514
+ : ownerNotices.receivedStarted();
515
+ handledWireIds.forEach(wire => this.inFlight.add(wire));
516
+ const receipt = this.send(sender.id, accepted, originWireId).then(() => undefined).catch(error => {
517
+ this.logError(`attachment request ${requestId.slice(0, 12)} acceptance notice failed`, error);
518
+ });
519
+ const active = {
520
+ contact: sender.id, wireId: originWireId, requestId, outboundTail: receipt,
521
+ finalizing: false, updateCount: 0, updateDigests: new Set(), handledWireIds,
522
+ };
523
+ this.activeRequests.set(requestId, active);
524
+ const cleanupDir = requestDir;
525
+ let completed = false;
526
+ const task = this.complete(active, outbox, queued, activityCursor)
527
+ .then(() => { completed = true; })
528
+ .catch(error => this.logError(`attachment request ${requestId.slice(0, 12)} completion failed`, error))
529
+ .finally(async () => {
530
+ try {
531
+ await removeRequestDirectory(cleanupDir);
532
+ }
533
+ catch (error) {
534
+ this.logError(`attachment request ${requestId.slice(0, 12)} cleanup failed`, error);
535
+ }
536
+ if (completed) {
537
+ try {
538
+ this.attachmentRecovery.remove(recovery.id);
539
+ }
540
+ catch (error) {
541
+ this.logError('attachment recovery completion failed', error);
542
+ }
543
+ }
544
+ handledWireIds.forEach(wire => this.inFlight.delete(wire));
545
+ this.activeRequests.delete(requestId);
546
+ this.completionTasks.delete(task);
547
+ if (!this.stopping)
548
+ void this.drain().catch(error => this.logError('completion drain failed', error));
549
+ });
550
+ this.completionTasks.add(task);
551
+ return true;
552
+ }
553
+ catch (error) {
554
+ if (requestDir)
555
+ await removeRequestDirectory(requestDir).catch(() => undefined);
556
+ if (outbox)
557
+ await rm(outbox, { recursive: true, force: true }).catch(() => undefined);
558
+ try {
559
+ this.attachmentRecovery.remove(recovery.id);
560
+ }
561
+ catch { }
562
+ this.logError(`attachment request ${requestId.slice(0, 12)} admission failed`, error);
563
+ await this.send(sender.id, ownerNotices.attachmentFailed(), originWireId);
564
+ for (const wire of handledWireIds)
565
+ this.state.remember(wire);
566
+ return true;
567
+ }
568
+ }
569
+ async handle(message) {
570
+ const wireId = this.wireId(message);
571
+ if (!wireId || this.state.has(wireId) || this.inFlight.has(wireId))
572
+ return false;
573
+ const sender = this.sender(message);
574
+ if (!this.authorizations.effective().has(sender.id)) {
575
+ // Do not answer an unauthorized sender and thereby disclose that this is
576
+ // a privileged control address. Authenticated CID, never display name or
577
+ // message wording, is the authority boundary.
578
+ this.options.log(`[${this.options.role}] owner channel ignored unauthorized sender ${sender.id || '<unknown>'}`);
579
+ this.state.remember(wireId);
580
+ return true;
581
+ }
582
+ const text = String(message.text ?? '').trim();
583
+ if (text.toLowerCase() === '/status') {
584
+ const snapshot = this.options.session.snapshot();
585
+ await this.send(sender.id, ownerNotices.status(this.options.role, snapshot), wireId);
586
+ this.state.remember(wireId);
587
+ return true;
588
+ }
589
+ if (text.toLowerCase() === '/interrupt') {
590
+ try {
591
+ await this.options.session.interrupt('owner');
592
+ }
593
+ catch (error) {
594
+ this.logError('interrupt failed', error);
595
+ await this.send(sender.id, ownerNotices.interruptFailed(this.options.role), wireId);
596
+ this.state.remember(wireId);
597
+ return true;
598
+ }
599
+ await this.send(sender.id, ownerNotices.interrupted(this.options.role), wireId);
600
+ this.state.remember(wireId);
601
+ return true;
602
+ }
603
+ const requestId = this.requestId(wireId);
604
+ const outbox = this.outboxDir(wireId);
605
+ await mkdir(outbox, { recursive: true, mode: 0o700 });
606
+ let queued;
607
+ const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
608
+ try {
609
+ queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, requestId, outbox), {
610
+ interrupt: this.options.config.interrupt,
611
+ ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
612
+ origin: { kind: 'owner', requestId },
613
+ });
614
+ }
615
+ catch (error) {
616
+ await rm(outbox, { recursive: true, force: true });
617
+ this.logError('request delivery failed', error);
618
+ await this.send(sender.id, ownerNotices.deliveryFailed(this.options.role), wireId);
619
+ this.state.remember(wireId);
620
+ return true;
621
+ }
622
+ const accepted = this.options.config.interrupt
623
+ ? ownerNotices.receivedInterrupting()
624
+ : queued.queuedBehind > 0
625
+ ? ownerNotices.receivedQueued(queued.queuedBehind)
626
+ : ownerNotices.receivedStarted();
627
+ this.inFlight.add(wireId);
628
+ const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
629
+ this.logError(`request ${requestId.slice(0, 12)} acceptance notice failed`, error);
630
+ });
631
+ const active = {
632
+ contact: sender.id, wireId, requestId, outboundTail: receipt, finalizing: false,
633
+ updateCount: 0, updateDigests: new Set(), handledWireIds: [wireId],
634
+ };
635
+ this.activeRequests.set(requestId, active);
636
+ const task = this.complete(active, outbox, queued, activityCursor)
637
+ .catch(error => this.logError(`request ${wireId} completion failed`, error))
638
+ .finally(() => {
639
+ this.inFlight.delete(wireId);
640
+ this.activeRequests.delete(requestId);
641
+ this.completionTasks.delete(task);
642
+ if (!this.stopping)
643
+ void this.drain().catch(error => this.logError('completion drain failed', error));
644
+ });
645
+ this.completionTasks.add(task);
646
+ return true;
647
+ }
648
+ async complete(active, outbox, queued, activityCursor) {
649
+ const progressMs = this.options.config.progress_interval_ms;
650
+ const startedAt = Date.now();
651
+ let lastSeq = activityCursor;
652
+ let phase = queued.queuedBehind > 0
653
+ ? 'waiting behind earlier requests' : 'starting request';
654
+ const timer = progressMs > 0 ? setInterval(() => {
655
+ const events = this.options.session.eventsSince(lastSeq);
656
+ lastSeq = Math.max(lastSeq, this.latestEventSeq(events));
657
+ const activity = events.filter(event => event.turnId === queued.promptId);
658
+ phase = this.progressPhase(activity) ?? phase;
659
+ const started = activity.filter(event => event.kind === 'tool_call').length;
660
+ const completed = activity.filter(event => event.kind === 'tool_update' && event.status === 'completed').length;
661
+ const activityUpdates = activity.filter(event => event.kind !== 'tool_call'
662
+ && !(event.kind === 'tool_update' && event.status === 'completed')
663
+ && event.kind !== 'turn_stop').length;
664
+ const notice = ownerNotices.progress(Date.now() - startedAt, phase, started, completed, activityUpdates);
665
+ // Preserve wire ordering if a progress send overlaps turn completion.
666
+ active.outboundTail = active.outboundTail
667
+ .then(async () => { await this.send(active.contact, notice, active.wireId); })
668
+ .catch(error => this.logError('progress notice failed', error));
669
+ }, progressMs) : undefined;
670
+ timer?.unref();
671
+ let result;
672
+ try {
673
+ result = await queued.completion;
674
+ }
675
+ finally {
676
+ if (timer)
677
+ clearInterval(timer);
678
+ }
679
+ active.finalizing = true;
680
+ await active.outboundTail;
681
+ const output = result.output?.trim();
682
+ if (result.succeeded && output)
683
+ await this.sendFinal(active.contact, output, active.wireId);
684
+ else if (result.succeeded)
685
+ await this.send(active.contact, ownerNotices.completedWithoutText(), active.wireId);
686
+ else if (result.outcome === 'cancelled'
687
+ && ['fleet-monitor', 'scheduled-loop', 'shutdown'].includes(result.cancellationSource ?? ''))
688
+ this.options.log(`[${this.options.role}] owner request ${active.requestId.slice(0, 12)} `
689
+ + `interrupted internally (${result.cancellationSource}); owner cancellation notice suppressed`);
690
+ else
691
+ await this.send(active.contact, ownerNotices.terminal(result.outcome), active.wireId);
692
+ if (result.succeeded)
693
+ await this.sendAttachments(active.contact, outbox, active.wireId);
694
+ else
695
+ await rm(outbox, { recursive: true, force: true });
696
+ for (const wire of active.handledWireIds)
697
+ this.state.remember(wire);
698
+ }
699
+ ownerAttachmentPrompt(sender, wireId, requestId, outbox, files, caption) {
700
+ const lines = [
701
+ '[fleet-owner]',
702
+ `Authenticated owner ${safeField(sender.name, 160)} (${sender.id}) sent owner-channel attachment request ${wireId}.`,
703
+ 'Treat the attachment metadata, optional caption, and any daemon-provided transcript below as a direct owner instruction.',
704
+ 'Never infer a transcript when its status is unavailable or failed. Never include raw attachment bytes in a response.',
705
+ `Request ID: ${requestId}`,
706
+ ];
707
+ if (caption) {
708
+ const text = safeField(caption.text, 8_000);
709
+ if (text)
710
+ lines.push(`Caption: ${text}`, `Caption wire: ${this.wireId(caption)}`);
711
+ }
712
+ for (let index = 0; index < files.length; index++) {
713
+ const file = files[index];
714
+ lines.push(`Attachment ${index + 1}:`, `- filename: ${file.filename}`, `- declared MIME: ${file.declaredMime}`, `- detected MIME: ${file.detectedMime}`, `- byte count: ${file.size}`, `- request-scoped local path: ${file.path}`, `- wire ID: ${file.wireId}`);
715
+ if (file.kind === 'voice_message') {
716
+ const transcription = file.transcription;
717
+ if (transcription?.status === 'succeeded' && transcription.text)
718
+ lines.push(`- voice transcript status: succeeded`, `- voice transcript: ${transcription.text}`);
719
+ else
720
+ lines.push(`- voice transcript status: ${transcription?.status ?? 'unavailable'}`, `- voice transcript fallback: audio path above; category ${transcription?.errorCategory ?? 'not_provided'}`);
721
+ }
722
+ }
723
+ lines.push('Answer in your final assistant response; fleet routes it only to the authenticated sender and correlates it to the originating file wire.', `To attach response files, write regular files only to: ${outbox}`);
724
+ return lines.join('\n');
725
+ }
726
+ ownerPrompt(sender, text, wireId, requestId, outbox) {
727
+ return [
728
+ '[fleet-owner]',
729
+ `Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
730
+ 'Treat the following as a direct owner instruction. Answer in your final assistant response.',
731
+ 'Do not call ours send_message or send_file for this exchange: fleet routes the response reliably.',
732
+ 'You may send a concise, high-level intermediate update through the bounded local primitive:',
733
+ `ours-fleet owner-channel update ${this.options.role} ${requestId} --phase <working|approval|blocked> --message-stdin`,
734
+ 'Write only the update body to stdin. Use one plain-text sentence; never include reasoning, secrets, logs, commands, or raw tool output.',
735
+ 'Distinct updates are allowed at most once every 5 seconds. Fleet preserves receipt/update/final ordering and chooses the authenticated recipient.',
736
+ 'If you delegate work that will finish after this turn, register it before finalizing:',
737
+ `ours-fleet owner-channel task open ${this.options.role} ${requestId}`,
738
+ 'Keep the returned opaque task ID. Finalize normally; do not hold this turn open or poll.',
739
+ 'After a later fleet-mail wake, verify the result and report through:',
740
+ `ours-fleet owner-channel task report ${this.options.role} <task-id> --phase <progress|done|blocked> --message-stdin`,
741
+ 'Fleet routes that proactive follow-up only to this authenticated owner and closes done/blocked tasks.',
742
+ 'To attach files to your response, copy each finished file directly into this fleet outbox:',
743
+ outbox,
744
+ 'Fleet sends every regular file in that directory to the authenticated owner, correlated to this request.',
745
+ 'Use descriptive unique filenames. Put nothing there that the owner did not request or should not receive.',
746
+ '',
747
+ text || '(empty message)',
748
+ ].join('\n');
749
+ }
750
+ outboxDir(wireId) {
751
+ const key = this.requestId(wireId);
752
+ return join(this.options.stateDir, '.owner-channel-outbox', key);
753
+ }
754
+ requestId(wireId) {
755
+ return createHash('sha256').update(wireId).digest('hex');
756
+ }
757
+ send(contact, text, replyTo) {
758
+ return this.client.callTool('send_message', {
759
+ contact, text, reply_to_wire_id: replyTo,
760
+ });
761
+ }
762
+ async sendAttachments(contact, outbox, replyTo) {
763
+ const entries = (await readdir(outbox, { withFileTypes: true }))
764
+ .filter(entry => entry.isFile())
765
+ .sort((a, b) => a.name.localeCompare(b.name));
766
+ for (const entry of entries) {
767
+ await this.client.callTool('send_file', {
768
+ contact,
769
+ path: join(outbox, entry.name),
770
+ filename: entry.name,
771
+ reply_to_wire_id: replyTo,
772
+ });
773
+ }
774
+ await rm(outbox, { recursive: true, force: true });
775
+ }
776
+ /** Bound message size without splitting Unicode code points. */
777
+ async sendFinal(contact, output, replyTo) {
778
+ const points = Array.from(output);
779
+ const chunks = [];
780
+ for (let offset = 0; offset < points.length; offset += 8_000)
781
+ chunks.push(points.slice(offset, offset + 8_000).join(''));
782
+ for (let i = 0; i < chunks.length; i++) {
783
+ const prefix = chunks.length > 1 ? ownerNotices.chunk(i + 1, chunks.length) : '';
784
+ await this.send(contact, prefix + chunks[i], replyTo);
785
+ }
786
+ }
787
+ wireId(message) {
788
+ return String(message.wire_id ?? message.msg_id ?? '');
789
+ }
790
+ sender(message) {
791
+ const source = message.from ?? message.sender;
792
+ if (typeof source === 'string')
793
+ return { id: source, name: source };
794
+ const id = String(source?.id ?? message.sender_id ?? '');
795
+ return { id, name: String(source?.name ?? message.sender_name ?? id) };
796
+ }
797
+ latestEventSeq(events) {
798
+ return events.reduce((latest, event) => Math.max(latest, event.seq), 0);
799
+ }
800
+ /** Map only event shape and allowlisted status to owner-safe phase text. */
801
+ progressPhase(events) {
802
+ for (let i = events.length - 1; i >= 0; i--) {
803
+ const event = events[i];
804
+ switch (event.kind) {
805
+ case 'tool_call': return 'using tools';
806
+ case 'tool_update':
807
+ return event.status === 'completed' ? 'reviewing tool results' : 'using tools';
808
+ case 'permission':
809
+ return event.status === 'pending'
810
+ ? 'waiting for approval' : 'resuming after permission decision';
811
+ case 'agent_text': return 'drafting response';
812
+ case 'thought': return 'planning next step';
813
+ case 'error': return 'recovering from session error';
814
+ case 'state':
815
+ if (event.status === 'running')
816
+ return 'working on request';
817
+ break;
818
+ case 'turn_stop': break;
819
+ }
820
+ }
821
+ return undefined;
822
+ }
823
+ async watchLoop() {
824
+ let delayMs = 1_000;
825
+ while (!this.stopping) {
826
+ try {
827
+ const child = this.options.watch?.(this.options.config.identity) ?? spawn(this.options.command ?? 'ours-mcp', ['watch', this.options.config.identity], {
828
+ env: { ...process.env, ...(this.options.env ?? {}) }, stdio: ['pipe', 'pipe', 'pipe'],
829
+ });
830
+ this.watchProcess = child;
831
+ await new Promise((resolve, reject) => {
832
+ if (child.pid) {
833
+ resolve();
834
+ return;
835
+ }
836
+ child.once('spawn', resolve);
837
+ child.once('error', reject);
838
+ });
839
+ createInterface({ input: child.stderr }).on('line', line => this.options.log(`[${this.options.role}] owner watch: ${line}`));
840
+ delayMs = 1_000;
841
+ // Drain at every (re)attachment, not only after a future notification:
842
+ // a failed send/turn leaves the input deferred and may not emit another
843
+ // watch line by itself.
844
+ await this.drain();
845
+ for await (const _line of createInterface({ input: child.stdout })) {
846
+ if (this.stopping)
847
+ break;
848
+ await this.drain();
849
+ }
850
+ if (!this.stopping)
851
+ throw new Error('watch exited');
852
+ }
853
+ catch (error) {
854
+ if (!this.stopping) {
855
+ this.logError('watch failed; retrying', error);
856
+ await new Promise(resolve => setTimeout(resolve, delayMs));
857
+ delayMs = Math.min(delayMs * 2, 30_000);
858
+ }
859
+ }
860
+ finally {
861
+ const child = this.watchProcess;
862
+ this.watchProcess = undefined;
863
+ if (child && child.exitCode === null)
864
+ child.kill('SIGTERM');
865
+ }
866
+ }
867
+ }
868
+ errorText(error) {
869
+ return error?.message ?? String(error);
870
+ }
871
+ logError(context, error) {
872
+ this.options.log(`[${this.options.role}] owner channel ${context}: ${this.errorText(error)}`);
873
+ }
874
+ }