webmux 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6959,13 +6959,13 @@ var require_public_api = __commonJS((exports) => {
6959
6959
 
6960
6960
  // backend/src/server.ts
6961
6961
  import { randomUUID as randomUUID4 } from "crypto";
6962
- import { join as join12, resolve as resolve9 } from "path";
6962
+ import { join as join12, resolve as resolve10 } from "path";
6963
6963
  import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "fs";
6964
6964
  import { networkInterfaces } from "os";
6965
6965
  // package.json
6966
6966
  var package_default = {
6967
6967
  name: "webmux",
6968
- version: "0.39.0",
6968
+ version: "0.40.0",
6969
6969
  description: "Web dashboard for workmux \u2014 browser UI with embedded terminals, PR monitoring, and CI integration",
6970
6970
  type: "module",
6971
6971
  repository: {
@@ -10994,7 +10994,7 @@ var coerce = {
10994
10994
  date: (arg) => ZodDate.create({ ...arg, coerce: true })
10995
10995
  };
10996
10996
  var NEVER = INVALID;
10997
- // node_modules/.bun/@ts-rest+core@3.52.1+f5aac5c7d31a6dcb/node_modules/@ts-rest/core/index.esm.mjs
10997
+ // node_modules/.bun/@ts-rest+core@3.52.1+aea371d0c1c4fd29/node_modules/@ts-rest/core/index.esm.mjs
10998
10998
  var isZodObjectStrict = (obj) => {
10999
10999
  return typeof (obj === null || obj === undefined ? undefined : obj.passthrough) === "function";
11000
11000
  };
@@ -12395,6 +12395,7 @@ import { join } from "path";
12395
12395
  // backend/src/domain/model.ts
12396
12396
  var WORKTREE_META_SCHEMA_VERSION = 1;
12397
12397
  var WORKTREE_ARCHIVE_STATE_VERSION = 1;
12398
+ var OPEN_SESSIONS_STATE_VERSION = 1;
12398
12399
  var ROOT_TAB_ID = "root";
12399
12400
  function conversationSessionId(conversation) {
12400
12401
  if (!conversation)
@@ -12456,6 +12457,9 @@ function getWorktreeStoragePaths(gitDir) {
12456
12457
  function getProjectArchiveStatePath(gitDir) {
12457
12458
  return join(gitDir, "webmux", "archive.json");
12458
12459
  }
12460
+ function getProjectOpenSessionsStatePath(gitDir) {
12461
+ return join(gitDir, "webmux", "open-sessions.json");
12462
+ }
12459
12463
  async function ensureWorktreeStorageDirs(gitDir) {
12460
12464
  const paths = getWorktreeStoragePaths(gitDir);
12461
12465
  await mkdir2(paths.webmuxDir, { recursive: true });
@@ -12505,6 +12509,12 @@ async function writeWorktreeArchiveState(gitDir, state) {
12505
12509
  await Bun.write(archivePath, JSON.stringify(state, null, 2) + `
12506
12510
  `);
12507
12511
  }
12512
+ async function writeOpenSessionsState(gitDir, state) {
12513
+ const statePath = getProjectOpenSessionsStatePath(gitDir);
12514
+ await ensureWorktreeStorageDirs(gitDir);
12515
+ await Bun.write(statePath, JSON.stringify(state, null, 2) + `
12516
+ `);
12517
+ }
12508
12518
  function buildRuntimeEnvMap(meta, extraEnv = {}, dotenvValues = {}) {
12509
12519
  return {
12510
12520
  ...dotenvValues,
@@ -18304,9 +18314,13 @@ class LifecycleService {
18304
18314
  async pruneWorktrees() {
18305
18315
  try {
18306
18316
  const resolvedWorktrees = await this.resolveAllWorktrees();
18317
+ const sessionName = buildProjectSessionName(this.deps.projectRoot);
18307
18318
  const removedBranches = [];
18308
18319
  for (const resolved of resolvedWorktrees) {
18309
18320
  const branch = resolved.entry.branch ?? resolved.entry.path;
18321
+ if (this.deps.tmux.hasWindow(sessionName, buildWorktreeWindowName(branch))) {
18322
+ continue;
18323
+ }
18310
18324
  await this.removeResolvedWorktree(resolved);
18311
18325
  removedBranches.push(branch);
18312
18326
  }
@@ -19601,6 +19615,58 @@ function startAutoPullMonitor(deps, intervalMs) {
19601
19615
  return startSerializedInterval(run2, intervalMs);
19602
19616
  }
19603
19617
 
19618
+ // backend/src/services/session-restore-service.ts
19619
+ import { basename as basename5, resolve as resolve8 } from "path";
19620
+ function entryBranch(entry) {
19621
+ return entry.branch ?? basename5(entry.path);
19622
+ }
19623
+ function computeOpenBranches(input) {
19624
+ const resolvedProjectDir = resolve8(input.projectDir);
19625
+ const openWindowNames = new Set(input.windows.filter((window) => window.sessionName === input.sessionName).map((window) => window.windowName));
19626
+ return input.worktrees.filter((entry) => !entry.bare && resolve8(entry.path) !== resolvedProjectDir).map((entry) => entryBranch(entry)).filter((branch) => openWindowNames.has(buildWorktreeWindowName(branch))).sort((left, right) => left.localeCompare(right));
19627
+ }
19628
+ function buildOpenSessionsState(branches, savedAt) {
19629
+ return {
19630
+ schemaVersion: OPEN_SESSIONS_STATE_VERSION,
19631
+ savedAt: savedAt.toISOString(),
19632
+ branches
19633
+ };
19634
+ }
19635
+ var DEFAULT_SESSION_SNAPSHOT_INTERVAL_MS = 30000;
19636
+ async function saveOpenSessionsSnapshot(deps) {
19637
+ const projectRoot2 = resolve8(deps.projectRoot);
19638
+ const sessionName = buildProjectSessionName(projectRoot2);
19639
+ let windows = [];
19640
+ try {
19641
+ windows = deps.tmux.listWindows();
19642
+ } catch {
19643
+ windows = [];
19644
+ }
19645
+ const worktrees = deps.git.listLiveWorktrees(projectRoot2);
19646
+ const branches = computeOpenBranches({ worktrees, windows, sessionName, projectDir: projectRoot2 });
19647
+ if (branches.length === 0)
19648
+ return null;
19649
+ const now = deps.now ?? (() => new Date);
19650
+ const writeState = deps.writeState ?? writeOpenSessionsState;
19651
+ const gitDir = deps.git.resolveWorktreeGitDir(projectRoot2);
19652
+ await writeState(gitDir, buildOpenSessionsState(branches, now()));
19653
+ return branches;
19654
+ }
19655
+ function startSessionSnapshotMonitor(deps, intervalMs = DEFAULT_SESSION_SNAPSHOT_INTERVAL_MS) {
19656
+ log.info(`[session-snapshot] monitor started (interval: ${intervalMs}ms)`);
19657
+ const run2 = async () => {
19658
+ try {
19659
+ const branches = await saveOpenSessionsSnapshot(deps);
19660
+ if (branches) {
19661
+ log.debug(`[session-snapshot] saved ${branches.length} open session(s): ${branches.join(", ")}`);
19662
+ }
19663
+ } catch (error) {
19664
+ log.warn(`[session-snapshot] save failed: ${error instanceof Error ? error.message : String(error)}`);
19665
+ }
19666
+ };
19667
+ return startSerializedInterval(run2, intervalMs);
19668
+ }
19669
+
19604
19670
  // backend/src/services/agents-ui-service.ts
19605
19671
  function cloneConversationMeta(meta) {
19606
19672
  return meta ? { ...meta } : null;
@@ -21660,7 +21726,7 @@ class BunPortProbe {
21660
21726
  this.hostnames = hostnames;
21661
21727
  }
21662
21728
  isListening(port) {
21663
- return new Promise((resolve8) => {
21729
+ return new Promise((resolve9) => {
21664
21730
  let settled = false;
21665
21731
  let pending = this.hostnames.length;
21666
21732
  const settle = (result) => {
@@ -21669,20 +21735,20 @@ class BunPortProbe {
21669
21735
  if (result) {
21670
21736
  settled = true;
21671
21737
  clearTimeout(timer);
21672
- resolve8(true);
21738
+ resolve9(true);
21673
21739
  return;
21674
21740
  }
21675
21741
  pending--;
21676
21742
  if (pending === 0) {
21677
21743
  settled = true;
21678
21744
  clearTimeout(timer);
21679
- resolve8(false);
21745
+ resolve9(false);
21680
21746
  }
21681
21747
  };
21682
21748
  const timer = setTimeout(() => {
21683
21749
  if (!settled) {
21684
21750
  settled = true;
21685
- resolve8(false);
21751
+ resolve9(false);
21686
21752
  }
21687
21753
  }, this.timeoutMs);
21688
21754
  for (const hostname of this.hostnames) {
@@ -21832,8 +21898,8 @@ class ArchiveStateService {
21832
21898
  async withMutationLock(operation) {
21833
21899
  const previous = this.mutationQueue;
21834
21900
  let release = () => {};
21835
- this.mutationQueue = new Promise((resolve8) => {
21836
- release = resolve8;
21901
+ this.mutationQueue = new Promise((resolve9) => {
21902
+ release = resolve9;
21837
21903
  });
21838
21904
  await previous.catch(() => {});
21839
21905
  try {
@@ -22136,9 +22202,9 @@ class ProjectRuntime {
22136
22202
  }
22137
22203
 
22138
22204
  // backend/src/services/reconciliation-service.ts
22139
- import { basename as basename5, resolve as resolve8 } from "path";
22205
+ import { basename as basename6, resolve as resolve9 } from "path";
22140
22206
  function makeUnmanagedWorktreeId(path) {
22141
- return `unmanaged:${resolve8(path)}`;
22207
+ return `unmanaged:${resolve9(path)}`;
22142
22208
  }
22143
22209
  function isValidPort2(port) {
22144
22210
  return port !== null && Number.isInteger(port) && port >= 1 && port <= 65535;
@@ -22171,7 +22237,7 @@ function findWindow(windows, sessionName, branch) {
22171
22237
  return windows.find((window) => window.sessionName === sessionName && window.windowName === windowName) ?? null;
22172
22238
  }
22173
22239
  function resolveBranch(entry, metaBranch) {
22174
- const fallback = basename5(entry.path);
22240
+ const fallback = basename6(entry.path);
22175
22241
  return entry.branch ?? metaBranch ?? (fallback.length > 0 ? fallback : "unknown");
22176
22242
  }
22177
22243
 
@@ -22195,7 +22261,7 @@ class ReconciliationService {
22195
22261
  if (!options.force && this.now() - this.lastReconciledAt < this.freshnessMs) {
22196
22262
  return;
22197
22263
  }
22198
- const normalizedRepoRoot = resolve8(repoRoot);
22264
+ const normalizedRepoRoot = resolve9(repoRoot);
22199
22265
  const reconcilePromise = this.runReconcile(normalizedRepoRoot).then(() => {
22200
22266
  this.lastReconciledAt = this.now();
22201
22267
  });
@@ -22214,7 +22280,7 @@ class ReconciliationService {
22214
22280
  windows = [];
22215
22281
  }
22216
22282
  const seenWorktreeIds = new Set;
22217
- const candidateEntries = worktrees.filter((entry) => !entry.bare && resolve8(entry.path) !== normalizedRepoRoot);
22283
+ const candidateEntries = worktrees.filter((entry) => !entry.bare && resolve9(entry.path) !== normalizedRepoRoot);
22218
22284
  const reconciledStates = await mapWithConcurrency(candidateEntries, this.concurrency, async (entry) => {
22219
22285
  const gitDir = this.deps.git.resolveWorktreeGitDir(entry.path);
22220
22286
  const meta = await readWorktreeMeta(gitDir);
@@ -22689,6 +22755,7 @@ function createProjectApp(runtime, instancePrefix) {
22689
22755
  let stopPrMonitor = null;
22690
22756
  let stopOneshotWatcher = null;
22691
22757
  let stopAutoPullMonitor = null;
22758
+ let stopSessionSnapshot = null;
22692
22759
  async function runOneshotForIssue(issueId) {
22693
22760
  const seed = await buildSeedFromLinear({ issueId }, defaultSeedFromLinearDeps);
22694
22761
  if (!seed.ok) {
@@ -22798,7 +22865,7 @@ function createProjectApp(runtime, instancePrefix) {
22798
22865
  startupEnvs: config.startupEnvs,
22799
22866
  linkedRepos: config.integrations.github.linkedRepos.map((lr) => ({
22800
22867
  alias: lr.alias,
22801
- ...lr.dir ? { dir: resolve9(PROJECT_DIR, lr.dir) } : {}
22868
+ ...lr.dir ? { dir: resolve10(PROJECT_DIR, lr.dir) } : {}
22802
22869
  })),
22803
22870
  linearAutoCreateWorktrees: linearAutoCreateEnabled,
22804
22871
  autoRemoveOnMerge: autoRemoveOnMergeEnabled,
@@ -22966,9 +23033,9 @@ function createProjectApp(runtime, instancePrefix) {
22966
23033
  }
22967
23034
  async function getWorktreeGitDirs() {
22968
23035
  const gitDirs = new Map;
22969
- const projectRoot2 = resolve9(PROJECT_DIR);
23036
+ const projectRoot2 = resolve10(PROJECT_DIR);
22970
23037
  for (const entry of git.listLiveWorktrees(projectRoot2)) {
22971
- if (entry.bare || resolve9(entry.path) === projectRoot2 || !entry.branch)
23038
+ if (entry.bare || resolve10(entry.path) === projectRoot2 || !entry.branch)
22972
23039
  continue;
22973
23040
  gitDirs.set(entry.branch, git.resolveWorktreeGitDir(entry.path));
22974
23041
  }
@@ -23763,7 +23830,7 @@ ${resolvedPrompt}` : conversationContext;
23763
23830
  return errorResponse(`Unknown linked repo: ${repo}`, 404);
23764
23831
  if (!linkedRepo.dir)
23765
23832
  return errorResponse(`Linked repo "${repo}" has no dir configured`, 400);
23766
- const resolvedDir = resolve9(PROJECT_DIR, linkedRepo.dir);
23833
+ const resolvedDir = resolve10(PROJECT_DIR, linkedRepo.dir);
23767
23834
  const repoRoot = git.resolveRepoRoot(resolvedDir);
23768
23835
  if (!repoRoot)
23769
23836
  return errorResponse(`Linked repo "${repo}" dir is not a git repository: ${resolvedDir}`, 400);
@@ -23928,7 +23995,7 @@ ${resolvedPrompt}` : conversationContext;
23928
23995
  }
23929
23996
  const safeName = `${Date.now()}_${sanitizeFilename(entry.name)}`;
23930
23997
  const destPath = join12(uploadDir, safeName);
23931
- if (!resolve9(destPath).startsWith(uploadDir + "/")) {
23998
+ if (!resolve10(destPath).startsWith(uploadDir + "/")) {
23932
23999
  return errorResponse("Invalid filename", 400);
23933
24000
  }
23934
24001
  await Bun.write(destPath, entry);
@@ -24383,6 +24450,7 @@ ${resolvedPrompt}` : conversationContext;
24383
24450
  if (config.workspace.autoPull.enabled) {
24384
24451
  stopAutoPullMonitor = startAutoPullMonitor({ git, projectRoot: PROJECT_DIR, mainBranch: config.workspace.mainBranch }, config.workspace.autoPull.intervalSeconds * 1000);
24385
24452
  }
24453
+ stopSessionSnapshot = startSessionSnapshotMonitor({ git, tmux, projectRoot: PROJECT_DIR });
24386
24454
  }
24387
24455
  function stopLight() {
24388
24456
  stopPrMonitor?.();
@@ -24392,6 +24460,8 @@ ${resolvedPrompt}` : conversationContext;
24392
24460
  stopOneshotWatcher = null;
24393
24461
  stopAutoPullMonitor?.();
24394
24462
  stopAutoPullMonitor = null;
24463
+ stopSessionSnapshot?.();
24464
+ stopSessionSnapshot = null;
24395
24465
  }
24396
24466
  return { prefix: instancePrefix, routes, wsHandlers, startLight, stopLight };
24397
24467
  }
@@ -24565,8 +24635,8 @@ async function globalFetch(req) {
24565
24635
  if (STATIC_DIR) {
24566
24636
  const rawPath = url.pathname === "/" ? "index.html" : url.pathname;
24567
24637
  const filePath = join12(STATIC_DIR, rawPath);
24568
- const staticRoot = resolve9(STATIC_DIR);
24569
- if (!resolve9(filePath).startsWith(staticRoot + "/")) {
24638
+ const staticRoot = resolve10(STATIC_DIR);
24639
+ if (!resolve10(filePath).startsWith(staticRoot + "/")) {
24570
24640
  return new Response("Forbidden", { status: 403 });
24571
24641
  }
24572
24642
  const file = Bun.file(filePath);
package/bin/webmux.js CHANGED
@@ -473,7 +473,8 @@ _webmux() {
473
473
  'remove:Remove a worktree'
474
474
  'merge:Merge a worktree into main'
475
475
  'send:Send a prompt to a running worktree agent'
476
- 'prune:Remove all worktrees in the current project'
476
+ 'prune:Remove all closed (not open) worktrees in the current project'
477
+ 'restore:Re-open all worktree sessions that were open before'
477
478
  'linear:Post a worktree conversation to a Linear issue/team'
478
479
  'project:List, add, or remove projects served by the dashboard'
479
480
  'completion:Generate shell completion script'
@@ -543,7 +544,7 @@ compdef _webmux webmux`, BASH_SCRIPT = `_webmux() {
543
544
  prev="\${COMP_WORDS[COMP_CWORD-1]}"
544
545
 
545
546
  if [[ \${COMP_CWORD} -eq 1 ]]; then
546
- COMPREPLY=($(compgen -W "serve init service update add oneshot list open close refresh archive unarchive label remove merge send prune linear project completion" -- "\${cur}"))
547
+ COMPREPLY=($(compgen -W "serve init service update add oneshot list open close refresh archive unarchive label remove merge send prune restore linear project completion" -- "\${cur}"))
547
548
  return
548
549
  fi
549
550
 
@@ -5996,7 +5997,7 @@ var init_zod = __esm(() => {
5996
5997
  init_external();
5997
5998
  });
5998
5999
 
5999
- // node_modules/.bun/@ts-rest+core@3.52.1+f5aac5c7d31a6dcb/node_modules/@ts-rest/core/index.esm.mjs
6000
+ // node_modules/.bun/@ts-rest+core@3.52.1+aea371d0c1c4fd29/node_modules/@ts-rest/core/index.esm.mjs
6000
6001
  var isZodType = (obj) => {
6001
6002
  return typeof (obj === null || obj === undefined ? undefined : obj.safeParse) === "function";
6002
6003
  }, isZodObjectStrict = (obj) => {
@@ -11317,7 +11318,7 @@ function conversationSessionId(conversation) {
11317
11318
  return null;
11318
11319
  return conversation.provider === "codexAppServer" ? conversation.threadId : conversation.sessionId;
11319
11320
  }
11320
- var WORKTREE_META_SCHEMA_VERSION = 1, WORKTREE_ARCHIVE_STATE_VERSION = 1, ROOT_TAB_ID = "root";
11321
+ var WORKTREE_META_SCHEMA_VERSION = 1, WORKTREE_ARCHIVE_STATE_VERSION = 1, OPEN_SESSIONS_STATE_VERSION = 1, ROOT_TAB_ID = "root";
11321
11322
 
11322
11323
  // backend/src/adapters/fs.ts
11323
11324
  import { mkdir } from "fs/promises";
@@ -11373,6 +11374,9 @@ function getWorktreeStoragePaths(gitDir) {
11373
11374
  function getProjectArchiveStatePath(gitDir) {
11374
11375
  return join8(gitDir, "webmux", "archive.json");
11375
11376
  }
11377
+ function getProjectOpenSessionsStatePath(gitDir) {
11378
+ return join8(gitDir, "webmux", "open-sessions.json");
11379
+ }
11376
11380
  async function ensureWorktreeStorageDirs(gitDir) {
11377
11381
  const paths = getWorktreeStoragePaths(gitDir);
11378
11382
  await mkdir(paths.webmuxDir, { recursive: true });
@@ -11422,6 +11426,29 @@ async function writeWorktreeArchiveState(gitDir, state) {
11422
11426
  await Bun.write(archivePath, JSON.stringify(state, null, 2) + `
11423
11427
  `);
11424
11428
  }
11429
+ function emptyOpenSessionsState() {
11430
+ return {
11431
+ schemaVersion: OPEN_SESSIONS_STATE_VERSION,
11432
+ savedAt: "",
11433
+ branches: []
11434
+ };
11435
+ }
11436
+ function isOpenSessionsState(raw) {
11437
+ return isRecord3(raw) && typeof raw.schemaVersion === "number" && typeof raw.savedAt === "string" && Array.isArray(raw.branches) && raw.branches.every((branch) => typeof branch === "string");
11438
+ }
11439
+ async function readOpenSessionsState(gitDir) {
11440
+ const statePath = getProjectOpenSessionsStatePath(gitDir);
11441
+ try {
11442
+ const raw = await Bun.file(statePath).json();
11443
+ return isOpenSessionsState(raw) ? {
11444
+ schemaVersion: raw.schemaVersion,
11445
+ savedAt: raw.savedAt,
11446
+ branches: [...raw.branches]
11447
+ } : emptyOpenSessionsState();
11448
+ } catch {
11449
+ return emptyOpenSessionsState();
11450
+ }
11451
+ }
11425
11452
  function buildRuntimeEnvMap(meta, extraEnv = {}, dotenvValues = {}) {
11426
11453
  return {
11427
11454
  ...dotenvValues,
@@ -21289,9 +21316,13 @@ class LifecycleService {
21289
21316
  async pruneWorktrees() {
21290
21317
  try {
21291
21318
  const resolvedWorktrees = await this.resolveAllWorktrees();
21319
+ const sessionName = buildProjectSessionName(this.deps.projectRoot);
21292
21320
  const removedBranches = [];
21293
21321
  for (const resolved of resolvedWorktrees) {
21294
21322
  const branch = resolved.entry.branch ?? resolved.entry.path;
21323
+ if (this.deps.tmux.hasWindow(sessionName, buildWorktreeWindowName(branch))) {
21324
+ continue;
21325
+ }
21295
21326
  await this.removeResolvedWorktree(resolved);
21296
21327
  removedBranches.push(branch);
21297
21328
  }
@@ -22544,6 +22575,11 @@ function getWorktreeCommandUsage(command) {
22544
22575
  case "prune":
22545
22576
  return `Usage:
22546
22577
  webmux prune`;
22578
+ case "restore":
22579
+ return `Usage:
22580
+ webmux restore
22581
+
22582
+ Re-open every worktree session that was open the last time sessions were saved.`;
22547
22583
  case "tab":
22548
22584
  return [
22549
22585
  "Usage:",
@@ -22844,6 +22880,18 @@ function parsePruneCommandArgs(args) {
22844
22880
  }
22845
22881
  return true;
22846
22882
  }
22883
+ function parseRestoreCommandArgs(args) {
22884
+ for (const arg of args) {
22885
+ if (arg === "--help" || arg === "-h") {
22886
+ return false;
22887
+ }
22888
+ if (arg.startsWith("-")) {
22889
+ throw new CommandUsageError(`Unknown option: ${arg}`);
22890
+ }
22891
+ throw new CommandUsageError(`Unexpected argument: ${arg}`);
22892
+ }
22893
+ return true;
22894
+ }
22847
22895
  function parseListCommandArgs(args) {
22848
22896
  let mode = "active";
22849
22897
  let search = "";
@@ -22882,9 +22930,19 @@ function listProjectWorktrees(runtime) {
22882
22930
  const projectDir = resolve12(runtime.projectDir);
22883
22931
  return runtime.git.listWorktrees(projectDir).filter((entry) => !entry.bare && resolve12(entry.path) !== projectDir);
22884
22932
  }
22933
+ function buildOpenWorktreeWindowSet(runtime) {
22934
+ const sessionName = buildProjectSessionName(resolve12(runtime.projectDir));
22935
+ let windows = [];
22936
+ try {
22937
+ windows = runtime.tmux.listWindows();
22938
+ } catch {
22939
+ windows = [];
22940
+ }
22941
+ return new Set(windows.filter((w) => w.sessionName === sessionName).map((w) => w.windowName));
22942
+ }
22885
22943
  async function defaultConfirmPrune(worktreeCount) {
22886
22944
  const response = await confirm({
22887
- message: `Prune all ${worktreeCount} worktree${worktreeCount === 1 ? "" : "s"}? This action cannot be undone.`,
22945
+ message: `Prune ${worktreeCount} closed worktree${worktreeCount === 1 ? "" : "s"}? This action cannot be undone.`,
22888
22946
  initialValue: false
22889
22947
  });
22890
22948
  return !isCancel(response) && response;
@@ -22928,14 +22986,7 @@ async function listWorktrees(runtime, stdout2, options) {
22928
22986
  stdout2("No worktrees found.");
22929
22987
  return;
22930
22988
  }
22931
- const sessionName = buildProjectSessionName(projectDir);
22932
- let windows = [];
22933
- try {
22934
- windows = runtime.tmux.listWindows();
22935
- } catch {
22936
- windows = [];
22937
- }
22938
- const openWindows = new Set(windows.filter((w) => w.sessionName === sessionName).map((w) => w.windowName));
22989
+ const openWindows = buildOpenWorktreeWindowSet(runtime);
22939
22990
  const projectGitDir = runtime.git.resolveWorktreeGitDir(projectDir);
22940
22991
  const archivedPaths = buildArchivedWorktreePathSet(await readWorktreeArchiveState(projectGitDir));
22941
22992
  const rows = await Promise.all(entries.map(async (entry) => {
@@ -23000,6 +23051,7 @@ async function runWorktreeCommand(context, deps2 = {}) {
23000
23051
  const resolveBaseUrl = deps2.resolveBaseUrl ?? resolveProjectBaseUrl;
23001
23052
  const switchToTmuxWindow = deps2.switchToTmuxWindow ?? defaultSwitchToTmuxWindow;
23002
23053
  const confirmPrune = deps2.confirmPrune ?? defaultConfirmPrune;
23054
+ const readOpenSessions = deps2.readOpenSessions ?? readOpenSessionsState;
23003
23055
  try {
23004
23056
  if (context.command === "add") {
23005
23057
  const parsed = parseAddCommandArgs(context.args);
@@ -23078,18 +23130,85 @@ ${parsed.input.prompt}` : seed.data.conversationMarkdown;
23078
23130
  stdout2("No worktrees found.");
23079
23131
  return 0;
23080
23132
  }
23081
- if (!await confirmPrune(worktrees.length)) {
23133
+ const openWindows = buildOpenWorktreeWindowSet(runtime2);
23134
+ const closedWorktrees = worktrees.filter((entry) => !openWindows.has(buildWorktreeWindowName(entry.branch ?? basename7(entry.path))));
23135
+ if (closedWorktrees.length === 0) {
23136
+ stdout2("No closed worktrees to prune.");
23137
+ return 0;
23138
+ }
23139
+ if (!await confirmPrune(closedWorktrees.length)) {
23082
23140
  stdout2("Aborted.");
23083
23141
  return 0;
23084
23142
  }
23085
23143
  const result = await runtime2.lifecycleService.pruneWorktrees();
23086
23144
  if (result.removedBranches.length === 0) {
23087
- stdout2("No worktrees found.");
23145
+ stdout2("No closed worktrees to prune.");
23088
23146
  return 0;
23089
23147
  }
23090
23148
  stdout2(`Pruned ${result.removedBranches.length} worktree${result.removedBranches.length === 1 ? "" : "s"}: ${result.removedBranches.join(", ")}`);
23091
23149
  return 0;
23092
23150
  }
23151
+ if (context.command === "restore") {
23152
+ if (!parseRestoreCommandArgs(context.args)) {
23153
+ stdout2(getWorktreeCommandUsage("restore"));
23154
+ return 0;
23155
+ }
23156
+ const runtime2 = createRuntime({
23157
+ projectDir: context.projectDir,
23158
+ port: context.port
23159
+ });
23160
+ const projectDir = resolve12(runtime2.projectDir);
23161
+ const gitDir = runtime2.git.resolveWorktreeGitDir(projectDir);
23162
+ const state = await readOpenSessions(gitDir);
23163
+ if (state.branches.length === 0) {
23164
+ stdout2("No saved sessions to restore.");
23165
+ return 0;
23166
+ }
23167
+ const sessionName = buildProjectSessionName(projectDir);
23168
+ let openWindows = new Set;
23169
+ try {
23170
+ openWindows = new Set(runtime2.tmux.listWindows().filter((w) => w.sessionName === sessionName).map((w) => w.windowName));
23171
+ } catch {
23172
+ openWindows = new Set;
23173
+ }
23174
+ const existingBranches = new Set(listProjectWorktrees(runtime2).map((entry) => entry.branch ?? basename7(entry.path)));
23175
+ let restored = 0;
23176
+ let skipped = 0;
23177
+ let failed = 0;
23178
+ let firstRestored = null;
23179
+ for (const branch2 of state.branches) {
23180
+ if (openWindows.has(buildWorktreeWindowName(branch2))) {
23181
+ stdout2(`Already open: ${branch2}`);
23182
+ skipped++;
23183
+ continue;
23184
+ }
23185
+ if (!existingBranches.has(branch2)) {
23186
+ stderr(`Skipping ${branch2}: worktree no longer exists`);
23187
+ skipped++;
23188
+ continue;
23189
+ }
23190
+ try {
23191
+ await runtime2.lifecycleService.openWorktree(branch2);
23192
+ stdout2(`Restored ${branch2}`);
23193
+ restored++;
23194
+ if (!firstRestored)
23195
+ firstRestored = branch2;
23196
+ } catch (error) {
23197
+ stderr(`Failed to restore ${branch2}: ${error instanceof Error ? error.message : String(error)}`);
23198
+ failed++;
23199
+ }
23200
+ }
23201
+ const summaryParts = [`Restored ${restored} session${restored === 1 ? "" : "s"}`];
23202
+ if (skipped > 0)
23203
+ summaryParts.push(`skipped ${skipped}`);
23204
+ if (failed > 0)
23205
+ summaryParts.push(`${failed} failed`);
23206
+ stdout2(`${summaryParts.join(", ")}.`);
23207
+ if (firstRestored) {
23208
+ switchToTmuxWindow(runtime2.projectDir, firstRestored);
23209
+ }
23210
+ return failed > 0 ? 1 : 0;
23211
+ }
23093
23212
  if (context.command === "send") {
23094
23213
  const parsed = parseSendCommandArgs(context.args);
23095
23214
  if (!parsed) {
@@ -23233,7 +23352,7 @@ import { fileURLToPath } from "url";
23233
23352
  // package.json
23234
23353
  var package_default = {
23235
23354
  name: "webmux",
23236
- version: "0.39.0",
23355
+ version: "0.40.0",
23237
23356
  description: "Web dashboard for workmux \u2014 browser UI with embedded terminals, PR monitoring, and CI integration",
23238
23357
  type: "module",
23239
23358
  repository: {
@@ -23313,7 +23432,8 @@ Usage:
23313
23432
  webmux merge Merge a worktree into the main branch and remove it
23314
23433
  webmux send Send a prompt to a running worktree agent
23315
23434
  webmux tab List, create, switch, or close agent tabs in a worktree
23316
- webmux prune Remove all worktrees in the current project
23435
+ webmux prune Remove all closed (not open) worktrees in the current project
23436
+ webmux restore Re-open all worktree sessions that were open before
23317
23437
  webmux linear Post a worktree conversation to a Linear issue/team
23318
23438
  webmux project List, add, or remove projects served by the dashboard
23319
23439
  webmux completion Generate shell completion script (bash, zsh)
@@ -23331,7 +23451,7 @@ Environment:
23331
23451
  `);
23332
23452
  }
23333
23453
  function isRootCommand(value) {
23334
- return value === "serve" || value === "init" || value === "service" || value === "update" || value === "add" || value === "oneshot" || value === "list" || value === "open" || value === "close" || value === "refresh" || value === "archive" || value === "unarchive" || value === "label" || value === "remove" || value === "merge" || value === "send" || value === "tab" || value === "prune" || value === "linear" || value === "project" || value === "completion";
23454
+ return value === "serve" || value === "init" || value === "service" || value === "update" || value === "add" || value === "oneshot" || value === "list" || value === "open" || value === "close" || value === "refresh" || value === "archive" || value === "unarchive" || value === "label" || value === "remove" || value === "merge" || value === "send" || value === "tab" || value === "prune" || value === "restore" || value === "linear" || value === "project" || value === "completion";
23335
23455
  }
23336
23456
  function isServeRootOption(value) {
23337
23457
  return value === "--port" || value === "--app" || value === "--debug" || value === "--help" || value === "-h" || value === "--version" || value === "-V";
@@ -23398,7 +23518,7 @@ Run webmux --help for usage.`);
23398
23518
  };
23399
23519
  }
23400
23520
  function isWorktreeCommand(command) {
23401
- return command === "add" || command === "list" || command === "open" || command === "close" || command === "refresh" || command === "archive" || command === "unarchive" || command === "label" || command === "remove" || command === "merge" || command === "send" || command === "tab" || command === "prune";
23521
+ return command === "add" || command === "list" || command === "open" || command === "close" || command === "refresh" || command === "archive" || command === "unarchive" || command === "label" || command === "remove" || command === "merge" || command === "send" || command === "tab" || command === "prune" || command === "restore";
23402
23522
  }
23403
23523
  async function loadEnvFile(path) {
23404
23524
  if (!existsSync6(path))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webmux",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Web dashboard for workmux — browser UI with embedded terminals, PR monitoring, and CI integration",
5
5
  "type": "module",
6
6
  "repository": {