@maintainer-pro/ai-bridge 0.1.0 → 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 (3) hide show
  1. package/README.md +8 -1
  2. package/package.json +4 -1
  3. package/src/daemon.mjs +1649 -112
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) {
@@ -256,6 +435,401 @@ function mergeEnvFile(file, values) {
256
435
  fs.writeFileSync(file, body + "\n", "utf8");
257
436
  }
258
437
 
438
+ const IGNORE_NAMES = new Set([
439
+ ".git",
440
+ ".DS_Store",
441
+ "Thumbs.db",
442
+ "node_modules",
443
+ ".maintainer-pro",
444
+ ".maintainer-pro-bridge.json",
445
+ ".cloudflare-tunnel-url",
446
+ ]);
447
+
448
+ function isIgnorableEntry(name) {
449
+ if (IGNORE_NAMES.has(name)) return true;
450
+ if (name.startsWith(".env")) return true;
451
+ return false;
452
+ }
453
+
454
+ /**
455
+ * Detect project shape in a folder.
456
+ * @returns {"empty"|"next"|"html"|"other"}
457
+ */
458
+ function detectProjectKind(dir) {
459
+ if (!fs.existsSync(dir)) return "empty";
460
+ const names = fs.readdirSync(dir).filter((n) => !isIgnorableEntry(n));
461
+ if (names.length === 0) return "empty";
462
+
463
+ const has = (n) => names.includes(n) || fs.existsSync(path.join(dir, n));
464
+ if (
465
+ has("next.config.js") ||
466
+ has("next.config.mjs") ||
467
+ has("next.config.ts") ||
468
+ has("app") ||
469
+ has("pages")
470
+ ) {
471
+ // package.json with next is a stronger signal
472
+ try {
473
+ const pkg = JSON.parse(
474
+ fs.readFileSync(path.join(dir, "package.json"), "utf8")
475
+ );
476
+ if (pkg.dependencies?.next || pkg.devDependencies?.next) return "next";
477
+ } catch {
478
+ /* fall through */
479
+ }
480
+ if (has("next.config.js") || has("next.config.mjs") || has("next.config.ts")) {
481
+ return "next";
482
+ }
483
+ }
484
+
485
+ if (has("index.html") || has(path.join("public", "index.html"))) {
486
+ return "html";
487
+ }
488
+
489
+ // Only README / license → treat as empty scaffold target
490
+ const meaningful = names.filter(
491
+ (n) =>
492
+ !/^readme/i.test(n) &&
493
+ !/^license/i.test(n) &&
494
+ n !== "package.json"
495
+ );
496
+ if (meaningful.length === 0 && !has("package.json")) return "empty";
497
+
498
+ return "other";
499
+ }
500
+
501
+ function escapeHtml(value) {
502
+ return String(value)
503
+ .replaceAll("&", "&amp;")
504
+ .replaceAll("<", "&lt;")
505
+ .replaceAll(">", "&gt;")
506
+ .replaceAll('"', "&quot;");
507
+ }
508
+
509
+ function emptyProjectIndexHtml(appName) {
510
+ return `<!doctype html>
511
+ <html lang="en">
512
+ <head>
513
+ <meta charset="utf-8" />
514
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
515
+ <title>${escapeHtml(appName)}</title>
516
+ <style>
517
+ body { font-family: system-ui, sans-serif; margin: 2rem; color: #1a2330; }
518
+ p { color: #5c6b7a; }
519
+ </style>
520
+ </head>
521
+ <body>
522
+ <h1>${escapeHtml(appName)}</h1>
523
+ <p>Ask the assistant to change this page.</p>
524
+ <script src="/embed-config.js"></script>
525
+ <script src="/ai-ui.iife.js"></script>
526
+ <script>
527
+ (function () {
528
+ var cfg = window.__MAINTAINER_PRO__ || {};
529
+ AiUi.init({
530
+ apiUrl: cfg.apiUrl || "/api/chat",
531
+ title: "AI Assistant",
532
+ maintainerProUrl: cfg.maintainerProUrl || undefined,
533
+ maintainerProApiKey: cfg.maintainerProApiKey || undefined,
534
+ });
535
+ })();
536
+ </script>
537
+ </body>
538
+ </html>
539
+ `;
540
+ }
541
+
542
+ const WIDGET_MARKER = "AiUi.init";
543
+
544
+ function injectHtmlWidget(html, aiServerUrl) {
545
+ if (html.includes(WIDGET_MARKER) || html.includes("ai-ui.iife.js")) {
546
+ return { html, injected: false };
547
+ }
548
+ const snippet = `
549
+ <script src="${aiServerUrl}/embed-config.js"></script>
550
+ <script src="${aiServerUrl}/ai-ui.iife.js"></script>
551
+ <script>
552
+ (function () {
553
+ var cfg = window.__MAINTAINER_PRO__ || {};
554
+ if (!cfg.apiUrl) {
555
+ console.error("Maintainer Pro: embed-config.js missing apiUrl (is ai-server running?)");
556
+ return;
557
+ }
558
+ AiUi.init({
559
+ apiUrl: cfg.apiUrl,
560
+ title: "AI Assistant",
561
+ maintainerProUrl: cfg.maintainerProUrl || undefined,
562
+ maintainerProApiKey: cfg.maintainerProApiKey || undefined,
563
+ });
564
+ })();
565
+ </script>
566
+ `;
567
+ if (/<\/body>/i.test(html)) {
568
+ return {
569
+ html: html.replace(/<\/body>/i, `${snippet}</body>`),
570
+ injected: true,
571
+ };
572
+ }
573
+ return { html: html + snippet, injected: true };
574
+ }
575
+
576
+ function nextWidgetComponentSource() {
577
+ return `"use client";
578
+
579
+ import { useEffect } from "react";
580
+
581
+ declare global {
582
+ interface Window {
583
+ AiUi?: { init: (opts: Record<string, unknown>) => void; destroy?: () => void };
584
+ __MAINTAINER_PRO__?: {
585
+ aiServerUrl?: string;
586
+ apiUrl?: string;
587
+ maintainerProUrl?: string;
588
+ maintainerProApiKey?: string;
589
+ };
590
+ }
591
+ }
592
+
593
+ const AI_SERVER_URL = (process.env.NEXT_PUBLIC_AI_SERVER_URL || "").replace(/\\/$/, "");
594
+
595
+ export function MaintainerProWidget() {
596
+ useEffect(() => {
597
+ if (!AI_SERVER_URL) {
598
+ console.error("Set NEXT_PUBLIC_AI_SERVER_URL to the ai-server origin");
599
+ return;
600
+ }
601
+ let cancelled = false;
602
+
603
+ const start = (cfg: {
604
+ apiUrl?: string;
605
+ maintainerProUrl?: string;
606
+ maintainerProApiKey?: string;
607
+ }) => {
608
+ if (cancelled) return;
609
+ const AiUi = window.AiUi;
610
+ if (!AiUi?.init) {
611
+ setTimeout(() => start(cfg), 40);
612
+ return;
613
+ }
614
+ AiUi.init({
615
+ apiUrl: cfg.apiUrl || \`\${AI_SERVER_URL}/api/chat\`,
616
+ title: "AI Assistant",
617
+ maintainerProUrl: cfg.maintainerProUrl,
618
+ maintainerProApiKey: cfg.maintainerProApiKey,
619
+ });
620
+ };
621
+
622
+ const ensureScript = (src: string) =>
623
+ new Promise<void>((resolve, reject) => {
624
+ const existing = document.querySelector<HTMLScriptElement>(\`script[src="\${src}"]\`);
625
+ if (existing) {
626
+ if (existing.dataset.loaded === "1") resolve();
627
+ else existing.addEventListener("load", () => resolve(), { once: true });
628
+ return;
629
+ }
630
+ const script = document.createElement("script");
631
+ script.src = src;
632
+ script.async = true;
633
+ script.onload = () => {
634
+ script.dataset.loaded = "1";
635
+ resolve();
636
+ };
637
+ script.onerror = () => reject(new Error(\`Failed to load \${src}\`));
638
+ document.body.appendChild(script);
639
+ });
640
+
641
+ void (async () => {
642
+ try {
643
+ await ensureScript(\`\${AI_SERVER_URL}/embed-config.js\`);
644
+ await ensureScript(\`\${AI_SERVER_URL}/ai-ui.iife.js\`);
645
+ start(window.__MAINTAINER_PRO__ || {});
646
+ } catch (err) {
647
+ console.error(err);
648
+ }
649
+ })();
650
+
651
+ return () => {
652
+ cancelled = true;
653
+ window.AiUi?.destroy?.();
654
+ };
655
+ }, []);
656
+
657
+ return null;
658
+ }
659
+ `;
660
+ }
661
+
662
+ function tryMountNextWidget(dir) {
663
+ const candidates = [
664
+ path.join(dir, "app", "layout.tsx"),
665
+ path.join(dir, "app", "layout.jsx"),
666
+ path.join(dir, "src", "app", "layout.tsx"),
667
+ path.join(dir, "src", "app", "layout.jsx"),
668
+ ];
669
+ for (const layout of candidates) {
670
+ if (!fs.existsSync(layout)) continue;
671
+ let text = fs.readFileSync(layout, "utf8");
672
+ if (text.includes("MaintainerProWidget")) {
673
+ return { mounted: false, reason: "already mounted" };
674
+ }
675
+ const fromAppRoot =
676
+ /[/\\]app[/\\]layout\.(t|j)sx$/.test(layout) &&
677
+ !/[/\\]src[/\\]app[/\\]/.test(layout);
678
+ const imp = fromAppRoot
679
+ ? "../components/MaintainerProWidget"
680
+ : "@/components/MaintainerProWidget";
681
+
682
+ text = `import { MaintainerProWidget } from "${imp}";\n` + text;
683
+ if (/\{children\}/.test(text)) {
684
+ text = text.replace(
685
+ /\{children\}/,
686
+ "{children}\n <MaintainerProWidget />"
687
+ );
688
+ fs.writeFileSync(layout, text, "utf8");
689
+ return { mounted: true, layout };
690
+ }
691
+ }
692
+ return { mounted: false, reason: "no layout found" };
693
+ }
694
+
695
+ /**
696
+ * Configure client files for a workspace.
697
+ */
698
+ function configureClient(opts) {
699
+ const {
700
+ dir,
701
+ port,
702
+ appName,
703
+ mode, // auto | empty | existing | skip
704
+ hostAppUrl,
705
+ } = opts;
706
+ const aiOrigin = `http://localhost:${port}`;
707
+ /** @type {string[]} */
708
+ const notes = [];
709
+ /** @type {string[]} */
710
+ const filesWritten = [];
711
+
712
+ let kind = detectProjectKind(dir);
713
+ if (mode === "empty") kind = "empty";
714
+ if (mode === "existing") {
715
+ if (kind === "empty") kind = "other";
716
+ }
717
+ if (mode === "skip") {
718
+ return {
719
+ kind: "skipped",
720
+ notes: ["Client scaffolding skipped (manual)."],
721
+ filesWritten,
722
+ corsOrigin: hostAppUrl || null,
723
+ appUrl: hostAppUrl || null,
724
+ sameOrigin: false,
725
+ };
726
+ }
727
+
728
+ if (kind === "empty" || mode === "empty") {
729
+ const indexPath = path.join(dir, "index.html");
730
+ if (!fs.existsSync(indexPath)) {
731
+ fs.writeFileSync(indexPath, emptyProjectIndexHtml(appName), "utf8");
732
+ filesWritten.push("index.html");
733
+ notes.push("Created index.html (served by ai-server).");
734
+ } else {
735
+ notes.push("index.html already present.");
736
+ }
737
+ return {
738
+ kind: "empty",
739
+ notes,
740
+ filesWritten,
741
+ corsOrigin: aiOrigin,
742
+ appUrl: hostAppUrl || aiOrigin,
743
+ sameOrigin: true,
744
+ };
745
+ }
746
+
747
+ if (kind === "html") {
748
+ const candidates = [
749
+ path.join(dir, "index.html"),
750
+ path.join(dir, "public", "index.html"),
751
+ ];
752
+ for (const htmlPath of candidates) {
753
+ if (!fs.existsSync(htmlPath)) continue;
754
+ const raw = fs.readFileSync(htmlPath, "utf8");
755
+ const { html, injected } = injectHtmlWidget(raw, aiOrigin);
756
+ if (injected) {
757
+ fs.writeFileSync(htmlPath, html, "utf8");
758
+ filesWritten.push(path.relative(dir, htmlPath));
759
+ notes.push(`Injected widget into ${path.relative(dir, htmlPath)}.`);
760
+ } else {
761
+ notes.push(`Widget already present in ${path.relative(dir, htmlPath)}.`);
762
+ }
763
+ break;
764
+ }
765
+ const origin = hostAppUrl || "http://localhost:3000";
766
+ return {
767
+ kind: "html",
768
+ notes,
769
+ filesWritten,
770
+ corsOrigin: origin,
771
+ appUrl: origin,
772
+ sameOrigin: false,
773
+ };
774
+ }
775
+
776
+ if (kind === "next") {
777
+ const envLocal = path.join(dir, ".env.local");
778
+ mergeEnvFile(envLocal, {
779
+ NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
780
+ });
781
+ filesWritten.push(".env.local");
782
+ notes.push("Set NEXT_PUBLIC_AI_SERVER_URL in .env.local.");
783
+
784
+ const useSrc = fs.existsSync(path.join(dir, "src", "app"));
785
+ const compDir = useSrc
786
+ ? path.join(dir, "src", "components")
787
+ : path.join(dir, "components");
788
+ fs.mkdirSync(compDir, { recursive: true });
789
+ const widgetPath = path.join(compDir, "MaintainerProWidget.tsx");
790
+ if (!fs.existsSync(widgetPath)) {
791
+ fs.writeFileSync(widgetPath, nextWidgetComponentSource(), "utf8");
792
+ filesWritten.push(path.relative(dir, widgetPath));
793
+ notes.push("Added MaintainerProWidget.tsx.");
794
+ }
795
+ const mount = tryMountNextWidget(dir);
796
+ if (mount.mounted) {
797
+ notes.push(`Mounted widget in ${path.relative(dir, mount.layout)}.`);
798
+ } else {
799
+ notes.push(
800
+ "Add <MaintainerProWidget /> to your root layout if it is not mounted yet."
801
+ );
802
+ }
803
+ const origin = hostAppUrl || "http://localhost:3000";
804
+ return {
805
+ kind: "next",
806
+ notes,
807
+ filesWritten,
808
+ corsOrigin: origin,
809
+ appUrl: origin,
810
+ sameOrigin: false,
811
+ };
812
+ }
813
+
814
+ // other — write host env hints only
815
+ const origin = hostAppUrl || "http://localhost:3000";
816
+ mergeEnvFile(path.join(dir, ".env"), {
817
+ AI_SERVER_URL: aiOrigin,
818
+ NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
819
+ });
820
+ notes.push(
821
+ "Existing project detected. Set AI_SERVER_URL / mount the widget manually if needed."
822
+ );
823
+ return {
824
+ kind: "other",
825
+ notes,
826
+ filesWritten,
827
+ corsOrigin: origin,
828
+ appUrl: origin,
829
+ sameOrigin: false,
830
+ };
831
+ }
832
+
259
833
  function collectOfferedFolders(cfg) {
260
834
  /** @type {string[]} */
261
835
  const folders = [];
@@ -270,26 +844,389 @@ function collectOfferedFolders(cfg) {
270
844
  return folders;
271
845
  }
272
846
 
273
- /** @type {Map<string, import('node:child_process').ChildProcess>} */
274
- 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();
275
852
 
276
- function stopChild(sandboxId) {
277
- const child = children.get(sandboxId);
278
- if (!child || child.killed) {
279
- children.delete(sandboxId);
280
- 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);
281
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) {
282
914
  try {
283
- child.kill("SIGTERM");
915
+ return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
284
916
  } catch {
285
- /* 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}`;
286
1027
  }
287
- children.delete(sandboxId);
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
+ });
288
1073
  }
289
1074
 
290
- function startAiServerForWorkspace(ws) {
291
- const key = ws.sandboxId;
292
- if (children.has(key) && !children.get(key)?.killed) return;
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}`;
1159
+ }
1160
+ return job.command;
1161
+ }
1162
+
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
+ }
293
1230
 
294
1231
  const localCli = path.resolve(
295
1232
  __dirname,
@@ -300,42 +1237,371 @@ function startAiServerForWorkspace(ws) {
300
1237
  "cli.js"
301
1238
  );
302
1239
  const useLocal = fs.existsSync(localCli);
303
- const env = {
304
- ...process.env,
305
- PORT: String(ws.port),
306
- AI_SERVER_URL: `http://localhost:${ws.port}`,
307
- NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${ws.port}`,
308
- };
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}`;
309
1245
 
310
- log(`starting ai-server for ${ws.sandboxName || ws.sandboxId} on :${ws.port}`);
311
- const child = spawn(
312
- useLocal
313
- ? process.execPath
314
- : process.platform === "win32"
315
- ? "npx.cmd"
316
- : "npx",
317
- useLocal ? [localCli] : ["--yes", "@maintainer-pro/ai-server"],
318
- {
319
- cwd: ws.folderPath,
320
- env,
321
- stdio: ["ignore", "pipe", "pipe"],
322
- shell: process.platform === "win32" && !useLocal,
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
+ }
1274
+
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 */
323
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
324
1306
  );
325
- const tag = ws.sandboxName || ws.sandboxId.slice(0, 8);
326
- child.stdout?.on("data", (buf) => {
327
- const line = String(buf).trimEnd();
328
- if (line) console.log(`[ai-server:${tag}] ${line}`);
329
- });
330
- child.stderr?.on("data", (buf) => {
331
- const line = String(buf).trimEnd();
332
- 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);
333
1368
  });
334
- child.on("exit", (code, signal) => {
335
- log(`ai-server ${tag} exited (code=${code}, signal=${signal})`);
336
- 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);
337
1414
  });
338
- 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;
339
1605
  }
