@norman-else/dsh-claude 0.1.46 → 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
@@ -1,6 +1,6 @@
1
- import { A as CLAUDE_REPOSITORY_FEEDBACK_PATH, B as DEFAULT_CLAUDE_RENDER_MODE, C as CLAUDE_PROJECTION_PATH, D as CLAUDE_PROSE_MODES, E as CLAUDE_PROMPT_REFINE_PATH, F as CLAUDE_REWIND_PATH, G as isClaudeRenderMode, H as TASK_TOOL_NAMES, I as CLAUDE_UPDATE_CHECK_PATH, L as CLAUDE_UPDATE_PATH, M as CLAUDE_REPOSITORY_SETUP_PATH, N as CLAUDE_REPOSITORY_STATUS_PATH, O as CLAUDE_RENDER_MODES, P as CLAUDE_REVIEW_COMMENT_PATH, R as CLAUDE_USAGE_PATH, S as CLAUDE_PLAN_FEEDBACK_PATH, T as CLAUDE_PROMPT_NAME_PATH, U as isClaudeAlertMode, W as isClaudeProseMode, _ as CLAUDE_CODE_PROVIDER_IDS, a as latestClaudeTasks, b as CLAUDE_GLOBAL_SETTINGS_PATH, c as normalizeTasksEvent, d as CLAUDE_ACTIVITY_EVENT, f as CLAUDE_ALERT_MODES, g as CLAUDE_CODE_PROVIDER, h as CLAUDE_CODE_PRESET_ID, i as latestClaudeSessionBinding, j as CLAUDE_REPOSITORY_FILE_PATH, k as CLAUDE_REPOSITORY_ACTION_PATH, l as redactText, m as CLAUDE_CLIENT_DIAGNOSTICS_PATH, n as currentClaudeActivityCursor, o as normalizeActivity, p as CLAUDE_ASK_PATH, r as latestClaudeContextUsage, s as normalizeContextUsage, t as boundText, u as safeDetail, v as CLAUDE_DOCTOR_PATH, w as CLAUDE_PROMPTS_PATH, x as CLAUDE_JIRA_PATH, y as CLAUDE_EDITOR_OPEN_PATH, z as DEFAULT_CLAUDE_PROSE_MODE } from "./events-oovRTmX7.mjs";
2
- import { a as projectClaudeCommands, i as CLAUDE_COMMANDS_SERVICE, r as dynamicPresenterDefinition, t as CLAUDE_PRESENTER_NAMES } from "./presenters-BVWj7u0a.mjs";
3
- import { a as resolveClaudeExecutable, n as ensureManagedPreset, o as runClaudeDoctor, t as ManagedPresetConflictError } from "./preset-installer-CuosP3sA.mjs";
1
+ import { A as CLAUDE_REPOSITORY_FILE_PATH, C as CLAUDE_PROMPTS_PATH, D as CLAUDE_RENDER_MODES, E as CLAUDE_PROSE_MODES, F as CLAUDE_UPDATE_CHECK_PATH, H as isClaudeAlertMode, I as CLAUDE_UPDATE_PATH, L as CLAUDE_USAGE_PATH, M as CLAUDE_REPOSITORY_STATUS_PATH, N as CLAUDE_REVIEW_COMMENT_PATH, O as CLAUDE_REPOSITORY_ACTION_PATH, P as CLAUDE_REWIND_PATH, R as DEFAULT_CLAUDE_PROSE_MODE, S as CLAUDE_PROJECTION_PATH, T as CLAUDE_PROMPT_REFINE_PATH, U as isClaudeProseMode, V as TASK_TOOL_NAMES, W as isClaudeRenderMode, _ as CLAUDE_CODE_PROVIDER_IDS, a as latestClaudeTasks, b as CLAUDE_JIRA_PATH, c as normalizeTasksEvent, d as CLAUDE_ACTIVITY_EVENT, f as CLAUDE_ALERT_MODES, g as CLAUDE_CODE_PROVIDER, h as CLAUDE_CODE_PRESET_ID, i as latestClaudeSessionBinding, j as CLAUDE_REPOSITORY_SETUP_PATH, k as CLAUDE_REPOSITORY_FEEDBACK_PATH, l as redactText, m as CLAUDE_CLIENT_DIAGNOSTICS_PATH, n as currentClaudeActivityCursor, o as normalizeActivity, p as CLAUDE_ASK_PATH, r as latestClaudeContextUsage, s as normalizeContextUsage, t as boundText, u as safeDetail, v as CLAUDE_DOCTOR_PATH, w as CLAUDE_PROMPT_NAME_PATH, x as CLAUDE_PLAN_FEEDBACK_PATH, y as CLAUDE_GLOBAL_SETTINGS_PATH, z as DEFAULT_CLAUDE_RENDER_MODE } from "./events-B498uofA.mjs";
2
+ import { a as projectClaudeCommands, i as CLAUDE_COMMANDS_SERVICE, r as dynamicPresenterDefinition, t as CLAUDE_PRESENTER_NAMES } from "./presenters-CRFcjLSC.mjs";
3
+ import { a as resolveClaudeExecutable, n as ensureManagedPreset, o as runClaudeDoctor, t as ManagedPresetConflictError } from "./preset-installer-C4TTNV9L.mjs";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { chmod, mkdir, opendir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
@@ -1810,7 +1810,7 @@ function createManagedClaudeSpawner(runtime, executablePath, observe) {
1810
1810
  * honours `.gitignore`, so build output and `node_modules` are neither
1811
1811
  * captured nor removed.
1812
1812
  */
1813
- const MAX_OUTPUT_BYTES$5 = 65536;
1813
+ const MAX_OUTPUT_BYTES$4 = 65536;
1814
1814
  /** `add -A` walks the whole checkout; a large repository needs more than the
1815
1815
  * status probes' five seconds, and this sits in front of every turn. */
1816
1816
  const GIT_TIMEOUT_MS$4 = 3e4;
@@ -1835,8 +1835,8 @@ async function run$1(runtime, git, args, cwd, env = {}) {
1835
1835
  cwd,
1836
1836
  stdio: {
1837
1837
  stdin: "ignore",
1838
- stdout: { maxBytes: MAX_OUTPUT_BYTES$5 },
1839
- stderr: { maxBytes: MAX_OUTPUT_BYTES$5 }
1838
+ stdout: { maxBytes: MAX_OUTPUT_BYTES$4 },
1839
+ stderr: { maxBytes: MAX_OUTPUT_BYTES$4 }
1840
1840
  },
1841
1841
  graceMs: 1e3,
1842
1842
  signal: AbortSignal.timeout(GIT_TIMEOUT_MS$4),
@@ -1923,6 +1923,18 @@ const CLAUDE_MODE_BY_SANDBOX = {
1923
1923
  "workspace-write": "acceptEdits",
1924
1924
  "danger-full-access": "bypassPermissions"
1925
1925
  };
1926
+ /** Appended to the Claude Code system prompt on every session.
1927
+ *
1928
+ * The plan panel opens on exactly one signal: an `ExitPlanMode` call, whose
1929
+ * argument is the plan (see `planText` in permission.ts). Claude Code's own
1930
+ * plan mode says to make that call once the plan file is written, but
1931
+ * user-installed skills routinely say the opposite — "confirm the design in
1932
+ * prose before proceeding" — and a turn that ends on that question hands DSH
1933
+ * nothing to show: no record, no button, no panel, until the user types
1934
+ * "continue" and Claude makes the call it skipped. This line settles the
1935
+ * conflict in favour of the handoff, so the plan reaches the panel the moment
1936
+ * it is done rather than one message later. */
1937
+ const PLAN_MODE_HANDOFF_PROMPT = "When you are in plan mode and the plan is written, end the turn by calling ExitPlanMode with the plan. Do not end a plan-mode turn by asking the user for confirmation in prose: the user reads and approves the plan through ExitPlanMode, and a turn that stops short of that call shows them nothing.";
1926
1938
  /** Fold DSH's native access selector into Claude Code's closest permission mode. */
1927
1939
  function claudePermissionMode(events) {
1928
1940
  for (let index = events.length - 1; index >= 0; index -= 1) {
@@ -2072,8 +2084,7 @@ var ClaudeSupervisor = class {
2072
2084
  cwd: entry.cwd,
2073
2085
  model: entry.model,
2074
2086
  ...entry.thinkingMode === void 0 ? {} : { thinkingMode: entry.thinkingMode },
2075
- lastUsedAt: entry.lastUsedAt,
2076
- ...entry.process === void 0 ? {} : { pid: entry.process.handle.pid }
2087
+ lastUsedAt: entry.lastUsedAt
2077
2088
  }));
2078
2089
  }
2079
2090
  supportedCommands(agent, model = this.#config.defaultModel) {
@@ -2596,7 +2607,8 @@ var ClaudeSupervisor = class {
2596
2607
  ],
2597
2608
  systemPrompt: {
2598
2609
  type: "preset",
2599
- preset: "claude_code"
2610
+ preset: "claude_code",
2611
+ append: PLAN_MODE_HANDOFF_PROMPT
2600
2612
  },
2601
2613
  tools: {
2602
2614
  type: "preset",
@@ -4198,7 +4210,7 @@ function registerClaudeDoctorRoutes(ctx, runtime, supervisor, config, resolution
4198
4210
  }
4199
4211
  //#endregion
4200
4212
  //#region src/projection-routes.ts
4201
- const MAX_SESSION_ID_CHARS$7 = 1024;
4213
+ const MAX_SESSION_ID_CHARS$6 = 1024;
4202
4214
  /** Slow-moving metadata refresh and stream heartbeat cadence; deliberately off
4203
4215
  * the transcript hot path so git/gh latency never delays visible text. */
4204
4216
  const META_REFRESH_MS = 5e3;
@@ -4206,7 +4218,7 @@ const META_REFRESH_MS = 5e3;
4206
4218
  * so this cannot collide with one. */
4207
4219
  const MULTI_SEGMENT = "multi";
4208
4220
  function validSessionId(value) {
4209
- return value.length > 0 && value.length <= MAX_SESSION_ID_CHARS$7;
4221
+ return value.length > 0 && value.length <= MAX_SESSION_ID_CHARS$6;
4210
4222
  }
4211
4223
  function targetFromUrl(url) {
4212
4224
  const prefix = `${CLAUDE_PROJECTION_PATH}/`;
@@ -4536,15 +4548,22 @@ async function diffFuncnameArgs() {
4536
4548
  }
4537
4549
  //#endregion
4538
4550
  //#region src/repository-status.ts
4539
- const MAX_OUTPUT_BYTES$4 = 65536;
4551
+ const MAX_OUTPUT_BYTES$3 = 65536;
4552
+ /** What the diff panel receives: whole files packed under this budget. */
4540
4553
  const MAX_DIFF_BYTES = 262144;
4554
+ /** What git may hand back before packing; beyond this the diff is truncated. */
4555
+ const MAX_RAW_DIFF_BYTES = 8388608;
4556
+ /** One file's share of the panel; a lockfile or bundle past this is elided by name. */
4557
+ const MAX_FILE_PATCH_BYTES = 65536;
4541
4558
  const MAX_FILE_BYTES = 8388608;
4542
4559
  const MAX_UNTRACKED_DIFFS = 50;
4543
4560
  const GIT_TIMEOUT_MS$3 = 5e3;
4544
4561
  const GH_TIMEOUT_MS$1 = 8e3;
4545
4562
  const CACHE_TTL_MS = 5e3;
4563
+ const PULL_REQUEST_TTL_FACTOR = 12;
4546
4564
  const MAX_TEXT_CHARS = 1024;
4547
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";
4548
4567
  function bounded(value) {
4549
4568
  return value.trim().slice(0, MAX_TEXT_CHARS);
4550
4569
  }
@@ -4557,7 +4576,7 @@ async function collect$3(handle) {
4557
4576
  lossy: stdout?.lossy === true
4558
4577
  };
4559
4578
  }
4560
- async function run(runtime, executable, args, cwd, timeoutMs, maxBytes = MAX_OUTPUT_BYTES$4) {
4579
+ async function run(runtime, executable, args, cwd, timeoutMs, maxBytes = MAX_OUTPUT_BYTES$3) {
4561
4580
  const signal = AbortSignal.timeout(timeoutMs);
4562
4581
  return collect$3(runtime.spawn({
4563
4582
  argv: [executable, ...args],
@@ -4565,7 +4584,7 @@ async function run(runtime, executable, args, cwd, timeoutMs, maxBytes = MAX_OUT
4565
4584
  stdio: {
4566
4585
  stdin: "ignore",
4567
4586
  stdout: { maxBytes },
4568
- stderr: { maxBytes: MAX_OUTPUT_BYTES$4 }
4587
+ stderr: { maxBytes: MAX_OUTPUT_BYTES$3 }
4569
4588
  },
4570
4589
  graceMs: 1e3,
4571
4590
  signal,
@@ -4644,6 +4663,31 @@ async function detectRepositoryOperation(gitDir) {
4644
4663
  };
4645
4664
  }
4646
4665
  }
4666
+ /**
4667
+ * Pack a unified diff into a byte budget one whole file at a time.
4668
+ *
4669
+ * A single oversized file (a lockfile, a bundle) used to void the entire
4670
+ * patch, which read as "no changes" in the panel. Files are kept in git's
4671
+ * order while they fit; a file past its own cap or past what is left of the
4672
+ * budget is skipped and named, so the panel still shows every other change.
4673
+ */
4674
+ function packPatchByFile(patch, budget = MAX_DIFF_BYTES, perFile = MAX_FILE_PATCH_BYTES) {
4675
+ let packed = "";
4676
+ const elided = [];
4677
+ for (const section of patch.split(/(?=^diff --git )/mu)) {
4678
+ if (section.length === 0) continue;
4679
+ if (section.length <= perFile && packed.length + section.length <= budget) {
4680
+ packed += section;
4681
+ continue;
4682
+ }
4683
+ const header = /^diff --git a\/(?<a>.*?) b\/(?<b>.*)$/mu.exec(section);
4684
+ elided.push(header?.groups?.["b"] ?? header?.groups?.["a"] ?? "?");
4685
+ }
4686
+ return {
4687
+ patch: packed,
4688
+ elided
4689
+ };
4690
+ }
4647
4691
  function parseDiffNumstat(value) {
4648
4692
  let additions = 0;
4649
4693
  let deletions = 0;
@@ -4719,6 +4763,7 @@ function parsePullRequest(value) {
4719
4763
  review: reviewState(input.reviewDecision),
4720
4764
  checks: aggregateChecks(input.statusCheckRollup),
4721
4765
  ...typeof input.mergeStateStatus === "string" ? { mergeState: bounded(input.mergeStateStatus) } : {},
4766
+ ...typeof input.headRefName === "string" && input.headRefName.length > 0 ? { headBranch: bounded(input.headRefName) } : {},
4722
4767
  ...typeof record$11(input.author)?.login === "string" ? { author: bounded(String(record$11(input.author)?.login)) } : {},
4723
4768
  ...typeof input.createdAt === "string" && Number.isFinite(Date.parse(input.createdAt)) ? { createdAt: new Date(input.createdAt).toISOString() } : {},
4724
4769
  ...typeof input.mergedAt === "string" && Number.isFinite(Date.parse(input.mergedAt)) ? { mergedAt: new Date(input.mergedAt).toISOString() } : {},
@@ -4759,6 +4804,75 @@ var RepositoryStatusService = class {
4759
4804
  value.catch(() => this.#cache.delete(cwd));
4760
4805
  return value;
4761
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
+ }
4762
4876
  invalidate(cwd) {
4763
4877
  this.#cache.delete(cwd);
4764
4878
  this.#lastReady.delete(cwd);
@@ -4956,19 +5070,20 @@ var RepositoryStatusService = class {
4956
5070
  "--unified=3",
4957
5071
  base,
4958
5072
  "--"
4959
- ], cwd, GIT_TIMEOUT_MS$3, MAX_DIFF_BYTES);
5073
+ ], cwd, GIT_TIMEOUT_MS$3, MAX_RAW_DIFF_BYTES);
4960
5074
  if (patch.exitCode !== 0) return {
4961
5075
  ...summary,
4962
5076
  truncated: true
4963
5077
  };
4964
5078
  const untracked = await this.#untrackedDiff(cwd, git, signal);
4965
- const combinedPatch = `${patch.stdout}${untracked.patch}`;
5079
+ const packed = packPatchByFile(`${patch.stdout}${untracked.patch}`);
4966
5080
  return {
4967
5081
  additions: summary.additions + untracked.additions,
4968
5082
  deletions: summary.deletions,
4969
5083
  files: summary.files + untracked.files,
4970
- ...patch.lossy ? {} : { patch: combinedPatch.slice(0, MAX_DIFF_BYTES) },
4971
- truncated: patch.lossy || untracked.truncated || combinedPatch.length > MAX_DIFF_BYTES
5084
+ ...patch.lossy ? {} : { patch: packed.patch },
5085
+ ...packed.elided.length === 0 ? {} : { elided: packed.elided },
5086
+ truncated: patch.lossy || untracked.truncated
4972
5087
  };
4973
5088
  } catch {
4974
5089
  return;
@@ -5037,7 +5152,7 @@ var RepositoryStatusService = class {
5037
5152
  "--repo",
5038
5153
  repository,
5039
5154
  "--json",
5040
- "number,title,url,state,isDraft,reviewDecision,mergeStateStatus,mergedAt,statusCheckRollup,author,createdAt,baseRefName"
5155
+ PULL_REQUEST_FIELDS
5041
5156
  ], cwd, GH_TIMEOUT_MS$1);
5042
5157
  if (result.exitCode !== 0) return void 0;
5043
5158
  return parsePullRequest(JSON.parse(result.stdout));
@@ -5132,7 +5247,7 @@ async function summarizeBranchSlug(executablePath, intent, factory = query) {
5132
5247
  }
5133
5248
  //#endregion
5134
5249
  //#region src/repository-setup.ts
5135
- const MAX_OUTPUT_BYTES$3 = 131072;
5250
+ const MAX_OUTPUT_BYTES$2 = 131072;
5136
5251
  const GIT_TIMEOUT_MS$2 = 1e4;
5137
5252
  const GIT_FETCH_TIMEOUT_MS = 6e4;
5138
5253
  const MAX_PATH_CHARS$2 = 4096;
@@ -5295,9 +5410,16 @@ var RepositorySetupService = class {
5295
5410
  /** Tear down a merged branch: remove a plugin worktree (and its lease and
5296
5411
  * branch), or switch a plain checkout back to the base branch and delete
5297
5412
  * the merged branch. Refuses dirty trees. */
5298
- 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) {
5299
5420
  const path = safePath(pathValue);
5300
- const base = safeBranch(baseBranch);
5421
+ const base = baseBranch === void 0 ? void 0 : safeBranch(baseBranch);
5422
+ const named = branch === void 0 ? void 0 : safeBranch(branch);
5301
5423
  const git = await this.#git();
5302
5424
  const status = await this.#run(git, [
5303
5425
  "status",
@@ -5307,36 +5429,56 @@ var RepositorySetupService = class {
5307
5429
  if (status.exitCode !== 0 || status.lossy) throw new RepositorySetupError("repository-unavailable", "The repository state is unavailable.");
5308
5430
  if (status.stdout.trim().length > 0) throw new RepositorySetupError("dirty-workspace", "Commit or stash workspace changes before cleaning up.");
5309
5431
  const lease = (await this.#readLeases()).find((item) => comparablePath(item.path) === comparablePath(path));
5310
- if (lease !== void 0) return this.#serialize(async () => {
5311
- if ((await this.#run(git, [
5312
- "worktree",
5313
- "remove",
5314
- "--",
5315
- lease.path
5316
- ], lease.root)).exitCode !== 0) throw new RepositorySetupError("worktree-remove-failed", "Git could not remove the worktree.");
5317
- await this.#run(git, [
5318
- "branch",
5319
- "-D",
5320
- "--",
5321
- lease.branch
5322
- ], lease.root).catch(() => void 0);
5323
- const current = await this.#readLeases();
5324
- await this.#writeLeases(current.filter((item) => item.id !== lease.id));
5325
- return {
5326
- mode: "worktree",
5327
- root: lease.root,
5328
- branch: lease.branch
5329
- };
5330
- });
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
+ }
5331
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.");
5332
5458
  const head = await this.#run(git, [
5333
5459
  "symbolic-ref",
5334
5460
  "--quiet",
5335
5461
  "--short",
5336
5462
  "HEAD"
5337
5463
  ], root);
5338
- const branch = head.exitCode === 0 ? head.stdout.trim() : "";
5339
- 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);
5340
5482
  if ((await this.#run(git, [
5341
5483
  "switch",
5342
5484
  "--",
@@ -5346,13 +5488,13 @@ var RepositorySetupService = class {
5346
5488
  "branch",
5347
5489
  "-D",
5348
5490
  "--",
5349
- branch
5491
+ current
5350
5492
  ], root).catch(() => void 0);
5351
5493
  await this.#run(git, ["pull", "--ff-only"], root, GIT_FETCH_TIMEOUT_MS).catch(() => void 0);
5352
5494
  return {
5353
5495
  mode: "checkout",
5354
5496
  root,
5355
- branch
5497
+ branch: current
5356
5498
  };
5357
5499
  }
5358
5500
  bindLease(leaseId, sessionId) {
@@ -5629,6 +5771,17 @@ var RepositorySetupService = class {
5629
5771
  }
5630
5772
  return `${prefix}/${slug(baseBranch, "branch")}-${stamp}-${suffix}`;
5631
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
+ }
5632
5785
  async #repositoryRoot(git, cwd) {
5633
5786
  const result = await this.#run(git, [
5634
5787
  "rev-parse",
@@ -5652,8 +5805,8 @@ var RepositorySetupService = class {
5652
5805
  cwd,
5653
5806
  stdio: {
5654
5807
  stdin: "ignore",
5655
- stdout: { maxBytes: MAX_OUTPUT_BYTES$3 },
5656
- stderr: { maxBytes: MAX_OUTPUT_BYTES$3 }
5808
+ stdout: { maxBytes: MAX_OUTPUT_BYTES$2 },
5809
+ stderr: { maxBytes: MAX_OUTPUT_BYTES$2 }
5657
5810
  },
5658
5811
  graceMs: 1e3,
5659
5812
  signal: AbortSignal.timeout(timeoutMs),
@@ -5694,7 +5847,7 @@ var RepositorySetupService = class {
5694
5847
  };
5695
5848
  //#endregion
5696
5849
  //#region src/repository-actions.ts
5697
- const MAX_OUTPUT_BYTES$2 = 262144;
5850
+ const MAX_OUTPUT_BYTES$1 = 262144;
5698
5851
  const MAX_PATCH_CHARS = 65536;
5699
5852
  const MAX_MESSAGE_CHARS = 512;
5700
5853
  const MAX_PR_TEXT_CHARS = 8192;
@@ -5867,6 +6020,7 @@ var RepositoryActionService = class {
5867
6020
  const merged = await this.#run(gh, [
5868
6021
  "pr",
5869
6022
  "merge",
6023
+ ...request.pullNumber === void 0 ? [] : [String(request.pullNumber)],
5870
6024
  `--${method}`,
5871
6025
  ...request.admin === true ? ["--admin"] : []
5872
6026
  ], before.root, REMOTE_TIMEOUT_MS);
@@ -6120,7 +6274,7 @@ var RepositoryActionService = class {
6120
6274
  "--",
6121
6275
  ":(exclude)WARP.md",
6122
6276
  ":(exclude)**/WARP.md"
6123
- ], root, GIT_TIMEOUT_MS$1, MAX_OUTPUT_BYTES$2),
6277
+ ], root, GIT_TIMEOUT_MS$1, MAX_OUTPUT_BYTES$1),
6124
6278
  this.#run(git, [
