mixdog 0.9.126 → 0.9.128

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 (43) hide show
  1. package/package.json +3 -3
  2. package/scripts/prepare-native-assets.mjs +78 -0
  3. package/scripts/tool-overhead-microbench.mjs +4 -4
  4. package/scripts/tool-stress.mjs +1 -1
  5. package/src/rules/shared/01-tool.md +5 -4
  6. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +49 -40
  7. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +54 -1
  8. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -0
  9. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +34 -104
  10. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +23 -42
  11. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +31 -88
  12. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +88 -11
  13. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +47 -128
  14. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +259 -84
  15. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +8 -2
  16. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +20 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +3 -3
  18. package/src/runtime/agent/orchestrator/tools/builtin/read-mode-tool.mjs +35 -30
  19. package/src/runtime/agent/orchestrator/tools/builtin/read-open.mjs +23 -22
  20. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +38 -31
  21. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +15 -15
  22. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +9 -9
  23. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +22 -18
  24. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +3 -3
  25. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +28 -8
  26. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +3 -0
  27. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  28. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +11 -0
  29. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +3 -0
  30. package/src/runtime/shared/background-tasks.mjs +6 -19
  31. package/src/runtime/shared/native-tool-paths.mjs +21 -0
  32. package/src/runtime/shared/tool-card-model.mjs +7 -1
  33. package/src/session-runtime/prewarm.mjs +13 -0
  34. package/src/standalone/session-runtime-pool.mjs +12 -8
  35. package/src/tui/app/transcript-row-estimate.mjs +3 -9
  36. package/src/tui/dist/index.mjs +4240 -4242
  37. package/src/tui/session/agent-envelope.mjs +18 -1
  38. package/src/tui/session/session-api-ext.mjs +6 -1
  39. package/src/tui/session/session-api.mjs +7 -3
  40. package/src/tui/session/session-flow.mjs +21 -4
  41. package/src/tui/session/tui-steering-persist.mjs +28 -3
  42. package/src/tui/session-local.mjs +6 -1
  43. package/src/runtime/agent/orchestrator/tools/builtin/cwd-utils.mjs +0 -37
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.126",
3
+ "version": "0.9.128",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -43,7 +43,7 @@
43
43
  "vendor/"
44
44
  ],