340
1606
 
341
1607
  async function setupWorkspace(cfg, action) {
@@ -343,7 +1609,29 @@ async function setupWorkspace(cfg, action) {
343
1609
  const sandboxId = String(
344
1610
  action.sandboxId || action.payload?.sandboxId || ""
345
1611
  );
346
- 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
+ }
1628
+ const clientMode = String(action.payload?.clientMode || "auto"); // auto|empty|existing|skip
1629
+ const hostAppUrl =
1630
+ typeof action.payload?.hostAppUrl === "string" &&
1631
+ action.payload.hostAppUrl.trim()
1632
+ ? action.payload.hostAppUrl.trim().replace(/\/$/, "")
1633
+ : null;
1634
+
347
1635
  if (!folderPath || !sandboxId) {
348
1636
  throw new Error("setup_workspace requires folderPath and sandboxId");
349
1637
  }
@@ -357,13 +1645,34 @@ async function setupWorkspace(cfg, action) {
357
1645
  `/api/v1/bridge/machine/sandboxes/${sandboxId}/setup-config?port=${port}`
358
1646
  );
359
1647
 
1648
+ const appName =
1649
+ config.sandbox?.applicationName ||
1650
+ config.sandbox?.name ||
1651
+ "Maintainer Pro App";
1652
+
1653
+ const client = configureClient({
1654
+ dir: resolved,
1655
+ port,
1656
+ appName,
1657
+ mode: clientMode,
1658
+ hostAppUrl,
1659
+ });
1660
+
1661
+ const aiOrigin = `http://localhost:${port}`;
1662
+ const corsOrigin = client.corsOrigin || aiOrigin;
1663
+ const appUrl = client.appUrl || corsOrigin;
1664
+
360
1665
  const envPath = path.join(resolved, ".env");
361
1666
  const envValues = {
362
1667
  ...config.env,
363
1668
  AI_CLI_WORKSPACE: ".",
364
- AI_SERVER_UI: ".",
365
- CORS_ORIGIN: process.env.CORS_ORIGIN || `http://localhost:${port}`,
366
- APP_URL: process.env.APP_URL || `http://localhost:3000`,
1669
+ AI_SERVER_UI: client.sameOrigin || client.kind === "empty" ? "." : ".",
1670
+ PORT: String(port),
1671
+ AI_SERVER_URL: aiOrigin,
1672
+ NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
1673
+ CORS_ORIGIN: corsOrigin,
1674
+ APP_URL: appUrl,
1675
+ AI_SERVER_PRODUCT_DESCRIPTION: appName,
367
1676
  };
368
1677
  mergeEnvFile(envPath, envValues);
369
1678
 
@@ -375,6 +1684,9 @@ async function setupWorkspace(cfg, action) {
375
1684
  port,
376
1685
  sandboxName: config.sandbox?.name,
377
1686
  applicationName: config.sandbox?.applicationName,
1687
+ clientKind: client.kind,
1688
+ appUrl,
1689
+ sameOrigin: Boolean(client.sameOrigin),
378
1690
  };
379
1691
  if (existing >= 0) cfg.workspaces[existing] = entry;
380
1692
  else cfg.workspaces.push(entry);
@@ -382,18 +1694,66 @@ async function setupWorkspace(cfg, action) {
382
1694
  if (!cfg.offeredFolders.includes(resolved)) cfg.offeredFolders.push(resolved);
383
1695
  saveConfig(cfg);
384
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
+
385
1708
  if (!cfg.noAiServer) {
386
- startAiServerForWorkspace(entry);
387
- await new Promise((r) => setTimeout(r, 1200));
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");
1722
+ }
1723
+
1724
+ const processIssues = issuesForSandbox(sandboxId).map(
1725
+ ({ role: _role, ...issue }) => issue
1726
+ );
1727
+ const warning = processIssues[0]?.message || null;
1728
+
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");
388
1739
  }
389
1740
 
390
1741
  return {
391
1742
  sandboxId,
392
1743
  folderPath: resolved,
393
- port,
394
- appUrl: envValues.APP_URL || null,
395
- origins: envValues.CORS_ORIGIN ? [envValues.CORS_ORIGIN] : [],
1744
+ port: entry.port,
1745
+ appUrl: entry.appUrl || appUrl,
1746
+ origins: [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
396
1747
  wroteEnv: true,
1748
+ clientKind: client.kind,
1749
+ clientFiles: client.filesWritten,
1750
+ clientNotes: client.notes,
1751
+ aiServerUp,
1752
+ startedHosts,
1753
+ openUrl,
1754
+ processIssues,
1755
+ warning,
1756
+ projectInfo,
397
1757
  };
398
1758
  }
399
1759
 
@@ -410,7 +1770,15 @@ async function runActions(cfg, actions) {
410
1770
  } else if (action.code === "setup_workspace") {
411
1771
  result = await setupWorkspace(cfg, action);
412
1772
  } else if (action.code === "recheck") {
413
- 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
+ };
414
1782
  } else if (action.code === "start_ai_server") {
415
1783
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
416
1784
  const ws =
@@ -426,23 +1794,88 @@ async function runActions(cfg, actions) {
426
1794
  ok = false;
427
1795
  result = { error: "No workspace or --no-ai-server" };
428
1796
  } else {
429
- startAiServerForWorkspace(ws);
430
- 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;
431
1825
  result = {
432
- up: await probeUrl(
433
- `http://127.0.0.1:${ws.port}/embed-config.js`
434
- ),
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,
435
1835
  };
1836
+ if (processIssues.some((issue) => issue.code === "ai_server_launch")) {
1837
+ result.error = warning;
1838
+ ok = false;
1839
+ }
436
1840
  }
437
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);
438
1844
  result = {
439
- 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,
440
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
+ }
441
1874
  } else if (action.code === "remove_workspace") {
442
1875
  const sandboxId = String(
443
1876
  action.sandboxId || action.payload?.sandboxId || ""
444
1877
  );
445
- stopChild(sandboxId);
1878
+ forgetLaunch(sandboxId);
446
1879
  cfg.workspaces = (cfg.workspaces || []).filter(
447
1880
  (w) => w.sandboxId !== sandboxId
448
1881
  );
@@ -475,10 +1908,53 @@ async function runActions(cfg, actions) {
475
1908
  }
476
1909
  }
