@maintainer-pro/ai-bridge 0.1.7 → 0.1.10

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 +2258 -1542
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,104 +2121,629 @@ 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
+ /**
2129
+ * Folder picker root — not `/` or a drive letter.
2130
+ * Windows: `D:\Projects\app` `D:\Projects`.
2131
+ * macOS/Linux: `~/projects/app` → `~/projects`.
2132
+ */
2133
+ function browseRootFromCwd() {
2134
+ const cwd = path.resolve(process.cwd());
2135
+ if (process.platform === "win32") {
2136
+ const drive = path.parse(cwd).root;
2137
+ const first = cwd.slice(drive.length).split(/[\\/]/).filter(Boolean)[0];
2138
+ if (first) return path.resolve(path.join(drive, first));
2139
+ return cwd;
2140
+ }
2141
+ const home = path.resolve(os.homedir());
2142
+ if (pathInside(cwd, home)) {
2143
+ const first = cwd.slice(home.length).split("/").filter(Boolean)[0];
2144
+ if (first) return path.join(home, first);
2145
+ return home;
2146
+ }
2147
+ const bits = cwd.split("/").filter(Boolean);
2148
+ if (bits[0] === "Volumes" && bits[1]) {
2149
+ return path.join("/", bits[0], bits[1]);
2150
+ }
2151
+ if (bits[0]) return path.join("/", bits[0]);
2152
+ return cwd;
2153
+ }
2280
2154
 
2281
- /** Last process problems to send on heartbeat. Key: sandboxId::code::role */
2282
- const processProblems = new Map();
2155
+ function normalizeFsPath(p) {
2156
+ let s = path.resolve(String(p || "").trim());
2157
+ if (process.platform === "win32") {
2158
+ s = s.replace(/\//g, "\\");
2159
+ if (/^[a-zA-Z]:$/.test(s)) s = `${s}\\`;
2160
+ else s = s.replace(/\\+$/, "");
2161
+ if (/^[a-zA-Z]:$/.test(s)) s = `${s}\\`;
2162
+ return s.toLowerCase();
2163
+ }
2164
+ if (s !== "/") s = s.replace(/\/+$/, "");
2165
+ return s;
2166
+ }
2283
2167
 
2284
- /** @type {Map<string, Set<string>>} sandboxId -> CMD/terminal titles we opened */
2285
- const openedTerminalTitles = new Map();
2168
+ function pathInside(inner, outer) {
2169
+ const a = normalizeFsPath(inner);
2170
+ const b = normalizeFsPath(outer);
2171
+ if (!a || !b) return false;
2172
+ if (a === b) return true;
2173
+ const sep = process.platform === "win32" ? "\\" : "/";
2174
+ const prefix = b.endsWith(sep) ? b : `${b}${sep}`;
2175
+ return a.startsWith(prefix);
2176
+ }
2286
2177
 
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);
2178
+ function isLaunchRoot(dirPath, allowed) {
2179
+ const resolved = path.resolve(dirPath);
2180
+ return allowed.some((root) => normalizeFsPath(root) === normalizeFsPath(resolved));
2297
2181
  }
2298
2182
 
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
- });
2183
+ function listLaunchRoots(allowed) {
2184
+ const home = os.homedir();
2185
+ if (allowed.length === 1) return listDirEntries(allowed[0], allowed);
2186
+ return {
2187
+ path: "",
2188
+ parent: null,
2189
+ home,
2190
+ entries: allowed.map((root) => ({
2191
+ name: root.split(/[/\\]/).filter(Boolean).slice(-1)[0] || root,
2192
+ path: root,
2193
+ isDir: true,
2194
+ })),
2195
+ };
2331
2196
  }
2332
2197
 
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);
2198
+ /** Prevents opening a new window on every heartbeat while a process is starting. */
2199
+ const launchedAt = new Map();
2200
+ /** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
2201
+ const cloudflareTunnels = new Map();
2202
+ /** In-process chat servers, one per sandbox/project. */
2203
+ /** @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 }>} */
2204
+ const embeddedChat = new Map();
2205
+ /** @type {Map<string, Promise<object | null>>} */
2206
+ const embeddedChatStarting = new Map();
2207
+
2208
+ const PROXY_CHUNK_BYTES = 32 * 1024;
2209
+ /** @type {Map<string, import("node:http").ClientRequest>} */
2210
+ const proxyHttpReqs = new Map();
2211
+ /** @type {Map<string, WebSocket>} */
2212
+ const proxyLocalSockets = new Map();
2213
+ /** @type {Record<string, unknown> | null} */
2214
+ let bridgeCfg = null;
2215
+
2216
+ function safeProxyPath(path) {
2217
+ const raw = String(path || "/");
2218
+ if (!raw.startsWith("/") || raw.startsWith("//")) return "/";
2219
+ return raw;
2220
+ }
2221
+
2222
+ function workspaceForProxy(sandboxId) {
2223
+ return (
2224
+ (Array.isArray(bridgeCfg?.workspaces) ? bridgeCfg.workspaces : []).find(
2225
+ (row) => row.sandboxId === sandboxId
2226
+ ) || null
2227
+ );
2228
+ }
2229
+
2230
+ function localPortForProxy(ws, appId) {
2231
+ const apps = Array.isArray(ws?.hostApps) ? ws.hostApps : [];
2232
+ const app = apps.find((row) => row.id === appId);
2233
+ let port = 0;
2234
+ if (app && usesBridgeProxy(app) && Number(app.port)) port = Number(app.port);
2235
+ else if (appId === "ai-server" || app?.role === "ai-server") {
2236
+ port = Number(ws.port) || 3100;
2338
2237
  }
2339
- openedTerminalTitles.delete(sandboxId);
2238
+ if (
2239
+ (appId === "ai-server" || app?.role === "ai-server") &&
2240
+ isAdminListenPort(port)
2241
+ ) {
2242
+ return 3100;
2243
+ }
2244
+ return port;
2340
2245
  }
2341
2246
 
