mixdog 0.9.150 → 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 (53) hide show
  1. package/README.md +28 -11
  2. package/package.json +1 -1
  3. package/src/headless-exec.mjs +16 -3
  4. package/src/headless-exec.test.mjs +44 -1
  5. package/src/rules/shared/30-exploration.md +2 -1
  6. package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
  7. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
  8. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
  9. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
  10. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -20
  11. package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
  12. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
  13. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
  16. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -16
  17. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +28 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
  19. package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
  20. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
  21. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +33 -0
  22. package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
  23. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
  24. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +26 -6
  25. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +7 -66
  26. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +11 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +25 -1
  28. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
  29. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
  31. package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
  32. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
  33. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  34. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
  36. package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
  37. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
  38. package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
  39. package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
  40. package/src/runtime/memory/index.mjs +2 -4
  41. package/src/runtime/memory/lib/pg/process.mjs +29 -2
  42. package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
  43. package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
  44. package/src/runtime/memory/lib/query-handlers.mjs +7 -6
  45. package/src/runtime/memory/lib/recall-format.mjs +13 -31
  46. package/src/runtime/shared/pristine-execution-contract.json +3 -0
  47. package/src/runtime/shared/pristine-execution.mjs +5 -2
  48. package/src/session-runtime/lifecycle-api.mjs +1 -0
  49. package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
  50. package/src/standalone/channel-worker.mjs +31 -1
  51. package/src/standalone/memory-runtime-proxy.mjs +5 -2
  52. package/src/standalone/session-client.mjs +23 -3
  53. package/src/tui/session/turn.mjs +12 -0
@@ -41,11 +41,7 @@ import { ensureCodexWireSessionId, mintSessionId, mintUuidV7 } from './session-i
41
41
  import { providerCacheKey } from './provider-cache-key.mjs';
42
42
  import { clearTurnCheckpoint, recoverTurnCheckpoint } from './turn-checkpoint.mjs';
43
43
  import { IMPLICIT_APPROVAL_MODE } from '../approval-mode.mjs';
44
- import {
45
- describeGitStartupState,
46
- describeShellStartupPolicy,
47
- detectPathCapabilities,
48
- } from '../../tools/builtin/runtime-capabilities.mjs';
44
+ import { describeGitStartupState } from '../../tools/builtin/runtime-capabilities.mjs';
49
45
  import { captureOriginalUserCwd } from '../../../../shared/user-cwd.mjs';
50
46
  import { refreshSessionBp3Environment } from './prompt-utils.mjs';
51
47
 
