mixdog 0.9.2 → 0.9.3

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 (154) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/build-tui.mjs +13 -1
  4. package/scripts/explore-bench.mjs +124 -0
  5. package/scripts/hook-bus-test.mjs +191 -0
  6. package/scripts/path-suffix-test.mjs +57 -0
  7. package/scripts/recall-bench.mjs +207 -0
  8. package/scripts/tool-smoke.mjs +7 -4
  9. package/src/agents/debugger/AGENT.md +2 -2
  10. package/src/agents/heavy-worker/AGENT.md +20 -11
  11. package/src/agents/reviewer/AGENT.md +2 -2
  12. package/src/agents/worker/AGENT.md +17 -11
  13. package/src/mixdog-session-runtime.mjs +424 -1812
  14. package/src/repl.mjs +5 -5
  15. package/src/rules/agent/30-explorer.md +8 -11
  16. package/src/rules/lead/lead-tool.md +9 -5
  17. package/src/rules/shared/01-tool.md +11 -5
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -68
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +46 -7
  24. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  28. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  29. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  30. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +7 -5
  31. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +33 -23
  32. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -14
  33. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  34. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  35. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  36. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  37. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  39. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  40. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  41. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  42. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  43. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  44. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  45. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  46. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  47. package/src/runtime/agent/orchestrator/session/loop.mjs +169 -918
  48. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  50. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  51. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +65 -1032
  54. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  64. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  69. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  75. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  76. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  77. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  79. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  80. package/src/runtime/agent/orchestrator/tools/patch.mjs +3 -3
  81. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  82. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  84. package/src/runtime/channels/index.mjs +14 -233
  85. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  86. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  87. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  88. package/src/runtime/channels/lib/output-forwarder.mjs +8 -0
  89. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  90. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  91. package/src/runtime/memory/index.mjs +314 -359
  92. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  93. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  94. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  95. package/src/runtime/memory/lib/memory-cycle2.mjs +56 -3
  96. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  97. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  98. package/src/runtime/memory/lib/memory.mjs +20 -0
  99. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  100. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  101. package/src/runtime/memory/tool-defs.mjs +4 -4
  102. package/src/runtime/shared/abort-controller.mjs +1 -1
  103. package/src/runtime/shared/background-tasks.mjs +2 -3
  104. package/src/runtime/shared/buffered-appender.mjs +149 -0
  105. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  106. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  107. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  108. package/src/runtime/shared/transcript-writer.mjs +29 -2
  109. package/src/session-runtime/config-helpers.mjs +209 -0
  110. package/src/session-runtime/effort.mjs +128 -0
  111. package/src/session-runtime/fs-utils.mjs +10 -0
  112. package/src/session-runtime/model-capabilities.mjs +130 -0
  113. package/src/session-runtime/output-styles.mjs +124 -0
  114. package/src/session-runtime/plugin-mcp.mjs +114 -0
  115. package/src/session-runtime/session-text.mjs +100 -0
  116. package/src/session-runtime/statusline-route.mjs +35 -0
  117. package/src/session-runtime/tool-catalog.mjs +720 -0
  118. package/src/session-runtime/workflow.mjs +358 -0
  119. package/src/standalone/agent-tool.mjs +49 -10
  120. package/src/standalone/channel-worker.mjs +3 -2
  121. package/src/standalone/explore-tool.mjs +10 -3
  122. package/src/standalone/hook-bus.mjs +165 -8
  123. package/src/standalone/opencode-go-login.mjs +121 -0
  124. package/src/standalone/provider-admin.mjs +14 -3
  125. package/src/tui/App.jsx +884 -143
  126. package/src/tui/components/PromptInput.jsx +272 -15
  127. package/src/tui/components/ToolExecution.jsx +20 -10
  128. package/src/tui/components/tool-output-format.mjs +2 -2
  129. package/src/tui/dist/index.mjs +1771 -1947
  130. package/src/tui/engine/agent-envelope.mjs +296 -0
  131. package/src/tui/engine/boot-profile.mjs +21 -0
  132. package/src/tui/engine/labels.mjs +67 -0
  133. package/src/tui/engine/notice-text.mjs +112 -0
  134. package/src/tui/engine/queue-helpers.mjs +161 -0
  135. package/src/tui/engine/session-stats.mjs +46 -0
  136. package/src/tui/engine/tool-call-fields.mjs +23 -0
  137. package/src/tui/engine/tool-result-text.mjs +126 -0
  138. package/src/tui/engine.mjs +311 -851
  139. package/src/tui/input-editing.mjs +58 -8
  140. package/src/tui/input-editing.selection.test.mjs +75 -0
  141. package/src/tui/keyboard-protocol.mjs +2 -2
  142. package/src/tui/lib/voice-recorder.mjs +35 -19
  143. package/src/tui/markdown/format-token.mjs +7 -8
  144. package/src/tui/markdown/format-token.test.mjs +3 -3
  145. package/src/tui/paste-attachments.mjs +38 -0
  146. package/src/tui/paste-fix.test.mjs +119 -0
  147. package/src/tui/themes/base.mjs +2 -2
  148. package/src/tui/themes/kanagawa.mjs +4 -4
  149. package/src/tui/themes/teal.mjs +4 -5
  150. package/src/tui/themes/utils.mjs +1 -1
  151. package/src/ui/statusline.mjs +49 -0
  152. package/src/workflows/default/WORKFLOW.md +16 -9
  153. package/src/workflows/sequential/WORKFLOW.md +16 -11
  154. package/src/workflows/solo/WORKFLOW.md +5 -1
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { basename, dirname, join, resolve } from 'node:path';
3
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { performance } from 'node:perf_hooks';
6
6
  import { ensureStandaloneEnvironment } from './standalone/seeds.mjs';
@@ -38,6 +38,7 @@ import {
38
38
  renderProviderStatus,
39
39
  saveOpenAIUsageSessionKey,
40
40
  saveOpenCodeGoUsageAuth,
41
+ loginOpenCodeGoUsage,
41
42
  saveProviderApiKey,
42
43
  setLocalProvider,
43
44
  } from './standalone/provider-admin.mjs';
@@ -88,50 +89,125 @@ import {
88
89
  summarizeContextMessages,
89
90
  } from './runtime/agent/orchestrator/session/context-utils.mjs';
90
91
 
91
- function sessionMessageText(content) {
92
- if (content == null) return '';
93
- if (typeof content === 'string') return content;
94
- const parts = Array.isArray(content)
95
- ? content
96
- : (content && typeof content === 'object' && Array.isArray(content.content) ? content.content : null);
97
- if (parts) {
98
- return parts.map((part) => {
99
- if (typeof part === 'string') return part;
100
- return part?.text ?? '';
101
- }).filter(Boolean).join('\n');
102
- }
103
- if (typeof content === 'object' && typeof content.text === 'string') return content.text;
104
- try { return JSON.stringify(content); } catch { return String(content); }
105
- }
106
-
107
- function messageContextText(message) {
108
- if (!message || typeof message !== 'object') return '';
109
- let text = sessionMessageText(message.content);
110
- if (message.role === 'assistant' && Array.isArray(message.toolCalls) && message.toolCalls.length) {
111
- try { text += `\n${JSON.stringify(message.toolCalls)}`; }
112
- catch { text += `\n[${message.toolCalls.length} tool calls]`; }
113
- }
114
- if (message.role === 'tool' && message.toolCallId) text += `\n${message.toolCallId}`;
115
- return text;
116
- }
117
-
118
- function isSessionPreviewNoise(text) {
119
- const value = String(text || '').trim();
120
- return !value
121
- || value.startsWith('<system-reminder>')
122
- || value.startsWith('</system-reminder>')
123
- || /^#\s*permission\b/i.test(value)
124
- || /^permission:\s*/i.test(value)
125
- || /^cwd:\s*/i.test(value);
126
- }
127
-
128
- function cleanSessionPreview(text) {
129
- return String(text || '')
130
- .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
131
- .replace(/\s+/g, ' ')
132
- .trim()
133
- .slice(0, 160);
134
- }
92
+ import {
93
+ sessionMessageText,
94
+ messageContextText,
95
+ isSessionPreviewNoise,
96
+ cleanSessionPreview,
97
+ clean,
98
+ hasOwn,
99
+ toolResponseText,
100
+ isEmptyRecallText,
101
+ currentSessionRecallRows,
102
+ sessionHasConversationMessages,
103
+ } from './session-runtime/session-text.mjs';
104
+ import {
105
+ TOOL_MODES,
106
+ ALL_EFFORT_LEVELS,
107
+ EFFORT_LABELS,
108
+ EFFORT_OPTIONS_BY_PROVIDER,
109
+ EFFORT_BY_FAMILY,
110
+ EFFORT_FALLBACKS,
111
+ normalizeToolMode,
112
+ normalizeEffortInput,
113
+ effortOptionsFor,
114
+ coerceEffortFor,
115
+ normalizeSavedEffort,
116
+ effortItemsFor,
117
+ toolSpecForMode,
118
+ deferredSurfaceModeForLead,
119
+ } from './session-runtime/effort.mjs';
120
+ import {
121
+ LAZY_SECRET_PROVIDERS,
122
+ routeFastKey,
123
+ fastCapableFor,
124
+ makeSearchCapableFor,
125
+ fastPreferenceFor,
126
+ saveModelSettings,
127
+ } from './session-runtime/model-capabilities.mjs';
128
+ import {
129
+ DEFAULT_PROVIDER,
130
+ DEFAULT_MODEL,
131
+ makeResolveDefaultProvider,
132
+ findPreset,
133
+ makeResolveRoute,
134
+ isLikelyRawModelId,
135
+ validateRequestedModelSelector,
136
+ ensureProviderEnabled,
137
+ normalizeSystemShellConfig,
138
+ normalizeSystemShellCommand,
139
+ normalizeAutoClearConfig,
140
+ normalizeCompactionConfig,
141
+ moduleEnabled,
142
+ setModuleEnabledInConfig,
143
+ formatDurationMs,
144
+ parseDurationMs,
145
+ modelMetaLooksResolved,
146
+ modelSettingsFor,
147
+ normalizeCompactTypeSetting,
148
+ } from './session-runtime/config-helpers.mjs';
149
+ import {
150
+ routeForStatusline,
151
+ writeStatuslineRoute,
152
+ } from './session-runtime/statusline-route.mjs';
153
+ import {
154
+ normalizeOutputStyleId,
155
+ listOutputStyleCatalog,
156
+ findOutputStyle,
157
+ outputStyleStatus as outputStyleStatusRaw,
158
+ } from './session-runtime/output-styles.mjs';
159
+ import { readJsonSafe, readTextSafe } from './session-runtime/fs-utils.mjs';
160
+ import {
161
+ readProjectMcpServers,
162
+ countSkillFiles,
163
+ mcpScriptForPlugin,
164
+ normalizePluginMcpServerConfig,
165
+ pluginManifest,
166
+ pluginMcpServerName,
167
+ } from './session-runtime/plugin-mcp.mjs';
168
+ import {
169
+ WORKFLOW_ROUTE_SLOTS,
170
+ FIXED_AGENT_SLOTS,
171
+ SEARCH_DEFAULT_PROVIDER,
172
+ SEARCH_DEFAULT_MODEL,
173
+ workflowPresetId,
174
+ agentPresetSlot,
175
+ normalizeAgentId,
176
+ normalizeWorkflowId,
177
+ createWorkflowHelpers,
178
+ normalizeSearchProviderId,
179
+ isDefaultSearchRouteConfig,
180
+ isSearchCapableProvider,
181
+ normalizeSearchRouteConfig,
182
+ normalizeWorkflowRoute,
183
+ upsertWorkflowPreset,
184
+ createWorkflowRouteHelpers,
185
+ } from './session-runtime/workflow.mjs';
186
+ import {
187
+ MEASURED_TOOL_USAGE,
188
+ DEFERRED_DEFAULT_FULL_TOOLS,
189
+ DEFERRED_DEFAULT_READONLY_TOOLS,
190
+ DEFERRED_DEFAULT_LEAD_TOOLS,
191
+ toolKind,
192
+ toolSchemaBucket,
193
+ estimateToolSchemaBreakdown,
194
+ measuredToolUsage,
195
+ parseToolSelection,
196
+ parseToolSearchQuerySelection,
197
+ sortedCatalogByMeasuredUsage,
198
+ filterDisallowedTools,
199
+ sortedNamesByMeasuredUsage,
200
+ defaultDeferredToolNames,
201
+ compactToolSearchDescription,
202
+ toolRow,
203
+ toolSearchMatches,
204
+ applyDeferredToolSurface,
205
+ selectDeferredTools,
206
+ renderToolSearch,
207
+ } from './session-runtime/tool-catalog.mjs';
208
+ // Re-exported for external consumers (scripts/tool-smoke.mjs) that imported
209
+ // these from this module before the tool-catalog extraction.
210
+ export { defaultDeferredToolNames, compactToolSearchDescription } from './session-runtime/tool-catalog.mjs';
135
211
 
136
212
  const RUNTIME = './runtime/agent/orchestrator';
137
213
  const SEARCH_RUNTIME = './runtime/search/index.mjs';
@@ -151,26 +227,9 @@ const STANDALONE_ROOT = STANDALONE_SOURCE_ROOT;
151
227
  const MIXDOG_HOME = process.env.MIXDOG_HOME || join(homedir(), '.mixdog');
152
228
  const STANDALONE_DATA_DIR = process.env.MIXDOG_DATA_DIR || join(MIXDOG_HOME, 'data');
153
229
 
154
- const DEFAULT_PROVIDER = 'anthropic-oauth';
155
- const DEFAULT_MODEL = '';
156
-
157
- // Resolve the provider to use when a route carries no explicit provider.
158
- // Priority: config.defaultProvider (when it names a known provider) > DEFAULT_PROVIDER.
159
- function resolveDefaultProvider(config) {
160
- const configured = clean(config?.defaultProvider);
161
- if (configured && isKnownProvider(configured)) return configured;
162
- return DEFAULT_PROVIDER;
163
- }
164
- const TOOL_MODES = new Set(['full', 'readonly', 'lead']);
165
- const ALL_EFFORT_LEVELS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max']);
166
- const EFFORT_LABELS = {
167
- none: 'None',
168
- low: 'Low',
169
- medium: 'Medium',
170
- high: 'High',
171
- xhigh: 'Extra High',
172
- max: 'Max',
173
- };
230
+ const resolveDefaultProvider = makeResolveDefaultProvider(isKnownProvider);
231
+ const resolveRoute = makeResolveRoute(resolveDefaultProvider);
232
+ const searchCapableFor = makeSearchCapableFor(normalizeSearchProviderId, isSearchCapableProvider);
174
233
 
175
234
  function envFlag(name) {
176
235
  return /^(1|true|yes|on)$/i.test(String(process.env[name] || ''));
@@ -217,37 +276,6 @@ async function profiledImport(label, spec, { optional = false } = {}) {
217
276
  throw error;
218
277
  }
219
278
  }
