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
@@ -0,0 +1,60 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import {
8
+ _sessionForDisk,
9
+ _storedSessionFromFile,
10
+ } from './serialize.mjs';
11
+
12
+ test('provider prefix guard remains runtime-local across media-safe persistence', () => {
13
+ const guard = {
14
+ messageHashes: ['live-image-hash'],
15
+ requestPrefixHash: 'live-tools-hash',
16
+ };
17
+ const session = {
18
+ id: 'sess_prefix_guard',
19
+ messages: [{
20
+ role: 'tool',
21
+ content: [{
22
+ type: 'image_url',
23
+ image_url: { url: 'data:image/png;base64,AAAA' },
24
+ }],
25
+ }],
26
+ _providerPrefixGuardState: guard,
27
+ };
28
+
29
+ const stored = _sessionForDisk(session);
30
+
31
+ assert.equal(Object.hasOwn(stored, '_providerPrefixGuardState'), false);
32
+ assert.equal(session._providerPrefixGuardState, guard);
33
+ assert.match(JSON.stringify(stored.messages), /Image omitted from stored history: image\/png/);
34
+ });
35
+
36
+ test('legacy stored provider prefix guards are discarded on load', async () => {
37
+ const dir = await mkdtemp(join(tmpdir(), 'mixdog-prefix-guard-store-'));
38
+ const id = 'sess_legacy_prefix_guard';
39
+ try {
40
+ await writeFile(join(dir, `${id}.json`), JSON.stringify({
41
+ id,
42
+ generation: 0,
43
+ closed: false,
44
+ messages: [{ role: 'user', content: 'kept' }],
45
+ tools: [],
46
+ _providerPrefixGuardState: {
47
+ messageHashes: ['stale'],
48
+ requestPrefixHash: 'stale',
49
+ },
50
+ }), 'utf8');
51
+
52
+ const loaded = _storedSessionFromFile(dir, `${id}.json`);
53
+
54
+ assert.ok(loaded);
55
+ assert.equal(Object.hasOwn(loaded, '_providerPrefixGuardState'), false);
56
+ assert.equal(loaded.messages[0].content, 'kept');
57
+ } finally {
58
+ await rm(dir, { recursive: true, force: true });
59
+ }
60
+ });
@@ -0,0 +1,78 @@
1
+ // Per-session wake control for `task wait`.
2
+ //
3
+ // This is intentionally separate from the turn AbortController: new user input
4
+ // should release only the passive wait, let the tool return a running snapshot,
5
+ // and then flow through the normal post-tool steering boundary. The background
6
+ // task itself keeps running.
7
+ const waitersBySession = new Map();
8
+
9
+ function sessionKey(sessionId) {
10
+ return String(sessionId || '').trim();
11
+ }
12
+
13
+ export function beginInterruptibleTaskWait(sessionId, parentSignal = null) {
14
+ const key = sessionKey(sessionId);
15
+ if (!key) {
16
+ return {
17
+ signal: parentSignal || null,
18
+ interruptedByUser: false,
19
+ dispose() {},
20
+ };
21
+ }
22
+
23
+ const controller = new AbortController();
24
+ const waiter = {
25
+ controller,
26
+ interruptedByUser: false,
27
+ };
28
+ let waiters = waitersBySession.get(key);
29
+ if (!waiters) {
30
+ waiters = new Set();
31
+ waitersBySession.set(key, waiters);
32
+ }
33
+ waiters.add(waiter);
34
+
35
+ let disposed = false;
36
+ const onParentAbort = () => {
37
+ if (!controller.signal.aborted) controller.abort(parentSignal?.reason);
38
+ };
39
+ if (parentSignal) {
40
+ if (parentSignal.aborted) onParentAbort();
41
+ else {
42
+ try { parentSignal.addEventListener('abort', onParentAbort, { once: true }); } catch {}
43
+ }
44
+ }
45
+
46
+ return {
47
+ signal: controller.signal,
48
+ get interruptedByUser() {
49
+ return waiter.interruptedByUser;
50
+ },
51
+ dispose() {
52
+ if (disposed) return;
53
+ disposed = true;
54
+ if (parentSignal) {
55
+ try { parentSignal.removeEventListener('abort', onParentAbort); } catch {}
56
+ }
57
+ waiters.delete(waiter);
58
+ if (waiters.size === 0) waitersBySession.delete(key);
59
+ },
60
+ };
61
+ }
62
+
63
+ export function interruptTaskWaitForSession(sessionId, reason = 'user-message') {
64
+ const waiters = waitersBySession.get(sessionKey(sessionId));
65
+ if (!waiters?.size) return 0;
66
+ let interrupted = 0;
67
+ for (const waiter of [...waiters]) {
68
+ if (waiter.controller.signal.aborted) continue;
69
+ waiter.interruptedByUser = true;
70
+ interrupted += 1;
71
+ try { waiter.controller.abort(reason); } catch {}
72
+ }
73
+ return interrupted;
74
+ }
75
+
76
+ export function activeTaskWaitCountForSession(sessionId) {
77
+ return waitersBySession.get(sessionKey(sessionId))?.size || 0;
78
+ }
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ activeTaskWaitCountForSession,
6
+ beginInterruptibleTaskWait,
7
+ interruptTaskWaitForSession,
8
+ } from './task-wait-control.mjs';
9
+ import {
10
+ cancelBackgroundTask,
11
+ getBackgroundTask,
12
+ registerBackgroundTask,
13
+ } from '../../../shared/background-tasks.mjs';
14
+ import { executeTaskTool } from '../tools/builtin/task-tool.mjs';
15
+
16
+ test('user input interrupts only task waits in its session', () => {
17
+ const first = beginInterruptibleTaskWait('sess_wait_1');
18
+ const second = beginInterruptibleTaskWait('sess_wait_1');
19
+ const other = beginInterruptibleTaskWait('sess_wait_2');
20
+
21
+ assert.equal(activeTaskWaitCountForSession('sess_wait_1'), 2);
22
+ assert.equal(interruptTaskWaitForSession('sess_wait_1'), 2);
23
+ assert.equal(first.signal.aborted, true);
24
+ assert.equal(second.signal.aborted, true);
25
+ assert.equal(first.interruptedByUser, true);
26
+ assert.equal(second.interruptedByUser, true);
27
+ assert.equal(other.signal.aborted, false);
28
+
29
+ first.dispose();
30
+ second.dispose();
31
+ other.dispose();
32
+ assert.equal(activeTaskWaitCountForSession('sess_wait_1'), 0);
33
+ });
34
+
35
+ test('turn cancellation propagates without masquerading as user input', () => {
36
+ const parent = new AbortController();
37
+ const wait = beginInterruptibleTaskWait('sess_parent_abort', parent.signal);
38
+
39
+ parent.abort('turn-cancelled');
40
+ assert.equal(wait.signal.aborted, true);
41
+ assert.equal(wait.interruptedByUser, false);
42
+
43
+ wait.dispose();
44
+ });
45
+
46
+ test('task wait returns on user input without cancelling the background task', async () => {
47
+ const sessionId = 'sess_task_wait_integration';
48
+ const taskId = 'task_shell_wait_integration';
49
+ const task = registerBackgroundTask({
50
+ taskId,
51
+ surface: 'shell',
52
+ operation: 'run',
53
+ context: {
54
+ callerSessionId: sessionId,
55
+ routingSessionId: sessionId,
56
+ },
57
+ });
58
+ task.promise = new Promise(() => {});
59
+
60
+ const interruptTimer = setTimeout(
61
+ () => interruptTaskWaitForSession(sessionId),
62
+ 20,
63
+ );
64
+ const startedAt = Date.now();
65
+ try {
66
+ const result = await executeTaskTool(
67
+ { action: 'wait', task_id: taskId, timeout_ms: 10_000 },
68
+ { sessionId, callerSessionId: sessionId, routingSessionId: sessionId },
69
+ );
70
+
71
+ assert.ok(Date.now() - startedAt < 1_000, 'wait should return before its ceiling');
72
+ assert.match(result, /Wait interrupted by new user input/);
73
+ assert.equal(getBackgroundTask(taskId)?.status, 'running');
74
+ } finally {
75
+ clearTimeout(interruptTimer);
76
+ cancelBackgroundTask(taskId, 'test cleanup');
77
+ }
78
+ });
@@ -0,0 +1,43 @@
1
+ // Absence absorption: a conclusive "no such path" (read/list) and git's
2
+ // exit-128 state verdict are ANSWERS, not tool failures. The full diagnostic
3
+ // body is preserved — only the error envelope is dropped, so the caller acts
4
+ // on the evidence instead of entering error recovery.
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import test from 'node:test';
10
+
11
+ import { executeBuiltinTool } from '../builtin.mjs';
12
+ import { executeGitTool } from './git-command-tool.mjs';
13
+
14
+ test('read of a conclusively missing path answers [path absent], not Error', async (t) => {
15
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-absent-read-'));
16
+ t.after(() => rmSync(root, { recursive: true, force: true }));
17
+ const out = String(await executeBuiltinTool('read', { path: join(root, 'no-such-file.txt') }, root));
18
+ assert.match(out, /^\[path absent\]/);
19
+ assert.match(out, /ENOENT/);
20
+ assert.doesNotMatch(out, /^Error:/);
21
+ });
22
+
23
+ test('list of a conclusively missing directory answers [path absent], not Error', async (t) => {
24
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-absent-list-'));
25
+ t.after(() => rmSync(root, { recursive: true, force: true }));
26
+ const out = String(await executeBuiltinTool('list', { path: join(root, 'no-such-dir') }, root));
27
+ assert.match(out, /^\[path absent\]/);
28
+ assert.doesNotMatch(out, /^Error:/);
29
+ });
30
+
31
+ test('git exit 128 returns the state verdict as a non-error envelope', async (t) => {
32
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-absent-git128-'));
33
+ t.after(() => rmSync(root, { recursive: true, force: true }));
34
+ const repo = join(root, 'repo');
35
+ await executeGitTool({ command: `git init "${repo}"` }, root);
36
+ // Unborn branch: `git log` exits 128 with git's own explanation.
37
+ const out = String(await executeGitTool({ command: `git -C "${repo}" log` }, repo));
38
+ assert.doesNotMatch(out, /^Error:/);
39
+ const parsed = JSON.parse(out);
40
+ assert.equal(parsed.ok, false);
41
+ assert.equal(parsed.exit, 128);
42
+ assert.ok(String(parsed.stderr || parsed.reason || '').length > 0);
43
+ });
@@ -0,0 +1,63 @@
1
+ // An absolute glob is expanded straight off the filesystem when the inventory
2
+ // walker cannot see its targets (symlink farms such as /sys and /proc). The
3
+ // pattern bounds the walk; anything unbounded or unsupported declines.
4
+ import assert from 'node:assert/strict';
5
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import test from 'node:test';
9
+
10
+ import { expandAbsoluteGlob, expandAbsoluteGlobs } from './lib/absolute-glob-expand.mjs';
11
+
12
+ async function withBlockTree(run) {
13
+ const root = await mkdtemp(join(tmpdir(), 'mixdog-abs-glob-'));
14
+ try {
15
+ for (const disk of ['sda', 'sdb', 'nvme0n1']) {
16
+ await mkdir(join(root, 'block', disk, 'device'), { recursive: true });
17
+ await writeFile(join(root, 'block', disk, 'size'), '2048\n');
18
+ await writeFile(join(root, 'block', disk, 'device', 'model'), `${disk}-model\n`);
19
+ }
20
+ return await run(root);
21
+ } finally {
22
+ await rm(root, { recursive: true, force: true });
23
+ }
24
+ }
25
+
26
+ test('a magic middle segment expands to every matching path', async () => {
27
+ await withBlockTree(async (root) => {
28
+ const sizes = await expandAbsoluteGlob(join(root, 'block', 'sd*', 'size'));
29
+ assert.equal(sizes.length, 2);
30
+ assert.ok(sizes.every((p) => p.endsWith('/size')));
31
+ assert.ok(sizes.some((p) => p.includes('/sda/')));
32
+ assert.ok(sizes.some((p) => p.includes('/sdb/')));
33
+
34
+ const models = await expandAbsoluteGlob(join(root, 'block', '*', 'device', 'model'));
35
+ assert.equal(models.length, 3);
36
+ });
37
+ });
38
+
39
+ test('a pattern with no matches answers empty, not null', async () => {
40
+ await withBlockTree(async (root) => {
41
+ assert.deepEqual(await expandAbsoluteGlob(join(root, 'block', 'hd*', 'size')), []);
42
+ });
43
+ });
44
+
45
+ test('unbounded or non-absolute patterns decline so the caller keeps its answer', async () => {
46
+ await withBlockTree(async (root) => {
47
+ assert.equal(await expandAbsoluteGlob(join(root, '**', 'size')), null);
48
+ assert.equal(await expandAbsoluteGlob('block/sd*/size'), null);
49
+ assert.equal(await expandAbsoluteGlob(join(root, 'block', 'sda', 'size')), null);
50
+ });
51
+ });
52
+
53
+ test('the batch form dedupes and honours its limit', async () => {
54
+ await withBlockTree(async (root) => {
55
+ const found = await expandAbsoluteGlobs([
56
+ join(root, 'block', 'sd*', 'size'),
57
+ join(root, 'block', 's*a', 'size'),
58
+ ], { limit: 5 });
59
+ assert.equal(found.length, 2);
60
+ const limited = await expandAbsoluteGlobs([join(root, 'block', '*', 'size')], { limit: 1 });
61
+ assert.equal(limited.length, 1);
62
+ });
63
+ });
@@ -555,6 +555,14 @@ function maybeCapUnboundedRead(a) {
555
555
  if (/:\d+(?:-\d+)?\s*$/.test(p)) return;
556
556
  }
