mixdog 0.9.69 → 0.9.71

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 (31) hide show
  1. package/package.json +4 -1
  2. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +18 -4
  3. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +45 -12
  4. package/src/runtime/agent/orchestrator/session/store/listing.mjs +1 -0
  5. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +51 -18
  6. package/src/runtime/agent/orchestrator/session/store.mjs +3 -0
  7. package/src/runtime/channels/lib/owned-runtime.mjs +8 -1
  8. package/src/runtime/media/adapters/codex-image.mjs +109 -0
  9. package/src/runtime/media/adapters/gemini-image.mjs +68 -0
  10. package/src/runtime/media/adapters/gemini-video.mjs +119 -0
  11. package/src/runtime/media/adapters/xai-media.mjs +116 -0
  12. package/src/runtime/media/auth.mjs +51 -0
  13. package/src/runtime/media/index.mjs +18 -0
  14. package/src/runtime/media/jobs.mjs +174 -0
  15. package/src/runtime/media/lanes.mjs +230 -0
  16. package/src/runtime/media/renditions.mjs +203 -0
  17. package/src/runtime/media/store.mjs +356 -0
  18. package/src/runtime/media/store.test.mjs +103 -0
  19. package/src/runtime/media/upstream-error.mjs +39 -0
  20. package/src/session-runtime/channel-config-api.mjs +12 -1
  21. package/src/session-runtime/lifecycle-api.mjs +13 -4
  22. package/src/session-runtime/media-api.mjs +50 -0
  23. package/src/session-runtime/model-route-api.mjs +4 -1
  24. package/src/session-runtime/runtime-core.mjs +17 -1
  25. package/src/session-runtime/session-text.mjs +0 -1
  26. package/src/session-runtime/tool-catalog.mjs +32 -0
  27. package/src/session-runtime/warmup-schedulers.mjs +14 -1
  28. package/src/session-runtime/workflow-agents-api.mjs +29 -4
  29. package/src/tui/dist/index.mjs +21 -2
  30. package/src/tui/engine/session-api-ext.mjs +15 -0
  31. package/src/tui/engine.mjs +39 -1
@@ -17,13 +17,24 @@ import { runScheduleSession } from '../runtime/shared/schedule-session-run.mjs';
17
17
  // API object; the mutating admin helpers are imported directly here and the
18
18
  // runtime injects only the closure-owned callbacks (backend flush, channel
19
19
  // worker handle, soft reload).
