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.
@@ -1,310 +0,0 @@
1
- import { log } from "./lib/log";
2
-
3
- interface TerminalSession {
4
- proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
5
- groupedSessionName: string;
6
- scrollback: string[];
7
- scrollbackBytes: number;
8
- onData: ((data: string) => void) | null;
9
- onExit: ((exitCode: number) => void) | null;
10
- cancelled: boolean;
11
- }
12
-
13
- interface AttachCmdOptions {
14
- gName: string;
15
- worktreeName: string;
16
- tmuxSession: string;
17
- cols: number;
18
- rows: number;
19
- initialPane?: number;
20
- }
21
-
22
- // Scope session names per backend instance using the dashboard port so multiple
23
- // dashboards sharing the same tmux server don't collide or kill each other's sessions.
24
- const DASH_PORT = Bun.env.DASHBOARD_PORT || "5111";
25
- const SESSION_PREFIX = `wm-dash-${DASH_PORT}-`;
26
- const MAX_SCROLLBACK_BYTES = 1 * 1024 * 1024; // 1 MB
27
- const sessions = new Map<string, TerminalSession>();
28
- let sessionCounter = 0;
29
-
30
- function groupedName(): string {
31
- return `${SESSION_PREFIX}${++sessionCounter}`;
32
- }
33
-
34
- function buildAttachCmd(opts: AttachCmdOptions): string {
35
- const windowTarget = `wm-${opts.worktreeName}`;
36
- const paneTarget = `${opts.gName}:${windowTarget}.${opts.initialPane ?? 0}`;
37
- return [
38
- `tmux new-session -d -s "${opts.gName}" -t "${opts.tmuxSession}"`,
39
- `tmux set-option -t "${opts.tmuxSession}" window-size latest`,
40
- `tmux set-option -t "${opts.gName}" mouse on`,
41
- `tmux set-option -t "${opts.gName}" set-clipboard on`,
42
- `tmux select-window -t "${opts.gName}:${windowTarget}"`,
43
- // Unzoom if a previous session left a pane zoomed (zoom state is shared across grouped sessions)
44
- `if [ "$(tmux display-message -t '${opts.gName}:${windowTarget}' -p '#{window_zoomed_flag}')" = "1" ]; then tmux resize-pane -Z -t '${opts.gName}:${windowTarget}'; fi`,
45
- `tmux select-pane -t "${paneTarget}"`,
46
- // On mobile, zoom the selected pane to fill the window
47
- ...(opts.initialPane !== undefined ? [`tmux resize-pane -Z -t "${paneTarget}"`] : []),
48
- `stty rows ${opts.rows} cols ${opts.cols}`,
49
- `exec tmux attach-session -t "${opts.gName}"`,
50
- ].join(" && ");
51
- }
52
-
53
- async function asyncTmux(args: string[]): Promise<{ exitCode: number; stderr: string }> {
54
- const proc = Bun.spawn(args, { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
55
- const exitCode = await proc.exited;
56
- const stderr = (await new Response(proc.stderr).text()).trim();
57
- return { exitCode, stderr };
58
- }
59
-
60
- /** Kill any orphaned wm-dash-* tmux sessions left from previous server runs. */
61
- export function cleanupStaleSessions(): void {
62
- try {
63
- const result = Bun.spawnSync(
64
- ["tmux", "list-sessions", "-F", "#{session_name}"],
65
- { stdout: "pipe", stderr: "pipe" }
66
- );
67
- if (result.exitCode !== 0) return;
68
- const lines = new TextDecoder().decode(result.stdout).trim().split("\n");
69
- for (const name of lines) {
70
- if (name.startsWith(SESSION_PREFIX)) {
71
- Bun.spawnSync(["tmux", "kill-session", "-t", name]);
72
- }
73
- }
74
- } catch {
75
- // No tmux server running
76
- }
77
- }
78
-
79
- /** Kill a tmux session by name, logging unexpected failures. */
80
- function killTmuxSession(name: string): void {
81
- const result = Bun.spawnSync(["tmux", "kill-session", "-t", name], { stderr: "pipe" });
82
- if (result.exitCode !== 0) {
83
- const stderr = new TextDecoder().decode(result.stderr).trim();
84
- if (!stderr.includes("can't find session")) {
85
- log.warn(`[term] killTmuxSession(${name}) exit=${result.exitCode} ${stderr}`);
86
- }
87
- }
88
- }
89
-
90
- /**
91
- * Pure: parse `tmux list-windows -a` output to find the session owning
92
- * a worktree window. Skips wm-dash-* viewer sessions.
93
- * Returns the session name, or null if not found.
94
- */
95
- export function parseTmuxSessionForWorktree(
96
- tmuxOutput: string,
97
- worktreeName: string,
98
- ): string | null {
99
- const windowName = `wm-${worktreeName}`;
100
- const lines = tmuxOutput.trim().split("\n").filter(Boolean);
101
- // First pass: exact window match, skip viewer sessions
102
- for (const line of lines) {
103
- const colonIdx = line.indexOf(":");
104
- if (colonIdx === -1) continue;
105
- const session = line.slice(0, colonIdx);
106
- const name = line.slice(colonIdx + 1);
107
- if (name === windowName && !session.startsWith("wm-dash-")) {
108
- return session;
109
- }
110
- }
111
- // Fallback: any non-viewer session with a wm-* window
112
- for (const line of lines) {
113
- const colonIdx = line.indexOf(":");
114
- if (colonIdx === -1) continue;
115
- const session = line.slice(0, colonIdx);
116
- const name = line.slice(colonIdx + 1);
117
- if (name.startsWith("wm-") && !session.startsWith("wm-dash-")) {
118
- return session;
119
- }
120
- }
121
- return null;
122
- }
123
-
124
- /** Find the tmux session that owns the window for a given worktree.
125
- * Skips wm-dash-* grouped/viewer sessions to find the real workmux session. */
126
- async function findTmuxSessionForWorktree(worktreeName: string): Promise<string> {
127
- try {
128
- const proc = Bun.spawn(
129
- ["tmux", "list-windows", "-a", "-F", "#{session_name}:#{window_name}"],
130
- { stdout: "pipe", stderr: "pipe" },
131
- );
132
- if (await proc.exited !== 0) return "0";
133
- const output = await new Response(proc.stdout).text();
134
- return parseTmuxSessionForWorktree(output, worktreeName) ?? "0";
135
- } catch {
136
- // No tmux server running
137
- }
138
- return "0";
139
- }
140
-
141
- export async function attach(
142
- worktreeName: string,
143
- cols: number,
144
- rows: number,
145
- initialPane?: number
146
- ): Promise<string> {
147
- log.debug(`[term] attach(${worktreeName}) cols=${cols} rows=${rows} existing=${sessions.has(worktreeName)}`);
148
- if (sessions.has(worktreeName)) {
149
- await detach(worktreeName);
150
- }
151
-
152
- const tmuxSession = await findTmuxSessionForWorktree(worktreeName);
153
- const gName = groupedName();
154
- log.debug(`[term] attach(${worktreeName}) tmuxSession=${tmuxSession} gName=${gName} window=wm-${worktreeName}`);
155
-
156
- // Kill stale session with same name if it exists (leftover from previous server run)
157
- killTmuxSession(gName);
158
-
159
- const cmd = buildAttachCmd({ gName, worktreeName, tmuxSession, cols, rows, initialPane });
160
-
161
- const proc = Bun.spawn(["script", "-q", "-c", cmd, "/dev/null"], {
162
- stdin: "pipe",
163
- stdout: "pipe",
164
- stderr: "pipe",
165
- env: { ...Bun.env, TERM: "xterm-256color" },
166
- });
167
-
168
- const session: TerminalSession = {
169
- proc,
170
- groupedSessionName: gName,
171
- scrollback: [],
172
- scrollbackBytes: 0,
173
- onData: null,
174
- onExit: null,
175
- cancelled: false,
176
- };
177
-
178
- sessions.set(worktreeName, session);
179
- log.debug(`[term] attach(${worktreeName}) spawned pid=${proc.pid}`);
180
-
181
- // Read stdout → push to scrollback + callback
182
- (async () => {
183
- const reader = proc.stdout.getReader();
184
- try {
185
- while (true) {
186
- if (session.cancelled) break;
187
- const { done, value } = await reader.read();
188
- if (done) break;
189
- const str = new TextDecoder().decode(value);
190
- const encoder = new TextEncoder();
191
- session.scrollbackBytes += encoder.encode(str).byteLength;
192
- session.scrollback.push(str);
193
- while (session.scrollbackBytes > MAX_SCROLLBACK_BYTES && session.scrollback.length > 0) {
194
- const removed = session.scrollback.shift()!;
195
- session.scrollbackBytes -= encoder.encode(removed).byteLength;
196
- }
197
- session.onData?.(str);
198
- }
199
- } catch (err) {
200
- // Stream closed normally — no action needed.
201
- // Log anything unexpected so it surfaces during debugging.
202
- if (!session.cancelled) {
203
- log.error(`[term] stdout reader error(${worktreeName})`, err);
204
- }
205
- }
206
- })();
207
-
208
- // Read stderr → log for diagnostics
209
- (async () => {
210
- const reader = proc.stderr.getReader();
211
- try {
212
- while (true) {
213
- const { done, value } = await reader.read();
214
- if (done) break;
215
- log.debug(`[term] stderr(${worktreeName}): ${new TextDecoder().decode(value).trimEnd()}`);
216
- }
217
- } catch { /* stream closed */ }
218
- })();
219
-
220
- proc.exited.then((exitCode) => {
221
- log.debug(`[term] proc exited(${worktreeName}) pid=${proc.pid} code=${exitCode}`);
222
- // Only clean up if this session is still the active one (not replaced by a new attach)
223
- if (sessions.get(worktreeName) === session) {
224
- session.onExit?.(exitCode);
225
- sessions.delete(worktreeName);
226
- } else {
227
- log.debug(`[term] proc exited(${worktreeName}) stale session, skipping cleanup`);
228
- }
229
- killTmuxSession(gName);
230
- });
231
-
232
- return worktreeName;
233
- }
234
-
235
- export async function detach(worktreeName: string): Promise<void> {
236
- const session = sessions.get(worktreeName);
237
- if (!session) {
238
- log.debug(`[term] detach(${worktreeName}) no session found`);
239
- return;
240
- }
241
-
242
- log.debug(`[term] detach(${worktreeName}) killing pid=${session.proc.pid} tmux=${session.groupedSessionName}`);
243
- session.cancelled = true;
244
- session.proc.kill();
245
- sessions.delete(worktreeName);
246
-
247
- killTmuxSession(session.groupedSessionName);
248
- }
249
-
250
- export function write(worktreeName: string, data: string): void {
251
- const session = sessions.get(worktreeName);
252
- if (!session) {
253
- log.warn(`[term] write(${worktreeName}) NO SESSION - input dropped (${data.length} bytes)`);
254
- return;
255
- }
256
- try {
257
- session.proc.stdin.write(new TextEncoder().encode(data));
258
- session.proc.stdin.flush();
259
- } catch (err) {
260
- log.error(`[term] write(${worktreeName}) stdin closed`, err);
261
- }
262
- }
263
-
264
- export async function resize(worktreeName: string, cols: number, rows: number): Promise<void> {
265
- const session = sessions.get(worktreeName);
266
- if (!session) return;
267
- const windowTarget = `${session.groupedSessionName}:wm-${worktreeName}`;
268
- const result = await asyncTmux(["tmux", "resize-window", "-t", windowTarget, "-x", String(cols), "-y", String(rows)]);
269
- if (result.exitCode !== 0) log.warn(`[term] resize failed: ${result.stderr}`);
270
- }
271
-
272
- export function getScrollback(worktreeName: string): string {
273
- return sessions.get(worktreeName)?.scrollback.join("") ?? "";
274
- }
275
-
276
- export function setCallbacks(
277
- worktreeName: string,
278
- onData: (data: string) => void,
279
- onExit: (exitCode: number) => void
280
- ): void {
281
- const session = sessions.get(worktreeName);
282
- if (session) {
283
- session.onData = onData;
284
- session.onExit = onExit;
285
- }
286
- }
287
-
288
- export async function selectPane(worktreeName: string, paneIndex: number): Promise<void> {
289
- const session = sessions.get(worktreeName);
290
- if (!session) {
291
- log.debug(`[term] selectPane(${worktreeName}) no session found`);
292
- return;
293
- }
294
- const windowTarget = `wm-${worktreeName}`;
295
- const target = `${session.groupedSessionName}:${windowTarget}.${paneIndex}`;
296
- log.debug(`[term] selectPane(${worktreeName}) pane=${paneIndex} target=${target}`);
297
- const [r1, r2] = await Promise.all([
298
- asyncTmux(["tmux", "select-pane", "-t", target]),
299
- asyncTmux(["tmux", "resize-pane", "-Z", "-t", target]),
300
- ]);
301
- log.debug(`[term] selectPane(${worktreeName}) select=${r1.exitCode} zoom=${r2.exitCode}`);
302
- }
303
-
304
- export function clearCallbacks(worktreeName: string): void {
305
- const session = sessions.get(worktreeName);
306
- if (session) {
307
- session.onData = null;
308
- session.onExit = null;
309
- }
310
- }