@norman-else/dsh-claude 0.1.43 → 0.1.44

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
@@ -1408,56 +1408,108 @@ function normalizeSdkMessage(message) {
1408
1408
  }
1409
1409
  //#endregion
1410
1410
  //#region src/model-catalog.ts
1411
- /** What the selector shows before any session has initialized in this Host
1412
- * process -- a fresh app launch lands here. `default` is the only id that is
1413
- * valid on every release and plan; the aliases after it are the stable
1414
- * `/model` spellings Claude Code has kept across releases, so the menu is
1415
- * usable at first paint instead of a single row. The first initialize
1416
- * response replaces the whole list with the CLI's own lineup. */
1411
+ /** The Claude Code model lineup, read from the running CLI instead of pinned
1412
+ * here.
1413
+ *
1414
+ * Anthropic ships models between releases of this plugin -- Fable arrived in a
1415
+ * CLI update, not in one of ours -- so a table maintained here is stale the day
1416
+ * it is written, and a model the user can already pick in `/model` is missing
1417
+ * from the DSH selector until someone edits an array. The CLI answers the same
1418
+ * question itself: every session's initialize response carries the lineup it
1419
+ * would show in `/model`, already narrowed to the logged-in account's plan and
1420
+ * to any `availableModels` restriction the settings cascade imposes.
1421
+ *
1422
+ * What DSH persists on a session, though, must NOT be a CLI model id. DSH
1423
+ * stores the selector row's id verbatim and matches it back by string
1424
+ * equality, so a concrete id (`claude-fable-5-1[1m]`) turns into a dangling
1425
+ * reference the moment Anthropic bumps the version -- the session keeps
1426
+ * pointing at a row nothing advertises any more, and the composer falls back
1427
+ * to printing the raw id. The selector therefore advertises an alias this
1428
+ * plugin owns (`fable[1m]`), derived from the row rather than tabulated, and
1429
+ * the CLI id it stands for is kept beside it and used only at dispatch.
1430
+ */
1431
+ /** A 1M-context route spells it in the id (`opus[1m]`, `claude-fable-5-1[1m]`). */
1432
+ const WIDE_ROUTE = /\[1m\]$/u;
1433
+ /** What the selector shows before the lineup is known -- the probe below failed
1434
+ * or has not answered yet. `default` is the only id that is valid on every
1435
+ * release and plan; the rest are the stable `/model` spellings Claude Code has
1436
+ * kept across releases, and every one of them is a spelling the CLI accepts,
1437
+ * so a session that persists one still dispatches. */
1417
1438
  const SEED = [
1418
1439
  {
1419
1440
  id: "default",
1441
+ value: "default",
1420
1442
  name: "Default (recommended)",
1421
1443
  description: ""
1422
1444
  },
1423
1445
  {
1424
1446
  id: "opus[1m]",
1447
+ value: "opus[1m]",
1425
1448
  name: "Opus (1M context)",
1426
1449
  description: "",
1427
1450
  contextWindow: 1e6
1428
1451
  },
1429
1452
  {
1430
1453
  id: "fable",
1454
+ value: "fable",
1431
1455
  name: "Fable",
1432
1456
  description: ""
1433
1457
  },
1434
1458
  {
1435
1459
  id: "sonnet",
1460
+ value: "sonnet",
1436
1461
  name: "Sonnet",
1437
1462
  description: ""
1438
1463
  },
1439
1464
  {
1440
1465
  id: "haiku",
1466
+ value: "haiku",
1441
1467
  name: "Haiku",
1442
1468
  description: ""
1443
1469
  }
1444
1470
  ];
