wmdev 0.2.3 → 0.4.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.
@@ -6931,6 +6931,7 @@ var log = {
6931
6931
 
6932
6932
  // backend/src/workmux.ts
6933
6933
  var {$ } = globalThis.Bun;
6934
+ import { mkdir as mkdir2 } from "fs/promises";
6934
6935
 
6935
6936
  // backend/src/env.ts
6936
6937
  async function readEnvLocal(wtDir) {
@@ -6940,8 +6941,13 @@ async function readEnvLocal(wtDir) {
6940
6941
  for (const line of text.split(`
6941
6942
  `)) {
6942
6943
  const match = line.match(/^(\w+)=(.*)$/);
6943
- if (match)
6944
- 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
+ }
6945
6951
  }
6946
6952
  return env;
6947
6953
  } catch {
@@ -6958,26 +6964,22 @@ async function writeEnvLocal(wtDir, entries) {
6958
6964
  `);
6959
6965
  } catch {}
6960
6966
  for (const [key, value] of Object.entries(entries)) {
6967
+ const needsQuoting = /[{}"\s$`\\!#|;&()<>]/.test(value);
6968
+ const safe = needsQuoting ? `'${value}'` : value;
6961
6969
  const pattern = new RegExp(`^${key}=`);
6962
6970
  const idx = lines.findIndex((l) => pattern.test(l));
6963
6971
  if (idx >= 0) {
6964
- lines[idx] = `${key}=${value}`;
6972
+ lines[idx] = `${key}=${safe}`;
6965
6973
  } else {
6966
- lines.push(`${key}=${value}`);
6974
+ lines.push(`${key}=${safe}`);
6967
6975
  }
6968
6976
  }
6969
6977
  await Bun.write(filePath, lines.join(`
6970
6978
  `) + `
6971
6979
  `);
6972
6980
  }
6973
- async function readAllWorktreeEnvs(worktreePaths, excludeDir) {
6974
- const results = [];
6975
- for (const p of worktreePaths) {
6976
- if (excludeDir && p === excludeDir)
6977
- continue;
6978
- results.push(await readEnvLocal(p));
6979
- }
6980
- return results;
6981
+ function readAllWorktreeEnvs(worktreePaths, excludeDir) {
6982
+ return Promise.all(worktreePaths.filter((p) => !excludeDir || p !== excludeDir).map((p) => readEnvLocal(p)));
6981
6983
  }
6982
6984
  function allocatePorts(existingEnvs, services) {
6983
6985
  const allocatable = services.filter((s) => s.portStart != null);
@@ -7103,6 +7105,7 @@ function loadConfig(dir) {
7103
7105
  alias: typeof r.alias === "string" ? r.alias : r.repo.split("/").pop()
7104
7106
  })) : [];
7105
7107
  return {
7108
+ ...typeof parsed.name === "string" ? { name: parsed.name } : {},
7106
7109
  services: Array.isArray(parsed.services) ? parsed.services : DEFAULT_CONFIG.services,
7107
7110
  profiles: {
7108
7111
  default: defaultProfile?.name ? defaultProfile : DEFAULT_CONFIG.profiles.default,
@@ -7332,7 +7335,7 @@ async function launchContainer(opts) {
7332
7335
  const name = containerName(branch);
7333
7336
  const home = Bun.env.HOME ?? "/root";
7334
7337
  const rpcSecret = await loadRpcSecret();
7335
- const rpcPort = Bun.env.DASHBOARD_PORT ?? "5111";
7338
+ const rpcPort = Bun.env.BACKEND_PORT ?? "5111";
7336
7339
  let sshAuthSock = Bun.env.SSH_AUTH_SOCK;
7337
7340
  if (sshAuthSock) {
7338
7341
  try {
@@ -7488,19 +7491,30 @@ function workmuxEnv() {
7488
7491
  const uid = process.getuid?.() ?? 1000;
7489
7492
  return { ...process.env, TMUX: `${tmpdir}/tmux-${uid}/default,0,0` };
7490
7493
  }
7494
+ function resolveDetachedBranch(branch, path) {
7495
+ if (branch !== "(detached)" || !path)
7496
+ return branch;
7497
+ return path.split("/").pop() || branch;
7498
+ }
7491
7499
  async function listWorktrees() {
7492
- const result = await $`workmux list`.env(workmuxEnv()).text();
7493
- return parseTable(result, (cols) => ({
7494
- branch: cols[0] ?? "",
7495
- agent: cols[1] ?? "",
7496
- mux: cols[2] ?? "",
7497
- unmerged: cols[3] ?? "",
7498
- path: cols[4] ?? ""
7499
- }), WORKTREE_HEADERS);
7500
+ const proc = await $`workmux list`.env(workmuxEnv()).nothrow().quiet();
7501
+ if (proc.exitCode !== 0) {
7502
+ ensureTmux();
7503
+ return [];
7504
+ }
7505
+ return parseTable(proc.text(), (cols) => {
7506
+ const branch = resolveDetachedBranch(cols[0] ?? "", cols[4] ?? "");
7507
+ const path = cols[4] ?? "";
7508
+ return { branch, agent: cols[1] ?? "", mux: cols[2] ?? "", unmerged: cols[3] ?? "", path };
7509
+ }, WORKTREE_HEADERS);
7500
7510
  }
7501
7511
  async function getStatus() {
7502
- const result = await $`workmux status`.env(workmuxEnv()).text();
7503
- return parseTable(result, (cols) => ({
7512
+ const proc = await $`workmux status`.env(workmuxEnv()).nothrow().quiet();
7513
+ if (proc.exitCode !== 0) {
7514
+ ensureTmux();
7515
+ return [];
7516
+ }
7517
+ return parseTable(proc.text(), (cols) => ({
7504
7518
  worktree: cols[0] ?? "",
7505
7519
  status: cols[1] ?? "",
7506
7520
  elapsed: cols[2] ?? "",
@@ -7645,6 +7659,26 @@ async function addWorktree(rawBranch, opts) {
7645
7659
  const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
7646
7660
  const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
7647
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
+ `);
7648
7682
  }
7649
7683
  const env = wtDir ? await readEnvLocal(wtDir) : {};
7650
7684
  log.debug(`[workmux:add] branch=${branch} dir=${wtDir ?? "(not found)"} env=${JSON.stringify(env)}`);
@@ -7766,6 +7800,33 @@ async function openWorktree(name) {
7766
7800
  return result;
7767
7801
  return { ok: true, output: result.stdout };
7768
7802
  }
7803
+ async function checkDirty(dir) {
7804
+ const [status, ahead] = await Promise.all([
7805
+ (async () => {
7806
+ const proc = Bun.spawn(["git", "status", "--porcelain"], {
7807
+ cwd: dir,
7808
+ stdout: "pipe",
7809
+ stderr: "pipe"
7810
+ });
7811
+ const output = await new Response(proc.stdout).text();
7812
+ await proc.exited;
7813
+ return output.trim().length > 0;
7814
+ })(),
7815
+ (async () => {
7816
+ const proc = Bun.spawn(["git", "rev-list", "--count", "@{u}..HEAD"], {
7817
+ cwd: dir,
7818
+ stdout: "pipe",
7819
+ stderr: "pipe"
7820
+ });
7821
+ const output = await new Response(proc.stdout).text();
7822
+ const exitCode = await proc.exited;
7823
+ if (exitCode !== 0)
7824
+ return false;
7825
+ return (parseInt(output.trim(), 10) || 0) > 0;
7826
+ })()
7827
+ ]);
7828
+ return status || ahead;
7829
+ }
7769
7830
  async function mergeWorktree(name) {
7770
7831
  log.debug(`[workmux:merge] running: workmux merge ${name}`);
7771
7832
  await removeContainer(name);
@@ -7776,7 +7837,9 @@ async function mergeWorktree(name) {
7776
7837
  }
7777
7838
 
7778
7839
  // backend/src/terminal.ts
7779
- var DASH_PORT = Bun.env.DASHBOARD_PORT || "5111";
7840
+ var textDecoder = new TextDecoder;
7841
+ var textEncoder = new TextEncoder;
7842
+ var DASH_PORT = Bun.env.BACKEND_PORT || "5111";
7780
7843
  var SESSION_PREFIX = `wm-dash-${DASH_PORT}-`;
7781
7844
  var MAX_SCROLLBACK_BYTES = 1 * 1024 * 1024;
7782
7845
  var sessions = new Map;
@@ -7811,7 +7874,7 @@ function cleanupStaleSessions() {
7811
7874
  const result = Bun.spawnSync(["tmux", "list-sessions", "-F", "#{session_name}"], { stdout: "pipe", stderr: "pipe" });
7812
7875
  if (result.exitCode !== 0)
7813
7876
  return;
7814
- const lines = new TextDecoder().decode(result.stdout).trim().split(`
7877
+ const lines = textDecoder.decode(result.stdout).trim().split(`
7815
7878
  `);
7816
7879
  for (const name of lines) {
7817
7880
  if (name.startsWith(SESSION_PREFIX)) {
@@ -7823,7 +7886,7 @@ function cleanupStaleSessions() {
7823
7886
  function killTmuxSession(name) {
7824
7887
  const result = Bun.spawnSync(["tmux", "kill-session", "-t", name], { stderr: "pipe" });
7825
7888
  if (result.exitCode !== 0) {
7826
- const stderr = new TextDecoder().decode(result.stderr).trim();
7889
+ const stderr = textDecoder.decode(result.stderr).trim();
7827
7890
  if (!stderr.includes("can't find session")) {
7828
7891
  log.warn(`[term] killTmuxSession(${name}) exit=${result.exitCode} ${stderr}`);
7829
7892
  }
@@ -7901,13 +7964,12 @@ async function attach(worktreeName, cols, rows, initialPane) {
7901
7964
  const { done, value } = await reader.read();
7902
7965
  if (done)
7903
7966
  break;
7904
- const str = new TextDecoder().decode(value);
7905
- const encoder = new TextEncoder;
7906
- session.scrollbackBytes += encoder.encode(str).byteLength;
7967
+ const str = textDecoder.decode(value);
7968
+ session.scrollbackBytes += textEncoder.encode(str).byteLength;
7907
7969
  session.scrollback.push(str);
7908
7970
  while (session.scrollbackBytes > MAX_SCROLLBACK_BYTES && session.scrollback.length > 0) {
7909
7971
  const removed = session.scrollback.shift();
7910
- session.scrollbackBytes -= encoder.encode(removed).byteLength;
7972
+ session.scrollbackBytes -= textEncoder.encode(removed).byteLength;
7911
7973
  }
7912
7974
  session.onData?.(str);
7913
7975
  }
@@ -7924,7 +7986,7 @@ async function attach(worktreeName, cols, rows, initialPane) {
7924
7986
  const { done, value } = await reader.read();
7925
7987
  if (done)
7926
7988
  break;
7927
- log.debug(`[term] stderr(${worktreeName}): ${new TextDecoder().decode(value).trimEnd()}`);
7989
+ log.debug(`[term] stderr(${worktreeName}): ${textDecoder.decode(value).trimEnd()}`);
7928
7990
  }
7929
7991
  } catch {}
7930
7992
  })();
@@ -7959,7 +8021,7 @@ function write(worktreeName, data) {
7959
8021
  return;
7960
8022
  }
7961
8023
  try {
7962
- session.proc.stdin.write(new TextEncoder().encode(data));
8024
+ session.proc.stdin.write(textEncoder.encode(data));
7963
8025
  session.proc.stdin.flush();
7964
8026
  } catch (err) {
7965
8027
  log.error(`[term] write(${worktreeName}) stdin closed`, err);
@@ -8043,6 +8105,20 @@ function mapChecks(checks) {
8043
8105
  runId: parseRunId(c.detailsUrl)
8044
8106
  }));
8045
8107
  }
8108
+ function parseReviewComments(json) {
8109
+ const raw = JSON.parse(json);
8110
+ const sorted = raw.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
8111
+ return sorted.slice(0, PR_FETCH_LIMIT).map((c) => ({
8112
+ type: "inline",
8113
+ author: c.user?.login ?? "unknown",
8114
+ body: c.body ?? "",
8115
+ createdAt: c.created_at ?? "",
8116
+ path: c.path ?? "",
8117
+ line: c.line ?? null,
8118
+ diffHunk: c.diff_hunk ?? "",
8119
+ isReply: c.in_reply_to_id !== undefined
8120
+ }));
8121
+ }
8046
8122
  function parsePrResponse(json, repoLabel) {
8047
8123
  const prs = new Map;
8048
8124
  const entries = JSON.parse(json);
@@ -8057,6 +8133,7 @@ function parsePrResponse(json, repoLabel) {
8057
8133
  ciStatus: summarizeChecks(entry.statusCheckRollup),
8058
8134
  ciChecks: mapChecks(entry.statusCheckRollup),
8059
8135
  comments: (entry.comments ?? []).map((c) => ({
8136
+ type: "comment",
8060
8137
  author: c.author?.login ?? "unknown",
8061
8138
  body: c.body ?? "",
8062
8139
  createdAt: c.createdAt ?? ""
@@ -8107,6 +8184,45 @@ async function fetchAllPrs(repoSlug, repoLabel, cwd) {
8107
8184
  return { ok: false, error: `failed to parse gh output for ${label}: ${err}` };
8108
8185
  }
8109
8186
  }
8187
+ async function mapWithConcurrency(items, limit, fn) {
8188
+ const results = new Array(items.length);
8189
+ let next = 0;
8190
+ async function worker() {
8191
+ while (next < items.length) {
8192
+ const idx = next++;
8193
+ results[idx] = await fn(items[idx]);
8194
+ }
8195
+ }
8196
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
8197
+ return results;
8198
+ }
8199
+ async function fetchReviewComments(prNumber, repoSlug, cwd) {
8200
+ const repoFlag = repoSlug ? repoSlug : "{owner}/{repo}";
8201
+ const args = [
8202
+ "gh",
8203
+ "api",
8204
+ `repos/${repoFlag}/pulls/${prNumber}/comments`,
8205
+ "--paginate"
8206
+ ];
8207
+ const proc = Bun.spawn(args, {
8208
+ stdout: "pipe",
8209
+ stderr: "pipe",
8210
+ ...cwd ? { cwd } : {}
8211
+ });
8212
+ const timeout = Bun.sleep(GH_TIMEOUT_MS).then(() => {
8213
+ proc.kill();
8214
+ return "timeout";
8215
+ });
8216
+ const raceResult = await Promise.race([proc.exited, timeout]);
8217
+ if (raceResult === "timeout" || raceResult !== 0)
8218
+ return [];
8219
+ try {
8220
+ const json = await new Response(proc.stdout).text();
8221
+ return parseReviewComments(json);
8222
+ } catch {
8223
+ return [];
8224
+ }
8225
+ }
8110
8226
  async function fetchPrState(url) {
8111
8227
  const proc = Bun.spawn(["gh", "pr", "view", url, "--json", "state"], {
8112
8228
  stdout: "pipe",
@@ -8163,6 +8279,22 @@ async function syncPrStatus(getWorktreePaths, linkedRepos, projectDir) {
8163
8279
  branchPrs.set(branch, existing);
8164
8280
  }
8165
8281
  }
8282
+ const reviewTuples = [];
8283
+ for (const entries of branchPrs.values()) {
8284
+ for (const entry of entries) {
8285
+ if (entry.state === "open") {
8286
+ const repoSlug = entry.repo ? linkedRepos.find((lr) => lr.alias === entry.repo)?.repo : undefined;
8287
+ reviewTuples.push({ entry, repoSlug });
8288
+ }
8289
+ }
8290
+ }
8291
+ if (reviewTuples.length > 0) {
8292
+ const reviewResults = await mapWithConcurrency(reviewTuples, 5, (t) => fetchReviewComments(t.entry.number, t.repoSlug, projectDir));
8293
+ for (let i = 0;i < reviewTuples.length; i++) {
8294
+ const entry = reviewTuples[i].entry;
8295
+ entry.comments = [...entry.comments, ...reviewResults[i]].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
8296
+ }
8297
+ }
8166
8298
  const wtPaths = await getWorktreePaths();
8167
8299
  const seen = new Set;
8168
8300
  for (const [branch, entries] of branchPrs) {
@@ -8208,6 +8340,169 @@ function errorResponse(message, status = 500) {
8208
8340
  return jsonResponse({ error: message }, status);
8209
8341
  }
8210
8342
 
8343
+ // backend/src/notifications.ts
8344
+ import { mkdir as mkdir3, chmod as chmod2 } from "fs/promises";
8345
+ var nextId = 1;
8346
+ var notifications = [];
8347
+ var sseClients = new Set;
8348
+ function formatSse(event, data) {
8349
+ return new TextEncoder().encode(`event: ${event}
8350
+ data: ${JSON.stringify(data)}
8351
+
8352
+ `);
8353
+ }
8354
+ function broadcast(event) {
8355
+ const encoded = event.kind === "notification" ? formatSse("notification", event.data) : formatSse("dismiss", { id: event.id });
8356
+ for (const controller of sseClients) {
8357
+ try {
8358
+ controller.enqueue(encoded);
8359
+ } catch {
8360
+ sseClients.delete(controller);
8361
+ }
8362
+ }
8363
+ }
8364
+ function addNotification(branch, type, url) {
8365
+ const message = type === "agent_stopped" ? `Agent stopped on ${branch}` : `PR opened on ${branch}`;
8366
+ const notification = {
8367
+ id: nextId++,
8368
+ branch,
8369
+ type,
8370
+ message,
8371
+ url,
8372
+ timestamp: Date.now()
8373
+ };
8374
+ notifications.push(notification);
8375
+ if (notifications.length > 50)
8376
+ notifications.shift();
8377
+ log.info(`[notify] ${type} branch=${branch}${url ? ` url=${url}` : ""}`);
8378
+ broadcast({ kind: "notification", data: notification });
8379
+ return notification;
8380
+ }
8381
+ function dismissNotification(id) {
8382
+ const idx = notifications.findIndex((n) => n.id === id);
8383
+ if (idx === -1)
8384
+ return false;
8385
+ notifications.splice(idx, 1);
8386
+ broadcast({ kind: "dismiss", id });
8387
+ return true;
8388
+ }
8389
+ function handleNotificationStream() {
8390
+ let ctrl;
8391
+ const stream = new ReadableStream({
8392
+ start(controller) {
8393
+ ctrl = controller;
8394
+ sseClients.add(controller);
8395
+ for (const n of notifications) {
8396
+ controller.enqueue(formatSse("initial", n));
8397
+ }
8398
+ },
8399
+ cancel() {
8400
+ sseClients.delete(ctrl);
8401
+ }
8402
+ });
8403
+ return new Response(stream, {
8404
+ headers: {
8405
+ "Content-Type": "text/event-stream",
8406
+ "Cache-Control": "no-cache",
8407
+ Connection: "keep-alive"
8408
+ }
8409
+ });
8410
+ }
8411
+ function handleDismissNotification(id) {
8412
+ const ok = dismissNotification(id);
8413
+ if (!ok) {
8414
+ return new Response(JSON.stringify({ error: "Not found" }), {
8415
+ status: 404,
8416
+ headers: { "Content-Type": "application/json" }
8417
+ });
8418
+ }
8419
+ return new Response(JSON.stringify({ ok: true }), {
8420
+ headers: { "Content-Type": "application/json" }
8421
+ });
8422
+ }
8423
+ var HOOKS_DIR = `${Bun.env.HOME ?? "/root"}/.config/workmux/hooks`;
8424
+ var NOTIFY_STOP_SH = `#!/usr/bin/env bash
8425
+ # Claude Code Stop hook \u2014 notifies workmux backend that an agent stopped.
8426
+ set -euo pipefail
8427
+
8428
+ # Read hook input from stdin
8429
+ INPUT=$(cat)
8430
+
8431
+ # Auth: token from env or secret file
8432
+ TOKEN="\${WORKMUX_RPC_TOKEN:-}"
8433
+ if [ -z "$TOKEN" ] && [ -f "\${HOME}/.config/workmux/rpc-secret" ]; then
8434
+ TOKEN=$(cat "\${HOME}/.config/workmux/rpc-secret")
8435
+ fi
8436
+ [ -z "$TOKEN" ] && exit 0
8437
+
8438
+ PORT="\${WORKMUX_RPC_PORT:-5111}"
8439
+
8440
+ # Extract branch from cwd field: .../__worktrees/<branch>
8441
+ CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
8442
+ [ -z "$CWD" ] && exit 0
8443
+ BRANCH=$(echo "$CWD" | grep -oP '__worktrees/\\K[^/]+' || true)
8444
+ [ -z "$BRANCH" ] && exit 0
8445
+
8446
+ PAYLOAD=$(jq -n --arg branch "$BRANCH" '{"command":"notify","branch":$branch,"args":["agent_stopped"]}')
8447
+
8448
+ curl -sf -X POST "http://127.0.0.1:\${PORT}/rpc/workmux" \\
8449
+ -H "Authorization: Bearer $TOKEN" \\
8450
+ -H "Content-Type: application/json" \\
8451
+ -d "$PAYLOAD" \\
8452
+ >/dev/null 2>&1 || true
8453
+ `;
8454
+ var NOTIFY_PR_SH = `#!/usr/bin/env bash
8455
+ # Claude Code PostToolUse hook \u2014 notifies workmux backend when a PR is opened.
8456
+ set -euo pipefail
8457
+
8458
+ # Read hook input from stdin
8459
+ INPUT=$(cat)
8460
+
8461
+ # Only trigger on Bash tool calls containing "gh pr create"
8462
+ TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
8463
+ echo "$TOOL_INPUT" | grep -q 'gh pr create' || exit 0
8464
+
8465
+ # Auth: token from env or secret file
8466
+ TOKEN="\${WORKMUX_RPC_TOKEN:-}"
8467
+ if [ -z "$TOKEN" ] && [ -f "\${HOME}/.config/workmux/rpc-secret" ]; then
8468
+ TOKEN=$(cat "\${HOME}/.config/workmux/rpc-secret")
8469
+ fi
8470
+ [ -z "$TOKEN" ] && exit 0
8471
+
8472
+ PORT="\${WORKMUX_RPC_PORT:-5111}"
8473
+
8474
+ # Extract branch from cwd
8475
+ CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
8476
+ [ -z "$CWD" ] && exit 0
8477
+ BRANCH=$(echo "$CWD" | grep -oP '__worktrees/\\K[^/]+' || true)
8478
+ [ -z "$BRANCH" ] && exit 0
8479
+
8480
+ # Extract PR URL from tool response (gh pr create outputs the URL)
8481
+ PR_URL=$(echo "$INPUT" | jq -r '.tool_response // empty' | grep -oP 'https://github\\.com/[^\\s"]+/pull/\\d+' | head -1 || true)
8482
+
8483
+ if [ -n "$PR_URL" ]; then
8484
+ PAYLOAD=$(jq -n --arg branch "$BRANCH" --arg url "$PR_URL" '{"command":"notify","branch":$branch,"args":["pr_opened",$url]}')
8485
+ else
8486
+ PAYLOAD=$(jq -n --arg branch "$BRANCH" '{"command":"notify","branch":$branch,"args":["pr_opened"]}')
8487
+ fi
8488
+
8489
+ curl -sf -X POST "http://127.0.0.1:\${PORT}/rpc/workmux" \\
8490
+ -H "Authorization: Bearer $TOKEN" \\
8491
+ -H "Content-Type: application/json" \\
8492
+ -d "$PAYLOAD" \\
8493
+ >/dev/null 2>&1 || true
8494
+ `;
8495
+ async function installHookScripts() {
8496
+ await mkdir3(HOOKS_DIR, { recursive: true });
8497
+ const stopPath = `${HOOKS_DIR}/notify-stop.sh`;
8498
+ const prPath = `${HOOKS_DIR}/notify-pr.sh`;
8499
+ await Bun.write(stopPath, NOTIFY_STOP_SH);
8500
+ await Bun.write(prPath, NOTIFY_PR_SH);
8501
+ await chmod2(stopPath, 493);
8502
+ await chmod2(prPath, 493);
8503
+ log.info(`[notify] installed hook scripts in ${HOOKS_DIR}`);
8504
+ }
8505
+
8211
8506
  // backend/src/rpc.ts
8212
8507
  function tmuxEnv() {
8213
8508
  if (Bun.env.TMUX)
@@ -8251,6 +8546,14 @@ async function handleWorkmuxRpc(req) {
8251
8546
  if (!command) {
8252
8547
  return jsonResponse({ ok: false, error: "Missing command" }, 400);
8253
8548
  }
8549
+ if (command === "notify" && branch) {
8550
+ const [type, url] = args;
8551
+ if (type === "agent_stopped" || type === "pr_opened") {
8552
+ addNotification(branch, type, url);
8553
+ return jsonResponse({ ok: true, output: "ok" });
8554
+ }
8555
+ return jsonResponse({ ok: false, error: `Unknown notification type: ${type}` }, 400);
8556
+ }
8254
8557
  try {
8255
8558
  const env = tmuxEnv();
8256
8559
  if (command === "set-window-status" && branch) {
@@ -8279,11 +8582,130 @@ async function handleWorkmuxRpc(req) {
8279
8582
  }
8280
8583
  }
8281
8584
 
8585
+ // backend/src/linear.ts
8586
+ var ASSIGNED_ISSUES_QUERY = `
8587
+ query AssignedIssues {
8588
+ viewer {
8589
+ assignedIssues(
8590
+ filter: { state: { type: { nin: ["completed", "canceled"] } } }
8591
+ orderBy: updatedAt
8592
+ first: 50
8593
+ ) {
8594
+ nodes {
8595
+ id
8596
+ identifier
8597
+ title
8598
+ description
8599
+ priority
8600
+ priorityLabel
8601
+ url
8602
+ branchName
8603
+ dueDate
8604
+ updatedAt
8605
+ state { name color type }
8606
+ team { name key }
8607
+ labels { nodes { name color } }
8608
+ project { name }
8609
+ }
8610
+ }
8611
+ }
8612
+ }
8613
+ `;
8614
+ function parseIssuesResponse(raw) {
8615
+ if (raw.errors && raw.errors.length > 0) {
8616
+ return { ok: false, error: raw.errors.map((e) => e.message).join("; ") };
8617
+ }
8618
+ if (!raw.data) {
8619
+ return { ok: false, error: "No data in response" };
8620
+ }
8621
+ const nodes = raw.data.viewer.assignedIssues.nodes;
8622
+ const issues = nodes.map((n) => ({
8623
+ id: n.id,
8624
+ identifier: n.identifier,
8625
+ title: n.title,
8626
+ description: n.description,
8627
+ priority: n.priority,
8628
+ priorityLabel: n.priorityLabel,
8629
+ url: n.url,
8630
+ branchName: n.branchName,
8631
+ dueDate: n.dueDate,
8632
+ updatedAt: n.updatedAt,
8633
+ state: n.state,
8634
+ team: n.team,
8635
+ labels: n.labels.nodes,
8636
+ project: n.project?.name ?? null
8637
+ }));
8638
+ return { ok: true, data: issues };
8639
+ }
8640
+ function branchMatchesIssue(worktreeBranch, issueBranchName) {
8641
+ if (!worktreeBranch || !issueBranchName)
8642
+ return false;
8643
+ if (worktreeBranch === issueBranchName)
8644
+ return true;
8645
+ const issueSlashIdx = issueBranchName.indexOf("/");
8646
+ if (issueSlashIdx !== -1) {
8647
+ const suffix = issueBranchName.slice(issueSlashIdx + 1);
8648
+ if (worktreeBranch === suffix)
8649
+ return true;
8650
+ }
8651
+ const wtSlashIdx = worktreeBranch.indexOf("/");
8652
+ if (wtSlashIdx !== -1) {
8653
+ const wtSuffix = worktreeBranch.slice(wtSlashIdx + 1);
8654
+ if (wtSuffix === issueBranchName)
8655
+ return true;
8656
+ if (issueSlashIdx !== -1 && wtSuffix === issueBranchName.slice(issueSlashIdx + 1))
8657
+ return true;
8658
+ }
8659
+ return false;
8660
+ }
8661
+ var CACHE_TTL_MS = 300000;
8662
+ var issueCache = null;
8663
+ async function fetchAssignedIssues() {
8664
+ const apiKey = Bun.env.LINEAR_API_KEY;
8665
+ if (!apiKey) {
8666
+ return { ok: false, error: "LINEAR_API_KEY not set" };
8667
+ }
8668
+ const now = Date.now();
8669
+ if (issueCache && now < issueCache.expiry) {
8670
+ return issueCache.data;
8671
+ }
8672
+ try {
8673
+ const res = await fetch("https://api.linear.app/graphql", {
8674
+ method: "POST",
8675
+ headers: {
8676
+ "Content-Type": "application/json",
8677
+ Authorization: apiKey
8678
+ },
8679
+ body: JSON.stringify({ query: ASSIGNED_ISSUES_QUERY })
8680
+ });
8681
+ if (!res.ok) {
8682
+ const text = await res.text();
8683
+ const result2 = { ok: false, error: `Linear API ${res.status}: ${text.slice(0, 200)}` };
8684
+ return result2;
8685
+ }
8686
+ const json = await res.json();
8687
+ const result = parseIssuesResponse(json);
8688
+ if (result.ok) {
8689
+ issueCache = { data: result, expiry: now + CACHE_TTL_MS };
8690
+ log.debug(`[linear] fetched ${result.data.length} assigned issues`);
8691
+ } else {
8692
+ log.error(`[linear] GraphQL error: ${result.error}`);
8693
+ }
8694
+ return result;
8695
+ } catch (err) {
8696
+ const msg = err instanceof Error ? err.message : String(err);
8697
+ log.error(`[linear] fetch failed: ${msg}`);
8698
+ return { ok: false, error: msg };
8699
+ }
8700
+ }
8701
+
8282
8702
  // backend/src/server.ts
8283
- var PORT = parseInt(Bun.env.DASHBOARD_PORT || "5111", 10);
8703
+ var PORT = parseInt(Bun.env.BACKEND_PORT || "5111", 10);
8284
8704
  var STATIC_DIR = Bun.env.WMDEV_STATIC_DIR || "";
8285
8705
  var PROJECT_DIR = Bun.env.WMDEV_PROJECT_DIR || gitRoot(process.cwd());
8286
8706
  var config = loadConfig(PROJECT_DIR);
8707
+ var WORKTREE_CACHE_TTL_MS = 2000;
8708
+ var wtCache = null;
8287
8709
  function parseWsMessage(raw) {
8288
8710
  try {
8289
8711
  const str = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
@@ -8311,7 +8733,16 @@ function parseWsMessage(raw) {
8311
8733
  }
8312
8734
  }
8313
8735
  function sendWs(ws, msg) {
8314
- ws.send(JSON.stringify(msg));
8736
+ switch (msg.type) {
8737
+ case "output":
8738
+ ws.send("o" + msg.data);
8739
+ break;
8740
+ case "scrollback":
8741
+ ws.send("s" + msg.data);
8742
+ break;
8743
+ default:
8744
+ ws.send(JSON.stringify(msg));
8745
+ }
8315
8746
  }
8316
8747
  function isValidWorktreeName(name) {
8317
8748
  return name.length > 0 && /^[a-z0-9][a-z0-9\-_./]*$/.test(name) && !name.includes("..");
@@ -8349,25 +8780,48 @@ async function getWorktreePaths() {
8349
8780
  }
8350
8781
  return paths;
8351
8782
  }
8352
- async function getTmuxPaneCount(branch) {
8353
- const proc = Bun.spawn(["tmux", "list-panes", "-t", `wm-${branch}`, "-F", "#{pane_index}"], { stdout: "pipe", stderr: "pipe" });
8354
- const exitCode = await proc.exited;
8355
- if (exitCode !== 0)
8356
- return 0;
8783
+ async function getAllPaneCounts() {
8784
+ const proc = Bun.spawn(["tmux", "list-windows", "-a", "-F", "#{window_name} #{window_panes}"], { stdout: "pipe", stderr: "pipe" });
8785
+ if (await proc.exited !== 0)
8786
+ return new Map;
8357
8787
  const out = await new Response(proc.stdout).text();
8358
- return out.trim().split(`
8359
- `).filter(Boolean).length;
8788
+ const counts = new Map;
8789
+ for (const line of out.trim().split(`
8790
+ `)) {
8791
+ const spaceIdx = line.lastIndexOf(" ");
8792
+ if (spaceIdx === -1)
8793
+ continue;
8794
+ const name = line.slice(0, spaceIdx);
8795
+ if (!name.startsWith("wm-"))
8796
+ continue;
8797
+ const branch = name.slice(3);
8798
+ const count = parseInt(line.slice(spaceIdx + 1), 10) || 0;
8799
+ if (!counts.has(branch) || count > counts.get(branch)) {
8800
+ counts.set(branch, count);
8801
+ }
8802
+ }
8803
+ return counts;
8360
8804
  }
8361
8805
  function isPortListening(port) {
8362
8806
  return new Promise((resolve2) => {
8363
- const timeout = setTimeout(() => {
8364
- resolve2(false);
8365
- }, 1000);
8366
- fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1000) }).then(() => {
8367
- clearTimeout(timeout);
8368
- resolve2(true);
8807
+ const timer = setTimeout(() => resolve2(false), 300);
8808
+ Bun.connect({
8809
+ hostname: "127.0.0.1",
8810
+ port,
8811
+ socket: {
8812
+ open(socket) {
8813
+ clearTimeout(timer);
8814
+ socket.end();
8815
+ resolve2(true);
8816
+ },
8817
+ error() {
8818
+ clearTimeout(timer);
8819
+ resolve2(false);
8820
+ },
8821
+ data() {}
8822
+ }
8369
8823
  }).catch(() => {
8370
- clearTimeout(timeout);
8824
+ clearTimeout(timer);
8371
8825
  resolve2(false);
8372
8826
  });
8373
8827
  });
@@ -8384,35 +8838,57 @@ function makeCallbacks(ws) {
8384
8838
  }
8385
8839
  };
8386
8840
  }
8387
- async function apiGetWorktrees() {
8388
- const [worktrees, status, wtPaths] = await Promise.all([
8841
+ async function apiGetWorktrees(req) {
8842
+ const now = Date.now();
8843
+ if (wtCache && now < wtCache.expiry) {
8844
+ if (req.headers.get("if-none-match") === wtCache.etag) {
8845
+ return new Response(null, { status: 304 });
8846
+ }
8847
+ return new Response(wtCache.json, {
8848
+ headers: { "Content-Type": "application/json", ETag: wtCache.etag }
8849
+ });
8850
+ }
8851
+ const [worktrees, status, wtPaths, paneCounts, linearResult] = await Promise.all([
8389
8852
  listWorktrees(),
8390
8853
  getStatus(),
8391
- getWorktreePaths()
8854
+ getWorktreePaths(),
8855
+ getAllPaneCounts(),
8856
+ fetchAssignedIssues()
8392
8857
  ]);
8858
+ const linearIssues = linearResult.ok ? linearResult.data : [];
8393
8859
  const merged = await Promise.all(worktrees.map(async (wt) => {
8394
8860
  const st = status.find((s) => s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch));
8395
8861
  const wtDir = wtPaths.get(wt.branch);
8396
8862
  const env = wtDir ? await readEnvLocal(wtDir) : {};
8863
+ const dirty = wtDir ? await checkDirty(wtDir) : false;
8397
8864
  const services = await Promise.all(config.services.map(async (svc) => {
8398
8865
  const port = env[svc.portEnv] ? parseInt(env[svc.portEnv], 10) : null;
8399
8866
  const running = port !== null && port >= 1 && port <= 65535 ? await isPortListening(port) : false;
8400
8867
  return { name: svc.name, port, running };
8401
8868
  }));
8869
+ const matchedIssue = linearIssues.find((issue) => branchMatchesIssue(wt.branch, issue.branchName));
8870
+ const linearIssue = matchedIssue ? { identifier: matchedIssue.identifier, url: matchedIssue.url, state: matchedIssue.state } : null;
8402
8871
  return {
8403
8872
  ...wt,
8404
8873
  dir: wtDir ?? null,
8874
+ dirty,
8405
8875
  status: st?.status ?? "",
8406
8876
  elapsed: st?.elapsed ?? "",
8407
8877
  title: st?.title ?? "",
8408
8878
  profile: env.PROFILE || null,
8409
8879
  agentName: env.AGENT || null,
8410
8880
  services,
8411
- paneCount: wt.mux === "\u2713" ? await getTmuxPaneCount(wt.branch) : 0,
8412
- prs: env.PR_DATA ? (safeJsonParse(env.PR_DATA) ?? []).map((pr) => ({ ...pr, comments: pr.comments ?? [] })) : []
8881
+ paneCount: wt.mux === "\u2713" ? paneCounts.get(wt.branch) ?? 0 : 0,
8882
+ prs: env.PR_DATA ? (safeJsonParse(env.PR_DATA) ?? []).map((pr) => ({ ...pr, comments: pr.comments ?? [] })) : [],
8883
+ linearIssue
8413
8884
  };
8414
8885
  }));
8415
- return jsonResponse(merged);
8886
+ const json = JSON.stringify(merged);
8887
+ const etag = `"${Bun.hash(json).toString(36)}"`;
8888
+ wtCache = { json, etag, expiry: now + WORKTREE_CACHE_TTL_MS };
8889
+ return new Response(json, {
8890
+ headers: { "Content-Type": "application/json", ETag: etag }
8891
+ });
8416
8892
  }
8417
8893
  async function apiCreateWorktree(req) {
8418
8894
  const raw = await req.json();
@@ -8441,6 +8917,7 @@ async function apiCreateWorktree(req) {
8441
8917
  if (!result.ok)
8442
8918
  return errorResponse(result.error, 422);
8443
8919
  log.debug(`[worktree:add] done branch=${result.branch}: ${result.output}`);
8920
+ wtCache = null;
8444
8921
  return jsonResponse({ branch: result.branch }, 201);
8445
8922
  }
8446
8923
  async function apiDeleteWorktree(name) {
@@ -8449,6 +8926,7 @@ async function apiDeleteWorktree(name) {
8449
8926
  if (!result.ok)
8450
8927
  return errorResponse(result.error, 422);
8451
8928
  log.debug(`[worktree:rm] done name=${name}: ${result.output}`);
8929
+ wtCache = null;
8452
8930
  return jsonResponse({ message: result.output });
8453
8931
  }
8454
8932
  async function apiOpenWorktree(name) {
@@ -8480,6 +8958,7 @@ async function apiMergeWorktree(name) {
8480
8958
  if (!result.ok)
8481
8959
  return errorResponse(result.error, 422);
8482
8960
  log.debug(`[worktree:merge] done name=${name}: ${result.output}`);
8961
+ wtCache = null;
8483
8962
  return jsonResponse({ message: result.output });
8484
8963
  }
8485
8964
  async function apiWorktreeStatus(name) {
@@ -8489,6 +8968,12 @@ async function apiWorktreeStatus(name) {
8489
8968
  return errorResponse("Worktree status not found", 404);
8490
8969
  return jsonResponse(match);
8491
8970
  }
8971
+ async function apiGetLinearIssues() {
8972
+ const result = await fetchAssignedIssues();
8973
+ if (!result.ok)
8974
+ return errorResponse(result.error, 502);
8975
+ return jsonResponse(result.data);
8976
+ }
8492
8977
  async function apiCiLogs(runId) {
8493
8978
  if (!/^\d+$/.test(runId))
8494
8979
  return errorResponse("Invalid run ID", 400);
@@ -8519,7 +9004,7 @@ Bun.serve({
8519
9004
  GET: () => jsonResponse(config)
8520
9005
  },
8521
9006
  "/api/worktrees": {
8522
- GET: () => catching("GET /api/worktrees", apiGetWorktrees),
9007
+ GET: (req) => catching("GET /api/worktrees", () => apiGetWorktrees(req)),
8523
9008
  POST: (req) => catching("POST /api/worktrees", () => apiCreateWorktree(req))
8524
9009
  },
8525
9010
  "/api/worktrees/:name": {
@@ -8562,8 +9047,22 @@ Bun.serve({
8562
9047
  return catching(`GET /api/worktrees/${name}/status`, () => apiWorktreeStatus(name));
8563
9048
  }
8564
9049
  },
9050
+ "/api/linear/issues": {
9051
+ GET: () => catching("GET /api/linear/issues", () => apiGetLinearIssues())
9052
+ },
8565
9053
  "/api/ci-logs/:runId": {
8566
9054
  GET: (req) => catching(`GET /api/ci-logs/${req.params.runId}`, () => apiCiLogs(req.params.runId))
9055
+ },
9056
+ "/api/notifications/stream": {
9057
+ GET: () => handleNotificationStream()
9058
+ },
9059
+ "/api/notifications/:id/dismiss": {
9060
+ POST: (req) => {
9061
+ const id = parseInt(req.params.id, 10);
9062
+ if (isNaN(id))
9063
+ return errorResponse("Invalid notification ID", 400);
9064
+ return handleDismissNotification(id);
9065
+ }
8567
9066
  }
8568
9067
  },
8569
9068
  async fetch(req) {
@@ -8577,9 +9076,12 @@ Bun.serve({
8577
9076
  }
8578
9077
  const file = Bun.file(filePath);
8579
9078
  if (await file.exists()) {
8580
- return new Response(file);
9079
+ const headers = rawPath.startsWith("/assets/") ? { "Cache-Control": "public, max-age=31536000, immutable" } : {};
9080
+ return new Response(file, { headers });
8581
9081
  }
8582
- return new Response(Bun.file(join2(STATIC_DIR, "index.html")));
9082
+ return new Response(Bun.file(join2(STATIC_DIR, "index.html")), {
9083
+ headers: { "Cache-Control": "no-cache" }
9084
+ });
8583
9085
  }
8584
9086
  return new Response("Not Found", { status: 404 });
8585
9087
  },
@@ -8647,6 +9149,9 @@ if (tmuxCheck.exitCode !== 0) {
8647
9149
  }
8648
9150
  cleanupStaleSessions();
8649
9151
  startPrMonitor(getWorktreePaths, config.linkedRepos, PROJECT_DIR);
9152
+ installHookScripts().catch((err) => {
9153
+ log.error(`[notify] failed to install hook scripts: ${err instanceof Error ? err.message : String(err)}`);
9154
+ });
8650
9155
  log.info(`Dev Dashboard API running at http://localhost:${PORT}`);
8651
9156
  var nets = networkInterfaces();
8652
9157
  for (const addrs of Object.values(nets)) {