@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,130 @@
1
+ import { configureClient, previewClientConfiguration } from './clients.js';
2
+ import { prepareReplyTest, checkReplyTest } from './reply-test.js';
3
+ import { listOutboxAttention, setOutboxDismissed } from './outbox-attention.js';
4
+ import { initialize, invite, join, importPeer } from './onboarding.js';
5
+ import { runDoctor } from './doctor.js';
6
+ import { parseArgs } from 'node:util';
7
+ import path from 'node:path';
8
+ import { resolveContext } from './paths.js';
9
+ import { createLinuxAdapter } from './platform/linux.js';
10
+ import { createWindowsAdapter } from './platform/windows.js';
11
+ import { createDarwinAdapter } from './platform/darwin.js';
12
+ import { readStatus } from './status.js';
13
+ import { setWakeEnabled, markInboxRead, readInbox, readLogPath } from './commands.js';
14
+ import { renderStatusLine } from './status-line.js';
15
+ import { readVersion, checkUpdates, setUpdateChecks } from './updates.js';
16
+ export function platformAdapter() {
17
+ if (process.platform === 'linux')
18
+ return createLinuxAdapter();
19
+ if (process.platform === 'darwin')
20
+ return createDarwinAdapter();
21
+ if (process.platform === 'win32')
22
+ return createWindowsAdapter();
23
+ return { manager: 'none',
24
+ async status() {
25
+ return { state: 'unknown', manager: 'none', pid: null, since: null, lastExitCode: null,
26
+ observedStorePath: null, restartCount: null, restartWindowMs: null, detail: 'service.adapter-unavailable' };
27
+ },
28
+ async install() { throw new Error('service.adapter-unavailable'); },
29
+ async start() { throw new Error('service.adapter-unavailable'); },
30
+ async stop() { throw new Error('service.adapter-unavailable'); },
31
+ async detectClients() { return []; },
32
+ };
33
+ }
34
+ export const rawCliOutput = (text) => ({ kind: 'raw', text });
35
+ export const isRawCliOutput = (value) => !!value && typeof value === 'object'
36
+ && value.kind === 'raw' && typeof value.text === 'string';
37
+ export async function main(args, adapter = platformAdapter()) {
38
+ // `murmur --version` is the first thing people type after npm install; answer it like `murmur version`.
39
+ if (args.some(a => a === '--version' || a === '-v') && args.every(a => ['--version', '-v', '--json'].includes(a)))
40
+ return readVersion();
41
+ let parsed;
42
+ try {
43
+ parsed = parseArgs({ args, allowPositionals: true, options: {
44
+ 'msg-id': { type: 'string' }, 'expected-state': { type: 'string' }, 'expected-agent': { type: 'string' },
45
+ 'agent-id': { type: 'string' }, 'broker-url': { type: 'string' }, 'token-file': { type: 'string' }, 'invite-file': { type: 'string' }, 'reply-file': { type: 'string' }, 'reply-out': { type: 'string' }, out: { type: 'string' },
46
+ client: { type: 'string' }, replace: { type: 'boolean' }, 'plan-id': { type: 'string' }, 'test-token': { type: 'string' }, json: { type: 'boolean' }, line: { type: 'boolean' }, limit: { type: 'string' }, peer: { type: 'string' }, timeout: { type: 'string' }, 'data-dir': { type: 'string' }, 'service-name': { type: 'string' }, apply: { type: 'boolean' }, help: { type: 'boolean' },
47
+ } });
48
+ }
49
+ catch (error) {
50
+ // parseArgs explains itself in a sentence, which safeError must hide; keep a stable code instead.
51
+ throw new Error(error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION' ? 'cli.unknown-option' : 'cli.invalid-arguments');
52
+ }
53
+ const { values, positionals } = parsed;
54
+ if (values.help || !positionals.length)
55
+ return { commands: ['version --json', 'updates check|enable|disable --json', 'init --agent-id ID --broker-url URL [--token-file FILE]', 'invite --out FILE', 'join --agent-id ID --invite-file FILE --reply-out FILE', 'add-peer --reply-file FILE', 'status --json|--line', 'doctor --json [--peer AGENT] [--timeout MILLISECONDS]', 'logs path --json', 'service install|start|stop|uninstall', 'clients detect', 'clients preview --client ID', 'clients configure --client ID [--replace] [--plan-id SHA256]', 'reply-test prepare --peer AGENT', 'reply-test check --test-token TOKEN', 'wake pause|resume [--apply]', 'inbox read [--limit 1..100]', 'inbox mark-read', 'outbox list --json', 'outbox dismiss|restore --msg-id ID --expected-state TOKEN --expected-agent ID', 'mcp serve --data-dir ABSOLUTE'],
56
+ options: ['--data-dir ABSOLUTE', '--service-name NAME'], note: 'Windows service mutations require an elevated terminal and the matching native helper.' };
57
+ const [command, action, extra] = positionals;
58
+ if (extra)
59
+ throw new Error('cli.unexpected-argument');
60
+ if (values.line && command !== 'status')
61
+ throw new Error('cli.line-only-for-status');
62
+ if (values.line && values.json)
63
+ throw new Error('cli.output-mode-conflict');
64
+ if (command === 'version' && !action)
65
+ return readVersion();
66
+ if (command === 'updates' && action === 'check')
67
+ return checkUpdates();
68
+ if (command === 'updates' && ['enable', 'disable'].includes(action))
69
+ return setUpdateChecks(action === 'enable');
70
+ const context = resolveContext({ dataDir: values['data-dir'], serviceName: values['service-name'] });
71
+ const required = (name) => { const value = values[name]; if (typeof value !== 'string' || !value)
72
+ throw new Error('cli.required-option:' + name); return value; };
73
+ if (command === 'init' && !action)
74
+ return initialize(context, { agentId: required('agent-id'), brokerUrl: required('broker-url'), tokenFile: values['token-file'] });
75
+ if (command === 'invite' && !action)
76
+ return invite(context, required('out'));
77
+ if (command === 'join' && !action)
78
+ return join(context, { agentId: required('agent-id'), inviteFile: required('invite-file'), replyOut: required('reply-out') });
79
+ if (command === 'add-peer' && !action)
80
+ return importPeer(context, required('reply-file'));
81
+ if (command === 'status' && !action) {
82
+ const status = await readStatus({ context, adapter });
83
+ return values.line ? rawCliOutput(renderStatusLine(status)) : status;
84
+ }
85
+ if (command === 'outbox' && action === 'list')
86
+ return listOutboxAttention(context);
87
+ if (command === 'outbox' && ['dismiss', 'restore'].includes(action))
88
+ return setOutboxDismissed(context, required('msg-id'), required('expected-state'), required('expected-agent'), action === 'dismiss');
89
+ if (command === 'mcp' && action === 'serve') {
90
+ if (values['data-dir'] === undefined)
91
+ throw new Error('cli.required-option:data-dir');
92
+ process.env.DATA_DIR = context.dataDir;
93
+ process.env.MURMUR_DATA_DIR = context.dataDir;
94
+ process.env.MURMUR_STORE_PATH = context.storePath;
95
+ process.env.MURMUR_CHANNEL_ROSTER_PATH = path.join(context.dataDir, 'channel-roster.db');
96
+ await import('@murmurv2/mcp-server');
97
+ return rawCliOutput('');
98
+ }
99
+ if (command === 'logs' && action === 'path') {
100
+ if (process.platform === 'win32')
101
+ throw new Error('logs.windows-native-location-unavailable');
102
+ return readLogPath(context);
103
+ }
104
+ if (command === 'doctor' && !action)
105
+ return runDoctor({ context, adapter, peer: values.peer, timeoutMs: values.timeout === undefined ? undefined : Number(values.timeout) });
106
+ if (command === 'clients' && action === 'preview')
107
+ return previewClientConfiguration(context, adapter, required('client'));
108
+ if (command === 'clients' && action === 'configure')
109
+ return configureClient(context, adapter, required('client'), values.replace, values['plan-id']);
110
+ if (command === 'clients' && action === 'detect')
111
+ return { schema: 'murmur.clients/1', clients: await adapter.detectClients(context) };
112
+ if (command === 'reply-test' && action === 'prepare')
113
+ return prepareReplyTest(context, required('peer'));
114
+ if (command === 'reply-test' && action === 'check')
115
+ return checkReplyTest(context, required('test-token'));
116
+ if (command === 'wake' && ['pause', 'resume'].includes(action))
117
+ return setWakeEnabled(context, adapter, action === 'resume', values.apply);
118
+ if (command === 'inbox' && action === 'read')
119
+ return readInbox(context, values.limit === undefined ? 20 : Number(values.limit));
120
+ if (command === 'inbox' && action === 'mark-read')
121
+ return markInboxRead(context);
122
+ if (command === 'service' && ['install', 'start', 'stop', 'uninstall'].includes(action)) {
123
+ const operation = adapter[action];
124
+ if (!operation)
125
+ throw new Error('service.uninstall-unavailable');
126
+ await operation.call(adapter, context);
127
+ return { schema: 'murmur.service/1', action, service: await adapter.status(context) };
128
+ }
129
+ throw new Error('cli.unknown-command');
130
+ }
@@ -0,0 +1,151 @@
1
+ import type { PlatformAdapter, ServiceContext } from './types.js';
2
+ export { sameClientFileIdentity } from './file-identity.js';
3
+ /** Read-only preview: even a missing parent directory is not created. */
4
+ export declare function previewClientConfiguration(c: ServiceContext, adapter: PlatformAdapter, clientId: string): Promise<{
5
+ wakeHook?: {
6
+ settingsPath: string;
7
+ action: "replace" | "unchanged" | "add";
8
+ } | undefined;
9
+ schema: string;
10
+ client: "claude-code" | "claude-desktop" | "codex-cli" | "codex-desktop";
11
+ configPath: string;
12
+ agentId: string;
13
+ dataDir: string;
14
+ action: string;
15
+ planId: string;
16
+ configExisted: boolean;
17
+ restartRequired: boolean;
18
+ }>;
19
+ /**
20
+ * Patch only the selected Murmur entry; never execute a client or touch its auth. For Claude Code
21
+ * also install the Stop wake hook, so a delivered message wakes the session without a human turn.
22
+ */
23
+ export declare function configureClient(c: ServiceContext, adapter: PlatformAdapter, clientId: string, replace?: boolean, expectedPlan?: string): Promise<{
24
+ planId: string;
25
+ agentId: string;
26
+ dataDir: string;
27
+ schema: string;
28
+ client: "claude-code" | "claude-desktop" | "codex-cli" | "codex-desktop";
29
+ configPath: string;
30
+ changed: boolean;
31
+ backup: null;
32
+ restartRequired: boolean;
33
+ } | {
34
+ planId?: undefined;
35
+ agentId?: undefined;
36
+ dataDir?: undefined;
37
+ schema: string;
38
+ client: "claude-code" | "claude-desktop" | "codex-cli" | "codex-desktop";
39
+ configPath: string;
40
+ changed: boolean;
41
+ backup: null;
42
+ restartRequired: boolean;
43
+ } | {
44
+ restartRequired: boolean;
45
+ instruction: string;
46
+ planId: string;
47
+ agentId: string;
48
+ dataDir: string;
49
+ schema: string;
50
+ client: "claude-code" | "claude-desktop" | "codex-cli" | "codex-desktop";
51
+ configPath: string;
52
+ changed: boolean;
53
+ backup: string | null;
54
+ } | {
55
+ restartRequired: boolean;
56
+ instruction: string;
57
+ planId?: undefined;
58
+ agentId?: undefined;
59
+ dataDir?: undefined;
60
+ schema: string;
61
+ client: "claude-code" | "claude-desktop" | "codex-cli" | "codex-desktop";
62
+ configPath: string;
63
+ changed: boolean;
64
+ backup: string | null;
65
+ } | {
66
+ changed: boolean;
67
+ wakeHook: {
68
+ settingsPath: string;
69
+ action: "unchanged";
70
+ changed: boolean;
71
+ backup: null;
72
+ } | {
73
+ settingsPath: string;
74
+ action: "replace" | "add";
75
+ changed: boolean;
76
+ backup: string | null;
77
+ };
78
+ planId: string;
79
+ agentId: string;
80
+ dataDir: string;
81
+ schema: string;
82
+ client: "claude-code" | "claude-desktop" | "codex-cli" | "codex-desktop";
83
+ configPath: string;
84
+ backup: null;
85
+ restartRequired: boolean;
86
+ } | {
87
+ changed: boolean;
88
+ wakeHook: {
89
+ settingsPath: string;
90
+ action: "unchanged";
91
+ changed: boolean;
92
+ backup: null;
93
+ } | {
94
+ settingsPath: string;
95
+ action: "replace" | "add";
96
+ changed: boolean;
97
+ backup: string | null;
98
+ };
99
+ planId?: undefined;
100
+ agentId?: undefined;
101
+ dataDir?: undefined;
102
+ schema: string;
103
+ client: "claude-code" | "claude-desktop" | "codex-cli" | "codex-desktop";
104
+ configPath: string;
105
+ backup: null;
106
+ restartRequired: boolean;
107
+ } | {
108
+ changed: boolean;
109
+ wakeHook: {
110
+ settingsPath: string;
111
+ action: "unchanged";
112
+ changed: boolean;
113
+ backup: null;
114
+ } | {
115
+ settingsPath: string;
116
+ action: "replace" | "add";
117
+ changed: boolean;
118
+ backup: string | null;
119
+ };
120
+ restartRequired: boolean;
121
+ instruction: string;
122
+ planId: string;
123
+ agentId: string;
124
+ dataDir: string;
125
+ schema: string;
126
+ client: "claude-code" | "claude-desktop" | "codex-cli" | "codex-desktop";
127
+ configPath: string;
128
+ backup: string | null;
129
+ } | {
130
+ changed: boolean;
131
+ wakeHook: {
132
+ settingsPath: string;
133
+ action: "unchanged";
134
+ changed: boolean;
135
+ backup: null;
136
+ } | {
137
+ settingsPath: string;
138
+ action: "replace" | "add";
139
+ changed: boolean;
140
+ backup: string | null;
141
+ };
142
+ restartRequired: boolean;
143
+ instruction: string;
144
+ planId?: undefined;
145
+ agentId?: undefined;
146
+ dataDir?: undefined;
147
+ schema: string;
148
+ client: "claude-code" | "claude-desktop" | "codex-cli" | "codex-desktop";
149
+ configPath: string;
150
+ backup: string | null;
151
+ }>;
@@ -0,0 +1,251 @@
1
+ import * as TOML from '@iarna/toml';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, rename, rmdir, stat, unlink } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { isDeepStrictEqual } from 'node:util';
7
+ import { loadConfig } from './config.js';
8
+ import { sameClientFileIdentity } from './file-identity.js';
9
+ import { protectPrivateFile, preparePrivateReplacement } from './private-file.js';
10
+ export { sameClientFileIdentity } from './file-identity.js';
11
+ const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
12
+ async function selectedClient(c, adapter, clientId) {
13
+ const client = (await adapter.detectClients(c)).find(item => item.id === clientId);
14
+ if (!client || !client.installed)
15
+ throw new Error('client.not-detected');
16
+ if (!client.configPath || !path.isAbsolute(client.configPath))
17
+ throw new Error('client.config-path-unverified');
18
+ return { ...client, configPath: client.configPath };
19
+ }
20
+ async function readClientFile(file) {
21
+ let handle;
22
+ try {
23
+ // O_NOFOLLOW is unavailable on Windows. Inspect the link itself there too,
24
+ // including dangling links, and bind the opened handle to the checked file.
25
+ const before = await lstat(file, { bigint: true });
26
+ if (!before.isFile())
27
+ throw new Error('client.config-file-invalid');
28
+ handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
29
+ const info = await handle.stat({ bigint: true });
30
+ const after = await lstat(file, { bigint: true });
31
+ if (!info.isFile() || !after.isFile() || !sameClientFileIdentity(before, info)
32
+ || !sameClientFileIdentity(after, info) || !sameClientFileIdentity(before, after) || info.size > 4n * 1024n * 1024n
33
+ || (process.getuid && info.uid !== BigInt(process.getuid())))
34
+ throw new Error('client.config-file-invalid');
35
+ return { text: await handle.readFile('utf8'), existed: true };
36
+ }
37
+ catch (e) {
38
+ if (e.code === 'ENOENT')
39
+ return { text: '', existed: false };
40
+ throw e;
41
+ }
42
+ finally {
43
+ await handle?.close();
44
+ }
45
+ }
46
+ function clientDocument(client, text) {
47
+ const parse = (value) => {
48
+ try {
49
+ const result = value.trim() ? client.format === 'json' ? JSON.parse(value) : TOML.parse(value) : {};
50
+ if (!object(result))
51
+ throw new Error();
52
+ return result;
53
+ }
54
+ catch {
55
+ throw new Error('client.config-parse-failed');
56
+ }
57
+ };
58
+ const original = parse(text), key = client.format === 'json' ? 'mcpServers' : 'mcp_servers';
59
+ if (original[key] !== undefined && !object(original[key]))
60
+ throw new Error('client.mcp-table-invalid');
61
+ return { original, key, parse };
62
+ }
63
+ // Claude Code reads hooks from settings.json next to its config directory, not from ~/.claude.json.
64
+ const claudeSettingsPath = (configPath) => path.join(path.dirname(configPath), '.claude', 'settings.json');
65
+ // Forward slashes read the same in the bash and cmd that run hook commands; node accepts them.
66
+ const hookPath = (value) => `"${value.replaceAll('\\', '/')}"`;
67
+ // Eight hours after each turn instead of the drain's 20-minute default: a message that arrives
68
+ // during a working day still wakes the session (docs/wake-native.md, known limitation).
69
+ const WAKE_WINDOW_SECONDS = 28800;
70
+ /** The node wake drain (docs/wake-native.md): polls the store after each turn and wakes the session. */
71
+ function desiredWakeHook(c) {
72
+ return { type: 'command', asyncRewake: true,
73
+ command: `${hookPath(c.nodePath)} --no-warnings ${hookPath(path.join(c.repoRoot, 'scripts', 'wake-drain-claude.mjs'))} --db ${hookPath(c.storePath)} --max-seconds ${WAKE_WINDOW_SECONDS}` };
74
+ }
75
+ const isMurmurWakeHook = (hook) => object(hook) && typeof hook.command === 'string' && hook.command.includes('wake-drain-claude');
76
+ /** Plan the Stop hook edit; only Murmur's own wake hook is ever added, kept or replaced. */
77
+ function planWakeHook(c, text) {
78
+ let settings;
79
+ try {
80
+ settings = text.trim() ? JSON.parse(text) : {};
81
+ }
82
+ catch {
83
+ throw new Error('client.settings-parse-failed');
84
+ }
85
+ if (!object(settings) || (settings.hooks !== undefined && !object(settings.hooks))
86
+ || (settings.hooks?.Stop !== undefined && (!Array.isArray(settings.hooks.Stop)
87
+ || !settings.hooks.Stop.every((group) => object(group) && Array.isArray(group.hooks)))))
88
+ throw new Error('client.hooks-invalid');
89
+ const entry = desiredWakeHook(c), groups = settings.hooks?.Stop ?? [];
90
+ const ours = groups.flatMap(group => group.hooks.filter(isMurmurWakeHook));
91
+ if (ours.length === 1 && isDeepStrictEqual(ours[0], entry))
92
+ return { action: 'unchanged', next: settings };
93
+ const kept = groups.map(group => ({ ...group, hooks: group.hooks.filter((hook) => !isMurmurWakeHook(hook)) })).filter(group => group.hooks.length);
94
+ const next = { ...settings, hooks: { ...settings.hooks, Stop: [...kept, { hooks: [entry] }] } };
95
+ return { action: ours.length ? 'replace' : 'add', next };
96
+ }
97
+ /** Write settings.json the same guarded way as the MCP entry: lock, private backup, atomic rename. */
98
+ async function configureWakeHook(c, file, before) {
99
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
100
+ const lock = `${file}.murmur-lock`;
101
+ await mkdir(lock, { mode: 0o700 });
102
+ try {
103
+ const current = await readClientFile(file);
104
+ if (!isDeepStrictEqual(current, before))
105
+ throw new Error('client.plan-stale');
106
+ const { action, next } = planWakeHook(c, current.text);
107
+ if (action === 'unchanged')
108
+ return { settingsPath: file, action, changed: false, backup: null };
109
+ const serialized = JSON.stringify(next, null, 2) + '\n';
110
+ const backup = current.existed ? `${file}.murmur-backup-${randomUUID()}` : null;
111
+ if (backup) {
112
+ const out = await open(backup, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
113
+ try {
114
+ await protectPrivateFile(backup, out);
115
+ await out.writeFile(current.text);
116
+ await out.sync();
117
+ }
118
+ finally {
119
+ await out.close();
120
+ }
121
+ }
122
+ const temporary = `${file}.murmur-${randomUUID()}.tmp`;
123
+ try {
124
+ const out = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
125
+ try {
126
+ await preparePrivateReplacement(temporary, out, file);
127
+ await out.writeFile(serialized);
128
+ await out.sync();
129
+ }
130
+ finally {
131
+ await out.close();
132
+ }
133
+ if (!isDeepStrictEqual(await readClientFile(file), current))
134
+ throw new Error('client.plan-stale');
135
+ await rename(temporary, file);
136
+ }
137
+ finally {
138
+ await unlink(temporary).catch(() => { });
139
+ }
140
+ return { settingsPath: file, action, changed: true, backup };
141
+ }
142
+ finally {
143
+ await rmdir(lock);
144
+ }
145
+ }
146
+ function desiredEntry(c) {
147
+ return { command: c.nodePath, args: [path.join(c.repoRoot, 'packages', 'mcp-server', 'dist', 'src', 'index.js')],
148
+ env: { DATA_DIR: c.dataDir, MURMUR_STORE_PATH: c.storePath } };
149
+ }
150
+ async function planFor(c, client, current, settings) {
151
+ const config = await loadConfig(c), { original, key } = clientDocument(client, current.text);
152
+ const entry = desiredEntry(c), previous = original[key]?.murmur;
153
+ const action = isDeepStrictEqual(previous, entry) ? 'unchanged' : previous === undefined ? 'add' : 'replace';
154
+ const wakeHook = settings && { settingsPath: claudeSettingsPath(client.configPath), action: planWakeHook(c, settings.text).action };
155
+ // Bind confirmation to the target, exact bytes and selected identity/runtime.
156
+ // No existing commands, environment values, auth or config contents leave the engine.
157
+ const planId = createHash('sha256').update(JSON.stringify([client.id, client.configPath, current,
158
+ config.agentId, config.keys.signing.publicKey, config.keys.encryption.publicKey, entry, settings ?? null])).digest('hex');
159
+ return { schema: 'murmur.client-plan/1', client: client.id, configPath: client.configPath,
160
+ agentId: config.agentId, dataDir: c.dataDir, action, planId, configExisted: current.existed, restartRequired: true, ...(wakeHook ? { wakeHook } : {}) };
161
+ }
162
+ /** Read-only preview: even a missing parent directory is not created. */
163
+ export async function previewClientConfiguration(c, adapter, clientId) {
164
+ const client = await selectedClient(c, adapter, clientId);
165
+ const settings = client.id === 'claude-code' ? await readClientFile(claudeSettingsPath(client.configPath)) : undefined;
166
+ return planFor(c, client, await readClientFile(client.configPath), settings);
167
+ }
168
+ /**
169
+ * Patch only the selected Murmur entry; never execute a client or touch its auth. For Claude Code
170
+ * also install the Stop wake hook, so a delivered message wakes the session without a human turn.
171
+ */
172
+ export async function configureClient(c, adapter, clientId, replace = false, expectedPlan) {
173
+ const client = await selectedClient(c, adapter, clientId);
174
+ if (expectedPlan !== undefined && !/^[a-f0-9]{64}$/.test(expectedPlan))
175
+ throw new Error('client.plan-invalid');
176
+ if (client.id !== 'claude-code')
177
+ return configureMcpEntry(c, client, replace, expectedPlan);
178
+ const settingsFile = claudeSettingsPath(client.configPath), settings = await readClientFile(settingsFile);
179
+ // A hook must never point at a file this runtime does not ship (an older or partial bundle).
180
+ const drain = await stat(path.join(c.repoRoot, 'scripts', 'wake-drain-claude.mjs')).catch(() => null);
181
+ if (!drain?.isFile())
182
+ throw new Error('client.wake-drain-missing');
183
+ // Refuse a foreign variant of the hook before anything is written, as for the MCP entry.
184
+ if (planWakeHook(c, settings.text).action === 'replace' && !replace)
185
+ throw new Error('client.wake-hook-conflict');
186
+ const result = await configureMcpEntry(c, client, replace, expectedPlan, settings);
187
+ const wakeHook = await configureWakeHook(c, settingsFile, settings);
188
+ return { ...result, changed: result.changed || wakeHook.changed, wakeHook };
189
+ }
190
+ async function configureMcpEntry(c, client, replace, expectedPlan, settings) {
191
+ const clientId = client.id, file = client.configPath;
192
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
193
+ const lock = `${file}.murmur-lock`;
194
+ await mkdir(lock, { mode: 0o700 });
195
+ try {
196
+ const current = await readClientFile(file), { text, existed } = current;
197
+ const plan = expectedPlan === undefined ? undefined : await planFor(c, client, current, settings);
198
+ if (plan && plan.planId !== expectedPlan)
199
+ throw new Error('client.plan-stale');
200
+ const { original, key, parse } = clientDocument(client, text), entry = desiredEntry(c);
201
+ const confirmed = plan ? { planId: plan.planId, agentId: plan.agentId, dataDir: plan.dataDir } : {};
202
+ const previous = original[key]?.murmur;
203
+ if (isDeepStrictEqual(previous, entry))
204
+ return { schema: 'murmur.client/1', client: clientId, configPath: file, changed: false, backup: null, restartRequired: true, ...confirmed };
205
+ if (previous !== undefined && !replace)
206
+ throw new Error('client.murmur-entry-conflict');
207
+ const next = { ...original, [key]: { ...original[key], murmur: entry } };
208
+ const serialized = client.format === 'json' ? JSON.stringify(next, null, 2) + '\n' : TOML.stringify(next);
209
+ if (!isDeepStrictEqual(parse(serialized), next))
210
+ throw new Error('client.config-roundtrip-failed');
211
+ const backup = existed ? `${file}.murmur-backup-${randomUUID()}` : null;
212
+ if (backup) {
213
+ const out = await open(backup, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
214
+ try {
215
+ await protectPrivateFile(backup, out);
216
+ await out.writeFile(text);
217
+ await out.sync();
218
+ }
219
+ finally {
220
+ await out.close();
221
+ }
222
+ }
223
+ const temporary = `${file}.murmur-${randomUUID()}.tmp`;
224
+ try {
225
+ const out = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
226
+ try {
227
+ await preparePrivateReplacement(temporary, out, file);
228
+ await out.writeFile(serialized);
229
+ await out.sync();
230
+ }
231
+ finally {
232
+ await out.close();
233
+ }
234
+ // A client can save its config while the confirmation window is open or
235
+ // while we prepare the backup. Refuse a changed target instead of losing it.
236
+ if (!isDeepStrictEqual(await readClientFile(file), current))
237
+ throw new Error('client.plan-stale');
238
+ if (plan && (await planFor(c, client, current, settings)).planId !== plan.planId)
239
+ throw new Error('client.plan-stale');
240
+ await rename(temporary, file);
241
+ }
242
+ finally {
243
+ await unlink(temporary).catch(() => { });
244
+ }
245
+ return { schema: 'murmur.client/1', client: clientId, configPath: file, changed: true, backup, ...confirmed,
246
+ restartRequired: true, instruction: 'Reload the selected client to activate the Murmur MCP entry.' };
247
+ }
248
+ finally {
249
+ await rmdir(lock);
250
+ }
251
+ }
@@ -0,0 +1,35 @@
1
+ import type { PlatformAdapter, ServiceContext } from './types.js';
2
+ /** Observe the configured directory only; this does not prove a live daemon's log destination. */
3
+ export declare function readLogPath(context: ServiceContext): Promise<{
4
+ schema: string;
5
+ agentId: string;
6
+ dataDir: string;
7
+ serviceName: string;
8
+ logDir: string;
9
+ source: string;
10
+ }>;
11
+ /** Explicit local configuration mutation, serialized against other setup writers. */
12
+ export declare function setWakeEnabled(context: ServiceContext, adapter: PlatformAdapter, enabled: boolean, apply?: boolean): Promise<{
13
+ schema: string;
14
+ configuredEnabled: boolean;
15
+ effectiveEnabled: any;
16
+ restartRequired: boolean;
17
+ backup: string | null;
18
+ applyError: string | null;
19
+ }>;
20
+ /** Explicit read action only; merely displaying status never consumes unread state. */
21
+ export declare function markInboxRead(context: ServiceContext): Promise<{
22
+ schema: string;
23
+ agentId: string;
24
+ rowid: number;
25
+ }>;
26
+ /** Return durable inbound rows without advancing the independent read cursor. */
27
+ export declare function readInbox(context: ServiceContext, limit?: number): Promise<{
28
+ schema: string;
29
+ agentId: string;
30
+ readCursor: number | null;
31
+ unread: number | null;
32
+ messages: {
33
+ unread: boolean | null;
34
+ }[];
35
+ }>;