mixdog 0.9.84 → 0.9.86

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 (91) hide show
  1. package/README.md +7 -1
  2. package/package.json +3 -2
  3. package/scripts/channel-daemon-stub.mjs +2 -1
  4. package/src/defaults/agents.json +12 -0
  5. package/src/defaults/skills/setup/SKILL.md +44 -12
  6. package/src/help.mjs +40 -9
  7. package/src/lib/keychain-cjs.cjs +11 -1
  8. package/src/rules/agent/43-title-agent.md +22 -0
  9. package/src/rules/shared/01-tool.md +3 -3
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
  11. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
  12. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
  13. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +30 -3
  14. package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
  15. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
  16. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +38 -4
  18. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
  19. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +183 -41
  20. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
  21. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +12 -2
  22. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
  23. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
  24. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
  25. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
  26. package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
  27. package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
  28. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
  29. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
  32. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +44 -73
  33. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  34. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
  35. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  36. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
  37. package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
  38. package/src/runtime/agent/orchestrator/tools/token-manifest.json +5 -0
  39. package/src/runtime/channels/lib/owned-runtime.mjs +6 -8
  40. package/src/runtime/channels/lib/parent-bridge.mjs +27 -15
  41. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
  42. package/src/runtime/channels/lib/worker-ipc.mjs +17 -4
  43. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  44. package/src/runtime/media/renditions.mjs +1 -1
  45. package/src/runtime/media/store.mjs +1 -1
  46. package/src/runtime/memory/index.mjs +14 -5
  47. package/src/runtime/memory/lib/agent-ipc.mjs +11 -12
  48. package/src/runtime/shared/atomic-file.mjs +76 -38
  49. package/src/runtime/shared/automation-attachments.mjs +3 -3
  50. package/src/runtime/shared/child-guardian.mjs +15 -5
  51. package/src/runtime/shared/child-spawn-gate.mjs +6 -12
  52. package/src/runtime/shared/err-text.mjs +1 -0
  53. package/src/runtime/shared/resource-admission.mjs +7 -3
  54. package/src/runtime/shared/safe-ipc-send.mjs +34 -0
  55. package/src/runtime/shared/turn-snapshot.mjs +0 -3
  56. package/src/session-runtime/lifecycle-api.mjs +43 -12
  57. package/src/session-runtime/prewarm.mjs +35 -0
  58. package/src/session-runtime/remote-control.mjs +8 -47
  59. package/src/session-runtime/remote-transcript.mjs +3 -7
  60. package/src/session-runtime/route-preparation.mjs +24 -0
  61. package/src/session-runtime/runtime-core.mjs +42 -86
  62. package/src/session-runtime/session-turn-api.mjs +9 -2
  63. package/src/session-runtime/workflow-agents-api.mjs +3 -0
  64. package/src/standalone/agent-shard/shard-child.mjs +300 -0
  65. package/src/standalone/agent-shard/shard-pool.mjs +442 -0
  66. package/src/standalone/agent-tool.mjs +222 -0
  67. package/src/standalone/channel-daemon-transport.mjs +126 -185
  68. package/src/standalone/channel-daemon.mjs +11 -11
  69. package/src/standalone/channel-worker.mjs +21 -19
  70. package/src/standalone/memory-runtime-proxy.mjs +98 -23
  71. package/src/tui/App.jsx +16 -9
  72. package/src/tui/app/channel-pickers.mjs +3 -8
  73. package/src/tui/app/route-pickers.mjs +117 -152
  74. package/src/tui/app/settings-picker.mjs +90 -89
  75. package/src/tui/app/slash-commands.mjs +7 -7
  76. package/src/tui/app/slash-dispatch.mjs +23 -58
  77. package/src/tui/app/theme-effort-pickers.mjs +3 -1
  78. package/src/tui/components/StatusLine.jsx +1 -1
  79. package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
  80. package/src/tui/dist/index.mjs +358 -380
  81. package/src/tui/engine/live-share.mjs +2 -2
  82. package/src/tui/engine/session-api-ext.mjs +24 -9
  83. package/src/tui/engine/session-flow.mjs +20 -10
  84. package/src/tui/engine/turn.mjs +28 -16
  85. package/src/tui/engine.mjs +24 -2
  86. package/src/tui/figures.mjs +0 -1
  87. package/src/ui/statusline-format.mjs +0 -1
  88. package/src/hooks/lib/permission-evaluator.cjs +0 -24
  89. package/src/lib/config-cjs.cjs +0 -61
  90. package/src/runtime/shared/launcher-control.mjs +0 -258
  91. package/src/runtime/shared/workspace-router.mjs +0 -259
