wmdev 0.3.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/README.md CHANGED
@@ -62,6 +62,10 @@ wmdev uses two config files in the project root:
62
62
  ### `.wmdev.yaml` schema
63
63
 
64
64
  ```yaml
65
+ # Project name displayed in the sidebar header and browser tab title.
66
+ # Falls back to "Dashboard" if omitted.
67
+ name: string
68
+
65
69
  # Services to monitor — each maps a display name to a port env var.
66
70
  # The dashboard polls these ports and shows health status badges.
67
71
  # When portStart is set, wmdev auto-allocates ports for new worktrees
@@ -112,6 +116,8 @@ linkedRepos: []
112
116
  ### Example
113
117
 
114
118
  ```yaml
119
+ name: My Project
120
+
115
121
  services:
116
122
  - name: BE
117
123
  portEnv: BACKEND_PORT
@@ -149,6 +155,7 @@ linkedRepos:
149
155
 
150
156
  | Parameter | Type | Required | Description |
151
157
  |-----------|------|----------|-------------|
158
+ | `name` | string | no | Project name shown in the sidebar header and browser tab title. Defaults to "Dashboard" |
152
159
  | `services[].name` | string | yes | Display name shown in the dashboard |
153
160
  | `services[].portEnv` | string | yes | Env var containing the service port (read from each worktree's `.env.local`) |
154
161
  | `services[].portStart` | number | no | Base port for slot 0. When set, wmdev auto-allocates ports for new worktrees |
@@ -6941,8 +6941,13 @@ async function readEnvLocal(wtDir) {
6941
6941
  for (const line of text.split(`
6942
6942
  `)) {
6943
6943
  const match = line.match(/^(\w+)=(.*)$/);
6944
- if (match)
6945
- env[match[1]] = match[2];
6944
+ if (match) {
6945
+ let val = match[2];
6946
+ if (val.length >= 2 && val.startsWith("'") && val.endsWith("'")) {
6947
+ val = val.slice(1, -1);
6948
+ }
6949
+ env[match[1]] = val;
6950
+ }
6946
6951
  }
6947
6952
  return env;
6948
6953
  } catch {
@@ -6959,12 +6964,14 @@ async function writeEnvLocal(wtDir, entries) {
6959
6964
  `);
6960
6965
  } catch {}
6961
6966
  for (const [key, value] of Object.entries(entries)) {
6967
+ const needsQuoting = /[{}"\s$`\\!#|;&()<>]/.test(value);
6968
+ const safe = needsQuoting ? `'${value}'` : value;
6962
6969
  const pattern = new RegExp(`^${key}=`);
6963
6970
  const idx = lines.findIndex((l) => pattern.test(l));
6964
6971
  if (idx >= 0) {
6965
- lines[idx] = `${key}=${value}`;
6972
+ lines[idx] = `${key}=${safe}`;
6966
6973
  } else {
6967
- lines.push(`${key}=${value}`);
6974
+ lines.push(`${key}=${safe}`);
6968
6975
  }
6969
6976
  }
6970
6977
  await Bun.write(filePath, lines.join(`
@@ -7060,7 +7067,8 @@ var DEFAULT_CONFIG = {
7060
7067
  services: [],
7061
7068
  profiles: { default: { name: "default" } },
7062
7069
  autoName: false,
7063
- linkedRepos: []
7070
+ linkedRepos: [],
7071
+ startupEnvs: {}
7064
7072
  };
7065
7073
  function hasAutoName(dir) {
7066
7074
  try {
@@ -7097,14 +7105,23 @@ function loadConfig(dir) {
7097
7105
  repo: r.repo,
7098
7106
  alias: typeof r.alias === "string" ? r.alias : r.repo.split("/").pop()
7099
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
+ }
7100
7115
  return {
7116
+ ...typeof parsed.name === "string" ? { name: parsed.name } : {},
7101
7117
  services: Array.isArray(parsed.services) ? parsed.services : DEFAULT_CONFIG.services,
7102
7118
  profiles: {
7103
7119
  default: defaultProfile?.name ? defaultProfile : DEFAULT_CONFIG.profiles.default,
7104
7120
  ...sandboxProfile?.name && sandboxProfile?.image ? { sandbox: sandboxProfile } : {}
7105
7121
  },
7106
7122
  autoName,
7107
- linkedRepos
7123
+ linkedRepos,
7124
+ startupEnvs
7108
7125
  };
7109
7126
  } catch {
7110
7127
  return DEFAULT_CONFIG;
@@ -7589,6 +7606,48 @@ function parseBranchFromOutput(output) {
7589
7606
  const match = output.match(/branch:\s*(\S+)/i);
7590
7607
  return match?.[1] ?? null;
7591
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
+ }
7592
7651
  async function addWorktree(rawBranch, opts) {
7593
7652
  ensureTmux();
7594
7653
  const profile = opts?.profile ?? "default";
@@ -7643,35 +7702,10 @@ async function addWorktree(rawBranch, opts) {
7643
7702
  branch = parsed;
7644
7703
  }
7645
7704
  const windowTarget = `wm-${branch}`;
7705
+ await initWorktreeEnv(branch, { profile, agent, services: opts?.services, envOverrides: opts?.envOverrides });
7646
7706
  const porcelainResult = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
7647
7707
  const worktreeMap = parseWorktreePorcelain(new TextDecoder().decode(porcelainResult.stdout));
7648
7708
  const wtDir = worktreeMap.get(branch) ?? null;
7649
- if (wtDir) {
7650
- const allPaths = [...worktreeMap.values()];
7651
- const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
7652
- const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
7653
- await writeEnvLocal(wtDir, { ...portAssignments, PROFILE: profile, AGENT: agent });
7654
- const rpcPort = Bun.env.BACKEND_PORT || "5111";
7655
- const hooksConfig = {
7656
- hooks: {
7657
- Stop: [{ hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-stop.sh`, async: true }] }],
7658
- PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-pr.sh`, async: true }] }]
7659
- }
7660
- };
7661
- await mkdir2(`${wtDir}/.claude`, { recursive: true });
7662
- const settingsPath = `${wtDir}/.claude/settings.local.json`;
7663
- let existing = {};
7664
- try {
7665
- const file = Bun.file(settingsPath);
7666
- if (await file.exists()) {
7667
- existing = await file.json();
7668
- }
7669
- } catch {}
7670
- const existingHooks = existing.hooks ?? {};
7671
- const merged = { ...existing, hooks: { ...existingHooks, ...hooksConfig.hooks } };
7672
- await Bun.write(settingsPath, JSON.stringify(merged, null, 2) + `
7673
- `);
7674
- }
7675
7709
  const env = wtDir ? await readEnvLocal(wtDir) : {};
7676
7710
  log.debug(`[workmux:add] branch=${branch} dir=${wtDir ?? "(not found)"} env=${JSON.stringify(env)}`);
7677
7711
  if (hasSystemPrompt && profileConfig) {
@@ -7819,6 +7853,52 @@ async function checkDirty(dir) {
7819
7853
  ]);
7820
7854
  return status || ahead;
7821
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
+ }
7822
7902
  async function mergeWorktree(name) {
7823
7903
  log.debug(`[workmux:merge] running: workmux merge ${name}`);
7824
7904
  await removeContainer(name);
@@ -8019,6 +8099,13 @@ function write(worktreeName, data) {
8019
8099
  log.error(`[term] write(${worktreeName}) stdin closed`, err);
8020
8100
  }
8021
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
+ }
8022
8109
  async function resize(worktreeName, cols, rows) {
8023
8110
  const session = sessions.get(worktreeName);
8024
8111
  if (!session)
@@ -8097,6 +8184,20 @@ function mapChecks(checks) {
8097
8184
  runId: parseRunId(c.detailsUrl)
8098
8185
  }));
8099
8186
  }
8187
+ function parseReviewComments(json) {
8188
+ const raw = JSON.parse(json);
8189
+ const sorted = raw.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
8190
+ return sorted.slice(0, PR_FETCH_LIMIT).map((c) => ({
8191
+ type: "inline",
8192
+ author: c.user?.login ?? "unknown",
8193
+ body: c.body ?? "",
8194
+ createdAt: c.created_at ?? "",
8195
+ path: c.path ?? "",
8196
+ line: c.line ?? null,
8197
+ diffHunk: c.diff_hunk ?? "",
8198
+ isReply: c.in_reply_to_id !== undefined
8199
+ }));
8200
+ }
8100
8201
  function parsePrResponse(json, repoLabel) {
8101
8202
  const prs = new Map;
8102
8203
  const entries = JSON.parse(json);
@@ -8111,6 +8212,7 @@ function parsePrResponse(json, repoLabel) {
8111
8212
  ciStatus: summarizeChecks(entry.statusCheckRollup),
8112
8213
  ciChecks: mapChecks(entry.statusCheckRollup),
8113
8214
  comments: (entry.comments ?? []).map((c) => ({
8215
+ type: "comment",
8114
8216
  author: c.author?.login ?? "unknown",
8115
8217
  body: c.body ?? "",
8116
8218
  createdAt: c.createdAt ?? ""
@@ -8161,6 +8263,45 @@ async function fetchAllPrs(repoSlug, repoLabel, cwd) {
8161
8263
  return { ok: false, error: `failed to parse gh output for ${label}: ${err}` };
8162
8264
  }
8163
8265
  }
8266
+ async function mapWithConcurrency(items, limit, fn) {
8267
+ const results = new Array(items.length);
8268
+ let next = 0;
8269
+ async function worker() {
8270
+ while (next < items.length) {
8271
+ const idx = next++;
8272
+ results[idx] = await fn(items[idx]);
8273
+ }
8274
+ }
8275
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
8276
+ return results;
8277
+ }
8278
+ async function fetchReviewComments(prNumber, repoSlug, cwd) {
8279
+ const repoFlag = repoSlug ? repoSlug : "{owner}/{repo}";
8280
+ const args = [
8281
+ "gh",
8282
+ "api",
8283
+ `repos/${repoFlag}/pulls/${prNumber}/comments`,
8284
+ "--paginate"
8285
+ ];
8286
+ const proc = Bun.spawn(args, {
8287
+ stdout: "pipe",
8288
+ stderr: "pipe",
8289
+ ...cwd ? { cwd } : {}
8290
+ });
8291
+ const timeout = Bun.sleep(GH_TIMEOUT_MS).then(() => {
8292
+ proc.kill();
8293
+ return "timeout";
8294
+ });
8295
+ const raceResult = await Promise.race([proc.exited, timeout]);
8296
+ if (raceResult === "timeout" || raceResult !== 0)
8297
+ return [];
8298
+ try {
8299
+ const json = await new Response(proc.stdout).text();
8300
+ return parseReviewComments(json);
8301
+ } catch {
8302
+ return [];
8303
+ }
8304
+ }
8164
8305
  async function fetchPrState(url) {
8165
8306
  const proc = Bun.spawn(["gh", "pr", "view", url, "--json", "state"], {
8166
8307
  stdout: "pipe",
@@ -8217,6 +8358,22 @@ async function syncPrStatus(getWorktreePaths, linkedRepos, projectDir) {
8217
8358
  branchPrs.set(branch, existing);
8218
8359
  }
8219
8360
  }
8361
+ const reviewTuples = [];
8362
+ for (const entries of branchPrs.values()) {
8363
+ for (const entry of entries) {
8364
+ if (entry.state === "open") {
8365
+ const repoSlug = entry.repo ? linkedRepos.find((lr) => lr.alias === entry.repo)?.repo : undefined;
8366
+ reviewTuples.push({ entry, repoSlug });
8367
+ }
8368
+ }
8369
+ }
8370
+ if (reviewTuples.length > 0) {
8371
+ const reviewResults = await mapWithConcurrency(reviewTuples, 5, (t) => fetchReviewComments(t.entry.number, t.repoSlug, projectDir));
8372
+ for (let i = 0;i < reviewTuples.length; i++) {
8373
+ const entry = reviewTuples[i].entry;
8374
+ entry.comments = [...entry.comments, ...reviewResults[i]].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
8375
+ }
8376
+ }
8220
8377
  const wtPaths = await getWorktreePaths();
8221
8378
  const seen = new Set;
8222
8379
  for (const [branch, entries] of branchPrs) {
@@ -8638,6 +8795,8 @@ function parseWsMessage(raw) {
8638
8795
  switch (m.type) {
8639
8796
  case "input":
8640
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;
8641
8800
  case "selectPane":
8642
8801
  return typeof m.pane === "number" ? { type: "selectPane", pane: m.pane } : null;
8643
8802
  case "resize":
@@ -8778,7 +8937,11 @@ async function apiGetWorktrees(req) {
8778
8937
  fetchAssignedIssues()
8779
8938
  ]);
8780
8939
  const linearIssues = linearResult.ok ? linearResult.data : [];
8781
- 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) => {
8782
8945
  const st = status.find((s) => s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch));
8783
8946
  const wtDir = wtPaths.get(wt.branch);
8784
8947
  const env = wtDir ? await readEnvLocal(wtDir) : {};
@@ -8824,6 +8987,17 @@ async function apiCreateWorktree(req) {
8824
8987
  const agent = typeof body.agent === "string" ? body.agent : "claude";
8825
8988
  const isSandbox = config.profiles.sandbox !== undefined && profileName === config.profiles.sandbox.name;
8826
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
+ }
8827
9001
  log.info(`[worktree:add] agent=${agent} profile=${profileName}${branch ? ` branch=${branch}` : ""}${prompt ? ` prompt="${prompt.slice(0, 80)}"` : ""}`);
8828
9002
  const result = await addWorktree(branch, {
8829
9003
  prompt,
@@ -8834,7 +9008,8 @@ async function apiCreateWorktree(req) {
8834
9008
  isSandbox,
8835
9009
  sandboxConfig: isSandbox ? config.profiles.sandbox : undefined,
8836
9010
  services: config.services,
8837
- mainRepoDir: PROJECT_DIR
9011
+ mainRepoDir: PROJECT_DIR,
9012
+ envOverrides
8838
9013
  });
8839
9014
  if (!result.ok)
8840
9015
  return errorResponse(result.error, 422);
@@ -8853,9 +9028,24 @@ async function apiDeleteWorktree(name) {
8853
9028
  }
8854
9029
  async function apiOpenWorktree(name) {
8855
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
+ }
8856
9045
  const result = await openWorktree(name);
8857
9046
  if (!result.ok)
8858
9047
  return errorResponse(result.error, 422);
9048
+ wtCache = null;
8859
9049
  return jsonResponse({ message: result.output });
8860
9050
  }
8861
9051
  async function apiSendPrompt(name, req) {
@@ -9023,6 +9213,9 @@ Bun.serve({
9023
9213
  case "input":
9024
9214
  write(worktree, msg.data);
9025
9215
  break;
9216
+ case "sendKeys":
9217
+ await sendKeys(worktree, msg.hexBytes);
9218
+ break;
9026
9219
  case "selectPane":
9027
9220
  if (ws.data.attached) {
9028
9221
  log.debug(`[ws] selectPane pane=${msg.pane} worktree=${worktree}`);