@murmurv2/cli 2.11.0 → 2.12.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 (40) hide show
  1. package/README.md +37 -12
  2. package/package.json +2 -1
  3. package/runtime/bin/murmur-svc.exe +0 -0
  4. package/runtime/npm-runtime-manifest.json +97 -61
  5. package/runtime/package.json +1 -1
  6. package/runtime/packages/setup/bin/murmur.mjs +5 -3
  7. package/runtime/packages/setup/dist/src/cli.d.ts +1 -1
  8. package/runtime/packages/setup/dist/src/cli.js +49 -9
  9. package/runtime/packages/setup/dist/src/clients.js +8 -2
  10. package/runtime/packages/setup/dist/src/commands.d.ts +3 -1
  11. package/runtime/packages/setup/dist/src/commands.js +17 -6
  12. package/runtime/packages/setup/dist/src/config.d.ts +2 -0
  13. package/runtime/packages/setup/dist/src/config.js +61 -0
  14. package/runtime/packages/setup/dist/src/doctor.d.ts +12 -0
  15. package/runtime/packages/setup/dist/src/doctor.js +19 -2
  16. package/runtime/packages/setup/dist/src/invitation-server.d.ts +2 -0
  17. package/runtime/packages/setup/dist/src/invitation-server.js +45 -0
  18. package/runtime/packages/setup/dist/src/message-status.d.ts +4 -0
  19. package/runtime/packages/setup/dist/src/message-status.js +10 -0
  20. package/runtime/packages/setup/dist/src/onboarding.d.ts +19 -6
  21. package/runtime/packages/setup/dist/src/onboarding.js +46 -22
  22. package/runtime/packages/setup/dist/src/outbox-attention.js +3 -21
  23. package/runtime/packages/setup/dist/src/platform/darwin.js +7 -1
  24. package/runtime/packages/setup/dist/src/platform/linux.js +21 -16
  25. package/runtime/packages/setup/dist/src/platform/windows.js +67 -3
  26. package/runtime/packages/setup/dist/src/status.d.ts +17 -2
  27. package/runtime/packages/setup/dist/src/status.js +78 -18
  28. package/runtime/packages/setup/dist/src/types.d.ts +11 -1
  29. package/runtime/packages/setup/dist/src/verdict.js +4 -2
  30. package/runtime/packages/setup/dist/src/wake-attention.d.ts +11 -0
  31. package/runtime/packages/setup/dist/src/wake-attention.js +44 -0
  32. package/runtime/packages/setup/package.json +1 -1
  33. package/runtime/scripts/codex-app-server-wake.mjs +169 -32
  34. package/runtime/scripts/daemon-contacts.mjs +56 -0
  35. package/runtime/scripts/daemon-flush-tick.mjs +25 -0
  36. package/runtime/scripts/daemon-link-watch.mjs +60 -0
  37. package/runtime/scripts/daemon-observation.mjs +74 -18
  38. package/runtime/scripts/murmur-daemon.mjs +45 -31
  39. package/runtime/scripts/wake-drain-claude.mjs +69 -32
  40. package/runtime/scripts/wake-monitor.mjs +46 -18
@@ -10,6 +10,7 @@ import path from "node:path";
10
10
  const DEFAULT_TIMEOUT_MS = 10000;
11
11
  const INITIALIZE_TIMEOUT_MS = 10000;
12
12
  const DEFAULT_TURN_COMPLETION_TIMEOUT_MS = 180000;
13
+ const DEFAULT_UNOBSERVABLE_TIMEOUT_MS = 300000;
13
14
  const SESSION_LOG_POLL_INTERVAL_MS = 1000;
14
15
  const SESSION_LOG_TAIL_BYTES = 8 * 1024 * 1024;
15
16
 
@@ -87,15 +88,16 @@ const readSessionLogTail = (sessionPath) => {
87
88
  }
88
89
  };
89
90
 