6125
6279
  ...funcname,
6126
6280
  "diff",
@@ -6130,7 +6284,7 @@ var RepositoryActionService = class {
6130
6284
  "--",
6131
6285
  ":(exclude)WARP.md",
6132
6286
  ":(exclude)**/WARP.md"
6133
- ], root, GIT_TIMEOUT_MS$1, MAX_OUTPUT_BYTES$2)
6287
+ ], root, GIT_TIMEOUT_MS$1, MAX_OUTPUT_BYTES$1)
6134
6288
  ]);
6135
6289
  if (branchResult.exitCode !== 0) throw new RepositoryActionError("detached-head", "A detached HEAD cannot be committed from this panel.");
6136
6290
  if (headResult.exitCode !== 0 || statusResult.exitCode !== 0 || statusResult.lossy) throw new RepositoryActionError("repository-unavailable", "Repository state is unavailable.");
@@ -6224,14 +6378,14 @@ var RepositoryActionService = class {
6224
6378
  if (result.exitCode !== 0 || result.lossy) throw new RepositoryActionError(code, message);
6225
6379
  return result;
6226
6380
  }
6227
- #run(executable, args, cwd, timeoutMs, maxBytes = MAX_OUTPUT_BYTES$2) {
6381
+ #run(executable, args, cwd, timeoutMs, maxBytes = MAX_OUTPUT_BYTES$1) {
6228
6382
  return collect$1(this.#runtime.spawn({
6229
6383
  argv: [executable, ...args],
6230
6384
  cwd,
6231
6385
  stdio: {
6232
6386
  stdin: "ignore",
6233
6387
  stdout: { maxBytes },
6234
- stderr: { maxBytes: MAX_OUTPUT_BYTES$2 }
6388
+ stderr: { maxBytes: MAX_OUTPUT_BYTES$1 }
6235
6389
  },
6236
6390
  graceMs: 1e3,
6237
6391
  signal: AbortSignal.timeout(timeoutMs),
@@ -6342,8 +6496,10 @@ function registerRepositorySetupRoute(ctx, service, sweep, cleaned) {
6342
6496
  if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
6343
6497
  const input = await readJson$6(io);
6344
6498
  const path = string$2(input, "path");
6345
- const result = await service.cleanupMerged(path, string$2(input, "baseBranch"));
6346
- 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);
6347
6503
  return json(res, 200, result);
6348
6504
  }
