@agent-native/core 0.12.15 → 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 (85) 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/agent/run-manager.d.ts.map +1 -1
  6. package/dist/agent/run-manager.js +56 -42
  7. package/dist/agent/run-manager.js.map +1 -1
  8. package/dist/cli/create.js +1 -1
  9. package/dist/cli/create.js.map +1 -1
  10. package/dist/cli/index.js +13 -2
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/cli/workspace-dev.d.ts +40 -1
  13. package/dist/cli/workspace-dev.d.ts.map +1 -1
  14. package/dist/cli/workspace-dev.js +506 -363
  15. package/dist/cli/workspace-dev.js.map +1 -1
  16. package/dist/client/AgentPanel.d.ts +16 -0
  17. package/dist/client/AgentPanel.d.ts.map +1 -1
  18. package/dist/client/AgentPanel.js +30 -9
  19. package/dist/client/AgentPanel.js.map +1 -1
  20. package/dist/client/AssistantChat.d.ts +4 -0
  21. package/dist/client/AssistantChat.d.ts.map +1 -1
  22. package/dist/client/AssistantChat.js +49 -14
  23. package/dist/client/AssistantChat.js.map +1 -1
  24. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  25. package/dist/client/MultiTabAssistantChat.js +17 -3
  26. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  27. package/dist/client/NewWorkspaceAppFlow.d.ts.map +1 -1
  28. package/dist/client/NewWorkspaceAppFlow.js +4 -1
  29. package/dist/client/NewWorkspaceAppFlow.js.map +1 -1
  30. package/dist/client/agent-chat.d.ts +1 -1
  31. package/dist/client/agent-chat.js.map +1 -1
  32. package/dist/client/components/CodeRequiredDialog.d.ts +3 -2
  33. package/dist/client/components/CodeRequiredDialog.d.ts.map +1 -1
  34. package/dist/client/components/CodeRequiredDialog.js +4 -3
  35. package/dist/client/components/CodeRequiredDialog.js.map +1 -1
  36. package/dist/client/composer/PromptComposer.d.ts +2 -0
  37. package/dist/client/composer/PromptComposer.d.ts.map +1 -1
  38. package/dist/client/composer/PromptComposer.js +2 -2
  39. package/dist/client/composer/PromptComposer.js.map +1 -1
  40. package/dist/client/composer/TiptapComposer.d.ts +6 -1
  41. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  42. package/dist/client/composer/TiptapComposer.js +21 -12
  43. package/dist/client/composer/TiptapComposer.js.map +1 -1
  44. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  45. package/dist/client/settings/SettingsPanel.js +3 -0
  46. package/dist/client/settings/SettingsPanel.js.map +1 -1
  47. package/dist/client/sharing/ShareButton.js +6 -1
  48. package/dist/client/sharing/ShareButton.js.map +1 -1
  49. package/dist/client/sharing/ShareButton.spec.d.ts +2 -0
  50. package/dist/client/sharing/ShareButton.spec.d.ts.map +1 -0
  51. package/dist/client/sharing/ShareButton.spec.js +90 -0
  52. package/dist/client/sharing/ShareButton.spec.js.map +1 -0
  53. package/dist/client/sse-event-processor.d.ts.map +1 -1
  54. package/dist/client/sse-event-processor.js +10 -2
  55. package/dist/client/sse-event-processor.js.map +1 -1
  56. package/dist/client/use-chat-threads.d.ts.map +1 -1
  57. package/dist/client/use-chat-threads.js +19 -2
  58. package/dist/client/use-chat-threads.js.map +1 -1
  59. package/dist/client/use-send-to-agent-chat.d.ts +3 -3
  60. package/dist/client/use-send-to-agent-chat.js +3 -3
  61. package/dist/client/use-send-to-agent-chat.js.map +1 -1
  62. package/dist/deploy/workspace-deploy.js +4 -1
  63. package/dist/deploy/workspace-deploy.js.map +1 -1
  64. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  65. package/dist/server/agent-chat-plugin.js +11 -7
  66. package/dist/server/agent-chat-plugin.js.map +1 -1
  67. package/dist/templates/default/AGENTS.md +7 -1
  68. package/dist/templates/default/DEVELOPING.md +12 -0
  69. package/dist/templates/default/app/hooks/use-navigation-state.ts +81 -0
  70. package/dist/templates/default/app/root.tsx +11 -5
  71. package/dist/templates/workspace-root/AGENTS.md +3 -1
  72. package/dist/templates/workspace-root/README.md +4 -4
  73. package/dist/vite/client.d.ts.map +1 -1
  74. package/dist/vite/client.js +34 -6
  75. package/dist/vite/client.js.map +1 -1
  76. package/docs/content/multi-app-workspace.md +1 -1
  77. package/package.json +1 -1
  78. package/src/templates/default/AGENTS.md +7 -1
  79. package/src/templates/default/DEVELOPING.md +12 -0
  80. package/src/templates/default/app/hooks/use-navigation-state.ts +81 -0
  81. package/src/templates/default/app/root.tsx +11 -5
  82. package/src/templates/workspace-root/AGENTS.md +3 -1
  83. package/src/templates/workspace-root/README.md +4 -4
  84. package/dist/templates/workspace-root/netlify.toml +0 -11
  85. 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,92 +102,87 @@ 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;
