@xbghc/warden 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8,7 +8,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 randomUUID4 } 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";
@@ -653,7 +653,8 @@ var serve = (options, listeningListener) => {
653
653
 
654
654
  // packages/server/src/app.ts
655
655
  import path9 from "path";
656
- import { randomUUID as randomUUID2 } from "crypto";
656
+ import { stat as stat7 } from "fs/promises";
657
+ import { randomUUID as randomUUID3 } 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) => {
@@ -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,35 +3411,245 @@ 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
3406
- import path2 from "path";
3418
+ import path3 from "path";
3407
3419
  import { randomUUID } from "crypto";
3408
- import { copyFile, mkdir, rm, stat, utimes } from "fs/promises";
3420
+ import { copyFile, mkdir as mkdir2, rm, stat as stat2, utimes } from "fs/promises";
3421
+
3422
+ // packages/server/src/state.ts
3423
+ import os from "os";
3424
+ import path2 from "path";
3425
+ import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "fs/promises";
3426
+
3427
+ // packages/server/src/hash.ts
3428
+ import { createHash } from "crypto";
3429
+ function sha1(input) {
3430
+ return createHash("sha1").update(input).digest("hex");
3431
+ }
3432
+ function lineHash(content) {
3433
+ return sha1(content.trimEnd());
3434
+ }
3435
+ function hunkHash(lineContents) {
3436
+ return sha1(
3437
+ lineContents.map((l) => l.trimEnd()).join("\n").trim()
3438
+ );
3439
+ }
3440
+
3441
+ // packages/server/src/state.ts
3442
+ function dataDir() {
3443
+ const xdg = process.env.XDG_DATA_HOME;
3444
+ const base = xdg?.trim() ? xdg : path2.join(os.homedir(), ".local", "share");
3445
+ return path2.join(base, "warden");
3446
+ }
3447
+ function repoHash(repoRoot) {
3448
+ return sha1(repoRoot).slice(0, 12);
3449
+ }
3450
+ function stateFilePath(repoRoot, baseDir = dataDir()) {
3451
+ return path2.join(baseDir, repoHash(repoRoot), "state.json");
3452
+ }
3453
+ function defaultPrefs() {
3454
+ return { viewMode: "unified", nvimSocketByRoot: {}, autoRefresh: true, railOpen: false, ignoreDebug: true };
3455
+ }
3456
+ function defaultState(repoRoot) {
3457
+ return { schemaVersion: 1, repoRoot, targets: {}, todos: [], checkpoints: [], prefs: defaultPrefs() };
3458
+ }
3459
+ function ensureTarget(state, key) {
3460
+ let t = state.targets[key];
3461
+ if (!t) {
3462
+ t = { viewed: {}, comments: [] };
3463
+ state.targets[key] = t;
3464
+ }
3465
+ return t;
3466
+ }
3467
+ var usable = (v) => !!v && typeof v === "object" && typeof v.id === "string";
3468
+ var usableCheckpoint = (v) => {
3469
+ const c = v;
3470
+ return !!c && typeof c === "object" && Number.isInteger(c.id) && typeof c.tree === "string" && (c.worktree === void 0 || typeof c.worktree === "string");
3471
+ };
3472
+ var usableComment = (v) => usable(v) && typeof v.anchor === "object" && !!v.anchor;
3473
+ function normalise(raw2, repoRoot) {
3474
+ const base = defaultState(repoRoot);
3475
+ if (!raw2 || typeof raw2 !== "object") return base;
3476
+ const r = raw2;
3477
+ if (r.schemaVersion !== 1) return base;
3478
+ const targets = {};
3479
+ for (const [k, v] of Object.entries(r.targets ?? {})) {
3480
+ if (!v || typeof v !== "object") continue;
3481
+ targets[k] = {
3482
+ viewed: typeof v.viewed === "object" && v.viewed ? v.viewed : {},
3483
+ comments: Array.isArray(v.comments) ? v.comments.filter(usableComment) : [],
3484
+ ...typeof v.head === "string" ? { head: v.head } : {}
3485
+ };
3486
+ }
3487
+ migrateLocalViews(targets);
3488
+ return {
3489
+ schemaVersion: 1,
3490
+ repoRoot: r.repoRoot ?? repoRoot,
3491
+ targets,
3492
+ // Issues were folded into todos (a todo links comments now); the few there were are let go,
3493
+ // and the next write leaves them out of the file.
3494
+ todos: Array.isArray(r.todos) ? r.todos.filter(usable) : [],
3495
+ checkpoints: Array.isArray(r.checkpoints) ? r.checkpoints.filter(usableCheckpoint) : [],
3496
+ prefs: {
3497
+ ...defaultPrefs(),
3498
+ ...r.prefs ?? {},
3499
+ nvimSocketByRoot: r.prefs?.nvimSocketByRoot ?? {},
3500
+ autoRefresh: typeof r.prefs?.autoRefresh === "boolean" ? r.prefs.autoRefresh : true,
3501
+ railOpen: typeof r.prefs?.railOpen === "boolean" ? r.prefs.railOpen : false,
3502
+ ignoreDebug: typeof r.prefs?.ignoreDebug === "boolean" ? r.prefs.ignoreDebug : true
3503
+ }
3504
+ };
3505
+ }
3506
+ function migrateLocalViews(targets) {
3507
+ for (const key of Object.keys(targets).sort()) {
3508
+ const scope = commentScopeKey(key);
3509
+ if (scope === key) continue;
3510
+ const src = targets[key];
3511
+ delete targets[key];
3512
+ if (src.comments.length === 0) continue;
3513
+ let dst = targets[scope];
3514
+ if (!dst) {
3515
+ dst = { viewed: {}, comments: [] };
3516
+ targets[scope] = dst;
3517
+ }
3518
+ dst.comments.push(...src.comments);
3519
+ }
3520
+ }
3521
+ var LOCK_STALE_MS = 5e3;
3522
+ var LOCK_WAIT_MS = 5e3;
3523
+ async function sleep(ms) {
3524
+ await new Promise((r) => setTimeout(r, ms));
3525
+ }
3526
+ var StateStore = class {
3527
+ constructor(file, repoRoot) {
3528
+ this.file = file;
3529
+ this.repoRoot = repoRoot;
3530
+ }
3531
+ file;
3532
+ repoRoot;
3533
+ queue = Promise.resolve();
3534
+ async load() {
3535
+ try {
3536
+ const text = await readFile(this.file, "utf8");
3537
+ return normalise(JSON.parse(text), this.repoRoot);
3538
+ } catch (e) {
3539
+ const err = e;
3540
+ if (err.code === "ENOENT") return defaultState(this.repoRoot);
3541
+ if (e instanceof SyntaxError) {
3542
+ try {
3543
+ await rename(this.file, `${this.file}.corrupt-${Date.now()}`);
3544
+ } catch {
3545
+ }
3546
+ return defaultState(this.repoRoot);
3547
+ }
3548
+ throw e;
3549
+ }
3550
+ }
3551
+ /** Apply a mutation under lock. The callback receives the freshest on-disk state. */
3552
+ update(fn) {
3553
+ const run2 = async () => {
3554
+ await mkdir(path2.dirname(this.file), { recursive: true });
3555
+ const release = await this.acquireLock();
3556
+ try {
3557
+ const state = await this.load();
3558
+ const result = await fn(state);
3559
+ await this.writeAtomic(state);
3560
+ return result;
3561
+ } finally {
3562
+ await release();
3563
+ }
3564
+ };
3565
+ const p = this.queue.then(run2, run2);
3566
+ this.queue = p.catch(() => void 0);
3567
+ return p;
3568
+ }
3569
+ async writeAtomic(state) {
3570
+ const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`;
3571
+ await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
3572
+ await rename(tmp, this.file);
3573
+ }
3574
+ async acquireLock() {
3575
+ const lock = `${this.file}.lock`;
3576
+ const started = Date.now();
3577
+ for (; ; ) {
3578
+ try {
3579
+ const fh = await open(lock, "wx");
3580
+ await fh.writeFile(String(process.pid));
3581
+ await fh.close();
3582
+ return async () => {
3583
+ try {
3584
+ await unlink(lock);
3585
+ } catch {
3586
+ }
3587
+ };
3588
+ } catch (e) {
3589
+ const err = e;
3590
+ if (err.code !== "EEXIST") throw e;
3591
+ try {
3592
+ const st = await stat(lock);
3593
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
3594
+ await unlink(lock).catch(() => void 0);
3595
+ continue;
3596
+ }
3597
+ } catch {
3598
+ continue;
3599
+ }
3600
+ if (Date.now() - started > LOCK_WAIT_MS) {
3601
+ throw new Error(`timed out waiting for state lock ${lock}`);
3602
+ }
3603
+ await sleep(20);
3604
+ }
3605
+ }
3606
+ }
3607
+ };
3608
+ function forgetWorktreeTargets(state, worktreePath) {
3609
+ const prefix = `worktree:${worktreePath}:`;
3610
+ const gone = /* @__PURE__ */ new Set();
3611
+ for (const key of Object.keys(state.targets)) {
3612
+ if (!key.startsWith(prefix)) continue;
3613
+ for (const c of state.targets[key].comments) gone.add(c.id);
3614
+ delete state.targets[key];
3615
+ }
3616
+ unlinkComments(state, gone);
3617
+ state.checkpoints = state.checkpoints.filter((c) => c.worktree !== worktreePath);
3618
+ }
3619
+ function unlinkComments(state, ids) {
3620
+ const gone = new Set(ids);
3621
+ if (gone.size === 0) return;
3622
+ for (const todo of state.todos) {
3623
+ if (!todo.commentIds?.some((id) => gone.has(id))) continue;
3624
+ const kept = todo.commentIds.filter((id) => !gone.has(id));
3625
+ if (kept.length) todo.commentIds = kept;
3626
+ else delete todo.commentIds;
3627
+ }
3628
+ }
3629
+
3630
+ // packages/server/src/checkpoints.ts
3409
3631
  function checkpointStore(stateFile) {
3410
- return path2.join(path2.dirname(stateFile), "checkpoints");
3632
+ return path3.join(path3.dirname(stateFile), "checkpoints");
3411
3633
  }
3412
3634
  async function repoObjects(cwd) {
3413
3635
  const r = await runGit(["rev-parse", "--git-path", "objects"], { cwd });
3414
- return path2.resolve(cwd, r.stdout.trim());
3636
+ return path3.resolve(cwd, r.stdout.trim());
3415
3637
  }
3416
3638
  async function repoIndex(cwd) {
3417
3639
  const r = await runGit(["rev-parse", "--git-path", "index"], { cwd });
3418
- return path2.resolve(cwd, r.stdout.trim());
3640
+ return path3.resolve(cwd, r.stdout.trim());
3419
3641
  }
3420
3642
  async function checkpointObjects(cwd, store) {
3421
- return { objects: path2.join(store, "objects"), alternates: await repoObjects(cwd) };
3643
+ return { objects: path3.join(store, "objects"), alternates: await repoObjects(cwd) };
3422
3644
  }
3423
3645
  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 });
3646
+ const tmp = path3.join(store, "tmp");
3647
+ await mkdir2(path3.join(store, "objects"), { recursive: true });
3648
+ await mkdir2(tmp, { recursive: true });
3427
3649
  const [objects, index] = await Promise.all([checkpointObjects(cwd, store), repoIndex(cwd)]);
3428
- const copy = path2.join(tmp, `index-${randomUUID()}`);
3650
+ const copy = path3.join(tmp, `index-${randomUUID()}`);
3429
3651
  try {
3430
- const st = await stat(index).catch(() => void 0);
3652
+ const st = await stat2(index).catch(() => void 0);
3431
3653
  if (st) {
3432
3654
  await copyFile(index, copy);
3433
3655
  const earlier = Math.floor(st.mtimeMs / 1e3) - 1;
@@ -3468,29 +3690,24 @@ function forgetCheckpoint(state, c) {
3468
3690
  const key = checkpointKey(c);
3469
3691
  const gone = new Set((state.targets[key]?.comments ?? []).map((x) => x.id));
3470
3692
  delete state.targets[key];
3471
- if (gone.size) for (const issue of state.issues) issue.commentIds = issue.commentIds.filter((id) => !gone.has(id));
3693
+ unlinkComments(state, gone);
3472
3694
  }
3473
- function addCheckpoint(state, worktree, tree, head) {
3695
+ function addCheckpoint(state, worktree, tree, head, handoff = false) {
3474
3696
  const mine = checkpointsOf(state, worktree);
3475
3697
  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() };
3698
+ const checkpoint = { id, ...worktree ? { worktree } : {}, tree, head, createdAt: (/* @__PURE__ */ new Date()).toISOString(), ...handoff ? { handoff } : {} };
3477
3699
  state.checkpoints.push(checkpoint);
3478
3700
  for (const old of mine.slice(0, Math.max(0, mine.length + 1 - MAX_CHECKPOINTS))) forgetCheckpoint(state, old);
3479
3701
  return checkpoint;
3480
3702
  }
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
- );
3703
+ async function takeCheckpoint(store, cwd, worktree, opts = {}) {
3704
+ const tree = await snapshotWorktree(cwd, checkpointStore(store.file));
3705
+ const head = await revParse(cwd, "HEAD") ?? "";
3706
+ return store.update((s) => {
3707
+ const newest = checkpointsOf(s, worktree).at(-1);
3708
+ if (newest?.tree === tree) return { checkpoint: newest, unchanged: true };
3709
+ return { checkpoint: addCheckpoint(s, worktree, tree, head, opts.handoff), unchanged: false };
3710
+ });
3494
3711
  }
3495
3712
 
3496
3713
  // packages/server/src/diffparse.ts
@@ -3892,7 +4109,7 @@ async function getFullFile(ctx, filePath, side) {
3892
4109
  const ref = await refForSide(ctx, side);
3893
4110
  if (ref === void 0) {
3894
4111
  try {
3895
- const buf = await readFile(path3.join(ctx.cwd, filePath));
4112
+ const buf = await readFile2(path4.join(ctx.cwd, filePath));
3896
4113
  return buf.toString("utf8");
3897
4114
  } catch {
3898
4115
  return null;
@@ -4192,15 +4409,15 @@ function buildStagePatch(diff, mode, selection, opts = {}) {
4192
4409
  }
4193
4410
 
4194
4411
  // packages/server/src/worktrees.ts
4195
- import path4 from "path";
4196
- import { readdir, realpath as realpath2, stat as stat2 } from "fs/promises";
4412
+ import path5 from "path";
4413
+ import { readdir, realpath as realpath2, stat as stat3 } from "fs/promises";
4197
4414
  function slotPath(ctx, slot) {
4198
- return path4.join(path4.dirname(ctx.commonRoot), `${path4.basename(ctx.commonRoot)}-${slot}`);
4415
+ return path5.join(path5.dirname(ctx.commonRoot), `${path5.basename(ctx.commonRoot)}-${slot}`);
4199
4416
  }
4200
4417
  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);
4418
+ if (path5.dirname(p) !== path5.dirname(ctx.commonRoot)) return void 0;
4419
+ const prefix = `${path5.basename(ctx.commonRoot)}-`;
4420
+ const name = path5.basename(p);
4204
4421
  const n = name.startsWith(prefix) ? name.slice(prefix.length) : "";
4205
4422
  return /^[1-9]\d*$/.test(n) ? Number(n) : void 0;
4206
4423
  }
@@ -4285,7 +4502,7 @@ async function assertBranchName(ctx, name) {
4285
4502
  }
4286
4503
  }
4287
4504
  async function dirUsable(p) {
4288
- const st = await stat2(p).catch((e) => {
4505
+ const st = await stat3(p).catch((e) => {
4289
4506
  if (e.code === "ENOENT") return void 0;
4290
4507
  throw e;
4291
4508
  });
@@ -4363,8 +4580,8 @@ async function checkoutWorktree(ctx, req) {
4363
4580
  if (e instanceof GitError && /already (checked out|used by worktree)/.test(e.stderr)) throw new HttpError(409, e.message, "branch_in_use");
4364
4581
  throw e;
4365
4582
  }
4366
- const real = await realpath2(target.path).catch(() => target.path);
4367
- const worktree = (await listWorktrees(ctx)).find((w) => w.path === real);
4583
+ const real2 = await realpath2(target.path).catch(() => target.path);
4584
+ const worktree = (await listWorktrees(ctx)).find((w) => w.path === real2);
4368
4585
  if (!worktree) throw new HttpError(500, `git checked ${branch} out at ${target.path} but does not list it`, "git_failed");
4369
4586
  return { worktree, slot: target.slot, reused: target.reuse };
4370
4587
  }
@@ -4382,7 +4599,7 @@ async function dropBranch(ctx, wt, asked) {
4382
4599
  }
4383
4600
  async function releaseWorktree(ctx, req) {
4384
4601
  const p = req.path.trim();
4385
- const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path4.resolve(p));
4602
+ const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path5.resolve(p));
4386
4603
  if (!wt) throw badRequest(`unknown worktree: ${p}`, "unknown_worktree");
4387
4604
  if (wt.isMain) throw badRequest("the main worktree cannot be released", "main_worktree");
4388
4605
  if (wt.bare || slotOf(ctx, wt.path) === void 0) throw badRequest(`${wt.path} is not a slot; remove it instead`, "not_a_slot");
@@ -4398,7 +4615,7 @@ async function releaseWorktree(ctx, req) {
4398
4615
  }
4399
4616
  async function removeWorktree(ctx, req) {
4400
4617
  const p = req.path.trim();
4401
- const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path4.resolve(p));
4618
+ const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path5.resolve(p));
4402
4619
  if (!wt) throw badRequest(`unknown worktree: ${p}`, "unknown_worktree");
4403
4620
  if (wt.isMain) throw badRequest("the main worktree cannot be removed", "main_worktree");
4404
4621
  const args = ["worktree", "remove"];
@@ -4418,187 +4635,6 @@ async function removeWorktree(ctx, req) {
4418
4635
  return dropBranch(ctx, wt, req.deleteBranch);
4419
4636
  }
4420
4637
 
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
4638
  // packages/server/src/export.ts
4603
4639
  var LANG_BY_EXT = {
4604
4640
  ts: "ts",
@@ -4646,29 +4682,116 @@ function langForPath(filePath) {
4646
4682
  if (!m?.[1]) return "";
4647
4683
  return LANG_BY_EXT[m[1].toLowerCase()] ?? m[1].toLowerCase();
4648
4684
  }
4685
+ function shortId(id) {
4686
+ return id.slice(0, 8);
4687
+ }
4688
+ var quote = (text) => text.replace(/\r\n/g, "\n").split("\n").map((l) => l ? `> ${l}` : ">").join("\n");
4649
4689
  function formatCommentSection(c) {
4650
4690
  const range = c.startLine === c.endLine ? `${c.startLine}` : `${c.startLine}-${c.endLine}`;
4651
4691
  const width = String(c.endLine).length;
4652
4692
  const code = c.codeSnippet.map((line, i) => `${String(c.startLine + i).padStart(width, " ")} | ${line}`).join("\n");
4653
4693
  const lang = langForPath(c.filePath);
4654
- const body = c.body.replace(/\r\n/g, "\n").split("\n").map((l) => l ? `> ${l}` : ">").join("\n");
4655
4694
  const fence = code.includes("```") ? "````" : "```";
4656
4695
  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");
4696
+ const parts = [`## ${c.filePath}:${range} (${c.side}) [id: ${shortId(c.id)}]${orphanNote}`, `${fence}${lang}`, code, fence, quote(c.body)];
4697
+ for (const r of c.replies ?? []) parts.push(`${r.author === "agent" ? "Agent" : "Reviewer"} replied:
4698
+ ${quote(r.body)}`);
4699
+ return parts.join("\n");
4700
+ }
4701
+ function replyFooter(replyCommand) {
4702
+ 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.`;
4658
4703
  }
4659
- function formatCommentsExport({ repoRoot, comments }) {
4704
+ function formatCommentsExport({ repoRoot, comments, replyCommand }) {
4660
4705
  const targets = [...new Set(comments.map((c) => c.targetKey))];
4661
4706
  const head = ["# Review comments", `Target: ${targets.join(", ") || "-"}`, `Repo: ${repoRoot}`, `Count: ${comments.length}`];
4662
4707
  const sections = comments.map(formatCommentSection);
4663
- return [head.join("\n"), ...sections].join("\n\n") + "\n";
4708
+ const tail = replyCommand && comments.length > 0 ? [replyFooter(replyCommand)] : [];
4709
+ return [head.join("\n"), ...sections, ...tail].join("\n\n") + "\n";
4664
4710
  }
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";
4711
+ function formatTodoExport({ todo, comments, replyCommand }) {
4712
+ const body = todo.body.replace(/\r\n/g, "\n").trim();
4713
+ const parts = [todo.title, ...body ? [body] : [], ...comments.map(formatCommentSection)];
4714
+ if (replyCommand && comments.length > 0) parts.push(replyFooter(replyCommand));
4715
+ return parts.join("\n\n");
4716
+ }
4717
+
4718
+ // packages/server/src/feedback.ts
4719
+ import { realpath as realpath3 } from "fs/promises";
4720
+ import { randomUUID as randomUUID2 } from "crypto";
4721
+ async function real(p) {
4722
+ try {
4723
+ return await realpath3(p);
4724
+ } catch {
4725
+ return p;
4726
+ }
4727
+ }
4728
+ function worktreeOf(c, mainRoot) {
4729
+ return commentWorktree(c) ?? mainRoot;
4730
+ }
4731
+ async function commentsIn(state, worktreeRoot, mainRoot) {
4732
+ const want = await real(worktreeRoot);
4733
+ const resolved = /* @__PURE__ */ new Map();
4734
+ const out = [];
4735
+ for (const t of Object.values(state.targets)) {
4736
+ for (const c of t.comments) {
4737
+ const wt = worktreeOf(c, mainRoot);
4738
+ let p = resolved.get(wt);
4739
+ if (!p) {
4740
+ p = real(wt);
4741
+ resolved.set(wt, p);
4742
+ }
4743
+ if (await p === want) out.push(c);
4744
+ }
4745
+ }
4746
+ return out;
4747
+ }
4748
+ async function takeFeedback(store, opts) {
4749
+ if (opts.peek) return (await commentsIn(await store.load(), opts.worktreeRoot, opts.mainRoot)).filter(awaitsAgent);
4750
+ return store.update(async (s) => {
4751
+ const ids = new Set((await commentsIn(s, opts.worktreeRoot, opts.mainRoot)).filter(awaitsAgent).map((c) => c.id));
4752
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4753
+ const taken = [];
4754
+ for (const t of Object.values(s.targets)) {
4755
+ t.comments = t.comments.map((c) => {
4756
+ if (!ids.has(c.id)) return c;
4757
+ const next = { ...c, exportedAt: now, updatedAt: now, status: c.status === "active" ? "exported" : c.status };
4758
+ taken.push(next);
4759
+ return next;
4760
+ });
4761
+ }
4762
+ return taken;
4763
+ });
4764
+ }
4765
+ function locate(state, ref) {
4766
+ const needle = ref.trim().toLowerCase();
4767
+ if (!needle) throw badRequest("a comment id is required", "bad_comment_id");
4768
+ const hits = [];
4769
+ for (const t of Object.values(state.targets)) {
4770
+ t.comments.forEach((c, index) => {
4771
+ if (c.id === needle) hits.unshift({ list: t.comments, index });
4772
+ else if (c.id.startsWith(needle)) hits.push({ list: t.comments, index });
4773
+ });
4774
+ }
4775
+ const exact = hits[0] && hits[0].list[hits[0].index].id === needle;
4776
+ if (hits.length === 0) throw notFound(`no comment with id ${ref}; it may have been resolved or deleted`, "unknown_comment");
4777
+ if (!exact && hits.length > 1) throw badRequest(`id ${ref} matches ${hits.length} comments; give more of it`, "ambiguous_comment");
4778
+ return hits[0];
4779
+ }
4780
+ async function addReply(store, ref, author, body) {
4781
+ if (!body.trim()) throw badRequest("reply must not be empty", "empty_reply");
4782
+ return store.update((s) => {
4783
+ const { list, index } = locate(s, ref);
4784
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4785
+ const reply2 = { id: randomUUID2(), author, body: body.trim(), at: now };
4786
+ const prev = list[index];
4787
+ const next = { ...prev, replies: [...prev.replies ?? [], reply2], updatedAt: now };
4788
+ if (author === "reviewer") {
4789
+ delete next.exportedAt;
4790
+ if (next.status === "exported") next.status = "active";
4791
+ }
4792
+ list[index] = next;
4793
+ return next;
4794
+ });
4672
4795
  }
4673
4796
 
4674
4797
  // packages/server/src/nvim.ts
@@ -4792,7 +4915,7 @@ var NvimService = class {
4792
4915
 
4793
4916
  // packages/server/src/tmux.ts
4794
4917
  import { execFile as execFile3 } from "child_process";
4795
- import { realpath as realpath3 } from "fs/promises";
4918
+ import { realpath as realpath4 } from "fs/promises";
4796
4919
  var runTmux = (args) => new Promise((resolve, reject) => {
4797
4920
  execFile3("tmux", args, { encoding: "utf8", timeout: 5e3, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
4798
4921
  if (!error) return resolve(stdout);
@@ -4808,21 +4931,21 @@ var TmuxService = class {
4808
4931
  }
4809
4932
  run;
4810
4933
  async sessions(mainRoot) {
4811
- const root = await realpath3(mainRoot);
4934
+ const root = await realpath4(mainRoot);
4812
4935
  const output = await this.run(["list-sessions", "-F", "#{session_id} #{session_name} #{session_path}"]);
4813
4936
  const sessions = [];
4814
4937
  for (const line of output.trimEnd().split("\n")) {
4815
4938
  const [id, name, ...parts] = line.split(" ");
4816
4939
  const path12 = parts.join(" ");
4817
4940
  if (!id || !/^\$\d+$/.test(id) || !name || !path12) continue;
4818
- if (await realpath3(path12).catch(() => null) === root) sessions.push({ id, name, path: path12 });
4941
+ if (await realpath4(path12).catch(() => null) === root) sessions.push({ id, name, path: path12 });
4819
4942
  }
4820
4943
  return sessions;
4821
4944
  }
4822
4945
  async open(mainRoot, worktreePath, sessionId) {
4823
4946
  const session = (await this.sessions(mainRoot)).find((s) => s.id === sessionId);
4824
4947
  if (!session) throw new HttpError(409, "\u4E3B\u4ED3\u5E93\u5BF9\u5E94\u7684 tmux session \u5DF2\u4E0D\u5B58\u5728\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5", "tmux_session_missing");
4825
- const directory = await realpath3(worktreePath);
4948
+ const directory = await realpath4(worktreePath);
4826
4949
  const window = (await this.run(["new-window", "-d", "-P", "-F", "#{window_id}", "-t", `${session.id}:`, "-c", directory.replaceAll("#", "##")])).trim();
4827
4950
  return { session: session.name, window };
4828
4951
  }
@@ -4990,6 +5113,7 @@ async function serveStaticFile(c, webDir) {
4990
5113
 
4991
5114
  // packages/server/src/app.ts
4992
5115
  var SSE_HEARTBEAT_MS = 15e3;
5116
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost"]);
4993
5117
  var LISTING_HINT_TTL_MS = 5 * 6e4;
4994
5118
  function createApp(opts) {
4995
5119
  const { repo, store } = opts;
@@ -5014,6 +5138,10 @@ function createApp(opts) {
5014
5138
  return list;
5015
5139
  };
5016
5140
  const checkpoints = checkpointStore(store.file);
5141
+ const stateStamp = () => stat7(store.file).then(
5142
+ (st) => st.mtimeMs,
5143
+ () => 0
5144
+ );
5017
5145
  const worktreeCtx = async (key) => {
5018
5146
  let list = await worktrees();
5019
5147
  try {
@@ -5041,6 +5169,14 @@ function createApp(opts) {
5041
5169
  untracked: explicit?.untracked ?? hint?.untracked
5042
5170
  });
5043
5171
  };
5172
+ const checkpointHandoff = (comments) => {
5173
+ const worktrees2 = new Set(comments.map((x) => tryParseTargetKey(x.targetKey)?.worktree));
5174
+ for (const wt of worktrees2) {
5175
+ takeCheckpoint(store, wt ?? repo.root, wt, { handoff: true }).catch((e) => {
5176
+ console.error(`warden: no checkpoint for the comments handed over in ${wt ?? repo.root}: ${e instanceof Error ? e.message : e}`);
5177
+ });
5178
+ }
5179
+ };
5044
5180
  const findComment = (state, id) => {
5045
5181
  for (const [targetKey, t] of Object.entries(state.targets)) {
5046
5182
  const index = t.comments.findIndex((c) => c.id === id);
@@ -5048,6 +5184,13 @@ function createApp(opts) {
5048
5184
  }
5049
5185
  return void 0;
5050
5186
  };
5187
+ const linkable = (state, raw2) => {
5188
+ if (!Array.isArray(raw2) || raw2.some((x) => typeof x !== "string")) throw badRequest("commentIds must be a list of ids");
5189
+ const ids = [...new Set(raw2)];
5190
+ const missing = ids.find((x) => !findComment(state, x));
5191
+ if (missing) throw notFound(`no comment with id ${missing}`, "unknown_comment");
5192
+ return ids;
5193
+ };
5051
5194
  app.onError((err, c) => {
5052
5195
  if (err instanceof HttpError) {
5053
5196
  return c.json({ error: err.message, code: err.code }, err.status);
@@ -5055,10 +5198,20 @@ function createApp(opts) {
5055
5198
  console.error(err);
5056
5199
  return c.json({ error: err instanceof Error ? err.message : String(err), code: "internal" }, 500);
5057
5200
  });
5201
+ app.use("*", async (c, next) => {
5202
+ const host = new URL(c.req.url).hostname;
5203
+ if (!LOOPBACK_HOSTS.has(host)) {
5204
+ throw new HttpError(403, `requests for host ${host} are refused; open warden at http://127.0.0.1 or http://localhost`, "bad_host");
5205
+ }
5206
+ await next();
5207
+ });
5058
5208
  const api = new Hono2();
