@agent-native/core 0.12.16 → 0.12.17

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 (59) hide show
  1. package/dist/agent/production-agent.d.ts +1 -1
  2. package/dist/agent/production-agent.d.ts.map +1 -1
  3. package/dist/agent/production-agent.js +1 -1
  4. package/dist/agent/production-agent.js.map +1 -1
  5. package/dist/cli/create.js +1 -1
  6. package/dist/cli/create.js.map +1 -1
  7. package/dist/cli/index.js +13 -2
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/cli/workspace-dev.d.ts +40 -1
  10. package/dist/cli/workspace-dev.d.ts.map +1 -1
  11. package/dist/cli/workspace-dev.js +506 -384
  12. package/dist/cli/workspace-dev.js.map +1 -1
  13. package/dist/client/AgentPanel.d.ts +16 -0
  14. package/dist/client/AgentPanel.d.ts.map +1 -1
  15. package/dist/client/AgentPanel.js +30 -9
  16. package/dist/client/AgentPanel.js.map +1 -1
  17. package/dist/client/AssistantChat.d.ts +4 -0
  18. package/dist/client/AssistantChat.d.ts.map +1 -1
  19. package/dist/client/AssistantChat.js +16 -8
  20. package/dist/client/AssistantChat.js.map +1 -1
  21. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  22. package/dist/client/MultiTabAssistantChat.js +10 -1
  23. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  24. package/dist/client/NewWorkspaceAppFlow.d.ts.map +1 -1
  25. package/dist/client/NewWorkspaceAppFlow.js +2 -1
  26. package/dist/client/NewWorkspaceAppFlow.js.map +1 -1
  27. package/dist/client/agent-chat.d.ts +1 -1
  28. package/dist/client/agent-chat.js.map +1 -1
  29. package/dist/client/components/CodeRequiredDialog.d.ts +3 -2
  30. package/dist/client/components/CodeRequiredDialog.d.ts.map +1 -1
  31. package/dist/client/components/CodeRequiredDialog.js +4 -3
  32. package/dist/client/components/CodeRequiredDialog.js.map +1 -1
  33. package/dist/client/composer/PromptComposer.d.ts +2 -0
  34. package/dist/client/composer/PromptComposer.d.ts.map +1 -1
  35. package/dist/client/composer/PromptComposer.js +2 -2
  36. package/dist/client/composer/PromptComposer.js.map +1 -1
  37. package/dist/client/composer/TiptapComposer.d.ts +6 -1
  38. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  39. package/dist/client/composer/TiptapComposer.js +21 -12
  40. package/dist/client/composer/TiptapComposer.js.map +1 -1
  41. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  42. package/dist/client/settings/SettingsPanel.js +3 -0
  43. package/dist/client/settings/SettingsPanel.js.map +1 -1
  44. package/dist/client/use-send-to-agent-chat.d.ts +3 -3
  45. package/dist/client/use-send-to-agent-chat.js +3 -3
  46. package/dist/client/use-send-to-agent-chat.js.map +1 -1
  47. package/dist/deploy/workspace-deploy.js +4 -1
  48. package/dist/deploy/workspace-deploy.js.map +1 -1
  49. package/dist/templates/workspace-root/AGENTS.md +3 -1
  50. package/dist/templates/workspace-root/README.md +4 -4
  51. package/dist/vite/client.d.ts.map +1 -1
  52. package/dist/vite/client.js +34 -6
  53. package/dist/vite/client.js.map +1 -1
  54. package/docs/content/multi-app-workspace.md +1 -1
  55. package/package.json +1 -1
  56. package/src/templates/workspace-root/AGENTS.md +3 -1
  57. package/src/templates/workspace-root/README.md +4 -4
  58. package/dist/templates/workspace-root/netlify.toml +0 -11
  59. package/src/templates/workspace-root/netlify.toml +0 -11
@@ -4,16 +4,26 @@ import fs from "node:fs";
4
4
  import http from "node:http";
5
5
  import net from "node:net";
6
6
  import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
  import * as Sentry from "@sentry/node";
8
9
  import { extractOAuthStateAppId } from "../shared/oauth-state.js";
