@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/dist/index.html CHANGED
@@ -11,13 +11,13 @@
11
11
  href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Rubik:wght@500;700;800&family=Manrope:wght@400;500;600;700&display=swap"
12
12
  rel="stylesheet"
13
13
  />
14
- <script type="module" crossorigin src="/assets/index-DAtDe1Vc.js"></script>
15
- <link rel="modulepreload" crossorigin href="/assets/preact-vendor-jo0muZ28.js">
16
- <link rel="modulepreload" crossorigin href="/assets/vendor-BB3fzNns.js">
17
- <link rel="modulepreload" crossorigin href="/assets/react-query-wD0mx2Xi.js">
18
- <link rel="modulepreload" crossorigin href="/assets/motion-BCvrfAt1.js">
14
+ <script type="module" crossorigin src="/assets/index-DtaAHVxs.js"></script>
15
+ <link rel="modulepreload" crossorigin href="/assets/preact-vendor-CWXGD9A4.js">
16
+ <link rel="modulepreload" crossorigin href="/assets/vendor-Q0KoFXrG.js">
17
+ <link rel="modulepreload" crossorigin href="/assets/react-query-BiFBqyAt.js">
18
+ <link rel="modulepreload" crossorigin href="/assets/motion-m8lxAefi.js">
19
19
  <link rel="modulepreload" crossorigin href="/assets/markdown-DMcD1LHz.js">
