@ours.network/fleet 0.18.1 → 0.19.0-nightly.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +57 -91
  2. package/dist/application/fleet-query-service.js +0 -12
  3. package/dist/application/role-creation-service.js +10 -7
  4. package/dist/application/types.d.ts +0 -11
  5. package/dist/briefing.js +6 -15
  6. package/dist/build-info.json +5 -5
  7. package/dist/cli.js +12 -37
  8. package/dist/config.d.ts +8 -6
  9. package/dist/config.js +46 -30
  10. package/dist/creation.d.ts +38 -22
  11. package/dist/creation.js +111 -24
  12. package/dist/docs.d.ts +1 -1
  13. package/dist/docs.js +44 -95
  14. package/dist/doctor.d.ts +5 -1
  15. package/dist/doctor.js +18 -11
  16. package/dist/fleet-proxy.d.ts +0 -5
  17. package/dist/harness/acp-agent.js +6 -11
  18. package/dist/harness/claude-code.js +11 -200
  19. package/dist/harness/codex.d.ts +1 -4
  20. package/dist/harness/codex.js +12 -70
  21. package/dist/harness/types.d.ts +4 -54
  22. package/dist/loops/manager.d.ts +1 -30
  23. package/dist/loops/manager.js +6 -69
  24. package/dist/loops/state.d.ts +0 -18
  25. package/dist/loops/state.js +0 -4
  26. package/dist/monitor.js +1 -1
  27. package/dist/ops.d.ts +3 -0
  28. package/dist/ops.js +8 -3
  29. package/dist/owner-channel/attachments.d.ts +25 -2
  30. package/dist/owner-channel/attachments.js +61 -5
  31. package/dist/owner-channel/channel.d.ts +29 -30
  32. package/dist/owner-channel/channel.js +291 -291
  33. package/dist/owner-channel/commands.js +89 -0
  34. package/dist/owner-channel/message-recovery.d.ts +25 -0
  35. package/dist/owner-channel/message-recovery.js +114 -0
  36. package/dist/owner-channel/notices.d.ts +0 -7
  37. package/dist/owner-channel/notices.js +0 -9
  38. package/dist/owner-channel/ours-client.d.ts +148 -0
  39. package/dist/owner-channel/ours-client.js +231 -0
  40. package/dist/rooms-tasks/cli.d.ts +4 -0
  41. package/dist/rooms-tasks/cli.js +565 -0
  42. package/dist/rooms-tasks/config.d.ts +15 -0
  43. package/dist/rooms-tasks/config.js +171 -0
  44. package/dist/rooms-tasks/cowork-adapter.d.ts +80 -0
  45. package/dist/rooms-tasks/cowork-adapter.js +48 -0
  46. package/dist/rooms-tasks/index.d.ts +7 -0
  47. package/dist/rooms-tasks/index.js +7 -0
  48. package/dist/rooms-tasks/room-state.d.ts +22 -0
  49. package/dist/rooms-tasks/room-state.js +117 -0
  50. package/dist/rooms-tasks/task-state.d.ts +31 -0
  51. package/dist/rooms-tasks/task-state.js +173 -0
  52. package/dist/rooms-tasks/templates.d.ts +6 -0
  53. package/dist/rooms-tasks/templates.js +80 -0
  54. package/dist/rooms-tasks/types.d.ts +153 -0
  55. package/dist/rooms-tasks/types.js +20 -0
  56. package/dist/runner.d.ts +0 -48
  57. package/dist/runner.js +94 -236
  58. package/dist/session/acp.d.ts +0 -104
  59. package/dist/session/acp.js +10 -213
  60. package/dist/session/conversation-normalizer.d.ts +0 -6
  61. package/dist/session/conversation-normalizer.js +10 -153
  62. package/dist/session/conversation-types.d.ts +4 -23
  63. package/dist/session/types.d.ts +0 -35
  64. package/dist/spawn.js +26 -33
  65. package/dist/supervisor/systemd.js +29 -2
  66. package/dist/watchdog/briefing.js +0 -7
  67. package/dist/watchdog/run.js +3 -3
  68. package/dist/web-app/assets/{TerminalView-C_G1ID2P.js → TerminalView-BAVk1Bot.js} +1 -1
  69. package/dist/web-app/assets/{index-BCBK78hw.js → index-C3S-xFRU.js} +5 -5
  70. package/dist/web-app/index.html +1 -1
  71. package/dist/worklog.d.ts +1 -7
  72. package/dist/worklog.js +39 -191
  73. package/package.json +3 -1
  74. package/dist/model-env.d.ts +0 -71
  75. package/dist/model-env.js +0 -106
  76. package/dist/owner-channel/mcp.d.ts +0 -24
  77. package/dist/owner-channel/mcp.js +0 -145
  78. package/dist/session/activity.d.ts +0 -31
  79. package/dist/session/activity.js +0 -48
