@murmurv2/cli 2.11.0 → 2.12.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/README.md +37 -12
- package/package.json +2 -1
- package/runtime/bin/murmur-svc.exe +0 -0
- package/runtime/npm-runtime-manifest.json +97 -61
- package/runtime/package.json +1 -1
- package/runtime/packages/setup/bin/murmur.mjs +5 -3
- package/runtime/packages/setup/dist/src/cli.d.ts +1 -1
- package/runtime/packages/setup/dist/src/cli.js +49 -9
- package/runtime/packages/setup/dist/src/clients.js +8 -2
- package/runtime/packages/setup/dist/src/commands.d.ts +3 -1
- package/runtime/packages/setup/dist/src/commands.js +17 -6
- package/runtime/packages/setup/dist/src/config.d.ts +2 -0
- package/runtime/packages/setup/dist/src/config.js +61 -0
- package/runtime/packages/setup/dist/src/doctor.d.ts +12 -0
- package/runtime/packages/setup/dist/src/doctor.js +19 -2
- package/runtime/packages/setup/dist/src/invitation-server.d.ts +2 -0
- package/runtime/packages/setup/dist/src/invitation-server.js +45 -0
- package/runtime/packages/setup/dist/src/message-status.d.ts +4 -0
- package/runtime/packages/setup/dist/src/message-status.js +10 -0
- package/runtime/packages/setup/dist/src/onboarding.d.ts +19 -6
- package/runtime/packages/setup/dist/src/onboarding.js +46 -22
- package/runtime/packages/setup/dist/src/outbox-attention.js +3 -21
- package/runtime/packages/setup/dist/src/platform/darwin.js +7 -1
- package/runtime/packages/setup/dist/src/platform/linux.js +21 -16
- package/runtime/packages/setup/dist/src/platform/windows.js +67 -3
- package/runtime/packages/setup/dist/src/status.d.ts +17 -2
- package/runtime/packages/setup/dist/src/status.js +78 -18
- package/runtime/packages/setup/dist/src/types.d.ts +11 -1
- package/runtime/packages/setup/dist/src/verdict.js +4 -2
- package/runtime/packages/setup/dist/src/wake-attention.d.ts +11 -0
- package/runtime/packages/setup/dist/src/wake-attention.js +44 -0
- package/runtime/packages/setup/package.json +1 -1
- package/runtime/scripts/codex-app-server-wake.mjs +169 -32
- package/runtime/scripts/daemon-contacts.mjs +56 -0
- package/runtime/scripts/daemon-flush-tick.mjs +25 -0
- package/runtime/scripts/daemon-link-watch.mjs +60 -0
- package/runtime/scripts/daemon-observation.mjs +74 -18
- package/runtime/scripts/murmur-daemon.mjs +45 -31
- package/runtime/scripts/wake-drain-claude.mjs +69 -32
- package/runtime/scripts/wake-monitor.mjs +46 -18
|
@@ -10,6 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
const DEFAULT_TIMEOUT_MS = 10000;
|
|
11
11
|
const INITIALIZE_TIMEOUT_MS = 10000;
|
|
12
12
|
const DEFAULT_TURN_COMPLETION_TIMEOUT_MS = 180000;
|
|
13
|
+
const DEFAULT_UNOBSERVABLE_TIMEOUT_MS = 300000;
|
|
13
14
|
const SESSION_LOG_POLL_INTERVAL_MS = 1000;
|
|
14
15
|
const SESSION_LOG_TAIL_BYTES = 8 * 1024 * 1024;
|
|
15
16
|
|
|
@@ -87,15 +88,16 @@ const readSessionLogTail = (sessionPath) => {
|
|
|
87
88
|
}
|
|
88
89
|
};
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
if (!sessionPath || !turnId) return
|
|
91
|
+
const readTurnFromSessionLog = (sessionPath, turnId) => {
|
|
92
|
+
if (!sessionPath || !turnId) return null;
|
|
92
93
|
let data = "";
|
|
93
94
|
try {
|
|
94
95
|
data = readSessionLogTail(sessionPath);
|
|
95
96
|
} catch {
|
|
96
|
-
return
|
|
97
|
+
return null;
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
let finalText = '';
|
|
99
101
|
const lines = data.split("\n").filter(Boolean);
|
|
100
102
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
101
103
|
const line = lines[index];
|
|
@@ -110,7 +112,9 @@ export const readFinalAnswerFromSessionLog = (sessionPath, turnId) => {
|
|
|
110
112
|
|
|
111
113
|
const payload = entry?.payload || {};
|
|
112
114
|
if (entry.type === "event_msg" && payload.type === "task_complete" && payload.turn_id === turnId) {
|
|
113
|
-
return
|
|
115
|
+
return { turnId, status: payload.error == null ? 'completed' : 'failed',
|
|
116
|
+
error: typeof payload.error === 'string' ? { message: payload.error } : payload.error ?? null,
|
|
117
|
+
finalText: typeof payload.last_agent_message === "string" ? payload.last_agent_message : finalText };
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
const metadataTurnId = payload?.internal_chat_message_metadata_passthrough?.turn_id;
|
|
@@ -121,12 +125,14 @@ export const readFinalAnswerFromSessionLog = (sessionPath, turnId) => {
|
|
|
121
125
|
&& metadataTurnId === turnId
|
|
122
126
|
&& Array.isArray(payload.content)
|
|
123
127
|
) {
|
|
124
|
-
|
|
128
|
+
finalText ||= payload.content.map((part) => part?.text || "").join("\n").trim();
|
|
125
129
|
}
|
|
126
130
|
}
|
|
127
|
-
return
|
|
131
|
+
return finalText ? { turnId, status: 'inProgress', finalText } : null;
|
|
128
132
|
};
|
|
129
133
|
|
|
134
|
+
export const readFinalAnswerFromSessionLog = (sessionPath, turnId) => readTurnFromSessionLog(sessionPath, turnId)?.finalText ?? '';
|
|
135
|
+
|
|
130
136
|
export const buildThreadStartParams = (binding = null, peer = null) => ({
|
|
131
137
|
model: binding?.model ?? peer?.model ?? null,
|
|
132
138
|
modelProvider: null,
|
|
@@ -259,13 +265,24 @@ export class CodexAppServerClient {
|
|
|
259
265
|
startTurnAndWaitForFinal(params, {
|
|
260
266
|
completionTimeoutMs = DEFAULT_TURN_COMPLETION_TIMEOUT_MS,
|
|
261
267
|
sessionPath = null,
|
|
268
|
+
acceptedTurn = null,
|
|
269
|
+
onAccepted = () => {},
|
|
270
|
+
onWorking = () => {},
|
|
271
|
+
onObservation = () => {},
|
|
272
|
+
unobservableTimeoutMs = DEFAULT_UNOBSERVABLE_TIMEOUT_MS,
|
|
273
|
+
pollIntervalMs = SESSION_LOG_POLL_INTERVAL_MS,
|
|
274
|
+
maxPollIntervalMs = 30000,
|
|
262
275
|
} = {}) {
|
|
263
276
|
return new Promise((resolve, reject) => {
|
|
264
277
|
let settled = false;
|
|
265
278
|
let initialized = false;
|
|
266
|
-
let turnId = null;
|
|
279
|
+
let turnId = acceptedTurn?.turnId ?? null;
|
|
267
280
|
let startResult = null;
|
|
268
281
|
let finalText = "";
|
|
282
|
+
let receipt = Promise.resolve();
|
|
283
|
+
let observing = Boolean(turnId), polling = false, reported = null;
|
|
284
|
+
let unobservableSince = acceptedTurn?.unobservableSince ?? null;
|
|
285
|
+
let unobservableTimer = null, sessionLogTimer = null, pollDelay = pollIntervalMs;
|
|
269
286
|
const requestId = this.nextId++;
|
|
270
287
|
const initId = `init-${this.nextId++}`;
|
|
271
288
|
const url = `ws+unix://${this.socketPath}:/`;
|
|
@@ -273,29 +290,86 @@ export class CodexAppServerClient {
|
|
|
273
290
|
perMessageDeflate: false,
|
|
274
291
|
handshakeTimeout: Math.min(this.timeoutMs, INITIALIZE_TIMEOUT_MS),
|
|
275
292
|
});
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
293
|
+
const working = (reason) => {
|
|
294
|
+
observing = true;
|
|
295
|
+
if (reported !== reason) { reported = reason; onWorking({ threadId: params.threadId, turnId, reason }); }
|
|
296
|
+
};
|
|
297
|
+
const recordObservation = (since) => {
|
|
298
|
+
if (since === unobservableSince) return;
|
|
299
|
+
unobservableSince = since;
|
|
300
|
+
receipt = receipt.then(() => onObservation({ unobservableSince: since }));
|
|
301
|
+
receipt.catch(() => {});
|
|
302
|
+
};
|
|
303
|
+
const unavailable = () => {
|
|
304
|
+
if (settled) return;
|
|
305
|
+
if (!unobservableSince) recordObservation(new Date().toISOString());
|
|
306
|
+
working('accepted-turn-unobservable');
|
|
307
|
+
if (unobservableTimer === null) {
|
|
308
|
+
const remaining = unobservableTimeoutMs - (Date.now() - Date.parse(unobservableSince));
|
|
309
|
+
unobservableTimer = setTimeout(() => finish(Object.assign(
|
|
310
|
+
new Error('accepted-turn-unobservable'), { retryable: false })), Math.max(0, remaining));
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
const observable = () => {
|
|
314
|
+
clearTimeout(unobservableTimer); unobservableTimer = null;
|
|
315
|
+
recordObservation(null);
|
|
316
|
+
};
|
|
317
|
+
let timer = setTimeout(() => {
|
|
318
|
+
if (turnId) working('completion-window-elapsed');
|
|
319
|
+
else finish(new Error('codex-app-server-turn-start-timeout'));
|
|
320
|
+
}, turnId ? completionTimeoutMs : this.timeoutMs);
|
|
321
|
+
const accept = (id) => {
|
|
322
|
+
if (!id || turnId) return;
|
|
323
|
+
turnId = id;
|
|
324
|
+
try { receipt = Promise.resolve(onAccepted({ threadId: params.threadId, turnId, sessionPath })); }
|
|
325
|
+
catch (error) { receipt = Promise.reject(error); }
|
|
326
|
+
// Observe receipt-write failures immediately, without losing the accepted ID.
|
|
327
|
+
receipt.catch(() => {});
|
|
328
|
+
clearTimeout(timer);
|
|
329
|
+
timer = setTimeout(() => working('completion-window-elapsed'), completionTimeoutMs);
|
|
330
|
+
};
|
|
331
|
+
const inspect = async () => {
|
|
332
|
+
if (settled || !turnId || polling) return;
|
|
333
|
+
const savedTurn = sessionPath && readTurnFromSessionLog(sessionPath, turnId);
|
|
334
|
+
if (savedTurn && ['completed', 'failed'].includes(savedTurn.status)) {
|
|
335
|
+
finish(null, { ...startResult, ...savedTurn, finalText: finalText || savedTurn.finalText, source: "session-log" });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (!observing) return;
|
|
339
|
+
polling = true;
|
|
340
|
+
try {
|
|
341
|
+
const turn = await this.readTurn(params.threadId, turnId);
|
|
342
|
+
if (settled) return;
|
|
343
|
+
if (!turn || !['inProgress', 'completed', 'failed', 'interrupted'].includes(turn.status)) { unavailable(); return; }
|
|
344
|
+
observable();
|
|
345
|
+
if (turn.status === 'inProgress') return;
|
|
346
|
+
const answer = turn.items?.filter(item => item.type === 'agentMessage' && item.phase === 'final_answer')
|
|
347
|
+
.map(item => item.text ?? '').join('\n').trim();
|
|
348
|
+
finish(null, { finalText: finalText || answer || '', turnId, status: turn.status,
|
|
349
|
+
error: turn.error ?? null, source: 'app-server-read' });
|
|
350
|
+
} catch { unavailable(); }
|
|
351
|
+
finally { polling = false; }
|
|
352
|
+
};
|
|
353
|
+
const poll = async () => {
|
|
354
|
+
await inspect();
|
|
355
|
+
if (settled) return;
|
|
356
|
+
pollDelay = Math.min(maxPollIntervalMs, pollDelay * 2);
|
|
357
|
+
sessionLogTimer = setTimeout(poll, pollDelay);
|
|
358
|
+
};
|
|
359
|
+
sessionLogTimer = setTimeout(poll, pollDelay);
|
|
286
360
|
|
|
287
361
|
const finish = (err, result) => {
|
|
288
362
|
if (settled) return;
|
|
289
363
|
settled = true;
|
|
290
364
|
clearTimeout(timer);
|
|
291
|
-
|
|
365
|
+
clearTimeout(sessionLogTimer);
|
|
366
|
+
clearTimeout(unobservableTimer);
|
|
292
367
|
try {
|
|
293
368
|
socket.close();
|
|
294
369
|
} catch {
|
|
295
370
|
// Ignore close races; the request already has a terminal result.
|
|
296
371
|
}
|
|
297
|
-
|
|
298
|
-
else resolve(result);
|
|
372
|
+
receipt.then(() => err ? reject(err) : resolve(result), reject);
|
|
299
373
|
};
|
|
300
374
|
|
|
301
375
|
const sendJson = (message) => socket.send(JSON.stringify(message));
|
|
@@ -342,12 +416,17 @@ export class CodexAppServerClient {
|
|
|
342
416
|
|
|
343
417
|
if (message.id === initId) {
|
|
344
418
|
if (message.error) {
|
|
419
|
+
if (turnId) { unavailable(); return; }
|
|
345
420
|
finish(new Error(`codex-app-server-initialize-error:${message.error.message || JSON.stringify(message.error)}`));
|
|
346
421
|
return;
|
|
347
422
|
}
|
|
348
423
|
initialized = true;
|
|
349
424
|
sendJson({ method: "initialized" });
|
|
350
|
-
|
|
425
|
+
if (acceptedTurn) {
|
|
426
|
+
// Subscribe to the existing thread; never submit this letter again.
|
|
427
|
+
sendJson({ id: requestId, method: "thread/resume", params: { threadId: params.threadId } });
|
|
428
|
+
void inspect();
|
|
429
|
+
} else sendJson({ id: requestId, method: "turn/start", params });
|
|
351
430
|
return;
|
|
352
431
|
}
|
|
353
432
|
|
|
@@ -355,37 +434,38 @@ export class CodexAppServerClient {
|
|
|
355
434
|
|
|
356
435
|
if (message.id === requestId) {
|
|
357
436
|
if (message.error) {
|
|
437
|
+
if (turnId) { unavailable(); return; }
|
|
358
438
|
finish(new Error(`codex-app-server-error:${message.error.message || JSON.stringify(message.error)}`));
|
|
359
439
|
return;
|
|
360
440
|
}
|
|
361
441
|
startResult = message.result;
|
|
362
|
-
|
|
442
|
+
if (acceptedTurn) sessionPath = message.result?.thread?.path || sessionPath;
|
|
443
|
+
else accept(message.result?.turn?.id);
|
|
363
444
|
return;
|
|
364
445
|
}
|
|
365
446
|
|
|
447
|
+
if (!turnId || (message.params?.threadId && message.params.threadId !== params.threadId)) return;
|
|
366
448
|
if (message.method === "turn/started" && message.params?.turn?.id) {
|
|
367
|
-
turnId = turnId || message.params.turn.id;
|
|
368
449
|
return;
|
|
369
450
|
}
|
|
370
451
|
|
|
371
452
|
if (message.method === "item/completed" && message.params?.turnId) {
|
|
372
|
-
turnId = turnId || message.params.turnId;
|
|
373
453
|
if (message.params.turnId !== turnId) return;
|
|
454
|
+
observable();
|
|
374
455
|
const item = message.params.item;
|
|
375
456
|
if (item?.type === "agentMessage" && item.phase === "final_answer" && typeof item.text === "string") {
|
|
376
457
|
finalText = item.text;
|
|
377
|
-
finish(null, { ...startResult, finalText, turnId, source: "app-server-final-item" });
|
|
378
458
|
}
|
|
379
459
|
return;
|
|
380
460
|
}
|
|
381
461
|
|
|
382
462
|
if (message.method === "turn/completed" && message.params?.turn?.id) {
|
|
383
|
-
turnId = turnId || message.params.turn.id;
|
|
384
463
|
if (message.params.turn.id !== turnId) return;
|
|
385
464
|
// #106 — `turn.status` (completed | interrupted | failed | inProgress) and
|
|
386
465
|
// `turn.error` are part of the notification; a caller that ignores them cannot
|
|
387
466
|
// tell a finished turn from a broken one.
|
|
388
467
|
const turn = message.params.turn;
|
|
468
|
+
if (turn.status === 'inProgress') { observable(); working('turn-in-progress'); return; }
|
|
389
469
|
finish(null, {
|
|
390
470
|
...startResult,
|
|
391
471
|
finalText,
|
|
@@ -397,13 +477,38 @@ export class CodexAppServerClient {
|
|
|
397
477
|
}
|
|
398
478
|
});
|
|
399
479
|
socket.on("error", (err) => {
|
|
480
|
+
if (settled) return;
|
|
481
|
+
if (turnId) { unavailable(); return; }
|
|
400
482
|
finish(new Error(`codex-app-server-connect-failed:${this.socketPath}:${err.message}`));
|
|
401
483
|
});
|
|
402
484
|
socket.on("close", () => {
|
|
485
|
+
if (settled) return;
|
|
486
|
+
if (turnId) { unavailable(); return; }
|
|
403
487
|
if (!settled) finish(new Error(`codex-app-server-closed-before-response:${this.socketPath}:${initialized ? "after-initialize" : "before-initialize"}`));
|
|
404
488
|
});
|
|
405
489
|
});
|
|
406
490
|
}
|
|
491
|
+
|
|
492
|
+
async readTurn(threadId, turnId) {
|
|
493
|
+
try {
|
|
494
|
+
const result = await this.request('thread/read', { threadId, includeTurns: true });
|
|
495
|
+
if (result?.thread?.id !== threadId) return null;
|
|
496
|
+
const turn = result.thread.turns?.find(turn => turn.id === turnId);
|
|
497
|
+
if (turn) return turn;
|
|
498
|
+
} catch (error) {
|
|
499
|
+
// New paginated threads reject the legacy full-history read.
|
|
500
|
+
if (!/paginat|includeTurns|full.history/i.test(error.message)) throw error;
|
|
501
|
+
}
|
|
502
|
+
let cursor;
|
|
503
|
+
for (let page = 0; page < 20; page++) {
|
|
504
|
+
const result = await this.request('thread/turns/list', { threadId, limit: 50, sortDirection: 'desc', itemsView: 'full', ...(cursor ? { cursor } : {}) });
|
|
505
|
+
const turn = result?.data?.find(turn => turn.id === turnId);
|
|
506
|
+
if (turn) return turn;
|
|
507
|
+
if (!result?.nextCursor || result.nextCursor === cursor) return null;
|
|
508
|
+
cursor = result.nextCursor;
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
407
512
|
}
|
|
408
513
|
|
|
409
514
|
/**
|
|
@@ -536,6 +641,15 @@ export const createCodexAppServerInjector = ({
|
|
|
536
641
|
// so the mapping survives restarts; without one it still lives per conversation, only
|
|
537
642
|
// in memory. A static `peer.threadId` is an explicit pin and is never written here.
|
|
538
643
|
const memoryThreads = new Map();
|
|
644
|
+
const memoryTurns = new Map();
|
|
645
|
+
const turns = {
|
|
646
|
+
get: async msgId => memoryTurns.get(msgId) ?? await threadStore?.getWakeTurn?.(msgId),
|
|
647
|
+
set: async record => { memoryTurns.set(record.msgId, record); await threadStore?.setWakeTurn?.(record); },
|
|
648
|
+
delete: async (msgId, turnId) => {
|
|
649
|
+
await threadStore?.deleteWakeTurn?.(msgId, turnId);
|
|
650
|
+
if (memoryTurns.get(msgId)?.turnId === turnId) memoryTurns.delete(msgId);
|
|
651
|
+
},
|
|
652
|
+
};
|
|
539
653
|
const threads = threadStore ?? {
|
|
540
654
|
getWakeThread: async (peerId, conversationId) => memoryThreads.get(`${peerId}|${conversationId}`),
|
|
541
655
|
setWakeThread: async (record) => { memoryThreads.set(`${record.peerId}|${record.conversationId}`, record); },
|
|
@@ -559,12 +673,17 @@ export const createCodexAppServerInjector = ({
|
|
|
559
673
|
return { finalText: null, turnId: null, source: "relay-idempotent", replyMsgId };
|
|
560
674
|
}
|
|
561
675
|
|
|
676
|
+
const accepted = await turns.get(payload.msgId);
|
|
677
|
+
if (accepted && (accepted.peerId !== String(payload.from ?? '')
|
|
678
|
+
|| accepted.conversationId !== String(payload.conversationId ?? '') || accepted.socketPath !== socketPath)) {
|
|
679
|
+
throw Object.assign(new Error('codex-app-server-accepted-turn-binding-changed'), { retryable: false });
|
|
680
|
+
}
|
|
562
681
|
const client = new Client({ socketPath, timeoutMs });
|
|
563
682
|
const text = buildCodexTurnText(payload, peer);
|
|
564
683
|
const threadStartBinding = peer?.threadStartBinding ?? (resolveThreadStartBinding ? await resolveThreadStartBinding(payload, peer) : null);
|
|
565
684
|
const bindingMetadata = threadStartBinding?.metadata ?? {};
|
|
566
685
|
const shouldResumeThread = peer?.resume === true || (peer?.resume !== false && peer?.relayFinalToMurmur === true);
|
|
567
|
-
let threadPath = null;
|
|
686
|
+
let threadPath = accepted?.sessionPath ?? null;
|
|
568
687
|
const resumeThread = async (threadId) => {
|
|
569
688
|
if (!threadId || peer?.resume === false) return;
|
|
570
689
|
try {
|
|
@@ -592,13 +711,30 @@ export const createCodexAppServerInjector = ({
|
|
|
592
711
|
},
|
|
593
712
|
});
|
|
594
713
|
const startTurn = async (threadId) => {
|
|
595
|
-
|
|
714
|
+
// A configuration change must not turn an accepted receipt back into a
|
|
715
|
+
// fire-and-forget start. Observe it even if reply relay was disabled later.
|
|
716
|
+
if (relayEnabled || accepted) {
|
|
596
717
|
const result = await client.startTurnAndWaitForFinal(turnParams(threadId), {
|
|
597
718
|
completionTimeoutMs: Number(peer?.replyTimeoutMs) || DEFAULT_TURN_COMPLETION_TIMEOUT_MS,
|
|
598
719
|
sessionPath: threadPath,
|
|
720
|
+
acceptedTurn: accepted,
|
|
721
|
+
unobservableTimeoutMs: peer?.unobservableTimeoutMs ?? DEFAULT_UNOBSERVABLE_TIMEOUT_MS,
|
|
722
|
+
onAccepted: async receipt => {
|
|
723
|
+
await turns.set({ ...receipt, msgId: payload.msgId, peerId: String(payload.from ?? ''),
|
|
724
|
+
conversationId: String(payload.conversationId ?? ''), socketPath });
|
|
725
|
+
log('info', 'Codex app-server wake turn accepted', { msgId: payload.msgId, threadId, turnId: receipt.turnId });
|
|
726
|
+
},
|
|
727
|
+
onWorking: ({ turnId, reason }) => log(reason === 'accepted-turn-unobservable' ? 'warn' : 'info',
|
|
728
|
+
reason === 'accepted-turn-unobservable' ? 'Codex accepted turn outcome unavailable; wake retained' : 'Codex Assistant still working; wake retained',
|
|
729
|
+
{ msgId: payload.msgId, threadId, turnId, reason }),
|
|
730
|
+
onObservation: async ({ unobservableSince }) => {
|
|
731
|
+
const receipt = await turns.get(payload.msgId);
|
|
732
|
+
if (receipt) await turns.set({ ...receipt, unobservableSince });
|
|
733
|
+
},
|
|
599
734
|
});
|
|
600
735
|
const status = typeof result?.status === "string" ? result.status : null;
|
|
601
736
|
if (status && status !== "completed") {
|
|
737
|
+
if (status === 'failed' && result.turnId) await turns.delete(payload.msgId, result.turnId);
|
|
602
738
|
// #106 — a turn that failed or was interrupted is not a success, whatever text
|
|
603
739
|
// it left behind. `failed` may clear on a retry (model/provider hiccup);
|
|
604
740
|
// `interrupted` means someone stopped it on purpose — do not run it again.
|
|
@@ -616,6 +752,7 @@ export const createCodexAppServerInjector = ({
|
|
|
616
752
|
{ retryable: status === "failed" },
|
|
617
753
|
);
|
|
618
754
|
}
|
|
755
|
+
if (!relayEnabled) return result;
|
|
619
756
|
const finalText = String(result?.finalText || "").trim();
|
|
620
757
|
if (!finalText) {
|
|
621
758
|
// #106 — the turn ended and there is nothing to relay. That is not a relay;
|
|
@@ -670,9 +807,9 @@ export const createCodexAppServerInjector = ({
|
|
|
670
807
|
|
|
671
808
|
// Resolution order: a remembered thread for this conversation, unless it was seeded to
|
|
672
809
|
// 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);
|
|
810
|
+
const remembered = accepted ? null : await threads.getWakeThread(threadKey.peerId, threadKey.conversationId);
|
|
674
811
|
const rememberedUsable = remembered && (!peer?.threadId || !remembered.replacesThreadId || remembered.replacesThreadId === peer.threadId);
|
|
675
|
-
let threadId = rememberedUsable ? remembered.threadId : peer?.threadId;
|
|
812
|
+
let threadId = accepted?.threadId ?? (rememberedUsable ? remembered.threadId : peer?.threadId);
|
|
676
813
|
if (rememberedUsable && remembered.threadPath) threadPath = remembered.threadPath;
|
|
677
814
|
let seededHere = false;
|
|
678
815
|
if (!threadId) {
|
|
@@ -682,11 +819,11 @@ export const createCodexAppServerInjector = ({
|
|
|
682
819
|
|
|
683
820
|
let result;
|
|
684
821
|
try {
|
|
685
|
-
if (shouldResumeThread && !seededHere) await resumeThread(threadId);
|
|
822
|
+
if (shouldResumeThread && !seededHere && !accepted) await resumeThread(threadId);
|
|
686
823
|
result = await startTurn(threadId);
|
|
687
824
|
} catch (err) {
|
|
688
825
|
const e = err instanceof Error ? err : new Error(String(err));
|
|
689
|
-
if (!e.message.startsWith("codex-app-server-error:thread not found:")) throw e;
|
|
826
|
+
if (accepted || !e.message.startsWith("codex-app-server-error:thread not found:")) throw e;
|
|
690
827
|
threadId = await seedThread("re-seeded");
|
|
691
828
|
result = await startTurn(threadId);
|
|
692
829
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AgentConfigCache } from '../packages/mcp-server/dist/src/agent-config.js';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const key = value => typeof value === 'string' && Buffer.from(value, 'base64').length === 32
|
|
5
|
+
&& Buffer.from(value, 'base64').toString('base64') === value;
|
|
6
|
+
const contactId = value => /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value);
|
|
7
|
+
|
|
8
|
+
/** The config file's atomic replacement is the cross-platform reload trigger.
|
|
9
|
+
* Share MCP's owner/path/identity/runtime pinning. Failed reads cannot introduce
|
|
10
|
+
* new trust: keep the last valid Contacts available and report the failure. */
|
|
11
|
+
export function createDaemonContacts(configPath, log = () => {}) {
|
|
12
|
+
const cache = new AgentConfigCache(configPath);
|
|
13
|
+
const initial = cache.read();
|
|
14
|
+
const config = { ...initial, peers: {} };
|
|
15
|
+
let applied, failure, invalidSignature = '[]';
|
|
16
|
+
let diagnostic = { state: 'current', count: 0, invalidCount: 0,
|
|
17
|
+
lastSuccessAt: null, lastError: null, lastErrorAt: null };
|
|
18
|
+
const refresh = (force = false) => {
|
|
19
|
+
try {
|
|
20
|
+
const next = cache.read(force);
|
|
21
|
+
if (applied === next && !failure) return config.peers;
|
|
22
|
+
const invalid = [];
|
|
23
|
+
const peers = Object.fromEntries(Object.entries(next.peers).filter(([id, peer]) => {
|
|
24
|
+
if (contactId(id) && peer && typeof peer.subject === 'string' && /^[A-Za-z0-9_.:-]+$/.test(peer.subject)
|
|
25
|
+
&& key(peer.encryption?.publicKey) && key(peer.signing?.publicKey)) return true;
|
|
26
|
+
invalid.push(contactId(id) ? id : null);
|
|
27
|
+
return false;
|
|
28
|
+
}));
|
|
29
|
+
const signature = JSON.stringify(invalid.sort());
|
|
30
|
+
const changed = !isDeepStrictEqual(config.peers, peers);
|
|
31
|
+
config.peers = peers;
|
|
32
|
+
const at = new Date().toISOString();
|
|
33
|
+
diagnostic = { state: invalid.length ? 'partial' : 'current', count: Object.keys(peers).length,
|
|
34
|
+
invalidCount: invalid.length, lastSuccessAt: at,
|
|
35
|
+
lastError: invalid.length ? 'agent-config-peer-invalid' : null,
|
|
36
|
+
lastErrorAt: invalid.length ? signature !== invalidSignature || failure ? at : diagnostic.lastErrorAt : null };
|
|
37
|
+
if (invalid.length && (signature !== invalidSignature || failure))
|
|
38
|
+
log('warn', 'Invalid Contacts skipped', { reason: 'agent-config-peer-invalid', contacts: invalid, count: invalid.length });
|
|
39
|
+
// MCP may change ctime by protecting the same file. Compare content, not
|
|
40
|
+
// cache object identity, before claiming that Contacts changed.
|
|
41
|
+
if ((applied && changed) || failure) log('info', 'Contacts reloaded', { count: diagnostic.count });
|
|
42
|
+
invalidSignature = signature;
|
|
43
|
+
applied = next; failure = undefined;
|
|
44
|
+
return config.peers;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const reason = /^agent-config-[a-z-]+$/.test(error?.message) ? error.message : 'agent-config-unavailable-retry-or-restart';
|
|
47
|
+
if (failure !== reason) log('error', 'Contacts reload failed', { reason });
|
|
48
|
+
diagnostic = { ...diagnostic, state: 'retained', lastError: reason,
|
|
49
|
+
lastErrorAt: failure === reason ? diagnostic.lastErrorAt : new Date().toISOString() };
|
|
50
|
+
failure = reason;
|
|
51
|
+
return config.peers;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
refresh();
|
|
55
|
+
return { config, refresh, diagnostics: () => ({ ...diagnostic }) };
|
|
56
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// One pass of the daemon delivery loop. Outbound letters and notifications keep flowing
|
|
2
|
+
// while an assistant works: a wake turn can last many minutes, and awaiting it here held
|
|
3
|
+
// every reply in the outbox until the turn ended or timed out (24.09, the Mac sat on a
|
|
4
|
+
// ping from 11:29 to 11:36). WakeMonitor.drain() is single-flight, so starting it on
|
|
5
|
+
// every tick only kicks the running dispatcher; its rows stay durable across restarts.
|
|
6
|
+
export async function flushTick({ flushOutbox, flushNotify, drainWake, log }) {
|
|
7
|
+
try {
|
|
8
|
+
await flushOutbox();
|
|
9
|
+
} catch (err) {
|
|
10
|
+
log("error", "Outbox flush error", { error: err instanceof Error ? err.message : String(err) });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
await flushNotify();
|
|
15
|
+
} catch (err) {
|
|
16
|
+
log("error", "Notify flush error", { error: err instanceof Error ? err.message : String(err) });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// #105 — retry tick. With no drain running this reads the table: deliveries whose
|
|
20
|
+
// backoff has elapsed and rows a previous process left behind. A running drain is
|
|
21
|
+
// only nudged; such rows wait until it frees its conversations.
|
|
22
|
+
Promise.resolve()
|
|
23
|
+
.then(drainWake)
|
|
24
|
+
.catch((err) => log("error", "Wake retry drain error", { error: err instanceof Error ? err.message : String(err) }));
|
|
25
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit when the server link is gone (#276).
|
|
3
|
+
*
|
|
4
|
+
* A daemon whose broker connection has closed for good, or has been lost longer than the
|
|
5
|
+
* operator allows, keeps its PID and its supervisor's "healthy" while delivering nothing.
|
|
6
|
+
* This watch turns that into a non-zero exit so systemd, launchd or the Windows Service
|
|
7
|
+
* manager restart the daemon and the failure becomes visible in their status.
|
|
8
|
+
*
|
|
9
|
+
* - `exitOnClosed` (default on): the connection reported `closed` — auth abort after a
|
|
10
|
+
* rotated token, reconnect attempts exhausted — and will never come back by itself.
|
|
11
|
+
* - `maxDisconnectedMs` (default 0 = off): the link has been lost longer than this.
|
|
12
|
+
*/
|
|
13
|
+
export function createLinkWatch({ observation, maxDisconnectedMs = 0, exitOnClosed = true, intervalMs, log = () => {}, onLost, now = () => Date.now() }) {
|
|
14
|
+
const limit = Number.isFinite(maxDisconnectedMs) && maxDisconnectedMs > 0 ? Math.floor(maxDisconnectedMs) : 0;
|
|
15
|
+
const enabled = exitOnClosed || limit > 0;
|
|
16
|
+
const period = intervalMs ?? (limit > 0 ? Math.max(1000, Math.min(30000, Math.floor(limit / 4))) : 5000);
|
|
17
|
+
let timer;
|
|
18
|
+
let fired = false;
|
|
19
|
+
|
|
20
|
+
const fire = (reason, data) => {
|
|
21
|
+
if (fired) return true;
|
|
22
|
+
fired = true;
|
|
23
|
+
log("fatal", "Server link is gone; exiting so the supervisor restarts the daemon", { reason, ...data });
|
|
24
|
+
onLost?.(reason);
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const check = () => {
|
|
29
|
+
if (fired) return true;
|
|
30
|
+
if (!enabled) return false;
|
|
31
|
+
const broker = observation.broker();
|
|
32
|
+
if (exitOnClosed && broker.state === "closed") {
|
|
33
|
+
return fire("broker.link-closed", { lastError: broker.lastError, disconnectedAt: broker.disconnectedAt });
|
|
34
|
+
}
|
|
35
|
+
if (limit > 0) {
|
|
36
|
+
const disconnectedForMs = observation.disconnectedForMs(now());
|
|
37
|
+
if (disconnectedForMs > limit) {
|
|
38
|
+
return fire("broker.link-lost", { disconnectedAt: broker.disconnectedAt, disconnectedForMs, maxDisconnectedMs: limit,
|
|
39
|
+
reconnectAttempts: broker.reconnectAttempts });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
enabled,
|
|
47
|
+
maxDisconnectedMs: limit,
|
|
48
|
+
intervalMs: period,
|
|
49
|
+
start() {
|
|
50
|
+
if (!enabled || timer) return;
|
|
51
|
+
timer = setInterval(check, period);
|
|
52
|
+
timer.unref();
|
|
53
|
+
},
|
|
54
|
+
stop() {
|
|
55
|
+
if (timer) clearInterval(timer);
|
|
56
|
+
timer = undefined;
|
|
57
|
+
},
|
|
58
|
+
check,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -3,38 +3,94 @@ import { realpath } from "node:fs/promises";
|
|
|
3
3
|
import { writePrivateJson } from "./secure-state.mjs";
|
|
4
4
|
|
|
5
5
|
/** Non-secret, PID/store-bound liveness evidence for the read-only setup CLI. */
|
|
6
|
-
export function createDaemonObservation({ dataDir, storePath, agentId, wake, log = () => {} }) {
|
|
6
|
+
export function createDaemonObservation({ dataDir, storePath, agentId, wake, contacts = () => null, log = () => {} }) {
|
|
7
7
|
const startedAt = new Date().toISOString();
|
|
8
8
|
const snapshot = { schema: "murmur.runtime/1", agentId, pid: process.pid, startedAt,
|
|
9
9
|
storePath: null, measuredAt: null, wake: { ...wake, lastFault: null, lastFaultAt: null },
|
|
10
|
-
broker: { state: "
|
|
11
|
-
|
|
10
|
+
broker: { state: "disconnected", connectedAt: null, disconnectedAt: startedAt, reconnectAttempts: 0,
|
|
11
|
+
lastError: "broker.connecting", lastErrorAt: startedAt } };
|
|
12
|
+
const knownReasons = ["broker.unauthorized", "broker.permission-denied", "broker.connection-refused", "broker.name-unresolved",
|
|
13
|
+
"broker.timeout", "broker.connection-failed", "broker.stale-connection", "broker.closed"];
|
|
14
|
+
const safeReason = (value) => knownReasons.includes(value) ? value : "broker.connection-failed";
|
|
15
|
+
let writes = Promise.resolve();
|
|
12
16
|
let timer;
|
|
13
|
-
const write = async () => {
|
|
14
|
-
if (writing) return;
|
|
15
|
-
writing = true;
|
|
17
|
+
const write = () => writes = writes.then(async () => {
|
|
16
18
|
try {
|
|
17
19
|
snapshot.storePath = await realpath(storePath);
|
|
18
20
|
snapshot.measuredAt = new Date().toISOString();
|
|
21
|
+
snapshot.contacts = contacts();
|
|
19
22
|
await writePrivateJson(path.join(dataDir, "daemon-observation.json"), snapshot);
|
|
20
23
|
} catch { log("warn", "Daemon observation write failed", { reason: "runtime.observation-write-failed" }); }
|
|
21
|
-
|
|
22
|
-
};
|
|
24
|
+
});
|
|
23
25
|
return {
|
|
24
|
-
async start() {
|
|
25
|
-
|
|
26
|
+
async start() {
|
|
27
|
+
log("info", "Connecting to server", { reason: "broker.connecting" });
|
|
28
|
+
await write(); timer = setInterval(() => { void write(); }, 5000); timer.unref();
|
|
29
|
+
},
|
|
30
|
+
async connected() {
|
|
31
|
+
const broker = snapshot.broker;
|
|
32
|
+
broker.state = "connected"; broker.connectedAt = new Date().toISOString();
|
|
33
|
+
broker.disconnectedAt = null; broker.reconnectAttempts = 0;
|
|
34
|
+
await write();
|
|
35
|
+
},
|
|
26
36
|
onStatus(event) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
const broker = snapshot.broker;
|
|
38
|
+
const at = new Date().toISOString();
|
|
39
|
+
if (event.type === "connect_error") {
|
|
40
|
+
const reason = safeReason(event.data?.reason);
|
|
41
|
+
broker.state = reason === "broker.unauthorized" ? "unauthorized" : "disconnected";
|
|
42
|
+
broker.lastError = reason;
|
|
43
|
+
broker.lastErrorAt = at;
|
|
44
|
+
if (broker.disconnectedAt === null) broker.disconnectedAt = at;
|
|
45
|
+
log("warn", "Server connection failed; will retry", { reason });
|
|
46
|
+
} else if (event.type === "disconnect") {
|
|
47
|
+
broker.state = "disconnected";
|
|
48
|
+
broker.lastError = "broker.disconnected";
|
|
49
|
+
broker.lastErrorAt = at;
|
|
50
|
+
if (broker.disconnectedAt === null) broker.disconnectedAt = at;
|
|
51
|
+
broker.reconnectAttempts = 0;
|
|
52
|
+
} else if (event.type === "reconnecting") {
|
|
53
|
+
// #276 — every failed reconnect attempt is counted here; the broker logs them rate-limited.
|
|
54
|
+
if (broker.state === "connected") broker.state = "disconnected";
|
|
55
|
+
if (broker.disconnectedAt === null) {
|
|
56
|
+
broker.disconnectedAt = typeof event.data?.disconnectedAt === "string" ? event.data.disconnectedAt : at;
|
|
57
|
+
}
|
|
58
|
+
broker.reconnectAttempts = Number.isSafeInteger(event.data?.attempts) ? event.data.attempts : broker.reconnectAttempts + 1;
|
|
59
|
+
broker.lastError = "broker.reconnecting";
|
|
60
|
+
broker.lastErrorAt = at;
|
|
61
|
+
} else if (event.type === "error" || event.type === "staleConnection") {
|
|
62
|
+
const reason = safeReason(event.data?.reason);
|
|
63
|
+
broker.lastError = reason;
|
|
64
|
+
broker.lastErrorAt = at;
|
|
65
|
+
if (reason === "broker.unauthorized" && broker.state !== "connected") broker.state = "unauthorized";
|
|
66
|
+
} else if (event.type === "closed") {
|
|
67
|
+
// The client gave up (auth abort, attempts exhausted): nothing reconnects this process.
|
|
68
|
+
const reason = safeReason(event.data?.reason);
|
|
69
|
+
broker.state = "closed";
|
|
70
|
+
broker.lastError = reason;
|
|
71
|
+
broker.lastErrorAt = at;
|
|
72
|
+
if (broker.disconnectedAt === null) broker.disconnectedAt = at;
|
|
73
|
+
log("error", "Server connection closed for good; it will not reconnect on its own", { reason });
|
|
31
74
|
} else if (event.type === "reconnect") {
|
|
32
|
-
|
|
33
|
-
|
|
75
|
+
broker.state = "connected";
|
|
76
|
+
broker.connectedAt = at;
|
|
77
|
+
broker.disconnectedAt = null;
|
|
78
|
+
broker.reconnectAttempts = 0;
|
|
34
79
|
}
|
|
35
|
-
|
|
80
|
+
return write();
|
|
81
|
+
},
|
|
82
|
+
/** Read-only copy of the broker part of the snapshot. */
|
|
83
|
+
broker() { return { ...snapshot.broker }; },
|
|
84
|
+
/** Milliseconds since the server link was lost; 0 while connected. */
|
|
85
|
+
disconnectedForMs(now = Date.now()) {
|
|
86
|
+
const broker = snapshot.broker;
|
|
87
|
+
if (broker.state === "connected" || !broker.disconnectedAt) return 0;
|
|
88
|
+
return Math.max(0, now - Date.parse(broker.disconnectedAt));
|
|
36
89
|
},
|
|
37
90
|
observeLog(level, message, data) {
|
|
91
|
+
// This verdict was already committed to its message row. Let status follow
|
|
92
|
+
// that row (including an explicit CLI dismissal), not a sticky runtime fault.
|
|
93
|
+
if (message === 'WakeMonitor wake dead-lettered' && data?.error === 'accepted-turn-unobservable') return;
|
|
38
94
|
// Preserve a monitor crash even when SQLite could not record the failed wake.
|
|
39
95
|
// Never persist hook commands, stdout, message text or arbitrary error strings.
|
|
40
96
|
if ((level === "error" || level === "fatal") && message.startsWith("WakeMonitor")) {
|
|
@@ -43,6 +99,6 @@ export function createDaemonObservation({ dataDir, storePath, agentId, wake, log
|
|
|
43
99
|
return write();
|
|
44
100
|
}
|
|
45
101
|
},
|
|
46
|
-
stop() { if (timer) clearInterval(timer); },
|
|
102
|
+
async stop() { if (timer) clearInterval(timer); await writes; },
|
|
47
103
|
};
|
|
48
104
|
}
|