@xbghc/warden 0.16.0 → 0.16.2

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/cli.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/cli.ts
4
- import path11 from "path";
4
+ import path12 from "path";
5
5
  import { spawn } from "child_process";
6
6
  import { existsSync as existsSync2 } from "fs";
7
7
  import { fileURLToPath } from "url";
8
8
 
9
9
  // packages/server/src/index.ts
10
10
  import net from "net";
11
- import { randomUUID as randomUUID4 } from "crypto";
11
+ import { randomUUID as randomUUID5 } from "crypto";
12
12
 
13
13
  // node_modules/.pnpm/@hono+node-server@1.19.17_hono@4.13.7/node_modules/@hono/node-server/dist/index.mjs
14
14
  import { createServer as createServerHTTP } from "http";
@@ -652,9 +652,9 @@ var serve = (options, listeningListener) => {
652
652
  };
653
653
 
654
654
  // packages/server/src/app.ts
655
- import path9 from "path";
655
+ import path10 from "path";
656
656
  import { stat as stat7 } from "fs/promises";
657
- import { randomUUID as randomUUID3 } from "crypto";
657
+ import { randomUUID as randomUUID4 } from "crypto";
658
658
 
659
659
  // node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/compose.js
660
660
  var compose = (middleware, onError, onNotFound) => {
@@ -813,26 +813,26 @@ var throwNestingLimitExceeded = () => {
813
813
  };
814
814
 
815
815
  // node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/utils/url.js
816
- var splitPath = (path12) => {
817
- const paths = path12.split("/");
816
+ var splitPath = (path13) => {
817
+ const paths = path13.split("/");
818
818
  if (paths[0] === "") {
819
819
  paths.shift();
820
820
  }
821
821
  return paths;
822
822
  };
823
823
  var splitRoutingPath = (routePath) => {
824
- const { groups, path: path12 } = extractGroupsFromPath(routePath);
825
- const paths = splitPath(path12);
824
+ const { groups, path: path13 } = extractGroupsFromPath(routePath);
825
+ const paths = splitPath(path13);
826
826
  return replaceGroupMarks(paths, groups);
827
827
  };
828
- var extractGroupsFromPath = (path12) => {
828
+ var extractGroupsFromPath = (path13) => {
829
829
  const groups = [];
830
- path12 = path12.replace(/\{[^}]+\}/g, (match2, index) => {
830
+ path13 = path13.replace(/\{[^}]+\}/g, (match2, index) => {
831
831
  const mark = `@${index}`;
832
832
  groups.push([mark, match2]);
833
833
  return mark;
834
834
  });
835
- return { groups, path: path12 };
835
+ return { groups, path: path13 };
836
836
  };
837
837
  var replaceGroupMarks = (paths, groups) => {
838
838
  for (let i = groups.length - 1; i >= 0; i--) {
@@ -889,8 +889,8 @@ var getPath = (request) => {
889
889
  const queryIndex = url.indexOf("?", i);
890
890
  const hashIndex = url.indexOf("#", i);
891
891
  const end = queryIndex === -1 ? hashIndex === -1 ? void 0 : hashIndex : hashIndex === -1 ? queryIndex : Math.min(queryIndex, hashIndex);
892
- const path12 = url.slice(start, end);
893
- return tryDecodeURI(path12.includes("%25") ? path12.replace(/%25/g, "%2525") : path12);
892
+ const path13 = url.slice(start, end);
893
+ return tryDecodeURI(path13.includes("%25") ? path13.replace(/%25/g, "%2525") : path13);
894
894
  } else if (charCode === 63 || charCode === 35) {
895
895
  break;
896
896
  }
@@ -907,11 +907,11 @@ var mergePath = (base, sub, ...rest) => {
907
907
  }
908
908
  return `${base?.[0] === "/" ? "" : "/"}${base}${sub === "/" ? "" : `${base?.at(-1) === "/" ? "" : "/"}${sub?.[0] === "/" ? sub.slice(1) : sub}`}`;
909
909
  };
910
- var checkOptionalParameter = (path12) => {
911
- if (path12.charCodeAt(path12.length - 1) !== 63 || !path12.includes(":")) {
910
+ var checkOptionalParameter = (path13) => {
911
+ if (path13.charCodeAt(path13.length - 1) !== 63 || !path13.includes(":")) {
912
912
  return null;
913
913
  }
914
- const segments = path12.split("/");
914
+ const segments = path13.split("/");
915
915
  const results = [];
916
916
  let basePath = "";
917
917
  segments.forEach((segment) => {
@@ -1053,9 +1053,9 @@ var HonoRequest = class {
1053
1053
  */
1054
1054
  path;
1055
1055
  bodyCache = {};
1056
- constructor(request, path12 = "/", matchResult = [[]]) {
1056
+ constructor(request, path13 = "/", matchResult = [[]]) {
1057
1057
  this.raw = request;
1058
- this.path = path12;
1058
+ this.path = path13;
1059
1059
  this.#matchResult = matchResult;
1060
1060
  }
1061
1061
  param(key) {
@@ -1827,8 +1827,8 @@ var Hono = class _Hono {
1827
1827
  return this;
1828
1828
  };
1829
1829
  });
1830
- this.on = (method, path12, ...handlers) => {
1831
- for (const p of [path12].flat()) {
1830
+ this.on = (method, path13, ...handlers) => {
1831
+ for (const p of [path13].flat()) {
1832
1832
  this.#path = p;
1833
1833
  for (const m of [method].flat()) {
1834
1834
  const methodName = m.toUpperCase();
@@ -1886,8 +1886,8 @@ var Hono = class _Hono {
1886
1886
  * app.route("/api", app2) // GET /api/user
1887
1887
  * ```
1888
1888
  */
1889
- route(path12, app) {
1890
- const subApp = this.basePath(path12);
1889
+ route(path13, app) {
1890
+ const subApp = this.basePath(path13);
1891
1891
  app.routes.map((r) => {
1892
1892
  let handler;
1893
1893
  if (app.errorHandler === errorHandler) {
@@ -1913,9 +1913,9 @@ var Hono = class _Hono {
1913
1913
  * const api = new Hono().basePath('/api')
1914
1914
  * ```
1915
1915
  */
1916
- basePath(path12) {
1916
+ basePath(path13) {
1917
1917
  const subApp = this.#clone();
1918
- subApp._basePath = mergePath(this._basePath, path12);
1918
+ subApp._basePath = mergePath(this._basePath, path13);
1919
1919
  return subApp;
1920
1920
  }
1921
1921
  /**
@@ -1989,7 +1989,7 @@ var Hono = class _Hono {
1989
1989
  * })
1990
1990
  * ```
1991
1991
  */
1992
- mount(path12, applicationHandler, options) {
1992
+ mount(path13, applicationHandler, options) {
1993
1993
  let replaceRequest;
1994
1994
  let optionHandler;
1995
1995
  if (options) {
@@ -2016,7 +2016,7 @@ var Hono = class _Hono {
2016
2016
  return [c.env, executionContext];
2017
2017
  };
2018
2018
  replaceRequest ||= (() => {
2019
- const mergedPath = mergePath(this._basePath, path12);
2019
+ const mergedPath = mergePath(this._basePath, path13);
2020
2020
  const pathPrefixLength = mergedPath === "/" ? 0 : mergedPath.length;
2021
2021
  return (request) => {
2022
2022
  const url = new URL(request.url);
@@ -2031,18 +2031,18 @@ var Hono = class _Hono {
2031
2031
  }
2032
2032
  await next();
2033
2033
  };
2034
- this.#addRoute(METHOD_NAME_ALL, mergePath(path12, "*"), handler);
2034
+ this.#addRoute(METHOD_NAME_ALL, mergePath(path13, "*"), handler);
2035
2035
  return this;
2036
2036
  }
2037
- #addRoute(method, path12, handler, baseRoutePath) {
2038
- path12 = mergePath(this._basePath, path12);
2037
+ #addRoute(method, path13, handler, baseRoutePath) {
2038
+ path13 = mergePath(this._basePath, path13);
2039
2039
  const r = {
2040
2040
  basePath: baseRoutePath !== void 0 ? mergePath(this._basePath, baseRoutePath) : this._basePath,
2041
- path: path12,
2041
+ path: path13,
2042
2042
  method,
2043
2043
  handler
2044
2044
  };
2045
- this.router.add(method, path12, [handler, r]);
2045
+ this.router.add(method, path13, [handler, r]);
2046
2046
  this.routes.push(r);
2047
2047
  }
2048
2048
  #handleError(err, c) {
@@ -2055,10 +2055,10 @@ var Hono = class _Hono {
2055
2055
  if (method === "HEAD") {
2056
2056
  return (async () => new Response(null, await this.#dispatch(request, executionCtx, env, "GET")))();
2057
2057
  }
2058
- const path12 = this.getPath(request, { env });
2059
- const matchResult = this.router.match(method, path12);
2058
+ const path13 = this.getPath(request, { env });
2059
+ const matchResult = this.router.match(method, path13);
2060
2060
  const c = new Context(request, {
2061
- path: path12,
2061
+ path: path13,
2062
2062
  matchResult,
2063
2063
  env,
2064
2064
  executionCtx,
@@ -2161,7 +2161,7 @@ var createNullObject = () => /* @__PURE__ */ Object.create(null);
2161
2161
 
2162
2162
  // node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/router/reg-exp-router/matcher.js
2163
2163
  var emptyParam = [];
2164
- function match(method, path12) {
2164
+ function match(method, path13) {
2165
2165
  const matchers = this.buildAllMatchers();
2166
2166
  const match2 = ((method2, path22) => {
2167
2167
  const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
@@ -2177,7 +2177,7 @@ function match(method, path12) {
2177
2177
  return [matcher[1][index], match3];
2178
2178
  });
2179
2179
  this.match = match2;
2180
- return match2(method, path12);
2180
+ return match2(method, path13);
2181
2181
  }
2182
2182
 
2183
2183
  // node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/router/reg-exp-router/node.js
@@ -2294,14 +2294,14 @@ var Trie = class {
2294
2294
  #index = 0;
2295
2295
  // dynamic path -> [handler index, param assoc]; static paths are not registered
2296
2296
  paths = createNullObject();
2297
- insert(path12, isStatic) {
2297
+ insert(path13, isStatic) {
2298
2298
  if (isStatic) {
2299
- this.#root.insert(path12.split(""), 0, [], this.#context, true);
2299
+ this.#root.insert(path13.split(""), 0, [], this.#context, true);
2300
2300
  return;
2301
2301
  }
2302
2302
  const paramAssoc = [];
2303
2303
  const groups = [];
2304
- let markedPath = path12;
2304
+ let markedPath = path13;
2305
2305
  for (let i = 0; ; ) {
2306
2306
  let replaced = false;
2307
2307
  markedPath = markedPath.replace(/\{[^}]+\}/g, (m) => {
@@ -2326,7 +2326,7 @@ var Trie = class {
2326
2326
  }
2327
2327
  }
2328
2328
  this.#root.insert(tokens, this.#index, paramAssoc, this.#context, false);
2329
- this.paths[path12] = [this.#index++, paramAssoc];
2329
+ this.paths[path13] = [this.#index++, paramAssoc];
2330
2330
  }
2331
2331
  buildRegExp() {
2332
2332
  let regexp = this.#root.buildRegExpStr();
@@ -2353,17 +2353,17 @@ var Trie = class {
2353
2353
 
2354
2354
  // node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/router/reg-exp-router/router.js
2355
2355
  var wildcardRegExpCache = createNullObject();
2356
- function buildWildcardRegExp(path12) {
2357
- return wildcardRegExpCache[path12] ??= new RegExp(
2358
- `^${path12.replace(
2356
+ function buildWildcardRegExp(path13) {
2357
+ return wildcardRegExpCache[path13] ??= new RegExp(
2358
+ `^${path13.replace(
2359
2359
  /\/:[^/{}]+(?:\{\[\^\/]\+})?(?=[/{]|$)|\/?\*$|([.\\+*[^\]$()?{}|])/g,
2360
2360
  (match2, metaChar) => metaChar ? `\\${metaChar}` : match2 === "/*" ? TAIL_WILDCARD_REG_EXP_STR : match2 === "*" ? ONLY_WILDCARD_REG_EXP_STR : `/:${LABEL_REG_EXP_STR}`
2361
2361
  )}$`
2362
2362
  );
2363
2363
  }
2364
- function findMiddleware(middleware, path12) {
2364
+ function findMiddleware(middleware, path13) {
2365
2365
  for (const k of Object.keys(middleware).sort((a, b) => b.length - a.length)) {
2366
- if (buildWildcardRegExp(k).test(path12)) {
2366
+ if (buildWildcardRegExp(k).test(path13)) {
2367
2367
  return [...middleware[k]];
2368
2368
  }
2369
2369
  }
@@ -2379,14 +2379,14 @@ var RegExpRouter = class {
2379
2379
  this.#routes = { [METHOD_NAME_ALL]: createNullObject() };
2380
2380
  this.#tries = { [METHOD_NAME_ALL]: new Trie() };
2381
2381
  }
2382
- #insertPath(method, path12) {
2382
+ #insertPath(method, path13) {
2383
2383
  try {
2384
- this.#tries[method].insert(path12, !/\*|\/:/.test(path12));
2384
+ this.#tries[method].insert(path13, !/\*|\/:/.test(path13));
2385
2385
  } catch (e) {
2386
- throw e === PATH_ERROR ? new UnsupportedPathError(path12) : e;
2386
+ throw e === PATH_ERROR ? new UnsupportedPathError(path13) : e;
2387
2387
  }
2388
2388
  }
2389
- add(method, path12, handler) {
2389
+ add(method, path13, handler) {
2390
2390
  const middleware = this.#middleware;
2391
2391
  const routes = this.#routes;
2392
2392
  if (!middleware) {
@@ -2402,28 +2402,28 @@ var RegExpRouter = class {
2402
2402
  }
2403
2403
  }
2404
2404
  }
2405
- if (path12 === "/*") {
2406
- path12 = "*";
2405
+ if (path13 === "/*") {
2406
+ path13 = "*";
2407
2407
  }
2408
2408
  const methods = method === METHOD_NAME_ALL ? Object.keys(middleware) : [method];
2409
- if (/\*$/.test(path12)) {
2410
- const re = buildWildcardRegExp(path12);
2409
+ if (/\*$/.test(path13)) {
2410
+ const re = buildWildcardRegExp(path13);
2411
2411
  for (const m of methods) {
2412
- if (!middleware[m][path12]) {
2413
- this.#insertPath(m, path12);
2414
- middleware[m][path12] = findMiddleware(middleware[m], path12) || findMiddleware(middleware[METHOD_NAME_ALL], path12) || [];
2412
+ if (!middleware[m][path13]) {
2413
+ this.#insertPath(m, path13);
2414
+ middleware[m][path13] = findMiddleware(middleware[m], path13) || findMiddleware(middleware[METHOD_NAME_ALL], path13) || [];
2415
2415
  }
2416
2416
  }
2417
2417
  for (const handlerMap of [middleware, routes]) {
2418
2418
  for (const m of methods) {
2419
2419
  for (const p in handlerMap[m]) {
2420
- re.test(p) && handlerMap[m][p].push([handler, path12]);
2420
+ re.test(p) && handlerMap[m][p].push([handler, path13]);
2421
2421
  }
2422
2422
  }
2423
2423
  }
2424
2424
  return;
2425
2425
  }
2426
- const paths = checkOptionalParameter(path12) || [path12];
2426
+ const paths = checkOptionalParameter(path13) || [path13];
2427
2427
  for (const path22 of paths) {
2428
2428
  for (const m of methods) {
2429
2429
  if (!routes[m][path22]) {
@@ -2452,11 +2452,11 @@ var RegExpRouter = class {
2452
2452
  const handlerData = [];
2453
2453
  const [regexp, indexReplacementMap, paramReplacementMap] = trie.buildRegExp();
2454
2454
  for (const r of [middleware, routes]) {
2455
- for (const path12 in r) {
2456
- const handlers = r[path12];
2457
- const pathData = trie.paths[path12];
2455
+ for (const path13 in r) {
2456
+ const handlers = r[path13];
2457
+ const pathData = trie.paths[path13];
2458
2458
  if (!pathData) {
2459
- staticMap[path12] = [handlers.map(([h]) => [h, createNullObject()]), emptyParam];
2459
+ staticMap[path13] = [handlers.map(([h]) => [h, createNullObject()]), emptyParam];
2460
2460
  continue;
2461
2461
  }
2462
2462
  handlerData[pathData[0]] = handlers.map(([h, handlerPath]) => [
@@ -2480,13 +2480,13 @@ var SmartRouter = class {
2480
2480
  constructor(init) {
2481
2481
  this.#routers = init.routers;
2482
2482
  }
2483
- add(method, path12, handler) {
2483
+ add(method, path13, handler) {
2484
2484
  if (!this.#routes) {
2485
2485
  throw new Error(MESSAGE_MATCHER_IS_ALREADY_BUILT);
2486
2486
  }
2487
- this.#routes.push([method, path12, handler]);
2487
+ this.#routes.push([method, path13, handler]);
2488
2488
  }
2489
- match(method, path12) {
2489
+ match(method, path13) {
2490
2490
  if (!this.#routes) {
2491
2491
  throw new Error("Fatal error");
2492
2492
  }
@@ -2501,7 +2501,7 @@ var SmartRouter = class {
2501
2501
  for (let i2 = 0, len2 = routes.length; i2 < len2; i2++) {
2502
2502
  router.add(...routes[i2]);
2503
2503
  }
2504
- res = router.match(method, path12);
2504
+ res = router.match(method, path13);
2505
2505
  } catch (e) {
2506
2506
  if (e instanceof UnsupportedPathError) {
2507
2507
  continue;
@@ -2536,9 +2536,9 @@ var Node2 = class _Node2 {
2536
2536
  #patterns = [];
2537
2537
  #pattern;
2538
2538
  #params = emptyParams;
2539
- insert(method, path12, handler) {
2539
+ insert(method, path13, handler) {
2540
2540
  let curNode = this;
2541
- const parts = splitRoutingPath(path12);
2541
+ const parts = splitRoutingPath(path13);
2542
2542
  const possibleKeys = /* @__PURE__ */ new Set();
2543
2543
  let i = 0;
2544
2544
  for (const p of parts) {
@@ -2578,12 +2578,12 @@ var Node2 = class _Node2 {
2578
2578
  }
2579
2579
  }
2580
2580
  }
2581
- search(method, path12) {
2581
+ search(method, path13) {
2582
2582
  const handlerSets = [];
2583
2583
  this.#params = emptyParams;
2584
2584
  const curNode = this;
2585
2585
  let curNodes = [curNode];
2586
- const parts = splitPath(path12);
2586
+ const parts = splitPath(path13);
2587
2587
  const curNodesQueue = [];
2588
2588
  const len = parts.length;
2589
2589
  let partOffsets = null;
@@ -2625,13 +2625,13 @@ var Node2 = class _Node2 {
2625
2625
  if (matcher !== true) {
2626
2626
  if (!partOffsets) {
2627
2627
  partOffsets = [];
2628
- let offset = path12[0] === "/" ? 1 : 0;
2628
+ let offset = path13[0] === "/" ? 1 : 0;
2629
2629
  for (let p = 0; p < len; p++) {
2630
2630
  partOffsets[p] = offset;
2631
2631
  offset += parts[p].length + 1;
2632
2632
  }
2633
2633
  }
2634
- const restPathString = path12.slice(partOffsets[i]);
2634
+ const restPathString = path13.slice(partOffsets[i]);
2635
2635
  const m = matcher.exec(restPathString);
2636
2636
  if (m) {
2637
2637
  params[name] = m[0];
@@ -2691,13 +2691,13 @@ var Node2 = class _Node2 {
2691
2691
  var TrieRouter = class {
2692
2692
  name = "TrieRouter";
2693
2693
  #node = new Node2();
2694
- add(method, path12, handler) {
2695
- for (const result of checkOptionalParameter(path12) || [path12]) {
2694
+ add(method, path13, handler) {
2695
+ for (const result of checkOptionalParameter(path13) || [path13]) {
2696
2696
  this.#node.insert(method, result, handler);
2697
2697
  }
2698
2698
  }
2699
- match(method, path12) {
2700
- return this.#node.search(method, path12);
2699
+ match(method, path13) {
2700
+ return this.#node.search(method, path13);
2701
2701
  }
2702
2702
  };
2703
2703
 
@@ -3416,13 +3416,14 @@ import { readFile as readFile2 } from "fs/promises";
3416
3416
 
3417
3417
  // packages/server/src/checkpoints.ts
3418
3418
  import path3 from "path";
3419
- import { randomUUID } from "crypto";
3420
- import { copyFile, mkdir as mkdir2, rm, stat as stat2, utimes } from "fs/promises";
3419
+ import { randomUUID as randomUUID2 } from "crypto";
3420
+ import { copyFile, mkdir as mkdir2, rm, stat as stat2, utimes as utimes2 } from "fs/promises";
3421
3421
 
3422
3422
  // packages/server/src/state.ts
3423
3423
  import os from "os";
3424
3424
  import path2 from "path";
3425
- import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "fs/promises";
3425
+ import { randomUUID } from "crypto";
3426
+ import { mkdir, open, readFile, rename, stat, unlink, utimes } from "fs/promises";
3426
3427
 
3427
3428
  // packages/server/src/hash.ts
3428
3429
  import { createHash } from "crypto";
@@ -3470,11 +3471,21 @@ var usableCheckpoint = (v) => {
3470
3471
  return !!c && typeof c === "object" && Number.isInteger(c.id) && typeof c.tree === "string" && (c.worktree === void 0 || typeof c.worktree === "string");
3471
3472
  };
3472
3473
  var usableComment = (v) => usable(v) && typeof v.anchor === "object" && !!v.anchor;
3473
- function normalise(raw2, repoRoot) {
3474
- const base = defaultState(repoRoot);
3475
- if (!raw2 || typeof raw2 !== "object") return base;
3474
+ var SCHEMA_VERSION = 1;
3475
+ var KNOWN_KEYS = /* @__PURE__ */ new Set(["schemaVersion", "repoRoot", "targets", "todos", "checkpoints", "prefs", "issues"]);
3476
+ var StateTooNew = class extends HttpError {
3477
+ constructor(file, version) {
3478
+ super(409, `${file} was written by a newer warden (schema ${version}); upgrade warden to use it`, "state_too_new");
3479
+ }
3480
+ };
3481
+ var NotAState = class extends Error {
3482
+ };
3483
+ function normalise(raw2, repoRoot, file) {
3484
+ if (!raw2 || typeof raw2 !== "object" || Array.isArray(raw2)) throw new NotAState();
3476
3485
  const r = raw2;
3477
- if (r.schemaVersion !== 1) return base;
3486
+ if (typeof r.schemaVersion !== "number") throw new NotAState();
3487
+ if (r.schemaVersion > SCHEMA_VERSION) throw new StateTooNew(file, r.schemaVersion);
3488
+ const extra = Object.fromEntries(Object.entries(r).filter(([k]) => !KNOWN_KEYS.has(k)));
3478
3489
  const targets = {};
3479
3490
  for (const [k, v] of Object.entries(r.targets ?? {})) {
3480
3491
  if (!v || typeof v !== "object") continue;
@@ -3486,7 +3497,8 @@ function normalise(raw2, repoRoot) {
3486
3497
  }
3487
3498
  migrateLocalViews(targets);
3488
3499
  return {
3489
- schemaVersion: 1,
3500
+ ...extra,
3501
+ schemaVersion: SCHEMA_VERSION,
3490
3502
  repoRoot: r.repoRoot ?? repoRoot,
3491
3503
  targets,
3492
3504
  // Issues were folded into todos (a todo links comments now); the few there were are let go,
@@ -3534,11 +3546,11 @@ var StateStore = class {
3534
3546
  async load() {
3535
3547
  try {
3536
3548
  const text = await readFile(this.file, "utf8");
3537
- return normalise(JSON.parse(text), this.repoRoot);
3549
+ return normalise(JSON.parse(text), this.repoRoot, this.file);
3538
3550
  } catch (e) {
3539
3551
  const err = e;
3540
3552
  if (err.code === "ENOENT") return defaultState(this.repoRoot);
3541
- if (e instanceof SyntaxError) {
3553
+ if (e instanceof SyntaxError || e instanceof NotAState) {
3542
3554
  try {
3543
3555
  await rename(this.file, `${this.file}.corrupt-${Date.now()}`);
3544
3556
  } catch {
@@ -3552,14 +3564,15 @@ var StateStore = class {
3552
3564
  update(fn) {
3553
3565
  const run2 = async () => {
3554
3566
  await mkdir(path2.dirname(this.file), { recursive: true });
3555
- const release = await this.acquireLock();
3567
+ const lock = await this.acquireLock();
3556
3568
  try {
3557
3569
  const state = await this.load();
3558
3570
  const result = await fn(state);
3571
+ if (!await lock.held()) throw new HttpError(409, "the state lock was taken over while this change was prepared; try again", "state_lock_lost");
3559
3572
  await this.writeAtomic(state);
3560
3573
  return result;
3561
3574
  } finally {
3562
- await release();
3575
+ await lock.release();
3563
3576
  }
3564
3577
  };
3565
3578
  const p = this.queue.then(run2, run2);
@@ -3568,30 +3581,42 @@ var StateStore = class {
3568
3581
  }
3569
3582
  async writeAtomic(state) {
3570
3583
  const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`;
3571
- await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
3584
+ const fh = await open(tmp, "w");
3585
+ try {
3586
+ await fh.writeFile(JSON.stringify(state, null, 2) + "\n", "utf8");
3587
+ await fh.sync();
3588
+ } finally {
3589
+ await fh.close();
3590
+ }
3572
3591
  await rename(tmp, this.file);
3573
3592
  }
3593
+ /**
3594
+ * An exclusive lock file beside the state file, shared by every warden process and the agent
3595
+ * CLI. It carries a token of its own, so a holder releases only the lock it took, and its mtime
3596
+ * is refreshed while held: a lock left by a process that died goes stale and is taken over, one
3597
+ * whose holder is merely slow does not.
3598
+ */
3574
3599
  async acquireLock() {
3575
3600
  const lock = `${this.file}.lock`;
3601
+ const token = `${process.pid}:${randomUUID()}`;
3576
3602
  const started = Date.now();
3577
3603
  for (; ; ) {
3578
3604
  try {
3579
3605
  const fh = await open(lock, "wx");
3580
- await fh.writeFile(String(process.pid));
3606
+ await fh.writeFile(token);
3581
3607
  await fh.close();
3582
- return async () => {
3583
- try {
3584
- await unlink(lock);
3585
- } catch {
3586
- }
3587
- };
3608
+ break;
3588
3609
  } catch (e) {
3589
3610
  const err = e;
3590
3611
  if (err.code !== "EEXIST") throw e;
3591
3612
  try {
3592
3613
  const st = await stat(lock);
3593
3614
  if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
3594
- await unlink(lock).catch(() => void 0);
3615
+ const aside = `${lock}.stale-${token.replace(":", "-")}`;
3616
+ await rename(lock, aside).then(
3617
+ () => unlink(aside).catch(() => void 0),
3618
+ () => void 0
3619
+ );
3595
3620
  continue;
3596
3621
  }
3597
3622
  } catch {
@@ -3603,6 +3628,18 @@ var StateStore = class {
3603
3628
  await sleep(20);
3604
3629
  }
3605
3630
  }
3631
+ const ours = async () => await readFile(lock, "utf8").catch(() => "") === token;
3632
+ const beat = setInterval(() => {
3633
+ void ours().then((mine) => mine ? utimes(lock, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date()).catch(() => void 0) : void 0);
3634
+ }, LOCK_STALE_MS / 4);
3635
+ beat.unref();
3636
+ return {
3637
+ held: ours,
3638
+ release: async () => {
3639
+ clearInterval(beat);
3640
+ if (await ours()) await unlink(lock).catch(() => void 0);
3641
+ }
3642
+ };
3606
3643
  }
3607
3644
  };
3608
3645
  function forgetWorktreeTargets(state, worktreePath) {
@@ -3647,13 +3684,13 @@ async function withIndexCopy(cwd, store, fn) {
3647
3684
  await mkdir2(path3.join(store, "objects"), { recursive: true });
3648
3685
  await mkdir2(tmp, { recursive: true });
3649
3686
  const [objects, index] = await Promise.all([checkpointObjects(cwd, store), repoIndex(cwd)]);
3650
- const copy = path3.join(tmp, `index-${randomUUID()}`);
3687
+ const copy = path3.join(tmp, `index-${randomUUID2()}`);
3651
3688
  try {
3652
3689
  const st = await stat2(index).catch(() => void 0);
3653
3690
  if (st) {
3654
3691
  await copyFile(index, copy);
3655
3692
  const earlier = Math.floor(st.mtimeMs / 1e3) - 1;
3656
- await utimes(copy, earlier, earlier);
3693
+ await utimes2(copy, earlier, earlier);
3657
3694
  }
3658
3695
  return await fn({ ...objects, index: copy });
3659
3696
  } finally {
@@ -3697,7 +3734,9 @@ function addCheckpoint(state, worktree, tree, head, handoff = false) {
3697
3734
  const id = mine.reduce((n, c) => Math.max(n, c.id), 0) + 1;
3698
3735
  const checkpoint = { id, ...worktree ? { worktree } : {}, tree, head, createdAt: (/* @__PURE__ */ new Date()).toISOString(), ...handoff ? { handoff } : {} };
3699
3736
  state.checkpoints.push(checkpoint);
3700
- for (const old of mine.slice(0, Math.max(0, mine.length + 1 - MAX_CHECKPOINTS))) forgetCheckpoint(state, old);
3737
+ const evictable = mine.filter((c) => !state.targets[checkpointKey(c)]?.comments.length);
3738
+ const excess = mine.length + 1 - MAX_CHECKPOINTS;
3739
+ for (const old of evictable.slice(0, Math.max(0, excess))) forgetCheckpoint(state, old);
3701
3740
  return checkpoint;
3702
3741
  }
3703
3742
  async function takeCheckpoint(store, cwd, worktree, opts = {}) {
@@ -3962,7 +4001,8 @@ async function runDiff(ctx, args) {
3962
4001
  if (ctx.target.kind !== "checkpoint") return (await runGit(args, { cwd: ctx.cwd })).stdout;
3963
4002
  return withCheckpointIndex(ctx.cwd, checkpointOf(ctx).store, async (snapshot) => (await runGit(args, { cwd: ctx.cwd, snapshot })).stdout);
3964
4003
  }
3965
- var DIFF_BASE_ARGS = ["diff", "--no-color", "--no-ext-diff", "-U3", "-M", "--find-renames"];
4004
+ var DIFF_OUTPUT_ARGS = ["--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/", "--submodule=short", "-U3"];
4005
+ var DIFF_BASE_ARGS = ["diff", ...DIFF_OUTPUT_ARGS, "-M", "--find-renames"];
3966
4006
  function resolveTargetContext(repo, worktrees, key) {
3967
4007
  let target;
3968
4008
  try {
@@ -3971,7 +4011,7 @@ function resolveTargetContext(repo, worktrees, key) {
3971
4011
  if (e instanceof TargetKeyError) throw badRequest(e.message, "invalid_target");
3972
4012
  throw e;
3973
4013
  }
3974
- let cwd = repo.root;
4014
+ let cwd = repo.commonRoot;
3975
4015
  if (target.worktree) {
3976
4016
  const wt = worktrees.find((w) => w.path === target.worktree);
3977
4017
  if (!wt) throw badRequest(`unknown worktree: ${target.worktree}`, "unknown_worktree");
@@ -4026,12 +4066,21 @@ async function listUntracked(cwd) {
4026
4066
  const r = await runGit(["ls-files", "--others", "--exclude-standard", "-z"], { cwd });
4027
4067
  return r.stdout.split("\0").filter(Boolean);
4028
4068
  }
4069
+ async function listUnmerged(cwd, file) {
4070
+ const r = await runGit(["ls-files", "--unmerged", "-z", ...file ? ["--", literal(file)] : []], { cwd });
4071
+ return [...new Set(r.stdout.split("\0").flatMap((e) => e.includes(" ") ? [e.slice(e.indexOf(" ") + 1)] : []))];
4072
+ }
4073
+ async function conflictDiff(ctx, file) {
4074
+ const base = await hasHead(ctx.cwd) ? "HEAD" : EMPTY_TREE_SHA;
4075
+ const f = parseUnifiedDiff(await runDiff(ctx, [...DIFF_BASE_ARGS, base, "--", literal(file)])).find((d) => d.path === file);
4076
+ return f ? { ...f, conflicted: true } : void 0;
4077
+ }
4029
4078
  async function isUntracked(cwd, file) {
4030
4079
  const r = await runGit(["ls-files", "--others", "--exclude-standard", "-z", "--", literal(file)], { cwd });
4031
4080
  return r.stdout.split("\0").filter(Boolean).includes(file);
4032
4081
  }
4033
4082
  async function untrackedDiff(cwd, file) {
4034
- const r = await runGit(["diff", "--no-color", "--no-ext-diff", "-U3", "--no-index", "--", "/dev/null", file], {
4083
+ const r = await runGit(["diff", ...DIFF_OUTPUT_ARGS, "--no-index", "--", "/dev/null", file], {
4035
4084
  cwd,
4036
4085
  okCodes: [0, 1]
4037
4086
  });
@@ -4058,7 +4107,16 @@ async function mapLimit(items, limit, fn) {
4058
4107
  }
4059
4108
  async function listTargetDiffs(ctx) {
4060
4109
  const args = await diffArgs(ctx);
4061
- const files = parseUnifiedDiff(await runDiff(ctx, args));
4110
+ const files = parseUnifiedDiff(await runDiff(ctx, [...args, "--"]));
4111
+ if (ctx.target.kind === "working") {
4112
+ for (const path13 of await listUnmerged(ctx.cwd)) {
4113
+ const d = await conflictDiff(ctx, path13);
4114
+ if (!d) continue;
4115
+ const at = files.findIndex((f) => f.path === path13);
4116
+ if (at >= 0) files[at] = d;
4117
+ else files.push(d);
4118
+ }
4119
+ }
4062
4120
  if (includesUntracked(ctx.target)) {
4063
4121
  const untracked = await listUntracked(ctx.cwd);
4064
4122
  const extra = await mapLimit(untracked, 8, (f) => untrackedDiff(ctx.cwd, f).catch(() => void 0));
@@ -4077,6 +4135,7 @@ async function getFileDiff(ctx, filePath, hints = {}) {
4077
4135
  if (includesUntracked(ctx.target) && (hints.untracked || await isUntracked(ctx.cwd, filePath))) {
4078
4136
  return untrackedDiff(ctx.cwd, filePath);
4079
4137
  }
4138
+ if (ctx.target.kind === "working" && (await listUnmerged(ctx.cwd, filePath)).includes(filePath)) return conflictDiff(ctx, filePath);
4080
4139
  const args = await diffArgs(ctx);
4081
4140
  const pathspec = [literal(filePath)];
4082
4141
  if (hints.oldPath && hints.oldPath !== filePath) pathspec.push(literal(hints.oldPath));
@@ -4128,7 +4187,7 @@ async function pathsWithMarkers(ctx, side, paths) {
4128
4187
  const found = /* @__PURE__ */ new Set();
4129
4188
  if (paths.length === 0) return found;
4130
4189
  const ref = await refForSide(ctx, side);
4131
- const args = ["grep", "-l", "-z", "-I", "-i", "-F", ...DEBUG_MARKER_STRINGS.flatMap((m) => ["-e", m])];
4190
+ const args = ["grep", "--no-color", "--no-recurse-submodules", "-l", "-z", "-I", "-i", "-F", ...DEBUG_MARKER_STRINGS.flatMap((m) => ["-e", m])];
4132
4191
  if (ref === void 0) args.push("--untracked");
4133
4192
  else if (ref === ":0") args.push("--cached");
4134
4193
  else args.push(ref);
@@ -4223,6 +4282,12 @@ function matchesAt(lines, start, hashes) {
4223
4282
  }
4224
4283
  return true;
4225
4284
  }
4285
+ var DISTINCTIVE_CHARS = 24;
4286
+ function distinctive(lines, start, count) {
4287
+ let chars = 0;
4288
+ for (let i = start; i < start + count; i++) chars += lines[i].content.replace(/\s+/g, "").length;
4289
+ return chars >= DISTINCTIVE_CHARS;
4290
+ }
4226
4291
  function contextScore(lines, start, count, anchor) {
4227
4292
  let score = 0;
4228
4293
  const before = anchor.contextBefore;
@@ -4251,7 +4316,10 @@ function locateAnchor(diff, side, anchor) {
4251
4316
  if (matchesAt(lines, i, anchor.lineHashes)) candidates.push(i);
4252
4317
  }
4253
4318
  if (candidates.length === 0) return void 0;
4254
- if (candidates.length === 1) return candidates[0];
4319
+ if (candidates.length === 1) {
4320
+ const c = candidates[0];
4321
+ return contextScore(lines, c, n, anchor) > 0 || distinctive(lines, c, n) ? c : void 0;
4322
+ }
4255
4323
  let best = [];
4256
4324
  let bestScore = -1;
4257
4325
  for (const c of candidates) {
@@ -4444,6 +4512,39 @@ async function listUpstreams(ctx) {
4444
4512
  }
4445
4513
  return out;
4446
4514
  }
4515
+ async function commonDirOf(dir) {
4516
+ try {
4517
+ const r = await runGit(["rev-parse", "--show-toplevel", "--git-common-dir"], { cwd: dir });
4518
+ const [top, common] = r.stdout.trim().split("\n");
4519
+ if (!top || !common || await realpath2(top) !== await realpath2(dir)) return void 0;
4520
+ return await realpath2(path5.resolve(dir, common));
4521
+ } catch {
4522
+ return void 0;
4523
+ }
4524
+ }
4525
+ var IN_PROGRESS = [
4526
+ ["rebase-merge", "rebase"],
4527
+ ["rebase-apply", "rebase"],
4528
+ ["MERGE_HEAD", "merge"],
4529
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
4530
+ ["REVERT_HEAD", "revert"],
4531
+ ["BISECT_LOG", "bisect"]
4532
+ ];
4533
+ async function checkoutState(ctx, dir) {
4534
+ const [ours, theirs] = await Promise.all([commonDirOf(ctx.commonRoot), commonDirOf(dir)]);
4535
+ if (!ours || ours !== theirs) return { foreign: true };
4536
+ const r = await runGit(["rev-parse", ...IN_PROGRESS.flatMap(([f]) => ["--git-path", f])], { cwd: dir });
4537
+ const paths = r.stdout.trim().split("\n");
4538
+ for (const [i, [, what]] of IN_PROGRESS.entries()) {
4539
+ const p = paths[i];
4540
+ if (p && await stat3(path5.resolve(dir, p)).then(
4541
+ () => true,
4542
+ () => false
4543
+ ))
4544
+ return { foreign: false, busy: what };
4545
+ }
4546
+ return { foreign: false };
4547
+ }
4447
4548
  async function listWorktreesDetailed(ctx) {
4448
4549
  const [all, upstreams] = await Promise.all([listWorktreesAll(ctx), listUpstreams(ctx)]);
4449
4550
  const main2 = all.find((w) => w.isMain);
@@ -4451,12 +4552,16 @@ async function listWorktreesDetailed(ctx) {
4451
4552
  all.map(async (w) => {
4452
4553
  const slot = w.isMain || w.bare ? void 0 : slotOf(ctx, w.path);
4453
4554
  const dirty = w.prunable || w.bare ? 0 : await dirtyCount(w.path);
4555
+ const state = slot === void 0 || w.prunable ? { foreign: false } : await checkoutState(ctx, w.path);
4454
4556
  const detail = {
4455
4557
  ...w,
4456
4558
  dirty,
4457
4559
  ...slot === void 0 ? {} : { slot },
4458
- // Free is what the next checkout may take over: a detached HEAD and nothing that would be lost.
4459
- free: slot !== void 0 && !w.prunable && w.detached && dirty === 0
4560
+ ...state.foreign ? { foreign: true } : {},
4561
+ ...state.busy ? { busy: state.busy } : {},
4562
+ // Free is what the next checkout may take over: a detached HEAD, nothing that would be lost,
4563
+ // nothing stopped halfway, and a checkout that is this repository's own.
4564
+ free: slot !== void 0 && !w.prunable && w.detached && dirty === 0 && !state.foreign && !state.busy
4460
4565
  };
4461
4566
  if (detail.free) return detail;
4462
4567
  const upstream = w.branch ? upstreams.get(w.branch) : void 0;
@@ -4524,6 +4629,8 @@ async function pickSlot(ctx, worktrees, wanted) {
4524
4629
  }
4525
4630
  const have = slots.find((w) => w.slot === wanted);
4526
4631
  if (have?.prunable) throw new HttpError(409, `the directory of slot ${wanted} is gone; remove its entry first`, "slot_gone");
4632
+ if (have?.foreign) throw new HttpError(409, `${have.path} is not a checkout of this repository`, "not_our_checkout");
4633
+ if (have?.busy) throw new HttpError(409, `a ${have.busy} is in progress in slot ${wanted}`, "operation_in_progress");
4527
4634
  if (have && !have.free) throw new HttpError(409, `slot ${wanted} is in use${have.branch ? ` by ${have.branch}` : ""}`, "slot_in_use");
4528
4635
  if (have) return { slot: wanted, path: have.path, reuse: true };
4529
4636
  const p = slotPath(ctx, wanted);
@@ -4604,6 +4711,9 @@ async function releaseWorktree(ctx, req) {
4604
4711
  if (wt.isMain) throw badRequest("the main worktree cannot be released", "main_worktree");
4605
4712
  if (wt.bare || slotOf(ctx, wt.path) === void 0) throw badRequest(`${wt.path} is not a slot; remove it instead`, "not_a_slot");
4606
4713
  if (wt.prunable) throw badRequest(`the directory of ${wt.path} is gone; remove its entry instead`, "worktree_gone");
4714
+ const state = await checkoutState(ctx, wt.path);
4715
+ if (state.foreign) throw new HttpError(409, `${wt.path} is not a checkout of this repository; warden will not write in it`, "not_our_checkout");
4716
+ if (state.busy) throw new HttpError(409, `a ${state.busy} is in progress in ${wt.path}; finish or abort it first`, "operation_in_progress");
4607
4717
  const dirty = await dirtyCount(wt.path);
4608
4718
  if (dirty > 0 && !req.force) throw new HttpError(409, `${wt.path} has uncommitted changes`, "worktree_dirty");
4609
4719
  if (dirty > 0) {
@@ -4618,6 +4728,10 @@ async function removeWorktree(ctx, req) {
4618
4728
  const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path5.resolve(p));
4619
4729
  if (!wt) throw badRequest(`unknown worktree: ${p}`, "unknown_worktree");
4620
4730
  if (wt.isMain) throw badRequest("the main worktree cannot be removed", "main_worktree");
4731
+ if (!wt.prunable && !req.force) {
4732
+ const state = await checkoutState(ctx, wt.path);
4733
+ if (state.busy) throw new HttpError(409, `a ${state.busy} is in progress in ${wt.path}; removing it throws that away`, "needs_force");
4734
+ }
4621
4735
  const args = ["worktree", "remove"];
4622
4736
  if (req.force) args.push("--force");
4623
4737
  args.push(wt.path);
@@ -4717,7 +4831,7 @@ function formatTodoExport({ todo, comments, replyCommand }) {
4717
4831
 
4718
4832
  // packages/server/src/feedback.ts
4719
4833
  import { realpath as realpath3 } from "fs/promises";
4720
- import { randomUUID as randomUUID2 } from "crypto";
4834
+ import { randomUUID as randomUUID3 } from "crypto";
4721
4835
  async function real(p) {
4722
4836
  try {
4723
4837
  return await realpath3(p);
@@ -4746,9 +4860,16 @@ async function commentsIn(state, worktreeRoot, mainRoot) {
4746
4860
  return out;
4747
4861
  }
4748
4862
  async function takeFeedback(store, opts) {
4749
- if (opts.peek) return (await commentsIn(await store.load(), opts.worktreeRoot, opts.mainRoot)).filter(awaitsAgent);
4750
- return store.update(async (s) => {
4751
- const ids = new Set((await commentsIn(s, opts.worktreeRoot, opts.mainRoot)).filter(awaitsAgent).map((c) => c.id));
4863
+ const waiting = (await commentsIn(await store.load(), opts.worktreeRoot, opts.mainRoot)).filter(awaitsAgent);
4864
+ if (opts.peek) return waiting;
4865
+ return markHandedOver(
4866
+ store,
4867
+ waiting.map((c) => c.id)
4868
+ );
4869
+ }
4870
+ async function markHandedOver(store, commentIds) {
4871
+ return store.update((s) => {
4872
+ const ids = new Set(commentIds);
4752
4873
  const now = (/* @__PURE__ */ new Date()).toISOString();
4753
4874
  const taken = [];
4754
4875
  for (const t of Object.values(s.targets)) {
@@ -4782,7 +4903,7 @@ async function addReply(store, ref, author, body) {
4782
4903
  return store.update((s) => {
4783
4904
  const { list, index } = locate(s, ref);
4784
4905
  const now = (/* @__PURE__ */ new Date()).toISOString();
4785
- const reply2 = { id: randomUUID2(), author, body: body.trim(), at: now };
4906
+ const reply2 = { id: randomUUID3(), author, body: body.trim(), at: now };
4786
4907
  const prev = list[index];
4787
4908
  const next = { ...prev, replies: [...prev.replies ?? [], reply2], updatedAt: now };
4788
4909
  if (author === "reviewer") {
@@ -4861,6 +4982,11 @@ async function findSockets(dirs = socketDirs()) {
4861
4982
  for (const d of dirs) await visit(d, 0);
4862
4983
  return [...found.entries()].map(([socket, pid]) => ({ socket, pid }));
4863
4984
  }
4985
+ function chooseNvim(matching, wanted) {
4986
+ for (const w of wanted) if (w && matching.some((i) => i.socket === w)) return { socket: w };
4987
+ if (matching.length === 1) return { socket: matching[0].socket };
4988
+ return { error: matching.length ? "ambiguous" : "none" };
4989
+ }
4864
4990
  var NvimService = class {
4865
4991
  constructor(dirs) {
4866
4992
  this.dirs = dirs;
@@ -4915,6 +5041,7 @@ var NvimService = class {
4915
5041
 
4916
5042
  // packages/server/src/tmux.ts
4917
5043
  import { execFile as execFile3 } from "child_process";
5044
+ import path7 from "path";
4918
5045
  import { realpath as realpath4 } from "fs/promises";
4919
5046
  var runTmux = (args) => new Promise((resolve, reject) => {
4920
5047
  execFile3("tmux", args, { encoding: "utf8", timeout: 5e3, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
@@ -4925,34 +5052,48 @@ var runTmux = (args) => new Promise((resolve, reject) => {
4925
5052
  );
4926
5053
  });
4927
5054
  });
5055
+ function sessionNameFor(worktreePath) {
5056
+ return path7.basename(worktreePath).replace(/[.:#]/g, "_") || "warden";
5057
+ }
4928
5058
  var TmuxService = class {
4929
5059
  constructor(run2 = runTmux) {
4930
5060
  this.run = run2;
4931
5061
  }
4932
5062
  run;
4933
- async sessions(mainRoot) {
4934
- const root = await realpath4(mainRoot);
4935
- const output = await this.run(["list-sessions", "-F", "#{session_id} #{session_name} #{session_path}"]);
5063
+ async list() {
5064
+ const output = await this.run(["list-sessions", "-F", "#{session_id} #{session_name} #{session_path}"]).catch((e) => {
5065
+ if (e instanceof HttpError && /no server running|error connecting/i.test(e.message)) return "";
5066
+ throw e;
5067
+ });
4936
5068
  const sessions = [];
4937
5069
  for (const line of output.trimEnd().split("\n")) {
4938
5070
  const [id, name, ...parts] = line.split(" ");
4939
- const path12 = parts.join(" ");
4940
- if (!id || !/^\$\d+$/.test(id) || !name || !path12) continue;
4941
- if (await realpath4(path12).catch(() => null) === root) sessions.push({ id, name, path: path12 });
5071
+ const dir = parts.join(" ");
5072
+ if (!id || !/^\$\d+$/.test(id) || !name || !dir) continue;
5073
+ sessions.push({ id, name, path: dir });
4942
5074
  }
4943
5075
  return sessions;
4944
5076
  }
4945
- async open(mainRoot, worktreePath, sessionId) {
4946
- const session = (await this.sessions(mainRoot)).find((s) => s.id === sessionId);
4947
- if (!session) throw new HttpError(409, "\u4E3B\u4ED3\u5E93\u5BF9\u5E94\u7684 tmux session \u5DF2\u4E0D\u5B58\u5728\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5", "tmux_session_missing");
5077
+ /**
5078
+ * A detached session in the worktree, named after its directory, with a shell and nothing run in
5079
+ * it. One already there for the same directory is handed back rather than doubled; one of that
5080
+ * name elsewhere is refused, not replaced — it is someone's work.
5081
+ */
5082
+ async openSession(worktreePath) {
4948
5083
  const directory = await realpath4(worktreePath);
4949
- const window = (await this.run(["new-window", "-d", "-P", "-F", "#{window_id}", "-t", `${session.id}:`, "-c", directory.replaceAll("#", "##")])).trim();
4950
- return { session: session.name, window };
5084
+ const name = sessionNameFor(directory);
5085
+ const existing = (await this.list()).find((x) => x.name === name);
5086
+ if (existing) {
5087
+ if (await realpath4(existing.path).catch(() => null) === directory) return { session: name, created: false };
5088
+ throw new HttpError(409, `\u5DF2\u6709\u540C\u540D tmux session ${name}\uFF0C\u5DE5\u4F5C\u76EE\u5F55\u662F ${existing.path}`, "tmux_name_taken");
5089
+ }
5090
+ await this.run(["new-session", "-d", "-s", name, "-c", directory.replaceAll("#", "##")]);
5091
+ return { session: name, created: true };
4951
5092
  }
4952
5093
  };
4953
5094
 
4954
5095
  // packages/server/src/watcher.ts
4955
- import path7 from "path";
5096
+ import path8 from "path";
4956
5097
  import { stat as stat5 } from "fs/promises";
4957
5098
  var DEFAULT_POLL_INTERVAL_MS = 1500;
4958
5099
  function statusPaths(out) {
@@ -5049,7 +5190,7 @@ var RepoWatcher = class {
5049
5190
  const stats = await Promise.all(
5050
5191
  statusPaths(statusRes.stdout).map(async (rel) => {
5051
5192
  try {
5052
- const st = await stat5(path7.join(this.root, rel));
5193
+ const st = await stat5(path8.join(this.root, rel));
5053
5194
  return `${rel} ${st.mtimeMs} ${st.size}`;
5054
5195
  } catch {
5055
5196
  return `${rel} -`;
@@ -5061,7 +5202,7 @@ var RepoWatcher = class {
5061
5202
  };
5062
5203
 
5063
5204
  // packages/server/src/static.ts
5064
- import path8 from "path";
5205
+ import path9 from "path";
5065
5206
  import { readFile as readFile3, stat as stat6 } from "fs/promises";
5066
5207
  var MIME = {
5067
5208
  ".html": "text/html; charset=utf-8",
@@ -5079,20 +5220,20 @@ var MIME = {
5079
5220
  ".txt": "text/plain; charset=utf-8"
5080
5221
  };
5081
5222
  async function serveStaticFile(c, webDir) {
5082
- const root = path8.resolve(webDir);
5223
+ const root = path9.resolve(webDir);
5083
5224
  let rel;
5084
5225
  try {
5085
5226
  rel = decodeURIComponent(c.req.path);
5086
5227
  } catch {
5087
5228
  return c.text("Bad Request", 400);
5088
5229
  }
5089
- let target = path8.resolve(root, "." + rel);
5090
- if (target !== root && !target.startsWith(root + path8.sep)) return c.text("Forbidden", 403);
5230
+ let target = path9.resolve(root, "." + rel);
5231
+ if (target !== root && !target.startsWith(root + path9.sep)) return c.text("Forbidden", 403);
5091
5232
  try {
5092
5233
  const st = await stat6(target);
5093
- if (st.isDirectory()) target = path8.join(target, "index.html");
5234
+ if (st.isDirectory()) target = path9.join(target, "index.html");
5094
5235
  } catch {
5095
- target = path8.join(root, "index.html");
5236
+ target = path9.join(root, "index.html");
5096
5237
  }
5097
5238
  let body;
5098
5239
  try {
@@ -5100,7 +5241,7 @@ async function serveStaticFile(c, webDir) {
5100
5241
  } catch {
5101
5242
  return c.text("Not Found", 404);
5102
5243
  }
5103
- const ext = path8.extname(target).toLowerCase();
5244
+ const ext = path9.extname(target).toLowerCase();
5104
5245
  const isAsset = rel.startsWith("/assets/");
5105
5246
  return new Response(new Uint8Array(body), {
5106
5247
  status: 200,
@@ -5172,8 +5313,8 @@ function createApp(opts) {
5172
5313
  const checkpointHandoff = (comments) => {
5173
5314
  const worktrees2 = new Set(comments.map((x) => tryParseTargetKey(x.targetKey)?.worktree));
5174
5315
  for (const wt of worktrees2) {
5175
- takeCheckpoint(store, wt ?? repo.root, wt, { handoff: true }).catch((e) => {
5176
- console.error(`warden: no checkpoint for the comments handed over in ${wt ?? repo.root}: ${e instanceof Error ? e.message : e}`);
5316
+ takeCheckpoint(store, wt ?? repo.commonRoot, wt, { handoff: true }).catch((e) => {
5317
+ console.error(`warden: no checkpoint for the comments handed over in ${wt ?? repo.commonRoot}: ${e instanceof Error ? e.message : e}`);
5177
5318
  });
5178
5319
  }
5179
5320
  };
@@ -5218,16 +5359,21 @@ function createApp(opts) {
5218
5359
  api.get("/ping", (c) => c.text(opts.instanceToken ?? ""));
5219
5360
  api.get("/repo", async (c) => {
5220
5361
  const state = await store.load();
5221
- const info = await getRepoInfo(repo, "working");
5362
+ const own = repo.root === repo.commonRoot ? "working" : formatTargetKey({ kind: "working", worktree: repo.root });
5363
+ const info = await getRepoInfo(repo, own);
5222
5364
  worktreeCache = { at: Date.now(), list: info.worktrees };
5223
5365
  const last = state.prefs.lastTarget;
5224
5366
  const target = last ? tryParseTargetKey(last) : void 0;
5225
- const usable2 = !!target && (!target.worktree || info.worktrees.some((w) => w.path === target.worktree)) && (target.kind !== "checkpoint" || !!findCheckpoint(state, target.worktree, target.id));
5367
+ const usable2 = !!target && (!target.worktree || info.worktrees.some((w) => w.path === target.worktree)) && // A server started in a linked worktree is there to review that one, not whatever the main
5368
+ // worktree's page last looked at: the preference is shared by every server on the repository.
5369
+ (repo.root === repo.commonRoot || target.worktree === repo.root) && (target.kind !== "checkpoint" || !!findCheckpoint(state, target.worktree, target.id));
5226
5370
  if (last && usable2) info.defaultTarget = last;
5227
5371
  else if (last) {
5228
- await store.update((s) => {
5229
- s.prefs.lastTarget = "working";
5230
- });
5372
+ if (repo.root === repo.commonRoot) {
5373
+ await store.update((s) => {
5374
+ s.prefs.lastTarget = "working";
5375
+ });
5376
+ }
5231
5377
  }
5232
5378
  return c.json(info);
5233
5379
  });
@@ -5354,6 +5500,7 @@ function createApp(opts) {
5354
5500
  if (body.skipDebug !== void 0 && typeof body.skipDebug !== "boolean") throw badRequest("skipDebug must be a boolean", "bad_selection");
5355
5501
  const diff = await fileDiffWithHints(ctx, body.path);
5356
5502
  if (!diff) throw badRequest(`file ${body.path} is not part of ${key}`, "no_diff");
5503
+ if (diff.conflicted) throw new HttpError(409, `${body.path} is in conflict; resolve it and git add it in the terminal`, "conflicted");
5357
5504
  if (diff.contentHash !== body.contentHash)
5358
5505
  throw new HttpError(409, "the diff changed since it was loaded; refresh and pick the lines again", "diff_changed");
5359
5506
  if (body.skipDebug) await annotateDebug(ctx, [diff]);
@@ -5383,7 +5530,7 @@ function createApp(opts) {
5383
5530
  if (!anchored) throw badRequest("selection is not fully visible in the diff on that side (must be inside one hunk)", "bad_selection");
5384
5531
  const now = (/* @__PURE__ */ new Date()).toISOString();
5385
5532
  const comment = {
5386
- id: randomUUID3(),
5533
+ id: randomUUID4(),
5387
5534
  targetKey: key,
5388
5535
  filePath: body.filePath,
5389
5536
  side: body.side,
@@ -5408,20 +5555,20 @@ function createApp(opts) {
5408
5555
  const body = await c.req.json();
5409
5556
  const scope = commentScopeKey(key);
5410
5557
  const state = await store.load();
5411
- const existing = state.targets[scope]?.comments.find((x) => x.id === id);
5412
- if (!existing) throw notFound("comment not found");
5558
+ const existing2 = state.targets[scope]?.comments.find((x) => x.id === id);
5559
+ if (!existing2) throw notFound("comment not found");
5413
5560
  let patch = {};
5414
5561
  if (typeof body.body === "string") {
5415
5562
  if (!body.body.trim()) throw badRequest("body must not be empty");
5416
5563
  patch.body = body.body;
5417
5564
  }
5418
5565
  if (body.startLine !== void 0 || body.side !== void 0) {
5419
- const side = body.side ?? existing.side;
5420
- const start = Number(body.startLine ?? existing.startLine);
5421
- const end = Number(body.endLine ?? body.startLine ?? existing.endLine);
5566
+ const side = body.side ?? existing2.side;
5567
+ const start = Number(body.startLine ?? existing2.startLine);
5568
+ const end = Number(body.endLine ?? body.startLine ?? existing2.endLine);
5422
5569
  if (side !== "old" && side !== "new") throw badRequest("side must be old|new");
5423
- const diff = await fileDiffWithHints(ctx, existing.filePath);
5424
- if (!diff) throw badRequest(`file ${existing.filePath} is not part of ${key}`, "no_diff");
5570
+ const diff = await fileDiffWithHints(ctx, existing2.filePath);
5571
+ if (!diff) throw badRequest(`file ${existing2.filePath} is not part of ${key}`, "no_diff");
5425
5572
  const anchored = buildAnchor(diff, side, start, end);
5426
5573
  if (!anchored) throw badRequest("selection is not fully visible in the diff on that side", "bad_selection");
5427
5574
  patch = {
@@ -5431,7 +5578,7 @@ function createApp(opts) {
5431
5578
  endLine: anchored.endLine,
5432
5579
  codeSnippet: anchored.snippet,
5433
5580
  anchor: anchored.anchor,
5434
- status: existing.exportedAt ? "exported" : "active",
5581
+ status: existing2.exportedAt ? "exported" : "active",
5435
5582
  // Re-attaching in a view moves the comment to it.
5436
5583
  targetKey: key
5437
5584
  };
@@ -5440,7 +5587,12 @@ function createApp(opts) {
5440
5587
  const t = ensureTarget(s, scope);
5441
5588
  const idx = t.comments.findIndex((x) => x.id === id);
5442
5589
  if (idx < 0) throw notFound("comment not found");
5443
- const next = { ...t.comments[idx], ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
5590
+ const prev = t.comments[idx];
5591
+ const next = { ...prev, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
5592
+ if (patch.body !== void 0 && patch.body !== prev.body && next.exportedAt) {
5593
+ delete next.exportedAt;
5594
+ if (next.status === "exported") next.status = "active";
5595
+ }
5444
5596
  t.comments[idx] = next;
5445
5597
  return next;
5446
5598
  });
@@ -5507,7 +5659,6 @@ function createApp(opts) {
5507
5659
  const comments = state.targets[scope]?.comments ?? [];
5508
5660
  const prevHead = state.targets[scope]?.head;
5509
5661
  const head = await revParse(ctx.cwd, "HEAD") ?? "";
5510
- const committed = local && prevHead !== void 0 && prevHead !== head;
5511
5662
  const now = (/* @__PURE__ */ new Date()).toISOString();
5512
5663
  const located = /* @__PURE__ */ new Map();
5513
5664
  await Promise.all(
@@ -5531,6 +5682,19 @@ function createApp(opts) {
5531
5682
  }
5532
5683
  })
5533
5684
  );
5685
+ const landed = /* @__PURE__ */ new Set();
5686
+ const lost = comments.filter((cm) => !located.has(cm.id));
5687
+ if (local && lost.length && prevHead && head && prevHead !== head && await mergeBase(ctx.cwd, prevHead, head) === prevHead) {
5688
+ const range = await worktreeCtx(
5689
+ formatTargetKey({ kind: "range", base: prevHead, head, ...ctx.target.worktree ? { worktree: ctx.target.worktree } : {} })
5690
+ );
5691
+ await Promise.all(
5692
+ lost.map(async (cm) => {
5693
+ const committedDiff = await getFileDiff(range, cm.filePath).catch(() => void 0);
5694
+ if (committedDiff && reanchorComment(cm, committedDiff, now).status !== "orphaned") landed.add(cm.id);
5695
+ })
5696
+ );
5697
+ }
5534
5698
  const result = await store.update((s) => {
5535
5699
  const t = ensureTarget(s, scope);
5536
5700
  const dropped = [];
@@ -5538,7 +5702,7 @@ function createApp(opts) {
5538
5702
  const patch = located.get(cm.id);
5539
5703
  if (!patch) {
5540
5704
  if (!comments.some((x) => x.id === cm.id)) return [cm];
5541
- if (committed && !cm.replies?.length) {
5705
+ if (landed.has(cm.id) && !cm.replies?.length) {
5542
5706
  dropped.push(cm.id);
5543
5707
  return [];
5544
5708
  }
@@ -5555,30 +5719,34 @@ function createApp(opts) {
5555
5719
  const res = { comments: result };
5556
5720
  return c.json(res);
5557
5721
  });
5558
- api.post("/comments/export", async (c) => {
5559
- const body = await c.req.json();
5560
- if (!Array.isArray(body.commentIds)) throw badRequest("commentIds required");
5561
- const ids = body.commentIds.filter((x) => typeof x === "string");
5562
- let handed = [];
5563
- const res = await store.update((s) => {
5722
+ const handOver = async (ids) => {
5723
+ const handed = await store.update((s) => {
5564
5724
  const now = (/* @__PURE__ */ new Date()).toISOString();
5565
- const selected = [];
5725
+ const out = [];
5566
5726
  for (const id of ids) {
5567
5727
  const found = findComment(s, id);
5568
5728
  if (!found) continue;
5569
5729
  const next = { ...found.comment, exportedAt: now, updatedAt: now };
5570
5730
  if (next.status === "active") next.status = "exported";
5571
5731
  s.targets[found.targetKey].comments[found.index] = next;
5572
- selected.push(next);
5732
+ out.push(next);
5573
5733
  }
5574
- handed = selected;
5575
- return {
5576
- text: formatCommentsExport({ repoRoot: repo.root, comments: selected, replyCommand: opts.replyCommand }),
5577
- count: selected.length,
5578
- commentIds: selected.map((x) => x.id)
5579
- };
5734
+ return out;
5580
5735
  });
5581
5736
  checkpointHandoff(handed);
5737
+ return handed;
5738
+ };
5739
+ const existing = (state, ids) => ids.flatMap((id) => findComment(state, id)?.comment ?? []);
5740
+ api.post("/comments/export", async (c) => {
5741
+ const body = await c.req.json();
5742
+ if (!Array.isArray(body.commentIds)) throw badRequest("commentIds required");
5743
+ const ids = body.commentIds.filter((x) => typeof x === "string");
5744
+ const comments = body.preview ? existing(await store.load(), ids) : await handOver(ids);
5745
+ const res = {
5746
+ text: formatCommentsExport({ repoRoot: repo.root, comments, replyCommand: opts.replyCommand }),
5747
+ count: comments.length,
5748
+ commentIds: comments.map((x) => x.id)
5749
+ };
5582
5750
  return c.json(res);
5583
5751
  });
5584
5752
  const knownRoot = async (rootParam) => {
@@ -5602,7 +5770,7 @@ function createApp(opts) {
5602
5770
  const branch = body.branch?.trim() || await currentBranch(await knownRoot(body.root));
5603
5771
  const now = (/* @__PURE__ */ new Date()).toISOString();
5604
5772
  const todo = {
5605
- id: randomUUID3(),
5773
+ id: randomUUID4(),
5606
5774
  branch,
5607
5775
  title: body.title.trim(),
5608
5776
  body: typeof body.body === "string" ? body.body : "",
@@ -5653,26 +5821,17 @@ function createApp(opts) {
5653
5821
  });
5654
5822
  api.post("/todos/:id/export", async (c) => {
5655
5823
  const id = c.req.param("id");
5656
- const handed = [];
5657
- const res = await store.update((s) => {
5658
- const todo = s.todos.find((t) => t.id === id);
5659
- if (!todo) throw notFound("todo not found");
5660
- const now = (/* @__PURE__ */ new Date()).toISOString();
5661
- for (const cid of todo.commentIds ?? []) {
5662
- const found = findComment(s, cid);
5663
- if (!found) continue;
5664
- const next = { ...found.comment, exportedAt: now, updatedAt: now };
5665
- if (next.status === "active") next.status = "exported";
5666
- s.targets[found.targetKey].comments[found.index] = next;
5667
- handed.push(next);
5668
- }
5669
- return {
5670
- text: formatTodoExport({ todo, comments: handed, replyCommand: opts.replyCommand }),
5671
- count: handed.length,
5672
- commentIds: handed.map((x) => x.id)
5673
- };
5674
- });
5675
- checkpointHandoff(handed);
5824
+ const body = await c.req.json().catch(() => ({})) ?? {};
5825
+ const state = await store.load();
5826
+ const todo = state.todos.find((t) => t.id === id);
5827
+ if (!todo) throw notFound("todo not found");
5828
+ const ids = todo.commentIds ?? [];
5829
+ const comments = body.preview ? existing(state, ids) : await handOver(ids);
5830
+ const res = {
5831
+ text: formatTodoExport({ todo, comments, replyCommand: opts.replyCommand }),
5832
+ count: comments.length,
5833
+ commentIds: comments.map((x) => x.id)
5834
+ };
5676
5835
  return c.json(res);
5677
5836
  });
5678
5837
  api.delete("/todos/:id", async (c) => {
@@ -5705,13 +5864,14 @@ function createApp(opts) {
5705
5864
  if (ref && !isValidRef(ref)) throw badRequest("invalid ref");
5706
5865
  if (filePath && (filePath.startsWith("/") || filePath.split("/").includes(".."))) throw badRequest("invalid path");
5707
5866
  if (q.length > 200 || author.length > 200) throw badRequest("search text too long");
5708
- const args = ["log", `--max-count=${limit + 1}`, `--skip=${offset}`, `--format=${COMMIT_FORMAT}`];
5867
+ const args = ["log", "--no-show-signature", `--max-count=${limit + 1}`, `--skip=${offset}`, `--format=${COMMIT_FORMAT}`];
5709
5868
  if (firstParent) args.push("--first-parent");
5710
5869
  if (q || author) args.push("--fixed-strings", "--regexp-ignore-case");
5711
5870
  if (q) args.push(`--grep=${q}`);
5712
5871
  if (author) args.push(`--author=${author}`);
5713
5872
  if (ref) args.push(ref);
5714
- if (filePath) args.push("--", filePath);
5873
+ args.push("--");
5874
+ if (filePath) args.push(literal(filePath));
5715
5875
  let stdout = "";
5716
5876
  try {
5717
5877
  stdout = (await runGit(args, { cwd })).stdout;
@@ -5725,7 +5885,7 @@ function createApp(opts) {
5725
5885
  if (q && offset === 0 && /^[0-9a-f]{4,40}$/i.test(q)) {
5726
5886
  const sha = await revParse(cwd, `${q}^{commit}`);
5727
5887
  if (sha && !commits.some((x) => x.sha === sha)) {
5728
- const hit = parseCommitLog((await runGit(["log", "--max-count=1", `--format=${COMMIT_FORMAT}`, sha], { cwd })).stdout)[0];
5888
+ const hit = parseCommitLog((await runGit(["log", "--no-show-signature", "--max-count=1", `--format=${COMMIT_FORMAT}`, sha, "--"], { cwd })).stdout)[0];
5729
5889
  if (hit) commits = [hit, ...commits];
5730
5890
  }
5731
5891
  }
@@ -5744,13 +5904,13 @@ function createApp(opts) {
5744
5904
  return c.json(res);
5745
5905
  });
5746
5906
  const tmux = opts.tmux ?? new TmuxService();
5747
- api.get("/tmux/sessions", async (c) => c.json({ sessions: await tmux.sessions(repo.commonRoot) }));
5748
- api.post("/tmux/windows", async (c) => {
5907
+ api.post("/tmux/sessions", async (c) => {
5749
5908
  const body = await c.req.json();
5750
- if (!body || typeof body.path !== "string" || typeof body.sessionId !== "string") throw badRequest("path and sessionId are required");
5909
+ if (!body || typeof body.path !== "string") throw badRequest("path is required");
5751
5910
  const wt = (await worktrees(true)).find((w) => w.path === body.path && !w.bare);
5752
5911
  if (!wt) throw badRequest("unknown worktree", "unknown_worktree");
5753
- return c.json(await tmux.open(repo.commonRoot, wt.path, body.sessionId), 201);
5912
+ const res = await tmux.openSession(wt.path);
5913
+ return c.json(res, res.created ? 201 : 200);
5754
5914
  });
5755
5915
  api.get("/worktrees", async (c) => {
5756
5916
  const [list, state] = await Promise.all([listWorktreesDetailed(repo), store.load()]);
@@ -5759,7 +5919,7 @@ function createApp(opts) {
5759
5919
  const latest = /* @__PURE__ */ new Map();
5760
5920
  for (const t of Object.values(state.targets)) {
5761
5921
  for (const cm of t.comments) {
5762
- const at = commentWorktree(cm) ?? repo.root;
5922
+ const at = commentWorktree(cm) ?? repo.commonRoot;
5763
5923
  const r = review.get(at) ?? { toAgent: 0, toReviewer: 0 };
5764
5924
  const seen = latest.get(at) ?? { agent: "", reviewer: "" };
5765
5925
  if (awaitsAgent(cm)) {
@@ -5785,12 +5945,18 @@ function createApp(opts) {
5785
5945
  const res = { remotes: await lookupRemoteBranches(repo, c.req.query("branch") ?? "") };
5786
5946
  return c.json(res);
5787
5947
  });
5948
+ let worktreeWrites = Promise.resolve();
5949
+ const oneAtATime = (fn) => {
5950
+ const run2 = worktreeWrites.then(fn, fn);
5951
+ worktreeWrites = run2.catch(() => void 0);
5952
+ return run2;
5953
+ };
5788
5954
  api.post("/worktrees", async (c) => {
5789
5955
  const body = await c.req.json();
5790
5956
  if (typeof body.branch !== "string") throw badRequest("branch is required");
5791
5957
  if (body.base !== void 0 && typeof body.base !== "string") throw badRequest("base must be a ref");
5792
5958
  if (body.slot !== void 0 && (!Number.isInteger(body.slot) || body.slot < 1)) throw badRequest("slot must be a positive integer", "invalid_slot");
5793
- const res = await checkoutWorktree(repo, body);
5959
+ const res = await oneAtATime(() => checkoutWorktree(repo, body));
5794
5960
  worktreeCache = null;
5795
5961
  await store.update((s) => forgetWorktreeTargets(s, res.worktree.path));
5796
5962
  return c.json(res, 201);
@@ -5798,14 +5964,14 @@ function createApp(opts) {
5798
5964
  api.post("/worktrees/release", async (c) => {
5799
5965
  const body = await c.req.json();
5800
5966
  if (typeof body.path !== "string") throw badRequest("path is required");
5801
- const res = await releaseWorktree(repo, body);
5967
+ const res = await oneAtATime(() => releaseWorktree(repo, body));
5802
5968
  worktreeCache = null;
5803
5969
  return c.json(res);
5804
5970
  });
5805
5971
  api.post("/worktrees/remove", async (c) => {
5806
5972
  const body = await c.req.json();
5807
5973
  if (typeof body.path !== "string") throw badRequest("path is required");
5808
- const res = await removeWorktree(repo, body);
5974
+ const res = await oneAtATime(() => removeWorktree(repo, body));
5809
5975
  worktreeCache = null;
5810
5976
  return c.json(res);
5811
5977
  });
@@ -5856,9 +6022,8 @@ function createApp(opts) {
5856
6022
  const instances = NvimService.matching(scan.instances, root);
5857
6023
  const state = await store.load();
5858
6024
  const preferred = state.prefs.nvimSocketByRoot[root];
5859
- let selected;
5860
- if (preferred && instances.some((i) => i.socket === preferred)) selected = preferred;
5861
- else if (instances.length === 1) selected = instances[0].socket;
6025
+ const choice = chooseNvim(instances, [preferred]);
6026
+ const selected = "socket" in choice ? choice.socket : void 0;
5862
6027
  const res = { root, nvimAvailable: scan.nvimAvailable, instances, selected, scannedAt: scan.scannedAt };
5863
6028
  return c.json(res);
5864
6029
  });
@@ -5872,19 +6037,38 @@ function createApp(opts) {
5872
6037
  });
5873
6038
  api.post("/nvim/open", async (c) => {
5874
6039
  const body = await c.req.json();
5875
- if (!body.socket || !body.absPath) throw badRequest("socket and absPath are required");
5876
- if (!path9.isAbsolute(body.absPath)) throw badRequest("absPath must be absolute");
5877
- const scan = await nvim.scan();
5878
- if (!scan.instances.some((i) => i.socket === body.socket)) {
5879
- const fresh = await nvim.scan(true);
5880
- if (!fresh.instances.some((i) => i.socket === body.socket)) throw badRequest("nvim instance not found (rescan)", "nvim_gone");
6040
+ if (!body.absPath) throw badRequest("absPath is required");
6041
+ if (!path10.isAbsolute(body.absPath)) throw badRequest("absPath must be absolute");
6042
+ const root = await knownRoot(body.root);
6043
+ const wanted = [body.socket, (await store.load()).prefs.nvimSocketByRoot[root]];
6044
+ const line = Number(body.line) || 1;
6045
+ const pick = async (force) => {
6046
+ const scan = await nvim.scan(force);
6047
+ if (!scan.nvimAvailable) throw badRequest("nvim is not on PATH", "nvim_unavailable");
6048
+ return { matching: NvimService.matching(scan.instances, root), choice: chooseNvim(NvimService.matching(scan.instances, root), wanted) };
6049
+ };
6050
+ let { choice } = await pick(false);
6051
+ if ("error" in choice) choice = (await pick(true)).choice;
6052
+ if ("error" in choice) {
6053
+ if (choice.error === "none") throw notFound(`no nvim is open inside ${root}`, "nvim_none");
6054
+ throw new HttpError(409, `several nvim instances are open inside ${root}; select one`, "nvim_ambiguous");
5881
6055
  }
5882
6056
  try {
5883
- await nvim.open(body.socket, body.absPath, Number(body.line) || 1);
6057
+ await nvim.open(choice.socket, body.absPath, line);
6058
+ const res = { ok: true, socket: choice.socket };
6059
+ return c.json(res);
5884
6060
  } catch (e) {
5885
- throw new HttpError(502, e instanceof Error ? e.message : String(e), "nvim_failed");
6061
+ const failure = new HttpError(502, e instanceof Error ? e.message : String(e), "nvim_failed");
6062
+ const fresh = await pick(true);
6063
+ if (fresh.matching.some((i) => i.socket === choice.socket) || "error" in fresh.choice) throw failure;
6064
+ try {
6065
+ await nvim.open(fresh.choice.socket, body.absPath, line);
6066
+ } catch (e2) {
6067
+ throw new HttpError(502, e2 instanceof Error ? e2.message : String(e2), "nvim_failed");
6068
+ }
6069
+ const res = { ok: true, socket: fresh.choice.socket };
6070
+ return c.json(res);
5886
6071
  }
5887
- return c.json({ ok: true });
5888
6072
  });
5889
6073
  app.route("/api", api);
5890
6074
  app.notFound((c) => {
@@ -5933,14 +6117,14 @@ function reachableFromWindows(port, token, timeoutMs = PROBE_TIMEOUT_MS) {
5933
6117
  }
5934
6118
 
5935
6119
  // packages/server/src/update.ts
5936
- import path10 from "path";
5937
- import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
6120
+ import path11 from "path";
6121
+ import { mkdir as mkdir3, readFile as readFile5, writeFile } from "fs/promises";
5938
6122
  var PACKAGE = "@xbghc/warden";
5939
6123
  var LATEST_URL = "https://registry.npmjs.org/@xbghc%2fwarden/latest";
5940
6124
  var FETCH_TIMEOUT_MS = 3e3;
5941
6125
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
5942
6126
  function updateCacheFile(baseDir = dataDir()) {
5943
- return path10.join(baseDir, "update-check.json");
6127
+ return path11.join(baseDir, "update-check.json");
5944
6128
  }
5945
6129
  function updateCheckEnabled(env = process.env) {
5946
6130
  return !env.WARDEN_NO_UPDATE_CHECK && !env.NO_UPDATE_NOTIFIER && !env.CI;
@@ -5974,8 +6158,8 @@ async function readCache(file, now) {
5974
6158
  }
5975
6159
  async function writeCache(file, cache) {
5976
6160
  try {
5977
- await mkdir3(path10.dirname(file), { recursive: true });
5978
- await writeFile2(file, JSON.stringify(cache));
6161
+ await mkdir3(path11.dirname(file), { recursive: true });
6162
+ await writeFile(file, JSON.stringify(cache));
5979
6163
  } catch {
5980
6164
  }
5981
6165
  }
@@ -6019,7 +6203,7 @@ async function startServer(opts) {
6019
6203
  const repo = await resolveRepo(opts.repoPath);
6020
6204
  const stateFile = opts.stateFile ?? stateFilePath(repo.commonRoot);
6021
6205
  const store = new StateStore(stateFile, repo.commonRoot);
6022
- const instanceToken = randomUUID4();
6206
+ const instanceToken = randomUUID5();
6023
6207
  const app = createApp({ repo, store, nvim: new NvimService(), webDir: opts.webDir, instanceToken, update: opts.update, replyCommand: opts.replyCommand });
6024
6208
  const listen = (p) => new Promise((resolve, reject) => {
6025
6209
  const s = serve({ fetch: app.fetch, hostname: HOST, port: p }, () => resolve(s));
@@ -6085,18 +6269,21 @@ async function feedback(args, replyCommand) {
6085
6269
  const unknown = args.find((a) => a !== "--peek");
6086
6270
  if (unknown) throw new Error(`unknown argument: ${unknown}`);
6087
6271
  const { store, root, commonRoot } = await openStore();
6088
- const peek = args.includes("--peek");
6089
- const comments = await takeFeedback(store, { worktreeRoot: root, mainRoot: commonRoot, peek });
6272
+ const comments = await takeFeedback(store, { worktreeRoot: root, mainRoot: commonRoot, peek: true });
6090
6273
  if (comments.length === 0) {
6091
6274
  console.log("No review comments are waiting for you.");
6092
6275
  return;
6093
6276
  }
6094
- if (!peek) {
6095
- await takeCheckpoint(store, root, root === commonRoot ? void 0 : root, { handoff: true }).catch((e) => {
6096
- console.error(`warden feedback: no checkpoint taken: ${e instanceof Error ? e.message : e}`);
6097
- });
6098
- }
6099
- process.stdout.write(formatCommentsExport({ repoRoot: root, comments, replyCommand }));
6277
+ const text = formatCommentsExport({ repoRoot: root, comments, replyCommand });
6278
+ await new Promise((resolve, reject) => process.stdout.write(text, (e) => e ? reject(e) : resolve()));
6279
+ if (args.includes("--peek")) return;
6280
+ await markHandedOver(
6281
+ store,
6282
+ comments.map((c) => c.id)
6283
+ );
6284
+ await takeCheckpoint(store, root, root === commonRoot ? void 0 : root, { handoff: true }).catch((e) => {
6285
+ console.error(`warden feedback: no checkpoint taken: ${e instanceof Error ? e.message : e}`);
6286
+ });
6100
6287
  }
6101
6288
  async function reply(args) {
6102
6289
  const [id, ...words] = args;
@@ -6123,7 +6310,7 @@ async function runAgentCommand(argv, replyCommand) {
6123
6310
  }
6124
6311
 
6125
6312
  // bin/cli.ts
6126
- var VERSION = true ? "0.16.0" : "dev";
6313
+ var VERSION = true ? "0.16.2" : "dev";
6127
6314
  function parseArgs(argv) {
6128
6315
  const args = { repoPath: process.cwd(), open: true, updateCheck: true, help: false, version: false };
6129
6316
  for (let i = 0; i < argv.length; i++) {
@@ -6142,11 +6329,11 @@ function parseArgs(argv) {
6142
6329
  } else if (a === "--help" || a === "-h") args.help = true;
6143
6330
  else if (a === "--version" || a === "-v") args.version = true;
6144
6331
  else if (a.startsWith("-")) throw new Error(`unknown option: ${a}`);
6145
- else args.repoPath = path11.resolve(a);
6332
+ else args.repoPath = path12.resolve(a);
6146
6333
  }
6147
6334
  return args;
6148
6335
  }
6149
- var viaNpx = fileURLToPath(import.meta.url).includes(`${path11.sep}_npx${path11.sep}`);
6336
+ var viaNpx = fileURLToPath(import.meta.url).includes(`${path12.sep}_npx${path12.sep}`);
6150
6337
  var selfCommand = viaNpx ? "npx @xbghc/warden" : "warden";
6151
6338
  function usage() {
6152
6339
  return `warden \u2014 local git diff review UI
@@ -6193,16 +6380,16 @@ async function openBrowser(url) {
6193
6380
  return false;
6194
6381
  }
6195
6382
  function resolveWebDir() {
6196
- const here = path11.dirname(fileURLToPath(import.meta.url));
6383
+ const here = path12.dirname(fileURLToPath(import.meta.url));
6197
6384
  const candidates = [
6198
6385
  process.env.WARDEN_WEB_DIR,
6199
- path11.join(here, "web"),
6386
+ path12.join(here, "web"),
6200
6387
  // dist/cli.js -> dist/web
6201
- path11.join(here, "..", "dist", "web")
6388
+ path12.join(here, "..", "dist", "web")
6202
6389
  // bin/cli.ts (tsx dev) -> dist/web
6203
6390
  ].filter((p) => !!p);
6204
6391
  for (const dir of candidates) {
6205
- if (existsSync2(path11.join(dir, "index.html"))) return dir;
6392
+ if (existsSync2(path12.join(dir, "index.html"))) return dir;
6206
6393
  }
6207
6394
  return void 0;
6208
6395
  }