477
1910
 
478
- 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) {
479
1955
  /** @type {Array<Record<string, unknown>>} */
480
1956
  const issues = [];
481
- const cli = detectCliProviders();
1957
+ const cli = await detectCliProviders();
482
1958
  if (!cli.length) {
483
1959
  issues.push({
484
1960
  code: "missing_cli",
@@ -492,17 +1968,54 @@ function buildIssues(cfg, workspaceStates) {
492
1968
  });
493
1969
  }
494
1970
  for (const st of workspaceStates) {
495
- if (!st.aiServerUp) {
496
- issues.push({
497
- code: "ai_server_down",
498
- severity: "error",
499
- title: `AI server down (${st.sandboxName || st.sandboxId})`,
500
- message: `Cannot reach http://localhost:${st.port}`,
501
- resolution: "Start ai-server from the Bridges page or restart the bridge.",
502
- actionCode: "start_ai_server",
503
- sandboxId: st.sandboxId,
504
- });
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;
505
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
+ });
506
2019
  }
507
2020
  return issues;
508
2021
  }
@@ -542,6 +2055,8 @@ async function pairFlow(args) {
542
2055
  saveConfig(cfg);
543
2056
  log(`paired as ${result.machine?.name || cfg.machineId}`);
544
2057
  log(`config ${configPath()}`);
2058
+ log("Keep this process running.");
2059
+ log("Next: Admin → Bridges → pick a folder → Setup sandbox.");
545
2060
  return cfg;
546
2061
  }
