mixdog 0.9.83 → 0.9.85

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 (30) hide show
  1. package/package.json +1 -1
  2. package/scripts/channel-daemon-stub.mjs +2 -1
  3. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +30 -3
  4. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +38 -4
  5. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +48 -32
  6. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +25 -14
  7. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +8 -2
  8. package/src/runtime/agent/orchestrator/session/store/listing.mjs +38 -1
  9. package/src/runtime/agent/orchestrator/session/store.mjs +1 -0
  10. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +44 -73
  11. package/src/runtime/channels/lib/owned-runtime.mjs +6 -8
  12. package/src/runtime/channels/lib/parent-bridge.mjs +27 -15
  13. package/src/runtime/channels/lib/worker-ipc.mjs +17 -4
  14. package/src/runtime/memory/index.mjs +14 -5
  15. package/src/runtime/memory/lib/agent-ipc.mjs +11 -12
  16. package/src/runtime/shared/atomic-file.mjs +51 -24
  17. package/src/runtime/shared/err-text.mjs +1 -0
  18. package/src/runtime/shared/safe-ipc-send.mjs +34 -0
  19. package/src/session-runtime/lifecycle-api.mjs +9 -3
  20. package/src/session-runtime/runtime-core.mjs +5 -1
  21. package/src/standalone/channel-daemon.mjs +2 -1
  22. package/src/standalone/channel-worker.mjs +21 -19
  23. package/src/standalone/memory-runtime-proxy.mjs +98 -23
  24. package/src/tui/app/slash-dispatch.mjs +23 -13
  25. package/src/tui/app/theme-effort-pickers.mjs +3 -1
  26. package/src/tui/dist/index.mjs +115 -57
  27. package/src/tui/engine/live-share.mjs +2 -2
  28. package/src/tui/engine/session-flow.mjs +20 -10
  29. package/src/tui/engine/turn.mjs +28 -16
  30. package/src/tui/engine.mjs +17 -1
@@ -3,6 +3,7 @@ import { loadConfig, createBackend } from "./config.mjs";
3
3
  import { WebhookServer } from "./webhook.mjs";
4
4
  import { EventPipeline } from "./event-pipeline.mjs";
5
5
  import { startSnapshotWriter, stopSnapshotWriter } from "./status-snapshot.mjs";
6
+ import { safeIpcSend } from "../../shared/safe-ipc-send.mjs";
6
7
  import { initProviders } from "../../agent/orchestrator/providers/registry.mjs";
7
8
  import { loadConfig as loadAgentConfig } from "../../agent/orchestrator/config.mjs";
