@maintainer-pro/ai-server 0.1.4 → 0.1.6

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/server.mjs CHANGED
@@ -1,9 +1,9 @@
1
- #!/usr/bin/env node
2
1
  /**
3
2
  * Maintainer Pro chat HTTP server.
4
3
  * Talks to coding agents through @maintainer-pro/ai-cli.
5
4
  *
6
- * Config from .env (or --env-file). CLI flags override env.
5
+ * Use `startAiServer(options)` from the bridge (one instance per project).
6
+ * The CLI (`bin/cli.js`) still runs a single standalone listener.
7
7
  */
8
8
  import { randomUUID } from "node:crypto";
9
9
  import fs from "node:fs";
@@ -15,9 +15,10 @@ import {
15
15
  createChatHandler,
16
16
  createDefaultSystemPrompt,
17
17
  createLogger,
18
- createMaintainerProStoreFromEnv,
18
+ createMaintainerProStore,
19
19
  parseIgnorePathsEnv,
20
20
  resolveIgnorePaths,
21
+ ensureProjectDataDir,
21
22
  } from "@maintainer-pro/ai-cli";
22
23
  import { attachChatWebSocket } from "./ws.mjs";
23
24
 
@@ -39,8 +40,10 @@ function parseArgs(argv) {
39
40
  return out;
40
41
  }
41
42
 
