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