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.
- package/README.md +3 -3
- package/backend/dist/server.js +481 -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);
|
|
@@ -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.
|
|
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
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
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
|
|
7502
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
7904
|
-
|
|
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 -=
|
|
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}): ${
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
|
8352
|
-
const proc = Bun.spawn(["tmux", "list-
|
|
8353
|
-
|
|
8354
|
-
|
|
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
|
-
|
|
8358
|
-
|
|
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
|
|
8363
|
-
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
|
|
8367
|
-
|
|
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(
|
|
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
|
|
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" ?
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|