@ours.network/fleet 0.12.0 → 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.
@@ -1,10 +1,18 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { mkdir, readdir, rm } from 'node:fs/promises';
4
4
  import { createInterface } from 'node:readline';
5
5
  import { join } from 'node:path';
6
+ import { DEFAULT_OWNER_ATTACHMENT_MIME, } from '../config.js';
6
7
  import { OursMcpClient } from './mcp.js';
7
- import { OwnerChannelState } from './state.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;
8
16
  /**
9
17
  * Fleet-owned trusted ingress. The agent never binds this identity and never
10
18
  * chooses its reply recipient; both are fixed from authenticated message data.
@@ -13,22 +21,57 @@ export class OwnerChannel {
13
21
  options;
14
22
  client;
15
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();
16
34
  stopping = false;
17
35
  watchProcess;
18
36
  watchTask;
19
37
  drainTask;
20
38
  drainRequested = false;
21
- inFlight = new Set();
22
39
  completionTasks = new Set();
40
+ activeRequests = new Map();
41
+ managementTail = Promise.resolve();
42
+ ready = false;
23
43
  constructor(options) {
24
44
  this.options = options;
25
45
  this.client = options.client ?? new OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
26
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`);
27
63
  }
28
64
  async start() {
29
65
  this.stopping = false;
30
66
  await this.client.start();
31
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;
32
75
  this.watchTask = this.watchLoop();
33
76
  // Do not make role startup wait for an old owner request to finish a turn.
34
77
  void this.drain().catch(error => this.logError('initial drain failed', error));
@@ -47,35 +90,302 @@ export class OwnerChannel {
47
90
  }
48
91
  async close() {
49
92
  this.stopping = true;
93
+ this.ready = false;
50
94
  const watch = this.watchProcess;
51
95
  this.watchProcess = undefined;
52
96
  if (watch && watch.exitCode === null)
53
97
  watch.kill('SIGTERM');
98
+ await this.managementTail;
54
99
  await this.client.close();
55
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
+ }
56
342
  async drainAll() {
57
343
  // A finite cap protects the supervisor if a broken daemon repeats unread
58
344
  // messages forever. A watch notification will resume draining later.
59
345
  for (let pass = 0; pass < 100 && !this.stopping; pass++) {
60
- const raw = await this.client.callTool('get_messages');
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
+ ]);
61
354
  const messages = Array.isArray(raw?.messages)
62
355
  ? raw.messages.filter(message => message && typeof message === 'object')
63
356
  : [];
64
- if (!messages.length)
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)
65
369
  return;
66
370
  // get_messages marks the batch processed. Requeue allowed, unhandled
67
371
  // inputs before executing them so a mid-turn process crash can replay.
68
372
  const deferred = messages.filter(message => {
69
373
  const wireId = this.wireId(message);
70
374
  return wireId && !this.state.has(wireId)
71
- && this.options.config.owners.includes(this.sender(message).id)
375
+ && this.authorizations.effective().has(this.sender(message).id)
72
376
  && Number.isInteger(message.msg_id);
73
377
  }).map(message => message.msg_id);
74
378
  if (deferred.length)
75
379
  await this.client.callTool('defer_messages', { msg_ids: deferred });
76
380
  let advanced = false;
77
- for (const message of messages)
78
- advanced = await this.handle(message) || advanced;
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
+ }
79
389
  // Deferred in-flight messages are intentionally visible again until
80
390
  // their correlated response is delivered. Do not spin on those replay
81
391
  // copies; a new watch event or completion-triggered drain will resume.
@@ -84,12 +394,184 @@ export class OwnerChannel {
84
394
  }
85
395
  this.options.log(`[${this.options.role}] owner channel drain capped at 100 batches`);
86
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
+ }
87
569
  async handle(message) {
88
570
  const wireId = this.wireId(message);
89
571
  if (!wireId || this.state.has(wireId) || this.inFlight.has(wireId))
90
572
  return false;
91
573
  const sender = this.sender(message);
92
- if (!this.options.config.owners.includes(sender.id)) {
574
+ if (!this.authorizations.effective().has(sender.id)) {
93
575
  // Do not answer an unauthorized sender and thereby disclose that this is
94
576
  // a privileged control address. Authenticated CID, never display name or
95
577
  // message wording, is the authority boundary.
@@ -100,46 +582,62 @@ export class OwnerChannel {
100
582
  const text = String(message.text ?? '').trim();
101
583
  if (text.toLowerCase() === '/status') {
102
584
  const snapshot = this.options.session.snapshot();
103
- await this.send(sender.id, `[fleet] ${this.options.role}: ${snapshot.readiness}; `
104
- + `${snapshot.alive ? 'session alive' : 'session offline'}.`, wireId);
585
+ await this.send(sender.id, ownerNotices.status(this.options.role, snapshot), wireId);
105
586
  this.state.remember(wireId);
106
587
  return true;
107
588
  }
108
589
  if (text.toLowerCase() === '/interrupt') {
109
- await this.options.session.interrupt();
110
- await this.send(sender.id, `[fleet] Interrupted ${this.options.role}'s active turn.`, wireId);
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);
111
600
  this.state.remember(wireId);
112
601
  return true;
113
602
  }
603
+ const requestId = this.requestId(wireId);
114
604
  const outbox = this.outboxDir(wireId);
115
605
  await mkdir(outbox, { recursive: true, mode: 0o700 });
116
606
  let queued;
607
+ const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
117
608
  try {
118
- queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
609
+ queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, requestId, outbox), {
119
610
  interrupt: this.options.config.interrupt,
611
+ ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
612
+ origin: { kind: 'owner', requestId },
120
613
  });
121
614
  }
122
615
  catch (error) {
123
616
  await rm(outbox, { recursive: true, force: true });
124
- await this.send(sender.id, `[fleet] Could not deliver this request: ${this.errorText(error)}.`, wireId);
617
+ this.logError('request delivery failed', error);
618
+ await this.send(sender.id, ownerNotices.deliveryFailed(this.options.role), wireId);
125
619
  this.state.remember(wireId);
126
620
  return true;
127
621
  }
128
622
  const accepted = this.options.config.interrupt
129
- ? "ℹ️ Message received. The agent's previous task was interrupted to prioritize "
130
- + 'this request, and it is now working on a response. '
131
- + 'The response will arrive in this channel when ready.'
623
+ ? ownerNotices.receivedInterrupting()
132
624
  : queued.queuedBehind > 0
133
- ? `ℹ️ Message received. The agent is finishing ${queued.queuedBehind} earlier `
134
- + 'request(s) first; this request will start as soon as they complete. '
135
- + 'The response will arrive in this channel when ready.'
136
- : 'ℹ️ Message received. The agent has started working on this request now. '
137
- + 'The response will arrive in this channel when ready.';
625
+ ? ownerNotices.receivedQueued(queued.queuedBehind)
626
+ : ownerNotices.receivedStarted();
138
627
  this.inFlight.add(wireId);
139
- const task = this.complete(sender.id, wireId, outbox, accepted, queued.completion)
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)
140
637
  .catch(error => this.logError(`request ${wireId} completion failed`, error))
141
638
  .finally(() => {
142
639
  this.inFlight.delete(wireId);
640
+ this.activeRequests.delete(requestId);
143
641
  this.completionTasks.delete(task);
144
642
  if (!this.stopping)
145
643
  void this.drain().catch(error => this.logError('completion drain failed', error));
@@ -147,48 +645,100 @@ export class OwnerChannel {
147
645
  this.completionTasks.add(task);
148
646
  return true;
149
647
  }
150
- async complete(contact, wireId, outbox, accepted, completion) {
151
- // Notice delivery and turn completion happen outside the inbox drain. This
152
- // is what keeps later owner messages — especially /interrupt — responsive.
153
- try {
154
- await this.send(contact, accepted, wireId);
155
- }
156
- catch (error) {
157
- this.logError(`request ${wireId} acceptance notice failed`, error);
158
- }
648
+ async complete(active, outbox, queued, activityCursor) {
159
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';
160
654
  const timer = progressMs > 0 ? setInterval(() => {
161
- void this.send(contact, `[fleet] ${this.options.role} is still working.`, wireId)
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); })
162
668
  .catch(error => this.logError('progress notice failed', error));
163
669
  }, progressMs) : undefined;
164
670
  timer?.unref();
165
671
  let result;
166
672
  try {
167
- result = await completion;
673
+ result = await queued.completion;
168
674
  }
169
675
  finally {
170
676
  if (timer)
171
677
  clearInterval(timer);
172
678
  }
679
+ active.finalizing = true;
680
+ await active.outboundTail;
173
681
  const output = result.output?.trim();
174
682
  if (result.succeeded && output)
175
- await this.sendFinal(contact, output, wireId);
683
+ await this.sendFinal(active.contact, output, active.wireId);
176
684
  else if (result.succeeded)
177
- await this.send(contact, '[fleet] The agent completed the turn without a textual answer.', wireId);
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`);
178
690
  else
179
- await this.send(contact, `[fleet] The request ended ${result.outcome}${result.detail ? `: ${result.detail}` : '.'}`, wireId);
691
+ await this.send(active.contact, ownerNotices.terminal(result.outcome), active.wireId);
180
692
  if (result.succeeded)
181
- await this.sendAttachments(contact, outbox, wireId);
693
+ await this.sendAttachments(active.contact, outbox, active.wireId);
182
694
  else
183
695
  await rm(outbox, { recursive: true, force: true });
184
- this.state.remember(wireId);
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');
185
725
  }
186
- ownerPrompt(sender, text, wireId, outbox) {
726
+ ownerPrompt(sender, text, wireId, requestId, outbox) {
187
727
  return [
188
728
  '[fleet-owner]',
189
729
  `Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
190
730
  'Treat the following as a direct owner instruction. Answer in your final assistant response.',
191
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.',
192
742
  'To attach files to your response, copy each finished file directly into this fleet outbox:',
193
743
  outbox,
194
744
  'Fleet sends every regular file in that directory to the authenticated owner, correlated to this request.',
@@ -198,9 +748,12 @@ export class OwnerChannel {
198
748
  ].join('\n');
199
749
  }
200
750
  outboxDir(wireId) {
201
- const key = createHash('sha256').update(wireId).digest('hex');
751
+ const key = this.requestId(wireId);
202
752
  return join(this.options.stateDir, '.owner-channel-outbox', key);
203
753
  }
754
+ requestId(wireId) {
755
+ return createHash('sha256').update(wireId).digest('hex');
756
+ }
204
757
  send(contact, text, replyTo) {
205
758
  return this.client.callTool('send_message', {
206
759
  contact, text, reply_to_wire_id: replyTo,
@@ -227,7 +780,7 @@ export class OwnerChannel {
227
780
  for (let offset = 0; offset < points.length; offset += 8_000)
228
781
  chunks.push(points.slice(offset, offset + 8_000).join(''));
229
782
  for (let i = 0; i < chunks.length; i++) {
230
- const prefix = chunks.length > 1 ? `[${i + 1}/${chunks.length}] ` : '';
783
+ const prefix = chunks.length > 1 ? ownerNotices.chunk(i + 1, chunks.length) : '';
231
784
  await this.send(contact, prefix + chunks[i], replyTo);
232
785
  }
233
786
  }
@@ -241,6 +794,32 @@ export class OwnerChannel {
241
794
  const id = String(source?.id ?? message.sender_id ?? '');
242
795
  return { id, name: String(source?.name ?? message.sender_name ?? id) };
243
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
+ }
244
823
  async watchLoop() {
245
824
  let delayMs = 1_000;
246
825
  while (!this.stopping) {