mixdog 0.9.127 → 0.9.129

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 (51) 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/app.mjs +2 -0
  6. package/src/headless-command.mjs +1 -1
  7. package/src/headless-exec.mjs +14 -0
  8. package/src/rules/shared/01-tool.md +5 -4
  9. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +49 -40
  10. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +1 -0
  11. package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +278 -25
  12. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +26 -9
  13. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +54 -1
  14. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +34 -104
  16. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +25 -43
  17. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +31 -88
  18. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +88 -11
  19. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +47 -128
  20. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +255 -83
  21. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +8 -2
  22. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +20 -0
  23. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +3 -3
  24. package/src/runtime/agent/orchestrator/tools/builtin/read-mode-tool.mjs +35 -30
  25. package/src/runtime/agent/orchestrator/tools/builtin/read-open.mjs +23 -22
  26. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +38 -31
  27. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +15 -15
  28. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +9 -9
  29. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +8 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +22 -18
  31. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +3 -3
  32. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +28 -8
  33. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +3 -0
  34. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +27 -0
  35. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  36. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +6 -0
  37. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +3 -0
  38. package/src/runtime/shared/background-tasks.mjs +6 -19
  39. package/src/runtime/shared/native-tool-paths.mjs +21 -0
  40. package/src/runtime/shared/tool-card-model.mjs +7 -1
  41. package/src/session-runtime/prewarm.mjs +13 -0
  42. package/src/standalone/session-runtime-pool.mjs +12 -8
  43. package/src/tui/app/transcript-row-estimate.mjs +3 -9
  44. package/src/tui/dist/index.mjs +4240 -4242
  45. package/src/tui/session/agent-envelope.mjs +18 -1
  46. package/src/tui/session/session-api-ext.mjs +6 -1
  47. package/src/tui/session/session-api.mjs +7 -3
  48. package/src/tui/session/session-flow.mjs +21 -4
  49. package/src/tui/session/tui-steering-persist.mjs +28 -3
  50. package/src/tui/session-local.mjs +6 -1
  51. 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.127",
3
+ "version": "0.9.129",
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');
package/src/app.mjs CHANGED
@@ -86,6 +86,8 @@ export async function run(argv = [], classifiedInvocation = null) {
86
86
  model: opts.model,
87
87
  effort: opts.effort,
88
88
  fast: opts.fast,
89
+ webSearch: opts.webSearch,
90
+ memory: opts.memory,
89
91
  json: opts.json,
90
92
  cwd: process.cwd(),
91
93
  });
@@ -4,7 +4,7 @@ const FLAG_OPTIONS = new Set([
4
4
  '--web-search', '--memory', '--json',
5
5
  ]);
6
6
  const EXEC_UNSUPPORTED_FLAGS = new Set([
7
- '--readonly', '--remote', '--onboarding', '--web-search', '--memory',
7
+ '--readonly', '--remote', '--onboarding',
8
8
  ]);
9
9
  const HEADLESS_WORKFLOW_ERROR = 'option --workflow is not supported for mixdog exec';
10
10
 
@@ -90,6 +90,8 @@ function createJsonLifecycle({
90
90
  effort,
91
91
  fast,
92
92
  cwd,
93
+ webSearch = false,
94
+ memory = false,
93
95
  }) {
94
96
  let threadId = `exec_${randomUUID().replace(/-/g, '')}`;
95
97
  const turnId = 'turn_1';
@@ -143,6 +145,8 @@ function createJsonLifecycle({
143
145
  tool_mode: 'full',
144
146
  approval_mode: 'implicit',
145
147
  delegation: false,
148
+ web_search: webSearch === true,
149
+ memory: memory === true,
146
150
  },
147
151
  }, turnStartedAt);
148
152
  emit({
@@ -540,6 +544,8 @@ export async function runHeadlessExec({
540
544
  model,
541
545
  effort,
542
546
  fast,
547
+ webSearch = false,
548
+ memory = false,
543
549
  json = false,
544
550
  cwd = process.cwd(),
545
551
  write = (text) => stdout.write(text),
@@ -571,6 +577,8 @@ export async function runHeadlessExec({
571
577
  effort,
572
578
  fast,
573
579
  cwd,
580
+ webSearch,
581
+ memory,
574
582
  }) : null;
575
583
  let boundary = null;
576
584
  let runtime = null;
@@ -602,6 +610,12 @@ export async function runHeadlessExec({
602
610
  const createRuntime = runtimeFactory || (
603
611
  await import('./mixdog-session-runtime.mjs')
604
612
  ).createMixdogSessionRuntime;
613
+ // Headless defaults: web research and memory tools stay OFF unless the
614
+ // caller opts in via --web-search / --memory. Delegation is already
615
+ // disallowed below, completing the solo surface. The per-process
616
+ // MIXDOG_FEATURE_* overrides are the runtime's canonical switches.
617
+ process.env.MIXDOG_FEATURE_WEB_SEARCH = webSearch === true ? '1' : '0';
618
+ process.env.MIXDOG_FEATURE_MEMORY = memory === true ? '1' : '0';
605
619
  runtime = await createRuntime({
606
620
  provider,
607
621
  model,
@@ -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()]);
@@ -1009,6 +1009,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1009
1009
  // server-side cache prefix stable.
1010
1010
  const _assistantTurnMsg = attachAssistantTranscriptMetadata({
1011
1011
  role: 'assistant',
1012
+ createdAt: Date.now(),
1012
1013
  // Sub-agent tool-call turns carry only mid-turn preamble in
1013
1014
  // response.content (the real result rides the later final-answer
1014
1015
  // turn). Blank it so it never accumulates as input tokens.