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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/UNINSTALL.md +7 -4
- package/bin/statusline-lib.mjs +29 -6
- package/bin/statusline-route.mjs +273 -0
- package/bin/statusline.mjs +31 -8
- package/commands/model.md +61 -0
- package/hooks/session-start.cjs +15 -1
- package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +1 -1
- package/rules/lead/01-general.md +3 -0
- package/scripts/gateway-model.mjs +596 -0
- package/scripts/lib/gateway-inventory.mjs +178 -0
- package/scripts/lib/gateway-settings.mjs +78 -0
- package/scripts/run-mcp.mjs +26 -1
- package/scripts/statusline-launcher-smoke.mjs +155 -2
- package/scripts/uninstall.mjs +44 -7
- package/server-main.mjs +252 -11
- package/src/agent/index.mjs +96 -5
- package/src/agent/orchestrator/bridge-trace.mjs +18 -0
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +4 -1
- package/src/agent/orchestrator/providers/anthropic.mjs +6 -2
- package/src/agent/orchestrator/session/loop.mjs +145 -4
- package/src/agent/orchestrator/session/trim.mjs +1 -1
- package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +62 -6
- package/src/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/agent/orchestrator/tools/patch-manifest.json +7 -7
- package/src/agent/tool-defs.mjs +10 -3
- package/src/channels/lib/runtime-paths.mjs +39 -4
- package/src/gateway/claude-current.mjs +255 -0
- package/src/gateway/oauth-usage.mjs +598 -0
- package/src/gateway/route-meta.mjs +629 -0
- package/src/gateway/server.mjs +713 -0
- package/src/shared/llm/cost.mjs +1 -1
- package/src/shared/seed.mjs +25 -0
- 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
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/agent/index.mjs
CHANGED
|
@@ -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.
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|