@@ -1,21 +1,19 @@
1
- import { spawn } from 'node:child_process';
2
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { createHash } from 'node:crypto';
3
2
  import { existsSync, readFileSync } from 'node:fs';
4
3
  import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
5
- import { createInterface } from 'node:readline';
6
4
  import { join } from 'node:path';
7
5
  import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
8
6
  import { replaceFileAtomically } from '../atomic-file.js';
9
- import { resolveEndpoint } from '../monitor.js';
10
7
  import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, interruptOutcome, } from '../session/types.js';
11
8
  import { VERSION } from '../version.js';
12
9
  import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
13
- import { OursMcpClient } from './mcp.js';
10
+ import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
14
11
  import { ownerNotices, } from './notices.js';
15
12
  import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
16
13
  import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
17
- import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, } from './attachments.js';
14
+ import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, writeRecoveredAttachment, } from './attachments.js';
18
15
  import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
16
+ import { MessageRecoveryState } from './message-recovery.js';
19
17
  const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
20
18
  const OWNER_UPDATE_MAX_COUNT = 20;
21
19
  const OWNER_UPDATE_MAX_CHARS = 280;
@@ -28,15 +26,8 @@ const COMMENTARY_MAX_CHARS = 1_600;
28
26
  const COMMENTARY_MAX_BYTES = 6_400;
29
27
  const COMMENTARY_MAX_UPDATES = 32;
30
28
  const COMMENTARY_DEDUPE_LIMIT = 512;
31
- const OWNER_WATCH_STALL_MS = 120_000;
32
29
  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;
30
+ const OWNER_MESSAGE_BATCH_LIMIT = 200;
40
31
  /** A relay attempt that failed only because no owner route exists yet. */
41
32
  class RelayUnroutableError extends Error {
42
33
  }
