daemora 1.0.5 → 1.0.7

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/src/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import express from "express";
2
- import { mkdirSync, existsSync } from "fs";
2
+ import { mkdirSync, existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { randomBytes } from "crypto";
5
6
  import { toolFunctions } from "./tools/index.js";
6
7
  import { getSession, listSessions, createSession, clearSession } from "./services/sessions.js";
7
8
  import { config } from "./config/default.js";
@@ -54,9 +55,8 @@ if (config.cleanupAfterDays > 0) {
54
55
  }
55
56
  }
56
57
 
57
- // Initialize task system
58
+ // Initialize task system (TaskRunner starts after full init — see startup sequence below)
58
59
  taskQueue.init();
59
- taskRunner.start();
60
60
  supervisor.start();
61
61
  auditLog.start();
62
62
  scheduler.start();
@@ -82,9 +82,20 @@ if (process.env.OPENAI_COMPAT_ENABLED === "true") {
82
82
  }
83
83
 
84
84
  // --- Security middleware ---
85
+
86
+ // Security headers on all responses
87
+ app.use((req, res, next) => {
88
+ res.setHeader("X-Content-Type-Options", "nosniff");
89
+ res.setHeader("X-Frame-Options", "DENY");
90
+ res.setHeader("X-XSS-Protection", "1; mode=block");
91
+ res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
92
+ res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
93
+ next();
94
+ });
95
+
96
+ // Localhost-only: reject non-local IP addresses
85
97
  const localOnly = (req, res, next) => {
86
98
  const remoteAddress = req.socket.remoteAddress;
87
- // Support both IPv4 and IPv6 localhost
88
99
  if (remoteAddress === "127.0.0.1" || remoteAddress === "::ffff:127.0.0.1" || remoteAddress === "::1") {
89
100
  next();
90
101
  } else {
@@ -93,13 +104,86 @@ const localOnly = (req, res, next) => {
93
104
  }
94
105
  };
95
106
 
96
- // Apply local-only security to all API routes
107
+ // Origin validation: block DNS rebinding and cross-origin browser attacks.
108
+ // Browsers always send Origin on cross-origin requests. A malicious page on
109
+ // evil.com making fetch("http://localhost:8081/api/...") will have Origin: https://evil.com
110
+ // which we reject. Same-origin requests from our UI have no Origin or matching localhost.
111
+ const originGuard = (req, res, next) => {
112
+ const origin = req.headers.origin;
113
+ if (!origin) return next(); // Same-origin requests (no Origin header) — safe
114
+
115
+ // Allow only our own localhost origins
116
+ const allowedOrigins = [
117
+ `http://localhost:${config.port}`,
118
+ `http://127.0.0.1:${config.port}`,
119
+ `http://[::1]:${config.port}`,
120
+ ];
121
+ // Also allow Vite dev server (common dev ports)
122
+ for (const devPort of [5173, 5174, 3000, 3001]) {
123
+ allowedOrigins.push(`http://localhost:${devPort}`);
124
+ allowedOrigins.push(`http://127.0.0.1:${devPort}`);
125
+ }
126
+
127
+ if (allowedOrigins.includes(origin)) {
128
+ res.setHeader("Access-Control-Allow-Origin", origin);
129
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
130
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
131
+ if (req.method === "OPTIONS") return res.sendStatus(204);
132
+ return next();
133
+ }
134
+
135
+ console.warn(`[Security] Blocked cross-origin request from ${origin}`);
136
+ res.status(403).json({ error: "Cross-origin request blocked." });
137
+ };
138
+
139
+ // --- API Token auth ---
140
+ // Auto-generated on first start, stored on disk. Required for all /api/* requests.
141
+ // The UI receives the token via server-injected <meta> tag (no login needed).
142
+ // Other local tools (curl, scripts) read it from data/auth-token or pass via header.
143
+ const AUTH_TOKEN_PATH = join(config.dataDir, "auth-token");
144
+
145
+ function getOrCreateAuthToken() {
146
+ if (existsSync(AUTH_TOKEN_PATH)) {
147
+ const token = readFileSync(AUTH_TOKEN_PATH, "utf-8").trim();
148
+ if (token.length >= 32) return token;
149
+ }
150
+ const token = randomBytes(32).toString("hex");
151
+ mkdirSync(dirname(AUTH_TOKEN_PATH), { recursive: true });
152
+ writeFileSync(AUTH_TOKEN_PATH, token, { mode: 0o600 });
153
+ console.log("[Security] Generated new API auth token");
154
+ return token;
155
+ }
156
+
157
+ const API_TOKEN = getOrCreateAuthToken();
158
+
159
+ const tokenAuth = (req, res, next) => {
160
+ // Health endpoint is public (monitoring/readiness probes)
161
+ if (req.path === "/api/health") return next();
162
+
163
+ // Check Authorization: Bearer <token> header
164
+ const authHeader = req.headers.authorization;
165
+ if (authHeader === `Bearer ${API_TOKEN}`) return next();
166
+
167
+ // Check X-Auth-Token header (simpler for scripts/curl)
168
+ if (req.headers["x-auth-token"] === API_TOKEN) return next();
169
+
170
+ // Check ?token= query param (for SSE/EventSource which can't set headers)
171
+ if (req.query.token === API_TOKEN) return next();
172
+
173
+ console.warn(`[Security] Rejected unauthenticated request: ${req.method} ${req.path}`);
174
+ res.status(401).json({ error: "Authentication required. Include Authorization: Bearer <token> header." });
175
+ };
176
+
177
+ // Apply security to all API routes: IP check → origin check → token auth
97
178
  app.use("/api", localOnly);
179
+ app.use("/api", originGuard);
180
+ app.use("/api", tokenAuth);
98
181
 
99
182
  // --- Health check ---
100
183
  app.get("/api/health", (req, res) => {
101
184
  res.json({
102
- status: "ok",
185
+ status: _serverReady ? "ok" : "starting",
186
+ ready: _serverReady,
103
187
  uptime: process.uptime(),
104
188
  timestamp: new Date().toISOString(),
105
189
  tools: Object.keys(toolFunctions).length,
@@ -502,10 +586,29 @@ app.get("/api/audit", (req, res) => {
502
586
  app.get("/api/mcp", (req, res) => {
503
587
  const cfg = mcpManager.readConfig().mcpServers || {};
504
588
  const live = mcpManager.list();
589
+ const isPlaceholder = (v) => !v || v.startsWith("YOUR_") || v === "" || v.startsWith("${");
590
+ // Detect placeholder patterns in command args (e.g. connection strings, paths with dummy values)
591
+ const isArgPlaceholder = (v) => {
592
+ if (typeof v !== "string") return false;
593
+ return /user:pass@/i.test(v) || /\/Users\/you\//i.test(v) || /YOUR_/i.test(v)
594
+ || /your-.*-here/i.test(v) || /example\.com/i.test(v) || /changeme/i.test(v)
595
+ || /placeholder/i.test(v) || /xxx/i.test(v);
596
+ };
505
597
  const servers = Object.entries(cfg)
506
598
  .filter(([k]) => !k.startsWith("_comment"))
507
599
  .map(([name, serverCfg]) => {
508
600
  const liveEntry = live.find(s => s.name === name);
601
+ // Check if any env/header values are unconfigured placeholders
602
+ const envEntries = serverCfg.env ? Object.entries(serverCfg.env) : [];
603
+ const headerEntries = serverCfg.headers ? Object.entries(serverCfg.headers) : [];
604
+ // Also check args for placeholder patterns
605
+ const args = serverCfg.args || [];
606
+ const placeholderArgs = args
607
+ .map((v, i) => isArgPlaceholder(v) ? { index: i, value: v } : null)
608
+ .filter(Boolean);
609
+ const needsConfig = envEntries.some(([, v]) => isPlaceholder(v))
610
+ || headerEntries.some(([, v]) => isPlaceholder(v))
611
+ || placeholderArgs.length > 0;
509
612
  return {
510
613
  name,
511
614
  enabled: serverCfg.enabled !== false,
@@ -517,6 +620,8 @@ app.get("/api/mcp", (req, res) => {
517
620
  description: serverCfg.description || null,
518
621
  envKeys: serverCfg.env ? Object.keys(serverCfg.env) : [],
519
622
  headerKeys: serverCfg.headers ? Object.keys(serverCfg.headers) : [],
623
+ placeholderArgs,
624
+ needsConfig,
520
625
  };
521
626
  });
522
627
  res.json({ servers });
@@ -551,6 +656,39 @@ app.delete("/api/mcp/:name", async (req, res) => {
551
656
  }
552
657
  });
553
658
 
659
+ // Update MCP server credentials (env vars or headers)
660
+ app.patch("/api/mcp/:name", async (req, res) => {
661
+ const { name } = req.params;
662
+ const { env, headers: hdrs, args: argUpdates } = req.body;
663
+ try {
664
+ const mcpConfig = mcpManager.readConfig();
665
+ const serverCfg = mcpConfig.mcpServers?.[name];
666
+ if (!serverCfg) return res.status(404).json({ error: `Server "${name}" not found` });
667
+
668
+ if (env && typeof env === "object") {
669
+ serverCfg.env = { ...(serverCfg.env || {}), ...env };
670
+ }
671
+ if (hdrs && typeof hdrs === "object") {
672
+ serverCfg.headers = { ...(serverCfg.headers || {}), ...hdrs };
673
+ }
674
+ // Support updating specific args by index (e.g. connection strings)
675
+ if (argUpdates && typeof argUpdates === "object") {
676
+ if (!serverCfg.args) serverCfg.args = [];
677
+ for (const [indexStr, value] of Object.entries(argUpdates)) {
678
+ const idx = parseInt(indexStr, 10);
679
+ if (!isNaN(idx) && idx >= 0 && idx < serverCfg.args.length) {
680
+ serverCfg.args[idx] = value;
681
+ }
682
+ }
683
+ }
684
+ mcpConfig.mcpServers[name] = serverCfg;
685
+ mcpManager.writeConfig(mcpConfig);
686
+ res.json({ message: `Credentials updated for "${name}"` });
687
+ } catch (err) {
688
+ res.status(400).json({ error: err.message });
689
+ }
690
+ });
691
+
554
692
  // Enable / disable / reload an MCP server
555
693
  app.post("/api/mcp/:name/:action", async (req, res) => {
556
694
  const { name, action } = req.params;
@@ -694,6 +832,189 @@ app.post("/api/approvals/:id", (req, res) => {
694
832
  res.json({ message: `Approval ${req.params.id} → ${decision}` });
695
833
  });
696
834
 
835
+ // --- Settings endpoint (read/write .env vars) ---
836
+ app.get("/api/settings", (req, res) => {
837
+ const envPath = join(__dirname, "..", ".env");
838
+ const examplePath = join(__dirname, "..", ".env.example");
839
+
840
+ // Parse current .env
841
+ const envVars = {};
842
+ if (existsSync(envPath)) {
843
+ const lines = readFileSync(envPath, "utf-8").split("\n");
844
+ for (const line of lines) {
845
+ const trimmed = line.trim();
846
+ if (!trimmed || trimmed.startsWith("#")) continue;
847
+ const eqIdx = trimmed.indexOf("=");
848
+ if (eqIdx === -1) continue;
849
+ const key = trimmed.slice(0, eqIdx).trim();
850
+ const val = trimmed.slice(eqIdx + 1).trim();
851
+ envVars[key] = val;
852
+ }
853
+ }
854
+
855
+ // Parse .env.example for available vars with sections
856
+ const available = [];
857
+ if (existsSync(examplePath)) {
858
+ const lines = readFileSync(examplePath, "utf-8").split("\n");
859
+ let section = "General";
860
+ for (const line of lines) {
861
+ const trimmed = line.trim();
862
+ if (trimmed.startsWith("# ===")) {
863
+ section = trimmed.replace(/^# =+\s*/, "").replace(/\s*=+$/, "");
864
+ continue;
865
+ }
866
+ if (!trimmed || trimmed.startsWith("#")) continue;
867
+ const eqIdx = trimmed.indexOf("=");
868
+ if (eqIdx === -1) continue;
869
+ const key = trimmed.slice(0, eqIdx).trim();
870
+ available.push({ key, section });
871
+ }
872
+ }
873
+
874
+ // Mask values for security
875
+ const masked = {};
876
+ for (const [key, val] of Object.entries(envVars)) {
877
+ if (!val) { masked[key] = ""; continue; }
878
+ masked[key] = val.length <= 4 ? "****" : val.slice(0, 4) + "*".repeat(Math.min(val.length - 4, 20));
879
+ }
880
+
881
+ res.json({ vars: masked, available });
882
+ });
883
+
884
+ app.put("/api/settings", (req, res) => {
885
+ const { updates } = req.body; // { KEY: "value", KEY2: "value2" }
886
+ if (!updates || typeof updates !== "object") {
887
+ return res.status(400).json({ error: "updates object is required" });
888
+ }
889
+
890
+ const envPath = join(__dirname, "..", ".env");
891
+ let content = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
892
+
893
+ for (const [key, value] of Object.entries(updates)) {
894
+ // Validate key format (alphanumeric + underscore only)
895
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key)) continue;
896
+ const regex = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=.*$`, "m");
897
+ if (regex.test(content)) {
898
+ content = content.replace(regex, `${key}=${value}`);
899
+ } else {
900
+ content = content.trimEnd() + `\n${key}=${value}\n`;
901
+ }
902
+ // Also update process.env so changes take effect without restart
903
+ process.env[key] = value;
904
+ }
905
+
906
+ writeFileSync(envPath, content, "utf-8");
907
+
908
+ res.json({ message: `Updated ${Object.keys(updates).length} variable(s)`, updated: Object.keys(updates) });
909
+ });
910
+
911
+ // --- User Profile endpoints ---
912
+ app.get("/api/profile", (req, res) => {
913
+ const profilePath = join(config.dataDir, "user-profile.json");
914
+ if (!existsSync(profilePath)) return res.json({});
915
+ try {
916
+ const profile = JSON.parse(readFileSync(profilePath, "utf-8"));
917
+ res.json(profile);
918
+ } catch {
919
+ res.json({});
920
+ }
921
+ });
922
+
923
+ app.put("/api/profile", (req, res) => {
924
+ const { name, personality, tone, instructions, subAgentModel } = req.body;
925
+ const profilePath = join(config.dataDir, "user-profile.json");
926
+ const profile = { name: name || "", personality: personality || "", tone: tone || "", instructions: instructions || "", subAgentModel: subAgentModel || "" };
927
+ mkdirSync(dirname(profilePath), { recursive: true });
928
+ writeFileSync(profilePath, JSON.stringify(profile, null, 2), "utf-8");
929
+ // Apply sub-agent model to runtime so it takes effect immediately
930
+ if (subAgentModel) {
931
+ process.env.SUB_AGENT_MODEL = subAgentModel;
932
+ } else {
933
+ delete process.env.SUB_AGENT_MODEL;
934
+ }
935
+ res.json({ message: "Profile saved", profile });
936
+ });
937
+
938
+ // --- Custom Skills endpoints ---
939
+ app.get("/api/skills/custom", (req, res) => {
940
+ const customDir = join(config.skillsDir, "custom");
941
+ if (!existsSync(customDir)) return res.json({ skills: [] });
942
+ const files = [];
943
+ try {
944
+ const entries = readdirSync(customDir);
945
+ for (const f of entries) {
946
+ if (!f.endsWith(".md")) continue;
947
+ const content = readFileSync(join(customDir, f), "utf-8");
948
+ // Parse frontmatter
949
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
950
+ const meta = {};
951
+ if (fmMatch) {
952
+ for (const line of fmMatch[1].split("\n")) {
953
+ const idx = line.indexOf(":");
954
+ if (idx > 0) meta[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
955
+ }
956
+ }
957
+ files.push({
958
+ name: meta.name || f.replace(".md", ""),
959
+ description: meta.description || "",
960
+ triggers: meta.triggers || "",
961
+ filename: f,
962
+ content: fmMatch ? fmMatch[2].trim() : content,
963
+ });
964
+ }
965
+ } catch { /* ignore */ }
966
+ res.json({ skills: files });
967
+ });
968
+
969
+ app.post("/api/skills/custom", (req, res) => {
970
+ const { name, description, triggers, content } = req.body;
971
+ if (!name) return res.status(400).json({ error: "name is required" });
972
+ if (!content) return res.status(400).json({ error: "content is required" });
973
+
974
+ // Sanitize filename
975
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase();
976
+ const customDir = join(config.skillsDir, "custom");
977
+ mkdirSync(customDir, { recursive: true });
978
+
979
+ const filePath = join(customDir, `${safeName}.md`);
980
+ const frontmatter = `---\nname: ${safeName}\ndescription: ${description || ""}\n${triggers ? `triggers: ${triggers}\n` : ""}---\n\n`;
981
+ writeFileSync(filePath, frontmatter + content, "utf-8");
982
+
983
+ // Reload skills so new skill is discoverable
984
+ skillLoader.reload();
985
+
986
+ res.status(201).json({ message: "Custom skill created", name: safeName });
987
+ });
988
+
989
+ app.delete("/api/skills/custom/:name", (req, res) => {
990
+ const safeName = req.params.name.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase();
991
+ const filePath = join(config.skillsDir, "custom", `${safeName}.md`);
992
+ if (!existsSync(filePath)) return res.status(404).json({ error: "Skill not found" });
993
+
994
+ unlinkSync(filePath);
995
+ skillLoader.reload();
996
+ res.json({ message: "Custom skill deleted" });
997
+ });
998
+
999
+ // --- Memory endpoints ---
1000
+ app.get("/api/memory", (req, res) => {
1001
+ const memoryPath = config.memoryPath;
1002
+ if (!existsSync(memoryPath)) return res.json({ content: "" });
1003
+ try {
1004
+ const content = readFileSync(memoryPath, "utf-8");
1005
+ res.json({ content });
1006
+ } catch {
1007
+ res.json({ content: "" });
1008
+ }
1009
+ });
1010
+
1011
+ app.put("/api/memory", (req, res) => {
1012
+ const { content } = req.body;
1013
+ if (content === undefined) return res.status(400).json({ error: "content is required" });
1014
+ writeFileSync(config.memoryPath, content, "utf-8");
1015
+ res.json({ message: "Memory saved" });
1016
+ });
1017
+
697
1018
  // --- Costs endpoint ---
698
1019
  app.get("/api/costs/today", (req, res) => {
699
1020
  res.json({
@@ -704,42 +1025,107 @@ app.get("/api/costs/today", (req, res) => {
704
1025
  });
705
1026
  });
706
1027
 
707
- // --- Static UI ---
1028
+ // --- Static UI (with auth token injection) ---
708
1029
  const uiPath = join(__dirname, "..", "daemora-ui", "dist");
709
1030
  if (existsSync(uiPath)) {
710
- app.use(express.static(uiPath));
711
- // Serve index.html for all other routes (React Router support)
1031
+ const indexHtmlPath = join(uiPath, "index.html");
1032
+ let indexHtml = existsSync(indexHtmlPath) ? readFileSync(indexHtmlPath, "utf-8") : "";
1033
+
1034
+ // Inject auth token as a <meta> tag so the UI can read it without a login flow.
1035
+ // Safe because the HTML is only served to localhost (localOnly middleware).
1036
+ const tokenMeta = `<meta name="api-token" content="${API_TOKEN}" />`;
1037
+ if (indexHtml && !indexHtml.includes('name="api-token"')) {
1038
+ indexHtml = indexHtml.replace("</head>", ` ${tokenMeta}\n </head>`);
1039
+ }
1040
+
1041
+ // Serve static assets normally
1042
+ app.use(express.static(uiPath, { index: false })); // index:false so we handle index.html ourselves
1043
+
1044
+ // Serve token-injected index.html for all UI routes
712
1045
  app.get(/.*/, (req, res, next) => {
713
1046
  if (req.path.startsWith("/api/") || req.path.startsWith("/webhooks/") || req.path.startsWith("/voice/") || req.path.startsWith("/a2a/") || req.path.startsWith("/hooks/") || req.path.startsWith("/v1/")) {
714
1047
  return next();
715
1048
  }
716
- res.sendFile(join(uiPath, "index.html"));
1049
+ res.setHeader("Content-Type", "text/html");
1050
+ res.send(indexHtml);
717
1051
  });
718
1052
  console.log(`[Server] Serving UI from ${uiPath}`);
719
1053
  }
720
1054
 
1055
+ // --- Load user profile settings on startup ---
1056
+ try {
1057
+ const profilePath = join(config.dataDir, "user-profile.json");
1058
+ if (existsSync(profilePath)) {
1059
+ const p = JSON.parse(readFileSync(profilePath, "utf-8"));
1060
+ if (p.subAgentModel && !process.env.SUB_AGENT_MODEL) {
1061
+ process.env.SUB_AGENT_MODEL = p.subAgentModel;
1062
+ }
1063
+ }
1064
+ } catch { /* ignore */ }
1065
+
1066
+ // --- Server readiness gate ---
1067
+ // The server must fully initialize before processing user messages.
1068
+ // Skills, MCP, embeddings, and channels all need to load first.
1069
+ // Requests that arrive before ready get a 503 with a clear message.
1070
+ let _serverReady = false;
1071
+
1072
+ // Gate message-processing endpoints until startup completes
1073
+ const readinessGate = (req, res, next) => {
1074
+ if (_serverReady) return next();
1075
+ res.status(503).json({ error: "Server is starting up — loading skills, MCP, and channels. Please wait a moment and retry." });
1076
+ };
1077
+ app.use("/api/chat", readinessGate);
1078
+ app.post("/api/tasks", readinessGate);
1079
+
721
1080
  // --- Start server ---
722
1081
  app.listen(config.port, async () => {
723
1082
  console.log("\n--- Daemora Server ---");
724
1083
  console.log(`Running on http://localhost:${config.port}`);
725
1084
  console.log(`Model: ${config.defaultModel}`);
1085
+ if (process.env.SUB_AGENT_MODEL) console.log(`Sub-agent model: ${process.env.SUB_AGENT_MODEL}`);
726
1086
  console.log(`Permission tier: ${config.permissionTier}`);
727
- console.log(`Tools loaded: ${Object.keys(toolFunctions).join(", ")}`);
728
- console.log(`Total tools: ${Object.keys(toolFunctions).length}`);
729
1087
  console.log(`Data dir: ${config.dataDir}`);
730
1088
  console.log(`Daemon mode: ${config.daemonMode}`);
731
- console.log(`Task runner: active (concurrency: 2)`);
732
1089
 
733
- // Initialize MCP in background
734
- mcpManager.init().catch((e) => console.log(`[MCPManager] Init error: ${e.message}`));
1090
+ // ── Phase 1: Load skills + embeddings (must complete before processing messages) ──
1091
+ console.log("[Startup] Loading skills...");
1092
+ skillLoader.load();
1093
+ console.log(`[Startup] Skills loaded: ${skillLoader.list().length}`);
1094
+
1095
+ console.log("[Startup] Initializing embeddings...");
1096
+ try {
1097
+ const { ensureOllamaEmbedModel } = await import("./utils/Embeddings.js");
1098
+ await ensureOllamaEmbedModel();
1099
+ } catch { /* non-fatal */ }
1100
+
1101
+ // Embed skills (uses whatever embedding provider is available)
1102
+ try {
1103
+ await skillLoader.embedSkills();
1104
+ console.log("[Startup] Skill embeddings ready");
1105
+ } catch { /* non-fatal — TF-IDF fallback always works */ }
735
1106
 
736
- // Start channels (await so we see results before the blank line)
1107
+ // ── Phase 2: Connect MCP servers ──
1108
+ console.log("[Startup] Connecting MCP servers...");
1109
+ try {
1110
+ await mcpManager.init();
1111
+ } catch (e) {
1112
+ console.log(`[Startup] MCP init error (non-fatal): ${e.message}`);
1113
+ }
1114
+
1115
+ // ── Phase 3: Start channels ──
1116
+ console.log("[Startup] Starting channels...");
737
1117
  try {
738
1118
  await channelRegistry.startAll();
739
1119
  } catch (e) {
740
- console.log(`[ChannelRegistry] Start error: ${e.message}`);
1120
+ console.log(`[Startup] Channel start error: ${e.message}`);
741
1121
  }
742
- console.log("");
1122
+
1123
+ // ── Ready — start processing messages ──
1124
+ taskRunner.start();
1125
+ console.log(`[Startup] Tools: ${Object.keys(toolFunctions).length}`);
1126
+ console.log(`[Startup] Task runner: active (concurrency: 2)`);
1127
+ _serverReady = true;
1128
+ console.log("[Startup] Server ready ✓\n");
743
1129
  });
744
1130
 
745
1131
  // Graceful shutdown
@@ -218,11 +218,12 @@ const _profileEnvMap = {
218
218
  * 1. explicitModel (caller override)
219
219
  * 2. Per-tenant modelRoutes[profile]
220
220
  * 3. Global CODE_MODEL / RESEARCH_MODEL / WRITER_MODEL / ANALYST_MODEL env vars
221
- * 4. Per-tenant general model override
222
- * 5. DEFAULT_MODEL env var / hardcoded default
221
+ * 4. SUB_AGENT_MODEL (global sub-agent default)
222
+ * 5. Per-tenant general model override
223
+ * 6. DEFAULT_MODEL env var / hardcoded default
223
224
  *
224
225
  * @param {string|null} profile - e.g. "coder", "researcher", "writer", "analyst"
225
- * @param {object} tenantConfig - resolvedConfig from TenantManager (may have .modelRoutes, .model)
226
+ * @param {object} tenantConfig - resolvedConfig from TenantManager (may have .modelRoutes, .model, .subAgentModel)
226
227
  * @param {string|null} explicitModel - Caller-supplied model override (highest priority)
227
228
  * @returns {string} Resolved model ID
228
229
  */
@@ -230,6 +231,9 @@ export function resolveModelForProfile(profile, tenantConfig = {}, explicitModel
230
231
  if (explicitModel) return explicitModel;
231
232
  if (profile && tenantConfig.modelRoutes?.[profile]) return tenantConfig.modelRoutes[profile];
232
233
  if (profile && process.env[_profileEnvMap[profile]]) return process.env[_profileEnvMap[profile]];
234
+ // Sub-agent model: tenant-level > env-level
235
+ if (tenantConfig.subAgentModel) return tenantConfig.subAgentModel;
236
+ if (process.env.SUB_AGENT_MODEL) return process.env.SUB_AGENT_MODEL;
233
237
  if (tenantConfig.model) return tenantConfig.model;
234
238
  return process.env.DEFAULT_MODEL || "openai:gpt-4.1-mini";
235
239
  }