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.
@@ -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 merged = await Promise.all(worktrees.map(async (wt) => {
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}`);