9
- const root = process.cwd();
10
- const appsDir = path.join(root, "apps");
11
- fs.mkdirSync(path.join(root, "data"), { recursive: true });
12
- const gatewayHost = process.env.WORKSPACE_HOST || "127.0.0.1";
13
- const requestedPort = Number(process.env.WORKSPACE_PORT || process.env.PORT || 8080);
14
- const appPortStart = Number(process.env.WORKSPACE_APP_PORT_START || 8100);
15
- const forceVite = process.env.WORKSPACE_VITE_FORCE === "1";
16
- let gatewayUrl = `http://${gatewayHost}:${requestedPort}`;
10
+ const DEFAULT_GATEWAY_HOST = "127.0.0.1";
11
+ const DEFAULT_GATEWAY_PORT = 8080;
12
+ const DEFAULT_APP_PORT_START = 8100;
13
+ const PROXY_READY_RETRY_DELAY_MS = 250;
14
+ const APP_RESTART_MAX_DELAY_MS = 10_000;
15
+ export function shouldEagerStartWorkspaceApps(args = [], env = process.env) {
16
+ return (args.includes("--eager") ||
17
+ env.WORKSPACE_EAGER === "1" ||
18
+ env.WORKSPACE_EAGER === "true");
19
+ }
20
+ export function initialWorkspaceAppIds(apps, defaultApp, eager, startDefault = true) {
21
+ if (eager)
22
+ return apps.map((app) => app.id);
23
+ if (!startDefault)
24
+ return [];
25
+ return apps.some((app) => app.id === defaultApp) ? [defaultApp] : [];
26
+ }
17
27
  function readJson(file) {
18
28
  try {
19
29
  return JSON.parse(fs.readFileSync(file, "utf8"));
@@ -22,13 +32,11 @@ function readJson(file) {
22
32
  return null;
23
33
  }
24
34
  }
25
- function discoverApps() {
35
+ function discoverApps(appsDir, appPortStart) {
26
36
  if (!fs.existsSync(appsDir))
27
37
  return [];
28
- // existsSync readdirSync is a TOCTOU race appsDir can vanish between
29
- // the two calls (e.g. user running `git checkout` on the workspace mid-dev).
30
- // Treat ENOENT as "no apps right now" and let the next 2s sync recover.
31
- // Other errors get surfaced to Sentry so we learn about new failure modes.
38
+ // existsSync -> readdirSync is a TOCTOU race. Treat ENOENT as "no apps
39
+ // right now" and let the polling sync recover.
32
40
  let entries;
33
41
  try {
34
42
  entries = fs.readdirSync(appsDir, { withFileTypes: true });
@@ -60,29 +68,18 @@ function discoverApps() {
60
68
  };
61
69
  })
62
70
  .filter((app) => !!app)
63
- .sort((a, b) => {
64
- if (a.id === "dispatch")
65
- return -1;
66
- if (b.id === "dispatch")
67
- return 1;
68
- return a.id.localeCompare(b.id);
69
- })
71
+ .sort(compareApps)
70
72
  .map((app, index) => ({ ...app, port: appPortStart + index }));
71
73
  }
72
- const apps = discoverApps();
73
- if (apps.length === 0) {
74
- console.error("[workspace] No apps found under ./apps");
75
- process.exit(1);
74
+ function compareApps(a, b) {
75
+ if (a.id === "dispatch")
76
+ return -1;
77
+ if (b.id === "dispatch")
78
+ return 1;
79
+ return a.id.localeCompare(b.id);
76
80
  }
77
- const appById = new Map(apps.map((app) => [app.id, app]));
78
- const defaultApp = process.env.WORKSPACE_DEFAULT_APP &&
79
- appById.has(process.env.WORKSPACE_DEFAULT_APP)
80
- ? process.env.WORKSPACE_DEFAULT_APP
81
- : appById.has("dispatch")
82
- ? "dispatch"
83
- : apps[0].id;
84
81
  function isChildDevServerUrlLine(line) {
85
- return /^\s*➜\s+(?:Local|Network):\s+https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):\d+(?:\/\S*)?\s*$/i.test(line);
82
+ return /^\s*->\s+(?:Local|Network):\s+https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):\d+(?:\/\S*)?\s*$/i.test(line.replace(/\u279c/g, "->"));
86
83
  }
