mixdog 0.9.69 → 0.9.70

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.
@@ -96,6 +96,10 @@ export function createModelRouteApi(deps) {
96
96
  if (!searchCapableFor(selectedRoute.provider, modelMeta)) {
97
97
  throw new Error(`model "${selectedRoute.model}" is not marked as web-search capable`);
98
98
  }
99
+ // Route-scope isolation: the search route stores its own effort/fast in
100
+ // config.searchRoute. The shared config.modelSettings[provider/model]
101
+ // bucket belongs to the MAIN route alone, so a search-model pick that
102
+ // happens to match Main must not rewrite Main's saved effort/fast.
99
103
  const fastCapable = fastCapableFor(selectedRoute.provider, modelMeta);
100
104
  const effort = coerceEffortFor(selectedRoute.provider, modelMeta, selectedRoute.effort);
101
105
  selectedRoute = {
@@ -103,7 +107,6 @@ export function createModelRouteApi(deps) {
103
107
  ...(effort ? { effort } : {}),
104
108
  fast: fastCapable ? selectedRoute.fast === true : false,
105
109
  };
106
- adoptConfig(saveModelSettings(cfgMod, selectedRoute, { fastCapable, baseConfig: getConfig() }), { hasSecrets: getConfigHasSecrets() });
107
110
  const routeToSave = normalizeSearchRouteConfig(selectedRoute);
108
111
  const nextConfig = { ...getConfig() };
109
112
  nextConfig.searchRoute = routeToSave;
@@ -230,6 +230,7 @@ import { createProviderUsage } from './provider-usage.mjs';
230
230
  import { envFlag } from './env.mjs';
231
231
  import { bootProfile, profiledImport } from './boot-profile.mjs';
232
232
  import { createChannelConfigApi } from './channel-config-api.mjs';
233
+ import { createMediaApi } from './media-api.mjs';
233
234
  import { createProviderAuthApi } from './provider-auth-api.mjs';
234
235
  import { createContextStatus } from './context-status.mjs';
235
236
  import { createLifecycleApi } from './lifecycle-api.mjs';
@@ -588,6 +589,9 @@ export async function createMixdogSessionRuntime({
588
589
  };
589
590
  const providerInitPromises = new Map();
590
591
  let startupProviderCatalogRefreshStarted = false;
592
+ // True while the boot-time provider-catalog refresh is in flight: warming a
593
+ // model cache it is about to invalidate only burns the load twice.
594
+ let startupProviderCatalogRefreshPending = false;
591
595
  let lastProjectMcpKey = null;
592
596
  // MCP connect state, owned here so teardown/reconnect paths still observe it;
593
597
  // the mcp-glue factory mutates this object in place (see createMcpGlue).
@@ -1011,20 +1015,28 @@ export async function createMixdogSessionRuntime({
1011
1015
  }
1012
1016
  if (!startupProviderCatalogRefreshStarted && !closeRequested) {
1013
1017
  startupProviderCatalogRefreshStarted = true;
1018
+ startupProviderCatalogRefreshPending = true;
1014
1019
  try {
1015
1020
  void Promise.resolve(reg.refreshProviderCatalogsOnStartup())
1016
1021
  .then(() => {
1017
1022
  // Fresh catalog rows invalidate model-derived caches, but the
1018
1023
  // already initialized provider registry remains valid.
1019
1024
  invalidateProviderCaches({ preserveProviderInit: true });
1020
- warmProviderModelCache();
1025
+ startupProviderCatalogRefreshPending = false;
1026
+ // Secrets-aware: a no-secrets rewarm bumps the load sequence and
1027
+ // is never adopted, so it discarded the in-flight authoritative
1028
+ // load and left the picker cache empty — every later consumer then
1029
+ // paid a full catalog load (measured ~560ms each).
1030
+ warmProviderModelCache({ loadSecrets: true });
1021
1031
  bootProfile('provider-catalogs:refresh-ready');
1022
1032
  })
1023
1033
  .catch((error) => {
1034
+ startupProviderCatalogRefreshPending = false;
1024
1035
  bootProfile('provider-catalogs:refresh-failed', { error: error?.message || String(error) });
1025
1036
  });
1026
1037
  bootProfile('provider-catalogs:refresh-started');
1027
1038
  } catch (error) {
1039
+ startupProviderCatalogRefreshPending = false;
1028
1040
  bootProfile('provider-catalogs:refresh-failed', { error: error?.message || String(error) });
1029
1041
  }
1030
1042
  }
@@ -1355,6 +1367,7 @@ export async function createMixdogSessionRuntime({
1355
1367
  cachedProviderSetup,
1356
1368
  warmCatalogsInBackground,
1357
1369
  isFirstTurnCompleted: () => firstTurnCompleted,
1370
+ isCatalogRefreshPending: () => startupProviderCatalogRefreshPending,
1358
1371
  envFlag,
1359
1372
  delays: {
1360
1373
  providerWarmupDelayMs,
@@ -1631,6 +1644,7 @@ export async function createMixdogSessionRuntime({
1631
1644
  // Automation saved mid-session boots the worker (claim-if-vacant) even
1632
1645
  // though the boot-time autostart window has already passed.
1633
1646
  ensureAutomationRuntime: () => scheduleChannelStart(0),
1647
+ awaitKeychainPrewarm,
1634
1648
  });
1635
1649
  const providerAuthApi = createProviderAuthApi({
1636
1650
  cfgMod,
@@ -1648,6 +1662,7 @@ export async function createMixdogSessionRuntime({
1648
1662
  getUsageDashboard,
1649
1663
  collectProviderModels,
1650
1664
  });
1665
+ const mediaApi = createMediaApi();
1651
1666
  const lifecycleApi = createLifecycleApi({
1652
1667
  getSession: () => session,
1653
1668
  setSession: (v) => { session = v; },
@@ -1830,6 +1845,7 @@ export async function createMixdogSessionRuntime({
1830
1845
  ...settingsApi,
1831
1846
  ...channelConfigApi,
1832
1847
  ...providerAuthApi,
1848
+ ...mediaApi,
1833
1849
  // Turn-scoped worktree diff (shadow snapshot): everything changed since
1834
1850
  // the current turn's base tree, regardless of which agent/process wrote it.
1835
1851
  getTurnReviewDiff: () => getTurnSnapshotReviewDiff(currentCwd, session?.id),
@@ -26,6 +26,7 @@ export function createWarmupSchedulers({
26
26
  cachedProviderSetup,
27
27
  warmCatalogsInBackground,
28
28
  isFirstTurnCompleted,
29
+ isCatalogRefreshPending = () => false,
29
30
  envFlag,
30
31
  delays,
31
32
  flags,
@@ -104,9 +105,21 @@ export function createWarmupSchedulers({
104
105
  scheduleProviderModelWarmup(backgroundBusyRetryMs);
105
106
  return;
106
107
  }
108
+ // The startup catalog refresh invalidates every model-derived cache when
109
+ // it lands, so warming before it finishes throws the whole load away.
110
+ if (isCatalogRefreshPending()) {
111
+ bootProfile('provider-models:warm-deferred', { reason: 'catalog-refresh-pending' });
112
+ scheduleProviderModelWarmup(backgroundBusyRetryMs);
113
+ return;
114
+ }
107
115
  if (!isFirstTurnCompleted() && !envFlag('MIXDOG_PROVIDER_WARMUP_BEFORE_FIRST_TURN')) {
108
116
  bootProfile('provider-models:warm-deferred', { reason: 'first-turn-pending' });
109
- warmProviderModelCache();
117
+ // Secrets-aware even before the first turn: the no-secrets variant is
118
+ // never adopted as the picker cache, so every consumer immediately
119
+ // reloaded the whole catalog (measured ~900ms of duplicate provider
120
+ // I/O on a desktop boot). This runs on a background timer and the
121
+ // keychain prewarm is already in flight, so nothing user-facing waits.
122
+ warmProviderModelCache({ loadSecrets: true });
110
123
  scheduleProviderModelWarmup(backgroundBusyRetryMs);
111
124
  return;
112
125
  }
@@ -23,7 +23,7 @@ import {
23
23
  import { ONBOARDING_VERSION } from './quick-search-models.mjs';
24
24
  import { findOutputStyle } from './output-styles.mjs';
25
25
  import { ensureProviderEnabled } from './config-helpers.mjs';
26
- import { fastCapableFor, saveModelSettings } from './model-capabilities.mjs';
26
+ import { fastCapableFor } from './model-capabilities.mjs';
27
27
 
28
28
  // Onboarding + agents/workflows/output-style selection surface. Extracted
29
29
  // verbatim from the runtime API object; stateless helpers are imported directly
@@ -31,7 +31,7 @@ import { fastCapableFor, saveModelSettings } from './model-capabilities.mjs';
31
31
  // session locals plus the closure callbacks.
32
32
  export function createWorkflowAgentsApi(deps) {
33
33
  const {
34
- getConfig, getRoute, setRouteState, getSession, setSession, getConfigHasSecrets,
34
+ getConfig, getRoute, setRouteState, getSession, setSession,
35
35
  cfgMod, reg, mgr, STANDALONE_DATA_DIR,
36
36
  resolveRoute, lookupModelMeta, adoptConfig, saveConfigAndAdopt, displayConfig, ensureProvidersReady,
37
37
  agentRouteFromConfig, loadAgentDefinition, activeWorkflowId, listWorkflowPacks,
@@ -406,12 +406,37 @@ export function createWorkflowAgentsApi(deps) {
406
406
  const id = normalizeAgentId(agentId) || normalizeWorkflowId(agentId);
407
407
  if (!id) throw new Error(`unknown agent "${agentId}"`);
408
408
  if (isHiddenAgent(id)) throw new Error(`agent "${id}" is internal and has no configurable route`);
409
- let selectedRoute = resolveRoute(getConfig(), { ...(next || {}) });
409
+ // Route-scope isolation: an agent route owns its model AND its
410
+ // effort/fast, stored in config.agents[<id>]. The shared
411
+ // config.modelSettings[provider/model] bucket belongs to the MAIN route
412
+ // alone (setRoute/setFast/setEffort) — resolveRoute reads it with
413
+ // priority over the lead preset, so an agent that reused it silently
414
+ // rewrote Main's effort/fast for the next session. Read the agent's own
415
+ // stored route as the fallback instead, and never write the bucket here.
416
+ const requested = { ...(next || {}) };
417
+ const routeDataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
418
+ const stored = agentRouteFromConfig(getConfig(), id, routeDataDir) || {};
419
+ // A request with neither provider nor model is the "seed from the current
420
+ // default" path (new custom agent) and inherits Main verbatim.
421
+ const inheritsDefault = !clean(requested.provider) && !clean(requested.model);
422
+ let selectedRoute = resolveRoute(getConfig(), requested);
423
+ if (!inheritsDefault) {
424
+ const sameModel = clean(selectedRoute.provider) === clean(stored.provider)
425
+ && clean(selectedRoute.model) === clean(stored.model);
426
+ selectedRoute = {
427
+ ...selectedRoute,
428
+ effort: requested.effort !== undefined
429
+ ? selectedRoute.effort
430
+ : (sameModel ? (stored.effort || null) : null),
431
+ fast: requested.fast !== undefined
432
+ ? selectedRoute.fast === true
433
+ : (sameModel && stored.fast === true),
434
+ };
435
+ }
410
436
  await ensureProvidersReady(ensureProviderEnabled(getConfig(), selectedRoute.provider));
411
437
  const modelMeta = await lookupModelMeta(selectedRoute.provider, selectedRoute.model);
412
438
  const fastCapable = fastCapableFor(selectedRoute.provider, modelMeta);
413
439
  selectedRoute = { ...selectedRoute, fast: fastCapable ? selectedRoute.fast === true : false };
414
- adoptConfig(saveModelSettings(cfgMod, selectedRoute, { fastCapable, baseConfig: getConfig() }), { hasSecrets: getConfigHasSecrets() });
415
440
 
416
441
  const routeToSave = normalizeWorkflowRoute(selectedRoute);
417
442
  if (!routeToSave) throw new Error('agent route requires provider and model');
@@ -28402,6 +28402,20 @@ function createEngineApiB(bag) {
28402
28402
  pushNotice("channel saved", "info");
28403
28403
  return result;
28404
28404
  },
28405
+ // Media studio (image/video generation). Reads stay quiet; only the
28406
+ // generation start posts a notice so the TUI shows background work.
28407
+ listMediaLanes: () => runtime.listMediaLanes?.(),
28408
+ listMediaAssets: (options) => runtime.listMediaAssets?.(options),
28409
+ readMediaAsset: (id) => runtime.readMediaAsset?.(id),
28410
+ getMediaJob: (id) => runtime.getMediaJob?.(id),
28411
+ listMediaJobs: () => runtime.listMediaJobs?.(),
28412
+ // No notice on start: the Studio surfaces progress on its own pending tile,
28413
+ // and a toast for a user-initiated generation is pure noise.
28414
+ startMediaJob: (input) => runtime.startMediaJob(input),
28415
+ cancelMediaJob: (id) => runtime.cancelMediaJob?.(id),
28416
+ deleteMediaAsset: (id) => runtime.deleteMediaAsset?.(id),
28417
+ openMediaAsset: (id) => runtime.openMediaAsset?.(id),
28418
+ openMediaFolder: () => runtime.openMediaFolder?.(),
28405
28419
  setWebhookConfig: async (patch) => {
28406
28420
  const result = await runtime.setWebhookConfig(patch);
28407
28421
  pushNotice("webhook config updated", "info");
@@ -30532,6 +30546,11 @@ function createEngineItemMutators({
30532
30546
 
30533
30547
  // src/tui/engine.mjs
30534
30548
  var SESSION_RUNTIME_MODULE = import.meta.url.replace(/\\/g, "/").includes("/tui/dist/") ? "../../mixdog-session-runtime.mjs" : "../mixdog-session-runtime.mjs";
30549
+ var sessionRuntimeModulePromise = null;
30550
+ function importSessionRuntimeModule() {
30551
+ sessionRuntimeModulePromise ??= import(SESSION_RUNTIME_MODULE);
30552
+ return sessionRuntimeModulePromise;
30553
+ }
30535
30554
  var TOOL_APPROVAL_TIMEOUT_MS = (() => {
30536
30555
  const value = Number(process.env.MIXDOG_TOOL_APPROVAL_TIMEOUT_MS);
30537
30556
  return Number.isFinite(value) && value > 0 ? Math.max(1e3, Math.round(value)) : 12e4;
@@ -30556,7 +30575,7 @@ async function createEngineSession({
30556
30575
  process.env.MIXDOG_QUIET_MEMORY_LOG = "1";
30557
30576
  process.env.MIXDOG_PATCH_NATIVE_PREWARM ??= "0";
30558
30577
  const importStartedAt = performance3.now();
30559
- const { createMixdogSessionRuntime } = await import(SESSION_RUNTIME_MODULE);
30578
+ const { createMixdogSessionRuntime } = await importSessionRuntimeModule();
30560
30579
  bootProfile("session-runtime:imported", { ms: (performance3.now() - importStartedAt).toFixed(1) });
30561
30580
  const runtime = await createMixdogSessionRuntime({
30562
30581
  provider: providerName,
@@ -673,6 +673,20 @@ export function createEngineApiB(bag) {
673
673
  pushNotice('channel saved', 'info');
674
674
  return result;
675
675
  },
676
+ // Media studio (image/video generation). Reads stay quiet; only the
677
+ // generation start posts a notice so the TUI shows background work.
678
+ listMediaLanes: () => runtime.listMediaLanes?.(),
679
+ listMediaAssets: (options) => runtime.listMediaAssets?.(options),
680
+ readMediaAsset: (id) => runtime.readMediaAsset?.(id),
681
+ getMediaJob: (id) => runtime.getMediaJob?.(id),
682
+ listMediaJobs: () => runtime.listMediaJobs?.(),
683
+ // No notice on start: the Studio surfaces progress on its own pending tile,
684
+ // and a toast for a user-initiated generation is pure noise.
685
+ startMediaJob: (input) => runtime.startMediaJob(input),
686
+ cancelMediaJob: (id) => runtime.cancelMediaJob?.(id),
687
+ deleteMediaAsset: (id) => runtime.deleteMediaAsset?.(id),
688
+ openMediaAsset: (id) => runtime.openMediaAsset?.(id),
689
+ openMediaFolder: () => runtime.openMediaFolder?.(),
676
690
  setWebhookConfig: async (patch) => {
677
691
  const result = await runtime.setWebhookConfig(patch);
678
692
  pushNotice('webhook config updated', 'info');
@@ -119,6 +119,44 @@ const SESSION_RUNTIME_MODULE = import.meta.url.replace(/\\/g, '/').includes('/tu
119
119
  ? '../../mixdog-session-runtime.mjs'
120
120
  : '../mixdog-session-runtime.mjs';
121
121
 
122
+ // The runtime graph is imported lazily, but that import (measured ~250ms) used
123
+ // to land inside the FIRST engine creation, which desktop performs while it
124
+ // holds its transition lock — so the user's first navigation paid for it. Hosts
125
+ // can start it during their own idle startup instead; the promise is shared, so
126
+ // the create path either awaits an in-flight preload or does the import itself.
127
+ let sessionRuntimeModulePromise = null;
128
+ function importSessionRuntimeModule() {
129
+ sessionRuntimeModulePromise ??= import(SESSION_RUNTIME_MODULE);
130
+ return sessionRuntimeModulePromise;
131
+ }
132
+
133
+ export function preloadSessionRuntimeModule() {
134
+ void importSessionRuntimeModule().catch(() => {
135
+ // A real load failure surfaces on the authoritative create path.
136
+ sessionRuntimeModulePromise = null;
137
+ });
138
+ }
139
+
140
+ // Windows keychain reads go through a DPAPI PowerShell host whose cold start
141
+ // dominates a packaged boot (measured ~1.5s). The runtime batches every secret
142
+ // into one call, but it only starts that batch when the runtime is created —
143
+ // late enough that the first context switch waits on it. Hosts can start the
144
+ // same batch while their window is still coming up.
145
+ export function preloadKeychainSecrets() {
146
+ void (async () => {
147
+ try {
148
+ const { createRequire } = await import('node:module');
149
+ const require = createRequire(import.meta.url);
150
+ const keychain = require(import.meta.url.replace(/\\/g, '/').includes('/tui/dist/')
151
+ ? '../../lib/keychain-cjs.cjs'
152
+ : '../lib/keychain-cjs.cjs');
153
+ await keychain.prewarmSecrets?.();
154
+ } catch {
155
+ // Prewarm is opportunistic; the runtime still warms on its own path.
156
+ }
157
+ })();
158
+ }
159
+
122
160
  const TOOL_APPROVAL_TIMEOUT_MS = (() => {
123
161
  const value = Number(process.env.MIXDOG_TOOL_APPROVAL_TIMEOUT_MS);
124
162
  return Number.isFinite(value) && value > 0 ? Math.max(1000, Math.round(value)) : 120_000;
@@ -161,7 +199,7 @@ export async function createEngineSession({
161
199
  process.env.MIXDOG_PATCH_NATIVE_PREWARM ??= '0';
162
200
 
163
201
  const importStartedAt = performance.now();
164
- const { createMixdogSessionRuntime } = await import(SESSION_RUNTIME_MODULE);
202
+ const { createMixdogSessionRuntime } = await importSessionRuntimeModule();
165
203
  bootProfile('session-runtime:imported', { ms: (performance.now() - importStartedAt).toFixed(1) });
166
204
  const runtime = await createMixdogSessionRuntime({
167
205
  provider: providerName,