wmdev 0.4.0 → 0.5.0
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/backend/dist/server.js
CHANGED
|
@@ -7067,7 +7067,8 @@ var DEFAULT_CONFIG = {
|
|
|
7067
7067
|
services: [],
|
|
7068
7068
|
profiles: { default: { name: "default" } },
|
|
7069
7069
|
autoName: false,
|
|
7070
|
-
linkedRepos: []
|
|
7070
|
+
linkedRepos: [],
|
|
7071
|
+
startupEnvs: {}
|
|
7071
7072
|
};
|
|
7072
7073
|
function hasAutoName(dir) {
|
|
7073
7074
|
try {
|
|
@@ -7104,6 +7105,13 @@ function loadConfig(dir) {
|
|
|
7104
7105
|
repo: r.repo,
|
|
7105
7106
|
alias: typeof r.alias === "string" ? r.alias : r.repo.split("/").pop()
|
|
7106
7107
|
})) : [];
|
|
7108
|
+
let startupEnvs = {};
|
|
7109
|
+
if (parsed.startupEnvs && typeof parsed.startupEnvs === "object" && !Array.isArray(parsed.startupEnvs)) {
|
|
7110
|
+
const raw = parsed.startupEnvs;
|
|
7111
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
7112
|
+
startupEnvs[k] = typeof v === "string" ? v : String(v);
|
|
7113
|
+
}
|
|
7114
|
+
}
|
|
7107
7115
|
return {
|
|
7108
7116
|
...typeof parsed.name === "string" ? { name: parsed.name } : {},
|
|
7109
7117
|
services: Array.isArray(parsed.services) ? parsed.services : DEFAULT_CONFIG.services,
|
|
@@ -7112,7 +7120,8 @@ function loadConfig(dir) {
|
|
|
7112
7120
|
...sandboxProfile?.name && sandboxProfile?.image ? { sandbox: sandboxProfile } : {}
|
|
7113
7121
|
},
|
|
7114
7122
|
autoName,
|
|
7115
|
-
linkedRepos
|
|
7123
|
+
linkedRepos,
|
|
7124
|
+
startupEnvs
|
|
7116
7125
|
};
|
|
7117
7126
|
} catch {
|
|
7118
7127
|
return DEFAULT_CONFIG;
|
|
@@ -7597,6 +7606,48 @@ function parseBranchFromOutput(output) {
|
|
|
7597
7606
|
const match = output.match(/branch:\s*(\S+)/i);
|
|
7598
7607
|
return match?.[1] ?? null;
|
|
7599
7608
|
}
|
|
7609
|
+
async function initWorktreeEnv(branch, opts) {
|
|
7610
|
+
const profile = opts?.profile ?? "default";
|
|
7611
|
+
const agent = opts?.agent ?? "claude";
|
|
7612
|
+
const porcelainResult = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
|
|
7613
|
+
const worktreeMap = parseWorktreePorcelain(new TextDecoder().decode(porcelainResult.stdout));
|
|
7614
|
+
const wtDir = worktreeMap.get(branch) ?? null;
|
|
7615
|
+
if (!wtDir)
|
|
7616
|
+
return;
|
|
7617
|
+
const existingEnv = await readEnvLocal(wtDir);
|
|
7618
|
+
const allPaths = [...worktreeMap.values()];
|
|
7619
|
+
const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
|
|
7620
|
+
const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
|
|
7621
|
+
const defaults = { ...portAssignments, PROFILE: profile, AGENT: agent, ...opts?.envOverrides };
|
|
7622
|
+
const toWrite = {};
|
|
7623
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
7624
|
+
if (!existingEnv[key])
|
|
7625
|
+
toWrite[key] = value;
|
|
7626
|
+
}
|
|
7627
|
+
if (Object.keys(toWrite).length > 0) {
|
|
7628
|
+
await writeEnvLocal(wtDir, toWrite);
|
|
7629
|
+
}
|
|
7630
|
+
const rpcPort = Bun.env.BACKEND_PORT || "5111";
|
|
7631
|
+
const hooksConfig = {
|
|
7632
|
+
hooks: {
|
|
7633
|
+
Stop: [{ hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-stop.sh`, async: true }] }],
|
|
7634
|
+
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-pr.sh`, async: true }] }]
|
|
7635
|
+
}
|
|
7636
|
+
};
|
|
7637
|
+
await mkdir2(`${wtDir}/.claude`, { recursive: true });
|
|
7638
|
+
const settingsPath = `${wtDir}/.claude/settings.local.json`;
|
|
7639
|
+
let existing = {};
|
|
7640
|
+
try {
|
|
7641
|
+
const file = Bun.file(settingsPath);
|
|
7642
|
+
if (await file.exists()) {
|
|
7643
|
+
existing = await file.json();
|
|
7644
|
+
}
|
|
7645
|
+
} catch {}
|
|
7646
|
+
const existingHooks = existing.hooks ?? {};
|
|
7647
|
+
const merged = { ...existing, hooks: { ...existingHooks, ...hooksConfig.hooks } };
|
|
7648
|
+
await Bun.write(settingsPath, JSON.stringify(merged, null, 2) + `
|
|
7649
|
+
`);
|
|
7650
|
+
}
|
|
7600
7651
|
async function addWorktree(rawBranch, opts) {
|
|
7601
7652
|
ensureTmux();
|
|
7602
7653
|
const profile = opts?.profile ?? "default";
|
|
@@ -7651,35 +7702,10 @@ async function addWorktree(rawBranch, opts) {
|
|
|
7651
7702
|
branch = parsed;
|
|
7652
7703
|
}
|
|
7653
7704
|
const windowTarget = `wm-${branch}`;
|
|
7705
|
+
await initWorktreeEnv(branch, { profile, agent, services: opts?.services, envOverrides: opts?.envOverrides });
|
|
7654
7706
|
const porcelainResult = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
|
|
7655
7707
|
const worktreeMap = parseWorktreePorcelain(new TextDecoder().decode(porcelainResult.stdout));
|
|
7656
7708
|
const wtDir = worktreeMap.get(branch) ?? null;
|
|
7657
|
-
if (wtDir) {
|
|
7658
|
-
const allPaths = [...worktreeMap.values()];
|
|
7659
|
-
const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
|
|
7660
|
-
const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
|
|
7661
|
-
await writeEnvLocal(wtDir, { ...portAssignments, PROFILE: profile, AGENT: agent });
|
|
7662
|
-
const rpcPort = Bun.env.BACKEND_PORT || "5111";
|
|
7663
|
-
const hooksConfig = {
|
|
7664
|
-
hooks: {
|
|
7665
|
-
Stop: [{ hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-stop.sh`, async: true }] }],
|
|
7666
|
-
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-pr.sh`, async: true }] }]
|
|
7667
|
-
}
|
|
7668
|
-
};
|
|
7669
|
-
await mkdir2(`${wtDir}/.claude`, { recursive: true });
|
|
7670
|
-
const settingsPath = `${wtDir}/.claude/settings.local.json`;
|
|
7671
|
-
let existing = {};
|
|
7672
|
-
try {
|
|
7673
|
-
const file = Bun.file(settingsPath);
|
|
7674
|
-
if (await file.exists()) {
|
|
7675
|
-
existing = await file.json();
|
|
7676
|
-
}
|
|
7677
|
-
} catch {}
|
|
7678
|
-
const existingHooks = existing.hooks ?? {};
|
|
7679
|
-
const merged = { ...existing, hooks: { ...existingHooks, ...hooksConfig.hooks } };
|
|
7680
|
-
await Bun.write(settingsPath, JSON.stringify(merged, null, 2) + `
|
|
7681
|
-
`);
|
|
7682
|
-
}
|
|
7683
7709
|
const env = wtDir ? await readEnvLocal(wtDir) : {};
|
|
7684
7710
|
log.debug(`[workmux:add] branch=${branch} dir=${wtDir ?? "(not found)"} env=${JSON.stringify(env)}`);
|
|
7685
7711
|
if (hasSystemPrompt && profileConfig) {
|
|
@@ -7827,6 +7853,52 @@ async function checkDirty(dir) {
|
|
|
7827
7853
|
]);
|
|
7828
7854
|
return status || ahead;
|
|
7829
7855
|
}
|
|
7856
|
+
async function cleanupStaleWindows(activeBranches, worktreeBaseDir) {
|
|
7857
|
+
try {
|
|
7858
|
+
const proc = Bun.spawn(["tmux", "list-panes", "-a", "-F", "#{session_name}:#{window_name} #{pane_current_path}"], { stdout: "pipe", stderr: "pipe" });
|
|
7859
|
+
if (await proc.exited !== 0)
|
|
7860
|
+
return;
|
|
7861
|
+
const output = (await new Response(proc.stdout).text()).trim();
|
|
7862
|
+
if (!output)
|
|
7863
|
+
return;
|
|
7864
|
+
const toKill = [];
|
|
7865
|
+
const seen = new Set;
|
|
7866
|
+
for (const line of output.split(`
|
|
7867
|
+
`)) {
|
|
7868
|
+
const spaceIdx = line.indexOf(" ");
|
|
7869
|
+
if (spaceIdx === -1)
|
|
7870
|
+
continue;
|
|
7871
|
+
const target = line.slice(0, spaceIdx);
|
|
7872
|
+
const panePath = line.slice(spaceIdx + 1);
|
|
7873
|
+
const colonIdx = target.indexOf(":");
|
|
7874
|
+
if (colonIdx === -1)
|
|
7875
|
+
continue;
|
|
7876
|
+
const session = target.slice(0, colonIdx);
|
|
7877
|
+
const windowName = target.slice(colonIdx + 1);
|
|
7878
|
+
if (!windowName.startsWith("wm-"))
|
|
7879
|
+
continue;
|
|
7880
|
+
if (seen.has(windowName))
|
|
7881
|
+
continue;
|
|
7882
|
+
const branch = windowName.slice(3);
|
|
7883
|
+
if (activeBranches.has(branch))
|
|
7884
|
+
continue;
|
|
7885
|
+
if (!panePath.startsWith(worktreeBaseDir))
|
|
7886
|
+
continue;
|
|
7887
|
+
toKill.push({ session, windowName });
|
|
7888
|
+
seen.add(windowName);
|
|
7889
|
+
}
|
|
7890
|
+
await Promise.all(toKill.map(async ({ session, windowName }) => {
|
|
7891
|
+
log.info(`[cleanup] killing stale tmux window "${windowName}" (no matching worktree)`);
|
|
7892
|
+
const kill = Bun.spawn(["tmux", "kill-window", "-t", `${session}:${windowName}`], {
|
|
7893
|
+
stdout: "ignore",
|
|
7894
|
+
stderr: "ignore"
|
|
7895
|
+
});
|
|
7896
|
+
await kill.exited;
|
|
7897
|
+
}));
|
|
7898
|
+
} catch (err) {
|
|
7899
|
+
log.warn(`[cleanup] cleanupStaleWindows failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
7900
|
+
}
|
|
7901
|
+
}
|
|
7830
7902
|
async function mergeWorktree(name) {
|
|
7831
7903
|
log.debug(`[workmux:merge] running: workmux merge ${name}`);
|
|
7832
7904
|
await removeContainer(name);
|
|
@@ -8027,6 +8099,13 @@ function write(worktreeName, data) {
|
|
|
8027
8099
|
log.error(`[term] write(${worktreeName}) stdin closed`, err);
|
|
8028
8100
|
}
|
|
8029
8101
|
}
|
|
8102
|
+
async function sendKeys(worktreeName, hexBytes) {
|
|
8103
|
+
const session = sessions.get(worktreeName);
|
|
8104
|
+
if (!session)
|
|
8105
|
+
return;
|
|
8106
|
+
const windowTarget = `${session.groupedSessionName}:wm-${worktreeName}`;
|
|
8107
|
+
await asyncTmux(["tmux", "send-keys", "-t", windowTarget, "-H", ...hexBytes]);
|
|
8108
|
+
}
|
|
8030
8109
|
async function resize(worktreeName, cols, rows) {
|
|
8031
8110
|
const session = sessions.get(worktreeName);
|
|
8032
8111
|
if (!session)
|
|
@@ -8716,6 +8795,8 @@ function parseWsMessage(raw) {
|
|
|
8716
8795
|
switch (m.type) {
|
|
8717
8796
|
case "input":
|
|
8718
8797
|
return typeof m.data === "string" ? { type: "input", data: m.data } : null;
|
|
8798
|
+
case "sendKeys":
|
|
8799
|
+
return Array.isArray(m.hexBytes) && m.hexBytes.every((b) => typeof b === "string") ? { type: "sendKeys", hexBytes: m.hexBytes } : null;
|
|
8719
8800
|
case "selectPane":
|
|
8720
8801
|
return typeof m.pane === "number" ? { type: "selectPane", pane: m.pane } : null;
|
|
8721
8802
|
case "resize":
|
|
@@ -8856,7 +8937,11 @@ async function apiGetWorktrees(req) {
|
|
|
8856
8937
|
fetchAssignedIssues()
|
|
8857
8938
|
]);
|
|
8858
8939
|
const linearIssues = linearResult.ok ? linearResult.data : [];
|
|
8859
|
-
const
|
|
8940
|
+
const activeBranches = new Set(worktrees.map((wt) => wt.branch));
|
|
8941
|
+
activeBranches.add("main");
|
|
8942
|
+
cleanupStaleWindows(activeBranches, `${PROJECT_DIR}__worktrees/`);
|
|
8943
|
+
const nonMainWorktrees = worktrees.filter((wt) => wtPaths.has(wt.branch));
|
|
8944
|
+
const merged = await Promise.all(nonMainWorktrees.map(async (wt) => {
|
|
8860
8945
|
const st = status.find((s) => s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch));
|
|
8861
8946
|
const wtDir = wtPaths.get(wt.branch);
|
|
8862
8947
|
const env = wtDir ? await readEnvLocal(wtDir) : {};
|
|
@@ -8902,6 +8987,17 @@ async function apiCreateWorktree(req) {
|
|
|
8902
8987
|
const agent = typeof body.agent === "string" ? body.agent : "claude";
|
|
8903
8988
|
const isSandbox = config.profiles.sandbox !== undefined && profileName === config.profiles.sandbox.name;
|
|
8904
8989
|
const profileConfig = isSandbox ? config.profiles.sandbox : config.profiles.default;
|
|
8990
|
+
let envOverrides;
|
|
8991
|
+
if (body.envOverrides && typeof body.envOverrides === "object" && !Array.isArray(body.envOverrides)) {
|
|
8992
|
+
const raw2 = body.envOverrides;
|
|
8993
|
+
const parsed = {};
|
|
8994
|
+
for (const [k, v] of Object.entries(raw2)) {
|
|
8995
|
+
if (typeof v === "string")
|
|
8996
|
+
parsed[k] = v;
|
|
8997
|
+
}
|
|
8998
|
+
if (Object.keys(parsed).length > 0)
|
|
8999
|
+
envOverrides = parsed;
|
|
9000
|
+
}
|
|
8905
9001
|
log.info(`[worktree:add] agent=${agent} profile=${profileName}${branch ? ` branch=${branch}` : ""}${prompt ? ` prompt="${prompt.slice(0, 80)}"` : ""}`);
|
|
8906
9002
|
const result = await addWorktree(branch, {
|
|
8907
9003
|
prompt,
|
|
@@ -8912,7 +9008,8 @@ async function apiCreateWorktree(req) {
|
|
|
8912
9008
|
isSandbox,
|
|
8913
9009
|
sandboxConfig: isSandbox ? config.profiles.sandbox : undefined,
|
|
8914
9010
|
services: config.services,
|
|
8915
|
-
mainRepoDir: PROJECT_DIR
|
|
9011
|
+
mainRepoDir: PROJECT_DIR,
|
|
9012
|
+
envOverrides
|
|
8916
9013
|
});
|
|
8917
9014
|
if (!result.ok)
|
|
8918
9015
|
return errorResponse(result.error, 422);
|
|
@@ -8931,9 +9028,24 @@ async function apiDeleteWorktree(name) {
|
|
|
8931
9028
|
}
|
|
8932
9029
|
async function apiOpenWorktree(name) {
|
|
8933
9030
|
log.info(`[worktree:open] name=${name}`);
|
|
9031
|
+
const wtPaths = await getWorktreePaths();
|
|
9032
|
+
const wtDir = wtPaths.get(name);
|
|
9033
|
+
if (wtDir) {
|
|
9034
|
+
const env = await readEnvLocal(wtDir);
|
|
9035
|
+
if (!env.PROFILE) {
|
|
9036
|
+
log.info(`[worktree:open] initializing env for ${name}`);
|
|
9037
|
+
await initWorktreeEnv(name, {
|
|
9038
|
+
profile: config.profiles.default.name,
|
|
9039
|
+
agent: "claude",
|
|
9040
|
+
services: config.services
|
|
9041
|
+
});
|
|
9042
|
+
wtCache = null;
|
|
9043
|
+
}
|
|
9044
|
+
}
|
|
8934
9045
|
const result = await openWorktree(name);
|
|
8935
9046
|
if (!result.ok)
|
|
8936
9047
|
return errorResponse(result.error, 422);
|
|
9048
|
+
wtCache = null;
|
|
8937
9049
|
return jsonResponse({ message: result.output });
|
|
8938
9050
|
}
|
|
8939
9051
|
async function apiSendPrompt(name, req) {
|
|
@@ -9101,6 +9213,9 @@ Bun.serve({
|
|
|
9101
9213
|
case "input":
|
|
9102
9214
|
write(worktree, msg.data);
|
|
9103
9215
|
break;
|
|
9216
|
+
case "sendKeys":
|
|
9217
|
+
await sendKeys(worktree, msg.hexBytes);
|
|
9218
|
+
break;
|
|
9104
9219
|
case "selectPane":
|
|
9105
9220
|
if (ws.data.attached) {
|
|
9106
9221
|
log.debug(`[ws] selectPane pane=${msg.pane} worktree=${worktree}`);
|