20
- <link rel="stylesheet" crossorigin href="/assets/index-DzX1-UXX.css">
20
+ <link rel="stylesheet" crossorigin href="/assets/index-BsS1Z15-.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="app"></div>
@@ -0,0 +1,196 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
+ import { mkdir } from "fs/promises";
3
+ import { dirname, resolve } from "path";
4
+
5
+ const DEFAULT_CONTROL_PANEL_CONFIG = {
6
+ version: 1,
7
+ control_panel: {
8
+ mode: "auto",
9
+ aca_compact_nav: true,
10
+ },
11
+ agent: {
12
+ name: "ACA",
13
+ dry_run: false,
14
+ },
15
+ tandem: {
16
+ base_url: "http://127.0.0.1:39733",
17
+ token_env: "TANDEM_API_TOKEN",
18
+ token_file: "secrets/tandem_api_token",
19
+ required_version: "",
20
+ startup_mode: "reuse_or_start",
21
+ update_policy: "notify",
22
+ engine_command: "scripts/tandem-engine-serve.sh",
23
+ },
24
+ task_source: {
25
+ type: "kanban_board",
26
+ owner: "",
27
+ repo: "",
28
+ project: "",
29
+ item: "",
30
+ url: "",
31
+ path: "config/board.yaml",
32
+ prompt: "",
33
+ source_name: "",
34
+ card_id: "",
35
+ payload: {},
36
+ },
37
+ repository: {
38
+ path: "",
39
+ slug: "",
40
+ clone_url: "",
41
+ default_branch: "main",
42
+ worktree_root: "",
43
+ remote_name: "origin",
44
+ },
45
+ provider: {
46
+ id: "openai",
47
+ model: "gpt-4.1-mini",
48
+ base_url: "",
49
+ fallback_provider: "",
50
+ fallback_model: "",
51
+ },
52
+ execution: {
53
+ backend: "auto",
54
+ },
55
+ swarm: {
56
+ enabled: false,
57
+ shared_model: false,
58
+ max_workers: 3,
59
+ max_retries: 1,
60
+ manager: { provider: "", model: "" },
61
+ worker: { provider: "", model: "" },
62
+ reviewer: { provider: "", model: "" },
63
+ tester: { provider: "", model: "" },
64
+ },
65
+ output: {
66
+ root: "runs",
67
+ },
68
+ github_mcp: {
69
+ enabled: true,
70
+ url: "https://api.githubcopilot.com/mcp/",
71
+ toolsets: "default,projects",
72
+ scope: "intake_finalize",
73
+ remote_sync: "status_comment",
74
+ },
75
+ };
76
+
77
+ function deepMerge(base, overlay) {
78
+ if (Array.isArray(base) && Array.isArray(overlay)) {
79
+ return overlay.slice();
80
+ }
81
+ if (
82
+ base &&
83
+ overlay &&
84
+ typeof base === "object" &&
85
+ typeof overlay === "object" &&
86
+ !Array.isArray(base) &&
87
+ !Array.isArray(overlay)
88
+ ) {
89
+ const out = { ...base };
90
+ for (const [key, value] of Object.entries(overlay)) {
91
+ out[key] = key in out ? deepMerge(out[key], value) : value;
92
+ }
93
+ return out;
94
+ }
95
+ return overlay === undefined ? base : overlay;
96
+ }
97
+
98
+ function normalizeControlPanelConfig(raw = {}) {
99
+ const input = raw && typeof raw === "object" ? raw : {};
100
+ return deepMerge(DEFAULT_CONTROL_PANEL_CONFIG, input);
101
+ }
102
+
103
+ function resolveControlPanelConfigPath(options = {}) {
104
+ const env = options.env || process.env;
105
+ const explicit = String(
106
+ options.explicitPath || env.TANDEM_CONTROL_PANEL_CONFIG_FILE || ""
107
+ ).trim();
108
+ if (explicit) return resolve(explicit);
109
+ const stateDir = String(
110
+ options.stateDir || env.TANDEM_CONTROL_PANEL_STATE_DIR || ""
111
+ ).trim();
112
+ const fallbackStateDir = stateDir || resolve(process.cwd(), "tandem-data", "control-panel");
113
+ return resolve(fallbackStateDir, "control-panel-config.json");
114
+ }
115
+
116
+ function readControlPanelConfig(pathname, fallback = DEFAULT_CONTROL_PANEL_CONFIG) {
117
+ const target = String(pathname || "").trim();
118
+ if (!target || !existsSync(target)) {
119
+ return normalizeControlPanelConfig(fallback);
120
+ }
121
+ try {
122
+ const raw = JSON.parse(readFileSync(target, "utf8"));
123
+ return normalizeControlPanelConfig(raw);
124
+ } catch {
125
+ return normalizeControlPanelConfig(fallback);
126
+ }
127
+ }
128
+
129
+ async function writeControlPanelConfig(pathname, payload) {
130
+ const target = resolve(String(pathname || "").trim());
131
+ const data = normalizeControlPanelConfig(payload);
132
+ await mkdir(dirname(target), { recursive: true });
133
+ writeFileSync(target, `${JSON.stringify(data, null, 2)}\n`, "utf8");
134
+ return { path: target, config: data };
135
+ }
136
+
137
+ function resolveControlPanelMode({
138
+ config,
139
+ envMode,
140
+ acaAvailable,
141
+ } = {}) {
142
+ const normalizedEnvMode = String(envMode || "").trim().toLowerCase();
143
+ const configMode = String(config?.control_panel?.mode || "").trim().toLowerCase();
144
+ const explicitMode = ["aca", "standalone"].includes(normalizedEnvMode)
145
+ ? normalizedEnvMode
146
+ : "";
147
+ const requestedMode = explicitMode || configMode;
148
+ if (requestedMode === "aca" || requestedMode === "standalone") {
149
+ return {
150
+ mode: requestedMode,
151
+ source: explicitMode ? "env" : "config",
152
+ reason: explicitMode ? `forced via TANDEM_CONTROL_PANEL_MODE=${requestedMode}` : "",
153
+ };
154
+ }
155
+ return {
156
+ mode: acaAvailable ? "aca" : "standalone",
157
+ source: "detected",
158
+ reason: acaAvailable
159
+ ? "ACA integration detected on startup."
160
+ : "ACA integration not detected; using the standalone setup profile.",
161
+ };
162
+ }
163
+
164
+ function summarizeControlPanelConfig(config) {
165
+ const normalized = normalizeControlPanelConfig(config);
166
+ const missing = [];
167
+ if (
168
+ !String(normalized.repository.path || "").trim() &&
169
+ !String(normalized.repository.slug || "").trim() &&
170
+ !String(normalized.repository.clone_url || "").trim()
171
+ ) {
172
+ missing.push("repository");
173
+ }
174
+ if (!String(normalized.task_source.type || "").trim()) {
175
+ missing.push("task_source");
176
+ }
177
+ if (!String(normalized.provider.id || "").trim() || !String(normalized.provider.model || "").trim()) {
178
+ missing.push("provider");
179
+ }
180
+ return {
181
+ ...normalized,
182
+ missing,
183
+ ready: missing.length === 0,
184
+ };
185
+ }
186
+
187
+ export {
188
+ DEFAULT_CONTROL_PANEL_CONFIG,
189
+ deepMerge,
190
+ normalizeControlPanelConfig,
191
+ readControlPanelConfig,
192
+ resolveControlPanelConfigPath,
193
+ resolveControlPanelMode,
194
+ summarizeControlPanelConfig,
195
+ writeControlPanelConfig,
196
+ };
package/lib/setup/env.js CHANGED
@@ -70,7 +70,7 @@ function bootstrapDefaults(paths) {
70
70
  TANDEM_DISABLE_TOOL_GUARD_BUDGETS: "1",
71
71
  TANDEM_TOOL_ROUTER_ENABLED: "0",
72
72
  TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS: "5000",
73
- TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS: "30000",
73
+ TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS: "90000",
74
74
  TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS: "90000",
75
75
  TANDEM_PERMISSION_WAIT_TIMEOUT_MS: "15000",
76
76
  TANDEM_TOOL_EXEC_TIMEOUT_MS: "45000",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frumu/tandem-panel",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "Full web control center for Tandem Engine (chat, routines, swarm, memory, channels, and ops)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "docker:token": "node bin/docker-token.js",
24
24
  "prepublishOnly": "npm run build",
25
25
  "start": "node bin/cli.js run",
26
+ "agents:catalog:refresh": "node ../../scripts/generate-agent-catalog.mjs",
26
27
  "mcp:catalog:refresh": "node ../../scripts/generate-mcp-catalog.mjs"
27
28
  },
