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
@@ -0,0 +1,358 @@
1
+ // Workflow/agent pack loading + route resolution, and search-route
2
+ // normalization. Roots/dataDir and config-dependent helpers are injected to
3
+ // keep this module free of the runtime's path/provider constants.
4
+ import { basename, join } from 'node:path';
5
+ import { existsSync, readdirSync } from 'node:fs';
6
+ import { clean } from './session-text.mjs';
7
+ import { normalizeEffortInput } from './effort.mjs';
8
+ import { isLikelyRawModelId } from './config-helpers.mjs';
9
+ import { readTextSafe, readJsonSafe } from './fs-utils.mjs';
10
+
11
+ export const WORKFLOW_ROUTE_SLOTS = ['lead', 'agent', 'explorer', 'memory'];
12
+ export const FIXED_AGENT_SLOTS = Object.freeze([
13
+ { id: 'explore', label: 'Explore', description: 'Broad repository exploration', workflowSlot: 'explorer' },
14
+ { id: 'maintainer', label: 'Maintainer', description: 'Background memory and upkeep', workflowSlot: 'memory' },
15
+ { id: 'worker', label: 'Worker', description: 'Scoped implementation' },
16
+ { id: 'heavy-worker', label: 'Heavy Worker', description: 'Broad or multi-file implementation' },
17
+ { id: 'reviewer', label: 'Reviewer', description: 'Diff review and risk checks' },
18
+ { id: 'debugger', label: 'Debugger', description: 'Root-cause analysis and failure tracing' },
19
+ ]);
20
+ const AGENT_ROLE_IDS = new Set(FIXED_AGENT_SLOTS.map((agent) => agent.id));
21
+ const DEFAULT_WORKFLOW_ID = 'default';
22
+
23
+ const SEARCH_CAPABLE_PROVIDERS = new Set([
24
+ 'openai-oauth', 'openai', 'grok-oauth', 'xai', 'gemini', 'anthropic', 'anthropic-oauth',
25
+ ]);
26
+ export const SEARCH_DEFAULT_PROVIDER = 'default';
27
+ export const SEARCH_DEFAULT_MODEL = 'default';
28
+ const SEARCH_PROVIDER_ALIASES = Object.freeze({
29
+ 'openai-api': 'openai',
30
+ 'xai-api': 'xai',
31
+ 'gemini-api': 'gemini',
32
+ 'anthropic-api': 'anthropic',
33
+ });
34
+
35
+ const agentDefinitionCache = new Map();
36
+ const AGENT_DEFINITION_CACHE_LIMIT = 64;
37
+ function setAgentDefinitionCache(key, value) {
38
+ if (!agentDefinitionCache.has(key) && agentDefinitionCache.size >= AGENT_DEFINITION_CACHE_LIMIT) {
39
+ const oldestKey = agentDefinitionCache.keys().next().value;
40
+ agentDefinitionCache.delete(oldestKey);
41
+ }
42
+ agentDefinitionCache.set(key, value);
43
+ }
44
+
45
+ export function workflowPresetId(slot) {
46
+ return `workflow-${slot}`;
47
+ }
48
+
49
+ function workflowPresetName(slot) {
50
+ return `WORKFLOW ${String(slot || '').toUpperCase()}`;
51
+ }
52
+
53
+ export function agentPresetSlot(agentId) {
54
+ return `agent-${String(agentId || '').replace(/[^a-z0-9_.-]+/gi, '-').toLowerCase()}`;
55
+ }
56
+
57
+ export function normalizeAgentId(value) {
58
+ const id = clean(value).toLowerCase().replace(/[\s_]+/g, '-');
59
+ if (id === 'explorer') return 'explore';
60
+ if (id === 'maint' || id === 'maintenance' || id === 'memory') return 'maintainer';
61
+ if (id === 'heavy' || id === 'heavyworker') return 'heavy-worker';
62
+ if (id === 'review') return 'reviewer';
63
+ if (id === 'debug') return 'debugger';
64
+ return AGENT_ROLE_IDS.has(id) ? id : '';
65
+ }
66
+
67
+ export function normalizeWorkflowId(value, fallback = '') {
68
+ const id = clean(value).toLowerCase().replace(/[\s_]+/g, '-');
69
+ return /^[a-z0-9][a-z0-9_.-]*$/.test(id) ? id : fallback;
70
+ }
71
+
72
+ // A workflow/agent pack loader is created per data/root layout via
73
+ // createWorkflowHelpers, and the config-aware route helpers via
74
+ // createWorkflowRouteHelpers.
75
+ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument, normalizeAgentPermissionOrNone }) {
76
+ function workflowSourceDirs(dir) {
77
+ return [
78
+ { root: join(rootDir, 'workflows'), source: 'built-in' },
79
+ { root: join(dir || dataDir, 'workflows'), source: 'user' },
80
+ ];
81
+ }
82
+
83
+ function agentSourceDirs(dir, id) {
84
+ return [
85
+ join(dir || dataDir, 'agents', id),
86
+ join(rootDir, 'agents', id),
87
+ ];
88
+ }
89
+
90
+ function readWorkflowPackFromDir(dir, source = 'built-in', dirName = '') {
91
+ const entry = 'WORKFLOW.md';
92
+ const doc = readMarkdownDocument(readTextSafe(join(dir, entry)));
93
+ const body = doc.body;
94
+ if (!body) return null;
95
+ const fm = doc.frontmatter || {};
96
+ const id = normalizeWorkflowId(clean(fm.id) || dirName || basename(dir));
97
+ if (!id) return null;
98
+ const agentsConfigured = Object.prototype.hasOwnProperty.call(fm, 'agents');
99
+ return {
100
+ id,
101
+ name: clean(fm.name) || id,
102
+ description: clean(fm.description),
103
+ entry,
104
+ agentsConfigured,
105
+ agents: agentsConfigured
106
+ ? String(fm.agents || '')
107
+ .split(',')
108
+ .map((agent) => normalizeAgentId(agent) || normalizeWorkflowId(agent))
109
+ .filter(Boolean)
110
+ : [],
111
+ body,
112
+ source,
113
+ };
114
+ }
115
+
116
+ function listWorkflowPacks(dir) {
117
+ const byId = new Map();
118
+ for (const { root, source } of workflowSourceDirs(dir)) {
119
+ if (!existsSync(root)) continue;
120
+ let entries = [];
121
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch { entries = []; }
122
+ for (const entry of entries) {
123
+ if (!entry.isDirectory()) continue;
124
+ const d = join(root, entry.name);
125
+ if (!existsSync(join(d, 'WORKFLOW.md'))) continue;
126
+ const pack = readWorkflowPackFromDir(d, source, entry.name);
127
+ if (pack) byId.set(pack.id, pack);
128
+ }
129
+ }
130
+ return [...byId.values()].sort((a, b) => {
131
+ if (a.id === DEFAULT_WORKFLOW_ID) return -1;
132
+ if (b.id === DEFAULT_WORKFLOW_ID) return 1;
133
+ return a.name.localeCompare(b.name);
134
+ });
135
+ }
136
+
137
+ function activeWorkflowId(config) {
138
+ return normalizeWorkflowId(config?.workflow?.active, DEFAULT_WORKFLOW_ID);
139
+ }
140
+
141
+ function loadWorkflowPack(dir, id) {
142
+ const wanted = normalizeWorkflowId(id, DEFAULT_WORKFLOW_ID);
143
+ for (const { root, source } of workflowSourceDirs(dir).reverse()) {
144
+ const pack = readWorkflowPackFromDir(join(root, wanted), source, wanted);
145
+ if (pack) return pack;
146
+ }
147
+ return readWorkflowPackFromDir(join(rootDir, 'workflows', DEFAULT_WORKFLOW_ID), 'built-in', DEFAULT_WORKFLOW_ID);
148
+ }
149
+
150
+ function workflowSummary(pack) {
151
+ const id = normalizeWorkflowId(pack?.id, DEFAULT_WORKFLOW_ID);
152
+ return {
153
+ id,
154
+ name: clean(pack?.name) || (id === DEFAULT_WORKFLOW_ID ? 'Default' : id),
155
+ description: clean(pack?.description),
156
+ source: clean(pack?.source),
157
+ };
158
+ }
159
+
160
+ function activeWorkflowSummary(config, dir) {
161
+ return workflowSummary(loadWorkflowPack(dir, activeWorkflowId(config)));
162
+ }
163
+
164
+ function loadAgentDefinition(dir, id) {
165
+ const agentId = normalizeAgentId(id) || normalizeWorkflowId(id);
166
+ if (!agentId) return null;
167
+ const cacheKey = `${dir || dataDir}\n${agentId}`;
168
+ if (agentDefinitionCache.has(cacheKey)) return agentDefinitionCache.get(cacheKey);
169
+ for (const d of agentSourceDirs(dir, agentId)) {
170
+ const manifest = readJsonSafe(join(d, 'agent.json')) || {};
171
+ const entry = clean(manifest.entry) || 'AGENT.md';
172
+ const doc = readMarkdownDocument(readTextSafe(join(d, entry)));
173
+ const body = doc.body;
174
+ if (!body) continue;
175
+ const definition = {
176
+ id: agentId,
177
+ name: clean(manifest.name) || FIXED_AGENT_SLOTS.find((agent) => agent.id === agentId)?.label || agentId,
178
+ description: clean(manifest.description) || FIXED_AGENT_SLOTS.find((agent) => agent.id === agentId)?.description || '',
179
+ permission: normalizeAgentPermissionOrNone(doc.frontmatter.permission),
180
+ frontmatter: doc.frontmatter,
181
+ body,
182
+ };
183
+ setAgentDefinitionCache(cacheKey, definition);
184
+ return definition;
185
+ }
186
+ const legacyDoc = readMarkdownDocument(readTextSafe(join(rootDir, 'agents', `${agentId}.md`)));
187
+ if (!legacyDoc.body) {
188
+ setAgentDefinitionCache(cacheKey, null);
189
+ return null;
190
+ }
191
+ const definition = {
192
+ id: agentId,
193
+ name: FIXED_AGENT_SLOTS.find((agent) => agent.id === agentId)?.label || agentId,
194
+ description: '',
195
+ permission: normalizeAgentPermissionOrNone(legacyDoc.frontmatter.permission),
196
+ frontmatter: legacyDoc.frontmatter,
197
+ body: legacyDoc.body,
198
+ };
199
+ setAgentDefinitionCache(cacheKey, definition);
200
+ return definition;
201
+ }
202
+
203
+ function workflowContextBlock(config, dir) {
204
+ const pack = loadWorkflowPack(dir, activeWorkflowId(config));
205
+ if (!pack) return '';
206
+ const lines = [`# Active Workflow: ${pack.name}`];
207
+ if (pack.description) lines.push(pack.description);
208
+ lines.push(pack.body);
209
+ const agentIds = pack.agentsConfigured ? pack.agents : FIXED_AGENT_SLOTS.map((agent) => agent.id);
210
+ const agentBlocks = agentIds.map((id) => loadAgentDefinition(dir, id)).filter(Boolean);
211
+ if (agentBlocks.length) {
212
+ lines.push('# Available Agents');
213
+ for (const agent of agentBlocks) {
214
+ lines.push(`## ${agent.name} (${agent.id})`);
215
+ if (agent.description) lines.push(agent.description);
216
+ lines.push(agent.body);
217
+ }
218
+ }
219
+ return lines.join('\n\n');
220
+ }
221
+
222
+ return {
223
+ listWorkflowPacks,
224
+ activeWorkflowId,
225
+ loadWorkflowPack,
226
+ workflowSummary,
227
+ activeWorkflowSummary,
228
+ loadAgentDefinition,
229
+ workflowContextBlock,
230
+ };
231
+ }
232
+
233
+ export function normalizeSearchProviderId(provider) {
234
+ const id = clean(provider);
235
+ return SEARCH_PROVIDER_ALIASES[id] || id;
236
+ }
237
+
238
+ export function isDefaultSearchRouteConfig(routeLike = {}) {
239
+ return normalizeSearchProviderId(routeLike?.provider) === SEARCH_DEFAULT_PROVIDER
240
+ && clean(routeLike?.model).toLowerCase() === SEARCH_DEFAULT_MODEL;
241
+ }
242
+
243
+ export function isSearchCapableProvider(provider) {
244
+ return SEARCH_CAPABLE_PROVIDERS.has(normalizeSearchProviderId(provider));
245
+ }
246
+
247
+ export function normalizeSearchRouteConfig(routeLike, fallback = {}) {
248
+ const provider = normalizeSearchProviderId(routeLike?.provider || fallback.provider);
249
+ const model = clean(routeLike?.model || fallback.model);
250
+ if (!provider || !model) return null;
251
+ let effort = null;
252
+ try {
253
+ effort = normalizeEffortInput(routeLike?.effort ?? fallback.effort);
254
+ } catch {
255
+ effort = null;
256
+ }
257
+ const fast = routeLike?.fast ?? fallback.fast;
258
+ const toolType = clean(routeLike?.toolType || fallback.toolType);
259
+ return {
260
+ provider,
261
+ model,
262
+ ...(effort ? { effort } : {}),
263
+ ...(fast === true ? { fast: true } : {}),
264
+ ...(toolType ? { toolType } : {}),
265
+ };
266
+ }
267
+
268
+ export function normalizeWorkflowRoute(routeLike, fallback = {}) {
269
+ const provider = clean(routeLike?.provider) || clean(fallback.provider);
270
+ const model = clean(routeLike?.model) || clean(fallback.model);
271
+ if (!provider || !model) return null;
272
+ // Defensive: a workflow/agent route must carry a real model id. Reject values
273
+ // that are obviously free-form text (whitespace, prose) so a bad string can
274
+ // never be persisted as a preset/workflow route.
275
+ if (!isLikelyRawModelId(model)) return null;
276
+ const effort = normalizeEffortInput(routeLike?.effort ?? fallback.effort);
277
+ const fast = routeLike?.fast ?? fallback.fast;
278
+ return {
279
+ provider,
280
+ model,
281
+ ...(effort ? { effort } : {}),
282
+ ...(fast === true ? { fast: true } : {}),
283
+ };
284
+ }
285
+
286
+ export function upsertWorkflowPreset(presets, slot, routeLike) {
287
+ const route = normalizeWorkflowRoute(routeLike);
288
+ if (!route) return presets;
289
+ const id = workflowPresetId(slot);
290
+ const preset = {
291
+ id,
292
+ name: workflowPresetName(slot),
293
+ type: 'agent',
294
+ provider: route.provider,
295
+ model: route.model,
296
+ ...(route.effort ? { effort: route.effort } : {}),
297
+ ...(route.fast === true ? { fast: true } : {}),
298
+ tools: 'full',
299
+ };
300
+ const next = (Array.isArray(presets) ? presets : []).filter((p) => clean(p?.id) !== id && clean(p?.name) !== preset.name);
301
+ next.push(preset);
302
+ return next;
303
+ }
304
+
305
+ // Config-aware route resolvers need resolveDefaultProvider + findPreset from
306
+ // the runtime; created via this factory.
307
+ export function createWorkflowRouteHelpers({ resolveDefaultProvider, findPreset }) {
308
+ function summarizeWorkflowRoutes(config) {
309
+ const routes = config?.workflowRoutes && typeof config.workflowRoutes === 'object' ? config.workflowRoutes : {};
310
+ const fallbackProvider = resolveDefaultProvider(config);
311
+ const out = {};
312
+ for (const slot of WORKFLOW_ROUTE_SLOTS) {
313
+ const route = routes[slot];
314
+ // Read/interpret path: a route with a model but no provider falls back to
315
+ // config.defaultProvider (then DEFAULT_PROVIDER).
316
+ if (route?.model && (route?.provider || fallbackProvider)) {
317
+ out[slot] = normalizeWorkflowRoute(route, { provider: fallbackProvider });
318
+ }
319
+ }
320
+ return out;
321
+ }
322
+
323
+ function routeFromPreset(config, slotValue) {
324
+ // Maintenance slots now store a direct {provider, model} route. Accept that
325
+ // shape first; fall back to the legacy preset-NAME string lookup so configs
326
+ // written before the route migration still resolve.
327
+ if (slotValue && typeof slotValue === 'object' && !Array.isArray(slotValue)) {
328
+ const direct = normalizeWorkflowRoute(slotValue);
329
+ if (direct) return direct;
330
+ }
331
+ const preset = findPreset(config, slotValue);
332
+ return preset ? normalizeWorkflowRoute(preset) : null;
333
+ }
334
+
335
+ function agentRouteFromConfig(config, agentId, _dataDir) {
336
+ const id = normalizeAgentId(agentId);
337
+ if (!id) return null;
338
+ // Read/interpret path: inject config.defaultProvider (then DEFAULT_PROVIDER)
339
+ // when a stored route omits its provider.
340
+ const fallback = { provider: resolveDefaultProvider(config) };
341
+ const explicit = normalizeWorkflowRoute(config?.agents?.[id], fallback)
342
+ || (id === 'maintainer' ? normalizeWorkflowRoute(config?.agents?.maintenance, fallback) : null);
343
+ if (explicit) return explicit;
344
+
345
+ const agent = FIXED_AGENT_SLOTS.find((item) => item.id === id);
346
+ if (agent?.workflowSlot) {
347
+ const workflowRoute = normalizeWorkflowRoute(config?.workflowRoutes?.[agent.workflowSlot], fallback);
348
+ if (workflowRoute) return workflowRoute;
349
+ }
350
+
351
+ if (id === 'explore') return routeFromPreset(config, config?.maintenance?.explore);
352
+ if (id === 'maintainer') return routeFromPreset(config, config?.maintenance?.memory);
353
+
354
+ return null;
355
+ }
356
+
357
+ return { summarizeWorkflowRoutes, routeFromPreset, agentRouteFromConfig };
358
+ }
@@ -14,7 +14,7 @@ import {
14
14
  taskIdFromArgs,
15
15
  } from '../runtime/shared/background-tasks.mjs';
