mixdog 0.9.94 → 0.9.95

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 (161) hide show
  1. package/NOTICE.md +72 -0
  2. package/package.json +16 -6
  3. package/scripts/lib/isolated-root-cleanup.mjs +19 -0
  4. package/scripts/run-suite.mjs +100 -0
  5. package/src/lib/rules-builder.cjs +4 -3
  6. package/src/output-styles/detailed.md +13 -15
  7. package/src/output-styles/extreme-minimal.md +7 -11
  8. package/src/output-styles/minimal.md +4 -7
  9. package/src/output-styles/simple.md +11 -13
  10. package/src/rules/lead/01-general.md +1 -2
  11. package/src/rules/lead/lead-brief.md +4 -5
  12. package/src/rules/lead/lead-tool.md +3 -2
  13. package/src/rules/shared/01-tool.md +28 -34
  14. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +1 -1
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  17. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -3
  19. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  20. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  24. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  28. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +10 -10
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  32. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  33. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -15
  34. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +12 -32
  35. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  36. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  37. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  38. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -0
  39. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  40. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  41. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  42. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +7 -4
  43. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +40 -13
  46. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  48. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +5 -9
  49. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +3 -3
  50. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -6
  51. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +22 -0
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +6 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  56. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +14 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +1 -1
  63. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +3 -4
  64. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +3 -3
  65. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +12 -0
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -3
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +35 -5
  68. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  69. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -2
  70. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +3 -1
  71. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +53 -12
  72. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  73. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  74. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  75. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  78. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +148 -15
  79. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +5 -1
  80. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +109 -13
  81. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  82. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -5
  83. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +4 -0
  84. package/src/runtime/channels/backends/discord.mjs +5 -14
  85. package/src/runtime/channels/backends/telegram.mjs +0 -5
  86. package/src/runtime/channels/lib/inbound-handler.mjs +0 -1
  87. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  88. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  89. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  90. package/src/runtime/media/renditions.mjs +21 -2
  91. package/src/runtime/memory/tool-defs.mjs +6 -6
  92. package/src/runtime/shared/atomic-file.mjs +53 -0
  93. package/src/runtime/shared/background-tasks.mjs +6 -3
  94. package/src/runtime/shared/resource-admission.mjs +40 -1
  95. package/src/runtime/shared/tool-status.mjs +10 -1
  96. package/src/runtime/shared/tool-surface.mjs +7 -7
  97. package/src/runtime/shared/turn-snapshot.mjs +385 -21
  98. package/src/runtime/shared/turn-worktree-snapshot.mjs +540 -0
  99. package/src/session-runtime/context-status.mjs +9 -3
  100. package/src/session-runtime/lifecycle-api.mjs +35 -5
  101. package/src/session-runtime/mcp-glue.mjs +11 -6
  102. package/src/session-runtime/provider-models.mjs +94 -43
  103. package/src/session-runtime/runtime-core.mjs +46 -8
  104. package/src/session-runtime/runtime-tunables.mjs +4 -0
  105. package/src/session-runtime/self-update.mjs +33 -4
  106. package/src/session-runtime/session-lifecycle.mjs +2 -0
  107. package/src/session-runtime/session-text.mjs +2 -1
  108. package/src/session-runtime/session-turn-api.mjs +106 -22
  109. package/src/session-runtime/workflow.mjs +11 -4
  110. package/src/standalone/agent-tool.mjs +4 -4
  111. package/src/standalone/backend-daemon.mjs +570 -0
  112. package/src/standalone/channel-daemon-transport.mjs +141 -1
  113. package/src/standalone/channel-worker.mjs +3 -2
  114. package/src/standalone/engine-daemon-client.mjs +894 -0
  115. package/src/standalone/engine-daemon-local-bridge.mjs +20 -0
  116. package/src/standalone/engine-daemon-protocol.mjs +33 -0
  117. package/src/standalone/engine-daemon-service.mjs +864 -0
  118. package/src/standalone/engine-daemon-transport.mjs +603 -0
  119. package/src/tui/App.jsx +62 -47
  120. package/src/tui/app/app-format.mjs +4 -2
  121. package/src/tui/app/app-view.jsx +5 -0
  122. package/src/tui/app/channel-pickers.mjs +7 -6
  123. package/src/tui/app/core-memory-picker.mjs +4 -4
  124. package/src/tui/app/extension-pickers.mjs +20 -18
  125. package/src/tui/app/maintenance-pickers.mjs +27 -27
  126. package/src/tui/app/onboarding-steps.mjs +23 -18
  127. package/src/tui/app/prompt-submit.mjs +13 -2
  128. package/src/tui/app/route-pickers.mjs +13 -8
  129. package/src/tui/app/settings-picker.mjs +55 -58
  130. package/src/tui/app/slash-dispatch.mjs +32 -28
  131. package/src/tui/app/transcript-window.mjs +19 -0
  132. package/src/tui/app/usage-context-panels.mjs +22 -7
  133. package/src/tui/app/use-mouse-input.mjs +25 -3
  134. package/src/tui/app/use-prompt-queue-history.mjs +31 -16
  135. package/src/tui/app/use-transcript-scroll.mjs +30 -8
  136. package/src/tui/app/use-transcript-window.mjs +15 -0
  137. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  138. package/src/tui/components/PromptInput.jsx +10 -0
  139. package/src/tui/components/Spinner.jsx +89 -95
  140. package/src/tui/components/TextEntryPanel.jsx +14 -0
  141. package/src/tui/components/ToolExecution.jsx +2 -2
  142. package/src/tui/dist/index.mjs +1534 -9801
  143. package/src/tui/engine/agent-job-feed.mjs +2 -2
  144. package/src/tui/engine/live-share.mjs +76 -3
  145. package/src/tui/engine/session-api-ext.mjs +23 -1
  146. package/src/tui/engine/session-api.mjs +81 -41
  147. package/src/tui/engine/session-flow.mjs +43 -4
  148. package/src/tui/engine/tool-card-results.mjs +6 -0
  149. package/src/tui/engine/turn.mjs +8 -1
  150. package/src/tui/engine-local-session.mjs +1108 -0
  151. package/src/tui/engine.mjs +16 -1065
  152. package/src/tui/index.jsx +40 -2
  153. package/src/tui/markdown/stream-fence.mjs +1 -1
  154. package/src/tui/spinner-meta.mjs +80 -0
  155. package/src/tui/spinner-verbs.mjs +35 -0
  156. package/src/ui/statusline-segments.mjs +43 -10
  157. package/src/ui/statusline.mjs +10 -1
  158. package/scripts/tmp-cdp-errors.mjs +0 -41
  159. package/scripts/tmp-cdp-inspect.mjs +0 -41
  160. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  161. package/src/standalone/channel-daemon.mjs +0 -226
