wmdev 0.2.1 → 0.2.2
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/README.md +6 -17
- package/backend/dist/server.js +8657 -0
- package/bin/wmdev.js +1 -1
- package/package.json +4 -5
- package/backend/src/config.ts +0 -103
- package/backend/src/docker.ts +0 -432
- package/backend/src/env.ts +0 -95
- package/backend/src/http.ts +0 -10
- package/backend/src/lib/log.ts +0 -14
- package/backend/src/pr.ts +0 -333
- package/backend/src/rpc-secret.ts +0 -21
- package/backend/src/rpc.ts +0 -96
- package/backend/src/server.ts +0 -471
- package/backend/src/terminal.ts +0 -310
- package/backend/src/workmux.ts +0 -474
- package/backend/tsconfig.json +0 -15
package/backend/src/server.ts
DELETED
|
@@ -1,471 +0,0 @@
|
|
|
1
|
-
import { join, resolve } from "node:path";
|
|
2
|
-
import { networkInterfaces } from "node:os";
|
|
3
|
-
import { log } from "./lib/log";
|
|
4
|
-
import {
|
|
5
|
-
listWorktrees,
|
|
6
|
-
getStatus,
|
|
7
|
-
addWorktree,
|
|
8
|
-
removeWorktree,
|
|
9
|
-
openWorktree,
|
|
10
|
-
mergeWorktree,
|
|
11
|
-
sendPrompt,
|
|
12
|
-
readEnvLocal,
|
|
13
|
-
parseWorktreePorcelain,
|
|
14
|
-
} from "./workmux";
|
|
15
|
-
import {
|
|
16
|
-
attach,
|
|
17
|
-
detach,
|
|
18
|
-
write,
|
|
19
|
-
resize,
|
|
20
|
-
selectPane,
|
|
21
|
-
getScrollback,
|
|
22
|
-
setCallbacks,
|
|
23
|
-
clearCallbacks,
|
|
24
|
-
cleanupStaleSessions,
|
|
25
|
-
} from "./terminal";
|
|
26
|
-
import { loadConfig, gitRoot, type WmdevConfig } from "./config";
|
|
27
|
-
import { startPrMonitor, type PrEntry } from "./pr";
|
|
28
|
-
import { handleWorkmuxRpc } from "./rpc";
|
|
29
|
-
import { jsonResponse, errorResponse } from "./http";
|
|
30
|
-
|
|
31
|
-
const PORT = parseInt(Bun.env.DASHBOARD_PORT || "5111", 10);
|
|
32
|
-
const STATIC_DIR = Bun.env.WMDEV_STATIC_DIR || "";
|
|
33
|
-
const PROJECT_DIR = Bun.env.WMDEV_PROJECT_DIR || gitRoot(process.cwd());
|
|
34
|
-
const config: WmdevConfig = loadConfig(PROJECT_DIR);
|
|
35
|
-
|
|
36
|
-
// --- WebSocket protocol types ---
|
|
37
|
-
|
|
38
|
-
interface WsData {
|
|
39
|
-
worktree: string;
|
|
40
|
-
attached: boolean;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
type WsInboundMessage =
|
|
44
|
-
| { type: "input"; data: string }
|
|
45
|
-
| { type: "selectPane"; pane: number }
|
|
46
|
-
| { type: "resize"; cols: number; rows: number; initialPane?: number };
|
|
47
|
-
|
|
48
|
-
type WsOutboundMessage =
|
|
49
|
-
| { type: "output"; data: string }
|
|
50
|
-
| { type: "exit"; exitCode: number }
|
|
51
|
-
| { type: "error"; message: string }
|
|
52
|
-
| { type: "scrollback"; data: string };
|
|
53
|
-
|
|
54
|
-
function parseWsMessage(raw: string | Buffer): WsInboundMessage | null {
|
|
55
|
-
try {
|
|
56
|
-
const str = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
|
57
|
-
const msg: unknown = JSON.parse(str);
|
|
58
|
-
if (!msg || typeof msg !== "object") return null;
|
|
59
|
-
const m = msg as Record<string, unknown>;
|
|
60
|
-
switch (m.type) {
|
|
61
|
-
case "input":
|
|
62
|
-
return typeof m.data === "string" ? { type: "input", data: m.data } : null;
|
|
63
|
-
case "selectPane":
|
|
64
|
-
return typeof m.pane === "number" ? { type: "selectPane", pane: m.pane } : null;
|
|
65
|
-
case "resize":
|
|
66
|
-
return typeof m.cols === "number" && typeof m.rows === "number"
|
|
67
|
-
? {
|
|
68
|
-
type: "resize",
|
|
69
|
-
cols: m.cols,
|
|
70
|
-
rows: m.rows,
|
|
71
|
-
initialPane: typeof m.initialPane === "number" ? m.initialPane : undefined,
|
|
72
|
-
}
|
|
73
|
-
: null;
|
|
74
|
-
default:
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
} catch {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// --- HTTP helpers ---
|
|
83
|
-
|
|
84
|
-
function sendWs(ws: { send: (data: string) => void }, msg: WsOutboundMessage): void {
|
|
85
|
-
ws.send(JSON.stringify(msg));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function isValidWorktreeName(name: string): boolean {
|
|
89
|
-
return name.length > 0 && /^[a-z0-9][a-z0-9\-_./]*$/.test(name) && !name.includes("..");
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Wrap an async API handler to catch and log unhandled errors. */
|
|
93
|
-
function catching(label: string, fn: () => Promise<Response>): Promise<Response> {
|
|
94
|
-
return fn().catch((err: unknown) => {
|
|
95
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
96
|
-
log.error(`[api:error] ${label}: ${msg}`);
|
|
97
|
-
return errorResponse(msg);
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function safeJsonParse<T>(str: string): T | null {
|
|
102
|
-
try {
|
|
103
|
-
return JSON.parse(str) as T;
|
|
104
|
-
} catch {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// --- Process helpers ---
|
|
110
|
-
|
|
111
|
-
/** Map branch name → worktree directory using git worktree list.
|
|
112
|
-
* Skips the main working tree (always the first entry). */
|
|
113
|
-
async function getWorktreePaths(): Promise<Map<string, string>> {
|
|
114
|
-
const proc = Bun.spawn(["git", "worktree", "list", "--porcelain"], { stdout: "pipe" });
|
|
115
|
-
await proc.exited;
|
|
116
|
-
const output = await new Response(proc.stdout).text();
|
|
117
|
-
const all = parseWorktreePorcelain(output);
|
|
118
|
-
const paths = new Map<string, string>();
|
|
119
|
-
let isFirst = true;
|
|
120
|
-
for (const [branch, path] of all) {
|
|
121
|
-
// Skip the main working tree (first entry in porcelain output)
|
|
122
|
-
if (isFirst) { isFirst = false; continue; }
|
|
123
|
-
paths.set(branch, path);
|
|
124
|
-
// Also map by directory basename (workmux uses basename as branch key)
|
|
125
|
-
const basename = path.split("/").pop() ?? "";
|
|
126
|
-
if (basename !== branch) paths.set(basename, path);
|
|
127
|
-
}
|
|
128
|
-
return paths;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Count tmux panes for a worktree window. */
|
|
132
|
-
async function getTmuxPaneCount(branch: string): Promise<number> {
|
|
133
|
-
const proc = Bun.spawn(
|
|
134
|
-
["tmux", "list-panes", "-t", `wm-${branch}`, "-F", "#{pane_index}"],
|
|
135
|
-
{ stdout: "pipe", stderr: "pipe" }
|
|
136
|
-
);
|
|
137
|
-
const exitCode = await proc.exited;
|
|
138
|
-
if (exitCode !== 0) return 0;
|
|
139
|
-
const out = await new Response(proc.stdout).text();
|
|
140
|
-
return out.trim().split("\n").filter(Boolean).length;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Check if a port has a service responding (not just a TCP handshake). */
|
|
144
|
-
function isPortListening(port: number): Promise<boolean> {
|
|
145
|
-
return new Promise((resolve) => {
|
|
146
|
-
const timeout = setTimeout(() => { resolve(false); }, 1000);
|
|
147
|
-
fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1000) })
|
|
148
|
-
.then(() => { clearTimeout(timeout); resolve(true); })
|
|
149
|
-
.catch(() => { clearTimeout(timeout); resolve(false); });
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function makeCallbacks(ws: { send: (data: string) => void; readyState: number }): {
|
|
154
|
-
onData: (data: string) => void;
|
|
155
|
-
onExit: (exitCode: number) => void;
|
|
156
|
-
} {
|
|
157
|
-
return {
|
|
158
|
-
onData: (data: string) => {
|
|
159
|
-
if (ws.readyState <= 1) sendWs(ws, { type: "output", data });
|
|
160
|
-
},
|
|
161
|
-
onExit: (exitCode: number) => {
|
|
162
|
-
if (ws.readyState <= 1) sendWs(ws, { type: "exit", exitCode });
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// --- API handler functions (thin I/O layer, testable by injecting deps) ---
|
|
168
|
-
|
|
169
|
-
async function apiGetWorktrees(): Promise<Response> {
|
|
170
|
-
const [worktrees, status, wtPaths] = await Promise.all([
|
|
171
|
-
listWorktrees(),
|
|
172
|
-
getStatus(),
|
|
173
|
-
getWorktreePaths(),
|
|
174
|
-
]);
|
|
175
|
-
const merged = await Promise.all(worktrees.map(async (wt) => {
|
|
176
|
-
const st = status.find(s =>
|
|
177
|
-
s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch)
|
|
178
|
-
);
|
|
179
|
-
const wtDir = wtPaths.get(wt.branch);
|
|
180
|
-
const env = wtDir ? await readEnvLocal(wtDir) : {};
|
|
181
|
-
const services = await Promise.all(
|
|
182
|
-
config.services.map(async (svc) => {
|
|
183
|
-
const port = env[svc.portEnv] ? parseInt(env[svc.portEnv], 10) : null;
|
|
184
|
-
const running = port !== null && port >= 1 && port <= 65535
|
|
185
|
-
? await isPortListening(port)
|
|
186
|
-
: false;
|
|
187
|
-
return { name: svc.name, port, running };
|
|
188
|
-
})
|
|
189
|
-
);
|
|
190
|
-
return {
|
|
191
|
-
...wt,
|
|
192
|
-
dir: wtDir ?? null,
|
|
193
|
-
status: st?.status ?? "",
|
|
194
|
-
elapsed: st?.elapsed ?? "",
|
|
195
|
-
title: st?.title ?? "",
|
|
196
|
-
profile: env.PROFILE || null,
|
|
197
|
-
agentName: env.AGENT || null,
|
|
198
|
-
services,
|
|
199
|
-
paneCount: wt.mux === "✓" ? await getTmuxPaneCount(wt.branch) : 0,
|
|
200
|
-
prs: env.PR_DATA ? (safeJsonParse<PrEntry[]>(env.PR_DATA) ?? []).map(pr => ({ ...pr, comments: pr.comments ?? [] })) : [],
|
|
201
|
-
};
|
|
202
|
-
}));
|
|
203
|
-
return jsonResponse(merged);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function apiCreateWorktree(req: Request): Promise<Response> {
|
|
207
|
-
const raw: unknown = await req.json();
|
|
208
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
209
|
-
return errorResponse("Invalid request body", 400);
|
|
210
|
-
}
|
|
211
|
-
const body = raw as Record<string, unknown>;
|
|
212
|
-
const branch = typeof body.branch === "string" ? body.branch : undefined;
|
|
213
|
-
const prompt = typeof body.prompt === "string" ? body.prompt : undefined;
|
|
214
|
-
const profileName = typeof body.profile === "string" ? body.profile : config.profiles.default.name;
|
|
215
|
-
const agent = typeof body.agent === "string" ? body.agent : "claude";
|
|
216
|
-
const isSandbox = config.profiles.sandbox !== undefined && profileName === config.profiles.sandbox.name;
|
|
217
|
-
const profileConfig = isSandbox ? config.profiles.sandbox! : config.profiles.default;
|
|
218
|
-
log.info(`[worktree:add] agent=${agent} profile=${profileName}${branch ? ` branch=${branch}` : ""}${prompt ? ` prompt="${prompt.slice(0, 80)}"` : ""}`);
|
|
219
|
-
const result = await addWorktree(branch, {
|
|
220
|
-
prompt,
|
|
221
|
-
profile: profileName,
|
|
222
|
-
agent,
|
|
223
|
-
autoName: config.autoName,
|
|
224
|
-
profileConfig,
|
|
225
|
-
isSandbox,
|
|
226
|
-
sandboxConfig: isSandbox ? config.profiles.sandbox : undefined,
|
|
227
|
-
services: config.services,
|
|
228
|
-
mainRepoDir: PROJECT_DIR,
|
|
229
|
-
});
|
|
230
|
-
if (!result.ok) return errorResponse(result.error, 422);
|
|
231
|
-
log.debug(`[worktree:add] done branch=${result.branch}: ${result.output}`);
|
|
232
|
-
return jsonResponse({ branch: result.branch }, 201);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
async function apiDeleteWorktree(name: string): Promise<Response> {
|
|
236
|
-
log.info(`[worktree:rm] name=${name}`);
|
|
237
|
-
const result = await removeWorktree(name);
|
|
238
|
-
if (!result.ok) return errorResponse(result.error, 422);
|
|
239
|
-
log.debug(`[worktree:rm] done name=${name}: ${result.output}`);
|
|
240
|
-
return jsonResponse({ message: result.output });
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async function apiOpenWorktree(name: string): Promise<Response> {
|
|
244
|
-
log.info(`[worktree:open] name=${name}`);
|
|
245
|
-
const result = await openWorktree(name);
|
|
246
|
-
if (!result.ok) return errorResponse(result.error, 422);
|
|
247
|
-
return jsonResponse({ message: result.output });
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
async function apiSendPrompt(name: string, req: Request): Promise<Response> {
|
|
251
|
-
const raw: unknown = await req.json();
|
|
252
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
253
|
-
return errorResponse("Invalid request body", 400);
|
|
254
|
-
}
|
|
255
|
-
const body = raw as Record<string, unknown>;
|
|
256
|
-
const text = typeof body.text === "string" ? body.text : "";
|
|
257
|
-
if (!text) return errorResponse("Missing 'text' field", 400);
|
|
258
|
-
const preamble = typeof body.preamble === "string" ? body.preamble : undefined;
|
|
259
|
-
log.info(`[worktree:send] name=${name} text="${text.slice(0, 80)}"`);
|
|
260
|
-
const result = await sendPrompt(name, text, 0, preamble);
|
|
261
|
-
if (!result.ok) return errorResponse(result.error, 503);
|
|
262
|
-
return jsonResponse({ ok: true });
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
async function apiMergeWorktree(name: string): Promise<Response> {
|
|
266
|
-
log.info(`[worktree:merge] name=${name}`);
|
|
267
|
-
const result = await mergeWorktree(name);
|
|
268
|
-
if (!result.ok) return errorResponse(result.error, 422);
|
|
269
|
-
log.debug(`[worktree:merge] done name=${name}: ${result.output}`);
|
|
270
|
-
return jsonResponse({ message: result.output });
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
async function apiWorktreeStatus(name: string): Promise<Response> {
|
|
274
|
-
const statuses = await getStatus();
|
|
275
|
-
const match = statuses.find(s => s.worktree.includes(name));
|
|
276
|
-
if (!match) return errorResponse("Worktree status not found", 404);
|
|
277
|
-
return jsonResponse(match);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
async function apiCiLogs(runId: string): Promise<Response> {
|
|
281
|
-
if (!/^\d+$/.test(runId)) return errorResponse("Invalid run ID", 400);
|
|
282
|
-
const proc = Bun.spawn(["gh", "run", "view", runId, "--log-failed"], {
|
|
283
|
-
stdout: "pipe",
|
|
284
|
-
stderr: "pipe",
|
|
285
|
-
});
|
|
286
|
-
const exitCode = await proc.exited;
|
|
287
|
-
if (exitCode === 0) {
|
|
288
|
-
const logs = await new Response(proc.stdout).text();
|
|
289
|
-
return jsonResponse({ logs });
|
|
290
|
-
}
|
|
291
|
-
const stderr = (await new Response(proc.stderr).text()).trim();
|
|
292
|
-
return errorResponse(`Failed to fetch logs: ${stderr || "unknown error"}`, 502);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// --- Server ---
|
|
296
|
-
|
|
297
|
-
Bun.serve({
|
|
298
|
-
port: PORT,
|
|
299
|
-
idleTimeout: 255, // seconds; worktree removal can take >10s
|
|
300
|
-
|
|
301
|
-
routes: {
|
|
302
|
-
"/ws/:worktree": (req, server) => {
|
|
303
|
-
const worktree = decodeURIComponent(req.params.worktree);
|
|
304
|
-
return server.upgrade(req, { data: { worktree, attached: false } })
|
|
305
|
-
? undefined
|
|
306
|
-
: new Response("WebSocket upgrade failed", { status: 400 });
|
|
307
|
-
},
|
|
308
|
-
|
|
309
|
-
"/rpc/workmux": {
|
|
310
|
-
POST: (req) => handleWorkmuxRpc(req),
|
|
311
|
-
},
|
|
312
|
-
|
|
313
|
-
"/api/config": {
|
|
314
|
-
GET: () => jsonResponse(config),
|
|
315
|
-
},
|
|
316
|
-
|
|
317
|
-
"/api/worktrees": {
|
|
318
|
-
GET: () => catching("GET /api/worktrees", apiGetWorktrees),
|
|
319
|
-
POST: (req) => catching("POST /api/worktrees", () => apiCreateWorktree(req)),
|
|
320
|
-
},
|
|
321
|
-
|
|
322
|
-
"/api/worktrees/:name": {
|
|
323
|
-
DELETE: (req) => {
|
|
324
|
-
const name = decodeURIComponent(req.params.name);
|
|
325
|
-
if (!isValidWorktreeName(name)) return errorResponse("Invalid worktree name", 400);
|
|
326
|
-
return catching(`DELETE /api/worktrees/${name}`, () => apiDeleteWorktree(name));
|
|
327
|
-
},
|
|
328
|
-
},
|
|
329
|
-
|
|
330
|
-
"/api/worktrees/:name/open": {
|
|
331
|
-
POST: (req) => {
|
|
332
|
-
const name = decodeURIComponent(req.params.name);
|
|
333
|
-
if (!isValidWorktreeName(name)) return errorResponse("Invalid worktree name", 400);
|
|
334
|
-
return catching(`POST /api/worktrees/${name}/open`, () => apiOpenWorktree(name));
|
|
335
|
-
},
|
|
336
|
-
},
|
|
337
|
-
|
|
338
|
-
"/api/worktrees/:name/send": {
|
|
339
|
-
POST: (req) => {
|
|
340
|
-
const name = decodeURIComponent(req.params.name);
|
|
341
|
-
if (!isValidWorktreeName(name)) return errorResponse("Invalid worktree name", 400);
|
|
342
|
-
return catching(`POST /api/worktrees/${name}/send`, () => apiSendPrompt(name, req));
|
|
343
|
-
},
|
|
344
|
-
},
|
|
345
|
-
|
|
346
|
-
"/api/worktrees/:name/merge": {
|
|
347
|
-
POST: (req) => {
|
|
348
|
-
const name = decodeURIComponent(req.params.name);
|
|
349
|
-
if (!isValidWorktreeName(name)) return errorResponse("Invalid worktree name", 400);
|
|
350
|
-
return catching(`POST /api/worktrees/${name}/merge`, () => apiMergeWorktree(name));
|
|
351
|
-
},
|
|
352
|
-
},
|
|
353
|
-
|
|
354
|
-
"/api/worktrees/:name/status": {
|
|
355
|
-
GET: (req) => {
|
|
356
|
-
const name = decodeURIComponent(req.params.name);
|
|
357
|
-
if (!isValidWorktreeName(name)) return errorResponse("Invalid worktree name", 400);
|
|
358
|
-
return catching(`GET /api/worktrees/${name}/status`, () => apiWorktreeStatus(name));
|
|
359
|
-
},
|
|
360
|
-
},
|
|
361
|
-
|
|
362
|
-
"/api/ci-logs/:runId": {
|
|
363
|
-
GET: (req) => catching(`GET /api/ci-logs/${req.params.runId}`, () => apiCiLogs(req.params.runId)),
|
|
364
|
-
},
|
|
365
|
-
},
|
|
366
|
-
|
|
367
|
-
async fetch(req) {
|
|
368
|
-
// Static frontend files in production mode (fallback for unmatched routes)
|
|
369
|
-
if (STATIC_DIR) {
|
|
370
|
-
const url = new URL(req.url);
|
|
371
|
-
const rawPath = url.pathname === "/" ? "index.html" : url.pathname;
|
|
372
|
-
const filePath = join(STATIC_DIR, rawPath);
|
|
373
|
-
const staticRoot = resolve(STATIC_DIR);
|
|
374
|
-
// Path traversal protection: resolved path must stay within STATIC_DIR
|
|
375
|
-
if (!resolve(filePath).startsWith(staticRoot + "/")) {
|
|
376
|
-
return new Response("Forbidden", { status: 403 });
|
|
377
|
-
}
|
|
378
|
-
const file = Bun.file(filePath);
|
|
379
|
-
if (await file.exists()) {
|
|
380
|
-
return new Response(file);
|
|
381
|
-
}
|
|
382
|
-
// SPA fallback: serve index.html for unmatched routes
|
|
383
|
-
return new Response(Bun.file(join(STATIC_DIR, "index.html")));
|
|
384
|
-
}
|
|
385
|
-
return new Response("Not Found", { status: 404 });
|
|
386
|
-
},
|
|
387
|
-
|
|
388
|
-
websocket: {
|
|
389
|
-
// Type ws.data via the data property (Bun.serve<T> generic is deprecated)
|
|
390
|
-
data: {} as WsData,
|
|
391
|
-
|
|
392
|
-
open(ws) {
|
|
393
|
-
log.debug(`[ws] open worktree=${ws.data.worktree}`);
|
|
394
|
-
},
|
|
395
|
-
|
|
396
|
-
async message(ws, message) {
|
|
397
|
-
const msg = parseWsMessage(message);
|
|
398
|
-
if (!msg) {
|
|
399
|
-
sendWs(ws, { type: "error", message: "malformed message" });
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
const { worktree } = ws.data;
|
|
403
|
-
|
|
404
|
-
switch (msg.type) {
|
|
405
|
-
case "input":
|
|
406
|
-
write(worktree, msg.data);
|
|
407
|
-
break;
|
|
408
|
-
case "selectPane":
|
|
409
|
-
if (ws.data.attached) {
|
|
410
|
-
log.debug(`[ws] selectPane pane=${msg.pane} worktree=${worktree}`);
|
|
411
|
-
await selectPane(worktree, msg.pane);
|
|
412
|
-
}
|
|
413
|
-
break;
|
|
414
|
-
case "resize":
|
|
415
|
-
if (!ws.data.attached) {
|
|
416
|
-
// First resize = client reporting actual dimensions. Attach now.
|
|
417
|
-
ws.data.attached = true;
|
|
418
|
-
log.debug(`[ws] first resize (attaching) worktree=${worktree} cols=${msg.cols} rows=${msg.rows}`);
|
|
419
|
-
try {
|
|
420
|
-
if (msg.initialPane !== undefined) {
|
|
421
|
-
log.debug(`[ws] initialPane=${msg.initialPane} worktree=${worktree}`);
|
|
422
|
-
}
|
|
423
|
-
await attach(worktree, msg.cols, msg.rows, msg.initialPane);
|
|
424
|
-
const { onData, onExit } = makeCallbacks(ws);
|
|
425
|
-
setCallbacks(worktree, onData, onExit);
|
|
426
|
-
const scrollback = getScrollback(worktree);
|
|
427
|
-
log.debug(`[ws] attached worktree=${worktree} scrollback=${scrollback.length} bytes`);
|
|
428
|
-
if (scrollback.length > 0) {
|
|
429
|
-
sendWs(ws, { type: "scrollback", data: scrollback });
|
|
430
|
-
}
|
|
431
|
-
} catch (err: unknown) {
|
|
432
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
433
|
-
log.error(`[ws] attach failed worktree=${worktree}: ${errMsg}`);
|
|
434
|
-
sendWs(ws, { type: "error", message: errMsg });
|
|
435
|
-
ws.close(1011, errMsg.slice(0, 123)); // 1011 = Internal Error
|
|
436
|
-
}
|
|
437
|
-
} else {
|
|
438
|
-
await resize(worktree, msg.cols, msg.rows);
|
|
439
|
-
}
|
|
440
|
-
break;
|
|
441
|
-
}
|
|
442
|
-
},
|
|
443
|
-
|
|
444
|
-
async close(ws) {
|
|
445
|
-
log.debug(`[ws] close worktree=${ws.data.worktree} attached=${ws.data.attached}`);
|
|
446
|
-
clearCallbacks(ws.data.worktree);
|
|
447
|
-
await detach(ws.data.worktree);
|
|
448
|
-
},
|
|
449
|
-
},
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
// Ensure tmux server is running (needs at least one session to persist)
|
|
454
|
-
const tmuxCheck = Bun.spawnSync(["tmux", "list-sessions"], { stdout: "pipe", stderr: "pipe" });
|
|
455
|
-
if (tmuxCheck.exitCode !== 0) {
|
|
456
|
-
Bun.spawnSync(["tmux", "new-session", "-d", "-s", "0"]);
|
|
457
|
-
log.info("Started tmux session");
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
cleanupStaleSessions();
|
|
461
|
-
startPrMonitor(getWorktreePaths, config.linkedRepos, PROJECT_DIR);
|
|
462
|
-
|
|
463
|
-
log.info(`Dev Dashboard API running at http://localhost:${PORT}`);
|
|
464
|
-
const nets = networkInterfaces();
|
|
465
|
-
for (const addrs of Object.values(nets)) {
|
|
466
|
-
for (const a of addrs ?? []) {
|
|
467
|
-
if (a.family === "IPv4" && !a.internal) {
|
|
468
|
-
log.info(` Network: http://${a.address}:${PORT}`);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|