220
- const EFFORT_OPTIONS_BY_PROVIDER = {
221
- openai: ['none', 'low', 'medium', 'high', 'xhigh'],
222
- 'openai-oauth': ['none', 'low', 'medium', 'high', 'xhigh'],
223
- anthropic: ['low', 'medium', 'high', 'xhigh', 'max'],
224
- 'anthropic-oauth': ['low', 'medium', 'high', 'xhigh', 'max'],
225
- xai: ['none', 'low', 'medium', 'high'],
226
- 'grok-oauth': ['none', 'low', 'medium', 'high'],
227
- 'opencode-go': ['high', 'max'],
228
- };
229
- const EFFORT_BY_FAMILY = {
230
- opus: ['low', 'medium', 'high', 'xhigh', 'max'],
231
- sonnet: ['low', 'medium', 'high'],
232
- haiku: [],
233
- 'gpt-5.5': ['none', 'low', 'medium', 'high', 'xhigh'],
234
- 'gpt-5.4': ['none', 'low', 'medium', 'high', 'xhigh'],
235
- 'gpt-5.2': ['none', 'low', 'medium', 'high', 'xhigh'],
236
- 'gpt-5': ['none', 'low', 'medium', 'high', 'xhigh'],
237
- 'gpt-mini': ['none', 'low', 'medium', 'high', 'xhigh'],
238
- 'gpt-nano': ['none', 'low', 'medium', 'high'],
239
- 'gpt-codex': ['none', 'low', 'medium', 'high'],
240
- grok: ['none', 'low', 'medium', 'high'],
241
- };
242
- const EFFORT_FALLBACKS = {
243
- max: ['max', 'xhigh', 'high', 'medium', 'low'],
244
- xhigh: ['xhigh', 'high', 'medium', 'low'],
245
- high: ['high', 'medium', 'low'],
246
- medium: ['medium', 'low'],
247
- low: ['low'],
248
- none: ['none'],
249
- };
250
-
251
279
  export const TOOL_SEARCH_TOOL = {
252
280
  name: 'tool_search',
253
281
  title: 'Tool Search',
@@ -330,86 +358,7 @@ export const SKILL_TOOL = {
330
358
  },
331
359
  };
332
360
 
333
- const MEASURED_TOOL_USAGE = Object.freeze({
334
- read: 710,
335
- code_graph: 520,
336
- grep: 500,
337
- find: 480,
338
- glob: 460,
339
- list: 430,
340
- apply_patch: 400,
341
- explore: 360,
342
- agent: 330,
343
- shell: 81,
344
- cwd: 2,
345
- diagnostics: 2,
346
- recall: 2,
347
- search: 2,
348
- web_fetch: 2,
349
- provider_status: 2,
350
- channel_status: 2,
351
- });
352
- const MEASURED_TOOL_ORDER = Object.freeze(Object.keys(MEASURED_TOOL_USAGE));
353
361
  const LEAD_DISALLOWED_TOOLS = Object.freeze(['diagnostics', 'open_config']);
354
- const DEFERRED_DEFAULT_FULL_TOOLS = Object.freeze([
355
- 'read',
356
- 'code_graph',
357
- 'grep',
358
- 'find',
359
- 'glob',
360
- 'list',
361
- 'explore',
362
- 'apply_patch',
363
- 'Skill',
364
- 'tool_search',
365
- ]);
366
- const DEFERRED_DEFAULT_READONLY_TOOLS = Object.freeze([
367
- 'read',
368
- 'code_graph',
369
- 'grep',
370
- 'find',
371
- 'glob',
372
- 'list',
373
- 'explore',
374
- 'Skill',
375
- 'tool_search',
376
- ]);
377
- const DEFERRED_DEFAULT_LEAD_TOOLS = Object.freeze([
378
- 'read',
379
- 'code_graph',
380
- 'grep',
381
- 'find',
382
- 'glob',
383
- 'list',
384
- 'shell',
385
- 'task',
386
- 'explore',
387
- 'apply_patch',
388
- 'agent',
389
- 'recall',
390
- 'search',
391
- 'web_fetch',
392
- 'cwd',
393
- 'Skill',
394
- 'tool_search',
395
- ]);
396
- const READONLY_TOOL_NAMES = new Set([
397
- 'read',
398
- 'list',
399
- 'grep',
400
- 'find',
401
- 'glob',
402
- 'code_graph',
403
- 'search',
404
- 'web_fetch',
405
- 'recall',
406
- 'memory',
407
- 'provider_status',
408
- 'channel_status',
409
- 'schedule_status',
410
- 'fetch',
411
- 'Skill',
412
- ]);
413
362
  const AGENT_HIDDEN_WRAPPER_TOOLS = new Set([]);
414
363
 
415
364
  export function __applyStandaloneToolDefaultsForTest(tool) {
@@ -423,761 +372,9 @@ export function __applyStandaloneToolDefaultsForTest(tool) {
423
372
  };
424
373
  }
425
374
  const applyStandaloneToolDefaults = __applyStandaloneToolDefaultsForTest;