42
- function loadEnvFile(file) {
43
- if (!file || !fs.existsSync(file)) return;
43
+ export function parseEnvFile(file) {
44
+ /** @type {Record<string, string>} */
45
+ const out = {};
46
+ if (!file || !fs.existsSync(file)) return out;
44
47
  const text = fs.readFileSync(file, "utf8");
45
48
  for (const raw of text.split(/\r?\n/)) {
46
49
  const line = raw.trim();
@@ -55,51 +58,23 @@ function loadEnvFile(file) {
55
58
  ) {
56
59
  value = value.slice(1, -1);
57
60
  }
58
- if (process.env[key] === undefined) process.env[key] = value;
61
+ out[key] = value;
59
62
  }
63
+ return out;
60
64
  }
61
65
 
62
- const args = parseArgs(process.argv.slice(2));
63
- if (args.help) {
64
- console.log(`Maintainer Pro AI server
65
-
66
- Usage:
67
- npx @maintainer-pro/ai-server@latest
68
- npx @maintainer-pro/ai-server@latest --port 3000 --ui . --workspace .
69
-
70
- Flags override .env. Preferred config is env (from the admin setup guide).
71
-
72
- --port <n> Listen port (PORT)
73
- --ui <dir> Static UI folder (AI_SERVER_UI)
74
- --workspace <dir> Agent edit root (AI_CLI_WORKSPACE)
75
- --env-file <path> Env file (default: ./.env)
76
- --cors-origin <url> CORS origin (CORS_ORIGIN)
77
-
78
- Logging (env):
79
- LOG_LEVEL=info|debug|warn|error|silent
80
- AI_LOG_LEVEL=… alias for LOG_LEVEL
81
- NODE_ENV=development defaults to debug logs (auto if unset)
82
- LOG_PRETTY=0 disable pretty TTY output
83
- `);
84
- process.exit(0);
66
+ function loadEnvFile(file) {
67
+ const parsed = parseEnvFile(file);
68
+ for (const [key, value] of Object.entries(parsed)) {
69
+ if (process.env[key] === undefined) process.env[key] = value;
70
+ }
85
71
  }
86
72
 
87
- const envFile = path.resolve(
88
- typeof args.envFile === "string" ? args.envFile : path.join(process.cwd(), ".env")
89
- );
90
- loadEnvFile(envFile);
91
-
92
- // Local sidecar defaults to development (debug logs) unless explicitly set.
93
- if (!process.env.NODE_ENV?.trim()) {
94
- process.env.NODE_ENV = "development";
73
+ function pickEnv(env, key, fallback = "") {
74
+ const raw = env?.[key] ?? process.env[key] ?? fallback;
75
+ return String(raw ?? "").trim();
95
76
  }
96
77
 
97
- const logger = createLogger("ai-server");
98
- const fail = (msg) => {
99
- logger.fatal(msg);
100
- process.exit(1);
101
- };
102
-
103
78
  function contentType(file) {
104
79
  if (file.endsWith(".html")) return "text/html; charset=utf-8";
105
80
  if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
@@ -110,19 +85,6 @@ function contentType(file) {
110
85
  return "application/octet-stream";
111
86
  }
112
87
 
113
- function resolveUiFile(uiDir, pathname) {
114
- const rel = decodeURIComponent(pathname === "/" ? "/index.html" : pathname);
115
- const file = path.normalize(path.join(uiDir, rel));
116
- const root = path.resolve(uiDir);
117
- if (!file.startsWith(root)) return null;
118
- if (fs.existsSync(file) && fs.statSync(file).isFile()) return file;
119
- if (pathname === "/ai-ui.iife.js") {
120
- const bundled = findIife();
121
- if (bundled) return bundled;
122
- }
123
- return null;
124
- }
125
-
126
88
  function tryResolveIife(from) {
127
89
  try {
128
90
  return createRequire(from).resolve("@maintainer-pro/ai-ui");
@@ -131,7 +93,7 @@ function tryResolveIife(from) {
131
93
  }
132
94
  }
133
95
 
134
- function findIife() {
96
+ export function findIife() {
135
97
  const here = path.dirname(fileURLToPath(import.meta.url));
136
98
  const cwdPkg = path.join(process.cwd(), "package.json");
137
99
  const candidates = [
@@ -144,6 +106,19 @@ function findIife() {
144
106
  return candidates.find((file) => file && fs.existsSync(file)) ?? null;
145
107
  }
146
108
 
109
+ function resolveUiFile(uiDir, pathname) {
110
+ const rel = decodeURIComponent(pathname === "/" ? "/index.html" : pathname);
111
+ const file = path.normalize(path.join(uiDir, rel));
112
+ const root = path.resolve(uiDir);
113
+ if (!file.startsWith(root)) return null;
114
+ if (fs.existsSync(file) && fs.statSync(file).isFile()) return file;
115
+ if (pathname === "/ai-ui.iife.js") {
116
+ const bundled = findIife();
117
+ if (bundled) return bundled;
118
+ }
119
+ return null;
120
+ }
121
+
147
122
  async function toFetchRequest(req) {
148
123
  const host = req.headers.host ?? "localhost";
149
124
  const url = `http://${host}${req.url ?? "/"}`;
@@ -179,503 +154,495 @@ function toHttpWsUrl(httpUrl) {
179
154
  return u.toString().replace(/\/$/, "");
180
155
  }
181
156
 
182
- const port = Number(args.port || process.env.PORT || 3000);
183
- const workspaceDir = path.resolve(
184
- args.workspace || process.env.AI_CLI_WORKSPACE || process.cwd()
185
- );
186
- const uiDir = path.resolve(
187
- args.ui || process.env.AI_SERVER_UI || process.cwd()
188
- );
189
- const corsOrigin =
190
- (typeof args.corsOrigin === "string" ? args.corsOrigin : process.env.CORS_ORIGIN) ||
191
- "*";
192
-
193
- process.env.AI_CLI_WORKSPACE = workspaceDir;
194
-
195
- const runningTurns = new Set();
196
- /** One active AI turn per conversation — prevents raced double promotes. */
197
- const runningByConversation = new Map();
198
- /** Turns that arrived while the conversation was busy — run after unlock. */
199
- const deferredTurns = new Map();
200
- /** @type {{ publish: (conversationId: string, payload: Record<string, unknown>) => void; close: () => void }} */
201
- let hub = { publish() {}, close() {} };
202
- /** @type {(event: { conversationId: string; message: { id?: string; content?: string; senderType?: string | null; senderName?: string | null } }) => Promise<void>} */
203
- let runWorkingTurn = async () => {};
204
-
205
- const db = createMaintainerProStoreFromEnv({
206
- logger,
207
- onWorkingTurn: (event) => {
208
- void runWorkingTurn(event);
209
- },
210
- });
211
- if (!db) {
212
- fail(
213
- `Set MAINTAINER_PRO_URL and MAINTAINER_PRO_API_KEY (env file: ${envFile}).`
214
- );
157
+ function parseCorsOrigins(value) {
158
+ return String(value || "*")
159
+ .split(/[\s,]+/)
160
+ .map((item) => item.trim())
161
+ .filter(Boolean);
215
162
  }
216
163
 
217
- const productDescription =
218
- process.env.AI_SERVER_PRODUCT_DESCRIPTION?.trim() ||
219
- "This product uses Maintainer Pro. When the user asks for a change, edit source files in the workspace using the coding agent CLI. Do not invent JSON tool calls.";
220
-
221
- const ignorePaths = resolveIgnorePaths(
222
- parseIgnorePathsEnv(process.env.AI_CLI_IGNORE_PATHS)
223
- );
224
-
225
- const handlers = createChatHandler({
226
- systemPrompt: createDefaultSystemPrompt({ productDescription, ignorePaths }),
227
- workspaceDir,
228
- db,
229
- logger,
230
- });
231
-
232
- async function historyForTurn(conversationId, userMessage, userMessageId) {
233
- const history = db.listMessages ? await db.listMessages(conversationId) : [];
234
- let rows = history.filter(
235
- (row) =>
236
- (row.role === "user" || row.role === "assistant") &&
237
- row.provider !== "working" &&
238
- Boolean(row.content?.trim())
164
+ const HELP = `Maintainer Pro AI server
165
+
166
+ Usage:
167
+ npx --yes --prefer-online @maintainer-pro/ai-server@latest
168
+ npx --yes --prefer-online @maintainer-pro/ai-server@latest --port 3000 --ui . --workspace .
169
+
170
+ Flags override .env. Preferred config is env (from the admin setup guide).
171
+
172
+ --port <n> Listen port (PORT)
173
+ --ui <dir> Static UI folder (AI_SERVER_UI)
174
+ --workspace <dir> Agent edit root (AI_CLI_WORKSPACE)
175
+ --env-file <path> Env file (default: ./.env)
176
+ --cors-origin <url> CORS origin (CORS_ORIGIN)
177
+
178
+ Logging (env):
179
+ LOG_LEVEL=info|debug|warn|error|silent
180
+ AI_LOG_LEVEL=… alias for LOG_LEVEL
181
+ NODE_ENV=development defaults to debug logs (auto if unset)
182
+ LOG_PRETTY=0 disable pretty TTY output
183
+ `;
184
+
185
+ /**
186
+ * One chat server for one project folder. Safe to start several in one process.
187
+ *
188
+ * @param {object} [options]
189
+ * @param {number} [options.port]
190
+ * @param {string} [options.workspaceDir]
191
+ * @param {string} [options.uiDir]
192
+ * @param {string|string[]} [options.corsOrigins]
193
+ * @param {Record<string, string>} [options.env]
194
+ * @param {import("@maintainer-pro/ai-cli").Logger} [options.logger]
195
+ * @param {boolean} [options.listen]
196
+ * @param {string} [options.dataDir]
197
+ * @param {string} [options.sandboxId]
198
+ * @param {string} [options.label]
199
+ */
200
+ export async function startAiServer(options = {}) {
201
+ const env = options.env && typeof options.env === "object" ? options.env : {};
202
+ const port = Number(options.port || pickEnv(env, "PORT", "3100") || 3100);
203
+ const workspaceDir = path.resolve(
204
+ options.workspaceDir || pickEnv(env, "AI_CLI_WORKSPACE") || process.cwd()
239
205
  );
240
- // Exclude later queued user turns so the model answers the current working
241
- // message, not messages still waiting behind it.
242
- if (userMessageId) {
243
- const end = rows.findIndex((row) => row.id === userMessageId);
244
- if (end >= 0) {
245
- rows = rows.slice(0, end + 1);
246
- } else {
247
- rows = rows.filter(
248
- (row) =>
249
- row.queueStatus !== "queued" ||
250
- row.content === userMessage
206
+ const sandboxId =
207
+ options.sandboxId || pickEnv(env, "MAINTAINER_PRO_SANDBOX_ID") || "";
208
+ const dataDir = ensureProjectDataDir({
209
+ workspaceDir,
210
+ sandboxId,
211
+ dataDir:
212
+ options.dataDir || pickEnv(env, "MAINTAINER_PRO_DATA_DIR") || undefined,
213
+ });
214
+ const uiDir = path.resolve(
215
+ options.uiDir || pickEnv(env, "AI_SERVER_UI") || workspaceDir
216
+ );
217
+ const corsOrigins = Array.isArray(options.corsOrigins)
218
+ ? options.corsOrigins.filter(Boolean)
219
+ : parseCorsOrigins(
220
+ options.corsOrigins ||
221
+ pickEnv(env, "CORS_ORIGIN") ||
222
+ pickEnv(env, "CORS_ORIGINS") ||
223
+ "*"
251
224
  );
252
- }
253
- } else {
254
- rows = rows.filter((row) => row.queueStatus !== "queued");
255
- }
256
- const messages = rows.map((row) => ({ role: row.role, content: row.content }));
257
- if (
258
- userMessage &&
259
- !messages.some(
260
- (row) => row.role === "user" && row.content === userMessage
261
- )
262
- ) {
263
- messages.push({ role: "user", content: userMessage });
225
+ const logger =
226
+ options.logger || createLogger(options.label || "ai-server");
227
+ const shouldListen = options.listen !== false;
228
+
229
+ const maintainerProUrl = pickEnv(env, "MAINTAINER_PRO_URL");
230
+ const maintainerProApiKey = pickEnv(env, "MAINTAINER_PRO_API_KEY");
231
+ if (!maintainerProUrl || !maintainerProApiKey) {
232
+ throw new Error(
233
+ "Set MAINTAINER_PRO_URL and MAINTAINER_PRO_API_KEY for this project (.env)."
234
+ );
264
235
  }
265
- return messages;
266
- }
267
236
 
268
- async function runChatPost(body) {
269
- logger.debug(
270
- {
271
- conversationId: body.conversationId,
272
- skipPersistUser: body.skipPersistUser,
273
- userMessageId: body.userMessageId,
274
- messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
237
+ const runningTurns = new Set();
238
+ const runningByConversation = new Map();
239
+ const deferredTurns = new Map();
240
+ /** @type {{ publish: (conversationId: string, payload: Record<string, unknown>) => void; close: () => void }} */
241
+ let hub = { publish() {}, close() {} };
242
+ /** @type {(event: { conversationId: string; message: { id?: string; content?: string; senderType?: string | null; senderName?: string | null } }) => Promise<void>} */
243
+ let runWorkingTurn = async () => {};
244
+
245
+ const db = createMaintainerProStore({
246
+ baseUrl: maintainerProUrl,
247
+ apiKey: maintainerProApiKey,
248
+ logger,
249
+ tempDir: path.join(dataDir, "uploads"),
250
+ onWorkingTurn: (event) => {
251
+ void runWorkingTurn(event);
275
252
  },
276
- "runChatPost"
277
- );
278
- const response = await handlers.POST(
279
- new Request("http://127.0.0.1/api/chat", {
280
- method: "POST",
281
- headers: { "content-type": "application/json" },
282
- body: JSON.stringify(body),
283
- })
284
- );
285
- const text = await response.text();
286
- let data = {};
287
- try {
288
- data = text ? JSON.parse(text) : {};
289
- } catch {
290
- data = { error: text || "invalid chat response" };
291
- }
292
- logger.debug(
293
- { conversationId: body.conversationId, ok: response.ok, status: response.status },
294
- "runChatPost done"
295
- );
296
- return { ok: response.ok, status: response.status, data };
297
- }
253
+ });
298
254
 
299
- function publishChatResult(conversationId, result, fallbackMessageId, parentMessageId) {
300
- if (!conversationId) return;
301
- if (!result.ok) {
302
- logger.debug({ conversationId, status: result.status }, "publish chat.run.failed");
303
- hub.publish(conversationId, {
304
- type: "chat.run.failed",
305
- conversationId,
306
- error: result.data?.error ?? `chat failed (${result.status})`,
307
- userMessageId: parentMessageId,
308
- });
309
- return;
310
- }
311
- const reply = typeof result.data?.text === "string" ? result.data.text : "";
312
- if (!reply) {
313
- logger.debug({ conversationId }, "skip publish (empty reply)");
314
- return;
315
- }
316
- const messageId =
317
- typeof result.data?.messageId === "string"
318
- ? result.data.messageId
319
- : fallbackMessageId;
320
- logger.debug(
321
- { conversationId, messageId, provider: result.data?.provider, chars: reply.length },
322
- "publish message.created"
255
+ const productDescription =
256
+ pickEnv(env, "AI_SERVER_PRODUCT_DESCRIPTION") ||
257
+ "This product uses Maintainer Pro. When the user asks for a change, edit source files in the workspace using the coding agent CLI. Do not invent JSON tool calls.";
258
+
259
+ const ignorePaths = resolveIgnorePaths(
260
+ parseIgnorePathsEnv(pickEnv(env, "AI_CLI_IGNORE_PATHS"))
323
261
  );
324
- hub.publish(conversationId, {
325
- type: "message.created",
326
- conversationId,
327
- message: {
328
- id: messageId,
329
- role: "assistant",
330
- content: reply,
331
- senderType: "ai",
332
- senderName: "AI",
333
- provider: result.data?.provider ?? null,
334
- parentMessageId: parentMessageId ?? null,
335
- },
262
+
263
+ const handlers = createChatHandler({
264
+ systemPrompt: createDefaultSystemPrompt({ productDescription, ignorePaths }),
265
+ workspaceDir,
266
+ dataDir,
267
+ sandboxId: sandboxId || undefined,
268
+ db,
269
+ logger,
336
270
  });
337
- }
338
271
 
339
- runWorkingTurn = async (event) => {
340
- const userMessageId = event.message.id;
341
- const content = event.message.content ?? "";
342
- const conversationId = event.conversationId;
343
- if (!userMessageId || !content.trim()) {
344
- logger.debug({ event }, "skip working turn (empty)");
345
- return;
346
- }
347
- if (runningTurns.has(userMessageId)) {
348
- logger.debug({ userMessageId }, "skip working turn (already running)");
349
- return;
350
- }
351
- const busy = runningByConversation.get(conversationId);
352
- if (busy && busy !== userMessageId) {
353
- deferredTurns.set(conversationId, event);
354
- logger.info(
355
- { conversationId, busy, userMessageId },
356
- "defer working turn (conversation busy)"
357
- );
358
- return;
359
- }
360
- runningTurns.add(userMessageId);
361
- runningByConversation.set(conversationId, userMessageId);
362
- let nextQueued = null;
363
- try {
364
- const messages = await historyForTurn(conversationId, content, userMessageId);
365
- if (!messages.length) {
366
- messages.push({ role: "user", content });
367
- }
368
- logger.info(
369
- {
370
- conversationId,
371
- userMessageId,
372
- historyLength: messages.length,
373
- },
374
- "ai turn"
375
- );
376
- hub.publish(conversationId, {
377
- type: "chat.turn",
378
- conversationId,
379
- userMessageId,
380
- });
381
- const assistantMessageId = randomUUID();
382
- const result = await runChatPost({
383
- conversationId,
384
- messages,
385
- userMessage: content,
386
- skipPersistUser: true,
387
- userMessageId,
388
- assistantMessageId,
389
- senderType: event.message.senderType === "client" ? "client" : undefined,
390
- senderName: event.message.senderName ?? undefined,
391
- });
392
- publishChatResult(conversationId, result, assistantMessageId, userMessageId);
393
- if (!result.ok) {
394
- logger.warn({ status: result.status }, "ai turn failed");
395
- } else if (
396
- result.data?.nextQueued &&
397
- typeof result.data.nextQueued.id === "string"
398
- ) {
399
- nextQueued = result.data.nextQueued;
272
+ function applyCors(req, res) {
273
+ const requestOrigin = String(req.headers.origin || "").trim();
274
+ if (corsOrigins.includes("*")) {
275
+ res.setHeader("Access-Control-Allow-Origin", requestOrigin || "*");
276
+ return;
400
277
  }
401
- } catch (err) {
402
- logger.error({ err }, "ai turn failed");
403
- hub.publish(conversationId, {
404
- type: "chat.run.failed",
405
- conversationId,
406
- error: err instanceof Error ? err.message : String(err),
407
- userMessageId,
408
- });
409
- } finally {
410
- runningTurns.delete(userMessageId);
411
- if (runningByConversation.get(conversationId) === userMessageId) {
412
- runningByConversation.delete(conversationId);
278
+ if (requestOrigin && corsOrigins.includes(requestOrigin)) {
279
+ res.setHeader("Access-Control-Allow-Origin", requestOrigin);
280
+ res.setHeader("Vary", "Origin");
281
+ return;
413
282
  }
414
- // Prefer explicit next from complete-turn; else a deferred becameWorking event.
415
- const deferred = deferredTurns.get(conversationId);
416
- if (deferred) deferredTurns.delete(conversationId);
417
- const followUp =
418
- nextQueued?.id && nextQueued.content?.trim()
419
- ? {
420
- conversationId,
421
- message: {
422
- id: nextQueued.id,
423
- content: nextQueued.content,
424
- senderType: "client",
425
- },
426
- }
427
- : deferred && deferred.message?.id !== userMessageId
428
- ? deferred
429
- : null;
430
- if (followUp) {
431
- logger.info(
432
- {
433
- conversationId,
434
- from: userMessageId,
435
- next: followUp.message.id,
436
- via: nextQueued?.id ? "nextQueued" : "deferred",
437
- },
438
- "chain next queued turn"
439
- );
440
- queueMicrotask(() => {
441
- void runWorkingTurn(followUp);
442
- });
283
+ if (corsOrigins[0]) {
284
+ res.setHeader("Access-Control-Allow-Origin", corsOrigins[0]);
443
285
  }
444
286
  }
445
- };
446
-
447
- function resolveAiServerUrl(req) {
448
- const fromEnv = (
449
- process.env.AI_SERVER_URL ||
450
- process.env.AI_SERVER_BASE_URL ||
451
- ""
452
- )
453
- .trim()
454
- .replace(/\/$/, "");
455
- if (fromEnv) return fromEnv;
456
- const host = req.headers.host?.trim();
457
- if (host) {
458
- const xfProto = String(req.headers["x-forwarded-proto"] ?? "")
459
- .split(",")[0]
460
- ?.trim();
461
- const proto = xfProto || "http";
462
- return `${proto}://${host}`;
287
+
288
+ function headerMap(headers) {
289
+ /** @type {Record<string, string>} */
290
+ const out = {};
291
+ if (!headers || typeof headers !== "object") return out;
292
+ for (const [key, value] of Object.entries(headers)) {
293
+ if (value == null) continue;
294
+ out[String(key).toLowerCase()] = Array.isArray(value)
295
+ ? value.join(", ")
296
+ : String(value);
297
+ }
298
+ return out;
463
299
  }
464
- return `http://localhost:${port}`;
465
- }
466
300
 
467
- const server = http.createServer((req, res) => {
468
- const pathname = (req.url ?? "/").split("?")[0];
469
- logger.debug({ method: req.method, pathname }, "http");
470
- res.setHeader("Access-Control-Allow-Origin", corsOrigin);
471
- res.setHeader("Access-Control-Allow-Headers", "content-type, authorization");
472
- res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
473
- if (req.method === "OPTIONS") {
474
- res.statusCode = 204;
475
- res.end();
476
- return;
301
+ function resolvePublicUrl(reqOrHeaders) {
302
+ const headers = headerMap(
303
+ reqOrHeaders?.headers && typeof reqOrHeaders.headers === "object"
304
+ ? reqOrHeaders.headers
305
+ : reqOrHeaders
306
+ );
307
+ const fromEnv = (
308
+ pickEnv(env, "AI_SERVER_URL") ||
309
+ pickEnv(env, "AI_SERVER_BASE_URL")
310
+ ).replace(/\/$/, "");
311
+ if (fromEnv) return fromEnv;
312
+ const host = headers.host?.trim();
313
+ if (host) {
314
+ const xfProto = String(headers["x-forwarded-proto"] ?? "")
315
+ .split(",")[0]
316
+ ?.trim();
317
+ const proto = xfProto || "http";
318
+ return `${proto}://${host}`;
319
+ }
320
+ return `http://localhost:${port}`;
477
321
  }
478
- if (pathname === "/embed-config.js") {
479
- const aiServerUrl = resolveAiServerUrl(req);
480
- const payload = {
481
- aiServerUrl,
482
- apiUrl: `${aiServerUrl}/api/chat`,
483
- aiServerWsUrl: `${toHttpWsUrl(aiServerUrl)}/api/ws`,
484
- debug: true,
485
- logLevel: "debug",
486
- maintainerProUrl: process.env.MAINTAINER_PRO_URL ?? "",
487
- maintainerProApiKey:
488
- process.env.MAINTAINER_PRO_CLIENT_API_KEY ??
489
- process.env.NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY ??
490
- "",
322
+
323
+ function corsHeadersFor(headers) {
324
+ /** @type {Record<string, string>} */
325
+ const out = {
326
+ "access-control-allow-headers": "content-type, authorization",
327
+ "access-control-allow-methods": "GET,POST,OPTIONS",
491
328
  };
492
- logger.debug({ aiServerUrl }, "embed-config");
493
- res.setHeader("Content-Type", "text/javascript; charset=utf-8");
494
- res.end(`window.__MAINTAINER_PRO__=${JSON.stringify(payload)};`);
495
- return;
329
+ const requestOrigin = String(headerMap(headers).origin || "").trim();
330
+ if (corsOrigins.includes("*")) {
331
+ out["access-control-allow-origin"] = requestOrigin || "*";
332
+ return out;
333
+ }
334
+ if (requestOrigin && corsOrigins.includes(requestOrigin)) {
335
+ out["access-control-allow-origin"] = requestOrigin;
336
+ out.vary = "Origin";
337
+ return out;
338
+ }
339
+ if (corsOrigins[0]) out["access-control-allow-origin"] = corsOrigins[0];
340
+ return out;
496
341
  }
497
- if (pathname === "/api/chat") {
498
- logger.info({ method: req.method }, "http /api/chat");
499
- const handler = req.method === "GET" ? handlers.GET : handlers.POST;
500
- void toFetchRequest(req)
501
- .then(async (request) => {
502
- const response = await handler(request);
503
- if (req.method === "POST") {
504
- const clone = response.clone();
505
- const text = await clone.text();
506
- let data = {};
507
- try {
508
- data = text ? JSON.parse(text) : {};
509
- } catch {
510
- data = {};
511
- }
512
- const conversationId =
513
- typeof data.conversationId === "string" ? data.conversationId : "";
514
- const parentMessageId =
515
- typeof data.parentMessageId === "string"
516
- ? data.parentMessageId
517
- : undefined;
518
- publishChatResult(
519
- conversationId,
520
- {
521
- ok: response.ok,
522
- status: response.status,
523
- data,
524
- },
525
- undefined,
526
- parentMessageId
527
- );
342
+
343
+ /**
344
+ * Handle an HTTP request in-process (no loopback to this server).
345
+ * @param {{ method?: string, url?: string, headers?: Record<string, unknown>, body?: Buffer | Uint8Array | string }} input
346
+ */
347
+ async function handleHttp(input = {}) {
348
+ const method = String(input.method || "GET").toUpperCase();
349
+ const url = String(input.url || "/");
350
+ const headers = headerMap(input.headers);
351
+ const body = Buffer.isBuffer(input.body)
352
+ ? input.body
353
+ : typeof input.body === "string" && input.body
354
+ ? Buffer.from(input.body)
355
+ : input.body instanceof Uint8Array
356
+ ? Buffer.from(input.body)
357
+ : Buffer.alloc(0);
358
+ const pathname = url.split("?")[0] || "/";
359
+ const outHeaders = corsHeadersFor(headers);
360
+ logger.debug({ method, pathname }, "http");
361
+
362
+ if (method === "OPTIONS") {
363
+ return { status: 204, headers: outHeaders, body: Buffer.alloc(0) };
364
+ }
365
+ if (pathname === "/health") {
366
+ outHeaders["content-type"] = "application/json; charset=utf-8";
367
+ return {
368
+ status: 200,
369
+ headers: outHeaders,
370
+ body: Buffer.from(JSON.stringify({ ok: true, workspace: workspaceDir })),
371
+ };
372
+ }
373
+ if (pathname === "/embed-config.js") {
374
+ const aiServerUrl = resolvePublicUrl({ headers });
375
+ const payload = {
376
+ aiServerUrl,
377
+ apiUrl: `${aiServerUrl}/api/chat`,
378
+ aiServerWsUrl: `${toHttpWsUrl(aiServerUrl)}/api/ws`,
379
+ debug: true,
380
+ logLevel: "debug",
381
+ maintainerProUrl: maintainerProUrl,
382
+ maintainerProApiKey:
383
+ pickEnv(env, "MAINTAINER_PRO_CLIENT_API_KEY") ||
384
+ pickEnv(env, "NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY"),
385
+ };
386
+ logger.debug({ aiServerUrl }, "embed-config");
387
+ outHeaders["content-type"] = "text/javascript; charset=utf-8";
388
+ return {
389
+ status: 200,
390
+ headers: outHeaders,
391
+ body: Buffer.from(`window.__MAINTAINER_PRO__=${JSON.stringify(payload)};`),
392
+ };
393
+ }
394
+ if (pathname === "/api/chat") {
395
+ logger.info({ method }, "http /api/chat");
396
+ const handler = method === "GET" ? handlers.GET : handlers.POST;
397
+ const host = headers.host || `127.0.0.1:${port}`;
398
+ const hdrs = new Headers();
399
+ for (const [key, value] of Object.entries(headers)) {
400
+ hdrs.set(key, value);
401
+ }
402
+ /** @type {RequestInit} */
403
+ const init = { method, headers: hdrs };
404
+ if (method !== "GET" && method !== "HEAD" && body.length) {
405
+ init.body = body;
406
+ init.duplex = "half";
407
+ }
408
+ const request = new Request(`http://${host}${url}`, init);
409
+ const response = await handler(request);
410
+ if (method === "POST") {
411
+ const clone = response.clone();
412
+ const text = await clone.text();
413
+ let data = {};
414
+ try {
415
+ data = text ? JSON.parse(text) : {};
416
+ } catch {
417
+ data = {};
528
418
  }
529
- return response;
530
- })
531
- .then((response) => send(res, response))
532
- .catch((err) => {
533
- logger.error({ err }, "chat handler failed");
534
- res.statusCode = 500;
535
- res.end("Chat handler failed");
419
+ const conversationId =
420
+ typeof data.conversationId === "string" ? data.conversationId : "";
421
+ const parentMessageId =
422
+ typeof data.parentMessageId === "string"
423
+ ? data.parentMessageId
424
+ : undefined;
425
+ publishChatResult(
426
+ conversationId,
427
+ {
428
+ ok: response.ok,
429
+ status: response.status,
430
+ data,
431
+ },
432
+ undefined,
433
+ parentMessageId
434
+ );
435
+ }
436
+ const buf = Buffer.from(await response.arrayBuffer());
437
+ response.headers.forEach((value, key) => {
438
+ if (key.toLowerCase() === "transfer-encoding") return;
439
+ outHeaders[key] = value;
536
440
  });
537
- return;
538
- }
539
- const file = resolveUiFile(uiDir, pathname);
540
- if (!file) {
541
- if (pathname === "/ai-ui.iife.js") {
542
- logger.warn(
543
- "GET /ai-ui.iife.js 404 — @maintainer-pro/ai-ui is not installed next to ai-server"
544
- );
441
+ return { status: response.status, headers: outHeaders, body: buf };
545
442
  }
546
- res.statusCode = 404;
547
- res.end("Not found");
548
- return;
443
+ const file = resolveUiFile(uiDir, pathname);
444
+ if (!file) {
445
+ if (pathname === "/ai-ui.iife.js") {
446
+ logger.warn(
447
+ "GET /ai-ui.iife.js 404 — @maintainer-pro/ai-ui is not installed next to ai-server"
448
+ );
449
+ }
450
+ outHeaders["content-type"] = "text/plain; charset=utf-8";
451
+ return { status: 404, headers: outHeaders, body: Buffer.from("Not found") };
452
+ }
453
+ outHeaders["content-type"] = contentType(file);
454
+ return { status: 200, headers: outHeaders, body: fs.readFileSync(file) };
549
455
  }
550
- res.setHeader("Content-Type", contentType(file));
551
- fs.createReadStream(file).pipe(res);
552
- });
553
-
554
- hub = attachChatWebSocket(server, {
555
- logger,
556
- onChatRun: async (msg) => {
557
- const conversationId =
558
- typeof msg.conversationId === "string" ? msg.conversationId : "";
559
- const userMessage =
560
- typeof msg.userMessage === "string"
561
- ? msg.userMessage
562
- : typeof msg.content === "string"
563
- ? msg.content
564
- : "";
565
- const userMessageId =
566
- typeof msg.userMessageId === "string" ? msg.userMessageId : undefined;
567
- logger.info(
568
- {
569
- conversationId,
570
- userMessageId,
571
- chars: userMessage.length,
572
- hasMessages: Array.isArray(msg.messages),
573
- messageCount: Array.isArray(msg.messages) ? msg.messages.length : 0,
574
- skipPersistUser: msg.skipPersistUser,
575
- },
576
- "ws chat.run"
456
+
457
+ async function historyForTurn(conversationId, userMessage, userMessageId) {
458
+ const history = db.listMessages ? await db.listMessages(conversationId) : [];
459
+ let rows = history.filter(
460
+ (row) =>
461
+ (row.role === "user" || row.role === "assistant") &&
462
+ row.provider !== "working" &&
463
+ Boolean(row.content?.trim())
577
464
  );
578
- if (userMessageId && runningTurns.has(userMessageId)) {
579
- logger.debug({ userMessageId }, "ws chat.run duplicate");
580
- return { conversationId, accepted: true, duplicate: true };
581
- }
582
- if (conversationId) {
583
- const busy = runningByConversation.get(conversationId);
584
- if (busy && busy !== userMessageId) {
585
- if (userMessageId && userMessage.trim()) {
586
- deferredTurns.set(conversationId, {
587
- conversationId,
588
- message: {
589
- id: userMessageId,
590
- content: userMessage,
591
- senderType:
592
- typeof msg.senderType === "string" ? msg.senderType : "client",
593
- senderName:
594
- typeof msg.senderName === "string" ? msg.senderName : null,
595
- },
596
- });
597
- logger.info(
598
- { conversationId, busy, userMessageId },
599
- "ws chat.run deferred (conversation busy)"
600
- );
601
- return { conversationId, accepted: true, deferred: true };
602
- }
603
- logger.warn(
604
- { conversationId, busy, userMessageId },
605
- "ws chat.run skipped (conversation busy)"
465
+ if (userMessageId) {
466
+ const end = rows.findIndex((row) => row.id === userMessageId);
467
+ if (end >= 0) {
468
+ rows = rows.slice(0, end + 1);
469
+ } else {
470
+ rows = rows.filter(
471
+ (row) => row.queueStatus !== "queued" || row.content === userMessage
606
472
  );
607
- return { conversationId, accepted: false, busy: true };
608
473
  }
474
+ } else {
475
+ rows = rows.filter((row) => row.queueStatus !== "queued");
609
476
  }
610
- let messages = Array.isArray(msg.messages)
611
- ? msg.messages.filter(
612
- (row) =>
613
- row &&
614
- (row.role === "user" || row.role === "assistant") &&
615
- typeof row.content === "string"
616
- )
617
- : [];
618
- if (!messages.length && conversationId) {
619
- messages = await historyForTurn(conversationId, userMessage, userMessageId);
620
- logger.info(
621
- { conversationId, history: messages.length },
622
- "ws chat.run loaded history"
623
- );
624
- } else if (
477
+ const messages = rows.map((row) => ({ role: row.role, content: row.content }));
478
+ if (
625
479
  userMessage &&
626
480
  !messages.some((row) => row.role === "user" && row.content === userMessage)
627
481
  ) {
628
482
  messages.push({ role: "user", content: userMessage });
629
483
  }
630
- const assistantMessageId =
631
- typeof msg.assistantMessageId === "string" && msg.assistantMessageId
632
- ? msg.assistantMessageId
633
- : randomUUID();
634
- if (userMessageId) runningTurns.add(userMessageId);
635
- if (conversationId && userMessageId) {
636
- runningByConversation.set(conversationId, userMessageId);
484
+ return messages;
485
+ }
486
+
487
+ async function runChatPost(body) {
488
+ logger.debug(
489
+ {
490
+ conversationId: body.conversationId,
491
+ skipPersistUser: body.skipPersistUser,
492
+ userMessageId: body.userMessageId,
493
+ messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
494
+ attachmentCount: Array.isArray(body.attachments)
495
+ ? body.attachments.length
496
+ : 0,
497
+ workspaceDir,
498
+ },
499
+ "runChatPost"
500
+ );
501
+ const response = await handlers.POST(
502
+ new Request("http://127.0.0.1/api/chat", {
503
+ method: "POST",
504
+ headers: { "content-type": "application/json" },
505
+ body: JSON.stringify(body),
506
+ })
507
+ );
508
+ const text = await response.text();
509
+ let data = {};
510
+ try {
511
+ data = text ? JSON.parse(text) : {};
512
+ } catch {
513
+ data = { error: text || "invalid chat response" };
514
+ }
515
+ logger.debug(
516
+ { conversationId: body.conversationId, ok: response.ok, status: response.status },
517
+ "runChatPost done"
518
+ );
519
+ return { ok: response.ok, status: response.status, data };
520
+ }
521
+
522
+ function publishChatResult(
523
+ conversationId,
524
+ result,
525
+ fallbackMessageId,
526
+ parentMessageId
527
+ ) {
528
+ if (!conversationId) return;
529
+ if (!result.ok) {
530
+ logger.debug({ conversationId, status: result.status }, "publish chat.run.failed");
531
+ hub.publish(conversationId, {
532
+ type: "chat.run.failed",
533
+ conversationId,
534
+ error: result.data?.error ?? `chat failed (${result.status})`,
535
+ userMessageId: parentMessageId,
536
+ });
537
+ return;
538
+ }
539
+ const reply = typeof result.data?.text === "string" ? result.data.text : "";
540
+ if (!reply) {
541
+ logger.debug({ conversationId }, "skip publish (empty reply)");
542
+ return;
637
543
  }
544
+ const messageId =
545
+ typeof result.data?.messageId === "string"
546
+ ? result.data.messageId
547
+ : fallbackMessageId;
548
+ logger.debug(
549
+ { conversationId, messageId, provider: result.data?.provider, chars: reply.length },
550
+ "publish message.created"
551
+ );
552
+ hub.publish(conversationId, {
553
+ type: "message.created",
554
+ conversationId,
555
+ message: {
556
+ id: messageId,
557
+ role: "assistant",
558
+ content: reply,
559
+ senderType: "ai",
560
+ senderName: "AI",
561
+ provider: result.data?.provider ?? null,
562
+ parentMessageId: parentMessageId ?? null,
563
+ },
564
+ });
565
+ }
566
+
567
+ runWorkingTurn = async (event) => {
568
+ const userMessageId = event.message.id;
569
+ const content = event.message.content ?? "";
570
+ const conversationId = event.conversationId;
571
+ if (!userMessageId || !content.trim()) {
572
+ logger.debug({ event }, "skip working turn (empty)");
573
+ return;
574
+ }
575
+ if (runningTurns.has(userMessageId)) {
576
+ logger.debug({ userMessageId }, "skip working turn (already running)");
577
+ return;
578
+ }
579
+ const busy = runningByConversation.get(conversationId);
580
+ if (busy && busy !== userMessageId) {
581
+ deferredTurns.set(conversationId, event);
582
+ logger.info(
583
+ { conversationId, busy, userMessageId },
584
+ "defer working turn (conversation busy)"
585
+ );
586
+ return;
587
+ }
588
+ runningTurns.add(userMessageId);
589
+ runningByConversation.set(conversationId, userMessageId);
638
590
  let nextQueued = null;
639
591
  try {
592
+ const messages = await historyForTurn(
593
+ conversationId,
594
+ content,
595
+ userMessageId
596
+ );
597
+ if (!messages.length) {
598
+ messages.push({ role: "user", content });
599
+ }
600
+ logger.info(
601
+ { conversationId, userMessageId, historyLength: messages.length, workspaceDir },
602
+ "ai turn"
603
+ );
640
604
  hub.publish(conversationId, {
641
605
  type: "chat.turn",
642
606
  conversationId,
643
607
  userMessageId,
644
608
  });
609
+ const assistantMessageId = randomUUID();
645
610
  const result = await runChatPost({
646
611
  conversationId,
647
612
  messages,
648
- userMessage,
649
- skipPersistUser: msg.skipPersistUser !== false,
613
+ userMessage: content,
614
+ skipPersistUser: true,
650
615
  userMessageId,
651
616
  assistantMessageId,
652
- senderType: typeof msg.senderType === "string" ? msg.senderType : undefined,
653
- senderName: typeof msg.senderName === "string" ? msg.senderName : undefined,
617
+ senderType: event.message.senderType === "client" ? "client" : undefined,
618
+ senderName: event.message.senderName ?? undefined,
654
619
  });
655
620
  publishChatResult(conversationId, result, assistantMessageId, userMessageId);
656
621
  if (!result.ok) {
657
- throw new Error(result.data?.error ?? `chat failed (${result.status})`);
658
- }
659
- if (
622
+ logger.warn({ status: result.status }, "ai turn failed");
623
+ } else if (
660
624
  result.data?.nextQueued &&
661
625
  typeof result.data.nextQueued.id === "string"
662
626
  ) {
663
627
  nextQueued = result.data.nextQueued;
664
628
  }
665
- return { conversationId, ...result.data };
629
+ } catch (err) {
630
+ logger.error({ err }, "ai turn failed");
631
+ hub.publish(conversationId, {
632
+ type: "chat.run.failed",
633
+ conversationId,
634
+ error: err instanceof Error ? err.message : String(err),
635
+ userMessageId,
636
+ });
666
637
  } finally {
667
- if (userMessageId) runningTurns.delete(userMessageId);
668
- if (
669
- conversationId &&
670
- userMessageId &&
671
- runningByConversation.get(conversationId) === userMessageId
672
- ) {
638
+ runningTurns.delete(userMessageId);
639
+ if (runningByConversation.get(conversationId) === userMessageId) {
673
640
  runningByConversation.delete(conversationId);
674
641
  }
675
642
  const deferred = deferredTurns.get(conversationId);
676
643
  if (deferred) deferredTurns.delete(conversationId);
677
644
  const followUp =
678
- nextQueued?.id && String(nextQueued.content ?? "").trim()
645
+ nextQueued?.id && nextQueued.content?.trim()
679
646
  ? {
680
647
  conversationId,
681
648
  message: {
@@ -702,18 +669,195 @@ hub = attachChatWebSocket(server, {
702
669
  });
703
670
  }
704
671
  }
705
- },
706
- });
672
+ };
673
+
674
+ const server = http.createServer((req, res) => {
675
+ void (async () => {
676
+ const chunks = [];
677
+ for await (const chunk of req) chunks.push(chunk);
678
+ const result = await handleHttp({
679
+ method: req.method,
680
+ url: req.url,
681
+ headers: req.headers,
682
+ body: Buffer.concat(chunks),
683
+ });
684
+ res.statusCode = result.status;
685
+ for (const [key, value] of Object.entries(result.headers || {})) {
686
+ res.setHeader(key, value);
687
+ }
688
+ res.end(result.body);
689
+ })().catch((err) => {
690
+ logger.error({ err }, "http failed");
691
+ if (!res.headersSent) {
692
+ res.statusCode = 500;
693
+ res.end("Internal error");
694
+ }
695
+ });
696
+ });
697
+
698
+ hub = attachChatWebSocket(server, {
699
+ logger,
700
+ onChatRun: async (msg) => {
701
+ const conversationId =
702
+ typeof msg.conversationId === "string" ? msg.conversationId : "";
703
+ const userMessage =
704
+ typeof msg.userMessage === "string"
705
+ ? msg.userMessage
706
+ : typeof msg.content === "string"
707
+ ? msg.content
708
+ : "";
709
+ const userMessageId =
710
+ typeof msg.userMessageId === "string" ? msg.userMessageId : undefined;
711
+ logger.info(
712
+ {
713
+ conversationId,
714
+ userMessageId,
715
+ chars: userMessage.length,
716
+ hasMessages: Array.isArray(msg.messages),
717
+ messageCount: Array.isArray(msg.messages) ? msg.messages.length : 0,
718
+ skipPersistUser: msg.skipPersistUser,
719
+ },
720
+ "ws chat.run"
721
+ );
722
+ if (userMessageId && runningTurns.has(userMessageId)) {
723
+ logger.debug({ userMessageId }, "ws chat.run duplicate");
724
+ return { conversationId, accepted: true, duplicate: true };
725
+ }
726
+ if (conversationId) {
727
+ const busy = runningByConversation.get(conversationId);
728
+ if (busy && busy !== userMessageId) {
729
+ if (userMessageId && userMessage.trim()) {
730
+ deferredTurns.set(conversationId, {
731
+ conversationId,
732
+ message: {
733
+ id: userMessageId,
734
+ content: userMessage,
735
+ senderType:
736
+ typeof msg.senderType === "string" ? msg.senderType : "client",
737
+ senderName:
738
+ typeof msg.senderName === "string" ? msg.senderName : null,
739
+ },
740
+ });
741
+ logger.info(
742
+ { conversationId, busy, userMessageId },
743
+ "ws chat.run deferred (conversation busy)"
744
+ );
745
+ return { conversationId, accepted: true, deferred: true };
746
+ }
747
+ logger.warn(
748
+ { conversationId, busy, userMessageId },
749
+ "ws chat.run skipped (conversation busy)"
750
+ );
751
+ return { conversationId, accepted: false, busy: true };
752
+ }
753
+ }
754
+ let messages = Array.isArray(msg.messages)
755
+ ? msg.messages.filter(
756
+ (row) =>
757
+ row &&
758
+ (row.role === "user" || row.role === "assistant") &&
759
+ typeof row.content === "string"
760
+ )
761
+ : [];
762
+ if (!messages.length && conversationId) {
763
+ messages = await historyForTurn(
764
+ conversationId,
765
+ userMessage,
766
+ userMessageId
767
+ );
768
+ logger.info(
769
+ { conversationId, history: messages.length },
770
+ "ws chat.run loaded history"
771
+ );
772
+ } else if (
773
+ userMessage &&
774
+ !messages.some((row) => row.role === "user" && row.content === userMessage)
775
+ ) {
776
+ messages.push({ role: "user", content: userMessage });
777
+ }
778
+ const assistantMessageId =
779
+ typeof msg.assistantMessageId === "string" && msg.assistantMessageId
780
+ ? msg.assistantMessageId
781
+ : randomUUID();
782
+ if (userMessageId) runningTurns.add(userMessageId);
783
+ if (conversationId && userMessageId) {
784
+ runningByConversation.set(conversationId, userMessageId);
785
+ }
786
+ let nextQueued = null;
787
+ try {
788
+ hub.publish(conversationId, {
789
+ type: "chat.turn",
790
+ conversationId,
791
+ userMessageId,
792
+ });
793
+ const result = await runChatPost({
794
+ conversationId,
795
+ messages,
796
+ userMessage,
797
+ skipPersistUser: msg.skipPersistUser !== false,
798
+ userMessageId,
799
+ assistantMessageId,
800
+ senderType: typeof msg.senderType === "string" ? msg.senderType : undefined,
801
+ senderName: typeof msg.senderName === "string" ? msg.senderName : undefined,
802
+ });
803
+ publishChatResult(conversationId, result, assistantMessageId, userMessageId);
804
+ if (!result.ok) {
805
+ throw new Error(result.data?.error ?? `chat failed (${result.status})`);
806
+ }
807
+ if (
808
+ result.data?.nextQueued &&
809
+ typeof result.data.nextQueued.id === "string"
810
+ ) {
811
+ nextQueued = result.data.nextQueued;
812
+ }
813
+ return { conversationId, ...result.data };
814
+ } finally {
815
+ if (userMessageId) runningTurns.delete(userMessageId);
816
+ if (
817
+ conversationId &&
818
+ userMessageId &&
819
+ runningByConversation.get(conversationId) === userMessageId
820
+ ) {
821
+ runningByConversation.delete(conversationId);
822
+ }
823
+ const deferred = deferredTurns.get(conversationId);
824
+ if (deferred) deferredTurns.delete(conversationId);
825
+ const followUp =
826
+ nextQueued?.id && String(nextQueued.content ?? "").trim()
827
+ ? {
828
+ conversationId,
829
+ message: {
830
+ id: nextQueued.id,
831
+ content: nextQueued.content,
832
+ senderType: "client",
833
+ },
834
+ }
835
+ : deferred && deferred.message?.id !== userMessageId
836
+ ? deferred
837
+ : null;
838
+ if (followUp) {
839
+ logger.info(
840
+ {
841
+ conversationId,
842
+ from: userMessageId,
843
+ next: followUp.message.id,
844
+ via: nextQueued?.id ? "nextQueued" : "deferred",
845
+ },
846
+ "chain next queued turn"
847
+ );
848
+ queueMicrotask(() => {
849
+ void runWorkingTurn(followUp);
850
+ });
851
+ }
852
+ }
853
+ },
854
+ });
707
855
 
708
- async function start() {
709
856
  if (db.ready) {
710
857
  try {
711
858
  const info = await db.ready;
712
859
  logger.info(
713
- {
714
- conversations: info.conversations,
715
- messages: info.messages,
716
- },
860
+ { conversations: info.conversations, messages: info.messages, workspaceDir },
717
861
  "cache ready"
718
862
  );
719
863
  } catch (err) {
@@ -723,7 +867,7 @@ async function start() {
723
867
 
724
868
  const recoverMs = Math.max(
725
869
  5_000,
726
- Number(process.env.AI_QUEUE_RECOVER_MS || 20_000) || 20_000
870
+ Number(pickEnv(env, "AI_QUEUE_RECOVER_MS", "20000") || 20_000) || 20_000
727
871
  );
728
872
  let recoverInFlight = false;
729
873
  const sweepStuckQueues = async (reason) => {
@@ -774,11 +918,46 @@ async function start() {
774
918
  }, recoverMs);
775
919
  if (typeof recoverTimer.unref === "function") recoverTimer.unref();
776
920
 
777
- server.listen(port, () => {
921
+ const close = () =>
922
+ new Promise((resolve) => {
923
+ clearInterval(recoverTimer);
924
+ try {
925
+ db.stop?.();
926
+ } catch {
927
+ /* ignore */
928
+ }
929
+ try {
930
+ hub.close();
931
+ } catch {
932
+ /* ignore */
933
+ }
934
+ server.close(() => resolve());
935
+ setTimeout(resolve, 2000);
936
+ });
937
+
938
+ if (shouldListen) {
939
+ try {
940
+ await new Promise((resolve, reject) => {
941
+ const onError = (err) => {
942
+ server.off("listening", onListen);
943
+ reject(err);
944
+ };
945
+ const onListen = () => {
946
+ server.off("error", onError);
947
+ resolve();
948
+ };
949
+ server.once("error", onError);
950
+ server.listen(port, onListen);
951
+ });
952
+ } catch (err) {
953
+ await close();
954
+ throw err;
955
+ }
778
956
  const base =
779
- (process.env.AI_SERVER_URL || process.env.AI_SERVER_BASE_URL || "")
780
- .trim()
781
- .replace(/\/$/, "") || `http://localhost:${port}`;
957
+ (pickEnv(env, "AI_SERVER_URL") || pickEnv(env, "AI_SERVER_BASE_URL")).replace(
958
+ /\/$/,
959
+ ""
960
+ ) || `http://localhost:${port}`;
782
961
  logger.info(`chat → ${base}/api/chat`);
783
962
  logger.info(`ws → ${toHttpWsUrl(base)}/api/ws`);
784
963
  logger.info(`ui → ${base}/`);
@@ -793,23 +972,40 @@ async function start() {
793
972
  );
794
973
  }
795
974
  logger.info(`workspace ${workspaceDir}`);
796
- logger.info(
797
- {
798
- logLevel: logger.level,
799
- nodeEnv: process.env.NODE_ENV,
800
- queueRecoverMs: recoverMs,
801
- },
802
- "logger ready"
803
- );
804
- });
805
- }
975
+ }
806
976
 
807
- void start();
977
+ return {
978
+ port,
979
+ workspaceDir,
980
+ server,
981
+ close,
982
+ runChat: runChatPost,
983
+ handleHttp,
984
+ };
985
+ }
808
986
 
809
- for (const signal of ["SIGINT", "SIGTERM"]) {
810
- process.on(signal, () => {
811
- db.stop?.();
812
- hub.close();
813
- process.exit(0);
987
+ export async function runCli(argv = process.argv.slice(2)) {
988
+ const args = parseArgs(argv);
989
+ if (args.help) {
990
+ console.log(HELP);
991
+ return;
992
+ }
993
+ const envFile = path.resolve(
994
+ typeof args.envFile === "string" ? args.envFile : path.join(process.cwd(), ".env")
995
+ );
996
+ loadEnvFile(envFile);
997
+ if (!process.env.NODE_ENV?.trim()) {
998
+ process.env.NODE_ENV = "development";
999
+ }
1000
+ const instance = await startAiServer({
1001
+ port: Number(args.port || process.env.PORT || 3000),
1002
+ workspaceDir: args.workspace || process.env.AI_CLI_WORKSPACE || process.cwd(),
1003
+ uiDir: args.ui || process.env.AI_SERVER_UI || process.cwd(),
1004
+ corsOrigins: args.corsOrigin || undefined,
814
1005
  });
1006
+ const shutdown = () => {
1007
+ void instance.close().finally(() => process.exit(0));
1008
+ };
1009
+ process.on("SIGINT", shutdown);
1010
+ process.on("SIGTERM", shutdown);
815
1011
  }