87
84
  function pipeAppOutput(prefix, chunk, write) {
88
85
  const lines = String(chunk)
@@ -93,39 +90,6 @@ function pipeAppOutput(prefix, chunk, write) {
93
90
  return;
94
91
  write(lines.map((line) => `${prefix} ${line}`).join("\n") + "\n");
95
92
  }
96
- function syncApps() {
97
- const discovered = discoverApps();
98
- for (const app of discovered) {
99
- const existing = appById.get(app.id);
100
- if (existing) {
101
- existing.name = app.name;
102
- existing.dir = app.dir;
103
- continue;
104
- }
105
- const usedPorts = new Set(apps.map((existing) => existing.port));
106
- let port = appPortStart;
107
- while (usedPorts.has(port))
108
- port++;
109
- const next = { ...app, port };
110
- apps.push(next);
111
- apps.sort((a, b) => {
112
- if (a.id === "dispatch")
113
- return -1;
114
- if (b.id === "dispatch")
115
- return 1;
116
- return a.id.localeCompare(b.id);
117
- });
118
- appById.set(next.id, next);
119
- console.log(`[workspace] Detected new app: /${next.id}`);
120
- startApp(next);
121
- }
122
- }
123
- let syncTimer;
124
- function scheduleSync() {
125
- if (syncTimer)
126
- clearTimeout(syncTimer);
127
- syncTimer = setTimeout(syncApps, 400);
128
- }
129
93
  function firstPathSegment(url) {
130
94
  if (!url)
131
95
  return null;
@@ -138,128 +102,6 @@ function firstPathSegment(url) {
138
102
  return null;
139
103
  }
140
104
  }
141
- function appForRequest(req) {
142
- const params = new URL(req.url || "/", "http://workspace.local").searchParams;
143
- const explicit = params.get("_app");
144
- if (explicit && appById.has(explicit))
145
- return appById.get(explicit) ?? null;
146
- const direct = firstPathSegment(req.url);
147
- if (direct && appById.has(direct))
148
- return appById.get(direct) ?? null;
149
- const fromState = extractOAuthStateAppId(params.get("state"));
150
- if (fromState && appById.has(fromState)) {
151
- return appById.get(fromState) ?? null;
152
- }
153
- const referer = req.headers.referer;
154
- const fromReferer = typeof referer === "string" ? firstPathSegment(referer) : null;
155
- return fromReferer && appById.has(fromReferer)
156
- ? (appById.get(fromReferer) ?? null)
157
- : null;
158
- }
159
- function startApp(app) {
160
- if (app.process && !app.process.killed)
161
- return;
162
- if (app.restartTimer) {
163
- clearTimeout(app.restartTimer);
164
- app.restartTimer = undefined;
165
- }
166
- const basePath = `/${app.id}`;
167
- const workspaceAppsJson = JSON.stringify(apps.map((workspaceApp) => ({
168
- id: workspaceApp.id,
169
- name: workspaceApp.name,
170
- path: `/${workspaceApp.id}`,
171
- })));
172
- const child = spawn("pnpm", [
173
- "--dir",
174
- app.dir,
175
- "exec",
176
- "vite",
177
- "--host",
178
- "127.0.0.1",
179
- "--port",
180
- String(app.port),
181
- "--strictPort",
182
- ...(forceVite ? ["--force"] : []),
183
- ], {
184
- cwd: root,
185
- stdio: ["ignore", "pipe", "pipe"],
186
- env: {
187
- ...process.env,
188
- APP_NAME: app.id,
189
- AGENT_NATIVE_WORKSPACE: "1",
190
- AGENT_NATIVE_WORKSPACE_APPS_JSON: workspaceAppsJson,
191
- APP_BASE_PATH: basePath,
192
- VITE_AGENT_NATIVE_WORKSPACE: "1",
193
- VITE_APP_BASE_PATH: basePath,
194
- PORT: String(app.port),
195
- WORKSPACE_GATEWAY_URL: gatewayUrl,
196
- },
197
- });
198
- app.process = child;
199
- const prefix = `[${app.id}]`;
200
- const stableTimer = setTimeout(() => {
201
- app.restartAttempts = 0;
202
- }, 5_000);
203
- stableTimer.unref();
204
- child.stdout?.on("data", (chunk) => {
205
- pipeAppOutput(prefix, chunk, (value) => process.stdout.write(value));
206
- });
207
- child.stderr?.on("data", (chunk) => {
208
- pipeAppOutput(prefix, chunk, (value) => process.stderr.write(value));
209
- });
210
- child.on("exit", (code) => {
211
- clearTimeout(stableTimer);
212
- app.process = undefined;
213
- app.ready = false;
214
- if (code === 0 || shuttingDown)
215
- return;
216
- app.restartAttempts = (app.restartAttempts ?? 0) + 1;
217
- const delay = appRestartDelay(app.restartAttempts);
218
- console.error(`${prefix} exited with code ${code}; retrying in ${Math.round(delay / 1000)}s`);
219
- app.restartTimer = setTimeout(() => {
220
- app.restartTimer = undefined;
221
- startApp(app);
222
- }, delay);
223
- app.restartTimer.unref();
224
- });
225
- }
226
- function renderIndex() {
227
- return `<!doctype html>
228
- <html>
229
- <head>
230
- <meta charset="utf-8" />
231
- <meta name="viewport" content="width=device-width, initial-scale=1" />
232
- <title>Agent-Native Workspace</title>
233
- <style>
234
- body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 32px; background: #fafafa; color: #171717; }
235
- main { max-width: 760px; margin: 0 auto; }
236
- a { color: inherit; text-decoration: none; }
237
- .grid { display: grid; gap: 12px; margin-top: 20px; }
238
- .card { display: flex; justify-content: space-between; border: 1px solid #d4d4d4; border-radius: 8px; padding: 14px 16px; background: white; }
239
- .muted { color: #737373; }
240
- </style>
241
- </head>
242
- <body>
243
- <main>
244
- <h1>Agent-Native Workspace</h1>
245
- <p class="muted">Open an app below. Dispatch is the workspace control plane.</p>
246
- <div class="grid">
247
- ${apps
248
- .map((app) => `<a class="card" href="/${app.id}"><strong>${app.name}</strong><span class="muted">/${app.id}</span></a>`)
249
- .join("")}
250
- </div>
251
- </main>
252
- </body>
253
- </html>`;
254
- }
255
- // On `pnpm dev` the gateway answers requests immediately, but each app's vite
256
- // server takes a beat to bind its port. Without retry, the user sees an
257
- // "App is not ready yet: ECONNREFUSED" banner on the first page load and has
258
- // to refresh manually. We do a quick pre-flight TCP connect with retry so
259
- // startup is invisible for the common case (small/no body, slow boot).
260
- const PROXY_READY_TIMEOUT_MS = Number(process.env.WORKSPACE_PROXY_READY_TIMEOUT_MS ?? 30_000);
261
- const PROXY_READY_RETRY_DELAY_MS = 250;
262
- const APP_RESTART_MAX_DELAY_MS = 10_000;
263
105
  function appRestartDelay(attempts) {
264
106
  return Math.min(1_000 * 2 ** Math.max(0, attempts - 1), APP_RESTART_MAX_DELAY_MS);
265
107
  }
@@ -288,230 +130,510 @@ function firstHeaderValue(value) {
288
130
  return undefined;
289
131
  return String(value);
290
132
  }
291
- function forwardedProto(req) {
292
- return (firstHeaderValue(req.headers["x-forwarded-proto"]) ||
293
- (req.socket.encrypted ? "https" : "http"));
133
+ function wantsHtml(req) {
134
+ if (req.method !== "GET" && req.method !== "HEAD")
135
+ return false;
136
+ const accept = firstHeaderValue(req.headers.accept);
137
+ if (!accept)
138
+ return false;
139
+ return accept.includes("text/html");
294
140
  }
295
- function forwardedHost(req) {
296
- return (firstHeaderValue(req.headers["x-forwarded-host"]) ||
297
- firstHeaderValue(req.headers.host) ||
298
- new URL(gatewayUrl).host);
141
+ function renderStartingApp(app) {
142
+ const escapedName = escapeHtml(app.name || app.id);
143
+ return `<!doctype html>
144
+ <html>
145
+ <head>
146
+ <meta charset="utf-8" />
147
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
148
+ <meta http-equiv="refresh" content="1" />
149
+ <title>Starting ${escapedName}</title>
150
+ <style>
151
+ body { min-height: 100vh; margin: 0; display: grid; place-items: center; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #fafafa; color: #171717; }
152
+ main { width: min(420px, calc(100vw - 48px)); }
153
+ .bar { height: 3px; overflow: hidden; border-radius: 999px; background: #e5e5e5; }
154
+ .bar::before { content: ""; display: block; height: 100%; width: 42%; border-radius: inherit; background: #171717; animation: load 1s ease-in-out infinite; }
155
+ p { color: #737373; }
156
+ @keyframes load { 0% { transform: translateX(-105%); } 100% { transform: translateX(245%); } }
157
+ </style>
158
+ <script>setTimeout(() => window.location.reload(), 900);</script>
159
+ </head>
160
+ <body>
161
+ <main>
162
+ <div class="bar"></div>
163
+ <h1>Starting ${escapedName}</h1>
164
+ <p>The workspace gateway is waking this app's dev server.</p>
165
+ </main>
166
+ </body>
167
+ </html>`;
299
168
  }
300
- function proxyHeaders(req, targetHost) {
301
- return {
302
- ...req.headers,
303
- "x-forwarded-host": forwardedHost(req),
304
- "x-forwarded-proto": forwardedProto(req),
305
- host: targetHost,
306
- };
169
+ function escapeHtml(value) {
170
+ return value.replace(/[&<>"']/g, (char) => {
171
+ switch (char) {
172
+ case "&":
173
+ return "&amp;";
174
+ case "<":
175
+ return "&lt;";
176
+ case ">":
177
+ return "&gt;";
178
+ case '"':
179
+ return "&quot;";
180
+ default:
181
+ return "&#39;";
182
+ }
183
+ });
307
184
  }
308
- async function waitForPort(port, deadline) {
309
- while (Date.now() < deadline) {
310
- if (await probePort(port))
311
- return true;
312
- await new Promise((r) => setTimeout(r, PROXY_READY_RETRY_DELAY_MS));
313
- }
314
- return false;
185
+ function renderIndex(apps) {
186
+ return `<!doctype html>
187
+ <html>
188
+ <head>
189
+ <meta charset="utf-8" />
190
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
191
+ <title>Agent-Native Workspace</title>
192
+ <style>
193
+ body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 32px; background: #fafafa; color: #171717; }
194
+ main { max-width: 760px; margin: 0 auto; }
195
+ a { color: inherit; text-decoration: none; }
196
+ .grid { display: grid; gap: 12px; margin-top: 20px; }
197
+ .card { display: flex; justify-content: space-between; border: 1px solid #d4d4d4; border-radius: 8px; padding: 14px 16px; background: white; }
198
+ .muted { color: #737373; }
199
+ </style>
200
+ </head>
201
+ <body>
202
+ <main>
203
+ <h1>Agent-Native Workspace</h1>
204
+ <p class="muted">Open an app below. Dispatch is the workspace control plane when installed.</p>
205
+ <div class="grid">
206
+ ${apps
207
+ .map((app) => `<a class="card" href="/${app.id}"><strong>${escapeHtml(app.name)}</strong><span class="muted">/${escapeHtml(app.id)}</span></a>`)
208
+ .join("")}
209
+ </div>
210
+ </main>
211
+ </body>
212
+ </html>`;
315
213
  }
316
- function proxyHttp(app, req, res) {
317
- const dispatch = () => {
318
- const headers = proxyHeaders(req, `127.0.0.1:${app.port}`);
319
- const proxyReq = http.request({
320
- hostname: "127.0.0.1",
321
- port: app.port,
322
- method: req.method,
323
- path: req.url,
324
- headers,
325
- }, (proxyRes) => {
326
- app.ready = true;
327
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
328
- proxyRes.pipe(res);
214
+ export function runWorkspaceDev(options = {}) {
215
+ const args = options.args ?? process.argv.slice(2);
216
+ const env = options.env ?? process.env;
217
+ const root = options.root ?? process.cwd();
218
+ const appsDir = path.join(root, "apps");
219
+ const spawnProcess = options.spawnProcess ?? spawn;
220
+ const stdout = options.stdout ?? process.stdout;
221
+ const stderr = options.stderr ?? process.stderr;
222
+ fs.mkdirSync(path.join(root, "data"), { recursive: true });
223
+ const gatewayHost = env.WORKSPACE_HOST || DEFAULT_GATEWAY_HOST;
224
+ const requestedPort = Number(env.WORKSPACE_PORT || env.PORT || DEFAULT_GATEWAY_PORT);
225
+ const appPortStart = Number(env.WORKSPACE_APP_PORT_START || DEFAULT_APP_PORT_START);
226
+ const forceVite = env.WORKSPACE_VITE_FORCE === "1";
227
+ const eager = shouldEagerStartWorkspaceApps(args, env);
228
+ const proxyReadyTimeoutMs = Number(env.WORKSPACE_PROXY_READY_TIMEOUT_MS ?? 30_000);
229
+ let gatewayUrl = `http://${gatewayHost}:${requestedPort}`;
230
+ const apps = discoverApps(appsDir, appPortStart);
231
+ if (apps.length === 0) {
232
+ throw new Error("[workspace] No apps found under ./apps");
233
+ }
234
+ const appById = new Map(apps.map((app) => [app.id, app]));
235
+ const explicitDefaultApp = env.WORKSPACE_DEFAULT_APP && appById.has(env.WORKSPACE_DEFAULT_APP)
236
+ ? env.WORKSPACE_DEFAULT_APP
237
+ : null;
238
+ const hasDispatch = appById.has("dispatch");
239
+ const defaultApp = explicitDefaultApp ?? (hasDispatch ? "dispatch" : apps[0].id);
240
+ const redirectRootToDefault = Boolean(explicitDefaultApp || hasDispatch);
241
+ let syncTimer;
242
+ let shuttingDown = false;
243
+ let workspaceStarted = false;
244
+ let readyResolve;
245
+ const ready = new Promise((resolve) => {
246
+ readyResolve = resolve;
247
+ });
248
+ function workspaceAppsJson() {
249
+ return JSON.stringify(apps.map((workspaceApp) => ({
250
+ id: workspaceApp.id,
251
+ name: workspaceApp.name,
252
+ path: `/${workspaceApp.id}`,
253
+ })));
254
+ }
255
+ function syncApps() {
256
+ const discovered = discoverApps(appsDir, appPortStart);
257
+ for (const app of discovered) {
258
+ const existing = appById.get(app.id);
259
+ if (existing) {
260
+ existing.name = app.name;
261
+ existing.dir = app.dir;
262
+ continue;
263
+ }
264
+ const usedPorts = new Set(apps.map((existingApp) => existingApp.port));
265
+ let port = appPortStart;
266
+ while (usedPorts.has(port))
267
+ port++;
268
+ const next = { ...app, port };
269
+ apps.push(next);
270
+ apps.sort(compareApps);
271
+ appById.set(next.id, next);
272
+ stdout.write(`[workspace] Detected new app: /${next.id}\n`);
273
+ }
274
+ }
275
+ function scheduleSync() {
276
+ if (syncTimer)
277
+ clearTimeout(syncTimer);
278
+ syncTimer = setTimeout(syncApps, 400);
279
+ }
280
+ function appForRequest(req) {
281
+ const params = new URL(req.url || "/", "http://workspace.local")
282
+ .searchParams;
283
+ const explicit = params.get("_app");
284
+ if (explicit && appById.has(explicit))
285
+ return appById.get(explicit) ?? null;
286
+ const direct = firstPathSegment(req.url);
287
+ if (direct && appById.has(direct))
288
+ return appById.get(direct) ?? null;
289
+ const fromState = extractOAuthStateAppId(params.get("state"));
290
+ if (fromState && appById.has(fromState)) {
291
+ return appById.get(fromState) ?? null;
292
+ }
293
+ const referer = req.headers.referer;
294
+ const fromReferer = typeof referer === "string" ? firstPathSegment(referer) : null;
295
+ return fromReferer && appById.has(fromReferer)
296
+ ? (appById.get(fromReferer) ?? null)
297
+ : null;
298
+ }
299
+ function startApp(app) {
300
+ if (app.process && !app.process.killed)
301
+ return;
302
+ if (app.restartTimer) {
303
+ clearTimeout(app.restartTimer);
304
+ app.restartTimer = undefined;
305
+ }
306
+ const basePath = `/${app.id}`;
307
+ const child = spawnProcess("pnpm", [
308
+ "--dir",
309
+ app.dir,
310
+ "exec",
311
+ "vite",
312
+ "--host",
313
+ "127.0.0.1",
314
+ "--port",
315
+ String(app.port),
316
+ "--strictPort",
317
+ ...(forceVite ? ["--force"] : []),
318
+ ], {
319
+ cwd: root,
320
+ stdio: ["ignore", "pipe", "pipe"],
321
+ env: {
322
+ ...env,
323
+ APP_NAME: app.id,
324
+ AGENT_NATIVE_WORKSPACE: "1",
325
+ AGENT_NATIVE_WORKSPACE_APPS_JSON: workspaceAppsJson(),
326
+ APP_BASE_PATH: basePath,
327
+ VITE_AGENT_NATIVE_WORKSPACE: "1",
328
+ VITE_APP_BASE_PATH: basePath,
329
+ PORT: String(app.port),
330
+ WORKSPACE_GATEWAY_URL: gatewayUrl,
331
+ },
329
332
  });
330
- proxyReq.on("error", (err) => {
331
- if (res.headersSent) {
332
- res.end();
333
+ app.process = child;
334
+ const prefix = `[${app.id}]`;
335
+ const stableTimer = setTimeout(() => {
336
+ app.restartAttempts = 0;
337
+ }, 5_000);
338
+ stableTimer.unref();
339
+ child.stdout?.on("data", (chunk) => {
340
+ pipeAppOutput(prefix, chunk, (value) => stdout.write(value));
341
+ });
342
+ child.stderr?.on("data", (chunk) => {
343
+ pipeAppOutput(prefix, chunk, (value) => stderr.write(value));
344
+ });
345
+ child.on("exit", (code) => {
346
+ clearTimeout(stableTimer);
347
+ app.process = undefined;
348
+ app.ready = false;
349
+ if (code === 0 || shuttingDown)
333
350
  return;
334
- }
335
- res.writeHead(502, { "content-type": "text/plain" });
336
- res.end(`App "${app.id}" is not ready yet: ${err.message}`);
351
+ app.restartAttempts = (app.restartAttempts ?? 0) + 1;
352
+ const delay = appRestartDelay(app.restartAttempts);
353
+ stderr.write(`${prefix} exited with code ${code}; retrying in ${Math.round(delay / 1000)}s\n`);
354
+ app.restartTimer = setTimeout(() => {
355
+ app.restartTimer = undefined;
356
+ startApp(app);
357
+ }, delay);
358
+ app.restartTimer.unref();
337
359
  });
338
- req.pipe(proxyReq);
339
- };
340
- // Fast path: the upstream has accepted at least one request before, so it's
341
- // listening. Skip the probe so steady-state requests stay zero-latency.
342
- if (app.ready) {
343
- dispatch();
344
- return;
345
360
  }
346
- // Cold path: hold the request open while the child server boots. Node
347
- // keeps the request body in paused mode until a consumer attaches via
348
- // pipe(), so awaiting waitForPort() doesn't lose data.
349
- void waitForPort(app.port, Date.now() + PROXY_READY_TIMEOUT_MS).then((ready) => {
350
- if (!ready) {
351
- if (!res.headersSent) {
361
+ function forwardedProto(req) {
362
+ return (firstHeaderValue(req.headers["x-forwarded-proto"]) ||
363
+ (req.socket.encrypted ? "https" : "http"));
364
+ }
365
+ function forwardedHost(req) {
366
+ return (firstHeaderValue(req.headers["x-forwarded-host"]) ||
367
+ firstHeaderValue(req.headers.host) ||
368
+ new URL(gatewayUrl).host);
369
+ }
370
+ function proxyHeaders(req, targetHost) {
371
+ return {
372
+ ...req.headers,
373
+ "x-forwarded-host": forwardedHost(req),
374
+ "x-forwarded-proto": forwardedProto(req),
375
+ host: targetHost,
376
+ };
377
+ }
378
+ async function waitForPort(port, deadline) {
379
+ while (Date.now() < deadline) {
380
+ if (await probePort(port))
381
+ return true;
382
+ await new Promise((r) => setTimeout(r, PROXY_READY_RETRY_DELAY_MS));
383
+ }
384
+ return false;
385
+ }
386
+ function proxyHttp(app, req, res) {
387
+ const cold = !app.process || app.process.killed;
388
+ startApp(app);
389
+ if (!app.ready && wantsHtml(req)) {
390
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
391
+ if (req.method === "HEAD") {
392
+ res.end();
393
+ return;
394
+ }
395
+ res.end(renderStartingApp(app));
396
+ return;
397
+ }
398
+ const dispatch = () => {
399
+ const headers = proxyHeaders(req, `127.0.0.1:${app.port}`);
400
+ const proxyReq = http.request({
401
+ hostname: "127.0.0.1",
402
+ port: app.port,
403
+ method: req.method,
404
+ path: req.url,
405
+ headers,
406
+ }, (proxyRes) => {
407
+ app.ready = true;
408
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
409
+ proxyRes.pipe(res);
410
+ });
411
+ proxyReq.on("error", (err) => {
412
+ if (res.headersSent) {
413
+ res.end();
414
+ return;
415
+ }
352
416
  res.writeHead(502, { "content-type": "text/plain" });
353
- res.end(`App "${app.id}" is not ready yet: connect ECONNREFUSED 127.0.0.1:${app.port}`);
417
+ res.end(`App "${app.id}" is not ready yet: ${err.message}`);
418
+ });
419
+ req.pipe(proxyReq);
420
+ };
421
+ // Fast path: the upstream has accepted at least one request before, so
422
+ // it's listening. Skip the probe so steady-state requests stay zero-latency.
423
+ if (app.ready && !cold) {
424
+ dispatch();
425
+ return;
426
+ }
427
+ // Cold path: hold non-HTML requests open while the child server boots.
428
+ // Node keeps the request body paused until pipe() attaches.
429
+ void waitForPort(app.port, Date.now() + proxyReadyTimeoutMs).then((ready) => {
430
+ if (!ready) {
431
+ if (!res.headersSent) {
432
+ res.writeHead(502, { "content-type": "text/plain" });
433
+ res.end(`App "${app.id}" is not ready yet: connect ECONNREFUSED 127.0.0.1:${app.port}`);
434
+ }
435
+ else {
436
+ res.end();
437
+ }
438
+ return;
354
439
  }
355
- else {
356
- res.end();
440
+ app.ready = true;
441
+ dispatch();
442
+ });
443
+ }
444
+ function proxyUpgrade(app, req, socket, head) {
445
+ startApp(app);
446
+ void waitForPort(app.port, Date.now() + proxyReadyTimeoutMs).then((ready) => {
447
+ if (!ready) {
448
+ socket.destroy();
449
+ return;
357
450
  }
451
+ app.ready = true;
452
+ const target = net.connect(app.port, "127.0.0.1", () => {
453
+ const headers = Object.entries(proxyHeaders(req, `127.0.0.1:${app.port}`))
454
+ .flatMap(([key, value]) => Array.isArray(value)
455
+ ? value.map((item) => `${key}: ${item}`)
456
+ : [`${key}: ${value ?? ""}`])
457
+ .join("\r\n");
458
+ target.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${headers}\r\n\r\n`);
459
+ if (head.length)
460
+ target.write(head);
461
+ socket.pipe(target).pipe(socket);
462
+ });
463
+ target.on("error", () => socket.destroy());
464
+ });
465
+ }
466
+ function handleWatcherError(err) {
467
+ if (err.code === "ENOSPC") {
468
+ stderr.write(`[workspace] Recursive file watcher hit the system limit (ENOSPC). ` +
469
+ `New apps will still be detected via polling every ~2s. ` +
470
+ `On Linux you can raise the limit with ` +
471
+ `\`sudo sysctl fs.inotify.max_user_watches=524288\` ` +
472
+ `(persist via /etc/sysctl.d/*.conf). On macOS/Windows this usually ` +
473
+ `means too many other watchers are running.\n`);
474
+ Sentry.captureException(err, {
475
+ tags: { handled: "dev-watch-enospc" },
476
+ level: "warning",
477
+ });
358
478
  return;
359
479
  }
360
- app.ready = true;
361
- dispatch();
362
- });
363
- }
364
- function proxyUpgrade(app, req, socket, head) {
365
- const target = net.connect(app.port, "127.0.0.1", () => {
366
- const headers = Object.entries(proxyHeaders(req, `127.0.0.1:${app.port}`))
367
- .flatMap(([key, value]) => Array.isArray(value)
368
- ? value.map((item) => `${key}: ${item}`)
369
- : [`${key}: ${value ?? ""}`])
370
- .join("\r\n");
371
- target.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${headers}\r\n\r\n`);
372
- if (head.length)
373
- target.write(head);
374
- socket.pipe(target).pipe(socket);
375
- });
376
- target.on("error", () => socket.destroy());
377
- }
378
- let shuttingDown = false;
379
- let workspaceStarted = false;
380
- function handleWatcherError(err) {
381
- // ENOSPC: system inotify watcher limit hit (Linux). Userland-fixable;
382
- // capture as a warning so we still see frequency in Sentry but don't get
383
- // paged. Print actionable guidance and continue without watching — the
384
- // 2s polling interval below keeps app discovery working.
385
- if (err.code === "ENOSPC") {
386
- console.warn(`[workspace] Recursive file watcher hit the system limit (ENOSPC). ` +
387
- `New apps will still be detected via polling every ~2s. ` +
388
- `On Linux you can raise the limit with ` +
389
- `\`sudo sysctl fs.inotify.max_user_watches=524288\` ` +
390
- `(persist via /etc/sysctl.d/*.conf). On macOS/Windows this usually ` +
391
- `means too many other watchers are running.`);
480
+ if (err.code === "ENOENT") {
481
+ return;
482
+ }
483
+ stderr.write(`[workspace] Recursive file watcher failed (${err.code ?? "unknown"}): ${err.message}. ` +
484
+ `Falling back to polling.\n`);
392
485
  Sentry.captureException(err, {
393
- tags: { handled: "dev-watch-enospc" },
486
+ tags: { handled: "dev-watch-unknown" },
394
487
  level: "warning",
395
488
  });
396
- return;
397
489
  }
398
- // ENOENT: a watched directory disappeared (or a transient subdir under
399
- // appsDir vanished mid-enumeration). Benign — the polling fallback and
400
- // future scheduleSync calls will re-establish state. Don't capture.
401
- if (err.code === "ENOENT") {
402
- console.debug(`[workspace] Recursive file watcher saw a directory disappear ` +
403
- `(ENOENT: ${err.path ?? "unknown"}). Polling fallback will recover.`);
404
- return;
405
- }
406
- // Unknown failure mode — keep the dev experience alive (polling still
407
- // runs) but surface to Sentry as a warning so we learn about new cases.
408
- console.warn(`[workspace] Recursive file watcher failed (${err.code ?? "unknown"}): ${err.message}. ` +
409
- `Falling back to polling.`);
410
- Sentry.captureException(err, {
411
- tags: { handled: "dev-watch-unknown" },
412
- level: "warning",
413
- });
414
- }
415
- function startWorkspaceProcesses() {
416
- if (workspaceStarted)
417
- return;
418
- workspaceStarted = true;
419
- for (const app of apps)
420
- startApp(app);
421
- try {
422
- const watcher = fs.watch(appsDir, { recursive: true }, scheduleSync);
423
- // Async errors (e.g. ENOENT when a subdir vanishes mid-watch) surface on
424
- // the watcher rather than the original call site. Without an `error`
425
- // listener, Node would treat them as uncaught and crash the dev process.
426
- watcher.on("error", (err) => {
490
+ function startWorkspaceProcesses() {
491
+ if (workspaceStarted)
492
+ return;
493
+ workspaceStarted = true;
494
+ for (const id of initialWorkspaceAppIds(apps, defaultApp, eager, redirectRootToDefault)) {
495
+ const app = appById.get(id);
496
+ if (app)
497
+ startApp(app);
498
+ }
499
+ try {
500
+ const watcher = fs.watch(appsDir, { recursive: true }, scheduleSync);
501
+ watcher.on("error", (err) => {
502
+ handleWatcherError(err);
503
+ });
504
+ }
505
+ catch (err) {
427
506
  handleWatcherError(err);
428
- });
507
+ }
508
+ setInterval(syncApps, 2_000).unref();
429
509
  }
430
- catch (err) {
431
- handleWatcherError(err);
510
+ function openBrowser(url) {
511
+ if (options.openBrowser === false || env.WORKSPACE_NO_OPEN === "1")
512
+ return;
513
+ const command = process.platform === "darwin"
514
+ ? "open"
515
+ : process.platform === "win32"
516
+ ? "cmd"
517
+ : "xdg-open";
518
+ const openArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
519
+ const child = spawnProcess(command, openArgs, {
520
+ stdio: "ignore",
521
+ detached: true,
522
+ });
523
+ child.unref();
432
524
  }
433
- setInterval(syncApps, 2_000).unref();
434
- }
435
- function openBrowser(url) {
436
- if (process.env.WORKSPACE_NO_OPEN === "1")
437
- return;
438
- const command = process.platform === "darwin"
439
- ? "open"
440
- : process.platform === "win32"
441
- ? "cmd"
442
- : "xdg-open";
443
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
444
- const child = spawn(command, args, {
445
- stdio: "ignore",
446
- detached: true,
525
+ const server = http.createServer((req, res) => {
526
+ if (req.url === "/" || req.url === "/index.html") {
527
+ if (redirectRootToDefault) {
528
+ res.writeHead(302, { location: `/${defaultApp}` });
529
+ res.end();
530
+ return;
531
+ }
532
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
533
+ res.end(renderIndex(apps));
534
+ return;
535
+ }
536
+ if (req.url === "/_workspace/apps") {
537
+ syncApps();
538
+ res.writeHead(200, { "content-type": "application/json" });
539
+ res.end(JSON.stringify(apps.map((app) => ({
540
+ id: app.id,
541
+ name: app.name,
542
+ path: `/${app.id}`,
543
+ port: app.port,
544
+ running: Boolean(app.process && !app.process.killed),
545
+ }))));
546
+ return;
547
+ }
548
+ let app = appForRequest(req);
549
+ if (!app) {
550
+ syncApps();
551
+ app = appForRequest(req);
552
+ }
553
+ if (!app) {
554
+ res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
555
+ res.end(renderIndex(apps));
556
+ return;
557
+ }
558
+ proxyHttp(app, req, res);
447
559
  });
448
- child.unref();
449
- }
450
- const server = http.createServer((req, res) => {
451
- if (req.url === "/" || req.url === "/index.html") {
452
- res.writeHead(302, { location: `/${defaultApp}` });
453
- res.end();
454
- return;
455
- }
456
- if (req.url === "/_workspace/apps") {
457
- res.writeHead(200, { "content-type": "application/json" });
458
- res.end(JSON.stringify(apps.map((app) => ({
459
- id: app.id,
460
- name: app.name,
461
- path: `/${app.id}`,
462
- port: app.port,
463
- }))));
464
- return;
465
- }
466
- const app = appForRequest(req);
467
- if (!app) {
468
- res.writeHead(404, { "content-type": "text/html" });
469
- res.end(renderIndex());
470
- return;
471
- }
472
- proxyHttp(app, req, res);
473
- });
474
- server.on("upgrade", (req, socket, head) => {
475
- const app = appForRequest(req);
476
- if (!app) {
477
- socket.destroy();
478
- return;
479
- }
480
- proxyUpgrade(app, req, socket, head);
481
- });
482
- function listen(port, attempts = 20) {
483
- server.once("error", (err) => {
484
- if (err.code === "EADDRINUSE" && attempts > 0) {
485
- listen(port + 1, attempts - 1);
560
+ server.on("upgrade", (req, socket, head) => {
561
+ const app = appForRequest(req);
562
+ if (!app) {
563
+ socket.destroy();
486
564
  return;
487
565
  }
488
- console.error(`[workspace] Could not start gateway: ${err.message}`);
489
- process.exit(1);
566
+ proxyUpgrade(app, req, socket, head);
490
567
  });
491
- server.listen(port, gatewayHost, () => {
492
- const address = server.address();
493
- const actualPort = typeof address === "object" && address ? address.port : port;
494
- gatewayUrl = `http://${gatewayHost}:${actualPort}`;
495
- console.log(`[workspace] Default: http://${gatewayHost}:${actualPort}/${defaultApp}`);
496
- console.log(`[workspace] Gateway: http://${gatewayHost}:${actualPort}`);
568
+ function listen(port, attempts = 20) {
569
+ server.once("error", (err) => {
570
+ if (err.code === "EADDRINUSE" && attempts > 0) {
571
+ listen(port + 1, attempts - 1);
572
+ return;
573
+ }
574
+ stderr.write(`[workspace] Could not start gateway: ${err.message}\n`);
575
+ throw err;
576
+ });
577
+ server.listen(port, gatewayHost, () => {
578
+ const address = server.address();
579
+ const actualPort = typeof address === "object" && address ? address.port : port;
580
+ gatewayUrl = `http://${gatewayHost}:${actualPort}`;
581
+ stdout.write(`[workspace] Default: ${redirectRootToDefault ? `${gatewayUrl}/${defaultApp}` : gatewayUrl}\n`);
582
+ stdout.write(`[workspace] Gateway: ${gatewayUrl}\n`);
583
+ stdout.write(`[workspace] Mode: ${eager ? "eager" : "lazy"}\n`);
584
+ for (const app of apps) {
585
+ stdout.write(`[workspace] ${app.id}: /${app.id} -> 127.0.0.1:${app.port}\n`);
586
+ }
587
+ startWorkspaceProcesses();
588
+ openBrowser(redirectRootToDefault ? `${gatewayUrl}/${defaultApp}` : gatewayUrl);
589
+ readyResolve({ port: actualPort, url: gatewayUrl });
590
+ });
591
+ }
592
+ function shutdown() {
593
+ if (shuttingDown)
594
+ return;
595
+ shuttingDown = true;
596
+ server.close();
497
597
  for (const app of apps) {
498
- console.log(`[workspace] ${app.id}: /${app.id} -> 127.0.0.1:${app.port}`);
598
+ app.process?.kill("SIGTERM");
499
599
  }
500
- startWorkspaceProcesses();
501
- openBrowser(`http://${gatewayHost}:${actualPort}/${defaultApp}`);
502
- });
600
+ if (syncTimer)
601
+ clearTimeout(syncTimer);
602
+ process.off("SIGINT", handleSigint);
603
+ process.off("SIGTERM", handleSigterm);
604
+ }
605
+ const handleSigint = () => shutdown();
606
+ const handleSigterm = () => shutdown();
607
+ process.once("SIGINT", handleSigint);
608
+ process.once("SIGTERM", handleSigterm);
609
+ listen(requestedPort);
610
+ return {
611
+ apps,
612
+ defaultApp,
613
+ gatewayUrl: () => gatewayUrl,
614
+ ready,
615
+ server,
616
+ shutdown,
617
+ };
503
618
  }
504
- function shutdown() {
505
- if (shuttingDown)
506
- return;
507
- shuttingDown = true;
508
- server.close();
509
- for (const app of apps) {
510
- app.process?.kill("SIGTERM");
619
+ function isDirectRun() {
620
+ const entry = process.argv[1];
621
+ if (!entry)
622
+ return false;
623
+ try {
624
+ return path.resolve(entry) === fileURLToPath(import.meta.url);
625
+ }
626
+ catch {
627
+ return false;
628
+ }
629
+ }
630
+ if (isDirectRun()) {
631
+ try {
632
+ runWorkspaceDev({ args: process.argv.slice(2) });
633
+ }
634
+ catch (err) {
635
+ console.error(err instanceof Error ? err.message : String(err));
636
+ process.exit(1);
511
637
  }
512
- setTimeout(() => process.exit(0), 300).unref();
513
638
  }
514
- process.on("SIGINT", shutdown);
515
- process.on("SIGTERM", shutdown);
516
- listen(requestedPort);
517
639
  //# sourceMappingURL=workspace-dev.js.map