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
@@ -20,21 +20,32 @@ import {
20
20
 
21
21
  const DEFAULT_TIMEOUT_MS = 120_000;
22
22
  const MAX_CAPTURE_BYTES = 128 * 1024 * 1024;
23
- const SUPPORTED = new Set([
24
- 'add', 'am', 'apply', 'archive', 'bisect', 'blame', 'branch', 'bundle',
25
- 'cat-file', 'check-attr', 'check-ignore', 'check-ref-format', 'checkout',
26
- 'cherry', 'cherry-pick', 'clean', 'clone', 'commit', 'config',
27
- 'count-objects', 'describe', 'diff', 'diff-files', 'diff-index', 'diff-tree',
28
- 'fetch', 'filter-branch', 'for-each-ref', 'format-patch', 'fsck', 'gc',
29
- 'grep', 'hash-object', 'help', 'init', 'log', 'ls-files', 'ls-remote',
30
- 'ls-tree', 'maintenance', 'merge', 'merge-base', 'merge-tree', 'mv',
31
- 'name-rev', 'notes', 'pack-refs', 'prune', 'pull', 'push', 'range-diff',
32
- 'rebase', 'reflog', 'remote', 'repack', 'replace', 'reset', 'restore',
33
- 'revert', 'rev-list', 'rev-parse', 'rm', 'shortlog', 'show', 'show-branch',
34
- 'show-ref', 'sparse-checkout', 'stash', 'status', 'submodule', 'switch',
35
- 'symbolic-ref', 'tag', 'update-ref', 'verify-commit', 'verify-pack',
36
- 'verify-tag', 'version', 'whatchanged', 'worktree', 'write-tree',
37
- ]);
23
+ const GIT_OUTPUT_LIMIT_MAX = 200;
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']);
38
49
  // `git --version` / `git --help` are the standard availability probes and
39
50
  // arrive in global-flag position, not as subcommands. Map them onto their
40
51
  // subcommand form so the probe answers instead of erroring out.
@@ -63,12 +74,12 @@ export const GIT_TOOL_DEF = {
63
74
  openWorldHint: true,
64
75
  compressible: true,
65
76
  },
66
- description: 'Run one Git command directly, without a shell. History, blame, and old commits are evidence only when the task itself is about the past; work on current code ends at status and diff. Use diff directly when changed content for a known target is required; status is the repository summary. Shell operators and substitution are rejected. Repository mutations are serialized. Successful output is compacted.',
77
+ description: 'Run one Git command directly, without a shell. Owns repository state, diffs, history, and mutations. Use diff for known targets; otherwise use status alone to discover targets. Shell operators and substitution are rejected. Repository mutations are serialized. Successful output is compacted.',
67
78
  inputSchema: {
68
79
  type: 'object',
69
80
  properties: {
70
81
  command: { type: 'string', description: 'Full command beginning with git. Quote arguments as for a shell; shell operators are not allowed.' },
71
- output_limit: { type: 'integer', minimum: 1, maximum: 500, description: 'Item/line cap. Default 50; git log defaults to 10.' },
82
+ output_limit: { type: 'integer', minimum: 1, maximum: GIT_OUTPUT_LIMIT_MAX, description: 'Item/line cap. Default 50; git log defaults to 10.' },
72
83
  },
73
84
  required: ['command'],
74
85
  additionalProperties: false,
@@ -98,7 +109,7 @@ export const GIT_STAGE_TOOL_DEF = {
98
109
  ],
99
110
  description: 'Exact change ID or IDs to stage.',
100
111
  },
101
- output_limit: { type: 'integer', minimum: 1, maximum: 500, description: 'Status line cap; default 50.' },
112
+ output_limit: { type: 'integer', minimum: 1, maximum: GIT_OUTPUT_LIMIT_MAX, description: 'Status line cap; default 50.' },
102
113
  },
103
114
  required: ['diff_id', 'change_ids'],
104
115
  additionalProperties: false,
@@ -196,7 +207,13 @@ function parseCommand(command, workDir) {
196
207
  }
197
208
  const rawOperation = String(tokens[index] || '').toLowerCase();
198
209
  const operation = OPERATION_ALIASES.get(rawOperation) ?? rawOperation;