105
+ function appRestartDelay(attempts) {
106
+ return Math.min(1_000 * 2 ** Math.max(0, attempts - 1), APP_RESTART_MAX_DELAY_MS);
158
107
  }
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));
108
+ function probePort(port, timeoutMs = 1_000) {
109
+ return new Promise((resolve) => {
110
+ const socket = new net.Socket();
111
+ let settled = false;
112
+ const finish = (ok) => {
113
+ if (settled)
114
+ return;
115
+ settled = true;
116
+ socket.destroy();
117
+ resolve(ok);
118
+ };
119
+ socket.setTimeout(timeoutMs);
120
+ socket.once("connect", () => finish(true));
121
+ socket.once("error", () => finish(false));
122
+ socket.once("timeout", () => finish(false));
123
+ socket.connect(port, "127.0.0.1");
209
124
  });
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();
125
+ }
126
+ function firstHeaderValue(value) {
127
+ if (Array.isArray(value))
128
+ return value[0];
129
+ if (value === undefined)
130
+ return undefined;
131
+ return String(value);
132
+ }
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");
140
+ }
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>`;
168
+ }
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
+ }
224
183
  });
225
184
  }
226
- function renderIndex() {
185
+ function renderIndex(apps) {
227
186
  return `<!doctype html>
228
187
  <html>
229
188
  <head>