@@ -315,12 +311,7 @@ export function createSession(opts) {
315
311
  // resolves explicit session signals first and never leaks the daemon's
316
312
  // install root (user-cwd.mjs safe fallback chain).
317
313
  const sessionCwdLine = opts.cwd || captureOriginalUserCwd();
318
- const wantsShellStartupLine = toolsForRouting.some((tool) => tool?.name === 'shell');
319
314
  const wantsGitStartupLine = toolsForRouting.some((tool) => tool?.name === 'git');
320
- // One PATH walk feeds every startup line below.
321
- const startupCapabilities = wantsShellStartupLine || wantsGitStartupLine
322
- ? detectPathCapabilities()
323
- : null;
324
315
  const shellEnvironmentContext = [
325
316
  sessionCwdLine
326
317
  ? `- Cwd: ${sessionCwdLine} — the active Project root; relative paths and shell commands resolve here.`
@@ -328,18 +319,15 @@ export function createSession(opts) {
328
319
  !ownerIsAgent
329
320
  ? `- Shell: ${process.platform === 'win32' ? 'PowerShell' : 'Bash'}. Use ${process.platform === 'win32' ? 'PowerShell' : 'Bash'} syntax unless the user specifies otherwise.`
330
321
  : '',
331
- wantsShellStartupLine
332
- ? describeShellStartupPolicy({ capabilities: startupCapabilities })
333
- : '',
334
- // Whether the cwd is inside a repository is the fact the git tool
335
- // needs and cannot infer: a PATH listing only says the binary exists.
336
- // Without it a session spends a call discovering `exited 128`, and
322
+ // A startup inventory of PATH binaries used to sit here; see
323
+ // runtime-capabilities.mjs for why it cannot be stated truthfully
324
+ // before a command has run. Whether the cwd is inside a repository is
325
+ // different: it is a property of this directory, true at startup and
326
+ // observable without spawning anything, and the git tool cannot infer
327
+ // it. Without it a session spends a call discovering `exited 128`, and
337
328
  // repeats it per candidate path.
338
329
  wantsGitStartupLine
339
- ? describeGitStartupState({
340
- capabilities: startupCapabilities,
341
- ...(sessionCwdLine ? { cwd: sessionCwdLine } : {}),
342
- })
330
+ ? describeGitStartupState(sessionCwdLine ? { cwd: sessionCwdLine } : {})
343
331
  : '',
344
332
  ].filter(Boolean).join('\n');
345
333
  const { baseRules, stableSystemContext, sessionMarkerCore, sessionEnvironment } = composeSystemPrompt({
@@ -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
+ });
@@ -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,7 +44,7 @@ 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. Read only lines not already returned by another tool, using the smallest anchored range needed; never read an entire file when an existing result provides the relevant location. Do not read an output artifact after a still-valid passed check has established the needed facts. 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: {
@@ -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';
@@ -21,21 +21,31 @@ import {
21
21
  const DEFAULT_TIMEOUT_MS = 120_000;
22
22
  const MAX_CAPTURE_BYTES = 128 * 1024 * 1024;
23
23
  const GIT_OUTPUT_LIMIT_MAX = 200;
24
- const SUPPORTED = new Set([
25
- 'add', 'am', 'apply', 'archive', 'bisect', 'blame', 'branch', 'bundle',
26
- 'cat-file', 'check-attr', 'check-ignore', 'check-ref-format', 'checkout',
27
- 'cherry', 'cherry-pick', 'clean', 'clone', 'commit', 'config',
28
- 'count-objects', 'describe', 'diff', 'diff-files', 'diff-index', 'diff-tree',
29
- 'fetch', 'filter-branch', 'for-each-ref', 'format-patch', 'fsck', 'gc',
30
- 'grep', 'hash-object', 'help', 'init', 'log', 'ls-files', 'ls-remote',
31
- 'ls-tree', 'maintenance', 'merge', 'merge-base', 'merge-tree', 'mv',
32
- 'name-rev', 'notes', 'pack-refs', 'prune', 'pull', 'push', 'range-diff',
33
- 'rebase', 'reflog', 'remote', 'repack', 'replace', 'reset', 'restore',
34
- 'revert', 'rev-list', 'rev-parse', 'rm', 'shortlog', 'show', 'show-branch',
35
- 'show-ref', 'sparse-checkout', 'stash', 'status', 'submodule', 'switch',
36
- 'symbolic-ref', 'tag', 'update-ref', 'verify-commit', 'verify-pack',
37
- 'verify-tag', 'version', 'whatchanged', 'worktree', 'write-tree',
38
- ]);
24
+ // git's subcommand namespace is open-ended: any `git-*` executable on PATH
25
+ // (git-filter-repo, git-lfs, in-house wrappers) dispatches as a subcommand, so
26
+ // a finite allowlist can never keep up. It rejected `git filter-repo` as an
27
+ // "unsupported subcommand" while the shell tool still ran the very same
28
+ // command unblocked — buying no safety and costing a whole turn. So nothing is
29
+ // pre-rejected for being unknown: git itself reports a genuinely unknown
30
+ // subcommand, and that error tells the model what to do next.
31
+ //
32
+ // Destructive history/tree rewrites stay allowed on purpose. They are usually
33
+ // the actual job, reflog and loose objects make most of them recoverable, and
34
+ // the workflow already gates destructive git work.
35
+ //
36
+ // What this tool genuinely cannot host is a command that never returns, since
37
+ // each call occupies one synchronous turn. The classic hangs are already dead
38
+ // via the pinned GIT_PAGER / GIT_EDITOR / GIT_SEQUENCE_EDITOR /
39
+ // GIT_TERMINAL_PROMPT environment in runProcess, which leaves two shapes.
40
+ //
41
+ // A GUI subcommand blocks on a window or a stdin prompt that this process has
42
+ // no way to satisfy.
43
+ const GUI_OPERATIONS = new Set(['gui', 'citool', 'gitk', 'difftool', 'mergetool']);
44
+ // A resident server's correct behaviour IS to keep running, so it can only
45
+ // burn the full timeout here. It is not a bad command, just one in the wrong
46
+ // tool: shell promotes a long-running process to a background task, so point
47
+ // there instead of failing it outright.
48
+ const SERVER_OPERATIONS = new Set(['daemon', 'instaweb']);
39
49
  // `git --version` / `git --help` are the standard availability probes and
40
50
  // arrive in global-flag position, not as subcommands. Map them onto their
41
51
  // subcommand form so the probe answers instead of erroring out.
@@ -197,7 +207,13 @@ function parseCommand(command, workDir) {
197
207
  }
198
208
  const rawOperation = String(tokens[index] || '').toLowerCase();
199
209
  const operation = OPERATION_ALIASES.get(rawOperation) ?? rawOperation;
200
- if (!SUPPORTED.has(operation)) throw new Error(`unsupported git subcommand: ${rawOperation || '(missing)'}`);
210
+ if (!operation) throw new Error('git requires a subcommand');
211
+ if (GUI_OPERATIONS.has(operation)) {
212
+ throw new Error(`git ${operation} opens an interactive GUI and would never return here; use its non-interactive form (diff, merge, add -p)`);
213
+ }
214
+ if (SERVER_OPERATIONS.has(operation)) {
215
+ throw new Error(`git ${operation} is a long-running server; start it with the shell tool so it becomes a background task`);
216
+ }
201
217
  return { command: String(command), cwd, globalArgs, operation, args: tokens.slice(index + 1) };
202
218
  }
203
219
 
@@ -229,6 +229,34 @@ test('git availability probes answer instead of erroring', async (t) => {
229
229
  assert.match(probe, /git version/i);
230
230
  });
231
231
 
232
+ // git dispatches any `git-*` executable on PATH as a subcommand, so a finite
233
+ // allowlist could only ever go stale. An unknown name must reach git itself —
234
+ // whatever git answers is actionable, an "unsupported subcommand" refusal was
235
+ // not, and the shell tool ran the same command anyway.
236
+ test('git forwards unknown subcommands to git instead of pre-rejecting them', async (t) => {
237
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-git-open-'));
238
+ t.after(() => rmSync(root, { recursive: true, force: true }));
239
+ const repo = join(root, 'repo');
240
+ parseOk(await executeGitTool({ command: `git init ${quote(repo)}` }, root));
241
+ for (const command of ['git filter-repo --version', 'git fast-export --help']) {
242
+ assert.doesNotMatch(String(await executeGitTool({ command }, repo)), /unsupported git subcommand/);
243
+ }
244
+ });
245
+
246
+ // The only commands this tool cannot host are the ones that never return: a
247
+ // GUI blocks on a window nobody can click, and a resident server burns the
248
+ // full timeout by working correctly. The server case is a routing hint, not a
249
+ // verdict — shell can hold it as a background task.
250
+ test('git refuses only non-returning subcommands and routes servers to shell', async (t) => {
251
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-git-deny-'));
252
+ t.after(() => rmSync(root, { recursive: true, force: true }));
253
+ const repo = join(root, 'repo');
254
+ parseOk(await executeGitTool({ command: `git init ${quote(repo)}` }, root));
255
+ assert.match(String(await executeGitTool({ command: 'git mergetool' }, repo)), /interactive GUI/i);
256
+ assert.match(String(await executeGitTool({ command: 'git daemon --export-all' }, repo)), /shell tool/i);
257
+ assert.match(String(await executeGitTool({ command: 'git' }, repo)), /requires a subcommand/i);
258
+ });
259
+
232
260
  test('git stages selected change IDs and rejects stale diff snapshots without touching the index', async (t) => {
233
261
  const root = mkdtempSync(join(tmpdir(), 'mixdog-git-stage-'));
234
262
  t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -0,0 +1,106 @@
1
+ // A file scope the shared native search server could not serve is answered by
2
+ // reading that one file, so a starved queue costs a bounded wait instead of a
3
+ // whole turn. The scanner declines whenever it cannot reproduce the request.
4
+ import assert from 'node:assert/strict';
5
+ import { 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 { runGrepSingleFileRescue } from './lib/grep-single-file-rescue.mjs';
11
+
12
+ const STATUS = 'Name:\tnode\nUid:\t0\t0\t0\t0\nGid:\t0\t0\t0\t0\nCapEff:\t000001ffffffffff\n';
13
+
14
+ function request(overrides) {
15
+ return {
16
+ caseInsensitive: false,
17
+ multilineMode: false,
18
+ onlyMatching: false,
19
+ fileType: null,
20
+ outputMode: 'content',
21
+ showLineNumbers: true,
22
+ withFilename: false,
23
+ filenameOmitted: true,
24
+ beforeN: 0,
25
+ afterN: 0,
26
+ contextN: 0,
27
+ headLimit: 250,
28
+ offset: 0,
29
+ patternCapNote: '',
30
+ globPatterns: [],
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ async function withStatusFile(run) {
36
+ const root = await mkdtemp(join(tmpdir(), 'mixdog-grep-rescue-'));
37
+ try {
38
+ const file = join(root, 'status');
39
+ await writeFile(file, STATUS);
40
+ return await run({ root, file });
41
+ } finally {
42
+ await rm(root, { recursive: true, force: true });
43
+ }
44
+ }
45
+
46
+ test('a missed file scope is scanned directly and reports the same matches', async () => {
47
+ await withStatusFile(async ({ root, file }) => {
48
+ const out = await runGrepSingleFileRescue(request({
49
+ filePath: file,
50
+ searchPath: file,
51
+ grepResolvedPath: file,
52
+ workDir: root,
53
+ patterns: ['^(Uid|Gid|Cap(Inh|Prm|Eff|Bnd|Amb))'],
54
+ }));
55
+ assert.match(out, /2:Uid:/);
56
+ assert.match(out, /3:Gid:/);
57
+ assert.match(out, /4:CapEff:/);
58
+ assert.doesNotMatch(out, /Name:/);
59
+ assert.match(out, /scanned directly/);
60
+ });
61
+ });
62
+
63
+ test('counts and no-match answers keep the ordinary grep shapes', async () => {
64
+ await withStatusFile(async ({ root, file }) => {
65
+ const counted = await runGrepSingleFileRescue(request({
66
+ filePath: file,
67
+ searchPath: file,
68
+ grepResolvedPath: file,
69
+ workDir: root,
70
+ outputMode: 'count',
71
+ patterns: ['^(Uid|Gid)'],
72
+ }));
73
+ assert.match(counted, /\b2\b/);
74
+
75
+ const empty = await runGrepSingleFileRescue(request({
76
+ filePath: file,
77
+ searchPath: file,
78
+ grepResolvedPath: file,
79
+ workDir: root,
80
+ patterns: ['NoSuchFieldHere'],
81
+ }));
82
+ assert.match(empty, /\(no matches\)/);
83
+ });
84
+ });
85
+
86
+ test('a request the scanner cannot reproduce declines instead of guessing', async () => {
87
+ await withStatusFile(async ({ root, file }) => {
88
+ const base = {
89
+ filePath: file,
90
+ searchPath: file,
91
+ grepResolvedPath: file,
92
+ workDir: root,
93
+ };
94
+ // Regex JS cannot compile: the caller keeps the native error.
95
+ assert.equal(await runGrepSingleFileRescue(request({
96
+ ...base,
97
+ patterns: ['a{2,1}'],
98
+ })), null);
99
+ // Multiline matching spans lines; this scanner is line-oriented.
100
+ assert.equal(await runGrepSingleFileRescue(request({
101
+ ...base,
102
+ patterns: ['Uid'],
103
+ multilineMode: true,
104
+ })), null);
105
+ });
106
+ });
@@ -0,0 +1,129 @@
1
+ // Direct filesystem expansion for an ABSOLUTE glob pattern.
2
+ //
3
+ // The inventory walker never follows a symlinked directory — the right call on
4
+ // a project tree, where following them means cycles and duplicated subtrees.
5
+ // But a virtual filesystem is built almost entirely out of symlinks:
6
+ // /sys/block/sda is a link into /sys/devices, and /sys/block/sda/device is
7
+ // another one. A pattern like /sys/block/sd*/size therefore enumerates NOTHING
8
+ // through the walker, and the caller is told the path does not exist while it
9
+ // plainly does.
10
+ //
11
+ // The pattern itself is the bound here: only segments that match are descended,
12
+ // one readdir per surviving directory, no recursive `**`, plus a candidate cap
13
+ // and a wall-clock deadline. Returns the matches (possibly empty), or null when
14
+ // the pattern is not expandable this way and the caller should keep its own
15
+ // answer.
16
+ import { readdir, stat } from 'node:fs/promises';
17
+ import { isAbsolute, join, parse } from 'node:path';
18
+
19
+ import { hasGlobMagic, normalizeOutputPath } from '../path-utils.mjs';
20
+
21
+ const DEFAULT_DEADLINE_MS = 1_500;
22
+ const DEFAULT_CANDIDATE_CAP = 200;
23
+
24
+ function escapeRegExp(value) {
25
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26
+ }
27
+
28
+ /** One path SEGMENT's matcher, or null when the segment uses a construct this
29
+ * expander will not approximate. */
30
+ function segmentMatcher(segment) {
31
+ if (segment.includes('**')) return null;
32
+ let source = '';
33
+ for (let index = 0; index < segment.length; index += 1) {
34
+ const char = segment[index];
35
+ if (char === '*') { source += '[^/]*'; continue; }
36
+ if (char === '?') { source += '[^/]'; continue; }
37
+ if (char === '[') {
38
+ const end = segment.indexOf(']', index + 1);
39
+ if (end < 0) return null;
40
+ source += segment.slice(index, end + 1);
41
+ index = end;
42
+ continue;
43
+ }
44
+ if (char === '{') {
45
+ const end = segment.indexOf('}', index + 1);
46
+ if (end < 0) return null;
47
+ const alternatives = segment.slice(index + 1, end).split(',').map(escapeRegExp);
48
+ source += `(?:${alternatives.join('|')})`;
49
+ index = end;
50
+ continue;
51
+ }
52
+ source += escapeRegExp(char);
53
+ }
54
+ try {
55
+ return new RegExp(`^${source}$`, process.platform === 'win32' ? 'i' : '');
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export async function expandAbsoluteGlob(pattern, {
62
+ limit = 25,
63
+ deadlineMs = DEFAULT_DEADLINE_MS,
64
+ candidateCap = DEFAULT_CANDIDATE_CAP,
65
+ } = {}) {
66
+ const raw = String(pattern || '').replace(/\\/g, '/');
67
+ if (!raw || !isAbsolute(raw) || !hasGlobMagic(raw)) return null;
68
+ const root = parse(raw).root;
69
+ if (!root) return null;
70
+ const segments = raw.slice(root.length).split('/').filter(Boolean);
71
+ if (segments.length === 0) return null;
72
+ const stopAt = Date.now() + Math.max(1, deadlineMs);
73
+
74
+ let current = [root];
75
+ for (const segment of segments) {
76
+ if (Date.now() >= stopAt) return null;
77
+ if (!hasGlobMagic(segment)) {
78
+ current = current.map((dir) => join(dir, segment));
79
+ continue;
80
+ }
81
+ const matcher = segmentMatcher(segment);
82
+ if (!matcher) return null;
83
+ const next = [];
84
+ for (const dir of current) {
85
+ if (next.length >= candidateCap || Date.now() >= stopAt) break;
86
+ let entries;
87
+ try { entries = await readdir(dir); } catch { continue; }
88
+ for (const name of entries) {
89
+ if (!matcher.test(name)) continue;
90
+ next.push(join(dir, name));
91
+ if (next.length >= candidateCap) break;
92
+ }
93
+ }
94
+ current = next;
95
+ if (current.length === 0) return [];
96
+ }
97
+
98
+ // stat (not lstat) so a symlinked target counts as the file/dir it names —
99
+ // that is the whole point of expanding these scopes.
100
+ const found = [];
101
+ for (const candidate of current) {
102
+ if (found.length >= limit || Date.now() >= stopAt) break;
103
+ try {
104
+ const info = await stat(candidate);
105
+ if (info.isFile() || info.isDirectory()) found.push(normalizeOutputPath(candidate));
106
+ } catch { /* a pattern match that cannot be stat'ed is not a result */ }
107
+ }
108
+ return found;
109
+ }
110
+
111
+ /** Expand every absolute pattern in a list; returns deduped matches in order. */
112
+ export async function expandAbsoluteGlobs(patterns, options = {}) {
113
+ const list = (Array.isArray(patterns) ? patterns : [patterns]).filter(
114
+ (value) => typeof value === 'string' && value,
115
+ );
116
+ const seen = new Set();
117
+ const out = [];
118
+ for (const pattern of list) {
119
+ if (out.length >= (options.limit ?? 25)) break;
120
+ const matches = await expandAbsoluteGlob(pattern, options);
121
+ if (!matches) continue;
122
+ for (const match of matches) {
123
+ if (seen.has(match)) continue;
124
+ seen.add(match);
125
+ out.push(match);
126
+ }
127
+ }
128
+ return out;
129
+ }