1445
- /** A 1M-context route spells it in the id (`opus[1m]`, `claude-fable-5[1m]`),
1446
- * so this needs no capacity table either. It is only a floor: the supervisor
1447
- * overrides it with the window the CLI reports once a turn has run. */
1471
+ /** A 1M-context route spells it in the id, so this needs no capacity table
1472
+ * either. It is only a floor: the supervisor overrides it with the window the
1473
+ * CLI reports once a turn has run. */
1448
1474
  function declaredContextWindow(row) {
1449
- return /\[1m\]$/u.test(row.resolvedModel ?? row.value) ? 1e6 : void 0;
1475
+ return WIDE_ROUTE.test(row.resolvedModel ?? row.value) ? 1e6 : void 0;
1476
+ }
1477
+ /**
1478
+ * The selector id for one CLI row: the model's family, plus the `[1m]` marker
1479
+ * when the route carries one.
1480
+ *
1481
+ * Derived, never tabulated -- a family this plugin has never heard of gets its
1482
+ * id the same way, so a model Anthropic ships tomorrow lands in the selector
1483
+ * without an edit here, and a version bump (`claude-fable-5-1` ->
1484
+ * `claude-fable-5-2`) leaves an already-persisted selection pointing at the
1485
+ * same row. The family is the first non-numeric segment, which covers both
1486
+ * spellings Anthropic has used (`claude-fable-5-1`, `claude-3-5-sonnet-…`).
1487
+ *
1488
+ * Read off `value` alone, never the id it resolves to: `default` names a route
1489
+ * whose resolution moves with the account and the release, so folding the
1490
+ * resolved `[1m]` in would flip an already-persisted `default` to `default[1m]`
1491
+ * the day Anthropic repoints it.
1492
+ * @param value - the CLI's own id for the row.
1493
+ * @returns the alias to advertise.
1494
+ */
1495
+ function claudeModelAlias(value) {
1496
+ const wide = WIDE_ROUTE.test(value);
1497
+ const bare = value.replace(WIDE_ROUTE, "").replace(/^claude-/u, "");
1498
+ const family = bare.split("-").find((segment) => !/^\d+$/u.test(segment)) ?? bare;
1499
+ return wide ? `${family}[1m]` : family;
1450
1500
  }
