@kirosnn/mosaic 0.71.0 → 0.74.0

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 (79) hide show
  1. package/README.md +1 -5
  2. package/package.json +4 -2
  3. package/src/agent/Agent.ts +353 -131
  4. package/src/agent/context.ts +4 -4
  5. package/src/agent/prompts/systemPrompt.ts +15 -6
  6. package/src/agent/prompts/toolsPrompt.ts +136 -10
  7. package/src/agent/provider/anthropic.ts +100 -100
  8. package/src/agent/provider/google.ts +102 -102
  9. package/src/agent/provider/mistral.ts +95 -95
  10. package/src/agent/provider/ollama.ts +77 -60
  11. package/src/agent/provider/openai.ts +42 -38
  12. package/src/agent/provider/rateLimit.ts +178 -0
  13. package/src/agent/provider/xai.ts +99 -99
  14. package/src/agent/tools/definitions.ts +19 -9
  15. package/src/agent/tools/executor.ts +95 -85
  16. package/src/agent/tools/exploreExecutor.ts +8 -10
  17. package/src/agent/tools/grep.ts +30 -29
  18. package/src/agent/tools/question.ts +7 -1
  19. package/src/agent/types.ts +9 -8
  20. package/src/components/App.tsx +45 -45
  21. package/src/components/CustomInput.tsx +214 -36
  22. package/src/components/Main.tsx +552 -339
  23. package/src/components/Setup.tsx +1 -1
  24. package/src/components/Welcome.tsx +1 -1
  25. package/src/components/main/ApprovalPanel.tsx +4 -3
  26. package/src/components/main/ChatPage.tsx +858 -675
  27. package/src/components/main/HomePage.tsx +53 -38
  28. package/src/components/main/QuestionPanel.tsx +52 -7
  29. package/src/components/main/ThinkingIndicator.tsx +2 -1
  30. package/src/index.tsx +50 -20
  31. package/src/mcp/approvalPolicy.ts +156 -0
  32. package/src/mcp/cli/add.ts +185 -0
  33. package/src/mcp/cli/doctor.ts +74 -0
  34. package/src/mcp/cli/index.ts +85 -0
  35. package/src/mcp/cli/list.ts +50 -0
  36. package/src/mcp/cli/logs.ts +24 -0
  37. package/src/mcp/cli/manage.ts +99 -0
  38. package/src/mcp/cli/show.ts +53 -0
  39. package/src/mcp/cli/tools.ts +77 -0
  40. package/src/mcp/config.ts +234 -0
  41. package/src/mcp/index.ts +80 -0
  42. package/src/mcp/processManager.ts +304 -0
  43. package/src/mcp/rateLimiter.ts +50 -0
  44. package/src/mcp/registry.ts +151 -0
  45. package/src/mcp/schemaConverter.ts +100 -0
  46. package/src/mcp/servers/navigation/browser.ts +151 -0
  47. package/src/mcp/servers/navigation/index.ts +23 -0
  48. package/src/mcp/servers/navigation/tools.ts +263 -0
  49. package/src/mcp/servers/navigation/types.ts +17 -0
  50. package/src/mcp/servers/navigation/utils.ts +20 -0
  51. package/src/mcp/toolCatalog.ts +182 -0
  52. package/src/mcp/types.ts +116 -0
  53. package/src/utils/approvalBridge.ts +17 -5
  54. package/src/utils/commands/compact.ts +30 -0
  55. package/src/utils/commands/echo.ts +1 -1
  56. package/src/utils/commands/index.ts +4 -6
  57. package/src/utils/commands/new.ts +15 -0
  58. package/src/utils/commands/types.ts +3 -0
  59. package/src/utils/config.ts +3 -1
  60. package/src/utils/diffRendering.tsx +1 -3
  61. package/src/utils/exploreBridge.ts +10 -0
  62. package/src/utils/markdown.tsx +220 -122
  63. package/src/utils/models.ts +31 -9
  64. package/src/utils/questionBridge.ts +36 -1
  65. package/src/utils/tokenEstimator.ts +32 -0
  66. package/src/utils/toolFormatting.ts +317 -7
  67. package/src/web/app.tsx +72 -72
  68. package/src/web/components/HomePage.tsx +7 -7
  69. package/src/web/components/MessageItem.tsx +66 -35
  70. package/src/web/components/QuestionPanel.tsx +72 -12
  71. package/src/web/components/Sidebar.tsx +0 -2
  72. package/src/web/components/ThinkingIndicator.tsx +1 -0
  73. package/src/web/server.tsx +767 -683
  74. package/src/utils/commands/redo.ts +0 -74
  75. package/src/utils/commands/sessions.ts +0 -129
  76. package/src/utils/commands/undo.ts +0 -75
  77. package/src/utils/undoRedo.ts +0 -429
  78. package/src/utils/undoRedoBridge.ts +0 -45
  79. package/src/utils/undoRedoDb.ts +0 -338
@@ -1,114 +1,176 @@
1
- import { serve } from "bun";
2
- import { join } from "path";
3
- import { existsSync, readdirSync, statSync } from "fs";
4
- import { build } from "bun";
1
+ import { serve } from "bun";
2
+ import { join } from "path";
3
+ import { existsSync, readdirSync } from "fs";
4
+ import { build } from "bun";
5
5
  import { createCliRenderer, TextAttributes } from "@opentui/core";
6
6
  import { createRoot } from "@opentui/react";
7
7
  import React from "react";
8
8
  import { exec } from "child_process";
9
9
  import type { ImagePart, TextPart, UserContent } from "ai";
10
10
  import type { ImageAttachment } from "../utils/images";
11
-
12
- const PORT = 8192;
13
- const HOST = "127.0.0.1";
14
-
15
- import { subscribeQuestion, answerQuestion } from "../utils/questionBridge";
11
+
12
+ const PORT = 8192;
13
+ const HOST = "127.0.0.1";
14
+
15
+ import { subscribeQuestion, answerQuestion } from "../utils/questionBridge";
16
16
  import { subscribeApproval, respondApproval, getCurrentApproval } from "../utils/approvalBridge";