557
557
  a.limit = READ_GUARD_DEFAULT_LIMIT;
558
+ // Marker for the read tool's glob fan-out: this limit is the injected
559
+ // default, not a caller choice, so a multi-file survey may tighten it.
560
+ // Non-enumerable so it never leaks into arg spreads, cache keys, or output.
561
+ try {
562
+ Object.defineProperty(a, '_readLimitDefaulted', {
563
+ value: true, enumerable: false, configurable: true, writable: true,
564
+ });
565
+ } catch { /* best-effort */ }
558
566
  }
559
567
 
560
568
  function guardRead(a) {
@@ -1,4 +1,4 @@
1
- import { statSync, createWriteStream } from 'fs';
1
+ import { statSync, lstatSync, realpathSync, createWriteStream } from 'fs';
2
2
  import * as fsPromises from 'fs/promises';
3
3
  import { basename, dirname, join } from 'path';
4
4
  import { performance } from 'perf_hooks';
@@ -73,6 +73,21 @@ async function cleanupEmptyWxTarget(targetPath) {
73
73
  } catch { /* already gone or unreadable — leave it */ }
74
74
  }
75
75
 
76
+ // The real file a write aimed at `targetPath` must land on when the leaf is a
77
+ // symlink, or null when it is not a link (or is dangling, so there is nothing
78
+ // to resolve). Writers rename a temp file into place, which would replace the
79
+ // LINK itself with a regular file and silently detach layouts like nginx's
80
+ // sites-enabled/default from sites-available/default. Engines that write a
81
+ // path themselves use this as "do not hand this target to the engine".
82
+ export function symlinkWriteTarget(targetPath) {
83
+ try {
84
+ if (!lstatSync(targetPath).isSymbolicLink()) return null;
85
+ return realpathSync(targetPath);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
76
91
  export async function atomicWrite(targetPath, content, {
77
92
  mode,
78
93
  signal,
@@ -95,12 +110,16 @@ export async function atomicWrite(targetPath, content, {
95
110
  };
96
111
  if (resolvedSignal?.aborted) throw abortReason();
97
112
 
98
- const dir = dirname(targetPath);
113
+ // Write THROUGH a leaf symlink: resolve it first so the rename replaces the
114
+ // file the link points at, not the link. Resolving also keeps the temp file
115
+ // beside the real target, so the rename stays on one filesystem.
116
+ const writeTarget = symlinkWriteTarget(targetPath) ?? targetPath;
117
+ const dir = dirname(writeTarget);
99
118
  const rnd = randomBytes(4).toString('hex');
100
- const tmp = join(dir, `.${basename(targetPath)}.mixdog-tmp-${rnd}`);
119
+ const tmp = join(dir, `.${basename(writeTarget)}.mixdog-tmp-${rnd}`);
101
120
  let effectiveMode = mode;
102
121
  let existingStat = null;
103
- try { existingStat = statSync(targetPath); } catch { /* target doesn't exist */ }
122
+ try { existingStat = statSync(writeTarget); } catch { /* target doesn't exist */ }
104
123
  if (effectiveMode === undefined && existingStat) {
105
124
  effectiveMode = existingStat.mode & 0o777;
106
125
  }
@@ -165,7 +184,7 @@ export async function atomicWrite(targetPath, content, {
165
184
  if (flags === 'wx') {
166
185
  let excl = null;
167
186
  try {
168
- excl = await fsPromises.open(targetPath, 'wx');
187
+ excl = await fsPromises.open(writeTarget, 'wx');
169
188
  await excl.close();
170
189
  } catch (exclErr) {
171
190
  if (excl) try { await excl.close(); } catch { /* already closed */ }
@@ -179,7 +198,7 @@ export async function atomicWrite(targetPath, content, {
179
198
 
180
199
  if (resolvedSignal?.aborted) {
181
200
  try { await fsPromises.unlink(tmp); } catch { /* already gone */ }
182
- if (flags === 'wx') await cleanupEmptyWxTarget(targetPath);
201
+ if (flags === 'wx') await cleanupEmptyWxTarget(writeTarget);
183
202
  throw abortReason();
184
203
  }
185
204
 
@@ -188,16 +207,16 @@ export async function atomicWrite(targetPath, content, {
188
207
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
189
208
  if (expectedTargetSnapshot) {
190
209
  let currentStat = null;
191
- try { currentStat = statSync(targetPath); } catch { currentStat = null; }
210
+ try { currentStat = statSync(writeTarget); } catch { currentStat = null; }
192
211
  if (expectedTargetSnapshotChanged(currentStat, expectedTargetSnapshot)) {
193
212
  try { await fsPromises.unlink(tmp); } catch { /* already gone */ }
194
- const err = new Error(`target changed between preflight and rename (TOCTOU): ${targetPath}`);
213
+ const err = new Error(`target changed between preflight and rename (TOCTOU): ${writeTarget}`);
195
214
  err.code = 'ESTALE_TARGET';
196
215
  throw err;
197
216
  }
198
217
  }
199
218
  try {
200
- await fsPromises.rename(tmp, targetPath);
219
+ await fsPromises.rename(tmp, writeTarget);
201
220
  // When fsync is requested, also fsync the parent directory so
202
221
  // the rename itself is durable across power-loss. Directory
203
222
  // fsync is a no-op / unsupported on Windows; swallow EPERM /
@@ -231,6 +250,6 @@ export async function atomicWrite(targetPath, content, {
231
250
  }
232
251
  }
233
252
  try { await fsPromises.unlink(tmp); } catch { /* already gone */ }
234
- if (flags === 'wx') await cleanupEmptyWxTarget(targetPath);
253
+ if (flags === 'wx') await cleanupEmptyWxTarget(writeTarget);
235
254
  throw lastErr;
236
255
  }
@@ -44,12 +44,12 @@ export const BUILTIN_TOOLS = [
44
44
  name: 'read',
45
45
  title: 'Read',
46
46
  annotations: { title: 'Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
47
- description: 'Read-only; safe to batch in parallel. Known-file contents or line ranges, bounded to the narrowest range that answers the question. Images render for viewing; not directories. Replaces cat/head/tail.',
47
+ description: 'Read-only; safe to batch in parallel. Known-file contents or line ranges, bounded to the narrowest range that answers the question — when the answer depends on content not yet seen, read the wider range once instead of deciding from a partial view. Images render for viewing; not directories. Replaces cat/head/tail.',
48
48
  inputSchema: {
49
49
  type: 'object',
50
50
  properties: {
51
51
  file_path: {
52
- type: 'string', description: 'Known file path as plain text. A glob (e.g. "logs/*.log") fans out to per-file results (cap 10, newest first); literal-named files win over expansion.',
52
+ type: 'string', description: 'Known file path as plain text. A glob (e.g. "logs/*.log") fans out to per-file results (cap 10 files, newest first; limit applies per file and is capped at 25 lines; all files share a 10 KB output budget); literal-named files win over expansion.',
53
53
  },
54
54
  offset: {
55
55
  type: 'integer',
@@ -59,7 +59,7 @@ export const BUILTIN_TOOLS = [
59
59
  limit: {
60
60
  type: 'integer',
61
61
  minimum: 1,
62
- description: 'Maximum line count as a bare integer; default 500.',
62
+ description: 'Maximum line count as a bare integer; default 500 for one exact file, capped at 25 per file for glob expansion.',
63
63
  },
64
64
  },
65
65
  required: ['file_path'],
@@ -107,7 +107,7 @@ export const BUILTIN_TOOLS = [
107
107
  timeout_ms: {
108
108
  type: 'number',
109
109
  minimum: 0,
110
- description: 'Optional hard total deadline in ms; kills the command even after background promotion. Omit or 0 = no deadline.',
110
+ description: 'Hard process-kill deadline in ms; unrelated to the automatic 10s foreground window and still applies after background promotion. Omit for normal builds/tests; use only when forced termination is intended. Omit or 0 = no deadline.',
111
111
  },
112
112
  },
113
113
  required: ['command'],
@@ -160,7 +160,7 @@ export const BUILTIN_TOOLS = [
160
160
  name: 'grep',
161
161
  title: 'Grep',
162
162
  annotations: { title: 'Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
163
- description: 'Read-only; safe to batch in parallel. Search file contents for literal or regex matches and return contextual path:line blocks that are directly usable; read only the lines they omit. A wide reconnaissance pattern goes to mode:files first; context:0 when only the location is needed. Ripgrep-dialect regex (e.g. "log.*Error"; escape literal braces; patterns match within one line). Replaces grep/rg.',
163
+ description: 'Read-only; safe to batch in parallel. Search file contents for literal or regex matches and return contextual path:line blocks that are directly usable; read only the lines they omit. All rendered output is capped at 10 KB. A wide reconnaissance pattern goes to mode:files first; context:0 when only the location is needed. Ripgrep-dialect regex (e.g. "log.*Error"; escape literal braces; patterns match within one line). Replaces grep/rg.',
164
164
  inputSchema: {
165
165
  type: 'object',
166
166
  properties: {
@@ -175,7 +175,7 @@ export const BUILTIN_TOOLS = [
175
175
  type: 'string', description: 'One plain existing file or directory scope; if unsure, omit to search the project root.',
176
176
  },
177
177
  glob: {
178
- type: 'string', description: 'Optional file-path glob filter, not search text.',
178
+ type: 'string', description: 'Relative file-path glob filter evaluated inside path (e.g. "*.cs", "src/**/*.ts"). Never pass an absolute or exact file path here; use path instead.',
179
179
  },
180
180
  mode: { type: 'string', enum: ['content', 'files', 'count'], description: 'content default; files lists matching paths; count totals all patterns together per file.' },
181
181
  limit: { type: 'integer', minimum: 0, description: 'Max results; default 250; 0 unlimited.' },
@@ -244,7 +244,7 @@ export const BUILTIN_TOOLS = [
244
244
  },
245
245
  hidden: { type: 'boolean', description: 'Include dotfiles.' },
246
246
  meta: { type: 'boolean', description: 'Per-entry size bytes, UTC mtime, octal mode.' },
247
- limit: { type: 'integer', minimum: 0, description: 'Max entries; default 100; 0 unlimited.' },
247
+ limit: { type: 'integer', minimum: 0, maximum: 100, description: 'Max entries; default 100; 0 = no page cap (absolute cap still applies).' },
248
248
  offset: { type: 'integer', minimum: 0, description: 'Entry offset.' },
249
249
  },
250
250
  required: [],
@@ -0,0 +1,56 @@
1
+ // A missing ABSOLUTE path outside the project is recovered from its OWN
2
+ // filesystem, never from a project-wide scan: no file inside the project can
3
+ // be the correction for /sys/block/sd*/size, and the scan is what turned three
4
+ // such reads into 17.5s each.
5
+ import assert from 'node:assert/strict';
6
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import test from 'node:test';
10
+
11
+ import { executeBuiltinTool } from '../builtin.mjs';
12
+
13
+ async function withDirs(run) {
14
+ const project = await mkdtemp(join(tmpdir(), 'mixdog-enoent-project-'));
15
+ const outside = await mkdtemp(join(tmpdir(), 'mixdog-enoent-outside-'));
16
+ try {
17
+ return await run({ project, outside });
18
+ } finally {
19
+ await rm(project, { recursive: true, force: true });
20
+ await rm(outside, { recursive: true, force: true });
21
+ }
22
+ }
23
+
24
+ test('an outside absolute miss names its own nearest directory, not the project', async () => {
25
+ await withDirs(async ({ project, outside }) => {
26
+ await mkdir(join(project, 'src'), { recursive: true });
27
+ await writeFile(join(project, 'src', 'size'), 'decoy\n');
28
+ await mkdir(join(outside, 'block'), { recursive: true });
29
+ await writeFile(join(outside, 'block', 'sda'), 'disk\n');
30
+
31
+ const out = String(await executeBuiltinTool('read', {
32
+ file_path: join(outside, 'block', 'sd*', 'size'),
33
+ }, project));
34
+
35
+ assert.match(out, /\[path absent\]/);
36
+ assert.match(out, /nearest existing directory/);
37
+ assert.match(out, /"sda"/);
38
+ // The decoy inside the project must never be offered for an outside path.
39
+ assert.doesNotMatch(out, /same filename exists at/);
40
+ assert.doesNotMatch(out, /project scan/);
41
+ });
42
+ });
43
+
44
+ test('a miss INSIDE the project keeps its same-name recovery', async () => {
45
+ await withDirs(async ({ project }) => {
46
+ await mkdir(join(project, 'src', 'tools'), { recursive: true });
47
+ await writeFile(join(project, 'src', 'tools', 'widget.mjs'), 'export const widget = 1;\n');
48
+
49
+ const out = String(await executeBuiltinTool('read', {
50
+ file_path: join(project, 'lib', 'widget.mjs'),
51
+ }, project));
52
+
53
+ assert.match(out, /widget\.mjs/);
54
+ assert.doesNotMatch(out, /nearest existing directory/);
55
+ });
56
+ });
@@ -13,7 +13,7 @@
13
13
  // the existing EXTERNAL_TOOL_REDIRECTS guidance message in that case.
14
14
  import { readFileSync, mkdirSync, existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
15
15
  import { dirname } from 'node:path';
16
- import { atomicWrite } from './atomic-write.mjs';
16
+ import { atomicWrite, symlinkWriteTarget } from './atomic-write.mjs';
17
17
  import { assertPathsReachable } from './fs-reachability.mjs';
18
18
  import { normalizeInputPath, resolveAgainstCwd, normalizeOutputPath } from './path-utils.mjs';
19
19
  import { isUncPath, isWindowsDevicePath, hasUnsafeWin32Component, isBlockedDevicePath, isSpecialFileStat } from './device-paths.mjs';
@@ -587,7 +587,11 @@ async function adaptStrReplace(args, workDir, options) {
587
587
  // An unverified native edit session never receives work: the in-process
588
588
  // apply below is the fallback, so a stale/spoofed engine cannot touch bytes.
589
589
  const nativeEditVerified = await nativeEditSessionSatisfiesContract();
590
- if (!nativeEditVerified || fileEnc.encoding !== 'utf8' || editPlan.rebased || !editPlan.uniform) {
590
+ // A symlinked target never reaches the native engine either: it writes the
591
+ // path it is handed, which would replace the link with a regular file. The
592
+ // in-process apply goes through atomicWrite, which writes through the link.
593
+ const symlinkedTarget = symlinkWriteTarget(fullPath) !== null;
594
+ if (!nativeEditVerified || symlinkedTarget || fileEnc.encoding !== 'utf8' || editPlan.rebased || !editPlan.uniform) {
591
595
  const occurrences = editPlan.positions.length;
592
596
  if (occurrences === 0) {
593
597
  const message = 'old_string not found';