webmux 0.26.0 → 0.27.1

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.
@@ -6919,7 +6919,7 @@ var require_public_api = __commonJS((exports) => {
6919
6919
 
6920
6920
  // backend/src/server.ts
6921
6921
  import { randomUUID as randomUUID3 } from "crypto";
6922
- import { join as join7, resolve as resolve7 } from "path";
6922
+ import { join as join7, resolve as resolve8 } from "path";
6923
6923
  import { mkdirSync } from "fs";
6924
6924
  import { networkInterfaces } from "os";
6925
6925
 
@@ -7713,6 +7713,43 @@ function hasRecentDashboardActivity() {
7713
7713
  return Date.now() - lastActivityAt < ACTIVITY_TIMEOUT_MS;
7714
7714
  }
7715
7715
 
7716
+ // backend/src/services/archive-service.ts
7717
+ import { resolve as resolve2 } from "path";
7718
+
7719
+ // backend/src/domain/model.ts
7720
+ var WORKTREE_META_SCHEMA_VERSION = 1;
7721
+ var WORKTREE_ARCHIVE_STATE_VERSION = 1;
7722
+
7723
+ // backend/src/services/archive-service.ts
7724
+ function createArchiveState(entries) {
7725
+ return {
7726
+ schemaVersion: WORKTREE_ARCHIVE_STATE_VERSION,
7727
+ entries: [...entries].sort((left, right) => left.path.localeCompare(right.path))
7728
+ };
7729
+ }
7730
+ function normalizeArchivePath(path) {
7731
+ return resolve2(path);
7732
+ }
7733
+ function buildArchivedWorktreePathSet(state) {
7734
+ return new Set(state.entries.map((entry) => normalizeArchivePath(entry.path)));
7735
+ }
7736
+ function setArchivedWorktreeState(input) {
7737
+ const normalizedPath = normalizeArchivePath(input.path);
7738
+ const entries = input.state.entries.filter((entry) => normalizeArchivePath(entry.path) !== normalizedPath);
7739
+ if (!input.archived) {
7740
+ return createArchiveState(entries);
7741
+ }
7742
+ entries.push({
7743
+ path: normalizedPath,
7744
+ archivedAt: (input.now ?? (() => new Date))().toISOString()
7745
+ });
7746
+ return createArchiveState(entries);
7747
+ }
7748
+ function pruneArchivedWorktreeState(input) {
7749
+ const validPaths = new Set(input.paths.map((path) => normalizeArchivePath(path)));
7750
+ return createArchiveState(input.state.entries.filter((entry) => validPaths.has(normalizeArchivePath(entry.path))));
7751
+ }
7752
+
7716
7753
  // backend/src/services/linear-service.ts
7717
7754
  var ASSIGNED_ISSUES_QUERY = `
7718
7755
  query AssignedIssues {
@@ -8041,9 +8078,8 @@ async function createLinearIssue(input) {
8041
8078
  }
8042
8079
 
8043
8080
  // backend/src/services/lifecycle-service.ts
8044
- import { randomUUID as randomUUID2 } from "crypto";
8045
8081
  import { mkdir as mkdir4 } from "fs/promises";
8046
- import { dirname as dirname4, resolve as resolve5 } from "path";
8082
+ import { dirname as dirname4, resolve as resolve6 } from "path";
8047
8083
 
8048
8084
  // backend/src/adapters/agent-runtime.ts
8049
8085
  import { chmod as chmod2, mkdir as mkdir3 } from "fs/promises";
@@ -8102,6 +8138,9 @@ function getWorktreeStoragePaths(gitDir) {
8102
8138
  prsPath: join2(webmuxDir, "prs.json")
8103
8139
  };
8104
8140
  }
8141
+ function getProjectArchiveStatePath(gitDir) {
8142
+ return join2(gitDir, "webmux", "archive.json");
8143
+ }
8105
8144
  async function ensureWorktreeStorageDirs(gitDir) {
8106
8145
  const paths = getWorktreeStoragePaths(gitDir);
8107
8146
  await mkdir2(paths.webmuxDir, { recursive: true });
@@ -8120,6 +8159,36 @@ async function writeWorktreeMeta(gitDir, meta) {
8120
8159
  await Bun.write(metaPath, JSON.stringify(meta, null, 2) + `
8121
8160
  `);
8122
8161
  }
8162
+ function isArchivedWorktreeEntry(raw) {
8163
+ return isRecord2(raw) && typeof raw.path === "string" && typeof raw.archivedAt === "string";
8164
+ }
8165
+ function emptyWorktreeArchiveState() {
8166
+ return {
8167
+ schemaVersion: WORKTREE_ARCHIVE_STATE_VERSION,
8168
+ entries: []
8169
+ };
8170
+ }
8171
+ function isWorktreeArchiveState(raw) {
8172
+ return isRecord2(raw) && typeof raw.schemaVersion === "number" && Array.isArray(raw.entries) && raw.entries.every((entry) => isArchivedWorktreeEntry(entry));
8173
+ }
8174
+ async function readWorktreeArchiveState(gitDir) {
8175
+ const archivePath = getProjectArchiveStatePath(gitDir);
8176
+ try {
8177
+ const raw = await Bun.file(archivePath).json();
8178
+ return isWorktreeArchiveState(raw) ? {
8179
+ schemaVersion: raw.schemaVersion,
8180
+ entries: raw.entries.map((entry) => ({ ...entry }))
8181
+ } : emptyWorktreeArchiveState();
8182
+ } catch {
8183
+ return emptyWorktreeArchiveState();
8184
+ }
8185
+ }
8186
+ async function writeWorktreeArchiveState(gitDir, state) {
8187
+ const archivePath = getProjectArchiveStatePath(gitDir);
8188
+ await ensureWorktreeStorageDirs(gitDir);
8189
+ await Bun.write(archivePath, JSON.stringify(state, null, 2) + `
8190
+ `);
8191
+ }
8123
8192
  function buildRuntimeEnvMap(meta, extraEnv = {}, dotenvValues = {}) {
8124
8193
  return {
8125
8194
  ...dotenvValues,
@@ -8469,7 +8538,7 @@ async function ensureAgentRuntimeArtifacts(input) {
8469
8538
 
8470
8539
  // backend/src/adapters/tmux.ts
8471
8540
  import { createHash } from "crypto";
8472
- import { basename, resolve as resolve2 } from "path";
8541
+ import { basename, resolve as resolve3 } from "path";
8473
8542
  function runTmux(args) {
8474
8543
  const result = Bun.spawnSync(["tmux", ...args], {
8475
8544
  stdout: "pipe",
@@ -8497,7 +8566,7 @@ function sanitizeTmuxNameSegment(value, maxLength = 24) {
8497
8566
  return trimmed || "x";
8498
8567
  }
8499
8568
  function buildProjectSessionName(projectRoot2) {
8500
- const resolved = resolve2(projectRoot2);
8569
+ const resolved = resolve3(projectRoot2);
8501
8570
  const base = sanitizeTmuxNameSegment(basename(resolved), 18);
8502
8571
  const hash = createHash("sha1").update(resolved).digest("hex").slice(0, 8);
8503
8572
  return `wm-${base}-${hash}`;
@@ -8618,12 +8687,16 @@ function allocateServicePorts(existingMetas, services) {
8618
8687
  }
8619
8688
 
8620
8689
  // backend/src/services/agent-service.ts
8690
+ var DOCKER_PATH_FALLBACK = "/root/.local/bin:/usr/local/bin:/root/.bun/bin:/root/.cargo/bin";
8621
8691
  function quoteShell(value) {
8622
8692
  return `'${value.replaceAll("'", "'\\''")}'`;
8623
8693
  }
8624
8694
  function buildRuntimeBootstrap(runtimeEnvPath) {
8625
8695
  return `set -a; . ${quoteShell(runtimeEnvPath)}; set +a`;
8626
8696
  }
8697
+ function buildDockerRuntimeBootstrap(runtimeEnvPath) {
8698
+ return `${buildRuntimeBootstrap(runtimeEnvPath)}; export PATH="$PATH:${DOCKER_PATH_FALLBACK}"`;
8699
+ }
8627
8700
  function buildAgentInvocation(input) {
8628
8701
  if (input.agent === "codex") {
8629
8702
  const yoloFlag2 = input.yolo ? " --yolo" : "";
@@ -8646,11 +8719,11 @@ function buildAgentInvocation(input) {
8646
8719
  }
8647
8720
  return `claude${yoloFlag}${promptSuffix}`;
8648
8721
  }
8649
- function buildAgentCommand(input) {
8650
- return `${buildRuntimeBootstrap(input.runtimeEnvPath)}; ${buildAgentInvocation(input)}`;
8722
+ function buildAgentCommand(input, bootstrap = buildRuntimeBootstrap) {
8723
+ return `${bootstrap(input.runtimeEnvPath)}; ${buildAgentInvocation(input)}`;
8651
8724
  }
8652
8725
  function buildDockerExecCommand(containerName, worktreePath, command) {
8653
- return `docker exec -it -w ${quoteShell(worktreePath)} ${quoteShell(containerName)} bash -lc ${quoteShell(command)}`;
8726
+ return `docker exec -it -w ${quoteShell(worktreePath)} ${quoteShell(containerName)} /bin/sh -c ${quoteShell(command)}`;
8654
8727
  }
8655
8728
  function buildManagedShellCommand(runtimeEnvPath, shellPath = Bun.env.SHELL || "/bin/bash") {
8656
8729
  return `bash -lc ${quoteShell(`${buildRuntimeBootstrap(runtimeEnvPath)}; exec ${quoteShell(shellPath)} -i`)}`;
@@ -8658,15 +8731,15 @@ function buildManagedShellCommand(runtimeEnvPath, shellPath = Bun.env.SHELL || "
8658
8731
  function buildAgentPaneCommand(input) {
8659
8732
  return buildAgentCommand(input);
8660
8733
  }
8661
- function buildDockerShellCommand(containerName, worktreePath, runtimeEnvPath, shellPath = Bun.env.SHELL || "/bin/bash") {
8662
- return buildDockerExecCommand(containerName, worktreePath, `${buildRuntimeBootstrap(runtimeEnvPath)}; exec ${quoteShell(shellPath)} -i`);
8734
+ function buildDockerShellCommand(containerName, worktreePath, runtimeEnvPath, shellPath = "/bin/bash") {
8735
+ return buildDockerExecCommand(containerName, worktreePath, `${buildDockerRuntimeBootstrap(runtimeEnvPath)}; if [ -x ${quoteShell(shellPath)} ]; then exec ${quoteShell(shellPath)} -i; elif [ -x /bin/sh ]; then exec /bin/sh -i; else echo 'webmux: no shell found in container' >&2; exit 127; fi`);
8663
8736
  }
8664
8737
  function buildDockerAgentPaneCommand(input) {
8665
- return buildDockerExecCommand(input.containerName, input.worktreePath, buildAgentCommand(input));
8738
+ return buildAgentCommand(input, buildDockerRuntimeBootstrap);
8666
8739
  }
8667
8740
 
8668
8741
  // backend/src/services/session-service.ts
8669
- import { resolve as resolve3 } from "path";
8742
+ import { resolve as resolve4 } from "path";
8670
8743
  function quoteShell2(value) {
8671
8744
  return `'${value.replaceAll("'", "'\\''")}'`;
8672
8745
  }
@@ -8680,7 +8753,7 @@ function buildCommandPaneStartupCommand(template, ctx) {
8680
8753
  if (!template.workingDir) {
8681
8754
  return template.command;
8682
8755
  }
8683
- const workingDir = resolve3(resolvePaneCwd(template, ctx), template.workingDir);
8756
+ const workingDir = resolve4(resolvePaneCwd(template, ctx), template.workingDir);
8684
8757
  return `cd -- ${quoteShell2(workingDir)} && ${template.command}`;
8685
8758
  }
8686
8759
  function resolvePaneStartupCommand(template, ctx) {
@@ -8760,7 +8833,7 @@ import { randomUUID } from "crypto";
8760
8833
 
8761
8834
  // backend/src/adapters/git.ts
8762
8835
  import { readdirSync, rmSync, statSync } from "fs";
8763
- import { resolve as resolve4, join as join4 } from "path";
8836
+ import { resolve as resolve5, join as join4 } from "path";
8764
8837
  function runGit(args, cwd) {
8765
8838
  const result = Bun.spawnSync(["git", ...args], {
8766
8839
  cwd,
@@ -8794,8 +8867,8 @@ function errorMessage(error) {
8794
8867
  return error instanceof Error ? error.message : String(error);
8795
8868
  }
8796
8869
  function isRegisteredWorktree(entries, worktreePath) {
8797
- const resolvedPath = resolve4(worktreePath);
8798
- return entries.some((entry) => resolve4(entry.path) === resolvedPath);
8870
+ const resolvedPath = resolve5(worktreePath);
8871
+ return entries.some((entry) => resolve5(entry.path) === resolvedPath);
8799
8872
  }
8800
8873
  function removeDirectory(path) {
8801
8874
  rmSync(path, {
@@ -8819,7 +8892,7 @@ function currentCheckoutRef(cwd) {
8819
8892
  function resolveRepoRoot(dir) {
8820
8893
  const direct = tryRunGit(["rev-parse", "--show-toplevel"], dir);
8821
8894
  if (direct.ok)
8822
- return resolve4(dir, direct.stdout);
8895
+ return resolve5(dir, direct.stdout);
8823
8896
  let entries;
8824
8897
  try {
8825
8898
  entries = readdirSync(dir);
@@ -8836,17 +8909,17 @@ function resolveRepoRoot(dir) {
8836
8909
  }
8837
8910
  const childResult = tryRunGit(["rev-parse", "--show-toplevel"], child);
8838
8911
  if (childResult.ok)
8839
- return resolve4(child, childResult.stdout);
8912
+ return resolve5(child, childResult.stdout);
8840
8913
  }
8841
8914
  return null;
8842
8915
  }
8843
8916
  function resolveWorktreeRoot(cwd) {
8844
8917
  const output = runGit(["rev-parse", "--show-toplevel"], cwd);
8845
- return resolve4(cwd, output);
8918
+ return resolve5(cwd, output);
8846
8919
  }
8847
8920
  function resolveWorktreeGitDir(cwd) {
8848
8921
  const output = runGit(["rev-parse", "--git-dir"], cwd);
8849
- return resolve4(cwd, output);
8922
+ return resolve5(cwd, output);
8850
8923
  }
8851
8924
  function parseGitWorktreePorcelain(output) {
8852
8925
  const entries = [];
@@ -9057,9 +9130,6 @@ class BunGitGateway {
9057
9130
  }
9058
9131
  }
9059
9132
 
9060
- // backend/src/domain/model.ts
9061
- var WORKTREE_META_SCHEMA_VERSION = 1;
9062
-
9063
9133
  // backend/src/services/worktree-service.ts
9064
9134
  function toErrorMessage(error) {
9065
9135
  return error instanceof Error ? error.message : String(error);
@@ -9204,16 +9274,40 @@ function mergeManagedWorktree(opts, git = new BunGitGateway) {
9204
9274
  });
9205
9275
  }
9206
9276
 
9207
- // backend/src/services/lifecycle-service.ts
9208
- function generateBranchName() {
9277
+ // backend/src/lib/branch-name.ts
9278
+ import { randomUUID as randomUUID2 } from "crypto";
9279
+ function generateFallbackBranchName() {
9209
9280
  return `change-${randomUUID2().slice(0, 8)}`;
9210
9281
  }
9282
+
9283
+ // backend/src/services/lifecycle-service.ts
9284
+ var DOCKER_CONTROL_HOST = "host.docker.internal";
9211
9285
  function toErrorMessage2(error) {
9212
9286
  return error instanceof Error ? error.message : String(error);
9213
9287
  }
9214
9288
  function stringifyStartupEnvValue(value) {
9215
9289
  return typeof value === "boolean" ? String(value) : value;
9216
9290
  }
9291
+ function trimTrailingSlashes(value) {
9292
+ return value.replace(/\/+$/, "");
9293
+ }
9294
+ function isLoopbackHostname(hostname) {
9295
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]";
9296
+ }
9297
+ function buildRuntimeControlBaseUrl(controlBaseUrl, runtime) {
9298
+ const trimmed = trimTrailingSlashes(controlBaseUrl);
9299
+ if (runtime !== "docker")
9300
+ return trimmed;
9301
+ try {
9302
+ const url = new URL(trimmed);
9303
+ if (isLoopbackHostname(url.hostname)) {
9304
+ url.hostname = DOCKER_CONTROL_HOST;
9305
+ }
9306
+ return trimTrailingSlashes(url.toString());
9307
+ } catch {
9308
+ return trimmed;
9309
+ }
9310
+ }
9217
9311
  function prefixAgentBranch(agent, branch) {
9218
9312
  return `${agent}-${branch}`;
9219
9313
  }
@@ -9311,9 +9405,8 @@ class LifecycleService {
9311
9405
  }
9312
9406
  async closeWorktree(branch) {
9313
9407
  try {
9314
- const resolved = await this.resolveExistingWorktree(branch);
9315
- this.deps.tmux.killWindow(buildProjectSessionName(this.deps.projectRoot), buildWorktreeWindowName(branch));
9316
- await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
9408
+ await this.resolveExistingWorktree(branch);
9409
+ await this.closeBranchWindow(branch);
9317
9410
  } catch (error) {
9318
9411
  throw this.wrapOperationError(error);
9319
9412
  }
@@ -9358,6 +9451,17 @@ class LifecycleService {
9358
9451
  throw this.wrapOperationError(error);
9359
9452
  }
9360
9453
  }
9454
+ async setWorktreeArchived(branch, archived) {
9455
+ try {
9456
+ const resolved = await this.resolveExistingWorktree(branch);
9457
+ if (archived) {
9458
+ await this.closeBranchWindow(branch);
9459
+ }
9460
+ await this.updateWorktreeArchivedState(resolved.entry.path, archived);
9461
+ } catch (error) {
9462
+ throw this.wrapOperationError(error);
9463
+ }
9464
+ }
9361
9465
  listAvailableBranches(options = {}) {
9362
9466
  const localBranches = this.listLocalBranches().filter((branch) => isValidBranchName(branch));
9363
9467
  const remoteBranches = options.includeRemote ? this.listRemoteBranches().filter((branch) => isValidBranchName(branch)) : [];
@@ -9370,7 +9474,7 @@ class LifecycleService {
9370
9474
  }
9371
9475
  async resolveBranch(rawBranch, prompt, mode) {
9372
9476
  const explicitBranch = rawBranch?.trim();
9373
- const branch = mode === "existing" ? explicitBranch : explicitBranch || await this.generateAutoName(prompt) || generateBranchName();
9477
+ const branch = mode === "existing" ? explicitBranch : explicitBranch || await this.generateAutoName(prompt) || generateFallbackBranchName();
9374
9478
  if (!branch) {
9375
9479
  throw new LifecycleError("Existing branch is required", 400);
9376
9480
  }
@@ -9442,20 +9546,20 @@ class LifecycleService {
9442
9546
  return allocateServicePorts(metas, this.deps.config.services);
9443
9547
  }
9444
9548
  resolveWorktreePath(branch) {
9445
- return resolve5(this.deps.projectRoot, this.deps.config.workspace.worktreeRoot, branch);
9549
+ return resolve6(this.deps.projectRoot, this.deps.config.workspace.worktreeRoot, branch);
9446
9550
  }
9447
9551
  listLocalBranches() {
9448
- return this.deps.git.listLocalBranches(resolve5(this.deps.projectRoot));
9552
+ return this.deps.git.listLocalBranches(resolve6(this.deps.projectRoot));
9449
9553
  }
9450
9554
  listRemoteBranches() {
9451
- return this.deps.git.listRemoteBranches(resolve5(this.deps.projectRoot));
9555
+ return this.deps.git.listRemoteBranches(resolve6(this.deps.projectRoot));
9452
9556
  }
9453
9557
  listCheckedOutBranches() {
9454
- return new Set(this.deps.git.listWorktrees(resolve5(this.deps.projectRoot)).filter((entry) => !entry.bare && entry.branch !== null).map((entry) => entry.branch));
9558
+ return new Set(this.deps.git.listWorktrees(resolve6(this.deps.projectRoot)).filter((entry) => !entry.bare && entry.branch !== null).map((entry) => entry.branch));
9455
9559
  }
9456
9560
  listProjectWorktrees() {
9457
- const projectRoot2 = resolve5(this.deps.projectRoot);
9458
- return this.deps.git.listWorktrees(projectRoot2).filter((entry) => !entry.bare && resolve5(entry.path) !== projectRoot2);
9561
+ const projectRoot2 = resolve6(this.deps.projectRoot);
9562
+ return this.deps.git.listWorktrees(projectRoot2).filter((entry) => !entry.bare && resolve6(entry.path) !== projectRoot2);
9459
9563
  }
9460
9564
  async readManagedMetas() {
9461
9565
  const metas = await Promise.all(this.listProjectWorktrees().map(async (entry) => {
@@ -9494,7 +9598,7 @@ class LifecycleService {
9494
9598
  allocatedPorts: await this.allocatePorts(),
9495
9599
  runtimeEnvExtras: { WEBMUX_WORKTREE_PATH: resolved.entry.path },
9496
9600
  dotenvValues,
9497
- controlUrl: this.controlUrl(),
9601
+ controlUrl: this.controlUrl(profile.runtime),
9498
9602
  controlToken: await this.deps.getControlToken()
9499
9603
  });
9500
9604
  }
@@ -9515,7 +9619,7 @@ class LifecycleService {
9515
9619
  }, dotenvValues);
9516
9620
  await writeRuntimeEnv(input.gitDir, runtimeEnv);
9517
9621
  const controlEnv = buildControlEnvMap({
9518
- controlUrl: this.controlUrl(),
9622
+ controlUrl: this.controlUrl(input.meta.runtime),
9519
9623
  controlToken: await this.deps.getControlToken(),
9520
9624
  worktreeId: input.meta.worktreeId,
9521
9625
  branch: input.meta.branch
@@ -9528,6 +9632,13 @@ class LifecycleService {
9528
9632
  controlEnv
9529
9633
  };
9530
9634
  }
9635
+ async updateWorktreeArchivedState(path, archived) {
9636
+ await this.deps.archiveState.setArchived(path, archived);
9637
+ }
9638
+ async closeBranchWindow(branch) {
9639
+ this.deps.tmux.killWindow(buildProjectSessionName(this.deps.projectRoot), buildWorktreeWindowName(branch));
9640
+ await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
9641
+ }
9531
9642
  async materializeRuntimeSession(input) {
9532
9643
  if (input.profile.runtime === "docker") {
9533
9644
  const dockerProfile = this.requireDockerProfile(input.profile);
@@ -9570,8 +9681,6 @@ class LifecycleService {
9570
9681
  paneCommands: containerName ? {
9571
9682
  agent: buildDockerAgentPaneCommand({
9572
9683
  agent: input.agent,
9573
- containerName,
9574
- worktreePath: input.worktreePath,
9575
9684
  runtimeEnvPath: input.initialized.paths.runtimeEnvPath,
9576
9685
  yolo: input.profile.yolo === true,
9577
9686
  systemPrompt,
@@ -9632,8 +9741,8 @@ class LifecycleService {
9632
9741
  throw new LifecycleError(`Worktree has uncommitted changes: ${entry.branch ?? entry.path}`, 409);
9633
9742
  }
9634
9743
  }
9635
- controlUrl() {
9636
- return `${this.deps.controlBaseUrl.replace(/\/+$/, "")}/api/runtime/events`;
9744
+ controlUrl(runtime) {
9745
+ return `${buildRuntimeControlBaseUrl(this.deps.controlBaseUrl, runtime)}/api/runtime/events`;
9637
9746
  }
9638
9747
  async removeResolvedWorktree(resolved) {
9639
9748
  await this.runLifecycleHook({
@@ -9655,6 +9764,7 @@ class LifecycleService {
9655
9764
  deleteBranch: true,
9656
9765
  deleteBranchForce: true
9657
9766
  }, this.deps.git);
9767
+ await this.updateWorktreeArchivedState(resolved.entry.path, false);
9658
9768
  await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
9659
9769
  }
9660
9770
  async runLifecycleHook(input) {
@@ -9735,7 +9845,7 @@ class LifecycleService {
9735
9845
  startupEnvValues: await this.buildStartupEnvValues(input.envOverrides),
9736
9846
  allocatedPorts: await this.allocatePorts(),
9737
9847
  runtimeEnvExtras: { WEBMUX_WORKTREE_PATH: worktreePath },
9738
- controlUrl: this.controlUrl(),
9848
+ controlUrl: this.controlUrl(profile.runtime),
9739
9849
  controlToken: await this.deps.getControlToken(),
9740
9850
  deleteBranchOnRollback
9741
9851
  }, {
@@ -10440,12 +10550,13 @@ function mapCreationSnapshot(creating) {
10440
10550
  phase: creating.phase
10441
10551
  } : null;
10442
10552
  }
10443
- function mapWorktreeSnapshot(state, now, creating, findLinearIssue) {
10553
+ function mapWorktreeSnapshot(state, now, creating, isArchived, findLinearIssue) {
10444
10554
  return {
10445
10555
  branch: state.branch,
10446
10556
  ...state.baseBranch ? { baseBranch: state.baseBranch } : {},
10447
10557
  path: state.path,
10448
10558
  dir: state.path,
10559
+ archived: isArchived(state.path),
10449
10560
  profile: state.profile,
10450
10561
  agentName: state.agentName,
10451
10562
  mux: state.session.exists,
@@ -10460,12 +10571,13 @@ function mapWorktreeSnapshot(state, now, creating, findLinearIssue) {
10460
10571
  creation: mapCreationSnapshot(creating)
10461
10572
  };
10462
10573
  }
10463
- function mapCreatingWorktreeSnapshot(creating, findLinearIssue) {
10574
+ function mapCreatingWorktreeSnapshot(creating, isArchived, findLinearIssue) {
10464
10575
  return {
10465
10576
  branch: creating.branch,
10466
10577
  ...creating.baseBranch ? { baseBranch: creating.baseBranch } : {},
10467
10578
  path: creating.path,
10468
10579
  dir: creating.path,
10580
+ archived: isArchived(creating.path),
10469
10581
  profile: creating.profile,
10470
10582
  agentName: creating.agentName,
10471
10583
  mux: false,
@@ -10482,14 +10594,15 @@ function mapCreatingWorktreeSnapshot(creating, findLinearIssue) {
10482
10594
  }
10483
10595
  function buildProjectSnapshot(input) {
10484
10596
  const now = input.now ?? (() => new Date);
10597
+ const isArchived = input.isArchived ?? (() => false);
10485
10598
  const creatingWorktrees = input.creatingWorktrees ?? [];
10486
10599
  const creatingByBranch = new Map(creatingWorktrees.map((worktree) => [worktree.branch, worktree]));
10487
10600
  const runtimeWorktrees = input.runtime.listWorktrees();
10488
10601
  const runtimeBranches = new Set(runtimeWorktrees.map((worktree) => worktree.branch));
10489
- const worktrees = runtimeWorktrees.map((state) => mapWorktreeSnapshot(state, now, creatingByBranch.get(state.branch) ?? null, input.findLinearIssue));
10602
+ const worktrees = runtimeWorktrees.map((state) => mapWorktreeSnapshot(state, now, creatingByBranch.get(state.branch) ?? null, isArchived, input.findLinearIssue));
10490
10603
  for (const creating of creatingWorktrees) {
10491
10604
  if (!runtimeBranches.has(creating.branch)) {
10492
- worktrees.push(mapCreatingWorktreeSnapshot(creating, input.findLinearIssue));
10605
+ worktrees.push(mapCreatingWorktreeSnapshot(creating, isArchived, input.findLinearIssue));
10493
10606
  }
10494
10607
  }
10495
10608
  worktrees.sort((left, right) => left.branch.localeCompare(right.branch));
@@ -10867,7 +10980,7 @@ class BunPortProbe {
10867
10980
  this.hostnames = hostnames;
10868
10981
  }
10869
10982
  isListening(port) {
10870
- return new Promise((resolve6) => {
10983
+ return new Promise((resolve7) => {
10871
10984
  let settled = false;
10872
10985
  let pending = this.hostnames.length;
10873
10986
  const settle = (result) => {
@@ -10876,20 +10989,20 @@ class BunPortProbe {
10876
10989
  if (result) {
10877
10990
  settled = true;
10878
10991
  clearTimeout(timer);
10879
- resolve6(true);
10992
+ resolve7(true);
10880
10993
  return;
10881
10994
  }
10882
10995
  pending--;
10883
10996
  if (pending === 0) {
10884
10997
  settled = true;
10885
10998
  clearTimeout(timer);
10886
- resolve6(false);
10999
+ resolve7(false);
10887
11000
  }
10888
11001
  };
10889
11002
  const timer = setTimeout(() => {
10890
11003
  if (!settled) {
10891
11004
  settled = true;
10892
- resolve6(false);
11005
+ resolve7(false);
10893
11006
  }
10894
11007
  }, this.timeoutMs);
10895
11008
  for (const hostname of this.hostnames) {
@@ -10915,6 +11028,7 @@ class BunPortProbe {
10915
11028
  // backend/src/services/auto-name-service.ts
10916
11029
  var MAX_BRANCH_LENGTH = 40;
10917
11030
  var DEFAULT_AUTO_NAME_MODEL = "claude-haiku-4-5-20251001";
11031
+ var AUTO_NAME_TIMEOUT_MS = 1e4;
10918
11032
  var DEFAULT_SYSTEM_PROMPT = [
10919
11033
  "Generate a concise git branch name from the task description.",
10920
11034
  "Return only the branch name.",
@@ -10945,17 +11059,52 @@ function normalizeGeneratedBranchName(raw) {
10945
11059
  function getSystemPrompt(config) {
10946
11060
  return config.systemPrompt?.trim() || DEFAULT_SYSTEM_PROMPT;
10947
11061
  }
10948
- async function defaultSpawn(args) {
11062
+
11063
+ class AutoNameTimeoutError extends Error {
11064
+ timeoutMs;
11065
+ constructor(timeoutMs) {
11066
+ super(`Auto-name timed out after ${timeoutMs}ms`);
11067
+ this.timeoutMs = timeoutMs;
11068
+ }
11069
+ }
11070
+ async function defaultSpawn(args, options = {}) {
10949
11071
  const proc = Bun.spawn(args, {
10950
11072
  stdout: "pipe",
10951
11073
  stderr: "pipe"
10952
11074
  });
10953
- const [stdout, stderr, exitCode] = await Promise.all([
11075
+ const resultPromise = Promise.all([
10954
11076
  new Response(proc.stdout).text(),
10955
11077
  new Response(proc.stderr).text(),
10956
11078
  proc.exited
10957
- ]);
10958
- return { exitCode, stdout, stderr };
11079
+ ]).then(([stdout, stderr, exitCode]) => ({ exitCode, stdout, stderr }));
11080
+ if (options.timeoutMs === undefined) {
11081
+ return await resultPromise;
11082
+ }
11083
+ return await new Promise((resolve7, reject) => {
11084
+ let settled = false;
11085
+ const timeoutId = setTimeout(() => {
11086
+ if (settled)
11087
+ return;
11088
+ settled = true;
11089
+ try {
11090
+ proc.kill("SIGKILL");
11091
+ } catch {}
11092
+ reject(new AutoNameTimeoutError(options.timeoutMs));
11093
+ }, options.timeoutMs);
11094
+ resultPromise.then((result) => {
11095
+ if (settled)
11096
+ return;
11097
+ settled = true;
11098
+ clearTimeout(timeoutId);
11099
+ resolve7(result);
11100
+ }, (error) => {
11101
+ if (settled)
11102
+ return;
11103
+ settled = true;
11104
+ clearTimeout(timeoutId);
11105
+ reject(error);
11106
+ });
11107
+ });
10959
11108
  }
10960
11109
  function buildClaudeArgs(model, systemPrompt, prompt) {
10961
11110
  const args = [
@@ -10997,8 +11146,10 @@ function buildCodexArgs(model, systemPrompt, prompt) {
10997
11146
 
10998
11147
  class AutoNameService {
10999
11148
  spawnImpl;
11149
+ timeoutMs;
11000
11150
  constructor(deps = {}) {
11001
11151
  this.spawnImpl = deps.spawnImpl ?? defaultSpawn;
11152
+ this.timeoutMs = deps.timeoutMs ?? AUTO_NAME_TIMEOUT_MS;
11002
11153
  }
11003
11154
  async generateBranchName(config, task) {
11004
11155
  const prompt = task.trim();
@@ -11011,8 +11162,13 @@ class AutoNameService {
11011
11162
  const cli = config.provider === "claude" ? "claude" : "codex";
11012
11163
  let result;
11013
11164
  try {
11014
- result = await this.spawnImpl(args);
11015
- } catch {
11165
+ result = await this.spawnImpl(args, { timeoutMs: this.timeoutMs });
11166
+ } catch (error) {
11167
+ if (error instanceof AutoNameTimeoutError) {
11168
+ const fallback = generateFallbackBranchName();
11169
+ log.warn(`[auto-name] ${cli} timed out after ${this.timeoutMs}ms; using fallback branch ${fallback}`);
11170
+ return fallback;
11171
+ }
11016
11172
  throw new Error(`'${cli}' CLI not found. Install it or check your PATH.`);
11017
11173
  }
11018
11174
  if (result.exitCode !== 0) {
@@ -11030,6 +11186,66 @@ class AutoNameService {
11030
11186
  }
11031
11187
  }
11032
11188
 
11189
+ // backend/src/services/archive-state-service.ts
11190
+ function archiveStatesEqual(left, right) {
11191
+ if (left.schemaVersion !== right.schemaVersion)
11192
+ return false;
11193
+ if (left.entries.length !== right.entries.length)
11194
+ return false;
11195
+ return left.entries.every((entry, index) => entry.path === right.entries[index]?.path && entry.archivedAt === right.entries[index]?.archivedAt);
11196
+ }
11197
+
11198
+ class ArchiveStateService {
11199
+ gitDir;
11200
+ mutationQueue = Promise.resolve();
11201
+ readState;
11202
+ writeState;
11203
+ constructor(gitDir, deps = {}) {
11204
+ this.gitDir = gitDir;
11205
+ this.readState = deps.readState ?? readWorktreeArchiveState;
11206
+ this.writeState = deps.writeState ?? writeWorktreeArchiveState;
11207
+ }
11208
+ async read() {
11209
+ return await this.readState(this.gitDir);
11210
+ }
11211
+ async setArchived(path, archived) {
11212
+ return await this.mutate((state) => setArchivedWorktreeState({
11213
+ state,
11214
+ path,
11215
+ archived
11216
+ }));
11217
+ }
11218
+ async prune(paths) {
11219
+ return await this.mutate((state) => pruneArchivedWorktreeState({
11220
+ state,
11221
+ paths
11222
+ }));
11223
+ }
11224
+ async mutate(transform) {
11225
+ return await this.withMutationLock(async () => {
11226
+ const state = await this.readState(this.gitDir);
11227
+ const nextState = await transform(state);
11228
+ if (!archiveStatesEqual(state, nextState)) {
11229
+ await this.writeState(this.gitDir, nextState);
11230
+ }
11231
+ return nextState;
11232
+ });
11233
+ }
11234
+ async withMutationLock(operation) {
11235
+ const previous = this.mutationQueue;
11236
+ let release = () => {};
11237
+ this.mutationQueue = new Promise((resolve7) => {
11238
+ release = resolve7;
11239
+ });
11240
+ await previous.catch(() => {});
11241
+ try {
11242
+ return await operation();
11243
+ } finally {
11244
+ release();
11245
+ }
11246
+ }
11247
+ }
11248
+
11033
11249
  // backend/src/services/notification-service.ts
11034
11250
  function eventToNotificationInput(event) {
11035
11251
  switch (event.type) {
@@ -11294,9 +11510,9 @@ class ProjectRuntime {
11294
11510
  }
11295
11511
 
11296
11512
  // backend/src/services/reconciliation-service.ts
11297
- import { basename as basename2, resolve as resolve6 } from "path";
11513
+ import { basename as basename2, resolve as resolve7 } from "path";
11298
11514
  function makeUnmanagedWorktreeId(path) {
11299
- return `unmanaged:${resolve6(path)}`;
11515
+ return `unmanaged:${resolve7(path)}`;
11300
11516
  }
11301
11517
  function isValidPort2(port) {
11302
11518
  return port !== null && Number.isInteger(port) && port >= 1 && port <= 65535;
@@ -11353,7 +11569,7 @@ class ReconciliationService {
11353
11569
  if (!options.force && this.now() - this.lastReconciledAt < this.freshnessMs) {
11354
11570
  return;
11355
11571
  }
11356
- const normalizedRepoRoot = resolve6(repoRoot);
11572
+ const normalizedRepoRoot = resolve7(repoRoot);
11357
11573
  const reconcilePromise = this.runReconcile(normalizedRepoRoot).then(() => {
11358
11574
  this.lastReconciledAt = this.now();
11359
11575
  });
@@ -11372,7 +11588,7 @@ class ReconciliationService {
11372
11588
  windows = [];
11373
11589
  }
11374
11590
  const seenWorktreeIds = new Set;
11375
- const candidateEntries = worktrees.filter((entry) => !entry.bare && resolve6(entry.path) !== normalizedRepoRoot);
11591
+ const candidateEntries = worktrees.filter((entry) => !entry.bare && resolve7(entry.path) !== normalizedRepoRoot);
11376
11592
  const reconciledStates = await mapWithConcurrency(candidateEntries, this.concurrency, async (entry) => {
11377
11593
  const gitDir = this.deps.git.resolveWorktreeGitDir(entry.path);
11378
11594
  const meta = await readWorktreeMeta(gitDir);
@@ -11475,6 +11691,7 @@ function createWebmuxRuntime(options = {}) {
11475
11691
  const projectDir = projectRoot(options.projectDir ?? Bun.env.WEBMUX_PROJECT_DIR ?? process.cwd());
11476
11692
  const config = loadConfig(projectDir, { resolvedRoot: true });
11477
11693
  const git = new BunGitGateway;
11694
+ const archiveStateService = new ArchiveStateService(git.resolveWorktreeGitDir(projectDir));
11478
11695
  const portProbe = new BunPortProbe;
11479
11696
  const tmux = new BunTmuxGateway;
11480
11697
  const docker = new BunDockerGateway;
@@ -11495,6 +11712,7 @@ function createWebmuxRuntime(options = {}) {
11495
11712
  controlBaseUrl: `http://127.0.0.1:${port}`,
11496
11713
  getControlToken: loadControlToken,
11497
11714
  config,
11715
+ archiveState: archiveStateService,
11498
11716
  git,
11499
11717
  tmux,
11500
11718
  docker,
@@ -11513,6 +11731,7 @@ function createWebmuxRuntime(options = {}) {
11513
11731
  port,
11514
11732
  projectDir,
11515
11733
  config,
11734
+ archiveStateService,
11516
11735
  git,
11517
11736
  portProbe,
11518
11737
  tmux,
@@ -11537,6 +11756,7 @@ var runtime = createWebmuxRuntime({
11537
11756
  var PROJECT_DIR = runtime.projectDir;
11538
11757
  var config = runtime.config;
11539
11758
  var git = runtime.git;
11759
+ var archiveStateService = runtime.archiveStateService;
11540
11760
  var tmux = runtime.tmux;
11541
11761
  var projectRuntime = runtime.projectRuntime;
11542
11762
  var worktreeCreationTracker = runtime.worktreeCreationTracker;
@@ -11594,7 +11814,7 @@ function getFrontendConfig() {
11594
11814
  startupEnvs: config.startupEnvs,
11595
11815
  linkedRepos: config.integrations.github.linkedRepos.map((lr) => ({
11596
11816
  alias: lr.alias,
11597
- ...lr.dir ? { dir: resolve7(PROJECT_DIR, lr.dir) } : {}
11817
+ ...lr.dir ? { dir: resolve8(PROJECT_DIR, lr.dir) } : {}
11598
11818
  })),
11599
11819
  linearAutoCreateWorktrees: linearAutoCreateEnabled,
11600
11820
  autoRemoveOnMerge: autoRemoveOnMergeEnabled,
@@ -11725,9 +11945,9 @@ async function hasValidControlToken(req) {
11725
11945
  }
11726
11946
  async function getWorktreeGitDirs() {
11727
11947
  const gitDirs = new Map;
11728
- const projectRoot2 = resolve7(PROJECT_DIR);
11948
+ const projectRoot2 = resolve8(PROJECT_DIR);
11729
11949
  for (const entry of git.listWorktrees(projectRoot2)) {
11730
- if (entry.bare || resolve7(entry.path) === projectRoot2 || !entry.branch)
11950
+ if (entry.bare || resolve8(entry.path) === projectRoot2 || !entry.branch)
11731
11951
  continue;
11732
11952
  gitDirs.set(entry.branch, git.resolveWorktreeGitDir(entry.path));
11733
11953
  }
@@ -11749,10 +11969,10 @@ async function apiGetProject() {
11749
11969
  touchDashboardActivity();
11750
11970
  const linearApiKey = Bun.env.LINEAR_API_KEY;
11751
11971
  const linearIssuesPromise = config.integrations.linear.enabled && linearApiKey?.trim() ? fetchAssignedIssues() : Promise.resolve({ ok: true, data: [] });
11752
- const [, linearResult] = await Promise.all([
11753
- reconciliationService.reconcile(PROJECT_DIR),
11754
- linearIssuesPromise
11755
- ]);
11972
+ await reconciliationService.reconcile(PROJECT_DIR);
11973
+ const archiveState = await archiveStateService.prune(projectRuntime.listWorktrees().map((worktree) => worktree.path));
11974
+ const linearResult = await linearIssuesPromise;
11975
+ const archivedPaths = buildArchivedWorktreePathSet(archiveState);
11756
11976
  const linearIssues = linearResult.ok ? linearResult.data : [];
11757
11977
  return jsonResponse(buildProjectSnapshot({
11758
11978
  projectName: config.name,
@@ -11760,6 +11980,7 @@ async function apiGetProject() {
11760
11980
  runtime: projectRuntime,
11761
11981
  creatingWorktrees: worktreeCreationTracker.list(),
11762
11982
  notifications: runtimeNotifications.list(),
11983
+ isArchived: (path) => archivedPaths.has(normalizeArchivePath(path)),
11763
11984
  findLinearIssue: (branch) => {
11764
11985
  const match = linearIssues.find((issue) => branchMatchesIssue(branch, issue.branchName));
11765
11986
  return match ? {
@@ -11937,6 +12158,21 @@ async function apiCloseWorktree(name) {
11937
12158
  log.debug(`[worktree:close] done name=${name}`);
11938
12159
  return jsonResponse({ ok: true });
11939
12160
  }
12161
+ async function apiSetWorktreeArchived(name, req) {
12162
+ ensureBranchNotBusy(name);
12163
+ const raw = await req.json();
12164
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
12165
+ return errorResponse("Invalid request body", 400);
12166
+ }
12167
+ const body = raw;
12168
+ if (typeof body.archived !== "boolean") {
12169
+ return errorResponse("Missing boolean 'archived' field", 400);
12170
+ }
12171
+ log.info(`[worktree:archive] name=${name} archived=${body.archived}`);
12172
+ await lifecycleService.setWorktreeArchived(name, body.archived);
12173
+ log.debug(`[worktree:archive] done name=${name} archived=${body.archived}`);
12174
+ return jsonResponse({ ok: true, archived: body.archived });
12175
+ }
11940
12176
  async function apiSendPrompt(name, req) {
11941
12177
  ensureBranchNotBusy(name);
11942
12178
  const raw = await req.json();
@@ -12009,7 +12245,7 @@ async function apiPullMain(req) {
12009
12245
  return errorResponse(`Unknown linked repo: ${repo}`, 404);
12010
12246
  if (!linkedRepo.dir)
12011
12247
  return errorResponse(`Linked repo "${repo}" has no dir configured`, 400);
12012
- const resolvedDir = resolve7(PROJECT_DIR, linkedRepo.dir);
12248
+ const resolvedDir = resolve8(PROJECT_DIR, linkedRepo.dir);
12013
12249
  const repoRoot = git.resolveRepoRoot(resolvedDir);
12014
12250
  if (!repoRoot)
12015
12251
  return errorResponse(`Linked repo "${repo}" dir is not a git repository: ${resolvedDir}`, 400);
@@ -12102,7 +12338,7 @@ async function apiUploadFiles(name, req) {
12102
12338
  }
12103
12339
  const safeName = `${Date.now()}_${sanitizeFilename(entry.name)}`;
12104
12340
  const destPath = join7(uploadDir, safeName);
12105
- if (!resolve7(destPath).startsWith(uploadDir + "/")) {
12341
+ if (!resolve8(destPath).startsWith(uploadDir + "/")) {
12106
12342
  return errorResponse("Invalid filename", 400);
12107
12343
  }
12108
12344
  await Bun.write(destPath, entry);
@@ -12169,6 +12405,14 @@ Bun.serve({
12169
12405
  return catching(`POST /api/worktrees/${name}/close`, () => apiCloseWorktree(name));
12170
12406
  }
12171
12407
  },
12408
+ "/api/worktrees/:name/archive": {
12409
+ PUT: (req) => {
12410
+ const name = decodeURIComponent(req.params.name);
12411
+ if (!isValidWorktreeName(name))
12412
+ return errorResponse("Invalid worktree name", 400);
12413
+ return catching(`PUT /api/worktrees/${name}/archive`, () => apiSetWorktreeArchived(name, req));
12414
+ }
12415
+ },
12172
12416
  "/api/worktrees/:name/send": {
12173
12417
  POST: (req) => {
12174
12418
  const name = decodeURIComponent(req.params.name);
@@ -12235,8 +12479,8 @@ Bun.serve({
12235
12479
  const url = new URL(req.url);
12236
12480
  const rawPath = url.pathname === "/" ? "index.html" : url.pathname;
12237
12481
  const filePath = join7(STATIC_DIR, rawPath);
12238
- const staticRoot = resolve7(STATIC_DIR);
12239
- if (!resolve7(filePath).startsWith(staticRoot + "/")) {
12482
+ const staticRoot = resolve8(STATIC_DIR);
12483
+ if (!resolve8(filePath).startsWith(staticRoot + "/")) {
12240
12484
  return new Response("Forbidden", { status: 403 });
12241
12485
  }
12242
12486
  const file = Bun.file(filePath);