@moberg_hr/work-tree 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/bin.ts
4
- import fs41 from "fs";
5
- import path42 from "path";
4
+ import fs45 from "fs";
5
+ import path44 from "path";
6
6
  import chalk27 from "chalk";
7
7
 
8
8
  // src/cli.ts
@@ -309,10 +309,15 @@ function fetchRemote(cwd) {
309
309
  }
310
310
  async function fetchRemoteAsync(cwd) {
311
311
  await new Promise((resolve, reject) => {
312
- execFile("git", ["fetch", "--quiet"], { cwd, timeout: 3e4 }, (err) => {
313
- if (err) reject(err);
314
- else resolve();
315
- });
312
+ execFile(
313
+ "git",
314
+ ["fetch", "--quiet"],
315
+ { cwd, timeout: 3e4, windowsHide: true },
316
+ (err) => {
317
+ if (err) reject(err);
318
+ else resolve();
319
+ }
320
+ );
316
321
  });
317
322
  if (!getDefaultBranch(cwd)) {
318
323
  git(["remote", "set-head", "origin", "--auto"], cwd);
@@ -411,7 +416,8 @@ function generateGroupClaudeMd(groupName, repoAliases, config) {
411
416
  const result = spawn2.sync("claude", ["-p"], {
412
417
  input: prompt,
413
418
  encoding: "utf-8",
414
- stdio: ["pipe", "pipe", "pipe"]
419
+ stdio: ["pipe", "pipe", "pipe"],
420
+ windowsHide: true
415
421
  });
416
422
  let content;
417
423
  if (result.status !== 0 || !result.stdout?.trim()) {
@@ -873,7 +879,11 @@ function getWindowsDocumentsFolder() {
873
879
  const result = spawn4.sync(
874
880
  exe,
875
881
  ["-NoProfile", "-Command", "[Environment]::GetFolderPath('MyDocuments')"],
876
- { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
882
+ {
883
+ encoding: "utf-8",
884
+ stdio: ["pipe", "pipe", "pipe"],
885
+ windowsHide: true
886
+ }
877
887
  );
878
888
  if (result.status === 0 && result.stdout) {
879
889
  const dir = result.stdout.toString().trim();
@@ -886,7 +896,8 @@ function executableExists(name) {
886
896
  const cmd = process.platform === "win32" ? "where" : "which";
887
897
  const result = spawn4.sync(cmd, [name], {
888
898
  encoding: "utf-8",
889
- stdio: ["pipe", "pipe", "pipe"]
899
+ stdio: ["pipe", "pipe", "pipe"],
900
+ windowsHide: true
890
901
  });
891
902
  return result.status === 0;
892
903
  }
@@ -1564,11 +1575,12 @@ async function upsertSession(target, isGroup, branch, paths, jiraKey, baseBranch
1564
1575
  saveHistory(sessions);
1565
1576
  });
1566
1577
  }
1567
- async function upsertSessionWithPort(target, isGroup, branch, paths, config, jiraKey, baseBranch) {
1578
+ async function upsertSessionWithPort(target, isGroup, branch, paths, config, jiraKey, baseBranch, baseBranches) {
1568
1579
  return withHistoryLock(async () => {
1569
1580
  const sessions = loadHistory();
1570
1581
  const existing = findSession(sessions, target, branch);
1571
1582
  const now = (/* @__PURE__ */ new Date()).toISOString();
1583
+ const hasPerRepo = baseBranches && Object.keys(baseBranches).length > 0;
1572
1584
  let port = existing?.port;
1573
1585
  if (port === void 0) {
1574
1586
  const seedKey = sessionKey(target, branch);
@@ -1583,6 +1595,7 @@ async function upsertSessionWithPort(target, isGroup, branch, paths, config, jir
1583
1595
  existing.lastAccessedAt = now;
1584
1596
  if (jiraKey) existing.jiraKey = jiraKey;
1585
1597
  if (baseBranch && !existing.baseBranch) existing.baseBranch = baseBranch;
1598
+ if (hasPerRepo && !existing.baseBranches) existing.baseBranches = baseBranches;
1586
1599
  if (port !== void 0) existing.port = port;
1587
1600
  } else {
1588
1601
  const session = {
@@ -1595,6 +1608,7 @@ async function upsertSessionWithPort(target, isGroup, branch, paths, config, jir
1595
1608
  };
1596
1609
  if (jiraKey) session.jiraKey = jiraKey;
1597
1610
  if (baseBranch) session.baseBranch = baseBranch;
1611
+ if (hasPerRepo) session.baseBranches = baseBranches;
1598
1612
  if (port !== void 0) session.port = port;
1599
1613
  sessions.push(session);
1600
1614
  }
@@ -1710,6 +1724,62 @@ function copyConfigFiles(repoPath, worktreePath, patterns) {
1710
1724
  }
1711
1725
  }
1712
1726
 
1727
+ // src/core/base-spec.ts
1728
+ var BaseSpecError = class extends Error {
1729
+ constructor(message) {
1730
+ super(message);
1731
+ this.name = "BaseSpecError";
1732
+ }
1733
+ };
1734
+ function parseBaseSpec(raw) {
1735
+ const spec = { perRepo: {} };
1736
+ if (raw === void 0) return spec;
1737
+ const values = Array.isArray(raw) ? raw : [raw];
1738
+ for (const value of values) {
1739
+ const v = value.trim();
1740
+ if (!v) continue;
1741
+ const eq = v.indexOf("=");
1742
+ if (eq === -1) {
1743
+ if (spec.default !== void 0 && spec.default !== v) {
1744
+ throw new BaseSpecError(
1745
+ `Conflicting default --base values: '${spec.default}' and '${v}'`
1746
+ );
1747
+ }
1748
+ spec.default = v;
1749
+ } else {
1750
+ const alias = v.slice(0, eq).trim();
1751
+ const branch = v.slice(eq + 1).trim();
1752
+ if (!alias || !branch) {
1753
+ throw new BaseSpecError(
1754
+ `Invalid --base '${value}'. Use 'alias=branch' or a bare 'branch'.`
1755
+ );
1756
+ }
1757
+ const prior = spec.perRepo[alias];
1758
+ if (prior !== void 0 && prior !== branch) {
1759
+ throw new BaseSpecError(
1760
+ `Conflicting --base for '${alias}': '${prior}' and '${branch}'`
1761
+ );
1762
+ }
1763
+ spec.perRepo[alias] = branch;
1764
+ }
1765
+ }
1766
+ return spec;
1767
+ }
1768
+ function baseForAlias(spec, alias) {
1769
+ return spec.perRepo[alias] ?? spec.default;
1770
+ }
1771
+ function isEmptyBaseSpec(spec) {
1772
+ return spec.default === void 0 && Object.keys(spec.perRepo).length === 0;
1773
+ }
1774
+ function baseSpecOverrideAliases(spec) {
1775
+ return Object.keys(spec.perRepo);
1776
+ }
1777
+ function toBaseSpec(base) {
1778
+ if (base === void 0) return { perRepo: {} };
1779
+ if (typeof base === "string") return { default: base, perRepo: {} };
1780
+ return base;
1781
+ }
1782
+
1713
1783
  // src/core/worktree.ts
1714
1784
  function createSingleWorktree(repoPath, worktreePath, branchName, config, baseBranch) {
1715
1785
  debug("createSingleWorktree", { repoPath, worktreePath, branchName, baseBranch });
@@ -1915,8 +1985,9 @@ function removeSingleWorktree(repoPath, worktreePath, branchName, force) {
1915
1985
  return false;
1916
1986
  }
1917
1987
  }
1918
- async function setupWorktree(targetName, branchName, config, baseBranch, jiraKey) {
1919
- debug("setupWorktree", { targetName, branchName, baseBranch, jiraKey });
1988
+ async function setupWorktree(targetName, branchName, config, base, jiraKey) {
1989
+ const spec = toBaseSpec(base);
1990
+ debug("setupWorktree", { targetName, branchName, spec, jiraKey });
1920
1991
  const target = resolveProjectTarget(targetName, config);
1921
1992
  if (!target) {
1922
1993
  debug("setupWorktree: target not found", targetName);
@@ -1924,27 +1995,38 @@ async function setupWorktree(targetName, branchName, config, baseBranch, jiraKey
1924
1995
  }
1925
1996
  const workTreeDirName = branchName.replace(/\//g, "-");
1926
1997
  if (target.isGroup) {
1927
- return setupGroupWorktree(target.name, target.repoAliases, branchName, workTreeDirName, config, baseBranch, jiraKey);
1998
+ return setupGroupWorktree(target.name, target.repoAliases, branchName, workTreeDirName, config, spec, jiraKey);
1928
1999
  } else {
1929
- return setupSingleWorktree(targetName, branchName, workTreeDirName, config, baseBranch, jiraKey);
2000
+ return setupSingleWorktree(targetName, branchName, workTreeDirName, config, spec, jiraKey);
1930
2001
  }
1931
2002
  }
1932
- async function setupGroupWorktree(groupName, repoAliases, branchName, workTreeDirName, config, baseBranch, jiraKey) {
2003
+ async function setupGroupWorktree(groupName, repoAliases, branchName, workTreeDirName, config, spec, jiraKey) {
1933
2004
  const groupWorktreePath = path12.join(config.worktreesRoot, groupName, workTreeDirName);
1934
- if (baseBranch) {
2005
+ if (!isEmptyBaseSpec(spec)) {
2006
+ const unknownAliases = baseSpecOverrideAliases(spec).filter(
2007
+ (a) => !repoAliases.includes(a)
2008
+ );
2009
+ if (unknownAliases.length > 0) {
2010
+ console.error(
2011
+ `--base names repo(s) not in group '${groupName}': ${unknownAliases.join(", ")}. Group repos: ${repoAliases.join(", ")}`
2012
+ );
2013
+ return null;
2014
+ }
1935
2015
  const missingBase = [];
1936
2016
  const branchExists = [];
1937
2017
  for (const alias of repoAliases) {
1938
2018
  const repoPath = config.repos[alias];
1939
- if (!localBranchExists(baseBranch, repoPath) && !remoteBranchExists(baseBranch, repoPath)) {
1940
- missingBase.push(alias);
2019
+ const repoBase = baseForAlias(spec, alias);
2020
+ if (!repoBase) continue;
2021
+ if (!localBranchExists(repoBase, repoPath) && !remoteBranchExists(repoBase, repoPath)) {
2022
+ missingBase.push(`${alias} (${repoBase})`);
1941
2023
  }
1942
2024
  if (localBranchExists(branchName, repoPath) || remoteBranchExists(branchName, repoPath)) {
1943
2025
  branchExists.push(alias);
1944
2026
  }
1945
2027
  }
1946
2028
  if (missingBase.length > 0) {
1947
- console.error(`Base branch '${baseBranch}' not found in: ${missingBase.join(", ")}`);
2029
+ console.error(`Base branch not found in: ${missingBase.join(", ")}`);
1948
2030
  return null;
1949
2031
  }
1950
2032
  if (branchExists.length > 0) {
@@ -1957,14 +2039,17 @@ async function setupGroupWorktree(groupName, repoAliases, branchName, workTreeDi
1957
2039
  console.log("");
1958
2040
  fs13.mkdirSync(groupWorktreePath, { recursive: true });
1959
2041
  const createdWorktrees = [];
2042
+ const baseBranches = {};
1960
2043
  for (const alias of repoAliases) {
1961
2044
  const repoPath = config.repos[alias];
1962
2045
  const repoName = path12.basename(repoPath);
1963
2046
  const subWorktreePath = path12.join(groupWorktreePath, repoName);
2047
+ const repoBase = baseForAlias(spec, alias);
1964
2048
  console.log(chalk5.cyan(`[${alias}] (${repoName}):`));
1965
- const success = createSingleWorktree(repoPath, subWorktreePath, branchName, config, baseBranch);
2049
+ const success = createSingleWorktree(repoPath, subWorktreePath, branchName, config, repoBase);
1966
2050
  if (success) {
1967
2051
  createdWorktrees.push({ repoPath, worktreePath: subWorktreePath });
2052
+ if (repoBase) baseBranches[subWorktreePath] = repoBase;
1968
2053
  } else {
1969
2054
  console.log("");
1970
2055
  console.log(chalk5.yellow("Rolling back created worktrees due to failure..."));
@@ -1994,6 +2079,8 @@ async function setupGroupWorktree(groupName, repoAliases, branchName, workTreeDi
1994
2079
  console.log(chalk5.yellow(`Run 'work config regengroup ${groupName}' to generate it.`));
1995
2080
  }
1996
2081
  const allPaths = createdWorktrees.map((wt) => wt.worktreePath);
2082
+ const distinctBases = [...new Set(Object.values(baseBranches))];
2083
+ const representativeBase = spec.default ?? (distinctBases.length === 1 ? distinctBases[0] : void 0);
1997
2084
  const { port } = await upsertSessionWithPort(
1998
2085
  groupName,
1999
2086
  true,
@@ -2001,17 +2088,26 @@ async function setupGroupWorktree(groupName, repoAliases, branchName, workTreeDi
2001
2088
  allPaths,
2002
2089
  config,
2003
2090
  jiraKey,
2004
- baseBranch
2091
+ representativeBase,
2092
+ baseBranches
2005
2093
  );
2006
2094
  console.log("");
2007
2095
  console.log(`Branch: ${branchName}`);
2008
2096
  if (port !== void 0) console.log(chalk5.gray(`Dev-server port: ${port}`));
2009
2097
  return { launchDir: groupWorktreePath, paths: allPaths, isGroup: true, port };
2010
2098
  }
2011
- async function setupSingleWorktree(targetName, branchName, workTreeDirName, config, baseBranch, jiraKey) {
2099
+ async function setupSingleWorktree(targetName, branchName, workTreeDirName, config, spec, jiraKey) {
2012
2100
  const repoPath = config.repos[targetName];
2013
2101
  const repoName = path12.basename(repoPath);
2014
2102
  let workTreePath = path12.join(config.worktreesRoot, repoName, workTreeDirName);
2103
+ const unknownAliases = baseSpecOverrideAliases(spec).filter((a) => a !== targetName);
2104
+ if (unknownAliases.length > 0) {
2105
+ console.error(
2106
+ `--base names repo(s) other than '${targetName}': ${unknownAliases.join(", ")}`
2107
+ );
2108
+ return null;
2109
+ }
2110
+ const baseBranch = baseForAlias(spec, targetName);
2015
2111
  if (!fs13.existsSync(repoPath)) {
2016
2112
  console.error(`Repository path does not exist: ${repoPath}`);
2017
2113
  return null;
@@ -2038,7 +2134,8 @@ async function setupSingleWorktree(targetName, branchName, workTreeDirName, conf
2038
2134
  [workTreePath],
2039
2135
  config,
2040
2136
  jiraKey,
2041
- baseBranch
2137
+ baseBranch,
2138
+ baseBranch ? { [workTreePath]: baseBranch } : void 0
2042
2139
  );
2043
2140
  console.log(`Branch: ${branchName}`);
2044
2141
  if (port !== void 0) console.log(chalk5.gray(`Dev-server port: ${port}`));
@@ -2110,7 +2207,7 @@ var treeCommand = {
2110
2207
  type: "boolean",
2111
2208
  default: false
2112
2209
  }).option("base", {
2113
- describe: "Create the new branch from this base branch instead of HEAD",
2210
+ describe: "Base branch to fork from instead of HEAD. Repeatable. Use a bare branch (--base dev) for all repos, or alias=branch (--base backend=dev --base frontend=feat/x) for per-repo bases in a group.",
2114
2211
  type: "string"
2115
2212
  }).option("prompt", {
2116
2213
  describe: "Initial prompt to send to the AI tool on startup",
@@ -2135,7 +2232,17 @@ var treeCommand = {
2135
2232
  const open = argv.open;
2136
2233
  const unsafe = argv.unsafe;
2137
2234
  const setupOnly = argv["setup-only"];
2138
- const baseBranch = argv.base;
2235
+ let baseSpec;
2236
+ try {
2237
+ baseSpec = parseBaseSpec(argv.base);
2238
+ } catch (err) {
2239
+ if (err instanceof BaseSpecError) {
2240
+ console.error(err.message);
2241
+ process.exitCode = 1;
2242
+ return;
2243
+ }
2244
+ throw err;
2245
+ }
2139
2246
  const jiraKey = argv["jira-key"];
2140
2247
  const promptFile = argv["prompt-file"];
2141
2248
  let initialPrompt = argv.prompt;
@@ -2173,12 +2280,10 @@ var treeCommand = {
2173
2280
  process.exitCode = 1;
2174
2281
  return;
2175
2282
  }
2176
- if (baseBranch && !branchName) {
2283
+ if (!isEmptyBaseSpec(baseSpec) && !branchName) {
2177
2284
  console.error("--base requires a branch name");
2178
2285
  console.log(
2179
- chalk6.yellow(
2180
- `Usage: work tree ${targetName} <branch> --base ${baseBranch}`
2181
- )
2286
+ chalk6.yellow(`Usage: work tree ${targetName} <branch> --base <base>`)
2182
2287
  );
2183
2288
  process.exitCode = 1;
2184
2289
  return;
@@ -2226,7 +2331,7 @@ var treeCommand = {
2226
2331
  }
2227
2332
  return;
2228
2333
  }
2229
- const result = await setupWorktree(targetName, branchName, config, baseBranch, jiraKey);
2334
+ const result = await setupWorktree(targetName, branchName, config, baseSpec, jiraKey);
2230
2335
  if (!result) {
2231
2336
  process.exitCode = 1;
2232
2337
  return;
@@ -2795,10 +2900,8 @@ function collectPrunable(config, options = {}) {
2795
2900
  for (const [alias, repoPath] of Object.entries(config.repos)) {
2796
2901
  if (!fs19.existsSync(repoPath)) continue;
2797
2902
  if (skipAliases.has(alias)) continue;
2798
- const worktrees = parseWorktreeList(repoPath);
2799
- const normalizedRepoPath = path16.resolve(repoPath);
2903
+ const worktrees = parseWorktreeList(repoPath).slice(1);
2800
2904
  for (const wt of worktrees) {
2801
- if (path16.resolve(wt.path) === normalizedRepoPath) continue;
2802
2905
  if (!wt.branch) continue;
2803
2906
  if (groupCoveredKeys.has(`${alias}:${wt.branch}`)) continue;
2804
2907
  const { merged, into, confidence } = isBranchMerged(wt.branch, repoPath);
@@ -3128,10 +3231,15 @@ import spawn7 from "cross-spawn";
3128
3231
  import { execFile as execFile2 } from "child_process";
3129
3232
  function execAsync(cmd, args, cwd, timeout) {
3130
3233
  return new Promise((resolve, reject) => {
3131
- execFile2(cmd, args, { cwd, encoding: "utf-8", timeout }, (err, stdout) => {
3132
- if (err) reject(err);
3133
- else resolve(stdout ?? "");
3134
- });
3234
+ execFile2(
3235
+ cmd,
3236
+ args,
3237
+ { cwd, encoding: "utf-8", timeout, windowsHide: true },
3238
+ (err, stdout) => {
3239
+ if (err) reject(err);
3240
+ else resolve(stdout ?? "");
3241
+ }
3242
+ );
3135
3243
  });
3136
3244
  }
3137
3245
  function parsePrJson(stdout, repoAlias, currentUser) {
@@ -3247,10 +3355,15 @@ async function isGhAvailable() {
3247
3355
  import { execFile as execFile3 } from "child_process";
3248
3356
  function execAsync2(cmd, args, timeout) {
3249
3357
  return new Promise((resolve, reject) => {
3250
- execFile3(cmd, args, { encoding: "utf-8", timeout }, (err, stdout) => {
3251
- if (err) reject(err);
3252
- else resolve(stdout ?? "");
3253
- });
3358
+ execFile3(
3359
+ cmd,
3360
+ args,
3361
+ { encoding: "utf-8", timeout, windowsHide: true },
3362
+ (err, stdout) => {
3363
+ if (err) reject(err);
3364
+ else resolve(stdout ?? "");
3365
+ }
3366
+ );
3254
3367
  });
3255
3368
  }
3256
3369
  async function isAcliAvailable() {
@@ -4665,7 +4778,7 @@ function generateSlug(summary) {
4665
4778
  const child = execFile4(
4666
4779
  "claude",
4667
4780
  ["-p", "--model", "haiku"],
4668
- { encoding: "utf-8", timeout: 1e4 },
4781
+ { encoding: "utf-8", timeout: 1e4, windowsHide: true },
4669
4782
  (err, stdout) => {
4670
4783
  const result = stdout?.trim().toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/^-|-$/g, "");
4671
4784
  resolve(result || fallback);
@@ -6318,18 +6431,16 @@ var hydrateCommand = {
6318
6431
  };
6319
6432
 
6320
6433
  // src/commands/diff.ts
6321
- import fs32 from "fs";
6434
+ import fs36 from "fs";
6322
6435
  import os9 from "os";
6323
- import path31 from "path";
6436
+ import path33 from "path";
6324
6437
  import { spawn as childSpawn } from "child_process";
6325
6438
  import chalk21 from "chalk";
6326
6439
 
6327
6440
  // src/core/diff-pipeline.ts
6328
- import fs27 from "fs";
6329
- import os7 from "os";
6330
- import path24 from "path";
6331
- import crypto from "crypto";
6332
- import spawn8 from "cross-spawn";
6441
+ import fs28 from "fs";
6442
+ import path25 from "path";
6443
+ import spawn9 from "cross-spawn";
6333
6444
 
6334
6445
  // src/core/diff-parse.ts
6335
6446
  var HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
@@ -6621,19 +6732,65 @@ function coverageLookup(root, relPaths) {
6621
6732
  return { byPath: out, lcovMtimeMs: read.mtimeMs };
6622
6733
  }
6623
6734
 
6735
+ // src/core/git-tree-snapshot.ts
6736
+ import fs27 from "fs";
6737
+ import os7 from "os";
6738
+ import path24 from "path";
6739
+ import crypto from "crypto";
6740
+ import spawn8 from "cross-spawn";
6741
+ function writeTempTree(repoRoot, opts = {}) {
6742
+ const includeWorkingTree = opts.includeWorkingTree ?? true;
6743
+ const tmpIndex = path24.join(
6744
+ os7.tmpdir(),
6745
+ `wd-tree-${process.pid}-${crypto.randomBytes(6).toString("hex")}.idx`
6746
+ );
6747
+ const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
6748
+ const run2 = (args) => spawn8.sync("git", args, {
6749
+ cwd: repoRoot,
6750
+ encoding: "utf-8",
6751
+ env,
6752
+ windowsHide: true,
6753
+ maxBuffer: 64 * 1024 * 1024
6754
+ });
6755
+ try {
6756
+ const headSha = (spawn8.sync("git", ["rev-parse", "--verify", "HEAD"], {
6757
+ cwd: repoRoot,
6758
+ encoding: "utf-8",
6759
+ windowsHide: true
6760
+ }).stdout ?? "").trim() || null;
6761
+ if (headSha) {
6762
+ const r = run2(["read-tree", "HEAD"]);
6763
+ if (r.status !== 0) return null;
6764
+ }
6765
+ if (includeWorkingTree) {
6766
+ const add = run2(["add", "-A"]);
6767
+ if (add.status !== 0) return null;
6768
+ }
6769
+ const wt = run2(["write-tree"]);
6770
+ if (wt.status !== 0 || !wt.stdout) return null;
6771
+ return { treeSha: wt.stdout.trim(), headSha };
6772
+ } finally {
6773
+ try {
6774
+ if (fs27.existsSync(tmpIndex)) fs27.unlinkSync(tmpIndex);
6775
+ } catch {
6776
+ }
6777
+ }
6778
+ }
6779
+
6624
6780
  // src/core/diff-pipeline.ts
6781
+ var RENAME_DETECT = "-M";
6625
6782
  var MARKDOWN_EXT_RE = /\.(md|markdown|mdx)$/i;
6626
6783
  var MARKDOWN_SIZE_CAP = 256 * 1024;
6627
6784
  function isMarkdownPath(p) {
6628
6785
  return p !== "/dev/null" && MARKDOWN_EXT_RE.test(p);
6629
6786
  }
6630
6787
  function isInsideRoot(root, rel) {
6631
- const resolvedRoot = path24.resolve(root);
6632
- const resolvedTarget = path24.resolve(resolvedRoot, rel);
6633
- const r = path24.relative(resolvedRoot, resolvedTarget);
6788
+ const resolvedRoot = path25.resolve(root);
6789
+ const resolvedTarget = path25.resolve(resolvedRoot, rel);
6790
+ const r = path25.relative(resolvedRoot, resolvedTarget);
6634
6791
  if (r === "") return true;
6635
6792
  if (r.startsWith("..")) return false;
6636
- if (path24.isAbsolute(r)) return false;
6793
+ if (path25.isAbsolute(r)) return false;
6637
6794
  return true;
6638
6795
  }
6639
6796
  function readMarkdownContent(root, file, fromRef, toRef) {
@@ -6642,7 +6799,7 @@ function readMarkdownContent(root, file, fromRef, toRef) {
6642
6799
  return void 0;
6643
6800
  }
6644
6801
  const showAt = (ref, p) => {
6645
- const r = spawn8.sync("git", ["show", `${ref}:${p}`], {
6802
+ const r = spawn9.sync("git", ["show", `${ref}:${p}`], {
6646
6803
  cwd: root,
6647
6804
  encoding: "utf-8",
6648
6805
  maxBuffer: 16 * 1024 * 1024,
@@ -6658,12 +6815,12 @@ function readMarkdownContent(root, file, fromRef, toRef) {
6658
6815
  if (file.status !== "deleted" && isMarkdownPath(file.newPath) && isInsideRoot(root, file.newPath)) {
6659
6816
  if (toRef === "working") {
6660
6817
  try {
6661
- const absPath = path24.join(root, file.newPath);
6662
- const realRoot2 = fs27.realpathSync(path24.resolve(root));
6663
- const realPath = fs27.realpathSync(absPath);
6664
- const sep = path24.sep;
6818
+ const absPath = path25.join(root, file.newPath);
6819
+ const realRoot2 = fs28.realpathSync(path25.resolve(root));
6820
+ const realPath = fs28.realpathSync(absPath);
6821
+ const sep = path25.sep;
6665
6822
  if (realPath === realRoot2 || realPath.startsWith(realRoot2 + sep)) {
6666
- result.after = fs27.readFileSync(absPath, "utf-8");
6823
+ result.after = fs28.readFileSync(absPath, "utf-8");
6667
6824
  }
6668
6825
  } catch {
6669
6826
  }
@@ -6681,39 +6838,7 @@ function readMarkdownContent(root, file, fromRef, toRef) {
6681
6838
  return result;
6682
6839
  }
6683
6840
  function workingTreeTreeSha(root) {
6684
- const tmpIndex = path24.join(
6685
- os7.tmpdir(),
6686
- `wd-diff-${process.pid}-${crypto.randomBytes(6).toString("hex")}.idx`
6687
- );
6688
- const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
6689
- const run2 = (args) => spawn8.sync("git", args, {
6690
- cwd: root,
6691
- encoding: "utf-8",
6692
- env,
6693
- windowsHide: true,
6694
- maxBuffer: 64 * 1024 * 1024
6695
- });
6696
- try {
6697
- const headExists = (spawn8.sync("git", ["rev-parse", "--verify", "HEAD"], {
6698
- cwd: root,
6699
- encoding: "utf-8",
6700
- windowsHide: true
6701
- }).stdout ?? "").trim().length > 0;
6702
- if (headExists) {
6703
- const r = run2(["read-tree", "HEAD"]);
6704
- if (r.status !== 0) return null;
6705
- }
6706
- const add = run2(["add", "-A"]);
6707
- if (add.status !== 0) return null;
6708
- const wt = run2(["write-tree"]);
6709
- if (wt.status !== 0 || !wt.stdout) return null;
6710
- return wt.stdout.trim();
6711
- } finally {
6712
- try {
6713
- if (fs27.existsSync(tmpIndex)) fs27.unlinkSync(tmpIndex);
6714
- } catch {
6715
- }
6716
- }
6841
+ return writeTempTree(root, { includeWorkingTree: true })?.treeSha ?? null;
6717
6842
  }
6718
6843
  function isBinaryContent(buffer) {
6719
6844
  const len = Math.min(buffer.length, 8192);
@@ -6723,10 +6848,10 @@ function isBinaryContent(buffer) {
6723
6848
  return false;
6724
6849
  }
6725
6850
  function synthesizeUntrackedDiff(root, relPath) {
6726
- const absPath = path24.join(root, relPath);
6851
+ const absPath = path25.join(root, relPath);
6727
6852
  let buffer;
6728
6853
  try {
6729
- buffer = fs27.readFileSync(absPath);
6854
+ buffer = fs28.readFileSync(absPath);
6730
6855
  } catch {
6731
6856
  return "";
6732
6857
  }
@@ -6762,11 +6887,13 @@ new file mode 100644
6762
6887
  }
6763
6888
  function computeDiff(opts) {
6764
6889
  const { root, diffArg } = opts;
6765
- const trackedResult = spawn8.sync(
6890
+ const trackedResult = spawn9.sync(
6766
6891
  "git",
6767
6892
  // -w (ignore-all-space) hides pure whitespace changes so a reformat
6768
- // of indentation doesn't drown out the real changes.
6769
- ["diff", "--no-color", "--no-ext-diff", "-w", diffArg],
6893
+ // of indentation doesn't drown out the real changes. RENAME_DETECT turns
6894
+ // on rename detection so a `git mv` (+ edits) shows as one renamed entry
6895
+ // with its inline diff instead of a separate delete + add pair.
6896
+ ["diff", "--no-color", "--no-ext-diff", "-w", RENAME_DETECT, diffArg],
6770
6897
  {
6771
6898
  cwd: root,
6772
6899
  encoding: "utf-8",
@@ -6812,7 +6939,7 @@ function attachCoverage(root, files) {
6812
6939
  f.coverageMtimeMs = lcovMtimeMs;
6813
6940
  let srcMtimeMs = null;
6814
6941
  try {
6815
- srcMtimeMs = fs27.statSync(path24.join(root, f.path)).mtimeMs;
6942
+ srcMtimeMs = fs28.statSync(path25.join(root, f.path)).mtimeMs;
6816
6943
  } catch {
6817
6944
  srcMtimeMs = null;
6818
6945
  }
@@ -6830,9 +6957,12 @@ function computeRangeDiff(opts) {
6830
6957
  }
6831
6958
  const wtTreeSha = workingTreeTreeSha(root);
6832
6959
  if (!wtTreeSha) return [];
6833
- const result2 = spawn8.sync(
6960
+ const result2 = spawn9.sync(
6834
6961
  "git",
6835
- ["diff-tree", "-r", "-p", "--no-color", "--no-ext-diff", fromRef, wtTreeSha],
6962
+ // RENAME_DETECT: detect renames between the checkpoint tree and the
6963
+ // working tree so a rename (with or without edits) renders as one
6964
+ // entry, matching the HEAD-vs-working path above.
6965
+ ["diff-tree", "-r", "-p", RENAME_DETECT, "--no-color", "--no-ext-diff", fromRef, wtTreeSha],
6836
6966
  {
6837
6967
  cwd: root,
6838
6968
  encoding: "utf-8",
@@ -6851,9 +6981,11 @@ function computeRangeDiff(opts) {
6851
6981
  }
6852
6982
  return parsed;
6853
6983
  }
6854
- const result = spawn8.sync(
6984
+ const result = spawn9.sync(
6855
6985
  "git",
6856
- ["diff", "--no-color", "--no-ext-diff", fromRef, toRef],
6986
+ // RENAME_DETECT: rename detection between two checkpoint commits (no -w
6987
+ // here — see the note above on why range diffs keep whitespace changes).
6988
+ ["diff", "--no-color", "--no-ext-diff", RENAME_DETECT, fromRef, toRef],
6857
6989
  {
6858
6990
  cwd: root,
6859
6991
  encoding: "utf-8",
@@ -6874,69 +7006,82 @@ function computeRangeDiff(opts) {
6874
7006
  }
6875
7007
 
6876
7008
  // src/core/repo-spec.ts
6877
- import fs28 from "fs";
6878
- import path25 from "path";
7009
+ import fs29 from "fs";
7010
+ import path26 from "path";
6879
7011
  import os8 from "os";
6880
7012
  import crypto2 from "crypto";
6881
- function stableDiffPath(keyPaths) {
7013
+ function scopeHashFor(keyPaths) {
6882
7014
  const key = keyPaths.slice().sort().join("|");
6883
- const id = crypto2.createHash("sha1").update(key).digest("hex").slice(0, 12);
6884
- const dir = path25.join(os8.homedir(), ".work", "diffs");
6885
- fs28.mkdirSync(dir, { recursive: true });
6886
- return path25.join(dir, id);
7015
+ return crypto2.createHash("sha1").update(key).digest("hex").slice(0, 12);
7016
+ }
7017
+ function stableDiffPath(keyPaths) {
7018
+ const dir = path26.join(os8.homedir(), ".work", "diffs");
7019
+ fs29.mkdirSync(dir, { recursive: true });
7020
+ return path26.join(dir, scopeHashFor(keyPaths));
6887
7021
  }
6888
7022
 
6889
7023
  // src/core/diff-scope.ts
6890
- import path26 from "path";
7024
+ import fs30 from "fs";
7025
+ import path27 from "path";
6891
7026
  import chalk18 from "chalk";
6892
7027
  function normPath(p) {
6893
- return path26.resolve(p).replace(/\\/g, "/").toLowerCase();
7028
+ return path27.resolve(p).replace(/\\/g, "/").toLowerCase();
7029
+ }
7030
+ function realNorm(p) {
7031
+ try {
7032
+ return normPath(fs30.realpathSync(p));
7033
+ } catch {
7034
+ return normPath(p);
7035
+ }
6894
7036
  }
6895
7037
  function resolveScope(cwd) {
6896
7038
  const normCwd = normPath(cwd);
6897
7039
  const sessions = loadHistory();
7040
+ const top = git(["rev-parse", "--show-toplevel"], cwd);
7041
+ const toplevel = top.exitCode === 0 && top.stdout ? top.stdout : null;
7042
+ const realTop = toplevel ? realNorm(toplevel) : null;
6898
7043
  for (const s of sessions) {
6899
7044
  for (const p of s.paths) {
6900
7045
  const np = normPath(p);
6901
7046
  if (normCwd === np || normCwd.startsWith(np + "/")) {
7047
+ if (realTop && realNorm(p) !== realTop) continue;
6902
7048
  if (s.isGroup) {
6903
7049
  return {
6904
7050
  isGroup: true,
6905
7051
  session: s,
6906
- repos: s.paths.map((rp) => ({ name: path26.basename(rp), root: rp })),
6907
- activeRepoName: path26.basename(p)
7052
+ repos: s.paths.map((rp) => ({ name: path27.basename(rp), root: rp })),
7053
+ activeRepoName: path27.basename(p)
6908
7054
  };
6909
7055
  }
6910
7056
  return {
6911
7057
  isGroup: false,
6912
7058
  session: s,
6913
- repos: [{ name: path26.basename(p), root: p }],
6914
- activeRepoName: path26.basename(p)
7059
+ repos: [{ name: path27.basename(p), root: p }],
7060
+ activeRepoName: path27.basename(p)
6915
7061
  };
6916
7062
  }
6917
7063
  }
6918
7064
  }
6919
7065
  for (const s of sessions) {
6920
7066
  if (!s.isGroup || s.paths.length === 0) continue;
6921
- const parents = s.paths.map((p) => normPath(path26.dirname(p)));
7067
+ const parents = s.paths.map((p) => normPath(path27.dirname(p)));
6922
7068
  const groupRoot = parents[0];
6923
7069
  if (!parents.every((par) => par === groupRoot)) continue;
6924
7070
  if (normCwd === groupRoot || normCwd.startsWith(groupRoot + "/")) {
6925
7071
  return {
6926
7072
  isGroup: true,
6927
7073
  session: s,
6928
- repos: s.paths.map((rp) => ({ name: path26.basename(rp), root: rp })),
7074
+ repos: s.paths.map((rp) => ({ name: path27.basename(rp), root: rp })),
6929
7075
  activeRepoName: null
6930
7076
  };
6931
7077
  }
6932
7078
  }
6933
- const toplevel = git(["rev-parse", "--show-toplevel"], cwd);
6934
- if (toplevel.exitCode !== 0 || !toplevel.stdout) return null;
7079
+ if (!toplevel) return null;
6935
7080
  return {
6936
7081
  isGroup: false,
6937
7082
  session: null,
6938
- repos: [{ name: path26.basename(toplevel.stdout), root: toplevel.stdout }],
6939
- activeRepoName: path26.basename(toplevel.stdout)
7083
+ repos: [{ name: path27.basename(toplevel), root: toplevel }],
7084
+ activeRepoName: path27.basename(toplevel)
6940
7085
  };
6941
7086
  }
6942
7087
  function findAnyParentBranch(cwd) {
@@ -6990,11 +7135,12 @@ function resolveBase(scope, argv) {
6990
7135
  }
6991
7136
  return { base: "HEAD", source: "default" };
6992
7137
  }
6993
- function buildRepoSpecs(scope, base) {
7138
+ function buildRepoSpecs(scope, base, perRepoBase) {
6994
7139
  return scope.repos.map((r) => {
6995
- let diffArg = base;
6996
- if (base !== "HEAD") {
6997
- const mb = git(["merge-base", base, "HEAD"], r.root);
7140
+ const repoBase = perRepoBase?.[r.root] ?? base;
7141
+ let diffArg = repoBase;
7142
+ if (repoBase !== "HEAD") {
7143
+ const mb = git(["merge-base", repoBase, "HEAD"], r.root);
6998
7144
  if (mb.exitCode === 0 && mb.stdout) diffArg = mb.stdout;
6999
7145
  }
7000
7146
  return { name: r.name, root: r.root, diffArg };
@@ -7004,13 +7150,24 @@ function resolveRepoDiff(root, base, sessionBaseBranch) {
7004
7150
  if (base === "uncommitted") {
7005
7151
  return { resolvedBase: "HEAD", diffArg: "HEAD" };
7006
7152
  }
7007
- const parent = sessionBaseBranch ?? findAnyParentBranch(root);
7153
+ const parent = sessionBaseBranch ?? detectParentBranch(root) ?? findAnyParentBranch(root);
7008
7154
  if (!parent) return { resolvedBase: "HEAD", diffArg: "HEAD" };
7009
7155
  let diffArg = "HEAD";
7010
7156
  const mb = git(["merge-base", parent, "HEAD"], root);
7011
7157
  if (mb.exitCode === 0 && mb.stdout) diffArg = mb.stdout;
7012
7158
  return { resolvedBase: parent, diffArg };
7013
7159
  }
7160
+ function sessionBaseForPath(root) {
7161
+ const norm = normPath(root);
7162
+ for (const s of loadHistory()) {
7163
+ for (const p of s.paths) {
7164
+ if (normPath(p) === norm) {
7165
+ return s.baseBranches?.[p] ?? s.baseBranch;
7166
+ }
7167
+ }
7168
+ }
7169
+ return void 0;
7170
+ }
7014
7171
 
7015
7172
  // src/core/comment-server.ts
7016
7173
  import { Hono as Hono2 } from "hono";
@@ -7098,37 +7255,134 @@ import { Hono } from "hono";
7098
7255
  import { serve } from "@hono/node-server";
7099
7256
  import { streamSSE } from "hono/streaming";
7100
7257
 
7258
+ // src/core/file-context.ts
7259
+ import fs31 from "fs";
7260
+ import path28 from "path";
7261
+ import spawn10 from "cross-spawn";
7262
+ function readContextLines(opts) {
7263
+ const { root, relPath, ref } = opts;
7264
+ if (!relPath || !isInsideRoot(root, relPath)) return null;
7265
+ const start = Math.max(1, Math.floor(opts.start));
7266
+ const end = Math.max(start, Math.floor(opts.end));
7267
+ const content = !ref || ref === "working" ? readWorkingTree(root, relPath) : readAtRef(root, ref, relPath);
7268
+ if (content === null) return null;
7269
+ const all = content.split(/\r?\n/);
7270
+ if (all.length > 0 && all[all.length - 1] === "") all.pop();
7271
+ const totalLines = all.length;
7272
+ const slice = all.slice(start - 1, end);
7273
+ return {
7274
+ lines: slice,
7275
+ start,
7276
+ totalLines,
7277
+ eof: end >= totalLines
7278
+ };
7279
+ }
7280
+ function readWorkingTree(root, relPath) {
7281
+ try {
7282
+ const absPath = path28.join(root, relPath);
7283
+ const realRoot2 = fs31.realpathSync(path28.resolve(root));
7284
+ const realPath = fs31.realpathSync(absPath);
7285
+ const sep = path28.sep;
7286
+ if (realPath !== realRoot2 && !realPath.startsWith(realRoot2 + sep)) {
7287
+ return null;
7288
+ }
7289
+ return fs31.readFileSync(absPath, "utf-8");
7290
+ } catch {
7291
+ return null;
7292
+ }
7293
+ }
7294
+ function readAtRef(root, ref, relPath) {
7295
+ const r = spawn10.sync("git", ["show", `${ref}:${relPath}`], {
7296
+ cwd: root,
7297
+ encoding: "utf-8",
7298
+ maxBuffer: 64 * 1024 * 1024,
7299
+ windowsHide: true
7300
+ });
7301
+ if (r.status === 0 && typeof r.stdout === "string") return r.stdout;
7302
+ return null;
7303
+ }
7304
+
7101
7305
  // src/core/fs-watcher.ts
7102
- import path27 from "path";
7306
+ import fs32 from "fs";
7307
+ import path29 from "path";
7103
7308
  import chalk19 from "chalk";
7104
7309
  import chokidar from "chokidar";
7310
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([
7311
+ ".git",
7312
+ "node_modules",
7313
+ "bin",
7314
+ // .NET build output
7315
+ "obj",
7316
+ // .NET build output
7317
+ "dist",
7318
+ "build",
7319
+ "out",
7320
+ "target",
7321
+ // Rust / JVM
7322
+ ".next",
7323
+ ".nuxt",
7324
+ ".svelte-kit",
7325
+ ".turbo",
7326
+ ".gradle",
7327
+ "coverage",
7328
+ ".vs",
7329
+ ".idea"
7330
+ ]);
7331
+ function isIgnoredWatchPath(roots, filePath) {
7332
+ for (const root of roots) {
7333
+ const rel = path29.relative(root, filePath).replace(/\\/g, "/");
7334
+ if (rel === "" || rel.startsWith("../")) continue;
7335
+ if (rel.split("/").some((seg) => IGNORED_DIRS.has(seg))) return true;
7336
+ }
7337
+ return false;
7338
+ }
7339
+ var SUPPORTS_RECURSIVE_WATCH = process.platform === "darwin" || process.platform === "win32";
7340
+ function logWatchError(err) {
7341
+ process.stderr.write(
7342
+ chalk19.yellow("[watcher] fs error: ") + err.message + "\n"
7343
+ );
7344
+ }
7105
7345
  function createFsWatcher(opts) {
7106
7346
  const debounceMs = opts.debounceMs ?? 150;
7107
7347
  let debounceTimer = null;
7108
- const watcher = chokidar.watch(opts.roots, {
7109
- ignored: (filePath) => {
7110
- for (const root of opts.roots) {
7111
- const rel = path27.relative(root, filePath).replace(/\\/g, "/");
7112
- if (rel === ".git" || rel.startsWith(".git/")) return true;
7113
- }
7114
- return false;
7115
- },
7116
- ignoreInitial: true,
7117
- persistent: true,
7118
- awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 20 }
7119
- });
7120
- watcher.on("all", () => {
7348
+ const fire = () => {
7121
7349
  if (debounceTimer) clearTimeout(debounceTimer);
7122
7350
  debounceTimer = setTimeout(() => {
7123
7351
  debounceTimer = null;
7124
7352
  opts.onChange();
7125
7353
  }, debounceMs);
7354
+ };
7355
+ if (SUPPORTS_RECURSIVE_WATCH) {
7356
+ const watchers = opts.roots.map((root) => {
7357
+ const w = fs32.watch(root, { recursive: true }, (_event, filename) => {
7358
+ if (filename && isIgnoredWatchPath([root], path29.join(root, filename.toString()))) {
7359
+ return;
7360
+ }
7361
+ fire();
7362
+ });
7363
+ w.on("error", logWatchError);
7364
+ return w;
7365
+ });
7366
+ return {
7367
+ stop() {
7368
+ if (debounceTimer) clearTimeout(debounceTimer);
7369
+ for (const w of watchers) {
7370
+ try {
7371
+ w.close();
7372
+ } catch {
7373
+ }
7374
+ }
7375
+ }
7376
+ };
7377
+ }
7378
+ const watcher = chokidar.watch(opts.roots, {
7379
+ ignored: (filePath) => isIgnoredWatchPath(opts.roots, filePath),
7380
+ ignoreInitial: true,
7381
+ persistent: true,
7382
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 20 }
7126
7383
  });
7127
- watcher.on("error", (err) => {
7128
- process.stderr.write(
7129
- chalk19.yellow("[watcher] fs error: ") + err.message + "\n"
7130
- );
7131
- });
7384
+ watcher.on("all", fire);
7385
+ watcher.on("error", logWatchError);
7132
7386
  return {
7133
7387
  stop() {
7134
7388
  if (debounceTimer) clearTimeout(debounceTimer);
@@ -7139,30 +7393,30 @@ function createFsWatcher(opts) {
7139
7393
  }
7140
7394
 
7141
7395
  // src/core/web-static.ts
7142
- import fs29 from "fs";
7143
- import path28 from "path";
7396
+ import fs33 from "fs";
7397
+ import path30 from "path";
7144
7398
  import { fileURLToPath } from "url";
7145
7399
  function resolveWebRoot() {
7146
- const entryDir = path28.dirname(process.argv[1] ?? "");
7147
- const moduleDir = path28.dirname(fileURLToPath(import.meta.url));
7400
+ const entryDir = path30.dirname(process.argv[1] ?? "");
7401
+ const moduleDir = path30.dirname(fileURLToPath(import.meta.url));
7148
7402
  const candidates = [
7149
- path28.join(entryDir, "web"),
7403
+ path30.join(entryDir, "web"),
7150
7404
  // bundled: this module is inlined into dist/<bin>.js, so dist/web is a
7151
7405
  // sibling of the bundle. Works even when argv[1] is an npm bin symlink
7152
7406
  // (which is not realpath'd, so the entryDir candidate above misses).
7153
- path28.join(moduleDir, "web"),
7407
+ path30.join(moduleDir, "web"),
7154
7408
  // dev/tsx fallback: walk up from src/core to repo root then into dist/web.
7155
- path28.resolve(moduleDir, "../../dist/web")
7409
+ path30.resolve(moduleDir, "../../dist/web")
7156
7410
  ];
7157
7411
  for (const c of candidates) {
7158
- if (fs29.existsSync(path28.join(c, "index.html"))) return c;
7412
+ if (fs33.existsSync(path30.join(c, "index.html"))) return c;
7159
7413
  }
7160
7414
  return null;
7161
7415
  }
7162
7416
 
7163
7417
  // src/core/spa-handler.ts
7164
- import fs30 from "fs";
7165
- import path29 from "path";
7418
+ import fs34 from "fs";
7419
+ import path31 from "path";
7166
7420
  var MIME = {
7167
7421
  ".html": "text/html; charset=utf-8",
7168
7422
  ".js": "application/javascript; charset=utf-8",
@@ -7176,18 +7430,18 @@ var MIME = {
7176
7430
  function readFile(root, relPath) {
7177
7431
  const clean = relPath.split("?")[0];
7178
7432
  const requested = clean === "/" ? "/index.html" : clean;
7179
- const filePath = path29.join(root, requested);
7180
- const norm = path29.normalize(filePath);
7181
- if (!norm.startsWith(path29.normalize(root))) return null;
7433
+ const filePath = path31.join(root, requested);
7434
+ const norm = path31.normalize(filePath);
7435
+ if (!norm.startsWith(path31.normalize(root))) return null;
7182
7436
  let stat;
7183
7437
  try {
7184
- stat = fs30.statSync(norm);
7438
+ stat = fs34.statSync(norm);
7185
7439
  } catch {
7186
7440
  return null;
7187
7441
  }
7188
7442
  if (!stat.isFile()) return null;
7189
- const ext = path29.extname(norm).toLowerCase();
7190
- return { body: fs30.readFileSync(norm), ext };
7443
+ const ext = path31.extname(norm).toLowerCase();
7444
+ return { body: fs34.readFileSync(norm), ext };
7191
7445
  }
7192
7446
  function serveSpa(c, webRoot) {
7193
7447
  const url = new URL(c.req.url);
@@ -7222,20 +7476,32 @@ async function startDiffServer(opts) {
7222
7476
  for (const cb of sseListeners) cb(payload);
7223
7477
  }
7224
7478
  const app = new Hono();
7225
- app.get(
7226
- "/api/context",
7227
- (c) => c.json({
7479
+ app.get("/api/context", (c) => {
7480
+ const primaryRoot = opts.repos[0]?.root;
7481
+ let headBranch;
7482
+ if (primaryRoot) {
7483
+ const head = git(["rev-parse", "--abbrev-ref", "HEAD"], primaryRoot);
7484
+ if (head.exitCode === 0 && head.stdout && head.stdout !== "HEAD") {
7485
+ headBranch = head.stdout;
7486
+ }
7487
+ }
7488
+ return c.json({
7228
7489
  mode: "review",
7229
7490
  scopeLabel: opts.scopeLabel,
7230
7491
  repos: opts.repos.map((r) => ({ name: r.name })),
7231
- readOnly: !!opts.readOnly
7232
- })
7233
- );
7492
+ readOnly: !!opts.readOnly,
7493
+ headBranch
7494
+ });
7495
+ });
7234
7496
  app.get("/api/diff", (c) => {
7235
7497
  const base = c.req.query("base") === "branch" ? "branch" : "uncommitted";
7236
7498
  try {
7237
7499
  const resolved = opts.repos.map(
7238
- (r) => base === "uncommitted" ? { resolvedBase: "HEAD", diffArg: r.diffArg } : resolveRepoDiff(r.root, "branch", opts.sessionBaseBranch)
7500
+ (r) => base === "uncommitted" ? { resolvedBase: "HEAD", diffArg: r.diffArg } : resolveRepoDiff(
7501
+ r.root,
7502
+ "branch",
7503
+ opts.sessionBaseBranches?.[r.root] ?? opts.sessionBaseBranch
7504
+ )
7239
7505
  );
7240
7506
  const repos = opts.repos.map((r, i) => ({
7241
7507
  name: r.name,
@@ -7249,6 +7515,21 @@ async function startDiffServer(opts) {
7249
7515
  return c.json({ error: err.message }, 500);
7250
7516
  }
7251
7517
  });
7518
+ app.get("/api/file-lines", (c) => {
7519
+ const repoName = c.req.query("repo") ?? "";
7520
+ const relPath = c.req.query("path") ?? "";
7521
+ const start = Number(c.req.query("start"));
7522
+ const end = Number(c.req.query("end"));
7523
+ const ref = c.req.query("ref") || void 0;
7524
+ if (!relPath || !Number.isInteger(start) || !Number.isInteger(end)) {
7525
+ return c.json({ error: "bad path/start/end" }, 400);
7526
+ }
7527
+ const root = opts.repos.length === 1 ? opts.repos[0].root : opts.repos.find((r) => r.name === repoName)?.root;
7528
+ if (!root) return c.json({ error: "unknown repo" }, 404);
7529
+ const result = readContextLines({ root, relPath, start, end, ref });
7530
+ if (!result) return c.json({ error: "cannot read file" }, 400);
7531
+ return c.json(result);
7532
+ });
7252
7533
  app.get(
7253
7534
  "/events",
7254
7535
  (c) => streamSSE(c, async (stream) => {
@@ -7411,6 +7692,7 @@ async function startCommentServer(opts) {
7411
7692
  repos: opts.repos,
7412
7693
  scopeLabel: opts.scopeLabel,
7413
7694
  sessionBaseBranch: opts.sessionBaseBranch,
7695
+ sessionBaseBranches: opts.sessionBaseBranches,
7414
7696
  watchDebounceMs: opts.watchDebounceMs,
7415
7697
  attachRoutes
7416
7698
  });
@@ -7421,16 +7703,6 @@ async function startCommentServer(opts) {
7421
7703
  stop: () => server.stop()
7422
7704
  };
7423
7705
  }
7424
- async function startReadOnlyDiffServer(opts) {
7425
- const server = await startDiffServer({
7426
- repos: opts.repos,
7427
- scopeLabel: opts.scopeLabel,
7428
- sessionBaseBranch: opts.sessionBaseBranch,
7429
- watchDebounceMs: opts.watchDebounceMs,
7430
- readOnly: true
7431
- });
7432
- return { url: server.url, stop: () => server.stop() };
7433
- }
7434
7706
  function formatSingleComment(c) {
7435
7707
  const bodyLines = c.body.split("\n").map((l) => `> ${l}`).join("\n");
7436
7708
  const header = c.side === "general" ? `**General review comment**` : `**${c.repo}/${c.file}** : line ${c.line} (${c.side})`;
@@ -7462,8 +7734,8 @@ function diffReviewSnapshot(snapshot, seen) {
7462
7734
  }
7463
7735
 
7464
7736
  // src/core/static-renderer.ts
7465
- import fs31 from "fs";
7466
- import path30 from "path";
7737
+ import fs35 from "fs";
7738
+ import path32 from "path";
7467
7739
  function buildDiff(specs, resolvedBase) {
7468
7740
  return {
7469
7741
  repos: specs.map((r) => ({
@@ -7479,8 +7751,8 @@ function renderStatic(opts) {
7479
7751
  if (!webRoot) {
7480
7752
  throw new Error("Could not find dist/web/. Run `npm run build` first.");
7481
7753
  }
7482
- const shellPath = path30.join(webRoot, "index.html");
7483
- let shell = fs31.readFileSync(shellPath, "utf-8");
7754
+ const shellPath = path32.join(webRoot, "index.html");
7755
+ let shell = fs35.readFileSync(shellPath, "utf-8");
7484
7756
  const uncommitted = buildDiff(opts.uncommitted, "HEAD");
7485
7757
  const branch = opts.branch ? buildDiff(opts.branch.specs, opts.branch.resolvedBase) : void 0;
7486
7758
  const initialBase = opts.initialBase ?? "uncommitted";
@@ -7529,10 +7801,10 @@ function escapeForScriptTag(json) {
7529
7801
  }
7530
7802
  function readAsset(webRoot, urlPath) {
7531
7803
  const clean = urlPath.split("?")[0].replace(/^\//, "");
7532
- const full = path30.join(webRoot, clean);
7533
- if (!path30.normalize(full).startsWith(path30.normalize(webRoot))) return null;
7804
+ const full = path32.join(webRoot, clean);
7805
+ if (!path32.normalize(full).startsWith(path32.normalize(webRoot))) return null;
7534
7806
  try {
7535
- return fs31.readFileSync(full, "utf-8");
7807
+ return fs35.readFileSync(full, "utf-8");
7536
7808
  } catch {
7537
7809
  return null;
7538
7810
  }
@@ -7542,108 +7814,85 @@ function readAsset(webRoot, urlPath) {
7542
7814
  function info(message) {
7543
7815
  process.stderr.write(message + "\n");
7544
7816
  }
7545
- function isPidAlive(pid) {
7817
+ function scopePathStem(repoSpecs) {
7818
+ return stableDiffPath(repoSpecs.map((r) => r.root));
7819
+ }
7820
+ async function runStop(repoSpecs) {
7821
+ const webUrl = readWebUrl();
7822
+ if (!webUrl) {
7823
+ info(chalk21.gray("No work web running \u2014 nothing to stop."));
7824
+ return;
7825
+ }
7826
+ const hash = stableDiffPath(repoSpecs.map((r) => r.root)).split(/[\\/]/).pop();
7546
7827
  try {
7547
- process.kill(pid, 0);
7548
- return true;
7828
+ const res = await fetch(`${webUrl}api/scopes/${hash}`, {
7829
+ method: "DELETE"
7830
+ });
7831
+ if (res.ok) {
7832
+ info(chalk21.gray("De-registered this scope from work web."));
7833
+ } else {
7834
+ info(
7835
+ chalk21.yellow(
7836
+ `work web responded ${res.status} \u2014 scope may not have been registered.`
7837
+ )
7838
+ );
7839
+ }
7840
+ } catch (err) {
7841
+ console.error(
7842
+ chalk21.red("Could not reach work web:"),
7843
+ err.message
7844
+ );
7845
+ }
7846
+ }
7847
+ function webUrlFilePath() {
7848
+ return path33.join(os9.homedir(), ".work", "web.url");
7849
+ }
7850
+ function resolveWorkBinPath(selfArgv1) {
7851
+ let real = selfArgv1;
7852
+ try {
7853
+ real = fs36.realpathSync(selfArgv1);
7549
7854
  } catch {
7550
- return false;
7551
7855
  }
7856
+ if (real.endsWith("wd-bin.js")) {
7857
+ return path33.join(path33.dirname(real), "bin.js");
7858
+ }
7859
+ return real;
7552
7860
  }
7553
- function readPid(pidPath) {
7861
+ function readWebUrl() {
7554
7862
  try {
7555
- const raw = fs32.readFileSync(pidPath, "utf-8").trim();
7556
- const n = Number(raw);
7557
- return Number.isFinite(n) && n > 0 ? n : null;
7863
+ const v = fs36.readFileSync(webUrlFilePath(), "utf-8").trim();
7864
+ return v || null;
7558
7865
  } catch {
7559
7866
  return null;
7560
7867
  }
7561
7868
  }
7562
- function spawnDaemon(extraArgs, logPath2) {
7563
- const out = fs32.openSync(logPath2, "a");
7869
+ async function ensureWorkWebRunning() {
7870
+ const existing = readWebUrl();
7871
+ if (existing) return existing;
7872
+ const workBin = resolveWorkBinPath(process.argv[1]);
7873
+ const out = fs36.openSync(
7874
+ path33.join(os9.homedir(), ".work", "web-autostart.log"),
7875
+ "a"
7876
+ );
7564
7877
  const child = childSpawn(
7565
7878
  process.execPath,
7566
- [process.argv[1], ...extraArgs, "--watch-daemon"],
7879
+ [workBin, "web", "--lean", "--no-open"],
7567
7880
  {
7568
7881
  detached: true,
7569
7882
  stdio: ["ignore", out, out],
7570
- windowsHide: true,
7571
- cwd: process.cwd()
7883
+ windowsHide: true
7884
+ // Inherit cwd doesn't matter for work web — its file-watches use
7885
+ // ~/.work paths exclusively.
7572
7886
  }
7573
7887
  );
7574
7888
  child.unref();
7575
- fs32.closeSync(out);
7576
- return child.pid ?? 0;
7577
- }
7578
- function pathsForScope(repoSpecs) {
7579
- const base = stableDiffPath(repoSpecs.map((r) => r.root));
7580
- return {
7581
- base,
7582
- pid: `${base}.pid`,
7583
- log: `${base}.log`,
7584
- url: `${base}.url`
7585
- };
7586
- }
7587
- function runStop(paths) {
7588
- const pid = readPid(paths.pid);
7589
- if (pid && isPidAlive(pid)) {
7590
- try {
7591
- process.kill(pid);
7592
- info(chalk21.gray(`Stopped watcher (PID ${pid}).`));
7593
- } catch (err) {
7594
- console.error(chalk21.red("Failed to stop watcher:"), err.message);
7595
- }
7596
- } else {
7597
- info(chalk21.gray("No watcher running for this scope."));
7598
- }
7599
- try {
7600
- fs32.unlinkSync(paths.pid);
7601
- } catch {
7602
- }
7603
- try {
7604
- fs32.unlinkSync(paths.url);
7605
- } catch {
7606
- }
7607
- }
7608
- async function runDaemon(ctx) {
7609
- fs32.writeFileSync(ctx.paths.pid, String(process.pid));
7610
- const handle = await startReadOnlyDiffServer({
7611
- repos: ctx.repoSpecs,
7612
- scopeLabel: ctx.scopeLabel,
7613
- sessionBaseBranch: ctx.scope.session?.baseBranch
7614
- });
7615
- fs32.writeFileSync(ctx.paths.url, handle.url);
7616
- info(
7617
- chalk21.gray(
7618
- `[live] watcher started, pid=${process.pid}, repos=${ctx.repoSpecs.map((r) => r.name).join(",")}, base=${ctx.base}, url=${handle.url}`
7619
- )
7620
- );
7621
- const shutdown = () => {
7622
- handle.stop();
7623
- try {
7624
- fs32.unlinkSync(ctx.paths.pid);
7625
- } catch {
7626
- }
7627
- try {
7628
- fs32.unlinkSync(ctx.paths.url);
7629
- } catch {
7630
- }
7631
- process.exit(0);
7632
- };
7633
- process.on("SIGINT", shutdown);
7634
- process.on("SIGTERM", shutdown);
7635
- await new Promise(() => {
7636
- });
7889
+ fs36.closeSync(out);
7890
+ const url = await waitForUrlFile(webUrlFilePath(), 5e3);
7891
+ return url;
7637
7892
  }
7638
7893
  async function tryRegisterWithWorkWeb(ctx, routeKind) {
7639
- const webUrlFile = path31.join(os9.homedir(), ".work", "web.url");
7640
- let webUrl;
7641
- try {
7642
- webUrl = fs32.readFileSync(webUrlFile, "utf-8").trim();
7643
- if (!webUrl) return null;
7644
- } catch {
7645
- return null;
7646
- }
7894
+ const webUrl = await ensureWorkWebRunning();
7895
+ if (!webUrl) return null;
7647
7896
  try {
7648
7897
  const res = await fetch(`${webUrl}api/scopes`, {
7649
7898
  method: "POST",
@@ -7664,49 +7913,27 @@ async function tryRegisterWithWorkWeb(ctx, routeKind) {
7664
7913
  async function runLauncher(ctx) {
7665
7914
  const webRouteUrl = await tryRegisterWithWorkWeb(ctx, "diff");
7666
7915
  if (webRouteUrl) {
7667
- info(chalk21.gray(`Opening in work web: ${webRouteUrl}`));
7916
+ info(chalk21.gray(`Opening: ${webRouteUrl}`));
7668
7917
  openUrl(webRouteUrl);
7669
7918
  return;
7670
7919
  }
7671
- const existing = readPid(ctx.paths.pid);
7672
- let url = null;
7673
- if (existing && isPidAlive(existing)) {
7674
- info(chalk21.gray(`Watcher already running (PID ${existing}).`));
7675
- try {
7676
- url = fs32.readFileSync(ctx.paths.url, "utf-8").trim();
7677
- } catch {
7678
- }
7679
- } else {
7680
- try {
7681
- fs32.unlinkSync(ctx.paths.pid);
7682
- } catch {
7683
- }
7684
- try {
7685
- fs32.unlinkSync(ctx.paths.url);
7686
- } catch {
7687
- }
7688
- const passthrough = process.argv.slice(2).filter((a) => a !== "--stop" && a !== "--watch");
7689
- const pid = spawnDaemon(passthrough, ctx.paths.log);
7690
- info(chalk21.gray(`Started watcher (PID ${pid}). Log: ${ctx.paths.log}`));
7691
- info(chalk21.gray("Stop with: wd --stop"));
7692
- url = await waitForUrlFile(ctx.paths.url, 3e3);
7693
- }
7694
- if (!url) {
7695
- console.error(
7696
- chalk21.red("Watcher did not report a URL \u2014 check the log:"),
7697
- ctx.paths.log
7698
- );
7699
- return;
7700
- }
7701
- info(chalk21.gray(`URL: ${url}`));
7702
- openUrl(url);
7920
+ console.error(
7921
+ chalk21.red("Could not start or reach work web.")
7922
+ );
7923
+ console.error(
7924
+ chalk21.gray(
7925
+ `Tail ~/.work/web-autostart.log for diagnostics, or run \`work web\` in another shell to inspect startup directly.`
7926
+ )
7927
+ );
7928
+ process.exitCode = 1;
7703
7929
  }
7704
7930
  function runStatic(ctx, initialBranch) {
7705
7931
  const uncommitted = buildRepoSpecs(ctx.scope, "HEAD");
7706
7932
  const primaryRoot = ctx.scope.repos.find((r) => r.name === ctx.scope.activeRepoName)?.root ?? ctx.scope.repos[0].root;
7707
- const parent = ctx.scope.session?.baseBranch ?? findAnyParentBranch(primaryRoot);
7933
+ const perRepoBase = ctx.scope.session?.baseBranches;
7934
+ const parent = perRepoBase?.[primaryRoot] ?? ctx.scope.session?.baseBranch ?? findAnyParentBranch(primaryRoot);
7708
7935
  const branch = parent === null ? void 0 : {
7709
- specs: buildRepoSpecs(ctx.scope, parent),
7936
+ specs: buildRepoSpecs(ctx.scope, parent, perRepoBase),
7710
7937
  resolvedBase: parent
7711
7938
  };
7712
7939
  const uncommittedTotal = uncommitted.reduce(
@@ -7727,8 +7954,8 @@ function runStatic(ctx, initialBranch) {
7727
7954
  branch,
7728
7955
  initialBase: initialBranch && branch ? "branch" : "uncommitted"
7729
7956
  });
7730
- const filePath = `${ctx.paths.base}.html`;
7731
- fs32.writeFileSync(filePath, html, "utf-8");
7957
+ const filePath = `${ctx.scopeStem}.html`;
7958
+ fs36.writeFileSync(filePath, html, "utf-8");
7732
7959
  info(chalk21.gray(`Wrote ${filePath}`));
7733
7960
  openUrl(`file:///${filePath.replace(/\\/g, "/")}`);
7734
7961
  }
@@ -7737,7 +7964,7 @@ function waitForUrlFile(filePath, timeoutMs) {
7737
7964
  const start = Date.now();
7738
7965
  const tick = () => {
7739
7966
  try {
7740
- const v = fs32.readFileSync(filePath, "utf-8").trim();
7967
+ const v = fs36.readFileSync(filePath, "utf-8").trim();
7741
7968
  if (v) return resolve(v);
7742
7969
  } catch {
7743
7970
  }
@@ -7748,14 +7975,8 @@ function waitForUrlFile(filePath, timeoutMs) {
7748
7975
  });
7749
7976
  }
7750
7977
  async function tryReviewViaWorkWeb(ctx) {
7751
- const webUrlFile = path31.join(os9.homedir(), ".work", "web.url");
7752
- let webUrl;
7753
- try {
7754
- webUrl = fs32.readFileSync(webUrlFile, "utf-8").trim();
7755
- if (!webUrl) return false;
7756
- } catch {
7757
- return false;
7758
- }
7978
+ const webUrl = await ensureWorkWebRunning();
7979
+ if (!webUrl) return false;
7759
7980
  let hash;
7760
7981
  try {
7761
7982
  const res = await fetch(`${webUrl}api/scopes`, {
@@ -7883,6 +8104,7 @@ summary-id: ${info3.summary.id}` : ""}
7883
8104
  repos: ctx.repoSpecs,
7884
8105
  scopeLabel: ctx.scopeLabel,
7885
8106
  sessionBaseBranch: ctx.scope.session?.baseBranch,
8107
+ sessionBaseBranches: ctx.scope.session?.baseBranches,
7886
8108
  onComment,
7887
8109
  onCommentDeleted,
7888
8110
  onSubmitReviewStart,
@@ -7944,12 +8166,7 @@ var diffCommand = {
7944
8166
  }).option("stop", {
7945
8167
  type: "boolean",
7946
8168
  default: false,
7947
- describe: "Stop the background server for this scope."
7948
- }).option("watch-daemon", {
7949
- type: "boolean",
7950
- default: false,
7951
- hidden: true,
7952
- describe: "Internal: run the foreground watcher loop."
8169
+ describe: "De-register this scope from work web. The work web server itself keeps running; use `work web --stop` to terminate the server."
7953
8170
  }).option("comments", {
7954
8171
  type: "boolean",
7955
8172
  alias: "c",
@@ -7967,7 +8184,8 @@ var diffCommand = {
7967
8184
  branch: argv.branch
7968
8185
  });
7969
8186
  const repoSpecs = buildRepoSpecs(scope, base);
7970
- const paths = pathsForScope(repoSpecs);
8187
+ const scopeStem = scopePathStem(repoSpecs);
8188
+ if (argv.stop) return runStop(repoSpecs);
7971
8189
  if (base === "HEAD") {
7972
8190
  info(chalk21.gray("Showing uncommitted changes vs HEAD."));
7973
8191
  } else {
@@ -7983,11 +8201,9 @@ var diffCommand = {
7983
8201
  base,
7984
8202
  baseSource,
7985
8203
  repoSpecs,
7986
- paths,
8204
+ scopeStem,
7987
8205
  scopeLabel
7988
8206
  };
7989
- if (argv.stop) return runStop(ctx.paths);
7990
- if (argv["watch-daemon"]) return runDaemon(ctx);
7991
8207
  if (argv.comments) return runReview(ctx);
7992
8208
  if (argv.static) return runStatic(ctx, !!argv.branch);
7993
8209
  return runLauncher(ctx);
@@ -7995,21 +8211,21 @@ var diffCommand = {
7995
8211
  };
7996
8212
 
7997
8213
  // src/commands/web.ts
7998
- import fs37 from "fs";
8214
+ import fs41 from "fs";
7999
8215
  import os13 from "os";
8000
- import path40 from "path";
8216
+ import path42 from "path";
8001
8217
  import chalk23 from "chalk";
8002
8218
 
8003
8219
  // src/core/web-server.ts
8004
- import fs36 from "fs";
8220
+ import fs40 from "fs";
8005
8221
  import os12 from "os";
8006
- import path39 from "path";
8222
+ import path41 from "path";
8007
8223
  import chalk22 from "chalk";
8008
8224
  import { Hono as Hono3 } from "hono";
8009
8225
  import { streamSSE as streamSSE3 } from "hono/streaming";
8010
8226
 
8011
8227
  // src/core/web-state.ts
8012
- import path32 from "path";
8228
+ import path34 from "path";
8013
8229
  import crypto4 from "crypto";
8014
8230
  import chokidar2 from "chokidar";
8015
8231
  function sessionIdFor(s) {
@@ -8030,7 +8246,7 @@ function subscribeSession(sessionId, onChange) {
8030
8246
  const watcher = chokidar2.watch(roots, {
8031
8247
  ignored: (filePath) => {
8032
8248
  for (const r of roots) {
8033
- const rel = path32.relative(r, filePath).replace(/\\/g, "/");
8249
+ const rel = path34.relative(r, filePath).replace(/\\/g, "/");
8034
8250
  if (rel === ".git" || rel.startsWith(".git/")) return true;
8035
8251
  }
8036
8252
  return false;
@@ -8144,24 +8360,24 @@ function disposeAllPtys() {
8144
8360
  }
8145
8361
 
8146
8362
  // src/core/comment-file-store.ts
8147
- import fs33 from "fs";
8148
- import path33 from "path";
8363
+ import fs37 from "fs";
8364
+ import path35 from "path";
8149
8365
  import os10 from "os";
8150
8366
  function commentsDir() {
8151
- return path33.join(os10.homedir(), ".work", "comments");
8367
+ return path35.join(os10.homedir(), ".work", "comments");
8152
8368
  }
8153
8369
  function ensureDir() {
8154
- fs33.mkdirSync(commentsDir(), { recursive: true });
8370
+ fs37.mkdirSync(commentsDir(), { recursive: true });
8155
8371
  }
8156
8372
  function commentsFileFor(sessionId) {
8157
- return path33.join(commentsDir(), `${sessionId}.json`);
8373
+ return path35.join(commentsDir(), `${sessionId}.json`);
8158
8374
  }
8159
8375
  function pathFor(sessionId) {
8160
8376
  return commentsFileFor(sessionId);
8161
8377
  }
8162
8378
  function readDisk(sessionId) {
8163
8379
  try {
8164
- const raw = fs33.readFileSync(pathFor(sessionId), "utf-8");
8380
+ const raw = fs37.readFileSync(pathFor(sessionId), "utf-8");
8165
8381
  const parsed = JSON.parse(raw);
8166
8382
  return Array.isArray(parsed) ? parsed : [];
8167
8383
  } catch {
@@ -8233,29 +8449,29 @@ function clearCommentStoreCache() {
8233
8449
  }
8234
8450
 
8235
8451
  // src/core/pending-delivery.ts
8236
- import fs34 from "fs";
8237
- import path34 from "path";
8452
+ import fs38 from "fs";
8453
+ import path36 from "path";
8238
8454
  function pathFor2(sessionId) {
8239
8455
  return {
8240
8456
  comments: commentsFileFor(sessionId),
8241
- delivered: path34.join(commentsDir(), `${sessionId}.delivered.json`)
8457
+ delivered: path36.join(commentsDir(), `${sessionId}.delivered.json`)
8242
8458
  };
8243
8459
  }
8244
8460
  function readJson(filePath, fallback) {
8245
8461
  try {
8246
- return JSON.parse(fs34.readFileSync(filePath, "utf8"));
8462
+ return JSON.parse(fs38.readFileSync(filePath, "utf8"));
8247
8463
  } catch {
8248
8464
  return fallback;
8249
8465
  }
8250
8466
  }
8251
8467
  function writeAtomic2(filePath, content) {
8252
- fs34.mkdirSync(path34.dirname(filePath), { recursive: true });
8468
+ fs38.mkdirSync(path36.dirname(filePath), { recursive: true });
8253
8469
  const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
8254
- fs34.writeFileSync(tmp, content, "utf-8");
8255
- fs34.renameSync(tmp, filePath);
8470
+ fs38.writeFileSync(tmp, content, "utf-8");
8471
+ fs38.renameSync(tmp, filePath);
8256
8472
  }
8257
8473
  function normalize(p) {
8258
- return path34.resolve(p).replace(/\\/g, "/").toLowerCase();
8474
+ return path36.resolve(p).replace(/\\/g, "/").toLowerCase();
8259
8475
  }
8260
8476
  function findSessionForCwd(cwd) {
8261
8477
  const norm = normalize(cwd);
@@ -8276,13 +8492,40 @@ function findSessionForCwd(cwd) {
8276
8492
  }
8277
8493
  return best?.session ?? null;
8278
8494
  }
8495
+ function isPendingFor(delivered) {
8496
+ return (c) => c.status === "published" && c.author === "user" && !delivered.has(c.id);
8497
+ }
8279
8498
  function readPendingForSession(sessionId) {
8280
8499
  const paths = pathFor2(sessionId);
8281
8500
  const comments = getCommentFileStore(sessionId).snapshot();
8282
8501
  const delivered = new Set(readJson(paths.delivered, []));
8283
- return comments.filter(
8284
- (c) => c.status === "published" && c.author === "user" && !delivered.has(c.id)
8502
+ return comments.filter(isPendingFor(delivered));
8503
+ }
8504
+ function scopeStoreIdsForPaths(paths) {
8505
+ const resolved = paths.map((p) => path36.resolve(p));
8506
+ const ids = /* @__PURE__ */ new Set();
8507
+ ids.add(`scope-${scopeHashFor(resolved)}`);
8508
+ for (const p of resolved) ids.add(`scope-${scopeHashFor([p])}`);
8509
+ return [...ids];
8510
+ }
8511
+ function readPendingForWorktree(session) {
8512
+ const sessionId = sessionIdFor(session);
8513
+ const delivered = new Set(
8514
+ readJson(pathFor2(sessionId).delivered, [])
8285
8515
  );
8516
+ const pending = isPendingFor(delivered);
8517
+ const seen = /* @__PURE__ */ new Set();
8518
+ const out = [];
8519
+ const collect = (storeId) => {
8520
+ for (const c of getCommentFileStore(storeId).snapshot()) {
8521
+ if (seen.has(c.id) || !pending(c)) continue;
8522
+ seen.add(c.id);
8523
+ out.push(c);
8524
+ }
8525
+ };
8526
+ collect(sessionId);
8527
+ for (const storeId of scopeStoreIdsForPaths(session.paths)) collect(storeId);
8528
+ return out;
8286
8529
  }
8287
8530
  function markDelivered(sessionId, ids) {
8288
8531
  if (ids.length === 0) return;
@@ -8582,8 +8825,8 @@ function mountPanesRoutes(app, opts) {
8582
8825
  }
8583
8826
 
8584
8827
  // src/core/worktree-routes.ts
8585
- import path35 from "path";
8586
- import { spawn as spawn9 } from "child_process";
8828
+ import path37 from "path";
8829
+ import { spawn as spawn11 } from "child_process";
8587
8830
  import { zValidator as zValidator4 } from "@hono/zod-validator";
8588
8831
  import { z as z3 } from "zod";
8589
8832
  function mountWorktreeRoutes(app, opts) {
@@ -8706,10 +8949,10 @@ function mountWorktreeRoutes(app, opts) {
8706
8949
  const id = c.req.param("id");
8707
8950
  const session = findSession2(id);
8708
8951
  if (!session) return c.json({ error: "unknown session" }, 404);
8709
- const target = session.isGroup ? path35.dirname(session.paths[0]) : session.paths[0];
8952
+ const target = session.isGroup ? path37.dirname(session.paths[0]) : session.paths[0];
8710
8953
  try {
8711
8954
  const cmd = process.platform === "win32" ? "code.cmd" : "code";
8712
- const child = spawn9(cmd, [target], {
8955
+ const child = spawn11(cmd, [target], {
8713
8956
  detached: true,
8714
8957
  stdio: "ignore",
8715
8958
  shell: false
@@ -8726,28 +8969,27 @@ function mountWorktreeRoutes(app, opts) {
8726
8969
  import { zValidator as zValidator5 } from "@hono/zod-validator";
8727
8970
  import { z as z4 } from "zod";
8728
8971
  import { EventEmitter } from "events";
8729
- import path38 from "path";
8730
- import spawn11 from "cross-spawn";
8972
+ import path40 from "path";
8973
+ import spawn13 from "cross-spawn";
8731
8974
 
8732
8975
  // src/core/checkpoint.ts
8733
- import fs35 from "fs";
8976
+ import fs39 from "fs";
8734
8977
  import os11 from "os";
8735
- import path36 from "path";
8736
- import crypto5 from "crypto";
8737
- import spawn10 from "cross-spawn";
8978
+ import path38 from "path";
8979
+ import spawn12 from "cross-spawn";
8738
8980
  function manifestPath(scopeHash) {
8739
- const dir = path36.join(os11.homedir(), ".work", "diffs");
8740
- fs35.mkdirSync(dir, { recursive: true });
8741
- return path36.join(dir, `${scopeHash}.checkpoints.json`);
8981
+ const dir = path38.join(os11.homedir(), ".work", "diffs");
8982
+ fs39.mkdirSync(dir, { recursive: true });
8983
+ return path38.join(dir, `${scopeHash}.checkpoints.json`);
8742
8984
  }
8743
8985
  function emptyManifest(scopeHash) {
8744
8986
  return { version: 1, scopeHash, entries: [] };
8745
8987
  }
8746
8988
  function loadManifest(scopeHash) {
8747
8989
  const file = manifestPath(scopeHash);
8748
- if (!fs35.existsSync(file)) return emptyManifest(scopeHash);
8990
+ if (!fs39.existsSync(file)) return emptyManifest(scopeHash);
8749
8991
  try {
8750
- const raw = fs35.readFileSync(file, "utf-8");
8992
+ const raw = fs39.readFileSync(file, "utf-8");
8751
8993
  const parsed = JSON.parse(raw);
8752
8994
  if (parsed.version !== 1 || !Array.isArray(parsed.entries)) {
8753
8995
  return emptyManifest(scopeHash);
@@ -8761,68 +9003,37 @@ function loadManifest(scopeHash) {
8761
9003
  return emptyManifest(scopeHash);
8762
9004
  }
8763
9005
  }
8764
- function snapshotRepo(repoRoot, scopeHash, id) {
8765
- const tmpIndex = path36.join(
8766
- os11.tmpdir(),
8767
- `wd-cp-${process.pid}-${crypto5.randomBytes(6).toString("hex")}.idx`
8768
- );
8769
- const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
8770
- const run2 = (args) => spawn10.sync("git", args, {
9006
+ function snapshotRepo(repoRoot, scopeHash, id, includeWorkingTree = true) {
9007
+ const tree = writeTempTree(repoRoot, { includeWorkingTree });
9008
+ if (!tree) return null;
9009
+ const { treeSha, headSha } = tree;
9010
+ const commitArgs = ["commit-tree", treeSha, "-m", "wd checkpoint"];
9011
+ if (headSha) commitArgs.push("-p", headSha);
9012
+ const commitEnv = {
9013
+ ...process.env,
9014
+ GIT_AUTHOR_NAME: "wd",
9015
+ GIT_AUTHOR_EMAIL: "wd@local",
9016
+ GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z",
9017
+ GIT_COMMITTER_NAME: "wd",
9018
+ GIT_COMMITTER_EMAIL: "wd@local",
9019
+ GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z"
9020
+ };
9021
+ const commit = spawn12.sync("git", commitArgs, {
8771
9022
  cwd: repoRoot,
8772
9023
  encoding: "utf-8",
8773
- env,
8774
- windowsHide: true,
8775
- maxBuffer: 64 * 1024 * 1024
9024
+ env: commitEnv,
9025
+ windowsHide: true
8776
9026
  });
8777
- try {
8778
- const headSha = (spawn10.sync("git", ["rev-parse", "--verify", "HEAD"], {
8779
- cwd: repoRoot,
8780
- encoding: "utf-8",
8781
- windowsHide: true
8782
- }).stdout ?? "").trim();
8783
- const hasHead = headSha.length > 0;
8784
- if (hasHead) {
8785
- const r = run2(["read-tree", "HEAD"]);
8786
- if (r.status !== 0) return null;
8787
- }
8788
- const add = run2(["add", "-A"]);
8789
- if (add.status !== 0) return null;
8790
- const wt = run2(["write-tree"]);
8791
- if (wt.status !== 0 || !wt.stdout) return null;
8792
- const treeSha = wt.stdout.trim();
8793
- const commitArgs = ["commit-tree", treeSha, "-m", "wd checkpoint"];
8794
- if (hasHead) commitArgs.push("-p", headSha);
8795
- const commitEnv = {
8796
- ...process.env,
8797
- GIT_AUTHOR_NAME: "wd",
8798
- GIT_AUTHOR_EMAIL: "wd@local",
8799
- GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z",
8800
- GIT_COMMITTER_NAME: "wd",
8801
- GIT_COMMITTER_EMAIL: "wd@local",
8802
- GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z"
8803
- };
8804
- const commit = spawn10.sync("git", commitArgs, {
8805
- cwd: repoRoot,
8806
- encoding: "utf-8",
8807
- env: commitEnv,
8808
- windowsHide: true
8809
- });
8810
- if (commit.status !== 0 || !commit.stdout) return null;
8811
- const commitSha = commit.stdout.trim();
8812
- const refName = `refs/wd/${scopeHash}/${id}`;
8813
- const updateRef = spawn10.sync(
8814
- "git",
8815
- ["update-ref", refName, commitSha],
8816
- { cwd: repoRoot, encoding: "utf-8", windowsHide: true }
8817
- );
8818
- if (updateRef.status !== 0) return null;
8819
- return commitSha;
8820
- } finally {
8821
- try {
8822
- if (fs35.existsSync(tmpIndex)) fs35.unlinkSync(tmpIndex);
8823
- } catch {
8824
- }
8825
- }
9027
+ if (commit.status !== 0 || !commit.stdout) return null;
9028
+ const commitSha = commit.stdout.trim();
9029
+ const refName = `refs/wd/${scopeHash}/${id}`;
9030
+ const updateRef = spawn12.sync("git", ["update-ref", refName, commitSha], {
9031
+ cwd: repoRoot,
9032
+ encoding: "utf-8",
9033
+ windowsHide: true
9034
+ });
9035
+ if (updateRef.status !== 0) return null;
9036
+ return commitSha;
8826
9037
  }
8827
9038
  async function takeCheckpoint(scopeHash, repos, opts = {}) {
8828
9039
  const file = manifestPath(scopeHash);
@@ -8833,12 +9044,17 @@ async function takeCheckpoint(scopeHash, repos, opts = {}) {
8833
9044
  const nextId = isFirst ? 0 : manifest.entries[manifest.entries.length - 1].id + 1;
8834
9045
  const captured = {};
8835
9046
  for (const repo of repos) {
8836
- captured[repo.name] = snapshotRepo(repo.root, scopeHash, nextId);
9047
+ captured[repo.name] = snapshotRepo(
9048
+ repo.root,
9049
+ scopeHash,
9050
+ nextId,
9051
+ !isFirst
9052
+ );
8837
9053
  }
8838
9054
  const rollbackRefs = () => {
8839
9055
  const refName = `refs/wd/${scopeHash}/${nextId}`;
8840
9056
  for (const repo of repos) {
8841
- spawn10.sync("git", ["update-ref", "-d", refName], {
9057
+ spawn12.sync("git", ["update-ref", "-d", refName], {
8842
9058
  cwd: repo.root,
8843
9059
  encoding: "utf-8",
8844
9060
  windowsHide: true
@@ -8851,10 +9067,22 @@ async function takeCheckpoint(scopeHash, repos, opts = {}) {
8851
9067
  }
8852
9068
  if (!opts.force && !isFirst) {
8853
9069
  const prev = manifest.entries[manifest.entries.length - 1];
8854
- const allMatch = Object.keys(captured).every(
8855
- (k) => captured[k] === prev.repos[k]
8856
- );
8857
- if (allMatch) {
9070
+ const treeOf = (root, commitSha) => {
9071
+ if (!commitSha) return null;
9072
+ const r = spawn12.sync(
9073
+ "git",
9074
+ ["rev-parse", `${commitSha}^{tree}`],
9075
+ { cwd: root, encoding: "utf-8", windowsHide: true }
9076
+ );
9077
+ if (r.status !== 0 || typeof r.stdout !== "string") return null;
9078
+ return r.stdout.trim() || null;
9079
+ };
9080
+ const allTreesMatch = repos.every((repo) => {
9081
+ const curTree = treeOf(repo.root, captured[repo.name]);
9082
+ const prevTree = treeOf(repo.root, prev.repos[repo.name] ?? null);
9083
+ return curTree !== null && curTree === prevTree;
9084
+ });
9085
+ if (allTreesMatch) {
8858
9086
  rollbackRefs();
8859
9087
  return null;
8860
9088
  }
@@ -8876,7 +9104,7 @@ function clearCheckpoints(scopeHash, repoRoots) {
8876
9104
  for (const root of repoRoots) {
8877
9105
  for (const entry of manifest.entries) {
8878
9106
  const refName = `refs/wd/${scopeHash}/${entry.id}`;
8879
- spawn10.sync("git", ["update-ref", "-d", refName], {
9107
+ spawn12.sync("git", ["update-ref", "-d", refName], {
8880
9108
  cwd: root,
8881
9109
  encoding: "utf-8",
8882
9110
  windowsHide: true
@@ -8884,21 +9112,21 @@ function clearCheckpoints(scopeHash, repoRoots) {
8884
9112
  }
8885
9113
  }
8886
9114
  const file = manifestPath(scopeHash);
8887
- if (fs35.existsSync(file)) {
9115
+ if (fs39.existsSync(file)) {
8888
9116
  try {
8889
- fs35.unlinkSync(file);
9117
+ fs39.unlinkSync(file);
8890
9118
  } catch {
8891
9119
  }
8892
9120
  }
8893
9121
  }
8894
9122
 
8895
9123
  // src/core/scope-manager.ts
8896
- import path37 from "path";
8897
- import crypto6 from "crypto";
9124
+ import path39 from "path";
9125
+ import crypto5 from "crypto";
8898
9126
  var scopes = /* @__PURE__ */ new Map();
8899
9127
  function hashFor(paths) {
8900
9128
  const key = paths.slice().sort().join("|");
8901
- return crypto6.createHash("sha1").update(key).digest("hex").slice(0, 12);
9129
+ return crypto5.createHash("sha1").update(key).digest("hex").slice(0, 12);
8902
9130
  }
8903
9131
  var ScopePathRejectedError = class extends Error {
8904
9132
  constructor(rejected) {
@@ -8910,7 +9138,7 @@ var ScopePathRejectedError = class extends Error {
8910
9138
  }
8911
9139
  };
8912
9140
  function normaliseForCompare(p) {
8913
- return path37.resolve(p).replace(/\\/g, "/").toLowerCase();
9141
+ return path39.resolve(p).replace(/\\/g, "/").toLowerCase();
8914
9142
  }
8915
9143
  function rejectedPaths(normalised) {
8916
9144
  const config = loadConfig();
@@ -8928,7 +9156,7 @@ function rejectedPaths(normalised) {
8928
9156
  });
8929
9157
  }
8930
9158
  function registerScope(paths, label2) {
8931
- const normalised = paths.map((p) => path37.resolve(p));
9159
+ const normalised = paths.map((p) => path39.resolve(p));
8932
9160
  const rejected = rejectedPaths(normalised);
8933
9161
  if (rejected.length > 0) throw new ScopePathRejectedError(rejected);
8934
9162
  const hash = hashFor(normalised);
@@ -8942,7 +9170,7 @@ function registerScope(paths, label2) {
8942
9170
  const scope = {
8943
9171
  hash,
8944
9172
  paths: normalised,
8945
- label: label2 ?? path37.basename(normalised[0]),
9173
+ label: label2 ?? path39.basename(normalised[0]),
8946
9174
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8947
9175
  ended: false
8948
9176
  };
@@ -9015,7 +9243,7 @@ function mountScopeRoutes(app, opts) {
9015
9243
  function workingTreeFingerprint(paths) {
9016
9244
  const parts = [];
9017
9245
  for (const p of paths) {
9018
- const r = spawn11.sync(
9246
+ const r = spawn13.sync(
9019
9247
  "git",
9020
9248
  ["status", "--porcelain", "--no-renames", "-z"],
9021
9249
  { cwd: p, encoding: "utf-8", windowsHide: true }
@@ -9139,7 +9367,7 @@ function mountScopeRoutes(app, opts) {
9139
9367
  const fromSha = fromEntry.repos[p] ?? "HEAD";
9140
9368
  const toSha = toEntry === void 0 ? "working" : toEntry.repos[p] ?? "HEAD";
9141
9369
  return {
9142
- name: path38.basename(p),
9370
+ name: path40.basename(p),
9143
9371
  root: p,
9144
9372
  files: computeRangeDiff({ root: p, fromRef: fromSha, toRef: toSha })
9145
9373
  };
@@ -9153,19 +9381,46 @@ function mountScopeRoutes(app, opts) {
9153
9381
  repos: repos2
9154
9382
  });
9155
9383
  }
9156
- const resolved = scope.paths.map((p) => resolveRepoDiff(p, base));
9384
+ const resolved = scope.paths.map(
9385
+ (p) => resolveRepoDiff(p, base, sessionBaseForPath(p))
9386
+ );
9157
9387
  const repos = scope.paths.map((p, i) => ({
9158
- name: path38.basename(p),
9388
+ name: path40.basename(p),
9159
9389
  root: p,
9160
9390
  resolvedBase: resolved[i].resolvedBase,
9161
9391
  files: computeDiff({ root: p, diffArg: resolved[i].diffArg })
9162
9392
  }));
9163
9393
  const resolvedBase = resolved[0]?.resolvedBase ?? "HEAD";
9164
- return c.json({ scopeHash: scope.hash, base, resolvedBase, repos });
9394
+ const head = git(["rev-parse", "--abbrev-ref", "HEAD"], scope.paths[0]);
9395
+ const headBranch = head.exitCode === 0 && head.stdout && head.stdout !== "HEAD" ? head.stdout : void 0;
9396
+ return c.json({
9397
+ scopeHash: scope.hash,
9398
+ base,
9399
+ resolvedBase,
9400
+ headBranch,
9401
+ repos
9402
+ });
9165
9403
  } catch (err) {
9166
9404
  return c.json({ error: err.message }, 500);
9167
9405
  }
9168
9406
  });
9407
+ app.get("/api/scopes/:hash/file-lines", (c) => {
9408
+ const scope = getScope(c.req.param("hash"));
9409
+ if (!scope) return c.json({ error: "unknown scope" }, 404);
9410
+ const repoName = c.req.query("repo") ?? "";
9411
+ const relPath = c.req.query("path") ?? "";
9412
+ const start = Number(c.req.query("start"));
9413
+ const end = Number(c.req.query("end"));
9414
+ const ref = c.req.query("ref") || void 0;
9415
+ if (!relPath || !Number.isInteger(start) || !Number.isInteger(end)) {
9416
+ return c.json({ error: "bad path/start/end" }, 400);
9417
+ }
9418
+ const root = scope.paths.length === 1 ? scope.paths[0] : scope.paths.find((p) => path40.basename(p) === repoName);
9419
+ if (!root) return c.json({ error: "unknown repo" }, 404);
9420
+ const result = readContextLines({ root, relPath, start, end, ref });
9421
+ if (!result) return c.json({ error: "cannot read file" }, 400);
9422
+ return c.json(result);
9423
+ });
9169
9424
  app.get("/api/scopes/:hash/checkpoints", (c) => {
9170
9425
  const scope = getScope(c.req.param("hash"));
9171
9426
  if (!scope) return c.json({ error: "unknown scope" }, 404);
@@ -9394,16 +9649,19 @@ function sessionToWire(s) {
9394
9649
  };
9395
9650
  }
9396
9651
  function computeSessionDiff(s, base) {
9397
- const resolved = s.paths.map((p) => resolveRepoDiff(p, base, s.baseBranch));
9652
+ const resolved = s.paths.map(
9653
+ (p) => resolveRepoDiff(p, base, s.baseBranches?.[p] ?? s.baseBranch)
9654
+ );
9398
9655
  const repos = s.paths.map((p, i) => ({
9399
- name: path39.basename(p),
9656
+ name: path41.basename(p),
9400
9657
  root: p,
9401
9658
  resolvedBase: resolved[i].resolvedBase,
9402
9659
  files: computeDiff({ root: p, diffArg: resolved[i].diffArg })
9403
9660
  }));
9404
9661
  return { repos, resolvedBase: resolved[0]?.resolvedBase ?? "HEAD" };
9405
9662
  }
9406
- async function startWebServer() {
9663
+ async function startWebServer(opts = {}) {
9664
+ const { lean = false } = opts;
9407
9665
  const webRoot = resolveWebRoot();
9408
9666
  if (!webRoot) {
9409
9667
  throw new Error(
@@ -9415,25 +9673,27 @@ async function startWebServer() {
9415
9673
  for (const cb of sseListeners) cb({ event, data });
9416
9674
  };
9417
9675
  const home = os12.homedir();
9418
- const historyPath = path39.join(home, ".work", "history.json");
9676
+ const historyPath = path41.join(home, ".work", "history.json");
9419
9677
  const onHistoryChange = () => broadcast("sessions-changed", { ts: Date.now() });
9420
- fs36.watchFile(historyPath, { interval: 1e3 }, onHistoryChange);
9421
- const tasksPath = path39.join(home, ".work", "tasks.json");
9678
+ fs40.watchFile(historyPath, { interval: 1e3 }, onHistoryChange);
9679
+ const tasksPath = path41.join(home, ".work", "tasks.json");
9422
9680
  const onTasksChange = () => broadcast("tasks-changed", { ts: Date.now() });
9423
- fs36.watchFile(tasksPath, { interval: 1e3 }, onTasksChange);
9681
+ fs40.watchFile(tasksPath, { interval: 1e3 }, onTasksChange);
9424
9682
  const projectsRoot = claudeProjectsRoot();
9425
9683
  let activityWatcher = null;
9426
- try {
9427
- if (fs36.existsSync(projectsRoot)) {
9428
- activityWatcher = createFsWatcher({
9429
- roots: [projectsRoot],
9430
- debounceMs: 250,
9431
- onChange: () => broadcast("sessions-changed", { ts: Date.now() })
9432
- });
9684
+ if (!lean) {
9685
+ try {
9686
+ if (fs40.existsSync(projectsRoot)) {
9687
+ activityWatcher = createFsWatcher({
9688
+ roots: [projectsRoot],
9689
+ debounceMs: 250,
9690
+ onChange: () => broadcast("sessions-changed", { ts: Date.now() })
9691
+ });
9692
+ }
9693
+ } catch {
9433
9694
  }
9434
- } catch {
9435
9695
  }
9436
- const decayTick = setInterval(
9696
+ const decayTick = lean ? null : setInterval(
9437
9697
  () => broadcast("sessions-changed", { ts: Date.now() }),
9438
9698
  1e4
9439
9699
  );
@@ -9498,9 +9758,9 @@ async function startWebServer() {
9498
9758
  url: handle.url,
9499
9759
  port: handle.port,
9500
9760
  stop: async () => {
9501
- fs36.unwatchFile(historyPath, onHistoryChange);
9502
- fs36.unwatchFile(tasksPath, onTasksChange);
9503
- clearInterval(decayTick);
9761
+ fs40.unwatchFile(historyPath, onHistoryChange);
9762
+ fs40.unwatchFile(tasksPath, onTasksChange);
9763
+ if (decayTick) clearInterval(decayTick);
9504
9764
  activityWatcher?.stop();
9505
9765
  disposeAllWatchers();
9506
9766
  for (const scope of listScopes()) {
@@ -9561,12 +9821,12 @@ function info2(message) {
9561
9821
  process.stderr.write(message + "\n");
9562
9822
  }
9563
9823
  function urlFilePath() {
9564
- return path40.join(os13.homedir(), ".work", "web.url");
9824
+ return path42.join(os13.homedir(), ".work", "web.url");
9565
9825
  }
9566
9826
  function pidFilePath() {
9567
- return path40.join(os13.homedir(), ".work", "web.pid");
9827
+ return path42.join(os13.homedir(), ".work", "web.pid");
9568
9828
  }
9569
- function isPidAlive2(pid) {
9829
+ function isPidAlive(pid) {
9570
9830
  try {
9571
9831
  process.kill(pid, 0);
9572
9832
  return true;
@@ -9574,9 +9834,9 @@ function isPidAlive2(pid) {
9574
9834
  return false;
9575
9835
  }
9576
9836
  }
9577
- function readPid2() {
9837
+ function readPid() {
9578
9838
  try {
9579
- const raw = fs37.readFileSync(pidFilePath(), "utf-8").trim();
9839
+ const raw = fs41.readFileSync(pidFilePath(), "utf-8").trim();
9580
9840
  const n = Number(raw);
9581
9841
  return Number.isFinite(n) && n > 0 ? n : null;
9582
9842
  } catch {
@@ -9585,7 +9845,7 @@ function readPid2() {
9585
9845
  }
9586
9846
  function readUrl() {
9587
9847
  try {
9588
- const v = fs37.readFileSync(urlFilePath(), "utf-8").trim();
9848
+ const v = fs41.readFileSync(urlFilePath(), "utf-8").trim();
9589
9849
  return v || null;
9590
9850
  } catch {
9591
9851
  return null;
@@ -9603,19 +9863,19 @@ async function pingsAlive(url, timeoutMs = 500) {
9603
9863
  }
9604
9864
  }
9605
9865
  function stopExisting() {
9606
- const pid = readPid2();
9866
+ const pid = readPid();
9607
9867
  if (!pid) {
9608
9868
  info2(chalk23.gray("No work web running."));
9609
9869
  return false;
9610
9870
  }
9611
- if (!isPidAlive2(pid)) {
9871
+ if (!isPidAlive(pid)) {
9612
9872
  info2(chalk23.gray(`Stale PID ${pid} \u2014 cleaning up.`));
9613
9873
  try {
9614
- fs37.unlinkSync(pidFilePath());
9874
+ fs41.unlinkSync(pidFilePath());
9615
9875
  } catch {
9616
9876
  }
9617
9877
  try {
9618
- fs37.unlinkSync(urlFilePath());
9878
+ fs41.unlinkSync(urlFilePath());
9619
9879
  } catch {
9620
9880
  }
9621
9881
  return false;
@@ -9624,11 +9884,11 @@ function stopExisting() {
9624
9884
  process.kill(pid);
9625
9885
  info2(chalk23.gray(`Stopped work web (PID ${pid}).`));
9626
9886
  try {
9627
- fs37.unlinkSync(pidFilePath());
9887
+ fs41.unlinkSync(pidFilePath());
9628
9888
  } catch {
9629
9889
  }
9630
9890
  try {
9631
- fs37.unlinkSync(urlFilePath());
9891
+ fs41.unlinkSync(urlFilePath());
9632
9892
  } catch {
9633
9893
  }
9634
9894
  return true;
@@ -9648,14 +9908,19 @@ var webCommand = {
9648
9908
  type: "boolean",
9649
9909
  default: false,
9650
9910
  describe: "Stop a running work web instance and exit."
9911
+ }).option("lean", {
9912
+ type: "boolean",
9913
+ default: false,
9914
+ hidden: true,
9915
+ describe: "Internal: start without dashboard-only features (Claude activity watcher + hooks). Used by `wd` when it auto-starts work web for a diff-only session."
9651
9916
  }),
9652
9917
  handler: async (argv) => {
9653
9918
  if (argv.stop) {
9654
9919
  stopExisting();
9655
9920
  process.exit(0);
9656
9921
  }
9657
- const existingPid = readPid2();
9658
- if (existingPid && isPidAlive2(existingPid)) {
9922
+ const existingPid = readPid();
9923
+ if (existingPid && isPidAlive(existingPid)) {
9659
9924
  const url = readUrl();
9660
9925
  if (url && await pingsAlive(url)) {
9661
9926
  info2(
@@ -9674,55 +9939,64 @@ var webCommand = {
9674
9939
  process.exit(1);
9675
9940
  }
9676
9941
  try {
9677
- fs37.unlinkSync(pidFilePath());
9942
+ fs41.unlinkSync(pidFilePath());
9678
9943
  } catch {
9679
9944
  }
9680
9945
  try {
9681
- fs37.unlinkSync(urlFilePath());
9946
+ fs41.unlinkSync(urlFilePath());
9682
9947
  } catch {
9683
9948
  }
9684
- const handle = await startWebServer();
9949
+ const lean = !!argv.lean || process.env.WORK_WEB_LEAN === "1";
9950
+ const handle = await startWebServer({ lean });
9685
9951
  try {
9686
- fs37.mkdirSync(path40.dirname(urlFilePath()), { recursive: true });
9687
- fs37.writeFileSync(urlFilePath(), handle.url);
9688
- fs37.writeFileSync(pidFilePath(), String(process.pid));
9952
+ fs41.mkdirSync(path42.dirname(urlFilePath()), { recursive: true });
9953
+ fs41.writeFileSync(urlFilePath(), handle.url);
9954
+ fs41.writeFileSync(pidFilePath(), String(process.pid));
9689
9955
  } catch {
9690
9956
  }
9691
- info2(chalk23.gray(`work web running at ${handle.url}`));
9957
+ info2(
9958
+ chalk23.gray(
9959
+ `work web running at ${handle.url}${lean ? " (lean \u2014 diff-only mode)" : ""}`
9960
+ )
9961
+ );
9692
9962
  info2(chalk23.gray("Press Ctrl+C to stop. Or: `work web --stop` from another shell."));
9693
9963
  if (argv.open) openUrl(handle.url);
9694
- await Promise.all([
9695
- installCommandHook({
9696
- owner: "web",
9697
- event: "UserPromptSubmit",
9698
- command: "work hook prompt-submit",
9699
- timeoutSec: 5
9700
- }),
9701
- installCommandHook({
9702
- owner: "web",
9703
- event: "Stop",
9704
- command: "work hook stop",
9705
- timeoutSec: 5
9706
- })
9707
- ]).catch(() => {
9708
- });
9964
+ if (!lean) {
9965
+ await Promise.all([
9966
+ installCommandHook({
9967
+ owner: "web",
9968
+ event: "UserPromptSubmit",
9969
+ command: "work hook prompt-submit",
9970
+ timeoutSec: 5
9971
+ }),
9972
+ installCommandHook({
9973
+ owner: "web",
9974
+ event: "Stop",
9975
+ command: "work hook stop",
9976
+ timeoutSec: 5
9977
+ })
9978
+ ]).catch(() => {
9979
+ });
9980
+ }
9709
9981
  const shutdown = () => {
9710
9982
  info2(chalk23.gray("\nStopping work web."));
9711
9983
  try {
9712
- fs37.unlinkSync(urlFilePath());
9984
+ fs41.unlinkSync(urlFilePath());
9713
9985
  } catch {
9714
9986
  }
9715
9987
  try {
9716
- fs37.unlinkSync(pidFilePath());
9988
+ fs41.unlinkSync(pidFilePath());
9717
9989
  } catch {
9718
9990
  }
9719
- try {
9720
- removeCommandHookSync("web", "UserPromptSubmit");
9721
- } catch {
9722
- }
9723
- try {
9724
- removeCommandHookSync("web", "Stop");
9725
- } catch {
9991
+ if (!lean) {
9992
+ try {
9993
+ removeCommandHookSync("web", "UserPromptSubmit");
9994
+ } catch {
9995
+ }
9996
+ try {
9997
+ removeCommandHookSync("web", "Stop");
9998
+ } catch {
9999
+ }
9726
10000
  }
9727
10001
  handle.stop();
9728
10002
  process.exit(0);
@@ -9731,11 +10005,11 @@ var webCommand = {
9731
10005
  process.on("SIGTERM", shutdown);
9732
10006
  process.on("exit", () => {
9733
10007
  try {
9734
- fs37.unlinkSync(pidFilePath());
10008
+ fs41.unlinkSync(pidFilePath());
9735
10009
  } catch {
9736
10010
  }
9737
10011
  try {
9738
- fs37.unlinkSync(urlFilePath());
10012
+ fs41.unlinkSync(urlFilePath());
9739
10013
  } catch {
9740
10014
  }
9741
10015
  });
@@ -9751,7 +10025,7 @@ function computeHookOutput(input2) {
9751
10025
  const activity = readSessionActivity(session);
9752
10026
  if (activity.state === "stale") return null;
9753
10027
  const sessionId = sessionIdFor(session);
9754
- const pending = readPendingForSession(sessionId);
10028
+ const pending = readPendingForWorktree(session);
9755
10029
  if (pending.length === 0) return null;
9756
10030
  const text = formatPendingForPrompt(pending);
9757
10031
  if (!text) return null;
@@ -9810,7 +10084,7 @@ var hookCommand = {
9810
10084
 
9811
10085
  // src/commands/run.ts
9812
10086
  import chalk24 from "chalk";
9813
- import spawn12 from "cross-spawn";
10087
+ import spawn14 from "cross-spawn";
9814
10088
 
9815
10089
  // src/core/fleet.ts
9816
10090
  function selectSessions(sessions, filter) {
@@ -9852,7 +10126,7 @@ function killAllChildren(signal = "SIGTERM") {
9852
10126
  function runInPath(unit, cmd, prefix) {
9853
10127
  const { bin, args } = shellInvocation(cmd);
9854
10128
  return new Promise((resolve) => {
9855
- const child = spawn12(bin, args, {
10129
+ const child = spawn14(bin, args, {
9856
10130
  cwd: unit.path,
9857
10131
  stdio: prefix ? ["ignore", "pipe", "pipe"] : "inherit",
9858
10132
  shell: false
@@ -10084,15 +10358,15 @@ var runCommand = {
10084
10358
  };
10085
10359
 
10086
10360
  // src/commands/broadcast.ts
10087
- import fs39 from "fs";
10361
+ import fs43 from "fs";
10088
10362
  import chalk25 from "chalk";
10089
10363
 
10090
10364
  // src/core/broadcast.ts
10091
- import crypto7 from "crypto";
10092
- import fs38 from "fs";
10365
+ import crypto6 from "crypto";
10366
+ import fs42 from "fs";
10093
10367
  function readComments(file) {
10094
10368
  try {
10095
- const parsed = JSON.parse(fs38.readFileSync(file, "utf-8"));
10369
+ const parsed = JSON.parse(fs42.readFileSync(file, "utf-8"));
10096
10370
  return Array.isArray(parsed) ? parsed : [];
10097
10371
  } catch {
10098
10372
  return [];
@@ -10100,10 +10374,10 @@ function readComments(file) {
10100
10374
  }
10101
10375
  async function appendLocked(sessionId, body) {
10102
10376
  const file = commentsFileFor(sessionId);
10103
- fs38.mkdirSync(commentsDir(), { recursive: true });
10377
+ fs42.mkdirSync(commentsDir(), { recursive: true });
10104
10378
  ensureFile(file, "[]");
10105
10379
  const comment = {
10106
- id: crypto7.randomBytes(8).toString("hex"),
10380
+ id: crypto6.randomBytes(8).toString("hex"),
10107
10381
  repo: "",
10108
10382
  file: "",
10109
10383
  line: 0,
@@ -10137,7 +10411,7 @@ async function broadcastPrompt(sessions, filter, prompt) {
10137
10411
  // src/commands/broadcast.ts
10138
10412
  function readStdin() {
10139
10413
  try {
10140
- return fs39.readFileSync(0, "utf-8");
10414
+ return fs43.readFileSync(0, "utf-8");
10141
10415
  } catch {
10142
10416
  return "";
10143
10417
  }
@@ -10212,8 +10486,8 @@ var broadcastCommand = {
10212
10486
  };
10213
10487
 
10214
10488
  // src/completions/index.ts
10215
- import fs40 from "fs";
10216
- import path41 from "path";
10489
+ import fs44 from "fs";
10490
+ import path43 from "path";
10217
10491
  function completionHandler(current, argv, done) {
10218
10492
  const rawArgs = argv._;
10219
10493
  const args = rawArgs.slice(1, -1);
@@ -10317,7 +10591,7 @@ function completeTreeRemoveList(command, args, current, config, done) {
10317
10591
  }
10318
10592
  function completeRepoBranches(alias, current, config, done) {
10319
10593
  const repoPath = config.repos[alias];
10320
- if (!repoPath || !fs40.existsSync(repoPath)) {
10594
+ if (!repoPath || !fs44.existsSync(repoPath)) {
10321
10595
  done([]);
10322
10596
  return;
10323
10597
  }
@@ -10326,19 +10600,19 @@ function completeRepoBranches(alias, current, config, done) {
10326
10600
  done(branches);
10327
10601
  }
10328
10602
  function completeGroupBranches(groupName, repoAliases, current, config, done) {
10329
- const groupDir = path41.join(config.worktreesRoot, groupName);
10330
- if (!fs40.existsSync(groupDir)) {
10603
+ const groupDir = path43.join(config.worktreesRoot, groupName);
10604
+ if (!fs44.existsSync(groupDir)) {
10331
10605
  done([]);
10332
10606
  return;
10333
10607
  }
10334
10608
  try {
10335
- const branchDirs = fs40.readdirSync(groupDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
10336
- const bdPath = path41.join(groupDir, d.name);
10609
+ const branchDirs = fs44.readdirSync(groupDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
10610
+ const bdPath = path43.join(groupDir, d.name);
10337
10611
  for (const alias of repoAliases) {
10338
10612
  const repoPath = config.repos[alias];
10339
10613
  if (!repoPath) continue;
10340
- const subPath = path41.join(bdPath, path41.basename(repoPath));
10341
- if (fs40.existsSync(subPath)) {
10614
+ const subPath = path43.join(bdPath, path43.basename(repoPath));
10615
+ if (fs44.existsSync(subPath)) {
10342
10616
  const branch = getCurrentBranch(subPath);
10343
10617
  if (branch) return branch;
10344
10618
  }
@@ -10352,7 +10626,7 @@ function completeGroupBranches(groupName, repoAliases, current, config, done) {
10352
10626
  }
10353
10627
 
10354
10628
  // src/version.ts
10355
- var VERSION = true ? "1.5.0" : "dev";
10629
+ var VERSION = true ? "1.7.0" : "dev";
10356
10630
 
10357
10631
  // src/cli.ts
10358
10632
  function showHelp() {
@@ -10449,8 +10723,8 @@ function handleFatalError(err) {
10449
10723
  }
10450
10724
  if (err instanceof Error && err.message?.includes("pty that has already exited")) {
10451
10725
  try {
10452
- fs41.appendFileSync(
10453
- path42.join(getConfigDir(), "debug.log"),
10726
+ fs45.appendFileSync(
10727
+ path44.join(getConfigDir(), "debug.log"),
10454
10728
  `${(/* @__PURE__ */ new Date()).toISOString()} [WARN] Ignored async node-pty error: ${err.message}
10455
10729
  `
10456
10730
  );
@@ -10460,8 +10734,8 @@ function handleFatalError(err) {
10460
10734
  }
10461
10735
  try {
10462
10736
  const msg = err instanceof Error ? err.stack || err.message : String(err);
10463
- fs41.appendFileSync(
10464
- path42.join(getConfigDir(), "debug.log"),
10737
+ fs45.appendFileSync(
10738
+ path44.join(getConfigDir(), "debug.log"),
10465
10739
  `${(/* @__PURE__ */ new Date()).toISOString()} [FATAL] handleFatalError: ${msg}
10466
10740
  `
10467
10741
  );