wmdev 0.2.3 → 0.3.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) {
@@ -6970,14 +6971,8 @@ async function writeEnvLocal(wtDir, entries) {
6970
6971
  `) + `
6971
6972
  `);
6972
6973
  }
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;
6974
+ function readAllWorktreeEnvs(worktreePaths, excludeDir) {
6975
+ return Promise.all(worktreePaths.filter((p) => !excludeDir || p !== excludeDir).map((p) => readEnvLocal(p)));
6981
6976
  }
6982
6977
  function allocatePorts(existingEnvs, services) {
6983
6978
  const allocatable = services.filter((s) => s.portStart != null);
@@ -7332,7 +7327,7 @@ async function launchContainer(opts) {
7332
7327
  const name = containerName(branch);
7333
7328
  const home = Bun.env.HOME ?? "/root";
7334
7329
  const rpcSecret = await loadRpcSecret();
7335
- const rpcPort = Bun.env.DASHBOARD_PORT ?? "5111";
7330
+ const rpcPort = Bun.env.BACKEND_PORT ?? "5111";
7336
7331
  let sshAuthSock = Bun.env.SSH_AUTH_SOCK;
7337
7332
  if (sshAuthSock) {
7338
7333
  try {
@@ -7488,19 +7483,30 @@ function workmuxEnv() {
7488
7483
  const uid = process.getuid?.() ?? 1000;
7489
7484
  return { ...process.env, TMUX: `${tmpdir}/tmux-${uid}/default,0,0` };
7490
7485
  }
7486
+ function resolveDetachedBranch(branch, path) {
7487
+ if (branch !== "(detached)" || !path)
7488
+ return branch;
7489
+ return path.split("/").pop() || branch;
7490
+ }
7491
7491
  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);
7492
+ const proc = await $`workmux list`.env(workmuxEnv()).nothrow().quiet();
7493
+ if (proc.exitCode !== 0) {
7494
+ ensureTmux();
7495
+ return [];
7496
+ }
7497
+ return parseTable(proc.text(), (cols) => {
7498
+ const branch = resolveDetachedBranch(cols[0] ?? "", cols[4] ?? "");
7499
+ const path = cols[4] ?? "";
7500
+ return { branch, agent: cols[1] ?? "", mux: cols[2] ?? "", unmerged: cols[3] ?? "", path };
7501
+ }, WORKTREE_HEADERS);
7500
7502
  }
7501
7503
  async function getStatus() {
7502
- const result = await $`workmux status`.env(workmuxEnv()).text();
7503
- return parseTable(result, (cols) => ({
7504
+ const proc = await $`workmux status`.env(workmuxEnv()).nothrow().quiet();
7505
+ if (proc.exitCode !== 0) {
7506
+ ensureTmux();
7507
+ return [];
7508
+ }
7509
+ return parseTable(proc.text(), (cols) => ({
7504
7510
  worktree: cols[0] ?? "",
7505
7511
  status: cols[1] ?? "",
7506
7512
  elapsed: cols[2] ?? "",
@@ -7645,6 +7651,26 @@ async function addWorktree(rawBranch, opts) {
7645
7651
  const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
7646
7652
  const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
7647
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
+ `);
7648
7674
  }
7649
7675
  const env = wtDir ? await readEnvLocal(wtDir) : {};
7650
7676
  log.debug(`[workmux:add] branch=${branch} dir=${wtDir ?? "(not found)"} env=${JSON.stringify(env)}`);
@@ -7766,6 +7792,33 @@ async function openWorktree(name) {
7766
7792
  return result;
7767
7793
  return { ok: true, output: result.stdout };
7768
7794
  }
