@xbghc/warden 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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");
@@ -4031,7 +4071,7 @@ async function isUntracked(cwd, file) {
4031
4071
  return r.stdout.split("\0").filter(Boolean).includes(file);
4032
4072
  }
4033
4073
  async function untrackedDiff(cwd, file) {
4034
- const r = await runGit(["diff", "--no-color", "--no-ext-diff", "-U3", "--no-index", "--", "/dev/null", file], {
4074
+ const r = await runGit(["diff", ...DIFF_OUTPUT_ARGS, "--no-index", "--", "/dev/null", file], {
4035
4075
  cwd,
4036
4076
  okCodes: [0, 1]
4037
4077
  });
@@ -4058,7 +4098,7 @@ async function mapLimit(items, limit, fn) {
4058
4098
  }
4059
4099
  async function listTargetDiffs(ctx) {
4060
4100
  const args = await diffArgs(ctx);
4061
- const files = parseUnifiedDiff(await runDiff(ctx, args));
4101
+ const files = parseUnifiedDiff(await runDiff(ctx, [...args, "--"]));
4062
4102
  if (includesUntracked(ctx.target)) {
4063
4103
  const untracked = await listUntracked(ctx.cwd);
4064
4104
  const extra = await mapLimit(untracked, 8, (f) => untrackedDiff(ctx.cwd, f).catch(() => void 0));
@@ -4128,7 +4168,7 @@ async function pathsWithMarkers(ctx, side, paths) {
4128
4168
  const found = /* @__PURE__ */ new Set();
4129
4169
  if (paths.length === 0) return found;
4130
4170
  const ref = await refForSide(ctx, side);
4131
- const args = ["grep", "-l", "-z", "-I", "-i", "-F", ...DEBUG_MARKER_STRINGS.flatMap((m) => ["-e", m])];
4171
+ const args = ["grep", "--no-color", "--no-recurse-submodules", "-l", "-z", "-I", "-i", "-F", ...DEBUG_MARKER_STRINGS.flatMap((m) => ["-e", m])];
4132
4172
  if (ref === void 0) args.push("--untracked");
4133
4173
  else if (ref === ":0") args.push("--cached");
4134
4174
  else args.push(ref);
@@ -4444,6 +4484,39 @@ async function listUpstreams(ctx) {
4444
4484
  }
4445
4485
  return out;
4446
4486
  }
4487
+ async function commonDirOf(dir) {
4488
+ try {
4489
+ const r = await runGit(["rev-parse", "--show-toplevel", "--git-common-dir"], { cwd: dir });
4490
+ const [top, common] = r.stdout.trim().split("\n");
4491
+ if (!top || !common || await realpath2(top) !== await realpath2(dir)) return void 0;
4492
+ return await realpath2(path5.resolve(dir, common));
4493
+ } catch {
4494
+ return void 0;
4495
+ }
4496
+ }
4497
+ var IN_PROGRESS = [
4498
+ ["rebase-merge", "rebase"],
4499
+ ["rebase-apply", "rebase"],
4500
+ ["MERGE_HEAD", "merge"],
4501
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
4502
+ ["REVERT_HEAD", "revert"],
4503
+ ["BISECT_LOG", "bisect"]
4504
+ ];
4505
+ async function checkoutState(ctx, dir) {
4506
+ const [ours, theirs] = await Promise.all([commonDirOf(ctx.commonRoot), commonDirOf(dir)]);
4507
+ if (!ours || ours !== theirs) return { foreign: true };
4508
+ const r = await runGit(["rev-parse", ...IN_PROGRESS.flatMap(([f]) => ["--git-path", f])], { cwd: dir });
4509
+ const paths = r.stdout.trim().split("\n");
4510
+ for (const [i, [, what]] of IN_PROGRESS.entries()) {
4511
+ const p = paths[i];
4512
+ if (p && await stat3(path5.resolve(dir, p)).then(
4513
+ () => true,
4514
+ () => false
4515
+ ))
4516
+ return { foreign: false, busy: what };
4517
+ }
4518
+ return { foreign: false };
4519
+ }
4447
4520
  async function listWorktreesDetailed(ctx) {
4448
4521
  const [all, upstreams] = await Promise.all([listWorktreesAll(ctx), listUpstreams(ctx)]);
4449
4522
  const main2 = all.find((w) => w.isMain);
@@ -4451,12 +4524,16 @@ async function listWorktreesDetailed(ctx) {
4451
4524
  all.map(async (w) => {
4452
4525
  const slot = w.isMain || w.bare ? void 0 : slotOf(ctx, w.path);
4453
4526
  const dirty = w.prunable || w.bare ? 0 : await dirtyCount(w.path);
4527
+ const state = slot === void 0 || w.prunable ? { foreign: false } : await checkoutState(ctx, w.path);
4454
4528
  const detail = {
4455
4529
  ...w,
4456
4530
  dirty,
4457
4531
  ...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
4532
+ ...state.foreign ? { foreign: true } : {},
4533
+ ...state.busy ? { busy: state.busy } : {},
4534
+ // Free is what the next checkout may take over: a detached HEAD, nothing that would be lost,
4535
+ // nothing stopped halfway, and a checkout that is this repository's own.
4536
+ free: slot !== void 0 && !w.prunable && w.detached && dirty === 0 && !state.foreign && !state.busy
4460
4537
  };
4461
4538
  if (detail.free) return detail;
4462
4539
  const upstream = w.branch ? upstreams.get(w.branch) : void 0;
@@ -4524,6 +4601,8 @@ async function pickSlot(ctx, worktrees, wanted) {
4524
4601
  }
4525
4602
  const have = slots.find((w) => w.slot === wanted);
4526
4603
  if (have?.prunable) throw new HttpError(409, `the directory of slot ${wanted} is gone; remove its entry first`, "slot_gone");
4604
+ if (have?.foreign) throw new HttpError(409, `${have.path} is not a checkout of this repository`, "not_our_checkout");
4605
+ if (have?.busy) throw new HttpError(409, `a ${have.busy} is in progress in slot ${wanted}`, "operation_in_progress");
4527
4606
  if (have && !have.free) throw new HttpError(409, `slot ${wanted} is in use${have.branch ? ` by ${have.branch}` : ""}`, "slot_in_use");
4528
4607
  if (have) return { slot: wanted, path: have.path, reuse: true };
4529
4608
  const p = slotPath(ctx, wanted);
@@ -4604,6 +4683,9 @@ async function releaseWorktree(ctx, req) {
4604
4683
  if (wt.isMain) throw badRequest("the main worktree cannot be released", "main_worktree");
4605
4684
  if (wt.bare || slotOf(ctx, wt.path) === void 0) throw badRequest(`${wt.path} is not a slot; remove it instead`, "not_a_slot");
4606
4685
  if (wt.prunable) throw badRequest(`the directory of ${wt.path} is gone; remove its entry instead`, "worktree_gone");
4686
+ const state = await checkoutState(ctx, wt.path);
4687
+ 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");
4688
+ 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
4689
  const dirty = await dirtyCount(wt.path);
4608
4690
  if (dirty > 0 && !req.force) throw new HttpError(409, `${wt.path} has uncommitted changes`, "worktree_dirty");
4609
4691
  if (dirty > 0) {
@@ -4618,6 +4700,10 @@ async function removeWorktree(ctx, req) {
4618
4700
  const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path5.resolve(p));
4619
4701
  if (!wt) throw badRequest(`unknown worktree: ${p}`, "unknown_worktree");
4620
4702
  if (wt.isMain) throw badRequest("the main worktree cannot be removed", "main_worktree");
4703
+ if (!wt.prunable && !req.force) {
4704
+ const state = await checkoutState(ctx, wt.path);
4705
+ if (state.busy) throw new HttpError(409, `a ${state.busy} is in progress in ${wt.path}; removing it throws that away`, "needs_force");
4706
+ }
4621
4707
  const args = ["worktree", "remove"];
4622
4708
  if (req.force) args.push("--force");
4623
4709
  args.push(wt.path);
@@ -4717,7 +4803,7 @@ function formatTodoExport({ todo, comments, replyCommand }) {
4717
4803
 
4718
4804
  // packages/server/src/feedback.ts
4719
4805
  import { realpath as realpath3 } from "fs/promises";
4720
- import { randomUUID as randomUUID2 } from "crypto";
4806
+ import { randomUUID as randomUUID3 } from "crypto";
4721
4807
  async function real(p) {
4722
4808
  try {
4723
4809
  return await realpath3(p);
@@ -4782,7 +4868,7 @@ async function addReply(store, ref, author, body) {
4782
4868
  return store.update((s) => {
4783
4869
  const { list, index } = locate(s, ref);
4784
4870
  const now = (/* @__PURE__ */ new Date()).toISOString();
4785
- const reply2 = { id: randomUUID2(), author, body: body.trim(), at: now };
4871
+ const reply2 = { id: randomUUID3(), author, body: body.trim(), at: now };
4786
4872
  const prev = list[index];
4787
4873
  const next = { ...prev, replies: [...prev.replies ?? [], reply2], updatedAt: now };
4788
4874
  if (author === "reviewer") {
@@ -4861,6 +4947,11 @@ async function findSockets(dirs = socketDirs()) {
4861
4947
  for (const d of dirs) await visit(d, 0);
4862
4948
  return [...found.entries()].map(([socket, pid]) => ({ socket, pid }));
4863
4949
  }
4950
+ function chooseNvim(matching, wanted) {
4951
+ for (const w of wanted) if (w && matching.some((i) => i.socket === w)) return { socket: w };
4952
+ if (matching.length === 1) return { socket: matching[0].socket };
4953
+ return { error: matching.length ? "ambiguous" : "none" };
4954
+ }
4864
4955
  var NvimService = class {
4865
4956
  constructor(dirs) {
4866
4957
  this.dirs = dirs;
@@ -4915,6 +5006,7 @@ var NvimService = class {
4915
5006
 
4916
5007
  // packages/server/src/tmux.ts
4917
5008
  import { execFile as execFile3 } from "child_process";
5009
+ import path7 from "path";
4918
5010
  import { realpath as realpath4 } from "fs/promises";
4919
5011
  var runTmux = (args) => new Promise((resolve, reject) => {
4920
5012
  execFile3("tmux", args, { encoding: "utf8", timeout: 5e3, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
@@ -4925,34 +5017,48 @@ var runTmux = (args) => new Promise((resolve, reject) => {
4925
5017
  );
4926
5018
  });
4927
5019
  });
5020
+ function sessionNameFor(worktreePath) {
5021
+ return path7.basename(worktreePath).replace(/[.:#]/g, "_") || "warden";
5022
+ }
4928
5023
  var TmuxService = class {
4929
5024
  constructor(run2 = runTmux) {
4930
5025
  this.run = run2;
4931
5026
  }
4932
5027
  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}"]);
5028
+ async list() {
5029
+ const output = await this.run(["list-sessions", "-F", "#{session_id} #{session_name} #{session_path}"]).catch((e) => {
5030
+ if (e instanceof HttpError && /no server running|error connecting/i.test(e.message)) return "";
5031
+ throw e;
5032
+ });
4936
5033
  const sessions = [];
4937
5034
  for (const line of output.trimEnd().split("\n")) {
4938
5035
  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 });
5036
+ const dir = parts.join(" ");
5037
+ if (!id || !/^\$\d+$/.test(id) || !name || !dir) continue;
5038
+ sessions.push({ id, name, path: dir });
4942
5039
  }
4943
5040
  return sessions;
4944
5041
  }
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");
5042
+ /**
5043
+ * A detached session in the worktree, named after its directory, with a shell and nothing run in
5044
+ * it. One already there for the same directory is handed back rather than doubled; one of that
5045
+ * name elsewhere is refused, not replaced — it is someone's work.
5046
+ */
5047
+ async openSession(worktreePath) {
4948
5048
  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 };
5049
+ const name = sessionNameFor(directory);
5050
+ const existing = (await this.list()).find((x) => x.name === name);
5051
+ if (existing) {
5052
+ if (await realpath4(existing.path).catch(() => null) === directory) return { session: name, created: false };
5053
+ throw new HttpError(409, `\u5DF2\u6709\u540C\u540D tmux session ${name}\uFF0C\u5DE5\u4F5C\u76EE\u5F55\u662F ${existing.path}`, "tmux_name_taken");
5054
+ }
5055
+ await this.run(["new-session", "-d", "-s", name, "-c", directory.replaceAll("#", "##")]);
5056
+ return { session: name, created: true };
4951
5057
  }
4952
5058
  };
4953
5059
 
4954
5060
  // packages/server/src/watcher.ts
4955
- import path7 from "path";
5061
+ import path8 from "path";
4956
5062
  import { stat as stat5 } from "fs/promises";
4957
5063
  var DEFAULT_POLL_INTERVAL_MS = 1500;
4958
5064
  function statusPaths(out) {
@@ -5049,7 +5155,7 @@ var RepoWatcher = class {
5049
5155
  const stats = await Promise.all(
5050
5156
  statusPaths(statusRes.stdout).map(async (rel) => {
5051
5157
  try {
5052
- const st = await stat5(path7.join(this.root, rel));
5158
+ const st = await stat5(path8.join(this.root, rel));
5053
5159
  return `${rel} ${st.mtimeMs} ${st.size}`;
5054
5160
  } catch {
5055
5161
  return `${rel} -`;
@@ -5061,7 +5167,7 @@ var RepoWatcher = class {
5061
5167
  };
5062
5168
 
5063
5169
  // packages/server/src/static.ts
5064
- import path8 from "path";
5170
+ import path9 from "path";
5065
5171
  import { readFile as readFile3, stat as stat6 } from "fs/promises";
5066
5172
  var MIME = {
5067
5173
  ".html": "text/html; charset=utf-8",
@@ -5079,20 +5185,20 @@ var MIME = {
5079
5185
  ".txt": "text/plain; charset=utf-8"
5080
5186
  };
5081
5187
  async function serveStaticFile(c, webDir) {
5082
- const root = path8.resolve(webDir);
5188
+ const root = path9.resolve(webDir);
5083
5189
  let rel;
5084
5190
  try {
5085
5191
  rel = decodeURIComponent(c.req.path);
5086
5192
  } catch {
5087
5193
  return c.text("Bad Request", 400);
5088
5194
  }
5089
- let target = path8.resolve(root, "." + rel);
5090
- if (target !== root && !target.startsWith(root + path8.sep)) return c.text("Forbidden", 403);
5195
+ let target = path9.resolve(root, "." + rel);
5196
+ if (target !== root && !target.startsWith(root + path9.sep)) return c.text("Forbidden", 403);
5091
5197
  try {
5092
5198
  const st = await stat6(target);
5093
- if (st.isDirectory()) target = path8.join(target, "index.html");
5199
+ if (st.isDirectory()) target = path9.join(target, "index.html");
5094
5200
  } catch {
5095
- target = path8.join(root, "index.html");
5201
+ target = path9.join(root, "index.html");
5096
5202
  }
5097
5203
  let body;
5098
5204
  try {
@@ -5100,7 +5206,7 @@ async function serveStaticFile(c, webDir) {
5100
5206
  } catch {
5101
5207
  return c.text("Not Found", 404);
5102
5208
  }
5103
- const ext = path8.extname(target).toLowerCase();
5209
+ const ext = path9.extname(target).toLowerCase();
5104
5210
  const isAsset = rel.startsWith("/assets/");
5105
5211
  return new Response(new Uint8Array(body), {
5106
5212
  status: 200,
@@ -5172,8 +5278,8 @@ function createApp(opts) {
5172
5278
  const checkpointHandoff = (comments) => {
5173
5279
  const worktrees2 = new Set(comments.map((x) => tryParseTargetKey(x.targetKey)?.worktree));
5174
5280
  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}`);
5281
+ takeCheckpoint(store, wt ?? repo.commonRoot, wt, { handoff: true }).catch((e) => {
5282
+ console.error(`warden: no checkpoint for the comments handed over in ${wt ?? repo.commonRoot}: ${e instanceof Error ? e.message : e}`);
5177
5283
  });
5178
5284
  }
5179
5285
  };
@@ -5218,16 +5324,21 @@ function createApp(opts) {
5218
5324
  api.get("/ping", (c) => c.text(opts.instanceToken ?? ""));
5219
5325
  api.get("/repo", async (c) => {
5220
5326
  const state = await store.load();
5221
- const info = await getRepoInfo(repo, "working");
5327
+ const own = repo.root === repo.commonRoot ? "working" : formatTargetKey({ kind: "working", worktree: repo.root });
5328
+ const info = await getRepoInfo(repo, own);
5222
5329
  worktreeCache = { at: Date.now(), list: info.worktrees };
5223
5330
  const last = state.prefs.lastTarget;
5224
5331
  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));
5332
+ 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
5333
+ // worktree's page last looked at: the preference is shared by every server on the repository.
5334
+ (repo.root === repo.commonRoot || target.worktree === repo.root) && (target.kind !== "checkpoint" || !!findCheckpoint(state, target.worktree, target.id));
5226
5335
  if (last && usable2) info.defaultTarget = last;
5227
5336
  else if (last) {
5228
- await store.update((s) => {
5229
- s.prefs.lastTarget = "working";
5230
- });
5337
+ if (repo.root === repo.commonRoot) {
5338
+ await store.update((s) => {
5339
+ s.prefs.lastTarget = "working";
5340
+ });
5341
+ }
5231
5342
  }
5232
5343
  return c.json(info);
5233
5344
  });
@@ -5383,7 +5494,7 @@ function createApp(opts) {
5383
5494
  if (!anchored) throw badRequest("selection is not fully visible in the diff on that side (must be inside one hunk)", "bad_selection");
5384
5495
  const now = (/* @__PURE__ */ new Date()).toISOString();
5385
5496
  const comment = {
5386
- id: randomUUID3(),
5497
+ id: randomUUID4(),
5387
5498
  targetKey: key,
5388
5499
  filePath: body.filePath,
5389
5500
  side: body.side,
@@ -5507,7 +5618,6 @@ function createApp(opts) {
5507
5618
  const comments = state.targets[scope]?.comments ?? [];
5508
5619
  const prevHead = state.targets[scope]?.head;
5509
5620
  const head = await revParse(ctx.cwd, "HEAD") ?? "";
5510
- const committed = local && prevHead !== void 0 && prevHead !== head;
5511
5621
  const now = (/* @__PURE__ */ new Date()).toISOString();
5512
5622
  const located = /* @__PURE__ */ new Map();
5513
5623
  await Promise.all(
@@ -5531,6 +5641,19 @@ function createApp(opts) {
5531
5641
  }
5532
5642
  })
5533
5643
  );
5644
+ const landed = /* @__PURE__ */ new Set();
5645
+ const lost = comments.filter((cm) => !located.has(cm.id));
5646
+ if (local && lost.length && prevHead && head && prevHead !== head && await mergeBase(ctx.cwd, prevHead, head) === prevHead) {
5647
+ const range = await worktreeCtx(
5648
+ formatTargetKey({ kind: "range", base: prevHead, head, ...ctx.target.worktree ? { worktree: ctx.target.worktree } : {} })
5649
+ );
5650
+ await Promise.all(
5651
+ lost.map(async (cm) => {
5652
+ const committedDiff = await getFileDiff(range, cm.filePath).catch(() => void 0);
5653
+ if (committedDiff && reanchorComment(cm, committedDiff, now).status !== "orphaned") landed.add(cm.id);
5654
+ })
5655
+ );
5656
+ }
5534
5657
  const result = await store.update((s) => {
5535
5658
  const t = ensureTarget(s, scope);
5536
5659
  const dropped = [];
@@ -5538,7 +5661,7 @@ function createApp(opts) {
5538
5661
  const patch = located.get(cm.id);
5539
5662
  if (!patch) {
5540
5663
  if (!comments.some((x) => x.id === cm.id)) return [cm];
5541
- if (committed && !cm.replies?.length) {
5664
+ if (landed.has(cm.id) && !cm.replies?.length) {
5542
5665
  dropped.push(cm.id);
5543
5666
  return [];
5544
5667
  }
@@ -5602,7 +5725,7 @@ function createApp(opts) {
5602
5725
  const branch = body.branch?.trim() || await currentBranch(await knownRoot(body.root));
5603
5726
  const now = (/* @__PURE__ */ new Date()).toISOString();
5604
5727
  const todo = {
5605
- id: randomUUID3(),
5728
+ id: randomUUID4(),
5606
5729
  branch,
5607
5730
  title: body.title.trim(),
5608
5731
  body: typeof body.body === "string" ? body.body : "",
@@ -5705,13 +5828,14 @@ function createApp(opts) {
5705
5828
  if (ref && !isValidRef(ref)) throw badRequest("invalid ref");
5706
5829
  if (filePath && (filePath.startsWith("/") || filePath.split("/").includes(".."))) throw badRequest("invalid path");
5707
5830
  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}`];
5831
+ const args = ["log", "--no-show-signature", `--max-count=${limit + 1}`, `--skip=${offset}`, `--format=${COMMIT_FORMAT}`];
5709
5832
  if (firstParent) args.push("--first-parent");
5710
5833
  if (q || author) args.push("--fixed-strings", "--regexp-ignore-case");
5711
5834
  if (q) args.push(`--grep=${q}`);
5712
5835
  if (author) args.push(`--author=${author}`);
5713
5836
  if (ref) args.push(ref);
5714
- if (filePath) args.push("--", filePath);
5837
+ args.push("--");
5838
+ if (filePath) args.push(literal(filePath));
5715
5839
  let stdout = "";
5716
5840
  try {
5717
5841
  stdout = (await runGit(args, { cwd })).stdout;
@@ -5725,7 +5849,7 @@ function createApp(opts) {
5725
5849
  if (q && offset === 0 && /^[0-9a-f]{4,40}$/i.test(q)) {
5726
5850
  const sha = await revParse(cwd, `${q}^{commit}`);
5727
5851
  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];
5852
+ const hit = parseCommitLog((await runGit(["log", "--no-show-signature", "--max-count=1", `--format=${COMMIT_FORMAT}`, sha, "--"], { cwd })).stdout)[0];
5729
5853
  if (hit) commits = [hit, ...commits];
5730
5854
  }
5731
5855
  }
@@ -5744,13 +5868,13 @@ function createApp(opts) {
5744
5868
  return c.json(res);
5745
5869
  });
5746
5870
  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) => {
5871
+ api.post("/tmux/sessions", async (c) => {
5749
5872
  const body = await c.req.json();
5750
- if (!body || typeof body.path !== "string" || typeof body.sessionId !== "string") throw badRequest("path and sessionId are required");
5873
+ if (!body || typeof body.path !== "string") throw badRequest("path is required");
5751
5874
  const wt = (await worktrees(true)).find((w) => w.path === body.path && !w.bare);
5752
5875
  if (!wt) throw badRequest("unknown worktree", "unknown_worktree");
5753
- return c.json(await tmux.open(repo.commonRoot, wt.path, body.sessionId), 201);
5876
+ const res = await tmux.openSession(wt.path);
5877
+ return c.json(res, res.created ? 201 : 200);
5754
5878
  });
5755
5879
  api.get("/worktrees", async (c) => {
5756
5880
  const [list, state] = await Promise.all([listWorktreesDetailed(repo), store.load()]);
@@ -5759,7 +5883,7 @@ function createApp(opts) {
5759
5883
  const latest = /* @__PURE__ */ new Map();
5760
5884
  for (const t of Object.values(state.targets)) {
5761
5885
  for (const cm of t.comments) {
5762
- const at = commentWorktree(cm) ?? repo.root;
5886
+ const at = commentWorktree(cm) ?? repo.commonRoot;
5763
5887
  const r = review.get(at) ?? { toAgent: 0, toReviewer: 0 };
5764
5888
  const seen = latest.get(at) ?? { agent: "", reviewer: "" };
5765
5889
  if (awaitsAgent(cm)) {
@@ -5785,12 +5909,18 @@ function createApp(opts) {
5785
5909
  const res = { remotes: await lookupRemoteBranches(repo, c.req.query("branch") ?? "") };
5786
5910
  return c.json(res);
5787
5911
  });
5912
+ let worktreeWrites = Promise.resolve();
5913
+ const oneAtATime = (fn) => {
5914
+ const run2 = worktreeWrites.then(fn, fn);
5915
+ worktreeWrites = run2.catch(() => void 0);
5916
+ return run2;
5917
+ };
5788
5918
  api.post("/worktrees", async (c) => {
5789
5919
  const body = await c.req.json();
5790
5920
  if (typeof body.branch !== "string") throw badRequest("branch is required");
5791
5921
  if (body.base !== void 0 && typeof body.base !== "string") throw badRequest("base must be a ref");
5792
5922
  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);
5923
+ const res = await oneAtATime(() => checkoutWorktree(repo, body));
5794
5924
  worktreeCache = null;
5795
5925
  await store.update((s) => forgetWorktreeTargets(s, res.worktree.path));
5796
5926
  return c.json(res, 201);
@@ -5798,14 +5928,14 @@ function createApp(opts) {
5798
5928
  api.post("/worktrees/release", async (c) => {
5799
5929
  const body = await c.req.json();
5800
5930
  if (typeof body.path !== "string") throw badRequest("path is required");
5801
- const res = await releaseWorktree(repo, body);
5931
+ const res = await oneAtATime(() => releaseWorktree(repo, body));
5802
5932
  worktreeCache = null;
5803
5933
  return c.json(res);
5804
5934
  });
5805
5935
  api.post("/worktrees/remove", async (c) => {
5806
5936
  const body = await c.req.json();
5807
5937
  if (typeof body.path !== "string") throw badRequest("path is required");
5808
- const res = await removeWorktree(repo, body);
5938
+ const res = await oneAtATime(() => removeWorktree(repo, body));
5809
5939
  worktreeCache = null;
5810
5940
  return c.json(res);
5811
5941
  });
@@ -5856,9 +5986,8 @@ function createApp(opts) {
5856
5986
  const instances = NvimService.matching(scan.instances, root);
5857
5987
  const state = await store.load();
5858
5988
  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;
5989
+ const choice = chooseNvim(instances, [preferred]);
5990
+ const selected = "socket" in choice ? choice.socket : void 0;
5862
5991
  const res = { root, nvimAvailable: scan.nvimAvailable, instances, selected, scannedAt: scan.scannedAt };
5863
5992
  return c.json(res);
5864
5993
  });
@@ -5872,19 +6001,38 @@ function createApp(opts) {
5872
6001
  });
5873
6002
  api.post("/nvim/open", async (c) => {
5874
6003
  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");
6004
+ if (!body.absPath) throw badRequest("absPath is required");
6005
+ if (!path10.isAbsolute(body.absPath)) throw badRequest("absPath must be absolute");
6006
+ const root = await knownRoot(body.root);
6007
+ const wanted = [body.socket, (await store.load()).prefs.nvimSocketByRoot[root]];
6008
+ const line = Number(body.line) || 1;
6009
+ const pick = async (force) => {
6010
+ const scan = await nvim.scan(force);
6011
+ if (!scan.nvimAvailable) throw badRequest("nvim is not on PATH", "nvim_unavailable");
6012
+ return { matching: NvimService.matching(scan.instances, root), choice: chooseNvim(NvimService.matching(scan.instances, root), wanted) };
6013
+ };
6014
+ let { choice } = await pick(false);
6015
+ if ("error" in choice) choice = (await pick(true)).choice;
6016
+ if ("error" in choice) {
6017
+ if (choice.error === "none") throw notFound(`no nvim is open inside ${root}`, "nvim_none");
6018
+ throw new HttpError(409, `several nvim instances are open inside ${root}; select one`, "nvim_ambiguous");
5881
6019
  }
5882
6020
  try {
5883
- await nvim.open(body.socket, body.absPath, Number(body.line) || 1);
6021
+ await nvim.open(choice.socket, body.absPath, line);
6022
+ const res = { ok: true, socket: choice.socket };
6023
+ return c.json(res);
5884
6024
  } catch (e) {
5885
- throw new HttpError(502, e instanceof Error ? e.message : String(e), "nvim_failed");
6025
+ const failure = new HttpError(502, e instanceof Error ? e.message : String(e), "nvim_failed");
6026
+ const fresh = await pick(true);
6027
+ if (fresh.matching.some((i) => i.socket === choice.socket) || "error" in fresh.choice) throw failure;
6028
+ try {
6029
+ await nvim.open(fresh.choice.socket, body.absPath, line);
6030
+ } catch (e2) {
6031
+ throw new HttpError(502, e2 instanceof Error ? e2.message : String(e2), "nvim_failed");
6032
+ }
6033
+ const res = { ok: true, socket: fresh.choice.socket };
6034
+ return c.json(res);
5886
6035
  }
5887
- return c.json({ ok: true });
5888
6036
  });
5889
6037
  app.route("/api", api);
5890
6038
  app.notFound((c) => {
@@ -5933,14 +6081,14 @@ function reachableFromWindows(port, token, timeoutMs = PROBE_TIMEOUT_MS) {
5933
6081
  }
5934
6082
 
5935
6083
  // packages/server/src/update.ts
5936
- import path10 from "path";
5937
- import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
6084
+ import path11 from "path";
6085
+ import { mkdir as mkdir3, readFile as readFile5, writeFile } from "fs/promises";
5938
6086
  var PACKAGE = "@xbghc/warden";
5939
6087
  var LATEST_URL = "https://registry.npmjs.org/@xbghc%2fwarden/latest";
5940
6088
  var FETCH_TIMEOUT_MS = 3e3;
5941
6089
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
5942
6090
  function updateCacheFile(baseDir = dataDir()) {
5943
- return path10.join(baseDir, "update-check.json");
6091
+ return path11.join(baseDir, "update-check.json");
5944
6092
  }
5945
6093
  function updateCheckEnabled(env = process.env) {
5946
6094
  return !env.WARDEN_NO_UPDATE_CHECK && !env.NO_UPDATE_NOTIFIER && !env.CI;
@@ -5974,8 +6122,8 @@ async function readCache(file, now) {
5974
6122
  }
5975
6123
  async function writeCache(file, cache) {
5976
6124
  try {
5977
- await mkdir3(path10.dirname(file), { recursive: true });
5978
- await writeFile2(file, JSON.stringify(cache));
6125
+ await mkdir3(path11.dirname(file), { recursive: true });
6126
+ await writeFile(file, JSON.stringify(cache));
5979
6127
  } catch {
5980
6128
  }
5981
6129
  }
@@ -6019,7 +6167,7 @@ async function startServer(opts) {
6019
6167
  const repo = await resolveRepo(opts.repoPath);
6020
6168
  const stateFile = opts.stateFile ?? stateFilePath(repo.commonRoot);
6021
6169
  const store = new StateStore(stateFile, repo.commonRoot);
6022
- const instanceToken = randomUUID4();
6170
+ const instanceToken = randomUUID5();
6023
6171
  const app = createApp({ repo, store, nvim: new NvimService(), webDir: opts.webDir, instanceToken, update: opts.update, replyCommand: opts.replyCommand });
6024
6172
  const listen = (p) => new Promise((resolve, reject) => {
6025
6173
  const s = serve({ fetch: app.fetch, hostname: HOST, port: p }, () => resolve(s));
@@ -6123,7 +6271,7 @@ async function runAgentCommand(argv, replyCommand) {
6123
6271
  }
6124
6272
 
6125
6273
  // bin/cli.ts
6126
- var VERSION = true ? "0.16.0" : "dev";
6274
+ var VERSION = true ? "0.16.1" : "dev";
6127
6275
  function parseArgs(argv) {
6128
6276
  const args = { repoPath: process.cwd(), open: true, updateCheck: true, help: false, version: false };
6129
6277
  for (let i = 0; i < argv.length; i++) {
@@ -6142,11 +6290,11 @@ function parseArgs(argv) {
6142
6290
  } else if (a === "--help" || a === "-h") args.help = true;
6143
6291
  else if (a === "--version" || a === "-v") args.version = true;
6144
6292
  else if (a.startsWith("-")) throw new Error(`unknown option: ${a}`);
6145
- else args.repoPath = path11.resolve(a);
6293
+ else args.repoPath = path12.resolve(a);
6146
6294
  }
6147
6295
  return args;
6148
6296
  }
6149
- var viaNpx = fileURLToPath(import.meta.url).includes(`${path11.sep}_npx${path11.sep}`);
6297
+ var viaNpx = fileURLToPath(import.meta.url).includes(`${path12.sep}_npx${path12.sep}`);
6150
6298
  var selfCommand = viaNpx ? "npx @xbghc/warden" : "warden";
6151
6299
  function usage() {
6152
6300
  return `warden \u2014 local git diff review UI
@@ -6193,16 +6341,16 @@ async function openBrowser(url) {
6193
6341
  return false;
6194
6342
  }
6195
6343
  function resolveWebDir() {
6196
- const here = path11.dirname(fileURLToPath(import.meta.url));
6344
+ const here = path12.dirname(fileURLToPath(import.meta.url));
6197
6345
  const candidates = [
6198
6346
  process.env.WARDEN_WEB_DIR,
6199
- path11.join(here, "web"),
6347
+ path12.join(here, "web"),
6200
6348
  // dist/cli.js -> dist/web
6201
- path11.join(here, "..", "dist", "web")
6349
+ path12.join(here, "..", "dist", "web")
6202
6350
  // bin/cli.ts (tsx dev) -> dist/web
6203
6351
  ].filter((p) => !!p);
6204
6352
  for (const dir of candidates) {
6205
- if (existsSync2(path11.join(dir, "index.html"))) return dir;
6353
+ if (existsSync2(path12.join(dir, "index.html"))) return dir;
6206
6354
  }
6207
6355
  return void 0;
6208
6356
  }