426
- const DEFERRED_SELECT_ALIASES = {
427
- filesystem: ['read', 'list', 'grep', 'find', 'glob'],
428
- search: ['search', 'web_fetch'],
429
- web: ['web_fetch', 'search'],
430
- memory: ['memory', 'recall'],
431
- channels: ['reply', 'fetch', 'react', 'edit_message', 'download_attachment', 'schedule_status', 'trigger_schedule', 'schedule_control', 'reload_config'],
432
- discord: ['reply', 'fetch', 'react', 'edit_message', 'download_attachment'],
433
- providers: ['provider_status'],
434
- provider: ['provider_status'],
435
- status: ['provider_status', 'channel_status', 'schedule_status'],
436
- schedule: ['schedule_status', 'trigger_schedule', 'schedule_control'],
437
- channel: ['channel_status'],
438
- explore: ['explore'],
439
- discovery: ['explore'],
440
- agent: ['agent'],
441
- graph: ['code_graph'],
442
- code: ['code_graph'],
443
- shell: ['shell', 'task'],
444
- };
445
- const TOOL_SEARCH_SAFE_AUTO_ALIASES = new Set([
446
- 'shell',
447
- 'web',
448
- 'search',
449
- 'agent',
450
- 'provider',
451
- 'providers',
452
- 'channel',
453
- 'schedule',
454
- 'memory',
455
- ]);
456
- const TOOL_SEARCH_AMBIGUOUS_AUTO_QUERIES = new Set([
457
- 'status',
458
- 'state',
459
- 'info',
460
- 'list',
461
- 'show',
462
- 'config',
463
- ]);
464
- const TOOL_SEARCH_STOP_WORDS = new Set([
465
- 'a',
466
- 'an',
467
- 'and',
468
- 'are',
469
- 'as',
470
- 'for',
471
- 'from',
472
- 'how',
473
- 'i',
474
- 'in',
475
- 'is',
476
- 'me',
477
- 'need',
478
- 'of',
479
- 'on',
480
- 'please',
481
- 'the',
482
- 'to',
483
- 'tool',
484
- 'tools',
485
- 'use',
486
- 'using',
487
- 'with',
488
- ]);
489
- const TOOL_SEARCH_ROW_ALIASES = Object.freeze({
490
- agent: ['delegate', 'subagent', 'worker', 'parallel agent', 'background agent', 'reviewer', 'explorer'],
491
- channel_status: ['channel status', 'discord status', 'channel config'],
492
- cwd: ['cwd', 'working directory', 'current directory', 'project root', 'folder'],
493
- memory: ['save memory', 'store memory', 'delete memory', 'forget memory', 'memory status'],
494
- provider_status: ['provider status', 'auth status', 'model status', 'oauth status', 'provider config'],
495
- recall: ['recall', 'previous work', 'past work', 'prior context', 'history', 'resume context'],
496
- schedule_status: ['schedule status', 'cron status'],
497
- search: ['web search', 'internet search', 'current info', 'latest info', 'online search', 'docs search'],
498
- shell: ['run command', 'execute command', 'terminal', 'powershell', 'bash', 'run tests', 'test command', 'build command', 'npm', 'node', 'git'],
499
- task: ['background task', 'async task', 'wait task', 'cancel task', 'task status'],
500
- web_fetch: ['fetch url', 'fetch page', 'open url', 'web page', 'read url', 'docs page'],
501
- });
502
-
503
- function normalizeToolMode(mode) {
504
- const value = String(mode || '').trim().toLowerCase();
505
- return TOOL_MODES.has(value) ? value : 'full';
506
- }
507
-
508
- function normalizeEffortInput(value) {
509
- const v = clean(value).toLowerCase();
510
- if (!v || v === 'auto') return null;
511
- if (!ALL_EFFORT_LEVELS.has(v)) {
512
- throw new Error(`effort must be one of auto, ${[...ALL_EFFORT_LEVELS].join(', ')}`);
513
- }
514
- return v;
515
- }
516
-
517
- function effortOptionsFor(provider, model) {
518
- const providerAllowed = EFFORT_OPTIONS_BY_PROVIDER[provider] || null;
519
- const filterProvider = (values) => {
520
- const unique = [...new Set((values || []).map(clean).filter(Boolean))];
521
- return providerAllowed ? unique.filter((v) => providerAllowed.includes(v)) : unique;
522
- };
523
- const declared = Array.isArray(model?.reasoningLevels)
524
- ? model.reasoningLevels.map(clean).filter(Boolean)
525
- : [];
526
- const family = clean(model?.family).toLowerCase();
527
- if (Array.isArray(model?.reasoningLevels)) {
528
- if (declared.length) return filterProvider(declared);
529
- if (Object.prototype.hasOwnProperty.call(EFFORT_BY_FAMILY, family)) {
530
- return filterProvider(EFFORT_BY_FAMILY[family]);
531
- }
532
- return [];
533
- }
534
- const reasoningOptionEffort = Array.isArray(model?.reasoningOptions)
535
- ? model.reasoningOptions.find((option) => clean(option?.type).toLowerCase() === 'effort')
536
- : null;
537
- const reasoningOptionValues = Array.isArray(reasoningOptionEffort?.values)
538
- ? reasoningOptionEffort.values.map(clean).filter(Boolean)
539
- : [];
540
- if (reasoningOptionValues.length) return filterProvider(reasoningOptionValues);
541
- if (Object.prototype.hasOwnProperty.call(EFFORT_BY_FAMILY, family)) {
542
- return filterProvider(EFFORT_BY_FAMILY[family]);
543
- }
544
- return providerAllowed || [];
545
- }
546
-
547
- function coerceEffortFor(provider, model, effort) {
548
- if (!effort) return null;
549
- const allowed = effortOptionsFor(provider, model);
550
- if (!allowed || allowed.length === 0) return null;
551
- if (allowed.includes(effort)) return effort;
552
- for (const candidate of EFFORT_FALLBACKS[effort] || []) {
553
- if (allowed.includes(candidate)) return candidate;
554
- }
555
- return null;
556
- }
557
-
558
- function hasOwn(obj, key) {
559
- return Object.prototype.hasOwnProperty.call(obj || {}, key);
560
- }
561
-
562
- function modelSettingsFor(config, provider, model) {
563
- const key = routeFastKey(provider, model);
564
- const value = key ? config?.modelSettings?.[key] : null;
565
- return value && typeof value === 'object' ? value : {};
566
- }
567
-
568
- function normalizeSavedEffort(value) {
569
- try {
570
- return normalizeEffortInput(value);
571
- } catch {
572
- return null;
573
- }
574
- }
575
-
576
- function effortItemsFor(provider, model, activeEffort) {
577
- const allowed = effortOptionsFor(provider, model);
578
- const items = [];
579
- for (const value of allowed || []) {
580
- items.push({
581
- value,
582
- label: EFFORT_LABELS[value] || value,
583
- description: value === activeEffort ? 'current' : '',
584
- });
585
- }
586
- return items;
587
- }
588
-
589
- function toolSpecForMode(mode) {
590
- return mode === 'readonly' ? ['tools:readonly'] : 'full';
591
- }
592
-
593
- function deferredSurfaceModeForLead(mode) {
594
- return mode === 'readonly' ? 'readonly' : 'lead';
595
- }
596
-
597
- function clean(value) {
598
- return String(value ?? '').trim();
599
- }
600
-
601
- // A resolved model meta carries catalog-derived fields (contextWindow, pricing,
602
- // capabilities, …). The lookupModelMeta() fallback for an unknown id is the
603
- // bare shape `{ id, provider }`, so "more than id/provider" reliably tells a
604
- // real catalog hit apart from that placeholder.
605
- function modelMetaLooksResolved(meta) {
606
- if (!meta || typeof meta !== 'object') return false;
607
- return Object.keys(meta).some((key) => key !== 'id' && key !== 'provider');
608
- }
609
-
610
- const OUTPUT_STYLE_ORDER = ['default', 'simple', 'minimal', 'oneline'];
611
- const OUTPUT_STYLE_ALIASES = new Map([
612
- ['compact', 'default'],
613
- ['normal', 'default'],
614
- ['extreme', 'minimal'],
615
- ['extremesimple', 'minimal'],
616
- ['extreme-simple', 'minimal'],
617
- ['extreme_simple', 'minimal'],
618
- ['mono', 'oneline'],
619
- ['oneline', 'oneline'],
620
- ['one-line', 'oneline'],
621
- ['one_line', 'oneline'],
622
- ]);
623
-
624
- function normalizeOutputStyleId(value) {
625
- const raw = clean(value).toLowerCase();
626
- if (!raw) return '';
627
- const slug = raw.replace(/[_\s]+/g, '-').replace(/^-+|-+$/g, '');
628
- const compact = slug.replace(/[_.-]+/g, '');
629
- if (OUTPUT_STYLE_ALIASES.has(slug)) return OUTPUT_STYLE_ALIASES.get(slug);
630
- if (OUTPUT_STYLE_ALIASES.has(compact)) return OUTPUT_STYLE_ALIASES.get(compact);
631
- return /^[a-z0-9.-]+$/.test(slug) ? slug : '';
632
- }
633
-
634
- function outputStyleCompactKey(value) {
635
- return normalizeOutputStyleId(value).replace(/[_.-]+/g, '');
636
- }
637
-
638
- function titleCaseOutputStyle(id) {
639
- return clean(id)
640
- .split(/[_.-]+/)
641
- .filter(Boolean)
642
- .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
643
- .join(' ') || 'Default';
644
- }
645
-
646
- function parseOutputStyleFrontmatter(markdown) {
647
- const match = String(markdown || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
648
- const meta = {};
649
- if (!match) return meta;
650
- for (const line of match[1].split(/\r?\n/)) {
651
- const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
652
- if (!kv) continue;
653
- meta[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '').trim();
654
- }
655
- return meta;
656
- }
657
-
658
- function readOutputStyleMetadata(filePath, source) {
659
- let raw = '';
660
- try { raw = readFileSync(filePath, 'utf8'); } catch { return null; }
661
- const meta = parseOutputStyleFrontmatter(raw);
662
- const fileId = normalizeOutputStyleId(basename(filePath).replace(/\.md$/i, ''));
663
- const id = normalizeOutputStyleId(meta.name) || fileId;
664
- if (!id) return null;
665
- const aliases = clean(meta.aliases)
666
- .split(',')
667
- .map((value) => normalizeOutputStyleId(value))
668
- .filter(Boolean);
669
- const label = clean(meta.title || meta.label) || titleCaseOutputStyle(id);
670
- return {
671
- id,
672
- label,
673
- description: clean(meta.description),
674
- aliases,
675
- source,
676
- };
677
- }
678
-
679
- function listOutputStyleCatalog(dataDir = STANDALONE_DATA_DIR) {
680
- const byId = new Map();
681
- const dirs = [
682
- { dir: join(STANDALONE_ROOT, 'output-styles'), source: 'builtin' },
683
- { dir: join(dataDir || STANDALONE_DATA_DIR, 'output-styles'), source: 'user' },
684
- ];
685
- for (const { dir, source } of dirs) {
686
- let entries = [];
687
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
688
- for (const entry of entries) {
689
- if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue;
690
- const style = readOutputStyleMetadata(join(dir, entry.name), source);
691
- if (style) byId.set(style.id, style);
692
- }
693
- }
694
- return [...byId.values()].sort((a, b) => {
695
- const ai = OUTPUT_STYLE_ORDER.indexOf(a.id);
696
- const bi = OUTPUT_STYLE_ORDER.indexOf(b.id);
697
- if (ai !== bi) return (ai < 0 ? 999 : ai) - (bi < 0 ? 999 : bi);
698
- return a.label.localeCompare(b.label, 'en', { sensitivity: 'base' });
699
- });
700
- }
701
-
702
- function findOutputStyle(value, styles) {
703
- const id = normalizeOutputStyleId(value);
704
- const compact = outputStyleCompactKey(value);
705
- if (!id && !compact) return null;
706
- return (styles || []).find((style) => {
707
- if (style.id === id || outputStyleCompactKey(style.id) === compact) return true;
708
- if (outputStyleCompactKey(style.label) === compact) return true;
709
- return (style.aliases || []).some((alias) => alias === id || outputStyleCompactKey(alias) === compact);
710
- }) || null;
711
- }
712
-
713
- function configuredOutputStyleValue(dataDir = STANDALONE_DATA_DIR) {
714
- const unified = readJsonSafe(join(dataDir || STANDALONE_DATA_DIR, 'mixdog-config.json')) || {};
715
- return clean(unified.outputStyle || (unified.agent && unified.agent.outputStyle) || 'default') || 'default';
716
- }
717
-
718
- function outputStyleStatus(dataDir = STANDALONE_DATA_DIR) {
719
- const styles = listOutputStyleCatalog(dataDir);
720
- const configured = configuredOutputStyleValue(dataDir);
721
- const current = findOutputStyle(configured, styles)
722
- || findOutputStyle('default', styles)
723
- || styles[0]
724
- || { id: 'default', label: 'Default', description: '', aliases: [], source: 'builtin' };
725
- return { configured, current, styles };
726
- }
727
-
728
- function sessionHasConversationMessages(activeSession) {
729
- const messages = Array.isArray(activeSession?.messages) ? activeSession.messages : [];
730
- return messages.some((message) => {
731
- const role = message?.role;
732
- if (role !== 'user' && role !== 'assistant' && role !== 'tool') return false;
733
- const text = sessionMessageText(message.content).trim();
734
- if (!text && role !== 'assistant') return false;
735
- if (role === 'user' && isSessionPreviewNoise(text)) return false;
736
- return true;
737
- });
738
- }
739
-
740
- function readJsonSafe(path) {
741
- try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
742
- }
743
-
744
- // Standard Claude Code project-local MCP ingress: read `.mcp.json` from the
745
- // project root and return a cleaned { name: cfg } map. Best-effort — never
746
- // throws. Accepts either the standard `{ mcpServers: {...} }` shape or a bare
747
- // name->cfg map. Self-ref servers (`mixdog` / `trib-plugin`) are stripped for
748
- // parity with loadConfig (which strips them from the persisted agent section
749
- // but never sees `.mcp.json`). Inputs are not mutated.
750
- function readProjectMcpServers(cwd) {
751
- const path = join(cwd || '.', '.mcp.json');
752
- if (!existsSync(path)) return {};
753
- let raw;
754
- try {
755
- raw = JSON.parse(readFileSync(path, 'utf8'));
756
- } catch (error) {
757
- process.stderr.write(`[mcp-client] Ignoring unparseable .mcp.json at ${path}: ${error?.message || String(error)}\n`);
758
- return {};
759
- }
760
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
761
- const map = raw.mcpServers && typeof raw.mcpServers === 'object' && !Array.isArray(raw.mcpServers)
762
- ? raw.mcpServers
763
- : raw;
764
- if (!map || typeof map !== 'object' || Array.isArray(map)) return {};
765
- const out = {};
766
- for (const [name, cfg] of Object.entries(map)) {
767
- const key = clean(name);
768
- if (!key) continue;
769
- const lower = key.toLowerCase();
770
- if (lower === 'mixdog' || lower === 'trib-plugin') continue;
771
- if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) continue;
772
- // stdio entries (command + no url) spawn relative to the process launch
773
- // dir, but mixdog tracks the project dir in memory (no process.chdir).
774
- // Anchor their cwd to the .mcp.json directory: default when absent, resolve
775
- // relative values against it, keep absolute values as-is. resolve(base, p)
776
- // already returns `p` unchanged when it is absolute. Clone — never mutate.
777
- const isStdio = typeof cfg.command === 'string' && cfg.command !== '' && !cfg.url;
778
- if (isStdio) {
779
- out[key] = { ...cfg, cwd: typeof cfg.cwd === 'string' && cfg.cwd ? resolve(cwd, cfg.cwd) : resolve(cwd) };
780
- } else {
781
- out[key] = cfg;
782
- }
783
- }
784
- return out;
785
- }
786
-
787
- function countSkillFiles(root) {
788
- const skillsDir = join(root, 'skills');
789
- if (!existsSync(skillsDir)) return 0;
790
- let count = 0;
791
- const walk = (dir) => {
792
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
793
- const full = join(dir, entry.name);
794
- if (entry.isDirectory()) walk(full);
795
- else if (/^(SKILL|skill)\.md$/i.test(entry.name) || entry.name.toLowerCase().endsWith('.md')) count += 1;
796
- }
797
- };
798
- try { walk(skillsDir); } catch { return count; }
799
- return count;
800
- }
801
-
802
- function mcpScriptForPlugin(root) {
803
- const candidates = [
804
- 'scripts/run-mcp.mjs',
805
- 'mcp/server.mjs',
806
- 'server.mjs',
807
- ];
808
- return candidates.find((rel) => existsSync(join(root, rel))) || null;
809
- }
810
-
811
- function pluginManifest(root) {
812
- return readJsonSafe(join(root, '.codex-plugin', 'plugin.json'))
813
- || readJsonSafe(join(root, 'plugin.json'))
814
- || {};
815
- }
816
-
817
- function pluginMcpServerName(plugin = {}) {
818
- const base = clean(plugin.name || plugin.title || 'plugin')
819
- .toLowerCase()
820
- .replace(/[^a-z0-9_.-]+/g, '-')
821
- .replace(/^-+|-+$/g, '');
822
- return base ? `plugin-${base}` : 'plugin-mcp';
823
- }
824
-
825
- function findPreset(config, key) {
826
- const wanted = clean(key).toLowerCase();
827
- if (!wanted) return null;
828
- const presets = Array.isArray(config?.presets) ? config.presets : [];
829
- return presets.find((p) => {
830
- const id = clean(p?.id).toLowerCase();
831
- const name = clean(p?.name).toLowerCase();
832
- return id === wanted || name === wanted;
833
- }) || null;
834
- }
835
-
836
- function resolveRoute(config, { provider, model, effort, fast } = {}) {
837
- const explicitProvider = clean(provider);
838
- const explicitModel = clean(model);
839
- const hasExplicitEffort = effort !== undefined;
840
- const explicitEffort = hasExplicitEffort ? normalizeEffortInput(effort) : undefined;
841
- const hasExplicitFast = fast !== undefined;
842
- const explicitFast = fast === true;
843
-
844
- if (explicitModel && !explicitProvider) {
845
- const preset = findPreset(config, explicitModel);
846
- if (preset) {
847
- const p = clean(preset.provider) || DEFAULT_PROVIDER;
848
- const m = clean(preset.model) || DEFAULT_MODEL;
849
- const saved = modelSettingsFor(config, p, m);
850
- return {
851
- provider: p,
852
- model: m,
853
- preset,
854
- effort: hasExplicitEffort ? explicitEffort : normalizeSavedEffort(saved.effort ?? preset.effort),
855
- fast: hasExplicitFast ? explicitFast : (hasOwn(saved, 'fast') ? saved.fast === true : (preset.fast === true || fastPreferenceFor(config, p, m))),
856
- };
857
- }
858
- }
859
-
860
- if (!explicitProvider && !explicitModel) {
861
- const defaultKey = config?.default;
862
- const preset = findPreset(config, defaultKey);
863
- if (preset) {
864
- const p = clean(preset.provider) || DEFAULT_PROVIDER;
865
- const m = clean(preset.model) || DEFAULT_MODEL;
866
- const saved = modelSettingsFor(config, p, m);
867
- return {
868
- provider: p,
869
- model: m,
870
- preset,
871
- effort: hasExplicitEffort ? explicitEffort : normalizeSavedEffort(saved.effort ?? preset.effort),
872
- fast: hasExplicitFast ? explicitFast : (hasOwn(saved, 'fast') ? saved.fast === true : (preset.fast === true || fastPreferenceFor(config, p, m))),
873
- };
874
- }
875
- }
876
-
877
- const p = explicitProvider || resolveDefaultProvider(config);
878
- const m = explicitModel || DEFAULT_MODEL;
879
- const saved = modelSettingsFor(config, p, m);
880
- return {
881
- provider: p,
882
- model: m,
883
- preset: null,
884
- effort: hasExplicitEffort ? explicitEffort : normalizeSavedEffort(saved.effort),
885
- fast: hasExplicitFast ? explicitFast : (hasOwn(saved, 'fast') ? saved.fast === true : fastPreferenceFor(config, p, m)),
886
- };
887
- }
888
-
889
- function isLikelyRawModelId(value) {
890
- const model = clean(value);
891
- if (!model || model.length > 160) return false;
892
- if (/\s/.test(model)) return false;
893
- return /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/.test(model);
894
- }
895
-
896
- function validateRequestedModelSelector(config, requested = {}) {
897
- const model = clean(requested.model);
898
- if (!model) return;
899
- if (findPreset(config, model)) return;
900
- if (isLikelyRawModelId(model)) return;
901
- throw new Error(`Invalid model selector "${model}". Use a preset or a model id; free-form text cannot be used as a model.`);
902
- }
903
-
904
- function ensureProviderEnabled(config, provider) {
905
- const providers = { ...(config?.providers || {}) };
906
- providers[provider] = { ...(providers[provider] || {}), enabled: true };
907
- return providers;
908
- }
909
-
910
- const AUTO_CLEAR_DEFAULT_IDLE_MS = 60 * 60 * 1000;
911
-
912
- function normalizeSystemShellConfig(value = {}) {
913
- const raw = value && typeof value === 'object' ? value : {};
914
- const command = clean(raw.command ?? raw.path ?? raw.executable ?? raw.shell);
915
- const envCommand = clean(process.env.MIXDOG_SHELL);
916
- return {
917
- command,
918
- effective: command || envCommand || '',
919
- source: command ? 'config' : (envCommand ? 'env' : 'auto'),
920
- };
921
- }
922
-
923
- function normalizeSystemShellCommand(value) {
924
- const command = clean(value).replace(/^auto$/i, '').replace(/^['"](.+)['"]$/, '$1').trim();
925
- if (!command) return '';
926
- if (process.platform === 'win32') {
927
- const stem = command.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
928
- if (stem !== 'powershell' && stem !== 'pwsh') {
929
- throw new Error('system shell command must be powershell.exe or pwsh on Windows');
930
- }
931
- }
932
- return command;
933
- }
934
-
935
- function normalizeAutoClearConfig(value = {}) {
936
- const raw = value && typeof value === 'object' ? value : {};
937
- const idleMs = Number(raw.idleMs ?? raw.thresholdMs ?? raw.idleMillis);
938
- return {
939
- enabled: raw.enabled !== false,
940
- idleMs: Number.isFinite(idleMs) && idleMs > 0 ? Math.max(60_000, Math.round(idleMs)) : AUTO_CLEAR_DEFAULT_IDLE_MS,
941
- };
942
- }
943
-
944
- function normalizeCompactTypeSetting(value, fallback = 'semantic') {
945
- const raw = clean(value).toLowerCase().replace(/_/g, '-');
946
- if (!raw) return fallback;
947
- if (raw === '1' || raw === 'type1' || raw === 'type-1' || raw === 'semantic' || raw === 'summary' || raw === 'default') return 'semantic';
948
- if (raw === '2' || raw === 'type2' || raw === 'type-2' || raw === 'recall' || raw === 'recall-fast' || raw === 'recall-fasttrack' || raw === 'recall-fast-track' || raw === 'fasttrack' || raw === 'fast-track') return 'recall-fasttrack';
949
- return fallback;
950
- }
951
-
952
- function normalizeCompactionConfig(value = {}, { memoryEnabled = true } = {}) {
953
- const raw = value && typeof value === 'object' ? value : {};
954
- let compactType = normalizeCompactTypeSetting(raw.compactType ?? raw.compact_type ?? raw.type, 'semantic');
955
- if (compactType === 'recall-fasttrack' && memoryEnabled === false) compactType = 'semantic';
956
- return {
957
- ...raw,
958
- auto: raw.auto !== false && raw.enabled !== false,
959
- type: compactType,
960
- compactType,
961
- };
962
- }
963
-
964
- function moduleEnabled(configLike, name, fallback = true) {
965
- const entry = configLike?.modules?.[name];
966
- if (entry && typeof entry === 'object' && entry.enabled === false) return false;
967
- return fallback !== false;
968
- }
969
-
970
- function setModuleEnabledInConfig(configLike, name, enabled) {
971
- const next = { ...(configLike || {}) };
972
- next.modules = { ...(next.modules || {}) };
973
- next.modules[name] = {
974
- ...(next.modules[name] || {}),
975
- enabled: enabled !== false,
976
- };
977
- return next;
978
- }
979
-
980
- function formatDurationMs(ms) {
981
- const value = Math.max(0, Number(ms) || 0);
982
- if (value % 3_600_000 === 0) return `${value / 3_600_000}h`;
983
- if (value % 60_000 === 0) return `${value / 60_000}m`;
984
- return `${Math.round(value / 1000)}s`;
985
- }
986
-
987
- function parseDurationMs(input) {
988
- const text = clean(input).toLowerCase();
989
- if (!text) return null;
990
- const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(text);
991
- if (!match) return null;
992
- const n = Number(match[1]);
993
- if (!Number.isFinite(n) || n <= 0) return null;
994
- const unit = match[2] || 'm';
995
- const mult = unit === 'h' ? 3_600_000 : unit === 'm' ? 60_000 : unit === 's' ? 1000 : 1;
996
- return Math.max(60_000, Math.round(n * mult));
997
- }
998
-
999
- const FAST_CAPABLE_PROVIDERS = new Set(['anthropic', 'anthropic-oauth', 'openai', 'openai-oauth']);
1000
- const LAZY_SECRET_PROVIDERS = new Set(['openai-oauth', 'anthropic-oauth', 'grok-oauth', 'ollama', 'lmstudio']);
1001
-
1002
- function routeFastKey(provider, model) {
1003
- const p = clean(provider);
1004
- const m = clean(model);
1005
- return p && m ? `${p}/${m}` : '';
1006
- }
1007
-
1008
- function openAiModelMetaSupportsFast(model) {
1009
- const tiers = Array.isArray(model?.serviceTiers) ? model.serviceTiers : [];
1010
- const speedTiers = Array.isArray(model?.additionalSpeedTiers) ? model.additionalSpeedTiers : [];
1011
- if (tiers.length || speedTiers.length || model?.defaultServiceTier) {
1012
- return tiers.some((tier) => tier?.id === 'priority')
1013
- || speedTiers.includes('priority')
1014
- || model?.defaultServiceTier === 'priority';
1015
- }
1016
- const id = clean(model?.id || model).toLowerCase();
1017
- if (id.includes('mini') || id.includes('nano') || id.includes('codex')) return false;
1018
- return /^gpt-5(\.|-|$)/.test(id);
1019
- }
1020
-
1021
- function openAiDirectModelSupportsFast(model) {
1022
- const id = clean(model?.id || model);
1023
- return /^gpt-5\.5(?:-\d{4}|$)/.test(id)
1024
- || /^gpt-5\.4(?:-\d{4}|$)/.test(id)
1025
- || /^gpt-5\.4-mini(?:-\d{4}|$)/.test(id);
1026
- }
1027
-
1028
- function openAiModelSupportsHostedWebSearch(model) {
1029
- const id = clean(model?.id || model).toLowerCase();
1030
- if (!id) return false;
1031
- if (model?.supportsWebSearch === true) return true;
1032
- const tools = [
1033
- ...(Array.isArray(model?.supportedTools) ? model.supportedTools : []),
1034
- ...(Array.isArray(model?.tools) ? model.tools : []),
1035
- ...(Array.isArray(model?.capabilities?.tools) ? model.capabilities.tools : []),
1036
- ].map((tool) => clean(tool?.type || tool?.name || tool).toLowerCase());
1037
- if (tools.some((tool) => tool === 'web_search' || tool === 'web_search_preview')) return true;
1038
- if (/codex|image|audio|tts|stt|embedding|rerank|moderation|search-preview/.test(id)) return false;
1039
- return /^gpt-(5(?:\.|$|-)|4\.1(?:-|$)|4o(?:-|$)|4\.5(?:-|$))/.test(id)
1040
- || /^o[34](?:-|$)/.test(id);
1041
- }
1042
-
1043
- function grokModelSupportsHostedWebSearch(model) {
1044
- const id = clean(model?.id || model).toLowerCase();
1045
- if (!id || /imagine|image|video|composer/.test(id)) return false;
1046
- if (id === 'grok-build') return false;
1047
- return /^grok-/.test(id);
1048
- }
1049
-
1050
- function geminiModelSupportsHostedWebSearch(model) {
1051
- const id = clean(model?.id || model).toLowerCase();
1052
- if (!id || /embedding|aqa|imagen|veo|tts|image|computer-use|customtools/.test(id)) return false;
1053
- return /^gemini-(3(?:\.|-|$)|2\.5-|2\.0-flash)/.test(id);
1054
- }
1055
-
1056
- function anthropicModelSupportsHostedWebSearch(model) {
1057
- const id = clean(model?.id || model).toLowerCase();
1058
- if (!id) return false;
1059
- const match = id.match(/^claude-(opus|sonnet|haiku)-(\d+)(?:[-.](\d+))?/);
1060
- if (!match) return false;
1061
- const major = Number(match[2]) || 0;
1062
- const minor = Number(match[3]) || 0;
1063
- return major > 4 || (major === 4 && minor >= 0);
1064
- }
1065
-
1066
- function anthropicModelMetaSupportsFast(model) {
1067
- const id = clean(model?.id || model).toLowerCase();
1068
- return /^claude-(opus|sonnet)/.test(id);
1069
- }
1070
-
1071
- function fastCapableFor(provider, model) {
1072
- const p = clean(provider);
1073
- if (!FAST_CAPABLE_PROVIDERS.has(p)) return false;
1074
- if (p === 'openai') return openAiDirectModelSupportsFast(model);
1075
- if (p === 'openai-oauth') return openAiModelMetaSupportsFast(model);
1076
- if (p === 'anthropic' || p === 'anthropic-oauth') return anthropicModelMetaSupportsFast(model);
1077
- return false;
1078
- }
1079
-
1080
- function searchCapableFor(provider, model) {
1081
- const p = normalizeSearchProviderId(provider);
1082
- if (!isSearchCapableProvider(p)) return false;
1083
- if (p === 'openai' || p === 'openai-oauth') return openAiModelSupportsHostedWebSearch(model);
1084
- if (p === 'grok-oauth' || p === 'xai') return grokModelSupportsHostedWebSearch(model);
1085
- if (p === 'gemini') return geminiModelSupportsHostedWebSearch(model);
1086
- if (p === 'anthropic' || p === 'anthropic-oauth') return anthropicModelSupportsHostedWebSearch(model);
1087
- return model?.supportsWebSearch === true;
1088
- }
1089
-
1090
- function fastPreferenceFor(config, provider, model) {
1091
- const key = routeFastKey(provider, model);
1092
- if (!key) return false;
1093
- const saved = config?.modelSettings?.[key];
1094
- if (saved && typeof saved === 'object' && hasOwn(saved, 'fast')) return saved.fast === true;
1095
- return config?.fastModels?.[key] === true;
1096
- }
1097
-
1098
- function saveModelSettings(cfgMod, route, { fastCapable = true, baseConfig = null } = {}) {
1099
- const key = routeFastKey(route?.provider, route?.model);
1100
- if (!key) return baseConfig || cfgMod.loadConfig();
1101
- const nextConfig = baseConfig || cfgMod.loadConfig();
1102
- const modelSettings = { ...(nextConfig.modelSettings || {}) };
1103
- const nextSetting = { ...(modelSettings[key] || {}) };
1104
- if (hasOwn(route, 'effort') && route.effort) nextSetting.effort = route.effort;
1105
- else delete nextSetting.effort;
1106
- if (fastCapable) nextSetting.fast = route.fast === true;
1107
- else nextSetting.fast = false;
1108
- modelSettings[key] = nextSetting;
1109
-
1110
- // Legacy compatibility: keep fastModels true entries for old readers, but
1111
- // let modelSettings.fast=false override them in new readers.
1112
- const fastModels = { ...(nextConfig.fastModels || {}) };
1113
- if (nextSetting.fast === true) fastModels[key] = true;
1114
- else delete fastModels[key];
1115
-
1116
- const savedConfig = { ...nextConfig, modelSettings, fastModels };
1117
- cfgMod.saveConfig(savedConfig);
1118
- return savedConfig;
1119
- }
1120
-
1121
- function routeForStatusline(route) {
1122
- const out = {
1123
- mode: 'fixed',
1124
- defaultProvider: route.provider,
1125
- defaultModel: route.model,
1126
- };
1127
- const preset = route.preset || {};
1128
- if (preset.id) out.presetId = preset.id;
1129
- if (preset.name) out.presetName = preset.name;
1130
- // Prefer the preset's curated label, then the route's resolved model display
1131
- // (set by refreshRouteEffort from the live/offline catalog). Without the
1132
- // route fallback, a preset-less direct model (e.g. claude-fable-5) reaches
1133
- // the statusline with no display and renders as the raw id.
1134
- const modelDisplay = clean(preset.modelDisplay) || clean(route.modelDisplay);
1135
- if (modelDisplay) out.modelDisplay = modelDisplay;
1136
- if (route.fast === true || route.fast === false) out.fast = route.fast;
1137
- else if (preset.fast === true || preset.fast === false) out.fast = preset.fast;
1138
- if (route.effectiveEffort) {
1139
- out.effort = route.effectiveEffort;
1140
- out.displayEffort = route.effectiveEffort;
1141
- } else if (hasOwn(route, 'effort')) {
1142
- delete out.effort;
1143
- delete out.displayEffort;
1144
- }
1145
- return out;
1146
- }
1147
-
1148
- function writeStatuslineRoute(statusRoutes, session, route) {
1149
- if (!session?.id || !route) return;
1150
- const clientHostPid = session?.clientHostPid || process.pid;
1151
- statusRoutes?.writeGatewaySessionRoute?.(session.id, routeForStatusline(route), { clientHostPid });
1152
- }
375
+ const outputStyleStatus = (dataDir = STANDALONE_DATA_DIR) => outputStyleStatusRaw(STANDALONE_ROOT, dataDir || STANDALONE_DATA_DIR);
1153
376
 
1154
377
  const ONBOARDING_VERSION = 1;
1155
- const WORKFLOW_ROUTE_SLOTS = ['lead', 'agent', 'explorer', 'memory'];
1156
- const FIXED_AGENT_SLOTS = Object.freeze([
1157
- { id: 'explore', label: 'Explore', description: 'Broad repository exploration', workflowSlot: 'explorer' },
1158
- { id: 'maintainer', label: 'Maintainer', description: 'Background memory and upkeep', workflowSlot: 'memory' },
1159
- { id: 'worker', label: 'Worker', description: 'Scoped implementation' },
1160
- { id: 'heavy-worker', label: 'Heavy Worker', description: 'Broad or multi-file implementation' },
1161
- { id: 'reviewer', label: 'Reviewer', description: 'Diff review and risk checks' },
1162
- { id: 'debugger', label: 'Debugger', description: 'Root-cause analysis and failure tracing' },
1163
- ]);
1164
- const SEARCH_CAPABLE_PROVIDERS = new Set([
1165
- 'openai-oauth',
1166
- 'openai',
1167
- 'grok-oauth',
1168
- 'xai',
1169
- 'gemini',
1170
- 'anthropic',
1171
- 'anthropic-oauth',
1172
- ]);
1173
- const SEARCH_DEFAULT_PROVIDER = 'default';
1174
- const SEARCH_DEFAULT_MODEL = 'default';
1175
- const SEARCH_PROVIDER_ALIASES = Object.freeze({
1176
- 'openai-api': 'openai',
1177
- 'xai-api': 'xai',
1178
- 'gemini-api': 'gemini',
1179
- 'anthropic-api': 'anthropic',
1180
- });
1181
378
  const QUICK_SEARCH_MODELS = Object.freeze({
1182
379
  'openai-oauth': [
1183
380
  { id: 'gpt-5.5', display: 'GPT-5.5', latest: true, contextWindow: 1000000 },
@@ -1219,855 +416,26 @@ const QUICK_SEARCH_MODELS = Object.freeze({
1219
416
  { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5', contextWindow: 200000 },
1220
417
  ],
1221
418
  });
1222
- const AGENT_ROLE_IDS = new Set(FIXED_AGENT_SLOTS.map((agent) => agent.id));
1223
- const agentDefinitionCache = new Map();
1224
- const DEFAULT_WORKFLOW_ID = 'default';
1225
-
1226
- function workflowPresetId(slot) {
1227
- return `workflow-${slot}`;
1228
- }
1229
-
1230
- function workflowPresetName(slot) {
1231
- return `WORKFLOW ${String(slot || '').toUpperCase()}`;
1232
- }
1233
-
1234
- function agentPresetSlot(agentId) {
1235
- return `agent-${String(agentId || '').replace(/[^a-z0-9_.-]+/gi, '-').toLowerCase()}`;
1236
- }
1237
-
1238
- function normalizeAgentId(value) {
1239
- const id = clean(value).toLowerCase().replace(/[\s_]+/g, '-');
1240
- if (id === 'explorer') return 'explore';
1241
- if (id === 'maint' || id === 'maintenance' || id === 'memory') return 'maintainer';
1242
- if (id === 'heavy' || id === 'heavyworker') return 'heavy-worker';
1243
- if (id === 'review') return 'reviewer';
1244
- if (id === 'debug') return 'debugger';
1245
- return AGENT_ROLE_IDS.has(id) ? id : '';
1246
- }
1247
-
1248
- function normalizeWorkflowId(value, fallback = '') {
1249
- const id = clean(value).toLowerCase().replace(/[\s_]+/g, '-');
1250
- return /^[a-z0-9][a-z0-9_.-]*$/.test(id) ? id : fallback;
1251
- }
1252
-
1253
- function readTextSafe(path) {
1254
- try { return readFileSync(path, 'utf8').trim(); } catch { return ''; }
1255
- }
1256
-
1257
- function workflowSourceDirs(dataDir) {
1258
- return [
1259
- { root: join(STANDALONE_ROOT, 'workflows'), source: 'built-in' },
1260
- { root: join(dataDir || STANDALONE_DATA_DIR, 'workflows'), source: 'user' },
1261
- ];
1262
- }
1263
-
1264
- function agentSourceDirs(dataDir, id) {
1265
- return [
1266
- join(dataDir || STANDALONE_DATA_DIR, 'agents', id),
1267
- join(STANDALONE_ROOT, 'agents', id),
1268
- ];
1269
- }
1270
-
1271
- function readWorkflowPackFromDir(dir, source = 'built-in', dirName = '') {
1272
- const entry = 'WORKFLOW.md';
1273
- const doc = readMarkdownDocument(readTextSafe(join(dir, entry)));
1274
- const body = doc.body;
1275
- if (!body) return null;
1276
- const fm = doc.frontmatter || {};
1277
- const id = normalizeWorkflowId(clean(fm.id) || dirName || basename(dir));
1278
- if (!id) return null;
1279
- const agentsConfigured = Object.prototype.hasOwnProperty.call(fm, 'agents');
1280
- return {
1281
- id,
1282
- name: clean(fm.name) || id,
1283
- description: clean(fm.description),
1284
- entry,
1285
- agentsConfigured,
1286
- agents: agentsConfigured
1287
- ? String(fm.agents || '')
1288
- .split(',')
1289
- .map((agent) => normalizeAgentId(agent) || normalizeWorkflowId(agent))
1290
- .filter(Boolean)
1291
- : [],
1292
- body,
1293
- source,
1294
- };
1295
- }
1296
-
1297
- function listWorkflowPacks(dataDir) {
1298
- const byId = new Map();
1299
- for (const { root, source } of workflowSourceDirs(dataDir)) {
1300
- if (!existsSync(root)) continue;
1301
- let entries = [];
1302
- try { entries = readdirSync(root, { withFileTypes: true }); } catch { entries = []; }
1303
- for (const entry of entries) {
1304
- if (!entry.isDirectory()) continue;
1305
- const dir = join(root, entry.name);
1306
- if (!existsSync(join(dir, 'WORKFLOW.md'))) continue;
1307
- const pack = readWorkflowPackFromDir(dir, source, entry.name);
1308
- if (pack) byId.set(pack.id, pack);
1309
- }
1310
- }
1311
- return [...byId.values()].sort((a, b) => {
1312
- if (a.id === DEFAULT_WORKFLOW_ID) return -1;
1313
- if (b.id === DEFAULT_WORKFLOW_ID) return 1;
1314
- return a.name.localeCompare(b.name);
1315
- });
1316
- }
1317
-
1318
- function activeWorkflowId(config) {
1319
- return normalizeWorkflowId(config?.workflow?.active, DEFAULT_WORKFLOW_ID);
1320
- }
1321
-
1322
- function loadWorkflowPack(dataDir, id) {
1323
- const wanted = normalizeWorkflowId(id, DEFAULT_WORKFLOW_ID);
1324
- for (const { root, source } of workflowSourceDirs(dataDir).reverse()) {
1325
- const pack = readWorkflowPackFromDir(join(root, wanted), source, wanted);
1326
- if (pack) return pack;
1327
- }
1328
- return readWorkflowPackFromDir(join(STANDALONE_ROOT, 'workflows', DEFAULT_WORKFLOW_ID), 'built-in', DEFAULT_WORKFLOW_ID);
1329
- }
1330
-
1331
- function workflowSummary(pack) {
1332
- const id = normalizeWorkflowId(pack?.id, DEFAULT_WORKFLOW_ID);
1333
- return {
1334
- id,
1335
- name: clean(pack?.name) || (id === DEFAULT_WORKFLOW_ID ? 'Default' : id),
1336
- description: clean(pack?.description),
1337
- source: clean(pack?.source),
1338
- };
1339
- }
1340
-
1341
- function activeWorkflowSummary(config, dataDir) {
1342
- return workflowSummary(loadWorkflowPack(dataDir, activeWorkflowId(config)));
1343
- }
1344
-
1345
- function loadAgentDefinition(dataDir, id) {
1346
- const agentId = normalizeAgentId(id) || normalizeWorkflowId(id);
1347
- if (!agentId) return null;
1348
- const cacheKey = `${dataDir || STANDALONE_DATA_DIR}\n${agentId}`;
1349
- if (agentDefinitionCache.has(cacheKey)) return agentDefinitionCache.get(cacheKey);
1350
- for (const dir of agentSourceDirs(dataDir, agentId)) {
1351
- const manifest = readJsonSafe(join(dir, 'agent.json')) || {};
1352
- const entry = clean(manifest.entry) || 'AGENT.md';
1353
- const doc = readMarkdownDocument(readTextSafe(join(dir, entry)));
1354
- const body = doc.body;
1355
- if (!body) continue;
1356
- const definition = {
1357
- id: agentId,
1358
- name: clean(manifest.name) || FIXED_AGENT_SLOTS.find((agent) => agent.id === agentId)?.label || agentId,
1359
- description: clean(manifest.description) || FIXED_AGENT_SLOTS.find((agent) => agent.id === agentId)?.description || '',
1360
- permission: normalizeAgentPermissionOrNone(doc.frontmatter.permission),
1361
- frontmatter: doc.frontmatter,
1362
- body,
1363
- };
1364
- agentDefinitionCache.set(cacheKey, definition);
1365
- return definition;
1366
- }
1367
- const legacyDoc = readMarkdownDocument(readTextSafe(join(STANDALONE_ROOT, 'agents', `${agentId}.md`)));
1368
- if (!legacyDoc.body) {
1369
- agentDefinitionCache.set(cacheKey, null);
1370
- return null;
1371
- }
1372
- const definition = {
1373
- id: agentId,
1374
- name: FIXED_AGENT_SLOTS.find((agent) => agent.id === agentId)?.label || agentId,
1375
- description: '',
1376
- permission: normalizeAgentPermissionOrNone(legacyDoc.frontmatter.permission),
1377
- frontmatter: legacyDoc.frontmatter,
1378
- body: legacyDoc.body,
1379
- };
1380
- agentDefinitionCache.set(cacheKey, definition);
1381
- return definition;
1382
- }
1383
-
1384
- function workflowContextBlock(config, dataDir) {
1385
- const pack = loadWorkflowPack(dataDir, activeWorkflowId(config));
1386
- if (!pack) return '';
1387
- const lines = [
1388
- `# Active Workflow: ${pack.name}`,
1389
- ];
1390
- if (pack.description) lines.push(pack.description);
1391
- lines.push(pack.body);
1392
-
1393
- const agentIds = pack.agentsConfigured ? pack.agents : FIXED_AGENT_SLOTS.map((agent) => agent.id);
1394
- const agentBlocks = agentIds
1395
- .map((id) => loadAgentDefinition(dataDir, id))
1396
- .filter(Boolean);
1397
- if (agentBlocks.length) {
1398
- lines.push('# Available Agents');
1399
- for (const agent of agentBlocks) {
1400
- lines.push(`## ${agent.name} (${agent.id})`);
1401
- if (agent.description) lines.push(agent.description);
1402
- lines.push(agent.body);
1403
- }
1404
- }
1405
- return lines.join('\n\n');
1406
- }
1407
-
1408
- function normalizeWorkflowRoute(routeLike, fallback = {}) {
1409
- const provider = clean(routeLike?.provider) || clean(fallback.provider);
1410
- const model = clean(routeLike?.model) || clean(fallback.model);
1411
- if (!provider || !model) return null;
1412
- // Defensive: a workflow/agent route must carry a real model id. Reject values
1413
- // that are obviously free-form text (whitespace, prose) so a bad string can
1414
- // never be persisted as a preset/workflow route. Normal model ids pass
1415
- // isLikelyRawModelId unchanged.
1416
- if (!isLikelyRawModelId(model)) return null;
1417
- const effort = normalizeEffortInput(routeLike?.effort ?? fallback.effort);
1418
- const fast = routeLike?.fast ?? fallback.fast;
1419
- return {
1420
- provider,
1421
- model,
1422
- ...(effort ? { effort } : {}),
1423
- ...(fast === true ? { fast: true } : {}),
1424
- };
1425
- }
1426
-
1427
- function normalizeSearchProviderId(provider) {
1428
- const id = clean(provider);
1429
- return SEARCH_PROVIDER_ALIASES[id] || id;
1430
- }
1431
-
1432
- function isDefaultSearchRouteConfig(routeLike = {}) {
1433
- return normalizeSearchProviderId(routeLike?.provider) === SEARCH_DEFAULT_PROVIDER
1434
- && clean(routeLike?.model).toLowerCase() === SEARCH_DEFAULT_MODEL;
1435
- }
1436
-
1437
- function isSearchCapableProvider(provider) {
1438
- return SEARCH_CAPABLE_PROVIDERS.has(normalizeSearchProviderId(provider));
1439
- }
1440
-
1441
- function normalizeSearchRouteConfig(routeLike, fallback = {}) {
1442
- const provider = normalizeSearchProviderId(routeLike?.provider || fallback.provider);
1443
- const model = clean(routeLike?.model || fallback.model);
1444
- if (!provider || !model) return null;
1445
- let effort = null;
1446
- try {
1447
- effort = normalizeEffortInput(routeLike?.effort ?? fallback.effort);
1448
- } catch {
1449
- effort = null;
1450
- }
1451
- const fast = routeLike?.fast ?? fallback.fast;
1452
- const toolType = clean(routeLike?.toolType || fallback.toolType);
1453
- return {
1454
- provider,
1455
- model,
1456
- ...(effort ? { effort } : {}),
1457
- ...(fast === true ? { fast: true } : {}),
1458
- ...(toolType ? { toolType } : {}),
1459
- };
1460
- }
1461
-
1462
- function upsertWorkflowPreset(presets, slot, routeLike) {
1463
- const route = normalizeWorkflowRoute(routeLike);
1464
- if (!route) return presets;
1465
- const id = workflowPresetId(slot);
1466
- const preset = {
1467
- id,
1468
- name: workflowPresetName(slot),
1469
- type: 'agent',
1470
- provider: route.provider,
1471
- model: route.model,
1472
- ...(route.effort ? { effort: route.effort } : {}),
1473
- ...(route.fast === true ? { fast: true } : {}),
1474
- tools: 'full',
1475
- };
1476
- const next = (Array.isArray(presets) ? presets : []).filter((p) => clean(p?.id) !== id && clean(p?.name) !== preset.name);
1477
- next.push(preset);
1478
- return next;
1479
- }
1480
-
1481
- function summarizeWorkflowRoutes(config) {
1482
- const routes = config?.workflowRoutes && typeof config.workflowRoutes === 'object' ? config.workflowRoutes : {};
1483
- const fallbackProvider = resolveDefaultProvider(config);
1484
- const out = {};
1485
- for (const slot of WORKFLOW_ROUTE_SLOTS) {
1486
- const route = routes[slot];
1487
- // Read/interpret path: a route with a model but no provider falls back to
1488
- // config.defaultProvider (then DEFAULT_PROVIDER).
1489
- if (route?.model && (route?.provider || fallbackProvider)) {
1490
- out[slot] = normalizeWorkflowRoute(route, { provider: fallbackProvider });
1491
- }
1492
- }
1493
- return out;
1494
- }
1495
-
1496
- function routeFromPreset(config, slotValue) {
1497
- // Maintenance slots now store a direct {provider, model} route. Accept that
1498
- // shape first; fall back to the legacy preset-NAME string lookup so configs
1499
- // written before the route migration still resolve.
1500
- if (slotValue && typeof slotValue === 'object' && !Array.isArray(slotValue)) {
1501
- const direct = normalizeWorkflowRoute(slotValue);
1502
- if (direct) return direct;
1503
- }
1504
- const preset = findPreset(config, slotValue);
1505
- return preset ? normalizeWorkflowRoute(preset) : null;
1506
- }
1507
-
1508
- function agentRouteFromConfig(config, agentId, _dataDir) {
1509
- const id = normalizeAgentId(agentId);
1510
- if (!id) return null;
1511
- // Read/interpret path: inject config.defaultProvider (then DEFAULT_PROVIDER)
1512
- // when a stored route omits its provider.
1513
- const fallback = { provider: resolveDefaultProvider(config) };
1514
- const explicit = normalizeWorkflowRoute(config?.agents?.[id], fallback)
1515
- || (id === 'maintainer' ? normalizeWorkflowRoute(config?.agents?.maintenance, fallback) : null);
1516
- if (explicit) return explicit;
1517
-
1518
- const agent = FIXED_AGENT_SLOTS.find((item) => item.id === id);
1519
- if (agent?.workflowSlot) {
1520
- const workflowRoute = normalizeWorkflowRoute(config?.workflowRoutes?.[agent.workflowSlot], fallback);
1521
- if (workflowRoute) return workflowRoute;
1522
- }
1523
-
1524
- if (id === 'explore') return routeFromPreset(config, config?.maintenance?.explore);
1525
- if (id === 'maintainer') return routeFromPreset(config, config?.maintenance?.memory);
1526
-
1527
- return null;
1528
- }
1529
-
1530
- function toolResponseText(result) {
1531
- if (result && typeof result === 'object' && Array.isArray(result.content)) {
1532
- return result.content
1533
- .map((part) => (part?.type === 'text' ? part.text || '' : JSON.stringify(part)))
1534
- .join('\n');
1535
- }
1536
- if (typeof result === 'string') return result;
1537
- return JSON.stringify(result, null, 2);
1538
- }
1539
-
1540
- function isEmptyRecallText(value) {
1541
- const text = String(value || '').trim();
1542
- return !text || /^\(?no results\)?$/i.test(text) || /^\(?empty memory result\)?$/i.test(text);
1543
- }
1544
-
1545
- function currentSessionRecallRows(session, query, { limit = 10 } = {}) {
1546
- const messages = Array.isArray(session?.messages) ? session.messages : [];
1547
- if (!messages.length) return '(no results)';
1548
- const terms = [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_./:-]{2,}/gu) || [])]
1549
- .filter(Boolean)
1550
- .slice(0, 16);
1551
- const max = Math.max(1, Math.min(100, Number(limit) || 10));
1552
- const rows = [];
1553
- for (let i = messages.length - 1; i >= 0 && rows.length < max; i -= 1) {
1554
- const m = messages[i];
1555
- if (!m || (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool')) continue;
1556
- const text = messageContextText(m).replace(/\s+/g, ' ').trim();
1557
- if (!text) continue;
1558
- if (terms.length && !terms.some((term) => text.toLowerCase().includes(term))) continue;
1559
- rows.push(`[session:${i + 1}] ${m.role}: ${text.slice(0, 1000)}`);
1560
- }
1561
- return rows.length ? rows.join('\n') : '(no results)';
1562
- }
1563
-
1564
- function parseToolSelection(value) {
1565
- if (Array.isArray(value)) return value.map(clean).filter(Boolean);
1566
- if (value && typeof value !== 'string' && typeof value[Symbol.iterator] === 'function') {
1567
- return [...value].map(clean).filter(Boolean);
1568
- }
1569
- return String(value || '').replace(/^select\s*:/i, '')
1570
- .split(/[,\s]+/)
1571
- .map(clean)
1572
- .filter(Boolean);
1573
- }
1574
-
1575
- function parseToolSearchQuerySelection(query) {
1576
- const match = clean(query).match(/^select\s*:\s*(.+)$/i);
1577
- return match ? parseToolSelection(match[1]) : [];
1578
- }
1579
-
1580
- function toolKind(tool) {
1581
- const name = clean(tool?.name);
1582
- if (name.startsWith('mcp__')) return 'mcp';
1583
- if (name.startsWith('skill:') || tool?.annotations?.mixdogKind === 'skill') return 'skill';
1584
- if (name === 'Skill' || name.startsWith('skill_') || name === 'skills_list' || name === 'skill_view') return 'skill';
1585
- if (tool?.annotations?.agentHidden) return 'control';
1586
- if (['apply_patch', 'shell'].includes(name)) return 'mutation';
1587
- return 'tool';
1588
- }
1589
-
1590
- function toolSchemaBucket(tool) {
1591
- const name = clean(tool?.name);
1592
- const kind = toolKind(tool);
1593
- if (kind === 'mcp') return 'mcp';
1594
- if (kind === 'skill') return 'skills';
1595
- if (name === 'memory' || name === 'recall' || name.includes('memory')) return 'memory';
1596
- if (name === 'search' || name === 'web_fetch') return 'web';
1597
- if (['read', 'grep', 'find', 'glob', 'list', 'code_graph', 'explore'].includes(name)) return 'code';
1598
- if (['shell', 'apply_patch'].includes(name)) return 'mutation';
1599
- if (name === 'agent' || name === 'delegate') return 'agents';
1600
- if (name.includes('channel') || name.includes('discord') || name.includes('webhook')) return 'channels';
1601
- if (name.includes('provider') || name === 'tool_search' || name === 'cwd') return 'setup';
1602
- return 'other';
1603
- }
1604
-
1605
- function estimateToolSchemaBreakdown(tools) {
1606
- const out = {};
1607
- for (const tool of Array.isArray(tools) ? tools : []) {
1608
- const bucket = toolSchemaBucket(tool);
1609
- const row = out[bucket] || { count: 0, tokens: 0 };
1610
- row.count += 1;
1611
- row.tokens += estimateToolSchemaTokens([tool]);
1612
- out[bucket] = row;
1613
- }
1614
- return out;
1615
- }
1616
-
1617
- function measuredToolUsage(name) {
1618
- return MEASURED_TOOL_USAGE[clean(name)] || 0;
1619
- }
1620
-
1621
- function measuredToolRank(name) {
1622
- const index = MEASURED_TOOL_ORDER.indexOf(clean(name));
1623
- return index === -1 ? Number.MAX_SAFE_INTEGER : index;
1624
- }
1625
-
1626
- function sortedCatalogByMeasuredUsage(catalog) {
1627
- return (catalog || [])
1628
- .map((tool, index) => ({ tool, index }))
1629
- .sort((a, b) => {
1630
- const au = measuredToolUsage(a.tool?.name);
1631
- const bu = measuredToolUsage(b.tool?.name);
1632
- if (bu !== au) return bu - au;
1633
- const ar = measuredToolRank(a.tool?.name);
1634
- const br = measuredToolRank(b.tool?.name);
1635
- if (ar !== br) return ar - br;
1636
- return a.index - b.index;
1637
- })
1638
- .map((entry) => entry.tool);
1639
- }
1640
-
1641
- function activeToolForSurface(tool) {
1642
- if (!tool || typeof tool !== 'object') return tool;
1643
- return JSON.parse(JSON.stringify(tool));
1644
- }
1645
-
1646
- function deferredProviderMode(provider) {
1647
- const p = clean(provider).toLowerCase();
1648
- if (p === 'gemini') return 'full';
1649
- if (p === 'anthropic' || p === 'anthropic-oauth'
1650
- || p === 'openai' || p === 'openai-oauth'
1651
- || p === 'xai' || p === 'grok-oauth') {
1652
- return 'native';
1653
- }
1654
- return 'legacy';
1655
- }
1656
-
1657
- function filterDisallowedTools(tools, disallowed = []) {
1658
- if (!Array.isArray(disallowed) || disallowed.length === 0) return tools;
1659
- const deny = new Set(disallowed.map((name) => clean(name)).filter(Boolean));
1660
- if (deny.size === 0) return tools;
1661
- return (tools || []).filter((tool) => !deny.has(clean(tool?.name)));
1662
- }
1663
-
1664
- function sortedNamesByMeasuredUsage(names) {
1665
- return [...(names || [])].sort((a, b) => {
1666
- const au = measuredToolUsage(a);
1667
- const bu = measuredToolUsage(b);
1668
- if (bu !== au) return bu - au;
1669
- const ar = measuredToolRank(a);
1670
- const br = measuredToolRank(b);
1671
- if (ar !== br) return ar - br;
1672
- return String(a).localeCompare(String(b));
1673
- });
1674
- }
1675
-
1676
- export function defaultDeferredToolNames(catalog, mode) {
1677
- const available = new Set((catalog || []).map((tool) => clean(tool?.name)).filter(Boolean));
1678
- if (mode === 'lead') {
1679
- return new Set(DEFERRED_DEFAULT_LEAD_TOOLS.filter((name) => available.has(name)));
1680
- }
1681
- if (mode === 'readonly') {
1682
- return new Set(DEFERRED_DEFAULT_READONLY_TOOLS.filter((name) => available.has(name)));
1683
- }
1684
- return new Set(DEFERRED_DEFAULT_FULL_TOOLS.filter((name) => available.has(name)));
1685
- }
1686
-
1687
- export function compactToolSearchDescription(value, max = 220) {
1688
- const text = clean(value).replace(/\s+/g, ' ');
1689
- return text.length > max ? `${text.slice(0, max - 1)}…` : text;
1690
- }
1691
-
1692
- function toolRow(tool, activeNames = new Set()) {
1693
- const name = clean(tool?.name);
1694
- return {
1695
- name,
1696
- kind: toolKind(tool),
1697
- usage: measuredToolUsage(name),
1698
- active: activeNames.has(name),
1699
- description: compactToolSearchDescription(tool?.description),
1700
- };
1701
- }
1702
-
1703
- function providerSupportsResponsesCustomTools(provider) {
1704
- const p = clean(provider).toLowerCase();
1705
- if (!p) return true;
1706
- return p === 'openai' || p === 'openai-oauth';
1707
- }
1708
-
1709
- function openAILoadableToolSpec(tool, provider = '') {
1710
- if (providerSupportsResponsesCustomTools(provider) && isResponsesFreeformTool(tool)) return toResponsesCustomTool(tool);
1711
- return {
1712
- type: 'function',
1713
- name: clean(tool?.name),
1714
- description: clean(tool?.description),
1715
- defer_loading: true,
1716
- parameters: tool?.inputSchema && typeof tool.inputSchema === 'object'
1717
- ? tool.inputSchema
1718
- : { type: 'object', properties: {} },
1719
- };
1720
- }
1721
-
1722
- function toolSearchNativePayload(catalog, names, provider = '') {
1723
- const selected = new Set((names || []).map(clean).filter(Boolean));
1724
- if (!selected.size) return null;
1725
- const tools = [];
1726
- const refs = [];
1727
- for (const tool of catalog || []) {
1728
- const name = clean(tool?.name);
1729
- if (!name || !selected.has(name)) continue;
1730
- refs.push(name);
1731
- tools.push(openAILoadableToolSpec(tool, provider));
1732
- }
1733
- if (!refs.length) return null;
1734
- return {
1735
- toolReferences: refs,
1736
- openaiTools: tools,
1737
- summary: `Loaded deferred tools: ${refs.join(', ')}`,
1738
- };
1739
- }
1740
-
1741
- function toolSearchTokens(value) {
1742
- return (clean(value).toLowerCase().match(/[a-z0-9_.-]+/g) || [])
1743
- .map((token) => token.replace(/[-.]+/g, '_'))
1744
- .filter(Boolean);
1745
- }
1746
-
1747
- function toolSearchMeaningfulTokens(value) {
1748
- return toolSearchTokens(value).filter((token) => !TOOL_SEARCH_STOP_WORDS.has(token));
1749
- }
1750
-
1751
- function toolSearchText(row) {
1752
- const text = `${row.name} ${String(row.name || '').replace(/_/g, ' ')} ${row.kind} ${row.description} ${row.active ? 'active' : 'deferred'}`;
1753
- return `${text} ${text.replace(/[-.]+/g, '_')}`.toLowerCase();
1754
- }
1755
-
1756
- function toolSearchRowAliases(name) {
1757
- return TOOL_SEARCH_ROW_ALIASES[clean(name)] || [];
1758
- }
1759
-
1760
- function toolSearchRank(row, query) {
1761
- const raw = clean(query).toLowerCase();
1762
- if (!raw) return { score: 0, reasons: [] };
1763
- const name = clean(row?.name).toLowerCase();
1764
- const prettyName = name.replace(/_/g, ' ');
1765
- const haystack = toolSearchText(row);
1766
- const aliases = toolSearchRowAliases(name);
1767
- const aliasText = aliases.join(' ').toLowerCase();
1768
- const queryTokens = toolSearchMeaningfulTokens(raw);
1769
- const nameTokens = new Set(toolSearchTokens(`${name} ${prettyName}`));
1770
- const aliasTokens = new Set(toolSearchTokens(aliasText));
1771
- let score = 0;
1772
- const reasons = [];
1773
- if (raw === name || raw === prettyName) {
1774
- score += 120;
1775
- reasons.push('exact-name');
1776
- }
1777
- if (aliases.some((alias) => raw === alias || raw === alias.replace(/[_-]+/g, ' '))) {
1778
- score += 100;
1779
- reasons.push('exact-alias');
1780
- }
1781
- if (haystack.includes(raw)) {
1782
- score += 34;
1783
- reasons.push('phrase');
1784
- }
1785
- for (const alias of aliases) {
1786
- const normalizedAlias = alias.toLowerCase();
1787
- if (normalizedAlias && raw.includes(normalizedAlias)) {
1788
- score += 58;
1789
- reasons.push('alias-phrase');
1790
- break;
1791
- }
1792
- }
1793
- let matchedTokens = 0;
1794
- for (const token of queryTokens) {
1795
- if (nameTokens.has(token) || name.includes(token)) {
1796
- score += 24;
1797
- matchedTokens += 1;
1798
- continue;
1799
- }
1800
- if (aliasTokens.has(token) || aliasText.includes(token)) {
1801
- score += 18;
1802
- matchedTokens += 1;
1803
- continue;
1804
- }
1805
- if (haystack.includes(token)) {
1806
- score += 7;
1807
- matchedTokens += 1;
1808
- }
1809
- }
1810
- if (queryTokens.length && matchedTokens === queryTokens.length) {
1811
- score += Math.min(18, queryTokens.length * 6);
1812
- reasons.push('all-tokens');
1813
- }
1814
- if (clean(row?.kind).toLowerCase() === raw) score += 10;
1815
- if (score > 0) score += Math.min(6, Math.floor(measuredToolUsage(name) / 150));
1816
- return { score, reasons };
1817
- }
1818
-
1819
- function toolSearchMatches(row, query) {
1820
- const raw = clean(query).toLowerCase();
1821
- if (!raw) return true;
1822
- return toolSearchRank(row, raw).score > 0;
1823
- }
1824
-
1825
- function rankedToolSearchRows(rows, query) {
1826
- const raw = clean(query).toLowerCase();
1827
- if (!raw) return rows;
1828
- return rows
1829
- .map((row) => {
1830
- const ranked = toolSearchRank(row, raw);
1831
- return { ...row, score: ranked.score, reasons: ranked.reasons };
1832
- })
1833
- .filter((row) => row.score > 0)
1834
- .sort((a, b) => {
1835
- if (b.score !== a.score) return b.score - a.score;
1836
- if (a.active !== b.active) return a.active ? 1 : -1;
1837
- if (b.usage !== a.usage) return b.usage - a.usage;
1838
- return String(a.name).localeCompare(String(b.name));
1839
- });
1840
- }
1841
-
1842
- function queryHasAnyPhrase(query, phrases) {
1843
- const text = clean(query).toLowerCase().replace(/[_-]+/g, ' ');
1844
- return phrases.some((phrase) => text.includes(phrase));
1845
- }
1846
-
1847
- function autoToolSelectionNames(query, rows) {
1848
- const raw = clean(query).toLowerCase();
1849
- if (!raw || TOOL_SEARCH_AMBIGUOUS_AUTO_QUERIES.has(raw)) return [];
1850
- if (TOOL_SEARCH_SAFE_AUTO_ALIASES.has(raw) && DEFERRED_SELECT_ALIASES[raw]) {
1851
- return DEFERRED_SELECT_ALIASES[raw];
1852
- }
1853
- if (queryHasAnyPhrase(raw, ['save memory', 'store memory', 'delete memory', 'forget memory', 'memory status', '기억 저장', '메모리 저장'])) {
1854
- return ['memory'];
1855
- }
1856
- if (queryHasAnyPhrase(raw, ['run', 'execute', 'test', 'tests', 'build', 'terminal', 'command', 'powershell', 'bash', 'shell', 'npm', 'node', 'git', '실행', '테스트', '빌드', '터미널', '쉘'])) {
1857
- return ['shell', 'task'];
1858
- }
1859
- if (queryHasAnyPhrase(raw, ['web', 'internet', 'online', 'current info', 'latest', 'news', 'browse', 'docs', 'documentation', '웹', '인터넷', '최신', '뉴스', '문서'])) {
1860
- return ['search', 'web_fetch'];
1861
- }
1862
- if (queryHasAnyPhrase(raw, ['recall', 'remember', 'memory previous', 'previous', 'history', 'past', 'prior', 'earlier', 'resume', '이전', '기억', '히스토리'])) {
1863
- return ['recall'];
1864
- }
1865
- if (queryHasAnyPhrase(raw, ['delegate', 'subagent', 'worker', 'parallel agent', 'background agent', 'reviewer', 'explorer', '에이전트', '워커', '병렬'])) {
1866
- return ['agent'];
1867
- }
1868
- if (queryHasAnyPhrase(raw, ['working directory', 'project root', 'current directory', 'cwd'])) {
1869
- return ['cwd'];
1870
- }
1871
- const ranked = rankedToolSearchRows(rows, raw);
1872
- const top = ranked[0];
1873
- if (!top) return [];
1874
- const reasons = new Set(top.reasons || []);
1875
- if (reasons.has('exact-name') || reasons.has('exact-alias')) return [top.name];
1876
- return [];
1877
- }
1878
-
1879
- function expandSelectionNames(names) {
1880
- const out = [];
1881
- for (const raw of names || []) {
1882
- const key = clean(raw);
1883
- if (!key) continue;
1884
- const alias = DEFERRED_SELECT_ALIASES[key.toLowerCase()];
1885
- if (alias) out.push(...alias);
1886
- else out.push(key);
1887
- }
1888
- return [...new Set(out)];
1889
- }
1890
-
1891
- function storedDeferredToolNames(session) {
1892
- for (const source of [session?.deferredDiscoveredTools, session?.deferredSelectedTools]) {
1893
- const names = parseToolSelection(source);
1894
- if (names.length) return names;
1895
- }
1896
- return [];
1897
- }
1898
-
1899
- function canonicalDeferredToolNames(catalog, names) {
1900
- const byName = new Map();
1901
- for (const tool of catalog || []) {
1902
- const name = clean(tool?.name);
1903
- if (!name) continue;
1904
- byName.set(name, name);
1905
- byName.set(name.toLowerCase(), name);
1906
- }
1907
- const out = [];
1908
- for (const raw of expandSelectionNames(names)) {
1909
- const name = clean(raw);
1910
- const canonical = byName.get(name) || byName.get(name.toLowerCase());
1911
- if (canonical) out.push(canonical);
1912
- }
1913
- return sortedNamesByMeasuredUsage(new Set(out));
1914
- }
1915
-
1916
- function setDeferredToolState(session, names) {
1917
- if (!session) return [];
1918
- const selected = sortedNamesByMeasuredUsage(new Set(parseToolSelection(names)));
1919
- session.deferredDiscoveredTools = selected;
1920
- session.deferredSelectedTools = selected;
1921
- return selected;
1922
- }
1923
-
1924
- function isReadonlySelectable(tool) {
1925
- const name = clean(tool?.name);
1926
- if (READONLY_TOOL_NAMES.has(name)) return true;
1927
- const annotations = tool?.annotations || {};
1928
- if (annotations.destructiveHint === true) return false;
1929
- if (annotations.readOnlyHint === true) return true;
1930
- return false;
1931
- }
1932
-
1933
- function applyDeferredToolSurface(session, mode, extraTools = [], options = {}) {
1934
- if (!session || !Array.isArray(session.tools)) return session;
1935
- const providerMode = deferredProviderMode(options.provider || session.provider);
1936
- const byName = new Map();
1937
- for (const tool of [...session.tools, ...(extraTools || [])]) {
1938
- const name = clean(tool?.name);
1939
- if (!name || byName.has(name)) continue;
1940
- byName.set(name, activeToolForSurface(tool));
1941
- }
1942
- const catalog = sortedCatalogByMeasuredUsage([...byName.values()]);
1943
- const defaultNames = defaultDeferredToolNames(catalog, mode);
1944
- const storedNames = providerMode === 'native' ? [] : storedDeferredToolNames(session);
1945
- let selectedNames = providerMode === 'full'
1946
- ? sortedNamesByMeasuredUsage(catalog.map((tool) => clean(tool?.name)).filter(Boolean))
1947
- : [];
1948
- if (providerMode !== 'full') {
1949
- selectedNames = storedNames.length ? canonicalDeferredToolNames(catalog, storedNames) : [];
1950
- if (!selectedNames.length || providerMode === 'native') selectedNames = sortedNamesByMeasuredUsage(defaultNames);
1951
- }
1952
- const selected = new Set(selectedNames);
1953
- session.deferredToolCatalog = catalog;
1954
- session.deferredToolUsage = MEASURED_TOOL_USAGE;
1955
- session.deferredDefaultTools = sortedNamesByMeasuredUsage(defaultNames);
1956
- session.deferredProviderMode = providerMode;
1957
- session.deferredNativeTools = providerMode === 'native';
1958
- session.tools.length = 0;
1959
- const active = [];
1960
- for (const tool of catalog) {
1961
- if (!selected.has(clean(tool?.name))) continue;
1962
- if (mode === 'readonly' && !isReadonlySelectable(tool)) continue;
1963
- session.tools.push(tool);
1964
- active.push(clean(tool?.name));
1965
- }
1966
- if (providerMode === 'native') {
1967
- const discovered = canonicalDeferredToolNames(catalog, session.deferredDiscoveredTools || []);
1968
- session.deferredSelectedTools = active;
1969
- session.deferredDiscoveredTools = discovered.filter((name) => !selected.has(name));
1970
- } else {
1971
- setDeferredToolState(session, active);
1972
- }
1973
- return session;
1974
- }
1975
-
1976
- function selectDeferredTools(session, names, mode) {
1977
- const catalog = Array.isArray(session?.deferredToolCatalog)
1978
- ? session.deferredToolCatalog
1979
- : (Array.isArray(session?.tools) ? session.tools : []);
1980
- const active = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
1981
- const native = session?.deferredProviderMode === 'native' || session?.deferredNativeTools === true;
1982
- const discovered = new Set(Array.isArray(session?.deferredDiscoveredTools) ? session.deferredDiscoveredTools : []);
1983
- const byName = new Map();
1984
- for (const tool of catalog) {
1985
- const name = clean(tool?.name);
1986
- if (!name) continue;
1987
- byName.set(name, tool);
1988
- byName.set(name.toLowerCase(), tool);
1989
- }
1990
- const added = [];
1991
- const already = [];
1992
- const blocked = [];
1993
- const missing = [];
1994
- for (const rawName of expandSelectionNames(names)) {
1995
- const requestedName = clean(rawName);
1996
- const tool = byName.get(requestedName) || byName.get(requestedName.toLowerCase());
1997
- const name = clean(tool?.name);
1998
- if (!tool) {
1999
- missing.push(requestedName);
2000
- continue;
2001
- }
2002
- if (mode === 'readonly' && !isReadonlySelectable(tool)) {
2003
- blocked.push({ name, reason: 'readonly mode' });
2004
- continue;
2005
- }
2006
- if (active.has(name) || discovered.has(name)) {
2007
- already.push(name);
2008
- continue;
2009
- }
2010
- if (native) {
2011
- discovered.add(name);
2012
- } else {
2013
- session.tools.push(tool);
2014
- active.add(name);
2015
- }
2016
- added.push(name);
2017
- }
2018
- if (native) {
2019
- session.deferredDiscoveredTools = sortedNamesByMeasuredUsage(discovered);
2020
- session.deferredSelectedTools = sortedNamesByMeasuredUsage(active);
2021
- } else {
2022
- setDeferredToolState(session, active);
2023
- }
2024
- return { added, already, blocked, missing, native };
2025
- }
2026
-
2027
- function renderToolSearch(args = {}, session, mode = 'full') {
2028
- const catalog = Array.isArray(session?.deferredToolCatalog)
2029
- ? session.deferredToolCatalog
2030
- : (Array.isArray(session?.tools) ? session.tools : []);
2031
- const rawQuery = clean(args.query);
2032
- const explicitSelectedNames = parseToolSelection(args.select);
2033
- const querySelectedNames = explicitSelectedNames.length ? [] : parseToolSearchQuerySelection(rawQuery);
2034
- const forcedSelectedNames = explicitSelectedNames.length ? explicitSelectedNames : querySelectedNames;
2035
- const query = querySelectedNames.length ? '' : rawQuery.toLowerCase();
2036
- const limit = Math.max(1, Math.min(50, Number(args.limit) || 20));
2037
- const initialActiveNames = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
2038
- const initialRows = catalog.map((tool) => toolRow(tool, initialActiveNames)).filter((row) => row.name);
2039
- const autoSelectedNames = (!forcedSelectedNames.length && query)
2040
- ? autoToolSelectionNames(query, initialRows)
2041
- : [];
2042
- const selectedNames = forcedSelectedNames.length ? forcedSelectedNames : autoSelectedNames;
2043
- const toolSelection = selectedNames.length ? selectDeferredTools(session, selectedNames, mode) : null;
2044
- const selectionMode = forcedSelectedNames.length ? 'select' : (autoSelectedNames.length ? 'auto' : null);
2045
- const nextActiveNames = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
2046
- const rows = [
2047
- ...catalog.map((tool) => toolRow(tool, nextActiveNames)),
2048
- ].filter((row) => row.name);
2049
- const matches = query
2050
- ? rankedToolSearchRows(rows, query)
2051
- : rows;
2052
- const selected = toolSelection
2053
- ? {
2054
- mode: selectionMode,
2055
- tools: toolSelection,
2056
- }
2057
- : null;
2058
- const nativeToolSearch = toolSelection?.native
2059
- ? toolSearchNativePayload(catalog, toolSelection.added, session?.provider)
2060
- : null;
2061
- return JSON.stringify({
2062
- selected,
2063
- ...(nativeToolSearch ? { nativeToolSearch } : {}),
2064
- totalMatches: matches.length,
2065
- matches: matches.slice(0, limit),
2066
- activeTools: sortedNamesByMeasuredUsage(nextActiveNames),
2067
- discoveredTools: sortedNamesByMeasuredUsage(session?.deferredDiscoveredTools || []),
2068
- note: 'query ranks matches and auto-loads high-confidence deferred tools; select exact tool names, or query select:a,b, to force load.',
2069
- }, null, 2);
2070
- }
419
+ // Workflow/agent pack loaders bound to this runtime's root/data layout.
420
+ const {
421
+ listWorkflowPacks,
422
+ activeWorkflowId,
423
+ loadWorkflowPack,
424
+ workflowSummary,
425
+ activeWorkflowSummary,
426
+ loadAgentDefinition,
427
+ workflowContextBlock,
428
+ } = createWorkflowHelpers({
429
+ rootDir: STANDALONE_ROOT,
430
+ dataDir: STANDALONE_DATA_DIR,
431
+ readMarkdownDocument,
432
+ normalizeAgentPermissionOrNone,
433
+ });
434
+ const {
435
+ summarizeWorkflowRoutes,
436
+ routeFromPreset,
437
+ agentRouteFromConfig,
438
+ } = createWorkflowRouteHelpers({ resolveDefaultProvider, findPreset });
2071
439
 
2072
440
  export function __renderToolSearchForTest(args = {}, session = {}, mode = 'full') {
2073
441
  return renderToolSearch(args, session, mode);
@@ -2881,6 +1249,11 @@ export async function createMixdogSessionRuntime({
2881
1249
  }
2882
1250
  } catch {}
2883
1251
  }
1252
+ // CwdChanged: bridge an effective cwd switch to the standard hook bus.
1253
+ // No matcher event — payload is minimal { cwd }. Fire-and-forget.
1254
+ if (changed) {
1255
+ try { void hooks.dispatch('CwdChanged', hookCommonPayload({ cwd: currentCwd })); } catch {}
1256
+ }
2884
1257
  return currentCwd;
2885
1258
  }
2886
1259
 
@@ -2964,7 +1337,8 @@ export async function createMixdogSessionRuntime({
2964
1337
  mcpScript: mcpScriptForPlugin(root),
2965
1338
  };
2966
1339
  plugin.mcpServerName = pluginMcpServerName(plugin);
2967
- plugin.mcpEnabled = Object.prototype.hasOwnProperty.call(configuredMcp, plugin.mcpServerName);
1340
+ plugin.mcpEnabled = Object.prototype.hasOwnProperty.call(configuredMcp, plugin.mcpServerName)
1341
+ || Object.keys(configuredMcp).some((k) => k.startsWith(`${plugin.mcpServerName}--`));
2968
1342
  plugins.push(plugin);
2969
1343
  };
2970
1344
 
@@ -2986,17 +1360,18 @@ export async function createMixdogSessionRuntime({
2986
1360
  }
2987
1361
 
2988
1362
  // Merge mixdog-config `agent.mcpServers` with project-local `.mcp.json`.
2989
- // On name collision the mixdog-config entry WINS. `sources[name]` records
2990
- // each server's origin ('config' | 'project') for status reporting.
1363
+ // On name collision the project-local `.mcp.json` entry WINS (Claude Code
1364
+ // precedence: project > user config). `sources[name]` records each server's
1365
+ // origin ('config' | 'project') for status reporting.
2991
1366
  function resolveEffectiveMcpServers() {
2992
1367
  const configured = config?.mcpServers && typeof config.mcpServers === 'object'
2993
1368
  ? config.mcpServers
2994
1369
  : {};
2995
1370
  const project = readProjectMcpServers(currentCwd);
2996
- const servers = { ...project, ...configured };
1371
+ const servers = { ...configured, ...project };
2997
1372
  const sources = {};
2998
- for (const name of Object.keys(project)) sources[name] = 'project';
2999
1373
  for (const name of Object.keys(configured)) sources[name] = 'config';
1374
+ for (const name of Object.keys(project)) sources[name] = 'project';
3000
1375
  return { servers, sources };
3001
1376
  }
3002
1377
 
@@ -3100,6 +1475,18 @@ export async function createMixdogSessionRuntime({
3100
1475
  mgr,
3101
1476
  dataDir: cfgMod.getPluginData(),
3102
1477
  cwd,
1478
+ // SubagentStart/SubagentStop: bridge internal worker spawn/finish to the
1479
+ // standard hook bus. agent_type is passed top-level via hookCommonPayload
1480
+ // (added to hook-bus buildEventPayload passthrough). Best-effort.
1481
+ onSubagentEvent: (phase, info = {}) => {
1482
+ try {
1483
+ const event = phase === 'stop' ? 'SubagentStop' : 'SubagentStart';
1484
+ void hooks.dispatch(event, hookCommonPayload({
1485
+ session_id: info?.session_id || null,
1486
+ agent_type: info?.agent_type || null,
1487
+ }));
1488
+ } catch { /* best-effort: subagent hook must never affect worker lifecycle */ }
1489
+ },
3103
1490
  });
3104
1491
  bootProfile('agent:ready', { ms: (performance.now() - agentToolStartedAt).toFixed(1) });
3105
1492
  const agentStatusState = () => {
@@ -3308,6 +1695,58 @@ export async function createMixdogSessionRuntime({
3308
1695
  let pendingConfigToSave = null;
3309
1696
  let configSaveTimer = null;
3310
1697
  const CONFIG_SAVE_DEBOUNCE_MS = 150;
1698
+ // Same debounce pattern as saveConfigAndAdopt below, but for the channels-
1699
+ // section backend switch: channel-admin's setBackend() does a synchronous
1700
+ // file-locked read-modify-write (updateChannelsSection), which is a
1701
+ // secondary hitch source on the settings-toggle key handler. Coalesce
1702
+ // rapid toggles and move the write off the key-handler tick.
1703
+ let pendingBackendName = null;
1704
+ let backendSaveTimer = null;
1705
+ function flushBackendSave() {
1706
+ if (backendSaveTimer) {
1707
+ clearTimeout(backendSaveTimer);
1708
+ backendSaveTimer = null;
1709
+ }
1710
+ if (pendingBackendName === null) return;
1711
+ const name = pendingBackendName;
1712
+ pendingBackendName = null;
1713
+ try {
1714
+ setBackend(name);
1715
+ } catch (err) {
1716
+ process.stderr.write(`[channels] debounced setBackend failed: ${err?.message || err}\n`);
1717
+ }
1718
+ }
1719
+ // Debounced persist for the top-level `outputStyle` config key. This CANNOT
1720
+ // ride saveConfigAndAdopt/flushConfigSave: cfgMod.saveConfig() serializes
1721
+ // ONLY the agent-section fields (see orchestrator config.mjs saveConfig),
1722
+ // so a top-level outputStyle adopted in memory would never reach disk and
1723
+ // would silently revert on the next fresh catalog read/restart. Persist it
1724
+ // through sharedCfgMod.updateConfig (whole-root RMW under the same file
1725
+ // lock), debounced off the key-handler tick like the backend switch above.
1726
+ let pendingOutputStyleId = null;
1727
+ let outputStyleSaveTimer = null;
1728
+ function flushOutputStyleSave() {
1729
+ if (outputStyleSaveTimer) {
1730
+ clearTimeout(outputStyleSaveTimer);
1731
+ outputStyleSaveTimer = null;
1732
+ }
1733
+ if (pendingOutputStyleId === null) return;
1734
+ const styleId = pendingOutputStyleId;
1735
+ pendingOutputStyleId = null;
1736
+ try {
1737
+ sharedCfgMod.updateConfig((root) => {
1738
+ const next = { ...(root || {}), outputStyle: styleId };
1739
+ if (next.agent && typeof next.agent === 'object' && !Array.isArray(next.agent)) {
1740
+ const agent = { ...next.agent };
1741
+ delete agent.outputStyle;
1742
+ next.agent = agent;
1743
+ }
1744
+ return next;
1745
+ });
1746
+ } catch (err) {
1747
+ process.stderr.write(`[config] debounced outputStyle save failed: ${err?.message || err}\n`);
1748
+ }
1749
+ }
3311
1750
  function flushConfigSave() {
3312
1751
  if (configSaveTimer) {
3313
1752
  clearTimeout(configSaveTimer);
@@ -4022,11 +2461,61 @@ function parsedProviderModelVersion(id) {
4022
2461
  configurable: true,
4023
2462
  writable: true,
4024
2463
  });
2464
+ // PostToolUseFailure: dispatched by loop.mjs only when a tool execution
2465
+ // resolved to a failure (thrown-error path or an is_error result). Same
2466
+ // shape as afterToolHook; `result` carries the error text. Best-effort.
2467
+ Object.defineProperty(session, 'afterToolFailureHook', {
2468
+ value: (input) => hooks.dispatch('PostToolUseFailure', hookCommonPayload({
2469
+ session_id: input?.sessionId || input?.session_id || session?.id,
2470
+ cwd: input?.cwd || currentCwd,
2471
+ tool_name: input?.name,
2472
+ tool_input: input?.args,
2473
+ tool_use_id: input?.toolCallId || input?.tool_use_id,
2474
+ tool_response: input?.result,
2475
+ })),
2476
+ enumerable: false,
2477
+ configurable: true,
2478
+ writable: true,
2479
+ });
2480
+ // PostToolBatch: dispatched by loop.mjs after a full parallel batch of
2481
+ // tool calls resolves and before the next model call. No matcher event.
2482
+ Object.defineProperty(session, 'afterToolBatchHook', {
2483
+ value: (input) => hooks.dispatch('PostToolBatch', hookCommonPayload({
2484
+ session_id: input?.sessionId || input?.session_id || session?.id,
2485
+ cwd: input?.cwd || currentCwd,
2486
+ })),
2487
+ enumerable: false,
2488
+ configurable: true,
2489
+ writable: true,
2490
+ });
2491
+ // PreCompact / PostCompact: dispatched by manager.mjs/loop.mjs compaction
2492
+ // flow via these session-property hooks (manager has no hooks bus access).
2493
+ // payload { trigger: 'auto' | 'manual' }. Best-effort.
2494
+ Object.defineProperty(session, 'preCompactHook', {
2495
+ value: (input) => hooks.dispatch('PreCompact', hookCommonPayload({
2496
+ session_id: input?.sessionId || input?.session_id || session?.id,
2497
+ cwd: input?.cwd || currentCwd,
2498
+ trigger: input?.trigger || 'auto',
2499
+ })),
2500
+ enumerable: false,
2501
+ configurable: true,
2502
+ writable: true,
2503
+ });
2504
+ Object.defineProperty(session, 'postCompactHook', {
2505
+ value: (input) => hooks.dispatch('PostCompact', hookCommonPayload({
2506
+ session_id: input?.sessionId || input?.session_id || session?.id,
2507
+ cwd: input?.cwd || currentCwd,
2508
+ trigger: input?.trigger || 'auto',
2509
+ })),
2510
+ enumerable: false,
2511
+ configurable: true,
2512
+ writable: true,
2513
+ });
4025
2514
  applyDeferredToolSurface(session, deferredSurfaceModeForLead(mode), standaloneTools, { provider: route.provider });
4026
2515
  applyPreSessionToolSelection();
4027
2516
  writeStatuslineRoute(statusRoutes, session, route);
4028
2517
  hooks.emit('session:create', { sessionId: session.id, provider: route.provider, model: route.model, toolMode: mode, cwd: currentCwd });
4029
- // SessionStart: bridge to the standard Claude Code hook bus. Best-effort;
2518
+ // SessionStart: bridge to the standard project hook bus. Best-effort;
4030
2519
  // a hook error must never break session creation. additionalContext is
4031
2520
  // injected before the first user turn as a system-reminder context pair.
4032
2521
  try {
@@ -4266,6 +2755,10 @@ function parsedProviderModelVersion(id) {
4266
2755
  // boots the channel worker and contends for channel ownership.
4267
2756
  function startRemote() {
4268
2757
  remoteEnabled = true;
2758
+ // A backend switch may still be sitting in its debounce window; flush it
2759
+ // so the channel worker boots against the backend the user just chose,
2760
+ // not the previous on-disk value.
2761
+ try { flushBackendSave(); } catch {}
4269
2762
  if (envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
4270
2763
  bootProfile('channels:start-skipped');
4271
2764
  return true;
@@ -4897,6 +3390,10 @@ function parsedProviderModelVersion(id) {
4897
3390
  return this.getOnboardingStatus();
4898
3391
  },
4899
3392
  getChannelSetup() {
3393
+ // Flush a pending debounced backend switch first so setup readers
3394
+ // (Settings → Channel Setting, remote toggles) never observe the
3395
+ // previous backend during the 150ms debounce window.
3396
+ try { flushBackendSave(); } catch {}
4900
3397
  return channelSetup();
4901
3398
  },
4902
3399
  getChannelWorkerStatus() {
@@ -4932,8 +3429,21 @@ function parsedProviderModelVersion(id) {
4932
3429
  return result;
4933
3430
  },
4934
3431
  setBackend(name) {
4935
- const result = setBackend(name);
4936
- return result;
3432
+ // Validate synchronously (same contract as before: bad input throws on
3433
+ // this call so the TUI's try/catch can react immediately). The actual
3434
+ // channels-section file read-modify-write is the hitch source on the
3435
+ // settings-toggle key handler, so defer it through the same debounce
3436
+ // pattern as saveConfigAndAdopt/flushConfigSave instead of writing to
3437
+ // disk synchronously inside the key handler.
3438
+ const value = String(name || '').trim();
3439
+ if (value !== 'discord' && value !== 'telegram') {
3440
+ throw new Error('backend must be discord or telegram');
3441
+ }
3442
+ pendingBackendName = value;
3443
+ if (backendSaveTimer) clearTimeout(backendSaveTimer);
3444
+ backendSaveTimer = setTimeout(flushBackendSave, CONFIG_SAVE_DEBOUNCE_MS);
3445
+ backendSaveTimer.unref?.();
3446
+ return { ok: true, backend: value };
4937
3447
  },
4938
3448
  saveWebhookAuthtoken(token) {
4939
3449
  const result = saveWebhookAuthtoken(token);
@@ -5047,6 +3557,12 @@ function parsedProviderModelVersion(id) {
5047
3557
  invalidateProviderCaches();
5048
3558
  return result;
5049
3559
  },
3560
+ async loginOpenCodeGoUsage() {
3561
+ const result = await loginOpenCodeGoUsage(cfgMod);
3562
+ reloadFullConfig();
3563
+ invalidateProviderCaches();
3564
+ return result;
3565
+ },
5050
3566
  setLocalProvider(providerId, opts) {
5051
3567
  const result = setLocalProvider(cfgMod, providerId, opts);
5052
3568
  reloadFullConfig();
@@ -5157,26 +3673,51 @@ function parsedProviderModelVersion(id) {
5157
3673
  const names = before.styles.map((style) => style.label || style.id).join(', ') || 'Default';
5158
3674
  throw new Error(`output style must be one of ${names}`);
5159
3675
  }
5160
- if (typeof sharedCfgMod.updateConfig !== 'function') throw new Error('output style config writer unavailable');
5161
- sharedCfgMod.updateConfig((root) => {
5162
- const next = { ...(root || {}), outputStyle: selected.id };
5163
- if (next.agent && typeof next.agent === 'object' && !Array.isArray(next.agent)) {
5164
- const agent = { ...next.agent };
5165
- delete agent.outputStyle;
5166
- next.agent = agent;
5167
- }
5168
- return next;
5169
- });
5170
- invalidateOutputStyleStatusCache();
3676
+ // Adopt in-memory immediately so same-tick readers see the new style;
3677
+ // persist off the key-handler tick. NOTE: the disk write goes through
3678
+ // the dedicated flushOutputStyleSave debounce (sharedCfgMod whole-root
3679
+ // RMW) cfgMod.saveConfig only serializes agent-section fields and
3680
+ // would drop the top-level outputStyle key.
3681
+ const nextConfig = { ...config, outputStyle: selected.id };
3682
+ if (nextConfig.agent && typeof nextConfig.agent === 'object' && !Array.isArray(nextConfig.agent)) {
3683
+ const agent = { ...nextConfig.agent };
3684
+ delete agent.outputStyle;
3685
+ nextConfig.agent = agent;
3686
+ }
3687
+ adoptConfig(nextConfig);
3688
+ pendingOutputStyleId = selected.id;
3689
+ if (outputStyleSaveTimer) clearTimeout(outputStyleSaveTimer);
3690
+ outputStyleSaveTimer = setTimeout(flushOutputStyleSave, CONFIG_SAVE_DEBOUNCE_MS);
3691
+ outputStyleSaveTimer.unref?.();
3692
+ // Reuse the catalog already scanned above for `before` instead of a
3693
+ // second forced-fresh filesystem scan; refresh the status cache
3694
+ // in-memory (short TTL, same as getOutputStyleStatusCached) so reads
3695
+ // during the debounce window see the just-selected style.
3696
+ const freshStatus = { configured: selected.id, current: selected, styles: before.styles };
3697
+ outputStyleStatusCache = freshStatus;
3698
+ outputStyleStatusCacheAt = performance.now();
3699
+ outputStyleStatusCacheDir = resolve(cfgMod.getPluginData?.() || STANDALONE_DATA_DIR);
5171
3700
  const hasConversation = sessionHasConversationMessages(session);
5172
3701
  let appliedToCurrentSession = !hasConversation;
5173
3702
  if (session?.id && !hasConversation) {
5174
- mgr.closeSession(session.id, 'cli-output-style-switch');
3703
+ const closedSessionId = session.id;
3704
+ mgr.closeSession(closedSessionId, 'cli-output-style-switch');
5175
3705
  session = null;
5176
- await recreateCurrentSessionIfReady();
3706
+ // Defer the recreate so the output-style picker can repaint first;
3707
+ // any failure still surfaces via the existing notice path.
3708
+ setTimeout(() => {
3709
+ recreateCurrentSessionIfReady().catch((err) => {
3710
+ try {
3711
+ notifyFnForSession(closedSessionId)(
3712
+ `Failed to start a new session after output style change: ${err?.message || err}`,
3713
+ { level: 'error' },
3714
+ );
3715
+ } catch {}
3716
+ });
3717
+ }, 0);
5177
3718
  }
5178
3719
  invalidateContextStatusCache();
5179
- return { ...getOutputStyleStatusCached({ fresh: true }), appliedToCurrentSession };
3720
+ return { ...freshStatus, appliedToCurrentSession };
5180
3721
  },
5181
3722
  async setWorkflow(workflowId) {
5182
3723
  const id = normalizeWorkflowId(workflowId, DEFAULT_WORKFLOW_ID);
@@ -5389,6 +3930,18 @@ function parsedProviderModelVersion(id) {
5389
3930
  return { result, session };
5390
3931
  } catch (error) {
5391
3932
  hooks.emit('turn:error', { sessionId: session?.id || null, elapsedMs: Date.now() - startedAt, error: error?.message || String(error) });
3933
+ // StopFailure: bridge a turn error to the standard hook bus. Spec:
3934
+ // output + exit code ignored, so pure fire-and-forget. error_type is a
3935
+ // simple regex mapping from the error message (default 'unknown').
3936
+ try {
3937
+ const msg = String(error?.message || error || '').toLowerCase();
3938
+ const errorType = /rate.?limit|429|too many requests/.test(msg) ? 'rate_limit'
3939
+ : /overloaded|529/.test(msg) ? 'overloaded'
3940
+ : /authenticat|unauthorized|401|invalid.*api.?key/.test(msg) ? 'authentication_failed'
3941
+ : /server.?error|5\d\d|internal error/.test(msg) ? 'server_error'
3942
+ : 'unknown';
3943
+ void hooks.dispatch('StopFailure', hookCommonPayload({ session_id: session?.id || null, error_type: errorType }));
3944
+ } catch { /* best-effort: StopFailure hook must never break teardown */ }
5392
3945
  throw error;
5393
3946
  } finally {
5394
3947
  activeTurnCount = Math.max(0, activeTurnCount - 1);
@@ -5415,7 +3968,14 @@ function parsedProviderModelVersion(id) {
5415
3968
  if (activeTurnCount > 0) {
5416
3969
  return { changed: false, reason: 'compact skipped: turn in progress' };
5417
3970
  }
3971
+ // Manual compact bypasses loop.mjs, so its PreCompact/PostCompact never
3972
+ // fire here — dispatch them explicitly via the session-property hooks
3973
+ // (wired at session create). Best-effort; a hook must not block compaction.
3974
+ try { await session.preCompactHook?.({ trigger: 'manual' }); }
3975
+ catch { /* best-effort: PreCompact hook must never break manual compact */ }
5418
3976
  const result = await mgr.compactSessionMessages(session.id);
3977
+ try { await session.postCompactHook?.({ trigger: 'manual' }); }
3978
+ catch { /* best-effort: PostCompact hook must never break manual compact */ }
5419
3979
  session = mgr.getSession(session.id) || session;
5420
3980
  if (options.recoverAgent === true) {
5421
3981
  try { agentTool.recoverWorkers?.({ clientHostPid: session?.clientHostPid || process.pid }); } catch {}
@@ -5587,9 +4147,15 @@ function parsedProviderModelVersion(id) {
5587
4147
  const removed = registryRemovePlugin(key, { dataDir });
5588
4148
  const nextConfig = { ...config };
5589
4149
  const serverName = pluginMcpServerName(plugin);
5590
- if (nextConfig.mcpServers && Object.prototype.hasOwnProperty.call(nextConfig.mcpServers, serverName)) {
4150
+ const prefix = `${serverName}--`;
4151
+ const hasMatch = nextConfig.mcpServers && Object.keys(nextConfig.mcpServers).some(
4152
+ (k) => k === serverName || k.startsWith(prefix)
4153
+ );
4154
+ if (hasMatch) {
5591
4155
  const current = { ...nextConfig.mcpServers };
5592
- delete current[serverName];
4156
+ for (const k of Object.keys(current)) {
4157
+ if (k === serverName || k.startsWith(prefix)) delete current[k];
4158
+ }
5593
4159
  saveConfigAndAdopt({ ...nextConfig, mcpServers: current });
5594
4160
  await connectConfiguredMcp({ reset: true });
5595
4161
  invalidatePreSessionToolSurface();
@@ -5602,22 +4168,50 @@ function parsedProviderModelVersion(id) {
5602
4168
  const root = clean(plugin.root);
5603
4169
  const script = clean(plugin.mcpScript);
5604
4170
  if (!root || !script) throw new Error('plugin has no MCP script');
5605
- const scriptPath = join(root, script);
5606
- if (!existsSync(scriptPath)) throw new Error(`plugin MCP script not found: ${scriptPath}`);
5607
4171
  const serverName = pluginMcpServerName(plugin);
5608
4172
  const nextConfig = { ...config };
5609
- nextConfig.mcpServers = {
5610
- ...(nextConfig.mcpServers || {}),
5611
- [serverName]: {
5612
- command: 'node',
5613
- args: [scriptPath],
5614
- cwd: root,
5615
- env: {
4173
+ if (script === '.mcp.json') {
4174
+ const mcpJsonPath = join(root, script);
4175
+ if (!existsSync(mcpJsonPath)) throw new Error(`plugin MCP manifest not found: ${mcpJsonPath}`);
4176
+ const manifest = readJsonSafe(mcpJsonPath) || {};
4177
+ const rawServers = manifest.mcpServers;
4178
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
4179
+ if (!isPlainObject(rawServers)) throw new Error(`plugin .mcp.json missing mcpServers object: ${mcpJsonPath}`);
4180
+ const keys = Object.keys(rawServers).filter((k) => isPlainObject(rawServers[k]));
4181
+ if (!keys.length) throw new Error(`plugin .mcp.json has no mcpServers: ${mcpJsonPath}`);
4182
+ const ownedPrefix = `${serverName}--`;
4183
+ const nextServers = {};
4184
+ for (const [k, v] of Object.entries(nextConfig.mcpServers || {})) {
4185
+ if (k === serverName || k.startsWith(ownedPrefix)) continue;
4186
+ nextServers[k] = v;
4187
+ }
4188
+ for (const serverKey of keys) {
4189
+ const cfg = normalizePluginMcpServerConfig(rawServers[serverKey], root);
4190
+ cfg.env = {
4191
+ ...(cfg.env || {}),
5616
4192
  MIXDOG_PLUGIN_ROOT: root,
5617
4193
  MIXDOG_PLUGIN_DATA: join(cfgMod.getPluginData?.() || STANDALONE_DATA_DIR, 'plugins', 'data', clean(plugin.id || plugin.name || serverName)),
4194
+ };
4195
+ const key = keys.length === 1 ? serverName : `${serverName}--${serverKey}`;
4196
+ nextServers[key] = cfg;
4197
+ }
4198
+ nextConfig.mcpServers = nextServers;
4199
+ } else {
4200
+ const scriptPath = join(root, script);
4201
+ if (!existsSync(scriptPath)) throw new Error(`plugin MCP script not found: ${scriptPath}`);
4202
+ nextConfig.mcpServers = {
4203
+ ...(nextConfig.mcpServers || {}),
4204
+ [serverName]: {
4205
+ command: 'node',
4206
+ args: [scriptPath],
4207
+ cwd: root,
4208
+ env: {
4209
+ MIXDOG_PLUGIN_ROOT: root,
4210
+ MIXDOG_PLUGIN_DATA: join(cfgMod.getPluginData?.() || STANDALONE_DATA_DIR, 'plugins', 'data', clean(plugin.id || plugin.name || serverName)),
4211
+ },
5618
4212
  },
5619
- },
5620
- };
4213
+ };
4214
+ }
5621
4215
  saveConfigAndAdopt(nextConfig);
5622
4216
  const status = await connectConfiguredMcp({ reset: true });
5623
4217
  invalidatePreSessionToolSurface();
@@ -5801,10 +4395,28 @@ function parsedProviderModelVersion(id) {
5801
4395
  async close(reason = 'cli-exit', options = {}) {
5802
4396
  const detach = options?.detach === true || options?.wait === false || options?.waitForExit === false;
5803
4397
  closeRequested = true;
4398
+ // SessionEnd: bridge teardown to the standard hook bus. reason mapped to
4399
+ // standard values ('clear'/'exit' where applicable, else 'other'). Short
4400
+ // await guard so a slow hook cannot wedge teardown; best-effort.
4401
+ try {
4402
+ const rl = String(reason || '').toLowerCase();
4403
+ const endReason = /clear/.test(rl) ? 'clear'
4404
+ : /exit|quit|cli-exit|shutdown|sigint|sigterm/.test(rl) ? 'exit'
4405
+ : 'other';
4406
+ if (session?.id) {
4407
+ await withTeardownDeadline(
4408
+ Promise.resolve(hooks.dispatch('SessionEnd', hookCommonPayload({ session_id: session.id, reason: endReason }))).catch(() => {}),
4409
+ 300,
4410
+ undefined,
4411
+ );
4412
+ }
4413
+ } catch { /* best-effort: SessionEnd hook must never wedge teardown */ }
5804
4414
  // Persist any change that is still sitting in the debounce window so a
5805
4415
  // toggle made right before exit is not lost. Synchronous + best-effort:
5806
4416
  // teardown must continue even if the final write fails.
5807
4417
  try { flushConfigSave(); } catch {}
4418
+ try { flushBackendSave(); } catch {}
4419
+ try { flushOutputStyleSave(); } catch {}
5808
4420
  if (channelStartTimer) {
5809
4421
  clearTimeout(channelStartTimer);
5810
4422
  channelStartTimer = null;