6349
6505
  if (pathname === `/plugins/dsh-claude/repository/setup/sweep`) {
@@ -6372,7 +6528,7 @@ function registerRepositorySetupRoute(ctx, service, sweep, cleaned) {
6372
6528
  //#endregion
6373
6529
  //#region src/repository-action-routes.ts
6374
6530
  const MAX_BODY_BYTES$6 = 16384;
6375
- const MAX_SESSION_ID_CHARS$6 = 1024;
6531
+ const MAX_SESSION_ID_CHARS$5 = 1024;
6376
6532
  const MAX_ROOT_CHARS = 4096;
6377
6533
  const ACTIONS = /* @__PURE__ */ new Set([
6378
6534
  "commit",
@@ -6409,7 +6565,7 @@ async function readJson$5(io) {
6409
6565
  }
6410
6566
  function sessionId$1(url) {
6411
6567
  const value = url.searchParams.get("sessionId");
6412
- if (value === null || value.length === 0 || value.length > MAX_SESSION_ID_CHARS$6) throw new RepositoryActionError("invalid-session", "The session is invalid.");
6568
+ if (value === null || value.length === 0 || value.length > MAX_SESSION_ID_CHARS$5) throw new RepositoryActionError("invalid-session", "The session is invalid.");
6413
6569
  return value;
6414
6570
  }
6415
6571
  function requestedRoot(url) {
@@ -6447,6 +6603,9 @@ function actionRequest(input) {
6447
6603
  ...input.admin === void 0 ? {} : typeof input.admin === "boolean" ? { admin: input.admin } : (() => {
6448
6604
  throw new RepositoryActionError("invalid-request", "The admin field must be a boolean.");
6449
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
+ })(),
6450
6609
  ...input.mergeMethod === void 0 ? {} : input.mergeMethod === "merge" || input.mergeMethod === "squash" || input.mergeMethod === "rebase" ? { mergeMethod: input.mergeMethod } : (() => {
6451
6610
  throw new RepositoryActionError("invalid-request", "The mergeMethod field is invalid.");
6452
6611
  })()
@@ -6528,187 +6687,6 @@ function registerRepositoryActionRoute(ctx, service, cwdForSession) {
6528
6687
  });
6529
6688
  }
6530
6689
  //#endregion
6531
- //#region src/editor-open.ts
6532
- const MAX_OUTPUT_BYTES$1 = 8192;
6533
- /** Long enough for a launcher shim to fail loudly, short enough that the
6534
- * request returns while the IDE is still booting. On Windows and Linux the
6535
- * IDE binary IS the launched process, so still running past this is success. */
6536
- const SETTLE_MS = 1500;
6537
- const EDITOR_IDS = /* @__PURE__ */ new Set(["cursor", "idea"]);
6538
- /** Launch commands tried in order, first success wins. macOS keeps `open -a`
6539
- * behind the CLI shim because both shims are opt-in installs there, while
6540
- * `open -a` finds the bundle wherever Toolbox or the DMG dropped it. */
6541
- const LAUNCHERS = {
6542
- cursor: {
6543
- darwin: [["cursor"], [
6544
- "open",
6545
- "-a",
6546
- "Cursor"
6547
- ]],
6548
- win32: [["cursor"]],
6549
- linux: [["cursor"]]
6550
- },
6551
- idea: {
6552
- darwin: [
6553
- ["idea"],
6554
- [
6555
- "open",
6556
- "-a",
6557
- "IntelliJ IDEA"
6558
- ],
6559
- [
6560
- "open",
6561
- "-a",
6562
- "IntelliJ IDEA CE"
6563
- ]
6564
- ],
6565
- win32: [["idea"], ["idea64.exe"]],
6566
- linux: [["idea"], ["idea.sh"]]
6567
- }
6568
- };
6569
- /** cmd.exe re-interprets these, and a mis-parsed path opens the wrong project
6570
- * rather than failing. Refuse instead of guessing. */
6571
- const WINDOWS_UNSAFE = /["&|<>^%]/u;
6572
- var EditorOpenError = class extends Error {
6573
- code;
6574
- constructor(code, message) {
6575
- super(message);
6576
- this.name = "EditorOpenError";
6577
- this.code = code;
6578
- }
6579
- };
6580
- /** Open a session's working directory in a desktop editor. */
6581
- var EditorOpenService = class {
6582
- #runtime;
6583
- #platform;
6584
- #settleMs;
6585
- constructor(runtime, platform = process.platform, settleMs = SETTLE_MS) {
6586
- this.#runtime = runtime;
6587
- this.#platform = platform;
6588
- this.#settleMs = settleMs;
6589
- }
6590
- async open(cwd, editor) {
6591
- if (this.#platform === "win32" && WINDOWS_UNSAFE.test(cwd)) throw new EditorOpenError("unsupported-path", "The project path cannot be opened through the Windows shell.");
6592
- const candidates = LAUNCHERS[editor][this.#platform] ?? LAUNCHERS[editor].linux ?? [];
6593
- let found = false;
6594
- for (const candidate of candidates) {
6595
- const argv = await this.#argv(candidate, cwd);
6596
- if (argv === void 0) continue;
6597
- found = true;
6598
- if (await this.#launch(argv, cwd)) return;
6599
- }
6600
- throw found ? new EditorOpenError("launch-failed", "The editor refused to open the project.") : new EditorOpenError("editor-unavailable", "The editor was not found on PATH.");
6601
- }
6602
- async #argv(candidate, cwd) {
6603
- const [program, ...rest] = candidate;
6604
- if (program === void 0) return void 0;
6605
- if (this.#platform === "win32") return [
6606
- "cmd.exe",
6607
- "/d",
6608
- "/s",
6609
- "/c",
6610
- program,
6611
- ...rest,
6612
- cwd
6613
- ];
6614
- try {
6615
- return [
6616
- await this.#runtime.resolveExecutable(program),
6617
- ...rest,
6618
- cwd
6619
- ];
6620
- } catch {
6621
- return;
6622
- }
6623
- }
6624
- /** True once the editor is launched: either the shim exited cleanly or the
6625
- * process is still alive past the settle window. */
6626
- async #launch(argv, cwd) {
6627
- let handle;
6628
- try {
6629
- handle = this.#runtime.spawn({
6630
- argv,
6631
- cwd,
6632
- stdio: {
6633
- stdin: "ignore",
6634
- stdout: { maxBytes: MAX_OUTPUT_BYTES$1 },
6635
- stderr: { maxBytes: MAX_OUTPUT_BYTES$1 }
6636
- },
6637
- graceMs: 1e3,
6638
- env: {}
6639
- });
6640
- } catch {
6641
- return false;
6642
- }
6643
- return await Promise.race([handle.done.then((outcome) => outcome.exitCode === 0), new Promise((resolve) => {
6644
- setTimeout(() => resolve(true), this.#settleMs).unref?.();
6645
- })]);
6646
- }
6647
- };
6648
- //#endregion
6649
- //#region src/editor-open-routes.ts
6650
- const MAX_SESSION_ID_CHARS$5 = 1024;
6651
- /** Open the session's working directory in a desktop editor. Query-only: the
6652
- * request carries two enum-ish values, so there is no body to parse. */
6653
- function registerEditorOpenRoute(ctx, service, cwdForSession) {
6654
- registerPluginRoute(ctx, {
6655
- mode: "unary",
6656
- kind: "exact",
6657
- path: CLAUDE_EDITOR_OPEN_PATH,
6658
- methods: ["POST"],
6659
- budget: "fast",
6660
- handler: async (io) => {
6661
- const params = io.url.searchParams;
6662
- const id = params.get("sessionId");
6663
- const editor = params.get("editor");
6664
- if (id === null || id.length === 0 || id.length > MAX_SESSION_ID_CHARS$5) return {
6665
- status: 400,
6666
- value: {
6667
- error: "invalid-session",
6668
- message: "The session is invalid."
6669
- }
6670
- };
6671
- if (editor === null || !EDITOR_IDS.has(editor)) return {
6672
- status: 400,
6673
- value: {
6674
- error: "invalid-editor",
6675
- message: "The editor is invalid."
6676
- }
6677
- };
6678
- const cwd = cwdForSession(id);
6679
- if (cwd === void 0) return {
6680
- status: 409,
6681
- value: {
6682
- error: "session-unavailable",
6683
- message: "The Claude session is unavailable."
6684
- }
6685
- };
6686
- try {
6687
- await service.open(cwd, editor);
6688
- return {
6689
- status: 200,
6690
- value: { opened: true }
6691
- };
6692
- } catch (error) {
6693
- if (error instanceof EditorOpenError) return {
6694
- status: 409,
6695
- value: {
6696
- error: error.code,
6697
- message: error.message
6698
- }
6699
- };
6700
- return {
6701
- status: 500,
6702
- value: {
6703
- error: "editor-open-unavailable",
6704
- message: "The editor could not be launched."
6705
- }
6706
- };
6707
- }
6708
- }
6709
- });
6710
- }
6711
- //#endregion
6712
6690
  //#region src/prompts.ts
6713
6691
  const MAX_PROMPT_FILES = 256;
6714
6692
  const MAX_PROMPT_BYTES = 16384;
@@ -8660,30 +8638,121 @@ const FILE_TOOLS = /* @__PURE__ */ new Set([
8660
8638
  "NotebookEdit"
8661
8639
  ]);
8662
8640
  /** The activity detail is a redacted JSON string that may be cut short, so
8663
- * this matches the one key rather than parsing the document. */
8641
+ * these match one key each rather than parsing the document. */
8664
8642
  const PATH_KEY = /"(?:file_path|notebook_path)"\s*:\s*"((?:[^"\\]|\\.)*)"/u;
8665
- /** 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. */
8666
8677
  function touchedFilePaths(activities) {
8667
8678
  const paths = /* @__PURE__ */ new Set();
8668
8679
  for (const activity of activities) {
8669
- if (activity.kind !== "tool-call" && activity.kind !== "subagent" || activity.toolName === void 0 || !FILE_TOOLS.has(activity.toolName) || activity.detail === void 0) continue;
8670
- const escaped = PATH_KEY.exec(activity.detail)?.[1];
8671
- if (escaped === void 0) continue;
8672
- try {
8673
- const path = JSON.parse(`"${escaped}"`);
8674
- if (isAbsolute(path)) paths.add(path);
8675
- } 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
+ }
8676
8700
  }
8677
8701
  return [...paths];
8678
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
+ }
8679
8746
  /** Repository roots behind the touched paths, minus the session's own, in
8680
8747
  * first-seen order. `rootOf` answers undefined outside any repository. */
8681
- async function touchedRepositoryRoots(paths, sessionRoot, rootOf, max) {
8748
+ async function touchedRepositoryRoots(paths, sessionRoot, rootOf, max, directory = isDirectory) {
8682
8749
  const roots = [];
8683
- 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));
8684
8752
  for (const directory of directories) {
8685
8753
  const root = await rootOf(directory);
8686
8754
  if (root === void 0 || root === sessionRoot || roots.includes(root)) continue;
8755
+ if (sessionRoot.startsWith(root.endsWith(sep) ? root : root + sep)) continue;
8687
8756
  roots.push(root);
8688
8757
  if (roots.length >= max) break;
8689
8758
  }
@@ -8691,13 +8760,52 @@ async function touchedRepositoryRoots(paths, sessionRoot, rootOf, max) {
8691
8760
  }
8692
8761
  /** Whether a linked checkout still has anything to show. A cleaned-up
8693
8762
  * worktree is gone, and a checkout cleaned up in place is back on base with
8694
- * nothing pending -- either way its bar comes down. A clean checkout with an
8695
- * 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. */
8696
8766
  function linkedRepositoryShown(status) {
8697
8767
  if (status.status !== "ready") return false;
8698
- 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;
8699
8769
  }
8700
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
8701
8809
  //#region src/update-routes.ts
8702
8810
  const PLUGIN_PACKAGE_NAME = "@norman-else/dsh-claude";
8703
8811
  const MAX_MANIFEST_BYTES = 262144;
@@ -8967,6 +9075,54 @@ function registerClaudeUpdateRoutes(ctx, runtime, deps = {}) {
8967
9075
  });