5059
5209
  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");
5210
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") {
5211
+ const origin = c.req.header("origin");
5212
+ if (c.req.header("sec-fetch-site") === "cross-site" || origin && origin !== new URL(c.req.url).origin) {
5213
+ throw new HttpError(403, "cross-site requests are refused", "cross_site");
5214
+ }
5062
5215
  }
5063
5216
  await next();
5064
5217
  });
@@ -5175,15 +5328,9 @@ function createApp(opts) {
5175
5328
  });
5176
5329
  api.post("/targets/:key/checkpoints", async (c) => {
5177
5330
  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);
5331
+ const { checkpoint, unchanged } = await takeCheckpoint(store, ctx.cwd, ctx.target.worktree);
5332
+ const res = { checkpoint, unchanged, targetKey: checkpointKey(checkpoint) };
5333
+ return c.json(res, unchanged ? 200 : 201);
5187
5334
  });
5188
5335
  api.delete("/targets/:key/checkpoints/:id", async (c) => {
5189
5336
  const ctx = await worktreeCtx(c.req.param("key"));
@@ -5236,7 +5383,7 @@ function createApp(opts) {
5236
5383
  if (!anchored) throw badRequest("selection is not fully visible in the diff on that side (must be inside one hunk)", "bad_selection");
5237
5384
  const now = (/* @__PURE__ */ new Date()).toISOString();
5238
5385
  const comment = {
5239
- id: randomUUID2(),
5386
+ id: randomUUID3(),
5240
5387
  targetKey: key,
5241
5388
  filePath: body.filePath,
5242
5389
  side: body.side,
@@ -5307,12 +5454,19 @@ function createApp(opts) {
5307
5454
  if (!t) return false;
5308
5455
  const before = t.comments.length;
5309
5456
  t.comments = t.comments.filter((x) => x.id !== id);
5310
- for (const issue of s.issues) issue.commentIds = issue.commentIds.filter((x) => x !== id);
5457
+ unlinkComments(s, [id]);
5311
5458
  return t.comments.length !== before;
5312
5459
  });
5313
5460
  if (!removed) throw notFound("comment not found");
5314
5461
  return c.json({ ok: true });
5315
5462
  });
5463
+ api.post("/targets/:key/comments/:id/replies", async (c) => {
5464
+ const body = await c.req.json();
5465
+ if (typeof body.body !== "string") throw badRequest("body is required");
5466
+ const found = findComment(await store.load(), c.req.param("id"));
5467
+ if (!found || found.targetKey !== commentScopeKey(c.req.param("key"))) throw notFound("comment not found");
5468
+ return c.json(await addReply(store, found.comment.id, "reviewer", body.body), 201);
5469
+ });
5316
5470
  api.post("/targets/:key/comments/reanchor", async (c) => {
5317
5471
  const key = c.req.param("key");
5318
5472
  const ctx = await targetCtx(key);
@@ -5384,7 +5538,7 @@ function createApp(opts) {
5384
5538
  const patch = located.get(cm.id);
5385
5539
  if (!patch) {
5386
5540
  if (!comments.some((x) => x.id === cm.id)) return [cm];
5387
- if (committed) {
5541
+ if (committed && !cm.replies?.length) {
5388
5542
  dropped.push(cm.id);
5389
5543
  return [];
5390
5544
  }
@@ -5394,9 +5548,7 @@ function createApp(opts) {
5394
5548
  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
5549
  return [unchanged ? cm : { ...next, updatedAt: now }];
5396
5550
  });
5397
- for (const id of dropped) {
5398
- for (const issue of s.issues) issue.commentIds = issue.commentIds.filter((x) => x !== id);
5399
- }
5551
+ unlinkComments(s, dropped);
5400
5552
  t.head = head;
5401
5553
  return t.comments;
5402
5554
  });
@@ -5407,6 +5559,7 @@ function createApp(opts) {
5407
5559
  const body = await c.req.json();
5408
5560
  if (!Array.isArray(body.commentIds)) throw badRequest("commentIds required");
5409
5561
  const ids = body.commentIds.filter((x) => typeof x === "string");
5562
+ let handed = [];
5410
5563
  const res = await store.update((s) => {
5411
5564
  const now = (/* @__PURE__ */ new Date()).toISOString();
5412
5565
  const selected = [];
@@ -5418,94 +5571,14 @@ function createApp(opts) {
5418
5571
  s.targets[found.targetKey].comments[found.index] = next;
5419
5572
  selected.push(next);
5420
5573
  }
5574
+ handed = selected;
5421
5575
  return {
5422
- text: formatCommentsExport({ repoRoot: repo.root, comments: selected }),
5576
+ text: formatCommentsExport({ repoRoot: repo.root, comments: selected, replyCommand: opts.replyCommand }),
5423
5577
  count: selected.length,
5424
5578
  commentIds: selected.map((x) => x.id)
5425
5579
  };
5426
5580
  });
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
- });
5581
+ checkpointHandoff(handed);
5509
5582
  return c.json(res);
5510
5583
  });
