@xbghc/warden 0.15.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 randomUUID3 } 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,8 +652,9 @@ var serve = (options, listeningListener) => {
652
652
  };
653
653
 
654
654
  // packages/server/src/app.ts
655
- import path9 from "path";
656
- import { randomUUID as randomUUID2 } from "crypto";
655
+ import path10 from "path";
656
+ import { stat as stat7 } from "fs/promises";
657
+ import { randomUUID as randomUUID4 } from "crypto";
657
658
 
658
659
  // node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/compose.js
659
660
  var compose = (middleware, onError, onNotFound) => {
@@ -812,26 +813,26 @@ var throwNestingLimitExceeded = () => {
812
813
  };
813
814
 
814
815
  // node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/utils/url.js
815
- var splitPath = (path12) => {
816
- const paths = path12.split("/");
816
+ var splitPath = (path13) => {
817
+ const paths = path13.split("/");
817
818
  if (paths[0] === "") {
818
819
  paths.shift();
819
820
  }
820
821
  return paths;
821
822
  };
822
823
  var splitRoutingPath = (routePath) => {
823
- const { groups, path: path12 } = extractGroupsFromPath(routePath);
824
- const paths = splitPath(path12);
824
+ const { groups, path: path13 } = extractGroupsFromPath(routePath);
825
+ const paths = splitPath(path13);
825
826
  return replaceGroupMarks(paths, groups);
826
827
  };
827
- var extractGroupsFromPath = (path12) => {
828
+ var extractGroupsFromPath = (path13) => {
828
829
  const groups = [];
829
- path12 = path12.replace(/\{[^}]+\}/g, (match2, index) => {
830
+ path13 = path13.replace(/\{[^}]+\}/g, (match2, index) => {
830
831
  const mark = `@${index}`;
831
832
  groups.push([mark, match2]);
832
833
  return mark;
833
834
  });
834
- return { groups, path: path12 };
835
+ return { groups, path: path13 };
835
836
  };
836
837
  var replaceGroupMarks = (paths, groups) => {
837
838
  for (let i = groups.length - 1; i >= 0; i--) {
@@ -888,8 +889,8 @@ var getPath = (request) => {
888
889
  const queryIndex = url.indexOf("?", i);
889
890
  const hashIndex = url.indexOf("#", i);
890
891
  const end = queryIndex === -1 ? hashIndex === -1 ? void 0 : hashIndex : hashIndex === -1 ? queryIndex : Math.min(queryIndex, hashIndex);
891
- const path12 = url.slice(start, end);
892
- 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);
893
894
  } else if (charCode === 63 || charCode === 35) {
894
895
  break;
895
896
  }
@@ -906,11 +907,11 @@ var mergePath = (base, sub, ...rest) => {
906
907
  }
907
908
  return `${base?.[0] === "/" ? "" : "/"}${base}${sub === "/" ? "" : `${base?.at(-1) === "/" ? "" : "/"}${sub?.[0] === "/" ? sub.slice(1) : sub}`}`;
908
909
  };
909
- var checkOptionalParameter = (path12) => {
910
- if (path12.charCodeAt(path12.length - 1) !== 63 || !path12.includes(":")) {
910
+ var checkOptionalParameter = (path13) => {
911
+ if (path13.charCodeAt(path13.length - 1) !== 63 || !path13.includes(":")) {
911
912
  return null;
912
913
  }
913
- const segments = path12.split("/");
914
+ const segments = path13.split("/");
914
915
  const results = [];
915
916
  let basePath = "";
916
917
  segments.forEach((segment) => {
@@ -1052,9 +1053,9 @@ var HonoRequest = class {
1052
1053
  */
1053
1054
  path;
1054
1055
  bodyCache = {};
1055
- constructor(request, path12 = "/", matchResult = [[]]) {
1056
+ constructor(request, path13 = "/", matchResult = [[]]) {
1056
1057
  this.raw = request;
1057
- this.path = path12;
1058
+ this.path = path13;
1058
1059
  this.#matchResult = matchResult;
1059
1060
  }
1060
1061
  param(key) {
@@ -1826,8 +1827,8 @@ var Hono = class _Hono {
1826
1827
  return this;
1827
1828
  };
1828
1829
  });
1829
- this.on = (method, path12, ...handlers) => {
1830
- for (const p of [path12].flat()) {
1830
+ this.on = (method, path13, ...handlers) => {
1831
+ for (const p of [path13].flat()) {
1831
1832
  this.#path = p;
1832
1833
  for (const m of [method].flat()) {
1833
1834
  const methodName = m.toUpperCase();
@@ -1885,8 +1886,8 @@ var Hono = class _Hono {
1885
1886
  * app.route("/api", app2) // GET /api/user
1886
1887
  * ```
1887
1888
  */
1888
- route(path12, app) {
1889
- const subApp = this.basePath(path12);
1889
+ route(path13, app) {
1890
+ const subApp = this.basePath(path13);
1890
1891
  app.routes.map((r) => {
1891
1892
  let handler;
1892
1893
  if (app.errorHandler === errorHandler) {
@@ -1912,9 +1913,9 @@ var Hono = class _Hono {
1912
1913
  * const api = new Hono().basePath('/api')
1913
1914
  * ```
1914
1915
  */
1915
- basePath(path12) {
1916
+ basePath(path13) {
1916
1917
  const subApp = this.#clone();
1917
- subApp._basePath = mergePath(this._basePath, path12);
1918
+ subApp._basePath = mergePath(this._basePath, path13);
1918
1919
  return subApp;
1919
1920
  }
1920
1921
  /**
@@ -1988,7 +1989,7 @@ var Hono = class _Hono {
1988
1989
  * })
1989
1990
  * ```
1990
1991
  */
1991
- mount(path12, applicationHandler, options) {
1992
+ mount(path13, applicationHandler, options) {
1992
1993
  let replaceRequest;
1993
1994
  let optionHandler;
1994
1995
  if (options) {
@@ -2015,7 +2016,7 @@ var Hono = class _Hono {
2015
2016
  return [c.env, executionContext];
2016
2017
  };
2017
2018
  replaceRequest ||= (() => {
2018
- const mergedPath = mergePath(this._basePath, path12);
2019
+ const mergedPath = mergePath(this._basePath, path13);
2019
2020
  const pathPrefixLength = mergedPath === "/" ? 0 : mergedPath.length;
2020
2021
  return (request) => {
2021
2022
  const url = new URL(request.url);
@@ -2030,18 +2031,18 @@ var Hono = class _Hono {
2030
2031
  }
2031
2032
  await next();
2032
2033
  };
2033
- this.#addRoute(METHOD_NAME_ALL, mergePath(path12, "*"), handler);
2034
+ this.#addRoute(METHOD_NAME_ALL, mergePath(path13, "*"), handler);
2034
2035
  return this;
2035
2036
  }
2036
- #addRoute(method, path12, handler, baseRoutePath) {
2037
- path12 = mergePath(this._basePath, path12);
2037
+ #addRoute(method, path13, handler, baseRoutePath) {
2038
+ path13 = mergePath(this._basePath, path13);
2038
2039
  const r = {
2039
2040
  basePath: baseRoutePath !== void 0 ? mergePath(this._basePath, baseRoutePath) : this._basePath,
2040
- path: path12,
2041
+ path: path13,
2041
2042
  method,
2042
2043
  handler
2043
2044
  };
2044
- this.router.add(method, path12, [handler, r]);
2045
+ this.router.add(method, path13, [handler, r]);
2045
2046
  this.routes.push(r);
2046
2047
  }
2047
2048
  #handleError(err, c) {
@@ -2054,10 +2055,10 @@ var Hono = class _Hono {
2054
2055
  if (method === "HEAD") {
2055
2056
  return (async () => new Response(null, await this.#dispatch(request, executionCtx, env, "GET")))();
2056
2057
  }
2057
- const path12 = this.getPath(request, { env });
2058
- const matchResult = this.router.match(method, path12);
2058
+ const path13 = this.getPath(request, { env });
2059
+ const matchResult = this.router.match(method, path13);
2059
2060
  const c = new Context(request, {
2060
- path: path12,
2061
+ path: path13,
2061
2062
  matchResult,
2062
2063
  env,
2063
2064
  executionCtx,
@@ -2160,7 +2161,7 @@ var createNullObject = () => /* @__PURE__ */ Object.create(null);
2160
2161
 
2161
2162
  // node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/router/reg-exp-router/matcher.js
2162
2163
  var emptyParam = [];
2163
- function match(method, path12) {
2164
+ function match(method, path13) {
2164
2165
  const matchers = this.buildAllMatchers();
2165
2166
  const match2 = ((method2, path22) => {
2166
2167
  const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
@@ -2176,7 +2177,7 @@ function match(method, path12) {
2176
2177
  return [matcher[1][index], match3];
2177
2178
  });
2178
2179
  this.match = match2;
2179
- return match2(method, path12);
2180
+ return match2(method, path13);
2180
2181
  }
2181
2182
 
2182
2183
  // node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/router/reg-exp-router/node.js
@@ -2293,14 +2294,14 @@ var Trie = class {
2293
2294
  #index = 0;
2294
2295
  // dynamic path -> [handler index, param assoc]; static paths are not registered
2295
2296
  paths = createNullObject();
2296
- insert(path12, isStatic) {
2297
+ insert(path13, isStatic) {
2297
2298
  if (isStatic) {
2298
- this.#root.insert(path12.split(""), 0, [], this.#context, true);
2299
+ this.#root.insert(path13.split(""), 0, [], this.#context, true);
2299
2300
  return;
2300
2301
  }
2301
2302
  const paramAssoc = [];
2302
2303
  const groups = [];
2303
- let markedPath = path12;
2304
+ let markedPath = path13;
2304
2305
  for (let i = 0; ; ) {
2305
2306
  let replaced = false;
2306
2307
  markedPath = markedPath.replace(/\{[^}]+\}/g, (m) => {
@@ -2325,7 +2326,7 @@ var Trie = class {
2325
2326
  }
2326
2327
  }
2327
2328
  this.#root.insert(tokens, this.#index, paramAssoc, this.#context, false);
2328
- this.paths[path12] = [this.#index++, paramAssoc];
2329
+ this.paths[path13] = [this.#index++, paramAssoc];
2329
2330
  }
2330
2331
  buildRegExp() {
2331
2332
  let regexp = this.#root.buildRegExpStr();
@@ -2352,17 +2353,17 @@ var Trie = class {
2352
2353
 
2353
2354
  // node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/router/reg-exp-router/router.js
2354
2355
  var wildcardRegExpCache = createNullObject();
2355
- function buildWildcardRegExp(path12) {
2356
- return wildcardRegExpCache[path12] ??= new RegExp(
2357
- `^${path12.replace(
2356
+ function buildWildcardRegExp(path13) {
2357
+ return wildcardRegExpCache[path13] ??= new RegExp(
2358
+ `^${path13.replace(
2358
2359
  /\/:[^/{}]+(?:\{\[\^\/]\+})?(?=[/{]|$)|\/?\*$|([.\\+*[^\]$()?{}|])/g,
2359
2360
  (match2, metaChar) => metaChar ? `\\${metaChar}` : match2 === "/*" ? TAIL_WILDCARD_REG_EXP_STR : match2 === "*" ? ONLY_WILDCARD_REG_EXP_STR : `/:${LABEL_REG_EXP_STR}`
2360
2361
  )}$`
2361
2362
  );
2362
2363
  }
2363
- function findMiddleware(middleware, path12) {
2364
+ function findMiddleware(middleware, path13) {
2364
2365
  for (const k of Object.keys(middleware).sort((a, b) => b.length - a.length)) {
2365
- if (buildWildcardRegExp(k).test(path12)) {
2366
+ if (buildWildcardRegExp(k).test(path13)) {
2366
2367
  return [...middleware[k]];
2367
2368
  }
2368
2369
  }
@@ -2378,14 +2379,14 @@ var RegExpRouter = class {
2378
2379
  this.#routes = { [METHOD_NAME_ALL]: createNullObject() };
2379
2380
  this.#tries = { [METHOD_NAME_ALL]: new Trie() };
2380
2381
  }
2381
- #insertPath(method, path12) {
2382
+ #insertPath(method, path13) {
2382
2383
  try {
2383
- this.#tries[method].insert(path12, !/\*|\/:/.test(path12));
2384
+ this.#tries[method].insert(path13, !/\*|\/:/.test(path13));
2384
2385
  } catch (e) {
2385
- throw e === PATH_ERROR ? new UnsupportedPathError(path12) : e;
2386
+ throw e === PATH_ERROR ? new UnsupportedPathError(path13) : e;
2386
2387
  }
2387
2388
  }
2388
- add(method, path12, handler) {
2389
+ add(method, path13, handler) {
2389
2390
  const middleware = this.#middleware;
2390
2391
  const routes = this.#routes;
2391
2392
  if (!middleware) {
@@ -2401,28 +2402,28 @@ var RegExpRouter = class {
2401
2402
  }
2402
2403
  }
2403
2404
  }
2404
- if (path12 === "/*") {
2405
- path12 = "*";
2405
+ if (path13 === "/*") {
2406
+ path13 = "*";
2406
2407
  }
2407
2408
  const methods = method === METHOD_NAME_ALL ? Object.keys(middleware) : [method];
2408
- if (/\*$/.test(path12)) {
2409
- const re = buildWildcardRegExp(path12);
2409
+ if (/\*$/.test(path13)) {
2410
+ const re = buildWildcardRegExp(path13);
2410
2411
  for (const m of methods) {
2411
- if (!middleware[m][path12]) {
2412
- this.#insertPath(m, path12);
2413
- 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) || [];
2414
2415
  }
2415
2416
  }
2416
2417
  for (const handlerMap of [middleware, routes]) {
2417
2418
  for (const m of methods) {
2418
2419
  for (const p in handlerMap[m]) {
2419
- re.test(p) && handlerMap[m][p].push([handler, path12]);
2420
+ re.test(p) && handlerMap[m][p].push([handler, path13]);
2420
2421
  }
2421
2422
  }
2422
2423
  }
2423
2424
  return;
2424
2425
  }
2425
- const paths = checkOptionalParameter(path12) || [path12];
2426
+ const paths = checkOptionalParameter(path13) || [path13];
2426
2427
  for (const path22 of paths) {
2427
2428
  for (const m of methods) {
2428
2429
  if (!routes[m][path22]) {
@@ -2451,11 +2452,11 @@ var RegExpRouter = class {
2451
2452
  const handlerData = [];
2452
2453
  const [regexp, indexReplacementMap, paramReplacementMap] = trie.buildRegExp();
2453
2454
  for (const r of [middleware, routes]) {
2454
- for (const path12 in r) {
2455
- const handlers = r[path12];
2456
- const pathData = trie.paths[path12];
2455
+ for (const path13 in r) {
2456
+ const handlers = r[path13];
2457
+ const pathData = trie.paths[path13];
2457
2458
  if (!pathData) {
2458
- staticMap[path12] = [handlers.map(([h]) => [h, createNullObject()]), emptyParam];
2459
+ staticMap[path13] = [handlers.map(([h]) => [h, createNullObject()]), emptyParam];
2459
2460
  continue;
2460
2461
  }
2461
2462
  handlerData[pathData[0]] = handlers.map(([h, handlerPath]) => [
@@ -2479,13 +2480,13 @@ var SmartRouter = class {
2479
2480
  constructor(init) {
2480
2481
  this.#routers = init.routers;
2481
2482
  }
2482
- add(method, path12, handler) {
2483
+ add(method, path13, handler) {
2483
2484
  if (!this.#routes) {
2484
2485
  throw new Error(MESSAGE_MATCHER_IS_ALREADY_BUILT);
2485
2486
  }
2486
- this.#routes.push([method, path12, handler]);
2487
+ this.#routes.push([method, path13, handler]);
2487
2488
  }
2488
- match(method, path12) {
2489
+ match(method, path13) {
2489
2490
  if (!this.#routes) {
2490
2491
  throw new Error("Fatal error");
2491
2492
  }
@@ -2500,7 +2501,7 @@ var SmartRouter = class {
2500
2501
  for (let i2 = 0, len2 = routes.length; i2 < len2; i2++) {
2501
2502
  router.add(...routes[i2]);
2502
2503
  }
2503
- res = router.match(method, path12);
2504
+ res = router.match(method, path13);
2504
2505
  } catch (e) {
2505
2506
  if (e instanceof UnsupportedPathError) {
2506
2507
  continue;
@@ -2535,9 +2536,9 @@ var Node2 = class _Node2 {
2535
2536
  #patterns = [];
2536
2537
  #pattern;
2537
2538
  #params = emptyParams;
2538
- insert(method, path12, handler) {
2539
+ insert(method, path13, handler) {
2539
2540
  let curNode = this;
2540
- const parts = splitRoutingPath(path12);
2541
+ const parts = splitRoutingPath(path13);
2541
2542
  const possibleKeys = /* @__PURE__ */ new Set();
2542
2543
  let i = 0;
2543
2544
  for (const p of parts) {
@@ -2577,12 +2578,12 @@ var Node2 = class _Node2 {
2577
2578
  }
2578
2579
  }
2579
2580
  }
2580
- search(method, path12) {
2581
+ search(method, path13) {
2581
2582
  const handlerSets = [];
2582
2583
  this.#params = emptyParams;
2583
2584
  const curNode = this;
2584
2585
  let curNodes = [curNode];
2585
- const parts = splitPath(path12);
2586
+ const parts = splitPath(path13);
2586
2587
  const curNodesQueue = [];
2587
2588
  const len = parts.length;
2588
2589
  let partOffsets = null;
@@ -2624,13 +2625,13 @@ var Node2 = class _Node2 {
2624
2625
  if (matcher !== true) {
2625
2626
  if (!partOffsets) {
2626
2627
  partOffsets = [];
2627
- let offset = path12[0] === "/" ? 1 : 0;
2628
+ let offset = path13[0] === "/" ? 1 : 0;
2628
2629
  for (let p = 0; p < len; p++) {
2629
2630
  partOffsets[p] = offset;
2630
2631
  offset += parts[p].length + 1;
2631
2632
  }
2632
2633
  }
2633
- const restPathString = path12.slice(partOffsets[i]);
2634
+ const restPathString = path13.slice(partOffsets[i]);
2634
2635
  const m = matcher.exec(restPathString);
2635
2636
  if (m) {
2636
2637
  params[name] = m[0];
@@ -2690,13 +2691,13 @@ var Node2 = class _Node2 {
2690
2691
  var TrieRouter = class {
2691
2692
  name = "TrieRouter";
2692
2693
  #node = new Node2();
2693
- add(method, path12, handler) {
2694
- for (const result of checkOptionalParameter(path12) || [path12]) {
2694
+ add(method, path13, handler) {
2695
+ for (const result of checkOptionalParameter(path13) || [path13]) {
2695
2696
  this.#node.insert(method, result, handler);
2696
2697
  }
2697
2698
  }
2698
- match(method, path12) {
2699
- return this.#node.search(method, path12);
2699
+ match(method, path13) {
2700
+ return this.#node.search(method, path13);
2700
2701
  }
2701
2702
  };
2702
2703
 
@@ -3000,6 +3001,17 @@ function moveBefore(list, id, before) {
3000
3001
  return rest;
3001
3002
  }
3002
3003
 
3004
+ // packages/shared/src/comments.ts
3005
+ function commentWorktree(c) {
3006
+ return tryParseTargetKey(c.targetKey)?.worktree;
3007
+ }
3008
+ function awaitsAgent(c) {
3009
+ return !c.exportedAt;
3010
+ }
3011
+ function awaitsReviewer(c) {
3012
+ return c.replies?.at(-1)?.author === "agent";
3013
+ }
3014
+
3003
3015
  // packages/server/src/errors.ts
3004
3016
  var HttpError = class extends Error {
3005
3017
  constructor(status, message, code) {
@@ -3399,39 +3411,286 @@ async function getRepoInfo(ctx, defaultTarget) {
3399
3411
  }
3400
3412
 
3401
3413
  // packages/server/src/targets.ts
3402
- import path3 from "path";
3403
- import { readFile } from "fs/promises";
3414
+ import path4 from "path";
3415
+ import { readFile as readFile2 } from "fs/promises";
3404
3416
 
3405
3417
  // packages/server/src/checkpoints.ts
3418
+ import path3 from "path";
3419
+ import { randomUUID as randomUUID2 } from "crypto";
3420
+ import { copyFile, mkdir as mkdir2, rm, stat as stat2, utimes as utimes2 } from "fs/promises";
3421
+
3422
+ // packages/server/src/state.ts
3423
+ import os from "os";
3406
3424
  import path2 from "path";
3407
3425
  import { randomUUID } from "crypto";
3408
- import { copyFile, mkdir, rm, stat, utimes } from "fs/promises";
3426
+ import { mkdir, open, readFile, rename, stat, unlink, utimes } from "fs/promises";
3427
+
3428
+ // packages/server/src/hash.ts
3429
+ import { createHash } from "crypto";
3430
+ function sha1(input) {
3431
+ return createHash("sha1").update(input).digest("hex");
3432
+ }
3433
+ function lineHash(content) {
3434
+ return sha1(content.trimEnd());
3435
+ }
3436
+ function hunkHash(lineContents) {
3437
+ return sha1(
3438
+ lineContents.map((l) => l.trimEnd()).join("\n").trim()
3439
+ );
3440
+ }
3441
+
3442
+ // packages/server/src/state.ts
3443
+ function dataDir() {
3444
+ const xdg = process.env.XDG_DATA_HOME;
3445
+ const base = xdg?.trim() ? xdg : path2.join(os.homedir(), ".local", "share");
3446
+ return path2.join(base, "warden");
3447
+ }
3448
+ function repoHash(repoRoot) {
3449
+ return sha1(repoRoot).slice(0, 12);
3450
+ }
3451
+ function stateFilePath(repoRoot, baseDir = dataDir()) {
3452
+ return path2.join(baseDir, repoHash(repoRoot), "state.json");
3453
+ }
3454
+ function defaultPrefs() {
3455
+ return { viewMode: "unified", nvimSocketByRoot: {}, autoRefresh: true, railOpen: false, ignoreDebug: true };
3456
+ }
3457
+ function defaultState(repoRoot) {
3458
+ return { schemaVersion: 1, repoRoot, targets: {}, todos: [], checkpoints: [], prefs: defaultPrefs() };
3459
+ }
3460
+ function ensureTarget(state, key) {
3461
+ let t = state.targets[key];
3462
+ if (!t) {
3463
+ t = { viewed: {}, comments: [] };
3464
+ state.targets[key] = t;
3465
+ }
3466
+ return t;
3467
+ }
3468
+ var usable = (v) => !!v && typeof v === "object" && typeof v.id === "string";
3469
+ var usableCheckpoint = (v) => {
3470
+ const c = v;
3471
+ return !!c && typeof c === "object" && Number.isInteger(c.id) && typeof c.tree === "string" && (c.worktree === void 0 || typeof c.worktree === "string");
3472
+ };
3473
+ var usableComment = (v) => usable(v) && typeof v.anchor === "object" && !!v.anchor;
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();
3485
+ const r = raw2;
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)));
3489
+ const targets = {};
3490
+ for (const [k, v] of Object.entries(r.targets ?? {})) {
3491
+ if (!v || typeof v !== "object") continue;
3492
+ targets[k] = {
3493
+ viewed: typeof v.viewed === "object" && v.viewed ? v.viewed : {},
3494
+ comments: Array.isArray(v.comments) ? v.comments.filter(usableComment) : [],
3495
+ ...typeof v.head === "string" ? { head: v.head } : {}
3496
+ };
3497
+ }
3498
+ migrateLocalViews(targets);
3499
+ return {
3500
+ ...extra,
3501
+ schemaVersion: SCHEMA_VERSION,
3502
+ repoRoot: r.repoRoot ?? repoRoot,
3503
+ targets,
3504
+ // Issues were folded into todos (a todo links comments now); the few there were are let go,
3505
+ // and the next write leaves them out of the file.
3506
+ todos: Array.isArray(r.todos) ? r.todos.filter(usable) : [],
3507
+ checkpoints: Array.isArray(r.checkpoints) ? r.checkpoints.filter(usableCheckpoint) : [],
3508
+ prefs: {
3509
+ ...defaultPrefs(),
3510
+ ...r.prefs ?? {},
3511
+ nvimSocketByRoot: r.prefs?.nvimSocketByRoot ?? {},
3512
+ autoRefresh: typeof r.prefs?.autoRefresh === "boolean" ? r.prefs.autoRefresh : true,
3513
+ railOpen: typeof r.prefs?.railOpen === "boolean" ? r.prefs.railOpen : false,
3514
+ ignoreDebug: typeof r.prefs?.ignoreDebug === "boolean" ? r.prefs.ignoreDebug : true
3515
+ }
3516
+ };
3517
+ }
3518
+ function migrateLocalViews(targets) {
3519
+ for (const key of Object.keys(targets).sort()) {
3520
+ const scope = commentScopeKey(key);
3521
+ if (scope === key) continue;
3522
+ const src = targets[key];
3523
+ delete targets[key];
3524
+ if (src.comments.length === 0) continue;
3525
+ let dst = targets[scope];
3526
+ if (!dst) {
3527
+ dst = { viewed: {}, comments: [] };
3528
+ targets[scope] = dst;
3529
+ }
3530
+ dst.comments.push(...src.comments);
3531
+ }
3532
+ }
3533
+ var LOCK_STALE_MS = 5e3;
3534
+ var LOCK_WAIT_MS = 5e3;
3535
+ async function sleep(ms) {
3536
+ await new Promise((r) => setTimeout(r, ms));
3537
+ }
3538
+ var StateStore = class {
3539
+ constructor(file, repoRoot) {
3540
+ this.file = file;
3541
+ this.repoRoot = repoRoot;
3542
+ }
3543
+ file;
3544
+ repoRoot;
3545
+ queue = Promise.resolve();
3546
+ async load() {
3547
+ try {
3548
+ const text = await readFile(this.file, "utf8");
3549
+ return normalise(JSON.parse(text), this.repoRoot, this.file);
3550
+ } catch (e) {
3551
+ const err = e;
3552
+ if (err.code === "ENOENT") return defaultState(this.repoRoot);
3553
+ if (e instanceof SyntaxError || e instanceof NotAState) {
3554
+ try {
3555
+ await rename(this.file, `${this.file}.corrupt-${Date.now()}`);
3556
+ } catch {
3557
+ }
3558
+ return defaultState(this.repoRoot);
3559
+ }
3560
+ throw e;
3561
+ }
3562
+ }
3563
+ /** Apply a mutation under lock. The callback receives the freshest on-disk state. */
3564
+ update(fn) {
3565
+ const run2 = async () => {
3566
+ await mkdir(path2.dirname(this.file), { recursive: true });
3567
+ const lock = await this.acquireLock();
3568
+ try {
3569
+ const state = await this.load();
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");
3572
+ await this.writeAtomic(state);
3573
+ return result;
3574
+ } finally {
3575
+ await lock.release();
3576
+ }
3577
+ };
3578
+ const p = this.queue.then(run2, run2);
3579
+ this.queue = p.catch(() => void 0);
3580
+ return p;
3581
+ }
3582
+ async writeAtomic(state) {
3583
+ const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`;
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
+ }
3591
+ await rename(tmp, this.file);
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
+ */
3599
+ async acquireLock() {
3600
+ const lock = `${this.file}.lock`;
3601
+ const token = `${process.pid}:${randomUUID()}`;
3602
+ const started = Date.now();
3603
+ for (; ; ) {
3604
+ try {
3605
+ const fh = await open(lock, "wx");
3606
+ await fh.writeFile(token);
3607
+ await fh.close();
3608
+ break;
3609
+ } catch (e) {
3610
+ const err = e;
3611
+ if (err.code !== "EEXIST") throw e;
3612
+ try {
3613
+ const st = await stat(lock);
3614
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
3615
+ const aside = `${lock}.stale-${token.replace(":", "-")}`;
3616
+ await rename(lock, aside).then(
3617
+ () => unlink(aside).catch(() => void 0),
3618
+ () => void 0
3619
+ );
3620
+ continue;
3621
+ }
3622
+ } catch {
3623
+ continue;
3624
+ }
3625
+ if (Date.now() - started > LOCK_WAIT_MS) {
3626
+ throw new Error(`timed out waiting for state lock ${lock}`);
3627
+ }
3628
+ await sleep(20);
3629
+ }
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
+ };
3643
+ }
3644
+ };
3645
+ function forgetWorktreeTargets(state, worktreePath) {
3646
+ const prefix = `worktree:${worktreePath}:`;
3647
+ const gone = /* @__PURE__ */ new Set();
3648
+ for (const key of Object.keys(state.targets)) {
3649
+ if (!key.startsWith(prefix)) continue;
3650
+ for (const c of state.targets[key].comments) gone.add(c.id);
3651
+ delete state.targets[key];
3652
+ }
3653
+ unlinkComments(state, gone);
3654
+ state.checkpoints = state.checkpoints.filter((c) => c.worktree !== worktreePath);
3655
+ }
3656
+ function unlinkComments(state, ids) {
3657
+ const gone = new Set(ids);
3658
+ if (gone.size === 0) return;
3659
+ for (const todo of state.todos) {
3660
+ if (!todo.commentIds?.some((id) => gone.has(id))) continue;
3661
+ const kept = todo.commentIds.filter((id) => !gone.has(id));
3662
+ if (kept.length) todo.commentIds = kept;
3663
+ else delete todo.commentIds;
3664
+ }
3665
+ }
3666
+
3667
+ // packages/server/src/checkpoints.ts
3409
3668
  function checkpointStore(stateFile) {
3410
- return path2.join(path2.dirname(stateFile), "checkpoints");
3669
+ return path3.join(path3.dirname(stateFile), "checkpoints");
3411
3670
  }
3412
3671
  async function repoObjects(cwd) {
3413
3672
  const r = await runGit(["rev-parse", "--git-path", "objects"], { cwd });
3414
- return path2.resolve(cwd, r.stdout.trim());
3673
+ return path3.resolve(cwd, r.stdout.trim());
3415
3674
  }
3416
3675
  async function repoIndex(cwd) {
3417
3676
  const r = await runGit(["rev-parse", "--git-path", "index"], { cwd });
3418
- return path2.resolve(cwd, r.stdout.trim());
3677
+ return path3.resolve(cwd, r.stdout.trim());
3419
3678
  }
3420
3679
  async function checkpointObjects(cwd, store) {
3421
- return { objects: path2.join(store, "objects"), alternates: await repoObjects(cwd) };
3680
+ return { objects: path3.join(store, "objects"), alternates: await repoObjects(cwd) };
3422
3681
  }
3423
3682
  async function withIndexCopy(cwd, store, fn) {
3424
- const tmp = path2.join(store, "tmp");
3425
- await mkdir(path2.join(store, "objects"), { recursive: true });
3426
- await mkdir(tmp, { recursive: true });
3683
+ const tmp = path3.join(store, "tmp");
3684
+ await mkdir2(path3.join(store, "objects"), { recursive: true });
3685
+ await mkdir2(tmp, { recursive: true });
3427
3686
  const [objects, index] = await Promise.all([checkpointObjects(cwd, store), repoIndex(cwd)]);
3428
- const copy = path2.join(tmp, `index-${randomUUID()}`);
3687
+ const copy = path3.join(tmp, `index-${randomUUID2()}`);
3429
3688
  try {
3430
- const st = await stat(index).catch(() => void 0);
3689
+ const st = await stat2(index).catch(() => void 0);
3431
3690
  if (st) {
3432
3691
  await copyFile(index, copy);
3433
3692
  const earlier = Math.floor(st.mtimeMs / 1e3) - 1;
3434
- await utimes(copy, earlier, earlier);
3693
+ await utimes2(copy, earlier, earlier);
3435
3694
  }
3436
3695
  return await fn({ ...objects, index: copy });
3437
3696
  } finally {
@@ -3468,29 +3727,26 @@ function forgetCheckpoint(state, c) {
3468
3727
  const key = checkpointKey(c);
3469
3728
  const gone = new Set((state.targets[key]?.comments ?? []).map((x) => x.id));
3470
3729
  delete state.targets[key];
3471
- if (gone.size) for (const issue of state.issues) issue.commentIds = issue.commentIds.filter((id) => !gone.has(id));
3730
+ unlinkComments(state, gone);
3472
3731
  }
3473
- function addCheckpoint(state, worktree, tree, head) {
3732
+ function addCheckpoint(state, worktree, tree, head, handoff = false) {
3474
3733
  const mine = checkpointsOf(state, worktree);
3475
3734
  const id = mine.reduce((n, c) => Math.max(n, c.id), 0) + 1;
3476
- const checkpoint = { id, ...worktree ? { worktree } : {}, tree, head, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
3735
+ const checkpoint = { id, ...worktree ? { worktree } : {}, tree, head, createdAt: (/* @__PURE__ */ new Date()).toISOString(), ...handoff ? { handoff } : {} };
3477
3736
  state.checkpoints.push(checkpoint);
3478
- 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);
3479
3740
  return checkpoint;
3480
3741
  }
3481
-
3482
- // packages/server/src/hash.ts
3483
- import { createHash } from "crypto";
3484
- function sha1(input) {
3485
- return createHash("sha1").update(input).digest("hex");
3486
- }
3487
- function lineHash(content) {
3488
- return sha1(content.trimEnd());
3489
- }
3490
- function hunkHash(lineContents) {
3491
- return sha1(
3492
- lineContents.map((l) => l.trimEnd()).join("\n").trim()
3493
- );
3742
+ async function takeCheckpoint(store, cwd, worktree, opts = {}) {
3743
+ const tree = await snapshotWorktree(cwd, checkpointStore(store.file));
3744
+ const head = await revParse(cwd, "HEAD") ?? "";
3745
+ return store.update((s) => {
3746
+ const newest = checkpointsOf(s, worktree).at(-1);
3747
+ if (newest?.tree === tree) return { checkpoint: newest, unchanged: true };
3748
+ return { checkpoint: addCheckpoint(s, worktree, tree, head, opts.handoff), unchanged: false };
3749
+ });
3494
3750
  }
3495
3751
 
3496
3752
  // packages/server/src/diffparse.ts
@@ -3745,7 +4001,8 @@ async function runDiff(ctx, args) {
3745
4001
  if (ctx.target.kind !== "checkpoint") return (await runGit(args, { cwd: ctx.cwd })).stdout;
3746
4002
  return withCheckpointIndex(ctx.cwd, checkpointOf(ctx).store, async (snapshot) => (await runGit(args, { cwd: ctx.cwd, snapshot })).stdout);
3747
4003
  }
3748
- 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"];
3749
4006
  function resolveTargetContext(repo, worktrees, key) {
3750
4007
  let target;
3751
4008
  try {
@@ -3754,7 +4011,7 @@ function resolveTargetContext(repo, worktrees, key) {
3754
4011
  if (e instanceof TargetKeyError) throw badRequest(e.message, "invalid_target");
3755
4012
  throw e;
3756
4013
  }
3757
- let cwd = repo.root;
4014
+ let cwd = repo.commonRoot;
3758
4015
  if (target.worktree) {
3759
4016
  const wt = worktrees.find((w) => w.path === target.worktree);
3760
4017
  if (!wt) throw badRequest(`unknown worktree: ${target.worktree}`, "unknown_worktree");
@@ -3814,7 +4071,7 @@ async function isUntracked(cwd, file) {
3814
4071
  return r.stdout.split("\0").filter(Boolean).includes(file);
3815
4072
  }
3816
4073
  async function untrackedDiff(cwd, file) {
3817
- 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], {
3818
4075
  cwd,
3819
4076
  okCodes: [0, 1]
3820
4077
  });
@@ -3841,7 +4098,7 @@ async function mapLimit(items, limit, fn) {
3841
4098
  }
3842
4099
  async function listTargetDiffs(ctx) {
3843
4100
  const args = await diffArgs(ctx);
3844
- const files = parseUnifiedDiff(await runDiff(ctx, args));
4101
+ const files = parseUnifiedDiff(await runDiff(ctx, [...args, "--"]));
3845
4102
  if (includesUntracked(ctx.target)) {
3846
4103
  const untracked = await listUntracked(ctx.cwd);
3847
4104
  const extra = await mapLimit(untracked, 8, (f) => untrackedDiff(ctx.cwd, f).catch(() => void 0));
@@ -3892,7 +4149,7 @@ async function getFullFile(ctx, filePath, side) {
3892
4149
  const ref = await refForSide(ctx, side);
3893
4150
  if (ref === void 0) {
3894
4151
  try {
3895
- const buf = await readFile(path3.join(ctx.cwd, filePath));
4152
+ const buf = await readFile2(path4.join(ctx.cwd, filePath));
3896
4153
  return buf.toString("utf8");
3897
4154
  } catch {
3898
4155
  return null;
@@ -3911,7 +4168,7 @@ async function pathsWithMarkers(ctx, side, paths) {
3911
4168
  const found = /* @__PURE__ */ new Set();
3912
4169
  if (paths.length === 0) return found;
3913
4170
  const ref = await refForSide(ctx, side);
3914
- 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])];
3915
4172
  if (ref === void 0) args.push("--untracked");
3916
4173
  else if (ref === ":0") args.push("--cached");
3917
4174
  else args.push(ref);
@@ -4192,15 +4449,15 @@ function buildStagePatch(diff, mode, selection, opts = {}) {
4192
4449
  }
4193
4450
 
4194
4451
  // packages/server/src/worktrees.ts
4195
- import path4 from "path";
4196
- import { readdir, realpath as realpath2, stat as stat2 } from "fs/promises";
4452
+ import path5 from "path";
4453
+ import { readdir, realpath as realpath2, stat as stat3 } from "fs/promises";
4197
4454
  function slotPath(ctx, slot) {
4198
- return path4.join(path4.dirname(ctx.commonRoot), `${path4.basename(ctx.commonRoot)}-${slot}`);
4455
+ return path5.join(path5.dirname(ctx.commonRoot), `${path5.basename(ctx.commonRoot)}-${slot}`);
4199
4456
  }
4200
4457
  function slotOf(ctx, p) {
4201
- if (path4.dirname(p) !== path4.dirname(ctx.commonRoot)) return void 0;
4202
- const prefix = `${path4.basename(ctx.commonRoot)}-`;
4203
- const name = path4.basename(p);
4458
+ if (path5.dirname(p) !== path5.dirname(ctx.commonRoot)) return void 0;
4459
+ const prefix = `${path5.basename(ctx.commonRoot)}-`;
4460
+ const name = path5.basename(p);
4204
4461
  const n = name.startsWith(prefix) ? name.slice(prefix.length) : "";
4205
4462
  return /^[1-9]\d*$/.test(n) ? Number(n) : void 0;
4206
4463
  }
@@ -4227,6 +4484,39 @@ async function listUpstreams(ctx) {
4227
4484
  }
4228
4485
  return out;
4229
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
+ }
4230
4520
  async function listWorktreesDetailed(ctx) {
4231
4521
  const [all, upstreams] = await Promise.all([listWorktreesAll(ctx), listUpstreams(ctx)]);
4232
4522
  const main2 = all.find((w) => w.isMain);
@@ -4234,12 +4524,16 @@ async function listWorktreesDetailed(ctx) {
4234
4524
  all.map(async (w) => {
4235
4525
  const slot = w.isMain || w.bare ? void 0 : slotOf(ctx, w.path);
4236
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);
4237
4528
  const detail = {
4238
4529
  ...w,
4239
4530
  dirty,
4240
4531
  ...slot === void 0 ? {} : { slot },
4241
- // Free is what the next checkout may take over: a detached HEAD and nothing that would be lost.
4242
- 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
4243
4537
  };
4244
4538
  if (detail.free) return detail;
4245
4539
  const upstream = w.branch ? upstreams.get(w.branch) : void 0;
@@ -4285,7 +4579,7 @@ async function assertBranchName(ctx, name) {
4285
4579
  }
4286
4580
  }
4287
4581
  async function dirUsable(p) {
4288
- const st = await stat2(p).catch((e) => {
4582
+ const st = await stat3(p).catch((e) => {
4289
4583
  if (e.code === "ENOENT") return void 0;
4290
4584
  throw e;
4291
4585
  });
@@ -4307,6 +4601,8 @@ async function pickSlot(ctx, worktrees, wanted) {
4307
4601
  }
4308
4602
  const have = slots.find((w) => w.slot === wanted);
4309
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");
4310
4606
  if (have && !have.free) throw new HttpError(409, `slot ${wanted} is in use${have.branch ? ` by ${have.branch}` : ""}`, "slot_in_use");
4311
4607
  if (have) return { slot: wanted, path: have.path, reuse: true };
4312
4608
  const p = slotPath(ctx, wanted);
@@ -4363,8 +4659,8 @@ async function checkoutWorktree(ctx, req) {
4363
4659
  if (e instanceof GitError && /already (checked out|used by worktree)/.test(e.stderr)) throw new HttpError(409, e.message, "branch_in_use");
4364
4660
  throw e;
4365
4661
  }
4366
- const real = await realpath2(target.path).catch(() => target.path);
4367
- const worktree = (await listWorktrees(ctx)).find((w) => w.path === real);
4662
+ const real2 = await realpath2(target.path).catch(() => target.path);
4663
+ const worktree = (await listWorktrees(ctx)).find((w) => w.path === real2);
4368
4664
  if (!worktree) throw new HttpError(500, `git checked ${branch} out at ${target.path} but does not list it`, "git_failed");
4369
4665
  return { worktree, slot: target.slot, reused: target.reuse };
4370
4666
  }
@@ -4382,11 +4678,14 @@ async function dropBranch(ctx, wt, asked) {
4382
4678
  }
4383
4679
  async function releaseWorktree(ctx, req) {
4384
4680
  const p = req.path.trim();
4385
- const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path4.resolve(p));
4681
+ const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path5.resolve(p));
4386
4682
  if (!wt) throw badRequest(`unknown worktree: ${p}`, "unknown_worktree");
4387
4683
  if (wt.isMain) throw badRequest("the main worktree cannot be released", "main_worktree");
4388
4684
  if (wt.bare || slotOf(ctx, wt.path) === void 0) throw badRequest(`${wt.path} is not a slot; remove it instead`, "not_a_slot");
4389
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");
4390
4689
  const dirty = await dirtyCount(wt.path);
4391
4690
  if (dirty > 0 && !req.force) throw new HttpError(409, `${wt.path} has uncommitted changes`, "worktree_dirty");
4392
4691
  if (dirty > 0) {
@@ -4398,9 +4697,13 @@ async function releaseWorktree(ctx, req) {
4398
4697
  }
4399
4698
  async function removeWorktree(ctx, req) {
4400
4699
  const p = req.path.trim();
4401
- const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path4.resolve(p));
4700
+ const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path5.resolve(p));
4402
4701
  if (!wt) throw badRequest(`unknown worktree: ${p}`, "unknown_worktree");
4403
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
+ }
4404
4707
  const args = ["worktree", "remove"];
4405
4708
  if (req.force) args.push("--force");
4406
4709
  args.push(wt.path);
@@ -4418,187 +4721,6 @@ async function removeWorktree(ctx, req) {
4418
4721
  return dropBranch(ctx, wt, req.deleteBranch);
4419
4722
  }
4420
4723
 
4421
- // packages/server/src/state.ts
4422
- import os from "os";
4423
- import path5 from "path";
4424
- import { mkdir as mkdir2, open, readFile as readFile2, rename, stat as stat3, unlink, writeFile } from "fs/promises";
4425
- function dataDir() {
4426
- const xdg = process.env.XDG_DATA_HOME;
4427
- const base = xdg?.trim() ? xdg : path5.join(os.homedir(), ".local", "share");
4428
- return path5.join(base, "warden");
4429
- }
4430
- function repoHash(repoRoot) {
4431
- return sha1(repoRoot).slice(0, 12);
4432
- }
4433
- function stateFilePath(repoRoot, baseDir = dataDir()) {
4434
- return path5.join(baseDir, repoHash(repoRoot), "state.json");
4435
- }
4436
- function defaultPrefs() {
4437
- return { viewMode: "unified", nvimSocketByRoot: {}, autoRefresh: true, railOpen: false, ignoreDebug: true };
4438
- }
4439
- function defaultState(repoRoot) {
4440
- return { schemaVersion: 1, repoRoot, targets: {}, issues: [], todos: [], checkpoints: [], prefs: defaultPrefs() };
4441
- }
4442
- function ensureTarget(state, key) {
4443
- let t = state.targets[key];
4444
- if (!t) {
4445
- t = { viewed: {}, comments: [] };
4446
- state.targets[key] = t;
4447
- }
4448
- return t;
4449
- }
4450
- var usable = (v) => !!v && typeof v === "object" && typeof v.id === "string";
4451
- var usableCheckpoint = (v) => {
4452
- const c = v;
4453
- return !!c && typeof c === "object" && Number.isInteger(c.id) && typeof c.tree === "string" && (c.worktree === void 0 || typeof c.worktree === "string");
4454
- };
4455
- var usableComment = (v) => usable(v) && typeof v.anchor === "object" && !!v.anchor;
4456
- function normalise(raw2, repoRoot) {
4457
- const base = defaultState(repoRoot);
4458
- if (!raw2 || typeof raw2 !== "object") return base;
4459
- const r = raw2;
4460
- if (r.schemaVersion !== 1) return base;
4461
- const targets = {};
4462
- for (const [k, v] of Object.entries(r.targets ?? {})) {
4463
- if (!v || typeof v !== "object") continue;
4464
- targets[k] = {
4465
- viewed: typeof v.viewed === "object" && v.viewed ? v.viewed : {},
4466
- comments: Array.isArray(v.comments) ? v.comments.filter(usableComment) : [],
4467
- ...typeof v.head === "string" ? { head: v.head } : {}
4468
- };
4469
- }
4470
- migrateLocalViews(targets);
4471
- return {
4472
- schemaVersion: 1,
4473
- repoRoot: r.repoRoot ?? repoRoot,
4474
- targets,
4475
- issues: Array.isArray(r.issues) ? r.issues.filter(usable) : [],
4476
- todos: Array.isArray(r.todos) ? r.todos.filter(usable) : [],
4477
- checkpoints: Array.isArray(r.checkpoints) ? r.checkpoints.filter(usableCheckpoint) : [],
4478
- prefs: {
4479
- ...defaultPrefs(),
4480
- ...r.prefs ?? {},
4481
- nvimSocketByRoot: r.prefs?.nvimSocketByRoot ?? {},
4482
- autoRefresh: typeof r.prefs?.autoRefresh === "boolean" ? r.prefs.autoRefresh : true,
4483
- railOpen: typeof r.prefs?.railOpen === "boolean" ? r.prefs.railOpen : false,
4484
- ignoreDebug: typeof r.prefs?.ignoreDebug === "boolean" ? r.prefs.ignoreDebug : true
4485
- }
4486
- };
4487
- }
4488
- function migrateLocalViews(targets) {
4489
- for (const key of Object.keys(targets).sort()) {
4490
- const scope = commentScopeKey(key);
4491
- if (scope === key) continue;
4492
- const src = targets[key];
4493
- delete targets[key];
4494
- if (src.comments.length === 0) continue;
4495
- let dst = targets[scope];
4496
- if (!dst) {
4497
- dst = { viewed: {}, comments: [] };
4498
- targets[scope] = dst;
4499
- }
4500
- dst.comments.push(...src.comments);
4501
- }
4502
- }
4503
- var LOCK_STALE_MS = 5e3;
4504
- var LOCK_WAIT_MS = 5e3;
4505
- async function sleep(ms) {
4506
- await new Promise((r) => setTimeout(r, ms));
4507
- }
4508
- var StateStore = class {
4509
- constructor(file, repoRoot) {
4510
- this.file = file;
4511
- this.repoRoot = repoRoot;
4512
- }
4513
- file;
4514
- repoRoot;
4515
- queue = Promise.resolve();
4516
- async load() {
4517
- try {
4518
- const text = await readFile2(this.file, "utf8");
4519
- return normalise(JSON.parse(text), this.repoRoot);
4520
- } catch (e) {
4521
- const err = e;
4522
- if (err.code === "ENOENT") return defaultState(this.repoRoot);
4523
- if (e instanceof SyntaxError) {
4524
- try {
4525
- await rename(this.file, `${this.file}.corrupt-${Date.now()}`);
4526
- } catch {
4527
- }
4528
- return defaultState(this.repoRoot);
4529
- }
4530
- throw e;
4531
- }
4532
- }
4533
- /** Apply a mutation under lock. The callback receives the freshest on-disk state. */
4534
- update(fn) {
4535
- const run2 = async () => {
4536
- await mkdir2(path5.dirname(this.file), { recursive: true });
4537
- const release = await this.acquireLock();
4538
- try {
4539
- const state = await this.load();
4540
- const result = await fn(state);
4541
- await this.writeAtomic(state);
4542
- return result;
4543
- } finally {
4544
- await release();
4545
- }
4546
- };
4547
- const p = this.queue.then(run2, run2);
4548
- this.queue = p.catch(() => void 0);
4549
- return p;
4550
- }
4551
- async writeAtomic(state) {
4552
- const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`;
4553
- await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
4554
- await rename(tmp, this.file);
4555
- }
4556
- async acquireLock() {
4557
- const lock = `${this.file}.lock`;
4558
- const started = Date.now();
4559
- for (; ; ) {
4560
- try {
4561
- const fh = await open(lock, "wx");
4562
- await fh.writeFile(String(process.pid));
4563
- await fh.close();
4564
- return async () => {
4565
- try {
4566
- await unlink(lock);
4567
- } catch {
4568
- }
4569
- };
4570
- } catch (e) {
4571
- const err = e;
4572
- if (err.code !== "EEXIST") throw e;
4573
- try {
4574
- const st = await stat3(lock);
4575
- if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
4576
- await unlink(lock).catch(() => void 0);
4577
- continue;
4578
- }
4579
- } catch {
4580
- continue;
4581
- }
4582
- if (Date.now() - started > LOCK_WAIT_MS) {
4583
- throw new Error(`timed out waiting for state lock ${lock}`);
4584
- }
4585
- await sleep(20);
4586
- }
4587
- }
4588
- }
4589
- };
4590
- function forgetWorktreeTargets(state, worktreePath) {
4591
- const prefix = `worktree:${worktreePath}:`;
4592
- const gone = /* @__PURE__ */ new Set();
4593
- for (const key of Object.keys(state.targets)) {
4594
- if (!key.startsWith(prefix)) continue;
4595
- for (const c of state.targets[key].comments) gone.add(c.id);
4596
- delete state.targets[key];
4597
- }
4598
- if (gone.size) for (const issue of state.issues) issue.commentIds = issue.commentIds.filter((id) => !gone.has(id));
4599
- state.checkpoints = state.checkpoints.filter((c) => c.worktree !== worktreePath);
4600
- }
4601
-
4602
4724
  // packages/server/src/export.ts
4603
4725
  var LANG_BY_EXT = {
4604
4726
  ts: "ts",
@@ -4646,29 +4768,116 @@ function langForPath(filePath) {
4646
4768
  if (!m?.[1]) return "";
4647
4769
  return LANG_BY_EXT[m[1].toLowerCase()] ?? m[1].toLowerCase();
4648
4770
  }
4771
+ function shortId(id) {
4772
+ return id.slice(0, 8);
4773
+ }
4774
+ var quote = (text) => text.replace(/\r\n/g, "\n").split("\n").map((l) => l ? `> ${l}` : ">").join("\n");
4649
4775
  function formatCommentSection(c) {
4650
4776
  const range = c.startLine === c.endLine ? `${c.startLine}` : `${c.startLine}-${c.endLine}`;
4651
4777
  const width = String(c.endLine).length;
4652
4778
  const code = c.codeSnippet.map((line, i) => `${String(c.startLine + i).padStart(width, " ")} | ${line}`).join("\n");
4653
4779
  const lang = langForPath(c.filePath);
4654
- const body = c.body.replace(/\r\n/g, "\n").split("\n").map((l) => l ? `> ${l}` : ">").join("\n");
4655
4780
  const fence = code.includes("```") ? "````" : "```";
4656
4781
  const orphanNote = c.status === "orphaned" ? " [orphaned: original location no longer exists]" : "";
4657
- return [`## ${c.filePath}:${range} (${c.side})${orphanNote}`, `${fence}${lang}`, code, fence, body].join("\n");
4782
+ const parts = [`## ${c.filePath}:${range} (${c.side}) [id: ${shortId(c.id)}]${orphanNote}`, `${fence}${lang}`, code, fence, quote(c.body)];
4783
+ for (const r of c.replies ?? []) parts.push(`${r.author === "agent" ? "Agent" : "Reviewer"} replied:
4784
+ ${quote(r.body)}`);
4785
+ return parts.join("\n");
4658
4786
  }
4659
- function formatCommentsExport({ repoRoot, comments }) {
4787
+ function replyFooter(replyCommand) {
4788
+ return `When you have dealt with a comment, answer it with \`${replyCommand} reply <id> "<what you changed, or why you did not>"\` so the reviewer sees your answer beside it.`;
4789
+ }
4790
+ function formatCommentsExport({ repoRoot, comments, replyCommand }) {
4660
4791
  const targets = [...new Set(comments.map((c) => c.targetKey))];
4661
4792
  const head = ["# Review comments", `Target: ${targets.join(", ") || "-"}`, `Repo: ${repoRoot}`, `Count: ${comments.length}`];
4662
4793
  const sections = comments.map(formatCommentSection);
4663
- return [head.join("\n"), ...sections].join("\n\n") + "\n";
4794
+ const tail = replyCommand && comments.length > 0 ? [replyFooter(replyCommand)] : [];
4795
+ return [head.join("\n"), ...sections, ...tail].join("\n\n") + "\n";
4664
4796
  }
4665
- function formatIssueExport({ repoRoot, issue, comments }) {
4666
- const targets = [...new Set(comments.map((c) => c.targetKey))];
4667
- const head = [`# Issue: ${issue.title}`, `Status: ${issue.status}`, `Target: ${targets.join(", ") || "-"}`, `Repo: ${repoRoot}`, `Count: ${comments.length}`];
4668
- const parts = [head.join("\n")];
4669
- if (issue.body.trim()) parts.push(issue.body.trim());
4670
- parts.push(...comments.map(formatCommentSection));
4671
- return parts.join("\n\n") + "\n";
4797
+ function formatTodoExport({ todo, comments, replyCommand }) {
4798
+ const body = todo.body.replace(/\r\n/g, "\n").trim();
4799
+ const parts = [todo.title, ...body ? [body] : [], ...comments.map(formatCommentSection)];
4800
+ if (replyCommand && comments.length > 0) parts.push(replyFooter(replyCommand));
4801
+ return parts.join("\n\n");
4802
+ }
4803
+
4804
+ // packages/server/src/feedback.ts
4805
+ import { realpath as realpath3 } from "fs/promises";
4806
+ import { randomUUID as randomUUID3 } from "crypto";
4807
+ async function real(p) {
4808
+ try {
4809
+ return await realpath3(p);
4810
+ } catch {
4811
+ return p;
4812
+ }
4813
+ }
4814
+ function worktreeOf(c, mainRoot) {
4815
+ return commentWorktree(c) ?? mainRoot;
4816
+ }
4817
+ async function commentsIn(state, worktreeRoot, mainRoot) {
4818
+ const want = await real(worktreeRoot);
4819
+ const resolved = /* @__PURE__ */ new Map();
4820
+ const out = [];
4821
+ for (const t of Object.values(state.targets)) {
4822
+ for (const c of t.comments) {
4823
+ const wt = worktreeOf(c, mainRoot);
4824
+ let p = resolved.get(wt);
4825
+ if (!p) {
4826
+ p = real(wt);
4827
+ resolved.set(wt, p);
4828
+ }
4829
+ if (await p === want) out.push(c);
4830
+ }
4831
+ }
4832
+ return out;
4833
+ }
4834
+ async function takeFeedback(store, opts) {
4835
+ if (opts.peek) return (await commentsIn(await store.load(), opts.worktreeRoot, opts.mainRoot)).filter(awaitsAgent);
4836
+ return store.update(async (s) => {
4837
+ const ids = new Set((await commentsIn(s, opts.worktreeRoot, opts.mainRoot)).filter(awaitsAgent).map((c) => c.id));
4838
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4839
+ const taken = [];
4840
+ for (const t of Object.values(s.targets)) {
4841
+ t.comments = t.comments.map((c) => {
4842
+ if (!ids.has(c.id)) return c;
4843
+ const next = { ...c, exportedAt: now, updatedAt: now, status: c.status === "active" ? "exported" : c.status };
4844
+ taken.push(next);
4845
+ return next;
4846
+ });
4847
+ }
4848
+ return taken;
4849
+ });
4850
+ }
4851
+ function locate(state, ref) {
4852
+ const needle = ref.trim().toLowerCase();
4853
+ if (!needle) throw badRequest("a comment id is required", "bad_comment_id");
4854
+ const hits = [];
4855
+ for (const t of Object.values(state.targets)) {
4856
+ t.comments.forEach((c, index) => {
4857
+ if (c.id === needle) hits.unshift({ list: t.comments, index });
4858
+ else if (c.id.startsWith(needle)) hits.push({ list: t.comments, index });
4859
+ });
4860
+ }
4861
+ const exact = hits[0] && hits[0].list[hits[0].index].id === needle;
4862
+ if (hits.length === 0) throw notFound(`no comment with id ${ref}; it may have been resolved or deleted`, "unknown_comment");
4863
+ if (!exact && hits.length > 1) throw badRequest(`id ${ref} matches ${hits.length} comments; give more of it`, "ambiguous_comment");
4864
+ return hits[0];
4865
+ }
4866
+ async function addReply(store, ref, author, body) {
4867
+ if (!body.trim()) throw badRequest("reply must not be empty", "empty_reply");
4868
+ return store.update((s) => {
4869
+ const { list, index } = locate(s, ref);
4870
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4871
+ const reply2 = { id: randomUUID3(), author, body: body.trim(), at: now };
4872
+ const prev = list[index];
4873
+ const next = { ...prev, replies: [...prev.replies ?? [], reply2], updatedAt: now };
4874
+ if (author === "reviewer") {
4875
+ delete next.exportedAt;
4876
+ if (next.status === "exported") next.status = "active";
4877
+ }
4878
+ list[index] = next;
4879
+ return next;
4880
+ });
4672
4881
  }
4673
4882
 
4674
4883
  // packages/server/src/nvim.ts
@@ -4738,6 +4947,11 @@ async function findSockets(dirs = socketDirs()) {
4738
4947
  for (const d of dirs) await visit(d, 0);
4739
4948
  return [...found.entries()].map(([socket, pid]) => ({ socket, pid }));
4740
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
+ }
4741
4955
  var NvimService = class {
4742
4956
  constructor(dirs) {
4743
4957
  this.dirs = dirs;
@@ -4792,7 +5006,8 @@ var NvimService = class {
4792
5006
 
4793
5007
  // packages/server/src/tmux.ts
4794
5008
  import { execFile as execFile3 } from "child_process";
4795
- import { realpath as realpath3 } from "fs/promises";
5009
+ import path7 from "path";
5010
+ import { realpath as realpath4 } from "fs/promises";
4796
5011
  var runTmux = (args) => new Promise((resolve, reject) => {
4797
5012
  execFile3("tmux", args, { encoding: "utf8", timeout: 5e3, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
4798
5013
  if (!error) return resolve(stdout);
@@ -4802,34 +5017,48 @@ var runTmux = (args) => new Promise((resolve, reject) => {
4802
5017
  );
4803
5018
  });
4804
5019
  });
5020
+ function sessionNameFor(worktreePath) {
5021
+ return path7.basename(worktreePath).replace(/[.:#]/g, "_") || "warden";
5022
+ }
4805
5023
  var TmuxService = class {
4806
5024
  constructor(run2 = runTmux) {
4807
5025
  this.run = run2;
4808
5026
  }
4809
5027
  run;
4810
- async sessions(mainRoot) {
4811
- const root = await realpath3(mainRoot);
4812
- 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
+ });
4813
5033
  const sessions = [];
4814
5034
  for (const line of output.trimEnd().split("\n")) {
4815
5035
  const [id, name, ...parts] = line.split(" ");
4816
- const path12 = parts.join(" ");
4817
- if (!id || !/^\$\d+$/.test(id) || !name || !path12) continue;
4818
- if (await realpath3(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 });
4819
5039
  }
4820
5040
  return sessions;
4821
5041
  }
4822
- async open(mainRoot, worktreePath, sessionId) {
4823
- const session = (await this.sessions(mainRoot)).find((s) => s.id === sessionId);
4824
- 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");
4825
- const directory = await realpath3(worktreePath);
4826
- const window = (await this.run(["new-window", "-d", "-P", "-F", "#{window_id}", "-t", `${session.id}:`, "-c", directory.replaceAll("#", "##")])).trim();
4827
- return { session: session.name, window };
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) {
5048
+ const directory = await realpath4(worktreePath);
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 };
4828
5057
  }
4829
5058
  };
4830
5059
 
4831
5060
  // packages/server/src/watcher.ts
4832
- import path7 from "path";
5061
+ import path8 from "path";
4833
5062
  import { stat as stat5 } from "fs/promises";
4834
5063
  var DEFAULT_POLL_INTERVAL_MS = 1500;
4835
5064
  function statusPaths(out) {
@@ -4926,7 +5155,7 @@ var RepoWatcher = class {
4926
5155
  const stats = await Promise.all(
4927
5156
  statusPaths(statusRes.stdout).map(async (rel) => {
4928
5157
  try {
4929
- const st = await stat5(path7.join(this.root, rel));
5158
+ const st = await stat5(path8.join(this.root, rel));
4930
5159
  return `${rel} ${st.mtimeMs} ${st.size}`;
4931
5160
  } catch {
4932
5161
  return `${rel} -`;
@@ -4938,7 +5167,7 @@ var RepoWatcher = class {
4938
5167
  };
4939
5168
 
4940
5169
  // packages/server/src/static.ts
4941
- import path8 from "path";
5170
+ import path9 from "path";
4942
5171
  import { readFile as readFile3, stat as stat6 } from "fs/promises";
4943
5172
  var MIME = {
4944
5173
  ".html": "text/html; charset=utf-8",
@@ -4956,20 +5185,20 @@ var MIME = {
4956
5185
  ".txt": "text/plain; charset=utf-8"
4957
5186
  };
4958
5187
  async function serveStaticFile(c, webDir) {
4959
- const root = path8.resolve(webDir);
5188
+ const root = path9.resolve(webDir);
4960
5189
  let rel;
4961
5190
  try {
4962
5191
  rel = decodeURIComponent(c.req.path);
4963
5192
  } catch {
4964
5193
  return c.text("Bad Request", 400);
4965
5194
  }
4966
- let target = path8.resolve(root, "." + rel);
4967
- 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);
4968
5197
  try {
4969
5198
  const st = await stat6(target);
4970
- if (st.isDirectory()) target = path8.join(target, "index.html");
5199
+ if (st.isDirectory()) target = path9.join(target, "index.html");
4971
5200
  } catch {
4972
- target = path8.join(root, "index.html");
5201
+ target = path9.join(root, "index.html");
4973
5202
  }
4974
5203
  let body;
4975
5204
  try {
@@ -4977,7 +5206,7 @@ async function serveStaticFile(c, webDir) {
4977
5206
  } catch {
4978
5207
  return c.text("Not Found", 404);
4979
5208
  }
4980
- const ext = path8.extname(target).toLowerCase();
5209
+ const ext = path9.extname(target).toLowerCase();
4981
5210
  const isAsset = rel.startsWith("/assets/");
4982
5211
  return new Response(new Uint8Array(body), {
4983
5212
  status: 200,
@@ -4990,6 +5219,7 @@ async function serveStaticFile(c, webDir) {
4990
5219
 
4991
5220
  // packages/server/src/app.ts
4992
5221
  var SSE_HEARTBEAT_MS = 15e3;
5222
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost"]);
4993
5223
  var LISTING_HINT_TTL_MS = 5 * 6e4;
4994
5224
  function createApp(opts) {
4995
5225
  const { repo, store } = opts;
@@ -5014,6 +5244,10 @@ function createApp(opts) {
5014
5244
  return list;
5015
5245
  };
5016
5246
  const checkpoints = checkpointStore(store.file);
5247
+ const stateStamp = () => stat7(store.file).then(
5248
+ (st) => st.mtimeMs,
5249
+ () => 0
5250
+ );
5017
5251
  const worktreeCtx = async (key) => {
5018
5252
  let list = await worktrees();
5019
5253
  try {
@@ -5041,6 +5275,14 @@ function createApp(opts) {
5041
5275
  untracked: explicit?.untracked ?? hint?.untracked
5042
5276
  });
5043
5277
  };
5278
+ const checkpointHandoff = (comments) => {
5279
+ const worktrees2 = new Set(comments.map((x) => tryParseTargetKey(x.targetKey)?.worktree));
5280
+ for (const wt of worktrees2) {
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}`);
5283
+ });
5284
+ }
5285
+ };
5044
5286
  const findComment = (state, id) => {
5045
5287
  for (const [targetKey, t] of Object.entries(state.targets)) {
5046
5288
  const index = t.comments.findIndex((c) => c.id === id);
@@ -5048,6 +5290,13 @@ function createApp(opts) {
5048
5290
  }
5049
5291
  return void 0;
5050
5292
  };
5293
+ const linkable = (state, raw2) => {
5294
+ if (!Array.isArray(raw2) || raw2.some((x) => typeof x !== "string")) throw badRequest("commentIds must be a list of ids");
5295
+ const ids = [...new Set(raw2)];
5296
+ const missing = ids.find((x) => !findComment(state, x));
5297
+ if (missing) throw notFound(`no comment with id ${missing}`, "unknown_comment");
5298
+ return ids;
5299
+ };
5051
5300
  app.onError((err, c) => {
5052
5301
  if (err instanceof HttpError) {
5053
5302
  return c.json({ error: err.message, code: err.code }, err.status);
@@ -5055,26 +5304,41 @@ function createApp(opts) {
5055
5304
  console.error(err);
5056
5305
  return c.json({ error: err instanceof Error ? err.message : String(err), code: "internal" }, 500);
5057
5306
  });
5307
+ app.use("*", async (c, next) => {
5308
+ const host = new URL(c.req.url).hostname;
5309
+ if (!LOOPBACK_HOSTS.has(host)) {
5310
+ throw new HttpError(403, `requests for host ${host} are refused; open warden at http://127.0.0.1 or http://localhost`, "bad_host");
5311
+ }
5312
+ await next();
5313
+ });
5058
5314
  const api = new Hono2();
5059
5315
  api.use("*", async (c, next) => {
5060
- if (c.req.method !== "GET" && c.req.method !== "HEAD" && c.req.header("sec-fetch-site") === "cross-site") {
5061
- throw new HttpError(403, "cross-site requests are refused", "cross_site");
5316
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") {
5317
+ const origin = c.req.header("origin");
5318
+ if (c.req.header("sec-fetch-site") === "cross-site" || origin && origin !== new URL(c.req.url).origin) {
5319
+ throw new HttpError(403, "cross-site requests are refused", "cross_site");
5320
+ }
5062
5321
  }
5063
5322
  await next();
5064
5323
  });
5065
5324
  api.get("/ping", (c) => c.text(opts.instanceToken ?? ""));
5066
5325
  api.get("/repo", async (c) => {
5067
5326
  const state = await store.load();
5068
- 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);
5069
5329
  worktreeCache = { at: Date.now(), list: info.worktrees };
5070
5330
  const last = state.prefs.lastTarget;
5071
5331
  const target = last ? tryParseTargetKey(last) : void 0;
5072
- 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));
5073
5335
  if (last && usable2) info.defaultTarget = last;
5074
5336
  else if (last) {
5075
- await store.update((s) => {
5076
- s.prefs.lastTarget = "working";
5077
- });
5337
+ if (repo.root === repo.commonRoot) {
5338
+ await store.update((s) => {
5339
+ s.prefs.lastTarget = "working";
5340
+ });
5341
+ }
5078
5342
  }
5079
5343
  return c.json(info);
5080
5344
  });
@@ -5175,15 +5439,9 @@ function createApp(opts) {
5175
5439
  });
5176
5440
  api.post("/targets/:key/checkpoints", async (c) => {
5177
5441
  const ctx = await worktreeCtx(c.req.param("key"));
5178
- const tree = await snapshotWorktree(ctx.cwd, checkpoints);
5179
- const head = await revParse(ctx.cwd, "HEAD") ?? "";
5180
- const res = await store.update((s) => {
5181
- const newest = checkpointsOf(s, ctx.target.worktree).at(-1);
5182
- const unchanged = newest?.tree === tree;
5183
- const checkpoint = unchanged ? newest : addCheckpoint(s, ctx.target.worktree, tree, head);
5184
- return { checkpoint, unchanged, targetKey: checkpointKey(checkpoint) };
5185
- });
5186
- return c.json(res, res.unchanged ? 200 : 201);
5442
+ const { checkpoint, unchanged } = await takeCheckpoint(store, ctx.cwd, ctx.target.worktree);
5443
+ const res = { checkpoint, unchanged, targetKey: checkpointKey(checkpoint) };
5444
+ return c.json(res, unchanged ? 200 : 201);
5187
5445
  });
5188
5446
  api.delete("/targets/:key/checkpoints/:id", async (c) => {
5189
5447
  const ctx = await worktreeCtx(c.req.param("key"));
@@ -5236,7 +5494,7 @@ function createApp(opts) {
5236
5494
  if (!anchored) throw badRequest("selection is not fully visible in the diff on that side (must be inside one hunk)", "bad_selection");
5237
5495
  const now = (/* @__PURE__ */ new Date()).toISOString();
5238
5496
  const comment = {
5239
- id: randomUUID2(),
5497
+ id: randomUUID4(),
5240
5498
  targetKey: key,
5241
5499
  filePath: body.filePath,
5242
5500
  side: body.side,
@@ -5307,12 +5565,19 @@ function createApp(opts) {
5307
5565
  if (!t) return false;
5308
5566
  const before = t.comments.length;
5309
5567
  t.comments = t.comments.filter((x) => x.id !== id);
5310
- for (const issue of s.issues) issue.commentIds = issue.commentIds.filter((x) => x !== id);
5568
+ unlinkComments(s, [id]);
5311
5569
  return t.comments.length !== before;
5312
5570
  });
5313
5571
  if (!removed) throw notFound("comment not found");
5314
5572
  return c.json({ ok: true });
5315
5573
  });
5574
+ api.post("/targets/:key/comments/:id/replies", async (c) => {
5575
+ const body = await c.req.json();
5576
+ if (typeof body.body !== "string") throw badRequest("body is required");
5577
+ const found = findComment(await store.load(), c.req.param("id"));
5578
+ if (!found || found.targetKey !== commentScopeKey(c.req.param("key"))) throw notFound("comment not found");
5579
+ return c.json(await addReply(store, found.comment.id, "reviewer", body.body), 201);
5580
+ });
5316
5581
  api.post("/targets/:key/comments/reanchor", async (c) => {
5317
5582
  const key = c.req.param("key");
5318
5583
  const ctx = await targetCtx(key);
@@ -5353,7 +5618,6 @@ function createApp(opts) {
5353
5618
  const comments = state.targets[scope]?.comments ?? [];
5354
5619
  const prevHead = state.targets[scope]?.head;
5355
5620
  const head = await revParse(ctx.cwd, "HEAD") ?? "";
5356
- const committed = local && prevHead !== void 0 && prevHead !== head;
5357
5621
  const now = (/* @__PURE__ */ new Date()).toISOString();
5358
5622
  const located = /* @__PURE__ */ new Map();
5359
5623
  await Promise.all(
@@ -5377,6 +5641,19 @@ function createApp(opts) {
5377
5641
  }
5378
5642
  })
5379
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
+ }
5380
5657
  const result = await store.update((s) => {
5381
5658
  const t = ensureTarget(s, scope);
5382
5659
  const dropped = [];
@@ -5384,7 +5661,7 @@ function createApp(opts) {
5384
5661
  const patch = located.get(cm.id);
5385
5662
  if (!patch) {
5386
5663
  if (!comments.some((x) => x.id === cm.id)) return [cm];
5387
- if (committed) {
5664
+ if (landed.has(cm.id) && !cm.replies?.length) {
5388
5665
  dropped.push(cm.id);
5389
5666
  return [];
5390
5667
  }
@@ -5394,9 +5671,7 @@ function createApp(opts) {
5394
5671
  const unchanged = cm.targetKey === next.targetKey && cm.status === next.status && cm.startLine === next.startLine && cm.endLine === next.endLine && cm.anchor.hunkHash === next.anchor.hunkHash;
5395
5672
  return [unchanged ? cm : { ...next, updatedAt: now }];
5396
5673
  });
5397
- for (const id of dropped) {
5398
- for (const issue of s.issues) issue.commentIds = issue.commentIds.filter((x) => x !== id);
5399
- }
5674
+ unlinkComments(s, dropped);
5400
5675
  t.head = head;
5401
5676
  return t.comments;
5402
5677
  });
@@ -5407,6 +5682,7 @@ function createApp(opts) {
5407
5682
  const body = await c.req.json();
5408
5683
  if (!Array.isArray(body.commentIds)) throw badRequest("commentIds required");
5409
5684
  const ids = body.commentIds.filter((x) => typeof x === "string");
5685
+ let handed = [];
5410
5686
  const res = await store.update((s) => {
5411
5687
  const now = (/* @__PURE__ */ new Date()).toISOString();
5412
5688
  const selected = [];
@@ -5418,94 +5694,14 @@ function createApp(opts) {
5418
5694
  s.targets[found.targetKey].comments[found.index] = next;
5419
5695
  selected.push(next);
5420
5696
  }
5697
+ handed = selected;
5421
5698
  return {
5422
- text: formatCommentsExport({ repoRoot: repo.root, comments: selected }),
5699
+ text: formatCommentsExport({ repoRoot: repo.root, comments: selected, replyCommand: opts.replyCommand }),
5423
5700
  count: selected.length,
5424
5701
  commentIds: selected.map((x) => x.id)
5425
5702
  };
5426
5703
  });
5427
- return c.json(res);
5428
- });
5429
- api.get("/issues", async (c) => {
5430
- const s = await store.load();
5431
- return c.json({ issues: s.issues });
5432
- });
5433
- api.post("/issues", async (c) => {
5434
- const body = await c.req.json();
5435
- if (!body.title?.trim()) throw badRequest("title is required");
5436
- const now = (/* @__PURE__ */ new Date()).toISOString();
5437
- const issue = {
5438
- id: randomUUID2(),
5439
- title: body.title.trim(),
5440
- body: typeof body.body === "string" ? body.body : "",
5441
- status: body.status === "closed" ? "closed" : "open",
5442
- commentIds: Array.isArray(body.commentIds) ? body.commentIds.filter((x) => typeof x === "string") : [],
5443
- createdAt: now,
5444
- updatedAt: now
5445
- };
5446
- await store.update((s) => {
5447
- s.issues = insertAfter(s.issues, issue, typeof body.after === "string" ? body.after : void 0);
5448
- });
5449
- return c.json(issue, 201);
5450
- });
5451
- api.post("/issues/:id/move", async (c) => {
5452
- const id = c.req.param("id");
5453
- const body = await c.req.json();
5454
- if (body.before !== null && typeof body.before !== "string") throw badRequest("before must be an id or null");
5455
- const issues = await store.update((s) => {
5456
- const next = moveBefore(s.issues, id, body.before);
5457
- if (!next) throw notFound("issue not found");
5458
- s.issues = next;
5459
- return s.issues;
5460
- });
5461
- return c.json({ issues });
5462
- });
5463
- api.patch("/issues/:id", async (c) => {
5464
- const id = c.req.param("id");
5465
- const body = await c.req.json();
5466
- const updated = await store.update((s) => {
5467
- const issue = s.issues.find((i) => i.id === id);
5468
- if (!issue) throw notFound("issue not found");
5469
- if (typeof body.title === "string" && body.title.trim()) issue.title = body.title.trim();
5470
- if (typeof body.body === "string") issue.body = body.body;
5471
- if (body.status === "open" || body.status === "closed") issue.status = body.status;
5472
- if (Array.isArray(body.commentIds)) issue.commentIds = [...new Set(body.commentIds.filter((x) => typeof x === "string"))];
5473
- issue.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5474
- return issue;
5475
- });
5476
- return c.json(updated);
5477
- });
5478
- api.delete("/issues/:id", async (c) => {
5479
- const id = c.req.param("id");
5480
- const removed = await store.update((s) => {
5481
- const before = s.issues.length;
5482
- s.issues = s.issues.filter((i) => i.id !== id);
5483
- return s.issues.length !== before;
5484
- });
5485
- if (!removed) throw notFound("issue not found");
5486
- return c.json({ ok: true });
5487
- });
5488
- api.post("/issues/:id/export", async (c) => {
5489
- const id = c.req.param("id");
5490
- const res = await store.update((s) => {
5491
- const issue = s.issues.find((i) => i.id === id);
5492
- if (!issue) throw notFound("issue not found");
5493
- const now = (/* @__PURE__ */ new Date()).toISOString();
5494
- const comments = [];
5495
- for (const cid of issue.commentIds) {
5496
- const found = findComment(s, cid);
5497
- if (!found) continue;
5498
- const next = { ...found.comment, exportedAt: now, updatedAt: now };
5499
- if (next.status === "active") next.status = "exported";
5500
- s.targets[found.targetKey].comments[found.index] = next;
5501
- comments.push(next);
5502
- }
5503
- return {
5504
- text: formatIssueExport({ repoRoot: repo.root, issue, comments }),
5505
- count: comments.length,
5506
- commentIds: comments.map((x) => x.id)
5507
- };
5508
- });
5704
+ checkpointHandoff(handed);
5509
5705
  return c.json(res);
5510
5706
  });
5511
5707
  const knownRoot = async (rootParam) => {
@@ -5529,7 +5725,7 @@ function createApp(opts) {
5529
5725
  const branch = body.branch?.trim() || await currentBranch(await knownRoot(body.root));
5530
5726
  const now = (/* @__PURE__ */ new Date()).toISOString();
5531
5727
  const todo = {
5532
- id: randomUUID2(),
5728
+ id: randomUUID4(),
5533
5729
  branch,
5534
5730
  title: body.title.trim(),
5535
5731
  body: typeof body.body === "string" ? body.body : "",
@@ -5538,6 +5734,10 @@ function createApp(opts) {
5538
5734
  updatedAt: now
5539
5735
  };
5540
5736
  await store.update((s) => {
5737
+ if (body.commentIds !== void 0) {
5738
+ const ids = linkable(s, body.commentIds);
5739
+ if (ids.length) todo.commentIds = ids;
5740
+ }
5541
5741
  s.todos = insertAfter(s.todos, todo, typeof body.after === "string" ? body.after : void 0);
5542
5742
  });
5543
5743
  return c.json(todo, 201);
@@ -5564,11 +5764,40 @@ function createApp(opts) {
5564
5764
  if (typeof body.title === "string" && body.title.trim()) todo.title = body.title.trim();
5565
5765
  if (typeof body.body === "string") todo.body = body.body;
5566
5766
  if (body.status === "open" || body.status === "done") todo.status = body.status;
5767
+ if (body.commentIds !== void 0) {
5768
+ const ids = linkable(s, body.commentIds);
5769
+ if (ids.length) todo.commentIds = ids;
5770
+ else delete todo.commentIds;
5771
+ }
5567
5772
  todo.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5568
5773
  return todo;
5569
5774
  });
5570
5775
  return c.json(updated);
5571
5776
  });
5777
+ api.post("/todos/:id/export", async (c) => {
5778
+ const id = c.req.param("id");
5779
+ const handed = [];
5780
+ const res = await store.update((s) => {
5781
+ const todo = s.todos.find((t) => t.id === id);
5782
+ if (!todo) throw notFound("todo not found");
5783
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5784
+ for (const cid of todo.commentIds ?? []) {
5785
+ const found = findComment(s, cid);
5786
+ if (!found) continue;
5787
+ const next = { ...found.comment, exportedAt: now, updatedAt: now };
5788
+ if (next.status === "active") next.status = "exported";
5789
+ s.targets[found.targetKey].comments[found.index] = next;
5790
+ handed.push(next);
5791
+ }
5792
+ return {
5793
+ text: formatTodoExport({ todo, comments: handed, replyCommand: opts.replyCommand }),
5794
+ count: handed.length,
5795
+ commentIds: handed.map((x) => x.id)
5796
+ };
5797
+ });
5798
+ checkpointHandoff(handed);
5799
+ return c.json(res);
5800
+ });
5572
5801
  api.delete("/todos/:id", async (c) => {
5573
5802
  const id = c.req.param("id");
5574
5803
  const removed = await store.update((s) => {
@@ -5599,13 +5828,14 @@ function createApp(opts) {
5599
5828
  if (ref && !isValidRef(ref)) throw badRequest("invalid ref");
5600
5829
  if (filePath && (filePath.startsWith("/") || filePath.split("/").includes(".."))) throw badRequest("invalid path");
5601
5830
  if (q.length > 200 || author.length > 200) throw badRequest("search text too long");
5602
- 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}`];
5603
5832
  if (firstParent) args.push("--first-parent");
5604
5833
  if (q || author) args.push("--fixed-strings", "--regexp-ignore-case");
5605
5834
  if (q) args.push(`--grep=${q}`);
5606
5835
  if (author) args.push(`--author=${author}`);
5607
5836
  if (ref) args.push(ref);
5608
- if (filePath) args.push("--", filePath);
5837
+ args.push("--");
5838
+ if (filePath) args.push(literal(filePath));
5609
5839
  let stdout = "";
5610
5840
  try {
5611
5841
  stdout = (await runGit(args, { cwd })).stdout;
@@ -5619,7 +5849,7 @@ function createApp(opts) {
5619
5849
  if (q && offset === 0 && /^[0-9a-f]{4,40}$/i.test(q)) {
5620
5850
  const sha = await revParse(cwd, `${q}^{commit}`);
5621
5851
  if (sha && !commits.some((x) => x.sha === sha)) {
5622
- 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];
5623
5853
  if (hit) commits = [hit, ...commits];
5624
5854
  }
5625
5855
  }
@@ -5638,17 +5868,40 @@ function createApp(opts) {
5638
5868
  return c.json(res);
5639
5869
  });
5640
5870
  const tmux = opts.tmux ?? new TmuxService();
5641
- api.get("/tmux/sessions", async (c) => c.json({ sessions: await tmux.sessions(repo.commonRoot) }));
5642
- api.post("/tmux/windows", async (c) => {
5871
+ api.post("/tmux/sessions", async (c) => {
5643
5872
  const body = await c.req.json();
5644
- 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");
5645
5874
  const wt = (await worktrees(true)).find((w) => w.path === body.path && !w.bare);
5646
5875
  if (!wt) throw badRequest("unknown worktree", "unknown_worktree");
5647
- 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);
5648
5878
  });
5649
5879
  api.get("/worktrees", async (c) => {
5650
- const list = await listWorktreesDetailed(repo);
5880
+ const [list, state] = await Promise.all([listWorktreesDetailed(repo), store.load()]);
5651
5881
  const [branches, newSlot] = await Promise.all([listBranches(repo, list), nextSlot(repo, list)]);
5882
+ const review = /* @__PURE__ */ new Map();
5883
+ const latest = /* @__PURE__ */ new Map();
5884
+ for (const t of Object.values(state.targets)) {
5885
+ for (const cm of t.comments) {
5886
+ const at = commentWorktree(cm) ?? repo.commonRoot;
5887
+ const r = review.get(at) ?? { toAgent: 0, toReviewer: 0 };
5888
+ const seen = latest.get(at) ?? { agent: "", reviewer: "" };
5889
+ if (awaitsAgent(cm)) {
5890
+ r.toAgent++;
5891
+ if (cm.updatedAt >= seen.agent) [seen.agent, r.toAgentTarget] = [cm.updatedAt, cm.targetKey];
5892
+ }
5893
+ if (awaitsReviewer(cm)) {
5894
+ r.toReviewer++;
5895
+ if (cm.updatedAt >= seen.reviewer) [seen.reviewer, r.toReviewerTarget] = [cm.updatedAt, cm.targetKey];
5896
+ }
5897
+ review.set(at, r);
5898
+ latest.set(at, seen);
5899
+ }
5900
+ }
5901
+ for (const wt of list) {
5902
+ const r = review.get(wt.path);
5903
+ if (r && (r.toAgent || r.toReviewer)) wt.review = r;
5904
+ }
5652
5905
  const res = { worktrees: list, branches, newSlot };
5653
5906
  return c.json(res);
5654
5907
  });
@@ -5656,12 +5909,18 @@ function createApp(opts) {
5656
5909
  const res = { remotes: await lookupRemoteBranches(repo, c.req.query("branch") ?? "") };
5657
5910
  return c.json(res);
5658
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
+ };
5659
5918
  api.post("/worktrees", async (c) => {
5660
5919
  const body = await c.req.json();
5661
5920
  if (typeof body.branch !== "string") throw badRequest("branch is required");
5662
5921
  if (body.base !== void 0 && typeof body.base !== "string") throw badRequest("base must be a ref");
5663
5922
  if (body.slot !== void 0 && (!Number.isInteger(body.slot) || body.slot < 1)) throw badRequest("slot must be a positive integer", "invalid_slot");
5664
- const res = await checkoutWorktree(repo, body);
5923
+ const res = await oneAtATime(() => checkoutWorktree(repo, body));
5665
5924
  worktreeCache = null;
5666
5925
  await store.update((s) => forgetWorktreeTargets(s, res.worktree.path));
5667
5926
  return c.json(res, 201);
@@ -5669,14 +5928,14 @@ function createApp(opts) {
5669
5928
  api.post("/worktrees/release", async (c) => {
5670
5929
  const body = await c.req.json();
5671
5930
  if (typeof body.path !== "string") throw badRequest("path is required");
5672
- const res = await releaseWorktree(repo, body);
5931
+ const res = await oneAtATime(() => releaseWorktree(repo, body));
5673
5932
  worktreeCache = null;
5674
5933
  return c.json(res);
5675
5934
  });
5676
5935
  api.post("/worktrees/remove", async (c) => {
5677
5936
  const body = await c.req.json();
5678
5937
  if (typeof body.path !== "string") throw badRequest("path is required");
5679
- const res = await removeWorktree(repo, body);
5938
+ const res = await oneAtATime(() => removeWorktree(repo, body));
5680
5939
  worktreeCache = null;
5681
5940
  return c.json(res);
5682
5941
  });
@@ -5695,7 +5954,19 @@ function createApp(opts) {
5695
5954
  const event = { type: "changed", ...change };
5696
5955
  void enqueue(() => stream2.writeSSE({ event: "changed", data: JSON.stringify(event) }));
5697
5956
  });
5698
- stream2.onAbort(release);
5957
+ let stamp = await stateStamp();
5958
+ const statePoll = setInterval(() => {
5959
+ void stateStamp().then((next) => {
5960
+ if (next === stamp) return;
5961
+ stamp = next;
5962
+ const event = { type: "state", at: (/* @__PURE__ */ new Date()).toISOString() };
5963
+ void enqueue(() => stream2.writeSSE({ event: "state", data: JSON.stringify(event) }));
5964
+ });
5965
+ }, opts.watchIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
5966
+ stream2.onAbort(() => {
5967
+ clearInterval(statePoll);
5968
+ release();
5969
+ });
5699
5970
  try {
5700
5971
  while (!stream2.aborted && !stream2.closed) {
5701
5972
  await stream2.sleep(SSE_HEARTBEAT_MS);
@@ -5703,6 +5974,7 @@ function createApp(opts) {
5703
5974
  await enqueue(() => stream2.write(": ping\n\n"));
5704
5975
  }
5705
5976
  } finally {
5977
+ clearInterval(statePoll);
5706
5978
  release();
5707
5979
  }
5708
5980
  });
@@ -5714,9 +5986,8 @@ function createApp(opts) {
5714
5986
  const instances = NvimService.matching(scan.instances, root);
5715
5987
  const state = await store.load();
5716
5988
  const preferred = state.prefs.nvimSocketByRoot[root];
5717
- let selected;
5718
- if (preferred && instances.some((i) => i.socket === preferred)) selected = preferred;
5719
- 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;
5720
5991
  const res = { root, nvimAvailable: scan.nvimAvailable, instances, selected, scannedAt: scan.scannedAt };
5721
5992
  return c.json(res);
5722
5993
  });
@@ -5730,19 +6001,38 @@ function createApp(opts) {
5730
6001
  });
5731
6002
  api.post("/nvim/open", async (c) => {
5732
6003
  const body = await c.req.json();
5733
- if (!body.socket || !body.absPath) throw badRequest("socket and absPath are required");
5734
- if (!path9.isAbsolute(body.absPath)) throw badRequest("absPath must be absolute");
5735
- const scan = await nvim.scan();
5736
- if (!scan.instances.some((i) => i.socket === body.socket)) {
5737
- const fresh = await nvim.scan(true);
5738
- 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");
5739
6019
  }
5740
6020
  try {
5741
- 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);
5742
6024
  } catch (e) {
5743
- 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);
5744
6035
  }
5745
- return c.json({ ok: true });
5746
6036
  });
5747
6037
  app.route("/api", api);
5748
6038
  app.notFound((c) => {
@@ -5791,14 +6081,14 @@ function reachableFromWindows(port, token, timeoutMs = PROBE_TIMEOUT_MS) {
5791
6081
  }
5792
6082
 
5793
6083
  // packages/server/src/update.ts
5794
- import path10 from "path";
5795
- 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";
5796
6086
  var PACKAGE = "@xbghc/warden";
5797
6087
  var LATEST_URL = "https://registry.npmjs.org/@xbghc%2fwarden/latest";
5798
6088
  var FETCH_TIMEOUT_MS = 3e3;
5799
6089
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
5800
6090
  function updateCacheFile(baseDir = dataDir()) {
5801
- return path10.join(baseDir, "update-check.json");
6091
+ return path11.join(baseDir, "update-check.json");
5802
6092
  }
5803
6093
  function updateCheckEnabled(env = process.env) {
5804
6094
  return !env.WARDEN_NO_UPDATE_CHECK && !env.NO_UPDATE_NOTIFIER && !env.CI;
@@ -5832,8 +6122,8 @@ async function readCache(file, now) {
5832
6122
  }
5833
6123
  async function writeCache(file, cache) {
5834
6124
  try {
5835
- await mkdir3(path10.dirname(file), { recursive: true });
5836
- await writeFile2(file, JSON.stringify(cache));
6125
+ await mkdir3(path11.dirname(file), { recursive: true });
6126
+ await writeFile(file, JSON.stringify(cache));
5837
6127
  } catch {
5838
6128
  }
5839
6129
  }
@@ -5877,8 +6167,8 @@ async function startServer(opts) {
5877
6167
  const repo = await resolveRepo(opts.repoPath);
5878
6168
  const stateFile = opts.stateFile ?? stateFilePath(repo.commonRoot);
5879
6169
  const store = new StateStore(stateFile, repo.commonRoot);
5880
- const instanceToken = randomUUID3();
5881
- const app = createApp({ repo, store, nvim: new NvimService(), webDir: opts.webDir, instanceToken, update: opts.update });
6170
+ const instanceToken = randomUUID5();
6171
+ const app = createApp({ repo, store, nvim: new NvimService(), webDir: opts.webDir, instanceToken, update: opts.update, replyCommand: opts.replyCommand });
5882
6172
  const listen = (p) => new Promise((resolve, reject) => {
5883
6173
  const s = serve({ fetch: app.fetch, hostname: HOST, port: p }, () => resolve(s));
5884
6174
  s.once("error", reject);
@@ -5920,8 +6210,68 @@ async function startServer(opts) {
5920
6210
  };
5921
6211
  }
5922
6212
 
6213
+ // bin/agent.ts
6214
+ var AGENT_COMMANDS = ["feedback", "reply"];
6215
+ function agentUsage(cmd) {
6216
+ return `Commands for the agent, run inside the worktree under review:
6217
+ ${cmd} feedback [--peek] Print the review comments not yet handed over, and mark them handed
6218
+ over (--peek leaves them as they are)
6219
+ ${cmd} reply <id> <message> Answer a comment; <id> is the one in its heading. A message of "-",
6220
+ or none with stdin piped, is read from stdin
6221
+ `;
6222
+ }
6223
+ async function readStdin() {
6224
+ const chunks = [];
6225
+ for await (const chunk of process.stdin) chunks.push(chunk);
6226
+ return Buffer.concat(chunks).toString("utf8");
6227
+ }
6228
+ async function openStore() {
6229
+ const repo = await resolveRepo(process.cwd());
6230
+ return { store: new StateStore(stateFilePath(repo.commonRoot), repo.commonRoot), root: repo.root, commonRoot: repo.commonRoot };
6231
+ }
6232
+ async function feedback(args, replyCommand) {
6233
+ const unknown = args.find((a) => a !== "--peek");
6234
+ if (unknown) throw new Error(`unknown argument: ${unknown}`);
6235
+ const { store, root, commonRoot } = await openStore();
6236
+ const peek = args.includes("--peek");
6237
+ const comments = await takeFeedback(store, { worktreeRoot: root, mainRoot: commonRoot, peek });
6238
+ if (comments.length === 0) {
6239
+ console.log("No review comments are waiting for you.");
6240
+ return;
6241
+ }
6242
+ if (!peek) {
6243
+ await takeCheckpoint(store, root, root === commonRoot ? void 0 : root, { handoff: true }).catch((e) => {
6244
+ console.error(`warden feedback: no checkpoint taken: ${e instanceof Error ? e.message : e}`);
6245
+ });
6246
+ }
6247
+ process.stdout.write(formatCommentsExport({ repoRoot: root, comments, replyCommand }));
6248
+ }
6249
+ async function reply(args) {
6250
+ const [id, ...words] = args;
6251
+ if (!id) throw new Error("reply needs a comment id");
6252
+ let body = words.join(" ");
6253
+ if (body === "-" || !body && !process.stdin.isTTY) body = await readStdin();
6254
+ if (!body.trim()) throw new Error("reply needs a message");
6255
+ const { store } = await openStore();
6256
+ const c = await addReply(store, id, "agent", body);
6257
+ const range = c.startLine === c.endLine ? `${c.startLine}` : `${c.startLine}-${c.endLine}`;
6258
+ console.log(`Replied to ${shortId(c.id)} (${c.filePath}:${range}).`);
6259
+ }
6260
+ async function runAgentCommand(argv, replyCommand) {
6261
+ const [cmd, ...rest] = argv;
6262
+ if (!cmd || !AGENT_COMMANDS.includes(cmd)) return void 0;
6263
+ try {
6264
+ if (cmd === "feedback") await feedback(rest, replyCommand);
6265
+ else await reply(rest);
6266
+ return 0;
6267
+ } catch (e) {
6268
+ console.error(`warden ${cmd}: ${e instanceof HttpError || e instanceof Error ? e.message : String(e)}`);
6269
+ return 1;
6270
+ }
6271
+ }
6272
+
5923
6273
  // bin/cli.ts
5924
- var VERSION = true ? "0.15.0" : "dev";
6274
+ var VERSION = true ? "0.16.1" : "dev";
5925
6275
  function parseArgs(argv) {
5926
6276
  const args = { repoPath: process.cwd(), open: true, updateCheck: true, help: false, version: false };
5927
6277
  for (let i = 0; i < argv.length; i++) {
@@ -5940,14 +6290,17 @@ function parseArgs(argv) {
5940
6290
  } else if (a === "--help" || a === "-h") args.help = true;
5941
6291
  else if (a === "--version" || a === "-v") args.version = true;
5942
6292
  else if (a.startsWith("-")) throw new Error(`unknown option: ${a}`);
5943
- else args.repoPath = path11.resolve(a);
6293
+ else args.repoPath = path12.resolve(a);
5944
6294
  }
5945
6295
  return args;
5946
6296
  }
6297
+ var viaNpx = fileURLToPath(import.meta.url).includes(`${path12.sep}_npx${path12.sep}`);
6298
+ var selfCommand = viaNpx ? "npx @xbghc/warden" : "warden";
5947
6299
  function usage() {
5948
6300
  return `warden \u2014 local git diff review UI
5949
6301
 
5950
6302
  Usage: warden [repoPath] [options]
6303
+ warden <command> [args]
5951
6304
 
5952
6305
  Options:
5953
6306
  --port <n>, -p <n> Listen on a fixed port (default: first free port from 4100, and under WSL
@@ -5957,6 +6310,9 @@ Options:
5957
6310
  (also: WARDEN_NO_UPDATE_CHECK=1, NO_UPDATE_NOTIFIER=1, CI)
5958
6311
  -h, --help Show this help
5959
6312
  -v, --version Print version
6313
+
6314
+ ${agentUsage("warden")}
6315
+ A repository in a directory named after a command is given as ./<name>.
5960
6316
  `;
5961
6317
  }
5962
6318
  async function openBrowser(url) {
@@ -5985,20 +6341,22 @@ async function openBrowser(url) {
5985
6341
  return false;
5986
6342
  }
5987
6343
  function resolveWebDir() {
5988
- const here = path11.dirname(fileURLToPath(import.meta.url));
6344
+ const here = path12.dirname(fileURLToPath(import.meta.url));
5989
6345
  const candidates = [
5990
6346
  process.env.WARDEN_WEB_DIR,
5991
- path11.join(here, "web"),
6347
+ path12.join(here, "web"),
5992
6348
  // dist/cli.js -> dist/web
5993
- path11.join(here, "..", "dist", "web")
6349
+ path12.join(here, "..", "dist", "web")
5994
6350
  // bin/cli.ts (tsx dev) -> dist/web
5995
6351
  ].filter((p) => !!p);
5996
6352
  for (const dir of candidates) {
5997
- if (existsSync2(path11.join(dir, "index.html"))) return dir;
6353
+ if (existsSync2(path12.join(dir, "index.html"))) return dir;
5998
6354
  }
5999
6355
  return void 0;
6000
6356
  }
6001
6357
  async function main() {
6358
+ const code = await runAgentCommand(process.argv.slice(2), selfCommand);
6359
+ if (code !== void 0) process.exit(code);
6002
6360
  let args;
6003
6361
  try {
6004
6362
  args = parseArgs(process.argv.slice(2));
@@ -6019,12 +6377,11 @@ async function main() {
6019
6377
  if (!webDir) {
6020
6378
  console.error("warning: built web assets not found (dist/web). Only the /api endpoints will be served.");
6021
6379
  }
6022
- const viaNpx = fileURLToPath(import.meta.url).includes(`${path11.sep}_npx${path11.sep}`);
6023
6380
  const checking = args.updateCheck && VERSION !== "dev" && updateCheckEnabled();
6024
6381
  const update = checking ? checkForUpdate({ current: VERSION, npx: viaNpx }) : Promise.resolve(null);
6025
6382
  let server;
6026
6383
  try {
6027
- server = await startServer({ repoPath: args.repoPath, port: args.port, webDir, update });
6384
+ server = await startServer({ repoPath: args.repoPath, port: args.port, webDir, update, replyCommand: selfCommand });
6028
6385
  } catch (e) {
6029
6386
  console.error(`warden: ${e.message}`);
6030
6387
  process.exit(1);