1451
- function projectModel(row) {
1501
+ function projectModel(row, id) {
1452
1502
  const contextWindow = declaredContextWindow(row);
1453
1503
  return {
1454
- id: row.value,
1504
+ id,
1505
+ value: row.value,
1455
1506
  name: row.displayName,
1456
1507
  description: row.description,
1457
1508
  ...contextWindow === void 0 ? {} : { contextWindow }
1458
1509
  };
1459
1510
  }
1460
1511
  let latest$1;
1512
+ let inflight;
1461
1513
  /**
1462
1514
  * Learn the lineup from one session's initialize response.
1463
1515
  * @param models - the CLI's own `/model` rows; an empty list is ignored so a
@@ -1465,20 +1517,96 @@ let latest$1;
1465
1517
  */
1466
1518
  function recordClaudeModels(models) {
1467
1519
  if (models.length === 0) return;
1468
- latest$1 = models.map(projectModel);
1520
+ const taken = /* @__PURE__ */ new Set();
1521
+ latest$1 = models.map((row) => {
1522
+ const alias = claudeModelAlias(row.value);
1523
+ const id = taken.has(alias) ? row.value : alias;
1524
+ taken.add(id);
1525
+ return projectModel(row, id);
1526
+ });
1469
1527
  }
1470
1528
  /** The lineup to advertise: whatever the CLI last reported, else the seed. */
1471
1529
  function latestClaudeModels() {
1472
1530
  return latest$1 ?? SEED;
1473
1531
  }
1532
+ /** A throwaway probe should not outlive a wedged CLI. */
1533
+ const CLAUDE_MODEL_PROBE_TIMEOUT_MS = 2e4;
1534
+ /**
1535
+ * Read the lineup from a throwaway CLI process.
1536
+ *
1537
+ * Waiting for a session to start is too late: DSH loads the model catalog once
1538
+ * per Host generation, at connect, and does not reload it when this plugin
1539
+ * later learns the real lineup. A selector left on the seed until then hands
1540
+ * out seed ids, which is exactly how a session ends up persisting an id the
1541
+ * next launch cannot resolve. This query carries no tools, no permission
1542
+ * bridge and no session binding: it starts, reports what `/model` would show,
1543
+ * and is killed -- no prompt is ever sent, so it costs no tokens.
1544
+ * @param executablePath - the resolved CLI, or '' to let the SDK find it.
1545
+ * @param factory - test seam for the SDK query.
1546
+ * @returns the CLI's own `/model` rows.
1547
+ */
1548
+ async function probeClaudeModels(executablePath, factory = query) {
1549
+ const lifetime = new AbortController();
1550
+ const timer = setTimeout(() => lifetime.abort(), CLAUDE_MODEL_PROBE_TIMEOUT_MS);
1551
+ timer.unref?.();
1552
+ const query$2 = factory({
1553
+ prompt: (async function* () {
1554
+ await new Promise(() => {});
1555
+ })(),
1556
+ options: {
1557
+ cwd: process.cwd(),
1558
+ abortController: lifetime,
1559
+ ...executablePath.length === 0 ? {} : { pathToClaudeCodeExecutable: executablePath }
1560
+ }
1561
+ });
1562
+ try {
1563
+ (async () => {
1564
+ for await (const _ of query$2);
1565
+ })().catch(() => void 0);
1566
+ return (await Promise.race([query$2.initializationResult(), new Promise((_resolve, reject) => {
1567
+ setTimeout(() => reject(/* @__PURE__ */ new Error("dsh-claude: the model lineup probe did not answer in time")), CLAUDE_MODEL_PROBE_TIMEOUT_MS).unref?.();
1568
+ })])).models;
1569
+ } finally {
1570
+ clearTimeout(timer);
1571
+ lifetime.abort();
1572
+ }
1573
+ }
1574
+ /**
1575
+ * The lineup, learning it from the CLI the first time DSH asks for the catalog.
1576
+ * @param probe - reads the CLI's rows; a failure leaves the seed in place and
1577
+ * is retried on the next catalog load.
1578
+ * @returns the rows to advertise, never rejecting.
1579
+ */
1580
+ function ensureClaudeModels(probe) {
1581
+ if (latest$1 !== void 0) return Promise.resolve(latest$1);
1582
+ inflight ??= probe().then((models) => {
1583
+ recordClaudeModels(models);
1584
+ }).catch(() => void 0).then(() => {
1585
+ inflight = void 0;
1586
+ return latestClaudeModels();
1587
+ });
1588
+ return inflight;
1589
+ }
1474
1590
  /**
1475
1591
  * Look one id up in the current lineup.
1476
- * @param id - the id DSH persisted on the session, which may name a model the
1592
+ * @param id - the id DSH persisted on the session, which may be an alias, a
1593
+ * concrete CLI id persisted before this plugin aliased anything, or a row the
1477
1594
  * running CLI no longer lists.
1478
1595
  * @returns the row, or undefined when the lineup does not cover the id.
1479
1596
  */
1480
1597
  function claudeModelRow(id) {
1481
- return latestClaudeModels().find((row) => row.id === id);
1598
+ const rows = latestClaudeModels();
1599
+ return rows.find((row) => row.id === id) ?? rows.find((row) => row.value === id) ?? rows.find((row) => row.id === claudeModelAlias(id));
1600
+ }
1601
+ /**
1602
+ * The spelling to hand the CLI for one selector id.
1603
+ * @param id - the id DSH persisted on the session.
1604
+ * @returns the CLI's own id, or the selector id itself when the lineup does not
1605
+ * cover it -- the seed vocabulary is made of spellings the CLI accepts, and a
1606
+ * session persisted before this plugin aliased anything already holds one.
1607
+ */
1608
+ function claudeModelValue(id) {
1609
+ return claudeModelRow(id)?.value ?? id;
1482
1610
  }
1483
1611
  //#endregion
1484
1612
  //#region src/plan-usage.ts
@@ -2478,7 +2606,7 @@ var ClaudeSupervisor = class {
2478
2606
  resume: binding.claudeSessionId,
2479
2607
  ...forkAt === void 0 ? {} : { resumeSessionAt: forkAt }
2480
2608
  },
2481
- model,
2609
+ model: claudeModelValue(model),
2482
2610
  ...thinkingMode === void 0 ? {} : thinkingMode === "off" ? { thinking: { type: "disabled" } } : thinkingMode === "ultracode" ? { settings: { ultracode: true } } : { effort: thinkingMode }
2483
2611
  };
2484
2612
  entry.query = this.#queryFactory({
@@ -3527,7 +3655,10 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
3527
3655
  * Settings dialog, and the read is dwarfed by the process the turn spawns. */
3528
3656
  #renderMode;
3529
3657
  #summarizeTitle;
