@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.
- package/README.md +184 -0
- package/dist/briefing.js +10 -0
- package/dist/cli.js +404 -1
- package/dist/config.d.ts +18 -2
- package/dist/config.js +67 -3
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +111 -0
- package/dist/duration.js +7 -3
- package/dist/loops/config.d.ts +30 -0
- package/dist/loops/config.js +135 -0
- package/dist/loops/manager.d.ts +48 -0
- package/dist/loops/manager.js +237 -0
- package/dist/loops/state.d.ts +54 -0
- package/dist/loops/state.js +148 -0
- package/dist/monitor.js +26 -2
- package/dist/owner-channel/attachments.d.ts +74 -0
- package/dist/owner-channel/attachments.js +378 -0
- package/dist/owner-channel/channel.d.ts +114 -2
- package/dist/owner-channel/channel.js +622 -43
- package/dist/owner-channel/notices.d.ts +21 -0
- package/dist/owner-channel/notices.js +66 -0
- package/dist/owner-channel/state.d.ts +34 -0
- package/dist/owner-channel/state.js +148 -1
- package/dist/owner-channel/tasks.d.ts +62 -0
- package/dist/owner-channel/tasks.js +246 -0
- package/dist/resolved-plan.js +11 -0
- package/dist/runner.js +86 -7
- package/dist/session/acp.d.ts +3 -2
- package/dist/session/acp.js +67 -25
- package/dist/session/arbiter.d.ts +42 -0
- package/dist/session/arbiter.js +72 -0
- package/dist/session/control.d.ts +12 -1
- package/dist/session/control.js +56 -3
- package/dist/session/types.d.ts +26 -2
- package/dist/session/types.js +5 -2
- package/dist/supervisor/systemd.js +12 -2
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
78
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
?
|
|
134
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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 ?
|
|
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) {
|