@norman-else/dsh-claude 0.1.36 → 0.1.37

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
@@ -3977,6 +3977,90 @@ var RepositoryStatusService = class {
3977
3977
  }
3978
3978
  };
3979
3979
  //#endregion
3980
+ //#region src/branch-name.ts
3981
+ /** Name a generated worktree branch after what the user is about to ask for.
3982
+ *
3983
+ * The composer draft is the only description of the work that exists before
3984
+ * the session starts, and it is usually not English and never branch-safe, so
3985
+ * a throwaway Haiku turn compresses it into a slug. Naming is a nicety: every
3986
+ * failure here returns `undefined` and the caller keeps its timestamped name. */
3987
+ /** Cheapest model that can translate and compress a sentence. */
3988
+ const BRANCH_SUMMARY_MODEL = "haiku";
3989
+ /** A branch name is not worth blocking worktree creation on for long. */
3990
+ const BRANCH_SUMMARY_TIMEOUT_MS = 15e3;
3991
+ const MAX_INTENT_CHARS = 2e3;
3992
+ /** A compliant reply is one short fragment; anything longer is prose. */
3993
+ const MAX_REPLY_CHARS$2 = 80;
3994
+ /** More words than this is a sentence, not the fragment we asked for. */
3995
+ const MAX_SLUG_WORDS = 6;
3996
+ const MAX_SLUG_CHARS = 48;
3997
+ function branchSummaryPrompt(intent) {
3998
+ return [
3999
+ "Summarize this software task as a Git branch name fragment.",
4000
+ "Reply with 2-5 lowercase English words joined by hyphens and nothing else:",
4001
+ "no quotes, no slashes, no prefix, no punctuation, no explanation.",
4002
+ "Translate the task to English if it is written in another language.",
4003
+ "",
4004
+ "Task:",
4005
+ `"""\n${intent.replaceAll("\"\"\"", "\" \" \"")}\n"""`
4006
+ ].join("\n");
4007
+ }
4008
+ /** Branch-safe slug for a model reply, or `undefined` when the reply is not
4009
+ * the fragment we asked for. Mangling a refusal or a paragraph into a slug
4010
+ * would produce a worse name than the timestamped fallback. */
4011
+ function branchSlug(reply) {
4012
+ const line = reply.trim();
4013
+ if (line.length === 0 || line.length > MAX_REPLY_CHARS$2 || /[\r\n]/u.test(line)) return void 0;
4014
+ const words = line.toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/gu, "-").split("-").filter((word) => word.length > 0);
4015
+ if (words.length === 0 || words.length > MAX_SLUG_WORDS) return void 0;
4016
+ const slug = words.join("-").slice(0, MAX_SLUG_CHARS).replace(/-+$/u, "");
4017
+ return slug.length === 0 ? void 0 : slug;
4018
+ }
4019
+ /** First free name in `<candidate>`, `<candidate>-2`, `<candidate>-3`, … */
4020
+ function uniqueBranchName(candidate, taken) {
4021
+ const existing = new Set(taken);
4022
+ if (!existing.has(candidate)) return candidate;
4023
+ for (let index = 2; index < 100; index += 1) {
4024
+ const name = `${candidate}-${index}`;
4025
+ if (!existing.has(name)) return name;
4026
+ }
4027
+ return candidate;
4028
+ }
4029
+ /** Compress a composer draft into a branch slug with a throwaway Claude turn.
4030
+ *
4031
+ * Deliberately NOT routed through the supervisor, for the same reasons as the
4032
+ * plan-usage probe: there is no session to borrow yet. The turn is isolated
4033
+ * from filesystem settings as well, because a CLAUDE.md instruction ("always
4034
+ * reply in the user's language") turns the answer into an unusable slug. */
4035
+ async function summarizeBranchSlug(executablePath, intent, factory = query) {
4036
+ const task = intent.trim().slice(0, MAX_INTENT_CHARS);
4037
+ if (task.length === 0) return void 0;
4038
+ const lifetime = new AbortController();
4039
+ const timer = setTimeout(() => lifetime.abort(), BRANCH_SUMMARY_TIMEOUT_MS);
4040
+ timer.unref?.();
4041
+ try {
4042
+ const query = factory({
4043
+ prompt: branchSummaryPrompt(task),
4044
+ options: {
4045
+ cwd: process.cwd(),
4046
+ abortController: lifetime,
4047
+ model: BRANCH_SUMMARY_MODEL,
4048
+ allowedTools: [],
4049
+ settingSources: [],
4050
+ maxTurns: 1,
4051
+ ...executablePath.length === 0 ? {} : { pathToClaudeCodeExecutable: executablePath }
4052
+ }
4053
+ });
4054
+ for await (const message of query) if (message.type === "result" && message.subtype === "success") return branchSlug(message.result);
4055
+ return;
4056
+ } catch {
4057
+ return;
4058
+ } finally {
4059
+ clearTimeout(timer);
4060
+ lifetime.abort();
4061
+ }
4062
+ }
4063
+ //#endregion
3980
4064
  //#region src/repository-setup.ts
