@norman-else/dsh-claude 0.1.47 → 0.1.48

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/lib/index.mjs CHANGED
@@ -4560,8 +4560,10 @@ const MAX_UNTRACKED_DIFFS = 50;
4560
4560
  const GIT_TIMEOUT_MS$3 = 5e3;
4561
4561
  const GH_TIMEOUT_MS$1 = 8e3;
4562
4562
  const CACHE_TTL_MS = 5e3;
4563
+ const PULL_REQUEST_TTL_FACTOR = 12;
4563
4564
  const MAX_TEXT_CHARS = 1024;
4564
4565
  const MAX_CONFLICT_PATHS = 100;
4566
+ const PULL_REQUEST_FIELDS = "number,title,url,state,isDraft,reviewDecision,mergeStateStatus,mergedAt,statusCheckRollup,author,createdAt,baseRefName,headRefName,additions,deletions,changedFiles";
4565
4567
  function bounded(value) {
4566
4568
  return value.trim().slice(0, MAX_TEXT_CHARS);
4567
4569
  }
@@ -4761,6 +4763,7 @@ function parsePullRequest(value) {
4761
4763
  review: reviewState(input.reviewDecision),
4762
4764
  checks: aggregateChecks(input.statusCheckRollup),
4763
4765
  ...typeof input.mergeStateStatus === "string" ? { mergeState: bounded(input.mergeStateStatus) } : {},
4766
+ ...typeof input.headRefName === "string" && input.headRefName.length > 0 ? { headBranch: bounded(input.headRefName) } : {},
4764
4767
  ...typeof record$11(input.author)?.login === "string" ? { author: bounded(String(record$11(input.author)?.login)) } : {},
4765
4768
  ...typeof input.createdAt === "string" && Number.isFinite(Date.parse(input.createdAt)) ? { createdAt: new Date(input.createdAt).toISOString() } : {},
4766
4769
  ...typeof input.mergedAt === "string" && Number.isFinite(Date.parse(input.mergedAt)) ? { mergedAt: new Date(input.mergedAt).toISOString() } : {},
@@ -4801,6 +4804,75 @@ var RepositoryStatusService = class {
4801
4804
  value.catch(() => this.#cache.delete(cwd));
4802
4805
  return value;
4803
4806
  }
4807
+ /** One pull request by number, as the checkout-shaped status the panels
4808
+ * read: a session that opened a pull request in another repository and
4809
+ * then moved that checkout back to its base branch still has the pull
4810
+ * request. No root, no diff; `cwd` is only where gh runs. Same cache and
4811
+ * stabilisation as `inspect`, keyed off the pull request. */
4812
+ inspectPullRequest(cwd, repository, number) {
4813
+ const key = `gh:${repository}#${number}`;
4814
+ const current = this.#cache.get(key);
4815
+ if (current !== void 0 && current.expiresAt > Date.now()) return current.value;
4816
+ const value = (async () => {
4817
+ const gh = await this.#gh();
4818
+ if (gh === void 0) return {
4819
+ status: "unavailable",
4820
+ cwd
4821
+ };
4822
+ const result = await run(this.#runtime, gh, [
4823
+ "pr",
4824
+ "view",
4825
+ String(number),
4826
+ "--repo",
4827
+ repository,
4828
+ "--json",
4829
+ PULL_REQUEST_FIELDS
4830
+ ], cwd, GH_TIMEOUT_MS$1);
4831
+ const raw = result.exitCode === 0 ? JSON.parse(result.stdout) : void 0;
4832
+ const pullRequest = parsePullRequest(raw);
4833
+ if (pullRequest === void 0) return {
4834
+ status: "unavailable",
4835
+ cwd
4836
+ };
4837
+ const counts = record$11(raw);
4838
+ const count = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
4839
+ const patch = await run(this.#runtime, gh, [
4840
+ "pr",
4841
+ "diff",
4842
+ String(number),
4843
+ "--repo",
4844
+ repository
4845
+ ], cwd, GH_TIMEOUT_MS$1, MAX_RAW_DIFF_BYTES);
4846
+ const packed = patch.exitCode === 0 && !patch.lossy ? packPatchByFile(patch.stdout) : void 0;
4847
+ return {
4848
+ status: "ready",
4849
+ cwd,
4850
+ remote: repository,
4851
+ ...pullRequest.headBranch === void 0 ? {} : { branch: pullRequest.headBranch },
4852
+ detached: false,
4853
+ worktree: false,
4854
+ dirty: false,
4855
+ pullRequest,
4856
+ pullRequestOnly: true,
4857
+ diff: {
4858
+ additions: count(counts?.additions),
4859
+ deletions: count(counts?.deletions),
4860
+ files: count(counts?.changedFiles),
4861
+ ...packed === void 0 ? {} : { patch: packed.patch },
4862
+ ...packed === void 0 || packed.elided.length === 0 ? {} : { elided: packed.elided },
4863
+ truncated: packed === void 0
4864
+ }
4865
+ };
4866
+ })().then((next) => this.#stabilize(key, next), () => ({
4867
+ status: "unavailable",
4868
+ cwd
4869
+ }));
4870
+ this.#cache.set(key, {
4871
+ expiresAt: Date.now() + this.#cacheTtlMs * PULL_REQUEST_TTL_FACTOR,
4872
+ value
4873
+ });
4874
+ return value;
4875
+ }
4804
4876
  invalidate(cwd) {
4805
4877
  this.#cache.delete(cwd);
4806
4878
  this.#lastReady.delete(cwd);
@@ -5080,7 +5152,7 @@ var RepositoryStatusService = class {
5080
5152
  "--repo",
5081
5153
  repository,
5082
5154
  "--json",
5083
- "number,title,url,state,isDraft,reviewDecision,mergeStateStatus,mergedAt,statusCheckRollup,author,createdAt,baseRefName"
5155
+ PULL_REQUEST_FIELDS
5084
5156
  ], cwd, GH_TIMEOUT_MS$1);