45
45
  "scripts": {
46
- "postinstall": "node scripts/prune-embedding-runtime.mjs",
46
+ "postinstall": "node scripts/prune-embedding-runtime.mjs && node scripts/prepare-native-assets.mjs",
47
47
  "prepack": "npm run build:tui",
48
48
  "prepublishOnly": "node -e \"if(!process.env.CI){console.error('local npm publish is disabled — run npm run release:patch');process.exit(1)}\"",
49
49
  "start": "node src/cli.mjs",
@@ -61,7 +61,7 @@
61
61
  "test:shellhardening": "npm run build:spawn:test && node --test scripts/suite-shellhardening-test.mjs scripts/suite-shell-direct-exe-test.mjs",
62
62
  "test:providers": "node --test scripts/provider-toolcall-test.mjs scripts/provider-contract-test.mjs scripts/provider-stream-outcome-test.mjs",
63
63
  "test:embedding-runtime:warmup": "node scripts/verify-embedding-runtime.mjs --warmup",
64
- "test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/release-gate-test.mjs && node --test scripts/release-gate-test.mjs",
64
+ "test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/release-gate-test.mjs && node --check scripts/prepare-native-assets.mjs && node --test scripts/release-gate-test.mjs scripts/prepare-native-assets-test.mjs",
65
65
  "test:native-edit-wire": "node scripts/native-edit-wire-test.mjs",
66
66
  "test:release-focused": "npm run test:release-critical && npm run test:compact && npm run test:shellhardening && npm run test:tool-contracts && npm run test:session && npm run test:session-transport",
67
67
  "test:release-critical": "npm run test:release-assets && npm run smoke:patch && npm run test:providers",
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ chmod,
5
+ copyFile,
6
+ mkdir,
7
+ mkdtemp,
8
+ rename,
9
+ rm,
10
+ } from 'node:fs/promises';
11
+ import { dirname, join, resolve } from 'node:path';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+
14
+ import {
15
+ NATIVE_TOOL_FILENAMES,
16
+ packageNativeToolsDir,
17
+ } from '../src/runtime/shared/native-tool-paths.mjs';
18
+ import { ensureGraphBinary } from '../src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs';
19
+ import { ensurePatchBinary } from '../src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs';
20
+ import { ensureSpawnBinary } from '../src/runtime/agent/orchestrator/tools/spawn-binary-fetcher.mjs';
21
+ import { ensureTokenAddon } from '../src/runtime/agent/orchestrator/tools/token-addon-fetcher.mjs';
22
+
23
+ const DEFAULT_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
24
+
25
+ export const REQUIRED_NATIVE_INSTALLERS = Object.freeze({
26
+ graph: ensureGraphBinary,
27
+ patch: ensurePatchBinary,
28
+ spawn: ensureSpawnBinary,
29
+ token: ensureTokenAddon,
30
+ });
31
+
32
+ export async function prepareRequiredNativeAssets({
33
+ packageRoot = DEFAULT_PACKAGE_ROOT,
34
+ installers = REQUIRED_NATIVE_INSTALLERS,
35
+ } = {}) {
36
+ const root = resolve(String(packageRoot || ''));
37
+ const target = packageNativeToolsDir(root);
38
+ const stagingRoot = await mkdtemp(join(root, '.native-tools-install-'));
39
+ const downloadDataDir = join(stagingRoot, 'downloads');
40
+ const stagedToolsDir = join(stagingRoot, 'native-tools');
41
+ try {
42
+ await mkdir(stagedToolsDir, { recursive: true });
43
+ const entries = await Promise.all(
44
+ Object.entries(NATIVE_TOOL_FILENAMES).map(async ([name, fileName]) => {
45
+ const install = installers[name];
46
+ if (typeof install !== 'function') {
47
+ throw new Error(`native asset installer is invalid: ${name}`);
48
+ }
49
+ const installedPath = await install(downloadDataDir);
50
+ if (typeof installedPath !== 'string' || installedPath.length === 0) {
51
+ throw new Error(`native asset installer returned no path: ${name}`);
52
+ }
53
+ const destination = join(stagedToolsDir, fileName);
54
+ await copyFile(installedPath, destination);
55
+ if (process.platform !== 'win32' && name !== 'token') {
56
+ await chmod(destination, 0o755);
57
+ }
58
+ return [name, join(target, fileName)];
59
+ }),
60
+ );
61
+ await rm(target, { recursive: true, force: true });
62
+ await rename(stagedToolsDir, target);
63
+ return Object.fromEntries(entries);
64
+ } finally {
65
+ await rm(stagingRoot, { recursive: true, force: true });
66
+ }
67
+ }
68
+
69
+ const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
70
+ if (invokedPath === import.meta.url) {
71
+ try {
72
+ const prepared = await prepareRequiredNativeAssets();
73
+ console.log(`[native-assets] ready: ${Object.keys(prepared).join(', ')}`);
74
+ } catch (error) {
75
+ console.error(`[native-assets] install failed: ${error?.message || String(error)}`);
76
+ process.exitCode = 1;
77
+ }
78
+ }
@@ -1,12 +1,12 @@
1
1
  // Micro-bench: fixed per-call overhead of the shell tool path vs raw spawn.
2
- // Usage: node scripts/tool-overhead-microbench.mjs [bash|powershell] [N]
2
+ // Usage: node scripts/tool-overhead-microbench.mjs [N]
3
3
  // Prints per-call ms for executeBashTool('echo hi') and raw child spawn,
4
4
  // so (tool - raw) isolates our tool-layer overhead (policy, wrappers, I/O).
