@maintainer-pro/ai-bridge 0.1.7 → 0.1.9

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 (4) hide show
  1. package/README.md +15 -11
  2. package/bin/cli.js +2 -1
  3. package/package.json +3 -2
  4. package/src/daemon.mjs +2247 -1547
package/src/daemon.mjs CHANGED
@@ -3,9 +3,8 @@
3
3
  * Maintainer Pro bridge — partner machine daemon.
4
4
  *
5
5
  * Pair once with a code from admin, then manage many sandboxes/folders:
6
- * npx @maintainer-pro/ai-bridge --pair ABCD-EF01
7
- * npx @maintainer-pro/ai-bridge
8
- * npx @maintainer-pro/ai-bridge --admin-url https://… --pair ABCD-EF01
6
+ * npx --yes --prefer-online @maintainer-pro/ai-bridge@latest --pair ABCD-EF01
7
+ * npm run bridge -- --pair ABCD-EF01 --admin-url http://localhost:4100
9
8
  */
10
9
  import { spawn } from "node:child_process";
11
10
  import { createHash, randomBytes } from "node:crypto";
@@ -17,14 +16,17 @@ import os from "node:os";
17
16
  import path from "node:path";
18
17
  import readline from "node:readline";
19
18
  import { fileURLToPath, pathToFileURL } from "node:url";
20
- import { createLogger } from "@maintainer-pro/ai-cli";
19
+ import { createLogger, ensureProjectDataDir } from "@maintainer-pro/ai-cli";
20
+ import { findIife, startAiServer } from "@maintainer-pro/ai-server";
21
21
 
22
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
23
  const PACKAGE_VERSION = readPackageVersion();
24
24
  const HEARTBEAT_MS = 15_000;
25
25
  const WS_PING_MS = 10_000;
26
- const WS_RECONNECT_MIN_MS = 500;
27
- const WS_RECONNECT_MAX_MS = 8_000;
26
+ const WS_RECONNECT_MIN_MS = 1_000;
27
+ const WS_RECONNECT_MAX_MS = 30_000;
28
+ const WS_CONNECT_TIMEOUT_MS = 20_000;
29
+ const WS_WATCHDOG_MS = 5_000;
28
30
 
29
31
  /** @type {import("@maintainer-pro/ai-cli").Logger} */
30
32
  let logger = createLogger("ai-bridge");
@@ -35,6 +37,31 @@ const fail = (msg) => {
35
37
  process.exit(1);
36
38
  };
37
39
 
40
+ /** @type {Map<string, Array<{ at: string, level: string, message: string }>>} */
41
+ const activityBySandbox = new Map();
42
+
43
+ /** @type {(payload: Record<string, unknown>) => boolean} */
44
+ let bridgeSend = () => false;
45
+
46
+ function activity(sandboxId, level, message) {
47
+ const text = String(message || "").trim();
48
+ if (!text) return;
49
+ if (level === "error" || level === "warn") warn(text);
50
+ else log(text);
51
+ if (!sandboxId) return;
52
+ const list = activityBySandbox.get(sandboxId) || [];
53
+ list.push({
54
+ at: new Date().toISOString(),
55
+ level: level === "error" || level === "warn" ? level : "info",
56
+ message: text.slice(0, 500),
57
+ });
58
+ activityBySandbox.set(sandboxId, list.slice(-40));
59
+ }
60
+
61
+ function activityLogFor(sandboxId) {
62
+ return activityBySandbox.get(sandboxId) || [];
63
+ }
64
+
38
65
  function shortId(value) {
39
66
  const text = String(value || "");
40
67
  return text.length > 12 ? `${text.slice(0, 8)}…` : text;
@@ -83,6 +110,28 @@ function readPackageVersion() {
83
110
  }
84
111
  }
85
112
 