20
- export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon, ensureAutomationRuntime = () => {} }) {
20
+ export function createChannelConfigApi({
21
+ flushBackendSave,
22
+ channels,
23
+ reloadChannelsSoon,
24
+ ensureAutomationRuntime = () => {},
25
+ awaitKeychainPrewarm = async () => {},
26
+ }) {
21
27
  return {
22
28
  async getChannelSetup() {
23
29
  // Flush a pending debounced backend switch first so setup readers
24
30
  // (Settings → Channel Setting, remote toggles) never observe the
25
31
  // previous backend during the 150ms debounce window.
26
32
  try { await flushBackendSave(); } catch {}
33
+ // Bot tokens live in the OS keychain. Reading them before the batch
34
+ // prewarm lands costs one SYNCHRONOUS PowerShell DPAPI decrypt each
35
+ // (measured 971ms + 579ms on a cold desktop boot, blocking the whole
36
+ // event loop); waiting for the prewarm serves both from its cache.
37
+ try { await awaitKeychainPrewarm(); } catch {}
27
38
  return channelSetup();
28
39
  },
29
40
  getChannelWorkerStatus() {
@@ -52,6 +52,15 @@ export function createLifecycleApi(deps) {
52
52
  pushTranscriptRebind,
53
53
  notificationListeners, remoteStateListeners, desktopSession,
54
54
  } = deps;
55
+ const closeSurfaceSession = (session, reason, options) => {
56
+ if (!session?.id) return false;
57
+ // A remote-attached session is only a viewer handle owned by this surface.
58
+ // Closing it through the shared manager bumps the durable generation and
59
+ // invalidates the real owner's in-flight turn. Viewer exits therefore
60
+ // detach locally; only the process that owns the runtime may close it.
61
+ if (session.remoteAttached === true) return true;
62
+ return mgr.closeSession(session.id, reason, options);
63
+ };
55
64
  const listLeadSessions = (options = {}) => {
56
65
  const heartbeatMtimes = listSessionHeartbeatMtimes();
57
66
  return mgr.listSessions({
@@ -245,7 +254,7 @@ export function createLifecycleApi(deps) {
245
254
  // first-turn exit could still burn a real session.
246
255
  const tombstone = !hasUserConversationMessage(session.messages)
247
256
  && !hasUserConversationMessage(session.liveTurnMessages);
248
- ok = mgr.closeSession(session.id, reason, { tombstone });
257
+ ok = closeSurfaceSession(session, reason, { tombstone });
249
258
  setSession(null);
250
259
  }
251
260
  invalidateContextStatusCache();
@@ -345,7 +354,7 @@ export function createLifecycleApi(deps) {
345
354
  statusRoutes?.clearGatewaySessionRoute?.(session.id);
346
355
  const tombstone = !hasUserConversationMessage(session.messages)
347
356
  && !hasUserConversationMessage(session.liveTurnMessages);
348
- mgr.closeSession(session.id, cleanupReason, { tombstone });
357
+ closeSurfaceSession(session, cleanupReason, { tombstone });
349
358
  setSession(null);
350
359
  }
351
360
  setDesktopSession(nextDesktopSession && typeof nextDesktopSession === 'object'
@@ -376,7 +385,7 @@ export function createLifecycleApi(deps) {
376
385
  void ingestSessionIntoMemory(session);
377
386
  const tombstone = !hasUserConversationMessage(session.messages)
378
387
  && !hasUserConversationMessage(session.liveTurnMessages);
379
- mgr.closeSession(session.id, 'cli-new', { tombstone });
388
+ closeSurfaceSession(session, 'cli-new', { tombstone });
380
389
  setSession(null);
381
390
  }
382
391
  invalidateContextStatusCache();
@@ -411,7 +420,7 @@ export function createLifecycleApi(deps) {
411
420
  void ingestSessionIntoMemory(prev);
412
421
  const tombstone = !hasUserConversationMessage(previousMessages)
413
422
  && !hasUserConversationMessage(previousLive);
414
- mgr.closeSession(previousId, 'cli-resume', { tombstone });
423
+ closeSurfaceSession(prev, 'cli-resume', { tombstone });
415
424
  }
416
425
  setSession(resumed);
417
426
  applyResolvedCwd(resolveResumeCwd(resumed, getCurrentCwd()), { markRefresh: false });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Media studio API (image / video generation) exposed on the session runtime.
3
+ *
4
+ * The media graph (lane catalog, adapters, asset store) is imported on first
5
+ * use: a session that never opens the Studio must not pay for it at boot.
6
+ */
7
+ let mediaModule = null;
8
+
9
+ async function media() {
10
+ mediaModule ??= await import('../runtime/media/index.mjs');
11
+ return mediaModule;
12
+ }
13
+
14
+ export function createMediaApi() {
15
+ return {
16
+ async listMediaLanes() {
17
+ return (await media()).listMediaLanes();
18
+ },
19
+ async startMediaJob(input) {
20
+ return (await media()).startMediaJob(input || {});
21
+ },
22
+ async getMediaJob(id) {
23
+ return (await media()).getMediaJob(id);
24
+ },
25
+ async listMediaJobs() {
26
+ return (await media()).listMediaJobs();
27
+ },
28
+ async cancelMediaJob(id) {
29
+ return (await media()).cancelMediaJob(id);
30
+ },
31
+ async listMediaAssets(options) {
32
+ return (await media()).listMediaAssets(options || {});
33
+ },
34
+ async readMediaAsset(id, options) {
35
+ return (await media()).readMediaAsset(id, options || {});
36
+ },
37
+ async resolveMediaFile(id, options) {
38
+ return (await media()).resolveMediaFile(id, options || {});
39
+ },
40
+ async deleteMediaAsset(id) {
41
+ return (await media()).deleteMediaAsset(id);
42
+ },
43
+ async openMediaAsset(id) {
44
+ return (await media()).openMediaAsset(id);
45
+ },
46
+ async openMediaFolder() {
47
+ return (await media()).openMediaFolder();
48
+ },
49
+ };
50
+ }
@@ -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),
@@ -44,7 +44,6 @@ const SYNTHETIC_SESSION_TEXT_PATTERNS = Object.freeze([
44
44
  // "Re-attached after compaction…" rows in Recent (user report). Skipping
45
45
  // them titles the session from its first REAL user message instead.
46
46
  /^re-attached after compaction\b/i,
47
- /^reference files:\s/i,
48
47
  /^the async (?:agent|shell) task\b/i,
49
48
  ]);
50
49
 
@@ -131,6 +131,32 @@ function toolSearchNativePayload(catalog, names, provider = '') {
131
131
  };
132
132
  }
133
133
 
134
+ // Schemas for tools that are ALREADY active. They are deliberately NOT added
135
+ // to toolReferences/openaiTools: re-announcing them would rewrite the request
136
+ // tools array and break the cached prefix. The caller only ever sees names
137
+ // otherwise, so the parameter contract (enums, limits) rides in the RESULT.
138
+ function activeToolSchemas(catalog, session, names) {
139
+ const wanted = new Set((names || []).map(clean).filter(Boolean));
140
+ if (!wanted.size) return [];
141
+ const seen = new Set();
142
+ const specs = [];
143
+ for (const pool of [Array.isArray(session?.tools) ? session.tools : [], catalog || []]) {
144
+ for (const tool of pool) {
145
+ const name = clean(tool?.name);
146
+ if (!name || !wanted.has(name) || seen.has(name)) continue;
147
+ seen.add(name);
148
+ specs.push({
149
+ name,
150
+ description: clean(tool?.description),
151
+ parameters: tool?.inputSchema && typeof tool.inputSchema === 'object'
152
+ ? tool.inputSchema
153
+ : { type: 'object', properties: {} },
154
+ });
155
+ }
156
+ }
157
+ return specs;
158
+ }
159
+
134
160
  // Plain case-insensitive substring filter over name + description. No scores,
135
161
  // no aliases, no auto-selection: tool_search only lists and (via select)
136
162
  // loads. Empty query matches every row.
@@ -734,9 +760,14 @@ export function renderToolSearch(args = {}, session, mode = 'full', options = {}
734
760
  summary: '',
735
761
  })
736
762
  : null;
763
+ const alreadyActiveSchemas = activeToolSchemas(catalog, session, alreadyActive);
737
764
  const nativeSummary = [
738
765
  ...(loaded.length ? [`Loaded deferred tools: ${loaded.join(', ')}`] : []),
739
766
  ...(alreadyActive.length ? [`Already active: ${alreadyActive.join(', ')}`] : []),
767
+ // The native path replaces the whole JSON result with this summary
768
+ // (tool-batch), so an already-active tool's schema has to travel here or
769
+ // the caller keeps guessing its parameters.
770
+ ...(alreadyActiveSchemas.length ? [`Already-active schemas: ${JSON.stringify(alreadyActiveSchemas)}`] : []),
740
771
  ].join('\n');
741
772
  const nativeToolSearch = nativeToolSearchBase
742
773
  ? { ...nativeToolSearchBase, summary: nativeSummary || nativeToolSearchBase.summary }
@@ -754,6 +785,7 @@ export function renderToolSearch(args = {}, session, mode = 'full', options = {}
754
785
  ...(nativeToolSearch ? { nativeToolSearch } : {}),
755
786
  loaded,
756
787
  alreadyActive,
788
+ ...(alreadyActiveSchemas.length ? { alreadyActiveSchemas } : {}),
757
789
  missing,
758
790
  ...(blocked.length ? { blocked } : {}),
759
791
  ...mcpFields,
@@ -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');
@@ -23945,7 +23945,6 @@ var SYNTHETIC_SESSION_TEXT_PATTERNS = Object.freeze([
23945
23945
  // "Re-attached after compaction…" rows in Recent (user report). Skipping
23946
23946
  // them titles the session from its first REAL user message instead.
23947
23947
  /^re-attached after compaction\b/i,
23948
- /^reference files:\s/i,
23949
23948
  /^the async (?:agent|shell) task\b/i
23950
23949
  ]);
23951
23950
  var LATE_TOOL_ANNOUNCEMENT_SENTINEL = "connected after this session started";
@@ -28402,6 +28401,21 @@ function createEngineApiB(bag) {
28402
28401
  pushNotice("channel saved", "info");
28403
28402
  return result;
28404
28403
  },
28404
+ // Media studio (image/video generation). Reads stay quiet; only the
28405
+ // generation start posts a notice so the TUI shows background work.
28406
+ listMediaLanes: () => runtime.listMediaLanes?.(),
28407
+ listMediaAssets: (options) => runtime.listMediaAssets?.(options),
28408
+ readMediaAsset: (id, options) => runtime.readMediaAsset?.(id, options),
28409
+ resolveMediaFile: (id, options) => runtime.resolveMediaFile?.(id, options),
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,21 @@ 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, options) => runtime.readMediaAsset?.(id, options),
681
+ resolveMediaFile: (id, options) => runtime.resolveMediaFile?.(id, options),
682
+ getMediaJob: (id) => runtime.getMediaJob?.(id),
683
+ listMediaJobs: () => runtime.listMediaJobs?.(),
684
+ // No notice on start: the Studio surfaces progress on its own pending tile,
685
+ // and a toast for a user-initiated generation is pure noise.
686
+ startMediaJob: (input) => runtime.startMediaJob(input),
687
+ cancelMediaJob: (id) => runtime.cancelMediaJob?.(id),
688
+ deleteMediaAsset: (id) => runtime.deleteMediaAsset?.(id),
689
+ openMediaAsset: (id) => runtime.openMediaAsset?.(id),
690
+ openMediaFolder: () => runtime.openMediaFolder?.(),
676
691
  setWebhookConfig: async (patch) => {
677
692
  const result = await runtime.setWebhookConfig(patch);
678
693
  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,