@xiaoso/dsh-tool-plus 0.1.9 → 0.1.10-beta.1

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.
Files changed (40) hide show
  1. package/README.en.md +12 -5
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +36 -0
  4. package/lib/client.js +272 -181
  5. package/lib/index.mjs +875 -1081
  6. package/lib/{scrapers-mhvFrvjH.mjs → scrapers-DODCPcZW.mjs} +1 -1
  7. package/lib/types/client/BashPlusCard.d.ts +6 -6
  8. package/lib/types/client/ToolPlusSection.d.ts +3 -3
  9. package/lib/types/client/forms.d.ts +6 -6
  10. package/lib/types/client/index.d.ts +25 -12
  11. package/lib/types/client/locales.d.ts +1 -1
  12. package/lib/types/client/preset-panel.d.ts +54 -42
  13. package/lib/types/config/settings.d.ts +202 -21
  14. package/lib/types/host/browser-probe-rpc.d.ts +2 -1
  15. package/lib/types/host/preset-host.d.ts +103 -0
  16. package/lib/types/host/preset-rpc.d.ts +37 -72
  17. package/lib/types/presets/compare.d.ts +14 -37
  18. package/lib/types/presets/conflicts.d.ts +59 -0
  19. package/lib/types/presets/delta.d.ts +23 -18
  20. package/lib/types/presets/plan.d.ts +55 -0
  21. package/lib/types/presets/rows.d.ts +84 -0
  22. package/lib/types/presets/status.d.ts +56 -47
  23. package/lib/types/tools/bash/background.d.ts +20 -5
  24. package/lib/types/tools/bash/streaming-output.d.ts +4 -2
  25. package/lib/types/tools/shared/browser-rpc-channel.d.ts +84 -42
  26. package/lib/types/web/client/labels.d.ts +1 -1
  27. package/lib/types/web/client/row-utils.d.ts +26 -1
  28. package/lib/{utils-eiUz9n8I.mjs → utils-D80w8vry.mjs} +1 -1
  29. package/package.json +61 -46
  30. package/presets/tool-plus-ptc.patch.yml +164 -0
  31. package/presets/tool-plus-standard.patch.yml +158 -0
  32. package/lib/types/presets/install.d.ts +0 -44
  33. package/lib/types/presets/paths.d.ts +0 -41
  34. package/lib/types/presets/rewrite.d.ts +0 -86
  35. package/presets/install-presets.mjs +0 -143
  36. package/presets/package.json +0 -42
  37. package/presets/tool-plus-ptc/agent.cordis.yml +0 -284
  38. package/presets/tool-plus-ptc/preset.yml +0 -3
  39. package/presets/tool-plus-standard/agent.cordis.yml +0 -269
  40. package/presets/tool-plus-standard/preset.yml +0 -3
package/lib/index.mjs CHANGED
@@ -3,24 +3,24 @@ __installBunShim();
3
3
  import { n as fs_promises_shim_exports, t as exists } from "./fs-promises-shim-Bx0fpxuk.mjs";
4
4
  import { i as setRuntimeLogger, n as systemChromiumCandidatesForTest, r as runtimeLogger } from "./launch-Uq7920ng.mjs";
5
5
  import { $ as isEnoent, A as formatCount, C as warn, D as installBunShim, F as $flag, N as $env, O as formatAge, P as $envpos, Q as hasFsCode, U as getAutoQaDbPath, V as VERSION, X as getRemoteDir, Y as getPuppeteerDir, at as findOnPath, b as debug, c as combineSignals, d as $which, et as isEnotdir, it as untilAborted, k as formatBytes$1, l as exec, m as readImageMetadata, nt as peekFile, q as getInstallId, tt as isProbablyBinary, u as render, x as error } from "./src-DUUqyo9y.mjs";
6
- import { C as truncateToWidth$1, S as replaceTabs, _ as formatCodeFrameLine, a as fetchBinary, b as LRUCache, c as getParallelExtractContent, d as convertFileWithMarkit, f as Settings, g as capParseErrors, h as OMP_DEFAULTS, i as convertWithMarkit, l as ensureTool, m as settings, o as extractWithParallel, p as isSettingsInitialized, s as findParallelApiKey, u as CONVERTIBLE_EXTENSIONS, v as formatParseErrors, x as padding, y as shortenPath } from "./utils-eiUz9n8I.mjs";
6
+ import { C as truncateToWidth$1, S as replaceTabs, _ as formatCodeFrameLine, a as fetchBinary, b as LRUCache, c as getParallelExtractContent, d as convertFileWithMarkit, f as Settings, g as capParseErrors, h as OMP_DEFAULTS, i as convertWithMarkit, l as ensureTool, m as settings, o as extractWithParallel, p as isSettingsInitialized, s as findParallelApiKey, u as CONVERTIBLE_EXTENSIONS, v as formatParseErrors, x as padding, y as shortenPath } from "./utils-D80w8vry.mjs";
7
7
  import { t as sanitizeText } from "./sanitize-text-BCf5KmCf.mjs";
8
8
  import { n as isRecord$5 } from "./type-guards-CttAANEx.mjs";
9
9
  import { n as ToolError, r as throwIfAborted, t as ToolAbortError } from "./tool-errors-DoyxivAR.mjs";
10
10
  import { a as openArchive, c as sniffArchiveFormat, d as writeArchive, i as listArchiveRoot, o as parseArchivePathCandidates, r as formatArchiveEntryLines, s as readArchiveEntries, t as archiveFormatFromPath } from "./zip-CNtIFfyV.mjs";
11
11
  import { a as finalizeOutput, d as looksLikeHtml, n as MAX_OUTPUT_CHARS, t as MAX_BYTES, u as loadPage } from "./types-DT9vgZ3S.mjs";
12
12
  import * as fs from "node:fs";
13
- import { existsSync } from "node:fs";
13
+ import { copyFileSync, existsSync, readFileSync, readdirSync } from "node:fs";
14
14
  import { TOOL_ABORTED, defineTool } from "@deepseek-ai/dsh-tools";
15
15
  import { HarnessError } from "@deepseek-ai/dsh-llm";
16
16
  import { DSH_ENV_PREFIX, parseExitStatus } from "@deepseek-ai/dsh-shell";
17
17
  import * as natives from "@oh-my-pi/pi-natives";
18
18
  import { EditSession, EditStore, FileType, GrepOutputMode, Shell, astEdit, astGrep, diffLines, editDescription, enclosingBlockBoundaries, glob, grep, hashlineFormatHeader, hashlineFormatNumberedLines, hashlineStripPrefixes, htmlToMarkdown, invalidateFsScanCache, listWorkspace, notebookToEditableText, structuredPatchHunks, summarizeCode } from "@oh-my-pi/pi-natives";
19
19
  import * as path$1 from "node:path";
20
- import path from "node:path";
20
+ import path, { join } from "node:path";
21
21
  import { spawn } from "node:child_process";
22
22
  import * as os from "node:os";
23
- import { tmpdir } from "node:os";
23
+ import { homedir, tmpdir } from "node:os";
24
24
  import * as url from "node:url";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { scheduler } from "node:timers/promises";
@@ -1966,8 +1966,10 @@ var OutputSink = class extends OutputSink$1 {
1966
1966
  };
1967
1967
  /**
1968
1968
  * Composition wrapper over the verbatim {@link TailBuffer}, adding the DSH
1969
- * `reset()` increment. The verbatim class keeps its state in private fields,
1970
- * so reset is modeled as a fresh window with the same budget.
1969
+ * `reset()` increment: the consuming-read cursor reset the bash background
1970
+ * producer used while `JobHooks.readOutput` existed. 0.1.7 moves the model's
1971
+ * cursor onto the job registry's output ring, so the increment is no longer
1972
+ * part of that path; the preview buffer still offers it.
1971
1973
  */
1972
1974
  var TailBuffer = class {
1973
1975
  #inner;
@@ -2651,25 +2653,25 @@ function sweepStaleSpillFiles(now = Date.now()) {
2651
2653
  * Background-job adaptation for the `ctx.jobs` seam: one managed job runs its
2652
2654
  * command on its OWN `:async:` Shell (never the session's persistent shell),
2653
2655
  * so background work never blocks the foreground session. The registry owns
2654
- * identity, lifecycle state, and completion notices (`dsh-tool-jobs` injects
2655
- * them); this module owns the execution resources and their hooks.
2656
+ * identity, lifecycle state, the output ring, and completion notices
2657
+ * (`dsh-tool-jobs` injects them); this module owns the execution resources,
2658
+ * streams the run into that ring, and appends the plugin's completion message
2659
+ * once the run ends.
2656
2660
  * @module @xiaoso/dsh-tool-plus/background
2657
2661
  */
2658
2662
  /**
2659
2663
  * Start one managed background bash job. The producer owns a per-job
2660
- * `AbortController` (cancellation kills only this job's shell), a bounded
2661
- * preview tail, and a consuming delta cursor for `readOutput`.
2664
+ * `AbortController` (cancellation kills only this job's shell) and a bounded
2665
+ * preview tail; the registry owns the model's consuming cursor over the ring
2666
+ * this job writes to.
2662
2667
  * @param options - job identity and execution parameters.
2663
2668
  * @returns the registry hooks plus the tool-result completion promise.
2664
2669
  */
2665
2670
  function startBashJob(options) {
2666
- const { sessionId, command, cwd, timeoutMs, env, config } = options;
2671
+ const { sessionId, command, cwd, timeoutMs, env, config, job } = options;
2667
2672
  const shellKey = `${sessionId}:async:${randomBytes(4).toString("hex")}`;
2668
2673
  const abortController = new AbortController();
2669
2674
  const preview = new TailBuffer(config.outputMaxBytes);
2670
- const delta = new TailBuffer(config.outputMaxBytes);
2671
- let settled = false;
2672
- let settledText = "";
2673
2675
  const completion = (async () => {
2674
2676
  const startedAt = performance.now();
2675
2677
  const spillPath = allocateSpillFile();
@@ -2699,11 +2701,9 @@ function startBashJob(options) {
2699
2701
  onMinimizedSave: (originalText) => saveOriginalText(originalText),
2700
2702
  onChunk: (chunk) => {
2701
2703
  preview.append(chunk);
2702
- delta.append(chunk);
2704
+ job?.append(chunk);
2703
2705
  }
2704
2706
  });
2705
- settled = true;
2706
- settledText = preview.text();
2707
2707
  const wallTimeMs = performance.now() - startedAt;
2708
2708
  const aborted = result.cancelled && abortController.signal.aborted;
2709
2709
  const timedOut = result.timedOut ?? (result.cancelled && !aborted);
@@ -2712,6 +2712,9 @@ function startBashJob(options) {
2712
2712
  const seconds = Math.max(1, Math.round((timeoutMs ?? 0) / 1e3));
2713
2713
  if (!text.includes("[Command timed out after")) text = `[Command timed out after ${seconds} seconds]\n\n${text}`;
2714
2714
  }
2715
+ const previewText = preview.text();
2716
+ const message = options.formatCompletion?.(previewText, result.spillPath) ?? previewText;
2717
+ if (job !== void 0 && message !== previewText) job.append(message);
2715
2718
  return {
2716
2719
  kind: "foreground",
2717
2720
  exitCode: result.exitCode ?? null,
@@ -2756,13 +2759,7 @@ function startBashJob(options) {
2756
2759
  }, (error) => ({
2757
2760
  status: "failed",
2758
2761
  detail: error instanceof Error ? error.message : String(error)
2759
- })),
2760
- readOutput: () => {
2761
- if (settled) return settledText;
2762
- const text = delta.text();
2763
- delta.reset();
2764
- return text;
2765
- }
2762
+ }))
2766
2763
  },
2767
2764
  completion
2768
2765
  };