199
- 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
+ }
200
217
  return { command: String(command), cwd, globalArgs, operation, args: tokens.slice(index + 1) };
201
218
  }
202
219
 
@@ -273,12 +290,22 @@ function commandFailure(plan, result, limit = 50) {
273
290
  // Bounded like every success path: an unbounded failure dump from a large
274
291
  // repository cost far more context than the diagnosis it carried.
275
292
  const max = Math.min(Math.max(limit, 20), 40);
276
- return fail(reason, {
293
+ const detail = {
277
294
  exit: result.exitCode,
278
295
  signal: result.signal,
279
296
  stderr: failureText(result.stderr, max),
280
297
  stdout: failureText(result.stdout, max),
281
- });
298
+ };
299
+ // Exit 128 is git's state/precondition verdict (identity unset, bare
300
+ // repository, dubious ownership, missing ref) and stderr carries git's
301
+ // own explanation — the ANSWER the caller acts on next. The error
302
+ // envelope pushed callers into shell fallbacks for ordinary repository
303
+ // state. Spawn errors, timeouts, aborts, overflow and usage exits (129)
304
+ // keep failure semantics.
305
+ if (result.exitCode === 128 && !result.error && !result.timedOut && !result.aborted && !result.overflow) {
306
+ return JSON.stringify({ ok: false, reason, ...detail });
307
+ }
308
+ return fail(reason, detail);
282
309
  }
283
310
 
284
311
  // Non-zero exits that ARE the answer rather than a failure: git reports
@@ -761,7 +788,7 @@ export async function executeGitStageTool(input, workDir, options = {}) {
761
788
  hint: 'Run git diff in the current Project and use its diff_id/change_ids.',
762
789
  });
763
790
  }
764
- const limit = Math.min(500, Math.max(1, Number(input?.output_limit) || 50));
791
+ const limit = Math.min(GIT_OUTPUT_LIMIT_MAX, Math.max(1, Number(input?.output_limit) || 50));
765
792
  const signal = options?.signal || options?.abortSignal || null;
766
793
  const repo = snapshot.repo;