@@ -51,6 +42,7 @@ export class OwnerChannel {
51
42
  authorizations;
52
43
  conversations;
53
44
  tasks;
45
+ messageRecovery;
54
46
  attachmentRecovery;
55
47
  attachmentConfig;
56
48
  attachmentRoot;
@@ -59,7 +51,7 @@ export class OwnerChannel {
59
51
  * (a crash must replay them) but must not be queued twice while live.
60
52
  */
61
53
  inFlight = new Set();
62
- /** Wires already NACKed to the managed agent, so a deferred replay stays quiet. */
54
+ /** Wires already NACKed to the managed agent, so a history replay stays quiet. */
63
55
  relayNacks = new Set();
64
56
  /**
65
57
  * fleet.yaml declares the restart baseline; `/comments on|off` changes only
@@ -70,7 +62,6 @@ export class OwnerChannel {
70
62
  commentsBaseline;
71
63
  commentsEnabled;
72
64
  stopping = false;
73
- watchProcess;
74
65
  watchTask;
75
66
  watchAbort;
76
67
  drainTask;
@@ -84,12 +75,13 @@ export class OwnerChannel {
84
75
  fleetOps;
85
76
  constructor(options) {
86
77
  this.options = options;
87
- this.client = options.client ?? new OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
78
+ this.client = options.client ?? new OursSdkClient(options.env, line => options.log(`[${options.role}] owner channel ${line}`));
88
79
  this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
89
80
  this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
90
81
  this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
91
82
  this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
92
83
  this.tasks = new OwnerTaskState(join(options.stateDir, '.owner-channel-tasks.json'));
84
+ this.messageRecovery = new MessageRecoveryState(join(options.stateDir, '.owner-channel-message-recovery.json'));
93
85
  this.attachmentRecovery = new AttachmentRecoveryState(join(options.stateDir, '.owner-channel-attachment-recovery.json'));
94
86
  this.attachmentRoot = join(options.stateDir, '.owner-channel-inbox');
95
87
  // An absent key is a pre-`comments` configuration, which relayed live
@@ -108,6 +100,8 @@ export class OwnerChannel {
108
100
  options.log(`[${options.role}] owner conversation state corrupt; proactive messages disabled`);
109
101
  if (!this.tasks.integrity().ok)
110
102
  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`);
111
105
  if (!this.attachmentRecovery.integrity())
112
106
  options.log(`[${options.role}] owner attachment recovery state corrupt; attachments disabled`);
113
107
  }
@@ -123,12 +117,16 @@ export class OwnerChannel {
123
117
  const bindStartedAt = now();
124
118
  for (;;) {
125
119
  try {
126
- await this.client.callTool('choose_identity', { name: this.options.config.identity });
120
+ await this.client.bindIdentity(this.options.config.identity);
127
121
  break;
128
122
  }
129
123
  catch (error) {
130
- const message = error?.message ?? String(error);
131
- const liveConflict = /currently bound to another live session/i.test(message);
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;
132
130
  if (!this.binder.inherited || !liveConflict
133
131
  || now() - bindStartedAt >= OWNER_BIND_HANDOFF_TIMEOUT_MS)
134
132
  throw error;
@@ -150,9 +148,10 @@ export class OwnerChannel {
150
148
  void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
151
149
  }
152
150
  this.ready = true;
153
- this.watchTask = this.options.watch ? this.legacyWatchLoop() : this.watchLoop();
154
151
  // Do not make role startup wait for an old owner request to finish a turn.
155
- void this.drain().catch(error => this.logError('initial drain failed', error));
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();
156
155
  }
157
156
  drain() {
158
157
  this.drainRequested = true;
@@ -169,13 +168,8 @@ export class OwnerChannel {
169
168
  async close() {
170
169
  this.stopping = true;
171
170
  this.ready = false;
172
- const watch = this.watchProcess;
173
- this.watchProcess = undefined;
174
- if (watch && watch.exitCode === null)
175
- watch.kill('SIGTERM');
176
171
  this.watchAbort?.abort();
177
- if (!this.options.watch)
178
- await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
172
+ await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
179
173
  this.watchTask = undefined;
180
174
  await this.managementTail;
181
175
  try {
@@ -201,13 +195,10 @@ export class OwnerChannel {
201
195
  ? ' with interruption'
202
196
  : event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
203
197
  const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
204
- const permission = event.permissionMode
205
- ? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
206
- : '';
207
198
  const inherited = event.inherited.length
208
199
  ? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
209
200
  await this.sendProactiveMessage(`🧑‍💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
210
- + `(${event.harness}/${event.session}${model}; ${monitor}${permission}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
201
+ + `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
211
202
  });
212
203
  this.managementTail = run.then(() => undefined, () => undefined);
213
204
  return run;
@@ -220,11 +211,14 @@ export class OwnerChannel {
220
211
  return { action: request.action, contacts: await this.contacts() };
221
212
  case 'contact_invite': {
222
213
  this.assertLabel(request.name);
223
- const raw = await this.client.callTool('generate_invite', request.name ? { name: request.name } : {});
224
- const invite = typeof raw === 'string' ? raw : String(raw?.invite ?? '');
225
- if (!invite)
226
- throw new Error('ours-mcp returned no invite');
227
- return { action: request.action, invite };
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 };
228
222
  }
229
223
  case 'contact_add': {
230
224
  if (typeof request.invite !== 'string' || !request.invite)
@@ -232,18 +226,19 @@ export class OwnerChannel {
232
226
  if (Buffer.byteLength(request.invite) > 48 * 1024)
233
227
  throw new Error('invite exceeds 49152 bytes');
234
228
  this.assertLabel(request.name);
235
- let raw;
229
+ let added;
236
230
  try {
237
- raw = await this.client.callTool('add_contact', {
231
+ added = await this.client.addContact({
238
232
  invite: request.invite, ...(request.name ? { name: request.name } : {}),
239
233
  });
240
234
  }
241
235
  catch {
242
236
  // Daemon errors are not allowed to reflect invite material through
243
237
  // the control response, CLI stderr, or supervisor logs.
244
- throw new Error('ours-mcp could not accept the contact invite');
238
+ throw new Error('the ours daemon could not accept the contact invite');
245
239
  }
246
- return { action: request.action, status: 'pending', contact: this.contact(raw) };
240
+ const contact = this.contact(added.cid, added.display, 'pending');
241
+ return { action: request.action, status: 'pending', ...(contact ? { contact } : {}) };
247
242
  }
248
243
  case 'owner_list':
249
244
  return {
@@ -317,33 +312,36 @@ export class OwnerChannel {
317
312
  throw new Error('unknown owner-channel management action');
318
313
  }
319
314
  }
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
+ */
320
321
  async contacts() {
321
- const raw = await this.client.callTool('list_contacts');
322
- const values = Array.isArray(raw) ? raw : raw?.contacts;
323
- if (!Array.isArray(values))
324
- return [];
325
- return values.map(value => this.contact(value)).filter((v) => Boolean(v))
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))
326
330
  .sort((a, b) => a.cid.localeCompare(b.cid));
327
331
  }
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 ?? '');
332
+ contact(cidValue, nameValue, status, view) {
333
+ const cid = String(cidValue ?? '');
333
334
  if (!/^[A-Fa-f0-9]{64}$/.test(cid))
334
335
  return undefined;
335
- const humanRaw = value.human ?? value.root;
336
- const human = humanRaw && typeof humanRaw === 'object' ? humanRaw : undefined;
336
+ const root = view?.roots?.[cid];
337
+ const rootCid = String(root?.root_cid ?? '');
337
338
  return {
338
339
  cid,
339
- name: this.safeMetadata(value.name ?? value.display_name ?? cid),
340
- status: this.safeMetadata(value.status ?? 'established'),
341
- ...(typeof value.kind === 'string' ? { kind: this.safeMetadata(value.kind) } : {}),
342
- ...(human ? { human: {
343
- ...(typeof (human.cid ?? human.id) === 'string'
344
- && /^[A-Fa-f0-9]{64}$/.test(String(human.cid ?? human.id))
345
- ? { cid: String(human.cid ?? human.id) } : {}),
346
- ...(human.name ? { name: this.safeMetadata(human.name) } : {}),
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) } : {}),
347
345
  } } : {}),
