@frumu/tandem-panel 0.4.14 → 0.4.16

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
@@ -70,3 +70,27 @@ TANDEM_SEARCH_TIMEOUT_MS=10000
70
70
 
71
71
  # Session TTL in minutes
72
72
  TANDEM_CONTROL_PANEL_SESSION_TTL_MINUTES=1440
73
+
74
+ # ─── ACA Integration ─────────────────────────────────────────────────
75
+ # Optional external ACA base URL. Leave blank to disable ACA integration.
76
+ # When set, the control panel probes this URL on startup and exposes it via
77
+ # /api/capabilities as aca_integration.
78
+ # Example: ACA_BASE_URL=http://127.0.0.1:39735
79
+ ACA_BASE_URL=
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
+
88
+ # Path on ACA_BASE_URL to use for health/status probe (default: /health)
89
+ ACA_HEALTH_PATH=/health
90
+
91
+ # Probe timeout in milliseconds. Probes that take longer return "aca_probe_timeout".
92
+ ACA_PROBE_TIMEOUT_MS=5000
93
+
94
+ # How long to cache capability probe results, in milliseconds (default: 45000 = 45 s).
95
+ # Set lower for faster ACA availability detection, higher to reduce probe frequency.
96
+ ACA_CAPABILITY_CACHE_TTL_MS=45000
package/README.md CHANGED
@@ -2,6 +2,10 @@
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
+
5
9
  ## Install
6
10
 
7
11
  ```bash
@@ -41,7 +45,9 @@ Use the scaffold when you want the actual app source in your own folder so you c
41
45
  ## Official Bootstrap
42
46
 
43
47
  ```bash
44
- tandem-setup init
48
+ npm i -g @frumu/tandem
49
+ tandem install panel
50
+ tandem panel init
45
51
  ```
46
52
 
47
53
  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 +55,12 @@ This creates a canonical env file, bootstraps engine state, and installs service
49
55
  Useful follow-up commands:
50
56
 
51
57
  ```bash
52
- tandem-setup doctor
53
- tandem-setup service status
54
- tandem-setup service restart
55
- tandem-setup pair mobile
58
+ tandem doctor
59
+ tandem status
60
+ tandem service status
61
+ tandem service restart
62
+ tandem panel doctor
63
+ tandem panel open
56
64
  ```
57
65
 
58
66
  ## Run Foreground
@@ -76,7 +84,8 @@ tandem-setup service restart
76
84
  tandem-setup service logs
77
85
  ```
78
86
 
79
- Legacy flag mode is still supported for compatibility:
87
+ Legacy flag mode is still supported for compatibility, but new installs should
88
+ prefer `tandem` and the panel add-on commands:
80
89
 
81
90
  `tandem-control-panel --init`, `--install-services`, and `--service-op=...`
82
91
 
@@ -168,8 +177,8 @@ Notes:
168
177
 
169
178
  ## Setup Flow
170
179
 
171
- 1. Run `tandem-setup init`.
172
- 2. Verify with `tandem-setup doctor`.
180
+ 1. Run `tandem panel init` or `tandem-setup init`.
181
+ 2. Verify with `tandem panel doctor` or `tandem-setup doctor`.
173
182
  3. If running foreground, start `tandem-control-panel`.
174
183
  4. Sign in with the printed `TANDEM_CONTROL_PANEL_ENGINE_TOKEN`.
175
184
 
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,7 +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 { createSwarmApiHandler } from "../server/routes/swarm.js";
15
+ import {
16
+ readControlPanelConfig,
17
+ resolveControlPanelConfigPath,
18
+ resolveControlPanelMode,
19
+ summarizeControlPanelConfig,
20
+ } from "../lib/setup/control-panel-config.js";
21
+ import { createSwarmApiHandler, getOrchestratorMetrics } from "../server/routes/swarm.js";
22
+ import { createAcaApiHandler } from "../server/routes/aca.js";
23
+ import { createCapabilitiesHandler, getCapabilitiesMetrics } from "../server/routes/capabilities.js";
24
+ import { createControlPanelConfigHandler } from "../server/routes/control-panel-config.js";
16
25
 