767
794
  return withGitRepoWriteLock(repo, () => withBuiltinPathLocks([repo], () => withAdvisoryLocks([repo], async () => {
@@ -815,7 +842,7 @@ export async function executeGitTool(input, workDir, options = {}) {
815
842
  return fail('git archive requires -o/--output; binary stdout is not returned');
816
843
  }
817
844
  const limitDefault = plan.operation === 'log' ? 10 : 50;
818
- const limit = Math.min(500, Math.max(1, Number(input.output_limit) || limitDefault));
845
+ const limit = Math.min(GIT_OUTPUT_LIMIT_MAX, Math.max(1, Number(input.output_limit) || limitDefault));
819
846
  const signal = options?.signal || options?.abortSignal || null;
820
847
  if (plan.operation === 'init' || plan.operation === 'clone') {
821
848
  const target = creationTarget(plan);
@@ -187,17 +187,38 @@ test('git and deferred git_stage expose separate compact contracts', () => {
187
187
  assert.deepEqual(Object.keys(properties), ['command', 'output_limit']);
188
188
  assert.deepEqual(GIT_TOOL_DEF.inputSchema.required, ['command']);
189
189
  assert.equal(properties.command.minLength, undefined);
190
+ assert.equal(properties.output_limit.maximum, 200);
190
191
  const stageProperties = GIT_STAGE_TOOL_DEF.inputSchema.properties;
191
192
  assert.deepEqual(Object.keys(stageProperties), ['diff_id', 'change_ids', 'output_limit']);
192
193
  assert.deepEqual(GIT_STAGE_TOOL_DEF.inputSchema.required, ['diff_id', 'change_ids']);
193
194
  assert.equal(stageProperties.change_ids.anyOf[1].maxItems, 50);
195
+ assert.equal(stageProperties.output_limit.maximum, 200);
194
196
  assert.equal(GIT_STAGE_TOOL_DEF.annotations.destructiveHint, true);
195
197
  assert.doesNotMatch(GIT_TOOL_DEF.description, /confirm/i);
196
- assert.match(GIT_TOOL_DEF.description, /Use diff directly when changed content for a known target is required/i);
198
+ assert.match(GIT_TOOL_DEF.description, /Owns repository state, diffs, history, and mutations/i);
199
+ assert.match(GIT_TOOL_DEF.description, /Use diff for known targets; otherwise use status alone to discover targets/i);
197
200
  assert.match(GIT_TOOL_DEF.description, /Run one Git command directly, without a shell/i);
198
201
  assert.match(GIT_TOOL_DEF.description, /repository mutations are serialized/i);
199
202
  });
200
203
 
204
+ test('git clamps oversized output requests to 200 lines', async (t) => {
205
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-git-output-cap-'));
206
+ t.after(() => rmSync(root, { recursive: true, force: true }));
207
+ const repo = join(root, 'repo');
208
+ parseOk(await executeGitTool({ command: `git init ${quote(repo)}` }, root));
209
+ parseOk(await git(repo, 'config user.name "Mixdog Test"'));
210
+ parseOk(await git(repo, 'config user.email mixdog@example.invalid'));
211
+ writeFileSync(
212
+ join(repo, 'large.txt'),
213
+ `${Array.from({ length: 250 }, (_, index) => `line-${index}`).join('\n')}\n`,
214
+ );
215
+ parseOk(await git(repo, 'add -- large.txt'));
216
+ parseOk(await git(repo, 'commit -m large'));
217
+ const shown = parseOk(await git(repo, 'show HEAD:large.txt', { output_limit: 500 }));
218
+ assert.equal(shown.lines.length, 200);
219
+ assert.equal(shown.omitted, 50);
220
+ });
221
+
201
222
  // `git --version` is how a caller checks whether git exists at all; rejecting
202
223
  // it as an "unsupported subcommand" turned the probe into a dead turn.
203
224
  test('git availability probes answer instead of erroring', async (t) => {
@@ -208,6 +229,34 @@ test('git availability probes answer instead of erroring', async (t) => {
208
229
  assert.match(probe, /git version/i);
209
230
  });
210
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
+
211
260
  test('git stages selected change IDs and rejects stale diff snapshots without touching the index', async (t) => {
212
261
  const root = mkdtempSync(join(tmpdir(), 'mixdog-git-stage-'));
213
262
  t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -0,0 +1,31 @@
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 { executeBuiltinTool } from '../builtin.mjs';
8
+
9
+ test('grep rendered output is capped at 10 KiB', async () => {
10
+ const root = await mkdtemp(join(tmpdir(), 'mixdog-grep-budget-'));
11
+ try {
12
+ const file = join(root, 'large.txt');
13
+ const body = Array.from(
14
+ { length: 400 },
15
+ (_, index) => `${index + 1} needle ${'x'.repeat(160)}`,
16
+ ).join('\n');
17
+ await writeFile(file, body);
18
+ const out = await executeBuiltinTool('grep', {
19
+ pattern: 'needle',
20
+ path: file,
21
+ mode: 'content',
22
+ limit: 250,
23
+ offset: 0,
24
+ context: 2,
25
+ }, root);
26
+ assert.ok(Buffer.byteLength(out, 'utf8') <= 10 * 1024);
27
+ assert.match(out, /\[grep output capped at 10240 bytes;/);
28
+ } finally {
29
+ await rm(root, { recursive: true, force: true });
30
+ }
31
+ });
@@ -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
+ }
@@ -12,11 +12,12 @@ import {
12
12
  import {
13
13
  relativePathPrefix,
14
14
  } from '../search-path-diagnostics.mjs';
15
+ import { GREP_OUTPUT_MAX_BYTES } from '../tool-output-limit.mjs';
15
16
  import {
16
17
  relativeGrepLine,
17
18
  } from './search-input-helpers.mjs';
18
19
 
19
- export const GREP_CONTEXT_CHAR_BUDGET_DEFAULT = 5_000;
20
+ export const GREP_CONTEXT_CHAR_BUDGET_DEFAULT = GREP_OUTPUT_MAX_BYTES;
20
21
  const GREP_FOCUSED_CONTEXT_RADIUS = 12;
21
22
  const GREP_FOCUSED_RAW_BLOCKS = 3;
22
23
  // Anchors must stay usable as evidence without a follow-up read: keep the