wmdev 0.2.2 → 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);
@@ -7274,6 +7269,7 @@ function buildDockerRunArgs(opts, existingPaths, home, name, rpcSecret, rpcPort,
7274
7269
  args.push("-v", `${mainRepoDir}:${mainRepoDir}:ro`);
7275
7270
  args.push("-v", `${home}/.claude:/root/.claude`);
7276
7271
  args.push("-v", `${home}/.claude.json:/root/.claude.json`);
7272
+ args.push("-v", `${home}/.codex:/root/.codex`);
7277
7273
  const extraMountGuestPaths = new Set;
7278
7274
  if (sandboxConfig.extraMounts) {
7279
7275
  for (const mount of sandboxConfig.extraMounts) {
@@ -7331,7 +7327,7 @@ async function launchContainer(opts) {
7331
7327
  const name = containerName(branch);
7332
7328
  const home = Bun.env.HOME ?? "/root";
7333
7329
  const rpcSecret = await loadRpcSecret();
7334
- const rpcPort = Bun.env.DASHBOARD_PORT ?? "5111";
7330
+ const rpcPort = Bun.env.BACKEND_PORT ?? "5111";
7335
7331
  let sshAuthSock = Bun.env.SSH_AUTH_SOCK;
7336
7332
  if (sshAuthSock) {
7337
7333
  try {
@@ -7487,19 +7483,30 @@ function workmuxEnv() {
7487
7483
  const uid = process.getuid?.() ?? 1000;
7488
7484
  return { ...process.env, TMUX: `${tmpdir}/tmux-${uid}/default,0,0` };
7489
7485
  }
7486
+ function resolveDetachedBranch(branch, path) {
7487
+ if (branch !== "(detached)" || !path)
7488
+ return branch;
7489
+ return path.split("/").pop() || branch;
7490
+ }
7490
7491
  async function listWorktrees() {
7491
- const result = await $`workmux list`.env(workmuxEnv()).text();
7492
- return parseTable(result, (cols) => ({
7493
- branch: cols[0] ?? "",
7494
- agent: cols[1] ?? "",
7495
- mux: cols[2] ?? "",
7496
- unmerged: cols[3] ?? "",
7497
- path: cols[4] ?? ""
7498
- }), 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);
7499
7502
  }
7500
7503
  async function getStatus() {
7501
- const result = await $`workmux status`.env(workmuxEnv()).text();
7502
- 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) => ({
7503
7510
  worktree: cols[0] ?? "",
7504
7511
  status: cols[1] ?? "",
7505
7512
  elapsed: cols[2] ?? "",
@@ -7644,6 +7651,26 @@ async function addWorktree(rawBranch, opts) {
7644
7651
  const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
7645
7652
  const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
7646
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
+ `);
7647
7674
  }
7648
7675
  const env = wtDir ? await readEnvLocal(wtDir) : {};
7649
7676
  log.debug(`[workmux:add] branch=${branch} dir=${wtDir ?? "(not found)"} env=${JSON.stringify(env)}`);
@@ -7765,6 +7792,33 @@ async function openWorktree(name) {
7765
7792
  return result;
7766
7793
  return { ok: true, output: result.stdout };
7767
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
+ }
7768
7822
  async function mergeWorktree(name) {
7769
7823
  log.debug(`[workmux:merge] running: workmux merge ${name}`);
7770
7824
  await removeContainer(name);
@@ -7775,7 +7829,9 @@ async function mergeWorktree(name) {
7775
7829
  }
7776
7830
 
7777
7831
  // backend/src/terminal.ts
7778
- 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";
7779
7835
  var SESSION_PREFIX = `wm-dash-${DASH_PORT}-`;
7780
7836
  var MAX_SCROLLBACK_BYTES = 1 * 1024 * 1024;
7781
7837
  var sessions = new Map;
@@ -7810,7 +7866,7 @@ function cleanupStaleSessions() {
7810
7866
  const result = Bun.spawnSync(["tmux", "list-sessions", "-F", "#{session_name}"], { stdout: "pipe", stderr: "pipe" });
7811
7867
  if (result.exitCode !== 0)
7812
7868
  return;
7813
- const lines = new TextDecoder().decode(result.stdout).trim().split(`
7869
+ const lines = textDecoder.decode(result.stdout).trim().split(`
7814
7870
  `);
7815
7871
  for (const name of lines) {
7816
7872
  if (name.startsWith(SESSION_PREFIX)) {
@@ -7822,7 +7878,7 @@ function cleanupStaleSessions() {
7822
7878
  function killTmuxSession(name) {
7823
7879
  const result = Bun.spawnSync(["tmux", "kill-session", "-t", name], { stderr: "pipe" });
7824
7880
  if (result.exitCode !== 0) {
7825
- const stderr = new TextDecoder().decode(result.stderr).trim();
7881
+ const stderr = textDecoder.decode(result.stderr).trim();
7826
7882
  if (!stderr.includes("can't find session")) {
7827
7883
  log.warn(`[term] killTmuxSession(${name}) exit=${result.exitCode} ${stderr}`);
7828
7884
  }
@@ -7900,13 +7956,12 @@ async function attach(worktreeName, cols, rows, initialPane) {
7900
7956
  const { done, value } = await reader.read();
7901
7957
  if (done)
7902
7958
  break;
7903
- const str = new TextDecoder().decode(value);
7904
- const encoder = new TextEncoder;
7905
- session.scrollbackBytes += encoder.encode(str).byteLength;
7959
+ const str = textDecoder.decode(value);
7960
+ session.scrollbackBytes += textEncoder.encode(str).byteLength;
7906
7961
  session.scrollback.push(str);
7907
7962
  while (session.scrollbackBytes > MAX_SCROLLBACK_BYTES && session.scrollback.length > 0) {
7908
7963
  const removed = session.scrollback.shift();
7909
- session.scrollbackBytes -= encoder.encode(removed).byteLength;
7964
+ session.scrollbackBytes -= textEncoder.encode(removed).byteLength;
7910
7965
  }
7911
7966
  session.onData?.(str);
7912
7967
  }
@@ -7923,7 +7978,7 @@ async function attach(worktreeName, cols, rows, initialPane) {
7923
7978
  const { done, value } = await reader.read();
7924
7979
  if (done)
7925
7980
  break;
7926
- log.debug(`[term] stderr(${worktreeName}): ${new TextDecoder().decode(value).trimEnd()}`);
7981
+ log.debug(`[term] stderr(${worktreeName}): ${textDecoder.decode(value).trimEnd()}`);
7927
7982
  }
7928
7983
  } catch {}
7929
7984
  })();
@@ -7958,7 +8013,7 @@ function write(worktreeName, data) {
7958
8013
  return;
7959
8014
  }
7960
8015
  try {
7961
- session.proc.stdin.write(new TextEncoder().encode(data));
8016
+ session.proc.stdin.write(textEncoder.encode(data));
7962
8017
  session.proc.stdin.flush();
7963
8018
  } catch (err) {
7964
8019
  log.error(`[term] write(${worktreeName}) stdin closed`, err);
@@ -8207,6 +8262,169 @@ function errorResponse(message, status = 500) {
8207
8262
  return jsonResponse({ error: message }, status);
8208
8263
  }
8209
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
+
8210
8428
  // backend/src/rpc.ts
8211
8429
  function tmuxEnv() {
8212
8430
  if (Bun.env.TMUX)
@@ -8250,6 +8468,14 @@ async function handleWorkmuxRpc(req) {
8250
8468
  if (!command) {
8251
8469
  return jsonResponse({ ok: false, error: "Missing command" }, 400);
8252
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
+ }
8253
8479
  try {
8254
8480
  const env = tmuxEnv();
8255
8481
  if (command === "set-window-status" && branch) {
@@ -8278,11 +8504,130 @@ async function handleWorkmuxRpc(req) {
8278
8504
  }
8279
8505
  }
8280
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
+
8281
8624
  // backend/src/server.ts
8282
- var PORT = parseInt(Bun.env.DASHBOARD_PORT || "5111", 10);
8625
+ var PORT = parseInt(Bun.env.BACKEND_PORT || "5111", 10);
8283
8626
  var STATIC_DIR = Bun.env.WMDEV_STATIC_DIR || "";
8284
8627
  var PROJECT_DIR = Bun.env.WMDEV_PROJECT_DIR || gitRoot(process.cwd());
8285
8628
  var config = loadConfig(PROJECT_DIR);
8629
+ var WORKTREE_CACHE_TTL_MS = 2000;
8630
+ var wtCache = null;
8286
8631
  function parseWsMessage(raw) {
8287
8632
  try {
8288
8633
  const str = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
@@ -8310,7 +8655,16 @@ function parseWsMessage(raw) {
8310
8655
  }
8311
8656
  }
8312
8657
  function sendWs(ws, msg) {
8313
- 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
+ }
8314
8668
  }
8315
8669
  function isValidWorktreeName(name) {
8316
8670
  return name.length > 0 && /^[a-z0-9][a-z0-9\-_./]*$/.test(name) && !name.includes("..");
@@ -8348,25 +8702,48 @@ async function getWorktreePaths() {
8348
8702
  }
8349
8703
  return paths;
8350
8704
  }
8351
- async function getTmuxPaneCount(branch) {
8352
- const proc = Bun.spawn(["tmux", "list-panes", "-t", `wm-${branch}`, "-F", "#{pane_index}"], { stdout: "pipe", stderr: "pipe" });
8353
- const exitCode = await proc.exited;
8354
- if (exitCode !== 0)
8355
- 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;
8356
8709
  const out = await new Response(proc.stdout).text();
8357
- return out.trim().split(`
8358
- `).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;
8359
8726
  }
8360
8727
  function isPortListening(port) {
8361
8728
  return new Promise((resolve2) => {
8362
- const timeout = setTimeout(() => {
8363
- resolve2(false);
8364
- }, 1000);
8365
- fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1000) }).then(() => {
8366
- clearTimeout(timeout);
8367
- 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
+ }
8368
8745
  }).catch(() => {
8369
- clearTimeout(timeout);
8746
+ clearTimeout(timer);
8370
8747
  resolve2(false);
8371
8748
  });
8372
8749
  });
@@ -8383,35 +8760,57 @@ function makeCallbacks(ws) {
8383
8760
  }
8384
8761
  };
8385
8762
  }
8386
- async function apiGetWorktrees() {
8387
- 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([
8388
8774
  listWorktrees(),
8389
8775
  getStatus(),
8390
- getWorktreePaths()
8776
+ getWorktreePaths(),
8777
+ getAllPaneCounts(),
8778
+ fetchAssignedIssues()
8391
8779
  ]);
8780
+ const linearIssues = linearResult.ok ? linearResult.data : [];
8392
8781
  const merged = await Promise.all(worktrees.map(async (wt) => {
8393
8782
  const st = status.find((s) => s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch));
8394
8783
  const wtDir = wtPaths.get(wt.branch);
8395
8784
  const env = wtDir ? await readEnvLocal(wtDir) : {};
8785
+ const dirty = wtDir ? await checkDirty(wtDir) : false;
8396
8786
  const services = await Promise.all(config.services.map(async (svc) => {
8397
8787
  const port = env[svc.portEnv] ? parseInt(env[svc.portEnv], 10) : null;
8398
8788
  const running = port !== null && port >= 1 && port <= 65535 ? await isPortListening(port) : false;
8399
8789
  return { name: svc.name, port, running };
8400
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;
8401
8793
  return {
8402
8794
  ...wt,
8403
8795
  dir: wtDir ?? null,
8796
+ dirty,
8404
8797
  status: st?.status ?? "",
8405
8798
  elapsed: st?.elapsed ?? "",
8406
8799
  title: st?.title ?? "",
8407
8800
  profile: env.PROFILE || null,
8408
8801
  agentName: env.AGENT || null,
8409
8802
  services,
8410
- paneCount: wt.mux === "\u2713" ? await getTmuxPaneCount(wt.branch) : 0,
8411
- 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
8412
8806
  };
8413
8807
  }));
8414
- 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
+ });
8415
8814
  }
8416
8815
  async function apiCreateWorktree(req) {
8417
8816
  const raw = await req.json();
@@ -8440,6 +8839,7 @@ async function apiCreateWorktree(req) {
8440
8839
  if (!result.ok)
8441
8840
  return errorResponse(result.error, 422);
8442
8841
  log.debug(`[worktree:add] done branch=${result.branch}: ${result.output}`);
8842
+ wtCache = null;
8443
8843
  return jsonResponse({ branch: result.branch }, 201);
8444
8844
  }
8445
8845
  async function apiDeleteWorktree(name) {
@@ -8448,6 +8848,7 @@ async function apiDeleteWorktree(name) {
8448
8848
  if (!result.ok)
8449
8849
  return errorResponse(result.error, 422);
8450
8850
  log.debug(`[worktree:rm] done name=${name}: ${result.output}`);
8851
+ wtCache = null;
8451
8852
  return jsonResponse({ message: result.output });
8452
8853
  }
8453
8854
  async function apiOpenWorktree(name) {
@@ -8479,6 +8880,7 @@ async function apiMergeWorktree(name) {
8479
8880
  if (!result.ok)
8480
8881
  return errorResponse(result.error, 422);
8481
8882
  log.debug(`[worktree:merge] done name=${name}: ${result.output}`);
8883
+ wtCache = null;
8482
8884
  return jsonResponse({ message: result.output });
8483
8885
  }
8484
8886
  async function apiWorktreeStatus(name) {
@@ -8488,6 +8890,12 @@ async function apiWorktreeStatus(name) {
8488
8890
  return errorResponse("Worktree status not found", 404);
8489
8891
  return jsonResponse(match);
8490
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
+ }
8491
8899
  async function apiCiLogs(runId) {
8492
8900
  if (!/^\d+$/.test(runId))
8493
8901
  return errorResponse("Invalid run ID", 400);
@@ -8518,7 +8926,7 @@ Bun.serve({
8518
8926
  GET: () => jsonResponse(config)
8519
8927
  },
8520
8928
  "/api/worktrees": {
8521
- GET: () => catching("GET /api/worktrees", apiGetWorktrees),
8929
+ GET: (req) => catching("GET /api/worktrees", () => apiGetWorktrees(req)),
8522
8930
  POST: (req) => catching("POST /api/worktrees", () => apiCreateWorktree(req))
8523
8931
  },
8524
8932
  "/api/worktrees/:name": {
@@ -8561,8 +8969,22 @@ Bun.serve({
8561
8969
  return catching(`GET /api/worktrees/${name}/status`, () => apiWorktreeStatus(name));
8562
8970
  }
8563
8971
  },
8972
+ "/api/linear/issues": {
8973
+ GET: () => catching("GET /api/linear/issues", () => apiGetLinearIssues())
8974
+ },
8564
8975
  "/api/ci-logs/:runId": {
8565
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
+ }
8566
8988
  }
8567
8989
  },
8568
8990
  async fetch(req) {
@@ -8576,9 +8998,12 @@ Bun.serve({
8576
8998
  }
8577
8999
  const file = Bun.file(filePath);
8578
9000
  if (await file.exists()) {
8579
- return new Response(file);
9001
+ const headers = rawPath.startsWith("/assets/") ? { "Cache-Control": "public, max-age=31536000, immutable" } : {};
9002
+ return new Response(file, { headers });
8580
9003
  }
8581
- 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
+ });
8582
9007
  }
8583
9008
  return new Response("Not Found", { status: 404 });
8584
9009
  },
@@ -8646,6 +9071,9 @@ if (tmuxCheck.exitCode !== 0) {
8646
9071
  }
8647
9072
  cleanupStaleSessions();
8648
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
+ });
8649
9077
  log.info(`Dev Dashboard API running at http://localhost:${PORT}`);
8650
9078
  var nets = networkInterfaces();
8651
9079
  for (const addrs of Object.values(nets)) {