8
9
  import {
@@ -423,14 +424,11 @@ function armBridgeOwnershipTimer() {}
423
424
  // error-callback path of ERR_IPC_CHANNEL_CLOSED (channel closing between the
424
425
  // connected check and delivery). Log-and-continue — never crash the worker.
425
426
  function sendToParent(message) {
426
- if (!process.send || process.connected === false) return;
427
- try {
428
- process.send(message, undefined, undefined, err => {
429
- if (err) process.stderr.write(`[channels] parent IPC send failed: ${err?.message || err}\n`);
430
- });
431
- } catch (err) {
432
- process.stderr.write(`[channels] parent IPC send threw: ${err?.message || err}\n`);
433
- }
427
+ safeIpcSend(process, message, {
428
+ onError: (err) => {
429
+ try { process.stderr.write(`[channels] parent IPC send failed: ${err?.message || err}\n`); } catch {}
430
+ },
431
+ });
434
432
  }
435
433
  // Tell the parent session this worker ACQUIRED the bridge so it flips remote
436
434
  // mode ON (badge/transcript writer). Callers fire this only on a genuine
@@ -2,6 +2,8 @@
2
2
  // (behavior-preserving). Groups the notify-to-parent path and the
3
3
  // worker → parent → memory call bridge. Bound to live getters
4
4
  // (getInstanceId) so runtime identity stays consistent.
5
+ import { safeIpcSend } from '../../shared/safe-ipc-send.mjs';
6
+
5
7
  function normalizeChannelNotifyParams(method, params) {
6
8
  if (method === 'notifications/claude/channel' && params && params.meta) {
7
9
  const m = {};
@@ -24,7 +26,7 @@ function setChannelNotifySink(fn) {
24
26
  _notifySink = typeof fn === 'function' ? fn : null;
25
27
  }
26
28
 
27
- function createParentBridge({ getInstanceId }) {
29
+ function createParentBridge({ getInstanceId, ipcProcess = process }) {
28
30
  function sendNotifyToParent(method, params) {
29
31
  // CC channel schema requires meta: Record<string,string> (channelNotification.ts).
30
32
  // Coerce every meta value to string so a non-string (e.g. a Discord
@@ -37,15 +39,15 @@ function createParentBridge({ getInstanceId }) {
37
39
  catch (err) { try { process.stderr.write(`mixdog channels: notify sink failed: ${err && err.message || err}\n`); } catch {} }
38
40
  return;
39
41
  }
40
- if (!process.send) {
42
+ if (!ipcProcess?.send || ipcProcess.connected !== true) {
41
43
  try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
42
44
  return;
43
45
  }
44
- try {
45
- process.send({ type: 'notify', method, params: outParams });
46
- } catch (err) {
47
- try { process.stderr.write(`mixdog channels: notify IPC send failed: ${err && err.message || err}\n`); } catch {}
48
- }
46
+ safeIpcSend(ipcProcess, { type: 'notify', method, params: outParams }, {
47
+ onError: (err) => {
48
+ try { process.stderr.write(`mixdog channels: notify IPC send failed: ${err && err.message || err}\n`); } catch {}
49
+ },
50
+ });
49
51
  }
50
52
 
51
53
  // ── Memory worker bridge (worker → parent → memory) ─────────────────
@@ -56,10 +58,24 @@ function createParentBridge({ getInstanceId }) {
56
58
  // integrated into the main IPC handler below (not a second listener).
57
59
  const _memoryCallPending = new Map();
58
60
  let _memoryCallSeq = 0;
61
+ const failMemoryCall = (callId, error) => {
62
+ const pending = _memoryCallPending.get(callId);
63
+ if (!pending) return;
64
+ _memoryCallPending.delete(callId);
65
+ pending.reject(error instanceof Error ? error : new Error(String(error || 'memory_call IPC failed')));
66
+ };
67
+ const rejectAllMemoryCalls = (error) => {
68
+ for (const callId of Array.from(_memoryCallPending.keys())) failMemoryCall(callId, error);
69
+ };
70
+ ipcProcess?.once?.('disconnect', () => {
71
+ rejectAllMemoryCalls(new Error('memory_call parent IPC disconnected'));
72
+ });
59
73
 
60
74
  function callMemoryAction(action, args, timeoutMs) {
61
75
  return new Promise((resolve, reject) => {
62
- if (!process.send) return reject(new Error('not a worker process'));
76
+ if (!ipcProcess?.send || ipcProcess.connected !== true) {
77
+ return reject(new Error('not a connected worker process'));
78
+ }
63
79
  const callId = `mc_${getInstanceId()}_${++_memoryCallSeq}_${Math.random().toString(36).slice(2, 8)}`;
64
80
  const timer = setTimeout(() => {
65
81
  _memoryCallPending.delete(callId);
@@ -69,13 +85,9 @@ function createParentBridge({ getInstanceId }) {
69
85
  resolve: (v) => { clearTimeout(timer); resolve(v); },
70
86
  reject: (e) => { clearTimeout(timer); reject(e); },
71
87
  });
72
- try {
73
- process.send({ type: 'memory_call_request', callId, action, args: args || {} });
74
- } catch (e) {
75
- _memoryCallPending.delete(callId);
76
- clearTimeout(timer);
77
- reject(e);
78
- }
88
+ safeIpcSend(ipcProcess, { type: 'memory_call_request', callId, action, args: args || {} }, {
89
+ onError: (error) => failMemoryCall(callId, error),
90
+ });
79
91
  });
80
92
  }
81
93
 
@@ -1,4 +1,5 @@
1
1
  import { performance } from "perf_hooks";
2
+ import { safeIpcSend } from "../../shared/safe-ipc-send.mjs";
2
3
  // IPC worker-mode message loop extracted from channels/index.mjs
3
4
  // (behavior-preserving). Installs shutdown handlers, the parent->worker message
4
5
  // router, and the retrying start() bootstrap. Call once from the worker entry
@@ -19,6 +20,11 @@ export function runWorkerIpc({
19
20
  handleToolCallWithBridgeRetry,
20
21
  bootProfile,
21
22
  }) {
23
+ const sendToParent = (message) => safeIpcSend(process, message, {
24
+ onError: (error) => {
25
+ try { process.stderr.write(`[channels-worker] parent IPC send failed: ${error?.message || error}\n`); } catch {}
26
+ },
27
+ });
22
28
  // SIGTERM/SIGINT/IPC shutdown handler — mirrors src/memory/index.mjs pattern.
23
29
  // Cleans up in-progress webhook/scheduler state, removes runtime files, then exits.
24
30
  let _channelsStopInFlight = false
@@ -29,6 +35,10 @@ export function runWorkerIpc({
29
35
  return
30
36
  }
31
37
  _channelsStopInFlight = true
38
+ for (const controller of _inFlightChannelCalls.values()) {
39
+ try { controller.abort() } catch {}
40
+ }
41
+ _inFlightChannelCalls.clear()
32
42
  process.stderr.write(`[channels-worker] received ${sig} — shutting down cleanly\n`)
33
43
  _channelsForceExitTimer = setTimeout(() => {
34
44
  process.stderr.write(`[channels-worker] stop() timed out after 6000ms — forcing exit(2)\n`)
@@ -47,6 +57,7 @@ export function runWorkerIpc({
47
57
  }
48
58
  process.on('SIGTERM', () => _channelsShutdownHandler('SIGTERM'))
49
59
  process.on('SIGINT', () => _channelsShutdownHandler('SIGINT'))
60
+ process.once('disconnect', () => _channelsShutdownHandler('IPC:disconnect'))
50
61
 
51
62
  // Map of callId → AbortController for in-flight IPC calls.
52
63
  const _inFlightChannelCalls = new Map()
@@ -144,7 +155,7 @@ export function runWorkerIpc({
144
155
  entry.abort()
145
156
  _inFlightChannelCalls.delete(msg.callId)
146
157
  }
147
- process.send({ type: 'result', callId: msg.callId, error: 'cancelled' })
158
+ sendToParent({ type: 'result', callId: msg.callId, error: 'cancelled' })
148
159
  return
149
160
  }
150
161
  if (msg.type !== 'call' || !msg.callId) return
@@ -157,9 +168,9 @@ export function runWorkerIpc({
157
168
  } finally {
158
169
  _inFlightChannelCalls.delete(msg.callId)
159
170
  }
160
- process.send({ type: 'result', callId: msg.callId, result })
171
+ sendToParent({ type: 'result', callId: msg.callId, result })
161
172
  } catch (e) {
162
- process.send({ type: 'result', callId: msg.callId, error: e.message })
173
+ sendToParent({ type: 'result', callId: msg.callId, error: e.message })
163
174
  }
164
175
  })
165
176
  void (async () => {
@@ -174,7 +185,9 @@ export function runWorkerIpc({
174
185
  try {
175
186
  await start()
176
187
  bootProfile("worker:ready", { ms: (performance.now() - startedAt).toFixed(1), attempt })
177
- process.send({ type: 'ready' })
188
+ if (!sendToParent({ type: 'ready' })) {
189
+ void _channelsShutdownHandler('IPC:ready-send-failed')
190
+ }
178
191
  return
179
192
  } catch (e) {
180
193
  lastErr = e
@@ -73,6 +73,7 @@ import { listCore, addCore, editCore, deleteCore, compactCoreIds, listCoreCandid
73
73
  import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
74
74
  import { openTraceDatabase, closeTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertAgentCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
75
75
  import { updateJsonAtomicSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
76
+ import { safeIpcSend } from '../shared/safe-ipc-send.mjs'
76
77
  import { resolvePluginData, mixdogHome } from '../shared/plugin-paths.mjs'
77
78
  import { parsePeriod, formatTs, coreRecallTerms, normalizeRecallProjectScope, sessionRecallTerms, interleaveRawRows, renderEntryLines, renderSessionGroupedLines } from './lib/recall-format.mjs'
78
79
  import { readBody, sendJson, sendError, isLocalOrigin, normalizeCoreProjectId } from './lib/http-wire.mjs'
@@ -646,6 +647,14 @@ export { TOOL_DEFS, handleToolCall, buildSessionCoreMemoryPayload }
646
647
  export { MEMORY_INSTRUCTIONS_TEXT as instructions }
647
648
  export { acquireLock, releaseLock }
648
649
  export { cwdFromTranscriptPath }
650
+ function sendMemoryWorkerMessage(message) {
651
+ return safeIpcSend(process, message, {
652
+ onError: (error) => {
653
+ const code = error?.code ? ` code=${error.code}` : ''
654
+ __mixdogMemoryLog(`[memory-worker] IPC send failed${code}: ${error?.message || error}\n`)
655
+ },
656
+ })
657
+ }
649
658
  export async function init() {
650
659
  if (_initialized) return
651
660
  __mixdogMemoryLog(`[boot-time] tag=memory-init-start tMs=${Date.now()}\n`)
@@ -683,7 +692,7 @@ export async function init() {
683
692
  }
684
693
  if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
685
694
  __mixdogMemoryLog(`[boot-time] tag=memory-ready tMs=${Date.now()}\n`)
686
- process.send({ type: 'ready', port: boundPort })
695
+ sendMemoryWorkerMessage({ type: 'ready', port: boundPort })
687
696
  }
688
697
  __mixdogMemoryLog(`[memory-service] init() complete (entries unified mode, version=${PLUGIN_VERSION})\n`)
689
698
  touchDaemonIdleTimer('init')
@@ -888,7 +897,7 @@ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
888
897
  entry.cancelled = true
889
898
  entry.ac.abort()
890
899
  _inFlightCalls.delete(msg.callId)
891
- process.send({ type: 'result', callId: msg.callId, error: 'cancelled' })
900
+ sendMemoryWorkerMessage({ type: 'result', callId: msg.callId, error: 'cancelled' })
892
901
  }
893
902
  return
894
903
  }
@@ -902,9 +911,9 @@ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
902
911
  } finally {
903
912
  _inFlightCalls.delete(msg.callId)
904
913
  }
905
- if (!entry.cancelled) process.send({ type: 'result', callId: msg.callId, result })
914
+ if (!entry.cancelled) sendMemoryWorkerMessage({ type: 'result', callId: msg.callId, result })
906
915
  } catch (e) {
907
- if (!entry.cancelled) process.send({ type: 'result', callId: msg.callId, error: e.message })
916
+ if (!entry.cancelled) sendMemoryWorkerMessage({ type: 'result', callId: msg.callId, error: e.message })
908
917
  }
909
918
  })
910
919
  init().catch(e => {
@@ -929,7 +938,7 @@ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
929
938
  __mixdogMemoryLog(`[memory-worker] init failed: ${detail}\n`)
930
939
  // Signal degraded state to parent before exiting so it records the failure
931
940
  // rather than treating this as a normal pre-ready crash.
932
- try { process.send({ type: 'ready', degraded: true, error: detail.slice(0, 800) }) } catch {}
941
+ sendMemoryWorkerMessage({ type: 'ready', degraded: true, error: detail.slice(0, 800) })
933
942
  process.exit(1)
934
943
  })
935
944
  }
@@ -12,6 +12,7 @@
12
12
  * Uses a module-level pending map keyed by callId. Parent-side handler
13
13
  * lives in server.mjs spawnWorker's message listener.
14
14
  */
15
+ import { safeIpcSend } from '../../shared/safe-ipc-send.mjs'
15
16
 
16
17
  const pending = new Map()
17
18
  let listenerInstalled = false
@@ -28,6 +29,11 @@ function installListener() {
28
29
  if (msg.ok) entry.resolve(msg.result)
29
30
  else entry.reject(new Error(msg.error || 'agent_ipc_response error'))
30
31
  })
32
+ process.once('disconnect', () => {
33
+ const error = new Error('agent-ipc: parent IPC disconnected')
34
+ for (const [, entry] of pending) entry.reject(error)
35
+ pending.clear()
36
+ })
31
37
  }
32
38
 
33
39
  function nextCallId() {
@@ -61,9 +67,7 @@ export function callAgentDispatch(opts = {}, prompt) {
61
67
  if (!pending.has(callId)) return
62
68
  pending.delete(callId)
63
69
  if (process.connected) {
64
- try {
65
- process.send({ type: 'agent_ipc_cancel', callId }, undefined, {}, () => {})
66
- } catch {}
70
+ safeIpcSend(process, { type: 'agent_ipc_cancel', callId })
67
71
  }
68
72
  reject(new Error(`agent-ipc: timed out after ${timeoutMs}ms`))
69
73
  }, timeoutMs)
@@ -80,8 +84,7 @@ export function callAgentDispatch(opts = {}, prompt) {
80
84
  // Pass a write callback so a closed-channel write surfaces as an async
81
85
  // error here (rejecting this Promise) instead of bubbling to the process
82
86
  // 'error' event and crashing the daemon with ERR_IPC_CHANNEL_CLOSED.
83
- try {
84
- process.send({
87
+ safeIpcSend(process, {
85
88
  type: 'agent_ipc_request',
86
89
  callId,
87
90
  tool: 'agent_dispatch',
@@ -94,17 +97,13 @@ export function callAgentDispatch(opts = {}, prompt) {
94
97
  prompt: String(prompt ?? ''),
95
98
  timeout: timeoutMs,
96
99
  },
97
- }, undefined, {}, (err) => {
98
- if (!err) return
100
+ }, {
101
+ onError: (err) => {
99
102
  if (!pending.has(callId)) return
100
103
  pending.delete(callId)
101
104
  clearTimeout(timer)
102
105
  reject(err)
106
+ },
103
107
  })
104
- } catch (e) {
105
- pending.delete(callId)
106
- clearTimeout(timer)
107
- reject(e)
108
- }
109
108
  })
110
109
  }
@@ -11,6 +11,15 @@ import {
11
11
  unlinkSync,
12
12
  writeFileSync,
13
13
  } from 'fs';
14
+ import {
15
+ link as linkAsync,
16
+ mkdir as mkdirAsync,
17
+ open as openAsync,
18
+ readFile as readFileAsync,
19
+ rename as renameAsync,
20
+ unlink as unlinkAsync,
21
+ writeFile as writeFileAsync,
22
+ } from 'node:fs/promises';
14
23
  import { dirname, basename, join } from 'path';
15
24
  import { randomBytes } from 'crypto';
16
25
  import { execFile, execFileSync } from 'child_process';
@@ -76,6 +85,25 @@ export function renameWithRetrySync(src, dst, opts = {}) {
76
85
  throw lastErr;
77
86
  }
78
87
 
88
+ export async function renameWithRetry(src, dst, opts = {}) {
89
+ const backoffs = Array.isArray(opts.backoffs) && opts.backoffs.length > 0
90
+ ? opts.backoffs
91
+ : DEFAULT_BACKOFFS_MS;
92
+ let lastErr = null;
93
+ for (let attempt = 0; attempt <= backoffs.length; attempt++) {
94
+ try {
95
+ await renameAsync(src, dst);
96
+ return true;
97
+ } catch (err) {
98
+ lastErr = err;
99
+ if (!RETRY_CODES.has(err?.code) || attempt >= backoffs.length) break;
100
+ const jitter = Math.floor(Math.random() * Math.min(50, Math.max(1, backoffs[attempt])));
101
+ await _asyncSleep(backoffs[attempt] + jitter);
102
+ }
103
+ }
104
+ throw lastErr;
105
+ }
106
+
79
107
  export function withFileLockSync(lockPath, fn, opts = {}) {
80
108
  const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_LOCK_TIMEOUT_MS;
81
109
  const staleMs = Number.isFinite(opts.staleMs) ? opts.staleMs : 30000;
@@ -585,62 +613,60 @@ export function updateJsonAtomicSync(filePath, mutator, opts = {}) {
585
613
  }, opts);
586
614
  }
587
615
 
588
- // ── Async atomic file write (non-blocking secret ACL) ───────────────
589
- // Mirror of writeFileAtomicSync, but the owner-only ACL enforcement on the
590
- // temp and final path runs through the async icacls variant so a debounced
591
- // timer flush never blocks the event loop on a subprocess. The tiny
592
- // writeFileSync/renameSync of a small JSON payload stay synchronous (they are
593
- // not the hitch); only the icacls calls — the actual blockers — are awaited.
616
+ // ── Async atomic file write ─────────────────────────────────────────
617
+ // Mirror of writeFileAtomicSync using promise-based filesystem calls and async
618
+ // ACL enforcement. Large summary/pending-message JSON writes therefore never
619
+ // block the host event loop on read/write/fsync/rename.
594
620
  // The truncate renameFallback branch is intentionally omitted: it only ever
595
621
  // applied to non-secret writes (opts.secret !== true), and every async caller
596
622
  // here is a secret config write, so behavior is identical.
597
623
  export async function writeFileAtomicAsync(filePath, data, opts = {}) {
598
624
  const run = async () => {
599
625
  const dir = dirname(filePath);
600
- mkdirSync(dir, { recursive: true });
626
+ await mkdirAsync(dir, { recursive: true });
601
627
  const tmp = join(dir, `.${basename(filePath)}.${randomBytes(12).toString('hex')}.tmp`);
602
628
  try {
603
629
  const writeOpts = { encoding: opts.encoding || 'utf8', flag: 'wx', mode: opts.mode !== undefined ? opts.mode : 0o600 };
604
- writeFileSync(tmp, data, writeOpts);
630
+ await writeFileAsync(tmp, data, writeOpts);
605
631
  if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(tmp);
606
632
  if (opts.fsync !== false) {
607
633
  let fd = null;
608
634
  try {
609
- fd = openSync(tmp, 'r');
610
- fsyncSync(fd);
635
+ fd = await openAsync(tmp, 'r');
636
+ await fd.sync();
611
637
  } catch (err) {
612
638
  if (!['EPERM', 'ENOTSUP', 'EINVAL'].includes(err?.code)) throw err;
613
639
  } finally {
614
- try { if (fd !== null) closeSync(fd); } catch {}
640
+ try { if (fd !== null) await fd.close(); } catch {}
615
641
  }
616
642
  }
617
643
  if (opts.createOnly === true) {
618
644
  try {
619
- linkSync(tmp, filePath);
645
+ await linkAsync(tmp, filePath);
620
646
  } catch (err) {
621
- try { unlinkSync(tmp); } catch {}
647
+ try { await unlinkAsync(tmp); } catch {}
622
648
  if (err?.code === 'EEXIST') return false;
623
649
  throw err;
624
650
  }
625
- try { unlinkSync(tmp); } catch {}
651
+ try { await unlinkAsync(tmp); } catch {}
626
652
  } else {
627
- renameWithRetrySync(tmp, filePath, opts);
653
+ await renameWithRetry(tmp, filePath, opts);
628
654
  }
629
655
  if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(filePath);
630
656
  if (opts.fsyncDir === true) {
631
657
  let dfd = null;
632
658
  try {
633
- dfd = openSync(dir, 'r');
634
- fsyncSync(dfd);
659
+ dfd = await openAsync(dir, 'r');
660
+ await dfd.sync();
635
661
  } catch (err) {
636
662
  if (!['EPERM', 'ENOTSUP', 'EINVAL', 'EACCES'].includes(err?.code)) throw err;
637
663
  } finally {
638
- try { if (dfd !== null) closeSync(dfd); } catch {}
664
+ try { if (dfd !== null) await dfd.close(); } catch {}
639
665
  }
640
666
  }
641
667
  return true;
642
668
  } catch (err) {
643
- try { if (existsSync(tmp)) unlinkSync(tmp); } catch {}
669
+ try { await unlinkAsync(tmp); } catch {}
644
670
  throw err;
645
671
  }
646
672
  };
@@ -656,20 +682,21 @@ export function writeJsonAtomicAsync(filePath, value, opts = {}) {
656
682
 
657
683
  // Async read-modify-write. Same lock path (`${filePath}.lock`) and protocol
658
684
  // as updateJsonAtomicSync, so it is mutually exclusive with the sync variant.
659
- // The critical section itself is synchronous (readFileSync + writeJsonAtomicSync);
660
- // only the lock WAIT is async, so the event loop is never blocked on contention.
685
+ // The lock wait and filesystem critical section are both asynchronous. JSON
686
+ // parse/mutation/stringify remain synchronous CPU work, but no disk operation
687
+ // or rename retry sleeps on the host event loop.
661
688
  export async function updateJsonAtomic(filePath, mutator, opts = {}) {
662
689
  const { lock: _lock, ...writeOpts } = opts;
663
- return withFileLock(`${filePath}.lock`, () => {
690
+ return withFileLock(`${filePath}.lock`, async () => {
664
691
  let cur = null;
665
692
  try {
666
- cur = JSON.parse(readFileSync(filePath, 'utf8'));
693
+ cur = JSON.parse(await readFileAsync(filePath, 'utf8'));
667
694
  } catch {
668
695
  cur = null;
669
696
  }
670
697
  const next = mutator(cur);
671
698
  if (next === undefined) return cur;
672
- writeJsonAtomicSync(filePath, next, { ...writeOpts, lock: false });
699
+ await writeJsonAtomicAsync(filePath, next, { ...writeOpts, lock: false });
673
700
  return next;
674
701
  }, opts);
675
702
  }
@@ -146,6 +146,7 @@ export function presentErrorText(error, options = {}) {
146
146
  return 'Context too large.';
147
147
  }
148
148
  if (/\bcompact(?:ion)?\b.*\b(?:failed|error|overflow)\b|\b(?:failed|error)\b.*\bcompact(?:ion)?\b/i.test(text)) {
149
+ if (String(surface).toLowerCase().includes('compact')) return capText(text, max);
149
150
  return 'Compact failed.';
150
151
  }
151
152
 
@@ -0,0 +1,34 @@
1
+ function ipcUnavailableError() {
2
+ return Object.assign(new Error('IPC channel unavailable'), {
3
+ code: 'ERR_IPC_CHANNEL_CLOSED',
4
+ });
5
+ }
6
+
7
+ export function safeIpcSend(proc, message, { onError, onComplete } = {}) {
8
+ const report = (error) => {
9
+ if (!error) return;
10
+ try { onError?.(error); } catch {}
11
+ };
12
+ const complete = (error = null) => {
13
+ try { onComplete?.(error); } catch {}
14
+ };
15
+
16
+ if (!proc || typeof proc.send !== 'function' || proc.connected !== true) {
17
+ const error = ipcUnavailableError();
18
+ report(error);
19
+ complete(error);
20
+ return false;
21
+ }
22
+
23
+ try {
24
+ proc.send(message, undefined, {}, (error) => {
25
+ report(error);
26
+ complete(error || null);
27
+ });
28
+ return true;
29
+ } catch (error) {
30
+ report(error);
31
+ complete(error);
32
+ return false;
33
+ }
34
+ }
@@ -158,6 +158,12 @@ export function createLifecycleApi(deps) {
158
158
  return {
159
159
  async close(reason = 'cli-exit', options = {}) {
160
160
  const detach = options?.detach === true || options?.wait === false || options?.waitForExit === false;
161
+ // Desktop multi-engine hosts dispose idle engines while other sessions
162
+ // keep working in the SAME process. The background-work registries
163
+ // (shared background tasks, shell jobs, bash sessions) are
164
+ // process-global, so a non-exit dispose must leave them running instead
165
+ // of reaping every session's jobs. CLI exit paths never set this.
166
+ const keepBackgroundWork = options?.keepBackgroundWork === true;
161
167
  setCloseRequested(true);
162
168
  // Self-update now stages in the background and swaps on the next clean
163
169
  // launch (see staged-update.mjs) — nothing installs at shutdown. On a
@@ -218,7 +224,7 @@ export function createLifecycleApi(deps) {
218
224
  warmupTimers[timerKey] = null;
219
225
  }
220
226
  }
221
- try { cancelBackgroundTasks({ reason, notify: false }); } catch {}
227
+ try { if (!keepBackgroundWork) cancelBackgroundTasks({ reason, notify: false }); } catch {}
222
228
  const channelStop = channels.stop(reason, detach ? { waitForExit: false } : undefined);
223
229
  try { agentTool.closeAll(reason); } catch {}
224
230
  let mcpStop = null;
@@ -260,12 +266,12 @@ export function createLifecycleApi(deps) {
260
266
  invalidateContextStatusCache();
261
267
  notificationListeners?.clear?.();
262
268
  remoteStateListeners?.clear?.();
263
- const shellJobsStop = globalThis.__mixdogShellJobsRuntimeLoaded === true
269
+ const shellJobsStop = !keepBackgroundWork && globalThis.__mixdogShellJobsRuntimeLoaded === true
264
270
  ? import('../runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs')
265
271
  .then((mod) => mod?.shutdownShellJobs?.(reason, { sync: !detach }))
266
272
  .catch(() => {})
267
273
  : null;
268
- const bashSessionsStop = globalThis.__mixdogBashSessionRuntimeLoaded === true
274
+ const bashSessionsStop = !keepBackgroundWork && globalThis.__mixdogBashSessionRuntimeLoaded === true
269
275
  ? import('../runtime/agent/orchestrator/tools/bash-session.mjs')
270
276
  .then((mod) => mod?.shutdownBashSessions?.(reason))
271
277
  .catch(() => {})
@@ -845,7 +845,11 @@ export async function createMixdogSessionRuntime({
845
845
  if (name === 'recall' || name === 'memory' || name === 'search_memories') {
846
846
  const memoryMod = await getMemoryModule();
847
847
  if (!memoryMod?.handleToolCall) throw new Error('memory runtime is not available');
848
- return await memoryMod.handleToolCall(name, memoryToolArgsForCaller(args, callerCwd));
848
+ return await memoryMod.handleToolCall(
849
+ name,
850
+ memoryToolArgsForCaller(args, callerCwd),
851
+ callerCtx?.signal || session?.controller?.signal || null,
852
+ );
849
853
  }
850
854
  if (name === 'code_graph') {
851
855
  const codeGraphMod = await getCodeGraphModule();
@@ -23,6 +23,7 @@ import { performance } from 'node:perf_hooks';
23
23
  import { claimSingletonOwner, releaseSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
24
24
  import { setChannelNotifySink } from '../runtime/channels/lib/parent-bridge.mjs';
25
25
  import { setOwnerContext } from '../runtime/channels/lib/runtime-paths.mjs';
26
+ import { safeIpcSend } from '../runtime/shared/safe-ipc-send.mjs';
26
27
  import { createChannelDaemonTransport } from './channel-daemon-transport.mjs';
27
28
  import { createStandaloneMemoryRuntime } from './memory-runtime-proxy.mjs';
28
29
 
@@ -183,7 +184,7 @@ async function main() {
183
184
  // forever. process.connected gates the obvious-dead case; the send callback
184
185
  // swallows the async delivery error so it never reaches uncaughtException.
185
186
  if (process.connected && process.send) {
186
- try { process.send({ type: 'ready', port, token }, undefined, () => {}); } catch {}
187
+ safeIpcSend(process, { type: 'ready', port, token });
187
188
  }
188
189
  log(`ready port=${port} pid=${process.pid} in ${(performance.now() - startedAt).toFixed(0)}ms`);
189
190
 
@@ -10,6 +10,7 @@ import { scrubLoaderVars } from '../runtime/agent/orchestrator/tools/env-scrub.m
10
10
  import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
11
11
  import { attachToDaemon, readDaemonDiscovery, probeDaemonHealth } from './channel-daemon-client.mjs';
12
12
  import { claimSingletonOwner, releaseSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
13
+ import { safeIpcSend } from '../runtime/shared/safe-ipc-send.mjs';
13
14
  import { randomUUID } from 'node:crypto';
14
15
 
15
16
  const CHANNEL_TOOLS = new Set([
@@ -244,7 +245,9 @@ export function createStandaloneChannelWorker({
244
245
  // stuck send can't wedge the queue forever.
245
246
  inFlightWatchdog = setTimeout(() => finish(new Error('channels worker send timed out')), SEND_FLUSH_TIMEOUT_MS);
246
247
  inFlightWatchdog.unref?.();
247
- child.send(item.msg, (error) => finish(error));
248
+ safeIpcSend(child, item.msg, {
249
+ onComplete: (error) => finish(error),
250
+ });
248
251
  }
249
252
 
250
253
  function sendToChild(msg, cb) {
@@ -804,19 +807,20 @@ export function createStandaloneChannelWorker({
804
807
  const sendTimer = setTimeout(() => settle(true), 250);
805
808
  sendTimer.unref?.();
806
809
  target.once('exit', () => settle(true));
807
- try {
808
- target.send?.({ type: 'shutdown', reason }, () => {
810
+ safeIpcSend(target, { type: 'shutdown', reason }, {
811
+ onComplete: (error) => {
809
812
  try { target.disconnect?.(); } catch {}
810
- settle(true);
811
- });
812
- } catch {
813
- try { target.disconnect?.(); } catch {}
814
- // IPC unavailable: skip the grace window and escalate now.
815
- try {
816
- if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
817
- } catch {}
818
- settle(false);
819
- }
813
+ if (!error) {
814
+ settle(true);
815
+ return;
816
+ }
817
+ // IPC unavailable: skip the grace window and escalate now.
818
+ try {
819
+ if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
820
+ } catch {}
821
+ settle(false);
822
+ },
823
+ });
820
824
  });
821
825
  return stopPromise;
822
826
  }
@@ -847,13 +851,11 @@ export function createStandaloneChannelWorker({
847
851
  }, 5000);
848
852
  target.once('exit', () => finish(true));
849
853
  target.once('error', () => finish(false));
850
- try {
851
- target.send?.({ type: 'shutdown', reason }, () => {
854
+ safeIpcSend(target, { type: 'shutdown', reason }, {
855
+ onComplete: () => {
852
856
  try { target.disconnect?.(); } catch {}
853
- });
854
- } catch {
855
- try { target.disconnect?.(); } catch {}
856
- }
857
+ },
858
+ });
857
859
  });
858
860
  return stopPromise;
859
861
  }