@frumu/tandem-panel 0.4.15 → 0.4.17

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/.env.example CHANGED
@@ -1,7 +1,7 @@
1
1
  # Control panel bind port
2
2
  TANDEM_CONTROL_PANEL_PORT=39732
3
3
 
4
- # Tip: run `tandem-setup init` to auto-generate this file and token.
4
+ # Tip: run `tandem panel init` to auto-generate this file and token.
5
5
 
6
6
  # Control panel bind host (loopback by default)
7
7
  TANDEM_CONTROL_PANEL_HOST=127.0.0.1
@@ -31,7 +31,7 @@ TANDEM_CONTROL_PANEL_ENGINE_TOKEN=tk_change_me
31
31
  TANDEM_DISABLE_TOOL_GUARD_BUDGETS=1
32
32
  TANDEM_TOOL_ROUTER_ENABLED=0
33
33
  TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS=5000
34
- TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS=30000
34
+ TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS=90000
35
35
  TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS=90000
36
36
  TANDEM_PERMISSION_WAIT_TIMEOUT_MS=15000
37
37
  TANDEM_TOOL_EXEC_TIMEOUT_MS=45000
@@ -40,7 +40,7 @@ TANDEM_BASH_TIMEOUT_MS=30000
40
40
  # Built-in websearch defaults. `auto` can try configured backends in order
41
41
  # (managed Tandem URL, SearxNG, Brave, Exa) before falling back.
42
42
  TANDEM_SEARCH_BACKEND=auto
43
- TANDEM_SEARCH_URL=https://search.tandem.frumu.ai
43
+ TANDEM_SEARCH_URL=https://search.tandem.ac
44
44
  TANDEM_SEARCH_TIMEOUT_MS=10000
45
45
  # Optional direct-provider overrides
46
46
  # TANDEM_BRAVE_SEARCH_API_KEY=
@@ -78,6 +78,13 @@ TANDEM_CONTROL_PANEL_SESSION_TTL_MINUTES=1440
78
78
  # Example: ACA_BASE_URL=http://127.0.0.1:39735
79
79
  ACA_BASE_URL=
80
80
 
81
+ # ACA bearer token. Required for ACA project, run, and log APIs.
82
+ # If both ACA_API_TOKEN and ACA_API_TOKEN_FILE are set, ACA_API_TOKEN wins.
83
+ ACA_API_TOKEN=
84
+
85
+ # Optional path to a file containing the ACA bearer token.
86
+ ACA_API_TOKEN_FILE=
87
+
81
88
  # Path on ACA_BASE_URL to use for health/status probe (default: /health)
82
89
  ACA_HEALTH_PATH=/health
83
90
 
package/README.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  Full web control center for Tandem Engine (non-desktop entry point).
4
4
 
5
+ This package remains the control-panel add-on during migration. The canonical
6
+ master CLI now lives in `@frumu/tandem` as `tandem`, and this package keeps
7
+ `tandem-setup` / `tandem-control-panel` as compatibility shims.
8
+
9
+ ## Fastest Ways To Start
10
+
11
+ - Web control panel: `npm i -g @frumu/tandem && tandem install panel && tandem panel init`
12
+ - Raw local engine: `npm i -g @frumu/tandem && tandem-engine serve --hostname 127.0.0.1 --port 39731`
13
+ - Terminal UI: `npm i -g @frumu/tandem-tui && tandem-tui`
14
+ - SDK usage: `npm install @frumu/tandem-client` or `pip install tandem-client`
15
+
16
+ If you are not sure which path to take, start with the web control panel. It gives you the engine, panel, token setup, and service install flow in one place.
17
+
5
18
  ## Install
6
19
 
7
20
  ```bash
@@ -41,7 +54,9 @@ Use the scaffold when you want the actual app source in your own folder so you c
41
54
  ## Official Bootstrap
42
55
 
43
56
  ```bash
44
- tandem-setup init
57
+ npm i -g @frumu/tandem
58
+ tandem install panel
59
+ tandem panel init
45
60
  ```
46
61
 
47
62
  This creates a canonical env file, bootstraps engine state, and installs services on Linux/macOS when run with the privileges needed for service registration.
@@ -49,10 +64,13 @@ This creates a canonical env file, bootstraps engine state, and installs service
49
64
  Useful follow-up commands:
50
65
 
51
66
  ```bash
