webmux 0.7.2 → 0.7.3
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 +60 -17
- package/backend/dist/server.js +3014 -1438
- package/bin/webmux.js +754 -100
- package/frontend/dist/assets/index-DeLoWUbI.js +32 -0
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
- package/frontend/dist/assets/index-B8KQ-AWX.js +0 -32
package/backend/dist/server.js
CHANGED
|
@@ -6905,7 +6905,7 @@ var require_public_api = __commonJS((exports) => {
|
|
|
6905
6905
|
});
|
|
6906
6906
|
|
|
6907
6907
|
// backend/src/server.ts
|
|
6908
|
-
import { join as
|
|
6908
|
+
import { join as join5, resolve as resolve5 } from "path";
|
|
6909
6909
|
import { networkInterfaces } from "os";
|
|
6910
6910
|
|
|
6911
6911
|
// backend/src/lib/log.ts
|
|
@@ -6929,91 +6929,461 @@ var log = {
|
|
|
6929
6929
|
}
|
|
6930
6930
|
};
|
|
6931
6931
|
|
|
6932
|
-
// backend/src/
|
|
6933
|
-
var
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
|
|
6937
|
-
|
|
6932
|
+
// backend/src/adapters/terminal.ts
|
|
6933
|
+
var textDecoder = new TextDecoder;
|
|
6934
|
+
var textEncoder = new TextEncoder;
|
|
6935
|
+
var DASH_PORT = Bun.env.BACKEND_PORT || "5111";
|
|
6936
|
+
var SESSION_PREFIX = `wm-dash-${DASH_PORT}-`;
|
|
6937
|
+
var MAX_SCROLLBACK_BYTES = 1 * 1024 * 1024;
|
|
6938
|
+
var TMUX_TIMEOUT_MS = 5000;
|
|
6939
|
+
var sessions = new Map;
|
|
6940
|
+
var sessionCounter = 0;
|
|
6941
|
+
function groupedName() {
|
|
6942
|
+
return `${SESSION_PREFIX}${++sessionCounter}`;
|
|
6943
|
+
}
|
|
6944
|
+
function buildAttachCmd(opts) {
|
|
6945
|
+
const paneTarget = `${opts.gName}:${opts.windowName}.${opts.initialPane ?? 0}`;
|
|
6946
|
+
return [
|
|
6947
|
+
`tmux new-session -d -s "${opts.gName}" -t "${opts.ownerSessionName}"`,
|
|
6948
|
+
`tmux set-option -t "${opts.ownerSessionName}" window-size latest`,
|
|
6949
|
+
`tmux set-option -t "${opts.gName}" mouse on`,
|
|
6950
|
+
`tmux set-option -t "${opts.gName}" set-clipboard on`,
|
|
6951
|
+
`tmux select-window -t "${opts.gName}:${opts.windowName}"`,
|
|
6952
|
+
`if [ "$(tmux display-message -t '${opts.gName}:${opts.windowName}' -p '#{window_zoomed_flag}')" = "1" ]; then tmux resize-pane -Z -t '${opts.gName}:${opts.windowName}'; fi`,
|
|
6953
|
+
`tmux select-pane -t "${paneTarget}"`,
|
|
6954
|
+
...opts.initialPane !== undefined ? [`tmux resize-pane -Z -t "${paneTarget}"`] : [],
|
|
6955
|
+
`stty rows ${opts.rows} cols ${opts.cols}`,
|
|
6956
|
+
`exec tmux attach-session -t "${opts.gName}"`
|
|
6957
|
+
].join(" && ");
|
|
6958
|
+
}
|
|
6959
|
+
async function tmuxExec(args, opts = {}) {
|
|
6960
|
+
const proc = Bun.spawn(args, {
|
|
6961
|
+
stdin: opts.stdin ?? "ignore",
|
|
6962
|
+
stdout: "ignore",
|
|
6963
|
+
stderr: "pipe"
|
|
6964
|
+
});
|
|
6965
|
+
const timeout = Bun.sleep(TMUX_TIMEOUT_MS).then(() => {
|
|
6966
|
+
proc.kill();
|
|
6967
|
+
return "timeout";
|
|
6968
|
+
});
|
|
6969
|
+
const result = await Promise.race([proc.exited, timeout]);
|
|
6970
|
+
if (result === "timeout") {
|
|
6971
|
+
return { exitCode: -1, stderr: `timed out after ${TMUX_TIMEOUT_MS}ms` };
|
|
6972
|
+
}
|
|
6973
|
+
const stderr = (await new Response(proc.stderr).text()).trim();
|
|
6974
|
+
return { exitCode: result, stderr };
|
|
6975
|
+
}
|
|
6976
|
+
function cleanupStaleSessions() {
|
|
6938
6977
|
try {
|
|
6939
|
-
const
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6978
|
+
const result = Bun.spawnSync(["tmux", "list-sessions", "-F", "#{session_name}"], { stdout: "pipe", stderr: "pipe" });
|
|
6979
|
+
if (result.exitCode !== 0)
|
|
6980
|
+
return;
|
|
6981
|
+
const lines = textDecoder.decode(result.stdout).trim().split(`
|
|
6982
|
+
`);
|
|
6983
|
+
for (const name of lines) {
|
|
6984
|
+
if (name.startsWith(SESSION_PREFIX)) {
|
|
6985
|
+
Bun.spawnSync(["tmux", "kill-session", "-t", name]);
|
|
6986
|
+
}
|
|
6987
|
+
}
|
|
6988
|
+
} catch {}
|
|
6989
|
+
}
|
|
6990
|
+
function killTmuxSession(name) {
|
|
6991
|
+
const result = Bun.spawnSync(["tmux", "kill-session", "-t", name], { stderr: "pipe" });
|
|
6992
|
+
if (result.exitCode !== 0) {
|
|
6993
|
+
const stderr = textDecoder.decode(result.stderr).trim();
|
|
6994
|
+
if (!stderr.includes("can't find session")) {
|
|
6995
|
+
log.warn(`[term] killTmuxSession(${name}) exit=${result.exitCode} ${stderr}`);
|
|
6996
|
+
}
|
|
6997
|
+
}
|
|
6998
|
+
}
|
|
6999
|
+
async function attach(worktreeId, target, cols, rows, initialPane) {
|
|
7000
|
+
log.debug(`[term] attach(${worktreeId}) cols=${cols} rows=${rows} existing=${sessions.has(worktreeId)}`);
|
|
7001
|
+
if (sessions.has(worktreeId)) {
|
|
7002
|
+
await detach(worktreeId);
|
|
7003
|
+
}
|
|
7004
|
+
const gName = groupedName();
|
|
7005
|
+
log.debug(`[term] attach(${worktreeId}) ownerSession=${target.ownerSessionName} gName=${gName} window=${target.windowName}`);
|
|
7006
|
+
killTmuxSession(gName);
|
|
7007
|
+
const cmd = buildAttachCmd({
|
|
7008
|
+
gName,
|
|
7009
|
+
ownerSessionName: target.ownerSessionName,
|
|
7010
|
+
windowName: target.windowName,
|
|
7011
|
+
cols,
|
|
7012
|
+
rows,
|
|
7013
|
+
initialPane
|
|
7014
|
+
});
|
|
7015
|
+
const scriptArgs = process.platform === "darwin" ? ["python3", "-c", "import pty,sys;pty.spawn(sys.argv[1:])", "bash", "-c", cmd] : ["script", "-q", "-c", cmd, "/dev/null"];
|
|
7016
|
+
const proc = Bun.spawn(scriptArgs, {
|
|
7017
|
+
stdin: "pipe",
|
|
7018
|
+
stdout: "pipe",
|
|
7019
|
+
stderr: "pipe",
|
|
7020
|
+
env: { ...Bun.env, TERM: "xterm-256color" }
|
|
7021
|
+
});
|
|
7022
|
+
const session = {
|
|
7023
|
+
proc,
|
|
7024
|
+
groupedSessionName: gName,
|
|
7025
|
+
windowName: target.windowName,
|
|
7026
|
+
scrollback: [],
|
|
7027
|
+
scrollbackBytes: 0,
|
|
7028
|
+
onData: null,
|
|
7029
|
+
onExit: null,
|
|
7030
|
+
cancelled: false
|
|
7031
|
+
};
|
|
7032
|
+
sessions.set(worktreeId, session);
|
|
7033
|
+
log.debug(`[term] attach(${worktreeId}) spawned pid=${proc.pid}`);
|
|
7034
|
+
(async () => {
|
|
7035
|
+
const reader = proc.stdout.getReader();
|
|
7036
|
+
try {
|
|
7037
|
+
while (true) {
|
|
7038
|
+
if (session.cancelled)
|
|
7039
|
+
break;
|
|
7040
|
+
const { done, value } = await reader.read();
|
|
7041
|
+
if (done)
|
|
7042
|
+
break;
|
|
7043
|
+
const str = textDecoder.decode(value);
|
|
7044
|
+
session.scrollbackBytes += textEncoder.encode(str).byteLength;
|
|
7045
|
+
session.scrollback.push(str);
|
|
7046
|
+
while (session.scrollbackBytes > MAX_SCROLLBACK_BYTES && session.scrollback.length > 0) {
|
|
7047
|
+
const removed = session.scrollback.shift();
|
|
7048
|
+
session.scrollbackBytes -= textEncoder.encode(removed).byteLength;
|
|
6948
7049
|
}
|
|
6949
|
-
|
|
7050
|
+
session.onData?.(str);
|
|
7051
|
+
}
|
|
7052
|
+
} catch (err) {
|
|
7053
|
+
if (!session.cancelled) {
|
|
7054
|
+
log.error(`[term] stdout reader error(${worktreeId})`, err);
|
|
6950
7055
|
}
|
|
6951
7056
|
}
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
7057
|
+
})();
|
|
7058
|
+
(async () => {
|
|
7059
|
+
const reader = proc.stderr.getReader();
|
|
7060
|
+
try {
|
|
7061
|
+
while (true) {
|
|
7062
|
+
const { done, value } = await reader.read();
|
|
7063
|
+
if (done)
|
|
7064
|
+
break;
|
|
7065
|
+
log.debug(`[term] stderr(${worktreeId}): ${textDecoder.decode(value).trimEnd()}`);
|
|
7066
|
+
}
|
|
7067
|
+
} catch {}
|
|
7068
|
+
})();
|
|
7069
|
+
proc.exited.then((exitCode) => {
|
|
7070
|
+
log.debug(`[term] proc exited(${worktreeId}) pid=${proc.pid} code=${exitCode}`);
|
|
7071
|
+
if (sessions.get(worktreeId) === session) {
|
|
7072
|
+
session.onExit?.(exitCode);
|
|
7073
|
+
sessions.delete(worktreeId);
|
|
7074
|
+
} else {
|
|
7075
|
+
log.debug(`[term] proc exited(${worktreeId}) stale session, skipping cleanup`);
|
|
7076
|
+
}
|
|
7077
|
+
killTmuxSession(gName);
|
|
7078
|
+
});
|
|
7079
|
+
}
|
|
7080
|
+
async function detach(worktreeId) {
|
|
7081
|
+
const session = sessions.get(worktreeId);
|
|
7082
|
+
if (!session) {
|
|
7083
|
+
log.debug(`[term] detach(${worktreeId}) no session found`);
|
|
7084
|
+
return;
|
|
6955
7085
|
}
|
|
7086
|
+
log.debug(`[term] detach(${worktreeId}) killing pid=${session.proc.pid} tmux=${session.groupedSessionName}`);
|
|
7087
|
+
session.cancelled = true;
|
|
7088
|
+
session.proc.kill();
|
|
7089
|
+
sessions.delete(worktreeId);
|
|
7090
|
+
killTmuxSession(session.groupedSessionName);
|
|
6956
7091
|
}
|
|
6957
|
-
|
|
6958
|
-
const
|
|
6959
|
-
|
|
7092
|
+
function write(worktreeId, data) {
|
|
7093
|
+
const session = sessions.get(worktreeId);
|
|
7094
|
+
if (!session) {
|
|
7095
|
+
log.warn(`[term] write(${worktreeId}) NO SESSION - input dropped (${data.length} bytes)`);
|
|
7096
|
+
return;
|
|
7097
|
+
}
|
|
6960
7098
|
try {
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
`);
|
|
6965
|
-
}
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
6974
|
-
|
|
7099
|
+
session.proc.stdin.write(textEncoder.encode(data));
|
|
7100
|
+
session.proc.stdin.flush();
|
|
7101
|
+
} catch (err) {
|
|
7102
|
+
log.error(`[term] write(${worktreeId}) stdin closed`, err);
|
|
7103
|
+
}
|
|
7104
|
+
}
|
|
7105
|
+
async function sendKeys(worktreeId, hexBytes) {
|
|
7106
|
+
const session = sessions.get(worktreeId);
|
|
7107
|
+
if (!session)
|
|
7108
|
+
return;
|
|
7109
|
+
const windowTarget = `${session.groupedSessionName}:${session.windowName}`;
|
|
7110
|
+
await tmuxExec(["tmux", "send-keys", "-t", windowTarget, "-H", ...hexBytes]);
|
|
7111
|
+
}
|
|
7112
|
+
async function resize(worktreeId, cols, rows) {
|
|
7113
|
+
const session = sessions.get(worktreeId);
|
|
7114
|
+
if (!session)
|
|
7115
|
+
return;
|
|
7116
|
+
const windowTarget = `${session.groupedSessionName}:${session.windowName}`;
|
|
7117
|
+
const result = await tmuxExec(["tmux", "resize-window", "-t", windowTarget, "-x", String(cols), "-y", String(rows)]);
|
|
7118
|
+
if (result.exitCode !== 0)
|
|
7119
|
+
log.warn(`[term] resize failed: ${result.stderr}`);
|
|
7120
|
+
}
|
|
7121
|
+
function getScrollback(worktreeId) {
|
|
7122
|
+
return sessions.get(worktreeId)?.scrollback.join("") ?? "";
|
|
7123
|
+
}
|
|
7124
|
+
function setCallbacks(worktreeId, onData, onExit) {
|
|
7125
|
+
const session = sessions.get(worktreeId);
|
|
7126
|
+
if (session) {
|
|
7127
|
+
session.onData = onData;
|
|
7128
|
+
session.onExit = onExit;
|
|
7129
|
+
}
|
|
7130
|
+
}
|
|
7131
|
+
async function selectPane(worktreeId, paneIndex) {
|
|
7132
|
+
const session = sessions.get(worktreeId);
|
|
7133
|
+
if (!session) {
|
|
7134
|
+
log.debug(`[term] selectPane(${worktreeId}) no session found`);
|
|
7135
|
+
return;
|
|
7136
|
+
}
|
|
7137
|
+
const target = `${session.groupedSessionName}:${session.windowName}.${paneIndex}`;
|
|
7138
|
+
log.debug(`[term] selectPane(${worktreeId}) pane=${paneIndex} target=${target}`);
|
|
7139
|
+
const [r1, r2] = await Promise.all([
|
|
7140
|
+
tmuxExec(["tmux", "select-pane", "-t", target]),
|
|
7141
|
+
tmuxExec(["tmux", "resize-pane", "-Z", "-t", target])
|
|
7142
|
+
]);
|
|
7143
|
+
log.debug(`[term] selectPane(${worktreeId}) select=${r1.exitCode} zoom=${r2.exitCode}`);
|
|
7144
|
+
}
|
|
7145
|
+
function clearCallbacks(worktreeId) {
|
|
7146
|
+
const session = sessions.get(worktreeId);
|
|
7147
|
+
if (session) {
|
|
7148
|
+
session.onData = null;
|
|
7149
|
+
session.onExit = null;
|
|
7150
|
+
}
|
|
7151
|
+
}
|
|
7152
|
+
async function sendPrompt(worktreeId, target, text, paneIndex = 0, preamble) {
|
|
7153
|
+
const paneTarget = `${target.ownerSessionName}:${target.windowName}.${paneIndex}`;
|
|
7154
|
+
log.debug(`[term] sendPrompt(${worktreeId}) target=${paneTarget} textBytes=${text.length}`);
|
|
7155
|
+
if (preamble) {
|
|
7156
|
+
const preambleResult = await tmuxExec(["tmux", "send-keys", "-t", paneTarget, "-l", "--", preamble]);
|
|
7157
|
+
if (preambleResult.exitCode !== 0) {
|
|
7158
|
+
return { ok: false, error: `send-keys preamble failed${preambleResult.stderr ? `: ${preambleResult.stderr}` : ""}` };
|
|
6975
7159
|
}
|
|
6976
7160
|
}
|
|
6977
|
-
|
|
6978
|
-
`)
|
|
6979
|
-
|
|
7161
|
+
const cleaned = text.replace(/\0/g, "");
|
|
7162
|
+
const bufferName = `wm-prompt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
7163
|
+
const load = await tmuxExec(["tmux", "load-buffer", "-b", bufferName, "-"], {
|
|
7164
|
+
stdin: new TextEncoder().encode(cleaned)
|
|
7165
|
+
});
|
|
7166
|
+
if (load.exitCode !== 0) {
|
|
7167
|
+
return { ok: false, error: `load-buffer failed${load.stderr ? `: ${load.stderr}` : ""}` };
|
|
7168
|
+
}
|
|
7169
|
+
const paste = await tmuxExec(["tmux", "paste-buffer", "-b", bufferName, "-t", paneTarget, "-d"]);
|
|
7170
|
+
if (paste.exitCode !== 0) {
|
|
7171
|
+
return { ok: false, error: `paste-buffer failed${paste.stderr ? `: ${paste.stderr}` : ""}` };
|
|
7172
|
+
}
|
|
7173
|
+
return { ok: true };
|
|
6980
7174
|
}
|
|
6981
|
-
|
|
6982
|
-
|
|
7175
|
+
|
|
7176
|
+
// backend/src/adapters/git.ts
|
|
7177
|
+
import { rmSync } from "fs";
|
|
7178
|
+
import { resolve } from "path";
|
|
7179
|
+
function runGit(args, cwd) {
|
|
7180
|
+
const result = Bun.spawnSync(["git", ...args], {
|
|
7181
|
+
cwd,
|
|
7182
|
+
stdout: "pipe",
|
|
7183
|
+
stderr: "pipe"
|
|
7184
|
+
});
|
|
7185
|
+
if (result.exitCode !== 0) {
|
|
7186
|
+
const stderr = new TextDecoder().decode(result.stderr).trim();
|
|
7187
|
+
throw new Error(`git ${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);
|
|
7188
|
+
}
|
|
7189
|
+
return new TextDecoder().decode(result.stdout).trim();
|
|
6983
7190
|
}
|
|
6984
|
-
function
|
|
6985
|
-
const
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
7191
|
+
function tryRunGit(args, cwd) {
|
|
7192
|
+
const result = Bun.spawnSync(["git", ...args], {
|
|
7193
|
+
cwd,
|
|
7194
|
+
stdout: "pipe",
|
|
7195
|
+
stderr: "pipe"
|
|
7196
|
+
});
|
|
7197
|
+
if (result.exitCode !== 0) {
|
|
7198
|
+
return {
|
|
7199
|
+
ok: false,
|
|
7200
|
+
stderr: new TextDecoder().decode(result.stderr).trim()
|
|
7201
|
+
};
|
|
7202
|
+
}
|
|
7203
|
+
return {
|
|
7204
|
+
ok: true,
|
|
7205
|
+
stdout: new TextDecoder().decode(result.stdout).trim()
|
|
7206
|
+
};
|
|
7207
|
+
}
|
|
7208
|
+
function errorMessage(error) {
|
|
7209
|
+
return error instanceof Error ? error.message : String(error);
|
|
7210
|
+
}
|
|
7211
|
+
function isRegisteredWorktree(entries, worktreePath) {
|
|
7212
|
+
const resolvedPath = resolve(worktreePath);
|
|
7213
|
+
return entries.some((entry) => resolve(entry.path) === resolvedPath);
|
|
7214
|
+
}
|
|
7215
|
+
function removeDirectory(path) {
|
|
7216
|
+
rmSync(path, {
|
|
7217
|
+
recursive: true,
|
|
7218
|
+
force: true
|
|
7219
|
+
});
|
|
7220
|
+
}
|
|
7221
|
+
function currentCheckoutRef(cwd) {
|
|
7222
|
+
const symbolicRef = tryRunGit(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd);
|
|
7223
|
+
if (symbolicRef.ok && symbolicRef.stdout.length > 0) {
|
|
7224
|
+
return {
|
|
7225
|
+
ref: symbolicRef.stdout,
|
|
7226
|
+
branch: symbolicRef.stdout
|
|
7227
|
+
};
|
|
7228
|
+
}
|
|
7229
|
+
return {
|
|
7230
|
+
ref: runGit(["rev-parse", "--verify", "HEAD"], cwd),
|
|
7231
|
+
branch: null
|
|
7232
|
+
};
|
|
7233
|
+
}
|
|
7234
|
+
function resolveWorktreeRoot(cwd) {
|
|
7235
|
+
const output = runGit(["rev-parse", "--show-toplevel"], cwd);
|
|
7236
|
+
return resolve(cwd, output);
|
|
7237
|
+
}
|
|
7238
|
+
function resolveWorktreeGitDir(cwd) {
|
|
7239
|
+
const output = runGit(["rev-parse", "--git-dir"], cwd);
|
|
7240
|
+
return resolve(cwd, output);
|
|
7241
|
+
}
|
|
7242
|
+
function parseGitWorktreePorcelain(output) {
|
|
7243
|
+
const entries = [];
|
|
7244
|
+
let current = null;
|
|
7245
|
+
const flush = () => {
|
|
7246
|
+
if (current?.path)
|
|
7247
|
+
entries.push(current);
|
|
7248
|
+
current = null;
|
|
7249
|
+
};
|
|
7250
|
+
for (const rawLine of output.split(`
|
|
7251
|
+
`)) {
|
|
7252
|
+
const line = rawLine.trimEnd();
|
|
7253
|
+
if (!line) {
|
|
7254
|
+
flush();
|
|
7255
|
+
continue;
|
|
7256
|
+
}
|
|
7257
|
+
if (line.startsWith("worktree ")) {
|
|
7258
|
+
flush();
|
|
7259
|
+
current = {
|
|
7260
|
+
path: line.slice("worktree ".length),
|
|
7261
|
+
branch: null,
|
|
7262
|
+
head: null,
|
|
7263
|
+
detached: false,
|
|
7264
|
+
bare: false
|
|
7265
|
+
};
|
|
7266
|
+
continue;
|
|
7267
|
+
}
|
|
7268
|
+
if (!current)
|
|
7269
|
+
continue;
|
|
7270
|
+
if (line.startsWith("branch ")) {
|
|
7271
|
+
current.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
|
|
6995
7272
|
continue;
|
|
6996
|
-
|
|
6997
|
-
if (
|
|
7273
|
+
}
|
|
7274
|
+
if (line.startsWith("HEAD ")) {
|
|
7275
|
+
current.head = line.slice("HEAD ".length);
|
|
6998
7276
|
continue;
|
|
6999
|
-
|
|
7000
|
-
if (
|
|
7277
|
+
}
|
|
7278
|
+
if (line === "detached") {
|
|
7279
|
+
current.detached = true;
|
|
7001
7280
|
continue;
|
|
7002
|
-
|
|
7281
|
+
}
|
|
7282
|
+
if (line === "bare") {
|
|
7283
|
+
current.bare = true;
|
|
7284
|
+
}
|
|
7003
7285
|
}
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
|
|
7286
|
+
flush();
|
|
7287
|
+
return entries;
|
|
7288
|
+
}
|
|
7289
|
+
function listGitWorktrees(cwd) {
|
|
7290
|
+
const output = runGit(["worktree", "list", "--porcelain"], cwd);
|
|
7291
|
+
return parseGitWorktreePorcelain(output);
|
|
7292
|
+
}
|
|
7293
|
+
function readGitWorktreeStatus(cwd) {
|
|
7294
|
+
const dirtyOutput = runGit(["status", "--porcelain"], cwd);
|
|
7295
|
+
const commit = tryRunGit(["rev-parse", "HEAD"], cwd);
|
|
7296
|
+
const ahead = tryRunGit(["rev-list", "--count", "@{upstream}..HEAD"], cwd);
|
|
7297
|
+
return {
|
|
7298
|
+
dirty: dirtyOutput.length > 0,
|
|
7299
|
+
aheadCount: ahead.ok ? parseInt(ahead.stdout, 10) || 0 : 0,
|
|
7300
|
+
currentCommit: commit.ok && commit.stdout.length > 0 ? commit.stdout : null
|
|
7301
|
+
};
|
|
7302
|
+
}
|
|
7303
|
+
function removeGitWorktree(opts, deps = {}) {
|
|
7304
|
+
const args = ["worktree", "remove"];
|
|
7305
|
+
if (opts.force)
|
|
7306
|
+
args.push("--force");
|
|
7307
|
+
args.push(opts.worktreePath);
|
|
7308
|
+
const result = (deps.tryRunGit ?? tryRunGit)(args, opts.repoRoot);
|
|
7309
|
+
if (result.ok) {
|
|
7310
|
+
return;
|
|
7311
|
+
}
|
|
7312
|
+
const failure = `git ${args.join(" ")} failed: ${result.stderr || "exit 1"}`;
|
|
7313
|
+
const remainingWorktrees = (deps.listWorktrees ?? listGitWorktrees)(opts.repoRoot);
|
|
7314
|
+
if (isRegisteredWorktree(remainingWorktrees, opts.worktreePath)) {
|
|
7315
|
+
throw new Error(failure);
|
|
7316
|
+
}
|
|
7317
|
+
try {
|
|
7318
|
+
(deps.removeDirectory ?? removeDirectory)(opts.worktreePath);
|
|
7319
|
+
} catch (error) {
|
|
7320
|
+
throw new Error(`${failure}; cleanup failed: ${errorMessage(error)}`);
|
|
7321
|
+
}
|
|
7322
|
+
}
|
|
7323
|
+
|
|
7324
|
+
class BunGitGateway {
|
|
7325
|
+
resolveWorktreeRoot(cwd) {
|
|
7326
|
+
return resolveWorktreeRoot(cwd);
|
|
7327
|
+
}
|
|
7328
|
+
resolveWorktreeGitDir(cwd) {
|
|
7329
|
+
return resolveWorktreeGitDir(cwd);
|
|
7330
|
+
}
|
|
7331
|
+
listWorktrees(cwd) {
|
|
7332
|
+
return listGitWorktrees(cwd);
|
|
7333
|
+
}
|
|
7334
|
+
readWorktreeStatus(cwd) {
|
|
7335
|
+
return readGitWorktreeStatus(cwd);
|
|
7336
|
+
}
|
|
7337
|
+
createWorktree(opts) {
|
|
7338
|
+
const args = ["worktree", "add", "-b", opts.branch, opts.worktreePath];
|
|
7339
|
+
if (opts.baseBranch)
|
|
7340
|
+
args.push(opts.baseBranch);
|
|
7341
|
+
runGit(args, opts.repoRoot);
|
|
7342
|
+
}
|
|
7343
|
+
removeWorktree(opts) {
|
|
7344
|
+
removeGitWorktree(opts);
|
|
7345
|
+
}
|
|
7346
|
+
deleteBranch(repoRoot, branch, force = false) {
|
|
7347
|
+
runGit(["branch", force ? "-D" : "-d", branch], repoRoot);
|
|
7348
|
+
}
|
|
7349
|
+
mergeBranch(opts) {
|
|
7350
|
+
const current = currentCheckoutRef(opts.repoRoot);
|
|
7351
|
+
const shouldRestore = current.branch !== opts.targetBranch;
|
|
7352
|
+
if (shouldRestore) {
|
|
7353
|
+
runGit(["checkout", opts.targetBranch], opts.repoRoot);
|
|
7354
|
+
}
|
|
7355
|
+
let mergeError = null;
|
|
7356
|
+
const cleanupErrors = [];
|
|
7357
|
+
try {
|
|
7358
|
+
runGit(["merge", "--no-ff", "--no-edit", opts.sourceBranch], opts.repoRoot);
|
|
7359
|
+
} catch (error) {
|
|
7360
|
+
mergeError = errorMessage(error);
|
|
7361
|
+
const abort = tryRunGit(["merge", "--abort"], opts.repoRoot);
|
|
7362
|
+
if (!abort.ok && abort.stderr.length > 0 && !abort.stderr.includes("MERGE_HEAD missing")) {
|
|
7363
|
+
cleanupErrors.push(`merge abort failed: ${abort.stderr}`);
|
|
7364
|
+
}
|
|
7365
|
+
}
|
|
7366
|
+
if (shouldRestore) {
|
|
7367
|
+
const restore = tryRunGit(["checkout", current.ref], opts.repoRoot);
|
|
7368
|
+
if (!restore.ok) {
|
|
7369
|
+
cleanupErrors.push(`restore checkout failed: ${restore.stderr}`);
|
|
7370
|
+
}
|
|
7371
|
+
}
|
|
7372
|
+
if (mergeError) {
|
|
7373
|
+
const suffix = cleanupErrors.length > 0 ? `; ${cleanupErrors.join("; ")}` : "";
|
|
7374
|
+
throw new Error(`${mergeError}${suffix}`);
|
|
7375
|
+
}
|
|
7376
|
+
if (cleanupErrors.length > 0) {
|
|
7377
|
+
throw new Error(cleanupErrors.join("; "));
|
|
7378
|
+
}
|
|
7379
|
+
}
|
|
7380
|
+
currentBranch(repoRoot) {
|
|
7381
|
+
return runGit(["branch", "--show-current"], repoRoot);
|
|
7012
7382
|
}
|
|
7013
|
-
return result;
|
|
7014
7383
|
}
|
|
7015
7384
|
|
|
7016
|
-
// backend/src/config.ts
|
|
7385
|
+
// backend/src/adapters/config.ts
|
|
7386
|
+
import { readFileSync } from "fs";
|
|
7017
7387
|
import { join } from "path";
|
|
7018
7388
|
|
|
7019
7389
|
// node_modules/.bun/yaml@2.8.2/node_modules/yaml/dist/index.js
|
|
@@ -7062,70 +7432,221 @@ var $stringify = publicApi.stringify;
|
|
|
7062
7432
|
var $visit = visit.visit;
|
|
7063
7433
|
var $visitAsync = visit.visitAsync;
|
|
7064
7434
|
|
|
7065
|
-
// backend/src/config.ts
|
|
7435
|
+
// backend/src/adapters/config.ts
|
|
7436
|
+
var DEFAULT_PANES = [
|
|
7437
|
+
{ id: "agent", kind: "agent", focus: true },
|
|
7438
|
+
{ id: "shell", kind: "shell", split: "right", sizePct: 25 }
|
|
7439
|
+
];
|
|
7066
7440
|
var DEFAULT_CONFIG = {
|
|
7441
|
+
name: "Webmux",
|
|
7442
|
+
workspace: {
|
|
7443
|
+
mainBranch: "main",
|
|
7444
|
+
worktreeRoot: "__worktrees",
|
|
7445
|
+
defaultAgent: "claude"
|
|
7446
|
+
},
|
|
7447
|
+
profiles: {
|
|
7448
|
+
default: {
|
|
7449
|
+
runtime: "host",
|
|
7450
|
+
envPassthrough: [],
|
|
7451
|
+
panes: clonePanes(DEFAULT_PANES)
|
|
7452
|
+
}
|
|
7453
|
+
},
|
|
7067
7454
|
services: [],
|
|
7068
|
-
|
|
7069
|
-
|
|
7070
|
-
|
|
7071
|
-
|
|
7455
|
+
startupEnvs: {},
|
|
7456
|
+
integrations: {
|
|
7457
|
+
github: { linkedRepos: [] },
|
|
7458
|
+
linear: { enabled: true }
|
|
7459
|
+
},
|
|
7460
|
+
lifecycleHooks: {},
|
|
7461
|
+
autoName: null
|
|
7072
7462
|
};
|
|
7073
|
-
function
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
|
|
7077
|
-
|
|
7078
|
-
|
|
7079
|
-
|
|
7080
|
-
|
|
7081
|
-
|
|
7082
|
-
|
|
7083
|
-
|
|
7084
|
-
|
|
7463
|
+
function clonePanes(panes) {
|
|
7464
|
+
return panes.map((pane) => ({ ...pane }));
|
|
7465
|
+
}
|
|
7466
|
+
function isRecord(value) {
|
|
7467
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7468
|
+
}
|
|
7469
|
+
function isStringArray(value) {
|
|
7470
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
7471
|
+
}
|
|
7472
|
+
function parseAgentKind(value) {
|
|
7473
|
+
return value === "codex" ? "codex" : "claude";
|
|
7474
|
+
}
|
|
7475
|
+
function parsePanes(raw) {
|
|
7476
|
+
if (!Array.isArray(raw))
|
|
7477
|
+
return clonePanes(DEFAULT_PANES);
|
|
7478
|
+
const panes = raw.map((entry, index) => parsePane(entry, index)).filter((pane) => pane !== null);
|
|
7479
|
+
return panes.length > 0 ? panes : clonePanes(DEFAULT_PANES);
|
|
7480
|
+
}
|
|
7481
|
+
function parsePane(raw, index) {
|
|
7482
|
+
if (!isRecord(raw))
|
|
7483
|
+
return null;
|
|
7484
|
+
if (raw.kind !== "agent" && raw.kind !== "shell" && raw.kind !== "command")
|
|
7485
|
+
return null;
|
|
7486
|
+
const pane = {
|
|
7487
|
+
id: typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : `pane-${index + 1}`,
|
|
7488
|
+
kind: raw.kind
|
|
7489
|
+
};
|
|
7490
|
+
if (raw.split === "right" || raw.split === "bottom")
|
|
7491
|
+
pane.split = raw.split;
|
|
7492
|
+
if (typeof raw.sizePct === "number" && Number.isFinite(raw.sizePct))
|
|
7493
|
+
pane.sizePct = raw.sizePct;
|
|
7494
|
+
if (raw.focus === true)
|
|
7495
|
+
pane.focus = true;
|
|
7496
|
+
if (raw.cwd === "repo" || raw.cwd === "worktree")
|
|
7497
|
+
pane.cwd = raw.cwd;
|
|
7498
|
+
if (raw.kind === "command") {
|
|
7499
|
+
if (typeof raw.command !== "string" || !raw.command.trim())
|
|
7500
|
+
return null;
|
|
7501
|
+
pane.command = raw.command.trim();
|
|
7502
|
+
}
|
|
7503
|
+
return pane;
|
|
7504
|
+
}
|
|
7505
|
+
function parseMounts(raw) {
|
|
7506
|
+
if (!Array.isArray(raw))
|
|
7507
|
+
return;
|
|
7508
|
+
const mounts = raw.filter(isRecord).filter((entry) => typeof entry.hostPath === "string" && entry.hostPath.length > 0).map((entry) => ({
|
|
7509
|
+
hostPath: entry.hostPath,
|
|
7510
|
+
...typeof entry.guestPath === "string" && entry.guestPath.length > 0 ? { guestPath: entry.guestPath } : {},
|
|
7511
|
+
...typeof entry.writable === "boolean" ? { writable: entry.writable } : {}
|
|
7512
|
+
}));
|
|
7513
|
+
return mounts.length > 0 ? mounts : undefined;
|
|
7514
|
+
}
|
|
7515
|
+
function parseProfile(raw, fallbackRuntime) {
|
|
7516
|
+
if (!isRecord(raw)) {
|
|
7517
|
+
return {
|
|
7518
|
+
runtime: fallbackRuntime,
|
|
7519
|
+
envPassthrough: [],
|
|
7520
|
+
panes: clonePanes(DEFAULT_PANES)
|
|
7521
|
+
};
|
|
7522
|
+
}
|
|
7523
|
+
const runtime = raw.runtime === "docker" ? "docker" : fallbackRuntime;
|
|
7524
|
+
const envPassthrough = isStringArray(raw.envPassthrough) ? raw.envPassthrough : [];
|
|
7525
|
+
const panes = parsePanes(raw.panes);
|
|
7526
|
+
const mounts = parseMounts(raw.mounts);
|
|
7527
|
+
const image = typeof raw.image === "string" && raw.image.trim() ? raw.image.trim() : undefined;
|
|
7528
|
+
return {
|
|
7529
|
+
runtime,
|
|
7530
|
+
envPassthrough,
|
|
7531
|
+
...raw.yolo === true ? { yolo: true } : {},
|
|
7532
|
+
panes,
|
|
7533
|
+
...typeof raw.systemPrompt === "string" && raw.systemPrompt.length > 0 ? { systemPrompt: raw.systemPrompt } : {},
|
|
7534
|
+
...image ? { image } : {},
|
|
7535
|
+
...mounts ? { mounts } : {}
|
|
7536
|
+
};
|
|
7537
|
+
}
|
|
7538
|
+
function parseProfiles(raw) {
|
|
7539
|
+
if (!isRecord(raw))
|
|
7540
|
+
return { default: { ...DEFAULT_CONFIG.profiles.default, panes: clonePanes(DEFAULT_CONFIG.profiles.default.panes) } };
|
|
7541
|
+
const profiles = Object.entries(raw).reduce((acc, [name, value]) => {
|
|
7542
|
+
const fallbackRuntime = name === "sandbox" ? "docker" : "host";
|
|
7543
|
+
acc[name] = parseProfile(value, fallbackRuntime);
|
|
7544
|
+
return acc;
|
|
7545
|
+
}, {});
|
|
7546
|
+
if (Object.keys(profiles).length === 0) {
|
|
7547
|
+
return { default: { ...DEFAULT_CONFIG.profiles.default, panes: clonePanes(DEFAULT_CONFIG.profiles.default.panes) } };
|
|
7548
|
+
}
|
|
7549
|
+
return profiles;
|
|
7550
|
+
}
|
|
7551
|
+
function parseServices(raw) {
|
|
7552
|
+
if (!Array.isArray(raw))
|
|
7553
|
+
return [];
|
|
7554
|
+
return raw.filter(isRecord).filter((entry) => typeof entry.name === "string" && typeof entry.portEnv === "string").map((entry) => ({
|
|
7555
|
+
name: entry.name,
|
|
7556
|
+
portEnv: entry.portEnv,
|
|
7557
|
+
...typeof entry.portStart === "number" && Number.isFinite(entry.portStart) ? { portStart: entry.portStart } : {},
|
|
7558
|
+
...typeof entry.portStep === "number" && Number.isFinite(entry.portStep) ? { portStep: entry.portStep } : {},
|
|
7559
|
+
...typeof entry.urlTemplate === "string" && entry.urlTemplate.length > 0 ? { urlTemplate: entry.urlTemplate } : {}
|
|
7560
|
+
}));
|
|
7561
|
+
}
|
|
7562
|
+
function parseStartupEnvs(raw) {
|
|
7563
|
+
if (!isRecord(raw))
|
|
7564
|
+
return {};
|
|
7565
|
+
const startupEnvs = {};
|
|
7566
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
7567
|
+
if (typeof value === "boolean") {
|
|
7568
|
+
startupEnvs[key] = value;
|
|
7569
|
+
} else {
|
|
7570
|
+
startupEnvs[key] = typeof value === "string" ? value : String(value);
|
|
7571
|
+
}
|
|
7572
|
+
}
|
|
7573
|
+
return startupEnvs;
|
|
7574
|
+
}
|
|
7575
|
+
function parseLifecycleHooks(raw) {
|
|
7576
|
+
if (!isRecord(raw))
|
|
7577
|
+
return {};
|
|
7578
|
+
const hooks = {};
|
|
7579
|
+
if (typeof raw.postCreate === "string" && raw.postCreate.trim()) {
|
|
7580
|
+
hooks.postCreate = raw.postCreate.trim();
|
|
7085
7581
|
}
|
|
7582
|
+
if (typeof raw.preRemove === "string" && raw.preRemove.trim()) {
|
|
7583
|
+
hooks.preRemove = raw.preRemove.trim();
|
|
7584
|
+
}
|
|
7585
|
+
return hooks;
|
|
7586
|
+
}
|
|
7587
|
+
function parseAutoName(raw) {
|
|
7588
|
+
if (!isRecord(raw))
|
|
7589
|
+
return null;
|
|
7590
|
+
if (typeof raw.model !== "string" || !raw.model.trim())
|
|
7591
|
+
return null;
|
|
7592
|
+
return {
|
|
7593
|
+
model: raw.model.trim(),
|
|
7594
|
+
...typeof raw.system_prompt === "string" && raw.system_prompt.trim() ? { systemPrompt: raw.system_prompt.trim() } : {}
|
|
7595
|
+
};
|
|
7596
|
+
}
|
|
7597
|
+
function parseLinkedRepos(raw) {
|
|
7598
|
+
if (!Array.isArray(raw))
|
|
7599
|
+
return [];
|
|
7600
|
+
return raw.filter(isRecord).filter((entry) => typeof entry.repo === "string").map((entry) => ({
|
|
7601
|
+
repo: entry.repo,
|
|
7602
|
+
alias: typeof entry.alias === "string" ? entry.alias : entry.repo.split("/").pop() ?? "repo"
|
|
7603
|
+
}));
|
|
7604
|
+
}
|
|
7605
|
+
function isDockerProfile(profile) {
|
|
7606
|
+
return !!profile && profile.runtime === "docker" && typeof profile.image === "string" && profile.image.length > 0;
|
|
7607
|
+
}
|
|
7608
|
+
function getDefaultProfileName(config) {
|
|
7609
|
+
if (config.profiles.default)
|
|
7610
|
+
return "default";
|
|
7611
|
+
return Object.keys(config.profiles)[0] ?? "default";
|
|
7612
|
+
}
|
|
7613
|
+
function readConfigFile(root) {
|
|
7614
|
+
return readFileSync(join(root, ".webmux.yaml"), "utf8");
|
|
7086
7615
|
}
|
|
7087
7616
|
function gitRoot(dir) {
|
|
7088
|
-
const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { stdout: "pipe", cwd: dir });
|
|
7089
|
-
|
|
7617
|
+
const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { stdout: "pipe", stderr: "pipe", cwd: dir });
|
|
7618
|
+
if (result.exitCode !== 0)
|
|
7619
|
+
return dir;
|
|
7620
|
+
const root = new TextDecoder().decode(result.stdout).trim();
|
|
7621
|
+
return root || dir;
|
|
7090
7622
|
}
|
|
7091
7623
|
function loadConfig(dir) {
|
|
7092
7624
|
try {
|
|
7093
7625
|
const root = gitRoot(dir);
|
|
7094
|
-
const
|
|
7095
|
-
const result = Bun.spawnSync(["cat", filePath], { stdout: "pipe" });
|
|
7096
|
-
const text = new TextDecoder().decode(result.stdout).trim();
|
|
7626
|
+
const text = readConfigFile(root).trim();
|
|
7097
7627
|
if (!text)
|
|
7098
7628
|
return DEFAULT_CONFIG;
|
|
7099
7629
|
const parsed = $parse(text);
|
|
7100
|
-
const profiles = parsed.profiles;
|
|
7101
|
-
const defaultProfile = profiles?.default;
|
|
7102
|
-
const sandboxProfile = profiles?.sandbox;
|
|
7103
|
-
const autoName = hasAutoName(dir);
|
|
7104
|
-
const linkedRepos = Array.isArray(parsed.linkedRepos) ? parsed.linkedRepos.filter((r) => typeof r === "object" && r !== null && typeof r.repo === "string").map((r) => ({
|
|
7105
|
-
repo: r.repo,
|
|
7106
|
-
alias: typeof r.alias === "string" ? r.alias : r.repo.split("/").pop()
|
|
7107
|
-
})) : [];
|
|
7108
|
-
let startupEnvs = {};
|
|
7109
|
-
if (parsed.startupEnvs && typeof parsed.startupEnvs === "object" && !Array.isArray(parsed.startupEnvs)) {
|
|
7110
|
-
const raw = parsed.startupEnvs;
|
|
7111
|
-
for (const [k, v] of Object.entries(raw)) {
|
|
7112
|
-
if (typeof v === "boolean") {
|
|
7113
|
-
startupEnvs[k] = v;
|
|
7114
|
-
} else {
|
|
7115
|
-
startupEnvs[k] = typeof v === "string" ? v : String(v);
|
|
7116
|
-
}
|
|
7117
|
-
}
|
|
7118
|
-
}
|
|
7119
7630
|
return {
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7631
|
+
name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : DEFAULT_CONFIG.name,
|
|
7632
|
+
workspace: {
|
|
7633
|
+
mainBranch: isRecord(parsed.workspace) && typeof parsed.workspace.mainBranch === "string" ? parsed.workspace.mainBranch : DEFAULT_CONFIG.workspace.mainBranch,
|
|
7634
|
+
worktreeRoot: isRecord(parsed.workspace) && typeof parsed.workspace.worktreeRoot === "string" ? parsed.workspace.worktreeRoot : DEFAULT_CONFIG.workspace.worktreeRoot,
|
|
7635
|
+
defaultAgent: isRecord(parsed.workspace) ? parseAgentKind(parsed.workspace.defaultAgent) : DEFAULT_CONFIG.workspace.defaultAgent
|
|
7636
|
+
},
|
|
7637
|
+
profiles: parseProfiles(parsed.profiles),
|
|
7638
|
+
services: parseServices(parsed.services),
|
|
7639
|
+
startupEnvs: parseStartupEnvs(parsed.startupEnvs),
|
|
7640
|
+
integrations: {
|
|
7641
|
+
github: {
|
|
7642
|
+
linkedRepos: isRecord(parsed.integrations) && isRecord(parsed.integrations.github) ? parseLinkedRepos(parsed.integrations.github.linkedRepos) : []
|
|
7643
|
+
},
|
|
7644
|
+
linear: {
|
|
7645
|
+
enabled: isRecord(parsed.integrations) && isRecord(parsed.integrations.linear) && typeof parsed.integrations.linear.enabled === "boolean" ? parsed.integrations.linear.enabled : DEFAULT_CONFIG.integrations.linear.enabled
|
|
7646
|
+
}
|
|
7125
7647
|
},
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
startupEnvs
|
|
7648
|
+
lifecycleHooks: parseLifecycleHooks(parsed.lifecycleHooks),
|
|
7649
|
+
autoName: parseAutoName(parsed.auto_name)
|
|
7129
7650
|
};
|
|
7130
7651
|
} catch {
|
|
7131
7652
|
return DEFAULT_CONFIG;
|
|
@@ -7135,31 +7656,29 @@ function expandTemplate(template, env) {
|
|
|
7135
7656
|
return template.replace(/\$\{(\w+)\}/g, (_, key) => env[key] ?? "");
|
|
7136
7657
|
}
|
|
7137
7658
|
|
|
7138
|
-
// backend/src/
|
|
7139
|
-
import { stat } from "fs/promises";
|
|
7140
|
-
|
|
7141
|
-
// backend/src/rpc-secret.ts
|
|
7659
|
+
// backend/src/adapters/control-token.ts
|
|
7142
7660
|
import { chmod, mkdir } from "fs/promises";
|
|
7143
7661
|
import { dirname } from "path";
|
|
7144
|
-
var
|
|
7145
|
-
var
|
|
7146
|
-
async function
|
|
7147
|
-
if (
|
|
7148
|
-
return
|
|
7149
|
-
const file = Bun.file(
|
|
7662
|
+
var CONTROL_TOKEN_PATH = `${Bun.env.HOME ?? "/root"}/.config/webmux/control-token`;
|
|
7663
|
+
var cachedToken = null;
|
|
7664
|
+
async function loadControlToken() {
|
|
7665
|
+
if (cachedToken)
|
|
7666
|
+
return cachedToken;
|
|
7667
|
+
const file = Bun.file(CONTROL_TOKEN_PATH);
|
|
7150
7668
|
if (await file.exists()) {
|
|
7151
|
-
|
|
7152
|
-
return
|
|
7153
|
-
}
|
|
7154
|
-
const
|
|
7155
|
-
await mkdir(dirname(
|
|
7156
|
-
await Bun.write(
|
|
7157
|
-
await chmod(
|
|
7158
|
-
|
|
7159
|
-
return
|
|
7669
|
+
cachedToken = (await file.text()).trim();
|
|
7670
|
+
return cachedToken;
|
|
7671
|
+
}
|
|
7672
|
+
const controlToken = crypto.randomUUID();
|
|
7673
|
+
await mkdir(dirname(CONTROL_TOKEN_PATH), { recursive: true });
|
|
7674
|
+
await Bun.write(CONTROL_TOKEN_PATH, controlToken);
|
|
7675
|
+
await chmod(CONTROL_TOKEN_PATH, 384);
|
|
7676
|
+
cachedToken = controlToken;
|
|
7677
|
+
return controlToken;
|
|
7160
7678
|
}
|
|
7161
7679
|
|
|
7162
|
-
// backend/src/docker.ts
|
|
7680
|
+
// backend/src/adapters/docker.ts
|
|
7681
|
+
import { stat } from "fs/promises";
|
|
7163
7682
|
var DOCKER_RUN_TIMEOUT_MS = 60000;
|
|
7164
7683
|
async function pathExists(p) {
|
|
7165
7684
|
try {
|
|
@@ -7183,39 +7702,17 @@ function isValidPort(s) {
|
|
|
7183
7702
|
function isValidEnvKey(s) {
|
|
7184
7703
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(s);
|
|
7185
7704
|
}
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
7194
|
-
token = os.environ.get("WORKMUX_RPC_TOKEN", "")
|
|
7195
|
-
branch = os.environ.get("WORKMUX_BRANCH", "")
|
|
7196
|
-
|
|
7197
|
-
payload = {"command": cmd, "args": args, "branch": branch}
|
|
7198
|
-
data = json.dumps(payload).encode()
|
|
7199
|
-
req = urllib.request.Request(
|
|
7200
|
-
f"http://{host}:{port}/rpc/workmux",
|
|
7201
|
-
data=data,
|
|
7202
|
-
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
|
|
7203
|
-
)
|
|
7204
|
-
try:
|
|
7205
|
-
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
7206
|
-
result = json.loads(resp.read())
|
|
7207
|
-
if result.get("ok"):
|
|
7208
|
-
print(result.get("output", ""))
|
|
7209
|
-
else:
|
|
7210
|
-
print(result.get("error", "RPC failed"), file=sys.stderr)
|
|
7211
|
-
sys.exit(1)
|
|
7212
|
-
except Exception as e:
|
|
7213
|
-
print(f"workmux rpc error: {e}", file=sys.stderr)
|
|
7214
|
-
sys.exit(1)
|
|
7215
|
-
`;
|
|
7705
|
+
|
|
7706
|
+
class BunDockerGateway {
|
|
7707
|
+
launchContainer(opts) {
|
|
7708
|
+
return launchContainer(opts);
|
|
7709
|
+
}
|
|
7710
|
+
removeContainer(branch) {
|
|
7711
|
+
return removeContainer(branch);
|
|
7712
|
+
}
|
|
7216
7713
|
}
|
|
7217
|
-
function buildDockerRunArgs(opts, existingPaths, home, name,
|
|
7218
|
-
const { wtDir, mainRepoDir, sandboxConfig, services,
|
|
7714
|
+
function buildDockerRunArgs(opts, existingPaths, home, name, sshAuthSock, hostUid, hostGid) {
|
|
7715
|
+
const { wtDir, mainRepoDir, sandboxConfig, services, runtimeEnv } = opts;
|
|
7219
7716
|
const args = [
|
|
7220
7717
|
"docker",
|
|
7221
7718
|
"run",
|
|
@@ -7231,7 +7728,7 @@ function buildDockerRunArgs(opts, existingPaths, home, name, rpcSecret, rpcPort,
|
|
|
7231
7728
|
];
|
|
7232
7729
|
const seenPorts = new Set;
|
|
7233
7730
|
for (const svc of services) {
|
|
7234
|
-
const port =
|
|
7731
|
+
const port = runtimeEnv[svc.portEnv];
|
|
7235
7732
|
if (!port)
|
|
7236
7733
|
continue;
|
|
7237
7734
|
if (!isValidPort(port)) {
|
|
@@ -7276,9 +7773,9 @@ function buildDockerRunArgs(opts, existingPaths, home, name, rpcSecret, rpcPort,
|
|
|
7276
7773
|
}
|
|
7277
7774
|
}
|
|
7278
7775
|
}
|
|
7279
|
-
for (const [key, val] of Object.entries(
|
|
7776
|
+
for (const [key, val] of Object.entries(runtimeEnv)) {
|
|
7280
7777
|
if (!isValidEnvKey(key)) {
|
|
7281
|
-
log.warn(`[docker] skipping invalid
|
|
7778
|
+
log.warn(`[docker] skipping invalid runtime env key: ${JSON.stringify(key)}`);
|
|
7282
7779
|
continue;
|
|
7283
7780
|
}
|
|
7284
7781
|
if (reservedKeys.has(key))
|
|
@@ -7292,8 +7789,8 @@ function buildDockerRunArgs(opts, existingPaths, home, name, rpcSecret, rpcPort,
|
|
|
7292
7789
|
args.push("-v", `${home}/.claude.json:/root/.claude.json`);
|
|
7293
7790
|
args.push("-v", `${home}/.codex:/root/.codex`);
|
|
7294
7791
|
const extraMountGuestPaths = new Set;
|
|
7295
|
-
if (sandboxConfig.
|
|
7296
|
-
for (const mount of sandboxConfig.
|
|
7792
|
+
if (sandboxConfig.mounts) {
|
|
7793
|
+
for (const mount of sandboxConfig.mounts) {
|
|
7297
7794
|
const hostPath = mount.hostPath.replace(/^~/, home);
|
|
7298
7795
|
if (!hostPath.startsWith("/"))
|
|
7299
7796
|
continue;
|
|
@@ -7316,11 +7813,11 @@ function buildDockerRunArgs(opts, existingPaths, home, name, rpcSecret, rpcPort,
|
|
|
7316
7813
|
args.push("--mount", `type=bind,source=${sshAuthSock},target=${sshAuthSock}`);
|
|
7317
7814
|
args.push("-e", `SSH_AUTH_SOCK=${sshAuthSock}`);
|
|
7318
7815
|
}
|
|
7319
|
-
if (sandboxConfig.
|
|
7320
|
-
for (const mount of sandboxConfig.
|
|
7816
|
+
if (sandboxConfig.mounts) {
|
|
7817
|
+
for (const mount of sandboxConfig.mounts) {
|
|
7321
7818
|
const hostPath = mount.hostPath.replace(/^~/, home);
|
|
7322
7819
|
if (!hostPath.startsWith("/")) {
|
|
7323
|
-
log.warn(`[docker] skipping
|
|
7820
|
+
log.warn(`[docker] skipping mount with non-absolute host path: ${JSON.stringify(hostPath)}`);
|
|
7324
7821
|
continue;
|
|
7325
7822
|
}
|
|
7326
7823
|
const guestPath = mount.guestPath ?? hostPath;
|
|
@@ -7328,10 +7825,6 @@ function buildDockerRunArgs(opts, existingPaths, home, name, rpcSecret, rpcPort,
|
|
|
7328
7825
|
args.push("-v", `${hostPath}:${guestPath}${suffix}`);
|
|
7329
7826
|
}
|
|
7330
7827
|
}
|
|
7331
|
-
args.push("-e", `WORKMUX_RPC_HOST=host.docker.internal`);
|
|
7332
|
-
args.push("-e", `WORKMUX_RPC_PORT=${rpcPort}`);
|
|
7333
|
-
args.push("-e", `WORKMUX_RPC_TOKEN=${rpcSecret}`);
|
|
7334
|
-
args.push("-e", `WORKMUX_BRANCH=${opts.branch}`);
|
|
7335
7828
|
args.push(sandboxConfig.image, "sleep", "infinity");
|
|
7336
7829
|
return args;
|
|
7337
7830
|
}
|
|
@@ -7347,8 +7840,6 @@ async function launchContainer(opts) {
|
|
|
7347
7840
|
}
|
|
7348
7841
|
const name = containerName(branch);
|
|
7349
7842
|
const home = Bun.env.HOME ?? "/root";
|
|
7350
|
-
const rpcSecret = await loadRpcSecret();
|
|
7351
|
-
const rpcPort = Bun.env.BACKEND_PORT ?? "5111";
|
|
7352
7843
|
let sshAuthSock = Bun.env.SSH_AUTH_SOCK;
|
|
7353
7844
|
if (sshAuthSock) {
|
|
7354
7845
|
try {
|
|
@@ -7372,7 +7863,7 @@ async function launchContainer(opts) {
|
|
|
7372
7863
|
if (await pathExists(p))
|
|
7373
7864
|
existingPaths.add(p);
|
|
7374
7865
|
}));
|
|
7375
|
-
const args = buildDockerRunArgs(opts, existingPaths, home, name,
|
|
7866
|
+
const args = buildDockerRunArgs(opts, existingPaths, home, name, sshAuthSock, process.getuid(), process.getgid());
|
|
7376
7867
|
log.info(`[docker] launching container: ${name}`);
|
|
7377
7868
|
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
7378
7869
|
const timeout = Bun.sleep(DOCKER_RUN_TIMEOUT_MS).then(() => {
|
|
@@ -7393,30 +7884,6 @@ async function launchContainer(opts) {
|
|
|
7393
7884
|
throw new Error(`docker run failed (exit ${exitResult}): ${stderr}`);
|
|
7394
7885
|
}
|
|
7395
7886
|
log.info(`[docker] container ${name} ready (id=${containerId.trim().slice(0, 12)})`);
|
|
7396
|
-
const stub = buildWorkmuxStub();
|
|
7397
|
-
const injectProc = Bun.spawn([
|
|
7398
|
-
"docker",
|
|
7399
|
-
"exec",
|
|
7400
|
-
"-u",
|
|
7401
|
-
"root",
|
|
7402
|
-
"-i",
|
|
7403
|
-
name,
|
|
7404
|
-
"sh",
|
|
7405
|
-
"-c",
|
|
7406
|
-
"cat > /usr/local/bin/workmux && chmod +x /usr/local/bin/workmux"
|
|
7407
|
-
], { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
7408
|
-
const { stdin } = injectProc;
|
|
7409
|
-
if (stdin) {
|
|
7410
|
-
stdin.write(stub);
|
|
7411
|
-
stdin.end();
|
|
7412
|
-
}
|
|
7413
|
-
const injectExit = await injectProc.exited;
|
|
7414
|
-
if (injectExit !== 0) {
|
|
7415
|
-
const injectStderr = await new Response(injectProc.stderr).text();
|
|
7416
|
-
log.warn(`[docker] workmux stub injection failed for ${name}: ${injectStderr}`);
|
|
7417
|
-
} else {
|
|
7418
|
-
log.debug(`[docker] workmux stub injected into ${name}`);
|
|
7419
|
-
}
|
|
7420
7887
|
return name;
|
|
7421
7888
|
}
|
|
7422
7889
|
async function findContainer(branch) {
|
|
@@ -7463,701 +7930,1807 @@ async function removeContainer(branch) {
|
|
|
7463
7930
|
}));
|
|
7464
7931
|
}
|
|
7465
7932
|
|
|
7466
|
-
// backend/src/
|
|
7467
|
-
|
|
7468
|
-
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
return [];
|
|
7474
|
-
const headerLine = lines[0];
|
|
7475
|
-
if (expectedHeaders) {
|
|
7476
|
-
const actual = headerLine.trim().split(/\s+/).map((h) => h.toUpperCase());
|
|
7477
|
-
const match = expectedHeaders.every((h, i) => actual[i] === h.toUpperCase());
|
|
7478
|
-
if (!match) {
|
|
7479
|
-
log.warn(`[parseTable] unexpected headers: got [${actual.join(", ")}], expected [${expectedHeaders.join(", ")}]`);
|
|
7480
|
-
}
|
|
7481
|
-
}
|
|
7482
|
-
const colStarts = [];
|
|
7483
|
-
let inSpace = true;
|
|
7484
|
-
for (let i = 0;i < headerLine.length; i++) {
|
|
7485
|
-
if (headerLine[i] !== " " && inSpace) {
|
|
7486
|
-
colStarts.push(i);
|
|
7487
|
-
inSpace = false;
|
|
7488
|
-
} else if (headerLine[i] === " " && !inSpace) {
|
|
7489
|
-
inSpace = true;
|
|
7490
|
-
}
|
|
7491
|
-
}
|
|
7492
|
-
return lines.slice(1).map((line) => {
|
|
7493
|
-
const cols = colStarts.map((start, idx) => {
|
|
7494
|
-
const end = idx + 1 < colStarts.length ? colStarts[idx + 1] : line.length;
|
|
7495
|
-
return line.slice(start, end).trim();
|
|
7496
|
-
});
|
|
7497
|
-
return mapper(cols);
|
|
7498
|
-
});
|
|
7499
|
-
}
|
|
7500
|
-
function workmuxEnv() {
|
|
7501
|
-
if (process.env.TMUX)
|
|
7502
|
-
return process.env;
|
|
7503
|
-
const tmpdir = process.env.TMUX_TMPDIR || "/tmp";
|
|
7504
|
-
const uid = process.getuid?.() ?? 1000;
|
|
7505
|
-
return { ...process.env, TMUX: `${tmpdir}/tmux-${uid}/default,0,0` };
|
|
7506
|
-
}
|
|
7507
|
-
function resolveDetachedBranch(branch, path) {
|
|
7508
|
-
if (branch !== "(detached)" || !path)
|
|
7509
|
-
return branch;
|
|
7510
|
-
return path.split("/").pop() || branch;
|
|
7933
|
+
// backend/src/adapters/hooks.ts
|
|
7934
|
+
function buildErrorMessage(name, exitCode, stdout, stderr) {
|
|
7935
|
+
const output = stderr.trim() || stdout.trim();
|
|
7936
|
+
if (output) {
|
|
7937
|
+
return `${name} hook failed (exit ${exitCode}): ${output}`;
|
|
7938
|
+
}
|
|
7939
|
+
return `${name} hook failed (exit ${exitCode})`;
|
|
7511
7940
|
}
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
|
|
7515
|
-
|
|
7516
|
-
|
|
7941
|
+
|
|
7942
|
+
class BunLifecycleHookRunner {
|
|
7943
|
+
async run(input) {
|
|
7944
|
+
const proc = Bun.spawn(["bash", "-lc", input.command], {
|
|
7945
|
+
cwd: input.cwd,
|
|
7946
|
+
env: {
|
|
7947
|
+
...Bun.env,
|
|
7948
|
+
...input.env
|
|
7949
|
+
},
|
|
7950
|
+
stdout: "pipe",
|
|
7951
|
+
stderr: "pipe"
|
|
7952
|
+
});
|
|
7953
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
7954
|
+
proc.exited,
|
|
7955
|
+
new Response(proc.stdout).text(),
|
|
7956
|
+
new Response(proc.stderr).text()
|
|
7957
|
+
]);
|
|
7958
|
+
if (exitCode !== 0) {
|
|
7959
|
+
throw new Error(buildErrorMessage(input.name, exitCode, stdout, stderr));
|
|
7960
|
+
}
|
|
7517
7961
|
}
|
|
7518
|
-
return parseTable(proc.text(), (cols) => {
|
|
7519
|
-
const branch = resolveDetachedBranch(cols[0] ?? "", cols[4] ?? "");
|
|
7520
|
-
const path = cols[4] ?? "";
|
|
7521
|
-
return { branch, agent: cols[1] ?? "", mux: cols[2] ?? "", unmerged: cols[3] ?? "", path };
|
|
7522
|
-
}, WORKTREE_HEADERS);
|
|
7523
7962
|
}
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
7963
|
+
|
|
7964
|
+
// backend/src/adapters/port-probe.ts
|
|
7965
|
+
class BunPortProbe {
|
|
7966
|
+
timeoutMs;
|
|
7967
|
+
hostname;
|
|
7968
|
+
constructor(timeoutMs = 300, hostname = "127.0.0.1") {
|
|
7969
|
+
this.timeoutMs = timeoutMs;
|
|
7970
|
+
this.hostname = hostname;
|
|
7971
|
+
}
|
|
7972
|
+
isListening(port) {
|
|
7973
|
+
return new Promise((resolve2) => {
|
|
7974
|
+
let settled = false;
|
|
7975
|
+
const settle = (result) => {
|
|
7976
|
+
if (settled)
|
|
7977
|
+
return;
|
|
7978
|
+
settled = true;
|
|
7979
|
+
clearTimeout(timer);
|
|
7980
|
+
resolve2(result);
|
|
7981
|
+
};
|
|
7982
|
+
const timer = setTimeout(() => settle(false), this.timeoutMs);
|
|
7983
|
+
Bun.connect({
|
|
7984
|
+
hostname: this.hostname,
|
|
7985
|
+
port,
|
|
7986
|
+
socket: {
|
|
7987
|
+
open(socket) {
|
|
7988
|
+
socket.end();
|
|
7989
|
+
settle(true);
|
|
7990
|
+
},
|
|
7991
|
+
error() {
|
|
7992
|
+
settle(false);
|
|
7993
|
+
},
|
|
7994
|
+
data() {}
|
|
7995
|
+
}
|
|
7996
|
+
}).catch(() => settle(false));
|
|
7997
|
+
});
|
|
7529
7998
|
}
|
|
7530
|
-
return parseTable(proc.text(), (cols) => ({
|
|
7531
|
-
worktree: cols[0] ?? "",
|
|
7532
|
-
status: cols[1] ?? "",
|
|
7533
|
-
elapsed: cols[2] ?? "",
|
|
7534
|
-
title: cols[3] ?? ""
|
|
7535
|
-
}), STATUS_HEADERS);
|
|
7536
7999
|
}
|
|
7537
|
-
|
|
7538
|
-
|
|
8000
|
+
|
|
8001
|
+
// backend/src/adapters/tmux.ts
|
|
8002
|
+
import { createHash } from "crypto";
|
|
8003
|
+
import { basename, resolve as resolve2 } from "path";
|
|
8004
|
+
function runTmux(args) {
|
|
8005
|
+
const result = Bun.spawnSync(["tmux", ...args], {
|
|
7539
8006
|
stdout: "pipe",
|
|
7540
|
-
stderr: "pipe"
|
|
7541
|
-
...env ? { env: { ...Bun.env, ...env } } : {}
|
|
8007
|
+
stderr: "pipe"
|
|
7542
8008
|
});
|
|
7543
|
-
|
|
7544
|
-
|
|
7545
|
-
|
|
7546
|
-
|
|
7547
|
-
|
|
7548
|
-
log.error(`[workmux:exec] ${msg}`);
|
|
7549
|
-
return { ok: false, error: msg };
|
|
7550
|
-
}
|
|
7551
|
-
return { ok: true, stdout: stdout.trim() };
|
|
8009
|
+
return {
|
|
8010
|
+
stdout: new TextDecoder().decode(result.stdout).trim(),
|
|
8011
|
+
stderr: new TextDecoder().decode(result.stderr).trim(),
|
|
8012
|
+
exitCode: result.exitCode
|
|
8013
|
+
};
|
|
7552
8014
|
}
|
|
7553
|
-
function
|
|
7554
|
-
const
|
|
7555
|
-
|
|
7556
|
-
|
|
7557
|
-
const envPrefix = !isSandbox && profileConfig.envPassthrough?.length ? buildEnvPrefix(profileConfig.envPassthrough, { ...process.env, ...env }) : "";
|
|
7558
|
-
const promptSuffix = promptEscaped ? ` "${promptEscaped}"` : "";
|
|
7559
|
-
if (agent === "codex") {
|
|
7560
|
-
return systemPrompt ? `${envPrefix}codex --yolo -c "developer_instructions=${innerEscaped}"${promptSuffix}` : `${envPrefix}codex --yolo${promptSuffix}`;
|
|
8015
|
+
function assertTmuxOk(args, action) {
|
|
8016
|
+
const result = runTmux(args);
|
|
8017
|
+
if (result.exitCode !== 0) {
|
|
8018
|
+
throw new Error(`${action} failed: ${result.stderr || `tmux ${args.join(" ")} exit ${result.exitCode}`}`);
|
|
7561
8019
|
}
|
|
7562
|
-
|
|
7563
|
-
return systemPrompt ? `${envPrefix}claude${skipPerms} --append-system-prompt "${innerEscaped}"${promptSuffix}` : `${envPrefix}claude${skipPerms}${promptSuffix}`;
|
|
8020
|
+
return result.stdout;
|
|
7564
8021
|
}
|
|
7565
|
-
function
|
|
7566
|
-
const
|
|
7567
|
-
|
|
7568
|
-
|
|
7569
|
-
if (val) {
|
|
7570
|
-
const escaped = val.replace(/'/g, "'\\''");
|
|
7571
|
-
parts.push(`${key}='${escaped}'`);
|
|
7572
|
-
}
|
|
7573
|
-
}
|
|
7574
|
-
return parts.length > 0 ? parts.join(" ") + " " : "";
|
|
8022
|
+
function sanitizeTmuxNameSegment(value, maxLength = 24) {
|
|
8023
|
+
const sanitized = value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[.-]+|[.-]+$/g, "");
|
|
8024
|
+
const trimmed = sanitized.slice(0, maxLength);
|
|
8025
|
+
return trimmed || "x";
|
|
7575
8026
|
}
|
|
7576
|
-
function
|
|
7577
|
-
const
|
|
7578
|
-
|
|
7579
|
-
|
|
7580
|
-
`
|
|
7581
|
-
if (line.startsWith("worktree ")) {
|
|
7582
|
-
currentPath = line.slice("worktree ".length);
|
|
7583
|
-
} else if (line.startsWith("branch ")) {
|
|
7584
|
-
const name = line.slice("branch ".length).replace("refs/heads/", "");
|
|
7585
|
-
if (currentPath)
|
|
7586
|
-
paths.set(name, currentPath);
|
|
7587
|
-
}
|
|
7588
|
-
}
|
|
7589
|
-
return paths;
|
|
8027
|
+
function buildProjectSessionName(projectRoot) {
|
|
8028
|
+
const resolved = resolve2(projectRoot);
|
|
8029
|
+
const base = sanitizeTmuxNameSegment(basename(resolved), 18);
|
|
8030
|
+
const hash = createHash("sha1").update(resolved).digest("hex").slice(0, 8);
|
|
8031
|
+
return `wm-${base}-${hash}`;
|
|
7590
8032
|
}
|
|
7591
|
-
function
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
8033
|
+
function buildWorktreeWindowName(branch) {
|
|
8034
|
+
return `wm-${branch}`;
|
|
8035
|
+
}
|
|
8036
|
+
function parseWindowSummaries(output) {
|
|
8037
|
+
return output.split(`
|
|
8038
|
+
`).map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
8039
|
+
const [sessionName = "", windowName = "", paneCountRaw = "0"] = line.split("\t");
|
|
8040
|
+
return {
|
|
8041
|
+
sessionName,
|
|
8042
|
+
windowName,
|
|
8043
|
+
paneCount: parseInt(paneCountRaw, 10) || 0
|
|
8044
|
+
};
|
|
8045
|
+
}).filter((entry) => entry.sessionName.length > 0 && entry.windowName.length > 0);
|
|
8046
|
+
}
|
|
8047
|
+
|
|
8048
|
+
class BunTmuxGateway {
|
|
8049
|
+
ensureServer() {
|
|
8050
|
+
assertTmuxOk(["start-server"], "tmux start-server");
|
|
8051
|
+
}
|
|
8052
|
+
ensureSession(sessionName, cwd) {
|
|
8053
|
+
const check = runTmux(["has-session", "-t", sessionName]);
|
|
8054
|
+
if (check.exitCode === 0)
|
|
8055
|
+
return;
|
|
8056
|
+
assertTmuxOk(["new-session", "-d", "-s", sessionName, "-c", cwd], `create tmux session ${sessionName}`);
|
|
8057
|
+
}
|
|
8058
|
+
hasWindow(sessionName, windowName) {
|
|
8059
|
+
const result = runTmux(["list-windows", "-t", sessionName, "-F", "#{window_name}"]);
|
|
8060
|
+
if (result.exitCode !== 0)
|
|
8061
|
+
return false;
|
|
8062
|
+
return result.stdout.split(`
|
|
8063
|
+
`).some((line) => line.trim() === windowName);
|
|
8064
|
+
}
|
|
8065
|
+
killWindow(sessionName, windowName) {
|
|
8066
|
+
const result = runTmux(["kill-window", "-t", `${sessionName}:${windowName}`]);
|
|
8067
|
+
if (result.exitCode !== 0 && !result.stderr.includes("can't find window")) {
|
|
8068
|
+
throw new Error(`kill tmux window ${sessionName}:${windowName} failed: ${result.stderr}`);
|
|
7599
8069
|
}
|
|
7600
8070
|
}
|
|
8071
|
+
createWindow(opts) {
|
|
8072
|
+
const args = ["new-window", "-d", "-t", opts.sessionName, "-n", opts.windowName, "-c", opts.cwd];
|
|
8073
|
+
if (opts.command)
|
|
8074
|
+
args.push(opts.command);
|
|
8075
|
+
assertTmuxOk(args, `create tmux window ${opts.sessionName}:${opts.windowName}`);
|
|
8076
|
+
}
|
|
8077
|
+
splitWindow(opts) {
|
|
8078
|
+
const args = ["split-window", "-t", opts.target, opts.split === "right" ? "-h" : "-v", "-c", opts.cwd];
|
|
8079
|
+
if (opts.sizePct !== undefined)
|
|
8080
|
+
args.push("-l", `${opts.sizePct}%`);
|
|
8081
|
+
if (opts.command)
|
|
8082
|
+
args.push(opts.command);
|
|
8083
|
+
assertTmuxOk(args, `split tmux window at ${opts.target}`);
|
|
8084
|
+
}
|
|
8085
|
+
setWindowOption(sessionName, windowName, option, value) {
|
|
8086
|
+
assertTmuxOk(["set-window-option", "-t", `${sessionName}:${windowName}`, option, value], `set tmux option ${option} on ${sessionName}:${windowName}`);
|
|
8087
|
+
}
|
|
8088
|
+
runCommand(target, command) {
|
|
8089
|
+
assertTmuxOk(["send-keys", "-t", target, "-l", "--", command], `send tmux command to ${target}`);
|
|
8090
|
+
assertTmuxOk(["send-keys", "-t", target, "C-m"], `submit tmux command on ${target}`);
|
|
8091
|
+
}
|
|
8092
|
+
selectPane(target) {
|
|
8093
|
+
assertTmuxOk(["select-pane", "-t", target], `select tmux pane ${target}`);
|
|
8094
|
+
}
|
|
8095
|
+
listWindows() {
|
|
8096
|
+
const output = assertTmuxOk(["list-windows", "-a", "-F", "#{session_name}\t#{window_name}\t#{window_panes}"], "list tmux windows");
|
|
8097
|
+
return parseWindowSummaries(output);
|
|
8098
|
+
}
|
|
8099
|
+
}
|
|
8100
|
+
|
|
8101
|
+
// backend/src/lib/http.ts
|
|
8102
|
+
function jsonResponse(data, status = 200) {
|
|
8103
|
+
return new Response(JSON.stringify(data), {
|
|
8104
|
+
status,
|
|
8105
|
+
headers: { "Content-Type": "application/json" }
|
|
8106
|
+
});
|
|
8107
|
+
}
|
|
8108
|
+
function errorResponse(message, status = 500) {
|
|
8109
|
+
return jsonResponse({ error: message }, status);
|
|
8110
|
+
}
|
|
8111
|
+
|
|
8112
|
+
// backend/src/services/dashboard-activity.ts
|
|
8113
|
+
var ACTIVITY_TIMEOUT_MS = 15000;
|
|
8114
|
+
var lastActivityAt = Date.now();
|
|
8115
|
+
function touchDashboardActivity() {
|
|
8116
|
+
lastActivityAt = Date.now();
|
|
8117
|
+
}
|
|
8118
|
+
function hasRecentDashboardActivity() {
|
|
8119
|
+
return Date.now() - lastActivityAt < ACTIVITY_TIMEOUT_MS;
|
|
7601
8120
|
}
|
|
8121
|
+
|
|
8122
|
+
// backend/src/domain/policies.ts
|
|
8123
|
+
var INVALID_BRANCH_CHARS_RE = /[~^:?*\[\]\\]+/g;
|
|
8124
|
+
var UNSAFE_ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7602
8125
|
function sanitizeBranchName(raw) {
|
|
7603
|
-
return raw.toLowerCase().replace(/\s+/g, "-").replace(
|
|
8126
|
+
return raw.toLowerCase().replace(/\s+/g, "-").replace(INVALID_BRANCH_CHARS_RE, "").replace(/@\{/g, "").replace(/\.{2,}/g, ".").replace(/\/{2,}/g, "/").replace(/-{2,}/g, "-").replace(/^[.\-/]+|[.\-/]+$/g, "").replace(/\.lock$/i, "");
|
|
8127
|
+
}
|
|
8128
|
+
function isValidBranchName(raw) {
|
|
8129
|
+
return raw.length > 0 && sanitizeBranchName(raw) === raw;
|
|
8130
|
+
}
|
|
8131
|
+
function isValidEnvKey2(key) {
|
|
8132
|
+
return UNSAFE_ENV_KEY_RE.test(key);
|
|
7604
8133
|
}
|
|
7605
|
-
function
|
|
7606
|
-
const
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
|
|
8134
|
+
function allocateServicePorts(existingMetas, services) {
|
|
8135
|
+
const allocatable = services.filter((service) => service.portStart != null);
|
|
8136
|
+
if (allocatable.length === 0)
|
|
8137
|
+
return {};
|
|
8138
|
+
const reference = allocatable[0];
|
|
8139
|
+
const referenceStart = reference.portStart;
|
|
8140
|
+
const referenceStep = reference.portStep ?? 1;
|
|
8141
|
+
const occupiedSlots = new Set;
|
|
8142
|
+
for (const meta of existingMetas) {
|
|
8143
|
+
const port = meta.allocatedPorts[reference.portEnv];
|
|
8144
|
+
if (!Number.isInteger(port) || port < referenceStart)
|
|
8145
|
+
continue;
|
|
8146
|
+
const diff = port - referenceStart;
|
|
8147
|
+
if (diff % referenceStep !== 0)
|
|
8148
|
+
continue;
|
|
8149
|
+
occupiedSlots.add(diff / referenceStep);
|
|
8150
|
+
}
|
|
8151
|
+
let slot = 1;
|
|
8152
|
+
while (occupiedSlots.has(slot))
|
|
8153
|
+
slot += 1;
|
|
8154
|
+
const result = {};
|
|
8155
|
+
for (const service of allocatable) {
|
|
8156
|
+
const start = service.portStart;
|
|
8157
|
+
const step = service.portStep ?? 1;
|
|
8158
|
+
result[service.portEnv] = start + slot * step;
|
|
7610
8159
|
}
|
|
7611
8160
|
return result;
|
|
7612
8161
|
}
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
|
|
7616
|
-
|
|
7617
|
-
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
const worktreeMap = parseWorktreePorcelain(new TextDecoder().decode(porcelainResult.stdout));
|
|
7622
|
-
const wtDir = worktreeMap.get(branch) ?? null;
|
|
7623
|
-
if (!wtDir)
|
|
7624
|
-
return;
|
|
7625
|
-
const existingEnv = await readEnvLocal(wtDir);
|
|
7626
|
-
const allPaths = [...worktreeMap.values()];
|
|
7627
|
-
const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
|
|
7628
|
-
const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
|
|
7629
|
-
const defaults = { ...portAssignments, PROFILE: profile, AGENT: agent, ...opts?.envOverrides };
|
|
7630
|
-
const toWrite = {};
|
|
7631
|
-
for (const [key, value] of Object.entries(defaults)) {
|
|
7632
|
-
if (!existingEnv[key])
|
|
7633
|
-
toWrite[key] = value;
|
|
7634
|
-
}
|
|
7635
|
-
if (Object.keys(toWrite).length > 0) {
|
|
7636
|
-
await writeEnvLocal(wtDir, toWrite);
|
|
7637
|
-
}
|
|
7638
|
-
const rpcPort = Bun.env.BACKEND_PORT || "5111";
|
|
7639
|
-
const hooksConfig = {
|
|
7640
|
-
hooks: {
|
|
7641
|
-
Stop: [{ hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-stop.sh`, async: true }] }],
|
|
7642
|
-
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-pr.sh`, async: true }] }]
|
|
7643
|
-
}
|
|
7644
|
-
};
|
|
7645
|
-
await mkdir2(`${wtDir}/.claude`, { recursive: true });
|
|
7646
|
-
const settingsPath = `${wtDir}/.claude/settings.local.json`;
|
|
7647
|
-
let existing = {};
|
|
7648
|
-
try {
|
|
7649
|
-
const file = Bun.file(settingsPath);
|
|
7650
|
-
if (await file.exists()) {
|
|
7651
|
-
existing = await file.json();
|
|
7652
|
-
}
|
|
7653
|
-
} catch {}
|
|
7654
|
-
const existingHooks = existing.hooks ?? {};
|
|
7655
|
-
const merged = { ...existing, hooks: { ...existingHooks, ...hooksConfig.hooks } };
|
|
7656
|
-
await Bun.write(settingsPath, JSON.stringify(merged, null, 2) + `
|
|
7657
|
-
`);
|
|
7658
|
-
}
|
|
7659
|
-
async function addWorktree(rawBranch, opts) {
|
|
7660
|
-
ensureTmux();
|
|
7661
|
-
const profile = opts?.profile ?? "default";
|
|
7662
|
-
const agent = opts?.agent ?? "claude";
|
|
7663
|
-
const profileConfig = opts?.profileConfig;
|
|
7664
|
-
const isSandbox = opts?.isSandbox === true;
|
|
7665
|
-
const hasSystemPrompt = !!profileConfig?.systemPrompt;
|
|
7666
|
-
const args = ["workmux", "add", "-b"];
|
|
7667
|
-
let branch = "";
|
|
7668
|
-
let useAutoName = false;
|
|
7669
|
-
if (isSandbox) {
|
|
7670
|
-
args.push("-C");
|
|
7671
|
-
if (rawBranch) {
|
|
7672
|
-
branch = sanitizeBranchName(rawBranch);
|
|
7673
|
-
if (!branch) {
|
|
7674
|
-
return { ok: false, error: `"${rawBranch}" is not a valid branch name after sanitization` };
|
|
7675
|
-
}
|
|
7676
|
-
} else {
|
|
7677
|
-
branch = randomName(8);
|
|
7678
|
-
}
|
|
7679
|
-
args.push(branch);
|
|
7680
|
-
} else {
|
|
7681
|
-
if (hasSystemPrompt) {
|
|
7682
|
-
args.push("-C");
|
|
7683
|
-
}
|
|
7684
|
-
if (opts?.prompt)
|
|
7685
|
-
args.push("-p", opts.prompt);
|
|
7686
|
-
useAutoName = !rawBranch && !!opts?.prompt && !!opts?.autoName;
|
|
7687
|
-
if (rawBranch) {
|
|
7688
|
-
branch = sanitizeBranchName(rawBranch);
|
|
7689
|
-
if (!branch) {
|
|
7690
|
-
return { ok: false, error: `"${rawBranch}" is not a valid branch name after sanitization` };
|
|
7691
|
-
}
|
|
7692
|
-
args.push(branch);
|
|
7693
|
-
} else if (useAutoName) {
|
|
7694
|
-
args.push("-A");
|
|
7695
|
-
} else {
|
|
7696
|
-
branch = randomName(8);
|
|
7697
|
-
args.push(branch);
|
|
7698
|
-
}
|
|
7699
|
-
}
|
|
7700
|
-
log.debug(`[workmux:add] running: ${args.join(" ")}`);
|
|
7701
|
-
const execResult = await tryExec(args, opts?.envOverrides);
|
|
7702
|
-
if (!execResult.ok)
|
|
7703
|
-
return { ok: false, error: execResult.error };
|
|
7704
|
-
const result = execResult.stdout;
|
|
7705
|
-
if (useAutoName) {
|
|
7706
|
-
const parsed = parseBranchFromOutput(result);
|
|
7707
|
-
if (!parsed) {
|
|
7708
|
-
return { ok: false, error: `Failed to parse branch name from workmux output: ${JSON.stringify(result)}` };
|
|
7709
|
-
}
|
|
7710
|
-
branch = parsed;
|
|
7711
|
-
}
|
|
7712
|
-
const windowTarget = `wm-${branch}`;
|
|
7713
|
-
await initWorktreeEnv(branch, { profile, agent, services: opts?.services, envOverrides: opts?.envOverrides });
|
|
7714
|
-
const porcelainResult = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
|
|
7715
|
-
const worktreeMap = parseWorktreePorcelain(new TextDecoder().decode(porcelainResult.stdout));
|
|
7716
|
-
const wtDir = worktreeMap.get(branch) ?? null;
|
|
7717
|
-
const env = wtDir ? await readEnvLocal(wtDir) : {};
|
|
7718
|
-
log.debug(`[workmux:add] branch=${branch} dir=${wtDir ?? "(not found)"} env=${JSON.stringify(env)}`);
|
|
7719
|
-
if (hasSystemPrompt && profileConfig) {
|
|
7720
|
-
const paneCountResult = Bun.spawnSync(["tmux", "list-panes", "-t", windowTarget, "-F", "#{pane_index}"], { stdout: "pipe", stderr: "pipe" });
|
|
7721
|
-
if (paneCountResult.exitCode === 0) {
|
|
7722
|
-
const paneIds = new TextDecoder().decode(paneCountResult.stdout).trim().split(`
|
|
7723
|
-
`);
|
|
7724
|
-
for (let i = paneIds.length - 1;i >= 1; i--) {
|
|
7725
|
-
Bun.spawnSync(["tmux", "kill-pane", "-t", `${windowTarget}.${paneIds[i]}`]);
|
|
7726
|
-
}
|
|
7727
|
-
}
|
|
7728
|
-
let containerName2;
|
|
7729
|
-
if (isSandbox && opts?.sandboxConfig && wtDir) {
|
|
7730
|
-
const mainRepoDir = opts.mainRepoDir ?? process.cwd();
|
|
7731
|
-
containerName2 = await launchContainer({
|
|
7732
|
-
branch,
|
|
7733
|
-
wtDir,
|
|
7734
|
-
mainRepoDir,
|
|
7735
|
-
sandboxConfig: opts.sandboxConfig,
|
|
7736
|
-
services: opts.services ?? [],
|
|
7737
|
-
env
|
|
7738
|
-
});
|
|
7739
|
-
}
|
|
7740
|
-
const agentCmd = buildAgentCmd(env, agent, profileConfig, isSandbox, isSandbox ? opts?.prompt : undefined);
|
|
7741
|
-
if (containerName2) {
|
|
7742
|
-
const dockerExec = `docker exec -it -w ${wtDir} ${containerName2} bash`;
|
|
7743
|
-
Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, dockerExec, "Enter"]);
|
|
7744
|
-
await Bun.sleep(500);
|
|
7745
|
-
const entrypointThenAgent = `/usr/local/bin/entrypoint.sh && ${agentCmd}`;
|
|
7746
|
-
log.debug(`[workmux] sending to ${windowTarget}.0:
|
|
7747
|
-
${entrypointThenAgent}`);
|
|
7748
|
-
Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, entrypointThenAgent, "Enter"]);
|
|
7749
|
-
Bun.spawnSync(["tmux", "split-window", "-h", "-t", `${windowTarget}.0`, "-l", "25%", "-c", wtDir ?? process.cwd()]);
|
|
7750
|
-
} else {
|
|
7751
|
-
log.debug(`[workmux] sending command to ${windowTarget}.0:
|
|
7752
|
-
${agentCmd}`);
|
|
7753
|
-
Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, agentCmd, "Enter"]);
|
|
7754
|
-
Bun.spawnSync(["tmux", "split-window", "-h", "-t", `${windowTarget}.0`, "-l", "25%", "-c", wtDir ?? process.cwd()]);
|
|
8162
|
+
|
|
8163
|
+
// backend/src/services/auto-name-service.ts
|
|
8164
|
+
var BRANCH_NAME_SCHEMA = {
|
|
8165
|
+
type: "object",
|
|
8166
|
+
properties: {
|
|
8167
|
+
branch_name: {
|
|
8168
|
+
type: "string",
|
|
8169
|
+
description: "A lowercase kebab-case git branch name with no prefix"
|
|
7755
8170
|
}
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
8171
|
+
},
|
|
8172
|
+
required: ["branch_name"],
|
|
8173
|
+
additionalProperties: false
|
|
8174
|
+
};
|
|
8175
|
+
var GEMINI_BRANCH_NAME_SCHEMA = {
|
|
8176
|
+
...BRANCH_NAME_SCHEMA,
|
|
8177
|
+
propertyOrdering: ["branch_name"]
|
|
8178
|
+
};
|
|
8179
|
+
var DEFAULT_SYSTEM_PROMPT = [
|
|
8180
|
+
"Generate a concise git branch name from the task description.",
|
|
8181
|
+
"Return only the branch name.",
|
|
8182
|
+
"Use lowercase kebab-case.",
|
|
8183
|
+
"Do not include quotes, code fences, or prefixes like feature/ or fix/."
|
|
8184
|
+
].join(" ");
|
|
8185
|
+
function isRecord2(value) {
|
|
8186
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7759
8187
|
}
|
|
7760
|
-
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
const result = await tryExec(["workmux", "rm", "--force", name]);
|
|
7764
|
-
if (!result.ok)
|
|
7765
|
-
return result;
|
|
7766
|
-
return { ok: true, output: result.stdout };
|
|
8188
|
+
function buildPrompt(task) {
|
|
8189
|
+
return `Task description:
|
|
8190
|
+
${task.trim()}`;
|
|
7767
8191
|
}
|
|
7768
|
-
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
stdin: opts.stdin ?? "ignore",
|
|
7772
|
-
stdout: "ignore",
|
|
7773
|
-
stderr: "pipe"
|
|
7774
|
-
});
|
|
7775
|
-
const timeout = Bun.sleep(TMUX_TIMEOUT_MS).then(() => {
|
|
7776
|
-
proc.kill();
|
|
7777
|
-
return "timeout";
|
|
7778
|
-
});
|
|
7779
|
-
const result = await Promise.race([proc.exited, timeout]);
|
|
7780
|
-
if (result === "timeout") {
|
|
7781
|
-
return { exitCode: -1, stderr: "timed out after 5s (agent may be busy)" };
|
|
8192
|
+
function parseBranchNamePayload(raw) {
|
|
8193
|
+
if (!isRecord2(raw) || typeof raw.branch_name !== "string") {
|
|
8194
|
+
throw new Error("Auto-name response did not include branch_name");
|
|
7782
8195
|
}
|
|
7783
|
-
|
|
7784
|
-
return { exitCode: result, stderr };
|
|
8196
|
+
return raw.branch_name;
|
|
7785
8197
|
}
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
|
|
7790
|
-
|
|
8198
|
+
function parseJsonText(text) {
|
|
8199
|
+
try {
|
|
8200
|
+
return JSON.parse(text);
|
|
8201
|
+
} catch {
|
|
8202
|
+
throw new Error(`Auto-name response was not valid JSON: ${text}`);
|
|
7791
8203
|
}
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
7797
|
-
|
|
8204
|
+
}
|
|
8205
|
+
function normalizeGeneratedBranchName(raw) {
|
|
8206
|
+
let branch = raw.trim();
|
|
8207
|
+
branch = branch.replace(/^```[\w-]*\s*/, "").replace(/\s*```$/, "");
|
|
8208
|
+
branch = branch.split(/\r?\n/)[0]?.trim() ?? "";
|
|
8209
|
+
branch = branch.replace(/^branch(?:\s+name)?\s*:\s*/i, "");
|
|
8210
|
+
branch = branch.replace(/^["'`]+|["'`]+$/g, "");
|
|
8211
|
+
branch = branch.toLowerCase();
|
|
8212
|
+
branch = branch.replace(/[^a-z0-9._/-]+/g, "-");
|
|
8213
|
+
branch = branch.replace(/[/.]+/g, "-");
|
|
8214
|
+
branch = branch.replace(/-+/g, "-");
|
|
8215
|
+
branch = branch.replace(/^-+|-+$/g, "");
|
|
8216
|
+
if (!branch) {
|
|
8217
|
+
throw new Error("Auto-name model returned an empty branch name");
|
|
8218
|
+
}
|
|
8219
|
+
if (!isValidBranchName(branch)) {
|
|
8220
|
+
throw new Error(`Auto-name model returned an invalid branch name: ${branch}`);
|
|
8221
|
+
}
|
|
8222
|
+
return branch;
|
|
8223
|
+
}
|
|
8224
|
+
function resolveAutoNameModel(modelSpec) {
|
|
8225
|
+
const trimmed = modelSpec.trim();
|
|
8226
|
+
const slashIndex = trimmed.indexOf("/");
|
|
8227
|
+
if (slashIndex > 0) {
|
|
8228
|
+
const provider = trimmed.slice(0, slashIndex);
|
|
8229
|
+
const model = trimmed.slice(slashIndex + 1).trim().replace(/^models\//, "");
|
|
8230
|
+
if (!model) {
|
|
8231
|
+
throw new Error(`Invalid auto_name model: ${modelSpec}`);
|
|
8232
|
+
}
|
|
8233
|
+
if (provider === "anthropic" || provider === "google" || provider === "openai") {
|
|
8234
|
+
return { provider, model };
|
|
8235
|
+
}
|
|
8236
|
+
if (provider === "gemini") {
|
|
8237
|
+
return { provider: "google", model };
|
|
7798
8238
|
}
|
|
7799
8239
|
}
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
const load = await tmuxExec(["tmux", "load-buffer", "-b", bufName, "-"], { stdin: new TextEncoder().encode(cleaned) });
|
|
7803
|
-
if (load.exitCode !== 0) {
|
|
7804
|
-
return { ok: false, error: `load-buffer failed${load.stderr ? `: ${load.stderr}` : ""}` };
|
|
8240
|
+
if (trimmed.startsWith("claude-")) {
|
|
8241
|
+
return { provider: "anthropic", model: trimmed };
|
|
7805
8242
|
}
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
return { ok: false, error: `paste-buffer failed${paste.stderr ? `: ${paste.stderr}` : ""}` };
|
|
8243
|
+
if (trimmed.startsWith("gemini-") || trimmed.startsWith("models/gemini-")) {
|
|
8244
|
+
return { provider: "google", model: trimmed.replace(/^models\//, "") };
|
|
7809
8245
|
}
|
|
7810
|
-
|
|
8246
|
+
if (/^(gpt-|chatgpt-|o\d)/.test(trimmed)) {
|
|
8247
|
+
return { provider: "openai", model: trimmed };
|
|
8248
|
+
}
|
|
8249
|
+
throw new Error(`Unsupported auto_name model provider for ${modelSpec}. Use an anthropic/, gemini/, google/, or openai/ prefix, or a known model name.`);
|
|
7811
8250
|
}
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
|
|
7815
|
-
|
|
7816
|
-
|
|
7817
|
-
if (!output)
|
|
8251
|
+
function getSystemPrompt(config) {
|
|
8252
|
+
return config.systemPrompt?.trim() || DEFAULT_SYSTEM_PROMPT;
|
|
8253
|
+
}
|
|
8254
|
+
function extractAnthropicText(raw) {
|
|
8255
|
+
if (!isRecord2(raw) || !Array.isArray(raw.content))
|
|
7818
8256
|
return null;
|
|
7819
|
-
for (const
|
|
7820
|
-
|
|
7821
|
-
const colonIdx = line.indexOf(":");
|
|
7822
|
-
if (colonIdx === -1)
|
|
8257
|
+
for (const item of raw.content) {
|
|
8258
|
+
if (!isRecord2(item))
|
|
7823
8259
|
continue;
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
return session;
|
|
8260
|
+
if (item.type === "text" && typeof item.text === "string" && item.text.trim()) {
|
|
8261
|
+
return item.text;
|
|
8262
|
+
}
|
|
7828
8263
|
}
|
|
7829
8264
|
return null;
|
|
7830
8265
|
}
|
|
7831
|
-
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
});
|
|
7845
|
-
const output = await new Response(proc.stdout).text();
|
|
7846
|
-
await proc.exited;
|
|
7847
|
-
return output.trim().length > 0;
|
|
7848
|
-
})(),
|
|
7849
|
-
(async () => {
|
|
7850
|
-
const proc = Bun.spawn(["git", "rev-list", "--count", "@{u}..HEAD"], {
|
|
7851
|
-
cwd: dir,
|
|
7852
|
-
stdout: "pipe",
|
|
7853
|
-
stderr: "pipe"
|
|
7854
|
-
});
|
|
7855
|
-
const output = await new Response(proc.stdout).text();
|
|
7856
|
-
const exitCode = await proc.exited;
|
|
7857
|
-
if (exitCode !== 0)
|
|
7858
|
-
return false;
|
|
7859
|
-
return (parseInt(output.trim(), 10) || 0) > 0;
|
|
7860
|
-
})()
|
|
7861
|
-
]);
|
|
7862
|
-
return status || ahead;
|
|
8266
|
+
function extractGoogleText(raw) {
|
|
8267
|
+
if (!isRecord2(raw) || !Array.isArray(raw.candidates))
|
|
8268
|
+
return null;
|
|
8269
|
+
for (const candidate of raw.candidates) {
|
|
8270
|
+
if (!isRecord2(candidate) || !isRecord2(candidate.content) || !Array.isArray(candidate.content.parts))
|
|
8271
|
+
continue;
|
|
8272
|
+
for (const part of candidate.content.parts) {
|
|
8273
|
+
if (isRecord2(part) && typeof part.text === "string" && part.text.trim()) {
|
|
8274
|
+
return part.text;
|
|
8275
|
+
}
|
|
8276
|
+
}
|
|
8277
|
+
}
|
|
8278
|
+
return null;
|
|
7863
8279
|
}
|
|
7864
|
-
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
if (spaceIdx === -1)
|
|
7878
|
-
continue;
|
|
7879
|
-
const target = line.slice(0, spaceIdx);
|
|
7880
|
-
const panePath = line.slice(spaceIdx + 1);
|
|
7881
|
-
const colonIdx = target.indexOf(":");
|
|
7882
|
-
if (colonIdx === -1)
|
|
7883
|
-
continue;
|
|
7884
|
-
const session = target.slice(0, colonIdx);
|
|
7885
|
-
const windowName = target.slice(colonIdx + 1);
|
|
7886
|
-
if (!windowName.startsWith("wm-"))
|
|
7887
|
-
continue;
|
|
7888
|
-
if (seen.has(windowName))
|
|
7889
|
-
continue;
|
|
7890
|
-
const branch = windowName.slice(3);
|
|
7891
|
-
if (activeBranches.has(branch))
|
|
7892
|
-
continue;
|
|
7893
|
-
if (!panePath.startsWith(worktreeBaseDir))
|
|
8280
|
+
function extractOpenAiText(raw) {
|
|
8281
|
+
if (!isRecord2(raw))
|
|
8282
|
+
return null;
|
|
8283
|
+
if (typeof raw.output_text === "string" && raw.output_text.trim()) {
|
|
8284
|
+
return raw.output_text;
|
|
8285
|
+
}
|
|
8286
|
+
if (!Array.isArray(raw.output))
|
|
8287
|
+
return null;
|
|
8288
|
+
for (const item of raw.output) {
|
|
8289
|
+
if (!isRecord2(item) || !Array.isArray(item.content))
|
|
8290
|
+
continue;
|
|
8291
|
+
for (const content of item.content) {
|
|
8292
|
+
if (!isRecord2(content))
|
|
7894
8293
|
continue;
|
|
7895
|
-
|
|
7896
|
-
|
|
7897
|
-
|
|
7898
|
-
|
|
7899
|
-
log.info(`[cleanup] killing stale tmux window "${windowName}" (no matching worktree)`);
|
|
7900
|
-
const kill = Bun.spawn(["tmux", "kill-window", "-t", `${session}:${windowName}`], {
|
|
7901
|
-
stdout: "ignore",
|
|
7902
|
-
stderr: "ignore"
|
|
7903
|
-
});
|
|
7904
|
-
await kill.exited;
|
|
7905
|
-
}));
|
|
7906
|
-
} catch (err) {
|
|
7907
|
-
log.warn(`[cleanup] cleanupStaleWindows failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
8294
|
+
if (typeof content.text === "string" && content.text.trim()) {
|
|
8295
|
+
return content.text;
|
|
8296
|
+
}
|
|
8297
|
+
}
|
|
7908
8298
|
}
|
|
8299
|
+
return null;
|
|
7909
8300
|
}
|
|
7910
|
-
async function
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
const result = await tryExec(["workmux", "merge", name]);
|
|
7914
|
-
if (!result.ok)
|
|
7915
|
-
return result;
|
|
7916
|
-
return { ok: true, output: result.stdout };
|
|
8301
|
+
async function readErrorBody(response) {
|
|
8302
|
+
const text = (await response.text()).trim();
|
|
8303
|
+
return text || `HTTP ${response.status}`;
|
|
7917
8304
|
}
|
|
7918
8305
|
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
8306
|
+
class AutoNameService {
|
|
8307
|
+
fetchImpl;
|
|
8308
|
+
anthropicApiKey;
|
|
8309
|
+
geminiApiKey;
|
|
8310
|
+
openaiApiKey;
|
|
8311
|
+
constructor(deps = {}) {
|
|
8312
|
+
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
8313
|
+
this.anthropicApiKey = deps.anthropicApiKey ?? Bun.env.ANTHROPIC_API_KEY;
|
|
8314
|
+
this.geminiApiKey = deps.geminiApiKey ?? Bun.env.GEMINI_API_KEY;
|
|
8315
|
+
this.openaiApiKey = deps.openaiApiKey ?? Bun.env.OPENAI_API_KEY;
|
|
8316
|
+
}
|
|
8317
|
+
async generateBranchName(config, task) {
|
|
8318
|
+
const prompt = task.trim();
|
|
8319
|
+
if (!prompt) {
|
|
8320
|
+
throw new Error("Auto-name requires a prompt");
|
|
8321
|
+
}
|
|
8322
|
+
const resolved = resolveAutoNameModel(config.model);
|
|
8323
|
+
const branchName = resolved.provider === "anthropic" ? await this.generateWithAnthropic(resolved.model, getSystemPrompt(config), prompt) : resolved.provider === "google" ? await this.generateWithGoogle(resolved.model, getSystemPrompt(config), prompt) : await this.generateWithOpenAI(resolved.model, getSystemPrompt(config), prompt);
|
|
8324
|
+
return normalizeGeneratedBranchName(branchName);
|
|
8325
|
+
}
|
|
8326
|
+
async generateWithAnthropic(model, systemPrompt, task) {
|
|
8327
|
+
if (!this.anthropicApiKey) {
|
|
8328
|
+
throw new Error("ANTHROPIC_API_KEY is required for auto_name with Anthropic models");
|
|
8329
|
+
}
|
|
8330
|
+
const response = await this.fetchImpl("https://api.anthropic.com/v1/messages", {
|
|
8331
|
+
method: "POST",
|
|
8332
|
+
headers: {
|
|
8333
|
+
"content-type": "application/json",
|
|
8334
|
+
"x-api-key": this.anthropicApiKey,
|
|
8335
|
+
"anthropic-version": "2023-06-01"
|
|
8336
|
+
},
|
|
8337
|
+
body: JSON.stringify({
|
|
8338
|
+
model,
|
|
8339
|
+
system: systemPrompt,
|
|
8340
|
+
max_tokens: 64,
|
|
8341
|
+
messages: [{ role: "user", content: buildPrompt(task) }],
|
|
8342
|
+
output_config: {
|
|
8343
|
+
format: {
|
|
8344
|
+
type: "json_schema",
|
|
8345
|
+
schema: BRANCH_NAME_SCHEMA
|
|
8346
|
+
}
|
|
8347
|
+
}
|
|
8348
|
+
})
|
|
8349
|
+
});
|
|
8350
|
+
if (!response.ok) {
|
|
8351
|
+
throw new Error(`Anthropic auto-name request failed: ${await readErrorBody(response)}`);
|
|
8352
|
+
}
|
|
8353
|
+
const json = await response.json();
|
|
8354
|
+
if (isRecord2(json) && json.stop_reason === "refusal") {
|
|
8355
|
+
throw new Error("Anthropic auto-name request was refused");
|
|
8356
|
+
}
|
|
8357
|
+
if (isRecord2(json) && json.stop_reason === "max_tokens") {
|
|
8358
|
+
throw new Error("Anthropic auto-name response hit max_tokens before completing");
|
|
8359
|
+
}
|
|
8360
|
+
const text = extractAnthropicText(json);
|
|
8361
|
+
if (!text) {
|
|
8362
|
+
throw new Error("Anthropic auto-name response did not include text");
|
|
8363
|
+
}
|
|
8364
|
+
return parseBranchNamePayload(parseJsonText(text));
|
|
8365
|
+
}
|
|
8366
|
+
async generateWithGoogle(model, systemPrompt, task) {
|
|
8367
|
+
if (!this.geminiApiKey) {
|
|
8368
|
+
throw new Error("GEMINI_API_KEY is required for auto_name with Gemini models");
|
|
8369
|
+
}
|
|
8370
|
+
const response = await this.fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`, {
|
|
8371
|
+
method: "POST",
|
|
8372
|
+
headers: {
|
|
8373
|
+
"content-type": "application/json",
|
|
8374
|
+
"x-goog-api-key": this.geminiApiKey
|
|
8375
|
+
},
|
|
8376
|
+
body: JSON.stringify({
|
|
8377
|
+
systemInstruction: {
|
|
8378
|
+
parts: [{ text: systemPrompt }]
|
|
8379
|
+
},
|
|
8380
|
+
contents: [
|
|
8381
|
+
{
|
|
8382
|
+
role: "user",
|
|
8383
|
+
parts: [{ text: buildPrompt(task) }]
|
|
8384
|
+
}
|
|
8385
|
+
],
|
|
8386
|
+
generationConfig: {
|
|
8387
|
+
responseMimeType: "application/json",
|
|
8388
|
+
responseJsonSchema: GEMINI_BRANCH_NAME_SCHEMA
|
|
8389
|
+
}
|
|
8390
|
+
})
|
|
8391
|
+
});
|
|
8392
|
+
if (!response.ok) {
|
|
8393
|
+
throw new Error(`Google auto-name request failed: ${await readErrorBody(response)}`);
|
|
8394
|
+
}
|
|
8395
|
+
const json = await response.json();
|
|
8396
|
+
const text = extractGoogleText(json);
|
|
8397
|
+
if (!text) {
|
|
8398
|
+
throw new Error("Google auto-name response did not include text");
|
|
8399
|
+
}
|
|
8400
|
+
return parseBranchNamePayload(parseJsonText(text));
|
|
8401
|
+
}
|
|
8402
|
+
async generateWithOpenAI(model, systemPrompt, task) {
|
|
8403
|
+
if (!this.openaiApiKey) {
|
|
8404
|
+
throw new Error("OPENAI_API_KEY is required for auto_name with OpenAI models");
|
|
8405
|
+
}
|
|
8406
|
+
const response = await this.fetchImpl("https://api.openai.com/v1/responses", {
|
|
8407
|
+
method: "POST",
|
|
8408
|
+
headers: {
|
|
8409
|
+
"content-type": "application/json",
|
|
8410
|
+
authorization: `Bearer ${this.openaiApiKey}`
|
|
8411
|
+
},
|
|
8412
|
+
body: JSON.stringify({
|
|
8413
|
+
model,
|
|
8414
|
+
input: [
|
|
8415
|
+
{ role: "system", content: systemPrompt },
|
|
8416
|
+
{ role: "user", content: buildPrompt(task) }
|
|
8417
|
+
],
|
|
8418
|
+
max_output_tokens: 64,
|
|
8419
|
+
text: {
|
|
8420
|
+
format: {
|
|
8421
|
+
type: "json_schema",
|
|
8422
|
+
name: "branch_name_response",
|
|
8423
|
+
strict: true,
|
|
8424
|
+
schema: BRANCH_NAME_SCHEMA
|
|
8425
|
+
}
|
|
8426
|
+
}
|
|
8427
|
+
})
|
|
8428
|
+
});
|
|
8429
|
+
if (!response.ok) {
|
|
8430
|
+
throw new Error(`OpenAI auto-name request failed: ${await readErrorBody(response)}`);
|
|
8431
|
+
}
|
|
8432
|
+
const json = await response.json();
|
|
8433
|
+
if (isRecord2(json) && Array.isArray(json.output)) {
|
|
8434
|
+
for (const item of json.output) {
|
|
8435
|
+
if (!isRecord2(item) || !Array.isArray(item.content))
|
|
8436
|
+
continue;
|
|
8437
|
+
for (const content of item.content) {
|
|
8438
|
+
if (isRecord2(content) && content.type === "refusal" && typeof content.refusal === "string") {
|
|
8439
|
+
throw new Error(`OpenAI auto-name request was refused: ${content.refusal}`);
|
|
8440
|
+
}
|
|
8441
|
+
}
|
|
8442
|
+
}
|
|
8443
|
+
}
|
|
8444
|
+
const text = extractOpenAiText(json);
|
|
8445
|
+
if (!text) {
|
|
8446
|
+
throw new Error("OpenAI auto-name response did not include text");
|
|
8447
|
+
}
|
|
8448
|
+
return parseBranchNamePayload(parseJsonText(text));
|
|
8449
|
+
}
|
|
7929
8450
|
}
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
8451
|
+
|
|
8452
|
+
// backend/src/services/linear-service.ts
|
|
8453
|
+
var ASSIGNED_ISSUES_QUERY = `
|
|
8454
|
+
query AssignedIssues {
|
|
8455
|
+
viewer {
|
|
8456
|
+
assignedIssues(
|
|
8457
|
+
filter: { state: { type: { nin: ["completed", "canceled"] } } }
|
|
8458
|
+
orderBy: updatedAt
|
|
8459
|
+
first: 50
|
|
8460
|
+
) {
|
|
8461
|
+
nodes {
|
|
8462
|
+
id
|
|
8463
|
+
identifier
|
|
8464
|
+
title
|
|
8465
|
+
description
|
|
8466
|
+
priority
|
|
8467
|
+
priorityLabel
|
|
8468
|
+
url
|
|
8469
|
+
branchName
|
|
8470
|
+
dueDate
|
|
8471
|
+
updatedAt
|
|
8472
|
+
state { name color type }
|
|
8473
|
+
team { name key }
|
|
8474
|
+
labels { nodes { name color } }
|
|
8475
|
+
project { name }
|
|
8476
|
+
}
|
|
8477
|
+
}
|
|
8478
|
+
}
|
|
8479
|
+
}
|
|
8480
|
+
`;
|
|
8481
|
+
function parseIssuesResponse(raw) {
|
|
8482
|
+
if (raw.errors && raw.errors.length > 0) {
|
|
8483
|
+
return { ok: false, error: raw.errors.map((e) => e.message).join("; ") };
|
|
8484
|
+
}
|
|
8485
|
+
if (!raw.data) {
|
|
8486
|
+
return { ok: false, error: "No data in response" };
|
|
8487
|
+
}
|
|
8488
|
+
const nodes = raw.data.viewer.assignedIssues.nodes;
|
|
8489
|
+
const issues = nodes.map((n) => ({
|
|
8490
|
+
id: n.id,
|
|
8491
|
+
identifier: n.identifier,
|
|
8492
|
+
title: n.title,
|
|
8493
|
+
description: n.description,
|
|
8494
|
+
priority: n.priority,
|
|
8495
|
+
priorityLabel: n.priorityLabel,
|
|
8496
|
+
url: n.url,
|
|
8497
|
+
branchName: n.branchName,
|
|
8498
|
+
dueDate: n.dueDate,
|
|
8499
|
+
updatedAt: n.updatedAt,
|
|
8500
|
+
state: n.state,
|
|
8501
|
+
team: n.team,
|
|
8502
|
+
labels: n.labels.nodes,
|
|
8503
|
+
project: n.project?.name ?? null
|
|
8504
|
+
}));
|
|
8505
|
+
return { ok: true, data: issues };
|
|
7945
8506
|
}
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
8507
|
+
function branchMatchesIssue(worktreeBranch, issueBranchName) {
|
|
8508
|
+
if (!worktreeBranch || !issueBranchName)
|
|
8509
|
+
return false;
|
|
8510
|
+
if (worktreeBranch === issueBranchName)
|
|
8511
|
+
return true;
|
|
8512
|
+
const issueSlashIdx = issueBranchName.indexOf("/");
|
|
8513
|
+
if (issueSlashIdx !== -1) {
|
|
8514
|
+
const suffix = issueBranchName.slice(issueSlashIdx + 1);
|
|
8515
|
+
if (worktreeBranch === suffix)
|
|
8516
|
+
return true;
|
|
8517
|
+
}
|
|
8518
|
+
const wtSlashIdx = worktreeBranch.indexOf("/");
|
|
8519
|
+
if (wtSlashIdx !== -1) {
|
|
8520
|
+
const wtSuffix = worktreeBranch.slice(wtSlashIdx + 1);
|
|
8521
|
+
if (wtSuffix === issueBranchName)
|
|
8522
|
+
return true;
|
|
8523
|
+
if (issueSlashIdx !== -1 && wtSuffix === issueBranchName.slice(issueSlashIdx + 1))
|
|
8524
|
+
return true;
|
|
8525
|
+
}
|
|
8526
|
+
return false;
|
|
7951
8527
|
}
|
|
7952
|
-
|
|
8528
|
+
var CACHE_TTL_MS = 300000;
|
|
8529
|
+
var issueCache = null;
|
|
8530
|
+
async function fetchAssignedIssues() {
|
|
8531
|
+
const apiKey = Bun.env.LINEAR_API_KEY;
|
|
8532
|
+
if (!apiKey) {
|
|
8533
|
+
return { ok: false, error: "LINEAR_API_KEY not set" };
|
|
8534
|
+
}
|
|
8535
|
+
const now = Date.now();
|
|
8536
|
+
if (issueCache && now < issueCache.expiry) {
|
|
8537
|
+
return issueCache.data;
|
|
8538
|
+
}
|
|
7953
8539
|
try {
|
|
7954
|
-
const
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
|
|
8540
|
+
const res = await fetch("https://api.linear.app/graphql", {
|
|
8541
|
+
method: "POST",
|
|
8542
|
+
headers: {
|
|
8543
|
+
"Content-Type": "application/json",
|
|
8544
|
+
Authorization: apiKey
|
|
8545
|
+
},
|
|
8546
|
+
body: JSON.stringify({ query: ASSIGNED_ISSUES_QUERY })
|
|
8547
|
+
});
|
|
8548
|
+
if (!res.ok) {
|
|
8549
|
+
const text = await res.text();
|
|
8550
|
+
const result2 = { ok: false, error: `Linear API ${res.status}: ${text.slice(0, 200)}` };
|
|
8551
|
+
return result2;
|
|
8552
|
+
}
|
|
8553
|
+
const json = await res.json();
|
|
8554
|
+
const result = parseIssuesResponse(json);
|
|
8555
|
+
if (result.ok) {
|
|
8556
|
+
issueCache = { data: result, expiry: now + CACHE_TTL_MS };
|
|
8557
|
+
log.debug(`[linear] fetched ${result.data.length} assigned issues`);
|
|
8558
|
+
} else {
|
|
8559
|
+
log.error(`[linear] GraphQL error: ${result.error}`);
|
|
8560
|
+
}
|
|
8561
|
+
return result;
|
|
8562
|
+
} catch (err) {
|
|
8563
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
8564
|
+
log.error(`[linear] fetch failed: ${msg}`);
|
|
8565
|
+
return { ok: false, error: msg };
|
|
8566
|
+
}
|
|
8567
|
+
}
|
|
8568
|
+
|
|
8569
|
+
// backend/src/services/notification-service.ts
|
|
8570
|
+
function buildNotification(event, id, timestamp) {
|
|
8571
|
+
switch (event.type) {
|
|
8572
|
+
case "agent_stopped":
|
|
8573
|
+
return {
|
|
8574
|
+
id,
|
|
8575
|
+
branch: event.branch,
|
|
8576
|
+
type: "agent_stopped",
|
|
8577
|
+
message: `Agent stopped on ${event.branch}`,
|
|
8578
|
+
timestamp
|
|
8579
|
+
};
|
|
8580
|
+
case "pr_opened":
|
|
8581
|
+
return {
|
|
8582
|
+
id,
|
|
8583
|
+
branch: event.branch,
|
|
8584
|
+
type: "pr_opened",
|
|
8585
|
+
message: `PR opened on ${event.branch}`,
|
|
8586
|
+
url: event.url,
|
|
8587
|
+
timestamp
|
|
8588
|
+
};
|
|
8589
|
+
case "runtime_error":
|
|
8590
|
+
return {
|
|
8591
|
+
id,
|
|
8592
|
+
branch: event.branch,
|
|
8593
|
+
type: "runtime_error",
|
|
8594
|
+
message: `Runtime error on ${event.branch}: ${event.message}`,
|
|
8595
|
+
timestamp
|
|
8596
|
+
};
|
|
8597
|
+
default:
|
|
8598
|
+
return null;
|
|
8599
|
+
}
|
|
8600
|
+
}
|
|
8601
|
+
|
|
8602
|
+
class NotificationService {
|
|
8603
|
+
maxItems;
|
|
8604
|
+
notifications = [];
|
|
8605
|
+
sseClients = new Set;
|
|
8606
|
+
nextId = 1;
|
|
8607
|
+
constructor(maxItems = 50) {
|
|
8608
|
+
this.maxItems = maxItems;
|
|
8609
|
+
}
|
|
8610
|
+
list() {
|
|
8611
|
+
return [...this.notifications];
|
|
8612
|
+
}
|
|
8613
|
+
dismiss(id) {
|
|
8614
|
+
const index = this.notifications.findIndex((notification) => notification.id === id);
|
|
8615
|
+
if (index === -1)
|
|
8616
|
+
return false;
|
|
8617
|
+
this.notifications.splice(index, 1);
|
|
8618
|
+
this.broadcast("dismiss", { id });
|
|
8619
|
+
return true;
|
|
8620
|
+
}
|
|
8621
|
+
recordEvent(event, now = () => new Date) {
|
|
8622
|
+
const notification = buildNotification(event, this.nextId, now().getTime());
|
|
8623
|
+
if (!notification)
|
|
8624
|
+
return null;
|
|
8625
|
+
this.nextId += 1;
|
|
8626
|
+
this.notifications.push(notification);
|
|
8627
|
+
while (this.notifications.length > this.maxItems) {
|
|
8628
|
+
this.notifications.shift();
|
|
8629
|
+
}
|
|
8630
|
+
this.broadcast("notification", notification);
|
|
8631
|
+
return notification;
|
|
8632
|
+
}
|
|
8633
|
+
stream() {
|
|
8634
|
+
let controllerRef = null;
|
|
8635
|
+
const stream = new ReadableStream({
|
|
8636
|
+
start: (controller) => {
|
|
8637
|
+
controllerRef = controller;
|
|
8638
|
+
this.sseClients.add(controller);
|
|
8639
|
+
for (const notification of this.notifications) {
|
|
8640
|
+
controller.enqueue(this.formatSse("initial", notification));
|
|
8641
|
+
}
|
|
8642
|
+
},
|
|
8643
|
+
cancel: () => {
|
|
8644
|
+
if (controllerRef)
|
|
8645
|
+
this.sseClients.delete(controllerRef);
|
|
8646
|
+
}
|
|
8647
|
+
});
|
|
8648
|
+
return new Response(stream, {
|
|
8649
|
+
headers: {
|
|
8650
|
+
"Content-Type": "text/event-stream",
|
|
8651
|
+
"Cache-Control": "no-cache",
|
|
8652
|
+
Connection: "keep-alive"
|
|
8653
|
+
}
|
|
8654
|
+
});
|
|
8655
|
+
}
|
|
8656
|
+
formatSse(event, data) {
|
|
8657
|
+
return new TextEncoder().encode(`event: ${event}
|
|
8658
|
+
data: ${JSON.stringify(data)}
|
|
8659
|
+
|
|
7958
8660
|
`);
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
8661
|
+
}
|
|
8662
|
+
broadcast(event, data) {
|
|
8663
|
+
const encoded = this.formatSse(event, data);
|
|
8664
|
+
for (const controller of this.sseClients) {
|
|
8665
|
+
try {
|
|
8666
|
+
controller.enqueue(encoded);
|
|
8667
|
+
} catch {
|
|
8668
|
+
this.sseClients.delete(controller);
|
|
7962
8669
|
}
|
|
7963
8670
|
}
|
|
7964
|
-
}
|
|
8671
|
+
}
|
|
7965
8672
|
}
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
8673
|
+
|
|
8674
|
+
// backend/src/services/lifecycle-service.ts
|
|
8675
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
8676
|
+
import { mkdir as mkdir4 } from "fs/promises";
|
|
8677
|
+
import { dirname as dirname3, resolve as resolve3 } from "path";
|
|
8678
|
+
|
|
8679
|
+
// backend/src/adapters/agent-runtime.ts
|
|
8680
|
+
import { chmod as chmod2, mkdir as mkdir3 } from "fs/promises";
|
|
8681
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
8682
|
+
|
|
8683
|
+
// backend/src/adapters/fs.ts
|
|
8684
|
+
import { mkdir as mkdir2 } from "fs/promises";
|
|
8685
|
+
import { join as join2 } from "path";
|
|
8686
|
+
var SAFE_ENV_VALUE_RE = /^[A-Za-z0-9_./:@%+=,-]+$/;
|
|
8687
|
+
function stringifyAllocatedPorts(ports) {
|
|
8688
|
+
const entries = Object.entries(ports).map(([key, value]) => [key, String(value)]);
|
|
8689
|
+
return Object.fromEntries(entries);
|
|
8690
|
+
}
|
|
8691
|
+
function quoteEnvValue(value) {
|
|
8692
|
+
if (value.length > 0 && SAFE_ENV_VALUE_RE.test(value))
|
|
8693
|
+
return value;
|
|
8694
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
8695
|
+
}
|
|
8696
|
+
function getWorktreeStoragePaths(gitDir) {
|
|
8697
|
+
const webmuxDir = join2(gitDir, "webmux");
|
|
8698
|
+
return {
|
|
8699
|
+
gitDir,
|
|
8700
|
+
webmuxDir,
|
|
8701
|
+
metaPath: join2(webmuxDir, "meta.json"),
|
|
8702
|
+
runtimeEnvPath: join2(webmuxDir, "runtime.env"),
|
|
8703
|
+
controlEnvPath: join2(webmuxDir, "control.env"),
|
|
8704
|
+
prsPath: join2(webmuxDir, "prs.json")
|
|
8705
|
+
};
|
|
8706
|
+
}
|
|
8707
|
+
async function ensureWorktreeStorageDirs(gitDir) {
|
|
8708
|
+
const paths = getWorktreeStoragePaths(gitDir);
|
|
8709
|
+
await mkdir2(paths.webmuxDir, { recursive: true });
|
|
8710
|
+
return paths;
|
|
8711
|
+
}
|
|
8712
|
+
async function readWorktreeMeta(gitDir) {
|
|
8713
|
+
const { metaPath } = getWorktreeStoragePaths(gitDir);
|
|
8714
|
+
try {
|
|
8715
|
+
return await Bun.file(metaPath).json();
|
|
8716
|
+
} catch {
|
|
8717
|
+
return null;
|
|
8718
|
+
}
|
|
8719
|
+
}
|
|
8720
|
+
async function writeWorktreeMeta(gitDir, meta) {
|
|
8721
|
+
const { metaPath } = await ensureWorktreeStorageDirs(gitDir);
|
|
8722
|
+
await Bun.write(metaPath, JSON.stringify(meta, null, 2) + `
|
|
8723
|
+
`);
|
|
8724
|
+
}
|
|
8725
|
+
function buildRuntimeEnvMap(meta, extraEnv = {}) {
|
|
8726
|
+
return {
|
|
8727
|
+
...meta.startupEnvValues,
|
|
8728
|
+
...stringifyAllocatedPorts(meta.allocatedPorts),
|
|
8729
|
+
...extraEnv,
|
|
8730
|
+
WEBMUX_WORKTREE_ID: meta.worktreeId,
|
|
8731
|
+
WEBMUX_BRANCH: meta.branch,
|
|
8732
|
+
WEBMUX_PROFILE: meta.profile,
|
|
8733
|
+
WEBMUX_AGENT: meta.agent,
|
|
8734
|
+
WEBMUX_RUNTIME: meta.runtime
|
|
8735
|
+
};
|
|
8736
|
+
}
|
|
8737
|
+
function buildControlEnvMap(input) {
|
|
8738
|
+
return {
|
|
8739
|
+
WEBMUX_CONTROL_URL: input.controlUrl,
|
|
8740
|
+
WEBMUX_CONTROL_TOKEN: input.controlToken,
|
|
8741
|
+
WEBMUX_WORKTREE_ID: input.worktreeId,
|
|
8742
|
+
WEBMUX_BRANCH: input.branch
|
|
8743
|
+
};
|
|
8744
|
+
}
|
|
8745
|
+
function renderEnvFile(env) {
|
|
8746
|
+
const lines = Object.entries(env).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${quoteEnvValue(value)}`);
|
|
8747
|
+
return lines.join(`
|
|
8748
|
+
`) + `
|
|
8749
|
+
`;
|
|
8750
|
+
}
|
|
8751
|
+
async function writeRuntimeEnv(gitDir, env) {
|
|
8752
|
+
const { runtimeEnvPath } = await ensureWorktreeStorageDirs(gitDir);
|
|
8753
|
+
await Bun.write(runtimeEnvPath, renderEnvFile(env));
|
|
8754
|
+
}
|
|
8755
|
+
async function writeControlEnv(gitDir, env) {
|
|
8756
|
+
const { controlEnvPath } = await ensureWorktreeStorageDirs(gitDir);
|
|
8757
|
+
await Bun.write(controlEnvPath, renderEnvFile(env));
|
|
8758
|
+
}
|
|
8759
|
+
function isRecord3(raw) {
|
|
8760
|
+
return typeof raw === "object" && raw !== null && !Array.isArray(raw);
|
|
8761
|
+
}
|
|
8762
|
+
function isPrComment(raw) {
|
|
8763
|
+
if (!isRecord3(raw))
|
|
8764
|
+
return false;
|
|
8765
|
+
return (raw.type === "comment" || raw.type === "inline") && typeof raw.author === "string" && typeof raw.body === "string" && typeof raw.createdAt === "string" && (raw.path === undefined || typeof raw.path === "string") && (raw.line === undefined || raw.line === null || typeof raw.line === "number") && (raw.diffHunk === undefined || typeof raw.diffHunk === "string") && (raw.isReply === undefined || typeof raw.isReply === "boolean");
|
|
8766
|
+
}
|
|
8767
|
+
function isCiCheck(raw) {
|
|
8768
|
+
if (!isRecord3(raw))
|
|
8769
|
+
return false;
|
|
8770
|
+
return typeof raw.name === "string" && (raw.status === "pending" || raw.status === "success" || raw.status === "failed" || raw.status === "skipped") && typeof raw.url === "string" && (raw.runId === null || typeof raw.runId === "number");
|
|
8771
|
+
}
|
|
8772
|
+
function isPrEntry(raw) {
|
|
8773
|
+
if (!isRecord3(raw))
|
|
8774
|
+
return false;
|
|
8775
|
+
return typeof raw.repo === "string" && typeof raw.number === "number" && (raw.state === "open" || raw.state === "closed" || raw.state === "merged") && typeof raw.url === "string" && typeof raw.updatedAt === "string" && (raw.ciStatus === "none" || raw.ciStatus === "pending" || raw.ciStatus === "success" || raw.ciStatus === "failed") && Array.isArray(raw.ciChecks) && raw.ciChecks.every((check) => isCiCheck(check)) && Array.isArray(raw.comments) && raw.comments.every((comment) => isPrComment(comment));
|
|
8776
|
+
}
|
|
8777
|
+
async function readWorktreePrs(gitDir) {
|
|
8778
|
+
const { prsPath } = getWorktreeStoragePaths(gitDir);
|
|
8779
|
+
try {
|
|
8780
|
+
const raw = await Bun.file(prsPath).json();
|
|
8781
|
+
return Array.isArray(raw) && raw.every((entry) => isPrEntry(entry)) ? raw : [];
|
|
8782
|
+
} catch {
|
|
8783
|
+
return [];
|
|
8784
|
+
}
|
|
8785
|
+
}
|
|
8786
|
+
async function writeWorktreePrs(gitDir, prs) {
|
|
8787
|
+
const { prsPath } = await ensureWorktreeStorageDirs(gitDir);
|
|
8788
|
+
await Bun.write(prsPath, JSON.stringify(prs, null, 2) + `
|
|
8789
|
+
`);
|
|
8790
|
+
}
|
|
8791
|
+
|
|
8792
|
+
// backend/src/adapters/agent-runtime.ts
|
|
8793
|
+
function shellQuote(value) {
|
|
8794
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
8795
|
+
}
|
|
8796
|
+
function isRecord4(value) {
|
|
8797
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8798
|
+
}
|
|
8799
|
+
function buildAgentCtlScript() {
|
|
8800
|
+
return `#!/usr/bin/env python3
|
|
8801
|
+
import argparse
|
|
8802
|
+
import json
|
|
8803
|
+
import re
|
|
8804
|
+
import sys
|
|
8805
|
+
import urllib.error
|
|
8806
|
+
import urllib.request
|
|
8807
|
+
from pathlib import Path
|
|
8808
|
+
|
|
8809
|
+
|
|
8810
|
+
CONTROL_ENV_PATH = Path(__file__).resolve().with_name("control.env")
|
|
8811
|
+
|
|
8812
|
+
|
|
8813
|
+
def read_control_env():
|
|
8814
|
+
env = {}
|
|
8815
|
+
try:
|
|
8816
|
+
content = CONTROL_ENV_PATH.read_text()
|
|
8817
|
+
except OSError as error:
|
|
8818
|
+
raise RuntimeError(f"failed to read control.env: {error}") from error
|
|
8819
|
+
|
|
8820
|
+
for raw_line in content.splitlines():
|
|
8821
|
+
line = raw_line.strip()
|
|
8822
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
8823
|
+
continue
|
|
8824
|
+
key, value = line.split("=", 1)
|
|
8825
|
+
if len(value) >= 2 and value.startswith("'") and value.endswith("'"):
|
|
8826
|
+
value = value[1:-1].replace("'\\\\''", "'")
|
|
8827
|
+
env[key] = value
|
|
8828
|
+
|
|
8829
|
+
return env
|
|
8830
|
+
|
|
8831
|
+
|
|
8832
|
+
def build_parser():
|
|
8833
|
+
parser = argparse.ArgumentParser(prog="webmux-agentctl")
|
|
8834
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
8835
|
+
|
|
8836
|
+
subparsers.add_parser("agent-stopped")
|
|
8837
|
+
|
|
8838
|
+
status_changed = subparsers.add_parser("status-changed")
|
|
8839
|
+
status_changed.add_argument("--lifecycle", choices=["starting", "running", "idle", "stopped"], required=True)
|
|
8840
|
+
|
|
8841
|
+
pr_opened = subparsers.add_parser("pr-opened")
|
|
8842
|
+
pr_opened.add_argument("--url")
|
|
8843
|
+
|
|
8844
|
+
runtime_error = subparsers.add_parser("runtime-error")
|
|
8845
|
+
runtime_error.add_argument("--message", required=True)
|
|
8846
|
+
|
|
8847
|
+
subparsers.add_parser("claude-user-prompt-submit")
|
|
8848
|
+
subparsers.add_parser("claude-post-tool-use")
|
|
8849
|
+
|
|
8850
|
+
return parser
|
|
8851
|
+
|
|
8852
|
+
|
|
8853
|
+
def build_payload(command, args, control_env):
|
|
8854
|
+
payload = {
|
|
8855
|
+
"worktreeId": control_env["WEBMUX_WORKTREE_ID"],
|
|
8856
|
+
"branch": control_env["WEBMUX_BRANCH"],
|
|
8857
|
+
}
|
|
8858
|
+
|
|
8859
|
+
if command == "agent-stopped":
|
|
8860
|
+
payload["type"] = "agent_stopped"
|
|
8861
|
+
return payload
|
|
8862
|
+
if command == "status-changed":
|
|
8863
|
+
payload["type"] = "agent_status_changed"
|
|
8864
|
+
payload["lifecycle"] = args.lifecycle
|
|
8865
|
+
return payload
|
|
8866
|
+
if command == "pr-opened":
|
|
8867
|
+
payload["type"] = "pr_opened"
|
|
8868
|
+
if args.url:
|
|
8869
|
+
payload["url"] = args.url
|
|
8870
|
+
return payload
|
|
8871
|
+
if command == "runtime-error":
|
|
8872
|
+
payload["type"] = "runtime_error"
|
|
8873
|
+
payload["message"] = args.message
|
|
8874
|
+
return payload
|
|
8875
|
+
raise RuntimeError(f"unsupported command: {command}")
|
|
8876
|
+
|
|
8877
|
+
|
|
8878
|
+
def read_hook_payload():
|
|
8879
|
+
raw = sys.stdin.read()
|
|
8880
|
+
if not raw.strip():
|
|
8881
|
+
return {}
|
|
8882
|
+
|
|
8883
|
+
try:
|
|
8884
|
+
parsed = json.loads(raw)
|
|
8885
|
+
except json.JSONDecodeError:
|
|
8886
|
+
return {}
|
|
8887
|
+
|
|
8888
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
8889
|
+
|
|
8890
|
+
|
|
8891
|
+
def send_payload(payload, control_env):
|
|
8892
|
+
request = urllib.request.Request(
|
|
8893
|
+
control_env["WEBMUX_CONTROL_URL"],
|
|
8894
|
+
data=json.dumps(payload).encode(),
|
|
8895
|
+
headers={
|
|
8896
|
+
"Authorization": f"Bearer {control_env['WEBMUX_CONTROL_TOKEN']}",
|
|
8897
|
+
"Content-Type": "application/json",
|
|
8898
|
+
},
|
|
8899
|
+
method="POST",
|
|
8900
|
+
)
|
|
8901
|
+
|
|
8902
|
+
try:
|
|
8903
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
8904
|
+
if response.status < 200 or response.status >= 300:
|
|
8905
|
+
print(f"control endpoint returned HTTP {response.status}", file=sys.stderr)
|
|
8906
|
+
return False
|
|
8907
|
+
except urllib.error.HTTPError as error:
|
|
8908
|
+
print(f"control endpoint returned HTTP {error.code}", file=sys.stderr)
|
|
8909
|
+
return False
|
|
8910
|
+
except Exception as error:
|
|
8911
|
+
print(f"failed to send runtime event: {error}", file=sys.stderr)
|
|
8912
|
+
return False
|
|
8913
|
+
|
|
8914
|
+
return True
|
|
8915
|
+
|
|
8916
|
+
|
|
8917
|
+
def main():
|
|
8918
|
+
parsed = build_parser().parse_args()
|
|
8919
|
+
|
|
8920
|
+
try:
|
|
8921
|
+
control_env = read_control_env()
|
|
8922
|
+
except RuntimeError as error:
|
|
8923
|
+
print(str(error), file=sys.stderr)
|
|
8924
|
+
return 1
|
|
8925
|
+
|
|
8926
|
+
required_keys = [
|
|
8927
|
+
"WEBMUX_CONTROL_URL",
|
|
8928
|
+
"WEBMUX_CONTROL_TOKEN",
|
|
8929
|
+
"WEBMUX_WORKTREE_ID",
|
|
8930
|
+
"WEBMUX_BRANCH",
|
|
8931
|
+
]
|
|
8932
|
+
missing = [key for key in required_keys if not control_env.get(key)]
|
|
8933
|
+
if missing:
|
|
8934
|
+
print(f"missing control env keys: {', '.join(missing)}", file=sys.stderr)
|
|
8935
|
+
return 1
|
|
8936
|
+
|
|
8937
|
+
if parsed.command == "claude-user-prompt-submit":
|
|
8938
|
+
if not send_payload(build_payload("status-changed", argparse.Namespace(lifecycle="running"), control_env), control_env):
|
|
8939
|
+
return 1
|
|
8940
|
+
return 0
|
|
8941
|
+
|
|
8942
|
+
if parsed.command == "claude-post-tool-use":
|
|
8943
|
+
hook_payload = read_hook_payload()
|
|
8944
|
+
tool_name = hook_payload.get("tool_name")
|
|
8945
|
+
tool_input = hook_payload.get("tool_input")
|
|
8946
|
+
if not isinstance(tool_input, dict) or tool_name != "Bash":
|
|
8947
|
+
return 0
|
|
8948
|
+
|
|
8949
|
+
command = tool_input.get("command")
|
|
8950
|
+
if not isinstance(command, str) or "gh pr create" not in command:
|
|
8951
|
+
return 0
|
|
8952
|
+
|
|
8953
|
+
pr_args = argparse.Namespace(url=None)
|
|
8954
|
+
tool_response = hook_payload.get("tool_response")
|
|
8955
|
+
if isinstance(tool_response, str):
|
|
8956
|
+
match = re.search(r"https://github\\.com/[^\\s\\"]+/pull/\\d+", tool_response)
|
|
8957
|
+
if match:
|
|
8958
|
+
pr_args.url = match.group(0)
|
|
8959
|
+
|
|
8960
|
+
return 0 if send_payload(build_payload("pr-opened", pr_args, control_env), control_env) else 1
|
|
8961
|
+
|
|
8962
|
+
payload = build_payload(parsed.command, parsed, control_env)
|
|
8963
|
+
if not send_payload(payload, control_env):
|
|
8964
|
+
return 1
|
|
8965
|
+
|
|
8966
|
+
return 0
|
|
8967
|
+
|
|
8968
|
+
|
|
8969
|
+
if __name__ == "__main__":
|
|
8970
|
+
sys.exit(main())
|
|
8971
|
+
`;
|
|
8972
|
+
}
|
|
8973
|
+
function buildClaudeHookSettings(input) {
|
|
8974
|
+
return {
|
|
8975
|
+
hooks: {
|
|
8976
|
+
UserPromptSubmit: [
|
|
8977
|
+
{
|
|
8978
|
+
hooks: [
|
|
8979
|
+
{
|
|
8980
|
+
type: "command",
|
|
8981
|
+
command: `${shellQuote(input.agentCtlPath)} claude-user-prompt-submit`,
|
|
8982
|
+
async: true
|
|
8983
|
+
}
|
|
8984
|
+
]
|
|
8985
|
+
}
|
|
8986
|
+
],
|
|
8987
|
+
Notification: [
|
|
8988
|
+
{
|
|
8989
|
+
matcher: "permission_prompt|elicitation_dialog",
|
|
8990
|
+
hooks: [
|
|
8991
|
+
{
|
|
8992
|
+
type: "command",
|
|
8993
|
+
command: `${shellQuote(input.agentCtlPath)} status-changed --lifecycle idle`,
|
|
8994
|
+
async: true
|
|
8995
|
+
}
|
|
8996
|
+
]
|
|
8997
|
+
}
|
|
8998
|
+
],
|
|
8999
|
+
Stop: [
|
|
9000
|
+
{
|
|
9001
|
+
hooks: [
|
|
9002
|
+
{
|
|
9003
|
+
type: "command",
|
|
9004
|
+
command: `${shellQuote(input.agentCtlPath)} agent-stopped`,
|
|
9005
|
+
async: true
|
|
9006
|
+
}
|
|
9007
|
+
]
|
|
9008
|
+
}
|
|
9009
|
+
],
|
|
9010
|
+
PostToolUse: [
|
|
9011
|
+
{
|
|
9012
|
+
hooks: [
|
|
9013
|
+
{
|
|
9014
|
+
type: "command",
|
|
9015
|
+
command: `${shellQuote(input.agentCtlPath)} status-changed --lifecycle running`,
|
|
9016
|
+
async: true
|
|
9017
|
+
}
|
|
9018
|
+
]
|
|
9019
|
+
},
|
|
9020
|
+
{
|
|
9021
|
+
matcher: "Bash",
|
|
9022
|
+
hooks: [
|
|
9023
|
+
{
|
|
9024
|
+
type: "command",
|
|
9025
|
+
command: `${shellQuote(input.agentCtlPath)} claude-post-tool-use`,
|
|
9026
|
+
async: true
|
|
9027
|
+
}
|
|
9028
|
+
]
|
|
9029
|
+
}
|
|
9030
|
+
]
|
|
9031
|
+
}
|
|
9032
|
+
};
|
|
9033
|
+
}
|
|
9034
|
+
async function mergeClaudeSettings(settingsPath, hookSettings) {
|
|
9035
|
+
let existing = {};
|
|
9036
|
+
try {
|
|
9037
|
+
const file = Bun.file(settingsPath);
|
|
9038
|
+
if (await file.exists()) {
|
|
9039
|
+
const parsed = await file.json();
|
|
9040
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
9041
|
+
existing = parsed;
|
|
9042
|
+
}
|
|
9043
|
+
}
|
|
9044
|
+
} catch {
|
|
9045
|
+
existing = {};
|
|
9046
|
+
}
|
|
9047
|
+
const existingHooks = existing.hooks;
|
|
9048
|
+
const mergedHooks = existingHooks && typeof existingHooks === "object" && !Array.isArray(existingHooks) ? { ...existingHooks, ...hookSettings } : hookSettings;
|
|
9049
|
+
const merged = { ...existing, hooks: mergedHooks };
|
|
9050
|
+
await Bun.write(settingsPath, JSON.stringify(merged, null, 2) + `
|
|
9051
|
+
`);
|
|
9052
|
+
}
|
|
9053
|
+
async function ensureAgentRuntimeArtifacts(input) {
|
|
9054
|
+
const storagePaths = getWorktreeStoragePaths(input.gitDir);
|
|
9055
|
+
const artifacts = {
|
|
9056
|
+
agentCtlPath: join3(storagePaths.webmuxDir, "webmux-agentctl"),
|
|
9057
|
+
claudeSettingsPath: join3(input.worktreePath, ".claude", "settings.local.json")
|
|
9058
|
+
};
|
|
9059
|
+
await mkdir3(dirname2(artifacts.claudeSettingsPath), { recursive: true });
|
|
9060
|
+
await Bun.write(artifacts.agentCtlPath, buildAgentCtlScript());
|
|
9061
|
+
await chmod2(artifacts.agentCtlPath, 493);
|
|
9062
|
+
const hookSettings = buildClaudeHookSettings(artifacts);
|
|
9063
|
+
const hooks = hookSettings.hooks;
|
|
9064
|
+
if (!isRecord4(hooks)) {
|
|
9065
|
+
throw new Error("Invalid Claude hook settings");
|
|
9066
|
+
}
|
|
9067
|
+
await mergeClaudeSettings(artifacts.claudeSettingsPath, hooks);
|
|
9068
|
+
return artifacts;
|
|
9069
|
+
}
|
|
9070
|
+
|
|
9071
|
+
// backend/src/services/agent-service.ts
|
|
9072
|
+
function quoteShell(value) {
|
|
9073
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
9074
|
+
}
|
|
9075
|
+
function buildRuntimeBootstrap(runtimeEnvPath) {
|
|
9076
|
+
return `set -a; . ${quoteShell(runtimeEnvPath)}; set +a`;
|
|
9077
|
+
}
|
|
9078
|
+
function buildAgentInvocation(input) {
|
|
9079
|
+
const promptSuffix = input.prompt ? ` ${quoteShell(input.prompt)}` : "";
|
|
9080
|
+
if (input.agent === "codex") {
|
|
9081
|
+
const yoloFlag2 = input.yolo ? " --yolo" : "";
|
|
9082
|
+
if (input.systemPrompt) {
|
|
9083
|
+
return `codex${yoloFlag2} -c ${quoteShell(`developer_instructions=${input.systemPrompt}`)}${promptSuffix}`;
|
|
7972
9084
|
}
|
|
9085
|
+
return `codex${yoloFlag2}${promptSuffix}`;
|
|
9086
|
+
}
|
|
9087
|
+
const yoloFlag = input.yolo ? " --dangerously-skip-permissions" : "";
|
|
9088
|
+
if (input.systemPrompt) {
|
|
9089
|
+
return `claude${yoloFlag} --append-system-prompt ${quoteShell(input.systemPrompt)}${promptSuffix}`;
|
|
9090
|
+
}
|
|
9091
|
+
return `claude${yoloFlag}${promptSuffix}`;
|
|
9092
|
+
}
|
|
9093
|
+
function buildAgentCommand(input) {
|
|
9094
|
+
return `${buildRuntimeBootstrap(input.runtimeEnvPath)}; ${buildAgentInvocation(input)}`;
|
|
9095
|
+
}
|
|
9096
|
+
function buildDockerExecCommand(containerName2, worktreePath, command) {
|
|
9097
|
+
return `docker exec -it -w ${quoteShell(worktreePath)} ${quoteShell(containerName2)} bash -lc ${quoteShell(command)}`;
|
|
9098
|
+
}
|
|
9099
|
+
function buildManagedShellCommand(runtimeEnvPath, shellPath = Bun.env.SHELL || "/bin/bash") {
|
|
9100
|
+
return `bash -lc ${quoteShell(`${buildRuntimeBootstrap(runtimeEnvPath)}; exec ${quoteShell(shellPath)} -i`)}`;
|
|
9101
|
+
}
|
|
9102
|
+
function buildAgentPaneCommand(input) {
|
|
9103
|
+
return buildAgentCommand(input);
|
|
9104
|
+
}
|
|
9105
|
+
function buildDockerShellCommand(containerName2, worktreePath, runtimeEnvPath, shellPath = Bun.env.SHELL || "/bin/bash") {
|
|
9106
|
+
return buildDockerExecCommand(containerName2, worktreePath, `${buildRuntimeBootstrap(runtimeEnvPath)}; exec ${quoteShell(shellPath)} -i`);
|
|
9107
|
+
}
|
|
9108
|
+
function buildDockerAgentPaneCommand(input) {
|
|
9109
|
+
return buildDockerExecCommand(input.containerName, input.worktreePath, buildAgentCommand(input));
|
|
9110
|
+
}
|
|
9111
|
+
|
|
9112
|
+
// backend/src/services/session-service.ts
|
|
9113
|
+
function resolvePaneCwd(template, ctx) {
|
|
9114
|
+
return template.cwd === "repo" ? ctx.repoRoot : ctx.worktreePath;
|
|
9115
|
+
}
|
|
9116
|
+
function resolvePaneStartupCommand(template, commands) {
|
|
9117
|
+
switch (template.kind) {
|
|
9118
|
+
case "agent":
|
|
9119
|
+
return commands.agent;
|
|
9120
|
+
case "shell":
|
|
9121
|
+
return;
|
|
9122
|
+
case "command":
|
|
9123
|
+
if (!template.command) {
|
|
9124
|
+
throw new Error(`Pane "${template.id}" is kind=command but has no command`);
|
|
9125
|
+
}
|
|
9126
|
+
return template.command;
|
|
9127
|
+
}
|
|
9128
|
+
}
|
|
9129
|
+
function planSessionLayout(projectRoot, branch, templates, ctx) {
|
|
9130
|
+
if (templates.length === 0) {
|
|
9131
|
+
throw new Error("At least one pane template is required");
|
|
9132
|
+
}
|
|
9133
|
+
const panes = templates.map((template, index) => {
|
|
9134
|
+
const startupCommand = resolvePaneStartupCommand(template, ctx.paneCommands);
|
|
9135
|
+
return {
|
|
9136
|
+
id: template.id,
|
|
9137
|
+
index,
|
|
9138
|
+
kind: template.kind,
|
|
9139
|
+
cwd: resolvePaneCwd(template, ctx),
|
|
9140
|
+
...startupCommand ? { startupCommand } : {},
|
|
9141
|
+
focus: template.focus === true,
|
|
9142
|
+
...index > 0 ? {
|
|
9143
|
+
split: template.split ?? "right",
|
|
9144
|
+
...template.sizePct !== undefined ? { sizePct: template.sizePct } : {}
|
|
9145
|
+
} : {}
|
|
9146
|
+
};
|
|
9147
|
+
});
|
|
9148
|
+
const focusPaneIndex = panes.find((pane) => pane.focus)?.index ?? 0;
|
|
9149
|
+
return {
|
|
9150
|
+
sessionName: buildProjectSessionName(projectRoot),
|
|
9151
|
+
windowName: buildWorktreeWindowName(branch),
|
|
9152
|
+
shellCommand: ctx.paneCommands.shell,
|
|
9153
|
+
panes,
|
|
9154
|
+
focusPaneIndex
|
|
9155
|
+
};
|
|
9156
|
+
}
|
|
9157
|
+
function ensureSessionLayout(tmux, plan) {
|
|
9158
|
+
const rootPane = plan.panes[0];
|
|
9159
|
+
tmux.ensureServer();
|
|
9160
|
+
tmux.ensureSession(plan.sessionName, rootPane.cwd);
|
|
9161
|
+
if (tmux.hasWindow(plan.sessionName, plan.windowName)) {
|
|
9162
|
+
tmux.killWindow(plan.sessionName, plan.windowName);
|
|
9163
|
+
}
|
|
9164
|
+
tmux.createWindow({
|
|
9165
|
+
sessionName: plan.sessionName,
|
|
9166
|
+
windowName: plan.windowName,
|
|
9167
|
+
cwd: rootPane.cwd,
|
|
9168
|
+
command: plan.shellCommand
|
|
9169
|
+
});
|
|
9170
|
+
tmux.setWindowOption(plan.sessionName, plan.windowName, "automatic-rename", "off");
|
|
9171
|
+
tmux.setWindowOption(plan.sessionName, plan.windowName, "allow-rename", "off");
|
|
9172
|
+
for (const pane of plan.panes.slice(1)) {
|
|
9173
|
+
const target = `${plan.sessionName}:${plan.windowName}.${pane.index - 1}`;
|
|
9174
|
+
tmux.splitWindow({
|
|
9175
|
+
target,
|
|
9176
|
+
split: pane.split ?? "right",
|
|
9177
|
+
sizePct: pane.sizePct,
|
|
9178
|
+
cwd: pane.cwd,
|
|
9179
|
+
command: plan.shellCommand
|
|
9180
|
+
});
|
|
9181
|
+
}
|
|
9182
|
+
for (const pane of plan.panes) {
|
|
9183
|
+
if (!pane.startupCommand)
|
|
9184
|
+
continue;
|
|
9185
|
+
tmux.runCommand(`${plan.sessionName}:${plan.windowName}.${pane.index}`, pane.startupCommand);
|
|
9186
|
+
}
|
|
9187
|
+
tmux.selectPane(`${plan.sessionName}:${plan.windowName}.${plan.focusPaneIndex}`);
|
|
9188
|
+
}
|
|
9189
|
+
|
|
9190
|
+
// backend/src/services/worktree-service.ts
|
|
9191
|
+
import { randomUUID } from "crypto";
|
|
9192
|
+
|
|
9193
|
+
// backend/src/domain/model.ts
|
|
9194
|
+
var WORKTREE_META_SCHEMA_VERSION = 1;
|
|
9195
|
+
|
|
9196
|
+
// backend/src/services/worktree-service.ts
|
|
9197
|
+
function toErrorMessage(error) {
|
|
9198
|
+
return error instanceof Error ? error.message : String(error);
|
|
9199
|
+
}
|
|
9200
|
+
function joinErrorMessages(messages) {
|
|
9201
|
+
return messages.filter((message) => message.length > 0).join("; ");
|
|
9202
|
+
}
|
|
9203
|
+
function cleanupSessionLayout(tmux, plan) {
|
|
9204
|
+
if (!tmux || !plan)
|
|
9205
|
+
return null;
|
|
9206
|
+
try {
|
|
9207
|
+
tmux.killWindow(plan.sessionName, plan.windowName);
|
|
9208
|
+
return null;
|
|
9209
|
+
} catch (error) {
|
|
9210
|
+
return `tmux cleanup failed: ${toErrorMessage(error)}`;
|
|
9211
|
+
}
|
|
9212
|
+
}
|
|
9213
|
+
function rollbackManagedWorktreeCreation(opts, sessionLayoutPlan, git, deps) {
|
|
9214
|
+
const cleanupErrors = [];
|
|
9215
|
+
const sessionCleanupError = cleanupSessionLayout(deps.tmux, sessionLayoutPlan);
|
|
9216
|
+
if (sessionCleanupError)
|
|
9217
|
+
cleanupErrors.push(sessionCleanupError);
|
|
9218
|
+
try {
|
|
9219
|
+
git.removeWorktree({
|
|
9220
|
+
repoRoot: opts.repoRoot,
|
|
9221
|
+
worktreePath: opts.worktreePath,
|
|
9222
|
+
force: true
|
|
9223
|
+
});
|
|
9224
|
+
} catch (error) {
|
|
9225
|
+
cleanupErrors.push(`worktree rollback failed: ${toErrorMessage(error)}`);
|
|
9226
|
+
}
|
|
9227
|
+
try {
|
|
9228
|
+
git.deleteBranch(opts.repoRoot, opts.branch, true);
|
|
9229
|
+
} catch (error) {
|
|
9230
|
+
cleanupErrors.push(`branch rollback failed: ${toErrorMessage(error)}`);
|
|
9231
|
+
}
|
|
9232
|
+
return cleanupErrors.length > 0 ? joinErrorMessages(cleanupErrors) : null;
|
|
9233
|
+
}
|
|
9234
|
+
async function initializeManagedWorktree(opts) {
|
|
9235
|
+
if (opts.controlUrl && !opts.controlToken || !opts.controlUrl && opts.controlToken) {
|
|
9236
|
+
throw new Error("controlUrl and controlToken must be provided together");
|
|
9237
|
+
}
|
|
9238
|
+
const createdAt = (opts.now ?? (() => new Date))().toISOString();
|
|
9239
|
+
const meta = {
|
|
9240
|
+
schemaVersion: WORKTREE_META_SCHEMA_VERSION,
|
|
9241
|
+
worktreeId: opts.worktreeId ?? randomUUID(),
|
|
9242
|
+
branch: opts.branch,
|
|
9243
|
+
createdAt,
|
|
9244
|
+
profile: opts.profile,
|
|
9245
|
+
agent: opts.agent,
|
|
9246
|
+
runtime: opts.runtime,
|
|
9247
|
+
startupEnvValues: { ...opts.startupEnvValues ?? {} },
|
|
9248
|
+
allocatedPorts: { ...opts.allocatedPorts ?? {} }
|
|
9249
|
+
};
|
|
9250
|
+
const paths = await ensureWorktreeStorageDirs(opts.gitDir);
|
|
9251
|
+
await writeWorktreeMeta(opts.gitDir, meta);
|
|
9252
|
+
const runtimeEnv = buildRuntimeEnvMap(meta, opts.runtimeEnvExtras);
|
|
9253
|
+
await writeRuntimeEnv(opts.gitDir, runtimeEnv);
|
|
9254
|
+
let controlEnv = null;
|
|
9255
|
+
if (opts.controlUrl && opts.controlToken) {
|
|
9256
|
+
controlEnv = buildControlEnvMap({
|
|
9257
|
+
controlUrl: opts.controlUrl,
|
|
9258
|
+
controlToken: opts.controlToken,
|
|
9259
|
+
worktreeId: meta.worktreeId,
|
|
9260
|
+
branch: meta.branch
|
|
9261
|
+
});
|
|
9262
|
+
await writeControlEnv(opts.gitDir, controlEnv);
|
|
7973
9263
|
}
|
|
9264
|
+
return {
|
|
9265
|
+
meta,
|
|
9266
|
+
paths,
|
|
9267
|
+
runtimeEnv,
|
|
9268
|
+
controlEnv
|
|
9269
|
+
};
|
|
7974
9270
|
}
|
|
7975
|
-
function
|
|
7976
|
-
const
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
|
|
7980
|
-
|
|
7981
|
-
|
|
7982
|
-
|
|
7983
|
-
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
|
|
9271
|
+
async function createManagedWorktree(opts, deps = {}) {
|
|
9272
|
+
const git = deps.git ?? new BunGitGateway;
|
|
9273
|
+
let worktreeCreated = false;
|
|
9274
|
+
let sessionLayoutPlan = opts.sessionLayoutPlan;
|
|
9275
|
+
try {
|
|
9276
|
+
git.createWorktree({
|
|
9277
|
+
repoRoot: opts.repoRoot,
|
|
9278
|
+
worktreePath: opts.worktreePath,
|
|
9279
|
+
branch: opts.branch,
|
|
9280
|
+
baseBranch: opts.baseBranch
|
|
9281
|
+
});
|
|
9282
|
+
worktreeCreated = true;
|
|
9283
|
+
const gitDir = git.resolveWorktreeGitDir(opts.worktreePath);
|
|
9284
|
+
const initialized = await initializeManagedWorktree({
|
|
9285
|
+
gitDir,
|
|
9286
|
+
branch: opts.branch,
|
|
9287
|
+
profile: opts.profile,
|
|
9288
|
+
agent: opts.agent,
|
|
9289
|
+
runtime: opts.runtime,
|
|
9290
|
+
startupEnvValues: opts.startupEnvValues,
|
|
9291
|
+
allocatedPorts: opts.allocatedPorts,
|
|
9292
|
+
runtimeEnvExtras: opts.runtimeEnvExtras,
|
|
9293
|
+
controlUrl: opts.controlUrl,
|
|
9294
|
+
controlToken: opts.controlToken,
|
|
9295
|
+
now: opts.now,
|
|
9296
|
+
worktreeId: opts.worktreeId
|
|
9297
|
+
});
|
|
9298
|
+
if (deps.tmux) {
|
|
9299
|
+
sessionLayoutPlan = sessionLayoutPlan ?? opts.sessionLayoutPlanBuilder?.(initialized);
|
|
9300
|
+
if (sessionLayoutPlan) {
|
|
9301
|
+
ensureSessionLayout(deps.tmux, sessionLayoutPlan);
|
|
9302
|
+
}
|
|
7987
9303
|
}
|
|
9304
|
+
return initialized;
|
|
9305
|
+
} catch (error) {
|
|
9306
|
+
if (!worktreeCreated)
|
|
9307
|
+
throw error;
|
|
9308
|
+
const rollbackError = rollbackManagedWorktreeCreation(opts, sessionLayoutPlan, git, deps);
|
|
9309
|
+
if (!rollbackError)
|
|
9310
|
+
throw error;
|
|
9311
|
+
throw new Error(`${toErrorMessage(error)}; ${rollbackError}`);
|
|
7988
9312
|
}
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
|
|
9313
|
+
}
|
|
9314
|
+
function removeManagedWorktree(opts, git = new BunGitGateway) {
|
|
9315
|
+
git.removeWorktree({
|
|
9316
|
+
repoRoot: opts.repoRoot,
|
|
9317
|
+
worktreePath: opts.worktreePath,
|
|
9318
|
+
force: opts.force
|
|
9319
|
+
});
|
|
9320
|
+
if (opts.deleteBranch && opts.branch) {
|
|
9321
|
+
git.deleteBranch(opts.repoRoot, opts.branch, opts.deleteBranchForce);
|
|
7998
9322
|
}
|
|
7999
|
-
return null;
|
|
8000
9323
|
}
|
|
8001
|
-
|
|
8002
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
|
|
8007
|
-
return parseTmuxSessionForWorktree(output, worktreeName) ?? "0";
|
|
8008
|
-
} catch {}
|
|
8009
|
-
return "0";
|
|
9324
|
+
function mergeManagedWorktree(opts, git = new BunGitGateway) {
|
|
9325
|
+
git.mergeBranch({
|
|
9326
|
+
repoRoot: opts.repoRoot,
|
|
9327
|
+
sourceBranch: opts.sourceBranch,
|
|
9328
|
+
targetBranch: opts.targetBranch
|
|
9329
|
+
});
|
|
8010
9330
|
}
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
9331
|
+
|
|
9332
|
+
// backend/src/services/lifecycle-service.ts
|
|
9333
|
+
function generateBranchName() {
|
|
9334
|
+
return `change-${randomUUID2().slice(0, 8)}`;
|
|
9335
|
+
}
|
|
9336
|
+
function toErrorMessage2(error) {
|
|
9337
|
+
return error instanceof Error ? error.message : String(error);
|
|
9338
|
+
}
|
|
9339
|
+
function stringifyStartupEnvValue(value) {
|
|
9340
|
+
return typeof value === "boolean" ? String(value) : value;
|
|
9341
|
+
}
|
|
9342
|
+
|
|
9343
|
+
class LifecycleError extends Error {
|
|
9344
|
+
status;
|
|
9345
|
+
constructor(message, status) {
|
|
9346
|
+
super(message);
|
|
9347
|
+
this.status = status;
|
|
8015
9348
|
}
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
groupedSessionName: gName,
|
|
8031
|
-
scrollback: [],
|
|
8032
|
-
scrollbackBytes: 0,
|
|
8033
|
-
onData: null,
|
|
8034
|
-
onExit: null,
|
|
8035
|
-
cancelled: false
|
|
8036
|
-
};
|
|
8037
|
-
sessions.set(worktreeName, session);
|
|
8038
|
-
log.debug(`[term] attach(${worktreeName}) spawned pid=${proc.pid}`);
|
|
8039
|
-
(async () => {
|
|
8040
|
-
const reader = proc.stdout.getReader();
|
|
9349
|
+
}
|
|
9350
|
+
|
|
9351
|
+
class LifecycleService {
|
|
9352
|
+
deps;
|
|
9353
|
+
constructor(deps) {
|
|
9354
|
+
this.deps = deps;
|
|
9355
|
+
}
|
|
9356
|
+
async createWorktree(input) {
|
|
9357
|
+
const branch = await this.resolveBranch(input.branch, input.prompt);
|
|
9358
|
+
this.ensureBranchAvailable(branch);
|
|
9359
|
+
const { profileName, profile } = this.resolveProfile(input.profile);
|
|
9360
|
+
const agent = this.resolveAgent(input.agent);
|
|
9361
|
+
const worktreePath = this.resolveWorktreePath(branch);
|
|
9362
|
+
let initialized = null;
|
|
8041
9363
|
try {
|
|
8042
|
-
|
|
8043
|
-
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
|
|
8049
|
-
|
|
8050
|
-
|
|
8051
|
-
|
|
8052
|
-
|
|
8053
|
-
|
|
9364
|
+
await mkdir4(dirname3(worktreePath), { recursive: true });
|
|
9365
|
+
initialized = await createManagedWorktree({
|
|
9366
|
+
repoRoot: this.deps.projectRoot,
|
|
9367
|
+
worktreePath,
|
|
9368
|
+
branch,
|
|
9369
|
+
baseBranch: this.deps.config.workspace.mainBranch,
|
|
9370
|
+
profile: profileName,
|
|
9371
|
+
agent,
|
|
9372
|
+
runtime: profile.runtime,
|
|
9373
|
+
startupEnvValues: await this.buildStartupEnvValues(input.envOverrides),
|
|
9374
|
+
allocatedPorts: await this.allocatePorts(),
|
|
9375
|
+
runtimeEnvExtras: { WEBMUX_WORKTREE_PATH: worktreePath },
|
|
9376
|
+
controlUrl: this.controlUrl(),
|
|
9377
|
+
controlToken: await this.deps.getControlToken()
|
|
9378
|
+
}, {
|
|
9379
|
+
git: this.deps.git
|
|
9380
|
+
});
|
|
9381
|
+
await ensureAgentRuntimeArtifacts({
|
|
9382
|
+
gitDir: initialized.paths.gitDir,
|
|
9383
|
+
worktreePath
|
|
9384
|
+
});
|
|
9385
|
+
await this.runLifecycleHook({
|
|
9386
|
+
name: "postCreate",
|
|
9387
|
+
command: this.deps.config.lifecycleHooks.postCreate,
|
|
9388
|
+
meta: initialized.meta,
|
|
9389
|
+
worktreePath
|
|
9390
|
+
});
|
|
9391
|
+
await this.materializeRuntimeSession({
|
|
9392
|
+
branch,
|
|
9393
|
+
profile,
|
|
9394
|
+
agent,
|
|
9395
|
+
initialized,
|
|
9396
|
+
worktreePath,
|
|
9397
|
+
prompt: input.prompt
|
|
9398
|
+
});
|
|
9399
|
+
await this.deps.reconciliation.reconcile(this.deps.projectRoot);
|
|
9400
|
+
return {
|
|
9401
|
+
branch,
|
|
9402
|
+
worktreeId: initialized.meta.worktreeId
|
|
9403
|
+
};
|
|
9404
|
+
} catch (error) {
|
|
9405
|
+
if (initialized) {
|
|
9406
|
+
const cleanupError = await this.cleanupFailedCreate(branch, worktreePath, profile.runtime);
|
|
9407
|
+
if (cleanupError) {
|
|
9408
|
+
throw this.wrapOperationError(new Error(`${toErrorMessage2(error)}; ${cleanupError}`));
|
|
8054
9409
|
}
|
|
8055
|
-
session.onData?.(str);
|
|
8056
|
-
}
|
|
8057
|
-
} catch (err) {
|
|
8058
|
-
if (!session.cancelled) {
|
|
8059
|
-
log.error(`[term] stdout reader error(${worktreeName})`, err);
|
|
8060
9410
|
}
|
|
9411
|
+
throw this.wrapOperationError(error);
|
|
8061
9412
|
}
|
|
8062
|
-
}
|
|
8063
|
-
|
|
8064
|
-
const reader = proc.stderr.getReader();
|
|
9413
|
+
}
|
|
9414
|
+
async openWorktree(branch) {
|
|
8065
9415
|
try {
|
|
8066
|
-
|
|
8067
|
-
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
9416
|
+
const resolved = await this.resolveExistingWorktree(branch);
|
|
9417
|
+
const initialized = resolved.meta ? await this.refreshManagedArtifacts(resolved) : await this.initializeUnmanagedWorktree(resolved);
|
|
9418
|
+
const { profile } = this.resolveProfile(initialized.meta.profile);
|
|
9419
|
+
await ensureAgentRuntimeArtifacts({
|
|
9420
|
+
gitDir: initialized.paths.gitDir,
|
|
9421
|
+
worktreePath: resolved.entry.path
|
|
9422
|
+
});
|
|
9423
|
+
await this.materializeRuntimeSession({
|
|
9424
|
+
branch,
|
|
9425
|
+
profile,
|
|
9426
|
+
agent: initialized.meta.agent,
|
|
9427
|
+
initialized,
|
|
9428
|
+
worktreePath: resolved.entry.path
|
|
9429
|
+
});
|
|
9430
|
+
await this.deps.reconciliation.reconcile(this.deps.projectRoot);
|
|
9431
|
+
return {
|
|
9432
|
+
branch,
|
|
9433
|
+
worktreeId: initialized.meta.worktreeId
|
|
9434
|
+
};
|
|
9435
|
+
} catch (error) {
|
|
9436
|
+
throw this.wrapOperationError(error);
|
|
9437
|
+
}
|
|
9438
|
+
}
|
|
9439
|
+
async closeWorktree(branch) {
|
|
9440
|
+
try {
|
|
9441
|
+
const resolved = await this.resolveExistingWorktree(branch);
|
|
9442
|
+
this.deps.tmux.killWindow(buildProjectSessionName(this.deps.projectRoot), buildWorktreeWindowName(branch));
|
|
9443
|
+
await this.deps.reconciliation.reconcile(this.deps.projectRoot);
|
|
9444
|
+
} catch (error) {
|
|
9445
|
+
throw this.wrapOperationError(error);
|
|
9446
|
+
}
|
|
9447
|
+
}
|
|
9448
|
+
async removeWorktree(branch) {
|
|
9449
|
+
try {
|
|
9450
|
+
const resolved = await this.resolveExistingWorktree(branch);
|
|
9451
|
+
await this.removeResolvedWorktree(resolved);
|
|
9452
|
+
} catch (error) {
|
|
9453
|
+
throw this.wrapOperationError(error);
|
|
9454
|
+
}
|
|
9455
|
+
}
|
|
9456
|
+
async mergeWorktree(branch) {
|
|
9457
|
+
try {
|
|
9458
|
+
const resolved = await this.resolveExistingWorktree(branch);
|
|
9459
|
+
this.ensureNoUncommittedChanges(resolved.entry);
|
|
9460
|
+
mergeManagedWorktree({
|
|
9461
|
+
repoRoot: this.deps.projectRoot,
|
|
9462
|
+
sourceBranch: branch,
|
|
9463
|
+
targetBranch: this.deps.config.workspace.mainBranch
|
|
9464
|
+
}, this.deps.git);
|
|
9465
|
+
try {
|
|
9466
|
+
await this.removeResolvedWorktree(resolved);
|
|
9467
|
+
} catch (error) {
|
|
9468
|
+
throw new LifecycleError(`Merged ${branch} into ${this.deps.config.workspace.mainBranch} but cleanup failed: ${toErrorMessage2(error)}`, 500);
|
|
8071
9469
|
}
|
|
8072
|
-
} catch {
|
|
8073
|
-
|
|
8074
|
-
proc.exited.then((exitCode) => {
|
|
8075
|
-
log.debug(`[term] proc exited(${worktreeName}) pid=${proc.pid} code=${exitCode}`);
|
|
8076
|
-
if (sessions.get(worktreeName) === session) {
|
|
8077
|
-
session.onExit?.(exitCode);
|
|
8078
|
-
sessions.delete(worktreeName);
|
|
8079
|
-
} else {
|
|
8080
|
-
log.debug(`[term] proc exited(${worktreeName}) stale session, skipping cleanup`);
|
|
9470
|
+
} catch (error) {
|
|
9471
|
+
throw this.wrapOperationError(error);
|
|
8081
9472
|
}
|
|
8082
|
-
killTmuxSession(gName);
|
|
8083
|
-
});
|
|
8084
|
-
return worktreeName;
|
|
8085
|
-
}
|
|
8086
|
-
async function detach(worktreeName) {
|
|
8087
|
-
const session = sessions.get(worktreeName);
|
|
8088
|
-
if (!session) {
|
|
8089
|
-
log.debug(`[term] detach(${worktreeName}) no session found`);
|
|
8090
|
-
return;
|
|
8091
9473
|
}
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
}
|
|
8098
|
-
|
|
8099
|
-
const session = sessions.get(worktreeName);
|
|
8100
|
-
if (!session) {
|
|
8101
|
-
log.warn(`[term] write(${worktreeName}) NO SESSION - input dropped (${data.length} bytes)`);
|
|
8102
|
-
return;
|
|
9474
|
+
async resolveBranch(rawBranch, prompt) {
|
|
9475
|
+
const explicitBranch = rawBranch?.trim();
|
|
9476
|
+
const branch = explicitBranch || await this.generateAutoName(prompt) || generateBranchName();
|
|
9477
|
+
if (!isValidBranchName(branch)) {
|
|
9478
|
+
throw new LifecycleError(`Invalid branch name: ${branch}`, 400);
|
|
9479
|
+
}
|
|
9480
|
+
return branch;
|
|
8103
9481
|
}
|
|
8104
|
-
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
9482
|
+
async generateAutoName(prompt) {
|
|
9483
|
+
if (!this.deps.config.autoName || !prompt?.trim()) {
|
|
9484
|
+
return null;
|
|
9485
|
+
}
|
|
9486
|
+
return await this.deps.autoName.generateBranchName(this.deps.config.autoName, prompt);
|
|
8109
9487
|
}
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
const windowTarget = `${session.groupedSessionName}:wm-${worktreeName}`;
|
|
8116
|
-
await asyncTmux(["tmux", "send-keys", "-t", windowTarget, "-H", ...hexBytes]);
|
|
8117
|
-
}
|
|
8118
|
-
async function resize(worktreeName, cols, rows) {
|
|
8119
|
-
const session = sessions.get(worktreeName);
|
|
8120
|
-
if (!session)
|
|
8121
|
-
return;
|
|
8122
|
-
const windowTarget = `${session.groupedSessionName}:wm-${worktreeName}`;
|
|
8123
|
-
const result = await asyncTmux(["tmux", "resize-window", "-t", windowTarget, "-x", String(cols), "-y", String(rows)]);
|
|
8124
|
-
if (result.exitCode !== 0)
|
|
8125
|
-
log.warn(`[term] resize failed: ${result.stderr}`);
|
|
8126
|
-
}
|
|
8127
|
-
function getScrollback(worktreeName) {
|
|
8128
|
-
return sessions.get(worktreeName)?.scrollback.join("") ?? "";
|
|
8129
|
-
}
|
|
8130
|
-
function setCallbacks(worktreeName, onData, onExit) {
|
|
8131
|
-
const session = sessions.get(worktreeName);
|
|
8132
|
-
if (session) {
|
|
8133
|
-
session.onData = onData;
|
|
8134
|
-
session.onExit = onExit;
|
|
9488
|
+
ensureBranchAvailable(branch) {
|
|
9489
|
+
const exists = this.listProjectWorktrees().some((entry) => entry.branch === branch);
|
|
9490
|
+
if (exists) {
|
|
9491
|
+
throw new LifecycleError(`Worktree already exists: ${branch}`, 409);
|
|
9492
|
+
}
|
|
8135
9493
|
}
|
|
8136
|
-
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
9494
|
+
resolveProfile(profileName) {
|
|
9495
|
+
const name = profileName ?? getDefaultProfileName(this.deps.config);
|
|
9496
|
+
const profile = this.deps.config.profiles[name];
|
|
9497
|
+
if (!profile) {
|
|
9498
|
+
throw new LifecycleError(`Unknown profile: ${name}`, 400);
|
|
9499
|
+
}
|
|
9500
|
+
return {
|
|
9501
|
+
profileName: name,
|
|
9502
|
+
profile
|
|
9503
|
+
};
|
|
8142
9504
|
}
|
|
8143
|
-
|
|
8144
|
-
|
|
8145
|
-
|
|
8146
|
-
|
|
8147
|
-
|
|
8148
|
-
|
|
8149
|
-
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
9505
|
+
resolveAgent(agent) {
|
|
9506
|
+
if (!agent)
|
|
9507
|
+
return this.deps.config.workspace.defaultAgent;
|
|
9508
|
+
if (agent !== "claude" && agent !== "codex") {
|
|
9509
|
+
throw new LifecycleError(`Unknown agent: ${agent}`, 400);
|
|
9510
|
+
}
|
|
9511
|
+
return agent;
|
|
9512
|
+
}
|
|
9513
|
+
async buildStartupEnvValues(envOverrides) {
|
|
9514
|
+
const startupEnvValues = Object.fromEntries(Object.entries(this.deps.config.startupEnvs).map(([key, value]) => [key, stringifyStartupEnvValue(value)]));
|
|
9515
|
+
for (const [key, value] of Object.entries(envOverrides ?? {})) {
|
|
9516
|
+
if (!isValidEnvKey2(key)) {
|
|
9517
|
+
throw new LifecycleError(`Invalid env override key: ${key}`, 400);
|
|
9518
|
+
}
|
|
9519
|
+
startupEnvValues[key] = value;
|
|
9520
|
+
}
|
|
9521
|
+
return startupEnvValues;
|
|
9522
|
+
}
|
|
9523
|
+
async allocatePorts() {
|
|
9524
|
+
const metas = await this.readManagedMetas();
|
|
9525
|
+
return allocateServicePorts(metas, this.deps.config.services);
|
|
9526
|
+
}
|
|
9527
|
+
resolveWorktreePath(branch) {
|
|
9528
|
+
return resolve3(this.deps.projectRoot, this.deps.config.workspace.worktreeRoot, branch);
|
|
9529
|
+
}
|
|
9530
|
+
listProjectWorktrees() {
|
|
9531
|
+
const projectRoot = resolve3(this.deps.projectRoot);
|
|
9532
|
+
return this.deps.git.listWorktrees(projectRoot).filter((entry) => !entry.bare && resolve3(entry.path) !== projectRoot);
|
|
9533
|
+
}
|
|
9534
|
+
async readManagedMetas() {
|
|
9535
|
+
const metas = await Promise.all(this.listProjectWorktrees().map(async (entry) => {
|
|
9536
|
+
const gitDir = this.deps.git.resolveWorktreeGitDir(entry.path);
|
|
9537
|
+
return readWorktreeMeta(gitDir);
|
|
9538
|
+
}));
|
|
9539
|
+
return metas.filter((meta) => meta !== null);
|
|
9540
|
+
}
|
|
9541
|
+
async resolveExistingWorktree(branch) {
|
|
9542
|
+
const entry = this.listProjectWorktrees().find((candidate) => candidate.branch === branch);
|
|
9543
|
+
if (!entry) {
|
|
9544
|
+
throw new LifecycleError(`Worktree not found: ${branch}`, 404);
|
|
9545
|
+
}
|
|
9546
|
+
const gitDir = this.deps.git.resolveWorktreeGitDir(entry.path);
|
|
9547
|
+
const meta = await readWorktreeMeta(gitDir);
|
|
9548
|
+
return { entry, gitDir, meta };
|
|
9549
|
+
}
|
|
9550
|
+
async initializeUnmanagedWorktree(resolved) {
|
|
9551
|
+
const { profileName, profile } = this.resolveProfile(undefined);
|
|
9552
|
+
return initializeManagedWorktree({
|
|
9553
|
+
gitDir: resolved.gitDir,
|
|
9554
|
+
branch: resolved.entry.branch ?? resolved.entry.path,
|
|
9555
|
+
profile: profileName,
|
|
9556
|
+
agent: this.deps.config.workspace.defaultAgent,
|
|
9557
|
+
runtime: profile.runtime,
|
|
9558
|
+
startupEnvValues: await this.buildStartupEnvValues(undefined),
|
|
9559
|
+
allocatedPorts: await this.allocatePorts(),
|
|
9560
|
+
runtimeEnvExtras: { WEBMUX_WORKTREE_PATH: resolved.entry.path },
|
|
9561
|
+
controlUrl: this.controlUrl(),
|
|
9562
|
+
controlToken: await this.deps.getControlToken()
|
|
9563
|
+
});
|
|
9564
|
+
}
|
|
9565
|
+
async refreshManagedArtifacts(resolved) {
|
|
9566
|
+
if (!resolved.meta) {
|
|
9567
|
+
throw new Error("Missing managed metadata");
|
|
9568
|
+
}
|
|
9569
|
+
const runtimeEnv = buildRuntimeEnvMap(resolved.meta, {
|
|
9570
|
+
WEBMUX_WORKTREE_PATH: resolved.entry.path
|
|
9571
|
+
});
|
|
9572
|
+
await writeRuntimeEnv(resolved.gitDir, runtimeEnv);
|
|
9573
|
+
const controlEnv = buildControlEnvMap({
|
|
9574
|
+
controlUrl: this.controlUrl(),
|
|
9575
|
+
controlToken: await this.deps.getControlToken(),
|
|
9576
|
+
worktreeId: resolved.meta.worktreeId,
|
|
9577
|
+
branch: resolved.meta.branch
|
|
9578
|
+
});
|
|
9579
|
+
await writeControlEnv(resolved.gitDir, controlEnv);
|
|
9580
|
+
return {
|
|
9581
|
+
meta: resolved.meta,
|
|
9582
|
+
paths: getWorktreeStoragePaths(resolved.gitDir),
|
|
9583
|
+
runtimeEnv,
|
|
9584
|
+
controlEnv
|
|
9585
|
+
};
|
|
9586
|
+
}
|
|
9587
|
+
async materializeRuntimeSession(input) {
|
|
9588
|
+
if (input.profile.runtime === "docker") {
|
|
9589
|
+
const dockerProfile = this.requireDockerProfile(input.profile);
|
|
9590
|
+
const containerName2 = await this.deps.docker.launchContainer({
|
|
9591
|
+
branch: input.branch,
|
|
9592
|
+
wtDir: input.worktreePath,
|
|
9593
|
+
mainRepoDir: this.deps.projectRoot,
|
|
9594
|
+
sandboxConfig: dockerProfile,
|
|
9595
|
+
services: this.deps.config.services,
|
|
9596
|
+
runtimeEnv: input.initialized.runtimeEnv
|
|
9597
|
+
});
|
|
9598
|
+
ensureSessionLayout(this.deps.tmux, this.buildSessionLayout({
|
|
9599
|
+
branch: input.branch,
|
|
9600
|
+
profile: input.profile,
|
|
9601
|
+
agent: input.agent,
|
|
9602
|
+
initialized: input.initialized,
|
|
9603
|
+
worktreePath: input.worktreePath,
|
|
9604
|
+
prompt: input.prompt,
|
|
9605
|
+
containerName: containerName2
|
|
9606
|
+
}));
|
|
9607
|
+
return;
|
|
9608
|
+
}
|
|
9609
|
+
ensureSessionLayout(this.deps.tmux, this.buildSessionLayout({
|
|
9610
|
+
branch: input.branch,
|
|
9611
|
+
profile: input.profile,
|
|
9612
|
+
agent: input.agent,
|
|
9613
|
+
initialized: input.initialized,
|
|
9614
|
+
worktreePath: input.worktreePath,
|
|
9615
|
+
prompt: input.prompt
|
|
9616
|
+
}));
|
|
9617
|
+
}
|
|
9618
|
+
buildSessionLayout(input) {
|
|
9619
|
+
const systemPrompt = input.profile.systemPrompt ? expandTemplate(input.profile.systemPrompt, input.initialized.runtimeEnv) : undefined;
|
|
9620
|
+
const containerName2 = input.containerName;
|
|
9621
|
+
return planSessionLayout(this.deps.projectRoot, input.branch, input.profile.panes, {
|
|
9622
|
+
repoRoot: this.deps.projectRoot,
|
|
9623
|
+
worktreePath: input.worktreePath,
|
|
9624
|
+
paneCommands: containerName2 ? {
|
|
9625
|
+
agent: buildDockerAgentPaneCommand({
|
|
9626
|
+
agent: input.agent,
|
|
9627
|
+
containerName: containerName2,
|
|
9628
|
+
worktreePath: input.worktreePath,
|
|
9629
|
+
runtimeEnvPath: input.initialized.paths.runtimeEnvPath,
|
|
9630
|
+
yolo: input.profile.yolo === true,
|
|
9631
|
+
systemPrompt,
|
|
9632
|
+
prompt: input.prompt
|
|
9633
|
+
}),
|
|
9634
|
+
shell: buildDockerShellCommand(containerName2, input.worktreePath, input.initialized.paths.runtimeEnvPath)
|
|
9635
|
+
} : {
|
|
9636
|
+
agent: buildAgentPaneCommand({
|
|
9637
|
+
agent: input.agent,
|
|
9638
|
+
runtimeEnvPath: input.initialized.paths.runtimeEnvPath,
|
|
9639
|
+
yolo: input.profile.yolo === true,
|
|
9640
|
+
systemPrompt,
|
|
9641
|
+
prompt: input.prompt
|
|
9642
|
+
}),
|
|
9643
|
+
shell: buildManagedShellCommand(input.initialized.paths.runtimeEnvPath)
|
|
9644
|
+
}
|
|
9645
|
+
});
|
|
9646
|
+
}
|
|
9647
|
+
requireDockerProfile(profile) {
|
|
9648
|
+
if (!isDockerProfile(profile)) {
|
|
9649
|
+
throw new LifecycleError("Docker profile is missing an image", 422);
|
|
9650
|
+
}
|
|
9651
|
+
return profile;
|
|
9652
|
+
}
|
|
9653
|
+
async cleanupFailedCreate(branch, worktreePath, runtime) {
|
|
9654
|
+
const cleanupErrors = [];
|
|
9655
|
+
if (runtime === "docker") {
|
|
9656
|
+
try {
|
|
9657
|
+
await this.deps.docker.removeContainer(branch);
|
|
9658
|
+
} catch (error) {
|
|
9659
|
+
cleanupErrors.push(`container cleanup failed: ${toErrorMessage2(error)}`);
|
|
9660
|
+
}
|
|
9661
|
+
}
|
|
9662
|
+
try {
|
|
9663
|
+
this.deps.tmux.killWindow(buildProjectSessionName(this.deps.projectRoot), buildWorktreeWindowName(branch));
|
|
9664
|
+
} catch (error) {
|
|
9665
|
+
cleanupErrors.push(`tmux cleanup failed: ${toErrorMessage2(error)}`);
|
|
9666
|
+
}
|
|
9667
|
+
try {
|
|
9668
|
+
removeManagedWorktree({
|
|
9669
|
+
repoRoot: this.deps.projectRoot,
|
|
9670
|
+
worktreePath,
|
|
9671
|
+
branch,
|
|
9672
|
+
force: true,
|
|
9673
|
+
deleteBranch: true,
|
|
9674
|
+
deleteBranchForce: true
|
|
9675
|
+
}, this.deps.git);
|
|
9676
|
+
} catch (error) {
|
|
9677
|
+
cleanupErrors.push(`worktree cleanup failed: ${toErrorMessage2(error)}`);
|
|
9678
|
+
}
|
|
9679
|
+
return cleanupErrors.length > 0 ? cleanupErrors.join("; ") : null;
|
|
9680
|
+
}
|
|
9681
|
+
ensureNoUncommittedChanges(entry) {
|
|
9682
|
+
const status = this.deps.git.readWorktreeStatus(entry.path);
|
|
9683
|
+
if (status.dirty) {
|
|
9684
|
+
throw new LifecycleError(`Worktree has uncommitted changes: ${entry.branch ?? entry.path}`, 409);
|
|
9685
|
+
}
|
|
9686
|
+
}
|
|
9687
|
+
controlUrl() {
|
|
9688
|
+
return `${this.deps.controlBaseUrl.replace(/\/+$/, "")}/api/runtime/events`;
|
|
9689
|
+
}
|
|
9690
|
+
async removeResolvedWorktree(resolved) {
|
|
9691
|
+
await this.runLifecycleHook({
|
|
9692
|
+
name: "preRemove",
|
|
9693
|
+
command: this.deps.config.lifecycleHooks.preRemove,
|
|
9694
|
+
meta: resolved.meta,
|
|
9695
|
+
worktreePath: resolved.entry.path
|
|
9696
|
+
});
|
|
9697
|
+
const branch = resolved.entry.branch ?? resolved.entry.path;
|
|
9698
|
+
if (resolved.meta?.runtime === "docker") {
|
|
9699
|
+
await this.deps.docker.removeContainer(branch);
|
|
9700
|
+
}
|
|
9701
|
+
this.deps.tmux.killWindow(buildProjectSessionName(this.deps.projectRoot), buildWorktreeWindowName(branch));
|
|
9702
|
+
removeManagedWorktree({
|
|
9703
|
+
repoRoot: this.deps.projectRoot,
|
|
9704
|
+
worktreePath: resolved.entry.path,
|
|
9705
|
+
branch,
|
|
9706
|
+
force: true,
|
|
9707
|
+
deleteBranch: true,
|
|
9708
|
+
deleteBranchForce: true
|
|
9709
|
+
}, this.deps.git);
|
|
9710
|
+
await this.deps.reconciliation.reconcile(this.deps.projectRoot);
|
|
9711
|
+
}
|
|
9712
|
+
async runLifecycleHook(input) {
|
|
9713
|
+
if (!input.command || !input.meta) {
|
|
9714
|
+
return;
|
|
9715
|
+
}
|
|
9716
|
+
await this.deps.hooks.run({
|
|
9717
|
+
name: input.name,
|
|
9718
|
+
command: input.command,
|
|
9719
|
+
cwd: input.worktreePath,
|
|
9720
|
+
env: buildRuntimeEnvMap(input.meta, {
|
|
9721
|
+
WEBMUX_WORKTREE_PATH: input.worktreePath
|
|
9722
|
+
})
|
|
9723
|
+
});
|
|
9724
|
+
}
|
|
9725
|
+
wrapOperationError(error) {
|
|
9726
|
+
if (error instanceof LifecycleError) {
|
|
9727
|
+
return error;
|
|
9728
|
+
}
|
|
9729
|
+
return new LifecycleError(toErrorMessage2(error), 422);
|
|
8157
9730
|
}
|
|
8158
9731
|
}
|
|
8159
9732
|
|
|
8160
|
-
// backend/src/pr.ts
|
|
9733
|
+
// backend/src/services/pr-service.ts
|
|
8161
9734
|
var PR_FETCH_LIMIT = 50;
|
|
8162
9735
|
var GH_TIMEOUT_MS = 15000;
|
|
8163
9736
|
var prUpdatedAtCache = new Map;
|
|
@@ -8297,9 +9870,9 @@ async function fetchReviewComments(prNumber, repoSlug, cwd) {
|
|
|
8297
9870
|
apiPath,
|
|
8298
9871
|
"--include"
|
|
8299
9872
|
];
|
|
8300
|
-
const
|
|
8301
|
-
if (
|
|
8302
|
-
args.push("--header", `If-None-Match: ${
|
|
9873
|
+
const cached = etagCache.get(apiPath);
|
|
9874
|
+
if (cached) {
|
|
9875
|
+
args.push("--header", `If-None-Match: ${cached.etag}`);
|
|
8303
9876
|
}
|
|
8304
9877
|
const proc = Bun.spawn(args, {
|
|
8305
9878
|
stdout: "pipe",
|
|
@@ -8312,7 +9885,7 @@ async function fetchReviewComments(prNumber, repoSlug, cwd) {
|
|
|
8312
9885
|
});
|
|
8313
9886
|
const raceResult = await Promise.race([proc.exited, timeout]);
|
|
8314
9887
|
if (raceResult === "timeout")
|
|
8315
|
-
return
|
|
9888
|
+
return cached?.comments ?? [];
|
|
8316
9889
|
const raw = await new Response(proc.stdout).text();
|
|
8317
9890
|
let blankLineIdx = raw.indexOf(`\r
|
|
8318
9891
|
\r
|
|
@@ -8326,21 +9899,21 @@ async function fetchReviewComments(prNumber, repoSlug, cwd) {
|
|
|
8326
9899
|
}
|
|
8327
9900
|
if (blankLineIdx === -1) {
|
|
8328
9901
|
if (raceResult !== 0)
|
|
8329
|
-
return
|
|
9902
|
+
return cached?.comments ?? [];
|
|
8330
9903
|
try {
|
|
8331
9904
|
return parseReviewComments(raw);
|
|
8332
9905
|
} catch {
|
|
8333
|
-
return
|
|
9906
|
+
return cached?.comments ?? [];
|
|
8334
9907
|
}
|
|
8335
9908
|
}
|
|
8336
9909
|
const headerBlock = raw.slice(0, blankLineIdx);
|
|
8337
9910
|
const body = raw.slice(blankLineIdx + separatorLen);
|
|
8338
9911
|
if (headerBlock.includes("304 Not Modified")) {
|
|
8339
9912
|
log.debug(`[pr] etag cache hit for PR #${prNumber}`);
|
|
8340
|
-
return
|
|
9913
|
+
return cached?.comments ?? [];
|
|
8341
9914
|
}
|
|
8342
9915
|
if (raceResult !== 0)
|
|
8343
|
-
return
|
|
9916
|
+
return cached?.comments ?? [];
|
|
8344
9917
|
const etagMatch = headerBlock.match(/^etag:\s*(.+)$/mi);
|
|
8345
9918
|
try {
|
|
8346
9919
|
const comments = parseReviewComments(body);
|
|
@@ -8349,7 +9922,7 @@ async function fetchReviewComments(prNumber, repoSlug, cwd) {
|
|
|
8349
9922
|
}
|
|
8350
9923
|
return comments;
|
|
8351
9924
|
} catch {
|
|
8352
|
-
return
|
|
9925
|
+
return cached?.comments ?? [];
|
|
8353
9926
|
}
|
|
8354
9927
|
}
|
|
8355
9928
|
async function fetchPrState(url) {
|
|
@@ -8371,16 +9944,8 @@ async function fetchPrState(url) {
|
|
|
8371
9944
|
return null;
|
|
8372
9945
|
}
|
|
8373
9946
|
}
|
|
8374
|
-
async function refreshStalePrData(
|
|
8375
|
-
const
|
|
8376
|
-
if (!env.PR_DATA)
|
|
8377
|
-
return;
|
|
8378
|
-
let entries;
|
|
8379
|
-
try {
|
|
8380
|
-
entries = JSON.parse(env.PR_DATA);
|
|
8381
|
-
} catch {
|
|
8382
|
-
return;
|
|
8383
|
-
}
|
|
9947
|
+
async function refreshStalePrData(gitDir) {
|
|
9948
|
+
const entries = await readWorktreePrs(gitDir);
|
|
8384
9949
|
if (!entries.some((e) => e.state === "open"))
|
|
8385
9950
|
return;
|
|
8386
9951
|
const updated = await Promise.all(entries.map(async (entry) => {
|
|
@@ -8389,9 +9954,9 @@ async function refreshStalePrData(wtDir) {
|
|
|
8389
9954
|
const state = await fetchPrState(entry.url);
|
|
8390
9955
|
return state ? { ...entry, state } : entry;
|
|
8391
9956
|
}));
|
|
8392
|
-
await
|
|
9957
|
+
await writeWorktreePrs(gitDir, updated);
|
|
8393
9958
|
}
|
|
8394
|
-
async function syncPrStatus(
|
|
9959
|
+
async function syncPrStatus(getWorktreeGitDirs, linkedRepos, projectDir) {
|
|
8395
9960
|
log.debug(`[pr] starting sync (${1 + linkedRepos.length} repo(s))`);
|
|
8396
9961
|
const allRepoResults = await Promise.all([
|
|
8397
9962
|
fetchAllPrs(undefined, undefined, projectDir),
|
|
@@ -8409,8 +9974,8 @@ async function syncPrStatus(getWorktreePaths, linkedRepos, projectDir) {
|
|
|
8409
9974
|
branchPrs.set(branch, existing);
|
|
8410
9975
|
}
|
|
8411
9976
|
}
|
|
8412
|
-
const
|
|
8413
|
-
const activeBranches = new Set(
|
|
9977
|
+
const worktreeGitDirs = await getWorktreeGitDirs();
|
|
9978
|
+
const activeBranches = new Set(worktreeGitDirs.keys());
|
|
8414
9979
|
const reviewTuples = [];
|
|
8415
9980
|
for (const [branch, entries] of branchPrs) {
|
|
8416
9981
|
if (!activeBranches.has(branch))
|
|
@@ -8421,8 +9986,8 @@ async function syncPrStatus(getWorktreePaths, linkedRepos, projectDir) {
|
|
|
8421
9986
|
const cachedUpdatedAt = prUpdatedAtCache.get(entry.url);
|
|
8422
9987
|
if (cachedUpdatedAt === entry.updatedAt && prCommentsCache.has(entry.url)) {
|
|
8423
9988
|
log.debug(`[pr] skipping comments for PR #${entry.number} (unchanged)`);
|
|
8424
|
-
const
|
|
8425
|
-
entry.comments = [...entry.comments, ...
|
|
9989
|
+
const cached = prCommentsCache.get(entry.url);
|
|
9990
|
+
entry.comments = [...entry.comments, ...cached].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
8426
9991
|
} else {
|
|
8427
9992
|
const repoSlug = entry.repo ? linkedRepos.find((lr) => lr.alias === entry.repo)?.repo : undefined;
|
|
8428
9993
|
reviewTuples.push({ entry, repoSlug });
|
|
@@ -8442,21 +10007,21 @@ async function syncPrStatus(getWorktreePaths, linkedRepos, projectDir) {
|
|
|
8442
10007
|
}
|
|
8443
10008
|
const seen = new Set;
|
|
8444
10009
|
for (const [branch, entries] of branchPrs) {
|
|
8445
|
-
const
|
|
8446
|
-
if (!
|
|
10010
|
+
const gitDir = worktreeGitDirs.get(branch);
|
|
10011
|
+
if (!gitDir || seen.has(gitDir))
|
|
8447
10012
|
continue;
|
|
8448
|
-
seen.add(
|
|
8449
|
-
await
|
|
10013
|
+
seen.add(gitDir);
|
|
10014
|
+
await writeWorktreePrs(gitDir, entries);
|
|
8450
10015
|
}
|
|
8451
10016
|
if (seen.size > 0) {
|
|
8452
10017
|
log.debug(`[pr] synced ${seen.size} worktree(s) with PR data from ${allRepoResults.length} repo(s)`);
|
|
8453
10018
|
}
|
|
8454
|
-
const uniqueDirs = new Set(
|
|
10019
|
+
const uniqueDirs = new Set(worktreeGitDirs.values());
|
|
8455
10020
|
const staleRefreshes = [];
|
|
8456
|
-
for (const
|
|
8457
|
-
if (seen.has(
|
|
10021
|
+
for (const gitDir of uniqueDirs) {
|
|
10022
|
+
if (seen.has(gitDir))
|
|
8458
10023
|
continue;
|
|
8459
|
-
staleRefreshes.push(refreshStalePrData(
|
|
10024
|
+
staleRefreshes.push(refreshStalePrData(gitDir));
|
|
8460
10025
|
}
|
|
8461
10026
|
await Promise.all(staleRefreshes);
|
|
8462
10027
|
const currentPrUrls = new Set;
|
|
@@ -8481,13 +10046,13 @@ async function syncPrStatus(getWorktreePaths, linkedRepos, projectDir) {
|
|
|
8481
10046
|
etagCache.delete(key);
|
|
8482
10047
|
}
|
|
8483
10048
|
}
|
|
8484
|
-
function startPrMonitor(
|
|
10049
|
+
function startPrMonitor(getWorktreeGitDirs, linkedRepos, projectDir, intervalMs = 20000, isActive) {
|
|
8485
10050
|
const run = () => {
|
|
8486
10051
|
if (isActive && !isActive()) {
|
|
8487
10052
|
log.debug("[pr] skipping PR sync: no active clients");
|
|
8488
10053
|
return;
|
|
8489
10054
|
}
|
|
8490
|
-
syncPrStatus(
|
|
10055
|
+
syncPrStatus(getWorktreeGitDirs, linkedRepos, projectDir).catch((err) => {
|
|
8491
10056
|
log.error(`[pr] sync error: ${err}`);
|
|
8492
10057
|
});
|
|
8493
10058
|
};
|
|
@@ -8498,381 +10063,373 @@ function startPrMonitor(getWorktreePaths, linkedRepos, projectDir, intervalMs =
|
|
|
8498
10063
|
};
|
|
8499
10064
|
}
|
|
8500
10065
|
|
|
8501
|
-
// backend/src/
|
|
8502
|
-
function
|
|
8503
|
-
return new
|
|
8504
|
-
status,
|
|
8505
|
-
headers: { "Content-Type": "application/json" }
|
|
8506
|
-
});
|
|
8507
|
-
}
|
|
8508
|
-
function errorResponse(message, status = 500) {
|
|
8509
|
-
return jsonResponse({ error: message }, status);
|
|
8510
|
-
}
|
|
8511
|
-
|
|
8512
|
-
// backend/src/notifications.ts
|
|
8513
|
-
import { mkdir as mkdir3, chmod as chmod2 } from "fs/promises";
|
|
8514
|
-
var nextId = 1;
|
|
8515
|
-
var notifications = [];
|
|
8516
|
-
var sseClients = new Set;
|
|
8517
|
-
function formatSse(event, data) {
|
|
8518
|
-
return new TextEncoder().encode(`event: ${event}
|
|
8519
|
-
data: ${JSON.stringify(data)}
|
|
8520
|
-
|
|
8521
|
-
`);
|
|
8522
|
-
}
|
|
8523
|
-
function broadcast(event) {
|
|
8524
|
-
const encoded = event.kind === "notification" ? formatSse("notification", event.data) : formatSse("dismiss", { id: event.id });
|
|
8525
|
-
for (const controller of sseClients) {
|
|
8526
|
-
try {
|
|
8527
|
-
controller.enqueue(encoded);
|
|
8528
|
-
} catch {
|
|
8529
|
-
sseClients.delete(controller);
|
|
8530
|
-
}
|
|
8531
|
-
}
|
|
8532
|
-
}
|
|
8533
|
-
var ACTIVITY_TIMEOUT_MS = 15000;
|
|
8534
|
-
var lastActivityAt = Date.now();
|
|
8535
|
-
function touchActivity() {
|
|
8536
|
-
lastActivityAt = Date.now();
|
|
8537
|
-
}
|
|
8538
|
-
function hasDashboardActivity() {
|
|
8539
|
-
return Date.now() - lastActivityAt < ACTIVITY_TIMEOUT_MS;
|
|
10066
|
+
// backend/src/services/project-runtime.ts
|
|
10067
|
+
function isoNow(now) {
|
|
10068
|
+
return (now ?? (() => new Date))().toISOString();
|
|
8540
10069
|
}
|
|
8541
|
-
function
|
|
8542
|
-
|
|
8543
|
-
|
|
8544
|
-
|
|
8545
|
-
|
|
8546
|
-
|
|
8547
|
-
|
|
8548
|
-
|
|
8549
|
-
|
|
8550
|
-
|
|
8551
|
-
|
|
8552
|
-
|
|
8553
|
-
|
|
8554
|
-
log.info(`[notify] ${type} branch=${branch}${url ? ` url=${url}` : ""}`);
|
|
8555
|
-
broadcast({ kind: "notification", data: notification });
|
|
8556
|
-
return notification;
|
|
8557
|
-
}
|
|
8558
|
-
function dismissNotification(id) {
|
|
8559
|
-
const idx = notifications.findIndex((n) => n.id === id);
|
|
8560
|
-
if (idx === -1)
|
|
8561
|
-
return false;
|
|
8562
|
-
notifications.splice(idx, 1);
|
|
8563
|
-
broadcast({ kind: "dismiss", id });
|
|
8564
|
-
return true;
|
|
8565
|
-
}
|
|
8566
|
-
function handleNotificationStream() {
|
|
8567
|
-
let ctrl;
|
|
8568
|
-
const stream = new ReadableStream({
|
|
8569
|
-
start(controller) {
|
|
8570
|
-
ctrl = controller;
|
|
8571
|
-
sseClients.add(controller);
|
|
8572
|
-
for (const n of notifications) {
|
|
8573
|
-
controller.enqueue(formatSse("initial", n));
|
|
8574
|
-
}
|
|
10070
|
+
function makeDefaultState(input) {
|
|
10071
|
+
return {
|
|
10072
|
+
worktreeId: input.worktreeId,
|
|
10073
|
+
branch: input.branch,
|
|
10074
|
+
path: input.path,
|
|
10075
|
+
profile: input.profile ?? null,
|
|
10076
|
+
agentName: input.agentName ?? null,
|
|
10077
|
+
git: {
|
|
10078
|
+
exists: true,
|
|
10079
|
+
branch: input.branch,
|
|
10080
|
+
dirty: false,
|
|
10081
|
+
aheadCount: 0,
|
|
10082
|
+
currentCommit: null
|
|
8575
10083
|
},
|
|
8576
|
-
|
|
8577
|
-
|
|
8578
|
-
|
|
8579
|
-
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
|
|
8586
|
-
|
|
10084
|
+
session: {
|
|
10085
|
+
exists: false,
|
|
10086
|
+
sessionName: null,
|
|
10087
|
+
windowName: buildWorktreeWindowName(input.branch),
|
|
10088
|
+
paneCount: 0
|
|
10089
|
+
},
|
|
10090
|
+
agent: {
|
|
10091
|
+
runtime: input.runtime ?? "host",
|
|
10092
|
+
lifecycle: "closed",
|
|
10093
|
+
lastStartedAt: null,
|
|
10094
|
+
lastEventAt: null,
|
|
10095
|
+
lastError: null
|
|
10096
|
+
},
|
|
10097
|
+
services: [],
|
|
10098
|
+
prs: []
|
|
10099
|
+
};
|
|
8587
10100
|
}
|
|
8588
|
-
function
|
|
8589
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
});
|
|
8595
|
-
}
|
|
8596
|
-
return new Response(JSON.stringify({ ok: true }), {
|
|
8597
|
-
headers: { "Content-Type": "application/json" }
|
|
8598
|
-
});
|
|
10101
|
+
function clonePrEntry(pr) {
|
|
10102
|
+
return {
|
|
10103
|
+
...pr,
|
|
10104
|
+
ciChecks: pr.ciChecks.map((check) => ({ ...check })),
|
|
10105
|
+
comments: pr.comments.map((comment) => ({ ...comment }))
|
|
10106
|
+
};
|
|
8599
10107
|
}
|
|
8600
|
-
var HOOKS_DIR = `${Bun.env.HOME ?? "/root"}/.config/workmux/hooks`;
|
|
8601
|
-
var NOTIFY_STOP_SH = `#!/usr/bin/env bash
|
|
8602
|
-
# Claude Code Stop hook \u2014 notifies workmux backend that an agent stopped.
|
|
8603
|
-
set -euo pipefail
|
|
8604
|
-
|
|
8605
|
-
# Read hook input from stdin
|
|
8606
|
-
INPUT=$(cat)
|
|
8607
10108
|
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
|
|
8628
|
-
|
|
8629
|
-
|
|
8630
|
-
|
|
8631
|
-
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8638
|
-
|
|
8639
|
-
|
|
8640
|
-
|
|
8641
|
-
|
|
8642
|
-
|
|
8643
|
-
|
|
8644
|
-
|
|
8645
|
-
|
|
8646
|
-
|
|
8647
|
-
|
|
8648
|
-
|
|
8649
|
-
|
|
8650
|
-
|
|
8651
|
-
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8660
|
-
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
|
|
8664
|
-
|
|
8665
|
-
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
|
|
8683
|
-
|
|
8684
|
-
|
|
8685
|
-
|
|
8686
|
-
|
|
8687
|
-
|
|
8688
|
-
|
|
8689
|
-
|
|
8690
|
-
|
|
8691
|
-
|
|
8692
|
-
|
|
8693
|
-
const [stdout, , exitCode] = await Promise.all([
|
|
8694
|
-
new Response(proc.stdout).text(),
|
|
8695
|
-
new Response(proc.stderr).text(),
|
|
8696
|
-
proc.exited
|
|
8697
|
-
]);
|
|
8698
|
-
if (exitCode !== 0)
|
|
8699
|
-
return null;
|
|
8700
|
-
const target = `wm-${branch}`;
|
|
8701
|
-
for (const line of stdout.trim().split(`
|
|
8702
|
-
`)) {
|
|
8703
|
-
const [windowName, paneId] = line.split("\t");
|
|
8704
|
-
if (windowName === target && paneId)
|
|
8705
|
-
return paneId;
|
|
10109
|
+
class ProjectRuntime {
|
|
10110
|
+
worktrees = new Map;
|
|
10111
|
+
worktreeIdsByBranch = new Map;
|
|
10112
|
+
upsertWorktree(input) {
|
|
10113
|
+
const existing = this.worktrees.get(input.worktreeId);
|
|
10114
|
+
if (existing) {
|
|
10115
|
+
this.reindexBranch(existing.branch, input.branch, input.worktreeId);
|
|
10116
|
+
existing.path = input.path;
|
|
10117
|
+
existing.branch = input.branch;
|
|
10118
|
+
existing.profile = input.profile ?? existing.profile;
|
|
10119
|
+
existing.agentName = input.agentName ?? existing.agentName;
|
|
10120
|
+
if (input.runtime)
|
|
10121
|
+
existing.agent.runtime = input.runtime;
|
|
10122
|
+
existing.git.exists = true;
|
|
10123
|
+
existing.git.branch = input.branch;
|
|
10124
|
+
existing.session.windowName = buildWorktreeWindowName(input.branch);
|
|
10125
|
+
return existing;
|
|
10126
|
+
}
|
|
10127
|
+
const created = makeDefaultState(input);
|
|
10128
|
+
this.worktrees.set(input.worktreeId, created);
|
|
10129
|
+
this.worktreeIdsByBranch.set(input.branch, input.worktreeId);
|
|
10130
|
+
return created;
|
|
10131
|
+
}
|
|
10132
|
+
removeWorktree(worktreeId) {
|
|
10133
|
+
const state = this.worktrees.get(worktreeId);
|
|
10134
|
+
if (!state)
|
|
10135
|
+
return false;
|
|
10136
|
+
this.worktreeIdsByBranch.delete(state.branch);
|
|
10137
|
+
return this.worktrees.delete(worktreeId);
|
|
10138
|
+
}
|
|
10139
|
+
getWorktree(worktreeId) {
|
|
10140
|
+
return this.worktrees.get(worktreeId) ?? null;
|
|
10141
|
+
}
|
|
10142
|
+
getWorktreeByBranch(branch) {
|
|
10143
|
+
const worktreeId = this.worktreeIdsByBranch.get(branch);
|
|
10144
|
+
return worktreeId ? this.worktrees.get(worktreeId) ?? null : null;
|
|
10145
|
+
}
|
|
10146
|
+
listWorktrees() {
|
|
10147
|
+
return [...this.worktrees.values()].sort((a, b) => a.branch.localeCompare(b.branch));
|
|
10148
|
+
}
|
|
10149
|
+
setGitState(worktreeId, patch) {
|
|
10150
|
+
const state = this.requireWorktree(worktreeId);
|
|
10151
|
+
if (patch.branch && patch.branch !== state.branch) {
|
|
10152
|
+
this.applyBranchChange(state, patch.branch);
|
|
10153
|
+
}
|
|
10154
|
+
state.git = { ...state.git, ...patch, branch: state.branch };
|
|
10155
|
+
return state;
|
|
10156
|
+
}
|
|
10157
|
+
setSessionState(worktreeId, patch) {
|
|
10158
|
+
const state = this.requireWorktree(worktreeId);
|
|
10159
|
+
state.session = { ...state.session, ...patch, windowName: buildWorktreeWindowName(state.branch) };
|
|
10160
|
+
return state;
|
|
10161
|
+
}
|
|
10162
|
+
setServices(worktreeId, services) {
|
|
10163
|
+
const state = this.requireWorktree(worktreeId);
|
|
10164
|
+
state.services = services.map((service) => ({ ...service }));
|
|
10165
|
+
return state;
|
|
10166
|
+
}
|
|
10167
|
+
setPrs(worktreeId, prs) {
|
|
10168
|
+
const state = this.requireWorktree(worktreeId);
|
|
10169
|
+
state.prs = prs.map((pr) => clonePrEntry(pr));
|
|
10170
|
+
return state;
|
|
10171
|
+
}
|
|
10172
|
+
applyEvent(event, now) {
|
|
10173
|
+
const state = this.requireWorktree(event.worktreeId);
|
|
10174
|
+
if (event.branch !== state.branch) {
|
|
10175
|
+
this.applyBranchChange(state, event.branch);
|
|
10176
|
+
}
|
|
10177
|
+
const timestamp = isoNow(now);
|
|
10178
|
+
switch (event.type) {
|
|
10179
|
+
case "agent_stopped":
|
|
10180
|
+
state.agent.lifecycle = "stopped";
|
|
10181
|
+
state.agent.lastEventAt = timestamp;
|
|
10182
|
+
break;
|
|
10183
|
+
case "agent_status_changed":
|
|
10184
|
+
this.applyStatusChanged(state, event, timestamp);
|
|
10185
|
+
break;
|
|
10186
|
+
case "runtime_error":
|
|
10187
|
+
this.applyRuntimeError(state, event, timestamp);
|
|
10188
|
+
break;
|
|
10189
|
+
case "pr_opened":
|
|
10190
|
+
state.agent.lastEventAt = timestamp;
|
|
10191
|
+
break;
|
|
10192
|
+
}
|
|
10193
|
+
return state;
|
|
8706
10194
|
}
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
|
|
8714
|
-
return new Response("Unauthorized", { status: 401 });
|
|
10195
|
+
applyStatusChanged(state, event, timestamp) {
|
|
10196
|
+
state.agent.lifecycle = event.lifecycle;
|
|
10197
|
+
state.agent.lastEventAt = timestamp;
|
|
10198
|
+
if (state.agent.lastStartedAt === null && event.lifecycle === "running") {
|
|
10199
|
+
state.agent.lastStartedAt = timestamp;
|
|
10200
|
+
}
|
|
10201
|
+
state.agent.lastError = null;
|
|
8715
10202
|
}
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
return jsonResponse({ ok: false, error: "Invalid JSON" }, 400);
|
|
10203
|
+
applyRuntimeError(state, event, timestamp) {
|
|
10204
|
+
state.agent.lifecycle = "error";
|
|
10205
|
+
state.agent.lastError = event.message;
|
|
10206
|
+
state.agent.lastEventAt = timestamp;
|
|
8721
10207
|
}
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
10208
|
+
applyBranchChange(state, branch) {
|
|
10209
|
+
this.reindexBranch(state.branch, branch, state.worktreeId);
|
|
10210
|
+
state.branch = branch;
|
|
10211
|
+
state.git.branch = branch;
|
|
10212
|
+
state.session.windowName = buildWorktreeWindowName(branch);
|
|
8725
10213
|
}
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
addNotification(branch, type, url);
|
|
8730
|
-
return jsonResponse({ ok: true, output: "ok" });
|
|
10214
|
+
reindexBranch(previousBranch, nextBranch, worktreeId) {
|
|
10215
|
+
if (previousBranch !== nextBranch) {
|
|
10216
|
+
this.worktreeIdsByBranch.delete(previousBranch);
|
|
8731
10217
|
}
|
|
8732
|
-
|
|
10218
|
+
this.worktreeIdsByBranch.set(nextBranch, worktreeId);
|
|
8733
10219
|
}
|
|
8734
|
-
|
|
8735
|
-
const
|
|
8736
|
-
if (
|
|
8737
|
-
|
|
8738
|
-
if (paneId) {
|
|
8739
|
-
env.TMUX_PANE = paneId;
|
|
8740
|
-
}
|
|
8741
|
-
}
|
|
8742
|
-
const proc = Bun.spawn(["workmux", command, ...args], {
|
|
8743
|
-
stdout: "pipe",
|
|
8744
|
-
stderr: "pipe",
|
|
8745
|
-
env
|
|
8746
|
-
});
|
|
8747
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
8748
|
-
new Response(proc.stdout).text(),
|
|
8749
|
-
new Response(proc.stderr).text(),
|
|
8750
|
-
proc.exited
|
|
8751
|
-
]);
|
|
8752
|
-
if (exitCode !== 0) {
|
|
8753
|
-
return jsonResponse({ ok: false, error: stderr.trim() || `exit code ${exitCode}` }, 422);
|
|
10220
|
+
requireWorktree(worktreeId) {
|
|
10221
|
+
const state = this.worktrees.get(worktreeId);
|
|
10222
|
+
if (!state) {
|
|
10223
|
+
throw new Error(`Unknown worktree id: ${worktreeId}`);
|
|
8754
10224
|
}
|
|
8755
|
-
return
|
|
8756
|
-
} catch (err) {
|
|
8757
|
-
const error = err instanceof Error ? err.message : String(err);
|
|
8758
|
-
return jsonResponse({ ok: false, error }, 500);
|
|
10225
|
+
return state;
|
|
8759
10226
|
}
|
|
8760
10227
|
}
|
|
8761
10228
|
|
|
8762
|
-
// backend/src/
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
8787
|
-
|
|
8788
|
-
|
|
8789
|
-
|
|
8790
|
-
|
|
8791
|
-
function parseIssuesResponse(raw) {
|
|
8792
|
-
if (raw.errors && raw.errors.length > 0) {
|
|
8793
|
-
return { ok: false, error: raw.errors.map((e) => e.message).join("; ") };
|
|
8794
|
-
}
|
|
8795
|
-
if (!raw.data) {
|
|
8796
|
-
return { ok: false, error: "No data in response" };
|
|
8797
|
-
}
|
|
8798
|
-
const nodes = raw.data.viewer.assignedIssues.nodes;
|
|
8799
|
-
const issues = nodes.map((n) => ({
|
|
8800
|
-
id: n.id,
|
|
8801
|
-
identifier: n.identifier,
|
|
8802
|
-
title: n.title,
|
|
8803
|
-
description: n.description,
|
|
8804
|
-
priority: n.priority,
|
|
8805
|
-
priorityLabel: n.priorityLabel,
|
|
8806
|
-
url: n.url,
|
|
8807
|
-
branchName: n.branchName,
|
|
8808
|
-
dueDate: n.dueDate,
|
|
8809
|
-
updatedAt: n.updatedAt,
|
|
8810
|
-
state: n.state,
|
|
8811
|
-
team: n.team,
|
|
8812
|
-
labels: n.labels.nodes,
|
|
8813
|
-
project: n.project?.name ?? null
|
|
10229
|
+
// backend/src/services/reconciliation-service.ts
|
|
10230
|
+
import { basename as basename2, resolve as resolve4 } from "path";
|
|
10231
|
+
function makeUnmanagedWorktreeId(path) {
|
|
10232
|
+
return `unmanaged:${resolve4(path)}`;
|
|
10233
|
+
}
|
|
10234
|
+
function isValidPort2(port) {
|
|
10235
|
+
return port !== null && Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
10236
|
+
}
|
|
10237
|
+
async function buildServiceStates(deps, input) {
|
|
10238
|
+
const runtimeEnv = buildRuntimeEnvMap({
|
|
10239
|
+
schemaVersion: 1,
|
|
10240
|
+
worktreeId: input.worktreeId,
|
|
10241
|
+
branch: input.branch,
|
|
10242
|
+
createdAt: "",
|
|
10243
|
+
profile: input.profile,
|
|
10244
|
+
agent: input.agent,
|
|
10245
|
+
runtime: input.runtime,
|
|
10246
|
+
startupEnvValues: input.startupEnvValues,
|
|
10247
|
+
allocatedPorts: input.allocatedPorts
|
|
10248
|
+
});
|
|
10249
|
+
return Promise.all(deps.config.services.map(async (service) => {
|
|
10250
|
+
const port = input.allocatedPorts[service.portEnv] ?? null;
|
|
10251
|
+
const running = isValidPort2(port) ? await deps.portProbe.isListening(port) : false;
|
|
10252
|
+
return {
|
|
10253
|
+
name: service.name,
|
|
10254
|
+
port,
|
|
10255
|
+
running,
|
|
10256
|
+
url: port !== null && service.urlTemplate ? expandTemplate(service.urlTemplate, runtimeEnv) : null
|
|
10257
|
+
};
|
|
8814
10258
|
}));
|
|
8815
|
-
return { ok: true, data: issues };
|
|
8816
10259
|
}
|
|
8817
|
-
function
|
|
8818
|
-
|
|
8819
|
-
|
|
8820
|
-
if (worktreeBranch === issueBranchName)
|
|
8821
|
-
return true;
|
|
8822
|
-
const issueSlashIdx = issueBranchName.indexOf("/");
|
|
8823
|
-
if (issueSlashIdx !== -1) {
|
|
8824
|
-
const suffix = issueBranchName.slice(issueSlashIdx + 1);
|
|
8825
|
-
if (worktreeBranch === suffix)
|
|
8826
|
-
return true;
|
|
8827
|
-
}
|
|
8828
|
-
const wtSlashIdx = worktreeBranch.indexOf("/");
|
|
8829
|
-
if (wtSlashIdx !== -1) {
|
|
8830
|
-
const wtSuffix = worktreeBranch.slice(wtSlashIdx + 1);
|
|
8831
|
-
if (wtSuffix === issueBranchName)
|
|
8832
|
-
return true;
|
|
8833
|
-
if (issueSlashIdx !== -1 && wtSuffix === issueBranchName.slice(issueSlashIdx + 1))
|
|
8834
|
-
return true;
|
|
8835
|
-
}
|
|
8836
|
-
return false;
|
|
10260
|
+
function findWindow(windows, sessionName, branch) {
|
|
10261
|
+
const windowName = buildWorktreeWindowName(branch);
|
|
10262
|
+
return windows.find((window) => window.sessionName === sessionName && window.windowName === windowName) ?? null;
|
|
8837
10263
|
}
|
|
8838
|
-
|
|
8839
|
-
|
|
8840
|
-
|
|
8841
|
-
|
|
8842
|
-
|
|
8843
|
-
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
8847
|
-
return issueCache.data;
|
|
10264
|
+
function resolveBranch(entry, metaBranch) {
|
|
10265
|
+
const fallback = basename2(entry.path);
|
|
10266
|
+
return entry.branch ?? metaBranch ?? (fallback.length > 0 ? fallback : "unknown");
|
|
10267
|
+
}
|
|
10268
|
+
|
|
10269
|
+
class ReconciliationService {
|
|
10270
|
+
deps;
|
|
10271
|
+
constructor(deps) {
|
|
10272
|
+
this.deps = deps;
|
|
8848
10273
|
}
|
|
8849
|
-
|
|
8850
|
-
const
|
|
8851
|
-
|
|
8852
|
-
|
|
8853
|
-
|
|
8854
|
-
|
|
8855
|
-
|
|
8856
|
-
|
|
8857
|
-
|
|
8858
|
-
if (!res.ok) {
|
|
8859
|
-
const text = await res.text();
|
|
8860
|
-
const result2 = { ok: false, error: `Linear API ${res.status}: ${text.slice(0, 200)}` };
|
|
8861
|
-
return result2;
|
|
10274
|
+
async reconcile(repoRoot) {
|
|
10275
|
+
const normalizedRepoRoot = resolve4(repoRoot);
|
|
10276
|
+
const worktrees = this.deps.git.listWorktrees(normalizedRepoRoot);
|
|
10277
|
+
const sessionName = buildProjectSessionName(normalizedRepoRoot);
|
|
10278
|
+
let windows = [];
|
|
10279
|
+
try {
|
|
10280
|
+
windows = this.deps.tmux.listWindows();
|
|
10281
|
+
} catch {
|
|
10282
|
+
windows = [];
|
|
8862
10283
|
}
|
|
8863
|
-
const
|
|
8864
|
-
const
|
|
8865
|
-
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
10284
|
+
const seenWorktreeIds = new Set;
|
|
10285
|
+
for (const entry of worktrees) {
|
|
10286
|
+
if (entry.bare)
|
|
10287
|
+
continue;
|
|
10288
|
+
if (resolve4(entry.path) === normalizedRepoRoot)
|
|
10289
|
+
continue;
|
|
10290
|
+
const gitDir = this.deps.git.resolveWorktreeGitDir(entry.path);
|
|
10291
|
+
const meta = await readWorktreeMeta(gitDir);
|
|
10292
|
+
const branch = resolveBranch(entry, meta?.branch ?? null);
|
|
10293
|
+
const worktreeId = meta?.worktreeId ?? makeUnmanagedWorktreeId(entry.path);
|
|
10294
|
+
seenWorktreeIds.add(worktreeId);
|
|
10295
|
+
this.deps.runtime.upsertWorktree({
|
|
10296
|
+
worktreeId,
|
|
10297
|
+
branch,
|
|
10298
|
+
path: entry.path,
|
|
10299
|
+
profile: meta?.profile ?? null,
|
|
10300
|
+
agentName: meta?.agent ?? null,
|
|
10301
|
+
runtime: meta?.runtime ?? "host"
|
|
10302
|
+
});
|
|
10303
|
+
const gitStatus = this.deps.git.readWorktreeStatus(entry.path);
|
|
10304
|
+
this.deps.runtime.setGitState(worktreeId, {
|
|
10305
|
+
exists: true,
|
|
10306
|
+
branch,
|
|
10307
|
+
dirty: gitStatus.dirty,
|
|
10308
|
+
aheadCount: gitStatus.aheadCount,
|
|
10309
|
+
currentCommit: gitStatus.currentCommit
|
|
10310
|
+
});
|
|
10311
|
+
const window = findWindow(windows, sessionName, branch);
|
|
10312
|
+
this.deps.runtime.setSessionState(worktreeId, {
|
|
10313
|
+
exists: window !== null,
|
|
10314
|
+
sessionName: window?.sessionName ?? null,
|
|
10315
|
+
paneCount: window?.paneCount ?? 0
|
|
10316
|
+
});
|
|
10317
|
+
if (meta) {
|
|
10318
|
+
this.deps.runtime.setServices(worktreeId, await buildServiceStates(this.deps, {
|
|
10319
|
+
allocatedPorts: meta.allocatedPorts,
|
|
10320
|
+
startupEnvValues: meta.startupEnvValues,
|
|
10321
|
+
worktreeId: meta.worktreeId,
|
|
10322
|
+
branch,
|
|
10323
|
+
profile: meta.profile,
|
|
10324
|
+
agent: meta.agent,
|
|
10325
|
+
runtime: meta.runtime
|
|
10326
|
+
}));
|
|
10327
|
+
} else {
|
|
10328
|
+
this.deps.runtime.setServices(worktreeId, []);
|
|
10329
|
+
}
|
|
10330
|
+
this.deps.runtime.setPrs(worktreeId, await readWorktreePrs(gitDir));
|
|
8870
10331
|
}
|
|
8871
|
-
|
|
8872
|
-
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
10332
|
+
for (const state of this.deps.runtime.listWorktrees()) {
|
|
10333
|
+
if (!seenWorktreeIds.has(state.worktreeId)) {
|
|
10334
|
+
this.deps.runtime.removeWorktree(state.worktreeId);
|
|
10335
|
+
}
|
|
10336
|
+
}
|
|
10337
|
+
}
|
|
10338
|
+
}
|
|
10339
|
+
|
|
10340
|
+
// backend/src/services/snapshot-service.ts
|
|
10341
|
+
function formatElapsedSince(startedAt, now) {
|
|
10342
|
+
if (!startedAt)
|
|
10343
|
+
return "";
|
|
10344
|
+
const startedMs = Date.parse(startedAt);
|
|
10345
|
+
if (Number.isNaN(startedMs))
|
|
10346
|
+
return "";
|
|
10347
|
+
const diffMs = Math.max(0, now().getTime() - startedMs);
|
|
10348
|
+
const diffMinutes = Math.floor(diffMs / 60000);
|
|
10349
|
+
if (diffMinutes < 1)
|
|
10350
|
+
return "0m";
|
|
10351
|
+
if (diffMinutes < 60)
|
|
10352
|
+
return `${diffMinutes}m`;
|
|
10353
|
+
const diffHours = Math.floor(diffMinutes / 60);
|
|
10354
|
+
if (diffHours < 24)
|
|
10355
|
+
return `${diffHours}h`;
|
|
10356
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
10357
|
+
return `${diffDays}d`;
|
|
10358
|
+
}
|
|
10359
|
+
function clonePrEntry2(pr) {
|
|
10360
|
+
return {
|
|
10361
|
+
...pr,
|
|
10362
|
+
ciChecks: pr.ciChecks.map((check) => ({ ...check })),
|
|
10363
|
+
comments: pr.comments.map((comment) => ({ ...comment }))
|
|
10364
|
+
};
|
|
10365
|
+
}
|
|
10366
|
+
function mapWorktreeSnapshot(state, now, findLinearIssue) {
|
|
10367
|
+
return {
|
|
10368
|
+
branch: state.branch,
|
|
10369
|
+
path: state.path,
|
|
10370
|
+
dir: state.path,
|
|
10371
|
+
profile: state.profile,
|
|
10372
|
+
agentName: state.agentName,
|
|
10373
|
+
mux: state.session.exists,
|
|
10374
|
+
dirty: state.git.dirty || state.git.aheadCount > 0,
|
|
10375
|
+
paneCount: state.session.paneCount,
|
|
10376
|
+
status: state.agent.lifecycle,
|
|
10377
|
+
elapsed: formatElapsedSince(state.agent.lastStartedAt, now),
|
|
10378
|
+
services: state.services.map((service) => ({ ...service })),
|
|
10379
|
+
prs: state.prs.map((pr) => clonePrEntry2(pr)),
|
|
10380
|
+
linearIssue: findLinearIssue ? findLinearIssue(state.branch) : null
|
|
10381
|
+
};
|
|
10382
|
+
}
|
|
10383
|
+
function buildProjectSnapshot(input) {
|
|
10384
|
+
const now = input.now ?? (() => new Date);
|
|
10385
|
+
return {
|
|
10386
|
+
project: {
|
|
10387
|
+
name: input.projectName,
|
|
10388
|
+
mainBranch: input.mainBranch
|
|
10389
|
+
},
|
|
10390
|
+
worktrees: input.runtime.listWorktrees().map((state) => mapWorktreeSnapshot(state, now, input.findLinearIssue)),
|
|
10391
|
+
notifications: input.notifications.map((notification) => ({ ...notification }))
|
|
10392
|
+
};
|
|
10393
|
+
}
|
|
10394
|
+
|
|
10395
|
+
// backend/src/domain/events.ts
|
|
10396
|
+
function hasBaseFields(raw) {
|
|
10397
|
+
return typeof raw.worktreeId === "string" && raw.worktreeId.length > 0 && typeof raw.branch === "string" && raw.branch.length > 0 && typeof raw.type === "string" && ["agent_stopped", "agent_status_changed", "pr_opened", "runtime_error"].includes(raw.type);
|
|
10398
|
+
}
|
|
10399
|
+
function parseRuntimeEvent(raw) {
|
|
10400
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
10401
|
+
return null;
|
|
10402
|
+
if (!hasBaseFields(raw))
|
|
10403
|
+
return null;
|
|
10404
|
+
const event = raw;
|
|
10405
|
+
switch (event.type) {
|
|
10406
|
+
case "agent_stopped":
|
|
10407
|
+
return {
|
|
10408
|
+
worktreeId: event.worktreeId,
|
|
10409
|
+
branch: event.branch,
|
|
10410
|
+
type: event.type
|
|
10411
|
+
};
|
|
10412
|
+
case "agent_status_changed":
|
|
10413
|
+
return event.lifecycle === "starting" || event.lifecycle === "running" || event.lifecycle === "idle" || event.lifecycle === "stopped" ? {
|
|
10414
|
+
worktreeId: event.worktreeId,
|
|
10415
|
+
branch: event.branch,
|
|
10416
|
+
type: event.type,
|
|
10417
|
+
lifecycle: event.lifecycle
|
|
10418
|
+
} : null;
|
|
10419
|
+
case "pr_opened":
|
|
10420
|
+
return typeof event.url === "string" || event.url === undefined ? {
|
|
10421
|
+
worktreeId: event.worktreeId,
|
|
10422
|
+
branch: event.branch,
|
|
10423
|
+
type: event.type,
|
|
10424
|
+
...typeof event.url === "string" ? { url: event.url } : {}
|
|
10425
|
+
} : null;
|
|
10426
|
+
case "runtime_error":
|
|
10427
|
+
return typeof event.message === "string" && event.message.length > 0 ? {
|
|
10428
|
+
worktreeId: event.worktreeId,
|
|
10429
|
+
branch: event.branch,
|
|
10430
|
+
type: event.type,
|
|
10431
|
+
message: event.message
|
|
10432
|
+
} : null;
|
|
8876
10433
|
}
|
|
8877
10434
|
}
|
|
8878
10435
|
|
|
@@ -8881,8 +10438,55 @@ var PORT = parseInt(Bun.env.BACKEND_PORT || "5111", 10);
|
|
|
8881
10438
|
var STATIC_DIR = Bun.env.WEBMUX_STATIC_DIR || "";
|
|
8882
10439
|
var PROJECT_DIR = Bun.env.WEBMUX_PROJECT_DIR || gitRoot(process.cwd());
|
|
8883
10440
|
var config = loadConfig(PROJECT_DIR);
|
|
8884
|
-
var
|
|
8885
|
-
var
|
|
10441
|
+
var git = new BunGitGateway;
|
|
10442
|
+
var portProbe = new BunPortProbe;
|
|
10443
|
+
var tmux = new BunTmuxGateway;
|
|
10444
|
+
var docker = new BunDockerGateway;
|
|
10445
|
+
var hooks = new BunLifecycleHookRunner;
|
|
10446
|
+
var autoName = new AutoNameService;
|
|
10447
|
+
var projectRuntime = new ProjectRuntime;
|
|
10448
|
+
var runtimeNotifications = new NotificationService;
|
|
10449
|
+
var reconciliationService = new ReconciliationService({
|
|
10450
|
+
config,
|
|
10451
|
+
git,
|
|
10452
|
+
tmux,
|
|
10453
|
+
portProbe,
|
|
10454
|
+
runtime: projectRuntime
|
|
10455
|
+
});
|
|
10456
|
+
var removingBranches = new Set;
|
|
10457
|
+
var lifecycleService = new LifecycleService({
|
|
10458
|
+
projectRoot: PROJECT_DIR,
|
|
10459
|
+
controlBaseUrl: `http://127.0.0.1:${PORT}`,
|
|
10460
|
+
getControlToken: loadControlToken,
|
|
10461
|
+
config,
|
|
10462
|
+
git,
|
|
10463
|
+
tmux,
|
|
10464
|
+
docker,
|
|
10465
|
+
reconciliation: reconciliationService,
|
|
10466
|
+
hooks,
|
|
10467
|
+
autoName
|
|
10468
|
+
});
|
|
10469
|
+
function getFrontendConfig() {
|
|
10470
|
+
const defaultProfileName = getDefaultProfileName(config);
|
|
10471
|
+
const orderedProfileEntries = Object.entries(config.profiles).sort(([left], [right]) => {
|
|
10472
|
+
if (left === defaultProfileName)
|
|
10473
|
+
return -1;
|
|
10474
|
+
if (right === defaultProfileName)
|
|
10475
|
+
return 1;
|
|
10476
|
+
return 0;
|
|
10477
|
+
});
|
|
10478
|
+
return {
|
|
10479
|
+
name: config.name,
|
|
10480
|
+
services: config.services,
|
|
10481
|
+
profiles: orderedProfileEntries.map(([name, profile]) => ({
|
|
10482
|
+
name,
|
|
10483
|
+
...profile.systemPrompt ? { systemPrompt: profile.systemPrompt } : {}
|
|
10484
|
+
})),
|
|
10485
|
+
defaultProfileName,
|
|
10486
|
+
autoName: config.autoName !== null,
|
|
10487
|
+
startupEnvs: config.startupEnvs
|
|
10488
|
+
};
|
|
10489
|
+
}
|
|
8886
10490
|
function parseWsMessage(raw) {
|
|
8887
10491
|
try {
|
|
8888
10492
|
const str = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
|
@@ -8928,82 +10532,67 @@ function isValidWorktreeName(name) {
|
|
|
8928
10532
|
}
|
|
8929
10533
|
function catching(label, fn) {
|
|
8930
10534
|
return fn().catch((err) => {
|
|
10535
|
+
if (err instanceof LifecycleError) {
|
|
10536
|
+
return errorResponse(err.message, err.status);
|
|
10537
|
+
}
|
|
8931
10538
|
const msg = err instanceof Error ? err.message : String(err);
|
|
8932
10539
|
log.error(`[api:error] ${label}: ${msg}`);
|
|
8933
10540
|
return errorResponse(msg);
|
|
8934
10541
|
});
|
|
8935
10542
|
}
|
|
8936
|
-
function
|
|
10543
|
+
function ensureBranchNotRemoving(branch) {
|
|
10544
|
+
if (removingBranches.has(branch)) {
|
|
10545
|
+
throw new LifecycleError(`Worktree is being removed: ${branch}`, 409);
|
|
10546
|
+
}
|
|
10547
|
+
}
|
|
10548
|
+
async function withRemovingBranch(branch, fn) {
|
|
10549
|
+
ensureBranchNotRemoving(branch);
|
|
10550
|
+
removingBranches.add(branch);
|
|
8937
10551
|
try {
|
|
8938
|
-
return
|
|
8939
|
-
}
|
|
8940
|
-
|
|
10552
|
+
return await fn();
|
|
10553
|
+
} finally {
|
|
10554
|
+
removingBranches.delete(branch);
|
|
8941
10555
|
}
|
|
8942
10556
|
}
|
|
8943
|
-
async function
|
|
8944
|
-
|
|
8945
|
-
await
|
|
8946
|
-
const
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
10557
|
+
async function resolveTerminalWorktree(branch) {
|
|
10558
|
+
ensureBranchNotRemoving(branch);
|
|
10559
|
+
await reconciliationService.reconcile(PROJECT_DIR);
|
|
10560
|
+
const state = projectRuntime.getWorktreeByBranch(branch);
|
|
10561
|
+
if (!state) {
|
|
10562
|
+
throw new Error(`Worktree not found: ${branch}`);
|
|
10563
|
+
}
|
|
10564
|
+
if (!state.session.exists || !state.session.sessionName) {
|
|
10565
|
+
throw new Error(`No open tmux window found for worktree: ${branch}`);
|
|
10566
|
+
}
|
|
10567
|
+
return {
|
|
10568
|
+
worktreeId: state.worktreeId,
|
|
10569
|
+
attachTarget: {
|
|
10570
|
+
ownerSessionName: state.session.sessionName,
|
|
10571
|
+
windowName: state.session.windowName
|
|
8954
10572
|
}
|
|
8955
|
-
|
|
8956
|
-
|
|
8957
|
-
|
|
8958
|
-
|
|
10573
|
+
};
|
|
10574
|
+
}
|
|
10575
|
+
function getAttachedWorktreeId(ws) {
|
|
10576
|
+
if (ws.data.attached && ws.data.worktreeId) {
|
|
10577
|
+
return ws.data.worktreeId;
|
|
8959
10578
|
}
|
|
8960
|
-
|
|
10579
|
+
sendWs(ws, { type: "error", message: "Terminal not attached" });
|
|
10580
|
+
return null;
|
|
8961
10581
|
}
|
|
8962
|
-
async function
|
|
8963
|
-
const
|
|
8964
|
-
|
|
8965
|
-
|
|
8966
|
-
|
|
8967
|
-
|
|
8968
|
-
|
|
8969
|
-
|
|
8970
|
-
|
|
8971
|
-
if (
|
|
8972
|
-
continue;
|
|
8973
|
-
const name = line.slice(0, spaceIdx);
|
|
8974
|
-
if (!name.startsWith("wm-"))
|
|
10582
|
+
async function hasValidControlToken(req) {
|
|
10583
|
+
const authHeader = req.headers.get("Authorization");
|
|
10584
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
10585
|
+
return token === await loadControlToken();
|
|
10586
|
+
}
|
|
10587
|
+
async function getWorktreeGitDirs() {
|
|
10588
|
+
const gitDirs = new Map;
|
|
10589
|
+
const projectRoot = resolve5(PROJECT_DIR);
|
|
10590
|
+
for (const entry of git.listWorktrees(projectRoot)) {
|
|
10591
|
+
if (entry.bare || resolve5(entry.path) === projectRoot || !entry.branch)
|
|
8975
10592
|
continue;
|
|
8976
|
-
|
|
8977
|
-
const count = parseInt(line.slice(spaceIdx + 1), 10) || 0;
|
|
8978
|
-
if (!counts.has(branch) || count > counts.get(branch)) {
|
|
8979
|
-
counts.set(branch, count);
|
|
8980
|
-
}
|
|
10593
|
+
gitDirs.set(entry.branch, git.resolveWorktreeGitDir(entry.path));
|
|
8981
10594
|
}
|
|
8982
|
-
return
|
|
8983
|
-
}
|
|
8984
|
-
function isPortListening(port) {
|
|
8985
|
-
return new Promise((resolve2) => {
|
|
8986
|
-
const timer = setTimeout(() => resolve2(false), 300);
|
|
8987
|
-
Bun.connect({
|
|
8988
|
-
hostname: "127.0.0.1",
|
|
8989
|
-
port,
|
|
8990
|
-
socket: {
|
|
8991
|
-
open(socket) {
|
|
8992
|
-
clearTimeout(timer);
|
|
8993
|
-
socket.end();
|
|
8994
|
-
resolve2(true);
|
|
8995
|
-
},
|
|
8996
|
-
error() {
|
|
8997
|
-
clearTimeout(timer);
|
|
8998
|
-
resolve2(false);
|
|
8999
|
-
},
|
|
9000
|
-
data() {}
|
|
9001
|
-
}
|
|
9002
|
-
}).catch(() => {
|
|
9003
|
-
clearTimeout(timer);
|
|
9004
|
-
resolve2(false);
|
|
9005
|
-
});
|
|
9006
|
-
});
|
|
10595
|
+
return gitDirs;
|
|
9007
10596
|
}
|
|
9008
10597
|
function makeCallbacks(ws) {
|
|
9009
10598
|
return {
|
|
@@ -9017,61 +10606,56 @@ function makeCallbacks(ws) {
|
|
|
9017
10606
|
}
|
|
9018
10607
|
};
|
|
9019
10608
|
}
|
|
9020
|
-
async function
|
|
9021
|
-
|
|
9022
|
-
const
|
|
9023
|
-
|
|
9024
|
-
|
|
9025
|
-
|
|
9026
|
-
}
|
|
9027
|
-
return new Response(wtCache.json, {
|
|
9028
|
-
headers: { "Content-Type": "application/json", ETag: wtCache.etag }
|
|
9029
|
-
});
|
|
9030
|
-
}
|
|
9031
|
-
const [worktrees, status, wtPaths, paneCounts, linearResult] = await Promise.all([
|
|
9032
|
-
listWorktrees(),
|
|
9033
|
-
getStatus(),
|
|
9034
|
-
getWorktreePaths(),
|
|
9035
|
-
getAllPaneCounts(),
|
|
9036
|
-
fetchAssignedIssues()
|
|
10609
|
+
async function apiGetProject() {
|
|
10610
|
+
touchDashboardActivity();
|
|
10611
|
+
const linearIssuesPromise = config.integrations.linear.enabled ? fetchAssignedIssues() : Promise.resolve({ ok: true, data: [] });
|
|
10612
|
+
const [, linearResult] = await Promise.all([
|
|
10613
|
+
reconciliationService.reconcile(PROJECT_DIR),
|
|
10614
|
+
linearIssuesPromise
|
|
9037
10615
|
]);
|
|
9038
10616
|
const linearIssues = linearResult.ok ? linearResult.data : [];
|
|
9039
|
-
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
9044
|
-
|
|
9045
|
-
|
|
9046
|
-
|
|
9047
|
-
|
|
9048
|
-
|
|
9049
|
-
|
|
9050
|
-
|
|
9051
|
-
|
|
9052
|
-
}));
|
|
9053
|
-
const matchedIssue = linearIssues.find((issue) => branchMatchesIssue(wt.branch, issue.branchName));
|
|
9054
|
-
const linearIssue = matchedIssue ? { identifier: matchedIssue.identifier, url: matchedIssue.url, state: matchedIssue.state } : null;
|
|
9055
|
-
return {
|
|
9056
|
-
...wt,
|
|
9057
|
-
dir: wtDir ?? null,
|
|
9058
|
-
dirty,
|
|
9059
|
-
status: st?.status ?? "",
|
|
9060
|
-
elapsed: st?.elapsed ?? "",
|
|
9061
|
-
title: st?.title ?? "",
|
|
9062
|
-
profile: env.PROFILE || null,
|
|
9063
|
-
agentName: env.AGENT || null,
|
|
9064
|
-
services,
|
|
9065
|
-
paneCount: wt.mux === "\u2713" ? paneCounts.get(wt.branch) ?? 0 : 0,
|
|
9066
|
-
prs: env.PR_DATA ? (safeJsonParse(env.PR_DATA) ?? []).map((pr) => ({ ...pr, comments: pr.comments ?? [] })) : [],
|
|
9067
|
-
linearIssue
|
|
9068
|
-
};
|
|
10617
|
+
return jsonResponse(buildProjectSnapshot({
|
|
10618
|
+
projectName: config.name,
|
|
10619
|
+
mainBranch: config.workspace.mainBranch,
|
|
10620
|
+
runtime: projectRuntime,
|
|
10621
|
+
notifications: runtimeNotifications.list(),
|
|
10622
|
+
findLinearIssue: (branch) => {
|
|
10623
|
+
const match = linearIssues.find((issue) => branchMatchesIssue(branch, issue.branchName));
|
|
10624
|
+
return match ? {
|
|
10625
|
+
identifier: match.identifier,
|
|
10626
|
+
url: match.url,
|
|
10627
|
+
state: match.state
|
|
10628
|
+
} : null;
|
|
10629
|
+
}
|
|
9069
10630
|
}));
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
10631
|
+
}
|
|
10632
|
+
async function apiRuntimeEvent(req) {
|
|
10633
|
+
if (!await hasValidControlToken(req)) {
|
|
10634
|
+
return new Response("Unauthorized", { status: 401 });
|
|
10635
|
+
}
|
|
10636
|
+
let raw;
|
|
10637
|
+
try {
|
|
10638
|
+
raw = await req.json();
|
|
10639
|
+
} catch {
|
|
10640
|
+
return errorResponse("Invalid JSON", 400);
|
|
10641
|
+
}
|
|
10642
|
+
const event = parseRuntimeEvent(raw);
|
|
10643
|
+
if (!event)
|
|
10644
|
+
return errorResponse("Invalid runtime event body", 400);
|
|
10645
|
+
await reconciliationService.reconcile(PROJECT_DIR);
|
|
10646
|
+
try {
|
|
10647
|
+
projectRuntime.applyEvent(event);
|
|
10648
|
+
} catch (error) {
|
|
10649
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
10650
|
+
if (message.includes("Unknown worktree id")) {
|
|
10651
|
+
return errorResponse(message, 404);
|
|
10652
|
+
}
|
|
10653
|
+
throw error;
|
|
10654
|
+
}
|
|
10655
|
+
const notification = runtimeNotifications.recordEvent(event);
|
|
10656
|
+
return jsonResponse({
|
|
10657
|
+
ok: true,
|
|
10658
|
+
...notification ? { notification } : {}
|
|
9075
10659
|
});
|
|
9076
10660
|
}
|
|
9077
10661
|
async function apiCreateWorktree(req) {
|
|
@@ -9080,12 +10664,6 @@ async function apiCreateWorktree(req) {
|
|
|
9080
10664
|
return errorResponse("Invalid request body", 400);
|
|
9081
10665
|
}
|
|
9082
10666
|
const body = raw;
|
|
9083
|
-
const branch = typeof body.branch === "string" ? body.branch : undefined;
|
|
9084
|
-
const prompt = typeof body.prompt === "string" ? body.prompt : undefined;
|
|
9085
|
-
const profileName = typeof body.profile === "string" ? body.profile : config.profiles.default.name;
|
|
9086
|
-
const agent = typeof body.agent === "string" ? body.agent : "claude";
|
|
9087
|
-
const isSandbox = config.profiles.sandbox !== undefined && profileName === config.profiles.sandbox.name;
|
|
9088
|
-
const profileConfig = isSandbox ? config.profiles.sandbox : config.profiles.default;
|
|
9089
10667
|
let envOverrides;
|
|
9090
10668
|
if (body.envOverrides && typeof body.envOverrides === "object" && !Array.isArray(body.envOverrides)) {
|
|
9091
10669
|
const raw2 = body.envOverrides;
|
|
@@ -9097,55 +10675,41 @@ async function apiCreateWorktree(req) {
|
|
|
9097
10675
|
if (Object.keys(parsed).length > 0)
|
|
9098
10676
|
envOverrides = parsed;
|
|
9099
10677
|
}
|
|
9100
|
-
|
|
9101
|
-
const
|
|
10678
|
+
const branch = typeof body.branch === "string" ? body.branch : undefined;
|
|
10679
|
+
const prompt = typeof body.prompt === "string" ? body.prompt : undefined;
|
|
10680
|
+
const profile = typeof body.profile === "string" ? body.profile : undefined;
|
|
10681
|
+
const agent = body.agent === "claude" || body.agent === "codex" ? body.agent : undefined;
|
|
10682
|
+
log.info(`[worktree:add]${branch ? ` branch=${branch}` : ""}${profile ? ` profile=${profile}` : ""}${agent ? ` agent=${agent}` : ""}${prompt ? ` prompt="${prompt.slice(0, 80)}"` : ""}`);
|
|
10683
|
+
const result = await lifecycleService.createWorktree({
|
|
10684
|
+
branch,
|
|
9102
10685
|
prompt,
|
|
9103
|
-
profile
|
|
10686
|
+
profile,
|
|
9104
10687
|
agent,
|
|
9105
|
-
autoName: config.autoName,
|
|
9106
|
-
profileConfig,
|
|
9107
|
-
isSandbox,
|
|
9108
|
-
sandboxConfig: isSandbox ? config.profiles.sandbox : undefined,
|
|
9109
|
-
services: config.services,
|
|
9110
|
-
mainRepoDir: PROJECT_DIR,
|
|
9111
10688
|
envOverrides
|
|
9112
10689
|
});
|
|
9113
|
-
|
|
9114
|
-
return errorResponse(result.error, 422);
|
|
9115
|
-
log.debug(`[worktree:add] done branch=${result.branch}: ${result.output}`);
|
|
9116
|
-
wtCache = null;
|
|
10690
|
+
log.debug(`[worktree:add] done branch=${result.branch} worktreeId=${result.worktreeId}`);
|
|
9117
10691
|
return jsonResponse({ branch: result.branch }, 201);
|
|
9118
10692
|
}
|
|
9119
10693
|
async function apiDeleteWorktree(name) {
|
|
9120
|
-
|
|
9121
|
-
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
|
|
9126
|
-
return jsonResponse({ message: result.output });
|
|
10694
|
+
return withRemovingBranch(name, async () => {
|
|
10695
|
+
log.info(`[worktree:rm] name=${name}`);
|
|
10696
|
+
await lifecycleService.removeWorktree(name);
|
|
10697
|
+
log.debug(`[worktree:rm] done name=${name}`);
|
|
10698
|
+
return jsonResponse({ ok: true });
|
|
10699
|
+
});
|
|
9127
10700
|
}
|
|
9128
10701
|
async function apiOpenWorktree(name) {
|
|
10702
|
+
ensureBranchNotRemoving(name);
|
|
9129
10703
|
log.info(`[worktree:open] name=${name}`);
|
|
9130
|
-
const
|
|
9131
|
-
|
|
9132
|
-
|
|
9133
|
-
|
|
9134
|
-
|
|
9135
|
-
|
|
9136
|
-
|
|
9137
|
-
|
|
9138
|
-
|
|
9139
|
-
services: config.services
|
|
9140
|
-
});
|
|
9141
|
-
wtCache = null;
|
|
9142
|
-
}
|
|
9143
|
-
}
|
|
9144
|
-
const result = await openWorktree(name);
|
|
9145
|
-
if (!result.ok)
|
|
9146
|
-
return errorResponse(result.error, 422);
|
|
9147
|
-
wtCache = null;
|
|
9148
|
-
return jsonResponse({ message: result.output });
|
|
10704
|
+
const result = await lifecycleService.openWorktree(name);
|
|
10705
|
+
log.debug(`[worktree:open] done name=${name} worktreeId=${result.worktreeId}`);
|
|
10706
|
+
return jsonResponse({ ok: true });
|
|
10707
|
+
}
|
|
10708
|
+
async function apiCloseWorktree(name) {
|
|
10709
|
+
log.info(`[worktree:close] name=${name}`);
|
|
10710
|
+
await lifecycleService.closeWorktree(name);
|
|
10711
|
+
log.debug(`[worktree:close] done name=${name}`);
|
|
10712
|
+
return jsonResponse({ ok: true });
|
|
9149
10713
|
}
|
|
9150
10714
|
async function apiSendPrompt(name, req) {
|
|
9151
10715
|
const raw = await req.json();
|
|
@@ -9158,26 +10722,17 @@ async function apiSendPrompt(name, req) {
|
|
|
9158
10722
|
return errorResponse("Missing 'text' field", 400);
|
|
9159
10723
|
const preamble = typeof body.preamble === "string" ? body.preamble : undefined;
|
|
9160
10724
|
log.info(`[worktree:send] name=${name} text="${text.slice(0, 80)}"`);
|
|
9161
|
-
const
|
|
10725
|
+
const terminalWorktree = await resolveTerminalWorktree(name);
|
|
10726
|
+
const result = await sendPrompt(terminalWorktree.worktreeId, terminalWorktree.attachTarget, text, 0, preamble);
|
|
9162
10727
|
if (!result.ok)
|
|
9163
10728
|
return errorResponse(result.error, 503);
|
|
9164
10729
|
return jsonResponse({ ok: true });
|
|
9165
10730
|
}
|
|
9166
10731
|
async function apiMergeWorktree(name) {
|
|
9167
10732
|
log.info(`[worktree:merge] name=${name}`);
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
log.debug(`[worktree:merge] done name=${name}: ${result.output}`);
|
|
9172
|
-
wtCache = null;
|
|
9173
|
-
return jsonResponse({ message: result.output });
|
|
9174
|
-
}
|
|
9175
|
-
async function apiWorktreeStatus(name) {
|
|
9176
|
-
const statuses = await getStatus();
|
|
9177
|
-
const match = statuses.find((s) => s.worktree.includes(name));
|
|
9178
|
-
if (!match)
|
|
9179
|
-
return errorResponse("Worktree status not found", 404);
|
|
9180
|
-
return jsonResponse(match);
|
|
10733
|
+
await lifecycleService.mergeWorktree(name);
|
|
10734
|
+
log.debug(`[worktree:merge] done name=${name}`);
|
|
10735
|
+
return jsonResponse({ ok: true });
|
|
9181
10736
|
}
|
|
9182
10737
|
async function apiGetLinearIssues() {
|
|
9183
10738
|
const result = await fetchAssignedIssues();
|
|
@@ -9205,17 +10760,19 @@ Bun.serve({
|
|
|
9205
10760
|
idleTimeout: 255,
|
|
9206
10761
|
routes: {
|
|
9207
10762
|
"/ws/:worktree": (req, server) => {
|
|
9208
|
-
const
|
|
9209
|
-
return server.upgrade(req, { data: {
|
|
9210
|
-
},
|
|
9211
|
-
"/rpc/workmux": {
|
|
9212
|
-
POST: (req) => handleWorkmuxRpc(req)
|
|
10763
|
+
const branch = decodeURIComponent(req.params.worktree);
|
|
10764
|
+
return server.upgrade(req, { data: { branch, worktreeId: null, attached: false } }) ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
9213
10765
|
},
|
|
9214
10766
|
"/api/config": {
|
|
9215
|
-
GET: () => jsonResponse(
|
|
10767
|
+
GET: () => jsonResponse(getFrontendConfig())
|
|
10768
|
+
},
|
|
10769
|
+
"/api/project": {
|
|
10770
|
+
GET: () => catching("GET /api/project", () => apiGetProject())
|
|
10771
|
+
},
|
|
10772
|
+
"/api/runtime/events": {
|
|
10773
|
+
POST: (req) => catching("POST /api/runtime/events", () => apiRuntimeEvent(req))
|
|
9216
10774
|
},
|
|
9217
10775
|
"/api/worktrees": {
|
|
9218
|
-
GET: (req) => catching("GET /api/worktrees", () => apiGetWorktrees(req)),
|
|
9219
10776
|
POST: (req) => catching("POST /api/worktrees", () => apiCreateWorktree(req))
|
|
9220
10777
|
},
|
|
9221
10778
|
"/api/worktrees/:name": {
|
|
@@ -9234,28 +10791,28 @@ Bun.serve({
|
|
|
9234
10791
|
return catching(`POST /api/worktrees/${name}/open`, () => apiOpenWorktree(name));
|
|
9235
10792
|
}
|
|
9236
10793
|
},
|
|
9237
|
-
"/api/worktrees/:name/
|
|
10794
|
+
"/api/worktrees/:name/close": {
|
|
9238
10795
|
POST: (req) => {
|
|
9239
10796
|
const name = decodeURIComponent(req.params.name);
|
|
9240
10797
|
if (!isValidWorktreeName(name))
|
|
9241
10798
|
return errorResponse("Invalid worktree name", 400);
|
|
9242
|
-
return catching(`POST /api/worktrees/${name}/
|
|
10799
|
+
return catching(`POST /api/worktrees/${name}/close`, () => apiCloseWorktree(name));
|
|
9243
10800
|
}
|
|
9244
10801
|
},
|
|
9245
|
-
"/api/worktrees/:name/
|
|
10802
|
+
"/api/worktrees/:name/send": {
|
|
9246
10803
|
POST: (req) => {
|
|
9247
10804
|
const name = decodeURIComponent(req.params.name);
|
|
9248
10805
|
if (!isValidWorktreeName(name))
|
|
9249
10806
|
return errorResponse("Invalid worktree name", 400);
|
|
9250
|
-
return catching(`POST /api/worktrees/${name}/
|
|
10807
|
+
return catching(`POST /api/worktrees/${name}/send`, () => apiSendPrompt(name, req));
|
|
9251
10808
|
}
|
|
9252
10809
|
},
|
|
9253
|
-
"/api/worktrees/:name/
|
|
9254
|
-
|
|
10810
|
+
"/api/worktrees/:name/merge": {
|
|
10811
|
+
POST: (req) => {
|
|
9255
10812
|
const name = decodeURIComponent(req.params.name);
|
|
9256
10813
|
if (!isValidWorktreeName(name))
|
|
9257
10814
|
return errorResponse("Invalid worktree name", 400);
|
|
9258
|
-
return catching(`
|
|
10815
|
+
return catching(`POST /api/worktrees/${name}/merge`, () => apiMergeWorktree(name));
|
|
9259
10816
|
}
|
|
9260
10817
|
},
|
|
9261
10818
|
"/api/linear/issues": {
|
|
@@ -9265,14 +10822,16 @@ Bun.serve({
|
|
|
9265
10822
|
GET: (req) => catching(`GET /api/ci-logs/${req.params.runId}`, () => apiCiLogs(req.params.runId))
|
|
9266
10823
|
},
|
|
9267
10824
|
"/api/notifications/stream": {
|
|
9268
|
-
GET: () =>
|
|
10825
|
+
GET: () => runtimeNotifications.stream()
|
|
9269
10826
|
},
|
|
9270
10827
|
"/api/notifications/:id/dismiss": {
|
|
9271
10828
|
POST: (req) => {
|
|
9272
10829
|
const id = parseInt(req.params.id, 10);
|
|
9273
10830
|
if (isNaN(id))
|
|
9274
10831
|
return errorResponse("Invalid notification ID", 400);
|
|
9275
|
-
|
|
10832
|
+
if (!runtimeNotifications.dismiss(id))
|
|
10833
|
+
return errorResponse("Not found", 404);
|
|
10834
|
+
return jsonResponse({ ok: true });
|
|
9276
10835
|
}
|
|
9277
10836
|
}
|
|
9278
10837
|
},
|
|
@@ -9280,9 +10839,9 @@ Bun.serve({
|
|
|
9280
10839
|
if (STATIC_DIR) {
|
|
9281
10840
|
const url = new URL(req.url);
|
|
9282
10841
|
const rawPath = url.pathname === "/" ? "index.html" : url.pathname;
|
|
9283
|
-
const filePath =
|
|
9284
|
-
const staticRoot =
|
|
9285
|
-
if (!
|
|
10842
|
+
const filePath = join5(STATIC_DIR, rawPath);
|
|
10843
|
+
const staticRoot = resolve5(STATIC_DIR);
|
|
10844
|
+
if (!resolve5(filePath).startsWith(staticRoot + "/")) {
|
|
9286
10845
|
return new Response("Forbidden", { status: 403 });
|
|
9287
10846
|
}
|
|
9288
10847
|
const file = Bun.file(filePath);
|
|
@@ -9290,7 +10849,7 @@ Bun.serve({
|
|
|
9290
10849
|
const headers = rawPath.startsWith("/assets/") ? { "Cache-Control": "public, max-age=31536000, immutable" } : {};
|
|
9291
10850
|
return new Response(file, { headers });
|
|
9292
10851
|
}
|
|
9293
|
-
return new Response(Bun.file(
|
|
10852
|
+
return new Response(Bun.file(join5(STATIC_DIR, "index.html")), {
|
|
9294
10853
|
headers: { "Cache-Control": "no-cache" }
|
|
9295
10854
|
});
|
|
9296
10855
|
}
|
|
@@ -9299,7 +10858,7 @@ Bun.serve({
|
|
|
9299
10858
|
websocket: {
|
|
9300
10859
|
data: {},
|
|
9301
10860
|
open(ws) {
|
|
9302
|
-
log.debug(`[ws] open
|
|
10861
|
+
log.debug(`[ws] open branch=${ws.data.branch}`);
|
|
9303
10862
|
},
|
|
9304
10863
|
async message(ws, message) {
|
|
9305
10864
|
const msg = parseWsMessage(message);
|
|
@@ -9307,52 +10866,72 @@ Bun.serve({
|
|
|
9307
10866
|
sendWs(ws, { type: "error", message: "malformed message" });
|
|
9308
10867
|
return;
|
|
9309
10868
|
}
|
|
9310
|
-
const {
|
|
10869
|
+
const { branch } = ws.data;
|
|
9311
10870
|
switch (msg.type) {
|
|
9312
|
-
case "input":
|
|
9313
|
-
|
|
10871
|
+
case "input": {
|
|
10872
|
+
const worktreeId = getAttachedWorktreeId(ws);
|
|
10873
|
+
if (!worktreeId)
|
|
10874
|
+
return;
|
|
10875
|
+
write(worktreeId, msg.data);
|
|
9314
10876
|
break;
|
|
9315
|
-
|
|
9316
|
-
|
|
10877
|
+
}
|
|
10878
|
+
case "sendKeys": {
|
|
10879
|
+
const worktreeId = getAttachedWorktreeId(ws);
|
|
10880
|
+
if (!worktreeId)
|
|
10881
|
+
return;
|
|
10882
|
+
await sendKeys(worktreeId, msg.hexBytes);
|
|
9317
10883
|
break;
|
|
10884
|
+
}
|
|
9318
10885
|
case "selectPane":
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
10886
|
+
{
|
|
10887
|
+
const worktreeId = getAttachedWorktreeId(ws);
|
|
10888
|
+
if (!worktreeId)
|
|
10889
|
+
return;
|
|
10890
|
+
log.debug(`[ws] selectPane pane=${msg.pane} branch=${branch} worktreeId=${worktreeId}`);
|
|
10891
|
+
await selectPane(worktreeId, msg.pane);
|
|
9322
10892
|
}
|
|
9323
10893
|
break;
|
|
9324
10894
|
case "resize":
|
|
9325
10895
|
if (!ws.data.attached) {
|
|
9326
10896
|
ws.data.attached = true;
|
|
9327
|
-
log.debug(`[ws] first resize (attaching)
|
|
10897
|
+
log.debug(`[ws] first resize (attaching) branch=${branch} cols=${msg.cols} rows=${msg.rows}`);
|
|
9328
10898
|
try {
|
|
9329
10899
|
if (msg.initialPane !== undefined) {
|
|
9330
|
-
log.debug(`[ws] initialPane=${msg.initialPane}
|
|
10900
|
+
log.debug(`[ws] initialPane=${msg.initialPane} branch=${branch}`);
|
|
9331
10901
|
}
|
|
9332
|
-
|
|
10902
|
+
const terminalWorktree = await resolveTerminalWorktree(branch);
|
|
10903
|
+
ws.data.worktreeId = terminalWorktree.worktreeId;
|
|
10904
|
+
await attach(terminalWorktree.worktreeId, terminalWorktree.attachTarget, msg.cols, msg.rows, msg.initialPane);
|
|
9333
10905
|
const { onData, onExit } = makeCallbacks(ws);
|
|
9334
|
-
setCallbacks(
|
|
9335
|
-
const scrollback = getScrollback(
|
|
9336
|
-
log.debug(`[ws] attached
|
|
10906
|
+
setCallbacks(terminalWorktree.worktreeId, onData, onExit);
|
|
10907
|
+
const scrollback = getScrollback(terminalWorktree.worktreeId);
|
|
10908
|
+
log.debug(`[ws] attached branch=${branch} worktreeId=${terminalWorktree.worktreeId} scrollback=${scrollback.length} bytes`);
|
|
9337
10909
|
if (scrollback.length > 0) {
|
|
9338
10910
|
sendWs(ws, { type: "scrollback", data: scrollback });
|
|
9339
10911
|
}
|
|
9340
10912
|
} catch (err) {
|
|
9341
10913
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
9342
|
-
|
|
10914
|
+
ws.data.attached = false;
|
|
10915
|
+
ws.data.worktreeId = null;
|
|
10916
|
+
log.error(`[ws] attach failed branch=${branch}: ${errMsg}`);
|
|
9343
10917
|
sendWs(ws, { type: "error", message: errMsg });
|
|
9344
10918
|
ws.close(1011, errMsg.slice(0, 123));
|
|
9345
10919
|
}
|
|
9346
10920
|
} else {
|
|
9347
|
-
|
|
10921
|
+
const worktreeId = getAttachedWorktreeId(ws);
|
|
10922
|
+
if (!worktreeId)
|
|
10923
|
+
return;
|
|
10924
|
+
await resize(worktreeId, msg.cols, msg.rows);
|
|
9348
10925
|
}
|
|
9349
10926
|
break;
|
|
9350
10927
|
}
|
|
9351
10928
|
},
|
|
9352
10929
|
async close(ws) {
|
|
9353
|
-
log.debug(`[ws] close
|
|
9354
|
-
|
|
9355
|
-
|
|
10930
|
+
log.debug(`[ws] close branch=${ws.data.branch} attached=${ws.data.attached} worktreeId=${ws.data.worktreeId}`);
|
|
10931
|
+
if (ws.data.worktreeId) {
|
|
10932
|
+
clearCallbacks(ws.data.worktreeId);
|
|
10933
|
+
await detach(ws.data.worktreeId);
|
|
10934
|
+
}
|
|
9356
10935
|
}
|
|
9357
10936
|
}
|
|
9358
10937
|
});
|
|
@@ -9362,10 +10941,7 @@ if (tmuxCheck.exitCode !== 0) {
|
|
|
9362
10941
|
log.info("Started tmux session");
|
|
9363
10942
|
}
|
|
9364
10943
|
cleanupStaleSessions();
|
|
9365
|
-
startPrMonitor(
|
|
9366
|
-
installHookScripts().catch((err) => {
|
|
9367
|
-
log.error(`[notify] failed to install hook scripts: ${err instanceof Error ? err.message : String(err)}`);
|
|
9368
|
-
});
|
|
10944
|
+
startPrMonitor(getWorktreeGitDirs, config.integrations.github.linkedRepos, PROJECT_DIR, undefined, hasRecentDashboardActivity);
|
|
9369
10945
|
log.info(`Dev Dashboard API running at http://localhost:${PORT}`);
|
|
9370
10946
|
var nets = networkInterfaces();
|
|
9371
10947
|
for (const addrs of Object.values(nets)) {
|