17
26
  function parseDotEnv(content) {
18
27
  const out = {};
@@ -177,6 +186,11 @@ const ENGINE_PORT = Number.parseInt(process.env.TANDEM_ENGINE_PORT || "39731", 1
177
186
  const ENGINE_URL = (
178
187
  process.env.TANDEM_ENGINE_URL || `http://${ENGINE_HOST}:${ENGINE_PORT}`
179
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();
180
194
  const DEFAULT_TANDEM_SEARCH_URL = (
181
195
  process.env.TANDEM_SEARCH_URL || "https://search.tandem.frumu.ai"
182
196
  ).replace(/\/+$/, "");
@@ -188,11 +202,20 @@ const SWARM_HIDDEN_RUNS_PATH = resolve(
188
202
  "swarm-hidden-runs.json"
189
203
  );
190
204
  const AUTO_START_ENGINE = (process.env.TANDEM_CONTROL_PANEL_AUTO_START_ENGINE || "1") !== "0";
191
- const CONFIGURED_ENGINE_TOKEN = (
192
- process.env.TANDEM_CONTROL_PANEL_ENGINE_TOKEN ||
193
- process.env.TANDEM_API_TOKEN ||
194
- ""
195
- ).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();
196
219
  const SESSION_TTL_MS =
197
220
  Number.parseInt(process.env.TANDEM_CONTROL_PANEL_SESSION_TTL_MINUTES || "1440", 10) * 60 * 1000;
198
221
  const FILES_ROOT = resolve(
@@ -1061,7 +1084,9 @@ function readManagedSearchSettings() {
1061
1084
  has_brave_key: !!String(
1062
1085
  env.TANDEM_BRAVE_SEARCH_API_KEY || env.BRAVE_SEARCH_API_KEY || ""
1063
1086
  ).trim(),
1064
- 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(),
1065
1090
  },
1066
1091
  reason: localEngine
1067
1092
  ? ""
@@ -1102,9 +1127,13 @@ async function writeManagedSearchSettings(payload = {}) {
1102
1127
  }
1103
1128
 
1104
1129
  const exaKey = String(payload.exa_api_key || payload.exaApiKey || "").trim();
1105
- 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
+ }
1106
1134
  else if (payload.clear_exa_key || payload.clearExaKey) {
1107
1135
  delete nextEnv.TANDEM_EXA_API_KEY;
1136
+ delete nextEnv.TANDEM_EXA_SEARCH_API_KEY;
1108
1137
  delete nextEnv.EXA_API_KEY;
1109
1138
  }
1110
1139
 
@@ -1142,6 +1171,54 @@ function sendJson(res, code, payload) {
1142
1171
  res.end(body);
1143
1172
  }
1144
1173
 
1174
+ function readOptionalTokenFile(pathname) {
1175
+ const target = String(pathname || "").trim();
1176
+ if (!target) return "";
1177
+ try {
1178
+ return readFileSync(resolve(target), "utf8").trim();
1179
+ } catch {
1180
+ return "";
1181
+ }
1182
+ }
1183
+
1184
+ function getAcaToken() {
1185
+ return (
1186
+ String(process.env.ACA_API_TOKEN || "").trim() ||
1187
+ readOptionalTokenFile(ACA_TOKEN_FILE) ||
1188
+ ""
1189
+ );
1190
+ }
1191
+
1192
+ function getControlPanelConfigPath() {
1193
+ return resolveControlPanelConfigPath({
1194
+ explicitPath: CONTROL_PANEL_CONFIG_FILE,
1195
+ stateDir: process.env.TANDEM_CONTROL_PANEL_STATE_DIR,
1196
+ env: process.env,
1197
+ });
1198
+ }
1199
+
1200
+ async function getInstallProfile({ acaAvailable = false, acaReason = "" } = {}) {
1201
+ const configPath = getControlPanelConfigPath();
1202
+ const config = readControlPanelConfig(configPath);
1203
+ const mode = resolveControlPanelMode({
1204
+ config,
1205
+ envMode: CONTROL_PANEL_MODE,
1206
+ acaAvailable,
1207
+ });
1208
+ const summary = summarizeControlPanelConfig(config);
1209
+ return {
1210
+ control_panel_mode: mode.mode,
1211
+ control_panel_mode_source: mode.source,
1212
+ control_panel_mode_reason: mode.reason || "",
1213
+ control_panel_config_path: configPath,
1214
+ control_panel_config_ready: summary.ready,
1215
+ control_panel_config_missing: summary.missing,
1216
+ control_panel_compact_nav: !!summary.control_panel?.aca_compact_nav,
1217
+ aca_integration: !!acaAvailable,
1218
+ aca_reason: acaReason || "",
1219
+ };
1220
+ }
1221
+
1145
1222
  function pushSwarmEvent(kind, payload = {}) {
1146
1223
  const event = {
1147
1224
  kind,
@@ -1187,6 +1264,73 @@ async function engineHealth(token = "") {
1187
1264
  }
1188
1265
  }
1189
1266
 
1267
+ async function executeEngineTool(token, tool, args = {}) {
1268
+ const response = await fetch(`${ENGINE_URL}/tool/execute`, {
1269
+ method: "POST",
1270
+ headers: {
1271
+ "content-type": "application/json",
1272
+ authorization: `Bearer ${token}`,
1273
+ "x-tandem-token": token,
1274
+ },
1275
+ body: JSON.stringify({ tool, args }),
1276
+ signal: AbortSignal.timeout(15000),
1277
+ });
1278
+ const text = await response.text().catch(() => "");
1279
+ let parsed = null;
1280
+ try {
1281
+ parsed = text ? JSON.parse(text) : {};
1282
+ } catch {
1283
+ parsed = null;
1284
+ }
1285
+ if (!response.ok) {
1286
+ const message =
1287
+ parsed?.error || parsed?.detail || text || `${tool} failed (${response.status})`;
1288
+ const error = new Error(message);
1289
+ error.statusCode = response.status;
1290
+ error.payload = parsed;
1291
+ throw error;
1292
+ }
1293
+ return parsed || {};
1294
+ }
1295
+
1296
+ function buildSearchTestMarkdown(payload) {
1297
+ const query = String(payload?.query || "").trim();
1298
+ const backend = String(payload?.backend || "unknown").trim();
1299
+ const configuredBackend = String(payload?.configured_backend || backend || "unknown").trim();
1300
+ const attemptedBackends = Array.isArray(payload?.attempted_backends)
1301
+ ? payload.attempted_backends.filter(Boolean)
1302
+ : [];
1303
+ const resultCount = Number(payload?.result_count || 0) || 0;
1304
+ const partial = payload?.partial === true;
1305
+ const results = Array.isArray(payload?.results) ? payload.results : [];
1306
+
1307
+ const lines = [
1308
+ "# Websearch test",
1309
+ "",
1310
+ `- Query: \`${query || "n/a"}\``,
1311
+ `- Backend used: \`${backend || "unknown"}\``,
1312
+ `- Configured backend: \`${configuredBackend || "unknown"}\``,
1313
+ `- Attempted backends: ${attemptedBackends.length ? attemptedBackends.map((name) => `\`${name}\``).join(", ") : "none"}`,
1314
+ `- Results: ${resultCount}${partial ? " (partial)" : ""}`,
1315
+ "",
1316
+ ];
1317
+
1318
+ if (!results.length) {
1319
+ lines.push("No search results were returned.");
1320
+ return lines.join("\n");
1321
+ }
1322
+
1323
+ lines.push("## Top results", "");
1324
+ for (const [index, row] of results.entries()) {
1325
+ const title = String(row?.title || row?.url || `Result ${index + 1}`).trim();
1326
+ const url = String(row?.url || "").trim();
1327
+ const snippet = String(row?.snippet || "").trim();
1328
+ lines.push(`${index + 1}. [${title}](${url || "#"})`);
1329
+ if (snippet) lines.push(` ${snippet}`);
1330
+ }
1331
+ return lines.join("\n");
1332
+ }
1333
+
1190
1334
  async function validateEngineToken(token) {
1191
1335
  try {
1192
1336
  const response = await fetch(`${ENGINE_URL}/config/providers`, {
@@ -1224,7 +1368,7 @@ async function ensureEngineRunning() {
1224
1368
  engineEntrypoint = require.resolve("@frumu/tandem/bin/tandem-engine.js");
1225
1369
  } catch (e) {
1226
1370
  err("Could not resolve @frumu/tandem binary entrypoint.");
1227
- err("Reinstall with: npm i -g @frumu/tandem-panel");
1371
+ err("Reinstall with: npm i -g @frumu/tandem");
1228
1372
  throw e;
1229
1373
  }
1230
1374
 
@@ -4519,6 +4663,38 @@ const handleSwarmApi = createSwarmApiHandler({
4519
4663
  setActiveSwarmRunId,
4520
4664
  });
4521
4665
 
4666
+ const handleCapabilities = createCapabilitiesHandler({
4667
+ PROBE_TIMEOUT_MS: Number.parseInt(process.env.ACA_PROBE_TIMEOUT_MS || "5000", 10),
4668
+ ACA_BASE_URL,
4669
+ ACA_HEALTH_PATH: process.env.ACA_HEALTH_PATH || "/health",
4670
+ getAcaToken,
4671
+ getInstallProfile,
4672
+ engineHealth: async (token) => {
4673
+ const health = await engineHealth(token).catch(() => null);
4674
+ return health;
4675
+ },
4676
+ sendJson,
4677
+ cacheTtlMs: Number.parseInt(process.env.ACA_CAPABILITY_CACHE_TTL_MS || "45000", 10),
4678
+ });
4679
+
4680
+ const handleAcaApi = createAcaApiHandler({
4681
+ PORTAL_PORT,
4682
+ ACA_BASE_URL,
4683
+ getAcaToken,
4684
+ sendJson,
4685
+ });
4686
+
4687
+ const handleControlPanelConfig = createControlPanelConfigHandler({
4688
+ CONTROL_PANEL_CONFIG_FILE,
4689
+ TANDEM_CONTROL_PANEL_STATE_DIR: process.env.TANDEM_CONTROL_PANEL_STATE_DIR || "",
4690
+ CONTROL_PANEL_MODE,
4691
+ ACA_BASE_URL,
4692
+ PROBE_TIMEOUT_MS: Number.parseInt(process.env.ACA_PROBE_TIMEOUT_MS || "5000", 10),
4693
+ getAcaToken,
4694
+ sendJson,
4695
+ readJsonBody,
4696
+ });
4697
+
4522
4698
  async function handleApi(req, res) {
4523
4699
  const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
4524
4700
 
@@ -4534,6 +4710,26 @@ async function handleApi(req, res) {
4534
4710
  return true;
4535
4711
  }
4536
4712
 
4713
+ if (pathname === "/api/capabilities" && req.method === "GET") {
4714
+ await handleCapabilities(req, res);
4715
+ return true;
4716
+ }
4717
+
4718
+ if (pathname === "/api/capabilities/metrics" && req.method === "GET") {
4719
+ sendJson(res, 200, getCapabilitiesMetrics());
4720
+ return true;
4721
+ }
4722
+
4723
+ if (pathname === "/api/install/profile" && req.method === "GET") {
4724
+ await handleCapabilities(req, res);
4725
+ return true;
4726
+ }
4727
+
4728
+ if (pathname === "/api/system/orchestrator-metrics" && req.method === "GET") {
4729
+ sendJson(res, 200, getOrchestratorMetrics());
4730
+ return true;
4731
+ }
4732
+
4537
4733
  if (pathname === "/api/system/search-settings" && req.method === "GET") {
4538
4734
  const session = requireSession(req, res);
4539
4735
  if (!session) return true;
@@ -4557,6 +4753,49 @@ async function handleApi(req, res) {
4557
4753
  return true;
4558
4754
  }
4559
4755
 
4756
+ if (pathname === "/api/system/search-settings/test" && req.method === "POST") {
4757
+ const session = requireSession(req, res);
4758
+ if (!session) return true;
4759
+ try {
4760
+ const payload = await readJsonBody(req);
4761
+ const query = String(payload?.query || "").trim();
4762
+ const limitRaw = Number.parseInt(String(payload?.limit || "5"), 10);
4763
+ const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 10) : 5;
4764
+ if (!query) {
4765
+ sendJson(res, 400, { ok: false, error: "Search query is required." });
4766
+ return true;
4767
+ }
4768
+ const result = await executeEngineTool(session.token, "websearch", {
4769
+ query,
4770
+ limit,
4771
+ });
4772
+ const output = String(result?.output || "");
4773
+ let parsedOutput = null;
4774
+ try {
4775
+ parsedOutput = output ? JSON.parse(output) : null;
4776
+ } catch {
4777
+ parsedOutput = null;
4778
+ }
4779
+ const markdown = parsedOutput
4780
+ ? buildSearchTestMarkdown(parsedOutput)
4781
+ : `# Websearch test\n\n## Output\n\n\`\`\`\n${output || "No output returned."}\n\`\`\``;
4782
+ sendJson(res, 200, {
4783
+ ok: true,
4784
+ query,
4785
+ markdown,
4786
+ output,
4787
+ parsed_output: parsedOutput,
4788
+ metadata: result?.metadata || {},
4789
+ });
4790
+ } catch (error) {
4791
+ sendJson(res, Number(error?.statusCode || 500), {
4792
+ ok: false,
4793
+ error: error instanceof Error ? error.message : String(error),
4794
+ });
4795
+ }
4796
+ return true;
4797
+ }
4798
+
4560
4799
  if (pathname === "/api/auth/login" && req.method === "POST") {
4561
4800
  await handleAuthLogin(req, res);
4562
4801
  return true;
@@ -4592,12 +4831,24 @@ async function handleApi(req, res) {
4592
4831
  return true;
4593
4832
  }
4594
4833
 
4834
+ if (pathname === "/api/control-panel/config" && (req.method === "GET" || req.method === "PATCH")) {
4835
+ const session = requireSession(req, res);
4836
+ if (!session) return true;
4837
+ return handleControlPanelConfig(req, res);
4838
+ }
4839
+
4595
4840
  if (pathname.startsWith("/api/swarm") || pathname.startsWith("/api/orchestrator")) {
4596
4841
  const session = requireSession(req, res);
4597
4842
  if (!session) return true;
4598
4843
  return handleSwarmApi(req, res, session);
4599
4844
  }
4600
4845
 
4846
+ if (pathname.startsWith("/api/aca")) {
4847
+ const session = requireSession(req, res);
4848
+ if (!session) return true;
4849
+ return handleAcaApi(req, res);
4850
+ }
4851
+
4601
4852
  if (pathname.startsWith("/api/files")) {
4602
4853
  const session = requireSession(req, res);
4603
4854
  if (!session) return true;