@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.
- package/bin/dev.js +1 -49
- package/bin/run.js +42 -49
- package/dist/cli/commands/login.js +26 -0
- package/dist/cli/commands/project/add.d.ts +0 -1
- package/dist/cli/commands/project/add.js +16 -52
- package/dist/cli/commands/project/list.js +13 -93
- package/dist/cli/commands/project/remove.d.ts +0 -2
- package/dist/cli/commands/project/remove.js +11 -28
- package/dist/cli/commands/session/list.js +3 -12
- package/dist/cli/commands/session/mark-done.js +1 -7
- package/dist/cli/commands/session/start.d.ts +0 -1
- package/dist/cli/commands/session/start.js +5 -7
- package/dist/lib/active-sessions.d.ts +0 -12
- package/dist/lib/active-sessions.js +6 -175
- package/dist/lib/project-path.d.ts +6 -0
- package/dist/lib/project-path.js +21 -0
- package/dist/lib.d.ts +1 -2
- package/dist/lib.js +2 -4
- package/oclif.manifest.json +2569 -6368
- package/package.json +1 -1
- package/dist/cli/commands/backlog/add.d.ts +0 -22
- package/dist/cli/commands/backlog/add.js +0 -65
- package/dist/cli/commands/backlog/claim.d.ts +0 -19
- package/dist/cli/commands/backlog/claim.js +0 -45
- package/dist/cli/commands/backlog/complete.d.ts +0 -18
- package/dist/cli/commands/backlog/complete.js +0 -42
- package/dist/cli/commands/backlog/list.d.ts +0 -20
- package/dist/cli/commands/backlog/list.js +0 -91
- package/dist/cli/commands/backlog/pick.d.ts +0 -18
- package/dist/cli/commands/backlog/pick.js +0 -42
- package/dist/cli/commands/backlog/sync.d.ts +0 -24
- package/dist/cli/commands/backlog/sync.js +0 -109
- package/dist/cli/commands/daemon.d.ts +0 -56
- package/dist/cli/commands/daemon.js +0 -1465
- package/dist/cli/commands/docs/lint.d.ts +0 -18
- package/dist/cli/commands/docs/lint.js +0 -82
- package/dist/cli/commands/docs/sync.d.ts +0 -19
- package/dist/cli/commands/docs/sync.js +0 -76
- package/dist/cli/commands/gc.d.ts +0 -29
- package/dist/cli/commands/gc.js +0 -211
- package/dist/cli/commands/index.d.ts +0 -36
- package/dist/cli/commands/index.js +0 -228
- package/dist/cli/commands/panes/broker.d.ts +0 -17
- package/dist/cli/commands/panes/broker.js +0 -57
- package/dist/cli/commands/panes/pipe-sink.d.ts +0 -17
- package/dist/cli/commands/panes/pipe-sink.js +0 -90
- package/dist/cli/commands/panes/snapshot.d.ts +0 -20
- package/dist/cli/commands/panes/snapshot.js +0 -125
- package/dist/cli/commands/preview/init.d.ts +0 -25
- package/dist/cli/commands/preview/init.js +0 -159
- package/dist/cli/commands/preview/sync.d.ts +0 -23
- package/dist/cli/commands/preview/sync.js +0 -144
- package/dist/cli/commands/preview/watch.d.ts +0 -24
- package/dist/cli/commands/preview/watch.js +0 -153
- package/dist/cli/commands/resource/acquire.d.ts +0 -21
- package/dist/cli/commands/resource/acquire.js +0 -90
- package/dist/cli/commands/resource/list.d.ts +0 -15
- package/dist/cli/commands/resource/list.js +0 -61
- package/dist/cli/commands/resource/release.d.ts +0 -18
- package/dist/cli/commands/resource/release.js +0 -50
- package/dist/cli/commands/resource/wait.d.ts +0 -21
- package/dist/cli/commands/resource/wait.js +0 -73
- package/dist/cli/commands/session/view.d.ts +0 -24
- package/dist/cli/commands/session/view.js +0 -145
- package/dist/cli/commands/start.d.ts +0 -37
- package/dist/cli/commands/start.js +0 -234
- package/dist/cli/commands/triage/claim.d.ts +0 -23
- package/dist/cli/commands/triage/claim.js +0 -186
- package/dist/cli/commands/triage/list.d.ts +0 -22
- package/dist/cli/commands/triage/list.js +0 -112
- package/dist/cli/commands/triage/next.d.ts +0 -18
- package/dist/cli/commands/triage/next.js +0 -63
- package/dist/cli/commands/triage/pull.d.ts +0 -19
- package/dist/cli/commands/triage/pull.js +0 -82
- package/dist/cli/commands/triage/stats.d.ts +0 -16
- package/dist/cli/commands/triage/stats.js +0 -69
- package/dist/cli/commands/tunnel/list.d.ts +0 -16
- package/dist/cli/commands/tunnel/list.js +0 -98
- package/dist/cli/commands/tunnel/start.d.ts +0 -24
- package/dist/cli/commands/tunnel/start.js +0 -107
- package/dist/cli/commands/tunnel/stop.d.ts +0 -20
- package/dist/cli/commands/tunnel/stop.js +0 -90
- package/dist/cli/commands/tunnel/url.d.ts +0 -21
- package/dist/cli/commands/tunnel/url.js +0 -70
- package/dist/cli/commands/windows/context.d.ts +0 -18
- package/dist/cli/commands/windows/context.js +0 -326
- package/dist/cli/commands/windows/focus.d.ts +0 -17
- package/dist/cli/commands/windows/focus.js +0 -103
- package/dist/cli/commands/windows/list.d.ts +0 -21
- package/dist/cli/commands/windows/list.js +0 -172
- package/dist/cli/commands/windows/map.d.ts +0 -17
- package/dist/cli/commands/windows/map.js +0 -168
- package/dist/cli/commands/windows/read.d.ts +0 -21
- package/dist/cli/commands/windows/read.js +0 -241
- package/dist/cli/commands/windows/search.d.ts +0 -24
- package/dist/cli/commands/windows/search.js +0 -171
- package/dist/cli/commands/windows/show.d.ts +0 -19
- package/dist/cli/commands/windows/show.js +0 -165
- package/dist/cli/commands/windows/watch.d.ts +0 -19
- package/dist/cli/commands/windows/watch.js +0 -241
- package/dist/lib/managed-session.d.ts +0 -27
- package/dist/lib/managed-session.js +0 -105
- package/dist/lib/panes/broker.d.ts +0 -130
- package/dist/lib/panes/broker.js +0 -97
- package/dist/lib/panes/index.d.ts +0 -2
- package/dist/lib/panes/index.js +0 -1
- package/dist/lib/panes/server.d.ts +0 -17
- package/dist/lib/panes/server.js +0 -308
- package/dist/lib/preview/manager.d.ts +0 -77
- package/dist/lib/preview/manager.js +0 -369
- package/dist/lib/preview/schema.d.ts +0 -2
- package/dist/lib/preview/schema.js +0 -32
- package/dist/lib/preview/sprite.d.ts +0 -85
- package/dist/lib/preview/sprite.js +0 -321
- package/dist/lib/preview/watcher.d.ts +0 -63
- package/dist/lib/preview/watcher.js +0 -185
- package/dist/lib/project-identity.d.ts +0 -16
- package/dist/lib/project-identity.js +0 -75
- package/dist/lib/tmux/bridge.d.ts +0 -133
- package/dist/lib/tmux/bridge.js +0 -315
- package/dist/lib/tmux/context.d.ts +0 -82
- package/dist/lib/tmux/context.js +0 -239
- package/dist/lib/tmux/index.d.ts +0 -8
- package/dist/lib/tmux/index.js +0 -11
- package/dist/lib/tmux/map.d.ts +0 -57
- package/dist/lib/tmux/map.js +0 -198
- package/dist/lib/tmux/panes.d.ts +0 -27
- package/dist/lib/tmux/panes.js +0 -151
- package/dist/lib/tmux/redaction.d.ts +0 -57
- package/dist/lib/tmux/redaction.js +0 -152
- package/dist/lib/web/analytics.d.ts +0 -63
- package/dist/lib/web/analytics.js +0 -168
- package/dist/lib/web/server.d.ts +0 -26
- package/dist/lib/web/server.js +0 -697
- package/dist/lib/web/tmux-bridge.d.ts +0 -7
- package/dist/lib/web/tmux-bridge.js +0 -7
- package/dist/lib/windows/index.d.ts +0 -3
- package/dist/lib/windows/index.js +0 -2
- package/dist/lib/windows/inventory.d.ts +0 -21
- package/dist/lib/windows/inventory.js +0 -263
- package/dist/lib/windows/types.d.ts +0 -46
- package/dist/lib/windows/types.js +0 -1
package/dist/lib/web/server.js
DELETED
|
@@ -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 };
|