mixdog 0.7.17 → 0.7.18

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/UNINSTALL.md +7 -4
  4. package/bin/statusline-lib.mjs +29 -6
  5. package/bin/statusline-route.mjs +273 -0
  6. package/bin/statusline.mjs +31 -8
  7. package/commands/model.md +61 -0
  8. package/hooks/session-start.cjs +15 -1
  9. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  10. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  11. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  12. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  13. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  14. package/package.json +1 -1
  15. package/rules/lead/01-general.md +3 -0
  16. package/scripts/gateway-model.mjs +596 -0
  17. package/scripts/lib/gateway-inventory.mjs +178 -0
  18. package/scripts/lib/gateway-settings.mjs +78 -0
  19. package/scripts/run-mcp.mjs +26 -1
  20. package/scripts/statusline-launcher-smoke.mjs +155 -2
  21. package/scripts/uninstall.mjs +44 -7
  22. package/server-main.mjs +252 -11
  23. package/src/agent/index.mjs +96 -5
  24. package/src/agent/orchestrator/bridge-trace.mjs +18 -0
  25. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +4 -1
  26. package/src/agent/orchestrator/providers/anthropic.mjs +6 -2
  27. package/src/agent/orchestrator/session/loop.mjs +145 -4
  28. package/src/agent/orchestrator/session/trim.mjs +1 -1
  29. package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +62 -6
  30. package/src/agent/orchestrator/tools/graph-manifest.json +11 -11
  31. package/src/agent/orchestrator/tools/patch-manifest.json +7 -7
  32. package/src/agent/tool-defs.mjs +10 -3
  33. package/src/channels/lib/runtime-paths.mjs +39 -4
  34. package/src/gateway/claude-current.mjs +255 -0
  35. package/src/gateway/oauth-usage.mjs +598 -0
  36. package/src/gateway/route-meta.mjs +629 -0
  37. package/src/gateway/server.mjs +713 -0
  38. package/src/shared/llm/cost.mjs +1 -1
  39. package/src/shared/seed.mjs +25 -0
  40. package/tools.json +21 -3
