@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,696 @@
1
+ import WebSocket from "ws";
2
+ import { buildChannelThreadStartBinding } from "@murmurv2/core";
3
+ import { execFile } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { closeSync, mkdtempSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { tmpdir } from "node:os";
8
+ import path from "node:path";
9
+
10
+ const DEFAULT_TIMEOUT_MS = 10000;
11
+ const INITIALIZE_TIMEOUT_MS = 10000;
12
+ const DEFAULT_TURN_COMPLETION_TIMEOUT_MS = 180000;
13
+ const SESSION_LOG_POLL_INTERVAL_MS = 1000;
14
+ const SESSION_LOG_TAIL_BYTES = 8 * 1024 * 1024;
15
+
16
+ const shellQuote = (value) => `'${String(value ?? "").replace(/'/g, "'\\''")}'`;
17
+
18
+ const buildReplyHint = (payload, peer = {}) => {
19
+ if (peer?.relayFinalToMurmur === true) {
20
+ return [
21
+ "",
22
+ "[MURMUR REPLY RELAY]",
23
+ "If this Murmur message asks for a reply, put only the reply body in your final answer.",
24
+ "Do not call tools to send Murmur yourself; the local daemon will relay your final answer back through Murmur.",
25
+ ];
26
+ }
27
+
28
+ const murmurRoot = peer?.murmurRoot;
29
+ const dataDir = peer?.dataDir;
30
+ const storePath = peer?.storePath;
31
+ if (!murmurRoot || !dataDir || !storePath || !payload?.from) return [];
32
+
33
+ const conv = payload.conversationId || "";
34
+ const command = [
35
+ `cd ${shellQuote(murmurRoot)}`,
36
+ `DATA_DIR=${shellQuote(dataDir)}`,
37
+ `MURMUR_STORE_PATH=${shellQuote(storePath)}`,
38
+ "node scripts/murmur-shell-send.mjs",
39
+ `--to ${shellQuote(payload.from)}`,
40
+ `--conv ${shellQuote(conv)}`,
41
+ "--text '<your one-line reply>'",
42
+ ].join(" ");
43
+
44
+ return [
45
+ "",
46
+ "[LOCAL REPLY PATH]",
47
+ "If this Murmur message asks for a reply, use the local command below.",
48
+ "Do not SSH back into this Mac and do not use the default .data directory.",
49
+ command,
50
+ ];
51
+ };
52
+
53
+ export const buildCodexTurnText = (payload, peer = {}) => {
54
+ const lines = [
55
+ "[MURMUR WAKE]",
56
+ `from=${payload.from || "unknown"}`,
57
+ `conversationId=${payload.conversationId || ""}`,
58
+ `msgId=${payload.msgId || ""}`,
59
+ ...buildReplyHint(payload, peer),
60
+ "",
61
+ payload.text || "",
62
+ ];
63
+ return lines.join("\n");
64
+ };
65
+
66
+ export const buildTurnStartRequest = ({ id = 1, threadId, text, metadata = {} }) => ({
67
+ id,
68
+ method: "turn/start",
69
+ params: {
70
+ threadId,
71
+ input: [{ type: "text", text, text_elements: [] }],
72
+ responsesapiClientMetadata: metadata,
73
+ },
74
+ });
75
+
76
+ const readSessionLogTail = (sessionPath) => {
77
+ const { size } = statSync(sessionPath);
78
+ const length = Math.min(size, SESSION_LOG_TAIL_BYTES);
79
+ if (length <= 0) return "";
80
+ const fd = openSync(sessionPath, "r");
81
+ try {
82
+ const buffer = Buffer.alloc(length);
83
+ readSync(fd, buffer, 0, length, size - length);
84
+ return buffer.toString("utf8");
85
+ } finally {
86
+ closeSync(fd);
87
+ }
88
+ };
89
+
90
+ export const readFinalAnswerFromSessionLog = (sessionPath, turnId) => {
91
+ if (!sessionPath || !turnId) return "";
92
+ let data = "";
93
+ try {
94
+ data = readSessionLogTail(sessionPath);
95
+ } catch {
96
+ return "";
97
+ }
98
+
99
+ const lines = data.split("\n").filter(Boolean);
100
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
101
+ const line = lines[index];
102
+ if (!line.includes(turnId)) continue;
103
+
104
+ let entry;
105
+ try {
106
+ entry = JSON.parse(line);
107
+ } catch {
108
+ continue;
109
+ }
110
+
111
+ const payload = entry?.payload || {};
112
+ if (entry.type === "event_msg" && payload.type === "task_complete" && payload.turn_id === turnId) {
113
+ return typeof payload.last_agent_message === "string" ? payload.last_agent_message : "";
114
+ }
115
+
116
+ const metadataTurnId = payload?.internal_chat_message_metadata_passthrough?.turn_id;
117
+ if (
118
+ payload.type === "message"
119
+ && payload.role === "assistant"
120
+ && payload.phase === "final_answer"
121
+ && metadataTurnId === turnId
122
+ && Array.isArray(payload.content)
123
+ ) {
124
+ return payload.content.map((part) => part?.text || "").join("\n").trim();
125
+ }
126
+ }
127
+ return "";
128
+ };
129
+
130
+ export const buildThreadStartParams = (binding = null, peer = null) => ({
131
+ model: binding?.model ?? peer?.model ?? null,
132
+ modelProvider: null,
133
+ // A seeded thread must inherit the peer's working directory, otherwise Codex starts
134
+ // in `/` with no project instructions, wrong workspace roots and wrong permissions.
135
+ cwd: peer?.cwd ?? null,
136
+ runtimeWorkspaceRoots: null,
137
+ approvalPolicy: null,
138
+ approvalsReviewer: null,
139
+ sandbox: null,
140
+ permissions: null,
141
+ config: null,
142
+ serviceName: null,
143
+ baseInstructions: binding?.baseInstructions ?? null,
144
+ developerInstructions: null,
145
+ personality: binding?.personality ?? null,
146
+ ephemeral: false,
147
+ sessionStartSource: null,
148
+ threadSource: null,
149
+ environments: null,
150
+ dynamicTools: null,
151
+ selectedCapabilityRoots: null,
152
+ mockExperimentalField: null,
153
+ });
154
+
155
+ const buildInitializeRequest = (id) => ({
156
+ id,
157
+ method: "initialize",
158
+ params: {
159
+ clientInfo: {
160
+ name: "murmur-codex-app-server-wake",
161
+ title: "Murmur Codex App-Server Wake",
162
+ version: "0.1.0",
163
+ },
164
+ capabilities: {
165
+ experimentalApi: true,
166
+ requestAttestation: false,
167
+ optOutNotificationMethods: [
168
+ "command/exec/outputDelta",
169
+ "item/agentMessage/delta",
170
+ "item/plan/delta",
171
+ "item/fileChange/outputDelta",
172
+ "item/reasoning/summaryTextDelta",
173
+ "item/reasoning/textDelta",
174
+ ],
175
+ },
176
+ },
177
+ });
178
+
179
+ export class CodexAppServerClient {
180
+ constructor({ socketPath, timeoutMs = DEFAULT_TIMEOUT_MS, WebSocketImpl = WebSocket } = {}) {
181
+ if (!socketPath) throw new Error("codex-app-server-socket-missing");
182
+ this.socketPath = socketPath;
183
+ this.timeoutMs = timeoutMs;
184
+ this.WebSocketImpl = WebSocketImpl;
185
+ this.nextId = 1;
186
+ }
187
+
188
+ request(method, params) {
189
+ const id = this.nextId++;
190
+ const request = { id, method, params };
191
+ return this.send(request, id);
192
+ }
193
+
194
+ send(request, expectedId = request.id) {
195
+ return new Promise((resolve, reject) => {
196
+ let settled = false;
197
+ let initialized = false;
198
+ const initId = `init-${this.nextId++}`;
199
+ const url = `ws+unix://${this.socketPath}:/`;
200
+ const socket = new this.WebSocketImpl(url, {
201
+ perMessageDeflate: false,
202
+ handshakeTimeout: Math.min(this.timeoutMs, INITIALIZE_TIMEOUT_MS),
203
+ });
204
+ const timer = setTimeout(() => {
205
+ finish(new Error(`codex-app-server-timeout:${this.socketPath}`));
206
+ }, this.timeoutMs);
207
+
208
+ const finish = (err, result) => {
209
+ if (settled) return;
210
+ settled = true;
211
+ clearTimeout(timer);
212
+ try {
213
+ socket.close();
214
+ } catch {
215
+ // Ignore close races; the request already has a terminal result.
216
+ }
217
+ if (err) reject(err);
218
+ else resolve(result);
219
+ };
220
+
221
+ const sendJson = (message) => socket.send(JSON.stringify(message));
222
+
223
+ socket.on("open", () => sendJson(buildInitializeRequest(initId)));
224
+ socket.on("message", (data) => {
225
+ let message;
226
+ try {
227
+ message = JSON.parse(data.toString("utf8"));
228
+ } catch {
229
+ return;
230
+ }
231
+
232
+ if (message.id === initId) {
233
+ if (message.error) {
234
+ finish(new Error(`codex-app-server-initialize-error:${message.error.message || JSON.stringify(message.error)}`));
235
+ return;
236
+ }
237
+ initialized = true;
238
+ sendJson({ method: "initialized" });
239
+ sendJson(request);
240
+ return;
241
+ }
242
+
243
+ if (message.id !== expectedId) return;
244
+ if (message.error) {
245
+ finish(new Error(`codex-app-server-error:${message.error.message || JSON.stringify(message.error)}`));
246
+ } else {
247
+ finish(null, message.result);
248
+ }
249
+ });
250
+ socket.on("error", (err) => {
251
+ finish(new Error(`codex-app-server-connect-failed:${this.socketPath}:${err.message}`));
252
+ });
253
+ socket.on("close", () => {
254
+ if (!settled) finish(new Error(`codex-app-server-closed-before-response:${this.socketPath}:${initialized ? "after-initialize" : "before-initialize"}`));
255
+ });
256
+ });
257
+ }
258
+
259
+ startTurnAndWaitForFinal(params, {
260
+ completionTimeoutMs = DEFAULT_TURN_COMPLETION_TIMEOUT_MS,
261
+ sessionPath = null,
262
+ } = {}) {
263
+ return new Promise((resolve, reject) => {
264
+ let settled = false;
265
+ let initialized = false;
266
+ let turnId = null;
267
+ let startResult = null;
268
+ let finalText = "";
269
+ const requestId = this.nextId++;
270
+ const initId = `init-${this.nextId++}`;
271
+ const url = `ws+unix://${this.socketPath}:/`;
272
+ const socket = new this.WebSocketImpl(url, {
273
+ perMessageDeflate: false,
274
+ handshakeTimeout: Math.min(this.timeoutMs, INITIALIZE_TIMEOUT_MS),
275
+ });
276
+ const timer = setTimeout(() => {
277
+ finish(new Error(`codex-app-server-turn-completion-timeout:${this.socketPath}:${turnId || "unknown"}`));
278
+ }, completionTimeoutMs);
279
+ const sessionLogTimer = setInterval(() => {
280
+ if (settled || !sessionPath || !turnId) return;
281
+ const text = readFinalAnswerFromSessionLog(sessionPath, turnId);
282
+ if (!text) return;
283
+ finalText = finalText || text;
284
+ finish(null, { ...startResult, finalText, turnId, source: "session-log" });
285
+ }, SESSION_LOG_POLL_INTERVAL_MS);
286
+
287
+ const finish = (err, result) => {
288
+ if (settled) return;
289
+ settled = true;
290
+ clearTimeout(timer);
291
+ clearInterval(sessionLogTimer);
292
+ try {
293
+ socket.close();
294
+ } catch {
295
+ // Ignore close races; the request already has a terminal result.
296
+ }
297
+ if (err) reject(err);
298
+ else resolve(result);
299
+ };
300
+
301
+ const sendJson = (message) => socket.send(JSON.stringify(message));
302
+ const declineServerRequest = (message) => {
303
+ if (message.id === undefined || typeof message.method !== "string") return false;
304
+ if (message.method === "item/commandExecution/requestApproval" || message.method === "item/fileChange/requestApproval") {
305
+ sendJson({ jsonrpc: "2.0", id: message.id, result: { decision: "decline" } });
306
+ return true;
307
+ }
308
+ if (message.method === "item/permissions/requestApproval") {
309
+ sendJson({ jsonrpc: "2.0", id: message.id, result: { permissions: { id: ":workspace", extends: null }, scope: "turn" } });
310
+ return true;
311
+ }
312
+ return false;
313
+ };
314
+
315
+ socket.on("open", () => {
316
+ sendJson({
317
+ ...buildInitializeRequest(initId),
318
+ params: {
319
+ ...buildInitializeRequest(initId).params,
320
+ capabilities: {
321
+ experimentalApi: true,
322
+ requestAttestation: false,
323
+ optOutNotificationMethods: [
324
+ "command/exec/outputDelta",
325
+ "item/plan/delta",
326
+ "item/fileChange/outputDelta",
327
+ "item/reasoning/summaryTextDelta",
328
+ "item/reasoning/textDelta",
329
+ ],
330
+ },
331
+ },
332
+ });
333
+ });
334
+
335
+ socket.on("message", (data) => {
336
+ let message;
337
+ try {
338
+ message = JSON.parse(data.toString("utf8"));
339
+ } catch {
340
+ return;
341
+ }
342
+
343
+ if (message.id === initId) {
344
+ if (message.error) {
345
+ finish(new Error(`codex-app-server-initialize-error:${message.error.message || JSON.stringify(message.error)}`));
346
+ return;
347
+ }
348
+ initialized = true;
349
+ sendJson({ method: "initialized" });
350
+ sendJson({ id: requestId, method: "turn/start", params });
351
+ return;
352
+ }
353
+
354
+ if (declineServerRequest(message)) return;
355
+
356
+ if (message.id === requestId) {
357
+ if (message.error) {
358
+ finish(new Error(`codex-app-server-error:${message.error.message || JSON.stringify(message.error)}`));
359
+ return;
360
+ }
361
+ startResult = message.result;
362
+ turnId = message.result?.turn?.id || turnId;
363
+ return;
364
+ }
365
+
366
+ if (message.method === "turn/started" && message.params?.turn?.id) {
367
+ turnId = turnId || message.params.turn.id;
368
+ return;
369
+ }
370
+
371
+ if (message.method === "item/completed" && message.params?.turnId) {
372
+ turnId = turnId || message.params.turnId;
373
+ if (message.params.turnId !== turnId) return;
374
+ const item = message.params.item;
375
+ if (item?.type === "agentMessage" && item.phase === "final_answer" && typeof item.text === "string") {
376
+ finalText = item.text;
377
+ finish(null, { ...startResult, finalText, turnId, source: "app-server-final-item" });
378
+ }
379
+ return;
380
+ }
381
+
382
+ if (message.method === "turn/completed" && message.params?.turn?.id) {
383
+ turnId = turnId || message.params.turn.id;
384
+ if (message.params.turn.id !== turnId) return;
385
+ // #106 — `turn.status` (completed | interrupted | failed | inProgress) and
386
+ // `turn.error` are part of the notification; a caller that ignores them cannot
387
+ // tell a finished turn from a broken one.
388
+ const turn = message.params.turn;
389
+ finish(null, {
390
+ ...startResult,
391
+ finalText,
392
+ turnId,
393
+ source: "app-server-events",
394
+ status: typeof turn.status === "string" ? turn.status : null,
395
+ error: turn.error ?? null,
396
+ });
397
+ }
398
+ });
399
+ socket.on("error", (err) => {
400
+ finish(new Error(`codex-app-server-connect-failed:${this.socketPath}:${err.message}`));
401
+ });
402
+ socket.on("close", () => {
403
+ if (!settled) finish(new Error(`codex-app-server-closed-before-response:${this.socketPath}:${initialized ? "after-initialize" : "before-initialize"}`));
404
+ });
405
+ });
406
+ }
407
+ }
408
+
409
+ /**
410
+ * The reply to one inbound delivery always carries the same msgId (#105): derived from
411
+ * the inbound msgId, never minted fresh. A retry after a crash therefore lands on the
412
+ * outbox row the previous attempt already wrote instead of producing a second reply.
413
+ * Shape is a v5-style UUID so every consumer that validates msgIds keeps accepting it.
414
+ */
415
+ export const deriveRelayReplyMsgId = (inboundMsgId) => {
416
+ const hex = createHash("sha256").update(`murmur-relay-reply:${String(inboundMsgId ?? "")}`).digest("hex");
417
+ const variant = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16);
418
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
419
+ };
420
+
421
+ /**
422
+ * Was this reply already queued by a previous attempt? Read straight from the peer's
423
+ * outbox — the store the relay writes to — so the answer survives daemon restarts and
424
+ * does not depend on what the current process remembers.
425
+ */
426
+ const relayReplyAlreadyQueued = async (peer = {}, replyMsgId) => {
427
+ if (!peer?.storePath || !replyMsgId) return false;
428
+ try {
429
+ statSync(peer.storePath);
430
+ } catch {
431
+ return false;
432
+ }
433
+ const db = new DatabaseSync(peer.storePath, { readOnly: true });
434
+ try {
435
+ const row = db.prepare("SELECT 1 FROM outbox WHERE msg_id = ? LIMIT 1").get(replyMsgId);
436
+ return Boolean(row);
437
+ } catch {
438
+ return false;
439
+ } finally {
440
+ db.close();
441
+ }
442
+ };
443
+
444
+ const sendRelayReply = (peer = {}, payload = {}, finalText = "", { msgId } = {}) => new Promise((resolve, reject) => {
445
+ const text = String(finalText || "").trim();
446
+ if (!peer.relayFinalToMurmur || !text) {
447
+ resolve(null);
448
+ return;
449
+ }
450
+ if (!peer.murmurRoot || !peer.dataDir || !peer.storePath || !payload.from || !payload.conversationId) {
451
+ reject(new Error("murmur-reply-relay-config-missing"));
452
+ return;
453
+ }
454
+
455
+ const workdir = mkdtempSync(path.join(tmpdir(), "murmur-reply-relay."));
456
+ const replyFile = path.join(workdir, "reply.txt");
457
+ writeFileSync(replyFile, text, { mode: 0o600 });
458
+ const script = path.join(peer.murmurRoot, "scripts", "murmur-shell-send.mjs");
459
+ execFile(
460
+ process.execPath,
461
+ [script, "--to", payload.from, "--conv", payload.conversationId, "--text-file", replyFile, ...(msgId ? ["--msg-id", msgId] : [])],
462
+ {
463
+ cwd: peer.murmurRoot,
464
+ env: {
465
+ ...process.env,
466
+ DATA_DIR: peer.dataDir,
467
+ MURMUR_STORE_PATH: peer.storePath,
468
+ },
469
+ timeout: 30000,
470
+ },
471
+ (err, stdout, stderr) => {
472
+ rmSync(workdir, { recursive: true, force: true });
473
+ if (err) {
474
+ const detail = [stderr, stdout, err.message].filter(Boolean).join("\n").slice(0, 1200);
475
+ reject(new Error(detail || "murmur-reply-relay-failed"));
476
+ return;
477
+ }
478
+ let parsed = null;
479
+ try {
480
+ parsed = JSON.parse(String(stdout || "").trim());
481
+ } catch {
482
+ parsed = { stdout: String(stdout || "").trim() };
483
+ }
484
+ resolve(parsed);
485
+ },
486
+ );
487
+ });
488
+
489
+ const pickSingleOpenChannel = (channels) => {
490
+ const open = (channels || []).filter((channel) => !channel.closedAt);
491
+ return open.length === 1 ? open[0] : null;
492
+ };
493
+
494
+ export const createChannelThreadStartBindingResolver = ({ rosterStore, agentId, baseInstructionsResolver = null, log = () => {} } = {}) => {
495
+ if (!rosterStore) return null;
496
+ return async (payload, peer = {}) => {
497
+ const channelId = payload?.channelId || peer.channelId || pickSingleOpenChannel(rosterStore.listChannelsForConversation(payload?.conversationId || ""))?.channelId;
498
+ if (!channelId) return null;
499
+
500
+ const memberId = payload?.addresseeMemberId || peer.memberId;
501
+ const effectiveAgentId = agentId || peer.agentId;
502
+ const member = memberId
503
+ ? rosterStore.getChannelMember(channelId, memberId)
504
+ : effectiveAgentId
505
+ ? rosterStore.findActiveChannelMemberForAgent(channelId, effectiveAgentId)
506
+ : null;
507
+ if (!member || member.leftAt) return null;
508
+
509
+ const baseInstructions = typeof baseInstructionsResolver === "function"
510
+ ? await baseInstructionsResolver(member, payload, peer)
511
+ : peer.baseInstructions ?? null;
512
+ const binding = buildChannelThreadStartBinding({ member, baseInstructions });
513
+ log("info", "Codex app-server channel binding resolved", {
514
+ msgId: payload?.msgId,
515
+ channelId: member.channelId,
516
+ memberId: member.memberId,
517
+ agentId: member.agentId,
518
+ personaId: member.personaId ?? null,
519
+ model: member.model ?? null,
520
+ hasBaseInstructions: !!baseInstructions,
521
+ });
522
+ return binding;
523
+ };
524
+ };
525
+
526
+ export const createCodexAppServerInjector = ({
527
+ Client = CodexAppServerClient,
528
+ log = () => {},
529
+ timeoutMs = DEFAULT_TIMEOUT_MS,
530
+ resolveThreadStartBinding = null,
531
+ relay = sendRelayReply,
532
+ relayAlreadyQueued = relayReplyAlreadyQueued,
533
+ threadStore = null,
534
+ } = {}) => {
535
+ // #108 — one Codex thread per (peer, conversation). The daemon passes its message store
536
+ // so the mapping survives restarts; without one it still lives per conversation, only
537
+ // in memory. A static `peer.threadId` is an explicit pin and is never written here.
538
+ const memoryThreads = new Map();
539
+ const threads = threadStore ?? {
540
+ getWakeThread: async (peerId, conversationId) => memoryThreads.get(`${peerId}|${conversationId}`),
541
+ setWakeThread: async (record) => { memoryThreads.set(`${record.peerId}|${record.conversationId}`, record); },
542
+ };
543
+
544
+ return async (payload, peer) => {
545
+ const socketPath = peer?.socketPath || peer?.target;
546
+ if (!socketPath) throw new Error(`codex-app-server-socket-missing:${payload.from}`);
547
+
548
+ const relayEnabled = peer?.relayFinalToMurmur === true;
549
+ const replyMsgId = relayEnabled ? deriveRelayReplyMsgId(payload.msgId) : null;
550
+ if (relayEnabled && (await relayAlreadyQueued(peer, replyMsgId))) {
551
+ // A previous attempt got as far as queueing the reply and then died before the
552
+ // delivery was settled. Running the turn again would execute the instruction
553
+ // twice; the reply is already on its way, so this attempt has nothing left to do.
554
+ log("warn", "Codex app-server wake relay already queued, turn not restarted", {
555
+ msgId: payload.msgId,
556
+ replyMsgId,
557
+ attempt: payload.attempt ?? null,
558
+ });
559
+ return { finalText: null, turnId: null, source: "relay-idempotent", replyMsgId };
560
+ }
561
+
562
+ const client = new Client({ socketPath, timeoutMs });
563
+ const text = buildCodexTurnText(payload, peer);
564
+ const threadStartBinding = peer?.threadStartBinding ?? (resolveThreadStartBinding ? await resolveThreadStartBinding(payload, peer) : null);
565
+ const bindingMetadata = threadStartBinding?.metadata ?? {};
566
+ const shouldResumeThread = peer?.resume === true || (peer?.resume !== false && peer?.relayFinalToMurmur === true);
567
+ let threadPath = null;
568
+ const resumeThread = async (threadId) => {
569
+ if (!threadId || peer?.resume === false) return;
570
+ try {
571
+ const resumed = await client.request("thread/resume", {
572
+ threadId,
573
+ cwd: peer?.cwd ?? null,
574
+ model: peer?.model ?? null,
575
+ });
576
+ threadPath = resumed?.thread?.path || threadPath;
577
+ log("info", "Codex app-server wake thread resumed", { msgId: payload.msgId, threadId, socketPath });
578
+ } catch (err) {
579
+ const e = err instanceof Error ? err : new Error(String(err));
580
+ log("warn", "Codex app-server wake thread resume failed", { msgId: payload.msgId, threadId, socketPath, error: e.message });
581
+ }
582
+ };
583
+ const turnParams = (threadId) => ({
584
+ threadId,
585
+ input: [{ type: "text", text, text_elements: [] }],
586
+ ...(peer?.model ? { model: peer.model } : {}),
587
+ responsesapiClientMetadata: {
588
+ murmur_msg_id: payload.msgId || "",
589
+ murmur_conversation_id: payload.conversationId || "",
590
+ murmur_from: payload.from || "",
591
+ ...bindingMetadata,
592
+ },
593
+ });
594
+ const startTurn = async (threadId) => {
595
+ if (relayEnabled) {
596
+ const result = await client.startTurnAndWaitForFinal(turnParams(threadId), {
597
+ completionTimeoutMs: Number(peer?.replyTimeoutMs) || DEFAULT_TURN_COMPLETION_TIMEOUT_MS,
598
+ sessionPath: threadPath,
599
+ });
600
+ const status = typeof result?.status === "string" ? result.status : null;
601
+ if (status && status !== "completed") {
602
+ // #106 — a turn that failed or was interrupted is not a success, whatever text
603
+ // it left behind. `failed` may clear on a retry (model/provider hiccup);
604
+ // `interrupted` means someone stopped it on purpose — do not run it again.
605
+ const detail = result?.error?.message ? `:${result.error.message}` : "";
606
+ log("warn", "Codex app-server wake turn did not complete", {
607
+ msgId: payload.msgId,
608
+ threadId,
609
+ socketPath,
610
+ turnId: result?.turnId ?? null,
611
+ status,
612
+ error: result?.error?.message ?? null,
613
+ });
614
+ throw Object.assign(
615
+ new Error(`codex-app-server-turn-${status}:${result?.turnId ?? "unknown"}${detail}`),
616
+ { retryable: status === "failed" },
617
+ );
618
+ }
619
+ const finalText = String(result?.finalText || "").trim();
620
+ if (!finalText) {
621
+ // #106 — the turn ended and there is nothing to relay. That is not a relay;
622
+ // logging it as one is how the peer waited six hours believing it was answered.
623
+ // Not retryable: a second turn would run the instruction again, not recover
624
+ // the missing text.
625
+ log("warn", "Codex app-server wake produced no final answer to relay", {
626
+ msgId: payload.msgId,
627
+ threadId,
628
+ socketPath,
629
+ turnId: result?.turnId ?? null,
630
+ source: result?.source ?? null,
631
+ });
632
+ throw Object.assign(
633
+ new Error(`codex-app-server-final-empty:${result?.turnId ?? "unknown"}:${result?.source ?? "unknown"}`),
634
+ { retryable: false },
635
+ );
636
+ }
637
+ const relayResult = await relay(peer, payload, finalText, { msgId: replyMsgId });
638
+ log("info", "Codex app-server wake final relayed", {
639
+ msgId: payload.msgId,
640
+ threadId,
641
+ socketPath,
642
+ turnId: result?.turnId,
643
+ source: result?.source ?? null,
644
+ replyMsgId: relayResult?.msgId ?? replyMsgId,
645
+ finalTextLen: finalText.length,
646
+ });
647
+ return { ...result, replyMsgId: relayResult?.msgId ?? replyMsgId };
648
+ }
649
+ return client.request("turn/start", turnParams(threadId));
650
+ };
651
+
652
+ // A thread seeded in this call has no rollout file until its first turn, so
653
+ // `thread/resume` can only fail on it — and that failure is what used to leave
654
+ // `threadPath` null and silently disable the session-log completion fallback.
655
+ const threadKey = { peerId: String(payload.from ?? ""), conversationId: String(payload.conversationId ?? "") };
656
+ const seedThread = async (reason) => {
657
+ const started = await client.request("thread/start", buildThreadStartParams(threadStartBinding, peer));
658
+ const seededId = started?.thread?.id;
659
+ if (!seededId) throw new Error(`codex-app-server-thread-start-missing:${payload.from}`);
660
+ threadPath = started?.thread?.path || threadPath;
661
+ await threads.setWakeThread({
662
+ ...threadKey,
663
+ threadId: seededId,
664
+ threadPath: threadPath || undefined,
665
+ replacesThreadId: peer?.threadId || undefined,
666
+ });
667
+ log("info", `Codex app-server wake thread ${reason}`, { msgId: payload.msgId, threadId: seededId, socketPath, threadPath, conversationId: threadKey.conversationId });
668
+ return seededId;
669
+ };
670
+
671
+ // Resolution order: a remembered thread for this conversation, unless it was seeded to
672
+ // replace a pin that has since changed; then the static pin; then a fresh seed.
673
+ const remembered = await threads.getWakeThread(threadKey.peerId, threadKey.conversationId);
674
+ const rememberedUsable = remembered && (!peer?.threadId || !remembered.replacesThreadId || remembered.replacesThreadId === peer.threadId);
675
+ let threadId = rememberedUsable ? remembered.threadId : peer?.threadId;
676
+ if (rememberedUsable && remembered.threadPath) threadPath = remembered.threadPath;
677
+ let seededHere = false;
678
+ if (!threadId) {
679
+ threadId = await seedThread("seeded");
680
+ seededHere = true;
681
+ }
682
+
683
+ let result;
684
+ try {
685
+ if (shouldResumeThread && !seededHere) await resumeThread(threadId);
686
+ result = await startTurn(threadId);
687
+ } catch (err) {
688
+ const e = err instanceof Error ? err : new Error(String(err));
689
+ if (!e.message.startsWith("codex-app-server-error:thread not found:")) throw e;
690
+ threadId = await seedThread("re-seeded");
691
+ result = await startTurn(threadId);
692
+ }
693
+ log("info", "Codex app-server wake completed", { msgId: payload.msgId, threadId, socketPath });
694
+ return result;
695
+ };
696
+ };