mixdog 0.9.149 → 0.9.151

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 (113) hide show
  1. package/README.md +28 -12
  2. package/package.json +1 -1
  3. package/src/headless-exec.mjs +40 -4
  4. package/src/headless-exec.test.mjs +116 -1
  5. package/src/rules/lead/01-general.md +3 -2
  6. package/src/rules/shared/00-general.md +1 -0
  7. package/src/rules/shared/10-tool-workflow.md +11 -12
  8. package/src/rules/shared/30-exploration.md +4 -3
  9. package/src/rules/shared/60-verification.md +4 -3
  10. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +0 -2
  11. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +1 -4
  12. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +0 -18
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +0 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -1
  15. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +4 -72
  16. package/src/runtime/agent/orchestrator/session/approval-mode.mjs +0 -8
  17. package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
  18. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
  19. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
  20. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
  21. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +50 -8
  22. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +31 -1
  23. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +27 -4
  24. package/src/runtime/agent/orchestrator/session/store/serialize.test.mjs +60 -0
  25. package/src/runtime/agent/orchestrator/session/task-wait-control.mjs +78 -0
  26. package/src/runtime/agent/orchestrator/session/task-wait-control.test.mjs +78 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/absence-absorption.test.mjs +43 -0
  28. package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +8 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  32. package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
  34. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +50 -23
  35. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +50 -1
  36. package/src/runtime/agent/orchestrator/tools/builtin/grep-output-budget.test.mjs +31 -0
  37. package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
  38. package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
  39. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +2 -1
  40. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
  41. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +9 -2
  42. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +6 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +50 -2
  44. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +7 -1
  45. package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
  46. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
  47. package/src/runtime/agent/orchestrator/tools/builtin/read-glob-survey.test.mjs +71 -0
  48. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +6 -1
  49. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +7 -0
  50. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +54 -8
  51. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +90 -51
  52. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.test.mjs +56 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +25 -0
  54. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.test.mjs +16 -0
  55. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +30 -1
  56. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
  57. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +49 -24
  59. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +1 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
  61. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -1
  62. package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
  63. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
  64. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +16 -0
  65. package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +33 -1
  66. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  67. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +9 -1
  68. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
  69. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
  70. package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
  71. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
  72. package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
  73. package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
  74. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +42 -23
  75. package/src/runtime/media/renditions.mjs +7 -1
  76. package/src/runtime/memory/index.mjs +2 -4
  77. package/src/runtime/memory/lib/pg/process.mjs +29 -2
  78. package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
  79. package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
  80. package/src/runtime/memory/lib/query-handlers.mjs +13 -19
  81. package/src/runtime/memory/lib/recall-format.mjs +13 -31
  82. package/src/runtime/memory/tool-defs.mjs +2 -2
  83. package/src/runtime/shared/memory-snapshot.mjs +57 -4
  84. package/src/runtime/shared/pristine-execution-contract.json +3 -0
  85. package/src/runtime/shared/pristine-execution.mjs +6 -3
  86. package/src/runtime/shared/tool-surface.mjs +6 -0
  87. package/src/runtime/shared/tool-surface.test.mjs +8 -0
  88. package/src/session-runtime/lifecycle-api.mjs +1 -0
  89. package/src/session-runtime/session-turn-api.mjs +5 -0
  90. package/src/standalone/agent-dispatch-broker.mjs +25 -80
  91. package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
  92. package/src/standalone/channel-worker.mjs +31 -1
  93. package/src/standalone/daemon.mjs +9 -9
  94. package/src/standalone/memory-runtime-proxy.mjs +5 -2
  95. package/src/standalone/session-client.mjs +23 -3
  96. package/src/standalone/session-runtime-host-health.test.mjs +36 -0
  97. package/src/standalone/session-runtime-host.mjs +26 -76
  98. package/src/standalone/session-runtime-worker.mjs +97 -0
  99. package/src/tui/App.jsx +3 -2
  100. package/src/tui/app/use-global-key-input.mjs +9 -0
  101. package/src/tui/app/use-mouse-input.mjs +77 -14
  102. package/src/tui/components/Spinner.jsx +1 -1
  103. package/src/tui/dist/index.mjs +83 -23
  104. package/src/tui/lib/voice-setup.mjs +3 -2
  105. package/src/tui/session/completion-card-restore.test.mjs +18 -0
  106. package/src/tui/session/session-api-ext.mjs +2 -1
  107. package/src/tui/session/session-api.mjs +8 -1
  108. package/src/tui/session/task-wait-submit.test.mjs +80 -0
  109. package/src/tui/session/turn.mjs +58 -6
  110. package/src/tui/spinner-verbs.mjs +1 -0
  111. package/src/workflows/default/WORKFLOW.md +12 -19
  112. package/src/workflows/headless/WORKFLOW.md +11 -0
  113. package/src/workflows/solo/WORKFLOW.md +6 -11
