mixdog 0.9.43 → 0.9.44

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 (32) hide show
  1. package/package.json +3 -2
  2. package/scripts/ansi-color-capability-test.mjs +90 -0
  3. package/scripts/generate-runtime-manifest.mjs +39 -22
  4. package/scripts/grok-oauth-refresh-race-test.mjs +198 -0
  5. package/scripts/internal-comms-smoke.mjs +24 -7
  6. package/scripts/maintenance-default-routes-test.mjs +164 -0
  7. package/scripts/openai-oauth-ws-1006-retry-test.mjs +123 -0
  8. package/scripts/routing-corpus.mjs +1 -1
  9. package/scripts/shell-jobs-windows-hide-test.mjs +164 -0
  10. package/scripts/tui-transcript-jitter-harness-entry.jsx +302 -0
  11. package/scripts/tui-transcript-jitter-harness.mjs +58 -0
  12. package/src/agents/reviewer/AGENT.md +5 -7
  13. package/src/rules/lead/lead-brief.md +5 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +40 -3
  15. package/src/runtime/agent/orchestrator/config.mjs +29 -14
  16. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +46 -21
  17. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -1
  18. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -5
  19. package/src/runtime/memory/data/runtime-manifest.json +11 -11
  20. package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -2
  21. package/src/standalone/agent-tool.mjs +65 -50
  22. package/src/standalone/explore-tool.mjs +17 -16
  23. package/src/tui/app/provider-setup-picker.mjs +1 -0
  24. package/src/tui/app/use-transcript-window.mjs +5 -1
  25. package/src/tui/components/StatusLine.jsx +7 -6
  26. package/src/tui/dist/index.mjs +213 -77
  27. package/src/tui/engine/turn.mjs +1 -1
  28. package/src/tui/index.jsx +3 -2
  29. package/src/tui/markdown/streaming-markdown.mjs +35 -9
  30. package/src/tui/statusline-ansi-bridge.mjs +17 -7
  31. package/src/ui/ansi.mjs +85 -5
  32. package/src/ui/statusline-format.mjs +7 -7
@@ -8,12 +8,13 @@
8
8
  `Task:`. Never infer exactness from a task name, file count, or perceived
9
9
  difficulty.
10
10
  - All other fields are optional task-specific deltas: `Anchors:`
11
- `Allow/Forbid:` `Deliver:` `Verify:`. Omit any that add no information.
11
+ `Allow/Forbid:` `Deliver:`. Omit any that add no information.
12
12
  - Anchors: `file:line` + one-line conclusion; never log/code bodies. Specify
13
13
  outcome, not method unless required. `Deliver:` gives shape/size, never a long
14
- handoff. Referenced spec/test beats its summary.
15
- - Preserve the exact same `Task:` across Worker -> Debugger -> Reviewer. Never
16
- summarize, rewrite, or replace it when handing the scope to the next role.
14
+ handoff. The original request and official spec/test acceptance criteria beat
15
+ their brief summary.
16
+ - Each role independently constructs a role-appropriate, lossless `Task:` from
17
+ the original request and official spec/test acceptance criteria.
17
18
  - Full brief only for fresh spawn/`respawned: true`; live follow-ups are delta.
18
19
  Dead-tag send is cold: re-supply anchors.
19
20
  - Never `send` mid-run; batch one follow-up after completion; interrupt only to
@@ -24,6 +24,7 @@
24
24
  import { loadConfig } from '../config.mjs';
25
25
  import { resolveRuntimeSpec } from '../config.mjs';
26
26
  import { getHiddenAgent, resolveAgentSessionPermission } from '../internal-agents.mjs';
27
+ import { isKnownProvider } from '../../../../standalone/provider-admin.mjs';
27
28
  import { prepareAgentSession } from './session-builder.mjs';
