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