348
346
  };
349
347
  }
@@ -525,60 +523,145 @@ export class OwnerChannel {
525
523
  return message;
526
524
  }
527
525
  async drainAll() {
528
- // A finite cap protects the supervisor if a broken daemon repeats unread
529
- // messages forever. A watch notification will resume draining later.
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.
530
528
  for (let pass = 0; pass < 100 && !this.stopping; pass++) {
531
- const [raw, fileResult] = await Promise.all([
532
- this.client.callTool('get_messages'),
533
- this.client.callTool('list_incoming_files')
529
+ const [claimed, fileResult] = await Promise.all([
530
+ this.claimMessages(),
531
+ this.client.listIncomingFiles()
534
532
  .catch(error => {
535
533
  this.logError('attachment metadata inspection unavailable', error);
536
534
  return undefined;
537
535
  }),
538
536
  ]);
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
- }
549
537
  const pending = this.attachmentRecovery.integrity() ? this.attachmentRecovery.list() : [];
538
+ let files = await this.attachmentMetadata(fileResult, pending);
550
539
  const pendingWires = new Set(pending.flatMap(item => item.fileWireIds));
551
540
  files = files.filter(file => (file.status === 'unread' || pendingWires.has(file.wireId))
552
541
  && !this.state.has(file.wireId));
553
- if (!messages.length && !files.length)
542
+ if (!claimed.messages.length && !files.length && claimed.remaining === 0)
554
543
  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 });
565
544
  let advanced = false;
566
545
  const consumedMessages = new Set();
567
- const groups = this.attachmentGroups(files, messages, pending, consumedMessages);
546
+ const groups = this.attachmentGroups(files, claimed.messages, pending, consumedMessages);
568
547
  for (const group of groups)
569
548
  advanced = await this.handleAttachmentGroup(group) || advanced;
570
- for (const message of messages) {
549
+ for (const message of claimed.messages) {
571
550
  if (!consumedMessages.has(message))
572
551
  advanced = await this.handle(message) || advanced;
573
552
  }
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)
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)
578
558
  return;
579
559
  }
580
560
  this.options.log(`[${this.options.role}] owner channel drain capped at 100 batches`);