5085
5157
  if (result.exitCode !== 0) return void 0;
5086
5158
  return parsePullRequest(JSON.parse(result.stdout));
@@ -5338,9 +5410,16 @@ var RepositorySetupService = class {
5338
5410
  /** Tear down a merged branch: remove a plugin worktree (and its lease and
5339
5411
  * branch), or switch a plain checkout back to the base branch and delete
5340
5412
  * the merged branch. Refuses dirty trees. */
5341
- async cleanupMerged(pathValue, baseBranch) {
5413
+ /** `branch` names the merged branch when the checkout is no longer on it:
5414
+ * a session that opened a pull request in another clone switched that
5415
+ * clone back to base itself, and only the local branch is left to delete.
5416
+ * `requirePushed` is for a branch no pull request vouches for: it is only
5417
+ * removed once every commit on it is reachable from some remote, so a
5418
+ * worktree that never got as far as a pull request cannot take work with it. */
5419
+ async cleanupMerged(pathValue, baseBranch, branch, requirePushed = false) {
5342
5420
  const path = safePath(pathValue);
5343
- const base = safeBranch(baseBranch);
5421
+ const base = baseBranch === void 0 ? void 0 : safeBranch(baseBranch);
5422
+ const named = branch === void 0 ? void 0 : safeBranch(branch);
5344
5423
  const git = await this.#git();
5345
5424
  const status = await this.#run(git, [
5346
5425
  "status",
@@ -5350,36 +5429,56 @@ var RepositorySetupService = class {
5350
5429
  if (status.exitCode !== 0 || status.lossy) throw new RepositorySetupError("repository-unavailable", "The repository state is unavailable.");
5351
5430
  if (status.stdout.trim().length > 0) throw new RepositorySetupError("dirty-workspace", "Commit or stash workspace changes before cleaning up.");
5352
5431
  const lease = (await this.#readLeases()).find((item) => comparablePath(item.path) === comparablePath(path));
5353
- if (lease !== void 0) return this.#serialize(async () => {
5354
- if ((await this.#run(git, [
5355
- "worktree",
5356
- "remove",
5357
- "--",
5358
- lease.path
5359
- ], lease.root)).exitCode !== 0) throw new RepositorySetupError("worktree-remove-failed", "Git could not remove the worktree.");
5360
- await this.#run(git, [
5361
- "branch",
5362
- "-D",
5363
- "--",
5364
- lease.branch
5365
- ], lease.root).catch(() => void 0);
5366
- const current = await this.#readLeases();
5367
- await this.#writeLeases(current.filter((item) => item.id !== lease.id));
5368
- return {
5369
- mode: "worktree",
5370
- root: lease.root,
5371
- branch: lease.branch
5372
- };
5373
- });
5432
+ if (lease !== void 0) {
5433
+ if (requirePushed) await this.#requirePushed(git, lease.root, lease.branch);
5434
+ return this.#serialize(async () => {
5435
+ if ((await this.#run(git, [
5436
+ "worktree",
5437
+ "remove",
5438
+ "--",
5439
+ lease.path
5440
+ ], lease.root)).exitCode !== 0) throw new RepositorySetupError("worktree-remove-failed", "Git could not remove the worktree.");
5441
+ await this.#run(git, [
5442
+ "branch",
5443
+ "-D",
5444
+ "--",
5445
+ lease.branch
5446
+ ], lease.root).catch(() => void 0);
5447
+ const current = await this.#readLeases();
5448
+ await this.#writeLeases(current.filter((item) => item.id !== lease.id));
5449
+ return {
5450
+ mode: "worktree",
5451
+ root: lease.root,
5452
+ branch: lease.branch
5453
+ };
5454
+ });
5455
+ }
5374
5456
  const root = await this.#repositoryRoot(git, path);
