@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.
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { createHash, randomUUID } from 'node:crypto';
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 { OursMcpClient } from './mcp.js';
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 OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
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.callTool('choose_identity', { name: this.options.config.identity });
126
+ await this.client.bindIdentity(this.options.config.identity);
127
127
  break;
128
128
  }
129
129
  catch (error) {
130
- const message = error?.message ?? String(error);
131
- const liveConflict = /currently bound to another live session/i.test(message);
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}${permission}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
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
- const raw = await this.client.callTool('generate_invite', request.name ? { name: request.name } : {});
224
- const invite = typeof raw === 'string' ? raw : String(raw?.invite ?? '');
225
- if (!invite)
226
- throw new Error('ours-mcp returned no invite');
227
- return { action: request.action, invite };
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 raw;
239
+ let added;
236
240
  try {
237
- raw = await this.client.callTool('add_contact', {
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-mcp could not accept the contact invite');
248
+ throw new Error('the ours daemon could not accept the contact invite');
245
249
  }
246
- return { action: request.action, status: 'pending', contact: this.contact(raw) };
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 raw = await this.client.callTool('list_contacts');
322
- const values = Array.isArray(raw) ? raw : raw?.contacts;
323
- if (!Array.isArray(values))
324
- return [];
325
- return values.map(value => this.contact(value)).filter((v) => Boolean(v))
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(raw) {
329
- if (!raw || typeof raw !== 'object')
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 humanRaw = value.human ?? value.root;
336
- const human = humanRaw && typeof humanRaw === 'object' ? humanRaw : undefined;
346
+ const root = view?.roots?.[cid];
347
+ const rootCid = String(root?.root_cid ?? '');
337
348
  return {
338
349
  cid,
339
- name: this.safeMetadata(value.name ?? value.display_name ?? cid),
340
- status: this.safeMetadata(value.status ?? 'established'),
341
- ...(typeof value.kind === 'string' ? { kind: this.safeMetadata(value.kind) } : {}),
342
- ...(human ? { human: {
343
- ...(typeof (human.cid ?? human.id) === 'string'
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 [raw, fileResult] = await Promise.all([
532
- this.client.callTool('get_messages'),
533
- this.client.callTool('list_incoming_files')
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(raw?.messages)
540
- ? raw.messages.filter(message => message && typeof message === 'object')
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.callTool('defer_messages', { msg_ids: deferred });
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.callTool('get_files', {
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 = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
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
- const nackMessage = group.caption ?? { wire_id: nackWire };
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.callTool('get_files', {
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 = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
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.callTool('send_file', {
1110
+ await this.client.sendFile({
1107
1111
  contact, path: file.path, filename: file.filename,
1108
- ...(replyTo ? { reply_to_wire_id: 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, nackMessage, nackWire, ownerNotices.relayQueued());
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, nackMessage, nackWire, ownerNotices.relayRefused(this.errorText(error)));
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, message, wireId, notice) {
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, message.wire_id ? wireId : undefined);
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.callTool('send_message', {
1514
- contact, text, ...(replyTo ? { reply_to_wire_id: 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.callTool('send_file', {
1526
+ await this.client.sendFile({
1523
1527
  contact,
1524
1528
  path: join(outbox, entry.name),
1525
1529
  filename: entry.name,
1526
- reply_to_wire_id: replyTo,
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
- const source = message.from ?? message.sender;
1550
- if (typeof source === 'string')
1551
- return { id: source, name: source };
1552
- const id = String(source?.id ?? message.sender_id ?? '');
1553
- return { id, name: String(source?.name ?? message.sender_name ?? id) };
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
+ }