@murmurv2/cli 2.11.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/bin/murmur-npm.mjs +6 -0
  4. package/package.json +42 -0
  5. package/runtime/bin/murmur-svc.exe +0 -0
  6. package/runtime/npm-runtime-manifest.json +299 -0
  7. package/runtime/package.json +1 -0
  8. package/runtime/packages/mcp-server/dist/src/index.js +1 -0
  9. package/runtime/packages/mcp-server/package.json +1 -0
  10. package/runtime/packages/setup/bin/murmur.mjs +26 -0
  11. package/runtime/packages/setup/dist/src/appdata.d.ts +9 -0
  12. package/runtime/packages/setup/dist/src/appdata.js +53 -0
  13. package/runtime/packages/setup/dist/src/cli.d.ts +9 -0
  14. package/runtime/packages/setup/dist/src/cli.js +130 -0
  15. package/runtime/packages/setup/dist/src/clients.d.ts +151 -0
  16. package/runtime/packages/setup/dist/src/clients.js +251 -0
  17. package/runtime/packages/setup/dist/src/commands.d.ts +35 -0
  18. package/runtime/packages/setup/dist/src/commands.js +115 -0
  19. package/runtime/packages/setup/dist/src/config.d.ts +49 -0
  20. package/runtime/packages/setup/dist/src/config.js +70 -0
  21. package/runtime/packages/setup/dist/src/doctor.d.ts +25 -0
  22. package/runtime/packages/setup/dist/src/doctor.js +174 -0
  23. package/runtime/packages/setup/dist/src/file-identity.d.ts +3 -0
  24. package/runtime/packages/setup/dist/src/file-identity.js +11 -0
  25. package/runtime/packages/setup/dist/src/index.d.ts +7 -0
  26. package/runtime/packages/setup/dist/src/index.js +7 -0
  27. package/runtime/packages/setup/dist/src/onboarding.d.ts +46 -0
  28. package/runtime/packages/setup/dist/src/onboarding.js +298 -0
  29. package/runtime/packages/setup/dist/src/outbox-attention.d.ts +51 -0
  30. package/runtime/packages/setup/dist/src/outbox-attention.js +98 -0
  31. package/runtime/packages/setup/dist/src/paths.d.ts +10 -0
  32. package/runtime/packages/setup/dist/src/paths.js +41 -0
  33. package/runtime/packages/setup/dist/src/platform/darwin.d.ts +17 -0
  34. package/runtime/packages/setup/dist/src/platform/darwin.js +270 -0
  35. package/runtime/packages/setup/dist/src/platform/linux.d.ts +9 -0
  36. package/runtime/packages/setup/dist/src/platform/linux.js +176 -0
  37. package/runtime/packages/setup/dist/src/platform/windows.d.ts +25 -0
  38. package/runtime/packages/setup/dist/src/platform/windows.js +189 -0
  39. package/runtime/packages/setup/dist/src/private-file.d.ts +5 -0
  40. package/runtime/packages/setup/dist/src/private-file.js +101 -0
  41. package/runtime/packages/setup/dist/src/reply-test.d.ts +25 -0
  42. package/runtime/packages/setup/dist/src/reply-test.js +75 -0
  43. package/runtime/packages/setup/dist/src/state.d.ts +3 -0
  44. package/runtime/packages/setup/dist/src/state.js +12 -0
  45. package/runtime/packages/setup/dist/src/status-line.d.ts +5 -0
  46. package/runtime/packages/setup/dist/src/status-line.js +19 -0
  47. package/runtime/packages/setup/dist/src/status.d.ts +137 -0
  48. package/runtime/packages/setup/dist/src/status.js +199 -0
  49. package/runtime/packages/setup/dist/src/types.d.ts +40 -0
  50. package/runtime/packages/setup/dist/src/types.js +1 -0
  51. package/runtime/packages/setup/dist/src/updates.d.ts +46 -0
  52. package/runtime/packages/setup/dist/src/updates.js +284 -0
  53. package/runtime/packages/setup/dist/src/verdict.d.ts +11 -0
  54. package/runtime/packages/setup/dist/src/verdict.js +159 -0
  55. package/runtime/packages/setup/package.json +1 -0
  56. package/runtime/plugins/claude-code/.claude-plugin/plugin.json +34 -0
  57. package/runtime/plugins/claude-code/.mcp.json +8 -0
  58. package/runtime/plugins/claude-code/scripts/configure-statusline.mjs +124 -0
  59. package/runtime/plugins/claude-code/scripts/statusline.mjs +210 -0
  60. package/runtime/plugins/claude-code/skills/inbox/SKILL.md +8 -0
  61. package/runtime/plugins/claude-code/skills/mark-read/SKILL.md +13 -0
  62. package/runtime/plugins/claude-code/skills/status/SKILL.md +13 -0
  63. package/runtime/scripts/ack-security.mjs +22 -0
  64. package/runtime/scripts/codex-app-server-wake.mjs +696 -0
  65. package/runtime/scripts/daemon-observation.mjs +48 -0
  66. package/runtime/scripts/doctor-protocol.mjs +96 -0
  67. package/runtime/scripts/lease.mjs +193 -0
  68. package/runtime/scripts/murmur-daemon.mjs +545 -0
  69. package/runtime/scripts/murmur-jetstream-advisory.mjs +11 -0
  70. package/runtime/scripts/murmur-shell-send.mjs +142 -0
  71. package/runtime/scripts/notify-router.mjs +309 -0
  72. package/runtime/scripts/runtime-capability.mjs +34 -0
  73. package/runtime/scripts/secure-state.mjs +105 -0
  74. package/runtime/scripts/wake-drain-claude.mjs +484 -0
  75. package/runtime/scripts/wake-monitor.mjs +653 -0
