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.
- package/README.md +28 -12
- package/package.json +1 -1
- package/src/headless-exec.mjs +40 -4
- package/src/headless-exec.test.mjs +116 -1
- package/src/rules/lead/01-general.md +3 -2
- package/src/rules/shared/00-general.md +1 -0
- package/src/rules/shared/10-tool-workflow.md +11 -12
- package/src/rules/shared/30-exploration.md +4 -3
- package/src/rules/shared/60-verification.md +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +0 -2
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +1 -4
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +0 -18
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +0 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +4 -72
- package/src/runtime/agent/orchestrator/session/approval-mode.mjs +0 -8
- package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +50 -8
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +31 -1
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +27 -4
- package/src/runtime/agent/orchestrator/session/store/serialize.test.mjs +60 -0
- package/src/runtime/agent/orchestrator/session/task-wait-control.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/task-wait-control.test.mjs +78 -0
- package/src/runtime/agent/orchestrator/tools/builtin/absence-absorption.test.mjs +43 -0
- package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +50 -23
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +50 -1
- package/src/runtime/agent/orchestrator/tools/builtin/grep-output-budget.test.mjs +31 -0
- package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +50 -2
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +7 -1
- package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-glob-survey.test.mjs +71 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +54 -8
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +90 -51
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.test.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +25 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.test.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +30 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +49 -24
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +33 -1
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +9 -1
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
- package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +42 -23
- package/src/runtime/media/renditions.mjs +7 -1
- package/src/runtime/memory/index.mjs +2 -4
- package/src/runtime/memory/lib/pg/process.mjs +29 -2
- package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
- package/src/runtime/memory/lib/query-handlers.mjs +13 -19
- package/src/runtime/memory/lib/recall-format.mjs +13 -31
- package/src/runtime/memory/tool-defs.mjs +2 -2
- package/src/runtime/shared/memory-snapshot.mjs +57 -4
- package/src/runtime/shared/pristine-execution-contract.json +3 -0
- package/src/runtime/shared/pristine-execution.mjs +6 -3
- package/src/runtime/shared/tool-surface.mjs +6 -0
- package/src/runtime/shared/tool-surface.test.mjs +8 -0
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/session-runtime/session-turn-api.mjs +5 -0
- package/src/standalone/agent-dispatch-broker.mjs +25 -80
- package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
- package/src/standalone/channel-worker.mjs +31 -1
- package/src/standalone/daemon.mjs +9 -9
- package/src/standalone/memory-runtime-proxy.mjs +5 -2
- package/src/standalone/session-client.mjs +23 -3
- package/src/standalone/session-runtime-host-health.test.mjs +36 -0
- package/src/standalone/session-runtime-host.mjs +26 -76
- package/src/standalone/session-runtime-worker.mjs +97 -0
- package/src/tui/App.jsx +3 -2
- package/src/tui/app/use-global-key-input.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +77 -14
- package/src/tui/components/Spinner.jsx +1 -1
- package/src/tui/dist/index.mjs +83 -23
- package/src/tui/lib/voice-setup.mjs +3 -2
- package/src/tui/session/completion-card-restore.test.mjs +18 -0
- package/src/tui/session/session-api-ext.mjs +2 -1
- package/src/tui/session/session-api.mjs +8 -1
- package/src/tui/session/task-wait-submit.test.mjs +80 -0
- package/src/tui/session/turn.mjs +58 -6
- package/src/tui/spinner-verbs.mjs +1 -0
- package/src/workflows/default/WORKFLOW.md +12 -19
- package/src/workflows/headless/WORKFLOW.md +11 -0
- 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
|
-
|
|
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(
|
|
119
|
+
const tmp = join(dir, `.${basename(writeTarget)}.mixdog-tmp-${rnd}`);
|
|
101
120
|
let effectiveMode = mode;
|
|
102
121
|
let existingStat = null;
|
|
103
|
-
try { existingStat = statSync(
|
|
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(
|
|
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(
|
|
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(
|
|
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): ${
|
|
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,
|
|
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(
|
|
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: '
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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';
|