7795
+ async function checkDirty(dir) {
7796
+ const [status, ahead] = await Promise.all([
7797
+ (async () => {
7798
+ const proc = Bun.spawn(["git", "status", "--porcelain"], {
7799
+ cwd: dir,
7800
+ stdout: "pipe",
7801
+ stderr: "pipe"
7802
+ });
7803
+ const output = await new Response(proc.stdout).text();
7804
+ await proc.exited;
7805
+ return output.trim().length > 0;
7806
+ })(),
7807
+ (async () => {
7808
+ const proc = Bun.spawn(["git", "rev-list", "--count", "@{u}..HEAD"], {
7809
+ cwd: dir,
7810
+ stdout: "pipe",
7811
+ stderr: "pipe"
7812
+ });
7813
+ const output = await new Response(proc.stdout).text();
7814
+ const exitCode = await proc.exited;
7815
+ if (exitCode !== 0)
7816
+ return false;
7817
+ return (parseInt(output.trim(), 10) || 0) > 0;
7818
+ })()
7819
+ ]);
7820
+ return status || ahead;
7821
+ }
7769
7822
  async function mergeWorktree(name) {
7770
7823
  log.debug(`[workmux:merge] running: workmux merge ${name}`);
7771
7824
  await removeContainer(name);
@@ -7776,7 +7829,9 @@ async function mergeWorktree(name) {
7776
7829
  }
7777
7830
 
7778
7831
  // backend/src/terminal.ts
7779
- var DASH_PORT = Bun.env.DASHBOARD_PORT || "5111";
7832
+ var textDecoder = new TextDecoder;
7833
+ var textEncoder = new TextEncoder;
7834
+ var DASH_PORT = Bun.env.BACKEND_PORT || "5111";
7780
7835
  var SESSION_PREFIX = `wm-dash-${DASH_PORT}-`;
7781
7836
  var MAX_SCROLLBACK_BYTES = 1 * 1024 * 1024;
7782
7837
  var sessions = new Map;
@@ -7811,7 +7866,7 @@ function cleanupStaleSessions() {
7811
7866
  const result = Bun.spawnSync(["tmux", "list-sessions", "-F", "#{session_name}"], { stdout: "pipe", stderr: "pipe" });
7812
7867
  if (result.exitCode !== 0)
7813
7868
  return;
7814
- const lines = new TextDecoder().decode(result.stdout).trim().split(`
7869
+ const lines = textDecoder.decode(result.stdout).trim().split(`
7815
7870
  `);
7816
7871
  for (const name of lines) {
7817
7872
  if (name.startsWith(SESSION_PREFIX)) {
@@ -7823,7 +7878,7 @@ function cleanupStaleSessions() {
7823
7878
  function killTmuxSession(name) {
7824
7879
  const result = Bun.spawnSync(["tmux", "kill-session", "-t", name], { stderr: "pipe" });
7825
7880
  if (result.exitCode !== 0) {
7826
- const stderr = new TextDecoder().decode(result.stderr).trim();
7881
+ const stderr = textDecoder.decode(result.stderr).trim();
7827
7882
  if (!stderr.includes("can't find session")) {
7828
7883
  log.warn(`[term] killTmuxSession(${name}) exit=${result.exitCode} ${stderr}`);
7829
7884
  }
@@ -7901,13 +7956,12 @@ async function attach(worktreeName, cols, rows, initialPane) {
7901
7956
  const { done, value } = await reader.read();
7902
7957
  if (done)
7903
7958
  break;
7904
- const str = new TextDecoder().decode(value);
7905
- const encoder = new TextEncoder;
7906
- session.scrollbackBytes += encoder.encode(str).byteLength;
7959
+ const str = textDecoder.decode(value);
7960
+ session.scrollbackBytes += textEncoder.encode(str).byteLength;
7907
7961
  session.scrollback.push(str);
7908
7962
  while (session.scrollbackBytes > MAX_SCROLLBACK_BYTES && session.scrollback.length > 0) {
7909
7963
  const removed = session.scrollback.shift();
7910
- session.scrollbackBytes -= encoder.encode(removed).byteLength;
7964
+ session.scrollbackBytes -= textEncoder.encode(removed).byteLength;
7911
7965
  }
7912
7966
  session.onData?.(str);
7913
7967
  }
@@ -7924,7 +7978,7 @@ async function attach(worktreeName, cols, rows, initialPane) {
7924
7978
  const { done, value } = await reader.read();
7925
7979
  if (done)
7926
7980
  break;
7927
- log.debug(`[term] stderr(${worktreeName}): ${new TextDecoder().decode(value).trimEnd()}`);
7981
+ log.debug(`[term] stderr(${worktreeName}): ${textDecoder.decode(value).trimEnd()}`);
7928
7982
  }
7929
7983
  } catch {}
7930
7984
  })();
@@ -7959,7 +8013,7 @@ function write(worktreeName, data) {
7959
8013
  return;
7960
8014
  }
7961
8015
  try {
7962
- session.proc.stdin.write(new TextEncoder().encode(data));
8016
+ session.proc.stdin.write(textEncoder.encode(data));
7963
8017
  session.proc.stdin.flush();
7964
8018
  } catch (err) {
7965
8019
  log.error(`[term] write(${worktreeName}) stdin closed`, err);
@@ -8208,6 +8262,169 @@ function errorResponse(message, status = 500) {
8208
8262
  return jsonResponse({ error: message }, status);
8209
8263
  }
8210
8264
 
8265
+ // backend/src/notifications.ts
8266
+ import { mkdir as mkdir3, chmod as chmod2 } from "fs/promises";
8267
+ var nextId = 1;
8268
+ var notifications = [];
8269
+ var sseClients = new Set;
8270
+ function formatSse(event, data) {
8271
+ return new TextEncoder().encode(`event: ${event}
8272
+ data: ${JSON.stringify(data)}
8273
+
8274
+ `);
8275
+ }
8276
+ function broadcast(event) {
8277
+ const encoded = event.kind === "notification" ? formatSse("notification", event.data) : formatSse("dismiss", { id: event.id });
8278
+ for (const controller of sseClients) {
8279
+ try {
8280
+ controller.enqueue(encoded);
8281
+ } catch {
8282
+ sseClients.delete(controller);
8283
+ }
8284
+ }
8285
+ }
8286
+ function addNotification(branch, type, url) {
8287
+ const message = type === "agent_stopped" ? `Agent stopped on ${branch}` : `PR opened on ${branch}`;
8288
+ const notification = {
8289
+ id: nextId++,
8290
+ branch,
8291
+ type,
8292
+ message,
8293
+ url,
8294
+ timestamp: Date.now()
8295
+ };
8296
+ notifications.push(notification);
8297
+ if (notifications.length > 50)
8298
+ notifications.shift();
8299
+ log.info(`[notify] ${type} branch=${branch}${url ? ` url=${url}` : ""}`);
8300
+ broadcast({ kind: "notification", data: notification });
8301
+ return notification;
8302
+ }
8303
+ function dismissNotification(id) {
8304
+ const idx = notifications.findIndex((n) => n.id === id);
8305
+ if (idx === -1)
8306
+ return false;
8307
+ notifications.splice(idx, 1);
8308
+ broadcast({ kind: "dismiss", id });
8309
+ return true;
8310
+ }
8311
+ function handleNotificationStream() {
8312
+ let ctrl;
8313
+ const stream = new ReadableStream({
8314
+ start(controller) {
8315
+ ctrl = controller;
8316
+ sseClients.add(controller);
8317
+ for (const n of notifications) {
8318
+ controller.enqueue(formatSse("initial", n));
8319
+ }
8320
+ },
8321
+ cancel() {
8322
+ sseClients.delete(ctrl);
8323
+ }
8324
+ });
8325
+ return new Response(stream, {
8326
+ headers: {
8327
+ "Content-Type": "text/event-stream",
8328
+ "Cache-Control": "no-cache",
8329
+ Connection: "keep-alive"
8330
+ }
8331
+ });
8332
+ }
8333
+ function handleDismissNotification(id) {
8334
+ const ok = dismissNotification(id);
8335
+ if (!ok) {
8336
+ return new Response(JSON.stringify({ error: "Not found" }), {
8337
+ status: 404,
8338
+ headers: { "Content-Type": "application/json" }
8339
+ });
8340
+ }
8341
+ return new Response(JSON.stringify({ ok: true }), {
8342
+ headers: { "Content-Type": "application/json" }
8343
+ });
8344
+ }
8345
+ var HOOKS_DIR = `${Bun.env.HOME ?? "/root"}/.config/workmux/hooks`;
8346
+ var NOTIFY_STOP_SH = `#!/usr/bin/env bash
8347
+ # Claude Code Stop hook \u2014 notifies workmux backend that an agent stopped.
8348
+ set -euo pipefail
8349
+
8350
+ # Read hook input from stdin
8351
+ INPUT=$(cat)
8352
+
8353
+ # Auth: token from env or secret file
8354
+ TOKEN="\${WORKMUX_RPC_TOKEN:-}"
8355
+ if [ -z "$TOKEN" ] && [ -f "\${HOME}/.config/workmux/rpc-secret" ]; then
8356
+ TOKEN=$(cat "\${HOME}/.config/workmux/rpc-secret")
8357
+ fi
8358
+ [ -z "$TOKEN" ] && exit 0
8359
+
8360
+ PORT="\${WORKMUX_RPC_PORT:-5111}"
8361
+
8362
+ # Extract branch from cwd field: .../__worktrees/<branch>
8363
+ CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
8364
+ [ -z "$CWD" ] && exit 0
8365
+ BRANCH=$(echo "$CWD" | grep -oP '__worktrees/\\K[^/]+' || true)
8366
+ [ -z "$BRANCH" ] && exit 0
8367
+
8368
+ PAYLOAD=$(jq -n --arg branch "$BRANCH" '{"command":"notify","branch":$branch,"args":["agent_stopped"]}')
8369
+
8370
+ curl -sf -X POST "http://127.0.0.1:\${PORT}/rpc/workmux" \\
8371
+ -H "Authorization: Bearer $TOKEN" \\
8372
+ -H "Content-Type: application/json" \\
8373
+ -d "$PAYLOAD" \\
8374
+ >/dev/null 2>&1 || true
8375
+ `;
8376
+ var NOTIFY_PR_SH = `#!/usr/bin/env bash
8377
+ # Claude Code PostToolUse hook \u2014 notifies workmux backend when a PR is opened.
8378
+ set -euo pipefail
8379
+
8380
+ # Read hook input from stdin
8381
+ INPUT=$(cat)
8382
+
8383
+ # Only trigger on Bash tool calls containing "gh pr create"
8384
+ TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
8385
+ echo "$TOOL_INPUT" | grep -q 'gh pr create' || exit 0
8386
+
8387
+ # Auth: token from env or secret file
8388
+ TOKEN="\${WORKMUX_RPC_TOKEN:-}"
8389
+ if [ -z "$TOKEN" ] && [ -f "\${HOME}/.config/workmux/rpc-secret" ]; then
8390
+ TOKEN=$(cat "\${HOME}/.config/workmux/rpc-secret")
8391
+ fi
8392
+ [ -z "$TOKEN" ] && exit 0
8393
+
8394
+ PORT="\${WORKMUX_RPC_PORT:-5111}"
8395
+
8396
+ # Extract branch from cwd
8397
+ CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
8398
+ [ -z "$CWD" ] && exit 0
8399
+ BRANCH=$(echo "$CWD" | grep -oP '__worktrees/\\K[^/]+' || true)
8400
+ [ -z "$BRANCH" ] && exit 0
8401
+
8402
+ # Extract PR URL from tool response (gh pr create outputs the URL)
8403
+ PR_URL=$(echo "$INPUT" | jq -r '.tool_response // empty' | grep -oP 'https://github\\.com/[^\\s"]+/pull/\\d+' | head -1 || true)
8404
+
8405
+ if [ -n "$PR_URL" ]; then
8406
+ PAYLOAD=$(jq -n --arg branch "$BRANCH" --arg url "$PR_URL" '{"command":"notify","branch":$branch,"args":["pr_opened",$url]}')
8407
+ else
8408
+ PAYLOAD=$(jq -n --arg branch "$BRANCH" '{"command":"notify","branch":$branch,"args":["pr_opened"]}')
8409
+ fi
8410
+
8411
+ curl -sf -X POST "http://127.0.0.1:\${PORT}/rpc/workmux" \\
8412
+ -H "Authorization: Bearer $TOKEN" \\
8413
+ -H "Content-Type: application/json" \\
8414
+ -d "$PAYLOAD" \\
8415
+ >/dev/null 2>&1 || true
8416
+ `;
8417
+ async function installHookScripts() {
8418
+ await mkdir3(HOOKS_DIR, { recursive: true });
8419
+ const stopPath = `${HOOKS_DIR}/notify-stop.sh`;
8420
+ const prPath = `${HOOKS_DIR}/notify-pr.sh`;
8421
+ await Bun.write(stopPath, NOTIFY_STOP_SH);
8422
+ await Bun.write(prPath, NOTIFY_PR_SH);
8423
+ await chmod2(stopPath, 493);
8424
+ await chmod2(prPath, 493);
8425
+ log.info(`[notify] installed hook scripts in ${HOOKS_DIR}`);
8426
+ }
8427
+
8211
8428
  // backend/src/rpc.ts
8212
8429
  function tmuxEnv() {
8213
8430
  if (Bun.env.TMUX)
@@ -8251,6 +8468,14 @@ async function handleWorkmuxRpc(req) {
8251
8468
  if (!command) {
8252
8469
  return jsonResponse({ ok: false, error: "Missing command" }, 400);
8253
8470
  }
8471
+ if (command === "notify" && branch) {
8472
+ const [type, url] = args;
8473
+ if (type === "agent_stopped" || type === "pr_opened") {
8474
+ addNotification(branch, type, url);
8475
+ return jsonResponse({ ok: true, output: "ok" });
8476
+ }
8477
+ return jsonResponse({ ok: false, error: `Unknown notification type: ${type}` }, 400);
8478
+ }
8254
8479
  try {
8255
8480
  const env = tmuxEnv();
8256
8481
  if (command === "set-window-status" && branch) {
@@ -8279,11 +8504,130 @@ async function handleWorkmuxRpc(req) {
8279
8504
  }
8280
8505
  }
8281
8506
 
8507
+ // backend/src/linear.ts
8508
+ var ASSIGNED_ISSUES_QUERY = `
8509
+ query AssignedIssues {
8510
+ viewer {
8511
+ assignedIssues(
8512
+ filter: { state: { type: { nin: ["completed", "canceled"] } } }
8513
+ orderBy: updatedAt
8514
+ first: 50
8515
+ ) {
8516
+ nodes {
8517
+ id
8518
+ identifier
8519
+ title
8520
+ description
8521
+ priority
8522
+ priorityLabel
8523
+ url
8524
+ branchName
8525
+ dueDate
8526
+ updatedAt
8527
+ state { name color type }
8528
+ team { name key }
8529
+ labels { nodes { name color } }
8530
+ project { name }
8531
+ }
8532
+ }
8533
+ }
8534
+ }
8535
+ `;
8536
+ function parseIssuesResponse(raw) {
8537
+ if (raw.errors && raw.errors.length > 0) {
8538
+ return { ok: false, error: raw.errors.map((e) => e.message).join("; ") };
8539
+ }
8540
+ if (!raw.data) {
8541
+ return { ok: false, error: "No data in response" };
8542
+ }
8543
+ const nodes = raw.data.viewer.assignedIssues.nodes;
8544
+ const issues = nodes.map((n) => ({
8545
+ id: n.id,
8546
+ identifier: n.identifier,
8547
+ title: n.title,
8548
+ description: n.description,
8549
+ priority: n.priority,
8550
+ priorityLabel: n.priorityLabel,
8551
+ url: n.url,
8552
+ branchName: n.branchName,
8553
+ dueDate: n.dueDate,
8554
+ updatedAt: n.updatedAt,
8555
+ state: n.state,
8556
+ team: n.team,
8557
+ labels: n.labels.nodes,
8558
+ project: n.project?.name ?? null
8559
+ }));
8560
+ return { ok: true, data: issues };
8561
+ }
8562
+ function branchMatchesIssue(worktreeBranch, issueBranchName) {
8563
+ if (!worktreeBranch || !issueBranchName)
8564
+ return false;
8565
+ if (worktreeBranch === issueBranchName)
8566
+ return true;
8567
+ const issueSlashIdx = issueBranchName.indexOf("/");
8568
+ if (issueSlashIdx !== -1) {
8569
+ const suffix = issueBranchName.slice(issueSlashIdx + 1);
8570
+ if (worktreeBranch === suffix)
8571
+ return true;
8572
+ }
8573
+ const wtSlashIdx = worktreeBranch.indexOf("/");
8574
+ if (wtSlashIdx !== -1) {
8575
+ const wtSuffix = worktreeBranch.slice(wtSlashIdx + 1);
8576
+ if (wtSuffix === issueBranchName)
8577
+ return true;
8578
+ if (issueSlashIdx !== -1 && wtSuffix === issueBranchName.slice(issueSlashIdx + 1))
8579
+ return true;
8580
+ }
8581
+ return false;
8582
+ }
8583
+ var CACHE_TTL_MS = 300000;
8584
+ var issueCache = null;
8585
+ async function fetchAssignedIssues() {
8586
+ const apiKey = Bun.env.LINEAR_API_KEY;
8587
+ if (!apiKey) {
8588
+ return { ok: false, error: "LINEAR_API_KEY not set" };
8589
+ }
8590
+ const now = Date.now();
8591
+ if (issueCache && now < issueCache.expiry) {
8592
+ return issueCache.data;
8593
+ }
8594
+ try {
8595
+ const res = await fetch("https://api.linear.app/graphql", {
8596
+ method: "POST",
8597
+ headers: {
8598
+ "Content-Type": "application/json",
8599
+ Authorization: apiKey
8600
+ },
8601
+ body: JSON.stringify({ query: ASSIGNED_ISSUES_QUERY })
8602
+ });
8603
+ if (!res.ok) {
8604
+ const text = await res.text();
8605
+ const result2 = { ok: false, error: `Linear API ${res.status}: ${text.slice(0, 200)}` };
8606
+ return result2;
8607
+ }
8608
+ const json = await res.json();
8609
+ const result = parseIssuesResponse(json);
8610
+ if (result.ok) {
8611
+ issueCache = { data: result, expiry: now + CACHE_TTL_MS };
8612
+ log.debug(`[linear] fetched ${result.data.length} assigned issues`);
8613
+ } else {
8614
+ log.error(`[linear] GraphQL error: ${result.error}`);
8615
+ }
8616
+ return result;
8617
+ } catch (err) {
8618
+ const msg = err instanceof Error ? err.message : String(err);
8619
+ log.error(`[linear] fetch failed: ${msg}`);
8620
+ return { ok: false, error: msg };
8621
+ }
8622
+ }
8623
+
8282
8624
  // backend/src/server.ts
8283
- var PORT = parseInt(Bun.env.DASHBOARD_PORT || "5111", 10);
8625
+ var PORT = parseInt(Bun.env.BACKEND_PORT || "5111", 10);
8284
8626
  var STATIC_DIR = Bun.env.WMDEV_STATIC_DIR || "";
8285
8627
  var PROJECT_DIR = Bun.env.WMDEV_PROJECT_DIR || gitRoot(process.cwd());
8286
8628
  var config = loadConfig(PROJECT_DIR);
8629
+ var WORKTREE_CACHE_TTL_MS = 2000;
8630
+ var wtCache = null;
8287
8631
  function parseWsMessage(raw) {
8288
8632
  try {
8289
8633
  const str = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
@@ -8311,7 +8655,16 @@ function parseWsMessage(raw) {
8311
8655
  }
8312
8656
  }
8313
8657
  function sendWs(ws, msg) {
8314
- ws.send(JSON.stringify(msg));
8658
+ switch (msg.type) {
8659
+ case "output":
8660
+ ws.send("o" + msg.data);
8661
+ break;
8662
+ case "scrollback":
8663
+ ws.send("s" + msg.data);
8664
+ break;
8665
+ default:
8666
+ ws.send(JSON.stringify(msg));
8667
+ }
8315
8668
  }
8316
8669
  function isValidWorktreeName(name) {
8317
8670
  return name.length > 0 && /^[a-z0-9][a-z0-9\-_./]*$/.test(name) && !name.includes("..");
@@ -8349,25 +8702,48 @@ async function getWorktreePaths() {
8349
8702
  }
8350
8703
  return paths;
8351
8704
  }
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;
8705
+ async function getAllPaneCounts() {
8706
+ const proc = Bun.spawn(["tmux", "list-windows", "-a", "-F", "#{window_name} #{window_panes}"], { stdout: "pipe", stderr: "pipe" });
8707
+ if (await proc.exited !== 0)
8708
+ return new Map;
8357
8709
  const out = await new Response(proc.stdout).text();
8358
- return out.trim().split(`
8359
- `).filter(Boolean).length;
8710
+ const counts = new Map;
8711
+ for (const line of out.trim().split(`
8712
+ `)) {
8713
+ const spaceIdx = line.lastIndexOf(" ");
8714
+ if (spaceIdx === -1)
8715
+ continue;
8716
+ const name = line.slice(0, spaceIdx);
8717
+ if (!name.startsWith("wm-"))
8718
+ continue;
8719
+ const branch = name.slice(3);
8720
+ const count = parseInt(line.slice(spaceIdx + 1), 10) || 0;
8721
+ if (!counts.has(branch) || count > counts.get(branch)) {
8722
+ counts.set(branch, count);
8723
+ }
8724
+ }
8725
+ return counts;
8360
8726
  }
8361
8727
  function isPortListening(port) {
8362
8728
  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);
8729
+ const timer = setTimeout(() => resolve2(false), 300);
8730
+ Bun.connect({
8731
+ hostname: "127.0.0.1",
8732
+ port,
8733
+ socket: {
8734
+ open(socket) {
8735
+ clearTimeout(timer);
8736
+ socket.end();
8737
+ resolve2(true);
8738
+ },
8739
+ error() {
8740
+ clearTimeout(timer);
8741
+ resolve2(false);
8742
+ },
8743
+ data() {}
8744
+ }
8369
8745
  }).catch(() => {
8370
- clearTimeout(timeout);
8746
+ clearTimeout(timer);
8371
8747
  resolve2(false);
8372
8748
  });
8373
8749
  });
@@ -8384,35 +8760,57 @@ function makeCallbacks(ws) {
8384
8760
  }
8385
8761
  };
8386
8762
  }
8387
- async function apiGetWorktrees() {
8388
- const [worktrees, status, wtPaths] = await Promise.all([
8763
+ async function apiGetWorktrees(req) {
8764
+ const now = Date.now();
8765
+ if (wtCache && now < wtCache.expiry) {
8766
+ if (req.headers.get("if-none-match") === wtCache.etag) {
8767
+ return new Response(null, { status: 304 });
8768
+ }
8769
+ return new Response(wtCache.json, {
8770
+ headers: { "Content-Type": "application/json", ETag: wtCache.etag }
8771
+ });
8772
+ }
8773
+ const [worktrees, status, wtPaths, paneCounts, linearResult] = await Promise.all([
8389
8774
  listWorktrees(),
8390
8775
  getStatus(),
8391
- getWorktreePaths()
8776
+ getWorktreePaths(),
8777
+ getAllPaneCounts(),
8778
+ fetchAssignedIssues()
8392
8779
  ]);
8780
+ const linearIssues = linearResult.ok ? linearResult.data : [];
8393
8781
  const merged = await Promise.all(worktrees.map(async (wt) => {
8394
8782
  const st = status.find((s) => s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch));
8395
8783
  const wtDir = wtPaths.get(wt.branch);
8396
8784
  const env = wtDir ? await readEnvLocal(wtDir) : {};
8785
+ const dirty = wtDir ? await checkDirty(wtDir) : false;
8397
8786
  const services = await Promise.all(config.services.map(async (svc) => {
8398
8787
  const port = env[svc.portEnv] ? parseInt(env[svc.portEnv], 10) : null;
8399
8788
  const running = port !== null && port >= 1 && port <= 65535 ? await isPortListening(port) : false;
8400
8789
  return { name: svc.name, port, running };
8401
8790
  }));
8791
+ const matchedIssue = linearIssues.find((issue) => branchMatchesIssue(wt.branch, issue.branchName));
8792
+ const linearIssue = matchedIssue ? { identifier: matchedIssue.identifier, url: matchedIssue.url, state: matchedIssue.state } : null;
8402
8793
  return {
8403
8794
  ...wt,
8404
8795
  dir: wtDir ?? null,
8796
+ dirty,
8405
8797
  status: st?.status ?? "",
8406
8798
  elapsed: st?.elapsed ?? "",
8407
8799
  title: st?.title ?? "",
8408
8800
  profile: env.PROFILE || null,
8409
8801
  agentName: env.AGENT || null,
8410
8802
  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 ?? [] })) : []
8803
+ paneCount: wt.mux === "\u2713" ? paneCounts.get(wt.branch) ?? 0 : 0,
8804
+ prs: env.PR_DATA ? (safeJsonParse(env.PR_DATA) ?? []).map((pr) => ({ ...pr, comments: pr.comments ?? [] })) : [],
8805
+ linearIssue
8413
8806
  };
8414
8807
  }));
8415
- return jsonResponse(merged);
8808
+ const json = JSON.stringify(merged);
8809
+ const etag = `"${Bun.hash(json).toString(36)}"`;
8810
+ wtCache = { json, etag, expiry: now + WORKTREE_CACHE_TTL_MS };
8811
+ return new Response(json, {
8812
+ headers: { "Content-Type": "application/json", ETag: etag }
8813
+ });
8416
8814
  }
8417
8815
  async function apiCreateWorktree(req) {
8418
8816
  const raw = await req.json();
@@ -8441,6 +8839,7 @@ async function apiCreateWorktree(req) {
8441
8839
  if (!result.ok)
8442
8840
  return errorResponse(result.error, 422);
8443
8841
  log.debug(`[worktree:add] done branch=${result.branch}: ${result.output}`);
8842
+ wtCache = null;
8444
8843
  return jsonResponse({ branch: result.branch }, 201);
8445
8844
  }
8446
8845
  async function apiDeleteWorktree(name) {
@@ -8449,6 +8848,7 @@ async function apiDeleteWorktree(name) {
8449
8848
  if (!result.ok)
8450
8849
  return errorResponse(result.error, 422);
8451
8850
  log.debug(`[worktree:rm] done name=${name}: ${result.output}`);
8851
+ wtCache = null;
8452
8852
  return jsonResponse({ message: result.output });
8453
8853
  }
8454
8854
  async function apiOpenWorktree(name) {
@@ -8480,6 +8880,7 @@ async function apiMergeWorktree(name) {
8480
8880
  if (!result.ok)
8481
8881
  return errorResponse(result.error, 422);
8482
8882
  log.debug(`[worktree:merge] done name=${name}: ${result.output}`);
8883
+ wtCache = null;
8483
8884
  return jsonResponse({ message: result.output });
8484
8885
  }
8485
8886
  async function apiWorktreeStatus(name) {
@@ -8489,6 +8890,12 @@ async function apiWorktreeStatus(name) {
8489
8890
  return errorResponse("Worktree status not found", 404);
8490
8891
  return jsonResponse(match);
8491
8892
  }
8893
+ async function apiGetLinearIssues() {
8894
+ const result = await fetchAssignedIssues();
8895
+ if (!result.ok)
8896
+ return errorResponse(result.error, 502);
8897
+ return jsonResponse(result.data);
8898
+ }
8492
8899
  async function apiCiLogs(runId) {
8493
8900
  if (!/^\d+$/.test(runId))
8494
8901
  return errorResponse("Invalid run ID", 400);
@@ -8519,7 +8926,7 @@ Bun.serve({
8519
8926
  GET: () => jsonResponse(config)
8520
8927
  },
8521
8928
  "/api/worktrees": {
8522
- GET: () => catching("GET /api/worktrees", apiGetWorktrees),
8929
+ GET: (req) => catching("GET /api/worktrees", () => apiGetWorktrees(req)),
8523
8930
  POST: (req) => catching("POST /api/worktrees", () => apiCreateWorktree(req))
8524
8931
  },
8525
8932
  "/api/worktrees/:name": {
@@ -8562,8 +8969,22 @@ Bun.serve({
8562
8969
  return catching(`GET /api/worktrees/${name}/status`, () => apiWorktreeStatus(name));
8563
8970
  }
8564
8971
  },
8972
+ "/api/linear/issues": {
8973
+ GET: () => catching("GET /api/linear/issues", () => apiGetLinearIssues())
8974
+ },
8565
8975
  "/api/ci-logs/:runId": {
8566
8976
  GET: (req) => catching(`GET /api/ci-logs/${req.params.runId}`, () => apiCiLogs(req.params.runId))
8977
+ },
8978
+ "/api/notifications/stream": {
8979
+ GET: () => handleNotificationStream()
8980
+ },
8981
+ "/api/notifications/:id/dismiss": {
8982
+ POST: (req) => {
8983
+ const id = parseInt(req.params.id, 10);
8984
+ if (isNaN(id))
8985
+ return errorResponse("Invalid notification ID", 400);
8986
+ return handleDismissNotification(id);
8987
+ }
8567
8988
  }
8568
8989
  },
8569
8990
  async fetch(req) {
@@ -8577,9 +8998,12 @@ Bun.serve({
8577
8998
  }
8578
8999
  const file = Bun.file(filePath);
8579
9000
  if (await file.exists()) {
8580
- return new Response(file);
9001
+ const headers = rawPath.startsWith("/assets/") ? { "Cache-Control": "public, max-age=31536000, immutable" } : {};
9002
+ return new Response(file, { headers });
8581
9003
  }
8582
- return new Response(Bun.file(join2(STATIC_DIR, "index.html")));
9004
+ return new Response(Bun.file(join2(STATIC_DIR, "index.html")), {
9005
+ headers: { "Cache-Control": "no-cache" }
9006
+ });
8583
9007
  }
8584
9008
  return new Response("Not Found", { status: 404 });
8585
9009
  },
@@ -8647,6 +9071,9 @@ if (tmuxCheck.exitCode !== 0) {
8647
9071
  }
8648
9072
  cleanupStaleSessions();
8649
9073
  startPrMonitor(getWorktreePaths, config.linkedRepos, PROJECT_DIR);
9074
+ installHookScripts().catch((err) => {
9075
+ log.error(`[notify] failed to install hook scripts: ${err instanceof Error ? err.message : String(err)}`);
9076
+ });
8650
9077
  log.info(`Dev Dashboard API running at http://localhost:${PORT}`);
8651
9078
  var nets = networkInterfaces();
8652
9079
  for (const addrs of Object.values(nets)) {