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/workmux.ts
DELETED
|
@@ -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
|
-
|
package/backend/tsconfig.json
DELETED
|
@@ -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
|
-
}
|