@creativeintelligence/abbie 0.1.5 → 0.1.7

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 (142) hide show
  1. package/bin/dev.js +1 -49
  2. package/bin/run.js +42 -49
  3. package/dist/cli/commands/login.js +26 -0
  4. package/dist/cli/commands/project/add.d.ts +0 -1
  5. package/dist/cli/commands/project/add.js +16 -52
  6. package/dist/cli/commands/project/list.js +13 -93
  7. package/dist/cli/commands/project/remove.d.ts +0 -2
  8. package/dist/cli/commands/project/remove.js +11 -28
  9. package/dist/cli/commands/session/list.js +3 -12
  10. package/dist/cli/commands/session/mark-done.js +1 -7
  11. package/dist/cli/commands/session/start.d.ts +0 -1
  12. package/dist/cli/commands/session/start.js +5 -7
  13. package/dist/lib/active-sessions.d.ts +0 -12
  14. package/dist/lib/active-sessions.js +6 -175
  15. package/dist/lib/project-path.d.ts +6 -0
  16. package/dist/lib/project-path.js +21 -0
  17. package/dist/lib.d.ts +1 -2
  18. package/dist/lib.js +2 -4
  19. package/oclif.manifest.json +2569 -6368
  20. package/package.json +1 -1
  21. package/dist/cli/commands/backlog/add.d.ts +0 -22
  22. package/dist/cli/commands/backlog/add.js +0 -65
  23. package/dist/cli/commands/backlog/claim.d.ts +0 -19
  24. package/dist/cli/commands/backlog/claim.js +0 -45
  25. package/dist/cli/commands/backlog/complete.d.ts +0 -18
  26. package/dist/cli/commands/backlog/complete.js +0 -42
  27. package/dist/cli/commands/backlog/list.d.ts +0 -20
  28. package/dist/cli/commands/backlog/list.js +0 -91
  29. package/dist/cli/commands/backlog/pick.d.ts +0 -18
  30. package/dist/cli/commands/backlog/pick.js +0 -42
  31. package/dist/cli/commands/backlog/sync.d.ts +0 -24
  32. package/dist/cli/commands/backlog/sync.js +0 -109
  33. package/dist/cli/commands/daemon.d.ts +0 -56
  34. package/dist/cli/commands/daemon.js +0 -1465
  35. package/dist/cli/commands/docs/lint.d.ts +0 -18
  36. package/dist/cli/commands/docs/lint.js +0 -82
  37. package/dist/cli/commands/docs/sync.d.ts +0 -19
  38. package/dist/cli/commands/docs/sync.js +0 -76
  39. package/dist/cli/commands/gc.d.ts +0 -29
  40. package/dist/cli/commands/gc.js +0 -211
  41. package/dist/cli/commands/index.d.ts +0 -36
  42. package/dist/cli/commands/index.js +0 -228
  43. package/dist/cli/commands/panes/broker.d.ts +0 -17
  44. package/dist/cli/commands/panes/broker.js +0 -57
  45. package/dist/cli/commands/panes/pipe-sink.d.ts +0 -17
  46. package/dist/cli/commands/panes/pipe-sink.js +0 -90
  47. package/dist/cli/commands/panes/snapshot.d.ts +0 -20
  48. package/dist/cli/commands/panes/snapshot.js +0 -125
  49. package/dist/cli/commands/preview/init.d.ts +0 -25
  50. package/dist/cli/commands/preview/init.js +0 -159
  51. package/dist/cli/commands/preview/sync.d.ts +0 -23
  52. package/dist/cli/commands/preview/sync.js +0 -144
  53. package/dist/cli/commands/preview/watch.d.ts +0 -24
  54. package/dist/cli/commands/preview/watch.js +0 -153
  55. package/dist/cli/commands/resource/acquire.d.ts +0 -21
  56. package/dist/cli/commands/resource/acquire.js +0 -90
  57. package/dist/cli/commands/resource/list.d.ts +0 -15
  58. package/dist/cli/commands/resource/list.js +0 -61
  59. package/dist/cli/commands/resource/release.d.ts +0 -18
  60. package/dist/cli/commands/resource/release.js +0 -50
  61. package/dist/cli/commands/resource/wait.d.ts +0 -21
  62. package/dist/cli/commands/resource/wait.js +0 -73
  63. package/dist/cli/commands/session/view.d.ts +0 -24
  64. package/dist/cli/commands/session/view.js +0 -145
  65. package/dist/cli/commands/start.d.ts +0 -37
  66. package/dist/cli/commands/start.js +0 -234
  67. package/dist/cli/commands/triage/claim.d.ts +0 -23
  68. package/dist/cli/commands/triage/claim.js +0 -186
  69. package/dist/cli/commands/triage/list.d.ts +0 -22
  70. package/dist/cli/commands/triage/list.js +0 -112
  71. package/dist/cli/commands/triage/next.d.ts +0 -18
  72. package/dist/cli/commands/triage/next.js +0 -63
  73. package/dist/cli/commands/triage/pull.d.ts +0 -19
  74. package/dist/cli/commands/triage/pull.js +0 -82
  75. package/dist/cli/commands/triage/stats.d.ts +0 -16
  76. package/dist/cli/commands/triage/stats.js +0 -69
  77. package/dist/cli/commands/tunnel/list.d.ts +0 -16
  78. package/dist/cli/commands/tunnel/list.js +0 -98
  79. package/dist/cli/commands/tunnel/start.d.ts +0 -24
  80. package/dist/cli/commands/tunnel/start.js +0 -107
  81. package/dist/cli/commands/tunnel/stop.d.ts +0 -20
  82. package/dist/cli/commands/tunnel/stop.js +0 -90
  83. package/dist/cli/commands/tunnel/url.d.ts +0 -21
  84. package/dist/cli/commands/tunnel/url.js +0 -70
  85. package/dist/cli/commands/windows/context.d.ts +0 -18
  86. package/dist/cli/commands/windows/context.js +0 -326
  87. package/dist/cli/commands/windows/focus.d.ts +0 -17
  88. package/dist/cli/commands/windows/focus.js +0 -103
  89. package/dist/cli/commands/windows/list.d.ts +0 -21
  90. package/dist/cli/commands/windows/list.js +0 -172
  91. package/dist/cli/commands/windows/map.d.ts +0 -17
  92. package/dist/cli/commands/windows/map.js +0 -168
  93. package/dist/cli/commands/windows/read.d.ts +0 -21
  94. package/dist/cli/commands/windows/read.js +0 -241
  95. package/dist/cli/commands/windows/search.d.ts +0 -24
  96. package/dist/cli/commands/windows/search.js +0 -171
  97. package/dist/cli/commands/windows/show.d.ts +0 -19
  98. package/dist/cli/commands/windows/show.js +0 -165
  99. package/dist/cli/commands/windows/watch.d.ts +0 -19
  100. package/dist/cli/commands/windows/watch.js +0 -241
  101. package/dist/lib/managed-session.d.ts +0 -27
  102. package/dist/lib/managed-session.js +0 -105
  103. package/dist/lib/panes/broker.d.ts +0 -130
  104. package/dist/lib/panes/broker.js +0 -97
  105. package/dist/lib/panes/index.d.ts +0 -2
  106. package/dist/lib/panes/index.js +0 -1
  107. package/dist/lib/panes/server.d.ts +0 -17
  108. package/dist/lib/panes/server.js +0 -308
  109. package/dist/lib/preview/manager.d.ts +0 -77
  110. package/dist/lib/preview/manager.js +0 -369
  111. package/dist/lib/preview/schema.d.ts +0 -2
  112. package/dist/lib/preview/schema.js +0 -32
  113. package/dist/lib/preview/sprite.d.ts +0 -85
  114. package/dist/lib/preview/sprite.js +0 -321
  115. package/dist/lib/preview/watcher.d.ts +0 -63
  116. package/dist/lib/preview/watcher.js +0 -185
  117. package/dist/lib/project-identity.d.ts +0 -16
  118. package/dist/lib/project-identity.js +0 -75
  119. package/dist/lib/tmux/bridge.d.ts +0 -133
  120. package/dist/lib/tmux/bridge.js +0 -315
  121. package/dist/lib/tmux/context.d.ts +0 -82
  122. package/dist/lib/tmux/context.js +0 -239
  123. package/dist/lib/tmux/index.d.ts +0 -8
  124. package/dist/lib/tmux/index.js +0 -11
  125. package/dist/lib/tmux/map.d.ts +0 -57
  126. package/dist/lib/tmux/map.js +0 -198
  127. package/dist/lib/tmux/panes.d.ts +0 -27
  128. package/dist/lib/tmux/panes.js +0 -151
  129. package/dist/lib/tmux/redaction.d.ts +0 -57
  130. package/dist/lib/tmux/redaction.js +0 -152
  131. package/dist/lib/web/analytics.d.ts +0 -63
  132. package/dist/lib/web/analytics.js +0 -168
  133. package/dist/lib/web/server.d.ts +0 -26
  134. package/dist/lib/web/server.js +0 -697
  135. package/dist/lib/web/tmux-bridge.d.ts +0 -7
  136. package/dist/lib/web/tmux-bridge.js +0 -7
  137. package/dist/lib/windows/index.d.ts +0 -3
  138. package/dist/lib/windows/index.js +0 -2
  139. package/dist/lib/windows/inventory.d.ts +0 -21
  140. package/dist/lib/windows/inventory.js +0 -263
  141. package/dist/lib/windows/types.d.ts +0 -46
  142. package/dist/lib/windows/types.js +0 -1
