@maintainer-pro/ai-bridge 0.1.1 → 0.1.2

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.
Files changed (2) hide show
  1. package/package.json +4 -1
  2. package/src/daemon.mjs +1215 -111
package/src/daemon.mjs CHANGED
@@ -7,18 +7,20 @@
7
7
  * npx @maintainer-pro/ai-bridge
8
8
  * npx @maintainer-pro/ai-bridge --admin-url https://… --pair ABCD-EF01
9
9
  */
10
- import { spawn, execFileSync } from "node:child_process";
10
+ import { spawn } from "node:child_process";
11
11
  import { randomBytes } from "node:crypto";
12
12
  import fs from "node:fs";
13
13
  import http from "node:http";
14
+ import net from "node:net";
14
15
  import os from "node:os";
15
16
  import path from "node:path";
16
17
  import readline from "node:readline";
17
- import { fileURLToPath } from "node:url";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
19
 
19
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
21
  const PACKAGE_VERSION = readPackageVersion();
21
22
  const HEARTBEAT_MS = 15_000;
23
+ const ACTION_POLL_MS = 2_000;
22
24
 
23
25
  const log = (msg) => console.log(`[bridge] ${msg}`);
24
26
  const warn = (msg) => console.warn(`[bridge] ${msg}`);
@@ -85,7 +87,7 @@ Flags:
85
87
  --pair <code> Claim a pair code from admin → Bridges page
86
88
  --admin-url <url> Maintainer Pro base URL (required for first pair)
87
89
  --offer-folder <path> Suggest this folder in admin (repeatable via config)
88
- --no-ai-server Do not start sidecars for workspaces
90
+ --no-ai-server Do not open ai-server terminals for workspaces
89
91
  --help
90
92
  `);
91
93
  }
@@ -161,26 +163,161 @@ async function api(baseUrl, token, method, pathname, body) {
161
163
  return data;
162
164
  }
163
165
 
164
- function commandExists(cmd) {
166
+ /** @type {Promise<typeof import("@maintainer-pro/ai-cli")> | null} */
167
+ let aiCliModule = null;
168
+
169
+ async function loadAiCli() {
170
+ if (!aiCliModule) {
171
+ aiCliModule = (async () => {
172
+ try {
173
+ return await import("@maintainer-pro/ai-cli");
174
+ } catch {
175
+ const local = path.resolve(__dirname, "..", "..", "ai-cli", "dist", "index.js");
176
+ if (fs.existsSync(local)) {
177
+ return await import(pathToFileURL(local).href);
178
+ }
179
+ throw new Error("@maintainer-pro/ai-cli is not installed");
180
+ }
181
+ })();
182
+ }
183
+ return aiCliModule;
184
+ }
185
+
186
+ async function detectCliProviders() {
165
187
  try {
166
- if (process.platform === "win32") {
167
- execFileSync("where", [cmd], { stdio: "ignore" });
188
+ const { resolveProvider } = await loadAiCli();
189
+ const provider = await resolveProvider({ preference: "auto" });
190
+ return [provider.id];
191
+ } catch {
192
+ return [];
193
+ }
194
+ }
195
+
196
+ function applyProjectInfo(ws, info, cfg) {
197
+ if (!ws || !info) return;
198
+ ws.projectInfo = {
199
+ kind: info.kind,
200
+ name: info.name,
201
+ summary: info.summary,
202
+ scripts: info.scripts || {},
203
+ ports: info.ports || {},
204
+ issues: info.issues || [],
205
+ fixes: info.fixes || [],
206
+ ready: Boolean(info.ready),
207
+ provider: info.provider,
208
+ };
209
+ if (info.kind) ws.clientKind = info.kind;
210
+ const uiPort = Number(info.ports?.ui || info.ports?.app);
211
+ if (
212
+ uiPort &&
213
+ (!ws.appUrl || isLocalAppUrl(ws.appUrl))
214
+ ) {
215
+ ws.appUrl = urlWithPort(ws.appUrl || `http://localhost:${uiPort}`, uiPort);
216
+ }
217
+ persistWorkspaceEntry(cfg, ws);
218
+ }
219
+
220
+ async function inspectProjectWithAiCli(ws, opts = {}) {
221
+ const folder = path.resolve(ws.folderPath || "");
222
+ const label = ws.sandboxName || ws.applicationName || "this sandbox";
223
+ try {
224
+ const { inspectAndRepairWorkspace } = await loadAiCli();
225
+ log(`asking ai-cli to inspect ${folder}`);
226
+ const info = await inspectAndRepairWorkspace({
227
+ workspaceDir: folder,
228
+ appName: ws.applicationName || ws.sandboxName || undefined,
229
+ problem: opts.problem,
230
+ extraContext: opts.extraContext,
231
+ });
232
+ applyProjectInfo(ws, info, opts.cfg);
233
+ if (info.issues?.length) {
234
+ recordProcessProblem({
235
+ sandboxId: ws.sandboxId,
236
+ code: "project_issue",
237
+ role: "inspect",
238
+ title: `Project needs attention (${label})`,
239
+ message: info.issues.join(" "),
240
+ resolution: info.fixes?.length
241
+ ? info.fixes.join(" ")
242
+ : "Fix the project in that folder, then use Start chat server.",
243
+ });
168
244
  } else {
169
- execFileSync("which", [cmd], { stdio: "ignore" });
245
+ clearProcessProblem(ws.sandboxId, "project_issue", "inspect");
170
246
  }
171
- return true;
247
+ if (info.summary) log(`ai-cli: ${info.summary}`);
248
+ if (info.fixes?.length) log(`ai-cli fixes: ${info.fixes.join("; ")}`);
249
+ return info;
250
+ } catch (err) {
251
+ const message = err instanceof Error ? err.message : String(err);
252
+ warn(`ai-cli inspect failed: ${message}`);
253
+ recordProcessProblem({
254
+ sandboxId: ws.sandboxId,
255
+ code: "project_issue",
256
+ role: "inspect",
257
+ title: `Could not inspect the project (${label})`,
258
+ message,
259
+ resolution:
260
+ "Install and authenticate a coding-agent CLI (agent, claude, or agy), then Check connection.",
261
+ actionCode: "recheck",
262
+ });
263
+ return null;
264
+ }
265
+ }
266
+
267
+ function isPortFree(port) {
268
+ return new Promise((resolve) => {
269
+ const server = net.createServer();
270
+ server.unref();
271
+ server.once("error", () => resolve(false));
272
+ server.once("listening", () => {
273
+ server.close(() => resolve(true));
274
+ });
275
+ server.listen(port);
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Prefer `preferred` if it is free and not already reserved this launch.
281
+ * Walks upward so bind failures are avoided without a central port service.
282
+ */
283
+ async function findFreePort(preferred = 3100, reserved = new Set()) {
284
+ let port = Math.max(1024, Number(preferred) || 3100);
285
+ while (port <= 49151) {
286
+ if (!reserved.has(port) && (await isPortFree(port))) {
287
+ reserved.add(port);
288
+ return port;
289
+ }
290
+ port += 1;
291
+ }
292
+ throw new Error("No free TCP port found");
293
+ }
294
+
295
+ function urlWithPort(url, port) {
296
+ try {
297
+ const parsed = new URL(url);
298
+ parsed.port = String(port);
299
+ return parsed.toString().replace(/\/$/, "");
172
300
  } catch {
173
- return false;
301
+ return `http://localhost:${port}`;
174
302
  }
175
303
  }
176
304
 
177
- function detectCliProviders() {
178
- /** @type {string[]} */
179
- const found = [];
180
- if (commandExists("agent")) found.push("agent");
181
- if (commandExists("claude")) found.push("claude");
182
- if (commandExists("agy")) found.push("agy");
183
- return found;
305
+ function isLocalAppUrl(url) {
306
+ try {
307
+ const host = new URL(url).hostname;
308
+ return host === "localhost" || host === "127.0.0.1";
309
+ } catch {
310
+ return true;
311
+ }
312
+ }
313
+
314
+ function persistWorkspaceEntry(cfg, ws) {
315
+ if (!cfg || !ws?.sandboxId) return;
316
+ cfg.workspaces = cfg.workspaces || [];
317
+ const index = cfg.workspaces.findIndex((row) => row.sandboxId === ws.sandboxId);
318
+ if (index >= 0) cfg.workspaces[index] = { ...cfg.workspaces[index], ...ws };
319
+ else cfg.workspaces.push(ws);
320
+ saveConfig(cfg);
184
321
  }
185
322
 
186
323
  function probeUrl(url, timeoutMs = 2500) {
@@ -207,10 +344,46 @@ function probeUrl(url, timeoutMs = 2500) {
207
344
  });
208
345
  }
209
346
 
347
+ function listDriveRoots() {
348
+ if (process.platform !== "win32") return ["/"];
349
+ const roots = [];
350
+ for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZAB") {
351
+ const root = `${letter}:\\`;
352
+ try {
353
+ if (fs.existsSync(root)) roots.push(root);
354
+ } catch {
355
+ /* skip */
356
+ }
357
+ }
358
+ return roots.length ? roots : ["C:\\"];
359
+ }
360
+
210
361
  function listDirEntries(dirPath) {
211
- const resolved = path.resolve(dirPath);
362
+ const raw = String(dirPath || "").trim();
363
+ const home = os.homedir();
364
+ if (!raw || raw === "roots") {
365
+ const roots = listDriveRoots();
366
+ return {
367
+ path: "",
368
+ parent: null,
369
+ home,
370
+ entries: roots.map((root) => ({
371
+ name: root,
372
+ path: root,
373
+ isDir: true,
374
+ })),
375
+ };
376
+ }
377
+
378
+ const resolved = path.resolve(raw);
212
379
  if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
213
- return { error: "Not a directory", path: resolved, entries: [] };
380
+ return {
381
+ error: "Not a directory",
382
+ path: resolved,
383
+ parent: path.dirname(resolved),
384
+ home,
385
+ entries: [],
386
+ };
214
387
  }
215
388
  const names = fs.readdirSync(resolved);
216
389
  const entries = [];
@@ -232,7 +405,13 @@ function listDirEntries(dirPath) {
232
405
  if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
233
406
  return a.name.localeCompare(b.name);
234
407
  });
235
- return { path: resolved, entries: entries.slice(0, 200) };
408
+ const parent = path.dirname(resolved);
409
+ return {
410
+ path: resolved,
411
+ parent: parent === resolved ? null : parent,
412
+ home,
413
+ entries: entries.slice(0, 400),
414
+ };
236
415
  }