package/server-main.mjs CHANGED
@@ -237,26 +237,46 @@ process.once('exit', () => {
237
237
  globalThis.__tribFastEntry = true
238
238
 
239
239
  // ── Module enable flags (B6 General toggles) ──────────────────────
240
- // Snapshotted once at boot toggling in the setup UI requires a full
241
- // plugin restart to take effect. All four default to enabled:true when
242
- // the `modules` section is absent (backcompat for pre-B6 configs).
243
- const MODULE_NAMES = ['channels', 'memory', 'search', 'agent']
240
+ // Tool-facing modules are snapshotted once at boot because their advertised
241
+ // tool list is static for the MCP session. `gateway` is special: it has no MCP
242
+ // tools and is an owned HTTP child, so server-main reconciles its runtime
243
+ // process when modules.gateway.enabled changes below.
244
+ const MODULE_NAMES = ['channels', 'memory', 'search', 'agent', 'gateway']
245
+ const MIXDOG_CONFIG_PATH = join(PLUGIN_DATA, 'mixdog-config.json')
244
246
  const MODULE_ENABLED = (() => {
245
- const out = { channels: true, memory: true, search: true, agent: true }
247
+ // channels/memory/search/agent are opt-OUT (enabled unless explicitly
248
+ // disabled) for pre-B6 backcompat. `gateway` is opt-IN: OFF unless the
249
+ // user explicitly sets modules.gateway.enabled === true, so existing
250
+ // installs that never heard of the gateway are completely unaffected.
251
+ const out = { channels: true, memory: true, search: true, agent: true, gateway: false }
246
252
  try {
247
- const raw = JSON.parse(readFileSync(join(PLUGIN_DATA, 'mixdog-config.json'), 'utf8'))
253
+ const raw = JSON.parse(readFileSync(MIXDOG_CONFIG_PATH, 'utf8'))
248
254
  const mods = raw && typeof raw === 'object' ? raw.modules : null
249
255
  if (mods && typeof mods === 'object') {
250
256
  for (const name of MODULE_NAMES) {
251
257
  const entry = mods[name]
252
- if (entry && typeof entry === 'object' && entry.enabled === false) out[name] = false
258
+ if (!entry || typeof entry !== 'object') continue
259
+ if (name === 'gateway') {
260
+ if (entry.enabled === true) out[name] = true
261
+ } else if (entry.enabled === false) {
262
+ out[name] = false
263
+ }
253
264
  }
254
265
  }
255
- } catch { /* missing / malformed — keep all enabled */ }
266
+ } catch { /* missing / malformed — keep opt-out modules enabled, gateway off */ }
256
267
  return out
257
268
  })()
258
269
  const isModuleEnabled = (name) => MODULE_ENABLED[name] !== false
259
270
 
271
+ function readGatewayModuleEnabled() {
272
+ try {
273
+ const raw = JSON.parse(readFileSync(MIXDOG_CONFIG_PATH, 'utf8'))
274
+ return raw?.modules?.gateway?.enabled === true
275
+ } catch {
276
+ return false
277
+ }
278
+ }
279
+
260
280
  // ── Static manifest ─────────────────────────────────────────────────
261
281
  // Dev-only self-heal: if a TOOL_DEFS source was edited after the last
262
282
  // tools.json build, regenerate before loading so boot never serves a stale
@@ -636,6 +656,195 @@ function spawnStatusServer() {
636
656
  }
637
657
  spawnStatusServer()
638
658
 
659
+ // ── Gateway HTTP server (forked child) ─────────────────────────────
660
+ // Anthropic-compatible local gateway: routes an external Claude Code's MAIN
661
+ // model to a mixdog provider. OFF by default (modules.gateway.enabled !==
662
+ // true) so existing installs are unaffected. Lives in its OWN forked process
663
+ // (like the status server) so the HTTP request loop can't starve the MCP
664
+ // parent's event loop. The child reads gateway.defaultProvider /
665
+ // gateway.defaultModel / gateway.port from config (+ MIXDOG_GATEWAY_* env),
666
+ // binds a fixed port (default 3468), and advertises gateway_port into
667
+ // active-instance.json — mirroring the memory worker's port advertisement.
668
+ let gatewayServerChild = null
669
+ let gatewayServerRestartTimer = null
670
+ let gatewayServerStopping = false
671
+ let gatewayServerFinalShutdown = false
672
+ let gatewayServerDesiredEnabled = isModuleEnabled('gateway')
673
+ let gatewayServerReconcileChain = Promise.resolve()
674
+ const GATEWAY_STOP_TIMEOUT_MS = 3000
675
+
676
+ function scheduleGatewayServerRestart() {
677
+ if (gatewayServerFinalShutdown) return
678
+ if (gatewayServerStopping) return
679
+ if (!gatewayServerDesiredEnabled) return
680
+ if (gatewayServerChild) return
681
+ if (gatewayServerRestartTimer) return
682
+ gatewayServerRestartTimer = setTimeout(() => {
683
+ gatewayServerRestartTimer = null
684
+ spawnGatewayServer()
685
+ }, 1000)
686
+ gatewayServerRestartTimer.unref?.()
687
+ }
688
+
689
+ function spawnGatewayServer() {
690
+ if (gatewayServerFinalShutdown) return
691
+ if (gatewayServerStopping) return
692
+ if (!gatewayServerDesiredEnabled) return
693
+ if (gatewayServerChild) return
694
+ if (gatewayServerRestartTimer) {
695
+ clearTimeout(gatewayServerRestartTimer)
696
+ gatewayServerRestartTimer = null
697
+ }
698
+ try {
699
+ gatewayServerChild = fork(
700
+ join(PLUGIN_ROOT, 'src/gateway/server.mjs'),
701
+ [],
702
+ {
703
+ env: {
704
+ ...process.env,
705
+ CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
706
+ CLAUDE_PLUGIN_DATA: PLUGIN_DATA,
707
+ MIXDOG_SESSION_ID: SESSION_ID,
708
+ MIXDOG_OWNER_SESSION_ID: SESSION_ID,
709
+ MIXDOG_SERVER_PID: String(process.pid),
710
+ MIXDOG_OWNER_LEAD_PID: process.env.MIXDOG_SUPERVISOR_PID || '',
711
+ },
712
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
713
+ windowsHide: true,
714
+ }
715
+ )
716
+ const _emitGatewayLines = (prefix, chunk) => {
717
+ const text = String(chunk)
718
+ if (!text) return
719
+ const lines = text.split(/\r?\n/)
720
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
721
+ for (const line of lines) {
722
+ if (!line) continue
723
+ log(prefix ? `${prefix}${line}` : line)
724
+ }
725
+ }
726
+ gatewayServerChild.stdout?.on('data', (d) => _emitGatewayLines('', d))
727
+ gatewayServerChild.stderr?.on('data', (d) => _emitGatewayLines('[gateway] stderr: ', d))
728
+ gatewayServerChild.on('message', (msg) => {
729
+ if (msg && msg.type === 'gateway_ready' && Number.isFinite(msg.port)) {
730
+ log(`[gateway] ready on port ${msg.port}`)
731
+ }
732
+ })
733
+ gatewayServerChild.on('error', (e) => {
734
+ log(`[gateway] child error: ${(e && (e.stack || e.message)) || e}`)
735
+ gatewayServerChild = null
736
+ scheduleGatewayServerRestart()
737
+ })
738
+ gatewayServerChild.on('exit', (code, signal) => {
739
+ log(`[gateway] child exited code=${code} signal=${signal}`)
740
+ gatewayServerChild = null
741
+ // A clean exit (code 0) means the gateway intentionally went idle
742
+ // (enabled but no provider/model configured, or provider not enabled).
743
+ // Don't respawn-loop in that case — only restart on a crash.
744
+ if (code === 0) return
745
+ scheduleGatewayServerRestart()
746
+ })
747
+ } catch (e) {
748
+ log(`[gateway] failed to fork: ${(e && (e.stack || e.message)) || e}`)
749
+ scheduleGatewayServerRestart()
750
+ }
751
+ }
752
+
753
+ async function stopGatewayServer(reason = 'runtime', { final = false } = {}) {
754
+ if (final) gatewayServerFinalShutdown = true
755
+ if (gatewayServerRestartTimer) {
756
+ clearTimeout(gatewayServerRestartTimer)
757
+ gatewayServerRestartTimer = null
758
+ }
759
+ const child = gatewayServerChild
760
+ if (!child) return
761
+ gatewayServerStopping = true
762
+ const pid = child.pid
763
+ try {
764
+ const exited = new Promise(resolve => child.once('exit', () => resolve(false)))
765
+ let requested = false
766
+ try {
767
+ if (child.connected) {
768
+ child.send({ type: 'shutdown', reason })
769
+ requested = true
770
+ }
771
+ } catch {}
772
+ if (!requested) {
773
+ try { child.kill('SIGTERM') } catch {}
774
+ }
775
+ const timedOut = await Promise.race([
776
+ exited,
777
+ new Promise(resolve => setTimeout(() => resolve(true), GATEWAY_STOP_TIMEOUT_MS)),
778
+ ])
779
+ if (timedOut) {
780
+ try {
781
+ if (process.platform === 'win32' && pid) {
782
+ const { execSync: _execSync } = await import('node:child_process')
783
+ _execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore', windowsHide: true, timeout: GATEWAY_STOP_TIMEOUT_MS })
784
+ } else {
785
+ child.kill('SIGKILL')
786
+ }
787
+ } catch {}
788
+ }
789
+ } catch (e) {
790
+ log(`[gateway] stop failed (${reason}): ${(e && (e.stack || e.message)) || e}`)
791
+ } finally {
792
+ if (gatewayServerChild === child) gatewayServerChild = null
793
+ if (!gatewayServerFinalShutdown) gatewayServerStopping = false
794
+ }
795
+ }
796
+
797
+ async function reconcileGatewayServer(reason = 'config') {
798
+ const nextEnabled = readGatewayModuleEnabled()
799
+ const changed = gatewayServerDesiredEnabled !== nextEnabled
800
+ gatewayServerDesiredEnabled = nextEnabled
801
+
802
+ if (gatewayServerDesiredEnabled) {
803
+ if (changed) log(`[gateway] module enabled after ${reason} — starting gateway server`)
804
+ spawnGatewayServer()
805
+ return
806
+ }
807
+
808
+ if (gatewayServerChild || gatewayServerRestartTimer) {
809
+ log(`[gateway] module disabled after ${reason} — stopping gateway server`)
810
+ await stopGatewayServer(reason)
811
+ } else if (reason === 'boot') {
812
+ log(`module 'gateway' disabled — skipping gateway server`)
813
+ }
814
+ }
815
+
816
+ function queueGatewayServerReconcile(reason = 'config') {
817
+ gatewayServerReconcileChain = gatewayServerReconcileChain
818
+ .catch(() => {})
819
+ .then(() => reconcileGatewayServer(reason))
820
+ .catch(e => log(`[gateway] reconcile failed after ${reason}: ${(e && (e.stack || e.message)) || e}`))
821
+ }
822
+
823
+ function watchGatewayModuleConfig() {
824
+ let debounceTimer = null
825
+ const trigger = (reason) => {
826
+ if (debounceTimer) clearTimeout(debounceTimer)
827
+ debounceTimer = setTimeout(() => {
828
+ debounceTimer = null
829
+ queueGatewayServerReconcile(reason)
830
+ }, 250)
831
+ debounceTimer.unref?.()
832
+ }
833
+ try {
834
+ fsWatch(PLUGIN_DATA, { recursive: false, persistent: true }, (_eventType, filename) => {
835
+ if (!filename) return
836
+ const norm = String(filename).replace(/\\/g, '/')
837
+ if (norm.split('/').pop() !== 'mixdog-config.json') return
838
+ trigger('mixdog-config.json')
839
+ })
840
+ log(`[gateway-watcher] watching ${MIXDOG_CONFIG_PATH}`)
841
+ } catch (e) {
842
+ log(`[gateway-watcher] failed to watch ${PLUGIN_DATA}: ${(e && (e.stack || e.message)) || e}`)
843
+ }
844
+ }
845
+ watchGatewayModuleConfig()
846
+ queueGatewayServerReconcile('boot')
847
+
639
848
  // ── Crash handlers ──────────────────────────────────────────────────
640
849
  // Leave a trace on silent hangs. Previously only child workers
641
850
  // (channels/memory) installed these; the main MCP entry had none, so
@@ -1758,7 +1967,11 @@ function pushChannelNotification(content, extraMeta) {
1758
1967
  function agentContext() {
1759
1968
  return {
1760
1969
  notifyFn: (text, extraMeta) => pushChannelNotification(text, extraMeta),
1761
- elicitFn: opts => server.elicitInput(opts),
1970
+ // Default (stdio) elicitFn capability-gated so it returns null when the
1971
+ // client cannot do elicitation (tools degrade gracefully instead of the SDK
1972
+ // throwing "Client does not support elicitation"). The daemon path overrides
1973
+ // this with a per-connection elicitFn via callerCtx in _dispatchByModule.
1974
+ elicitFn: _buildElicitFn(server),
1762
1975
  // In-process tool bridge. External LLMs see the plugin's non-agent tools
1763
1976
  // (search, search_memories, channels actions, etc.) and their tool_calls
1764
1977
  // land back in dispatchTool, which routes to the same worker IPC /
@@ -2351,6 +2564,10 @@ async function _dispatchByModule(name, args, callerCtx, def) {
2351
2564
  const ctx = agentContext()
2352
2565
  if (callerCtx?.requestSignal) ctx.requestSignal = callerCtx.requestSignal
2353
2566
  if (callerCtx?.callerCwd) ctx.callerCwd = callerCtx.callerCwd
2567
+ // Prefer the connection-scoped elicitFn (daemon-correct, capability-gated)
2568
+ // threaded from runToolCall; null when the client lacks elicitation, so the
2569
+ // tool degrades gracefully. Overrides agentContext()'s module-global default.
2570
+ if ('elicitFn' in callerCtx) ctx.elicitFn = callerCtx.elicitFn
2354
2571
  // Tag the dispatching MCP session so a detached bridge worker's result
2355
2572
  // routes back to THIS terminal (daemon router), not the Lead connection.
2356
2573
  if (callerCtx?.callerSession?.sessionId) ctx.routingSessionId = callerCtx.callerSession.sessionId
@@ -2484,7 +2701,22 @@ function _buildProgressReporter(progressCtx) {
2484
2701
  }
2485
2702
  }
2486
2703
 
2487
- async function runToolCall(session, name, rawArgs, requestSignal, progressCtx = null) {
2704
+ // Build a connection-scoped elicitFn for the active MCP server, or null when
2705
+ // the client did not declare the `elicitation` capability (graceful
2706
+ // degradation — the gateway_select_model tool then falls back to listing).
2707
+ // connServer is the SDK Server actually connected to THIS client (module-global
2708
+ // `server` for stdio, the per-connection `perConn` in daemon mode), so the
2709
+ // elicitation/create request reaches the right transport. elicitFn returns the
2710
+ // raw SDK ElicitResult { action: 'accept'|'decline'|'cancel', content? }.
2711
+ function _buildElicitFn(connServer) {
2712
+ let caps = null;
2713
+ try { caps = connServer?.getClientCapabilities?.() || null; } catch { caps = null; }
2714
+ if (!caps || !caps.elicitation) return null;
2715
+ return ({ message, requestedSchema }) =>
2716
+ connServer.elicitInput({ message, requestedSchema, mode: 'form' });
2717
+ }
2718
+
2719
+ async function runToolCall(session, name, rawArgs, requestSignal, progressCtx = null, connServer = server) {
2488
2720
  const _entryT = Date.now()
2489
2721
  const args = MEMORY_FAMILY_TOOLS.has(name) && rawArgs && typeof rawArgs === 'object'
2490
2722
  ? { ...rawArgs, sessionId: rawArgs.sessionId ?? session.sessionId }
@@ -2512,12 +2744,17 @@ async function runToolCall(session, name, rawArgs, requestSignal, progressCtx =
2512
2744
  // build a monotonic-progress reporter ONLY when a token is present, so a
2513
2745
  // client that did not subscribe sees byte-identical behaviour (no emits).
2514
2746
  const _progressReporter = _buildProgressReporter(progressCtx)
2747
+ // Connection-scoped interactive elicitation (null when the client lacks the
2748
+ // capability → tools degrade to non-interactive). Threaded through callerCtx
2749
+ // so agentContext() prefers it over the module-global stdio fallback.
2750
+ const _elicitFn = _buildElicitFn(connServer)
2515
2751
  const _postT0 = Date.now()
2516
2752
  const result = await dispatchTool(name, args, {
2517
2753
  requestSignal,
2518
2754
  callerSession: session,
2519
2755
  callerCwd: session.resolveCwd(),
2520
2756
  progress: _progressReporter,
2757
+ elicitFn: _elicitFn,
2521
2758
  })
2522
2759
  const _postT1 = Date.now()
2523
2760
  // Apply the same chained safe-compression + trim passes the bridge role loop
@@ -2595,7 +2832,7 @@ async function serveDaemon(dataDir) {
2595
2832
  )
2596
2833
  perConn.setRequestHandler(ListToolsRequestSchema, async () => LIST_TOOLS_RESPONSE)
2597
2834
  perConn.setRequestHandler(CallToolRequestSchema, async (req, extra) =>
2598
- runToolCall(session, req.params.name, req.params.arguments, extra?.signal, extra))
2835
+ runToolCall(session, req.params.name, req.params.arguments, extra?.signal, extra, perConn))
2599
2836
  // Channel permission requests: forward to the shared channels worker and
2600
2837
  // record request_id → this connection so the worker's response routes back
2601
2838
  // here (the daemon notify router). Each connection registers its own.
@@ -3051,6 +3288,10 @@ async function shutdown(reason) {
3051
3288
  // Belt-and-braces: unlink the advertisement file if child didn't.
3052
3289
  try { unlinkSync(STATUS_ADVERTISE_PATH) } catch {}
3053
3290
  }
3291
+ // Stop gateway HTTP server child — IPC shutdown retracts gateway_port from
3292
+ // active-instance.json before the process exits. Reuse the runtime toggle
3293
+ // helper so /mixdog:model off and process shutdown stay consistent.
3294
+ await stopGatewayServer('shutdown', { final: true })
3054
3295
  // Graceful worker shutdown: IPC/SIGTERM → wait → force kill only as last resort.
3055
3296
  // Avoids taskkill /F /T which may corrupt pgdata.
3056
3297
  for (const [name, entry] of workers) {
@@ -5,6 +5,13 @@ import { publishHeartbeat as publishSessionHeartbeat } from './orchestrator/sess
5
5
  import { StreamStalledAbortError, startWatchdog as startStreamWatchdog } from './orchestrator/session/stream-watchdog.mjs';
6
6
  import { startBridgeStallWatchdog } from './bridge-stall-watchdog.mjs';
7
7
  import { loadConfig, getPluginData, listPresets, getDefaultPreset, resolveRuntimeSpec } from './orchestrator/config.mjs';
8
+ import { updateSection } from '../shared/config.mjs';
9
+ import { readGatewayRouteInfo } from '../gateway/route-meta.mjs';
10
+ import { CLAUDE_CURRENT_MODE } from '../gateway/claude-current.mjs';
11
+ // Shared gateway inventory/target helpers — SAME module scripts/gateway-model.mjs
12
+ // uses, so the interactive gateway_select_model tool and the CLI build the model
13
+ // list and persist the routing target through one code path.
14
+ import { buildInventory as buildGatewayInventory, inventoryChoices as gatewayInventoryChoices, parseChoiceId as parseGatewayChoiceId, resolveTargetChoice as resolveGatewayTargetChoice, writeGatewayTarget } from '../../scripts/lib/gateway-inventory.mjs';
8
15
  import { connectMcpServers, disconnectAll } from './orchestrator/mcp/client.mjs';
9
16
  import { setInternalToolsProvider, awaitBootReady } from './orchestrator/internal-tools.mjs';
10
17
  import { listWorkflows, getWorkflow, seedDefaults } from './orchestrator/workflow-store.mjs';
@@ -1219,6 +1226,10 @@ export async function handleToolCall(name, args, opts = {}) {
1219
1226
  const current = getDefaultPreset(cfg);
1220
1227
  if (presets.length === 0) return ok('No presets configured.');
1221
1228
  const currentLabel = current ? `${current.model}${current.effort ? ' · ' + current.effort : ''}${current.fast ? ' · fast' : ''}` : 'none';
1229
+ const routeInfo = readGatewayRouteInfo();
1230
+ const gatewayRoute = routeInfo?.provider && routeInfo?.model
1231
+ ? `${routeInfo.provider} / ${routeInfo.model}${routeInfo.effort ? ' · ' + routeInfo.effort : ''}${routeInfo.fast ? ' · fast' : ''}`
1232
+ : 'none';
1222
1233
  // list_models is read-only; default-preset changes go through
1223
1234
  // mixdog-config.json or the config UI, not interactive elicit.
1224
1235
  const lines = presets.map((p, i) => {
@@ -1228,7 +1239,84 @@ export async function handleToolCall(name, args, opts = {}) {
1228
1239
  const mark = current && p.name === current.name ? ' ← active' : '';
1229
1240
  return `[${i}] ${parts.join(' · ')}${mark}`;
1230
1241
  });
1231
- return ok({ current: currentLabel, presets: lines });
1242
+ return ok({ current: currentLabel, defaultPreset: currentLabel, gatewayRoute, presets: lines });
1243
+ }
1244
+
1245
+ case 'gateway_select_model': {
1246
+ // Interactive gateway routing-target picker via MCP elicitation.
1247
+ // Opt-in (only on explicit invocation). Builds the model inventory from
1248
+ // the SAME shared helper the CLI uses, then asks the client to render a
1249
+ // dropdown. Degrades gracefully when elicitation is unavailable.
1250
+ const cfg = loadConfig();
1251
+ const inventory = buildGatewayInventory(cfg);
1252
+ const choices = gatewayInventoryChoices(inventory);
1253
+ if (choices.length === 0) {
1254
+ return fail('No enabled agent providers found. Enable a provider in /mixdog:config first.');
1255
+ }
1256
+ const elicitFn = typeof opts.elicitFn === 'function' ? opts.elicitFn : null;
1257
+ // Graceful degradation: no elicitation support → list + point at the
1258
+ // slash command instead of opening a dropdown.
1259
+ if (!elicitFn) {
1260
+ const listing = choices.map((c) => ` • ${c.label}`).join('\n');
1261
+ return ok(
1262
+ 'Interactive selection is unavailable (this client does not support MCP elicitation).\n'
1263
+ + `Available gateway targets:\n${listing}\n\n`
1264
+ + 'Run /mixdog:model (or: bun scripts/gateway-model.mjs --set <provider> [model]) to choose one.',
1265
+ );
1266
+ }
1267
+ const ids = choices.map((c) => c.id);
1268
+ const labels = choices.map((c) => c.label);
1269
+ const listing = choices.map((c, i) => ` ${i + 1}. ${c.label}`).join('\n');
1270
+ let res;
1271
+ try {
1272
+ res = await elicitFn({
1273
+ message:
1274
+ 'Select how the mixdog gateway should route Claude Code\'s main model:\n\n'
1275
+ + `${listing}\n\n`
1276
+ + 'The first option follows Claude Code\'s native /model setting on future requests.',
1277
+ requestedSchema: {
1278
+ type: 'object',
1279
+ properties: {
1280
+ model: { type: 'string', title: 'Gateway model', enum: ids, enumNames: labels },
1281
+ },
1282
+ required: ['model'],
1283
+ },
1284
+ });
1285
+ } catch (e) {
1286
+ // elicitInput throws if the client capability is missing despite the
1287
+ // hook being present — fall back to the listing rather than erroring.
1288
+ const listing = choices.map((c) => ` • ${c.label}`).join('\n');
1289
+ return ok(
1290
+ `Interactive selection failed (${errText(e)}).\n`
1291
+ + `Available gateway targets:\n${listing}\n\n`
1292
+ + 'Run /mixdog:model to choose one.',
1293
+ );
1294
+ }
1295
+ if (!res || res.action !== 'accept') {
1296
+ return ok(`Selection ${res?.action || 'cancelled'} — gateway routing unchanged.`);
1297
+ }
1298
+ const chosenId = res.content && typeof res.content.model === 'string' ? res.content.model : null;
1299
+ const choice = choices.find((c) => c.id === chosenId) || null;
1300
+ const parsed = choice || parseGatewayChoiceId(chosenId);
1301
+ const provider = parsed?.provider || null;
1302
+ let model = parsed?.model || null;
1303
+ if (!provider) return fail(`Invalid selection: ${chosenId}`);
1304
+ // A provider-only choice (no concrete model) resolves to its first
1305
+ // candidate model from the inventory.
1306
+ if (!model) {
1307
+ const inv = inventory.find((e) => e.provider === provider);
1308
+ model = inv && inv.models.length ? inv.models[0] : null;
1309
+ }
1310
+ if (!model) return fail(`No model available for "${provider}".`);
1311
+ const meta = choice || resolveGatewayTargetChoice(inventory, provider, model, false);
1312
+ writeGatewayTarget(updateSection, provider, model, meta);
1313
+ const label = meta?.mode === CLAUDE_CURRENT_MODE
1314
+ ? `Claude Code current (${meta.modelDisplay || model}) — follows Claude Code /model`
1315
+ : `${provider} / ${model}${meta?.effort ? ` · ${String(meta.effort).toUpperCase()}` : ''}${meta?.fast ? ' · fast' : ''}`;
1316
+ return ok(
1317
+ `Gateway routing set: ${label}\n`
1318
+ + 'modules.gateway.enabled = true — live on the next gateway request (no restart needed if the gateway is running).',
1319
+ );
1232
1320
  }
1233
1321
 
1234
1322
  case 'get_workflows': {
@@ -1564,8 +1652,7 @@ export async function handleToolCall(name, args, opts = {}) {
1564
1652
  // --- spawn (default): dispatch a detached worker ---
1565
1653
  // Enforce exactly-one-of: prompt | file | ref. Schema is the first
1566
1654
  // gate; _resolveBridgePrompt is the second defence for clients that
1567
- // bypass JSON Schema validation. `message` is the unified alias for
1568
- // `prompt` on spawn — fold it in before resolution. Resolve the bridge
1655
+ // bypass JSON Schema validation. Resolve the bridge
1569
1656
  // worker's cwd BEFORE reading args.file so a relative path is resolved
1570
1657
  // against the worker's effective workspace (args.cwd > callerCwd), not
1571
1658
  // the agent host process cwd.
@@ -1580,7 +1667,6 @@ export async function handleToolCall(name, args, opts = {}) {
1580
1667
  if (isCurrentDaemonDoomed()) {
1581
1668
  return fail('bridge spawn deferred: this daemon (server_pid) is being recycled by dev-sync — retry; the next call reconnects to the fresh daemon');
1582
1669
  }
1583
- if (args.message != null && args.prompt == null) args = { ...args, prompt: args.message };
1584
1670
  // Allocate the tag up front so a duplicate-live-tag request fails fast
1585
1671
  // before any session is created.
1586
1672
  const _tagAlloc = _allocateBridgeTag(args.tag, args.role);
@@ -1588,7 +1674,12 @@ export async function handleToolCall(name, args, opts = {}) {
1588
1674
  const bridgeTag = _tagAlloc.tag;
1589
1675
  const { _rawBridgeCwd, _safeBridgeCwd } = _buildBridgeCwds(args, callerCwd);
1590
1676
  const _promptResolution = await _resolveBridgePrompt(args, _rawBridgeCwd);
1591
- if (_promptResolution.error) return fail(_promptResolution.error);
1677
+ if (_promptResolution.error) {
1678
+ const _messageOnly = args.message != null && args.prompt == null && args.file == null && args.ref == null;
1679
+ return fail(_messageOnly
1680
+ ? 'bridge spawn requires prompt (or file/ref). message is only for send.'
1681
+ : _promptResolution.error);
1682
+ }
1592
1683
  let prompt = _promptResolution.prompt;
1593
1684
 
1594
1685
  // Bench escape hatch — when both provider and model are supplied,
@@ -270,6 +270,7 @@ function messagePrefixHash(messages) {
270
270
  function traceBridgeTrim({
271
271
  sessionId,
272
272
  iteration,
273
+ stage,
273
274
  prune_count,
274
275
  trim_changed,
275
276
  input_prefix_hash,
@@ -277,12 +278,21 @@ function traceBridgeTrim({
277
278
  after_count,
278
279
  before_bytes,
279
280
  after_bytes,
281
+ context_window,
282
+ budget_tokens,
283
+ reserve_tokens,
284
+ message_tokens_est,
285
+ provider,
286
+ model,
287
+ error,
288
+ error_code,
280
289
  }) {
281
290
  if (process.env.MIXDOG_BRIDGE_TRACE_VERBOSE !== '1') return;
282
291
  appendBridgeTrace({
283
292
  sessionId,
284
293
  iteration,
285
294
  kind: 'trim_meta',
295
+ stage: stage || null,
286
296
  prune_count: prune_count ?? 0,
287
297
  trim_changed: !!trim_changed,
288
298
  input_prefix_hash: input_prefix_hash || null,
@@ -290,6 +300,14 @@ function traceBridgeTrim({
290
300
  after_count: after_count ?? null,
291
301
  before_bytes: before_bytes ?? null,
292
302
  after_bytes: after_bytes ?? null,
303
+ context_window: context_window ?? null,
304
+ budget_tokens: budget_tokens ?? null,
305
+ reserve_tokens: reserve_tokens ?? null,
306
+ message_tokens_est: message_tokens_est ?? null,
307
+ provider: provider || null,
308
+ model: model || null,
309
+ error: error || null,
310
+ error_code: error_code || null,
293
311
  });
294
312
  }
295
313
 
@@ -1187,7 +1187,10 @@ function buildRequestBody(messages, model, tools, sendOpts) {
1187
1187
  body.tools = toAnthropicTools(tools);
1188
1188
  }
1189
1189
 
1190
- if (opts.effort) {
1190
+ const thinkingBudgetTokens = Number(opts.thinkingBudgetTokens);
1191
+ if (Number.isFinite(thinkingBudgetTokens) && thinkingBudgetTokens > 0) {
1192
+ body.thinking = { type: 'enabled', budget_tokens: Math.floor(thinkingBudgetTokens) };
1193
+ } else if (opts.effort) {
1191
1194
  if (EFFORT_BUDGET[opts.effort]) {
1192
1195
  body.thinking = { type: 'enabled', budget_tokens: EFFORT_BUDGET[opts.effort] };
1193
1196
  } else if (!_LOGGED_UNKNOWN_EFFORT.has(opts.effort)) {
@@ -320,8 +320,12 @@ export class AnthropicProvider {
320
320
  // Anthropic prefix semantics (order: tools → system → messages).
321
321
  params.tools = toAnthropicTools(tools);
322
322
  }
323
- // Effort → extended thinking budget
324
- if (opts.effort && EFFORT_BUDGET[opts.effort]) {
323
+ // Effort → extended thinking budget. Gateway inherit mode may pass the
324
+ // exact Claude Code budget from the incoming Anthropic request.
325
+ const thinkingBudgetTokens = Number(opts.thinkingBudgetTokens);
326
+ if (Number.isFinite(thinkingBudgetTokens) && thinkingBudgetTokens > 0) {
327
+ params.thinking = { type: 'enabled', budget_tokens: Math.floor(thinkingBudgetTokens) };
328
+ } else if (opts.effort && EFFORT_BUDGET[opts.effort]) {
325
329
  params.thinking = { type: 'enabled', budget_tokens: EFFORT_BUDGET[opts.effort] };
326
330
  }
327
331
  // Fast mode → speed: "fast" on models Anthropic marks as speed-capable.