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.
- package/README.md +3 -3
- package/backend/dist/server.js +480 -53
- package/bin/wmdev.js +3 -3
- package/frontend/dist/assets/index-D9tls-CM.css +32 -0
- package/frontend/dist/assets/index-Dp6OOEt3.js +25 -0
- package/frontend/dist/index.html +7 -2
- package/package.json +3 -4
- package/frontend/dist/assets/index-CRpS9q83.js +0 -25
- package/frontend/dist/assets/index-Cy7rpGPt.css +0 -32
package/backend/dist/server.js
CHANGED
|
@@ -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
|
-
|
|
6974
|
-
|
|
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.
|
|
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
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
7499
|
-
|
|
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
|
|
7503
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
7905
|
-
|
|
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 -=
|
|
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}): ${
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
|
8353
|
-
const proc = Bun.spawn(["tmux", "list-
|
|
8354
|
-
|
|
8355
|
-
|
|
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
|
-
|
|
8359
|
-
|
|
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
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
|
|
8367
|
-
|
|
8368
|
-
|
|
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(
|
|
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
|
|
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" ?
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|