5511
5584
  const knownRoot = async (rootParam) => {
@@ -5529,7 +5602,7 @@ function createApp(opts) {
5529
5602
  const branch = body.branch?.trim() || await currentBranch(await knownRoot(body.root));
5530
5603
  const now = (/* @__PURE__ */ new Date()).toISOString();
5531
5604
  const todo = {
5532
- id: randomUUID2(),
5605
+ id: randomUUID3(),
5533
5606
  branch,
5534
5607
  title: body.title.trim(),
5535
5608
  body: typeof body.body === "string" ? body.body : "",
@@ -5538,6 +5611,10 @@ function createApp(opts) {
5538
5611
  updatedAt: now
5539
5612
  };
5540
5613
  await store.update((s) => {
5614
+ if (body.commentIds !== void 0) {
5615
+ const ids = linkable(s, body.commentIds);
5616
+ if (ids.length) todo.commentIds = ids;
5617
+ }
5541
5618
  s.todos = insertAfter(s.todos, todo, typeof body.after === "string" ? body.after : void 0);
5542
5619
  });
5543
5620
  return c.json(todo, 201);
@@ -5564,11 +5641,40 @@ function createApp(opts) {
5564
5641
  if (typeof body.title === "string" && body.title.trim()) todo.title = body.title.trim();
5565
5642
  if (typeof body.body === "string") todo.body = body.body;
5566
5643
  if (body.status === "open" || body.status === "done") todo.status = body.status;
5644
+ if (body.commentIds !== void 0) {
5645
+ const ids = linkable(s, body.commentIds);
5646
+ if (ids.length) todo.commentIds = ids;
5647
+ else delete todo.commentIds;
5648
+ }
5567
5649
  todo.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5568
5650
  return todo;
5569
5651
  });
5570
5652
  return c.json(updated);
5571
5653
  });
