@ours.network/fleet 0.17.7 → 0.18.0-nightly.2
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 +8 -20
- package/dist/application/fleet-query-service.js +0 -12
- package/dist/application/role-creation-service.js +1 -3
- package/dist/application/types.d.ts +0 -11
- package/dist/briefing.js +0 -5
- package/dist/build-info.json +5 -5
- package/dist/cli.js +7 -29
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +10 -22
- package/dist/fleet-proxy.d.ts +0 -5
- package/dist/harness/acp-agent.js +6 -11
- package/dist/harness/codex.d.ts +1 -4
- package/dist/harness/codex.js +11 -59
- package/dist/harness/types.d.ts +3 -3
- package/dist/loops/manager.d.ts +1 -30
- package/dist/loops/manager.js +6 -65
- package/dist/loops/state.d.ts +0 -18
- package/dist/loops/state.js +0 -4
- package/dist/owner-channel/attachments.d.ts +25 -2
- package/dist/owner-channel/attachments.js +61 -5
- package/dist/owner-channel/channel.d.ts +18 -2
- package/dist/owner-channel/channel.js +78 -74
- package/dist/owner-channel/ours-client.d.ts +126 -0
- package/dist/owner-channel/ours-client.js +148 -0
- package/dist/runner.js +3 -27
- package/dist/session/acp.d.ts +0 -55
- package/dist/session/acp.js +1 -122
- package/dist/session/types.d.ts +0 -24
- package/dist/supervisor/systemd.js +28 -1
- package/dist/watchdog/briefing.js +0 -7
- package/package.json +2 -1
- package/dist/owner-channel/mcp.d.ts +0 -24
- package/dist/owner-channel/mcp.js +0 -145
- package/dist/session/activity.d.ts +0 -31
- package/dist/session/activity.js +0 -48
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { createHash
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
@@ -10,11 +10,11 @@ import { resolveEndpoint } from '../monitor.js';
|
|
|
10
10
|
import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, interruptOutcome, } from '../session/types.js';
|
|
11
11
|
import { VERSION } from '../version.js';
|
|
12
12
|
import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
|
|
13
|
-
import {
|
|
13
|
+
import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
|
|
14
14
|
import { ownerNotices, } from './notices.js';
|
|
15
15
|
import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
|
|
16
16
|
import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
|
|
17
|
-
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, } from './attachments.js';
|
|
17
|
+
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, writeRecoveredAttachment, } from './attachments.js';
|
|
18
18
|
import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
|
|
19
19
|
const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
|
|
20
20
|
const OWNER_UPDATE_MAX_COUNT = 20;
|
|
@@ -84,7 +84,7 @@ export class OwnerChannel {
|
|
|
84
84
|
fleetOps;
|
|
85
85
|
constructor(options) {
|
|
86
86
|
this.options = options;
|
|
87
|
-
this.client = options.client ?? new
|
|
87
|
+
this.client = options.client ?? new OursSdkClient(options.env, line => options.log(`[${options.role}] owner channel ${line}`));
|
|
88
88
|
this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
|
|
89
89
|
this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
|
|
90
90
|
this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
|
|
@@ -123,12 +123,16 @@ export class OwnerChannel {
|
|
|
123
123
|
const bindStartedAt = now();
|
|
124
124
|
for (;;) {
|
|
125
125
|
try {
|
|
126
|
-
await this.client.
|
|
126
|
+
await this.client.bindIdentity(this.options.config.identity);
|
|
127
127
|
break;
|
|
128
128
|
}
|
|
129
129
|
catch (error) {
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
// The predecessor's lease may still be in flight. Only the daemon's
|
|
131
|
+
// own typed verdict may extend the handoff window: matching the
|
|
132
|
+
// wording of an error message would let any other failure whose text
|
|
133
|
+
// happens to say "bound to another live session" — including one
|
|
134
|
+
// relayed from a peer — spin here for the whole timeout.
|
|
135
|
+
const liveConflict = oursErrorCode(error) === OURS_BOUND_ELSEWHERE;
|
|
132
136
|
if (!this.binder.inherited || !liveConflict
|
|
133
137
|
|| now() - bindStartedAt >= OWNER_BIND_HANDOFF_TIMEOUT_MS)
|
|
134
138
|
throw error;
|
|
@@ -201,13 +205,10 @@ export class OwnerChannel {
|
|
|
201
205
|
? ' with interruption'
|
|
202
206
|
: event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
|
|
203
207
|
const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
|
|
204
|
-
const permission = event.permissionMode
|
|
205
|
-
? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
|
|
206
|
-
: '';
|
|
207
208
|
const inherited = event.inherited.length
|
|
208
209
|
? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
|
|
209
210
|
await this.sendProactiveMessage(`🧑💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
|
|
210
|
-
+ `(${event.harness}/${event.session}${model}; ${monitor}
|
|
211
|
+
+ `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
|
|
211
212
|
});
|
|
212
213
|
this.managementTail = run.then(() => undefined, () => undefined);
|
|
213
214
|
return run;
|
|
@@ -220,11 +221,14 @@ export class OwnerChannel {
|
|
|
220
221
|
return { action: request.action, contacts: await this.contacts() };
|
|
221
222
|
case 'contact_invite': {
|
|
222
223
|
this.assertLabel(request.name);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
224
|
+
// The invite is the blob field, not a sentence containing it. The MCP
|
|
225
|
+
// surface answered with "One-time invite for X created (invite_id …).
|
|
226
|
+
// Share this blob out-of-band …:\n<blob>" and the whole sentence was
|
|
227
|
+
// handed out as the invite, so any rewording changed the payload.
|
|
228
|
+
const { blob } = await this.client.generateInvite(request.name);
|
|
229
|
+
if (typeof blob !== 'string' || !blob)
|
|
230
|
+
throw new Error('the ours daemon returned no invite blob');
|
|
231
|
+
return { action: request.action, invite: blob };
|
|
228
232
|
}
|
|
229
233
|
case 'contact_add': {
|
|
230
234
|
if (typeof request.invite !== 'string' || !request.invite)
|
|
@@ -232,18 +236,19 @@ export class OwnerChannel {
|
|
|
232
236
|
if (Buffer.byteLength(request.invite) > 48 * 1024)
|
|
233
237
|
throw new Error('invite exceeds 49152 bytes');
|
|
234
238
|
this.assertLabel(request.name);
|
|
235
|
-
let
|
|
239
|
+
let added;
|
|
236
240
|
try {
|
|
237
|
-
|
|
241
|
+
added = await this.client.addContact({
|
|
238
242
|
invite: request.invite, ...(request.name ? { name: request.name } : {}),
|
|
239
243
|
});
|
|
240
244
|
}
|
|
241
245
|
catch {
|
|
242
246
|
// Daemon errors are not allowed to reflect invite material through
|
|
243
247
|
// the control response, CLI stderr, or supervisor logs.
|
|
244
|
-
throw new Error('ours
|
|
248
|
+
throw new Error('the ours daemon could not accept the contact invite');
|
|
245
249
|
}
|
|
246
|
-
|
|
250
|
+
const contact = this.contact(added.cid, added.display, 'pending');
|
|
251
|
+
return { action: request.action, status: 'pending', ...(contact ? { contact } : {}) };
|
|
247
252
|
}
|
|
248
253
|
case 'owner_list':
|
|
249
254
|
return {
|
|
@@ -317,33 +322,36 @@ export class OwnerChannel {
|
|
|
317
322
|
throw new Error('unknown owner-channel management action');
|
|
318
323
|
}
|
|
319
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* The daemon reports established contacts and pending introductions as two
|
|
327
|
+
* separate collections, so the status is structural rather than a word parsed
|
|
328
|
+
* out of a rendered line. Nothing here can be spoofed by a contact's own
|
|
329
|
+
* display name.
|
|
330
|
+
*/
|
|
320
331
|
async contacts() {
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
332
|
+
const view = await this.client.listContacts();
|
|
333
|
+
const rows = [
|
|
334
|
+
...(Array.isArray(view?.contacts) ? view.contacts : [])
|
|
335
|
+
.map(row => this.contact(row?.container_id, row?.name, 'established', view)),
|
|
336
|
+
...(Array.isArray(view?.pending) ? view.pending : [])
|
|
337
|
+
.map(row => this.contact(row?.container_id, row?.name, 'pending', view)),
|
|
338
|
+
];
|
|
339
|
+
return rows.filter((row) => Boolean(row))
|
|
326
340
|
.sort((a, b) => a.cid.localeCompare(b.cid));
|
|
327
341
|
}
|
|
328
|
-
contact(
|
|
329
|
-
|
|
330
|
-
return undefined;
|
|
331
|
-
const value = raw;
|
|
332
|
-
const cid = String(value.cid ?? value.id ?? value.container_id ?? value.containerId ?? '');
|
|
342
|
+
contact(cidValue, nameValue, status, view) {
|
|
343
|
+
const cid = String(cidValue ?? '');
|
|
333
344
|
if (!/^[A-Fa-f0-9]{64}$/.test(cid))
|
|
334
345
|
return undefined;
|
|
335
|
-
const
|
|
336
|
-
const
|
|
346
|
+
const root = view?.roots?.[cid];
|
|
347
|
+
const rootCid = String(root?.root_cid ?? '');
|
|
337
348
|
return {
|
|
338
349
|
cid,
|
|
339
|
-
name: this.safeMetadata(
|
|
340
|
-
status
|
|
341
|
-
...(
|
|
342
|
-
|
|
343
|
-
...(
|
|
344
|
-
&& /^[A-Fa-f0-9]{64}$/.test(String(human.cid ?? human.id))
|
|
345
|
-
? { cid: String(human.cid ?? human.id) } : {}),
|
|
346
|
-
...(human.name ? { name: this.safeMetadata(human.name) } : {}),
|
|
350
|
+
name: this.safeMetadata(nameValue ?? cid),
|
|
351
|
+
status,
|
|
352
|
+
...(root ? { human: {
|
|
353
|
+
...(/^[A-Fa-f0-9]{64}$/.test(rootCid) ? { cid: rootCid } : {}),
|
|
354
|
+
...(root.root_name ? { name: this.safeMetadata(root.root_name) } : {}),
|
|
347
355
|
} } : {}),
|
|
348
356
|
};
|
|
349
357
|
}
|
|
@@ -528,16 +536,16 @@ export class OwnerChannel {
|
|
|
528
536
|
// A finite cap protects the supervisor if a broken daemon repeats unread
|
|
529
537
|
// messages forever. A watch notification will resume draining later.
|
|
530
538
|
for (let pass = 0; pass < 100 && !this.stopping; pass++) {
|
|
531
|
-
const [
|
|
532
|
-
this.client.
|
|
533
|
-
this.client.
|
|
539
|
+
const [payload, fileResult] = await Promise.all([
|
|
540
|
+
this.client.getMessages(),
|
|
541
|
+
this.client.listIncomingFiles()
|
|
534
542
|
.catch(error => {
|
|
535
543
|
this.logError('attachment metadata inspection unavailable', error);
|
|
536
544
|
return undefined;
|
|
537
545
|
}),
|
|
538
546
|
]);
|
|
539
|
-
const messages = Array.isArray(
|
|
540
|
-
?
|
|
547
|
+
const messages = Array.isArray(payload?.messages)
|
|
548
|
+
? payload.messages.filter(message => message && typeof message === 'object')
|
|
541
549
|
: [];
|
|
542
550
|
let files = [];
|
|
543
551
|
try {
|
|
@@ -561,7 +569,7 @@ export class OwnerChannel {
|
|
|
561
569
|
&& Number.isInteger(message.msg_id);
|
|
562
570
|
}).map(message => message.msg_id);
|
|
563
571
|
if (deferred.length)
|
|
564
|
-
await this.client.
|
|
572
|
+
await this.client.deferMessages(deferred);
|
|
565
573
|
let advanced = false;
|
|
566
574
|
const consumedMessages = new Set();
|
|
567
575
|
const groups = this.attachmentGroups(files, messages, pending, consumedMessages);
|
|
@@ -692,15 +700,12 @@ export class OwnerChannel {
|
|
|
692
700
|
const unread = group.files.filter(file => file.status === 'unread');
|
|
693
701
|
const processed = group.files.filter(file => file.status !== 'unread');
|
|
694
702
|
const retrieved = unread.length
|
|
695
|
-
? parseRetrievedAttachments(await this.client.
|
|
696
|
-
wire_ids: unread.map(file => file.wireId),
|
|
697
|
-
}), unread)
|
|
703
|
+
? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
|
|
698
704
|
: [];
|
|
699
705
|
for (const file of processed) {
|
|
700
706
|
if (!group.recovery)
|
|
701
707
|
throw new Error('unexpected processed attachment without recovery route');
|
|
702
|
-
const recoveryPath =
|
|
703
|
-
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
708
|
+
const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
|
|
704
709
|
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
705
710
|
}
|
|
706
711
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
@@ -797,12 +802,12 @@ export class OwnerChannel {
|
|
|
797
802
|
// the authenticated agent once so the wait is never silent.
|
|
798
803
|
this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
|
|
799
804
|
+ `route yet; message stays queued: ${this.errorText(error)}`);
|
|
800
|
-
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayQueued());
|
|
805
|
+
await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayQueued());
|
|
801
806
|
return false;
|
|
802
807
|
}
|
|
803
808
|
else {
|
|
804
809
|
this.logError('managed-agent message relay refused', error);
|
|
805
|
-
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayRefused(this.errorText(error)));
|
|
810
|
+
await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayRefused(this.errorText(error)));
|
|
806
811
|
}
|
|
807
812
|
}
|
|
808
813
|
this.state.remember(wireId);
|
|
@@ -1047,7 +1052,9 @@ export class OwnerChannel {
|
|
|
1047
1052
|
async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
|
|
1048
1053
|
const captionWire = group.caption ? this.wireId(group.caption) : undefined;
|
|
1049
1054
|
const nackWire = captionWire ?? group.files[0].wireId;
|
|
1050
|
-
|
|
1055
|
+
// A caption whose own wire id is synthetic (msg_id only) must not be echoed
|
|
1056
|
+
// back as a reply reference; a file wire always is a real one.
|
|
1057
|
+
const nackReplyTo = (group.caption ? group.caption.wire_id : nackWire) ? nackWire : undefined;
|
|
1051
1058
|
let requestDir;
|
|
1052
1059
|
let recovery;
|
|
1053
1060
|
try {
|
|
@@ -1083,15 +1090,12 @@ export class OwnerChannel {
|
|
|
1083
1090
|
const unread = group.files.filter(file => file.status === 'unread');
|
|
1084
1091
|
const processed = group.files.filter(file => file.status !== 'unread');
|
|
1085
1092
|
const retrieved = unread.length
|
|
1086
|
-
? parseRetrievedAttachments(await this.client.
|
|
1087
|
-
wire_ids: unread.map(file => file.wireId),
|
|
1088
|
-
}), unread)
|
|
1093
|
+
? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
|
|
1089
1094
|
: [];
|
|
1090
1095
|
for (const file of processed) {
|
|
1091
1096
|
if (!group.recovery)
|
|
1092
1097
|
throw new Error('unexpected processed attachment without recovery route');
|
|
1093
|
-
const recoveryPath =
|
|
1094
|
-
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
1098
|
+
const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
|
|
1095
1099
|
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
1096
1100
|
}
|
|
1097
1101
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
@@ -1103,9 +1107,9 @@ export class OwnerChannel {
|
|
|
1103
1107
|
if (caption)
|
|
1104
1108
|
await this.send(contact, caption, replyTo);
|
|
1105
1109
|
for (const file of admitted) {
|
|
1106
|
-
await this.client.
|
|
1110
|
+
await this.client.sendFile({
|
|
1107
1111
|
contact, path: file.path, filename: file.filename,
|
|
1108
|
-
...(replyTo ? {
|
|
1112
|
+
...(replyTo ? { replyToWireId: replyTo } : {}),
|
|
1109
1113
|
});
|
|
1110
1114
|
}
|
|
1111
1115
|
}
|
|
@@ -1142,11 +1146,11 @@ export class OwnerChannel {
|
|
|
1142
1146
|
if (error instanceof RelayUnroutableError) {
|
|
1143
1147
|
this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
|
|
1144
1148
|
+ `transaction stays queued: ${this.errorText(error)}`);
|
|
1145
|
-
await this.nackManagedAgent(agent,
|
|
1149
|
+
await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayQueued());
|
|
1146
1150
|
return false;
|
|
1147
1151
|
}
|
|
1148
1152
|
this.logError('managed-agent caption/file relay refused', error);
|
|
1149
|
-
await this.nackManagedAgent(agent,
|
|
1153
|
+
await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayRefused(this.errorText(error)));
|
|
1150
1154
|
// Rejection/admission failure and uncertain transport are terminal and
|
|
1151
1155
|
// visible. Consuming every correlated wire prevents a later partial replay.
|
|
1152
1156
|
for (const wire of handledWireIds)
|
|
@@ -1185,14 +1189,14 @@ export class OwnerChannel {
|
|
|
1185
1189
|
* to the authenticated agent, while its deferred replays stay quiet. NACK
|
|
1186
1190
|
* delivery is best-effort — it must never make the failure worse.
|
|
1187
1191
|
*/
|
|
1188
|
-
async nackManagedAgent(contact,
|
|
1192
|
+
async nackManagedAgent(contact, replyTo, wireId, notice) {
|
|
1189
1193
|
if (this.relayNacks.has(wireId))
|
|
1190
1194
|
return;
|
|
1191
1195
|
this.relayNacks.add(wireId);
|
|
1192
1196
|
if (this.relayNacks.size > RELAY_NACK_MEMORY)
|
|
1193
1197
|
this.relayNacks.delete(this.relayNacks.values().next().value);
|
|
1194
1198
|
try {
|
|
1195
|
-
await this.send(contact, notice,
|
|
1199
|
+
await this.send(contact, notice, replyTo);
|
|
1196
1200
|
}
|
|
1197
1201
|
catch (error) {
|
|
1198
1202
|
this.logError('managed-agent relay NACK delivery failed', error);
|
|
@@ -1510,8 +1514,8 @@ export class OwnerChannel {
|
|
|
1510
1514
|
return createHash('sha256').update(wireId).digest('hex');
|
|
1511
1515
|
}
|
|
1512
1516
|
send(contact, text, replyTo) {
|
|
1513
|
-
return this.client.
|
|
1514
|
-
contact, text, ...(replyTo ? {
|
|
1517
|
+
return this.client.sendMessage({
|
|
1518
|
+
contact, text, ...(replyTo ? { replyToWireId: replyTo } : {}),
|
|
1515
1519
|
});
|
|
1516
1520
|
}
|
|
1517
1521
|
async sendAttachments(contact, outbox, replyTo) {
|
|
@@ -1519,11 +1523,11 @@ export class OwnerChannel {
|
|
|
1519
1523
|
.filter(entry => entry.isFile())
|
|
1520
1524
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1521
1525
|
for (const entry of entries) {
|
|
1522
|
-
await this.client.
|
|
1526
|
+
await this.client.sendFile({
|
|
1523
1527
|
contact,
|
|
1524
1528
|
path: join(outbox, entry.name),
|
|
1525
1529
|
filename: entry.name,
|
|
1526
|
-
|
|
1530
|
+
replyToWireId: replyTo,
|
|
1527
1531
|
});
|
|
1528
1532
|
}
|
|
1529
1533
|
await rm(outbox, { recursive: true, force: true });
|
|
@@ -1546,11 +1550,11 @@ export class OwnerChannel {
|
|
|
1546
1550
|
return Number.isInteger(message.msg_id) ? `msg:${message.msg_id}` : '';
|
|
1547
1551
|
}
|
|
1548
1552
|
sender(message) {
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
const id = String(source?.id ??
|
|
1553
|
-
return { id, name: String(source?.name ??
|
|
1553
|
+
// Authenticated routing data, straight from the daemon's typed envelope.
|
|
1554
|
+
// The id still goes through the CID checks in acceptedSender/isEffectiveOwner.
|
|
1555
|
+
const source = message.from;
|
|
1556
|
+
const id = String(source?.id ?? '');
|
|
1557
|
+
return { id, name: String(source?.name ?? id) };
|
|
1554
1558
|
}
|
|
1555
1559
|
latestEventSeq(events) {
|
|
1556
1560
|
return events.reduce((latest, event) => Math.max(latest, event.seq), 0);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { OursClient, type OursClientOptions } from '@ours.network/sdk/client';
|
|
2
|
+
/** Any failure of a daemon operation. Never carries a message body or a token. */
|
|
3
|
+
export declare class OursDaemonError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The daemon accepted the call and answered "not sent". The MCP surface reported
|
|
7
|
+
* exactly these verdicts as tool errors, so they must keep throwing here: the
|
|
8
|
+
* owner channel books a resolved send as delivered, and a silently-swallowed
|
|
9
|
+
* refusal would be recorded as a delivered message that never left the host.
|
|
10
|
+
*/
|
|
11
|
+
export declare class OursSendRefusedError extends OursDaemonError {
|
|
12
|
+
}
|
|
13
|
+
type Res<M extends keyof OursClient> = OursClient[M] extends (...args: never[]) => infer R ? Awaited<R> : never;
|
|
14
|
+
export type OursContactsView = Res<'listContacts'>;
|
|
15
|
+
export type OursInviteResult = Res<'generateInvite'>;
|
|
16
|
+
export type OursAddContactResult = Res<'addContact'>;
|
|
17
|
+
export type OursMessagesPayload = Res<'getMessages'>;
|
|
18
|
+
export type OursInboundMessage = OursMessagesPayload['messages'][number];
|
|
19
|
+
export type OursIncomingFile = Res<'listIncomingFiles'>[number];
|
|
20
|
+
export type OursRetrievedFiles = Res<'getFiles'>;
|
|
21
|
+
export type OursRetrievedFile = OursRetrievedFiles['files'][number];
|
|
22
|
+
/**
|
|
23
|
+
* The daemon operations the owner channel needs, one typed method each.
|
|
24
|
+
*
|
|
25
|
+
* This interface deliberately has no generic `callTool(name, args): unknown`
|
|
26
|
+
* escape hatch. The MCP surface had one, and because ours-mcp answers every
|
|
27
|
+
* tool with `{content:[{type:'text',...}]}` and no `structuredContent`, the
|
|
28
|
+
* transport fell back to returning the daemon's English sentence — which the
|
|
29
|
+
* channel then pattern-matched (an invite blob sliced out of a prose sentence,
|
|
30
|
+
* a bind conflict detected with /currently bound to another live session/i).
|
|
31
|
+
* With no untyped result there is nothing left to pattern-match.
|
|
32
|
+
*/
|
|
33
|
+
export interface OursOps {
|
|
34
|
+
/** Prepare the transport. Must be called before any operation. */
|
|
35
|
+
start(): Promise<void>;
|
|
36
|
+
/** Bind this session's identity. Throws `BOUND_ELSEWHERE` when it is held live. */
|
|
37
|
+
bindIdentity(name: string): Promise<void>;
|
|
38
|
+
listContacts(): Promise<OursContactsView>;
|
|
39
|
+
generateInvite(name?: string): Promise<OursInviteResult>;
|
|
40
|
+
addContact(a: {
|
|
41
|
+
invite: string;
|
|
42
|
+
name?: string;
|
|
43
|
+
}): Promise<OursAddContactResult>;
|
|
44
|
+
getMessages(): Promise<OursMessagesPayload>;
|
|
45
|
+
deferMessages(msgIds: number[]): Promise<void>;
|
|
46
|
+
listIncomingFiles(): Promise<OursIncomingFile[]>;
|
|
47
|
+
getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
|
|
48
|
+
/**
|
|
49
|
+
* The bytes of an already-retrieved file. Transport only — the caller owns
|
|
50
|
+
* where they land, so path safety stays with the attachment code that already
|
|
51
|
+
* enforces it (`writeRecoveredAttachment`).
|
|
52
|
+
*/
|
|
53
|
+
fetchFile(wireId: string): Promise<Uint8Array>;
|
|
54
|
+
sendMessage(a: {
|
|
55
|
+
contact: string;
|
|
56
|
+
text: string;
|
|
57
|
+
replyToWireId?: string;
|
|
58
|
+
}): Promise<void>;
|
|
59
|
+
sendFile(a: {
|
|
60
|
+
contact: string;
|
|
61
|
+
path: string;
|
|
62
|
+
filename: string;
|
|
63
|
+
replyToWireId?: string;
|
|
64
|
+
}): Promise<void>;
|
|
65
|
+
/** Release the daemon lease and stop. Never throws. */
|
|
66
|
+
close(): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The typed error code of a daemon operation, or undefined when the failure was
|
|
70
|
+
* not one (transport, abort, programming error). `instanceof` is checked first;
|
|
71
|
+
* the structural fallback keeps a duplicated SDK copy in a consumer's tree from
|
|
72
|
+
* silently demoting a real daemon verdict to "unknown transport failure".
|
|
73
|
+
*/
|
|
74
|
+
export declare function oursErrorCode(error: unknown): string | undefined;
|
|
75
|
+
/** The identity is bound by another live session; a predecessor may still be releasing it. */
|
|
76
|
+
export declare const OURS_BOUND_ELSEWHERE = "BOUND_ELSEWHERE";
|
|
77
|
+
export interface OursSdkClientDeps {
|
|
78
|
+
/** Test seam; production builds an `OursClient` from the resolved endpoint. */
|
|
79
|
+
createClient?(options: OursClientOptions): OursClient;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The owner-channel's daemon client: one `OursClient` over the local ours HTTP
|
|
83
|
+
* API, owning exactly one identity binding.
|
|
84
|
+
*
|
|
85
|
+
* Lease lifetime. The lease token IS the session, so each channel instance mints
|
|
86
|
+
* its own and hands it back in `close()`. That replaces the `ours-mcp proxy`
|
|
87
|
+
* shell-PID fence, which existed because a supervised attempt had to make its
|
|
88
|
+
* lease reclaimable while the supervisor itself stayed alive: an explicit
|
|
89
|
+
* release does that deterministically, and `clientPid` still covers the case
|
|
90
|
+
* where the whole supervisor dies without unwinding.
|
|
91
|
+
*/
|
|
92
|
+
export declare class OursSdkClient implements OursOps {
|
|
93
|
+
private readonly env;
|
|
94
|
+
private readonly log;
|
|
95
|
+
private readonly deps;
|
|
96
|
+
private client?;
|
|
97
|
+
private readonly leaseToken;
|
|
98
|
+
constructor(env?: Record<string, string>, log?: (line: string) => void, deps?: OursSdkClientDeps);
|
|
99
|
+
start(): Promise<void>;
|
|
100
|
+
bindIdentity(name: string): Promise<void>;
|
|
101
|
+
listContacts(): Promise<OursContactsView>;
|
|
102
|
+
generateInvite(name?: string): Promise<OursInviteResult>;
|
|
103
|
+
addContact(a: {
|
|
104
|
+
invite: string;
|
|
105
|
+
name?: string;
|
|
106
|
+
}): Promise<OursAddContactResult>;
|
|
107
|
+
getMessages(): Promise<OursMessagesPayload>;
|
|
108
|
+
deferMessages(msgIds: number[]): Promise<void>;
|
|
109
|
+
listIncomingFiles(): Promise<OursIncomingFile[]>;
|
|
110
|
+
getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
|
|
111
|
+
fetchFile(wireId: string): Promise<Uint8Array>;
|
|
112
|
+
sendMessage(a: {
|
|
113
|
+
contact: string;
|
|
114
|
+
text: string;
|
|
115
|
+
replyToWireId?: string;
|
|
116
|
+
}): Promise<void>;
|
|
117
|
+
sendFile(a: {
|
|
118
|
+
contact: string;
|
|
119
|
+
path: string;
|
|
120
|
+
filename: string;
|
|
121
|
+
replyToWireId?: string;
|
|
122
|
+
}): Promise<void>;
|
|
123
|
+
close(): Promise<void>;
|
|
124
|
+
private ops;
|
|
125
|
+
}
|
|
126
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { OursClient, OursError } from '@ours.network/sdk/client';
|
|
3
|
+
import { resolveApiToken, resolveEndpoint } from '../monitor.js';
|
|
4
|
+
/** Any failure of a daemon operation. Never carries a message body or a token. */
|
|
5
|
+
export class OursDaemonError extends Error {
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The daemon accepted the call and answered "not sent". The MCP surface reported
|
|
9
|
+
* exactly these verdicts as tool errors, so they must keep throwing here: the
|
|
10
|
+
* owner channel books a resolved send as delivered, and a silently-swallowed
|
|
11
|
+
* refusal would be recorded as a delivered message that never left the host.
|
|
12
|
+
*/
|
|
13
|
+
export class OursSendRefusedError extends OursDaemonError {
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The typed error code of a daemon operation, or undefined when the failure was
|
|
17
|
+
* not one (transport, abort, programming error). `instanceof` is checked first;
|
|
18
|
+
* the structural fallback keeps a duplicated SDK copy in a consumer's tree from
|
|
19
|
+
* silently demoting a real daemon verdict to "unknown transport failure".
|
|
20
|
+
*/
|
|
21
|
+
export function oursErrorCode(error) {
|
|
22
|
+
if (error instanceof OursError)
|
|
23
|
+
return error.code;
|
|
24
|
+
if (error instanceof Error && error.name === 'OursError') {
|
|
25
|
+
const code = error.code;
|
|
26
|
+
if (typeof code === 'string')
|
|
27
|
+
return code;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** The identity is bound by another live session; a predecessor may still be releasing it. */
|
|
32
|
+
export const OURS_BOUND_ELSEWHERE = 'BOUND_ELSEWHERE';
|
|
33
|
+
/**
|
|
34
|
+
* The owner-channel's daemon client: one `OursClient` over the local ours HTTP
|
|
35
|
+
* API, owning exactly one identity binding.
|
|
36
|
+
*
|
|
37
|
+
* Lease lifetime. The lease token IS the session, so each channel instance mints
|
|
38
|
+
* its own and hands it back in `close()`. That replaces the `ours-mcp proxy`
|
|
39
|
+
* shell-PID fence, which existed because a supervised attempt had to make its
|
|
40
|
+
* lease reclaimable while the supervisor itself stayed alive: an explicit
|
|
41
|
+
* release does that deterministically, and `clientPid` still covers the case
|
|
42
|
+
* where the whole supervisor dies without unwinding.
|
|
43
|
+
*/
|
|
44
|
+
export class OursSdkClient {
|
|
45
|
+
env;
|
|
46
|
+
log;
|
|
47
|
+
deps;
|
|
48
|
+
client;
|
|
49
|
+
leaseToken = `ours-fleet-owner-${process.pid}-${randomUUID()}`;
|
|
50
|
+
constructor(env = {}, log = () => undefined, deps = {}) {
|
|
51
|
+
this.env = env;
|
|
52
|
+
this.log = log;
|
|
53
|
+
this.deps = deps;
|
|
54
|
+
}
|
|
55
|
+
async start() {
|
|
56
|
+
if (this.client)
|
|
57
|
+
return;
|
|
58
|
+
const environment = { ...process.env, ...this.env };
|
|
59
|
+
// Reuse fleet's own daemon resolution so this client and the notification
|
|
60
|
+
// watch loop can never disagree about which daemon they are talking to.
|
|
61
|
+
const endpoint = resolveEndpoint(environment);
|
|
62
|
+
const apiToken = resolveApiToken(environment);
|
|
63
|
+
const options = {
|
|
64
|
+
url: endpoint.origin,
|
|
65
|
+
leaseToken: this.leaseToken,
|
|
66
|
+
clientPid: process.pid,
|
|
67
|
+
...(apiToken ? { apiToken } : {}),
|
|
68
|
+
};
|
|
69
|
+
this.client = this.deps.createClient?.(options) ?? new OursClient(options);
|
|
70
|
+
}
|
|
71
|
+
async bindIdentity(name) {
|
|
72
|
+
// force is pinned off: the owner channel never evicts another live session
|
|
73
|
+
// from an identity, it waits for the bounded handoff window and then fails.
|
|
74
|
+
await this.ops().chooseIdentity({ name, force: false });
|
|
75
|
+
}
|
|
76
|
+
async listContacts() {
|
|
77
|
+
return this.ops().listContacts();
|
|
78
|
+
}
|
|
79
|
+
async generateInvite(name) {
|
|
80
|
+
return this.ops().generateInvite(name ? { name } : {});
|
|
81
|
+
}
|
|
82
|
+
async addContact(a) {
|
|
83
|
+
return this.ops().addContact({ invite: a.invite, ...(a.name ? { name: a.name } : {}) });
|
|
84
|
+
}
|
|
85
|
+
async getMessages() {
|
|
86
|
+
return this.ops().getMessages();
|
|
87
|
+
}
|
|
88
|
+
async deferMessages(msgIds) {
|
|
89
|
+
await this.ops().deferMessages({ msg_ids: msgIds });
|
|
90
|
+
}
|
|
91
|
+
async listIncomingFiles() {
|
|
92
|
+
return this.ops().listIncomingFiles();
|
|
93
|
+
}
|
|
94
|
+
async getFiles(wireIds) {
|
|
95
|
+
return this.ops().getFiles({ wire_ids: wireIds });
|
|
96
|
+
}
|
|
97
|
+
async fetchFile(wireId) {
|
|
98
|
+
// save_file has no SDK operation on purpose: that daemon route only reports
|
|
99
|
+
// that a too-old connector reached it. The bytes of a retrieved file are
|
|
100
|
+
// already on disk, so read them back and let the caller write them as this
|
|
101
|
+
// process's own OS user.
|
|
102
|
+
return this.ops().fetchFile(wireId);
|
|
103
|
+
}
|
|
104
|
+
async sendMessage(a) {
|
|
105
|
+
const verdict = await this.ops().sendMessage({
|
|
106
|
+
contact: a.contact, text: a.text,
|
|
107
|
+
...(a.replyToWireId ? { reply_to_wire_id: a.replyToWireId } : {}),
|
|
108
|
+
});
|
|
109
|
+
// Parity with ours-mcp 0.16.0: only `refused` was a tool error. `migrating`,
|
|
110
|
+
// `deferred` and `e2e` are accepted-and-queued outcomes that it reported as
|
|
111
|
+
// success, so they must not become failures here.
|
|
112
|
+
if (verdict.kind === 'refused')
|
|
113
|
+
throw new OursSendRefusedError('the daemon refused the message: the contact\'s end-to-end session must be '
|
|
114
|
+
+ 're-established after an upgrade; it was not sent and not downgraded');
|
|
115
|
+
}
|
|
116
|
+
async sendFile(a) {
|
|
117
|
+
const verdict = await this.ops().sendFile({
|
|
118
|
+
contact: a.contact, path: a.path, filename: a.filename,
|
|
119
|
+
...(a.replyToWireId ? { reply_to_wire_id: a.replyToWireId } : {}),
|
|
120
|
+
});
|
|
121
|
+
// Parity with ours-mcp 0.16.0, which treated `migrating` as an error for
|
|
122
|
+
// files and as success for messages: files are not auto-queued behind a
|
|
123
|
+
// migration, so "queued" would be a false delivery claim.
|
|
124
|
+
if (verdict.kind === 'refused' || verdict.kind === 'migrating')
|
|
125
|
+
throw new OursSendRefusedError(`the daemon did not send the file (${verdict.kind}): the contact's end-to-end `
|
|
126
|
+
+ 'session must be re-established after an upgrade; files are not queued');
|
|
127
|
+
}
|
|
128
|
+
async close() {
|
|
129
|
+
const client = this.client;
|
|
130
|
+
this.client = undefined;
|
|
131
|
+
if (!client)
|
|
132
|
+
return;
|
|
133
|
+
// Handing the lease back is what lets a successor bind this identity without
|
|
134
|
+
// waiting for the supervisor to exit. A failure here is not fatal — the
|
|
135
|
+
// daemon still reclaims the lease when this process dies.
|
|
136
|
+
try {
|
|
137
|
+
await client.releaseLease();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
this.log(`lease release failed: ${error?.message ?? String(error)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
ops() {
|
|
144
|
+
if (!this.client)
|
|
145
|
+
throw new OursDaemonError('ours daemon client is not started');
|
|
146
|
+
return this.client;
|
|
147
|
+
}
|
|
148
|
+
}
|