@@ -1,697 +0,0 @@
1
- /**
2
- * Web server for agents web UI.
3
- * Uses Hono for HTTP routing and Bun for WebSocket.
4
- */
5
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
- import { homedir } from "node:os";
7
- import { join, sep as pathSep, resolve as resolvePath } from "node:path";
8
- import { Hono } from "hono";
9
- import { serveStatic } from "hono/bun";
10
- import { cors } from "hono/cors";
11
- import { getActiveSessionManager } from "../active-sessions.js";
12
- import { createAnalyticsRoutes, getAnalyticsMiddleware, initWebAnalytics } from "./analytics.js";
13
- import * as tmux from "./tmux-bridge.js";
14
- // Global state for WebSocket clients
15
- const clients = new Set();
16
- const terminalStreams = new Map();
17
- /**
18
- * Start streaming terminal output to a client.
19
- */
20
- async function startTerminalStream(client, window, pane, session) {
21
- console.log("[terminal] startTerminalStream called", { window, pane, session });
22
- // Stop existing stream if any
23
- stopTerminalStream(client);
24
- let lastContent = "";
25
- const _lastLength = 0;
26
- // Fast polling for real-time feel (50ms = 20fps)
27
- const interval = setInterval(async () => {
28
- try {
29
- const content = await tmux.capturePane(window, pane, { lines: 500, session });
30
- // Only send if content changed - always send full content since terminal
31
- // output is not append-only (typing changes content in place)
32
- if (content !== lastContent) {
33
- client.ws.send(JSON.stringify({
34
- type: "terminal:content",
35
- data: { content, full: true },
36
- timestamp: Date.now(),
37
- }));
38
- lastContent = content;
39
- }
40
- }
41
- catch {
42
- // Pane might have closed
43
- }
44
- }, 50);
45
- terminalStreams.set(client, { window, pane, session, interval, lastContent });
46
- // Send initial content immediately
47
- try {
48
- console.log("[terminal] capturing initial content for", { window, pane });
49
- const content = await tmux.capturePane(window, pane, { lines: 500, session });
50
- console.log("[terminal] captured", content.length, "bytes, sending...");
51
- client.ws.send(JSON.stringify({
52
- type: "terminal:content",
53
- data: { content, full: true },
54
- timestamp: Date.now(),
55
- }));
56
- const stream = terminalStreams.get(client);
57
- if (stream) {
58
- stream.lastContent = content;
59
- }
60
- }
61
- catch (err) {
62
- console.error("[terminal] initial capture error:", err);
63
- }
64
- }
65
- /**
66
- * Stop terminal streaming for a client.
67
- */
68
- function stopTerminalStream(client) {
69
- const stream = terminalStreams.get(client);
70
- if (stream) {
71
- clearInterval(stream.interval);
72
- terminalStreams.delete(client);
73
- }
74
- }
75
- /**
76
- * Send input to terminal.
77
- */
78
- async function sendTerminalInput(client, keys) {
79
- const stream = terminalStreams.get(client);
80
- if (!stream) {
81
- console.warn("[terminal:input] No stream found for client - input dropped");
82
- return;
83
- }
84
- // Log what we're sending (escape special chars for visibility)
85
- const escaped = keys
86
- .replace(/\r/g, "\\r")
87
- .replace(/\n/g, "\\n")
88
- .replaceAll("\u007f", "\\x7f")
89
- .replaceAll("\u001b", "\\e");
90
- console.log("[terminal:input]", {
91
- window: stream.window,
92
- pane: stream.pane,
93
- keys: escaped,
94
- raw: keys,
95
- });
96
- try {
97
- // Don't use literal mode - we need tmux to interpret control sequences
98
- // xterm sends \r for Enter, \x7f for backspace, escape sequences for arrows
99
- await tmux.sendKeys(stream.window, stream.pane, keys, {
100
- session: stream.session,
101
- literal: false,
102
- });
103
- }
104
- catch (err) {
105
- console.error("[terminal:input] tmux.sendKeys error:", err);
106
- }
107
- }
108
- /**
109
- * Broadcast a message to all subscribed clients.
110
- */
111
- function broadcast(channel, data) {
112
- const message = JSON.stringify({ type: channel, data, timestamp: Date.now() });
113
- for (const client of clients) {
114
- if (client.subscriptions.has(channel) || client.subscriptions.has("*")) {
115
- try {
116
- client.ws.send(message);
117
- }
118
- catch {
119
- // Client disconnected
120
- clients.delete(client);
121
- }
122
- }
123
- }
124
- }
125
- /**
126
- * Create the Hono app with all routes.
127
- */
128
- function createApp(options) {
129
- const app = new Hono();
130
- // CORS for local development
131
- app.use("*", cors({
132
- origin: ["http://localhost:5173", "http://localhost:3847", "http://127.0.0.1:5173"],
133
- credentials: true,
134
- }));
135
- // Analytics middleware (tracks API calls to PostHog)
136
- app.use("/api/*", getAnalyticsMiddleware());
137
- // Analytics API routes
138
- const analyticsRoutes = createAnalyticsRoutes(Hono);
139
- if (analyticsRoutes) {
140
- app.route("/api/analytics", analyticsRoutes);
141
- }
142
- // Health check
143
- app.get("/api/health", (c) => c.json({ ok: true, timestamp: Date.now() }));
144
- // ============ SESSIONS API ============
145
- app.get("/api/sessions", (c) => {
146
- const manager = getActiveSessionManager();
147
- const status = c.req.query("status");
148
- const project = c.req.query("project");
149
- const limit = parseInt(c.req.query("limit") || "50", 10);
150
- let sessions = manager.list({ status, project });
151
- sessions = sessions.slice(0, limit);
152
- return c.json({ sessions, total: sessions.length });
153
- });
154
- app.get("/api/sessions/:id", (c) => {
155
- const id = c.req.param("id");
156
- const manager = getActiveSessionManager();
157
- const session = manager.get(id);
158
- if (!session) {
159
- return c.json({ error: "Session not found" }, 404);
160
- }
161
- return c.json({ session });
162
- });
163
- app.post("/api/sessions/:id/stop", async (c) => {
164
- const id = c.req.param("id");
165
- const body = await c.req.json().catch(() => ({}));
166
- const signal = body.force === true ? "SIGKILL" : "SIGTERM";
167
- const manager = getActiveSessionManager();
168
- const stopped = manager.stop(id, signal);
169
- if (!stopped) {
170
- return c.json({ error: "Failed to stop session" }, 500);
171
- }
172
- broadcast("sessions", { action: "stopped", session_id: id });
173
- return c.json({ success: true, session_id: id });
174
- });
175
- // ============ EVENTS API ============
176
- app.get("/api/events", (c) => {
177
- const eventsDir = join(homedir(), ".agents", "events");
178
- const limit = parseInt(c.req.query("limit") || "100", 10);
179
- if (!existsSync(eventsDir)) {
180
- return c.json({ events: [] });
181
- }
182
- const files = readdirSync(eventsDir)
183
- .filter((f) => f.endsWith(".json"))
184
- .sort()
185
- .reverse()
186
- .slice(0, limit);
187
- const events = files
188
- .map((f) => {
189
- try {
190
- const content = readFileSync(join(eventsDir, f), "utf-8");
191
- return JSON.parse(content);
192
- }
193
- catch {
194
- return null;
195
- }
196
- })
197
- .filter(Boolean);
198
- return c.json({ events });
199
- });
200
- // ============ TMUX API ============
201
- app.get("/api/tmux/status", async (c) => {
202
- const running = await tmux.isRunning();
203
- return c.json({ running });
204
- });
205
- app.get("/api/tmux/sessions", async (c) => {
206
- const sessions = await tmux.listSessions();
207
- return c.json({ sessions });
208
- });
209
- app.get("/api/tmux/windows", async (c) => {
210
- const session = c.req.query("session");
211
- const windows = await tmux.listWindows(session);
212
- return c.json({ windows });
213
- });
214
- app.get("/api/tmux/windows/:window/panes", async (c) => {
215
- const window = c.req.param("window");
216
- const session = c.req.query("session");
217
- const panes = await tmux.listPanes(window, session);
218
- return c.json({ panes });
219
- });
220
- app.get("/api/tmux/capture/:window", async (c) => {
221
- const window = c.req.param("window");
222
- const pane = parseInt(c.req.query("pane") || "0", 10);
223
- const lines = parseInt(c.req.query("lines") || "100", 10);
224
- const session = c.req.query("session");
225
- const content = await tmux.capturePane(window, pane, { lines, session });
226
- return c.json({ content, window, pane });
227
- });
228
- app.post("/api/tmux/send/:window", async (c) => {
229
- const window = c.req.param("window");
230
- const body = await c.req.json();
231
- const { pane = 0, keys, literal = false, session } = body;
232
- if (!keys) {
233
- return c.json({ error: "keys required" }, 400);
234
- }
235
- await tmux.sendKeys(window, pane, keys, { session, literal });
236
- return c.json({ success: true });
237
- });
238
- app.post("/api/tmux/select/:window", async (c) => {
239
- const window = c.req.param("window");
240
- const body = await c.req.json().catch(() => ({}));
241
- const { pane, session } = body;
242
- await tmux.selectWindow(window, session);
243
- if (pane !== undefined) {
244
- await tmux.selectPane(window, pane, session);
245
- }
246
- return c.json({ success: true });
247
- });
248
- app.post("/api/tmux/new-window", async (c) => {
249
- const body = await c.req.json();
250
- const { name, cwd, session, command } = body;
251
- await tmux.newWindow({ name, cwd, session, command });
252
- broadcast("tmux", { action: "window-created", name });
253
- return c.json({ success: true });
254
- });
255
- app.post("/api/tmux/windows/reorder", async (c) => {
256
- const body = await c.req.json().catch(() => ({}));
257
- const session = body.session;
258
- const order = Array.isArray(body.order) ? body.order : null;
259
- if (order && order.length > 0) {
260
- const windows = await tmux.listWindows(session);
261
- const currentIds = new Set(windows.map((w) => w.id));
262
- const indices = [...windows.map((w) => w.index)].sort((a, b) => a - b);
263
- const idToIndex = new Map();
264
- const indexToId = new Map();
265
- for (const w of windows) {
266
- idToIndex.set(w.id, w.index);
267
- indexToId.set(w.index, w.id);
268
- }
269
- // Keep only windows that exist in this session, and append any missing ones in current tmux order.
270
- const desired = order.filter((id) => currentIds.has(id));
271
- for (const w of windows) {
272
- if (!desired.includes(w.id))
273
- desired.push(w.id);
274
- }
275
- for (let i = 0; i < desired.length; i++) {
276
- const targetIndex = indices[i];
277
- const desiredId = desired[i];
278
- const currentIndex = idToIndex.get(desiredId);
279
- if (currentIndex === undefined)
280
- continue;
281
- if (currentIndex === targetIndex)
282
- continue;
283
- const displacedId = indexToId.get(targetIndex);
284
- await tmux.swapWindows(currentIndex, targetIndex, session);
285
- idToIndex.set(desiredId, targetIndex);
286
- indexToId.set(targetIndex, desiredId);
287
- if (displacedId !== undefined) {
288
- idToIndex.set(displacedId, currentIndex);
289
- indexToId.set(currentIndex, displacedId);
290
- }
291
- }
292
- return c.json({ success: true });
293
- }
294
- const windows = await tmux.listWindows(session);
295
- const sourceId = body.sourceId;
296
- const targetId = body.targetId;
297
- if (!sourceId || !targetId) {
298
- return c.json({ error: "order[] or sourceId+targetId required" }, 400);
299
- }
300
- const source = windows.find((w) => w.id === sourceId);
301
- const target = windows.find((w) => w.id === targetId);
302
- if (!source || !target) {
303
- return c.json({ error: "Window not found" }, 404);
304
- }
305
- await tmux.swapWindows(source.index, target.index, session);
306
- return c.json({ success: true });
307
- });
308
- // ============ PROMPTS API ============
309
- app.get("/api/prompts", (c) => {
310
- const promptsDir = join(homedir(), ".agents", "prompts");
311
- const limit = parseInt(c.req.query("limit") || "50", 10);
312
- if (!existsSync(promptsDir)) {
313
- return c.json({ prompts: [] });
314
- }
315
- const files = readdirSync(promptsDir)
316
- .filter((f) => f.endsWith(".md"))
317
- .map((f) => {
318
- const path = join(promptsDir, f);
319
- const stats = statSync(path);
320
- return {
321
- name: f,
322
- path,
323
- size: stats.size,
324
- modified: stats.mtime.toISOString(),
325
- };
326
- })
327
- .sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime())
328
- .slice(0, limit);
329
- return c.json({ prompts: files });
330
- });
331
- app.get("/api/prompts/:name", (c) => {
332
- const name = c.req.param("name");
333
- const promptsDir = join(homedir(), ".agents", "prompts");
334
- const path = join(promptsDir, name);
335
- if (!existsSync(path)) {
336
- return c.json({ error: "Prompt not found" }, 404);
337
- }
338
- const content = readFileSync(path, "utf-8");
339
- return c.json({ name, content });
340
- });
341
- // ============ PROJECTS API ============
342
- app.get("/api/projects", (c) => {
343
- const projectsPath = join(homedir(), ".agents", "projects.json");
344
- if (!existsSync(projectsPath)) {
345
- return c.json({ projects: {}, defaultEmoji: "📁" });
346
- }
347
- try {
348
- const content = readFileSync(projectsPath, "utf-8");
349
- const data = JSON.parse(content);
350
- return c.json(data);
351
- }
352
- catch {
353
- return c.json({ projects: {}, defaultEmoji: "📁" });
354
- }
355
- });
356
- // ============ PROJECT FILES API ============
357
- async function getProjectRoot(project) {
358
- const panes = await tmux.listPanes(project);
359
- const root = panes.find((p) => p.active)?.currentPath ?? panes[0]?.currentPath ?? null;
360
- return root ? resolvePath(root) : null;
361
- }
362
- function resolveWithinRoot(root, relativePath) {
363
- const normalizedRoot = resolvePath(root);
364
- const target = resolvePath(normalizedRoot, relativePath || ".");
365
- const inRoot = target === normalizedRoot || target.startsWith(normalizedRoot + pathSep);
366
- return inRoot ? target : null;
367
- }
368
- app.get("/api/projects/:project/root", async (c) => {
369
- const project = c.req.param("project");
370
- try {
371
- const root = await getProjectRoot(project);
372
- return c.json({ project, root });
373
- }
374
- catch {
375
- return c.json({ project, root: null });
376
- }
377
- });
378
- app.get("/api/projects/:project/fs", async (c) => {
379
- const project = c.req.param("project");
380
- const rel = c.req.query("path") || ".";
381
- const root = await getProjectRoot(project);
382
- if (!root) {
383
- return c.json({ error: "Project root not found" }, 404);
384
- }
385
- const absolute = resolveWithinRoot(root, rel);
386
- if (!absolute) {
387
- return c.json({ error: "Path outside project root" }, 400);
388
- }
389
- if (!existsSync(absolute)) {
390
- return c.json({ error: "Path not found" }, 404);
391
- }
392
- const stats = statSync(absolute);
393
- if (!stats.isDirectory()) {
394
- return c.json({ error: "Path is not a directory" }, 400);
395
- }
396
- const entries = readdirSync(absolute, { withFileTypes: true }).map((d) => {
397
- const entryPath = join(absolute, d.name);
398
- let entryStats = null;
399
- try {
400
- entryStats = statSync(entryPath);
401
- }
402
- catch {
403
- // ignore
404
- }
405
- return {
406
- name: d.name,
407
- type: d.isDirectory() ? "dir" : d.isSymbolicLink() ? "symlink" : "file",
408
- size: entryStats?.isFile() ? entryStats.size : null,
409
- modified: entryStats ? entryStats.mtime.toISOString() : null,
410
- };
411
- });
412
- // Directories first, then alpha
413
- entries.sort((a, b) => {
414
- if (a.type === b.type)
415
- return a.name.localeCompare(b.name);
416
- if (a.type === "dir")
417
- return -1;
418
- if (b.type === "dir")
419
- return 1;
420
- return a.type.localeCompare(b.type);
421
- });
422
- return c.json({ project, root, path: rel, entries });
423
- });
424
- app.get("/api/projects/:project/file", async (c) => {
425
- const project = c.req.param("project");
426
- const rel = c.req.query("path");
427
- if (!rel)
428
- return c.json({ error: "path required" }, 400);
429
- const root = await getProjectRoot(project);
430
- if (!root) {
431
- return c.json({ error: "Project root not found" }, 404);
432
- }
433
- const absolute = resolveWithinRoot(root, rel);
434
- if (!absolute) {
435
- return c.json({ error: "Path outside project root" }, 400);
436
- }
437
- if (!existsSync(absolute)) {
438
- return c.json({ error: "File not found" }, 404);
439
- }
440
- const stats = statSync(absolute);
441
- if (!stats.isFile()) {
442
- return c.json({ error: "Path is not a file" }, 400);
443
- }
444
- // Avoid sending huge files to the browser
445
- const MAX_BYTES = 500_000;
446
- if (stats.size > MAX_BYTES) {
447
- return c.json({ error: "File too large" }, 413);
448
- }
449
- const content = readFileSync(absolute, "utf-8");
450
- return c.json({ project, root, path: rel, content });
451
- });
452
- // ============ PLANS API ============
453
- app.get("/api/projects/:project/plans", (c) => {
454
- const project = c.req.param("project");
455
- const plansRoot = join(homedir(), ".agents", "plans", project);
456
- if (!existsSync(plansRoot)) {
457
- return c.json({ project, plans: [] });
458
- }
459
- const plans = readdirSync(plansRoot, { withFileTypes: true })
460
- .filter((d) => d.isDirectory())
461
- .map((d) => {
462
- const slug = d.name;
463
- const planPath = join(plansRoot, slug, "plan.md");
464
- const hasPlan = existsSync(planPath);
465
- let title = slug;
466
- let modified = null;
467
- if (hasPlan) {
468
- try {
469
- const content = readFileSync(planPath, "utf-8");
470
- const firstLine = content.split("\n")[0] ?? "";
471
- title = firstLine.replace(/^#\s*/, "").trim() || slug;
472
- modified = statSync(planPath).mtime.toISOString();
473
- }
474
- catch {
475
- // ignore
476
- }
477
- }
478
- return {
479
- slug,
480
- title,
481
- hasPlan,
482
- modified,
483
- };
484
- })
485
- .sort((a, b) => (b.modified ?? "").localeCompare(a.modified ?? ""));
486
- return c.json({ project, plans });
487
- });
488
- app.get("/api/projects/:project/plans/:slug", (c) => {
489
- const project = c.req.param("project");
490
- const slug = c.req.param("slug");
491
- const planPath = join(homedir(), ".agents", "plans", project, slug, "plan.md");
492
- if (!existsSync(planPath)) {
493
- return c.json({ error: "Plan not found" }, 404);
494
- }
495
- const content = readFileSync(planPath, "utf-8");
496
- return c.json({ project, slug, content });
497
- });
498
- // ============ STATE API ============
499
- app.get("/api/state", async (c) => {
500
- const manager = getActiveSessionManager();
501
- const sessions = manager.list();
502
- const tmuxRunning = await tmux.isRunning();
503
- const tmuxWindows = tmuxRunning ? await tmux.listWindows() : [];
504
- return c.json({
505
- sessions: {
506
- total: sessions.length,
507
- running: sessions.filter((s) => s.status === "running").length,
508
- items: sessions,
509
- },
510
- tmux: {
511
- running: tmuxRunning,
512
- windows: tmuxWindows,
513
- },
514
- timestamp: Date.now(),
515
- });
516
- });
517
- // ============ STATIC FILES ============
518
- if (options.staticDir && existsSync(options.staticDir)) {
519
- app.use("/*", serveStatic({ root: options.staticDir }));
520
- }
521
- // Fallback for SPA routing
522
- app.get("*", (c) => {
523
- if (options.staticDir) {
524
- const indexPath = join(options.staticDir, "index.html");
525
- if (existsSync(indexPath)) {
526
- const html = readFileSync(indexPath, "utf-8");
527
- return c.html(html);
528
- }
529
- }
530
- return c.json({ error: "Not found" }, 404);
531
- });
532
- return app;
533
- }
534
- /**
535
- * Create and start the web server.
536
- */
537
- export async function createServer(options) {
538
- // Initialize web analytics (non-blocking, no-op if unavailable)
539
- await initWebAnalytics();
540
- const app = createApp(options);
541
- const server = Bun.serve({
542
- port: options.port,
543
- hostname: options.host,
544
- fetch(req, server) {
545
- const url = new URL(req.url);
546
- // Handle WebSocket upgrade
547
- if (url.pathname === "/ws") {
548
- const upgraded = server.upgrade(req);
549
- if (upgraded) {
550
- return undefined;
551
- }
552
- return new Response("WebSocket upgrade failed", { status: 400 });
553
- }
554
- // Handle regular HTTP requests with Hono
555
- return app.fetch(req);
556
- },
557
- websocket: {
558
- open(ws) {
559
- const client = { ws, subscriptions: new Set(["*"]) };
560
- clients.add(client);
561
- ws.send(JSON.stringify({ type: "connected", timestamp: Date.now() }));
562
- },
563
- message(ws, message) {
564
- const client = Array.from(clients).find((c) => c.ws === ws);
565
- if (!client)
566
- return;
567
- try {
568
- const data = JSON.parse(message.toString());
569
- if (data.type === "subscribe") {
570
- client.subscriptions.add(data.channel);
571
- ws.send(JSON.stringify({ type: "subscribed", channel: data.channel }));
572
- }
573
- else if (data.type === "unsubscribe") {
574
- client.subscriptions.delete(data.channel);
575
- ws.send(JSON.stringify({ type: "unsubscribed", channel: data.channel }));
576
- }
577
- else if (data.type === "ping") {
578
- ws.send(JSON.stringify({ type: "pong", timestamp: Date.now() }));
579
- }
580
- else if (data.type === "terminal:subscribe") {
581
- // Start streaming terminal output
582
- const { window, pane = 0, session } = data;
583
- console.log("[ws] terminal:subscribe received", { window, pane, session });
584
- startTerminalStream(client, window, pane, session);
585
- // Get current pane dimensions so client can sync
586
- tmux
587
- .getPaneDimensions(window, pane, session)
588
- .then((dims) => {
589
- console.log("[ws] sending terminal:subscribed with dims:", dims);
590
- ws.send(JSON.stringify({ type: "terminal:subscribed", window, pane, dims }));
591
- })
592
- .catch(() => {
593
- console.log("[ws] sending terminal:subscribed (no dims)");
594
- ws.send(JSON.stringify({ type: "terminal:subscribed", window, pane }));
595
- });
596
- }
597
- else if (data.type === "terminal:unsubscribe") {
598
- stopTerminalStream(client);
599
- ws.send(JSON.stringify({ type: "terminal:unsubscribed" }));
600
- }
601
- else if (data.type === "terminal:input") {
602
- // Send input to terminal
603
- console.log("[ws] terminal:input received, keys length:", data.keys?.length);
604
- sendTerminalInput(client, data.keys);
605
- }
606
- else if (data.type === "terminal:resize") {
607
- // Resize the tmux pane to match xterm dimensions
608
- const stream = terminalStreams.get(client);
609
- if (stream && data.cols && data.rows) {
610
- tmux
611
- .resizePane(stream.window, stream.pane, data.cols, data.rows, stream.session)
612
- .then(async () => {
613
- console.log("[terminal:resize]", {
614
- window: stream.window,
615
- pane: stream.pane,
616
- cols: data.cols,
617
- rows: data.rows,
618
- });
619
- // After resize, wait briefly for tmux to redraw then force a fresh capture
620
- // This ensures content is formatted for the new dimensions
621
- await new Promise((resolve) => setTimeout(resolve, 50));
622
- try {
623
- const content = await tmux.capturePane(stream.window, stream.pane, {
624
- lines: 500,
625
- session: stream.session,
626
- });
627
- stream.lastContent = content;
628
- ws.send(JSON.stringify({
629
- type: "terminal:content",
630
- data: { content, full: true },
631
- timestamp: Date.now(),
632
- }));
633
- }
634
- catch (captureErr) {
635
- console.error("[terminal:resize] post-resize capture error:", captureErr);
636
- }
637
- })
638
- .catch((err) => {
639
- console.error("[terminal:resize] error:", err);
640
- });
641
- }
642
- ws.send(JSON.stringify({ type: "terminal:resized", cols: data.cols, rows: data.rows }));
643
- }
644
- }
645
- catch (err) {
646
- console.error("[ws] message handler error:", err);
647
- }
648
- },
649
- close(ws) {
650
- const client = Array.from(clients).find((c) => c.ws === ws);
651
- if (client) {
652
- stopTerminalStream(client);
653
- clients.delete(client);
654
- }
655
- },
656
- },
657
- });
658
- // Start session watcher for real-time updates
659
- startSessionWatcher();
660
- return server;
661
- }
662
- /**
663
- * Watch for session changes and broadcast updates.
664
- */
665
- function startSessionWatcher() {
666
- const manager = getActiveSessionManager();
667
- let lastSessions = new Map();
668
- // Initialize
669
- for (const session of manager.list()) {
670
- lastSessions.set(session.session_id, session);
671
- }
672
- // Poll every 2 seconds
673
- setInterval(() => {
674
- const currentSessions = manager.list();
675
- const currentMap = new Map();
676
- for (const session of currentSessions) {
677
- currentMap.set(session.session_id, session);
678
- const prev = lastSessions.get(session.session_id);
679
- if (!prev) {
680
- // New session
681
- broadcast("sessions", { action: "created", session });
682
- }
683
- else if (prev.status !== session.status) {
684
- // Status changed
685
- broadcast("sessions", { action: "updated", session });
686
- }
687
- }
688
- // Check for removed sessions
689
- for (const [id] of lastSessions) {
690
- if (!currentMap.has(id)) {
691
- broadcast("sessions", { action: "removed", session_id: id });
692
- }
693
- }
694
- lastSessions = currentMap;
695
- }, 2000);
696
- }
697
- export { broadcast };