@ours.network/fleet 0.18.0-nightly.5 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +101 -25
  2. package/dist/application/fleet-query-service.js +12 -0
  3. package/dist/application/role-creation-service.js +3 -1
  4. package/dist/application/types.d.ts +11 -0
  5. package/dist/briefing.js +9 -2
  6. package/dist/build-info.json +7 -6
  7. package/dist/capabilities.d.ts +3 -1
  8. package/dist/capabilities.js +3 -0
  9. package/dist/cli.js +83 -7
  10. package/dist/config.d.ts +11 -3
  11. package/dist/config.js +40 -15
  12. package/dist/creation.d.ts +14 -15
  13. package/dist/creation.js +19 -13
  14. package/dist/docs.d.ts +1 -1
  15. package/dist/docs.js +113 -27
  16. package/dist/doctor.d.ts +1 -5
  17. package/dist/doctor.js +11 -18
  18. package/dist/fleet-proxy.d.ts +5 -0
  19. package/dist/harness/acp-agent.js +11 -6
  20. package/dist/harness/claude-code.js +204 -11
  21. package/dist/harness/codex.d.ts +4 -1
  22. package/dist/harness/codex.js +74 -12
  23. package/dist/harness/types.d.ts +54 -4
  24. package/dist/harness-plugins.d.ts +48 -0
  25. package/dist/harness-plugins.js +309 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +1 -0
  28. package/dist/loops/manager.d.ts +30 -1
  29. package/dist/loops/manager.js +69 -6
  30. package/dist/loops/state.d.ts +18 -0
  31. package/dist/loops/state.js +4 -0
  32. package/dist/model-env.d.ts +71 -0
  33. package/dist/model-env.js +106 -0
  34. package/dist/monitor.js +1 -1
  35. package/dist/ops.js +1 -1
  36. package/dist/owner-channel/attachments.d.ts +2 -25
  37. package/dist/owner-channel/attachments.js +5 -61
  38. package/dist/owner-channel/channel.d.ts +28 -17
  39. package/dist/owner-channel/channel.js +249 -158
  40. package/dist/owner-channel/mcp.d.ts +24 -0
  41. package/dist/owner-channel/mcp.js +145 -0
  42. package/dist/owner-channel/notices.d.ts +7 -0
  43. package/dist/owner-channel/notices.js +9 -0
  44. package/dist/resolved-plan.js +1 -0
  45. package/dist/runner.d.ts +48 -0
  46. package/dist/runner.js +237 -85
  47. package/dist/session/acp.d.ts +104 -0
  48. package/dist/session/acp.js +213 -10
  49. package/dist/session/activity.d.ts +31 -0
  50. package/dist/session/activity.js +48 -0
  51. package/dist/session/conversation-normalizer.d.ts +6 -0
  52. package/dist/session/conversation-normalizer.js +153 -10
  53. package/dist/session/conversation-types.d.ts +23 -4
  54. package/dist/session/types.d.ts +35 -0
  55. package/dist/spawn.js +29 -17
  56. package/dist/supervisor/systemd.js +2 -29
  57. package/dist/watchdog/briefing.js +7 -0
  58. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  59. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  60. package/dist/web-app/index.html +1 -1
  61. package/dist/worklog.d.ts +7 -1
  62. package/dist/worklog.js +191 -39
  63. package/package.json +1 -3
  64. package/dist/owner-channel/ours-client.d.ts +0 -141
  65. package/dist/owner-channel/ours-client.js +0 -225
@@ -1,17 +1,20 @@
1
- import { createHash } from 'node:crypto';
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
2
3
  import { existsSync, readFileSync } from 'node:fs';
3
4
  import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
5
+ import { createInterface } from 'node:readline';
4
6
  import { join } from 'node:path';
5
7
  import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
6
8
  import { replaceFileAtomically } from '../atomic-file.js';
9
+ import { resolveEndpoint } from '../monitor.js';
7
10
  import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, interruptOutcome, } from '../session/types.js';
8
11
  import { VERSION } from '../version.js';
9
12
  import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
10
- import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
13
+ import { OursMcpClient } from './mcp.js';
11
14
  import { ownerNotices, } from './notices.js';
12
15
  import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
13
16
  import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
14
- import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, writeRecoveredAttachment, } from './attachments.js';
17
+ import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, } from './attachments.js';
15
18
  import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
16
19
  const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
17
20
  const OWNER_UPDATE_MAX_COUNT = 20;
@@ -25,7 +28,15 @@ const COMMENTARY_MAX_CHARS = 1_600;
25
28
  const COMMENTARY_MAX_BYTES = 6_400;
26
29
  const COMMENTARY_MAX_UPDATES = 32;
27
30
  const COMMENTARY_DEDUPE_LIMIT = 512;
31
+ const OWNER_WATCH_STALL_MS = 120_000;
28
32
  const OWNER_WATCH_BACKOFF_MAX_MS = 30_000;
33
+ /**
34
+ * Credentials that are wrong now are wrong on the next attempt too. Retrying a
35
+ * permanent 401 forever burns the daemon and hides the real fault behind an
36
+ * endless reconnect log, so the watch stops after this many consecutive auth
37
+ * rejections and records a terminal reason instead.
38
+ */
39
+ const OWNER_WATCH_AUTH_FATAL_ATTEMPTS = 5;
29
40
  /** A relay attempt that failed only because no owner route exists yet. */