3530
- constructor(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => [], renderMode = async () => DEFAULT_CLAUDE_RENDER_MODE, summarizeTitle = (request) => summarizeSessionTitle("", request)) {
3658
+ /** Reads the CLI's own `/model` rows, so the selector never has to advertise
3659
+ * the seed vocabulary once the CLI can answer for itself. */
3660
+ #probeModels;
3661
+ constructor(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => [], renderMode = async () => DEFAULT_CLAUDE_RENDER_MODE, summarizeTitle = (request) => summarizeSessionTitle("", request), probeModels = async () => []) {
3531
3662
  super();
3532
3663
  this.#supervisor = supervisor;
3533
3664
  this.#agents = agents;
@@ -3536,6 +3667,7 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
3536
3667
  this.#drainReviewComments = drainReviewComments;
3537
3668
  this.#renderMode = renderMode;
3538
3669
  this.#summarizeTitle = summarizeTitle;
3670
+ this.#probeModels = probeModels;
3539
3671
  }
3540
3672
  providerInfo(provider) {
3541
3673
  return {
@@ -3547,7 +3679,7 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
3547
3679
  return NO_RETRY_POLICY;
3548
3680
  }
3549
3681
  async listModels(provider) {
3550
- return latestClaudeModels().map((model) => ({
3682
+ return (await ensureClaudeModels(this.#probeModels)).map((model) => ({
3551
3683
  provider,
3552
3684
  id: model.id,
3553
3685
  name: model.name,
@@ -3748,8 +3880,8 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
3748
3880
  if (!completed) throw new Error("dsh-claude: Claude turn stream ended without a result");
3749
3881
  }
3750
3882
  };
3751
- function createClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => [], renderMode = async () => DEFAULT_CLAUDE_RENDER_MODE, summarizeTitle = (request) => summarizeSessionTitle("", request)) {
3752
- return new ClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments, renderMode, summarizeTitle);
3883
+ function createClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => [], renderMode = async () => DEFAULT_CLAUDE_RENDER_MODE, summarizeTitle = (request) => summarizeSessionTitle("", request), probeModels = async () => []) {
3884
+ return new ClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments, renderMode, summarizeTitle, probeModels);
3753
3885
  }
3754
3886
  //#endregion
3755
3887
  //#region src/plugin-budget.ts
@@ -4314,6 +4446,82 @@ function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSes
4314
4446
  });
4315
4447
  }
4316
4448
  //#endregion
4449
+ //#region src/diff-funcname.ts
4450
+ /** Extensions mapped onto a funcname driver, so `@@` hunk headers name the
4451
+ * method a change sits in. Without one git falls back to "the last line that
4452
+ * starts in column 0", which in Java or Kotlin is always the class. */
4453
+ const DIFF_ATTRIBUTES = [
4454
+ "*.java diff=java",
4455
+ "*.kt diff=kotlin",
4456
+ "*.kts diff=kotlin",
4457
+ "*.py diff=python",
4458
+ "*.pyi diff=python",
4459
+ "*.js diff=dshweb",
4460
+ "*.jsx diff=dshweb",
4461
+ "*.mjs diff=dshweb",
4462
+ "*.cjs diff=dshweb",
4463
+ "*.ts diff=dshweb",
4464
+ "*.tsx diff=dshweb",
4465
+ "*.mts diff=dshweb",
4466
+ "*.cts diff=dshweb",
4467
+ "*.vue diff=dshweb",
4468
+ "*.svelte diff=dshweb",
4469
+ "*.css diff=css",
4470
+ "*.scss diff=css",
4471
+ "*.less diff=css",
4472
+ ""
4473
+ ].join("\n");
4474
+ /** git ships no JavaScript driver, so this is the one pattern we write ourselves.
4475
+ *
4476
+ * POSIX extended regexes, one per line, matched top-down: a leading `!` marks a
4477
+ * line that can never be a header, and the reported text is capture group 1 --
4478
+ * hence the outer parentheses around everything worth showing.
4479
+ *
4480
+ * Line 2 keeps git's own fallback (anything unindented), because a driver
4481
+ * replaces that fallback rather than extending it, and most of a frontend file's
4482
+ * declarations already live in column 0. Lines 3 and 4 add what the fallback
4483
+ * cannot see: nested declarations, and indented class or object methods.
4484
+ *
4485
+ * ponytail: a method line must end in `{`. Allowing `)` too would pick up every
4486
+ * bare `foo(bar)` statement, which reads as a header and hides the real one.
4487
+ */
4488
+ const DSHWEB_FUNCNAME = [
4489
+ "!^[ ]*(if|else|for|while|do|switch|case|catch|try|finally|return|await|new|throw|typeof)[^A-Za-z0-9_$]",
4490
+ "^([A-Za-z_$].*)$",
4491
+ "^[ ]*((export[ ]+)?(default[ ]+)?(declare[ ]+)?(abstract[ ]+)?(async[ ]+)?(function|class|interface|enum|namespace|module)[ ].*)$",
4492
+ "^[ ]*(((public|private|protected|static|readonly|abstract|async|get|set)[ ]+)*[A-Za-z_$#][A-Za-z0-9_$]*[ ]*[:=]?[ ]*(async[ ]+)?[(<][^;]*\\{)[ ]*$"
4493
+ ].join("\n");
4494
+ let attributesFile;
4495
+ async function writeAttributes() {
4496
+ const path = dshHomePath("plugins", "dsh-claude", "diff-attributes");
4497
+ try {
4498
+ await mkdir(dirname(path), { recursive: true });
4499
+ await writeFile(path, DIFF_ATTRIBUTES, "utf8");
4500
+ return path;
4501
+ } catch {
4502
+ return;
4503
+ }
4504
+ }
4505
+ /** `-c` overrides to place in front of a `git diff`, teaching it which funcname
4506
+ * driver each extension uses.
4507
+ *
4508
+ * `core.attributesFile` is the lowest-precedence attribute source, so a
4509
+ * repository that already declares its own `.gitattributes` still wins. Better
4510
+ * hunk headers are cosmetic: a failed write drops the overrides and the diff
4511
+ * runs exactly as before.
4512
+ */
4513
+ async function diffFuncnameArgs() {
4514
+ attributesFile ??= writeAttributes();
4515
+ const path = await attributesFile;
4516
+ if (path === void 0) return [];
4517
+ return [
4518
+ "-c",
4519
+ `core.attributesFile=${path}`,
4520
+ "-c",
4521
+ `diff.dshweb.xfuncname=${DSHWEB_FUNCNAME}`
4522
+ ];
4523
+ }
4524
+ //#endregion
4317
4525
  //#region src/repository-status.ts
