@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
@@ -1,5 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { realpath, stat } from 'node:fs/promises';
2
+ import { access, realpath, stat } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
3
4
  import { homedir } from 'node:os';
4
5
  import path from 'node:path';
5
6
  const paths = path.win32;
@@ -102,7 +103,10 @@ export function createWindowsAdapter(options = {}) {
102
103
  || !(v.lastExitCode === null || Number.isSafeInteger(v.lastExitCode))
103
104
  || !['since', 'lastFailureAt', 'observedStorePath', 'observedStoreUnknownReason', 'restartsUnknownReason'].every(k => textOrNull(v[k]))
104
105
  || !['since', 'lastFailureAt'].every(k => v[k] === null || Number.isFinite(Date.parse(v[k])))
105
- || v.restartsPerHourLimit !== 5)
106
+ || v.restartsPerHourLimit !== 5
107
+ // Older helpers omit foreignKind; it describes only a Service that is not this installation's.
108
+ || !(v.foreignKind === undefined || v.foreignKind === null
109
+ || v.manager === 'foreign' && ['previous-installation', 'not-murmur'].includes(v.foreignKind)))
106
110
  fail('native-response-invalid');
107
111
  if (!(v.restartCount === null && v.restartWindowMs === null || natural(v.restartCount) && natural(v.restartWindowMs) && v.restartWindowMs <= 3600000)
108
112
  || (v.restartWindowMs === 3600000 ? v.restartsLastHour !== v.restartCount : v.restartsLastHour !== null))
@@ -112,7 +116,12 @@ export function createWindowsAdapter(options = {}) {
112
116
  fail('native-response-invalid');
113
117
  return { snapshot: { ...empty('service.not-installed'), state: 'stopped' }, installed: false };
114
118
  }
115
- if (v.manager === 'foreign' || !object(v.profile))
119
+ // A Service left by another Murmur installation (a pilot or an earlier version) can be replaced
120
+ // on request; a name taken by another program never is. Anything else stays unverified.
121
+ if (v.manager === 'foreign')
122
+ fail(v.foreignKind === 'previous-installation' ? 'previous-installation'
123
+ : v.foreignKind === 'not-murmur' ? 'foreign-image' : 'profile-unverified');
124
+ if (!object(v.profile))
116
125
  fail('profile-unverified');
117
126
  const expected = { dataDir: c.dataDir, node: c.nodePath, workDir: c.repoRoot, entry: paths.join(c.repoRoot, 'scripts', 'murmur-daemon.mjs') };
118
127
  for (const [key, value] of Object.entries(expected)) {
@@ -151,8 +160,63 @@ export function createWindowsAdapter(options = {}) {
151
160
  || (action === 'stop' ? after.snapshot.state !== 'stopped' : after.snapshot.state !== 'running' || after.snapshot.pid === null))
152
161
  fail('action-unconfirmed');
153
162
  }