16
16
  import { modelVisibleToolCompletionMessage } from '../runtime/shared/tool-execution-contract.mjs';
17
- import { presentErrorText } from '../runtime/shared/err-text.mjs';
17
+ import { presentErrorText, errorLine } from '../runtime/shared/err-text.mjs';
18
18
  import { updateJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
19
19
  import {
20
20
  normalizeAgentPermission,
@@ -135,7 +135,11 @@ function envTimeoutMs(name, fallback) {
135
135
  // Grace window during which a terminated/idle worker row is kept around so the
136
136
  // same terminal can re-use (or cleanly re-spawn) the same tag. Cached as a
137
137
  // constant like the other timeouts; override with MIXDOG_AGENT_TERMINAL_REAP_MS.
138
- const TERMINAL_REAP_MS = envTimeoutMs('MIXDOG_AGENT_TERMINAL_REAP_MS', 60 * 60_000);
138
+ // 5m (was 1h): aligned with the BP4 message-tail cache TTL — past 5m the
139
+ // session's prompt cache is cold anyway, so same-tag reuse pays a full prefix
140
+ // rewrite; a fresh respawn (send dead-tag fallback) starts smaller and cheaper.
141
+ // Trace replay (2026-07): agent tail cost 1h 1.77M -> 5m 1.11M (-37%).
142
+ const TERMINAL_REAP_MS = envTimeoutMs('MIXDOG_AGENT_TERMINAL_REAP_MS', 5 * 60_000);
139
143
  // Independent hard cap for the spawn *prep* phase (ensureProvider /
140
144
  // prepareAgentSession / catalog+rules load). Kept separate from the
141
145
  // first-response watchdog so prep cannot hang a whole fanout before the model
@@ -387,7 +391,7 @@ function renderResult(value) {
387
391
  for (const job of jobs) {
388
392
  const target = job.tag || job.sessionId || '-';
389
393
  const terminal = job.clientHostPid ? ` term=${job.clientHostPid}` : '';
390
- const base = `- ${job.task_id} ${job.type} ${job.status} target=${target}${terminal}${job.error ? ` error=${presentErrorText(job.error, { surface: 'agent' })}` : ''}`;
394
+ const base = `- ${job.task_id} ${job.type} ${job.status} target=${target}${terminal}${job.error ? ` error=${job.error}` : ''}`;
391
395
  lines.push(appendAgentProgressKv(base, job));
392
396
  }
393
397
  if (workers.length === 0 && jobs.length === 0) lines.push('(no agents or tasks)');
@@ -422,7 +426,7 @@ function renderResult(value) {
422
426
  const elapsed = elapsedFromStamps(value.startedAt, value.finishedAt, value.status);
423
427
  if (elapsed) lines.push(`elapsed: ${elapsed}`);
424
428
  }
425
- if (value.error) lines.push(`error: ${presentErrorText(value.error, { surface: 'agent' })}`);
429
+ if (value.error) lines.push(`error: ${value.error}`);
426
430
  if (value.status === 'running') lines.push('notification: completion will be delivered to the owner session; use read/status only for manual recovery.');
427
431
  if (value.result !== undefined) {
428
432
  const result = value.result;
@@ -466,7 +470,13 @@ function renderResult(value) {
466
470
  return JSON.stringify(value, null, 2);
467
471
  }
468
472
 
469
- export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultCwd }) {
473
+ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultCwd, onSubagentEvent }) {
474
+ // Optional bridge to the standard hook bus for SubagentStart / SubagentStop.
475
+ // Best-effort: a hook error must never affect worker spawn/finish.
476
+ function emitSubagentEvent(phase, agent, extra = {}) {
477
+ if (typeof onSubagentEvent !== 'function') return;
478
+ try { onSubagentEvent(phase, { agent_type: agent || null, ...extra }); } catch { /* best-effort */ }
479
+ }
470
480
  const tags = new Map();
471
481
  const tagAgents = new Map();
472
482
  const tagCwds = new Map();
@@ -1246,6 +1256,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1246
1256
  tag: meta.tag || null,
1247
1257
  sessionId: meta.sessionId || null,
1248
1258
  agent: meta.agent || null,
1259
+ ...(meta.respawned === true ? { respawned: true, note: 'previous session reaped — fresh session, no prior context; re-supply anchors if needed' } : {}),
1249
1260
  preset: meta.preset || null,
1250
1261
  provider: meta.provider || null,
1251
1262
  model: meta.model || null,
@@ -1619,6 +1630,8 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1619
1630
  const { args, tag, session, agent, preset, presetName, workerCwd, prompt, watchdogPolicy } = prepared;
1620
1631
  const watchdog = startProgressIdleWatchdog(session.id, watchdogPolicy);
1621
1632
  let finalStatus = 'idle';
1633
+ // SubagentStart: a worker session is about to run its first turn.
1634
+ emitSubagentEvent('start', agent, { session_id: session.id, tag });
1622
1635
  upsertWorkerSessionDeferred(session, tag, {
1623
1636
  agent,
1624
1637
  preset: presetKey(preset) || presetName,
@@ -1710,7 +1723,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1710
1723
  try {
1711
1724
  reconcileBackgroundTask(job.taskId, {
1712
1725
  status: 'failed',
1713
- error: presentErrorText(error, { surface: 'agent' }),
1726
+ error,
1714
1727
  terminalReason: 'agent-stream-stalled',
1715
1728
  });
1716
1729
  } catch {}
@@ -1747,6 +1760,8 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1747
1760
  } catch {}
1748
1761
  }
1749
1762
  scheduleReap(session.id);
1763
+ // SubagentStop: worker finished (terminal), regardless of outcome.
1764
+ emitSubagentEvent('stop', agent, { session_id: session.id, tag, status: finalStatus });
1750
1765
  }
1751
1766
  }
1752
1767
 
@@ -1836,7 +1851,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1836
1851
  try {
1837
1852
  reconcileBackgroundTask(job.taskId, {
1838
1853
  status: 'failed',
1839
- error: presentErrorText(error, { surface: 'agent' }),
1854
+ error,
1840
1855
  terminalReason: 'agent-stream-stalled',
1841
1856
  });
1842
1857
  } catch {}
@@ -1996,8 +2011,32 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1996
2011
  if (type === 'cancel') return renderResult(close(args, scopedContext));
1997
2012
  if (type === 'close') return renderResult(close(args, scopedContext));
1998
2013
  if (type === 'send') {
1999
- const prepared = await prepareSend(args, scopedContext);
2000
- return dispatchToExistingSession(prepared, notifyContext);
2014
+ try {
2015
+ const prepared = await prepareSend(args, scopedContext);
2016
+ return dispatchToExistingSession(prepared, notifyContext);
2017
+ } catch (err) {
2018
+ // Reaped/dead-tag fallback: with the 5m terminal-reap window a
2019
+ // same-scope follow-up often lands after the session is gone.
2020
+ // Instead of bouncing an error back to Lead (who would just
2021
+ // re-issue the same content as a spawn), respawn a FRESH session
2022
+ // under the same tag with the message as its brief. `respawned:
2023
+ // true` in the result tells Lead the worker has no prior session
2024
+ // context — re-supply anchors on the next send if needed.
2025
+ // Only tag-addressed sends fall back; explicit sessionId sends
2026
+ // keep erroring (caller pinned a specific session on purpose).
2027
+ const fallbackTag = clean(args.tag);
2028
+ const isDeadTarget = /not found|is closed/i.test(String(err?.message || ''));
2029
+ if (!fallbackTag || fallbackTag.startsWith('sess_') || !isDeadTarget) throw err;
2030
+ const prompt = clean(args.message || args.prompt);
2031
+ if (!prompt) throw err;
2032
+ // Clear the terminal trace so the fresh spawn isn't rejected by
2033
+ // the lingering-trace guard, then run the normal deferred spawn.
2034
+ try { forgetTag(fallbackTag); } catch {}
2035
+ try { removeWorkerRow({ tag: fallbackTag }); } catch {}
2036
+ const spawnArgs = { ...args, type: 'spawn', tag: fallbackTag, prompt, message: undefined };
2037
+ const job = startDeferredSpawnJob(spawnArgs, callerCwd, context, notifyContext, { respawned: true });
2038
+ return renderResult(renderJob(job, false));
2039
+ }
2001
2040
  }
2002
2041
  if (type === 'spawn') {
2003
2042
  // Explicit-tag spawn priority (auto nextTag always creates a fresh session):
@@ -2033,7 +2072,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
2033
2072
  }
2034
2073
  throw new Error(`agent: unknown type "${type}"`);
2035
2074
  } catch (err) {
2036
- return `Error: ${presentErrorText(err, { surface: 'agent' })}`;
2075
+ return errorLine(err, { surface: 'agent' });
2037
2076
  }