5457
+ if (base === void 0) throw new RepositorySetupError("nothing-to-clean", "A plain checkout needs the base branch to return to.");
5375
5458
  const head = await this.#run(git, [
5376
5459
  "symbolic-ref",
5377
5460
  "--quiet",
5378
5461
  "--short",
5379
5462
  "HEAD"
5380
5463
  ], root);
5381
- const branch = head.exitCode === 0 ? head.stdout.trim() : "";
5382
- if (branch.length === 0 || branch === base) throw new RepositorySetupError("nothing-to-clean", "The checkout is already on the base branch.");
5464
+ const current = head.exitCode === 0 ? head.stdout.trim() : "";
5465
+ if (current.length === 0 || current === base) {
5466
+ if (named === void 0 || named === base) throw new RepositorySetupError("nothing-to-clean", "The checkout is already on the base branch.");
5467
+ if (requirePushed) await this.#requirePushed(git, root, named);
5468
+ await this.#run(git, [
5469
+ "branch",
5470
+ "-D",
5471
+ "--",
5472
+ named
5473
+ ], root).catch(() => void 0);
5474
+ await this.#run(git, ["pull", "--ff-only"], root, GIT_FETCH_TIMEOUT_MS).catch(() => void 0);
5475
+ return {
5476
+ mode: "checkout",
5477
+ root,
5478
+ branch: named
5479
+ };
5480
+ }
5481
+ if (requirePushed) await this.#requirePushed(git, root, current);
5383
5482
  if ((await this.#run(git, [
5384
5483
  "switch",
5385
5484
  "--",
@@ -5389,13 +5488,13 @@ var RepositorySetupService = class {
5389
5488
  "branch",
5390
5489
  "-D",
5391
5490
  "--",
5392
- branch
5491
+ current
5393
5492
  ], root).catch(() => void 0);
5394
5493
  await this.#run(git, ["pull", "--ff-only"], root, GIT_FETCH_TIMEOUT_MS).catch(() => void 0);
5395
5494
  return {
5396
5495
  mode: "checkout",
5397
5496
  root,
5398
- branch
5497
+ branch: current
5399
5498
  };
5400
5499
  }
5401
5500
  bindLease(leaseId, sessionId) {
@@ -5672,6 +5771,17 @@ var RepositorySetupService = class {
5672
5771
  }
5673
5772
  return `${prefix}/${slug(baseBranch, "branch")}-${stamp}-${suffix}`;
5674
5773
  }