@@ -4,7 +4,7 @@
4
4
  * thin executor; search-tool.mjs re-exports these for unchanged importers.
5
5
  */
6
6
  import { statSync } from 'fs';
7
- import { basename, isAbsolute, join, resolve } from 'path';
7
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
8
8
  import { findBySuffixStrip, findDirectoryByBasename, findFileByBasename, listSiblings } from './path-diagnostics.mjs';
9
9
  import { normalizeOutputPath, resolveAgainstCwd } from './path-utils.mjs';
10
10
 
@@ -18,6 +18,9 @@ import { normalizeOutputPath, resolveAgainstCwd } from './path-utils.mjs';
18
18
  // indexed file exists or the glob child is unavailable.
19
19
  export async function _suggestIndexedPaths(missingPath, executeChildBuiltinTool, workDir) {
20
20
  if (typeof executeChildBuiltinTool !== 'function') return '';
21
+ // An outside path has no indexed twin worth proposing; the project-wide
22
+ // glob would only spend the turn.
23
+ if (missingPathIsOutsideProject(workDir, missingPath)) return '';
21
24
  const base = String(missingPath).replace(/\\/g, '/').split('/').pop();
22
25
  // Skip when there is no usable basename or it carries glob magic (the
23
26
  // pattern, not a literal filename, would not map to a real file).
@@ -90,6 +93,50 @@ function cachedFileByBasename(workDir, missingPath, cache) {
90
93
  return cache.fileHits;
91
94
  }
92
95
 
96
+ // A missing ABSOLUTE path that resolves outside the project cannot be
97
+ // corrected by anything inside it: `/sys/block/sd*/size` is not a misplaced
98
+ // project file, and scanning the tree for a file named `size` only spends the
99
+ // turn (measured: three such reads burned 17.5s each and left the shared
100
+ // search server saturated for the calls behind them). The honest recovery for
101
+ // an outside path is its own filesystem: name the nearest directory that does
102
+ // exist and what it holds.
103
+ function missingPathIsOutsideProject(workDir, missingPath) {
104
+ const raw = String(missingPath || '');
105
+ if (!raw || !isAbsolute(raw)) return false;
106
+ if (typeof workDir !== 'string' || !workDir) return false;
107
+ const rel = relative(resolve(workDir), resolve(raw));
108
+ return !rel || rel === '..' || rel.startsWith(`..${sep}`) || rel.startsWith('../') || isAbsolute(rel);
109
+ }
110
+
111
+ function nearestExistingRealParent(missingPath) {
112
+ try {
113
+ let dir = dirname(resolve(String(missingPath)));
114
+ for (let depth = 0; depth < 24; depth += 1) {
115
+ try {
116
+ if (statSync(dir).isDirectory()) return dir;
117
+ } catch { /* keep climbing */ }
118
+ const parent = dirname(dir);
119
+ if (!parent || parent === dir) break;
120
+ dir = parent;
121
+ }
122
+ } catch { /* fall through */ }
123
+ return null;
124
+ }
125
+
126
+ function outsideProjectNotFoundHint(missingPath, actionVerb) {
127
+ const parent = nearestExistingRealParent(missingPath);
128
+ if (!parent) return ' Not found, and no parent directory of it exists either.';
129
+ const entries = listSiblings(parent, 12).slice(0, 5);
130
+ const where = `"${normalizeOutputPath(parent)}"`;
131
+ if (entries.length === 0) {
132
+ return ` Not found; ${where} exists but is empty — nothing under it to ${actionVerb.toLowerCase()}.`;
133
+ }
134
+ const magic = /[*?[\]{}]/.test(String(missingPath))
135
+ ? ' The path carries a glob pattern; name one of those entries directly.'
136
+ : '';
137
+ return ` Not found; the nearest existing directory ${where} holds: ${entries.map((n) => `"${n}"`).join(', ')}.${magic}`;
138
+ }
139
+
93
140
  function isDirectoryPathGuess(missingPath) {
94
141
  const base = basename(String(missingPath || '').replace(/\\/g, '/'));
95
142
  if (!base || /[*?[\]{}]/.test(base)) return false;
@@ -137,6 +184,9 @@ export function finalizeReadFamilyEnoentTail(hint, requestedPath, errCode = 'ENO
137
184
 
138
185
  function resolveUniqueEnoentRedirect(workDir, missingPath, errCode = 'ENOENT', cache = null) {
139
186
  if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return null;
187
+ // Redirecting an outside path INTO the project would answer a different
188
+ // question than the one asked.
189
+ if (missingPathIsOutsideProject(workDir, missingPath)) return null;
140
190
  const suffixHit = cachedSuffixStrip(workDir, missingPath, cache);
141
191
  if (suffixHit) return suffixHit;
142
192
  const elsewhere = cachedFileByBasename(workDir, missingPath, cache);
@@ -183,6 +233,9 @@ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'E
183
233
  if (/^[A-Za-z]:(?![\\/])[^\\/]/.test(rawMissing.replace(/\\/g, '\\'))) {
184
234
  return ` The path has a drive letter but no separator after it — the backslashes were probably lost in escaping. Re-issue with forward slashes (e.g. "C:/tmp/smp").`;
185
235
  }
236
+ if (missingPathIsOutsideProject(workDir, missingPath)) {
237
+ return outsideProjectNotFoundHint(missingPath, actionVerb);
238
+ }
186
239
  if (resolveUniqueEnoentRedirect(workDir, missingPath, errCode, cache)) return '';
187
240
  const elsewhere = cachedFileByBasename(workDir, missingPath, cache);
188
241
  if (elsewhere.length) {
@@ -20,6 +20,7 @@ import {
20
20
  TASK_WAIT_TIMEOUT_MIN_MS,
21
21
  } from './builtin-tools.mjs';
22
22
  import { getAbortSignalForSession } from '../../session/abort-lookup.mjs';
23
+ import { beginInterruptibleTaskWait } from '../../session/task-wait-control.mjs';
23
24
  import {
24
25
  acknowledgeBackgroundTaskCompletion,
25
26
  cancelBackgroundTask,
@@ -160,39 +161,63 @@ export async function executeTaskTool(args, options = {}) {
160
161
  const isShellTask = task.surface === 'shell';
161
162
 
162
163
  if (action === 'read' || action === 'wait') {
164
+ let waitInterruptedByUser = false;
163
165
  if (action === 'wait') {
164
166
  if (!isShellTask) return `Error: task wait is only available for shell tasks: ${taskId}`;
165
167
  // The turn's abort signal must cut the wait short; a cancelled turn
166
168
  // cannot sit out the remaining ceiling.
167
- let waitSignal = null;
168
- try { waitSignal = (await getAbortSignalForSession(options?.sessionId)) || null; }
169
- catch { waitSignal = null; }
169
+ let turnSignal = null;
170
+ try { turnSignal = (await getAbortSignalForSession(options?.sessionId)) || null; }
171
+ catch { turnSignal = null; }
172
+ const waitControl = beginInterruptibleTaskWait(options?.sessionId, turnSignal);
173
+ const waitSignal = waitControl.signal;
170
174
  const waitTimeoutMs = resolveTaskWaitTimeoutMs(args.timeout_ms);
171
- // A shell task whose descendants outlived the shell process has no
172
- // native job to subscribe to — the process it belonged to is gone.
173
- // Its registry promise (resolved by the descendant observer) is the
174
- // completion event in that case.
175
- if (!peekShellJob(taskId) && task.status === 'running' && task.promise) {
176
- await new Promise((resolve) => {
177
- const timer = setTimeout(resolve, waitTimeoutMs);
178
- timer.unref?.();
179
- const done = () => { clearTimeout(timer); resolve(); };
180
- task.promise.then(done, done);
181
- if (waitSignal) {
182
- if (waitSignal.aborted) done();
183
- else waitSignal.addEventListener('abort', done, { once: true });
184
- }
185
- });
186
- } else {
187
- await waitForShellJob(taskId, {
188
- timeoutMs: waitTimeoutMs,
189
- signal: waitSignal,
190
- });
175
+ try {
176
+ // A shell task whose descendants outlived the shell process has no
177
+ // native job to subscribe to — the process it belonged to is gone.
178
+ // Its registry promise (resolved by the descendant observer) is the
179
+ // completion event in that case.
180
+ if (!peekShellJob(taskId) && task.status === 'running' && task.promise) {
181
+ await new Promise((resolve) => {
182
+ const timer = setTimeout(resolve, waitTimeoutMs);
183
+ timer.unref?.();
184
+ const done = () => {
185
+ clearTimeout(timer);
186
+ if (waitSignal) {
187
+ try { waitSignal.removeEventListener('abort', done); } catch {}
188
+ }
189
+ resolve();
190
+ };
191
+ task.promise.then(done, done);
192
+ if (waitSignal) {
193
+ if (waitSignal.aborted) done();
194
+ else waitSignal.addEventListener('abort', done, { once: true });
195
+ }
196
+ });
197
+ } else {
198
+ await waitForShellJob(taskId, {
199
+ timeoutMs: waitTimeoutMs,
200
+ signal: waitSignal,
201
+ });
202
+ }
203
+ } finally {
204
+ waitInterruptedByUser = waitControl.interruptedByUser;
205
+ waitControl.dispose();
191
206
  }
192
207
  }
193
208
  if (isShellTask) refreshShellTask(taskId, { includeRunning: true });
194
209
  const latest = getBackgroundTask(taskId, { context: options }) || task;
195
- const rendered = renderBackgroundTask(latest, { includeResult: true });
210
+ let rendered = renderBackgroundTask(latest, { includeResult: true });
211
+ if (action === 'wait' && latest.status === 'running') {
212
+ if (waitInterruptedByUser) {
213
+ rendered += '\n\nWait interrupted by new user input; task still running. Handle the new message now and call task wait again only when its result is needed.';
214
+ } else {
215
+ // In-band report cue: the static "periodic reports" rule does not
216
+ // survive a long tool loop, so a ceiling-hit wait carries the
217
+ // protocol reminder itself.
218
+ rendered += '\n\nWait ceiling reached; task still running. If periodic reports were requested, write the user-facing report now, then call task wait for the next interval; otherwise continue independent work or end the turn.';
219
+ }
220
+ }
196
221
  if (acknowledgeBackgroundTaskCompletion(taskId, { context: options })) {
197
222
  recordDeliveredCompletion({ executionId: taskId });
198
223
  }
@@ -9,6 +9,7 @@ function _envInt(name, fallback) {
9
9
  }
10
10
  export const TOOL_OUTPUT_MAX_BYTES = _envInt('MIXDOG_TOOL_OUTPUT_MAX_BYTES', 50 * 1024);
11
11
  export const LOCATOR_OUTPUT_MAX_BYTES = _envInt('MIXDOG_LOCATOR_OUTPUT_MAX_BYTES', 20 * 1024);
12
+ export const GREP_OUTPUT_MAX_BYTES = _envInt('MIXDOG_GREP_OUTPUT_MAX_BYTES', 10 * 1024);
12
13
  export const CODE_GRAPH_OUTPUT_MAX_BYTES = _envInt('MIXDOG_CODE_GRAPH_OUTPUT_MAX_BYTES', 30 * 1024);
13
14
  // recall/memory search returns model-facing prose blocks; uncapped multi-query
14
15
  // browses were observed injecting 42KB in one call (2026-08-05 audit). Same
@@ -0,0 +1,153 @@
1
+ // Writing through a symlink must land on the file the link points at and leave
2
+ // the link in place. The atomic rename would otherwise replace the link with a
3
+ // regular file and silently detach layouts like nginx's sites-enabled/default
4
+ // from sites-available/default.
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import {
8
+ lstatSync,
9
+ mkdtempSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ rmSync,
13
+ statSync,
14
+ symlinkSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ import { atomicWrite, symlinkWriteTarget } from './atomic-write.mjs';
21
+ import { tryExecuteExternalToolAdapter } from './external-tool-adapters.mjs';
22
+
23
+ function makeDir() {
24
+ return mkdtempSync(join(tmpdir(), 'mixdog-write-symlink-'));
25
+ }
26
+
27
+ // Windows without Developer Mode / admin rights refuses symlink creation
28
+ // (EPERM), so the host capability decides whether these cases can run.
29
+ function symlinkSupported(dir) {
30
+ try {
31
+ writeFileSync(join(dir, 'probe.txt'), 'x');
32
+ symlinkSync(join(dir, 'probe.txt'), join(dir, 'probe.link'));
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ test('symlinkWriteTarget resolves a live link and nothing else', (t) => {
40
+ const dir = makeDir();
41
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
42
+ if (!symlinkSupported(dir)) {
43
+ t.skip('symlink creation is not permitted on this host');
44
+ return;
45
+ }
46
+ const real = join(dir, 'real.txt');
47
+ const link = join(dir, 'link.txt');
48
+ writeFileSync(real, 'a\n');
49
+ symlinkSync(real, link);
50
+
51
+ assert.equal(symlinkWriteTarget(link), realpathSync(real));
52
+ assert.equal(symlinkWriteTarget(real), null);
53
+ assert.equal(symlinkWriteTarget(join(dir, 'missing.txt')), null);
54
+ // A dangling link has no target to resolve, so the caller keeps its path.
55
+ const dangling = join(dir, 'dangling.link');
56
+ symlinkSync(join(dir, 'gone.txt'), dangling);
57
+ assert.equal(symlinkWriteTarget(dangling), null);
58
+ });
59
+
60
+ test('atomicWrite writes through a symlink and keeps the link', async (t) => {
61
+ const dir = makeDir();
62
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
63
+ if (!symlinkSupported(dir)) {
64
+ t.skip('symlink creation is not permitted on this host');
65
+ return;
66
+ }
67
+ const real = join(dir, 'real.conf');
68
+ const link = join(dir, 'link.conf');
69
+ writeFileSync(real, 'old\n');
70
+ symlinkSync(real, link);
71
+
72
+ await atomicWrite(link, 'new\n');
73
+
74
+ assert.equal(readFileSync(real, 'utf8'), 'new\n');
75
+ assert.equal(lstatSync(link).isSymbolicLink(), true);
76
+ });
77
+
78
+ test('the expected-target guard still fires when the real file moved', async (t) => {
79
+ const dir = makeDir();
80
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
81
+ if (!symlinkSupported(dir)) {
82
+ t.skip('symlink creation is not permitted on this host');
83
+ return;
84
+ }
85
+ const real = join(dir, 'guard.conf');
86
+ const link = join(dir, 'guard.link');
87
+ writeFileSync(real, 'old\n');
88
+ symlinkSync(real, link);
89
+ const before = statSync(link);
90
+
91
+ // Somebody else rewrites the real file after the snapshot was taken.
92
+ writeFileSync(real, 'rewritten by another writer\n');
93
+
94
+ await assert.rejects(
95
+ () => atomicWrite(link, 'mine\n', {
96
+ expectedTargetSnapshot: {
97
+ exists: true,
98
+ size: before.size,
99
+ mtimeMs: before.mtimeMs,
100
+ ctimeMs: before.ctimeMs,
101
+ ino: before.ino,
102
+ },
103
+ }),
104
+ (err) => err?.code === 'ESTALE_TARGET',
105
+ );
106
+ assert.equal(readFileSync(real, 'utf8'), 'rewritten by another writer\n');
107
+ assert.equal(lstatSync(link).isSymbolicLink(), true);
108
+ });
109
+
110
+ test('edit updates through a symlink and keeps the link', async (t) => {
111
+ const dir = makeDir();
112
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
113
+ if (!symlinkSupported(dir)) {
114
+ t.skip('symlink creation is not permitted on this host');
115
+ return;
116
+ }
117
+ const real = join(dir, 'sites-available.conf');
118
+ const link = join(dir, 'sites-enabled.conf');
119
+ writeFileSync(real, 'root /var/www/html;\nindex index.html;\n');
120
+ symlinkSync(real, link);
121
+
122
+ const result = String(await tryExecuteExternalToolAdapter('edit', {
123
+ file_path: link,
124
+ old_string: 'root /var/www/html;',
125
+ new_string: 'root /usr/share/novnc;',
126
+ }, dir, {}));
127
+
128
+ assert.match(result, /^Updated /);
129
+ assert.equal(readFileSync(real, 'utf8'), 'root /usr/share/novnc;\nindex index.html;\n');
130
+ assert.equal(lstatSync(link).isSymbolicLink(), true);
131
+ });
132
+
133
+ test('write replaces content through a symlink and keeps the link', async (t) => {
134
+ const dir = makeDir();
135
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
136
+ if (!symlinkSupported(dir)) {
137
+ t.skip('symlink creation is not permitted on this host');
138
+ return;
139
+ }
140
+ const real = join(dir, 'payload.txt');
141
+ const link = join(dir, 'payload.link');
142
+ writeFileSync(real, 'old\n');
143
+ symlinkSync(real, link);
144
+
145
+ const result = String(await tryExecuteExternalToolAdapter('write', {
146
+ file_path: link,
147
+ contents: 'fresh\n',
148
+ }, dir, {}));
149
+
150
+ assert.match(result, /^Updated /);
151
+ assert.equal(readFileSync(real, 'utf8'), 'fresh\n');
152
+ assert.equal(lstatSync(link).isSymbolicLink(), true);
153
+ });
@@ -75,6 +75,7 @@ import {
75
75
  READ_STREAM_RANGE_MIN_BYTES,
76
76
  } from './builtin/read-constants.mjs';
77
77
  import {
78
+ GREP_OUTPUT_MAX_BYTES,
78
79
  LOCATOR_OUTPUT_MAX_BYTES,
79
80
  capLineOrientedToolOutput,
80
81
  } from './builtin/tool-output-limit.mjs';
@@ -495,7 +496,16 @@ export async function executeBuiltinTool(name, args, cwd, options = {}) {
495
496
  const _locatorCap = _explicitCap
496
497
  ? Math.min(_explicitCap, LOCATOR_OUTPUT_MAX_BYTES)
497
498
  : LOCATOR_OUTPUT_MAX_BYTES;
498
- const _budgetedResult = _LOCATOR_BUDGET_TOOLS.has(toolName)
499
+ const _grepCap = _explicitCap
500
+ ? Math.min(_explicitCap, GREP_OUTPUT_MAX_BYTES)
501
+ : GREP_OUTPUT_MAX_BYTES;
502
+ const _budgetedResult = toolName === 'grep'
503
+ ? capLineOrientedToolOutput(
504
+ _withNotices,
505
+ _grepCap,
506
+ (kept) => `[grep output capped at ${_grepCap} bytes; ${kept.length} line(s) shown, remainder omitted]`,
507
+ )
508
+ : _LOCATOR_BUDGET_TOOLS.has(toolName)
499
509
  ? capLineOrientedToolOutput(
500
510
  _withNotices,
501
511
  _locatorCap,
@@ -0,0 +1,55 @@
1
+ // A sentinel-free cwd that HOLDS one project answers an anchored call by
2
+ // adopting that project; a genuinely ambiguous tree still refuses, but names
3
+ // where to anchor instead.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+
10
+ import { executeCodeGraphTool } from './dispatch.mjs';
11
+
12
+ const PY = 'class Mean:\n def compute(self):\n return 42\n';
13
+
14
+ function makeRepo(root, name) {
15
+ const repo = join(root, name);
16
+ mkdirSync(join(repo, '.git'), { recursive: true });
17
+ mkdirSync(join(repo, 'tools'), { recursive: true });
18
+ writeFileSync(join(repo, 'tools', 'compute_mean.py'), PY);
19
+ return repo;
20
+ }
21
+
22
+ test('a relative anchor held by exactly one child project adopts that project', async (t) => {
23
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-anchor-one-'));
24
+ t.after(() => rmSync(root, { recursive: true, force: true }));
25
+ makeRepo(root, 'caffe');
26
+
27
+ const out = String(await executeCodeGraphTool(
28
+ 'code_graph',
29
+ { mode: 'symbols', files: 'tools/compute_mean.py' },
30
+ root,
31
+ ));
32
+ assert.doesNotMatch(out, /Refusing to index an arbitrary tree/);
33
+ assert.match(out, /compute/);
34
+ });
35
+
36
+ test('two child projects holding the same anchor stay refused, with the roots named', async (t) => {
37
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-anchor-many-'));
38
+ t.after(() => rmSync(root, { recursive: true, force: true }));
39
+ makeRepo(root, 'caffe');
40
+ makeRepo(root, 'caffe-fork');
41
+
42
+ let message = '';
43
+ try {
44
+ await executeCodeGraphTool(
45
+ 'code_graph',
46
+ { mode: 'symbols', files: 'tools/compute_mean.py' },
47
+ root,
48
+ );
49
+ } catch (error) {
50
+ message = String(error?.message || error);
51
+ }
52
+ assert.match(message, /Refusing to index an arbitrary tree/);
53
+ assert.match(message, /Project roots under this cwd/);
54
+ assert.match(message, /"caffe"/);
55
+ });
@@ -365,6 +365,47 @@ function _boundedExactFileAggregateRoot(args, baseCwd) {
365
365
  return roots.size === 1 ? [...roots][0] : null;
366
366
  }
367
367
 
368
+ // A sentinel-free working directory that HOLDS a project (a container's /app
369
+ // with one cloned repo under it) answered every anchored call with a refusal:
370
+ // the caller wrote the anchor the way the repo's own docs do
371
+ // (`tools/compute_image_mean.cpp`), it does not exist under /app, and no root
372
+ // could be derived from a path that resolves nowhere. The relative anchor is
373
+ // still unambiguous whenever exactly ONE child project holds it — that project
374
+ // is the answer, and adopting it is the same recovery read and grep already
375
+ // perform for a misplaced path. Ambiguity (several holders, none, an absolute
376
+ // anchor, a wildcard) keeps refusing.
377
+ function _relocateAggregateAnchorsUnderChildProject(args, baseCwd) {
378
+ if (!_hasAggregateFileArgs(args)) return null;
379
+ const files = _collectGraphFileList(args);
380
+ if (files.length === 0) return null;
381
+ for (const file of files) {
382
+ if (_AGGREGATE_FILE_WILDCARD_RE.test(file) || isAbsolute(file)) return null;
383
+ if (existsSync(pathResolve(baseCwd, file))) return null;
384
+ }
385
+ const holders = _childProjectRoots(baseCwd, { cap: 8 })
386
+ .filter((root) => pathResolve(root) !== pathResolve(baseCwd))
387
+ .filter((root) => files.every((file) => {
388
+ try {
389
+ return statSync(pathResolve(root, file)).isFile();
390
+ } catch {
391
+ return false;
392
+ }
393
+ }));
394
+ if (holders.length !== 1) return null;
395
+ const root = pathResolve(holders[0]);
396
+ const remap = (file) => pathResolve(root, String(file || '').trim());
397
+ return {
398
+ root,
399
+ args: {
400
+ ...args,
401
+ file: typeof args?.file === 'string' && args.file.trim() ? remap(args.file) : args?.file,
402
+ files: Array.isArray(args?.files)
403
+ ? args.files.map(remap)
404
+ : (typeof args?.files === 'string' && args.files.trim() ? remap(args.files) : args?.files),
405
+ },
406
+ };
407
+ }
408
+
368
409
  export function _resolveBoundedSentinelFreeAggregateRootForTest(args, baseCwd) {
369
410
  if (!_hasAggregateFileArgs(args)) return null;
370
411
  const base = pathResolve(baseCwd);
@@ -1045,13 +1086,23 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
1045
1086
  stopAtUserBoundary: !explicitCwdArg,
1046
1087
  }) || _resolveBoundedSentinelFreeAggregateRootForTest(args, baseCwd)
1047
1088
  || _boundedExactFileAggregateRoot(args, baseCwd);
1048
- if (!aggregateRoot) {
1089
+ const relocated = aggregateRoot ? null : _relocateAggregateAnchorsUnderChildProject(args, baseCwd);
1090
+ if (!aggregateRoot && !relocated) {
1091
+ // Name the projects that DO sit under this cwd: the refusal is only
1092
+ // actionable if the caller learns where to anchor instead.
1093
+ const candidates = _childProjectRoots(baseCwd, { cap: 5 })
1094
+ .filter((root) => pathResolve(root) !== pathResolve(baseCwd))
1095
+ .map((root) => `"${pathBasename(root)}"`);
1049
1096
  throw new Error(
1050
1097
  `${name}: cwd '${baseCwd}' is not inside a project and aggregate file anchors do not all `
1051
- + `exist under exactly one detectable project root. Refusing to index an arbitrary tree.`,
1098
+ + `exist under exactly one detectable project root. Refusing to index an arbitrary tree.`
1099
+ + (candidates.length
1100
+ ? ` Project roots under this cwd: ${candidates.join(', ')} — anchor the call inside one of them.`
1101
+ : ''),
1052
1102
  );
1053
1103
  }
1054
- effectiveCwd = aggregateRoot;
1104
+ effectiveCwd = relocated ? relocated.root : aggregateRoot;
1105
+ if (relocated) args = relocated.args;
1055
1106
  args = _absolutizeAggregateFileArgs(args, baseCwd);
1056
1107
  }
1057
1108
  if (fileArg && !hasAggregateFileArgs) {
@@ -135,5 +135,21 @@ export function scrubRuntimeRootVars(env) {
135
135
  delete env.MIXDOG_WORKER_MODE;
136
136
  delete env.MIXDOG_DAEMON_SPAWNED_FOR;
137
137
  delete env.MIXDOG_SUPERVISOR_PID;
138
+ // Same identity family as MIXDOG_SUPERVISOR_PID above, and the last one
139
+ // missing: MIXDOG_SERVER_PID is the DAEMON's own pid. A child that
140
+ // inherits it advertises the daemon as its server in active-instance.json,
141
+ // so the liveness/staleness checks in runtime-paths.mjs judge an unrelated
142
+ // process. Internal forks blank or re-set it explicitly after this
143
+ // boundary (see memory-runtime-proxy.mjs).
144
+ delete env.MIXDOG_SERVER_PID;
145
+ // Electron execution-MODE leak: a packaged desktop/daemon host runs its
146
+ // node entrypoints with ELECTRON_RUN_AS_NODE=1. Inherited by a child, it
147
+ // silently converts every Electron app that child launches into a bare
148
+ // node interpreter — Unity Hub, VS Code, Slack and friends stop booting
149
+ // their own CLI and hand the argv to node instead, which eats a leading
150
+ // `--` as its end-of-options marker and rejects app flags as bad node
151
+ // options. Internal daemon/worker forks that genuinely need node mode set
152
+ // this explicitly after the boundary and are unaffected.
153
+ delete env.ELECTRON_RUN_AS_NODE;
138
154
  return env;
139
155
  }
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
- import { applyShellEgressPolicy } from './env-scrub.mjs';
4
+ import { applyShellEgressPolicy, scrubRuntimeRootVars } from './env-scrub.mjs';
5
5
 
6
6
  test('web-search disabled preserves ordinary shell network environment', () => {
7
7
  const previous = process.env.MIXDOG_FEATURE_WEB_SEARCH;
@@ -23,3 +23,35 @@ test('web-search disabled preserves ordinary shell network environment', () => {
23
23
  else process.env.MIXDOG_FEATURE_WEB_SEARCH = previous;
24
24
  }
25
25
  });
26
+
27
+ test('spawned children never inherit the Electron execution-mode switch', () => {
28
+ // ELECTRON_RUN_AS_NODE=1 turns any Electron app the child launches into a
29
+ // bare node interpreter, which swallows a leading `--` as its
30
+ // end-of-options marker and rejects the app's own CLI flags.
31
+ const env = {
32
+ ELECTRON_RUN_AS_NODE: '1',
33
+ PATH: '/usr/bin',
34
+ MIXDOG_DATA_DIR: '/home/u/.mixdog/data',
35
+ };
36
+ scrubRuntimeRootVars(env);
37
+ assert.equal(env.ELECTRON_RUN_AS_NODE, undefined);
38
+ // The child still needs to locate its tools and user data.
39
+ assert.equal(env.PATH, '/usr/bin');
40
+ assert.equal(env.MIXDOG_DATA_DIR, '/home/u/.mixdog/data');
41
+ });
42
+
43
+ test('runtime-root scrub clears host identity and leaves user environment intact', () => {
44
+ const env = {
45
+ MIXDOG_ROOT: '/app/resources/runtime.asar',
46
+ NODE_ENV: 'production',
47
+ MIXDOG_DAEMON_HOST: '1',
48
+ MIXDOG_WORKER_MODE: '1',
49
+ MIXDOG_DAEMON_SPAWNED_FOR: 'session',
50
+ MIXDOG_SUPERVISOR_PID: '4242',
51
+ MIXDOG_SERVER_PID: '74976',
52
+ ELECTRON_RUN_AS_NODE: '1',
53
+ HOME: '/home/u',
54
+ };
55
+ assert.equal(scrubRuntimeRootVars(env), env);
56
+ assert.deepEqual(env, { HOME: '/home/u' });
57
+ });
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.1.19",
2
+ "version": "0.1.20",
3
3
  "_comment": "Synced from immutable graph-v release assets.",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.19/mixdog-graph-darwin-arm64",
7
- "sha256": "7829ad6e428f090843907fd76c2e7b45b9c2314f706776614004fe6715a09313"
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-darwin-arm64",
7
+ "sha256": "d059f2ab33cb7f97b7c96b4e21e3d16880cc44baf1400ce81374a1031a11ddd9"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.19/mixdog-graph-darwin-x64",
11
- "sha256": "84d43ad67fda7399d633d93e51438a01ed817137153e9c6ecb8f911e255b235e"
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-darwin-x64",
11
+ "sha256": "c91873322ce6390a7103daa4b872e31de1abbe7e922967727f41b5b7b2f21373"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.19/mixdog-graph-linux-arm64",
15
- "sha256": "9a0590415b40e90723653205f9476210d031216edff6225b1391a7f350900a12"
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-linux-arm64",
15
+ "sha256": "e8183e1a36aae1887245bd55b94fae6d3fa874bb350559de95ea6ca65a14c6a0"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.19/mixdog-graph-linux-x64",
19
- "sha256": "5bd08eb666c9ad82b0b152ee6207b6c315182b861c60baf58f76d87025dcc2bc"
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-linux-x64",
19
+ "sha256": "40142ad17359cc2eb490f639de7c358c4449ca36dc7bbb966bff9a21f81df40f"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.19/mixdog-graph-win32-x64.exe",
23
- "sha256": "f1a0ed0058797655ca231078da0ceacd0d35a71289a8089c7ba637fcd8634a03"
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-win32-x64.exe",
23
+ "sha256": "a727ca6ea385d715232ce246c2519037ccd74b509e57d97ad7c1b4a61c26aade"
24
24
  }
25
25
  }
26
26
  }
@@ -861,7 +861,15 @@ export function promoteNativeTask({
861
861
  } = {}) {
862
862
  const key = String(jobId || '').trim();
863
863
  const server = _server;
864
- if (server?.caps?.promoteTask !== true || !getNativeTask(key)) return null;
864
+ // Distinguish the two promotion-unavailable causes: a silent null here used
865
+ // to surface as an unexplained SIGKILL ("background-promotion-failed") with
866
+ // no way to tell a capability gap from a missing task record afterwards.
867
+ if (server?.caps?.promoteTask !== true) {
868
+ throw new Error('native runner lacks promoteTask capability');
869
+ }
870
+ if (!getNativeTask(key)) {
871
+ throw new Error(`no native task record for jobId ${key || '(empty)'}`);
872
+ }
865
873
  return requestNativeTaskState(key, {
866
874
  promoteTask: key,
867
875
  timeoutMs,