@ours.network/fleet 0.18.0-nightly.6 → 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 (67) hide show
  1. package/README.md +111 -43
  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 +117 -35
  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 +30 -29
  39. package/dist/owner-channel/channel.js +291 -291
  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/message-recovery.d.ts +0 -25
  65. package/dist/owner-channel/message-recovery.js +0 -114
  66. package/dist/owner-channel/ours-client.d.ts +0 -148
  67. package/dist/owner-channel/ours-client.js +0 -231
@@ -1,19 +1,21 @@
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
- import { MessageRecoveryState } from './message-recovery.js';
17
19
  const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
18
20
  const OWNER_UPDATE_MAX_COUNT = 20;
19
21
  const OWNER_UPDATE_MAX_CHARS = 280;
@@ -26,8 +28,15 @@ const COMMENTARY_MAX_CHARS = 1_600;
26
28
  const COMMENTARY_MAX_BYTES = 6_400;
27
29
  const COMMENTARY_MAX_UPDATES = 32;
28
30
  const COMMENTARY_DEDUPE_LIMIT = 512;
31
+ const OWNER_WATCH_STALL_MS = 120_000;
29
32
  const OWNER_WATCH_BACKOFF_MAX_MS = 30_000;
30
- const OWNER_MESSAGE_BATCH_LIMIT = 200;
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;
31
40
  /** A relay attempt that failed only because no owner route exists yet. */
32
41
  class RelayUnroutableError extends Error {
33
42
  }