5774
+ /** Refuse to delete a branch holding commits no remote has. */
5775
+ async #requirePushed(git, root, branch) {
5776
+ const count = await this.#run(git, [
5777
+ "rev-list",
5778
+ "--count",
5779
+ branch,
5780
+ "--not",
5781
+ "--remotes"
5782
+ ], root);
5783
+ if (count.exitCode !== 0 || Number(count.stdout.trim()) > 0) throw new RepositorySetupError("unpushed-commits", "The branch has commits that are not on any remote; push or discard them first.");
5784
+ }
5675
5785
  async #repositoryRoot(git, cwd) {
5676
5786
  const result = await this.#run(git, [
5677
5787
  "rev-parse",
@@ -5910,6 +6020,7 @@ var RepositoryActionService = class {
5910
6020
  const merged = await this.#run(gh, [
5911
6021
  "pr",
5912
6022
  "merge",
6023
+ ...request.pullNumber === void 0 ? [] : [String(request.pullNumber)],
5913
6024
  `--${method}`,
5914
6025
  ...request.admin === true ? ["--admin"] : []
5915
6026
  ], before.root, REMOTE_TIMEOUT_MS);
@@ -6385,8 +6496,10 @@ function registerRepositorySetupRoute(ctx, service, sweep, cleaned) {
6385
6496
  if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
6386
6497
  const input = await readJson$6(io);
6387
6498
  const path = string$2(input, "path");
6388
- const result = await service.cleanupMerged(path, string$2(input, "baseBranch"));
6389
- cleaned?.(path);
6499
+ const branch = typeof input.branch === "string" && input.branch.length > 0 ? input.branch : void 0;
6500
+ const baseBranch = typeof input.baseBranch === "string" && input.baseBranch.length > 0 ? input.baseBranch : void 0;
6501
+ const result = await service.cleanupMerged(path, baseBranch, branch, input.requirePushed === true);
6502
+ cleaned?.(path, branch);
6390
6503
  return json(res, 200, result);
6391
6504
  }
6392
6505
  if (pathname === `/plugins/dsh-claude/repository/setup/sweep`) {
@@ -6490,6 +6603,9 @@ function actionRequest(input) {
6490
6603
  ...input.admin === void 0 ? {} : typeof input.admin === "boolean" ? { admin: input.admin } : (() => {
6491
6604
  throw new RepositoryActionError("invalid-request", "The admin field must be a boolean.");
6492
6605
  })(),
6606
+ ...input.pullNumber === void 0 ? {} : Number.isSafeInteger(input.pullNumber) && input.pullNumber > 0 ? { pullNumber: input.pullNumber } : (() => {
6607
+ throw new RepositoryActionError("invalid-request", "The pullNumber field must be a positive integer.");
6608
+ })(),
6493
6609
  ...input.mergeMethod === void 0 ? {} : input.mergeMethod === "merge" || input.mergeMethod === "squash" || input.mergeMethod === "rebase" ? { mergeMethod: input.mergeMethod } : (() => {
6494
6610
  throw new RepositoryActionError("invalid-request", "The mergeMethod field is invalid.");
6495
6611
  })()
@@ -8522,30 +8638,121 @@ const FILE_TOOLS = /* @__PURE__ */ new Set([
8522
8638
  "NotebookEdit"
8523
8639
  ]);
8524
8640
  /** The activity detail is a redacted JSON string that may be cut short, so
8525
- * this matches the one key rather than parsing the document. */
8641
+ * these match one key each rather than parsing the document. */
8526
8642
  const PATH_KEY = /"(?:file_path|notebook_path)"\s*:\s*"((?:[^"\\]|\\.)*)"/u;