90
- export const readFinalAnswerFromSessionLog = (sessionPath, turnId) => {
91
- if (!sessionPath || !turnId) return "";
91
+ const readTurnFromSessionLog = (sessionPath, turnId) => {
92
+ if (!sessionPath || !turnId) return null;
92
93
  let data = "";
93
94
  try {
94
95
  data = readSessionLogTail(sessionPath);
95
96
  } catch {
96
- return "";
97
+ return null;
97
98
  }
98
99
 
100
+ let finalText = '';
99
101
  const lines = data.split("\n").filter(Boolean);
100
102
  for (let index = lines.length - 1; index >= 0; index -= 1) {
101
103
  const line = lines[index];
@@ -110,7 +112,9 @@ export const readFinalAnswerFromSessionLog = (sessionPath, turnId) => {
110
112
 
111
113
  const payload = entry?.payload || {};
112
114
  if (entry.type === "event_msg" && payload.type === "task_complete" && payload.turn_id === turnId) {
113
- return typeof payload.last_agent_message === "string" ? payload.last_agent_message : "";
115
+ return { turnId, status: payload.error == null ? 'completed' : 'failed',
116
+ error: typeof payload.error === 'string' ? { message: payload.error } : payload.error ?? null,
117
+ finalText: typeof payload.last_agent_message === "string" ? payload.last_agent_message : finalText };
114
118
  }
115
119
 
116
120
  const metadataTurnId = payload?.internal_chat_message_metadata_passthrough?.turn_id;
@@ -121,12 +125,14 @@ export const readFinalAnswerFromSessionLog = (sessionPath, turnId) => {
121
125
  && metadataTurnId === turnId
122
126
  && Array.isArray(payload.content)
123
127
  ) {
124
- return payload.content.map((part) => part?.text || "").join("\n").trim();
128
+ finalText ||= payload.content.map((part) => part?.text || "").join("\n").trim();
125
129
  }
126
130
  }
127
- return "";
131
+ return finalText ? { turnId, status: 'inProgress', finalText } : null;
128
132
  };
129
133
 
134
+ export const readFinalAnswerFromSessionLog = (sessionPath, turnId) => readTurnFromSessionLog(sessionPath, turnId)?.finalText ?? '';
135
+
130
136
  export const buildThreadStartParams = (binding = null, peer = null) => ({
131
137
  model: binding?.model ?? peer?.model ?? null,
132
138
  modelProvider: null,
@@ -259,13 +265,24 @@ export class CodexAppServerClient {
259
265
  startTurnAndWaitForFinal(params, {
260
266
  completionTimeoutMs = DEFAULT_TURN_COMPLETION_TIMEOUT_MS,
261
267
  sessionPath = null,
268
+ acceptedTurn = null,
269
+ onAccepted = () => {},
270
+ onWorking = () => {},
271
+ onObservation = () => {},
272
+ unobservableTimeoutMs = DEFAULT_UNOBSERVABLE_TIMEOUT_MS,
273
+ pollIntervalMs = SESSION_LOG_POLL_INTERVAL_MS,
274
+ maxPollIntervalMs = 30000,
262
275
  } = {}) {
263
276
  return new Promise((resolve, reject) => {
264
277
  let settled = false;
265
278
  let initialized = false;
266
- let turnId = null;
279
+ let turnId = acceptedTurn?.turnId ?? null;
267
280
  let startResult = null;
268
281
  let finalText = "";
282
+ let receipt = Promise.resolve();
283
+ let observing = Boolean(turnId), polling = false, reported = null;
284
+ let unobservableSince = acceptedTurn?.unobservableSince ?? null;
285
+ let unobservableTimer = null, sessionLogTimer = null, pollDelay = pollIntervalMs;
269
286
  const requestId = this.nextId++;
270
287
  const initId = `init-${this.nextId++}`;
271
288
  const url = `ws+unix://${this.socketPath}:/`;
@@ -273,29 +290,86 @@ export class CodexAppServerClient {
273
290
  perMessageDeflate: false,
274
291
  handshakeTimeout: Math.min(this.timeoutMs, INITIALIZE_TIMEOUT_MS),
275
292
  });
276
- const timer = setTimeout(() => {
277
- finish(new Error(`codex-app-server-turn-completion-timeout:${this.socketPath}:${turnId || "unknown"}`));
278
- }, completionTimeoutMs);
279
- const sessionLogTimer = setInterval(() => {
280
- if (settled || !sessionPath || !turnId) return;
281
- const text = readFinalAnswerFromSessionLog(sessionPath, turnId);
282
- if (!text) return;
283
- finalText = finalText || text;
284
- finish(null, { ...startResult, finalText, turnId, source: "session-log" });
285
- }, SESSION_LOG_POLL_INTERVAL_MS);
293
+ const working = (reason) => {
294
+ observing = true;
295
+ if (reported !== reason) { reported = reason; onWorking({ threadId: params.threadId, turnId, reason }); }
296
+ };
297
+ const recordObservation = (since) => {
298
+ if (since === unobservableSince) return;
299
+ unobservableSince = since;
300
+ receipt = receipt.then(() => onObservation({ unobservableSince: since }));
301
+ receipt.catch(() => {});
302
+ };
303
+ const unavailable = () => {
304
+ if (settled) return;
305
+ if (!unobservableSince) recordObservation(new Date().toISOString());
306
+ working('accepted-turn-unobservable');
307
+ if (unobservableTimer === null) {
308
+ const remaining = unobservableTimeoutMs - (Date.now() - Date.parse(unobservableSince));
309
+ unobservableTimer = setTimeout(() => finish(Object.assign(
310
+ new Error('accepted-turn-unobservable'), { retryable: false })), Math.max(0, remaining));
311
+ }
312
+ };
313
+ const observable = () => {
314
+ clearTimeout(unobservableTimer); unobservableTimer = null;
315
+ recordObservation(null);
316
+ };
317
+ let timer = setTimeout(() => {
318
+ if (turnId) working('completion-window-elapsed');
319
+ else finish(new Error('codex-app-server-turn-start-timeout'));
320
+ }, turnId ? completionTimeoutMs : this.timeoutMs);
321
+ const accept = (id) => {
322
+ if (!id || turnId) return;
323
+ turnId = id;
324
+ try { receipt = Promise.resolve(onAccepted({ threadId: params.threadId, turnId, sessionPath })); }
325
+ catch (error) { receipt = Promise.reject(error); }
326
+ // Observe receipt-write failures immediately, without losing the accepted ID.
327
+ receipt.catch(() => {});
328
+ clearTimeout(timer);
329
+ timer = setTimeout(() => working('completion-window-elapsed'), completionTimeoutMs);
330
+ };
331
+ const inspect = async () => {
332
+ if (settled || !turnId || polling) return;
333
+ const savedTurn = sessionPath && readTurnFromSessionLog(sessionPath, turnId);
334
+ if (savedTurn && ['completed', 'failed'].includes(savedTurn.status)) {
335
+ finish(null, { ...startResult, ...savedTurn, finalText: finalText || savedTurn.finalText, source: "session-log" });
336
+ return;
337
+ }
338
+ if (!observing) return;
339
+ polling = true;
340
+ try {
341
+ const turn = await this.readTurn(params.threadId, turnId);
342
+ if (settled) return;
343
+ if (!turn || !['inProgress', 'completed', 'failed', 'interrupted'].includes(turn.status)) { unavailable(); return; }
344
+ observable();
345
+ if (turn.status === 'inProgress') return;
346
+ const answer = turn.items?.filter(item => item.type === 'agentMessage' && item.phase === 'final_answer')
347
+ .map(item => item.text ?? '').join('\n').trim();
348
+ finish(null, { finalText: finalText || answer || '', turnId, status: turn.status,
349
+ error: turn.error ?? null, source: 'app-server-read' });
350
+ } catch { unavailable(); }
351
+ finally { polling = false; }
352
+ };
353
+ const poll = async () => {
354
+ await inspect();
355
+ if (settled) return;
356
+ pollDelay = Math.min(maxPollIntervalMs, pollDelay * 2);
357
+ sessionLogTimer = setTimeout(poll, pollDelay);
358
+ };
359
+ sessionLogTimer = setTimeout(poll, pollDelay);
286
360
 
287
361
  const finish = (err, result) => {
288
362
  if (settled) return;
289
363
  settled = true;
290
364
  clearTimeout(timer);
291
- clearInterval(sessionLogTimer);
365
+ clearTimeout(sessionLogTimer);
366
+ clearTimeout(unobservableTimer);
292
367
  try {
293
368
  socket.close();
294
369
  } catch {
295
370
  // Ignore close races; the request already has a terminal result.
296
371
  }
297
- if (err) reject(err);
298
- else resolve(result);
372
+ receipt.then(() => err ? reject(err) : resolve(result), reject);
299
373
  };
300
374
 
301
375
  const sendJson = (message) => socket.send(JSON.stringify(message));
@@ -342,12 +416,17 @@ export class CodexAppServerClient {
342
416
 
343
417
  if (message.id === initId) {
344
418
  if (message.error) {
419
+ if (turnId) { unavailable(); return; }
345
420
  finish(new Error(`codex-app-server-initialize-error:${message.error.message || JSON.stringify(message.error)}`));
346
421
  return;
347
422
  }
348
423
  initialized = true;
349
424
  sendJson({ method: "initialized" });
350
- sendJson({ id: requestId, method: "turn/start", params });
425
+ if (acceptedTurn) {
426
+ // Subscribe to the existing thread; never submit this letter again.
427
+ sendJson({ id: requestId, method: "thread/resume", params: { threadId: params.threadId } });
428
+ void inspect();
429
+ } else sendJson({ id: requestId, method: "turn/start", params });
351
430
  return;
352
431
  }
353
432
 
@@ -355,37 +434,38 @@ export class CodexAppServerClient {
355
434
 
356
435
  if (message.id === requestId) {
357
436
  if (message.error) {
437
+ if (turnId) { unavailable(); return; }
358
438
  finish(new Error(`codex-app-server-error:${message.error.message || JSON.stringify(message.error)}`));
359
439
  return;
360
440
  }
361
441
  startResult = message.result;
362
- turnId = message.result?.turn?.id || turnId;
442
+ if (acceptedTurn) sessionPath = message.result?.thread?.path || sessionPath;
443
+ else accept(message.result?.turn?.id);
363
444
  return;
364
445
  }
365
446
 
447
+ if (!turnId || (message.params?.threadId && message.params.threadId !== params.threadId)) return;
366
448
  if (message.method === "turn/started" && message.params?.turn?.id) {
367
- turnId = turnId || message.params.turn.id;
368
449
  return;
369
450
  }
370
451
 
371
452
  if (message.method === "item/completed" && message.params?.turnId) {
372
- turnId = turnId || message.params.turnId;
373
453
  if (message.params.turnId !== turnId) return;
454
+ observable();
374
455
  const item = message.params.item;
375
456
  if (item?.type === "agentMessage" && item.phase === "final_answer" && typeof item.text === "string") {
376
457
  finalText = item.text;
377
- finish(null, { ...startResult, finalText, turnId, source: "app-server-final-item" });
378
458
  }
379
459
  return;
380
460
  }
381
461
 
382
462
  if (message.method === "turn/completed" && message.params?.turn?.id) {
383
- turnId = turnId || message.params.turn.id;
384
463
  if (message.params.turn.id !== turnId) return;
385
464
  // #106 — `turn.status` (completed | interrupted | failed | inProgress) and
386
465
  // `turn.error` are part of the notification; a caller that ignores them cannot
387
466
  // tell a finished turn from a broken one.
388
467
  const turn = message.params.turn;
468
+ if (turn.status === 'inProgress') { observable(); working('turn-in-progress'); return; }
389
469
  finish(null, {
390
470
  ...startResult,
391
471
  finalText,
@@ -397,13 +477,38 @@ export class CodexAppServerClient {
397
477
  }
398
478
  });
399
479
  socket.on("error", (err) => {
480
+ if (settled) return;
481
+ if (turnId) { unavailable(); return; }
400
482
  finish(new Error(`codex-app-server-connect-failed:${this.socketPath}:${err.message}`));
401
483
  });
402
484
  socket.on("close", () => {
485
+ if (settled) return;
486
+ if (turnId) { unavailable(); return; }
403
487
  if (!settled) finish(new Error(`codex-app-server-closed-before-response:${this.socketPath}:${initialized ? "after-initialize" : "before-initialize"}`));
404
488
  });
405
489
  });
406
490
  }
491
+
492
+ async readTurn(threadId, turnId) {
493
+ try {
494
+ const result = await this.request('thread/read', { threadId, includeTurns: true });
495
+ if (result?.thread?.id !== threadId) return null;
496
+ const turn = result.thread.turns?.find(turn => turn.id === turnId);
497
+ if (turn) return turn;
498
+ } catch (error) {
499
+ // New paginated threads reject the legacy full-history read.
500
+ if (!/paginat|includeTurns|full.history/i.test(error.message)) throw error;
501
+ }
502
+ let cursor;
503
+ for (let page = 0; page < 20; page++) {
504
+ const result = await this.request('thread/turns/list', { threadId, limit: 50, sortDirection: 'desc', itemsView: 'full', ...(cursor ? { cursor } : {}) });
505
+ const turn = result?.data?.find(turn => turn.id === turnId);
506
+ if (turn) return turn;
507
+ if (!result?.nextCursor || result.nextCursor === cursor) return null;
508
+ cursor = result.nextCursor;
509
+ }
510
+ return null;
511
+ }
407
512
  }
408
513
 
409
514
  /**
@@ -536,6 +641,15 @@ export const createCodexAppServerInjector = ({
536
641
  // so the mapping survives restarts; without one it still lives per conversation, only
537
642
  // in memory. A static `peer.threadId` is an explicit pin and is never written here.
538
643
  const memoryThreads = new Map();
644
+ const memoryTurns = new Map();
645
+ const turns = {
646
+ get: async msgId => memoryTurns.get(msgId) ?? await threadStore?.getWakeTurn?.(msgId),
647
+ set: async record => { memoryTurns.set(record.msgId, record); await threadStore?.setWakeTurn?.(record); },
648
+ delete: async (msgId, turnId) => {
649
+ await threadStore?.deleteWakeTurn?.(msgId, turnId);
650
+ if (memoryTurns.get(msgId)?.turnId === turnId) memoryTurns.delete(msgId);
651
+ },
652
+ };
539
653
  const threads = threadStore ?? {
540
654
  getWakeThread: async (peerId, conversationId) => memoryThreads.get(`${peerId}|${conversationId}`),
541
655
  setWakeThread: async (record) => { memoryThreads.set(`${record.peerId}|${record.conversationId}`, record); },
@@ -559,12 +673,17 @@ export const createCodexAppServerInjector = ({
559
673
  return { finalText: null, turnId: null, source: "relay-idempotent", replyMsgId };
560
674
  }
561
675
 
676
+ const accepted = await turns.get(payload.msgId);
677
+ if (accepted && (accepted.peerId !== String(payload.from ?? '')
678
+ || accepted.conversationId !== String(payload.conversationId ?? '') || accepted.socketPath !== socketPath)) {
679
+ throw Object.assign(new Error('codex-app-server-accepted-turn-binding-changed'), { retryable: false });
680
+ }
562
681
  const client = new Client({ socketPath, timeoutMs });
563
682
  const text = buildCodexTurnText(payload, peer);
564
683
  const threadStartBinding = peer?.threadStartBinding ?? (resolveThreadStartBinding ? await resolveThreadStartBinding(payload, peer) : null);
565
684
  const bindingMetadata = threadStartBinding?.metadata ?? {};
566
685
  const shouldResumeThread = peer?.resume === true || (peer?.resume !== false && peer?.relayFinalToMurmur === true);
567
- let threadPath = null;
686
+ let threadPath = accepted?.sessionPath ?? null;
568
687
  const resumeThread = async (threadId) => {
569
688
  if (!threadId || peer?.resume === false) return;
570
689
  try {
@@ -592,13 +711,30 @@ export const createCodexAppServerInjector = ({
592
711
  },
593
712
  });
594
713
  const startTurn = async (threadId) => {
595
- if (relayEnabled) {
714
+ // A configuration change must not turn an accepted receipt back into a
715
+ // fire-and-forget start. Observe it even if reply relay was disabled later.
716
+ if (relayEnabled || accepted) {
596
717
  const result = await client.startTurnAndWaitForFinal(turnParams(threadId), {
597
718
  completionTimeoutMs: Number(peer?.replyTimeoutMs) || DEFAULT_TURN_COMPLETION_TIMEOUT_MS,
598
719
  sessionPath: threadPath,
720
+ acceptedTurn: accepted,
721
+ unobservableTimeoutMs: peer?.unobservableTimeoutMs ?? DEFAULT_UNOBSERVABLE_TIMEOUT_MS,
722
+ onAccepted: async receipt => {
723
+ await turns.set({ ...receipt, msgId: payload.msgId, peerId: String(payload.from ?? ''),
724
+ conversationId: String(payload.conversationId ?? ''), socketPath });
725
+ log('info', 'Codex app-server wake turn accepted', { msgId: payload.msgId, threadId, turnId: receipt.turnId });
726
+ },
727
+ onWorking: ({ turnId, reason }) => log(reason === 'accepted-turn-unobservable' ? 'warn' : 'info',
728
+ reason === 'accepted-turn-unobservable' ? 'Codex accepted turn outcome unavailable; wake retained' : 'Codex Assistant still working; wake retained',
729
+ { msgId: payload.msgId, threadId, turnId, reason }),
730
+ onObservation: async ({ unobservableSince }) => {
731
+ const receipt = await turns.get(payload.msgId);
732
+ if (receipt) await turns.set({ ...receipt, unobservableSince });
733
+ },
599
734
  });
600
735
  const status = typeof result?.status === "string" ? result.status : null;
601
736
  if (status && status !== "completed") {
737
+ if (status === 'failed' && result.turnId) await turns.delete(payload.msgId, result.turnId);
602
738
  // #106 — a turn that failed or was interrupted is not a success, whatever text
603
739
  // it left behind. `failed` may clear on a retry (model/provider hiccup);
604
740
  // `interrupted` means someone stopped it on purpose — do not run it again.
@@ -616,6 +752,7 @@ export const createCodexAppServerInjector = ({
616
752
  { retryable: status === "failed" },
617
753
  );
618
754
  }
755
+ if (!relayEnabled) return result;
619
756
  const finalText = String(result?.finalText || "").trim();
620
757
  if (!finalText) {
621
758
  // #106 — the turn ended and there is nothing to relay. That is not a relay;
@@ -670,9 +807,9 @@ export const createCodexAppServerInjector = ({
670
807
 
671
808
  // Resolution order: a remembered thread for this conversation, unless it was seeded to
672
809
  // replace a pin that has since changed; then the static pin; then a fresh seed.
673
- const remembered = await threads.getWakeThread(threadKey.peerId, threadKey.conversationId);
810
+ const remembered = accepted ? null : await threads.getWakeThread(threadKey.peerId, threadKey.conversationId);
674
811
  const rememberedUsable = remembered && (!peer?.threadId || !remembered.replacesThreadId || remembered.replacesThreadId === peer.threadId);
675
- let threadId = rememberedUsable ? remembered.threadId : peer?.threadId;
812
+ let threadId = accepted?.threadId ?? (rememberedUsable ? remembered.threadId : peer?.threadId);
676
813
  if (rememberedUsable && remembered.threadPath) threadPath = remembered.threadPath;
677
814
  let seededHere = false;
678
815
  if (!threadId) {
@@ -682,11 +819,11 @@ export const createCodexAppServerInjector = ({
682
819
 
683
820
  let result;
684
821
  try {
685
- if (shouldResumeThread && !seededHere) await resumeThread(threadId);
822
+ if (shouldResumeThread && !seededHere && !accepted) await resumeThread(threadId);
686
823
  result = await startTurn(threadId);
687
824
  } catch (err) {
688
825
  const e = err instanceof Error ? err : new Error(String(err));
689
- if (!e.message.startsWith("codex-app-server-error:thread not found:")) throw e;
826
+ if (accepted || !e.message.startsWith("codex-app-server-error:thread not found:")) throw e;
690
827
  threadId = await seedThread("re-seeded");
691
828
  result = await startTurn(threadId);
692
829
  }
@@ -0,0 +1,56 @@
1
+ import { AgentConfigCache } from '../packages/mcp-server/dist/src/agent-config.js';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+
4
+ const key = value => typeof value === 'string' && Buffer.from(value, 'base64').length === 32
5
+ && Buffer.from(value, 'base64').toString('base64') === value;
6
+ const contactId = value => /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value);
7
+
8
+ /** The config file's atomic replacement is the cross-platform reload trigger.
9
+ * Share MCP's owner/path/identity/runtime pinning. Failed reads cannot introduce
10
+ * new trust: keep the last valid Contacts available and report the failure. */
11
+ export function createDaemonContacts(configPath, log = () => {}) {
12
+ const cache = new AgentConfigCache(configPath);
13
+ const initial = cache.read();
14
+ const config = { ...initial, peers: {} };
15
+ let applied, failure, invalidSignature = '[]';
16
+ let diagnostic = { state: 'current', count: 0, invalidCount: 0,
17
+ lastSuccessAt: null, lastError: null, lastErrorAt: null };
18
+ const refresh = (force = false) => {
19
+ try {
20
+ const next = cache.read(force);
21
+ if (applied === next && !failure) return config.peers;
22
+ const invalid = [];
23
+ const peers = Object.fromEntries(Object.entries(next.peers).filter(([id, peer]) => {
24
+ if (contactId(id) && peer && typeof peer.subject === 'string' && /^[A-Za-z0-9_.:-]+$/.test(peer.subject)
25
+ && key(peer.encryption?.publicKey) && key(peer.signing?.publicKey)) return true;
26
+ invalid.push(contactId(id) ? id : null);
27
+ return false;
28
+ }));
29
+ const signature = JSON.stringify(invalid.sort());
30
+ const changed = !isDeepStrictEqual(config.peers, peers);
31
+ config.peers = peers;
32
+ const at = new Date().toISOString();
33
+ diagnostic = { state: invalid.length ? 'partial' : 'current', count: Object.keys(peers).length,
34
+ invalidCount: invalid.length, lastSuccessAt: at,
35
+ lastError: invalid.length ? 'agent-config-peer-invalid' : null,
36
+ lastErrorAt: invalid.length ? signature !== invalidSignature || failure ? at : diagnostic.lastErrorAt : null };
37
+ if (invalid.length && (signature !== invalidSignature || failure))
38
+ log('warn', 'Invalid Contacts skipped', { reason: 'agent-config-peer-invalid', contacts: invalid, count: invalid.length });
39
+ // MCP may change ctime by protecting the same file. Compare content, not
40
+ // cache object identity, before claiming that Contacts changed.
41
+ if ((applied && changed) || failure) log('info', 'Contacts reloaded', { count: diagnostic.count });
42
+ invalidSignature = signature;
43
+ applied = next; failure = undefined;
44
+ return config.peers;
45
+ } catch (error) {
46
+ const reason = /^agent-config-[a-z-]+$/.test(error?.message) ? error.message : 'agent-config-unavailable-retry-or-restart';
47
+ if (failure !== reason) log('error', 'Contacts reload failed', { reason });
48
+ diagnostic = { ...diagnostic, state: 'retained', lastError: reason,
49
+ lastErrorAt: failure === reason ? diagnostic.lastErrorAt : new Date().toISOString() };
50
+ failure = reason;
51
+ return config.peers;
52
+ }
53
+ };
54
+ refresh();
55
+ return { config, refresh, diagnostics: () => ({ ...diagnostic }) };
56
+ }
@@ -0,0 +1,25 @@
1
+ // One pass of the daemon delivery loop. Outbound letters and notifications keep flowing
2
+ // while an assistant works: a wake turn can last many minutes, and awaiting it here held
3
+ // every reply in the outbox until the turn ended or timed out (24.09, the Mac sat on a
4
+ // ping from 11:29 to 11:36). WakeMonitor.drain() is single-flight, so starting it on
5
+ // every tick only kicks the running dispatcher; its rows stay durable across restarts.
6
+ export async function flushTick({ flushOutbox, flushNotify, drainWake, log }) {
7
+ try {
8
+ await flushOutbox();
9
+ } catch (err) {
10
+ log("error", "Outbox flush error", { error: err instanceof Error ? err.message : String(err) });
11
+ }
12
+
13
+ try {
14
+ await flushNotify();
15
+ } catch (err) {
16
+ log("error", "Notify flush error", { error: err instanceof Error ? err.message : String(err) });
17
+ }
18
+
19
+ // #105 — retry tick. With no drain running this reads the table: deliveries whose
20
+ // backoff has elapsed and rows a previous process left behind. A running drain is
21
+ // only nudged; such rows wait until it frees its conversations.
22
+ Promise.resolve()
23
+ .then(drainWake)
24
+ .catch((err) => log("error", "Wake retry drain error", { error: err instanceof Error ? err.message : String(err) }));
25
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Exit when the server link is gone (#276).
3
+ *
4
+ * A daemon whose broker connection has closed for good, or has been lost longer than the
5
+ * operator allows, keeps its PID and its supervisor's "healthy" while delivering nothing.
6
+ * This watch turns that into a non-zero exit so systemd, launchd or the Windows Service
7
+ * manager restart the daemon and the failure becomes visible in their status.
8
+ *
9
+ * - `exitOnClosed` (default on): the connection reported `closed` — auth abort after a
10
+ * rotated token, reconnect attempts exhausted — and will never come back by itself.
11
+ * - `maxDisconnectedMs` (default 0 = off): the link has been lost longer than this.
12
+ */
13
+ export function createLinkWatch({ observation, maxDisconnectedMs = 0, exitOnClosed = true, intervalMs, log = () => {}, onLost, now = () => Date.now() }) {
14
+ const limit = Number.isFinite(maxDisconnectedMs) && maxDisconnectedMs > 0 ? Math.floor(maxDisconnectedMs) : 0;
15
+ const enabled = exitOnClosed || limit > 0;
16
+ const period = intervalMs ?? (limit > 0 ? Math.max(1000, Math.min(30000, Math.floor(limit / 4))) : 5000);
17
+ let timer;
18
+ let fired = false;
19
+
20
+ const fire = (reason, data) => {
21
+ if (fired) return true;
22
+ fired = true;
23
+ log("fatal", "Server link is gone; exiting so the supervisor restarts the daemon", { reason, ...data });
24
+ onLost?.(reason);
25
+ return true;
26
+ };
27
+
28
+ const check = () => {
29
+ if (fired) return true;
30
+ if (!enabled) return false;
31
+ const broker = observation.broker();
32
+ if (exitOnClosed && broker.state === "closed") {
33
+ return fire("broker.link-closed", { lastError: broker.lastError, disconnectedAt: broker.disconnectedAt });
34
+ }
35
+ if (limit > 0) {
36
+ const disconnectedForMs = observation.disconnectedForMs(now());
37
+ if (disconnectedForMs > limit) {
38
+ return fire("broker.link-lost", { disconnectedAt: broker.disconnectedAt, disconnectedForMs, maxDisconnectedMs: limit,
39
+ reconnectAttempts: broker.reconnectAttempts });
40
+ }
41
+ }
42
+ return false;
43
+ };
44
+
45
+ return {
46
+ enabled,
47
+ maxDisconnectedMs: limit,
48
+ intervalMs: period,
49
+ start() {
50
+ if (!enabled || timer) return;
51
+ timer = setInterval(check, period);
52
+ timer.unref();
53
+ },
54
+ stop() {
55
+ if (timer) clearInterval(timer);
56
+ timer = undefined;
57
+ },
58
+ check,
59
+ };
60
+ }
@@ -3,38 +3,94 @@ import { realpath } from "node:fs/promises";
3
3
  import { writePrivateJson } from "./secure-state.mjs";
4
4
 
5
5
  /** Non-secret, PID/store-bound liveness evidence for the read-only setup CLI. */
6
- export function createDaemonObservation({ dataDir, storePath, agentId, wake, log = () => {} }) {
6
+ export function createDaemonObservation({ dataDir, storePath, agentId, wake, contacts = () => null, log = () => {} }) {
7
7
  const startedAt = new Date().toISOString();
8
8
  const snapshot = { schema: "murmur.runtime/1", agentId, pid: process.pid, startedAt,
9
9
  storePath: null, measuredAt: null, wake: { ...wake, lastFault: null, lastFaultAt: null },
10
- broker: { state: "unknown", connectedAt: null, lastError: null, lastErrorAt: null } };
11
- let writing = false;
10
+ broker: { state: "disconnected", connectedAt: null, disconnectedAt: startedAt, reconnectAttempts: 0,
11
+ lastError: "broker.connecting", lastErrorAt: startedAt } };
12
+ const knownReasons = ["broker.unauthorized", "broker.permission-denied", "broker.connection-refused", "broker.name-unresolved",
13
+ "broker.timeout", "broker.connection-failed", "broker.stale-connection", "broker.closed"];
14
+ const safeReason = (value) => knownReasons.includes(value) ? value : "broker.connection-failed";
15
+ let writes = Promise.resolve();
12
16
  let timer;
13
- const write = async () => {
14
- if (writing) return;
15
- writing = true;
17
+ const write = () => writes = writes.then(async () => {
16
18
  try {
17
19
  snapshot.storePath = await realpath(storePath);
18
20
  snapshot.measuredAt = new Date().toISOString();
21
+ snapshot.contacts = contacts();
19
22
  await writePrivateJson(path.join(dataDir, "daemon-observation.json"), snapshot);
20
23
  } catch { log("warn", "Daemon observation write failed", { reason: "runtime.observation-write-failed" }); }
21
- finally { writing = false; }
22
- };
24
+ });
23
25
  return {
24
- async start() { await write(); timer = setInterval(() => { void write(); }, 5000); timer.unref(); },
25
- async connected() { snapshot.broker.state = "connected"; snapshot.broker.connectedAt = new Date().toISOString(); await write(); },
26
+ async start() {
27
+ log("info", "Connecting to server", { reason: "broker.connecting" });
28
+ await write(); timer = setInterval(() => { void write(); }, 5000); timer.unref();
29
+ },
30
+ async connected() {
31
+ const broker = snapshot.broker;
32
+ broker.state = "connected"; broker.connectedAt = new Date().toISOString();
33
+ broker.disconnectedAt = null; broker.reconnectAttempts = 0;
34
+ await write();
35
+ },
26
36
  onStatus(event) {
27
- if (event.type === "disconnect") {
28
- snapshot.broker.state = "disconnected";
29
- snapshot.broker.lastError = "broker.disconnected";
30
- snapshot.broker.lastErrorAt = new Date().toISOString();
37
+ const broker = snapshot.broker;
38
+ const at = new Date().toISOString();
39
+ if (event.type === "connect_error") {
40
+ const reason = safeReason(event.data?.reason);
41
+ broker.state = reason === "broker.unauthorized" ? "unauthorized" : "disconnected";
42
+ broker.lastError = reason;
43
+ broker.lastErrorAt = at;
44
+ if (broker.disconnectedAt === null) broker.disconnectedAt = at;
45
+ log("warn", "Server connection failed; will retry", { reason });
46
+ } else if (event.type === "disconnect") {
47
+ broker.state = "disconnected";
48
+ broker.lastError = "broker.disconnected";
49
+ broker.lastErrorAt = at;
50
+ if (broker.disconnectedAt === null) broker.disconnectedAt = at;
51
+ broker.reconnectAttempts = 0;
52
+ } else if (event.type === "reconnecting") {
53
+ // #276 — every failed reconnect attempt is counted here; the broker logs them rate-limited.
54
+ if (broker.state === "connected") broker.state = "disconnected";
55
+ if (broker.disconnectedAt === null) {
56
+ broker.disconnectedAt = typeof event.data?.disconnectedAt === "string" ? event.data.disconnectedAt : at;
57
+ }
58
+ broker.reconnectAttempts = Number.isSafeInteger(event.data?.attempts) ? event.data.attempts : broker.reconnectAttempts + 1;
59
+ broker.lastError = "broker.reconnecting";
60
+ broker.lastErrorAt = at;
61
+ } else if (event.type === "error" || event.type === "staleConnection") {
62
+ const reason = safeReason(event.data?.reason);
63
+ broker.lastError = reason;
64
+ broker.lastErrorAt = at;
65
+ if (reason === "broker.unauthorized" && broker.state !== "connected") broker.state = "unauthorized";
66
+ } else if (event.type === "closed") {
67
+ // The client gave up (auth abort, attempts exhausted): nothing reconnects this process.
68
+ const reason = safeReason(event.data?.reason);
69
+ broker.state = "closed";
70
+ broker.lastError = reason;
71
+ broker.lastErrorAt = at;
72
+ if (broker.disconnectedAt === null) broker.disconnectedAt = at;
73
+ log("error", "Server connection closed for good; it will not reconnect on its own", { reason });
31
74
  } else if (event.type === "reconnect") {
32
- snapshot.broker.state = "connected";
33
- snapshot.broker.connectedAt = new Date().toISOString();
75
+ broker.state = "connected";
76
+ broker.connectedAt = at;
77
+ broker.disconnectedAt = null;
78
+ broker.reconnectAttempts = 0;
34
79
  }
35
- void write();
80
+ return write();
81
+ },
82
+ /** Read-only copy of the broker part of the snapshot. */
83
+ broker() { return { ...snapshot.broker }; },
84
+ /** Milliseconds since the server link was lost; 0 while connected. */
85
+ disconnectedForMs(now = Date.now()) {
86
+ const broker = snapshot.broker;
87
+ if (broker.state === "connected" || !broker.disconnectedAt) return 0;
88
+ return Math.max(0, now - Date.parse(broker.disconnectedAt));
36
89
  },
37
90
  observeLog(level, message, data) {
91
+ // This verdict was already committed to its message row. Let status follow
92
+ // that row (including an explicit CLI dismissal), not a sticky runtime fault.
93
+ if (message === 'WakeMonitor wake dead-lettered' && data?.error === 'accepted-turn-unobservable') return;
38
94
  // Preserve a monitor crash even when SQLite could not record the failed wake.
39
95
  // Never persist hook commands, stdout, message text or arbitrary error strings.
40
96
  if ((level === "error" || level === "fatal") && message.startsWith("WakeMonitor")) {
@@ -43,6 +99,6 @@ export function createDaemonObservation({ dataDir, storePath, agentId, wake, log
43
99
  return write();
44
100
  }
45
101
  },
46
- stop() { if (timer) clearInterval(timer); },
102
+ async stop() { if (timer) clearInterval(timer); await writes; },
47
103
  };
48
104
  }