@norman-else/dsh-claude 0.1.36 → 0.1.38

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
@@ -3,12 +3,12 @@ import { a as projectClaudeCommands, i as CLAUDE_COMMANDS_SERVICE, r as dynamicP
3
3
  import { a as resolveClaudeExecutable, n as ensureManagedPreset, o as runClaudeDoctor, t as ManagedPresetConflictError } from "./preset-installer-loenwnLS.mjs";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
- import { chmod, mkdir, opendir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { chmod, mkdir, opendir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
7
7
  import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
8
8
  import { dshHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
9
9
  import { homedir } from "node:os";
10
10
  import { query } from "@anthropic-ai/claude-agent-sdk";
11
- import { CallId, LlmAdapter, ReasoningEffortId, createToolResultMessage } from "@deepseek-ai/dsh-llm";
11
+ import { LlmAdapter, ReasoningEffortId, ToolCallId, createToolResultMessage } from "@deepseek-ai/dsh-llm";
12
12
  import { EventEmitter } from "node:events";
13
13
  import { DSH_ENV_PREFIX, SENSITIVE_ENV_PATTERN } from "@deepseek-ai/dsh-subprocess";
14
14
  import { fileURLToPath } from "node:url";
@@ -2067,7 +2067,7 @@ var ClaudeSupervisor = class {
2067
2067
  await active.agent.session.append("tool/call", {
2068
2068
  turn: active.cursor.turn,
2069
2069
  step: active.cursor.step,
2070
- callId: CallId(message.toolUseId),
2070
+ callId: ToolCallId(message.toolUseId),
2071
2071
  name: message.toolName,
2072
2072
  arguments: safeDetail(message.input) ?? "{}"
2073
2073
  });
@@ -2080,7 +2080,7 @@ var ClaudeSupervisor = class {
2080
2080
  turn: active.cursor.turn,
2081
2081
  step: active.cursor.step,
2082
2082
  message: createToolResultMessage({
2083
- callId: CallId(message.toolUseId),
2083
+ callId: ToolCallId(message.toolUseId),
2084
2084
  content: [{
2085
2085
  type: "text",
2086
2086
  text
@@ -3352,16 +3352,23 @@ function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSes
3352
3352
  const info = (message) => {
3353
3353
  ctx.logger?.info?.(message);
3354
3354
  };
3355
- const assembleMeta = async (sessionId) => {
3355
+ /** Everything the host already knows, without touching the repository. */
3356
+ const localMeta = (sessionId) => {
3356
3357
  const owned = ownsSession(sessionId);
3357
- const repository = owned ? await repositoryForSession(sessionId) : void 0;
3358
3358
  return {
3359
3359
  owned,
3360
3360
  commands: commandsForSession(sessionId),
3361
- ...repository === void 0 ? {} : { repository },
3362
3361
  reviewComments: owned ? reviewCommentsForSession(sessionId) : []
3363
3362
  };
3364
3363
  };
3364
+ const assembleMeta = async (sessionId) => {
3365
+ const meta = localMeta(sessionId);
3366
+ const repository = meta.owned ? await repositoryForSession(sessionId) : void 0;
3367
+ return {
3368
+ ...meta,
3369
+ ...repository === void 0 ? {} : { repository }
3370
+ };
3371
+ };
3365
3372
  const streamMulti = async (res, io, sessionIds) => {
3366
3373
  info(`dsh-claude: projection stream opened for ${sessionIds.length} session(s)`);
3367
3374
  let textDeltas = 0;
@@ -3383,8 +3390,20 @@ function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSes
3383
3390
  }
3384
3391
  };
3385
3392
  const metas = /* @__PURE__ */ new Map();
3393
+ const writeMeta = (sessionId, meta) => {
3394
+ metas.set(sessionId, meta);
3395
+ writeLine({
3396
+ type: "meta",
3397
+ session: sessionId,
3398
+ owned: meta.owned,
3399
+ commands: meta.commands,
3400
+ ...meta.repository === void 0 ? {} : { repository: meta.repository },
3401
+ reviewComments: meta.reviewComments
3402
+ });
3403
+ };
3386
3404
  const writeSnapshot = async (sessionId) => {
3387
- const [projection, meta] = await Promise.all([sidecar.read(sessionId), assembleMeta(sessionId)]);
3405
+ const projection = await sidecar.read(sessionId);
3406
+ const meta = metas.get(sessionId) ?? localMeta(sessionId);
3388
3407
  metas.set(sessionId, meta);
3389
3408
  writeLine({
3390
3409
  type: "snapshot",
@@ -3392,6 +3411,9 @@ function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSes
3392
3411
  seq: sidecar.sequence(sessionId),
3393
3412
  ...envelope(projection, meta)
3394
3413
  });
3414
+ const probed = await assembleMeta(sessionId);
3415
+ if (closed || JSON.stringify(probed) === JSON.stringify(metas.get(sessionId))) return;
3416
+ writeMeta(sessionId, probed);
3395
3417
  };
3396
3418
  const unsubscribes = sessionIds.map((sessionId) => sidecar.subscribe(sessionId, (delta) => {
3397
3419
  switch (delta.kind) {
@@ -3458,15 +3480,7 @@ function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSes
3458
3480
  const next = await assembleMeta(sessionId);
3459
3481
  if (closed) return;
3460
3482
  if (JSON.stringify(next) === JSON.stringify(metas.get(sessionId))) continue;
3461
- metas.set(sessionId, next);
3462
- writeLine({
3463
- type: "meta",
3464
- session: sessionId,
3465
- owned: next.owned,
3466
- commands: next.commands,
3467
- ...next.repository === void 0 ? {} : { repository: next.repository },
3468
- reviewComments: next.reviewComments
3469
- });
3483
+ writeMeta(sessionId, next);
3470
3484
  }
3471
3485
  writeLine({ type: "ping" });
3472
3486
  })().catch(() => void 0);
@@ -3977,6 +3991,90 @@ var RepositoryStatusService = class {
3977
3991
  }
3978
3992
  };
3979
3993
  //#endregion
3994
+ //#region src/branch-name.ts
3995
+ /** Name a generated worktree branch after what the user is about to ask for.
3996
+ *
3997
+ * The composer draft is the only description of the work that exists before
3998
+ * the session starts, and it is usually not English and never branch-safe, so
3999
+ * a throwaway Haiku turn compresses it into a slug. Naming is a nicety: every
4000
+ * failure here returns `undefined` and the caller keeps its timestamped name. */
4001
+ /** Cheapest model that can translate and compress a sentence. */
4002
+ const BRANCH_SUMMARY_MODEL = "haiku";
4003
+ /** A branch name is not worth blocking worktree creation on for long. */
4004
+ const BRANCH_SUMMARY_TIMEOUT_MS = 15e3;
4005
+ const MAX_INTENT_CHARS = 2e3;
4006
+ /** A compliant reply is one short fragment; anything longer is prose. */
4007
+ const MAX_REPLY_CHARS$2 = 80;
4008
+ /** More words than this is a sentence, not the fragment we asked for. */
4009
+ const MAX_SLUG_WORDS = 6;
4010
+ const MAX_SLUG_CHARS = 48;
4011
+ function branchSummaryPrompt(intent) {
4012
+ return [
4013
+ "Summarize this software task as a Git branch name fragment.",
4014
+ "Reply with 2-5 lowercase English words joined by hyphens and nothing else:",
4015
+ "no quotes, no slashes, no prefix, no punctuation, no explanation.",
4016
+ "Translate the task to English if it is written in another language.",
4017
+ "",
4018
+ "Task:",
4019
+ `"""\n${intent.replaceAll("\"\"\"", "\" \" \"")}\n"""`
4020
+ ].join("\n");
4021
+ }
4022
+ /** Branch-safe slug for a model reply, or `undefined` when the reply is not
4023
+ * the fragment we asked for. Mangling a refusal or a paragraph into a slug
4024
+ * would produce a worse name than the timestamped fallback. */
4025
+ function branchSlug(reply) {
4026
+ const line = reply.trim();
4027
+ if (line.length === 0 || line.length > MAX_REPLY_CHARS$2 || /[\r\n]/u.test(line)) return void 0;
4028
+ const words = line.toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/gu, "-").split("-").filter((word) => word.length > 0);
4029
+ if (words.length === 0 || words.length > MAX_SLUG_WORDS) return void 0;
4030
+ const slug = words.join("-").slice(0, MAX_SLUG_CHARS).replace(/-+$/u, "");
4031
+ return slug.length === 0 ? void 0 : slug;
4032
+ }
4033
+ /** First free name in `<candidate>`, `<candidate>-2`, `<candidate>-3`, … */
4034
+ function uniqueBranchName(candidate, taken) {
4035
+ const existing = new Set(taken);
4036
+ if (!existing.has(candidate)) return candidate;
4037
+ for (let index = 2; index < 100; index += 1) {
4038
+ const name = `${candidate}-${index}`;
4039
+ if (!existing.has(name)) return name;
4040
+ }
4041
+ return candidate;
4042
+ }
4043
+ /** Compress a composer draft into a branch slug with a throwaway Claude turn.
4044
+ *
4045
+ * Deliberately NOT routed through the supervisor, for the same reasons as the
4046
+ * plan-usage probe: there is no session to borrow yet. The turn is isolated
4047
+ * from filesystem settings as well, because a CLAUDE.md instruction ("always
4048
+ * reply in the user's language") turns the answer into an unusable slug. */
4049
+ async function summarizeBranchSlug(executablePath, intent, factory = query) {
4050
+ const task = intent.trim().slice(0, MAX_INTENT_CHARS);
4051
+ if (task.length === 0) return void 0;
4052
+ const lifetime = new AbortController();
4053
+ const timer = setTimeout(() => lifetime.abort(), BRANCH_SUMMARY_TIMEOUT_MS);
4054
+ timer.unref?.();
4055
+ try {
4056
+ const query = factory({
4057
+ prompt: branchSummaryPrompt(task),
4058
+ options: {
4059
+ cwd: process.cwd(),
4060
+ abortController: lifetime,
4061
+ model: BRANCH_SUMMARY_MODEL,
4062
+ allowedTools: [],
4063
+ settingSources: [],
4064
+ maxTurns: 1,
4065
+ ...executablePath.length === 0 ? {} : { pathToClaudeCodeExecutable: executablePath }
4066
+ }
4067
+ });
4068
+ for await (const message of query) if (message.type === "result" && message.subtype === "success") return branchSlug(message.result);
4069
+ return;
4070
+ } catch {
4071
+ return;
4072
+ } finally {
4073
+ clearTimeout(timer);
4074
+ lifetime.abort();
4075
+ }
4076
+ }
4077
+ //#endregion
3980
4078
  //#region src/repository-setup.ts
3981
4079
  const MAX_OUTPUT_BYTES$3 = 131072;
3982
4080
  const GIT_TIMEOUT_MS$2 = 1e4;
@@ -4054,6 +4152,7 @@ var RepositorySetupService = class {
4054
4152
  #leasePath;
4055
4153
  #worktreeRoot;
4056
4154
  #branchPrefix;
4155
+ #summarizeBranch;
4057
4156
  #cleanupGraceMs;
4058
4157
  #gitPath;
4059
4158
  #pending = Promise.resolve();
@@ -4062,6 +4161,7 @@ var RepositorySetupService = class {
4062
4161
  this.#leasePath = options.leasePath ?? dshHomePath("plugins", "dsh-claude", "worktrees.json");
4063
4162
  this.#worktreeRoot = options.worktreeRoot ?? dshHomePath("plugins", "dsh-claude", "worktrees");
4064
4163
  this.#branchPrefix = options.branchPrefix ?? (async () => "claude");
4164
+ this.#summarizeBranch = options.summarizeBranch ?? (async () => void 0);
4065
4165
  this.#cleanupGraceMs = options.cleanupGraceMs ?? CLEANUP_GRACE_MS;
4066
4166
  }
4067
4167
  async listBranches(cwd) {
@@ -4108,7 +4208,7 @@ var RepositorySetupService = class {
4108
4208
  remoteBranches
4109
4209
  };
4110
4210
  }
4111
- async setup(cwd, branchValue, useWorktree, explicitBranchName, progress = () => {}) {
4211
+ async setup(cwd, branchValue, useWorktree, explicitBranchName, progress = () => {}, intent) {
4112
4212
  progress("inspecting");
4113
4213
  const branch = safeBranch(branchValue);
4114
4214
  const info = await this.listBranches(cwd);
@@ -4116,7 +4216,7 @@ var RepositorySetupService = class {
4116
4216
  const remote = info.remoteBranches.includes(branch);
4117
4217
  if (!local && !remote) throw new RepositorySetupError("branch-not-found", "The selected local or remote-tracking branch does not exist.");
4118
4218
  const requestedBranch = explicitBranchName === void 0 ? void 0 : safeBranch(explicitBranchName);
4119
- if (useWorktree) return this.#createWorktree(info.root, branch, local ? `refs/heads/${branch}` : `refs/remotes/${branch}`, requestedBranch, requestedBranch !== void 0 && info.branches.includes(requestedBranch), progress);
4219
+ if (useWorktree) return this.#createWorktree(info, branch, local ? `refs/heads/${branch}` : `refs/remotes/${branch}`, requestedBranch, requestedBranch !== void 0 && info.branches.includes(requestedBranch), progress, intent);
4120
4220
  progress("switching-branch");
4121
4221
  return !local && remote ? this.#checkoutRemote(info, branch) : this.#checkout(info, branch);
4122
4222
  }
@@ -4199,11 +4299,17 @@ var RepositorySetupService = class {
4199
4299
  });
4200
4300
  }
4201
4301
  /** Reconcile leases against the set of directories still referenced by a
4202
- * workspace: an unreferenced lease's clean worktree is removed. Fresh
4203
- * leases are retained for a grace period so a worktree created moments ago
4204
- * cannot be swept before its workspace registration lands, and dirty
4205
- * worktrees are always retained to avoid losing uncommitted work. */
4206
- cleanupOrphans(activePaths) {
4302
+ * workspace. Deleting a workspace is the user saying they are done with it,
4303
+ * so an unreferenced worktree goes even with uncommitted changes; its
4304
+ * sessions are archived first, through `archiveSessions`, or the Host
4305
+ * rebuilds the deleted workspace from their headers on the next boot.
4306
+ * Fresh leases are retained for a grace period so a worktree created
4307
+ * moments ago cannot be swept before its workspace registration lands.
4308
+ *
4309
+ * Every command runs from the repository root, never from the worktree
4310
+ * being removed: spawning in a directory that is already gone throws
4311
+ * ENOENT, which used to strand the lease forever. */
4312
+ cleanupOrphans(activePaths, archiveSessions) {
4207
4313
  const active = new Set(activePaths.map(comparablePath));
4208
4314
  return this.#serialize(async () => {
4209
4315
  const leases = await this.#readLeases();
@@ -4218,32 +4324,19 @@ var RepositorySetupService = class {
4218
4324
  continue;
4219
4325
  }
4220
4326
  try {
4221
- const status = await this.#run(git, [
4222
- "status",
4223
- "--porcelain=v1",
4224
- "--untracked-files=normal"
4225
- ], item.path);
4226
- if (status.exitCode !== 0 || status.lossy) {
4227
- if (await pathExists(item.path)) retained.push(item);
4228
- else {
4229
- await this.#run(git, ["worktree", "prune"], item.root).catch(() => void 0);
4230
- changed = true;
4327
+ await archiveSessions?.(item.path).catch(() => void 0);
4328
+ if (await pathExists(item.path)) {
4329
+ if ((await this.#run(git, [
4330
+ "worktree",
4331
+ "remove",
4332
+ "--force",
4333
+ "--",
4334
+ item.path
4335
+ ], item.root)).exitCode !== 0) {
4336
+ retained.push(item);
4337
+ continue;
4231
4338
  }
4232
- continue;
4233
- }
4234
- if (status.stdout.trim().length > 0) {
4235
- retained.push(item);
4236
- continue;
4237
- }
4238
- if ((await this.#run(git, [
4239
- "worktree",
4240
- "remove",
4241
- "--",
4242
- item.path
4243
- ], item.root)).exitCode !== 0) {
4244
- retained.push(item);
4245
- continue;
4246
- }
4339
+ } else await this.#run(git, ["worktree", "prune"], item.root).catch(() => void 0);
4247
4340
  if (item.pluginGeneratedBranch) await this.#run(git, [
4248
4341
  "branch",
4249
4342
  "-D",
@@ -4257,8 +4350,39 @@ var RepositorySetupService = class {
4257
4350
  }
4258
4351
  }
4259
4352
  if (changed) await this.#writeLeases(retained);
4353
+ await this.#removeUnleasedDirectories(retained, active, now, archiveSessions);
4260
4354
  });
4261
4355
  }
4356
+ /** Remove directories under the plugin's own worktree root that no lease and
4357
+ * no workspace claims. A lease file lost to a crash, or a worktree whose
4358
+ * lease write failed, otherwise leaves a directory nothing will ever sweep.
4359
+ * The grace period covers the gap between `worktree add` and the lease
4360
+ * write, so a worktree being created right now is never taken. */
4361
+ async #removeUnleasedDirectories(retained, active, now, archiveSessions) {
4362
+ const leased = new Set(retained.map((item) => comparablePath(item.path)));
4363
+ let entries;
4364
+ try {
4365
+ entries = await readdir(this.#worktreeRoot);
4366
+ } catch {
4367
+ return;
4368
+ }
4369
+ for (const entry of entries) {
4370
+ const path = join(this.#worktreeRoot, entry);
4371
+ const key = comparablePath(path);
4372
+ if (leased.has(key) || active.has(key)) continue;
4373
+ try {
4374
+ const info = await stat(path);
4375
+ if (!info.isDirectory()) continue;
4376
+ const created = info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs;
4377
+ if (Math.max(0, now - created) < this.#cleanupGraceMs) continue;
4378
+ await archiveSessions?.(path).catch(() => void 0);
4379
+ await rm(path, {
4380
+ recursive: true,
4381
+ force: true
4382
+ });
4383
+ } catch {}
4384
+ }
4385
+ }
4262
4386
  async #checkoutRemote(info, remoteBranch) {
4263
4387
  const separator = remoteBranch.indexOf("/");
4264
4388
  if (separator <= 0 || separator === remoteBranch.length - 1) throw new RepositorySetupError("invalid-branch", "The remote-tracking branch name is invalid.");
@@ -4325,13 +4449,14 @@ var RepositorySetupService = class {
4325
4449
  branch
4326
4450
  };
4327
4451
  }
4328
- async #createWorktree(root, baseBranch, baseRef, explicitBranchName, reuseExistingBranch, progress) {
4452
+ async #createWorktree(info, baseBranch, baseRef, explicitBranchName, reuseExistingBranch, progress, intent) {
4453
+ const { root } = info;
4329
4454
  const git = await this.#git();
4330
4455
  progress("fetching");
4331
4456
  await this.#fetchRemotes(git, root);
4332
4457
  const suffix = randomUUID().slice(0, 8);
4333
4458
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/gu, "").replace(/\.\d{3}Z$/u, "Z");
4334
- const branch = explicitBranchName ?? `${safeBranch(await this.#branchPrefix())}/${slug(baseBranch, "branch")}-${stamp}-${suffix}`;
4459
+ const branch = explicitBranchName ?? await this.#generatedBranch(info, baseBranch, intent, stamp, suffix, progress);
4335
4460
  const path = join(this.#worktreeRoot, `${slug(basename(root), "repository")}-${stamp}-${suffix}`);
4336
4461
  progress("creating-worktree");
4337
4462
  await mkdir(this.#worktreeRoot, { recursive: true });
@@ -4392,6 +4517,18 @@ var RepositorySetupService = class {
4392
4517
  leaseId: item.id
4393
4518
  };
4394
4519
  }
4520
+ /** `<prefix>/<what the draft is about>`, falling back to
4521
+ * `<prefix>/<base branch>-<stamp>-<random>` whenever the summary is missing
4522
+ * or unusable. The prefix and the fallback shape are unchanged. */
4523
+ async #generatedBranch(info, baseBranch, intent, stamp, suffix, progress) {
4524
+ const prefix = safeBranch(await this.#branchPrefix());
4525
+ if (intent !== void 0 && intent.trim().length > 0) {
4526
+ progress("summarizing");
4527
+ const summary = await this.#summarizeBranch(intent).catch(() => void 0);
4528
+ if (summary !== void 0) return uniqueBranchName(`${prefix}/${summary}`, info.branches);
4529
+ }
4530
+ return `${prefix}/${slug(baseBranch, "branch")}-${stamp}-${suffix}`;
4531
+ }
4395
4532
  async #repositoryRoot(git, cwd) {
4396
4533
  const result = await this.#run(git, [
4397
4534
  "rev-parse",
@@ -4964,7 +5101,7 @@ async function streamSetup(res, service, input) {
4964
5101
  type: "progress",
4965
5102
  stage
4966
5103
  });
4967
- })
5104
+ }, optionalString$1(input, "intent"))
4968
5105
  });