17
-
18
- let currentAbortController: AbortController | null = null;
19
-
20
- const HTML_TEMPLATE = `<!DOCTYPE html>
21
- <html lang="en">
22
- <head>
23
- <meta charset="UTF-8">
24
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
25
- <title>Mosaic</title>
26
- <link rel="icon" type="image/svg+xml" href="/logo_black.svg" media="(prefers-color-scheme: light)">
27
- <link rel="icon" type="image/svg+xml" href="/logo_white.svg" media="(prefers-color-scheme: dark)">
28
- <link rel="stylesheet" href="/app.css">
29
- </head>
30
- <body>
31
- <div id="root"></div>
32
- <script type="module" src="/app.js"></script>
33
- </body>
34
- </html>`;
35
-
36
- type LogEntry = { message: string; timestamp: string };
37
-
38
- const logs: LogEntry[] = [];
39
- const listeners: Set<() => void> = new Set();
40
-
41
- function addLog(message: string) {
42
- const timestamp = new Date().toLocaleTimeString();
43
- const clean = String(message ?? "").replace(/\r/g, "").trimEnd();
44
- if (!clean) return;
45
-
46
- const lines = clean.split("\n");
47
- for (const line of lines) {
48
- if (!line) continue;
49
- logs.push({ message: line, timestamp });
50
-
51
- }
52
- while (logs.length > 50) logs.shift();
53
- listeners.forEach((l) => l());
54
- }
55
-
56
- function installExternalLogCapture() {
57
- const originalLog = console.log.bind(console);
58
- const originalInfo = console.info.bind(console);
59
- const originalWarn = console.warn.bind(console);
60
- const originalError = console.error.bind(console);
61
-
62
- console.log = (...args: any[]) => {
63
- addLog(args.map(String).join(" "));
64
- originalLog(...args);
65
- };
66
- console.info = (...args: any[]) => {
67
- addLog(args.map(String).join(" "));
68
- originalInfo(...args);
69
- };
70
- console.warn = (...args: any[]) => {
71
- addLog(args.map(String).join(" "));
72
- originalWarn(...args);
73
- };
74
- console.error = (...args: any[]) => {
75
- addLog(args.map(String).join(" "));
76
- originalError(...args);
77
- };
78
-
79
- if (typeof process !== "undefined" && process?.stdout?.write) {
80
- const originalStdoutWrite = process.stdout.write.bind(process.stdout) as (
81
- chunk: any,
82
- encoding?: any,
83
- cb?: any
84
- ) => boolean;
85
-
86
- process.stdout.write = ((chunk: any, encoding?: any, cb?: any) => {
87
- try {
88
- const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
89
- addLog(text);
90
- } catch { }
91
- return originalStdoutWrite(chunk, encoding as any, cb as any);
92
- }) as any;
93
- }
94
-
95
- if (typeof process !== "undefined" && process?.stderr?.write) {
96
- const originalStderrWrite = process.stderr.write.bind(process.stderr) as (
97
- chunk: any,
98
- encoding?: any,
99
- cb?: any
100
- ) => boolean;
101
-
102
- process.stderr.write = ((chunk: any, encoding?: any, cb?: any) => {
103
- try {
104
- const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
105
- addLog(text);
106
- } catch { }
107
- return originalStderrWrite(chunk, encoding as any, cb as any);
108
- }) as any;
109
- }
110
- }
111
-
17
+
18
+ let currentAbortController: AbortController | null = null;
19
+
20
+ const HTML_TEMPLATE = `<!DOCTYPE html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="UTF-8">
24
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
25
+ <title>Mosaic</title>
26
+ <link rel="icon" type="image/svg+xml" href="/logo_black.svg" media="(prefers-color-scheme: light)">
27
+ <link rel="icon" type="image/svg+xml" href="/logo_white.svg" media="(prefers-color-scheme: dark)">
28
+ <link rel="stylesheet" href="/app.css">
29
+ </head>
30
+ <body>
31
+ <div id="root"></div>
32
+ <script type="module" src="/app.js"></script>
33
+ </body>
34
+ </html>`;
35
+
36
+ type LogEntry = { message: string; timestamp: string };
37
+
38
+ const logs: LogEntry[] = [];
39
+ const listeners: Set<() => void> = new Set();
40
+ const MAX_HISTORY_MESSAGES = 24;
41
+ const PROVIDER_CONCURRENCY = 1;
42
+ const PROVIDER_QUEUE_TIMEOUT_MS = 30000;
43
+
44
+ type ReleaseFn = () => void;
45
+
46
+ type QueueItem = {
47
+ resolve: (release: ReleaseFn) => void;
48
+ cancelled: boolean;
49
+ };
50
+
51
+ type QueueEntry = {
52
+ inflight: number;
53
+ queue: QueueItem[];
54
+ };
55
+
56
+ const providerQueues = new Map<string, QueueEntry>();
57
+
58
+ function createRelease(entry: QueueEntry): ReleaseFn {
59
+ let released = false;
60
+ return () => {
61
+ if (released) return;
62
+ released = true;
63
+ entry.inflight = Math.max(0, entry.inflight - 1);
64
+ while (entry.queue.length > 0) {
65
+ const next = entry.queue.shift();
66
+ if (!next || next.cancelled) continue;
67
+ entry.inflight += 1;
68
+ next.resolve(createRelease(entry));
69
+ break;
70
+ }
71
+ };
72
+ }
73
+
74
+ function acquireProviderSlot(key: string, limit = PROVIDER_CONCURRENCY): { promise: Promise<ReleaseFn>; cancel: () => void } {
75
+ let entry = providerQueues.get(key);
76
+ if (!entry) {
77
+ entry = { inflight: 0, queue: [] };
78
+ providerQueues.set(key, entry);
79
+ }
80
+
81
+ if (entry.inflight < limit) {
82
+ entry.inflight += 1;
83
+ return {
84
+ promise: Promise.resolve(createRelease(entry)),
85
+ cancel: () => { }
86
+ };
87
+ }
88
+
89
+ let item: QueueItem | null = null;
90
+ const promise = new Promise<ReleaseFn>((resolve) => {
91
+ item = { resolve, cancelled: false };
92
+ entry!.queue.push(item);
93
+ });
94
+
95
+ return {
96
+ promise,
97
+ cancel: () => {
98
+ if (item) item.cancelled = true;
99
+ }
100
+ };
101
+ }
102
+
103
+ function addLog(message: string) {
104
+ const timestamp = new Date().toLocaleTimeString();
105
+ const clean = String(message ?? "").replace(/\r/g, "").trimEnd();
106
+ if (!clean) return;
107
+
108
+ const lines = clean.split("\n");
109
+ for (const line of lines) {
110
+ if (!line) continue;
111
+ logs.push({ message: line, timestamp });
112
+
113
+ }
114
+ while (logs.length > 50) logs.shift();
115
+ listeners.forEach((l) => l());
116
+ }
117
+
118
+ function installExternalLogCapture() {
119
+ const originalLog = console.log.bind(console);
120
+ const originalInfo = console.info.bind(console);
121
+ const originalWarn = console.warn.bind(console);
122
+ const originalError = console.error.bind(console);
123
+
124
+ console.log = (...args: any[]) => {
125
+ addLog(args.map(String).join(" "));
126
+ originalLog(...args);
127
+ };
128
+ console.info = (...args: any[]) => {
129
+ addLog(args.map(String).join(" "));
130
+ originalInfo(...args);
131
+ };
132
+ console.warn = (...args: any[]) => {
133
+ addLog(args.map(String).join(" "));
134
+ originalWarn(...args);
135
+ };
136
+ console.error = (...args: any[]) => {
137
+ addLog(args.map(String).join(" "));
138
+ originalError(...args);
139
+ };
140
+
141
+ if (typeof process !== "undefined" && process?.stdout?.write) {
142
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout) as (
143
+ chunk: any,
144
+ encoding?: any,
145
+ cb?: any
146
+ ) => boolean;
147
+
148
+ process.stdout.write = ((chunk: any, encoding?: any, cb?: any) => {
149
+ try {
150
+ const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
151
+ addLog(text);
152
+ } catch { }
153
+ return originalStdoutWrite(chunk, encoding as any, cb as any);
154
+ }) as any;
155
+ }
156
+
157
+ if (typeof process !== "undefined" && process?.stderr?.write) {
158
+ const originalStderrWrite = process.stderr.write.bind(process.stderr) as (
159
+ chunk: any,
160
+ encoding?: any,
161
+ cb?: any
162
+ ) => boolean;
163
+
164
+ process.stderr.write = ((chunk: any, encoding?: any, cb?: any) => {
165
+ try {
166
+ const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
167
+ addLog(text);
168
+ } catch { }
169
+ return originalStderrWrite(chunk, encoding as any, cb as any);
170
+ }) as any;
171
+ }
172
+ }
173
+
112
174
  installExternalLogCapture();
113
175
 
114
176
  let appJsContent: string | null = null;