28
29
  "files": [
@@ -30,6 +31,7 @@
30
31
  "lib",
31
32
  "server",
32
33
  "dist",
34
+ "src/generated",
33
35
  ".env.example",
34
36
  "README.md"
35
37
  ],
@@ -37,11 +39,15 @@
37
39
  "type": "git",
38
40
  "url": "git+https://github.com/frumu-ai/tandem.git"
39
41
  },
40
- "homepage": "https://tandem.frumu.ai",
42
+ "homepage": "https://tandem.ac",
41
43
  "author": "Frumu Ltd",
42
44
  "dependencies": {
43
- "@frumu/tandem": "^0.4.13",
44
- "@frumu/tandem-client": "^0.4.13",
45
+ "@fullcalendar/core": "6.1.20",
46
+ "@fullcalendar/interaction": "6.1.20",
47
+ "@fullcalendar/react": "6.1.20",
48
+ "@fullcalendar/timegrid": "6.1.20",
49
+ "@frumu/tandem": "^0.4.17",
50
+ "@frumu/tandem-client": "^0.4.17",
45
51
  "@tanstack/react-query": "^5.90.21",
46
52
  "dompurify": "^3.3.1",
47
53
  "lucide": "^0.575.0",
@@ -0,0 +1,97 @@
1
+ function copyRequestHeaders(req) {
2
+ const headers = new Headers();
3
+ for (const [key, value] of Object.entries(req.headers || {})) {
4
+ if (!value) continue;
5
+ const lower = key.toLowerCase();
6
+ if (["host", "content-length", "cookie", "authorization"].includes(lower)) {
7
+ continue;
8
+ }
9
+ if (Array.isArray(value)) headers.set(key, value.join(", "));
10
+ else headers.set(key, value);
11
+ }
12
+ return headers;
13
+ }
14
+
15
+ export function createAcaApiHandler(deps) {
16
+ const { PORTAL_PORT, ACA_BASE_URL, getAcaToken, sendJson } = deps;
17
+
18
+ return async function handleAcaApi(req, res) {
19
+ const baseUrl = String(ACA_BASE_URL || "").trim().replace(/\/+$/, "");
20
+ if (!baseUrl) {
21
+ sendJson(res, 503, {
22
+ ok: false,
23
+ error: "ACA integration is not configured. Set ACA_BASE_URL to enable ACA-backed coding.",
24
+ });
25
+ return true;
26
+ }
27
+
28
+ const incoming = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`);
29
+ const targetPath = incoming.pathname.replace(/^\/api\/aca/, "") || "/";
30
+ const targetUrl = `${baseUrl}${targetPath}${incoming.search}`;
31
+ const token = String(getAcaToken?.() || "aca-proxy").trim();
32
+ const needsAuth = targetPath !== "/health";
33
+
34
+ const headers = copyRequestHeaders(req);
35
+ if (needsAuth && token) headers.set("authorization", `Bearer ${token}`);
36
+ if (!headers.has("accept")) headers.set("accept", "*/*");
37
+
38
+ const hasBody = !["GET", "HEAD"].includes(req.method || "GET");
39
+
40
+ let upstream;
41
+ try {
42
+ upstream = await fetch(targetUrl, {
43
+ method: req.method,
44
+ headers,
45
+ body: hasBody ? req : undefined,
46
+ duplex: hasBody ? "half" : undefined,
47
+ });
48
+ } catch (error) {
49
+ sendJson(res, 502, {
50
+ ok: false,
51
+ error: `ACA unreachable: ${error instanceof Error ? error.message : String(error)}`,
52
+ });
53
+ return true;
54
+ }
55
+
56
+ const responseHeaders = {};
57
+ upstream.headers.forEach((value, key) => {
58
+ const lower = key.toLowerCase();
59
+ if (["content-encoding", "transfer-encoding", "connection"].includes(lower)) return;
60
+ responseHeaders[key] = value;
61
+ });
62
+
63
+ try {
64
+ res.writeHead(upstream.status, responseHeaders);
65
+ if (!upstream.body) {
66
+ res.end();
67
+ return true;
68
+ }
69
+ for await (const chunk of upstream.body) {
70
+ if (res.writableEnded || res.destroyed) break;
71
+ res.write(chunk);
72
+ }
73
+ if (!res.writableEnded && !res.destroyed) {
74
+ res.end();
75
+ }
76
+ } catch (error) {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ if (res.headersSent) {
79
+ const lower = message.toLowerCase();
80
+ if (lower.includes("terminated") || lower.includes("aborted")) {
81
+ if (!res.writableEnded && !res.destroyed) res.end();
82
+ return true;
83
+ }
84
+ if (!res.destroyed && !res.writableEnded) {
85
+ res.destroy(error instanceof Error ? error : undefined);
86
+ }
87
+ return true;
88
+ }
89
+ sendJson(res, 502, {
90
+ ok: false,
91
+ error: `ACA proxy stream failed: ${message}`,
92
+ });
93
+ }
94
+
95
+ return true;
96
+ };
97
+ }
@@ -27,18 +27,25 @@ function logCapabilityTransition(next) {
27
27
  const prev = _lastReported;
28
28
  const ts = new Date().toISOString();
29
29
  if (prev.aca_available !== next.aca_integration) {
30
- console.log(`[Capabilities] ${ts} ACA integration: ${prev.aca_available ?? "unknown"} → ${next.aca_integration} (reason: ${next.aca_reason || "n/a"})`);
30
+ console.log(
31
+ `[Capabilities] ${ts} ACA integration: ${prev.aca_available ?? "unknown"} → ${next.aca_integration} (reason: ${next.aca_reason || "n/a"})`
32
+ );
31
33
  }
32
34
  if (prev.engine_healthy !== next.engine_healthy) {
33
- console.log(`[Capabilities] ${ts} Engine healthy: ${prev.engine_healthy ?? "unknown"} → ${next.engine_healthy}`);
35
+ console.log(
36
+ `[Capabilities] ${ts} Engine healthy: ${prev.engine_healthy ?? "unknown"} → ${next.engine_healthy}`
37
+ );
34
38
  }
35
39
  _lastReported = { aca_available: next.aca_integration, engine_healthy: next.engine_healthy };
36
40
  }
37
41
 
38
42
  function incrementProbeError(reason) {
39
- const bucket = reason in _metrics.aca_probe_error_counts
40
- ? reason
41
- : reason.match(/^aca_health_failed_\d+$/) ? "aca_health_failed_xxx" : null;
43
+ const bucket =
44
+ reason in _metrics.aca_probe_error_counts
45
+ ? reason
46
+ : reason.match(/^aca_health_failed_\d+$/)
47
+ ? "aca_health_failed_xxx"
48
+ : null;
42
49
  if (bucket) {
43
50
  _metrics.aca_probe_error_counts[bucket] += 1;
44
51
  }
@@ -49,6 +56,8 @@ export function createCapabilitiesHandler(deps) {
49
56
  PROBE_TIMEOUT_MS = 5_000,
50
57
  ACA_BASE_URL,
51
58
  ACA_HEALTH_PATH = "/health",
59
+ getAcaToken,
60
+ getInstallProfile,
52
61
  engineHealth,
53
62
  cacheTtlMs = DEFAULT_CAPABILITY_CACHE_TTL_MS,
54
63
  } = deps;
@@ -60,13 +69,17 @@ export function createCapabilitiesHandler(deps) {
60
69
  return { available: false, reason: "aca_not_configured" };
61
70
  }
62
71
  const target = `${base.replace(/\/+$/, "")}${ACA_HEALTH_PATH}`;
72
+ const token = String(getAcaToken?.() || "").trim();
63
73
  const controller = new AbortController();
64
74
  const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
65
75
  try {
66
76
  const res = await fetch(target, {
67
77
  method: "GET",
68
78
  signal: controller.signal,
69
- headers: { Accept: "application/json" },
79
+ headers: {
80
+ Accept: "application/json",
81
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
82
+ },
70
83
  });
71
84
  clearTimeout(timer);
72
85
  if (res.ok) return { available: true, reason: "" };
@@ -88,18 +101,23 @@ export function createCapabilitiesHandler(deps) {
88
101
  }
89
102
  }
90
103
 
91
- async function probeEngineFeatures(engineOk) {
92
- if (!engineOk) {
104
+ async function probeEngineFeatures(engineOk, acaOk) {
105
+ if (!engineOk && !acaOk) {
93
106
  return { coding_workflows: false, missions: false, agent_teams: false, coder: false };
94
107
  }
95
108
  return {
96
- coding_workflows: true,
109
+ coding_workflows: engineOk || acaOk,
97
110
  missions: true,
98
111
  agent_teams: true,
99
- coder: true,
112
+ coder: engineOk,
100
113
  };
101
114
  }
102
115
 
116
+ function engineIsHealthy(health) {
117
+ const engine = health?.engine && typeof health.engine === "object" ? health.engine : health;
118
+ return !!(engine?.ready || engine?.healthy);
119
+ }
120
+
103
121
  return async function handleCapabilities(req, res) {
104
122
  const now = Date.now();
105
123
  if (_cache.value && now < _cache.expiresAt) {
@@ -109,10 +127,22 @@ export function createCapabilitiesHandler(deps) {
109
127
 
110
128
  const t0 = Date.now();
111
129
  const health = await engineHealth().catch(() => null);
112
- const engineOk = !!(health?.engine?.ready || health?.engine?.healthy);
130
+ const engineOk = engineIsHealthy(health);
113
131
  const aca = await probeAca();
114
- const features = await probeEngineFeatures(engineOk);
132
+ const features = await probeEngineFeatures(engineOk, aca.available);
115
133
  const durationMs = Date.now() - t0;
134
+ let installProfile = null;
135
+ if (typeof getInstallProfile === "function") {
136
+ try {
137
+ installProfile = await getInstallProfile({
138
+ acaAvailable: aca.available,
139
+ engineHealthy: engineOk,
140
+ acaReason: aca.reason,
141
+ });
142
+ } catch {
143
+ installProfile = null;
144
+ }
145
+ }
116
146
 
117
147
  _metrics.detect_duration_ms = durationMs;
118
148
  _metrics.detect_ok = true;
@@ -127,6 +157,15 @@ export function createCapabilitiesHandler(deps) {
127
157
  coder: features.coder,
128
158
  engine_healthy: engineOk,
129
159
  cached_at_ms: now,
160
+ control_panel_mode: installProfile?.control_panel_mode || (aca.available ? "aca" : "standalone"),
161
+ control_panel_mode_source: installProfile?.control_panel_mode_source || "detected",
162
+ control_panel_mode_reason: installProfile?.control_panel_mode_reason || "",
163
+ control_panel_config_path: installProfile?.control_panel_config_path || "",
164
+ control_panel_config_ready: !!installProfile?.control_panel_config_ready,
165
+ control_panel_config_missing: Array.isArray(installProfile?.control_panel_config_missing)
166
+ ? installProfile.control_panel_config_missing
167
+ : [],
168
+ control_panel_compact_nav: !!installProfile?.control_panel_compact_nav,
130
169
  _internal: {
131
170
  capability_detect_duration_ms: durationMs,
132
171
  },
@@ -0,0 +1,106 @@
1
+ import {
2
+ readControlPanelConfig,
3
+ resolveControlPanelConfigPath,
4
+ resolveControlPanelMode,
5
+ summarizeControlPanelConfig,
6
+ writeControlPanelConfig,
7
+ } from "../../lib/setup/control-panel-config.js";
8
+
9
+ export function createControlPanelConfigHandler(deps) {
10
+ const { CONTROL_PANEL_CONFIG_FILE, CONTROL_PANEL_MODE, ACA_BASE_URL, getAcaToken, sendJson } =
11
+ deps;
12
+
13
+ function getConfigPath() {
14
+ return resolveControlPanelConfigPath({
15
+ env: {
16
+ TANDEM_CONTROL_PANEL_CONFIG_FILE: CONTROL_PANEL_CONFIG_FILE,
17
+ TANDEM_CONTROL_PANEL_STATE_DIR: deps.TANDEM_CONTROL_PANEL_STATE_DIR,
18
+ },
19
+ explicitPath: CONTROL_PANEL_CONFIG_FILE,
20
+ stateDir: deps.TANDEM_CONTROL_PANEL_STATE_DIR,
21
+ });
22
+ }
23
+
24
+ async function loadInstallProfile() {
25
+ const configPath = getConfigPath();
26
+ const config = readControlPanelConfig(configPath);
27
+ const baseUrl = String(ACA_BASE_URL || "").trim();
28
+ const token = String(getAcaToken?.() || "").trim();
29
+ let acaAvailable = false;
30
+ let acaReason = "aca_not_configured";
31
+ if (baseUrl) {
32
+ try {
33
+ const controller = new AbortController();
34
+ const timer = setTimeout(() => controller.abort(), Number(deps.PROBE_TIMEOUT_MS || 5000));
35
+ const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/health`, {
36
+ method: "GET",
37
+ signal: controller.signal,
38
+ headers: {
39
+ Accept: "application/json",
40
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
41
+ },
42
+ });
43
+ clearTimeout(timer);
44
+ acaAvailable = !!res.ok;
45
+ acaReason = res.ok ? "" : `aca_health_failed_${res.status}`;
46
+ } catch (error) {
47
+ acaReason = String(error?.message || error || "aca_probe_error");
48
+ }
49
+ }
50
+ const mode = resolveControlPanelMode({
51
+ config,
52
+ envMode: CONTROL_PANEL_MODE,
53
+ acaAvailable,
54
+ });
55
+ const summary = summarizeControlPanelConfig(config);
56
+ return {
57
+ ok: true,
58
+ control_panel_mode: mode.mode,
59
+ control_panel_mode_source: mode.source,
60
+ control_panel_mode_reason: mode.reason || "",
61
+ aca_integration: acaAvailable,
62
+ aca_reason: acaReason,
63
+ control_panel_config_path: configPath,
64
+ control_panel_config_ready: summary.ready,
65
+ control_panel_config_missing: summary.missing,
66
+ control_panel_compact_nav: !!summary.control_panel?.aca_compact_nav,
67
+ config: summary,
68
+ };
69
+ }
70
+
71
+ return async function handleControlPanelConfig(req, res) {
72
+ const incoming = new URL(req.url, "http://127.0.0.1");
73
+ if (incoming.pathname === "/api/install/profile" && req.method === "GET") {
74
+ const payload = await loadInstallProfile();
75
+ sendJson(res, 200, payload);
76
+ return true;
77
+ }
78
+
79
+ if (incoming.pathname === "/api/control-panel/config" && req.method === "GET") {
80
+ const configPath = getConfigPath();
81
+ const config = readControlPanelConfig(configPath);
82
+ sendJson(res, 200, {
83
+ ok: true,
84
+ path: configPath,
85
+ config,
86
+ summary: summarizeControlPanelConfig(config),
87
+ });
88
+ return true;
89
+ }
90
+
91
+ if (incoming.pathname === "/api/control-panel/config" && req.method === "PATCH") {
92
+ const configPath = getConfigPath();
93
+ const payload = await deps.readJsonBody(req);
94
+ const saved = await writeControlPanelConfig(configPath, payload?.config || payload);
95
+ sendJson(res, 200, {
96
+ ok: true,
97
+ path: saved.path,
98
+ config: saved.config,
99
+ summary: summarizeControlPanelConfig(saved.config),
100
+ });
101
+ return true;
102
+ }
103
+
104
+ return false;
105
+ };
106
+ }