8968
9076
  }
8969
9077
  //#endregion
9078
+ //#region src/windows-job-runner.ts
9079
+ const RUN_AS_NODE = "ELECTRON_RUN_AS_NODE";
9080
+ /**
9081
+ * Make the Host's Windows Job runner start as Node when the Host itself is an
9082
+ * Electron utility process.
9083
+ *
9084
+ * Since DSH Desktop 2.0.7 the Host runs in `utilityProcess.fork` and, on
9085
+ * Windows, `subprocess-local` launches every ordinary target through a helper
9086
+ * it spawns as `[process.execPath, runner.js]`. Inside Electron that path is
9087
+ * `DSH Desktop.exe`, and the fork carries no `ELECTRON_RUN_AS_NODE`, so the
9088
+ * helper starts as a second GUI instance, yields to the running one, and exits
9089
+ * 0 without ever reporting -- the Host then fails the spawn with "Windows Job
9090
+ * runner exited with exit code 0 before proving its managed range empty".
9091
+ *
9092
+ * The runner's environment is read from `process.env` synchronously inside
9093
+ * `spawn()`, so the flag is set for exactly that call and removed after. The
9094
+ * target's own environment is built the same way, so the spec tells the Host
9095
+ * to drop the flag there: a target must not inherit a variable that turns any
9096
+ * Electron app it launches into a bare Node process.
9097
+ */
9098
+ function withElectronNodeRunner(runtime, options = {}) {
9099
+ const platform = options.platform ?? process.platform;
9100
+ const electron = options.electron ?? process.versions.electron !== void 0;
9101
+ if (platform !== "win32" || !electron) return runtime;
9102
+ return new Proxy(runtime, { get(target, property, receiver) {
9103
+ if (property !== "spawn") {
9104
+ const value = Reflect.get(target, property, receiver);
9105
+ return typeof value === "function" ? value.bind(target) : value;
9106
+ }
9107
+ return (spec) => {
9108
+ const withoutFlag = {
9109
+ ...spec,
9110
+ env: {
9111
+ ...spec.env,
9112
+ [RUN_AS_NODE]: void 0
9113
+ }
9114
+ };
9115
+ if (process.env[RUN_AS_NODE] !== void 0) return target.spawn(withoutFlag);
9116
+ process.env[RUN_AS_NODE] = "1";
9117
+ try {
9118
+ return target.spawn(withoutFlag);
9119
+ } finally {
9120
+ delete process.env[RUN_AS_NODE];
9121
+ }
9122
+ };
9123
+ } });
9124
+ }
9125
+ //#endregion
8970
9126
  //#region src/plan-usage-routes.ts
