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.
@@ -6905,7 +6905,7 @@ var require_public_api = __commonJS((exports) => {
6905
6905
  });
6906
6906
 
6907
6907
  // backend/src/server.ts
6908
- import { join as join2, resolve } from "path";
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/workmux.ts
6933
- var {$ } = globalThis.Bun;
6934
- import { mkdir as mkdir2 } from "fs/promises";
6935
-
6936
- // backend/src/env.ts
6937
- async function readEnvLocal(wtDir) {
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 text = (await Bun.file(`${wtDir}/.env.local`).text()).trim();
6940
- const env = {};
6941
- for (const line of text.split(`
6942
- `)) {
6943
- const match = line.match(/^(\w+)=(.*)$/);
6944
- if (match) {
6945
- let val = match[2];
6946
- if (val.length >= 2 && val.startsWith("'") && val.endsWith("'")) {
6947
- val = val.slice(1, -1).replaceAll("'\\''", "'");
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
- env[match[1]] = val;
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
- return env;
6953
- } catch {
6954
- return {};
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
- async function writeEnvLocal(wtDir, entries) {
6958
- const filePath = `${wtDir}/.env.local`;
6959
- let lines = [];
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
- const content = (await Bun.file(filePath).text()).trim();
6962
- if (content)
6963
- lines = content.split(`
6964
- `);
6965
- } catch {}
6966
- for (const [key, value] of Object.entries(entries)) {
6967
- const needsQuoting = /[{}"\s$`\\!#|;&()<>']/.test(value);
6968
- const safe = needsQuoting ? `'${value.replaceAll("'", "'\\''")}'` : value;
6969
- const pattern = new RegExp(`^${key}=`);
6970
- const idx = lines.findIndex((l) => pattern.test(l));
6971
- if (idx >= 0) {
6972
- lines[idx] = `${key}=${safe}`;
6973
- } else {
6974
- lines.push(`${key}=${safe}`);
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
- await Bun.write(filePath, lines.join(`
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
- function readAllWorktreeEnvs(worktreePaths, excludeDir) {
6982
- return Promise.all(worktreePaths.filter((p) => !excludeDir || p !== excludeDir).map((p) => readEnvLocal(p)));
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 allocatePorts(existingEnvs, services) {
6985
- const allocatable = services.filter((s) => s.portStart != null);
6986
- if (allocatable.length === 0)
6987
- return {};
6988
- const ref = allocatable[0];
6989
- const refStart = ref.portStart;
6990
- const refStep = ref.portStep ?? 1;
6991
- const occupied = new Set;
6992
- for (const env of existingEnvs) {
6993
- const raw = env[ref.portEnv];
6994
- if (raw == null)
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
- const port = Number(raw);
6997
- if (!Number.isInteger(port) || port < refStart)
7273
+ }
7274
+ if (line.startsWith("HEAD ")) {
7275
+ current.head = line.slice("HEAD ".length);
6998
7276
  continue;
6999
- const diff = port - refStart;
7000
- if (diff % refStep !== 0)
7277
+ }
7278
+ if (line === "detached") {
7279
+ current.detached = true;
7001
7280
  continue;
7002
- occupied.add(diff / refStep);
7281
+ }
7282
+ if (line === "bare") {
7283
+ current.bare = true;
7284
+ }
7003
7285
  }
7004
- let slot = 1;
7005
- while (occupied.has(slot))
7006
- slot++;
7007
- const result = {};
7008
- for (const svc of allocatable) {
7009
- const start = svc.portStart;
7010
- const step = svc.portStep ?? 1;
7011
- result[svc.portEnv] = String(start + slot * step);
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
- profiles: { default: { name: "default" } },
7069
- autoName: false,
7070
- linkedRepos: [],
7071
- startupEnvs: {}
7455
+ startupEnvs: {},
7456
+ integrations: {
7457
+ github: { linkedRepos: [] },
7458
+ linear: { enabled: true }
7459
+ },
7460
+ lifecycleHooks: {},
7461
+ autoName: null
7072
7462
  };
7073
- function hasAutoName(dir) {
7074
- try {
7075
- const filePath = join(gitRoot(dir), ".workmux.yaml");
7076
- const result = Bun.spawnSync(["cat", filePath], { stdout: "pipe", stderr: "pipe" });
7077
- const text = new TextDecoder().decode(result.stdout).trim();
7078
- if (!text)
7079
- return false;
7080
- const parsed = $parse(text);
7081
- const autoName = parsed.auto_name;
7082
- return !!autoName?.model;
7083
- } catch {
7084
- return false;
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
- return new TextDecoder().decode(result.stdout).trim() || dir;
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 filePath = join(root, ".webmux.yaml");
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
- ...typeof parsed.name === "string" ? { name: parsed.name } : {},
7121
- services: Array.isArray(parsed.services) ? parsed.services : DEFAULT_CONFIG.services,
7122
- profiles: {
7123
- default: defaultProfile?.name ? defaultProfile : DEFAULT_CONFIG.profiles.default,
7124
- ...sandboxProfile?.name && sandboxProfile?.image ? { sandbox: sandboxProfile } : {}
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
- autoName,
7127
- linkedRepos,
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/docker.ts
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 SECRET_PATH = `${Bun.env.HOME ?? "/root"}/.config/workmux/rpc-secret`;
7145
- var cached = null;
7146
- async function loadRpcSecret() {
7147
- if (cached)
7148
- return cached;
7149
- const file = Bun.file(SECRET_PATH);
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
- cached = (await file.text()).trim();
7152
- return cached;
7153
- }
7154
- const secret = crypto.randomUUID();
7155
- await mkdir(dirname(SECRET_PATH), { recursive: true });
7156
- await Bun.write(SECRET_PATH, secret);
7157
- await chmod(SECRET_PATH, 384);
7158
- cached = secret;
7159
- return secret;
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
- function buildWorkmuxStub() {
7187
- return `#!/usr/bin/env python3
7188
- import sys, json, os, urllib.request
7189
-
7190
- cmd = sys.argv[1] if len(sys.argv) > 1 else ""
7191
- args = sys.argv[2:]
7192
- host = os.environ.get("WORKMUX_RPC_HOST", "host.docker.internal")
7193
- port = os.environ.get("WORKMUX_RPC_PORT", "5111")
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, rpcSecret, rpcPort, sshAuthSock, hostUid, hostGid) {
7218
- const { wtDir, mainRepoDir, sandboxConfig, services, env } = opts;
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 = env[svc.portEnv];
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(env)) {
7776
+ for (const [key, val] of Object.entries(runtimeEnv)) {
7280
7777
  if (!isValidEnvKey(key)) {
7281
- log.warn(`[docker] skipping invalid .env.local key: ${JSON.stringify(key)}`);
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.extraMounts) {
7296
- for (const mount of sandboxConfig.extraMounts) {
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.extraMounts) {
7320
- for (const mount of sandboxConfig.extraMounts) {
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 extra mount with non-absolute host path: ${JSON.stringify(hostPath)}`);
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, rpcSecret, rpcPort, sshAuthSock, process.getuid(), process.getgid());
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/workmux.ts
7467
- var WORKTREE_HEADERS = ["BRANCH", "AGENT", "MUX", "UNMERGED", "PATH"];
7468
- var STATUS_HEADERS = ["WORKTREE", "STATUS", "ELAPSED", "TITLE"];
7469
- function parseTable(output, mapper, expectedHeaders) {
7470
- const lines = output.trim().split(`
7471
- `).filter(Boolean);
7472
- if (lines.length < 2)
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
- async function listWorktrees() {
7513
- const proc = await $`workmux list`.env(workmuxEnv()).nothrow().quiet();
7514
- if (proc.exitCode !== 0) {
7515
- ensureTmux();
7516
- return [];
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
- async function getStatus() {
7525
- const proc = await $`workmux status`.env(workmuxEnv()).nothrow().quiet();
7526
- if (proc.exitCode !== 0) {
7527
- ensureTmux();
7528
- return [];
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
- async function tryExec(args, env) {
7538
- const proc = Bun.spawn(args, {
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
- const stdout = await new Response(proc.stdout).text();
7544
- const stderr = await new Response(proc.stderr).text();
7545
- const exitCode = await proc.exited;
7546
- if (exitCode !== 0) {
7547
- const msg = `${args.join(" ")} failed (exit ${exitCode}): ${stderr || stdout}`;
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 buildAgentCmd(env, agent, profileConfig, isSandbox, prompt) {
7554
- const systemPrompt = profileConfig.systemPrompt ? expandTemplate(profileConfig.systemPrompt, env) : "";
7555
- const innerEscaped = systemPrompt.replace(/["\\$`]/g, "\\$&");
7556
- const promptEscaped = prompt ? prompt.replace(/["\\$`]/g, "\\$&") : "";
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
- const skipPerms = isSandbox ? " --dangerously-skip-permissions" : "";
7563
- return systemPrompt ? `${envPrefix}claude${skipPerms} --append-system-prompt "${innerEscaped}"${promptSuffix}` : `${envPrefix}claude${skipPerms}${promptSuffix}`;
8020
+ return result.stdout;
7564
8021
  }
7565
- function buildEnvPrefix(keys, env) {
7566
- const parts = [];
7567
- for (const key of keys) {
7568
- const val = env[key];
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 parseWorktreePorcelain(output) {
7577
- const paths = new Map;
7578
- let currentPath = "";
7579
- for (const line of output.split(`
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 ensureTmux() {
7592
- const check = Bun.spawnSync(["tmux", "list-sessions"], { stdout: "pipe", stderr: "pipe" });
7593
- if (check.exitCode !== 0) {
7594
- const started = Bun.spawnSync(["tmux", "new-session", "-d", "-s", "0"]);
7595
- if (started.exitCode !== 0) {
7596
- log.debug("[workmux] tmux session already exists (concurrent start)");
7597
- } else {
7598
- log.debug("[workmux] restarted tmux session");
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(/[~^:?*\[\]\\]+/g, "").replace(/@\{/g, "").replace(/\.{2,}/g, ".").replace(/\/{2,}/g, "/").replace(/-{2,}/g, "-").replace(/^[.\-/]+|[.\-/]+$/g, "").replace(/\.lock$/i, "");
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 randomName(len) {
7606
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
7607
- let result = "";
7608
- for (let i = 0;i < len; i++) {
7609
- result += chars[Math.floor(Math.random() * chars.length)];
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
- function parseBranchFromOutput(output) {
7614
- const match = output.match(/branch:\s*(\S+)/i);
7615
- return match?.[1] ?? null;
7616
- }
7617
- async function initWorktreeEnv(branch, opts) {
7618
- const profile = opts?.profile ?? "default";
7619
- const agent = opts?.agent ?? "claude";
7620
- const porcelainResult = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
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
- Bun.spawnSync(["tmux", "select-pane", "-t", `${windowTarget}.0`]);
7757
- }
7758
- return { ok: true, branch, output: result };
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
- async function removeWorktree(name) {
7761
- log.debug(`[workmux:rm] running: workmux rm --force ${name}`);
7762
- await removeContainer(name);
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
- var TMUX_TIMEOUT_MS = 5000;
7769
- async function tmuxExec(args, opts = {}) {
7770
- const proc = Bun.spawn(args, {
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
- const stderr = (await new Response(proc.stderr).text()).trim();
7784
- return { exitCode: result, stderr };
8196
+ return raw.branch_name;
7785
8197
  }
7786
- async function sendPrompt(branch, text, pane = 0, preamble) {
7787
- const windowName = `wm-${branch}`;
7788
- const session = await findWorktreeSession(windowName);
7789
- if (!session) {
7790
- return { ok: false, error: `tmux window "${windowName}" not found` };
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
- const target = `${session}:${windowName}.${pane}`;
7793
- log.debug(`[send:${branch}] target=${target} textBytes=${text.length}${preamble ? ` preamble=${preamble.length}b` : ""}`);
7794
- if (preamble) {
7795
- const { exitCode, stderr } = await tmuxExec(["tmux", "send-keys", "-t", target, "-l", "--", preamble]);
7796
- if (exitCode !== 0) {
7797
- return { ok: false, error: `send-keys preamble failed${stderr ? `: ${stderr}` : ""}` };
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
- const cleaned = text.replace(/\0/g, "");
7801
- const bufName = `wm-prompt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
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
- const paste = await tmuxExec(["tmux", "paste-buffer", "-b", bufName, "-t", target, "-d"]);
7807
- if (paste.exitCode !== 0) {
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
- return { ok: true };
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
- async function findWorktreeSession(windowName) {
7813
- const proc = Bun.spawn(["tmux", "list-windows", "-a", "-F", "#{session_name}:#{window_name}"], { stdout: "pipe", stderr: "pipe" });
7814
- if (await proc.exited !== 0)
7815
- return null;
7816
- const output = (await new Response(proc.stdout).text()).trim();
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 line of output.split(`
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
- const session = line.slice(0, colonIdx);
7825
- const name = line.slice(colonIdx + 1);
7826
- if (name === windowName)
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
- async function openWorktree(name) {
7832
- const result = await tryExec(["workmux", "open", name]);
7833
- if (!result.ok)
7834
- return result;
7835
- return { ok: true, output: result.stdout };
7836
- }
7837
- async function checkDirty(dir) {
7838
- const [status, ahead] = await Promise.all([
7839
- (async () => {
7840
- const proc = Bun.spawn(["git", "status", "--porcelain"], {
7841
- cwd: dir,
7842
- stdout: "pipe",
7843
- stderr: "pipe"
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
- async function cleanupStaleWindows(activeBranches, worktreeBaseDir) {
7865
- try {
7866
- const proc = Bun.spawn(["tmux", "list-panes", "-a", "-F", "#{session_name}:#{window_name} #{pane_current_path}"], { stdout: "pipe", stderr: "pipe" });
7867
- if (await proc.exited !== 0)
7868
- return;
7869
- const output = (await new Response(proc.stdout).text()).trim();
7870
- if (!output)
7871
- return;
7872
- const toKill = [];
7873
- const seen = new Set;
7874
- for (const line of output.split(`
7875
- `)) {
7876
- const spaceIdx = line.indexOf(" ");
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
- toKill.push({ session, windowName });
7896
- seen.add(windowName);
7897
- }
7898
- await Promise.all(toKill.map(async ({ session, windowName }) => {
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 mergeWorktree(name) {
7911
- log.debug(`[workmux:merge] running: workmux merge ${name}`);
7912
- await removeContainer(name);
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
- // backend/src/terminal.ts
7920
- var textDecoder = new TextDecoder;
7921
- var textEncoder = new TextEncoder;
7922
- var DASH_PORT = Bun.env.BACKEND_PORT || "5111";
7923
- var SESSION_PREFIX = `wm-dash-${DASH_PORT}-`;
7924
- var MAX_SCROLLBACK_BYTES = 1 * 1024 * 1024;
7925
- var sessions = new Map;
7926
- var sessionCounter = 0;
7927
- function groupedName() {
7928
- return `${SESSION_PREFIX}${++sessionCounter}`;
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
- function buildAttachCmd(opts) {
7931
- const windowTarget = `wm-${opts.worktreeName}`;
7932
- const paneTarget = `${opts.gName}:${windowTarget}.${opts.initialPane ?? 0}`;
7933
- return [
7934
- `tmux new-session -d -s "${opts.gName}" -t "${opts.tmuxSession}"`,
7935
- `tmux set-option -t "${opts.tmuxSession}" window-size latest`,
7936
- `tmux set-option -t "${opts.gName}" mouse on`,
7937
- `tmux set-option -t "${opts.gName}" set-clipboard on`,
7938
- `tmux select-window -t "${opts.gName}:${windowTarget}"`,
7939
- `if [ "$(tmux display-message -t '${opts.gName}:${windowTarget}' -p '#{window_zoomed_flag}')" = "1" ]; then tmux resize-pane -Z -t '${opts.gName}:${windowTarget}'; fi`,
7940
- `tmux select-pane -t "${paneTarget}"`,
7941
- ...opts.initialPane !== undefined ? [`tmux resize-pane -Z -t "${paneTarget}"`] : [],
7942
- `stty rows ${opts.rows} cols ${opts.cols}`,
7943
- `exec tmux attach-session -t "${opts.gName}"`
7944
- ].join(" && ");
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
- async function asyncTmux(args) {
7947
- const proc = Bun.spawn(args, { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
7948
- const exitCode = await proc.exited;
7949
- const stderr = (await new Response(proc.stderr).text()).trim();
7950
- return { exitCode, stderr };
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
- function cleanupStaleSessions() {
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 result = Bun.spawnSync(["tmux", "list-sessions", "-F", "#{session_name}"], { stdout: "pipe", stderr: "pipe" });
7955
- if (result.exitCode !== 0)
7956
- return;
7957
- const lines = textDecoder.decode(result.stdout).trim().split(`
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
- for (const name of lines) {
7960
- if (name.startsWith(SESSION_PREFIX)) {
7961
- Bun.spawnSync(["tmux", "kill-session", "-t", name]);
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
- } catch {}
8671
+ }
7965
8672
  }
7966
- function killTmuxSession(name) {
7967
- const result = Bun.spawnSync(["tmux", "kill-session", "-t", name], { stderr: "pipe" });
7968
- if (result.exitCode !== 0) {
7969
- const stderr = textDecoder.decode(result.stderr).trim();
7970
- if (!stderr.includes("can't find session")) {
7971
- log.warn(`[term] killTmuxSession(${name}) exit=${result.exitCode} ${stderr}`);
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 parseTmuxSessionForWorktree(tmuxOutput, worktreeName) {
7976
- const windowName = `wm-${worktreeName}`;
7977
- const lines = tmuxOutput.trim().split(`
7978
- `).filter(Boolean);
7979
- for (const line of lines) {
7980
- const colonIdx = line.indexOf(":");
7981
- if (colonIdx === -1)
7982
- continue;
7983
- const session = line.slice(0, colonIdx);
7984
- const name = line.slice(colonIdx + 1);
7985
- if (name === windowName && !session.startsWith("wm-dash-")) {
7986
- return session;
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
- for (const line of lines) {
7990
- const colonIdx = line.indexOf(":");
7991
- if (colonIdx === -1)
7992
- continue;
7993
- const session = line.slice(0, colonIdx);
7994
- const name = line.slice(colonIdx + 1);
7995
- if (name.startsWith("wm-") && !session.startsWith("wm-dash-")) {
7996
- return session;
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
- async function findTmuxSessionForWorktree(worktreeName) {
8002
- try {
8003
- const proc = Bun.spawn(["tmux", "list-windows", "-a", "-F", "#{session_name}:#{window_name}"], { stdout: "pipe", stderr: "pipe" });
8004
- if (await proc.exited !== 0)
8005
- return "0";
8006
- const output = await new Response(proc.stdout).text();
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
- async function attach(worktreeName, cols, rows, initialPane) {
8012
- log.debug(`[term] attach(${worktreeName}) cols=${cols} rows=${rows} existing=${sessions.has(worktreeName)}`);
8013
- if (sessions.has(worktreeName)) {
8014
- await detach(worktreeName);
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
- const tmuxSession = await findTmuxSessionForWorktree(worktreeName);
8017
- const gName = groupedName();
8018
- log.debug(`[term] attach(${worktreeName}) tmuxSession=${tmuxSession} gName=${gName} window=wm-${worktreeName}`);
8019
- killTmuxSession(gName);
8020
- const cmd = buildAttachCmd({ gName, worktreeName, tmuxSession, cols, rows, initialPane });
8021
- const scriptArgs = process.platform === "darwin" ? ["python3", "-c", "import pty,sys;pty.spawn(sys.argv[1:])", "bash", "-c", cmd] : ["script", "-q", "-c", cmd, "/dev/null"];
8022
- const proc = Bun.spawn(scriptArgs, {
8023
- stdin: "pipe",
8024
- stdout: "pipe",
8025
- stderr: "pipe",
8026
- env: { ...Bun.env, TERM: "xterm-256color" }
8027
- });
8028
- const session = {
8029
- proc,
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
- while (true) {
8043
- if (session.cancelled)
8044
- break;
8045
- const { done, value } = await reader.read();
8046
- if (done)
8047
- break;
8048
- const str = textDecoder.decode(value);
8049
- session.scrollbackBytes += textEncoder.encode(str).byteLength;
8050
- session.scrollback.push(str);
8051
- while (session.scrollbackBytes > MAX_SCROLLBACK_BYTES && session.scrollback.length > 0) {
8052
- const removed = session.scrollback.shift();
8053
- session.scrollbackBytes -= textEncoder.encode(removed).byteLength;
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
- (async () => {
8064
- const reader = proc.stderr.getReader();
9413
+ }
9414
+ async openWorktree(branch) {
8065
9415
  try {
8066
- while (true) {
8067
- const { done, value } = await reader.read();
8068
- if (done)
8069
- break;
8070
- log.debug(`[term] stderr(${worktreeName}): ${textDecoder.decode(value).trimEnd()}`);
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
- log.debug(`[term] detach(${worktreeName}) killing pid=${session.proc.pid} tmux=${session.groupedSessionName}`);
8093
- session.cancelled = true;
8094
- session.proc.kill();
8095
- sessions.delete(worktreeName);
8096
- killTmuxSession(session.groupedSessionName);
8097
- }
8098
- function write(worktreeName, data) {
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
- try {
8105
- session.proc.stdin.write(textEncoder.encode(data));
8106
- session.proc.stdin.flush();
8107
- } catch (err) {
8108
- log.error(`[term] write(${worktreeName}) stdin closed`, err);
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
- async function sendKeys(worktreeName, hexBytes) {
8112
- const session = sessions.get(worktreeName);
8113
- if (!session)
8114
- return;
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
- async function selectPane(worktreeName, paneIndex) {
8138
- const session = sessions.get(worktreeName);
8139
- if (!session) {
8140
- log.debug(`[term] selectPane(${worktreeName}) no session found`);
8141
- return;
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
- const windowTarget = `wm-${worktreeName}`;
8144
- const target = `${session.groupedSessionName}:${windowTarget}.${paneIndex}`;
8145
- log.debug(`[term] selectPane(${worktreeName}) pane=${paneIndex} target=${target}`);
8146
- const [r1, r2] = await Promise.all([
8147
- asyncTmux(["tmux", "select-pane", "-t", target]),
8148
- asyncTmux(["tmux", "resize-pane", "-Z", "-t", target])
8149
- ]);
8150
- log.debug(`[term] selectPane(${worktreeName}) select=${r1.exitCode} zoom=${r2.exitCode}`);
8151
- }
8152
- function clearCallbacks(worktreeName) {
8153
- const session = sessions.get(worktreeName);
8154
- if (session) {
8155
- session.onData = null;
8156
- session.onExit = null;
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 cached2 = etagCache.get(apiPath);
8301
- if (cached2) {
8302
- args.push("--header", `If-None-Match: ${cached2.etag}`);
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 cached2?.comments ?? [];
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 cached2?.comments ?? [];
9902
+ return cached?.comments ?? [];
8330
9903
  try {
8331
9904
  return parseReviewComments(raw);
8332
9905
  } catch {
8333
- return cached2?.comments ?? [];
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 cached2?.comments ?? [];
9913
+ return cached?.comments ?? [];
8341
9914
  }
8342
9915
  if (raceResult !== 0)
8343
- return cached2?.comments ?? [];
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 cached2?.comments ?? [];
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(wtDir) {
8375
- const env = await readEnvLocal(wtDir);
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 writeEnvLocal(wtDir, { PR_DATA: JSON.stringify(updated) });
9957
+ await writeWorktreePrs(gitDir, updated);
8393
9958
  }
8394
- async function syncPrStatus(getWorktreePaths, linkedRepos, projectDir) {
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 wtPaths = await getWorktreePaths();
8413
- const activeBranches = new Set(wtPaths.keys());
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 cached2 = prCommentsCache.get(entry.url);
8425
- entry.comments = [...entry.comments, ...cached2].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
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 wtDir = wtPaths.get(branch);
8446
- if (!wtDir || seen.has(wtDir))
10010
+ const gitDir = worktreeGitDirs.get(branch);
10011
+ if (!gitDir || seen.has(gitDir))
8447
10012
  continue;
8448
- seen.add(wtDir);
8449
- await writeEnvLocal(wtDir, { PR_DATA: JSON.stringify(entries) });
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(wtPaths.values());
10019
+ const uniqueDirs = new Set(worktreeGitDirs.values());
8455
10020
  const staleRefreshes = [];
8456
- for (const wtDir of uniqueDirs) {
8457
- if (seen.has(wtDir))
10021
+ for (const gitDir of uniqueDirs) {
10022
+ if (seen.has(gitDir))
8458
10023
  continue;
8459
- staleRefreshes.push(refreshStalePrData(wtDir));
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(getWorktreePaths, linkedRepos, projectDir, intervalMs = 20000, isActive) {
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(getWorktreePaths, linkedRepos, projectDir).catch((err) => {
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/http.ts
8502
- function jsonResponse(data, status = 200) {
8503
- return new Response(JSON.stringify(data), {
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 addNotification(branch, type, url) {
8542
- const message = type === "agent_stopped" ? `Agent stopped on ${branch}` : `PR opened on ${branch}`;
8543
- const notification = {
8544
- id: nextId++,
8545
- branch,
8546
- type,
8547
- message,
8548
- url,
8549
- timestamp: Date.now()
8550
- };
8551
- notifications.push(notification);
8552
- if (notifications.length > 50)
8553
- notifications.shift();
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
- cancel() {
8577
- sseClients.delete(ctrl);
8578
- }
8579
- });
8580
- return new Response(stream, {
8581
- headers: {
8582
- "Content-Type": "text/event-stream",
8583
- "Cache-Control": "no-cache",
8584
- Connection: "keep-alive"
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 handleDismissNotification(id) {
8589
- const ok = dismissNotification(id);
8590
- if (!ok) {
8591
- return new Response(JSON.stringify({ error: "Not found" }), {
8592
- status: 404,
8593
- headers: { "Content-Type": "application/json" }
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
- # Auth: token from env or secret file
8609
- TOKEN="\${WORKMUX_RPC_TOKEN:-}"
8610
- if [ -z "$TOKEN" ] && [ -f "\${HOME}/.config/workmux/rpc-secret" ]; then
8611
- TOKEN=$(cat "\${HOME}/.config/workmux/rpc-secret")
8612
- fi
8613
- [ -z "$TOKEN" ] && exit 0
8614
-
8615
- PORT="\${WORKMUX_RPC_PORT:-5111}"
8616
-
8617
- # Extract branch from cwd field: .../__worktrees/<branch>
8618
- CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
8619
- [ -z "$CWD" ] && exit 0
8620
- BRANCH=$(echo "$CWD" | grep -oP '__worktrees/\\K[^/]+' || true)
8621
- [ -z "$BRANCH" ] && exit 0
8622
-
8623
- PAYLOAD=$(jq -n --arg branch "$BRANCH" '{"command":"notify","branch":$branch,"args":["agent_stopped"]}')
8624
-
8625
- curl -sf -X POST "http://127.0.0.1:\${PORT}/rpc/workmux" \\
8626
- -H "Authorization: Bearer $TOKEN" \\
8627
- -H "Content-Type: application/json" \\
8628
- -d "$PAYLOAD" \\
8629
- >/dev/null 2>&1 || true
8630
- `;
8631
- var NOTIFY_PR_SH = `#!/usr/bin/env bash
8632
- # Claude Code PostToolUse hook \u2014 notifies workmux backend when a PR is opened.
8633
- set -euo pipefail
8634
-
8635
- # Read hook input from stdin
8636
- INPUT=$(cat)
8637
-
8638
- # Only trigger on Bash tool calls containing "gh pr create"
8639
- TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
8640
- echo "$TOOL_INPUT" | grep -q 'gh pr create' || exit 0
8641
-
8642
- # Auth: token from env or secret file
8643
- TOKEN="\${WORKMUX_RPC_TOKEN:-}"
8644
- if [ -z "$TOKEN" ] && [ -f "\${HOME}/.config/workmux/rpc-secret" ]; then
8645
- TOKEN=$(cat "\${HOME}/.config/workmux/rpc-secret")
8646
- fi
8647
- [ -z "$TOKEN" ] && exit 0
8648
-
8649
- PORT="\${WORKMUX_RPC_PORT:-5111}"
8650
-
8651
- # Extract branch from cwd
8652
- CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
8653
- [ -z "$CWD" ] && exit 0
8654
- BRANCH=$(echo "$CWD" | grep -oP '__worktrees/\\K[^/]+' || true)
8655
- [ -z "$BRANCH" ] && exit 0
8656
-
8657
- # Extract PR URL from tool response (gh pr create outputs the URL)
8658
- PR_URL=$(echo "$INPUT" | jq -r '.tool_response // empty' | grep -oP 'https://github\\.com/[^\\s"]+/pull/\\d+' | head -1 || true)
8659
-
8660
- if [ -n "$PR_URL" ]; then
8661
- PAYLOAD=$(jq -n --arg branch "$BRANCH" --arg url "$PR_URL" '{"command":"notify","branch":$branch,"args":["pr_opened",$url]}')
8662
- else
8663
- PAYLOAD=$(jq -n --arg branch "$BRANCH" '{"command":"notify","branch":$branch,"args":["pr_opened"]}')
8664
- fi
8665
-
8666
- curl -sf -X POST "http://127.0.0.1:\${PORT}/rpc/workmux" \\
8667
- -H "Authorization: Bearer $TOKEN" \\
8668
- -H "Content-Type: application/json" \\
8669
- -d "$PAYLOAD" \\
8670
- >/dev/null 2>&1 || true
8671
- `;
8672
- async function installHookScripts() {
8673
- await mkdir3(HOOKS_DIR, { recursive: true });
8674
- const stopPath = `${HOOKS_DIR}/notify-stop.sh`;
8675
- const prPath = `${HOOKS_DIR}/notify-pr.sh`;
8676
- await Bun.write(stopPath, NOTIFY_STOP_SH);
8677
- await Bun.write(prPath, NOTIFY_PR_SH);
8678
- await chmod2(stopPath, 493);
8679
- await chmod2(prPath, 493);
8680
- log.info(`[notify] installed hook scripts in ${HOOKS_DIR}`);
8681
- }
8682
-
8683
- // backend/src/rpc.ts
8684
- function tmuxEnv() {
8685
- if (Bun.env.TMUX)
8686
- return Bun.env;
8687
- const tmpdir = Bun.env.TMUX_TMPDIR || "/tmp";
8688
- const uid = process.getuid?.() ?? 1000;
8689
- return { ...Bun.env, TMUX: `${tmpdir}/tmux-${uid}/default,0,0` };
8690
- }
8691
- async function resolvePaneId(branch) {
8692
- const proc = Bun.spawn(["tmux", "list-panes", "-a", "-F", "#{window_name}\t#{pane_id}"], { stdout: "pipe", stderr: "pipe", env: tmuxEnv() });
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
- return null;
8708
- }
8709
- async function handleWorkmuxRpc(req) {
8710
- const secret = await loadRpcSecret();
8711
- const authHeader = req.headers.get("Authorization");
8712
- const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
8713
- if (token !== secret) {
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
- let raw;
8717
- try {
8718
- raw = await req.json();
8719
- } catch {
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
- const { command, args = [], branch } = raw;
8723
- if (!command) {
8724
- return jsonResponse({ ok: false, error: "Missing command" }, 400);
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
- if (command === "notify" && branch) {
8727
- const [type, url] = args;
8728
- if (type === "agent_stopped" || type === "pr_opened") {
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
- return jsonResponse({ ok: false, error: `Unknown notification type: ${type}` }, 400);
10218
+ this.worktreeIdsByBranch.set(nextBranch, worktreeId);
8733
10219
  }
8734
- try {
8735
- const env = tmuxEnv();
8736
- if (command === "set-window-status" && branch) {
8737
- const paneId = await resolvePaneId(branch);
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 jsonResponse({ ok: true, output: stdout.trim() });
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/linear.ts
8763
- var ASSIGNED_ISSUES_QUERY = `
8764
- query AssignedIssues {
8765
- viewer {
8766
- assignedIssues(
8767
- filter: { state: { type: { nin: ["completed", "canceled"] } } }
8768
- orderBy: updatedAt
8769
- first: 50
8770
- ) {
8771
- nodes {
8772
- id
8773
- identifier
8774
- title
8775
- description
8776
- priority
8777
- priorityLabel
8778
- url
8779
- branchName
8780
- dueDate
8781
- updatedAt
8782
- state { name color type }
8783
- team { name key }
8784
- labels { nodes { name color } }
8785
- project { name }
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 branchMatchesIssue(worktreeBranch, issueBranchName) {
8818
- if (!worktreeBranch || !issueBranchName)
8819
- return false;
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
- var CACHE_TTL_MS = 300000;
8839
- var issueCache = null;
8840
- async function fetchAssignedIssues() {
8841
- const apiKey = Bun.env.LINEAR_API_KEY;
8842
- if (!apiKey) {
8843
- return { ok: false, error: "LINEAR_API_KEY not set" };
8844
- }
8845
- const now = Date.now();
8846
- if (issueCache && now < issueCache.expiry) {
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
- try {
8850
- const res = await fetch("https://api.linear.app/graphql", {
8851
- method: "POST",
8852
- headers: {
8853
- "Content-Type": "application/json",
8854
- Authorization: apiKey
8855
- },
8856
- body: JSON.stringify({ query: ASSIGNED_ISSUES_QUERY })
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 json = await res.json();
8864
- const result = parseIssuesResponse(json);
8865
- if (result.ok) {
8866
- issueCache = { data: result, expiry: now + CACHE_TTL_MS };
8867
- log.debug(`[linear] fetched ${result.data.length} assigned issues`);
8868
- } else {
8869
- log.error(`[linear] GraphQL error: ${result.error}`);
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
- return result;
8872
- } catch (err) {
8873
- const msg = err instanceof Error ? err.message : String(err);
8874
- log.error(`[linear] fetch failed: ${msg}`);
8875
- return { ok: false, error: msg };
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 WORKTREE_CACHE_TTL_MS = 2000;
8885
- var wtCache = null;
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 safeJsonParse(str) {
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 JSON.parse(str);
8939
- } catch {
8940
- return null;
10552
+ return await fn();
10553
+ } finally {
10554
+ removingBranches.delete(branch);
8941
10555
  }
8942
10556
  }
8943
- async function getWorktreePaths() {
8944
- const proc = Bun.spawn(["git", "worktree", "list", "--porcelain"], { stdout: "pipe" });
8945
- await proc.exited;
8946
- const output = await new Response(proc.stdout).text();
8947
- const all = parseWorktreePorcelain(output);
8948
- const paths = new Map;
8949
- let isFirst = true;
8950
- for (const [branch, path] of all) {
8951
- if (isFirst) {
8952
- isFirst = false;
8953
- continue;
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
- paths.set(branch, path);
8956
- const basename = path.split("/").pop() ?? "";
8957
- if (basename !== branch)
8958
- paths.set(basename, path);
10573
+ };
10574
+ }
10575
+ function getAttachedWorktreeId(ws) {
10576
+ if (ws.data.attached && ws.data.worktreeId) {
10577
+ return ws.data.worktreeId;
8959
10578
  }
8960
- return paths;
10579
+ sendWs(ws, { type: "error", message: "Terminal not attached" });
10580
+ return null;
8961
10581
  }
8962
- async function getAllPaneCounts() {
8963
- const proc = Bun.spawn(["tmux", "list-windows", "-a", "-F", "#{window_name} #{window_panes}"], { stdout: "pipe", stderr: "pipe" });
8964
- if (await proc.exited !== 0)
8965
- return new Map;
8966
- const out = await new Response(proc.stdout).text();
8967
- const counts = new Map;
8968
- for (const line of out.trim().split(`
8969
- `)) {
8970
- const spaceIdx = line.lastIndexOf(" ");
8971
- if (spaceIdx === -1)
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
- const branch = name.slice(3);
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 counts;
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 apiGetWorktrees(req) {
9021
- touchActivity();
9022
- const now = Date.now();
9023
- if (wtCache && now < wtCache.expiry) {
9024
- if (req.headers.get("if-none-match") === wtCache.etag) {
9025
- return new Response(null, { status: 304 });
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
- const activeBranches = new Set(worktrees.map((wt) => wt.branch));
9040
- activeBranches.add("main");
9041
- cleanupStaleWindows(activeBranches, `${PROJECT_DIR}__worktrees/`);
9042
- const nonMainWorktrees = worktrees.filter((wt) => wtPaths.has(wt.branch));
9043
- const merged = await Promise.all(nonMainWorktrees.map(async (wt) => {
9044
- const st = status.find((s) => s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch));
9045
- const wtDir = wtPaths.get(wt.branch);
9046
- const env = wtDir ? await readEnvLocal(wtDir) : {};
9047
- const dirty = wtDir ? await checkDirty(wtDir) : false;
9048
- const services = await Promise.all(config.services.map(async (svc) => {
9049
- const port = env[svc.portEnv] ? parseInt(env[svc.portEnv], 10) : null;
9050
- const running = port !== null && port >= 1 && port <= 65535 ? await isPortListening(port) : false;
9051
- return { name: svc.name, port, running };
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
- const json = JSON.stringify(merged);
9071
- const etag = `"${Bun.hash(json).toString(36)}"`;
9072
- wtCache = { json, etag, expiry: now + WORKTREE_CACHE_TTL_MS };
9073
- return new Response(json, {
9074
- headers: { "Content-Type": "application/json", ETag: etag }
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
- log.info(`[worktree:add] agent=${agent} profile=${profileName}${branch ? ` branch=${branch}` : ""}${prompt ? ` prompt="${prompt.slice(0, 80)}"` : ""}`);
9101
- const result = await addWorktree(branch, {
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: profileName,
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
- if (!result.ok)
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
- log.info(`[worktree:rm] name=${name}`);
9121
- const result = await removeWorktree(name);
9122
- if (!result.ok)
9123
- return errorResponse(result.error, 422);
9124
- log.debug(`[worktree:rm] done name=${name}: ${result.output}`);
9125
- wtCache = null;
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 wtPaths = await getWorktreePaths();
9131
- const wtDir = wtPaths.get(name);
9132
- if (wtDir) {
9133
- const env = await readEnvLocal(wtDir);
9134
- if (!env.PROFILE) {
9135
- log.info(`[worktree:open] initializing env for ${name}`);
9136
- await initWorktreeEnv(name, {
9137
- profile: config.profiles.default.name,
9138
- agent: "claude",
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 result = await sendPrompt(name, text, 0, preamble);
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
- const result = await mergeWorktree(name);
9169
- if (!result.ok)
9170
- return errorResponse(result.error, 422);
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 worktree = decodeURIComponent(req.params.worktree);
9209
- return server.upgrade(req, { data: { worktree, attached: false } }) ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
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(config)
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/send": {
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}/send`, () => apiSendPrompt(name, req));
10799
+ return catching(`POST /api/worktrees/${name}/close`, () => apiCloseWorktree(name));
9243
10800
  }
9244
10801
  },
9245
- "/api/worktrees/:name/merge": {
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}/merge`, () => apiMergeWorktree(name));
10807
+ return catching(`POST /api/worktrees/${name}/send`, () => apiSendPrompt(name, req));
9251
10808
  }
9252
10809
  },
9253
- "/api/worktrees/:name/status": {
9254
- GET: (req) => {
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(`GET /api/worktrees/${name}/status`, () => apiWorktreeStatus(name));
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: () => handleNotificationStream()
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
- return handleDismissNotification(id);
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 = join2(STATIC_DIR, rawPath);
9284
- const staticRoot = resolve(STATIC_DIR);
9285
- if (!resolve(filePath).startsWith(staticRoot + "/")) {
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(join2(STATIC_DIR, "index.html")), {
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 worktree=${ws.data.worktree}`);
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 { worktree } = ws.data;
10869
+ const { branch } = ws.data;
9311
10870
  switch (msg.type) {
9312
- case "input":
9313
- write(worktree, msg.data);
10871
+ case "input": {
10872
+ const worktreeId = getAttachedWorktreeId(ws);
10873
+ if (!worktreeId)
10874
+ return;
10875
+ write(worktreeId, msg.data);
9314
10876
  break;
9315
- case "sendKeys":
9316
- await sendKeys(worktree, msg.hexBytes);
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
- if (ws.data.attached) {
9320
- log.debug(`[ws] selectPane pane=${msg.pane} worktree=${worktree}`);
9321
- await selectPane(worktree, msg.pane);
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) worktree=${worktree} cols=${msg.cols} rows=${msg.rows}`);
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} worktree=${worktree}`);
10900
+ log.debug(`[ws] initialPane=${msg.initialPane} branch=${branch}`);
9331
10901
  }
9332
- await attach(worktree, msg.cols, msg.rows, msg.initialPane);
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(worktree, onData, onExit);
9335
- const scrollback = getScrollback(worktree);
9336
- log.debug(`[ws] attached worktree=${worktree} scrollback=${scrollback.length} bytes`);
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
- log.error(`[ws] attach failed worktree=${worktree}: ${errMsg}`);
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
- await resize(worktree, msg.cols, msg.rows);
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 worktree=${ws.data.worktree} attached=${ws.data.attached}`);
9354
- clearCallbacks(ws.data.worktree);
9355
- await detach(ws.data.worktree);
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(getWorktreePaths, config.linkedRepos, PROJECT_DIR, undefined, hasDashboardActivity);
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)) {