30
41
  class RelayUnroutableError extends Error {
31
42
  }
@@ -59,6 +70,7 @@ export class OwnerChannel {
59
70
  commentsBaseline;
60
71
  commentsEnabled;
61
72
  stopping = false;
73
+ watchProcess;
62
74
  watchTask;
63
75
  watchAbort;
64
76
  drainTask;
@@ -72,7 +84,7 @@ export class OwnerChannel {
72
84
  fleetOps;
73
85
  constructor(options) {
74
86
  this.options = options;
75
- this.client = options.client ?? new OursSdkClient(options.env, line => options.log(`[${options.role}] owner channel ${line}`));
87
+ this.client = options.client ?? new OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
76
88
  this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
77
89
  this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
78
90
  this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
@@ -111,16 +123,12 @@ export class OwnerChannel {
111
123
  const bindStartedAt = now();
112
124
  for (;;) {
113
125
  try {
114
- await this.client.bindIdentity(this.options.config.identity);
126
+ await this.client.callTool('choose_identity', { name: this.options.config.identity });
115
127
  break;
116
128
  }
117
129
  catch (error) {
118
- // The predecessor's lease may still be in flight. Only the daemon's
119
- // own typed verdict may extend the handoff window: matching the
120
- // wording of an error message would let any other failure whose text
121
- // happens to say "bound to another live session" — including one
122
- // relayed from a peer — spin here for the whole timeout.
123
- const liveConflict = oursErrorCode(error) === OURS_BOUND_ELSEWHERE;
130
+ const message = error?.message ?? String(error);
131
+ const liveConflict = /currently bound to another live session/i.test(message);
124
132
  if (!this.binder.inherited || !liveConflict
125
133
  || now() - bindStartedAt >= OWNER_BIND_HANDOFF_TIMEOUT_MS)
126
134
  throw error;
@@ -142,10 +150,9 @@ export class OwnerChannel {
142
150
  void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
143
151
  }
144
152
  this.ready = true;
153
+ this.watchTask = this.options.watch ? this.legacyWatchLoop() : this.watchLoop();
145
154
  // Do not make role startup wait for an old owner request to finish a turn.
146
- // watchLoop itself drains before every establishment, including this first
147
- // one, so there is no drain-to-tip race.
148
- this.watchTask = this.watchLoop();
155
+ void this.drain().catch(error => this.logError('initial drain failed', error));
149
156
  }
150
157
  drain() {
151
158
  this.drainRequested = true;
@@ -162,8 +169,13 @@ export class OwnerChannel {
162
169
  async close() {
163
170
  this.stopping = true;
164
171
  this.ready = false;
172
+ const watch = this.watchProcess;
173
+ this.watchProcess = undefined;
174
+ if (watch && watch.exitCode === null)
175
+ watch.kill('SIGTERM');
165
176
  this.watchAbort?.abort();
166
- await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
177
+ if (!this.options.watch)
178
+ await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
167
179
  this.watchTask = undefined;
168
180
  await this.managementTail;
169
181
  try {
@@ -189,10 +201,13 @@ export class OwnerChannel {
189
201
  ? ' with interruption'
190
202
  : event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
191
203
  const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
204
+ const permission = event.permissionMode
205
+ ? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
206
+ : '';
192
207
  const inherited = event.inherited.length
193
208
  ? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
194
209
  await this.sendProactiveMessage(`🧑‍💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
195
- + `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
210
+ + `(${event.harness}/${event.session}${model}; ${monitor}${permission}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
196
211
  });
197
212
  this.managementTail = run.then(() => undefined, () => undefined);
198
213
  return run;
@@ -205,14 +220,11 @@ export class OwnerChannel {
205
220
  return { action: request.action, contacts: await this.contacts() };
206
221
  case 'contact_invite': {
207
222
  this.assertLabel(request.name);
208
- // The invite is the blob field, not a sentence containing it. The MCP
209
- // surface answered with "One-time invite for X created (invite_id ).
210
- // Share this blob out-of-band …:\n<blob>" and the whole sentence was
211
- // handed out as the invite, so any rewording changed the payload.
212
- const { blob } = await this.client.generateInvite(request.name);
213
- if (typeof blob !== 'string' || !blob)
214
- throw new Error('the ours daemon returned no invite blob');
215
- return { action: request.action, invite: blob };
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 };
216
228
  }
217
229
  case 'contact_add': {
218
230
  if (typeof request.invite !== 'string' || !request.invite)
@@ -220,19 +232,18 @@ export class OwnerChannel {
220
232
  if (Buffer.byteLength(request.invite) > 48 * 1024)
221
233
  throw new Error('invite exceeds 49152 bytes');
222
234
  this.assertLabel(request.name);
223
- let added;
235
+ let raw;
224
236
  try {
225
- added = await this.client.addContact({
237
+ raw = await this.client.callTool('add_contact', {
226
238
  invite: request.invite, ...(request.name ? { name: request.name } : {}),
227
239
  });
228
240
  }
229
241
  catch {
230
242
  // Daemon errors are not allowed to reflect invite material through
231
243
  // the control response, CLI stderr, or supervisor logs.
232
- throw new Error('the ours daemon could not accept the contact invite');
244
+ throw new Error('ours-mcp could not accept the contact invite');
233
245
  }
234
- const contact = this.contact(added.cid, added.display, 'pending');
235
- return { action: request.action, status: 'pending', ...(contact ? { contact } : {}) };
246
+ return { action: request.action, status: 'pending', contact: this.contact(raw) };
236
247
  }
237
248
  case 'owner_list':
238
249
  return {
@@ -306,36 +317,33 @@ export class OwnerChannel {
306
317
  throw new Error('unknown owner-channel management action');
307
318
  }
308
319
  }
309
- /**
310
- * The daemon reports established contacts and pending introductions as two
311
- * separate collections, so the status is structural rather than a word parsed
312
- * out of a rendered line. Nothing here can be spoofed by a contact's own
313
- * display name.
314
- */
315
320
  async contacts() {
316
- const view = await this.client.listContacts();
317
- const rows = [
318
- ...(Array.isArray(view?.contacts) ? view.contacts : [])
319
- .map(row => this.contact(row?.container_id, row?.name, 'established', view)),
320
- ...(Array.isArray(view?.pending) ? view.pending : [])
321
- .map(row => this.contact(row?.container_id, row?.name, 'pending', view)),
322
- ];
323
- return rows.filter((row) => Boolean(row))
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))
324
326
  .sort((a, b) => a.cid.localeCompare(b.cid));
325
327
  }
326
- contact(cidValue, nameValue, status, view) {
327
- const cid = String(cidValue ?? '');
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 ?? '');
328
333
  if (!/^[A-Fa-f0-9]{64}$/.test(cid))
329
334
  return undefined;
330
- const root = view?.roots?.[cid];
331
- const rootCid = String(root?.root_cid ?? '');
335
+ const humanRaw = value.human ?? value.root;
336
+ const human = humanRaw && typeof humanRaw === 'object' ? humanRaw : undefined;
332
337
  return {
333
338
  cid,
334
- name: this.safeMetadata(nameValue ?? cid),
335
- status,
336
- ...(root ? { human: {
337
- ...(/^[A-Fa-f0-9]{64}$/.test(rootCid) ? { cid: rootCid } : {}),
338
- ...(root.root_name ? { name: this.safeMetadata(root.root_name) } : {}),
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) } : {}),
339
347
  } } : {}),
340
348
  };
341
349
  }
@@ -520,16 +528,16 @@ export class OwnerChannel {
520
528
  // A finite cap protects the supervisor if a broken daemon repeats unread
521
529
  // messages forever. A watch notification will resume draining later.
522
530
  for (let pass = 0; pass < 100 && !this.stopping; pass++) {
523
- const [payload, fileResult] = await Promise.all([
524
- this.client.getMessages(),
525
- this.client.listIncomingFiles()
531
+ const [raw, fileResult] = await Promise.all([
532
+ this.client.callTool('get_messages'),
533
+ this.client.callTool('list_incoming_files')
526
534
  .catch(error => {
527
535
  this.logError('attachment metadata inspection unavailable', error);
528
536
  return undefined;
529
537
  }),
530
538
  ]);
531
- const messages = Array.isArray(payload?.messages)
532
- ? payload.messages.filter(message => message && typeof message === 'object')
539
+ const messages = Array.isArray(raw?.messages)
540
+ ? raw.messages.filter(message => message && typeof message === 'object')
533
541
  : [];
534
542
  let files = [];
535
543
  try {
@@ -553,7 +561,7 @@ export class OwnerChannel {
553
561
  && Number.isInteger(message.msg_id);
554
562
  }).map(message => message.msg_id);
555
563
  if (deferred.length)
556
- await this.client.deferMessages(deferred);
564
+ await this.client.callTool('defer_messages', { msg_ids: deferred });
557
565
  let advanced = false;
558
566
  const consumedMessages = new Set();
559
567
  const groups = this.attachmentGroups(files, messages, pending, consumedMessages);
@@ -684,12 +692,15 @@ export class OwnerChannel {
684
692
  const unread = group.files.filter(file => file.status === 'unread');
685
693
  const processed = group.files.filter(file => file.status !== 'unread');
686
694
  const retrieved = unread.length
687
- ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
695
+ ? parseRetrievedAttachments(await this.client.callTool('get_files', {
696
+ wire_ids: unread.map(file => file.wireId),
697
+ }), unread)
688
698
  : [];
689
699
  for (const file of processed) {
690
700
  if (!group.recovery)
691
701
  throw new Error('unexpected processed attachment without recovery route');
692
- const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
702
+ const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
703
+ await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
693
704
  retrieved.push(await recoveredAttachment(file, recoveryPath));
694
705
  }
695
706
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
@@ -698,17 +709,13 @@ export class OwnerChannel {
698
709
  outbox = this.outboxDir(originWireId);
699
710
  await mkdir(outbox, { recursive: true, mode: 0o700 });
700
711
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
701
- const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, outbox, admitted, group.caption), {
712
+ const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
702
713
  interrupt: this.options.config.interrupt,
703
714
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
704
715
  origin: { kind: 'owner', requestId,
705
716
  ...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
706
717
  });
707
- const accepted = this.options.config.interrupt
708
- ? ownerNotices.receivedInterrupting()
709
- : queued.queuedBehind > 0
710
- ? ownerNotices.receivedQueued(queued.queuedBehind)
711
- : ownerNotices.receivedStarted();
718
+ const accepted = this.acceptanceNotice(queued);
712
719
  handledWireIds.forEach(wire => this.inFlight.add(wire));
713
720
  const receipt = this.send(sender.id, accepted, originWireId).then(() => undefined).catch(error => {
714
721
  this.logError(`attachment request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -786,12 +793,12 @@ export class OwnerChannel {
786
793
  // the authenticated agent once so the wait is never silent.
787
794
  this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
788
795
  + `route yet; message stays queued: ${this.errorText(error)}`);
789
- await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayQueued());
796
+ await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayQueued());
790
797
  return false;
791
798
  }
792
799
  else {
793
800
  this.logError('managed-agent message relay refused', error);
794
- await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayRefused(this.errorText(error)));
801
+ await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayRefused(this.errorText(error)));
795
802
  }
796
803
  }
797
804
  this.state.remember(wireId);
@@ -827,7 +834,7 @@ export class OwnerChannel {
827
834
  let queued;
828
835
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
829
836
  try {
830
- queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
837
+ queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId), {
831
838
  interrupt: this.options.config.interrupt,
832
839
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
833
840
  origin: { kind: 'owner', requestId, displayText: text },
@@ -849,14 +856,7 @@ export class OwnerChannel {
849
856
  this.state.remember(wireId);
850
857
  return true;
851
858
  }
852
- // Interrupting the live turn does not remove prompts which were already
853
- // accepted into the ACP queue. Never claim this request is running while
854
- // the session itself says earlier work remains ahead of it.
855
- const accepted = queued.queuedBehind > 0
856
- ? ownerNotices.receivedQueued(queued.queuedBehind)
857
- : this.options.config.interrupt
858
- ? ownerNotices.receivedInterrupting()
859
- : ownerNotices.receivedStarted();
859
+ const accepted = this.acceptanceNotice(queued);
860
860
  this.inFlight.add(wireId);
861
861
  const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
862
862
  this.logError(`request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -1036,9 +1036,7 @@ export class OwnerChannel {
1036
1036
  async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
1037
1037
  const captionWire = group.caption ? this.wireId(group.caption) : undefined;
1038
1038
  const nackWire = captionWire ?? group.files[0].wireId;
1039
- // A caption whose own wire id is synthetic (msg_id only) must not be echoed
1040
- // back as a reply reference; a file wire always is a real one.
1041
- const nackReplyTo = (group.caption ? group.caption.wire_id : nackWire) ? nackWire : undefined;
1039
+ const nackMessage = group.caption ?? { wire_id: nackWire };
1042
1040
  let requestDir;
1043
1041
  let recovery;
1044
1042
  try {
@@ -1074,12 +1072,15 @@ export class OwnerChannel {
1074
1072
  const unread = group.files.filter(file => file.status === 'unread');
1075
1073
  const processed = group.files.filter(file => file.status !== 'unread');
1076
1074
  const retrieved = unread.length
1077
- ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1075
+ ? parseRetrievedAttachments(await this.client.callTool('get_files', {
1076
+ wire_ids: unread.map(file => file.wireId),
1077
+ }), unread)
1078
1078
  : [];
1079
1079
  for (const file of processed) {
1080
1080
  if (!group.recovery)
1081
1081
  throw new Error('unexpected processed attachment without recovery route');
1082
- const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
1082
+ const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
1083
+ await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
1083
1084
  retrieved.push(await recoveredAttachment(file, recoveryPath));
1084
1085
  }
1085
1086
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
@@ -1091,9 +1092,9 @@ export class OwnerChannel {
1091
1092
  if (caption)
1092
1093
  await this.send(contact, caption, replyTo);
1093
1094
  for (const file of admitted) {
1094
- await this.client.sendFile({
1095
+ await this.client.callTool('send_file', {
1095
1096
  contact, path: file.path, filename: file.filename,
1096
- ...(replyTo ? { replyToWireId: replyTo } : {}),
1097
+ ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
1097
1098
  });
1098
1099
  }
1099
1100
  }
@@ -1130,11 +1131,11 @@ export class OwnerChannel {
1130
1131
  if (error instanceof RelayUnroutableError) {
1131
1132
  this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
1132
1133
  + `transaction stays queued: ${this.errorText(error)}`);
1133
- await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayQueued());
1134
+ await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayQueued());
1134
1135
  return false;
1135
1136
  }
1136
1137
  this.logError('managed-agent caption/file relay refused', error);
1137
- await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayRefused(this.errorText(error)));
1138
+ await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayRefused(this.errorText(error)));
1138
1139
  // Rejection/admission failure and uncertain transport are terminal and
1139
1140
  // visible. Consuming every correlated wire prevents a later partial replay.
1140
1141
  for (const wire of handledWireIds)
@@ -1173,14 +1174,14 @@ export class OwnerChannel {
1173
1174
  * to the authenticated agent, while its deferred replays stay quiet. NACK
1174
1175
  * delivery is best-effort — it must never make the failure worse.
1175
1176
  */
1176
- async nackManagedAgent(contact, replyTo, wireId, notice) {
1177
+ async nackManagedAgent(contact, message, wireId, notice) {
1177
1178
  if (this.relayNacks.has(wireId))
1178
1179
  return;
1179
1180
  this.relayNacks.add(wireId);
1180
1181
  if (this.relayNacks.size > RELAY_NACK_MEMORY)
1181
1182
  this.relayNacks.delete(this.relayNacks.values().next().value);
1182
1183
  try {
1183
- await this.send(contact, notice, replyTo);
1184
+ await this.send(contact, notice, message.wire_id ? wireId : undefined);
1184
1185
  }
1185
1186
  catch (error) {
1186
1187
  this.logError('managed-agent relay NACK delivery failed', error);
@@ -1241,6 +1242,32 @@ export class OwnerChannel {
1241
1242
  authorizationIntegrity() {
1242
1243
  return this.options.config.agent ? { ok: true } : this.authorizations.integrity();
1243
1244
  }
1245
+ /**
1246
+ * Report what the session actually did with the prompt, not what the config
1247
+ * asked for. `interrupt: true` used to be reported as "your request
1248
+ * interrupted the previous task" unconditionally; the session now answers
1249
+ * whether anything was cancelled, whether the request is queued behind
1250
+ * earlier prompts, or whether it is held until the current task reaches a
1251
+ * safe stopping point. Backends that report no delivery state keep the old
1252
+ * queuedBehind-based wording.
1253
+ */
1254
+ acceptanceNotice(queued) {
1255
+ switch (queued.delivery) {
1256
+ case 'interrupted': return ownerNotices.receivedInterrupting();
1257
+ case 'deferred': return ownerNotices.receivedDeferred();
1258
+ case 'queued': return ownerNotices.receivedQueued(Math.max(1, queued.queuedBehind));
1259
+ case 'started': return ownerNotices.receivedStarted();
1260
+ default:
1261
+ // Interrupting the live turn does not remove prompts which were already
1262
+ // accepted into the ACP queue. Never claim this request is running while
1263
+ // the session itself says earlier work remains ahead of it.
1264
+ return queued.queuedBehind > 0
1265
+ ? ownerNotices.receivedQueued(queued.queuedBehind)
1266
+ : this.options.config.interrupt
1267
+ ? ownerNotices.receivedInterrupting()
1268
+ : ownerNotices.receivedStarted();
1269
+ }
1270
+ }
1244
1271
  async complete(active, outbox, queued, activityCursor) {
1245
1272
  const progressMs = this.options.config.progress_interval_ms;
1246
1273
  let lastSeq = activityCursor;
@@ -1441,7 +1468,7 @@ export class OwnerChannel {
1441
1468
  throw new Error('commentary appears to contain secret, reasoning, or raw tool content');
1442
1469
  return message;
1443
1470
  }
1444
- ownerAttachmentPrompt(sender, wireId, requestId, outbox, files, caption) {
1471
+ ownerAttachmentPrompt(sender, wireId, requestId, files, caption) {
1445
1472
  const lines = [
1446
1473
  '[fleet-owner]',
1447
1474
  `Authenticated owner ${safeField(sender.name, 160)} (${sender.id}) sent owner-channel attachment request ${wireId}.`,
@@ -1465,10 +1492,13 @@ export class OwnerChannel {
1465
1492
  lines.push(`- voice transcript status: ${transcription?.status ?? 'unavailable'}`, `- voice transcript fallback: audio path above; category ${transcription?.errorCategory ?? 'not_provided'}`);
1466
1493
  }
1467
1494
  }
1468
- lines.push('Answer in your final assistant response; fleet routes it only to the authenticated sender and correlates it to the originating file wire.', `To attach response files, write regular files only to: ${outbox}`);
1495
+ lines.push('Answer in your final assistant response; fleet routes it only to the authenticated sender and correlates it to the originating file wire.',
1496
+ // send_file is the only delivery route an agent is given: a tool call either
1497
+ // delivers or reports an error, where a file written to disk does neither.
1498
+ 'To send the owner a file — now or later in this turn — call ours `send_file`:', `contact: ${this.options.config.identity}`, 'and the path of the finished file. Fleet routes it to the authenticated owner.', 'A file written anywhere else is not delivered and nothing will report that it was not.', 'Use descriptive unique filenames. Send nothing the owner did not request or should not receive.');
1469
1499
  return lines.join('\n');
1470
1500
  }
1471
- ownerPrompt(sender, text, wireId, outbox) {
1501
+ ownerPrompt(sender, text, wireId) {
1472
1502
  return [
1473
1503
  '[fleet-owner]',
1474
1504
  `Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
@@ -1482,10 +1512,13 @@ export class OwnerChannel {
1482
1512
  ] : [
1483
1513
  'Managed-agent outbound relay is not configured; do not send intermediate or proactive owner-channel messages.',
1484
1514
  ]),
1485
- 'To attach files to your response, copy each finished file directly into this fleet outbox:',
1486
- outbox,
1487
- 'Fleet sends every regular file in that directory to the authenticated owner, correlated to this request.',
1488
- 'Use descriptive unique filenames. Put nothing there that the owner did not request or should not receive.',
1515
+ // send_file is the only delivery route an agent is given: a tool call either
1516
+ // delivers or reports an error, where a file written to disk does neither.
1517
+ 'To send the owner a file now or later in this turn call ours `send_file`:',
1518
+ `contact: ${this.options.config.identity}`,
1519
+ 'and the path of the finished file. Fleet routes it to the authenticated owner.',
1520
+ 'A file written anywhere else is not delivered and nothing will report that it was not.',
1521
+ 'Use descriptive unique filenames. Send nothing the owner did not request or should not receive.',
1489
1522
  '',
1490
1523
  text || '(empty message)',
1491
1524
  ].join('\n');
@@ -1498,8 +1531,8 @@ export class OwnerChannel {
1498
1531
  return createHash('sha256').update(wireId).digest('hex');
1499
1532
  }
1500
1533
  send(contact, text, replyTo) {
1501
- return this.client.sendMessage({
1502
- contact, text, ...(replyTo ? { replyToWireId: replyTo } : {}),
1534
+ return this.client.callTool('send_message', {
1535
+ contact, text, ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
1503
1536
  });
1504
1537
  }
1505
1538
  async sendAttachments(contact, outbox, replyTo) {
@@ -1507,11 +1540,11 @@ export class OwnerChannel {
1507
1540
  .filter(entry => entry.isFile())
1508
1541
  .sort((a, b) => a.name.localeCompare(b.name));
1509
1542
  for (const entry of entries) {
1510
- await this.client.sendFile({
1543
+ await this.client.callTool('send_file', {
1511
1544
  contact,
1512
1545
  path: join(outbox, entry.name),
1513
1546
  filename: entry.name,
1514
- replyToWireId: replyTo,
1547
+ reply_to_wire_id: replyTo,
1515
1548
  });
1516
1549
  }
1517
1550
  await rm(outbox, { recursive: true, force: true });
@@ -1534,11 +1567,11 @@ export class OwnerChannel {
1534
1567
  return Number.isInteger(message.msg_id) ? `msg:${message.msg_id}` : '';
1535
1568
  }
1536
1569
  sender(message) {
1537
- // Authenticated routing data, straight from the daemon's typed envelope.
1538
- // The id still goes through the CID checks in acceptedSender/isEffectiveOwner.
1539
- const source = message.from;
1540
- const id = String(source?.id ?? '');
1541
- return { id, name: String(source?.name ?? id) };
1570
+ const source = message.from ?? message.sender;
1571
+ if (typeof source === 'string')
1572
+ return { id: source, name: source };
1573
+ const id = String(source?.id ?? message.sender_id ?? '');
1574
+ return { id, name: String(source?.name ?? message.sender_name ?? id) };
1542
1575
  }
1543
1576
  latestEventSeq(events) {
1544
1577
  return events.reduce((latest, event) => Math.max(latest, event.seq), 0);
@@ -1567,65 +1600,90 @@ export class OwnerChannel {
1567
1600
  return undefined;
1568
1601
  }
1569
1602
  async watchLoop() {
1603
+ const endpoint = resolveEndpoint({ ...process.env, ...(this.options.env ?? {}) });
1604
+ const fetch = this.options.watchFetch
1605
+ ?? ((url, init) => globalThis.fetch(url, init));
1570
1606
  const sleep = this.options.binderDeps?.sleep
1571
1607
  ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
1572
1608
  const restored = this.readWatchState();
1573
1609
  let state = restored.state;
1574
- if (restored.recovered)
1575
- state = this.writeWatchState(state, 'OWNER_WATCH_STATE_RECOVERED');
1610
+ // An unreadable cursor is the ONLY reason to restart at the tip. Say so on
1611
+ // the next successful connect, and drain first: everything the lost cursor
1612
+ // would have pointed at is still in the inbox, which is the authority.
1613
+ let recovering = restored.recovered;
1614
+ let cursor = state?.cursor ?? 'tip';
1576
1615
  let delayMs = 1_000;
1577
- let attempts = 0;
1616
+ let authFailures = 0;
1617
+ if (recovering)
1618
+ await this.drain().catch(error => this.logError('cursor recovery drain failed', error));
1578
1619
  while (!this.stopping) {
1579
1620
  const ctrl = new AbortController();
1580
1621
  this.watchAbort = ctrl;
1622
+ let stalled = false;
1623
+ let authRejected = false;
1624
+ const timer = setTimeout(() => { stalled = true; ctrl.abort(); }, this.options.watchStallMs ?? OWNER_WATCH_STALL_MS);
1625
+ timer.unref?.();
1581
1626
  try {
1582
- // This drain is unconditional at EVERY establishment. Starting the SDK
1583
- // stream at 0 then replays notification hints instead of tip-priming,
1584
- // so mail arriving after the drain but before the first request cannot
1585
- // fall into a gap. The inbox is authoritative and its durable wire-ID
1586
- // dedupe makes replayed hints harmless.
1587
- await this.drain();
1588
- if (this.stopping)
1589
- return;
1590
- state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTING', { reconnected: attempts > 0 });
1591
- attempts++;
1592
- for await (const _event of this.client.watchNotifications(this.options.config.identity, { since: 0, signal: ctrl.signal })) {
1593
- if (this.stopping)
1627
+ const response = await fetch(`${endpoint.url(this.options.config.identity)}?since=${cursor}`, { headers: endpoint.headers, signal: ctrl.signal });
1628
+ if (response.status === 401) {
1629
+ authRejected = true;
1630
+ authFailures++;
1631
+ const at = cursor === 'tip' ? state?.cursor ?? 0 : cursor;
1632
+ if (authFailures >= OWNER_WATCH_AUTH_FATAL_ATTEMPTS) {
1633
+ state = this.writeWatchState(state, at, 'OWNER_WATCH_AUTH_FATAL', true);
1634
+ this.options.log(`[${this.options.role}] owner watch stopped `
1635
+ + `reason=OWNER_WATCH_AUTH_FATAL after ${authFailures} consecutive HTTP 401 responses; `
1636
+ + 'owner notifications require re-authorization and will not be retried');
1594
1637
  return;
1595
- state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTED', { resetFailures: true });
1596
- delayMs = 1_000;
1597
- // Notification events contain no bodies and are only wake hints.
1598
- // Draining is idempotent at the turn boundary because wire IDs are
1599
- // recorded before a managed request is dispatched.
1600
- await this.drain();
1638
+ }
1639
+ state = this.writeWatchState(state, at, 'OWNER_WATCH_AUTH_FAILED', true);
1640
+ throw new Error('OWNER_WATCH_AUTH_FAILED: daemon rejected notification credentials');
1601
1641
  }
1602
- if (!this.stopping)
1603
- throw new Error('ours SDK notification stream ended');
1642
+ if (!response.ok)
1643
+ throw new Error(`daemon returned HTTP ${response.status}`);
1644
+ const body = await response.json();
1645
+ const next = typeof body.cursor === 'number' ? body.cursor : cursor === 'tip' ? 0 : cursor;
1646
+ const reconnect = (state?.consecutiveFailures ?? 0) > 0;
1647
+ state = this.writeWatchState(state, next, recovering ? 'OWNER_WATCH_CURSOR_RECOVERED' : 'OWNER_WATCH_CONNECTED', false, reconnect);
1648
+ recovering = false;
1649
+ authFailures = 0;
1650
+ cursor = next;
1651
+ delayMs = 1_000;
1652
+ // Notification events are content-free hints. The inbox remains the
1653
+ // authority and its wire-level durable dedupe prevents duplicate turns.
1654
+ if ((body.events?.length ?? 0) > 0)
1655
+ await this.drain();
1604
1656
  }
1605
1657
  catch (error) {
1606
1658
  if (this.stopping)
1607
1659
  return;
1608
- // SDK 2 deliberately hides transport status behind its typed stream.
1609
- // Do not parse error prose to rediscover it: every failure follows the
1610
- // same capped retry path forever, and the pre-establishment drain makes
1611
- // that retry correctness-preserving.
1612
- state = this.writeWatchState(state, 'OWNER_WATCH_STREAM_ERROR', { failed: true });
1660
+ const reason = stalled
1661
+ ? 'OWNER_WATCH_STALLED' : 'OWNER_WATCH_STREAM_ERROR';
1662
+ const current = cursor === 'tip' ? state?.cursor ?? 0 : cursor;
1663
+ cursor = current;
1664
+ // Only THIS iteration's auth rejection is already written. A stale
1665
+ // AUTH_FAILED from an earlier attempt must never suppress the cursor,
1666
+ // the failure counter, or a later STALLED transition.
1667
+ if (!authRejected)
1668
+ state = this.writeWatchState(state, current, reason, true);
1613
1669
  this.options.log(`[${this.options.role}] owner watch reconnect `
1614
- + `reason=OWNER_WATCH_STREAM_ERROR delay_ms=${delayMs} `
1615
- + `failures=${state.consecutiveFailures}: ${this.errorText(error)}`);
1670
+ + `reason=${authRejected ? 'OWNER_WATCH_AUTH_FAILED' : reason} `
1671
+ + `delay_ms=${delayMs} cursor=${current}`);
1616
1672
  await sleep(delayMs);
1617
1673
  delayMs = Math.min(delayMs * 2, OWNER_WATCH_BACKOFF_MAX_MS);
1618
1674
  }
1619
1675
  finally {
1676
+ clearTimeout(timer);
1620
1677
  if (this.watchAbort === ctrl)
1621
1678
  this.watchAbort = undefined;
1622
1679
  }
1623
1680
  }
1624
1681
  }
1625
1682
  /**
1626
- * `recovered` distinguishes a first-ever start from unreadable persisted
1627
- * diagnostics. Notification correctness does not depend on this state: every
1628
- * establishment drains and then replays SDK hints from offset zero.
1683
+ * `recovered` distinguishes a first-ever start (no state, nothing lost) from a
1684
+ * cursor we HAD and can no longer read. Only the latter is a recovery, and the
1685
+ * caller needs to know because the reason it reports is the only evidence a
1686
+ * durable cursor was ever lost.
1629
1687
  */
1630
1688
  readWatchState() {
1631
1689
  const path = join(this.options.stateDir, '.owner-channel-watch.json');
@@ -1633,43 +1691,76 @@ export class OwnerChannel {
1633
1691
  return { recovered: false };
1634
1692
  try {
1635
1693
  const value = JSON.parse(readFileSync(path, 'utf8'));
1636
- if ((value.version !== 1 && value.version !== 2)
1694
+ if (value.version !== 1 || !Number.isSafeInteger(value.cursor) || value.cursor < 0
1637
1695
  || !Number.isSafeInteger(value.reconnects) || value.reconnects < 0
1638
1696
  || !Number.isSafeInteger(value.consecutiveFailures) || value.consecutiveFailures < 0)
1639
1697
  throw new Error('invalid owner watch state');
1640
- const reasons = new Set([
1641
- 'OWNER_WATCH_CONNECTING', 'OWNER_WATCH_CONNECTED',
1642
- 'OWNER_WATCH_STREAM_ERROR', 'OWNER_WATCH_STATE_RECOVERED',
1643
- ]);
1644
- return { state: {
1645
- version: 2,
1646
- reconnects: value.reconnects,
1647
- consecutiveFailures: value.consecutiveFailures,
1648
- reason: reasons.has(value.reason)
1649
- ? value.reason : 'OWNER_WATCH_CONNECTING',
1650
- updatedAt: typeof value.updatedAt === 'string'
1651
- ? value.updatedAt : new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
1652
- }, recovered: false };
1698
+ return { state: value, recovered: false };
1653
1699
  }
1654
1700
  catch {
1655
1701
  this.options.log(`[${this.options.role}] owner watch `
1656
- + 'reason=OWNER_WATCH_STATE_RECOVERED invalid persisted counters; restarting safely');
1702
+ + 'reason=OWNER_WATCH_CURSOR_RECOVERED invalid cursor state; draining inbox and starting at tip');
1657
1703
  return { recovered: true };
1658
1704
  }
1659
1705
  }
1660
- writeWatchState(previous, reason, options = {}) {
1706
+ writeWatchState(previous, cursor, reason, failed, reconnected = false) {
1661
1707
  const state = {
1662
- version: 2,
1663
- reconnects: (previous?.reconnects ?? 0) + (options.reconnected ? 1 : 0),
1664
- consecutiveFailures: options.failed
1665
- ? (previous?.consecutiveFailures ?? 0) + 1
1666
- : options.resetFailures ? 0 : previous?.consecutiveFailures ?? 0,
1708
+ version: 1,
1709
+ cursor,
1710
+ reconnects: (previous?.reconnects ?? 0) + (reconnected ? 1 : 0),
1711
+ consecutiveFailures: failed ? (previous?.consecutiveFailures ?? 0) + 1 : 0,
1667
1712
  reason,
1668
1713
  updatedAt: new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
1669
1714
  };
1670
1715
  replaceFileAtomically(join(this.options.stateDir, '.owner-channel-watch.json'), `${JSON.stringify(state)}\n`, 0o600);
1671
1716
  return state;
1672
1717
  }
1718
+ /** Compatibility path for injected child-process tests; production is direct. */
1719
+ async legacyWatchLoop() {
1720
+ let delayMs = 1_000;
1721
+ while (!this.stopping) {
1722
+ try {
1723
+ const child = this.options.watch?.(this.options.config.identity) ?? spawn(this.options.command ?? 'ours-mcp', ['watch', this.options.config.identity], {
1724
+ env: { ...process.env, ...(this.options.env ?? {}) }, stdio: ['pipe', 'pipe', 'pipe'],
1725
+ });
1726
+ this.watchProcess = child;
1727
+ await new Promise((resolve, reject) => {
1728
+ if (child.pid) {
1729
+ resolve();
1730
+ return;
1731
+ }
1732
+ child.once('spawn', resolve);
1733
+ child.once('error', reject);
1734
+ });
1735
+ createInterface({ input: child.stderr }).on('line', line => this.options.log(`[${this.options.role}] owner watch: ${line}`));
1736
+ delayMs = 1_000;
1737
+ // Drain at every (re)attachment, not only after a future notification:
1738
+ // a failed send/turn leaves the input deferred and may not emit another
1739
+ // watch line by itself.
1740
+ await this.drain();
1741
+ for await (const _line of createInterface({ input: child.stdout })) {
1742
+ if (this.stopping)
1743
+ break;
1744
+ await this.drain();
1745
+ }
1746
+ if (!this.stopping)
1747
+ throw new Error('watch exited');
1748
+ }
1749
+ catch (error) {
1750
+ if (!this.stopping) {
1751
+ this.logError('watch failed; retrying', error);
1752
+ await new Promise(resolve => setTimeout(resolve, delayMs));
1753
+ delayMs = Math.min(delayMs * 2, 30_000);
1754
+ }
1755
+ }
1756
+ finally {
1757
+ const child = this.watchProcess;
1758
+ this.watchProcess = undefined;
1759
+ if (child && child.exitCode === null)
1760
+ child.kill('SIGTERM');
1761
+ }
1762
+ }
1763
+ }
1673
1764
  errorText(error) {
1674
1765
  return error?.message ?? String(error);
1675
1766
  }