mixdog 0.9.19 → 0.9.21

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,2104 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { performance } from 'node:perf_hooks';
6
+ import httpMod from 'node:http';
7
+ import { ensureStandaloneEnvironment } from '../standalone/seeds.mjs';
8
+ import { createStandaloneAgent } from '../standalone/agent-tool.mjs';
9
+ import { isAgentOwner } from '../runtime/agent/orchestrator/agent-owner.mjs';
10
+ import { EXPLORE_TOOL, runExplore } from '../standalone/explore-tool.mjs';
11
+ import { createStandaloneChannelWorker } from '../standalone/channel-worker.mjs';
12
+ import { createStandaloneMemoryRuntime } from '../standalone/memory-runtime-proxy.mjs';
13
+ import { createStandaloneHookBus } from '../standalone/hook-bus.mjs';
14
+ import { writeLastSessionCwd } from '../runtime/shared/user-cwd.mjs';
15
+ import { cancelBackgroundTasks } from '../runtime/shared/background-tasks.mjs';
16
+ import { createTranscriptWriter } from '../runtime/shared/transcript-writer.mjs';
17
+ import { mixdogHome } from '../runtime/shared/plugin-paths.mjs';
18
+ import { checkLatestVersion, runGlobalUpdate, localPackageVersion } from '../runtime/shared/update-checker.mjs';
19
+ import {
20
+ modelVisibleToolCompletionMessage,
21
+ shouldPersistModelVisibleToolCompletion,
22
+ } from '../runtime/shared/tool-execution-contract.mjs';
23
+ import {
24
+ channelNotificationModelContent,
25
+ shouldMirrorChannelNotificationToPending,
26
+ } from '../runtime/shared/channel-notification-routing.mjs';
27
+ import {
28
+ normalizeAgentPermissionOrNone,
29
+ readMarkdownDocument,
30
+ } from '../runtime/shared/markdown-frontmatter.mjs';
31
+ import { setConfiguredShell } from '../runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs';
32
+ import { hasUserConversationMessage } from '../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
33
+ import {
34
+ beginOAuthProviderLogin,
35
+ forgetProviderAuth,
36
+ isKnownProvider,
37
+ loginOAuthProvider,
38
+ providerSetup,
39
+ renderProviderStatus,
40
+ saveOpenAIUsageSessionKey,
41
+ saveOpenCodeGoUsageAuth,
42
+ loginOpenCodeGoUsage,
43
+ saveProviderApiKey,
44
+ setLocalProvider,
45
+ } from '../standalone/provider-admin.mjs';
46
+ import { createUsageDashboard } from '../standalone/usage-dashboard.mjs';
47
+ import { fetchOAuthUsageSnapshot } from '../runtime/agent/orchestrator/providers/oauth-usage.mjs';
48
+ import {
49
+ getModelMetadataSync,
50
+ warmCatalogsInBackground,
51
+ } from '../runtime/agent/orchestrator/providers/model-catalog.mjs';
52
+ import {
53
+ isResponsesFreeformTool,
54
+ toResponsesCustomTool,
55
+ } from '../runtime/agent/orchestrator/providers/custom-tool-wire.mjs';
56
+ import {
57
+ channelSetup,
58
+ deleteSchedule,
59
+ deleteWebhook,
60
+ forgetDiscordToken,
61
+ forgetTelegramToken,
62
+ forgetWebhookAuthtoken,
63
+ setChannel,
64
+ saveDiscordToken,
65
+ saveTelegramToken,
66
+ saveSchedule,
67
+ saveWebhook,
68
+ saveWebhookAuthtoken,
69
+ setBackend,
70
+ setScheduleEnabled,
71
+ setWebhookEnabled,
72
+ setWebhookConfig,
73
+ } from '../standalone/channel-admin.mjs';
74
+ import {
75
+ addPlugin as registryAddPlugin,
76
+ listRegisteredPlugins,
77
+ pluginAdminStatus,
78
+ removePlugin as registryRemovePlugin,
79
+ updatePlugin as registryUpdatePlugin,
80
+ } from '../standalone/plugin-admin.mjs';
81
+ import {
82
+ estimateMessagesTokens,
83
+ estimateRequestReserveTokens,
84
+ estimateTranscriptContextUsage,
85
+ estimateToolSchemaTokens,
86
+ resolveCompactBufferTokens,
87
+ resolveCompactTriggerTokens,
88
+ summarizeContextMessages,
89
+ } from '../runtime/agent/orchestrator/session/context-utils.mjs';
90
+
91
+ import {
92
+ sessionMessageText,
93
+ messageContextText,
94
+ isSessionPreviewNoise,
95
+ cleanSessionPreview,
96
+ clean,
97
+ hasOwn,
98
+ toolResponseText,
99
+ isEmptyRecallText,
100
+ currentSessionRecallRows,
101
+ sessionHasConversationMessages,
102
+ } from './session-text.mjs';
103
+ import {
104
+ TOOL_MODES,
105
+ ALL_EFFORT_LEVELS,
106
+ EFFORT_LABELS,
107
+ EFFORT_OPTIONS_BY_PROVIDER,
108
+ EFFORT_BY_FAMILY,
109
+ EFFORT_FALLBACKS,
110
+ normalizeToolMode,
111
+ normalizeEffortInput,
112
+ effortOptionsFor,
113
+ coerceEffortFor,
114
+ normalizeSavedEffort,
115
+ effortItemsFor,
116
+ toolSpecForMode,
117
+ deferredSurfaceModeForLead,
118
+ } from './effort.mjs';
119
+ import {
120
+ LAZY_SECRET_PROVIDERS,
121
+ routeFastKey,
122
+ fastCapableFor,
123
+ makeSearchCapableFor,
124
+ fastPreferenceFor,
125
+ saveModelSettings,
126
+ } from './model-capabilities.mjs';
127
+ import {
128
+ DEFAULT_PROVIDER,
129
+ DEFAULT_MODEL,
130
+ makeResolveDefaultProvider,
131
+ findPreset,
132
+ makeResolveRoute,
133
+ isLikelyRawModelId,
134
+ validateRequestedModelSelector,
135
+ ensureProviderEnabled,
136
+ normalizeSystemShellConfig,
137
+ normalizeSystemShellCommand,
138
+ normalizeAutoClearConfig,
139
+ resolveAutoClearIdleMs,
140
+ autoClearIdleMsForProvider,
141
+ autoClearProviderDefaults,
142
+ normalizeCompactionConfig,
143
+ moduleEnabled,
144
+ setModuleEnabledInConfig,
145
+ recapEnabled,
146
+ setRecapEnabledInConfig,
147
+ formatDurationMs,
148
+ parseDurationMs,
149
+ modelMetaLooksResolved,
150
+ modelSettingsFor,
151
+ normalizeCompactTypeSetting,
152
+ } from './config-helpers.mjs';
153
+ import {
154
+ routeForStatusline,
155
+ writeStatuslineRoute,
156
+ } from './statusline-route.mjs';
157
+ import {
158
+ normalizeOutputStyleId,
159
+ listOutputStyleCatalog,
160
+ findOutputStyle,
161
+ outputStyleStatus as outputStyleStatusRaw,
162
+ } from './output-styles.mjs';
163
+ import { readJsonSafe, readTextSafe } from './fs-utils.mjs';
164
+ import {
165
+ readProjectMcpServers,
166
+ countSkillFiles,
167
+ mcpScriptForPlugin,
168
+ normalizePluginMcpServerConfig,
169
+ pluginManifest,
170
+ pluginMcpServerName,
171
+ pluginRawMcpServers,
172
+ pluginMcpEnableScript,
173
+ resolveContainedPluginPath,
174
+ } from './plugin-mcp.mjs';
175
+ import {
176
+ WORKFLOW_ROUTE_SLOTS,
177
+ FIXED_AGENT_SLOTS,
178
+ SEARCH_DEFAULT_PROVIDER,
179
+ SEARCH_DEFAULT_MODEL,
180
+ workflowPresetId,
181
+ agentPresetSlot,
182
+ normalizeAgentId,
183
+ normalizeWorkflowId,
184
+ DEFAULT_WORKFLOW_ID,
185
+ createWorkflowHelpers,
186
+ normalizeSearchProviderId,
187
+ isDefaultSearchRouteConfig,
188
+ isSearchCapableProvider,
189
+ normalizeSearchRouteConfig,
190
+ normalizeWorkflowRoute,
191
+ upsertWorkflowPreset,
192
+ createWorkflowRouteHelpers,
193
+ } from './workflow.mjs';
194
+ import {
195
+ MEASURED_TOOL_USAGE,
196
+ DEFERRED_DEFAULT_FULL_TOOLS,
197
+ DEFERRED_DEFAULT_READONLY_TOOLS,
198
+ DEFERRED_DEFAULT_LEAD_TOOLS,
199
+ toolKind,
200
+ toolSchemaBucket,
201
+ estimateToolSchemaBreakdown,
202
+ measuredToolUsage,
203
+ parseToolSelection,
204
+ parseToolSearchQuerySelection,
205
+ sortedCatalogByMeasuredUsage,
206
+ filterDisallowedTools,
207
+ sortedNamesByMeasuredUsage,
208
+ defaultDeferredToolNames,
209
+ compactToolSearchDescription,
210
+ toolRow,
211
+ toolSearchMatches,
212
+ applyDeferredToolSurface,
213
+ selectDeferredTools,
214
+ renderToolSearch,
215
+ } from './tool-catalog.mjs';
216
+ // Re-exported for external consumers (scripts/tool-smoke.mjs) that imported
217
+ // these from this module before the tool-catalog extraction.
218
+ export { defaultDeferredToolNames, compactToolSearchDescription } from './tool-catalog.mjs';
219
+ import {
220
+ TOOL_SEARCH_TOOL,
221
+ CWD_TOOL,
222
+ SKILL_TOOL,
223
+ SESSION_MANAGE_TOOL,
224
+ LEAD_DISALLOWED_TOOLS,
225
+ applyStandaloneToolDefaults,
226
+ } from './tool-defs.mjs';
227
+ import { ONBOARDING_VERSION, QUICK_SEARCH_MODELS } from './quick-search-models.mjs';
228
+ import {
229
+ sortProviderModels as sortProviderModelsRaw,
230
+ providerModelCacheRow as providerModelCacheRowRaw,
231
+ } from './model-recency.mjs';
232
+ import { createNativeSearch } from './native-search.mjs';
233
+ import { createConfigLifecycle } from './config-lifecycle.mjs';
234
+ import { attachSessionHooks } from './session-hooks.mjs';
235
+ import { createQuickModelRows } from './quick-model-rows.mjs';
236
+ import { createWarmupSchedulers } from './warmup-schedulers.mjs';
237
+ import { createPrewarmSchedulers } from './prewarm.mjs';
238
+ import { createMcpGlue } from './mcp-glue.mjs';
239
+ import { createCwdPlugins } from './cwd-plugins.mjs';
240
+ import { createSettingsApi } from './settings-api.mjs';
241
+ import { createProviderModels } from './provider-models.mjs';
242
+ import { createProviderUsage } from './provider-usage.mjs';
243
+ import { envFlag, envPresent, envDelayMs } from './env.mjs';
244
+ import { bootProfile, profiledImport } from './boot-profile.mjs';
245
+ import { createChannelConfigApi } from './channel-config-api.mjs';
246
+ import { createProviderAuthApi } from './provider-auth-api.mjs';
247
+ import { createContextStatus } from './context-status.mjs';
248
+ import { createLifecycleApi } from './lifecycle-api.mjs';
249
+ import { createResourceApi } from './resource-api.mjs';
250
+ import { createModelRouteApi } from './model-route-api.mjs';
251
+ import { createWorkflowAgentsApi } from './workflow-agents-api.mjs';
252
+ import { createSessionTurnApi } from './session-turn-api.mjs';
253
+ // Re-exported for external consumers (scripts/tool-smoke.mjs) that imported
254
+ // these from this module before the tool-defs extraction.
255
+ export { TOOL_SEARCH_TOOL, SKILL_TOOL };
256
+ // Back-compat test alias; delegates to the extracted helper.
257
+ export function __applyStandaloneToolDefaultsForTest(tool) {
258
+ return applyStandaloneToolDefaults(tool);
259
+ }
260
+
261
+ const RUNTIME = '../runtime/agent/orchestrator';
262
+ const SEARCH_RUNTIME = '../runtime/search/index.mjs';
263
+ const SEARCH_TOOL_DEFS = '../runtime/search/tool-defs.mjs';
264
+ const MEMORY_TOOL_DEFS = '../runtime/memory/tool-defs.mjs';
265
+ const MEMORY_RUNTIME = '../runtime/memory/index.mjs';
266
+ const CHANNEL_TOOL_DEFS = '../runtime/channels/tool-defs.mjs';
267
+ const CHANNEL_WORKER_ENTRY = '../runtime/channels/index.mjs';
268
+ const CODE_GRAPH_TOOL_DEFS = '../runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs';
269
+ const CODE_GRAPH_RUNTIME = '../runtime/agent/orchestrator/tools/code-graph.mjs';
270
+ const STATUSLINE_SESSION_ROUTES = '../vendor/statusline/src/gateway/session-routes.mjs';
271
+ const __dirname = dirname(fileURLToPath(import.meta.url));
272
+ // This module lives in src/session-runtime/, but the resource root must remain
273
+ // src/ (defaults/, rules/, runtime/, vendor/ live there), so climb one level.
274
+ const STANDALONE_SOURCE_ROOT = dirname(__dirname);
275
+ // Resource root stays at src/ because defaults/, rules/, runtime/, vendor/ live
276
+ // there. User-owned standalone state lives under MIXDOG_HOME (~/.mixdog).
277
+ const STANDALONE_ROOT = STANDALONE_SOURCE_ROOT;
278
+ const MIXDOG_HOME = process.env.MIXDOG_HOME || join(homedir(), '.mixdog');
279
+ const STANDALONE_DATA_DIR = process.env.MIXDOG_DATA_DIR || join(MIXDOG_HOME, 'data');
280
+
281
+ const resolveDefaultProvider = makeResolveDefaultProvider(isKnownProvider);
282
+ const resolveRoute = makeResolveRoute(resolveDefaultProvider);
283
+ const searchCapableFor = makeSearchCapableFor(normalizeSearchProviderId, isSearchCapableProvider);
284
+
285
+ const outputStyleStatus = (dataDir = STANDALONE_DATA_DIR, opts = {}) => outputStyleStatusRaw(STANDALONE_ROOT, dataDir || STANDALONE_DATA_DIR, opts);
286
+ // Workflow/agent pack loaders bound to this runtime's root/data layout.
287
+ const {
288
+ listWorkflowPacks,
289
+ activeWorkflowId,
290
+ loadWorkflowPack,
291
+ workflowSummary,
292
+ activeWorkflowSummary,
293
+ loadAgentDefinition,
294
+ workflowContextBlock,
295
+ activeWorkflowContext,
296
+ } = createWorkflowHelpers({
297
+ rootDir: STANDALONE_ROOT,
298
+ dataDir: STANDALONE_DATA_DIR,
299
+ readMarkdownDocument,
300
+ normalizeAgentPermissionOrNone,
301
+ });
302
+ const {
303
+ summarizeWorkflowRoutes,
304
+ routeFromPreset,
305
+ agentRouteFromConfig,
306
+ } = createWorkflowRouteHelpers({ resolveDefaultProvider, findPreset });
307
+
308
+ export function __renderToolSearchForTest(args = {}, session = {}, mode = 'full') {
309
+ return renderToolSearch(args, session, mode);
310
+ }
311
+
312
+ export function __saveModelSettingsForTest(cfgMod, route, options = {}) {
313
+ return saveModelSettings(cfgMod, route, options);
314
+ }
315
+
316
+ export async function createMixdogSessionRuntime({
317
+ provider,
318
+ model,
319
+ cwd = process.cwd(),
320
+ toolMode = 'full',
321
+ remote = false,
322
+ } = {}) {
323
+ bootProfile('session-runtime:start', { provider, model, toolMode, cwd });
324
+ let remoteEnabled = remote === true;
325
+ // Transient marker: an AUTO start (config/delayed autoStart) has forked the
326
+ // worker to ATTEMPT a claim-if-vacant but has NOT asserted remote for this
327
+ // session yet. remoteEnabled flips only if the worker reports it acquired the
328
+ // seat (the 'acquired' notification). Lets the deferred start chain proceed
329
+ // past its remoteEnabled guards without prematurely showing this session as
330
+ // remote (single-holder: a live owner must not be stolen by autoStart).
331
+ let remoteClaimPending = false;
332
+ // Remote-mode transcript writer (Discord outbound). Lazily created per
333
+ // session.id + cwd inside ask(); only active while remoteEnabled.
334
+ let _transcriptWriter = null;
335
+ let _twKey = '';
336
+ // Last assistant text handed to the transcript writer (via onAssistantText),
337
+ // so the post-turn final-content append can skip an exact duplicate.
338
+ let _lastAppendedAssistant = '';
339
+ process.env.MIXDOG_QUIET_SESSION_LOG ??= '1';
340
+ const standaloneStartedAt = performance.now();
341
+ ensureStandaloneEnvironment({
342
+ rootDir: STANDALONE_ROOT,
343
+ dataDir: STANDALONE_DATA_DIR,
344
+ });
345
+ bootProfile('standalone-env:ready', { ms: (performance.now() - standaloneStartedAt).toFixed(1) });
346
+
347
+ const importsStartedAt = performance.now();
348
+ const [
349
+ cfgMod,
350
+ sharedCfgMod,
351
+ reg,
352
+ mcpClient,
353
+ mgr,
354
+ contextMod,
355
+ internalTools,
356
+ statusRoutes,
357
+ searchToolDefs,
358
+ memoryToolDefs,
359
+ channelToolDefs,
360
+ codeGraphToolDefs,
361
+ ] = await Promise.all([
362
+ profiledImport('config', `${RUNTIME}/config.mjs`),
363
+ profiledImport('shared-config', `${RUNTIME}/../../shared/config.mjs`),
364
+ profiledImport('providers-registry', `${RUNTIME}/providers/registry.mjs`),
365
+ profiledImport('mcp-client', `${RUNTIME}/mcp/client.mjs`),
366
+ profiledImport('session-manager', `${RUNTIME}/session/manager.mjs`),
367
+ profiledImport('context-collect', `${RUNTIME}/context/collect.mjs`),
368
+ profiledImport('internal-tools', `${RUNTIME}/internal-tools.mjs`),
369
+ profiledImport('status-routes', STATUSLINE_SESSION_ROUTES, { optional: true }),
370
+ profiledImport('search-tool-defs', SEARCH_TOOL_DEFS, { optional: true }),
371
+ profiledImport('memory-tool-defs', MEMORY_TOOL_DEFS, { optional: true }),
372
+ profiledImport('channel-tool-defs', CHANNEL_TOOL_DEFS, { optional: true }),
373
+ profiledImport('code-graph-tool-defs', CODE_GRAPH_TOOL_DEFS, { optional: true }),
374
+ ]);
375
+ bootProfile('imports:ready', { ms: (performance.now() - importsStartedAt).toFixed(1) });
376
+ const pluginDataDir = cfgMod.getPluginData();
377
+ // Re-wire the idle/tombstone sweep. startIdleCleanup() lost its caller in a
378
+ // refactor, so closed-session tombstones were never deleted after their 24h
379
+ // grace — the store grew unbounded (observed: 1.8k files / 114MB), which
380
+ // made summary-index rebuilds and per-save index rewrites stall boot for
381
+ // seconds. Timer is unref'd and first fires after CLEANUP_INITIAL_DELAY_MS
382
+ // (5min), so this adds zero boot-path cost.
383
+ try { mgr.startIdleCleanup?.(); } catch { /* cleanup is best-effort */ }
384
+ const memoryRuntime = createStandaloneMemoryRuntime({
385
+ // Entry constants are module-relative ('../runtime/...'); resolve against
386
+ // this module's dir, not STANDALONE_ROOT, or the 'src/' segment is lost.
387
+ entry: join(__dirname, MEMORY_RUNTIME),
388
+ dataDir: pluginDataDir,
389
+ cwd,
390
+ });
391
+ let memoryModPromise = null;
392
+ let searchModPromise = null;
393
+ let codeGraphModPromise = null;
394
+
395
+ // Memory module is always-on. `memoryEnabled()` is kept as a thin alias that
396
+ // now always returns true (callers/compaction helpers still reference it);
397
+ // the user-facing toggle is `recap` (background cycles only), read via
398
+ // recapEnabled(config).
399
+ const memoryEnabled = () => true;
400
+ const recapEnabledFn = () => recapEnabled(config, true);
401
+ const channelsEnabled = () => moduleEnabled(config, 'channels', true);
402
+
403
+ async function getMemoryModule() {
404
+ const startedAt = performance.now();
405
+ memoryModPromise ??= Promise.resolve(memoryRuntime);
406
+ const mod = await memoryModPromise;
407
+ if (typeof mod?.init === 'function') {
408
+ await mod.init();
409
+ }
410
+ bootProfile('memory-runtime:ready', { ms: (performance.now() - startedAt).toFixed(1) });
411
+ return mod;
412
+ }
413
+
414
+ async function getSearchModule() {
415
+ const startedAt = performance.now();
416
+ searchModPromise ??= import(SEARCH_RUNTIME);
417
+ const mod = await searchModPromise;
418
+ bootProfile('search-runtime:ready', { ms: (performance.now() - startedAt).toFixed(1) });
419
+ return mod;
420
+ }
421
+
422
+ async function getCodeGraphModule() {
423
+ const startedAt = performance.now();
424
+ codeGraphModPromise ??= import(CODE_GRAPH_RUNTIME);
425
+ const mod = await codeGraphModPromise;
426
+ bootProfile('code-graph-runtime:ready', { ms: (performance.now() - startedAt).toFixed(1) });
427
+ return mod;
428
+ }
429
+
430
+ function persistLeadRoute(routeLike) {
431
+ const leadRoute = normalizeWorkflowRoute(routeLike);
432
+ if (!leadRoute) return null;
433
+
434
+ const nextConfig = { ...(config || {}) };
435
+ nextConfig.presets = upsertWorkflowPreset(nextConfig.presets, 'lead', leadRoute);
436
+ nextConfig.workflowRoutes = {
437
+ ...(nextConfig.workflowRoutes || {}),
438
+ lead: leadRoute,
439
+ };
440
+ nextConfig.default = workflowPresetId('lead');
441
+
442
+ saveConfigAndAdopt(nextConfig);
443
+ return leadRoute;
444
+ }
445
+
446
+ async function closePatchRuntimeIfLoaded(options = {}) {
447
+ const closer = globalThis.__mixdogCloseNativePatchServers;
448
+ if (typeof closer !== 'function' || globalThis.__mixdogNativePatchRuntimeTouched !== true) return;
449
+ bootProfile('patch-runtime:close:start');
450
+ const startedAt = performance.now();
451
+ try {
452
+ await closer(options);
453
+ } catch {
454
+ // Best-effort shutdown only; terminal restore must continue.
455
+ } finally {
456
+ bootProfile('patch-runtime:close:done', { ms: (performance.now() - startedAt).toFixed(1) });
457
+ }
458
+ }
459
+
460
+ const configStartedAt = performance.now();
461
+ let config = cfgMod.loadConfig({ secrets: false });
462
+ setConfiguredShell(normalizeSystemShellConfig(config.shell).command);
463
+ let configHasSecrets = false;
464
+ let route = resolveRoute(config, { provider, model });
465
+ let searchRoute = normalizeSearchRouteConfig(config.searchRoute);
466
+ bootProfile('config:ready', { ms: (performance.now() - configStartedAt).toFixed(1) });
467
+ let mode = normalizeToolMode(toolMode);
468
+ let session = null;
469
+ let sessionCreatePromise = null;
470
+ let currentCwd = cwd;
471
+ let sessionNeedsCwdRefresh = false;
472
+ // session_manage tool: reset request scheduled by the model mid-turn,
473
+ // consumed by the TUI engine at turn end ('clear' | 'compact_clear').
474
+ let pendingSessionReset = null;
475
+ let closeRequested = false;
476
+ const warmupTimers = {
477
+ providerSetupWarmupTimer: null,
478
+ providerWarmupTimer: null,
479
+ providerModelWarmupTimer: null,
480
+ modelCatalogWarmupTimer: null,
481
+ statuslineUsageWarmupTimer: null,
482
+ statuslineUsageRefreshTimer: null,
483
+ };
484
+ // Prewarm/channel-start timer handles + async state, owned here so the
485
+ // teardown clearTimeout sweep still sees them; the prewarm scheduler factory
486
+ // mutates these objects in place (see createPrewarmSchedulers).
487
+ const prewarmTimers = {
488
+ codeGraphPrewarmTimer: null,
489
+ channelStartTimer: null,
490
+ };
491
+ const prewarmState = {
492
+ codeGraphPrewarmInFlight: false,
493
+ codeGraphPrewarmQueuedCwd: '',
494
+ channelStartPromise: null,
495
+ };
496
+ let activeTurnCount = 0;
497
+ let firstTurnCompleted = false;
498
+ function hookTranscriptPath(sessionId) {
499
+ const id = clean(sessionId);
500
+ if (!id || !/^[A-Za-z0-9_-]+$/.test(id)) return null;
501
+ const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
502
+ return join(dataDir, 'sessions', `${id}.json`);
503
+ }
504
+ function hookEffortPayload() {
505
+ const level = clean(route.effectiveEffort || route.effort);
506
+ return level ? { level: level.toLowerCase() } : undefined;
507
+ }
508
+ function hookCommonPayload(extra = {}) {
509
+ const sid = clean(extra.session_id || extra.sessionId || session?.id);
510
+ return {
511
+ ...(sid ? { session_id: sid, transcript_path: hookTranscriptPath(sid) } : {}),
512
+ cwd: currentCwd,
513
+ permission_mode: session?.permissionMode || 'default',
514
+ ...(hookEffortPayload() ? { effort: hookEffortPayload() } : {}),
515
+ ...extra,
516
+ };
517
+ }
518
+ const sessionPrewarmDelayMs = envDelayMs('MIXDOG_SESSION_PREWARM_DELAY_MS', 50, { min: 0, max: 10_000 });
519
+ const providerSetupWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_SETUP_WARMUP_DELAY_MS', 300, { min: 0, max: 60_000 });
520
+ const modelCatalogWarmupDelayMs = envDelayMs('MIXDOG_MODEL_CATALOG_WARMUP_DELAY_MS', 200, { min: 0, max: 60_000 });
521
+ const providerWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_WARMUP_DELAY_MS', 1_500, { min: 0, max: 60_000 });
522
+ // Background model-catalog prefetch delay. Kept short so the first `/model`
523
+ // open finds a warm cache instead of paying a cold full network load. The
524
+ // work is async + unref'd, so short-lived detached runtimes still exit
525
+ // cleanly without waiting on it. Operators can raise it via env if a
526
+ // detached runtime must avoid the /models round-trip entirely.
527
+ const providerModelWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS', 2_000, { min: 0, max: 120_000 });
528
+ const codeGraphPrewarmDelayMs = envDelayMs('MIXDOG_CODE_GRAPH_PREWARM_DELAY_MS', 250, { min: 0, max: 60_000 });
529
+ const statuslineUsageWarmupDelayMs = envDelayMs('MIXDOG_STATUSLINE_USAGE_WARMUP_DELAY_MS', 800, { min: 0, max: 60_000 });
530
+ // Idle keep-alive: re-fetch usage before the statusline's 10-min staleness cut
531
+ // (LIVE_USAGE_SNAPSHOT_MAX_AGE_MS) so the usage segment does not disappear
532
+ // while the session sits idle with no turns to trigger a refresh.
533
+ const statuslineUsageRefreshDelayMs = envDelayMs('MIXDOG_STATUSLINE_USAGE_REFRESH_MS', 240_000, { min: 30_000, max: 540_000 });
534
+ const channelStartDelayMs = envDelayMs('MIXDOG_CHANNEL_START_DELAY_MS', 10_000, { min: 0, max: 120_000 });
535
+ const backgroundBusyRetryMs = envDelayMs('MIXDOG_BACKGROUND_BUSY_RETRY_MS', 1_000, { min: 50, max: 10_000 });
536
+ const sessionPrewarmEnabled = !envFlag('MIXDOG_DISABLE_SESSION_PREWARM')
537
+ && (envFlag('MIXDOG_ENABLE_SESSION_PREWARM') || envPresent('MIXDOG_SESSION_PREWARM_DELAY_MS'));
538
+ const providerWarmupEnabled = !envFlag('MIXDOG_DISABLE_PROVIDER_WARMUP')
539
+ && (
540
+ envFlag('MIXDOG_ENABLE_PROVIDER_WARMUP')
541
+ || envFlag('MIXDOG_PROVIDER_WARMUP_BEFORE_FIRST_TURN')
542
+ || envPresent('MIXDOG_PROVIDER_WARMUP_DELAY_MS')
543
+ || envPresent('MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS')
544
+ );
545
+ // Boot-time model-catalog prefetch is intentionally decoupled from the
546
+ // heavier providerWarmupEnabled gate (which stays opt-in for provider
547
+ // *init* side effects). Fetching the model list in the background after a
548
+ // short delay is cheap, fire-and-forget, and unref'd, so it is ON by
549
+ // default — otherwise the FIRST `/model` open always paid a cold full
550
+ // network load. Operators can still disable it explicitly.
551
+ const modelPrefetchEnabled = !envFlag('MIXDOG_DISABLE_PROVIDER_WARMUP')
552
+ && !envFlag('MIXDOG_DISABLE_MODEL_PREFETCH');
553
+ const codeGraphPrewarmEnabled = !envFlag('MIXDOG_DISABLE_CODE_GRAPH_PREWARM');
554
+ const modelCatalogWarmupEnabled = !envFlag('MIXDOG_DISABLE_MODEL_CATALOG_WARMUP');
555
+ // Lazy code-graph prewarm (default ON): do NOT prewarm at startup / on cwd
556
+ // change — that fired ~250ms after the first frame and, in a large tree,
557
+ // burned a worker (and felt like a freeze) before the user did anything.
558
+ // Instead prewarm ONCE on the first real turn, when a code lookup is actually
559
+ // imminent. Operators who want the old eager behavior can set
560
+ // MIXDOG_CODE_GRAPH_PREWARM_EAGER=1.
561
+ const codeGraphPrewarmLazy = codeGraphPrewarmEnabled && !envFlag('MIXDOG_CODE_GRAPH_PREWARM_EAGER');
562
+ let codeGraphFirstTurnPrewarmDone = false;
563
+ const modelMetaByRoute = new Map();
564
+ const notificationListeners = new Set();
565
+ // Remote seat listeners (TUI): fired when remote mode flips outside a direct
566
+ // user action — currently only the superseded (seat stolen) path.
567
+ const remoteStateListeners = new Set();
568
+ function emitRemoteStateChange(enabled, reason = '') {
569
+ for (const listener of [...remoteStateListeners]) {
570
+ try { listener({ enabled: enabled === true, reason: String(reason || '') }); } catch {}
571
+ }
572
+ }
573
+ const providerModelCaches = {
574
+ providerModelsCache: { models: null, at: 0 },
575
+ providerModelsPromise: null,
576
+ providerModelsLoadSeq: 0,
577
+ searchProviderModelsCache: { models: null, at: 0 },
578
+ };
579
+ const providerUsageCaches = {
580
+ usageDashboardCache: { dashboard: null, at: 0 },
581
+ usageDashboardPromise: null,
582
+ providerSetupCache: { setup: null, at: 0 },
583
+ providerSetupQuickCache: { setup: null, at: 0 },
584
+ providerSetupPromise: null,
585
+ };
586
+ let providerInitPromise = null;
587
+ let lastProjectMcpKey = null;
588
+ // MCP connect state, owned here so teardown/reconnect paths still observe it;
589
+ // the mcp-glue factory mutates this object in place (see createMcpGlue).
590
+ const mcpState = {
591
+ mcpFailures: [],
592
+ mcpConnectGeneration: 0,
593
+ mcpConnectInFlight: null,
594
+ };
595
+ // MCP glue factory — config/currentCwd live-bound; connect state shared via
596
+ // the caller-owned mcpState object above.
597
+ const {
598
+ mcpTransportLabel,
599
+ resolveEffectiveMcpServers,
600
+ mcpStatus,
601
+ connectConfiguredMcp,
602
+ normalizeMcpServerInput,
603
+ } = createMcpGlue({
604
+ mcpClient,
605
+ getConfig: () => config,
606
+ getCurrentCwd: () => currentCwd,
607
+ state: mcpState,
608
+ });
609
+ let preSessionToolSurface = null;
610
+ const hooksStartedAt = performance.now();
611
+ const hooks = createStandaloneHookBus({ dataDir: cfgMod.getPluginData() });
612
+ hooks.emit('runtime:start', { cwd: currentCwd, provider: route.provider, model: route.model, toolMode: mode });
613
+ bootProfile('hooks:ready', { ms: (performance.now() - hooksStartedAt).toFixed(1) });
614
+
615
+ // ---------------------------------------------------------------------
616
+ // Self-update (npm registry version check + optional auto-install).
617
+ // updateCheckState mirrors the last-known checkLatestVersion() result;
618
+ // updateProcessState tracks the in-flight install lifecycle so the TUI can
619
+ // poll getUpdateStatus() instead of needing a push/event channel. Both are
620
+ // purely in-memory (per runtime instance) — the on-disk cache lives inside
621
+ // update-checker.mjs and is what actually enforces the 24h TTL.
622
+ let updateCheckState = {
623
+ currentVersion: null,
624
+ latestVersion: null,
625
+ updateAvailable: false,
626
+ lastCheckedAt: 0,
627
+ };
628
+ // phase: 'idle' | 'checking' | 'installing' | 'installed' | 'failed'
629
+ let updateProcessState = { phase: 'idle', version: null, error: null };
630
+
631
+ function autoUpdateEnabled() {
632
+ return config?.update?.auto !== false;
633
+ }
634
+
635
+ async function checkForUpdateInternal({ force = false } = {}) {
636
+ if (updateProcessState.phase !== 'installing') updateProcessState.phase = 'checking';
637
+ try {
638
+ const result = await checkLatestVersion({ force, dataDir: cfgMod.getPluginData?.() || STANDALONE_DATA_DIR });
639
+ updateCheckState = {
640
+ currentVersion: result.currentVersion,
641
+ latestVersion: result.latestVersion,
642
+ updateAvailable: result.updateAvailable,
643
+ lastCheckedAt: result.lastCheckedAt,
644
+ };
645
+ } catch {
646
+ // checkLatestVersion() is already silent-safe; this catch is belt-and-
647
+ // braces so a boot-time call can never crash the runtime.
648
+ } finally {
649
+ if (updateProcessState.phase === 'checking') updateProcessState.phase = 'idle';
650
+ }
651
+ return updateCheckState;
652
+ }
653
+
654
+ async function runUpdateNowInternal() {
655
+ if (updateProcessState.phase === 'installing') {
656
+ return { ...updateProcessState, alreadyInstalling: true, error: 'update already in progress' };
657
+ }
658
+ updateProcessState = { phase: 'installing', version: null, error: null };
659
+ try {
660
+ const result = await runGlobalUpdate();
661
+ if (result?.ok) {
662
+ updateProcessState = { phase: 'installed', version: result.version || null, error: null };
663
+ } else {
664
+ updateProcessState = { phase: 'failed', version: null, error: result?.error || 'update failed' };
665
+ }
666
+ } catch (err) {
667
+ updateProcessState = { phase: 'failed', version: null, error: err?.message || String(err) };
668
+ }
669
+ return updateProcessState;
670
+ }
671
+
672
+ // Non-blocking boot hook: fires after the runtime object below is fully
673
+ // constructed (setTimeout(0) defers past the synchronous return), so a
674
+ // slow/hanging registry request or npm install can never delay session
675
+ // boot. Auto-update defaults to ON unless config.update.auto is explicitly
676
+ // false; a failed check or install is
677
+ // swallowed — getUpdateStatus()/getUpdateSettings() are the only surfaces,
678
+ // there is no push notice channel from runtime -> TUI today.
679
+ // force:true — always hit the registry at boot (the 24h disk cache went
680
+ // stale-visible: it kept reporting an older "latest" than the installed
681
+ // version). checkLatestVersion() still falls back to the cache offline.
682
+ const updateBootTimer = setTimeout(() => {
683
+ void (async () => {
684
+ await checkForUpdateInternal({ force: true });
685
+ if (autoUpdateEnabled() && updateCheckState.updateAvailable) {
686
+ await runUpdateNowInternal();
687
+ }
688
+ })().catch(() => {});
689
+ }, 0);
690
+ updateBootTimer.unref?.();
691
+
692
+ function emitRuntimeNotification(content, meta = {}) {
693
+ const text = String(content || '').trim();
694
+ if (!text) return false;
695
+ const event = { content: text, meta: meta && typeof meta === 'object' ? meta : {} };
696
+ let handled = false;
697
+ for (const listener of [...notificationListeners]) {
698
+ try {
699
+ if (listener(event) === true) handled = true;
700
+ } catch {}
701
+ }
702
+ return handled;
703
+ }
704
+
705
+ function notifyFnForSession(callerSessionId) {
706
+ return (text, meta = {}) => {
707
+ const handledByRuntimeListener = emitRuntimeNotification(text, meta);
708
+ let enqueued = false;
709
+ // TUI sessions consume raw execution notifications for UI/task cards via
710
+ // onNotification, but those raw envelopes are internal-only in pending
711
+ // drain. Always mirror terminal completions with a model-visible wrapper
712
+ // while keeping the raw text for UI display.
713
+ if (callerSessionId && typeof mgr.enqueuePendingMessage === 'function'
714
+ && shouldPersistModelVisibleToolCompletion(text, meta)) {
715
+ try {
716
+ const visible = modelVisibleToolCompletionMessage(text, meta);
717
+ if (visible) enqueued = mgr.enqueuePendingMessage(callerSessionId, visible) > 0;
718
+ } catch {}
719
+ }
720
+ // Headless/API listeners may exist but not consume the event; preserve
721
+ // the old fallback for non-terminal notifications only when unhandled.
722
+ if (!enqueued && !handledByRuntimeListener && callerSessionId
723
+ && typeof mgr.enqueuePendingMessage === 'function') {
724
+ try {
725
+ const visible = modelVisibleToolCompletionMessage(text, meta);
726
+ if (visible) enqueued = mgr.enqueuePendingMessage(callerSessionId, visible) > 0;
727
+ } catch {}
728
+ }
729
+ return enqueued || handledByRuntimeListener;
730
+ };
731
+ }
732
+
733
+ function skillsStatus() {
734
+ const skills = typeof contextMod.collectSkillsCached === 'function'
735
+ ? contextMod.collectSkillsCached(currentCwd)
736
+ : [];
737
+ const norm = (value) => String(value || '').replace(/\\/g, '/').toLowerCase();
738
+ const cwdNorm = norm(currentCwd);
739
+ const sourceForSkill = (filePath) => {
740
+ const path = norm(filePath);
741
+ if (cwdNorm && path.startsWith(`${cwdNorm}/.mixdog/skills/`)) return 'project';
742
+ return 'skill';
743
+ };
744
+ return {
745
+ cwd: currentCwd,
746
+ count: skills.length,
747
+ skills: skills.map((skill) => ({
748
+ name: skill.name,
749
+ description: skill.description || '',
750
+ filePath: skill.filePath || null,
751
+ source: sourceForSkill(skill.filePath),
752
+ })),
753
+ };
754
+ }
755
+
756
+ // cwd resolution/apply + plugins-status + core-memory context. Extracted to
757
+ // session-runtime/cwd-plugins.mjs; the facade keeps ownership of the mutable
758
+ // currentCwd/session/config/lastProjectMcpKey locals via getter/setter
759
+ // injection and passes the later-defined callbacks (prewarm/tool-surface/
760
+ // memory) as closures.
761
+ const {
762
+ resolveCwdPath,
763
+ applyResolvedCwd,
764
+ refreshSessionForCwdIfNeeded,
765
+ pluginsStatus,
766
+ loadCoreMemoryContext,
767
+ } = createCwdPlugins({
768
+ getCurrentCwd: () => currentCwd,
769
+ setCurrentCwd: (next) => { currentCwd = next; },
770
+ getConfig: () => config,
771
+ getSession: () => session,
772
+ getRoute: () => route,
773
+ getLastProjectMcpKey: () => lastProjectMcpKey,
774
+ setLastProjectMcpKey: (next) => { lastProjectMcpKey = next; },
775
+ isCodeGraphPrewarmLazy: () => codeGraphPrewarmLazy,
776
+ isCodeGraphFirstTurnPrewarmDone: () => codeGraphFirstTurnPrewarmDone,
777
+ getCodeGraphPrewarmDelayMs: () => codeGraphPrewarmDelayMs,
778
+ setSessionNeedsCwdRefresh: (next) => { sessionNeedsCwdRefresh = next; },
779
+ connectConfiguredMcp,
780
+ invalidatePreSessionToolSurface: (...a) => invalidatePreSessionToolSurface(...a),
781
+ scheduleCodeGraphPrewarm: (...a) => scheduleCodeGraphPrewarm(...a),
782
+ hooks,
783
+ hookCommonPayload: (...a) => hookCommonPayload(...a),
784
+ bootProfile,
785
+ getMemoryModule: (...a) => getMemoryModule(...a),
786
+ listRegisteredPlugins,
787
+ pluginAdminStatus,
788
+ pluginManifest,
789
+ pluginMcpServerName,
790
+ mcpScriptForPlugin,
791
+ countSkillFiles,
792
+ readProjectMcpServers,
793
+ writeLastSessionCwd,
794
+ clean,
795
+ resolve,
796
+ statSync,
797
+ existsSync,
798
+ cfgMod,
799
+ STANDALONE_DATA_DIR,
800
+ });
801
+
802
+ function skillContent(name) {
803
+ const res = typeof contextMod.loadSkillResource === 'function'
804
+ ? contextMod.loadSkillResource(name, currentCwd)
805
+ : null;
806
+ if (!res) throw new Error(`skill not found: ${name}`);
807
+ return { name, content: res.content, dir: res.dir };
808
+ }
809
+
810
+ function skillToolContent(name) {
811
+ if (typeof contextMod.isSkillDisabled === 'function' && contextMod.isSkillDisabled(name)) {
812
+ const label = String(name || '').trim() || 'skill';
813
+ return `Error: skill "${label}" is disabled`;
814
+ }
815
+ const skill = skillContent(name);
816
+ // Return the general tool envelope so the main/Lead session behaves the
817
+ // same as agent-loop sessions: the model-visible tool_result is the short
818
+ // stub (`Loaded skill: <name>`) and the full SKILL.md body is delivered
819
+ // ONCE as a separate injected role:'user' message (newMessages). The
820
+ // envelope passes through internal-tools._normalize untouched (it preserves
821
+ // __toolEnvelope objects), and the agent loop's central normalizeToolEnvelope
822
+ // splits it into stub + injected user body.
823
+ return contextMod.buildSkillToolEnvelope(skill.name, skill.content, skill.dir);
824
+ }
825
+
826
+ function addProjectSkill(input = {}) {
827
+ const name = clean(input.name).replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
828
+ if (!name) throw new Error('skill name is required');
829
+ const dir = join(currentCwd, '.mixdog', 'skills', name);
830
+ const filePath = join(dir, 'SKILL.md');
831
+ if (existsSync(filePath)) throw new Error(`skill already exists: ${name}`);
832
+ const description = clean(input.description) || 'Project skill.';
833
+ mkdirSync(dir, { recursive: true });
834
+ writeFileSync(filePath, [
835
+ '---',
836
+ `name: ${name}`,
837
+ `description: ${description}`,
838
+ '---',
839
+ '',
840
+ '# Instructions',
841
+ '',
842
+ 'Describe when and how to use this skill.',
843
+ '',
844
+ ].join('\n'), 'utf8');
845
+ return { name, filePath };
846
+ }
847
+
848
+ const agentToolStartedAt = performance.now();
849
+ const agentTool = createStandaloneAgent({
850
+ cfgMod,
851
+ reg,
852
+ mgr,
853
+ dataDir: cfgMod.getPluginData(),
854
+ cwd,
855
+ // SubagentStart/SubagentStop: bridge internal worker spawn/finish to the
856
+ // standard hook bus. agent_type is passed top-level via hookCommonPayload
857
+ // (added to hook-bus buildEventPayload passthrough). Best-effort.
858
+ onSubagentEvent: (phase, info = {}) => {
859
+ try {
860
+ const event = phase === 'stop' ? 'SubagentStop' : 'SubagentStart';
861
+ void hooks.dispatch(event, hookCommonPayload({
862
+ session_id: info?.session_id || null,
863
+ agent_type: info?.agent_type || null,
864
+ }));
865
+ } catch { /* best-effort: subagent hook must never affect worker lifecycle */ }
866
+ },
867
+ });
868
+ bootProfile('agent:ready', { ms: (performance.now() - agentToolStartedAt).toFixed(1) });
869
+ const agentStatusState = () => {
870
+ try {
871
+ const status = agentTool.getStatus?.({ clientHostPid: session?.clientHostPid || process.pid }) || {};
872
+ return {
873
+ agentWorkers: Array.isArray(status.workers) ? status.workers : [],
874
+ agentJobs: Array.isArray(status.jobs) ? status.jobs : [],
875
+ agentScope: status.scope || null,
876
+ };
877
+ } catch {
878
+ return { agentWorkers: [], agentJobs: [], agentScope: null };
879
+ }
880
+ };
881
+ const channelsStartedAt = performance.now();
882
+ const channels = createStandaloneChannelWorker({
883
+ entry: join(__dirname, CHANNEL_WORKER_ENTRY),
884
+ rootDir: STANDALONE_ROOT,
885
+ dataDir: cfgMod.getPluginData(),
886
+ cwd,
887
+ onNotify: (msg) => {
888
+ // Single-holder remote: the worker reports it lost the bridge seat to a
889
+ // newer remote session. Drop remote mode entirely on this session (no
890
+ // handover, no retry) and tell UI listeners so the indicator updates.
891
+ if (msg?.method === 'notifications/mixdog/remote') {
892
+ // Any acquire/supersede verdict resolves an in-flight auto claim attempt.
893
+ remoteClaimPending = false;
894
+ if (msg?.params?.state === 'superseded' && remoteEnabled) {
895
+ stopRemote('superseded-by-newer-remote-session');
896
+ emitRemoteStateChange(false, 'superseded');
897
+ }
898
+ // Symmetric acquire: the worker took the bridge seat (boot make-before-
899
+ // break or activate). Flip remote ON via the same side-state a user
900
+ // /remote toggles — remoteEnabled + transcript writer — but WITHOUT
901
+ // re-invoking channel start (the worker is already running; startRemote
902
+ // would re-fork/activate). Idempotent: no-op when already enabled.
903
+ if (msg?.params?.state === 'acquired' && !remoteEnabled) {
904
+ remoteEnabled = true;
905
+ ensureRemoteTranscriptWriter();
906
+ emitRemoteStateChange(true, 'acquired');
907
+ }
908
+ return;
909
+ }
910
+ if (msg?.method !== 'notifications/claude/channel') return;
911
+ const params = msg?.params && typeof msg.params === 'object' ? msg.params : {};
912
+ const meta = params.meta && typeof params.meta === 'object' ? params.meta : {};
913
+ const content = channelNotificationModelContent(params);
914
+ if (!content) return;
915
+ const handled = emitRuntimeNotification(content, meta);
916
+ if (!handled && session?.id && shouldMirrorChannelNotificationToPending(meta)) {
917
+ try { mgr.enqueuePendingMessage(session.id, content); } catch {}
918
+ }
919
+ },
920
+ });
921
+ bootProfile('channels:worker-ready', { ms: (performance.now() - channelsStartedAt).toFixed(1) });
922
+ const toolsStartedAt = performance.now();
923
+ const standaloneTools = [
924
+ TOOL_SEARCH_TOOL,
925
+ SKILL_TOOL,
926
+ CWD_TOOL,
927
+ SESSION_MANAGE_TOOL,
928
+ EXPLORE_TOOL,
929
+ ...(searchToolDefs?.TOOL_DEFS || []).filter((tool) => tool?.name === 'search' || tool?.name === 'web_fetch'),
930
+ ...(memoryToolDefs?.TOOL_DEFS || []).filter((tool) => tool?.name === 'recall' || tool?.name === 'memory'),
931
+ ...(channelToolDefs?.TOOL_DEFS || []).filter((tool) => channels.isChannelTool(tool?.name)),
932
+ ...(codeGraphToolDefs?.CODE_GRAPH_TOOL_DEFS || []).filter((tool) => tool?.name === 'code_graph'),
933
+ ...agentTool.tools,
934
+ ].map(applyStandaloneToolDefaults);
935
+ bootProfile('tools:ready', { ms: (performance.now() - toolsStartedAt).toFixed(1), count: standaloneTools.length });
936
+
937
+ function invalidatePreSessionToolSurface() {
938
+ preSessionToolSurface = null;
939
+ }
940
+
941
+ const { contextStatus: computeContextStatus, invalidateContextStatusCache } = createContextStatus({
942
+ getSession: () => session,
943
+ getRoute: () => route,
944
+ getCurrentCwd: () => currentCwd,
945
+ getMode: () => mode,
946
+ });
947
+
948
+ function buildPreSessionToolSurface() {
949
+ const previewTools = typeof mgr.previewSessionTools === 'function'
950
+ ? mgr.previewSessionTools(toolSpecForMode(mode), [])
951
+ : [];
952
+ const tools = filterDisallowedTools(previewTools, LEAD_DISALLOWED_TOOLS);
953
+ const surface = { tools: Array.isArray(tools) ? tools.slice() : [] };
954
+ applyDeferredToolSurface(surface, deferredSurfaceModeForLead(mode), standaloneTools, { provider: route.provider });
955
+ return surface;
956
+ }
957
+
958
+ function activeToolSurface() {
959
+ if (session) return session;
960
+ preSessionToolSurface ??= buildPreSessionToolSurface();
961
+ return preSessionToolSurface;
962
+ }
963
+
964
+ function applyPreSessionToolSelection() {
965
+ if (!session || !preSessionToolSurface) return;
966
+ const selected = Array.isArray(preSessionToolSurface.deferredSelectedTools)
967
+ ? preSessionToolSurface.deferredSelectedTools
968
+ : [];
969
+ const discovered = Array.isArray(preSessionToolSurface.deferredDiscoveredTools)
970
+ ? preSessionToolSurface.deferredDiscoveredTools
971
+ : [];
972
+ const replay = [...new Set([...selected, ...discovered])];
973
+ if (replay.length) selectDeferredTools(session, replay, deferredSurfaceModeForLead(mode));
974
+ }
975
+ internalTools.setInternalToolsProvider({
976
+ tools: standaloneTools,
977
+ executor: async (name, args, callerCtx = {}) => {
978
+ const callerCwd = callerCtx?.callerCwd || currentCwd;
979
+ if (name === 'search' || name === 'web_fetch') {
980
+ const callerSessionId = callerCtx?.callerSessionId || session?.id || null;
981
+ const searchMod = await getSearchModule();
982
+ if (!searchMod?.handleToolCall) throw new Error('search runtime is not available');
983
+ return await searchMod.handleToolCall(name, args || {}, {
984
+ callerCwd,
985
+ callerSessionId,
986
+ routingSessionId: callerSessionId,
987
+ clientHostPid: callerCtx?.clientHostPid || session?.clientHostPid || process.pid,
988
+ notifyFn: notifyFnForSession(callerSessionId),
989
+ nativeSearch: name === 'search'
990
+ ? async (searchArgs) => runNativeWebSearch(searchArgs, { signal: callerCtx?.signal || session?.controller?.signal })
991
+ : undefined,
992
+ });
993
+ }
994
+ if (name === 'recall' || name === 'memory' || name === 'search_memories') {
995
+ const memoryMod = await getMemoryModule();
996
+ if (!memoryMod?.handleToolCall) throw new Error('memory runtime is not available');
997
+ return await memoryMod.handleToolCall(name, args || {});
998
+ }
999
+ if (name === 'code_graph') {
1000
+ const codeGraphMod = await getCodeGraphModule();
1001
+ if (!codeGraphMod?.executeCodeGraphTool) throw new Error('code_graph runtime is not available');
1002
+ return await codeGraphMod.executeCodeGraphTool(name, args || {}, args?.cwd || callerCwd);
1003
+ }
1004
+ if (name === 'tool_search') {
1005
+ return renderToolSearch(args, activeToolSurface(), mode);
1006
+ }
1007
+ if (name === 'explore') {
1008
+ const callerSessionId = callerCtx?.callerSessionId || session?.id || null;
1009
+ return await runExplore(args || {}, {
1010
+ callerCwd: args?.cwd ? resolveCwdPath(args.cwd) : callerCwd,
1011
+ callerSessionId,
1012
+ routingSessionId: callerSessionId,
1013
+ clientHostPid: callerCtx?.clientHostPid || session?.clientHostPid || process.pid,
1014
+ notifyFn: notifyFnForSession(callerSessionId),
1015
+ });
1016
+ }
1017
+ if (name === 'cwd') {
1018
+ const action = clean(args?.action || (args?.path ? 'set' : 'get')).toLowerCase();
1019
+ if (action === 'set') {
1020
+ applyResolvedCwd(resolveCwdPath(args?.path));
1021
+ } else if (action !== 'get') {
1022
+ throw new Error(`cwd: unknown action "${action}"`);
1023
+ }
1024
+ return JSON.stringify({ cwd: currentCwd, sessionId: session?.id || null }, null, 2);
1025
+ }
1026
+ if (name === 'session_manage') {
1027
+ // Lead/owner sessions only: an agent worker resetting its own
1028
+ // transcript mid-task would corrupt the delegation contract, and it
1029
+ // must never reach the owner conversation either.
1030
+ const callerSessionId = callerCtx?.callerSessionId || null;
1031
+ if (callerSessionId && session?.id && callerSessionId !== session.id) {
1032
+ throw new Error('session_manage: only the lead session may reset the conversation');
1033
+ }
1034
+ if (!session?.id) throw new Error('session_manage: no active session');
1035
+ const action = clean(args?.action).toLowerCase();
1036
+ if (action !== 'clear' && action !== 'compact_clear') {
1037
+ throw new Error(`session_manage: unknown action "${action}" (use clear | compact_clear)`);
1038
+ }
1039
+ // Never clear mid-turn — the loop is still reading the transcript.
1040
+ // Schedule and let the TUI engine consume it at turn end (same
1041
+ // boundary the idle auto-clear uses).
1042
+ // Pin to the current session id so a resume/new-session between
1043
+ // scheduling and consumption can never clear the wrong conversation.
1044
+ pendingSessionReset = { action, sessionId: session.id };
1045
+ return action === 'clear'
1046
+ ? 'Session reset scheduled: full clear will run when this turn ends. All prior context will be gone.'
1047
+ : 'Session reset scheduled: the conversation will be summarized (compact) and cleared when this turn ends; key context carries forward in the summary.';
1048
+ }
1049
+ if (name === 'Skill') {
1050
+ return skillToolContent(args?.name);
1051
+ }
1052
+ if (name === 'agent') {
1053
+ const callerSessionId = callerCtx?.callerSessionId || session?.id || null;
1054
+ return await agentTool.execute(args, {
1055
+ callerCwd,
1056
+ invocationSource: 'model-tool',
1057
+ callerSessionId,
1058
+ clientHostPid: callerCtx?.clientHostPid || session?.clientHostPid || process.pid,
1059
+ signal: callerCtx?.signal,
1060
+ notifyFn: notifyFnForSession(callerSessionId),
1061
+ });
1062
+ }
1063
+ if (channels.isChannelTool(name)) {
1064
+ if (!channelsEnabled()) throw new Error('channels are disabled in settings');
1065
+ return await channels.execute(name, args || {});
1066
+ }
1067
+ throw new Error(`unknown standalone internal tool: ${name}`);
1068
+ },
1069
+ });
1070
+ internalTools.markBootReady?.();
1071
+ try { lastProjectMcpKey = resolve(currentCwd) + '\u0000' + JSON.stringify(readProjectMcpServers(currentCwd)); } catch { lastProjectMcpKey = null; }
1072
+ void connectConfiguredMcp()
1073
+ .then((status) => bootProfile('mcp:ready', {
1074
+ connected: Number(status?.connectedCount || 0),
1075
+ failed: Number(status?.failedCount || 0),
1076
+ }))
1077
+ .catch((error) => bootProfile('mcp:failed', { error: error?.message || String(error) }));
1078
+
1079
+ function reloadChannelsSoon() {
1080
+ channels.execute('reload_config', {}).catch(() => {});
1081
+ }
1082
+
1083
+ function invalidateProviderCaches() {
1084
+ providerModelCaches.providerModelsCache = { models: null, at: 0 };
1085
+ providerModelCaches.providerModelsPromise = null;
1086
+ providerModelCaches.providerModelsLoadSeq += 1;
1087
+ providerModelCaches.searchProviderModelsCache = { models: null, at: 0 };
1088
+ providerUsageCaches.usageDashboardCache = { dashboard: null, at: 0 };
1089
+ providerUsageCaches.usageDashboardPromise = null;
1090
+ providerUsageCaches.providerSetupCache = { setup: null, at: 0 };
1091
+ providerUsageCaches.providerSetupQuickCache = { setup: null, at: 0 };
1092
+ providerUsageCaches.providerSetupPromise = null;
1093
+ providerInitPromise = null;
1094
+ modelMetaByRoute.clear();
1095
+ }
1096
+
1097
+ // Config reload/save/adopt family + output-style status cache. Extracted to
1098
+ // session-runtime/config-lifecycle.mjs; the facade retains ownership of the
1099
+ // config/searchRoute/configHasSecrets mutable locals via getter/setter
1100
+ // injection (the proven mutable-state pattern).
1101
+ const {
1102
+ getOutputStyleStatusCached,
1103
+ invalidateOutputStyleStatusCache,
1104
+ seedOutputStyleStatusCache,
1105
+ adoptConfig,
1106
+ saveConfigAndAdopt,
1107
+ flushConfigSave,
1108
+ flushBackendSave,
1109
+ scheduleBackendSave,
1110
+ flushOutputStyleSave,
1111
+ scheduleOutputStyleSave,
1112
+ reloadFullConfig,
1113
+ ensureFullConfig,
1114
+ displayConfig,
1115
+ ensureConfigForRouteProvider,
1116
+ } = createConfigLifecycle({
1117
+ getConfig: () => config,
1118
+ setConfig: (next) => { config = next; },
1119
+ getSearchRoute: () => searchRoute,
1120
+ setSearchRoute: (next) => { searchRoute = next; },
1121
+ getConfigHasSecrets: () => configHasSecrets,
1122
+ setConfigHasSecrets: (next) => { configHasSecrets = next; },
1123
+ getRoute: () => route,
1124
+ cfgMod,
1125
+ sharedCfgMod,
1126
+ setBackend,
1127
+ setConfiguredShell,
1128
+ normalizeSystemShellConfig,
1129
+ normalizeSearchRouteConfig,
1130
+ outputStyleStatus,
1131
+ LAZY_SECRET_PROVIDERS,
1132
+ clean,
1133
+ resolve,
1134
+ STANDALONE_DATA_DIR,
1135
+ });
1136
+
1137
+ async function ensureProvidersReady(providerConfig = config.providers || {}) {
1138
+ if (providerInitPromise) return await providerInitPromise;
1139
+ providerInitPromise = reg.initProviders(providerConfig)
1140
+ .finally(() => {
1141
+ providerInitPromise = null;
1142
+ });
1143
+ return await providerInitPromise;
1144
+ }
1145
+
1146
+ const {
1147
+ currentMainSearchModelMeta,
1148
+ runNativeWebSearch,
1149
+ } = createNativeSearch({
1150
+ getRoute: () => route,
1151
+ getSearchRoute: () => searchRoute,
1152
+ setSearchRoute: (next) => { searchRoute = next; },
1153
+ getConfig: () => config,
1154
+ getSession: () => session,
1155
+ getReg: () => reg,
1156
+ ensureFullConfig,
1157
+ ensureProvidersReady,
1158
+ ensureProviderEnabled,
1159
+ normalizeSearchProviderId,
1160
+ normalizeSearchRouteConfig,
1161
+ isDefaultSearchRouteConfig,
1162
+ isSearchCapableProvider,
1163
+ searchCapableFor,
1164
+ });
1165
+
1166
+ // Late-bound: createWarmupSchedulers is constructed after this factory, but
1167
+ // cachedProviderSetup(quick) may nudge scheduleProviderSetupWarmup on a cold
1168
+ // quick-cache fill. Thread it by reference so the scheduler is reachable once
1169
+ // it exists (a pre-scheduler quick fill simply skips the warmup nudge).
1170
+ let scheduleProviderSetupWarmupRef = () => {};
1171
+ const {
1172
+ refreshStatuslineUsageSnapshot,
1173
+ cachedProviderSetup,
1174
+ getUsageDashboard,
1175
+ } = createProviderUsage({
1176
+ caches: providerUsageCaches,
1177
+ getConfig: () => config,
1178
+ getReg: () => reg,
1179
+ displayConfig,
1180
+ providerSetup,
1181
+ createUsageDashboard,
1182
+ fetchOAuthUsageSnapshot,
1183
+ isCloseRequested: () => closeRequested,
1184
+ getProviderSetupWarmupTimer: () => warmupTimers.providerSetupWarmupTimer,
1185
+ scheduleProviderSetupWarmup: (delayMs) => scheduleProviderSetupWarmupRef(delayMs),
1186
+ });
1187
+
1188
+ // Holder filled after createQuickModelRows resolves; provider-models and
1189
+ // quick-model-rows are mutually dependent (rows need cache-row helpers, the
1190
+ // model factory needs quick fallbacks) so we thread the quick surface in by
1191
+ // reference after both are constructed.
1192
+ const providerModelQuickHelpers = {};
1193
+ // Late-bound: createWarmupSchedulers is constructed after this factory, but
1194
+ // lookupModelMeta may fire scheduleProviderModelWarmup on a cache miss. Thread
1195
+ // it by reference so the scheduler is called once it exists (miss handling is
1196
+ // best-effort; a pre-scheduler miss simply skips the warmup nudge).
1197
+ let scheduleProviderModelWarmupRef = () => {};
1198
+ const {
1199
+ modelMetaKey,
1200
+ lookupModelMeta,
1201
+ sortProviderModels,
1202
+ providerModelCacheRow,
1203
+ providerModelsFromCacheRows,
1204
+ collectSearchProviderModels,
1205
+ collectProviderModels,
1206
+ warmProviderModelCache,
1207
+ } = createProviderModels({
1208
+ caches: providerModelCaches,
1209
+ modelMetaByRoute,
1210
+ getRoute: () => route,
1211
+ getConfig: () => config,
1212
+ getReg: () => reg,
1213
+ searchCapableFor,
1214
+ sortProviderModelsRaw,
1215
+ providerModelCacheRowRaw,
1216
+ normalizeSearchProviderId,
1217
+ isSearchCapableProvider,
1218
+ ensureFullConfig,
1219
+ ensureProvidersReady,
1220
+ bootProfile,
1221
+ scheduleProviderModelWarmup: () => scheduleProviderModelWarmupRef(),
1222
+ quickHelpers: providerModelQuickHelpers,
1223
+ });
1224
+
1225
+ const {
1226
+ quickProviderModelRows,
1227
+ addDefaultSearchModel,
1228
+ quickSearchProviderModelRows,
1229
+ searchModelsFromRows,
1230
+ searchRowsWithDefault,
1231
+ } = createQuickModelRows({
1232
+ getRoute: () => route,
1233
+ getSearchRoute: () => searchRoute,
1234
+ displayConfig,
1235
+ providerModelCacheRow,
1236
+ providerModelsFromCacheRows,
1237
+ sortProviderModels,
1238
+ modelMetaByRoute,
1239
+ modelMetaKey,
1240
+ normalizeSearchProviderId,
1241
+ normalizeSearchRouteConfig,
1242
+ isSearchCapableProvider,
1243
+ searchCapableFor,
1244
+ currentMainSearchModelMeta,
1245
+ });
1246
+ Object.assign(providerModelQuickHelpers, {
1247
+ quickProviderModelRows,
1248
+ addDefaultSearchModel,
1249
+ quickSearchProviderModelRows,
1250
+ searchModelsFromRows,
1251
+ searchRowsWithDefault,
1252
+ });
1253
+
1254
+ async function resolveMissingRouteModelForFirstTurn() {
1255
+ if (routeHasModel()) return route;
1256
+ const models = await collectProviderModels();
1257
+ const picked = models[0] || null;
1258
+ if (!picked) {
1259
+ throw new Error('No provider models available. Open /providers to sign in, then /model to choose a model.');
1260
+ }
1261
+ route = {
1262
+ ...route,
1263
+ provider: picked.provider,
1264
+ model: picked.id,
1265
+ preset: null,
1266
+ };
1267
+ return route;
1268
+ }
1269
+
1270
+ async function refreshRouteEffort(modelMetaOverride = null) {
1271
+ await ensureProvidersReady(ensureProviderEnabled(config, route.provider));
1272
+ const modelMeta = modelMetaOverride || await lookupModelMeta(route.provider, route.model);
1273
+ const requested = hasOwn(route, 'effort') ? route.effort : (route.preset?.effort || null);
1274
+ const effectiveEffort = coerceEffortFor(route.provider, modelMeta, requested);
1275
+ const fastCapable = fastCapableFor(route.provider, modelMeta);
1276
+ // Carry the catalog display name onto the route so the statusline shows a
1277
+ // human label (e.g. "Claude Fable 5") for preset-less direct models instead
1278
+ // of the raw id. `name` is only trusted when it differs from the raw model
1279
+ // id (some providers echo the id as `name`), so it can't clobber a better
1280
+ // already-resolved label. Falls back to existing route.modelDisplay, then unset.
1281
+ const metaName = clean(modelMeta?.name);
1282
+ const modelDisplay = clean(modelMeta?.display) || clean(modelMeta?.displayName)
1283
+ || (metaName && metaName !== clean(route.model) ? metaName : '')
1284
+ || clean(route.modelDisplay);
1285
+ route = {
1286
+ ...route,
1287
+ fast: fastCapable ? route.fast === true : false,
1288
+ fastCapable,
1289
+ effectiveEffort,
1290
+ effortOptions: effortItemsFor(route.provider, modelMeta, effectiveEffort),
1291
+ ...(modelDisplay ? { modelDisplay } : {}),
1292
+ };
1293
+ return route;
1294
+ }
1295
+
1296
+ function routeHasModel() {
1297
+ return !!clean(route?.model);
1298
+ }
1299
+
1300
+ function requireModelRoute() {
1301
+ if (routeHasModel()) return;
1302
+ throw new Error('No model configured. Open /providers to sign in, then /model to choose a model.');
1303
+ }
1304
+
1305
+ async function recreateCurrentSessionIfReady() {
1306
+ if (!routeHasModel()) {
1307
+ session = null;
1308
+ return null;
1309
+ }
1310
+ return await createCurrentSession();
1311
+ }
1312
+
1313
+ async function createCurrentSession(reason = 'demand') {
1314
+ if (sessionCreatePromise) return await sessionCreatePromise;
1315
+ if (session?.id && !sessionNeedsCwdRefresh) {
1316
+ const liveSession = mgr.getSession(session.id);
1317
+ if (liveSession && liveSession.closed !== true && liveSession.status !== 'closed') {
1318
+ session = liveSession;
1319
+ return session;
1320
+ }
1321
+ session = null;
1322
+ }
1323
+
1324
+ const startedAt = performance.now();
1325
+ bootProfile('session:create:start', { mode, reason });
1326
+ const promise = (async () => {
1327
+ ensureConfigForRouteProvider();
1328
+ await resolveMissingRouteModelForFirstTurn();
1329
+ requireModelRoute();
1330
+ bootProfile('session:create:route-ready', { ms: (performance.now() - startedAt).toFixed(1) });
1331
+ // refreshRouteEffort (effort/model-meta) and loadCoreMemoryContext (memory
1332
+ // files) are independent — refreshRouteEffort only touches route effort/
1333
+ // display fields, never provider/model that the memory load reads — so run
1334
+ // them concurrently instead of serially on the boot path.
1335
+ const [, coreMemoryContext] = await Promise.all([
1336
+ refreshRouteEffort(),
1337
+ loadCoreMemoryContext(),
1338
+ ]);
1339
+ bootProfile('session:create:effort-ready', { ms: (performance.now() - startedAt).toFixed(1) });
1340
+ const providerImpl = reg.getProvider(route.provider);
1341
+ if (!providerImpl) {
1342
+ throw new Error(`Provider "${route.provider}" is not configured.`);
1343
+ }
1344
+ bootProfile('session:create:provider-ready', { ms: (performance.now() - startedAt).toFixed(1) });
1345
+ if (closeRequested) throw new Error('runtime is closing');
1346
+ const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
1347
+ // Load the active WORKFLOW.md pack once for both summary + context block.
1348
+ const { summary: workflow, context: workflowContext } = activeWorkflowContext(config, dataDir);
1349
+ const sessionOpts = {
1350
+ provider: route.provider,
1351
+ model: route.model,
1352
+ preset: route.preset || undefined,
1353
+ tools: toolSpecForMode(mode),
1354
+ owner: 'cli',
1355
+ agent: 'lead',
1356
+ lane: 'cli',
1357
+ sourceType: 'lead',
1358
+ sourceName: 'main',
1359
+ clientHostPid: process.pid,
1360
+ disallowedTools: LEAD_DISALLOWED_TOOLS,
1361
+ cwd: currentCwd,
1362
+ coreMemoryContext,
1363
+ workflow,
1364
+ workflowContext,
1365
+ fast: route.fast === true,
1366
+ compaction: config.compaction && typeof config.compaction === 'object'
1367
+ ? normalizeCompactionConfig(config.compaction, { memoryEnabled: memoryEnabled() })
1368
+ : undefined,
1369
+ };
1370
+ if (hasOwn(route, 'effort') || route.effectiveEffort) {
1371
+ sessionOpts.effort = route.effectiveEffort || null;
1372
+ }
1373
+ session = mgr.createSession(sessionOpts);
1374
+ sessionNeedsCwdRefresh = false;
1375
+ attachSessionHooks(session, { hooks, hookCommonPayload, getCwd: () => currentCwd });
1376
+ applyDeferredToolSurface(session, deferredSurfaceModeForLead(mode), standaloneTools, { provider: route.provider });
1377
+ applyPreSessionToolSelection();
1378
+ writeStatuslineRoute(statusRoutes, session, route);
1379
+ hooks.emit('session:create', { sessionId: session.id, provider: route.provider, model: route.model, toolMode: mode, cwd: currentCwd });
1380
+ // SessionStart: bridge to the standard project hook bus. Best-effort;
1381
+ // a hook error must never break session creation. additionalContext is
1382
+ // injected before the first user turn as a system-reminder context pair.
1383
+ try {
1384
+ const startSource = /resume/i.test(String(reason || ''))
1385
+ ? 'resume'
1386
+ : (/clear/i.test(String(reason || '')) ? 'clear' : 'startup');
1387
+ const startDispatch = await hooks.dispatch('SessionStart', hookCommonPayload({ session_id: session.id, source: startSource, model: route.model }));
1388
+ const startContext = Array.isArray(startDispatch?.additionalContext)
1389
+ ? startDispatch.additionalContext.join('\n\n')
1390
+ : String(startDispatch?.additionalContext || '');
1391
+ if (startContext.trim()) {
1392
+ session.messages.push({ role: 'user', content: `<system-reminder>\n# SessionStart Hook Context\n${startContext.trim()}\n</system-reminder>` });
1393
+ session.messages.push({ role: 'assistant', content: '.' });
1394
+ session.updatedAt = Date.now();
1395
+ }
1396
+ } catch { /* best-effort: never break session create */ }
1397
+ bootProfile('session:create:ready', {
1398
+ ms: (performance.now() - startedAt).toFixed(1),
1399
+ reason,
1400
+ tools: Array.isArray(session.tools) ? session.tools.length : 0,
1401
+ catalog: Array.isArray(session.deferredToolCatalog) ? session.deferredToolCatalog.length : 0,
1402
+ });
1403
+ return session;
1404
+ })();
1405
+
1406
+ sessionCreatePromise = promise;
1407
+ try {
1408
+ return await promise;
1409
+ } finally {
1410
+ if (sessionCreatePromise === promise) sessionCreatePromise = null;
1411
+ }
1412
+ }
1413
+
1414
+ const {
1415
+ scheduleProviderWarmup,
1416
+ scheduleProviderSetupWarmup,
1417
+ scheduleProviderModelWarmup,
1418
+ scheduleModelCatalogWarmup,
1419
+ scheduleStatuslineUsageWarmup,
1420
+ scheduleStatuslineUsageRefresh,
1421
+ } = createWarmupSchedulers({
1422
+ timers: warmupTimers,
1423
+ bootProfile,
1424
+ getRoute: () => route,
1425
+ getConfig: () => config,
1426
+ isCloseRequested: () => closeRequested,
1427
+ getActiveTurnCount: () => activeTurnCount,
1428
+ getSessionCreatePromise: () => sessionCreatePromise,
1429
+ getProviderModelsCache: () => providerModelCaches.providerModelsCache,
1430
+ getProviderModelsPromise: () => providerModelCaches.providerModelsPromise,
1431
+ reloadFullConfig,
1432
+ ensureConfigForRouteProvider,
1433
+ ensureProvidersReady,
1434
+ ensureProviderEnabled,
1435
+ refreshStatuslineUsageSnapshot,
1436
+ warmProviderModelCache,
1437
+ cachedProviderSetup,
1438
+ warmCatalogsInBackground,
1439
+ isFirstTurnCompleted: () => firstTurnCompleted,
1440
+ envFlag,
1441
+ delays: {
1442
+ providerWarmupDelayMs,
1443
+ providerSetupWarmupDelayMs,
1444
+ providerModelWarmupDelayMs,
1445
+ modelCatalogWarmupDelayMs,
1446
+ statuslineUsageWarmupDelayMs,
1447
+ statuslineUsageRefreshDelayMs,
1448
+ backgroundBusyRetryMs,
1449
+ },
1450
+ flags: {
1451
+ providerWarmupEnabled,
1452
+ modelPrefetchEnabled,
1453
+ modelCatalogWarmupEnabled,
1454
+ },
1455
+ });
1456
+ scheduleProviderModelWarmupRef = scheduleProviderModelWarmup;
1457
+ scheduleProviderSetupWarmupRef = scheduleProviderSetupWarmup;
1458
+
1459
+ const {
1460
+ scheduleCodeGraphPrewarm,
1461
+ scheduleLeadSessionPrewarm,
1462
+ invokeChannelStart,
1463
+ scheduleChannelStart,
1464
+ } = createPrewarmSchedulers({
1465
+ timers: prewarmTimers,
1466
+ bootProfile,
1467
+ getCurrentCwd: () => currentCwd,
1468
+ isCloseRequested: () => closeRequested,
1469
+ getActiveTurnCount: () => activeTurnCount,
1470
+ getSessionCreatePromise: () => sessionCreatePromise,
1471
+ getSession: () => session,
1472
+ isRemoteEnabled: () => remoteEnabled,
1473
+ channelsEnabled,
1474
+ getCodeGraphModule,
1475
+ createCurrentSession,
1476
+ channels,
1477
+ envFlag,
1478
+ delays: {
1479
+ codeGraphPrewarmDelayMs,
1480
+ channelStartDelayMs,
1481
+ sessionPrewarmDelayMs,
1482
+ backgroundBusyRetryMs,
1483
+ },
1484
+ flags: {
1485
+ codeGraphPrewarmEnabled,
1486
+ sessionPrewarmEnabled,
1487
+ },
1488
+ state: prewarmState,
1489
+ });
1490
+
1491
+ // Eagerly create/refresh the remote transcript writer for the CURRENT
1492
+ // session + cwd, publish the session record, and ensure the transcript
1493
+ // file exists on disk. Called from startRemote() (so the channel worker's
1494
+ // activate-time discovery finds THIS session immediately instead of
1495
+ // waiting for the first turn) and from ask() at turn start. Returns true
1496
+ // when a writer is bound.
1497
+ function ensureRemoteTranscriptWriter() {
1498
+ if (!remoteEnabled || !session?.id) return false;
1499
+ const twKey = `${session.id}\u0000${currentCwd}`;
1500
+ if (_twKey !== twKey) {
1501
+ try {
1502
+ _transcriptWriter = createTranscriptWriter({
1503
+ mixdogHome: mixdogHome(),
1504
+ sessionId: session.id,
1505
+ cwd: currentCwd,
1506
+ pid: process.pid,
1507
+ });
1508
+ _transcriptWriter.writeSessionRecord();
1509
+ _twKey = twKey;
1510
+ } catch (error) {
1511
+ process.stderr.write(`mixdog: transcript-writer: init failed: ${error?.message || error}\n`);
1512
+ _transcriptWriter = null;
1513
+ _twKey = '';
1514
+ return false;
1515
+ }
1516
+ } else {
1517
+ // Same binding — refresh updatedAt so worker-side discovery keeps
1518
+ // ranking this session as the live parent-chain candidate.
1519
+ try { _transcriptWriter?.writeSessionRecord(); } catch {}
1520
+ }
1521
+ try { _transcriptWriter?.ensureTranscriptFile(); }
1522
+ catch (error) { process.stderr.write(`mixdog: transcript-writer: ensureTranscriptFile failed: ${error?.message || error}\n`); }
1523
+ return _transcriptWriter != null;
1524
+ }
1525
+
1526
+ // Remote (Discord channel) mode is opt-in per session. Only a session that
1527
+ // explicitly enables remote — via `mixdog --remote` or the runtime toggle —
1528
+ // boots the channel worker and contends for channel ownership.
1529
+ // startRemote() is FORCE-TAKEOVER: it always (re)claims the bridge seat and
1530
+ // rebinds output forwarding to this session, even when the worker is
1531
+ // already running (e.g. `/remote` re-issued after another session took the
1532
+ // seat, or to re-pin forwarding onto the current transcript).
1533
+ function startRemote(options = {}) {
1534
+ const intent = options?.intent === 'auto' ? 'auto' : 'explicit';
1535
+ // Claim intent reaches the worker's boot claim via MIXDOG_REMOTE_INTENT
1536
+ // (last-wins for explicit, claim-if-vacant for auto). It is set transiently
1537
+ // around the worker fork below (see invokeChannelStart) and restored right
1538
+ // after — NOT here on the shared process.env — so unrelated children forked
1539
+ // during the boot window never inherit a stale intent.
1540
+ if (intent === 'auto') {
1541
+ // Auto-start (config/delayed): do NOT flip this session to remote up
1542
+ // front. Boot the worker to ATTEMPT a claim-if-vacant; remoteEnabled is
1543
+ // set only when the worker reports it actually acquired the seat (the
1544
+ // 'acquired' notification). A live owner already holding the seat makes
1545
+ // the worker back off silently and this session stays non-remote.
1546
+ remoteClaimPending = true;
1547
+ } else {
1548
+ remoteEnabled = true;
1549
+ }
1550
+ // Boot the memory daemon eagerly. The channels worker forwards
1551
+ // transcript ingests/entries to the memory HTTP service, whose port is
1552
+ // published to active-instance.json by getMemoryModule().init(). Without
1553
+ // this, memory only starts on the first turn's getMemoryModule() call —
1554
+ // so early channel traffic finds no memory_port and gets buffered (or,
1555
+ // pre-drainer, silently dropped). Runs BEFORE channel claim so the port is
1556
+ // racing to be live by the time the worker sends its first ingest.
1557
+ //
1558
+ // Not fire-and-forget: init() only resolves the module handle, so a bare
1559
+ // getMemoryModule() could return before /health reports ok — leaving early
1560
+ // ingests to hit a not-yet-listening port. Await the proxy start() and then
1561
+ // poll /health with a bounded retry so the daemon is provably reachable
1562
+ // (or logged failed) rather than assumed-up. Still non-blocking to the
1563
+ // caller: the whole probe is detached, but it internally awaits readiness.
1564
+ void (async () => {
1565
+ try {
1566
+ // Yield one event-loop tick before the heavy chain below (module
1567
+ // resolve, daemon fork/health-poll) starts, so Ink's next render
1568
+ // (scheduled via setImmediate/timers) and any queued keypress
1569
+ // events get a turn first instead of being starved by this
1570
+ // detached chain's synchronous setup work.
1571
+ await new Promise((r) => setImmediate(r));
1572
+ const mod = await getMemoryModule();
1573
+ const started = typeof mod?.start === 'function' ? await mod.start() : null;
1574
+ const port = started?.port;
1575
+ if (!port) { bootProfile('channels:memory-eager-init-failed', { error: 'no port from start()' }); return; }
1576
+ for (let i = 0; i < 30; i++) {
1577
+ try {
1578
+ const ok = await new Promise((res) => {
1579
+ const req = httpMod.request({ hostname: '127.0.0.1', port, path: '/health', timeout: 1500 }, (r) => {
1580
+ let d = ''; r.on('data', (c) => { d += c; }); r.on('end', () => {
1581
+ try { res(JSON.parse(d)?.status === 'ok'); } catch { res(false); }
1582
+ });
1583
+ });
1584
+ req.on('error', () => res(false));
1585
+ req.on('timeout', () => { req.destroy(); res(false); });
1586
+ req.end();
1587
+ });
1588
+ if (ok) { bootProfile('channels:memory-eager-init-ready', { port }); return; }
1589
+ } catch {}
1590
+ await new Promise((r) => setTimeout(r, 500));
1591
+ }
1592
+ bootProfile('channels:memory-eager-init-failed', { error: `health not ok after retries (port ${port})` });
1593
+ } catch (error) {
1594
+ bootProfile('channels:memory-eager-init-failed', { error: error?.message || String(error) });
1595
+ }
1596
+ })();
1597
+ // Publish this session's record + transcript file BEFORE the worker's
1598
+ // activate-time discovery polls, so output forwarding binds to this
1599
+ // terminal session immediately instead of waiting for the first turn.
1600
+ // No-op when the session has not been created yet (lazy mode); that
1601
+ // case is covered by the turn-start rebind in ask().
1602
+ ensureRemoteTranscriptWriter();
1603
+ // A backend switch may still be sitting in its debounce window; flush it
1604
+ // so the channel worker boots against the backend the user just chose,
1605
+ // not the previous on-disk value.
1606
+ try { flushBackendSave(); } catch {}
1607
+ if (envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
1608
+ bootProfile('channels:start-skipped');
1609
+ return true;
1610
+ }
1611
+ if (!channelsEnabled()) {
1612
+ bootProfile('channels:start-disabled');
1613
+ return true;
1614
+ }
1615
+ if (closeRequested) return true;
1616
+ if (prewarmTimers.channelStartTimer) {
1617
+ clearTimeout(prewarmTimers.channelStartTimer);
1618
+ prewarmTimers.channelStartTimer = null;
1619
+ }
1620
+ bootProfile('channels:start-scheduled', { delayMs: 0, immediate: true });
1621
+ void (async () => {
1622
+ // Yield before the createCurrentSession/transcript/fork chain below —
1623
+ // same rationale as the memory-eager-init yield above: this detached
1624
+ // chain runs synchronous config/fs work (createCurrentSession, backend
1625
+ // flush, transcript writer) back-to-back, and without a tick break it
1626
+ // can run ahead of Ink's queued render/input handling.
1627
+ await new Promise((r) => setImmediate(r));
1628
+ // Immediate-occupancy guarantee: make sure a session + transcript
1629
+ // exist BEFORE the worker boots — a freshly-forked worker claims and
1630
+ // runs transcript discovery inside its own start(), so publishing the
1631
+ // session record/file first lets that very first discovery pass bind
1632
+ // output forwarding to THIS terminal instead of a persisted/stale
1633
+ // neighbour. Lazy mode means the session may not exist yet at /remote
1634
+ // time; create it here (idempotent — reuses a live session, joins an
1635
+ // in-flight create). On create failure we still claim: that matches
1636
+ // the pre-eager behavior (bind resolves on the first turn's rebind).
1637
+ try { await createCurrentSession('remote-start'); }
1638
+ catch (error) { bootProfile('channels:remote-session-create-failed', { error: error?.message || String(error) }); }
1639
+ ensureRemoteTranscriptWriter();
1640
+ // Re-check after the awaits above: stopRemote()/superseded or runtime
1641
+ // close may have landed mid-chain — do not boot/claim for a session
1642
+ // that already turned remote off.
1643
+ if ((!remoteEnabled && !remoteClaimPending) || closeRequested) { remoteClaimPending = false; return; }
1644
+ // Set the fork-inherited intent immediately before the worker fork (the
1645
+ // fork reads process.env synchronously inside invokeChannelStart) and
1646
+ // restore the prior value the instant it resolves, so the pollution
1647
+ // window is just this fork rather than the whole boot chain.
1648
+ const _prevIntent = process.env.MIXDOG_REMOTE_INTENT;
1649
+ try {
1650
+ process.env.MIXDOG_REMOTE_INTENT = intent;
1651
+ await invokeChannelStart();
1652
+ } finally {
1653
+ if (_prevIntent === undefined) delete process.env.MIXDOG_REMOTE_INTENT;
1654
+ else process.env.MIXDOG_REMOTE_INTENT = _prevIntent;
1655
+ }
1656
+ if ((!remoteEnabled && !remoteClaimPending) || closeRequested) { remoteClaimPending = false; return; }
1657
+ // Explicit start: unconditional claim + forwarder rebind (last-wins seat
1658
+ // overwrite + transcript rebind onto this session). AUTO start SKIPS this
1659
+ // — the freshly-forked worker already ran its claim-if-vacant boot claim,
1660
+ // and forcing activate here would steal a live owner that autoStart is
1661
+ // meant to yield to. The worker's acquire notification drives remote ON.
1662
+ if (intent !== 'auto') {
1663
+ await channels.execute('activate_channel_bridge', { active: true });
1664
+ }
1665
+ // Claim attempt dispatched; the worker's acquire/supersede notification
1666
+ // now owns the remoteEnabled transition. Drop the transient marker.
1667
+ remoteClaimPending = false;
1668
+ })().catch((error) => { remoteClaimPending = false; bootProfile('channels:claim-failed', { error: error?.message || String(error) }); });
1669
+ return true;
1670
+ }
1671
+
1672
+ function stopRemote(reason) {
1673
+ remoteEnabled = false;
1674
+ // A pending auto-claim is abandoned by an explicit stop/supersede.
1675
+ remoteClaimPending = false;
1676
+ // Cancel any pending deferred start so it can't fire after remote is off.
1677
+ if (prewarmTimers.channelStartTimer) { clearTimeout(prewarmTimers.channelStartTimer); prewarmTimers.channelStartTimer = null; }
1678
+ // Route /remote-off and supersede through a WAITING stop: the runtime keeps
1679
+ // running, so we don't block on it (no await), but the waiting path runs the
1680
+ // full SIGTERM -> taskkill /T /F escalation ladder to guarantee the worker
1681
+ // dies rather than lingering as a zombie holding the bridge seat.
1682
+ channels.stop(reason || 'remote-disabled').catch(() => {});
1683
+ return true;
1684
+ }
1685
+
1686
+ function isRemoteEnabled() {
1687
+ return remoteEnabled;
1688
+ }
1689
+
1690
+ function withTeardownDeadline(promise, ms, fallback = false) {
1691
+ let timer = null;
1692
+ return Promise.race([
1693
+ Promise.resolve(promise),
1694
+ new Promise((resolve) => {
1695
+ timer = setTimeout(() => resolve(fallback), ms);
1696
+ }),
1697
+ ]).finally(() => {
1698
+ if (timer) clearTimeout(timer);
1699
+ });
1700
+ }
1701
+
1702
+ bootProfile('session-runtime:ready', {
1703
+ lazySession: true,
1704
+ prewarmSession: sessionPrewarmEnabled,
1705
+ providerWarmup: providerWarmupEnabled,
1706
+ codeGraphPrewarm: codeGraphPrewarmEnabled,
1707
+ });
1708
+ scheduleLeadSessionPrewarm();
1709
+ // Lazy mode (default): skip the startup prewarm entirely; the first turn
1710
+ // triggers it instead (see ask()). Eager mode keeps the old startup schedule.
1711
+ if (!codeGraphPrewarmLazy) {
1712
+ scheduleCodeGraphPrewarm(codeGraphPrewarmDelayMs, 'startup');
1713
+ } else {
1714
+ bootProfile('code-graph:prewarm-lazy', { reason: 'startup-deferred-to-first-turn' });
1715
+ }
1716
+ scheduleProviderSetupWarmup();
1717
+ scheduleModelCatalogWarmup();
1718
+ scheduleProviderWarmup();
1719
+ // Warm the provider model catalog in the background, but keep it on its own
1720
+ // delay so short-lived detached runtimes can exit before /models I/O starts.
1721
+ // Operators that want earlier catalog warming can lower
1722
+ // MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS explicitly.
1723
+ scheduleProviderModelWarmup();
1724
+ scheduleStatuslineUsageWarmup();
1725
+ // Channels are opt-in: only boot the worker when this session started in (or
1726
+ // was toggled into) remote mode. Non-remote sessions never contend for the
1727
+ // channel; see startRemote()/stopRemote() and the `/remote` toggle.
1728
+ // `remote.autoStart` in mixdog-config.json makes every session claim remote
1729
+ // at boot (same force-takeover semantics as `mixdog --remote` / `/remote`).
1730
+ // The flag lives in the TOP-LEVEL `remote` section of mixdog-config.json
1731
+ // (sibling of agent/ui/channels), not inside the agent section that
1732
+ // cfgMod.loadConfig returns — read it via the shared whole-file reader.
1733
+ // `config?.remote?.autoStart` is kept for back-compat with agent-section
1734
+ // placement.
1735
+ // `remote` (from `mixdog --remote`) is EXPLICIT force-takeover. Config
1736
+ // `remote.autoStart` is AUTO: it must claim the seat ONLY if no live owner
1737
+ // exists (claim-if-vacant) and back off silently otherwise — so it does NOT
1738
+ // set remoteEnabled up front (that would assert remote before the claim is
1739
+ // known to have won). Track it as a separate deferred auto request; the
1740
+ // worker's acquire notification flips remoteEnabled if/when it wins the seat.
1741
+ let remoteAutoStartRequested = false;
1742
+ if (!remoteEnabled) {
1743
+ try {
1744
+ if (config?.remote?.autoStart === true
1745
+ || sharedCfgMod?.readSection?.('remote')?.autoStart === true) {
1746
+ remoteAutoStartRequested = true;
1747
+ }
1748
+ } catch { /* unreadable config never blocks boot */ }
1749
+ }
1750
+ // Boot-time remote start (autoStart or --remote) is DEFERRED past the TUI's
1751
+ // first frame. startRemote() front-loads heavy work — memory daemon fork
1752
+ // (PG + forced ONNX embed warmup in the child), eager session create, and
1753
+ // the channel-worker fork — and running it inline here interleaves that
1754
+ // CPU/disk load with engine boot, visibly delaying the first ink frame by
1755
+ // seconds. The deferred timer reuses prewarmTimers.channelStartTimer so an
1756
+ // early /remote (startRemote clears it), stopRemote(), and close() all
1757
+ // cancel it through the existing clearTimeout paths. Runtime /remote calls
1758
+ // still start immediately (user-initiated, UI already painted).
1759
+ if (remoteEnabled || remoteAutoStartRequested) {
1760
+ const remoteStartIntent = remoteEnabled ? 'explicit' : 'auto';
1761
+ const remoteAutoStartDelayMs = envDelayMs('MIXDOG_REMOTE_AUTOSTART_DELAY_MS', 1_500, { min: 0, max: 60_000 });
1762
+ bootProfile('channels:autostart-deferred', { delayMs: remoteAutoStartDelayMs, intent: remoteStartIntent });
1763
+ prewarmTimers.channelStartTimer = setTimeout(() => {
1764
+ prewarmTimers.channelStartTimer = null;
1765
+ if (closeRequested) return;
1766
+ // Explicit: a /remote-off before the timer clears remoteEnabled — skip.
1767
+ // Auto: always attempt (claim-if-vacant is safe when a live owner exists).
1768
+ if (remoteStartIntent === 'explicit' && !remoteEnabled) return;
1769
+ startRemote({ intent: remoteStartIntent });
1770
+ }, remoteAutoStartDelayMs);
1771
+ prewarmTimers.channelStartTimer.unref?.();
1772
+ }
1773
+
1774
+ // Pure settings-delegate methods (onboarding status/skip, autoClear, profile,
1775
+ // compaction, recap/memory, channels, systemShell, update settings, channel
1776
+ // token save/forget, setBackend). Extracted to session-runtime/settings-api.mjs
1777
+ // and SPREAD into the API object below so the external surface is unchanged.
1778
+ const settingsApi = createSettingsApi({
1779
+ getConfig: () => config,
1780
+ getRoute: () => route,
1781
+ getSession: () => session,
1782
+ getRemoteEnabled: () => remoteEnabled,
1783
+ adoptConfig,
1784
+ saveConfigAndAdopt,
1785
+ scheduleBackendSave,
1786
+ cfgMod,
1787
+ hasOwn,
1788
+ normalizeAutoClearConfig,
1789
+ autoClearIdleMsForProvider,
1790
+ autoClearProviderDefaults,
1791
+ normalizeCompactionConfig,
1792
+ normalizeCompactTypeSetting,
1793
+ normalizeSystemShellConfig,
1794
+ normalizeSystemShellCommand,
1795
+ setConfiguredShell,
1796
+ setRecapEnabledInConfig,
1797
+ setModuleEnabledInConfig,
1798
+ summarizeWorkflowRoutes,
1799
+ parseDurationMs,
1800
+ formatDurationMs,
1801
+ localPackageVersion,
1802
+ memoryEnabled,
1803
+ recapEnabledFn,
1804
+ channelsEnabled,
1805
+ autoUpdateEnabled,
1806
+ getUpdateCheckState: () => updateCheckState,
1807
+ getUpdateProcessState: () => updateProcessState,
1808
+ invalidateContextStatusCache: (...a) => invalidateContextStatusCache(...a),
1809
+ invalidatePreSessionToolSurface: (...a) => invalidatePreSessionToolSurface(...a),
1810
+ scheduleChannelStart: (...a) => scheduleChannelStart(...a),
1811
+ channels,
1812
+ clearChannelStartTimer: () => {
1813
+ if (prewarmTimers.channelStartTimer) {
1814
+ clearTimeout(prewarmTimers.channelStartTimer);
1815
+ prewarmTimers.channelStartTimer = null;
1816
+ }
1817
+ },
1818
+ checkForUpdateInternal: (...a) => checkForUpdateInternal(...a),
1819
+ runUpdateNowInternal: (...a) => runUpdateNowInternal(...a),
1820
+ reloadChannelsSoon: (...a) => reloadChannelsSoon(...a),
1821
+ ONBOARDING_VERSION,
1822
+ saveDiscordToken,
1823
+ forgetDiscordToken,
1824
+ saveTelegramToken,
1825
+ forgetTelegramToken,
1826
+ saveWebhookAuthtoken,
1827
+ forgetWebhookAuthtoken,
1828
+ setBackend,
1829
+ });
1830
+
1831
+ const channelConfigApi = createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon });
1832
+ const providerAuthApi = createProviderAuthApi({
1833
+ cfgMod,
1834
+ getConfig: () => config,
1835
+ saveConfigAndAdopt,
1836
+ displayConfig,
1837
+ reloadFullConfig,
1838
+ invalidateProviderCaches,
1839
+ warmProviderModelCache,
1840
+ cachedProviderSetup,
1841
+ getUsageDashboard,
1842
+ collectProviderModels,
1843
+ });
1844
+ const lifecycleApi = createLifecycleApi({
1845
+ getSession: () => session,
1846
+ setSession: (v) => { session = v; },
1847
+ getRoute: () => route,
1848
+ setRoute: (v) => { route = v; },
1849
+ getConfig: () => config,
1850
+ getMode: () => mode,
1851
+ getCurrentCwd: () => currentCwd,
1852
+ setCloseRequested: (v) => { closeRequested = v; },
1853
+ getMemoryModPromise: () => memoryModPromise,
1854
+ setMemoryModPromise: (v) => { memoryModPromise = v; },
1855
+ setSessionNeedsCwdRefresh: (v) => { sessionNeedsCwdRefresh = v; },
1856
+ hooks,
1857
+ hookCommonPayload,
1858
+ mgr,
1859
+ statusRoutes,
1860
+ channels,
1861
+ agentTool,
1862
+ mcpClient,
1863
+ warmupTimers,
1864
+ prewarmTimers,
1865
+ flushConfigSave,
1866
+ flushBackendSave,
1867
+ flushOutputStyleSave,
1868
+ withTeardownDeadline,
1869
+ closePatchRuntimeIfLoaded,
1870
+ createCurrentSession,
1871
+ refreshRouteEffort,
1872
+ invalidateContextStatusCache,
1873
+ invalidatePreSessionToolSurface,
1874
+ applyResolvedCwd,
1875
+ resolveRoute,
1876
+ applyDeferredToolSurface,
1877
+ standaloneTools,
1878
+ });
1879
+ const resourceApi = createResourceApi({
1880
+ getConfig: () => config,
1881
+ getSession: () => session,
1882
+ getCurrentCwd: () => currentCwd,
1883
+ cfgMod,
1884
+ mgr,
1885
+ hooks,
1886
+ STANDALONE_DATA_DIR,
1887
+ saveConfigAndAdopt,
1888
+ connectConfiguredMcp,
1889
+ invalidatePreSessionToolSurface,
1890
+ recreateCurrentSessionIfReady,
1891
+ normalizeMcpServerInput,
1892
+ mcpStatus,
1893
+ skillsStatus,
1894
+ skillContent,
1895
+ addProjectSkill,
1896
+ pluginsStatus,
1897
+ getMemoryModule,
1898
+ reloadFullConfig,
1899
+ });
1900
+ const modelRouteApi = createModelRouteApi({
1901
+ getConfig: () => config,
1902
+ getRoute: () => route,
1903
+ setRouteState: (v) => { route = v; },
1904
+ getSession: () => session,
1905
+ setSession: (v) => { session = v; },
1906
+ getConfigHasSecrets: () => configHasSecrets,
1907
+ getSearchRouteState: () => searchRoute,
1908
+ setSearchRouteState: (v) => { searchRoute = v; },
1909
+ cfgMod,
1910
+ reg,
1911
+ mgr,
1912
+ statusRoutes,
1913
+ resolveRoute,
1914
+ searchCapableFor,
1915
+ lookupModelMeta,
1916
+ adoptConfig,
1917
+ saveConfigAndAdopt,
1918
+ ensureFullConfig,
1919
+ persistLeadRoute,
1920
+ refreshRouteEffort,
1921
+ refreshStatuslineUsageSnapshot,
1922
+ scheduleStatuslineUsageRefresh,
1923
+ invalidateContextStatusCache,
1924
+ invalidateProviderCaches,
1925
+ collectSearchProviderModels,
1926
+ });
1927
+ const workflowAgentsApi = createWorkflowAgentsApi({
1928
+ getConfig: () => config,
1929
+ getRoute: () => route,
1930
+ setRouteState: (v) => { route = v; },
1931
+ getSession: () => session,
1932
+ setSession: (v) => { session = v; },
1933
+ getConfigHasSecrets: () => configHasSecrets,
1934
+ cfgMod,
1935
+ reg,
1936
+ mgr,
1937
+ STANDALONE_DATA_DIR,
1938
+ resolveRoute,
1939
+ lookupModelMeta,
1940
+ adoptConfig,
1941
+ saveConfigAndAdopt,
1942
+ displayConfig,
1943
+ agentRouteFromConfig,
1944
+ loadAgentDefinition,
1945
+ activeWorkflowId,
1946
+ listWorkflowPacks,
1947
+ loadWorkflowPack,
1948
+ workflowSummary,
1949
+ getOutputStyleStatusCached,
1950
+ seedOutputStyleStatusCache,
1951
+ scheduleOutputStyleSave,
1952
+ recreateCurrentSessionIfReady,
1953
+ notifyFnForSession,
1954
+ invalidateContextStatusCache,
1955
+ });
1956
+ const sessionTurnApi = createSessionTurnApi({
1957
+ getSession: () => session,
1958
+ setSession: (v) => { session = v; },
1959
+ getCurrentCwd: () => currentCwd,
1960
+ getMode: () => mode,
1961
+ setMode: (v) => { mode = v; },
1962
+ getActiveTurnCount: () => activeTurnCount,
1963
+ setActiveTurnCount: (v) => { activeTurnCount = v; },
1964
+ isFirstTurnCompleted: () => firstTurnCompleted,
1965
+ setFirstTurnCompleted: (v) => { firstTurnCompleted = v; },
1966
+ getCodeGraphFirstTurnPrewarmDone: () => codeGraphFirstTurnPrewarmDone,
1967
+ setCodeGraphFirstTurnPrewarmDone: (v) => { codeGraphFirstTurnPrewarmDone = v; },
1968
+ codeGraphPrewarmLazy,
1969
+ getRemoteEnabled: () => remoteEnabled,
1970
+ getCloseRequested: () => closeRequested,
1971
+ getPendingSessionReset: () => pendingSessionReset,
1972
+ setPendingSessionReset: (v) => { pendingSessionReset = v; },
1973
+ getTranscriptWriter: () => _transcriptWriter,
1974
+ getTwKey: () => _twKey,
1975
+ getLastAppendedAssistant: () => _lastAppendedAssistant,
1976
+ setLastAppendedAssistant: (v) => { _lastAppendedAssistant = v; },
1977
+ scheduleCodeGraphPrewarm,
1978
+ refreshSessionForCwdIfNeeded,
1979
+ createCurrentSession,
1980
+ ensureRemoteTranscriptWriter,
1981
+ channelsEnabled,
1982
+ invokeChannelStart,
1983
+ channels,
1984
+ hooks,
1985
+ hookCommonPayload,
1986
+ mgr,
1987
+ notifyFnForSession,
1988
+ bootProfile,
1989
+ scheduleProviderWarmup,
1990
+ scheduleProviderModelWarmup,
1991
+ invalidateContextStatusCache,
1992
+ agentTool,
1993
+ recreateCurrentSessionIfReady,
1994
+ invalidatePreSessionToolSurface,
1995
+ activeToolSurface,
1996
+ applyResolvedCwd,
1997
+ resolveCwdPath,
1998
+ agentStatusState,
1999
+ notificationListeners,
2000
+ });
2001
+
2002
+ return {
2003
+ ...settingsApi,
2004
+ ...channelConfigApi,
2005
+ ...providerAuthApi,
2006
+ get id() {
2007
+ return session?.id || null;
2008
+ },
2009
+ get provider() {
2010
+ return route.provider;
2011
+ },
2012
+ get model() {
2013
+ return route.model;
2014
+ },
2015
+ get effort() {
2016
+ return route.effectiveEffort || route.effort || route.preset?.effort || null;
2017
+ },
2018
+ get fast() {
2019
+ return route.fast === true;
2020
+ },
2021
+ get fastCapable() {
2022
+ return route.fastCapable === true;
2023
+ },
2024
+ get effortOptions() {
2025
+ return route.effortOptions || [];
2026
+ },
2027
+ get contextWindow() {
2028
+ return session?.contextWindow || null;
2029
+ },
2030
+ get rawContextWindow() {
2031
+ return session?.rawContextWindow || session?.contextWindow || null;
2032
+ },
2033
+ get effectiveContextWindowPercent() {
2034
+ return session?.effectiveContextWindowPercent || null;
2035
+ },
2036
+ get toolMode() {
2037
+ return mode;
2038
+ },
2039
+ get autoClear() {
2040
+ return this.getAutoClear();
2041
+ },
2042
+ get systemShell() {
2043
+ return normalizeSystemShellConfig(config.shell);
2044
+ },
2045
+ get searchRoute() {
2046
+ searchRoute = normalizeSearchRouteConfig(config.searchRoute) || normalizeSearchRouteConfig(searchRoute);
2047
+ return searchRoute;
2048
+ },
2049
+ get workflow() {
2050
+ const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
2051
+ const active = activeWorkflowSummary(config, dataDir);
2052
+ if (session?.workflow && typeof session.workflow === 'object') {
2053
+ const current = workflowSummary(session.workflow);
2054
+ return current?.id && active?.id && current.id !== active.id
2055
+ ? { ...active, currentSession: current, appliedToCurrentSession: false }
2056
+ : active;
2057
+ }
2058
+ return active;
2059
+ },
2060
+ get outputStyle() {
2061
+ return getOutputStyleStatusCached().current;
2062
+ },
2063
+ get cwd() {
2064
+ return currentCwd;
2065
+ },
2066
+ get session() {
2067
+ return session;
2068
+ },
2069
+ contextStatus() {
2070
+ // Prefer the in-flight working transcript while a turn is running so the
2071
+ // context gauge reflects LIVE growth (user turn + tool calls/results) as
2072
+ // it accumulates, instead of freezing at the pre-turn committed snapshot.
2073
+ // askSession() sets session.liveTurnMessages for the turn duration and
2074
+ // clears it on commit/cancel/error, after which we fall back to the
2075
+ // authoritative committed transcript.
2076
+ return computeContextStatus();
2077
+ },
2078
+ startRemote() {
2079
+ return startRemote();
2080
+ },
2081
+ stopRemote(reason) {
2082
+ return stopRemote(reason);
2083
+ },
2084
+ isRemoteEnabled() {
2085
+ return isRemoteEnabled();
2086
+ },
2087
+ // Subscribe to non-user-initiated remote flips (seat superseded). Returns
2088
+ // an unsubscribe function. TUI uses this to sync its Remote indicator and
2089
+ // show a "remote taken over" notice.
2090
+ onRemoteStateChange(listener) {
2091
+ if (typeof listener !== 'function') return () => {};
2092
+ remoteStateListeners.add(listener);
2093
+ return () => remoteStateListeners.delete(listener);
2094
+ },
2095
+ get clientHostPid() {
2096
+ return session?.clientHostPid || process.pid;
2097
+ },
2098
+ ...lifecycleApi,
2099
+ ...resourceApi,
2100
+ ...modelRouteApi,
2101
+ ...workflowAgentsApi,
2102
+ ...sessionTurnApi,
2103
+ };
2104
+ }