547
2062
 
@@ -584,35 +2099,32 @@ async function main() {
584
2099
  /** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,folderPath:string}>} */
585
2100
  const localStates = [];
586
2101
 
2102
+ const reserved = new Set();
587
2103
  for (const ws of cfg.workspaces || []) {
2104
+ const folder = path.resolve(ws.folderPath || "");
588
2105
  const up = await probeUrl(
589
2106
  `http://127.0.0.1:${ws.port}/embed-config.js`
590
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);
591
2116
  localStates.push({
592
2117
  sandboxId: ws.sandboxId,
593
2118
  sandboxName: ws.sandboxName,
594
2119
  port: ws.port,
595
2120
  folderPath: ws.folderPath,
596
2121
  aiServerUp: up,
2122
+ startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
2123
+ appUrl: ws.appUrl || null,
597
2124
  });
598
- if (!cfg.noAiServer && !up) {
599
- startAiServerForWorkspace(ws);
600
- }
601
2125
  }
602
2126
 
603
- const hb = await api(
604
- cfg.adminUrl,
605
- cfg.token,
606
- "POST",
607
- "/api/v1/bridge/machine/heartbeat",
608
- {
609
- hostname: os.hostname(),
610
- platform: `${os.platform()}-${os.arch()}`,
611
- bridgeVersion: PACKAGE_VERSION,
612
- folders,
613
- issues: buildIssues(cfg, localStates),
614
- }
615
- );
2127
+ const hb = await sendHeartbeat(cfg, folders, localStates);
616
2128
 
617
2129
  // Sync local workspace list from server assignments
618
2130
  if (Array.isArray(hb.workspaces)) {
@@ -641,20 +2153,45 @@ async function main() {
641
2153
 
642
2154
  if (Array.isArray(hb.actions) && hb.actions.length > 0) {
643
2155
  await runActions(cfg, hb.actions);
2156
+ await sendHeartbeat(cfg, folders, await collectWorkspaceStates(cfg));
644
2157
  }
645
2158
  } catch (err) {
646
2159
  warn(err instanceof Error ? err.message : String(err));
647
2160
  }
648
2161
  };
649
2162
 
650
- 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);
651
2175
  setInterval(() => {
652
- void tick();
2176
+ void runLocked(tick);
653
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);
654
2191
 
655
2192
  const shutdown = () => {
656
- log("shutting down");
657
- for (const id of [...children.keys()]) stopChild(id);
2193
+ stopAllCloudflare();
2194
+ log("shutting down (other terminals stay open)");
658
2195
  process.exit(0);
659
2196
  };
660
2197
  process.on("SIGINT", shutdown);