@ours.network/fleet 0.17.3 → 0.18.0-nightly.1

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,9 +1,9 @@
1
1
  {
2
- "version": "0.17.3",
3
- "buildId": "789acfed6bbd",
4
- "commit": "47f1efd0f90321cf233edc694a9af5fb5ba05647",
5
- "dirty": false,
6
- "builtAt": "2026-08-14T20:15:30.780Z",
2
+ "version": "0.18.0-nightly.1",
3
+ "buildId": "880ef60a977f",
4
+ "commit": "ece91028c05a5669507c0cdcff3a6f365902fcab",
5
+ "dirty": true,
6
+ "builtAt": "2026-08-16T13:06:15.416Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
@@ -1,4 +1,6 @@
1
+ import { type FileHandle } from 'node:fs/promises';
1
2
  import type { OwnerAttachmentConfig } from '../config.js';
3
+ import type { OursIncomingFile, OursRetrievedFiles } from './ours-client.js';
2
4
  export interface AttachmentReplyRef {
3
5
  wire_id: string;
4
6
  sentence?: number;
@@ -42,8 +44,14 @@ export interface AdmittedAttachment {
42
44
  kind: 'file' | 'voice_message';
43
45
  transcription?: Omit<VoiceTranscription, 'audioPath'>;
44
46
  }
45
- export declare function parseIncomingAttachments(raw: unknown): IncomingAttachment[];
46
- export declare function parseRetrievedAttachments(raw: unknown, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
47
+ /**
48
+ * Admit the daemon's file listing. The rows are typed now, but every field is
49
+ * still re-validated here: sender CID, wire id, sizes and ids all cross the
50
+ * trust boundary and decide routing, so a daemon-side shape change must drop a
51
+ * row rather than produce a half-built attachment.
52
+ */
53
+ export declare function parseIncomingAttachments(raw: OursIncomingFile[] | undefined): IncomingAttachment[];
54
+ export declare function parseRetrievedAttachments(raw: OursRetrievedFiles | undefined, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
47
55
  export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
48
56
  /**
49
57
  * Managed-agent -> owner egress limits. This intentionally does not consult
@@ -54,6 +62,21 @@ export declare function prepareAttachmentDirectory(root: string, requestId: stri
54
62
  export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
55
63
  mimePolicy?: 'strict' | 'report-only';
56
64
  }): Promise<AdmittedAttachment[]>;
65
+ /** Injectable short-write seam, so partial writes are provably handled. */
66
+ export interface AttachmentWriteDeps {
67
+ write?(handle: FileHandle, bytes: Uint8Array, offset: number): Promise<number>;
68
+ }
69
+ /**
70
+ * Land crash-recovered file bytes inside an already-prepared request directory.
71
+ *
72
+ * The MCP path handed the daemon a `dest_path` and let its connector write the
73
+ * file. Nothing writes on our behalf any more, so this owns both halves of that
74
+ * contract: the destination is DERIVED from a validated wire id inside `dir`
75
+ * rather than accepted from a caller, and the file is published by link-after-
76
+ * fsync, so a crash or a short write can never leave a partial file where the
77
+ * admission step would read it as complete.
78
+ */
79
+ export declare function writeRecoveredAttachment(dir: string, wireId: string, bytes: Uint8Array, deps?: AttachmentWriteDeps): Promise<string>;
57
80
  export declare function recoveredAttachment(file: IncomingAttachment, path: string): Promise<RetrievedAttachment>;
58
81
  export declare function removeRequestDirectory(path: string): Promise<void>;
59
82
  export declare function cleanupAttachmentRoot(root: string, now: number, retentionMs: number, limit?: number): Promise<number>;
@@ -6,8 +6,14 @@ import { replaceFileAtomically } from '../atomic-file.js';
6
6
  const WIRE = /^[A-Fa-f0-9]{64}$/;
7
7
  const CID = /^[A-Fa-f0-9]{64}$/;
8
8
  const MAX_PENDING_REQUESTS = 32;
9
+ /**
10
+ * Admit the daemon's file listing. The rows are typed now, but every field is
11
+ * still re-validated here: sender CID, wire id, sizes and ids all cross the
12
+ * trust boundary and decide routing, so a daemon-side shape change must drop a
13
+ * row rather than produce a half-built attachment.
14
+ */
9
15
  export function parseIncomingAttachments(raw) {
10
- const values = raw?.files;
16
+ const values = raw;
11
17
  if (!Array.isArray(values))
12
18
  return [];
13
19
  const out = [];
@@ -40,12 +46,12 @@ export function parseIncomingAttachments(raw) {
40
46
  export function parseRetrievedAttachments(raw, expected, recovered = false) {
41
47
  const values = raw?.files;
42
48
  if (!Array.isArray(values) || values.length !== expected.length)
43
- throw new Error('ours-mcp returned an incomplete selected attachment set');
49
+ throw new Error('the ours daemon returned an incomplete selected attachment set');
44
50
  const byWire = new Map(expected.map(file => [file.wireId, file]));
45
51
  const out = [];
46
52
  for (const value of values) {
47
53
  if (!value || typeof value !== 'object')
48
- throw new Error('ours-mcp returned invalid attachment metadata');
54
+ throw new Error('the ours daemon returned invalid attachment metadata');
49
55
  const file = value;
50
56
  const wireId = String(file.wire_id ?? '');
51
57
  const listed = byWire.get(wireId);
@@ -58,7 +64,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
58
64
  || !Number.isSafeInteger(size) || size !== listed.size || mime !== listed.mime
59
65
  || kind !== listed.kind || !/^[a-f0-9]{64}$/.test(sha256)
60
66
  || typeof file.path !== 'string' || !file.path)
61
- throw new Error('ours-mcp selected attachment provenance or integrity metadata mismatched');
67
+ throw new Error('selected attachment provenance or integrity metadata mismatched');
62
68
  out.push({
63
69
  ...listed, filename: safeField(file.filename, 255), mime, size, path: file.path, sha256, kind,
64
70
  ...(recovered ? {} : parseTranscription(file.transcription, wireId)),
@@ -66,7 +72,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
66
72
  byWire.delete(wireId);
67
73
  }
68
74
  if (byWire.size)
69
- throw new Error('ours-mcp omitted a selected attachment');
75
+ throw new Error('the ours daemon omitted a selected attachment');
70
76
  return out;
71
77
  }
72
78
  function parseTranscription(value, wireId) {
@@ -219,6 +225,56 @@ export async function admitAttachments(files, dir, config, options = {}) {
219
225
  }
220
226
  return admitted;
221
227
  }
228
+ /**
229
+ * Land crash-recovered file bytes inside an already-prepared request directory.
230
+ *
231
+ * The MCP path handed the daemon a `dest_path` and let its connector write the
232
+ * file. Nothing writes on our behalf any more, so this owns both halves of that
233
+ * contract: the destination is DERIVED from a validated wire id inside `dir`
234
+ * rather than accepted from a caller, and the file is published by link-after-
235
+ * fsync, so a crash or a short write can never leave a partial file where the
236
+ * admission step would read it as complete.
237
+ */
238
+ export async function writeRecoveredAttachment(dir, wireId, bytes, deps = {}) {
239
+ if (!WIRE.test(wireId))
240
+ throw new Error('recovered attachment wire id is not a 64-hex value');
241
+ const dirStat = await lstat(dir);
242
+ if (!dirStat.isDirectory() || dirStat.isSymbolicLink())
243
+ throw new Error('recovered attachment directory is not a safe directory');
244
+ const write = deps.write
245
+ ?? ((handle, buffer, offset) => handle.write(buffer, offset, buffer.length - offset)
246
+ .then(result => result.bytesWritten));
247
+ const finalPath = join(dir, `.recovered-${wireId}-${randomUUID()}`);
248
+ const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
249
+ const handle = await open(tmp, 'wx', 0o600);
250
+ try {
251
+ for (let written = 0; written < bytes.length;) {
252
+ const advanced = await write(handle, bytes, written);
253
+ if (advanced <= 0)
254
+ throw new Error(`recovered attachment write made no progress at byte ${written}`);
255
+ written += advanced;
256
+ }
257
+ await handle.sync();
258
+ }
259
+ catch (error) {
260
+ await handle.close().catch(() => undefined);
261
+ await rm(tmp, { force: true });
262
+ throw error;
263
+ }
264
+ await handle.close();
265
+ // link publishes the finished bytes under a name that never existed in a
266
+ // partial state; the temp is only ever removed after it succeeded.
267
+ try {
268
+ await link(tmp, finalPath);
269
+ }
270
+ catch (error) {
271
+ await rm(tmp, { force: true });
272
+ throw error;
273
+ }
274
+ await rm(tmp, { force: true });
275
+ await chmod(finalPath, 0o600);
276
+ return finalPath;
277
+ }
222
278
  export async function recoveredAttachment(file, path) {
223
279
  const stat = await lstat(path);
224
280
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== file.size)
@@ -4,7 +4,7 @@ import { type FetchLike } from '../monitor.js';
4
4
  import { type SessionHandle } from '../session/types.js';
5
5
  import { type OwnerFleetOps } from './commands.js';
6
6
  import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
7
- import { type OursToolClient } from './mcp.js';
7
+ import { type OursOps } from './ours-client.js';
8
8
  import { type OwnerUpdatePhase } from './notices.js';
9
9
  import { type OwnerEntry } from './state.js';
10
10
  import { type OwnerTaskPhase } from './tasks.js';
@@ -17,9 +17,13 @@ export interface OwnerChannelOptions {
17
17
  session: SessionHandle;
18
18
  stateDir: string;
19
19
  env?: Record<string, string>;
20
+ /**
21
+ * `ours-mcp` binary for the legacy `watch` child process only. Daemon
22
+ * operations no longer go through it; they use the ours SDK client.
23
+ */
20
24
  command?: string;
21
25
  log(line: string): void;
22
- client?: OursToolClient;
26
+ client?: OursOps;
23
27
  /** Legacy child-process test seam; production uses the direct notification API. */
24
28
  watch?: (identity: string) => ChildProcessWithoutNullStreams;
25
29
  /** Test seam for the production direct notification long-poll. */
@@ -118,7 +122,13 @@ export type { OwnerUpdatePhase } from './notices.js';
118
122
  export interface OwnerContact {
119
123
  cid: string;
120
124
  name: string;
125
+ /** Structural, from which daemon collection the row came: established or pending. */
121
126
  status: string;
127
+ /**
128
+ * Retained for the `ours-fleet owner contact list` column. The daemon's typed
129
+ * contact view has no such field, so it is always absent; it is not inferred
130
+ * from anything a contact controls.
131
+ */
122
132
  kind?: string;
123
133
  human?: {
124
134
  cid?: string;
@@ -174,6 +184,12 @@ export declare class OwnerChannel implements OwnerChannelHandle {
174
184
  manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
175
185
  notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
176
186
  private manageNow;
187
+ /**
188
+ * The daemon reports established contacts and pending introductions as two
189
+ * separate collections, so the status is structural rather than a word parsed
190
+ * out of a rendered line. Nothing here can be spoofed by a contact's own
191
+ * display name.
192
+ */
177
193
  private contacts;
178
194
  private contact;
179
195
  private assertCid;
@@ -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;
@@ -217,11 +221,14 @@ export class OwnerChannel {
217
221
  return { action: request.action, contacts: await this.contacts() };
218
222
  case 'contact_invite': {
219
223
  this.assertLabel(request.name);
220
- const raw = await this.client.callTool('generate_invite', request.name ? { name: request.name } : {});
221
- const invite = typeof raw === 'string' ? raw : String(raw?.invite ?? '');
222
- if (!invite)
223
- throw new Error('ours-mcp returned no invite');
224
- 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 };
225
232
  }
226
233
  case 'contact_add': {
227
234
  if (typeof request.invite !== 'string' || !request.invite)
@@ -229,18 +236,19 @@ export class OwnerChannel {
229
236
  if (Buffer.byteLength(request.invite) > 48 * 1024)
230
237
  throw new Error('invite exceeds 49152 bytes');
231
238
  this.assertLabel(request.name);
232
- let raw;
239
+ let added;
233
240
  try {
234
- raw = await this.client.callTool('add_contact', {
241
+ added = await this.client.addContact({
235
242
  invite: request.invite, ...(request.name ? { name: request.name } : {}),
236
243
  });
237
244
  }
238
245
  catch {
239
246
  // Daemon errors are not allowed to reflect invite material through
240
247
  // the control response, CLI stderr, or supervisor logs.
241
- throw new Error('ours-mcp could not accept the contact invite');
248
+ throw new Error('the ours daemon could not accept the contact invite');
242
249
  }
243
- 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 } : {}) };
244
252
  }
245
253
  case 'owner_list':
246
254
  return {
@@ -314,33 +322,36 @@ export class OwnerChannel {
314
322
  throw new Error('unknown owner-channel management action');
315
323
  }
316
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
+ */
317
331
  async contacts() {
318
- const raw = await this.client.callTool('list_contacts');
319
- const values = Array.isArray(raw) ? raw : raw?.contacts;
320
- if (!Array.isArray(values))
321
- return [];
322
- 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))
323
340
  .sort((a, b) => a.cid.localeCompare(b.cid));
324
341
  }
325
- contact(raw) {
326
- if (!raw || typeof raw !== 'object')
327
- return undefined;
328
- const value = raw;
329
- const cid = String(value.cid ?? value.id ?? value.container_id ?? value.containerId ?? '');
342
+ contact(cidValue, nameValue, status, view) {
343
+ const cid = String(cidValue ?? '');
330
344
  if (!/^[A-Fa-f0-9]{64}$/.test(cid))
331
345
  return undefined;
332
- const humanRaw = value.human ?? value.root;
333
- const human = humanRaw && typeof humanRaw === 'object' ? humanRaw : undefined;
346
+ const root = view?.roots?.[cid];
347
+ const rootCid = String(root?.root_cid ?? '');
334
348
  return {
335
349
  cid,
336
- name: this.safeMetadata(value.name ?? value.display_name ?? cid),
337
- status: this.safeMetadata(value.status ?? 'established'),
338
- ...(typeof value.kind === 'string' ? { kind: this.safeMetadata(value.kind) } : {}),
339
- ...(human ? { human: {
340
- ...(typeof (human.cid ?? human.id) === 'string'
341
- && /^[A-Fa-f0-9]{64}$/.test(String(human.cid ?? human.id))
342
- ? { cid: String(human.cid ?? human.id) } : {}),
343
- ...(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) } : {}),
344
355
  } } : {}),
345
356
  };
346
357
  }
@@ -525,16 +536,16 @@ export class OwnerChannel {
525
536
  // A finite cap protects the supervisor if a broken daemon repeats unread
526
537
  // messages forever. A watch notification will resume draining later.
527
538
  for (let pass = 0; pass < 100 && !this.stopping; pass++) {
528
- const [raw, fileResult] = await Promise.all([
529
- this.client.callTool('get_messages'),
530
- this.client.callTool('list_incoming_files')
539
+ const [payload, fileResult] = await Promise.all([
540
+ this.client.getMessages(),
541
+ this.client.listIncomingFiles()
531
542
  .catch(error => {
532
543
  this.logError('attachment metadata inspection unavailable', error);
533
544
  return undefined;
534
545
  }),
535
546
  ]);
536
- const messages = Array.isArray(raw?.messages)
537
- ? raw.messages.filter(message => message && typeof message === 'object')
547
+ const messages = Array.isArray(payload?.messages)
548
+ ? payload.messages.filter(message => message && typeof message === 'object')
538
549
  : [];
539
550
  let files = [];
540
551
  try {
@@ -558,7 +569,7 @@ export class OwnerChannel {
558
569
  && Number.isInteger(message.msg_id);
559
570
  }).map(message => message.msg_id);
560
571
  if (deferred.length)
561
- await this.client.callTool('defer_messages', { msg_ids: deferred });
572
+ await this.client.deferMessages(deferred);
562
573
  let advanced = false;
563
574
  const consumedMessages = new Set();
564
575
  const groups = this.attachmentGroups(files, messages, pending, consumedMessages);
@@ -689,15 +700,12 @@ export class OwnerChannel {
689
700
  const unread = group.files.filter(file => file.status === 'unread');
690
701
  const processed = group.files.filter(file => file.status !== 'unread');
691
702
  const retrieved = unread.length
692
- ? parseRetrievedAttachments(await this.client.callTool('get_files', {
693
- wire_ids: unread.map(file => file.wireId),
694
- }), unread)
703
+ ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
695
704
  : [];
696
705
  for (const file of processed) {
697
706
  if (!group.recovery)
698
707
  throw new Error('unexpected processed attachment without recovery route');
699
- const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
700
- 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));
701
709
  retrieved.push(await recoveredAttachment(file, recoveryPath));
702
710
  }
703
711
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
@@ -794,12 +802,12 @@ export class OwnerChannel {
794
802
  // the authenticated agent once so the wait is never silent.
795
803
  this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
796
804
  + `route yet; message stays queued: ${this.errorText(error)}`);
797
- await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayQueued());
805
+ await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayQueued());
798
806
  return false;
799
807
  }
800
808
  else {
801
809
  this.logError('managed-agent message relay refused', error);
802
- 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)));
803
811
  }
804
812
  }
805
813
  this.state.remember(wireId);
@@ -1044,7 +1052,9 @@ export class OwnerChannel {
1044
1052
  async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
1045
1053
  const captionWire = group.caption ? this.wireId(group.caption) : undefined;
1046
1054
  const nackWire = captionWire ?? group.files[0].wireId;
1047
- 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;
1048
1058
  let requestDir;
1049
1059
  let recovery;
1050
1060
  try {
@@ -1080,15 +1090,12 @@ export class OwnerChannel {
1080
1090
  const unread = group.files.filter(file => file.status === 'unread');
1081
1091
  const processed = group.files.filter(file => file.status !== 'unread');
1082
1092
  const retrieved = unread.length
1083
- ? parseRetrievedAttachments(await this.client.callTool('get_files', {
1084
- wire_ids: unread.map(file => file.wireId),
1085
- }), unread)
1093
+ ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1086
1094
  : [];
1087
1095
  for (const file of processed) {
1088
1096
  if (!group.recovery)
1089
1097
  throw new Error('unexpected processed attachment without recovery route');
1090
- const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
1091
- 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));
1092
1099
  retrieved.push(await recoveredAttachment(file, recoveryPath));
1093
1100
  }
1094
1101
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
@@ -1100,9 +1107,9 @@ export class OwnerChannel {
1100
1107
  if (caption)
1101
1108
  await this.send(contact, caption, replyTo);
1102
1109
  for (const file of admitted) {
1103
- await this.client.callTool('send_file', {
1110
+ await this.client.sendFile({
1104
1111
  contact, path: file.path, filename: file.filename,
1105
- ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
1112
+ ...(replyTo ? { replyToWireId: replyTo } : {}),
1106
1113
  });
1107
1114
  }
1108
1115
  }
@@ -1139,11 +1146,11 @@ export class OwnerChannel {
1139
1146
  if (error instanceof RelayUnroutableError) {
1140
1147
  this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
1141
1148
  + `transaction stays queued: ${this.errorText(error)}`);
1142
- await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayQueued());
1149
+ await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayQueued());
1143
1150
  return false;
1144
1151
  }
1145
1152
  this.logError('managed-agent caption/file relay refused', error);
1146
- await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayRefused(this.errorText(error)));
1153
+ await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayRefused(this.errorText(error)));
1147
1154
  // Rejection/admission failure and uncertain transport are terminal and
1148
1155
  // visible. Consuming every correlated wire prevents a later partial replay.
1149
1156
  for (const wire of handledWireIds)
@@ -1182,14 +1189,14 @@ export class OwnerChannel {
1182
1189
  * to the authenticated agent, while its deferred replays stay quiet. NACK
1183
1190
  * delivery is best-effort — it must never make the failure worse.
1184
1191
  */
1185
- async nackManagedAgent(contact, message, wireId, notice) {
1192
+ async nackManagedAgent(contact, replyTo, wireId, notice) {
1186
1193
  if (this.relayNacks.has(wireId))
1187
1194
  return;
1188
1195
  this.relayNacks.add(wireId);
1189
1196
  if (this.relayNacks.size > RELAY_NACK_MEMORY)
1190
1197
  this.relayNacks.delete(this.relayNacks.values().next().value);
1191
1198
  try {
1192
- await this.send(contact, notice, message.wire_id ? wireId : undefined);
1199
+ await this.send(contact, notice, replyTo);
1193
1200
  }
1194
1201
  catch (error) {
1195
1202
  this.logError('managed-agent relay NACK delivery failed', error);
@@ -1507,8 +1514,8 @@ export class OwnerChannel {
1507
1514
  return createHash('sha256').update(wireId).digest('hex');
1508
1515
  }
1509
1516
  send(contact, text, replyTo) {
1510
- return this.client.callTool('send_message', {
1511
- contact, text, ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
1517
+ return this.client.sendMessage({
1518
+ contact, text, ...(replyTo ? { replyToWireId: replyTo } : {}),
1512
1519
  });
1513
1520
  }
1514
1521
  async sendAttachments(contact, outbox, replyTo) {
@@ -1516,11 +1523,11 @@ export class OwnerChannel {
1516
1523
  .filter(entry => entry.isFile())
1517
1524
  .sort((a, b) => a.name.localeCompare(b.name));
1518
1525
  for (const entry of entries) {
1519
- await this.client.callTool('send_file', {
1526
+ await this.client.sendFile({
1520
1527
  contact,
1521
1528
  path: join(outbox, entry.name),
1522
1529
  filename: entry.name,
1523
- reply_to_wire_id: replyTo,
1530
+ replyToWireId: replyTo,
1524
1531
  });
1525
1532
  }
1526
1533
  await rm(outbox, { recursive: true, force: true });
@@ -1543,11 +1550,11 @@ export class OwnerChannel {
1543
1550
  return Number.isInteger(message.msg_id) ? `msg:${message.msg_id}` : '';
1544
1551
  }
1545
1552
  sender(message) {
1546
- const source = message.from ?? message.sender;
1547
- if (typeof source === 'string')
1548
- return { id: source, name: source };
1549
- const id = String(source?.id ?? message.sender_id ?? '');
1550
- 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) };
1551
1558
  }
1552
1559
  latestEventSeq(events) {
1553
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
+ }
package/dist/runner.d.ts CHANGED
@@ -3,7 +3,9 @@ import type { Launch } from './harness/types.js';
3
3
  import { Tmux } from './tmux.js';
4
4
  import { type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.js';
5
5
  import { type Exec } from './exec.js';
6
- import type { ExitRecord } from './session/types.js';
6
+ import { type AcpSessionOptions } from './session/acp.js';
7
+ import { RoleControlServer } from './session/control.js';
8
+ import type { ExitRecord, SessionHandle, TurnResult } from './session/types.js';
7
9
  import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-channel/channel.js';
8
10
  import { type OwnerBinderLease } from './owner-channel/binder.js';
9
11
  export interface RunnerDeps {
@@ -20,6 +22,10 @@ export interface RunnerDeps {
20
22
  createMonitor(opts: MonitorOpts): MonitorHandle;
21
23
  /** Construct trusted owner ingress (injectable for lifecycle tests). */
22
24
  createOwnerChannel(opts: OwnerChannelOptions): OwnerChannelHandle;
25
+ /** Start the ACP transport (injectable for deterministic runner lifecycle tests). */
26
+ startAcpSession(opts: AcpSessionOptions): Promise<SessionHandle>;
27
+ /** Construct the authenticated role control route (injectable where sockets are unavailable). */
28
+ createControlServer(stateDir: string, session: SessionHandle, log: (line: string) => void): Pick<RoleControlServer, 'start' | 'close' | 'setFleetSpawner' | 'setOwnerChannel' | 'setConfigReloader' | 'setLoopManager'>;
23
29
  /** Acquire the cross-process owner-channel binder lease before replacing the control socket. */
24
30
  acquireOwnerBinder(stateDir: string, role: string, identity: string): Promise<OwnerBinderLease>;
25
31
  /** Ask the still-authenticated predecessor to emit the fixed recovery notice. */
@@ -107,6 +113,8 @@ export interface AttemptResult {
107
113
  export declare const TEMP_IDENTITY_CLOSE_DEBOUNCE_MS = 5000;
108
114
  /** Lifecycle polling is deliberately slower than the 500ms stop-signal loop. */
109
115
  export declare const TEMP_IDENTITY_POLL_MS = 2000;
116
+ /** Only authenticated wake interrupts may turn a temp startup cancellation into readiness. */
117
+ export declare function isRecoverableTempStartupCancellation(temp: boolean, result: TurnResult): boolean;
110
118
  /** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
111
119
  export declare function runOnce(name: string, opts?: {
112
120
  temp?: boolean;
package/dist/runner.js CHANGED
@@ -42,6 +42,8 @@ const defaultDeps = () => ({
42
42
  fetch: (url, init) => globalThis.fetch(url, init),
43
43
  createMonitor: opts => createMonitor(opts),
44
44
  createOwnerChannel: opts => new OwnerChannel(opts),
45
+ startAcpSession: opts => AcpSession.start(opts),
46
+ createControlServer: (stateDir, session, log) => new RoleControlServer(stateDir, session, log),
45
47
  acquireOwnerBinder: (stateDir, role, identity) => acquireOwnerBinderLease(stateDir, role, identity),
46
48
  reportOwnerStartupFailure: async (stateDir) => {
47
49
  const response = await controlRequest(stateDir, {
@@ -352,6 +354,12 @@ function resolveConfigPath(dir, explicit) {
352
354
  export const TEMP_IDENTITY_CLOSE_DEBOUNCE_MS = 5_000;
353
355
  /** Lifecycle polling is deliberately slower than the 500ms stop-signal loop. */
354
356
  export const TEMP_IDENTITY_POLL_MS = 2_000;
357
+ /** Only authenticated wake interrupts may turn a temp startup cancellation into readiness. */
358
+ export function isRecoverableTempStartupCancellation(temp, result) {
359
+ return temp && result.accepted && result.outcome === 'cancelled'
360
+ && (result.cancellationSource === 'local-console'
361
+ || result.cancellationSource === 'fleet-monitor');
362
+ }
355
363
  /** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
356
364
  export async function runOnce(name, opts = {}, partialDeps = {}) {
357
365
  const deps = { ...defaultDeps(), ...partialDeps };
@@ -512,7 +520,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
512
520
  if (perms.unattended === 'deny')
513
521
  deps.log(`[${name}] permission policy: unattended=deny — with no console attached, ` +
514
522
  `permission requests are automatically denied once each (reject_once) and the turn continues`);
515
- acpSession = await AcpSession.start({
523
+ acpSession = await deps.startAcpSession({
516
524
  name,
517
525
  argv: wrappedArgv,
518
526
  cwd: runCwd,
@@ -555,7 +563,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
555
563
  + `${error?.message ?? String(error)}`);
556
564
  }
557
565
  }
558
- control = new RoleControlServer(dir, arbiter, deps.log);
566
+ control = deps.createControlServer(dir, arbiter, deps.log);
559
567
  try {
560
568
  await control.start();
561
569
  }
@@ -640,7 +648,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
640
648
  // success, so there is neither a deaf gap nor a boot-cancellation loop.
641
649
  monitorLoop = monitor?.run(pid);
642
650
  const started = await starting;
643
- if (!started.succeeded) {
651
+ // A temporary role's first turn can be the active turn when an ours wake
652
+ // needs immediate attention. A typed console/monitor cancellation ends
653
+ // only that turn: the already-live ACP session and any queued wake remain
654
+ // valid. Keep every unproven cancellation, refusal, shutdown, and genuine
655
+ // failure terminal so a role that never accepted its briefing is not
656
+ // silently reported as healthy.
657
+ const interruptedForWake = isRecoverableTempStartupCancellation(temp, started);
658
+ if (!started.succeeded && !interruptedForWake) {
644
659
  monitor?.stop();
645
660
  await control.close();
646
661
  ownerBinder?.release();
@@ -664,6 +679,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
664
679
  throw new Error(`[${name}] ACP startup prompt ${started.outcome}` +
665
680
  `${started.detail ? `: ${started.detail}` : ''}`);
666
681
  }
682
+ if (interruptedForWake)
683
+ deps.log(`[${name}] ACP startup prompt cancelled by ${started.cancellationSource}; `
684
+ + 'keeping temporary supervisor alive');
667
685
  acpStartupComplete = true;
668
686
  if (role.owner_channel) {
669
687
  ownerChannel = deps.createOwnerChannel({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.17.3",
3
+ "version": "0.18.0-nightly.1",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -38,6 +38,7 @@
38
38
  "@agentclientprotocol/sdk": "^1.3.0",
39
39
  "@fastify/static": "^10.1.2",
40
40
  "@fastify/websocket": "^11.2.0",
41
+ "@ours.network/sdk": "1.3.1",
41
42
  "@xterm/addon-fit": "0.10.0",
42
43
  "@xterm/addon-serialize": "0.13.0",
43
44
  "@xterm/headless": "5.5.0",
@@ -1,24 +0,0 @@
1
- export declare class OursMcpError extends Error {
2
- }
3
- export interface OursToolClient {
4
- start(): Promise<void>;
5
- callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
6
- close(): Promise<void>;
7
- }
8
- /** Minimal MCP stdio client. One instance owns exactly one ours identity binding. */
9
- export declare class OursMcpClient implements OursToolClient {
10
- private readonly command;
11
- private readonly env;
12
- private readonly log;
13
- private child?;
14
- private nextId;
15
- private tail;
16
- constructor(command?: string, env?: Record<string, string>, log?: (line: string) => void);
17
- start(): Promise<void>;
18
- callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
19
- close(): Promise<void>;
20
- private request;
21
- private requestNow;
22
- private notify;
23
- private write;
24
- }
@@ -1,145 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { randomUUID } from 'node:crypto';
3
- import { createInterface } from 'node:readline';
4
- export class OursMcpError extends Error {
5
- }
6
- /** Minimal MCP stdio client. One instance owns exactly one ours identity binding. */
7
- export class OursMcpClient {
8
- command;
9
- env;
10
- log;
11
- child;
12
- nextId = 0;
13
- tail = Promise.resolve();
14
- constructor(command = 'ours-mcp', env = {}, log = () => undefined) {
15
- this.command = command;
16
- this.env = env;
17
- this.log = log;
18
- }
19
- async start() {
20
- if (this.child && this.child.exitCode === null)
21
- return;
22
- // ours-mcp normally records the long-lived client PID so an identity lease
23
- // survives connector churn. An owner-channel connector has the opposite
24
- // lifecycle: each supervised attempt owns a fresh connector and must make
25
- // its lease reclaimable when that connector exits, even though the fleet
26
- // supervisor itself remains alive. POSIX exec preserves the shell PID as
27
- // the proxy PID, giving the daemon an exact process-lifetime fence without
28
- // interpolating the command path into shell text.
29
- const child = spawn('/bin/sh', [
30
- '-c', 'OURS_CLIENT_PID=$$; export OURS_CLIENT_PID; exec "$1" "$2"',
31
- 'ours-fleet-owner-proxy', this.command, 'proxy',
32
- ], {
33
- env: {
34
- ...process.env,
35
- ...this.env,
36
- // Bindings are keyed by this value. Sharing it would silently rebind a
37
- // role's normal mailbox or another owner channel.
38
- CLAUDE_CODE_SESSION_ID: `ours-fleet-owner-${process.pid}-${randomUUID()}`,
39
- },
40
- stdio: ['pipe', 'pipe', 'pipe'],
41
- });
42
- await new Promise((resolve, reject) => {
43
- child.once('spawn', resolve);
44
- child.once('error', reject);
45
- });
46
- this.child = child;
47
- child.once('exit', (code, signal) => {
48
- this.log(`ours-mcp proxy launcher exited (${code ?? signal ?? 'unknown'})`);
49
- });
50
- child.stdin.on('error', error => this.log(`ours-mcp stdin: ${error.message}`));
51
- createInterface({ input: child.stderr }).on('line', line => this.log(`ours-mcp: ${line}`));
52
- try {
53
- await this.request('initialize', {
54
- protocolVersion: '2025-03-26', capabilities: {},
55
- clientInfo: { name: 'ours-fleet-owner-channel', version: '1' },
56
- });
57
- await this.notify('notifications/initialized', {});
58
- }
59
- catch (error) {
60
- await this.close();
61
- throw error;
62
- }
63
- }
64
- async callTool(name, args = {}) {
65
- const result = await this.request('tools/call', { name, arguments: args });
66
- const text = (result.content ?? [])
67
- .filter(item => item.type === 'text').map(item => item.text ?? '').join('\n').trim();
68
- if (result.isError)
69
- throw new OursMcpError(text || `ours tool ${name} failed`);
70
- if (result.structuredContent !== undefined)
71
- return result.structuredContent;
72
- if (!text)
73
- return {};
74
- try {
75
- return JSON.parse(text);
76
- }
77
- catch {
78
- return text;
79
- }
80
- }
81
- async close() {
82
- const child = this.child;
83
- this.child = undefined;
84
- if (!child || child.exitCode !== null)
85
- return;
86
- // EOF asks the proxy to close normally. Once this exact process exits, the
87
- // daemon can reclaim its lease even while the supervisor stays alive.
88
- child.stdin.end();
89
- const exited = await new Promise(resolve => {
90
- const timer = setTimeout(() => resolve(false), 1_000);
91
- child.once('exit', () => { clearTimeout(timer); resolve(true); });
92
- });
93
- if (exited || child.exitCode !== null)
94
- return;
95
- child.kill('SIGTERM');
96
- await new Promise(resolve => {
97
- const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 5_000);
98
- child.once('exit', () => { clearTimeout(timer); resolve(); });
99
- });
100
- }
101
- request(method, params) {
102
- const run = this.tail.then(() => this.requestNow(method, params));
103
- this.tail = run.then(() => undefined, () => undefined);
104
- return run;
105
- }
106
- async requestNow(method, params) {
107
- const child = this.child;
108
- if (!child || child.exitCode !== null)
109
- throw new OursMcpError('ours-mcp proxy is not running');
110
- const id = ++this.nextId;
111
- await this.write(child, { jsonrpc: '2.0', id, method, params });
112
- const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
113
- try {
114
- for await (const line of lines) {
115
- let response;
116
- try {
117
- response = JSON.parse(line);
118
- }
119
- catch {
120
- continue;
121
- }
122
- if (response.id !== id)
123
- continue;
124
- if (response.error !== undefined)
125
- throw new OursMcpError(JSON.stringify(response.error));
126
- return response.result ?? {};
127
- }
128
- throw new OursMcpError('ours-mcp proxy closed its output');
129
- }
130
- finally {
131
- lines.close();
132
- }
133
- }
134
- async notify(method, params) {
135
- const child = this.child;
136
- if (!child || child.exitCode !== null)
137
- throw new OursMcpError('ours-mcp proxy is not running');
138
- await this.write(child, { jsonrpc: '2.0', method, params });
139
- }
140
- write(child, value) {
141
- return new Promise((resolve, reject) => {
142
- child.stdin.write(JSON.stringify(value) + '\n', error => error ? reject(error) : resolve());
143
- });
144
- }
145
- }