mixdog 0.7.1
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 +31 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.gitattributes +34 -0
- package/.mcp.json +14 -0
- package/ARCHITECTURE.md +77 -0
- package/CHANGELOG.md +7 -0
- package/CONTRIBUTING.md +45 -0
- package/DATA-FLOW.md +79 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/SECURITY.md +138 -0
- package/UNINSTALL.md +112 -0
- package/agents/maintenance.md +5 -0
- package/agents/memory-classification.md +30 -0
- package/agents/scheduler-task.md +18 -0
- package/agents/webhook-handler.md +27 -0
- package/agents/worker.md +24 -0
- package/bin/bridge +133 -0
- package/bin/statusline-launcher.mjs +78 -0
- package/bin/statusline-lib.mjs +550 -0
- package/bin/statusline.mjs +607 -0
- package/bun.lock +802 -0
- package/commands/config.md +16 -0
- package/commands/doctor.md +13 -0
- package/commands/setup.md +17 -0
- package/defaults/cycle3-review-prompt.md +90 -0
- package/defaults/hidden-roles.json +65 -0
- package/defaults/memory-chunk-prompt.md +63 -0
- package/defaults/memory-promote-prompt.md +135 -0
- package/defaults/mixdog-config.template.json +27 -0
- package/defaults/user-workflow.json +8 -0
- package/defaults/user-workflow.md +12 -0
- package/hooks/hooks.json +73 -0
- package/hooks/lib/active-instance.cjs +77 -0
- package/hooks/lib/permission-evaluator.cjs +411 -0
- package/hooks/lib/permission-route.cjs +63 -0
- package/hooks/lib/permission-rules.cjs +170 -0
- package/hooks/lib/settings-loader.cjs +116 -0
- package/hooks/post-tool-use.cjs +84 -0
- package/hooks/pre-mcp-sandbox.cjs +158 -0
- package/hooks/pre-tool-subagent.cjs +253 -0
- package/hooks/session-start.cjs +1372 -0
- package/hooks/turn-timer.cjs +82 -0
- package/lib/claude-md-writer.cjs +386 -0
- package/lib/config-cjs.cjs +61 -0
- package/lib/hook-pipe-path.cjs +10 -0
- package/lib/keychain-cjs.cjs +263 -0
- package/lib/plugin-paths.cjs +61 -0
- package/lib/rules-builder.cjs +241 -0
- package/lib/text-utils.cjs +61 -0
- package/native/README.md +117 -0
- 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 +107 -0
- package/prompts/code-review.txt +16 -0
- package/prompts/security-audit.txt +17 -0
- package/rules/bridge/00-common.md +39 -0
- package/rules/bridge/20-skip-protocol.md +18 -0
- package/rules/bridge/30-explorer.md +33 -0
- package/rules/bridge/40-cycle1-agent.md +52 -0
- package/rules/bridge/41-cycle2-agent.md +62 -0
- package/rules/bridge/42-cycle3-agent.md +44 -0
- package/rules/lead/00-tool-lead.md +61 -0
- package/rules/lead/01-general.md +23 -0
- package/rules/lead/02-channels.md +49 -0
- package/rules/lead/03-team.md +27 -0
- package/rules/lead/04-workflow.md +20 -0
- package/rules/shared/00-language.md +14 -0
- package/rules/shared/01-tool.md +138 -0
- package/scripts/bootstrap.mjs +184 -0
- package/scripts/bridge-unify-smoke.mjs +308 -0
- package/scripts/build-runtime-linux.sh +348 -0
- package/scripts/build-runtime-macos.sh +217 -0
- package/scripts/build-runtime-windows.ps1 +242 -0
- package/scripts/builtin-utils-smoke.mjs +392 -0
- package/scripts/check-json.mjs +45 -0
- package/scripts/check-syntax-changed.mjs +102 -0
- package/scripts/check-syntax.mjs +58 -0
- package/scripts/code-graph-batch.test.mjs +33 -0
- package/scripts/config-preserve-smoke.mjs +180 -0
- package/scripts/doctor.mjs +484 -0
- package/scripts/edit-normalize-fuzz.mjs +130 -0
- package/scripts/edit-normalize-smoke.mjs +401 -0
- package/scripts/edit-operation-smoke.mjs +369 -0
- package/scripts/edit2-smoke.mjs +63 -0
- package/scripts/fuzzy-e2e.mjs +28 -0
- package/scripts/fuzzy-smoke.mjs +26 -0
- package/scripts/generate-runtime-manifest.mjs +166 -0
- package/scripts/guard-smoke.mjs +66 -0
- package/scripts/hidden-role-schema-smoke.mjs +162 -0
- package/scripts/hook-routing-smoke.mjs +29 -0
- package/scripts/inject-input.ps1 +204 -0
- package/scripts/io-complex-smoke.mjs +667 -0
- package/scripts/io-explore-bench.mjs +424 -0
- package/scripts/io-guardrails-smoke.mjs +205 -0
- package/scripts/io-mini-bench-baseline.json +11 -0
- package/scripts/io-mini-bench.mjs +216 -0
- package/scripts/io-route-harness.mjs +933 -0
- package/scripts/io-telemetry-report.mjs +691 -0
- package/scripts/mutation-bench.mjs +564 -0
- package/scripts/mutation-io-smoke.mjs +1081 -0
- package/scripts/native-patch-bridge-smoke.mjs +288 -0
- package/scripts/native-patch-smoke.mjs +304 -0
- package/scripts/patch-interior-context-smoke.mjs +49 -0
- package/scripts/patch-newline-utf8-smoke.mjs +157 -0
- package/scripts/perf-hook-smoke.mjs +71 -0
- package/scripts/permission-eval-smoke.mjs +426 -0
- package/scripts/prep-patch.mjs +53 -0
- package/scripts/prep-shim.mjs +96 -0
- package/scripts/provider-cache-smoke.mjs +687 -0
- package/scripts/report-runtime-health.mjs +132 -0
- package/scripts/run-mcp.mjs +1547 -0
- package/scripts/salvage-v4a-shatter.test.mjs +58 -0
- package/scripts/scoped-cache-io-smoke.mjs +103 -0
- package/scripts/shell-policy-round3-smoke.mjs +46 -0
- package/scripts/smoke-runtime-negative.ps1 +100 -0
- package/scripts/smoke-runtime-negative.sh +95 -0
- package/scripts/stall-policy-smoke.mjs +50 -0
- package/scripts/start-memory-worker.mjs +23 -0
- package/scripts/statusline-launcher-smoke.mjs +82 -0
- package/scripts/stress-atomic-write.mjs +1028 -0
- package/scripts/test-config-rmw-restore.mjs +122 -0
- package/scripts/test-fault-inject.mjs +164 -0
- package/scripts/test-large-file.mjs +174 -0
- package/scripts/tool-edge-smoke.mjs +209 -0
- package/scripts/uninstall.mjs +201 -0
- package/scripts/webhook-selfheal-smoke.mjs +29 -0
- package/scripts/write-overwrite-guard-smoke.mjs +56 -0
- package/server-main.mjs +3055 -0
- package/server.mjs +468 -0
- package/setup/config-merge.mjs +254 -0
- package/setup/install.mjs +120 -0
- package/setup/launch-core.mjs +507 -0
- package/setup/launch.mjs +101 -0
- package/setup/setup-server.mjs +3206 -0
- package/setup/setup.html +3693 -0
- package/skills/retro-skill-proposer/SKILL.md +92 -0
- package/skills/schedule-add/SKILL.md +77 -0
- package/skills/setup/SKILL.md +346 -0
- package/skills/webhook-add/SKILL.md +81 -0
- package/src/agent/bridge-stall-watchdog.mjs +337 -0
- package/src/agent/index.mjs +2138 -0
- package/src/agent/orchestrator/activity-bus.mjs +38 -0
- package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +1010 -0
- package/src/agent/orchestrator/bridge-retry.mjs +220 -0
- package/src/agent/orchestrator/bridge-trace.mjs +583 -0
- package/src/agent/orchestrator/cache-mtime.mjs +58 -0
- package/src/agent/orchestrator/config.mjs +358 -0
- package/src/agent/orchestrator/context/collect.mjs +651 -0
- package/src/agent/orchestrator/dispatch-persist.mjs +549 -0
- package/src/agent/orchestrator/drain-registry.mjs +50 -0
- package/src/agent/orchestrator/explore-validator.mjs +8 -0
- package/src/agent/orchestrator/internal-roles.mjs +118 -0
- package/src/agent/orchestrator/internal-tools.mjs +88 -0
- package/src/agent/orchestrator/jobs.mjs +116 -0
- package/src/agent/orchestrator/mcp/client.mjs +364 -0
- package/src/agent/orchestrator/providers/anthropic-betas.mjs +21 -0
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +1745 -0
- package/src/agent/orchestrator/providers/anthropic.mjs +437 -0
- package/src/agent/orchestrator/providers/gemini.mjs +1175 -0
- package/src/agent/orchestrator/providers/grok-oauth.mjs +782 -0
- package/src/agent/orchestrator/providers/model-catalog.mjs +241 -0
- package/src/agent/orchestrator/providers/openai-compat.mjs +1467 -0
- package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +1890 -0
- package/src/agent/orchestrator/providers/openai-oauth.mjs +1307 -0
- package/src/agent/orchestrator/providers/openai-ws.mjs +104 -0
- package/src/agent/orchestrator/providers/registry.mjs +192 -0
- package/src/agent/orchestrator/providers/retry-classifier.mjs +325 -0
- package/src/agent/orchestrator/session/abort-lookup.mjs +13 -0
- package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
- package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
- package/src/agent/orchestrator/session/cache/read-cache.mjs +319 -0
- package/src/agent/orchestrator/session/cache/scoped-cache-outcome.mjs +11 -0
- package/src/agent/orchestrator/session/cache/scoped-cache.mjs +361 -0
- package/src/agent/orchestrator/session/cache/util.mjs +49 -0
- package/src/agent/orchestrator/session/loop.mjs +1478 -0
- package/src/agent/orchestrator/session/manager.mjs +1975 -0
- package/src/agent/orchestrator/session/read-dedup.mjs +6 -0
- package/src/agent/orchestrator/session/result-classification.mjs +65 -0
- package/src/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/agent/orchestrator/session/store.mjs +624 -0
- package/src/agent/orchestrator/session/stream-watchdog.mjs +130 -0
- package/src/agent/orchestrator/session/tool-result-offload.mjs +166 -0
- package/src/agent/orchestrator/session/trim.mjs +491 -0
- package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +115 -0
- package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +327 -0
- package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +150 -0
- package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +228 -0
- package/src/agent/orchestrator/smart-bridge/index.mjs +215 -0
- package/src/agent/orchestrator/smart-bridge/profiles.mjs +37 -0
- package/src/agent/orchestrator/smart-bridge/registry.mjs +348 -0
- package/src/agent/orchestrator/smart-bridge/session-builder.mjs +116 -0
- package/src/agent/orchestrator/stall-policy.mjs +195 -0
- package/src/agent/orchestrator/tool-loop-guard.mjs +75 -0
- package/src/agent/orchestrator/tools/bash-policy-scan.mjs +77 -0
- package/src/agent/orchestrator/tools/bash-session.mjs +721 -0
- package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
- package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/atomic-write.mjs +236 -0
- package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +480 -0
- package/src/agent/orchestrator/tools/builtin/binary-file.mjs +76 -0
- package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +256 -0
- package/src/agent/orchestrator/tools/builtin/cache-layers.mjs +386 -0
- package/src/agent/orchestrator/tools/builtin/cwd-utils.mjs +37 -0
- package/src/agent/orchestrator/tools/builtin/device-paths.mjs +154 -0
- package/src/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +292 -0
- package/src/agent/orchestrator/tools/builtin/diff-utils.mjs +109 -0
- package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +58 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +240 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +74 -0
- package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +242 -0
- package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +211 -0
- package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +1364 -0
- package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +126 -0
- package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +141 -0
- package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +194 -0
- package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +60 -0
- package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +168 -0
- package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fs-reachability.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +99 -0
- package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +170 -0
- package/src/agent/orchestrator/tools/builtin/grep-formatting.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/hash-utils.mjs +6 -0
- package/src/agent/orchestrator/tools/builtin/list-formatting.mjs +7 -0
- package/src/agent/orchestrator/tools/builtin/list-tool.mjs +593 -0
- package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +89 -0
- package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +300 -0
- package/src/agent/orchestrator/tools/builtin/open-config-tool.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +152 -0
- package/src/agent/orchestrator/tools/builtin/path-locks.mjs +35 -0
- package/src/agent/orchestrator/tools/builtin/path-utils.mjs +201 -0
- package/src/agent/orchestrator/tools/builtin/read-args.mjs +103 -0
- package/src/agent/orchestrator/tools/builtin/read-batch.mjs +172 -0
- package/src/agent/orchestrator/tools/builtin/read-constants.mjs +40 -0
- package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
- package/src/agent/orchestrator/tools/builtin/read-image-resize.mjs +189 -0
- package/src/agent/orchestrator/tools/builtin/read-image.mjs +88 -0
- package/src/agent/orchestrator/tools/builtin/read-lines.mjs +12 -0
- package/src/agent/orchestrator/tools/builtin/read-mode-tool.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/read-open.mjs +190 -0
- package/src/agent/orchestrator/tools/builtin/read-range-index.mjs +271 -0
- package/src/agent/orchestrator/tools/builtin/read-ranges.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +728 -0
- package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/read-special-files.mjs +268 -0
- package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
- package/src/agent/orchestrator/tools/builtin/read-tool.mjs +530 -0
- package/src/agent/orchestrator/tools/builtin/read-windows.mjs +107 -0
- package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +196 -0
- package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +422 -0
- package/src/agent/orchestrator/tools/builtin/search-builders.mjs +158 -0
- package/src/agent/orchestrator/tools/builtin/search-tool.mjs +869 -0
- package/src/agent/orchestrator/tools/builtin/shell-analysis.mjs +653 -0
- package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +936 -0
- package/src/agent/orchestrator/tools/builtin/shell-output.mjs +36 -0
- package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +214 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +143 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +206 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-validation.mjs +98 -0
- package/src/agent/orchestrator/tools/builtin/text-stats.mjs +69 -0
- package/src/agent/orchestrator/tools/builtin/windows-roots.mjs +23 -0
- package/src/agent/orchestrator/tools/builtin/write-tool.mjs +401 -0
- package/src/agent/orchestrator/tools/builtin.mjs +500 -0
- package/src/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +39 -0
- package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
- package/src/agent/orchestrator/tools/code-graph.mjs +4095 -0
- package/src/agent/orchestrator/tools/cwd-tool.mjs +298 -0
- package/src/agent/orchestrator/tools/destructive-warning.mjs +323 -0
- package/src/agent/orchestrator/tools/edit-normalize.mjs +603 -0
- package/src/agent/orchestrator/tools/env-scrub.mjs +100 -0
- package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +144 -0
- package/src/agent/orchestrator/tools/graph-manifest.json +26 -0
- package/src/agent/orchestrator/tools/host-input.mjs +204 -0
- package/src/agent/orchestrator/tools/mutation-content-cache.mjs +67 -0
- package/src/agent/orchestrator/tools/mutation-planner.mjs +75 -0
- package/src/agent/orchestrator/tools/next-call-utils.mjs +48 -0
- package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +133 -0
- package/src/agent/orchestrator/tools/patch-manifest.json +26 -0
- package/src/agent/orchestrator/tools/patch-tool-defs.mjs +20 -0
- package/src/agent/orchestrator/tools/patch.mjs +2754 -0
- package/src/agent/orchestrator/tools/progress-message.mjs +118 -0
- package/src/agent/orchestrator/tools/result-compression.mjs +279 -0
- package/src/agent/orchestrator/tools/shell-command.mjs +865 -0
- package/src/agent/orchestrator/tools/shell-exec-policy.mjs +89 -0
- package/src/agent/orchestrator/tools/shell-policy-danger-target.mjs +27 -0
- package/src/agent/orchestrator/tools/shell-policy-imports.mjs +7 -0
- package/src/agent/orchestrator/tools/shell-policy.mjs +345 -0
- package/src/agent/orchestrator/tools/shell-snapshot.mjs +313 -0
- package/src/agent/orchestrator/workflow-store.mjs +93 -0
- package/src/agent/tool-defs.mjs +103 -0
- package/src/channels/backends/discord.mjs +784 -0
- package/src/channels/data/voice-runtime-manifest.json +138 -0
- package/src/channels/index.mjs +3229 -0
- package/src/channels/lib/cli-worker-host.mjs +12 -0
- package/src/channels/lib/config-lock.mjs +13 -0
- package/src/channels/lib/config.mjs +292 -0
- package/src/channels/lib/drop-trace.mjs +71 -0
- package/src/channels/lib/event-pipeline.mjs +81 -0
- package/src/channels/lib/event-queue.mjs +345 -0
- package/src/channels/lib/executor.mjs +168 -0
- package/src/channels/lib/format.mjs +188 -0
- package/src/channels/lib/holidays.mjs +138 -0
- package/src/channels/lib/hook-pipe-server.mjs +802 -0
- package/src/channels/lib/interaction-workflows.mjs +184 -0
- package/src/channels/lib/memory-client.mjs +149 -0
- package/src/channels/lib/output-forwarder.mjs +765 -0
- package/src/channels/lib/runtime-paths.mjs +479 -0
- package/src/channels/lib/scheduler.mjs +723 -0
- package/src/channels/lib/session-control.mjs +36 -0
- package/src/channels/lib/session-discovery.mjs +103 -0
- package/src/channels/lib/settings.mjs +11 -0
- package/src/channels/lib/state-file.mjs +68 -0
- package/src/channels/lib/status-snapshot.mjs +219 -0
- package/src/channels/lib/tool-format.mjs +140 -0
- package/src/channels/lib/transcript-discovery.mjs +195 -0
- package/src/channels/lib/voice-runtime-fetcher.mjs +734 -0
- package/src/channels/lib/webhook.mjs +1179 -0
- package/src/channels/lib/whisper-server.mjs +477 -0
- package/src/channels/tool-defs.mjs +170 -0
- package/src/daemon/host.mjs +118 -0
- package/src/daemon/mcp-transport.mjs +47 -0
- package/src/daemon/session.mjs +100 -0
- package/src/daemon/thin-client.mjs +71 -0
- package/src/daemon/transport.mjs +163 -0
- package/src/memory/data/runtime-manifest.json +40 -0
- package/src/memory/index.mjs +3305 -0
- package/src/memory/lib/agent-ipc.mjs +93 -0
- package/src/memory/lib/bridge-trace-queries.mjs +120 -0
- package/src/memory/lib/core-memory-store.mjs +330 -0
- package/src/memory/lib/embedding-provider.mjs +269 -0
- package/src/memory/lib/embedding-worker.mjs +323 -0
- package/src/memory/lib/llm-worker-host.mjs +17 -0
- package/src/memory/lib/memory-cycle.mjs +11 -0
- package/src/memory/lib/memory-cycle1.mjs +641 -0
- package/src/memory/lib/memory-cycle2.mjs +1284 -0
- package/src/memory/lib/memory-cycle3.mjs +540 -0
- package/src/memory/lib/memory-embed.mjs +299 -0
- package/src/memory/lib/memory-extraction.mjs +5 -0
- package/src/memory/lib/memory-maintenance-store.mjs +32 -0
- package/src/memory/lib/memory-ops-policy.mjs +190 -0
- package/src/memory/lib/memory-recall-id-patch.mjs +15 -0
- package/src/memory/lib/memory-recall-read-query.mjs +7 -0
- package/src/memory/lib/memory-recall-scope-filter.mjs +63 -0
- package/src/memory/lib/memory-recall-store.mjs +621 -0
- package/src/memory/lib/memory-retrievers.mjs +112 -0
- package/src/memory/lib/memory-score.mjs +71 -0
- package/src/memory/lib/memory-text-utils.mjs +58 -0
- package/src/memory/lib/memory.mjs +412 -0
- package/src/memory/lib/model-profile.mjs +85 -0
- package/src/memory/lib/pg/adapter.mjs +308 -0
- package/src/memory/lib/pg/process.mjs +360 -0
- package/src/memory/lib/pg/supervisor.mjs +396 -0
- package/src/memory/lib/project-id-resolver.mjs +86 -0
- package/src/memory/lib/runtime-fetcher.mjs +442 -0
- package/src/memory/lib/trace-store.mjs +728 -0
- package/src/memory/tool-defs.mjs +79 -0
- package/src/search/index.mjs +1173 -0
- package/src/search/lib/backends/anthropic-oauth.mjs +98 -0
- package/src/search/lib/backends/exa.mjs +50 -0
- package/src/search/lib/backends/firecrawl.mjs +61 -0
- package/src/search/lib/backends/gemini-api.mjs +83 -0
- package/src/search/lib/backends/grok-oauth.mjs +86 -0
- package/src/search/lib/backends/index.mjs +150 -0
- package/src/search/lib/backends/openai-api.mjs +144 -0
- package/src/search/lib/backends/openai-oauth.mjs +98 -0
- package/src/search/lib/backends/openai-web-search.mjs +76 -0
- package/src/search/lib/backends/tavily.mjs +55 -0
- package/src/search/lib/backends/xai-api.mjs +113 -0
- package/src/search/lib/cache.mjs +131 -0
- package/src/search/lib/config.mjs +192 -0
- package/src/search/lib/formatter.mjs +115 -0
- package/src/search/lib/provider-usage.mjs +67 -0
- package/src/search/lib/providers.mjs +47 -0
- package/src/search/lib/search-intent.mjs +109 -0
- package/src/search/lib/setup-handler.mjs +261 -0
- package/src/search/lib/state.mjs +201 -0
- package/src/search/lib/web-tools.mjs +1207 -0
- package/src/search/tool-defs.mjs +83 -0
- package/src/setup/defender-exclusion.mjs +183 -0
- package/src/shared/abort-controller.mjs +15 -0
- package/src/shared/atomic-file.mjs +420 -0
- package/src/shared/config.mjs +350 -0
- package/src/shared/daemon-recycle.mjs +108 -0
- package/src/shared/disable-claude-builtins.mjs +88 -0
- package/src/shared/err-text.mjs +12 -0
- package/src/shared/llm/cost.mjs +66 -0
- package/src/shared/llm/http-agent.mjs +123 -0
- package/src/shared/llm/index.mjs +41 -0
- package/src/shared/llm/pid-cleanup.mjs +27 -0
- package/src/shared/llm/usage-log.mjs +47 -0
- package/src/shared/plugin-paths.mjs +58 -0
- package/src/shared/schedules-store.mjs +70 -0
- package/src/shared/seed.mjs +119 -0
- package/src/shared/user-cwd.mjs +213 -0
- package/src/shared/user-data-guard.mjs +238 -0
- package/src/status/aggregator.mjs +584 -0
- package/src/status/server.mjs +413 -0
- package/tools.json +1653 -0
|
@@ -0,0 +1,3206 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { exec, execSync, spawn, spawnSync } from 'child_process';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, createWriteStream, mkdirSync, renameSync, unlinkSync, readdirSync, rmSync, statSync, openSync, readSync, closeSync } from 'fs';
|
|
4
|
+
import { join, dirname, basename } from 'path';
|
|
5
|
+
import { homedir, arch, platform } from 'os';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import http from 'http';
|
|
8
|
+
import https from 'https';
|
|
9
|
+
import { DEFAULT_MAINTENANCE, MAINTENANCE_SLOTS, DEFAULT_PRESETS, getPluginData } from '../src/agent/orchestrator/config.mjs';
|
|
10
|
+
import { getOpenAIOAuthModelCatalogError, hasOpenAIOAuthCredentials } from '../src/agent/orchestrator/providers/openai-oauth.mjs';
|
|
11
|
+
import { hasAnthropicOAuthCredentials } from '../src/agent/orchestrator/providers/anthropic-oauth.mjs';
|
|
12
|
+
import { hasGrokOAuthCredentials, loginOAuth as loginGrokOAuth } from '../src/agent/orchestrator/providers/grok-oauth.mjs';
|
|
13
|
+
import { resolvePluginData } from '../src/shared/plugin-paths.mjs';
|
|
14
|
+
import { listSchedules } from '../src/shared/schedules-store.mjs';
|
|
15
|
+
import { ensureDataSeeds } from '../src/shared/seed.mjs';
|
|
16
|
+
import { backupUserData, markUserDataInitialized, shouldSeedMissingUserData } from '../src/shared/user-data-guard.mjs';
|
|
17
|
+
import { tmpdir } from 'os';
|
|
18
|
+
import { readSection, writeSection, updateSection, saveSecret, deleteSecret, hasStoredSecret, SECRET_ACCOUNTS, getSearchApiKey, getAgentApiKey, getDiscordToken, getWebhookAuthtoken, diagnoseDiscordTokenValue } from '../src/shared/config.mjs';
|
|
19
|
+
import { applyDefaults as applyChannelsDefaults } from '../src/channels/lib/config.mjs';
|
|
20
|
+
import { validateCronExpression } from '../src/channels/lib/scheduler.mjs';
|
|
21
|
+
import { mergeAgentConfig, mergeMemoryConfig, mergeSearchConfig, mergeConfig, mergeEndpointConfig, mergeWebhookEndpointConfig } from './config-merge.mjs';
|
|
22
|
+
|
|
23
|
+
// C2 — Origin/Referer guard for mutating routes.
|
|
24
|
+
// Returns true when the request is safe to handle (same-origin loopback UI,
|
|
25
|
+
// or direct curl/native-client that sends no browser Origin or Referer).
|
|
26
|
+
// Empty Origin alone is no longer trusted — browsers always send Origin on
|
|
27
|
+
// cross-origin requests; same-origin browser requests may omit Origin but
|
|
28
|
+
// will carry a matching Referer, handled by the loopback regex below.
|
|
29
|
+
function isAllowedOrigin(req) {
|
|
30
|
+
const origin = req.headers.origin || '';
|
|
31
|
+
const referer = req.headers.referer || '';
|
|
32
|
+
// No Origin AND no Referer → direct curl / native client → allow.
|
|
33
|
+
if (!origin && !referer) return true;
|
|
34
|
+
// Origin present → must match our loopback UI port.
|
|
35
|
+
if (origin) return /^http:\/\/(localhost|127\.0\.0\.1):3458(\/|$)/.test(origin);
|
|
36
|
+
// Origin absent but Referer present → allow only if Referer is loopback UI.
|
|
37
|
+
return /^http:\/\/(localhost|127\.0\.0\.1):3458(\/|$)/.test(referer);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// sanitizeName — reject path-traversal in user-supplied names used as
|
|
41
|
+
// directory/filename components (schedules, webhooks, presets, etc.).
|
|
42
|
+
function sanitizeName(n) {
|
|
43
|
+
if (!n || typeof n !== 'string') return null;
|
|
44
|
+
if (n !== basename(n)) return null;
|
|
45
|
+
if (n.includes('..') || n.startsWith('.')) return null;
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const isWin = process.platform === 'win32';
|
|
51
|
+
|
|
52
|
+
// MIXDOG_DEBUG_SETUP=1 gates verbose tracing for the chrome launcher and the
|
|
53
|
+
// /open / /req HTTP path. The previous unconditional console.error calls
|
|
54
|
+
// produced a wall of [open-debug] / [req-trace] noise on every config-UI
|
|
55
|
+
// session even when nothing was wrong; gating preserves the diagnostic
|
|
56
|
+
// power without filling supervisor.log on the happy path.
|
|
57
|
+
function debugSetup(msg) {
|
|
58
|
+
if (!process.env.MIXDOG_DEBUG_SETUP) return;
|
|
59
|
+
try { console.error(`${new Date().toISOString()} ${msg}`); } catch { /* best-effort */ }
|
|
60
|
+
}
|
|
61
|
+
const home = homedir();
|
|
62
|
+
|
|
63
|
+
// -- Channels paths --
|
|
64
|
+
const DATA_DIR = resolvePluginData();
|
|
65
|
+
const MIXDOG_CONFIG_PATH = join(DATA_DIR, 'mixdog-config.json');
|
|
66
|
+
const STATUS_SNAPSHOT_PATH = join(DATA_DIR, 'channels', 'status-snapshot.json');
|
|
67
|
+
|
|
68
|
+
// -- Workflow paths --
|
|
69
|
+
const USER_WORKFLOW_PATH = join(DATA_DIR, 'user-workflow.json');
|
|
70
|
+
const USER_WORKFLOW_MD_PATH = join(DATA_DIR, 'user-workflow.md');
|
|
71
|
+
|
|
72
|
+
// Plugin-shipped defaults loaded from <plugin-root>/defaults/. Keeps the
|
|
73
|
+
// canonical user-facing seed templates editable as plain files instead of
|
|
74
|
+
// inline string constants. See defaults/user-workflow.{json,md}.
|
|
75
|
+
const DEFAULTS_DIR = join(__dirname, '..', 'defaults');
|
|
76
|
+
const DEFAULT_USER_WORKFLOW = JSON.parse(readFileSync(join(DEFAULTS_DIR, 'user-workflow.json'), 'utf8'));
|
|
77
|
+
const DEFAULT_USER_WORKFLOW_MD = readFileSync(join(DEFAULTS_DIR, 'user-workflow.md'), 'utf8');
|
|
78
|
+
|
|
79
|
+
const PORT = 3458;
|
|
80
|
+
const APP_WIDTH = 950;
|
|
81
|
+
const APP_HEIGHT = 900;
|
|
82
|
+
const HTML_PATH = join(__dirname, 'setup.html');
|
|
83
|
+
|
|
84
|
+
// Drop runtime-provider model caches on boot and after provider saves so the
|
|
85
|
+
// Config UI re-fetches fresh catalogs. Caches can get stuck on partial/stale
|
|
86
|
+
// responses (e.g. Codex /backend-api/codex/models returning just one model).
|
|
87
|
+
function dropRuntimeModelCaches() {
|
|
88
|
+
for (const name of ['openai-oauth-models.json', 'anthropic-oauth-models.json', 'grok-oauth-models.json']) {
|
|
89
|
+
try { rmSync(join(getPluginData(), name), { force: true }); } catch {}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
dropRuntimeModelCaches();
|
|
93
|
+
|
|
94
|
+
// Seed user-workflow.json and user-workflow.md on first launch so Smart
|
|
95
|
+
// Bridge has sensible role→preset mappings and the Lead has a baseline
|
|
96
|
+
// workflow description out of the box. Leaves existing files untouched.
|
|
97
|
+
try {
|
|
98
|
+
if (!existsSync(USER_WORKFLOW_PATH) && shouldSeedMissingUserData(DATA_DIR, 'user-workflow.json')) {
|
|
99
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
100
|
+
writeFileSync(USER_WORKFLOW_PATH, JSON.stringify(DEFAULT_USER_WORKFLOW, null, 2));
|
|
101
|
+
markUserDataInitialized(DATA_DIR);
|
|
102
|
+
}
|
|
103
|
+
if (!existsSync(USER_WORKFLOW_MD_PATH) && shouldSeedMissingUserData(DATA_DIR, 'user-workflow.md')) {
|
|
104
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
105
|
+
writeFileSync(USER_WORKFLOW_MD_PATH, DEFAULT_USER_WORKFLOW_MD);
|
|
106
|
+
markUserDataInitialized(DATA_DIR);
|
|
107
|
+
}
|
|
108
|
+
} catch {}
|
|
109
|
+
|
|
110
|
+
// Seed plugin-owned scaffolding files (memory-config.json, etc.).
|
|
111
|
+
// Idempotent — ensureDataSeeds skips existing files; fatal throw propagates
|
|
112
|
+
// anything that already exists, so the agent/index.mjs call and this one
|
|
113
|
+
// can both run without colliding.
|
|
114
|
+
ensureDataSeeds(DATA_DIR);
|
|
115
|
+
|
|
116
|
+
// -- Helpers --
|
|
117
|
+
|
|
118
|
+
function readJsonFile(path) {
|
|
119
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
120
|
+
catch { return {}; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writeJsonFile(path, data) {
|
|
124
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
125
|
+
if (path === MIXDOG_CONFIG_PATH || path === USER_WORKFLOW_PATH) {
|
|
126
|
+
try { backupUserData(DATA_DIR, path === USER_WORKFLOW_PATH ? 'pre-workflow-write' : 'pre-config-write'); } catch {}
|
|
127
|
+
}
|
|
128
|
+
const tmp = path + '.tmp';
|
|
129
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
130
|
+
renameSync(tmp, path);
|
|
131
|
+
if (path === MIXDOG_CONFIG_PATH || path === USER_WORKFLOW_PATH) {
|
|
132
|
+
try { markUserDataInitialized(DATA_DIR); } catch {}
|
|
133
|
+
try { backupUserData(DATA_DIR, path === USER_WORKFLOW_PATH ? 'post-workflow-write' : 'post-config-write'); } catch {}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readConfig() { return readSection('channels'); }
|
|
138
|
+
function writeConfig(data) { writeSection('channels', data); }
|
|
139
|
+
|
|
140
|
+
function readAgentConfig() { return readSection('agent'); }
|
|
141
|
+
function writeAgentConfig(data) { writeSection('agent', data); }
|
|
142
|
+
|
|
143
|
+
function readMemoryConfig() { return readSection('memory'); }
|
|
144
|
+
function writeMemoryConfig(data) { writeSection('memory', data); }
|
|
145
|
+
|
|
146
|
+
function readSearchConfig() { return readSection('search'); }
|
|
147
|
+
function writeSearchConfig(data) { writeSection('search', data); }
|
|
148
|
+
|
|
149
|
+
// Provider availability — credential resolution follows the invariant declared
|
|
150
|
+
// in Core Memory: `search.credentials → agent.providers.<x>-oauth →
|
|
151
|
+
// agent.providers.<x>.apiKey`, first match. Returns the subset of the 9
|
|
152
|
+
// supported providers whose credentials are already registered, so the Setup
|
|
153
|
+
// UI dropdown can show only selectable options.
|
|
154
|
+
const SUPPORTED_PROVIDERS = [
|
|
155
|
+
'anthropic-oauth', 'openai-oauth', 'openai-api', 'gemini-api', 'xai-api', 'grok-oauth',
|
|
156
|
+
'tavily', 'firecrawl', 'exa',
|
|
157
|
+
];
|
|
158
|
+
const AGENT_KEY_PROVIDER_IDS = ['openai', 'anthropic', 'gemini', 'deepseek', 'xai', 'nvidia'];
|
|
159
|
+
const SEARCH_KEY_PROVIDER_IDS = ['firecrawl', 'tavily', 'exa'];
|
|
160
|
+
const AGENT_PROVIDER_ENV = Object.freeze({
|
|
161
|
+
openai: 'OPENAI_API_KEY',
|
|
162
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
163
|
+
gemini: 'GEMINI_API_KEY',
|
|
164
|
+
deepseek: 'DEEPSEEK_API_KEY',
|
|
165
|
+
xai: 'XAI_API_KEY',
|
|
166
|
+
nvidia: 'NVIDIA_API_KEY',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
function envSecretPresent(account) {
|
|
170
|
+
const key = 'MIXDOG_' + String(account || '').replace(/[.\s]+/g, '_').toUpperCase();
|
|
171
|
+
if (process.env[key]) return true;
|
|
172
|
+
const agentMatch = String(account || '').match(/^agent\.([^.]+)\.apiKey$/);
|
|
173
|
+
if (agentMatch) {
|
|
174
|
+
const std = AGENT_PROVIDER_ENV[agentMatch[1]];
|
|
175
|
+
if (std && process.env[std]) return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getSecretByAccount(account) {
|
|
181
|
+
if (account === SECRET_ACCOUNTS.discordToken) return getDiscordToken();
|
|
182
|
+
if (account === SECRET_ACCOUNTS.webhookAuth) return getWebhookAuthtoken();
|
|
183
|
+
const searchMatch = String(account || '').match(/^search\.([^.]+)\.apiKey$/);
|
|
184
|
+
if (searchMatch) return getSearchApiKey(searchMatch[1]);
|
|
185
|
+
const agentMatch = String(account || '').match(/^agent\.([^.]+)\.apiKey$/);
|
|
186
|
+
if (agentMatch) return getAgentApiKey(agentMatch[1]);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function keychainSecretPresent(account) {
|
|
191
|
+
if (hasStoredSecret(account)) return true;
|
|
192
|
+
// Cross-check through the canonical getters. On Windows the setup server can
|
|
193
|
+
// occasionally see a false negative from the low-level presence check while
|
|
194
|
+
// the normal read path succeeds; the UI cares whether the credential is
|
|
195
|
+
// actually available to runtime.
|
|
196
|
+
if (envSecretPresent(account)) return false;
|
|
197
|
+
return !!getSecretByAccount(account);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function secretPresence(account) {
|
|
201
|
+
return {
|
|
202
|
+
keychain: keychainSecretPresent(account),
|
|
203
|
+
env: envSecretPresent(account),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function fullSecretStatus(channelsConfig = null) {
|
|
208
|
+
const agent = {};
|
|
209
|
+
for (const id of AGENT_KEY_PROVIDER_IDS) agent[id] = secretPresence(SECRET_ACCOUNTS.agentApiKey(id));
|
|
210
|
+
const search = {};
|
|
211
|
+
for (const id of SEARCH_KEY_PROVIDER_IDS) search[id] = secretPresence(SECRET_ACCOUNTS.searchApiKey(id));
|
|
212
|
+
const discordToken = secretPresence(SECRET_ACCOUNTS.discordToken);
|
|
213
|
+
const discordProblem = diagnoseDiscordTokenValue(getDiscordToken(), channelsConfig || {});
|
|
214
|
+
if (discordProblem) {
|
|
215
|
+
discordToken.invalid = true;
|
|
216
|
+
discordToken.problem = discordProblem;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
channels: {
|
|
220
|
+
discordToken,
|
|
221
|
+
webhookAuth: secretPresence(SECRET_ACCOUNTS.webhookAuth),
|
|
222
|
+
},
|
|
223
|
+
agent,
|
|
224
|
+
memory: {
|
|
225
|
+
// Memory provider keys reuse the same provider keychain accounts as Agent.
|
|
226
|
+
agent,
|
|
227
|
+
},
|
|
228
|
+
search,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function saveSecretChecked(account, rawValue, label) {
|
|
233
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : '';
|
|
234
|
+
if (!value) {
|
|
235
|
+
return { attempted: false, stored: keychainSecretPresent(account), env: envSecretPresent(account), inputLength: 0 };
|
|
236
|
+
}
|
|
237
|
+
saveSecret(account, value);
|
|
238
|
+
const stored = keychainSecretPresent(account);
|
|
239
|
+
if (!stored && !envSecretPresent(account)) {
|
|
240
|
+
throw new Error(`${label}: keychain write completed but secret is not readable`);
|
|
241
|
+
}
|
|
242
|
+
return { attempted: true, stored, env: envSecretPresent(account), inputLength: value.length };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function deleteSecretIfCurrentValue(account, value) {
|
|
246
|
+
if (!value) return false;
|
|
247
|
+
try {
|
|
248
|
+
if (getSecretByAccount(account) !== value) return false;
|
|
249
|
+
deleteSecret(account);
|
|
250
|
+
return true;
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function saveDiscordTokenChecked(rawValue, config) {
|
|
257
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : '';
|
|
258
|
+
if (!value) {
|
|
259
|
+
return { attempted: false, stored: keychainSecretPresent(SECRET_ACCOUNTS.discordToken), env: envSecretPresent(SECRET_ACCOUNTS.discordToken), inputLength: 0 };
|
|
260
|
+
}
|
|
261
|
+
const problem = diagnoseDiscordTokenValue(value, config || {});
|
|
262
|
+
if (problem) {
|
|
263
|
+
const removed = deleteSecretIfCurrentValue(SECRET_ACCOUNTS.discordToken, value);
|
|
264
|
+
throw new Error(`channels.discord.token: ${problem}${removed ? ' Removed the invalid saved value.' : ''}`);
|
|
265
|
+
}
|
|
266
|
+
return saveSecretChecked(SECRET_ACCOUNTS.discordToken, value, 'channels.discord.token');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function pruneInvalidDiscordToken(config) {
|
|
270
|
+
const value = getDiscordToken();
|
|
271
|
+
const problem = diagnoseDiscordTokenValue(value, config || {});
|
|
272
|
+
if (!problem) return null;
|
|
273
|
+
const removed = deleteSecretIfCurrentValue(SECRET_ACCOUNTS.discordToken, value);
|
|
274
|
+
return { invalid: true, removed, problem };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function computeAvailableProviders() {
|
|
278
|
+
const isAvailable = (id) => {
|
|
279
|
+
if (id === 'anthropic-oauth') return hasAnthropicOAuthCredentials();
|
|
280
|
+
if (id === 'openai-oauth') return hasOpenAIOAuthCredentials();
|
|
281
|
+
if (id === 'grok-oauth') return hasGrokOAuthCredentials();
|
|
282
|
+
if (id === 'openai-api') return !!getAgentApiKey('openai');
|
|
283
|
+
if (id === 'gemini-api') return !!getAgentApiKey('gemini');
|
|
284
|
+
if (id === 'xai-api') return !!getAgentApiKey('xai');
|
|
285
|
+
if (id === 'firecrawl' || id === 'tavily' || id === 'exa') return !!getSearchApiKey(id);
|
|
286
|
+
return false;
|
|
287
|
+
};
|
|
288
|
+
return SUPPORTED_PROVIDERS.filter(isAvailable);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function readUserWorkflow() {
|
|
292
|
+
if (!existsSync(USER_WORKFLOW_PATH)) return DEFAULT_USER_WORKFLOW;
|
|
293
|
+
try { return JSON.parse(readFileSync(USER_WORKFLOW_PATH, 'utf8')); }
|
|
294
|
+
catch { return DEFAULT_USER_WORKFLOW; }
|
|
295
|
+
}
|
|
296
|
+
// Phase C Ship 3 — the `worker` role is reserved and non-deletable. Smart
|
|
297
|
+
// Bridge's router dispatches any request with `role: "worker"` to the
|
|
298
|
+
// `worker-full` profile; if the role goes missing the router has nowhere to
|
|
299
|
+
// send Worker calls. Every persist path funnels through here, so reinstating
|
|
300
|
+
// the role on save keeps the contract intact regardless of how the caller
|
|
301
|
+
// mutated the roster (UI drag-delete, raw MD edit, direct JSON PUT).
|
|
302
|
+
function writeUserWorkflow(data) {
|
|
303
|
+
const roles = Array.isArray(data?.roles) ? data.roles.slice() : [];
|
|
304
|
+
if (!roles.some(r => r?.name === 'worker')) {
|
|
305
|
+
const existing = readUserWorkflow();
|
|
306
|
+
const preservedWorker = existing?.roles?.find(r => r?.name === 'worker');
|
|
307
|
+
const seedWorker = DEFAULT_USER_WORKFLOW.roles.find(r => r?.name === 'worker');
|
|
308
|
+
roles.unshift(preservedWorker || seedWorker);
|
|
309
|
+
}
|
|
310
|
+
const sanitizedRoles = roles.map(r => {
|
|
311
|
+
if (!r || typeof r !== "object") return r;
|
|
312
|
+
const name = sanitizeName(r.name);
|
|
313
|
+
if (name == null) throw new Error('invalid role name: ' + r.name);
|
|
314
|
+
return { ...r, name };
|
|
315
|
+
});
|
|
316
|
+
// Intentional full replace: POST /workflow/save sends the complete workflow
|
|
317
|
+
// document; no unmanaged top-level sidecar fields on user-workflow.json.
|
|
318
|
+
writeJsonFile(USER_WORKFLOW_PATH, { ...data, roles: sanitizedRoles });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function readUserWorkflowMd() {
|
|
322
|
+
if (!existsSync(USER_WORKFLOW_MD_PATH)) return DEFAULT_USER_WORKFLOW_MD;
|
|
323
|
+
try { return readFileSync(USER_WORKFLOW_MD_PATH, 'utf8'); }
|
|
324
|
+
catch { return DEFAULT_USER_WORKFLOW_MD; }
|
|
325
|
+
}
|
|
326
|
+
function writeUserWorkflowMd(content) {
|
|
327
|
+
// Intentional full replace: the UI owns the entire user-workflow.md body.
|
|
328
|
+
mkdirSync(dirname(USER_WORKFLOW_MD_PATH), { recursive: true });
|
|
329
|
+
try { backupUserData(DATA_DIR, 'pre-workflow-md-write'); } catch {}
|
|
330
|
+
const tmp = USER_WORKFLOW_MD_PATH + '.tmp';
|
|
331
|
+
writeFileSync(tmp, content, 'utf8');
|
|
332
|
+
renameSync(tmp, USER_WORKFLOW_MD_PATH);
|
|
333
|
+
try { markUserDataInitialized(DATA_DIR); } catch {}
|
|
334
|
+
try { backupUserData(DATA_DIR, 'post-workflow-md-write'); } catch {}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// -- HTTPS helpers --
|
|
338
|
+
|
|
339
|
+
function httpGetJson(url, headers) {
|
|
340
|
+
return new Promise((resolve, reject) => {
|
|
341
|
+
const u = new URL(url);
|
|
342
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
343
|
+
const req = lib.request({
|
|
344
|
+
hostname: u.hostname, port: u.port, path: u.pathname + u.search,
|
|
345
|
+
method: 'GET', headers, timeout: 10000,
|
|
346
|
+
}, res => {
|
|
347
|
+
let body = '';
|
|
348
|
+
res.on('data', c => { body += c; });
|
|
349
|
+
res.on('end', () => { res.statusCode < 400 ? resolve(JSON.parse(body)) : reject(); });
|
|
350
|
+
});
|
|
351
|
+
req.on('error', reject);
|
|
352
|
+
req.on('timeout', () => { req.destroy(); reject(); });
|
|
353
|
+
req.end();
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function httpPostJson(url, data, headers) {
|
|
358
|
+
return new Promise((resolve, reject) => {
|
|
359
|
+
const u = new URL(url);
|
|
360
|
+
const body = JSON.stringify(data);
|
|
361
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
362
|
+
const req = lib.request({
|
|
363
|
+
hostname: u.hostname, port: u.port, path: u.pathname,
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: { ...headers, 'Content-Length': Buffer.byteLength(body) },
|
|
366
|
+
timeout: 10000,
|
|
367
|
+
}, res => {
|
|
368
|
+
let buf = '';
|
|
369
|
+
res.on('data', c => { buf += c; });
|
|
370
|
+
res.on('end', () => { res.statusCode < 400 ? resolve(JSON.parse(buf)) : reject(); });
|
|
371
|
+
});
|
|
372
|
+
req.on('error', reject);
|
|
373
|
+
req.on('timeout', () => { req.destroy(); reject(); });
|
|
374
|
+
req.write(body);
|
|
375
|
+
req.end();
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function pingLocalHttp(url, timeoutMs = 1500) {
|
|
380
|
+
return new Promise((resolve) => {
|
|
381
|
+
try {
|
|
382
|
+
const u = new URL(url);
|
|
383
|
+
const req = http.request({
|
|
384
|
+
hostname: u.hostname, port: u.port,
|
|
385
|
+
path: u.pathname + u.search,
|
|
386
|
+
method: 'GET', timeout: timeoutMs,
|
|
387
|
+
}, res => { res.resume(); resolve(res.statusCode > 0 && res.statusCode < 500); });
|
|
388
|
+
req.on('error', () => resolve(false));
|
|
389
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
390
|
+
req.end();
|
|
391
|
+
} catch { resolve(false); }
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// -- Agent key validation --
|
|
396
|
+
|
|
397
|
+
async function validateAgentKey(provider, key) {
|
|
398
|
+
if (!key) return 'empty';
|
|
399
|
+
try {
|
|
400
|
+
switch (provider) {
|
|
401
|
+
case 'openai':
|
|
402
|
+
await httpGetJson('https://api.openai.com/v1/models', { 'Authorization': `Bearer ${key}` });
|
|
403
|
+
return 'valid';
|
|
404
|
+
case 'anthropic':
|
|
405
|
+
await httpPostJson('https://api.anthropic.com/v1/messages',
|
|
406
|
+
{ model: 'claude-haiku-4-5-20251001', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] },
|
|
407
|
+
{ 'x-api-key': key, 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' });
|
|
408
|
+
return 'valid';
|
|
409
|
+
case 'gemini':
|
|
410
|
+
await httpGetJson(`https://generativelanguage.googleapis.com/v1beta/models?key=${key}`, {});
|
|
411
|
+
return 'valid';
|
|
412
|
+
case 'groq':
|
|
413
|
+
await httpGetJson('https://api.groq.com/openai/v1/models', { 'Authorization': `Bearer ${key}` });
|
|
414
|
+
return 'valid';
|
|
415
|
+
case 'openrouter':
|
|
416
|
+
await httpGetJson('https://openrouter.ai/api/v1/models', { 'Authorization': `Bearer ${key}` });
|
|
417
|
+
return 'valid';
|
|
418
|
+
case 'xai':
|
|
419
|
+
await httpPostJson('https://api.x.ai/v1/chat/completions',
|
|
420
|
+
{ model: 'grok-3-mini-fast', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
|
|
421
|
+
{ 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' });
|
|
422
|
+
return 'valid';
|
|
423
|
+
case 'nvidia':
|
|
424
|
+
await httpPostJson('https://integrate.api.nvidia.com/v1/chat/completions',
|
|
425
|
+
{ model: 'meta/llama-3.3-70b-instruct', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
|
|
426
|
+
{ 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' });
|
|
427
|
+
return 'valid';
|
|
428
|
+
default: return 'valid';
|
|
429
|
+
}
|
|
430
|
+
} catch { return 'invalid'; }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// -- Search key validation --
|
|
434
|
+
|
|
435
|
+
async function validateSearchKey(provider, key) {
|
|
436
|
+
if (!key) return 'empty';
|
|
437
|
+
try {
|
|
438
|
+
switch (provider) {
|
|
439
|
+
case 'serper':
|
|
440
|
+
await httpPostJson('https://google.serper.dev/search', { q: 'test' },
|
|
441
|
+
{ 'X-API-KEY': key, 'Content-Type': 'application/json' });
|
|
442
|
+
return 'valid';
|
|
443
|
+
case 'brave':
|
|
444
|
+
await httpGetJson('https://api.search.brave.com/res/v1/web/search?q=test&count=1',
|
|
445
|
+
{ 'X-Subscription-Token': key });
|
|
446
|
+
return 'valid';
|
|
447
|
+
case 'xai':
|
|
448
|
+
await httpPostJson('https://api.x.ai/v1/chat/completions',
|
|
449
|
+
{ model: 'grok-3-mini-fast', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
|
|
450
|
+
{ 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' });
|
|
451
|
+
return 'valid';
|
|
452
|
+
case 'perplexity':
|
|
453
|
+
await httpPostJson('https://api.perplexity.ai/chat/completions',
|
|
454
|
+
{ model: 'sonar', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
|
|
455
|
+
{ 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' });
|
|
456
|
+
return 'valid';
|
|
457
|
+
case 'firecrawl':
|
|
458
|
+
await httpGetJson('https://api.firecrawl.dev/v1/crawl', { 'Authorization': `Bearer ${key}` });
|
|
459
|
+
return 'valid';
|
|
460
|
+
case 'tavily':
|
|
461
|
+
await httpPostJson('https://api.tavily.com/search',
|
|
462
|
+
{ api_key: key, query: 'test', max_results: 1 },
|
|
463
|
+
{ 'Content-Type': 'application/json' });
|
|
464
|
+
return 'valid';
|
|
465
|
+
default: return 'valid';
|
|
466
|
+
}
|
|
467
|
+
} catch { return 'invalid'; }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// -- Auth detection (shared by agent & memory) --
|
|
471
|
+
|
|
472
|
+
async function detectAuth(config = {}) {
|
|
473
|
+
const result = {};
|
|
474
|
+
// Single source of truth: same predicate the runtime uses to decide
|
|
475
|
+
// whether to register the provider. Avoids the UI showing "Set" while
|
|
476
|
+
// runtime disables the provider (or vice versa) when only one of the
|
|
477
|
+
// candidate paths is populated.
|
|
478
|
+
result.codexOAuth = hasOpenAIOAuthCredentials();
|
|
479
|
+
result.anthropicOAuth = hasAnthropicOAuthCredentials();
|
|
480
|
+
result.grokOAuth = hasGrokOAuthCredentials();
|
|
481
|
+
const configDir = isWin
|
|
482
|
+
? (process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'))
|
|
483
|
+
: join(home, '.config');
|
|
484
|
+
result.copilot = existsSync(join(configDir, 'github-copilot', 'hosts.json'))
|
|
485
|
+
|| existsSync(join(configDir, 'github-copilot', 'apps.json'));
|
|
486
|
+
result.envKeys = {};
|
|
487
|
+
for (const [name, envKey] of [
|
|
488
|
+
['openai', 'OPENAI_API_KEY'], ['anthropic', 'ANTHROPIC_API_KEY'],
|
|
489
|
+
['gemini', 'GEMINI_API_KEY'], ['deepseek', 'DEEPSEEK_API_KEY'],
|
|
490
|
+
['xai', 'XAI_API_KEY'], ['nvidia', 'NVIDIA_API_KEY'],
|
|
491
|
+
]) { result.envKeys[name] = !!process.env[envKey]; }
|
|
492
|
+
// GROK_API_KEY is the last-resort xAI env alias honored by getAgentApiKey('xai')
|
|
493
|
+
// (shared/config.mjs is the SSOT); mirror it here so a GROK_API_KEY-only env
|
|
494
|
+
// shows xAI as available. keyStored semantics (keychain-only) stay unchanged.
|
|
495
|
+
result.envKeys.xai = result.envKeys.xai || !!process.env.GROK_API_KEY;
|
|
496
|
+
// Keychain-stored provider keys. Only this boolean is sent to the browser —
|
|
497
|
+
// the secret value never leaves the server, so the UI can show "Set" without
|
|
498
|
+
// exposing the key.
|
|
499
|
+
result.keyStored = {};
|
|
500
|
+
for (const name of ['openai', 'anthropic', 'gemini', 'deepseek', 'xai', 'nvidia']) {
|
|
501
|
+
result.keyStored[name] = hasStoredSecret(SECRET_ACCOUNTS.agentApiKey(name));
|
|
502
|
+
}
|
|
503
|
+
const ollamaUrl = config?.providers?.ollama?.baseURL || 'http://localhost:11434/v1';
|
|
504
|
+
const lmstudioUrl = config?.providers?.lmstudio?.baseURL || 'http://localhost:1234/v1';
|
|
505
|
+
const [ollamaUp, lmstudioUp] = await Promise.all([
|
|
506
|
+
pingLocalHttp(ollamaUrl + '/models'),
|
|
507
|
+
pingLocalHttp(lmstudioUrl + '/models'),
|
|
508
|
+
]);
|
|
509
|
+
result.ollama = ollamaUp;
|
|
510
|
+
result.lmstudio = lmstudioUp;
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// -- Provider model listing --
|
|
515
|
+
|
|
516
|
+
// Static model fallback removed: model lists must come from the live
|
|
517
|
+
// provider (registry.mjs `provider.listModels()` or the provider's REST
|
|
518
|
+
// endpoint). Hard-coded lists drift behind real releases (e.g. opus-4-6
|
|
519
|
+
// vs opus-4-7) and silently mis-resolve saved presets. Empty result on
|
|
520
|
+
// missing credentials is the correct invariant — only show models the
|
|
521
|
+
// user can actually use.
|
|
522
|
+
|
|
523
|
+
// Try the live provider registry first (dynamic catalog via /v1/models,
|
|
524
|
+
// Codex /backend-api/codex/models, Gemini /v1beta/models). Returns null on
|
|
525
|
+
// any failure so the caller uses the direct HTTP endpoint handlers below.
|
|
526
|
+
// Preserves full metadata (tier, family, latest,
|
|
527
|
+
// contextWindow, reasoningLevels, pricing) so the UI can build tier-grouped
|
|
528
|
+
// dropdowns and adapt effort options per model.
|
|
529
|
+
//
|
|
530
|
+
// registry.mjs populates its provider Map only after initProviders(cfg) is
|
|
531
|
+
// called, so setup-server (which never runs the agent's normal boot path)
|
|
532
|
+
// must force-init before querying — otherwise getProvider() returns
|
|
533
|
+
// undefined and listModels() never runs.
|
|
534
|
+
// Provider IDs registry.mjs actually knows. Listing anything else makes
|
|
535
|
+
// initProviders throw `unknown enabled provider: …`, which the outer catch
|
|
536
|
+
// swallows — silently nuking every model lookup for every provider. Keep
|
|
537
|
+
// in sync with src/agent/orchestrator/providers/registry.mjs.
|
|
538
|
+
const _RUNTIME_PROVIDER_NAMES = [
|
|
539
|
+
'anthropic', 'anthropic-oauth', 'openai', 'openai-oauth',
|
|
540
|
+
'gemini', 'deepseek', 'xai', 'grok-oauth', 'nvidia',
|
|
541
|
+
'ollama', 'lmstudio',
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
async function getRuntimeProviderModels(providerId, cfg, opts = {}) {
|
|
545
|
+
try {
|
|
546
|
+
const { initProviders, getProvider } = await import('../src/agent/orchestrator/providers/registry.mjs');
|
|
547
|
+
const initCfg = {};
|
|
548
|
+
for (const name of _RUNTIME_PROVIDER_NAMES) {
|
|
549
|
+
initCfg[name] = { ...(cfg?.providers?.[name] || {}), enabled: true };
|
|
550
|
+
}
|
|
551
|
+
await initProviders(initCfg);
|
|
552
|
+
const provider = getProvider(providerId);
|
|
553
|
+
if (!provider) return null;
|
|
554
|
+
let models = null;
|
|
555
|
+
if (opts.forceRefresh && typeof provider._refreshModelCache === 'function') {
|
|
556
|
+
models = await provider._refreshModelCache();
|
|
557
|
+
}
|
|
558
|
+
if (!Array.isArray(models) || models.length === 0) models = await provider.listModels();
|
|
559
|
+
if (!Array.isArray(models) || models.length === 0) return null;
|
|
560
|
+
return models
|
|
561
|
+
.map(m => {
|
|
562
|
+
if (typeof m === 'string') return { id: m };
|
|
563
|
+
const id = m?.id || m?.name;
|
|
564
|
+
if (!id) return null;
|
|
565
|
+
return { ...m, id: String(id) };
|
|
566
|
+
})
|
|
567
|
+
.filter(Boolean);
|
|
568
|
+
} catch { return null; }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function _idOnly(id) { return id ? { id: String(id) } : null; }
|
|
572
|
+
|
|
573
|
+
// Per-provider id blocklist applied to dynamic and direct-HTTP catalogs.
|
|
574
|
+
// Pro tier models are surfaced by /v1/models but not usable through the
|
|
575
|
+
// standard chat/responses paths we support, so they get filtered out at
|
|
576
|
+
// catalog level rather than per-UI.
|
|
577
|
+
const _MODEL_ID_BLOCKLIST = {
|
|
578
|
+
openai: [/^gpt-\d+(\.\d+)?-pro(-|$)/i, /^o\d+-pro(-|$)/i, /^sora-\d+-pro(-|$)/i],
|
|
579
|
+
'openai-oauth': [/^gpt-\d+(\.\d+)?-pro(-|$)/i],
|
|
580
|
+
};
|
|
581
|
+
function _applyModelBlocklist(providerId, models) {
|
|
582
|
+
const rules = _MODEL_ID_BLOCKLIST[providerId];
|
|
583
|
+
if (!rules || !Array.isArray(models)) return models;
|
|
584
|
+
return models.filter(m => {
|
|
585
|
+
const id = typeof m === 'string' ? m : m?.id;
|
|
586
|
+
if (!id) return true;
|
|
587
|
+
return !rules.some(re => re.test(id));
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function _configuredProviderModels(providerId, cfg) {
|
|
592
|
+
const provider = normalizeAgentProviderId(providerId);
|
|
593
|
+
const presets = Array.isArray(cfg?.presets) ? cfg.presets : [];
|
|
594
|
+
const seen = new Set();
|
|
595
|
+
const out = [];
|
|
596
|
+
for (const p of presets) {
|
|
597
|
+
const modelId = String(p?.model || '').trim();
|
|
598
|
+
if (!modelId || seen.has(modelId)) continue;
|
|
599
|
+
if (normalizeAgentProviderId(p?.provider) !== provider) continue;
|
|
600
|
+
seen.add(modelId);
|
|
601
|
+
out.push(_modelFromConfiguredId(modelId, provider));
|
|
602
|
+
}
|
|
603
|
+
return out;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function _modelFromConfiguredId(id, provider) {
|
|
607
|
+
const family = _familyFromModelId(id);
|
|
608
|
+
return {
|
|
609
|
+
id,
|
|
610
|
+
display: _displayFromModelId(id),
|
|
611
|
+
provider,
|
|
612
|
+
family,
|
|
613
|
+
tier: /-\d{8}$/.test(id) ? 'dated' : /-\d+-\d+/.test(id) ? 'version' : undefined,
|
|
614
|
+
latest: true,
|
|
615
|
+
contextWindow: _contextWindowFromModelId(id),
|
|
616
|
+
mode: 'chat',
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function _familyFromModelId(id) {
|
|
621
|
+
const claude = String(id || '').match(/^claude-(opus|sonnet|haiku)/i);
|
|
622
|
+
if (claude) return claude[1].toLowerCase();
|
|
623
|
+
const gpt = String(id || '').match(/^(gpt-\d+)/i);
|
|
624
|
+
if (gpt) return gpt[1].toLowerCase();
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function _displayFromModelId(id) {
|
|
629
|
+
const m = String(id || '').match(/^claude-(opus|sonnet|haiku)-(\d+)-(\d+)(?:-\d{8})?$/i);
|
|
630
|
+
if (!m) return id;
|
|
631
|
+
const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
|
|
632
|
+
return `Claude ${family} ${m[2]}.${m[3]}`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function _contextWindowFromModelId(id) {
|
|
636
|
+
const v = String(id || '').toLowerCase();
|
|
637
|
+
if (/^claude-opus-4-(6|7|8)(?:$|-)/.test(v)) return 1000000;
|
|
638
|
+
if (/^claude-sonnet-4-6(?:$|-)/.test(v)) return 1000000;
|
|
639
|
+
if (/^claude-haiku-4-5/.test(v)) return 200000;
|
|
640
|
+
if (/^gpt-5(?:\.|-|$)/.test(v)) return 1000000;
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function _hasAllConfiguredModels(providerId, cfg, models) {
|
|
645
|
+
const ids = new Set((models || []).map(m => typeof m === 'string' ? m : m?.id).filter(Boolean).map(String));
|
|
646
|
+
return _configuredProviderModels(providerId, cfg).every(m => ids.has(m.id));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function _mergeConfiguredModels(providerId, cfg, models) {
|
|
650
|
+
const out = Array.isArray(models) ? models.slice() : [];
|
|
651
|
+
const ids = new Set(out.map(m => typeof m === 'string' ? m : m?.id).filter(Boolean).map(String));
|
|
652
|
+
for (const model of _configuredProviderModels(providerId, cfg).reverse()) {
|
|
653
|
+
if (ids.has(model.id)) continue;
|
|
654
|
+
out.unshift(model);
|
|
655
|
+
ids.add(model.id);
|
|
656
|
+
}
|
|
657
|
+
return out;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function listProviderModels(providerId, cfg) {
|
|
661
|
+
// Search backends use suffix-tagged IDs (openai-api / gemini-api / xai-api)
|
|
662
|
+
// that share their model catalog with the bare provider. Alias them ONLY
|
|
663
|
+
// for the direct-HTTP fallback path. The runtime registry has its own
|
|
664
|
+
// provider entry per auth shape (`anthropic-oauth` ≠ `anthropic`), so the
|
|
665
|
+
// runtime call must keep the original ID.
|
|
666
|
+
const HTTP_ALIAS = {
|
|
667
|
+
'openai-api': 'openai',
|
|
668
|
+
'gemini-api': 'gemini',
|
|
669
|
+
'xai-api': 'xai',
|
|
670
|
+
};
|
|
671
|
+
const httpLookupId = HTTP_ALIAS[providerId] || providerId;
|
|
672
|
+
const pcfg = cfg?.providers?.[providerId] || cfg?.providers?.[httpLookupId] || {};
|
|
673
|
+
// 1. Runtime provider (dynamic catalog, cached 24h). Try the original ID
|
|
674
|
+
// first (oauth providers expose their own model catalog), then the bare
|
|
675
|
+
// alias (gemini-api → gemini etc. where the registry only knows the
|
|
676
|
+
// unsuffixed entry).
|
|
677
|
+
let runtime = await getRuntimeProviderModels(providerId, cfg);
|
|
678
|
+
if ((!runtime || runtime.length === 0) && httpLookupId !== providerId) {
|
|
679
|
+
runtime = await getRuntimeProviderModels(httpLookupId, cfg);
|
|
680
|
+
}
|
|
681
|
+
if (runtime && runtime.length > 0 && !_hasAllConfiguredModels(providerId, cfg, runtime)) {
|
|
682
|
+
const refreshed = await getRuntimeProviderModels(providerId, cfg, { forceRefresh: true });
|
|
683
|
+
if (refreshed && refreshed.length > 0) runtime = refreshed;
|
|
684
|
+
}
|
|
685
|
+
if (runtime && runtime.length > 0) {
|
|
686
|
+
return _applyModelBlocklist(providerId, _mergeConfiguredModels(providerId, cfg, runtime));
|
|
687
|
+
}
|
|
688
|
+
// 2. Direct HTTP model list for key-based providers.
|
|
689
|
+
const KNOWN_ENDPOINTS = {
|
|
690
|
+
openai: { url: 'https://api.openai.com/v1/models', auth: k => ({ 'Authorization': `Bearer ${k}` }) },
|
|
691
|
+
xai: { url: 'https://api.x.ai/v1/models', auth: k => ({ 'Authorization': `Bearer ${k}` }) },
|
|
692
|
+
deepseek: { url: 'https://api.deepseek.com/v1/models', auth: k => ({ 'Authorization': `Bearer ${k}` }) },
|
|
693
|
+
nvidia: { url: 'https://integrate.api.nvidia.com/v1/models', auth: k => ({ 'Authorization': `Bearer ${k}` }) },
|
|
694
|
+
};
|
|
695
|
+
const ep = KNOWN_ENDPOINTS[httpLookupId];
|
|
696
|
+
if (ep && pcfg.apiKey) {
|
|
697
|
+
try {
|
|
698
|
+
const json = await httpGetJson(ep.url, ep.auth(pcfg.apiKey));
|
|
699
|
+
const data = Array.isArray(json?.data) ? json.data : [];
|
|
700
|
+
// Preserve `created` (UNIX timestamp) so the UI can apply its 6-month
|
|
701
|
+
// freshness cutoff. Without it the dropdown silently includes legacy
|
|
702
|
+
// generations (gpt-3.5-turbo, gpt-4-0613, …) because the filter has
|
|
703
|
+
// no date to compare against.
|
|
704
|
+
const mapped = data
|
|
705
|
+
.map(m => {
|
|
706
|
+
const id = m?.id || m?.name;
|
|
707
|
+
if (!id) return null;
|
|
708
|
+
const entry = { id: String(id) };
|
|
709
|
+
if (typeof m.created === 'number' && m.created > 0) entry.created = m.created;
|
|
710
|
+
return entry;
|
|
711
|
+
})
|
|
712
|
+
.filter(Boolean)
|
|
713
|
+
.sort((a, b) => (b.created || 0) - (a.created || 0) || a.id.localeCompare(b.id));
|
|
714
|
+
return _applyModelBlocklist(providerId, mapped);
|
|
715
|
+
} catch { /* runtime+HTTP both unavailable → fall through to [] */ }
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const LOCAL_DEFAULTS = { ollama: 'http://localhost:11434/v1/models', lmstudio: 'http://localhost:1234/v1/models' };
|
|
719
|
+
if (LOCAL_DEFAULTS[providerId]) {
|
|
720
|
+
const baseURL = pcfg.baseURL || LOCAL_DEFAULTS[providerId].replace(/\/models$/, '');
|
|
721
|
+
const url = `${baseURL.replace(/\/$/, '')}/models`;
|
|
722
|
+
try {
|
|
723
|
+
const json = await httpGetJson(url, {});
|
|
724
|
+
const data = Array.isArray(json?.data) ? json.data : [];
|
|
725
|
+
return data
|
|
726
|
+
.map(m => _idOnly(m.id || m.name))
|
|
727
|
+
.filter(Boolean)
|
|
728
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
729
|
+
} catch { return []; }
|
|
730
|
+
}
|
|
731
|
+
return [];
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// -- Presets (shared logic for agent & memory) --
|
|
735
|
+
|
|
736
|
+
const VALID_TOOLS = new Set(['full', 'readonly', 'mcp']);
|
|
737
|
+
const VALID_EFFORTS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
738
|
+
const AGENT_PROVIDER_ALIASES = Object.freeze({
|
|
739
|
+
'openai-api': 'openai',
|
|
740
|
+
'gemini-api': 'gemini',
|
|
741
|
+
'xai-api': 'xai',
|
|
742
|
+
});
|
|
743
|
+
const FAST_CAPABLE_PRESET_PROVIDERS = new Set([
|
|
744
|
+
'anthropic',
|
|
745
|
+
'anthropic-oauth',
|
|
746
|
+
'openai',
|
|
747
|
+
'openai-oauth',
|
|
748
|
+
]);
|
|
749
|
+
function normalizeAgentProviderId(provider) {
|
|
750
|
+
const id = String(provider || '').trim();
|
|
751
|
+
return AGENT_PROVIDER_ALIASES[id] || id;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function normalizePreset(input) {
|
|
755
|
+
if (!input || typeof input !== 'object') throw new Error('preset must be an object');
|
|
756
|
+
const id = String(input.id || '').trim();
|
|
757
|
+
if (!id) throw new Error('preset.id is required');
|
|
758
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(id)) throw new Error('preset.id must be alphanumeric (._- allowed)');
|
|
759
|
+
const model = String(input.model || '').trim();
|
|
760
|
+
if (!model) throw new Error('preset.model is required');
|
|
761
|
+
const provider = normalizeAgentProviderId(input.provider);
|
|
762
|
+
if (!provider) throw new Error('preset.provider is required');
|
|
763
|
+
const tools = String(input.tools || 'full');
|
|
764
|
+
if (!VALID_TOOLS.has(tools)) throw new Error(`preset.tools must be one of ${[...VALID_TOOLS].join(', ')}`);
|
|
765
|
+
const out = { id, type: 'bridge', model, provider, tools };
|
|
766
|
+
if (typeof input.name === 'string' && input.name.trim()) out.name = input.name.trim();
|
|
767
|
+
if (input.effort != null && input.effort !== '') {
|
|
768
|
+
const effort = String(input.effort);
|
|
769
|
+
if (!VALID_EFFORTS.has(effort)) throw new Error(`preset.effort must be one of ${[...VALID_EFFORTS].join(', ')}`);
|
|
770
|
+
out.effort = effort;
|
|
771
|
+
}
|
|
772
|
+
if (input.fast === true && FAST_CAPABLE_PRESET_PROVIDERS.has(provider)) out.fast = true;
|
|
773
|
+
return out;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function readAgentPresets() {
|
|
777
|
+
const cfg = readAgentConfig();
|
|
778
|
+
// Migrated/unified configs may have no agent.presets key (vs an explicit
|
|
779
|
+
// empty array, which the user may have intentionally cleared). Fall back
|
|
780
|
+
// to the seeded defaults only when the key is absent so the Custom
|
|
781
|
+
// Workflow dropdowns render real options on first load. An explicit
|
|
782
|
+
// empty array stays empty — matches the "No presets yet" UI path.
|
|
783
|
+
const raw = Array.isArray(cfg.presets)
|
|
784
|
+
? cfg.presets
|
|
785
|
+
: DEFAULT_PRESETS.map((p) => ({ ...p }));
|
|
786
|
+
return raw.map((p) => {
|
|
787
|
+
try { return normalizePreset(p); }
|
|
788
|
+
catch { return p; }
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function writeAgentPresets(list) {
|
|
793
|
+
const cfg = readAgentConfig();
|
|
794
|
+
// Intentional presets-array replace inside read-modify-write agent section
|
|
795
|
+
// (other agent keys preserved via writeAgentConfig).
|
|
796
|
+
cfg.presets = Array.isArray(list) ? list.map((p) => normalizePreset(p)) : [];
|
|
797
|
+
if ('defaultPreset' in cfg) delete cfg.defaultPreset;
|
|
798
|
+
const validKeys = cfg.presets.map(p => p.id || p.name).filter(Boolean);
|
|
799
|
+
if (!cfg.default || !validKeys.includes(cfg.default)) cfg.default = validKeys[0] || null;
|
|
800
|
+
writeAgentConfig(cfg);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function readMemoryPresets() {
|
|
804
|
+
const cfg = readMemoryConfig();
|
|
805
|
+
return Array.isArray(cfg.presets) ? cfg.presets : [];
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function writeMemoryPresets(list) {
|
|
809
|
+
const cfg = readMemoryConfig();
|
|
810
|
+
// Intentional presets-array replace inside read-modify-write memory section.
|
|
811
|
+
cfg.presets = list;
|
|
812
|
+
writeMemoryConfig(cfg);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function getMemoryServicePort() {
|
|
816
|
+
const active = JSON.parse(readFileSync(join(tmpdir(), 'mixdog', 'active-instance.json'), 'utf8'));
|
|
817
|
+
const port = Number(active && active.memory_port);
|
|
818
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
819
|
+
throw new Error('active-instance.json missing memory_port');
|
|
820
|
+
}
|
|
821
|
+
return port;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function memoryServiceCall(method, urlPath, body, timeoutMs = 600000) {
|
|
825
|
+
return new Promise((resolve, reject) => {
|
|
826
|
+
const port = getMemoryServicePort();
|
|
827
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
828
|
+
const req = http.request({
|
|
829
|
+
hostname: '127.0.0.1',
|
|
830
|
+
port,
|
|
831
|
+
path: urlPath,
|
|
832
|
+
method,
|
|
833
|
+
headers: payload
|
|
834
|
+
? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
|
|
835
|
+
: {},
|
|
836
|
+
timeout: Math.max(1, timeoutMs),
|
|
837
|
+
}, (res) => {
|
|
838
|
+
const chunks = [];
|
|
839
|
+
res.on('data', (c) => chunks.push(c));
|
|
840
|
+
res.on('end', () => {
|
|
841
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
842
|
+
let parsed;
|
|
843
|
+
try { parsed = JSON.parse(text); }
|
|
844
|
+
catch (e) { reject(new Error(`memory-service ${urlPath} invalid JSON: ${e.message}`)); return; }
|
|
845
|
+
resolve({ statusCode: res.statusCode, body: parsed });
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
req.on('error', reject);
|
|
849
|
+
req.on('timeout', () => { req.destroy(new Error('memory-service timeout')); });
|
|
850
|
+
if (payload) req.write(payload);
|
|
851
|
+
req.end();
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const WINDOWS_BROWSER_CANDIDATES = [
|
|
856
|
+
{ label: 'Chrome (user)', env: 'LOCALAPPDATA', parts: ['Google', 'Chrome', 'Application', 'chrome.exe'] },
|
|
857
|
+
{ label: 'Chrome (Program Files)', env: 'PROGRAMFILES', parts: ['Google', 'Chrome', 'Application', 'chrome.exe'] },
|
|
858
|
+
{ label: 'Chrome (Program Files x86)', env: 'PROGRAMFILES(X86)', parts: ['Google', 'Chrome', 'Application', 'chrome.exe'] },
|
|
859
|
+
{ label: 'Edge (user)', env: 'LOCALAPPDATA', parts: ['Microsoft', 'Edge', 'Application', 'msedge.exe'] },
|
|
860
|
+
{ label: 'Edge (Program Files)', env: 'PROGRAMFILES', parts: ['Microsoft', 'Edge', 'Application', 'msedge.exe'] },
|
|
861
|
+
{ label: 'Edge (Program Files x86)', env: 'PROGRAMFILES(X86)', parts: ['Microsoft', 'Edge', 'Application', 'msedge.exe'] },
|
|
862
|
+
{ label: 'Brave (user)', env: 'LOCALAPPDATA', parts: ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'] },
|
|
863
|
+
{ label: 'Brave (Program Files)', env: 'PROGRAMFILES', parts: ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'] },
|
|
864
|
+
{ label: 'Brave (Program Files x86)', env: 'PROGRAMFILES(X86)', parts: ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'] },
|
|
865
|
+
{ label: 'Vivaldi (user)', env: 'LOCALAPPDATA', parts: ['Vivaldi', 'Application', 'vivaldi.exe'] },
|
|
866
|
+
{ label: 'Vivaldi (Program Files)', env: 'PROGRAMFILES', parts: ['Vivaldi', 'Application', 'vivaldi.exe'] },
|
|
867
|
+
{ label: 'Vivaldi (Program Files x86)', env: 'PROGRAMFILES(X86)', parts: ['Vivaldi', 'Application', 'vivaldi.exe'] },
|
|
868
|
+
];
|
|
869
|
+
|
|
870
|
+
function getBrowserPath() {
|
|
871
|
+
const checked = [];
|
|
872
|
+
const missingEnv = new Set();
|
|
873
|
+
const seenPaths = new Set();
|
|
874
|
+
|
|
875
|
+
for (const candidate of WINDOWS_BROWSER_CANDIDATES) {
|
|
876
|
+
const base = process.env[candidate.env];
|
|
877
|
+
if (!base) {
|
|
878
|
+
missingEnv.add(candidate.env);
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const browserPath = join(base, ...candidate.parts);
|
|
883
|
+
if (seenPaths.has(browserPath)) continue;
|
|
884
|
+
seenPaths.add(browserPath);
|
|
885
|
+
checked.push({ label: candidate.label, path: browserPath });
|
|
886
|
+
if (existsSync(browserPath)) return browserPath;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const checkedText = checked.length
|
|
890
|
+
? checked.map(item => `${item.label}: ${item.path}`).join('; ')
|
|
891
|
+
: 'no candidate paths because required environment variables were missing';
|
|
892
|
+
const missingText = missingEnv.size ? ` Missing env vars: ${[...missingEnv].join(', ')}.` : '';
|
|
893
|
+
console.error(`[setup] No supported Chromium browser found for Config UI app mode. Checked ${checked.length} path(s): ${checkedText}.${missingText}`);
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// One stable chrome profile dir reused across /mixdog:config invocations.
|
|
898
|
+
//
|
|
899
|
+
// Previous design created a fresh `chrome-app-<unix-ms>` per call to sidestep
|
|
900
|
+
// the singleton-lock issue, but that forced chrome through its full first-run
|
|
901
|
+
// path every time: many short-lived helper subprocesses (component update,
|
|
902
|
+
// crash handler, network service, gpu, renderer warmups, etc.), each
|
|
903
|
+
// allocating a conhost window even with the cmd.exe console-inherit trick.
|
|
904
|
+
// Users saw this as repeated terminal flashes throughout the loading
|
|
905
|
+
// spinner. Stable profile means first-run happens once; subsequent launches
|
|
906
|
+
// open the popup directly with no helper churn.
|
|
907
|
+
//
|
|
908
|
+
// Singleton-lock collision is handled in the launch path: the vbs that
|
|
909
|
+
// ShellExecutes chrome first taskkill-s any existing `MIXDOG CONFIG` window,
|
|
910
|
+
// so the profile lock is released before the new chrome boots. The kill
|
|
911
|
+
// runs hidden+synchronous via WScript.Shell.Run, no extra spawn flash.
|
|
912
|
+
//
|
|
913
|
+
// Sweep legacy `chrome-app-<unix-ms>` dirs (from the fresh-profile era) so
|
|
914
|
+
// they don't bloat the data dir.
|
|
915
|
+
const CHROME_PROFILE_DIR = 'chrome-app-profile';
|
|
916
|
+
const CHROME_PROFILE_LEGACY_PREFIX = 'chrome-app-';
|
|
917
|
+
function ensureStableChromeProfileDir() {
|
|
918
|
+
const root = DATA_DIR;
|
|
919
|
+
// Sweep old `chrome-app-<digits>` directories (legacy, not the stable one).
|
|
920
|
+
try {
|
|
921
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
922
|
+
for (const e of entries) {
|
|
923
|
+
if (!e.isDirectory()) continue;
|
|
924
|
+
if (e.name === CHROME_PROFILE_DIR) continue;
|
|
925
|
+
if (!e.name.startsWith(CHROME_PROFILE_LEGACY_PREFIX)) continue;
|
|
926
|
+
const suffix = e.name.slice(CHROME_PROFILE_LEGACY_PREFIX.length);
|
|
927
|
+
if (!/^\d+$/.test(suffix)) continue;
|
|
928
|
+
try { rmSync(join(root, e.name), { recursive: true, force: true }); } catch {}
|
|
929
|
+
}
|
|
930
|
+
} catch {}
|
|
931
|
+
const profileDir = join(root, CHROME_PROFILE_DIR);
|
|
932
|
+
try { mkdirSync(profileDir, { recursive: true }); } catch {}
|
|
933
|
+
// Suppress chrome's password-save prompt and autofill prompts inside the
|
|
934
|
+
// config UI popup. The setup window only renders local form fields (API
|
|
935
|
+
// keys, tokens) that chrome would otherwise treat as login fields and pop
|
|
936
|
+
// a "save password?" bubble on every edit. Writing the Preferences file
|
|
937
|
+
// before chrome's first launch with this profile dir is the only reliable
|
|
938
|
+
// way to disable the password manager — command-line flags alone don't
|
|
939
|
+
// suppress the bubble in current chrome versions. Only seed on first
|
|
940
|
+
// launch (file absent); preserve user-mutated state otherwise.
|
|
941
|
+
try {
|
|
942
|
+
const defaultDir = join(profileDir, 'Default');
|
|
943
|
+
mkdirSync(defaultDir, { recursive: true });
|
|
944
|
+
const prefsPath = join(defaultDir, 'Preferences');
|
|
945
|
+
if (!existsSync(prefsPath)) {
|
|
946
|
+
writeFileSync(
|
|
947
|
+
prefsPath,
|
|
948
|
+
JSON.stringify({
|
|
949
|
+
credentials_enable_service: false,
|
|
950
|
+
credentials_enable_autosignin: false,
|
|
951
|
+
profile: { password_manager_enabled: false },
|
|
952
|
+
autofill: { enabled: false, profile_enabled: false, credit_card_enabled: false },
|
|
953
|
+
}),
|
|
954
|
+
'utf8',
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
} catch {}
|
|
958
|
+
return profileDir;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// killChromesUsingProfile() and the makeFreshChromeProfileDir alias were
|
|
962
|
+
// removed: with per-launch profile dirs there is no singleton contention
|
|
963
|
+
// to clean up, and the alias only ever pointed at ensureStableChromeProfileDir
|
|
964
|
+
// from a single call site.
|
|
965
|
+
|
|
966
|
+
// Terminate every chrome.exe whose command line references a mixdog
|
|
967
|
+
// per-launch profile dir (matched by prefix `chrome-app-`). Runs before
|
|
968
|
+
// each new `--app=` spawn so old popups die and release their keepalive
|
|
969
|
+
// HTTP connections to setup-server; without this the connections pile up
|
|
970
|
+
// in ESTABLISHED state and exhaust the listener's accept queue (visible
|
|
971
|
+
// to the user as `/mixdog:config` printing "Config UI" but every
|
|
972
|
+
// subsequent fetch — including F5 — hanging forever).
|
|
973
|
+
function killAllMixdogChromes() {
|
|
974
|
+
if (!isWin) return;
|
|
975
|
+
debugSetup(`[open-debug] killAllMixdogChromes spawnSync powershell`);
|
|
976
|
+
const script =
|
|
977
|
+
"Get-CimInstance Win32_Process -Filter \"Name='chrome.exe'\" -ErrorAction SilentlyContinue | " +
|
|
978
|
+
"Where-Object { $_.CommandLine -and $_.CommandLine -match 'chrome-app-' } | " +
|
|
979
|
+
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }";
|
|
980
|
+
try {
|
|
981
|
+
spawnSync(
|
|
982
|
+
'powershell.exe',
|
|
983
|
+
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', script],
|
|
984
|
+
{ encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'ignore', 'ignore'], timeout: 6000 },
|
|
985
|
+
);
|
|
986
|
+
} catch {
|
|
987
|
+
// Best-effort. If chrome stays alive the next spawn may still succeed
|
|
988
|
+
// (different profile dir), at worst the user gets a duplicate window.
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Bring the most recently spawned MIXDOG CONFIG window to the foreground.
|
|
993
|
+
// Bring the newest MIXDOG CONFIG window to the foreground. WScript.Shell
|
|
994
|
+
// AppActivate only — no Add-Type / P-Invoke. Add-Type compiles C# at runtime
|
|
995
|
+
// via csc.exe, which flashes a conhost window and adds ~1-2s; AppActivate is a
|
|
996
|
+
// script-context activation Windows allows with no compile. A SendKeys('%')
|
|
997
|
+
// first synthesizes an Alt input event from this process, lifting the
|
|
998
|
+
// foreground restriction so AppActivate reliably promotes the window. Detached
|
|
999
|
+
// PowerShell — does not block the /open response. Polls up to ~3s (Chrome may
|
|
1000
|
+
// take 400-900ms to bind its window title after launch).
|
|
1001
|
+
function focusNewestMixdogWindow() {
|
|
1002
|
+
if (!isWin) return;
|
|
1003
|
+
debugSetup(`[open-debug] focusNewestMixdogWindow spawn powershell`);
|
|
1004
|
+
const psScript =
|
|
1005
|
+
"$sh = New-Object -ComObject WScript.Shell;\n" +
|
|
1006
|
+
"for ($i = 0; $i -lt 12; $i++) {\n" +
|
|
1007
|
+
" Start-Sleep -Milliseconds 250;\n" +
|
|
1008
|
+
" $p = Get-Process | Where-Object { $_.MainWindowTitle -eq 'MIXDOG CONFIG' } | Sort-Object StartTime -Descending | Select-Object -First 1;\n" +
|
|
1009
|
+
" if ($p -and $p.MainWindowHandle -ne 0) {\n" +
|
|
1010
|
+
" try { $sh.SendKeys('%') } catch {}\n" +
|
|
1011
|
+
" $sh.AppActivate($p.Id) | Out-Null;\n" +
|
|
1012
|
+
" break;\n" +
|
|
1013
|
+
" }\n" +
|
|
1014
|
+
"}";
|
|
1015
|
+
try {
|
|
1016
|
+
const child = spawn(
|
|
1017
|
+
'powershell.exe',
|
|
1018
|
+
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript],
|
|
1019
|
+
{ detached: true, stdio: 'ignore', windowsHide: true },
|
|
1020
|
+
);
|
|
1021
|
+
child.unref();
|
|
1022
|
+
} catch {
|
|
1023
|
+
// best-effort — window still opens, just may stay buried
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function getCenteredWindowPosition() {
|
|
1028
|
+
if (!isWin) return null;
|
|
1029
|
+
debugSetup(`[open-debug] getCenteredWindowPosition spawnSync powershell`);
|
|
1030
|
+
// Center on whichever monitor the cursor is on, not unconditionally on
|
|
1031
|
+
// PrimaryScreen. With a two-monitor setup the popup used to land on the
|
|
1032
|
+
// primary at (805, 246) even when the user was working on the secondary —
|
|
1033
|
+
// so they reported the window as "not showing" while it was actually
|
|
1034
|
+
// visible on the other monitor. Pick the active monitor via
|
|
1035
|
+
// Cursor.Position → Screen.FromPoint.
|
|
1036
|
+
const script = [
|
|
1037
|
+
"[void][Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')",
|
|
1038
|
+
"$p=[System.Windows.Forms.Cursor]::Position",
|
|
1039
|
+
"$s=[System.Windows.Forms.Screen]::FromPoint($p)",
|
|
1040
|
+
"$a=$s.WorkingArea",
|
|
1041
|
+
'Write-Output "$($a.X),$($a.Y),$($a.Width),$($a.Height)"',
|
|
1042
|
+
].join(';');
|
|
1043
|
+
try {
|
|
1044
|
+
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', script], {
|
|
1045
|
+
encoding: 'utf8',
|
|
1046
|
+
windowsHide: true,
|
|
1047
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1048
|
+
});
|
|
1049
|
+
if (result.status !== 0) return null;
|
|
1050
|
+
const [x, y, width, height] = (result.stdout || '').trim().split(',').map(Number);
|
|
1051
|
+
if ([x, y, width, height].some(Number.isNaN)) return null;
|
|
1052
|
+
return {
|
|
1053
|
+
x: Math.max(0, Math.round(x + ((width - APP_WIDTH) / 2))),
|
|
1054
|
+
y: Math.max(0, Math.round(y + ((height - APP_HEIGHT) / 2))),
|
|
1055
|
+
};
|
|
1056
|
+
} catch {
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function formatOpenError(error) {
|
|
1062
|
+
return error instanceof Error ? error.message : String(error);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function describeSpawnSyncResult(result) {
|
|
1066
|
+
if (result.error) return formatOpenError(result.error);
|
|
1067
|
+
const details = [];
|
|
1068
|
+
if (typeof result.status === 'number') details.push(`exit status ${result.status}`);
|
|
1069
|
+
if (result.signal) details.push(`signal ${result.signal}`);
|
|
1070
|
+
const stderr = (result.stderr || '').toString().trim();
|
|
1071
|
+
const stdout = (result.stdout || '').toString().trim();
|
|
1072
|
+
if (stderr) details.push(`stderr: ${stderr}`);
|
|
1073
|
+
if (stdout) details.push(`stdout: ${stdout}`);
|
|
1074
|
+
return details.join('; ') || 'unknown launch failure';
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function logOpenFailure(method, message) {
|
|
1078
|
+
console.error(`[setup] Failed to open Config UI window via ${method}: ${message}`);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function tryDetachedOpen(method, command, args, attempts) {
|
|
1082
|
+
return new Promise(resolve => {
|
|
1083
|
+
let child;
|
|
1084
|
+
let settled = false;
|
|
1085
|
+
const finish = ok => {
|
|
1086
|
+
if (settled) return;
|
|
1087
|
+
settled = true;
|
|
1088
|
+
resolve(ok);
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
try {
|
|
1092
|
+
child = spawn(command, args, {
|
|
1093
|
+
detached: true,
|
|
1094
|
+
stdio: 'ignore',
|
|
1095
|
+
windowsHide: true,
|
|
1096
|
+
});
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
const message = formatOpenError(error);
|
|
1099
|
+
attempts.push({ method, ok: false, error: message });
|
|
1100
|
+
logOpenFailure(method, message);
|
|
1101
|
+
finish(false);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
child.once('error', error => {
|
|
1106
|
+
const message = formatOpenError(error);
|
|
1107
|
+
attempts.push({ method, ok: false, error: message });
|
|
1108
|
+
logOpenFailure(method, message);
|
|
1109
|
+
finish(false);
|
|
1110
|
+
});
|
|
1111
|
+
child.once('spawn', () => {
|
|
1112
|
+
child.unref();
|
|
1113
|
+
attempts.push({ method, ok: true });
|
|
1114
|
+
finish(true);
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function trySyncOpen(method, command, args, attempts) {
|
|
1120
|
+
debugSetup(`[open-debug] trySyncOpen method=${method} command=${command}`);
|
|
1121
|
+
let result;
|
|
1122
|
+
try {
|
|
1123
|
+
result = spawnSync(command, args, {
|
|
1124
|
+
encoding: 'utf8',
|
|
1125
|
+
windowsHide: true,
|
|
1126
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1127
|
+
});
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
const message = formatOpenError(error);
|
|
1130
|
+
attempts.push({ method, ok: false, error: message });
|
|
1131
|
+
logOpenFailure(method, message);
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (!result.error && result.status === 0) {
|
|
1136
|
+
attempts.push({ method, ok: true });
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const message = describeSpawnSyncResult(result);
|
|
1141
|
+
attempts.push({ method, ok: false, error: message });
|
|
1142
|
+
logOpenFailure(method, message);
|
|
1143
|
+
return false;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function quotePowerShellString(value) {
|
|
1147
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Serialize every window-open sequence through one in-process chain. All
|
|
1151
|
+
// /open requests (and the open-on-start path) funnel through this single
|
|
1152
|
+
// server process, so concurrent opens would otherwise race the FindChromePid
|
|
1153
|
+
// existence gate: open #2 runs while open #1's browser has spawned but is not
|
|
1154
|
+
// yet discoverable, sees pid=0, and deletes the LIVE owner's Singleton* files
|
|
1155
|
+
// then respawns. The lock holds (see openAppWindowSequence's win32 path) until
|
|
1156
|
+
// the spawned browser is discoverable or its launcher child exits, so by the
|
|
1157
|
+
// time the next sequence runs the existence check is stable.
|
|
1158
|
+
let openWindowChain = Promise.resolve();
|
|
1159
|
+
function openAppWindow() {
|
|
1160
|
+
// Run after the previous sequence settles (success OR failure), ignoring its
|
|
1161
|
+
// result; keep the chain alive with a rejection-swallowing tail so one
|
|
1162
|
+
// failed open never poisons later opens.
|
|
1163
|
+
const run = openWindowChain.then(openAppWindowSequence, openAppWindowSequence);
|
|
1164
|
+
openWindowChain = run.then(() => {}, () => {});
|
|
1165
|
+
return run;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function openAppWindowSequence() {
|
|
1169
|
+
const appUrl = `http://localhost:${PORT}`;
|
|
1170
|
+
const attempts = [];
|
|
1171
|
+
|
|
1172
|
+
if (isWin) {
|
|
1173
|
+
const browser = getBrowserPath();
|
|
1174
|
+
if (browser) {
|
|
1175
|
+
// Chrome `--app=` mode renders the frameless standalone popup the
|
|
1176
|
+
// user wants. Two pitfalls handled:
|
|
1177
|
+
// 1. Singleton lock: shared user-data-dir + stale chrome session →
|
|
1178
|
+
// IPC reuse → silent no-op when prior window was closed. Sidestep
|
|
1179
|
+
// via unique per-launch profile dir.
|
|
1180
|
+
// 2. Socket-leak deadlock: each chrome holds persistent keepalive
|
|
1181
|
+
// HTTP connections to setup-server. Repeated launches without
|
|
1182
|
+
// closing earlier popups accumulate ESTABLISHED/CLOSE_WAIT entries
|
|
1183
|
+
// until the server can't accept new requests (F5 hangs forever).
|
|
1184
|
+
// Sweep any mixdog-owned chrome before spawning so connections
|
|
1185
|
+
// are released and only one popup is ever live.
|
|
1186
|
+
// killAllMixdogChromes / getCenteredWindowPosition / focusNewestMixdogWindow
|
|
1187
|
+
// were each spawning a PowerShell child (3 console-flash sources per
|
|
1188
|
+
// /open). Removing them eliminates the conhost flashes. Trade-offs: old
|
|
1189
|
+
// popups stay alive until the user closes them, the new window opens at
|
|
1190
|
+
// chrome's default position rather than centered on the cursor's
|
|
1191
|
+
// monitor, and it spawns behind whatever was foregrounded. Use a fresh
|
|
1192
|
+
// per-launch profile dir to guarantee a new window even without the
|
|
1193
|
+
// singleton kill.
|
|
1194
|
+
const chromeProfile = ensureStableChromeProfileDir();
|
|
1195
|
+
debugSetup(`[open-debug] chromeProfile=${chromeProfile}`);
|
|
1196
|
+
const args = [
|
|
1197
|
+
`--app=${appUrl}`,
|
|
1198
|
+
`--user-data-dir=${chromeProfile}`,
|
|
1199
|
+
`--window-size=${APP_WIDTH},${APP_HEIGHT}`,
|
|
1200
|
+
'--no-first-run',
|
|
1201
|
+
'--no-default-browser-check',
|
|
1202
|
+
// Password/autofill suppression defensive belt to the Preferences
|
|
1203
|
+
// file suspenders above (some code paths key off the flag instead).
|
|
1204
|
+
'--password-store=basic',
|
|
1205
|
+
// Helper-subprocess minimization (verified safe). --no-zygote and
|
|
1206
|
+
// the other startup-stripping flags broke renderer init (blank
|
|
1207
|
+
// page), so this set is the maximum that keeps chrome rendering:
|
|
1208
|
+
// Audio in-process via --disable-features below.
|
|
1209
|
+
// --disable-logging / --log-level=3: suppress chrome's stderr
|
|
1210
|
+
// pipe so helpers don't allocate a console.
|
|
1211
|
+
// --enable-features=...:disable trims a few background services.
|
|
1212
|
+
// Removed in this revision:
|
|
1213
|
+
// --in-process-gpu: produced a white screen on the --app window
|
|
1214
|
+
// (renderer JS executed, .main.ready class landed, /generation
|
|
1215
|
+
// polled; but the compositor never painted). The cmd.exe show=0
|
|
1216
|
+
// wrapper above already hides every helper conhost, so this
|
|
1217
|
+
// optimization saved one process at the cost of breaking
|
|
1218
|
+
// rendering — keep the default out-of-process GPU.
|
|
1219
|
+
// --no-sandbox: did not reduce helper count (sandbox is per-
|
|
1220
|
+
// renderer, not a separate process). It only triggered chrome's
|
|
1221
|
+
// "unsupported command-line flag" warning bar on top of the
|
|
1222
|
+
// --app window. Zero ergonomic benefit, real UX regression.
|
|
1223
|
+
'--disable-logging',
|
|
1224
|
+
'--log-level=3',
|
|
1225
|
+
'--disable-features=PasswordManagerOnboarding,AutofillServerCommunication,PasswordCheck,AudioServiceOutOfProcess,RendererCodeIntegrity,CalculateNativeWinOcclusion',
|
|
1226
|
+
];
|
|
1227
|
+
|
|
1228
|
+
// Direct chrome.exe spawn with detached + ignored stdio + windowsHide.
|
|
1229
|
+
// The previous `cmd /c start` path made chrome inherit cmd's console
|
|
1230
|
+
// handle; its --type=renderer/gpu helpers then attached conhost.exe
|
|
1231
|
+
// windows that flashed visibly on screen during open (5-10 flashes
|
|
1232
|
+
// observed). Direct spawn keeps the entire chrome process tree
|
|
1233
|
+
// consoleless. detached:true puts chrome in its own process group so
|
|
1234
|
+
// it stays alive after this wrapper exits.
|
|
1235
|
+
debugSetup(`[open-debug] chrome via wscript: ${browser}`);
|
|
1236
|
+
let chromeSpawnOk = false;
|
|
1237
|
+
try {
|
|
1238
|
+
// Bun 1.3.13 spawn() on Windows lets CreateProcess flash a console
|
|
1239
|
+
// for the child (and chrome's helpers) before windowsHide takes
|
|
1240
|
+
// effect, even with stdio:'ignore'. wscript.exe is a GUI-mode
|
|
1241
|
+
// scripting host with no console of its own, but Win11 can ignore
|
|
1242
|
+
// Shell.Application.ShellExecute(..., show=0) for cmd.exe and leave
|
|
1243
|
+
// the empty conhost visible. Use WMI Win32_Process.Create instead:
|
|
1244
|
+
// Win32_ProcessStartup.ShowWindow=0 is applied to the actual
|
|
1245
|
+
// CreateProcess call that creates cmd.exe, so cmd's console is born
|
|
1246
|
+
// hidden rather than hidden after ShellExecute creates it. Do NOT
|
|
1247
|
+
// set CREATE_NO_WINDOW; chrome helpers need a real hidden console to
|
|
1248
|
+
// inherit so they do not allocate their own conhost instances.
|
|
1249
|
+
const escVbs = s => String(s).replace(/"/g, '""');
|
|
1250
|
+
const argsStr = args.join(' ');
|
|
1251
|
+
// Warm open + cold-open invariant. The profile dir is stable per
|
|
1252
|
+
// install (CHROME_PROFILE_DIR) and chrome enforces ONE singleton per
|
|
1253
|
+
// --user-data-dir, so two facts decide the action:
|
|
1254
|
+
//
|
|
1255
|
+
// 1. A live mixdog chrome already owns the profile (FindChromePid by
|
|
1256
|
+
// --app + --user-data-dir, ignoring --type= helpers) → just focus
|
|
1257
|
+
// it. Killing+respawning a live window is wasteful and races the
|
|
1258
|
+
// /generation self-close poll. (warm open)
|
|
1259
|
+
//
|
|
1260
|
+
// 2. No live owner → the profile may still carry a STALE singleton
|
|
1261
|
+
// lock (SingletonLock/Socket/Cookie) left by a prior chrome that
|
|
1262
|
+
// was force-killed (takeover taskkill /T /F) or lost to sleep/
|
|
1263
|
+
// crash. A fresh `--app` launch then rendezvouses with that dead
|
|
1264
|
+
// instance over the singleton socket, forwards its URL, and exits
|
|
1265
|
+
// WITHOUT opening a window — the reported cold-open bug (URL
|
|
1266
|
+
// printed, no window, a later /open works once the lock is reaped).
|
|
1267
|
+
// Deleting the stale Singleton* files first guarantees chrome
|
|
1268
|
+
// boots a real window instead of IPC-forwarding to a ghost. This
|
|
1269
|
+
// is the invariant ("spawn into a clean singleton when no live
|
|
1270
|
+
// owner"), not a retry. The title-scoped taskkill is kept only as
|
|
1271
|
+
// a defensive belt for a same-title window with a non-matching
|
|
1272
|
+
// command line; it is a no-op in the common case.
|
|
1273
|
+
//
|
|
1274
|
+
// The taskkill → chrome chain runs under one hidden cmd.exe (one
|
|
1275
|
+
// cmd.exe per /open). `&` runs both regardless of exit code (taskkill
|
|
1276
|
+
// exits 128 when nothing matches — must not abort the chain). cmd /c
|
|
1277
|
+
// waits until chrome exits, then exits with it.
|
|
1278
|
+
const cmdArg = `/d /c taskkill /F /FI "WINDOWTITLE eq MIXDOG CONFIG" >NUL 2>&1 & "${browser}" ${argsStr} >NUL 2>&1`;
|
|
1279
|
+
const appNeedle = `--app=${appUrl}`;
|
|
1280
|
+
const profileNeedle = `--user-data-dir=${chromeProfile}`;
|
|
1281
|
+
// Process-name needle derived from the ACTUAL chosen browser exe
|
|
1282
|
+
// (chrome.exe / msedge.exe / brave.exe / vivaldi.exe). Hardcoding
|
|
1283
|
+
// chrome.exe made FindChromePid always return 0 under Edge/Brave/
|
|
1284
|
+
// Vivaldi, so the cold branch deleted a LIVE owner's Singleton* files
|
|
1285
|
+
// and respawned instead of focusing.
|
|
1286
|
+
const procName = basename(browser);
|
|
1287
|
+
const vbsLines = [
|
|
1288
|
+
'Option Explicit',
|
|
1289
|
+
'Const HIDDEN_WINDOW = 0',
|
|
1290
|
+
'Dim Wmi, Startup, Wsh, cmdLine, cmdPid, rc, appNeedle, profileNeedle, existingPid, profileDir, procName',
|
|
1291
|
+
'Set Wmi = GetObject("winmgmts:{impersonationLevel=impersonate}!\\\\.\\root\\cimv2")',
|
|
1292
|
+
'Set Wsh = CreateObject("WScript.Shell")',
|
|
1293
|
+
`appNeedle = "${escVbs(appNeedle)}"`,
|
|
1294
|
+
`profileNeedle = "${escVbs(profileNeedle)}"`,
|
|
1295
|
+
`profileDir = "${escVbs(chromeProfile)}"`,
|
|
1296
|
+
`procName = "${escVbs(procName)}"`,
|
|
1297
|
+
'existingPid = FindChromePid(Wmi, appNeedle, profileNeedle)',
|
|
1298
|
+
'If existingPid = 0 Then',
|
|
1299
|
+
' Call ClearSingletonLocks(profileDir)',
|
|
1300
|
+
' Set Startup = Wmi.Get("Win32_ProcessStartup").SpawnInstance_',
|
|
1301
|
+
' Startup.ShowWindow = HIDDEN_WINDOW',
|
|
1302
|
+
` cmdLine = "cmd.exe ${escVbs(cmdArg)}"`,
|
|
1303
|
+
' rc = Wmi.Get("Win32_Process").Create(cmdLine, Null, Startup, cmdPid)',
|
|
1304
|
+
' If rc <> 0 Then WScript.Quit rc',
|
|
1305
|
+
'End If',
|
|
1306
|
+
'Call FocusMixdogWindow(Wmi, Wsh, appNeedle, profileNeedle)',
|
|
1307
|
+
'',
|
|
1308
|
+
'Sub ClearSingletonLocks(profileDir)',
|
|
1309
|
+
' Dim Fso, names, i, p',
|
|
1310
|
+
' On Error Resume Next',
|
|
1311
|
+
' Set Fso = CreateObject("Scripting.FileSystemObject")',
|
|
1312
|
+
' names = Array("SingletonLock", "SingletonSocket", "SingletonCookie")',
|
|
1313
|
+
' For i = 0 To UBound(names)',
|
|
1314
|
+
' p = Fso.BuildPath(profileDir, names(i))',
|
|
1315
|
+
' If Fso.FileExists(p) Then Fso.DeleteFile p, True',
|
|
1316
|
+
' Next',
|
|
1317
|
+
' On Error GoTo 0',
|
|
1318
|
+
'End Sub',
|
|
1319
|
+
'',
|
|
1320
|
+
'Sub FocusMixdogWindow(Wmi, Wsh, appNeedle, profileNeedle)',
|
|
1321
|
+
' Dim i, pid, activated',
|
|
1322
|
+
' On Error Resume Next',
|
|
1323
|
+
' For i = 1 To 40',
|
|
1324
|
+
' WScript.Sleep 150',
|
|
1325
|
+
' pid = FindChromePid(Wmi, appNeedle, profileNeedle)',
|
|
1326
|
+
' activated = False',
|
|
1327
|
+
' If pid <> 0 Then',
|
|
1328
|
+
' Wsh.SendKeys "%"',
|
|
1329
|
+
' WScript.Sleep 25',
|
|
1330
|
+
' activated = Wsh.AppActivate(CLng(pid))',
|
|
1331
|
+
' End If',
|
|
1332
|
+
' If Not activated And (pid <> 0 Or i > 8) Then',
|
|
1333
|
+
' Wsh.SendKeys "%"',
|
|
1334
|
+
' WScript.Sleep 25',
|
|
1335
|
+
' activated = Wsh.AppActivate("MIXDOG CONFIG")',
|
|
1336
|
+
' End If',
|
|
1337
|
+
' If activated Then Exit For',
|
|
1338
|
+
' Next',
|
|
1339
|
+
' On Error GoTo 0',
|
|
1340
|
+
'End Sub',
|
|
1341
|
+
'',
|
|
1342
|
+
'Function FindChromePid(Wmi, appNeedle, profileNeedle)',
|
|
1343
|
+
' Dim proc, newestPid, newestCreated, commandLine',
|
|
1344
|
+
' newestPid = 0',
|
|
1345
|
+
' newestCreated = ""',
|
|
1346
|
+
' For Each proc In Wmi.ExecQuery("SELECT ProcessId,CommandLine,CreationDate FROM Win32_Process WHERE Name = \'" & procName & "\'")',
|
|
1347
|
+
' commandLine = ""',
|
|
1348
|
+
' If Not IsNull(proc.CommandLine) Then commandLine = CStr(proc.CommandLine)',
|
|
1349
|
+
' If InStr(1, commandLine, appNeedle, vbTextCompare) > 0 Then',
|
|
1350
|
+
' If InStr(1, commandLine, profileNeedle, vbTextCompare) > 0 Then',
|
|
1351
|
+
' If InStr(1, commandLine, "--type=", vbTextCompare) = 0 Then',
|
|
1352
|
+
' If newestCreated = "" Or CStr(proc.CreationDate) > newestCreated Then',
|
|
1353
|
+
' newestCreated = CStr(proc.CreationDate)',
|
|
1354
|
+
' newestPid = CLng(proc.ProcessId)',
|
|
1355
|
+
' End If',
|
|
1356
|
+
' End If',
|
|
1357
|
+
' End If',
|
|
1358
|
+
' End If',
|
|
1359
|
+
' Next',
|
|
1360
|
+
' FindChromePid = newestPid',
|
|
1361
|
+
'End Function',
|
|
1362
|
+
];
|
|
1363
|
+
const vbsPath = join(tmpdir(), `mixdog-chrome-launch-${Date.now()}.vbs`);
|
|
1364
|
+
writeFileSync(vbsPath, vbsLines.join('\r\n'), 'utf8');
|
|
1365
|
+
// Hold the open mutex until this launcher exits. The wscript host
|
|
1366
|
+
// runs ClearSingletonLocks→spawn→FocusMixdogWindow, whose loop polls
|
|
1367
|
+
// FindChromePid until the browser is discoverable (or its bounded
|
|
1368
|
+
// ~6s/40-tick loop expires), then exits. Awaiting it guarantees the
|
|
1369
|
+
// next queued open sees a stable existence check — the spawned
|
|
1370
|
+
// browser is discoverable (so it focuses) rather than racing the
|
|
1371
|
+
// pid=0 gate into a stale-Singleton delete of a live owner.
|
|
1372
|
+
//
|
|
1373
|
+
// Bounded await: if wscript/WMI/CreateProcess hangs before exiting,
|
|
1374
|
+
// an unbounded wait would leave openAppWindowSequence() pending →
|
|
1375
|
+
// openWindowChain stuck → every future /open queued forever (the
|
|
1376
|
+
// client-side requestOpen timeout never settles the server chain).
|
|
1377
|
+
// Race the child's exit against a server-side deadline a few seconds
|
|
1378
|
+
// above the VBS focus loop's own ~6s bound; on deadline, kill the
|
|
1379
|
+
// wscript child (tree) and report a failed open so the chain advances.
|
|
1380
|
+
// Killing wscript cannot touch the browser: the VBS spawns it via WMI
|
|
1381
|
+
// Win32_Process.Create (cmd.exe → browser), an independent process not
|
|
1382
|
+
// parented to wscript, so a tree-kill of wscript reaps only the focus
|
|
1383
|
+
// loop.
|
|
1384
|
+
const WSCRIPT_OPEN_DEADLINE_MS = 12000;
|
|
1385
|
+
const timedOut = await new Promise(resolve => {
|
|
1386
|
+
const wscriptChild = spawn('wscript.exe', ['//B', '//NoLogo', vbsPath], {
|
|
1387
|
+
stdio: 'ignore', windowsHide: true,
|
|
1388
|
+
});
|
|
1389
|
+
let settled = false;
|
|
1390
|
+
const finish = via => { if (settled) return; settled = true; clearTimeout(timer); resolve(via); };
|
|
1391
|
+
const timer = setTimeout(() => {
|
|
1392
|
+
// Tree-kill the wscript launcher only; the detached browser lives on.
|
|
1393
|
+
try { spawnSync('taskkill', ['/F', '/T', '/PID', String(wscriptChild.pid)], { windowsHide: true, stdio: 'ignore', timeout: 4000 }); } catch {}
|
|
1394
|
+
try { wscriptChild.kill(); } catch {}
|
|
1395
|
+
finish(true);
|
|
1396
|
+
}, WSCRIPT_OPEN_DEADLINE_MS);
|
|
1397
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
1398
|
+
wscriptChild.once('error', () => finish(false));
|
|
1399
|
+
wscriptChild.once('exit', () => finish(false));
|
|
1400
|
+
});
|
|
1401
|
+
if (timedOut) {
|
|
1402
|
+
const err = `wscript launcher did not exit within ${WSCRIPT_OPEN_DEADLINE_MS}ms; killed launcher (browser left running)`;
|
|
1403
|
+
attempts.push({ method: 'browser app mode (wscript)', ok: false, error: err });
|
|
1404
|
+
logOpenFailure('browser app mode (wscript)', err);
|
|
1405
|
+
} else {
|
|
1406
|
+
chromeSpawnOk = true;
|
|
1407
|
+
attempts.push({ method: 'browser app mode (wscript)', ok: true });
|
|
1408
|
+
}
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
attempts.push({ method: 'browser app mode (wscript)', ok: false, error: formatOpenError(error) });
|
|
1411
|
+
logOpenFailure('browser app mode (wscript)', formatOpenError(error));
|
|
1412
|
+
}
|
|
1413
|
+
if (chromeSpawnOk) {
|
|
1414
|
+
return { ok: true, method: 'browser app mode (wscript)', attempts };
|
|
1415
|
+
}
|
|
1416
|
+
} else {
|
|
1417
|
+
attempts.push({ method: 'browser app mode', ok: false, error: 'No supported Chromium browser path found' });
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
if (trySyncOpen('cmd start', 'cmd', ['/c', 'start', '', appUrl], attempts)) {
|
|
1421
|
+
return {
|
|
1422
|
+
ok: true,
|
|
1423
|
+
method: 'cmd start',
|
|
1424
|
+
warning: browser ? undefined : 'Supported Chromium browser not found; opened with the default browser instead of app mode.',
|
|
1425
|
+
attempts,
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const psCommand = `Start-Process -FilePath ${quotePowerShellString(appUrl)}`;
|
|
1430
|
+
if (trySyncOpen('PowerShell Start-Process', 'powershell.exe', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psCommand], attempts)) {
|
|
1431
|
+
return { ok: true, method: 'PowerShell Start-Process', attempts };
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
return { ok: false, error: 'Failed to launch Config UI window', attempts };
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
if (process.platform === 'darwin') {
|
|
1438
|
+
const macResult = await new Promise(resolve => {
|
|
1439
|
+
const child = spawn('open', [appUrl], { stdio: 'ignore' });
|
|
1440
|
+
let timer = setTimeout(() => {
|
|
1441
|
+
try { child.kill(); } catch {}
|
|
1442
|
+
resolve({ ok: false, error: 'open-timeout' });
|
|
1443
|
+
}, 5000);
|
|
1444
|
+
child.once('error', err => resolve({ ok: false, error: err.message }));
|
|
1445
|
+
child.once('close', code => {
|
|
1446
|
+
clearTimeout(timer);
|
|
1447
|
+
resolve(code === 0 ? { ok: true } : { ok: false, error: `exit ${code}` });
|
|
1448
|
+
});
|
|
1449
|
+
});
|
|
1450
|
+
const macAttempt = { method: 'macOS open', ...macResult };
|
|
1451
|
+
if (!macResult.ok) logOpenFailure('macOS open', macResult.error);
|
|
1452
|
+
return { ...macResult, method: 'macOS open', attempts: [macAttempt] };
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const xdgResult = await new Promise(resolve => {
|
|
1456
|
+
const child = spawn('xdg-open', [appUrl], { stdio: 'ignore' });
|
|
1457
|
+
let timer = setTimeout(() => {
|
|
1458
|
+
try { child.kill(); } catch {}
|
|
1459
|
+
resolve({ ok: false, error: 'open-timeout' });
|
|
1460
|
+
}, 5000);
|
|
1461
|
+
child.once('error', err => resolve({ ok: false, error: err.message }));
|
|
1462
|
+
child.once('close', code => {
|
|
1463
|
+
clearTimeout(timer);
|
|
1464
|
+
resolve(code === 0 ? { ok: true } : { ok: false, error: `exit ${code}` });
|
|
1465
|
+
});
|
|
1466
|
+
});
|
|
1467
|
+
const xdgAttempt = { method: 'xdg-open', ...xdgResult };
|
|
1468
|
+
if (!xdgResult.ok) logOpenFailure('xdg-open', xdgResult.error);
|
|
1469
|
+
return { ...xdgResult, method: 'xdg-open', attempts: [xdgAttempt] };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// -- CLI check --
|
|
1473
|
+
|
|
1474
|
+
// Direct spawn of `where.exe` / `which` (shell:false) skips Node's default
|
|
1475
|
+
// cmd.exe-wrapped exec on Windows. The cmd.exe wrapper allocates a console
|
|
1476
|
+
// that flashed conhost on /cli-check during config-UI boot even with
|
|
1477
|
+
// windowsHide:true. Direct spawn keeps the lookup consoleless.
|
|
1478
|
+
//
|
|
1479
|
+
// 6s timeout + auto-resolve {installed:false} guards against a where.exe
|
|
1480
|
+
// PATH-resolution hang. The /cli-check route is one of the 9 loaders the
|
|
1481
|
+
// setup-html boot path Promise.allSettled-s on before flipping .main.ready;
|
|
1482
|
+
// without a timeout, a hung lookup would leave the page on a white screen
|
|
1483
|
+
// indefinitely (loader never settles → spinner never hides → no UI render).
|
|
1484
|
+
// 2s was too tight: on cold-cache page-boot the bun spawn → where.exe →
|
|
1485
|
+
// close-event roundtrip was empirically 1.5-2.4s on Windows, so the timer
|
|
1486
|
+
// raced the close handler and intermittently returned `installed:false`
|
|
1487
|
+
// for an actually-installed binary (user-visible "ngrok not found" after
|
|
1488
|
+
// fresh /mixdog:config). 6s keeps the hang-guard intact while clearing
|
|
1489
|
+
// the spawn-overhead band by ~3x.
|
|
1490
|
+
function checkCli(name) {
|
|
1491
|
+
return new Promise(resolve => {
|
|
1492
|
+
const tool = isWin ? 'where.exe' : 'which';
|
|
1493
|
+
let settled = false;
|
|
1494
|
+
const finish = result => { if (!settled) { settled = true; resolve(result); } };
|
|
1495
|
+
let child;
|
|
1496
|
+
try {
|
|
1497
|
+
child = spawn(tool, [name], {
|
|
1498
|
+
windowsHide: true,
|
|
1499
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1500
|
+
shell: false,
|
|
1501
|
+
});
|
|
1502
|
+
} catch {
|
|
1503
|
+
finish({ installed: false });
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
const timer = setTimeout(() => {
|
|
1507
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
1508
|
+
finish({ installed: false });
|
|
1509
|
+
}, 6000);
|
|
1510
|
+
let out = '';
|
|
1511
|
+
if (child.stdout) child.stdout.on('data', chunk => { out += chunk; });
|
|
1512
|
+
child.once('error', () => { clearTimeout(timer); finish({ installed: false }); });
|
|
1513
|
+
child.once('close', code => {
|
|
1514
|
+
clearTimeout(timer);
|
|
1515
|
+
if (code !== 0 || !out.trim()) finish({ installed: false });
|
|
1516
|
+
else finish({ installed: true, path: out.trim().split(/\r?\n/)[0] });
|
|
1517
|
+
});
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// -- HTTP body reader --
|
|
1522
|
+
// An empty/whitespace body resolves to {} (action endpoints POST with no
|
|
1523
|
+
// body). A NON-empty body that fails JSON.parse is rejected with a tagged
|
|
1524
|
+
// error instead of being silently coerced to {} — previously a truncated or
|
|
1525
|
+
// malformed payload parsed as {} and the save handler still returned success,
|
|
1526
|
+
// masking a failed save (defaults written, real data lost). The request
|
|
1527
|
+
// handler converts BadJsonError into a 400 so the client sees the failure.
|
|
1528
|
+
class BadJsonError extends Error {
|
|
1529
|
+
constructor(message) { super(message); this.name = 'BadJsonError'; this.statusCode = 400; }
|
|
1530
|
+
}
|
|
1531
|
+
function readBody(req) {
|
|
1532
|
+
return new Promise((resolve, reject) => {
|
|
1533
|
+
let body = '';
|
|
1534
|
+
req.on('data', c => { body += c; });
|
|
1535
|
+
req.on('end', () => {
|
|
1536
|
+
if (!body.trim()) { resolve({}); return; }
|
|
1537
|
+
try { resolve(JSON.parse(body)); }
|
|
1538
|
+
catch (e) { reject(new BadJsonError(`malformed JSON body: ${e.message}`)); }
|
|
1539
|
+
});
|
|
1540
|
+
req.on('error', reject);
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// -- Server --
|
|
1545
|
+
let openGeneration = 0;
|
|
1546
|
+
let windowOpen = false;
|
|
1547
|
+
|
|
1548
|
+
const server = http.createServer(async (req, res) => {
|
|
1549
|
+
// Outer guard: most handlers await readBody() outside their own try/catch,
|
|
1550
|
+
// so a rejected body parse (BadJsonError) would otherwise be an unhandled
|
|
1551
|
+
// rejection that leaves the request hanging. Map it to a JSON error response
|
|
1552
|
+
// (400 for malformed input, 500 otherwise) so malformed saves fail loudly.
|
|
1553
|
+
try {
|
|
1554
|
+
await handleRequest(req, res);
|
|
1555
|
+
} catch (e) {
|
|
1556
|
+
const code = Number.isInteger(e?.statusCode) ? e.statusCode : 500;
|
|
1557
|
+
if (!res.headersSent) {
|
|
1558
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
1559
|
+
res.end(JSON.stringify({ ok: false, error: e?.message || String(e) }));
|
|
1560
|
+
} else {
|
|
1561
|
+
try { res.end(); } catch {}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
async function handleRequest(req, res) {
|
|
1567
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
1568
|
+
const path = url.pathname;
|
|
1569
|
+
|
|
1570
|
+
// Reflective CORS — echo Origin only when it is our loopback UI port;
|
|
1571
|
+
// otherwise omit the header so browsers block the cross-origin response.
|
|
1572
|
+
const _reqOrigin = req.headers.origin || '';
|
|
1573
|
+
if (_reqOrigin && /^http:\/\/(localhost|127\.0\.0\.1):3458(\/|$)/.test(_reqOrigin)) {
|
|
1574
|
+
res.setHeader('Access-Control-Allow-Origin', _reqOrigin);
|
|
1575
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
1576
|
+
}
|
|
1577
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
1578
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
1579
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
1580
|
+
|
|
1581
|
+
// Global CSRF guard — POST/PUT/DELETE require an allowed origin.
|
|
1582
|
+
if ((req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') && !isAllowedOrigin(req)) {
|
|
1583
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1584
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
if (req.method === 'GET' && path === '/') {
|
|
1589
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1590
|
+
res.end(readFileSync(HTML_PATH, 'utf8'));
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// Phase D-2 — Smart Bridge cache dashboard (provider × profile matrix).
|
|
1595
|
+
// Each workflow role exposes one `shards` map keyed by provider so callers
|
|
1596
|
+
// can show, for example, that a role is warm on anthropic-oauth but cold on
|
|
1597
|
+
// openai-oauth without either row overwriting the other.
|
|
1598
|
+
if (req.method === 'GET' && path === '/bridge/stats') {
|
|
1599
|
+
try {
|
|
1600
|
+
const { CacheRegistry } = await import('../src/agent/orchestrator/smart-bridge/registry.mjs');
|
|
1601
|
+
const registry = CacheRegistry.shared();
|
|
1602
|
+
const stats = registry.getStats();
|
|
1603
|
+
const profiles = {};
|
|
1604
|
+
let warmShards = 0;
|
|
1605
|
+
for (const [profileId, providers] of Object.entries(stats.profiles || {})) {
|
|
1606
|
+
const shards = {};
|
|
1607
|
+
for (const [provider, entry] of Object.entries(providers)) {
|
|
1608
|
+
const hit = entry.hitCount || 0;
|
|
1609
|
+
const miss = entry.missCount || 0;
|
|
1610
|
+
const total = hit + miss;
|
|
1611
|
+
const expiresInMs = Math.max(0, entry.expiresIn || 0);
|
|
1612
|
+
const warm = expiresInMs > 0 && entry.observedOnly !== true;
|
|
1613
|
+
if (warm) warmShards += 1;
|
|
1614
|
+
shards[provider] = {
|
|
1615
|
+
prefixHash: entry.prefixHash || null,
|
|
1616
|
+
hitCount: hit,
|
|
1617
|
+
missCount: miss,
|
|
1618
|
+
hitRate: total > 0 ? hit / total : 0,
|
|
1619
|
+
warm,
|
|
1620
|
+
expiresInMs,
|
|
1621
|
+
createdAt: entry.createdAt ? new Date(entry.createdAt).toISOString() : null,
|
|
1622
|
+
observedOnly: entry.observedOnly === true,
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
profiles[profileId] = {
|
|
1626
|
+
id: profileId,
|
|
1627
|
+
taskType: null,
|
|
1628
|
+
behavior: null,
|
|
1629
|
+
fallbackPreset: null,
|
|
1630
|
+
description: '(workflow role)',
|
|
1631
|
+
shards,
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1635
|
+
res.end(JSON.stringify({
|
|
1636
|
+
profileCount: stats.profileCount || Object.keys(profiles).length,
|
|
1637
|
+
shardCount: stats.shardCount || 0,
|
|
1638
|
+
warmShardCount: warmShards,
|
|
1639
|
+
openaiKeyCount: stats.openaiKeyCount || 0,
|
|
1640
|
+
updatedAt: registry.data.updatedAt,
|
|
1641
|
+
profiles,
|
|
1642
|
+
}));
|
|
1643
|
+
} catch (e) {
|
|
1644
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1645
|
+
res.end(JSON.stringify({ error: String(e?.message || e) }));
|
|
1646
|
+
}
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// ── GET /bridge/status ──────────────────────────────────────────────
|
|
1651
|
+
// Cheap, read-only, loopback-only. Returns mixdog runtime state as
|
|
1652
|
+
// either a JSON object (?format=json) or a single-line statusline
|
|
1653
|
+
// string (?format=text or Accept: text/plain).
|
|
1654
|
+
// No Origin guard needed — read-only endpoint (C2 convention, v0.1.14).
|
|
1655
|
+
// 0.1.26: aggregation logic lives in src/status/aggregator.mjs so the
|
|
1656
|
+
// MCP-embedded status server shares the same implementation.
|
|
1657
|
+
if (req.method === 'GET' && path === '/bridge/status') {
|
|
1658
|
+
try {
|
|
1659
|
+
const { buildBridgeStatus, renderBridgeStatusText } = await import('../src/status/aggregator.mjs');
|
|
1660
|
+
const wantText = url.searchParams.get('format') === 'text'
|
|
1661
|
+
|| (req.headers['accept'] || '').includes('text/plain');
|
|
1662
|
+
const payload = await buildBridgeStatus(DATA_DIR);
|
|
1663
|
+
if (wantText) {
|
|
1664
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1665
|
+
res.end(renderBridgeStatusText(payload));
|
|
1666
|
+
} else {
|
|
1667
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1668
|
+
res.end(JSON.stringify(payload));
|
|
1669
|
+
}
|
|
1670
|
+
} catch (e) {
|
|
1671
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1672
|
+
res.end(JSON.stringify({ error: String(e?.message || e) }));
|
|
1673
|
+
}
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
// ── GET /api/plugin-path ─────────────────────────────────────────────────
|
|
1679
|
+
// Returns the absolute directory of the plugin install (parent of setup/).
|
|
1680
|
+
// Used by setup.html to render the correct statusline.sh path in the snippet.
|
|
1681
|
+
if (req.method === 'GET' && path === '/api/plugin-path') {
|
|
1682
|
+
const pluginRoot = join(__dirname, '..');
|
|
1683
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1684
|
+
res.end(JSON.stringify({ path: pluginRoot }));
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (req.method === 'GET' && path === '/config') {
|
|
1689
|
+
const raw = readConfig();
|
|
1690
|
+
const config = applyChannelsDefaults(raw);
|
|
1691
|
+
const invalidDiscordToken = pruneInvalidDiscordToken(config);
|
|
1692
|
+
// Re-hydrate secrets from the keychain so the UI password inputs round-
|
|
1693
|
+
// trip. mergeConfig() routes discord.token / webhook.authtoken to the
|
|
1694
|
+
// keychain on save and deletes them from the JSON (keychain is the
|
|
1695
|
+
// canonical source of truth); without re-hydration here, every reload
|
|
1696
|
+
// shows blank password fields and users assume the save failed.
|
|
1697
|
+
const dToken = getDiscordToken();
|
|
1698
|
+
if (dToken && !invalidDiscordToken) config.discord = { ...(config.discord || {}), token: dToken };
|
|
1699
|
+
const wAuth = getWebhookAuthtoken();
|
|
1700
|
+
if (wAuth) config.webhook = { ...(config.webhook || {}), authtoken: wAuth };
|
|
1701
|
+
if (invalidDiscordToken) config._secretDiagnostics = { discordToken: invalidDiscordToken };
|
|
1702
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1703
|
+
res.end(JSON.stringify(config));
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
if (req.method === 'GET' && path === '/config/secrets') {
|
|
1708
|
+
const channelsConfig = applyChannelsDefaults(readConfig());
|
|
1709
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1710
|
+
res.end(JSON.stringify(fullSecretStatus(channelsConfig)));
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
if (req.method === 'POST' && path === '/config') {
|
|
1715
|
+
if (!isAllowedOrigin(req)) {
|
|
1716
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1717
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const data = await readBody(req);
|
|
1721
|
+
const secrets = {};
|
|
1722
|
+
try {
|
|
1723
|
+
// RMW inside the config lock: compute mergeConfig() against the value
|
|
1724
|
+
// read under the same lock that guards the write, so overlapping saves
|
|
1725
|
+
// can't clobber each other (read-then-write outside the lock raced).
|
|
1726
|
+
let merged;
|
|
1727
|
+
updateSection('channels', (current) => {
|
|
1728
|
+
merged = mergeConfig(current, data, secrets);
|
|
1729
|
+
return merged;
|
|
1730
|
+
});
|
|
1731
|
+
console.log(` Config saved: channels secrets=${JSON.stringify(secrets)}`);
|
|
1732
|
+
|
|
1733
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1734
|
+
res.end(JSON.stringify({ ok: true, secrets, secretStatus: fullSecretStatus(merged) }));
|
|
1735
|
+
} catch (e) {
|
|
1736
|
+
process.stderr.write('[setup] /config failed: ' + (e?.stack || e?.message || String(e)) + '\n');
|
|
1737
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1738
|
+
res.end(JSON.stringify({ ok: false, error: e?.message || String(e), secrets }));
|
|
1739
|
+
}
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// -- B6 General module toggles (channels / memory / search / agent) --
|
|
1744
|
+
// Stored as a top-level `modules` section inside mixdog-config.json.
|
|
1745
|
+
// Missing keys default to enabled:true so pre-B6 configs keep all
|
|
1746
|
+
// modules on. Changes require a plugin restart to take effect.
|
|
1747
|
+
if (req.method === 'GET' && path === '/modules') {
|
|
1748
|
+
const raw = readSection('modules');
|
|
1749
|
+
const out = {};
|
|
1750
|
+
for (const name of ['channels', 'memory', 'search', 'agent']) {
|
|
1751
|
+
const entry = raw && typeof raw === 'object' ? raw[name] : null;
|
|
1752
|
+
const enabled = entry && typeof entry === 'object' && entry.enabled === false ? false : true;
|
|
1753
|
+
out[name] = { enabled };
|
|
1754
|
+
}
|
|
1755
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1756
|
+
res.end(JSON.stringify(out));
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
if (req.method === 'POST' && path === '/modules') {
|
|
1761
|
+
if (!isAllowedOrigin(req)) {
|
|
1762
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1763
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
const data = await readBody(req);
|
|
1767
|
+
const sanitized = {};
|
|
1768
|
+
for (const name of ['channels', 'memory', 'search', 'agent']) {
|
|
1769
|
+
const entry = data && typeof data === 'object' ? data[name] : null;
|
|
1770
|
+
const enabled = entry && typeof entry === 'object' && entry.enabled === false ? false : true;
|
|
1771
|
+
sanitized[name] = { enabled };
|
|
1772
|
+
}
|
|
1773
|
+
// Serialize through updateSection (file lock + atomic RMW + backup
|
|
1774
|
+
// restore) instead of read→merge→whole-file write. A bare
|
|
1775
|
+
// readJsonFile()→{} on a transient read failure here used to drop
|
|
1776
|
+
// every other section when the thin object was written back.
|
|
1777
|
+
// Intentional full replace: the modules section schema is only {enabled}
|
|
1778
|
+
// per module; the UI owns the complete map.
|
|
1779
|
+
updateSection('modules', () => sanitized);
|
|
1780
|
+
console.log(' Config saved: modules');
|
|
1781
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1782
|
+
res.end(JSON.stringify({ ok: true, modules: sanitized }));
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// -- B2 Security capabilities (homeAccess) ---------------------------
|
|
1787
|
+
// Stored as a top-level `capabilities` section inside mixdog-config.json.
|
|
1788
|
+
// Missing keys default to `false` so out-of-the-box installs stay
|
|
1789
|
+
// cwd-only; flipping a toggle takes effect on the next tool call
|
|
1790
|
+
// (capability is re-read per invocation in builtin.mjs/patch.mjs).
|
|
1791
|
+
if (req.method === 'GET' && path === '/capabilities') {
|
|
1792
|
+
const raw = readSection('capabilities');
|
|
1793
|
+
const out = { homeAccess: !!(raw && typeof raw === 'object' && raw.homeAccess === true) };
|
|
1794
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1795
|
+
res.end(JSON.stringify(out));
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
if (req.method === 'POST' && path === '/capabilities') {
|
|
1800
|
+
if (!isAllowedOrigin(req)) {
|
|
1801
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1802
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
const data = await readBody(req);
|
|
1806
|
+
const sanitized = { homeAccess: !!(data && typeof data === 'object' && data.homeAccess === true) };
|
|
1807
|
+
// Serialize through updateSection (file lock + atomic RMW + backup
|
|
1808
|
+
// restore); see /modules POST above for why the read→merge→whole-file
|
|
1809
|
+
// write was unsafe.
|
|
1810
|
+
// Intentional full replace: capabilities is a flat toggle map (homeAccess);
|
|
1811
|
+
// no unmanaged sidecar fields today.
|
|
1812
|
+
updateSection('capabilities', () => sanitized);
|
|
1813
|
+
console.log(' Config saved: capabilities');
|
|
1814
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1815
|
+
res.end(JSON.stringify({ ok: true, capabilities: sanitized }));
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// -- Schedules CRUD --
|
|
1820
|
+
const SCHEDULES_DIR = join(DATA_DIR, 'schedules');
|
|
1821
|
+
|
|
1822
|
+
if (req.method === 'GET' && path === '/schedules') {
|
|
1823
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1824
|
+
res.end(JSON.stringify(listSchedules()));
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
if (req.method === 'POST' && path === '/schedules') {
|
|
1829
|
+
if (!isAllowedOrigin(req)) {
|
|
1830
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1831
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
const sc = await readBody(req);
|
|
1835
|
+
const name = sanitizeName(sc.name);
|
|
1836
|
+
if (!name) { res.writeHead(400); res.end('name required or invalid'); return; }
|
|
1837
|
+
if (!sc.time) {
|
|
1838
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1839
|
+
res.end(JSON.stringify({ ok: false, error: 'time required' }));
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
const isCronLike = sc.time.trim().split(/\s+/).length >= 5;
|
|
1843
|
+
const isHHMM = /^([01]?\d|2[0-3]):[0-5]\d$/.test(sc.time);
|
|
1844
|
+
const isLegacy = /^(every\d+m|hourly|daily)$/.test(sc.time);
|
|
1845
|
+
if (isCronLike) {
|
|
1846
|
+
try { validateCronExpression(sc.time); } catch (e) {
|
|
1847
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1848
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
} else if (!isHHMM && !isLegacy) {
|
|
1852
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1853
|
+
res.end(JSON.stringify({ ok: false, error: 'invalid time format: must be HH:MM, cron expression, or every<N>m|hourly|daily' }));
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
if (isHHMM) {
|
|
1857
|
+
// The scheduler registers cron expressions only — convert on save so
|
|
1858
|
+
// UI-created entries actually fire instead of being skipped at reload.
|
|
1859
|
+
const [h, m] = sc.time.split(':');
|
|
1860
|
+
// Encode the legacy `days` field into the day-of-week slot.
|
|
1861
|
+
const dow = sc.days === 'weekday' ? '1-5' : (sc.days === 'weekend' ? '0,6' : '*');
|
|
1862
|
+
sc.time = `${Number(m)} ${Number(h)} * * ${dow}`;
|
|
1863
|
+
} else if (isLegacy) {
|
|
1864
|
+
const everyM = sc.time.match(/^every(\d+)m$/);
|
|
1865
|
+
if (everyM) {
|
|
1866
|
+
const n = Math.min(Math.max(Number(everyM[1]), 1), 59);
|
|
1867
|
+
sc.time = `*/${n} * * * *`;
|
|
1868
|
+
} else if (sc.time === 'hourly') {
|
|
1869
|
+
sc.time = '0 * * * *';
|
|
1870
|
+
} else {
|
|
1871
|
+
sc.time = '0 0 * * *';
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
const dir = join(SCHEDULES_DIR, name);
|
|
1875
|
+
mkdirSync(dir, { recursive: true });
|
|
1876
|
+
const prompt = sc.prompt || '';
|
|
1877
|
+
delete sc.prompt;
|
|
1878
|
+
delete sc.name;
|
|
1879
|
+
const configPath = join(dir, 'config.json');
|
|
1880
|
+
const merged = mergeEndpointConfig(readJsonFile(configPath), sc);
|
|
1881
|
+
writeFileSync(configPath, JSON.stringify(merged, null, 2));
|
|
1882
|
+
writeFileSync(join(dir, 'instructions.md'), prompt);
|
|
1883
|
+
console.log(' Schedule saved:', name);
|
|
1884
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1885
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
if (req.method === 'DELETE' && path === '/schedules') {
|
|
1890
|
+
if (!isAllowedOrigin(req)) {
|
|
1891
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1892
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
const name = sanitizeName(url.searchParams.get('name'));
|
|
1896
|
+
if (!name) { res.writeHead(400); res.end('name required or invalid'); return; }
|
|
1897
|
+
const dir = join(SCHEDULES_DIR, name);
|
|
1898
|
+
if (existsSync(dir)) { rmSync(dir, { recursive: true }); console.log(' Schedule deleted:', name); }
|
|
1899
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1900
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
if (req.method === 'GET' && path.startsWith('/schedules/file/')) {
|
|
1905
|
+
if (!isAllowedOrigin(req)) { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' })); return; }
|
|
1906
|
+
const name = sanitizeName(decodeURIComponent(path.slice('/schedules/file/'.length)));
|
|
1907
|
+
if (!name) { res.writeHead(400); res.end('invalid schedule name'); return; }
|
|
1908
|
+
const filePath = join(SCHEDULES_DIR, name, 'instructions.md');
|
|
1909
|
+
if (!existsSync(filePath)) { mkdirSync(join(SCHEDULES_DIR, name), { recursive: true }); writeFileSync(filePath, '', 'utf8'); }
|
|
1910
|
+
if (isWin) { spawn('cmd', ['/c', 'start', '""', filePath.replace(/[&^"<>|]/g, '^$&')], { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: false }).unref(); }
|
|
1911
|
+
else { spawn('open', [filePath], { detached: true, stdio: 'ignore' }).unref(); }
|
|
1912
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1913
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// -- Webhooks CRUD --
|
|
1918
|
+
const WEBHOOKS_DIR = join(DATA_DIR, 'webhooks');
|
|
1919
|
+
|
|
1920
|
+
if (req.method === 'GET' && path === '/webhooks') {
|
|
1921
|
+
const result = [];
|
|
1922
|
+
if (existsSync(WEBHOOKS_DIR)) {
|
|
1923
|
+
for (const name of readdirSync(WEBHOOKS_DIR, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name)) {
|
|
1924
|
+
const cfg = readJsonFile(join(WEBHOOKS_DIR, name, 'config.json')) || {};
|
|
1925
|
+
let instructions = '';
|
|
1926
|
+
try { instructions = readFileSync(join(WEBHOOKS_DIR, name, 'instructions.md'), 'utf8'); } catch {}
|
|
1927
|
+
result.push({ name, ...cfg, instructions });
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1931
|
+
res.end(JSON.stringify(result));
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
if (req.method === 'POST' && path === '/webhooks') {
|
|
1936
|
+
if (!isAllowedOrigin(req)) {
|
|
1937
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1938
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
const wh = await readBody(req);
|
|
1942
|
+
const name = sanitizeName(wh.name);
|
|
1943
|
+
if (!name) { res.writeHead(400); res.end('name required or invalid'); return; }
|
|
1944
|
+
const dir = join(WEBHOOKS_DIR, name);
|
|
1945
|
+
mkdirSync(dir, { recursive: true });
|
|
1946
|
+
const instructions = wh.instructions || '';
|
|
1947
|
+
delete wh.instructions;
|
|
1948
|
+
delete wh.name;
|
|
1949
|
+
// Invariant: webhook delegate dispatch is bound to the internal
|
|
1950
|
+
// `webhook-handler` hidden role. The UI no longer exposes a role
|
|
1951
|
+
// picker, and any role value posted by a third-party client is
|
|
1952
|
+
// overwritten here so dispatch always lands on the plugin-managed
|
|
1953
|
+
// hidden role rather than a user-workflow role.
|
|
1954
|
+
const configPath = join(dir, 'config.json');
|
|
1955
|
+
const merged = mergeWebhookEndpointConfig(readJsonFile(configPath), wh);
|
|
1956
|
+
writeFileSync(configPath, JSON.stringify(merged, null, 2));
|
|
1957
|
+
writeFileSync(join(dir, 'instructions.md'), instructions);
|
|
1958
|
+
console.log(' Webhook saved:', name);
|
|
1959
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1960
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
if (req.method === 'DELETE' && path === '/webhooks') {
|
|
1965
|
+
if (!isAllowedOrigin(req)) {
|
|
1966
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1967
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
const name = sanitizeName(url.searchParams.get('name'));
|
|
1971
|
+
if (!name) { res.writeHead(400); res.end('name required or invalid'); return; }
|
|
1972
|
+
const dir = join(WEBHOOKS_DIR, name);
|
|
1973
|
+
if (existsSync(dir)) { rmSync(dir, { recursive: true }); console.log(' Webhook deleted:', name); }
|
|
1974
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1975
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
if (req.method === 'GET' && path.startsWith('/webhooks/file/')) {
|
|
1980
|
+
if (!isAllowedOrigin(req)) { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' })); return; }
|
|
1981
|
+
const name = sanitizeName(decodeURIComponent(path.slice('/webhooks/file/'.length)));
|
|
1982
|
+
if (!name) { res.writeHead(400); res.end('invalid webhook name'); return; }
|
|
1983
|
+
const filePath = join(WEBHOOKS_DIR, name, 'instructions.md');
|
|
1984
|
+
if (!existsSync(filePath)) { mkdirSync(join(WEBHOOKS_DIR, name), { recursive: true }); writeFileSync(filePath, '', 'utf8'); }
|
|
1985
|
+
if (isWin) { spawn('cmd', ['/c', 'start', '""', filePath.replace(/[&^"<>|]/g, '^$&')], { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: false }).unref(); }
|
|
1986
|
+
else { spawn('open', [filePath], { detached: true, stdio: 'ignore' }).unref(); }
|
|
1987
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1988
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// -- Delivery log --
|
|
1993
|
+
// Each endpoint keeps an append-only JSONL under its folder. Lists are
|
|
1994
|
+
// latest-wins merged by id, filtered by ?name= / ?status=, sorted ts desc.
|
|
1995
|
+
if (req.method === 'GET' && path === '/webhooks/deliveries') {
|
|
1996
|
+
const name = url.searchParams.get('name') || null;
|
|
1997
|
+
const status = url.searchParams.get('status') || null;
|
|
1998
|
+
const limitRaw = parseInt(url.searchParams.get('limit') || '100', 10);
|
|
1999
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 500) : 100;
|
|
2000
|
+
try {
|
|
2001
|
+
const mod = await import('../src/channels/lib/webhook.mjs');
|
|
2002
|
+
const list = mod.listAllDeliveries({ endpoint: name, status, limit });
|
|
2003
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2004
|
+
res.end(JSON.stringify(list));
|
|
2005
|
+
} catch (err) {
|
|
2006
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2007
|
+
res.end(JSON.stringify({ error: String(err?.message || err) }));
|
|
2008
|
+
}
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// Retry: payload is only preserved as a 512-char preview, so a silent
|
|
2013
|
+
// replay would be misleading. Return 400 and ask the sender to redeliver.
|
|
2014
|
+
if (req.method === 'POST' && path.startsWith('/webhooks/deliveries/') && path.endsWith('/retry')) {
|
|
2015
|
+
if (!isAllowedOrigin(req)) {
|
|
2016
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2017
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
const id = decodeURIComponent(path.slice('/webhooks/deliveries/'.length, -'/retry'.length));
|
|
2021
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2022
|
+
res.end(JSON.stringify({
|
|
2023
|
+
ok: false,
|
|
2024
|
+
id,
|
|
2025
|
+
error: 'payload not retained — use the upstream Redeliver action (GitHub webhooks UI → Recent Deliveries → Redeliver)',
|
|
2026
|
+
}));
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
if (req.method === 'GET' && path === '/cli-check') {
|
|
2031
|
+
let whisperInstalled = false;
|
|
2032
|
+
let voiceMeta = null;
|
|
2033
|
+
let binaryReady = false;
|
|
2034
|
+
let modelReady = false;
|
|
2035
|
+
let ffmpegReady = false;
|
|
2036
|
+
try {
|
|
2037
|
+
const { resolveVoiceRuntime } = await import('../src/channels/lib/voice-runtime-fetcher.mjs');
|
|
2038
|
+
const runtime = resolveVoiceRuntime(DATA_DIR);
|
|
2039
|
+
const whisperCmd = runtime?.whisperCmd || '';
|
|
2040
|
+
const modelPath = runtime?.modelPath || '';
|
|
2041
|
+
const ffmpegPath = runtime?.ffmpegPath || '';
|
|
2042
|
+
binaryReady = !!runtime?.binary;
|
|
2043
|
+
modelReady = !!runtime?.model;
|
|
2044
|
+
ffmpegReady = !!runtime?.ffmpeg;
|
|
2045
|
+
if (whisperCmd || modelPath || ffmpegPath || runtime?.kind) {
|
|
2046
|
+
voiceMeta = {
|
|
2047
|
+
kind: runtime?.kind || '',
|
|
2048
|
+
label: runtime?.label || '',
|
|
2049
|
+
commandName: whisperCmd ? basename(whisperCmd) : '',
|
|
2050
|
+
commandPath: whisperCmd || '',
|
|
2051
|
+
modelName: modelPath ? basename(modelPath) : '',
|
|
2052
|
+
modelPath: modelPath || '',
|
|
2053
|
+
ffmpegName: ffmpegPath ? basename(ffmpegPath) : '',
|
|
2054
|
+
ffmpegPath: ffmpegPath || '',
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
} catch {}
|
|
2058
|
+
whisperInstalled = binaryReady && modelReady && ffmpegReady;
|
|
2059
|
+
const ngrok = await checkCli('ngrok');
|
|
2060
|
+
const cliPayload = {
|
|
2061
|
+
whisper: { installed: whisperInstalled, binary: binaryReady, model: modelReady, ffmpeg: ffmpegReady },
|
|
2062
|
+
ngrok,
|
|
2063
|
+
};
|
|
2064
|
+
if (voiceMeta) cliPayload.voice = voiceMeta;
|
|
2065
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2066
|
+
res.end(JSON.stringify(cliPayload));
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// ============================================================
|
|
2071
|
+
// AGENT MODULE ROUTES
|
|
2072
|
+
// ============================================================
|
|
2073
|
+
|
|
2074
|
+
if (req.method === 'GET' && path === '/agent/config') {
|
|
2075
|
+
const config = readAgentConfig();
|
|
2076
|
+
const auth = await detectAuth(config);
|
|
2077
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2078
|
+
res.end(JSON.stringify({ config, auth }));
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
if (req.method === 'POST' && path === '/agent/config') {
|
|
2083
|
+
if (!isAllowedOrigin(req)) {
|
|
2084
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2085
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
const data = await readBody(req);
|
|
2089
|
+
const secrets = {};
|
|
2090
|
+
try {
|
|
2091
|
+
// RMW inside the config lock (see /config) so concurrent agent saves
|
|
2092
|
+
// serialize through updateSection instead of racing a read→merge→write.
|
|
2093
|
+
let merged;
|
|
2094
|
+
updateSection('agent', (current) => {
|
|
2095
|
+
merged = mergeAgentConfig(current, data, secrets);
|
|
2096
|
+
return merged;
|
|
2097
|
+
});
|
|
2098
|
+
if (data?.providers && typeof data.providers === 'object') dropRuntimeModelCaches();
|
|
2099
|
+
console.log(` Config saved: agent secrets=${JSON.stringify(secrets)}`);
|
|
2100
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2101
|
+
res.end(JSON.stringify({ ok: true, secrets, secretStatus: fullSecretStatus() }));
|
|
2102
|
+
} catch (e) {
|
|
2103
|
+
process.stderr.write('[setup] /agent/config failed: ' + (e?.stack || e?.message || String(e)) + '\n');
|
|
2104
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2105
|
+
res.end(JSON.stringify({ ok: false, error: e?.message || String(e), secrets }));
|
|
2106
|
+
}
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// Grok CLI OAuth login ("Grok Build"). An existing `grok` CLI login under
|
|
2111
|
+
// ~/.grok/auth.json is auto-detected; this route is for signing in directly
|
|
2112
|
+
// from Setup when no CLI login exists. Blocking: waits (up to 5 min) for the
|
|
2113
|
+
// loopback callback on 127.0.0.1:56121, then persists to the own token store.
|
|
2114
|
+
if (req.method === 'POST' && path === '/agent/grok-oauth/login') {
|
|
2115
|
+
if (!isAllowedOrigin(req)) {
|
|
2116
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2117
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
try {
|
|
2121
|
+
const tokens = await loginGrokOAuth();
|
|
2122
|
+
if (!tokens?.access_token) {
|
|
2123
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2124
|
+
res.end(JSON.stringify({ ok: false, error: 'login cancelled or timed out' }));
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
dropRuntimeModelCaches();
|
|
2128
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2129
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2130
|
+
} catch (e) {
|
|
2131
|
+
process.stderr.write('[setup] /agent/grok-oauth/login failed: ' + (e?.stack || e?.message || String(e)) + '\n');
|
|
2132
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2133
|
+
res.end(JSON.stringify({ ok: false, error: e?.message || String(e) }));
|
|
2134
|
+
}
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
if (req.method === 'GET' && path === '/agent/presets') {
|
|
2139
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2140
|
+
res.end(JSON.stringify({ presets: readAgentPresets() }));
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
if (req.method === 'POST' && path === '/agent/presets') {
|
|
2145
|
+
if (!isAllowedOrigin(req)) {
|
|
2146
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2147
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
const data = await readBody(req);
|
|
2151
|
+
let preset;
|
|
2152
|
+
try { preset = normalizePreset(data); }
|
|
2153
|
+
catch (err) {
|
|
2154
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2155
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
const list = readAgentPresets();
|
|
2159
|
+
const idx = list.findIndex(p => p.id === preset.id);
|
|
2160
|
+
if (idx >= 0) list[idx] = preset; else list.push(preset);
|
|
2161
|
+
writeAgentPresets(list);
|
|
2162
|
+
console.log(` Agent preset saved: ${preset.id}`);
|
|
2163
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2164
|
+
res.end(JSON.stringify({ ok: true, preset }));
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
if (req.method === 'DELETE' && path === '/agent/presets') {
|
|
2169
|
+
const id = url.searchParams.get('id');
|
|
2170
|
+
if (!id) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'id required' })); return; }
|
|
2171
|
+
const list = readAgentPresets().filter(p => p.id !== id);
|
|
2172
|
+
writeAgentPresets(list);
|
|
2173
|
+
console.log(` Agent preset deleted: ${id}`);
|
|
2174
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2175
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// -- Agent maintenance presets --
|
|
2180
|
+
if (req.method === 'GET' && path === '/agent/maintenance') {
|
|
2181
|
+
const cfg = readAgentConfig();
|
|
2182
|
+
const rawMaint = cfg.maintenance || {};
|
|
2183
|
+
// Strip legacy keys that no longer belong in maintenance
|
|
2184
|
+
// (classification/recap were retired with the cycle1 split;
|
|
2185
|
+
// scheduler/webhook keep their model per-entry).
|
|
2186
|
+
// Persist back when the stored config carried any of them so the Setup
|
|
2187
|
+
// panel and the runtime resolver stop having to dual-match name vs id.
|
|
2188
|
+
const allowedKeys = new Set([...Object.keys(DEFAULT_MAINTENANCE), ...MAINTENANCE_SLOTS]);
|
|
2189
|
+
const cleanMaint = {};
|
|
2190
|
+
let changed = false;
|
|
2191
|
+
for (const [k, v] of Object.entries(rawMaint)) {
|
|
2192
|
+
if (allowedKeys.has(k)) cleanMaint[k] = v;
|
|
2193
|
+
else changed = true;
|
|
2194
|
+
}
|
|
2195
|
+
if (changed) {
|
|
2196
|
+
cfg.maintenance = cleanMaint;
|
|
2197
|
+
try { writeAgentConfig(cfg); }
|
|
2198
|
+
catch (e) { process.stderr.write(`[setup] maintenance legacy-key cleanup write failed: ${e.message}\n`); }
|
|
2199
|
+
}
|
|
2200
|
+
const merged = { ...DEFAULT_MAINTENANCE, ...cleanMaint };
|
|
2201
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2202
|
+
res.end(JSON.stringify({ maintenance: merged, defaults: { ...DEFAULT_MAINTENANCE } }));
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
if (req.method === 'POST' && path === '/agent/maintenance') {
|
|
2207
|
+
if (!isAllowedOrigin(req)) {
|
|
2208
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2209
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
const data = await readBody(req);
|
|
2213
|
+
const cfg = readAgentConfig();
|
|
2214
|
+
const validIds = new Set([
|
|
2215
|
+
...(cfg.presets || []).map(p => p.id),
|
|
2216
|
+
...DEFAULT_PRESETS.map(p => p.id),
|
|
2217
|
+
]);
|
|
2218
|
+
const allowedKeys = new Set([...Object.keys(DEFAULT_MAINTENANCE), ...MAINTENANCE_SLOTS]);
|
|
2219
|
+
const unknownKeys = Object.keys(data).filter(k => !allowedKeys.has(k));
|
|
2220
|
+
if (unknownKeys.length) {
|
|
2221
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2222
|
+
res.end(JSON.stringify({ ok: false, error: `Unknown maintenance task(s): ${unknownKeys.join(', ')} (per-entry model required for scheduler/webhook)` }));
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
const invalid = Object.entries(data)
|
|
2226
|
+
.filter(([k, v]) => v && !validIds.has(v))
|
|
2227
|
+
.map(([k, v]) => `${k}: ${v}`);
|
|
2228
|
+
if (invalid.length) {
|
|
2229
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2230
|
+
res.end(JSON.stringify({ ok: false, error: `Unknown preset(s): ${invalid.join(', ')}` }));
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
const nextMaint = { ...(cfg.maintenance || {}) };
|
|
2234
|
+
for (const [k, v] of Object.entries(data)) {
|
|
2235
|
+
if (v == null || v === '') delete nextMaint[k]; // inherit → remove override
|
|
2236
|
+
else nextMaint[k] = v;
|
|
2237
|
+
}
|
|
2238
|
+
cfg.maintenance = nextMaint;
|
|
2239
|
+
writeAgentConfig(cfg);
|
|
2240
|
+
console.log(' Maintenance presets saved');
|
|
2241
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2242
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
if (req.method === 'GET' && path === '/agent/models') {
|
|
2247
|
+
const provider = url.searchParams.get('provider');
|
|
2248
|
+
if (!provider) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'provider required' })); return; }
|
|
2249
|
+
const cfg = readAgentConfig();
|
|
2250
|
+
const models = await listProviderModels(provider, cfg);
|
|
2251
|
+
if (provider === 'openai-oauth' && (!Array.isArray(models) || models.length === 0) && hasOpenAIOAuthCredentials()) {
|
|
2252
|
+
const detail = getOpenAIOAuthModelCatalogError();
|
|
2253
|
+
const error = detail
|
|
2254
|
+
? `OpenAI OAuth model catalog unavailable: ${detail}`
|
|
2255
|
+
: 'OpenAI OAuth model catalog unavailable. Run codex login, then reopen this setup page.';
|
|
2256
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2257
|
+
res.end(JSON.stringify({ ok: false, provider, models: [], error }));
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2261
|
+
res.end(JSON.stringify({ ok: true, provider, models }));
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
if (req.method === 'POST' && path === '/agent/validate') {
|
|
2266
|
+
if (!isAllowedOrigin(req)) {
|
|
2267
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2268
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
const data = await readBody(req);
|
|
2272
|
+
const validation = {};
|
|
2273
|
+
const checks = [];
|
|
2274
|
+
for (const [id, key] of Object.entries(data.keys || {})) {
|
|
2275
|
+
if (key) checks.push(validateAgentKey(id, key).then(r => { validation[id] = r; }));
|
|
2276
|
+
}
|
|
2277
|
+
await Promise.all(checks);
|
|
2278
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2279
|
+
res.end(JSON.stringify({ ok: true, validation }));
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// ============================================================
|
|
2284
|
+
// MEMORY MODULE ROUTES
|
|
2285
|
+
// ============================================================
|
|
2286
|
+
|
|
2287
|
+
if (req.method === 'GET' && path === '/memory/config') {
|
|
2288
|
+
const config = readMemoryConfig();
|
|
2289
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2290
|
+
res.end(JSON.stringify(config));
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
if (req.method === 'POST' && path === '/memory/config') {
|
|
2295
|
+
if (!isAllowedOrigin(req)) {
|
|
2296
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2297
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
const data = await readBody(req);
|
|
2301
|
+
const secrets = {};
|
|
2302
|
+
try {
|
|
2303
|
+
// RMW inside the config lock (see /config) so concurrent memory saves
|
|
2304
|
+
// serialize through updateSection instead of racing a read→merge→write.
|
|
2305
|
+
let merged;
|
|
2306
|
+
updateSection('memory', (current) => {
|
|
2307
|
+
merged = mergeMemoryConfig(current, data, secrets);
|
|
2308
|
+
return merged;
|
|
2309
|
+
});
|
|
2310
|
+
console.log(` Config saved: memory secrets=${JSON.stringify(secrets)}`);
|
|
2311
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2312
|
+
res.end(JSON.stringify({ ok: true, secrets, secretStatus: fullSecretStatus() }));
|
|
2313
|
+
} catch (e) {
|
|
2314
|
+
process.stderr.write('[setup] /memory/config failed: ' + (e?.stack || e?.message || String(e)) + '\n');
|
|
2315
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2316
|
+
res.end(JSON.stringify({ ok: false, error: e?.message || String(e), secrets }));
|
|
2317
|
+
}
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
if (req.method === 'GET' && path === '/memory/auth') {
|
|
2322
|
+
const cfg = readMemoryConfig();
|
|
2323
|
+
const result = await detectAuth(cfg);
|
|
2324
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2325
|
+
res.end(JSON.stringify(result));
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
if (req.method === 'GET' && path === '/memory/presets') {
|
|
2330
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2331
|
+
res.end(JSON.stringify({ presets: readMemoryPresets() }));
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
if (req.method === 'POST' && path === '/memory/presets') {
|
|
2336
|
+
if (!isAllowedOrigin(req)) {
|
|
2337
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2338
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
const data = await readBody(req);
|
|
2342
|
+
let preset;
|
|
2343
|
+
try { preset = normalizePreset(data); }
|
|
2344
|
+
catch (err) {
|
|
2345
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2346
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
const list = readMemoryPresets();
|
|
2350
|
+
const idx = list.findIndex(p => p.id === preset.id);
|
|
2351
|
+
if (idx >= 0) list[idx] = preset; else list.push(preset);
|
|
2352
|
+
writeMemoryPresets(list);
|
|
2353
|
+
console.log(` Memory preset saved: ${preset.id}`);
|
|
2354
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2355
|
+
res.end(JSON.stringify({ ok: true, preset }));
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
if (req.method === 'PUT' && path === '/memory/presets') {
|
|
2360
|
+
const data = await readBody(req);
|
|
2361
|
+
if (!Array.isArray(data.presets)) {
|
|
2362
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2363
|
+
res.end(JSON.stringify({ ok: false, error: 'presets array required' }));
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
const normalized = data.presets.map(p => normalizePreset(p));
|
|
2367
|
+
writeMemoryPresets(normalized);
|
|
2368
|
+
console.log(` Memory presets reordered: ${normalized.length} items`);
|
|
2369
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2370
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
if (req.method === 'DELETE' && path === '/memory/presets') {
|
|
2375
|
+
const id = url.searchParams.get('id');
|
|
2376
|
+
if (!id) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'id required' })); return; }
|
|
2377
|
+
const list = readMemoryPresets().filter(p => p.id !== id);
|
|
2378
|
+
writeMemoryPresets(list);
|
|
2379
|
+
console.log(` Memory preset deleted: ${id}`);
|
|
2380
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2381
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
if (req.method === 'GET' && path === '/memory/models') {
|
|
2386
|
+
const provider = url.searchParams.get('provider');
|
|
2387
|
+
if (!provider) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'provider required' })); return; }
|
|
2388
|
+
const cfg = readMemoryConfig();
|
|
2389
|
+
const models = await listProviderModels(provider, cfg);
|
|
2390
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2391
|
+
res.end(JSON.stringify({ ok: true, provider, models }));
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
const MEMORY_FILE_WHITELIST = ['user.md', 'bot.md'];
|
|
2395
|
+
const HISTORY_DIR = join(DATA_DIR, 'history');
|
|
2396
|
+
|
|
2397
|
+
if (req.method === 'GET' && path === '/memory/files') {
|
|
2398
|
+
const result = {};
|
|
2399
|
+
for (const name of MEMORY_FILE_WHITELIST) {
|
|
2400
|
+
const filePath = join(HISTORY_DIR, name);
|
|
2401
|
+
try { result[name] = readFileSync(filePath, 'utf8'); }
|
|
2402
|
+
catch { result[name] = ''; }
|
|
2403
|
+
}
|
|
2404
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2405
|
+
res.end(JSON.stringify(result));
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
if (req.method === 'POST' && path === '/memory/files') {
|
|
2410
|
+
const data = await readBody(req);
|
|
2411
|
+
const keys = Object.keys(data);
|
|
2412
|
+
for (const key of keys) {
|
|
2413
|
+
if (!MEMORY_FILE_WHITELIST.includes(key)) {
|
|
2414
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2415
|
+
res.end(JSON.stringify({ ok: false, error: `disallowed file name: ${key}` }));
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
mkdirSync(HISTORY_DIR, { recursive: true });
|
|
2420
|
+
for (const name of MEMORY_FILE_WHITELIST) {
|
|
2421
|
+
if (!(name in data)) continue;
|
|
2422
|
+
const filePath = join(HISTORY_DIR, name);
|
|
2423
|
+
const tmp = filePath + '.tmp';
|
|
2424
|
+
writeFileSync(tmp, String(data[name]), 'utf8');
|
|
2425
|
+
renameSync(tmp, filePath);
|
|
2426
|
+
}
|
|
2427
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2428
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
{
|
|
2433
|
+
const fileNameMatch = path.match(/^\/memory\/file\/([^/]+)$/);
|
|
2434
|
+
if (req.method === 'GET' && fileNameMatch) {
|
|
2435
|
+
const name = fileNameMatch[1];
|
|
2436
|
+
if (!MEMORY_FILE_WHITELIST.includes(name)) {
|
|
2437
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2438
|
+
res.end(JSON.stringify({ ok: false, error: `disallowed file name: ${name}` }));
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
const filePath = join(HISTORY_DIR, name);
|
|
2442
|
+
if (!existsSync(filePath)) {
|
|
2443
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2444
|
+
res.end(JSON.stringify({ ok: false, error: 'not found' }));
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
2448
|
+
res.end(readFileSync(filePath, 'utf8'));
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
if (req.method === 'GET' && path === '/api/memory/entries/active') {
|
|
2454
|
+
try {
|
|
2455
|
+
const r = await memoryServiceCall('GET', '/admin/entries/active', null, 30000);
|
|
2456
|
+
res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
|
|
2457
|
+
res.end(JSON.stringify(r.body));
|
|
2458
|
+
} catch (e) {
|
|
2459
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2460
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
2461
|
+
}
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
if (req.method === 'GET' && path === '/api/memory/core') {
|
|
2466
|
+
try {
|
|
2467
|
+
const r = await memoryServiceCall('GET', '/admin/core/entries', null, 30000);
|
|
2468
|
+
res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
|
|
2469
|
+
res.end(JSON.stringify(r.body));
|
|
2470
|
+
} catch (e) {
|
|
2471
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2472
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
2473
|
+
}
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
if (req.method === 'POST' && path === '/api/memory/core') {
|
|
2478
|
+
if (!isAllowedOrigin(req)) {
|
|
2479
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2480
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
const data = await readBody(req);
|
|
2484
|
+
try {
|
|
2485
|
+
const r = await memoryServiceCall('POST', '/admin/core/entries', {
|
|
2486
|
+
element: data.element,
|
|
2487
|
+
summary: data.summary,
|
|
2488
|
+
category: data.category,
|
|
2489
|
+
project_id: data.project_id,
|
|
2490
|
+
}, 30000);
|
|
2491
|
+
if (r.body?.ok) console.log(` Core memory saved: ${r.body.item?.id ?? '?'}`);
|
|
2492
|
+
res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
|
|
2493
|
+
res.end(JSON.stringify(r.body));
|
|
2494
|
+
} catch (e) {
|
|
2495
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2496
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
2497
|
+
}
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
{
|
|
2502
|
+
const coreDeleteMatch = req.method === 'POST' && path.match(/^\/api\/memory\/core\/(\d+)\/delete$/);
|
|
2503
|
+
if (coreDeleteMatch && !isAllowedOrigin(req)) {
|
|
2504
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2505
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
if (coreDeleteMatch) {
|
|
2509
|
+
const id = Number(coreDeleteMatch[1]);
|
|
2510
|
+
try {
|
|
2511
|
+
const r = await memoryServiceCall('POST', '/admin/core/entries/delete', { id }, 30000);
|
|
2512
|
+
console.log(` Core memory #${id} deleted`);
|
|
2513
|
+
res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
|
|
2514
|
+
res.end(JSON.stringify(r.body));
|
|
2515
|
+
} catch (e) {
|
|
2516
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2517
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
2518
|
+
}
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
{
|
|
2524
|
+
const statusMatch = req.method === 'POST' && path.match(/^\/api\/memory\/entries\/(\d+)\/status$/);
|
|
2525
|
+
if (statusMatch && !isAllowedOrigin(req)) {
|
|
2526
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2527
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
if (statusMatch) {
|
|
2531
|
+
const id = Number(statusMatch[1]);
|
|
2532
|
+
const data = await readBody(req);
|
|
2533
|
+
const VALID = ['pending', 'active', 'archived'];
|
|
2534
|
+
const status = String(data.status ?? '').trim().toLowerCase();
|
|
2535
|
+
if (!Number.isInteger(id) || id <= 0 || !VALID.includes(status)) {
|
|
2536
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2537
|
+
res.end(JSON.stringify({ ok: false, error: 'valid id and status required' }));
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
try {
|
|
2541
|
+
const r = await memoryServiceCall('POST', '/admin/entries/status', { id, status }, 30000);
|
|
2542
|
+
console.log(` Entry #${id} → ${status} (changes=${r.body?.changes ?? '?'})`);
|
|
2543
|
+
res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
|
|
2544
|
+
res.end(JSON.stringify(r.body));
|
|
2545
|
+
} catch (e) {
|
|
2546
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2547
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
2548
|
+
}
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
if (req.method === 'POST' && path === '/api/memory/entries') {
|
|
2554
|
+
if (!isAllowedOrigin(req)) {
|
|
2555
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2556
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
const data = await readBody(req);
|
|
2560
|
+
try {
|
|
2561
|
+
const r = await memoryServiceCall('POST', '/admin/entries/add', {
|
|
2562
|
+
element: data.element,
|
|
2563
|
+
summary: data.summary,
|
|
2564
|
+
category: data.category,
|
|
2565
|
+
}, 30000);
|
|
2566
|
+
if (r.body?.ok) console.log(` Remembered entry #${r.body.id}: ${r.body.text || ''}`);
|
|
2567
|
+
res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
|
|
2568
|
+
res.end(JSON.stringify(r.body));
|
|
2569
|
+
} catch (e) {
|
|
2570
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2571
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
2572
|
+
}
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
if (req.method === 'POST' && path === '/memory/backfill') {
|
|
2577
|
+
if (!isAllowedOrigin(req)) {
|
|
2578
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2579
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
const data = await readBody(req);
|
|
2583
|
+
const requestedWindow = data.window || '7d';
|
|
2584
|
+
try {
|
|
2585
|
+
console.log(`[backfill] start window=${requestedWindow}`);
|
|
2586
|
+
// Backfill iterates transcripts and runs cycle1/cycle2 — long, no fixed
|
|
2587
|
+
// upper bound. Pass a generous timeout (1h) and let memory-service's
|
|
2588
|
+
// _cycle1InFlight guard serialise overlapping requests.
|
|
2589
|
+
const r = await memoryServiceCall('POST', '/admin/backfill', {
|
|
2590
|
+
window: requestedWindow,
|
|
2591
|
+
scope: 'all',
|
|
2592
|
+
}, 3_600_000);
|
|
2593
|
+
console.log(`[backfill] ${r.body?.text || JSON.stringify(r.body)}`);
|
|
2594
|
+
res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
|
|
2595
|
+
res.end(JSON.stringify(r.body));
|
|
2596
|
+
} catch (err) {
|
|
2597
|
+
console.error(`[backfill] failed: ${err.message}`);
|
|
2598
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2599
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
2600
|
+
}
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
if (req.method === 'POST' && path === '/memory/delete') {
|
|
2605
|
+
if (!isAllowedOrigin(req)) {
|
|
2606
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2607
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
const data = await readBody(req);
|
|
2611
|
+
try {
|
|
2612
|
+
const r = await memoryServiceCall('POST', '/admin/purge', {
|
|
2613
|
+
confirm: data?.confirm,
|
|
2614
|
+
}, 60000);
|
|
2615
|
+
res.writeHead(r.statusCode, { 'Content-Type': 'application/json' });
|
|
2616
|
+
res.end(JSON.stringify(r.body));
|
|
2617
|
+
} catch (err) {
|
|
2618
|
+
console.error(`[memory delete] failed: ${err.message}`);
|
|
2619
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2620
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
2621
|
+
}
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
if (req.method === 'POST' && path === '/memory/validate') {
|
|
2626
|
+
if (!isAllowedOrigin(req)) {
|
|
2627
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2628
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
const data = await readBody(req);
|
|
2632
|
+
const validation = {};
|
|
2633
|
+
const checks = [];
|
|
2634
|
+
for (const [id, key] of Object.entries(data.keys || {})) {
|
|
2635
|
+
if (key) checks.push(validateAgentKey(id, key).then(r => { validation[id] = r; }));
|
|
2636
|
+
}
|
|
2637
|
+
await Promise.all(checks);
|
|
2638
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2639
|
+
res.end(JSON.stringify({ ok: true, validation }));
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// ============================================================
|
|
2644
|
+
// SEARCH MODULE ROUTES
|
|
2645
|
+
// ============================================================
|
|
2646
|
+
|
|
2647
|
+
if (req.method === 'GET' && path === '/search/config') {
|
|
2648
|
+
const config = readSearchConfig();
|
|
2649
|
+
if (config.rawSearch && config.rawSearch.credentials) {
|
|
2650
|
+
for (const [id, cred] of Object.entries(config.rawSearch.credentials)) {
|
|
2651
|
+
const secret = getSearchApiKey(id);
|
|
2652
|
+
if (secret) {
|
|
2653
|
+
config.rawSearch.credentials[id] = { ...(cred || {}), apiKey: secret };
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
config.availableProviders = computeAvailableProviders();
|
|
2658
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2659
|
+
res.end(JSON.stringify(config));
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
if (req.method === 'POST' && path === '/search/config') {
|
|
2664
|
+
if (!isAllowedOrigin(req)) {
|
|
2665
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2666
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
const data = await readBody(req);
|
|
2670
|
+
const secrets = {};
|
|
2671
|
+
try {
|
|
2672
|
+
// RMW inside the config lock (see /config) so concurrent search saves
|
|
2673
|
+
// serialize through updateSection instead of racing a read→merge→write.
|
|
2674
|
+
let merged;
|
|
2675
|
+
updateSection('search', (current) => {
|
|
2676
|
+
merged = mergeSearchConfig(current, data, secrets);
|
|
2677
|
+
return merged;
|
|
2678
|
+
});
|
|
2679
|
+
console.log(` Config saved: search secrets=${JSON.stringify(secrets)}`);
|
|
2680
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2681
|
+
res.end(JSON.stringify({ ok: true, secrets, secretStatus: fullSecretStatus() }));
|
|
2682
|
+
} catch (e) {
|
|
2683
|
+
process.stderr.write('[setup] /search/config failed: ' + (e?.stack || e?.message || String(e)) + '\n');
|
|
2684
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2685
|
+
res.end(JSON.stringify({ ok: false, error: e?.message || String(e), secrets }));
|
|
2686
|
+
}
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
if (req.method === 'POST' && path === '/search/validate') {
|
|
2691
|
+
if (!isAllowedOrigin(req)) {
|
|
2692
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2693
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
const data = await readBody(req);
|
|
2697
|
+
const validation = {};
|
|
2698
|
+
const checks = [];
|
|
2699
|
+
for (const [id, val] of Object.entries(data.searchProviders || {})) {
|
|
2700
|
+
const key = typeof val === 'object' ? val.key : val;
|
|
2701
|
+
if (key) checks.push(validateSearchKey(id, key).then(r => { validation[id] = r; }));
|
|
2702
|
+
}
|
|
2703
|
+
for (const [id, val] of Object.entries(data.aiProviders || {})) {
|
|
2704
|
+
if (val && val !== 'cli') checks.push(validateSearchKey(id, val).then(r => { validation[id] = r; }));
|
|
2705
|
+
}
|
|
2706
|
+
await Promise.all(checks);
|
|
2707
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2708
|
+
res.end(JSON.stringify({ ok: true, validation }));
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
if (req.method === 'GET' && path === '/search/cli-check') {
|
|
2713
|
+
// Previously three serial `execSync(`${cmd} --version`)` calls — each
|
|
2714
|
+
// wrapped by Node in cmd.exe which flashed a conhost window even with
|
|
2715
|
+
// windowsHide:true, and each blocking the server thread for up to 5s
|
|
2716
|
+
// (15s worst case if all three CLIs are missing). Switch to non-shell
|
|
2717
|
+
// existence-only checks via `where.exe` / `which`, in parallel: no
|
|
2718
|
+
// cmd.exe wrapper → no flash, and the request returns in one round-trip.
|
|
2719
|
+
const check = (cmd) => new Promise(resolve => {
|
|
2720
|
+
const tool = isWin ? 'where.exe' : 'which';
|
|
2721
|
+
const child = spawn(tool, [cmd], {
|
|
2722
|
+
windowsHide: true,
|
|
2723
|
+
stdio: 'ignore',
|
|
2724
|
+
shell: false,
|
|
2725
|
+
});
|
|
2726
|
+
child.once('error', () => resolve(false));
|
|
2727
|
+
child.once('close', code => resolve(code === 0));
|
|
2728
|
+
});
|
|
2729
|
+
const [codex, claude, gemini] = await Promise.all([
|
|
2730
|
+
check('codex'), check('claude'), check('gemini'),
|
|
2731
|
+
]);
|
|
2732
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2733
|
+
res.end(JSON.stringify({ codex, claude, gemini }));
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
// ============================================================
|
|
2738
|
+
// CHANNELS MODULE ROUTES (continued)
|
|
2739
|
+
// ============================================================
|
|
2740
|
+
|
|
2741
|
+
if (req.method === 'POST' && path === '/install') {
|
|
2742
|
+
if (!isAllowedOrigin(req)) {
|
|
2743
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2744
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
const data = await readBody(req);
|
|
2748
|
+
const tool = data.tool;
|
|
2749
|
+
if (!tool || !['ngrok'].includes(tool)) {
|
|
2750
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2751
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid tool' }));
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
try {
|
|
2755
|
+
const { stdout } = await new Promise((resolve, reject) => {
|
|
2756
|
+
exec('npm install -g ngrok', { timeout: 120000, windowsHide: true }, (err, stdout, stderr) => {
|
|
2757
|
+
if (err) reject(err);
|
|
2758
|
+
else resolve({ stdout, stderr });
|
|
2759
|
+
});
|
|
2760
|
+
});
|
|
2761
|
+
console.log(` Installed ${tool}`);
|
|
2762
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2763
|
+
res.end(JSON.stringify({ ok: true, tool, output: stdout.trim() }));
|
|
2764
|
+
} catch (e) {
|
|
2765
|
+
console.log(` Install ${tool} failed: ${e.message}`);
|
|
2766
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2767
|
+
res.end(JSON.stringify({ ok: false, tool, error: e.message }));
|
|
2768
|
+
}
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
// POST /install/voice-runtime — single-shot voice install: binary + model.
|
|
2773
|
+
// Sequentially fetches the platform-matched whisper.cpp runtime and the
|
|
2774
|
+
// large-v3-turbo model from the managed manifest. Both are idempotent: if
|
|
2775
|
+
// the cached binary exists and the model's sha256 matches, the call returns
|
|
2776
|
+
// without re-downloading. The endpoint completes only after both are ready.
|
|
2777
|
+
if (req.method === 'POST' && path === '/install/voice-runtime') {
|
|
2778
|
+
if (!isAllowedOrigin(req)) {
|
|
2779
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2780
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2781
|
+
return;
|
|
2782
|
+
}
|
|
2783
|
+
res.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' });
|
|
2784
|
+
const send = (obj) => { try { res.write(JSON.stringify(obj) + '\n'); } catch {} };
|
|
2785
|
+
try {
|
|
2786
|
+
const { ensureWhisperRuntime, ensureWhisperModel, ensureFfmpegRuntime } = await import(new URL('../src/channels/lib/voice-runtime-fetcher.mjs', import.meta.url).href);
|
|
2787
|
+
const onProgress = (p) => send({ type: 'progress', ...p });
|
|
2788
|
+
const runtime = await ensureWhisperRuntime(DATA_DIR, onProgress);
|
|
2789
|
+
const model = await ensureWhisperModel(DATA_DIR, onProgress);
|
|
2790
|
+
const ffmpeg = await ensureFfmpegRuntime(DATA_DIR, onProgress);
|
|
2791
|
+
send({ type: 'done', ok: true, runtime, model, ffmpeg });
|
|
2792
|
+
} catch (e) {
|
|
2793
|
+
send({ type: 'error', ok: false, error: e?.message || String(e) });
|
|
2794
|
+
} finally {
|
|
2795
|
+
res.end();
|
|
2796
|
+
}
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
if (req.method === 'GET' && path === '/general/config') {
|
|
2801
|
+
const config = readConfig();
|
|
2802
|
+
const pi = (config && typeof config.promptInjection === 'object' && config.promptInjection) || {};
|
|
2803
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2804
|
+
res.end(JSON.stringify({
|
|
2805
|
+
promptInjection: {
|
|
2806
|
+
mode: pi.mode === 'hook' ? 'hook' : 'claude_md',
|
|
2807
|
+
targetPath: typeof pi.targetPath === 'string' && pi.targetPath ? pi.targetPath : '~/.claude/CLAUDE.md',
|
|
2808
|
+
},
|
|
2809
|
+
}));
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
if (req.method === 'POST' && path === '/general/save') {
|
|
2814
|
+
if (!isAllowedOrigin(req)) {
|
|
2815
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2816
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
const data = await readBody(req);
|
|
2820
|
+
const existing = readConfig();
|
|
2821
|
+
const next = { ...existing };
|
|
2822
|
+
const prev = (existing && typeof existing.promptInjection === 'object' && existing.promptInjection) || {};
|
|
2823
|
+
const merged = { ...prev };
|
|
2824
|
+
if (data && (data.mode === 'hook' || data.mode === 'claude_md')) {
|
|
2825
|
+
merged.mode = data.mode;
|
|
2826
|
+
}
|
|
2827
|
+
if (data && typeof data.targetPath === 'string' && data.targetPath.trim()) {
|
|
2828
|
+
merged.targetPath = data.targetPath.trim();
|
|
2829
|
+
}
|
|
2830
|
+
if (!merged.mode) merged.mode = 'claude_md';
|
|
2831
|
+
if (!merged.targetPath) merged.targetPath = '~/.claude/CLAUDE.md';
|
|
2832
|
+
next.promptInjection = merged;
|
|
2833
|
+
writeConfig(next);
|
|
2834
|
+
console.log(' Config saved: general/promptInjection');
|
|
2835
|
+
// Update CLAUDE.md managed block when mode is claude_md
|
|
2836
|
+
let claudeMdResult = null;
|
|
2837
|
+
if (merged.mode === 'claude_md') {
|
|
2838
|
+
claudeMdResult = { ok: false, error: 'generateClaudeMdBlock not available' };
|
|
2839
|
+
}
|
|
2840
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2841
|
+
res.end(JSON.stringify({ ok: true, promptInjection: merged, claudeMd: claudeMdResult }));
|
|
2842
|
+
return;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
// ============================================================
|
|
2846
|
+
// MD LIBRARY ROUTES — Project MD + per-role MD (Common MD moved to
|
|
2847
|
+
// plugin rules/agent.md and is no longer user-editable).
|
|
2848
|
+
// ============================================================
|
|
2849
|
+
|
|
2850
|
+
if (req.method === 'GET' && path === '/md/project') {
|
|
2851
|
+
const indexPath = join(getPluginData(), 'project-md-index.json');
|
|
2852
|
+
let registry = { paths: [] };
|
|
2853
|
+
try { registry = JSON.parse(readFileSync(indexPath, 'utf8')); } catch {}
|
|
2854
|
+
const items = [];
|
|
2855
|
+
for (const cwd of registry.paths || []) {
|
|
2856
|
+
let content = '';
|
|
2857
|
+
try { content = readFileSync(join(cwd, 'PROJECT.md'), 'utf8'); } catch {}
|
|
2858
|
+
items.push({ path: cwd, content });
|
|
2859
|
+
}
|
|
2860
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2861
|
+
res.end(JSON.stringify({ items }));
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
if (req.method === 'POST' && path === '/md/project') {
|
|
2866
|
+
if (!isAllowedOrigin(req)) {
|
|
2867
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2868
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
const body = await readBody(req);
|
|
2872
|
+
const cwd = String(body?.path || '').trim();
|
|
2873
|
+
const content = String(body?.content ?? '');
|
|
2874
|
+
if (!cwd) {
|
|
2875
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2876
|
+
res.end(JSON.stringify({ ok: false, error: 'path required' }));
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
try {
|
|
2880
|
+
mkdirSync(cwd, { recursive: true });
|
|
2881
|
+
const _pTmp = join(cwd, 'PROJECT.md.tmp');
|
|
2882
|
+
writeFileSync(_pTmp, content, 'utf8');
|
|
2883
|
+
renameSync(_pTmp, join(cwd, 'PROJECT.md'));
|
|
2884
|
+
} catch (err) {
|
|
2885
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2886
|
+
res.end(JSON.stringify({ ok: false, error: `Cannot write PROJECT.md: ${err.message}` }));
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
// Update registry
|
|
2890
|
+
const indexPath = join(getPluginData(), 'project-md-index.json');
|
|
2891
|
+
let registry = { paths: [] };
|
|
2892
|
+
try { registry = JSON.parse(readFileSync(indexPath, 'utf8')); } catch {}
|
|
2893
|
+
if (!registry.paths.includes(cwd)) registry.paths.push(cwd);
|
|
2894
|
+
mkdirSync(dirname(indexPath), { recursive: true });
|
|
2895
|
+
// Intentional full replace of project-md-index.json (paths registry only).
|
|
2896
|
+
try {
|
|
2897
|
+
const _regTmp = indexPath + '.tmp';
|
|
2898
|
+
writeFileSync(_regTmp, JSON.stringify(registry, null, 2), 'utf8');
|
|
2899
|
+
renameSync(_regTmp, indexPath);
|
|
2900
|
+
} catch (err) {
|
|
2901
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2902
|
+
res.end(JSON.stringify({ ok: false, error: `Cannot write registry: ${err.message}` }));
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
console.log(` Config saved: project MD (${cwd})`);
|
|
2906
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2907
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
if (req.method === 'DELETE' && path === '/md/project') {
|
|
2912
|
+
const qs = new URL(req.url, 'http://x').searchParams;
|
|
2913
|
+
const cwd = String(qs.get('path') || '').trim();
|
|
2914
|
+
if (!cwd) {
|
|
2915
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2916
|
+
res.end(JSON.stringify({ ok: false, error: 'path required' }));
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
const indexPath = join(getPluginData(), 'project-md-index.json');
|
|
2920
|
+
let registry = { paths: [] };
|
|
2921
|
+
try { registry = JSON.parse(readFileSync(indexPath, 'utf8')); } catch {}
|
|
2922
|
+
registry.paths = (registry.paths || []).filter(p => p !== cwd);
|
|
2923
|
+
mkdirSync(dirname(indexPath), { recursive: true });
|
|
2924
|
+
// Intentional full replace of project-md-index.json (paths registry only).
|
|
2925
|
+
try {
|
|
2926
|
+
const _regTmp = indexPath + '.tmp';
|
|
2927
|
+
writeFileSync(_regTmp, JSON.stringify(registry, null, 2), 'utf8');
|
|
2928
|
+
renameSync(_regTmp, indexPath);
|
|
2929
|
+
} catch (err) {
|
|
2930
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2931
|
+
res.end(JSON.stringify({ ok: false, error: `Cannot write registry: ${err.message}` }));
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
console.log(` Config removed from registry: ${cwd} (PROJECT.md file kept)`);
|
|
2935
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2936
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
// ROLE MD ROUTES (Phase B §4) — UI-managed agent role files.
|
|
2941
|
+
// Each role lives at <data>/roles/<name>.md with frontmatter
|
|
2942
|
+
// (name, description, permission) + optional body. Permission is one of
|
|
2943
|
+
// "read" | "read-write" | "mcp".
|
|
2944
|
+
|
|
2945
|
+
if (req.method === 'GET' && path === '/md/role') {
|
|
2946
|
+
const rolesDir = join(getPluginData(), 'roles');
|
|
2947
|
+
const items = [];
|
|
2948
|
+
try {
|
|
2949
|
+
mkdirSync(rolesDir, { recursive: true });
|
|
2950
|
+
const files = (await import('fs')).readdirSync(rolesDir).filter(f => f.endsWith('.md'));
|
|
2951
|
+
for (const f of files) {
|
|
2952
|
+
const name = f.replace(/\.md$/, '');
|
|
2953
|
+
let raw = '';
|
|
2954
|
+
try { raw = readFileSync(join(rolesDir, f), 'utf8'); } catch {}
|
|
2955
|
+
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n*/);
|
|
2956
|
+
const fm = fmMatch ? fmMatch[1] : '';
|
|
2957
|
+
const body = fmMatch ? raw.slice(fmMatch[0].length).trim() : raw.trim();
|
|
2958
|
+
const description = (fm.match(/^description:\s*["']?(.+?)["']?\s*$/m)?.[1] || '').trim();
|
|
2959
|
+
const permission = (fm.match(/^permission:\s*["']?(.+?)["']?\s*$/m)?.[1] || '').trim().toLowerCase();
|
|
2960
|
+
items.push({ name, description, permission, body });
|
|
2961
|
+
}
|
|
2962
|
+
} catch {}
|
|
2963
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2964
|
+
res.end(JSON.stringify({ items }));
|
|
2965
|
+
return;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
if (req.method === 'POST' && path === '/md/role') {
|
|
2969
|
+
if (!isAllowedOrigin(req)) {
|
|
2970
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2971
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
const body = await readBody(req);
|
|
2975
|
+
const name = String(body?.name || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
2976
|
+
const description = String(body?.description ?? '').trim();
|
|
2977
|
+
const permission = String(body?.permission ?? '').trim().toLowerCase();
|
|
2978
|
+
const note = String(body?.body ?? '').trim();
|
|
2979
|
+
if (!name) {
|
|
2980
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2981
|
+
res.end(JSON.stringify({ ok: false, error: 'name required' }));
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
if (permission && permission !== 'read' && permission !== 'read-write' && permission !== 'mcp') {
|
|
2985
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2986
|
+
res.end(JSON.stringify({ ok: false, error: 'permission must be "read", "read-write", or "mcp"' }));
|
|
2987
|
+
return;
|
|
2988
|
+
}
|
|
2989
|
+
const fmLines = [`name: ${name}`];
|
|
2990
|
+
if (description) fmLines.push(`description: ${description.replace(/\n/g, ' ')}`);
|
|
2991
|
+
if (permission) fmLines.push(`permission: ${permission}`);
|
|
2992
|
+
const content = `---\n${fmLines.join('\n')}\n---\n${note ? `\n${note}\n` : ''}`;
|
|
2993
|
+
const p = join(getPluginData(), 'roles', `${name}.md`);
|
|
2994
|
+
// Intentional full replace: POST /md/role owns the entire role markdown file.
|
|
2995
|
+
try {
|
|
2996
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
2997
|
+
const _rTmp = p + '.tmp';
|
|
2998
|
+
writeFileSync(_rTmp, content, 'utf8');
|
|
2999
|
+
renameSync(_rTmp, p);
|
|
3000
|
+
} catch (err) {
|
|
3001
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
3002
|
+
res.end(JSON.stringify({ ok: false, error: 'Cannot write role: ' + err.message }));
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
console.log(` Config saved: role MD (${name})`);
|
|
3006
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3007
|
+
res.end(JSON.stringify({ ok: true }));
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
if (req.method === 'DELETE' && path === '/md/role') {
|
|
3012
|
+
const qs = new URL(req.url, 'http://x').searchParams;
|
|
3013
|
+
const name = String(qs.get('name') || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
3014
|
+
if (!name) {
|
|
3015
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3016
|
+
res.end(JSON.stringify({ ok: false, error: 'name required' }));
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
const p = join(getPluginData(), 'roles', `${name}.md`);
|
|
3020
|
+
try { (await import('fs')).unlinkSync(p); } catch (err) {
|
|
3021
|
+
if (err.code !== 'ENOENT') {
|
|
3022
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
3023
|
+
res.end(JSON.stringify({ ok: false, error: 'Cannot delete role: ' + err.message }));
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
console.log(` Config removed: role MD (${name})`);
|
|
3028
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3029
|
+
res.end(JSON.stringify({ ok: true }));
|
|
3030
|
+
return;
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
// ============================================================
|
|
3034
|
+
// WORKFLOW MODULE ROUTES
|
|
3035
|
+
// ============================================================
|
|
3036
|
+
|
|
3037
|
+
if (req.method === 'GET' && path === '/workflow/load') {
|
|
3038
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3039
|
+
res.end(JSON.stringify(readUserWorkflow()));
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
if (req.method === 'POST' && path === '/workflow/save') {
|
|
3044
|
+
if (!isAllowedOrigin(req)) {
|
|
3045
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
3046
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
const data = await readBody(req);
|
|
3050
|
+
writeUserWorkflow(data);
|
|
3051
|
+
console.log(' Config saved: user-workflow');
|
|
3052
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3053
|
+
res.end(JSON.stringify({ ok: true }));
|
|
3054
|
+
return;
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
if (req.method === 'GET' && path === '/workflow/md') {
|
|
3058
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
3059
|
+
res.end(readUserWorkflowMd());
|
|
3060
|
+
return;
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
if (req.method === 'POST' && path === '/workflow/md') {
|
|
3064
|
+
if (!isAllowedOrigin(req)) {
|
|
3065
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
3066
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
let body = '';
|
|
3070
|
+
await new Promise((resolve, reject) => {
|
|
3071
|
+
req.on('data', c => { body += c; });
|
|
3072
|
+
req.on('end', resolve);
|
|
3073
|
+
req.on('error', reject);
|
|
3074
|
+
});
|
|
3075
|
+
writeUserWorkflowMd(body);
|
|
3076
|
+
console.log(' Config saved: user-workflow.md');
|
|
3077
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3078
|
+
res.end(JSON.stringify({ ok: true }));
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
if (req.method === 'GET' && path === '/workflow/file') {
|
|
3083
|
+
if (!existsSync(USER_WORKFLOW_MD_PATH)) {
|
|
3084
|
+
if (!shouldSeedMissingUserData(DATA_DIR, 'user-workflow.md')) {
|
|
3085
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
3086
|
+
res.end(JSON.stringify({ ok: false, error: 'user-workflow.md missing; restore from backup or intentionally reset user data' }));
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
mkdirSync(dirname(USER_WORKFLOW_MD_PATH), { recursive: true });
|
|
3090
|
+
writeFileSync(USER_WORKFLOW_MD_PATH, DEFAULT_USER_WORKFLOW_MD, 'utf8');
|
|
3091
|
+
markUserDataInitialized(DATA_DIR);
|
|
3092
|
+
}
|
|
3093
|
+
if (isWin) { spawn('cmd', ['/c', 'start', '', USER_WORKFLOW_MD_PATH], { detached: true, stdio: 'ignore', windowsHide: true }).unref(); }
|
|
3094
|
+
else { spawn('open', [USER_WORKFLOW_MD_PATH], { detached: true, stdio: 'ignore' }).unref(); }
|
|
3095
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3096
|
+
res.end(JSON.stringify({ ok: true }));
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
if (path === '/close') {
|
|
3101
|
+
windowOpen = false;
|
|
3102
|
+
res.writeHead(200);
|
|
3103
|
+
res.end();
|
|
3104
|
+
console.log(' Window closed');
|
|
3105
|
+
return;
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
debugSetup(`[req-trace] ${req.method} ${path}`);
|
|
3109
|
+
if (path === '/open') {
|
|
3110
|
+
debugSetup(`[open-debug] /open request received`);
|
|
3111
|
+
const result = await openAppWindow();
|
|
3112
|
+
debugSetup(`[open-debug] /open result=${JSON.stringify(result)}`);
|
|
3113
|
+
if (!result.ok) {
|
|
3114
|
+
windowOpen = false;
|
|
3115
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
3116
|
+
res.end(JSON.stringify(result));
|
|
3117
|
+
return;
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
windowOpen = true;
|
|
3121
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3122
|
+
res.end(JSON.stringify(result));
|
|
3123
|
+
return;
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
if (req.method === 'GET' && path === '/generation') {
|
|
3127
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3128
|
+
res.end(JSON.stringify({ generation: openGeneration }));
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
res.writeHead(404);
|
|
3133
|
+
res.end('Not found');
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// Fire-and-forget warm-up of the model catalogs the Config UI will request.
|
|
3137
|
+
// Collects every provider referenced by the current agent config's presets,
|
|
3138
|
+
// then drives each through listProviderModels — the exact path /agent/models
|
|
3139
|
+
// serves — so the 24h runtime cache is populated by the time the UI asks.
|
|
3140
|
+
// Errors per provider are logged and swallowed; this never throws.
|
|
3141
|
+
function prefetchModelCatalogs() {
|
|
3142
|
+
// Config reads can rethrow non-ENOENT I/O failures (EACCES/EIO) through
|
|
3143
|
+
// readJsonFile; this runs unguarded inside the listen callback, so contain
|
|
3144
|
+
// them here — a failed prefetch must never take the server down.
|
|
3145
|
+
let cfg;
|
|
3146
|
+
const providers = new Set();
|
|
3147
|
+
try {
|
|
3148
|
+
cfg = readAgentConfig();
|
|
3149
|
+
for (const preset of readAgentPresets()) {
|
|
3150
|
+
const id = normalizeAgentProviderId(preset?.provider);
|
|
3151
|
+
if (id) providers.add(id);
|
|
3152
|
+
}
|
|
3153
|
+
} catch (err) {
|
|
3154
|
+
console.error(`[setup] model catalog prefetch skipped: ${err?.message || err}`);
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
for (const id of providers) {
|
|
3158
|
+
listProviderModels(id, cfg).catch(err => {
|
|
3159
|
+
console.error(`[setup] model catalog prefetch failed for ${id}: ${err?.message || err}`);
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
server.listen(PORT, '127.0.0.1', () => { // localhost-only — config UI holds secrets
|
|
3165
|
+
console.log(`\n MIXDOG CONFIG`);
|
|
3166
|
+
console.log(` http://localhost:${PORT}\n`);
|
|
3167
|
+
// Warm model catalogs in the background so the Config UI's provider
|
|
3168
|
+
// dropdowns fill without a cold network round-trip. Boot drops the runtime
|
|
3169
|
+
// caches (dropRuntimeModelCaches above), so the first /agent/models request
|
|
3170
|
+
// would otherwise pay full fetch latency while the user waits on a disabled
|
|
3171
|
+
// dropdown. Fire-and-forget through the same listProviderModels path the UI
|
|
3172
|
+
// uses; errors are logged and never thrown.
|
|
3173
|
+
prefetchModelCatalogs();
|
|
3174
|
+
if (process.env.MIXDOG_SETUP_OPEN_ON_START === '1') {
|
|
3175
|
+
openGeneration++;
|
|
3176
|
+
windowOpen = true;
|
|
3177
|
+
openAppWindow().then(result => {
|
|
3178
|
+
if (!result?.ok) {
|
|
3179
|
+
windowOpen = false;
|
|
3180
|
+
console.error(`[setup] openAppWindow failed: ${result?.error || JSON.stringify(result?.attempts)}`);
|
|
3181
|
+
}
|
|
3182
|
+
}).catch(err => {
|
|
3183
|
+
windowOpen = false;
|
|
3184
|
+
console.error(`[setup] openAppWindow threw: ${err?.message || err}`);
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
});
|
|
3188
|
+
|
|
3189
|
+
// Parent-PID watchdog: setup-server is launched detached/unref'd (see
|
|
3190
|
+
// setup/launch.mjs), so losing Claude Code does not reap it. Poll the
|
|
3191
|
+
// launcher's parent PID (the Claude Code CLI) and exit when it dies. This is
|
|
3192
|
+
// the detached-process equivalent of the run-mcp.mjs stdin-close pattern
|
|
3193
|
+
// applied to memory/channels workers in v0.6.0.
|
|
3194
|
+
(() => {
|
|
3195
|
+
const parentPid = parseInt(process.env.MIXDOG_SETUP_PARENT_PID || '', 10);
|
|
3196
|
+
if (!Number.isFinite(parentPid) || parentPid <= 0) return;
|
|
3197
|
+
const tick = () => {
|
|
3198
|
+
try {
|
|
3199
|
+
process.kill(parentPid, 0);
|
|
3200
|
+
} catch {
|
|
3201
|
+
process.exit(0);
|
|
3202
|
+
}
|
|
3203
|
+
};
|
|
3204
|
+
const timer = setInterval(tick, 5000);
|
|
3205
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
3206
|
+
})();
|