@@ -128,9 +190,9 @@ function buildConversationHistory(
128
190
  history: Array<{ role: string; content: string; images?: ImageAttachment[] }>,
129
191
  allowImages: boolean
130
192
  ) {
131
- return history
132
- .filter((m) => m.role === "user" || m.role === "assistant")
133
- .map((m) => {
193
+ const filtered = history.filter((m) => m.role === "user" || m.role === "assistant");
194
+ const sliced = filtered.slice(-MAX_HISTORY_MESSAGES);
195
+ return sliced.map((m) => {
134
196
  if (m.role === "user") {
135
197
  const content = allowImages ? buildUserContent(m.content, m.images) : m.content;
136
198
  return { role: "user" as const, content };
@@ -138,230 +200,227 @@ function buildConversationHistory(
138
200
  return { role: "assistant" as const, content: m.content };
139
201
  });
140
202
  }
141
-
142
- async function buildApp() {
143
- const appPath = join(__dirname, "app.tsx");
144
-
145
- if (!existsSync(appPath)) {
146
- throw new Error(`App file not found at: ${appPath}`);
147
- }
148
-
149
- const buildResult = await build({
150
- entrypoints: [appPath],
151
- target: "browser",
152
- format: "esm",
153
- minify: false,
154
- splitting: false,
155
- sourcemap: "none",
156
- });
157
-
158
-
159
- if (!buildResult.success) {
160
- throw new Error("Build failed");
161
- }
162
-
163
- const outputs = buildResult.outputs;
164
- if (outputs.length === 0) {
165
- throw new Error("No build output generated");
166
- }
167
-
168
- for (const output of outputs) {
169
- if (output.path.endsWith('.js') || output.kind === 'entry-point') {
170
- appJsContent = await output.text();
171
- } else if (output.path.endsWith('.css') || output.type === 'text/css') {
172
- appCssContent = await output.text();
173
- }
174
- }
175
- }
176
-
177
- try {
178
- await buildApp();
179
- addLog("App built");
180
-
181
- const projectPath = process.env.MOSAIC_PROJECT_PATH;
182
- if (projectPath) {
183
- const { addRecentProject } = await import("../utils/config");
184
- addRecentProject(projectPath);
185
- addLog(`Project added to recents: ${projectPath}`);
186
- }
187
- } catch (error) {
188
- console.error("Failed to build app:", error);
189
- throw error;
190
- }
191
-
192
-
193
- let currentPort = PORT;
194
-
195
- async function startServer(port: number, maxRetries = 10) {
196
- try {
197
- const server = serve({
198
- port: port,
199
- hostname: HOST,
200
- idleTimeout: 0,
201
- async fetch(request) {
202
- const url = new URL(request.url);
203
-
204
- try {
205
- const isApiRoute = url.pathname.startsWith('/api/');
206
- const isStaticFile = url.pathname.match(/\.(js|css|svg|ico|png|jpg|jpeg|gif|webp|woff|woff2|ttf|eot)$/);
207
-
208
- if (url.pathname === "/" || url.pathname === "/home" || url.pathname.startsWith("/chat")) {
209
- addLog(`${request.method} ${url.pathname}`);
210
- return new Response(HTML_TEMPLATE, {
211
- headers: { "Content-Type": "text/html" },
212
- });
213
- }
214
-
215
- if (url.pathname === "/app.js") {
216
- if (!appJsContent) {
217
- addLog("App not built");
218
- return new Response("App not built", { status: 500 });
219
-
220
- }
221
- addLog(`${request.method} /app.js`);
222
- return new Response(appJsContent, {
223
- headers: {
224
- "Content-Type": "application/javascript",
225
- "Cache-Control": "no-cache",
226
- },
227
- });
228
-
229
- }
230
-
231
- if (url.pathname === "/app.css") {
232
- if (!appCssContent) {
233
- return new Response("", { headers: { "Content-Type": "text/css" } });
234
-
235
- }
236
- addLog(`${request.method} /app.css`);
237
- return new Response(appCssContent, {
238
- headers: {
239
- "Content-Type": "text/css",
240
- "Cache-Control": "no-cache",
241
- },
242
- });
243
-
244
- }
245
-
246
- if (url.pathname === "/logo_black.svg") {
247
- const logoPath = join(__dirname, "logo_black.svg");
248
- if (existsSync(logoPath)) {
249
- return new Response(Bun.file(logoPath), {
250
- headers: { "Content-Type": "image/svg+xml" }
251
- });
252
-
253
- }
254
- return new Response("Not Found", { status: 404 });
255
-
256
- }
257
-
258
- if (url.pathname === "/logo_white.svg") {
259
- const logoPath = join(__dirname, "logo_white.svg");
260
- if (existsSync(logoPath)) {
261
- return new Response(Bun.file(logoPath), {
262
- headers: { "Content-Type": "image/svg+xml" }
263
- });
264
-
265
- }
266
- return new Response("Not Found", { status: 404 });
267
-
268
- }
269
-
270
- if (url.pathname === "/favicon.ico") {
271
- const faviconPath = join(__dirname, "favicon.ico");
272
- if (existsSync(faviconPath)) {
273
- return new Response(Bun.file(faviconPath));
274
- }
275
- return new Response("Not Found", { status: 404 });
276
-
277
- }
278
-
279
- if (url.pathname === "/favicon.png") {
280
- const faviconPath = join(__dirname, "favicon.png");
281
- if (existsSync(faviconPath)) {
282
- return new Response(Bun.file(faviconPath));
283
- }
284
- return new Response("Not Found", { status: 404 });
285
-
286
- }
287
-
288
- if (url.pathname === "/api/workspace" && request.method === "GET") {
289
- const workspace = process.cwd();
290
- return new Response(JSON.stringify({ workspace }), {
291
- headers: { "Content-Type": "application/json" },
292
- });
293
- }
294
-
295
- if (url.pathname === "/api/workspace" && request.method === "POST") {
296
- const body = (await request.json()) as { path: string };
297
- if (!body.path || typeof body.path !== "string") {
298
- return new Response(JSON.stringify({ error: "Invalid path" }), {
299
- status: 400,
300
- headers: { "Content-Type": "application/json" },
301
- });
302
- }
303
-
304
- try {
305
- process.chdir(body.path);
306
- return new Response(JSON.stringify({ success: true, workspace: process.cwd() }), {
307
- headers: { "Content-Type": "application/json" },
308
- });
309
- } catch (error) {
310
- return new Response(JSON.stringify({ error: "Failed to change directory" }), {
311
- status: 500,
312
- headers: { "Content-Type": "application/json" },
313
- });
314
- }
315
- }
316
-
317
- if (url.pathname === "/api/files" && request.method === "GET") {
318
- const urlObj = new URL(request.url);
319
- const queryPath = urlObj.searchParams.get("path");
320
- const currentPath = queryPath || process.cwd();
321
-
322
- try {
323
- if (!existsSync(currentPath)) {
324
- return new Response(JSON.stringify({ error: "Path does not exist" }), {
325
- status: 404,
326
- headers: { "Content-Type": "application/json" },
327
- });
328
- }
329
-
330
- const items = readdirSync(currentPath, { withFileTypes: true });
331
- const files = items.map((item) => ({
332
- name: item.name,
333
- isDirectory: item.isDirectory(),
334
- path: join(currentPath, item.name)
335
- })).sort((a, b) => {
336
- if (a.isDirectory === b.isDirectory) {
337
- return a.name.localeCompare(b.name);
338
- }
339
- return a.isDirectory ? -1 : 1;
340
- });
341
-
342
- return new Response(JSON.stringify({
343
- path: currentPath,
344
- files
345
- }), {
346
- headers: { "Content-Type": "application/json" },
347
- });
348
-
349
- } catch (error) {
350
- return new Response(JSON.stringify({ error: "Failed to list files" }), {
351
- status: 500,
352
- headers: { "Content-Type": "application/json" },
353
- });
354
- }
355
- }
356
-
357
- if (url.pathname === "/api/recent-projects" && request.method === "GET") {
358
- const { getRecentProjects } = await import("../utils/config");
359
- const recentProjects = getRecentProjects();
360
- return new Response(JSON.stringify(recentProjects), {
361
- headers: { "Content-Type": "application/json" },
362
- });
363
- }
364
-
203
+
204
+ async function buildApp() {
205
+ const appPath = join(__dirname, "app.tsx");
206
+
207
+ if (!existsSync(appPath)) {
208
+ throw new Error(`App file not found at: ${appPath}`);
209
+ }
210
+
211
+ const buildResult = await build({
212
+ entrypoints: [appPath],
213
+ target: "browser",
214
+ format: "esm",
215
+ minify: false,
216
+ splitting: false,
217
+ sourcemap: "none",
218
+ });
219
+
220
+
221
+ if (!buildResult.success) {
222
+ throw new Error("Build failed");
223
+ }
224
+
225
+ const outputs = buildResult.outputs;
226
+ if (outputs.length === 0) {
227
+ throw new Error("No build output generated");
228
+ }
229
+
230
+ for (const output of outputs) {
231
+ if (output.path.endsWith('.js') || output.kind === 'entry-point') {
232
+ appJsContent = await output.text();
233
+ } else if (output.path.endsWith('.css') || output.type === 'text/css') {
234
+ appCssContent = await output.text();
235
+ }
236
+ }
237
+ }
238
+
239
+ try {
240
+ await buildApp();
241
+ addLog("App built");
242
+
243
+ const projectPath = process.env.MOSAIC_PROJECT_PATH;
244
+ if (projectPath) {
245
+ const { addRecentProject } = await import("../utils/config");
246
+ addRecentProject(projectPath);
247
+ addLog(`Project added to recents: ${projectPath}`);
248
+ }
249
+ } catch (error) {
250
+ console.error("Failed to build app:", error);
251
+ throw error;
252
+ }
253
+
254
+
255
+ let currentPort = PORT;
256
+
257
+ async function startServer(port: number, maxRetries = 10) {
258
+ try {
259
+ const server = serve({
260
+ port: port,
261
+ hostname: HOST,
262
+ idleTimeout: 0,
263
+ async fetch(request) {
264
+ const url = new URL(request.url);
265
+
266
+ try {
267
+ if (url.pathname === "/" || url.pathname === "/home" || url.pathname.startsWith("/chat")) {
268
+ addLog(`${request.method} ${url.pathname}`);
269
+ return new Response(HTML_TEMPLATE, {
270
+ headers: { "Content-Type": "text/html" },
271
+ });
272
+ }
273
+
274
+ if (url.pathname === "/app.js") {
275
+ if (!appJsContent) {
276
+ addLog("App not built");
277
+ return new Response("App not built", { status: 500 });
278
+
279
+ }
280
+ addLog(`${request.method} /app.js`);
281
+ return new Response(appJsContent, {
282
+ headers: {
283
+ "Content-Type": "application/javascript",
284
+ "Cache-Control": "no-cache",
285
+ },
286
+ });
287
+
288
+ }
289
+
290
+ if (url.pathname === "/app.css") {
291
+ if (!appCssContent) {
292
+ return new Response("", { headers: { "Content-Type": "text/css" } });
293
+
294
+ }
295
+ addLog(`${request.method} /app.css`);
296
+ return new Response(appCssContent, {
297
+ headers: {
298
+ "Content-Type": "text/css",
299
+ "Cache-Control": "no-cache",
300
+ },
301
+ });
302
+
303
+ }
304
+
305
+ if (url.pathname === "/logo_black.svg") {
306
+ const logoPath = join(__dirname, "logo_black.svg");
307
+ if (existsSync(logoPath)) {
308
+ return new Response(Bun.file(logoPath), {
309
+ headers: { "Content-Type": "image/svg+xml" }
310
+ });
311
+
312
+ }
313
+ return new Response("Not Found", { status: 404 });
314
+
315
+ }
316
+
317
+ if (url.pathname === "/logo_white.svg") {
318
+ const logoPath = join(__dirname, "logo_white.svg");
319
+ if (existsSync(logoPath)) {
320
+ return new Response(Bun.file(logoPath), {
321
+ headers: { "Content-Type": "image/svg+xml" }
322
+ });
323
+
324
+ }
325
+ return new Response("Not Found", { status: 404 });
326
+
327
+ }
328
+
329
+ if (url.pathname === "/favicon.ico") {
330
+ const faviconPath = join(__dirname, "favicon.ico");
331
+ if (existsSync(faviconPath)) {
332
+ return new Response(Bun.file(faviconPath));
333
+ }
334
+ return new Response("Not Found", { status: 404 });
335
+
336
+ }
337
+
338
+ if (url.pathname === "/favicon.png") {
339
+ const faviconPath = join(__dirname, "favicon.png");
340
+ if (existsSync(faviconPath)) {
341
+ return new Response(Bun.file(faviconPath));
342
+ }
343
+ return new Response("Not Found", { status: 404 });
344
+
345
+ }
346
+
347
+ if (url.pathname === "/api/workspace" && request.method === "GET") {
348
+ const workspace = process.cwd();
349
+ return new Response(JSON.stringify({ workspace }), {
350
+ headers: { "Content-Type": "application/json" },
351
+ });
352
+ }
353
+
354
+ if (url.pathname === "/api/workspace" && request.method === "POST") {
355
+ const body = (await request.json()) as { path: string };
356
+ if (!body.path || typeof body.path !== "string") {
357
+ return new Response(JSON.stringify({ error: "Invalid path" }), {
358
+ status: 400,
359
+ headers: { "Content-Type": "application/json" },
360
+ });
361
+ }
362
+
363
+ try {
364
+ process.chdir(body.path);
365
+ return new Response(JSON.stringify({ success: true, workspace: process.cwd() }), {
366
+ headers: { "Content-Type": "application/json" },
367
+ });
368
+ } catch (error) {
369
+ return new Response(JSON.stringify({ error: "Failed to change directory" }), {
370
+ status: 500,
371
+ headers: { "Content-Type": "application/json" },
372
+ });
373
+ }
374
+ }
375
+
376
+ if (url.pathname === "/api/files" && request.method === "GET") {
377
+ const urlObj = new URL(request.url);
378
+ const queryPath = urlObj.searchParams.get("path");
379
+ const currentPath = queryPath || process.cwd();
380
+
381
+ try {
382
+ if (!existsSync(currentPath)) {
383
+ return new Response(JSON.stringify({ error: "Path does not exist" }), {
384
+ status: 404,
385
+ headers: { "Content-Type": "application/json" },
386
+ });
387
+ }
388
+
389
+ const items = readdirSync(currentPath, { withFileTypes: true });
390
+ const files = items.map((item) => ({
391
+ name: item.name,
392
+ isDirectory: item.isDirectory(),
393
+ path: join(currentPath, item.name)
394
+ })).sort((a, b) => {
395
+ if (a.isDirectory === b.isDirectory) {
396
+ return a.name.localeCompare(b.name);
397
+ }
398
+ return a.isDirectory ? -1 : 1;
399
+ });
400
+
401
+ return new Response(JSON.stringify({
402
+ path: currentPath,
403
+ files
404
+ }), {
405
+ headers: { "Content-Type": "application/json" },
406
+ });
407
+
408
+ } catch (error) {
409
+ return new Response(JSON.stringify({ error: "Failed to list files" }), {
410
+ status: 500,
411
+ headers: { "Content-Type": "application/json" },
412
+ });
413
+ }
414
+ }
415
+
416
+ if (url.pathname === "/api/recent-projects" && request.method === "GET") {
417
+ const { getRecentProjects } = await import("../utils/config");
418
+ const recentProjects = getRecentProjects();
419
+ return new Response(JSON.stringify(recentProjects), {
420
+ headers: { "Content-Type": "application/json" },
421
+ });
422
+ }
423
+
365
424
  if (url.pathname === "/api/config" && request.method === "GET") {
366
425
  const { readConfig } = await import("../utils/config");
367
426
  const config = readConfig();
@@ -401,13 +460,13 @@ async function startServer(port: number, maxRetries = 10) {
401
460
  headers: { "Content-Type": "application/json" },
402
461
  });
403
462
  }
404
-
405
- if (url.pathname === "/api/tui-conversations" && request.method === "GET") {
406
- const { loadConversations } = await import("../utils/history");
407
- const historyConversations = loadConversations();
408
- const mapped = historyConversations.map((conv) => {
409
- const steps = Array.isArray(conv.steps) ? conv.steps : [];
410
- const baseTimestamp = typeof conv.timestamp === "number" ? conv.timestamp : Date.now();
463
+
464
+ if (url.pathname === "/api/tui-conversations" && request.method === "GET") {
465
+ const { loadConversations } = await import("../utils/history");
466
+ const historyConversations = loadConversations();
467
+ const mapped = historyConversations.map((conv) => {
468
+ const steps = Array.isArray(conv.steps) ? conv.steps : [];
469
+ const baseTimestamp = typeof conv.timestamp === "number" ? conv.timestamp : Date.now();
411
470
  const messages = steps.map((step, index) => ({
412
471
  id: `${conv.id}_${index}`,
413
472
  role: step.type === "tool" ? "tool" : step.type,
@@ -416,104 +475,104 @@ async function startServer(port: number, maxRetries = 10) {
416
475
  toolName: step.toolName,
417
476
  toolArgs: step.toolArgs,
418
477
  toolResult: step.toolResult,
419
- timestamp: step.timestamp,
420
- responseDuration: step.responseDuration,
421
- blendWord: step.blendWord
422
- }));
423
-
424
- return {
425
- id: `tui_${conv.id}`,
426
- title: conv.title ?? null,
427
- messages,
428
- workspace: conv.workspace ?? null,
429
- createdAt: baseTimestamp,
430
- updatedAt: baseTimestamp
431
- };
432
- });
433
-
434
- return new Response(JSON.stringify(mapped), {
435
- headers: { "Content-Type": "application/json" },
436
- });
437
- }
438
-
439
- if (url.pathname === "/api/tui-conversation/rename" && request.method === "POST") {
440
- const body = (await request.json()) as { id: string; title: string | null };
441
- if (!body?.id || typeof body.id !== "string") {
442
- return new Response(JSON.stringify({ error: "Invalid id" }), {
443
- status: 400,
444
- headers: { "Content-Type": "application/json" },
445
- });
446
- }
447
- const historyId = body.id.startsWith("tui_") ? body.id.slice(4) : body.id;
448
- const { updateConversationTitle } = await import("../utils/history");
449
- const success = updateConversationTitle(historyId, body.title ?? null);
450
- return new Response(JSON.stringify({ success }), {
451
- headers: { "Content-Type": "application/json" },
452
- });
453
- }
454
-
455
- if (url.pathname === "/api/tui-conversation/delete" && request.method === "POST") {
456
- const body = (await request.json()) as { id: string };
457
- if (!body?.id || typeof body.id !== "string") {
458
- return new Response(JSON.stringify({ error: "Invalid id" }), {
459
- status: 400,
460
- headers: { "Content-Type": "application/json" },
461
- });
462
- }
463
- const historyId = body.id.startsWith("tui_") ? body.id.slice(4) : body.id;
464
- const { deleteConversation } = await import("../utils/history");
465
- const success = deleteConversation(historyId);
466
- return new Response(JSON.stringify({ success }), {
467
- headers: { "Content-Type": "application/json" },
468
- });
469
- }
470
-
471
- if (url.pathname === "/api/add-recent-project" && request.method === "POST") {
472
- const body = (await request.json()) as { path: string };
473
- if (!body.path || typeof body.path !== "string") {
474
- return new Response(JSON.stringify({ error: "Invalid path" }), {
475
- status: 400,
476
- headers: { "Content-Type": "application/json" },
477
- });
478
- }
479
- const { addRecentProject } = await import("../utils/config");
480
- addRecentProject(body.path);
481
- addLog(`Added recent project: ${body.path}`);
482
- return new Response(JSON.stringify({ success: true }), {
483
- headers: { "Content-Type": "application/json" },
484
- });
485
- }
486
-
487
- if (url.pathname === "/api/question/answer" && request.method === "POST") {
488
- const body = (await request.json()) as { index: number; customText?: string };
489
- answerQuestion(body.index, body.customText);
490
- return new Response(JSON.stringify({ success: true }), {
491
- headers: { "Content-Type": "application/json" },
492
- });
493
- }
494
-
495
- if (url.pathname === "/api/approval/respond" && request.method === "POST") {
496
- const body = (await request.json()) as { approved: boolean; customResponse?: string };
497
- respondApproval(body.approved, body.customResponse);
498
- return new Response(JSON.stringify({ success: true }), {
499
- headers: { "Content-Type": "application/json" },
500
- });
501
- }
502
-
503
- if (url.pathname === "/api/stop" && request.method === "POST") {
504
- if (currentAbortController) {
505
- currentAbortController.abort();
506
- currentAbortController = null;
507
- addLog("Agent stopped by user");
508
- return new Response(JSON.stringify({ success: true, message: "Agent stopped" }), {
509
- headers: { "Content-Type": "application/json" },
510
- });
511
- }
512
- return new Response(JSON.stringify({ success: false, message: "No agent running" }), {
513
- headers: { "Content-Type": "application/json" },
514
- });
515
- }
516
-
478
+ timestamp: step.timestamp,
479
+ responseDuration: step.responseDuration,
480
+ blendWord: step.blendWord
481
+ }));
482
+
483
+ return {
484
+ id: `tui_${conv.id}`,
485
+ title: conv.title ?? null,
486
+ messages,
487
+ workspace: conv.workspace ?? null,
488
+ createdAt: baseTimestamp,
489
+ updatedAt: baseTimestamp
490
+ };
491
+ });
492
+
493
+ return new Response(JSON.stringify(mapped), {
494
+ headers: { "Content-Type": "application/json" },
495
+ });
496
+ }
497
+
498
+ if (url.pathname === "/api/tui-conversation/rename" && request.method === "POST") {
499
+ const body = (await request.json()) as { id: string; title: string | null };
500
+ if (!body?.id || typeof body.id !== "string") {
501
+ return new Response(JSON.stringify({ error: "Invalid id" }), {
502
+ status: 400,
503
+ headers: { "Content-Type": "application/json" },
504
+ });
505
+ }
506
+ const historyId = body.id.startsWith("tui_") ? body.id.slice(4) : body.id;
507
+ const { updateConversationTitle } = await import("../utils/history");
508
+ const success = updateConversationTitle(historyId, body.title ?? null);
509
+ return new Response(JSON.stringify({ success }), {
510
+ headers: { "Content-Type": "application/json" },
511
+ });
512
+ }
513
+
514
+ if (url.pathname === "/api/tui-conversation/delete" && request.method === "POST") {
515
+ const body = (await request.json()) as { id: string };
516
+ if (!body?.id || typeof body.id !== "string") {
517
+ return new Response(JSON.stringify({ error: "Invalid id" }), {
518
+ status: 400,
519
+ headers: { "Content-Type": "application/json" },
520
+ });
521
+ }
522
+ const historyId = body.id.startsWith("tui_") ? body.id.slice(4) : body.id;
523
+ const { deleteConversation } = await import("../utils/history");
524
+ const success = deleteConversation(historyId);
525
+ return new Response(JSON.stringify({ success }), {
526
+ headers: { "Content-Type": "application/json" },
527
+ });
528
+ }
529
+
530
+ if (url.pathname === "/api/add-recent-project" && request.method === "POST") {
531
+ const body = (await request.json()) as { path: string };
532
+ if (!body.path || typeof body.path !== "string") {
533
+ return new Response(JSON.stringify({ error: "Invalid path" }), {
534
+ status: 400,
535
+ headers: { "Content-Type": "application/json" },
536
+ });
537
+ }
538
+ const { addRecentProject } = await import("../utils/config");
539
+ addRecentProject(body.path);
540
+ addLog(`Added recent project: ${body.path}`);
541
+ return new Response(JSON.stringify({ success: true }), {
542
+ headers: { "Content-Type": "application/json" },
543
+ });
544
+ }
545
+
546
+ if (url.pathname === "/api/question/answer" && request.method === "POST") {
547
+ const body = (await request.json()) as { index: number; customText?: string };
548
+ answerQuestion(body.index, body.customText);
549
+ return new Response(JSON.stringify({ success: true }), {
550
+ headers: { "Content-Type": "application/json" },
551
+ });
552
+ }
553
+
554
+ if (url.pathname === "/api/approval/respond" && request.method === "POST") {
555
+ const body = (await request.json()) as { approved: boolean; customResponse?: string };
556
+ respondApproval(body.approved, body.customResponse);
557
+ return new Response(JSON.stringify({ success: true }), {
558
+ headers: { "Content-Type": "application/json" },
559
+ });
560
+ }
561
+
562
+ if (url.pathname === "/api/stop" && request.method === "POST") {
563
+ if (currentAbortController) {
564
+ currentAbortController.abort();
565
+ currentAbortController = null;
566
+ addLog("Agent stopped by user");
567
+ return new Response(JSON.stringify({ success: true, message: "Agent stopped" }), {
568
+ headers: { "Content-Type": "application/json" },
569
+ });
570
+ }
571
+ return new Response(JSON.stringify({ success: false, message: "No agent running" }), {
572
+ headers: { "Content-Type": "application/json" },
573
+ });
574
+ }
575
+
517
576
  if (url.pathname === "/api/message" && request.method === "POST") {
518
577
  const body = (await request.json()) as {
519
578
  message?: string;
@@ -541,88 +600,113 @@ async function startServer(port: number, maxRetries = 10) {
541
600
  }
542
601
 
543
602
  addLog("Message received");
544
-
545
- currentAbortController = new AbortController();
546
- const abortSignal = currentAbortController.signal;
547
-
548
- const encoder = new TextEncoder();
549
- const stream = new ReadableStream({
550
- async start(controller) {
551
- let keepAlive: ReturnType<typeof setInterval> | null = null;
552
- let aborted = false;
553
-
554
- const cleanup = () => {
555
- if (keepAlive) clearInterval(keepAlive);
556
- currentAbortController = null;
557
- };
558
-
559
- const safeEnqueue = (text: string) => {
560
- if (aborted) return false;
561
- try {
562
- controller.enqueue(encoder.encode(text));
563
- return true;
564
- } catch {
565
- return false;
566
- }
567
- };
568
-
569
- abortSignal.addEventListener('abort', () => {
570
- aborted = true;
571
- safeEnqueue(JSON.stringify({ type: 'stopped', message: 'Agent stopped by user' }) + "\n");
572
- cleanup();
573
- questionUnsub();
574
- approvalUnsub();
575
- exploreUnsub?.();
576
- try { controller.close(); } catch { }
577
- });
578
-
579
- const questionUnsub = subscribeQuestion((req) => {
580
- safeEnqueue(JSON.stringify({ type: 'question', request: req }) + "\n");
581
- });
582
-
583
-
584
- const approvalUnsub = subscribeApproval((req) => {
585
- safeEnqueue(JSON.stringify({ type: 'approval', request: req }) + "\n");
586
- });
587
-
588
- keepAlive = setInterval(() => {
589
- safeEnqueue(JSON.stringify({ type: 'ping' }) + "\n");
590
- }, 5000);
591
-
592
- let exploreUnsub: (() => void) | null = null;
593
-
594
- try {
595
- const { Agent } = await import("../agent");
596
- const { subscribeExploreTool } = await import("../utils/exploreBridge");
597
-
598
- addLog("[EXPLORE] Subscribing...");
599
- exploreUnsub = subscribeExploreTool((event) => {
600
- addLog(`[EXPLORE] Tool: ${event.toolName}`);
601
- safeEnqueue(JSON.stringify({ type: 'explore-tool', ...event }) + "\n");
602
- });
603
- addLog("[EXPLORE] Subscribed");
604
- const providerStatus = await Agent.ensureProviderReady();
605
-
606
- if (!providerStatus.ready) {
607
- safeEnqueue(
608
- JSON.stringify({
609
- type: "error",
610
- error: providerStatus.error || "Provider not ready",
611
- }) + "\n"
612
- );
613
- cleanup();
614
- questionUnsub();
615
- approvalUnsub();
616
- exploreUnsub?.();
617
- controller.close();
618
- return;
619
- }
620
-
603
+
604
+ const { readConfig } = await import("../utils/config");
605
+ const config = readConfig();
606
+ const providerKey = `${config.provider ?? "unknown"}:${config.model ?? "unknown"}`;
607
+ const { promise, cancel } = acquireProviderSlot(providerKey, PROVIDER_CONCURRENCY);
608
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
609
+ const release = await Promise.race([
610
+ promise,
611
+ new Promise<ReleaseFn | null>((resolve) => {
612
+ timeoutId = setTimeout(() => resolve(null), PROVIDER_QUEUE_TIMEOUT_MS);
613
+ })
614
+ ]);
615
+ if (timeoutId) clearTimeout(timeoutId);
616
+
617
+ if (!release) {
618
+ cancel();
619
+ return new Response(JSON.stringify({ error: "Rate limit: too many concurrent requests. Try again shortly." }), {
620
+ status: 429,
621
+ headers: { "Content-Type": "application/json" },
622
+ });
623
+ }
624
+ const releaseSlot = release;
625
+
626
+ currentAbortController = new AbortController();
627
+ const abortSignal = currentAbortController.signal;
628
+
629
+ const encoder = new TextEncoder();
630
+ const stream = new ReadableStream({
631
+ async start(controller) {
632
+ let keepAlive: ReturnType<typeof setInterval> | null = null;
633
+ let aborted = false;
634
+ let released = false;
635
+
636
+ const cleanup = () => {
637
+ if (keepAlive) clearInterval(keepAlive);
638
+ currentAbortController = null;
639
+ if (!released) {
640
+ released = true;
641
+ releaseSlot();
642
+ }
643
+ };
644
+
645
+ const safeEnqueue = (text: string) => {
646
+ if (aborted) return false;
647
+ try {
648
+ controller.enqueue(encoder.encode(text));
649
+ return true;
650
+ } catch {
651
+ return false;
652
+ }
653
+ };
654
+
655
+ abortSignal.addEventListener('abort', () => {
656
+ aborted = true;
657
+ safeEnqueue(JSON.stringify({ type: 'stopped', message: 'Agent stopped by user' }) + "\n");
658
+ cleanup();
659
+ questionUnsub();
660
+ approvalUnsub();
661
+ exploreUnsub?.();
662
+ try { controller.close(); } catch { }
663
+ });
664
+
665
+ const questionUnsub = subscribeQuestion((req) => {
666
+ safeEnqueue(JSON.stringify({ type: 'question', request: req }) + "\n");
667
+ });
668
+
669
+
670
+ const approvalUnsub = subscribeApproval((req) => {
671
+ safeEnqueue(JSON.stringify({ type: 'approval', request: req }) + "\n");
672
+ });
673
+
674
+ keepAlive = setInterval(() => {
675
+ safeEnqueue(JSON.stringify({ type: 'ping' }) + "\n");
676
+ }, 5000);
677
+
678
+ let exploreUnsub: (() => void) | null = null;
679
+
680
+ try {
681
+ const { Agent } = await import("../agent");
682
+ const { subscribeExploreTool } = await import("../utils/exploreBridge");
683
+
684
+ addLog("[EXPLORE] Subscribing...");
685
+ exploreUnsub = subscribeExploreTool((event) => {
686
+ addLog(`[EXPLORE] Tool: ${event.toolName}`);
687
+ safeEnqueue(JSON.stringify({ type: 'explore-tool', ...event }) + "\n");
688
+ });
689
+ addLog("[EXPLORE] Subscribed");
690
+ const providerStatus = await Agent.ensureProviderReady();
691
+
692
+ if (!providerStatus.ready) {
693
+ safeEnqueue(
694
+ JSON.stringify({
695
+ type: "error",
696
+ error: providerStatus.error || "Provider not ready",
697
+ }) + "\n"
698
+ );
699
+ cleanup();
700
+ questionUnsub();
701
+ approvalUnsub();
702
+ exploreUnsub?.();
703
+ controller.close();
704
+ return;
705
+ }
706
+
621
707
  const agent = new Agent();
622
708
  let allowImages = false;
623
709
  try {
624
- const { readConfig } = await import("../utils/config");
625
- const config = readConfig();
626
710
  if (config.model) {
627
711
  const { findModelsDevModelById, modelAcceptsImages } = await import("../utils/models");
628
712
  const result = await findModelsDevModelById(config.model);
@@ -639,172 +723,172 @@ async function startServer(port: number, maxRetries = 10) {
639
723
 
640
724
 
641
725
  for await (const event of agent.streamMessages(conversationHistory as any, {})) {
642
- if (aborted) break;
643
- if (!safeEnqueue(JSON.stringify(event) + "\n")) break;
644
- }
645
-
646
- cleanup();
647
- questionUnsub();
648
- approvalUnsub();
649
- exploreUnsub?.();
650
- if (!aborted) controller.close();
651
- } catch (error) {
652
- if (!aborted) {
653
- safeEnqueue(
654
- JSON.stringify({
655
- type: "error",
656
- error: error instanceof Error ? error.message : "Unknown error",
657
- }) + "\n"
658
- );
659
- }
660
- cleanup();
661
- questionUnsub();
662
- approvalUnsub();
663
- exploreUnsub?.();
664
- try { controller.close(); } catch { }
665
- }
666
- },
667
- });
668
-
669
-
670
- return new Response(stream, {
671
- headers: {
672
- "Content-Type": "text/event-stream",
673
- "Cache-Control": "no-cache",
674
- Connection: "keep-alive",
675
- },
676
- });
677
-
678
- }
679
-
680
- addLog(`${request.method} ${url.pathname} (404)`);
681
- return new Response("Not Found", { status: 404 });
682
-
683
- } catch (error) {
684
- console.error("Request error:", error);
685
- addLog(`Server error: ${error instanceof Error ? error.message : "Unknown"}`);
686
- return new Response("Internal Server Error", { status: 500 });
687
- }
688
- },
689
- error(error) {
690
- console.error("Server error:", error);
691
- return new Response("Internal Server Error", { status: 500 });
692
- },
693
- });
694
-
695
- currentPort = port;
696
- const serverUrl = `http://${HOST}:${port}`;
697
- const openCommand = process.platform === "win32" ? `start ${serverUrl}` :
698
- process.platform === "darwin" ? `open ${serverUrl}` :
699
- `xdg-open ${serverUrl}`;
700
-
701
- exec(openCommand, (error) => {
702
- if (error) {
703
- console.error("Failed to open browser:", error);
704
- }
705
- });
706
-
707
- return server;
708
- } catch (err: any) {
709
- if (err.code === "EADDRINUSE") {
710
- if (maxRetries > 0) {
711
- console.log(`Port ${port} is in use, trying ${port + 1}...`);
712
- return startServer(port + 1, maxRetries - 1);
713
- } else {
714
- console.error(`Failed to find an available port after retries.`);
715
- throw err;
716
- }
717
- } else {
718
- throw err;
719
- }
720
- }
721
- }
722
-
723
- await startServer(PORT);
724
-
725
- function ServerStatus() {
726
- const [logList, setLogList] = React.useState<LogEntry[]>(logs);
727
- const [scrollOffset, setScrollOffset] = React.useState(0);
728
- const [terminalHeight, setTerminalHeight] = React.useState(process.stdout.rows || 24);
729
-
730
- React.useEffect(() => {
731
- const listener = () => {
732
- setLogList([...logs]);
733
- setScrollOffset(Math.max(0, logs.length - (terminalHeight - 6)));
734
- };
735
- listeners.add(listener);
736
- return () => {
737
- listeners.delete(listener);
738
- };
739
- }, [terminalHeight]);
740
-
741
- React.useEffect(() => {
742
- const handleResize = () => {
743
- setTerminalHeight(process.stdout.rows || 24);
744
- };
745
- process.stdout.on('resize', handleResize);
746
- return () => {
747
- process.stdout.off('resize', handleResize);
748
- };
749
- }, []);
750
-
751
- React.useEffect(() => {
752
- const handleData = (data: Buffer) => {
753
- const str = data.toString();
754
- if (str.includes('\x03')) {
755
- process.exit(0);
756
- }
757
-
758
- if (str.match(/\x1b\[<64;\d+;\d+M/)) {
759
- setScrollOffset(prev => Math.max(0, prev - 1));
760
- } else if (str.match(/\x1b\[<65;\d+;\d+M/)) {
761
- setScrollOffset(prev => prev + 1);
762
- }
763
- };
764
-
765
- if (process.stdin.isTTY) {
766
- process.stdin.setRawMode(true);
767
- process.stdout.write('\x1b[?1000h\x1b[?1006h\x1b[?1003h');
768
- process.stdin.on('data', handleData);
769
- }
770
-
771
- return () => {
772
- if (process.stdin.isTTY) {
773
- process.stdin.off('data', handleData);
774
- process.stdout.write('\x1b[?1000l\x1b[?1006l\x1b[?1003l');
775
- process.stdin.setRawMode(false);
776
- }
777
- };
778
- }, []);
779
-
780
- const logsHeight = Math.max(5, terminalHeight - 6);
781
- const visibleLogs = logList.slice(scrollOffset, scrollOffset + logsHeight);
782
-
783
- return (
784
- <box flexDirection="column" width="100%" height="100%" justifyContent="flex-start" alignItems="center" paddingTop={1}>
785
- <box flexDirection="row" marginBottom={1}>
786
- <text fg="#ffca38" attributes={TextAttributes.BOLD}>
787
- Web interface:{" "}
788
- </text>
789
- <text fg="gray">http://{HOST}:{currentPort}</text>
790
- </box>
791
-
792
- <box flexDirection="column" width={80} height={logsHeight} borderStyle="rounded" borderColor="gray" title={`Server Logs`}>
793
- {logList.length === 0 ? (
794
- <text fg="gray" attributes={TextAttributes.DIM}>
795
- No logs yet...
796
- </text>
797
- ) : (
798
- visibleLogs.map((log, i) => (
799
- <text key={i} fg="gray">
800
- [{log.timestamp}] {log.message}
801
- </text>
802
- ))
803
- )}
804
- </box>
805
- </box>
806
- );
807
- }
808
-
809
- const renderer = await createCliRenderer();
726
+ if (aborted) break;
727
+ if (!safeEnqueue(JSON.stringify(event) + "\n")) break;
728
+ }
729
+
730
+ cleanup();
731
+ questionUnsub();
732
+ approvalUnsub();
733
+ exploreUnsub?.();
734
+ if (!aborted) controller.close();
735
+ } catch (error) {
736
+ if (!aborted) {
737
+ safeEnqueue(
738
+ JSON.stringify({
739
+ type: "error",
740
+ error: error instanceof Error ? error.message : "Unknown error",
741
+ }) + "\n"
742
+ );
743
+ }
744
+ cleanup();
745
+ questionUnsub();
746
+ approvalUnsub();
747
+ exploreUnsub?.();
748
+ try { controller.close(); } catch { }
749
+ }
750
+ },
751
+ });
752
+
753
+
754
+ return new Response(stream, {
755
+ headers: {
756
+ "Content-Type": "text/event-stream",
757
+ "Cache-Control": "no-cache",
758
+ Connection: "keep-alive",
759
+ },
760
+ });
761
+
762
+ }
763
+
764
+ addLog(`${request.method} ${url.pathname} (404)`);
765
+ return new Response("Not Found", { status: 404 });
766
+
767
+ } catch (error) {
768
+ console.error("Request error:", error);
769
+ addLog(`Server error: ${error instanceof Error ? error.message : "Unknown"}`);
770
+ return new Response("Internal Server Error", { status: 500 });
771
+ }
772
+ },
773
+ error(error) {
774
+ console.error("Server error:", error);
775
+ return new Response("Internal Server Error", { status: 500 });
776
+ },
777
+ });
778
+
779
+ currentPort = port;
780
+ const serverUrl = `http://${HOST}:${port}`;
781
+ const openCommand = process.platform === "win32" ? `start ${serverUrl}` :
782
+ process.platform === "darwin" ? `open ${serverUrl}` :
783
+ `xdg-open ${serverUrl}`;
784
+
785
+ exec(openCommand, (error) => {
786
+ if (error) {
787
+ console.error("Failed to open browser:", error);
788
+ }
789
+ });
790
+
791
+ return server;
792
+ } catch (err: any) {
793
+ if (err.code === "EADDRINUSE") {
794
+ if (maxRetries > 0) {
795
+ console.log(`Port ${port} is in use, trying ${port + 1}...`);
796
+ return startServer(port + 1, maxRetries - 1);
797
+ } else {
798
+ console.error(`Failed to find an available port after retries.`);
799
+ throw err;
800
+ }
801
+ } else {
802
+ throw err;
803
+ }
804
+ }
805
+ }
806
+
807
+ await startServer(PORT);
808
+
809
+ function ServerStatus() {
810
+ const [logList, setLogList] = React.useState<LogEntry[]>(logs);
811
+ const [scrollOffset, setScrollOffset] = React.useState(0);
812
+ const [terminalHeight, setTerminalHeight] = React.useState(process.stdout.rows || 24);
813
+
814
+ React.useEffect(() => {
815
+ const listener = () => {
816
+ setLogList([...logs]);
817
+ setScrollOffset(Math.max(0, logs.length - (terminalHeight - 6)));
818
+ };
819
+ listeners.add(listener);
820
+ return () => {
821
+ listeners.delete(listener);
822
+ };
823
+ }, [terminalHeight]);
824
+
825
+ React.useEffect(() => {
826
+ const handleResize = () => {
827
+ setTerminalHeight(process.stdout.rows || 24);
828
+ };
829
+ process.stdout.on('resize', handleResize);
830
+ return () => {
831
+ process.stdout.off('resize', handleResize);
832
+ };
833
+ }, []);
834
+
835
+ React.useEffect(() => {
836
+ const handleData = (data: Buffer) => {
837
+ const str = data.toString();
838
+ if (str.includes('\x03')) {
839
+ process.exit(0);
840
+ }
841
+
842
+ if (str.match(/\x1b\[<64;\d+;\d+M/)) {
843
+ setScrollOffset(prev => Math.max(0, prev - 1));
844
+ } else if (str.match(/\x1b\[<65;\d+;\d+M/)) {
845
+ setScrollOffset(prev => prev + 1);
846
+ }
847
+ };
848
+
849
+ if (process.stdin.isTTY) {
850
+ process.stdin.setRawMode(true);
851
+ process.stdout.write('\x1b[?1000h\x1b[?1006h\x1b[?1003h');
852
+ process.stdin.on('data', handleData);
853
+ }
854
+
855
+ return () => {
856
+ if (process.stdin.isTTY) {
857
+ process.stdin.off('data', handleData);
858
+ process.stdout.write('\x1b[?1000l\x1b[?1006l\x1b[?1003l');
859
+ process.stdin.setRawMode(false);
860
+ }
861
+ };
862
+ }, []);
863
+
864
+ const logsHeight = Math.max(5, terminalHeight - 6);
865
+ const visibleLogs = logList.slice(scrollOffset, scrollOffset + logsHeight);
866
+
867
+ return (
868
+ <box flexDirection="column" width="100%" height="100%" justifyContent="flex-start" alignItems="center" paddingTop={1}>
869
+ <box flexDirection="row" marginBottom={1}>
870
+ <text fg="#ffca38" attributes={TextAttributes.BOLD}>
871
+ Web interface:{" "}
872
+ </text>
873
+ <text fg="gray">http://{HOST}:{currentPort}</text>
874
+ </box>
875
+
876
+ <box flexDirection="column" width={80} height={logsHeight} borderStyle="rounded" borderColor="gray" title={`Server Logs`}>
877
+ {logList.length === 0 ? (
878
+ <text fg="gray" attributes={TextAttributes.DIM}>
879
+ No logs yet...
880
+ </text>
881
+ ) : (
882
+ visibleLogs.map((log, i) => (
883
+ <text key={i} fg="gray">
884
+ [{log.timestamp}] {log.message}
885
+ </text>
886
+ ))
887
+ )}
888
+ </box>
889
+ </box>
890
+ );
891
+ }
892
+
893
+ const renderer = await createCliRenderer();
810
894
  createRoot(renderer).render(<ServerStatus />);