@@ -0,0 +1,115 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { access, mkdir, realpath, rmdir, stat } from 'node:fs/promises';
5
+ import { loadConfig, readJson } from './config.js';
6
+ import { readStatus } from './status.js';
7
+ import { writeState } from './state.js';
8
+ /** Observe the configured directory only; this does not prove a live daemon's log destination. */
9
+ export async function readLogPath(context) {
10
+ const config = await loadConfig(context);
11
+ try {
12
+ const dataDir = await realpath(context.dataDir);
13
+ const logDir = await realpath(context.logDir);
14
+ const relative = path.relative(dataDir, logDir);
15
+ // Resolve aliases before containment checks; never open an unrelated directory as this profile's logs.
16
+ if (!relative || path.isAbsolute(relative) || relative === '..' || relative.startsWith('..' + path.sep)) {
17
+ throw new Error('logs.path-outside-profile');
18
+ }
19
+ if (!(await stat(logDir)).isDirectory())
20
+ throw new Error('logs.not-directory');
21
+ await access(logDir, constants.R_OK | constants.X_OK);
22
+ return { schema: 'murmur.logs/1', agentId: config.agentId, dataDir,
23
+ serviceName: context.serviceName, logDir, source: 'configured' };
24
+ }
25
+ catch (error) {
26
+ if (error instanceof Error && ['logs.path-outside-profile', 'logs.not-directory'].includes(error.message))
27
+ throw error;
28
+ switch (error.code) {
29
+ case 'ENOENT': throw new Error('logs.directory-missing');
30
+ case 'ENOTDIR': throw new Error('logs.not-directory');
31
+ case 'EACCES':
32
+ case 'EPERM': throw new Error('logs.directory-unreadable');
33
+ default: throw new Error('logs.path-unavailable');
34
+ }
35
+ }
36
+ }
37
+ /** Explicit local configuration mutation, serialized against other setup writers. */
38
+ export async function setWakeEnabled(context, adapter, enabled, apply = false) {
39
+ const lock = path.join(context.dataDir, '.setup-write.lock');
40
+ await mkdir(lock, { mode: 0o700 });
41
+ let backup = null;
42
+ try {
43
+ const config = await loadConfig(context);
44
+ if ((config.wake?.enabled !== false) !== enabled) {
45
+ backup = path.join(context.dataDir, `agent-config.backup-${randomUUID()}.json`);
46
+ await writeState(context, backup, config);
47
+ await writeState(context, context.configPath, { ...config, wake: { ...config.wake, enabled } });
48
+ }
49
+ }
50
+ finally {
51
+ await rmdir(lock);
52
+ }
53
+ let applyError = null;
54
+ if (apply) {
55
+ try {
56
+ await adapter.stop(context);
57
+ await adapter.start(context);
58
+ }
59
+ catch {
60
+ applyError = 'wake.service-apply-failed';
61
+ }
62
+ }
63
+ const status = await readStatus({ context, adapter });
64
+ const effectiveEnabled = status.wake.effective.enabled;
65
+ return { schema: 'murmur.wake/1', configuredEnabled: enabled, effectiveEnabled,
66
+ restartRequired: effectiveEnabled !== enabled, backup, applyError };
67
+ }
68
+ /** Explicit read action only; merely displaying status never consumes unread state. */
69
+ export async function markInboxRead(context) {
70
+ const { DatabaseSync } = await import('node:sqlite');
71
+ const config = await loadConfig(context);
72
+ const db = new DatabaseSync(context.storePath, { readOnly: true });
73
+ try {
74
+ const rowid = Number(db.prepare("SELECT COALESCE(MAX(rowid),0) AS rowid FROM local_messages WHERE direction='inbound'").get().rowid);
75
+ const state = { schema: 'murmur.read/1', agentId: config.agentId, rowid };
76
+ await writeState(context, path.join(context.dataDir, 'read-state.json'), state);
77
+ return state;
78
+ }
79
+ finally {
80
+ db.close();
81
+ }
82
+ }
83
+ /** Return durable inbound rows without advancing the independent read cursor. */
84
+ export async function readInbox(context, limit = 20) {
85
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
86
+ throw new Error('inbox.limit-invalid');
87
+ const { DatabaseSync } = await import('node:sqlite');
88
+ const config = await loadConfig(context);
89
+ const db = new DatabaseSync(context.storePath, { readOnly: true });
90
+ try {
91
+ db.exec('PRAGMA busy_timeout=1000; BEGIN');
92
+ const tip = Number(db.prepare("SELECT COALESCE(MAX(rowid),0) AS rowid FROM local_messages WHERE direction='inbound'").get().rowid);
93
+ let cursor = null;
94
+ try {
95
+ const state = await readJson(path.join(context.dataDir, 'read-state.json'));
96
+ if (state.schema !== 'murmur.read/1' || state.agentId !== config.agentId || !Number.isSafeInteger(state.rowid)
97
+ || state.rowid < 0 || state.rowid > tip)
98
+ throw new Error('inbox.cursor-invalid');
99
+ cursor = state.rowid;
100
+ }
101
+ catch { /* Reading messages must not invent or repair read state. */ }
102
+ const unread = cursor === null ? null : Number(db.prepare("SELECT COUNT(*) AS n FROM local_messages WHERE direction='inbound' AND rowid>?").get(cursor).n);
103
+ const rows = db.prepare(`SELECT rowid,conversation_id AS conversationId,msg_id AS msgId,sender,text,
104
+ created_at AS createdAt,transport,channel_id AS channelId,sender_member_id AS senderMemberId,
105
+ addressee_member_id AS addresseeMemberId FROM local_messages WHERE direction='inbound'
106
+ ORDER BY created_at DESC,rowid DESC LIMIT ?`).all(limit);
107
+ db.exec('COMMIT');
108
+ return { schema: 'murmur.inbox/1', agentId: config.agentId, readCursor: cursor,
109
+ unread,
110
+ messages: rows.map(({ rowid, ...row }) => ({ ...row, unread: cursor === null ? null : Number(rowid) > cursor })) };
111
+ }
112
+ finally {
113
+ db.close();
114
+ }
115
+ }
@@ -0,0 +1,49 @@
1
+ import type { ServiceContext } from "./types.js";
2
+ export interface PeerConfig {
3
+ subject: string;
4
+ encryption: {
5
+ publicKey: string;
6
+ };
7
+ signing: {
8
+ publicKey: string;
9
+ };
10
+ channelId?: string;
11
+ memberId?: string;
12
+ subjectScoping?: boolean;
13
+ }
14
+ export interface AgentConfig {
15
+ agentId: string;
16
+ subject: string;
17
+ natsUrl: string;
18
+ natsToken?: string;
19
+ keys: {
20
+ encryption: {
21
+ publicKey: string;
22
+ privateKey: string;
23
+ };
24
+ signing: {
25
+ publicKey: string;
26
+ privateKey: string;
27
+ };
28
+ };
29
+ peers: Record<string, PeerConfig>;
30
+ memberId?: string;
31
+ subjectScoping?: {
32
+ enabled?: boolean;
33
+ channelIds?: string[];
34
+ };
35
+ onReceive?: string;
36
+ wake?: {
37
+ enabled?: boolean;
38
+ mode?: string;
39
+ peers?: Record<string, {
40
+ mode?: string;
41
+ }>;
42
+ };
43
+ }
44
+ export declare const validAgentId: (v: unknown) => v is string;
45
+ export declare function validateConfig(input: unknown): AgentConfig;
46
+ /** Read-only: status and doctor never chmod, initialize, or migrate user state. */
47
+ export declare function readJson(file: string): Promise<any>;
48
+ export declare function loadConfig(context: ServiceContext): Promise<AgentConfig>;
49
+ export declare function safeError(error: unknown): string;
@@ -0,0 +1,70 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
4
+ export const validAgentId = (v) => typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(v);
5
+ const validKey = (v) => typeof v === "string" && Buffer.from(v, "base64").length === 32 && Buffer.from(v, "base64").toString("base64") === v;
6
+ export function validateConfig(input) {
7
+ if (!object(input) || !validAgentId(input.agentId) || input.subject !== `msg.${input.agentId}`)
8
+ throw new Error("config.identity-invalid");
9
+ let url;
10
+ try {
11
+ url = new URL(input.natsUrl);
12
+ }
13
+ catch {
14
+ throw new Error("config.broker-url-invalid");
15
+ }
16
+ if (!["nats:", "tls:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
17
+ throw new Error("config.broker-url-invalid");
18
+ for (const kind of ["encryption", "signing"]) {
19
+ if (!validKey(input.keys?.[kind]?.publicKey) || !validKey(input.keys?.[kind]?.privateKey))
20
+ throw new Error("config.keys-invalid");
21
+ }
22
+ if (!object(input.peers))
23
+ throw new Error("config.peers-invalid");
24
+ for (const [id, peer] of Object.entries(input.peers)) {
25
+ if (!validAgentId(id) || !object(peer) || typeof peer.subject !== "string" || !/^[A-Za-z0-9_.:-]+$/.test(peer.subject)
26
+ || !validKey(peer.encryption?.publicKey) || !validKey(peer.signing?.publicKey))
27
+ throw new Error("config.peer-invalid");
28
+ }
29
+ return input;
30
+ }
31
+ /** Read-only: status and doctor never chmod, initialize, or migrate user state. */
32
+ export async function readJson(file) {
33
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
34
+ try {
35
+ const stat = await handle.stat();
36
+ if (!stat.isFile() || stat.size > 4 * 1024 * 1024)
37
+ throw new Error("config.file-invalid");
38
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
39
+ throw new Error("config.owner-mismatch");
40
+ return JSON.parse(await handle.readFile("utf8"));
41
+ }
42
+ finally {
43
+ await handle.close();
44
+ }
45
+ }
46
+ export async function loadConfig(context) {
47
+ try {
48
+ return validateConfig(await readJson(context.configPath));
49
+ }
50
+ catch (e) {
51
+ if (e.code === "ENOENT")
52
+ throw new Error("config.missing");
53
+ throw e;
54
+ }
55
+ }
56
+ export function safeError(error) {
57
+ const value = error instanceof Error ? error.message : String(error ?? "");
58
+ if (value === "database is locked")
59
+ return value;
60
+ // Neither command output nor transport errors may leak tokens/message content.
61
+ if (/^[a-zA-Z][a-zA-Z0-9_.:-]{0,150}$/.test(value))
62
+ return value;
63
+ // System error messages embed paths; their errno code and syscall alone are safe and actionable
64
+ // (an unreadable invite file must not surface as a bare operation-failed).
65
+ const { code, syscall } = (error ?? {});
66
+ if (typeof code === "string" && /^E[A-Z0-9]{1,30}$/.test(code)) {
67
+ return typeof syscall === "string" && /^[a-z_]{1,30}$/.test(syscall) ? `system.${code}:${syscall}` : `system.${code}`;
68
+ }
69
+ return "operation-failed";
70
+ }
@@ -0,0 +1,25 @@
1
+ import { type NatsConnection } from 'nats';
2
+ import { type AgentConfig } from './config.js';
3
+ import type { ServiceContext, PlatformAdapter } from './types.js';
4
+ export interface DoctorOptions {
5
+ context: ServiceContext;
6
+ adapter: PlatformAdapter;
7
+ peer?: string;
8
+ timeoutMs?: number;
9
+ }
10
+ export declare function probeRoundtrip(c: ServiceContext, config: AgentConfig, peerId: string, connection: NatsConnection, timeoutMs: number): Promise<{
11
+ msgId: string;
12
+ replyMsgId: string;
13
+ peerId: string;
14
+ verifiedAt: string;
15
+ }>;
16
+ export declare function runDoctor({ context, adapter, peer, timeoutMs }: DoctorOptions): Promise<{
17
+ schema: string;
18
+ generatedAt: string;
19
+ agentId: string | null;
20
+ stages: Record<string, unknown>[];
21
+ summary: {
22
+ worst: string;
23
+ failedStage: null;
24
+ };
25
+ }>;
@@ -0,0 +1,174 @@
1
+ import { connect, StringCodec } from 'nats';
2
+ import { randomBytes, randomUUID } from 'node:crypto';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import path from 'node:path';
5
+ import { realpath } from 'node:fs/promises';
6
+ import { buildNatsConnectionOptions } from '@murmurv2/broker-nats';
7
+ import { channelSubjectRoutes, resolveMessageSubject, SQLiteDedupeOutboxStore, stableEnvelopePayload, isEnvelopeV1 } from '@murmurv2/core';
8
+ import { encryptPayload, signEnvelope, verifyEnvelopeSignature, decryptPayload } from '@murmurv2/security';
9
+ import { loadConfig, readJson, safeError } from './config.js';
10
+ import { pairFingerprint, readStatus } from './status.js';
11
+ import { writeState } from './state.js';
12
+ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
13
+ export async function probeRoundtrip(c, config, peerId, connection, timeoutMs) {
14
+ const peer = config.peers[peerId];
15
+ if (!peer)
16
+ throw new Error('peers.unknown');
17
+ const nonce = randomBytes(24).toString('hex'), conversationId = `murmur:doctor:${randomUUID()}`;
18
+ const text = `Murmur diagnostic roundtrip. Reply in this same conversation with exactly this line: MURMUR-DOCTOR-REPLY ${nonce}`;
19
+ const payload = await encryptPayload(text, peer.encryption.publicKey, config.keys.encryption.privateKey);
20
+ const envelope = { schemaVersion: '1.0', msgId: randomUUID(), conversationId, senderAgentId: config.agentId,
21
+ recipients: [peerId], createdAt: new Date().toISOString(), payloadCiphertext: payload.ciphertext, payloadNonce: payload.nonce, signature: '',
22
+ ...(peer.channelId ? { channelId: peer.channelId } : {}), ...(config.memberId ? { senderMemberId: config.memberId } : {}), ...(peer.memberId ? { addresseeMemberId: peer.memberId } : {}) };
23
+ envelope.signature = await signEnvelope(stableEnvelopePayload(envelope), config.keys.signing.privateKey);
24
+ const codec = StringCodec();
25
+ // Subscribe only to this profile's receiver routes; restricted broker ACLs reject mesh-wide taps.
26
+ const subs = channelSubjectRoutes(config.subject, config.agentId, config.subjectScoping).map(route => connection.subscribe(route.subject));
27
+ const started = Date.now(), deadline = started + timeoutMs;
28
+ let verified = null;
29
+ const listener = Promise.all(subs.map(async (sub) => {
30
+ for await (const message of sub) {
31
+ try {
32
+ const reply = JSON.parse(codec.decode(message.data));
33
+ if (!isEnvelopeV1(reply) || reply.senderAgentId !== peerId || reply.conversationId !== conversationId
34
+ || !reply.recipients.includes(config.agentId) || reply.channelId !== envelope.channelId
35
+ || Date.parse(reply.createdAt) < started - 5000 || Date.parse(reply.createdAt) > Date.now() + 5000)
36
+ continue;
37
+ if (!await verifyEnvelopeSignature(stableEnvelopePayload(reply), reply.signature, peer.signing.publicKey))
38
+ continue;
39
+ const plain = await decryptPayload({ ciphertext: reply.payloadCiphertext, nonce: reply.payloadNonce, senderPublicKey: peer.encryption.publicKey }, config.keys.encryption.privateKey);
40
+ if (plain.trim() === `MURMUR-DOCTOR-REPLY ${nonce}`)
41
+ verified = { msgId: reply.msgId, text: plain };
42
+ }
43
+ catch { /* Unrelated or untrusted frames are not probe evidence. */ }
44
+ }
45
+ }));
46
+ let db;
47
+ try {
48
+ await connection.flush();
49
+ // Reuse the real daemon outbox path. A publisher-only probe could hide a broken daemon.
50
+ const outbox = new SQLiteDedupeOutboxStore(c.storePath);
51
+ try {
52
+ await outbox.enqueue(resolveMessageSubject(peer, peer.channelId), envelope);
53
+ }
54
+ finally {
55
+ outbox.close();
56
+ }
57
+ db = new DatabaseSync(c.storePath, { readOnly: true });
58
+ while (Date.now() < deadline) {
59
+ const candidate = verified;
60
+ if (candidate) {
61
+ const row = db.prepare("SELECT text FROM local_messages WHERE direction='inbound' AND msg_id=? AND sender=? AND conversation_id=?").get(candidate.msgId, peerId, conversationId);
62
+ if (row?.text === candidate.text) {
63
+ const verifiedAt = new Date().toISOString();
64
+ let proofs = {};
65
+ try {
66
+ proofs = await readJson(path.join(c.dataDir, 'pair-proofs.json'));
67
+ }
68
+ catch { }
69
+ await writeState(c, path.join(c.dataDir, 'pair-proofs.json'), { ...proofs,
70
+ [peerId]: { peerId, localAgentId: config.agentId, keyFingerprint: pairFingerprint(config, peerId), verifiedAt } });
71
+ return { msgId: envelope.msgId, replyMsgId: candidate.msgId, peerId, verifiedAt };
72
+ }
73
+ }
74
+ await delay(Math.min(50, Math.max(0, deadline - Date.now())));
75
+ }
76
+ throw new Error('roundtrip.timeout');
77
+ }
78
+ finally {
79
+ db?.close();
80
+ for (const sub of subs)
81
+ sub.unsubscribe();
82
+ await listener;
83
+ }
84
+ }
85
+ // The next command for a failure a new user meets first. Keyed by the stable reason code only; the
86
+ // selected service name is included because a command without it would select a different service.
87
+ // resolveContext already restricts serviceName to [A-Za-z0-9._-].
88
+ const failureHints = (serviceName) => ({
89
+ 'config.missing': 'No profile here yet: run murmur join --data-dir <this profile> --agent-id <your-agent-id> --invite-file <invite file> --reply-out <new reply file>',
90
+ 'daemon.not-running': `Install or start the service: murmur service install --data-dir <this profile> --service-name ${serviceName} (or service start with the same options if installed; on Windows from an administrator terminal)`,
91
+ });
92
+ export async function runDoctor({ context, adapter, peer, timeoutMs = 10000 }) {
93
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 60000)
94
+ throw new Error('doctor.invalid-timeout');
95
+ const generatedAt = new Date().toISOString(), stages = [];
96
+ let config = null, connection;
97
+ let snapshot;
98
+ let failedStage = null, worst = 'ok';
99
+ const stage = async (id, title, fn) => {
100
+ const start = Date.now();
101
+ if (failedStage) {
102
+ stages.push({ id, title, state: 'skip', reason: `blocked-by:${failedStage}`, detail: 'Not measured after earlier failure', fixHint: null, elapsedMs: id === 'wake' ? null : 0, measuredAt: null });
103
+ return;
104
+ }
105
+ try {
106
+ const result = await fn(), state = result.state ?? 'ok';
107
+ if (state === 'warn' && worst === 'ok')
108
+ worst = 'warn';
109
+ stages.push({ id, title, state, detail: result.detail, reason: result.reason ?? null, fixHint: result.fixHint ?? null,
110
+ elapsedMs: id === 'wake' ? null : Date.now() - start, measuredAt: new Date().toISOString() });
111
+ }
112
+ catch (e) {
113
+ failedStage = id;
114
+ worst = 'fail';
115
+ stages.push({ id, title, state: 'fail', reason: safeError(e), detail: `${title}: ${safeError(e)}`, fixHint: failureHints(context.serviceName)[safeError(e)] ?? null,
116
+ elapsedMs: id === 'wake' ? null : Date.now() - start, measuredAt: new Date().toISOString() });
117
+ }
118
+ };
119
+ try {
120
+ await stage('config', 'Configuration', async () => { config = await loadConfig(context); return { detail: 'Identity, keys and peer configuration validated' }; });
121
+ await stage('daemon', 'Daemon and store', async () => {
122
+ snapshot = await readStatus({ context, adapter });
123
+ if (snapshot.service.state !== 'running')
124
+ throw new Error('daemon.not-running');
125
+ if (!snapshot.service.observedStorePath || await realpath(snapshot.service.observedStorePath) !== await realpath(context.storePath))
126
+ throw new Error('daemon.store-unverified');
127
+ return { detail: 'Service PID holds the selected database open' };
128
+ });
129
+ await stage('broker', 'Broker authentication and RTT', async () => {
130
+ try {
131
+ connection = await connect({ ...buildNatsConnectionOptions({ url: config.natsUrl, token: config.natsToken }), reconnect: false, waitOnFirstConnect: false, timeout: timeoutMs });
132
+ await connection.rtt();
133
+ }
134
+ catch (e) {
135
+ throw new Error(e.code === 'AUTHORIZATION_VIOLATION' ? 'broker.unauthorized' : 'broker.unreachable');
136
+ }
137
+ return { detail: 'Broker accepted authentication and replied' };
138
+ });
139
+ await stage('peers', 'Peer pairing evidence', async () => {
140
+ if (peer && !config.peers[peer])
141
+ throw new Error('peers.unknown');
142
+ if (!Object.keys(config.peers).length)
143
+ throw new Error('peers.none');
144
+ const list = snapshot.peers.list ?? [];
145
+ return list.every(p => p.paired === true) ? { detail: 'Fresh two-way proofs exist for configured peers' }
146
+ : { state: 'warn', reason: 'peers.unmeasured', detail: 'Local keys alone do not prove mutual pairing', fixHint: 'Run murmur doctor --peer <configured-agent-id> --json' };
147
+ });
148
+ await stage('roundtrip', 'Encrypted signed roundtrip', async () => {
149
+ if (!peer)
150
+ return { state: 'warn', reason: 'roundtrip.peer-required', detail: 'No diagnostic peer selected; no message sent', fixHint: 'Run murmur doctor --peer <configured-agent-id> --json' };
151
+ const proof = await probeRoundtrip(context, config, peer, connection, timeoutMs);
152
+ return { detail: `Authenticated reply persisted from ${proof.peerId}` };
153
+ });
154
+ await stage('wake', 'Wake mode and responder', async () => {
155
+ const s = await readStatus({ context, adapter });
156
+ if (s.wake.effective.enabled === null)
157
+ return { state: 'warn', reason: 'wake.unmeasured', detail: 'No fresh runtime evidence' };
158
+ if (s.wake.effective.needsRestart)
159
+ return { state: 'warn', reason: 'wake.mode-mismatch', detail: 'Configured and effective wake differ', fixHint: 'Run murmur wake pause --apply or murmur wake resume --apply for the intended mode' };
160
+ if (!s.wake.effective.enabled)
161
+ return { state: 'warn', reason: 'wake.paused', detail: 'Wake is paused; pending messages remain queued' };
162
+ if (s.wake.config.mode === 'none' || s.wake.config.responder === 'none')
163
+ return { state: 'warn', reason: 'wake.no-responder', detail: 'No wake responder configured' };
164
+ if (s.wake.faults.lastFault)
165
+ throw new Error(s.wake.faults.lastFault);
166
+ // A reply is not evidence that a particular UI/session was awakened.
167
+ return { state: 'warn', reason: 'wake.live-proof-required', detail: 'Wake configured; intended live-session receipt requires separate proof' };
168
+ });
169
+ }
170
+ finally {
171
+ await connection?.close();
172
+ }
173
+ return { schema: 'murmur.doctor/1', generatedAt, agentId: config?.agentId ?? null, stages, summary: { worst, failedStage } };
174
+ }
@@ -0,0 +1,3 @@
1
+ import type { BigIntStats } from 'node:fs';
2
+ /** Windows/libuv may omit the path's volume serial; a full inode must still match. */
3
+ export declare function sameClientFileIdentity(a: Pick<BigIntStats, 'dev' | 'ino'>, b: Pick<BigIntStats, 'dev' | 'ino'>, platform?: NodeJS.Platform): boolean;
@@ -0,0 +1,11 @@
1
+ /** Windows/libuv may omit the path's volume serial; a full inode must still match. */
2
+ export function sameClientFileIdentity(a, b, platform = process.platform) {
3
+ if (a.ino !== b.ino)
4
+ return false;
5
+ if (platform !== 'win32')
6
+ return a.dev === b.dev;
7
+ // Node 22.13/libuv 1.49.2 can report path dev=0 with a nonzero handle dev.
8
+ // When both are available, compare the Windows volume serial's low 32 bits:
9
+ // https://github.com/libuv/libuv/commit/82cdfb75ff9bbd0dc65820ca418b7c5d412ff4d7
10
+ return a.dev === 0n || b.dev === 0n || BigInt.asUintN(32, a.dev) === BigInt.asUintN(32, b.dev);
11
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./types.js";
2
+ export * from "./paths.js";
3
+ export * from "./config.js";
4
+ export * from "./status.js";
5
+ export * from "./verdict.js";
6
+ export * from "./status-line.js";
7
+ export { createLinuxAdapter } from "./platform/linux.js";
@@ -0,0 +1,7 @@
1
+ export * from "./types.js";
2
+ export * from "./paths.js";
3
+ export * from "./config.js";
4
+ export * from "./status.js";
5
+ export * from "./verdict.js";
6
+ export * from "./status-line.js";
7
+ export { createLinuxAdapter } from "./platform/linux.js";
@@ -0,0 +1,46 @@
1
+ import type { ServiceContext } from './types.js';
2
+ export declare function initialize(c: ServiceContext, options: {
3
+ agentId: string;
4
+ brokerUrl: string;
5
+ tokenFile?: string;
6
+ }): Promise<{
7
+ schema: string;
8
+ agentId: string;
9
+ dataDir: string;
10
+ serviceName: string;
11
+ existing: boolean;
12
+ }>;
13
+ export declare function invite(c: ServiceContext, outFile: string): Promise<{
14
+ schema: string;
15
+ file: string;
16
+ containsBrokerCredential: boolean;
17
+ instruction: string;
18
+ }>;
19
+ export declare function join(c: ServiceContext, options: {
20
+ agentId: string;
21
+ inviteFile: string;
22
+ replyOut: string;
23
+ }): Promise<{
24
+ schema: string;
25
+ agentId: string;
26
+ peerId: any;
27
+ paired: null;
28
+ replyFile: string;
29
+ backup: null;
30
+ restartRequired: boolean;
31
+ serviceName: string;
32
+ }>;
33
+ export declare function importPeer(c: ServiceContext, replyFile: string): Promise<{
34
+ schema: string;
35
+ peerId: any;
36
+ paired: null;
37
+ backup: string | null;
38
+ poisonReset: {
39
+ cleared: number;
40
+ reason: null;
41
+ } | {
42
+ cleared: null;
43
+ reason: string;
44
+ };
45
+ restartRequired: boolean;
46
+ }>;