5654
+ api.post("/todos/:id/export", async (c) => {
5655
+ const id = c.req.param("id");
5656
+ const handed = [];
5657
+ const res = await store.update((s) => {
5658
+ const todo = s.todos.find((t) => t.id === id);
5659
+ if (!todo) throw notFound("todo not found");
5660
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5661
+ for (const cid of todo.commentIds ?? []) {
5662
+ const found = findComment(s, cid);
5663
+ if (!found) continue;
5664
+ const next = { ...found.comment, exportedAt: now, updatedAt: now };
5665
+ if (next.status === "active") next.status = "exported";
5666
+ s.targets[found.targetKey].comments[found.index] = next;
5667
+ handed.push(next);
5668
+ }
5669
+ return {
5670
+ text: formatTodoExport({ todo, comments: handed, replyCommand: opts.replyCommand }),
5671
+ count: handed.length,
5672
+ commentIds: handed.map((x) => x.id)
5673
+ };
5674
+ });
5675
+ checkpointHandoff(handed);
5676
+ return c.json(res);
5677
+ });
5572
5678
  api.delete("/todos/:id", async (c) => {
5573
5679
  const id = c.req.param("id");
5574
5680
  const removed = await store.update((s) => {
@@ -5647,8 +5753,31 @@ function createApp(opts) {
5647
5753
  return c.json(await tmux.open(repo.commonRoot, wt.path, body.sessionId), 201);
5648
5754
  });
5649
5755
  api.get("/worktrees", async (c) => {
5650
- const list = await listWorktreesDetailed(repo);
5756
+ const [list, state] = await Promise.all([listWorktreesDetailed(repo), store.load()]);
5651
5757
  const [branches, newSlot] = await Promise.all([listBranches(repo, list), nextSlot(repo, list)]);
5758
+ const review = /* @__PURE__ */ new Map();
5759
+ const latest = /* @__PURE__ */ new Map();
5760
+ for (const t of Object.values(state.targets)) {
5761
+ for (const cm of t.comments) {
5762
+ const at = commentWorktree(cm) ?? repo.root;
5763
+ const r = review.get(at) ?? { toAgent: 0, toReviewer: 0 };
5764
+ const seen = latest.get(at) ?? { agent: "", reviewer: "" };
5765
+ if (awaitsAgent(cm)) {
5766
+ r.toAgent++;
5767
+ if (cm.updatedAt >= seen.agent) [seen.agent, r.toAgentTarget] = [cm.updatedAt, cm.targetKey];
5768
+ }
5769
+ if (awaitsReviewer(cm)) {
5770
+ r.toReviewer++;
5771
+ if (cm.updatedAt >= seen.reviewer) [seen.reviewer, r.toReviewerTarget] = [cm.updatedAt, cm.targetKey];
5772
+ }
5773
+ review.set(at, r);
5774
+ latest.set(at, seen);
5775
+ }
5776
+ }
5777
+ for (const wt of list) {
5778
+ const r = review.get(wt.path);
5779
+ if (r && (r.toAgent || r.toReviewer)) wt.review = r;
5780
+ }
5652
5781
  const res = { worktrees: list, branches, newSlot };
5653
5782
  return c.json(res);
5654
5783
  });
@@ -5695,7 +5824,19 @@ function createApp(opts) {
5695
5824
  const event = { type: "changed", ...change };
5696
5825
  void enqueue(() => stream2.writeSSE({ event: "changed", data: JSON.stringify(event) }));
5697
5826
  });
5698
- stream2.onAbort(release);
5827
+ let stamp = await stateStamp();
5828
+ const statePoll = setInterval(() => {
5829
+ void stateStamp().then((next) => {
5830
+ if (next === stamp) return;
5831
+ stamp = next;
5832
+ const event = { type: "state", at: (/* @__PURE__ */ new Date()).toISOString() };
5833
+ void enqueue(() => stream2.writeSSE({ event: "state", data: JSON.stringify(event) }));
5834
+ });
5835
+ }, opts.watchIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
5836
+ stream2.onAbort(() => {
5837
+ clearInterval(statePoll);
5838
+ release();
5839
+ });
5699
5840
  try {
5700
5841
  while (!stream2.aborted && !stream2.closed) {
5701
5842
  await stream2.sleep(SSE_HEARTBEAT_MS);
@@ -5703,6 +5844,7 @@ function createApp(opts) {
5703
5844
  await enqueue(() => stream2.write(": ping\n\n"));
5704
5845
  }
5705
5846
  } finally {
5847
+ clearInterval(statePoll);
5706
5848
  release();
5707
5849
  }
5708
5850
  });
@@ -5877,8 +6019,8 @@ async function startServer(opts) {
5877
6019
  const repo = await resolveRepo(opts.repoPath);
5878
6020
  const stateFile = opts.stateFile ?? stateFilePath(repo.commonRoot);
5879
6021
  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 });
6022
+ const instanceToken = randomUUID4();
6023
+ const app = createApp({ repo, store, nvim: new NvimService(), webDir: opts.webDir, instanceToken, update: opts.update, replyCommand: opts.replyCommand });
5882
6024
  const listen = (p) => new Promise((resolve, reject) => {
5883
6025
  const s = serve({ fetch: app.fetch, hostname: HOST, port: p }, () => resolve(s));
5884
6026
  s.once("error", reject);
@@ -5920,8 +6062,68 @@ async function startServer(opts) {
5920
6062
  };
5921
6063
  }
5922
6064
 
6065
+ // bin/agent.ts
6066
+ var AGENT_COMMANDS = ["feedback", "reply"];
6067
+ function agentUsage(cmd) {
6068
+ return `Commands for the agent, run inside the worktree under review:
6069
+ ${cmd} feedback [--peek] Print the review comments not yet handed over, and mark them handed
6070
+ over (--peek leaves them as they are)
6071
+ ${cmd} reply <id> <message> Answer a comment; <id> is the one in its heading. A message of "-",
6072
+ or none with stdin piped, is read from stdin
6073
+ `;
6074
+ }
6075
+ async function readStdin() {
6076
+ const chunks = [];
6077
+ for await (const chunk of process.stdin) chunks.push(chunk);
6078
+ return Buffer.concat(chunks).toString("utf8");
6079
+ }
6080
+ async function openStore() {
6081
+ const repo = await resolveRepo(process.cwd());
6082
+ return { store: new StateStore(stateFilePath(repo.commonRoot), repo.commonRoot), root: repo.root, commonRoot: repo.commonRoot };
6083
+ }
6084
+ async function feedback(args, replyCommand) {
6085
+ const unknown = args.find((a) => a !== "--peek");
6086
+ if (unknown) throw new Error(`unknown argument: ${unknown}`);
6087
+ const { store, root, commonRoot } = await openStore();
6088
+ const peek = args.includes("--peek");
6089
+ const comments = await takeFeedback(store, { worktreeRoot: root, mainRoot: commonRoot, peek });
6090
+ if (comments.length === 0) {
6091
+ console.log("No review comments are waiting for you.");
6092
+ return;
6093
+ }
6094
+ if (!peek) {
6095
+ await takeCheckpoint(store, root, root === commonRoot ? void 0 : root, { handoff: true }).catch((e) => {
6096
+ console.error(`warden feedback: no checkpoint taken: ${e instanceof Error ? e.message : e}`);
6097
+ });
6098
+ }
6099
+ process.stdout.write(formatCommentsExport({ repoRoot: root, comments, replyCommand }));
6100
+ }
6101
+ async function reply(args) {
6102
+ const [id, ...words] = args;
6103
+ if (!id) throw new Error("reply needs a comment id");
6104
+ let body = words.join(" ");
6105
+ if (body === "-" || !body && !process.stdin.isTTY) body = await readStdin();
6106
+ if (!body.trim()) throw new Error("reply needs a message");
6107
+ const { store } = await openStore();
6108
+ const c = await addReply(store, id, "agent", body);
6109
+ const range = c.startLine === c.endLine ? `${c.startLine}` : `${c.startLine}-${c.endLine}`;
6110
+ console.log(`Replied to ${shortId(c.id)} (${c.filePath}:${range}).`);
6111
+ }
6112
+ async function runAgentCommand(argv, replyCommand) {
6113
+ const [cmd, ...rest] = argv;
6114
+ if (!cmd || !AGENT_COMMANDS.includes(cmd)) return void 0;
6115
+ try {
6116
+ if (cmd === "feedback") await feedback(rest, replyCommand);
6117
+ else await reply(rest);
6118
+ return 0;
6119
+ } catch (e) {
6120
+ console.error(`warden ${cmd}: ${e instanceof HttpError || e instanceof Error ? e.message : String(e)}`);
6121
+ return 1;
6122
+ }
6123
+ }
6124
+
5923
6125
  // bin/cli.ts
5924
- var VERSION = true ? "0.15.0" : "dev";
6126
+ var VERSION = true ? "0.16.0" : "dev";
5925
6127
  function parseArgs(argv) {
5926
6128
  const args = { repoPath: process.cwd(), open: true, updateCheck: true, help: false, version: false };
5927
6129
  for (let i = 0; i < argv.length; i++) {
@@ -5944,10 +6146,13 @@ function parseArgs(argv) {
5944
6146
  }
5945
6147
  return args;
5946
6148
  }
6149
+ var viaNpx = fileURLToPath(import.meta.url).includes(`${path11.sep}_npx${path11.sep}`);
6150
+ var selfCommand = viaNpx ? "npx @xbghc/warden" : "warden";
5947
6151
  function usage() {
5948
6152
  return `warden \u2014 local git diff review UI
5949
6153
 
5950
6154
  Usage: warden [repoPath] [options]
6155
+ warden <command> [args]
5951
6156
 
5952
6157
  Options:
5953
6158
  --port <n>, -p <n> Listen on a fixed port (default: first free port from 4100, and under WSL
@@ -5957,6 +6162,9 @@ Options:
5957
6162
  (also: WARDEN_NO_UPDATE_CHECK=1, NO_UPDATE_NOTIFIER=1, CI)
5958
6163
  -h, --help Show this help
5959
6164
  -v, --version Print version
6165
+
6166
+ ${agentUsage("warden")}
6167
+ A repository in a directory named after a command is given as ./<name>.
5960
6168
  `;
5961
6169
  }
5962
6170
  async function openBrowser(url) {
@@ -5999,6 +6207,8 @@ function resolveWebDir() {
5999
6207
  return void 0;
6000
6208
  }
6001
6209
  async function main() {
6210
+ const code = await runAgentCommand(process.argv.slice(2), selfCommand);
6211
+ if (code !== void 0) process.exit(code);
6002
6212
  let args;
6003
6213
  try {
6004
6214
  args = parseArgs(process.argv.slice(2));
@@ -6019,12 +6229,11 @@ async function main() {
6019
6229
  if (!webDir) {
6020
6230
  console.error("warning: built web assets not found (dist/web). Only the /api endpoints will be served.");
6021
6231
  }
6022
- const viaNpx = fileURLToPath(import.meta.url).includes(`${path11.sep}_npx${path11.sep}`);
6023
6232
  const checking = args.updateCheck && VERSION !== "dev" && updateCheckEnabled();
6024
6233
  const update = checking ? checkForUpdate({ current: VERSION, npx: viaNpx }) : Promise.resolve(null);
6025
6234
  let server;
6026
6235
  try {
6027
- server = await startServer({ repoPath: args.repoPath, port: args.port, webDir, update });
6236
+ server = await startServer({ repoPath: args.repoPath, port: args.port, webDir, update, replyCommand: selfCommand });
6028
6237
  } catch (e) {
6029
6238
  console.error(`warden: ${e.message}`);
6030
6239
  process.exit(1);