5
5
  import { spawn } from 'node:child_process';
6
6
  import { executeBashTool } from '../src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs';
7
7
 
8
- const shell = process.argv[2] || (process.platform === 'win32' ? 'powershell' : 'bash');
9
- const N = Number(process.argv[3] || 15);
8
+ const shell = process.platform === 'win32' ? 'powershell' : 'bash';
9
+ const N = Number(process.argv[2] || 15);
10
10
 
11
11
  const stats = (arr) => {
12
12
  const s = [...arr].sort((a, b) => a - b);
@@ -30,7 +30,7 @@ const rawOnce = () => new Promise((resolveDone, reject) => {
30
30
  });
31
31
 
32
32
  const toolOnce = async () => {
33
- const out = await executeBashTool({ command: 'echo hi', shell }, process.cwd(), {});
33
+ const out = await executeBashTool({ command: 'echo hi' }, process.cwd(), {});
34
34
  return String(out);
35
35
  };
36
36
 
@@ -119,7 +119,7 @@ try {
119
119
 
120
120
  // ── Phase D: cancellation under load ─────────────────────────────────────
121
121
  const bg = await timed('shell-async', /task_id/, () => executeBuiltinTool('shell', {
122
- command: 'node -e "setTimeout(()=>{}, 30000)"', mode: 'async', timeout: 60_000,
122
+ command: 'node -e "setTimeout(()=>{}, 30000)"', run_in_background: true, timeout_ms: 60_000,
123
123
  }, root, { sessionId: 'stress-cancel' }));
124
124
  const bgId = (/task_id:\s*(\S+)/.exec(String(bg)) || [])[1];
125
125
  if (!bgId) failures.push('async shell did not return task_id');
@@ -12,7 +12,7 @@
12
12
  prior work→`recall` (history only, never current local state);
13
13
  durable compact English memory→`memory`;
14
14
  explicit Project change→`cwd`
15
- (`shell.cwd` is call-local and never changes the Project);
15
+ (a shell-local `cd` never changes the Project);
16
16
  explicit user-requested conversation reset→`session_manage`.
17
17
  Use only named tools present in the current tool surface.
18
18
  - Requirements define what must be true; evidence establishes what is true.
@@ -35,8 +35,9 @@
35
35
  tool, and launch independent facets together — never split or duplicate a
36
36
  facet across tools, mutate merely to widen retrieval, reserve known work, or
37
37
  cap fanout.
38
- Guesses go wide in one batch, scopes narrow only on verified cues — returned
39
- siblings/conventions or known literals and returned output is fully mined
38
+ Cover every independent facet of the round in one batch one best-routed
39
+ call per facet; extra tools on the same facet add cost, not progress.
40
+ Returned output is fully mined
40
41
  before the next round. `code_graph references` supplies the declaration and
41
42
  scoped usages and ends that facet; values/locations end at the context grep returns; `read`
42
43
  covers only what returned spans cannot, as an anchored offset/limit
@@ -59,7 +60,7 @@
59
60
  execution, runtime/state operations, calculations, data transformation, file
60
61
  generation, or formats unsupported by file tools. Do not use `shell` instead
61
62
  of an available file tool for ordinary file-content inspection.
62
- - Start shell commands sync. Use async only when their result is not needed
63
+ - Start shell commands in the foreground. Set `run_in_background:true` only when their result is not needed
63
64
  immediately and completion notification is sufficient. Tracked sync/async
64
65
  commands belong to the current run. Only when the request explicitly requires
65
66
  a service to survive after the run exits, detach it at shell level (for
@@ -8,10 +8,10 @@
8
8
  *
9
9
  * Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
10
10
  *
11
- * The catalog is cached on disk for 24h. On fetch failure, providers fall
12
- * back to whatever metadata their native endpoint exposed (usually nothing
13
- * beyond the id). Pricing stays null in that case; UI shows "-" instead of
14
- * a stale number.
11
+ * Overlay catalogs (LiteLLM + models.dev) are fetched once per process.
12
+ * Disk is a stale-ok fallback when the remote fetch fails. On fetch
13
+ * failure with no disk copy, providers keep whatever metadata their
14
+ * native endpoint exposed (usually nothing beyond the id).
15
15
  */
16
16
 
17
17
  import { existsSync, readFileSync } from 'fs';
@@ -25,14 +25,13 @@ import {
25
25
 
26
26
  const CATALOG_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
27
27
  const CATALOG_CACHE_FILE = 'litellm-catalog.json';
28
- const CATALOG_TTL_MS = 24 * 60 * 60_000;
29
28
 
30
29
  // Second auto pricing source: models.dev publishes per-PROVIDER model
31
30
  // catalogs (cost in $/M) for 140+ providers — including ones LiteLLM does not
32
31
  // track yet (e.g. opencode-go). Because it is keyed provider→model, a
33
32
  // provider-scoped lookup is collision-free: deepseek-v4-pro under `deepseek`
34
- // and under `opencode-go` resolve to their own distinct rates. Same 24h TTL
35
- // + disk cache shape as the LiteLLM catalog above.
33
+ // and under `opencode-go` resolve to their own distinct rates. Same
34
+ // process-lifetime fetch + disk fallback as the LiteLLM catalog above.
36
35
  const MODELSDEV_URL = 'https://models.dev/api.json';
37
36
  const MODELSDEV_CACHE_FILE = 'modelsdev-catalog.json';
38
37
 
@@ -193,28 +192,28 @@ const PRICING_OVERRIDES = {
193
192
 
194
193
  let _memCache = null;
195
194
  let _memCacheAt = 0;
195
+ // Disk warm must not count as "fetched this process" — otherwise a sync
196
+ // lookup before startup refresh would pin a stale overlay for the whole run.
197
+ let _catalogFetchedRemote = false;
196
198
  // Single-flight: concurrent loadCatalog callers share the same in-flight
197
- // Promise so a cold cache only triggers one disk read + one remote fetch.
199
+ // Promise so a cold process only triggers one remote fetch.
198
200
  let _loadPromise = null;
199
201
 
200
202
  function cachePath() {
201
203
  return join(getPluginData(), CATALOG_CACHE_FILE);
202
204
  }
203
205
 
204
- async function _loadCatalogImpl(fetchFn = fetch, force = false) {
205
- // Disk cache first
206
+ function readDiskCatalog(filePath) {
206
207
  try {
207
- if (force) throw new Error('forced refresh');
208
- if (existsSync(cachePath())) {
209
- const raw = JSON.parse(readFileSync(cachePath(), 'utf-8'));
210
- if (raw?.fetchedAt && (Date.now() - raw.fetchedAt) < CATALOG_TTL_MS && raw.data) {
211
- _memCache = raw.data;
212
- _memCacheAt = raw.fetchedAt;
213
- return _memCache;
214
- }
215
- }
216
- } catch { /* fall through */ }
217
- // Remote fetch
208
+ if (!existsSync(filePath)) return null;
209
+ const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
210
+ return raw?.data ? raw : null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ async function _loadCatalogImpl(fetchFn = fetch) {
218
217
  try {
219
218
  const res = await fetchFn(CATALOG_URL, { signal: AbortSignal.timeout(10_000) });
220
219
  if (!res.ok) throw new Error('HTTP ' + res.status);
@@ -224,14 +223,23 @@ async function _loadCatalogImpl(fetchFn = fetch, force = false) {
224
223
  } catch { /* cache is best-effort */ }
225
224
  _memCache = data;
226
225
  _memCacheAt = Date.now();
226
+ _catalogFetchedRemote = true;
227
227
  return data;
228
228
  } catch (err) {
229
229
  process.stderr.write(`[model-catalog] fetch failed: ${err.message}\n`);
230
+ const raw = readDiskCatalog(cachePath());
231
+ if (raw?.data) {
232
+ _memCache = raw.data;
233
+ _memCacheAt = raw.fetchedAt || Date.now();
234
+ _catalogFetchedRemote = true;
235
+ return _memCache;
236
+ }
230
237
  if (fetchFn !== fetch) {
231
238
  _memCache = _memCache || {};
232
239
  _memCacheAt = Date.now();
240
+ _catalogFetchedRemote = true;
233
241
  }
234
- return {};
242
+ return _memCache || {};
235
243
  }
236
244
  }
237
245
 
@@ -250,9 +258,9 @@ export async function loadCatalog({ fetchFn, force = false } = {}) {
250
258
  if (typeof fetchFn === 'function' && fetchFn !== fetch) {
251
259
  return _loadCatalogInjected(fetchFn);
252
260
  }
253
- if (!force && _memCache && (Date.now() - _memCacheAt) < CATALOG_TTL_MS) return _memCache;
261
+ if (!force && _catalogFetchedRemote && _memCache) return _memCache;
254
262
  if (_loadPromise) return _loadPromise;
255
- _loadPromise = _loadCatalogImpl(fetchFn, force).finally(() => { _loadPromise = null; });
263
+ _loadPromise = _loadCatalogImpl(fetchFn).finally(() => { _loadPromise = null; });
256
264
  return _loadPromise;
257
265
  }
258
266
 
@@ -271,21 +279,11 @@ function warmFromDiskSync() {
271
279
  let _mdCache = null;
272
280
  let _mdCacheAt = 0;
273
281
  let _mdLoadPromise = null;
282
+ let _mdFetchedRemote = false;
274
283
  function mdCachePath() {
275
284
  return join(getPluginData(), MODELSDEV_CACHE_FILE);
276
285
  }
277
- async function _loadModelsDevImpl(fetchFn = fetch, force = false) {
278
- try {
279
- if (force) throw new Error('forced refresh');
280
- if (existsSync(mdCachePath())) {
281
- const raw = JSON.parse(readFileSync(mdCachePath(), 'utf-8'));
282
- if (raw?.fetchedAt && (Date.now() - raw.fetchedAt) < CATALOG_TTL_MS && raw.data) {
283
- _mdCache = raw.data;
284
- _mdCacheAt = raw.fetchedAt;
285
- return _mdCache;
286
- }
287
- }
288
- } catch { /* fall through to remote */ }
286
+ async function _loadModelsDevImpl(fetchFn = fetch) {
289
287
  try {
290
288
  const res = await fetchFn(MODELSDEV_URL, { signal: AbortSignal.timeout(10_000) });
291
289
  if (!res.ok) throw new Error('HTTP ' + res.status);
@@ -295,12 +293,21 @@ async function _loadModelsDevImpl(fetchFn = fetch, force = false) {
295
293
  } catch { /* cache is best-effort */ }
296
294
  _mdCache = data;
297
295
  _mdCacheAt = Date.now();
296
+ _mdFetchedRemote = true;
298
297
  return data;
299
298
  } catch (err) {
300
299
  process.stderr.write(`[model-catalog] models.dev fetch failed: ${err.message}\n`);
300
+ const raw = readDiskCatalog(mdCachePath());
301
+ if (raw?.data) {
302
+ _mdCache = raw.data;
303
+ _mdCacheAt = raw.fetchedAt || Date.now();
304
+ _mdFetchedRemote = true;
305
+ return _mdCache;
306
+ }
301
307
  if (fetchFn !== fetch) {
302
308
  _mdCache = _mdCache || {};
303
309
  _mdCacheAt = Date.now();
310
+ _mdFetchedRemote = true;
304
311
  }
305
312
  return _mdCache || {};
306
313
  }
@@ -319,9 +326,9 @@ export async function loadModelsDevCatalog({ fetchFn, force = false } = {}) {
319
326
  if (typeof fetchFn === 'function' && fetchFn !== fetch) {
320
327
  return _loadModelsDevInjected(fetchFn);
321
328
  }
322
- if (!force && _mdCache && (Date.now() - _mdCacheAt) < CATALOG_TTL_MS) return _mdCache;
329
+ if (!force && _mdFetchedRemote && _mdCache) return _mdCache;
323
330
  if (_mdLoadPromise) return _mdLoadPromise;
324
- _mdLoadPromise = _loadModelsDevImpl(fetchFn, force).finally(() => { _mdLoadPromise = null; });
331
+ _mdLoadPromise = _loadModelsDevImpl(fetchFn).finally(() => { _mdLoadPromise = null; });
325
332
  return _mdLoadPromise;
326
333
  }
327
334
  function warmModelsDevFromDiskSync() {
@@ -598,13 +605,15 @@ export async function enrichModels(models, { fetchFn, force = false } = {}) {
598
605
  /**
599
606
  * Force-refresh the catalog by ignoring cached data and re-fetching.
600
607
  * Exposed so a user-initiated "refresh catalog" action in the UI can
601
- * bypass the 24h TTL.
608
+ * bypass the process-lifetime overlay cache.
602
609
  */
603
610
  export async function refreshCatalog() {
604
611
  _memCache = null;
605
612
  _memCacheAt = 0;
613
+ _catalogFetchedRemote = false;
606
614
  _mdCache = null;
607
615
  _mdCacheAt = 0;
616
+ _mdFetchedRemote = false;
608
617
  try {
609
618
  if (existsSync(cachePath())) {
610
619
  const fs = await import('fs');
@@ -626,7 +635,7 @@ export async function warmModelMetadataCatalogs() {
626
635
  return litellm;
627
636
  }
628
637
 
629
- /** Fire-and-forget warm of both in-memory catalog caches (disk-first, then remote). */
638
+ /** Fire-and-forget warm of both overlay catalogs (remote once per process). */
630
639
  export async function warmCatalogsInBackground() {
631
640
  try {
632
641
  await Promise.all([loadCatalog(), loadModelsDevCatalog()]);
@@ -23,6 +23,53 @@ import { preDispatchDenyForSession, routeWebFetchCall } from './pre-dispatch-den
23
23
  import { runWithToolExecutionOwner } from '../../../../shared/tool-execution-owner.mjs';
24
24
  import { runWithLocalSearchTelemetry } from '../../tools/builtin/local-search-telemetry.mjs';
25
25
 
26
+ const READ_ONLY_IO_TOOL_NAMES = new Set([
27
+ 'read', 'head', 'tail', 'wc', 'summary', 'hex',
28
+ 'grep', 'glob', 'find', 'find_files', 'list', 'tree',
29
+ ]);
30
+ const READ_ONLY_IO_TIMEOUT_MS = 20_000;
31
+
32
+ function readOnlyIoTimeoutMs() {
33
+ const raw = String(process.env.MIXDOG_IO_TOOL_TIMEOUT_MS ?? '').trim();
34
+ if (raw === '0') return 0;
35
+ const configured = Number(raw);
36
+ return Number.isFinite(configured) && configured > 0
37
+ ? Math.max(1, Math.floor(configured))
38
+ : READ_ONLY_IO_TIMEOUT_MS;
39
+ }
40
+
41
+ async function runReadOnlyIoWithDeadline(name, parentSignal, run) {
42
+ if (!READ_ONLY_IO_TOOL_NAMES.has(name)) return await run(parentSignal || null);
43
+ const timeoutMs = readOnlyIoTimeoutMs();
44
+ if (timeoutMs <= 0) return await run(parentSignal || null);
45
+ const controller = new AbortController();
46
+ let rejectAbort;
47
+ const aborted = new Promise((_, reject) => { rejectAbort = reject; });
48
+ const abortWith = (reason) => {
49
+ if (controller.signal.aborted) return;
50
+ const error = reason instanceof Error ? reason : new Error(String(reason || `tool "${name}" aborted`));
51
+ rejectAbort(error);
52
+ controller.abort(error);
53
+ };
54
+ const onParentAbort = () => abortWith(parentSignal?.reason);
55
+ if (parentSignal?.aborted) onParentAbort();
56
+ else parentSignal?.addEventListener?.('abort', onParentAbort, { once: true });
57
+ const timeoutError = new Error(`read-only I/O tool "${name}" timed out after ${timeoutMs}ms; work was cancelled`);
58
+ timeoutError.code = 'READ_ONLY_IO_TIMEOUT';
59
+ const timer = setTimeout(() => abortWith(timeoutError), timeoutMs);
60
+ try {
61
+ return await Promise.race([
62
+ Promise.resolve().then(() => run(controller.signal)),
63
+ aborted,
64
+ ]);
65
+ } finally {
66
+ clearTimeout(timer);
67
+ parentSignal?.removeEventListener?.('abort', onParentAbort);
68
+ }
69
+ }
70
+
71
+ export { runReadOnlyIoWithDeadline as _runReadOnlyIoWithDeadlineForTest };
72
+
26
73
  let codeGraphRuntimePromise = null;
27
74
  async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
28
75
  codeGraphRuntimePromise ??= import('../../tools/code-graph.mjs');
@@ -166,7 +213,12 @@ async function executeToolOwned(name, args, cwd, callerSessionId, sessionRef, ex
166
213
  : sessionRef?.afterToolHook;
167
214
  const deferredPrep = prepareDeferredToolCallThrough(sessionRef, name, args);
168
215
  if (deferredPrep?.deny) return deferredPrep.deny;
169
- const __result = await (async () => {
216
+ const __result = await runReadOnlyIoWithDeadline(name, executeOpts.signal || null, async (deadlineSignal) => {
217
+ if (deadlineSignal !== executeOpts.signal) {
218
+ executeOpts = { ...executeOpts, signal: deadlineSignal };
219
+ completionToolOpts.signal = deadlineSignal;
220
+ }
221
+ return await (async () => {
170
222
  if (name === 'Skill') {
171
223
  return viewSkill(cwd, args?.name);
172
224
  }
@@ -238,6 +290,7 @@ async function executeToolOwned(name, args, cwd, callerSessionId, sessionRef, ex
238
290
  }
239
291
  return formatUnknownBuiltinToolMessage(name, args, 'tool');
240
292
  })();
293
+ });
241
294
  if (typeof afterToolHook === 'function') {
242
295
  try {
243
296
  // Tool outcome metadata is runtime-internal. Hooks receive the same
@@ -13,6 +13,7 @@ import { Worker } from 'node:worker_threads';
13
13
  import { getPluginData } from '../config.mjs';
14
14
  import { ensureTokenAddon, findCachedTokenAddon } from '../tools/token-addon-fetcher.mjs';
15
15
  import { safeIpcSend } from '../../../shared/safe-ipc-send.mjs';
16
+ import { packageNativeToolPath } from '../../../shared/native-tool-paths.mjs';
16
17
 
17
18
  const PLUGIN_ROOT = process.env.MIXDOG_ROOT
18
19
  || pathResolve(pathDirname(fileURLToPath(import.meta.url)), '../../../../..');
@@ -51,6 +52,7 @@ function _resolveAddon() {
51
52
  String(process.env.MIXDOG_TOKEN_NATIVE_ADDON || '').trim() || null,
52
53
  String(process.env.MIXDOG_TOKEN_NATIVE_BIN || '').trim() || null,
53
54
  LOCAL_ADDON,
55
+ packageNativeToolPath('token'),
54
56
  ].filter(Boolean);
55
57
  _addonPath = candidates.find((candidate) => {
56
58
  try { return existsSync(candidate); } catch { return false; }