52
- tandem-setup doctor
53
- tandem-setup service status
54
- tandem-setup service restart
55
- tandem-setup pair mobile
67
+ tandem doctor
68
+ tandem status
69
+ tandem service install
70
+ tandem service status
71
+ tandem service restart
72
+ tandem panel doctor
73
+ tandem panel open
56
74
  ```
57
75
 
58
76
  ## Run Foreground
@@ -76,7 +94,8 @@ tandem-setup service restart
76
94
  tandem-setup service logs
77
95
  ```
78
96
 
79
- Legacy flag mode is still supported for compatibility:
97
+ Legacy flag mode is still supported for compatibility, but new installs should
98
+ prefer `tandem` and the panel add-on commands:
80
99
 
81
100
  `tandem-control-panel --init`, `--install-services`, and `--service-op=...`
82
101
 
@@ -168,8 +187,8 @@ Notes:
168
187
 
169
188
  ## Setup Flow
170
189
 
171
- 1. Run `tandem-setup init`.
172
- 2. Verify with `tandem-setup doctor`.
190
+ 1. Run `tandem panel init` or `tandem-setup init`.
191
+ 2. Verify with `tandem panel doctor` or `tandem-setup doctor`.
173
192
  3. If running foreground, start `tandem-control-panel`.
174
193
  4. Sign in with the printed `TANDEM_CONTROL_PANEL_ENGINE_TOKEN`.
175
194
 
package/bin/cli.js CHANGED
@@ -57,7 +57,9 @@ async function main() {
57
57
  }
58
58
 
59
59
  if (first.startsWith("--")) {
60
- console.warn("[Tandem Setup] Legacy flag mode is deprecated. Use `tandem-setup init|service|doctor`.");
60
+ console.warn(
61
+ "[Tandem Setup] Legacy flag mode is deprecated. Use `tandem panel init|service|doctor`."
62
+ );
61
63
  process.exit(await runLegacy(argv));
62
64
  }
63
65
 
package/bin/setup.js CHANGED
@@ -12,8 +12,16 @@ import { fileURLToPath } from "url";
12
12
  import { createRequire } from "module";
13
13
  import { homedir } from "os";
14
14
  import { ensureBootstrapEnv, resolveEnvLoadOrder } from "../lib/setup/env.js";
15
+ import {
16
+ readControlPanelConfig,
17
+ resolveControlPanelConfigPath,
18
+ resolveControlPanelMode,
19
+ summarizeControlPanelConfig,
20
+ } from "../lib/setup/control-panel-config.js";
15
21
  import { createSwarmApiHandler, getOrchestratorMetrics } from "../server/routes/swarm.js";
22
+ import { createAcaApiHandler } from "../server/routes/aca.js";
16
23
  import { createCapabilitiesHandler, getCapabilitiesMetrics } from "../server/routes/capabilities.js";
24
+ import { createControlPanelConfigHandler } from "../server/routes/control-panel-config.js";
17
25
 