4318
4526
  const MAX_OUTPUT_BYTES$4 = 65536;
4319
4527
  const MAX_DIFF_BYTES = 262144;
@@ -4707,6 +4915,7 @@ var RepositoryStatusService = class {
4707
4915
  if (numstat.exitCode !== 0 || numstat.lossy) return void 0;
4708
4916
  const summary = parseDiffNumstat(numstat.stdout);
4709
4917
  const patch = await run(this.#runtime, git, [
4918
+ ...await diffFuncnameArgs(),
4710
4919
  "diff",
4711
4920
  "--no-ext-diff",
4712
4921
  "--no-color",
@@ -5851,6 +6060,7 @@ var RepositoryActionService = class {
5851
6060
  "--path-format=absolute",
5852
6061
  "--show-toplevel"
5853
6062
  ], cwd, GIT_TIMEOUT_MS$1, "not-repository", "The session directory is not a Git repository.")).stdout.trim();
6063
+ const funcname = await diffFuncnameArgs();
5854
6064
  const [branchResult, headResult, statusResult, stagedPatch, unstagedPatch] = await Promise.all([
5855
6065
  this.#run(git, [
5856
6066
  "symbolic-ref",
@@ -5866,6 +6076,7 @@ var RepositoryActionService = class {
5866
6076
  "--untracked-files=all"
5867
6077
  ], root, GIT_TIMEOUT_MS$1),
5868
6078
  this.#run(git, [
6079
+ ...funcname,
5869
6080
  "diff",
5870
6081
  "--cached",
5871
6082
  "--no-ext-diff",
@@ -5876,6 +6087,7 @@ var RepositoryActionService = class {
5876
6087
  ":(exclude)**/WARP.md"
5877
6088
  ], root, GIT_TIMEOUT_MS$1, MAX_OUTPUT_BYTES$2),
5878
6089
  this.#run(git, [
6090
+ ...funcname,
5879
6091
  "diff",
5880
6092
  "--no-ext-diff",
5881
6093
  "--no-color",
@@ -9232,7 +9444,7 @@ async function apply(ctx, config) {
9232
9444
  let resolutionError;
9233
9445
  try {
9234
9446
  supervisorConfig.executablePath = (await resolveClaudeExecutable(ctx.subprocess, config.executablePath === void 0 || config.executablePath.length === 0 ? void 0 : config.executablePath)).path;
9235
- 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)));
9447
+ 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)));
9236
9448
  ctx.effect(() => {
9237
9449
  const mounted = /* @__PURE__ */ new Map();
9238
9450
  const pending = /* @__PURE__ */ new Set();
@@ -9361,7 +9573,7 @@ async function apply(ctx, config) {
9361
9573
  registerAskRoute(webCtx, new AskService(webCtx.subprocess, supervisorConfig.executablePath), cwdForClaudeSession, (sessionId) => {
9362
9574
  const snapshot = supervisor.snapshots().find((item) => item.sessionId === sessionId);
9363
9575
  return snapshot === void 0 ? void 0 : {
9364
- model: snapshot.model,
9576
+ model: claudeModelValue(snapshot.model),
9365
9577
  ...snapshot.thinkingMode === void 0 ? {} : { thinkingMode: snapshot.thinkingMode }
9366
9578
  };
9367
9579
  });