@@ -42,7 +51,6 @@ export class OwnerChannel {
42
51
  authorizations;
43
52
  conversations;
44
53
  tasks;
45
- messageRecovery;
46
54
  attachmentRecovery;
47
55
  attachmentConfig;
48
56
  attachmentRoot;
@@ -51,7 +59,7 @@ export class OwnerChannel {
51
59
  * (a crash must replay them) but must not be queued twice while live.
52
60
  */
53
61
  inFlight = new Set();
54
- /** Wires already NACKed to the managed agent, so a history replay stays quiet. */
62
+ /** Wires already NACKed to the managed agent, so a deferred replay stays quiet. */
55
63
  relayNacks = new Set();
56
64
  /**
57
65
  * fleet.yaml declares the restart baseline; `/comments on|off` changes only
@@ -62,6 +70,7 @@ export class OwnerChannel {
62
70
  commentsBaseline;
63
71
  commentsEnabled;
64
72
  stopping = false;
73
+ watchProcess;
65
74
  watchTask;
66
75
  watchAbort;
67
76
  drainTask;
@@ -75,13 +84,12 @@ export class OwnerChannel {
75
84
  fleetOps;
76
85
  constructor(options) {
77
86
  this.options = options;
78
- 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}`));
79
88
  this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
80
89
  this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
81
90
  this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
82
91
  this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
83
92
  this.tasks = new OwnerTaskState(join(options.stateDir, '.owner-channel-tasks.json'));
84
- this.messageRecovery = new MessageRecoveryState(join(options.stateDir, '.owner-channel-message-recovery.json'));
85
93
  this.attachmentRecovery = new AttachmentRecoveryState(join(options.stateDir, '.owner-channel-attachment-recovery.json'));
86
94
  this.attachmentRoot = join(options.stateDir, '.owner-channel-inbox');
87
95
  // An absent key is a pre-`comments` configuration, which relayed live
@@ -100,8 +108,6 @@ export class OwnerChannel {
100
108
  options.log(`[${options.role}] owner conversation state corrupt; proactive messages disabled`);
101
109
  if (!this.tasks.integrity().ok)
102
110
  options.log(`[${options.role}] owner task state corrupt; proactive reports disabled`);
103
- if (!this.messageRecovery.integrity())
104
- options.log(`[${options.role}] owner message recovery state corrupt; message intake disabled`);
105
111
  if (!this.attachmentRecovery.integrity())
106
112
  options.log(`[${options.role}] owner attachment recovery state corrupt; attachments disabled`);
107
113
  }
@@ -117,16 +123,12 @@ export class OwnerChannel {
117
123
  const bindStartedAt = now();
118
124
  for (;;) {
119
125
  try {
120
- await this.client.bindIdentity(this.options.config.identity);
126
+ await this.client.callTool('choose_identity', { name: this.options.config.identity });
121
127
  break;
122
128
  }
123
129
  catch (error) {
124
- // The predecessor's lease may still be in flight. Only the daemon's
125
- // own typed verdict may extend the handoff window: matching the
126
- // wording of an error message would let any other failure whose text
127
- // happens to say "bound to another live session" — including one
128
- // relayed from a peer — spin here for the whole timeout.
129
- 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);
130
132
  if (!this.binder.inherited || !liveConflict
131
133
  || now() - bindStartedAt >= OWNER_BIND_HANDOFF_TIMEOUT_MS)
132
134
  throw error;
@@ -148,10 +150,9 @@ export class OwnerChannel {
148
150
  void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
149
151
  }
150
152
  this.ready = true;
153
+ this.watchTask = this.options.watch ? this.legacyWatchLoop() : this.watchLoop();
151
154
  // Do not make role startup wait for an old owner request to finish a turn.
152
- // watchLoop itself drains before every establishment, including this first
153
- // one, so there is no drain-to-tip race.
154
- this.watchTask = this.watchLoop();
155
+ void this.drain().catch(error => this.logError('initial drain failed', error));
155
156
  }
156
157
  drain() {
157
158
  this.drainRequested = true;
@@ -168,8 +169,13 @@ export class OwnerChannel {
168
169
  async close() {
169
170
  this.stopping = true;
170
171
  this.ready = false;
172
+ const watch = this.watchProcess;
173
+ this.watchProcess = undefined;
174
+ if (watch && watch.exitCode === null)
175
+ watch.kill('SIGTERM');
171
176
  this.watchAbort?.abort();
172
- 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));
173
179
  this.watchTask = undefined;
174
180
  await this.managementTail;
175
181
  try {
@@ -195,10 +201,13 @@ export class OwnerChannel {
195
201
  ? ' with interruption'
196
202
  : event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
197
203
  const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
204
+ const permission = event.permissionMode
205
+ ? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
206
+ : '';
198
207
  const inherited = event.inherited.length
199
208
  ? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
200
209
  await this.sendProactiveMessage(`🧑‍💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
201
- + `(${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);
202
211
  });
203
212
  this.managementTail = run.then(() => undefined, () => undefined);
204
213
  return run;
@@ -211,14 +220,11 @@ export class OwnerChannel {
211
220
  return { action: request.action, contacts: await this.contacts() };
212
221
  case 'contact_invite': {
213
222
  this.assertLabel(request.name);
214
- // The invite is the blob field, not a sentence containing it. The MCP
215
- // surface answered with "One-time invite for X created (invite_id ).
216
- // Share this blob out-of-band …:\n<blob>" and the whole sentence was
217
- // handed out as the invite, so any rewording changed the payload.
218
- const { blob } = await this.client.generateInvite(request.name);
219
- if (typeof blob !== 'string' || !blob)
220
- throw new Error('the ours daemon returned no invite blob');
221
- 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 };
222
228
  }
223
229
  case 'contact_add': {
224
230
  if (typeof request.invite !== 'string' || !request.invite)
@@ -226,19 +232,18 @@ export class OwnerChannel {
226
232
  if (Buffer.byteLength(request.invite) > 48 * 1024)
227
233
  throw new Error('invite exceeds 49152 bytes');
228
234
  this.assertLabel(request.name);
229
- let added;
235
+ let raw;
230
236
  try {
231
- added = await this.client.addContact({
237
+ raw = await this.client.callTool('add_contact', {
232
238
  invite: request.invite, ...(request.name ? { name: request.name } : {}),
233
239
  });
234
240
  }
235
241
  catch {
236
242
  // Daemon errors are not allowed to reflect invite material through
237
243
  // the control response, CLI stderr, or supervisor logs.
238
- throw new Error('the ours daemon could not accept the contact invite');
244
+ throw new Error('ours-mcp could not accept the contact invite');
239
245
  }
240
- const contact = this.contact(added.cid, added.display, 'pending');
241
- return { action: request.action, status: 'pending', ...(contact ? { contact } : {}) };
246
+ return { action: request.action, status: 'pending', contact: this.contact(raw) };
242
247
  }
243
248
  case 'owner_list':
244
249
  return {
@@ -312,36 +317,33 @@ export class OwnerChannel {
312
317
  throw new Error('unknown owner-channel management action');
313
318
  }
314
319
  }
315
- /**
316
- * The daemon reports established contacts and pending introductions as two
317
- * separate collections, so the status is structural rather than a word parsed
318
- * out of a rendered line. Nothing here can be spoofed by a contact's own
319
- * display name.
320
- */
321
320
  async contacts() {
322
- const view = await this.client.listContacts();
323
- const rows = [
324
- ...(Array.isArray(view?.contacts) ? view.contacts : [])
325
- .map(row => this.contact(row?.container_id, row?.name, 'established', view)),
326
- ...(Array.isArray(view?.pending) ? view.pending : [])
327
- .map(row => this.contact(row?.container_id, row?.name, 'pending', view)),
328
- ];
329
- 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))
330
326
  .sort((a, b) => a.cid.localeCompare(b.cid));
331
327
  }
332
- contact(cidValue, nameValue, status, view) {
333
- 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 ?? '');
334
333
  if (!/^[A-Fa-f0-9]{64}$/.test(cid))
335
334
  return undefined;
