@maintainer-pro/ai-bridge 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/daemon.mjs +213 -78
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maintainer-pro/ai-bridge",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Local bridge daemon that pairs a machine to Maintainer Pro and configures multiple client sandboxes.",
5
5
  "keywords": [
6
6
  "maintainer-pro",
package/src/daemon.mjs CHANGED
@@ -850,7 +850,65 @@ const launchedAt = new Map();
850
850
  /** Last process problems to send on heartbeat. Key: sandboxId::code::role */
851
851
  const processProblems = new Map();
852
852
 
853
- function forgetLaunch(sandboxId) {
853
+ /** @type {Map<string, Set<string>>} sandboxId -> CMD/terminal titles we opened */
854
+ const openedTerminalTitles = new Map();
855
+
856
+ function rememberTerminalTitle(sandboxId, title) {
857
+ const id = String(sandboxId || "").trim();
858
+ const name = String(title || "").trim();
859
+ if (!id || !name) return;
860
+ let titles = openedTerminalTitles.get(id);
861
+ if (!titles) {
862
+ titles = new Set();
863
+ openedTerminalTitles.set(id, titles);
864
+ }
865
+ titles.add(name);
866
+ }
867
+
868
+ function closeWindowsByTitle(title) {
869
+ const name = String(title || "").trim();
870
+ if (!name) return Promise.resolve();
871
+ return new Promise((resolve) => {
872
+ const done = () => resolve();
873
+ if (process.platform === "win32") {
874
+ const child = spawn(
875
+ "taskkill",
876
+ ["/F", "/T", "/FI", `WINDOWTITLE eq ${name}*`],
877
+ { windowsHide: true, stdio: "ignore" }
878
+ );
879
+ child.on("exit", done);
880
+ child.on("error", done);
881
+ setTimeout(done, 4000);
882
+ return;
883
+ }
884
+ if (process.platform === "darwin") {
885
+ const child = spawn(
886
+ "osascript",
887
+ [
888
+ "-e",
889
+ `tell application "Terminal" to close (every window whose name contains ${JSON.stringify(name)})`,
890
+ ],
891
+ { stdio: "ignore" }
892
+ );
893
+ child.on("exit", done);
894
+ child.on("error", done);
895
+ setTimeout(done, 4000);
896
+ return;
897
+ }
898
+ done();
899
+ });
900
+ }
901
+
902
+ async function closeRememberedTerminals(sandboxId) {
903
+ const titles = openedTerminalTitles.get(sandboxId);
904
+ if (!titles) return;
905
+ for (const title of titles) {
906
+ await closeWindowsByTitle(title);
907
+ }
908
+ openedTerminalTitles.delete(sandboxId);
909
+ }
910
+
911
+ async function forgetLaunch(sandboxId) {
854
912
  for (const key of [...launchedAt.keys()]) {
855
913
  if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
856
914
  launchedAt.delete(key);
@@ -859,7 +917,8 @@ function forgetLaunch(sandboxId) {
859
917
  for (const key of [...processProblems.keys()]) {
860
918
  if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
861
919
  }
862
- stopCloudflare(sandboxId);
920
+ await closeRememberedTerminals(sandboxId);
921
+ await stopCloudflare(sandboxId);
863
922
  }
864
923
 
865
924
  function problemKey(sandboxId, code, role = "") {
@@ -1073,7 +1132,7 @@ function runLauncher(command, args, extra = {}) {
1073
1132
  }
1074
1133
 
1075
1134
  async function openInNewTerminal(opts) {
1076
- const { title, folder, command, env = {}, launchKey } = opts;
1135
+ const { title, folder, command, env = {}, launchKey, sandboxId } = opts;
1077
1136
  if (launchKey) {
1078
1137
  if (!opts.force && recentlyLaunched(launchKey)) {
1079
1138
  return { ok: true, skipped: true };
@@ -1086,6 +1145,13 @@ async function openInNewTerminal(opts) {
1086
1145
  return { ok: false, error };
1087
1146
  }
1088
1147
 
1148
+ await closeWindowsByTitle(title);
1149
+ await sleep(250);
1150
+ rememberTerminalTitle(
1151
+ sandboxId || String(launchKey || "").split(":")[0],
1152
+ title
1153
+ );
1154
+
1089
1155
  const envWin = Object.entries(env)
1090
1156
  .map(([key, value]) => `set ${key}=${value}`)
1091
1157
  .join("&& ");
@@ -1098,18 +1164,14 @@ async function openInNewTerminal(opts) {
1098
1164
  try {
1099
1165
  if (process.platform === "win32") {
1100
1166
  const inner = `cd /d "${folder}" && ${envWin ? `${envWin}&& ` : ""}title ${title}&& ${command}`;
1101
- const escaped = inner.replace(/'/g, "''");
1102
- const opened = await runLauncher(
1103
- "powershell.exe",
1104
- [
1105
- "-NoProfile",
1106
- "-WindowStyle",
1107
- "Hidden",
1108
- "-Command",
1109
- `Start-Process -FilePath $env:ComSpec -WorkingDirectory ${JSON.stringify(folder)} -ArgumentList @('/k', '${escaped}')`,
1110
- ],
1111
- { windowsHide: true }
1112
- );
1167
+ const opened = await runLauncher(process.env.ComSpec || "cmd.exe", [
1168
+ "/c",
1169
+ "start",
1170
+ title,
1171
+ "cmd.exe",
1172
+ "/k",
1173
+ inner,
1174
+ ]);
1113
1175
  if (!opened.ok) {
1114
1176
  return { ok: false, error: friendlyLaunchError(opened.error, title) };
1115
1177
  }
@@ -1255,6 +1317,7 @@ async function startAiServerForWorkspace(ws, opts = {}) {
1255
1317
  NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
1256
1318
  },
1257
1319
  launchKey,
1320
+ sandboxId: ws.sandboxId,
1258
1321
  });
1259
1322
  if (opened.skipped) {
1260
1323
  return { port, up: false, launched: false, starting: true };
@@ -1382,6 +1445,7 @@ async function stopWorkspaceApps(ws) {
1382
1445
  }
1383
1446
  }
1384
1447
  log(`stopping local apps on ports ${[...ports].join(", ") || "(none)"}`);
1448
+ await closeRememberedTerminals(ws.sandboxId);
1385
1449
  for (const port of ports) {
1386
1450
  await killPort(port);
1387
1451
  }
@@ -1413,12 +1477,10 @@ async function waitForUrlInFile(file, timeoutMs = 90_000) {
1413
1477
  }
1414
1478
 
1415
1479
  function cloudflaredCommand(localUrl, logFile) {
1416
- const run = `npx --yes cloudflared tunnel --url ${localUrl} --no-autoupdate`;
1417
- if (process.platform === "win32") {
1418
- const dest = String(logFile).replace(/'/g, "''");
1419
- return `powershell -NoProfile -Command "${run} 2>&1 | Tee-Object -FilePath '${dest}'"`;
1420
- }
1421
- return `${run} 2>&1 | tee ${JSON.stringify(logFile)}`;
1480
+ const logArg = JSON.stringify(logFile);
1481
+ const run = `npx --yes cloudflared tunnel --url ${localUrl} --no-autoupdate --logfile ${logArg}`;
1482
+ if (process.platform === "win32") return run;
1483
+ return `${run} 2>&1 | tee ${logArg}`;
1422
1484
  }
1423
1485
 
1424
1486
  async function startCloudflareTerminal(ws, role, localUrl) {
@@ -1439,6 +1501,7 @@ async function startCloudflareTerminal(ws, role, localUrl) {
1439
1501
  folder,
1440
1502
  command: cloudflaredCommand(localUrl, logFile),
1441
1503
  launchKey: `${ws.sandboxId}:cf:${role}`,
1504
+ sandboxId: ws.sandboxId,
1442
1505
  force: true,
1443
1506
  });
1444
1507
  if (!opened.ok) {
@@ -1501,22 +1564,52 @@ function writeTunnelEnv(ws, tunnels) {
1501
1564
  }
1502
1565
  }
1503
1566
 
1504
- async function configureCloudflareForWorkspace(ws, cfg) {
1505
- const sandboxId = ws.sandboxId;
1506
- const label = ws.sandboxName || "this sandbox";
1507
- const folder = path.resolve(ws.folderPath || "");
1567
+ function reservedPortsFor(cfg, sandboxId) {
1508
1568
  const reserved = new Set();
1509
1569
  for (const other of cfg.workspaces || []) {
1510
1570
  if (other.sandboxId !== sandboxId && other.port) {
1511
1571
  reserved.add(Number(other.port));
1512
1572
  }
1513
1573
  }
1574
+ return reserved;
1575
+ }
1514
1576
 
1515
- log(`Cloudflare setup for ${label}: stop apps, then tunnel ai-server/backend before UI`);
1516
- try {
1577
+ function appsWanted(ws) {
1578
+ return Boolean(ws?.appsRequested);
1579
+ }
1580
+
1581
+ async function configureCloudflareForWorkspace(ws, cfg) {
1582
+ const sandboxId = ws.sandboxId;
1583
+ const label = ws.sandboxName || "this sandbox";
1584
+
1585
+ log(`Cloudflare queued for ${label}: stop apps and wait for Start`);
1517
1586
  await stopCloudflare(sandboxId);
1518
1587
  await stopWorkspaceApps(ws);
1588
+ await forgetLaunch(sandboxId);
1589
+ ws.cloudflarePending = true;
1590
+ ws.appsRequested = false;
1591
+ persistWorkspaceEntry(cfg, ws);
1519
1592
 
1593
+ return {
1594
+ sandboxId,
1595
+ folderPath: ws.folderPath,
1596
+ port: ws.port,
1597
+ pending: true,
1598
+ cloudflarePending: true,
1599
+ waitingForStart: true,
1600
+ warning:
1601
+ "Cloudflare is ready in Maintainer Pro. Use Start chat server when you want to launch the apps and create the public URLs.",
1602
+ };
1603
+ }
1604
+
1605
+ async function launchCloudflareTunnels(ws, cfg) {
1606
+ const sandboxId = ws.sandboxId;
1607
+ const label = ws.sandboxName || "this sandbox";
1608
+ const folder = path.resolve(ws.folderPath || "");
1609
+ const reserved = reservedPortsFor(cfg, sandboxId);
1610
+
1611
+ log(`Cloudflare start for ${label}: tunnel chat script/backend before UI`);
1612
+ try {
1520
1613
  if (!cfg.noAiServer) {
1521
1614
  await startAiServerForWorkspace(ws, { reserved, cfg });
1522
1615
  await waitUntilReachable(
@@ -1596,6 +1689,8 @@ async function configureCloudflareForWorkspace(ws, cfg) {
1596
1689
  ws.cloudflareUrl = appUrl;
1597
1690
  ws.cloudflare = tunnels;
1598
1691
  ws.appUrl = appUrl;
1692
+ ws.cloudflarePending = false;
1693
+ ws.appsRequested = true;
1599
1694
  persistWorkspaceEntry(cfg, ws);
1600
1695
  cloudflareTunnels.set(sandboxId, { tunnels: started });
1601
1696
  clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
@@ -1603,6 +1698,7 @@ async function configureCloudflareForWorkspace(ws, cfg) {
1603
1698
  return {
1604
1699
  sandboxId,
1605
1700
  folderPath: ws.folderPath,
1701
+ port: ws.port,
1606
1702
  appUrl,
1607
1703
  origins: Object.values(tunnels).filter(Boolean),
1608
1704
  tunnels,
@@ -1618,13 +1714,52 @@ async function configureCloudflareForWorkspace(ws, cfg) {
1618
1714
  title: `Could not start Cloudflare (${label})`,
1619
1715
  message,
1620
1716
  resolution:
1621
- "Install cloudflared or allow npx to download it, then try Share with Cloudflare again.",
1622
- actionCode: "configure_cloudflare",
1717
+ "Install cloudflared or allow npx to download it, then use Start chat server again.",
1718
+ actionCode: "start_ai_server",
1623
1719
  });
1624
1720
  throw err;
1625
1721
  }
1626
1722
  }
1627
1723
 
1724
+ async function startAppsForWorkspace(ws, cfg) {
1725
+ if (ws.cloudflarePending) {
1726
+ return launchCloudflareTunnels(ws, cfg);
1727
+ }
1728
+ ws.appsRequested = true;
1729
+ persistWorkspaceEntry(cfg, ws);
1730
+ const reserved = reservedPortsFor(cfg, ws.sandboxId);
1731
+ if (!cfg.noAiServer) {
1732
+ await startAiServerForWorkspace(ws, { reserved, cfg });
1733
+ await sleep(1500);
1734
+ }
1735
+ const startedHosts = await ensureHostProcesses(ws, {
1736
+ reserved,
1737
+ cfg,
1738
+ force: true,
1739
+ });
1740
+ await sleep(800);
1741
+ await inspectHostJobs(ws);
1742
+ const up = await probeUrl(`http://127.0.0.1:${ws.port}/embed-config.js`);
1743
+ if (up) {
1744
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1745
+ clearProcessProblem(ws.sandboxId, "apps_not_started");
1746
+ }
1747
+ const processIssues = issuesForSandbox(ws.sandboxId).map(
1748
+ ({ role: _role, ...issue }) => issue
1749
+ );
1750
+ const warning = processIssues[0]?.message || null;
1751
+ return {
1752
+ up,
1753
+ startedHosts,
1754
+ sandboxId: ws.sandboxId,
1755
+ folderPath: ws.folderPath,
1756
+ port: ws.port,
1757
+ appUrl: ws.appUrl,
1758
+ processIssues,
1759
+ warning,
1760
+ };
1761
+ }
1762
+
1628
1763
  async function ensureHostProcesses(ws, opts = {}) {
1629
1764
  const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
1630
1765
  const cfg = opts.cfg || null;
@@ -1704,6 +1839,7 @@ async function ensureHostProcesses(ws, opts = {}) {
1704
1839
  command,
1705
1840
  env: { PORT: String(port), ...extraEnv },
1706
1841
  launchKey,
1842
+ sandboxId: ws.sandboxId,
1707
1843
  force: Boolean(opts.force),
1708
1844
  });
1709
1845
  if (opened.skipped) continue;
@@ -1763,6 +1899,11 @@ async function inspectHostJobs(ws) {
1763
1899
  clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
1764
1900
  continue;
1765
1901
  }
1902
+ if (!appsWanted(ws)) {
1903
+ clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
1904
+ clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
1905
+ continue;
1906
+ }
1766
1907
  if (starting) continue;
1767
1908
  const tried = launchedAt.has(launchKey);
1768
1909
  recordProcessProblem({
@@ -1863,6 +2004,8 @@ async function setupWorkspace(cfg, action) {
1863
2004
  clientKind: client.kind,
1864
2005
  appUrl,
1865
2006
  sameOrigin: Boolean(client.sameOrigin),
2007
+ appsRequested: false,
2008
+ cloudflarePending: false,
1866
2009
  };
1867
2010
  if (existing >= 0) cfg.workspaces[existing] = entry;
1868
2011
  else cfg.workspaces.push(entry);
@@ -1881,12 +2024,6 @@ async function setupWorkspace(cfg, action) {
1881
2024
  .join("\n"),
1882
2025
  });
1883
2026
 
1884
- if (!cfg.noAiServer) {
1885
- await startAiServerForWorkspace(entry, { reserved, cfg });
1886
- await sleep(1500);
1887
- }
1888
- const startedHosts = await ensureHostProcesses(entry, { reserved, cfg });
1889
- await sleep(800);
1890
2027
  await inspectHostJobs(entry);
1891
2028
 
1892
2029
  const openUrl = client.sameOrigin
@@ -1895,23 +2032,22 @@ async function setupWorkspace(cfg, action) {
1895
2032
  const aiServerUp = await probeUrl(`http://127.0.0.1:${entry.port}/embed-config.js`);
1896
2033
  if (aiServerUp) {
1897
2034
  clearProcessProblem(sandboxId, "ai_server_launch", "ai");
2035
+ clearProcessProblem(sandboxId, "apps_not_started");
1898
2036
  }
1899
2037
 
1900
2038
  const processIssues = issuesForSandbox(sandboxId).map(
1901
2039
  ({ role: _role, ...issue }) => issue
1902
2040
  );
1903
- const warning = processIssues[0]?.message || null;
2041
+ const waitingForStart = !aiServerUp;
2042
+ const warning = waitingForStart
2043
+ ? "Folder is attached in Maintainer Pro. Use Start chat server when you want to launch the apps."
2044
+ : processIssues[0]?.message || null;
1904
2045
 
1905
2046
  for (const note of client.notes) log(note);
1906
- if (startedHosts.length) {
1907
- log(`started ${startedHosts.join(" + ")} in separate terminals`);
1908
- }
1909
- if (aiServerUp) {
1910
- log(`ai-server up — open ${openUrl}`);
1911
- } else if (warning) {
1912
- log(`process issue: ${warning}`);
2047
+ if (waitingForStart) {
2048
+ log(`folder attached waiting for Start (${openUrl})`);
1913
2049
  } else {
1914
- log("ai-server not reachable yet; it may still be starting");
2050
+ log(`ai-server already up open ${openUrl}`);
1915
2051
  }
1916
2052
 
1917
2053
  return {
@@ -1925,10 +2061,11 @@ async function setupWorkspace(cfg, action) {
1925
2061
  clientFiles: client.filesWritten,
1926
2062
  clientNotes: client.notes,
1927
2063
  aiServerUp,
1928
- startedHosts,
2064
+ startedHosts: [],
1929
2065
  openUrl,
1930
2066
  processIssues,
1931
2067
  warning,
2068
+ waitingForStart,
1932
2069
  projectInfo,
1933
2070
  };
1934
2071
  }
@@ -1970,12 +2107,6 @@ async function runActions(cfg, actions) {
1970
2107
  ok = false;
1971
2108
  result = { error: "No workspace or --no-ai-server" };
1972
2109
  } else {
1973
- const reserved = new Set();
1974
- for (const other of cfg.workspaces || []) {
1975
- if (other.sandboxId !== ws.sandboxId && other.port) {
1976
- reserved.add(Number(other.port));
1977
- }
1978
- }
1979
2110
  const problem = issuesForSandbox(ws.sandboxId)
1980
2111
  .map((issue) => issue.message)
1981
2112
  .join("\n");
@@ -1985,32 +2116,15 @@ async function runActions(cfg, actions) {
1985
2116
  problem ||
1986
2117
  "Local processes are not running or the project setup looks incomplete.",
1987
2118
  });
1988
- await startAiServerForWorkspace(ws, { reserved, cfg });
1989
- await sleep(1500);
1990
- const startedHosts = await ensureHostProcesses(ws, { reserved, cfg });
1991
- await sleep(800);
1992
- await inspectHostJobs(ws);
1993
- const up = await probeUrl(
1994
- `http://127.0.0.1:${ws.port}/embed-config.js`
1995
- );
1996
- if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1997
- const processIssues = issuesForSandbox(ws.sandboxId).map(
1998
- ({ role: _role, ...issue }) => issue
1999
- );
2000
- const warning = processIssues[0]?.message || null;
2001
2119
  result = {
2002
- up,
2003
- startedHosts,
2004
- sandboxId: ws.sandboxId,
2005
- folderPath: ws.folderPath,
2006
- port: ws.port,
2007
- appUrl: ws.appUrl,
2008
- processIssues,
2009
- warning,
2120
+ ...(await startAppsForWorkspace(ws, cfg)),
2010
2121
  projectInfo,
2011
2122
  };
2012
- if (processIssues.some((issue) => issue.code === "ai_server_launch")) {
2013
- result.error = warning;
2123
+ if (
2124
+ Array.isArray(result.processIssues) &&
2125
+ result.processIssues.some((issue) => issue.code === "ai_server_launch")
2126
+ ) {
2127
+ result.error = result.warning;
2014
2128
  ok = false;
2015
2129
  }
2016
2130
  }
@@ -2051,7 +2165,7 @@ async function runActions(cfg, actions) {
2051
2165
  const sandboxId = String(
2052
2166
  action.sandboxId || action.payload?.sandboxId || ""
2053
2167
  );
2054
- forgetLaunch(sandboxId);
2168
+ await forgetLaunch(sandboxId);
2055
2169
  cfg.workspaces = (cfg.workspaces || []).filter(
2056
2170
  (w) => w.sandboxId !== sandboxId
2057
2171
  );
@@ -2100,6 +2214,7 @@ async function collectWorkspaceStates(cfg) {
2100
2214
  aiServerUp: up,
2101
2215
  startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
2102
2216
  appUrl: ws.appUrl || null,
2217
+ appsRequested: appsWanted(ws),
2103
2218
  });
2104
2219
  }
2105
2220
  return localStates;
@@ -2122,6 +2237,7 @@ async function sendHeartbeat(cfg, folders, localStates) {
2122
2237
  aiServerUp: st.aiServerUp,
2123
2238
  port: st.port,
2124
2239
  appUrl: st.appUrl || undefined,
2240
+ appsRequested: Boolean(st.appsRequested),
2125
2241
  })),
2126
2242
  }
2127
2243
  );
@@ -2145,6 +2261,19 @@ async function buildIssues(cfg, workspaceStates) {
2145
2261
  }
2146
2262
  for (const st of workspaceStates) {
2147
2263
  if (st.aiServerUp || st.startingAi) continue;
2264
+ if (!st.appsRequested) {
2265
+ issues.push({
2266
+ code: "apps_not_started",
2267
+ severity: "info",
2268
+ title: `Apps are not running (${st.sandboxName || "sandbox"})`,
2269
+ message:
2270
+ "This folder is attached in Maintainer Pro. Use Start chat server when you want to launch the local apps.",
2271
+ resolution: "Use Start chat server.",
2272
+ actionCode: "start_ai_server",
2273
+ sandboxId: st.sandboxId,
2274
+ });
2275
+ continue;
2276
+ }
2148
2277
  const launch = [...processProblems.values()].find(
2149
2278
  (issue) =>
2150
2279
  issue.sandboxId === st.sandboxId && issue.code === "ai_server_launch"
@@ -2281,13 +2410,18 @@ async function main() {
2281
2410
  const up = await probeUrl(
2282
2411
  `http://127.0.0.1:${ws.port}/embed-config.js`
2283
2412
  );
2284
- if (!cfg.noAiServer && !up) {
2413
+ if (appsWanted(ws) && !cfg.noAiServer && !up) {
2285
2414
  await startAiServerForWorkspace(ws, { reserved, cfg });
2286
2415
  } else if (ws.port) {
2287
2416
  reserved.add(Number(ws.port));
2288
- if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2417
+ if (up) {
2418
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2419
+ clearProcessProblem(ws.sandboxId, "apps_not_started");
2420
+ }
2421
+ }
2422
+ if (appsWanted(ws)) {
2423
+ await ensureHostProcesses(ws, { reserved, cfg });
2289
2424
  }
2290
- await ensureHostProcesses(ws, { reserved, cfg });
2291
2425
  await inspectHostJobs(ws);
2292
2426
  localStates.push({
2293
2427
  sandboxId: ws.sandboxId,
@@ -2297,6 +2431,7 @@ async function main() {
2297
2431
  aiServerUp: up,
2298
2432
  startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
2299
2433
  appUrl: ws.appUrl || null,
2434
+ appsRequested: appsWanted(ws),
2300
2435
  });
2301
2436
  }
2302
2437