8527
- /** Absolute paths Claude wrote through its file tools, first-seen order. */
8643
+ const COMMAND_KEY = /"command"\s*:\s*"((?:[^"\\]|\\.)*)"/u;
8644
+ /** An absolute path token in shell text: not the tail of a URL, a relative
8645
+ * path, a `$VAR` or a `NAME=` assignment, and not reaching into quotes or
8646
+ * shell punctuation. */
8647
+ const PATH_TOKEN = String.raw`\/[\w.@+~-]+(?:\/[\w.@+~-]+)*`;
8648
+ /** Where a command goes to work or writes: the shell forms Claude actually
8649
+ * uses under full access. Reading a path (grep, cat, ls) is not touching it. */
8650
+ const WRITE_CONTEXTS = [
8651
+ new RegExp(String.raw`(?:^|[;&|(]\s*)(?:cd|pushd)\s+(${PATH_TOKEN})`, "gmu"),
8652
+ new RegExp(String.raw`\bgit\s+-C\s+(${PATH_TOKEN})`, "gu"),
8653
+ new RegExp(String.raw`\bgit\s+worktree\s+add\s+(?:-\S+\s+)*(${PATH_TOKEN})`, "gu"),
8654
+ new RegExp(String.raw`>{1,2}\s*(${PATH_TOKEN})`, "gu"),
8655
+ new RegExp(String.raw`\btee\s+(?:-\S+\s+)*(${PATH_TOKEN})`, "gu"),
8656
+ new RegExp(String.raw`\bmkdir\s+(?:-\S+\s+)*(${PATH_TOKEN})`, "gu"),
8657
+ new RegExp(String.raw`\bsed\s+-i\S*\s+(?:(?:'[^']*'|"[^"]*"|\S+)\s+)+?(${PATH_TOKEN})`, "gu"),
8658
+ new RegExp(String.raw`\b(?:cp|mv|install)\s+(?:\S+\s+)+?(${PATH_TOKEN})`, "gu"),
8659
+ new RegExp(String.raw`\bopen\(\s*['"](${PATH_TOKEN})['"]\s*,\s*['"][wa]`, "gu")
8660
+ ];
8661
+ /** A pathological command (a generated file list) must not turn into a
8662
+ * hundred git probes. */
8663
+ const MAX_PATHS_PER_COMMAND = 50;
8664
+ const PULL_REQUEST_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d{1,9})(?![\d])/gu;
8665
+ const MAX_PULL_REQUESTS = 8;
8666
+ function unescaped(escaped) {
8667
+ try {
8668
+ return JSON.parse(`"${escaped}"`);
8669
+ } catch {
8670
+ return;
8671
+ }
8672
+ }
8673
+ /** Absolute paths Claude worked in or wrote to, first-seen order: the file
8674
+ * tools' own argument, plus the paths a Bash command changes into, sets a
8675
+ * worktree up at, or writes -- under full access Claude edits through
8676
+ * heredocs and sed. Paths outside any repository are dropped downstream. */
8528
8677
  function touchedFilePaths(activities) {
8529
8678
  const paths = /* @__PURE__ */ new Set();
8530
8679
  for (const activity of activities) {
8531
- if (activity.kind !== "tool-call" && activity.kind !== "subagent" || activity.toolName === void 0 || !FILE_TOOLS.has(activity.toolName) || activity.detail === void 0) continue;
8532
- const escaped = PATH_KEY.exec(activity.detail)?.[1];
8533
- if (escaped === void 0) continue;
8534
- try {
8535
- const path = JSON.parse(`"${escaped}"`);
8536
- if (isAbsolute(path)) paths.add(path);
8537
- } catch {}
8680
+ if (activity.kind !== "tool-call" && activity.kind !== "subagent" || activity.toolName === void 0 || activity.detail === void 0) continue;
8681
+ if (FILE_TOOLS.has(activity.toolName)) {
8682
+ const escaped = PATH_KEY.exec(activity.detail)?.[1];
8683
+ const path = escaped === void 0 ? void 0 : unescaped(escaped);
8684
+ if (path !== void 0 && isAbsolute(path)) paths.add(path);
8685
+ } else if (activity.toolName === "Bash") {
8686
+ const escaped = COMMAND_KEY.exec(activity.detail)?.[1];
8687
+ const command = escaped === void 0 ? void 0 : unescaped(escaped);
8688
+ if (command === void 0) continue;
8689
+ const found = [];
8690
+ for (const context of WRITE_CONTEXTS) for (const match of command.matchAll(context)) {
8691
+ const path = match[1];
8692
+ if (path !== void 0 && !path.startsWith("/dev/")) found.push({
8693
+ index: match.index + match[0].length - path.length,
8694
+ path
8695
+ });
8696
+ }
8697
+ found.sort((left, right) => left.index - right.index);
8698
+ for (const { path } of found.slice(0, MAX_PATHS_PER_COMMAND)) paths.add(path);
8699
+ }
8538
8700
  }
8539
8701
  return [...paths];
8540
8702
  }