@@ -29,6 +29,7 @@ import {
29
29
  shellJobEnforcedPath,
30
30
  shellJobGuardianReceiptPath,
31
31
  resolveJobOwnerHostPid,
32
+ resolveJobOwnerSessionId,
32
33
  trimShellJobSpill,
33
34
  writeShellJobDetail,
34
35
  readShellJobDetail,
@@ -563,10 +564,24 @@ export function endShellJobWait(jobId) {
563
564
  export function clearShellJobNotifyCtx(jobId) {
564
565
  jobNotifyCtxByJobId.delete(jobId);
565
566
  }
566
- export function shutdownShellJobs(reason = 'runtime-close', { sync = false } = {}) {
567
- const livePids = [..._liveJobPids];
568
- const jobIds = new Set([..._liveJobIdsByPid.values()].filter(Boolean));
569
- const watcherJobIds = [...backgroundShellJobWatchers.keys()];
567
+ // scope: reap only the jobs a single session owns (`{ ownerSessionId }`).
568
+ // Omitted => the process-wide sweep, which is correct ONLY on a real process
569
+ // exit: the registries here are process-global, so an unscoped sweep run by
570
+ // one engine's dispose killed other sessions' live jobs (user report).
571
+ // A scope without an owner id matches nothing — never everything.
572
+ export function shutdownShellJobs(reason = 'runtime-close', { sync = false, scope = null } = {}) {
573
+ const scoped = Boolean(scope);
574
+ const scopeSessionId = scoped ? String(scope.ownerSessionId || '') : '';
575
+ if (scoped && !scopeSessionId) return { killed: 0, cancelledJobs: 0, cancelledWatchers: 0 };
576
+ const ownsJob = (jobId) => {
577
+ if (!scoped) return true;
578
+ if (!jobId) return false;
579
+ try { return String(readShellJobDetail(jobId)?.ownerSessionId || '') === scopeSessionId; }
580
+ catch { return false; }
581
+ };
582
+ const livePids = [..._liveJobPids].filter((pid) => ownsJob(_liveJobIdsByPid.get(pid)));
583
+ const jobIds = new Set([..._liveJobIdsByPid.values()].filter((jobId) => jobId && ownsJob(jobId)));
584
+ const watcherJobIds = [...backgroundShellJobWatchers.keys()].filter((jobId) => ownsJob(jobId));
570
585
  for (const jobId of watcherJobIds) {
571
586
  jobIds.add(jobId);
572
587
  try { cancelBackgroundShellJobWatch(jobId); } catch { /* ignore */ }
@@ -576,6 +591,18 @@ export function shutdownShellJobs(reason = 'runtime-close', { sync = false } = {
576
591
  for (const jobId of jobIds) {
577
592
  try { if (markShellJobCancelledByShutdown(jobId, reason)) marked += 1; } catch { /* ignore */ }
578
593
  }
594
+ if (scoped) {
595
+ // Per-job teardown only: another session's watchers, safety pollers and
596
+ // notify contexts must survive this dispose so its completions still fire.
597
+ for (const jobId of jobIds) {
598
+ unpersistedTerminalJobs.delete(jobId);
599
+ backgroundShellJobWatchers.delete(jobId);
600
+ clearShellTaskSafetyNet(jobId);
601
+ jobNotifyCtxByJobId.delete(jobId);
602
+ jobWaitWaiterCountByJobId.delete(jobId);
603
+ }
604
+ return { killed: livePids.length, cancelledJobs: marked, cancelledWatchers: watcherJobIds.length };
605
+ }
579
606
  unpersistedTerminalJobs.clear();
580
607
  backgroundShellJobWatchers.clear();
581
608
  for (const jobId of [...shellTaskSafetyPollers.keys()]) clearShellTaskSafetyNet(jobId);
@@ -780,7 +807,7 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
780
807
  // depends on holds for adopted jobs exactly as it does for staged wrappers.
781
808
  // This function only allocates the jobId/paths and writes the initial
782
809
  // 'running' detail.
783
- export function adoptForegroundShellJob({ command, cwd, pid, timeoutMs, mergeStderr, stdoutPath, stderrPath, clientHostPid }) {
810
+ export function adoptForegroundShellJob({ command, cwd, pid, timeoutMs, mergeStderr, stdoutPath, stderrPath, clientHostPid, ownerSessionId }) {
784
811
  const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
785
812
  const exitPath = shellJobExitPath(jobId);
786
813
  const donePath = shellJobDonePath(jobId);
@@ -805,6 +832,9 @@ export function adoptForegroundShellJob({ command, cwd, pid, timeoutMs, mergeStd
805
832
  // dispatching terminal's claude.exe pid (resolveJobOwnerHostPid falls
806
833
  // back to the single-client env only when unset).
807
834
  ownerHostPid: resolveJobOwnerHostPid(clientHostPid),
835
+ // Dispatching session (see resolveJobOwnerSessionId): a pane counts
836
+ // only the jobs its own session started.
837
+ ownerSessionId: resolveJobOwnerSessionId(ownerSessionId),
808
838
  startedAt: new Date().toISOString(),
809
839
  };
810
840
  writeShellJobDetail(detail);
@@ -14,6 +14,9 @@ const readFilesByScope = new Map(); // scope → Map(fullPath → { mtimeMs, siz
14
14
  // apply_patch call may reuse a path correction proven by a read in the live
15
15
  // session, but must never inherit a guessed redirect from another process.
16
16
  const readRedirectsByScope = new Map(); // scope → Map(requestedPathKey → targetPath)
17
+ export const READ_SNAPSHOT_SCOPE_CACHE_LIMIT = 32;
18
+ export const READ_SNAPSHOT_FILES_PER_SCOPE_LIMIT = 1024;
19
+ export const READ_SNAPSHOT_REDIRECTS_PER_SCOPE_LIMIT = 256;
17
20
 
18
21
  function snapshotPathKey(fullPath) {
19
22
  const value = String(fullPath || '');
@@ -166,6 +169,69 @@ function persistScopeSync(scopeKey, { exitDrain = false } = {}) {
166
169
  } catch {}
167
170
  }
168
171
 
172
+ export function releaseReadSnapshotScope(scope, {
173
+ deletePersisted = false,
174
+ persist = !deletePersisted,
175
+ } = {}) {
176
+ const scopeKey = readScopeKey(scope);
177
+ if (scopeKey === null) return false;
178
+ const timer = persistPending.get(scopeKey);
179
+ if (timer) {
180
+ try { clearTimeout(timer); } catch {}
181
+ persistPending.delete(scopeKey);
182
+ }
183
+ if (persist && readFilesByScope.has(scopeKey)) {
184
+ try { persistScopeSync(scopeKey, { exitDrain: true }); } catch {}
185
+ }
186
+ const released = readFilesByScope.delete(scopeKey)
187
+ || readRedirectsByScope.has(scopeKey)
188
+ || scopeHydrated.has(scopeKey);
189
+ readRedirectsByScope.delete(scopeKey);
190
+ scopeHydrated.delete(scopeKey);
191
+ if (deletePersisted) {
192
+ const path = snapshotScopeFilePath(scopeKey);
193
+ try { if (path && existsSync(path)) unlinkSync(path); } catch {}
194
+ }
195
+ return released;
196
+ }
197
+
198
+ function pruneReadSnapshotScopes(protectedScopeKey) {
199
+ while (readFilesByScope.size > READ_SNAPSHOT_SCOPE_CACHE_LIMIT) {
200
+ let oldest = readFilesByScope.keys().next().value;
201
+ if (oldest === protectedScopeKey) {
202
+ oldest = [...readFilesByScope.keys()].find((key) => key !== protectedScopeKey);
203
+ }
204
+ if (oldest === undefined) break;
205
+ releaseReadSnapshotScope(oldest);
206
+ }
207
+ }
208
+
209
+ function dropRedirectsToPath(scopeKey, fullPath) {
210
+ const redirects = readRedirectsByScope.get(scopeKey);
211
+ if (!redirects) return;
212
+ const droppedKey = snapshotPathKey(fullPath);
213
+ for (const [requestedKey, targetPath] of redirects) {
214
+ if (requestedKey === droppedKey || snapshotPathKey(targetPath) === droppedKey) {
215
+ redirects.delete(requestedKey);
216
+ }
217
+ }
218
+ if (redirects.size === 0) readRedirectsByScope.delete(scopeKey);
219
+ }
220
+
221
+ export function rememberReadSnapshot(fullPath, snapshot, scope, knownReadFiles = null) {
222
+ const scopeKey = readScopeKey(scope);
223
+ const readFiles = knownReadFiles || readFilesForScope(scope);
224
+ readFiles.delete(fullPath);
225
+ readFiles.set(fullPath, snapshot);
226
+ while (readFiles.size > READ_SNAPSHOT_FILES_PER_SCOPE_LIMIT) {
227
+ const oldest = readFiles.keys().next().value;
228
+ if (oldest === undefined) break;
229
+ readFiles.delete(oldest);
230
+ if (scopeKey !== null) dropRedirectsToPath(scopeKey, oldest);
231
+ }
232
+ return readFiles;
233
+ }
234
+
169
235
  export function deleteReadSnapshotPathEverywhere(fullPath) {
170
236
  for (const [scopeKey, readFiles] of readFilesByScope.entries()) {
171
237
  if (readFiles.delete(fullPath)) scheduleScopePersist(scopeKey);
@@ -241,7 +307,13 @@ export function recordReadPathRedirect(requestedFullPath, targetFullPath, scope)
241
307
  redirects = new Map();
242
308
  readRedirectsByScope.set(scopeKey, redirects);
243
309
  }
310
+ redirects.delete(snapshotPathKey(requested));
244
311
  redirects.set(snapshotPathKey(requested), target);
312
+ while (redirects.size > READ_SNAPSHOT_REDIRECTS_PER_SCOPE_LIMIT) {
313
+ const oldest = redirects.keys().next().value;
314
+ if (oldest === undefined) break;
315
+ redirects.delete(oldest);
316
+ }
245
317
  return true;
246
318
  }
247
319
 
@@ -278,6 +350,32 @@ export function readFilesForScope(scope) {
278
350
  readFiles = scopeHydrated.has(key) ? new Map() : loadScopeFromDisk(key);
279
351
  scopeHydrated.add(key);
280
352
  readFilesByScope.set(key, readFiles);
353
+ pruneReadSnapshotScopes(key);
354
+ } else {
355
+ readFilesByScope.delete(key);
356
+ readFilesByScope.set(key, readFiles);
281
357
  }
282
358
  return readFiles;
283
359
  }
360
+
361
+ export function _readSnapshotStoreStatsForTest(scope = null) {
362
+ const key = readScopeKey(scope);
363
+ const readFiles = key === null ? null : readFilesByScope.get(key);
364
+ return {
365
+ scopeCount: readFilesByScope.size,
366
+ hydratedScopeCount: scopeHydrated.size,
367
+ fileCount: readFiles?.size || 0,
368
+ redirectCount: key === null ? 0 : (readRedirectsByScope.get(key)?.size || 0),
369
+ pendingPersistCount: persistPending.size,
370
+ };
371
+ }
372
+
373
+ export function _resetReadSnapshotStoreForTest() {
374
+ for (const timer of persistPending.values()) {
375
+ try { clearTimeout(timer); } catch {}
376
+ }
377
+ persistPending.clear();
378
+ readFilesByScope.clear();
379
+ readRedirectsByScope.clear();
380
+ scopeHydrated.clear();
381
+ }
@@ -443,8 +443,8 @@ function _locatorBudgetFooter(toolName, args, keptLines, capBytes) {
443
443
  const target = targets[targetIndex] || targets[0] || '';
444
444
  const remaining = targets.length - targetIndex - 1;
445
445
  const capKb = Math.round((Number(capBytes) > 0 ? Number(capBytes) : LOCATOR_OUTPUT_MAX_BYTES) / 1024);
446
- // Factual continuation only — no retry imperatives (Claude Code/Codex
447
- // markers state what was cut, never instruct another call).
446
+ // Factual continuation only — no retry imperatives: the marker states
447
+ // what was cut, never instructs another call.
448
448
  const nextTarget = remaining > 0 ? `; ${remaining} target(s) not shown, next=${JSON.stringify(targets[targetIndex + 1])}` : '';
449
449
  if (toolName === 'find') {
450
450
  return `... [find output capped at ${capKb} KB; remaining matches for query=${JSON.stringify(target)} omitted${nextTarget}]`;
@@ -299,7 +299,9 @@ export function prewarmCodeGraphSymbols(cwd, symbols, { language = null } = {})
299
299
 
300
300
  export function prewarmCodeGraphIfProject(cwd) {
301
301
  if (!cwd) return false;
302
- const root = _findDirProjectRoot(cwd);
302
+ // Implicit walk (nobody asked for this build): never adopt a home/temp
303
+ // sentinel and prewarm the entire user profile in the background.
304
+ const root = _findDirProjectRoot(cwd, { stopAtUserBoundary: true });
303
305
  if (!root) return false;
304
306
  prewarmCodeGraph(root);
305
307
  return true;
@@ -2,7 +2,8 @@
2
2
  // resolveSymbolReadSpan, executeCodeGraphTool (entry with cwd re-rooting +
3
3
  // batch fan-out + abort race), isCodeGraphTool. Extracted verbatim from
4
4
  // code-graph.mjs.
5
- import { resolve as pathResolve, isAbsolute, relative as pathRelative } from 'node:path';
5
+ import { resolve as pathResolve, isAbsolute, relative as pathRelative, basename as pathBasename } from 'node:path';
6
+ import { homedir as osHomedir } from 'node:os';
6
7
  import { readFileSync, existsSync, statSync } from 'node:fs';
7
8
  import { normalizeInputPath, toDisplayPath } from '../builtin.mjs';
8
9
  import { findFileByBasename } from '../builtin/path-diagnostics.mjs';
@@ -20,6 +21,7 @@ import {
20
21
  _PROJECT_ROOT_SENTINELS,
21
22
  _resolveFileProjectRoot,
22
23
  _findDirProjectRoot,
24
+ _childProjectRoots,
23
25
  _stripEmptyArgs,
24
26
  } from './project-root.mjs';
25
27
  import { buildCodeGraphAsync, prewarmCodeGraph, prewarmCodeGraphSymbols } from './build.mjs';
@@ -625,7 +627,11 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
625
627
  const fileArg = (args && typeof args.file === 'string' && args.file.trim()) ? args.file.trim() : '';
626
628
  const hasAggregateFileArgs = _hasAggregateFileArgs(args);
627
629
  let effectiveCwd = baseCwd;
628
- const baseProjectRoot = _findDirProjectRoot(baseCwd);
630
+ // An explicit `cwd` argument is a deliberate target: honour whatever root it
631
+ // resolves to. A session cwd is a guess, so its ancestor walk stops at the
632
+ // home/temp boundary instead of adopting a stray sentinel found there.
633
+ const explicitCwdArg = !!(args && typeof args.cwd === 'string' && args.cwd.trim());
634
+ const baseProjectRoot = _findDirProjectRoot(baseCwd, { stopAtUserBoundary: !explicitCwdArg });
629
635
  const filesystemRootCwd = !baseProjectRoot && _isFilesystemRootPath(baseCwd);
630
636
  const aggregateFilesAtBase = _collectGraphFileList(args, { cap: false });
631
637
  const rawModeAtBase = String(args?.mode || '').trim();
@@ -635,8 +641,24 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
635
641
  const parentDotFederation = !baseProjectRoot
636
642
  && !filesystemRootCwd
637
643
  && exactDotFederation;
638
- if (filesystemRootCwd || parentDotFederation) {
639
- const trustedRoots = collectTrustedCodeGraphRoots(baseCwd);
644
+ // Trusted graph targets living UNDER a sentinel-free cwd. Self is filtered
645
+ // out: federating a directory into itself would recurse forever.
646
+ const trustedRootsAtBase = baseProjectRoot
647
+ ? []
648
+ : collectTrustedCodeGraphRoots(baseCwd)
649
+ .filter((root) => pathResolve(root) !== pathResolve(baseCwd));
650
+ // A sentinel-free cwd (multi-repo parent, vendored reference tree) is still
651
+ // routable when trusted project roots live under it — federate over those
652
+ // instead of refusing the call outright.
653
+ const sentinelFreeFederation = !baseProjectRoot
654
+ && !filesystemRootCwd
655
+ && !parentDotFederation
656
+ && !fileArg
657
+ && !hasAggregateFileArgs
658
+ && ROOT_FEDERATED_MODES.has(rawModeAtBase)
659
+ && trustedRootsAtBase.length > 0;
660
+ if (filesystemRootCwd || parentDotFederation || sentinelFreeFederation) {
661
+ const trustedRoots = trustedRootsAtBase;
640
662
  const files = exactDotFederation ? [] : aggregateFilesAtBase;
641
663
  const rawMode = rawModeAtBase;
642
664
  const canFederate = files.length > 0 || ROOT_FEDERATED_MODES.has(rawMode);
@@ -756,15 +778,34 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
756
778
  }
757
779
  }
758
780
  if (!fileArg && !(args && typeof args.cwd === 'string' && args.cwd.trim())) {
759
- const projectRoot = _findDirProjectRoot(effectiveCwd);
760
- if (!projectRoot) {
761
- throw new Error(
762
- `${name}: cwd '${effectiveCwd}' is not inside a project (no `
763
- + `${_PROJECT_ROOT_SENTINELS.join('/')} at it or any ancestor). Refusing to `
764
- + `index an arbitrary tree. Run 'cwd set <repo>', or pass an explicit `
765
- + `'cwd' (repo root) or a 'file' anchor.`);
781
+ const projectRoot = _findDirProjectRoot(effectiveCwd, { stopAtUserBoundary: true });
782
+ if (projectRoot) {
783
+ effectiveCwd = projectRoot;
784
+ } else {
785
+ // A sentinel-free SINGLE tree (vendored reference checkout, script
786
+ // folder) indexes as its own root — the same treatment an explicit
787
+ // 'cwd' argument already gets. Only genuinely unbounded or ambiguous
788
+ // targets stay refused: a filesystem root, the home directory, or a
789
+ // parent holding several separate repositories.
790
+ const childRoots = _childProjectRoots(effectiveCwd);
791
+ // Non-null only when the sole sentinel sits at/above the home or temp
792
+ // boundary — name it, so the refusal reads as a deliberate rule rather
793
+ // than a missing project.
794
+ const boundaryRoot = _findDirProjectRoot(effectiveCwd);
795
+ const unbounded = filesystemRootCwd
796
+ || _isFilesystemRootPath(effectiveCwd)
797
+ || pathResolve(effectiveCwd) === pathResolve(osHomedir());
798
+ if (unbounded || childRoots.length > 1) {
799
+ const listed = childRoots.slice(0, 5).map((root) => `"${pathBasename(root)}"`).join(', ');
800
+ throw new Error(
801
+ `${name}: cwd '${effectiveCwd}' is not inside a project (no `
802
+ + `${_PROJECT_ROOT_SENTINELS.join('/')} at it or any ancestor)`
803
+ + `${childRoots.length > 1 ? ` and holds ${childRoots.length} separate project roots (${listed})` : ''}. `
804
+ + `${boundaryRoot ? `The nearest sentinel is at '${boundaryRoot}' (home/temp), which is never auto-adopted. ` : ''}`
805
+ + `Refusing to index an arbitrary tree. Run 'cwd set <repo>', or pass an explicit `
806
+ + `'cwd' (repo root) or a 'file' anchor.`);
807
+ }
766
808
  }
767
- effectiveCwd = projectRoot;
768
809
  }
769
810
  if (signal?.aborted) throw new Error('aborted');
770
811
  const _work = (() => {
@@ -1,12 +1,31 @@
1
1
  // Project-root sentinel resolution + empty-arg stripping. Extracted verbatim
2
2
  // from code-graph.mjs. Used by the dispatcher to re-root file/dir queries.
3
3
  import { resolve as pathResolve, dirname, join } from 'node:path';
4
- import { existsSync } from 'node:fs';
4
+ import { existsSync, readdirSync } from 'node:fs';
5
+ import { homedir, tmpdir } from 'node:os';
5
6
 
6
7
  // P1: project-root sentinels. A directory containing any of these (or with one
7
8
  // at an ancestor) is treated as a real project we may index.
8
9
  export const _PROJECT_ROOT_SENTINELS = ['package.json', '.git', 'Cargo.toml', 'go.mod', 'pyproject.toml', 'setup.py', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'build.sbt', 'Package.swift'];
9
10
 
11
+ // Directories an IMPLICIT ancestor walk must never cross. A stray npm
12
+ // `package.json` in the home directory (or in the temp root) would otherwise
13
+ // promote "index this folder" into "index the whole user profile" — observed
14
+ // as a 20s graph-binary timeout on a `%TEMP%` path. Explicit targets (a `cwd`
15
+ // argument, `cwd set`, a registered project) are unaffected: only the walk
16
+ // that guesses a root for the caller stops here.
17
+ function _userBoundaryDirs() {
18
+ const dirs = [];
19
+ try { dirs.push(homedir()); } catch { /* no home — nothing to guard */ }
20
+ try { dirs.push(tmpdir()); } catch { /* no temp — nothing to guard */ }
21
+ return dirs.filter(Boolean);
22
+ }
23
+
24
+ function _dirKey(dir) {
25
+ const resolved = pathResolve(dir);
26
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
27
+ }
28
+
10
29
  // P1: resolve a file to its nearest project root (sentinel ancestor).
11
30
  // Returns null when no root found; caller throws rather than falling back.
12
31
  export function _resolveFileProjectRoot(file) {
@@ -22,16 +41,42 @@ export function _resolveFileProjectRoot(file) {
22
41
 
23
42
  // P1: nearest project root for a DIRECTORY (the dir itself or any ancestor).
24
43
  // Returns null when the dir sits in no project.
25
- export function _findDirProjectRoot(dir) {
44
+ // `stopAtUserBoundary` marks the walk as implicit: it then returns null at the
45
+ // home/temp boundary instead of adopting whatever sentinel happens to sit
46
+ // there. `boundaries` is injectable so the boundary rule stays testable.
47
+ export function _findDirProjectRoot(dir, { stopAtUserBoundary = false, boundaries = null } = {}) {
26
48
  if (!dir) return null;
49
+ const stops = stopAtUserBoundary
50
+ ? new Set((boundaries || _userBoundaryDirs()).map(_dirKey))
51
+ : null;
27
52
  let d = pathResolve(dir);
28
53
  while (d && d !== dirname(d)) {
54
+ if (stops && stops.has(_dirKey(d))) return null;
29
55
  if (_PROJECT_ROOT_SENTINELS.some((s) => existsSync(join(d, s)))) return d;
30
56
  d = dirname(d);
31
57
  }
32
58
  return null;
33
59
  }
34
60
 
61
+ // Immediate child directories that are themselves project roots. Separates a
62
+ // sentinel-free SINGLE tree (a vendored reference checkout — indexable as its
63
+ // own root) from a multi-repo PARENT (ambiguous — indexing it would walk
64
+ // unrelated trees). Capped: the caller only needs "none/one" vs "many".
65
+ export function _childProjectRoots(dir, { cap = 32 } = {}) {
66
+ if (!dir) return [];
67
+ let entries;
68
+ try { entries = readdirSync(pathResolve(dir), { withFileTypes: true }); }
69
+ catch { return []; }
70
+ const roots = [];
71
+ for (const entry of entries) {
72
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
73
+ const child = join(pathResolve(dir), entry.name);
74
+ if (_PROJECT_ROOT_SENTINELS.some((s) => existsSync(join(child, s)))) roots.push(child);
75
+ if (roots.length >= cap) break;
76
+ }
77
+ return roots;
78
+ }
79
+
35
80
  // MCP clients sometimes inject empty-string defaults for optional schema
36
81
  // fields (e.g. `file: ""`). Strip empty/null optional path-like fields before
37
82
  // dispatch so a literal "" doesn't trip the "file not found in graph" path.
@@ -56,7 +56,9 @@ export function collectTrustedCodeGraphRoots(filesystemRoot, {
56
56
  ],
57
57
  cached = listCachedCodeGraphRoots,
58
58
  directory = isDirectory,
59
- projectRoot = _findDirProjectRoot,
59
+ // Selected paths are promoted to their nearest project root — but a home or
60
+ // temp sentinel must not widen a selected subfolder into the whole profile.
61
+ projectRoot = (dir) => _findDirProjectRoot(dir, { stopAtUserBoundary: true }),
60
62
  } = {}) {
61
63
  const root = resolve(filesystemRoot);
62
64
  const rows = [
@@ -105,8 +105,7 @@ export function scrubProviderSecrets(env) {
105
105
  // a dev-tree test run inside such a shell would resolve defaults/agents.json
106
106
  // and friends from the INSTALLED app instead of the repo under test (plain
107
107
  // node can't even read asar paths → ENOENT). Internal worker spawns that need
108
- // the root re-set it explicitly and are unaffected: only shell spawn sites
109
- // call this.
108
+ // the root re-set it explicitly and are unaffected.
110
109
  export function scrubRuntimeRootVars(env) {
111
110
  if (!env || typeof env !== 'object') return env;
112
111
  delete env.MIXDOG_ROOT;
@@ -118,5 +117,13 @@ export function scrubRuntimeRootVars(env) {
118
117
  // env, so shells must not see it. A command that needs NODE_ENV sets it
119
118
  // explicitly.
120
119
  delete env.NODE_ENV;
120
+ // Daemon IDENTITY leak: user-facing children must not inherit host/worker
121
+ // ownership or the daemon's terminal-lead PID. Internal daemon/worker
122
+ // spawns set every identity value explicitly after this boundary.
123
+ delete env.MIXDOG_ENGINE_DAEMON_HOST;
124
+ delete env.MIXDOG_CHANNEL_DAEMON;
125
+ delete env.MIXDOG_WORKER_MODE;
126
+ delete env.MIXDOG_DAEMON_SPAWNED_FOR;
127
+ delete env.MIXDOG_SUPERVISOR_PID;
121
128
  return env;
122
129
  }
@@ -24,7 +24,7 @@ import {
24
24
  countHunkChanges,
25
25
  } from './paths.mjs';
26
26
  import {
27
- getNativePatchServer,
27
+ runServerApply,
28
28
  scheduleNativePatchIdleClose,
29
29
  nativePatchTraceEnabled,
30
30
  patchTraceEnabled,
@@ -99,7 +99,7 @@ export async function dispatchNativePatch({ entries, basePath, nativePatchStr, f
99
99
  const nativeStart = performance.now();
100
100
  let stats;
101
101
  try {
102
- stats = await getNativePatchServer().apply(basePath, nativePatchStr, { fuzz, rejectPartial, dryRun, signal });
102
+ stats = await runServerApply(basePath, nativePatchStr, { fuzz, rejectPartial, dryRun, signal });
103
103
  } catch (err) {
104
104
  scheduleNativePatchIdleClose();
105
105
  const msg = err?.message || String(err);
@@ -65,7 +65,7 @@ export function collectUnifiedNewLines(hunk) {
65
65
  }
66
66
 
67
67
  // Guard a replacement plan before it is applied in DESCENDING order (the
68
- // Codex `apply_replacements` shape). The plan must already be sorted
68
+ // `apply_replacements` shape). The plan must already be sorted
69
69
  // ascending by `start`. Descending application is only content-safe when the
70
70
  // replaced ranges are disjoint; the single well-defined exception is several
71
71
  // zero-length insertions at one index, which descending application emits in
@@ -129,6 +129,11 @@ function decodeNativeFailures(hexPayload) {
129
129
  return out;
130
130
  }
131
131
 
132
+ // Error code for a request refused/failed because the server child is gone.
133
+ // Nothing was written when it is thrown from the pre-flight, so one respawned
134
+ // retry is safe.
135
+ export const NATIVE_PATCH_TRANSPORT_DEAD = 'ENATIVEPATCHDEAD';
136
+
132
137
  class NativePatchServer {
133
138
  constructor(binPath) {
134
139
  this.binPath = binPath;
@@ -141,6 +146,7 @@ class NativePatchServer {
141
146
  this.lines = [];
142
147
  this.waiters = [];
143
148
  this.exited = false;
149
+ this.transportError = null;
144
150
  this.child.stderr.setEncoding('utf8');
145
151
  this.child.stderr.on('data', (chunk) => { this.stderr += chunk; });
146
152
  this.rl = createInterface({ input: this.child.stdout });
@@ -149,6 +155,12 @@ class NativePatchServer {
149
155
  if (waiter) waiter.resolve(line);
150
156
  else this.lines.push(line);
151
157
  });
158
+ // A child that died mid-request turns every pending `stdin.write()` into an
159
+ // UNHANDLED 'error' event (EPIPE) that takes the whole host process down —
160
+ // a release validate job was lost exactly that way. Absorb it here: mark
161
+ // the transport dead and reject the waiters so callers see an ordinary
162
+ // rejection (and respawn) instead of a crash.
163
+ this.child.stdin.on('error', (err) => { this.failTransport(err); });
152
164
  this.child.on('exit', (code, signal) => {
153
165
  this.exited = true;
154
166
  const err = new Error(`native patch server exited code=${code} signal=${signal} stderr=${this.stderr}`);
@@ -157,6 +169,29 @@ class NativePatchServer {
157
169
  });
158
170
  }
159
171
 
172
+ // Idempotent death record: the stdin error and the child exit usually both
173
+ // fire for the same death, and only the first one owns the reason.
174
+ failTransport(cause) {
175
+ this.exited = true;
176
+ if (!this.transportError) {
177
+ const err = cause instanceof Error ? cause : new Error(String(cause || 'native patch transport closed'));
178
+ err.code = NATIVE_PATCH_TRANSPORT_DEAD;
179
+ this.transportError = err;
180
+ }
181
+ for (const waiter of this.waiters.splice(0)) waiter.reject(this.transportError);
182
+ try { this.rl.close(); } catch {}
183
+ }
184
+
185
+ // Pre-flight for every request: refuse BEFORE a single byte is written, so
186
+ // the caller may respawn and retry without any risk of double-applying.
187
+ assertAlive(action) {
188
+ if (!this.exited && !this.transportError) return;
189
+ const reason = this.transportError?.message || this.stderr || 'exited';
190
+ const err = new Error(`native patch server is not running (${action}): ${reason}`);
191
+ err.code = NATIVE_PATCH_TRANSPORT_DEAD;
192
+ throw err;
193
+ }
194
+
160
195
  abort(signal) {
161
196
  const err = new Error(signal?.reason?.message || signal?.reason || 'native patch aborted');
162
197
  err.name = 'AbortError';
@@ -188,6 +223,7 @@ class NativePatchServer {
188
223
 
189
224
  async ping() {
190
225
  this.ref();
226
+ this.assertAlive('ping');
191
227
  const linePromise = this.nextLine();
192
228
  this.child.stdin.write('PING\n');
193
229
  const line = await linePromise;
@@ -198,6 +234,7 @@ class NativePatchServer {
198
234
 
199
235
  async apply(basePath, patchText, { fuzz = 2, rejectPartial = true, dryRun = false, signal = null } = {}) {
200
236
  this.ref();
237
+ this.assertAlive('apply');
201
238
  if (signal?.aborted) {
202
239
  const err = new Error(signal.reason?.message || signal.reason || 'native patch aborted');
203
240
  err.name = 'AbortError';
@@ -294,6 +331,7 @@ class NativePatchServer {
294
331
  // matched tier.
295
332
  async edit(fullPath, oldBuf, newBuf, { replaceAll = false, dryRun = false, signal = null } = {}) {
296
333
  this.ref();
334
+ this.assertAlive('edit');
297
335
  if (signal?.aborted) {
298
336
  const err = new Error(signal.reason?.message || signal.reason || 'native edit aborted');
299
337
  err.name = 'AbortError';
@@ -384,8 +422,25 @@ function getNativeEditServer() {
384
422
  // NativePatchServer transport but runs on a DEDICATED instance so edit and
385
423
  // patch requests never interleave their stdin framing on one stdout stream.
386
424
  export async function runServerEdit({ fullPath, oldBuf, newBuf, replaceAll = false, dryRun = false, signal = null }) {
387
- const server = getNativeEditServer();
388
- return server.edit(fullPath, oldBuf, newBuf, { replaceAll, dryRun, signal });
425
+ try {
426
+ return await getNativeEditServer().edit(fullPath, oldBuf, newBuf, { replaceAll, dryRun, signal });
427
+ } catch (err) {
428
+ if (err?.code !== NATIVE_PATCH_TRANSPORT_DEAD) throw err;
429
+ // The dead instance was refused before any byte went out; the getter
430
+ // respawns because the previous one is marked exited.
431
+ return getNativeEditServer().edit(fullPath, oldBuf, newBuf, { replaceAll, dryRun, signal });
432
+ }
433
+ }
434
+
435
+ // Same one-shot respawn for APPLY: an idle-watchdog exit or an external kill
436
+ // between requests must cost a respawn, not the request.
437
+ export async function runServerApply(basePath, patchText, options = {}) {
438
+ try {
439
+ return await getNativePatchServer().apply(basePath, patchText, options);
440
+ } catch (err) {
441
+ if (err?.code !== NATIVE_PATCH_TRANSPORT_DEAD) throw err;
442
+ return getNativePatchServer().apply(basePath, patchText, options);
443
+ }
389
444
  }
390
445
 
391
446
  export function scheduleNativePatchPrewarm() {