@@ -4887,8 +4884,24 @@ function toolPlusField(name) {
4887
4884
  TOOL_PLUS_FIELDS.map((field) => field.name);
4888
4885
  //#endregion
4889
4886
  //#region src/config/settings.ts
4890
- /** Settings namespace of this plugin, served to the web Plugins page. */
4891
- const BASH_PLUS_SETTINGS_NS = "tool-plus";
4887
+ /**
4888
+ * Protocol key `@deepseek-ai/cosmokit` stamps on a live config reference.
4889
+ * `Symbol.for` keeps the check valid across copies of the shared library — the
4890
+ * reason cosmokit's own `isVolatile` resolves the same key.
4891
+ */
4892
+ const VOLATILE_WRITE = Symbol.for("cosmokit.volatile.write");
4893
+ /**
4894
+ * Whether a parsed value is one of the Loader's live config references.
4895
+ *
4896
+ * `@deepseek-ai/cordis` re-exports the `Volatile` TYPE only, and cosmokit —
4897
+ * which owns the runtime check — is not a dependency of this package, so the
4898
+ * protocol is read here instead of imported.
4899
+ * @param value - a parsed config value.
4900
+ * @returns whether the value is a live reference.
4901
+ */
4902
+ function isLiveField(value) {
4903
+ return typeof value === "object" && value !== null && VOLATILE_WRITE in value;
4904
+ }
4892
4905
  /** OMP-parity defaults for the completion-message truncation policy. */
4893
4906
  const DEFAULT_OUTPUT_TRUNCATE = {
4894
4907
  strategy: "bytes",
@@ -4923,73 +4936,81 @@ function fieldDefault(name, fallback) {
4923
4936
  return value === void 0 ? fallback : value;
4924
4937
  }
4925
4938
  /**
4926
- * Runtime configuration schema for the plugin. The namespace resolves through
4927
- * this flat surface; {@link resolveConfig} maps it onto {@link RuntimeConfig}.
4939
+ * Runtime configuration schema for the plugin, in the dsh 0.1.7 settings shape:
4940
+ * EVERY field is declared `.volatile()`, because a settings namespace is the
4941
+ * plugin's own entry and `SettingsForms.describe()` serves exactly the fields
4942
+ * whose schema declares them live. {@link resolveConfig} maps the flat surface
4943
+ * onto {@link RuntimeConfig}.
4944
+ *
4945
+ * The annotation is deliberately inferred rather than written as `z<Config>`:
4946
+ * the schema's parsed output is {@link LiveConfig} (one stable reference per
4947
+ * field), while {@link Config} stays the plain settings DOCUMENT the browser
4948
+ * edits and the specs pass in.
4928
4949
  */
4929
4950
  const Config = z.object({
4930
- enableRunInBackground: z.boolean().default(fieldDefault("enableRunInBackground", true)),
4931
- autoBackgroundMs: z.number().default(fieldDefault("autoBackgroundMs", DEFAULT_AUTO_BACKGROUND_MS)),
4932
- defaultTimeoutMs: z.number().default(fieldDefault("defaultTimeoutMs", DEFAULT_TIMEOUT_MS)),
4933
- maxTimeoutMs: z.number().default(fieldDefault("maxTimeoutMs", DEFAULT_MAX_TIMEOUT_MS)),
4934
- outputMaxBytes: z.number().default(fieldDefault("outputMaxBytes", 51200)),
4935
- outputSinkTailBytes: z.number().default(fieldDefault("outputSinkTailBytes", 51200)),
4936
- outputSinkHeadBytes: z.number().default(fieldDefault("outputSinkHeadBytes", 20480)),
4937
- minimizerEnabled: z.boolean().default(fieldDefault("minimizerEnabled", true)),
4938
- interceptorEnabled: z.boolean().default(fieldDefault("interceptorEnabled", true)),
4939
- nonInteractiveEnv: z.boolean().default(fieldDefault("nonInteractiveEnv", true)),
4940
- snapshotEnabled: z.boolean().default(fieldDefault("snapshotEnabled", true)),
4941
- rmSafe: z.boolean().default(fieldDefault("rmSafe", true)),
4942
- useShellCommandWrapper: z.boolean().default(fieldDefault("useShellCommandWrapper", false)),
4943
- webCards: z.boolean().default(fieldDefault("webCards", true)),
4944
- maxBackgroundJobs: z.number().default(fieldDefault("maxBackgroundJobs", 15)),
4945
- outputTruncateStrategy: z.union(["bytes", "lines"]).default(fieldDefault("outputTruncateStrategy", "bytes")),
4946
- outputTruncateTriggerBytes: z.number().default(fieldDefault("outputTruncateTriggerBytes", 10240)),
4947
- outputTruncateTriggerLines: z.number().default(fieldDefault("outputTruncateTriggerLines", 100)),
4951
+ enableRunInBackground: z.boolean().default(fieldDefault("enableRunInBackground", true)).volatile(),
4952
+ autoBackgroundMs: z.number().default(fieldDefault("autoBackgroundMs", DEFAULT_AUTO_BACKGROUND_MS)).volatile(),
4953
+ defaultTimeoutMs: z.number().default(fieldDefault("defaultTimeoutMs", DEFAULT_TIMEOUT_MS)).volatile(),
4954
+ maxTimeoutMs: z.number().default(fieldDefault("maxTimeoutMs", DEFAULT_MAX_TIMEOUT_MS)).volatile(),
4955
+ outputMaxBytes: z.number().default(fieldDefault("outputMaxBytes", 51200)).volatile(),
4956
+ outputSinkTailBytes: z.number().default(fieldDefault("outputSinkTailBytes", 51200)).volatile(),
4957
+ outputSinkHeadBytes: z.number().default(fieldDefault("outputSinkHeadBytes", 20480)).volatile(),
4958
+ minimizerEnabled: z.boolean().default(fieldDefault("minimizerEnabled", true)).volatile(),
4959
+ interceptorEnabled: z.boolean().default(fieldDefault("interceptorEnabled", true)).volatile(),
4960
+ nonInteractiveEnv: z.boolean().default(fieldDefault("nonInteractiveEnv", true)).volatile(),
4961
+ snapshotEnabled: z.boolean().default(fieldDefault("snapshotEnabled", true)).volatile(),
4962
+ rmSafe: z.boolean().default(fieldDefault("rmSafe", true)).volatile(),
4963
+ useShellCommandWrapper: z.boolean().default(fieldDefault("useShellCommandWrapper", false)).volatile(),
4964
+ webCards: z.boolean().default(fieldDefault("webCards", true)).volatile(),
4965
+ maxBackgroundJobs: z.number().default(fieldDefault("maxBackgroundJobs", 15)).volatile(),
4966
+ outputTruncateStrategy: z.union(["bytes", "lines"]).default(fieldDefault("outputTruncateStrategy", "bytes")).volatile(),
4967
+ outputTruncateTriggerBytes: z.number().default(fieldDefault("outputTruncateTriggerBytes", 10240)).volatile(),
4968
+ outputTruncateTriggerLines: z.number().default(fieldDefault("outputTruncateTriggerLines", 100)).volatile(),
4948
4969
  outputTruncateByteMode: z.union([
4949
4970
  "head",
4950
4971
  "tail",
4951
4972
  "middle"
4952
- ]).default(fieldDefault("outputTruncateByteMode", "middle")),
4953
- outputTruncateByteHeadBytes: z.number().default(fieldDefault("outputTruncateByteHeadBytes", 4096)),
4954
- outputTruncateByteTailBytes: z.number().default(fieldDefault("outputTruncateByteTailBytes", 4096)),
4973
+ ]).default(fieldDefault("outputTruncateByteMode", "middle")).volatile(),
4974
+ outputTruncateByteHeadBytes: z.number().default(fieldDefault("outputTruncateByteHeadBytes", 4096)).volatile(),
4975
+ outputTruncateByteTailBytes: z.number().default(fieldDefault("outputTruncateByteTailBytes", 4096)).volatile(),
4955
4976
  outputTruncateLineMode: z.union([
4956
4977
  "head",
4957
4978
  "tail",
4958
4979
  "middle"
4959
- ]).default(fieldDefault("outputTruncateLineMode", "middle")),
4960
- outputTruncateLineHeadLines: z.number().default(fieldDefault("outputTruncateLineHeadLines", 50)),
4961
- outputTruncateLineTailLines: z.number().default(fieldDefault("outputTruncateLineTailLines", 100)),
4962
- editBlockAutoGenerated: z.boolean().default(fieldDefault("editBlockAutoGenerated", true)),
4963
- readSummarizeEnabled: z.boolean().default(fieldDefault("readSummarizeEnabled", true)),
4964
- readSummarizeProse: z.boolean().default(fieldDefault("readSummarizeProse", false)),
4965
- readSummarizeMinBodyLines: z.number().default(fieldDefault("readSummarizeMinBodyLines", 4)),
4966
- readSummarizeMinCommentLines: z.number().default(fieldDefault("readSummarizeMinCommentLines", 6)),
4967
- readSummarizeMinTotalLines: z.number().default(fieldDefault("readSummarizeMinTotalLines", 100)),
4968
- readSummarizeUnfoldUntil: z.number().default(fieldDefault("readSummarizeUnfoldUntil", 50)),
4969
- readSummarizeUnfoldLimit: z.number().default(fieldDefault("readSummarizeUnfoldLimit", 100)),
4980
+ ]).default(fieldDefault("outputTruncateLineMode", "middle")).volatile(),
4981
+ outputTruncateLineHeadLines: z.number().default(fieldDefault("outputTruncateLineHeadLines", 50)).volatile(),
4982
+ outputTruncateLineTailLines: z.number().default(fieldDefault("outputTruncateLineTailLines", 100)).volatile(),
4983
+ editBlockAutoGenerated: z.boolean().default(fieldDefault("editBlockAutoGenerated", true)).volatile(),
4984
+ readSummarizeEnabled: z.boolean().default(fieldDefault("readSummarizeEnabled", true)).volatile(),
4985
+ readSummarizeProse: z.boolean().default(fieldDefault("readSummarizeProse", false)).volatile(),
4986
+ readSummarizeMinBodyLines: z.number().default(fieldDefault("readSummarizeMinBodyLines", 4)).volatile(),
4987
+ readSummarizeMinCommentLines: z.number().default(fieldDefault("readSummarizeMinCommentLines", 6)).volatile(),
4988
+ readSummarizeMinTotalLines: z.number().default(fieldDefault("readSummarizeMinTotalLines", 100)).volatile(),
4989
+ readSummarizeUnfoldUntil: z.number().default(fieldDefault("readSummarizeUnfoldUntil", 50)).volatile(),
4990
+ readSummarizeUnfoldLimit: z.number().default(fieldDefault("readSummarizeUnfoldLimit", 100)).volatile(),
4970
4991
  editMode: z.union([
4971
4992
  "replace",
4972
4993
  "patch",
4973
4994
  "hashline",
4974
4995
  "apply_patch"
4975
- ]).default(fieldDefault("editMode", "replace")),
4976
- editFuzzyMatch: z.boolean().default(fieldDefault("editFuzzyMatch", true)),
4977
- editFuzzyThreshold: z.number().default(fieldDefault("editFuzzyThreshold", .95)),
4978
- editEnforceSeenLines: z.boolean().default(fieldDefault("editEnforceSeenLines", false)),
4979
- readDefaultLimit: z.number().default(fieldDefault("readDefaultLimit", 300)),
4980
- readLineNumbers: z.boolean().default(fieldDefault("readLineNumbers", false)),
4981
- readRenderMarkdown: z.boolean().default(fieldDefault("readRenderMarkdown", false)),
4982
- readConcurrentSafe: z.boolean().default(fieldDefault("readConcurrentSafe", true)),
4983
- grepContextBefore: z.number().default(fieldDefault("grepContextBefore", 1)),
4984
- grepContextAfter: z.number().default(fieldDefault("grepContextAfter", 3)),
4985
- grepCaseDefault: z.boolean().default(fieldDefault("grepCaseDefault", true)),
4986
- grepGitignoreDefault: z.boolean().default(fieldDefault("grepGitignoreDefault", true)),
4987
- globGitignoreDefault: z.boolean().default(fieldDefault("globGitignoreDefault", true)),
4988
- globHiddenDefault: z.boolean().default(fieldDefault("globHiddenDefault", true)),
4989
- astGrepEnabled: z.boolean().default(fieldDefault("astGrepEnabled", false)),
4990
- astEditEnabled: z.boolean().default(fieldDefault("astEditEnabled", true)),
4991
- fetchEnabled: z.boolean().default(fieldDefault("fetchEnabled", true)),
4992
- fetchMaxTimeoutSeconds: z.number().default(fieldDefault("fetchMaxTimeoutSeconds", 0)),
4996
+ ]).default(fieldDefault("editMode", "replace")).volatile(),
4997
+ editFuzzyMatch: z.boolean().default(fieldDefault("editFuzzyMatch", true)).volatile(),
4998
+ editFuzzyThreshold: z.number().default(fieldDefault("editFuzzyThreshold", .95)).volatile(),
4999
+ editEnforceSeenLines: z.boolean().default(fieldDefault("editEnforceSeenLines", false)).volatile(),
5000
+ readDefaultLimit: z.number().default(fieldDefault("readDefaultLimit", 300)).volatile(),
5001
+ readLineNumbers: z.boolean().default(fieldDefault("readLineNumbers", false)).volatile(),
5002
+ readRenderMarkdown: z.boolean().default(fieldDefault("readRenderMarkdown", false)).volatile(),
5003
+ readConcurrentSafe: z.boolean().default(fieldDefault("readConcurrentSafe", true)).volatile(),
5004
+ grepContextBefore: z.number().default(fieldDefault("grepContextBefore", 1)).volatile(),
5005
+ grepContextAfter: z.number().default(fieldDefault("grepContextAfter", 3)).volatile(),
5006
+ grepCaseDefault: z.boolean().default(fieldDefault("grepCaseDefault", true)).volatile(),
5007
+ grepGitignoreDefault: z.boolean().default(fieldDefault("grepGitignoreDefault", true)).volatile(),
5008
+ globGitignoreDefault: z.boolean().default(fieldDefault("globGitignoreDefault", true)).volatile(),
5009
+ globHiddenDefault: z.boolean().default(fieldDefault("globHiddenDefault", true)).volatile(),
5010
+ astGrepEnabled: z.boolean().default(fieldDefault("astGrepEnabled", false)).volatile(),
5011
+ astEditEnabled: z.boolean().default(fieldDefault("astEditEnabled", true)).volatile(),
5012
+ fetchEnabled: z.boolean().default(fieldDefault("fetchEnabled", true)).volatile(),
5013
+ fetchMaxTimeoutSeconds: z.number().default(fieldDefault("fetchMaxTimeoutSeconds", 0)).volatile(),
4993
5014
  fetchReader: z.union([
4994
5015
  "auto",
4995
5016
  "native",
@@ -4998,26 +5019,39 @@ const Config = z.object({
4998
5019
  "parallel",
4999
5020
  "jina",
5000
5021
  "browser"
5001
- ]).default(fieldDefault("fetchReader", "auto")),
5002
- browserReaderEnabled: z.boolean().default(fieldDefault("browserReaderEnabled", true)),
5003
- imagesAutoResize: z.boolean().default(fieldDefault("imagesAutoResize", true)),
5004
- imagesBlockImages: z.boolean().default(fieldDefault("imagesBlockImages", false)),
5005
- imagesExcludeWebp: z.boolean().default(fieldDefault("imagesExcludeWebp", false)),
5006
- imagesInputMaxBytes: z.number().default(fieldDefault("imagesInputMaxBytes", 20971520)),
5007
- imagesResizeMaxSide: z.number().default(fieldDefault("imagesResizeMaxSide", 1568)),
5008
- imagesResizeMaxBytes: z.number().default(fieldDefault("imagesResizeMaxBytes", 512e3)),
5009
- imagesResizeMinSide: z.number().default(fieldDefault("imagesResizeMinSide", 200)),
5010
- imagesResizeJpegQuality: z.number().default(fieldDefault("imagesResizeJpegQuality", 80))
5022
+ ]).default(fieldDefault("fetchReader", "auto")).volatile(),
5023
+ browserReaderEnabled: z.boolean().default(fieldDefault("browserReaderEnabled", true)).volatile(),
5024
+ imagesAutoResize: z.boolean().default(fieldDefault("imagesAutoResize", true)).volatile(),
5025
+ imagesBlockImages: z.boolean().default(fieldDefault("imagesBlockImages", false)).volatile(),
5026
+ imagesExcludeWebp: z.boolean().default(fieldDefault("imagesExcludeWebp", false)).volatile(),
5027
+ imagesInputMaxBytes: z.number().default(fieldDefault("imagesInputMaxBytes", 20971520)).volatile(),
5028
+ imagesResizeMaxSide: z.number().default(fieldDefault("imagesResizeMaxSide", 1568)).volatile(),
5029
+ imagesResizeMaxBytes: z.number().default(fieldDefault("imagesResizeMaxBytes", 512e3)).volatile(),
5030
+ imagesResizeMinSide: z.number().default(fieldDefault("imagesResizeMinSide", 200)).volatile(),
5031
+ imagesResizeJpegQuality: z.number().default(fieldDefault("imagesResizeJpegQuality", 80)).volatile()
5011
5032
  });
5012
5033
  /**
5034
+ * Unwrap the Loader's live field references into a plain settings document.
5035
+ * @param input - the entry's live config, or an already-plain document.
5036
+ * @returns a plain copy carrying the fields the input has.
5037
+ */
5038
+ function plainFields(input) {
5039
+ const out = {};
5040
+ for (const [field, value] of Object.entries(input)) Object.assign(out, { [field]: isLiveField(value) ? value.get() : value });
5041
+ return out;
5042
+ }
5043
+ /**
5013
5044
  * Resolve the flat settings (composition entry or the settings document) to
5014
5045
  * the nested runtime config the ported OMP runtime consumes. A direct mount
5015
5046
  * (`ctx.plugin`) skips the Loader's schema parse, so the `?? default` pass
5016
5047
  * mirrors the schema; the settings namespace resolution rides the same path.
5017
- * @param config - flat settings, possibly partial and not schema-parsed.
5048
+ * Accepts both shapes a caller can hold: the Loader's live entry config (one
5049
+ * reference per field) and a plain settings document.
5050
+ * @param input - the entry's live config, or a plain (possibly partial) document.
5018
5051
  * @returns the fully-defaulted nested runtime config.
5019
5052
  */
