@frumu/tandem-panel 0.4.15 → 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
@@ -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,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,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,6 +186,11 @@ 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
195
  process.env.TANDEM_SEARCH_URL || "https://search.tandem.frumu.ai"
183
196
  ).replace(/\/+$/, "");
@@ -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(
@@ -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
 
@@ -1143,6 +1171,54 @@ function sendJson(res, code, payload) {
1143
1171
  res.end(body);
1144
1172
  }
1145
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
+
1146
1222
  function pushSwarmEvent(kind, payload = {}) {
1147
1223
  const event = {
1148
1224
  kind,
@@ -1188,6 +1264,73 @@ async function engineHealth(token = "") {
1188
1264
  }
1189
1265
  }
1190
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
+
1191
1334
  async function validateEngineToken(token) {
1192
1335
  try {
1193
1336
  const response = await fetch(`${ENGINE_URL}/config/providers`, {
@@ -1225,7 +1368,7 @@ async function ensureEngineRunning() {
1225
1368
  engineEntrypoint = require.resolve("@frumu/tandem/bin/tandem-engine.js");
1226
1369
  } catch (e) {
1227
1370
  err("Could not resolve @frumu/tandem binary entrypoint.");
1228
- err("Reinstall with: npm i -g @frumu/tandem-panel");
1371
+ err("Reinstall with: npm i -g @frumu/tandem");
1229
1372
  throw e;
1230
1373
  }
1231
1374
 
@@ -4522,8 +4665,10 @@ const handleSwarmApi = createSwarmApiHandler({
4522
4665
 
4523
4666
  const handleCapabilities = createCapabilitiesHandler({
4524
4667
  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",
4668
+ ACA_BASE_URL,
4669
+ ACA_HEALTH_PATH: process.env.ACA_HEALTH_PATH || "/health",
4670
+ getAcaToken,
4671
+ getInstallProfile,
4527
4672
  engineHealth: async (token) => {
4528
4673
  const health = await engineHealth(token).catch(() => null);
4529
4674
  return health;
@@ -4532,6 +4677,24 @@ const handleCapabilities = createCapabilitiesHandler({
4532
4677
  cacheTtlMs: Number.parseInt(process.env.ACA_CAPABILITY_CACHE_TTL_MS || "45000", 10),
4533
4678
  });
4534
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
+
4535
4698
  async function handleApi(req, res) {
4536
4699
  const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
4537
4700
 
@@ -4557,6 +4720,11 @@ async function handleApi(req, res) {
4557
4720
  return true;
4558
4721
  }
4559
4722
 
4723
+ if (pathname === "/api/install/profile" && req.method === "GET") {
4724
+ await handleCapabilities(req, res);
4725
+ return true;
4726
+ }
4727
+
4560
4728
  if (pathname === "/api/system/orchestrator-metrics" && req.method === "GET") {
4561
4729
  sendJson(res, 200, getOrchestratorMetrics());
4562
4730
  return true;
@@ -4585,6 +4753,49 @@ async function handleApi(req, res) {
4585
4753
  return true;
4586
4754
  }
4587
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
+
4588
4799
  if (pathname === "/api/auth/login" && req.method === "POST") {
4589
4800
  await handleAuthLogin(req, res);
4590
4801
  return true;
@@ -4620,12 +4831,24 @@ async function handleApi(req, res) {
4620
4831
  return true;
4621
4832
  }
4622
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
+
4623
4840
  if (pathname.startsWith("/api/swarm") || pathname.startsWith("/api/orchestrator")) {
4624
4841
  const session = requireSession(req, res);
4625
4842
  if (!session) return true;
4626
4843
  return handleSwarmApi(req, res, session);
4627
4844
  }
4628
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
+
4629
4852
  if (pathname.startsWith("/api/files")) {
4630
4853
  const session = requireSession(req, res);
4631
4854
  if (!session) return true;