113
+ async function warnIfBridgeOutdated() {
114
+ const fromWorkspace = path
115
+ .normalize(__dirname)
116
+ .includes(`${path.sep}packages${path.sep}ai-bridge${path.sep}`);
117
+ if (fromWorkspace) return;
118
+ try {
119
+ const res = await fetch(
120
+ "https://registry.npmjs.org/@maintainer-pro/ai-bridge/latest",
121
+ { signal: AbortSignal.timeout(2500) }
122
+ );
123
+ if (!res.ok) return;
124
+ const body = await res.json();
125
+ const latest = typeof body?.version === "string" ? body.version : "";
126
+ if (!latest || latest === PACKAGE_VERSION) return;
127
+ warn(
128
+ `this process is v${PACKAGE_VERSION}; npm latest is v${latest}. Re-run: npx --yes --prefer-online @maintainer-pro/ai-bridge@${latest}`
129
+ );
130
+ } catch {
131
+ // Registry unreachable — keep running the local copy.
132
+ }
133
+ }
134
+
86
135
  function parseArgs(argv) {
87
136
  /** @type {Record<string, string | boolean>} */
88
137
  const out = {};
@@ -120,17 +169,19 @@ Pair this machine to your partner account, then configure folders for any
120
169
  client sandbox from the Maintainer Pro admin UI.
121
170
 
122
171
  Usage:
123
- npx @maintainer-pro/ai-bridge --pair ABCD-EF01 --admin-url https://admin.example.com
124
- npx @maintainer-pro/ai-bridge
125
- npx @maintainer-pro/ai-bridge --offer-folder D:\\apps\\client-a
172
+ npx --yes --prefer-online @maintainer-pro/ai-bridge@latest --pair ABCD-EF01 --admin-url https://admin.example.com
173
+ npx --yes --prefer-online @maintainer-pro/ai-bridge@latest
174
+
175
+ From the SDK repo (local development):
176
+ npm run bridge -- --pair ABCD-EF01 --admin-url http://localhost:4100
177
+ npm run bridge
126
178
 
127
179
  Config is stored in ~/.maintainer-pro/bridge.json (not the cwd).
128
180
 
129
181
  Flags:
130
182
  --pair <code> Claim a pair code from admin → Bridges page
131
183
  --admin-url <url> Maintainer Pro base URL (required for first pair)
132
- --offer-folder <path> Suggest this folder in admin (repeatable via config)
133
- --no-ai-server Do not open ai-server terminals for workspaces
184
+ --no-ai-server Do not start in-process chat for workspaces
134
185
  --help
135
186
 
136
187
  Logging (env):
@@ -143,6 +194,14 @@ function configDir() {
143
194
  return path.join(os.homedir(), ".maintainer-pro");
144
195
  }
145
196
 
197
+ /** Per-sandbox data (apps cache, uploads, logs) — never the host app folder. */
198
+ function dataDirFor(folder, sandboxId) {
199
+ return ensureProjectDataDir({
200
+ workspaceDir: folder || "",
201
+ sandboxId: sandboxId || undefined,
202
+ });
203
+ }
204
+
146
205
  function configPath() {
147
206
  return path.join(configDir(), "bridge.json");
148
207
  }
@@ -263,15 +322,11 @@ let aiCliModule = null;
263
322
  async function loadAiCli() {
264
323
  if (!aiCliModule) {
265
324
  aiCliModule = (async () => {
266
- try {
267
- return await import("@maintainer-pro/ai-cli");
268
- } catch {
269
- const local = path.resolve(__dirname, "..", "..", "ai-cli", "dist", "index.js");
270
- if (fs.existsSync(local)) {
271
- return await import(pathToFileURL(local).href);
272
- }
273
- throw new Error("@maintainer-pro/ai-cli is not installed");
325
+ const local = path.resolve(__dirname, "..", "..", "ai-cli", "dist", "index.js");
326
+ if (fs.existsSync(local)) {
327
+ return await import(pathToFileURL(local).href);
274
328
  }
329
+ return await import("@maintainer-pro/ai-cli");
275
330
  })();
276
331
  }
277
332
  return aiCliModule;
@@ -295,6 +350,85 @@ async function detectCliProviders() {
295
350
  }
296
351
  }
297
352
 
353
+ async function resolveWorkspaceHostApps(ws, opts = {}) {
354
+ const folder = path.resolve(ws.folderPath || "");
355
+ const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
356
+ const desired = Array.isArray(opts.desired)
357
+ ? opts.desired
358
+ : Array.isArray(ws.hostApps)
359
+ ? ws.hostApps
360
+ : [];
361
+ try {
362
+ const cli = await loadAiCli();
363
+ if (typeof cli.resolveHostApps !== "function") {
364
+ throw new Error("ai-cli is missing resolveHostApps — update @maintainer-pro/ai-cli");
365
+ }
366
+ activity(
367
+ ws.sandboxId,
368
+ "info",
369
+ `detecting ports for ${label} in ${folder} (env first${opts.force ? ", redetect" : ""})`
370
+ );
371
+ const result = await cli.resolveHostApps({
372
+ workspaceDir: folder,
373
+ appName: ws.applicationName || ws.sandboxName,
374
+ preferredAiPort: Number(ws.port) || 3100,
375
+ desired: opts.ignoreDesired ? [] : desired,
376
+ force: Boolean(opts.force),
377
+ allowAi: Boolean(opts.allowAi),
378
+ sandboxId: ws.sandboxId,
379
+ });
380
+ const previous = Array.isArray(ws.hostApps) ? ws.hostApps : [];
381
+ result.apps = result.apps.map((app) => {
382
+ const match = previous.find((row) => row && row.id === app.id);
383
+ if (!match || (app.envMaps && app.envMaps.length)) return app;
384
+ return { ...app, envMaps: match.envMaps || [] };
385
+ });
386
+ ws.hostApps = result.apps;
387
+ if (opts.cfg) persistWorkspaceEntry(opts.cfg, ws);
388
+ const summary = result.apps
389
+ .map((app) => `${app.name}:${app.port}`)
390
+ .join(", ");
391
+ activity(
392
+ ws.sandboxId,
393
+ result.confused ? "warn" : "info",
394
+ `ports ${result.source}${result.cached ? " cache" : ""}${
395
+ result.usedAi ? " + ai-cli" : ""
396
+ }: ${summary || "none"}`
397
+ );
398
+ for (const reason of result.reasons || []) {
399
+ activity(ws.sandboxId, "warn", reason);
400
+ }
401
+ return result;
402
+ } catch (err) {
403
+ const message = err instanceof Error ? err.message : String(err);
404
+ activity(
405
+ ws.sandboxId,
406
+ "warn",
407
+ `port detect failed (${message}) — using AI server ${Number(ws.port) || 3100} so setup can continue`
408
+ );
409
+ if (!Array.isArray(ws.hostApps) || !ws.hostApps.length) {
410
+ ws.hostApps = [
411
+ {
412
+ id: "ai-server",
413
+ name: "AI server",
414
+ role: "ai-server",
415
+ port: Number(ws.port) || 3100,
416
+ source: "default",
417
+ locked: true,
418
+ },
419
+ ];
420
+ }
421
+ return {
422
+ apps: ws.hostApps,
423
+ source: "default",
424
+ cached: false,
425
+ usedAi: false,
426
+ confused: true,
427
+ reasons: [message],
428
+ };
429
+ }
430
+ }
431
+
298
432
  function applyProjectInfo(ws, info, cfg) {
299
433
  if (!ws || !info) return;
300
434
  const folder = path.resolve(ws.folderPath || "");
@@ -328,8 +462,8 @@ function applyProjectInfo(ws, info, cfg) {
328
462
 
329
463
  const PROJECT_INSPECT_CACHE = "project-inspect.json";
330
464
 
331
- function projectInspectCachePath(folder) {
332
- return path.join(folder, ".maintainer-pro", PROJECT_INSPECT_CACHE);
465
+ function projectInspectCachePath(folder, sandboxId) {
466
+ return path.join(dataDirFor(folder, sandboxId), PROJECT_INSPECT_CACHE);
333
467
  }
334
468
 
335
469
  /** Invalidate cache when package.json / common config files change. */
@@ -378,10 +512,10 @@ function usableCachedProjectInfo(info) {
378
512
  return hasKind || hasScript || Boolean(info.summary);
379
513
  }
380
514
 
381
- function readProjectInspectCache(folder) {
515
+ function readProjectInspectCache(folder, sandboxId) {
382
516
  const resolved = path.resolve(folder || "");
383
517
  try {
384
- const file = projectInspectCachePath(resolved);
518
+ const file = projectInspectCachePath(resolved, sandboxId);
385
519
  if (!fs.existsSync(file)) return null;
386
520
  const data = JSON.parse(fs.readFileSync(file, "utf8"));
387
521
  if (!data || typeof data !== "object") return null;
@@ -399,11 +533,11 @@ function readProjectInspectCache(folder) {
399
533
  }
400
534
  }
401
535
 
402
- function writeProjectInspectCache(folder, info) {
536
+ function writeProjectInspectCache(folder, info, sandboxId) {
403
537
  const resolved = path.resolve(folder || "");
404
538
  if (!info || !usableCachedProjectInfo(info)) return;
405
539
  try {
406
- const dir = path.join(resolved, ".maintainer-pro");
540
+ const dir = dataDirFor(resolved, sandboxId);
407
541
  fs.mkdirSync(dir, { recursive: true });
408
542
  const fingerprint = projectFingerprint(resolved);
409
543
  const payload = {
@@ -423,7 +557,7 @@ function writeProjectInspectCache(folder, info) {
423
557
  },
424
558
  };
425
559
  fs.writeFileSync(
426
- projectInspectCachePath(resolved),
560
+ projectInspectCachePath(resolved, sandboxId),
427
561
  `${JSON.stringify(payload, null, 2)}\n`,
428
562
  "utf8"
429
563
  );
@@ -447,7 +581,7 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
447
581
 
448
582
  if (!force) {
449
583
  const fingerprint = projectFingerprint(folder);
450
- const fromDisk = readProjectInspectCache(folder);
584
+ const fromDisk = readProjectInspectCache(folder, ws.sandboxId);
451
585
  const fromWs =
452
586
  usableCachedProjectInfo(ws.projectInfo) &&
453
587
  (!ws.projectInfo.fingerprint ||
@@ -458,7 +592,7 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
458
592
  if (cached) {
459
593
  log(`using cached project inspect for ${folder}`);
460
594
  applyProjectInfo(ws, { ...cached, fingerprint }, opts.cfg);
461
- if (!fromDisk) writeProjectInspectCache(folder, cached);
595
+ if (!fromDisk) writeProjectInspectCache(folder, cached, ws.sandboxId);
462
596
  clearProcessProblem(ws.sandboxId, "project_issue", "inspect");
463
597
  return { ...cached, fingerprint, cached: true };
464
598
  }
@@ -475,7 +609,7 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
475
609
  });
476
610
  const fingerprint = projectFingerprint(folder);
477
611
  applyProjectInfo(ws, { ...info, fingerprint }, opts.cfg);
478
- writeProjectInspectCache(folder, info);
612
+ writeProjectInspectCache(folder, info, ws.sandboxId);
479
613
  if (info.issues?.length) {
480
614
  recordProcessProblem({
481
615
  sandboxId: ws.sandboxId,
@@ -498,7 +632,7 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
498
632
  warn(`ai-cli inspect failed: ${message}`);
499
633
  // Fall back to any stale cache so Start Apps can still try.
500
634
  const fallback =
501
- readProjectInspectCache(folder) ||
635
+ readProjectInspectCache(folder, ws.sandboxId) ||
502
636
  (usableCachedProjectInfo(ws.projectInfo) ? ws.projectInfo : null);
503
637
  if (fallback) {
504
638
  warn(`falling back to cached project inspect after ai-cli error`);
@@ -559,10 +693,14 @@ function urlWithPort(url, port) {
559
693
 
560
694
  function isLocalAppUrl(url) {
561
695
  try {
562
- const host = new URL(url).hostname;
563
- return host === "localhost" || host === "127.0.0.1";
696
+ const parsed = new URL(String(url || "").trim());
697
+ // Share proxy paths live on the admin host; they are not the listen URL.
698
+ if (parsed.pathname === "/p" || parsed.pathname.startsWith("/p/")) {
699
+ return false;
700
+ }
701
+ return parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
564
702
  } catch {
565
- return true;
703
+ return false;
566
704
  }
567
705
  }
568
706
 
@@ -575,6 +713,71 @@ function persistWorkspaceEntry(cfg, ws) {
575
713
  saveConfig(cfg);
576
714
  }
577
715
 
716
+ /**
717
+ * Sandbox store keys live on the workspace (and ~/.maintainer-pro/bridge.json),
718
+ * not in the app .env. Never use the bridge machine token as MAINTAINER_PRO_API_KEY.
719
+ */
720
+ async function loadSandboxStoreEnv(ws, cfg) {
721
+ const cached = ws?.store && typeof ws.store === "object" ? ws.store : {};
722
+ const cachedServer = String(cached.serverKey || "").trim();
723
+ const cachedClient = String(cached.clientKey || "").trim();
724
+ if (cachedServer) {
725
+ return {
726
+ MAINTAINER_PRO_URL: cfg?.adminUrl || "",
727
+ MAINTAINER_PRO_API_KEY: cachedServer,
728
+ MAINTAINER_PRO_CLIENT_API_KEY: cachedClient,
729
+ NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY: cachedClient,
730
+ NEXT_PUBLIC_MAINTAINER_PRO_URL: cfg?.adminUrl || "",
731
+ };
732
+ }
733
+ if (!cfg?.adminUrl || !cfg.token || !ws?.sandboxId) {
734
+ return {
735
+ MAINTAINER_PRO_URL: cfg?.adminUrl || "",
736
+ MAINTAINER_PRO_API_KEY: "",
737
+ MAINTAINER_PRO_CLIENT_API_KEY: cachedClient,
738
+ };
739
+ }
740
+ try {
741
+ const config = await api(
742
+ cfg.adminUrl,
743
+ cfg.token,
744
+ "GET",
745
+ `/api/v1/bridge/machine/sandboxes/${ws.sandboxId}/setup-config?port=${
746
+ Number(ws.port) || 3100
747
+ }`
748
+ );
749
+ const env = config?.env && typeof config.env === "object" ? config.env : {};
750
+ const serverKey = String(env.MAINTAINER_PRO_API_KEY || "").trim();
751
+ const clientKey = String(
752
+ env.MAINTAINER_PRO_CLIENT_API_KEY ||
753
+ env.NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY ||
754
+ ""
755
+ ).trim();
756
+ ws.store = { serverKey, clientKey };
757
+ persistWorkspaceEntry(cfg, ws);
758
+ return {
759
+ MAINTAINER_PRO_URL: String(env.MAINTAINER_PRO_URL || cfg.adminUrl || ""),
760
+ MAINTAINER_PRO_API_KEY: serverKey,
761
+ MAINTAINER_PRO_CLIENT_API_KEY: clientKey,
762
+ NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY: clientKey,
763
+ NEXT_PUBLIC_MAINTAINER_PRO_URL: String(
764
+ env.NEXT_PUBLIC_MAINTAINER_PRO_URL || cfg.adminUrl || ""
765
+ ),
766
+ };
767
+ } catch (err) {
768
+ warn(
769
+ `setup-config ${shortId(ws.sandboxId)}: ${
770
+ err instanceof Error ? err.message : String(err)
771
+ }`
772
+ );
773
+ return {
774
+ MAINTAINER_PRO_URL: cfg.adminUrl || "",
775
+ MAINTAINER_PRO_API_KEY: "",
776
+ MAINTAINER_PRO_CLIENT_API_KEY: cachedClient,
777
+ };
778
+ }
779
+ }
780
+
578
781
  function probeUrl(url, timeoutMs = 2500) {
579
782
  return new Promise((resolve) => {
580
783
  let settled = false;
@@ -609,6 +812,88 @@ function probeUrl(url, timeoutMs = 2500) {
609
812
  });
610
813
  }
611
814
 
815
+ function adminListenPort(cfg = bridgeCfg) {
816
+ try {
817
+ const u = new URL(String(cfg?.adminUrl || "http://localhost:4100"));
818
+ if (u.port) return Number(u.port);
819
+ return u.protocol === "https:" ? 443 : 80;
820
+ } catch {
821
+ return 4100;
822
+ }
823
+ }
824
+
825
+ function isAdminListenPort(port, cfg = bridgeCfg) {
826
+ return Number(port) === adminListenPort(cfg);
827
+ }
828
+
829
+ function looksLikeEmbedConfig(body, contentType) {
830
+ const text = String(body || "").trim();
831
+ if (!text || text.startsWith("<")) return false;
832
+ const ct = String(contentType || "").toLowerCase();
833
+ if (ct.includes("html")) return false;
834
+ return (
835
+ text.includes("__MAINTAINER_PRO__") || /javascript|ecmascript/.test(ct)
836
+ );
837
+ }
838
+
839
+ function probeEmbedConfig(url, timeoutMs = 2500) {
840
+ return new Promise((resolve) => {
841
+ let settled = false;
842
+ const done = (ok) => {
843
+ if (settled) return;
844
+ settled = true;
845
+ resolve(ok);
846
+ };
847
+ try {
848
+ const parsed = new URL(String(url));
849
+ const lib = parsed.protocol === "https:" ? https : http;
850
+ const req = lib.get(
851
+ parsed,
852
+ {
853
+ timeout: timeoutMs,
854
+ rejectUnauthorized: true,
855
+ headers: { Accept: "application/javascript,*/*" },
856
+ },
857
+ (res) => {
858
+ const chunks = [];
859
+ res.on("data", (d) => {
860
+ if (chunks.reduce((n, c) => n + c.length, 0) < 8192) chunks.push(d);
861
+ });
862
+ res.on("end", () => {
863
+ const body = Buffer.concat(chunks).toString("utf8");
864
+ done(
865
+ Boolean(res.statusCode && res.statusCode < 400) &&
866
+ looksLikeEmbedConfig(body, res.headers["content-type"])
867
+ );
868
+ });
869
+ }
870
+ );
871
+ req.on("error", () => done(false));
872
+ req.on("timeout", () => {
873
+ req.destroy();
874
+ done(false);
875
+ });
876
+ } catch {
877
+ done(false);
878
+ }
879
+ });
880
+ }
881
+
882
+ function sanitizeAiServerPort(ws) {
883
+ if (!ws || typeof ws !== "object") return;
884
+ const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
885
+ for (const app of apps) {
886
+ if (
887
+ app &&
888
+ (app.role === "ai-server" || app.id === "ai-server") &&
889
+ isAdminListenPort(app.port)
890
+ ) {
891
+ app.port = 3100;
892
+ }
893
+ }
894
+ if (isAdminListenPort(ws.port)) ws.port = 3100;
895
+ }
896
+
612
897
  function fetchText(url, timeoutMs = 8000) {
613
898
  return new Promise((resolve) => {
614
899
  try {
@@ -645,43 +930,33 @@ function fetchText(url, timeoutMs = 8000) {
645
930
  });
646
931
  }
647
932
 
648
- function listDriveRoots() {
649
- if (process.platform !== "win32") return ["/"];
650
- const roots = [];
651
- for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZAB") {
652
- const root = `${letter}:\\`;
653
- try {
654
- if (fs.existsSync(root)) roots.push(root);
655
- } catch {
656
- /* skip */
657
- }
658
- }
659
- return roots.length ? roots : ["C:\\"];
660
- }
661
-
662
- function listDirEntries(dirPath) {
933
+ function listDirEntries(dirPath, allowed) {
663
934
  const raw = String(dirPath || "").trim();
664
935
  const home = os.homedir();
936
+ const roots =
937
+ Array.isArray(allowed) && allowed.length > 0
938
+ ? allowed.map((p) => path.resolve(p))
939
+ : [process.cwd()];
940
+
665
941
  if (!raw || raw === "roots") {
666
- const roots = listDriveRoots();
942
+ return listLaunchRoots(roots);
943
+ }
944
+
945
+ const resolved = path.resolve(raw);
946
+ if (!roots.some((root) => pathInside(resolved, root))) {
667
947
  return {
668
- path: "",
948
+ error: "That folder is outside the directory where the bridge is running.",
949
+ path: resolved,
669
950
  parent: null,
670
951
  home,
671
- entries: roots.map((root) => ({
672
- name: root,
673
- path: root,
674
- isDir: true,
675
- })),
952
+ entries: [],
676
953
  };
677
954
  }
678
-
679
- const resolved = path.resolve(raw);
680
955
  if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
681
956
  return {
682
957
  error: "Not a directory",
683
958
  path: resolved,
684
- parent: path.dirname(resolved),
959
+ parent: isLaunchRoot(resolved, roots) ? null : path.dirname(resolved),
685
960
  home,
686
961
  entries: [],
687
962
  };
@@ -706,10 +981,11 @@ function listDirEntries(dirPath) {
706
981
  if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
707
982
  return a.name.localeCompare(b.name);
708
983
  });
984
+ const atRoot = isLaunchRoot(resolved, roots);
709
985
  const parent = path.dirname(resolved);
710
986
  return {
711
987
  path: resolved,
712
- parent: parent === resolved ? null : parent,
988
+ parent: atRoot ? (roots.length > 1 ? "" : null) : parent === resolved ? null : parent,
713
989
  home,
714
990
  entries: entries.slice(0, 400),
715
991
  };
@@ -743,54 +1019,20 @@ function mergeEnvFile(file, values, opts = {}) {
743
1019
  fs.writeFileSync(file, body + "\n", "utf8");
744
1020
  }
745
1021
 
746
- function writeProjectEnv(folder, values, opts = {}) {
747
- const remove = opts.remove || [];
748
- const next = values && typeof values === "object" ? values : {};
749
- if (!folder || (!Object.keys(next).length && !remove.length)) return;
750
- mergeEnvFile(path.join(folder, ".env"), next, { remove });
751
- const localValues = {};
752
- for (const [key, value] of Object.entries(next)) {
753
- if (
754
- key === "PORT" ||
755
- key === "APP_URL" ||
756
- key === "PUBLIC_URL" ||
757
- key === "CORS_ORIGIN" ||
758
- key === "AI_SERVER_URL" ||
759
- key.startsWith("NEXT_PUBLIC_") ||
760
- key.startsWith("VITE_") ||
761
- key.startsWith("REACT_APP_")
762
- ) {
763
- localValues[key] = value;
764
- }
765
- }
766
- const localRemove = remove.filter(
767
- (key) =>
768
- key === "PORT" ||
769
- key === "APP_URL" ||
770
- key === "PUBLIC_URL" ||
771
- key === "CORS_ORIGIN" ||
772
- key === "AI_SERVER_URL" ||
773
- key.startsWith("NEXT_PUBLIC_") ||
774
- key.startsWith("VITE_") ||
775
- key.startsWith("REACT_APP_")
776
- );
777
- if (Object.keys(localValues).length || localRemove.length) {
778
- mergeEnvFile(path.join(folder, ".env.local"), localValues, {
779
- remove: localRemove,
780
- });
1022
+ /**
1023
+ * Do not write Maintainer Pro keys into the app. The bridge injects env into
1024
+ * the process it launches (terminal script / in-process chat).
1025
+ * Only strip leftover trycloudflare values we previously wrote.
1026
+ */
1027
+ function writeProjectEnv(folder, _values, opts = {}) {
1028
+ const remove = (opts.remove || []).filter(Boolean);
1029
+ if (!folder || !remove.length) return;
1030
+ for (const name of [".env", ".env.local"]) {
1031
+ const file = path.join(folder, name);
1032
+ if (!fs.existsSync(file)) continue;
1033
+ mergeEnvFile(file, {}, { remove });
781
1034
  }
782
- const keys = Object.entries(next)
783
- .map(([key, value]) => `${key}=${value}`)
784
- .join(" ");
785
- log(
786
- `env write ${folder} .env${
787
- Object.keys(localValues).length || localRemove.length
788
- ? " +.env.local"
789
- : ""
790
- }${remove.length ? ` remove=${remove.join(",")}` : ""}${
791
- keys ? ` ${keys}` : ""
792
- }`
793
- );
1035
+ log(`env strip ${folder} remove=${remove.join(",")}`);
794
1036
  }
795
1037
 
796
1038
  function originFromUrl(value) {
@@ -822,11 +1064,6 @@ function isTryCloudflareUrl(value) {
822
1064
  }
823
1065
  }
824
1066
 
825
- function normalizePublicOrigin(value) {
826
- const origin = originFromUrl(value);
827
- return origin || null;
828
- }
829
-
830
1067
  function readCloudflareTunnelFile(folder) {
831
1068
  if (!folder) return null;
832
1069
  const file = path.join(path.resolve(folder), ".cloudflare-tunnel-url");
@@ -856,9 +1093,9 @@ function clearCloudflareTunnelFile(folder) {
856
1093
  }
857
1094
 
858
1095
  /** Stop rediscovering dead trycloudflare URLs from old cloudflared logs. */
859
- function archiveStaleCloudflareLogs(folder) {
1096
+ function archiveStaleCloudflareLogs(folder, sandboxId) {
860
1097
  if (!folder) return;
861
- const logDir = path.join(path.resolve(folder), ".maintainer-pro");
1098
+ const logDir = dataDirFor(folder, sandboxId);
862
1099
  if (!fs.existsSync(logDir)) return;
863
1100
  let names = [];
864
1101
  try {
@@ -883,6 +1120,7 @@ const CLOUDFLARE_ENV_KEYS = [
883
1120
  "APP_URL",
884
1121
  "PUBLIC_URL",
885
1122
  "CORS_ORIGIN",
1123
+ "CORS_ORIGINS",
886
1124
  "NEXT_PUBLIC_APP_URL",
887
1125
  "VITE_APP_URL",
888
1126
  "REACT_APP_APP_URL",
@@ -938,7 +1176,7 @@ function purgeUnreachableCloudflare(ws, cfg, opts = {}) {
938
1176
  }
939
1177
 
940
1178
  // Always archive cf-*.log so dead trycloudflare hosts are not rediscovered.
941
- archiveStaleCloudflareLogs(folder);
1179
+ archiveStaleCloudflareLogs(folder, ws.sandboxId);
942
1180
  if (keep && Object.keys(keep).length) {
943
1181
  writeTunnelEnv(ws, keep);
944
1182
  } else {
@@ -958,12 +1196,7 @@ function purgeUnreachableCloudflare(ws, cfg, opts = {}) {
958
1196
  remove.push(key);
959
1197
  }
960
1198
  if (remove.length) {
961
- /** @type {Record<string, string>} */
962
- const localFallback = {};
963
- if (opts.localEnv && typeof opts.localEnv === "object") {
964
- Object.assign(localFallback, opts.localEnv);
965
- }
966
- writeProjectEnv(folder, localFallback, { remove });
1199
+ writeProjectEnv(folder, {}, { remove });
967
1200
  }
968
1201
 
969
1202
  persistWorkspaceEntry(cfg, ws);
@@ -1026,7 +1259,7 @@ function discoverCloudflareTunnels(ws) {
1026
1259
  ];
1027
1260
  for (const [role, value] of envRoles) setRole(role, value);
1028
1261
 
1029
- const logDir = path.join(folder, ".maintainer-pro");
1262
+ const logDir = dataDirFor(folder, ws.sandboxId);
1030
1263
  if (fs.existsSync(logDir)) {
1031
1264
  const sandboxPrefix = `cf-${String(ws.sandboxId || "").slice(0, 8)}-`;
1032
1265
  let names = [];
@@ -1064,31 +1297,6 @@ function discoverCloudflareTunnels(ws) {
1064
1297
  return Object.keys(tunnels).length ? tunnels : null;
1065
1298
  }
1066
1299
 
1067
- function rememberCloudflareTunnels(sandboxId, tunnels) {
1068
- if (!sandboxId || !tunnels) return;
1069
- cloudflareTunnels.set(sandboxId, {
1070
- tunnels: Object.entries(tunnels)
1071
- .filter(([, url]) => Boolean(url))
1072
- .map(([role, publicUrl]) => ({
1073
- role,
1074
- localUrl: "",
1075
- publicUrl: String(publicUrl),
1076
- logFile: "",
1077
- })),
1078
- });
1079
- }
1080
-
1081
- function cloudflareLive(ws) {
1082
- if (!ws?.sandboxId) return false;
1083
- if (ws.cloudflarePending) return false;
1084
- // Only tunnels managed in this process count as live. Persisted
1085
- // cloudflareUrl / discovered log URLs go stale when apps stop.
1086
- const row = cloudflareTunnels.get(ws.sandboxId);
1087
- return Boolean(
1088
- row?.tunnels?.some((t) => isTryCloudflareUrl(t.publicUrl))
1089
- );
1090
- }
1091
-
1092
1300
  function workspaceHostReport(ws) {
1093
1301
  /** @type {string[]} */
1094
1302
  const origins = [];
@@ -1097,41 +1305,22 @@ function workspaceHostReport(ws) {
1097
1305
  if (origin && !origins.includes(origin)) origins.push(origin);
1098
1306
  };
1099
1307
  const env = readProjectEnvValues(ws.folderPath);
1100
- const discovered = discoverCloudflareTunnels(ws);
1101
- const liveCf = cloudflareLive(ws);
1102
1308
  const chatPort = Number(env.AI_SERVER_PORT || ws.port);
1103
1309
  const uiPort = Number(
1104
1310
  env.PORT || ws.projectInfo?.ports?.ui || ws.projectInfo?.ports?.app
1105
1311
  );
1106
- const cf =
1107
- liveCf || discovered
1108
- ? {
1109
- ...(typeof ws.cloudflare === "object" && ws.cloudflare
1110
- ? ws.cloudflare
1111
- : {}),
1112
- ...(discovered || {}),
1113
- }
1114
- : null;
1115
1312
 
1116
- if (cf && Object.keys(cf).length) {
1117
- add(ws.cloudflareUrl);
1118
- for (const value of Object.values(cf)) add(value);
1119
- add(env.NEXT_PUBLIC_AI_SERVER_URL);
1120
- add(env.AI_SERVER_URL);
1121
- add(env.APP_URL);
1122
- add(env.NEXT_PUBLIC_APP_URL);
1123
- } else {
1124
- for (const value of [
1125
- env.CORS_ORIGIN,
1126
- env.APP_URL,
1127
- env.NEXT_PUBLIC_APP_URL,
1128
- env.PUBLIC_URL,
1129
- ws.appUrl,
1130
- ]) {
1131
- if (value && isLocalAppUrl(value)) add(value);
1132
- }
1133
- add(env.AI_SERVER_URL);
1134
- add(env.NEXT_PUBLIC_AI_SERVER_URL);
1313
+ for (const value of [
1314
+ env.CORS_ORIGIN,
1315
+ env.APP_URL,
1316
+ env.NEXT_PUBLIC_APP_URL,
1317
+ env.PUBLIC_URL,
1318
+ env.AI_SERVER_URL,
1319
+ env.NEXT_PUBLIC_AI_SERVER_URL,
1320
+ ws.appUrl,
1321
+ ]) {
1322
+ if (!value || isTryCloudflareUrl(value)) continue;
1323
+ add(value);
1135
1324
  }
1136
1325
  if (chatPort) {
1137
1326
  add(`http://localhost:${chatPort}`);
@@ -1142,13 +1331,13 @@ function workspaceHostReport(ws) {
1142
1331
  add(`http://127.0.0.1:${uiPort}`);
1143
1332
  }
1144
1333
 
1334
+ const uiShare = proxyUrlForApp(
1335
+ ws,
1336
+ hostAppOf(ws) || { id: "ui", role: "ui", host: true }
1337
+ );
1145
1338
  let appUrl = null;
1146
- if (cf?.ui && isTryCloudflareUrl(cf.ui)) {
1147
- appUrl = normalizePublicOrigin(cf.ui);
1148
- } else if (ws.cloudflareUrl && isTryCloudflareUrl(ws.cloudflareUrl)) {
1149
- appUrl = normalizePublicOrigin(ws.cloudflareUrl);
1150
- } else if (cf?.ai && isTryCloudflareUrl(cf.ai)) {
1151
- appUrl = normalizePublicOrigin(cf.ai);
1339
+ if (uiShare) {
1340
+ appUrl = uiShare;
1152
1341
  } else if (env.APP_URL && isLocalAppUrl(env.APP_URL)) {
1153
1342
  appUrl = originFromUrl(env.APP_URL);
1154
1343
  } else if (ws.appUrl && isLocalAppUrl(ws.appUrl)) {
@@ -1161,59 +1350,17 @@ function workspaceHostReport(ws) {
1161
1350
  return { appUrl, origins };
1162
1351
  }
1163
1352
 
1164
- /**
1165
- * Probe discovered public URLs and keep only ones that respond.
1166
- * @returns {Promise<{ live: Record<string, string> | null, dead: Record<string, string> }>}
1167
- */
1168
- async function filterReachableCloudflareTunnels(tunnels) {
1169
- /** @type {Record<string, string>} */
1170
- const live = {};
1171
- /** @type {Record<string, string>} */
1172
- const dead = {};
1173
- if (!tunnels || !Object.keys(tunnels).length) {
1174
- return { live: null, dead };
1175
- }
1176
- for (const [role, url] of Object.entries(tunnels)) {
1177
- if (!isTryCloudflareUrl(url)) continue;
1178
- const normalized = String(url).replace(/\/$/, "");
1179
- const target =
1180
- role === "ai"
1181
- ? `${normalized}/embed-config.js`
1182
- : normalized;
1183
- if (await probeUrl(target, 8_000)) {
1184
- live[role] = normalized;
1185
- } else {
1186
- dead[role] = normalized;
1187
- }
1188
- }
1189
- return {
1190
- live: Object.keys(live).length ? live : null,
1191
- dead,
1192
- };
1193
- }
1194
-
1195
- function envNeedsUpdate(folder, desired) {
1196
- if (!desired || !Object.keys(desired).length) return false;
1197
- const current = readProjectEnvValues(folder);
1198
- return Object.entries(desired).some(
1199
- ([key, value]) => String(current[key] || "") !== String(value)
1200
- );
1201
- }
1202
-
1203
1353
  /**
1204
1354
  * Status-only reconcile for a workspace:
1205
1355
  * 1) probe chat / ui / backend
1206
- * 2) detect reachable Cloudflare (never starts tunnels)
1207
- * 3) sync base URLs in env when mode or ports changed
1208
- * 4) compute host appUrl + CORS origins for Maintainer Pro
1356
+ * 2) drop leftover trycloudflare URLs (never start or reuse tunnels)
1357
+ * 3) compute host appUrl + CORS origins for Maintainer Pro
1209
1358
  *
1210
- * Does not start apps or Cloudflare.
1359
+ * Does not start apps. Does not write the app's .env files.
1211
1360
  */
1212
1361
  async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
1213
- const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
1214
- const writeEnv = opts.writeEnv !== false;
1215
1362
  const timeoutMs = opts.timeoutMs || 800;
1216
- const folder = path.resolve(ws.folderPath || "");
1363
+ sanitizeAiServerPort(ws);
1217
1364
 
1218
1365
  // 1. Local process probes
1219
1366
  const probe = await probeRunningApps(ws, timeoutMs);
@@ -1221,133 +1368,29 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
1221
1368
  ws.port = probe.chatPort;
1222
1369
  }
1223
1370
 
1224
- // 2. Cloudflare detection (never starts tunnels). Purge stale URLs so we
1225
- // do not keep re-probing dead trycloudflare links from env/logs every tick.
1226
- const discovered = discoverCloudflareTunnels(ws);
1227
- const cfCheck = discovered
1228
- ? await filterReachableCloudflareTunnels(discovered)
1229
- : { live: null, dead: {} };
1230
- const reachableCf = cfCheck.live;
1231
- const deadCf = cfCheck.dead;
1232
- const managedLive = cloudflareLive(ws);
1233
-
1234
- if (Object.keys(deadCf).length > 0) {
1235
- /** @type {Record<string, string>} */
1236
- let localEnv = {};
1237
- if (probe.running) {
1238
- const jobs = probe.hosts
1239
- .filter((h) => h.up)
1240
- .map((h) => ({ role: h.role, port: h.port, preferredPort: h.port }));
1241
- localEnv = envForWorkspacePorts(ws, jobs);
1242
- }
1243
- /** @type {Record<string, string> | null} */
1244
- let keep = reachableCf;
1245
- if (!keep && managedLive) {
1246
- const managed = {};
1247
- for (const t of cloudflareTunnels.get(ws.sandboxId)?.tunnels || []) {
1248
- if (t?.role && isTryCloudflareUrl(t.publicUrl)) {
1249
- managed[t.role] = String(t.publicUrl).replace(/\/$/, "");
1250
- }
1251
- }
1252
- keep = Object.keys(managed).length ? managed : null;
1253
- }
1371
+ // 2. Remove leftover Cloudflare tunnels / env share URLs replace them.
1372
+ const leftover = discoverCloudflareTunnels(ws);
1373
+ if (leftover || ws.cloudflareUrl || ws.cloudflare) {
1254
1374
  purgeUnreachableCloudflare(ws, cfg, {
1255
- deadUrls: Object.values(deadCf),
1256
- keep,
1257
- localEnv,
1375
+ deadUrls: leftover ? Object.values(leftover) : [],
1376
+ keep: null,
1258
1377
  });
1259
- }
1260
-
1261
- const usingCloudflare = Boolean(
1262
- managedLive || (reachableCf && Object.keys(reachableCf).length)
1263
- );
1264
-
1265
- if (reachableCf) {
1266
- ws.cloudflare = { ...(ws.cloudflare || {}), ...reachableCf };
1267
- ws.cloudflareUrl = reachableCf.ui || reachableCf.ai || ws.cloudflareUrl || null;
1268
- if (reachableCf.ui || reachableCf.ai) {
1269
- ws.appUrl = reachableCf.ui || reachableCf.ai;
1270
- }
1271
- } else if (!managedLive) {
1272
- ws.cloudflareUrl = null;
1273
- ws.cloudflare = null;
1274
- if (ws.appUrl && !isLocalAppUrl(ws.appUrl)) ws.appUrl = null;
1275
- }
1276
-
1277
- const cfTunnels =
1278
- usingCloudflare
1279
- ? {
1280
- ...(typeof ws.cloudflare === "object" && ws.cloudflare
1281
- ? ws.cloudflare
1282
- : {}),
1283
- ...(reachableCf || {}),
1284
- }
1285
- : null;
1286
-
1287
- // 3. Sync env base URLs when apps are up or Cloudflare is live
1288
- if (writeEnv && folder && fs.existsSync(folder)) {
1289
- /** @type {Record<string, string>} */
1290
- let desired = {};
1291
- if (cfTunnels && Object.keys(cfTunnels).length) {
1292
- desired = {
1293
- ...uiPublicEnv(cfTunnels),
1294
- AI_SERVER_PORT: String(probe.chatPort || ws.port || 3100),
1295
- };
1296
- if (probe.hosts.some((h) => h.role === "ui" || h.role === "app")) {
1297
- const ui = probe.hosts.find(
1298
- (h) => (h.role === "ui" || h.role === "app") && h.up
1299
- );
1300
- if (ui?.port) desired.PORT = String(ui.port);
1301
- }
1302
- if (envNeedsUpdate(folder, desired)) {
1303
- log(
1304
- `env sync ${label}: cloudflare urls (${Object.keys(cfTunnels).join(",")})`
1305
- );
1306
- writeTunnelEnv(ws, cfTunnels);
1307
- writeProjectEnv(folder, desired);
1308
- }
1309
- } else if (probe.running) {
1310
- const jobs = probe.hosts
1311
- .filter((h) => h.up)
1312
- .map((h) => ({ role: h.role, port: h.port, preferredPort: h.port }));
1313
- desired = envForWorkspacePorts(ws, jobs);
1314
- // Drop stale trycloudflare values when running local-only.
1315
- const current = readProjectEnvValues(folder);
1316
- /** @type {string[]} */
1317
- const remove = [];
1318
- for (const key of [
1319
- "APP_URL",
1320
- "PUBLIC_URL",
1321
- "CORS_ORIGIN",
1322
- "NEXT_PUBLIC_APP_URL",
1323
- "VITE_APP_URL",
1324
- "AI_SERVER_URL",
1325
- "NEXT_PUBLIC_AI_SERVER_URL",
1326
- "VITE_AI_SERVER_URL",
1327
- "REACT_APP_AI_SERVER_URL",
1328
- "API_URL",
1329
- "VITE_API_URL",
1330
- "NEXT_PUBLIC_API_URL",
1331
- ]) {
1332
- if (isTryCloudflareUrl(current[key]) && desired[key]) {
1333
- // overwritten by desired
1334
- } else if (isTryCloudflareUrl(current[key]) && !desired[key]) {
1335
- remove.push(key);
1336
- }
1337
- }
1338
- if (envNeedsUpdate(folder, desired) || remove.length) {
1339
- log(`env sync ${label}: local app urls`);
1340
- writeProjectEnv(folder, desired, { remove });
1341
- }
1378
+ try {
1379
+ await stopCloudflare(ws.sandboxId, ws.folderPath);
1380
+ } catch {
1381
+ /* leftover cloudflared */
1342
1382
  }
1343
1383
  }
1344
1384
 
1345
- // 4. Host + CORS origins for Maintainer Pro
1385
+ // 3. Host + CORS origins for Maintainer Pro
1346
1386
  const host = workspaceHostReport(ws);
1347
- if (cfTunnels?.ui && isTryCloudflareUrl(cfTunnels.ui)) {
1348
- host.appUrl = normalizePublicOrigin(cfTunnels.ui);
1349
- } else if (cfTunnels?.ai && isTryCloudflareUrl(cfTunnels.ai)) {
1350
- host.appUrl = normalizePublicOrigin(cfTunnels.ai);
1387
+ const uiShare = proxyUrlForApp(ws, hostAppOf(ws) || { id: "ui", role: "ui", host: true });
1388
+ const aiShare = proxyUrlForApp(ws, {
1389
+ id: "ai-server",
1390
+ role: "ai-server",
1391
+ });
1392
+ if (uiShare) {
1393
+ host.appUrl = uiShare;
1351
1394
  } else if (probe.running) {
1352
1395
  const ui = probe.hosts.find(
1353
1396
  (h) => (h.role === "ui" || h.role === "app") && h.up
@@ -1358,6 +1401,12 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
1358
1401
  host.appUrl = `http://localhost:${probe.chatPort}`;
1359
1402
  }
1360
1403
  }
1404
+ if (uiShare && !host.origins.includes(uiShare)) host.origins.push(uiShare);
1405
+ if (aiShare && !host.origins.includes(aiShare)) host.origins.push(aiShare);
1406
+ const proxyOrigin = String(ws?.proxy?.origin || "").replace(/\/$/, "");
1407
+ if (proxyOrigin && !host.origins.includes(proxyOrigin)) {
1408
+ host.origins.push(proxyOrigin);
1409
+ }
1361
1410
 
1362
1411
  // Ensure probed local origins are always included when processes are up.
1363
1412
  for (const h of probe.hosts) {
@@ -1371,12 +1420,6 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
1371
1420
  const chatOrigin = `http://localhost:${probe.chatPort}`;
1372
1421
  if (!host.origins.includes(chatOrigin)) host.origins.push(chatOrigin);
1373
1422
  }
1374
- if (cfTunnels) {
1375
- for (const value of Object.values(cfTunnels)) {
1376
- const origin = normalizePublicOrigin(value);
1377
- if (origin && !host.origins.includes(origin)) host.origins.push(origin);
1378
- }
1379
- }
1380
1423
 
1381
1424
  if (host.appUrl) ws.appUrl = host.appUrl;
1382
1425
 
@@ -1395,146 +1438,77 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
1395
1438
 
1396
1439
  return {
1397
1440
  probe,
1398
- usingCloudflare,
1399
- cloudflare: cfTunnels,
1441
+ usingCloudflare: false,
1442
+ cloudflare: null,
1400
1443
  host,
1401
1444
  appsRunning: probe.running,
1402
1445
  aiServerUp: probe.chatUp,
1403
1446
  };
1404
1447
  }
1405
1448
 
1406
- async function reconcileCloudflareState(ws, cfg) {
1407
- const status = await reconcileWorkspacePresence(ws, cfg, {
1408
- writeEnv: true,
1409
- timeoutMs: 2500,
1449
+ async function probeHttpPaths(port, paths, timeoutMs = 2500) {
1450
+ const n = Number(port);
1451
+ if (!n) return false;
1452
+ for (const host of ["127.0.0.1", "localhost"]) {
1453
+ for (const suffix of paths) {
1454
+ if (await probeUrl(`http://${host}:${n}${suffix}`, timeoutMs)) return true;
1455
+ }
1456
+ }
1457
+ return false;
1458
+ }
1459
+
1460
+ function probePortOpen(port, timeoutMs = 800) {
1461
+ const n = Number(port);
1462
+ if (!n) return Promise.resolve(false);
1463
+ return new Promise((resolve) => {
1464
+ const socket = net.connect({ host: "127.0.0.1", port: n });
1465
+ const done = (ok) => {
1466
+ socket.removeAllListeners();
1467
+ socket.destroy();
1468
+ resolve(ok);
1469
+ };
1470
+ socket.setTimeout(timeoutMs);
1471
+ socket.once("connect", () => done(true));
1472
+ socket.once("timeout", () => done(false));
1473
+ socket.once("error", () => done(false));
1410
1474
  });
1411
- return status.usingCloudflare;
1412
1475
  }
1413
1476
 
1414
- /**
1415
- * If Cloudflare is already running for this folder, attach and share with MP
1416
- * instead of creating new tunnels.
1417
- */
1418
- async function tryAttachExistingCloudflare(ws, cfg, opts = {}) {
1419
- const progress =
1420
- typeof opts.onProgress === "function" ? opts.onProgress : async () => {};
1421
- const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
1422
- await progress(
1423
- "Looking for Cloudflare tunnels that are already running for this project…"
1424
- );
1425
- const discovered = discoverCloudflareTunnels(ws);
1426
- if (!discovered) {
1427
- await progress("No existing Cloudflare tunnel URLs found yet.");
1428
- return null;
1429
- }
1430
- await progress(
1431
- `Found candidate tunnels: ${Object.entries(discovered)
1432
- .map(([role, url]) => `${role}=${url}`)
1433
- .join(", ")}`
1434
- );
1435
- const { live, dead } = await filterReachableCloudflareTunnels(discovered);
1436
- if (!live || (!live.ai && !live.ui)) {
1437
- if (Object.keys(dead).length) {
1438
- purgeUnreachableCloudflare(ws, cfg, {
1439
- deadUrls: Object.values(dead),
1440
- });
1441
- }
1442
- await progress(
1443
- "Those Cloudflare URLs did not respond — will create fresh tunnels."
1444
- );
1445
- return null;
1477
+ async function portIsLive(port, timeoutMs = 1200) {
1478
+ const n = Number(port);
1479
+ if (!n) return false;
1480
+ if (
1481
+ await probeHttpPaths(
1482
+ n,
1483
+ ["/", "/embed-config.js", "/health", "/index.html"],
1484
+ timeoutMs
1485
+ )
1486
+ ) {
1487
+ return true;
1446
1488
  }
1489
+ return probePortOpen(n, Math.min(timeoutMs, 800));
1490
+ }
1447
1491
 
1448
- writeTunnelEnv(ws, live);
1449
- // Make sure the chat script advertises the public AI URL when we have one.
1450
- if (live.ai) {
1451
- const reserved = reservedPortsFor(cfg, ws.sandboxId);
1452
- const up = await probeUrl(
1453
- `http://127.0.0.1:${Number(ws.port) || 3100}/embed-config.js`
1454
- );
1455
- if (up) {
1456
- launchedAt.delete(`${ws.sandboxId}:${path.resolve(ws.folderPath)}:ai`);
1457
- await killPort(ws.port);
1458
- await sleep(1200);
1459
- }
1460
- await startAiServerForWorkspace(ws, {
1461
- reserved,
1462
- cfg,
1463
- port: ws.port,
1464
- env: uiPublicEnv(live),
1465
- });
1466
- await waitUntilReachable(
1467
- `http://127.0.0.1:${ws.port}/embed-config.js`,
1468
- 45_000,
1469
- "the chat script",
1470
- progress
1471
- );
1472
- }
1473
-
1474
- const validation = await validateCloudflareGoLive(ws, live, {
1475
- onProgress: progress,
1476
- });
1477
- if (!validation.ok) {
1478
- await progress(
1479
- "Existing tunnels failed go-live checks — will recreate if needed."
1480
- );
1481
- return null;
1482
- }
1483
-
1484
- const appUrl = live.ui || live.ai;
1485
- ws.cloudflareUrl = appUrl;
1486
- ws.cloudflare = live;
1487
- ws.appUrl = appUrl;
1488
- ws.cloudflarePending = false;
1489
- ws.appsRequested = true;
1490
- persistWorkspaceEntry(cfg, ws);
1491
- rememberCloudflareTunnels(ws.sandboxId, live);
1492
- clearProcessProblem(ws.sandboxId, "cloudflare_launch", "tunnel");
1493
- const host = workspaceHostReport(ws);
1494
- await progress(`Attached to existing Cloudflare: ${host.appUrl || appUrl}`);
1495
- log(`cloudflare attached (reuse) ${label}: ${appUrl}`);
1496
-
1497
- return {
1498
- sandboxId: ws.sandboxId,
1499
- folderPath: ws.folderPath,
1500
- port: ws.port,
1501
- appUrl: host.appUrl || appUrl,
1502
- origins: host.origins.length
1503
- ? host.origins
1504
- : Object.values(live).filter(Boolean),
1505
- tunnels: live,
1506
- validation,
1507
- cloudflare: true,
1508
- reused: true,
1509
- attached: true,
1510
- };
1511
- }
1512
-
1513
- function clearStaleCloudflare(ws) {
1514
- if (cloudflareLive(ws)) return;
1515
- if (!ws.cloudflareUrl && !ws.cloudflare) return;
1516
- if (discoverCloudflareTunnels(ws)) return;
1517
- const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
1518
- log(`clearing stale Cloudflare URL for ${label}`);
1519
- ws.cloudflareUrl = null;
1520
- ws.cloudflare = null;
1521
- if (ws.appUrl && !isLocalAppUrl(ws.appUrl)) {
1522
- ws.appUrl = null;
1523
- }
1524
- }
1525
-
1526
- async function probeHttpPaths(port, paths, timeoutMs = 2500) {
1492
+ async function isChatServerOnPort(port, timeoutMs = 2500) {
1527
1493
  const n = Number(port);
1528
- if (!n) return false;
1529
- for (const host of ["127.0.0.1", "localhost"]) {
1530
- for (const suffix of paths) {
1531
- if (await probeUrl(`http://${host}:${n}${suffix}`, timeoutMs)) return true;
1532
- }
1533
- }
1534
- return false;
1494
+ if (!n || isAdminListenPort(n)) return false;
1495
+ return (
1496
+ (await probeEmbedConfig(
1497
+ `http://127.0.0.1:${n}/embed-config.js`,
1498
+ timeoutMs
1499
+ )) ||
1500
+ (await probeEmbedConfig(
1501
+ `http://localhost:${n}/embed-config.js`,
1502
+ timeoutMs
1503
+ ))
1504
+ );
1535
1505
  }
1536
1506
 
1537
1507
  async function discoverChatPort(ws, timeoutMs = 2500) {
1508
+ const embedded = ws?.sandboxId ? embeddedChat.get(ws.sandboxId) : null;
1509
+ if (embedded?.port) {
1510
+ return { port: embedded.port, up: true };
1511
+ }
1538
1512
  const env = readProjectEnvValues(ws.folderPath);
1539
1513
  const candidates = [
1540
1514
  Number(ws.port),
@@ -1542,72 +1516,182 @@ async function discoverChatPort(ws, timeoutMs = 2500) {
1542
1516
  portFromText(env.AI_SERVER_URL, 0),
1543
1517
  portFromText(env.NEXT_PUBLIC_AI_SERVER_URL, 0),
1544
1518
  3100,
1545
- ].filter((port) => port >= 1024);
1519
+ ].filter((port) => port >= 1024 && !isAdminListenPort(port));
1546
1520
  const unique = [...new Set(candidates)];
1547
- const chatPaths = ["/embed-config.js", "/", "/health"];
1548
1521
  for (const port of unique) {
1549
- if (await probeHttpPaths(port, chatPaths, timeoutMs)) {
1522
+ if (await isChatServerOnPort(port, timeoutMs)) {
1550
1523
  return { port, up: true };
1551
1524
  }
1552
1525
  }
1553
- return { port: unique[0] || Number(ws.port) || 3100, up: false };
1526
+ return { port: unique[0] || 3100, up: false };
1527
+ }
1528
+
1529
+ function hostAppOf(ws) {
1530
+ const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
1531
+ return (
1532
+ apps.find((app) => app && app.host === true && app.role !== "ai-server") ||
1533
+ apps.find((app) => app && (app.role === "ui" || app.role === "app")) ||
1534
+ null
1535
+ );
1536
+ }
1537
+
1538
+ function isBackendApp(app) {
1539
+ if (!app) return false;
1540
+ if (app.role === "backend") return true;
1541
+ if (app.role === "ai-server" || app.id === "ai-server") return false;
1542
+ if (app.host || app.role === "ui") return false;
1543
+ return /backend|api|server/i.test(`${app.id || ""} ${app.name || ""}`);
1544
+ }
1545
+
1546
+ function usesBridgeProxy(app) {
1547
+ return Boolean(app && proxySlugForApp(app));
1548
+ }
1549
+
1550
+ function proxySlugForApp(app) {
1551
+ if (!app) return "";
1552
+ if (app.role === "ai-server" || app.id === "ai-server") return "ai";
1553
+ if (isBackendApp(app)) return "backend";
1554
+ if (app.host || app.role === "ui" || app.role === "app") return "ui";
1555
+ const id = String(app.id || "")
1556
+ .trim()
1557
+ .toLowerCase()
1558
+ .replace(/[^a-z0-9_-]+/g, "-")
1559
+ .replace(/^-+|-+$/g, "");
1560
+ return id || "app";
1561
+ }
1562
+
1563
+ function applyAssignedProxy(ws, remote, cfg) {
1564
+ const proxy =
1565
+ remote?.proxy && typeof remote.proxy === "object" ? remote.proxy : null;
1566
+ if (!proxy?.token) return;
1567
+ const origin = String(proxy.origin || cfg?.adminUrl || "").replace(/\/$/, "");
1568
+ const slugs =
1569
+ proxy.slugs && typeof proxy.slugs === "object" ? proxy.slugs : {};
1570
+ /** @type {Record<string, string>} */
1571
+ const urls = {};
1572
+ const apps = Array.isArray(ws.hostApps)
1573
+ ? ws.hostApps
1574
+ : Array.isArray(remote.hostApps)
1575
+ ? remote.hostApps
1576
+ : [];
1577
+ for (const app of apps) {
1578
+ const slug = slugs[app.id] || proxySlugForApp(app);
1579
+ if (!slug || !origin) continue;
1580
+ urls[app.id] = `${origin}/p/${proxy.token}/${slug}`;
1581
+ }
1582
+ ws.proxy = { origin, token: String(proxy.token), slugs, urls };
1583
+ }
1584
+
1585
+ function proxyUrlForApp(ws, app) {
1586
+ if (!usesBridgeProxy(app)) return "";
1587
+ const urls =
1588
+ ws?.proxy?.urls && typeof ws.proxy.urls === "object" ? ws.proxy.urls : {};
1589
+ if (typeof urls[app?.id] === "string" && urls[app.id]) {
1590
+ return String(urls[app.id]).replace(/\/$/, "");
1591
+ }
1592
+ const origin = String(ws?.proxy?.origin || "").replace(/\/$/, "");
1593
+ const token = String(ws?.proxy?.token || "");
1594
+ const slug =
1595
+ (ws?.proxy?.slugs && app?.id && ws.proxy.slugs[app.id]) ||
1596
+ proxySlugForApp(app);
1597
+ if (!origin || !token || !slug) return "";
1598
+ return `${origin}/p/${token}/${slug}`;
1599
+ }
1600
+
1601
+ function tunnelRoleForApp(app) {
1602
+ if (!app) return "app";
1603
+ if (app.role === "ai-server") return "ai";
1604
+ if (app.host === true || app.role === "ui" || app.role === "app") return "ui";
1605
+ if (isBackendApp(app)) return "backend";
1606
+ if (app.role === "custom") return "app";
1607
+ return app.role;
1608
+ }
1609
+
1610
+ function jobsFromHostApps(ws) {
1611
+ const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
1612
+ const hostId = hostAppOf(ws)?.id;
1613
+ return apps
1614
+ .filter((app) => app && app.role !== "ai-server")
1615
+ .map((app) => {
1616
+ const port = Number(app.port) || 3000;
1617
+ const command = String(app.startCommand || "").trim();
1618
+ const script = command.replace(/^npm\s+run\s+/, "") || "dev";
1619
+ return {
1620
+ role: isBackendApp(app)
1621
+ ? "backend"
1622
+ : app.role === "custom"
1623
+ ? "app"
1624
+ : app.role || "app",
1625
+ host: app.id === hostId || app.host === true,
1626
+ script,
1627
+ command: command || `npm run ${script}`,
1628
+ preferredPort: port,
1629
+ port,
1630
+ probeUrl: `http://127.0.0.1:${port}`,
1631
+ appId: app.id,
1632
+ };
1633
+ });
1554
1634
  }
1555
1635
 
1556
1636
  async function probeRunningApps(ws, timeoutMs = 2500) {
1557
1637
  const folder = path.resolve(ws.folderPath || "");
1558
- const env = readProjectEnvValues(ws.folderPath);
1559
1638
  const chat = await discoverChatPort(ws, timeoutMs);
1560
1639
  if (chat.up && Number(ws.port) !== chat.port) {
1561
1640
  ws.port = chat.port;
1562
1641
  }
1563
- const jobs = planHostJobs(
1564
- folder,
1565
- ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1566
- ws.projectInfo
1567
- );
1568
- /** @type {Array<{ role: string, port: number, up: boolean }>} */
1642
+ const jobs = jobsFromHostApps(ws).length
1643
+ ? jobsFromHostApps(ws)
1644
+ : planHostJobs(
1645
+ folder,
1646
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1647
+ ws.projectInfo
1648
+ );
1649
+ /** @type {Array<{ role: string, port: number, up: boolean, appId?: string }>} */
1569
1650
  const hosts = [];
1570
1651
  for (const job of jobs) {
1571
- const candidates = [
1572
- Number(job.port),
1573
- Number(job.preferredPort),
1574
- Number(env.PORT),
1575
- portFromText(env.APP_URL, 0),
1576
- portFromText(ws.appUrl, 0),
1577
- ].filter((port) => port >= 1024);
1578
- const unique = [...new Set(candidates.length ? candidates : [3000])];
1579
- let up = false;
1580
- let port = unique[0];
1581
- for (const candidate of unique) {
1582
- const probe = (
1583
- job.probeUrl || `http://127.0.0.1:${candidate}`
1584
- ).replace("localhost", "127.0.0.1");
1585
- const ok =
1586
- (await probeUrl(probe, timeoutMs)) ||
1587
- (await probeHttpPaths(candidate, ["/"], timeoutMs));
1588
- if (ok) {
1589
- up = true;
1590
- port = candidate;
1591
- break;
1592
- }
1593
- }
1594
- hosts.push({ role: job.role, port, up });
1652
+ const port = Number(job.port || job.preferredPort) || 0;
1653
+ const up = port ? await portIsLive(port, timeoutMs) : false;
1654
+ hosts.push({
1655
+ role: job.role,
1656
+ port,
1657
+ up,
1658
+ appId: job.appId || job.role,
1659
+ });
1595
1660
  }
1661
+ const listed = Array.isArray(ws.hostApps) ? ws.hostApps : [];
1662
+ const hostApps = await Promise.all(
1663
+ listed.map(async (app) => {
1664
+ const port = Number(app.port) || 0;
1665
+ const up =
1666
+ (app.role === "ai-server" &&
1667
+ chat.up &&
1668
+ (!port || Number(chat.port) === port)) ||
1669
+ (port ? await portIsLive(port, timeoutMs) : false);
1670
+ return {
1671
+ ...app,
1672
+ port: port || app.port,
1673
+ running: Boolean(up),
1674
+ cloudflareUrl: null,
1675
+ publicUrl: proxyUrlForApp(ws, app) || null,
1676
+ lastCheckedAt: new Date().toISOString(),
1677
+ };
1678
+ })
1679
+ );
1596
1680
  return {
1597
1681
  chatUp: chat.up,
1598
1682
  chatPort: chat.port,
1599
1683
  hosts,
1600
- running: chat.up || hosts.some((host) => host.up),
1684
+ hostApps,
1685
+ running: chat.up || hosts.some((host) => host.up) || hostApps.some((app) => app.running),
1601
1686
  };
1602
1687
  }
1603
1688
 
1604
1689
  async function restoreHostsAfterReconnect(cfg) {
1605
1690
  for (const ws of cfg.workspaces || []) {
1606
1691
  const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
1607
- // Status-only: probe apps + Cloudflare, sync env/origins for MP.
1608
- // Never start apps or tunnels on reconnect.
1692
+ // Status-only: probe apps, drop leftover tunnels, report origins.
1693
+ // Never start apps on reconnect. Never write the app's .env.
1609
1694
  const status = await reconcileWorkspacePresence(ws, cfg, {
1610
- writeEnv: true,
1611
1695
  timeoutMs: 2500,
1612
1696
  });
1613
1697
  const hostSummary = status.probe.hosts
@@ -1617,17 +1701,15 @@ async function restoreHostsAfterReconnect(cfg) {
1617
1701
  log(
1618
1702
  `found running apps for ${label}: chat=${status.probe.chatPort}${
1619
1703
  status.probe.chatUp ? "(up)" : "(down)"
1620
- }${hostSummary ? ` ${hostSummary}` : ""} cloudflare=${
1621
- status.usingCloudflare ? "yes" : "no"
1622
- } app=${status.host.appUrl || "(none)"} origins=${
1704
+ }${hostSummary ? ` ${hostSummary}` : ""} share=${
1705
+ status.host.appUrl || "(none)"
1706
+ } origins=${
1623
1707
  status.host.origins.join(",") || "none"
1624
1708
  }`
1625
1709
  );
1626
1710
  } else {
1627
1711
  log(
1628
- `no apps running for ${label}${
1629
- status.usingCloudflare ? " (cloudflare urls present)" : ""
1630
- } — waiting for Start Apps from Maintainer Pro`
1712
+ `no apps running for ${label} — waiting for Start Apps from Maintainer Pro`
1631
1713
  );
1632
1714
  }
1633
1715
  }
@@ -1635,11 +1717,10 @@ async function restoreHostsAfterReconnect(cfg) {
1635
1717
 
1636
1718
  function envForWorkspacePorts(ws, jobs) {
1637
1719
  const aiPort = Number(ws.port) || 3100;
1638
- const publicAi =
1639
- (typeof ws.cloudflare?.ai === "string" &&
1640
- isTryCloudflareUrl(ws.cloudflare.ai) &&
1641
- String(ws.cloudflare.ai).replace(/\/$/, "")) ||
1642
- "";
1720
+ const aiApp = (Array.isArray(ws.hostApps) ? ws.hostApps : []).find(
1721
+ (app) => app.role === "ai-server" || app.id === "ai-server"
1722
+ ) || { id: "ai-server", role: "ai-server" };
1723
+ const publicAi = proxyUrlForApp(ws, aiApp) || "";
1643
1724
  const ai = publicAi || `http://localhost:${aiPort}`;
1644
1725
  /** @type {Record<string, string>} */
1645
1726
  const env = {
@@ -1648,13 +1729,19 @@ function envForWorkspacePorts(ws, jobs) {
1648
1729
  NEXT_PUBLIC_AI_SERVER_URL: ai,
1649
1730
  VITE_AI_SERVER_URL: ai,
1650
1731
  REACT_APP_AI_SERVER_URL: ai,
1732
+ MAINTAINER_PRO_DATA_DIR: dataDirFor(ws.folderPath, ws.sandboxId),
1733
+ MAINTAINER_PRO_SANDBOX_ID: String(ws.sandboxId || ""),
1651
1734
  };
1652
- const ui = jobs.find((job) => job.role === "ui" || job.role === "app");
1653
- const publicUi =
1654
- (typeof ws.cloudflare?.ui === "string" &&
1655
- isTryCloudflareUrl(ws.cloudflare.ui) &&
1656
- String(ws.cloudflare.ui).replace(/\/$/, "")) ||
1657
- "";
1735
+ const ui =
1736
+ jobs.find((job) => job.host) ||
1737
+ jobs.find((job) => job.role === "ui" || job.role === "app");
1738
+ const uiApp =
1739
+ hostAppOf(ws) ||
1740
+ (Array.isArray(ws.hostApps) ? ws.hostApps : []).find(
1741
+ (app) => app && (app.host || app.role === "ui" || app.role === "app")
1742
+ ) ||
1743
+ { id: "ui", role: "ui", host: true };
1744
+ const publicUi = proxyUrlForApp(ws, uiApp) || "";
1658
1745
  if (publicUi) {
1659
1746
  env.PORT = ui?.port ? String(ui.port) : env.PORT;
1660
1747
  env.APP_URL = publicUi;
@@ -1672,16 +1759,20 @@ function envForWorkspacePorts(ws, jobs) {
1672
1759
  }
1673
1760
  const backend = jobs.find((job) => job.role === "backend");
1674
1761
  if (backend?.port) {
1675
- const publicBackend =
1676
- (typeof ws.cloudflare?.backend === "string" &&
1677
- isTryCloudflareUrl(ws.cloudflare.backend) &&
1678
- String(ws.cloudflare.backend).replace(/\/$/, "")) ||
1679
- "";
1762
+ const backendApp = (Array.isArray(ws.hostApps) ? ws.hostApps : []).find(
1763
+ (app) => isBackendApp(app)
1764
+ ) || { id: "backend", role: "backend", port: backend.port };
1765
+ const publicBackend = proxyUrlForApp(ws, backendApp) || "";
1680
1766
  const api = publicBackend || `http://localhost:${backend.port}`;
1681
1767
  env.API_URL = api;
1768
+ env.API_BASE_URL = api;
1682
1769
  env.API_PORT = String(backend.port);
1770
+ env.BACKEND_URL = api;
1683
1771
  env.VITE_API_URL = api;
1772
+ env.VITE_API_BASE_URL = api;
1684
1773
  env.NEXT_PUBLIC_API_URL = api;
1774
+ env.NEXT_PUBLIC_API_BASE_URL = api;
1775
+ env.REACT_APP_API_URL = api;
1685
1776
  }
1686
1777
  return env;
1687
1778
  }
@@ -1695,10 +1786,12 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1695
1786
  return { aiPort, jobs: [], env: {} };
1696
1787
  }
1697
1788
  log(`ports pick ${label} in ${folder}`);
1698
- const preferredAi = Number(ws.port) || 3100;
1699
- const aiUp = await probeUrl(
1700
- `http://127.0.0.1:${preferredAi}/embed-config.js`
1701
- );
1789
+ const listedAi = Array.isArray(ws.hostApps)
1790
+ ? ws.hostApps.find((app) => app.role === "ai-server" || app.id === "ai-server")
1791
+ : null;
1792
+ const preferredRaw = Number(listedAi?.port || ws.port) || 3100;
1793
+ const preferredAi = isAdminListenPort(preferredRaw) ? 3100 : preferredRaw;
1794
+ const aiUp = await isChatServerOnPort(preferredAi);
1702
1795
  const aiPort = aiUp
1703
1796
  ? (reserved.add(preferredAi), preferredAi)
1704
1797
  : await findFreePort(preferredAi, reserved);
@@ -1711,11 +1804,13 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1711
1804
  );
1712
1805
  ws.port = aiPort;
1713
1806
 
1714
- const jobs = planHostJobs(
1715
- folder,
1716
- ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1717
- ws.projectInfo
1718
- );
1807
+ const jobs = jobsFromHostApps(ws).length
1808
+ ? jobsFromHostApps(ws)
1809
+ : planHostJobs(
1810
+ folder,
1811
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1812
+ ws.projectInfo
1813
+ );
1719
1814
  log(
1720
1815
  jobs.length
1721
1816
  ? `ports jobs ${jobs.map((job) => `${job.role}:${job.script}:${job.preferredPort}`).join(" ")}`
@@ -1749,7 +1844,9 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1749
1844
  }
1750
1845
 
1751
1846
  const env = envForWorkspacePorts(ws, planned);
1752
- writeProjectEnv(folder, env);
1847
+ log(
1848
+ `env inject ${label} (process only): ${Object.keys(env).join(" ") || "(none)"}`
1849
+ );
1753
1850
  if (planned.length) {
1754
1851
  ws.projectInfo = {
1755
1852
  ...(ws.projectInfo || {}),
@@ -1768,14 +1865,11 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1768
1865
  return { aiPort, jobs: planned, env };
1769
1866
  }
1770
1867
 
1771
- const ACCESS_IGNORE_BEGIN = "# maintainer-pro:access-begin";
1772
- const ACCESS_IGNORE_END = "# maintainer-pro:access-end";
1773
1868
  const DEFAULT_AI_IGNORE_PATHS = [
1774
1869
  ".env",
1775
1870
  ".env.*",
1776
1871
  "**/.env",
1777
1872
  "**/.env.*",
1778
- ".maintainer-pro/",
1779
1873
  ];
1780
1874
 
1781
1875
  function normalizeIgnorePaths(paths) {
@@ -1798,51 +1892,20 @@ function resolveIgnorePaths(partnerPaths) {
1798
1892
  return normalizeIgnorePaths([...DEFAULT_AI_IGNORE_PATHS, ...(partnerPaths || [])]);
1799
1893
  }
1800
1894
 
1801
- function upsertManagedIgnoreFile(existing, ignorePaths) {
1802
- const block = [
1803
- ACCESS_IGNORE_BEGIN,
1804
- "# Managed by Maintainer Pro — do not edit this block by hand.",
1805
- ...ignorePaths,
1806
- ACCESS_IGNORE_END,
1807
- "",
1808
- ].join("\n");
1809
- const begin = existing.indexOf(ACCESS_IGNORE_BEGIN);
1810
- const end = existing.indexOf(ACCESS_IGNORE_END);
1811
- if (begin >= 0 && end > begin) {
1812
- const afterEnd = end + ACCESS_IGNORE_END.length;
1813
- const before = existing.slice(0, begin).replace(/\s+$/, "");
1814
- const after = existing.slice(afterEnd).replace(/^\r?\n/, "");
1815
- const parts = [before, block.trimEnd(), after.trimStart()].filter(Boolean);
1816
- return `${parts.join("\n\n")}\n`;
1817
- }
1818
- const trimmed = existing.replace(/\s+$/, "");
1819
- return trimmed ? `${trimmed}\n\n${block}` : block;
1820
- }
1821
-
1822
1895
  /**
1823
- * Restrict AI CLI to this project folder and write partner ignore paths.
1824
- * @param {string} folder
1825
- * @param {string[]} partnerIgnorePaths
1896
+ * Store partner ignore paths next to the bridge. Does not write into the host app.
1826
1897
  */
1827
- function applyAccessPolicy(folder, partnerIgnorePaths) {
1898
+ function applyAccessPolicy(folder, partnerIgnorePaths, sandboxId) {
1828
1899
  const resolved = path.resolve(folder);
1829
1900
  fs.mkdirSync(resolved, { recursive: true });
1830
1901
  const ignorePaths = resolveIgnorePaths(partnerIgnorePaths);
1831
- const envPath = path.join(resolved, ".env");
1832
- mergeEnvFile(envPath, {
1833
- AI_CLI_WORKSPACE: ".",
1834
- AI_CLI_IGNORE_PATHS: JSON.stringify(
1835
- normalizeIgnorePaths(partnerIgnorePaths || [])
1836
- ),
1837
- });
1838
1902
 
1839
- const mpDir = path.join(resolved, ".maintainer-pro");
1840
- fs.mkdirSync(mpDir, { recursive: true });
1903
+ const mpDir = dataDirFor(resolved, sandboxId);
1841
1904
  fs.writeFileSync(
1842
1905
  path.join(mpDir, "access.json"),
1843
1906
  JSON.stringify(
1844
1907
  {
1845
- workspace: ".",
1908
+ workspace: resolved,
1846
1909
  ignorePaths,
1847
1910
  partnerIgnorePaths: normalizeIgnorePaths(partnerIgnorePaths || []),
1848
1911
  updatedAt: new Date().toISOString(),
@@ -1853,15 +1916,7 @@ function applyAccessPolicy(folder, partnerIgnorePaths) {
1853
1916
  "utf8"
1854
1917
  );
1855
1918
 
1856
- for (const name of [".cursorignore"]) {
1857
- const file = path.join(resolved, name);
1858
- const existing = fs.existsSync(file)
1859
- ? fs.readFileSync(file, "utf8")
1860
- : "";
1861
- fs.writeFileSync(file, upsertManagedIgnoreFile(existing, ignorePaths), "utf8");
1862
- }
1863
-
1864
- return { ignorePaths };
1919
+ return { ignorePaths, dataDir: mpDir };
1865
1920
  }
1866
1921
 
1867
1922
  const IGNORE_NAMES = new Set([
@@ -1870,6 +1925,7 @@ const IGNORE_NAMES = new Set([
1870
1925
  "Thumbs.db",
1871
1926
  "node_modules",
1872
1927
  ".maintainer-pro",
1928
+ ".collaborater",
1873
1929
  ".maintainer-pro-bridge.json",
1874
1930
  ".cloudflare-tunnel-url",
1875
1931
  ]);
@@ -1968,161 +2024,9 @@ function emptyProjectIndexHtml(appName) {
1968
2024
  `;
1969
2025
  }
1970
2026
 
1971
- const WIDGET_MARKER = "AiUi.init";
1972
-
1973
- function injectHtmlWidget(html, aiServerUrl) {
1974
- if (html.includes(WIDGET_MARKER) || html.includes("ai-ui.iife.js")) {
1975
- return { html, injected: false };
1976
- }
1977
- const snippet = `
1978
- <script src="${aiServerUrl}/embed-config.js"></script>
1979
- <script src="${aiServerUrl}/ai-ui.iife.js"></script>
1980
- <script>
1981
- (function () {
1982
- var cfg = window.__MAINTAINER_PRO__ || {};
1983
- if (!cfg.apiUrl) {
1984
- console.error("Maintainer Pro: embed-config.js missing apiUrl (is ai-server running?)");
1985
- return;
1986
- }
1987
- AiUi.init({
1988
- apiUrl: cfg.apiUrl,
1989
- title: "AI Assistant",
1990
- maintainerProUrl: cfg.maintainerProUrl || undefined,
1991
- maintainerProApiKey: cfg.maintainerProApiKey || undefined,
1992
- });
1993
- })();
1994
- </script>
1995
- `;
1996
- if (/<\/body>/i.test(html)) {
1997
- return {
1998
- html: html.replace(/<\/body>/i, `${snippet}</body>`),
1999
- injected: true,
2000
- };
2001
- }
2002
- return { html: html + snippet, injected: true };
2003
- }
2004
-
2005
- function nextWidgetComponentSource() {
2006
- return `"use client";
2007
-
2008
- import { useEffect } from "react";
2009
-
2010
- declare global {
2011
- interface Window {
2012
- AiUi?: { init: (opts: Record<string, unknown>) => void; destroy?: () => void };
2013
- __MAINTAINER_PRO__?: {
2014
- aiServerUrl?: string;
2015
- apiUrl?: string;
2016
- maintainerProUrl?: string;
2017
- maintainerProApiKey?: string;
2018
- };
2019
- }
2020
- }
2021
-
2022
- const AI_SERVER_URL = (process.env.NEXT_PUBLIC_AI_SERVER_URL || "").replace(/\\/$/, "");
2023
-
2024
- export function MaintainerProWidget() {
2025
- useEffect(() => {
2026
- if (!AI_SERVER_URL) {
2027
- console.error("Set NEXT_PUBLIC_AI_SERVER_URL to the ai-server origin");
2028
- return;
2029
- }
2030
- let cancelled = false;
2031
-
2032
- const start = (cfg: {
2033
- apiUrl?: string;
2034
- maintainerProUrl?: string;
2035
- maintainerProApiKey?: string;
2036
- }) => {
2037
- if (cancelled) return;
2038
- const AiUi = window.AiUi;
2039
- if (!AiUi?.init) {
2040
- setTimeout(() => start(cfg), 40);
2041
- return;
2042
- }
2043
- AiUi.init({
2044
- apiUrl: cfg.apiUrl || \`\${AI_SERVER_URL}/api/chat\`,
2045
- title: "AI Assistant",
2046
- maintainerProUrl: cfg.maintainerProUrl,
2047
- maintainerProApiKey: cfg.maintainerProApiKey,
2048
- });
2049
- };
2050
-
2051
- const ensureScript = (src: string) =>
2052
- new Promise<void>((resolve, reject) => {
2053
- const existing = document.querySelector<HTMLScriptElement>(\`script[src="\${src}"]\`);
2054
- if (existing) {
2055
- if (existing.dataset.loaded === "1") resolve();
2056
- else existing.addEventListener("load", () => resolve(), { once: true });
2057
- return;
2058
- }
2059
- const script = document.createElement("script");
2060
- script.src = src;
2061
- script.async = true;
2062
- script.onload = () => {
2063
- script.dataset.loaded = "1";
2064
- resolve();
2065
- };
2066
- script.onerror = () => reject(new Error(\`Failed to load \${src}\`));
2067
- document.body.appendChild(script);
2068
- });
2069
-
2070
- void (async () => {
2071
- try {
2072
- await ensureScript(\`\${AI_SERVER_URL}/embed-config.js\`);
2073
- await ensureScript(\`\${AI_SERVER_URL}/ai-ui.iife.js\`);
2074
- start(window.__MAINTAINER_PRO__ || {});
2075
- } catch (err) {
2076
- console.error(err);
2077
- }
2078
- })();
2079
-
2080
- return () => {
2081
- cancelled = true;
2082
- window.AiUi?.destroy?.();
2083
- };
2084
- }, []);
2085
-
2086
- return null;
2087
- }
2088
- `;
2089
- }
2090
-
2091
- function tryMountNextWidget(dir) {
2092
- const candidates = [
2093
- path.join(dir, "app", "layout.tsx"),
2094
- path.join(dir, "app", "layout.jsx"),
2095
- path.join(dir, "src", "app", "layout.tsx"),
2096
- path.join(dir, "src", "app", "layout.jsx"),
2097
- ];
2098
- for (const layout of candidates) {
2099
- if (!fs.existsSync(layout)) continue;
2100
- let text = fs.readFileSync(layout, "utf8");
2101
- if (text.includes("MaintainerProWidget")) {
2102
- return { mounted: false, reason: "already mounted" };
2103
- }
2104
- const fromAppRoot =
2105
- /[/\\]app[/\\]layout\.(t|j)sx$/.test(layout) &&
2106
- !/[/\\]src[/\\]app[/\\]/.test(layout);
2107
- const imp = fromAppRoot
2108
- ? "../components/MaintainerProWidget"
2109
- : "@/components/MaintainerProWidget";
2110
-
2111
- text = `import { MaintainerProWidget } from "${imp}";\n` + text;
2112
- if (/\{children\}/.test(text)) {
2113
- text = text.replace(
2114
- /\{children\}/,
2115
- "{children}\n <MaintainerProWidget />"
2116
- );
2117
- fs.writeFileSync(layout, text, "utf8");
2118
- return { mounted: true, layout };
2119
- }
2120
- }
2121
- return { mounted: false, reason: "no layout found" };
2122
- }
2123
-
2124
2027
  /**
2125
- * Configure client files for a workspace.
2028
+ * Detect project kind only. Existing host files are never rewritten —
2029
+ * the share proxy injects chat, and Start Apps injects process env.
2126
2030
  */
2127
2031
  function configureClient(opts) {
2128
2032
  const {
@@ -2159,7 +2063,7 @@ function configureClient(opts) {
2159
2063
  if (!fs.existsSync(indexPath)) {
2160
2064
  fs.writeFileSync(indexPath, emptyProjectIndexHtml(appName), "utf8");
2161
2065
  filesWritten.push("index.html");
2162
- notes.push("Created index.html (served by ai-server).");
2066
+ notes.push("Created index.html (chat served in-process).");
2163
2067
  } else {
2164
2068
  notes.push("index.html already present.");
2165
2069
  }
@@ -2174,27 +2078,13 @@ function configureClient(opts) {
2174
2078
  }
2175
2079
 
2176
2080
  if (kind === "html") {
2177
- const candidates = [
2178
- path.join(dir, "index.html"),
2179
- path.join(dir, "public", "index.html"),
2180
- ];
2181
- for (const htmlPath of candidates) {
2182
- if (!fs.existsSync(htmlPath)) continue;
2183
- const raw = fs.readFileSync(htmlPath, "utf8");
2184
- const { html, injected } = injectHtmlWidget(raw, aiOrigin);
2185
- if (injected) {
2186
- fs.writeFileSync(htmlPath, html, "utf8");
2187
- filesWritten.push(path.relative(dir, htmlPath));
2188
- notes.push(`Injected widget into ${path.relative(dir, htmlPath)}.`);
2189
- } else {
2190
- notes.push(`Widget already present in ${path.relative(dir, htmlPath)}.`);
2191
- }
2192
- break;
2193
- }
2194
2081
  const origin = hostAppUrl || "http://localhost:3000";
2195
2082
  return {
2196
2083
  kind: "html",
2197
- notes,
2084
+ notes: [
2085
+ ...notes,
2086
+ "Existing HTML app — chat is injected on the share URL, the host files are not changed.",
2087
+ ],
2198
2088
  filesWritten,
2199
2089
  corsOrigin: origin,
2200
2090
  appUrl: origin,
@@ -2203,36 +2093,12 @@ function configureClient(opts) {
2203
2093
  }
2204
2094
 
2205
2095
  if (kind === "next") {
2206
- const envLocal = path.join(dir, ".env.local");
2207
- mergeEnvFile(envLocal, {
2208
- NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
2209
- });
2210
- filesWritten.push(".env.local");
2211
- notes.push("Set NEXT_PUBLIC_AI_SERVER_URL in .env.local.");
2212
-
2213
- const useSrc = fs.existsSync(path.join(dir, "src", "app"));
2214
- const compDir = useSrc
2215
- ? path.join(dir, "src", "components")
2216
- : path.join(dir, "components");
2217
- fs.mkdirSync(compDir, { recursive: true });
2218
- const widgetPath = path.join(compDir, "MaintainerProWidget.tsx");
2219
- if (!fs.existsSync(widgetPath)) {
2220
- fs.writeFileSync(widgetPath, nextWidgetComponentSource(), "utf8");
2221
- filesWritten.push(path.relative(dir, widgetPath));
2222
- notes.push("Added MaintainerProWidget.tsx.");
2223
- }
2224
- const mount = tryMountNextWidget(dir);
2225
- if (mount.mounted) {
2226
- notes.push(`Mounted widget in ${path.relative(dir, mount.layout)}.`);
2227
- } else {
2228
- notes.push(
2229
- "Add <MaintainerProWidget /> to your root layout if it is not mounted yet."
2230
- );
2231
- }
2232
2096
  const origin = hostAppUrl || "http://localhost:3000";
2233
2097
  return {
2234
2098
  kind: "next",
2235
- notes,
2099
+ notes: [
2100
+ "Existing Next.js app — chat is injected on the share URL, the host files are not changed.",
2101
+ ],
2236
2102
  filesWritten,
2237
2103
  corsOrigin: origin,
2238
2104
  appUrl: origin,
@@ -2240,14 +2106,10 @@ function configureClient(opts) {
2240
2106
  };
2241
2107
  }
2242
2108
 
2243
- // other — write host env hints only
2109
+ // other — leave the host app untouched
2244
2110
  const origin = hostAppUrl || "http://localhost:3000";
2245
- mergeEnvFile(path.join(dir, ".env"), {
2246
- AI_SERVER_URL: aiOrigin,
2247
- NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
2248
- });
2249
2111
  notes.push(
2250
- "Existing project detected. Set AI_SERVER_URL / mount the widget manually if needed."
2112
+ "Existing project detected. The bridge injects chat/share env when it starts the app."
2251
2113
  );
2252
2114
  return {
2253
2115
  kind: "other",
@@ -2259,110 +2121,619 @@ function configureClient(opts) {
2259
2121
  };
2260
2122
  }
2261
2123
 
2262
- function collectOfferedFolders(cfg) {
2263
- /** @type {string[]} */
2264
- const folders = [];
2265
- const add = (p) => {
2266
- if (!p) return;
2267
- const resolved = path.resolve(p);
2268
- if (!folders.includes(resolved)) folders.push(resolved);
2269
- };
2270
- add(process.cwd());
2271
- for (const f of cfg.offeredFolders || []) add(f);
2272
- for (const w of cfg.workspaces || []) add(w.folderPath);
2273
- return folders;
2124
+ function collectOfferedFolders() {
2125
+ return [browseRootFromCwd()];
2274
2126
  }
2275
2127
 
2276
- /** Prevents opening a new window on every heartbeat while a process is starting. */
2277
- const launchedAt = new Map();
2278
- /** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
2279
- const cloudflareTunnels = new Map();
2128
+ /** Windows: `D:\Projects\…` `D:\Projects`. Never the drive root. */
2129
+ function browseRootFromCwd() {
2130
+ const cwd = path.resolve(process.cwd());
2131
+ if (process.platform === "win32") {
2132
+ const drive = path.parse(cwd).root;
2133
+ const first = cwd.slice(drive.length).split(/[\\/]/).filter(Boolean)[0];
2134
+ if (first) return path.resolve(path.join(drive, first));
2135
+ }
2136
+ return cwd;
2137
+ }
2280
2138
 
2281
- /** Last process problems to send on heartbeat. Key: sandboxId::code::role */
2282
- const processProblems = new Map();
2139
+ function normalizeFsPath(p) {
2140
+ let s = path.resolve(String(p || "").trim());
2141
+ if (process.platform === "win32") {
2142
+ s = s.replace(/\//g, "\\");
2143
+ if (/^[a-zA-Z]:$/.test(s)) s = `${s}\\`;
2144
+ else s = s.replace(/\\+$/, "");
2145
+ if (/^[a-zA-Z]:$/.test(s)) s = `${s}\\`;
2146
+ return s.toLowerCase();
2147
+ }
2148
+ if (s !== "/") s = s.replace(/\/+$/, "");
2149
+ return s;
2150
+ }
2283
2151
 
2284
- /** @type {Map<string, Set<string>>} sandboxId -> CMD/terminal titles we opened */
2285
- const openedTerminalTitles = new Map();
2152
+ function pathInside(inner, outer) {
2153
+ const a = normalizeFsPath(inner);
2154
+ const b = normalizeFsPath(outer);
2155
+ if (!a || !b) return false;
2156
+ if (a === b) return true;
2157
+ const sep = process.platform === "win32" ? "\\" : "/";
2158
+ const prefix = b.endsWith(sep) ? b : `${b}${sep}`;
2159
+ return a.startsWith(prefix);
2160
+ }
2286
2161
 
2287
- function rememberTerminalTitle(sandboxId, title) {
2288
- const id = String(sandboxId || "").trim();
2289
- const name = String(title || "").trim();
2290
- if (!id || !name) return;
2291
- let titles = openedTerminalTitles.get(id);
2292
- if (!titles) {
2293
- titles = new Set();
2294
- openedTerminalTitles.set(id, titles);
2295
- }
2296
- titles.add(name);
2162
+ function isLaunchRoot(dirPath, allowed) {
2163
+ const resolved = path.resolve(dirPath);
2164
+ return allowed.some((root) => normalizeFsPath(root) === normalizeFsPath(resolved));
2297
2165
  }
2298
2166
 
2299
- function closeWindowsByTitle(title) {
2300
- const name = String(title || "").trim();
2301
- if (!name) return Promise.resolve();
2302
- return new Promise((resolve) => {
2303
- const done = () => resolve();
2304
- if (process.platform === "win32") {
2305
- const child = spawn(
2306
- "taskkill",
2307
- ["/F", "/T", "/FI", `WINDOWTITLE eq ${name}*`],
2308
- { windowsHide: true, stdio: "ignore" }
2309
- );
2310
- child.on("exit", done);
2311
- child.on("error", done);
2312
- setTimeout(done, 4000);
2313
- return;
2314
- }
2315
- if (process.platform === "darwin") {
2316
- const child = spawn(
2317
- "osascript",
2318
- [
2319
- "-e",
2320
- `tell application "Terminal" to close (every window whose name contains ${JSON.stringify(name)})`,
2321
- ],
2322
- { stdio: "ignore" }
2323
- );
2324
- child.on("exit", done);
2325
- child.on("error", done);
2326
- setTimeout(done, 4000);
2327
- return;
2328
- }
2329
- done();
2330
- });
2167
+ function listLaunchRoots(allowed) {
2168
+ const home = os.homedir();
2169
+ if (allowed.length === 1) return listDirEntries(allowed[0], allowed);
2170
+ return {
2171
+ path: "",
2172
+ parent: null,
2173
+ home,
2174
+ entries: allowed.map((root) => ({
2175
+ name: root.split(/[/\\]/).filter(Boolean).slice(-1)[0] || root,
2176
+ path: root,
2177
+ isDir: true,
2178
+ })),
2179
+ };
2331
2180
  }
2332
2181
 
2333
- async function closeRememberedTerminals(sandboxId) {
2334
- const titles = openedTerminalTitles.get(sandboxId);
2335
- if (!titles) return;
2336
- for (const title of titles) {
2337
- await closeWindowsByTitle(title);
2338
- }
2339
- openedTerminalTitles.delete(sandboxId);
2182
+ /** Prevents opening a new window on every heartbeat while a process is starting. */
2183
+ const launchedAt = new Map();
2184
+ /** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
2185
+ const cloudflareTunnels = new Map();
2186
+ /** In-process chat servers, one per sandbox/project. */
2187
+ /** @type {Map<string, { port: number, workspaceDir: string, close: () => Promise<void>, runChat: (body: Record<string, unknown>) => Promise<{ ok: boolean, status: number, data: Record<string, unknown> }>, handleHttp?: Function }>} */
2188
+ const embeddedChat = new Map();
2189
+ /** @type {Map<string, Promise<object | null>>} */
2190
+ const embeddedChatStarting = new Map();
2191
+
2192
+ const PROXY_CHUNK_BYTES = 32 * 1024;
2193
+ /** @type {Map<string, import("node:http").ClientRequest>} */
2194
+ const proxyHttpReqs = new Map();
2195
+ /** @type {Map<string, WebSocket>} */
2196
+ const proxyLocalSockets = new Map();
2197
+ /** @type {Record<string, unknown> | null} */
2198
+ let bridgeCfg = null;
2199
+
2200
+ function safeProxyPath(path) {
2201
+ const raw = String(path || "/");
2202
+ if (!raw.startsWith("/") || raw.startsWith("//")) return "/";
2203
+ return raw;
2204
+ }
2205
+
2206
+ function workspaceForProxy(sandboxId) {
2207
+ return (
2208
+ (Array.isArray(bridgeCfg?.workspaces) ? bridgeCfg.workspaces : []).find(
2209
+ (row) => row.sandboxId === sandboxId
2210
+ ) || null
2211
+ );
2340
2212
  }
2341
2213
 
2342
- async function forgetLaunch(sandboxId) {
2343
- for (const key of [...launchedAt.keys()]) {
2344
- if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
2345
- launchedAt.delete(key);
2346
- }
2214
+ function localPortForProxy(ws, appId) {
2215
+ const apps = Array.isArray(ws?.hostApps) ? ws.hostApps : [];
2216
+ const app = apps.find((row) => row.id === appId);
2217
+ let port = 0;
2218
+ if (app && usesBridgeProxy(app) && Number(app.port)) port = Number(app.port);
2219
+ else if (appId === "ai-server" || app?.role === "ai-server") {
2220
+ port = Number(ws.port) || 3100;
2347
2221
  }
2348
- for (const key of [...processProblems.keys()]) {
2349
- if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
2222
+ if (
2223
+ (appId === "ai-server" || app?.role === "ai-server") &&
2224
+ isAdminListenPort(port)
2225
+ ) {
2226
+ return 3100;
2350
2227
  }
2351
- await closeRememberedTerminals(sandboxId);
2352
- await stopCloudflare(sandboxId);
2228
+ return port;
2353
2229
  }
2354
2230
 
2355
- function problemKey(sandboxId, code, role = "") {
2356
- return `${sandboxId || ""}::${code}::${role}`;
2231
+ function proxyReqHeaders(incoming) {
2232
+ /** @type {Record<string, string>} */
2233
+ const headers = {};
2234
+ if (!incoming || typeof incoming !== "object") return headers;
2235
+ for (const [key, value] of Object.entries(incoming)) {
2236
+ const lower = String(key).toLowerCase();
2237
+ if (
2238
+ [
2239
+ "connection",
2240
+ "keep-alive",
2241
+ "transfer-encoding",
2242
+ "upgrade",
2243
+ "host",
2244
+ "content-length",
2245
+ "te",
2246
+ "trailer",
2247
+ ].includes(lower)
2248
+ ) {
2249
+ continue;
2250
+ }
2251
+ if (typeof value === "string" && value) headers[key] = value;
2252
+ }
2253
+ return headers;
2357
2254
  }
2358
2255
 
2359
- function clipIssueText(text, max) {
2360
- const value = String(text || "").trim();
2361
- if (value.length <= max) return value;
2362
- return `${value.slice(0, max - 1)}…`;
2256
+ function isAiServerAppId(appId, ws) {
2257
+ if (appId === "ai-server") return true;
2258
+ const apps = Array.isArray(ws?.hostApps) ? ws.hostApps : [];
2259
+ const app = apps.find((row) => row.id === appId);
2260
+ return Boolean(app && (app.role === "ai-server" || app.id === "ai-server"));
2363
2261
  }
2364
2262
 
2365
- function processRoleLabel(role) {
2263
+ function replyProxyHttp(id, status, headers, body) {
2264
+ const stream = randomBytes(8).toString("hex");
2265
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(body || "");
2266
+ /** @type {Record<string, string>} */
2267
+ const out = {};
2268
+ for (const [key, value] of Object.entries(headers || {})) {
2269
+ if (value == null) continue;
2270
+ out[String(key)] = String(value);
2271
+ }
2272
+ bridgeSend({ type: "proxy.http.start", id, stream, status, headers: out });
2273
+ for (let offset = 0; offset < buf.length; offset += PROXY_CHUNK_BYTES) {
2274
+ const end = Math.min(offset + PROXY_CHUNK_BYTES, buf.length);
2275
+ bridgeSend({
2276
+ type: "proxy.http.chunk",
2277
+ id,
2278
+ stream,
2279
+ data: buf.subarray(offset, end).toString("base64"),
2280
+ eof: false,
2281
+ });
2282
+ }
2283
+ bridgeSend({ type: "proxy.http.chunk", id, stream, data: "", eof: true });
2284
+ }
2285
+
2286
+ function bridgeEmbedConfigJs(ws) {
2287
+ const store = ws?.store && typeof ws.store === "object" ? ws.store : {};
2288
+ const aiApp = { id: "ai-server", role: "ai-server" };
2289
+ const ai = (
2290
+ proxyUrlForApp(ws, aiApp) ||
2291
+ `http://127.0.0.1:${Number(ws.port) || 3100}`
2292
+ ).replace(/\/$/, "");
2293
+ const wsUrl = ai.replace(/^http/i, "ws");
2294
+ const payload = {
2295
+ aiServerUrl: ai,
2296
+ apiUrl: `${ai}/api/chat`,
2297
+ aiServerWsUrl: `${wsUrl}/api/ws`,
2298
+ debug: true,
2299
+ logLevel: "debug",
2300
+ maintainerProUrl: bridgeCfg?.adminUrl || "",
2301
+ maintainerProApiKey: String(store.clientKey || "").trim(),
2302
+ };
2303
+ return `window.__MAINTAINER_PRO__=${JSON.stringify(payload)};`;
2304
+ }
2305
+
2306
+ async function handleAiProxyHttpFromAdmin(msg, ws) {
2307
+ const id = typeof msg.id === "string" ? msg.id : "";
2308
+ const method = String(msg.method || "GET").toUpperCase();
2309
+ const reqPath = safeProxyPath(msg.path);
2310
+ const pathname = reqPath.split("?")[0] || "/";
2311
+ const headers = proxyReqHeaders(msg.headers);
2312
+ const body =
2313
+ typeof msg.body === "string" && msg.body
2314
+ ? Buffer.from(msg.body, "base64")
2315
+ : Buffer.alloc(0);
2316
+
2317
+ if (pathname === "/ai-ui.iife.js") {
2318
+ const file = findIife();
2319
+ if (!file || !fs.existsSync(file)) {
2320
+ replyProxyHttp(
2321
+ id,
2322
+ 404,
2323
+ { "content-type": "text/plain; charset=utf-8" },
2324
+ "Not found"
2325
+ );
2326
+ return;
2327
+ }
2328
+ replyProxyHttp(
2329
+ id,
2330
+ 200,
2331
+ {
2332
+ "content-type": "text/javascript; charset=utf-8",
2333
+ "cache-control": "no-store",
2334
+ },
2335
+ fs.readFileSync(file)
2336
+ );
2337
+ return;
2338
+ }
2339
+
2340
+ if (pathname === "/embed-config.js") {
2341
+ const cfg = bridgeCfg || loadConfig();
2342
+ await loadSandboxStoreEnv(ws, cfg);
2343
+ replyProxyHttp(
2344
+ id,
2345
+ 200,
2346
+ {
2347
+ "content-type": "text/javascript; charset=utf-8",
2348
+ "cache-control": "no-store",
2349
+ },
2350
+ bridgeEmbedConfigJs(ws)
2351
+ );
2352
+ return;
2353
+ }
2354
+
2355
+ let embedded = ws.sandboxId ? embeddedChat.get(ws.sandboxId) : null;
2356
+ if (!embedded?.handleHttp) {
2357
+ embedded = await ensureEmbeddedChat(ws);
2358
+ }
2359
+ if (embedded?.handleHttp) {
2360
+ try {
2361
+ const result = await embedded.handleHttp({
2362
+ method,
2363
+ url: reqPath,
2364
+ headers,
2365
+ body,
2366
+ });
2367
+ replyProxyHttp(id, result.status, result.headers, result.body);
2368
+ } catch (err) {
2369
+ bridgeSend({
2370
+ type: "proxy.http.error",
2371
+ id,
2372
+ error: err instanceof Error ? err.message : String(err),
2373
+ });
2374
+ }
2375
+ return;
2376
+ }
2377
+
2378
+ bridgeSend({
2379
+ type: "proxy.http.error",
2380
+ id,
2381
+ error: "AI chat is not running. Use Start Apps in Maintainer Pro.",
2382
+ });
2383
+ }
2384
+
2385
+ function handleProxyHttpFromAdmin(msg) {
2386
+ const id = typeof msg.id === "string" ? msg.id : "";
2387
+ const sandboxId = typeof msg.sandboxId === "string" ? msg.sandboxId : "";
2388
+ const appId = typeof msg.appId === "string" ? msg.appId : "";
2389
+ if (!id) return;
2390
+ const ws = workspaceForProxy(sandboxId);
2391
+ if (!ws) {
2392
+ bridgeSend({
2393
+ type: "proxy.http.error",
2394
+ id,
2395
+ error: "No local port mapped for that app",
2396
+ });
2397
+ return;
2398
+ }
2399
+ if (isAiServerAppId(appId, ws)) {
2400
+ void handleAiProxyHttpFromAdmin(msg, ws);
2401
+ return;
2402
+ }
2403
+ const existing = proxyHttpReqs.get(id);
2404
+ if (existing) {
2405
+ try {
2406
+ existing.destroy();
2407
+ } catch {
2408
+ /* ignore */
2409
+ }
2410
+ proxyHttpReqs.delete(id);
2411
+ }
2412
+ const port = localPortForProxy(ws, appId);
2413
+ if (!port) {
2414
+ bridgeSend({
2415
+ type: "proxy.http.error",
2416
+ id,
2417
+ error: "No local port mapped for that app",
2418
+ });
2419
+ return;
2420
+ }
2421
+ const method = String(msg.method || "GET").toUpperCase();
2422
+ const path = safeProxyPath(msg.path);
2423
+ const headers = proxyReqHeaders(msg.headers);
2424
+ headers.host = `127.0.0.1:${port}`;
2425
+ // Next.js dev 403s `/_next` when Origin/sec-fetch look cross-site.
2426
+ // This hop is server-to-server; drop those so chunks always load.
2427
+ for (const key of Object.keys(headers)) {
2428
+ const lower = key.toLowerCase();
2429
+ if (
2430
+ lower === "origin" ||
2431
+ lower === "referer" ||
2432
+ lower === "referrer" ||
2433
+ lower === "sec-fetch-site" ||
2434
+ lower === "sec-fetch-mode" ||
2435
+ lower === "sec-fetch-dest"
2436
+ ) {
2437
+ delete headers[key];
2438
+ }
2439
+ }
2440
+ const stream = randomBytes(8).toString("hex");
2441
+ let req;
2442
+ try {
2443
+ req = http.request(
2444
+ {
2445
+ hostname: "127.0.0.1",
2446
+ port,
2447
+ path,
2448
+ method,
2449
+ headers,
2450
+ agent: false,
2451
+ },
2452
+ (res) => {
2453
+ /** @type {Record<string, string>} */
2454
+ const outHeaders = {};
2455
+ for (const [key, value] of Object.entries(res.headers)) {
2456
+ if (value == null) continue;
2457
+ outHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value);
2458
+ }
2459
+ bridgeSend({
2460
+ type: "proxy.http.start",
2461
+ id,
2462
+ stream,
2463
+ status: res.statusCode || 502,
2464
+ headers: outHeaders,
2465
+ });
2466
+ res.on("data", (chunk) => {
2467
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2468
+ for (let offset = 0; offset < buf.length; offset += PROXY_CHUNK_BYTES) {
2469
+ const end = Math.min(offset + PROXY_CHUNK_BYTES, buf.length);
2470
+ bridgeSend({
2471
+ type: "proxy.http.chunk",
2472
+ id,
2473
+ stream,
2474
+ data: Buffer.from(buf.subarray(offset, end)).toString("base64"),
2475
+ eof: false,
2476
+ });
2477
+ }
2478
+ });
2479
+ res.on("end", () => {
2480
+ proxyHttpReqs.delete(id);
2481
+ bridgeSend({ type: "proxy.http.chunk", id, stream, data: "", eof: true });
2482
+ });
2483
+ }
2484
+ );
2485
+ } catch (err) {
2486
+ bridgeSend({
2487
+ type: "proxy.http.error",
2488
+ id,
2489
+ error: err instanceof Error ? err.message : String(err),
2490
+ });
2491
+ return;
2492
+ }
2493
+ req.on("error", (err) => {
2494
+ proxyHttpReqs.delete(id);
2495
+ bridgeSend({
2496
+ type: "proxy.http.error",
2497
+ id,
2498
+ error: err instanceof Error ? err.message : String(err),
2499
+ });
2500
+ });
2501
+ proxyHttpReqs.set(id, req);
2502
+ if (typeof msg.body === "string" && msg.body) {
2503
+ req.write(Buffer.from(msg.body, "base64"));
2504
+ }
2505
+ if (msg.bodyEof !== false) req.end();
2506
+ }
2507
+
2508
+ function handleProxyHttpBodyFromAdmin(msg) {
2509
+ const id = typeof msg.id === "string" ? msg.id : "";
2510
+ const req = proxyHttpReqs.get(id);
2511
+ if (!req) return;
2512
+ if (typeof msg.data === "string" && msg.data) {
2513
+ req.write(Buffer.from(msg.data, "base64"));
2514
+ }
2515
+ if (msg.eof === true) req.end();
2516
+ }
2517
+
2518
+ function wsDataToBuffer(data) {
2519
+ if (typeof data === "string") return Buffer.from(data);
2520
+ if (Buffer.isBuffer(data)) return data;
2521
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
2522
+ if (ArrayBuffer.isView(data)) {
2523
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
2524
+ }
2525
+ throw new Error("Unsupported WebSocket payload type");
2526
+ }
2527
+
2528
+ function attachProxyLocalWs(id, socket) {
2529
+ try {
2530
+ socket.binaryType = "arraybuffer";
2531
+ } catch {
2532
+ /* WHATWG WebSocket in Node 22 */
2533
+ }
2534
+ proxyLocalSockets.set(id, socket);
2535
+ socket.addEventListener("open", () => {
2536
+ bridgeSend({ type: "proxy.ws.opened", id });
2537
+ });
2538
+ socket.addEventListener("message", (event) => {
2539
+ try {
2540
+ if (typeof event.data === "string") {
2541
+ bridgeSend({
2542
+ type: "proxy.ws.frame",
2543
+ id,
2544
+ data: event.data,
2545
+ binary: false,
2546
+ });
2547
+ return;
2548
+ }
2549
+ const buf = wsDataToBuffer(event.data);
2550
+ bridgeSend({
2551
+ type: "proxy.ws.frame",
2552
+ id,
2553
+ data: buf.toString("base64"),
2554
+ binary: true,
2555
+ });
2556
+ } catch (err) {
2557
+ logger.warn(
2558
+ `proxy ws frame encode failed: ${err instanceof Error ? err.message : String(err)}`
2559
+ );
2560
+ }
2561
+ });
2562
+ socket.addEventListener("close", (event) => {
2563
+ proxyLocalSockets.delete(id);
2564
+ bridgeSend({
2565
+ type: "proxy.ws.close",
2566
+ id,
2567
+ code: event.code,
2568
+ reason: event.reason || "",
2569
+ });
2570
+ });
2571
+ socket.addEventListener("error", () => {
2572
+ /* close handler follows */
2573
+ });
2574
+ }
2575
+
2576
+ function openProxyLocalWs(id, port, path) {
2577
+ let socket;
2578
+ try {
2579
+ socket = new WebSocket(`ws://127.0.0.1:${port}${path}`);
2580
+ } catch (err) {
2581
+ bridgeSend({
2582
+ type: "proxy.ws.error",
2583
+ id,
2584
+ error: err instanceof Error ? err.message : String(err),
2585
+ });
2586
+ return;
2587
+ }
2588
+ attachProxyLocalWs(id, socket);
2589
+ }
2590
+
2591
+ function handleProxyWsOpenFromAdmin(msg) {
2592
+ const id = typeof msg.id === "string" ? msg.id : "";
2593
+ const sandboxId = typeof msg.sandboxId === "string" ? msg.sandboxId : "";
2594
+ const appId = typeof msg.appId === "string" ? msg.appId : "";
2595
+ if (!id) return;
2596
+ const ws = workspaceForProxy(sandboxId);
2597
+ if (!ws) {
2598
+ bridgeSend({ type: "proxy.ws.error", id, error: "No local port mapped" });
2599
+ return;
2600
+ }
2601
+ const path = safeProxyPath(msg.path);
2602
+ if (isAiServerAppId(appId, ws)) {
2603
+ void (async () => {
2604
+ const embedded = await ensureEmbeddedChat(ws);
2605
+ const port = Number(embedded?.port) || localPortForProxy(ws, appId);
2606
+ if (!port) {
2607
+ bridgeSend({ type: "proxy.ws.error", id, error: "AI chat is not running" });
2608
+ return;
2609
+ }
2610
+ openProxyLocalWs(id, port, path);
2611
+ })();
2612
+ return;
2613
+ }
2614
+ const port = localPortForProxy(ws, appId);
2615
+ if (!port) {
2616
+ bridgeSend({ type: "proxy.ws.error", id, error: "No local port mapped" });
2617
+ return;
2618
+ }
2619
+ openProxyLocalWs(id, port, path);
2620
+ }
2621
+
2622
+ function handleProxyWsFrameFromAdmin(msg) {
2623
+ const id = typeof msg.id === "string" ? msg.id : "";
2624
+ const socket = proxyLocalSockets.get(id);
2625
+ if (!socket || socket.readyState !== 1) return;
2626
+ try {
2627
+ if (msg.binary === true) {
2628
+ socket.send(Buffer.from(String(msg.data || ""), "base64"));
2629
+ } else {
2630
+ socket.send(String(msg.data || ""));
2631
+ }
2632
+ } catch {
2633
+ /* ignore */
2634
+ }
2635
+ }
2636
+
2637
+ function handleProxyWsCloseFromAdmin(msg) {
2638
+ const id = typeof msg.id === "string" ? msg.id : "";
2639
+ const socket = proxyLocalSockets.get(id);
2640
+ if (!socket) return;
2641
+ proxyLocalSockets.delete(id);
2642
+ try {
2643
+ socket.close(
2644
+ typeof msg.code === "number" ? msg.code : 1000,
2645
+ typeof msg.reason === "string" ? msg.reason.slice(0, 120) : ""
2646
+ );
2647
+ } catch {
2648
+ /* ignore */
2649
+ }
2650
+ }
2651
+
2652
+ /** Last process problems to send on heartbeat. Key: sandboxId::code::role */
2653
+ const processProblems = new Map();
2654
+
2655
+ /** @type {Map<string, Set<string>>} sandboxId -> CMD/terminal titles we opened */
2656
+ const openedTerminalTitles = new Map();
2657
+
2658
+ function rememberTerminalTitle(sandboxId, title) {
2659
+ const id = String(sandboxId || "").trim();
2660
+ const name = String(title || "").trim();
2661
+ if (!id || !name) return;
2662
+ let titles = openedTerminalTitles.get(id);
2663
+ if (!titles) {
2664
+ titles = new Set();
2665
+ openedTerminalTitles.set(id, titles);
2666
+ }
2667
+ titles.add(name);
2668
+ }
2669
+
2670
+ function closeWindowsByTitle(title) {
2671
+ const name = String(title || "").trim();
2672
+ if (!name) return Promise.resolve();
2673
+ return new Promise((resolve) => {
2674
+ const done = () => resolve();
2675
+ if (process.platform === "win32") {
2676
+ const child = spawn(
2677
+ "taskkill",
2678
+ ["/F", "/T", "/FI", `WINDOWTITLE eq ${name}*`],
2679
+ { windowsHide: true, stdio: "ignore" }
2680
+ );
2681
+ child.on("exit", done);
2682
+ child.on("error", done);
2683
+ setTimeout(done, 4000);
2684
+ return;
2685
+ }
2686
+ if (process.platform === "darwin") {
2687
+ const child = spawn(
2688
+ "osascript",
2689
+ [
2690
+ "-e",
2691
+ `tell application "Terminal" to close (every window whose name contains ${JSON.stringify(name)})`,
2692
+ ],
2693
+ { stdio: "ignore" }
2694
+ );
2695
+ child.on("exit", done);
2696
+ child.on("error", done);
2697
+ setTimeout(done, 4000);
2698
+ return;
2699
+ }
2700
+ done();
2701
+ });
2702
+ }
2703
+
2704
+ async function closeRememberedTerminals(sandboxId) {
2705
+ const titles = openedTerminalTitles.get(sandboxId);
2706
+ if (!titles) return;
2707
+ for (const title of titles) {
2708
+ await closeWindowsByTitle(title);
2709
+ }
2710
+ openedTerminalTitles.delete(sandboxId);
2711
+ }
2712
+
2713
+ async function forgetLaunch(sandboxId) {
2714
+ for (const key of [...launchedAt.keys()]) {
2715
+ if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
2716
+ launchedAt.delete(key);
2717
+ }
2718
+ }
2719
+ for (const key of [...processProblems.keys()]) {
2720
+ if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
2721
+ }
2722
+ await closeRememberedTerminals(sandboxId);
2723
+ await stopCloudflare(sandboxId);
2724
+ }
2725
+
2726
+ function problemKey(sandboxId, code, role = "") {
2727
+ return `${sandboxId || ""}::${code}::${role}`;
2728
+ }
2729
+
2730
+ function clipIssueText(text, max) {
2731
+ const value = String(text || "").trim();
2732
+ if (value.length <= max) return value;
2733
+ return `${value.slice(0, max - 1)}…`;
2734
+ }
2735
+
2736
+ function processRoleLabel(role) {
2366
2737
  if (role === "ui") return "app UI";
2367
2738
  if (role === "backend") return "backend";
2368
2739
  if (role === "ai") return "chat script";
@@ -2518,8 +2889,8 @@ function friendlyLaunchError(raw, title) {
2518
2889
  return text || `Could not open a terminal for "${title}".`;
2519
2890
  }
2520
2891
 
2521
- function writeWinLaunchScript(folder, title, command, env) {
2522
- const dir = path.join(folder, ".maintainer-pro");
2892
+ function writeWinLaunchScript(folder, title, command, env, sandboxId) {
2893
+ const dir = dataDirFor(folder, sandboxId);
2523
2894
  fs.mkdirSync(dir, { recursive: true });
2524
2895
  const safe = String(title || "app").replace(/[^a-zA-Z0-9._-]+/g, "-");
2525
2896
  const file = path.join(dir, `launch-${safe}.cmd`);
@@ -2651,7 +3022,13 @@ async function openInNewTerminal(opts) {
2651
3022
  const safeTitle =
2652
3023
  String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ").trim() ||
2653
3024
  "Maintainer Pro";
2654
- const script = writeWinLaunchScript(folder, safeTitle, command, env);
3025
+ const script = writeWinLaunchScript(
3026
+ folder,
3027
+ safeTitle,
3028
+ command,
3029
+ env,
3030
+ sandboxId
3031
+ );
2655
3032
  log(`terminal script [${title}] ${script}`);
2656
3033
  const opened = await openWindowsConsole(script, folder);
2657
3034
  if (!opened.ok) {
@@ -2708,12 +3085,56 @@ function commandWithPort(job, scripts, port) {
2708
3085
  return job.command;
2709
3086
  }
2710
3087
 
3088
+ async function stopEmbeddedChat(sandboxId) {
3089
+ const inst = embeddedChat.get(sandboxId);
3090
+ if (!inst) return;
3091
+ embeddedChat.delete(sandboxId);
3092
+ try {
3093
+ await inst.close();
3094
+ } catch {
3095
+ /* ignore */
3096
+ }
3097
+ }
3098
+
3099
+ async function stopAllEmbeddedChat() {
3100
+ const ids = [...embeddedChat.keys()];
3101
+ await Promise.all(ids.map((id) => stopEmbeddedChat(id)));
3102
+ }
3103
+
3104
+ async function ensureEmbeddedChat(ws) {
3105
+ if (!ws?.sandboxId) return null;
3106
+ const existing = embeddedChat.get(ws.sandboxId);
3107
+ if (existing?.handleHttp) return existing;
3108
+ const inflight = embeddedChatStarting.get(ws.sandboxId);
3109
+ if (inflight) return inflight;
3110
+ const task = (async () => {
3111
+ const cfg = bridgeCfg || loadConfig();
3112
+ const jobs = jobsFromHostApps(ws);
3113
+ const shareEnv = {
3114
+ ...envForWorkspacePorts(ws, jobs),
3115
+ ...uiPublicEnv({}, ws),
3116
+ };
3117
+ log(`lazy-start in-process chat for ${ws.sandboxName || shortId(ws.sandboxId)}`);
3118
+ const result = await startAiServerForWorkspace(ws, {
3119
+ cfg,
3120
+ env: shareEnv,
3121
+ forceEmbed: true,
3122
+ });
3123
+ if (!result.up) return null;
3124
+ return embeddedChat.get(ws.sandboxId) || null;
3125
+ })().finally(() => {
3126
+ embeddedChatStarting.delete(ws.sandboxId);
3127
+ });
3128
+ embeddedChatStarting.set(ws.sandboxId, task);
3129
+ return task;
3130
+ }
3131
+
2711
3132
  async function startAiServerForWorkspace(ws, opts = {}) {
2712
3133
  const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
2713
3134
  const cfg = opts.cfg || null;
2714
3135
  const folder = path.resolve(ws.folderPath);
2715
- const preferred = Number(ws.port) || 3100;
2716
- const launchKey = `${ws.sandboxId}:${folder}:ai`;
3136
+ const preferredRaw = Number(ws.port) || 3100;
3137
+ const preferred = isAdminListenPort(preferredRaw) ? 3100 : preferredRaw;
2717
3138
  const label = ws.sandboxName || "this sandbox";
2718
3139
 
2719
3140
  if (!fs.existsSync(folder)) {
@@ -2728,19 +3149,28 @@ async function startAiServerForWorkspace(ws, opts = {}) {
2728
3149
  return { port: preferred, up: false, launched: false };
2729
3150
  }
2730
3151
 
2731
- if (await probeUrl(`http://127.0.0.1:${preferred}/embed-config.js`)) {
3152
+ const existing = embeddedChat.get(ws.sandboxId);
3153
+ if (existing && existing.workspaceDir === folder) {
3154
+ if (await isChatServerOnPort(existing.port)) {
3155
+ reserved.add(existing.port);
3156
+ ws.port = existing.port;
3157
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
3158
+ log(`start chat skip ${label}: already in-process on ${existing.port}`);
3159
+ return { port: existing.port, up: true, launched: false };
3160
+ }
3161
+ await stopEmbeddedChat(ws.sandboxId);
3162
+ } else if (existing) {
3163
+ await stopEmbeddedChat(ws.sandboxId);
3164
+ }
3165
+
3166
+ if (!opts.forceEmbed && (await isChatServerOnPort(preferred))) {
2732
3167
  reserved.add(preferred);
3168
+ ws.port = preferred;
2733
3169
  clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2734
3170
  log(`start chat skip ${label}: already up on ${preferred}`);
2735
3171
  return { port: preferred, up: true, launched: false };
2736
3172
  }
2737
3173
 
2738
- if (recentlyLaunched(launchKey)) {
2739
- reserved.add(preferred);
2740
- log(`start chat skip ${label}: already launching on ${preferred}`);
2741
- return { port: preferred, up: false, launched: false, starting: true };
2742
- }
2743
-
2744
3174
  let port = Number(opts.port) || preferred;
2745
3175
  try {
2746
3176
  if (!opts.port) {
@@ -2762,7 +3192,7 @@ async function startAiServerForWorkspace(ws, opts = {}) {
2762
3192
  return { port: preferred, up: false, launched: false };
2763
3193
  }
2764
3194
  if (port !== preferred) {
2765
- log(`port ${preferred} busy; using ${port} for chat script`);
3195
+ log(`port ${preferred} busy; using ${port} for chat`);
2766
3196
  }
2767
3197
  ws.port = port;
2768
3198
  const localAi = `http://localhost:${port}`;
@@ -2776,59 +3206,52 @@ async function startAiServerForWorkspace(ws, opts = {}) {
2776
3206
  (typeof ws.cloudflare?.ai === "string" && ws.cloudflare.ai.trim()) ||
2777
3207
  "";
2778
3208
  const aiUrl = publicAi || localAi;
2779
- writeProjectEnv(folder, {
2780
- AI_SERVER_URL: aiUrl,
2781
- NEXT_PUBLIC_AI_SERVER_URL: aiUrl,
2782
- VITE_AI_SERVER_URL: aiUrl,
2783
- ...overrideEnv,
2784
- });
2785
3209
  persistWorkspaceEntry(cfg, ws);
2786
3210
 
2787
- const localCli = path.resolve(
2788
- __dirname,
2789
- "..",
2790
- "..",
2791
- "ai-server",
2792
- "bin",
2793
- "cli.js"
2794
- );
2795
- const useLocal = fs.existsSync(localCli);
2796
- const run = useLocal
2797
- ? process.platform === "win32"
2798
- ? `"${process.execPath}" "${localCli}" --port ${port}`
2799
- : `${JSON.stringify(process.execPath)} ${JSON.stringify(localCli)} --port ${port}`
2800
- : `npx --yes @maintainer-pro/ai-server --port ${port}`;
2801
-
2802
- const opened = await openInNewTerminal({
2803
- title: `MP-ai-${port}`,
2804
- folder,
2805
- command: run,
2806
- env: {
2807
- AI_SERVER_PORT: String(port),
2808
- AI_SERVER_URL: aiUrl,
2809
- NEXT_PUBLIC_AI_SERVER_URL: aiUrl,
2810
- VITE_AI_SERVER_URL: aiUrl,
2811
- ...overrideEnv,
2812
- },
2813
- launchKey,
2814
- sandboxId: ws.sandboxId,
2815
- });
2816
- if (opened.skipped) {
2817
- return { port, up: false, launched: false, starting: true };
2818
- }
2819
- if (!opened.ok) {
3211
+ const storeEnv = await loadSandboxStoreEnv(ws, cfg);
3212
+ const folderEnv = readProjectEnvValues(folder);
3213
+ const dataDir = dataDirFor(folder, ws.sandboxId);
3214
+ const instanceEnv = {
3215
+ ...folderEnv,
3216
+ ...overrideEnv,
3217
+ ...storeEnv,
3218
+ AI_SERVER_URL: aiUrl,
3219
+ CORS_ORIGIN: overrideEnv.CORS_ORIGIN || folderEnv.CORS_ORIGIN || "",
3220
+ CORS_ORIGINS: overrideEnv.CORS_ORIGINS || folderEnv.CORS_ORIGINS || "",
3221
+ MAINTAINER_PRO_DATA_DIR: dataDir,
3222
+ MAINTAINER_PRO_SANDBOX_ID: String(ws.sandboxId || ""),
3223
+ };
3224
+
3225
+ try {
3226
+ const instance = await startAiServer({
3227
+ port,
3228
+ workspaceDir: folder,
3229
+ uiDir: folder,
3230
+ dataDir,
3231
+ env: instanceEnv,
3232
+ logger: createLogger(`ai-server:${shortId(ws.sandboxId)}`),
3233
+ label: `ai-server:${shortId(ws.sandboxId)}`,
3234
+ listen: true,
3235
+ });
3236
+ embeddedChat.set(ws.sandboxId, instance);
3237
+ ws.port = instance.port;
3238
+ persistWorkspaceEntry(cfg, ws);
3239
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
3240
+ log(`chat in-process for ${label} on ${instance.port} (${folder})`);
3241
+ return { port: instance.port, up: true, launched: true };
3242
+ } catch (err) {
3243
+ const message = err instanceof Error ? err.message : String(err);
2820
3244
  recordProcessProblem({
2821
3245
  sandboxId: ws.sandboxId,
2822
3246
  code: "ai_server_launch",
2823
3247
  role: "ai",
2824
- title: `Could not start the chat script (${label})`,
2825
- message: opened.error,
3248
+ title: `Could not start chat for ${label}`,
3249
+ message,
2826
3250
  resolution:
2827
- "Allow the bridge to open terminal windows, or start the script manually in that folder.",
3251
+ "Keep the bridge connected to Maintainer Pro, then Start Apps.",
2828
3252
  });
2829
3253
  return { port, up: false, launched: false };
2830
3254
  }
2831
- return { port, up: false, launched: true, starting: true };
2832
3255
  }
2833
3256
 
2834
3257
  function sleep(ms) {
@@ -2839,10 +3262,6 @@ function stopAllCloudflare() {
2839
3262
  for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
2840
3263
  }
2841
3264
 
2842
- function parseTryCloudflareUrl(text) {
2843
- return lastTryCloudflareUrl(text);
2844
- }
2845
-
2846
3265
  function killProcessesByCommand(fragment) {
2847
3266
  if (!fragment) return Promise.resolve();
2848
3267
  return new Promise((resolve) => {
@@ -2901,11 +3320,27 @@ function killPort(port) {
2901
3320
  });
2902
3321
  }
2903
3322
 
2904
- function stopCloudflare(sandboxId) {
3323
+ function stopCloudflare(sandboxId, folder) {
2905
3324
  const row = cloudflareTunnels.get(sandboxId);
2906
- const files = row?.tunnels?.map((t) => t.logFile).filter(Boolean) ?? [];
3325
+ const files = new Set(
3326
+ (row?.tunnels?.map((t) => t.logFile).filter(Boolean) ?? [])
3327
+ );
2907
3328
  cloudflareTunnels.delete(sandboxId);
2908
- return Promise.all(files.map((file) => killProcessesByCommand(file)));
3329
+ const logDir = folder
3330
+ ? dataDirFor(folder, sandboxId)
3331
+ : null;
3332
+ if (logDir && fs.existsSync(logDir)) {
3333
+ try {
3334
+ for (const name of fs.readdirSync(logDir)) {
3335
+ if (/^cf-.*\.log$/i.test(name) && !name.endsWith(".stale")) {
3336
+ files.add(path.join(logDir, name));
3337
+ }
3338
+ }
3339
+ } catch {
3340
+ /* ignore */
3341
+ }
3342
+ }
3343
+ return Promise.all([...files].map((file) => killProcessesByCommand(file)));
2909
3344
  }
2910
3345
 
2911
3346
  function forgetProcessLaunches(sandboxId) {
@@ -2918,12 +3353,18 @@ function forgetProcessLaunches(sandboxId) {
2918
3353
 
2919
3354
  async function stopWorkspaceApps(ws) {
2920
3355
  const folder = path.resolve(ws.folderPath || "");
2921
- const jobs = planHostJobs(folder, null, ws.projectInfo);
3356
+ const jobs = jobsFromHostApps(ws).length
3357
+ ? jobsFromHostApps(ws)
3358
+ : planHostJobs(folder, null, ws.projectInfo);
2922
3359
  const ports = new Set();
2923
3360
  if (ws.port) ports.add(Number(ws.port));
2924
3361
  for (const job of jobs) {
3362
+ if (job.port) ports.add(Number(job.port));
2925
3363
  if (job.preferredPort) ports.add(Number(job.preferredPort));
2926
3364
  }
3365
+ for (const app of Array.isArray(ws.hostApps) ? ws.hostApps : []) {
3366
+ if (app?.port) ports.add(Number(app.port));
3367
+ }
2927
3368
  if (ws.appUrl && isLocalAppUrl(ws.appUrl)) {
2928
3369
  try {
2929
3370
  const port = Number(new URL(ws.appUrl).port);
@@ -2933,12 +3374,13 @@ async function stopWorkspaceApps(ws) {
2933
3374
  }
2934
3375
  }
2935
3376
  log(`stopping local apps on ports ${[...ports].join(", ") || "(none)"}`);
3377
+ await stopEmbeddedChat(ws.sandboxId);
2936
3378
  await closeRememberedTerminals(ws.sandboxId);
2937
3379
  for (const port of ports) {
2938
3380
  await killPort(port);
2939
3381
  }
2940
3382
  forgetProcessLaunches(ws.sandboxId);
2941
- await sleep(400);
3383
+ await sleep(800);
2942
3384
  }
2943
3385
 
2944
3386
  async function waitUntilReachable(url, timeoutMs, label, onWait) {
@@ -2958,82 +3400,45 @@ async function waitUntilReachable(url, timeoutMs, label, onWait) {
2958
3400
  throw new Error(`${label} did not become reachable at ${url}`);
2959
3401
  }
2960
3402
 
2961
- async function waitForUrlInFile(file, timeoutMs = 90_000, onWait) {
2962
- const start = Date.now();
2963
- let last = 0;
2964
- while (Date.now() - start < timeoutMs) {
2965
- if (fs.existsSync(file)) {
2966
- const url = parseTryCloudflareUrl(fs.readFileSync(file, "utf8"));
2967
- if (url) return url;
2968
- }
2969
- const elapsed = Date.now() - start;
2970
- if (onWait && elapsed - last >= 8_000) {
2971
- last = elapsed;
2972
- await onWait(
2973
- `Still waiting for a Cloudflare URL (${Math.round(elapsed / 1000)}s)…`
2974
- );
2975
- }
2976
- await sleep(500);
2977
- }
2978
- throw new Error(
2979
- `Cloudflare did not publish a URL in time (${path.basename(file)}). Check that terminal.`
2980
- );
2981
- }
2982
-
2983
- function cloudflaredCommand(localUrl, logFile) {
2984
- const logArg = JSON.stringify(logFile);
2985
- const run = `npx --yes cloudflared tunnel --url ${localUrl} --no-autoupdate --logfile ${logArg}`;
2986
- if (process.platform === "win32") return run;
2987
- return `${run} 2>&1 | tee ${logArg}`;
2988
- }
2989
-
2990
- async function startCloudflareTerminal(ws, role, localUrl, onWait) {
2991
- const folder = path.resolve(ws.folderPath);
2992
- const logDir = path.join(folder, ".maintainer-pro");
2993
- fs.mkdirSync(logDir, { recursive: true });
2994
- const logFile = path.join(
2995
- logDir,
2996
- `cf-${String(ws.sandboxId).slice(0, 8)}-${role}.log`
2997
- );
2998
- try {
2999
- fs.unlinkSync(logFile);
3000
- } catch {
3001
- /* ignore */
3002
- }
3003
- const opened = await openInNewTerminal({
3004
- title: `MP-cf-${role}`,
3005
- folder,
3006
- command: cloudflaredCommand(localUrl, logFile),
3007
- launchKey: `${ws.sandboxId}:cf:${role}`,
3008
- sandboxId: ws.sandboxId,
3009
- force: true,
3010
- });
3011
- if (!opened.ok) {
3012
- throw new Error(opened.error || `Could not open a Cloudflare terminal for ${role}`);
3013
- }
3014
- const publicUrl = await waitForUrlInFile(logFile, 90_000, onWait);
3015
- return { role, localUrl, publicUrl, logFile };
3403
+ function applyBackendPublicEnv(env, url) {
3404
+ const backend = String(url || "").replace(/\/$/, "");
3405
+ if (!backend) return env;
3406
+ env.API_URL = backend;
3407
+ env.API_BASE_URL = backend;
3408
+ env.BACKEND_URL = backend;
3409
+ env.VITE_API_URL = backend;
3410
+ env.VITE_API_BASE_URL = backend;
3411
+ env.NEXT_PUBLIC_API_URL = backend;
3412
+ env.NEXT_PUBLIC_API_BASE_URL = backend;
3413
+ env.REACT_APP_API_URL = backend;
3414
+ return env;
3016
3415
  }
3017
3416
 
3018
- function uiPublicEnv(tunnels) {
3417
+ function uiPublicEnv(tunnels, ws) {
3019
3418
  /** @type {Record<string, string>} */
3020
3419
  const env = {};
3021
- if (tunnels.ai) {
3022
- const ai = String(tunnels.ai).replace(/\/$/, "");
3420
+ const aiApp = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).find(
3421
+ (app) => app.role === "ai-server" || app.id === "ai-server"
3422
+ ) || { id: "ai-server", role: "ai-server" };
3423
+ const ai =
3424
+ proxyUrlForApp(ws, aiApp) ||
3425
+ (tunnels.ai ? String(tunnels.ai).replace(/\/$/, "") : "");
3426
+ if (ai) {
3023
3427
  env.AI_SERVER_URL = ai;
3024
3428
  env.NEXT_PUBLIC_AI_SERVER_URL = ai;
3025
3429
  env.VITE_AI_SERVER_URL = ai;
3026
3430
  env.REACT_APP_AI_SERVER_URL = ai;
3027
3431
  }
3028
- if (tunnels.backend) {
3029
- const backend = String(tunnels.backend).replace(/\/$/, "");
3030
- env.API_URL = backend;
3031
- env.API_BASE_URL = backend;
3032
- env.VITE_API_URL = backend;
3033
- env.VITE_API_BASE_URL = backend;
3034
- env.NEXT_PUBLIC_API_URL = backend;
3035
- env.NEXT_PUBLIC_API_BASE_URL = backend;
3036
- env.BACKEND_URL = backend;
3432
+ const backendApp = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).find(
3433
+ (app) => isBackendApp(app)
3434
+ );
3435
+ const backendUrl =
3436
+ (backendApp ? proxyUrlForApp(ws, backendApp) : "") ||
3437
+ (typeof tunnels.backend === "string" && tunnels.backend) ||
3438
+ (backendApp ? publicUrlForApp(ws, backendApp) : "") ||
3439
+ "";
3440
+ if (backendUrl && backendApp) {
3441
+ applyBackendPublicEnv(env, backendUrl);
3037
3442
  }
3038
3443
  if (tunnels.ui) {
3039
3444
  const ui = String(tunnels.ui).replace(/\/$/, "");
@@ -3043,156 +3448,181 @@ function uiPublicEnv(tunnels) {
3043
3448
  env.NEXT_PUBLIC_APP_URL = ui;
3044
3449
  env.VITE_APP_URL = ui;
3045
3450
  env.REACT_APP_APP_URL = ui;
3046
- } else if (tunnels.ai) {
3047
- const ai = String(tunnels.ai).replace(/\/$/, "");
3048
- env.CORS_ORIGIN = ai;
3049
- env.APP_URL = ai;
3050
- env.PUBLIC_URL = ai;
3051
- env.NEXT_PUBLIC_APP_URL = ai;
3052
- env.VITE_APP_URL = ai;
3451
+ } else if (tunnels.app) {
3452
+ const app = String(tunnels.app).replace(/\/$/, "");
3453
+ env.APP_URL = app;
3454
+ env.PUBLIC_URL = app;
3455
+ env.CORS_ORIGIN = app;
3456
+ env.NEXT_PUBLIC_APP_URL = app;
3457
+ env.VITE_APP_URL = app;
3458
+ env.REACT_APP_APP_URL = app;
3459
+ } else if (ai) {
3460
+ const hostUi = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).some(
3461
+ (app) => app.host || app.role === "ui" || app.role === "app"
3462
+ );
3463
+ if (!hostUi) {
3464
+ env.CORS_ORIGIN = ai;
3465
+ env.APP_URL = ai;
3466
+ env.PUBLIC_URL = ai;
3467
+ env.NEXT_PUBLIC_APP_URL = ai;
3468
+ env.VITE_APP_URL = ai;
3469
+ }
3053
3470
  }
3054
3471
  return env;
3055
3472
  }
3056
3473
 
3057
- function writeTunnelEnv(ws, tunnels) {
3058
- const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
3059
- if (!folder || !fs.existsSync(folder)) return { ok: false, env: {} };
3060
- const env = uiPublicEnv(tunnels);
3061
- const lines = Object.entries(tunnels)
3062
- .filter(([, url]) => url)
3063
- .map(([role, url]) => `${role}=${String(url).replace(/\/$/, "")}`);
3064
- fs.writeFileSync(
3065
- path.join(folder, ".cloudflare-tunnel-url"),
3066
- `${lines.join("\n")}\n`,
3067
- "utf8"
3068
- );
3069
- if (Object.keys(env).length) writeProjectEnv(folder, env);
3070
- return { ok: true, env };
3474
+ function publicUrlForApp(ws, app) {
3475
+ if (!app) return "";
3476
+ const proxied = proxyUrlForApp(ws, app);
3477
+ if (proxied) return proxied;
3478
+ const port = Number(app.port) || 0;
3479
+ return port ? `http://localhost:${port}` : "";
3071
3480
  }
3072
3481
 
3073
- /**
3074
- * Validate tunnels + frontend env before sharing URLs with Maintainer Pro.
3075
- * @returns {Promise<{ ok: boolean, checks: Array<{ id: string, ok: boolean, detail: string }> }>}
3076
- */
3077
- async function validateCloudflareGoLive(ws, tunnels, opts = {}) {
3078
- const folder = path.resolve(ws.folderPath || "");
3482
+ function envFromConfiguredMaps(ws) {
3483
+ /** @type {Record<string, string>} */
3484
+ const env = {};
3485
+ const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
3486
+ for (const app of apps) {
3487
+ const maps = Array.isArray(app.envMaps) ? app.envMaps : [];
3488
+ for (const row of maps) {
3489
+ const key = String(row?.key || "").trim();
3490
+ const sourceId = String(row?.sourceAppId || "").trim();
3491
+ if (!key || !sourceId) continue;
3492
+ const source =
3493
+ sourceId === "self"
3494
+ ? app
3495
+ : apps.find((item) => item.id === sourceId) || null;
3496
+ const url = publicUrlForApp(ws, source);
3497
+ if (url) env[key] = url;
3498
+ }
3499
+ }
3500
+ return env;
3501
+ }
3502
+
3503
+ function cloudflarePublicEnv(ws) {
3504
+ const tunnels =
3505
+ ws.cloudflare && typeof ws.cloudflare === "object" ? ws.cloudflare : {};
3506
+ const jobs = jobsFromHostApps(ws);
3507
+ const env = {
3508
+ ...envForWorkspacePorts(ws, jobs),
3509
+ ...uiPublicEnv(tunnels, ws),
3510
+ };
3511
+ const origins = [];
3512
+ const add = (value) => {
3513
+ const origin = originFromUrl(value);
3514
+ if (origin && !origins.includes(origin)) origins.push(origin);
3515
+ };
3516
+ add(tunnels.ui);
3517
+ add(tunnels.app);
3518
+ add(tunnels.backend);
3519
+ add(tunnels.ai);
3520
+ add(env.APP_URL);
3521
+ add(env.CORS_ORIGIN);
3522
+ for (const job of jobs) {
3523
+ if (job.port) add(`http://localhost:${job.port}`);
3524
+ }
3525
+ add(`http://localhost:${Number(ws.port) || 3100}`);
3526
+ if (origins.length) {
3527
+ env.CORS_ORIGIN = origins.join(",");
3528
+ env.CORS_ORIGINS = origins.join(",");
3529
+ }
3530
+ Object.assign(env, envFromConfiguredMaps(ws));
3531
+ return env;
3532
+ }
3533
+
3534
+ async function applyPublicUrlsToRunningApps(ws, cfg, opts = {}) {
3535
+ const folder = ws.folderPath ? path.resolve(ws.folderPath) : "";
3079
3536
  const progress =
3080
3537
  typeof opts.onProgress === "function" ? opts.onProgress : async () => {};
3081
- /** @type {Array<{ id: string, ok: boolean, detail: string }>} */
3082
- const checks = [];
3083
- const add = (id, ok, detail) => {
3084
- checks.push({ id, ok, detail });
3085
- log(`go-live ${ok ? "ok" : "FAIL"} ${id}: ${detail}`);
3538
+ const tunnels =
3539
+ ws.cloudflare && typeof ws.cloudflare === "object" ? ws.cloudflare : {};
3540
+ const hasProxy = Boolean(ws.proxy?.token);
3541
+ if (
3542
+ !folder ||
3543
+ !fs.existsSync(folder) ||
3544
+ (!Object.keys(tunnels).length && !hasProxy)
3545
+ ) {
3546
+ return { restarted: [], rewritten: [] };
3547
+ }
3548
+ const env = {
3549
+ ...cloudflarePublicEnv(ws),
3086
3550
  };
3087
-
3088
- await progress(
3089
- "Validating Cloudflare setup before sharing with Maintainer Pro…"
3090
- );
3091
-
3092
- writeTunnelEnv(ws, tunnels);
3093
- const env = readProjectEnvValues(folder);
3094
-
3095
- if (!tunnels.ai) {
3096
- add("ai_tunnel", false, "Missing chat-script Cloudflare URL");
3097
- } else {
3098
- const ai = String(tunnels.ai).replace(/\/$/, "");
3099
- const embed = `${ai}/embed-config.js`;
3100
- const reachable = await probeUrl(embed, 10_000);
3101
- add(
3102
- "ai_tunnel",
3103
- reachable,
3104
- reachable ? `Reachable ${embed}` : `Unreachable ${embed}`
3551
+ if (Object.keys(tunnels).length) writeTunnelEnv(ws, tunnels);
3552
+ const mappedKeys = Object.keys(envFromConfiguredMaps(ws));
3553
+ if (mappedKeys.length) {
3554
+ activity(
3555
+ ws.sandboxId,
3556
+ "info",
3557
+ `Injecting public URLs into process env for ${mappedKeys.join(", ")}`
3105
3558
  );
3106
- if (reachable) {
3107
- const body = await fetchText(embed, 10_000);
3108
- const advertises =
3109
- Boolean(body) &&
3110
- (body.includes(ai) ||
3111
- body.includes(ai.replace(/^https:\/\//, "")));
3112
- add(
3113
- "embed_config",
3114
- advertises,
3115
- advertises
3116
- ? "embed-config.js advertises the public AI URL"
3117
- : `embed-config.js does not advertise ${ai}`
3118
- );
3119
- }
3559
+ await progress(`Passing ${mappedKeys.join(", ")} via process env`);
3120
3560
  }
3121
3561
 
3122
- if (tunnels.ui) {
3123
- const ui = String(tunnels.ui).replace(/\/$/, "");
3124
- const reachable = await probeUrl(ui, 10_000);
3125
- add(
3126
- "ui_tunnel",
3127
- reachable,
3128
- reachable ? `Reachable ${ui}` : `Unreachable ${ui}`
3129
- );
3130
- } else {
3131
- add(
3132
- "ui_tunnel",
3133
- true,
3134
- "No separate UI tunnel (AI-only / same-origin share)"
3135
- );
3562
+ const probe = await probeRunningApps(ws, 1200);
3563
+ const runningRoles = new Set(
3564
+ (probe.hostApps || [])
3565
+ .filter((app) => app.running)
3566
+ .map((app) =>
3567
+ app.role === "ai-server" ? "ai-server" : app.role === "custom" ? "app" : app.role
3568
+ )
3569
+ );
3570
+ const reserved = reservedPortsFor(cfg, ws.sandboxId);
3571
+ const restarted = [];
3572
+
3573
+ if (probe.chatUp || runningRoles.has("ai-server") || embeddedChat.has(ws.sandboxId)) {
3574
+ await progress("Restarting in-process chat with public URLs and CORS…");
3575
+ launchedAt.delete(`${ws.sandboxId}:${folder}:ai`);
3576
+ await stopEmbeddedChat(ws.sandboxId);
3577
+ await startAiServerForWorkspace(ws, {
3578
+ reserved,
3579
+ cfg,
3580
+ port: ws.port,
3581
+ env,
3582
+ });
3583
+ restarted.push("ai-server");
3136
3584
  }
3137
3585
 
3138
- const ai = String(tunnels.ai || "").replace(/\/$/, "");
3139
- if (ai) {
3140
- const envAi = [
3141
- env.NEXT_PUBLIC_AI_SERVER_URL,
3142
- env.VITE_AI_SERVER_URL,
3143
- env.AI_SERVER_URL,
3144
- env.REACT_APP_AI_SERVER_URL,
3145
- ]
3146
- .map((value) => String(value || "").replace(/\/$/, ""))
3147
- .filter(Boolean);
3148
- const envOk = envAi.some((value) => value === ai);
3149
- add(
3150
- "frontend_env",
3151
- envOk,
3152
- envOk
3153
- ? `Frontend env has AI URL ${ai}`
3154
- : `Frontend env missing AI URL (NEXT_PUBLIC=${
3155
- env.NEXT_PUBLIC_AI_SERVER_URL || "(empty)"
3156
- }, VITE=${env.VITE_AI_SERVER_URL || "(empty)"}, AI_SERVER_URL=${
3157
- env.AI_SERVER_URL || "(empty)"
3158
- })`
3586
+ const jobs = jobsFromHostApps(ws).filter((job) =>
3587
+ runningRoles.has(job.role === "custom" ? "app" : job.role)
3588
+ );
3589
+ if (jobs.length) {
3590
+ const roles = [...new Set(jobs.map((job) => job.role))];
3591
+ await progress(
3592
+ `Restarting ${roles.join(", ")} so they load the public URLs…`
3159
3593
  );
3594
+ for (const job of jobs) {
3595
+ launchedAt.delete(`${ws.sandboxId}:${folder}:${job.script}`);
3596
+ await killPort(job.port);
3597
+ }
3598
+ await sleep(1500);
3599
+ await ensureHostProcesses(ws, {
3600
+ reserved,
3601
+ cfg,
3602
+ onlyRoles: roles,
3603
+ extraEnv: env,
3604
+ force: true,
3605
+ plannedJobs: jobsFromHostApps(ws),
3606
+ });
3607
+ restarted.push(...roles);
3160
3608
  }
3161
3609
 
3162
- if (tunnels.ui) {
3163
- const ui = String(tunnels.ui).replace(/\/$/, "");
3164
- const envApp = [
3165
- env.APP_URL,
3166
- env.NEXT_PUBLIC_APP_URL,
3167
- env.PUBLIC_URL,
3168
- env.VITE_APP_URL,
3169
- env.CORS_ORIGIN,
3170
- ]
3171
- .map((value) => String(value || "").replace(/\/$/, ""))
3172
- .filter(Boolean);
3173
- const appOk = envApp.some((value) => value === ui);
3174
- add(
3175
- "app_url_env",
3176
- appOk,
3177
- appOk
3178
- ? `App URL env has ${ui}`
3179
- : `App URL env missing ${ui} (APP_URL=${env.APP_URL || "(empty)"}, NEXT_PUBLIC_APP_URL=${
3180
- env.NEXT_PUBLIC_APP_URL || "(empty)"
3181
- })`
3610
+ if (restarted.length) {
3611
+ activity(
3612
+ ws.sandboxId,
3613
+ "info",
3614
+ `Restarted ${[...new Set(restarted)].join(", ")} with public URLs`
3182
3615
  );
3183
3616
  }
3617
+ return { restarted: [...new Set(restarted)], rewritten: [], env };
3618
+ }
3184
3619
 
3185
- const ok = checks.every((check) => check.ok);
3186
- if (ok) {
3187
- await progress("Validation passed sharing public URLs with Maintainer Pro.");
3188
- } else {
3189
- const failed = checks
3190
- .filter((check) => !check.ok)
3191
- .map((check) => `${check.id}: ${check.detail}`)
3192
- .join(" | ");
3193
- await progress(`Validation failed — not sharing yet. ${failed}`);
3194
- }
3195
- return { ok, checks };
3620
+ function writeTunnelEnv(ws, tunnels) {
3621
+ const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
3622
+ if (!folder || !fs.existsSync(folder)) return { ok: false, env: {} };
3623
+ clearCloudflareTunnelFile(folder);
3624
+ const env = uiPublicEnv(tunnels, ws);
3625
+ return { ok: true, env };
3196
3626
  }
3197
3627
 
3198
3628
  function reservedPortsFor(cfg, sandboxId) {
@@ -3211,271 +3641,35 @@ function appsWanted(ws) {
3211
3641
 
3212
3642
  async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
3213
3643
  const sandboxId = ws.sandboxId;
3214
- const label = ws.sandboxName || "this sandbox";
3215
- const progress = (message) =>
3216
- reportActionProgress(cfg, opts.actionId, message);
3217
-
3218
- // If tunnels are already up, attach instead of tearing them down.
3219
- const attached = await tryAttachExistingCloudflare(ws, cfg, {
3220
- onProgress: progress,
3221
- });
3222
- if (attached) {
3223
- return {
3224
- ...attached,
3225
- pending: false,
3226
- cloudflarePending: false,
3227
- waitingForStart: false,
3228
- warning: null,
3229
- };
3230
- }
3231
-
3232
- await progress(
3233
- `Preparing Cloudflare for ${label}. Stopping local apps first — this can take a minute.`
3234
- );
3235
- await stopCloudflare(sandboxId);
3236
- await progress("Stopping local app terminals and freeing their ports…");
3237
- await stopWorkspaceApps(ws);
3238
- await forgetLaunch(sandboxId);
3239
- ws.cloudflarePending = true;
3240
- ws.appsRequested = false;
3241
- persistWorkspaceEntry(cfg, ws);
3242
- await progress(
3243
- "Cloudflare is queued. Use Start Apps to create the public URLs."
3244
- );
3245
-
3246
- return {
3247
- sandboxId,
3248
- folderPath: ws.folderPath,
3249
- port: ws.port,
3250
- pending: true,
3251
- cloudflarePending: true,
3252
- waitingForStart: true,
3253
- warning:
3254
- "Cloudflare is ready in Maintainer Pro. Use Start Apps when you want to launch the apps and create the public URLs.",
3255
- };
3256
- }
3257
-
3258
- async function launchCloudflareTunnels(ws, cfg, opts = {}) {
3259
- const sandboxId = ws.sandboxId;
3260
- const label = ws.sandboxName || "this sandbox";
3261
- const folder = path.resolve(ws.folderPath || "");
3262
- const reserved = reservedPortsFor(cfg, sandboxId);
3263
3644
  const progress = (message) =>
3264
3645
  reportActionProgress(cfg, opts.actionId, message);
3265
-
3266
- const attached = await tryAttachExistingCloudflare(ws, cfg, {
3267
- onProgress: progress,
3268
- });
3269
- if (attached) return attached;
3270
-
3271
- await progress(
3272
- `Creating Cloudflare tunnels for ${label}. This usually takes 1–2 minutes.`
3273
- );
3646
+ await progress("Using Maintainer Pro share URLs (no Cloudflare).");
3274
3647
  try {
3275
- const plan = await prepareWorkspaceLaunch(ws, cfg, reserved);
3276
- if (!cfg.noAiServer) {
3277
- await progress(`Starting the chat script on port ${plan.aiPort}…`);
3278
- await startAiServerForWorkspace(ws, {
3279
- reserved,
3280
- cfg,
3281
- port: plan.aiPort,
3282
- });
3283
- await waitUntilReachable(
3284
- `http://127.0.0.1:${ws.port}/embed-config.js`,
3285
- 45_000,
3286
- "the chat script",
3287
- progress
3288
- );
3289
- }
3290
-
3291
- const backendJob = plan.jobs.find((job) => job.role === "backend");
3292
- const uiJob = plan.jobs.find((job) => job.role === "ui" || job.role === "app");
3293
-
3294
- if (backendJob) {
3295
- await progress(`Starting the backend on port ${backendJob.port}…`);
3296
- await ensureHostProcesses(ws, {
3297
- reserved,
3298
- cfg,
3299
- onlyRoles: ["backend"],
3300
- force: true,
3301
- plannedJobs: plan.jobs,
3302
- });
3303
- const backendPort = Number(backendJob.port) || 4100;
3304
- await waitUntilReachable(
3305
- `http://127.0.0.1:${backendPort}`,
3306
- 45_000,
3307
- "the backend",
3308
- progress
3309
- );
3310
- }
3311
-
3312
- /** @type {Record<string, string>} */
3313
- const tunnels = {};
3314
- /** @type {Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }>} */
3315
- const started = [];
3316
-
3317
- const aiLocal = `http://127.0.0.1:${Number(ws.port) || 3100}`;
3318
- await progress(`Opening a Cloudflare tunnel for the chat script (${aiLocal})…`);
3319
- const aiTunnel = await startCloudflareTerminal(ws, "ai", aiLocal, progress);
3320
- tunnels.ai = aiTunnel.publicUrl;
3321
- started.push(aiTunnel);
3322
- await progress(`Chat script public URL: ${aiTunnel.publicUrl}`);
3323
-
3324
- if (backendJob) {
3325
- const backendLocal = `http://127.0.0.1:${Number(backendJob.port) || 4100}`;
3326
- await progress(`Opening a Cloudflare tunnel for the backend (${backendLocal})…`);
3327
- const backendTunnel = await startCloudflareTerminal(
3328
- ws,
3329
- "backend",
3330
- backendLocal,
3331
- progress
3332
- );
3333
- tunnels.backend = backendTunnel.publicUrl;
3334
- started.push(backendTunnel);
3335
- await progress(`Backend public URL: ${backendTunnel.publicUrl}`);
3336
- }
3337
-
3338
- writeTunnelEnv(ws, tunnels);
3339
- let uiEnv = uiPublicEnv(tunnels);
3340
- const uiPort = uiJob ? Number(uiJob.port) || 5173 : null;
3341
-
3342
- if (uiJob) {
3343
- await progress(`Starting the app UI on port ${uiPort}…`);
3344
- await ensureHostProcesses(ws, {
3345
- reserved,
3346
- cfg,
3347
- onlyRoles: ["ui", "app"],
3348
- extraEnv: uiEnv,
3349
- force: true,
3350
- plannedJobs: plan.jobs,
3351
- });
3352
- await waitUntilReachable(
3353
- `http://127.0.0.1:${uiPort}`,
3354
- 60_000,
3355
- "the app UI",
3356
- progress
3357
- );
3358
- await progress(
3359
- `Opening a Cloudflare tunnel for the app UI (http://127.0.0.1:${uiPort})…`
3360
- );
3361
- const uiTunnel = await startCloudflareTerminal(
3362
- ws,
3363
- "ui",
3364
- `http://127.0.0.1:${uiPort}`,
3365
- progress
3366
- );
3367
- tunnels.ui = uiTunnel.publicUrl;
3368
- started.push(uiTunnel);
3369
- await progress(`App UI public URL: ${uiTunnel.publicUrl}`);
3370
- writeTunnelEnv(ws, tunnels);
3371
- uiEnv = uiPublicEnv(tunnels);
3372
-
3373
- // Restart UI so Vite/Next pick up APP_URL + public AI URL from env files.
3374
- await progress(
3375
- "Restarting the app UI so it loads the public base URL from env…"
3376
- );
3377
- for (const job of plan.jobs) {
3378
- if (job.role === "ui" || job.role === "app") {
3379
- launchedAt.delete(`${sandboxId}:${folder}:${job.script}`);
3380
- }
3381
- }
3382
- await killPort(uiPort);
3383
- await sleep(1500);
3384
- await ensureHostProcesses(ws, {
3385
- reserved,
3386
- cfg,
3387
- onlyRoles: ["ui", "app"],
3388
- extraEnv: uiEnv,
3389
- force: true,
3390
- plannedJobs: plan.jobs,
3391
- });
3392
- await waitUntilReachable(
3393
- `http://127.0.0.1:${uiPort}`,
3394
- 60_000,
3395
- "the app UI",
3396
- progress
3397
- );
3398
- }
3399
-
3400
- // Restart chat script with the public AI URL (and CORS/app origins).
3401
- if (tunnels.ai) {
3402
- await progress("Restarting the chat script with the public AI URL…");
3403
- launchedAt.delete(`${sandboxId}:${folder}:ai`);
3404
- await killPort(ws.port);
3405
- await sleep(1500);
3406
- await startAiServerForWorkspace(ws, {
3407
- reserved,
3408
- cfg,
3409
- port: ws.port,
3410
- env: uiPublicEnv(tunnels),
3411
- });
3412
- writeTunnelEnv(ws, tunnels);
3413
- await waitUntilReachable(
3414
- `http://127.0.0.1:${ws.port}/embed-config.js`,
3415
- 45_000,
3416
- "the chat script",
3417
- progress
3418
- );
3419
- }
3420
-
3421
- const validation = await validateCloudflareGoLive(ws, tunnels, {
3422
- onProgress: progress,
3423
- });
3424
- if (!validation.ok) {
3425
- const failed = validation.checks
3426
- .filter((check) => !check.ok)
3427
- .map((check) => `${check.id}: ${check.detail}`)
3428
- .join("\n");
3429
- ws.cloudflarePending = true;
3430
- ws.cloudflare = tunnels;
3431
- ws.cloudflareUrl = null;
3432
- persistWorkspaceEntry(cfg, ws);
3433
- rememberCloudflareTunnels(sandboxId, tunnels);
3434
- throw new Error(
3435
- `Cloudflare validation failed — public URLs were not shared with Maintainer Pro yet.\n${failed}`
3436
- );
3648
+ await stopCloudflare(sandboxId, ws.folderPath);
3649
+ } catch {
3650
+ /* leftover tunnels */
3437
3651
  }
3438
-
3439
- const appUrl = tunnels.ui || tunnels.ai;
3440
- ws.cloudflareUrl = appUrl;
3441
- ws.cloudflare = tunnels;
3442
- ws.appUrl = appUrl;
3443
3652
  ws.cloudflarePending = false;
3444
- ws.appsRequested = true;
3445
3653
  persistWorkspaceEntry(cfg, ws);
3446
- rememberCloudflareTunnels(sandboxId, tunnels);
3447
- cloudflareTunnels.set(sandboxId, { tunnels: started });
3448
- clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
3449
- const host = workspaceHostReport(ws);
3450
- await progress(`Cloudflare is ready: ${host.appUrl || appUrl}`);
3451
-
3452
- return {
3453
- sandboxId,
3454
- folderPath: ws.folderPath,
3455
- port: ws.port,
3456
- appUrl: host.appUrl || appUrl,
3457
- origins: host.origins.length
3458
- ? host.origins
3459
- : Object.values(tunnels).filter(Boolean),
3460
- tunnels,
3461
- validation,
3462
- cloudflare: true,
3463
- reused: false,
3464
- };
3465
- } catch (err) {
3466
- const message = err instanceof Error ? err.message : String(err);
3467
- recordProcessProblem({
3468
- sandboxId,
3469
- code: "cloudflare_launch",
3470
- role: "tunnel",
3471
- title: `Could not start Cloudflare (${label})`,
3472
- message,
3473
- resolution:
3474
- "Install cloudflared or allow npx to download it, then use Start Apps again.",
3475
- actionCode: "start_ai_server",
3476
- });
3477
- throw err;
3478
- }
3654
+ const status = await reconcileWorkspacePresence(ws, cfg, {
3655
+ timeoutMs: 2500,
3656
+ });
3657
+ const share = proxyUrlForApp(
3658
+ ws,
3659
+ hostAppOf(ws) || { id: "ui", role: "ui", host: true }
3660
+ );
3661
+ return {
3662
+ sandboxId,
3663
+ folderPath: ws.folderPath,
3664
+ port: ws.port,
3665
+ pending: false,
3666
+ cloudflarePending: false,
3667
+ waitingForStart: false,
3668
+ appUrl: status.host.appUrl || share,
3669
+ origins: status.host.origins,
3670
+ publicUrl: share || null,
3671
+ cloudflare: false,
3672
+ };
3479
3673
  }
3480
3674
 
3481
3675
  async function startAppsForWorkspace(ws, cfg, opts = {}) {
@@ -3483,12 +3677,17 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3483
3677
  await reportActionProgress(
3484
3678
  cfg,
3485
3679
  opts.actionId,
3486
- `Checking running apps and Cloudflare for ${label}…`
3680
+ `Checking running apps for ${label}…`
3487
3681
  );
3488
3682
 
3489
- // Status + env sync first (never starts Cloudflare here).
3683
+ try {
3684
+ await stopCloudflare(ws.sandboxId, ws.folderPath);
3685
+ } catch {
3686
+ /* leftover cloudflared */
3687
+ }
3688
+ ws.cloudflarePending = false;
3689
+
3490
3690
  let status = await reconcileWorkspacePresence(ws, cfg, {
3491
- writeEnv: true,
3492
3691
  timeoutMs: 2500,
3493
3692
  });
3494
3693
  log(
@@ -3498,15 +3697,9 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3498
3697
  status.probe.hosts
3499
3698
  .map((h) => `${h.role}:${h.up ? "up" : "down"}`)
3500
3699
  .join(",") || "none"
3501
- } cloudflare=${status.usingCloudflare ? "yes" : "no"}`
3700
+ }`
3502
3701
  );
3503
3702
 
3504
- // Cloudflare tunnels only when Maintainer Pro left a pending Share signal.
3505
- if (ws.cloudflarePending) {
3506
- log(`start apps ${label}: Cloudflare pending, launching tunnels`);
3507
- return launchCloudflareTunnels(ws, cfg, opts);
3508
- }
3509
-
3510
3703
  await reportActionProgress(
3511
3704
  cfg,
3512
3705
  opts.actionId,
@@ -3516,16 +3709,15 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3516
3709
  const reserved = reservedPortsFor(cfg, ws.sandboxId);
3517
3710
  const plan = await prepareWorkspaceLaunch(ws, cfg, reserved);
3518
3711
 
3519
- // Re-sync env after port assignment (local or cloudflare urls).
3520
3712
  status = await reconcileWorkspacePresence(ws, cfg, {
3521
- writeEnv: true,
3522
3713
  timeoutMs: 800,
3523
3714
  });
3524
3715
 
3525
- const cfEnv =
3526
- status.usingCloudflare && status.cloudflare
3527
- ? uiPublicEnv(status.cloudflare)
3528
- : {};
3716
+ const shareEnv = {
3717
+ ...envForWorkspacePorts(ws, plan.jobs),
3718
+ ...uiPublicEnv({}, ws),
3719
+ ...envFromConfiguredMaps(ws),
3720
+ };
3529
3721
 
3530
3722
  if (!cfg.noAiServer) {
3531
3723
  if (status.probe.chatUp) {
@@ -3538,7 +3730,7 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3538
3730
  reserved,
3539
3731
  cfg,
3540
3732
  port: plan.aiPort,
3541
- env: cfEnv,
3733
+ env: shareEnv,
3542
3734
  });
3543
3735
  await sleep(1500);
3544
3736
  }
@@ -3553,12 +3745,15 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3553
3745
  const probed = status.probe.hosts.find((h) => h.role === job.role);
3554
3746
  return probed?.up ? { ...job, up: true, port: probed.port } : job;
3555
3747
  }),
3556
- extraEnv: cfEnv,
3748
+ extraEnv: shareEnv,
3557
3749
  });
3558
3750
  await sleep(800);
3559
3751
 
3752
+ await applyPublicUrlsToRunningApps(ws, cfg, {
3753
+ onProgress: (message) => reportActionProgress(cfg, opts.actionId, message),
3754
+ });
3755
+
3560
3756
  status = await reconcileWorkspacePresence(ws, cfg, {
3561
- writeEnv: true,
3562
3757
  timeoutMs: 2500,
3563
3758
  });
3564
3759
  await inspectHostJobs(ws);
@@ -3570,11 +3765,11 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3570
3765
  log(
3571
3766
  `start apps done ${label}: chat=${ws.port} chatUp=${
3572
3767
  status.aiServerUp
3573
- } hosts=${startedHosts.join(",") || "none"} cloudflare=${
3574
- status.usingCloudflare ? "yes" : "no"
3575
- }${status.host.appUrl ? ` app=${status.host.appUrl}` : ""} origins=${
3576
- status.host.origins.join(",") || "none"
3577
- }${warning ? ` warning=${warning}` : ""}`
3768
+ } hosts=${startedHosts.join(",") || "none"}${
3769
+ status.host.appUrl ? ` app=${status.host.appUrl}` : ""
3770
+ } origins=${status.host.origins.join(",") || "none"}${
3771
+ warning ? ` warning=${warning}` : ""
3772
+ }`
3578
3773
  );
3579
3774
  return {
3580
3775
  up: status.aiServerUp,
@@ -3584,12 +3779,196 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3584
3779
  port: ws.port,
3585
3780
  appUrl: status.host.appUrl || ws.appUrl,
3586
3781
  origins: status.host.origins,
3587
- cloudflare: status.usingCloudflare,
3782
+ publicUrl:
3783
+ proxyUrlForApp(
3784
+ ws,
3785
+ hostAppOf(ws) || { id: "ui", role: "ui", host: true }
3786
+ ) || null,
3787
+ cloudflare: false,
3788
+ hostApps: status.probe.hostApps || ws.hostApps || [],
3588
3789
  processIssues,
3589
3790
  warning,
3590
3791
  };
3591
3792
  }
3592
3793
 
3794
+ function findHostApp(ws, payload = {}) {
3795
+ const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
3796
+ const id = String(payload.appId || payload.id || "").trim();
3797
+ const role = String(payload.role || "").trim();
3798
+ const port = Number(payload.port) || 0;
3799
+ return (
3800
+ apps.find((app) => id && app.id === id) ||
3801
+ apps.find((app) => role && app.role === role) ||
3802
+ apps.find((app) => port && Number(app.port) === port) ||
3803
+ null
3804
+ );
3805
+ }
3806
+
3807
+ async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
3808
+ const app = findHostApp(ws, payload);
3809
+ if (!app) {
3810
+ activity(ws.sandboxId, "error", "Start app failed: unknown app/port");
3811
+ return { error: "Unknown app. Detect or add the port first." };
3812
+ }
3813
+ const progress = (message) => reportActionProgress(cfg, opts.actionId, message);
3814
+ const alreadyUp = await portIsLive(app.port, 1200);
3815
+ if (alreadyUp) {
3816
+ activity(ws.sandboxId, "info", `${app.name} already running on ${app.port}`);
3817
+ await progress(`${app.name} is already running on port ${app.port}.`);
3818
+ const status = await reconcileWorkspacePresence(ws, cfg, {
3819
+ timeoutMs: 1200,
3820
+ });
3821
+ return {
3822
+ app,
3823
+ running: true,
3824
+ port: app.port,
3825
+ hostApps: status.probe.hostApps || [],
3826
+ log: activityLogFor(ws.sandboxId),
3827
+ };
3828
+ }
3829
+ activity(ws.sandboxId, "info", `Start ${app.name} on port ${app.port}`);
3830
+ await progress(`Starting ${app.name} on port ${app.port}…`);
3831
+ ws.appsRequested = true;
3832
+ persistWorkspaceEntry(cfg, ws);
3833
+ const publicEnv = {
3834
+ ...cloudflarePublicEnv(ws),
3835
+ };
3836
+ if (app.role === "ai-server") {
3837
+ const reserved = reservedPortsFor(cfg, ws.sandboxId);
3838
+ const chat = await discoverChatPort(ws, 1200);
3839
+ if (chat.up) {
3840
+ activity(ws.sandboxId, "info", `AI server already running on ${chat.port}`);
3841
+ ws.port = chat.port;
3842
+ } else {
3843
+ await startAiServerForWorkspace(ws, {
3844
+ reserved,
3845
+ cfg,
3846
+ port: Number(app.port) || ws.port || 3100,
3847
+ env: publicEnv,
3848
+ });
3849
+ }
3850
+ } else {
3851
+ const reserved = reservedPortsFor(cfg, ws.sandboxId);
3852
+ await ensureHostProcesses(ws, {
3853
+ reserved,
3854
+ cfg,
3855
+ onlyRoles: [app.role === "custom" ? "app" : app.role],
3856
+ extraEnv: publicEnv,
3857
+ force: true,
3858
+ plannedJobs: jobsFromHostApps(ws),
3859
+ });
3860
+ }
3861
+ const status = await reconcileWorkspacePresence(ws, cfg, {
3862
+ timeoutMs: 2500,
3863
+ });
3864
+ const live = (status.probe.hostApps || []).find((row) => row.id === app.id);
3865
+ activity(
3866
+ ws.sandboxId,
3867
+ live?.running ? "info" : "warn",
3868
+ live?.running
3869
+ ? `${app.name} is running on ${live.port}`
3870
+ : `${app.name} did not answer on port ${app.port}. Check the MP terminal.`
3871
+ );
3872
+ return {
3873
+ app,
3874
+ running: Boolean(live?.running),
3875
+ port: live?.port || app.port,
3876
+ hostApps: status.probe.hostApps || [],
3877
+ log: activityLogFor(ws.sandboxId),
3878
+ warning: live?.running
3879
+ ? null
3880
+ : `${app.name} did not answer on port ${app.port}. Check the terminal on that computer.`,
3881
+ };
3882
+ }
3883
+
3884
+ async function stopSingleApp(ws, app) {
3885
+ const port = Number(app.port) || 0;
3886
+ const role =
3887
+ app.role === "ai-server"
3888
+ ? "ai-server"
3889
+ : app.role === "custom"
3890
+ ? "app"
3891
+ : app.role || "app";
3892
+ const folder = path.resolve(ws.folderPath || "");
3893
+ await closeWindowsByTitle(`MP-${role}-${port}`);
3894
+ if (role === "app") await closeWindowsByTitle(`MP-custom-${port}`);
3895
+ if (app.role === "ai-server" || app.id === "ai-server") {
3896
+ await stopEmbeddedChat(ws.sandboxId);
3897
+ }
3898
+ if (port) {
3899
+ await killPort(port);
3900
+ const until = Date.now() + 4000;
3901
+ while (Date.now() < until && (await portIsLive(port, 300))) {
3902
+ await killPort(port);
3903
+ await sleep(300);
3904
+ }
3905
+ }
3906
+ const job = jobsFromHostApps(ws).find((row) => row.appId === app.id);
3907
+ if (job) launchedAt.delete(`${ws.sandboxId}:${folder}:${job.script}`);
3908
+ launchedAt.delete(`${ws.sandboxId}:${folder}:ai`);
3909
+ await sleep(800);
3910
+ }
3911
+
3912
+ async function restartSingleApp(ws, cfg, payload = {}, opts = {}) {
3913
+ const app = findHostApp(ws, payload);
3914
+ if (!app) {
3915
+ activity(ws.sandboxId, "error", "Restart app failed: unknown app/port");
3916
+ return { error: "Unknown app. Detect or add the port first." };
3917
+ }
3918
+ const progress = (message) =>
3919
+ reportActionProgress(cfg, opts.actionId, message);
3920
+ activity(ws.sandboxId, "info", `Restart ${app.name} on ${app.port}`);
3921
+ await progress(`Stopping ${app.name} on port ${app.port}…`);
3922
+ await stopSingleApp(ws, app);
3923
+ return startSingleApp(ws, cfg, payload, opts);
3924
+ }
3925
+
3926
+ async function startCloudflareForApp(ws, cfg, payload = {}, opts = {}) {
3927
+ const app = findHostApp(ws, payload);
3928
+ if (!app) {
3929
+ activity(ws.sandboxId, "error", "Share URL failed: unknown app/port");
3930
+ return { error: "Unknown app. Detect or add the port first." };
3931
+ }
3932
+ const progress = (message) => reportActionProgress(cfg, opts.actionId, message);
3933
+ if (usesBridgeProxy(app)) {
3934
+ const shareUrl = proxyUrlForApp(ws, app);
3935
+ if (!shareUrl) {
3936
+ return {
3937
+ error:
3938
+ "Share URL is not ready. Keep the bridge connected to Maintainer Pro.",
3939
+ };
3940
+ }
3941
+ await progress(`Using the Maintainer Pro share URL for ${app.name}…`);
3942
+ const started = await startSingleApp(ws, cfg, { appId: app.id }, opts);
3943
+ if (started.error) return started;
3944
+ const applied = await applyPublicUrlsToRunningApps(ws, cfg, {
3945
+ onProgress: progress,
3946
+ });
3947
+ const status = await reconcileWorkspacePresence(ws, cfg, {
3948
+ timeoutMs: 2500,
3949
+ });
3950
+ activity(ws.sandboxId, "info", `${app.name} share URL: ${shareUrl}`);
3951
+ return {
3952
+ app,
3953
+ running: Boolean(started.running),
3954
+ port: started.port,
3955
+ publicUrl: shareUrl,
3956
+ cloudflareUrl: null,
3957
+ appUrl: status.host.appUrl,
3958
+ origins: status.host.origins,
3959
+ hostApps: status.probe.hostApps || [],
3960
+ rewritten: applied.rewritten,
3961
+ restarted: applied.restarted,
3962
+ log: activityLogFor(ws.sandboxId),
3963
+ };
3964
+ }
3965
+ return {
3966
+ error:
3967
+ "Share URL is not ready. Keep the bridge connected to Maintainer Pro.",
3968
+ app,
3969
+ };
3970
+ }
3971
+
3593
3972
  async function ensureHostProcesses(ws, opts = {}) {
3594
3973
  const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
3595
3974
  const cfg = opts.cfg || null;
@@ -3708,7 +4087,13 @@ async function ensureHostProcesses(ws, opts = {}) {
3708
4087
  continue;
3709
4088
  }
3710
4089
  started.push(job.role);
3711
- log(`start ${job.role} launched ${label} on ${port}: ${command}`);
4090
+ log(
4091
+ `start ${job.role} launched ${label} on ${port}: ${command}${
4092
+ Object.keys(extraEnv).length
4093
+ ? ` env=${Object.keys(extraEnv).join(",")}`
4094
+ : ""
4095
+ }`
4096
+ );
3712
4097
  if (
3713
4098
  (job.role === "ui" || job.role === "app") &&
3714
4099
  (!ws.appUrl || isLocalAppUrl(ws.appUrl))
@@ -3724,11 +4109,13 @@ async function ensureHostProcesses(ws, opts = {}) {
3724
4109
 
3725
4110
  async function inspectHostJobs(ws) {
3726
4111
  const folder = path.resolve(ws.folderPath || "");
3727
- const jobs = planHostJobs(
3728
- folder,
3729
- ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
3730
- ws.projectInfo
3731
- );
4112
+ const jobs = jobsFromHostApps(ws).length
4113
+ ? jobsFromHostApps(ws)
4114
+ : planHostJobs(
4115
+ folder,
4116
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
4117
+ ws.projectInfo
4118
+ );
3732
4119
  const label = ws.sandboxName || "this sandbox";
3733
4120
  const hosts = [];
3734
4121
  for (const job of jobs) {
@@ -3788,7 +4175,7 @@ async function setupWorkspace(cfg, action) {
3788
4175
  }
3789
4176
  }
3790
4177
  let port = requestedPort;
3791
- if (await probeUrl(`http://127.0.0.1:${requestedPort}/embed-config.js`)) {
4178
+ if (await isChatServerOnPort(requestedPort)) {
3792
4179
  reserved.add(requestedPort);
3793
4180
  log(`setup chat port ${requestedPort} already up`);
3794
4181
  } else {
@@ -3810,6 +4197,12 @@ async function setupWorkspace(cfg, action) {
3810
4197
  throw new Error("setup_workspace requires folderPath and sandboxId");
3811
4198
  }
3812
4199
  const resolved = path.resolve(folderPath);
4200
+ const allowed = collectOfferedFolders();
4201
+ if (!allowed.some((root) => pathInside(resolved, root))) {
4202
+ throw new Error(
4203
+ "That folder is outside the directory where the bridge is running. Start the bridge from the project folder you want to share."
4204
+ );
4205
+ }
3813
4206
  fs.mkdirSync(resolved, { recursive: true });
3814
4207
 
3815
4208
  const config = await api(
@@ -3836,30 +4229,10 @@ async function setupWorkspace(cfg, action) {
3836
4229
  const corsOrigin = client.corsOrigin || aiOrigin;
3837
4230
  const appUrl = client.appUrl || corsOrigin;
3838
4231
 
3839
- const envPath = path.join(resolved, ".env");
3840
4232
  const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
3841
4233
  ? config.aiIgnorePaths
3842
4234
  : [];
3843
- const envValues = {
3844
- ...config.env,
3845
- AI_CLI_WORKSPACE: ".",
3846
- AI_CLI_IGNORE_PATHS: JSON.stringify(
3847
- normalizeIgnorePaths(partnerIgnorePaths)
3848
- ),
3849
- AI_SERVER_UI: client.sameOrigin || client.kind === "empty" ? "." : ".",
3850
- AI_SERVER_PORT: String(port),
3851
- AI_SERVER_URL: aiOrigin,
3852
- NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
3853
- CORS_ORIGIN: corsOrigin,
3854
- APP_URL: appUrl,
3855
- AI_SERVER_PRODUCT_DESCRIPTION: appName,
3856
- };
3857
- mergeEnvFile(envPath, envValues, { remove: ["PORT"] });
3858
- const envLocal = path.join(resolved, ".env.local");
3859
- if (fs.existsSync(envLocal)) {
3860
- mergeEnvFile(envLocal, {}, { remove: ["PORT"] });
3861
- }
3862
- const access = applyAccessPolicy(resolved, partnerIgnorePaths);
4235
+ const access = applyAccessPolicy(resolved, partnerIgnorePaths, sandboxId);
3863
4236
 
3864
4237
  cfg.workspaces = cfg.workspaces || [];
3865
4238
  const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
@@ -3874,36 +4247,42 @@ async function setupWorkspace(cfg, action) {
3874
4247
  sameOrigin: Boolean(client.sameOrigin),
3875
4248
  appsRequested: false,
3876
4249
  cloudflarePending: false,
4250
+ store: {
4251
+ serverKey: String(config.env?.MAINTAINER_PRO_API_KEY || "").trim(),
4252
+ clientKey: String(
4253
+ config.env?.MAINTAINER_PRO_CLIENT_API_KEY ||
4254
+ config.env?.NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY ||
4255
+ ""
4256
+ ).trim(),
4257
+ },
3877
4258
  };
3878
4259
  if (existing >= 0) cfg.workspaces[existing] = entry;
3879
4260
  else cfg.workspaces.push(entry);
3880
- if (!cfg.offeredFolders) cfg.offeredFolders = [];
3881
- if (!cfg.offeredFolders.includes(resolved)) cfg.offeredFolders.push(resolved);
3882
4261
  saveConfig(cfg);
3883
4262
 
3884
- const projectInfo = await inspectProjectWithAiCli(entry, {
4263
+ const ports = await resolveWorkspaceHostApps(entry, {
3885
4264
  cfg,
3886
- extraContext: [
3887
- `Scaffold kind: ${client.kind}`,
3888
- `Chat script port: ${port}`,
3889
- client.notes.join(" "),
3890
- ]
3891
- .filter(Boolean)
3892
- .join("\n"),
4265
+ allowAi: false,
3893
4266
  });
4267
+ const needsReview =
4268
+ Boolean(ports.confused) ||
4269
+ !(ports.apps || []).some((app) => app && app.role !== "ai-server");
4270
+ if (needsReview) {
4271
+ activity(
4272
+ sandboxId,
4273
+ "warn",
4274
+ "Review the suggested apps and ports in Maintainer Pro before Start Apps."
4275
+ );
4276
+ }
4277
+ // Do not auto-repair the repo on attach — interactive Review setup handles suggestions.
4278
+ const projectInfo = entry.projectInfo || null;
3894
4279
 
3895
4280
  await inspectHostJobs(entry);
3896
- const planned = planHostJobs(
3897
- resolved,
3898
- entry.appUrl && isLocalAppUrl(entry.appUrl) ? entry.appUrl : null,
3899
- entry.projectInfo
3900
- ).map((job) => ({ ...job, port: job.preferredPort }));
3901
- writeProjectEnv(resolved, envForWorkspacePorts(entry, planned));
3902
4281
 
3903
4282
  const openUrl = client.sameOrigin
3904
4283
  ? `http://localhost:${entry.port}`
3905
4284
  : entry.appUrl || appUrl;
3906
- const aiServerUp = await probeUrl(`http://127.0.0.1:${entry.port}/embed-config.js`);
4285
+ const aiServerUp = await isChatServerOnPort(entry.port);
3907
4286
  if (aiServerUp) {
3908
4287
  clearProcessProblem(sandboxId, "ai_server_launch", "ai");
3909
4288
  clearProcessProblem(sandboxId, "apps_not_started");
@@ -3914,7 +4293,9 @@ async function setupWorkspace(cfg, action) {
3914
4293
  );
3915
4294
  const waitingForStart = !aiServerUp;
3916
4295
  const warning = waitingForStart
3917
- ? "Folder is attached in Maintainer Pro. Use Start Apps when you want to launch the apps."
4296
+ ? needsReview
4297
+ ? "Folder attached. Review the suggested apps and ports in Maintainer Pro, then Start Apps."
4298
+ : "Folder is attached in Maintainer Pro. Review apps if needed, then use Start Apps."
3918
4299
  : processIssues[0]?.message || null;
3919
4300
 
3920
4301
  for (const note of client.notes) log(`setup note ${note}`);
@@ -3933,7 +4314,7 @@ async function setupWorkspace(cfg, action) {
3933
4314
  origins: host.origins.length
3934
4315
  ? host.origins
3935
4316
  : [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
3936
- wroteEnv: true,
4317
+ wroteEnv: false,
3937
4318
  clientKind: client.kind,
3938
4319
  clientFiles: client.filesWritten,
3939
4320
  clientNotes: client.notes,
@@ -3943,11 +4324,98 @@ async function setupWorkspace(cfg, action) {
3943
4324
  processIssues,
3944
4325
  warning,
3945
4326
  waitingForStart,
4327
+ needsReview,
4328
+ hostApps: ports.apps || entry.hostApps || [],
4329
+ reasons: ports.reasons || [],
3946
4330
  projectInfo,
3947
4331
  ignorePaths: access.ignorePaths,
3948
4332
  };
3949
4333
  }
3950
4334
 
4335
+ async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
4336
+ const folder = path.resolve(ws.folderPath || "");
4337
+ const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
4338
+ activity(ws.sandboxId, "info", `Suggesting setup for ${label} from config files`);
4339
+ try {
4340
+ const cli = await loadAiCli();
4341
+ if (typeof cli.proposeHostAppsFromConfig !== "function") {
4342
+ throw new Error(
4343
+ "ai-cli is missing proposeHostAppsFromConfig — update @maintainer-pro/ai-cli"
4344
+ );
4345
+ }
4346
+ const proposal = await cli.proposeHostAppsFromConfig({
4347
+ workspaceDir: folder,
4348
+ appName: ws.applicationName || ws.sandboxName,
4349
+ preferredAiPort: Number(ws.port) || 3100,
4350
+ allowAi: opts.allowAi !== false,
4351
+ });
4352
+ const previous = Array.isArray(ws.hostApps) ? ws.hostApps : [];
4353
+ const apps = (proposal.apps || []).map((app) => {
4354
+ const match = previous.find((row) => row && row.id === app.id);
4355
+ if (!match || (app.envMaps && app.envMaps.length)) return app;
4356
+ return { ...app, envMaps: match.envMaps || [] };
4357
+ });
4358
+ // Keep last known apps on the workspace; do not auto-apply proposal.
4359
+ if (!Array.isArray(ws.hostApps) || !ws.hostApps.length) {
4360
+ ws.hostApps = apps;
4361
+ persistWorkspaceEntry(cfg, ws);
4362
+ }
4363
+ activity(
4364
+ ws.sandboxId,
4365
+ proposal.needsReview ? "warn" : "info",
4366
+ `setup suggestion ${proposal.confidence}${proposal.usedAi ? " + ai" : ""}: ${
4367
+ proposal.projectSummary || apps.map((a) => `${a.name}:${a.port}`).join(", ")
4368
+ }`
4369
+ );
4370
+ for (const reason of proposal.reasons || []) {
4371
+ activity(ws.sandboxId, "warn", reason);
4372
+ }
4373
+ return {
4374
+ proposal: {
4375
+ apps,
4376
+ alternatives: proposal.alternatives || [],
4377
+ reasons: proposal.reasons || [],
4378
+ confidence: proposal.confidence,
4379
+ projectSummary: proposal.projectSummary || "",
4380
+ usedAi: Boolean(proposal.usedAi),
4381
+ needsReview: Boolean(proposal.needsReview),
4382
+ fingerprint: proposal.fingerprint || null,
4383
+ },
4384
+ hostApps: apps,
4385
+ reasons: proposal.reasons || [],
4386
+ usedAi: Boolean(proposal.usedAi),
4387
+ needsReview: Boolean(proposal.needsReview),
4388
+ log: activityLogFor(ws.sandboxId),
4389
+ };
4390
+ } catch (err) {
4391
+ const message = err instanceof Error ? err.message : String(err);
4392
+ activity(ws.sandboxId, "warn", `setup suggestion failed: ${message}`);
4393
+ const fallback = await resolveWorkspaceHostApps(ws, {
4394
+ cfg,
4395
+ force: true,
4396
+ ignoreDesired: true,
4397
+ allowAi: false,
4398
+ });
4399
+ return {
4400
+ proposal: {
4401
+ apps: fallback.apps,
4402
+ alternatives: [],
4403
+ reasons: [...(fallback.reasons || []), message],
4404
+ confidence: "low",
4405
+ projectSummary: "Could not finish AI suggest — showing file detection.",
4406
+ usedAi: false,
4407
+ needsReview: true,
4408
+ fingerprint: fallback.fingerprint || null,
4409
+ },
4410
+ hostApps: fallback.apps,
4411
+ reasons: fallback.reasons || [],
4412
+ usedAi: false,
4413
+ needsReview: true,
4414
+ log: activityLogFor(ws.sandboxId),
4415
+ };
4416
+ }
4417
+ }
4418
+
3951
4419
  async function runActions(cfg, actions) {
3952
4420
  if (!actions.length) return;
3953
4421
  log(`actions received ${actions.length}: ${actions.map((a) => a.code).join(", ")}`);
@@ -3960,9 +4428,11 @@ async function runActions(cfg, actions) {
3960
4428
  let result = {};
3961
4429
  try {
3962
4430
  if (action.code === "browse") {
3963
- const p = String(action.payload?.path || process.cwd());
4431
+ const allowed = collectOfferedFolders();
4432
+ const p = String(action.payload?.path || "roots");
3964
4433
  log(`${label} browse ${p}`);
3965
- result = listDirEntries(p);
4434
+ result = listDirEntries(p, allowed);
4435
+ if (result.error) ok = false;
3966
4436
  log(`${label} browse ${result.entries?.length ?? 0} entries`);
3967
4437
  } else if (action.code === "setup_workspace") {
3968
4438
  result = await setupWorkspace(cfg, action);
@@ -3984,7 +4454,11 @@ async function runActions(cfg, actions) {
3984
4454
  const partnerIgnorePaths = Array.isArray(action.payload?.aiIgnorePaths)
3985
4455
  ? action.payload.aiIgnorePaths
3986
4456
  : [];
3987
- const access = applyAccessPolicy(ws.folderPath, partnerIgnorePaths);
4457
+ const access = applyAccessPolicy(
4458
+ ws.folderPath,
4459
+ partnerIgnorePaths,
4460
+ ws.sandboxId
4461
+ );
3988
4462
  log(
3989
4463
  `access policy synced for ${ws.folderPath} (${access.ignorePaths.length} ignore rules)`
3990
4464
  );
@@ -4000,14 +4474,96 @@ async function runActions(cfg, actions) {
4000
4474
  if (!ws) {
4001
4475
  log(`${label} recheck: no local workspace`);
4002
4476
  }
4003
- const projectInfo = ws
4004
- ? await inspectProjectWithAiCli(ws, { cfg, force: true })
4477
+ const hostApps = ws
4478
+ ? await resolveWorkspaceHostApps(ws, { cfg, force: true, allowAi: false })
4005
4479
  : null;
4006
4480
  result = {
4007
4481
  recheckedAt: new Date().toISOString(),
4008
- projectInfo,
4482
+ hostApps: hostApps?.apps || [],
4483
+ projectInfo: ws?.projectInfo || null,
4009
4484
  };
4010
- } else if (action.code === "start_ai_server") {
4485
+ } else if (action.code === "redetect_ports" || action.code === "propose_setup") {
4486
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
4487
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4488
+ if (!ws) {
4489
+ ok = false;
4490
+ result = { error: "No folder is attached for this sandbox" };
4491
+ } else {
4492
+ activity(
4493
+ ws.sandboxId,
4494
+ "info",
4495
+ action.code === "propose_setup"
4496
+ ? "Setup suggestions requested from Maintainer Pro"
4497
+ : "Detect ports requested from Maintainer Pro"
4498
+ );
4499
+ result = await proposeSetupForWorkspace(ws, cfg, {
4500
+ allowAi: action.payload?.allowAi !== false,
4501
+ });
4502
+ }
4503
+ } else if (action.code === "update_host_apps") {
4504
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
4505
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4506
+ if (!ws) {
4507
+ ok = false;
4508
+ result = { error: "No folder is attached for this sandbox" };
4509
+ } else {
4510
+ const desired = Array.isArray(action.payload?.hostApps)
4511
+ ? action.payload.hostApps
4512
+ : [];
4513
+ activity(
4514
+ ws.sandboxId,
4515
+ "info",
4516
+ `Applying ports from Maintainer Pro: ${desired
4517
+ .map((app) => `${app.name || app.id}:${app.port}`)
4518
+ .join(", ")}`
4519
+ );
4520
+ const resolved = await resolveWorkspaceHostApps(ws, {
4521
+ cfg,
4522
+ desired,
4523
+ allowAi: false,
4524
+ });
4525
+ result = { hostApps: resolved.apps, log: activityLogFor(ws.sandboxId) };
4526
+ }
4527
+ } else if (action.code === "start_app") {
4528
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
4529
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4530
+ if (!ws) {
4531
+ ok = false;
4532
+ result = { error: "No folder is attached for this sandbox" };
4533
+ } else {
4534
+ result = await startSingleApp(ws, cfg, action.payload || {}, {
4535
+ actionId: action.id,
4536
+ });
4537
+ if (result.error) ok = false;
4538
+ }
4539
+ } else if (action.code === "restart_app") {
4540
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
4541
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4542
+ if (!ws) {
4543
+ ok = false;
4544
+ result = { error: "No folder is attached for this sandbox" };
4545
+ } else {
4546
+ result = await restartSingleApp(ws, cfg, action.payload || {}, {
4547
+ actionId: action.id,
4548
+ });
4549
+ if (result.error) ok = false;
4550
+ }
4551
+ } else if (action.code === "start_cloudflare_app") {
4552
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
4553
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4554
+ if (!ws) {
4555
+ ok = false;
4556
+ result = { error: "No folder is attached for this sandbox" };
4557
+ } else {
4558
+ result = await startCloudflareForApp(ws, cfg, action.payload || {}, {
4559
+ actionId: action.id,
4560
+ });
4561
+ if (result.error) ok = false;
4562
+ }
4563
+ } else if (
4564
+ action.code === "start_ai_server" ||
4565
+ action.code === "restart_apps"
4566
+ ) {
4011
4567
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
4012
4568
  const ws =
4013
4569
  (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
@@ -4023,10 +4579,24 @@ async function runActions(cfg, actions) {
4023
4579
  result = { error: "No workspace or --no-ai-server" };
4024
4580
  warn(`${label} skipped: ${result.error}`);
4025
4581
  } else {
4582
+ const restarting = action.code === "restart_apps";
4583
+ log(
4584
+ `${label} ${restarting ? "restart" : "start"} ${ws.folderPath}`
4585
+ );
4586
+ if (restarting) {
4587
+ await reportActionProgress(
4588
+ cfg,
4589
+ action.id,
4590
+ `Stopping apps for ${ws.sandboxName || shortId(ws.sandboxId)}…`
4591
+ );
4592
+ await stopWorkspaceApps(ws);
4593
+ }
4026
4594
  // Prefer cached project inspect; only call ai-cli when missing or
4027
4595
  // after a failed start that used the cache.
4028
- log(`${label} start ${ws.folderPath}`);
4029
- let projectInfo = await inspectProjectWithAiCli(ws, { cfg });
4596
+ let projectInfo = await resolveWorkspaceHostApps(ws, {
4597
+ cfg,
4598
+ allowAi: false,
4599
+ });
4030
4600
  let started = await startAppsForWorkspace(ws, cfg, {
4031
4601
  actionId: action.id,
4032
4602
  });
@@ -4182,7 +4752,6 @@ async function collectWorkspaceStates(cfg) {
4182
4752
  for (const ws of cfg.workspaces || []) {
4183
4753
  const folder = path.resolve(ws.folderPath || "");
4184
4754
  const status = await reconcileWorkspacePresence(ws, cfg, {
4185
- writeEnv: true,
4186
4755
  timeoutMs: 800,
4187
4756
  });
4188
4757
  localStates.push({
@@ -4194,14 +4763,13 @@ async function collectWorkspaceStates(cfg) {
4194
4763
  appsRunning: status.appsRunning,
4195
4764
  startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
4196
4765
  appUrl:
4197
- status.appsRunning || status.usingCloudflare
4766
+ status.appsRunning
4198
4767
  ? status.host.appUrl || ws.appUrl || null
4199
4768
  : null,
4200
- origins:
4201
- status.appsRunning || status.usingCloudflare
4202
- ? status.host.origins
4203
- : [],
4769
+ origins: status.appsRunning ? status.host.origins : [],
4204
4770
  appsRequested: appsWanted(ws) || status.appsRunning,
4771
+ hostApps: status.probe.hostApps || ws.hostApps || [],
4772
+ activityLog: activityLogFor(ws.sandboxId),
4205
4773
  });
4206
4774
  }
4207
4775
  return localStates;
@@ -4213,24 +4781,36 @@ const lastHostReports = new Map();
4213
4781
  function syncAssignedWorkspaces(cfg, remotes) {
4214
4782
  if (!Array.isArray(remotes)) return;
4215
4783
  for (const remote of remotes) {
4216
- const local = (cfg.workspaces || []).find(
4784
+ let local = (cfg.workspaces || []).find(
4217
4785
  (w) => w.sandboxId === remote.sandboxId
4218
4786
  );
4219
4787
  if (!local) {
4220
4788
  cfg.workspaces = cfg.workspaces || [];
4221
- cfg.workspaces.push({
4789
+ local = {
4222
4790
  sandboxId: remote.sandboxId,
4223
4791
  folderPath: remote.folderPath,
4224
4792
  port: remote.port,
4225
4793
  sandboxName: remote.sandboxName,
4226
4794
  applicationName: remote.applicationName,
4227
- });
4795
+ };
4796
+ cfg.workspaces.push(local);
4228
4797
  saveConfig(cfg);
4229
4798
  } else if (local.folderPath !== remote.folderPath) {
4230
4799
  local.folderPath = remote.folderPath;
4231
4800
  local.port = remote.port;
4232
4801
  saveConfig(cfg);
4233
4802
  }
4803
+ if (Array.isArray(remote.hostApps) && remote.hostApps.length) {
4804
+ const localCount = Array.isArray(local.hostApps) ? local.hostApps.length : 0;
4805
+ if (remote.hostApps.length >= localCount) {
4806
+ local.hostApps = remote.hostApps;
4807
+ }
4808
+ }
4809
+ const prevToken = local.proxy?.token;
4810
+ applyAssignedProxy(local, remote, cfg);
4811
+ if (local.proxy?.token && local.proxy.token !== prevToken) {
4812
+ persistWorkspaceEntry(cfg, local);
4813
+ }
4234
4814
  }
4235
4815
  }
4236
4816
 
@@ -4263,6 +4843,8 @@ async function sendHeartbeat(cfg, folders, localStates) {
4263
4843
  ? st.origins
4264
4844
  : undefined,
4265
4845
  appsRequested: Boolean(st.appsRequested),
4846
+ hostApps: st.hostApps || [],
4847
+ activityLog: st.activityLog || [],
4266
4848
  })),
4267
4849
  };
4268
4850
  return api(
@@ -4314,6 +4896,8 @@ async function buildHeartbeatPayload(cfg, folders, localStates) {
4314
4896
  ? st.origins
4315
4897
  : undefined,
4316
4898
  appsRequested: Boolean(st.appsRequested),
4899
+ hostApps: st.hostApps || [],
4900
+ activityLog: st.activityLog || [],
4317
4901
  })),
4318
4902
  };
4319
4903
  }
@@ -4464,32 +5048,24 @@ async function main() {
4464
5048
  { logLevel: logger.level, nodeEnv: process.env.NODE_ENV },
4465
5049
  "bridge starting"
4466
5050
  );
5051
+ void warnIfBridgeOutdated();
4467
5052
 
4468
5053
  let cfg = loadConfig() || {};
4469
5054
  ensureMachineId(cfg);
4470
5055
 
4471
- if (typeof args.offerFolder === "string") {
4472
- cfg.offeredFolders = cfg.offeredFolders || [];
4473
- const resolved = path.resolve(args.offerFolder);
4474
- if (!cfg.offeredFolders.includes(resolved)) {
4475
- cfg.offeredFolders.push(resolved);
4476
- saveConfig(cfg);
4477
- log(`offering folder ${resolved}`);
4478
- }
4479
- }
4480
-
4481
5056
  if (args.pair || !cfg.token || !cfg.adminUrl) {
4482
5057
  cfg = await pairFlow(args);
4483
5058
  }
4484
5059
 
4485
5060
  cfg.noAiServer = Boolean(args.noAiServer);
4486
5061
  saveConfig(cfg);
5062
+ bridgeCfg = cfg;
4487
5063
 
4488
5064
  log(`machine ${cfg.machineId}`);
4489
5065
  log(`admin ${cfg.adminUrl}`);
4490
5066
  log(`config ${configPath()}`);
4491
5067
  log(
4492
- `online via websocket (presence every ${HEARTBEAT_MS / 1000}s, ping every ${WS_PING_MS / 1000}s)`
5068
+ `online via websocket (presence every ${HEARTBEAT_MS / 1000}s, ping every ${WS_PING_MS / 1000}s; reconnects until stopped)`
4493
5069
  );
4494
5070
  await restoreHostsAfterReconnect(cfg);
4495
5071
 
@@ -4519,25 +5095,37 @@ async function main() {
4519
5095
  void runWork();
4520
5096
  };
4521
5097
 
4522
- /** @type {import('ws').WebSocket | null} */
5098
+ /** @type {WebSocket | null} */
4523
5099
  let socket = null;
4524
5100
  let heartbeatTimer = null;
4525
5101
  let pingTimer = null;
4526
5102
  let reconnectTimer = null;
5103
+ let watchdogTimer = null;
4527
5104
  let wsGeneration = 0;
4528
5105
  let reconnectAttempt = 0;
5106
+ let connectStartedAt = 0;
4529
5107
  let stopped = false;
4530
5108
  let presenceBusy = false;
4531
5109
 
5110
+ let sendChain = Promise.resolve();
4532
5111
  const sendJson = (payload) => {
4533
- if (!socket || socket.readyState !== 1) return false;
5112
+ const current = socket;
5113
+ if (!current || current.readyState !== 1) return false;
5114
+ let text;
4534
5115
  try {
4535
- socket.send(JSON.stringify(payload));
4536
- return true;
5116
+ text = JSON.stringify(payload);
4537
5117
  } catch {
4538
5118
  return false;
4539
5119
  }
5120
+ sendChain = sendChain
5121
+ .then(() => {
5122
+ if (!socket || socket !== current || socket.readyState !== 1) return;
5123
+ socket.send(text);
5124
+ })
5125
+ .catch(() => {});
5126
+ return true;
4540
5127
  };
5128
+ bridgeSend = sendJson;
4541
5129
 
4542
5130
  const clearHeartbeatTimer = () => {
4543
5131
  if (heartbeatTimer) {
@@ -4566,7 +5154,7 @@ async function main() {
4566
5154
  };
4567
5155
 
4568
5156
  const sendLightPresence = () => {
4569
- const folders = collectOfferedFolders(cfg);
5157
+ const folders = collectOfferedFolders();
4570
5158
  sendJson(buildLightHeartbeatPayload(cfg, folders));
4571
5159
  };
4572
5160
 
@@ -4574,7 +5162,7 @@ async function main() {
4574
5162
  if (!socket || socket.readyState !== 1 || presenceBusy) return;
4575
5163
  presenceBusy = true;
4576
5164
  try {
4577
- const folders = collectOfferedFolders(cfg);
5165
+ const folders = collectOfferedFolders();
4578
5166
  const localStates = await collectWorkspaceStates(cfg);
4579
5167
  const payload = await buildHeartbeatPayload(cfg, folders, localStates);
4580
5168
  sendJson(payload);
@@ -4627,6 +5215,38 @@ async function main() {
4627
5215
  fail(`no local workspace for sandbox ${sandboxId || "?"}`);
4628
5216
  return;
4629
5217
  }
5218
+ const body = {
5219
+ conversationId,
5220
+ messages: payloadMessages,
5221
+ userMessage: content || undefined,
5222
+ skipPersistUser: true,
5223
+ userMessageId: userMessageId || undefined,
5224
+ senderType: msg.senderType === "client" ? "client" : undefined,
5225
+ senderName:
5226
+ typeof msg.senderName === "string" ? msg.senderName : undefined,
5227
+ };
5228
+ const embedded = embeddedChat.get(sandboxId);
5229
+ if (embedded?.runChat) {
5230
+ log(`chat.run → in-process ${embedded.workspaceDir} (${conversationId})`);
5231
+ try {
5232
+ const result = await embedded.runChat(body);
5233
+ if (!result.ok) {
5234
+ fail(result.data?.error || `AI server ${result.status}`);
5235
+ return;
5236
+ }
5237
+ log(`chat.run ok (${conversationId})`);
5238
+ sendJson({
5239
+ type: "chat.run.result",
5240
+ ok: true,
5241
+ sandboxId,
5242
+ conversationId,
5243
+ userMessageId,
5244
+ });
5245
+ } catch (err) {
5246
+ fail(err instanceof Error ? err.message : String(err));
5247
+ }
5248
+ return;
5249
+ }
4630
5250
  const chat = await discoverChatPort(ws, 2500);
4631
5251
  if (chat.up) ws.port = chat.port;
4632
5252
  const port = Number(chat.port || ws.port) || 3100;
@@ -4639,16 +5259,7 @@ async function main() {
4639
5259
  "content-type": "application/json",
4640
5260
  accept: "application/json",
4641
5261
  },
4642
- body: JSON.stringify({
4643
- conversationId,
4644
- messages: payloadMessages,
4645
- userMessage: content || undefined,
4646
- skipPersistUser: true,
4647
- userMessageId: userMessageId || undefined,
4648
- senderType: msg.senderType === "client" ? "client" : undefined,
4649
- senderName:
4650
- typeof msg.senderName === "string" ? msg.senderName : undefined,
4651
- }),
5262
+ body: JSON.stringify(body),
4652
5263
  });
4653
5264
  const text = await res.text();
4654
5265
  let data = null;
@@ -4692,6 +5303,26 @@ async function main() {
4692
5303
  queueActions(msg.actions);
4693
5304
  return;
4694
5305
  }
5306
+ if (msg.type === "proxy.http") {
5307
+ handleProxyHttpFromAdmin(msg);
5308
+ return;
5309
+ }
5310
+ if (msg.type === "proxy.http.body") {
5311
+ handleProxyHttpBodyFromAdmin(msg);
5312
+ return;
5313
+ }
5314
+ if (msg.type === "proxy.ws.open") {
5315
+ handleProxyWsOpenFromAdmin(msg);
5316
+ return;
5317
+ }
5318
+ if (msg.type === "proxy.ws.frame") {
5319
+ handleProxyWsFrameFromAdmin(msg);
5320
+ return;
5321
+ }
5322
+ if (msg.type === "proxy.ws.close") {
5323
+ handleProxyWsCloseFromAdmin(msg);
5324
+ return;
5325
+ }
4695
5326
  if (msg.type === "chat.run") {
4696
5327
  void handleChatRun(msg);
4697
5328
  return;
@@ -4701,37 +5332,56 @@ async function main() {
4701
5332
  }
4702
5333
  };
4703
5334
 
5335
+ const socketState = () => {
5336
+ if (!socket) return -1;
5337
+ return socket.readyState;
5338
+ };
5339
+
5340
+ const dropSocket = (ws) => {
5341
+ if (!ws) return;
5342
+ try {
5343
+ ws.close();
5344
+ } catch {
5345
+ /* ignore */
5346
+ }
5347
+ if (socket === ws) socket = null;
5348
+ };
5349
+
4704
5350
  const scheduleReconnect = (code, reason) => {
4705
5351
  if (stopped || reconnectTimer) return;
5352
+ const state = socketState();
5353
+ if (state === 0 || state === 1) return;
4706
5354
  const delay = Math.min(
4707
5355
  WS_RECONNECT_MAX_MS,
4708
- WS_RECONNECT_MIN_MS * 2 ** Math.min(reconnectAttempt, 4)
5356
+ WS_RECONNECT_MIN_MS * 2 ** Math.min(reconnectAttempt, 5)
4709
5357
  );
4710
5358
  reconnectAttempt += 1;
4711
5359
  const detail = reason ? ` ${reason}` : "";
4712
5360
  const wait =
4713
5361
  delay < 1000 ? `${delay}ms` : `${Math.round(delay / 1000)}s`;
4714
5362
  warn(
4715
- `websocket closed (${code || "?"}${detail}); reconnecting in ${wait}`
5363
+ `admin connection lost (${code || "?"}${detail}); retrying in ${wait}`
4716
5364
  );
4717
5365
  reconnectTimer = setTimeout(() => {
4718
5366
  reconnectTimer = null;
5367
+ if (stopped) return;
5368
+ const next = socketState();
5369
+ if (next === 0 || next === 1) return;
4719
5370
  connectWs();
4720
5371
  }, delay);
4721
5372
  };
4722
5373
 
4723
5374
  const connectWs = () => {
4724
5375
  if (stopped) return;
5376
+ const state = socketState();
5377
+ if (state === 0 || state === 1) return;
4725
5378
  clearReconnectTimer();
4726
5379
  const generation = ++wsGeneration;
4727
5380
  if (socket) {
4728
- try {
4729
- socket.close();
4730
- } catch {
4731
- /* ignore */
4732
- }
5381
+ dropSocket(socket);
4733
5382
  socket = null;
4734
5383
  }
5384
+ sendChain = Promise.resolve();
4735
5385
  const url = adminWsUrl(cfg.adminUrl, cfg.token);
4736
5386
  log(
4737
5387
  reconnectAttempt
@@ -4739,12 +5389,35 @@ async function main() {
4739
5389
  : "websocket connecting…"
4740
5390
  );
4741
5391
  /** @type {WebSocket} */
4742
- const ws = new WebSocket(url);
5392
+ let ws;
5393
+ try {
5394
+ ws = new WebSocket(url);
5395
+ } catch (err) {
5396
+ warn(
5397
+ `websocket connect failed: ${
5398
+ err instanceof Error ? err.message : String(err)
5399
+ }`
5400
+ );
5401
+ scheduleReconnect(0, "connect failed");
5402
+ return;
5403
+ }
4743
5404
  socket = ws;
5405
+ connectStartedAt = Date.now();
5406
+
5407
+ const onDisconnected = (code, reason) => {
5408
+ if (generation !== wsGeneration) return;
5409
+ // Node fires `error` while CONNECTING/OPEN; that is not a drop.
5410
+ if (ws.readyState === 0 || ws.readyState === 1) return;
5411
+ clearHeartbeatTimer();
5412
+ clearPingTimer();
5413
+ if (socket === ws) socket = null;
5414
+ scheduleReconnect(code, reason);
5415
+ };
4744
5416
 
4745
5417
  ws.addEventListener("open", () => {
4746
5418
  if (generation !== wsGeneration) return;
4747
5419
  reconnectAttempt = 0;
5420
+ connectStartedAt = Date.now();
4748
5421
  log("websocket connected");
4749
5422
  clearHeartbeatTimer();
4750
5423
  clearPingTimer();
@@ -4763,34 +5436,61 @@ async function main() {
4763
5436
  });
4764
5437
 
4765
5438
  ws.addEventListener("close", (event) => {
4766
- if (generation !== wsGeneration) return;
4767
- clearHeartbeatTimer();
4768
- clearPingTimer();
4769
- if (socket === ws) socket = null;
4770
- scheduleReconnect(event.code, event.reason);
5439
+ onDisconnected(event.code, event.reason);
4771
5440
  });
4772
5441
 
4773
5442
  ws.addEventListener("error", () => {
4774
- // close handler drives reconnect
5443
+ // `close` follows for real failures. Reconnecting here races `open`
5444
+ // and makes admin close the live socket with 1000 "replaced".
4775
5445
  });
4776
5446
  };
4777
5447
 
5448
+ const watchdogTick = () => {
5449
+ if (stopped) return;
5450
+ const state = socketState();
5451
+ if (state === 1) return;
5452
+ if (state === 0) {
5453
+ if (Date.now() - connectStartedAt > WS_CONNECT_TIMEOUT_MS) {
5454
+ warn("websocket connect timed out; retrying");
5455
+ wsGeneration += 1;
5456
+ dropSocket(socket);
5457
+ socket = null;
5458
+ scheduleReconnect(0, "connect timeout");
5459
+ }
5460
+ return;
5461
+ }
5462
+ scheduleReconnect(0, "watchdog");
5463
+ };
5464
+
4778
5465
  connectWs();
5466
+ watchdogTimer = setInterval(watchdogTick, WS_WATCHDOG_MS);
5467
+
5468
+ const onFatal = (kind, err) => {
5469
+ const msg = err instanceof Error ? err.message : String(err || kind);
5470
+ warn(`${kind}: ${msg}`);
5471
+ if (!stopped) scheduleReconnect(kind, msg);
5472
+ };
5473
+ process.on("uncaughtException", (err) => onFatal("uncaughtException", err));
5474
+ process.on("unhandledRejection", (err) =>
5475
+ onFatal("unhandledRejection", err)
5476
+ );
4779
5477
 
4780
5478
  const shutdown = () => {
5479
+ if (stopped) return;
4781
5480
  stopped = true;
4782
5481
  wsGeneration += 1;
4783
5482
  clearHeartbeatTimer();
4784
5483
  clearPingTimer();
4785
5484
  clearReconnectTimer();
4786
- stopAllCloudflare();
4787
- try {
4788
- socket?.close();
4789
- } catch {
4790
- /* ignore */
5485
+ if (watchdogTimer) {
5486
+ clearInterval(watchdogTimer);
5487
+ watchdogTimer = null;
4791
5488
  }
5489
+ stopAllCloudflare();
5490
+ dropSocket(socket);
5491
+ socket = null;
4792
5492
  log("shutting down (other terminals stay open)");
4793
- process.exit(0);
5493
+ void stopAllEmbeddedChat().finally(() => process.exit(0));
4794
5494
  };
4795
5495
  process.on("SIGINT", shutdown);
4796
5496
  process.on("SIGTERM", shutdown);