18
26
  function parseDotEnv(content) {
19
27
  const out = {};
@@ -178,8 +186,13 @@ const ENGINE_PORT = Number.parseInt(process.env.TANDEM_ENGINE_PORT || "39731", 1
178
186
  const ENGINE_URL = (
179
187
  process.env.TANDEM_ENGINE_URL || `http://${ENGINE_HOST}:${ENGINE_PORT}`
180
188
  ).replace(/\/+$/, "");
189
+ const ACA_BASE_URL = String(process.env.ACA_BASE_URL || "")
190
+ .trim()
191
+ .replace(/\/+$/, "");
192
+ const CONTROL_PANEL_CONFIG_FILE = String(process.env.TANDEM_CONTROL_PANEL_CONFIG_FILE || "").trim();
193
+ const CONTROL_PANEL_MODE = String(process.env.TANDEM_CONTROL_PANEL_MODE || "auto").trim();
181
194
  const DEFAULT_TANDEM_SEARCH_URL = (
182
- process.env.TANDEM_SEARCH_URL || "https://search.tandem.frumu.ai"
195
+ process.env.TANDEM_SEARCH_URL || "https://search.tandem.ac"
183
196
  ).replace(/\/+$/, "");
184
197
  const SWARM_RUNS_PATH = resolve(homedir(), ".tandem", "control-panel", "swarm-runs.json");
185
198
  const SWARM_HIDDEN_RUNS_PATH = resolve(
@@ -189,11 +202,20 @@ const SWARM_HIDDEN_RUNS_PATH = resolve(
189
202
  "swarm-hidden-runs.json"
190
203
  );
191
204
  const AUTO_START_ENGINE = (process.env.TANDEM_CONTROL_PANEL_AUTO_START_ENGINE || "1") !== "0";
192
- const CONFIGURED_ENGINE_TOKEN = (
193
- process.env.TANDEM_CONTROL_PANEL_ENGINE_TOKEN ||
194
- process.env.TANDEM_API_TOKEN ||
195
- ""
196
- ).trim();
205
+ const CONFIGURED_ENGINE_TOKEN = (() => {
206
+ const explicit = String(
207
+ process.env.TANDEM_CONTROL_PANEL_ENGINE_TOKEN || process.env.TANDEM_API_TOKEN || ""
208
+ ).trim();
209
+ if (explicit) return explicit;
210
+ const tokenFile = String(process.env.TANDEM_API_TOKEN_FILE || "").trim();
211
+ if (tokenFile) {
212
+ try {
213
+ return readFileSync(resolve(tokenFile), "utf8").trim();
214
+ } catch {}
215
+ }
216
+ return "";
217
+ })();
218
+ const ACA_TOKEN_FILE = String(process.env.ACA_API_TOKEN_FILE || "").trim();
197
219
  const SESSION_TTL_MS =
198
220
  Number.parseInt(process.env.TANDEM_CONTROL_PANEL_SESSION_TTL_MINUTES || "1440", 10) * 60 * 1000;
199
221
  const FILES_ROOT = resolve(
@@ -800,7 +822,7 @@ async function installServices() {
800
822
  TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS:
801
823
  existingEngineEnv.TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS || "5000",
802
824
  TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS:
803
- existingEngineEnv.TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS || "30000",
825
+ existingEngineEnv.TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS || "90000",
804
826
  TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS:
805
827
  existingEngineEnv.TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS || "90000",
806
828
  TANDEM_PERMISSION_WAIT_TIMEOUT_MS:
@@ -1062,7 +1084,9 @@ function readManagedSearchSettings() {
1062
1084
  has_brave_key: !!String(
1063
1085
  env.TANDEM_BRAVE_SEARCH_API_KEY || env.BRAVE_SEARCH_API_KEY || ""
1064
1086
  ).trim(),
1065
- has_exa_key: !!String(env.TANDEM_EXA_API_KEY || env.EXA_API_KEY || "").trim(),
1087
+ has_exa_key: !!String(
1088
+ env.TANDEM_EXA_API_KEY || env.TANDEM_EXA_SEARCH_API_KEY || env.EXA_API_KEY || ""
1089
+ ).trim(),
1066
1090
  },
1067
1091
  reason: localEngine
1068
1092
  ? ""
@@ -1103,9 +1127,13 @@ async function writeManagedSearchSettings(payload = {}) {
1103
1127
  }
1104
1128
 
1105
1129
  const exaKey = String(payload.exa_api_key || payload.exaApiKey || "").trim();
1106
- if (exaKey) nextEnv.TANDEM_EXA_API_KEY = exaKey;
1130
+ if (exaKey) {
1131
+ nextEnv.TANDEM_EXA_API_KEY = exaKey;
1132
+ delete nextEnv.TANDEM_EXA_SEARCH_API_KEY;
1133
+ }
1107
1134
  else if (payload.clear_exa_key || payload.clearExaKey) {
1108
1135
  delete nextEnv.TANDEM_EXA_API_KEY;
1136
+ delete nextEnv.TANDEM_EXA_SEARCH_API_KEY;
1109
1137
  delete nextEnv.EXA_API_KEY;
1110
1138
  }
1111
1139
 
@@ -1133,6 +1161,70 @@ async function writeManagedSearchSettings(payload = {}) {
1133
1161
  };
1134
1162
  }
1135
1163
 
1164
+ function getManagedSchedulerSettings() {
1165
+ const envPath = getManagedEngineEnvPath();
1166
+ const localEngine = isLocalEngineUrl(ENGINE_URL);
1167
+ const env = existsSync(envPath) ? parseDotEnv(readFileSync(envPath, "utf8")) : {};
1168
+ const modeRaw = String(env.TANDEM_SCHEDULER_MODE || "multi").trim().toLowerCase();
1169
+ const mode = modeRaw === "single" ? "single" : "multi";
1170
+ const maxRaw = Number.parseInt(String(env.TANDEM_SCHEDULER_MAX_CONCURRENT_RUNS || ""), 10);
1171
+ const maxConcurrentRuns = Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : null;
1172
+ return {
1173
+ available: localEngine,
1174
+ local_engine: localEngine,
1175
+ writable: localEngine,
1176
+ managed_env_path: envPath,
1177
+ restart_required: false,
1178
+ restart_hint: "Restart tandem-engine after changing scheduler mode.",
1179
+ settings: {
1180
+ mode,
1181
+ max_concurrent_runs: maxConcurrentRuns,
1182
+ },
1183
+ reason: localEngine
1184
+ ? ""
1185
+ : "Scheduler settings can only be edited here when the control panel points at a local engine host.",
1186
+ };
1187
+ }
1188
+
1189
+ async function writeManagedSchedulerSettings(payload = {}) {
1190
+ const current = getManagedSchedulerSettings();
1191
+ if (!current.local_engine) {
1192
+ const error = new Error(
1193
+ current.reason || "Scheduler settings are not editable for this engine."
1194
+ );
1195
+ error.statusCode = 400;
1196
+ throw error;
1197
+ }
1198
+ const envPath = current.managed_env_path;
1199
+ const existingEnv = existsSync(envPath) ? parseDotEnv(readFileSync(envPath, "utf8")) : {};
1200
+ const nextEnv = { ...existingEnv };
1201
+ const modeRaw = String(payload.mode || "multi").trim().toLowerCase();
1202
+ nextEnv.TANDEM_SCHEDULER_MODE = modeRaw === "single" ? "single" : "multi";
1203
+ if (payload.max_concurrent_runs != null && payload.max_concurrent_runs > 0) {
1204
+ nextEnv.TANDEM_SCHEDULER_MAX_CONCURRENT_RUNS = String(payload.max_concurrent_runs);
1205
+ } else {
1206
+ delete nextEnv.TANDEM_SCHEDULER_MAX_CONCURRENT_RUNS;
1207
+ }
1208
+ const preferredKeys = [
1209
+ "TANDEM_API_TOKEN",
1210
+ "TANDEM_STATE_DIR",
1211
+ "TANDEM_SCHEDULER_MODE",
1212
+ "TANDEM_SCHEDULER_MAX_CONCURRENT_RUNS",
1213
+ ];
1214
+ const ordered = [];
1215
+ for (const key of preferredKeys) {
1216
+ if (nextEnv[key] !== undefined) ordered.push([key, nextEnv[key]]);
1217
+ }
1218
+ for (const [key, value] of Object.entries(nextEnv)) {
1219
+ if (!preferredKeys.includes(key)) ordered.push([key, value]);
1220
+ }
1221
+ await writeFile(envPath, serializeEnv(ordered), "utf8");
1222
+ return {
1223
+ ...getManagedSchedulerSettings(),
1224
+ restart_required: true,
1225
+ };
1226
+ }
1227
+
1136
1228
  function sendJson(res, code, payload) {
1137
1229
  if (res.headersSent || res.writableEnded || res.destroyed) return;
1138
1230
  const body = JSON.stringify(payload);
@@ -1143,6 +1235,54 @@ function sendJson(res, code, payload) {
1143
1235
  res.end(body);
1144
1236
  }
1145
1237
 
1238
+ function readOptionalTokenFile(pathname) {
1239
+ const target = String(pathname || "").trim();
1240
+ if (!target) return "";
1241
+ try {
1242
+ return readFileSync(resolve(target), "utf8").trim();
1243
+ } catch {
1244
+ return "";
1245
+ }
1246
+ }
1247
+
1248
+ function getAcaToken() {
1249
+ return (
1250
+ String(process.env.ACA_API_TOKEN || "").trim() ||
1251
+ readOptionalTokenFile(ACA_TOKEN_FILE) ||
1252
+ ""
1253
+ );
1254
+ }
1255
+
1256
+ function getControlPanelConfigPath() {
1257
+ return resolveControlPanelConfigPath({
1258
+ explicitPath: CONTROL_PANEL_CONFIG_FILE,
1259
+ stateDir: process.env.TANDEM_CONTROL_PANEL_STATE_DIR,
1260
+ env: process.env,
1261
+ });
1262
+ }
1263
+
1264
+ async function getInstallProfile({ acaAvailable = false, acaReason = "" } = {}) {
1265
+ const configPath = getControlPanelConfigPath();
1266
+ const config = readControlPanelConfig(configPath);
1267
+ const mode = resolveControlPanelMode({
1268
+ config,
1269
+ envMode: CONTROL_PANEL_MODE,
1270
+ acaAvailable,
1271
+ });
1272
+ const summary = summarizeControlPanelConfig(config);
1273
+ return {
1274
+ control_panel_mode: mode.mode,
1275
+ control_panel_mode_source: mode.source,
1276
+ control_panel_mode_reason: mode.reason || "",
1277
+ control_panel_config_path: configPath,
1278
+ control_panel_config_ready: summary.ready,
1279
+ control_panel_config_missing: summary.missing,
1280
+ control_panel_compact_nav: !!summary.control_panel?.aca_compact_nav,
1281
+ aca_integration: !!acaAvailable,
1282
+ aca_reason: acaReason || "",
1283
+ };
1284
+ }
1285
+
1146
1286
  function pushSwarmEvent(kind, payload = {}) {
1147
1287
  const event = {
1148
1288
  kind,
@@ -1188,6 +1328,73 @@ async function engineHealth(token = "") {
1188
1328
  }
1189
1329
  }
1190
1330
 
1331
+ async function executeEngineTool(token, tool, args = {}) {
1332
+ const response = await fetch(`${ENGINE_URL}/tool/execute`, {
1333
+ method: "POST",
1334
+ headers: {
1335
+ "content-type": "application/json",
1336
+ authorization: `Bearer ${token}`,
1337
+ "x-tandem-token": token,
1338
+ },
1339
+ body: JSON.stringify({ tool, args }),
1340
+ signal: AbortSignal.timeout(15000),
1341
+ });
1342
+ const text = await response.text().catch(() => "");
1343
+ let parsed = null;
1344
+ try {
1345
+ parsed = text ? JSON.parse(text) : {};
1346
+ } catch {
1347
+ parsed = null;
1348
+ }
1349
+ if (!response.ok) {
1350
+ const message =
1351
+ parsed?.error || parsed?.detail || text || `${tool} failed (${response.status})`;
1352
+ const error = new Error(message);
1353
+ error.statusCode = response.status;
1354
+ error.payload = parsed;
1355
+ throw error;
1356
+ }
1357
+ return parsed || {};
1358
+ }
1359
+
1360
+ function buildSearchTestMarkdown(payload) {
1361
+ const query = String(payload?.query || "").trim();
1362
+ const backend = String(payload?.backend || "unknown").trim();
1363
+ const configuredBackend = String(payload?.configured_backend || backend || "unknown").trim();
1364
+ const attemptedBackends = Array.isArray(payload?.attempted_backends)
1365
+ ? payload.attempted_backends.filter(Boolean)
1366
+ : [];
1367
+ const resultCount = Number(payload?.result_count || 0) || 0;
1368
+ const partial = payload?.partial === true;
1369
+ const results = Array.isArray(payload?.results) ? payload.results : [];
1370
+
1371
+ const lines = [
1372
+ "# Websearch test",
1373
+ "",
1374
+ `- Query: \`${query || "n/a"}\``,
1375
+ `- Backend used: \`${backend || "unknown"}\``,
1376
+ `- Configured backend: \`${configuredBackend || "unknown"}\``,
1377
+ `- Attempted backends: ${attemptedBackends.length ? attemptedBackends.map((name) => `\`${name}\``).join(", ") : "none"}`,
1378
+ `- Results: ${resultCount}${partial ? " (partial)" : ""}`,
1379
+ "",
1380
+ ];
1381
+
1382
+ if (!results.length) {
1383
+ lines.push("No search results were returned.");
1384
+ return lines.join("\n");
1385
+ }
1386
+
1387
+ lines.push("## Top results", "");
1388
+ for (const [index, row] of results.entries()) {
1389
+ const title = String(row?.title || row?.url || `Result ${index + 1}`).trim();
1390
+ const url = String(row?.url || "").trim();
1391
+ const snippet = String(row?.snippet || "").trim();
1392
+ lines.push(`${index + 1}. [${title}](${url || "#"})`);
1393
+ if (snippet) lines.push(` ${snippet}`);
1394
+ }
1395
+ return lines.join("\n");
1396
+ }
1397
+
1191
1398
  async function validateEngineToken(token) {
1192
1399
  try {
1193
1400
  const response = await fetch(`${ENGINE_URL}/config/providers`, {
@@ -1225,7 +1432,7 @@ async function ensureEngineRunning() {
1225
1432
  engineEntrypoint = require.resolve("@frumu/tandem/bin/tandem-engine.js");
1226
1433
  } catch (e) {
1227
1434
  err("Could not resolve @frumu/tandem binary entrypoint.");
1228
- err("Reinstall with: npm i -g @frumu/tandem-panel");
1435
+ err("Reinstall with: npm i -g @frumu/tandem");
1229
1436
  throw e;
1230
1437
  }
1231
1438
 
@@ -1252,7 +1459,7 @@ async function ensureEngineRunning() {
1252
1459
  TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS:
1253
1460
  process.env.TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS || "5000",
1254
1461
  TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS:
1255
- process.env.TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS || "30000",
1462
+ process.env.TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS || "90000",
1256
1463
  TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS:
1257
1464
  process.env.TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS || "90000",
1258
1465
  TANDEM_PERMISSION_WAIT_TIMEOUT_MS: process.env.TANDEM_PERMISSION_WAIT_TIMEOUT_MS || "15000",
@@ -4522,8 +4729,10 @@ const handleSwarmApi = createSwarmApiHandler({
4522
4729
 
4523
4730
  const handleCapabilities = createCapabilitiesHandler({
4524
4731
  PROBE_TIMEOUT_MS: Number.parseInt(process.env.ACA_PROBE_TIMEOUT_MS || "5000", 10),
4525
- ACA_BASE_URL: process.env.ACA_BASE_URL || "",
4526
- ACA_HEALTH_PATH: "/health",
4732
+ ACA_BASE_URL,
4733
+ ACA_HEALTH_PATH: process.env.ACA_HEALTH_PATH || "/health",
4734
+ getAcaToken,
4735
+ getInstallProfile,
4527
4736
  engineHealth: async (token) => {
4528
4737
  const health = await engineHealth(token).catch(() => null);
4529
4738
  return health;
@@ -4532,6 +4741,24 @@ const handleCapabilities = createCapabilitiesHandler({
4532
4741
  cacheTtlMs: Number.parseInt(process.env.ACA_CAPABILITY_CACHE_TTL_MS || "45000", 10),
4533
4742
  });
4534
4743
 
4744
+ const handleAcaApi = createAcaApiHandler({
4745
+ PORTAL_PORT,
4746
+ ACA_BASE_URL,
4747
+ getAcaToken,
4748
+ sendJson,
4749
+ });
4750
+
4751
+ const handleControlPanelConfig = createControlPanelConfigHandler({
4752
+ CONTROL_PANEL_CONFIG_FILE,
4753
+ TANDEM_CONTROL_PANEL_STATE_DIR: process.env.TANDEM_CONTROL_PANEL_STATE_DIR || "",
4754
+ CONTROL_PANEL_MODE,
4755
+ ACA_BASE_URL,
4756
+ PROBE_TIMEOUT_MS: Number.parseInt(process.env.ACA_PROBE_TIMEOUT_MS || "5000", 10),
4757
+ getAcaToken,
4758
+ sendJson,
4759
+ readJsonBody,
4760
+ });
4761
+
4535
4762
  async function handleApi(req, res) {
4536
4763
  const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
4537
4764
 
@@ -4557,6 +4784,11 @@ async function handleApi(req, res) {
4557
4784
  return true;
4558
4785
  }
4559
4786
 
4787
+ if (pathname === "/api/install/profile" && req.method === "GET") {
4788
+ await handleCapabilities(req, res);
4789
+ return true;
4790
+ }
4791
+
4560
4792
  if (pathname === "/api/system/orchestrator-metrics" && req.method === "GET") {
4561
4793
  sendJson(res, 200, getOrchestratorMetrics());
4562
4794
  return true;
@@ -4585,6 +4817,72 @@ async function handleApi(req, res) {
4585
4817
  return true;
4586
4818
  }
4587
4819
 
4820
+ if (pathname === "/api/system/search-settings/test" && req.method === "POST") {
4821
+ const session = requireSession(req, res);
4822
+ if (!session) return true;
4823
+ try {
4824
+ const payload = await readJsonBody(req);
4825
+ const query = String(payload?.query || "").trim();
4826
+ const limitRaw = Number.parseInt(String(payload?.limit || "5"), 10);
4827
+ const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 10) : 5;
4828
+ if (!query) {
4829
+ sendJson(res, 400, { ok: false, error: "Search query is required." });
4830
+ return true;
4831
+ }
4832
+ const result = await executeEngineTool(session.token, "websearch", {
4833
+ query,
4834
+ limit,
4835
+ });
4836
+ const output = String(result?.output || "");
4837
+ let parsedOutput = null;
4838
+ try {
4839
+ parsedOutput = output ? JSON.parse(output) : null;
4840
+ } catch {
4841
+ parsedOutput = null;
4842
+ }
4843
+ const markdown = parsedOutput
4844
+ ? buildSearchTestMarkdown(parsedOutput)
4845
+ : `# Websearch test\n\n## Output\n\n\`\`\`\n${output || "No output returned."}\n\`\`\``;
4846
+ sendJson(res, 200, {
4847
+ ok: true,
4848
+ query,
4849
+ markdown,
4850
+ output,
4851
+ parsed_output: parsedOutput,
4852
+ metadata: result?.metadata || {},
4853
+ });
4854
+ } catch (error) {
4855
+ sendJson(res, Number(error?.statusCode || 500), {
4856
+ ok: false,
4857
+ error: error instanceof Error ? error.message : String(error),
4858
+ });
4859
+ }
4860
+ return true;
4861
+ }
4862
+
4863
+ if (pathname === "/api/system/scheduler-settings" && req.method === "GET") {
4864
+ const session = requireSession(req, res);
4865
+ if (!session) return true;
4866
+ sendJson(res, 200, getManagedSchedulerSettings());
4867
+ return true;
4868
+ }
4869
+
4870
+ if (pathname === "/api/system/scheduler-settings" && req.method === "PATCH") {
4871
+ const session = requireSession(req, res);
4872
+ if (!session) return true;
4873
+ try {
4874
+ const payload = await readJsonBody(req);
4875
+ const saved = await writeManagedSchedulerSettings(payload);
4876
+ sendJson(res, 200, saved);
4877
+ } catch (error) {
4878
+ sendJson(res, Number(error?.statusCode || 500), {
4879
+ ok: false,
4880
+ error: error instanceof Error ? error.message : String(error),
4881
+ });
4882
+ }
4883
+ return true;
4884
+ }
4885
+
4588
4886
  if (pathname === "/api/auth/login" && req.method === "POST") {
4589
4887
  await handleAuthLogin(req, res);
4590
4888
  return true;
@@ -4620,12 +4918,24 @@ async function handleApi(req, res) {
4620
4918
  return true;
4621
4919
  }
4622
4920
 
4921
+ if (pathname === "/api/control-panel/config" && (req.method === "GET" || req.method === "PATCH")) {
4922
+ const session = requireSession(req, res);
4923
+ if (!session) return true;
4924
+ return handleControlPanelConfig(req, res);
4925
+ }
4926
+
4623
4927
  if (pathname.startsWith("/api/swarm") || pathname.startsWith("/api/orchestrator")) {
4624
4928
  const session = requireSession(req, res);
4625
4929
  if (!session) return true;
4626
4930
  return handleSwarmApi(req, res, session);
4627
4931
  }
4628
4932
 
4933
+ if (pathname.startsWith("/api/aca")) {
4934
+ const session = requireSession(req, res);
4935
+ if (!session) return true;
4936
+ return handleAcaApi(req, res);
4937
+ }
4938
+
4629
4939
  if (pathname.startsWith("/api/files")) {
4630
4940
  const session = requireSession(req, res);
4631
4941
  if (!session) return true;