5020
- function resolveConfig(config) {
5053
+ function resolveConfig(input) {
5054
+ const config = plainFields(input);
5021
5055
  const resolveMinimizer = () => ({
5022
5056
  enabled: config.minimizerEnabled ?? fieldDefault("minimizerEnabled", true),
5023
5057
  only: [],
@@ -5094,29 +5128,46 @@ function resolveConfig(config) {
5094
5128
  };
5095
5129
  }
5096
5130
  /**
5097
- * Install the optional-settings consumer wiring for this plugin: while a
5098
- * settings provider is mounted, resolve through the `tool-plus` namespace
5099
- * (schema defaults → composition entry `base` → user document) and forward the
5100
- * scope to `onSource`; when none is mounted, keep the composition entry, so
5101
- * the tool keeps working exactly as composed. Mirrors the official bash-local
5102
- * pattern so a committed change applies without a reload.
5131
+ * Install the optional-settings consumer wiring for this plugin, in the dsh
5132
+ * 0.1.7 shape.
5133
+ *
5134
+ * 0.1.7 deleted the namespace-registration API (`SettingsForms` no longer has
5135
+ * `installSection`): a settings namespace is not something a plugin installs
5136
+ * any more — it IS the plugin's own profile entry, and the service serves
5137
+ * exactly the Config fields whose schema declares them live
5138
+ * ({@link Config} declares every field `.volatile()`). The plugin therefore
5139
+ * keeps one authoritative source, its own entry config:
5140
+ *
5141
+ * - the Loader merges the composition layers and the profile override before
5142
+ * `apply` runs, so the entry already carries the effective settings;
5143
+ * - a settings write is committed INTO that entry's live references without a
5144
+ * remount, and the Loader then announces the changed paths on this fiber
5145
+ * (`loader/volatile-update`) — which is the signal for refreshing
5146
+ * registration facts (the `editMode`-sensitive descriptions and the AST
5147
+ * switches), exactly as the settings docs prescribe.
5148
+ *
5149
+ * The plugin ships its own settings pages in the browser half, so it owns its
5150
+ * page policy: `configure({ auto: false })` tells the settings service not to
5151
+ * auto-generate a schema page for this entry. That call rides an optional
5152
+ * `ctx.inject(['settings'], ...)` child, so the plugin runs unchanged in a
5153
+ * deployment that composes no settings service at all.
5154
+ *
5155
+ * Mirrors the official bash-local pattern (`packages/shell/bash-local`): the
5156
+ * business plugin reads its own Config references and registers only its page
5157
+ * policy; nothing re-registers the namespace.
5103
5158
  * @param ctx - plugin context owning the wiring.
5104
- * @param entry - composition entry config declared by the caller.
5159
+ * @param entry - the entry's config as the Loader resolved it (live references
5160
+ * over the composition layers and the profile override), or a plain document.
5105
5161
  * @param onSource - receives a thunk of the currently authoritative runtime config.
5106
5162
  */
5107
5163
  function installBashPlusSettings(ctx, entry, onSource) {
5108
- let currentSource;
5164
+ const current = () => resolveConfig(entry);
5165
+ onSource(current);
5166
+ ctx.on("loader/volatile-update", () => {
5167
+ onSource(current);
5168
+ });
5109
5169
  ctx.inject(["settings"], (settingsCtx) => {
5110
- settingsCtx.settings.installSection(ctx, BASH_PLUS_SETTINGS_NS, Config, entry, {
5111
- setSource: (current) => {
5112
- currentSource = current;
5113
- onSource(() => resolveConfig(current()));
5114
- },
5115
- onChange: () => {
5116
- const source = currentSource;
5117
- if (source) onSource(() => resolveConfig(source()));
5118
- }
5119
- });
5170
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber), "tool-plus: own settings pages");
5120
5171
  });
5121
5172
  }
5122
5173
  //#endregion
@@ -5135,13 +5186,27 @@ function installBashPlusSettings(ctx, entry, onSource) {
5135
5186
  /** Logical RPC channel registered by the host half of this plugin. */
5136
5187
  const TOOL_PLUS_RPC_CHANNEL = "/tool-plus";
5137
5188
  /**
5138
- * The preset ids this plugin ships a template for. Single source of truth for
5139
- * both halves: the host uses it for the startup bootstrap and the template
5140
- * lookup, the panel uses it to synthesize the "not installed" rows for a
5141
- * deployment whose roster does not carry them yet.
5189
+ * The preset ids this plugin **declares**. Single source of truth for both
5190
+ * halves: the host picks its own declarations out of the profile's composed
5191
+ * configuration, and the panel uses the list to tell "ours" from everything else.
5192
+ *
5193
+ * They ship as `@deepseek-ai/dsh-agent-preset` declaration rows in this
5194
+ * package's own bundle patches (`presets/<id>.patch.yml`), so installing the
5195
+ * plugin as a profile bundle declares them: no install step, no file copy, and
5196
+ * nothing written at startup.
5142
5197
  */
5143
5198
  const BUNDLED_PRESET_IDS = ["tool-plus-standard", "tool-plus-ptc"];
5144
5199
  /**
5200
+ * Loader row id a preset declaration is addressed by. A profile patch overrides
5201
+ * a declaration **by this id** — the harness's config editor locates the row by
5202
+ * id, and the override replaces the row's whole `config`.
5203
+ * @param presetId - The preset identity (the declaration's `config.id`).
5204
+ * @returns The row id as it appears in profile patches.
5205
+ */
5206
+ function profileRowId(presetId) {
5207
+ return `preset-${presetId}`;
5208
+ }
5209
+ /**
5145
5210
  * Whether one difference belongs to the "yours" group (you added or changed it)
5146
5211
  * rather than the "behind" group (the template has it, you do not). Lives in the
5147
5212
  * shared contract so host and client group the exact same way.
@@ -5288,8 +5353,8 @@ function parsePresetComparePayload(payload) {
5288
5353
  }
5289
5354
  /**
5290
5355
  * Validate a `presets/apply` payload. Anything that is not exactly
5291
- * `{ id: non-empty string, action: 'upgrade' | 'reset' }` is rejected, so the
5292
- * write endpoint never receives a half-formed request.
5356
+ * `{ id: non-empty string, action: 'upgrade' | 'align' | 'revert', templateId?: non-empty string }`
5357
+ * is rejected, so the write endpoint never receives a half-formed request.
5293
5358
  * @param payload - the raw RPC payload.
5294
5359
  * @returns the narrowed payload, or undefined when invalid.
5295
5360
  */
@@ -5297,7 +5362,7 @@ function parsePresetActionPayload(payload) {
5297
5362
  if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
5298
5363
  const { id, action, templateId } = payload;
5299
5364
  if (typeof id !== "string" || id.length === 0) return void 0;
5300
- if (action !== "upgrade" && action !== "reset") return void 0;
5365
+ if (action !== "upgrade" && action !== "align" && action !== "revert") return void 0;
5301
5366
  if (templateId !== void 0 && (typeof templateId !== "string" || templateId.length === 0)) return void 0;
5302
5367
  return templateId === void 0 ? {
5303
5368
  id,
@@ -5308,26 +5373,8 @@ function parsePresetActionPayload(payload) {
5308
5373
  templateId
5309
5374
  };
5310
5375
  }
5311
- /**
5312
- * Narrow one roster entry coming from the official service. Unknown trust is
5313
- * treated as `user` (the conservative side: it is a writable preset only when
5314
- * the caller proves otherwise — the caller matches ids against our templates).
5315
- * @param preset - raw entry from `ctx.agentPresets.list()`.
5316
- * @returns the entry in our own shape.
5317
- */
5318
- function toRosterEntry(preset) {
5319
- const text = (value) => typeof value === "string" && value.length > 0 ? value : void 0;
5320
- return {
5321
- id: text(preset.id) ?? "",
5322
- trust: preset.trust === "system" ? "system" : "user",
5323
- path: text(preset.path) ?? "",
5324
- name: text(preset.name),
5325
- description: text(preset.description),
5326
- broken: text(preset.broken)
5327
- };
5328
- }
5329
5376
  /** One RPC error result in the gateway's shape. */
5330
- function failure$1(error, code = "internal") {
5377
+ function failure$2(error, code = "internal") {
5331
5378
  return {
5332
5379
  ok: false,
5333
5380
  error: {
@@ -5338,62 +5385,47 @@ function failure$1(error, code = "internal") {
5338
5385
  };
5339
5386
  }
5340
5387
  /**
5341
- * Answer one preset endpoint on the shared `/tool-plus` channel.
5388
+ * Answer one preset endpoint on the shared `/tool-plus` route.
5342
5389
  *
5343
5390
  * Returns `undefined` for an endpoint this module does not own, so the caller
5344
- * (the single channel handler) can keep serving its own families. The service
5345
- * is resolved per call: a deployment that mounts `agent-presets` after this
5346
- * plugin still gets a working endpoint instead of a stale `undefined`.
5347
- * @param ctx - context used to resolve the `agentPresets` service.
5348
- * @param deps - injected preset analysis.
5391
+ * (the single route handler) can keep serving its own families.
5392
+ * @param ctx - context forwarded to the injected host implementation.
5393
+ * @param deps - injected host implementation.
5349
5394
  * @param endpoint - the requested endpoint name.
5350
5395
  * @param payload - the raw RPC payload.
5351
5396
  * @returns the RPC result, or undefined when the endpoint is not ours.
5352
5397
  */
5353
5398
  async function handlePresetEndpoint(ctx, deps, endpoint, payload) {
5354
5399
  if (endpoint !== "presets/status" && endpoint !== "presets/apply" && endpoint !== "presets/compare") return;
5355
- const service = ctx.get("agentPresets");
5356
- if (!service || typeof service.list !== "function") return failure$1("agent presets are not available in this deployment");
5357
5400
  if (endpoint === "presets/status") try {
5358
- const roster = await service.list();
5359
5401
  return {
5360
5402
  ok: true,
5361
- value: {
5362
- presets: deps.listStatuses(roster.map(toRosterEntry)),
5363
- templates: deps.listTemplates()
5364
- }
5403
+ value: await deps.listPresets(ctx)
5365
5404
  };
5366
5405
  } catch (error) {
5367
- return failure$1(error);
5406
+ return failure$2(error);
5368
5407
  }
5369
5408
  if (endpoint === "presets/compare") {
5370
5409
  const parsed = parsePresetComparePayload(payload);
5371
- if (!parsed) return failure$1("presets/compare expects { presetId: string, templateId: string }", "bad-request");
5410
+ if (!parsed) return failure$2("presets/compare expects { presetId: string, templateId: string }", "bad-request");
5372
5411
  try {
5373
- const preset = (await service.list()).map(toRosterEntry).find((entry) => entry.id === parsed.presetId);
5374
- if (preset === void 0) return failure$1(`unknown preset: ${parsed.presetId}`, "bad-request");
5375
- if (!deps.listTemplates().some((template) => template.id === parsed.templateId)) return failure$1(`unknown template: ${parsed.templateId}`, "bad-request");
5376
5412
  return {
5377
5413
  ok: true,
5378
- value: {
5379
- presetId: parsed.presetId,
5380
- templateId: parsed.templateId,
5381
- ...deps.comparePreset(preset, parsed.templateId)
5382
- }
5414
+ value: await deps.comparePreset(ctx, parsed.presetId, parsed.templateId)
5383
5415
  };
5384
5416
  } catch (error) {
5385
- return failure$1(error);
5417
+ return failure$2(error);
5386
5418
  }
5387
5419
  }
5388
5420
  const parsed = parsePresetActionPayload(payload);
5389
- if (!parsed) return failure$1("presets/apply expects { id: string, action: \"upgrade\" | \"reset\", templateId?: string }", "bad-request");
5421
+ if (!parsed) return failure$2("presets/apply expects { id: string, action: \"upgrade\" | \"align\" | \"revert\", templateId?: string }", "bad-request");
5390
5422
  try {
5391
5423
  return {
5392
5424
  ok: true,
5393
- value: deps.applyAction(parsed.id, parsed.action, parsed.templateId)
5425
+ value: await deps.applyAction(ctx, parsed.id, parsed.action, parsed.templateId)
5394
5426
  };
5395
5427
  } catch (error) {
5396
- return failure$1(error);
5428
+ return failure$2(error);
5397
5429
  }
5398
5430
  }
5399
5431
  //#endregion
@@ -5511,7 +5543,8 @@ async function queryRmSafeStatus(deps) {
5511
5543
  *
5512
5544
  * **Why this registers its own route instead of calling `connection.rpc.handle`.**
5513
5545
  * The documented helper is unusable from a plugin in a real web deployment on
5514
- * dsh 0.1.5-rc.1: `HostConnectionService.rpc` builds its handle against
5546
+ * dsh 0.1.5-rc.1 — and still on 0.1.7-rc.1, re-checked for this migration:
5547
+ * `HostConnectionService.rpc` builds its handle against
5515
5548
  * `owner = this.ctx` (`rpc-host.ts: "Generic channel registry scoped to the
5516
5549
  * Context reading this service"`), and `register()` then evaluates
5517
5550
  * `owner.webServer.register(route)`. The client-connection row declares
@@ -5542,7 +5575,7 @@ async function queryRmSafeStatus(deps) {
5542
5575
  /** Cap on one request body; every legitimate payload here is a few hundred bytes. */
5543
5576
  const MAX_BODY_BYTES = 1048576;
5544
5577
  /** One internal error result in the gateway's shape. */
5545
- function failure(error) {
5578
+ function failure$1(error) {
5546
5579
  return {
5547
5580
  ok: false,
5548
5581
  error: {
@@ -5626,7 +5659,7 @@ function installBrowserProbeRpc(ctx, deps) {
5626
5659
  value: { found: probeBrowsers() }
5627
5660
  };
5628
5661
  } catch (error) {
5629
- return failure(error);
5662
+ return failure$1(error);
5630
5663
  }
5631
5664
  if (endpoint === "rmSafe/status") {
5632
5665
  if (!deps.getRmSafe()) return {
@@ -5647,7 +5680,7 @@ function installBrowserProbeRpc(ctx, deps) {
5647
5680
  })
5648
5681
  };
5649
5682
  } catch (error) {
5650
- return failure(error);
5683
+ return failure$1(error);
5651
5684
  }
5652
5685
  }
5653
5686
  if (deps.presets !== void 0) {
@@ -5733,772 +5766,295 @@ function installBrowserProbeRpc(ctx, deps) {
5733
5766
  };
5734
5767
  }
5735
5768
  //#endregion
5736
- //#region src/presets/paths.ts
5737
- /**
5738
- * preset 安装路径解析:`<dshHome>`、用户根预设目录、包内模板目录。
5739
- *
5740
- * 三处路径各有唯一来源:
5741
- * - `<dshHome>`:`PresetDeps.homeDir`(测试注入)→ `$DSH_HOME`(trim 后非空)
5742
- * → `~/.dsh`。规则与官方 `@deepseek-ai/dsh-home-paths` 的
5743
- * `defaultDshHome()` / `DSH_HOME_ENV`(`'DSH_HOME'`)/ 目录名 `.dsh`
5744
- * 完全一致;这里就地实现而不 import 该包——它在插件安装树里解析不到,
5745
- * 静态 import 一个解析不到的模块会让整个插件加载失败。
5746
- * - 用户根:`<dshHome>/.agent-presets`(与 `dsh-agent-presets` 的
5747
- * `USER_PRESET_DIR` 同值,所有 profile 共享)。
5748
- * - 模板:插件包内 `presets/<id>/`,用 `import.meta.url` 相对解析(与
5749
- * `rmSafeCliPath()` 同一手法,见 src/tools/bash/rm-safe.ts)。构建后入口
5750
- * 是单文件 `lib/index.mjs`,源码期是 `src/presets/paths.ts`,两者到包根的
5751
- * 相对深度不同,所以向上探两个候选、取同时含 `package.json` 与 `presets/`
5752
- * 的那个。
5753
- * @module @xiaoso/dsh-tool-plus/presets/paths
5754
- */
5755
- /** 用户根预设目录名(`dsh-agent-presets` 的 `USER_PRESET_DIR` 同值)。 */
5756
- const PRESET_DIR_NAME = ".agent-presets";
5757
- /** 让一个目录成为 preset 的组合文件名。 */
5758
- const COMPOSITION_FILE_NAME = "agent.cordis.yml";
5759
- /** 组合文件旁的可选展示元数据文件名。 */
5760
- const METADATA_FILE_NAME = "preset.yml";
5761
- /**
5762
- * 本插件随包的两个预设 id(启动补缺只认它们)。名单的单一来源是共享契约
5763
- * 模块 {@link BUNDLED_PRESET_IDS},这里只做本模块语义下的别名。
5764
- */
5765
- const DEFAULT_PRESET_IDS = BUNDLED_PRESET_IDS;
5766
- /** 覆盖 `DSH_HOME` 的环境变量名(与官方 `DSH_HOME_ENV` 同值)。 */
5767
- const DSH_HOME_ENV = "DSH_HOME";
5768
- /** 默认 harness home 的目录名(与官方 `DSH_HOME_DIR_NAME` 同值)。 */
5769
- const DSH_HOME_DIR_NAME = ".dsh";
5770
- /**
5771
- * 解析 `<dshHome>`。优先级:注入的 `homeDir` → `$DSH_HOME`(trim 后非空)
5772
- * → `~/.dsh`,与官方 `@deepseek-ai/dsh-home-paths` 的 `defaultDshHome()` 同规则。
5773
- * @param deps - 可选的注入覆盖。
5774
- * @returns 绝对路径的 harness home。
5775
- */
5776
- function dshHomeDir(deps = {}) {
5777
- if (deps.homeDir !== void 0 && deps.homeDir.trim().length > 0) return path$1.resolve(deps.homeDir);
5778
- const env = process.env[DSH_HOME_ENV];
5779
- return env !== void 0 && env.trim().length > 0 ? path$1.resolve(env) : path$1.join(os.homedir(), DSH_HOME_DIR_NAME);
5780
- }
5781
- /** 用户根预设目录:`<dshHome>/.agent-presets`(所有 profile 共享的写入根)。 */
5782
- function userPresetRoot(deps = {}) {
5783
- return path$1.join(dshHomeDir(deps), PRESET_DIR_NAME);
5784
- }
5785
- /** 某个预设的用户根目录:`<dshHome>/.agent-presets/<id>`。 */
5786
- function userPresetDir(id, deps = {}) {
5787
- return path$1.join(userPresetRoot(deps), id);
5788
- }
5789
- /**
5790
- * 插件包根:注入的 `packageRoot` 优先,否则按 `import.meta.url` 向上探。
5791
- * 构建后入口是 `lib/index.mjs`(`..` 即包根),源码期是
5792
- * `src/presets/paths.ts`(`../..` 即包根),两个候选都试、取同时含
5793
- * `package.json` 与 `presets/` 的那个;都不命中时返回 `../..` 候选。
5794
- */
5795
- function packageRootDir(deps = {}) {
5796
- if (deps.packageRoot !== void 0 && deps.packageRoot.trim().length > 0) return path$1.resolve(deps.packageRoot);
5797
- const here = path$1.dirname(fileURLToPath(import.meta.url));
5798
- const candidates = [path$1.resolve(here, "..", ".."), path$1.resolve(here, "..")];
5799
- for (const candidate of candidates) if (fs.existsSync(path$1.join(candidate, "package.json")) && fs.existsSync(path$1.join(candidate, "presets"))) return candidate;
5800
- return candidates[0];
5801
- }
5802
- /** 包内模板目录:`<包根>/presets/<id>`。 */
5803
- function templateDir(id, deps = {}) {
5804
- return path$1.join(packageRootDir(deps), "presets", id);
5769
+ //#region src/presets/rows.ts
5770
+ /** 行 id 的取值(非空字符串才认,与官方对 `name` 的要求同款)。 */
5771
+ function rowId(row) {
5772
+ return typeof row.id === "string" && row.id.length > 0 ? row.id : void 0;
5805
5773
  }
5806
- //#endregion
5807
- //#region src/presets/rewrite.ts
5808
- /**
5809
- * preset 组合改写:纯函数,文本进文本出,零 I/O。
5810
- *
5811
- * 一个预设要"支持本插件的工具",只需让官方 4 个冲突行 id 处于「不存在」或
5812
- * `disabled: true` 状态(插件已在宿主面全局接管这 4 个工具;预设里再挂一份
5813
- * 会造成 per-session 影子实例)。本模块只做这一件事:
5814
- *
5815
- * - 只认**顶层**行 `- id: <target>`(`-` 在第 0 列),嵌套子行一律不碰;
5816
- * - 行块已有 `disabled: true` → no-op;`disabled: false`(或任何非 `true`
5817
- * 标量)→ 就地改成 `true`;没有 `disabled` → 在行块末尾追加一行
5818
- * ` disabled: true`(缩进与行内其它键对齐);行块不存在 → 不动;
5819
- * - 注释、空行、缩进、CRLF/LF、YAML 顺序、用户的本地调参全部逐字节保留;
5820
- * - 输出与输入逐字节相同时 `changes` 为空——调用方以字节相等判定是否写盘
5821
- * (preset 代际以 `agent.cordis.yml` 的 mtime 为键,白写一次就白造一代);
5822
- * - 文本不构成顶层行列表 → `shape: 'unrecognized'`,一律不改写。
5823
- *
5824
- * `PresetChange.action` 的 `'absent'` 供 RPC 摘要层描述"这行本来就不挂",
5825
- * 改写本身只报告真实发生的改动(否则"幂等时 changes 为空"不成立)。
5826
- * @module @xiaoso/dsh-tool-plus/presets/rewrite
5827
- */
5828
- /** 默认冲突行 id:官方 standard / ptc / cordis 挂的 4 个官方工具行。 */
5829
- const PRESET_CONFLICT_IDS = [
5830
- "tool-bash",
5831
- "tool-pwsh",
5832
- "tool-fs",
5833
- "tool-fs-search"
5834
- ];
5835
- /** 顶层 `- id: <value>` 行(允许尾随注释;忽略缩进的同名行)。 */
5836
- const TOP_ID_ROW = /^-[ \t]+id:[ \t]*([^#\s]+)[ \t]*(?:#.*)?$/;
5837
- /** 任意顶层行(块边界判定)。 */
5838
- const TOP_ROW = /^-(?:[ \t]|$)/;
5839
- /** 行内键:`disabled: <value>`(任意缩进,缩进另判)。 */
5840
- const DISABLED_KEY = /^([ \t]*)disabled[ \t]*:[ \t]*(.*)$/;
5841
- /** 注释行(含缩进的纯注释行)。 */
5842
- const COMMENT_LINE = /^[ \t]*#/;
5843
- /** 空行(含仅空白行)。 */
5844
- const BLANK_LINE = /^[ \t]*$/;
5845
- /**
5846
- * 无损拆行:每行保留自己的行尾符,`joinLines` 后与原文逐字节相同。
5847
- * 只区分 LF 与 CRLF(YAML 组合文件的两种现实写法);孤立 `\r` 当内容。
5848
- */
5849
- function splitLines(text) {
5850
- const lines = [];
5851
- let start = 0;
5852
- for (let i = 0; i < text.length; i++) {
5853
- if (text[i] !== "\n") continue;
5854
- const crlf = i > start && text[i - 1] === "\r";
5855
- lines.push({
5856
- text: text.slice(start, crlf ? i - 1 : i),
5857
- eol: crlf ? "\r\n" : "\n"
5858
- });
5859
- start = i + 1;
5860
- }
5861
- if (start < text.length) lines.push({
5862
- text: text.slice(start),
5863
- eol: ""
5864
- });
5865
- return lines;
5866
- }
5867
- /** 逐字节重建(`splitLines` 的逆运算)。 */
5868
- function joinLines(lines) {
5869
- return lines.map((line) => line.text + line.eol).join("");
5774
+ /** 行模块名(官方 entry-list 要求每行都有非空 `name`)。 */
5775
+ function rowName(row) {
5776
+ return typeof row.name === "string" && row.name.length > 0 ? row.name : void 0;
5870
5777
  }
5871
- /** 文件的行尾风格:取第一个带行尾符的行,缺省 LF。 */
5872
- function fileEol(lines) {
5873
- for (const line of lines) if (line.eol !== "") return line.eol;
5874
- return "\n";
5875
- }
5876
- /** 去掉 YAML 单双引号(`- id: 'tool-bash'`)。 */
5877
- function stripQuotes(value) {
5878
- if (value.length >= 2) {
5879
- const first = value[0];
5880
- const last = value[value.length - 1];
5881
- if (first === "'" && last === "'" || first === "\"" && last === "\"") return value.slice(1, -1);
5882
- }
5883
- return value;
5884
- }
5885
- /** 缩进宽度(空格与制表符都按 1 计)。 */
5886
- function indentWidth(line) {
5887
- return line.length - line.trimStart().length;
5778
+ /** 这一行是不是 group 载体(group 的子行在 `config` 里)。 */
5779
+ function isGroupRow(row) {
5780
+ return row.group === true && Array.isArray(row.config);
5888
5781
  }
5889
5782
  /**
5890
- * 形状防护:文本必须构成"顶层行列表"。
5891
- * 判据:至少一行有效内容;**首个**有效行是顶层 `- `;所有第 0 列有效行都是
5892
- * 顶层 `- `(映射、仅注释的空文件、被破坏的片段、只有一段缩进嵌套的片段
5893
- * 都会落在这里)。
5783
+ * 判定一份 `plugins` 值是不是合法的顶层行列表。规则与官方
5784
+ * `entryListProblem` 一致:必须是数组,每一项必须是带非空 `name` 的映射。
5785
+ * @param value - 待判定的值(来自宿主配置或测试注入)。
5786
+ * @returns 合法时为 true。
5894
5787
  */
5895
- function detectShape(lines) {
5896
- const significant = lines.filter((line) => !BLANK_LINE.test(line.text) && !COMMENT_LINE.test(line.text));
5897
- if (significant.length === 0) return "unrecognized";
5898
- if (!TOP_ROW.test(significant[0].text)) return "unrecognized";
5899
- for (const line of significant) if (line.text.trimStart() === line.text && !TOP_ROW.test(line.text)) return "unrecognized";
5900
- return "ok";
5788
+ function isRowList(value) {
5789
+ if (!Array.isArray(value)) return false;
5790
+ return value.every((row) => typeof row === "object" && row !== null && !Array.isArray(row) && rowName(row) !== void 0);
5901
5791
  }
5902
- /** 扫出全部顶层行块(含块内键缩进),按出现顺序。 */
5903
- function scanRows(lines) {
5904
- const starts = [];
5905
- for (let i = 0; i < lines.length; i++) {
5906
- const match = TOP_ID_ROW.exec(lines[i].text);
5907
- if (match === null) continue;
5908
- const id = stripQuotes(match[1]);
5909
- if (id.length === 0) continue;
5910
- starts.push({
5911
- index: i,
5912
- id
5913
- });
5792
+ /**
5793
+ * 展平一棵行树(含 group 嵌套),键为 `父/子` 形式的路径。
5794
+ *
5795
+ * 路径是**给面板看的标识**,也用于我们自己的差异比较;官方
5796
+ * `compositionInventory()` 回的行没有路径(它压平后只剩 entryId/moduleName),
5797
+ * 所以需要分组结构时只能从声明本身取。
5798
+ * @param rows - 顶层行列表。
5799
+ * @param prefix - 递归时的父路径。
5800
+ * @returns 路径 → 行。
5801
+ */
5802
+ function flattenRows(rows, prefix = "") {
5803
+ const out = /* @__PURE__ */ new Map();
5804
+ for (const row of rows) {
5805
+ const id = rowId(row);
5806
+ if (id === void 0) continue;
5807
+ const path = prefix.length > 0 ? `${prefix}/${id}` : id;
5808
+ out.set(path, row);
5809
+ if (isGroupRow(row)) for (const [nested, nestedRow] of flattenRows(row.config, path)) out.set(nested, nestedRow);
5914
5810
  }
5915
- return starts.map((row) => {
5916
- let end = lines.length;
5917
- for (let i = row.index + 1; i < lines.length; i++) if (TOP_ROW.test(lines[i].text)) {
5918
- end = i;
5919
- break;
5920
- }
5921
- let keyIndent;
5922
- let keyIndentText;
5923
- for (let i = row.index + 1; i < end; i++) {
5924
- const text = lines[i].text;
5925
- if (BLANK_LINE.test(text) || COMMENT_LINE.test(text)) continue;
5926
- keyIndent = indentWidth(text);
5927
- keyIndentText = text.slice(0, keyIndent);
5928
- break;
5929
- }
5930
- return {
5931
- id: row.id,
5932
- start: row.index,
5933
- end,
5934
- keyIndent,
5935
- keyIndentText
5936
- };
5937
- });
5811
+ return out;
5938
5812
  }
5939
5813
  /**
5940
- * 行块自身的 `disabled:` 键行下标(只认与行内其它键同缩进的那一行;
5941
- * 嵌套子行的 `disabled` 不算)。返回 undefined 表示该行块没有禁用键。
5814
+ * `!!js` 表达式标记:entry-list 方言把它解析成 `{ __jsExpr: <源码> }`(见
5815
+ * `vendor/include/src/index.ts` 的 `JsExpr`)。它对我们是**标量**——一整段待求值的
5816
+ * 源码,不是可以逐键比较的结构。漏了这一步会造出 `disabled.__jsExpr` 这种假叶子,
5817
+ * 顺带让"冲突行的 disabled 归 conflicts 管"这条跳过规则失效。
5818
+ * @param value - 待判定的值。
5819
+ * @returns 是表达式标记时为 true。
5942
5820
  */
5943
- function findDisabledLine(lines, row) {
5944
- for (let i = row.start + 1; i < row.end; i++) {
5945
- const text = lines[i].text;
5946
- if (BLANK_LINE.test(text) || COMMENT_LINE.test(text)) continue;
5947
- if (row.keyIndent === void 0 || indentWidth(text) !== row.keyIndent) continue;
5948
- if (DISABLED_KEY.test(text)) return i;
5949
- }
5821
+ function isJsExpr(value) {
5822
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 1 && typeof value.__jsExpr === "string";
5950
5823
  }
5951
- /** `disabled:` 键的值是否已是 `true`(尾随注释忽略)。 */
5952
- function disabledValue(line) {
5953
- const match = DISABLED_KEY.exec(line);
5954
- if (match === null) return "";
5955
- return match[2].replace(/[ \t]+#.*$/, "").trim();
5824
+ /** 规范化一个标量用于比较与展示。 */
5825
+ function canonical(value) {
5826
+ if (value === void 0) return "";
5827
+ if (isJsExpr(value)) return `!!js ${value.__jsExpr}`;
5828
+ return JSON.stringify(value) ?? String(value);
5956
5829
  }
5957
5830
  /**
5958
- * 把一整行 `disabled: <值>` 换成**整值** `true`,原有尾随注释保留。
5831
+ * 收集一行自己的叶子路径(不含 `id`,不含 group 的子行)。
5959
5832
  *
5960
- * 官方随附预设会用 `!!js` 表达式值(`standard` 的 tool-bash 是
5961
- * `disabled: !!js process.platform === 'win32'`)。表达式既不是 `true` 也不是
5962
- * `false`,我们无从判断它当前是否生效,所以只能整值替换;替换时把旧值以
5963
- * `# was: …` 备注留在行尾(信息不丢,且下一轮 `disabledValue` 读到的是 `true`,
5964
- * 幂等仍然成立)。逐 token 替换是错的:会把值拼成
5965
- * `true process.platform === 'win32'`,表达式被破坏、`!!js` 标签也丢了。
5966
- * @param line - 原始 `disabled:` 行(含缩进与可能的尾随注释)。
5967
- * @returns 改写后的行。
5968
- */
5969
- function flipDisabledLine(line) {
5970
- const match = DISABLED_KEY.exec(line);
5971
- if (match === null) return line;
5972
- const indent = match[1];
5973
- const rest = match[2];
5974
- const commentPart = /([ \t]+#.*)$/.exec(rest)?.[1] ?? "";
5975
- const value = (commentPart === "" ? rest : rest.slice(0, rest.length - commentPart.length)).trim();
5976
- const note = value === "" || value === "false" ? "" : `; was: ${value}`;
5977
- if (commentPart !== "") return `${indent}disabled: true${commentPart}${note}`;
5978
- return note === "" ? `${indent}disabled: true` : `${indent}disabled: true # was: ${value}`;
5979
- }
5980
- /**
5981
- * 追加位置:行块末尾最后一处"内容行"之后(跳过尾随空行与注释行,
5982
- * 免得把 `disabled` 插到下一行块的说明注释之后)。
5983
- */
5984
- function appendIndex(lines, row) {
5985
- let last = row.start;
5986
- for (let i = row.start + 1; i < row.end; i++) {
5987
- const text = lines[i].text;
5988
- if (BLANK_LINE.test(text) || COMMENT_LINE.test(text)) continue;
5989
- last = i;
5990
- }
5991
- return last + 1;
5992
- }
5993
- /** 去重后按出现顺序的冲突 id。 */
5994
- function normalizeIds(conflictIds) {
5995
- return [...new Set((conflictIds ?? PRESET_CONFLICT_IDS).filter((id) => id.length > 0))];
5996
- }
5997
- /** 逐个目标 id 定处置(不改动文本)。 */
5998
- function plan(lines, rows, ids) {
5999
- const plans = [];
6000
- for (const id of ids) {
6001
- const row = rows.find((candidate) => candidate.id === id);
6002
- if (row === void 0) continue;
6003
- const disabledAt = findDisabledLine(lines, row);
6004
- if (disabledAt !== void 0) {
6005
- if (disabledValue(lines[disabledAt].text) === "true") continue;
6006
- plans.push({
6007
- kind: "flip",
6008
- id,
6009
- line: disabledAt
5833
+ * 用规范化 JSON 而不是 `String()`:Loader 对 `disabled` 的语义是
5834
+ * "布尔取 Boolean(value)",所以字符串 `"false"` 与布尔 `false` **语义相反**;
5835
+ * 两者若都显示成 `false`,面板就会把一处真实差异藏起来。
5836
+ * @param row - 目标行。
5837
+ * @param base - 递归时的前缀。
5838
+ * @returns 该行的叶子清单(顺序为键的声明顺序)。
5839
+ */
5840
+ function rowLeaves(row, base = "") {
5841
+ const out = [];
5842
+ const walk = (value, path) => {
5843
+ if (isJsExpr(value)) {
5844
+ out.push({
5845
+ path,
5846
+ value: canonical(value)
6010
5847
  });
6011
- continue;
5848
+ return;
6012
5849
  }
6013
- const indent = row.keyIndentText ?? " ";
6014
- plans.push({
6015
- kind: "append",
6016
- id,
6017
- at: appendIndex(lines, row),
6018
- indent
5850
+ if (Array.isArray(value)) {
5851
+ value.forEach((item, index) => walk(item, `${path}[${index}]`));
5852
+ return;
5853
+ }
5854
+ if (value !== null && typeof value === "object") {
5855
+ for (const [key, nested] of Object.entries(value)) walk(nested, path.length > 0 ? `${path}.${key}` : key);
5856
+ return;
5857
+ }
5858
+ out.push({
5859
+ path,
5860
+ value: canonical(value)
6019
5861
  });
5862
+ };
5863
+ for (const [key, value] of Object.entries(row)) {
5864
+ if (key === "id") continue;
5865
+ if (key === "config" && isGroupRow(row)) continue;
5866
+ walk(value, base.length > 0 ? `${base}.${key}` : key);
6020
5867
  }
6021
- return plans;
5868
+ return out;
5869
+ }
5870
+ /** 规范化的深比较(两侧都是 YAML/JSON 值,无函数与原型差异)。 */
5871
+ function sameValue(a, b) {
5872
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
6022
5873
  }
5874
+ //#endregion
5875
+ //#region src/presets/conflicts.ts
6023
5876
  /**
6024
- * 把官方冲突行改写成"不存在或 disabled: true"。
5877
+ * 冲突不变式:一个 preset「接上了本插件的工具」当且仅当官方那 4 个工具行
5878
+ * 处于**不存在**或 **`disabled: true`** 状态。
6025
5879
  *
6026
- * `changes` 只含真实发生的改动(幂等跑第二次必为空);`shape: 'unrecognized'`
6027
- * 时原文本原样返回。
6028
- * @param text - `agent.cordis.yml` 的原文。
6029
- * @param conflictIds - 冲突行 id,缺省 {@link PRESET_CONFLICT_IDS}。
6030
- * @returns 最终文本与实际改动清单。
6031
- */
6032
- function rewritePresetComposition(text, conflictIds) {
6033
- const lines = splitLines(text);
6034
- if (detectShape(lines) === "unrecognized") return {
6035
- text,
6036
- changes: []
6037
- };
6038
- const ids = normalizeIds(conflictIds);
6039
- const plans = plan(lines, scanRows(lines), ids);
6040
- const changes = [];
6041
- const eol = fileEol(lines);
6042
- const inserts = [];
6043
- for (const entry of plans) if (entry.kind === "flip") {
6044
- lines[entry.line] = {
6045
- text: flipDisabledLine(lines[entry.line].text),
6046
- eol: lines[entry.line].eol
6047
- };
6048
- changes.push({
6049
- id: entry.id,
6050
- action: "flipped"
6051
- });
6052
- } else inserts.push({
6053
- at: entry.at,
6054
- indent: entry.indent,
6055
- id: entry.id
6056
- });
6057
- for (const insert of inserts.sort((a, b) => b.at - a.at)) {
6058
- if (insert.at === lines.length && lines.length > 0 && lines[lines.length - 1].eol === "") {
6059
- lines[lines.length - 1] = {
6060
- text: lines[lines.length - 1].text,
6061
- eol
6062
- };
6063
- lines.push({
6064
- text: `${insert.indent}disabled: true`,
6065
- eol: ""
6066
- });
6067
- } else lines.splice(insert.at, 0, {
6068
- text: `${insert.indent}disabled: true`,
6069
- eol
6070
- });
6071
- changes.push({
6072
- id: insert.id,
6073
- action: "disabled"
6074
- });
6075
- }
6076
- const ordered = ids.map((id) => changes.find((change) => change.id === id)).filter((change) => change !== void 0);
6077
- return {
6078
- text: joinLines(lines),
6079
- changes: ordered
6080
- };
5880
+ * 原因(这套设计的地基,别丢):`@xiaoso/dsh-tool-plus` 作为包在**宿主面**
5881
+ * 加载时,它自己的 profile 补丁已经禁用了这 4 行并插入**一行全局** `tool-plus`;
5882
+ * 注册表按 scope 分层、就近优先,所以宿主面那一份已经服务所有会话。若某个
5883
+ * preset 又在 **agent 面**挂一份同名工具行,就会用**第二个 `apply()` 影子实例**
5884
+ * 盖掉健康的宿主实例,而那个实例的 settings 注入永不触发(cfg 退化成
5885
+ * replace-mode 读取 —— 2026-08-25 hashline 事故的根因)。
5886
+ *
5887
+ * 与旧文本实现的唯一区别是**载体**:以前逐行读 `agent.cordis.yml` 的文本,
5888
+ * 现在直接看宿主交给我们的行对象。判定规则一字不改:
5889
+ * - 只认**顶层**行;group 里嵌套的同名行不碰;
5890
+ * - `disabled` 严格等于布尔 `true` 才算已处理 —— 缺席、`false`、字符串、
5891
+ * `!!js` 表达式统统算冲突(`!!js` 在挂载时才求值,我们要的是无条件关闭,
5892
+ * 所以和旧实现一样整值翻成 `true`);
5893
+ * - 输出恒为新数组,输入不变;结果与输入逐值相同时 `changes` 为空(调用方以
5894
+ * 此判定"不写盘")。
5895
+ * @module @xiaoso/dsh-tool-plus/presets/conflicts
5896
+ */
5897
+ /** 官方 standard / ptc / cordis 会挂的 4 个工具行 id。 */
5898
+ const PRESET_CONFLICT_IDS = [
5899
+ "tool-bash",
5900
+ "tool-pwsh",
5901
+ "tool-fs",
5902
+ "tool-fs-search"
5903
+ ];
5904
+ /** 该行是否已被无条件关闭(只有布尔 `true` 算)。 */
5905
+ function disabled(row) {
5906
+ return row.disabled === true;
5907
+ }
5908
+ /** 顶层行里按 id 找第一行(只看顶层,与不变式一致)。 */
5909
+ function topRow(rows, id) {
5910
+ return rows.find((row) => rowId(row) === id);
6081
5911
  }
6082
5912
  /**
6083
- * 只读判定:该组合还有哪些冲突行没被禁用。
6084
- * @param text - `agent.cordis.yml` 的原文。
6085
- * @param conflictIds - 冲突行 id,缺省 {@link PRESET_CONFLICT_IDS}。
6086
- * @returns 冲突清单、是否干净、形状结论。
5913
+ * 只读分析一份 `plugins` 值。
5914
+ * @param plugins - 声明的子插件列表(来自宿主配置,形状未知)。
5915
+ * @param conflictIds - 冲突行清单,默认 {@link PRESET_CONFLICT_IDS}。
5916
+ * @returns 冲突清单、是否干净、形状是否可识别。
6087
5917
  */
6088
- function analyzePresetComposition(text, conflictIds) {
6089
- const lines = splitLines(text);
6090
- const shape = detectShape(lines);
6091
- if (shape === "unrecognized") return {
5918
+ function analyzePlugins(plugins, conflictIds = PRESET_CONFLICT_IDS) {
5919
+ if (!isRowList(plugins)) return {
6092
5920
  conflicts: [],
6093
5921
  clean: false,
6094
- shape
6095
- };
6096
- const rows = scanRows(lines);
6097
- const conflicts = normalizeIds(conflictIds).filter((id) => {
6098
- const row = rows.find((candidate) => candidate.id === id);
6099
- if (row === void 0) return false;
6100
- const disabledAt = findDisabledLine(lines, row);
6101
- return disabledAt === void 0 || disabledValue(lines[disabledAt].text) !== "true";
5922
+ shape: "unrecognized"
5923
+ };
5924
+ const conflicts = conflictIds.filter((id) => {
5925
+ const row = topRow(plugins, id);
5926
+ return row !== void 0 && !disabled(row);
6102
5927
  });
6103
5928
  return {
6104
5929
  conflicts,
6105
5930
  clean: conflicts.length === 0,
6106
- shape
5931
+ shape: "ok"
6107
5932
  };
6108
5933
  }
6109
- /** 行内 `key: value`(顶层键;嵌套子键缩进更大,不在这里取)。 */
6110
- const ROW_KEY = /^[ \t]*([^#\s:][^:]*):[ \t]*(.*)$/;
6111
- /** 去掉行尾注释与引号,得到可比的值。 */
6112
- function leafValue(text) {
6113
- return stripQuotes(text.replace(/[ \t]+#.*$/, "").trim());
6114
- }
6115
5934
  /**
6116
- * 把一个行块按缩进展开成叶子路径:容器键(`config:`)只做路径前缀,列表项
6117
- * 以 `[i]` 编号。只读、不修改原文;识别不了的行原样当叶子。
5935
+ * 把那 4 个仍挂着的冲突行翻成 `disabled: true`(不改别的行、不删行)。
5936
+ * @param plugins - 合法顶层行列表。
5937
+ * @param conflictIds - 冲突行清单,默认 {@link PRESET_CONFLICT_IDS}。
5938
+ * @returns 新列表与实际改动清单(无改动时 `changes` 为空且列表为原引用)。
6118
5939
  */
6119
- function walkRowLeaves(lines, row) {
6120
- const out = [];
6121
- const stack = [];
6122
- const counters = /* @__PURE__ */ new Map();
6123
- for (let i = row.start + 1; i < row.end; i += 1) {
6124
- const raw = lines[i].text;
6125
- if (COMMENT_LINE.test(raw) || BLANK_LINE.test(raw)) continue;
6126
- const indent = indentWidth(raw);
6127
- const trimmed = raw.trim();
6128
- while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
6129
- const prefix = stack.length === 0 ? "" : stack[stack.length - 1].path;
6130
- if (trimmed === "-" || trimmed.startsWith("- ")) {
6131
- const index = counters.get(prefix) ?? 0;
6132
- counters.set(prefix, index + 1);
6133
- const itemPath = prefix === "" ? `[${index}]` : `${prefix}[${index}]`;
6134
- stack.push({
6135
- indent,
6136
- path: itemPath
6137
- });
6138
- const body = trimmed.replace(/^-[ \t]*/, "");
6139
- const match = ROW_KEY.exec(` ${body}`);
6140
- if (match === null) {
6141
- out.push({
6142
- path: itemPath,
6143
- value: leafValue(body)
6144
- });
6145
- continue;
6146
- }
6147
- const value = leafValue(match[2]);
6148
- if (value !== "") out.push({
6149
- path: `${itemPath}.${match[1].trim()}`,
6150
- value
6151
- });
6152
- continue;
6153
- }
6154
- const match = ROW_KEY.exec(trimmed);
6155
- if (match === null) continue;
6156
- const key = match[1].trim();
6157
- const path = prefix === "" ? key : `${prefix}.${key}`;
6158
- const value = leafValue(match[2]);
6159
- if (value === "") stack.push({
6160
- indent,
6161
- path
6162
- });
6163
- else out.push({
6164
- path,
6165
- value
5940
+ function disableConflicts(plugins, conflictIds = PRESET_CONFLICT_IDS) {
5941
+ const changes = [];
5942
+ let touched = false;
5943
+ const next = plugins.map((row) => {
5944
+ const id = rowId(row);
5945
+ if (id === void 0 || !conflictIds.includes(id) || disabled(row)) return row;
5946
+ changes.push({
5947
+ id,
5948
+ action: Object.hasOwn(row, "disabled") ? "flipped" : "disabled"
6166
5949
  });
6167
- }
6168
- return out;
6169
- }
6170
- /**
6171
- * 只读读出顶层行的键值对与展开叶子。形状不可识别时返回 undefined(不猜、不修)。
6172
- *
6173
- * 与 {@link analyzePresetComposition} 共用同一套扫描器(`splitLines` /
6174
- * `detectShape` / `scanRows`):解析只有一份,差异视图与改写视图永远同源。
6175
- * @param text - `agent.cordis.yml` 原文。
6176
- * @returns 逐行视图,或形状不可识别时的 undefined。
6177
- */
6178
- function readPresetRows(text) {
6179
- const lines = splitLines(text);
6180
- if (detectShape(lines) !== "ok") return void 0;
6181
- return scanRows(lines).map((row) => {
6182
- const keys = [];
6183
- if (row.keyIndent !== void 0) for (let i = row.start; i < row.end; i += 1) {
6184
- const line = lines[i].text;
6185
- if (COMMENT_LINE.test(line) || BLANK_LINE.test(line)) continue;
6186
- if (indentWidth(line) !== row.keyIndent) continue;
6187
- const match = ROW_KEY.exec(line);
6188
- if (match === null) continue;
6189
- keys.push({
6190
- key: match[1].trim(),
6191
- value: leafValue(match[2])
6192
- });
6193
- }
5950
+ touched = true;
6194
5951
  return {
6195
- id: row.id,
6196
- keys,
6197
- leaves: walkRowLeaves(lines, row)
5952
+ ...row,
5953
+ disabled: true
6198
5954
  };
6199
5955
  });
6200
- }
6201
- //#endregion
6202
- //#region src/presets/install.ts
6203
- /**
6204
- * preset 安装 fs 层:启动补缺、升级(只改冲突行)、重置(整份覆盖)。
6205
- *
6206
- * 三条纪律:
6207
- * - **启动唯一自动动作**是"目录不存在才整份写入";目录已存在则一个字节都不碰
6208
- * (不检查、不补文件、不更新),失败绝不抛出、绝不阻塞启动。
6209
- * - **内容相同就不写盘**:preset 代际以 `agent.cordis.yml` 的 mtime 为键,
6210
- * 白写一次就白造一代 + 白起 watcher。判定一律用"写前先读回比较字节"。
6211
- * - **写盘一律原子**:同目录临时文件 + `rename`;替换前若同名备份不存在,
6212
- * 先落一份 `agent.cordis.yml.bak-<插件版本>`(改写前的内容,同名不覆盖)。
6213
- * @module @xiaoso/dsh-tool-plus/presets/install
6214
- */
6215
- /**
6216
- * 读取包内模板目录的全部常规文件("整份写入"的来源)。
6217
- * 模板目录不存在、或里面没有 `agent.cordis.yml` → 抛错,由调用方收进 reason。
6218
- */
6219
- function readTemplateFiles(id, deps) {
6220
- const dir = templateDir(id, deps);
6221
- const names = fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => entry.name);
6222
- if (!names.includes("agent.cordis.yml")) throw new Error(`preset template is incomplete: ${path$1.join(dir, COMPOSITION_FILE_NAME)} not found`);
6223
- return names.map((name) => ({
6224
- name,
6225
- content: fs.readFileSync(path$1.join(dir, name))
6226
- }));
6227
- }
6228
- /** 把异常收成一句人话(启动路径永不抛)。 */
6229
- function reasonOf(error) {
6230
- return error instanceof Error ? error.message : String(error);
6231
- }
6232
- /** 只读文本文件;读不到返回 undefined(不抛)。 */
6233
- function readTextFile$1(file) {
6234
- try {
6235
- return fs.readFileSync(file, "utf8");
6236
- } catch {
6237
- return;
6238
- }
6239
- }
6240
- /** 只读原始字节;读不到返回 undefined(不抛)。 */
6241
- function readFile(file) {
6242
- try {
6243
- return fs.readFileSync(file);
6244
- } catch {
6245
- return;
6246
- }
6247
- }
6248
- /**
6249
- * 启动补缺:`<dshHome>/.agent-presets/<id>` 目录不存在时,从包内模板整份写入
6250
- * (`mkdir -p` + 模板目录里的每个文件)。
6251
- *
6252
- * 目录已存在 → 一律不碰(含"目录存在但缺文件"的情形,那种只在设置页状态里
6253
- * 显示)。整个流程每个 id 单独 try/catch,**永不抛**。
6254
- * @param deps - 路径注入(fs 层测试用)。
6255
- * @returns 新建成功的 id 列表与失败原因列表(顺序均为 {@link DEFAULT_PRESET_IDS})。
6256
- */
6257
- function ensureDefaultPresets(deps = {}) {
6258
- const created = [];
6259
- const failed = [];
6260
- for (const id of DEFAULT_PRESET_IDS) try {
6261
- const dir = userPresetDir(id, deps);
6262
- if (fs.existsSync(dir)) continue;
6263
- const files = readTemplateFiles(id, deps);
6264
- fs.mkdirSync(dir, { recursive: true });
6265
- for (const file of files) fs.writeFileSync(path$1.join(dir, file.name), file.content);
6266
- created.push(id);
6267
- } catch (error) {
6268
- failed.push({
6269
- id,
6270
- reason: reasonOf(error)
6271
- });
6272
- }
6273
- return {
6274
- created,
6275
- failed
6276
- };
6277
- }
6278
- /** 本插件的版本号(备份名后缀):读包根 package.json,读不到就退化成 `unknown`。 */
6279
- function pluginVersion(deps) {
6280
- try {
6281
- const raw = JSON.parse(fs.readFileSync(path$1.join(packageRootDir(deps), "package.json"), "utf8"));
6282
- if (typeof raw.version === "string" && raw.version.length > 0) return raw.version;
6283
- } catch {}
6284
- return "unknown";
6285
- }
6286
- /** 备份路径:与被替换的文件同目录,`<文件>.bak-<插件版本>`。 */
6287
- function backupFileFor(file, deps) {
6288
- return `${file}.bak-${pluginVersion(deps)}`;
6289
- }
6290
- /**
6291
- * 替换前落一份"改写前内容"的备份;同名备份已存在则不覆盖(返回既有路径)。
6292
- * 备份是纯新增文件,不参与写入链的原子性。
6293
- */
6294
- function writeBackup(file, content, deps) {
6295
- const backup = backupFileFor(file, deps);
6296
- if (!fs.existsSync(backup)) fs.writeFileSync(backup, content);
6297
- return backup;
6298
- }
6299
- /** 同目录临时文件 + `rename` 的原子替换;失败时清掉临时文件再把错抛出去。 */
6300
- function writeFileAtomic(file, content) {
6301
- const temp = path$1.join(path$1.dirname(file), `.${path$1.basename(file)}.${process.pid}.${Date.now().toString(36)}.tmp`);
6302
- fs.writeFileSync(temp, content);
6303
- try {
6304
- fs.renameSync(temp, file);
6305
- } catch (error) {
6306
- try {
6307
- fs.unlinkSync(temp);
6308
- } catch {}
6309
- throw error;
6310
- }
6311
- }
6312
- /**
6313
- * 升级:只改冲突行(追加 `disabled: true` 或把既有值翻成 `true`)。
6314
- * 形状无法识别 → 不写;内容逐字节相同 → 不写盘、不备份。
6315
- */
6316
- function upgrade(id, deps) {
6317
- const file = path$1.join(userPresetDir(id, deps), COMPOSITION_FILE_NAME);
6318
- const original = readTextFile$1(file);
6319
- if (original === void 0) return {
6320
- ok: false,
6321
- changed: false,
6322
- reason: `preset is not installed: ${file} cannot be read`,
6323
- changes: []
6324
- };
6325
- if (analyzePresetComposition(original).shape === "unrecognized") return {
6326
- ok: false,
6327
- changed: false,
6328
- reason: `composition shape is unrecognized, nothing was written: ${file}`,
5956
+ if (!touched) return {
5957
+ plugins,
6329
5958
  changes: []
6330
5959
  };
6331
- const { text, changes } = rewritePresetComposition(original);
6332
- if (text === original) return {
6333
- ok: true,
6334
- changed: false,
6335
- changes: []
6336
- };
6337
- const backupPath = writeBackup(file, original, deps);
6338
- writeFileAtomic(file, text);
5960
+ changes.sort((a, b) => conflictIds.indexOf(a.id) - conflictIds.indexOf(b.id));
6339
5961
  return {
6340
- ok: true,
6341
- changed: true,
6342
- backupPath,
5962
+ plugins: next,
6343
5963
  changes
6344
5964
  };
6345
5965
  }
6346
- /**
6347
- * 重置:用**指定的**模板整份覆盖该预设目录(组合文件 + 元数据等每个模板文件)。
6348
- *
6349
- * 模板来源按用户所选:`templateId` 给了就用它(可以把 A 预设对齐到 B 模板),
6350
- * 没给才退回"同名模板"(历史行为)。内容相同则一个字节都不写;组合文件被改写
6351
- * 前先落备份。`changes` 恒为空:重置不是"逐行改写",行级摘要不在它的契约里。
6352
- */
6353
- function reset(id, deps, templateId) {
6354
- const source = templateId ?? id;
6355
- const sourceComposition = path$1.join(templateDir(source, deps), COMPOSITION_FILE_NAME);
6356
- if (!fs.existsSync(sourceComposition)) return {
6357
- ok: false,
6358
- changed: false,
6359
- reason: `template is missing: ${sourceComposition}`,
6360
- changes: []
6361
- };
6362
- const dir = userPresetDir(id, deps);
6363
- const files = readTemplateFiles(source, deps);
6364
- path$1.join(dir, COMPOSITION_FILE_NAME);
6365
- let changed = false;
6366
- let backupPath;
6367
- for (const file of files) {
6368
- const target = path$1.join(dir, file.name);
6369
- const current = readFile(target);
6370
- if (current !== void 0 && current.equals(file.content)) continue;
6371
- if (current !== void 0 && file.name === "agent.cordis.yml") backupPath = writeBackup(target, current, deps);
6372
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
6373
- writeFileAtomic(target, file.content);
6374
- changed = true;
6375
- }
6376
- return {
6377
- ok: true,
6378
- changed,
6379
- backupPath,
6380
- changes: []
6381
- };
6382
- }
6383
- /**
6384
- * 用户在设置页对某个预设执行的动作。
6385
- *
6386
- * - `upgrade`:只改官方冲突行(面板上叫「最小更新」);
6387
- * - `reset`:用**所选模板**整份覆盖该预设(先备份)。模板由 `templateId` 指定,
6388
- * 缺省才是同名模板 —— 面板总是把用户挑的那份模板传进来。
6389
- *
6390
- * 永不抛:任何异常收进 `ok: false` + `reason`。
6391
- * @param id - 预设 id(目录名)。
6392
- * @param action - `upgrade` 或 `reset`。
6393
- * @param deps - 路径注入(fs 层测试用)。
6394
- * @param templateId - `reset` 的模板来源(默认同名模板)。
6395
- * @returns 结果(`changed: false` = 一个字节都没写)。
6396
- */
6397
- function applyPresetAction(id, action, deps = {}, templateId) {
6398
- try {
6399
- return action === "reset" ? reset(id, deps, templateId) : upgrade(id, deps);
6400
- } catch (error) {
6401
- return {
6402
- ok: false,
6403
- changed: false,
6404
- reason: reasonOf(error),
6405
- changes: []
6406
- };
6407
- }
6408
- }
6409
5966
  //#endregion
6410
5967
  //#region src/presets/delta.ts
6411
5968
  /**
6412
- * 预设差异清单:只读地说清"用户这份和随包模板差在哪"。
5969
+ * 差异清单:只读地说清"这份 preset 与另一份(通常是随包声明)差在哪"。
6413
5970
  *
6414
5971
  * 为什么需要它:`templateDiffers: boolean` 只能说"不一样",而面板上紧挨着的
6415
- * 是"工具冲突行都已禁用、无需调整"。两句都对,但读起来打架 —— 前者说的是
6416
- * **你改过内容**,后者说的是**我们自己那 4 个冲突行**。把差异逐条列出来,
6417
- * 面板才不需要用户自己猜"到底哪里不同、更新会不会动它"。
5972
+ * 是"冲突行都已禁用、无需调整"。两句都对,读起来却打架 —— 前者说的是
5973
+ * **内容被改过**,后者说的是**我们自己那 4 个冲突行**。把差异逐条列出来,
5974
+ * 用户才不用猜"到底哪里不同、对齐会不会动它"。
6418
5975
  *
6419
- * 契约:①比**展开到叶子的点号路径**(`config.thresholdRatio`,列表项带 `[i]`)——
6420
- * 真实漂移常藏在嵌套键里,只比顶层键会得出"没有差异"的错误结论;②冲突行的
6421
- * `disabled` 由 `conflicts` 负责,不在差异里重复报;③形状不可识别 → 空清单,
6422
- * 绝不猜;④顺序=用户这份的行序在前,模板独有的行补在后;⑤`total` 是全量,
6423
- * `items` 可被 `limit` 截断(面板据此说"等 N 项")。
5976
+ * 契约(自旧文本实现原样继承,语义未变):
5977
+ * - 比**展开到叶子的点号路径**(`config.thresholdRatio`、`config.models[0].id`)——
5978
+ * 真实漂移常藏在嵌套键里,只比顶层键会得出"没有差异"的错误结论;
5979
+ * - 冲突行的 `disabled` 由 `conflicts` 负责,不在差异里重复报;
5980
+ * - 形状不可识别 → 空清单,绝不猜;
5981
+ * - 顺序 = 你这份的行序在前(含 group 内子行),对方独有的行补在后;
5982
+ * - `total` 是全量,`items` 可被 `limit` 截断(面板据此说"等 N 项")。
6424
5983
  * @module @xiaoso/dsh-tool-plus/presets/delta
6425
5984
  */
6426
- /** 默认最多列几条(再多就该去看文件本身了)。 */
5985
+ /** 默认最多列几条(再多就该去看配置本身了)。 */
6427
5986
  const DEFAULT_LIMIT$1 = 6;
6428
- /** 某个路径是不是"我们自己的冲突行开关"——那属于 conflicts 的话题。 */
6429
- function isConflictSwitch(row, path, conflictIds) {
6430
- return path === "disabled" && conflictIds.includes(row);
6431
- }
6432
- /** 把行视图折成 `路径 → 值`(重复路径以最后一个为准,与 YAML 一致)。 */
6433
- function leafMap(row) {
6434
- return new Map(row.leaves.map((entry) => [entry.path, entry.value]));
5987
+ /** 空结果(任一侧形状不可识别时)。 */
5988
+ const EMPTY = {
5989
+ items: [],
5990
+ total: 0,
5991
+ yoursCount: 0,
5992
+ behindCount: 0
5993
+ };
5994
+ /** 某个叶子的 `disabled` 是不是"我们自己的冲突行开关"——那属于 conflicts 的话题。 */
5995
+ function isConflictSwitch(rowPath, leafPath, conflictIds) {
5996
+ return leafPath === "disabled" && !rowPath.includes("/") && conflictIds.includes(rowPath);
6435
5997
  }
6436
5998
  /**
6437
- * 比较两份组合文件,列出差异(只读,不改任何字节)。
6438
- * @param userText - 用户这份的 `agent.cordis.yml` 原文。
6439
- * @param templateText - 随包模板的同一文件原文。
5999
+ * 比较两份 `config.plugins`,列出差异(只读,不改任何值)。
6000
+ * @param yours - 你这一侧的 plugins(形状未知)。
6001
+ * @param theirs - 对方的 plugins(形状未知)。
6440
6002
  * @param options - `conflictIds`(默认 {@link PRESET_CONFLICT_IDS})与 `limit`(默认 6)。
6441
- * @returns 差异清单;两侧任一不可识别时为空清单。
6442
- */
6443
- function presetDelta(userText, templateText, options = {}) {
6444
- const yours = readPresetRows(userText);
6445
- const theirs = readPresetRows(templateText);
6446
- if (yours === void 0 || theirs === void 0) return {
6447
- items: [],
6448
- total: 0,
6449
- yoursCount: 0,
6450
- behindCount: 0
6451
- };
6003
+ * @returns 差异清单;两侧任一形状不可识别时为空清单。
6004
+ */
6005
+ function presetDelta(yours, theirs, options = {}) {
6006
+ if (!isRowList(yours) || !isRowList(theirs)) return EMPTY;
6452
6007
  const conflictIds = options.conflictIds ?? PRESET_CONFLICT_IDS;
6453
6008
  const limit = options.limit ?? DEFAULT_LIMIT$1;
6454
- const theirRows = new Map(theirs.map((row) => [row.id, row]));
6455
- const myRowIds = new Set(yours.map((row) => row.id));
6009
+ const mine = flattenRows(yours);
6010
+ const other = flattenRows(theirs);
6456
6011
  const items = [];
6457
- for (const row of yours) {
6458
- const counterpart = theirRows.get(row.id);
6012
+ const leavesOf = (row) => new Map(rowLeaves(row).map((leaf) => [leaf.path, leaf.value]));
6013
+ for (const [path, row] of mine) {
6014
+ const counterpart = other.get(path);
6459
6015
  if (counterpart === void 0) {
6460
6016
  items.push({
6461
6017
  kind: "row-only-yours",
6462
- row: row.id,
6018
+ row: path,
6463
6019
  path: ""
6464
6020
  });
6465
6021
  continue;
6466
6022
  }
6467
- const mine = leafMap(row);
6468
- const other = leafMap(counterpart);
6469
- for (const [path, value] of mine) {
6470
- if (isConflictSwitch(row.id, path, conflictIds)) continue;
6471
- if (!other.has(path)) {
6023
+ const a = leavesOf(row);
6024
+ const b = leavesOf(counterpart);
6025
+ for (const [leaf, value] of a) {
6026
+ if (isConflictSwitch(path, leaf, conflictIds)) continue;
6027
+ if (!b.has(leaf)) {
6472
6028
  items.push({
6473
6029
  kind: "only-yours",
6474
- row: row.id,
6475
- path,
6030
+ row: path,
6031
+ path: leaf,
6476
6032
  yours: value
6477
6033
  });
6478
6034
  continue;
6479
6035
  }
6480
- const theirValue = other.get(path) ?? "";
6481
- if (theirValue !== value) items.push({
6036
+ const theirsValue = b.get(leaf) ?? "";
6037
+ if (theirsValue !== value) items.push({
6482
6038
  kind: "changed",
6483
- row: row.id,
6484
- path,
6039
+ row: path,
6040
+ path: leaf,
6485
6041
  yours: value,
6486
- template: theirValue
6042
+ template: theirsValue
6487
6043
  });
6488
6044
  }
6489
- for (const [path, value] of other) {
6490
- if (isConflictSwitch(row.id, path, conflictIds)) continue;
6491
- if (!mine.has(path)) items.push({
6045
+ for (const [leaf, value] of b) {
6046
+ if (isConflictSwitch(path, leaf, conflictIds)) continue;
6047
+ if (!a.has(leaf)) items.push({
6492
6048
  kind: "only-template",
6493
- row: row.id,
6494
- path,
6049
+ row: path,
6050
+ path: leaf,
6495
6051
  template: value
6496
6052
  });
6497
6053
  }
6498
6054
  }
6499
- for (const row of theirs) if (!myRowIds.has(row.id)) items.push({
6055
+ for (const path of other.keys()) if (!mine.has(path)) items.push({
6500
6056
  kind: "row-only-template",
6501
- row: row.id,
6057
+ row: path,
6502
6058
  path: ""
6503
6059
  });
6504
6060
  const yoursCount = items.filter((item) => isYoursDelta(item.kind)).length;
@@ -6510,99 +6066,19 @@ function presetDelta(userText, templateText, options = {}) {
6510
6066
  };
6511
6067
  }
6512
6068
  //#endregion
6513
- //#region src/presets/status.ts
6514
- /**
6515
- * preset 状态探测:把 roster 变成设置页要的清单。同步、只读。
6516
- *
6517
- * roster 由调用方(RPC 层)从 `ctx.agentPresets.list()` 映射后传入——本模块
6518
- * 不依赖 cordis 类型,只吃一个最小结构,因此可以纯 fs 单测。三种来源按设计
6519
- * 文档 §4.2 区分:
6520
- * - `ours`:本插件随包的两个预设(id 在 {@link DEFAULT_PRESET_IDS} 里),
6521
- * 可"更新"(只改冲突行)与"重置"(整份对齐模板);
6522
- * - `user`:用户根里的其它预设,只能"升级"(只改冲突行);
6523
- * - `shipped`:随部署提供的官方预设(`trust: 'system'`),只读展示。
6524
- *
6525
- * 判定只读文件、绝不写入:官方随附根与用户根在这里都只是被读。
6526
- * @module @xiaoso/dsh-tool-plus/presets/status
6527
- */
6528
- /** 读组合文件;绝不抛。ENOENT/ENOTDIR 归为"不存在",其余归为"读不了"。 */
6529
- function readComposition(file) {
6530
- try {
6531
- return {
6532
- kind: "ok",
6533
- text: fs.readFileSync(file, "utf8")
6534
- };
6535
- } catch (error) {
6536
- const code = error.code;
6537
- return code === "ENOENT" || code === "ENOTDIR" ? { kind: "missing" } : { kind: "unreadable" };
6538
- }
6539
- }
6540
- /** 只读文本文件;读不到返回 undefined(不抛)。 */
6541
- function readTextFile(file) {
6542
- try {
6543
- return fs.readFileSync(file, "utf8");
6544
- } catch {
6545
- return;
6546
- }
6547
- }
6548
- /**
6549
- * 列出每个 roster 行的状态(顺序与入参一致)。
6550
- * @param roster - 调用方映射好的预设清单(`path` = 组合文件路径)。
6551
- * @param deps - 路径注入(fs 层测试用)。
6552
- * @returns 逐行的设置页状态。
6553
- */
6554
- function listPresetStatuses(roster, deps = {}) {
6555
- return roster.map((entry) => {
6556
- const source = DEFAULT_PRESET_IDS.includes(entry.id) ? "ours" : entry.trust === "system" ? "shipped" : "user";
6557
- const installed = fs.existsSync(userPresetDir(entry.id, deps));
6558
- const templateFile = path$1.join(templateDir(entry.id, deps), COMPOSITION_FILE_NAME);
6559
- const templatePresent = fs.existsSync(templateFile);
6560
- const read = readComposition(entry.path);
6561
- const analysis = read.kind === "ok" ? analyzePresetComposition(read.text) : {
6562
- conflicts: [],
6563
- clean: false,
6564
- shape: read.kind === "unreadable" ? "unrecognized" : "ok"
6565
- };
6566
- const unrecognized = read.kind === "missing" ? false : analysis.shape === "unrecognized";
6567
- const templateText = installed && templatePresent ? readTextFile(templateFile) : void 0;
6568
- const templateDiffers = templateText !== void 0 && read.kind === "ok" && templateText !== read.text;
6569
- const templateDelta = templateDiffers && templateText !== void 0 && read.kind === "ok" ? presetDelta(read.text, templateText) : void 0;
6570
- const delta = templateDelta !== void 0 && templateDelta.total > 0 ? templateDelta : void 0;
6571
- return {
6572
- id: entry.id,
6573
- name: entry.name,
6574
- description: entry.description,
6575
- source,
6576
- path: entry.path,
6577
- conflicts: analysis.conflicts,
6578
- clean: analysis.clean,
6579
- unrecognized,
6580
- broken: entry.broken,
6581
- installed,
6582
- templatePresent,
6583
- templateDiffers,
6584
- templateDelta: delta
6585
- };
6586
- });
6587
- }
6588
- //#endregion
6589
6069
  //#region src/presets/compare.ts
6590
6070
  /**
6591
- * 预设比较:把"某份预设"和"我们随包的某个模板"放在一起看。
6071
+ * 预设比较:把"某份 preset 生效的 plugins"和"某个随包声明的 plugins"放在一起看。
6592
6072
  *
6593
- * 面板现在是两块信息、两个状态点:
6594
- * ①**工具行**:这份预设里我们那 4 个冲突行都禁用了吗(=`conflicts`);
6595
- * ②**完整比较**:内容上和我们选的模板差多少(=`items`,弹窗里逐条展示)。
6596
- * 这是只读操作,绝不写盘。比较一律**不跳过**冲突行的 `disabled` —— 第二个点
6597
- * 要的是"完整的比较",工具开关的不同也必须出现在差异清单里,否则会出现
6598
- * "说内容一致、却说工具行没配好"这种自相矛盾的读数。
6073
+ * 面板是两块信息、两个状态点:
6074
+ * ①**工具行**:这份 preset 里那 4 个冲突行都禁用了吗(=`conflicts`);
6075
+ * ②**完整比较**:内容上和选中的模板差多少(=`items`,弹窗里逐条展示)。
6076
+ * 只读,绝不写盘。比较一律**不跳过**冲突行的 `disabled` —— 第二个点要的是
6077
+ * "完整的比较",工具开关的不同也必须出现在差异清单里,否则会出现"说内容一致、
6078
+ * 却说工具行没配好"这种自相矛盾的读数。
6599
6079
  * @module @xiaoso/dsh-tool-plus/presets/compare
6600
6080
  */
6601
- /** 模板根目录:`<packageRoot>/presets`。 */
6602
- function templatesRoot(deps) {
6603
- return path$1.join(packageRootDir(deps), "presets");
6604
- }
6605
- /** 读不到/不可识别时的统一结果(面板渲染红点,不抛)。 */
6081
+ /** 任一侧不可比较时的统一结果(面板渲染红点,不抛)。 */
6606
6082
  const UNREADABLE_COMPARISON = {
6607
6083
  status: "unreadable",
6608
6084
  conflicts: [],
@@ -6611,56 +6087,17 @@ const UNREADABLE_COMPARISON = {
6611
6087
  behindCount: 0,
6612
6088
  items: []
6613
6089
  };
6614
- /** 只读文本文件;读不到返回 undefined(不抛)。 */
6615
- function readText(file) {
6616
- try {
6617
- return fs.readFileSync(file, "utf8");
6618
- } catch {
6619
- return;
6620
- }
6621
- }
6622
- /** 模板元数据里的 `name:`(一行,去掉引号);读不到就退回 id。 */
6623
- function templateName(id, deps) {
6624
- const text = readText(path$1.join(templatesRoot(deps), id, METADATA_FILE_NAME));
6625
- if (text === void 0) return void 0;
6626
- const match = /^name[ \t]*:[ \t]*(.+)$/m.exec(text);
6627
- if (match === null) return void 0;
6628
- return match[1].trim().replace(/^['"]|['"]$/g, "");
6629
- }
6630
- /**
6631
- * 列出包里带模板的预设 id(面板"对比模板"下拉的选项)。只读目录,不抛。
6632
- * @param deps - 路径注入(测试用)。
6633
- * @returns 按 id 排序的模板清单(读不到目录时为空数组)。
6634
- */
6635
- function listTemplates(deps = {}) {
6636
- let entries;
6637
- try {
6638
- entries = fs.readdirSync(templatesRoot(deps), { withFileTypes: true });
6639
- } catch {
6640
- return [];
6641
- }
6642
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((id) => fs.existsSync(path$1.join(templatesRoot(deps), id, COMPOSITION_FILE_NAME))).sort().map((id) => {
6643
- const name = templateName(id, deps);
6644
- return name === void 0 ? { id } : {
6645
- id,
6646
- name
6647
- };
6648
- });
6649
- }
6650
- /** 读一份模板的组合文件;不存在返回 undefined。 */
6651
- function readTemplateComposition(templateId, deps = {}) {
6652
- return readText(path$1.join(templatesRoot(deps), templateId, COMPOSITION_FILE_NAME));
6653
- }
6654
6090
  /**
6655
- * 比较两份组合文件文本(纯函数,只读)。
6656
- * @param presetText - 所选预设的原文。
6657
- * @param templateText - 所选模板的原文。
6091
+ * 比较两份 `config.plugins`(纯函数,只读)。
6092
+ * @param presetPlugins - 所选 preset 生效的 plugins。
6093
+ * @param templatePlugins - 所选模板的 plugins(随包声明)。
6658
6094
  * @returns 两个状态点与弹窗需要的全部数据。
6659
6095
  */
6660
- function compareCompositions(presetText, templateText) {
6661
- const analysis = analyzePresetComposition(presetText);
6096
+ function comparePlugins(presetPlugins, templatePlugins) {
6097
+ if (!isRowList(presetPlugins) || !isRowList(templatePlugins)) return UNREADABLE_COMPARISON;
6098
+ const analysis = analyzePlugins(presetPlugins);
6662
6099
  if (analysis.shape === "unrecognized") return UNREADABLE_COMPARISON;
6663
- const delta = presetDelta(presetText, templateText, {
6100
+ const delta = presetDelta(presetPlugins, templatePlugins, {
6664
6101
  conflictIds: [],
6665
6102
  limit: Number.MAX_SAFE_INTEGER
6666
6103
  });
@@ -6673,19 +6110,420 @@ function compareCompositions(presetText, templateText) {
6673
6110
  items: delta.items
6674
6111
  };
6675
6112
  }
6113
+ //#endregion
6114
+ //#region src/presets/plan.ts
6115
+ /** 生效的 plugins(覆盖优先)。 */
6116
+ function effectivePlugins$1(input) {
6117
+ const plugins = input.effective?.plugins;
6118
+ return isRowList(plugins) ? plugins : void 0;
6119
+ }
6120
+ /**
6121
+ * 判定一个动作该不该写、会改什么。
6122
+ * @param input - 判定所需的事实。
6123
+ * @returns 计划;`noop` 时调用方**不要**调用 `configEditor.edit`。
6124
+ */
6125
+ function planPresetAction(input) {
6126
+ if (input.action === "revert") {
6127
+ if (!input.customized) return {
6128
+ kind: "noop",
6129
+ reason: "this preset carries no profile override to remove"
6130
+ };
6131
+ return { kind: "revert" };
6132
+ }
6133
+ if (input.action === "align") {
6134
+ if (!input.ours) return {
6135
+ kind: "noop",
6136
+ reason: "only presets this plugin declares can be aligned"
6137
+ };
6138
+ if (input.templateId === void 0) return {
6139
+ kind: "noop",
6140
+ reason: "align needs a template id"
6141
+ };
6142
+ if (!isRowList(input.templatePlugins)) return {
6143
+ kind: "noop",
6144
+ reason: `template ${input.templateId} has no readable plugin list`
6145
+ };
6146
+ if (sameValue(input.templatePlugins, input.inherited?.plugins)) {
6147
+ if (!input.customized) return {
6148
+ kind: "noop",
6149
+ reason: "this preset already matches the bundled declaration"
6150
+ };
6151
+ return { kind: "revert" };
6152
+ }
6153
+ if (sameValue(input.templatePlugins, input.effective?.plugins)) return {
6154
+ kind: "noop",
6155
+ reason: `this preset already matches template ${input.templateId}`
6156
+ };
6157
+ return { kind: "align" };
6158
+ }
6159
+ const plugins = effectivePlugins$1(input);
6160
+ if (plugins === void 0) return {
6161
+ kind: "noop",
6162
+ reason: "the plugin list is not a readable row list, nothing was written"
6163
+ };
6164
+ if (analyzePlugins(plugins).conflicts.length === 0) return {
6165
+ kind: "noop",
6166
+ reason: "no official tool row needs disabling"
6167
+ };
6168
+ const { changes } = disableConflicts(plugins);
6169
+ if (changes.length === 0) return {
6170
+ kind: "noop",
6171
+ reason: "no official tool row needs disabling"
6172
+ };
6173
+ return {
6174
+ kind: "upgrade",
6175
+ changes
6176
+ };
6177
+ }
6178
+ //#endregion
6179
+ //#region src/presets/status.ts
6180
+ /** 生效的 plugins:宿主的生效 config 优先,回落到覆盖层、再回落到随包声明。 */
6181
+ function effectivePlugins(facts) {
6182
+ if (facts.effective?.plugins !== void 0) return facts.effective.plugins;
6183
+ const overridePlugins = facts.override?.plugins;
6184
+ if (overridePlugins !== void 0) return overridePlugins;
6185
+ return facts.inherited?.plugins;
6186
+ }
6187
+ /** 覆盖里是不是真带了内容(`{}` 表示没有覆盖行)。 */
6188
+ function isCustomized(facts) {
6189
+ return facts.override !== void 0 && Object.keys(facts.override).length > 0;
6190
+ }
6191
+ /**
6192
+ * 把一个 preset 折成面板状态。
6193
+ * @param facts - 宿主收集的原始事实。
6194
+ * @param templateIds - 随包声明的模板清单(用于"与随包声明是否一致")。
6195
+ * @returns 面板状态行。
6196
+ */
6197
+ function toPresetStatus(facts, templateIds) {
6198
+ const plugins = effectivePlugins(facts);
6199
+ const analysis = analyzePlugins(plugins);
6200
+ const customized = isCustomized(facts);
6201
+ const rows = isRowList(plugins) ? flattenRows(plugins).size : 0;
6202
+ const templateDiffers = facts.ours && templateIds.includes(facts.id) && isRowList(plugins) && isRowList(facts.inherited?.plugins) && presetDelta(plugins, facts.inherited?.plugins, { limit: Number.MAX_SAFE_INTEGER }).total > 0;
6203
+ const templateDelta = templateDiffers ? presetDelta(plugins, facts.inherited?.plugins) : void 0;
6204
+ const delta = templateDelta !== void 0 && templateDelta.total > 0 ? templateDelta : void 0;
6205
+ return {
6206
+ id: facts.id,
6207
+ ...facts.entryId === void 0 ? {} : { entryId: facts.entryId },
6208
+ ...facts.name === void 0 ? {} : { name: facts.name },
6209
+ ...facts.description === void 0 ? {} : { description: facts.description },
6210
+ source: facts.ours ? "ours" : "other",
6211
+ isDefault: facts.isDefault,
6212
+ conflicts: analysis.conflicts,
6213
+ clean: analysis.clean,
6214
+ unrecognized: analysis.shape === "unrecognized",
6215
+ ...facts.broken === void 0 ? {} : { broken: facts.broken },
6216
+ customized,
6217
+ templateDiffers,
6218
+ ...delta === void 0 ? {} : { templateDelta: delta },
6219
+ rowCount: rows
6220
+ };
6221
+ }
6222
+ /**
6223
+ * 列出每个事实的状态,并给出模板清单。
6224
+ * @param facts - 宿主收集的原始事实(顺序即面板顺序)。
6225
+ * @param writable - 本部署有没有可编辑的 profile(`configEditor` 是否在场)。
6226
+ * @returns `presets/status` 的完整返回值。
6227
+ */
6228
+ function listPresetStatuses$1(facts, writable) {
6229
+ const templateIds = facts.filter((item) => item.ours).map((item) => item.id);
6230
+ const templates = facts.filter((item) => item.ours).map((item) => item.name === void 0 ? { id: item.id } : {
6231
+ id: item.id,
6232
+ name: item.name
6233
+ });
6234
+ return {
6235
+ presets: facts.map((item) => toPresetStatus(item, templateIds)),
6236
+ templates,
6237
+ writable
6238
+ };
6239
+ }
6676
6240
  /**
6677
- * 读盘版比较:预设一侧用 roster 给的组合文件路径,模板一侧按 id 去包里找。
6678
- * 任一侧读不到都返回 `unreadable` —— 那是面板要渲染的红点,不是调用失败。
6679
- * @param presetPath - 所选预设的组合文件绝对路径(来自 roster)。
6680
- * @param templateId - 所选模板的 id。
6681
- * @param deps - 路径注入(测试用)。
6682
- * @returns 比较结果。
6241
+ * 取某个模板声明的 plugins(面板"对比 / 对齐"的右侧)。
6242
+ * @param facts - 同一个事实集合。
6243
+ * @param templateId - 模板 id(本插件声明的某个 preset)。
6244
+ * @returns 该模板的 plugins;不是我们的模板时 undefined。
6683
6245
  */
6684
- function comparePresetFile(presetPath, templateId, deps = {}) {
6685
- const presetText = readText(presetPath);
6686
- const templateText = readTemplateComposition(templateId, deps);
6687
- if (presetText === void 0 || templateText === void 0) return UNREADABLE_COMPARISON;
6688
- return compareCompositions(presetText, templateText);
6246
+ function templatePlugins(facts, templateId) {
6247
+ return facts.find((item) => item.ours && item.id === templateId)?.inherited?.plugins;
6248
+ }
6249
+ //#endregion
6250
+ //#region src/host/preset-host.ts
6251
+ /**
6252
+ * Host half of the preset surface: read the deployment's declared presets and
6253
+ * apply the panel's three actions through the harness's own config editor.
6254
+ *
6255
+ * WHY WE DO NOT WRITE FILES OURSELVES: dsh 0.1.7 removed the directory preset
6256
+ * mechanism and the `agent_preset` tool with it. The only supported programmatic
6257
+ * write path is `ctx.configEditor.edit()`, and it is a better one than we had —
6258
+ * it takes the profile lock, serializes with HMR, validates the next config
6259
+ * through the Loader's own waterfall, writes atomically, rolls back on a failed
6260
+ * reconcile, and refuses when a home patch or `--patch` overlay would defeat the
6261
+ * edit. It also gives us "revert to the bundled declaration" for free: handing
6262
+ * back the inherited config makes it delete the override's `config` key.
6263
+ *
6264
+ * The service is resolved per call, so a deployment that mounts the editor
6265
+ * after this plugin still works instead of caching an early `undefined`.
6266
+ *
6267
+ * Everything here is a thin adapter; the decisions live in `src/presets/**`
6268
+ * (pure, fs-free, unit-testable) and this module only resolves services and
6269
+ * shapes results.
6270
+ * @module @xiaoso/dsh-tool-plus/host/preset-host
6271
+ */
6272
+ /** Narrow a non-empty string, else undefined. */
6273
+ function text(value) {
6274
+ return typeof value === "string" && value.length > 0 ? value : void 0;
6275
+ }
6276
+ /** `{ key: value }` only when the value is present, so optional fields stay absent. */
6277
+ function optional(key, value) {
6278
+ return value === void 0 ? {} : { [key]: value };
6279
+ }
6280
+ /** Read a service only when it actually looks like the one we need. */
6281
+ function configEditorOf(ctx) {
6282
+ const service = ctx.get("configEditor");
6283
+ return service !== void 0 && typeof service.edit === "function" && typeof service.configuration === "function" ? service : void 0;
6284
+ }
6285
+ /** Read the preset registry roster only when it exposes one of its two readers. */
6286
+ async function rosterOf(ctx) {
6287
+ const service = ctx.get("agentPresets");
6288
+ if (service === void 0) return void 0;
6289
+ try {
6290
+ if (typeof service.remoteExportList === "function") {
6291
+ const exported = await service.remoteExportList();
6292
+ if (Array.isArray(exported?.presets)) return exported.presets;
6293
+ }
6294
+ if (typeof service.list === "function") return await service.list();
6295
+ return;
6296
+ } catch {
6297
+ return;
6298
+ }
6299
+ }
6300
+ /**
6301
+ * Collect every declared preset this deployment can see.
6302
+ *
6303
+ * Ordering is OURS FIRST (the panel's primary subject), then the rest in the
6304
+ * registry's own order — which the registry sorts by each declaration's `order`.
6305
+ * @param ctx - context used to resolve the services.
6306
+ * @returns the facts, the editor (when present) and the row→entry map.
6307
+ */
6308
+ async function collect(ctx) {
6309
+ const editor = configEditorOf(ctx);
6310
+ const roster = await rosterOf(ctx);
6311
+ const byId = /* @__PURE__ */ new Map();
6312
+ const entries = /* @__PURE__ */ new Map();
6313
+ if (editor !== void 0) for (const row of editor.configuration()) {
6314
+ const entryId = text(row.entry.options.id);
6315
+ if (entryId === void 0 || !entryId.startsWith("preset-")) continue;
6316
+ const effective = row.entry.options.config ?? {};
6317
+ const id = text(row.inherited.id) ?? text(row.override.id) ?? text(effective.id) ?? entryId.slice(7);
6318
+ entries.set(entryId, row.entry);
6319
+ byId.set(id, {
6320
+ id,
6321
+ entryId,
6322
+ ...optional("name", text(row.inherited.name) ?? text(effective.name)),
6323
+ ...optional("description", text(row.inherited.description) ?? text(effective.description)),
6324
+ isDefault: false,
6325
+ effective,
6326
+ inherited: row.inherited,
6327
+ override: row.override,
6328
+ ours: BUNDLED_PRESET_IDS.includes(id)
6329
+ });
6330
+ }
6331
+ const rosterOrder = [];
6332
+ for (const row of roster ?? []) {
6333
+ const id = text(row.id);
6334
+ if (id === void 0) continue;
6335
+ rosterOrder.push(id);
6336
+ const existing = byId.get(id);
6337
+ byId.set(id, {
6338
+ id,
6339
+ ...existing?.entryId === void 0 ? {} : { entryId: existing.entryId },
6340
+ ...optional("name", text(row.name) ?? existing?.name),
6341
+ ...optional("description", text(row.description) ?? existing?.description),
6342
+ isDefault: row.isDefault === true,
6343
+ ...optional("broken", text(row.broken)),
6344
+ ...existing?.effective === void 0 ? {} : { effective: existing.effective },
6345
+ ...existing?.inherited === void 0 ? {} : { inherited: existing.inherited },
6346
+ ...existing?.override === void 0 ? {} : { override: existing.override },
6347
+ ours: BUNDLED_PRESET_IDS.includes(id)
6348
+ });
6349
+ }
6350
+ const ours = BUNDLED_PRESET_IDS.map((id) => byId.get(id) ?? {
6351
+ id,
6352
+ isDefault: false,
6353
+ ours: true
6354
+ });
6355
+ const others = rosterOrder.filter((id) => !BUNDLED_PRESET_IDS.includes(id)).map((id) => byId.get(id)).filter((fact) => fact !== void 0);
6356
+ return {
6357
+ facts: [...ours, ...others],
6358
+ editor,
6359
+ entries
6360
+ };
6361
+ }
6362
+ /**
6363
+ * The pre-0.1.7 user preset directory, reported ONLY as a cleanup hint.
6364
+ *
6365
+ * dsh reads no directory any more ("Definitions are ordinary plugin rows; the
6366
+ * registry neither scans directories nor accepts preset paths"), so a leftover
6367
+ * `$DSH_HOME/.agent-presets` is dead weight. We surface its path so the panel can
6368
+ * tell the user it is safe to delete; we never read or write presets through it.
6369
+ * @returns the absolute path when the directory exists and holds entries.
6370
+ */
6371
+ function legacyPresetRoot() {
6372
+ const home = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
6373
+ const dir = join(home, ".agent-presets");
6374
+ try {
6375
+ return readdirSync(dir).length > 0 ? dir : void 0;
6376
+ } catch {
6377
+ return;
6378
+ }
6379
+ }
6380
+ /**
6381
+ * This plugin's own version, for the backup file name.
6382
+ *
6383
+ * Probes two candidates because the module URL differs between the built single
6384
+ * file (`lib/index.mjs` → `../package.json` is the package root) and the source
6385
+ * tree under test (`src/host/…` → `../../package.json`). Falls back to
6386
+ * `'unknown'` rather than failing an action over a cosmetic label.
6387
+ * @returns the version string, or `'unknown'`.
6388
+ */
6389
+ function pluginVersion() {
6390
+ for (const candidate of ["../package.json", "../../package.json"]) try {
6391
+ const raw = JSON.parse(readFileSync(fileURLToPath(new URL(candidate, import.meta.url)), "utf8"));
6392
+ if (typeof raw.version === "string" && raw.version.length > 0) return raw.version;
6393
+ } catch {}
6394
+ return "unknown";
6395
+ }
6396
+ /**
6397
+ * Copy the profile patch aside before we change it.
6398
+ *
6399
+ * The config editor already gives us atomicity and rollback, but NOT a durable
6400
+ * "what it looked like before" artifact — and `align` overwrites content the
6401
+ * user may have written there. This restores the pre-0.1.10 affordance exactly:
6402
+ * one `<patch>.bak-<plugin version>` beside the patch, written once and never
6403
+ * overwritten, so the earliest pre-change state survives repeated actions.
6404
+ * @param documentPath - the profile patch the editor is about to rewrite.
6405
+ * @returns the backup path, or undefined when there was nothing to copy.
6406
+ */
6407
+ function backupProfilePatch(documentPath) {
6408
+ if (!existsSync(documentPath)) return void 0;
6409
+ const target = `${documentPath}.bak-${pluginVersion()}`;
6410
+ if (existsSync(target)) return target;
6411
+ copyFileSync(documentPath, target);
6412
+ return target;
6413
+ }
6414
+ /** One failure in the gateway's shape. */
6415
+ function failure(reason) {
6416
+ return {
6417
+ ok: false,
6418
+ changed: false,
6419
+ reason,
6420
+ changes: []
6421
+ };
6422
+ }
6423
+ /**
6424
+ * Answer `presets/status`.
6425
+ * @param ctx - context used to resolve the config editor and the registry.
6426
+ * @returns the panel's full status payload.
6427
+ */
6428
+ async function listPresetStatuses(ctx) {
6429
+ const { facts, editor } = await collect(ctx);
6430
+ const list = listPresetStatuses$1(facts, editor !== void 0);
6431
+ const legacy = legacyPresetRoot();
6432
+ return legacy === void 0 ? list : {
6433
+ ...list,
6434
+ legacyPresetRoot: legacy
6435
+ };
6436
+ }
6437
+ /**
6438
+ * Answer `presets/apply` by planning the action and, when it is worth writing,
6439
+ * delegating the write to the harness's own config editor.
6440
+ * @param ctx - context used to resolve the services.
6441
+ * @param presetId - the preset to act on.
6442
+ * @param action - `upgrade`, `align` or `revert`.
6443
+ * @param templateId - the align target (required for `align`).
6444
+ * @returns the action result, never throwing.
6445
+ */
6446
+ async function applyPresetAction(ctx, presetId, action, templateId) {
6447
+ try {
6448
+ const { facts, editor, entries } = await collect(ctx);
6449
+ const fact = facts.find((item) => item.id === presetId);
6450
+ if (fact === void 0) return failure(`unknown preset: ${presetId}`);
6451
+ const wanted = templateId === void 0 ? void 0 : templatePlugins(facts, templateId);
6452
+ const plan = planPresetAction({
6453
+ action,
6454
+ ours: fact.ours,
6455
+ customized: fact.override !== void 0 && Object.keys(fact.override).length > 0,
6456
+ effective: fact.effective,
6457
+ inherited: fact.inherited,
6458
+ ...wanted === void 0 ? {} : { templatePlugins: wanted },
6459
+ ...templateId === void 0 ? {} : { templateId }
6460
+ });
6461
+ if (plan.kind === "noop") return {
6462
+ ok: true,
6463
+ changed: false,
6464
+ reason: plan.reason,
6465
+ changes: []
6466
+ };
6467
+ if (editor === void 0) return failure("this deployment has no editable profile: ctx.configEditor is unavailable");
6468
+ const entry = fact.entryId === void 0 ? void 0 : entries.get(fact.entryId);
6469
+ if (entry === void 0) return failure(`this profile has no addressable row for ${presetId} (expected id ${profileRowId(presetId)}); install the plugin as a profile bundle so its patch declares the preset`);
6470
+ const backupPath = backupProfilePatch(editor.documentPath);
6471
+ if (plan.kind === "revert") await editor.edit(entry, (_current, inherited) => inherited);
6472
+ else if (plan.kind === "align") await editor.edit(entry, (current) => ({
6473
+ ...current,
6474
+ plugins: wanted
6475
+ }));
6476
+ else await editor.edit(entry, (current) => {
6477
+ if (!isRowList(current.plugins)) return current;
6478
+ return {
6479
+ ...current,
6480
+ plugins: disableConflicts(current.plugins).plugins
6481
+ };
6482
+ });
6483
+ return {
6484
+ ok: true,
6485
+ changed: true,
6486
+ ...backupPath === void 0 ? {} : { backupPath },
6487
+ changes: plan.kind === "upgrade" ? plan.changes : []
6488
+ };
6489
+ } catch (error) {
6490
+ return failure(error instanceof Error ? error.message : String(error));
6491
+ }
6492
+ }
6493
+ /**
6494
+ * Answer `presets/compare`: one preset's effective plugins against one of our
6495
+ * declared templates. Never throws; an unresolvable side reports `unreadable`
6496
+ * (that is a state the panel renders as a red dot, not a failed call).
6497
+ * @param ctx - context used to resolve the services.
6498
+ * @param presetId - the preset being inspected.
6499
+ * @param templateId - the template to compare it against.
6500
+ * @returns the comparison.
6501
+ */
6502
+ async function comparePresetAgainstTemplate(ctx, presetId, templateId) {
6503
+ const empty = {
6504
+ presetId,
6505
+ templateId,
6506
+ status: "unreadable",
6507
+ conflicts: [],
6508
+ identical: false,
6509
+ yoursCount: 0,
6510
+ behindCount: 0,
6511
+ items: []
6512
+ };
6513
+ try {
6514
+ const { facts } = await collect(ctx);
6515
+ const fact = facts.find((item) => item.id === presetId);
6516
+ if (fact === void 0) return empty;
6517
+ const template = templatePlugins(facts, templateId);
6518
+ if (template === void 0) return empty;
6519
+ return {
6520
+ presetId,
6521
+ templateId,
6522
+ ...comparePlugins(fact.effective?.plugins, template)
6523
+ };
6524
+ } catch {
6525
+ return empty;
6526
+ }
6689
6527
  }
6690
6528
  //#endregion
6691
6529
  //#region src/web/host/bash.ts
@@ -11558,7 +11396,7 @@ let specialHandlersPromise;
11558
11396
  * requires a special handler, so we keep them out of the cold-startup graph.
11559
11397
  */
11560
11398
  function loadSpecialHandlers() {
11561
- specialHandlersPromise ??= import("./scrapers-mhvFrvjH.mjs").then((m) => m.specialHandlers);
11399
+ specialHandlersPromise ??= import("./scrapers-DODCPcZW.mjs").then((m) => m.specialHandlers);
11562
11400
  return specialHandlersPromise;
11563
11401
  }
11564
11402
  /**
@@ -23738,19 +23576,11 @@ function apply(ctx, config = {}) {
23738
23576
  const disposeBrowserProbe = installBrowserProbeRpc(ctx, {
23739
23577
  getRmSafe: () => cfg.rmSafe,
23740
23578
  presets: {
23741
- listStatuses: (roster) => listPresetStatuses(roster),
23742
- applyAction: (id, action, templateId) => applyPresetAction(id, action, {}, templateId),
23743
- listTemplates: () => listTemplates(),
23744
- comparePreset: (preset, templateId) => comparePresetFile(preset.path, templateId)
23579
+ listPresets: (presetCtx) => listPresetStatuses(presetCtx),
23580
+ applyAction: (presetCtx, id, action, templateId) => applyPresetAction(presetCtx, id, action, templateId),
23581
+ comparePreset: (presetCtx, presetId, templateId) => comparePresetAgainstTemplate(presetCtx, presetId, templateId)
23745
23582
  }
23746
23583
  });
23747
- try {
23748
- const { created, failed } = ensureDefaultPresets();
23749
- if (created.length > 0) runtimeLogger().info(`preset templates installed: ${created.join(", ")}`);
23750
- for (const item of failed) runtimeLogger().warn(`preset template ${item.id} not installed: ${item.reason}`);
23751
- } catch (error) {
23752
- runtimeLogger().warn(`preset bootstrap skipped: ${error instanceof Error ? error.message : String(error)}`);
23753
- }
23754
23584
  ctx.systemPrompt.section({
23755
23585
  name: "tool:bash",
23756
23586
  order: 105,
@@ -23928,72 +23758,47 @@ function apply(ctx, config = {}) {
23928
23758
  ...commandEnv
23929
23759
  };
23930
23760
  const jobs = ctx.get("jobs");
23761
+ const owner = exec.agent?.id;
23931
23762
  const backgroundSlotsAvailable = () => {
23932
23763
  if (jobs === void 0 || cfg.maxBackgroundJobs <= 0) return true;
23933
- return jobs.list(exec.agent).filter((j) => j.status === "running" || j.status === "stopping").length < cfg.maxBackgroundJobs;
23934
- };
23935
- const startManagedJob = () => {
23936
- const managed = startBashJob({
23937
- sessionId,
23938
- command,
23939
- cwd: commandCwd,
23940
- timeoutMs: args.timeoutMs ?? 0,
23941
- env,
23942
- config: cfg
23943
- });
23944
- let settled = false;
23945
- let settledSpillPath;
23946
- managed.completion.then((value) => {
23947
- settled = true;
23948
- settledSpillPath = value.output.spillPath;
23949
- }, () => {
23950
- settled = true;
23951
- settledSpillPath = void 0;
23952
- });
23953
- return {
23954
- hooks: {
23955
- ...managed.hooks,
23956
- readOutput: () => {
23957
- const text = managed.hooks.readOutput?.() ?? "";
23958
- return settled ? applyConfiguredTruncation(text, settledSpillPath, cfg.outputTruncate) : text;
23959
- }
23960
- },
23961
- completion: managed.completion
23962
- };
23764
+ return jobs.list(owner).filter((j) => j.status === "running" || j.status === "stopping").length < cfg.maxBackgroundJobs;
23963
23765
  };
23766
+ const startManagedJob = (job) => startBashJob({
23767
+ sessionId,
23768
+ command,
23769
+ cwd: commandCwd,
23770
+ timeoutMs: args.timeoutMs ?? 0,
23771
+ env,
23772
+ config: cfg,
23773
+ ...job !== void 0 ? { job } : {},
23774
+ formatCompletion: (text, spillPath) => applyConfiguredTruncation(text, spillPath, cfg.outputTruncate)
23775
+ });
23776
+ const jobSpec = (run) => ({
23777
+ kind: "bash",
23778
+ label: command,
23779
+ ...owner !== void 0 ? { owner } : {},
23780
+ ...Number.isSafeInteger(cfg.outputMaxBytes) && cfg.outputMaxBytes > 0 ? { outputLimitBytes: cfg.outputMaxBytes } : {},
23781
+ run
23782
+ });
23964
23783
  if (args.run_in_background === true) {
23965
23784
  if (!cfg.enableRunInBackground) throw new Error("run_in_background is disabled for this deployment (enableRunInBackground: false)");
23966
23785
  if (jobs === void 0) throw new Error("background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs");
23967
23786
  if (!backgroundSlotsAvailable()) throw new Error(`background jobs at capacity (maxBackgroundJobs: ${cfg.maxBackgroundJobs})`);
23968
23787
  if (exec.signal.aborted) throw abortError();
23969
- let managed;
23970
23788
  return {
23971
23789
  kind: "background",
23972
- jobId: jobs.start({
23973
- kind: "bash",
23974
- label: command,
23975
- ...exec.agent !== void 0 ? { owner: exec.agent } : {},
23976
- run: () => {
23977
- managed = startManagedJob();
23978
- return managed.hooks;
23979
- }
23980
- })
23790
+ jobId: jobs.start(jobSpec((job) => startManagedJob(job).hooks))
23981
23791
  };
23982
23792
  }
23983
23793
  if (cfg.autoBackgroundMs > 0 && jobs !== void 0 && backgroundSlotsAvailable() && !exec.signal.aborted) {
23984
- const autoBgWaitMs = timeoutMs === 0 ? cfg.autoBackgroundMs : Math.max(0, Math.min(cfg.autoBackgroundMs, timeoutMs - 1e3));
23794
+ const autoBgWaitMs = Math.max(1, timeoutMs === 0 ? cfg.autoBackgroundMs : Math.min(cfg.autoBackgroundMs, timeoutMs - 1e3));
23985
23795
  let managed;
23986
23796
  let id;
23987
23797
  try {
23988
- id = jobs.start({
23989
- kind: "bash",
23990
- label: command,
23991
- ...exec.agent !== void 0 ? { owner: exec.agent } : {},
23992
- run: () => {
23993
- managed = startManagedJob();
23994
- return managed.hooks;
23995
- }
23996
- });
23798
+ id = jobs.start(jobSpec((job) => {
23799
+ managed = startManagedJob(job);
23800
+ return managed.hooks;
23801
+ }));
23997
23802
  } catch {
23998
23803
  const startedAt = performance.now();
23999
23804
  const result = await executeBash(command, {
@@ -24025,34 +23830,23 @@ function apply(ctx, config = {}) {
24025
23830
  if (result.workingDir !== void 0) state.cwd = result.workingDir;
24026
23831
  return buildForeground(result, performance.now() - startedAt, timeoutMs);
24027
23832
  }
24028
- const completion = managed.completion;
24029
- const window = await Promise.race([
24030
- completion.then((value) => ({
24031
- kind: "completed",
24032
- value
24033
- })),
24034
- new Promise((resolve) => {
24035
- setTimeout(() => resolve({ kind: "window" }), autoBgWaitMs).unref?.();
24036
- }),
24037
- new Promise((_, reject) => {
24038
- if (exec.signal.aborted) {
24039
- reject(abortError());
24040
- return;
24041
- }
24042
- exec.signal.addEventListener("abort", () => reject(abortError()), { once: true });
24043
- })
24044
- ]);
24045
- if (window.kind === "window") return {
23833
+ let settled = false;
23834
+ try {
23835
+ const view = await jobs.wait(id, autoBgWaitMs, owner, exec.signal);
23836
+ settled = view.status !== "running" && view.status !== "stopping";
23837
+ } catch {
23838
+ jobs.kill(id, owner, "tool call aborted");
23839
+ await jobs.wait(id, Math.max(1e3, timeoutMs), owner);
23840
+ throw abortError();
23841
+ }
23842
+ if (!settled) return {
24046
23843
  kind: "background",
24047
23844
  jobId: id
24048
23845
  };
24049
- if (window.kind === "aborted") {
24050
- jobs.kill(id, exec.agent, "tool call aborted");
24051
- throw abortError();
24052
- }
24053
- jobs.read(id, exec.agent);
24054
- if (window.value.workingDir !== void 0) state.cwd = window.value.workingDir;
24055
- return window.value;
23846
+ const value = await managed.completion;
23847
+ jobs.remove(id, owner);
23848
+ if (value.workingDir !== void 0) state.cwd = value.workingDir;
23849
+ return value;
24056
23850
  }
24057
23851
  const startedAt = performance.now();
24058
23852
  const result = await executeBash(command, {