@@ -16,7 +16,7 @@ const OMNI_TIMEOUT_MS = 600_000;
16
16
  const POLL_INTERVAL_MS = 8_000;
17
17
  const TOTAL_TIMEOUT_MS = 900_000;
18
18
 
19
- export function isOmniModel(model) {
19
+ function isOmniModel(model) {
20
20
  return /omni/i.test(String(model || ''));
21
21
  }
22
22
 
@@ -30,7 +30,7 @@ const EXTENSION_MIME = { '.webp': 'image/webp', '.png': 'image/png' };
30
30
  const POSTER_TIMEOUT_MS = 20_000;
31
31
  const MAX_POSTER_BYTES = 32 * 1024 * 1024;
32
32
 
33
- export const MEDIA_VARIANTS = Object.keys(SPECS);
33
+ const MEDIA_VARIANTS = Object.keys(SPECS);
34
34
 
35
35
  /** Spec for a variant name, or null when the caller asked for the original. */
36
36
  export function renditionSpec(variant) {
@@ -222,7 +222,7 @@ async function prewarmThumbs(assets) {
222
222
  await Promise.all(workers).catch(() => {});
223
223
  }
224
224
 
225
- export function getMediaAsset(id) {
225
+ function getMediaAsset(id) {
226
226
  ensureDirs();
227
227
  return readIndex().find((entry) => entry.id === id) || null;
228
228
  }
@@ -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;
@@ -90,10 +118,15 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
90
118
  } catch (err) {
91
119
  lastErr = err;
92
120
  if (!LOCK_WAIT_CODES.has(err?.code)) throw err;
93
- // try-once semantics: timeoutMs:0 makes a single openSync attempt and
94
- // never sleeps — on contention it throws ELOCKCONTENDED immediately so
95
- // the main loop is never blocked by Atomics.wait. No stale-reclaim,
96
- // since reclaim would require waiting on the guard.
121
+ // Reclaim is itself try-once: the guard is acquired atomically and never
122
+ // waited on. This lets timeoutMs:0 recover a dead/pidless corpse without
123
+ // weakening its no-sleep contract.
124
+ try {
125
+ if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
126
+ } catch {}
127
+ // try-once semantics: timeoutMs:0 makes one guarded reclaim attempt and
128
+ // never sleeps — live contention throws ELOCKCONTENDED immediately so
129
+ // the main loop is never blocked by Atomics.wait.
97
130
  if (timeoutMs <= 0) {
98
131
  const contErr = new Error(`atomic lock contended (try-once): ${lockPath} [${_describeLockHolder(lockPath)}]`);
99
132
  contErr.code = 'ELOCKCONTENDED';
@@ -102,9 +135,6 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
102
135
  }
103
136
  // Reclaim only when owner death is positively established. Age, an
104
137
  // unreadable owner, or a same-pid foreign token remain contended.
105
- try {
106
- if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
107
- } catch {}
108
138
  if (Date.now() >= deadline) break;
109
139
  const base = DEFAULT_BACKOFFS_MS[Math.min(attempt, DEFAULT_BACKOFFS_MS.length - 1)];
110
140
  const jitter = Math.floor(Math.random() * Math.min(75, Math.max(1, base)));
@@ -195,14 +225,19 @@ function _lockOwnedBySelf(lockPath) {
195
225
  //
196
226
  // Reclaim decision table (owner = identity recorded in the lock file):
197
227
  // owner pid DEAD (ESRCH) → reclaim
198
- // owner pid NULL/uncertain or same-pid foreign token → back off
228
+ // owner pid NULL/uncertain and lock age >= staleMs → reclaim
229
+ // owner pid NULL/uncertain and lock age < staleMs → back off
230
+ // same-pid foreign token → back off
199
231
  // owner token MISMATCH on guarded re-read (new holder took over) → back off
200
232
  // owner LIVE → back off regardless of age
201
233
  function _tryReclaimStaleLock(lockPath, staleMs) {
202
- try { statSync(lockPath); } catch { return false; }
234
+ let initial;
235
+ try { initial = statSync(lockPath); } catch { return false; }
203
236
  const owner = _readLockOwner(lockPath);
204
237
  const dead = owner.pid !== null && !_ownerIsLive(owner);
205
- if (!dead) return false;
238
+ const pidlessStale = owner.pid === null
239
+ && Date.now() - (Number(initial.mtimeMs) || 0) >= Math.max(0, staleMs);
240
+ if (!dead && !pidlessStale) return false;
206
241
  const reclaim = _tryAcquireReclaimGuard(lockPath, staleMs);
207
242
  if (reclaim === null) return false;
208
243
  try {
@@ -215,7 +250,9 @@ function _tryReclaimStaleLock(lockPath, staleMs) {
215
250
  // corpse — so we never yank a freshly-acquired live lock.
216
251
  if (curOwner.pid !== owner.pid || curOwner.token !== owner.token) return false;
217
252
  const curDead = curOwner.pid !== null && !_ownerIsLive(curOwner);
218
- if (curDead) {
253
+ const curPidlessStale = curOwner.pid === null
254
+ && Date.now() - (Number(cur.mtimeMs) || 0) >= Math.max(0, staleMs);
255
+ if (curDead || curPidlessStale) {
219
256
  try { unlinkSync(lockPath); return true; } catch { return false; }
220
257
  }
221
258
  return false;
@@ -279,6 +316,11 @@ export async function withFileLock(lockPath, fn, opts = {}) {
279
316
  } catch (err) {
280
317
  lastErr = err;
281
318
  if (!LOCK_WAIT_CODES.has(err?.code)) throw err;
319
+ // The guarded reclaim is non-blocking, so try-once callers can recover
320
+ // stale corpses while still returning immediately for a live holder.
321
+ try {
322
+ if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
323
+ } catch {}
282
324
  if (timeoutMs <= 0) {
283
325
  const contErr = new Error(`atomic lock contended (try-once): ${lockPath} [${_describeLockHolder(lockPath)}]`);
284
326
  contErr.code = 'ELOCKCONTENDED';
@@ -288,9 +330,6 @@ export async function withFileLock(lockPath, fn, opts = {}) {
288
330
  // Stale-lock reclaim (shared with the sync variant): dead owner pid,
289
331
  // a same-pid-but-foreign-token corpse from a reused pid, or a
290
332
  // pidless/empty corpse lock. A live foreign holder is never stolen.
291
- try {
292
- if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
293
- } catch {}
294
333
  if (Date.now() >= deadline) break;
295
334
  const base = DEFAULT_BACKOFFS_MS[Math.min(attempt, DEFAULT_BACKOFFS_MS.length - 1)];
296
335
  const jitter = Math.floor(Math.random() * Math.min(75, Math.max(1, base)));
@@ -585,62 +624,60 @@ export function updateJsonAtomicSync(filePath, mutator, opts = {}) {
585
624
  }, opts);
586
625
  }
587
626
 
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.
627
+ // ── Async atomic file write ─────────────────────────────────────────
628
+ // Mirror of writeFileAtomicSync using promise-based filesystem calls and async
629
+ // ACL enforcement. Large summary/pending-message JSON writes therefore never
630
+ // block the host event loop on read/write/fsync/rename.
594
631
  // The truncate renameFallback branch is intentionally omitted: it only ever
595
632
  // applied to non-secret writes (opts.secret !== true), and every async caller
596
633
  // here is a secret config write, so behavior is identical.
597
634
  export async function writeFileAtomicAsync(filePath, data, opts = {}) {
598
635
  const run = async () => {
599
636
  const dir = dirname(filePath);
600
- mkdirSync(dir, { recursive: true });
637
+ await mkdirAsync(dir, { recursive: true });
601
638
  const tmp = join(dir, `.${basename(filePath)}.${randomBytes(12).toString('hex')}.tmp`);
602
639
  try {
603
640
  const writeOpts = { encoding: opts.encoding || 'utf8', flag: 'wx', mode: opts.mode !== undefined ? opts.mode : 0o600 };
604
- writeFileSync(tmp, data, writeOpts);
641
+ await writeFileAsync(tmp, data, writeOpts);
605
642
  if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(tmp);
606
643
  if (opts.fsync !== false) {
607
644
  let fd = null;
608
645
  try {
609
- fd = openSync(tmp, 'r');
610
- fsyncSync(fd);
646
+ fd = await openAsync(tmp, 'r');
647
+ await fd.sync();
611
648
  } catch (err) {
612
649
  if (!['EPERM', 'ENOTSUP', 'EINVAL'].includes(err?.code)) throw err;
613
650
  } finally {
614
- try { if (fd !== null) closeSync(fd); } catch {}
651
+ try { if (fd !== null) await fd.close(); } catch {}
615
652
  }
616
653
  }
617
654
  if (opts.createOnly === true) {
618
655
  try {
619
- linkSync(tmp, filePath);
656
+ await linkAsync(tmp, filePath);
620
657
  } catch (err) {
621
- try { unlinkSync(tmp); } catch {}
658
+ try { await unlinkAsync(tmp); } catch {}
622
659
  if (err?.code === 'EEXIST') return false;
623
660
  throw err;
624
661
  }
625
- try { unlinkSync(tmp); } catch {}
662
+ try { await unlinkAsync(tmp); } catch {}
626
663
  } else {
627
- renameWithRetrySync(tmp, filePath, opts);
664
+ await renameWithRetry(tmp, filePath, opts);
628
665
  }
629
666
  if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(filePath);
630
667
  if (opts.fsyncDir === true) {
631
668
  let dfd = null;
632
669
  try {
633
- dfd = openSync(dir, 'r');
634
- fsyncSync(dfd);
670
+ dfd = await openAsync(dir, 'r');
671
+ await dfd.sync();
635
672
  } catch (err) {
636
673
  if (!['EPERM', 'ENOTSUP', 'EINVAL', 'EACCES'].includes(err?.code)) throw err;
637
674
  } finally {
638
- try { if (dfd !== null) closeSync(dfd); } catch {}
675
+ try { if (dfd !== null) await dfd.close(); } catch {}
639
676
  }
640
677
  }
641
678
  return true;
642
679
  } catch (err) {
643
- try { if (existsSync(tmp)) unlinkSync(tmp); } catch {}
680
+ try { await unlinkAsync(tmp); } catch {}
644
681
  throw err;
645
682
  }
646
683
  };
@@ -656,20 +693,21 @@ export function writeJsonAtomicAsync(filePath, value, opts = {}) {
656
693
 
657
694
  // Async read-modify-write. Same lock path (`${filePath}.lock`) and protocol
658
695
  // 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.
696
+ // The lock wait and filesystem critical section are both asynchronous. JSON
697
+ // parse/mutation/stringify remain synchronous CPU work, but no disk operation
698
+ // or rename retry sleeps on the host event loop.
661
699
  export async function updateJsonAtomic(filePath, mutator, opts = {}) {
662
700
  const { lock: _lock, ...writeOpts } = opts;
663
- return withFileLock(`${filePath}.lock`, () => {
701
+ return withFileLock(`${filePath}.lock`, async () => {
664
702
  let cur = null;
665
703
  try {
666
- cur = JSON.parse(readFileSync(filePath, 'utf8'));
704
+ cur = JSON.parse(await readFileAsync(filePath, 'utf8'));
667
705
  } catch {
668
706
  cur = null;
669
707
  }
670
708
  const next = mutator(cur);
671
709
  if (next === undefined) return cur;
672
- writeJsonAtomicSync(filePath, next, { ...writeOpts, lock: false });
710
+ await writeJsonAtomicAsync(filePath, next, { ...writeOpts, lock: false });
673
711
  return next;
674
712
  }, opts);
675
713
  }
@@ -7,11 +7,11 @@
7
7
  * {type:'file'}), so provider media normalization treats them identically.
8
8
  */
9
9
 
10
- export const AUTOMATION_ATTACHMENT_KINDS = ['image', 'text', 'pdf'];
10
+ const AUTOMATION_ATTACHMENT_KINDS = ['image', 'text', 'pdf'];
11
11
  export const MAX_AUTOMATION_ATTACHMENTS = 8;
12
12
  // Base64 total across image/pdf items; text totals separately.
13
- export const MAX_AUTOMATION_BINARY_TOTAL = 8_000_000;
14
- export const MAX_AUTOMATION_TEXT_TOTAL = 200_000;
13
+ const MAX_AUTOMATION_BINARY_TOTAL = 8_000_000;
14
+ const MAX_AUTOMATION_TEXT_TOTAL = 200_000;
15
15
 
16
16
  /** Validate + strip a stored/list attachments value to the persisted shape (or null). */
17
17
  export function normalizeAutomationAttachments(value) {
@@ -8,6 +8,20 @@ function positiveInt(value) {
8
8
  return Number.isInteger(n) && n > 0 ? n : null;
9
9
  }
10
10
 
11
+ export function childGuardianSpawnEnv(env = process.env) {
12
+ return {
13
+ PATH: env.PATH || '',
14
+ SystemRoot: env.SystemRoot || env.WINDIR || '',
15
+ WINDIR: env.WINDIR || env.SystemRoot || '',
16
+ // Desktop's engine runs inside an Electron utility process, where
17
+ // process.execPath points at electron.exe. Without Node mode Electron
18
+ // ignores --eval, starts a full app process, and the intended guardian
19
+ // loop never runs — every completed shell then leaks ~100 MB until the
20
+ // desktop exits.
21
+ ELECTRON_RUN_AS_NODE: '1',
22
+ };
23
+ }
24
+
11
25
  function guardianScript({ parentPid, childPid, childGroupPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
12
26
  return `
13
27
  const { spawnSync } = require('node:child_process');
@@ -88,11 +102,7 @@ export function startChildGuardian({
88
102
  }),
89
103
  ], {
90
104
  stdio: 'ignore',
91
- env: {
92
- PATH: process.env.PATH || '',
93
- SystemRoot: process.env.SystemRoot || process.env.WINDIR || '',
94
- WINDIR: process.env.WINDIR || process.env.SystemRoot || '',
95
- },
105
+ env: childGuardianSpawnEnv(),
96
106
  ...detachedSpawnOpts,
97
107
  });
98
108
  guardian.unref?.();
@@ -3,25 +3,19 @@
3
3
  // ── Module-global child-spawn semaphore ──────────────────────────────────
4
4
  //
5
5
  // Single-daemon premise: tool execution is in-process, so a module-level
6
- // singleton semaphore bounds the number of concurrent child processes (rg,
7
- // mixdog-graph, …) across ALL agents/workers in this daemon. The goal is to
8
- // absorb the over-saturation tail — when N agents fire grep/code_graph at
9
- // once each child no longer fans out across every core, and the rg 20s
10
- // deadline stops tripping en masse — WITHOUT serializing: up to the cap run
11
- // concurrently, only the overflow queues and drains as slots free.
6
+ // singleton semaphore CAN bound the number of concurrent child processes (rg,
7
+ // mixdog-graph, …) across ALL agents/workers in this daemon. By default the
8
+ // gate is UNBOUNDED — parallel waves must never queue behind it — and it only
9
+ // becomes a real cap when MIXDOG_CHILD_SPAWN_MAX_INFLIGHT is set explicitly.
12
10
  //
13
11
  // IMPORTANT: this is a resource-control knob and is deliberately NOT exposed
14
12
  // on any tool JSON schema / tool parameter surface. The only tuning surface
15
- // is the internal constant + the MIXDOG_CHILD_SPAWN_MAX_INFLIGHT env override.
16
-
17
- import os from 'node:os';
13
+ // is the MIXDOG_CHILD_SPAWN_MAX_INFLIGHT env override.
18
14
 
19
15
  function _defaultMaxInflight() {
20
16
  const override = Number(process.env.MIXDOG_CHILD_SPAWN_MAX_INFLIGHT);
21
17
  if (Number.isFinite(override) && override >= 1) return Math.floor(override);
22
- let cpus = 0;
23
- try { cpus = os.cpus()?.length || 0; } catch { cpus = 0; }
24
- return Math.max(4, cpus || 4);
18
+ return Infinity;
25
19
  }
26
20
 
27
21
  const MAX_INFLIGHT = _defaultMaxInflight();
@@ -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
 
@@ -4,10 +4,14 @@ import { requestMemoryPressureSnapshot } from './memory-snapshot.mjs';
4
4
 
5
5
  const MB = 1024 * 1024;
6
6
 
7
+ // Concurrency defaults are UNBOUNDED by design: parallel agent/shell waves
8
+ // must never serialize behind an admission queue. The only default guards are
9
+ // the memory-pressure checks below; hard caps exist solely as opt-in env
10
+ // overrides (MIXDOG_MAX_CONCURRENT_AGENTS / _SHELLS / _HIGH_LOAD).
7
11
  export const RESOURCE_ADMISSION_DEFAULTS = Object.freeze({
8
- maxAgents: 4,
9
- maxShells: 4,
10
- maxHighLoad: 6,
12
+ maxAgents: Infinity,
13
+ maxShells: Infinity,
14
+ maxHighLoad: Infinity,
11
15
  maxQueue: 32,
12
16
  minFreeMemoryMb: 1024,
13
17
  maxRssMb: 3072,
@@ -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
+ }
@@ -47,9 +47,6 @@ function publicAgentReviews(sessionId) {
47
47
  return [...turn.agents.values()].map((review) => ({ ...review }));
48
48
  }
49
49
 
50
- /** Compatibility no-op: apply_patch tracking has no project warmup cost. */
51
- export function prewarmTurnSnapshot() {}
52
-
53
50
  /** Start a new user turn and invalidate the prior turn's child review group. */
54
51
  export async function beginTurnSnapshot(_worktree, sessionId) {
55
52
  const ownerSessionId = clean(sessionId);