4969
5106
  } catch (error) {
4970
5107
  const setupError = error instanceof RepositorySetupError ? error : void 0;
@@ -4983,7 +5120,7 @@ async function streamSetup(res, service, input) {
4983
5120
  * The prefix is registered as a stream because the setup POST holds its
4984
5121
  * connection open for the whole worktree build; the short sibling paths ride
4985
5122
  * the same registration and answer with `json` before releasing it. */
4986
- function registerRepositorySetupRoute(ctx, service) {
5123
+ function registerRepositorySetupRoute(ctx, service, sweep) {
4987
5124
  registerPluginRoute(ctx, {
4988
5125
  mode: "stream",
4989
5126
  kind: "prefix",
@@ -5017,6 +5154,11 @@ function registerRepositorySetupRoute(ctx, service) {
5017
5154
  const input = await readJson$5(io);
5018
5155
  return json(res, 200, await service.cleanupMerged(string$2(input, "path"), string$2(input, "baseBranch")));
5019
5156
  }
5157
+ if (pathname === `/plugins/dsh-claude/repository/setup/sweep`) {
5158
+ if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
5159
+ sweep?.();
5160
+ return json(res, 200, { ok: true });
5161
+ }
5020
5162
  if (pathname === `/plugins/dsh-claude/repository/setup/bind`) {
5021
5163
  if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
5022
5164
  const input = await readJson$5(io);
@@ -7650,7 +7792,10 @@ async function apply(ctx, config) {
7650
7792
  await applySettingsOverrides();
7651
7793
  const sidecar = new ClaudeSidecarRepository();
7652
7794
  const repositoryStatus = new RepositoryStatusService(ctx.subprocess);
7653
- const repositorySetup = new RepositorySetupService(ctx.subprocess, { branchPrefix: () => readWorktreeBranchPrefix() });
7795
+ const repositorySetup = new RepositorySetupService(ctx.subprocess, {
7796
+ branchPrefix: () => readWorktreeBranchPrefix(),
7797
+ summarizeBranch: (intent) => summarizeBranchSlug(supervisorConfig.executablePath, intent)
7798
+ });
7654
7799
  const reviewComments = new ReviewCommentStore();
7655
7800
  const commandCatalogs = /* @__PURE__ */ new Map();
7656
7801
  const supervisor = new ClaudeSupervisor({
@@ -7728,19 +7873,34 @@ async function apply(ctx, config) {
7728
7873
  reviewComments.disposeSession(agent.id);
7729
7874
  await supervisor.disposeSession(agent.id);
7730
7875
  });
7876
+ let sweepWorktrees;
7731
7877
  const injectWorkspaceRegistry = ctx.inject;
7732
7878
  injectWorkspaceRegistry(["workspaceRegistry"], (sweepCtx) => {
7879
+ const archiveSessions = async (worktreePath) => {
7880
+ const persistence = sweepCtx.get("sessionPersistence");
7881
+ if (persistence === void 0) return;
7882
+ const target = comparablePath(worktreePath);
7883
+ for (const header of await persistence.list()) {
7884
+ if (typeof header.id !== "string" || typeof header.cwd !== "string") continue;
7885
+ if (comparablePath(header.cwd) !== target) continue;
7886
+ await sweepCtx.workspaceRegistry.archiveSession(header.id).catch(() => void 0);
7887
+ }
7888
+ };
7733
7889
  const sweep = () => {
7734
7890
  try {
7735
7891
  const paths = sweepCtx.workspaceRegistry.list().map((workspace) => workspace.path);
7736
- repositorySetup.cleanupOrphans(paths).catch(() => void 0);
7892
+ repositorySetup.cleanupOrphans(paths, archiveSessions).catch(() => void 0);
7737
7893
  } catch {}
7738
7894
  };
7739
7895
  sweepCtx.effect(() => {
7740
7896
  sweep();
7897
+ sweepWorktrees = sweep;
7741
7898
  const timer = setInterval(sweep, 6e4);
7742
7899
  timer.unref?.();
7743
- return () => clearInterval(timer);
7900
+ return () => {
7901
+ sweepWorktrees = void 0;
7902
+ clearInterval(timer);
7903
+ };
7744
7904
  }, "dsh-claude: worktree reconciliation");
7745
7905
  });
7746
7906
  ctx.effect(() => () => reviewComments.dispose(), "dsh-claude: review comments store");
@@ -7755,7 +7915,7 @@ async function apply(ctx, config) {
7755
7915
  defaultLimits,
7756
7916
  onUpdated: applySettingsOverrides
7757
7917
  });
7758
- registerRepositorySetupRoute(webCtx, repositorySetup);
7918
+ registerRepositorySetupRoute(webCtx, repositorySetup, () => sweepWorktrees?.());
7759
7919
  registerRepositoryStatusRoute(webCtx, repositoryStatus);
7760
7920
  registerRepositoryFileRoute(webCtx, repositoryStatus);
7761
7921
  registerJiraRoute(webCtx, new JiraService());