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