@maintainer-pro/ai-bridge 0.1.32 → 0.1.33

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.
package/src/daemon.mjs CHANGED
@@ -43,6 +43,18 @@ import {
43
43
  mergeCookieHeader,
44
44
  storeCorsCookies,
45
45
  } from "./cors-cookies.mjs";
46
+ import {
47
+ isIntakeArtifactName,
48
+ startIntakeServer,
49
+ } from "./intake-server.mjs";
50
+ import {
51
+ flushWebsiteFeedback,
52
+ isIntakeBuiltWorkspace,
53
+ markIntakeBuilt,
54
+ queueWebsiteFeedback,
55
+ websiteFeedbackShouldFlush,
56
+ WEBSITE_FEEDBACK_CHECK_MS,
57
+ } from "./website-feedback.mjs";
46
58
  import { WebSocket as WsWebSocket } from "ws";
47
59
 
48
60
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -69,8 +81,52 @@ const WS_WATCHDOG_MS = 5_000;
69
81
 
70
82
  /** @type {import("@maintainer-pro/ai-cli").Logger} */
71
83
  let logger = createLogger("ai-bridge");
72
- const log = (msg) => logger.info(msg);
73
- const warn = (msg) => logger.warn(msg);
84
+ const SECRET_KEY_RE =
85
+ /token|secret|password|authorization|cookie|api[_-]?key|bearer|pair/i;
86
+
87
+ function sanitizeLogValue(value, depth = 0) {
88
+ if (value == null) return value;
89
+ if (typeof value === "string") {
90
+ return value.length > 280 ? `${value.slice(0, 280)}…` : value;
91
+ }
92
+ if (typeof value !== "object") return value;
93
+ if (depth > 2) return Array.isArray(value) ? `[${value.length} items]` : "[object]";
94
+ if (Array.isArray(value)) {
95
+ return value.slice(0, 16).map((item) => sanitizeLogValue(item, depth + 1));
96
+ }
97
+ /** @type {Record<string, unknown>} */
98
+ const out = {};
99
+ for (const [key, item] of Object.entries(value)) {
100
+ out[key] = SECRET_KEY_RE.test(key)
101
+ ? "[redacted]"
102
+ : sanitizeLogValue(item, depth + 1);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ function logExtra(extra) {
108
+ return extra && typeof extra === "object" && !Array.isArray(extra)
109
+ ? extra
110
+ : extra != null
111
+ ? { detail: extra }
112
+ : undefined;
113
+ }
114
+
115
+ const log = (msg, extra) => {
116
+ const fields = logExtra(extra);
117
+ if (fields) logger.info(fields, String(msg));
118
+ else logger.info(String(msg));
119
+ };
120
+ const dbg = (msg, extra) => {
121
+ const fields = logExtra(extra);
122
+ if (fields) logger.debug(fields, String(msg));
123
+ else logger.debug(String(msg));
124
+ };
125
+ const warn = (msg, extra) => {
126
+ const fields = logExtra(extra);
127
+ if (fields) logger.warn(fields, String(msg));
128
+ else logger.warn(String(msg));
129
+ };
74
130
  const fail = (msg) => {
75
131
  logger.fatal(msg);
76
132
  process.exit(1);
@@ -79,6 +135,9 @@ const fail = (msg) => {
79
135
  /** @type {Map<string, Array<{ at: string, level: string, message: string }>>} */
80
136
  const activityBySandbox = new Map();
81
137
 
138
+ /** @type {Map<string, { port: number, close: () => Promise<void> }>} */
139
+ const intakeServers = new Map();
140
+
82
141
  /** @type {(payload: Record<string, unknown>) => boolean} */
83
142
  let bridgeSend = () => false;
84
143
 
@@ -94,7 +153,7 @@ function activity(sandboxId, level, message) {
94
153
  level: level === "error" || level === "warn" ? level : "info",
95
154
  message: text.slice(0, 500),
96
155
  });
97
- activityBySandbox.set(sandboxId, list.slice(-40));
156
+ activityBySandbox.set(sandboxId, list.slice(-80));
98
157
  }
99
158
 
100
159
  function activityLogFor(sandboxId) {
@@ -152,16 +211,30 @@ function resultSummary(result) {
152
211
  if (result.folderPath) bits.push(`folder=${result.folderPath}`);
153
212
  if (result.port) bits.push(`chat=${result.port}`);
154
213
  if (result.appUrl) bits.push(`app=${result.appUrl}`);
214
+ if (result.publicUrl) bits.push(`share=${result.publicUrl}`);
215
+ if (result.clientKind) bits.push(`kind=${result.clientKind}`);
216
+ if (result.intakeStatus) bits.push(`intake=${result.intakeStatus}`);
155
217
  if (Array.isArray(result.origins) && result.origins.length) {
156
218
  bits.push(`origins=${result.origins.join(",")}`);
157
219
  }
158
220
  if (typeof result.up === "boolean") bits.push(`chatUp=${result.up}`);
221
+ if (typeof result.running === "boolean") bits.push(`running=${result.running}`);
159
222
  if (typeof result.waitingForStart === "boolean") {
160
223
  bits.push(`waitingForStart=${result.waitingForStart}`);
161
224
  }
162
225
  if (Array.isArray(result.startedHosts) && result.startedHosts.length) {
163
226
  bits.push(`started=${result.startedHosts.join(",")}`);
164
227
  }
228
+ if (Array.isArray(result.hostApps) && result.hostApps.length) {
229
+ bits.push(
230
+ `apps=${result.hostApps
231
+ .map((app) => `${app?.id || app?.role || "?"}:${app?.port || "?"}`)
232
+ .join(",")}`
233
+ );
234
+ }
235
+ if (Array.isArray(result.processIssues) && result.processIssues.length) {
236
+ bits.push(`issues=${result.processIssues.length}`);
237
+ }
165
238
  if (result.warning) bits.push(`warning=${result.warning}`);
166
239
  return bits.join(" ");
167
240
  }
@@ -384,6 +457,7 @@ function fetchErrorDetail(err) {
384
457
 
385
458
  async function api(baseUrl, token, method, pathname, body) {
386
459
  const url = `${String(baseUrl || "").replace(/\/$/, "")}${pathname}`;
460
+ dbg(`admin ${method} ${pathname}`);
387
461
  if (!baseUrl) {
388
462
  throw new Error("Admin URL is missing. Pair this computer again.");
389
463
  }
@@ -415,10 +489,14 @@ async function api(baseUrl, token, method, pathname, body) {
415
489
  data = { raw: text };
416
490
  }
417
491
  if (!res.ok) {
492
+ warn(`admin ${method} ${pathname} → ${res.status}`, {
493
+ error: data?.error || text.slice(0, 200),
494
+ });
418
495
  throw new Error(
419
496
  data?.error || `Bridge API ${method} ${pathname} failed (${res.status})`
420
497
  );
421
498
  }
499
+ dbg(`admin ${method} ${pathname} → ${res.status}`);
422
500
  return data;
423
501
  }
424
502
 
@@ -943,6 +1021,63 @@ function persistWorkspaceEntry(cfg, ws) {
943
1021
  saveConfig(cfg);
944
1022
  }
945
1023
 
1024
+ function liveBridgeConfig() {
1025
+ return bridgeCfg || loadConfig();
1026
+ }
1027
+
1028
+ function noteIntakeWebsiteFeedback(ws, userMessage) {
1029
+ const cfg = liveBridgeConfig();
1030
+ const live =
1031
+ (cfg?.workspaces || []).find((row) => row.sandboxId === ws?.sandboxId) || ws;
1032
+ if (!isIntakeBuiltWorkspace(live)) return;
1033
+ if (!queueWebsiteFeedback(live, userMessage)) return;
1034
+ persistWorkspaceEntry(cfg, live);
1035
+ if (websiteFeedbackShouldFlush(live)) {
1036
+ void flushIntakeWebsiteFeedback(live, cfg);
1037
+ }
1038
+ }
1039
+
1040
+ const websiteFeedbackFlushing = new Set();
1041
+
1042
+ async function flushIntakeWebsiteFeedback(ws, cfg = liveBridgeConfig(), opts = {}) {
1043
+ if (!cfg?.adminUrl || !cfg.token || !ws?.sandboxId) return;
1044
+ if (!websiteFeedbackShouldFlush(ws, opts)) return;
1045
+ if (websiteFeedbackFlushing.has(ws.sandboxId)) return;
1046
+ websiteFeedbackFlushing.add(ws.sandboxId);
1047
+ try {
1048
+ const result = await flushWebsiteFeedback(ws, {
1049
+ loadAiCli,
1050
+ memorySource: {
1051
+ baseUrl: cfg.adminUrl,
1052
+ token: cfg.token,
1053
+ sandboxId: ws.sandboxId,
1054
+ },
1055
+ api: (method, pathname, body) =>
1056
+ api(cfg.adminUrl, cfg.token, method, pathname, body),
1057
+ });
1058
+ persistWorkspaceEntry(cfg, ws);
1059
+ if (result.appended) {
1060
+ activity(ws.sandboxId, "info", "Saved studio feedback for later websites");
1061
+ }
1062
+ } catch (err) {
1063
+ warn(
1064
+ `website memory flush ${shortId(ws.sandboxId)}: ${
1065
+ err instanceof Error ? err.message : String(err)
1066
+ }`
1067
+ );
1068
+ } finally {
1069
+ websiteFeedbackFlushing.delete(ws.sandboxId);
1070
+ }
1071
+ }
1072
+
1073
+ async function flushAllIntakeWebsiteFeedback(opts = {}) {
1074
+ const cfg = liveBridgeConfig();
1075
+ const rows = Array.isArray(cfg?.workspaces) ? cfg.workspaces : [];
1076
+ for (const ws of rows) {
1077
+ await flushIntakeWebsiteFeedback(ws, cfg, opts);
1078
+ }
1079
+ }
1080
+
946
1081
  /**
947
1082
  * Sandbox store keys live on the workspace (and ~/.maintainer-pro/bridge.json),
948
1083
  * not in the app .env. Never use the bridge machine token as MAINTAINER_PRO_API_KEY.
@@ -1824,6 +1959,9 @@ async function restoreHostsAfterReconnect(cfg) {
1824
1959
  status.host.origins.join(",") || "none"
1825
1960
  }`
1826
1961
  );
1962
+ } else if (isIntakePendingWorkspace(ws)) {
1963
+ await startIntakeForWorkspace(ws, cfg);
1964
+ log(`intake form restored for ${label} on ${ws.sitePort}`);
1827
1965
  } else {
1828
1966
  log(
1829
1967
  `no apps running for ${label} — waiting for Start Apps from Maintainer Pro`
@@ -2032,12 +2170,13 @@ function detectProjectKind(dir) {
2032
2170
  return "html";
2033
2171
  }
2034
2172
 
2035
- // Only README / license → treat as empty scaffold target
2173
+ // Only README / license / intake artifacts → treat as empty scaffold target
2036
2174
  const meaningful = names.filter(
2037
2175
  (n) =>
2038
2176
  !/^readme/i.test(n) &&
2039
2177
  !/^license/i.test(n) &&
2040
- n !== "package.json"
2178
+ n !== "package.json" &&
2179
+ !isIntakeArtifactName(n)
2041
2180
  );
2042
2181
  if (meaningful.length === 0 && !has("package.json")) return "empty";
2043
2182
 
@@ -2052,37 +2191,26 @@ function escapeHtml(value) {
2052
2191
  .replaceAll('"', "&quot;");
2053
2192
  }
2054
2193
 
2055
- function emptyProjectIndexHtml(appName) {
2056
- return `<!doctype html>
2057
- <html lang="en">
2058
- <head>
2059
- <meta charset="utf-8" />
2060
- <meta name="viewport" content="width=device-width, initial-scale=1" />
2061
- <title>${escapeHtml(appName)}</title>
2062
- <style>
2063
- body { font-family: system-ui, sans-serif; margin: 2rem; color: #1a2330; }
2064
- p { color: #5c6b7a; }
2065
- </style>
2066
- </head>
2067
- <body>
2068
- <h1>${escapeHtml(appName)}</h1>
2069
- <p>Ask the assistant to change this page.</p>
2070
- <script src="/embed-config.js"></script>
2071
- <script src="/ai-ui.iife.js"></script>
2072
- <script>
2073
- (function () {
2074
- var cfg = window.__MAINTAINER_PRO__ || {};
2075
- AiUi.init({
2076
- apiUrl: cfg.apiUrl || "/api/chat",
2077
- title: "AI Assistant",
2078
- maintainerProUrl: cfg.maintainerProUrl || undefined,
2079
- maintainerProApiKey: cfg.maintainerProApiKey || undefined,
2080
- });
2081
- })();
2082
- </script>
2083
- </body>
2084
- </html>
2085
- `;
2194
+ function intakeHostApp(appName, port, folder) {
2195
+ return {
2196
+ id: "ui",
2197
+ name: appName || "Website",
2198
+ role: "ui",
2199
+ port: Number(port),
2200
+ startCommand: null,
2201
+ source: "manual",
2202
+ host: true,
2203
+ folderPath: folder || null,
2204
+ };
2205
+ }
2206
+
2207
+ function isIntakePendingFolder(dir) {
2208
+ return detectProjectKind(dir) === "empty";
2209
+ }
2210
+
2211
+ function isIntakePendingWorkspace(ws) {
2212
+ const folder = path.resolve(ws?.folderPath || "");
2213
+ return Boolean(Number(ws?.sitePort)) && isIntakePendingFolder(folder);
2086
2214
  }
2087
2215
 
2088
2216
  /**
@@ -2096,6 +2224,7 @@ function configureClient(opts) {
2096
2224
  appName,
2097
2225
  mode, // auto | empty | existing | skip
2098
2226
  hostAppUrl,
2227
+ sitePort,
2099
2228
  } = opts;
2100
2229
  const aiOrigin = `http://localhost:${port}`;
2101
2230
  /** @type {string[]} */
@@ -2120,21 +2249,22 @@ function configureClient(opts) {
2120
2249
  }
2121
2250
 
2122
2251
  if (kind === "empty" || mode === "empty") {
2123
- const indexPath = path.join(dir, "index.html");
2124
- if (!fs.existsSync(indexPath)) {
2125
- fs.writeFileSync(indexPath, emptyProjectIndexHtml(appName), "utf8");
2126
- filesWritten.push("index.html");
2127
- notes.push("Created index.html (chat served in-process).");
2128
- } else {
2129
- notes.push("index.html already present.");
2130
- }
2252
+ const origin = sitePort
2253
+ ? `http://localhost:${sitePort}`
2254
+ : hostAppUrl || aiOrigin;
2255
+ notes.push(
2256
+ sitePort
2257
+ ? `Serving the business intake form on port ${sitePort}. The generated site will use the same port.`
2258
+ : "Empty folder — waiting for a reserved site port."
2259
+ );
2131
2260
  return {
2132
2261
  kind: "empty",
2133
2262
  notes,
2134
2263
  filesWritten,
2135
- corsOrigin: aiOrigin,
2136
- appUrl: hostAppUrl || aiOrigin,
2137
- sameOrigin: true,
2264
+ corsOrigin: origin,
2265
+ appUrl: origin,
2266
+ sameOrigin: false,
2267
+ sitePort: sitePort || null,
2138
2268
  };
2139
2269
  }
2140
2270
 
@@ -2182,6 +2312,172 @@ function configureClient(opts) {
2182
2312
  };
2183
2313
  }
2184
2314
 
2315
+ async function stopIntakeForWorkspace(sandboxId) {
2316
+ const row = intakeServers.get(sandboxId);
2317
+ if (!row) return;
2318
+ intakeServers.delete(sandboxId);
2319
+ try {
2320
+ await row.close();
2321
+ } catch {
2322
+ /* ignore */
2323
+ }
2324
+ }
2325
+
2326
+ async function startIntakeForWorkspace(ws, cfg) {
2327
+ const port = Number(ws.sitePort);
2328
+ if (!port || !ws.sandboxId) return null;
2329
+ const existing = intakeServers.get(ws.sandboxId);
2330
+ if (existing && existing.port === port && (await portIsLive(port, 600))) {
2331
+ dbg(`intake already listening ${shortId(ws.sandboxId)} :${port}`);
2332
+ return existing;
2333
+ }
2334
+ if (existing) {
2335
+ log(`intake restart ${shortId(ws.sandboxId)} :${existing.port} → :${port}`);
2336
+ await stopIntakeForWorkspace(ws.sandboxId);
2337
+ }
2338
+ if (await portIsLive(port, 600)) {
2339
+ warn(`intake skip ${shortId(ws.sandboxId)}: port ${port} already in use`);
2340
+ return existing || null;
2341
+ }
2342
+ const instance = await startIntakeServer({
2343
+ port,
2344
+ appName: ws.applicationName || ws.sandboxName,
2345
+ getStatus: () => ({
2346
+ status: ws.intakeStatus || "form",
2347
+ message: ws.intakeMessage || "",
2348
+ startedAt: Number(ws.intakeStartedAt) || 0,
2349
+ etaSeconds: 5 * 60,
2350
+ }),
2351
+ onSubmit: async (intake) => {
2352
+ if (ws.intakeStatus === "building") {
2353
+ warn(`intake submit ignored ${shortId(ws.sandboxId)}: already building`);
2354
+ throw new Error("A website is already being created.");
2355
+ }
2356
+ ws.intakeStatus = "building";
2357
+ ws.intakeStartedAt = Date.now();
2358
+ ws.intakeMessage = "This usually takes about 5 minutes.";
2359
+ persistWorkspaceEntry(cfg, ws);
2360
+ const trade =
2361
+ intake && typeof intake === "object"
2362
+ ? String(intake.business?.tradeName || intake.slug || "")
2363
+ : "";
2364
+ activity(
2365
+ ws.sandboxId,
2366
+ "info",
2367
+ `Intake received${trade ? ` (${trade})` : ""} — generating website`
2368
+ );
2369
+ log(`intake submit ${shortId(ws.sandboxId)} :${port}`, {
2370
+ trade: trade || null,
2371
+ pages: Array.isArray(intake?.pages) ? intake.pages.length : 0,
2372
+ });
2373
+ void generateSiteForWorkspace(ws, cfg, intake);
2374
+ },
2375
+ });
2376
+ intakeServers.set(ws.sandboxId, instance);
2377
+ if (!ws.intakeStatus) ws.intakeStatus = "form";
2378
+ persistWorkspaceEntry(cfg, ws);
2379
+ activity(ws.sandboxId, "info", `Intake form listening on port ${port}`);
2380
+ log(`intake listening ${shortId(ws.sandboxId)} :${port} status=${ws.intakeStatus || "form"}`);
2381
+ return instance;
2382
+ }
2383
+
2384
+ async function generateSiteForWorkspace(ws, cfg, intake) {
2385
+ const folder = path.resolve(ws.folderPath || "");
2386
+ const sitePort = Number(ws.sitePort);
2387
+ const label = ws.sandboxName || shortId(ws.sandboxId);
2388
+ const startedAt = Date.now();
2389
+ log(`generate site start ${label}`, { folder, sitePort });
2390
+ try {
2391
+ const cli = await loadAiCli();
2392
+ if (typeof cli.generateWebsiteFromIntake !== "function") {
2393
+ throw new Error(
2394
+ "ai-cli is missing generateWebsiteFromIntake — update @maintainer-pro/ai-cli"
2395
+ );
2396
+ }
2397
+ activity(
2398
+ ws.sandboxId,
2399
+ "info",
2400
+ `Generating website for ${label} with the coding agent`
2401
+ );
2402
+ log(`generate site calling coding agent ${label} in ${folder}`);
2403
+ const generated = await cli.generateWebsiteFromIntake({
2404
+ workspaceDir: folder,
2405
+ intake,
2406
+ sitePort,
2407
+ appName: ws.applicationName || ws.sandboxName,
2408
+ memorySource: {
2409
+ baseUrl: cfg.adminUrl,
2410
+ token: cfg.token,
2411
+ sandboxId: ws.sandboxId,
2412
+ },
2413
+ });
2414
+ log(`generate site files ready ${label}`, {
2415
+ provider: generated?.provider || null,
2416
+ kind: detectProjectKind(folder),
2417
+ ms: Date.now() - startedAt,
2418
+ });
2419
+ markIntakeBuilt(ws);
2420
+ ws.intakeStatus = "ready";
2421
+ ws.intakeStartedAt = null;
2422
+ ws.intakeMessage = "Starting the preview…";
2423
+ ws.clientKind = detectProjectKind(folder);
2424
+ persistWorkspaceEntry(cfg, ws);
2425
+ activity(ws.sandboxId, "info", "Website files created — starting preview");
2426
+
2427
+ await stopIntakeForWorkspace(ws.sandboxId);
2428
+ await sleep(400);
2429
+ if (await portIsLive(sitePort, 600)) {
2430
+ await killPort(sitePort);
2431
+ await sleep(400);
2432
+ }
2433
+
2434
+ const detected = await resolveWorkspaceHostApps(ws, {
2435
+ cfg,
2436
+ force: true,
2437
+ allowAi: false,
2438
+ ignoreDesired: true,
2439
+ });
2440
+ const apps = listedHostApps(detected.apps || ws.hostApps || []);
2441
+ const ui =
2442
+ apps.find((app) => app.host || app.role === "ui" || app.role === "app") || {
2443
+ id: "ui",
2444
+ name: ws.applicationName || "Website",
2445
+ role: "ui",
2446
+ source: "package",
2447
+ };
2448
+ ws.hostApps = listedHostApps([
2449
+ {
2450
+ ...ui,
2451
+ id: ui.id || "ui",
2452
+ role: ui.role === "backend" ? "ui" : ui.role || "ui",
2453
+ port: sitePort,
2454
+ startCommand: ui.startCommand || "npm run dev",
2455
+ source: ui.source || "package",
2456
+ host: true,
2457
+ folderPath: folder,
2458
+ },
2459
+ ...apps.filter(
2460
+ (app) => app.id !== (ui.id || "ui") && app.role === "backend"
2461
+ ),
2462
+ ]);
2463
+ ws.appsRequested = true;
2464
+ persistWorkspaceEntry(cfg, ws);
2465
+ await startAppsForWorkspace(ws, cfg);
2466
+ activity(ws.sandboxId, "info", `Website preview on port ${sitePort}`);
2467
+ log(`generate site done ${label} ${Date.now() - startedAt}ms :${sitePort}`);
2468
+ } catch (err) {
2469
+ const message = err instanceof Error ? err.message : String(err);
2470
+ warn(`generate site failed ${label} ${Date.now() - startedAt}ms: ${message}`);
2471
+ ws.intakeStatus = "error";
2472
+ ws.intakeStartedAt = null;
2473
+ ws.intakeMessage = message.slice(0, 400);
2474
+ persistWorkspaceEntry(cfg, ws);
2475
+ activity(ws.sandboxId, "error", `Website generation failed: ${message}`);
2476
+ ws.intakeStatus = "form";
2477
+ persistWorkspaceEntry(cfg, ws);
2478
+ }
2479
+ }
2480
+
2185
2481
  function collectOfferedFolders() {
2186
2482
  return [browseRootFromCwd()];
2187
2483
  }
@@ -2383,9 +2679,20 @@ function shareProxyContext(msg, ws, appId) {
2383
2679
  port: localPortForProxy(ws, appId),
2384
2680
  portUrls: { ...fromWs, ...fromMsg },
2385
2681
  bypassCors: msg?.bypassCors === true,
2682
+ injectChat: !isIntakeShareWorkspace(ws),
2386
2683
  };
2387
2684
  }
2388
2685
 
2686
+ function isIntakeShareWorkspace(ws) {
2687
+ const status = String(ws?.intakeStatus || "");
2688
+ return (
2689
+ status === "form" ||
2690
+ status === "building" ||
2691
+ status === "error" ||
2692
+ isIntakePendingWorkspace(ws)
2693
+ );
2694
+ }
2695
+
2389
2696
  function sendProcessedProxyHttp(id, ctx, status, headers, body) {
2390
2697
  const processed = processShareHttpResponse({
2391
2698
  ...ctx,
@@ -2393,6 +2700,15 @@ function sendProcessedProxyHttp(id, ctx, status, headers, body) {
2393
2700
  headers,
2394
2701
  body,
2395
2702
  });
2703
+ const pathOnly = String(ctx.path || "").split("?")[0] || "/";
2704
+ if (processed.status >= 400 || pathOnly.includes("/__mp/")) {
2705
+ log(`proxy ${processed.status} ${pathOnly} :${ctx.port || 0}`, {
2706
+ slug: ctx.slug,
2707
+ bytes: processed.body?.length || 0,
2708
+ });
2709
+ } else {
2710
+ dbg(`proxy ${processed.status} ${pathOnly} :${ctx.port || 0}`);
2711
+ }
2396
2712
  replyProxyHttp(id, processed.status, processed.headers, processed.body);
2397
2713
  }
2398
2714
 
@@ -2807,6 +3123,7 @@ function handleProxyHttpFromAdmin(msg) {
2807
3123
  if (!id) return;
2808
3124
  const ws = workspaceForProxy(sandboxId);
2809
3125
  if (!ws) {
3126
+ warn(`proxy miss sandbox=${shortId(sandboxId)} app=${appId} id=${shortId(id)}`);
2810
3127
  bridgeSend({
2811
3128
  type: "proxy.http.error",
2812
3129
  id,
@@ -2829,6 +3146,9 @@ function handleProxyHttpFromAdmin(msg) {
2829
3146
  }
2830
3147
  const port = localPortForProxy(ws, appId);
2831
3148
  if (!port) {
3149
+ warn(
3150
+ `proxy no port sandbox=${shortId(sandboxId)} app=${appId} ${String(msg.method || "GET")} ${safeProxyPath(msg.path)}`
3151
+ );
2832
3152
  bridgeSend({
2833
3153
  type: "proxy.http.error",
2834
3154
  id,
@@ -2840,6 +3160,21 @@ function handleProxyHttpFromAdmin(msg) {
2840
3160
  const path = safeProxyPath(msg.path);
2841
3161
  const ctx = shareProxyContext(msg, ws, appId);
2842
3162
  ctx.port = port;
3163
+ const pathOnly = String(path || "").split("?")[0] || "/";
3164
+ const notableProxy =
3165
+ method !== "GET" && method !== "HEAD" ||
3166
+ pathOnly.includes("/__mp/") ||
3167
+ pathOnly === "/" ||
3168
+ pathOnly === "";
3169
+ if (notableProxy) {
3170
+ log(`proxy ${method} ${pathOnly} → :${port} ${appId}`, {
3171
+ sandbox: shortId(sandboxId),
3172
+ slug: ctx.slug,
3173
+ injectChat: ctx.injectChat !== false,
3174
+ });
3175
+ } else {
3176
+ dbg(`proxy ${method} ${pathOnly} → :${port} ${appId}`);
3177
+ }
2843
3178
  if (replyShareInterceptor(id, ctx, path)) return;
2844
3179
  const prepared = prepareShareHttpRequest(
2845
3180
  proxyReqHeaders(msg.headers),
@@ -3833,6 +4168,9 @@ async function startAiServerForWorkspace(ws, opts = {}) {
3833
4168
  logger: createLogger(`ai-server:${shortId(ws.sandboxId)}`),
3834
4169
  label: `ai-server:${shortId(ws.sandboxId)}`,
3835
4170
  listen: true,
4171
+ onTurnComplete: ({ userMessage }) => {
4172
+ noteIntakeWebsiteFeedback(ws, userMessage);
4173
+ },
3836
4174
  });
3837
4175
  embeddedChat.set(ws.sandboxId, instance);
3838
4176
  ws.port = instance.port;
@@ -3921,6 +4259,7 @@ async function stopWorkspaceApps(ws) {
3921
4259
  }
3922
4260
  }
3923
4261
  log(`stopping local apps on ports ${[...ports].join(", ") || "(none)"}`);
4262
+ await stopIntakeForWorkspace(ws.sandboxId);
3924
4263
  await stopEmbeddedChat(ws.sandboxId);
3925
4264
  await closeRememberedTerminals(ws.sandboxId);
3926
4265
  for (const port of ports) {
@@ -3950,8 +4289,11 @@ async function waitUntilReachable(url, timeoutMs, label, onWait) {
3950
4289
  function reservedPortsFor(cfg, sandboxId) {
3951
4290
  const reserved = new Set();
3952
4291
  for (const other of cfg.workspaces || []) {
3953
- if (other.sandboxId !== sandboxId && other.port) {
3954
- reserved.add(Number(other.port));
4292
+ if (other.sandboxId === sandboxId) continue;
4293
+ if (other.port) reserved.add(Number(other.port));
4294
+ if (other.sitePort) reserved.add(Number(other.sitePort));
4295
+ for (const app of listedHostApps(other.hostApps)) {
4296
+ if (app.port) reserved.add(Number(app.port));
3955
4297
  }
3956
4298
  }
3957
4299
  return reserved;
@@ -4025,6 +4367,42 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
4025
4367
 
4026
4368
  const chatEnv = chatLaunchEnv(ws, cfg);
4027
4369
 
4370
+ if (isIntakePendingWorkspace(ws)) {
4371
+ await startIntakeForWorkspace(ws, cfg);
4372
+ if (!cfg.noAiServer) {
4373
+ if (status.probe.chatUp) {
4374
+ ws.port = status.probe.chatPort;
4375
+ } else {
4376
+ await startAiServerForWorkspace(ws, {
4377
+ reserved,
4378
+ cfg,
4379
+ port: plan.aiPort,
4380
+ env: chatEnv,
4381
+ });
4382
+ }
4383
+ }
4384
+ status = await reconcileWorkspacePresence(ws, cfg, { timeoutMs: 1200 });
4385
+ return {
4386
+ up: status.aiServerUp,
4387
+ startedHosts: (await portIsLive(ws.sitePort, 800)) ? ["ui"] : [],
4388
+ sandboxId: ws.sandboxId,
4389
+ folderPath: ws.folderPath,
4390
+ port: ws.port,
4391
+ appUrl: status.host.appUrl || ws.appUrl,
4392
+ origins: status.host.origins,
4393
+ publicUrl:
4394
+ proxyUrlForApp(
4395
+ ws,
4396
+ hostAppOf(ws) || { id: "ui", role: "ui", host: true }
4397
+ ) || null,
4398
+ hostApps: status.probe.hostApps || ws.hostApps || [],
4399
+ processIssues: issuesForSandbox(ws.sandboxId).map(
4400
+ ({ role: _role, ...issue }) => issue
4401
+ ),
4402
+ warning: null,
4403
+ };
4404
+ }
4405
+
4028
4406
  if (!cfg.noAiServer) {
4029
4407
  if (status.probe.chatUp) {
4030
4408
  log(`chat already running on ${status.probe.chatPort} — not restarting`);
@@ -4454,12 +4832,7 @@ async function setupWorkspace(cfg, action) {
4454
4832
  );
4455
4833
  log(`setup begin sandbox=${shortId(sandboxId)} folder=${folderPath || "(none)"}`);
4456
4834
  const requestedPort = Number(action.payload?.port) || 3100;
4457
- const reserved = new Set();
4458
- for (const other of cfg.workspaces || []) {
4459
- if (other.sandboxId !== sandboxId && other.port) {
4460
- reserved.add(Number(other.port));
4461
- }
4462
- }
4835
+ const reserved = reservedPortsFor(cfg, sandboxId);
4463
4836
  let port = requestedPort;
4464
4837
  if (await isChatServerOnPort(requestedPort)) {
4465
4838
  reserved.add(requestedPort);
@@ -4578,12 +4951,27 @@ async function setupWorkspace(cfg, action) {
4578
4951
  );
4579
4952
  }
4580
4953
 
4954
+ let detectedKind = detectProjectKind(resolved);
4955
+ if (clientMode === "empty") detectedKind = "empty";
4956
+ log(`setup detect sandbox=${shortId(sandboxId)} kind=${detectedKind} mode=${clientMode}`);
4957
+ let sitePort = 0;
4958
+ if (
4959
+ (detectedKind === "empty" || clientMode === "empty") &&
4960
+ clientMode !== "skip" &&
4961
+ clientMode !== "existing"
4962
+ ) {
4963
+ const preferredSite = Number(existingWs?.sitePort) || 3000;
4964
+ sitePort = await findFreePort(preferredSite, reserved);
4965
+ log(`setup site port ${sitePort} reserved for intake / Next.js`);
4966
+ }
4967
+
4581
4968
  const client = configureClient({
4582
4969
  dir: resolved,
4583
4970
  port,
4584
4971
  appName,
4585
4972
  mode: clientMode,
4586
4973
  hostAppUrl,
4974
+ sitePort,
4587
4975
  });
4588
4976
 
4589
4977
  const aiOrigin = `http://localhost:${port}`;
@@ -4602,10 +4990,14 @@ async function setupWorkspace(cfg, action) {
4602
4990
  sandboxName: config.sandbox?.name,
4603
4991
  applicationName: config.sandbox?.applicationName,
4604
4992
  clientKind: client.kind,
4605
- appUrl,
4606
- sameOrigin: Boolean(client.sameOrigin),
4607
- appsRequested: existingWs?.appsRequested || false,
4608
- hostApps: existingWs?.hostApps,
4993
+ appUrl: sitePort ? `http://localhost:${sitePort}` : appUrl,
4994
+ sameOrigin: sitePort ? false : Boolean(client.sameOrigin),
4995
+ appsRequested: existingWs?.appsRequested || Boolean(sitePort),
4996
+ sitePort: sitePort || existingWs?.sitePort || null,
4997
+ intakeStatus: sitePort ? "form" : existingWs?.intakeStatus || null,
4998
+ hostApps: sitePort
4999
+ ? [intakeHostApp(appName, sitePort, resolved)]
5000
+ : existingWs?.hostApps,
4609
5001
  store: {
4610
5002
  serverKey: String(config.env?.MAINTAINER_PRO_API_KEY || "").trim(),
4611
5003
  clientKey: String(
@@ -4619,13 +5011,21 @@ async function setupWorkspace(cfg, action) {
4619
5011
  else cfg.workspaces.push(entry);
4620
5012
  saveConfig(cfg);
4621
5013
 
4622
- const ports = await resolveWorkspaceHostApps(entry, {
4623
- cfg,
4624
- allowAi: false,
4625
- });
5014
+ let ports = { apps: entry.hostApps || [], confused: false, reasons: [] };
5015
+ if (!sitePort) {
5016
+ log(`setup existing app ${shortId(sandboxId)} kind=${client.kind}`);
5017
+ ports = await resolveWorkspaceHostApps(entry, {
5018
+ cfg,
5019
+ allowAi: false,
5020
+ });
5021
+ } else {
5022
+ log(`setup empty folder ${shortId(sandboxId)} — starting intake on :${sitePort}`);
5023
+ await startIntakeForWorkspace(entry, cfg);
5024
+ }
4626
5025
  const needsReview =
4627
- Boolean(ports.confused) ||
4628
- !(ports.apps || []).some((app) => app && app.role !== "ai-server");
5026
+ !sitePort &&
5027
+ (Boolean(ports.confused) ||
5028
+ !(ports.apps || []).some((app) => app && app.role !== "ai-server"));
4629
5029
  if (needsReview) {
4630
5030
  activity(
4631
5031
  sandboxId,
@@ -4636,13 +5036,16 @@ async function setupWorkspace(cfg, action) {
4636
5036
  // Do not auto-repair the repo on attach — interactive Review setup handles suggestions.
4637
5037
  const projectInfo = entry.projectInfo || null;
4638
5038
 
4639
- await inspectHostJobs(entry);
5039
+ if (!sitePort) await inspectHostJobs(entry);
4640
5040
 
4641
- const openUrl = client.sameOrigin
4642
- ? `http://localhost:${entry.port}`
4643
- : entry.appUrl || appUrl;
5041
+ const intakeUp = sitePort ? await portIsLive(sitePort, 1200) : false;
5042
+ const openUrl = sitePort
5043
+ ? `http://localhost:${sitePort}`
5044
+ : client.sameOrigin
5045
+ ? `http://localhost:${entry.port}`
5046
+ : entry.appUrl || appUrl;
4644
5047
  const aiServerUp = await isChatServerOnPort(entry.port);
4645
- if (aiServerUp) {
5048
+ if (aiServerUp || intakeUp) {
4646
5049
  clearProcessProblem(sandboxId, "ai_server_launch", "ai");
4647
5050
  clearProcessProblem(sandboxId, "apps_not_started");
4648
5051
  }
@@ -4650,17 +5053,21 @@ async function setupWorkspace(cfg, action) {
4650
5053
  const processIssues = issuesForSandbox(sandboxId).map(
4651
5054
  ({ role: _role, ...issue }) => issue
4652
5055
  );
4653
- const waitingForStart = !aiServerUp;
5056
+ const waitingForStart = sitePort ? !intakeUp : !aiServerUp;
4654
5057
  const warning = waitingForStart
4655
5058
  ? needsReview
4656
5059
  ? "Folder attached. Review the suggested apps and ports in Maintainer Pro, then Start Apps."
4657
- : "Folder is attached in Maintainer Pro. Review apps if needed, then use Start Apps."
5060
+ : sitePort
5061
+ ? "Folder attached. The intake form should be on the share link."
5062
+ : "Folder is attached in Maintainer Pro. Review apps if needed, then use Start Apps."
4658
5063
  : processIssues[0]?.message || null;
4659
5064
 
4660
5065
  for (const note of client.notes) log(`setup note ${note}`);
4661
5066
  log(
4662
- waitingForStart
4663
- ? `setup done — waiting for Start (${openUrl}) kind=${client.kind}`
5067
+ sitePort
5068
+ ? `setup done — intake on ${openUrl} kind=${client.kind}`
5069
+ : waitingForStart
5070
+ ? `setup done — waiting for Start (${openUrl}) kind=${client.kind}`
4664
5071
  : `setup done — chat already up (${openUrl}) kind=${client.kind}`
4665
5072
  );
4666
5073
 
@@ -4681,7 +5088,7 @@ async function setupWorkspace(cfg, action) {
4681
5088
  clientFiles: client.filesWritten,
4682
5089
  clientNotes: client.notes,
4683
5090
  aiServerUp,
4684
- startedHosts: [],
5091
+ startedHosts: intakeUp ? ["ui"] : [],
4685
5092
  openUrl,
4686
5093
  processIssues,
4687
5094
  warning,
@@ -4788,7 +5195,9 @@ async function runActions(cfg, actions) {
4788
5195
  action.code = canonicalActionCode(action.code);
4789
5196
  const startedAt = Date.now();
4790
5197
  const label = actionLabel(action);
4791
- log(`${label} start`);
5198
+ log(`${label} start`, {
5199
+ payload: sanitizeLogValue(action.payload || {}),
5200
+ });
4792
5201
  let ok = true;
4793
5202
  /** @type {Record<string, unknown>} */
4794
5203
  let result = {};
@@ -4865,6 +5274,7 @@ async function runActions(cfg, actions) {
4865
5274
  hostApps: hostApps?.apps || [],
4866
5275
  projectInfo: ws?.projectInfo || null,
4867
5276
  };
5277
+ log(`${label} recheck cli=${folderProbe.cliProvider || "none"} missing=${folderProbe.missingCli === true}`);
4868
5278
  } else if (action.code === "redetect_ports" || action.code === "propose_setup") {
4869
5279
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
4870
5280
  const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
@@ -5138,7 +5548,8 @@ async function runActions(cfg, actions) {
5138
5548
  log(
5139
5549
  `${label} ${ok ? "ok" : "failed"} ${elapsed}ms${
5140
5550
  summary ? ` ${summary}` : ""
5141
- }`
5551
+ }`,
5552
+ { result: sanitizeLogValue(result) }
5142
5553
  );
5143
5554
 
5144
5555
  try {
@@ -5521,6 +5932,12 @@ async function main() {
5521
5932
  );
5522
5933
  await warnIfMissingCli();
5523
5934
  await restoreHostsAfterReconnect(cfg);
5935
+ const websiteFeedbackTimer = setInterval(() => {
5936
+ void flushAllIntakeWebsiteFeedback();
5937
+ }, WEBSITE_FEEDBACK_CHECK_MS);
5938
+ if (typeof websiteFeedbackTimer.unref === "function") {
5939
+ websiteFeedbackTimer.unref();
5940
+ }
5524
5941
 
5525
5942
  /** @type {unknown[]} */
5526
5943
  const claimedActions = [];
@@ -5536,15 +5953,26 @@ async function main() {
5536
5953
  }
5537
5954
  await sendPresenceOverWs();
5538
5955
  } catch (err) {
5539
- warn(err instanceof Error ? err.message : String(err));
5956
+ warn(`action runner failed: ${err instanceof Error ? err.message : String(err)}`);
5540
5957
  } finally {
5541
5958
  workBusy = false;
5542
- if (claimedActions.length) void runWork();
5959
+ if (claimedActions.length) {
5960
+ log(`action runner still has ${claimedActions.length} queued`);
5961
+ void runWork();
5962
+ }
5543
5963
  }
5544
5964
  };
5545
5965
 
5546
5966
  const queueActions = (actions) => {
5547
5967
  if (!Array.isArray(actions) || actions.length === 0) return;
5968
+ log(
5969
+ `queue ${actions.length} action(s): ${actions.map((row) => row.code).join(", ")}`,
5970
+ {
5971
+ ids: actions.map((row) => shortId(row.id)),
5972
+ busy: workBusy,
5973
+ waiting: claimedActions.length,
5974
+ }
5975
+ );
5548
5976
  claimedActions.push(...actions);
5549
5977
  void runWork();
5550
5978
  };
@@ -5750,10 +6178,14 @@ async function main() {
5750
6178
  if (!msg || typeof msg !== "object") return;
5751
6179
  if (msg.type === "pong" || msg.type === "hello") return;
5752
6180
  if (msg.type === "actions") {
6181
+ log(`ws inbound actions (${Array.isArray(msg.actions) ? msg.actions.length : 0})`);
5753
6182
  queueActions(msg.actions);
5754
6183
  return;
5755
6184
  }
5756
6185
  if (msg.type === "heartbeat.ok") {
6186
+ const remotes = Array.isArray(msg.workspaces) ? msg.workspaces.length : 0;
6187
+ const incoming = Array.isArray(msg.actions) ? msg.actions.length : 0;
6188
+ dbg(`ws inbound heartbeat.ok workspaces=${remotes} actions=${incoming}`);
5757
6189
  syncAssignedWorkspaces(cfg, msg.workspaces);
5758
6190
  queueActions(msg.actions);
5759
6191
  return;
@@ -5779,12 +6211,17 @@ async function main() {
5779
6211
  return;
5780
6212
  }
5781
6213
  if (msg.type === "chat.run") {
6214
+ log(
6215
+ `ws inbound chat.run sandbox=${shortId(msg.sandboxId)} conv=${shortId(msg.conversationId)}`
6216
+ );
5782
6217
  void handleChatRun(msg);
5783
6218
  return;
5784
6219
  }
5785
6220
  if (msg.type === "error") {
5786
6221
  warn(`ws: ${msg.error || "error"}`);
6222
+ return;
5787
6223
  }
6224
+ dbg(`ws inbound ${msg.type}`);
5788
6225
  };
5789
6226
 
5790
6227
  const socketState = () => {
@@ -5942,6 +6379,7 @@ async function main() {
5942
6379
  clearHeartbeatTimer();
5943
6380
  clearPingTimer();
5944
6381
  clearReconnectTimer();
6382
+ clearInterval(websiteFeedbackTimer);
5945
6383
  if (watchdogTimer) {
5946
6384
  clearInterval(watchdogTimer);
5947
6385
  watchdogTimer = null;
@@ -5949,7 +6387,11 @@ async function main() {
5949
6387
  dropSocket(socket);
5950
6388
  socket = null;
5951
6389
  log("shutting down (other terminals stay open)");
5952
- void stopAllEmbeddedChat().finally(() => process.exit(0));
6390
+ void flushAllIntakeWebsiteFeedback({ force: true })
6391
+ .catch(() => {})
6392
+ .finally(() =>
6393
+ stopAllEmbeddedChat().finally(() => process.exit(0))
6394
+ );
5953
6395
  };
5954
6396
  process.on("SIGINT", shutdown);
5955
6397
  process.on("SIGTERM", shutdown);