@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.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/bin/murmur-npm.mjs +6 -0
- package/package.json +42 -0
- package/runtime/bin/murmur-svc.exe +0 -0
- package/runtime/npm-runtime-manifest.json +299 -0
- package/runtime/package.json +1 -0
- package/runtime/packages/mcp-server/dist/src/index.js +1 -0
- package/runtime/packages/mcp-server/package.json +1 -0
- package/runtime/packages/setup/bin/murmur.mjs +26 -0
- package/runtime/packages/setup/dist/src/appdata.d.ts +9 -0
- package/runtime/packages/setup/dist/src/appdata.js +53 -0
- package/runtime/packages/setup/dist/src/cli.d.ts +9 -0
- package/runtime/packages/setup/dist/src/cli.js +130 -0
- package/runtime/packages/setup/dist/src/clients.d.ts +151 -0
- package/runtime/packages/setup/dist/src/clients.js +251 -0
- package/runtime/packages/setup/dist/src/commands.d.ts +35 -0
- package/runtime/packages/setup/dist/src/commands.js +115 -0
- package/runtime/packages/setup/dist/src/config.d.ts +49 -0
- package/runtime/packages/setup/dist/src/config.js +70 -0
- package/runtime/packages/setup/dist/src/doctor.d.ts +25 -0
- package/runtime/packages/setup/dist/src/doctor.js +174 -0
- package/runtime/packages/setup/dist/src/file-identity.d.ts +3 -0
- package/runtime/packages/setup/dist/src/file-identity.js +11 -0
- package/runtime/packages/setup/dist/src/index.d.ts +7 -0
- package/runtime/packages/setup/dist/src/index.js +7 -0
- package/runtime/packages/setup/dist/src/onboarding.d.ts +46 -0
- package/runtime/packages/setup/dist/src/onboarding.js +298 -0
- package/runtime/packages/setup/dist/src/outbox-attention.d.ts +51 -0
- package/runtime/packages/setup/dist/src/outbox-attention.js +98 -0
- package/runtime/packages/setup/dist/src/paths.d.ts +10 -0
- package/runtime/packages/setup/dist/src/paths.js +41 -0
- package/runtime/packages/setup/dist/src/platform/darwin.d.ts +17 -0
- package/runtime/packages/setup/dist/src/platform/darwin.js +270 -0
- package/runtime/packages/setup/dist/src/platform/linux.d.ts +9 -0
- package/runtime/packages/setup/dist/src/platform/linux.js +176 -0
- package/runtime/packages/setup/dist/src/platform/windows.d.ts +25 -0
- package/runtime/packages/setup/dist/src/platform/windows.js +189 -0
- package/runtime/packages/setup/dist/src/private-file.d.ts +5 -0
- package/runtime/packages/setup/dist/src/private-file.js +101 -0
- package/runtime/packages/setup/dist/src/reply-test.d.ts +25 -0
- package/runtime/packages/setup/dist/src/reply-test.js +75 -0
- package/runtime/packages/setup/dist/src/state.d.ts +3 -0
- package/runtime/packages/setup/dist/src/state.js +12 -0
- package/runtime/packages/setup/dist/src/status-line.d.ts +5 -0
- package/runtime/packages/setup/dist/src/status-line.js +19 -0
- package/runtime/packages/setup/dist/src/status.d.ts +137 -0
- package/runtime/packages/setup/dist/src/status.js +199 -0
- package/runtime/packages/setup/dist/src/types.d.ts +40 -0
- package/runtime/packages/setup/dist/src/types.js +1 -0
- package/runtime/packages/setup/dist/src/updates.d.ts +46 -0
- package/runtime/packages/setup/dist/src/updates.js +284 -0
- package/runtime/packages/setup/dist/src/verdict.d.ts +11 -0
- package/runtime/packages/setup/dist/src/verdict.js +159 -0
- package/runtime/packages/setup/package.json +1 -0
- package/runtime/plugins/claude-code/.claude-plugin/plugin.json +34 -0
- package/runtime/plugins/claude-code/.mcp.json +8 -0
- package/runtime/plugins/claude-code/scripts/configure-statusline.mjs +124 -0
- package/runtime/plugins/claude-code/scripts/statusline.mjs +210 -0
- package/runtime/plugins/claude-code/skills/inbox/SKILL.md +8 -0
- package/runtime/plugins/claude-code/skills/mark-read/SKILL.md +13 -0
- package/runtime/plugins/claude-code/skills/status/SKILL.md +13 -0
- package/runtime/scripts/ack-security.mjs +22 -0
- package/runtime/scripts/codex-app-server-wake.mjs +696 -0
- package/runtime/scripts/daemon-observation.mjs +48 -0
- package/runtime/scripts/doctor-protocol.mjs +96 -0
- package/runtime/scripts/lease.mjs +193 -0
- package/runtime/scripts/murmur-daemon.mjs +545 -0
- package/runtime/scripts/murmur-jetstream-advisory.mjs +11 -0
- package/runtime/scripts/murmur-shell-send.mjs +142 -0
- package/runtime/scripts/notify-router.mjs +309 -0
- package/runtime/scripts/runtime-capability.mjs +34 -0
- package/runtime/scripts/secure-state.mjs +105 -0
- package/runtime/scripts/wake-drain-claude.mjs +484 -0
- package/runtime/scripts/wake-monitor.mjs +653 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* murmur-daemon.mjs — Persistent agent-to-agent messaging daemon.
|
|
4
|
+
*/
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
8
|
+
import { NatsBroker } from "@murmurv2/broker-nats";
|
|
9
|
+
import {
|
|
10
|
+
ChannelRosterStore,
|
|
11
|
+
channelSubjectRoutes,
|
|
12
|
+
SQLiteDedupeOutboxStore,
|
|
13
|
+
SQLiteMessageStore,
|
|
14
|
+
stableAckPayload,
|
|
15
|
+
stableEnvelopePayload,
|
|
16
|
+
} from "@murmurv2/core";
|
|
17
|
+
import { decryptPayload, signEnvelope, verifyEnvelopeSignature } from "@murmurv2/security";
|
|
18
|
+
import { NotifyQueue, flushNotifyQueue, normalizeNotifyTargets, enqueuePeerNotification } from "./notify-router.mjs";
|
|
19
|
+
import { createChannelThreadStartBindingResolver, createCodexAppServerInjector } from "./codex-app-server-wake.mjs";
|
|
20
|
+
import { startJetStreamAdvisoryDlqIfEnabled } from "./murmur-jetstream-advisory.mjs";
|
|
21
|
+
import { WakeMonitor, createAuditShellHook, createShellHook, normalizeWakeConfig } from "./wake-monitor.mjs";
|
|
22
|
+
import { SessionLeaseStore, createNativeLeaseGate } from "./lease.mjs";
|
|
23
|
+
import { ensurePrivateDirectory, readPrivateJson, setPrivateUmask } from "./secure-state.mjs";
|
|
24
|
+
import { createDaemonObservation } from "./daemon-observation.mjs";
|
|
25
|
+
import { normalizeAckSecurity } from "./ack-security.mjs";
|
|
26
|
+
import { classifyVerifiedDoctorMessage, createDoctorResponder } from "./doctor-protocol.mjs";
|
|
27
|
+
// vault-guard: optional content policy hook (not included in OSS release)
|
|
28
|
+
|
|
29
|
+
setPrivateUmask();
|
|
30
|
+
|
|
31
|
+
let observeLog = () => {};
|
|
32
|
+
const log = (level, msg, data) => {
|
|
33
|
+
const entry = { ts: new Date().toISOString(), level, msg, ...data };
|
|
34
|
+
console.log(JSON.stringify(entry));
|
|
35
|
+
observeLog(level, msg, data);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const dataDir = process.env.DATA_DIR || ".data";
|
|
39
|
+
const configPath = path.join(dataDir, "agent-config.json");
|
|
40
|
+
|
|
41
|
+
let config;
|
|
42
|
+
try {
|
|
43
|
+
await ensurePrivateDirectory(dataDir);
|
|
44
|
+
config = await readPrivateJson(configPath);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
log("fatal", "Cannot load agent config", { path: configPath, error: err.message });
|
|
47
|
+
log("info", "Run: node scripts/agent-config-init.mjs");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { agentId, natsUrl, natsToken, subject, peers, keys } = config;
|
|
52
|
+
const dbPath = path.join(dataDir, "murmur.db");
|
|
53
|
+
const flushIntervalMs = Number(process.env.FLUSH_INTERVAL_MS) || 2000;
|
|
54
|
+
const jetstreamConfig = config.jetstream || {};
|
|
55
|
+
const jetstreamEnabled = jetstreamConfig.enabled ?? process.env.MURMUR_JETSTREAM === "1";
|
|
56
|
+
const jetstreamStream = jetstreamConfig.stream || process.env.MURMUR_JETSTREAM_STREAM || "MURMUR";
|
|
57
|
+
const jetstreamSubjects = jetstreamConfig.subjects || ["msg.>", "ack.>"];
|
|
58
|
+
const streamingConfig = config.streaming || {};
|
|
59
|
+
const ackWindowConfig = streamingConfig.ackWindow || {};
|
|
60
|
+
const firstDefined = (...values) => values.find((value) => value !== undefined && value !== null && value !== "");
|
|
61
|
+
const optionalPositiveInteger = (name, value) => {
|
|
62
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
63
|
+
const parsed = Number(value);
|
|
64
|
+
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name}-invalid`);
|
|
65
|
+
return parsed;
|
|
66
|
+
};
|
|
67
|
+
const jetstreamMaxDeliver = optionalPositiveInteger(
|
|
68
|
+
"jetstream-max-deliver",
|
|
69
|
+
firstDefined(jetstreamConfig.maxDeliver, process.env.MURMUR_JETSTREAM_MAX_DELIVER),
|
|
70
|
+
);
|
|
71
|
+
const jetstreamAckWaitMs = optionalPositiveInteger(
|
|
72
|
+
"jetstream-ack-wait-ms",
|
|
73
|
+
firstDefined(jetstreamConfig.ackWaitMs, process.env.MURMUR_JETSTREAM_ACK_WAIT_MS),
|
|
74
|
+
);
|
|
75
|
+
const ackTimeoutMs = optionalPositiveInteger(
|
|
76
|
+
"ack-timeout-ms",
|
|
77
|
+
firstDefined(streamingConfig.ackTimeoutMs, process.env.MURMUR_ACK_TIMEOUT_MS),
|
|
78
|
+
) ?? 15_000;
|
|
79
|
+
const ackSecurityConfig = config.ackSecurity || {};
|
|
80
|
+
const ackPolicy = normalizeAckSecurity(config, process.env, log);
|
|
81
|
+
const emitSignedAcks = ackPolicy.emitSigned;
|
|
82
|
+
const requireSignedAcks = ackPolicy.requireSigned;
|
|
83
|
+
const maxAckAgeMs = optionalPositiveInteger(
|
|
84
|
+
"ack-max-age-ms",
|
|
85
|
+
firstDefined(ackSecurityConfig.maxAgeMs, process.env.MURMUR_ACK_MAX_AGE_MS),
|
|
86
|
+
) ?? 5 * 60_000;
|
|
87
|
+
const ackWindowEnabled = ackWindowConfig.enabled ?? process.env.MURMUR_STREAM_ACK_WINDOW === "1";
|
|
88
|
+
const ackWindow = ackWindowEnabled
|
|
89
|
+
? {
|
|
90
|
+
maxInFlightChunks: optionalPositiveInteger(
|
|
91
|
+
"stream-max-in-flight-chunks",
|
|
92
|
+
firstDefined(ackWindowConfig.maxInFlightChunks, process.env.MURMUR_STREAM_MAX_IN_FLIGHT_CHUNKS),
|
|
93
|
+
) ?? 64,
|
|
94
|
+
maxInFlightBytes: optionalPositiveInteger(
|
|
95
|
+
"stream-max-in-flight-bytes",
|
|
96
|
+
firstDefined(ackWindowConfig.maxInFlightBytes, process.env.MURMUR_STREAM_MAX_IN_FLIGHT_BYTES),
|
|
97
|
+
) ?? 4 * 1024 * 1024,
|
|
98
|
+
}
|
|
99
|
+
: undefined;
|
|
100
|
+
const notifyTargets = normalizeNotifyTargets(config.notify);
|
|
101
|
+
const envTelegramFallback = (() => {
|
|
102
|
+
const botToken = process.env.MURMUR_TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN;
|
|
103
|
+
const chatId = process.env.MURMUR_TELEGRAM_CHAT_ID || process.env.TELEGRAM_CHAT_ID;
|
|
104
|
+
const topicId = process.env.MURMUR_TELEGRAM_TOPIC_ID || process.env.TELEGRAM_TOPIC_ID;
|
|
105
|
+
if (notifyTargets.length > 0 || !botToken || !chatId) return [];
|
|
106
|
+
return [{ type: "telegram", channel: "telegram", botToken, chatId, ...(topicId ? { topicId } : {}) }];
|
|
107
|
+
})();
|
|
108
|
+
const effectiveNotifyTargets = notifyTargets.length > 0 ? notifyTargets : envTelegramFallback;
|
|
109
|
+
const notifyQueue = new NotifyQueue(dbPath);
|
|
110
|
+
const wakeDb = new DatabaseSync(dbPath);
|
|
111
|
+
const wakeConfig = normalizeWakeConfig(config);
|
|
112
|
+
|
|
113
|
+
log("info", "Daemon starting", {
|
|
114
|
+
agentId,
|
|
115
|
+
subject,
|
|
116
|
+
natsUrl,
|
|
117
|
+
dbPath,
|
|
118
|
+
flushIntervalMs,
|
|
119
|
+
jetstreamEnabled,
|
|
120
|
+
jetstreamStream: jetstreamEnabled ? jetstreamStream : undefined,
|
|
121
|
+
jetstreamMaxDeliver,
|
|
122
|
+
jetstreamAckWaitMs,
|
|
123
|
+
ackTimeoutMs,
|
|
124
|
+
ackSecurity: {
|
|
125
|
+
...ackPolicy,
|
|
126
|
+
maxAgeMs: maxAckAgeMs,
|
|
127
|
+
},
|
|
128
|
+
ackWindow,
|
|
129
|
+
wake: { enabled: wakeConfig.enabled, mode: wakeConfig.mode, hookConfigured: Boolean(config.onReceive) },
|
|
130
|
+
notifyTargets: effectiveNotifyTargets.map((t) => `${t.type}:${t.channel}`),
|
|
131
|
+
notifyFallbackFromEnv: envTelegramFallback.length > 0,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!wakeConfig.enabled) log("warn", "Wake dispatch paused by configuration", {
|
|
135
|
+
reason: "wake-disabled", pendingPolicy: "preserve-until-enabled",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const store = new SQLiteDedupeOutboxStore(dbPath);
|
|
139
|
+
const msgStore = new SQLiteMessageStore(dbPath);
|
|
140
|
+
const doctorResponder = createDoctorResponder({ config, outbox: store, messages: msgStore });
|
|
141
|
+
|
|
142
|
+
// Scoped-channels (#82): native daemon wake becomes a lease-gated fallback. Default OFF
|
|
143
|
+
// (backward-compat: no lease -> WakeMonitor behaves exactly as before). Lease lives in its
|
|
144
|
+
// own SQLite file (separate WAL) per review.
|
|
145
|
+
const scopedChannelsEnabled = config.scopedChannels?.enabled ?? process.env.MURMUR_SCOPED_CHANNELS === "1";
|
|
146
|
+
const nativeLeaseTtlMs = Number(process.env.MURMUR_LEASE_TTL_MS) || 20000;
|
|
147
|
+
const leaseStore = scopedChannelsEnabled ? new SessionLeaseStore(path.join(dataDir, "lease.db")) : null;
|
|
148
|
+
const nativeLeaseGate = leaseStore
|
|
149
|
+
? createNativeLeaseGate({ store: leaseStore, agentId, ttlMs: nativeLeaseTtlMs, log })
|
|
150
|
+
: null;
|
|
151
|
+
if (scopedChannelsEnabled) log("info", "Scoped-channels native lease gate enabled", { ttlMs: nativeLeaseTtlMs });
|
|
152
|
+
|
|
153
|
+
const channelRosterConfig = config.channelRoster || {};
|
|
154
|
+
const channelRosterEnabled = channelRosterConfig.enabled ?? process.env.MURMUR_CHANNEL_ROSTER === "1";
|
|
155
|
+
const channelRosterPath = channelRosterConfig.path || process.env.MURMUR_CHANNEL_ROSTER_PATH || path.join(dataDir, "channel-roster.db");
|
|
156
|
+
const channelRosterStore = channelRosterEnabled ? new ChannelRosterStore(channelRosterPath) : null;
|
|
157
|
+
const subjectRoutes = channelSubjectRoutes(subject, agentId, config.subjectScoping);
|
|
158
|
+
if (subjectRoutes.length > 1) {
|
|
159
|
+
if (!jetstreamEnabled || !channelRosterStore) throw new Error("subject-scoping-requires-jetstream-and-roster");
|
|
160
|
+
for (const route of subjectRoutes.slice(1)) {
|
|
161
|
+
const channel = channelRosterStore.getChannel(route.channelId);
|
|
162
|
+
if (!channel || channel.closedAt || !channelRosterStore.findActiveMembersForAgent(agentId).some((m) => m.channelId === route.channelId)) {
|
|
163
|
+
throw new Error(`subject-scoping-inactive-local-channel:${route.channelId}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const threadStartBindingResolver = channelRosterStore
|
|
168
|
+
? createChannelThreadStartBindingResolver({ rosterStore: channelRosterStore, agentId, log })
|
|
169
|
+
: null;
|
|
170
|
+
const nativeConfigured = wakeConfig.mode === "codex_app_server" || Object.values(wakeConfig.peers).some((peer) => peer.mode === "codex_app_server");
|
|
171
|
+
const observation = createDaemonObservation({ dataDir, storePath: dbPath, agentId, log,
|
|
172
|
+
wake: { enabled: wakeConfig.enabled, mode: nativeConfigured ? "monitor" : config.onReceive ? "hook" : "none",
|
|
173
|
+
// A custom shell command's identity cannot be inferred from arbitrary text.
|
|
174
|
+
responder: nativeConfigured ? "codex" : config.onReceive ? null : "none" } });
|
|
175
|
+
observeLog = observation.observeLog;
|
|
176
|
+
// #108 — Codex threads are remembered per (peer, conversation) in the message store.
|
|
177
|
+
const codexAppServerInjector = createCodexAppServerInjector({ log, resolveThreadStartBinding: threadStartBindingResolver, threadStore: msgStore });
|
|
178
|
+
if (channelRosterEnabled) log("info", "Channel roster thread-start binding enabled", { channelRosterPath });
|
|
179
|
+
const broker = new NatsBroker({
|
|
180
|
+
url: natsUrl,
|
|
181
|
+
token: natsToken,
|
|
182
|
+
jetstream: jetstreamEnabled,
|
|
183
|
+
stream: jetstreamEnabled ? jetstreamStream : undefined,
|
|
184
|
+
streamSubjects: jetstreamSubjects,
|
|
185
|
+
jetstreamMaxDeliver,
|
|
186
|
+
jetstreamAckWaitMs,
|
|
187
|
+
onStatus: (event) => observation.onStatus(event),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const signAck = async (unsignedAck) => ({
|
|
191
|
+
...unsignedAck,
|
|
192
|
+
signature: await signEnvelope(stableAckPayload(unsignedAck), keys.signing.privateKey),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const verifyAck = async (ack) => {
|
|
196
|
+
const peer = peers[ack.senderAgentId];
|
|
197
|
+
if (!peer?.signing?.publicKey) return "key-unavailable";
|
|
198
|
+
return verifyEnvelopeSignature(stableAckPayload(ack), ack.signature, peer.signing.publicKey);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const durableSafe = (value) => value.replace(/[^A-Za-z0-9_-]/g, "-");
|
|
202
|
+
|
|
203
|
+
const inboundCursor = () => {
|
|
204
|
+
const row = wakeDb.prepare("SELECT COALESCE(MAX(rowid), 0) as cursor FROM local_messages WHERE direction = 'inbound'").get();
|
|
205
|
+
return Number(row?.cursor ?? 0);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const enqueueWakeNotification = async (payload, reason) => {
|
|
209
|
+
log("warn", "WakeMonitor fallback notify", { reason, msgId: payload.msgId, from: payload.from });
|
|
210
|
+
enqueuePeerNotification({ queue: notifyQueue, targets: effectiveNotifyTargets, payload, log, reason });
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// #105 — the message store is the wake queue. Backlog, retries and the cursor all come
|
|
214
|
+
// from the rows' durable wake state; nothing about a delivery lives only in this process.
|
|
215
|
+
const wakeMonitor = new WakeMonitor({
|
|
216
|
+
...wakeConfig,
|
|
217
|
+
deliveries: msgStore,
|
|
218
|
+
leaseGate: nativeLeaseGate,
|
|
219
|
+
auditHook: createAuditShellHook({ command: wakeConfig.auditHook, log }),
|
|
220
|
+
hook: createShellHook({ command: config.onReceive, timeoutMs: wakeConfig.hookTimeoutMs, log }),
|
|
221
|
+
injector: async (payload, peer) => {
|
|
222
|
+
if (peer.mode === "codex_app_server") {
|
|
223
|
+
return codexAppServerInjector(payload, peer);
|
|
224
|
+
}
|
|
225
|
+
throw new Error(`wake-native-mode-unsupported:${peer.mode}`);
|
|
226
|
+
},
|
|
227
|
+
notify: enqueueWakeNotification,
|
|
228
|
+
log,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const proxyWakeMonitor = new WakeMonitor({
|
|
232
|
+
...wakeConfig,
|
|
233
|
+
initialCursor: inboundCursor(),
|
|
234
|
+
auditHook: createAuditShellHook({ command: wakeConfig.auditHook, log }),
|
|
235
|
+
hook: createShellHook({ command: config.proxyOnReceive, timeoutMs: wakeConfig.hookTimeoutMs, log }),
|
|
236
|
+
leaseGate: nativeLeaseGate,
|
|
237
|
+
injector: async (payload, peer) => {
|
|
238
|
+
if (peer.mode === "codex_app_server") {
|
|
239
|
+
return codexAppServerInjector(payload, peer);
|
|
240
|
+
}
|
|
241
|
+
throw new Error(`wake-native-mode-unsupported:${peer.mode}`);
|
|
242
|
+
},
|
|
243
|
+
notify: enqueueWakeNotification,
|
|
244
|
+
log,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
const onMessage = async (envelope) => {
|
|
249
|
+
const senderId = envelope.senderAgentId;
|
|
250
|
+
const peer = peers[senderId];
|
|
251
|
+
|
|
252
|
+
if (!peer) {
|
|
253
|
+
// Отказ обязан быть виден ПРИНИМАЮЩЕЙ стороне. Бросок уходит в
|
|
254
|
+
// broker.subscribeWithAck, тот шлёт отправителю nack с причиной — и на этом всё:
|
|
255
|
+
// у владельца машины отвергнутого сообщения нет нигде, ни в логе, ни в базе.
|
|
256
|
+
// Найдено agent-misha 2026-09-08: три агента час выясняли, доходит ли сообщение,
|
|
257
|
+
// потому что принимающая сторона своих отказов не видела.
|
|
258
|
+
log("warn", "Message rejected", {
|
|
259
|
+
reason: "unknown-sender",
|
|
260
|
+
senderId,
|
|
261
|
+
msgId: envelope.msgId,
|
|
262
|
+
conversationId: envelope.conversationId,
|
|
263
|
+
});
|
|
264
|
+
throw new Error(`unknown-sender:${senderId}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const sigPayload = stableEnvelopePayload(envelope);
|
|
268
|
+
const valid = await verifyEnvelopeSignature(sigPayload, envelope.signature, peer.signing.publicKey);
|
|
269
|
+
if (!valid) {
|
|
270
|
+
// Важнее предыдущего: неизвестный отправитель — это чаще всего незаконченная
|
|
271
|
+
// настройка, а невалидная подпись при ИЗВЕСТНОМ пире означает либо рассинхрон
|
|
272
|
+
// ключей, либо попытку писать от чужого имени. Молча такое проходить не должно.
|
|
273
|
+
log("warn", "Message rejected", {
|
|
274
|
+
reason: "signature-invalid",
|
|
275
|
+
senderId,
|
|
276
|
+
msgId: envelope.msgId,
|
|
277
|
+
conversationId: envelope.conversationId,
|
|
278
|
+
});
|
|
279
|
+
throw new Error(`signature-invalid:${senderId}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const plaintext = await decryptPayload(
|
|
283
|
+
{
|
|
284
|
+
ciphertext: envelope.payloadCiphertext,
|
|
285
|
+
nonce: envelope.payloadNonce,
|
|
286
|
+
senderPublicKey: peer.encryption.publicKey,
|
|
287
|
+
},
|
|
288
|
+
keys.encryption.privateKey,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const addressing = channelRosterStore?.evaluateAddressing({
|
|
292
|
+
channelId: envelope.channelId,
|
|
293
|
+
selfAgentId: agentId,
|
|
294
|
+
senderAgentId: senderId,
|
|
295
|
+
senderMemberId: envelope.senderMemberId,
|
|
296
|
+
addresseeMemberId: envelope.addresseeMemberId,
|
|
297
|
+
});
|
|
298
|
+
if (addressing?.reject) throw new Error(`channel-addressing-rejected:${addressing.reason}`);
|
|
299
|
+
const diagnostic = classifyVerifiedDoctorMessage(config, envelope, plaintext);
|
|
300
|
+
const wakeEligible = !diagnostic && addressing?.allowWake !== false;
|
|
301
|
+
|
|
302
|
+
// Durable commit first (#105): the row, its delivery id and its wake state land in
|
|
303
|
+
// one transaction. Returning from here is what lets the broker mark the envelope seen
|
|
304
|
+
// and ACK it — so the ACK now means "stored", not "the wake finished".
|
|
305
|
+
const stored = await msgStore.append({
|
|
306
|
+
conversationId: envelope.conversationId,
|
|
307
|
+
msgId: envelope.msgId,
|
|
308
|
+
direction: "inbound",
|
|
309
|
+
sender: senderId,
|
|
310
|
+
text: plaintext,
|
|
311
|
+
createdAt: envelope.createdAt,
|
|
312
|
+
transport: "nats",
|
|
313
|
+
channelId: envelope.channelId,
|
|
314
|
+
senderMemberId: envelope.senderMemberId,
|
|
315
|
+
addresseeMemberId: envelope.addresseeMemberId,
|
|
316
|
+
wakeEligible,
|
|
317
|
+
});
|
|
318
|
+
if (stored.duplicate) {
|
|
319
|
+
// Crash window "receiver committed, ACK lost": the sender (or JetStream) delivered
|
|
320
|
+
// the same envelope again. The delivery already succeeded — ACK it, wake nothing.
|
|
321
|
+
log("info", "Message duplicate ignored", {
|
|
322
|
+
msgId: envelope.msgId,
|
|
323
|
+
from: senderId,
|
|
324
|
+
conversationId: envelope.conversationId,
|
|
325
|
+
rowid: stored.rowid,
|
|
326
|
+
});
|
|
327
|
+
if (diagnostic?.kind === "request" && addressing?.allowWake !== false) {
|
|
328
|
+
await doctorResponder.respond(envelope, plaintext);
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
log("info", "Message received", {
|
|
334
|
+
msgId: envelope.msgId,
|
|
335
|
+
from: senderId,
|
|
336
|
+
conversationId: envelope.conversationId,
|
|
337
|
+
channelId: envelope.channelId,
|
|
338
|
+
senderMemberId: envelope.senderMemberId,
|
|
339
|
+
addresseeMemberId: envelope.addresseeMemberId,
|
|
340
|
+
wakeEligible,
|
|
341
|
+
textLen: plaintext.length,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (diagnostic) {
|
|
345
|
+
const result = diagnostic.kind === "request" && addressing?.allowWake !== false
|
|
346
|
+
? await doctorResponder.respond(envelope, plaintext)
|
|
347
|
+
: { state: "ignored" };
|
|
348
|
+
log("info", "Doctor protocol handled", { msgId: envelope.msgId, from: senderId,
|
|
349
|
+
kind: diagnostic.kind, response: result.state, replyMsgId: result.msgId });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const payload = {
|
|
354
|
+
from: senderId,
|
|
355
|
+
text: plaintext,
|
|
356
|
+
msgId: envelope.msgId,
|
|
357
|
+
conversationId: envelope.conversationId,
|
|
358
|
+
channelId: envelope.channelId,
|
|
359
|
+
senderMemberId: envelope.senderMemberId,
|
|
360
|
+
addresseeMemberId: envelope.addresseeMemberId,
|
|
361
|
+
wakeEligible,
|
|
362
|
+
ts: new Date().toISOString(),
|
|
363
|
+
cursor: stored.rowid,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
if (effectiveNotifyTargets.length > 0 && wakeEligible) {
|
|
367
|
+
enqueuePeerNotification({ queue: notifyQueue, targets: effectiveNotifyTargets, payload, log });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// The wake runs off the durable queue, not on the broker's clock. Awaiting it here
|
|
371
|
+
// used to hold the sender's ACK (and JetStream's ack_wait) for the whole Codex turn —
|
|
372
|
+
// minutes — so the sender's ACK timeout resent the envelope while the first copy was
|
|
373
|
+
// still being processed. Failures are retried from the row; nothing is lost by not
|
|
374
|
+
// waiting.
|
|
375
|
+
if (wakeEligible) {
|
|
376
|
+
wakeMonitor.onInbound(payload).catch((err) => {
|
|
377
|
+
log("error", "WakeMonitor onInbound failed", { error: err instanceof Error ? err.message : String(err), msgId: envelope.msgId });
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
let running = true;
|
|
383
|
+
|
|
384
|
+
const flushLoop = async () => {
|
|
385
|
+
while (running) {
|
|
386
|
+
try {
|
|
387
|
+
await broker.flushOutbox({ outbox: store, maxAttempts: 5, ackTimeoutMs, ackWindow });
|
|
388
|
+
} catch (err) {
|
|
389
|
+
log("error", "Outbox flush error", { error: err.message });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
await flushNotifyQueue({ queue: notifyQueue, log, limit: 100 });
|
|
394
|
+
} catch (err) {
|
|
395
|
+
log("error", "Notify flush error", { error: err.message });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// #105 — retry tick: deliveries whose backoff has elapsed, and anything a previous
|
|
399
|
+
// process left behind, are picked up from the table here.
|
|
400
|
+
try {
|
|
401
|
+
await wakeMonitor.drain();
|
|
402
|
+
} catch (err) {
|
|
403
|
+
log("error", "Wake retry drain error", { error: err.message });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
await sleep(flushIntervalMs);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const shutdown = async (signal) => {
|
|
411
|
+
log("info", "Shutdown signal received, draining NATS", { signal });
|
|
412
|
+
running = false;
|
|
413
|
+
observation.stop();
|
|
414
|
+
try {
|
|
415
|
+
await broker.close();
|
|
416
|
+
} catch (err) {
|
|
417
|
+
log("error", "Broker close error", { error: err.message });
|
|
418
|
+
}
|
|
419
|
+
log("info", "Daemon stopped", { agentId });
|
|
420
|
+
process.exit(0);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
424
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
await observation.start();
|
|
428
|
+
await broker.connect();
|
|
429
|
+
await observation.connected();
|
|
430
|
+
log("info", "NATS connected", { url: natsUrl });
|
|
431
|
+
|
|
432
|
+
for (const route of subjectRoutes) {
|
|
433
|
+
await broker.subscribeWithAck({
|
|
434
|
+
...route,
|
|
435
|
+
consumerId: agentId,
|
|
436
|
+
dedupe: store,
|
|
437
|
+
onMessage,
|
|
438
|
+
...(emitSignedAcks ? { signAck } : {}),
|
|
439
|
+
});
|
|
440
|
+
log("info", "Subscribed", route);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Proxy wake bridges cannot confirm delivery on behalf of another agent.
|
|
444
|
+
const proxySubjects = (config.proxySubjects || []);
|
|
445
|
+
for (const ps of proxySubjects) {
|
|
446
|
+
const proxyAgentId = ps.replace(/^msg\./, "");
|
|
447
|
+
const proxyOnMessage = async (envelope, plaintext) => {
|
|
448
|
+
const senderId = envelope.senderAgentId || "unknown";
|
|
449
|
+
const addressing = channelRosterStore?.evaluateAddressing({
|
|
450
|
+
channelId: envelope.channelId,
|
|
451
|
+
selfAgentId: proxyAgentId,
|
|
452
|
+
senderAgentId: senderId,
|
|
453
|
+
senderMemberId: envelope.senderMemberId,
|
|
454
|
+
addresseeMemberId: envelope.addresseeMemberId,
|
|
455
|
+
});
|
|
456
|
+
if (addressing?.reject) throw new Error(`channel-addressing-rejected:${addressing.reason}`);
|
|
457
|
+
const wakeEligible = addressing?.allowWake !== false;
|
|
458
|
+
log("info", "Proxy message received", {
|
|
459
|
+
subject: ps,
|
|
460
|
+
from: senderId,
|
|
461
|
+
channelId: envelope.channelId,
|
|
462
|
+
senderMemberId: envelope.senderMemberId,
|
|
463
|
+
addresseeMemberId: envelope.addresseeMemberId,
|
|
464
|
+
wakeEligible,
|
|
465
|
+
len: plaintext?.length,
|
|
466
|
+
});
|
|
467
|
+
await proxyWakeMonitor.onInbound({
|
|
468
|
+
from: senderId,
|
|
469
|
+
text: plaintext ?? "",
|
|
470
|
+
msgId: envelope.msgId,
|
|
471
|
+
conversationId: envelope.conversationId,
|
|
472
|
+
channelId: envelope.channelId,
|
|
473
|
+
senderMemberId: envelope.senderMemberId,
|
|
474
|
+
addresseeMemberId: envelope.addresseeMemberId,
|
|
475
|
+
wakeEligible,
|
|
476
|
+
ts: new Date().toISOString(),
|
|
477
|
+
env: { MURMUR_PROXY_AGENT: proxyAgentId },
|
|
478
|
+
});
|
|
479
|
+
};
|
|
480
|
+
await broker.subscribeWithAck({
|
|
481
|
+
subject: ps,
|
|
482
|
+
consumerId: `${agentId}-proxy-${durableSafe(ps)}`,
|
|
483
|
+
dedupe: store,
|
|
484
|
+
onMessage: proxyOnMessage,
|
|
485
|
+
emitDeliveryAcks: false,
|
|
486
|
+
});
|
|
487
|
+
log("warn", "Subscribed (proxy wake only); without an addressed agent daemon, delivery is unconfirmed and sender retries end in DLQ", { subject: ps });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// `store` is a SQLiteDedupeOutboxStore, which also implements AckReceiptStore: ACK nonces
|
|
491
|
+
// are claimed in the same database as the outbox, so replay protection survives a daemon
|
|
492
|
+
// restart. Without this the broker falls back to an in-memory set that forgets everything
|
|
493
|
+
// on exit.
|
|
494
|
+
const ackReceipts = typeof store.claimAckNonce === "function" ? store : undefined;
|
|
495
|
+
await broker.startAckCorrelation({
|
|
496
|
+
outbox: store,
|
|
497
|
+
ackReceipts,
|
|
498
|
+
ackSubject: `ack.${agentId}`,
|
|
499
|
+
consumerId: `${agentId}-ack`,
|
|
500
|
+
verifyAck,
|
|
501
|
+
requireSignedAcks,
|
|
502
|
+
maxAckAgeMs,
|
|
503
|
+
onInvalidAck: (event) => log("warn", "Invalid ACK rejected", event),
|
|
504
|
+
});
|
|
505
|
+
log("info", "ACK correlation started", {
|
|
506
|
+
ackSubject: `ack.${agentId}`,
|
|
507
|
+
emitSignedAcks,
|
|
508
|
+
requireSignedAcks,
|
|
509
|
+
durableAckReplayProtection: Boolean(ackReceipts),
|
|
510
|
+
});
|
|
511
|
+
if (!ackReceipts) {
|
|
512
|
+
log("warn", "ACK replay protection is in-memory only — nonces are forgotten on restart", {
|
|
513
|
+
hint: "use the SQLite store (storePath) so ack_receipts is persisted",
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
await startJetStreamAdvisoryDlqIfEnabled({
|
|
517
|
+
broker,
|
|
518
|
+
outbox: store,
|
|
519
|
+
jetstreamEnabled,
|
|
520
|
+
log,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const pendingNotify = notifyQueue.pendingCount();
|
|
524
|
+
if (pendingNotify > 0) {
|
|
525
|
+
log("info", "Resuming pending notifications", { pendingNotify });
|
|
526
|
+
await flushNotifyQueue({ queue: notifyQueue, log, limit: 250 });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
flushLoop();
|
|
530
|
+
// Wake readiness belongs in the readiness line. A daemon with no responder accepts,
|
|
531
|
+
// decrypts, stores and ACKs every message exactly like a healthy one — the difference
|
|
532
|
+
// is only visible once somebody waits for a reply that nobody was ever going to write.
|
|
533
|
+
// `wake` is additive: `agentId` and `peers` keep their shape for existing log readers.
|
|
534
|
+
const wakeStatus = wakeMonitor.responderStatus();
|
|
535
|
+
if (!wakeStatus.configured) {
|
|
536
|
+
log("warn", "No wake responder configured - inbound messages will be stored and nothing else", {
|
|
537
|
+
hint: "set onReceive (shell hook), or wake.peers[<agentId>].mode=codex_app_server (native wake), in agent-config.json",
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
log("info", "Daemon ready", { agentId, peers: Object.keys(peers), wake: wakeStatus });
|
|
541
|
+
} catch (err) {
|
|
542
|
+
log("fatal", "Daemon startup failed", { error: err.message });
|
|
543
|
+
await broker.close().catch(() => {});
|
|
544
|
+
process.exit(1);
|
|
545
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export async function startJetStreamAdvisoryDlqIfEnabled({
|
|
2
|
+
broker,
|
|
3
|
+
outbox,
|
|
4
|
+
jetstreamEnabled,
|
|
5
|
+
log = () => {},
|
|
6
|
+
}) {
|
|
7
|
+
if (!jetstreamEnabled) return undefined;
|
|
8
|
+
const subscription = await broker.startJetStreamAdvisoryDlq({ outbox });
|
|
9
|
+
log("info", "JetStream advisory DLQ correlation started");
|
|
10
|
+
return subscription;
|
|
11
|
+
}
|