3981
4065
  const MAX_OUTPUT_BYTES$3 = 131072;
3982
4066
  const GIT_TIMEOUT_MS$2 = 1e4;
@@ -4054,6 +4138,7 @@ var RepositorySetupService = class {
4054
4138
  #leasePath;
4055
4139
  #worktreeRoot;
4056
4140
  #branchPrefix;
4141
+ #summarizeBranch;
4057
4142
  #cleanupGraceMs;
4058
4143
  #gitPath;
4059
4144
  #pending = Promise.resolve();
@@ -4062,6 +4147,7 @@ var RepositorySetupService = class {
4062
4147
  this.#leasePath = options.leasePath ?? dshHomePath("plugins", "dsh-claude", "worktrees.json");
4063
4148
  this.#worktreeRoot = options.worktreeRoot ?? dshHomePath("plugins", "dsh-claude", "worktrees");
4064
4149
  this.#branchPrefix = options.branchPrefix ?? (async () => "claude");
4150
+ this.#summarizeBranch = options.summarizeBranch ?? (async () => void 0);
4065
4151
  this.#cleanupGraceMs = options.cleanupGraceMs ?? CLEANUP_GRACE_MS;
4066
4152
  }
4067
4153
  async listBranches(cwd) {
@@ -4108,7 +4194,7 @@ var RepositorySetupService = class {
4108
4194
  remoteBranches
4109
4195
  };
4110
4196
  }
4111
- async setup(cwd, branchValue, useWorktree, explicitBranchName, progress = () => {}) {
4197
+ async setup(cwd, branchValue, useWorktree, explicitBranchName, progress = () => {}, intent) {
4112
4198
  progress("inspecting");
4113
4199
  const branch = safeBranch(branchValue);
4114
4200
  const info = await this.listBranches(cwd);
@@ -4116,7 +4202,7 @@ var RepositorySetupService = class {
4116
4202
  const remote = info.remoteBranches.includes(branch);
4117
4203
  if (!local && !remote) throw new RepositorySetupError("branch-not-found", "The selected local or remote-tracking branch does not exist.");
4118
4204
  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);
4205
+ 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
4206
  progress("switching-branch");
4121
4207
  return !local && remote ? this.#checkoutRemote(info, branch) : this.#checkout(info, branch);
4122
4208
  }
@@ -4199,11 +4285,17 @@ var RepositorySetupService = class {
4199
4285
  });
4200
4286
  }