237
416
 
238
417
  function mergeEnvFile(file, values) {
@@ -665,26 +844,389 @@ function collectOfferedFolders(cfg) {
665
844
  return folders;
666
845
  }
667
846
 
668
- /** @type {Map<string, import('node:child_process').ChildProcess>} */
669
- const children = new Map();
847
+ /** Prevents opening a new window on every heartbeat while a process is starting. */
848
+ const launchedAt = new Map();
849
+
850
+ /** Last process problems to send on heartbeat. Key: sandboxId::code::role */
851
+ const processProblems = new Map();
670
852
 
671
- function stopChild(sandboxId) {
672
- const child = children.get(sandboxId);
673
- if (!child || child.killed) {
674
- children.delete(sandboxId);
675
- return;
853
+ function forgetLaunch(sandboxId) {
854
+ for (const key of [...launchedAt.keys()]) {
855
+ if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
856
+ launchedAt.delete(key);
857
+ }
858
+ }
859
+ for (const key of [...processProblems.keys()]) {
860
+ if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
676
861
  }
862
+ stopCloudflare(sandboxId);
863
+ }
864
+
865
+ function problemKey(sandboxId, code, role = "") {
866
+ return `${sandboxId || ""}::${code}::${role}`;
867
+ }
868
+
869
+ function clipIssueText(text, max) {
870
+ const value = String(text || "").trim();
871
+ if (value.length <= max) return value;
872
+ return `${value.slice(0, max - 1)}…`;
873
+ }
874
+
875
+ function processRoleLabel(role) {
876
+ if (role === "ui") return "app UI";
877
+ if (role === "backend") return "backend";
878
+ if (role === "ai") return "chat script";
879
+ return "app";
880
+ }
881
+
882
+ function recordProcessProblem(issue) {
883
+ const sandboxId = issue.sandboxId || "";
884
+ const role = issue.role || "";
885
+ processProblems.set(problemKey(sandboxId, issue.code, role), {
886
+ code: issue.code,
887
+ role,
888
+ severity: issue.severity || "error",
889
+ title: clipIssueText(issue.title, 200),
890
+ message: clipIssueText(issue.message, 2000),
891
+ resolution: clipIssueText(issue.resolution || "", 2000),
892
+ actionCode: issue.actionCode ?? "start_ai_server",
893
+ sandboxId: sandboxId || null,
894
+ });
895
+ warn(`${issue.title}: ${issue.message}`);
896
+ }
897
+
898
+ function clearProcessProblem(sandboxId, code, role = "") {
899
+ processProblems.delete(problemKey(sandboxId, code, role));
900
+ }
901
+
902
+ function issuesForSandbox(sandboxId) {
903
+ return [...processProblems.values()].filter(
904
+ (issue) => issue.sandboxId === sandboxId
905
+ );
906
+ }
907
+
908
+ function recentlyLaunched(launchKey) {
909
+ const last = launchedAt.get(launchKey) ?? 0;
910
+ return last > 0 && Date.now() - last < 60_000;
911
+ }
912
+
913
+ function readPackageJson(dir) {
677
914
  try {
678
- child.kill("SIGTERM");
915
+ return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
679
916
  } catch {
680
- /* ignore */
917
+ return null;
918
+ }
919
+ }
920
+
921
+ function isChatScript(name, command) {
922
+ const text = `${name} ${command}`;
923
+ return /\b(ai-server|ai-cli|dev:chat|start:chat)\b/i.test(text);
924
+ }
925
+
926
+ function isUiCommand(command) {
927
+ return /\b(vite|next|nuxt|astro|remix|react-scripts|webpack-dev-server|parcel)\b/i.test(
928
+ command
929
+ );
930
+ }
931
+
932
+ function portFromText(text, fallback) {
933
+ const match =
934
+ String(text || "").match(/--port(?:\s+|=)(\d{2,5})/i) ||
935
+ String(text || "").match(/\bPORT[=:]\s*(\d{2,5})/i) ||
936
+ String(text || "").match(/:(\d{2,5})\b/);
937
+ return match ? Number(match[1]) : fallback;
938
+ }
939
+
940
+ /**
941
+ * @returns {Array<{ role: string, script: string, command: string, preferredPort: number, probeUrl: string | null }>}
942
+ */
943
+ function planHostJobs(dir, appUrl, hints = null) {
944
+ const pkg = readPackageJson(dir);
945
+ const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
946
+ const hinted = hints?.scripts && typeof hints.scripts === "object" ? hints.scripts : {};
947
+ const hintedPorts = hints?.ports && typeof hints.ports === "object" ? hints.ports : {};
948
+ const pickHint = (name) =>
949
+ name && scripts[name] && !isChatScript(name, scripts[name]) ? name : null;
950
+ const pick = (names) =>
951
+ names.find((name) => scripts[name] && !isChatScript(name, scripts[name]));
952
+
953
+ const uiScript =
954
+ pickHint(hinted.ui) ||
955
+ pick([
956
+ "dev:client",
957
+ "dev:ui",
958
+ "dev:web",
959
+ "dev:frontend",
960
+ "client",
961
+ "start:client",
962
+ ]);
963
+ const apiScript =
964
+ pickHint(hinted.backend) ||
965
+ pick([
966
+ "dev:server",
967
+ "dev:backend",
968
+ "dev:api",
969
+ "server",
970
+ "start:server",
971
+ ]);
972
+
973
+ /** @type {Array<{ role: string, script: string, command: string, preferredPort: number, probeUrl: string | null }>} */
974
+ const jobs = [];
975
+ const addJob = (role, script, fallbackPort, probe) => {
976
+ if (!script || jobs.some((job) => job.script === script)) return;
977
+ const port = portFromText(scripts[script] || probe, fallbackPort);
978
+ jobs.push({
979
+ role,
980
+ script,
981
+ command: `npm run ${script}`,
982
+ preferredPort: port,
983
+ probeUrl: probe || `http://127.0.0.1:${port}`,
984
+ });
985
+ };
986
+
987
+ addJob("ui", uiScript, Number(hintedPorts.ui) || 5173, appUrl || null);
988
+ addJob("backend", apiScript, Number(hintedPorts.backend) || 4100, null);
989
+ if (jobs.length === 0 && pickHint(hinted.app)) {
990
+ addJob(
991
+ isUiCommand(scripts[hinted.app]) ? "ui" : "app",
992
+ hinted.app,
993
+ Number(hintedPorts.app) || 3000,
994
+ appUrl || null
995
+ );
996
+ }
997
+
998
+ if (scripts.dev && !isChatScript("dev", scripts.dev)) {
999
+ const nested = [
1000
+ ...String(scripts.dev).matchAll(/npm run ([a-zA-Z0-9:_-]+)/g),
1001
+ ]
1002
+ .map((match) => match[1])
1003
+ .filter(
1004
+ (name) =>
1005
+ name !== "dev" && scripts[name] && !isChatScript(name, scripts[name])
1006
+ );
1007
+ if (nested.length >= 2) {
1008
+ for (const name of nested) {
1009
+ const role = /client|ui|web|frontend/i.test(name)
1010
+ ? "ui"
1011
+ : /server|backend|api/i.test(name)
1012
+ ? "backend"
1013
+ : name;
1014
+ addJob(role, name, 3000, role === "ui" ? appUrl || null : null);
1015
+ }
1016
+ } else if (jobs.length === 0) {
1017
+ addJob(isUiCommand(scripts.dev) ? "ui" : "app", "dev", 3000, appUrl || null);
1018
+ }
1019
+ }
1020
+ return jobs;
1021
+ }
1022
+
1023
+ function friendlyLaunchError(raw, title) {
1024
+ const text = String(raw || "").trim();
1025
+ if (/ENOENT/i.test(text)) {
1026
+ return `Could not find a program to open a terminal for "${title}". ${text}`;
1027
+ }
1028
+ return text || `Could not open a terminal for "${title}".`;
1029
+ }
1030
+
1031
+ function runLauncher(command, args, extra = {}) {
1032
+ return new Promise((resolve) => {
1033
+ let settled = false;
1034
+ const done = (result) => {
1035
+ if (settled) return;
1036
+ settled = true;
1037
+ resolve(result);
1038
+ };
1039
+ let child;
1040
+ try {
1041
+ child = spawn(command, args, {
1042
+ stdio: ["ignore", "pipe", "pipe"],
1043
+ windowsHide: extra.windowsHide,
1044
+ });
1045
+ } catch (err) {
1046
+ done({
1047
+ ok: false,
1048
+ error: err instanceof Error ? err.message : String(err),
1049
+ });
1050
+ return;
1051
+ }
1052
+ let stderr = "";
1053
+ child.stderr.on("data", (chunk) => {
1054
+ stderr += String(chunk);
1055
+ });
1056
+ child.once("error", (err) => {
1057
+ done({ ok: false, error: err.message });
1058
+ });
1059
+ child.once("exit", (code) => {
1060
+ if (code === 0 || code === null) done({ ok: true });
1061
+ else {
1062
+ const detail = stderr.trim().replace(/\s+/g, " ");
1063
+ done({
1064
+ ok: false,
1065
+ error: detail || `${command} exited with code ${code}`,
1066
+ });
1067
+ }
1068
+ });
1069
+ child.once("spawn", () => {
1070
+ setTimeout(() => done({ ok: true }), 400);
1071
+ });
1072
+ });
1073
+ }
1074
+
1075
+ async function openInNewTerminal(opts) {
1076
+ const { title, folder, command, env = {}, launchKey } = opts;
1077
+ if (launchKey) {
1078
+ if (recentlyLaunched(launchKey)) return { ok: true, skipped: true };
1079
+ launchedAt.set(launchKey, Date.now());
1080
+ }
1081
+ if (!fs.existsSync(folder)) {
1082
+ const error = `Folder is missing: ${folder}`;
1083
+ warn(`cannot open terminal: ${error}`);
1084
+ return { ok: false, error };
1085
+ }
1086
+
1087
+ const envWin = Object.entries(env)
1088
+ .map(([key, value]) => `set ${key}=${value}`)
1089
+ .join("&& ");
1090
+ const envUnix = Object.entries(env)
1091
+ .map(([key, value]) => `export ${key}=${JSON.stringify(String(value))}`)
1092
+ .join(" && ");
1093
+
1094
+ log(`opening separate terminal [${title}] in ${folder}: ${command}`);
1095
+
1096
+ try {
1097
+ if (process.platform === "win32") {
1098
+ const inner = `cd /d "${folder}" && ${envWin ? `${envWin}&& ` : ""}title ${title}&& ${command}`;
1099
+ const escaped = inner.replace(/'/g, "''");
1100
+ const opened = await runLauncher(
1101
+ "powershell.exe",
1102
+ [
1103
+ "-NoProfile",
1104
+ "-WindowStyle",
1105
+ "Hidden",
1106
+ "-Command",
1107
+ `Start-Process -FilePath $env:ComSpec -WorkingDirectory ${JSON.stringify(folder)} -ArgumentList @('/k', '${escaped}')`,
1108
+ ],
1109
+ { windowsHide: true }
1110
+ );
1111
+ if (!opened.ok) {
1112
+ return { ok: false, error: friendlyLaunchError(opened.error, title) };
1113
+ }
1114
+ return { ok: true };
1115
+ }
1116
+
1117
+ const script = `cd ${JSON.stringify(folder)} && ${envUnix ? `${envUnix} && ` : ""}${command}`;
1118
+ if (process.platform === "darwin") {
1119
+ const opened = await runLauncher("osascript", [
1120
+ "-e",
1121
+ `tell application "Terminal" to do script ${JSON.stringify(script)}`,
1122
+ ]);
1123
+ if (!opened.ok) {
1124
+ return { ok: false, error: friendlyLaunchError(opened.error, title) };
1125
+ }
1126
+ return { ok: true };
1127
+ }
1128
+
1129
+ const opened = await runLauncher(process.env.TERMINAL || "x-terminal-emulator", [
1130
+ "-e",
1131
+ "bash",
1132
+ "-lc",
1133
+ `${script}; exec bash`,
1134
+ ]);
1135
+ if (!opened.ok) {
1136
+ return { ok: false, error: friendlyLaunchError(opened.error, title) };
1137
+ }
1138
+ return { ok: true };
1139
+ } catch (err) {
1140
+ return {
1141
+ ok: false,
1142
+ error: friendlyLaunchError(
1143
+ err instanceof Error ? err.message : String(err),
1144
+ title
1145
+ ),
1146
+ };
1147
+ }
1148
+ }
1149
+
1150
+ function commandWithPort(job, scripts, port) {
1151
+ const raw = String(scripts?.[job.script] || "");
1152
+ if (
1153
+ job.role === "ui" ||
1154
+ job.role === "app" ||
1155
+ isUiCommand(raw) ||
1156
+ /--port\b/i.test(raw)
1157
+ ) {
1158
+ return `${job.command} -- --port ${port}`;
681
1159
  }
682
- children.delete(sandboxId);
1160
+ return job.command;
683
1161
  }
684
1162
 
685
- function startAiServerForWorkspace(ws) {
686
- const key = ws.sandboxId;
687
- if (children.has(key) && !children.get(key)?.killed) return;
1163
+ async function startAiServerForWorkspace(ws, opts = {}) {
1164
+ const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
1165
+ const cfg = opts.cfg || null;
1166
+ const folder = path.resolve(ws.folderPath);
1167
+ const preferred = Number(ws.port) || 3100;
1168
+ const launchKey = `${ws.sandboxId}:${folder}:ai`;
1169
+ const label = ws.sandboxName || "this sandbox";
1170
+
1171
+ if (!fs.existsSync(folder)) {
1172
+ recordProcessProblem({
1173
+ sandboxId: ws.sandboxId,
1174
+ code: "ai_server_launch",
1175
+ role: "ai",
1176
+ title: `Could not start the chat script (${label})`,
1177
+ message: `The project folder is missing: ${folder}`,
1178
+ resolution: "Attach the folder again from Local setup.",
1179
+ });
1180
+ return { port: preferred, up: false, launched: false };
1181
+ }
1182
+
1183
+ if (await probeUrl(`http://127.0.0.1:${preferred}/embed-config.js`)) {
1184
+ reserved.add(preferred);
1185
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1186
+ return { port: preferred, up: true, launched: false };
1187
+ }
1188
+
1189
+ if (recentlyLaunched(launchKey)) {
1190
+ reserved.add(preferred);
1191
+ return { port: preferred, up: false, launched: false, starting: true };
1192
+ }
1193
+
1194
+ let port = preferred;
1195
+ try {
1196
+ if (reserved.has(preferred)) {
1197
+ if (!(await isPortFree(preferred))) {
1198
+ reserved.delete(preferred);
1199
+ port = await findFreePort(preferred, reserved);
1200
+ }
1201
+ } else {
1202
+ port = await findFreePort(preferred, reserved);
1203
+ }
1204
+ } catch (err) {
1205
+ recordProcessProblem({
1206
+ sandboxId: ws.sandboxId,
1207
+ code: "ai_server_launch",
1208
+ role: "ai",
1209
+ title: `Could not start the chat script (${label})`,
1210
+ message: `No free port found (tried from ${preferred}). ${
1211
+ err instanceof Error ? err.message : String(err)
1212
+ }`,
1213
+ resolution: "Close other local servers, then use Start chat server.",
1214
+ });
1215
+ return { port: preferred, up: false, launched: false };
1216
+ }
1217
+ if (port !== preferred) {
1218
+ log(`port ${preferred} busy; using ${port} for ai-server`);
1219
+ ws.port = port;
1220
+ const envPath = path.join(folder, ".env");
1221
+ if (fs.existsSync(envPath)) {
1222
+ mergeEnvFile(envPath, {
1223
+ PORT: String(port),
1224
+ AI_SERVER_URL: `http://localhost:${port}`,
1225
+ NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
1226
+ });
1227
+ }
1228
+ persistWorkspaceEntry(cfg, ws);
1229
+ }
688
1230
 
689
1231
  const localCli = path.resolve(
690
1232
  __dirname,
@@ -695,42 +1237,371 @@ function startAiServerForWorkspace(ws) {
695
1237
  "cli.js"
696
1238
  );
697
1239
  const useLocal = fs.existsSync(localCli);
698
- const env = {
699
- ...process.env,
700
- PORT: String(ws.port),
701
- AI_SERVER_URL: `http://localhost:${ws.port}`,
702
- NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${ws.port}`,
703
- };
1240
+ const run = useLocal
1241
+ ? process.platform === "win32"
1242
+ ? `"${process.execPath}" "${localCli}" --port ${port}`
1243
+ : `${JSON.stringify(process.execPath)} ${JSON.stringify(localCli)} --port ${port}`
1244
+ : `npx --yes @maintainer-pro/ai-server --port ${port}`;
1245
+
1246
+ const opened = await openInNewTerminal({
1247
+ title: `MP-ai-${port}`,
1248
+ folder,
1249
+ command: run,
1250
+ env: {
1251
+ PORT: String(port),
1252
+ AI_SERVER_URL: `http://localhost:${port}`,
1253
+ NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
1254
+ },
1255
+ launchKey,
1256
+ });
1257
+ if (opened.skipped) {
1258
+ return { port, up: false, launched: false, starting: true };
1259
+ }
1260
+ if (!opened.ok) {
1261
+ recordProcessProblem({
1262
+ sandboxId: ws.sandboxId,
1263
+ code: "ai_server_launch",
1264
+ role: "ai",
1265
+ title: `Could not start the chat script (${label})`,
1266
+ message: opened.error,
1267
+ resolution:
1268
+ "Allow the bridge to open terminal windows, or start the script manually in that folder.",
1269
+ });
1270
+ return { port, up: false, launched: false };
1271
+ }
1272
+ return { port, up: false, launched: true, starting: true };
1273
+ }
704
1274
 
705
- log(`starting ai-server for ${ws.sandboxName || ws.sandboxId} on :${ws.port}`);
706
- const child = spawn(
707
- useLocal
708
- ? process.execPath
709
- : process.platform === "win32"
710
- ? "npx.cmd"
711
- : "npx",
712
- useLocal ? [localCli] : ["--yes", "@maintainer-pro/ai-server"],
713
- {
714
- cwd: ws.folderPath,
715
- env,
716
- stdio: ["ignore", "pipe", "pipe"],
717
- shell: process.platform === "win32" && !useLocal,
1275
+ function sleep(ms) {
1276
+ return new Promise((resolve) => setTimeout(resolve, ms));
1277
+ }
1278
+
1279
+ /** @type {Map<string, { child: import("node:child_process").ChildProcess, url: string | null, localUrl: string }>} */
1280
+ const cloudflareTunnels = new Map();
1281
+
1282
+ function stopCloudflare(sandboxId) {
1283
+ const row = cloudflareTunnels.get(sandboxId);
1284
+ if (row?.child && !row.child.killed) {
1285
+ try {
1286
+ row.child.kill();
1287
+ } catch {
1288
+ /* ignore */
718
1289
  }
1290
+ }
1291
+ cloudflareTunnels.delete(sandboxId);
1292
+ }
1293
+
1294
+ function stopAllCloudflare() {
1295
+ for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
1296
+ }
1297
+
1298
+ function localUrlForWorkspace(ws) {
1299
+ if (ws.appUrl && isLocalAppUrl(ws.appUrl)) return ws.appUrl;
1300
+ return `http://127.0.0.1:${Number(ws.port) || 3100}`;
1301
+ }
1302
+
1303
+ function parseTryCloudflareUrl(text) {
1304
+ const match = String(text || "").match(
1305
+ /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
719
1306
  );
720
- const tag = ws.sandboxName || ws.sandboxId.slice(0, 8);
721
- child.stdout?.on("data", (buf) => {
722
- const line = String(buf).trimEnd();
723
- if (line) console.log(`[ai-server:${tag}] ${line}`);
724
- });
725
- child.stderr?.on("data", (buf) => {
726
- const line = String(buf).trimEnd();
727
- if (line) console.error(`[ai-server:${tag}] ${line}`);
1307
+ return match ? match[0].replace(/\/$/, "") : null;
1308
+ }
1309
+
1310
+ function spawnCloudflared(localUrl) {
1311
+ const args = ["tunnel", "--url", localUrl, "--no-autoupdate"];
1312
+ const trySpawn = (file, argv, extra = {}) =>
1313
+ new Promise((resolve, reject) => {
1314
+ const child = spawn(file, argv, {
1315
+ stdio: ["ignore", "pipe", "pipe"],
1316
+ windowsHide: true,
1317
+ ...extra,
1318
+ });
1319
+ const onError = (err) => reject(err);
1320
+ child.once("error", onError);
1321
+ child.once("spawn", () => {
1322
+ child.off("error", onError);
1323
+ resolve(child);
1324
+ });
1325
+ });
1326
+
1327
+ return trySpawn("cloudflared", args).catch(() =>
1328
+ trySpawn(
1329
+ process.platform === "win32" ? "npx.cmd" : "npx",
1330
+ ["--yes", "cloudflared", ...args],
1331
+ { shell: process.platform === "win32" }
1332
+ )
1333
+ );
1334
+ }
1335
+
1336
+ function waitForTunnelUrl(child, timeoutMs = 90_000) {
1337
+ return new Promise((resolve, reject) => {
1338
+ let buffer = "";
1339
+ let settled = false;
1340
+ const done = (err, url) => {
1341
+ if (settled) return;
1342
+ settled = true;
1343
+ clearTimeout(timer);
1344
+ if (err) reject(err);
1345
+ else resolve(url);
1346
+ };
1347
+ const onData = (chunk) => {
1348
+ buffer += String(chunk);
1349
+ const url = parseTryCloudflareUrl(buffer);
1350
+ if (url) done(null, url);
1351
+ };
1352
+ child.stdout?.on("data", onData);
1353
+ child.stderr?.on("data", onData);
1354
+ child.once("exit", (code) => {
1355
+ done(
1356
+ new Error(
1357
+ `cloudflared exited ${code ?? "early"} before it published a URL`
1358
+ )
1359
+ );
1360
+ });
1361
+ const timer = setTimeout(() => {
1362
+ done(
1363
+ new Error(
1364
+ "Cloudflare did not publish a URL in time. Is cloudflared installed and online?"
1365
+ )
1366
+ );
1367
+ }, timeoutMs);
728
1368
  });
729
- child.on("exit", (code, signal) => {
730
- log(`ai-server ${tag} exited (code=${code}, signal=${signal})`);
731
- children.delete(key);
1369
+ }
1370
+
1371
+ async function configureCloudflareForWorkspace(ws, cfg) {
1372
+ const sandboxId = ws.sandboxId;
1373
+ const label = ws.sandboxName || "this sandbox";
1374
+ const localUrl = localUrlForWorkspace(ws);
1375
+ const existing = cloudflareTunnels.get(sandboxId);
1376
+ if (existing?.url && existing.localUrl === localUrl && existing.child && !existing.child.killed) {
1377
+ return {
1378
+ sandboxId,
1379
+ folderPath: ws.folderPath,
1380
+ appUrl: existing.url,
1381
+ origins: [existing.url],
1382
+ localUrl,
1383
+ cloudflare: true,
1384
+ reused: true,
1385
+ };
1386
+ }
1387
+ if (existing) stopCloudflare(sandboxId);
1388
+
1389
+ log(`starting Cloudflare tunnel for ${label} → ${localUrl}`);
1390
+ let child;
1391
+ try {
1392
+ child = await spawnCloudflared(localUrl);
1393
+ } catch (err) {
1394
+ const message = err instanceof Error ? err.message : String(err);
1395
+ recordProcessProblem({
1396
+ sandboxId,
1397
+ code: "cloudflare_launch",
1398
+ role: "tunnel",
1399
+ title: `Could not start Cloudflare (${label})`,
1400
+ message,
1401
+ resolution:
1402
+ "Install cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) or allow npx to download it, then try again.",
1403
+ actionCode: "configure_cloudflare",
1404
+ });
1405
+ throw new Error(
1406
+ `Could not start cloudflared. Install it or allow npx to download it. ${message}`
1407
+ );
1408
+ }
1409
+
1410
+ cloudflareTunnels.set(sandboxId, { child, url: null, localUrl });
1411
+ child.once("exit", () => {
1412
+ const row = cloudflareTunnels.get(sandboxId);
1413
+ if (row?.child === child) cloudflareTunnels.delete(sandboxId);
732
1414
  });
733
- children.set(key, child);
1415
+
1416
+ const publicUrl = await waitForTunnelUrl(child);
1417
+ cloudflareTunnels.set(sandboxId, { child, url: publicUrl, localUrl });
1418
+ child.unref();
1419
+
1420
+ ws.cloudflareUrl = publicUrl;
1421
+ ws.appUrl = publicUrl;
1422
+ const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
1423
+ if (folder && fs.existsSync(folder)) {
1424
+ fs.writeFileSync(
1425
+ path.join(folder, ".cloudflare-tunnel-url"),
1426
+ `${publicUrl}\n`,
1427
+ "utf8"
1428
+ );
1429
+ const envPath = path.join(folder, ".env");
1430
+ const envValues = {
1431
+ APP_URL: publicUrl,
1432
+ PUBLIC_URL: publicUrl,
1433
+ CORS_ORIGIN: publicUrl,
1434
+ };
1435
+ if (isAiServerTarget(ws, localUrl)) {
1436
+ envValues.AI_SERVER_URL = publicUrl;
1437
+ envValues.NEXT_PUBLIC_AI_SERVER_URL = publicUrl;
1438
+ }
1439
+ if (fs.existsSync(envPath)) mergeEnvFile(envPath, envValues);
1440
+ }
1441
+ persistWorkspaceEntry(cfg, ws);
1442
+ clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
1443
+ log(`Cloudflare URL for ${label}: ${publicUrl}`);
1444
+ return {
1445
+ sandboxId,
1446
+ folderPath: ws.folderPath,
1447
+ appUrl: publicUrl,
1448
+ origins: [publicUrl],
1449
+ localUrl,
1450
+ cloudflare: true,
1451
+ reused: false,
1452
+ };
1453
+ }
1454
+
1455
+ function isAiServerTarget(ws, localUrl) {
1456
+ try {
1457
+ const port = Number(new URL(localUrl).port);
1458
+ return port === Number(ws.port);
1459
+ } catch {
1460
+ return true;
1461
+ }
1462
+ }
1463
+
1464
+ async function ensureHostProcesses(ws, opts = {}) {
1465
+ const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
1466
+ const cfg = opts.cfg || null;
1467
+ const folder = path.resolve(ws.folderPath);
1468
+ const scripts = readPackageJson(folder)?.scripts || {};
1469
+ const jobs = planHostJobs(folder, ws.appUrl || null, ws.projectInfo);
1470
+ const started = [];
1471
+ let persisted = false;
1472
+ const label = ws.sandboxName || "this sandbox";
1473
+
1474
+ if (jobs.length > 0 && !fs.existsSync(folder)) {
1475
+ recordProcessProblem({
1476
+ sandboxId: ws.sandboxId,
1477
+ code: "host_process_launch",
1478
+ role: "app",
1479
+ title: `Could not start the app (${label})`,
1480
+ message: `The project folder is missing: ${folder}`,
1481
+ resolution: "Attach the folder again from Local setup.",
1482
+ });
1483
+ return started;
1484
+ }
1485
+
1486
+ for (const job of jobs) {
1487
+ const preferred = Number(job.preferredPort) || 3000;
1488
+ const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
1489
+ "localhost",
1490
+ "127.0.0.1"
1491
+ );
1492
+ if (await probeUrl(probe)) {
1493
+ reserved.add(portFromText(probe, preferred));
1494
+ clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
1495
+ clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
1496
+ log(`${job.role} already running at ${job.probeUrl || probe}`);
1497
+ continue;
1498
+ }
1499
+ const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
1500
+ if (recentlyLaunched(launchKey)) {
1501
+ reserved.add(preferred);
1502
+ continue;
1503
+ }
1504
+
1505
+ let port = preferred;
1506
+ try {
1507
+ port = await findFreePort(preferred, reserved);
1508
+ } catch (err) {
1509
+ recordProcessProblem({
1510
+ sandboxId: ws.sandboxId,
1511
+ code: "host_process_launch",
1512
+ role: job.role,
1513
+ title: `Could not start the ${processRoleLabel(job.role)} (${label})`,
1514
+ message: `No free port found for "${job.script}" (tried from ${preferred}). ${
1515
+ err instanceof Error ? err.message : String(err)
1516
+ }`,
1517
+ resolution: "Close other local servers, then use Start chat server.",
1518
+ });
1519
+ continue;
1520
+ }
1521
+ if (port !== preferred) {
1522
+ log(`port ${preferred} busy; using ${port} for ${job.role}`);
1523
+ }
1524
+
1525
+ const needsInstall =
1526
+ fs.existsSync(path.join(folder, "package.json")) &&
1527
+ !fs.existsSync(path.join(folder, "node_modules"));
1528
+ const run = commandWithPort(job, scripts, port);
1529
+ const command = needsInstall ? `npm install && ${run}` : run;
1530
+ const opened = await openInNewTerminal({
1531
+ title: `MP-${job.role}-${port}`,
1532
+ folder,
1533
+ command,
1534
+ env: { PORT: String(port) },
1535
+ launchKey,
1536
+ });
1537
+ if (opened.skipped) continue;
1538
+ if (!opened.ok) {
1539
+ recordProcessProblem({
1540
+ sandboxId: ws.sandboxId,
1541
+ code: "host_process_launch",
1542
+ role: job.role,
1543
+ title: `Could not start the ${processRoleLabel(job.role)} (${label})`,
1544
+ message: `${opened.error} Command: ${command}`,
1545
+ resolution:
1546
+ "Allow the bridge to open terminal windows, or run that command manually in the folder.",
1547
+ });
1548
+ continue;
1549
+ }
1550
+ started.push(job.role);
1551
+ if (
1552
+ (job.role === "ui" || job.role === "app") &&
1553
+ (!ws.appUrl || isLocalAppUrl(ws.appUrl))
1554
+ ) {
1555
+ ws.appUrl = urlWithPort(ws.appUrl || `http://localhost:${port}`, port);
1556
+ persisted = true;
1557
+ }
1558
+ await sleep(400);
1559
+ }
1560
+ if (persisted) persistWorkspaceEntry(cfg, ws);
1561
+ return started;
1562
+ }
1563
+
1564
+ async function inspectHostJobs(ws) {
1565
+ const folder = path.resolve(ws.folderPath || "");
1566
+ const jobs = planHostJobs(folder, ws.appUrl || null, ws.projectInfo);
1567
+ const label = ws.sandboxName || "this sandbox";
1568
+ const hosts = [];
1569
+ for (const job of jobs) {
1570
+ const preferred = Number(job.preferredPort) || 3000;
1571
+ const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
1572
+ "localhost",
1573
+ "127.0.0.1"
1574
+ );
1575
+ const up = await probeUrl(probe);
1576
+ const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
1577
+ const starting = recentlyLaunched(launchKey);
1578
+ hosts.push({
1579
+ role: job.role,
1580
+ script: job.script,
1581
+ probeUrl: job.probeUrl || probe,
1582
+ up,
1583
+ starting,
1584
+ });
1585
+ if (up) {
1586
+ clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
1587
+ clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
1588
+ continue;
1589
+ }
1590
+ if (starting) continue;
1591
+ const tried = launchedAt.has(launchKey);
1592
+ recordProcessProblem({
1593
+ sandboxId: ws.sandboxId,
1594
+ code: "host_process_down",
1595
+ role: job.role,
1596
+ title: `The ${processRoleLabel(job.role)} is not running (${label})`,
1597
+ message: tried
1598
+ ? `Started "${job.script}" in a separate terminal, but nothing answered at ${probe}. Open that window and read the error.`
1599
+ : `Nothing is running at ${probe} for "${job.script}".`,
1600
+ resolution:
1601
+ "Fix the error in that terminal, then use Start chat server to try again.",
1602
+ });
1603
+ }
1604
+ return hosts;
734
1605
  }
735
1606
 
736
1607
  async function setupWorkspace(cfg, action) {
@@ -738,7 +1609,22 @@ async function setupWorkspace(cfg, action) {
738
1609
  const sandboxId = String(
739
1610
  action.sandboxId || action.payload?.sandboxId || ""
740
1611
  );
741
- const port = Number(action.payload?.port) || 3100;
1612
+ const requestedPort = Number(action.payload?.port) || 3100;
1613
+ const reserved = new Set();
1614
+ for (const other of cfg.workspaces || []) {
1615
+ if (other.sandboxId !== sandboxId && other.port) {
1616
+ reserved.add(Number(other.port));
1617
+ }
1618
+ }
1619
+ let port = requestedPort;
1620
+ if (await probeUrl(`http://127.0.0.1:${requestedPort}/embed-config.js`)) {
1621
+ reserved.add(requestedPort);
1622
+ } else {
1623
+ port = await findFreePort(requestedPort, reserved);
1624
+ if (port !== requestedPort) {
1625
+ log(`port ${requestedPort} busy; using ${port} for ai-server`);
1626
+ }
1627
+ }
742
1628
  const clientMode = String(action.payload?.clientMode || "auto"); // auto|empty|existing|skip
743
1629
  const hostAppUrl =
744
1630
  typeof action.payload?.hostAppUrl === "string" &&
@@ -799,6 +1685,8 @@ async function setupWorkspace(cfg, action) {
799
1685
  sandboxName: config.sandbox?.name,
800
1686
  applicationName: config.sandbox?.applicationName,
801
1687
  clientKind: client.kind,
1688
+ appUrl,
1689
+ sameOrigin: Boolean(client.sameOrigin),
802
1690
  };
803
1691
  if (existing >= 0) cfg.workspaces[existing] = entry;
804
1692
  else cfg.workspaces.push(entry);
@@ -806,27 +1694,66 @@ async function setupWorkspace(cfg, action) {
806
1694
  if (!cfg.offeredFolders.includes(resolved)) cfg.offeredFolders.push(resolved);
807
1695
  saveConfig(cfg);
808
1696
 
1697
+ const projectInfo = await inspectProjectWithAiCli(entry, {
1698
+ cfg,
1699
+ extraContext: [
1700
+ `Scaffold kind: ${client.kind}`,
1701
+ `Chat script port: ${port}`,
1702
+ client.notes.join(" "),
1703
+ ]
1704
+ .filter(Boolean)
1705
+ .join("\n"),
1706
+ });
1707
+
809
1708
  if (!cfg.noAiServer) {
810
- startAiServerForWorkspace(entry);
811
- await new Promise((r) => setTimeout(r, 1500));
1709
+ await startAiServerForWorkspace(entry, { reserved, cfg });
1710
+ await sleep(1500);
1711
+ }
1712
+ const startedHosts = await ensureHostProcesses(entry, { reserved, cfg });
1713
+ await sleep(800);
1714
+ await inspectHostJobs(entry);
1715
+
1716
+ const openUrl = client.sameOrigin
1717
+ ? `http://localhost:${entry.port}`
1718
+ : entry.appUrl || appUrl;
1719
+ const aiServerUp = await probeUrl(`http://127.0.0.1:${entry.port}/embed-config.js`);
1720
+ if (aiServerUp) {
1721
+ clearProcessProblem(sandboxId, "ai_server_launch", "ai");
812
1722
  }
813
1723
 
814
- const aiServerUp = await probeUrl(`${aiOrigin.replace("localhost", "127.0.0.1")}/embed-config.js`);
1724
+ const processIssues = issuesForSandbox(sandboxId).map(
1725
+ ({ role: _role, ...issue }) => issue
1726
+ );
1727
+ const warning = processIssues[0]?.message || null;
815
1728
 
816
1729
  for (const note of client.notes) log(note);
1730
+ if (startedHosts.length) {
1731
+ log(`started ${startedHosts.join(" + ")} in separate terminals`);
1732
+ }
1733
+ if (aiServerUp) {
1734
+ log(`ai-server up — open ${openUrl}`);
1735
+ } else if (warning) {
1736
+ log(`process issue: ${warning}`);
1737
+ } else {
1738
+ log("ai-server not reachable yet; it may still be starting");
1739
+ }
817
1740
 
818
1741
  return {
819
1742
  sandboxId,
820
1743
  folderPath: resolved,
821
- port,
822
- appUrl,
823
- origins: [corsOrigin, aiOrigin].filter(Boolean),
1744
+ port: entry.port,
1745
+ appUrl: entry.appUrl || appUrl,
1746
+ origins: [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
824
1747
  wroteEnv: true,
825
1748
  clientKind: client.kind,
826
1749
  clientFiles: client.filesWritten,
827
1750
  clientNotes: client.notes,
828
1751
  aiServerUp,
829
- openUrl: client.sameOrigin ? aiOrigin : appUrl,
1752
+ startedHosts,
1753
+ openUrl,
1754
+ processIssues,
1755
+ warning,
1756
+ projectInfo,
830
1757
  };
831
1758
  }
832
1759
 
@@ -843,7 +1770,15 @@ async function runActions(cfg, actions) {
843
1770
  } else if (action.code === "setup_workspace") {
844
1771
  result = await setupWorkspace(cfg, action);
845
1772
  } else if (action.code === "recheck") {
846
- result = { recheckedAt: new Date().toISOString() };
1773
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
1774
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
1775
+ const projectInfo = ws
1776
+ ? await inspectProjectWithAiCli(ws, { cfg })
1777
+ : null;
1778
+ result = {
1779
+ recheckedAt: new Date().toISOString(),
1780
+ projectInfo,
1781
+ };
847
1782
  } else if (action.code === "start_ai_server") {
848
1783
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
849
1784
  const ws =
@@ -859,23 +1794,88 @@ async function runActions(cfg, actions) {
859
1794
  ok = false;
860
1795
  result = { error: "No workspace or --no-ai-server" };
861
1796
  } else {
862
- startAiServerForWorkspace(ws);
863
- await new Promise((r) => setTimeout(r, 1200));
1797
+ const reserved = new Set();
1798
+ for (const other of cfg.workspaces || []) {
1799
+ if (other.sandboxId !== ws.sandboxId && other.port) {
1800
+ reserved.add(Number(other.port));
1801
+ }
1802
+ }
1803
+ const problem = issuesForSandbox(ws.sandboxId)
1804
+ .map((issue) => issue.message)
1805
+ .join("\n");
1806
+ const projectInfo = await inspectProjectWithAiCli(ws, {
1807
+ cfg,
1808
+ problem:
1809
+ problem ||
1810
+ "Local processes are not running or the project setup looks incomplete.",
1811
+ });
1812
+ await startAiServerForWorkspace(ws, { reserved, cfg });
1813
+ await sleep(1500);
1814
+ const startedHosts = await ensureHostProcesses(ws, { reserved, cfg });
1815
+ await sleep(800);
1816
+ await inspectHostJobs(ws);
1817
+ const up = await probeUrl(
1818
+ `http://127.0.0.1:${ws.port}/embed-config.js`
1819
+ );
1820
+ if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1821
+ const processIssues = issuesForSandbox(ws.sandboxId).map(
1822
+ ({ role: _role, ...issue }) => issue
1823
+ );
1824
+ const warning = processIssues[0]?.message || null;
864
1825
  result = {
865
- up: await probeUrl(
866
- `http://127.0.0.1:${ws.port}/embed-config.js`
867
- ),
1826
+ up,
1827
+ startedHosts,
1828
+ sandboxId: ws.sandboxId,
1829
+ folderPath: ws.folderPath,
1830
+ port: ws.port,
1831
+ appUrl: ws.appUrl,
1832
+ processIssues,
1833
+ warning,
1834
+ projectInfo,
868
1835
  };
1836
+ if (processIssues.some((issue) => issue.code === "ai_server_launch")) {
1837
+ result.error = warning;
1838
+ ok = false;
1839
+ }
869
1840
  }
870
1841
  } else if (action.code === "refresh_public_url") {
1842
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
1843
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
871
1844
  result = {
872
- appUrl: process.env.APP_URL || process.env.PUBLIC_URL || null,
1845
+ appUrl:
1846
+ ws?.cloudflareUrl ||
1847
+ ws?.appUrl ||
1848
+ process.env.APP_URL ||
1849
+ process.env.PUBLIC_URL ||
1850
+ null,
873
1851
  };
1852
+ } else if (action.code === "configure_cloudflare") {
1853
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
1854
+ const ws =
1855
+ (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
1856
+ (action.payload?.folderPath
1857
+ ? {
1858
+ sandboxId,
1859
+ folderPath: String(action.payload.folderPath),
1860
+ port: Number(action.payload.port) || 3100,
1861
+ appUrl:
1862
+ typeof action.payload.appUrl === "string"
1863
+ ? action.payload.appUrl
1864
+ : null,
1865
+ sandboxName: action.payload.sandboxName,
1866
+ }
1867
+ : null);
1868
+ if (!ws) {
1869
+ ok = false;
1870
+ result = { error: "No folder is attached for this sandbox" };
1871
+ } else {
1872
+ result = await configureCloudflareForWorkspace(ws, cfg);
1873
+ }
874
1874
  } else if (action.code === "remove_workspace") {
875
1875
  const sandboxId = String(
876
1876
  action.sandboxId || action.payload?.sandboxId || ""
877
1877
  );
878
- stopChild(sandboxId);
1878
+ forgetLaunch(sandboxId);
879
1879
  cfg.workspaces = (cfg.workspaces || []).filter(
880
1880
  (w) => w.sandboxId !== sandboxId
881
1881
  );
@@ -908,10 +1908,53 @@ async function runActions(cfg, actions) {
908
1908
  }
909
1909
  }
910
1910
 
911
- function buildIssues(cfg, workspaceStates) {
1911
+ async function collectWorkspaceStates(cfg) {
1912
+ /** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,startingAi:boolean,folderPath:string,appUrl?:string|null}>} */
1913
+ const localStates = [];
1914
+ for (const ws of cfg.workspaces || []) {
1915
+ const folder = path.resolve(ws.folderPath || "");
1916
+ const up = await probeUrl(`http://127.0.0.1:${ws.port}/embed-config.js`);
1917
+ if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1918
+ await inspectHostJobs(ws);
1919
+ localStates.push({
1920
+ sandboxId: ws.sandboxId,
1921
+ sandboxName: ws.sandboxName,
1922
+ port: ws.port,
1923
+ folderPath: ws.folderPath,
1924
+ aiServerUp: up,
1925
+ startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
1926
+ appUrl: ws.appUrl || null,
1927
+ });
1928
+ }
1929
+ return localStates;
1930
+ }
1931
+
1932
+ async function sendHeartbeat(cfg, folders, localStates) {
1933
+ return api(
1934
+ cfg.adminUrl,
1935
+ cfg.token,
1936
+ "POST",
1937
+ "/api/v1/bridge/machine/heartbeat",
1938
+ {
1939
+ hostname: os.hostname(),
1940
+ platform: `${os.platform()}-${os.arch()}`,
1941
+ bridgeVersion: PACKAGE_VERSION,
1942
+ folders,
1943
+ issues: await buildIssues(cfg, localStates),
1944
+ workspaces: localStates.map((st) => ({
1945
+ sandboxId: st.sandboxId,
1946
+ aiServerUp: st.aiServerUp,
1947
+ port: st.port,
1948
+ appUrl: st.appUrl || undefined,
1949
+ })),
1950
+ }
1951
+ );
1952
+ }
1953
+
1954
+ async function buildIssues(cfg, workspaceStates) {
912
1955
  /** @type {Array<Record<string, unknown>>} */
913
1956
  const issues = [];
914
- const cli = detectCliProviders();
1957
+ const cli = await detectCliProviders();
915
1958
  if (!cli.length) {
916
1959
  issues.push({
917
1960
  code: "missing_cli",
@@ -925,17 +1968,54 @@ function buildIssues(cfg, workspaceStates) {
925
1968
  });
926
1969
  }
927
1970
  for (const st of workspaceStates) {
928
- if (!st.aiServerUp) {
929
- issues.push({
930
- code: "ai_server_down",
931
- severity: "error",
932
- title: `AI server down (${st.sandboxName || st.sandboxId})`,
933
- message: `Cannot reach http://localhost:${st.port}`,
934
- resolution: "Start ai-server from the Bridges page or restart the bridge.",
935
- actionCode: "start_ai_server",
936
- sandboxId: st.sandboxId,
937
- });
1971
+ if (st.aiServerUp || st.startingAi) continue;
1972
+ const launch = [...processProblems.values()].find(
1973
+ (issue) =>
1974
+ issue.sandboxId === st.sandboxId && issue.code === "ai_server_launch"
1975
+ );
1976
+ issues.push({
1977
+ code: "ai_server_down",
1978
+ severity: "error",
1979
+ title:
1980
+ launch?.title ||
1981
+ `Local script is not running (${st.sandboxName || "sandbox"})`,
1982
+ message:
1983
+ launch?.message ||
1984
+ `Cannot reach http://localhost:${st.port}. Check the MP-ai terminal on that computer for the error.`,
1985
+ resolution:
1986
+ launch?.resolution ||
1987
+ "Read the error in that terminal, then use Start chat server.",
1988
+ actionCode: "start_ai_server",
1989
+ sandboxId: st.sandboxId,
1990
+ });
1991
+ }
1992
+ for (const problem of processProblems.values()) {
1993
+ if (
1994
+ problem.code === "ai_server_launch" ||
1995
+ problem.code === "ai_server_down"
1996
+ ) {
1997
+ continue;
1998
+ }
1999
+ if (
2000
+ problem.code === "host_process_down" &&
2001
+ [...processProblems.values()].some(
2002
+ (other) =>
2003
+ other.sandboxId === problem.sandboxId &&
2004
+ other.role === problem.role &&
2005
+ other.code === "host_process_launch"
2006
+ )
2007
+ ) {
2008
+ continue;
938
2009
  }
2010
+ issues.push({
2011
+ code: problem.code,
2012
+ severity: problem.severity,
2013
+ title: problem.title,
2014
+ message: problem.message,
2015
+ resolution: problem.resolution,
2016
+ actionCode: problem.actionCode,
2017
+ sandboxId: problem.sandboxId,
2018
+ });
939
2019
  }
940
2020
  return issues;
941
2021
  }
@@ -975,6 +2055,8 @@ async function pairFlow(args) {
975
2055
  saveConfig(cfg);
976
2056
  log(`paired as ${result.machine?.name || cfg.machineId}`);
977
2057
  log(`config ${configPath()}`);
2058
+ log("Keep this process running.");
2059
+ log("Next: Admin → Bridges → pick a folder → Setup sandbox.");
978
2060
  return cfg;
979
2061
  }
980
2062
 
@@ -1017,35 +2099,32 @@ async function main() {
1017
2099
  /** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,folderPath:string}>} */
1018
2100
  const localStates = [];
1019
2101
 
2102
+ const reserved = new Set();
1020
2103
  for (const ws of cfg.workspaces || []) {
2104
+ const folder = path.resolve(ws.folderPath || "");
1021
2105
  const up = await probeUrl(
1022
2106
  `http://127.0.0.1:${ws.port}/embed-config.js`
1023
2107
  );
2108
+ if (!cfg.noAiServer && !up) {
2109
+ await startAiServerForWorkspace(ws, { reserved, cfg });
2110
+ } else if (ws.port) {
2111
+ reserved.add(Number(ws.port));
2112
+ if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2113
+ }
2114
+ await ensureHostProcesses(ws, { reserved, cfg });
2115
+ await inspectHostJobs(ws);
1024
2116
  localStates.push({
1025
2117
  sandboxId: ws.sandboxId,
1026
2118
  sandboxName: ws.sandboxName,
1027
2119
  port: ws.port,
1028
2120
  folderPath: ws.folderPath,
1029
2121
  aiServerUp: up,
2122
+ startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
2123
+ appUrl: ws.appUrl || null,
1030
2124
  });
1031
- if (!cfg.noAiServer && !up) {
1032
- startAiServerForWorkspace(ws);
1033
- }
1034
2125
  }
1035
2126
 
1036
- const hb = await api(
1037
- cfg.adminUrl,
1038
- cfg.token,
1039
- "POST",
1040
- "/api/v1/bridge/machine/heartbeat",
1041
- {
1042
- hostname: os.hostname(),
1043
- platform: `${os.platform()}-${os.arch()}`,
1044
- bridgeVersion: PACKAGE_VERSION,
1045
- folders,
1046
- issues: buildIssues(cfg, localStates),
1047
- }
1048
- );
2127
+ const hb = await sendHeartbeat(cfg, folders, localStates);
1049
2128
 
1050
2129
  // Sync local workspace list from server assignments
1051
2130
  if (Array.isArray(hb.workspaces)) {
@@ -1074,20 +2153,45 @@ async function main() {
1074
2153
 
1075
2154
  if (Array.isArray(hb.actions) && hb.actions.length > 0) {
1076
2155
  await runActions(cfg, hb.actions);
2156
+ await sendHeartbeat(cfg, folders, await collectWorkspaceStates(cfg));
1077
2157
  }
1078
2158
  } catch (err) {
1079
2159
  warn(err instanceof Error ? err.message : String(err));
1080
2160
  }
1081
2161
  };
1082
2162
 
1083
- await tick();
2163
+ let cycleBusy = false;
2164
+ const runLocked = async (fn) => {
2165
+ if (cycleBusy) return;
2166
+ cycleBusy = true;
2167
+ try {
2168
+ await fn();
2169
+ } finally {
2170
+ cycleBusy = false;
2171
+ }
2172
+ };
2173
+
2174
+ await runLocked(tick);
1084
2175
  setInterval(() => {
1085
- void tick();
2176
+ void runLocked(tick);
1086
2177
  }, HEARTBEAT_MS);
2178
+ setInterval(() => {
2179
+ void runLocked(async () => {
2180
+ const pending = await api(
2181
+ cfg.adminUrl,
2182
+ cfg.token,
2183
+ "GET",
2184
+ "/api/v1/bridge/machine/actions"
2185
+ );
2186
+ if (Array.isArray(pending?.actions) && pending.actions.length > 0) {
2187
+ await runActions(cfg, pending.actions);
2188
+ }
2189
+ });
2190
+ }, ACTION_POLL_MS);
1087
2191
 
1088
2192
  const shutdown = () => {
1089
- log("shutting down");
1090
- for (const id of [...children.keys()]) stopChild(id);
2193
+ stopAllCloudflare();
2194
+ log("shutting down (other terminals stay open)");
1091
2195
  process.exit(0);
1092
2196
  };
1093
2197
  process.on("SIGINT", shutdown);