8703
+ const PR_CREATE = /\bgh\s+pr\s+create\b/u;
8704
+ /** The pull requests the session opened: the URL `gh pr create` printed,
8705
+ * read off that call's own result. Other than the session repository's,
8706
+ * once each in first-seen order. A URL merely read, quoted or mentioned
8707
+ * (a fixture, a summary, a `gh pr view`) is not one the session made.
8708
+ * A checkout that has since moved back to its base branch no longer knows
8709
+ * about its pull request; the log still does. */
8710
+ function touchedPullRequests(activities, ownRepository) {
8711
+ const found = /* @__PURE__ */ new Map();
8712
+ const own = ownRepository?.toLowerCase();
8713
+ const creating = /* @__PURE__ */ new Set();
8714
+ for (const activity of activities) {
8715
+ if (activity.toolUseId === void 0) continue;
8716
+ if (activity.kind === "tool-call" || activity.kind === "subagent" && activity.toolName !== void 0) {
8717
+ if (activity.toolName !== "Bash" || activity.detail === void 0) continue;
8718
+ const escaped = COMMAND_KEY.exec(activity.detail)?.[1];
8719
+ const command = escaped === void 0 ? void 0 : unescaped(escaped);
8720
+ if (command !== void 0 && PR_CREATE.test(command)) creating.add(activity.toolUseId);
8721
+ continue;
8722
+ }
8723
+ if (activity.detail === void 0 || !creating.has(activity.toolUseId)) continue;
8724
+ for (const match of activity.detail.matchAll(PULL_REQUEST_URL)) {
8725
+ const repository = match[1]?.toLowerCase();
8726
+ const number = Number(match[2]);
8727
+ if (repository === void 0 || repository === own || !Number.isSafeInteger(number) || number <= 0) continue;
8728
+ const key = `${repository}#${number}`;
8729
+ if (found.has(key)) continue;
8730
+ found.set(key, {
8731
+ repository,
8732
+ number
8733
+ });
8734
+ if (found.size >= MAX_PULL_REQUESTS) return [...found.values()];
8735
+ }
8736
+ }
8737
+ return [...found.values()];
8738
+ }
8739
+ async function isDirectory(path) {
8740
+ try {
8741
+ return (await stat(path)).isDirectory();
8742
+ } catch {
8743
+ return false;
8744
+ }
8745
+ }
8541
8746
  /** Repository roots behind the touched paths, minus the session's own, in
8542
8747
  * first-seen order. `rootOf` answers undefined outside any repository. */