163
+ /** Removes only a Service that another Murmur installation registered under this name. */
164
+ async function removePrevious(c) {
165
+ validate(c);
166
+ const found = await inspect(c).then(() => null, (error) => error);
167
+ if (found === null)
168
+ return; // Absent or already this installation's: nothing to replace.
169
+ if (!(found instanceof Error && found.message === 'service.previous-installation'))
170
+ throw found;
171
+ if (await elevated() === false)
172
+ fail('elevation-required');
173
+ await invoke(c, 'remove-previous');
174
+ const after = await inspect(c).catch(() => null);
175
+ if (!after || after.installed)
176
+ fail('action-unconfirmed');
177
+ }
154
178
  return {
155
179
  manager: 'windows-service',
180
+ removePrevious,
181
+ async logDirectory(c) {
182
+ validate(c);
183
+ if (!(await inspect(c)).installed)
184
+ throw new Error('logs.service-not-installed');
185
+ const programData = Object.entries(env).find(([key]) => key.toLowerCase() === 'programdata')?.[1];
186
+ if (!programData || !paths.isAbsolute(programData))
187
+ throw new Error('logs.windows-native-location-unavailable');
188
+ // This is the native helper's ProgramData/Murmur/logs/<service> layout.
189
+ // Verify ownership above and containment after resolving junctions below.
190
+ try {
191
+ const root = await canonicalize(paths.join(programData, 'Murmur', 'logs'));
192
+ const logDir = await canonicalize(paths.join(programData, 'Murmur', 'logs', c.serviceName));
193
+ if (paths.relative(root, logDir) !== c.serviceName)
194
+ throw new Error('logs.path-outside-service');
195
+ if (!(await stat(logDir)).isDirectory())
196
+ throw new Error('logs.not-directory');
197
+ await access(logDir, constants.R_OK);
198
+ return logDir;
199
+ }
200
+ catch (error) {
201
+ if (error instanceof Error && error.message.startsWith('logs.'))
202
+ throw error;
203
+ const code = error.code;
204
+ throw new Error(code === 'ENOENT' ? 'logs.directory-missing' : code === 'EACCES' || code === 'EPERM' ? 'logs.directory-unreadable' : 'logs.path-unavailable');
205
+ }
206
+ },
207
+ async observeStore(c, pid) {
208
+ validate(c);
209
+ if (!natural(pid) || pid === 0)
210
+ return null;
211
+ const result = await run(await helper(c), ['observe-store', String(pid)], { env: selectedEnvironment(c), timeout: 8000 });
212
+ if (result.code !== 0)
213
+ return null; // Older helpers cannot provide this proof.
214
+ const v = JSON.parse(result.stdout);
215
+ if (!object(v) || v.schema !== 'murmur.store-proof/1' || v.pid !== pid || typeof v.observedStorePath !== 'string')
216
+ return null;
217
+ absolute(v.observedStorePath);
218
+ return await same(v.observedStorePath, c.storePath) ? canonicalize(v.observedStorePath) : null;
219
+ },
156
220
  async status(c) {
157
221
  try {
158
222
  return (await inspect(c)).snapshot;
@@ -6,6 +6,14 @@ export type Measurement = {
6
6
  };
7
7
  export declare const pairFingerprint: (config: AgentConfig, peerId: string) => string;
8
8
  export declare function validPairProof(proof: any, config: AgentConfig, peerId: string, now: number): boolean;
9
+ interface ContactReload {
10
+ state: "current" | "partial" | "retained";
11
+ count: number;
12
+ invalidCount: number;
13
+ lastSuccessAt: string | null;
14
+ lastError: string | null;
15
+ lastErrorAt: string | null;
16
+ }
9
17
  export interface StatusOptions {
10
18
  context: ServiceContext;
11
19
  adapter: PlatformAdapter;
@@ -14,9 +22,10 @@ export interface StatusOptions {
14
22
  export declare function readStatus({ context: c, adapter, now }: StatusOptions): Promise<{
15
23
  schema: "murmur.status/1";
16
24
  generatedAt: string;
17
- agentId: string | null;
25
+ agentId: any;
18
26
  serviceName: string;
19
27
  service: {
28
+ managed: boolean | null;
20
29
  lastFailureAt: null;
21
30
  restartsLastHour: number | null;
22
31
  restartFailureThreshold: number;
@@ -25,7 +34,7 @@ export declare function readStatus({ context: c, adapter, now }: StatusOptions):
25
34
  manager: Measurement;
26
35
  history: Measurement;
27
36
  };
28
- state: "running" | "stopped" | "failed" | "unknown";
37
+ state: "running" | "running-unmanaged" | "stopped" | "failed" | "unknown";
29
38
  manager: import("./types.js").ServiceManager;
30
39
  since: string | null;
31
40
  pid: number | null;
@@ -41,6 +50,8 @@ export declare function readStatus({ context: c, adapter, now }: StatusOptions):
41
50
  connectedAt: any;
42
51
  lastError: any;
43
52
  lastErrorAt: any;
53
+ disconnectedAt: any;
54
+ reconnectAttempts: any;
44
55
  unknownReason: string | null;
45
56
  measurements: {
46
57
  runtime: Measurement;
@@ -52,7 +63,10 @@ export declare function readStatus({ context: c, adapter, now }: StatusOptions):
52
63
  paired: boolean | null;
53
64
  lastInboundAt: string | null;
54
65
  lastOutboundAt: string | null;
66
+ lastExchangeAt: string | null;
67
+ connection: "connected" | "stale" | "unverified";
55
68
  }> | null;
69
+ reload: ContactReload | null;
56
70
  unknownReason: string | null;
57
71
  measurements: Record<string, Measurement>;
58
72
  };
@@ -135,3 +149,4 @@ export declare function readStatus({ context: c, adapter, now }: StatusOptions):
135
149
  };
136
150
  }>;
137
151
  export declare function configuredWakeMode(config: AgentConfig): "hook" | "monitor" | "none";
152
+ export {};
@@ -2,8 +2,9 @@ import { DatabaseSync } from "node:sqlite";
2
2
  import path from "node:path";
3
3
  import { realpath } from "node:fs/promises";
4
4
  import { createHash } from "node:crypto";
5
- import { loadConfig, readJson, safeError } from "./config.js";
5
+ import { loadConfig, readJson, safeError, validAgentId } from "./config.js";
6
6
  import { readOutboxAttention } from "./outbox-attention.js";
7
+ import { assistantRead, wakeColumns } from "./message-status.js";
7
8
  const measured = (at) => ({ measuredAt: at, unknownReason: null });
8
9
  const unknown = (reason) => ({ measuredAt: null, unknownReason: reason });
9
10
  export const pairFingerprint = (config, peerId) => createHash("sha256").update(JSON.stringify([
@@ -11,10 +12,27 @@ export const pairFingerprint = (config, peerId) => createHash("sha256").update(J
11
12
  config.peers[peerId]?.signing.publicKey, config.peers[peerId]?.encryption.publicKey,
12
13
  ])).digest("hex");
13
14
  export function validPairProof(proof, config, peerId, now) {
14
- const at = Date.parse(proof?.verifiedAt);
15
+ const at = pairProofTime(proof, config, peerId, now);
16
+ return at !== null && now - Date.parse(at) <= 86400000;
17
+ }
18
+ function pairProofTime(proof, config, peerId, now) {
15
19
  return proof?.peerId === peerId && proof?.localAgentId === config.agentId
16
20
  && proof?.keyFingerprint === pairFingerprint(config, peerId)
17
- && Number.isFinite(at) && now - at >= -5000 && now - at <= 86400000;
21
+ ? exchangeTime(proof.verifiedAt, now) : null;
22
+ }
23
+ function exchangeTime(value, now) {
24
+ const timestamp = typeof value === "string" ? Date.parse(value) : NaN;
25
+ return Number.isFinite(timestamp) && timestamp <= now + 5000 ? new Date(timestamp).toISOString() : null;
26
+ }
27
+ function contactReload(raw, now) {
28
+ if (!raw || !["current", "partial", "retained"].includes(raw.state)
29
+ || !Number.isSafeInteger(raw.count) || raw.count < 0
30
+ || !Number.isSafeInteger(raw.invalidCount) || raw.invalidCount < 0
31
+ || (raw.lastError !== null && (typeof raw.lastError !== "string" || !/^agent-config-[a-z-]+$/.test(raw.lastError))))
32
+ return null;
33
+ return { state: raw.state, count: raw.count, invalidCount: raw.invalidCount,
34
+ lastSuccessAt: exchangeTime(raw.lastSuccessAt, now), lastError: raw.lastError,
35
+ lastErrorAt: exchangeTime(raw.lastErrorAt, now) };
18
36
  }
19
37
  export async function readStatus({ context: c, adapter, now = Date.now }) {
20
38
  const started = now();
@@ -46,13 +64,33 @@ export async function readStatus({ context: c, adapter, now = Date.now }) {
46
64
  const raw = await readJson(path.join(c.dataDir, "daemon-observation.json"));
47
65
  const timestamp = Date.parse(raw.measuredAt);
48
66
  const storePath = await realpath(c.storePath);
49
- if (raw.schema === "murmur.runtime/1" && service.state === "running" && raw.pid === service.pid
50
- && raw.agentId === config?.agentId && raw.storePath === storePath && service.observedStorePath === storePath
67
+ if (raw.schema === "murmur.runtime/1" && Number.isSafeInteger(raw.pid) && raw.pid > 0
68
+ // A rejected on-disk config must not hide an already running process's
69
+ // reload diagnostic. Fresh PID/open-store evidence still gates acceptance.
70
+ && validAgentId(raw.agentId) && (!config || raw.agentId === config.agentId) && raw.storePath === storePath
51
71
  && Number.isFinite(timestamp) && started - timestamp >= -5000 && started - timestamp <= 15000
52
- && ["connected", "disconnected", "unauthorized", "unknown"].includes(raw.broker?.state)
72
+ && ["connected", "disconnected", "unauthorized", "closed", "unknown"].includes(raw.broker?.state)
53
73
  && typeof raw.wake?.enabled === "boolean" && ["hook", "monitor", "none"].includes(raw.wake?.mode)) {
54
- observation = raw;
55
- runtimeMeasurement = measured(raw.measuredAt);
74
+ const managed = service.state === "running" && raw.pid === service.pid && service.observedStorePath === storePath;
75
+ // A fresh file alone cannot prove liveness: the OS must observe this PID's
76
+ // open database. Never use process arguments or the configured service name.
77
+ const independent = !managed && service.state !== "failed"
78
+ ? await adapter.observeStore?.(c, raw.pid).catch(() => null) : null;
79
+ if (managed || independent === storePath) {
80
+ // A Service of a previous Murmur installation keeps its own label: the live process is
81
+ // most likely its child, and "running outside this app" would hide the replacement.
82
+ const previous = service.detail === "service.previous-installation";
83
+ if (!managed && raw.pid !== service.pid && !previous)
84
+ service = { state: "running-unmanaged", manager: "none", pid: raw.pid,
85
+ observedStorePath: storePath, since: exchangeTime(raw.startedAt, started), lastExitCode: null,
86
+ restartCount: null, restartWindowMs: null, detail: "service.running-unmanaged" };
87
+ else if (independent === storePath)
88
+ service = { ...service, observedStorePath: storePath };
89
+ observation = raw;
90
+ runtimeMeasurement = measured(raw.measuredAt);
91
+ }
92
+ else
93
+ runtimeMeasurement = unknown("runtime.process-store-unverified");
56
94
  }
57
95
  else
58
96
  runtimeMeasurement = unknown("runtime.observation-unverified-or-stale");
@@ -70,7 +108,9 @@ export async function readStatus({ context: c, adapter, now = Date.now }) {
70
108
  lastFault: null, lastFaultAt: null, unknownReason: null,
71
109
  measurements: { store: unknown("store.unavailable"), runtime: runtimeMeasurement } };
72
110
  const peers = {
73
- list: config ? Object.keys(config.peers).map((agentId) => ({ agentId, paired: null, lastInboundAt: null, lastOutboundAt: null })) : null,
111
+ list: config ? Object.keys(config.peers).map((agentId) => ({ agentId, paired: null, lastInboundAt: null, lastOutboundAt: null,
112
+ lastExchangeAt: null, connection: "unverified" })) : null,
113
+ reload: contactReload(observation?.contacts, started),
74
114
  unknownReason: configError, measurements: { config: config ? measured(at) : unknown(configError ?? "config.unavailable"), store: unknown("store.unavailable"), proof: unknown("peers.proof-unavailable") },
75
115
  };
76
116
  let deliveries = null;
@@ -116,7 +156,9 @@ export async function readStatus({ context: c, adapter, now = Date.now }) {
116
156
  wake.storedOnly = rows["stored-only"] ?? 0;
117
157
  wake.pendingUndelivered = (rows.pending ?? 0) + (rows.inflight ?? 0) + (rows.failed ?? 0);
118
158
  wake.lastDeliveredAt = db.prepare("SELECT MAX(wake_updated_at) AS at FROM local_messages WHERE direction='inbound' AND wake_status='handled'").get().at;
119
- const fault = db.prepare("SELECT wake_error,wake_updated_at FROM local_messages WHERE direction='inbound' AND wake_status IN ('failed','dlq') AND wake_error IS NOT NULL ORDER BY wake_updated_at DESC LIMIT 1").get();
159
+ const fault = db.prepare(`SELECT wake_error,wake_updated_at FROM local_messages WHERE direction='inbound'
160
+ AND (wake_status IN ('failed','dlq') OR (wake_status='inflight' AND wake_error='accepted-turn-unobservable'))
161
+ AND wake_error IS NOT NULL ORDER BY wake_updated_at DESC LIMIT 1`).get();
120
162
  wake.lastFault = fault ? safeError(fault.wake_error) : null;
121
163
  wake.lastFaultAt = fault?.wake_updated_at ?? null;
122
164
  wake.measurements.store = measured(at);
@@ -124,14 +166,20 @@ export async function readStatus({ context: c, adapter, now = Date.now }) {
124
166
  catch {
125
167
  wake.measurements.store = unknown("wake.store-schema-unavailable");
126
168
  }
169
+ const exchangeCutoff = new Date(started + 5000).toISOString();
127
170
  for (const peer of peers.list ?? []) {
128
- peer.lastInboundAt = db.prepare("SELECT MAX(created_at) AS at FROM local_messages WHERE direction='inbound' AND sender=?").get(peer.agentId).at;
129
- peer.lastOutboundAt = db.prepare("SELECT MAX(created_at) AS at FROM outbox WHERE json_extract(envelope_json,'$.recipients[0]')=?").get(peer.agentId).at;
171
+ peer.lastInboundAt = db.prepare("SELECT created_at AS at FROM local_messages WHERE direction='inbound' AND sender=? AND julianday(created_at)<=julianday(?) ORDER BY julianday(created_at) DESC LIMIT 1").get(peer.agentId, exchangeCutoff)?.at ?? null;
172
+ peer.lastOutboundAt = db.prepare("SELECT created_at AS at FROM outbox WHERE json_extract(envelope_json,'$.recipients[0]')=? AND julianday(created_at)<=julianday(?) ORDER BY julianday(created_at) DESC LIMIT 1").get(peer.agentId, exchangeCutoff)?.at ?? null;
173
+ const ackedAt = db.prepare("SELECT updated_at AS at FROM outbox WHERE status='acked' AND json_extract(envelope_json,'$.recipients[0]')=? AND julianday(updated_at)<=julianday(?) ORDER BY julianday(updated_at) DESC LIMIT 1").get(peer.agentId, exchangeCutoff)?.at ?? null;
174
+ peer.lastExchangeAt = [peer.lastInboundAt, ackedAt].map(value => exchangeTime(value, started)).filter((value) => value !== null).sort().at(-1) ?? null;
130
175
  }
131
176
  peers.measurements.store = measured(at);
132
177
  const outbound = db.prepare("SELECT msg_id AS msgId,json_extract(envelope_json,'$.recipients[0]') AS peer,'outbound' AS direction,CASE status WHEN 'acked' THEN 'delivered' WHEN 'sent' THEN 'inflight' ELSE status END AS state,updated_at AS at,attempts,last_error AS error FROM outbox ORDER BY updated_at DESC LIMIT 20").all();
133
- const inbound = db.prepare("SELECT msg_id AS msgId,sender AS peer,'inbound' AS direction,'delivered' AS state,created_at AS at,0 AS attempts,NULL AS error FROM local_messages WHERE direction='inbound' ORDER BY created_at DESC LIMIT 20").all();
134
- deliveries = [...outbound, ...inbound].sort((a, b) => String(b.at).localeCompare(String(a.at))).slice(0, 20).map((r) => ({ ...r, error: r.error ? safeError(r.error) : null }));
178
+ const inbound = db.prepare(`SELECT msg_id AS msgId,sender AS peer,'inbound' AS direction,'delivered' AS state,created_at AS at,0 AS attempts,NULL AS error,${wakeColumns(db)} FROM local_messages WHERE direction='inbound' ORDER BY created_at DESC LIMIT 20`).all();
179
+ deliveries = [...outbound, ...inbound].sort((a, b) => String(b.at).localeCompare(String(a.at))).slice(0, 20).map((r) => ({ ...r,
180
+ error: r.error ? safeError(r.error) : null, wake_status: r.wake_status ?? null,
181
+ wake_error: r.wake_error ? safeError(r.wake_error) : null,
182
+ wake_updated_at: r.wake_updated_at ?? null, assistantRead: assistantRead(r.wake_status) }));
135
183
  db.exec("COMMIT");
136
184
  }
137
185
  catch {
@@ -143,25 +191,37 @@ export async function readStatus({ context: c, adapter, now = Date.now }) {
143
191
  if (config) {
144
192
  try {
145
193
  const proofs = await readJson(path.join(c.dataDir, "pair-proofs.json"));
146
- for (const peer of peers.list ?? [])
194
+ for (const peer of peers.list ?? []) {
147
195
  peer.paired = validPairProof(proofs[peer.agentId], config, peer.agentId, started) ? true : null;
196
+ const proofAt = pairProofTime(proofs[peer.agentId], config, peer.agentId, started);
197
+ peer.lastExchangeAt = [peer.lastExchangeAt, proofAt].filter((value) => value !== null).sort().at(-1) ?? null;
198
+ }
148
199
  peers.measurements.proof = measured(at);
149
200
  }
150
201
  catch { /* No roundtrip evidence: every pairing stays unknown. */ }
151
202
  }
203
+ for (const peer of peers.list ?? [])
204
+ peer.connection = peer.lastExchangeAt === null ? "unverified"
205
+ : started - Date.parse(peer.lastExchangeAt) <= 86400000 ? "connected" : "stale";
152
206
  const reason = (sources) => Object.entries(sources).filter(([, v]) => v.unknownReason).map(([name, v]) => `${name}:${v.unknownReason}`).join("; ") || null;
153
207
  inbox.unknownReason = reason(inbox.measurements);
154
208
  outbox.unknownReason = reason(outbox.measurements);
155
209
  wake.unknownReason = reason(wake.measurements);
156
210
  peers.unknownReason = reason(peers.measurements);
157
211
  return {
158
- schema: "murmur.status/1", generatedAt: at, agentId: config?.agentId ?? null, serviceName: c.serviceName,
159
- service: { ...service, lastFailureAt: null, restartsLastHour, restartFailureThreshold: 5,
212
+ schema: "murmur.status/1", generatedAt: at, agentId: config?.agentId ?? observation?.agentId ?? null, serviceName: c.serviceName,
213
+ service: { ...service, managed: service.state === "running" ? true : service.state === "running-unmanaged" ? false : null, lastFailureAt: null,
214
+ restartsLastHour: service.state === "running-unmanaged" ? null : restartsLastHour, restartFailureThreshold: 5,
160
215
  unknownReason: service.state === "unknown" ? service.detail ?? "service.unavailable" : null,
161
216
  measurements: { manager: service.state === "unknown" ? unknown(service.detail ?? "service.unavailable") : measured(at), history: unknown("service.history-unavailable") } },
162
217
  broker: { url: config?.natsUrl ?? null, state: observation?.broker?.state ?? "unknown",
163
218
  connectedAt: observation?.broker?.connectedAt ?? null, lastError: observation?.broker?.lastError ?? null,
164
- lastErrorAt: observation?.broker?.lastErrorAt ?? null, unknownReason: runtimeMeasurement.unknownReason,
219
+ lastErrorAt: observation?.broker?.lastErrorAt ?? null,
220
+ // #276 — since when the link is lost and how many reconnect attempts failed meanwhile.
221
+ disconnectedAt: typeof observation?.broker?.disconnectedAt === "string" ? observation.broker.disconnectedAt : null,
222
+ reconnectAttempts: Number.isSafeInteger(observation?.broker?.reconnectAttempts) && observation.broker.reconnectAttempts >= 0
223
+ ? observation.broker.reconnectAttempts : null,
224
+ unknownReason: runtimeMeasurement.unknownReason,
165
225
  measurements: { runtime: runtimeMeasurement } },
166
226
  peers, inbox, deliveries,
167
227
  outbox: {
@@ -10,7 +10,7 @@ export interface ServiceContext {
10
10
  }
11
11
  export type ServiceManager = "windows-service" | "scheduled-task" | "launchd" | "systemd" | "none";
12
12
  export interface ServiceSnapshot {
13
- state: "running" | "stopped" | "failed" | "unknown";
13
+ state: "running" | "running-unmanaged" | "stopped" | "failed" | "unknown";
14
14
  manager: ServiceManager;
15
15
  since: string | null;
16
16
  pid: number | null;
@@ -32,9 +32,19 @@ export interface ClientDetection {
32
32
  export interface PlatformAdapter {
33
33
  manager: ServiceManager;
34
34
  status(context: ServiceContext): Promise<ServiceSnapshot>;
35
+ /** Read-only proof that this PID currently holds the selected store open. */
36
+ observeStore?(context: ServiceContext, pid: number): Promise<string | null>;
35
37
  install(context: ServiceContext): Promise<void>;
36
38
  start(context: ServiceContext): Promise<void>;
37
39
  stop(context: ServiceContext): Promise<void>;
38
40
  uninstall?(context: ServiceContext): Promise<void>;
41
+ /**
42
+ * Removes a Service registered under this name by another installation of Murmur (a pilot or
43
+ * an earlier version), so that install can follow in the same elevated process. Resolves
44
+ * without change when there is none; refuses a Service that does not run Murmur.
45
+ */
46
+ removePrevious?(context: ServiceContext): Promise<void>;
39
47
  detectClients(context: ServiceContext): Promise<ClientDetection[]>;
48
+ /** Native log location, after verifying the selected service owns this profile. */
49
+ logDirectory?(context: ServiceContext): Promise<string>;
40
50
  }
@@ -59,7 +59,7 @@ export function statusVerdict(input, now = Date.now()) {
59
59
  return out("grey", invalid);
60
60
  if (typeof s.schema !== "string" || !/^murmur\.status\/1(?:\.\d+)?$/.test(s.schema))
61
61
  return out("grey", "schema.unknown");
62
- unread = count(s.inbox?.unread) && s.inbox.unread > 0;
62
+ unread = count(s.wake?.delivery?.pendingUndelivered) && s.wake.delivery.pendingUndelivered > 0;
63
63
  const stamp = typeof s.generatedAt === "string" && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(s.generatedAt) ? Date.parse(s.generatedAt) : NaN;
64
64
  if (!Number.isFinite(stamp) || !Number.isFinite(now))
65
65
  return out("grey", "snapshot.unparsable");
@@ -69,7 +69,7 @@ export function statusVerdict(input, now = Date.now()) {
69
69
  return out("grey", "snapshot.future");
70
70
  if (s.service?.state === "stopped")
71
71
  return out("grey", "service.stopped");
72
- if (!["running", "failed"].includes(s.service?.state))
72
+ if (!["running", "running-unmanaged", "failed"].includes(s.service?.state))
73
73
  return out("grey", "service.unknown");
74
74
  if (s.service.state === "failed")
75
75
  return out("red", "service.failed");
@@ -101,6 +101,8 @@ export function statusVerdict(input, now = Date.now()) {
101
101
  note(name, "source-unreadable");
102
102
  switch (s.broker?.state) {
103
103
  case "unauthorized": return out("yellow", "broker.unauthorized");
104
+ // #276 — the client gave up for good; only a restart brings the link back.
105
+ case "closed": return out("red", "broker.closed");
104
106
  case "connected": break;
105
107
  case null:
106
108
  case undefined:
@@ -0,0 +1,11 @@
1
+ import type { ServiceContext } from './types.js';
2
+ /** Acknowledge an unknown accepted outcome; never replay it or claim it was read. */
3
+ export declare function dismissWake(c: ServiceContext, msgId: string, expectedAgent: string): Promise<{
4
+ schema: string;
5
+ agentId: string;
6
+ msgIds: import("node:sqlite").SQLOutputValue[];
7
+ status: string;
8
+ reason: string;
9
+ executed: boolean;
10
+ historyPreserved: boolean;
11
+ }>;
@@ -0,0 +1,44 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { lstat, mkdir, rmdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { loadConfig, validAgentId } from './config.js';
5
+ /** Acknowledge an unknown accepted outcome; never replay it or claim it was read. */
6
+ export async function dismissWake(c, msgId, expectedAgent) {
7
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,150}$/.test(msgId) || !validAgentId(expectedAgent))
8
+ throw new Error('wake.selection-invalid');
9
+ const lock = path.join(c.dataDir, '.setup-write.lock');
10
+ await mkdir(lock, { mode: 0o700 });
11
+ try {
12
+ const config = await loadConfig(c);
13
+ if (config.agentId !== expectedAgent)
14
+ throw new Error('wake.identity-changed');
15
+ const file = await lstat(c.storePath);
16
+ if (!file.isFile() || file.isSymbolicLink() || (typeof process.getuid === 'function' && file.uid !== process.getuid())) {
17
+ throw new Error('wake.store-invalid');
18
+ }
19
+ const db = new DatabaseSync(c.storePath);
20
+ try {
21
+ db.exec('PRAGMA busy_timeout=1000; BEGIN IMMEDIATE');
22
+ const row = db.prepare("SELECT wake_batch_id FROM local_messages WHERE direction='inbound' AND msg_id=?").get(msgId);
23
+ if (!row)
24
+ throw new Error('wake.dismiss-not-eligible');
25
+ const rows = db.prepare(`SELECT msg_id,wake_status,wake_error FROM local_messages WHERE direction='inbound'
26
+ AND (msg_id=? OR (wake_batch_id IS NOT NULL AND wake_batch_id=?))`).all(msgId, row.wake_batch_id);
27
+ if (rows.some(r => !['dlq', 'muted'].includes(String(r.wake_status)) || r.wake_error !== 'accepted-turn-unobservable')) {
28
+ throw new Error('wake.dismiss-not-eligible');
29
+ }
30
+ const update = db.prepare("UPDATE local_messages SET wake_status='muted',wake_next_at=NULL,wake_updated_at=? WHERE direction='inbound' AND msg_id=?");
31
+ for (const r of rows)
32
+ update.run(new Date().toISOString(), r.msg_id);
33
+ db.exec('COMMIT');
34
+ return { schema: 'murmur.wake-dismiss/1', agentId: config.agentId, msgIds: rows.map(r => r.msg_id),
35
+ status: 'muted', reason: 'accepted-turn-unobservable', executed: false, historyPreserved: true };
36
+ }
37
+ finally {
38
+ db.close();
39
+ }
40
+ }
41
+ finally {
42
+ await rmdir(lock);
43
+ }
44
+ }
@@ -1 +1 @@
1
- {"name":"@murmurv2/cli","private":true,"version":"2.11.0","type":"module","main":"dist/src/index.js","types":"dist/src/index.d.ts"}
1
+ {"name":"@murmurv2/cli","private":true,"version":"2.12.0","type":"module","main":"dist/src/index.js","types":"dist/src/index.d.ts"}