28
29
  import {
29
30
  askSession,
@@ -163,9 +164,30 @@ export function resolveHiddenRoleSchemaAllowedTools(hidden) {
163
164
  * against config.presets for backward compatibility.
164
165
  * - null — unresolved.
165
166
  *
166
- * Hidden roles read their slot from `maint[maintKey || slot]`; the cycle1/2/3
167
- * agents share one knob via the `maintKey: 'memory'` override.
167
+ * Explore and memory hidden roles mirror public spawning precedence:
168
+ * `agents.<role>` (including legacy `agents.maintenance`) workflow route
169
+ * maintenance route → Main. The cycle1/2/3 agents share the memory knob via
170
+ * their `maintKey: 'memory'` override. Scheduler and webhook are unchanged.
168
171
  */
172
+ const DEFAULT_AGENT_ROUTE_PROVIDER = 'anthropic-oauth';
173
+
174
+ function normalizeMaintenanceCandidate(candidate, config) {
175
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return candidate || null;
176
+ const configuredProvider = String(config?.defaultProvider || '').trim();
177
+ const fallbackProvider = isKnownProvider(configuredProvider)
178
+ ? configuredProvider
179
+ : DEFAULT_AGENT_ROUTE_PROVIDER;
180
+ const provider = String(candidate.provider || fallbackProvider).trim();
181
+ const model = String(candidate.model || '').trim();
182
+ if (!provider || !model) return null;
183
+ return {
184
+ provider,
185
+ model,
186
+ effort: String(candidate.effort || '').trim() || undefined,
187
+ fast: candidate.fast === true,
188
+ };
189
+ }
190
+
169
191
  export function resolveMaintenanceRoute({ preset, optsPreset, agent, config: cfgIn = null }) {
170
192
  if (preset) return preset;
171
193
  if (optsPreset) return optsPreset;
@@ -175,7 +197,22 @@ export function resolveMaintenanceRoute({ preset, optsPreset, agent, config: cfg
175
197
  try {
176
198
  const config = cfgIn || loadConfig({ secrets: false });
177
199
  const maint = config?.maintenance || {};
178
- return maint[hidden.maintKey || hidden.slot] ?? null;
200
+ const key = hidden.maintKey || hidden.slot;
201
+ const role = key === 'explore' ? 'explore' : (key === 'memory' ? 'maintainer' : '');
202
+ const workflowSlot = key === 'explore' ? 'explorer' : (key === 'memory' ? 'memory' : '');
203
+ if (!role) return maint[key] ?? null;
204
+ const candidates = [
205
+ role ? config?.agents?.[role] : null,
206
+ key === 'memory' ? config?.agents?.maintenance : null,
207
+ workflowSlot ? config?.workflowRoutes?.[workflowSlot] : null,
208
+ maint[key],
209
+ role ? config?.default : null,
210
+ ];
211
+ for (const candidate of candidates) {
212
+ const route = normalizeMaintenanceCandidate(candidate, config);
213
+ if (route) return route;
214
+ }
215
+ return null;
179
216
  } catch { return null; }
180
217
  }
181
218
  return null;
@@ -7,6 +7,20 @@ import {
7
7
  hasGrokOAuthCredentials,
8
8
  } from './providers/oauth-credential-probes.mjs';
9
9
 
10
+ // Keep config loading free of standalone provider-admin and its provider
11
+ // runtimes. These identifiers mirror that module's static catalog, assembled
12
+ // solely from the lightweight config/provider constants already imported here.
13
+ const CONFIG_PROVIDER_IDS = new Set([
14
+ ...Object.keys(AGENT_PROVIDER_ENV),
15
+ ...Object.keys(OPENAI_COMPAT_PRESETS),
16
+ 'openai-oauth',
17
+ 'anthropic-oauth',
18
+ 'grok-oauth',
19
+ ]);
20
+ function isConfiguredProviderId(provider) {
21
+ return CONFIG_PROVIDER_IDS.has(String(provider || '').trim());
22
+ }
23
+
10
24
  // Thin wrapper around resolvePluginData so callers in this orchestrator tree
11
25
  // can import a single helper without reaching into shared/.
12
26
  export function getPluginData() {
@@ -18,15 +32,12 @@ export function getPluginData() {
18
32
  // Canonical maintenance defaults. Single source of truth — imported by
19
33
  // llm/index.mjs and setup-server.mjs so UI/runtime cannot drift from config.
20
34
  //
21
- // Every hidden maintenance slot carries a CONCRETE preset here, so
22
- // resolvePresetName() (agent-dispatch) always resolves a model directly from
23
- // `maint[slot]` no shared `defaultPreset` fallback is needed or used.
24
- // Memory cycles + Lead helper fan-out (explore/cycle1/cycle2/cycle3) and
25
- // entry-driven dispatch (scheduler/webhook) all default to `haiku`. The three
26
- // memory cycles (chunker / re-scorer / core reviewer) share ONE `memory`
27
- // preset knob — the cycle agents stay separate (cycle1/2/3-agent, distinct
28
- // slots and invokedBy) but resolve their model from `maint.memory` via the
29
- // `maintKey` override on their hidden-role entries.
35
+ // Explore and Maintainer start without a route so they dynamically inherit the
36
+ // Main route. Explicit `maintenance.explore` / `maintenance.memory` choices
37
+ // remain supported. The memory cycles (chunker / re-scorer / core reviewer)
38
+ // share the optional `memory` preset knob the cycle agents stay separate
39
+ // (cycle1/2/3-agent, distinct slots and invokedBy) but resolve their model from
40
+ // `maint.memory` via the `maintKey` override on their hidden-role entries.
30
41
  // scheduler/webhook still let a per-entry config.json model win first (the
31
42
  // caller passes it explicitly via opts.preset); the haiku default below only
32
43
  // applies when an entry omits its own model.
@@ -129,8 +140,6 @@ const _HAIKU_ROUTE = Object.freeze({
129
140
  model: resolveAnthropicFamilyModel('haiku'),
130
141
  });
131
142
  export const DEFAULT_MAINTENANCE = Object.freeze({
132
- explore: { ..._HAIKU_ROUTE },
133
- memory: { ..._HAIKU_ROUTE },
134
143
  scheduler: { ..._HAIKU_ROUTE },
135
144
  webhook: { ..._HAIKU_ROUTE },
136
145
  });
@@ -201,12 +210,18 @@ function normalizeSearchRoute(route) {
201
210
  // is resolved against the config.presets array (the legacy lookup) and rewritten
202
211
  // to a route. Unresolvable strings are dropped so the DEFAULT_MAINTENANCE route
203
212
  // fills the slot. `presets` is the normalized preset array for legacy lookup.
204
- function migrateMaintenanceRoutes(rawMaint, presets) {
213
+ function migrateMaintenanceRoutes(rawMaint, presets, defaultProvider) {
205
214
  const out = {};
206
215
  const list = Array.isArray(presets) ? presets : [];
216
+ const configuredProvider = String(defaultProvider || '').trim();
217
+ const fallbackProvider = isConfiguredProviderId(configuredProvider)
218
+ ? configuredProvider
219
+ : 'anthropic-oauth';
207
220
  for (const [slot, value] of Object.entries(rawMaint || {})) {
208
221
  if (value && typeof value === 'object' && !Array.isArray(value)) {
209
- const provider = normalizeAgentProviderId(value.provider);
222
+ const provider = normalizeAgentProviderId(
223
+ value.provider || ((slot === 'explore' || slot === 'memory') ? fallbackProvider : ''),
224
+ );
210
225
  const model = String(value.model || '').trim();
211
226
  if (provider && model) {
212
227
  const route = { provider, model };
@@ -437,7 +452,7 @@ export function loadConfig(options = {}) {
437
452
  delete workflowRoutes.search;
438
453
  // Migrate legacy preset-name maintenance slots to direct routes,
439
454
  // then overlay onto the route-shaped defaults.
440
- const migratedMaint = migrateMaintenanceRoutes(rawMaint, normalizedPresets);
455
+ const migratedMaint = migrateMaintenanceRoutes(rawMaint, normalizedPresets, raw.defaultProvider);
441
456
  return {
442
457
  providers: mergedProviders,
443
458
  mcpServers,
@@ -22,7 +22,7 @@ import { randomBytes, createHash } from 'crypto';
22
22
  import { readFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs';
23
23
  import { join } from 'path';
24
24
  import { getPluginData } from '../config.mjs';
25
- import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
25
+ import { writeJsonAtomicSync, withFileLock } from '../../../shared/atomic-file.mjs';
26
26
  import { enrichModels, getModelMetadataSync } from './model-catalog.mjs';
27
27
  import { sanitizeModelList } from './model-list-sanitize.mjs';
28
28
  import { makeModelCache } from './model-cache.mjs';
@@ -180,6 +180,10 @@ function getOwnTokenPath() {
180
180
  return join(dir, 'grok-oauth.json');
181
181
  }
182
182
 
183
+ function getRefreshLockPath() {
184
+ return `${getOwnTokenPath()}.refresh.lock`;
185
+ }
186
+
183
187
  // expires_at may arrive as a unix number or an ISO-8601 string. Normalize both
184
188
  // to epoch milliseconds; 0 means unknown.
185
189
  function _normalizeExpiresAt(value) {
@@ -369,32 +373,53 @@ async function _postRefresh(tokens) {
369
373
  }
370
374
  }
371
375
 
372
- // 16 mixdog processes share one grok-oauth.json and xAI rotates refresh
373
- // tokens single-use. Re-read the store immediately before the network POST so
374
- // a peer's just-completed rotation is adopted instead of spending our
375
- // (now-stale) refresh_token; on invalid_grant, re-read once and retry with a
376
- // peer-rotated refresh_token before propagating.
376
+ // Mixdog processes share one grok-oauth.json and xAI rotates refresh tokens
377
+ // single-use. Hold a cross-process lease across the re-read, exchange, and
378
+ // atomic save so only one process can spend a generation.
377
379
  async function refreshTokens(tokens, { force = false } = {}) {
378
380
  if (!tokens?.refresh_token) {
379
381
  throw new Error('[grok-oauth] refresh token not available — open /providers in mixdog to sign in again');
380
382
  }
381
- const disk = _loadOwnTokens();
382
- const validAfter = Date.now() + (force ? 0 : TOKEN_REFRESH_SKEW_MS);
383
- if (disk?.access_token && disk.access_token !== tokens.access_token
384
- && (!disk.expires_at || disk.expires_at >= validAfter)) {
385
- return disk;
386
- }
387
- try {
388
- return await _postRefresh(tokens);
389
- } catch (err) {
390
- if (err?.isInvalidGrant) {
391
- const rotated = _loadOwnTokens();
392
- if (rotated?.refresh_token && rotated.refresh_token !== tokens.refresh_token) {
393
- return await _postRefresh(rotated);
383
+
384
+ return withFileLock(getRefreshLockPath(), async () => {
385
+ const validAfter = Date.now() + (force ? 0 : TOKEN_REFRESH_SKEW_MS);
386
+ const disk = _loadOwnTokens();
387
+ // A waiter that entered with the prior generation adopts the winner's
388
+ // persisted, valid token instead of rotating it again. xAI may rotate
389
+ // only the refresh token while reissuing the same access token.
390
+ const diskGenerationChanged = disk?.access_token
391
+ && (disk.access_token !== tokens.access_token
392
+ || disk.refresh_token !== tokens.refresh_token);
393
+ if (diskGenerationChanged
394
+ && (!disk.expires_at || disk.expires_at >= validAfter)) {
395
+ return disk;
396
+ }
397
+
398
+ const current = disk || tokens;
399
+ try {
400
+ return await _postRefresh(current);
401
+ } catch (err) {
402
+ if (err?.isInvalidGrant) {
403
+ // A writer that does not participate in this lease may still
404
+ // have won the rotation while the request was in flight.
405
+ // Adopt its valid generation; only exchange it when it cannot
406
+ // satisfy this caller.
407
+ const rotated = _loadOwnTokens();
408
+ if (rotated?.refresh_token && rotated.refresh_token !== current.refresh_token) {
409
+ if (rotated.access_token
410
+ && (!rotated.expires_at || rotated.expires_at >= validAfter)) {
411
+ return rotated;
412
+ }
413
+ return await _postRefresh(rotated);
414
+ }
394
415
  }
416
+ throw err;
395
417
  }
396
- throw err;
397
- }
418
+ }, {
419
+ timeoutMs: 120_000,
420
+ staleMs: 120_000,
421
+ secret: true,
422
+ });
398
423
  }
399
424
 
400
425
  // --- Model catalog cache (24h disk TTL) ---
@@ -325,7 +325,11 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
325
325
  }
326
326
  if (!state.sawResponseCreated) {
327
327
  const closeCode = Number(err?.wsCloseCode || state.wsCloseCode || 0)
328
- if (closeCode !== 1011 && closeCode !== 1012) return null
328
+ // An abnormal close before response.created has not produced any response
329
+ // bytes to the caller. It is therefore safe to reconnect and replay under
330
+ // the normal ws_1006 bounded retry policy (text/tool emission was denied
331
+ // above before reaching this gate).
332
+ if (closeCode !== 1006 && closeCode !== 1011 && closeCode !== 1012) return null
329
333
  }
330
334
  if (state.userAbort) return null
331
335
 
@@ -985,10 +985,12 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
985
985
  // the execution policy on Windows PowerShell; pwsh on macOS/Linux
986
986
  // accepts the parameter as a no-op, so it is safe unconditionally.
987
987
  " $argList = @('-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $innerPath)",
988
- // -WindowStyle is a Windows-only Start-Process parameter; pwsh on
989
- // macOS/Linux throws "not supported on this platform". Add it only on win32.
988
+ // Do not set WindowStyle: Hidden creates a separate transient conhost.
989
+ // On Windows, explicitly reuse the wrapper's CREATE_NO_WINDOW console.
990
+ // NoNewWindow is unavailable on non-Windows pwsh, so only add it for
991
+ // Windows (where $IsWindows is absent/null in Windows PowerShell 5.1).
990
992
  ' $spArgs = @{ FilePath = $exe; ArgumentList = $argList; RedirectStandardOutput = $stdoutPath; RedirectStandardError = $stderrPath; PassThru = $true }',
991
- ' if ($IsWindows -or $null -eq $IsWindows) { $spArgs[\'WindowStyle\'] = \'Hidden\' }',
993
+ ' if ($IsWindows -or $null -eq $IsWindows) { $spArgs[\'NoNewWindow\'] = $true }',
992
994
  ' $p = Start-Process @spArgs',
993
995
  ' if ($timeoutMs -gt 0 -and -not $p.WaitForExit($timeoutMs)) {',
994
996
  // Kill the whole process TREE, not just the direct child: Start-Process
@@ -1039,8 +1041,8 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
1039
1041
  // No `-WindowStyle Hidden` CLI switch: windowsHide:true on the spawn below
1040
1042
  // already gives CREATE_NO_WINDOW, and the visible command-line token trips
1041
1043
  // Defender's hidden-PowerShell dropper signature (PowhidSubExec). The
1042
- // in-wrapper Start-Process WindowStyle=Hidden (above) stays it lives in
1043
- // the staged .ps1, not on the command line, and is still needed there.
1044
+ // in-wrapper Start-Process uses Windows-only NoNewWindow to reuse this
1045
+ // hidden console rather than creating a transient conhost window.
1044
1046
  // `-ExecutionPolicy` only applies to Windows PowerShell; build per-platform.
1045
1047
  const isWin = process.platform === 'win32';
1046
1048
  const wrapperArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "generated_at": "2026-06-30T06:23:20.385Z",
3
+ "generated_at": "2026-07-13T06:08:53.718Z",
4
4
  "release_tag": "runtime-v0.4.0",
5
5
  "pg": {
6
6
  "major": 16,
@@ -12,28 +12,28 @@
12
12
  "assets": {
13
13
  "darwin-arm64": {
14
14
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-darwin-arm64-pg16.4-pgvector0.8.2.tar.gz",
15
- "sha256": "7a642006ef60ccf8c385ba89c761c2b6f4927f6f01a1c4fb152604e6b41fe3d2",
16
- "size": 23397361
15
+ "sha256": "c11f4b3046f0eeecf88790d2fff88cee1834231f96c11ec315a7a97180ea1413",
16
+ "size": 23397436
17
17
  },
18
18
  "darwin-x64": {
19
19
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-darwin-x64-pg16.4-pgvector0.8.2.tar.gz",
20
- "sha256": "8e39bfc3f06d88e9854946d5d83f7d77d9530f99284d7c1c51872c12f389f7e7",
21
- "size": 23687666
20
+ "sha256": "ef93b837b1e3ea0b30591f2d31de10f9a628db88ad00183d271d539fe3cb0adb",
21
+ "size": 23687427
22
22
  },
23
23
  "linux-arm64": {
24
24
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-linux-arm64-pg16.4-pgvector0.8.2.tar.gz",
25
- "sha256": "64ff170013b9543ad8843c98cbfe14019e6be6ea3d142ec189b2bea0dd1dc797",
26
- "size": 22885036
25
+ "sha256": "288d566fd38dd80ce2dcfa68083a76b6a9400fbd8020453cf72197d3cc161315",
26
+ "size": 22884871
27
27
  },
28
28
  "linux-x64": {
29
29
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-linux-x64-pg16.4-pgvector0.8.2.tar.gz",
30
- "sha256": "28ee64125a0125ff36c47560c08ce8f38e49aef034c010c7e4867e8cdd783385",
31
- "size": 23276106
30
+ "sha256": "31b40d32d51afb85963e33557c3140e378b21c7cc54cec794049276a2160afd3",
31
+ "size": 23282291
32
32
  },
33
33
  "win32-x64": {
34
34
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-win32-x64-pg16.4-pgvector0.8.2.tar.gz",
35
- "sha256": "6faed8a49b3303b0adb1c5715b2b19c5cc47df2e738d586d0d0bd432f1ca035d",
36
- "size": 44036958
35
+ "sha256": "5d3d9d6e3ae4a93bb0bde615365bdda3ab5ff7206c0d1a066ed51b316932a8f8",
36
+ "size": 44037023
37
37
  }
38
38
  }
39
39
  }
@@ -37,7 +37,6 @@ const BUNDLED_MANIFEST_PATH = fileURLToPath(new URL('../data/runtime-manifest.js
37
37
 
38
38
  // GitHub raw URL fallback — used only when no cached or bundled manifest exists.
39
39
  const MANIFEST_URL = 'https://raw.githubusercontent.com/tribgames/mixdog/main/src/runtime/memory/data/runtime-manifest.json'
40
- const LEGACY_RUNTIME_RELEASE_REPOSITORY = 'trib-plugin/mixdog'
41
40
 
42
41
  // ---------------------------------------------------------------------------
43
42
  // Platform key
@@ -183,7 +182,7 @@ function runtimeAssetUrlCandidates(url) {
183
182
  .split(/[\s,;]+/u)
184
183
  .map((repo) => repo.trim())
185
184
  .filter(Boolean)
186
- for (const repo of [...overrides, LEGACY_RUNTIME_RELEASE_REPOSITORY]) {
185
+ for (const repo of overrides) {
187
186
  if (repo && repo !== releaseRepo) add(value.replace(`/github.com/${releaseRepo}/`, `/github.com/${repo}/`))
188
187
  }
189
188
  return out
@@ -92,6 +92,70 @@ const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIME
92
92
  const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 0);
93
93
  const TAG_TOMBSTONE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
94
94
  const MAX_TAG_TOMBSTONES = 500;
95
+
96
+ /**
97
+ * Resolve the route for a public agent spawn. Explore and Maintainer inherit
98
+ * Main only after their explicit agent, workflow, and maintenance routes.
99
+ */
100
+ export function resolveAgentSpawnPreset(config, args = {}) {
101
+ if (args.provider && args.model) {
102
+ return {
103
+ presetName: args.preset || '__direct__',
104
+ preset: {
105
+ id: '__direct__',
106
+ name: '__DIRECT__',
107
+ type: 'agent',
108
+ provider: clean(args.provider),
109
+ model: clean(args.model),
110
+ effort: clean(args.effort) || undefined,
111
+ fast: args.fast === true,
112
+ tools: 'full',
113
+ },
114
+ };
115
+ }
116
+
117
+ const agentName = normalizeAgentName(args.agent);
118
+ const configuredDefault = clean(config?.defaultProvider);
119
+ const fallbackProvider = configuredDefault && isKnownProvider(configuredDefault)
120
+ ? configuredDefault
121
+ : DEFAULT_PROVIDER;
122
+ const workflowSlot = agentName === 'explore' ? 'explorer'
123
+ : (agentName === 'maintainer' ? 'memory' : '');
124
+ const maintenanceSlot = agentName === 'explore' ? 'explore'
125
+ : (agentName === 'maintainer' ? 'memory' : '');
126
+ const agentRoute = !clean(args.preset)
127
+ ? (normalizeAgentRoute(config?.agents?.[agentName], fallbackProvider)
128
+ || (agentName === 'maintainer' ? normalizeAgentRoute(config?.agents?.maintenance, fallbackProvider) : null)
129
+ || normalizeAgentRoute(config?.workflowRoutes?.[workflowSlot], fallbackProvider)
130
+ || normalizeAgentRoute(config?.maintenance?.[maintenanceSlot], fallbackProvider))
131
+ : null;
132
+ if (agentRoute) {
133
+ return {
134
+ presetName: agentPresetName(agentName),
135
+ preset: {
136
+ id: `agent-${agentName}`,
137
+ name: agentPresetName(agentName),
138
+ type: 'agent',
139
+ provider: agentRoute.provider,
140
+ model: agentRoute.model,
141
+ effort: agentRoute.effort,
142
+ fast: agentRoute.fast === true,
143
+ tools: 'full',
144
+ },
145
+ };
146
+ }
147
+
148
+ const mainPreset = !clean(args.preset) && (agentName === 'explore' || agentName === 'maintainer')
149
+ ? findPreset(config, config?.default)
150
+ : null;
151
+ if (mainPreset) return { presetName: mainPreset.id || mainPreset.name, preset: mainPreset };
152
+
153
+ const presetName = clean(args.preset) || DEFAULT_AGENT_PRESETS[agentName];
154
+ if (!presetName) throw new Error(`agent: agent "${agentName}" has no model assignment`);
155
+ const preset = findPreset(config, presetName) || synthesizePreset(config, presetName);
156
+ if (!preset) throw new Error(`agent: preset "${presetName}" not found`);
157
+ return { presetName, preset };
158
+ }
95
159
  let lastSpawnStartAt = 0;
96
160
  async function waitForSpawnStagger() {
97
161
  if (SPAWN_STAGGER_MS <= 0) return;
@@ -741,55 +805,6 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
741
805
  // chain-gate defaults to the spawn-prep cap (see provider-init.mjs comments).
742
806
  const { ensureProvider } = createProviderInit(reg, DEFAULT_SPAWN_PREP_TIMEOUT_MS);
743
807
 
744
- function resolvePreset(config, args) {
745
- if (args.provider && args.model) {
746
- return {
747
- presetName: args.preset || '__direct__',
748
- preset: {
749
- id: '__direct__',
750
- name: '__DIRECT__',
751
- type: 'agent',
752
- provider: clean(args.provider),
753
- model: clean(args.model),
754
- effort: clean(args.effort) || undefined,
755
- fast: args.fast === true,
756
- tools: 'full',
757
- },
758
- };
759
- }
760
-
761
- const agentName = normalizeAgentName(args.agent);
762
- const configuredDefault = clean(config?.defaultProvider);
763
- const fallbackProvider = configuredDefault && isKnownProvider(configuredDefault)
764
- ? configuredDefault
765
- : DEFAULT_PROVIDER;
766
- const agentRoute = !clean(args.preset)
767
- ? (normalizeAgentRoute(config?.agents?.[agentName], fallbackProvider)
768
- || (agentName === 'maintainer' ? normalizeAgentRoute(config?.agents?.maintenance, fallbackProvider) : null))
769
- : null;
770
- if (agentRoute) {
771
- return {
772
- presetName: agentPresetName(agentName),
773
- preset: {
774
- id: `agent-${agentName}`,
775
- name: agentPresetName(agentName),
776
- type: 'agent',
777
- provider: agentRoute.provider,
778
- model: agentRoute.model,
779
- effort: agentRoute.effort,
780
- fast: agentRoute.fast === true,
781
- tools: 'full',
782
- },
783
- };
784
- }
785
-
786
- const presetName = clean(args.preset) || DEFAULT_AGENT_PRESETS[agentName];
787
- if (!presetName) throw new Error(`agent: agent "${agentName}" has no model assignment`);
788
- const preset = findPreset(config, presetName) || synthesizePreset(config, presetName);
789
- if (!preset) throw new Error(`agent: preset "${presetName}" not found`);
790
- return { presetName, preset };
791
- }
792
-
793
808
  function list({ scanSessions = false, context = {} } = {}) {
794
809
  refreshTagsFromSessions({ scanSessions, context });
795
810
  const now = Date.now();
@@ -1175,7 +1190,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1175
1190
  if (!agent) throw new Error('agent spawn: agent is required');
1176
1191
  const agentPermission = readAgentFrontmatterPermission(agent, dataDir, STANDALONE_SOURCE_ROOT);
1177
1192
  const agentPerm = normalizeAgentPermission(agentPermission) || null;
1178
- const { presetName, preset } = resolvePreset(config, args);
1193
+ const { presetName, preset } = resolveAgentSpawnPreset(config, args);
1179
1194
  await ensureProvider(config, preset.provider);
1180
1195
  if (prepState?.timedOut) {
1181
1196
  throw new Error('agent spawn prep timed out before session bind');
@@ -1,7 +1,7 @@
1
1
  import { isAbsolute, resolve } from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { makeAgentDispatch } from '../runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs';
4
+ import { makeAgentDispatch, resolveMaintenanceRoute } from '../runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs';
5
5
  import { loadConfig } from '../runtime/agent/orchestrator/config.mjs';
6
6
  import { initProviders } from '../runtime/agent/orchestrator/providers/registry.mjs';
7
7
  import { presentErrorText } from '../runtime/shared/err-text.mjs';
@@ -195,10 +195,13 @@ function exploreResultCacheEnabled() {
195
195
  && !/^(?:0|false|off|no)$/i.test(String(process.env.MIXDOG_EXPLORE_RESULT_CACHE || '1'));
196
196
  }
197
197
 
198
- function exploreResultCacheKey({ cwd, presetName, query }) {
198
+ export function exploreResultCacheKey({ cwd, route, query }) {
199
199
  return JSON.stringify({
200
200
  cwd: String(cwd || ''),
201
- presetName: String(presetName || ''),
201
+ provider: clean(route?.provider),
202
+ model: clean(route?.model),
203
+ effort: clean(route?.effort),
204
+ fast: route?.fast === true,
202
205
  query: String(query || ''),
203
206
  });
204
207
  }
@@ -211,13 +214,14 @@ function findConfigPreset(config, presetName) {
211
214
  || null;
212
215
  }
213
216
 
214
- async function ensureExploreProviderReady(config) {
215
- const route = config?.maintenance?.explore;
216
- // Route object ({provider,model}) is the current shape; a string is a
217
- // legacy preset NAME resolved against config.presets.
218
- const provider = (route && typeof route === 'object')
219
- ? clean(route.provider)
220
- : clean(findConfigPreset(config, route)?.provider);
217
+ export function resolveExploreRoute(config) {
218
+ const routeOrName = resolveMaintenanceRoute({ agent: 'explorer', config });
219
+ if (routeOrName && typeof routeOrName === 'object') return routeOrName;
220
+ return findConfigPreset(config, routeOrName);
221
+ }
222
+
223
+ async function ensureExploreProviderReady(config, route) {
224
+ const provider = clean(route?.provider);
221
225
  if (!provider) return;
222
226
  const providers = { ...(config?.providers || {}) };
223
227
  providers[provider] = { ...(providers[provider] || {}), enabled: true };
@@ -411,11 +415,8 @@ async function runExploreSync(args = {}, ctx = {}) {
411
415
  scheduleExploreCodeGraphPrewarm(resolvedCwd);
412
416
  scheduleExploreFindPrewarm(resolvedCwd);
413
417
  const config = loadConfig();
414
- await ensureExploreProviderReady(config);
415
- const route = config?.maintenance?.explore;
416
- const presetName = (route && typeof route === 'object')
417
- ? `${clean(route.provider)}/${clean(route.model)}`
418
- : (route || '');
418
+ const route = resolveExploreRoute(config);
419
+ await ensureExploreProviderReady(config, route);
419
420
  // Turn budget is enforced BOTH ways: the prompt contract (rules/agent/
420
421
  // 30-explorer.md, "hard max 3 tool turns, expected 1") steers the model,
421
422
  // and maxLoopIterations mechanically backstops it — live traces showed
@@ -439,7 +440,7 @@ async function runExploreSync(args = {}, ctx = {}) {
439
440
  // query resolves without delay regardless of index.
440
441
  const stagger = working.length > 1 ? EXPLORE_FANOUT_STAGGER_MS : 0;
441
442
  const settled = await Promise.allSettled(working.map((q, i) => {
442
- const key = exploreResultCacheKey({ cwd: resolvedCwd, presetName, query: q });
443
+ const key = exploreResultCacheKey({ cwd: resolvedCwd, route, query: q });
443
444
  return runExploreCached(key, () => {
444
445
  const delay = (stagger > 0 && i > 0) ? new Promise((r) => setTimeout(r, stagger)) : null;
445
446
  return delay
@@ -49,6 +49,7 @@ export function createProviderSetupPicker({
49
49
  let setup = options.preloadedSetup && typeof options.preloadedSetup === 'object'
50
50
  ? options.preloadedSetup
51
51
  : null;
52
+ options.preloadedSetup = null;
52
53
  if (!setup) {
53
54
  setPicker({
54
55
  title: options.title || 'Providers',
@@ -352,7 +352,11 @@ export function useTranscriptWindow({
352
352
  // measuredRowsVersion bumps, the row index absorbs the growth (idEntry.rows ==
353
353
  // the new measured height) and the live estimate matches it, so delta → 0.
354
354
  if (scrolledUp && !followingRef.current) {
355
- const growth = streamingTailMountedGrowth(transcriptItems, frameColumns, toolOutputExpanded);
355
+ const growth = streamingTailMountedGrowth(
356
+ streamingTailItem ? [streamingTailItem] : transcriptItems,
357
+ frameColumns,
358
+ toolOutputExpanded,
359
+ );
356
360
  // Only compensate when the tail is actually mounted in the rendered slice
357
361
  // (viewport + overscan). Off-slice it is represented by a row-index-sized
358
362
  // bottom spacer that does NOT physically grow this frame, so shifting the
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import React, { useEffect, useRef, useState } from 'react';
10
10
  import { Box, Text } from 'ink';
11
+ import { rgbSgr } from '../../ui/ansi.mjs';
11
12
  import { displayModelName, shortenModelName } from '../../ui/model-display.mjs';
12
13
  import { theme, surfaceBackground } from '../theme.mjs';
13
14
  import {
@@ -116,7 +117,7 @@ function scheduleBootFullRetry(backoffMsRef, nextAttemptAtRef) {
116
117
  function ansiRgb(value, fallback) {
117
118
  const match = /^rgb\((\d+),(\d+),(\d+)\)$/.exec(String(value || '').replace(/\s+/g, ''));
118
119
  if (!match) return fallback;
119
- return `\x1b[38;2;${match[1]};${match[2]};${match[3]}m`;
120
+ return rgbSgr(match[1], match[2], match[3]);
120
121
  }
121
122
 
122
123
  // SGR escapes derived from the active theme. Resolved per call (not captured at
@@ -124,11 +125,11 @@ function ansiRgb(value, fallback) {
124
125
  // render. `theme` is mutated in-place on switch.
125
126
  function statusColors() {
126
127
  return {
127
- STATUS: ansiRgb(theme.statusText, '\x1b[38;2;198;198;198m'),
128
- SUBTLE: ansiRgb(theme.statusSubtle, '\x1b[38;2;136;136;136m'),
129
- SUCCESS: ansiRgb(theme.success, '\x1b[38;2;0;170;75m'),
130
- WARNING: ansiRgb(theme.warning, '\x1b[38;2;255;193;7m'),
131
- ERROR: ansiRgb(theme.error, '\x1b[38;2;220;70;88m'),
128
+ STATUS: ansiRgb(theme.statusText, rgbSgr(198, 198, 198)),
129
+ SUBTLE: ansiRgb(theme.statusSubtle, rgbSgr(136, 136, 136)),
130
+ SUCCESS: ansiRgb(theme.success, rgbSgr(0, 170, 75)),
131
+ WARNING: ansiRgb(theme.warning, rgbSgr(255, 193, 7)),
132
+ ERROR: ansiRgb(theme.error, rgbSgr(220, 70, 88)),
132
133
  };
133
134
  }
134
135