581
561
  }
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
+ }
582
665
  attachmentGroups(files, messages, pending, consumed) {
583
666
  const groups = [];
584
667
  const used = new Set();
@@ -603,9 +686,9 @@ export class OwnerChannel {
603
686
  ? undefined : messageByWire.get(recovery.originWireId);
604
687
  if (caption && this.sender(caption).id !== recovery.contact)
605
688
  continue;
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.
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.
609
692
  if (!caption && !recovery.fileWireIds.includes(recovery.originWireId))
610
693
  continue;
611
694
  if (caption)
@@ -690,17 +773,14 @@ export class OwnerChannel {
690
773
  this.attachmentRecovery.add(recovery);
691
774
  requestDir = await prepareAttachmentDirectory(this.attachmentRoot, requestId);
692
775
  const unread = group.files.filter(file => file.status === 'unread');
693
- const processed = group.files.filter(file => file.status !== 'unread');
776
+ const historyRecovered = group.files.filter(file => file.status !== 'unread');
694
777
  const retrieved = unread.length
695
- ? parseRetrievedAttachments(await this.client.callTool('get_files', {
696
- wire_ids: unread.map(file => file.wireId),
697
- }), unread)
778
+ ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
698
779
  : [];
699
- for (const file of processed) {
780
+ for (const file of historyRecovered) {
700
781
  if (!group.recovery)
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 });
782
+ throw new Error('unexpected read attachment without recovery route');
783
+ const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
704
784
  retrieved.push(await recoveredAttachment(file, recoveryPath));
705
785
  }
706
786
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
@@ -709,13 +789,17 @@ export class OwnerChannel {
709
789
  outbox = this.outboxDir(originWireId);
710
790
  await mkdir(outbox, { recursive: true, mode: 0o700 });
711
791
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
712
- const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
792
+ const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, outbox, admitted, group.caption), {
713
793
  interrupt: this.options.config.interrupt,
714
794
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
715
795
  origin: { kind: 'owner', requestId,
716
796
  ...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
717
797
  });
718
- const accepted = this.acceptanceNotice(queued);
798
+ const accepted = this.options.config.interrupt
799
+ ? ownerNotices.receivedInterrupting()
800
+ : queued.queuedBehind > 0
801
+ ? ownerNotices.receivedQueued(queued.queuedBehind)
802
+ : ownerNotices.receivedStarted();
719
803
  handledWireIds.forEach(wire => this.inFlight.add(wire));
720
804
  const receipt = this.send(sender.id, accepted, originWireId).then(() => undefined).catch(error => {
721
805
  this.logError(`attachment request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -788,17 +872,17 @@ export class OwnerChannel {
788
872
  this.options.log(`[${this.options.role}] managed-agent relay replay of a delivered wire consumed`);
789
873
  }
790
874
  else if (error instanceof RelayUnroutableError) {
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.
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.
794
878
  this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
795
879
  + `route yet; message stays queued: ${this.errorText(error)}`);
796
- await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayQueued());
880
+ await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayQueued());
797
881
  return false;
798
882
  }
799
883
  else {
800
884
  this.logError('managed-agent message relay refused', error);
801
- await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayRefused(this.errorText(error)));
885
+ await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayRefused(this.errorText(error)));
802
886
  }
803
887
  }
804
888
  this.state.remember(wireId);
@@ -834,7 +918,7 @@ export class OwnerChannel {
834
918
  let queued;
835
919
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
836
920
  try {
837
- queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId), {
921
+ queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
838
922
  interrupt: this.options.config.interrupt,
839
923
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
840
924
  origin: { kind: 'owner', requestId, displayText: text },
@@ -844,9 +928,9 @@ export class OwnerChannel {
844
928
  await rm(outbox, { recursive: true, force: true });
845
929
  if (error instanceof SessionControlError
846
930
  && error.reasonCode === ACP_CANCEL_DEADLINE_EXCEEDED) {
847
- // drainAll deferred this authenticated message before delivery. The
931
+ // drainAll journaled this authenticated message before delivery. The
848
932
  // adapter generation is terminating, so leave the wire unhandled and
849
- // body-free: the resumed owner channel will replay it exactly once.
933
+ // body-free: the resumed owner channel recovers it exactly once.
850
934
  this.options.log(`[${this.options.role}] owner request ${requestId.slice(0, 12)} `
851
935
  + `held for adapter resume reason=${ACP_CANCEL_DEADLINE_EXCEEDED}`);
852
936
  return false;
@@ -856,7 +940,14 @@ export class OwnerChannel {
856
940
  this.state.remember(wireId);
857
941
  return true;
858
942
  }
859
- const accepted = this.acceptanceNotice(queued);
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();
860
951
  this.inFlight.add(wireId);
861
952
  const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
862
953
  this.logError(`request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -1036,7 +1127,9 @@ export class OwnerChannel {
1036
1127
  async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
1037
1128
  const captionWire = group.caption ? this.wireId(group.caption) : undefined;
1038
1129
  const nackWire = captionWire ?? group.files[0].wireId;
1039
- const nackMessage = group.caption ?? { wire_id: nackWire };
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;
1040
1133
  let requestDir;
1041
1134
  let recovery;
1042
1135
  try {
@@ -1070,17 +1163,14 @@ export class OwnerChannel {
1070
1163
  this.attachmentRecovery.add(recovery);
1071
1164
  requestDir = await prepareAttachmentDirectory(this.attachmentRoot, transactionId);
1072
1165
  const unread = group.files.filter(file => file.status === 'unread');
1073
- const processed = group.files.filter(file => file.status !== 'unread');
1166
+ const historyRecovered = group.files.filter(file => file.status !== 'unread');
1074
1167
  const retrieved = unread.length
1075
- ? parseRetrievedAttachments(await this.client.callTool('get_files', {
1076
- wire_ids: unread.map(file => file.wireId),
1077
- }), unread)
1168
+ ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1078
1169
  : [];
1079
- for (const file of processed) {
1170
+ for (const file of historyRecovered) {
1080
1171
  if (!group.recovery)
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 });
1172
+ throw new Error('unexpected read attachment without recovery route');
1173
+ const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
1084
1174
  retrieved.push(await recoveredAttachment(file, recoveryPath));
1085
1175
  }
1086
1176
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
@@ -1092,9 +1182,9 @@ export class OwnerChannel {
1092
1182
  if (caption)
1093
1183
  await this.send(contact, caption, replyTo);
1094
1184
  for (const file of admitted) {
1095
- await this.client.callTool('send_file', {
1185
+ await this.client.sendFile({
1096
1186
  contact, path: file.path, filename: file.filename,
1097
- ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
1187
+ ...(replyTo ? { replyToWireId: replyTo } : {}),
1098
1188
  });
1099
1189
  }
1100
1190
  }
@@ -1131,11 +1221,11 @@ export class OwnerChannel {
1131
1221
  if (error instanceof RelayUnroutableError) {
1132
1222
  this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
1133
1223
  + `transaction stays queued: ${this.errorText(error)}`);
1134
- await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayQueued());
1224
+ await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayQueued());
1135
1225
  return false;
1136
1226
  }
1137
1227
  this.logError('managed-agent caption/file relay refused', error);
1138
- await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayRefused(this.errorText(error)));
1228
+ await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayRefused(this.errorText(error)));
1139
1229
  // Rejection/admission failure and uncertain transport are terminal and
1140
1230
  // visible. Consuming every correlated wire prevents a later partial replay.
1141
1231
  for (const wire of handledWireIds)
@@ -1171,17 +1261,17 @@ export class OwnerChannel {
1171
1261
  }
1172
1262
  /**
1173
1263
  * One bounded NACK per wire: an unroutable or refused relay must be visible
1174
- * to the authenticated agent, while its deferred replays stay quiet. NACK
1264
+ * to the authenticated agent, while its history replays stay quiet. NACK
1175
1265
  * delivery is best-effort — it must never make the failure worse.
1176
1266
  */
1177
- async nackManagedAgent(contact, message, wireId, notice) {
1267
+ async nackManagedAgent(contact, replyTo, wireId, notice) {
1178
1268
  if (this.relayNacks.has(wireId))
1179
1269
  return;
1180
1270
  this.relayNacks.add(wireId);
1181
1271
  if (this.relayNacks.size > RELAY_NACK_MEMORY)
1182
1272
  this.relayNacks.delete(this.relayNacks.values().next().value);
1183
1273
  try {
1184
- await this.send(contact, notice, message.wire_id ? wireId : undefined);
1274
+ await this.send(contact, notice, replyTo);
1185
1275
  }
1186
1276
  catch (error) {
1187
1277
  this.logError('managed-agent relay NACK delivery failed', error);
@@ -1242,32 +1332,6 @@ export class OwnerChannel {
1242
1332
  authorizationIntegrity() {
1243
1333
  return this.options.config.agent ? { ok: true } : this.authorizations.integrity();
1244
1334
  }
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
- }
1271
1335
  async complete(active, outbox, queued, activityCursor) {
1272
1336
  const progressMs = this.options.config.progress_interval_ms;
1273
1337
  let lastSeq = activityCursor;
@@ -1468,7 +1532,7 @@ export class OwnerChannel {
1468
1532
  throw new Error('commentary appears to contain secret, reasoning, or raw tool content');
1469
1533
  return message;
1470
1534
  }
1471
- ownerAttachmentPrompt(sender, wireId, requestId, files, caption) {
1535
+ ownerAttachmentPrompt(sender, wireId, requestId, outbox, files, caption) {
1472
1536
  const lines = [
1473
1537
  '[fleet-owner]',
1474
1538
  `Authenticated owner ${safeField(sender.name, 160)} (${sender.id}) sent owner-channel attachment request ${wireId}.`,
@@ -1492,13 +1556,10 @@ export class OwnerChannel {
1492
1556
  lines.push(`- voice transcript status: ${transcription?.status ?? 'unavailable'}`, `- voice transcript fallback: audio path above; category ${transcription?.errorCategory ?? 'not_provided'}`);
1493
1557
  }
1494
1558
  }
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.');
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}`);
1499
1560
  return lines.join('\n');
1500
1561
  }
1501
- ownerPrompt(sender, text, wireId) {
1562
+ ownerPrompt(sender, text, wireId, outbox) {
1502
1563
  return [
1503
1564
  '[fleet-owner]',
1504
1565
  `Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
@@ -1512,13 +1573,10 @@ export class OwnerChannel {
1512
1573
  ] : [
1513
1574
  'Managed-agent outbound relay is not configured; do not send intermediate or proactive owner-channel messages.',
1514
1575
  ]),
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.',
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.',
1522
1580
  '',
1523
1581
  text || '(empty message)',
1524
1582
  ].join('\n');
@@ -1531,8 +1589,8 @@ export class OwnerChannel {
1531
1589
  return createHash('sha256').update(wireId).digest('hex');
1532
1590
  }
1533
1591
  send(contact, text, replyTo) {
1534
- return this.client.callTool('send_message', {
1535
- contact, text, ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
1592
+ return this.client.sendMessage({
1593
+ contact, text, ...(replyTo ? { replyToWireId: replyTo } : {}),
1536
1594
  });
1537
1595
  }
1538
1596
  async sendAttachments(contact, outbox, replyTo) {
@@ -1540,11 +1598,11 @@ export class OwnerChannel {
1540
1598
  .filter(entry => entry.isFile())
1541
1599
  .sort((a, b) => a.name.localeCompare(b.name));
1542
1600
  for (const entry of entries) {
1543
- await this.client.callTool('send_file', {
1601
+ await this.client.sendFile({
1544
1602
  contact,
1545
1603
  path: join(outbox, entry.name),
1546
1604
  filename: entry.name,
1547
- reply_to_wire_id: replyTo,
1605
+ replyToWireId: replyTo,
1548
1606
  });
1549
1607
  }
1550
1608
  await rm(outbox, { recursive: true, force: true });
@@ -1567,11 +1625,11 @@ export class OwnerChannel {
1567
1625
  return Number.isInteger(message.msg_id) ? `msg:${message.msg_id}` : '';
1568
1626
  }
1569
1627
  sender(message) {
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) };
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) };
1575
1633
  }
1576
1634
  latestEventSeq(events) {
1577
1635
  return events.reduce((latest, event) => Math.max(latest, event.seq), 0);
@@ -1600,90 +1658,65 @@ export class OwnerChannel {
1600
1658
  return undefined;
1601
1659
  }
1602
1660
  async watchLoop() {
1603
- const endpoint = resolveEndpoint({ ...process.env, ...(this.options.env ?? {}) });
1604
- const fetch = this.options.watchFetch
1605
- ?? ((url, init) => globalThis.fetch(url, init));
1606
1661
  const sleep = this.options.binderDeps?.sleep
1607
1662
  ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
1608
1663
  const restored = this.readWatchState();
1609
1664
  let state = restored.state;
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';
1665
+ if (restored.recovered)
1666
+ state = this.writeWatchState(state, 'OWNER_WATCH_STATE_RECOVERED');
1615
1667
  let delayMs = 1_000;
1616
- let authFailures = 0;
1617
- if (recovering)
1618
- await this.drain().catch(error => this.logError('cursor recovery drain failed', error));
1668
+ let attempts = 0;
1619
1669
  while (!this.stopping) {
1620
1670
  const ctrl = new AbortController();
1621
1671
  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?.();
1626
1672
  try {
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');
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)
1637
1685
  return;
1638
- }
1639
- state = this.writeWatchState(state, at, 'OWNER_WATCH_AUTH_FAILED', true);
1640
- throw new Error('OWNER_WATCH_AUTH_FAILED: daemon rejected notification credentials');
1641
- }
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)
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.
1655
1691
  await this.drain();
1692
+ }
1693
+ if (!this.stopping)
1694
+ throw new Error('ours SDK notification stream ended');
1656
1695
  }
1657
1696
  catch (error) {
1658
1697
  if (this.stopping)
1659
1698
  return;
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);
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 });
1669
1704
  this.options.log(`[${this.options.role}] owner watch reconnect `
1670
- + `reason=${authRejected ? 'OWNER_WATCH_AUTH_FAILED' : reason} `
1671
- + `delay_ms=${delayMs} cursor=${current}`);
1705
+ + `reason=OWNER_WATCH_STREAM_ERROR delay_ms=${delayMs} `
1706
+ + `failures=${state.consecutiveFailures}: ${this.errorText(error)}`);
1672
1707
  await sleep(delayMs);
1673
1708
  delayMs = Math.min(delayMs * 2, OWNER_WATCH_BACKOFF_MAX_MS);
1674
1709
  }
1675
1710
  finally {
1676
- clearTimeout(timer);
1677
1711
  if (this.watchAbort === ctrl)
1678
1712
  this.watchAbort = undefined;
1679
1713
  }
1680
1714
  }
1681
1715
  }
1682
1716
  /**
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.
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.
1687
1720
  */
1688
1721
  readWatchState() {
1689
1722
  const path = join(this.options.stateDir, '.owner-channel-watch.json');
@@ -1691,76 +1724,43 @@ export class OwnerChannel {
1691
1724
  return { recovered: false };
1692
1725
  try {
1693
1726
  const value = JSON.parse(readFileSync(path, 'utf8'));
1694
- if (value.version !== 1 || !Number.isSafeInteger(value.cursor) || value.cursor < 0
1727
+ if ((value.version !== 1 && value.version !== 2)
1695
1728
  || !Number.isSafeInteger(value.reconnects) || value.reconnects < 0
1696
1729
  || !Number.isSafeInteger(value.consecutiveFailures) || value.consecutiveFailures < 0)
1697
1730
  throw new Error('invalid owner watch state');
1698
- return { state: value, recovered: false };
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 };
1699
1744
  }
1700
1745
  catch {
1701
1746
  this.options.log(`[${this.options.role}] owner watch `
1702
- + 'reason=OWNER_WATCH_CURSOR_RECOVERED invalid cursor state; draining inbox and starting at tip');
1747
+ + 'reason=OWNER_WATCH_STATE_RECOVERED invalid persisted counters; restarting safely');
1703
1748
  return { recovered: true };
1704
1749
  }
1705
1750
  }
1706
- writeWatchState(previous, cursor, reason, failed, reconnected = false) {
1751
+ writeWatchState(previous, reason, options = {}) {
1707
1752
  const state = {
1708
- version: 1,
1709
- cursor,
1710
- reconnects: (previous?.reconnects ?? 0) + (reconnected ? 1 : 0),
1711
- consecutiveFailures: failed ? (previous?.consecutiveFailures ?? 0) + 1 : 0,
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,
1712
1758
  reason,
1713
1759
  updatedAt: new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
1714
1760
  };
1715
1761
  replaceFileAtomically(join(this.options.stateDir, '.owner-channel-watch.json'), `${JSON.stringify(state)}\n`, 0o600);
1716
1762
  return state;
1717
1763
  }
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
  }