@@ -242,255 +201,439 @@ function renderIndex() {
242
201
  <body>
243
202
  <main>
244
203
  <h1>Agent-Native Workspace</h1>
245
- <p class="muted">Open an app below. Dispatch is the workspace control plane.</p>
204
+ <p class="muted">Open an app below. Dispatch is the workspace control plane when installed.</p>
246
205
  <div class="grid">
247
206
  ${apps
248
- .map((app) => `<a class="card" href="/${app.id}"><strong>${app.name}</strong><span class="muted">/${app.id}</span></a>`)
207
+ .map((app) => `<a class="card" href="/${app.id}"><strong>${escapeHtml(app.name)}</strong><span class="muted">/${escapeHtml(app.id)}</span></a>`)
249
208
  .join("")}
250
209
  </div>
251
210
  </main>
252
211
  </body>
253
212
  </html>`;
254
213
  }
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
- function appRestartDelay(attempts) {
264
- return Math.min(1_000 * 2 ** Math.max(0, attempts - 1), APP_RESTART_MAX_DELAY_MS);
265
- }
266
- function probePort(port, timeoutMs = 1_000) {
267
- return new Promise((resolve) => {
268
- const socket = new net.Socket();
269
- let settled = false;
270
- const finish = (ok) => {
271
- if (settled)
272
- return;
273
- settled = true;
274
- socket.destroy();
275
- resolve(ok);
276
- };
277
- socket.setTimeout(timeoutMs);
278
- socket.once("connect", () => finish(true));
279
- socket.once("error", () => finish(false));
280
- socket.once("timeout", () => finish(false));
281
- socket.connect(port, "127.0.0.1");
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;
282
247
  });
283
- }
284
- async function waitForPort(port, deadline) {
285
- while (Date.now() < deadline) {
286
- if (await probePort(port))
287
- return true;
288
- await new Promise((r) => setTimeout(r, PROXY_READY_RETRY_DELAY_MS));
248
+ function workspaceAppsJson() {
249
+ return JSON.stringify(apps.map((workspaceApp) => ({
250
+ id: workspaceApp.id,
251
+ name: workspaceApp.name,
252
+ path: `/${workspaceApp.id}`,
253
+ })));
289
254
  }
290
- return false;
291
- }
292
- function proxyHttp(app, req, res) {
293
- const dispatch = () => {
294
- const headers = { ...req.headers, host: `127.0.0.1:${app.port}` };
295
- const proxyReq = http.request({
296
- hostname: "127.0.0.1",
297
- port: app.port,
298
- method: req.method,
299
- path: req.url,
300
- headers,
301
- }, (proxyRes) => {
302
- app.ready = true;
303
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
304
- proxyRes.pipe(res);
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
+ },
305
332
  });
306
- proxyReq.on("error", (err) => {
307
- if (res.headersSent) {
308
- 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)
309
350
  return;
310
- }
311
- res.writeHead(502, { "content-type": "text/plain" });
312
- 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();
313
359
  });
314
- req.pipe(proxyReq);
315
- };
316
- // Fast path: the upstream has accepted at least one request before, so it's
317
- // listening. Skip the probe so steady-state requests stay zero-latency.
318
- if (app.ready) {
319
- dispatch();
320
- return;
321
360
  }
322
- // Cold path: hold the request open while the child server boots. Node
323
- // keeps the request body in paused mode until a consumer attaches via
324
- // pipe(), so awaiting waitForPort() doesn't lose data.
325
- void waitForPort(app.port, Date.now() + PROXY_READY_TIMEOUT_MS).then((ready) => {
326
- if (!ready) {
327
- 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
+ }
328
416
  res.writeHead(502, { "content-type": "text/plain" });
329
- 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;
330
439
  }
331
- else {
332
- 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;
333
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
+ });
334
478
  return;
335
479
  }
336
- app.ready = true;
337
- dispatch();
338
- });
339
- }
340
- function proxyUpgrade(app, req, socket, head) {
341
- const target = net.connect(app.port, "127.0.0.1", () => {
342
- const headers = Object.entries({
343
- ...req.headers,
344
- host: `127.0.0.1:${app.port}`,
345
- })
346
- .flatMap(([key, value]) => Array.isArray(value)
347
- ? value.map((item) => `${key}: ${item}`)
348
- : [`${key}: ${value ?? ""}`])
349
- .join("\r\n");
350
- target.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${headers}\r\n\r\n`);
351
- if (head.length)
352
- target.write(head);
353
- socket.pipe(target).pipe(socket);
354
- });
355
- target.on("error", () => socket.destroy());
356
- }
357
- let shuttingDown = false;
358
- let workspaceStarted = false;
359
- function handleWatcherError(err) {
360
- // ENOSPC: system inotify watcher limit hit (Linux). Userland-fixable;
361
- // capture as a warning so we still see frequency in Sentry but don't get
362
- // paged. Print actionable guidance and continue without watching — the
363
- // 2s polling interval below keeps app discovery working.
364
- if (err.code === "ENOSPC") {
365
- console.warn(`[workspace] Recursive file watcher hit the system limit (ENOSPC). ` +
366
- `New apps will still be detected via polling every ~2s. ` +
367
- `On Linux you can raise the limit with ` +
368
- `\`sudo sysctl fs.inotify.max_user_watches=524288\` ` +
369
- `(persist via /etc/sysctl.d/*.conf). On macOS/Windows this usually ` +
370
- `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`);
371
485
  Sentry.captureException(err, {
372
- tags: { handled: "dev-watch-enospc" },
486
+ tags: { handled: "dev-watch-unknown" },
373
487
  level: "warning",
374
488
  });
375
- return;
376
- }
377
- // ENOENT: a watched directory disappeared (or a transient subdir under
378
- // appsDir vanished mid-enumeration). Benign — the polling fallback and
379
- // future scheduleSync calls will re-establish state. Don't capture.
380
- if (err.code === "ENOENT") {
381
- console.debug(`[workspace] Recursive file watcher saw a directory disappear ` +
382
- `(ENOENT: ${err.path ?? "unknown"}). Polling fallback will recover.`);
383
- return;
384
489
  }
385
- // Unknown failure mode — keep the dev experience alive (polling still
386
- // runs) but surface to Sentry as a warning so we learn about new cases.
387
- console.warn(`[workspace] Recursive file watcher failed (${err.code ?? "unknown"}): ${err.message}. ` +
388
- `Falling back to polling.`);
389
- Sentry.captureException(err, {
390
- tags: { handled: "dev-watch-unknown" },
391
- level: "warning",
392
- });
393
- }
394
- function startWorkspaceProcesses() {
395
- if (workspaceStarted)
396
- return;
397
- workspaceStarted = true;
398
- for (const app of apps)
399
- startApp(app);
400
- try {
401
- const watcher = fs.watch(appsDir, { recursive: true }, scheduleSync);
402
- // Async errors (e.g. ENOENT when a subdir vanishes mid-watch) surface on
403
- // the watcher rather than the original call site. Without an `error`
404
- // listener, Node would treat them as uncaught and crash the dev process.
405
- 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) {
406
506
  handleWatcherError(err);
407
- });
507
+ }
508
+ setInterval(syncApps, 2_000).unref();
408
509
  }
