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,474 +0,0 @@
1
- import { $ } from "bun";
2
- import { readEnvLocal, writeEnvLocal, readAllWorktreeEnvs, allocatePorts } from "./env";
3
- import { expandTemplate, type ProfileConfig, type SandboxProfileConfig, type ServiceConfig } from "./config";
4
- import { launchContainer, removeContainer } from "./docker";
5
- import { log } from "./lib/log";
6
-
7
- export interface Worktree {
8
- branch: string;
9
- agent: string;
10
- mux: string;
11
- unmerged: string;
12
- path: string;
13
- }
14
-
15
- export interface WorktreeStatus {
16
- worktree: string;
17
- status: string;
18
- elapsed: string;
19
- title: string;
20
- }
21
-
22
- const WORKTREE_HEADERS = ["BRANCH", "AGENT", "MUX", "UNMERGED", "PATH"] as const;
23
- const STATUS_HEADERS = ["WORKTREE", "STATUS", "ELAPSED", "TITLE"] as const;
24
-
25
- function parseTable<T>(
26
- output: string,
27
- mapper: (cols: string[]) => T,
28
- expectedHeaders?: readonly string[],
29
- ): T[] {
30
- const lines = output.trim().split("\n").filter(Boolean);
31
- if (lines.length < 2) return [];
32
-
33
- const headerLine = lines[0];
34
-
35
- if (expectedHeaders) {
36
- const actual = headerLine.trim().split(/\s+/).map(h => h.toUpperCase());
37
- const match = expectedHeaders.every((h, i) => actual[i] === h.toUpperCase());
38
- if (!match) {
39
- log.warn(`[parseTable] unexpected headers: got [${actual.join(", ")}], expected [${expectedHeaders.join(", ")}]`);
40
- }
41
- }
42
-
43
- // Find column positions based on header spacing
44
- const colStarts: number[] = [];
45
- let inSpace = true;
46
- for (let i = 0; i < headerLine.length; i++) {
47
- if (headerLine[i] !== " " && inSpace) {
48
- colStarts.push(i);
49
- inSpace = false;
50
- } else if (headerLine[i] === " " && !inSpace) {
51
- inSpace = true;
52
- }
53
- }
54
-
55
- return lines.slice(1).map(line => {
56
- const cols = colStarts.map((start, idx) => {
57
- const end = idx + 1 < colStarts.length ? colStarts[idx + 1] : line.length;
58
- return line.slice(start, end).trim();
59
- });
60
- return mapper(cols);
61
- });
62
- }
63
-
64
- /** Build env with TMUX set so workmux can resolve agent states outside tmux. */
65
- function workmuxEnv(): Record<string, string | undefined> {
66
- if (process.env.TMUX) return process.env;
67
- const tmpdir = process.env.TMUX_TMPDIR || "/tmp";
68
- const uid = process.getuid?.() ?? 1000;
69
- return { ...process.env, TMUX: `${tmpdir}/tmux-${uid}/default,0,0` };
70
- }
71
-
72
- export async function listWorktrees(): Promise<Worktree[]> {
73
- const result = await $`workmux list`.env(workmuxEnv()).text();
74
- return parseTable(result, (cols) => ({
75
- branch: cols[0] ?? "",
76
- agent: cols[1] ?? "",
77
- mux: cols[2] ?? "",
78
- unmerged: cols[3] ?? "",
79
- path: cols[4] ?? "",
80
- }), WORKTREE_HEADERS);
81
- }
82
-
83
- export async function getStatus(): Promise<WorktreeStatus[]> {
84
- const result = await $`workmux status`.env(workmuxEnv()).text();
85
- return parseTable(result, (cols) => ({
86
- worktree: cols[0] ?? "",
87
- status: cols[1] ?? "",
88
- elapsed: cols[2] ?? "",
89
- title: cols[3] ?? "",
90
- }), STATUS_HEADERS);
91
- }
92
-
93
- async function tryExec(args: string[]): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
94
- const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
95
- const stdout = await new Response(proc.stdout).text();
96
- const stderr = await new Response(proc.stderr).text();
97
- const exitCode = await proc.exited;
98
-
99
- if (exitCode !== 0) {
100
- const msg = `${args.join(" ")} failed (exit ${exitCode}): ${stderr || stdout}`;
101
- log.error(`[workmux:exec] ${msg}`);
102
- return { ok: false, error: msg };
103
- }
104
- return { ok: true, stdout: stdout.trim() };
105
- }
106
-
107
- export { readEnvLocal } from "./env";
108
-
109
- function buildAgentCmd(env: Record<string, string>, agent: string, profileConfig: ProfileConfig, isSandbox: boolean, prompt?: string): string {
110
- const systemPrompt = profileConfig.systemPrompt
111
- ? expandTemplate(profileConfig.systemPrompt, env)
112
- : "";
113
- // Escape for double-quoted shell context: backslash, double-quote, dollar, backtick.
114
- const innerEscaped = systemPrompt.replace(/["\\$`]/g, "\\$&");
115
- const promptEscaped = prompt ? prompt.replace(/["\\$`]/g, "\\$&") : "";
116
-
117
- // For sandbox, env is passed via Docker -e flags, no inline prefix needed.
118
- // For non-sandbox, build inline env prefix for passthrough vars.
119
- // Merge host env with worktree env; worktree env takes precedence.
120
- const envPrefix = !isSandbox && profileConfig.envPassthrough?.length
121
- ? buildEnvPrefix(profileConfig.envPassthrough, { ...process.env, ...env })
122
- : "";
123
-
124
- const promptSuffix = promptEscaped ? ` "${promptEscaped}"` : "";
125
-
126
- if (agent === "codex") {
127
- return systemPrompt
128
- ? `${envPrefix}codex --yolo -c "developer_instructions=${innerEscaped}"${promptSuffix}`
129
- : `${envPrefix}codex --yolo${promptSuffix}`;
130
- }
131
- const skipPerms = isSandbox ? " --dangerously-skip-permissions" : "";
132
- return systemPrompt
133
- ? `${envPrefix}claude${skipPerms} --append-system-prompt "${innerEscaped}"${promptSuffix}`
134
- : `${envPrefix}claude${skipPerms}${promptSuffix}`;
135
- }
136
-
137
- /** Build an inline env prefix (e.g. "KEY='val' KEY2='val2' ") for vars listed in envPassthrough. */
138
- function buildEnvPrefix(keys: string[], env: Record<string, string | undefined>): string {
139
- const parts: string[] = [];
140
- for (const key of keys) {
141
- const val = env[key];
142
- if (val) {
143
- const escaped = val.replace(/'/g, "'\\''");
144
- parts.push(`${key}='${escaped}'`);
145
- }
146
- }
147
- return parts.length > 0 ? parts.join(" ") + " " : "";
148
- }
149
-
150
- /**
151
- * Pure: parse `git worktree list --porcelain` output into a branch→path map.
152
- * Detached HEAD entries (line === "detached") are skipped — they have no branch
153
- * name to key on.
154
- */
155
- export function parseWorktreePorcelain(output: string): Map<string, string> {
156
- const paths = new Map<string, string>();
157
- let currentPath = "";
158
- for (const line of output.split("\n")) {
159
- if (line.startsWith("worktree ")) {
160
- currentPath = line.slice("worktree ".length);
161
- } else if (line.startsWith("branch ")) {
162
- const name = line.slice("branch ".length).replace("refs/heads/", "");
163
- if (currentPath) paths.set(name, currentPath);
164
- }
165
- }
166
- return paths;
167
- }
168
-
169
- function ensureTmux(): void {
170
- const check = Bun.spawnSync(["tmux", "list-sessions"], { stdout: "pipe", stderr: "pipe" });
171
- if (check.exitCode !== 0) {
172
- const started = Bun.spawnSync(["tmux", "new-session", "-d", "-s", "0"]);
173
- if (started.exitCode !== 0) {
174
- log.debug("[workmux] tmux session already exists (concurrent start)");
175
- } else {
176
- log.debug("[workmux] restarted tmux session");
177
- }
178
- }
179
- }
180
-
181
- /** Sanitize user input into a valid git branch name. */
182
- function sanitizeBranchName(raw: string): string {
183
- return raw
184
- .toLowerCase()
185
- .replace(/\s+/g, "-")
186
- .replace(/[~^:?*\[\]\\]+/g, "")
187
- .replace(/@\{/g, "")
188
- .replace(/\.{2,}/g, ".")
189
- .replace(/\/{2,}/g, "/")
190
- .replace(/-{2,}/g, "-")
191
- .replace(/^[.\-/]+|[.\-/]+$/g, "")
192
- .replace(/\.lock$/i, "");
193
- }
194
-
195
- function randomName(len: number): string {
196
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
197
- let result = "";
198
- for (let i = 0; i < len; i++) {
199
- result += chars[Math.floor(Math.random() * chars.length)];
200
- }
201
- return result;
202
- }
203
-
204
- /** Parse branch name from workmux add output (e.g. "Branch: my-feature"). */
205
- function parseBranchFromOutput(output: string): string | null {
206
- const match = output.match(/branch:\s*(\S+)/i);
207
- return match?.[1] ?? null;
208
- }
209
-
210
- export interface AddWorktreeOpts {
211
- prompt?: string;
212
- profile?: string;
213
- agent?: string;
214
- autoName?: boolean;
215
- profileConfig?: ProfileConfig;
216
- isSandbox?: boolean;
217
- sandboxConfig?: SandboxProfileConfig;
218
- services?: ServiceConfig[];
219
- mainRepoDir?: string;
220
- }
221
-
222
- export async function addWorktree(
223
- rawBranch: string | undefined,
224
- opts?: AddWorktreeOpts
225
- ): Promise<{ ok: true; branch: string; output: string } | { ok: false; error: string }> {
226
- ensureTmux();
227
- const profile = opts?.profile ?? "default";
228
- const agent = opts?.agent ?? "claude";
229
- const profileConfig = opts?.profileConfig;
230
- const isSandbox = opts?.isSandbox === true;
231
- const hasSystemPrompt = !!profileConfig?.systemPrompt;
232
- const args: string[] = ["workmux", "add", "-b"]; // -b = background (don't switch tmux)
233
- let branch = "";
234
- let useAutoName = false;
235
-
236
- if (isSandbox) {
237
- // Sandbox: we manage panes ourselves, don't pass -p (we pass prompt to claude directly)
238
- args.push("-C"); // --no-pane-cmds
239
- // No -p: workmux can't use it with -C
240
- // No -A: auto-name needs -p which we can't pass
241
- if (rawBranch) {
242
- branch = sanitizeBranchName(rawBranch);
243
- if (!branch) {
244
- return { ok: false, error: `"${rawBranch}" is not a valid branch name after sanitization` };
245
- }
246
- } else {
247
- branch = randomName(8);
248
- }
249
- args.push(branch);
250
- } else {
251
- // Non-sandbox: skip default pane commands for profiles with a system prompt (custom pane setup)
252
- if (hasSystemPrompt) {
253
- args.push("-C"); // --no-pane-cmds
254
- }
255
-
256
- if (opts?.prompt) args.push("-p", opts.prompt);
257
-
258
- // Branch name resolution:
259
- // 1. User provided a name → sanitize and use it
260
- // 2. No name + prompt + autoName → let workmux generate via -A
261
- // 3. No name + (no prompt or no autoName) → random
262
- useAutoName = !rawBranch && !!opts?.prompt && !!opts?.autoName;
263
-
264
- if (rawBranch) {
265
- branch = sanitizeBranchName(rawBranch);
266
- if (!branch) {
267
- return { ok: false, error: `"${rawBranch}" is not a valid branch name after sanitization` };
268
- }
269
- args.push(branch);
270
- } else if (useAutoName) {
271
- args.push("-A");
272
- } else {
273
- branch = randomName(8);
274
- args.push(branch);
275
- }
276
- }
277
-
278
- log.debug(`[workmux:add] running: ${args.join(" ")}`);
279
- const execResult = await tryExec(args);
280
- if (!execResult.ok) return { ok: false, error: execResult.error };
281
- const result = execResult.stdout;
282
-
283
- // When using -A, extract the branch name from workmux output
284
- if (useAutoName) {
285
- const parsed = parseBranchFromOutput(result);
286
- if (!parsed) {
287
- return { ok: false, error: `Failed to parse branch name from workmux output: ${JSON.stringify(result)}` };
288
- }
289
- branch = parsed;
290
- }
291
-
292
- const windowTarget = `wm-${branch}`;
293
-
294
- // Parse worktree list once — used for both dir lookup and port allocation
295
- const porcelainResult = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
296
- const worktreeMap = parseWorktreePorcelain(new TextDecoder().decode(porcelainResult.stdout));
297
- const wtDir = worktreeMap.get(branch) ?? null;
298
-
299
- // Allocate ports + write PROFILE/AGENT to .env.local
300
- if (wtDir) {
301
- const allPaths = [...worktreeMap.values()];
302
- const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
303
- const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
304
- await writeEnvLocal(wtDir, { ...portAssignments, PROFILE: profile, AGENT: agent });
305
- }
306
-
307
- const env = wtDir ? await readEnvLocal(wtDir) : {};
308
- log.debug(`[workmux:add] branch=${branch} dir=${wtDir ?? "(not found)"} env=${JSON.stringify(env)}`);
309
-
310
- // For profiles with a system prompt, kill extra panes and send commands
311
- if (hasSystemPrompt && profileConfig) {
312
- // Kill extra panes (highest index first to avoid shifting)
313
- const paneCountResult = Bun.spawnSync(
314
- ["tmux", "list-panes", "-t", windowTarget, "-F", "#{pane_index}"],
315
- { stdout: "pipe", stderr: "pipe" }
316
- );
317
- if (paneCountResult.exitCode === 0) {
318
- const paneIds = new TextDecoder().decode(paneCountResult.stdout).trim().split("\n");
319
- // Kill all panes except pane 0
320
- for (let i = paneIds.length - 1; i >= 1; i--) {
321
- Bun.spawnSync(["tmux", "kill-pane", "-t", `${windowTarget}.${paneIds[i]}`]);
322
- }
323
- }
324
-
325
- // Launch Docker container for sandbox worktrees
326
- let containerName: string | undefined;
327
- if (isSandbox && opts?.sandboxConfig && wtDir) {
328
- const mainRepoDir = opts.mainRepoDir ?? process.cwd();
329
- containerName = await launchContainer({
330
- branch,
331
- wtDir,
332
- mainRepoDir,
333
- sandboxConfig: opts.sandboxConfig,
334
- services: opts.services ?? [],
335
- env,
336
- });
337
- }
338
-
339
- // Build and send agent command (pass prompt for sandbox — we handle it directly)
340
- const agentCmd = buildAgentCmd(env, agent, profileConfig, isSandbox, isSandbox ? opts?.prompt : undefined);
341
-
342
- if (containerName) {
343
- // Sandbox: enter container, run entrypoint visibly, then start agent
344
- const dockerExec = `docker exec -it -w ${wtDir} ${containerName} bash`;
345
- Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, dockerExec, "Enter"]);
346
- // Wait for shell to be ready, then chain entrypoint → agent
347
- await Bun.sleep(500);
348
- const entrypointThenAgent = `/usr/local/bin/entrypoint.sh && ${agentCmd}`;
349
- log.debug(`[workmux] sending to ${windowTarget}.0:\n${entrypointThenAgent}`);
350
- Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, entrypointThenAgent, "Enter"]);
351
- // Shell pane: host shell in worktree dir
352
- Bun.spawnSync(["tmux", "split-window", "-h", "-t", `${windowTarget}.0`, "-l", "25%", "-c", wtDir ?? process.cwd()]);
353
- } else {
354
- // Non-sandbox: run agent directly in pane 0
355
- log.debug(`[workmux] sending command to ${windowTarget}.0:\n${agentCmd}`);
356
- Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, agentCmd, "Enter"]);
357
- // Open a shell pane on the right (1/3 width) in the worktree dir
358
- Bun.spawnSync(["tmux", "split-window", "-h", "-t", `${windowTarget}.0`, "-l", "25%", "-c", wtDir ?? process.cwd()]);
359
- }
360
- // Keep focus on the agent pane (left)
361
- Bun.spawnSync(["tmux", "select-pane", "-t", `${windowTarget}.0`]);
362
- }
363
-
364
- return { ok: true, branch, output: result };
365
- }
366
-
367
- export async function removeWorktree(name: string): Promise<{ ok: true; output: string } | { ok: false; error: string }> {
368
- log.debug(`[workmux:rm] running: workmux rm --force ${name}`);
369
- await removeContainer(name);
370
- const result = await tryExec(["workmux", "rm", "--force", name]);
371
- if (!result.ok) return result;
372
- return { ok: true, output: result.stdout };
373
- }
374
-
375
- const TMUX_TIMEOUT_MS = 5_000;
376
-
377
- /** Run a tmux subprocess and await exit with a timeout. Kills the process on timeout. */
378
- async function tmuxExec(args: string[], opts: { stdin?: Uint8Array } = {}): Promise<{ exitCode: number; stderr: string }> {
379
- const proc = Bun.spawn(args, {
380
- stdin: opts.stdin ?? "ignore",
381
- stdout: "ignore",
382
- stderr: "pipe",
383
- });
384
-
385
- const timeout = Bun.sleep(TMUX_TIMEOUT_MS).then(() => {
386
- proc.kill();
387
- return "timeout" as const;
388
- });
389
-
390
- const result = await Promise.race([proc.exited, timeout]);
391
- if (result === "timeout") {
392
- return { exitCode: -1, stderr: "timed out after 5s (agent may be busy)" };
393
- }
394
- const stderr = (await new Response(proc.stderr).text()).trim();
395
- return { exitCode: result, stderr };
396
- }
397
-
398
- export async function sendPrompt(
399
- branch: string,
400
- text: string,
401
- pane = 0,
402
- preamble?: string,
403
- ): Promise<{ ok: true } | { ok: false; error: string }> {
404
- const windowName = `wm-${branch}`;
405
- const session = await findWorktreeSession(windowName);
406
- if (!session) {
407
- return { ok: false, error: `tmux window "${windowName}" not found` };
408
- }
409
- const target = `${session}:${windowName}.${pane}`;
410
- log.debug(`[send:${branch}] target=${target} textBytes=${text.length}${preamble ? ` preamble=${preamble.length}b` : ""}`);
411
-
412
- // Type the preamble as regular keystrokes so it shows inline in the agent,
413
- // then paste the bulk payload via a tmux buffer (appears as [pasted text]).
414
- if (preamble) {
415
- const { exitCode, stderr } = await tmuxExec(["tmux", "send-keys", "-t", target, "-l", "--", preamble]);
416
- if (exitCode !== 0) {
417
- return { ok: false, error: `send-keys preamble failed${stderr ? `: ${stderr}` : ""}` };
418
- }
419
- }
420
-
421
- const cleaned = text.replace(/\0/g, "");
422
-
423
- // Use a unique buffer name per invocation to avoid races when concurrent
424
- // sendPrompt calls overlap (e.g. two worktrees sending at the same time).
425
- const bufName = `wm-prompt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
426
-
427
- // Load text into a named tmux buffer via stdin — avoids all send-keys
428
- // escaping/chunking issues and handles any text size in a single operation.
429
- const load = await tmuxExec(["tmux", "load-buffer", "-b", bufName, "-"], { stdin: new TextEncoder().encode(cleaned) });
430
- if (load.exitCode !== 0) {
431
- return { ok: false, error: `load-buffer failed${load.stderr ? `: ${load.stderr}` : ""}` };
432
- }
433
-
434
- // Paste buffer into target pane; -d deletes the buffer after pasting.
435
- const paste = await tmuxExec(["tmux", "paste-buffer", "-b", bufName, "-t", target, "-d"]);
436
- if (paste.exitCode !== 0) {
437
- return { ok: false, error: `paste-buffer failed${paste.stderr ? `: ${paste.stderr}` : ""}` };
438
- }
439
-
440
- return { ok: true };
441
- }
442
-
443
- async function findWorktreeSession(windowName: string): Promise<string | null> {
444
- const proc = Bun.spawn(
445
- ["tmux", "list-windows", "-a", "-F", "#{session_name}:#{window_name}"],
446
- { stdout: "pipe", stderr: "pipe" }
447
- );
448
- if (await proc.exited !== 0) return null;
449
- const output = (await new Response(proc.stdout).text()).trim();
450
- if (!output) return null;
451
- for (const line of output.split("\n")) {
452
- const colonIdx = line.indexOf(":");
453
- if (colonIdx === -1) continue;
454
- const session = line.slice(0, colonIdx);
455
- const name = line.slice(colonIdx + 1);
456
- if (name === windowName) return session;
457
- }
458
- return null;
459
- }
460
-
461
- export async function openWorktree(name: string): Promise<{ ok: true; output: string } | { ok: false; error: string }> {
462
- const result = await tryExec(["workmux", "open", name]);
463
- if (!result.ok) return result;
464
- return { ok: true, output: result.stdout };
465
- }
466
-
467
- export async function mergeWorktree(name: string): Promise<{ ok: true; output: string } | { ok: false; error: string }> {
468
- log.debug(`[workmux:merge] running: workmux merge ${name}`);
469
- await removeContainer(name);
470
- const result = await tryExec(["workmux", "merge", name]);
471
- if (!result.ok) return result;
472
- return { ok: true, output: result.stdout };
473
- }
474
-
@@ -1,15 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ESNext",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
6
- "strict": true,
7
- "esModuleInterop": true,
8
- "skipLibCheck": true,
9
- "outDir": "dist",
10
- "rootDir": "src",
11
- "typeRoots": ["../node_modules/@types"],
12
- "types": ["bun"]
13
- },
14
- "include": ["src/**/*.ts"]
15
- }