4201
4287
  /** 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) {
4288
+ * workspace. Deleting a workspace is the user saying they are done with it,
4289
+ * so an unreferenced worktree goes even with uncommitted changes; its
4290
+ * sessions are archived first, through `archiveSessions`, or the Host
4291
+ * rebuilds the deleted workspace from their headers on the next boot.
4292
+ * Fresh leases are retained for a grace period so a worktree created
4293
+ * moments ago cannot be swept before its workspace registration lands.
4294
+ *
4295
+ * Every command runs from the repository root, never from the worktree
4296
+ * being removed: spawning in a directory that is already gone throws
4297
+ * ENOENT, which used to strand the lease forever. */
4298
+ cleanupOrphans(activePaths, archiveSessions) {
4207
4299
  const active = new Set(activePaths.map(comparablePath));
4208
4300
  return this.#serialize(async () => {
4209
4301
  const leases = await this.#readLeases();
@@ -4218,32 +4310,19 @@ var RepositorySetupService = class {
4218
4310
  continue;
4219
4311
  }
4220
4312
  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;
4313
+ await archiveSessions?.(item.path).catch(() => void 0);
4314
+ if (await pathExists(item.path)) {
4315
+ if ((await this.#run(git, [
4316
+ "worktree",
4317
+ "remove",
4318
+ "--force",
4319
+ "--",
4320
+ item.path
4321
+ ], item.root)).exitCode !== 0) {
4322
+ retained.push(item);
4323
+ continue;
4231
4324
  }
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
- }
4325
+ } else await this.#run(git, ["worktree", "prune"], item.root).catch(() => void 0);
4247
4326
  if (item.pluginGeneratedBranch) await this.#run(git, [
4248
4327
  "branch",
4249
4328
  "-D",
@@ -4257,8 +4336,38 @@ var RepositorySetupService = class {
4257
4336
  }
4258
4337
  }
4259
4338
  if (changed) await this.#writeLeases(retained);
4339
+ await this.#removeUnleasedDirectories(retained, active, now);
4260
4340
  });
4261
4341
  }