409
- catch (err) {
410
- 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();
411
524
  }
412
- setInterval(syncApps, 2_000).unref();
413
- }
414
- function openBrowser(url) {
415
- if (process.env.WORKSPACE_NO_OPEN === "1")
416
- return;
417
- const command = process.platform === "darwin"
418
- ? "open"
419
- : process.platform === "win32"
420
- ? "cmd"
421
- : "xdg-open";
422
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
423
- const child = spawn(command, args, {
424
- stdio: "ignore",
425
- 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);
426
559
  });
427
- child.unref();
428
- }
429
- const server = http.createServer((req, res) => {
430
- if (req.url === "/" || req.url === "/index.html") {
431
- res.writeHead(302, { location: `/${defaultApp}` });
432
- res.end();
433
- return;
434
- }
435
- if (req.url === "/_workspace/apps") {
436
- res.writeHead(200, { "content-type": "application/json" });
437
- res.end(JSON.stringify(apps.map((app) => ({
438
- id: app.id,
439
- name: app.name,
440
- path: `/${app.id}`,
441
- port: app.port,
442
- }))));
443
- return;
444
- }
445
- const app = appForRequest(req);
446
- if (!app) {
447
- res.writeHead(404, { "content-type": "text/html" });
448
- res.end(renderIndex());
449
- return;
450
- }
451
- proxyHttp(app, req, res);
452
- });
453
- server.on("upgrade", (req, socket, head) => {
454
- const app = appForRequest(req);
455
- if (!app) {
456
- socket.destroy();
457
- return;
458
- }
459
- proxyUpgrade(app, req, socket, head);
460
- });
461
- function listen(port, attempts = 20) {
462
- server.once("error", (err) => {
463
- if (err.code === "EADDRINUSE" && attempts > 0) {
464
- listen(port + 1, attempts - 1);
560
+ server.on("upgrade", (req, socket, head) => {
561
+ const app = appForRequest(req);
562
+ if (!app) {
563
+ socket.destroy();
465
564
  return;
466
565
  }
467
- console.error(`[workspace] Could not start gateway: ${err.message}`);
468
- process.exit(1);
566
+ proxyUpgrade(app, req, socket, head);
469
567
  });
470
- server.listen(port, gatewayHost, () => {
471
- const address = server.address();
472
- const actualPort = typeof address === "object" && address ? address.port : port;
473
- gatewayUrl = `http://${gatewayHost}:${actualPort}`;
474
- console.log(`[workspace] Default: http://${gatewayHost}:${actualPort}/${defaultApp}`);
475
- 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();
476
597
  for (const app of apps) {
477
- console.log(`[workspace] ${app.id}: /${app.id} -> 127.0.0.1:${app.port}`);
598
+ app.process?.kill("SIGTERM");
478
599
  }
479
- startWorkspaceProcesses();
480
- openBrowser(`http://${gatewayHost}:${actualPort}/${defaultApp}`);
481
- });
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
+ };
482
618
  }
483
- function shutdown() {
484
- if (shuttingDown)
485
- return;
486
- shuttingDown = true;
487
- server.close();
488
- for (const app of apps) {
489
- 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);
490
637
  }
491
- setTimeout(() => process.exit(0), 300).unref();
492
638
  }
493
- process.on("SIGINT", shutdown);
494
- process.on("SIGTERM", shutdown);
495
- listen(requestedPort);
496
639
  //# sourceMappingURL=workspace-dev.js.map