2342
- async function forgetLaunch(sandboxId) {
2343
- for (const key of [...launchedAt.keys()]) {
2344
- if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
2345
- launchedAt.delete(key);
2247
+ function proxyReqHeaders(incoming) {
2248
+ /** @type {Record<string, string>} */
2249
+ const headers = {};
2250
+ if (!incoming || typeof incoming !== "object") return headers;
2251
+ for (const [key, value] of Object.entries(incoming)) {
2252
+ const lower = String(key).toLowerCase();
2253
+ if (
2254
+ [
2255
+ "connection",
2256
+ "keep-alive",
2257
+ "transfer-encoding",
2258
+ "upgrade",
2259
+ "host",
2260
+ "content-length",
2261
+ "te",
2262
+ "trailer",
2263
+ ].includes(lower)
2264
+ ) {
2265
+ continue;
2346
2266
  }
2267
+ if (typeof value === "string" && value) headers[key] = value;
2347
2268
  }
2348
- for (const key of [...processProblems.keys()]) {
2349
- if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
2350
- }
2351
- await closeRememberedTerminals(sandboxId);
2352
- await stopCloudflare(sandboxId);
2269
+ return headers;
2353
2270
  }
2354
2271
 
2355
- function problemKey(sandboxId, code, role = "") {
2356
- return `${sandboxId || ""}::${code}::${role}`;
2272
+ function isAiServerAppId(appId, ws) {
2273
+ if (appId === "ai-server") return true;
2274
+ const apps = Array.isArray(ws?.hostApps) ? ws.hostApps : [];
2275
+ const app = apps.find((row) => row.id === appId);
2276
+ return Boolean(app && (app.role === "ai-server" || app.id === "ai-server"));
2357
2277
  }
2358
2278
 
2359
- function clipIssueText(text, max) {
2279
+ function replyProxyHttp(id, status, headers, body) {
2280
+ const stream = randomBytes(8).toString("hex");
2281
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(body || "");
2282
+ /** @type {Record<string, string>} */
2283
+ const out = {};
2284
+ for (const [key, value] of Object.entries(headers || {})) {
2285
+ if (value == null) continue;
2286
+ out[String(key)] = String(value);
2287
+ }
2288
+ bridgeSend({ type: "proxy.http.start", id, stream, status, headers: out });
2289
+ for (let offset = 0; offset < buf.length; offset += PROXY_CHUNK_BYTES) {
2290
+ const end = Math.min(offset + PROXY_CHUNK_BYTES, buf.length);
2291
+ bridgeSend({
2292
+ type: "proxy.http.chunk",
2293
+ id,
2294
+ stream,
2295
+ data: buf.subarray(offset, end).toString("base64"),
2296
+ eof: false,
2297
+ });
2298
+ }
2299
+ bridgeSend({ type: "proxy.http.chunk", id, stream, data: "", eof: true });
2300
+ }
2301
+
2302
+ function bridgeEmbedConfigJs(ws) {
2303
+ const store = ws?.store && typeof ws.store === "object" ? ws.store : {};
2304
+ const aiApp = { id: "ai-server", role: "ai-server" };
2305
+ const ai = (
2306
+ proxyUrlForApp(ws, aiApp) ||
2307
+ `http://127.0.0.1:${Number(ws.port) || 3100}`
2308
+ ).replace(/\/$/, "");
2309
+ const wsUrl = ai.replace(/^http/i, "ws");
2310
+ const payload = {
2311
+ aiServerUrl: ai,
2312
+ apiUrl: `${ai}/api/chat`,
2313
+ aiServerWsUrl: `${wsUrl}/api/ws`,
2314
+ debug: true,
2315
+ logLevel: "debug",
2316
+ maintainerProUrl: bridgeCfg?.adminUrl || "",
2317
+ maintainerProApiKey: String(store.clientKey || "").trim(),
2318
+ };
2319
+ return `window.__MAINTAINER_PRO__=${JSON.stringify(payload)};`;
2320
+ }
2321
+
2322
+ async function handleAiProxyHttpFromAdmin(msg, ws) {
2323
+ const id = typeof msg.id === "string" ? msg.id : "";
2324
+ const method = String(msg.method || "GET").toUpperCase();
2325
+ const reqPath = safeProxyPath(msg.path);
2326
+ const pathname = reqPath.split("?")[0] || "/";
2327
+ const headers = proxyReqHeaders(msg.headers);
2328
+ const body =
2329
+ typeof msg.body === "string" && msg.body
2330
+ ? Buffer.from(msg.body, "base64")
2331
+ : Buffer.alloc(0);
2332
+
2333
+ if (pathname === "/ai-ui.iife.js") {
2334
+ const file = findIife();
2335
+ if (!file || !fs.existsSync(file)) {
2336
+ replyProxyHttp(
2337
+ id,
2338
+ 404,
2339
+ { "content-type": "text/plain; charset=utf-8" },
2340
+ "Not found"
2341
+ );
2342
+ return;
2343
+ }
2344
+ replyProxyHttp(
2345
+ id,
2346
+ 200,
2347
+ {
2348
+ "content-type": "text/javascript; charset=utf-8",
2349
+ "cache-control": "no-store",
2350
+ },
2351
+ fs.readFileSync(file)
2352
+ );
2353
+ return;
2354
+ }
2355
+
2356
+ if (pathname === "/embed-config.js") {
2357
+ const cfg = bridgeCfg || loadConfig();
2358
+ await loadSandboxStoreEnv(ws, cfg);
2359
+ replyProxyHttp(
2360
+ id,
2361
+ 200,
2362
+ {
2363
+ "content-type": "text/javascript; charset=utf-8",
2364
+ "cache-control": "no-store",
2365
+ },
2366
+ bridgeEmbedConfigJs(ws)
2367
+ );
2368
+ return;
2369
+ }
2370
+
2371
+ let embedded = ws.sandboxId ? embeddedChat.get(ws.sandboxId) : null;
2372
+ if (!embedded?.handleHttp) {
2373
+ embedded = await ensureEmbeddedChat(ws);
2374
+ }
2375
+ if (embedded?.handleHttp) {
2376
+ try {
2377
+ const result = await embedded.handleHttp({
2378
+ method,
2379
+ url: reqPath,
2380
+ headers,
2381
+ body,
2382
+ });
2383
+ replyProxyHttp(id, result.status, result.headers, result.body);
2384
+ } catch (err) {
2385
+ bridgeSend({
2386
+ type: "proxy.http.error",
2387
+ id,
2388
+ error: err instanceof Error ? err.message : String(err),
2389
+ });
2390
+ }
2391
+ return;
2392
+ }
2393
+
2394
+ bridgeSend({
2395
+ type: "proxy.http.error",
2396
+ id,
2397
+ error: "AI chat is not running. Use Start Apps in Maintainer Pro.",
2398
+ });
2399
+ }
2400
+
2401
+ function handleProxyHttpFromAdmin(msg) {
2402
+ const id = typeof msg.id === "string" ? msg.id : "";
2403
+ const sandboxId = typeof msg.sandboxId === "string" ? msg.sandboxId : "";
2404
+ const appId = typeof msg.appId === "string" ? msg.appId : "";
2405
+ if (!id) return;
2406
+ const ws = workspaceForProxy(sandboxId);
2407
+ if (!ws) {
2408
+ bridgeSend({
2409
+ type: "proxy.http.error",
2410
+ id,
2411
+ error: "No local port mapped for that app",
2412
+ });
2413
+ return;
2414
+ }
2415
+ if (isAiServerAppId(appId, ws)) {
2416
+ void handleAiProxyHttpFromAdmin(msg, ws);
2417
+ return;
2418
+ }
2419
+ const existing = proxyHttpReqs.get(id);
2420
+ if (existing) {
2421
+ try {
2422
+ existing.destroy();
2423
+ } catch {
2424
+ /* ignore */
2425
+ }
2426
+ proxyHttpReqs.delete(id);
2427
+ }
2428
+ const port = localPortForProxy(ws, appId);
2429
+ if (!port) {
2430
+ bridgeSend({
2431
+ type: "proxy.http.error",
2432
+ id,
2433
+ error: "No local port mapped for that app",
2434
+ });
2435
+ return;
2436
+ }
2437
+ const method = String(msg.method || "GET").toUpperCase();
2438
+ const path = safeProxyPath(msg.path);
2439
+ const headers = proxyReqHeaders(msg.headers);
2440
+ headers.host = `127.0.0.1:${port}`;
2441
+ // Next.js dev 403s `/_next` when Origin/sec-fetch look cross-site.
2442
+ // This hop is server-to-server; drop those so chunks always load.
2443
+ for (const key of Object.keys(headers)) {
2444
+ const lower = key.toLowerCase();
2445
+ if (
2446
+ lower === "origin" ||
2447
+ lower === "referer" ||
2448
+ lower === "referrer" ||
2449
+ lower === "sec-fetch-site" ||
2450
+ lower === "sec-fetch-mode" ||
2451
+ lower === "sec-fetch-dest"
2452
+ ) {
2453
+ delete headers[key];
2454
+ }
2455
+ }
2456
+ const stream = randomBytes(8).toString("hex");
2457
+ let req;
2458
+ try {
2459
+ req = http.request(
2460
+ {
2461
+ hostname: "127.0.0.1",
2462
+ port,
2463
+ path,
2464
+ method,
2465
+ headers,
2466
+ agent: false,
2467
+ },
2468
+ (res) => {
2469
+ /** @type {Record<string, string>} */
2470
+ const outHeaders = {};
2471
+ for (const [key, value] of Object.entries(res.headers)) {
2472
+ if (value == null) continue;
2473
+ outHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value);
2474
+ }
2475
+ bridgeSend({
2476
+ type: "proxy.http.start",
2477
+ id,
2478
+ stream,
2479
+ status: res.statusCode || 502,
2480
+ headers: outHeaders,
2481
+ });
2482
+ res.on("data", (chunk) => {
2483
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2484
+ for (let offset = 0; offset < buf.length; offset += PROXY_CHUNK_BYTES) {
2485
+ const end = Math.min(offset + PROXY_CHUNK_BYTES, buf.length);
2486
+ bridgeSend({
2487
+ type: "proxy.http.chunk",
2488
+ id,
2489
+ stream,
2490
+ data: Buffer.from(buf.subarray(offset, end)).toString("base64"),
2491
+ eof: false,
2492
+ });
2493
+ }
2494
+ });
2495
+ res.on("end", () => {
2496
+ proxyHttpReqs.delete(id);
2497
+ bridgeSend({ type: "proxy.http.chunk", id, stream, data: "", eof: true });
2498
+ });
2499
+ }
2500
+ );
2501
+ } catch (err) {
2502
+ bridgeSend({
2503
+ type: "proxy.http.error",
2504
+ id,
2505
+ error: err instanceof Error ? err.message : String(err),
2506
+ });
2507
+ return;
2508
+ }
2509
+ req.on("error", (err) => {
2510
+ proxyHttpReqs.delete(id);
2511
+ bridgeSend({
2512
+ type: "proxy.http.error",
2513
+ id,
2514
+ error: err instanceof Error ? err.message : String(err),
2515
+ });
2516
+ });
2517
+ proxyHttpReqs.set(id, req);
2518
+ if (typeof msg.body === "string" && msg.body) {
2519
+ req.write(Buffer.from(msg.body, "base64"));
2520
+ }
2521
+ if (msg.bodyEof !== false) req.end();
2522
+ }
2523
+
2524
+ function handleProxyHttpBodyFromAdmin(msg) {
2525
+ const id = typeof msg.id === "string" ? msg.id : "";
2526
+ const req = proxyHttpReqs.get(id);
2527
+ if (!req) return;
2528
+ if (typeof msg.data === "string" && msg.data) {
2529
+ req.write(Buffer.from(msg.data, "base64"));
2530
+ }
2531
+ if (msg.eof === true) req.end();
2532
+ }
2533
+
2534
+ function wsDataToBuffer(data) {
2535
+ if (typeof data === "string") return Buffer.from(data);
2536
+ if (Buffer.isBuffer(data)) return data;
2537
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
2538
+ if (ArrayBuffer.isView(data)) {
2539
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
2540
+ }
2541
+ throw new Error("Unsupported WebSocket payload type");
2542
+ }
2543
+
2544
+ function attachProxyLocalWs(id, socket) {
2545
+ try {
2546
+ socket.binaryType = "arraybuffer";
2547
+ } catch {
2548
+ /* WHATWG WebSocket in Node 22 */
2549
+ }
2550
+ proxyLocalSockets.set(id, socket);
2551
+ socket.addEventListener("open", () => {
2552
+ bridgeSend({ type: "proxy.ws.opened", id });
2553
+ });
2554
+ socket.addEventListener("message", (event) => {
2555
+ try {
2556
+ if (typeof event.data === "string") {
2557
+ bridgeSend({
2558
+ type: "proxy.ws.frame",
2559
+ id,
2560
+ data: event.data,
2561
+ binary: false,
2562
+ });
2563
+ return;
2564
+ }
2565
+ const buf = wsDataToBuffer(event.data);
2566
+ bridgeSend({
2567
+ type: "proxy.ws.frame",
2568
+ id,
2569
+ data: buf.toString("base64"),
2570
+ binary: true,
2571
+ });
2572
+ } catch (err) {
2573
+ logger.warn(
2574
+ `proxy ws frame encode failed: ${err instanceof Error ? err.message : String(err)}`
2575
+ );
2576
+ }
2577
+ });
2578
+ socket.addEventListener("close", (event) => {
2579
+ proxyLocalSockets.delete(id);
2580
+ bridgeSend({
2581
+ type: "proxy.ws.close",
2582
+ id,
2583
+ code: event.code,
2584
+ reason: event.reason || "",
2585
+ });
2586
+ });
2587
+ socket.addEventListener("error", () => {
2588
+ /* close handler follows */
2589
+ });
2590
+ }
2591
+
2592
+ function openProxyLocalWs(id, port, path) {
2593
+ let socket;
2594
+ try {
2595
+ socket = new WebSocket(`ws://127.0.0.1:${port}${path}`);
2596
+ } catch (err) {
2597
+ bridgeSend({
2598
+ type: "proxy.ws.error",
2599
+ id,
2600
+ error: err instanceof Error ? err.message : String(err),
2601
+ });
2602
+ return;
2603
+ }
2604
+ attachProxyLocalWs(id, socket);
2605
+ }
2606
+
2607
+ function handleProxyWsOpenFromAdmin(msg) {
2608
+ const id = typeof msg.id === "string" ? msg.id : "";
2609
+ const sandboxId = typeof msg.sandboxId === "string" ? msg.sandboxId : "";
2610
+ const appId = typeof msg.appId === "string" ? msg.appId : "";
2611
+ if (!id) return;
2612
+ const ws = workspaceForProxy(sandboxId);
2613
+ if (!ws) {
2614
+ bridgeSend({ type: "proxy.ws.error", id, error: "No local port mapped" });
2615
+ return;
2616
+ }
2617
+ const path = safeProxyPath(msg.path);
2618
+ if (isAiServerAppId(appId, ws)) {
2619
+ void (async () => {
2620
+ const embedded = await ensureEmbeddedChat(ws);
2621
+ const port = Number(embedded?.port) || localPortForProxy(ws, appId);
2622
+ if (!port) {
2623
+ bridgeSend({ type: "proxy.ws.error", id, error: "AI chat is not running" });
2624
+ return;
2625
+ }
2626
+ openProxyLocalWs(id, port, path);
2627
+ })();
2628
+ return;
2629
+ }
2630
+ const port = localPortForProxy(ws, appId);
2631
+ if (!port) {
2632
+ bridgeSend({ type: "proxy.ws.error", id, error: "No local port mapped" });
2633
+ return;
2634
+ }
2635
+ openProxyLocalWs(id, port, path);
2636
+ }
2637
+
2638
+ function handleProxyWsFrameFromAdmin(msg) {
2639
+ const id = typeof msg.id === "string" ? msg.id : "";
2640
+ const socket = proxyLocalSockets.get(id);
2641
+ if (!socket || socket.readyState !== 1) return;
2642
+ try {
2643
+ if (msg.binary === true) {
2644
+ socket.send(Buffer.from(String(msg.data || ""), "base64"));
2645
+ } else {
2646
+ socket.send(String(msg.data || ""));
2647
+ }
2648
+ } catch {
2649
+ /* ignore */
2650
+ }
2651
+ }
2652
+
2653
+ function handleProxyWsCloseFromAdmin(msg) {
2654
+ const id = typeof msg.id === "string" ? msg.id : "";
2655
+ const socket = proxyLocalSockets.get(id);
2656
+ if (!socket) return;
2657
+ proxyLocalSockets.delete(id);
2658
+ try {
2659
+ socket.close(
2660
+ typeof msg.code === "number" ? msg.code : 1000,
2661
+ typeof msg.reason === "string" ? msg.reason.slice(0, 120) : ""
2662
+ );
2663
+ } catch {
2664
+ /* ignore */
2665
+ }
2666
+ }
2667
+
2668
+ /** Last process problems to send on heartbeat. Key: sandboxId::code::role */
2669
+ const processProblems = new Map();
2670
+
2671
+ /** @type {Map<string, Set<string>>} sandboxId -> CMD/terminal titles we opened */
2672
+ const openedTerminalTitles = new Map();
2673
+
2674
+ function rememberTerminalTitle(sandboxId, title) {
2675
+ const id = String(sandboxId || "").trim();
2676
+ const name = String(title || "").trim();
2677
+ if (!id || !name) return;
2678
+ let titles = openedTerminalTitles.get(id);
2679
+ if (!titles) {
2680
+ titles = new Set();
2681
+ openedTerminalTitles.set(id, titles);
2682
+ }
2683
+ titles.add(name);
2684
+ }
2685
+
2686
+ function closeWindowsByTitle(title) {
2687
+ const name = String(title || "").trim();
2688
+ if (!name) return Promise.resolve();
2689
+ return new Promise((resolve) => {
2690
+ const done = () => resolve();
2691
+ if (process.platform === "win32") {
2692
+ const child = spawn(
2693
+ "taskkill",
2694
+ ["/F", "/T", "/FI", `WINDOWTITLE eq ${name}*`],
2695
+ { windowsHide: true, stdio: "ignore" }
2696
+ );
2697
+ child.on("exit", done);
2698
+ child.on("error", done);
2699
+ setTimeout(done, 4000);
2700
+ return;
2701
+ }
2702
+ if (process.platform === "darwin") {
2703
+ const child = spawn(
2704
+ "osascript",
2705
+ [
2706
+ "-e",
2707
+ `tell application "Terminal" to close (every window whose name contains ${JSON.stringify(name)})`,
2708
+ ],
2709
+ { stdio: "ignore" }
2710
+ );
2711
+ child.on("exit", done);
2712
+ child.on("error", done);
2713
+ setTimeout(done, 4000);
2714
+ return;
2715
+ }
2716
+ done();
2717
+ });
2718
+ }
2719
+
2720
+ async function closeRememberedTerminals(sandboxId) {
2721
+ const titles = openedTerminalTitles.get(sandboxId);
2722
+ if (!titles) return;
2723
+ for (const title of titles) {
2724
+ await closeWindowsByTitle(title);
2725
+ }
2726
+ openedTerminalTitles.delete(sandboxId);
2727
+ }
2728
+
2729
+ async function forgetLaunch(sandboxId) {
2730
+ for (const key of [...launchedAt.keys()]) {
2731
+ if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
2732
+ launchedAt.delete(key);
2733
+ }
2734
+ }
2735
+ for (const key of [...processProblems.keys()]) {
2736
+ if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
2737
+ }
2738
+ await closeRememberedTerminals(sandboxId);
2739
+ await stopCloudflare(sandboxId);
2740
+ }
2741
+
2742
+ function problemKey(sandboxId, code, role = "") {
2743
+ return `${sandboxId || ""}::${code}::${role}`;
2744
+ }
2745
+
2746
+ function clipIssueText(text, max) {
2360
2747
  const value = String(text || "").trim();
2361
2748
  if (value.length <= max) return value;
2362
2749
  return `${value.slice(0, max - 1)}…`;
@@ -2518,8 +2905,8 @@ function friendlyLaunchError(raw, title) {
2518
2905
  return text || `Could not open a terminal for "${title}".`;
2519
2906
  }
2520
2907
 
2521
- function writeWinLaunchScript(folder, title, command, env) {
2522
- const dir = path.join(folder, ".maintainer-pro");
2908
+ function writeWinLaunchScript(folder, title, command, env, sandboxId) {
2909
+ const dir = dataDirFor(folder, sandboxId);
2523
2910
  fs.mkdirSync(dir, { recursive: true });
2524
2911
  const safe = String(title || "app").replace(/[^a-zA-Z0-9._-]+/g, "-");
2525
2912
  const file = path.join(dir, `launch-${safe}.cmd`);
@@ -2651,7 +3038,13 @@ async function openInNewTerminal(opts) {
2651
3038
  const safeTitle =
2652
3039
  String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ").trim() ||
2653
3040
  "Maintainer Pro";
2654
- const script = writeWinLaunchScript(folder, safeTitle, command, env);
3041
+ const script = writeWinLaunchScript(
3042
+ folder,
3043
+ safeTitle,
3044
+ command,
3045
+ env,
3046
+ sandboxId
3047
+ );
2655
3048
  log(`terminal script [${title}] ${script}`);
2656
3049
  const opened = await openWindowsConsole(script, folder);
2657
3050
  if (!opened.ok) {
@@ -2708,12 +3101,56 @@ function commandWithPort(job, scripts, port) {
2708
3101
  return job.command;
2709
3102
  }
2710
3103
 
3104
+ async function stopEmbeddedChat(sandboxId) {
3105
+ const inst = embeddedChat.get(sandboxId);
3106
+ if (!inst) return;
3107
+ embeddedChat.delete(sandboxId);
3108
+ try {
3109
+ await inst.close();
3110
+ } catch {
3111
+ /* ignore */
3112
+ }
3113
+ }
3114
+
3115
+ async function stopAllEmbeddedChat() {
3116
+ const ids = [...embeddedChat.keys()];
3117
+ await Promise.all(ids.map((id) => stopEmbeddedChat(id)));
3118
+ }
3119
+
3120
+ async function ensureEmbeddedChat(ws) {
3121
+ if (!ws?.sandboxId) return null;
3122
+ const existing = embeddedChat.get(ws.sandboxId);
3123
+ if (existing?.handleHttp) return existing;
3124
+ const inflight = embeddedChatStarting.get(ws.sandboxId);
3125
+ if (inflight) return inflight;
3126
+ const task = (async () => {
3127
+ const cfg = bridgeCfg || loadConfig();
3128
+ const jobs = jobsFromHostApps(ws);
3129
+ const shareEnv = {
3130
+ ...envForWorkspacePorts(ws, jobs),
3131
+ ...uiPublicEnv({}, ws),
3132
+ };
3133
+ log(`lazy-start in-process chat for ${ws.sandboxName || shortId(ws.sandboxId)}`);
3134
+ const result = await startAiServerForWorkspace(ws, {
3135
+ cfg,
3136
+ env: shareEnv,
3137
+ forceEmbed: true,
3138
+ });
3139
+ if (!result.up) return null;
3140
+ return embeddedChat.get(ws.sandboxId) || null;
3141
+ })().finally(() => {
3142
+ embeddedChatStarting.delete(ws.sandboxId);
3143
+ });
3144
+ embeddedChatStarting.set(ws.sandboxId, task);
3145
+ return task;
3146
+ }
3147
+
2711
3148
  async function startAiServerForWorkspace(ws, opts = {}) {
2712
3149
  const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
2713
3150
  const cfg = opts.cfg || null;
2714
3151
  const folder = path.resolve(ws.folderPath);
2715
- const preferred = Number(ws.port) || 3100;
2716
- const launchKey = `${ws.sandboxId}:${folder}:ai`;
3152
+ const preferredRaw = Number(ws.port) || 3100;
3153
+ const preferred = isAdminListenPort(preferredRaw) ? 3100 : preferredRaw;
2717
3154
  const label = ws.sandboxName || "this sandbox";
2718
3155
 
2719
3156
  if (!fs.existsSync(folder)) {
@@ -2728,19 +3165,28 @@ async function startAiServerForWorkspace(ws, opts = {}) {
2728
3165
  return { port: preferred, up: false, launched: false };
2729
3166
  }
2730
3167
 
2731
- if (await probeUrl(`http://127.0.0.1:${preferred}/embed-config.js`)) {
3168
+ const existing = embeddedChat.get(ws.sandboxId);
3169
+ if (existing && existing.workspaceDir === folder) {
3170
+ if (await isChatServerOnPort(existing.port)) {
3171
+ reserved.add(existing.port);
3172
+ ws.port = existing.port;
3173
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
3174
+ log(`start chat skip ${label}: already in-process on ${existing.port}`);
3175
+ return { port: existing.port, up: true, launched: false };
3176
+ }
3177
+ await stopEmbeddedChat(ws.sandboxId);
3178
+ } else if (existing) {
3179
+ await stopEmbeddedChat(ws.sandboxId);
3180
+ }
3181
+
3182
+ if (!opts.forceEmbed && (await isChatServerOnPort(preferred))) {
2732
3183
  reserved.add(preferred);
3184
+ ws.port = preferred;
2733
3185
  clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2734
3186
  log(`start chat skip ${label}: already up on ${preferred}`);
2735
3187
  return { port: preferred, up: true, launched: false };
2736
3188
  }
2737
3189
 
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
3190
  let port = Number(opts.port) || preferred;
2745
3191
  try {
2746
3192
  if (!opts.port) {
@@ -2762,7 +3208,7 @@ async function startAiServerForWorkspace(ws, opts = {}) {
2762
3208
  return { port: preferred, up: false, launched: false };
2763
3209
  }
2764
3210
  if (port !== preferred) {
2765
- log(`port ${preferred} busy; using ${port} for chat script`);
3211
+ log(`port ${preferred} busy; using ${port} for chat`);
2766
3212
  }
2767
3213
  ws.port = port;
2768
3214
  const localAi = `http://localhost:${port}`;
@@ -2776,59 +3222,52 @@ async function startAiServerForWorkspace(ws, opts = {}) {
2776
3222
  (typeof ws.cloudflare?.ai === "string" && ws.cloudflare.ai.trim()) ||
2777
3223
  "";
2778
3224
  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
3225
  persistWorkspaceEntry(cfg, ws);
2786
3226
 
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) {
3227
+ const storeEnv = await loadSandboxStoreEnv(ws, cfg);
3228
+ const folderEnv = readProjectEnvValues(folder);
3229
+ const dataDir = dataDirFor(folder, ws.sandboxId);
3230
+ const instanceEnv = {
3231
+ ...folderEnv,
3232
+ ...overrideEnv,
3233
+ ...storeEnv,
3234
+ AI_SERVER_URL: aiUrl,
3235
+ CORS_ORIGIN: overrideEnv.CORS_ORIGIN || folderEnv.CORS_ORIGIN || "",
3236
+ CORS_ORIGINS: overrideEnv.CORS_ORIGINS || folderEnv.CORS_ORIGINS || "",
3237
+ MAINTAINER_PRO_DATA_DIR: dataDir,
3238
+ MAINTAINER_PRO_SANDBOX_ID: String(ws.sandboxId || ""),
3239
+ };
3240
+
3241
+ try {
3242
+ const instance = await startAiServer({
3243
+ port,
3244
+ workspaceDir: folder,
3245
+ uiDir: folder,
3246
+ dataDir,
3247
+ env: instanceEnv,
3248
+ logger: createLogger(`ai-server:${shortId(ws.sandboxId)}`),
3249
+ label: `ai-server:${shortId(ws.sandboxId)}`,
3250
+ listen: true,
3251
+ });
3252
+ embeddedChat.set(ws.sandboxId, instance);
3253
+ ws.port = instance.port;
3254
+ persistWorkspaceEntry(cfg, ws);
3255
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
3256
+ log(`chat in-process for ${label} on ${instance.port} (${folder})`);
3257
+ return { port: instance.port, up: true, launched: true };
3258
+ } catch (err) {
3259
+ const message = err instanceof Error ? err.message : String(err);
2820
3260
  recordProcessProblem({
2821
3261
  sandboxId: ws.sandboxId,
2822
3262
  code: "ai_server_launch",
2823
3263
  role: "ai",
2824
- title: `Could not start the chat script (${label})`,
2825
- message: opened.error,
3264
+ title: `Could not start chat for ${label}`,
3265
+ message,
2826
3266
  resolution:
2827
- "Allow the bridge to open terminal windows, or start the script manually in that folder.",
3267
+ "Keep the bridge connected to Maintainer Pro, then Start Apps.",
2828
3268
  });
2829
3269
  return { port, up: false, launched: false };
2830
3270
  }
2831
- return { port, up: false, launched: true, starting: true };
2832
3271
  }
2833
3272
 
2834
3273
  function sleep(ms) {
@@ -2839,10 +3278,6 @@ function stopAllCloudflare() {
2839
3278
  for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
2840
3279
  }
2841
3280
 
2842
- function parseTryCloudflareUrl(text) {
2843
- return lastTryCloudflareUrl(text);
2844
- }
2845
-
2846
3281
  function killProcessesByCommand(fragment) {
2847
3282
  if (!fragment) return Promise.resolve();
2848
3283
  return new Promise((resolve) => {
@@ -2901,11 +3336,27 @@ function killPort(port) {
2901
3336
  });
2902
3337
  }
2903
3338
 
2904
- function stopCloudflare(sandboxId) {
3339
+ function stopCloudflare(sandboxId, folder) {
2905
3340
  const row = cloudflareTunnels.get(sandboxId);
2906
- const files = row?.tunnels?.map((t) => t.logFile).filter(Boolean) ?? [];
3341
+ const files = new Set(
3342
+ (row?.tunnels?.map((t) => t.logFile).filter(Boolean) ?? [])
3343
+ );
2907
3344
  cloudflareTunnels.delete(sandboxId);
2908
- return Promise.all(files.map((file) => killProcessesByCommand(file)));
3345
+ const logDir = folder
3346
+ ? dataDirFor(folder, sandboxId)
3347
+ : null;
3348
+ if (logDir && fs.existsSync(logDir)) {
3349
+ try {
3350
+ for (const name of fs.readdirSync(logDir)) {
3351
+ if (/^cf-.*\.log$/i.test(name) && !name.endsWith(".stale")) {
3352
+ files.add(path.join(logDir, name));
3353
+ }
3354
+ }
3355
+ } catch {
3356
+ /* ignore */
3357
+ }
3358
+ }
3359
+ return Promise.all([...files].map((file) => killProcessesByCommand(file)));
2909
3360
  }
2910
3361
 
2911
3362
  function forgetProcessLaunches(sandboxId) {
@@ -2918,12 +3369,18 @@ function forgetProcessLaunches(sandboxId) {
2918
3369
 
2919
3370
  async function stopWorkspaceApps(ws) {
2920
3371
  const folder = path.resolve(ws.folderPath || "");
2921
- const jobs = planHostJobs(folder, null, ws.projectInfo);
3372
+ const jobs = jobsFromHostApps(ws).length
3373
+ ? jobsFromHostApps(ws)
3374
+ : planHostJobs(folder, null, ws.projectInfo);
2922
3375
  const ports = new Set();
2923
3376
  if (ws.port) ports.add(Number(ws.port));
2924
3377
  for (const job of jobs) {
3378
+ if (job.port) ports.add(Number(job.port));
2925
3379
  if (job.preferredPort) ports.add(Number(job.preferredPort));
2926
3380
  }
3381
+ for (const app of Array.isArray(ws.hostApps) ? ws.hostApps : []) {
3382
+ if (app?.port) ports.add(Number(app.port));
3383
+ }
2927
3384
  if (ws.appUrl && isLocalAppUrl(ws.appUrl)) {
2928
3385
  try {
2929
3386
  const port = Number(new URL(ws.appUrl).port);
@@ -2933,12 +3390,13 @@ async function stopWorkspaceApps(ws) {
2933
3390
  }
2934
3391
  }
2935
3392
  log(`stopping local apps on ports ${[...ports].join(", ") || "(none)"}`);
3393
+ await stopEmbeddedChat(ws.sandboxId);
2936
3394
  await closeRememberedTerminals(ws.sandboxId);
2937
3395
  for (const port of ports) {
2938
3396
  await killPort(port);
2939
3397
  }
2940
3398
  forgetProcessLaunches(ws.sandboxId);
2941
- await sleep(400);
3399
+ await sleep(800);
2942
3400
  }
2943
3401
 
2944
3402
  async function waitUntilReachable(url, timeoutMs, label, onWait) {
@@ -2958,82 +3416,45 @@ async function waitUntilReachable(url, timeoutMs, label, onWait) {
2958
3416
  throw new Error(`${label} did not become reachable at ${url}`);
2959
3417
  }
2960
3418
 
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 };
3419
+ function applyBackendPublicEnv(env, url) {
3420
+ const backend = String(url || "").replace(/\/$/, "");
3421
+ if (!backend) return env;
3422
+ env.API_URL = backend;
3423
+ env.API_BASE_URL = backend;
3424
+ env.BACKEND_URL = backend;
3425
+ env.VITE_API_URL = backend;
3426
+ env.VITE_API_BASE_URL = backend;
3427
+ env.NEXT_PUBLIC_API_URL = backend;
3428
+ env.NEXT_PUBLIC_API_BASE_URL = backend;
3429
+ env.REACT_APP_API_URL = backend;
3430
+ return env;
3016
3431
  }
3017
3432
 
3018
- function uiPublicEnv(tunnels) {
3433
+ function uiPublicEnv(tunnels, ws) {
3019
3434
  /** @type {Record<string, string>} */
3020
3435
  const env = {};
3021
- if (tunnels.ai) {
3022
- const ai = String(tunnels.ai).replace(/\/$/, "");
3436
+ const aiApp = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).find(
3437
+ (app) => app.role === "ai-server" || app.id === "ai-server"
3438
+ ) || { id: "ai-server", role: "ai-server" };
3439
+ const ai =
3440
+ proxyUrlForApp(ws, aiApp) ||
3441
+ (tunnels.ai ? String(tunnels.ai).replace(/\/$/, "") : "");
3442
+ if (ai) {
3023
3443
  env.AI_SERVER_URL = ai;
3024
3444
  env.NEXT_PUBLIC_AI_SERVER_URL = ai;
3025
3445
  env.VITE_AI_SERVER_URL = ai;
3026
3446
  env.REACT_APP_AI_SERVER_URL = ai;
3027
3447
  }
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;
3448
+ const backendApp = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).find(
3449
+ (app) => isBackendApp(app)
3450
+ );
3451
+ const backendUrl =
3452
+ (backendApp ? proxyUrlForApp(ws, backendApp) : "") ||
3453
+ (typeof tunnels.backend === "string" && tunnels.backend) ||
3454
+ (backendApp ? publicUrlForApp(ws, backendApp) : "") ||
3455
+ "";
3456
+ if (backendUrl && backendApp) {
3457
+ applyBackendPublicEnv(env, backendUrl);
3037
3458
  }
3038
3459
  if (tunnels.ui) {
3039
3460
  const ui = String(tunnels.ui).replace(/\/$/, "");
@@ -3043,156 +3464,181 @@ function uiPublicEnv(tunnels) {
3043
3464
  env.NEXT_PUBLIC_APP_URL = ui;
3044
3465
  env.VITE_APP_URL = ui;
3045
3466
  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;
3467
+ } else if (tunnels.app) {
3468
+ const app = String(tunnels.app).replace(/\/$/, "");
3469
+ env.APP_URL = app;
3470
+ env.PUBLIC_URL = app;
3471
+ env.CORS_ORIGIN = app;
3472
+ env.NEXT_PUBLIC_APP_URL = app;
3473
+ env.VITE_APP_URL = app;
3474
+ env.REACT_APP_APP_URL = app;
3475
+ } else if (ai) {
3476
+ const hostUi = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).some(
3477
+ (app) => app.host || app.role === "ui" || app.role === "app"
3478
+ );
3479
+ if (!hostUi) {
3480
+ env.CORS_ORIGIN = ai;
3481
+ env.APP_URL = ai;
3482
+ env.PUBLIC_URL = ai;
3483
+ env.NEXT_PUBLIC_APP_URL = ai;
3484
+ env.VITE_APP_URL = ai;
3485
+ }
3053
3486
  }
3054
3487
  return env;
3055
3488
  }
3056
3489
 
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 };
3490
+ function publicUrlForApp(ws, app) {
3491
+ if (!app) return "";
3492
+ const proxied = proxyUrlForApp(ws, app);
3493
+ if (proxied) return proxied;
3494
+ const port = Number(app.port) || 0;
3495
+ return port ? `http://localhost:${port}` : "";
3071
3496
  }
3072
3497
 
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 || "");
3498
+ function envFromConfiguredMaps(ws) {
3499
+ /** @type {Record<string, string>} */
3500
+ const env = {};
3501
+ const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
3502
+ for (const app of apps) {
3503
+ const maps = Array.isArray(app.envMaps) ? app.envMaps : [];
3504
+ for (const row of maps) {
3505
+ const key = String(row?.key || "").trim();
3506
+ const sourceId = String(row?.sourceAppId || "").trim();
3507
+ if (!key || !sourceId) continue;
3508
+ const source =
3509
+ sourceId === "self"
3510
+ ? app
3511
+ : apps.find((item) => item.id === sourceId) || null;
3512
+ const url = publicUrlForApp(ws, source);
3513
+ if (url) env[key] = url;
3514
+ }
3515
+ }
3516
+ return env;
3517
+ }
3518
+
3519
+ function cloudflarePublicEnv(ws) {
3520
+ const tunnels =
3521
+ ws.cloudflare && typeof ws.cloudflare === "object" ? ws.cloudflare : {};
3522
+ const jobs = jobsFromHostApps(ws);
3523
+ const env = {
3524
+ ...envForWorkspacePorts(ws, jobs),
3525
+ ...uiPublicEnv(tunnels, ws),
3526
+ };
3527
+ const origins = [];
3528
+ const add = (value) => {
3529
+ const origin = originFromUrl(value);
3530
+ if (origin && !origins.includes(origin)) origins.push(origin);
3531
+ };
3532
+ add(tunnels.ui);
3533
+ add(tunnels.app);
3534
+ add(tunnels.backend);
3535
+ add(tunnels.ai);
3536
+ add(env.APP_URL);
3537
+ add(env.CORS_ORIGIN);
3538
+ for (const job of jobs) {
3539
+ if (job.port) add(`http://localhost:${job.port}`);
3540
+ }
3541
+ add(`http://localhost:${Number(ws.port) || 3100}`);
3542
+ if (origins.length) {
3543
+ env.CORS_ORIGIN = origins.join(",");
3544
+ env.CORS_ORIGINS = origins.join(",");
3545
+ }
3546
+ Object.assign(env, envFromConfiguredMaps(ws));
3547
+ return env;
3548
+ }
3549
+
3550
+ async function applyPublicUrlsToRunningApps(ws, cfg, opts = {}) {
3551
+ const folder = ws.folderPath ? path.resolve(ws.folderPath) : "";
3079
3552
  const progress =
3080
3553
  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}`);
3554
+ const tunnels =
3555
+ ws.cloudflare && typeof ws.cloudflare === "object" ? ws.cloudflare : {};
3556
+ const hasProxy = Boolean(ws.proxy?.token);
3557
+ if (
3558
+ !folder ||
3559
+ !fs.existsSync(folder) ||
3560
+ (!Object.keys(tunnels).length && !hasProxy)
3561
+ ) {
3562
+ return { restarted: [], rewritten: [] };
3563
+ }
3564
+ const env = {
3565
+ ...cloudflarePublicEnv(ws),
3086
3566
  };
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}`
3567
+ if (Object.keys(tunnels).length) writeTunnelEnv(ws, tunnels);
3568
+ const mappedKeys = Object.keys(envFromConfiguredMaps(ws));
3569
+ if (mappedKeys.length) {
3570
+ activity(
3571
+ ws.sandboxId,
3572
+ "info",
3573
+ `Injecting public URLs into process env for ${mappedKeys.join(", ")}`
3105
3574
  );
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
- }
3575
+ await progress(`Passing ${mappedKeys.join(", ")} via process env`);
3120
3576
  }
3121
3577
 
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
- );
3578
+ const probe = await probeRunningApps(ws, 1200);
3579
+ const runningRoles = new Set(
3580
+ (probe.hostApps || [])
3581
+ .filter((app) => app.running)
3582
+ .map((app) =>
3583
+ app.role === "ai-server" ? "ai-server" : app.role === "custom" ? "app" : app.role
3584
+ )
3585
+ );
3586
+ const reserved = reservedPortsFor(cfg, ws.sandboxId);
3587
+ const restarted = [];
3588
+
3589
+ if (probe.chatUp || runningRoles.has("ai-server") || embeddedChat.has(ws.sandboxId)) {
3590
+ await progress("Restarting in-process chat with public URLs and CORS…");
3591
+ launchedAt.delete(`${ws.sandboxId}:${folder}:ai`);
3592
+ await stopEmbeddedChat(ws.sandboxId);
3593
+ await startAiServerForWorkspace(ws, {
3594
+ reserved,
3595
+ cfg,
3596
+ port: ws.port,
3597
+ env,
3598
+ });
3599
+ restarted.push("ai-server");
3136
3600
  }
3137
3601
 
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
- })`
3602
+ const jobs = jobsFromHostApps(ws).filter((job) =>
3603
+ runningRoles.has(job.role === "custom" ? "app" : job.role)
3604
+ );
3605
+ if (jobs.length) {
3606
+ const roles = [...new Set(jobs.map((job) => job.role))];
3607
+ await progress(
3608
+ `Restarting ${roles.join(", ")} so they load the public URLs…`
3159
3609
  );
3610
+ for (const job of jobs) {
3611
+ launchedAt.delete(`${ws.sandboxId}:${folder}:${job.script}`);
3612
+ await killPort(job.port);
3613
+ }
3614
+ await sleep(1500);
3615
+ await ensureHostProcesses(ws, {
3616
+ reserved,
3617
+ cfg,
3618
+ onlyRoles: roles,
3619
+ extraEnv: env,
3620
+ force: true,
3621
+ plannedJobs: jobsFromHostApps(ws),
3622
+ });
3623
+ restarted.push(...roles);
3160
3624
  }
3161
3625
 
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
- })`
3626
+ if (restarted.length) {
3627
+ activity(
3628
+ ws.sandboxId,
3629
+ "info",
3630
+ `Restarted ${[...new Set(restarted)].join(", ")} with public URLs`
3182
3631
  );
3183
3632
  }
3633
+ return { restarted: [...new Set(restarted)], rewritten: [], env };
3634
+ }
3184
3635
 
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 };
3636
+ function writeTunnelEnv(ws, tunnels) {
3637
+ const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
3638
+ if (!folder || !fs.existsSync(folder)) return { ok: false, env: {} };
3639
+ clearCloudflareTunnelFile(folder);
3640
+ const env = uiPublicEnv(tunnels, ws);
3641
+ return { ok: true, env };
3196
3642
  }
3197
3643
 
3198
3644
  function reservedPortsFor(cfg, sandboxId) {
@@ -3211,271 +3657,35 @@ function appsWanted(ws) {
3211
3657
 
3212
3658
  async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
3213
3659
  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
3660
  const progress = (message) =>
3264
3661
  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
- );
3662
+ await progress("Using Maintainer Pro share URLs (no Cloudflare).");
3274
3663
  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
- );
3664
+ await stopCloudflare(sandboxId, ws.folderPath);
3665
+ } catch {
3666
+ /* leftover tunnels */
3437
3667
  }
3438
-
3439
- const appUrl = tunnels.ui || tunnels.ai;
3440
- ws.cloudflareUrl = appUrl;
3441
- ws.cloudflare = tunnels;
3442
- ws.appUrl = appUrl;
3443
3668
  ws.cloudflarePending = false;
3444
- ws.appsRequested = true;
3445
3669
  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
- }
3670
+ const status = await reconcileWorkspacePresence(ws, cfg, {
3671
+ timeoutMs: 2500,
3672
+ });
3673
+ const share = proxyUrlForApp(
3674
+ ws,
3675
+ hostAppOf(ws) || { id: "ui", role: "ui", host: true }
3676
+ );
3677
+ return {
3678
+ sandboxId,
3679
+ folderPath: ws.folderPath,
3680
+ port: ws.port,
3681
+ pending: false,
3682
+ cloudflarePending: false,
3683
+ waitingForStart: false,
3684
+ appUrl: status.host.appUrl || share,
3685
+ origins: status.host.origins,
3686
+ publicUrl: share || null,
3687
+ cloudflare: false,
3688
+ };
3479
3689
  }
3480
3690
 
3481
3691
  async function startAppsForWorkspace(ws, cfg, opts = {}) {
@@ -3483,12 +3693,17 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3483
3693
  await reportActionProgress(
3484
3694
  cfg,
3485
3695
  opts.actionId,
3486
- `Checking running apps and Cloudflare for ${label}…`
3696
+ `Checking running apps for ${label}…`
3487
3697
  );
3488
3698
 
3489
- // Status + env sync first (never starts Cloudflare here).
3699
+ try {
3700
+ await stopCloudflare(ws.sandboxId, ws.folderPath);
3701
+ } catch {
3702
+ /* leftover cloudflared */
3703
+ }
3704
+ ws.cloudflarePending = false;
3705
+
3490
3706
  let status = await reconcileWorkspacePresence(ws, cfg, {
3491
- writeEnv: true,
3492
3707
  timeoutMs: 2500,
3493
3708
  });
3494
3709
  log(
@@ -3498,15 +3713,9 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3498
3713
  status.probe.hosts
3499
3714
  .map((h) => `${h.role}:${h.up ? "up" : "down"}`)
3500
3715
  .join(",") || "none"
3501
- } cloudflare=${status.usingCloudflare ? "yes" : "no"}`
3716
+ }`
3502
3717
  );
3503
3718
 
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
3719
  await reportActionProgress(
3511
3720
  cfg,
3512
3721
  opts.actionId,
@@ -3516,16 +3725,15 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3516
3725
  const reserved = reservedPortsFor(cfg, ws.sandboxId);
3517
3726
  const plan = await prepareWorkspaceLaunch(ws, cfg, reserved);
3518
3727
 
3519
- // Re-sync env after port assignment (local or cloudflare urls).
3520
3728
  status = await reconcileWorkspacePresence(ws, cfg, {
3521
- writeEnv: true,
3522
3729
  timeoutMs: 800,
3523
3730
  });
3524
3731
 
3525
- const cfEnv =
3526
- status.usingCloudflare && status.cloudflare
3527
- ? uiPublicEnv(status.cloudflare)
3528
- : {};
3732
+ const shareEnv = {
3733
+ ...envForWorkspacePorts(ws, plan.jobs),
3734
+ ...uiPublicEnv({}, ws),
3735
+ ...envFromConfiguredMaps(ws),
3736
+ };
3529
3737
 
3530
3738
  if (!cfg.noAiServer) {
3531
3739
  if (status.probe.chatUp) {
@@ -3538,7 +3746,7 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3538
3746
  reserved,
3539
3747
  cfg,
3540
3748
  port: plan.aiPort,
3541
- env: cfEnv,
3749
+ env: shareEnv,
3542
3750
  });
3543
3751
  await sleep(1500);
3544
3752
  }
@@ -3553,12 +3761,15 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3553
3761
  const probed = status.probe.hosts.find((h) => h.role === job.role);
3554
3762
  return probed?.up ? { ...job, up: true, port: probed.port } : job;
3555
3763
  }),
3556
- extraEnv: cfEnv,
3764
+ extraEnv: shareEnv,
3557
3765
  });
3558
3766
  await sleep(800);
3559
3767
 
3768
+ await applyPublicUrlsToRunningApps(ws, cfg, {
3769
+ onProgress: (message) => reportActionProgress(cfg, opts.actionId, message),
3770
+ });
3771
+
3560
3772
  status = await reconcileWorkspacePresence(ws, cfg, {
3561
- writeEnv: true,
3562
3773
  timeoutMs: 2500,
3563
3774
  });
3564
3775
  await inspectHostJobs(ws);
@@ -3570,11 +3781,11 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3570
3781
  log(
3571
3782
  `start apps done ${label}: chat=${ws.port} chatUp=${
3572
3783
  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}` : ""}`
3784
+ } hosts=${startedHosts.join(",") || "none"}${
3785
+ status.host.appUrl ? ` app=${status.host.appUrl}` : ""
3786
+ } origins=${status.host.origins.join(",") || "none"}${
3787
+ warning ? ` warning=${warning}` : ""
3788
+ }`
3578
3789
  );
3579
3790
  return {
3580
3791
  up: status.aiServerUp,
@@ -3584,12 +3795,196 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3584
3795
  port: ws.port,
3585
3796
  appUrl: status.host.appUrl || ws.appUrl,
3586
3797
  origins: status.host.origins,
3587
- cloudflare: status.usingCloudflare,
3798
+ publicUrl:
3799
+ proxyUrlForApp(
3800
+ ws,
3801
+ hostAppOf(ws) || { id: "ui", role: "ui", host: true }
3802
+ ) || null,
3803
+ cloudflare: false,
3804
+ hostApps: status.probe.hostApps || ws.hostApps || [],
3588
3805
  processIssues,
3589
3806
  warning,
3590
3807
  };
3591
3808
  }
3592
3809
 
3810
+ function findHostApp(ws, payload = {}) {
3811
+ const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
3812
+ const id = String(payload.appId || payload.id || "").trim();
3813
+ const role = String(payload.role || "").trim();
3814
+ const port = Number(payload.port) || 0;
3815
+ return (
3816
+ apps.find((app) => id && app.id === id) ||
3817
+ apps.find((app) => role && app.role === role) ||
3818
+ apps.find((app) => port && Number(app.port) === port) ||
3819
+ null
3820
+ );
3821
+ }
3822
+
3823
+ async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
3824
+ const app = findHostApp(ws, payload);
3825
+ if (!app) {
3826
+ activity(ws.sandboxId, "error", "Start app failed: unknown app/port");
3827
+ return { error: "Unknown app. Detect or add the port first." };
3828
+ }
3829
+ const progress = (message) => reportActionProgress(cfg, opts.actionId, message);
3830
+ const alreadyUp = await portIsLive(app.port, 1200);
3831
+ if (alreadyUp) {
3832
+ activity(ws.sandboxId, "info", `${app.name} already running on ${app.port}`);
3833
+ await progress(`${app.name} is already running on port ${app.port}.`);
3834
+ const status = await reconcileWorkspacePresence(ws, cfg, {
3835
+ timeoutMs: 1200,
3836
+ });
3837
+ return {
3838
+ app,
3839
+ running: true,
3840
+ port: app.port,
3841
+ hostApps: status.probe.hostApps || [],
3842
+ log: activityLogFor(ws.sandboxId),
3843
+ };
3844
+ }
3845
+ activity(ws.sandboxId, "info", `Start ${app.name} on port ${app.port}`);
3846
+ await progress(`Starting ${app.name} on port ${app.port}…`);
3847
+ ws.appsRequested = true;
3848
+ persistWorkspaceEntry(cfg, ws);
3849
+ const publicEnv = {
3850
+ ...cloudflarePublicEnv(ws),
3851
+ };
3852
+ if (app.role === "ai-server") {
3853
+ const reserved = reservedPortsFor(cfg, ws.sandboxId);
3854
+ const chat = await discoverChatPort(ws, 1200);
3855
+ if (chat.up) {
3856
+ activity(ws.sandboxId, "info", `AI server already running on ${chat.port}`);
3857
+ ws.port = chat.port;
3858
+ } else {
3859
+ await startAiServerForWorkspace(ws, {
3860
+ reserved,
3861
+ cfg,
3862
+ port: Number(app.port) || ws.port || 3100,
3863
+ env: publicEnv,
3864
+ });
3865
+ }
3866
+ } else {
3867
+ const reserved = reservedPortsFor(cfg, ws.sandboxId);
3868
+ await ensureHostProcesses(ws, {
3869
+ reserved,
3870
+ cfg,
3871
+ onlyRoles: [app.role === "custom" ? "app" : app.role],
3872
+ extraEnv: publicEnv,
3873
+ force: true,
3874
+ plannedJobs: jobsFromHostApps(ws),
3875
+ });
3876
+ }
3877
+ const status = await reconcileWorkspacePresence(ws, cfg, {
3878
+ timeoutMs: 2500,
3879
+ });
3880
+ const live = (status.probe.hostApps || []).find((row) => row.id === app.id);
3881
+ activity(
3882
+ ws.sandboxId,
3883
+ live?.running ? "info" : "warn",
3884
+ live?.running
3885
+ ? `${app.name} is running on ${live.port}`
3886
+ : `${app.name} did not answer on port ${app.port}. Check the MP terminal.`
3887
+ );
3888
+ return {
3889
+ app,
3890
+ running: Boolean(live?.running),
3891
+ port: live?.port || app.port,
3892
+ hostApps: status.probe.hostApps || [],
3893
+ log: activityLogFor(ws.sandboxId),
3894
+ warning: live?.running
3895
+ ? null
3896
+ : `${app.name} did not answer on port ${app.port}. Check the terminal on that computer.`,
3897
+ };
3898
+ }
3899
+
3900
+ async function stopSingleApp(ws, app) {
3901
+ const port = Number(app.port) || 0;
3902
+ const role =
3903
+ app.role === "ai-server"
3904
+ ? "ai-server"
3905
+ : app.role === "custom"
3906
+ ? "app"
3907
+ : app.role || "app";
3908
+ const folder = path.resolve(ws.folderPath || "");
3909
+ await closeWindowsByTitle(`MP-${role}-${port}`);
3910
+ if (role === "app") await closeWindowsByTitle(`MP-custom-${port}`);
3911
+ if (app.role === "ai-server" || app.id === "ai-server") {
3912
+ await stopEmbeddedChat(ws.sandboxId);
3913
+ }
3914
+ if (port) {
3915
+ await killPort(port);
3916
+ const until = Date.now() + 4000;
3917
+ while (Date.now() < until && (await portIsLive(port, 300))) {
3918
+ await killPort(port);
3919
+ await sleep(300);
3920
+ }
3921
+ }
3922
+ const job = jobsFromHostApps(ws).find((row) => row.appId === app.id);
3923
+ if (job) launchedAt.delete(`${ws.sandboxId}:${folder}:${job.script}`);
3924
+ launchedAt.delete(`${ws.sandboxId}:${folder}:ai`);
3925
+ await sleep(800);
3926
+ }
3927
+
3928
+ async function restartSingleApp(ws, cfg, payload = {}, opts = {}) {
3929
+ const app = findHostApp(ws, payload);
3930
+ if (!app) {
3931
+ activity(ws.sandboxId, "error", "Restart app failed: unknown app/port");
3932
+ return { error: "Unknown app. Detect or add the port first." };
3933
+ }
3934
+ const progress = (message) =>
3935
+ reportActionProgress(cfg, opts.actionId, message);
3936
+ activity(ws.sandboxId, "info", `Restart ${app.name} on ${app.port}`);
3937
+ await progress(`Stopping ${app.name} on port ${app.port}…`);
3938
+ await stopSingleApp(ws, app);
3939
+ return startSingleApp(ws, cfg, payload, opts);
3940
+ }
3941
+
3942
+ async function startCloudflareForApp(ws, cfg, payload = {}, opts = {}) {
3943
+ const app = findHostApp(ws, payload);
3944
+ if (!app) {
3945
+ activity(ws.sandboxId, "error", "Share URL failed: unknown app/port");
3946
+ return { error: "Unknown app. Detect or add the port first." };
3947
+ }
3948
+ const progress = (message) => reportActionProgress(cfg, opts.actionId, message);
3949
+ if (usesBridgeProxy(app)) {
3950
+ const shareUrl = proxyUrlForApp(ws, app);
3951
+ if (!shareUrl) {
3952
+ return {
3953
+ error:
3954
+ "Share URL is not ready. Keep the bridge connected to Maintainer Pro.",
3955
+ };
3956
+ }
3957
+ await progress(`Using the Maintainer Pro share URL for ${app.name}…`);
3958
+ const started = await startSingleApp(ws, cfg, { appId: app.id }, opts);
3959
+ if (started.error) return started;
3960
+ const applied = await applyPublicUrlsToRunningApps(ws, cfg, {
3961
+ onProgress: progress,
3962
+ });
3963
+ const status = await reconcileWorkspacePresence(ws, cfg, {
3964
+ timeoutMs: 2500,
3965
+ });
3966
+ activity(ws.sandboxId, "info", `${app.name} share URL: ${shareUrl}`);
3967
+ return {
3968
+ app,
3969
+ running: Boolean(started.running),
3970
+ port: started.port,
3971
+ publicUrl: shareUrl,
3972
+ cloudflareUrl: null,
3973
+ appUrl: status.host.appUrl,
3974
+ origins: status.host.origins,
3975
+ hostApps: status.probe.hostApps || [],
3976
+ rewritten: applied.rewritten,
3977
+ restarted: applied.restarted,
3978
+ log: activityLogFor(ws.sandboxId),
3979
+ };
3980
+ }
3981
+ return {
3982
+ error:
3983
+ "Share URL is not ready. Keep the bridge connected to Maintainer Pro.",
3984
+ app,
3985
+ };
3986
+ }
3987
+
3593
3988
  async function ensureHostProcesses(ws, opts = {}) {
3594
3989
  const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
3595
3990
  const cfg = opts.cfg || null;
@@ -3708,7 +4103,13 @@ async function ensureHostProcesses(ws, opts = {}) {
3708
4103
  continue;
3709
4104
  }
3710
4105
  started.push(job.role);
3711
- log(`start ${job.role} launched ${label} on ${port}: ${command}`);
4106
+ log(
4107
+ `start ${job.role} launched ${label} on ${port}: ${command}${
4108
+ Object.keys(extraEnv).length
4109
+ ? ` env=${Object.keys(extraEnv).join(",")}`
4110
+ : ""
4111
+ }`
4112
+ );
3712
4113
  if (
3713
4114
  (job.role === "ui" || job.role === "app") &&
3714
4115
  (!ws.appUrl || isLocalAppUrl(ws.appUrl))
@@ -3724,11 +4125,13 @@ async function ensureHostProcesses(ws, opts = {}) {
3724
4125
 
3725
4126
  async function inspectHostJobs(ws) {
3726
4127
  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
- );
4128
+ const jobs = jobsFromHostApps(ws).length
4129
+ ? jobsFromHostApps(ws)
4130
+ : planHostJobs(
4131
+ folder,
4132
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
4133
+ ws.projectInfo
4134
+ );
3732
4135
  const label = ws.sandboxName || "this sandbox";
3733
4136
  const hosts = [];
3734
4137
  for (const job of jobs) {
@@ -3788,7 +4191,7 @@ async function setupWorkspace(cfg, action) {
3788
4191
  }
3789
4192
  }
3790
4193
  let port = requestedPort;
3791
- if (await probeUrl(`http://127.0.0.1:${requestedPort}/embed-config.js`)) {
4194
+ if (await isChatServerOnPort(requestedPort)) {
3792
4195
  reserved.add(requestedPort);
3793
4196
  log(`setup chat port ${requestedPort} already up`);
3794
4197
  } else {
@@ -3810,6 +4213,12 @@ async function setupWorkspace(cfg, action) {
3810
4213
  throw new Error("setup_workspace requires folderPath and sandboxId");
3811
4214
  }
3812
4215
  const resolved = path.resolve(folderPath);
4216
+ const allowed = collectOfferedFolders();
4217
+ if (!allowed.some((root) => pathInside(resolved, root))) {
4218
+ throw new Error(
4219
+ "That folder is outside the directory where the bridge is running. Start the bridge from the project folder you want to share."
4220
+ );
4221
+ }
3813
4222
  fs.mkdirSync(resolved, { recursive: true });
3814
4223
 
3815
4224
  const config = await api(
@@ -3836,30 +4245,10 @@ async function setupWorkspace(cfg, action) {
3836
4245
  const corsOrigin = client.corsOrigin || aiOrigin;
3837
4246
  const appUrl = client.appUrl || corsOrigin;
3838
4247
 
3839
- const envPath = path.join(resolved, ".env");
3840
4248
  const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
3841
4249
  ? config.aiIgnorePaths
3842
4250
  : [];
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);
4251
+ const access = applyAccessPolicy(resolved, partnerIgnorePaths, sandboxId);
3863
4252
 
3864
4253
  cfg.workspaces = cfg.workspaces || [];
3865
4254
  const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
@@ -3874,36 +4263,42 @@ async function setupWorkspace(cfg, action) {
3874
4263
  sameOrigin: Boolean(client.sameOrigin),
3875
4264
  appsRequested: false,
3876
4265
  cloudflarePending: false,
4266
+ store: {
4267
+ serverKey: String(config.env?.MAINTAINER_PRO_API_KEY || "").trim(),
4268
+ clientKey: String(
4269
+ config.env?.MAINTAINER_PRO_CLIENT_API_KEY ||
4270
+ config.env?.NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY ||
4271
+ ""
4272
+ ).trim(),
4273
+ },
3877
4274
  };
3878
4275
  if (existing >= 0) cfg.workspaces[existing] = entry;
3879
4276
  else cfg.workspaces.push(entry);
3880
- if (!cfg.offeredFolders) cfg.offeredFolders = [];
3881
- if (!cfg.offeredFolders.includes(resolved)) cfg.offeredFolders.push(resolved);
3882
4277
  saveConfig(cfg);
3883
4278
 
3884
- const projectInfo = await inspectProjectWithAiCli(entry, {
4279
+ const ports = await resolveWorkspaceHostApps(entry, {
3885
4280
  cfg,
3886
- extraContext: [
3887
- `Scaffold kind: ${client.kind}`,
3888
- `Chat script port: ${port}`,
3889
- client.notes.join(" "),
3890
- ]
3891
- .filter(Boolean)
3892
- .join("\n"),
4281
+ allowAi: false,
3893
4282
  });
4283
+ const needsReview =
4284
+ Boolean(ports.confused) ||
4285
+ !(ports.apps || []).some((app) => app && app.role !== "ai-server");
4286
+ if (needsReview) {
4287
+ activity(
4288
+ sandboxId,
4289
+ "warn",
4290
+ "Review the suggested apps and ports in Maintainer Pro before Start Apps."
4291
+ );
4292
+ }
4293
+ // Do not auto-repair the repo on attach — interactive Review setup handles suggestions.
4294
+ const projectInfo = entry.projectInfo || null;
3894
4295
 
3895
4296
  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
4297
 
3903
4298
  const openUrl = client.sameOrigin
3904
4299
  ? `http://localhost:${entry.port}`
3905
4300
  : entry.appUrl || appUrl;
3906
- const aiServerUp = await probeUrl(`http://127.0.0.1:${entry.port}/embed-config.js`);
4301
+ const aiServerUp = await isChatServerOnPort(entry.port);
3907
4302
  if (aiServerUp) {
3908
4303
  clearProcessProblem(sandboxId, "ai_server_launch", "ai");
3909
4304
  clearProcessProblem(sandboxId, "apps_not_started");
@@ -3914,7 +4309,9 @@ async function setupWorkspace(cfg, action) {
3914
4309
  );
3915
4310
  const waitingForStart = !aiServerUp;
3916
4311
  const warning = waitingForStart
3917
- ? "Folder is attached in Maintainer Pro. Use Start Apps when you want to launch the apps."
4312
+ ? needsReview
4313
+ ? "Folder attached. Review the suggested apps and ports in Maintainer Pro, then Start Apps."
4314
+ : "Folder is attached in Maintainer Pro. Review apps if needed, then use Start Apps."
3918
4315
  : processIssues[0]?.message || null;
3919
4316
 
3920
4317
  for (const note of client.notes) log(`setup note ${note}`);
@@ -3933,7 +4330,7 @@ async function setupWorkspace(cfg, action) {
3933
4330
  origins: host.origins.length
3934
4331
  ? host.origins
3935
4332
  : [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
3936
- wroteEnv: true,
4333
+ wroteEnv: false,
3937
4334
  clientKind: client.kind,
3938
4335
  clientFiles: client.filesWritten,
3939
4336
  clientNotes: client.notes,
@@ -3943,11 +4340,98 @@ async function setupWorkspace(cfg, action) {
3943
4340
  processIssues,
3944
4341
  warning,
3945
4342
  waitingForStart,
4343
+ needsReview,
4344
+ hostApps: ports.apps || entry.hostApps || [],
4345
+ reasons: ports.reasons || [],
3946
4346
  projectInfo,
3947
4347
  ignorePaths: access.ignorePaths,
3948
4348
  };
3949
4349
  }
3950
4350
 
4351
+ async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
4352
+ const folder = path.resolve(ws.folderPath || "");
4353
+ const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
4354
+ activity(ws.sandboxId, "info", `Suggesting setup for ${label} from config files`);
4355
+ try {
4356
+ const cli = await loadAiCli();
4357
+ if (typeof cli.proposeHostAppsFromConfig !== "function") {
4358
+ throw new Error(
4359
+ "ai-cli is missing proposeHostAppsFromConfig — update @maintainer-pro/ai-cli"
4360
+ );
4361
+ }
4362
+ const proposal = await cli.proposeHostAppsFromConfig({
4363
+ workspaceDir: folder,
4364
+ appName: ws.applicationName || ws.sandboxName,
4365
+ preferredAiPort: Number(ws.port) || 3100,
4366
+ allowAi: opts.allowAi !== false,
4367
+ });
4368
+ const previous = Array.isArray(ws.hostApps) ? ws.hostApps : [];
4369
+ const apps = (proposal.apps || []).map((app) => {
4370
+ const match = previous.find((row) => row && row.id === app.id);
4371
+ if (!match || (app.envMaps && app.envMaps.length)) return app;
4372
+ return { ...app, envMaps: match.envMaps || [] };
4373
+ });
4374
+ // Keep last known apps on the workspace; do not auto-apply proposal.
4375
+ if (!Array.isArray(ws.hostApps) || !ws.hostApps.length) {
4376
+ ws.hostApps = apps;
4377
+ persistWorkspaceEntry(cfg, ws);
4378
+ }
4379
+ activity(
4380
+ ws.sandboxId,
4381
+ proposal.needsReview ? "warn" : "info",
4382
+ `setup suggestion ${proposal.confidence}${proposal.usedAi ? " + ai" : ""}: ${
4383
+ proposal.projectSummary || apps.map((a) => `${a.name}:${a.port}`).join(", ")
4384
+ }`
4385
+ );
4386
+ for (const reason of proposal.reasons || []) {
4387
+ activity(ws.sandboxId, "warn", reason);
4388
+ }
4389
+ return {
4390
+ proposal: {
4391
+ apps,
4392
+ alternatives: proposal.alternatives || [],
4393
+ reasons: proposal.reasons || [],
4394
+ confidence: proposal.confidence,
4395
+ projectSummary: proposal.projectSummary || "",
4396
+ usedAi: Boolean(proposal.usedAi),
4397
+ needsReview: Boolean(proposal.needsReview),
4398
+ fingerprint: proposal.fingerprint || null,
4399
+ },
4400
+ hostApps: apps,
4401
+ reasons: proposal.reasons || [],
4402
+ usedAi: Boolean(proposal.usedAi),
4403
+ needsReview: Boolean(proposal.needsReview),
4404
+ log: activityLogFor(ws.sandboxId),
4405
+ };
4406
+ } catch (err) {
4407
+ const message = err instanceof Error ? err.message : String(err);
4408
+ activity(ws.sandboxId, "warn", `setup suggestion failed: ${message}`);
4409
+ const fallback = await resolveWorkspaceHostApps(ws, {
4410
+ cfg,
4411
+ force: true,
4412
+ ignoreDesired: true,
4413
+ allowAi: false,
4414
+ });
4415
+ return {
4416
+ proposal: {
4417
+ apps: fallback.apps,
4418
+ alternatives: [],
4419
+ reasons: [...(fallback.reasons || []), message],
4420
+ confidence: "low",
4421
+ projectSummary: "Could not finish AI suggest — showing file detection.",
4422
+ usedAi: false,
4423
+ needsReview: true,
4424
+ fingerprint: fallback.fingerprint || null,
4425
+ },
4426
+ hostApps: fallback.apps,
4427
+ reasons: fallback.reasons || [],
4428
+ usedAi: false,
4429
+ needsReview: true,
4430
+ log: activityLogFor(ws.sandboxId),
4431
+ };
4432
+ }
4433
+ }
4434
+
3951
4435
  async function runActions(cfg, actions) {
3952
4436
  if (!actions.length) return;
3953
4437
  log(`actions received ${actions.length}: ${actions.map((a) => a.code).join(", ")}`);
@@ -3960,9 +4444,11 @@ async function runActions(cfg, actions) {
3960
4444
  let result = {};
3961
4445
  try {
3962
4446
  if (action.code === "browse") {
3963
- const p = String(action.payload?.path || process.cwd());
4447
+ const allowed = collectOfferedFolders();
4448
+ const p = String(action.payload?.path || "roots");
3964
4449
  log(`${label} browse ${p}`);
3965
- result = listDirEntries(p);
4450
+ result = listDirEntries(p, allowed);
4451
+ if (result.error) ok = false;
3966
4452
  log(`${label} browse ${result.entries?.length ?? 0} entries`);
3967
4453
  } else if (action.code === "setup_workspace") {
3968
4454
  result = await setupWorkspace(cfg, action);
@@ -3984,7 +4470,11 @@ async function runActions(cfg, actions) {
3984
4470
  const partnerIgnorePaths = Array.isArray(action.payload?.aiIgnorePaths)
3985
4471
  ? action.payload.aiIgnorePaths
3986
4472
  : [];
3987
- const access = applyAccessPolicy(ws.folderPath, partnerIgnorePaths);
4473
+ const access = applyAccessPolicy(
4474
+ ws.folderPath,
4475
+ partnerIgnorePaths,
4476
+ ws.sandboxId
4477
+ );
3988
4478
  log(
3989
4479
  `access policy synced for ${ws.folderPath} (${access.ignorePaths.length} ignore rules)`
3990
4480
  );
@@ -4000,14 +4490,96 @@ async function runActions(cfg, actions) {
4000
4490
  if (!ws) {
4001
4491
  log(`${label} recheck: no local workspace`);
4002
4492
  }
4003
- const projectInfo = ws
4004
- ? await inspectProjectWithAiCli(ws, { cfg, force: true })
4493
+ const hostApps = ws
4494
+ ? await resolveWorkspaceHostApps(ws, { cfg, force: true, allowAi: false })
4005
4495
  : null;
4006
4496
  result = {
4007
4497
  recheckedAt: new Date().toISOString(),
4008
- projectInfo,
4498
+ hostApps: hostApps?.apps || [],
4499
+ projectInfo: ws?.projectInfo || null,
4009
4500
  };
4010
- } else if (action.code === "start_ai_server") {
4501
+ } else if (action.code === "redetect_ports" || action.code === "propose_setup") {
4502
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
4503
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4504
+ if (!ws) {
4505
+ ok = false;
4506
+ result = { error: "No folder is attached for this sandbox" };
4507
+ } else {
4508
+ activity(
4509
+ ws.sandboxId,
4510
+ "info",
4511
+ action.code === "propose_setup"
4512
+ ? "Setup suggestions requested from Maintainer Pro"
4513
+ : "Detect ports requested from Maintainer Pro"
4514
+ );
4515
+ result = await proposeSetupForWorkspace(ws, cfg, {
4516
+ allowAi: action.payload?.allowAi !== false,
4517
+ });
4518
+ }
4519
+ } else if (action.code === "update_host_apps") {
4520
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
4521
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4522
+ if (!ws) {
4523
+ ok = false;
4524
+ result = { error: "No folder is attached for this sandbox" };
4525
+ } else {
4526
+ const desired = Array.isArray(action.payload?.hostApps)
4527
+ ? action.payload.hostApps
4528
+ : [];
4529
+ activity(
4530
+ ws.sandboxId,
4531
+ "info",
4532
+ `Applying ports from Maintainer Pro: ${desired
4533
+ .map((app) => `${app.name || app.id}:${app.port}`)
4534
+ .join(", ")}`
4535
+ );
4536
+ const resolved = await resolveWorkspaceHostApps(ws, {
4537
+ cfg,
4538
+ desired,
4539
+ allowAi: false,
4540
+ });
4541
+ result = { hostApps: resolved.apps, log: activityLogFor(ws.sandboxId) };
4542
+ }
4543
+ } else if (action.code === "start_app") {
4544
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
4545
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4546
+ if (!ws) {
4547
+ ok = false;
4548
+ result = { error: "No folder is attached for this sandbox" };
4549
+ } else {
4550
+ result = await startSingleApp(ws, cfg, action.payload || {}, {
4551
+ actionId: action.id,
4552
+ });
4553
+ if (result.error) ok = false;
4554
+ }
4555
+ } else if (action.code === "restart_app") {
4556
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
4557
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4558
+ if (!ws) {
4559
+ ok = false;
4560
+ result = { error: "No folder is attached for this sandbox" };
4561
+ } else {
4562
+ result = await restartSingleApp(ws, cfg, action.payload || {}, {
4563
+ actionId: action.id,
4564
+ });
4565
+ if (result.error) ok = false;
4566
+ }
4567
+ } else if (action.code === "start_cloudflare_app") {
4568
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
4569
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4570
+ if (!ws) {
4571
+ ok = false;
4572
+ result = { error: "No folder is attached for this sandbox" };
4573
+ } else {
4574
+ result = await startCloudflareForApp(ws, cfg, action.payload || {}, {
4575
+ actionId: action.id,
4576
+ });
4577
+ if (result.error) ok = false;
4578
+ }
4579
+ } else if (
4580
+ action.code === "start_ai_server" ||
4581
+ action.code === "restart_apps"
4582
+ ) {
4011
4583
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
4012
4584
  const ws =
4013
4585
  (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
@@ -4023,10 +4595,24 @@ async function runActions(cfg, actions) {
4023
4595
  result = { error: "No workspace or --no-ai-server" };
4024
4596
  warn(`${label} skipped: ${result.error}`);
4025
4597
  } else {
4598
+ const restarting = action.code === "restart_apps";
4599
+ log(
4600
+ `${label} ${restarting ? "restart" : "start"} ${ws.folderPath}`
4601
+ );
4602
+ if (restarting) {
4603
+ await reportActionProgress(
4604
+ cfg,
4605
+ action.id,
4606
+ `Stopping apps for ${ws.sandboxName || shortId(ws.sandboxId)}…`
4607
+ );
4608
+ await stopWorkspaceApps(ws);
4609
+ }
4026
4610
  // Prefer cached project inspect; only call ai-cli when missing or
4027
4611
  // after a failed start that used the cache.
4028
- log(`${label} start ${ws.folderPath}`);
4029
- let projectInfo = await inspectProjectWithAiCli(ws, { cfg });
4612
+ let projectInfo = await resolveWorkspaceHostApps(ws, {
4613
+ cfg,
4614
+ allowAi: false,
4615
+ });
4030
4616
  let started = await startAppsForWorkspace(ws, cfg, {
4031
4617
  actionId: action.id,
4032
4618
  });
@@ -4182,7 +4768,6 @@ async function collectWorkspaceStates(cfg) {
4182
4768
  for (const ws of cfg.workspaces || []) {
4183
4769
  const folder = path.resolve(ws.folderPath || "");
4184
4770
  const status = await reconcileWorkspacePresence(ws, cfg, {
4185
- writeEnv: true,
4186
4771
  timeoutMs: 800,
4187
4772
  });
4188
4773
  localStates.push({
@@ -4194,14 +4779,13 @@ async function collectWorkspaceStates(cfg) {
4194
4779
  appsRunning: status.appsRunning,
4195
4780
  startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
4196
4781
  appUrl:
4197
- status.appsRunning || status.usingCloudflare
4782
+ status.appsRunning
4198
4783
  ? status.host.appUrl || ws.appUrl || null
4199
4784
  : null,
4200
- origins:
4201
- status.appsRunning || status.usingCloudflare
4202
- ? status.host.origins
4203
- : [],
4785
+ origins: status.appsRunning ? status.host.origins : [],
4204
4786
  appsRequested: appsWanted(ws) || status.appsRunning,
4787
+ hostApps: status.probe.hostApps || ws.hostApps || [],
4788
+ activityLog: activityLogFor(ws.sandboxId),
4205
4789
  });
4206
4790
  }
4207
4791
  return localStates;
@@ -4213,24 +4797,36 @@ const lastHostReports = new Map();
4213
4797
  function syncAssignedWorkspaces(cfg, remotes) {
4214
4798
  if (!Array.isArray(remotes)) return;
4215
4799
  for (const remote of remotes) {
4216
- const local = (cfg.workspaces || []).find(
4800
+ let local = (cfg.workspaces || []).find(
4217
4801
  (w) => w.sandboxId === remote.sandboxId
4218
4802
  );
4219
4803
  if (!local) {
4220
4804
  cfg.workspaces = cfg.workspaces || [];
4221
- cfg.workspaces.push({
4805
+ local = {
4222
4806
  sandboxId: remote.sandboxId,
4223
4807
  folderPath: remote.folderPath,
4224
4808
  port: remote.port,
4225
4809
  sandboxName: remote.sandboxName,
4226
4810
  applicationName: remote.applicationName,
4227
- });
4811
+ };
4812
+ cfg.workspaces.push(local);
4228
4813
  saveConfig(cfg);
4229
4814
  } else if (local.folderPath !== remote.folderPath) {
4230
4815
  local.folderPath = remote.folderPath;
4231
4816
  local.port = remote.port;
4232
4817
  saveConfig(cfg);
4233
4818
  }
4819
+ if (Array.isArray(remote.hostApps) && remote.hostApps.length) {
4820
+ const localCount = Array.isArray(local.hostApps) ? local.hostApps.length : 0;
4821
+ if (remote.hostApps.length >= localCount) {
4822
+ local.hostApps = remote.hostApps;
4823
+ }
4824
+ }
4825
+ const prevToken = local.proxy?.token;
4826
+ applyAssignedProxy(local, remote, cfg);
4827
+ if (local.proxy?.token && local.proxy.token !== prevToken) {
4828
+ persistWorkspaceEntry(cfg, local);
4829
+ }
4234
4830
  }
4235
4831
  }
4236
4832
 
@@ -4263,6 +4859,8 @@ async function sendHeartbeat(cfg, folders, localStates) {
4263
4859
  ? st.origins
4264
4860
  : undefined,
4265
4861
  appsRequested: Boolean(st.appsRequested),
4862
+ hostApps: st.hostApps || [],
4863
+ activityLog: st.activityLog || [],
4266
4864
  })),
4267
4865
  };
4268
4866
  return api(
@@ -4314,6 +4912,8 @@ async function buildHeartbeatPayload(cfg, folders, localStates) {
4314
4912
  ? st.origins
4315
4913
  : undefined,
4316
4914
  appsRequested: Boolean(st.appsRequested),
4915
+ hostApps: st.hostApps || [],
4916
+ activityLog: st.activityLog || [],
4317
4917
  })),
4318
4918
  };
4319
4919
  }
@@ -4464,32 +5064,24 @@ async function main() {
4464
5064
  { logLevel: logger.level, nodeEnv: process.env.NODE_ENV },
4465
5065
  "bridge starting"
4466
5066
  );
5067
+ void warnIfBridgeOutdated();
4467
5068
 
4468
5069
  let cfg = loadConfig() || {};
4469
5070
  ensureMachineId(cfg);
4470
5071
 
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
5072
  if (args.pair || !cfg.token || !cfg.adminUrl) {
4482
5073
  cfg = await pairFlow(args);
4483
5074
  }
4484
5075
 
4485
5076
  cfg.noAiServer = Boolean(args.noAiServer);
4486
5077
  saveConfig(cfg);
5078
+ bridgeCfg = cfg;
4487
5079
 
4488
5080
  log(`machine ${cfg.machineId}`);
4489
5081
  log(`admin ${cfg.adminUrl}`);
4490
5082
  log(`config ${configPath()}`);
4491
5083
  log(
4492
- `online via websocket (presence every ${HEARTBEAT_MS / 1000}s, ping every ${WS_PING_MS / 1000}s)`
5084
+ `online via websocket (presence every ${HEARTBEAT_MS / 1000}s, ping every ${WS_PING_MS / 1000}s; reconnects until stopped)`
4493
5085
  );
4494
5086
  await restoreHostsAfterReconnect(cfg);
4495
5087
 
@@ -4519,25 +5111,37 @@ async function main() {
4519
5111
  void runWork();
4520
5112
  };
4521
5113
 
4522
- /** @type {import('ws').WebSocket | null} */
5114
+ /** @type {WebSocket | null} */
4523
5115
  let socket = null;
4524
5116
  let heartbeatTimer = null;
4525
5117
  let pingTimer = null;
4526
5118
  let reconnectTimer = null;
5119
+ let watchdogTimer = null;
4527
5120
  let wsGeneration = 0;
4528
5121
  let reconnectAttempt = 0;
5122
+ let connectStartedAt = 0;
4529
5123
  let stopped = false;
4530
5124
  let presenceBusy = false;
4531
5125
 
5126
+ let sendChain = Promise.resolve();
4532
5127
  const sendJson = (payload) => {
4533
- if (!socket || socket.readyState !== 1) return false;
5128
+ const current = socket;
5129
+ if (!current || current.readyState !== 1) return false;
5130
+ let text;
4534
5131
  try {
4535
- socket.send(JSON.stringify(payload));
4536
- return true;
5132
+ text = JSON.stringify(payload);
4537
5133
  } catch {
4538
5134
  return false;
4539
5135
  }
5136
+ sendChain = sendChain
5137
+ .then(() => {
5138
+ if (!socket || socket !== current || socket.readyState !== 1) return;
5139
+ socket.send(text);
5140
+ })
5141
+ .catch(() => {});
5142
+ return true;
4540
5143
  };
5144
+ bridgeSend = sendJson;
4541
5145
 
4542
5146
  const clearHeartbeatTimer = () => {
4543
5147
  if (heartbeatTimer) {
@@ -4566,7 +5170,7 @@ async function main() {
4566
5170
  };
4567
5171
 
4568
5172
  const sendLightPresence = () => {
4569
- const folders = collectOfferedFolders(cfg);
5173
+ const folders = collectOfferedFolders();
4570
5174
  sendJson(buildLightHeartbeatPayload(cfg, folders));
4571
5175
  };
4572
5176
 
@@ -4574,7 +5178,7 @@ async function main() {
4574
5178
  if (!socket || socket.readyState !== 1 || presenceBusy) return;
4575
5179
  presenceBusy = true;
4576
5180
  try {
4577
- const folders = collectOfferedFolders(cfg);
5181
+ const folders = collectOfferedFolders();
4578
5182
  const localStates = await collectWorkspaceStates(cfg);
4579
5183
  const payload = await buildHeartbeatPayload(cfg, folders, localStates);
4580
5184
  sendJson(payload);
@@ -4627,6 +5231,38 @@ async function main() {
4627
5231
  fail(`no local workspace for sandbox ${sandboxId || "?"}`);
4628
5232
  return;
4629
5233
  }
5234
+ const body = {
5235
+ conversationId,
5236
+ messages: payloadMessages,
5237
+ userMessage: content || undefined,
5238
+ skipPersistUser: true,
5239
+ userMessageId: userMessageId || undefined,
5240
+ senderType: msg.senderType === "client" ? "client" : undefined,
5241
+ senderName:
5242
+ typeof msg.senderName === "string" ? msg.senderName : undefined,
5243
+ };
5244
+ const embedded = embeddedChat.get(sandboxId);
5245
+ if (embedded?.runChat) {
5246
+ log(`chat.run → in-process ${embedded.workspaceDir} (${conversationId})`);
5247
+ try {
5248
+ const result = await embedded.runChat(body);
5249
+ if (!result.ok) {
5250
+ fail(result.data?.error || `AI server ${result.status}`);
5251
+ return;
5252
+ }
5253
+ log(`chat.run ok (${conversationId})`);
5254
+ sendJson({
5255
+ type: "chat.run.result",
5256
+ ok: true,
5257
+ sandboxId,
5258
+ conversationId,
5259
+ userMessageId,
5260
+ });
5261
+ } catch (err) {
5262
+ fail(err instanceof Error ? err.message : String(err));
5263
+ }
5264
+ return;
5265
+ }
4630
5266
  const chat = await discoverChatPort(ws, 2500);
4631
5267
  if (chat.up) ws.port = chat.port;
4632
5268
  const port = Number(chat.port || ws.port) || 3100;
@@ -4639,16 +5275,7 @@ async function main() {
4639
5275
  "content-type": "application/json",
4640
5276
  accept: "application/json",
4641
5277
  },
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
- }),
5278
+ body: JSON.stringify(body),
4652
5279
  });
4653
5280
  const text = await res.text();
4654
5281
  let data = null;
@@ -4692,6 +5319,26 @@ async function main() {
4692
5319
  queueActions(msg.actions);
4693
5320
  return;
4694
5321
  }
5322
+ if (msg.type === "proxy.http") {
5323
+ handleProxyHttpFromAdmin(msg);
5324
+ return;
5325
+ }
5326
+ if (msg.type === "proxy.http.body") {
5327
+ handleProxyHttpBodyFromAdmin(msg);
5328
+ return;
5329
+ }
5330
+ if (msg.type === "proxy.ws.open") {
5331
+ handleProxyWsOpenFromAdmin(msg);
5332
+ return;
5333
+ }
5334
+ if (msg.type === "proxy.ws.frame") {
5335
+ handleProxyWsFrameFromAdmin(msg);
5336
+ return;
5337
+ }
5338
+ if (msg.type === "proxy.ws.close") {
5339
+ handleProxyWsCloseFromAdmin(msg);
5340
+ return;
5341
+ }
4695
5342
  if (msg.type === "chat.run") {
4696
5343
  void handleChatRun(msg);
4697
5344
  return;
@@ -4701,37 +5348,56 @@ async function main() {
4701
5348
  }
4702
5349
  };
4703
5350
 
5351
+ const socketState = () => {
5352
+ if (!socket) return -1;
5353
+ return socket.readyState;
5354
+ };
5355
+
5356
+ const dropSocket = (ws) => {
5357
+ if (!ws) return;
5358
+ try {
5359
+ ws.close();
5360
+ } catch {
5361
+ /* ignore */
5362
+ }
5363
+ if (socket === ws) socket = null;
5364
+ };
5365
+
4704
5366
  const scheduleReconnect = (code, reason) => {
4705
5367
  if (stopped || reconnectTimer) return;
5368
+ const state = socketState();
5369
+ if (state === 0 || state === 1) return;
4706
5370
  const delay = Math.min(
4707
5371
  WS_RECONNECT_MAX_MS,
4708
- WS_RECONNECT_MIN_MS * 2 ** Math.min(reconnectAttempt, 4)
5372
+ WS_RECONNECT_MIN_MS * 2 ** Math.min(reconnectAttempt, 5)
4709
5373
  );
4710
5374
  reconnectAttempt += 1;
4711
5375
  const detail = reason ? ` ${reason}` : "";
4712
5376
  const wait =
4713
5377
  delay < 1000 ? `${delay}ms` : `${Math.round(delay / 1000)}s`;
4714
5378
  warn(
4715
- `websocket closed (${code || "?"}${detail}); reconnecting in ${wait}`
5379
+ `admin connection lost (${code || "?"}${detail}); retrying in ${wait}`
4716
5380
  );
4717
5381
  reconnectTimer = setTimeout(() => {
4718
5382
  reconnectTimer = null;
5383
+ if (stopped) return;
5384
+ const next = socketState();
5385
+ if (next === 0 || next === 1) return;
4719
5386
  connectWs();
4720
5387
  }, delay);
4721
5388
  };
4722
5389
 
4723
5390
  const connectWs = () => {
4724
5391
  if (stopped) return;
5392
+ const state = socketState();
5393
+ if (state === 0 || state === 1) return;
4725
5394
  clearReconnectTimer();
4726
5395
  const generation = ++wsGeneration;
4727
5396
  if (socket) {
4728
- try {
4729
- socket.close();
4730
- } catch {
4731
- /* ignore */
4732
- }
5397
+ dropSocket(socket);
4733
5398
  socket = null;
4734
5399
  }
5400
+ sendChain = Promise.resolve();
4735
5401
  const url = adminWsUrl(cfg.adminUrl, cfg.token);
4736
5402
  log(
4737
5403
  reconnectAttempt
@@ -4739,12 +5405,35 @@ async function main() {
4739
5405
  : "websocket connecting…"
4740
5406
  );
4741
5407
  /** @type {WebSocket} */
4742
- const ws = new WebSocket(url);
5408
+ let ws;
5409
+ try {
5410
+ ws = new WebSocket(url);
5411
+ } catch (err) {
5412
+ warn(
5413
+ `websocket connect failed: ${
5414
+ err instanceof Error ? err.message : String(err)
5415
+ }`
5416
+ );
5417
+ scheduleReconnect(0, "connect failed");
5418
+ return;
5419
+ }
4743
5420
  socket = ws;
5421
+ connectStartedAt = Date.now();
5422
+
5423
+ const onDisconnected = (code, reason) => {
5424
+ if (generation !== wsGeneration) return;
5425
+ // Node fires `error` while CONNECTING/OPEN; that is not a drop.
5426
+ if (ws.readyState === 0 || ws.readyState === 1) return;
5427
+ clearHeartbeatTimer();
5428
+ clearPingTimer();
5429
+ if (socket === ws) socket = null;
5430
+ scheduleReconnect(code, reason);
5431
+ };
4744
5432
 
4745
5433
  ws.addEventListener("open", () => {
4746
5434
  if (generation !== wsGeneration) return;
4747
5435
  reconnectAttempt = 0;
5436
+ connectStartedAt = Date.now();
4748
5437
  log("websocket connected");
4749
5438
  clearHeartbeatTimer();
4750
5439
  clearPingTimer();
@@ -4763,34 +5452,61 @@ async function main() {
4763
5452
  });
4764
5453
 
4765
5454
  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);
5455
+ onDisconnected(event.code, event.reason);
4771
5456
  });
4772
5457
 
4773
5458
  ws.addEventListener("error", () => {
4774
- // close handler drives reconnect
5459
+ // `close` follows for real failures. Reconnecting here races `open`
5460
+ // and makes admin close the live socket with 1000 "replaced".
4775
5461
  });
4776
5462
  };
4777
5463
 
5464
+ const watchdogTick = () => {
5465
+ if (stopped) return;
5466
+ const state = socketState();
5467
+ if (state === 1) return;
5468
+ if (state === 0) {
5469
+ if (Date.now() - connectStartedAt > WS_CONNECT_TIMEOUT_MS) {
5470
+ warn("websocket connect timed out; retrying");
5471
+ wsGeneration += 1;
5472
+ dropSocket(socket);
5473
+ socket = null;
5474
+ scheduleReconnect(0, "connect timeout");
5475
+ }
5476
+ return;
5477
+ }
5478
+ scheduleReconnect(0, "watchdog");
5479
+ };
5480
+
4778
5481
  connectWs();
5482
+ watchdogTimer = setInterval(watchdogTick, WS_WATCHDOG_MS);
5483
+
5484
+ const onFatal = (kind, err) => {
5485
+ const msg = err instanceof Error ? err.message : String(err || kind);
5486
+ warn(`${kind}: ${msg}`);
5487
+ if (!stopped) scheduleReconnect(kind, msg);
5488
+ };
5489
+ process.on("uncaughtException", (err) => onFatal("uncaughtException", err));
5490
+ process.on("unhandledRejection", (err) =>
5491
+ onFatal("unhandledRejection", err)
5492
+ );
4779
5493
 
4780
5494
  const shutdown = () => {
5495
+ if (stopped) return;
4781
5496
  stopped = true;
4782
5497
  wsGeneration += 1;
4783
5498
  clearHeartbeatTimer();
4784
5499
  clearPingTimer();
4785
5500
  clearReconnectTimer();
4786
- stopAllCloudflare();
4787
- try {
4788
- socket?.close();
4789
- } catch {
4790
- /* ignore */
5501
+ if (watchdogTimer) {
5502
+ clearInterval(watchdogTimer);
5503
+ watchdogTimer = null;
4791
5504
  }
5505
+ stopAllCloudflare();
5506
+ dropSocket(socket);
5507
+ socket = null;
4792
5508
  log("shutting down (other terminals stay open)");
4793
- process.exit(0);
5509
+ void stopAllEmbeddedChat().finally(() => process.exit(0));
4794
5510
  };
4795
5511
  process.on("SIGINT", shutdown);
4796
5512
  process.on("SIGTERM", shutdown);