8971
9127
  const EMPTY = {
8972
9128
  available: false,
@@ -9514,6 +9670,7 @@ async function installManagedPresetCompatibility(logger, install = ensureManaged
9514
9670
  }
9515
9671
  }
9516
9672
  async function apply(ctx, config) {
9673
+ const subprocess = withElectronNodeRunner(ctx.subprocess);
9517
9674
  await installManagedPresetCompatibility(ctx.logger);
9518
9675
  const defaultLimits = {
9519
9676
  idleTimeoutMs: config.idleTimeoutMs ?? 18e5,
@@ -9531,15 +9688,15 @@ async function apply(ctx, config) {
9531
9688
  };
9532
9689
  await applySettingsOverrides();
9533
9690
  const sidecar = new ClaudeSidecarRepository();
9534
- const repositoryStatus = new RepositoryStatusService(ctx.subprocess);
9535
- const repositorySetup = new RepositorySetupService(ctx.subprocess, {
9691
+ const repositoryStatus = new RepositoryStatusService(subprocess);
9692
+ const repositorySetup = new RepositorySetupService(subprocess, {
9536
9693
  branchPrefix: () => readWorktreeBranchPrefix(),
9537
9694
  summarizeBranch: (intent) => summarizeBranchSlug(supervisorConfig.executablePath, intent)
9538
9695
  });
9539
9696
  const reviewComments = new ReviewCommentStore();
9540
9697
  const commandCatalogs = /* @__PURE__ */ new Map();
9541
9698
  const supervisor = new ClaudeSupervisor({
9542
- runtime: ctx.subprocess,
9699
+ runtime: subprocess,
9543
9700
  approval: ctx.approval,
9544
9701
  userQuestions: ctx.userQuestions,
9545
9702
  config: supervisorConfig,
@@ -9548,7 +9705,7 @@ async function apply(ctx, config) {
9548
9705
  });
9549
9706
  let resolutionError;
9550
9707
  try {
9551
- supervisorConfig.executablePath = (await resolveClaudeExecutable(ctx.subprocess, config.executablePath === void 0 || config.executablePath.length === 0 ? void 0 : config.executablePath)).path;
9708
+ supervisorConfig.executablePath = (await resolveClaudeExecutable(subprocess, config.executablePath === void 0 || config.executablePath.length === 0 ? void 0 : config.executablePath)).path;
9552
9709
  ctx.llm.registerAdapter([...CLAUDE_CODE_PROVIDER_IDS], createClaudeCodeAdapter(supervisor, ctx.agents, ctx.attachments, (agent) => ctx.agentPresets.composedPreset(agent.ctx), (sessionId) => reviewComments.drain(sessionId), () => readRenderMode(), (request) => summarizeSessionTitle(supervisorConfig.executablePath, request), () => probeClaudeModels(supervisorConfig.executablePath)));
9553
9710
  ctx.effect(() => {
9554
9711
  const mounted = /* @__PURE__ */ new Map();
@@ -9648,9 +9805,9 @@ async function apply(ctx, config) {
9648
9805
  ctx.effect(() => () => repositoryStatus.dispose(), "dsh-claude: repository status cache");
9649
9806
  ctx.inject(["webServer"], (webCtx) => {
9650
9807
  registerClaudeClientDiagnosticsRoute(webCtx);
9651
- registerClaudeDoctorRoutes(webCtx, webCtx.subprocess, supervisor, supervisorConfig, resolutionError);
9808
+ registerClaudeDoctorRoutes(webCtx, subprocess, supervisor, supervisorConfig, resolutionError);
9652
9809
  const desktopActions = webCtx.get("desktopActions");
9653
- registerClaudeUpdateRoutes(webCtx, webCtx.subprocess, { ...typeof desktopActions?.requestRestart === "function" ? { requestRestart: desktopActions.requestRestart.bind(desktopActions) } : {} });
9810
+ registerClaudeUpdateRoutes(webCtx, subprocess, { ...typeof desktopActions?.requestRestart === "function" ? { requestRestart: desktopActions.requestRestart.bind(desktopActions) } : {} });
9654
9811
  registerClaudeGlobalSettingsRoute(webCtx, {
9655
9812
  defaultLimits,
9656
9813
  onUpdated: async () => {
@@ -9658,37 +9815,60 @@ async function apply(ctx, config) {
9658
9815
  supervisor.limitsChanged();
9659
9816
  }
9660
9817
  });
9661
- 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
+ });
9662
9825
  registerRepositoryStatusRoute(webCtx, repositoryStatus);
9663
9826
  registerRepositoryFileRoute(webCtx, repositoryStatus);
9664
9827
  registerJiraRoute(webCtx, new JiraService());
9665
- const repositoryActions = new RepositoryActionService(webCtx.subprocess, supervisorConfig.executablePath, (cwd) => repositoryStatus.invalidate(cwd));
9666
- /** Roots the latest projection probe vouched for, per session: the only
9667
- * checkouts a route may act on besides the session's own. */
9668
- const extraRoots = /* @__PURE__ */ new Map();
9828
+ const repositoryActions = new RepositoryActionService(subprocess, supervisorConfig.executablePath, (cwd) => repositoryStatus.invalidate(cwd));
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);
9669
9833
  const cwdForClaudeSession = (sessionId, root) => {
9670
9834
  const agent = webCtx.agents.get(sessionId);
9671
9835
  if (agent === void 0 || webCtx.agentPresets.composedPreset(agent.ctx) !== "claude") return void 0;
9672
9836
  if (root === void 0) return agent.session.header.cwd;
9673
- return extraRoots.get(sessionId)?.includes(root) === true ? root : void 0;
9837
+ return extraRoots.allows(sessionId, root) ? root : void 0;
9674
9838
  };
9675
9839
  const extraRepositoriesForClaudeSession = async (sessionId, activities) => {
9676
9840
  const cwd = cwdForClaudeSession(sessionId);
9677
9841
  if (cwd === void 0) return [];
9678
- const own = await repositoryStatus.rootOf(cwd);
9842
+ const [own, ownStatus] = await Promise.all([repositoryStatus.rootOf(cwd), repositoryStatus.inspect(cwd)]);
9679
9843
  const roots = await touchedRepositoryRoots(touchedFilePaths(activities), own ?? cwd, (directory) => repositoryStatus.rootOf(directory), MAX_EXTRA_REPOSITORIES);
9680
- const statuses = (await Promise.all(roots.map((root) => repositoryStatus.inspect(root)))).filter(linkedRepositoryShown);
9681
- extraRoots.set(sessionId, statuses.map((status) => status.root ?? status.cwd));
9682
- 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;
9683
9864
  };
9684
9865
  registerRepositoryActionRoute(webCtx, repositoryActions, cwdForClaudeSession);
9685
- registerEditorOpenRoute(webCtx, new EditorOpenService(webCtx.subprocess), cwdForClaudeSession);
9686
9866
  registerClaudePromptsRoute(webCtx);
9687
- const promptAssist = new PromptAssistService(webCtx.subprocess, () => supervisorConfig.executablePath);
9867
+ const promptAssist = new PromptAssistService(subprocess, () => supervisorConfig.executablePath);
9688
9868
  registerClaudePromptNameRoute(webCtx, promptAssist);
9689
9869
  registerClaudePromptRefineRoute(webCtx, promptAssist);
9690
- registerPullRequestFeedbackRoute(webCtx, new PullRequestFeedbackService(webCtx.subprocess), cwdForClaudeSession);
9691
- registerAskRoute(webCtx, new AskService(webCtx.subprocess, supervisorConfig.executablePath), cwdForClaudeSession, (sessionId) => {
9870
+ registerPullRequestFeedbackRoute(webCtx, new PullRequestFeedbackService(subprocess), cwdForClaudeSession);
9871
+ registerAskRoute(webCtx, new AskService(subprocess, supervisorConfig.executablePath), cwdForClaudeSession, (sessionId) => {
9692
9872
  const snapshot = supervisor.snapshots().find((item) => item.sessionId === sessionId);
9693
9873
  return snapshot === void 0 ? void 0 : {
9694
9874
  model: claudeModelValue(snapshot.model),
@@ -9710,7 +9890,7 @@ async function apply(ctx, config) {
9710
9890
  reset: (sessionId) => supervisor.disposeSession(sessionId),
9711
9891
  restoreFiles: async (sessionId, tree) => {
9712
9892
  const cwd = webCtx.agents.get(sessionId)?.session.header.cwd;
9713
- return cwd === void 0 ? false : restoreWorktreeTree(ctx.subprocess, cwd, tree);
9893
+ return cwd === void 0 ? false : restoreWorktreeTree(subprocess, cwd, tree);
9714
9894
  }
9715
9895
  });
9716
9896
  registerPlanUsageRoute(webCtx, (fetchedAt) => probePlanUsage(supervisorConfig.executablePath, fetchedAt));