4342
+ /** Remove directories under the plugin's own worktree root that no lease and
4343
+ * no workspace claims. A lease file lost to a crash, or a worktree whose
4344
+ * lease write failed, otherwise leaves a directory nothing will ever sweep.
4345
+ * The grace period covers the gap between `worktree add` and the lease
4346
+ * write, so a worktree being created right now is never taken. */
4347
+ async #removeUnleasedDirectories(retained, active, now) {
4348
+ const leased = new Set(retained.map((item) => comparablePath(item.path)));
4349
+ let entries;
4350
+ try {
4351
+ entries = await readdir(this.#worktreeRoot);
4352
+ } catch {
4353
+ return;
4354
+ }
4355
+ for (const entry of entries) {
4356
+ const path = join(this.#worktreeRoot, entry);
4357
+ const key = comparablePath(path);
4358
+ if (leased.has(key) || active.has(key)) continue;
4359
+ try {
4360
+ const info = await stat(path);
4361
+ if (!info.isDirectory()) continue;
4362
+ const created = info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs;
4363
+ if (Math.max(0, now - created) < this.#cleanupGraceMs) continue;
4364
+ await rm(path, {
4365
+ recursive: true,
4366
+ force: true
4367
+ });
4368
+ } catch {}
4369
+ }
4370
+ }
4262
4371
  async #checkoutRemote(info, remoteBranch) {
4263
4372
  const separator = remoteBranch.indexOf("/");
4264
4373
  if (separator <= 0 || separator === remoteBranch.length - 1) throw new RepositorySetupError("invalid-branch", "The remote-tracking branch name is invalid.");
@@ -4325,13 +4434,14 @@ var RepositorySetupService = class {
4325
4434
  branch
4326
4435
  };
4327
4436
  }
4328
- async #createWorktree(root, baseBranch, baseRef, explicitBranchName, reuseExistingBranch, progress) {
4437
+ async #createWorktree(info, baseBranch, baseRef, explicitBranchName, reuseExistingBranch, progress, intent) {
4438
+ const { root } = info;
4329
4439
  const git = await this.#git();
4330
4440
  progress("fetching");
4331
4441
  await this.#fetchRemotes(git, root);
4332
4442
  const suffix = randomUUID().slice(0, 8);
4333
4443
  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}`;
4444
+ const branch = explicitBranchName ?? await this.#generatedBranch(info, baseBranch, intent, stamp, suffix, progress);
4335
4445
  const path = join(this.#worktreeRoot, `${slug(basename(root), "repository")}-${stamp}-${suffix}`);
4336
4446
  progress("creating-worktree");
4337
4447
  await mkdir(this.#worktreeRoot, { recursive: true });
@@ -4392,6 +4502,18 @@ var RepositorySetupService = class {
4392
4502
  leaseId: item.id
4393
4503
  };
4394
4504
  }
4505
+ /** `<prefix>/<what the draft is about>`, falling back to
4506
+ * `<prefix>/<base branch>-<stamp>-<random>` whenever the summary is missing
4507
+ * or unusable. The prefix and the fallback shape are unchanged. */
4508
+ async #generatedBranch(info, baseBranch, intent, stamp, suffix, progress) {
4509
+ const prefix = safeBranch(await this.#branchPrefix());
4510
+ if (intent !== void 0 && intent.trim().length > 0) {
4511
+ progress("summarizing");
4512
+ const summary = await this.#summarizeBranch(intent).catch(() => void 0);
4513
+ if (summary !== void 0) return uniqueBranchName(`${prefix}/${summary}`, info.branches);
4514
+ }
4515
+ return `${prefix}/${slug(baseBranch, "branch")}-${stamp}-${suffix}`;
4516
+ }
4395
4517
  async #repositoryRoot(git, cwd) {
4396
4518
  const result = await this.#run(git, [
4397
4519
  "rev-parse",
@@ -4964,7 +5086,7 @@ async function streamSetup(res, service, input) {
4964
5086
  type: "progress",
4965
5087
  stage
4966
5088
  });
4967
- })
5089
+ }, optionalString$1(input, "intent"))
4968
5090
  });
4969
5091
  } catch (error) {
4970
5092
  const setupError = error instanceof RepositorySetupError ? error : void 0;
@@ -7650,7 +7772,10 @@ async function apply(ctx, config) {
7650
7772
  await applySettingsOverrides();
7651
7773
  const sidecar = new ClaudeSidecarRepository();
7652
7774
  const repositoryStatus = new RepositoryStatusService(ctx.subprocess);
7653
- const repositorySetup = new RepositorySetupService(ctx.subprocess, { branchPrefix: () => readWorktreeBranchPrefix() });
7775
+ const repositorySetup = new RepositorySetupService(ctx.subprocess, {
7776
+ branchPrefix: () => readWorktreeBranchPrefix(),
7777
+ summarizeBranch: (intent) => summarizeBranchSlug(supervisorConfig.executablePath, intent)
7778
+ });
7654
7779
  const reviewComments = new ReviewCommentStore();
7655
7780
  const commandCatalogs = /* @__PURE__ */ new Map();
7656
7781
  const supervisor = new ClaudeSupervisor({
@@ -7730,10 +7855,20 @@ async function apply(ctx, config) {
7730
7855
  });
7731
7856
  const injectWorkspaceRegistry = ctx.inject;
7732
7857
  injectWorkspaceRegistry(["workspaceRegistry"], (sweepCtx) => {
7858
+ const archiveSessions = async (worktreePath) => {
7859
+ const persistence = sweepCtx.get("sessionPersistence");
7860
+ if (persistence === void 0) return;
7861
+ const target = comparablePath(worktreePath);
7862
+ for (const header of await persistence.list()) {
7863
+ if (typeof header.id !== "string" || typeof header.cwd !== "string") continue;
7864
+ if (comparablePath(header.cwd) !== target) continue;
7865
+ await sweepCtx.workspaceRegistry.archiveSession(header.id).catch(() => void 0);
7866
+ }
7867
+ };
7733
7868
  const sweep = () => {
7734
7869
  try {
7735
7870
  const paths = sweepCtx.workspaceRegistry.list().map((workspace) => workspace.path);
7736
- repositorySetup.cleanupOrphans(paths).catch(() => void 0);
7871
+ repositorySetup.cleanupOrphans(paths, archiveSessions).catch(() => void 0);
7737
7872
  } catch {}
7738
7873
  };
7739
7874
  sweepCtx.effect(() => {