336
- const root = view?.roots?.[cid];
337
- const rootCid = String(root?.root_cid ?? '');
335
+ const humanRaw = value.human ?? value.root;
336
+ const human = humanRaw && typeof humanRaw === 'object' ? humanRaw : undefined;
338
337
  return {
339
338
  cid,
340
- name: this.safeMetadata(nameValue ?? cid),
341
- status,
342
- ...(root ? { human: {
343
- ...(/^[A-Fa-f0-9]{64}$/.test(rootCid) ? { cid: rootCid } : {}),
344
- ...(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) } : {}),
345
347
  } } : {}),
346
348
  };
347
349
  }
@@ -523,145 +525,60 @@ export class OwnerChannel {
523
525
  return message;
524
526
  }
525
527
  async drainAll() {
526
- // A finite cap protects the supervisor if a broken daemon never advances
527
- // its oldest-first unread batch. A watch hint will resume draining later.
528
+ // A finite cap protects the supervisor if a broken daemon repeats unread
529
+ // messages forever. A watch notification will resume draining later.
528
530
  for (let pass = 0; pass < 100 && !this.stopping; pass++) {
529
- const [claimed, fileResult] = await Promise.all([
530
- this.claimMessages(),
531
- this.client.listIncomingFiles()
531
+ const [raw, fileResult] = await Promise.all([
532
+ this.client.callTool('get_messages'),
533
+ this.client.callTool('list_incoming_files')
532
534
  .catch(error => {
533
535
  this.logError('attachment metadata inspection unavailable', error);
534
536
  return undefined;
535
537
  }),
536
538
  ]);
539
+ const messages = Array.isArray(raw?.messages)
540
+ ? raw.messages.filter(message => message && typeof message === 'object')
541
+ : [];
542
+ let files = [];
543
+ try {
544
+ files = parseIncomingAttachments(fileResult);
545
+ }
546
+ catch (error) {
547
+ this.logError('attachment metadata inspection unavailable', error);
548
+ }
537
549
  const pending = this.attachmentRecovery.integrity() ? this.attachmentRecovery.list() : [];
538
- let files = await this.attachmentMetadata(fileResult, pending);
539
550
  const pendingWires = new Set(pending.flatMap(item => item.fileWireIds));
540
551
  files = files.filter(file => (file.status === 'unread' || pendingWires.has(file.wireId))
541
552
  && !this.state.has(file.wireId));
542
- if (!claimed.messages.length && !files.length && claimed.remaining === 0)
553
+ if (!messages.length && !files.length)
543
554
  return;
555
+ // get_messages marks the batch processed. Requeue allowed, unhandled
556
+ // inputs before executing them so a mid-turn process crash can replay.
557
+ const deferred = messages.filter(message => {
558
+ const wireId = this.wireId(message);
559
+ return wireId && !this.state.has(wireId)
560
+ && this.acceptedSender(this.sender(message).id)
561
+ && Number.isInteger(message.msg_id);
562
+ }).map(message => message.msg_id);
563
+ if (deferred.length)
564
+ await this.client.callTool('defer_messages', { msg_ids: deferred });
544
565
  let advanced = false;
545
566
  const consumedMessages = new Set();
546
- const groups = this.attachmentGroups(files, claimed.messages, pending, consumedMessages);
567
+ const groups = this.attachmentGroups(files, messages, pending, consumedMessages);
547
568
  for (const group of groups)
548
569
  advanced = await this.handleAttachmentGroup(group) || advanced;
549
- for (const message of claimed.messages) {
570
+ for (const message of messages) {
550
571
  if (!consumedMessages.has(message))
551
572
  advanced = await this.handle(message) || advanced;
552
573
  }
553
- this.messageRecovery.pruneHandled(wireId => this.state.has(wireId));
554
- // Journaled in-flight messages remain history-recoverable until their
555
- // correlated response is delivered. Do not spin on those copies once the
556
- // unread SQLite queue itself is empty; completion triggers another drain.
557
- if (!advanced && claimed.remaining === 0)
574
+ // Deferred in-flight messages are intentionally visible again until
575
+ // their correlated response is delivered. Do not spin on those replay
576
+ // copies; a new watch event or completion-triggered drain will resume.
577
+ if (!advanced)
558
578
  return;
559
579
  }
560
580
  this.options.log(`[${this.options.role}] owner channel drain capped at 100 batches`);
561
581
  }
562
- /**
563
- * Claim the exact oldest unread SQLite batch before marking it read.
564
- * The journal contains only wire IDs and sequence numbers; bodies remain in
565
- * the daemon's persistent history and are recovered with getHistoryItem.
566
- */
567
- async claimMessages() {
568
- if (!this.messageRecovery.integrity())
569
- throw new Error('message recovery state is corrupt; refusing owner message intake');
570
- this.messageRecovery.pruneHandled(wireId => this.state.has(wireId));
571
- const recovered = [];
572
- for (const claim of this.messageRecovery.list()) {
573
- const item = await this.client.getHistoryItem(claim.wireId);
574
- if (!item)
575
- throw new Error(`journaled owner message ${claim.wireId} is missing from persistent history`);
576
- recovered.push(this.historyMessage(item, claim));
577
- }
578
- const listed = await this.client.listIncomingMessages();
579
- if (!Array.isArray(listed))
580
- throw new Error('the ours daemon returned invalid unread message metadata');
581
- const preflight = listed.slice(0, OWNER_MESSAGE_BATCH_LIMIT);
582
- const now = Date.now();
583
- const claims = preflight.map(item => this.messageClaim(item, now));
584
- const claimKeys = new Set(claims.map(item => `${item.seq}\0${item.wireId}`));
585
- if (claimKeys.size !== claims.length)
586
- throw new Error('the ours daemon returned duplicate unread message metadata');
587
- this.messageRecovery.claim(claims);
588
- let fresh = [];
589
- let remaining = 0;
590
- // SDK batchLimit rejects zero. An empty preflight is a read-only drain.
591
- if (claims.length) {
592
- const payload = await this.client.getMessages(claims.length);
593
- if (!payload || !Array.isArray(payload.messages)
594
- || !Number.isSafeInteger(payload.remaining) || payload.remaining < 0)
595
- throw new Error('the ours daemon returned an invalid claimed message batch');
596
- fresh = payload.messages.map(message => this.historyMessage(message));
597
- remaining = payload.remaining;
598
- const expected = claimKeys;
599
- const actual = new Set(fresh.map(item => `${item.seq}\0${item.wire_id}`));
600
- if (fresh.length !== claims.length || actual.size !== fresh.length
601
- || actual.size !== expected.size
602
- || [...expected].some(item => !actual.has(item)))
603
- throw new Error('the ours daemon claimed a different message batch than fleet journaled');
604
- }
605
- const merged = new Map();
606
- for (const message of [...recovered, ...fresh]) {
607
- const wireId = this.wireId(message);
608
- const previous = merged.get(wireId);
609
- if (previous && previous.seq !== message.seq)
610
- throw new Error('persistent message history changed sequence during recovery');
611
- merged.set(wireId, message);
612
- }
613
- return {
614
- messages: [...merged.values()].sort((a, b) => a.seq - b.seq),
615
- remaining,
616
- };
617
- }
618
- messageClaim(message, claimedAt) {
619
- const value = message;
620
- const wireId = String(value.wire_id ?? '').trim();
621
- const seq = Number(value.seq);
622
- if (!wireId || !Number.isSafeInteger(seq) || seq < 1
623
- || value.status !== 'unread' || value.inbox_state !== 'unread')
624
- throw new Error('the ours daemon returned malformed unread message metadata');
625
- return { wireId, seq, claimedAt };
626
- }
627
- historyMessage(message, claim) {
628
- const value = message;
629
- const wireId = String(value.wire_id ?? '').trim();
630
- const seq = Number(value.seq);
631
- const direction = String(value.direction ?? '');
632
- if (!wireId || !Number.isSafeInteger(seq) || seq < 1 || direction !== 'in'
633
- || (claim && (claim.wireId !== wireId || claim.seq !== seq)))
634
- throw new Error('persistent owner message metadata mismatched its recovery claim');
635
- return message;
636
- }
637
- async attachmentMetadata(unread, pending) {
638
- const raw = Array.isArray(unread) ? [...unread] : [];
639
- const present = new Set(raw.map(file => String(file.wire_id ?? '')));
640
- for (const recovery of pending) {
641
- for (const wireId of recovery.fileWireIds) {
642
- if (present.has(wireId))
643
- continue;
644
- const item = await this.client.getFileInfo(wireId);
645
- if (!item)
646
- throw new Error(`journaled owner file ${wireId} is missing from persistent history`);
647
- if (item.wire_id !== wireId || item.direction !== 'in'
648
- || item.inbox_state !== 'read' || item.status !== 'read')
649
- throw new Error(`journaled owner file ${wireId} mismatched persistent history`);
650
- raw.push(item);
651
- present.add(wireId);
652
- }
653
- }
654
- const files = parseIncomingAttachments(raw);
655
- const byWire = new Map(files.map(file => [file.wireId, file]));
656
- for (const recovery of pending) {
657
- for (const wireId of recovery.fileWireIds) {
658
- const file = byWire.get(wireId);
659
- if (!file || file.senderId !== recovery.contact)
660
- throw new Error(`journaled owner file ${wireId} failed recovery provenance validation`);
661
- }
662
- }
663
- return files;
664
- }
665
582
  attachmentGroups(files, messages, pending, consumed) {
666
583
  const groups = [];
667
584
  const used = new Set();
@@ -686,9 +603,9 @@ export class OwnerChannel {
686
603
  ? undefined : messageByWire.get(recovery.originWireId);
687
604
  if (caption && this.sender(caption).id !== recovery.contact)
688
605
  continue;
689
- // A managed-agent caption is claimed before its inbox row becomes read.
690
- // If recovery sees a read file before that body is recovered from history,
691
- // keep the file reserved by the journal until both halves are present.
606
+ // A managed-agent caption is deferred before retrieval. If recovery sees
607
+ // the processed file before that body is replayed, keep the file reserved
608
+ // by the journal until both halves are present again.
692
609
  if (!caption && !recovery.fileWireIds.includes(recovery.originWireId))
693
610
  continue;
694
611
  if (caption)
@@ -773,14 +690,17 @@ export class OwnerChannel {
773
690
  this.attachmentRecovery.add(recovery);
774
691
  requestDir = await prepareAttachmentDirectory(this.attachmentRoot, requestId);
775
692
  const unread = group.files.filter(file => file.status === 'unread');
776
- const historyRecovered = group.files.filter(file => file.status !== 'unread');
693
+ const processed = group.files.filter(file => file.status !== 'unread');
777
694
  const retrieved = unread.length
778
- ? 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)
779
698
  : [];
780
- for (const file of historyRecovered) {
699
+ for (const file of processed) {
781
700
  if (!group.recovery)
782
- throw new Error('unexpected read attachment without recovery route');
783
- const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
701
+ throw new Error('unexpected processed attachment without recovery route');
702
+ const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
703
+ await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
784
704
  retrieved.push(await recoveredAttachment(file, recoveryPath));
785
705
  }
786
706
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
@@ -789,17 +709,13 @@ export class OwnerChannel {
789
709
  outbox = this.outboxDir(originWireId);
790
710
  await mkdir(outbox, { recursive: true, mode: 0o700 });
791
711
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
792
- 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), {
793
713
  interrupt: this.options.config.interrupt,
794
714
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
795
715
  origin: { kind: 'owner', requestId,
796
716
  ...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
797
717
  });
798
- const accepted = this.options.config.interrupt
799
- ? ownerNotices.receivedInterrupting()
800
- : queued.queuedBehind > 0
801
- ? ownerNotices.receivedQueued(queued.queuedBehind)
802
- : ownerNotices.receivedStarted();
718
+ const accepted = this.acceptanceNotice(queued);
803
719
  handledWireIds.forEach(wire => this.inFlight.add(wire));
804
720
  const receipt = this.send(sender.id, accepted, originWireId).then(() => undefined).catch(error => {
805
721
  this.logError(`attachment request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -872,17 +788,17 @@ export class OwnerChannel {
872
788
  this.options.log(`[${this.options.role}] managed-agent relay replay of a delivered wire consumed`);
873
789
  }
874
790
  else if (error instanceof RelayUnroutableError) {
875
- // No owner route exists yet. Leave the wire journaled and unconsumed
876
- // so persistent history replays it after the first owner contact, and
877
- // tell the authenticated agent once so the wait is never silent.
791
+ // No owner route exists yet. Leave the wire deferred and unconsumed
792
+ // so the daemon replays it after the first owner contact, and tell
793
+ // the authenticated agent once so the wait is never silent.
878
794
  this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
879
795
  + `route yet; message stays queued: ${this.errorText(error)}`);
880
- await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayQueued());
796
+ await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayQueued());
881
797
  return false;
882
798
  }
883
799
  else {
884
800
  this.logError('managed-agent message relay refused', error);
885
- 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)));
886
802
  }
887
803
  }
888
804
  this.state.remember(wireId);
@@ -918,7 +834,7 @@ export class OwnerChannel {
918
834
  let queued;
919
835
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
920
836
  try {
921
- queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
837
+ queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId), {
922
838
  interrupt: this.options.config.interrupt,
923
839
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
924
840
  origin: { kind: 'owner', requestId, displayText: text },
@@ -928,9 +844,9 @@ export class OwnerChannel {
928
844
  await rm(outbox, { recursive: true, force: true });
929
845
  if (error instanceof SessionControlError
930
846
  && error.reasonCode === ACP_CANCEL_DEADLINE_EXCEEDED) {
931
- // drainAll journaled this authenticated message before delivery. The
847
+ // drainAll deferred this authenticated message before delivery. The
932
848
  // adapter generation is terminating, so leave the wire unhandled and
933
- // body-free: the resumed owner channel recovers it exactly once.
849
+ // body-free: the resumed owner channel will replay it exactly once.
934
850
  this.options.log(`[${this.options.role}] owner request ${requestId.slice(0, 12)} `
935
851
  + `held for adapter resume reason=${ACP_CANCEL_DEADLINE_EXCEEDED}`);
936
852
  return false;
@@ -940,14 +856,7 @@ export class OwnerChannel {
940
856
  this.state.remember(wireId);
941
857
  return true;
942
858
  }
943
- // Interrupting the live turn does not remove prompts which were already
944
- // accepted into the ACP queue. Never claim this request is running while
945
- // the session itself says earlier work remains ahead of it.
946
- const accepted = queued.queuedBehind > 0
947
- ? ownerNotices.receivedQueued(queued.queuedBehind)
948
- : this.options.config.interrupt
949
- ? ownerNotices.receivedInterrupting()
950
- : ownerNotices.receivedStarted();
859
+ const accepted = this.acceptanceNotice(queued);
951
860
  this.inFlight.add(wireId);
952
861
  const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
953
862
  this.logError(`request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -1127,9 +1036,7 @@ export class OwnerChannel {
1127
1036
  async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
1128
1037
  const captionWire = group.caption ? this.wireId(group.caption) : undefined;
1129
1038
  const nackWire = captionWire ?? group.files[0].wireId;
1130
- // A caption whose own wire id is synthetic (msg_id only) must not be echoed
1131
- // back as a reply reference; a file wire always is a real one.
1132
- const nackReplyTo = (group.caption ? group.caption.wire_id : nackWire) ? nackWire : undefined;
1039
+ const nackMessage = group.caption ?? { wire_id: nackWire };
1133
1040
  let requestDir;
1134
1041
  let recovery;
1135
1042
  try {
@@ -1163,14 +1070,17 @@ export class OwnerChannel {
1163
1070
  this.attachmentRecovery.add(recovery);
1164
1071
  requestDir = await prepareAttachmentDirectory(this.attachmentRoot, transactionId);
1165
1072
  const unread = group.files.filter(file => file.status === 'unread');
1166
- const historyRecovered = group.files.filter(file => file.status !== 'unread');
1073
+ const processed = group.files.filter(file => file.status !== 'unread');
1167
1074
  const retrieved = unread.length
1168
- ? 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)
1169
1078
  : [];
1170
- for (const file of historyRecovered) {
1079
+ for (const file of processed) {
1171
1080
  if (!group.recovery)
1172
- throw new Error('unexpected read attachment without recovery route');
1173
- const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
1081
+ throw new Error('unexpected processed attachment without recovery route');
1082
+ const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
1083
+ await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
1174
1084
  retrieved.push(await recoveredAttachment(file, recoveryPath));
1175
1085
  }
1176
1086
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
@@ -1182,9 +1092,9 @@ export class OwnerChannel {
1182
1092
  if (caption)
1183
1093
  await this.send(contact, caption, replyTo);
1184
1094
  for (const file of admitted) {
1185
- await this.client.sendFile({
1095
+ await this.client.callTool('send_file', {
1186
1096
  contact, path: file.path, filename: file.filename,
1187
- ...(replyTo ? { replyToWireId: replyTo } : {}),
1097
+ ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
1188
1098
  });
1189
1099
  }
1190
1100
  }
@@ -1221,11 +1131,11 @@ export class OwnerChannel {
1221
1131
  if (error instanceof RelayUnroutableError) {
1222
1132
  this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
1223
1133
  + `transaction stays queued: ${this.errorText(error)}`);
1224
- await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayQueued());
1134
+ await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayQueued());
1225
1135
  return false;
1226
1136
  }
1227
1137
  this.logError('managed-agent caption/file relay refused', error);
1228
- await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayRefused(this.errorText(error)));
1138
+ await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayRefused(this.errorText(error)));
1229
1139
  // Rejection/admission failure and uncertain transport are terminal and
1230
1140
  // visible. Consuming every correlated wire prevents a later partial replay.
1231
1141
  for (const wire of handledWireIds)
@@ -1261,17 +1171,17 @@ export class OwnerChannel {
1261
1171
  }
1262
1172
  /**
1263
1173
  * One bounded NACK per wire: an unroutable or refused relay must be visible
1264
- * to the authenticated agent, while its history replays stay quiet. NACK
1174
+ * to the authenticated agent, while its deferred replays stay quiet. NACK
1265
1175
  * delivery is best-effort — it must never make the failure worse.
1266
1176
  */
1267
- async nackManagedAgent(contact, replyTo, wireId, notice) {
1177
+ async nackManagedAgent(contact, message, wireId, notice) {
1268
1178
  if (this.relayNacks.has(wireId))
1269
1179
  return;
1270
1180
  this.relayNacks.add(wireId);
1271
1181
  if (this.relayNacks.size > RELAY_NACK_MEMORY)
1272
1182
  this.relayNacks.delete(this.relayNacks.values().next().value);
1273
1183
  try {
1274
- await this.send(contact, notice, replyTo);
1184
+ await this.send(contact, notice, message.wire_id ? wireId : undefined);
1275
1185
  }
1276
1186
  catch (error) {
1277
1187
  this.logError('managed-agent relay NACK delivery failed', error);
@@ -1332,6 +1242,32 @@ export class OwnerChannel {
1332
1242
  authorizationIntegrity() {
1333
1243
  return this.options.config.agent ? { ok: true } : this.authorizations.integrity();
1334
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
+ }
1335
1271
  async complete(active, outbox, queued, activityCursor) {
1336
1272
  const progressMs = this.options.config.progress_interval_ms;
1337
1273
  let lastSeq = activityCursor;
@@ -1532,7 +1468,7 @@ export class OwnerChannel {
1532
1468
  throw new Error('commentary appears to contain secret, reasoning, or raw tool content');
1533
1469
  return message;
1534
1470
  }
1535
- ownerAttachmentPrompt(sender, wireId, requestId, outbox, files, caption) {
1471
+ ownerAttachmentPrompt(sender, wireId, requestId, files, caption) {
1536
1472
  const lines = [
1537
1473
  '[fleet-owner]',
1538
1474
  `Authenticated owner ${safeField(sender.name, 160)} (${sender.id}) sent owner-channel attachment request ${wireId}.`,
@@ -1556,10 +1492,13 @@ export class OwnerChannel {
1556
1492
  lines.push(`- voice transcript status: ${transcription?.status ?? 'unavailable'}`, `- voice transcript fallback: audio path above; category ${transcription?.errorCategory ?? 'not_provided'}`);
1557
1493
  }
1558
1494
  }
1559
- 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.');
1560
1499
  return lines.join('\n');
1561
1500
  }
1562
- ownerPrompt(sender, text, wireId, outbox) {
1501
+ ownerPrompt(sender, text, wireId) {
1563
1502
  return [
1564
1503
  '[fleet-owner]',
1565
1504
  `Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
@@ -1573,10 +1512,13 @@ export class OwnerChannel {
1573
1512
  ] : [
1574
1513
  'Managed-agent outbound relay is not configured; do not send intermediate or proactive owner-channel messages.',
1575
1514
  ]),
1576
- 'To attach files to your response, copy each finished file directly into this fleet outbox:',
1577
- outbox,
1578
- 'Fleet sends every regular file in that directory to the authenticated owner, correlated to this request.',
1579
- '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.',
1580
1522
  '',
1581
1523
  text || '(empty message)',
1582
1524
  ].join('\n');
@@ -1589,8 +1531,8 @@ export class OwnerChannel {
1589
1531
  return createHash('sha256').update(wireId).digest('hex');
1590
1532
  }
1591
1533
  send(contact, text, replyTo) {
1592
- return this.client.sendMessage({
1593
- contact, text, ...(replyTo ? { replyToWireId: replyTo } : {}),
1534
+ return this.client.callTool('send_message', {
1535
+ contact, text, ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
1594
1536
  });
1595
1537
  }
1596
1538
  async sendAttachments(contact, outbox, replyTo) {
@@ -1598,11 +1540,11 @@ export class OwnerChannel {
1598
1540
  .filter(entry => entry.isFile())
1599
1541
  .sort((a, b) => a.name.localeCompare(b.name));
1600
1542
  for (const entry of entries) {
1601
- await this.client.sendFile({
1543
+ await this.client.callTool('send_file', {
1602
1544
  contact,
1603
1545
  path: join(outbox, entry.name),
1604
1546
  filename: entry.name,
1605
- replyToWireId: replyTo,
1547
+ reply_to_wire_id: replyTo,
1606
1548
  });
1607
1549
  }
1608
1550
  await rm(outbox, { recursive: true, force: true });
@@ -1625,11 +1567,11 @@ export class OwnerChannel {
1625
1567
  return Number.isInteger(message.msg_id) ? `msg:${message.msg_id}` : '';
1626
1568
  }
1627
1569
  sender(message) {
1628
- // Authenticated routing data, straight from the daemon's typed envelope.
1629
- // The id still goes through the CID checks in acceptedSender/isEffectiveOwner.
1630
- const source = message.from;
1631
- const id = String(source?.id ?? '');
1632
- 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) };
1633
1575
  }
1634
1576
  latestEventSeq(events) {
1635
1577
  return events.reduce((latest, event) => Math.max(latest, event.seq), 0);
@@ -1658,65 +1600,90 @@ export class OwnerChannel {
1658
1600
  return undefined;
1659
1601
  }
1660
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));
1661
1606
  const sleep = this.options.binderDeps?.sleep
1662
1607
  ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
1663
1608
  const restored = this.readWatchState();
1664
1609
  let state = restored.state;
1665
- if (restored.recovered)
1666
- 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';
1667
1615
  let delayMs = 1_000;
1668
- let attempts = 0;
1616
+ let authFailures = 0;
1617
+ if (recovering)
1618
+ await this.drain().catch(error => this.logError('cursor recovery drain failed', error));
1669
1619
  while (!this.stopping) {
1670
1620
  const ctrl = new AbortController();
1671
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?.();
1672
1626
  try {
1673
- // This drain is unconditional at EVERY establishment. Starting the SDK
1674
- // stream at 0 then replays notification hints instead of tip-priming,
1675
- // so mail arriving after the drain but before the first request cannot
1676
- // fall into a gap. Persistent history plus fleet's body-free claim
1677
- // journal is authoritative; durable wire-ID dedupe makes hints harmless.
1678
- await this.drain();
1679
- if (this.stopping)
1680
- return;
1681
- state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTING', { reconnected: attempts > 0 });
1682
- attempts++;
1683
- for await (const _event of this.client.watchNotifications(this.options.config.identity, { since: 0, signal: ctrl.signal })) {
1684
- 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');
1685
1637
  return;
1686
- state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTED', { resetFailures: true });
1687
- delayMs = 1_000;
1688
- // Notification events contain no bodies and are only wake hints.
1689
- // Draining is idempotent at the turn boundary because wire IDs are
1690
- // recorded before a managed request is dispatched.
1691
- 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');
1692
1641
  }
1693
- if (!this.stopping)
1694
- 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();
1695
1656
  }
1696
1657
  catch (error) {
1697
1658
  if (this.stopping)
1698
1659
  return;
1699
- // SDK 2 deliberately hides transport status behind its typed stream.
1700
- // Do not parse error prose to rediscover it: every failure follows the
1701
- // same capped retry path forever, and the pre-establishment drain makes
1702
- // that retry correctness-preserving.
1703
- 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);
1704
1669
  this.options.log(`[${this.options.role}] owner watch reconnect `
1705
- + `reason=OWNER_WATCH_STREAM_ERROR delay_ms=${delayMs} `
1706
- + `failures=${state.consecutiveFailures}: ${this.errorText(error)}`);
1670
+ + `reason=${authRejected ? 'OWNER_WATCH_AUTH_FAILED' : reason} `
1671
+ + `delay_ms=${delayMs} cursor=${current}`);
1707
1672
  await sleep(delayMs);
1708
1673
  delayMs = Math.min(delayMs * 2, OWNER_WATCH_BACKOFF_MAX_MS);
1709
1674
  }
1710
1675
  finally {
1676
+ clearTimeout(timer);
1711
1677
  if (this.watchAbort === ctrl)
1712
1678
  this.watchAbort = undefined;
1713
1679
  }
1714
1680
  }
1715
1681
  }
1716
1682
  /**
1717
- * `recovered` distinguishes a first-ever start from unreadable persisted
1718
- * diagnostics. Notification correctness does not depend on this state: every
1719
- * 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.
1720
1687
  */
1721
1688
  readWatchState() {
1722
1689
  const path = join(this.options.stateDir, '.owner-channel-watch.json');
@@ -1724,43 +1691,76 @@ export class OwnerChannel {
1724
1691
  return { recovered: false };
1725
1692
  try {
1726
1693
  const value = JSON.parse(readFileSync(path, 'utf8'));
1727
- if ((value.version !== 1 && value.version !== 2)
1694
+ if (value.version !== 1 || !Number.isSafeInteger(value.cursor) || value.cursor < 0
1728
1695
  || !Number.isSafeInteger(value.reconnects) || value.reconnects < 0
1729
1696
  || !Number.isSafeInteger(value.consecutiveFailures) || value.consecutiveFailures < 0)
1730
1697
  throw new Error('invalid owner watch state');
1731
- const reasons = new Set([
1732
- 'OWNER_WATCH_CONNECTING', 'OWNER_WATCH_CONNECTED',
1733
- 'OWNER_WATCH_STREAM_ERROR', 'OWNER_WATCH_STATE_RECOVERED',
1734
- ]);
1735
- return { state: {
1736
- version: 2,
1737
- reconnects: value.reconnects,
1738
- consecutiveFailures: value.consecutiveFailures,
1739
- reason: reasons.has(value.reason)
1740
- ? value.reason : 'OWNER_WATCH_CONNECTING',
1741
- updatedAt: typeof value.updatedAt === 'string'
1742
- ? value.updatedAt : new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
1743
- }, recovered: false };
1698
+ return { state: value, recovered: false };
1744
1699
  }
1745
1700
  catch {
1746
1701
  this.options.log(`[${this.options.role}] owner watch `
1747
- + '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');
1748
1703
  return { recovered: true };
1749
1704
  }
1750
1705
  }
1751
- writeWatchState(previous, reason, options = {}) {
1706
+ writeWatchState(previous, cursor, reason, failed, reconnected = false) {
1752
1707
  const state = {
1753
- version: 2,
1754
- reconnects: (previous?.reconnects ?? 0) + (options.reconnected ? 1 : 0),
1755
- consecutiveFailures: options.failed
1756
- ? (previous?.consecutiveFailures ?? 0) + 1
1757
- : 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,
1758
1712
  reason,
1759
1713
  updatedAt: new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
1760
1714
  };
1761
1715
  replaceFileAtomically(join(this.options.stateDir, '.owner-channel-watch.json'), `${JSON.stringify(state)}\n`, 0o600);
1762
1716
  return state;
1763
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
+ }
1764
1764
  errorText(error) {
1765
1765
  return error?.message ?? String(error);
1766
1766
  }