@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,189 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { realpath, stat } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ const paths = path.win32;
6
+ const fail = (reason) => { throw new Error(`service.${reason}`); };
7
+ /**
8
+ * murmur-svc ends a failure with exactly one footer line `murmur-svc: reason=<key>` (LF). Only that
9
+ * strict last line is trusted and only its key is kept, case preserved; the rest of stderr may carry
10
+ * paths or OS text and is never read. Anything else keeps the old generic code.
11
+ */
12
+ export function helperReason(stderr) {
13
+ const key = /(?:^|\n)murmur-svc: reason=([A-Za-z][A-Za-z0-9.]{0,80})\n?$/.exec(stderr)?.[1];
14
+ return key && !key.endsWith('.') && !key.includes('..') ? `helper.${key}` : 'helper-command-failed';
15
+ }
16
+ const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
17
+ const natural = (v) => Number.isSafeInteger(v) && Number(v) >= 0;
18
+ const textOrNull = (v) => v === null || typeof v === 'string';
19
+ function absolute(value) {
20
+ if (typeof value !== 'string' || !paths.isAbsolute(value) || paths.normalize(value) !== value || /[\x00-\x1f\x7f]/.test(value))
21
+ fail('invalid-path');
22
+ }
23
+ function validate(c) {
24
+ for (const key of ['dataDir', 'configPath', 'storePath', 'repoRoot', 'nodePath', 'logDir'])
25
+ absolute(c[key]);
26
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,100}$/.test(c.serviceName))
27
+ fail('invalid-name');
28
+ if (c.configPath !== paths.join(c.dataDir, 'agent-config.json') || c.storePath !== paths.join(c.dataDir, 'murmur.db') || c.logDir !== paths.join(c.dataDir, 'logs'))
29
+ fail('conflicting-store-path');
30
+ }
31
+ const empty = (detail) => ({ state: 'unknown', manager: 'windows-service', since: null, pid: null,
32
+ lastExitCode: null, observedStorePath: null, restartCount: null, restartWindowMs: null, ...(detail ? { detail } : {}) });
33
+ const defaultRun = (file, args, options) => new Promise(resolve => {
34
+ execFile(file, args, { ...options, windowsHide: true, maxBuffer: 256 * 1024, encoding: 'utf8' }, (error, stdout, stderr) => {
35
+ resolve({ code: error ? typeof error.code === 'number' ? error.code : -1 : 0, stdout, stderr });
36
+ });
37
+ });
38
+ // SCM mutations need a High (elevated administrator) or System integrity token. Without it the helper
39
+ // fails with a bare exit code, so a new user must be told to reopen the terminal as administrator.
40
+ const defaultElevated = () => new Promise(resolve => {
41
+ // By absolute path: a whoami.exe planted earlier in PATH must not decide this.
42
+ execFile(paths.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'whoami.exe'), ['/groups', '/fo', 'csv', '/nh'], { windowsHide: true, encoding: 'utf8', timeout: 8000 }, (error, stdout) => {
43
+ resolve(error ? null : /"S-1-16-(12288|16384)"/.test(stdout));
44
+ });
45
+ });
46
+ /** Native SCM helper is the only service observer and mutator. No shell, PID guessing or registry fallback. */
47
+ export function createWindowsAdapter(options = {}) {
48
+ const env = options.env ?? process.env, home = options.homeDir ?? homedir();
49
+ const run = options.run ?? defaultRun, canonicalize = options.canonicalize ?? realpath, elevated = options.elevated ?? defaultElevated;
50
+ const same = async (a, b) => (await canonicalize(a)) === (await canonicalize(b));
51
+ async function helper(c) {
52
+ const explicit = options.helperPath ?? env.MURMUR_SERVICE_BIN;
53
+ if (explicit !== undefined) {
54
+ absolute(explicit);
55
+ return explicit;
56
+ }
57
+ for (const file of [paths.join(c.repoRoot, 'bin', 'murmur-svc.exe'), paths.join(c.repoRoot, 'spikes', 'windows-service-go', 'murmur-svc.exe')]) {
58
+ try {
59
+ if ((await stat(file)).isFile())
60
+ return file;
61
+ }
62
+ catch { }
63
+ }
64
+ return fail('helper-not-installed');
65
+ }
66
+ function selectedEnvironment(c) {
67
+ const selected = {};
68
+ for (const name of ['SystemRoot', 'WINDIR', 'ProgramData', 'TEMP', 'TMP', 'USERPROFILE', 'LOCALAPPDATA', 'APPDATA', 'PATH']) {
69
+ const found = Object.keys(env).find(k => k.toLowerCase() === name.toLowerCase());
70
+ if (found && env[found] !== undefined)
71
+ selected[name] = env[found];
72
+ }
73
+ return { ...selected, DATA_DIR: c.dataDir, MURMUR_DATA_DIR: c.dataDir, MURMUR_NODE: c.nodePath,
74
+ MURMUR_ENTRY: paths.join(c.repoRoot, 'scripts', 'murmur-daemon.mjs'), MURMUR_WORKDIR: c.repoRoot,
75
+ MURMUR_SERVICE_NAME: c.serviceName, MURMUR_RESTARTS_PER_HOUR_LIMIT: '5' };
76
+ }
77
+ async function invoke(c, action) {
78
+ validate(c);
79
+ const result = await run(await helper(c), [action], { env: selectedEnvironment(c), timeout: action === 'status' ? 8000 : 55000 });
80
+ if (result.code !== 0)
81
+ fail(helperReason(result.stderr));
82
+ return result.stdout;
83
+ }
84
+ async function inspect(c) {
85
+ let v;
86
+ try {
87
+ const parsed = JSON.parse(await invoke(c, 'status'));
88
+ if (!object(parsed))
89
+ fail('native-response-invalid');
90
+ v = parsed;
91
+ }
92
+ catch (error) {
93
+ if (error instanceof SyntaxError)
94
+ fail('native-response-invalid');
95
+ throw error;
96
+ }
97
+ const keys = ['schema', 'serviceName', 'profile', 'state', 'manager', 'pid', 'daemonPid', 'since', 'lastExitCode', 'lastFailureAt',
98
+ 'observedStorePath', 'observedStoreUnknownReason', 'restartCount', 'restartWindowMs', 'restartsLastHour', 'restartsUnknownReason', 'restartsPerHourLimit'];
99
+ if (keys.some(k => !Object.hasOwn(v, k)) || v.schema !== 'murmur.windows-service/1' || v.serviceName !== c.serviceName
100
+ || !['running', 'stopped', 'failed', 'unknown'].includes(v.state) || !['windows-service', 'none', 'foreign'].includes(v.manager)
101
+ || !natural(v.pid) || !(v.daemonPid === null || natural(v.daemonPid) && v.daemonPid > 0)
102
+ || !(v.lastExitCode === null || Number.isSafeInteger(v.lastExitCode))
103
+ || !['since', 'lastFailureAt', 'observedStorePath', 'observedStoreUnknownReason', 'restartsUnknownReason'].every(k => textOrNull(v[k]))
104
+ || !['since', 'lastFailureAt'].every(k => v[k] === null || Number.isFinite(Date.parse(v[k])))
105
+ || v.restartsPerHourLimit !== 5)
106
+ fail('native-response-invalid');
107
+ if (!(v.restartCount === null && v.restartWindowMs === null || natural(v.restartCount) && natural(v.restartWindowMs) && v.restartWindowMs <= 3600000)
108
+ || (v.restartWindowMs === 3600000 ? v.restartsLastHour !== v.restartCount : v.restartsLastHour !== null))
109
+ fail('native-response-invalid');
110
+ if (v.manager === 'none') {
111
+ if (v.state !== 'stopped' || v.profile !== null || v.pid !== 0 || v.daemonPid !== null || v.observedStorePath !== null || v.restartCount !== null)
112
+ fail('native-response-invalid');
113
+ return { snapshot: { ...empty('service.not-installed'), state: 'stopped' }, installed: false };
114
+ }
115
+ if (v.manager === 'foreign' || !object(v.profile))
116
+ fail('profile-unverified');
117
+ const expected = { dataDir: c.dataDir, node: c.nodePath, workDir: c.repoRoot, entry: paths.join(c.repoRoot, 'scripts', 'murmur-daemon.mjs') };
118
+ for (const [key, value] of Object.entries(expected)) {
119
+ absolute(v.profile[key]);
120
+ if (!await same(v.profile[key], value))
121
+ fail('profile-mismatch');
122
+ }
123
+ if (v.profile.restartsPerHourLimit !== 5 || v.state === 'running' && v.pid === 0)
124
+ fail('native-response-invalid');
125
+ let observed = null;
126
+ if (v.observedStorePath !== null) {
127
+ absolute(v.observedStorePath);
128
+ if (v.daemonPid === null || !await same(v.observedStorePath, c.storePath))
129
+ fail('observed-store-mismatch');
130
+ observed = await canonicalize(v.observedStorePath);
131
+ }
132
+ return { installed: true, snapshot: { ...empty(), state: v.state, since: v.since, pid: v.daemonPid,
133
+ lastExitCode: v.lastExitCode, observedStorePath: observed, restartCount: v.restartCount, restartWindowMs: v.restartWindowMs } };
134
+ }
135
+ async function mutate(c, action) {
136
+ validate(c);
137
+ const before = await inspect(c);
138
+ if (action === 'install' && before.installed)
139
+ fail('already-installed');
140
+ if (action !== 'install' && !before.installed) {
141
+ if (action === 'stop' || action === 'uninstall')
142
+ return;
143
+ fail('not-installed');
144
+ }
145
+ // An unmeasurable level leaves the decision to SCM rather than refusing a real administrator.
146
+ if (await elevated() === false)
147
+ fail('elevation-required');
148
+ await invoke(c, action);
149
+ const after = await inspect(c);
150
+ if (action === 'uninstall' ? after.installed : !after.installed
151
+ || (action === 'stop' ? after.snapshot.state !== 'stopped' : after.snapshot.state !== 'running' || after.snapshot.pid === null))
152
+ fail('action-unconfirmed');
153
+ }
154
+ return {
155
+ manager: 'windows-service',
156
+ async status(c) {
157
+ try {
158
+ return (await inspect(c)).snapshot;
159
+ }
160
+ catch (e) {
161
+ const reason = e instanceof Error && /^service\.(?:[a-z-]+|helper\.[A-Za-z][A-Za-z0-9.]*)$/.test(e.message) ? e.message : 'service.measurement-failed';
162
+ return empty(reason);
163
+ }
164
+ },
165
+ install: c => mutate(c, 'install'), start: c => mutate(c, 'start'), stop: c => mutate(c, 'stop'), uninstall: c => mutate(c, 'uninstall'),
166
+ async detectClients() {
167
+ const rows = [];
168
+ for (const [id, command, configPath, format] of [
169
+ ['claude-code', 'claude', env.CLAUDE_CONFIG_DIR ? null : paths.join(home, '.claude.json'), 'json'],
170
+ ['codex-cli', 'codex', paths.join(env.CODEX_HOME || paths.join(home, '.codex'), 'config.toml'), 'toml'],
171
+ ]) {
172
+ let installed = false;
173
+ for (const dir of (env.PATH ?? env.Path ?? '').split(';')) {
174
+ if (!paths.isAbsolute(dir))
175
+ continue;
176
+ for (const suffix of ['.exe', '.cmd', '.bat']) {
177
+ try {
178
+ if ((await stat(paths.join(dir, command + suffix))).isFile())
179
+ installed = true;
180
+ }
181
+ catch { }
182
+ }
183
+ }
184
+ rows.push({ id, installed, configPath: configPath && paths.isAbsolute(configPath) ? configPath : null, format });
185
+ }
186
+ return rows;
187
+ },
188
+ };
189
+ }
@@ -0,0 +1,5 @@
1
+ import { type FileHandle } from 'node:fs/promises';
2
+ /** Protect a newly created, still-empty setup output before writing credentials. */
3
+ export declare function protectPrivateFile(file: string, handle: FileHandle): Promise<void>;
4
+ /** Existing Windows files keep their access policy, including intentional sandbox readers. */
5
+ export declare function preparePrivateReplacement(file: string, handle: FileHandle, previous: string): Promise<void>;
@@ -0,0 +1,101 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { lstat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { sameClientFileIdentity } from './file-identity.js';
6
+ const exec = promisify(execFile);
7
+ const protect = `
8
+ $ErrorActionPreference = 'Stop'
9
+ # Node can inherit PowerShell 7's module paths when launched by a CI runner or
10
+ # terminal. Only load modules belonging to this Windows PowerShell 5.1 process.
11
+ $env:PSModulePath = [System.IO.Path]::Combine($PSHOME, 'Modules')
12
+ $file = $env:MURMUR_SETUP_PRIVATE_FILE
13
+ $source = $env:MURMUR_SETUP_ACCESS_SOURCE
14
+ $section = [System.Security.AccessControl.AccessControlSections]::Access
15
+ function AccessPolicy($acl) {
16
+ $sddl = $acl.GetSecurityDescriptorSddlForm($section)
17
+ # Windows marks a copied DACL as auto-inherited even when its ACEs are unchanged.
18
+ # Ignore only that bookkeeping flag, retaining protection, order and every ACE.
19
+ return [regex]::Replace($sddl, '^D:(?:P|AI|AR)*', { param($match) $match.Value.Replace('AI', '') })
20
+ }
21
+ $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
22
+ # Match onboarding's new-profile policy: creator and the LocalSystem service.
23
+ # Existing replacements use their saved DACL below, including intentional readers.
24
+ $allowed = @($user, 'S-1-5-18') | Select-Object -Unique
25
+ $security = New-Object System.Security.AccessControl.FileSecurity
26
+ if ($source) {
27
+ $original = Get-Acl -LiteralPath $source
28
+ $expected = $original.GetSecurityDescriptorSddlForm($section)
29
+ $security.SetSecurityDescriptorSddlForm($expected, $section)
30
+ $expectedPolicy = AccessPolicy $original
31
+ } else {
32
+ $security.SetAccessRuleProtection($true, $false)
33
+ foreach ($id in $allowed) {
34
+ $sid = New-Object System.Security.Principal.SecurityIdentifier($id)
35
+ $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'Allow')
36
+ $security.AddAccessRule($rule)
37
+ }
38
+ }
39
+ Set-Acl -LiteralPath $file -AclObject $security
40
+ $actual = Get-Acl -LiteralPath $file
41
+ if ($source) {
42
+ if ((AccessPolicy $actual) -cne $expectedPolicy -or
43
+ (AccessPolicy (Get-Acl -LiteralPath $source)) -cne $expectedPolicy) { throw 'changed access policy' }
44
+ exit 0
45
+ }
46
+ if (!$actual.AreAccessRulesProtected) { throw 'unprotected ACL' }
47
+ $rules = @($actual.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))
48
+ if ($rules.Count -ne $allowed.Count) { throw 'unexpected ACL entries' }
49
+ foreach ($rule in $rules) {
50
+ if ($allowed -notcontains $rule.IdentityReference.Value -or $rule.AccessControlType -ne 'Allow' -or
51
+ $rule.FileSystemRights -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { throw 'unexpected ACL' }
52
+ }
53
+ `;
54
+ async function prepareFile(file, handle, accessSource) {
55
+ if (process.platform !== 'win32')
56
+ return;
57
+ const opened = await handle.stat({ bigint: true });
58
+ const before = await lstat(file, { bigint: true });
59
+ if (!opened.isFile() || opened.size !== 0n || !before.isFile() || !sameClientFileIdentity(before, opened)) {
60
+ throw new Error('private-file.target-invalid');
61
+ }
62
+ const sourceBefore = accessSource ? await lstat(accessSource, { bigint: true }).catch(error => {
63
+ if (error.code === 'ENOENT')
64
+ return null;
65
+ throw error;
66
+ }) : null;
67
+ if (sourceBefore && (!sourceBefore.isFile() || sameClientFileIdentity(sourceBefore, opened))) {
68
+ throw new Error('private-file.access-source-invalid');
69
+ }
70
+ try {
71
+ // Cold Windows PowerShell 5.1 startup (including antivirus checks) can exceed
72
+ // ten seconds. This ceiling bounds a hung ACL command, not its normal path.
73
+ await exec(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(protect, 'utf16le').toString('base64')], { env: { ...process.env, MURMUR_SETUP_PRIVATE_FILE: file, MURMUR_SETUP_ACCESS_SOURCE: sourceBefore ? accessSource : '' },
74
+ windowsHide: true, timeout: 60_000, maxBuffer: 64 * 1024 });
75
+ }
76
+ catch (error) {
77
+ // Keep paths, command text and arbitrary PowerShell stderr out of diagnostics.
78
+ const failure = error;
79
+ const rejection = ['changed access policy', 'unprotected ACL', 'unexpected ACL entries', 'unexpected ACL']
80
+ .find(reason => failure.stderr?.includes(reason));
81
+ const reason = failure.killed ? 'timeout' : failure.code === 'ENOENT' ? 'powershell-unavailable'
82
+ : rejection?.replaceAll(' ', '-') ?? 'command-failed';
83
+ throw new Error(`private-file.windows-acl-failed:${reason}`);
84
+ }
85
+ const after = await lstat(file, { bigint: true });
86
+ if (!after.isFile() || !sameClientFileIdentity(after, opened))
87
+ throw new Error('private-file.target-changed');
88
+ if (sourceBefore) {
89
+ const sourceAfter = await lstat(accessSource, { bigint: true });
90
+ if (!sourceAfter.isFile() || !sameClientFileIdentity(sourceBefore, sourceAfter))
91
+ throw new Error('private-file.access-source-changed');
92
+ }
93
+ }
94
+ /** Protect a newly created, still-empty setup output before writing credentials. */
95
+ export async function protectPrivateFile(file, handle) {
96
+ return prepareFile(file, handle);
97
+ }
98
+ /** Existing Windows files keep their access policy, including intentional sandbox readers. */
99
+ export async function preparePrivateReplacement(file, handle, previous) {
100
+ return prepareFile(file, handle, previous);
101
+ }
@@ -0,0 +1,25 @@
1
+ import type { ServiceContext } from './types.js';
2
+ /** Preparing instructions never sends a message or writes any profile state. */
3
+ export declare function prepareReplyTest(context: ServiceContext, peerId: string): Promise<{
4
+ schema: string;
5
+ agentId: string;
6
+ peerId: string;
7
+ conversationId: string;
8
+ token: string;
9
+ createdAt: string;
10
+ expiresAt: string;
11
+ requestText: string;
12
+ expectedReply: string;
13
+ }>;
14
+ /** Inspect only this test's durable rows; do not consume inbox state or poll NATS. */
15
+ export declare function checkReplyTest(context: ServiceContext, token: string): Promise<{
16
+ schema: string;
17
+ generatedAt: string;
18
+ agentId: string;
19
+ peerId: string;
20
+ conversationId: string;
21
+ state: string;
22
+ requestMsgId: any;
23
+ replyMsgId: any;
24
+ receivedAt: any;
25
+ }>;
@@ -0,0 +1,75 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+ import { loadConfig, validAgentId } from './config.js';
4
+ import { pairFingerprint } from './status.js';
5
+ const duration = 15 * 60 * 1000;
6
+ const conversation = (c) => `murmur:setup:${c.nonce}`;
7
+ const reply = (c) => `MURMUR-SETUP-REPLY ${c.nonce}`;
8
+ const request = (c) => `Murmur connection test from ${c.agentId}. Use murmur_send to reply to ${c.agentId} in this same conversation with exactly this line: ${reply(c)}`;
9
+ // Agent identities/signatures may surround the protocol line, but a substring
10
+ // inside an explanation or blockquote is not a completed request/reply.
11
+ const hasProtocolLine = (text, expected) => text.split(/\r?\n/).some(line => line.trim() === expected);
12
+ /** Preparing instructions never sends a message or writes any profile state. */
13
+ export async function prepareReplyTest(context, peerId) {
14
+ const config = await loadConfig(context);
15
+ if (!validAgentId(peerId) || !config.peers[peerId] || peerId === config.agentId)
16
+ throw new Error('reply-test.peer-invalid');
17
+ const challenge = { version: 1, agentId: config.agentId, peerId, fingerprint: pairFingerprint(config, peerId),
18
+ nonce: randomBytes(24).toString('hex'), createdAt: new Date().toISOString() };
19
+ return { schema: 'murmur.reply-test-plan/1', agentId: config.agentId, peerId, conversationId: conversation(challenge),
20
+ token: Buffer.from(JSON.stringify(challenge)).toString('base64url'), createdAt: challenge.createdAt,
21
+ expiresAt: new Date(Date.parse(challenge.createdAt) + duration).toISOString(),
22
+ requestText: request(challenge), expectedReply: reply(challenge) };
23
+ }
24
+ /** Inspect only this test's durable rows; do not consume inbox state or poll NATS. */
25
+ export async function checkReplyTest(context, token) {
26
+ if (token.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(token))
27
+ throw new Error('reply-test.token-invalid');
28
+ let test;
29
+ try {
30
+ test = JSON.parse(Buffer.from(token, 'base64url').toString('utf8'));
31
+ }
32
+ catch {
33
+ throw new Error('reply-test.token-invalid');
34
+ }
35
+ const now = Date.now(), start = Date.parse(test?.createdAt), end = start + duration;
36
+ if (test?.version !== 1 || !validAgentId(test.agentId) || !validAgentId(test.peerId)
37
+ || typeof test.nonce !== 'string' || !/^[a-f0-9]{48}$/.test(test.nonce)
38
+ || typeof test.fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(test.fingerprint)
39
+ || !Number.isFinite(start) || start > now + 5000)
40
+ throw new Error('reply-test.token-invalid');
41
+ const config = await loadConfig(context), peer = config.peers[test.peerId];
42
+ if (config.agentId !== test.agentId || !peer || pairFingerprint(config, test.peerId) !== test.fingerprint)
43
+ throw new Error('reply-test.profile-changed');
44
+ const db = new DatabaseSync(context.storePath, { readOnly: true });
45
+ try {
46
+ db.exec('PRAGMA busy_timeout=1000; BEGIN');
47
+ const sent = db.prepare(`SELECT msg_id AS msgId, created_at AS createdAt, text, channel_id AS channelId,
48
+ sender_member_id AS senderMemberId, addressee_member_id AS addresseeMemberId FROM local_messages
49
+ WHERE direction='outbound' AND sender=? AND conversation_id=? ORDER BY rowid DESC LIMIT 100`)
50
+ .all(test.agentId, conversation(test));
51
+ const received = db.prepare(`SELECT msg_id AS msgId, created_at AS createdAt, text, channel_id AS channelId,
52
+ sender_member_id AS senderMemberId, addressee_member_id AS addresseeMemberId FROM local_messages
53
+ WHERE direction='inbound' AND sender=? AND conversation_id=?
54
+ ORDER BY rowid DESC LIMIT 100`).all(test.peerId, conversation(test));
55
+ db.exec('COMMIT');
56
+ const inWindow = (row) => {
57
+ const at = Date.parse(row.createdAt);
58
+ return Number.isFinite(at) && at >= start - 5000 && at <= Math.min(end, now + 5000);
59
+ };
60
+ const routing = (row, inbound) => (row.channelId ?? null) === (peer.channelId ?? null)
61
+ && (row.senderMemberId ?? null) === ((inbound ? peer.memberId : config.memberId) ?? null)
62
+ && (row.addresseeMemberId ?? null) === ((inbound ? config.memberId : peer.memberId) ?? null);
63
+ const outbound = sent.find(row => hasProtocolLine(row.text, request(test)) && inWindow(row) && routing(row, false));
64
+ const inbound = outbound && received.find(row => inWindow(row) && routing(row, true)
65
+ && hasProtocolLine(row.text, reply(test))
66
+ && Date.parse(row.createdAt) >= Date.parse(outbound.createdAt) - 5000);
67
+ return { schema: 'murmur.reply-test/1', generatedAt: new Date(now).toISOString(), agentId: config.agentId,
68
+ peerId: test.peerId, conversationId: conversation(test),
69
+ state: inbound ? 'replied' : now > end ? 'expired' : outbound ? 'waiting' : 'not-sent',
70
+ requestMsgId: outbound?.msgId ?? null, replyMsgId: inbound?.msgId ?? null, receivedAt: inbound?.createdAt ?? null };
71
+ }
72
+ finally {
73
+ db.close();
74
+ }
75
+ }
@@ -0,0 +1,3 @@
1
+ import type { ServiceContext } from "./types.js";
2
+ /** Reuse the runtime's existing private-state writer; setup ships with that runtime. */
3
+ export declare function writeState(c: ServiceContext, file: string, value: unknown): Promise<void>;
@@ -0,0 +1,12 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { preparePrivateReplacement } from "./private-file.js";
4
+ /** Reuse the runtime's existing private-state writer; setup ships with that runtime. */
5
+ export async function writeState(c, file, value) {
6
+ if (path.dirname(file) !== c.dataDir)
7
+ throw new Error("state.outside-data-dir");
8
+ const helpers = await import(pathToFileURL(path.join(c.repoRoot, "scripts", "secure-state.mjs")).href);
9
+ await helpers.writePrivateJson(file, value, {
10
+ beforeWrite: (temporary, handle) => preparePrivateReplacement(temporary, handle, file),
11
+ });
12
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Render one untrusted-data-free segment for prompts, tmux and Claude Code.
3
+ * Message bodies, peer IDs and runtime errors are deliberately never copied.
4
+ */
5
+ export declare function renderStatusLine(status: unknown, now?: number): string;
@@ -0,0 +1,19 @@
1
+ import { statusVerdict } from './verdict.js';
2
+ const natural = (value) => Number.isSafeInteger(value) && Number(value) >= 0;
3
+ const safeCode = (value) => /^[a-z][a-z0-9.-]{0,80}$/.test(value) ? value : 'status.invalid';
4
+ /**
5
+ * Render one untrusted-data-free segment for prompts, tmux and Claude Code.
6
+ * Message bodies, peer IDs and runtime errors are deliberately never copied.
7
+ */
8
+ export function renderStatusLine(status, now = Date.now()) {
9
+ const verdict = statusVerdict(status, now);
10
+ const unreadValue = status && typeof status === 'object'
11
+ ? status.inbox?.unread
12
+ : null;
13
+ const unread = natural(unreadValue) && unreadValue > 0 ? `Murmur: ${unreadValue} unread` : '';
14
+ if (verdict.level === 'green')
15
+ return unread;
16
+ const label = verdict.level === 'red' ? 'error' : verdict.level === 'yellow' ? 'warning' : 'unknown';
17
+ const health = `Murmur: ${label} (${safeCode(verdict.code)})`;
18
+ return unread ? `${unread} | ${health}` : health;
19
+ }
@@ -0,0 +1,137 @@
1
+ import { type AgentConfig } from "./config.js";
2
+ import type { PlatformAdapter, ServiceContext } from "./types.js";
3
+ export type Measurement = {
4
+ measuredAt: string | null;
5
+ unknownReason: string | null;
6
+ };
7
+ export declare const pairFingerprint: (config: AgentConfig, peerId: string) => string;
8
+ export declare function validPairProof(proof: any, config: AgentConfig, peerId: string, now: number): boolean;
9
+ export interface StatusOptions {
10
+ context: ServiceContext;
11
+ adapter: PlatformAdapter;
12
+ now?: () => number;
13
+ }
14
+ export declare function readStatus({ context: c, adapter, now }: StatusOptions): Promise<{
15
+ schema: "murmur.status/1";
16
+ generatedAt: string;
17
+ agentId: string | null;
18
+ serviceName: string;
19
+ service: {
20
+ lastFailureAt: null;
21
+ restartsLastHour: number | null;
22
+ restartFailureThreshold: number;
23
+ unknownReason: string | null;
24
+ measurements: {
25
+ manager: Measurement;
26
+ history: Measurement;
27
+ };
28
+ state: "running" | "stopped" | "failed" | "unknown";
29
+ manager: import("./types.js").ServiceManager;
30
+ since: string | null;
31
+ pid: number | null;
32
+ lastExitCode: number | null;
33
+ observedStorePath: string | null;
34
+ restartCount: number | null;
35
+ restartWindowMs: number | null;
36
+ detail?: string;
37
+ };
38
+ broker: {
39
+ url: string | null;
40
+ state: any;
41
+ connectedAt: any;
42
+ lastError: any;
43
+ lastErrorAt: any;
44
+ unknownReason: string | null;
45
+ measurements: {
46
+ runtime: Measurement;
47
+ };
48
+ };
49
+ peers: {
50
+ list: Array<{
51
+ agentId: string;
52
+ paired: boolean | null;
53
+ lastInboundAt: string | null;
54
+ lastOutboundAt: string | null;
55
+ }> | null;
56
+ unknownReason: string | null;
57
+ measurements: Record<string, Measurement>;
58
+ };
59
+ inbox: {
60
+ unread: number | null;
61
+ total: number | null;
62
+ lastAt: string | null;
63
+ unknownReason: string | null;
64
+ measurements: {
65
+ store: Measurement;
66
+ cursor: Measurement;
67
+ };
68
+ };
69
+ deliveries: Record<string, unknown>[] | null;
70
+ outbox: {
71
+ attention: {
72
+ schema: "murmur.outbox-attention/1";
73
+ total: number;
74
+ pending: number;
75
+ dismissed: number;
76
+ items: {
77
+ msgId: string;
78
+ peer: string | null;
79
+ createdAt: string;
80
+ failedAt: string;
81
+ reason: string;
82
+ attempts: number;
83
+ token: string;
84
+ dismissed: boolean;
85
+ }[];
86
+ truncated: boolean;
87
+ unknownReason: null;
88
+ } | {
89
+ unknownReason: string;
90
+ };
91
+ queue: {
92
+ pending: number | null;
93
+ inflight: number | null;
94
+ delivered: number | null;
95
+ failed: number | null;
96
+ dlq: number | null;
97
+ oldestPendingAt: string | null;
98
+ unknownReason: string | null;
99
+ };
100
+ faults: {
101
+ lastError: string | null;
102
+ lastErrorAt: string | null;
103
+ unknownReason: string | null;
104
+ source: string;
105
+ };
106
+ };
107
+ wake: {
108
+ config: {
109
+ enabled: boolean | null;
110
+ mode: "none" | "hook" | "monitor" | null;
111
+ responder: string | null;
112
+ unknownReason: string | null;
113
+ };
114
+ effective: {
115
+ enabled: any;
116
+ needsRestart: boolean | null;
117
+ observedAt: string | null;
118
+ unknownReason: string | null;
119
+ };
120
+ delivery: {
121
+ pendingUndelivered: number | null;
122
+ storedOnly: number | null;
123
+ lastDeliveredAt: string | null;
124
+ unknownReason: string | null;
125
+ };
126
+ faults: {
127
+ lastFault: string | null;
128
+ lastFaultAt: any;
129
+ unknownReason: string | null;
130
+ measurements: {
131
+ store: Measurement;
132
+ runtime: Measurement;
133
+ };
134
+ };
135
+ };
136
+ }>;
137
+ export declare function configuredWakeMode(config: AgentConfig): "hook" | "monitor" | "none";