@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,142 @@
1
+ #!/usr/bin/env node
2
+ // Standalone Murmur V2 sender for shell scripts and send-boundary services.
3
+ // Reads agent-config.json from DATA_DIR, encrypts/signs/enqueues an envelope.
4
+
5
+ import { randomUUID } from "node:crypto";
6
+ import { readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { SQLiteDedupeOutboxStore, SQLiteMessageStore, stableEnvelopePayload, resolveMessageSubject } from "@murmurv2/core";
9
+ import { encryptPayload, signEnvelope } from "@murmurv2/security";
10
+ import { readPrivateJson } from "./secure-state.mjs";
11
+
12
+ const args = process.argv.slice(2);
13
+ const opt = {};
14
+ for (let i = 0; i < args.length; i += 1) {
15
+ const a = args[i];
16
+ if (a === "--to") opt.to = args[++i];
17
+ else if (a === "--conv" || a === "--conversation") opt.conversationId = args[++i];
18
+ else if (a === "--channel") opt.channelId = args[++i];
19
+ else if (a === "--sender-member") opt.senderMemberId = args[++i];
20
+ else if (a === "--addressee-member") opt.addresseeMemberId = args[++i];
21
+ else if (a === "--text") opt.text = args[++i];
22
+ else if (a === "--text-file") opt.textFile = args[++i];
23
+ else if (a === "--stdin") opt.stdin = true;
24
+ else if (a === "--msg-id") opt.msgId = args[++i];
25
+ else if (a === "--help" || a === "-h") opt.help = true;
26
+ }
27
+
28
+ if (opt.help || !opt.to || (!opt.text && !opt.textFile && !opt.stdin)) {
29
+ process.stderr.write(
30
+ "usage: murmur-shell-send.mjs --to <peer-id> (--text <txt> | --text-file <path> | --stdin) [--conv <id>] [--msg-id <uuid>] [--channel <id> --sender-member <id> [--addressee-member <id>]]\n",
31
+ );
32
+ process.exit(1);
33
+ }
34
+
35
+ // `--msg-id` makes a send idempotent (#105): the caller supplies the id — the Codex wake
36
+ // relay derives it from the inbound msgId — and a repeat with the same id changes nothing.
37
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
38
+ if (opt.msgId !== undefined && !UUID_RE.test(String(opt.msgId))) {
39
+ process.stderr.write("error: --msg-id must be a UUID\n");
40
+ process.exit(1);
41
+ }
42
+
43
+ if (opt.textFile) {
44
+ opt.text = readFileSync(opt.textFile, "utf8");
45
+ } else if (opt.stdin) {
46
+ opt.text = readFileSync(0, "utf8");
47
+ }
48
+ const text = String(opt.text ?? "").trim();
49
+ if (!text) {
50
+ process.stderr.write("error: text is empty\n");
51
+ process.exit(1);
52
+ }
53
+
54
+ const dataDir = process.env.DATA_DIR || ".data";
55
+ const configPath = path.join(dataDir, "agent-config.json");
56
+ const dbPath = process.env.MURMUR_STORE_PATH ?? path.join(dataDir, "murmur.db");
57
+
58
+ let cfg;
59
+ try {
60
+ cfg = await readPrivateJson(configPath);
61
+ } catch (err) {
62
+ process.stderr.write(`error: cannot read ${configPath}: ${err.message}\n`);
63
+ process.exit(2);
64
+ }
65
+
66
+ const peer = cfg.peers?.[opt.to];
67
+ if (!peer) {
68
+ process.stderr.write(`error: unknown peer '${opt.to}' in ${configPath}\n`);
69
+ process.exit(2);
70
+ }
71
+
72
+ const conversationId = opt.conversationId || `dm:${cfg.agentId}:${opt.to}`;
73
+ const msgId = opt.msgId ? String(opt.msgId).toLowerCase() : randomUUID();
74
+ const createdAt = new Date().toISOString();
75
+ const optionalString = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
76
+ const channelId = optionalString(opt.channelId) ?? optionalString(peer.channelId);
77
+ const senderMemberId = optionalString(opt.senderMemberId) ?? optionalString(cfg.memberId);
78
+ const addresseeMemberId = optionalString(opt.addresseeMemberId) ?? optionalString(peer.memberId);
79
+ if ((channelId || senderMemberId || addresseeMemberId) && (!channelId || !senderMemberId)) {
80
+ process.stderr.write("error: channelId and senderMemberId are required together for structured routing\n");
81
+ process.exit(2);
82
+ }
83
+ const routing = channelId ? {
84
+ channelId,
85
+ senderMemberId,
86
+ ...(addresseeMemberId ? { addresseeMemberId } : {}),
87
+ } : {};
88
+
89
+
90
+ try {
91
+ const encrypted = await encryptPayload(
92
+ text,
93
+ peer.encryption.publicKey,
94
+ cfg.keys.encryption.privateKey,
95
+ );
96
+
97
+ const envelope = {
98
+ schemaVersion: "1.0",
99
+ msgId,
100
+ conversationId,
101
+ senderAgentId: cfg.agentId,
102
+ recipients: [opt.to],
103
+ createdAt,
104
+ payloadCiphertext: encrypted.ciphertext,
105
+ payloadNonce: encrypted.nonce,
106
+ signature: "",
107
+ ...routing,
108
+ };
109
+ envelope.signature = await signEnvelope(
110
+ stableEnvelopePayload(envelope),
111
+ cfg.keys.signing.privateKey,
112
+ );
113
+
114
+ const outbox = new SQLiteDedupeOutboxStore(dbPath);
115
+ outbox.db?.exec?.("PRAGMA busy_timeout=5000;");
116
+ if (opt.msgId && (await outbox.getOutboxRecord(msgId))) {
117
+ process.stdout.write(`${JSON.stringify({ msgId, to: opt.to, conversationId, status: "already-queued", ...routing })}\n`);
118
+ process.exit(0);
119
+ }
120
+ await outbox.enqueue(resolveMessageSubject(peer, channelId), envelope);
121
+
122
+ const store = new SQLiteMessageStore(dbPath);
123
+ store.db?.exec?.("PRAGMA busy_timeout=5000;");
124
+ await store.append({
125
+ conversationId,
126
+ msgId,
127
+ direction: "outbound",
128
+ sender: cfg.agentId,
129
+ text,
130
+ createdAt,
131
+ transport: "nats",
132
+ ...routing,
133
+ });
134
+
135
+ process.stdout.write(
136
+ `${JSON.stringify({ msgId, to: opt.to, conversationId, status: "queued", ...routing })}\n`,
137
+ );
138
+ process.exit(0);
139
+ } catch (err) {
140
+ process.stderr.write(`error: enqueue failed: ${err.message}\n`);
141
+ process.exit(3);
142
+ }
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+ import { DatabaseSync } from "node:sqlite";
3
+
4
+ const nowIso = () => new Date().toISOString();
5
+
6
+ const ensureArray = (value) => (Array.isArray(value) ? value : value ? [value] : []);
7
+
8
+ // Optional sender filter on a notify target: `peers: ["agent-jarvis"]`.
9
+ // A target without it stays a catch-all, so existing configs keep receiving
10
+ // everything. Agent ids are compared case-insensitively.
11
+ const normalizePeers = (value) => {
12
+ if (value === undefined) return undefined;
13
+ const list = ensureArray(value)
14
+ .filter((peer) => typeof peer === "string")
15
+ .map((peer) => peer.trim().toLowerCase())
16
+ .filter(Boolean);
17
+ return list;
18
+ };
19
+
20
+ const normalizeTelegram = (value, channelName = "telegram") => {
21
+ const list = ensureArray(value).filter(Boolean);
22
+ return list
23
+ .map((entry, i) => ({
24
+ type: "telegram",
25
+ channel: entry?.channel || `${channelName}${list.length > 1 ? `-${i + 1}` : ""}`,
26
+ botToken: entry?.botToken,
27
+ chatId: entry?.chatId,
28
+ topicId: entry?.topicId,
29
+ peers: normalizePeers(entry?.peers),
30
+ fallback: entry?.fallback === true || undefined,
31
+ }))
32
+ .filter((entry) => entry.botToken && entry.chatId);
33
+ };
34
+
35
+ const normalizeWebhook = (value, channelName = "webhook") => {
36
+ const list = ensureArray(value).filter(Boolean);
37
+ return list
38
+ .map((entry, i) => ({
39
+ type: "webhook",
40
+ channel: entry?.channel || entry?.name || `${channelName}${list.length > 1 ? `-${i + 1}` : ""}`,
41
+ url: entry?.url,
42
+ headers: entry?.headers && typeof entry.headers === "object" ? entry.headers : {},
43
+ peers: normalizePeers(entry?.peers),
44
+ fallback: entry?.fallback === true || undefined,
45
+ }))
46
+ .filter((entry) => entry.url);
47
+ };
48
+
49
+ export const normalizeNotifyTargets = (notifyConfig) => {
50
+ if (!notifyConfig) return [];
51
+
52
+ if (Array.isArray(notifyConfig)) {
53
+ return notifyConfig.flatMap((entry) => {
54
+ if (!entry || typeof entry !== "object") return [];
55
+ if (entry.type === "telegram") return normalizeTelegram(entry, entry.channel || "telegram");
56
+ if (entry.type === "webhook") return normalizeWebhook(entry, entry.channel || "webhook");
57
+ return [];
58
+ });
59
+ }
60
+
61
+ const targets = [];
62
+
63
+ if (notifyConfig.telegram) targets.push(...normalizeTelegram(notifyConfig.telegram, "telegram"));
64
+ if (notifyConfig.webhook) targets.push(...normalizeWebhook(notifyConfig.webhook, "webhook"));
65
+
66
+ // Backward compatibility: notify: { botToken, chatId, topicId }
67
+ if (!notifyConfig.telegram && notifyConfig.botToken && notifyConfig.chatId) {
68
+ targets.push(...normalizeTelegram(notifyConfig, "telegram"));
69
+ }
70
+
71
+ // Backward compatibility: notify: { url, headers }
72
+ if (!notifyConfig.webhook && notifyConfig.url) {
73
+ targets.push(...normalizeWebhook(notifyConfig, "webhook"));
74
+ }
75
+
76
+ return targets;
77
+ };
78
+
79
+ // Which targets a message from `sender` belongs to.
80
+ //
81
+ // Three kinds of target, and the difference matters when one chat has a thread
82
+ // per peer:
83
+ // - `peers: [...]` takes only those senders;
84
+ // - `fallback: true` takes what no peer-filtered target took — a home for
85
+ // a peer nobody made a thread for, without copying
86
+ // every message into it;
87
+ // - neither takes everything, which is what every config written
88
+ // before this option did.
89
+ export const targetsForSender = (targets, sender) => {
90
+ const from = String(sender || "").trim().toLowerCase();
91
+ const matched = targets.filter((target) => target.peers && from && target.peers.includes(from));
92
+ const always = targets.filter((target) => !target.peers && !target.fallback);
93
+ if (matched.length > 0) return [...matched, ...always];
94
+ return [...always, ...targets.filter((target) => target.fallback && !target.peers)];
95
+ };
96
+
97
+ // Both normal delivery and failed-wake notifications use this path, so routing
98
+ // and the diagnostic for an unmatched peer cannot diverge between them.
99
+ export const enqueuePeerNotification = ({ queue, targets, payload, log, reason }) => {
100
+ if (targets.length === 0 || payload.wakeEligible === false) return 0;
101
+ const selected = targetsForSender(targets, payload.from);
102
+ if (selected.length === 0) {
103
+ log("warn", "No notify target accepts this sender", {
104
+ msgId: payload.msgId, from: payload.from, targetCount: targets.length,
105
+ ...(reason ? { reason } : {}),
106
+ });
107
+ return 0;
108
+ }
109
+ queue.enqueueMessage(reason ? { ...payload, text: `[WakeMonitor ${reason}] ${payload.text}` } : payload, selected);
110
+ log("info", "Notifications queued", {
111
+ msgId: payload.msgId, targetCount: selected.length,
112
+ channels: selected.map((target) => target.channel).join(","),
113
+ ...(reason ? { reason } : {}),
114
+ });
115
+ return selected.length;
116
+ };
117
+
118
+ export class NotifyQueue {
119
+ constructor(dbPath) {
120
+ this.db = new DatabaseSync(dbPath);
121
+ this.db.exec(`
122
+ PRAGMA journal_mode=WAL;
123
+ PRAGMA busy_timeout=5000;
124
+ CREATE TABLE IF NOT EXISTS notify_queue (
125
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
126
+ dedupe_key TEXT NOT NULL UNIQUE,
127
+ msg_id TEXT NOT NULL,
128
+ channel_type TEXT NOT NULL,
129
+ channel_name TEXT NOT NULL,
130
+ target_json TEXT NOT NULL,
131
+ payload_json TEXT NOT NULL,
132
+ status TEXT NOT NULL,
133
+ attempts INTEGER NOT NULL DEFAULT 0,
134
+ last_error TEXT,
135
+ next_attempt_at TEXT NOT NULL,
136
+ created_at TEXT NOT NULL,
137
+ updated_at TEXT NOT NULL,
138
+ sent_at TEXT
139
+ );
140
+ CREATE INDEX IF NOT EXISTS idx_notify_queue_due ON notify_queue(status, next_attempt_at);
141
+ `);
142
+ }
143
+
144
+ enqueueMessage(payload, targets) {
145
+ const now = nowIso();
146
+ for (const target of targets) {
147
+ const dedupeKey = `${payload.msgId}:${target.type}:${target.channel}`;
148
+ this.db.prepare(`
149
+ INSERT OR IGNORE INTO notify_queue
150
+ (dedupe_key, msg_id, channel_type, channel_name, target_json, payload_json, status, attempts, next_attempt_at, created_at, updated_at)
151
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?)
152
+ `).run(
153
+ dedupeKey,
154
+ payload.msgId,
155
+ target.type,
156
+ target.channel,
157
+ JSON.stringify(target),
158
+ JSON.stringify(payload),
159
+ now,
160
+ now,
161
+ now,
162
+ );
163
+ }
164
+ }
165
+
166
+ claimDue(limit = 50) {
167
+ return this.db.prepare(`
168
+ SELECT * FROM notify_queue
169
+ WHERE status IN ('pending', 'failed') AND next_attempt_at <= ?
170
+ ORDER BY next_attempt_at ASC
171
+ LIMIT ?
172
+ `).all(nowIso(), limit);
173
+ }
174
+
175
+ markSent(id) {
176
+ this.db.prepare(`
177
+ UPDATE notify_queue
178
+ SET status = 'sent', attempts = attempts + 1, updated_at = ?, sent_at = ?, last_error = NULL
179
+ WHERE id = ?
180
+ `).run(nowIso(), nowIso(), id);
181
+ }
182
+
183
+ markFailed(id, reason, backoffMs = 5000) {
184
+ const nextAttemptAt = new Date(Date.now() + backoffMs).toISOString();
185
+ this.db.prepare(`
186
+ UPDATE notify_queue
187
+ SET status = 'failed', attempts = attempts + 1, updated_at = ?, next_attempt_at = ?, last_error = ?
188
+ WHERE id = ?
189
+ `).run(nowIso(), nextAttemptAt, reason, id);
190
+ }
191
+
192
+ markDead(id, reason) {
193
+ this.db.prepare(`
194
+ UPDATE notify_queue
195
+ SET status = 'dead', attempts = attempts + 1, updated_at = ?, last_error = ?
196
+ WHERE id = ?
197
+ `).run(nowIso(), reason, id);
198
+ }
199
+
200
+ pendingCount() {
201
+ const row = this.db.prepare(`
202
+ SELECT COUNT(*) as count FROM notify_queue WHERE status IN ('pending', 'failed')
203
+ `).get();
204
+ return Number(row?.count ?? 0);
205
+ }
206
+ }
207
+
208
+ const formatNotifyText = (payload) => `📨 [${payload.from}]\n${payload.text}`;
209
+
210
+ const TG_MAX_LENGTH = 4000; // Telegram limit is 4096, leave margin
211
+
212
+ const sendTelegram = async (target, payload) => {
213
+ let text = formatNotifyText(payload);
214
+
215
+ // Truncate if too long for Telegram
216
+ if (text.length > TG_MAX_LENGTH) {
217
+ text = text.slice(0, TG_MAX_LENGTH - 40) + "\n\n... [truncated, full in DB]";
218
+ }
219
+
220
+ const body = {
221
+ chat_id: target.chatId,
222
+ text,
223
+ disable_web_page_preview: true,
224
+ };
225
+ if (target.topicId) body.message_thread_id = Number(target.topicId);
226
+
227
+ const res = await fetch(`https://api.telegram.org/bot${target.botToken}/sendMessage`, {
228
+ method: "POST",
229
+ headers: { "Content-Type": "application/json" },
230
+ body: JSON.stringify(body),
231
+ });
232
+
233
+ if (!res.ok) {
234
+ const errText = await res.text().catch(() => "");
235
+ const err = new Error(`telegram-http-${res.status}:${errText.slice(0, 280)}`);
236
+ err.httpStatus = res.status;
237
+ throw err;
238
+ }
239
+ };
240
+
241
+ const sendWebhook = async (target, payload) => {
242
+ const res = await fetch(target.url, {
243
+ method: "POST",
244
+ headers: { "Content-Type": "application/json", ...(target.headers || {}) },
245
+ body: JSON.stringify(payload),
246
+ });
247
+
248
+ if (!res.ok) {
249
+ const text = await res.text().catch(() => "");
250
+ throw new Error(`webhook-http-${res.status}:${text.slice(0, 280)}`);
251
+ }
252
+ };
253
+
254
+ export const dispatchNotification = async (target, payload) => {
255
+ if (target.type === "telegram") return sendTelegram(target, payload);
256
+ if (target.type === "webhook") return sendWebhook(target, payload);
257
+ throw new Error(`unsupported-notify-target:${target.type}`);
258
+ };
259
+
260
+ export const flushNotifyQueue = async ({ queue, log, limit = 50 }) => {
261
+ const due = queue.claimDue(limit);
262
+ for (const row of due) {
263
+ const target = JSON.parse(String(row.target_json));
264
+ const payload = JSON.parse(String(row.payload_json));
265
+
266
+ log("info", "Notify attempt", {
267
+ queueId: row.id,
268
+ msgId: row.msg_id,
269
+ target: `${row.channel_type}:${row.channel_name}`,
270
+ attempt: Number(row.attempts) + 1,
271
+ });
272
+
273
+ try {
274
+ await dispatchNotification(target, payload);
275
+ queue.markSent(row.id);
276
+ log("info", "Notify success", {
277
+ queueId: row.id,
278
+ msgId: row.msg_id,
279
+ target: `${row.channel_type}:${row.channel_name}`,
280
+ });
281
+ } catch (err) {
282
+ const reason = err instanceof Error ? err.message : String(err);
283
+ const httpStatus = err instanceof Error ? err.httpStatus : undefined;
284
+ const attempts = Number(row.attempts) + 1;
285
+
286
+ // Dead-letter on permanent failures (4xx) or too many retries
287
+ if ((httpStatus && httpStatus >= 400 && httpStatus < 500) || attempts >= 10) {
288
+ queue.markDead(row.id, `permanent: ${reason}`);
289
+ log("error", "Notify dead-lettered", {
290
+ queueId: row.id,
291
+ msgId: row.msg_id,
292
+ target: `${row.channel_type}:${row.channel_name}`,
293
+ reason,
294
+ attempts,
295
+ });
296
+ } else {
297
+ queue.markFailed(row.id, reason, Math.min(60_000, 2_000 * attempts));
298
+ log("warn", "Notify failed", {
299
+ queueId: row.id,
300
+ msgId: row.msg_id,
301
+ target: `${row.channel_type}:${row.channel_name}`,
302
+ reason,
303
+ });
304
+ }
305
+ }
306
+ }
307
+
308
+ return due.length;
309
+ };
@@ -0,0 +1,34 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const policy = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).engines?.node;
4
+ const minimumPolicy = /^>=(\d+\.\d+\.\d+)$/.exec(policy ?? '');
5
+ if (!minimumPolicy) throw new Error('runtime.node-policy-invalid: engines.node must declare one minimum Node.js version.');
6
+ export const MIN_NODE_VERSION = minimumPolicy[1];
7
+
8
+ function failure(code, detail) {
9
+ return Object.assign(new Error(`${code}: ${detail}`), { code });
10
+ }
11
+
12
+ // Only built-ins and the root policy manifest: no installed workspace modules or
13
+ // profile access before the CLI loads modules that statically import node:sqlite.
14
+ export async function checkRuntime({ version = process.versions.node, loadSqlite = () => import('node:sqlite') } = {}) {
15
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
16
+ const current = match?.slice(1).map(Number);
17
+ const minimum = MIN_NODE_VERSION.split('.').map(Number);
18
+ const difference = current?.findIndex((value, index) => value !== minimum[index]);
19
+ if (!current || (difference >= 0 && current[difference] < minimum[difference])) {
20
+ throw failure('runtime.node-version-unsupported', `Use Node.js ${MIN_NODE_VERSION} or newer; current version is ${version}.`);
21
+ }
22
+ try {
23
+ const { DatabaseSync } = await loadSqlite();
24
+ const db = new DatabaseSync(':memory:');
25
+ try {
26
+ if (db.prepare('SELECT 1 AS ok').get().ok !== 1) throw new Error('probe failed');
27
+ } finally {
28
+ db.close();
29
+ }
30
+ } catch {
31
+ // Do not expose loader output or an arbitrary error payload in diagnostics.
32
+ throw failure('runtime.sqlite-unavailable', `Node.js ${MIN_NODE_VERSION} or newer with working node:sqlite is required; current version is ${version}. Remove --no-experimental-sqlite or choose a compatible Node.js installation.`);
33
+ }
34
+ }
@@ -0,0 +1,105 @@
1
+ import { constants } from "node:fs";
2
+ import { chmod, lstat, mkdir, open, rename, unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ const currentUid = () => typeof process.getuid === "function" ? process.getuid() : undefined;
7
+
8
+ const assertOwned = (stats, target) => {
9
+ const uid = currentUid();
10
+ if (uid !== undefined && stats.uid !== uid) {
11
+ throw new Error(`private-state-owner-mismatch:${target}`);
12
+ }
13
+ };
14
+
15
+ const assertPrivateDirectory = async (dirPath) => {
16
+ const stats = await lstat(dirPath);
17
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
18
+ throw new Error(`private-state-directory-invalid:${dirPath}`);
19
+ }
20
+ assertOwned(stats, dirPath);
21
+ };
22
+
23
+ const assertPrivateRegularFile = async (filePath) => {
24
+ const stats = await lstat(filePath);
25
+ if (stats.isSymbolicLink() || !stats.isFile()) {
26
+ throw new Error(`private-state-file-invalid:${filePath}`);
27
+ }
28
+ assertOwned(stats, filePath);
29
+ };
30
+
31
+ export const setPrivateUmask = () => {
32
+ process.umask(0o077);
33
+ };
34
+
35
+ export const ensurePrivateDirectory = async (dirPath) => {
36
+ setPrivateUmask();
37
+ await mkdir(dirPath, { recursive: true, mode: 0o700 });
38
+ await assertPrivateDirectory(dirPath);
39
+ await chmod(dirPath, 0o700);
40
+ };
41
+
42
+ export const readPrivateJson = async (filePath) => {
43
+ setPrivateUmask();
44
+ await assertPrivateDirectory(path.dirname(filePath));
45
+ await assertPrivateRegularFile(filePath);
46
+ await chmod(filePath, 0o600);
47
+
48
+ const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
49
+ try {
50
+ const stats = await handle.stat();
51
+ if (!stats.isFile()) throw new Error(`private-state-file-invalid:${filePath}`);
52
+ assertOwned(stats, filePath);
53
+ return JSON.parse(await handle.readFile("utf8"));
54
+ } finally {
55
+ await handle.close();
56
+ }
57
+ };
58
+
59
+ export const writePrivateJson = async (filePath, value, { beforeWrite } = {}) => {
60
+ setPrivateUmask();
61
+ const dirPath = path.dirname(filePath);
62
+ await ensurePrivateDirectory(dirPath);
63
+
64
+ try {
65
+ await assertPrivateRegularFile(filePath);
66
+ } catch (err) {
67
+ if (err?.code !== "ENOENT") throw err;
68
+ }
69
+
70
+ const tempPath = path.join(
71
+ dirPath,
72
+ `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
73
+ );
74
+ let handle;
75
+ try {
76
+ handle = await open(
77
+ tempPath,
78
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
79
+ 0o600,
80
+ );
81
+ await beforeWrite?.(tempPath, handle);
82
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
83
+ await handle.sync();
84
+ await handle.close();
85
+ handle = undefined;
86
+ await rename(tempPath, filePath);
87
+ await chmod(filePath, 0o600);
88
+
89
+ // Windows does not support fsync on a directory handle (FlushFileBuffers
90
+ // returns EPERM/EINVAL). The file itself is already fsync'd above; the
91
+ // directory sync is a best-effort durability nicety, so skip it on win32.
92
+ if (process.platform !== "win32") {
93
+ const dirHandle = await open(dirPath, constants.O_RDONLY | constants.O_DIRECTORY);
94
+ try {
95
+ await dirHandle.sync();
96
+ } finally {
97
+ await dirHandle.close();
98
+ }
99
+ }
100
+ } catch (err) {
101
+ if (handle) await handle.close().catch(() => {});
102
+ await unlink(tempPath).catch(() => {});
103
+ throw err;
104
+ }
105
+ };