2038
2077
  }
2039
2078
 
@@ -1,9 +1,10 @@
1
1
  import { execFile, fork, spawnSync } from 'node:child_process';
2
- import { appendFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { startChildGuardian } from '../runtime/shared/child-guardian.mjs';
7
+ import { appendBuffered } from '../runtime/shared/buffered-appender.mjs';
7
8
 
8
9
  const CHANNEL_TOOLS = new Set([
9
10
  'reply',
@@ -24,7 +25,7 @@ const WORKER_PRELOAD = fileURLToPath(new URL('./channel-worker-preload.cjs', imp
24
25
  function logLine(path, line) {
25
26
  try {
26
27
  mkdirSync(dirname(path), { recursive: true });
27
- appendFileSync(path, `[${new Date().toISOString()}] ${line}\n`);
28
+ appendBuffered(path, `[${new Date().toISOString()}] ${line}\n`);
28
29
  } catch {
29
30
  // Logging must never break the TUI.
30
31
  }
@@ -170,8 +170,12 @@ function findConfigPreset(config, presetName) {
170
170
  }
171
171
 
172
172
  async function ensureExploreProviderReady(config) {
173
- const preset = findConfigPreset(config, config?.maintenance?.explore);
174
- const provider = clean(preset?.provider);
173
+ const route = config?.maintenance?.explore;
174
+ // Route object ({provider,model}) is the current shape; a string is a
175
+ // legacy preset NAME resolved against config.presets.
176
+ const provider = (route && typeof route === 'object')
177
+ ? clean(route.provider)
178
+ : clean(findConfigPreset(config, route)?.provider);
175
179
  if (!provider) return;
176
180
  const providers = { ...(config?.providers || {}) };
177
181
  providers[provider] = { ...(providers[provider] || {}), enabled: true };
@@ -303,7 +307,10 @@ async function runExploreSync(args = {}, ctx = {}) {
303
307
  scheduleExploreCodeGraphPrewarm(resolvedCwd);
304
308
  const config = loadConfig();
305
309
  await ensureExploreProviderReady(config);
306
- const presetName = config?.maintenance?.explore || '';
310
+ const route = config?.maintenance?.explore;
311
+ const presetName = (route && typeof route === 'object')
312
+ ? `${clean(route.provider)}/${clean(route.model)}`
313
+ : (route || '');
307
314
  const llm = makeAgentDispatch({
308
315
  agent: 'explorer',
309
316
  cwd: resolvedCwd,