8543
- async function touchedRepositoryRoots(paths, sessionRoot, rootOf, max) {
8748
+ async function touchedRepositoryRoots(paths, sessionRoot, rootOf, max, directory = isDirectory) {
8544
8749
  const roots = [];
8545
- const directories = new Set(paths.map((path) => dirname(path)));
8750
+ const directories = /* @__PURE__ */ new Set();
8751
+ for (const path of paths) directories.add(await directory(path) ? path : dirname(path));
8546
8752
  for (const directory of directories) {
8547
8753
  const root = await rootOf(directory);
8548
8754
  if (root === void 0 || root === sessionRoot || roots.includes(root)) continue;
8755
+ if (sessionRoot.startsWith(root.endsWith(sep) ? root : root + sep)) continue;
8549
8756
  roots.push(root);
8550
8757
  if (roots.length >= max) break;
8551
8758
  }
@@ -8553,13 +8760,52 @@ async function touchedRepositoryRoots(paths, sessionRoot, rootOf, max) {
8553
8760
  }
8554
8761
  /** Whether a linked checkout still has anything to show. A cleaned-up
8555
8762
  * worktree is gone, and a checkout cleaned up in place is back on base with
8556
- * nothing pending -- either way its bar comes down. A clean checkout with an
8557
- * open or merged pull request, or unpushed work, stays. */
8763
+ * nothing pending -- either way its bar comes down. A checkout with an open
8764
+ * or merged pull request, or unpushed commits, stays. A clean branch with no
8765
+ * upstream is what a tool checkout looks like and is no evidence on its own. */
8558
8766
  function linkedRepositoryShown(status) {
8559
8767
  if (status.status !== "ready") return false;
8560
- return status.dirty === true || status.upstream === false || (status.ahead ?? 0) > 0 || status.pullRequest !== void 0;
8768
+ return status.dirty === true || (status.ahead ?? 0) > 0 || status.pullRequest !== void 0;
8561
8769
  }
8562
8770
  //#endregion
8771
+ //#region src/session-root-ledger.ts
8772
+ /** Which extra checkout roots a session's routes may act on.
8773
+ *
8774
+ * A session's linked repositories are re-derived on every projection sweep,
8775
+ * and any one sweep's git/gh probes can transiently fail -- dropping a
8776
+ * checkout the client is still showing a bar for. Replacing the authorised
8777
+ * set each sweep would then 409 that bar's controls until the next good
8778
+ * sweep, which reads as a control that silently does nothing. A session only
8779
+ * ever gains checkouts it has genuinely written into, so the ledger only
8780
+ * grows within a session's lifetime and never un-vouches a root a later
8781
+ * probe happened to miss. Bounded so a long session cannot grow it forever. */
8782
+ var SessionRootLedger = class {
8783
+ #roots = /* @__PURE__ */ new Map();
8784
+ #max;
8785
+ constructor(max) {
8786
+ this.#max = max;
8787
+ }
8788
+ /** Add the roots this sweep vouched for; existing ones stay. */
8789
+ vouch(sessionId, roots) {
8790
+ let set = this.#roots.get(sessionId);
8791
+ if (set === void 0) {
8792
+ set = /* @__PURE__ */ new Set();
8793
+ this.#roots.set(sessionId, set);
8794
+ }
8795
+ for (const root of roots) {
8796
+ set.delete(root);
8797
+ set.add(root);
8798
+ if (set.size > this.#max) set.delete(set.values().next().value);
8799
+ }
8800
+ }
8801
+ allows(sessionId, root) {
8802
+ return this.#roots.get(sessionId)?.has(root) === true;
8803
+ }
8804
+ forget(sessionId) {
8805
+ this.#roots.delete(sessionId);
8806
+ }
8807
+ };
8808
+ //#endregion
8563
8809
  //#region src/update-routes.ts
8564
8810
  const PLUGIN_PACKAGE_NAME = "@norman-else/dsh-claude";
8565
8811
  const MAX_MANIFEST_BYTES = 262144;
@@ -9569,28 +9815,52 @@ async function apply(ctx, config) {
9569
9815
  supervisor.limitsChanged();
9570
9816
  }
9571
9817
  });
9572
- registerRepositorySetupRoute(webCtx, repositorySetup, () => sweepWorktrees?.(), (path) => repositoryStatus.invalidate(path));
9818
+ /** Linked pull requests cleaned up, as `clone root + branch`: the log
9819
+ * still names them, so the sweep has to be told to stop listing them. */
9820
+ const cleanedLinked = /* @__PURE__ */ new Set();
9821
+ registerRepositorySetupRoute(webCtx, repositorySetup, () => sweepWorktrees?.(), (path, branch) => {
9822
+ repositoryStatus.invalidate(path);
9823
+ if (branch !== void 0) cleanedLinked.add(`${path}\0${branch}`);
9824
+ });
9573
9825
  registerRepositoryStatusRoute(webCtx, repositoryStatus);
9574
9826
  registerRepositoryFileRoute(webCtx, repositoryStatus);
9575
9827
  registerJiraRoute(webCtx, new JiraService());
9576
9828
  const repositoryActions = new RepositoryActionService(subprocess, supervisorConfig.executablePath, (cwd) => repositoryStatus.invalidate(cwd));
9577
- /** Roots the latest projection probe vouched for, per session: the only
9578
- * checkouts a route may act on besides the session's own. */
9579
- const extraRoots = /* @__PURE__ */ new Map();
9829
+ /** Checkouts besides its own a session's routes may act on: every root a
9830
+ * projection sweep has ever vouched for, so a transient probe failure in
9831
+ * one sweep does not un-authorise a linked bar the client still shows. */
9832
+ const extraRoots = new SessionRootLedger(32);
9580
9833
  const cwdForClaudeSession = (sessionId, root) => {
9581
9834
  const agent = webCtx.agents.get(sessionId);
9582
9835
  if (agent === void 0 || webCtx.agentPresets.composedPreset(agent.ctx) !== "claude") return void 0;
9583
9836
  if (root === void 0) return agent.session.header.cwd;
9584
- return extraRoots.get(sessionId)?.includes(root) === true ? root : void 0;
9837
+ return extraRoots.allows(sessionId, root) ? root : void 0;
9585
9838
  };
9586
9839
  const extraRepositoriesForClaudeSession = async (sessionId, activities) => {
9587
9840
  const cwd = cwdForClaudeSession(sessionId);
9588
9841
  if (cwd === void 0) return [];
9589
- const own = await repositoryStatus.rootOf(cwd);
9842
+ const [own, ownStatus] = await Promise.all([repositoryStatus.rootOf(cwd), repositoryStatus.inspect(cwd)]);
9590
9843
  const roots = await touchedRepositoryRoots(touchedFilePaths(activities), own ?? cwd, (directory) => repositoryStatus.rootOf(directory), MAX_EXTRA_REPOSITORIES);
9591
- const statuses = (await Promise.all(roots.map((root) => repositoryStatus.inspect(root)))).filter(linkedRepositoryShown);
9592
- extraRoots.set(sessionId, statuses.map((status) => status.root ?? status.cwd));
9593
- return statuses;
9844
+ const probed = await Promise.all(roots.map((root) => repositoryStatus.inspect(root)));
9845
+ const checkouts = probed.filter(linkedRepositoryShown);
9846
+ const covered = new Set(checkouts.map((status) => `${status.remote?.toLowerCase()}#${status.pullRequest?.number}`));
9847
+ const pullRequests = touchedPullRequests(activities, ownStatus.remote).filter((item) => !covered.has(`${item.repository}#${item.number}`)).slice(0, Math.max(0, MAX_EXTRA_REPOSITORIES - checkouts.length));
9848
+ const detached = (await Promise.all(pullRequests.map(async (item) => {
9849
+ const clone = probed.find((status) => status.status === "ready" && status.remote?.toLowerCase() === item.repository && status.root !== void 0);
9850
+ const status = await repositoryStatus.inspectPullRequest(clone?.root ?? cwd, item.repository, item.number);
9851
+ if (clone?.root === void 0 || status.status !== "ready") return status;
9852
+ if (status.branch !== void 0 && cleanedLinked.has(`${clone.root}\0${status.branch}`)) return {
9853
+ status: "unavailable",
9854
+ cwd
9855
+ };
9856
+ return {
9857
+ ...status,
9858
+ root: clone.root
9859
+ };
9860
+ }))).filter(linkedRepositoryShown);
9861
+ const linked = [...checkouts, ...detached];
9862
+ extraRoots.vouch(sessionId, linked.flatMap((status) => status.root === void 0 ? [] : [status.root]));
9863
+ return linked;
9594
9864
  };
9595
9865
  registerRepositoryActionRoute(webCtx, repositoryActions, cwdForClaudeSession);
9596
9866
  registerClaudePromptsRoute(webCtx);