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

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 +377 -906
  5. package/lib/index.mjs +885 -1087
  6. package/lib/{scrapers-mhvFrvjH.mjs → scrapers-DSArdJ6N.mjs} +1 -1
  7. package/lib/types/client/ToolPlusSection.d.ts +3 -3
  8. package/lib/types/client/forms.d.ts +6 -6
  9. package/lib/types/client/index.d.ts +29 -12
  10. package/lib/types/client/locales.d.ts +1 -1
  11. package/lib/types/client/preset-panel.d.ts +54 -42
  12. package/lib/types/config/settings.d.ts +202 -21
  13. package/lib/types/host/browser-probe-rpc.d.ts +2 -1
  14. package/lib/types/host/preset-host.d.ts +103 -0
  15. package/lib/types/host/preset-rpc.d.ts +37 -72
  16. package/lib/types/presets/compare.d.ts +14 -37
  17. package/lib/types/presets/conflicts.d.ts +59 -0
  18. package/lib/types/presets/delta.d.ts +23 -18
  19. package/lib/types/presets/plan.d.ts +55 -0
  20. package/lib/types/presets/rows.d.ts +84 -0
  21. package/lib/types/presets/status.d.ts +56 -47
  22. package/lib/types/tools/bash/background.d.ts +20 -5
  23. package/lib/types/tools/bash/streaming-output.d.ts +4 -2
  24. package/lib/types/tools/shared/browser-rpc-channel.d.ts +84 -42
  25. package/lib/types/web/client/labels.d.ts +1 -1
  26. package/lib/types/web/client/row-utils.d.ts +26 -1
  27. package/lib/{utils-eiUz9n8I.mjs → utils-DA8R1_Q8.mjs} +1 -1
  28. package/package.json +166 -151
  29. package/presets/tool-plus-ptc.patch.yml +164 -0
  30. package/presets/tool-plus-standard.patch.yml +158 -0
  31. package/lib/types/client/BashPlusCard.d.ts +0 -53
  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-DA8R1_Q8.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";
@@ -726,7 +726,8 @@ async function getOrCreateSnapshot(shell, env, timeoutMs = SNAPSHOT_TIMEOUT_MS)
726
726
  env: spawnEnv,
727
727
  timeout: timeoutMs,
728
728
  stdio: "ignore",
729
- killSignal: "SIGKILL"
729
+ killSignal: "SIGKILL",
730
+ windowsHide: true
730
731
  });
731
732
  await new Promise((resolve, reject) => {
732
733
  child.on("close", (code) => {
@@ -1966,8 +1967,10 @@ var OutputSink = class extends OutputSink$1 {
1966
1967
  };
1967
1968
  /**
1968
1969
  * 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.
1970
+ * `reset()` increment: the consuming-read cursor reset the bash background
1971
+ * producer used while `JobHooks.readOutput` existed. 0.1.7 moves the model's
1972
+ * cursor onto the job registry's output ring, so the increment is no longer
1973
+ * part of that path; the preview buffer still offers it.
1971
1974
  */
1972
1975
  var TailBuffer = class {
1973
1976
  #inner;
@@ -2651,25 +2654,25 @@ function sweepStaleSpillFiles(now = Date.now()) {
2651
2654
  * Background-job adaptation for the `ctx.jobs` seam: one managed job runs its
2652
2655
  * command on its OWN `:async:` Shell (never the session's persistent shell),
2653
2656
  * 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.
2657
+ * identity, lifecycle state, the output ring, and completion notices
2658
+ * (`dsh-tool-jobs` injects them); this module owns the execution resources,
2659
+ * streams the run into that ring, and appends the plugin's completion message
2660
+ * once the run ends.
2656
2661
  * @module @xiaoso/dsh-tool-plus/background
2657
2662
  */
2658
2663
  /**
2659
2664
  * 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`.
2665
+ * `AbortController` (cancellation kills only this job's shell) and a bounded
2666
+ * preview tail; the registry owns the model's consuming cursor over the ring
2667
+ * this job writes to.
2662
2668
  * @param options - job identity and execution parameters.
2663
2669
  * @returns the registry hooks plus the tool-result completion promise.
2664
2670
  */
2665
2671
  function startBashJob(options) {
2666
- const { sessionId, command, cwd, timeoutMs, env, config } = options;
2672
+ const { sessionId, command, cwd, timeoutMs, env, config, job } = options;
2667
2673
  const shellKey = `${sessionId}:async:${randomBytes(4).toString("hex")}`;
2668
2674
  const abortController = new AbortController();
2669
2675
  const preview = new TailBuffer(config.outputMaxBytes);
2670
- const delta = new TailBuffer(config.outputMaxBytes);
2671
- let settled = false;
2672
- let settledText = "";
2673
2676
  const completion = (async () => {
2674
2677
  const startedAt = performance.now();
2675
2678
  const spillPath = allocateSpillFile();
@@ -2699,11 +2702,9 @@ function startBashJob(options) {
2699
2702
  onMinimizedSave: (originalText) => saveOriginalText(originalText),
2700
2703
  onChunk: (chunk) => {
2701
2704
  preview.append(chunk);
2702
- delta.append(chunk);
2705
+ job?.append(chunk);
2703
2706
  }
2704
2707
  });
2705
- settled = true;
2706
- settledText = preview.text();
2707
2708
  const wallTimeMs = performance.now() - startedAt;
2708
2709
  const aborted = result.cancelled && abortController.signal.aborted;
2709
2710
  const timedOut = result.timedOut ?? (result.cancelled && !aborted);
@@ -2712,6 +2713,9 @@ function startBashJob(options) {
2712
2713
  const seconds = Math.max(1, Math.round((timeoutMs ?? 0) / 1e3));
2713
2714
  if (!text.includes("[Command timed out after")) text = `[Command timed out after ${seconds} seconds]\n\n${text}`;
2714
2715
  }
2716
+ const previewText = preview.text();
2717
+ const message = options.formatCompletion?.(previewText, result.spillPath) ?? previewText;
2718
+ if (job !== void 0 && message !== previewText) job.append(message);
2715
2719
  return {
2716
2720
  kind: "foreground",
2717
2721
  exitCode: result.exitCode ?? null,
@@ -2756,13 +2760,7 @@ function startBashJob(options) {
2756
2760
  }, (error) => ({
2757
2761
  status: "failed",
2758
2762
  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
- }
2763
+ }))
2766
2764
  },
2767
2765
  completion
2768
2766
  };
@@ -4887,8 +4885,24 @@ function toolPlusField(name) {
4887
4885
  TOOL_PLUS_FIELDS.map((field) => field.name);
4888
4886
  //#endregion
4889
4887
  //#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";
4888
+ /**
4889
+ * Protocol key `@deepseek-ai/cosmokit` stamps on a live config reference.
4890
+ * `Symbol.for` keeps the check valid across copies of the shared library — the
4891
+ * reason cosmokit's own `isVolatile` resolves the same key.
4892
+ */
4893
+ const VOLATILE_WRITE = Symbol.for("cosmokit.volatile.write");
4894
+ /**
4895
+ * Whether a parsed value is one of the Loader's live config references.
4896
+ *
4897
+ * `@deepseek-ai/cordis` re-exports the `Volatile` TYPE only, and cosmokit —
4898
+ * which owns the runtime check — is not a dependency of this package, so the
4899
+ * protocol is read here instead of imported.
4900
+ * @param value - a parsed config value.
4901
+ * @returns whether the value is a live reference.
4902
+ */
4903
+ function isLiveField(value) {
4904
+ return typeof value === "object" && value !== null && VOLATILE_WRITE in value;
4905
+ }
4892
4906
  /** OMP-parity defaults for the completion-message truncation policy. */
4893
4907
  const DEFAULT_OUTPUT_TRUNCATE = {
4894
4908
  strategy: "bytes",
@@ -4923,73 +4937,81 @@ function fieldDefault(name, fallback) {
4923
4937
  return value === void 0 ? fallback : value;
4924
4938
  }
4925
4939
  /**
4926
- * Runtime configuration schema for the plugin. The namespace resolves through
4927
- * this flat surface; {@link resolveConfig} maps it onto {@link RuntimeConfig}.
4940
+ * Runtime configuration schema for the plugin, in the dsh 0.1.7 settings shape:
4941
+ * EVERY field is declared `.volatile()`, because a settings namespace is the
4942
+ * plugin's own entry and `SettingsForms.describe()` serves exactly the fields
4943
+ * whose schema declares them live. {@link resolveConfig} maps the flat surface
4944
+ * onto {@link RuntimeConfig}.
4945
+ *
4946
+ * The annotation is deliberately inferred rather than written as `z<Config>`:
4947
+ * the schema's parsed output is {@link LiveConfig} (one stable reference per
4948
+ * field), while {@link Config} stays the plain settings DOCUMENT the browser
4949
+ * edits and the specs pass in.
4928
4950
  */
4929
4951
  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)),
4952
+ enableRunInBackground: z.boolean().default(fieldDefault("enableRunInBackground", true)).volatile(),
4953
+ autoBackgroundMs: z.number().default(fieldDefault("autoBackgroundMs", DEFAULT_AUTO_BACKGROUND_MS)).volatile(),
4954
+ defaultTimeoutMs: z.number().default(fieldDefault("defaultTimeoutMs", DEFAULT_TIMEOUT_MS)).volatile(),
4955
+ maxTimeoutMs: z.number().default(fieldDefault("maxTimeoutMs", DEFAULT_MAX_TIMEOUT_MS)).volatile(),
4956
+ outputMaxBytes: z.number().default(fieldDefault("outputMaxBytes", 51200)).volatile(),
4957
+ outputSinkTailBytes: z.number().default(fieldDefault("outputSinkTailBytes", 51200)).volatile(),
4958
+ outputSinkHeadBytes: z.number().default(fieldDefault("outputSinkHeadBytes", 20480)).volatile(),
4959
+ minimizerEnabled: z.boolean().default(fieldDefault("minimizerEnabled", true)).volatile(),
4960
+ interceptorEnabled: z.boolean().default(fieldDefault("interceptorEnabled", true)).volatile(),
4961
+ nonInteractiveEnv: z.boolean().default(fieldDefault("nonInteractiveEnv", true)).volatile(),
4962
+ snapshotEnabled: z.boolean().default(fieldDefault("snapshotEnabled", true)).volatile(),
4963
+ rmSafe: z.boolean().default(fieldDefault("rmSafe", true)).volatile(),
4964
+ useShellCommandWrapper: z.boolean().default(fieldDefault("useShellCommandWrapper", false)).volatile(),
4965
+ webCards: z.boolean().default(fieldDefault("webCards", true)).volatile(),
4966
+ maxBackgroundJobs: z.number().default(fieldDefault("maxBackgroundJobs", 15)).volatile(),
4967
+ outputTruncateStrategy: z.union(["bytes", "lines"]).default(fieldDefault("outputTruncateStrategy", "bytes")).volatile(),
4968
+ outputTruncateTriggerBytes: z.number().default(fieldDefault("outputTruncateTriggerBytes", 10240)).volatile(),
4969
+ outputTruncateTriggerLines: z.number().default(fieldDefault("outputTruncateTriggerLines", 100)).volatile(),
4948
4970
  outputTruncateByteMode: z.union([
4949
4971
  "head",
4950
4972
  "tail",
4951
4973
  "middle"
4952
- ]).default(fieldDefault("outputTruncateByteMode", "middle")),
4953
- outputTruncateByteHeadBytes: z.number().default(fieldDefault("outputTruncateByteHeadBytes", 4096)),
4954
- outputTruncateByteTailBytes: z.number().default(fieldDefault("outputTruncateByteTailBytes", 4096)),
4974
+ ]).default(fieldDefault("outputTruncateByteMode", "middle")).volatile(),
4975
+ outputTruncateByteHeadBytes: z.number().default(fieldDefault("outputTruncateByteHeadBytes", 4096)).volatile(),
4976
+ outputTruncateByteTailBytes: z.number().default(fieldDefault("outputTruncateByteTailBytes", 4096)).volatile(),
4955
4977
  outputTruncateLineMode: z.union([
4956
4978
  "head",
4957
4979
  "tail",
4958
4980
  "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)),
4981
+ ]).default(fieldDefault("outputTruncateLineMode", "middle")).volatile(),
4982
+ outputTruncateLineHeadLines: z.number().default(fieldDefault("outputTruncateLineHeadLines", 50)).volatile(),
4983
+ outputTruncateLineTailLines: z.number().default(fieldDefault("outputTruncateLineTailLines", 100)).volatile(),
4984
+ editBlockAutoGenerated: z.boolean().default(fieldDefault("editBlockAutoGenerated", true)).volatile(),
4985
+ readSummarizeEnabled: z.boolean().default(fieldDefault("readSummarizeEnabled", true)).volatile(),
4986
+ readSummarizeProse: z.boolean().default(fieldDefault("readSummarizeProse", false)).volatile(),
4987
+ readSummarizeMinBodyLines: z.number().default(fieldDefault("readSummarizeMinBodyLines", 4)).volatile(),
4988
+ readSummarizeMinCommentLines: z.number().default(fieldDefault("readSummarizeMinCommentLines", 6)).volatile(),
4989
+ readSummarizeMinTotalLines: z.number().default(fieldDefault("readSummarizeMinTotalLines", 100)).volatile(),
4990
+ readSummarizeUnfoldUntil: z.number().default(fieldDefault("readSummarizeUnfoldUntil", 50)).volatile(),
4991
+ readSummarizeUnfoldLimit: z.number().default(fieldDefault("readSummarizeUnfoldLimit", 100)).volatile(),
4970
4992
  editMode: z.union([
4971
4993
  "replace",
4972
4994
  "patch",
4973
4995
  "hashline",
4974
4996
  "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)),
4997
+ ]).default(fieldDefault("editMode", "replace")).volatile(),
4998
+ editFuzzyMatch: z.boolean().default(fieldDefault("editFuzzyMatch", true)).volatile(),
4999
+ editFuzzyThreshold: z.number().default(fieldDefault("editFuzzyThreshold", .95)).volatile(),
5000
+ editEnforceSeenLines: z.boolean().default(fieldDefault("editEnforceSeenLines", false)).volatile(),
5001
+ readDefaultLimit: z.number().default(fieldDefault("readDefaultLimit", 300)).volatile(),
5002
+ readLineNumbers: z.boolean().default(fieldDefault("readLineNumbers", false)).volatile(),
5003
+ readRenderMarkdown: z.boolean().default(fieldDefault("readRenderMarkdown", false)).volatile(),
5004
+ readConcurrentSafe: z.boolean().default(fieldDefault("readConcurrentSafe", true)).volatile(),
5005
+ grepContextBefore: z.number().default(fieldDefault("grepContextBefore", 1)).volatile(),
5006
+ grepContextAfter: z.number().default(fieldDefault("grepContextAfter", 3)).volatile(),
5007
+ grepCaseDefault: z.boolean().default(fieldDefault("grepCaseDefault", true)).volatile(),
5008
+ grepGitignoreDefault: z.boolean().default(fieldDefault("grepGitignoreDefault", true)).volatile(),
5009
+ globGitignoreDefault: z.boolean().default(fieldDefault("globGitignoreDefault", true)).volatile(),
5010
+ globHiddenDefault: z.boolean().default(fieldDefault("globHiddenDefault", true)).volatile(),
5011
+ astGrepEnabled: z.boolean().default(fieldDefault("astGrepEnabled", false)).volatile(),
5012
+ astEditEnabled: z.boolean().default(fieldDefault("astEditEnabled", true)).volatile(),
5013
+ fetchEnabled: z.boolean().default(fieldDefault("fetchEnabled", true)).volatile(),
5014
+ fetchMaxTimeoutSeconds: z.number().default(fieldDefault("fetchMaxTimeoutSeconds", 0)).volatile(),
4993
5015
  fetchReader: z.union([
4994
5016
  "auto",
4995
5017
  "native",
@@ -4998,26 +5020,39 @@ const Config = z.object({
4998
5020
  "parallel",
4999
5021
  "jina",
5000
5022
  "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))
5023
+ ]).default(fieldDefault("fetchReader", "auto")).volatile(),
5024
+ browserReaderEnabled: z.boolean().default(fieldDefault("browserReaderEnabled", true)).volatile(),
5025
+ imagesAutoResize: z.boolean().default(fieldDefault("imagesAutoResize", true)).volatile(),
5026
+ imagesBlockImages: z.boolean().default(fieldDefault("imagesBlockImages", false)).volatile(),
5027
+ imagesExcludeWebp: z.boolean().default(fieldDefault("imagesExcludeWebp", false)).volatile(),
5028
+ imagesInputMaxBytes: z.number().default(fieldDefault("imagesInputMaxBytes", 20971520)).volatile(),
5029
+ imagesResizeMaxSide: z.number().default(fieldDefault("imagesResizeMaxSide", 1568)).volatile(),
5030
+ imagesResizeMaxBytes: z.number().default(fieldDefault("imagesResizeMaxBytes", 512e3)).volatile(),
5031
+ imagesResizeMinSide: z.number().default(fieldDefault("imagesResizeMinSide", 200)).volatile(),
5032
+ imagesResizeJpegQuality: z.number().default(fieldDefault("imagesResizeJpegQuality", 80)).volatile()
5011
5033
  });
5012
5034
  /**
5035
+ * Unwrap the Loader's live field references into a plain settings document.
5036
+ * @param input - the entry's live config, or an already-plain document.
5037
+ * @returns a plain copy carrying the fields the input has.
5038
+ */
5039
+ function plainFields(input) {
5040
+ const out = {};
5041
+ for (const [field, value] of Object.entries(input)) Object.assign(out, { [field]: isLiveField(value) ? value.get() : value });
5042
+ return out;
5043
+ }
5044
+ /**
5013
5045
  * Resolve the flat settings (composition entry or the settings document) to
5014
5046
  * the nested runtime config the ported OMP runtime consumes. A direct mount
5015
5047
  * (`ctx.plugin`) skips the Loader's schema parse, so the `?? default` pass
5016
5048
  * mirrors the schema; the settings namespace resolution rides the same path.
5017
- * @param config - flat settings, possibly partial and not schema-parsed.
5049
+ * Accepts both shapes a caller can hold: the Loader's live entry config (one
5050
+ * reference per field) and a plain settings document.
5051
+ * @param input - the entry's live config, or a plain (possibly partial) document.
5018
5052
  * @returns the fully-defaulted nested runtime config.
5019
5053
  */
5020
- function resolveConfig(config) {
5054
+ function resolveConfig(input) {
5055
+ const config = plainFields(input);
5021
5056
  const resolveMinimizer = () => ({
5022
5057
  enabled: config.minimizerEnabled ?? fieldDefault("minimizerEnabled", true),
5023
5058
  only: [],
@@ -5094,29 +5129,46 @@ function resolveConfig(config) {
5094
5129
  };
5095
5130
  }
5096
5131
  /**
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.
5132
+ * Install the optional-settings consumer wiring for this plugin, in the dsh
5133
+ * 0.1.7 shape.
5134
+ *
5135
+ * 0.1.7 deleted the namespace-registration API (`SettingsForms` no longer has
5136
+ * `installSection`): a settings namespace is not something a plugin installs
5137
+ * any more — it IS the plugin's own profile entry, and the service serves
5138
+ * exactly the Config fields whose schema declares them live
5139
+ * ({@link Config} declares every field `.volatile()`). The plugin therefore
5140
+ * keeps one authoritative source, its own entry config:
5141
+ *
5142
+ * - the Loader merges the composition layers and the profile override before
5143
+ * `apply` runs, so the entry already carries the effective settings;
5144
+ * - a settings write is committed INTO that entry's live references without a
5145
+ * remount, and the Loader then announces the changed paths on this fiber
5146
+ * (`loader/volatile-update`) — which is the signal for refreshing
5147
+ * registration facts (the `editMode`-sensitive descriptions and the AST
5148
+ * switches), exactly as the settings docs prescribe.
5149
+ *
5150
+ * The plugin ships its own settings pages in the browser half, so it owns its
5151
+ * page policy: `configure({ auto: false })` tells the settings service not to
5152
+ * auto-generate a schema page for this entry. That call rides an optional
5153
+ * `ctx.inject(['settings'], ...)` child, so the plugin runs unchanged in a
5154
+ * deployment that composes no settings service at all.
5155
+ *
5156
+ * Mirrors the official bash-local pattern (`packages/shell/bash-local`): the
5157
+ * business plugin reads its own Config references and registers only its page
5158
+ * policy; nothing re-registers the namespace.
5103
5159
  * @param ctx - plugin context owning the wiring.
5104
- * @param entry - composition entry config declared by the caller.
5160
+ * @param entry - the entry's config as the Loader resolved it (live references
5161
+ * over the composition layers and the profile override), or a plain document.
5105
5162
  * @param onSource - receives a thunk of the currently authoritative runtime config.
5106
5163
  */
5107
5164
  function installBashPlusSettings(ctx, entry, onSource) {
5108
- let currentSource;
5165
+ const current = () => resolveConfig(entry);
5166
+ onSource(current);
5167
+ ctx.on("loader/volatile-update", () => {
5168
+ onSource(current);
5169
+ });
5109
5170
  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
- });
5171
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber), "tool-plus: own settings pages");
5120
5172
  });
5121
5173
  }
5122
5174
  //#endregion
@@ -5135,13 +5187,27 @@ function installBashPlusSettings(ctx, entry, onSource) {
5135
5187
  /** Logical RPC channel registered by the host half of this plugin. */
5136
5188
  const TOOL_PLUS_RPC_CHANNEL = "/tool-plus";
5137
5189
  /**
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.
5190
+ * The preset ids this plugin **declares**. Single source of truth for both
5191
+ * halves: the host picks its own declarations out of the profile's composed
5192
+ * configuration, and the panel uses the list to tell "ours" from everything else.
5193
+ *
5194
+ * They ship as `@deepseek-ai/dsh-agent-preset` declaration rows in this
5195
+ * package's own bundle patches (`presets/<id>.patch.yml`), so installing the
5196
+ * plugin as a profile bundle declares them: no install step, no file copy, and
5197
+ * nothing written at startup.
5142
5198
  */
5143
5199
  const BUNDLED_PRESET_IDS = ["tool-plus-standard", "tool-plus-ptc"];
5144
5200
  /**
5201
+ * Loader row id a preset declaration is addressed by. A profile patch overrides
5202
+ * a declaration **by this id** — the harness's config editor locates the row by
5203
+ * id, and the override replaces the row's whole `config`.
5204
+ * @param presetId - The preset identity (the declaration's `config.id`).
5205
+ * @returns The row id as it appears in profile patches.
5206
+ */
5207
+ function profileRowId(presetId) {
5208
+ return `preset-${presetId}`;
5209
+ }
5210
+ /**
5145
5211
  * Whether one difference belongs to the "yours" group (you added or changed it)
5146
5212
  * rather than the "behind" group (the template has it, you do not). Lives in the
5147
5213
  * shared contract so host and client group the exact same way.
@@ -5288,8 +5354,8 @@ function parsePresetComparePayload(payload) {
5288
5354
  }
5289
5355
  /**
5290
5356
  * 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.
5357
+ * `{ id: non-empty string, action: 'upgrade' | 'align' | 'revert', templateId?: non-empty string }`
5358
+ * is rejected, so the write endpoint never receives a half-formed request.
5293
5359
  * @param payload - the raw RPC payload.
5294
5360
  * @returns the narrowed payload, or undefined when invalid.
5295
5361
  */
@@ -5297,7 +5363,7 @@ function parsePresetActionPayload(payload) {
5297
5363
  if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
5298
5364
  const { id, action, templateId } = payload;
5299
5365
  if (typeof id !== "string" || id.length === 0) return void 0;
5300
- if (action !== "upgrade" && action !== "reset") return void 0;
5366
+ if (action !== "upgrade" && action !== "align" && action !== "revert") return void 0;
5301
5367
  if (templateId !== void 0 && (typeof templateId !== "string" || templateId.length === 0)) return void 0;
5302
5368
  return templateId === void 0 ? {
5303
5369
  id,
@@ -5308,26 +5374,8 @@ function parsePresetActionPayload(payload) {
5308
5374
  templateId
5309
5375
  };
5310
5376
  }
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
5377
  /** One RPC error result in the gateway's shape. */
5330
- function failure$1(error, code = "internal") {
5378
+ function failure$2(error, code = "internal") {
5331
5379
  return {
5332
5380
  ok: false,
5333
5381
  error: {
@@ -5338,62 +5386,47 @@ function failure$1(error, code = "internal") {
5338
5386
  };
5339
5387
  }
5340
5388
  /**
5341
- * Answer one preset endpoint on the shared `/tool-plus` channel.
5389
+ * Answer one preset endpoint on the shared `/tool-plus` route.
5342
5390
  *
5343
5391
  * 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.
5392
+ * (the single route handler) can keep serving its own families.
5393
+ * @param ctx - context forwarded to the injected host implementation.
5394
+ * @param deps - injected host implementation.
5349
5395
  * @param endpoint - the requested endpoint name.
5350
5396
  * @param payload - the raw RPC payload.
5351
5397
  * @returns the RPC result, or undefined when the endpoint is not ours.
5352
5398
  */
5353
5399
  async function handlePresetEndpoint(ctx, deps, endpoint, payload) {
5354
5400
  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
5401
  if (endpoint === "presets/status") try {
5358
- const roster = await service.list();
5359
5402
  return {
5360
5403
  ok: true,
5361
- value: {
5362
- presets: deps.listStatuses(roster.map(toRosterEntry)),
5363
- templates: deps.listTemplates()
5364
- }
5404
+ value: await deps.listPresets(ctx)
5365
5405
  };
5366
5406
  } catch (error) {
5367
- return failure$1(error);
5407
+ return failure$2(error);
5368
5408
  }
5369
5409
  if (endpoint === "presets/compare") {
5370
5410
  const parsed = parsePresetComparePayload(payload);
5371
- if (!parsed) return failure$1("presets/compare expects { presetId: string, templateId: string }", "bad-request");
5411
+ if (!parsed) return failure$2("presets/compare expects { presetId: string, templateId: string }", "bad-request");
5372
5412
  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
5413
  return {
5377
5414
  ok: true,
5378
- value: {
5379
- presetId: parsed.presetId,
5380
- templateId: parsed.templateId,
5381
- ...deps.comparePreset(preset, parsed.templateId)
5382
- }
5415
+ value: await deps.comparePreset(ctx, parsed.presetId, parsed.templateId)
5383
5416
  };
5384
5417
  } catch (error) {
5385
- return failure$1(error);
5418
+ return failure$2(error);
5386
5419
  }
5387
5420
  }
5388
5421
  const parsed = parsePresetActionPayload(payload);
5389
- if (!parsed) return failure$1("presets/apply expects { id: string, action: \"upgrade\" | \"reset\", templateId?: string }", "bad-request");
5422
+ if (!parsed) return failure$2("presets/apply expects { id: string, action: \"upgrade\" | \"align\" | \"revert\", templateId?: string }", "bad-request");
5390
5423
  try {
5391
5424
  return {
5392
5425
  ok: true,
5393
- value: deps.applyAction(parsed.id, parsed.action, parsed.templateId)
5426
+ value: await deps.applyAction(ctx, parsed.id, parsed.action, parsed.templateId)
5394
5427
  };
5395
5428
  } catch (error) {
5396
- return failure$1(error);
5429
+ return failure$2(error);
5397
5430
  }
5398
5431
  }
5399
5432
  //#endregion
@@ -5431,11 +5464,14 @@ function toGitBashPath(p) {
5431
5464
  }
5432
5465
  /** 默认 spawn 实现:收集 stdout,超时 kill 并标记 timedOut。 */
5433
5466
  const defaultSpawnDeps = { spawnShell: (shell, args, timeoutMs) => new Promise((resolve, reject) => {
5434
- const child = spawn(shell, args, { stdio: [
5435
- "ignore",
5436
- "pipe",
5437
- "pipe"
5438
- ] });
5467
+ const child = spawn(shell, args, {
5468
+ stdio: [
5469
+ "ignore",
5470
+ "pipe",
5471
+ "pipe"
5472
+ ],
5473
+ windowsHide: true
5474
+ });
5439
5475
  let stdout = "";
5440
5476
  let timedOut = false;
5441
5477
  const timer = setTimeout(() => {
@@ -5511,7 +5547,8 @@ async function queryRmSafeStatus(deps) {
5511
5547
  *
5512
5548
  * **Why this registers its own route instead of calling `connection.rpc.handle`.**
5513
5549
  * 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
5550
+ * dsh 0.1.5-rc.1 — and still on 0.1.7-rc.1, re-checked for this migration:
5551
+ * `HostConnectionService.rpc` builds its handle against
5515
5552
  * `owner = this.ctx` (`rpc-host.ts: "Generic channel registry scoped to the
5516
5553
  * Context reading this service"`), and `register()` then evaluates
5517
5554
  * `owner.webServer.register(route)`. The client-connection row declares
@@ -5542,7 +5579,7 @@ async function queryRmSafeStatus(deps) {
5542
5579
  /** Cap on one request body; every legitimate payload here is a few hundred bytes. */
5543
5580
  const MAX_BODY_BYTES = 1048576;
5544
5581
  /** One internal error result in the gateway's shape. */
5545
- function failure(error) {
5582
+ function failure$1(error) {
5546
5583
  return {
5547
5584
  ok: false,
5548
5585
  error: {
@@ -5626,7 +5663,7 @@ function installBrowserProbeRpc(ctx, deps) {
5626
5663
  value: { found: probeBrowsers() }
5627
5664
  };
5628
5665
  } catch (error) {
5629
- return failure(error);
5666
+ return failure$1(error);
5630
5667
  }
5631
5668
  if (endpoint === "rmSafe/status") {
5632
5669
  if (!deps.getRmSafe()) return {
@@ -5647,7 +5684,7 @@ function installBrowserProbeRpc(ctx, deps) {
5647
5684
  })
5648
5685
  };
5649
5686
  } catch (error) {
5650
- return failure(error);
5687
+ return failure$1(error);
5651
5688
  }
5652
5689
  }
5653
5690
  if (deps.presets !== void 0) {
@@ -5733,772 +5770,295 @@ function installBrowserProbeRpc(ctx, deps) {
5733
5770
  };
5734
5771
  }
5735
5772
  //#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);
5773
+ //#region src/presets/rows.ts
5774
+ /** 行 id 的取值(非空字符串才认,与官方对 `name` 的要求同款)。 */
5775
+ function rowId(row) {
5776
+ return typeof row.id === "string" && row.id.length > 0 ? row.id : void 0;
5805
5777
  }
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;
5778
+ /** 行模块名(官方 entry-list 要求每行都有非空 `name`)。 */
5779
+ function rowName(row) {
5780
+ return typeof row.name === "string" && row.name.length > 0 ? row.name : void 0;
5866
5781
  }
5867
- /** 逐字节重建(`splitLines` 的逆运算)。 */
5868
- function joinLines(lines) {
5869
- return lines.map((line) => line.text + line.eol).join("");
5870
- }
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;
5782
+ /** 这一行是不是 group 载体(group 的子行在 `config` 里)。 */
5783
+ function isGroupRow(row) {
5784
+ return row.group === true && Array.isArray(row.config);
5888
5785
  }
5889
5786
  /**
5890
- * 形状防护:文本必须构成"顶层行列表"。
5891
- * 判据:至少一行有效内容;**首个**有效行是顶层 `- `;所有第 0 列有效行都是
5892
- * 顶层 `- `(映射、仅注释的空文件、被破坏的片段、只有一段缩进嵌套的片段
5893
- * 都会落在这里)。
5787
+ * 判定一份 `plugins` 值是不是合法的顶层行列表。规则与官方
5788
+ * `entryListProblem` 一致:必须是数组,每一项必须是带非空 `name` 的映射。
5789
+ * @param value - 待判定的值(来自宿主配置或测试注入)。
5790
+ * @returns 合法时为 true。
5894
5791
  */
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";
5792
+ function isRowList(value) {
5793
+ if (!Array.isArray(value)) return false;
5794
+ return value.every((row) => typeof row === "object" && row !== null && !Array.isArray(row) && rowName(row) !== void 0);
5901
5795
  }
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
- });
5796
+ /**
5797
+ * 展平一棵行树(含 group 嵌套),键为 `父/子` 形式的路径。
5798
+ *
5799
+ * 路径是**给面板看的标识**,也用于我们自己的差异比较;官方
5800
+ * `compositionInventory()` 回的行没有路径(它压平后只剩 entryId/moduleName),
5801
+ * 所以需要分组结构时只能从声明本身取。
5802
+ * @param rows - 顶层行列表。
5803
+ * @param prefix - 递归时的父路径。
5804
+ * @returns 路径 → 行。
5805
+ */
5806
+ function flattenRows(rows, prefix = "") {
5807
+ const out = /* @__PURE__ */ new Map();
5808
+ for (const row of rows) {
5809
+ const id = rowId(row);
5810
+ if (id === void 0) continue;
5811
+ const path = prefix.length > 0 ? `${prefix}/${id}` : id;
5812
+ out.set(path, row);
5813
+ if (isGroupRow(row)) for (const [nested, nestedRow] of flattenRows(row.config, path)) out.set(nested, nestedRow);
5914
5814
  }
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
- });
5815
+ return out;
5938
5816
  }
5939
5817
  /**
5940
- * 行块自身的 `disabled:` 键行下标(只认与行内其它键同缩进的那一行;
5941
- * 嵌套子行的 `disabled` 不算)。返回 undefined 表示该行块没有禁用键。
5818
+ * `!!js` 表达式标记:entry-list 方言把它解析成 `{ __jsExpr: <源码> }`(见
5819
+ * `vendor/include/src/index.ts` 的 `JsExpr`)。它对我们是**标量**——一整段待求值的
5820
+ * 源码,不是可以逐键比较的结构。漏了这一步会造出 `disabled.__jsExpr` 这种假叶子,
5821
+ * 顺带让"冲突行的 disabled 归 conflicts 管"这条跳过规则失效。
5822
+ * @param value - 待判定的值。
5823
+ * @returns 是表达式标记时为 true。
5942
5824
  */
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
- }
5825
+ function isJsExpr(value) {
5826
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 1 && typeof value.__jsExpr === "string";
5950
5827
  }
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();
5828
+ /** 规范化一个标量用于比较与展示。 */
5829
+ function canonical(value) {
5830
+ if (value === void 0) return "";
5831
+ if (isJsExpr(value)) return `!!js ${value.__jsExpr}`;
5832
+ return JSON.stringify(value) ?? String(value);
5956
5833
  }
5957
5834
  /**
5958
- * 把一整行 `disabled: <值>` 换成**整值** `true`,原有尾随注释保留。
5835
+ * 收集一行自己的叶子路径(不含 `id`,不含 group 的子行)。
5959
5836
  *
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
5837
+ * 用规范化 JSON 而不是 `String()`:Loader 对 `disabled` 的语义是
5838
+ * "布尔取 Boolean(value)",所以字符串 `"false"` 与布尔 `false` **语义相反**;
5839
+ * 两者若都显示成 `false`,面板就会把一处真实差异藏起来。
5840
+ * @param row - 目标行。
5841
+ * @param base - 递归时的前缀。
5842
+ * @returns 该行的叶子清单(顺序为键的声明顺序)。
5843
+ */
5844
+ function rowLeaves(row, base = "") {
5845
+ const out = [];
5846
+ const walk = (value, path) => {
5847
+ if (isJsExpr(value)) {
5848
+ out.push({
5849
+ path,
5850
+ value: canonical(value)
6010
5851
  });
6011
- continue;
5852
+ return;
6012
5853
  }
6013
- const indent = row.keyIndentText ?? " ";
6014
- plans.push({
6015
- kind: "append",
6016
- id,
6017
- at: appendIndex(lines, row),
6018
- indent
5854
+ if (Array.isArray(value)) {
5855
+ value.forEach((item, index) => walk(item, `${path}[${index}]`));
5856
+ return;
5857
+ }
5858
+ if (value !== null && typeof value === "object") {
5859
+ for (const [key, nested] of Object.entries(value)) walk(nested, path.length > 0 ? `${path}.${key}` : key);
5860
+ return;
5861
+ }
5862
+ out.push({
5863
+ path,
5864
+ value: canonical(value)
6019
5865
  });
5866
+ };
5867
+ for (const [key, value] of Object.entries(row)) {
5868
+ if (key === "id") continue;
5869
+ if (key === "config" && isGroupRow(row)) continue;
5870
+ walk(value, base.length > 0 ? `${base}.${key}` : key);
6020
5871
  }
6021
- return plans;
5872
+ return out;
6022
5873
  }
5874
+ /** 规范化的深比较(两侧都是 YAML/JSON 值,无函数与原型差异)。 */
5875
+ function sameValue(a, b) {
5876
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
5877
+ }
5878
+ //#endregion
5879
+ //#region src/presets/conflicts.ts
6023
5880
  /**
6024
- * 把官方冲突行改写成"不存在或 disabled: true"。
5881
+ * 冲突不变式:一个 preset「接上了本插件的工具」当且仅当官方那 4 个工具行
5882
+ * 处于**不存在**或 **`disabled: true`** 状态。
6025
5883
  *
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
- };
5884
+ * 原因(这套设计的地基,别丢):`@xiaoso/dsh-tool-plus` 作为包在**宿主面**
5885
+ * 加载时,它自己的 profile 补丁已经禁用了这 4 行并插入**一行全局** `tool-plus`;
5886
+ * 注册表按 scope 分层、就近优先,所以宿主面那一份已经服务所有会话。若某个
5887
+ * preset 又在 **agent 面**挂一份同名工具行,就会用**第二个 `apply()` 影子实例**
5888
+ * 盖掉健康的宿主实例,而那个实例的 settings 注入永不触发(cfg 退化成
5889
+ * replace-mode 读取 —— 2026-08-25 hashline 事故的根因)。
5890
+ *
5891
+ * 与旧文本实现的唯一区别是**载体**:以前逐行读 `agent.cordis.yml` 的文本,
5892
+ * 现在直接看宿主交给我们的行对象。判定规则一字不改:
5893
+ * - 只认**顶层**行;group 里嵌套的同名行不碰;
5894
+ * - `disabled` 严格等于布尔 `true` 才算已处理 —— 缺席、`false`、字符串、
5895
+ * `!!js` 表达式统统算冲突(`!!js` 在挂载时才求值,我们要的是无条件关闭,
5896
+ * 所以和旧实现一样整值翻成 `true`);
5897
+ * - 输出恒为新数组,输入不变;结果与输入逐值相同时 `changes` 为空(调用方以
5898
+ * 此判定"不写盘")。
5899
+ * @module @xiaoso/dsh-tool-plus/presets/conflicts
5900
+ */
5901
+ /** 官方 standard / ptc / cordis 会挂的 4 个工具行 id。 */
5902
+ const PRESET_CONFLICT_IDS = [
5903
+ "tool-bash",
5904
+ "tool-pwsh",
5905
+ "tool-fs",
5906
+ "tool-fs-search"
5907
+ ];
5908
+ /** 该行是否已被无条件关闭(只有布尔 `true` 算)。 */
5909
+ function disabled(row) {
5910
+ return row.disabled === true;
5911
+ }
5912
+ /** 顶层行里按 id 找第一行(只看顶层,与不变式一致)。 */
5913
+ function topRow(rows, id) {
5914
+ return rows.find((row) => rowId(row) === id);
6081
5915
  }
6082
5916
  /**
6083
- * 只读判定:该组合还有哪些冲突行没被禁用。
6084
- * @param text - `agent.cordis.yml` 的原文。
6085
- * @param conflictIds - 冲突行 id,缺省 {@link PRESET_CONFLICT_IDS}。
6086
- * @returns 冲突清单、是否干净、形状结论。
5917
+ * 只读分析一份 `plugins` 值。
5918
+ * @param plugins - 声明的子插件列表(来自宿主配置,形状未知)。
5919
+ * @param conflictIds - 冲突行清单,默认 {@link PRESET_CONFLICT_IDS}。
5920
+ * @returns 冲突清单、是否干净、形状是否可识别。
6087
5921
  */
6088
- function analyzePresetComposition(text, conflictIds) {
6089
- const lines = splitLines(text);
6090
- const shape = detectShape(lines);
6091
- if (shape === "unrecognized") return {
5922
+ function analyzePlugins(plugins, conflictIds = PRESET_CONFLICT_IDS) {
5923
+ if (!isRowList(plugins)) return {
6092
5924
  conflicts: [],
6093
5925
  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";
5926
+ shape: "unrecognized"
5927
+ };
5928
+ const conflicts = conflictIds.filter((id) => {
5929
+ const row = topRow(plugins, id);
5930
+ return row !== void 0 && !disabled(row);
6102
5931
  });
6103
5932
  return {
6104
5933
  conflicts,
6105
5934
  clean: conflicts.length === 0,
6106
- shape
5935
+ shape: "ok"
6107
5936
  };
6108
5937
  }
6109
- /** 行内 `key: value`(顶层键;嵌套子键缩进更大,不在这里取)。 */
6110
- const ROW_KEY = /^[ \t]*([^#\s:][^:]*):[ \t]*(.*)$/;
6111
- /** 去掉行尾注释与引号,得到可比的值。 */
6112
- function leafValue(text) {
6113
- return stripQuotes(text.replace(/[ \t]+#.*$/, "").trim());
6114
- }
6115
5938
  /**
6116
- * 把一个行块按缩进展开成叶子路径:容器键(`config:`)只做路径前缀,列表项
6117
- * 以 `[i]` 编号。只读、不修改原文;识别不了的行原样当叶子。
5939
+ * 把那 4 个仍挂着的冲突行翻成 `disabled: true`(不改别的行、不删行)。
5940
+ * @param plugins - 合法顶层行列表。
5941
+ * @param conflictIds - 冲突行清单,默认 {@link PRESET_CONFLICT_IDS}。
5942
+ * @returns 新列表与实际改动清单(无改动时 `changes` 为空且列表为原引用)。
6118
5943
  */
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
5944
+ function disableConflicts(plugins, conflictIds = PRESET_CONFLICT_IDS) {
5945
+ const changes = [];
5946
+ let touched = false;
5947
+ const next = plugins.map((row) => {
5948
+ const id = rowId(row);
5949
+ if (id === void 0 || !conflictIds.includes(id) || disabled(row)) return row;
5950
+ changes.push({
5951
+ id,
5952
+ action: Object.hasOwn(row, "disabled") ? "flipped" : "disabled"
6166
5953
  });
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
- }
5954
+ touched = true;
6194
5955
  return {
6195
- id: row.id,
6196
- keys,
6197
- leaves: walkRowLeaves(lines, row)
5956
+ ...row,
5957
+ disabled: true
6198
5958
  };
6199
5959
  });
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}`,
5960
+ if (!touched) return {
5961
+ plugins,
6329
5962
  changes: []
6330
5963
  };
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);
5964
+ changes.sort((a, b) => conflictIds.indexOf(a.id) - conflictIds.indexOf(b.id));
6339
5965
  return {
6340
- ok: true,
6341
- changed: true,
6342
- backupPath,
5966
+ plugins: next,
6343
5967
  changes
6344
5968
  };
6345
5969
  }
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
5970
  //#endregion
6410
5971
  //#region src/presets/delta.ts
6411
5972
  /**
6412
- * 预设差异清单:只读地说清"用户这份和随包模板差在哪"。
5973
+ * 差异清单:只读地说清"这份 preset 与另一份(通常是随包声明)差在哪"。
6413
5974
  *
6414
5975
  * 为什么需要它:`templateDiffers: boolean` 只能说"不一样",而面板上紧挨着的
6415
- * 是"工具冲突行都已禁用、无需调整"。两句都对,但读起来打架 —— 前者说的是
6416
- * **你改过内容**,后者说的是**我们自己那 4 个冲突行**。把差异逐条列出来,
6417
- * 面板才不需要用户自己猜"到底哪里不同、更新会不会动它"。
5976
+ * 是"冲突行都已禁用、无需调整"。两句都对,读起来却打架 —— 前者说的是
5977
+ * **内容被改过**,后者说的是**我们自己那 4 个冲突行**。把差异逐条列出来,
5978
+ * 用户才不用猜"到底哪里不同、对齐会不会动它"。
6418
5979
  *
6419
- * 契约:①比**展开到叶子的点号路径**(`config.thresholdRatio`,列表项带 `[i]`)——
6420
- * 真实漂移常藏在嵌套键里,只比顶层键会得出"没有差异"的错误结论;②冲突行的
6421
- * `disabled` 由 `conflicts` 负责,不在差异里重复报;③形状不可识别 → 空清单,
6422
- * 绝不猜;④顺序=用户这份的行序在前,模板独有的行补在后;⑤`total` 是全量,
6423
- * `items` 可被 `limit` 截断(面板据此说"等 N 项")。
5980
+ * 契约(自旧文本实现原样继承,语义未变):
5981
+ * - 比**展开到叶子的点号路径**(`config.thresholdRatio`、`config.models[0].id`)——
5982
+ * 真实漂移常藏在嵌套键里,只比顶层键会得出"没有差异"的错误结论;
5983
+ * - 冲突行的 `disabled` 由 `conflicts` 负责,不在差异里重复报;
5984
+ * - 形状不可识别 → 空清单,绝不猜;
5985
+ * - 顺序 = 你这份的行序在前(含 group 内子行),对方独有的行补在后;
5986
+ * - `total` 是全量,`items` 可被 `limit` 截断(面板据此说"等 N 项")。
6424
5987
  * @module @xiaoso/dsh-tool-plus/presets/delta
6425
5988
  */
6426
- /** 默认最多列几条(再多就该去看文件本身了)。 */
5989
+ /** 默认最多列几条(再多就该去看配置本身了)。 */
6427
5990
  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]));
5991
+ /** 空结果(任一侧形状不可识别时)。 */
5992
+ const EMPTY = {
5993
+ items: [],
5994
+ total: 0,
5995
+ yoursCount: 0,
5996
+ behindCount: 0
5997
+ };
5998
+ /** 某个叶子的 `disabled` 是不是"我们自己的冲突行开关"——那属于 conflicts 的话题。 */
5999
+ function isConflictSwitch(rowPath, leafPath, conflictIds) {
6000
+ return leafPath === "disabled" && !rowPath.includes("/") && conflictIds.includes(rowPath);
6435
6001
  }
6436
6002
  /**
6437
- * 比较两份组合文件,列出差异(只读,不改任何字节)。
6438
- * @param userText - 用户这份的 `agent.cordis.yml` 原文。
6439
- * @param templateText - 随包模板的同一文件原文。
6003
+ * 比较两份 `config.plugins`,列出差异(只读,不改任何值)。
6004
+ * @param yours - 你这一侧的 plugins(形状未知)。
6005
+ * @param theirs - 对方的 plugins(形状未知)。
6440
6006
  * @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
- };
6007
+ * @returns 差异清单;两侧任一形状不可识别时为空清单。
6008
+ */
6009
+ function presetDelta(yours, theirs, options = {}) {
6010
+ if (!isRowList(yours) || !isRowList(theirs)) return EMPTY;
6452
6011
  const conflictIds = options.conflictIds ?? PRESET_CONFLICT_IDS;
6453
6012
  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));
6013
+ const mine = flattenRows(yours);
6014
+ const other = flattenRows(theirs);
6456
6015
  const items = [];
6457
- for (const row of yours) {
6458
- const counterpart = theirRows.get(row.id);
6016
+ const leavesOf = (row) => new Map(rowLeaves(row).map((leaf) => [leaf.path, leaf.value]));
6017
+ for (const [path, row] of mine) {
6018
+ const counterpart = other.get(path);
6459
6019
  if (counterpart === void 0) {
6460
6020
  items.push({
6461
6021
  kind: "row-only-yours",
6462
- row: row.id,
6022
+ row: path,
6463
6023
  path: ""
6464
6024
  });
6465
6025
  continue;
6466
6026
  }
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)) {
6027
+ const a = leavesOf(row);
6028
+ const b = leavesOf(counterpart);
6029
+ for (const [leaf, value] of a) {
6030
+ if (isConflictSwitch(path, leaf, conflictIds)) continue;
6031
+ if (!b.has(leaf)) {
6472
6032
  items.push({
6473
6033
  kind: "only-yours",
6474
- row: row.id,
6475
- path,
6034
+ row: path,
6035
+ path: leaf,
6476
6036
  yours: value
6477
6037
  });
6478
6038
  continue;
6479
6039
  }
6480
- const theirValue = other.get(path) ?? "";
6481
- if (theirValue !== value) items.push({
6040
+ const theirsValue = b.get(leaf) ?? "";
6041
+ if (theirsValue !== value) items.push({
6482
6042
  kind: "changed",
6483
- row: row.id,
6484
- path,
6043
+ row: path,
6044
+ path: leaf,
6485
6045
  yours: value,
6486
- template: theirValue
6046
+ template: theirsValue
6487
6047
  });
6488
6048
  }
6489
- for (const [path, value] of other) {
6490
- if (isConflictSwitch(row.id, path, conflictIds)) continue;
6491
- if (!mine.has(path)) items.push({
6049
+ for (const [leaf, value] of b) {
6050
+ if (isConflictSwitch(path, leaf, conflictIds)) continue;
6051
+ if (!a.has(leaf)) items.push({
6492
6052
  kind: "only-template",
6493
- row: row.id,
6494
- path,
6053
+ row: path,
6054
+ path: leaf,
6495
6055
  template: value
6496
6056
  });
6497
6057
  }
6498
6058
  }
6499
- for (const row of theirs) if (!myRowIds.has(row.id)) items.push({
6059
+ for (const path of other.keys()) if (!mine.has(path)) items.push({
6500
6060
  kind: "row-only-template",
6501
- row: row.id,
6061
+ row: path,
6502
6062
  path: ""
6503
6063
  });
6504
6064
  const yoursCount = items.filter((item) => isYoursDelta(item.kind)).length;
@@ -6510,99 +6070,19 @@ function presetDelta(userText, templateText, options = {}) {
6510
6070
  };
6511
6071
  }
6512
6072
  //#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
6073
  //#region src/presets/compare.ts
6590
6074
  /**
6591
- * 预设比较:把"某份预设"和"我们随包的某个模板"放在一起看。
6075
+ * 预设比较:把"某份 preset 生效的 plugins"和"某个随包声明的 plugins"放在一起看。
6592
6076
  *
6593
- * 面板现在是两块信息、两个状态点:
6594
- * ①**工具行**:这份预设里我们那 4 个冲突行都禁用了吗(=`conflicts`);
6595
- * ②**完整比较**:内容上和我们选的模板差多少(=`items`,弹窗里逐条展示)。
6596
- * 这是只读操作,绝不写盘。比较一律**不跳过**冲突行的 `disabled` —— 第二个点
6597
- * 要的是"完整的比较",工具开关的不同也必须出现在差异清单里,否则会出现
6598
- * "说内容一致、却说工具行没配好"这种自相矛盾的读数。
6077
+ * 面板是两块信息、两个状态点:
6078
+ * ①**工具行**:这份 preset 里那 4 个冲突行都禁用了吗(=`conflicts`);
6079
+ * ②**完整比较**:内容上和选中的模板差多少(=`items`,弹窗里逐条展示)。
6080
+ * 只读,绝不写盘。比较一律**不跳过**冲突行的 `disabled` —— 第二个点要的是
6081
+ * "完整的比较",工具开关的不同也必须出现在差异清单里,否则会出现"说内容一致、
6082
+ * 却说工具行没配好"这种自相矛盾的读数。
6599
6083
  * @module @xiaoso/dsh-tool-plus/presets/compare
6600
6084
  */
6601
- /** 模板根目录:`<packageRoot>/presets`。 */
6602
- function templatesRoot(deps) {
6603
- return path$1.join(packageRootDir(deps), "presets");
6604
- }
6605
- /** 读不到/不可识别时的统一结果(面板渲染红点,不抛)。 */
6085
+ /** 任一侧不可比较时的统一结果(面板渲染红点,不抛)。 */
6606
6086
  const UNREADABLE_COMPARISON = {
6607
6087
  status: "unreadable",
6608
6088
  conflicts: [],
@@ -6611,56 +6091,17 @@ const UNREADABLE_COMPARISON = {
6611
6091
  behindCount: 0,
6612
6092
  items: []
6613
6093
  };
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
6094
  /**
6655
- * 比较两份组合文件文本(纯函数,只读)。
6656
- * @param presetText - 所选预设的原文。
6657
- * @param templateText - 所选模板的原文。
6095
+ * 比较两份 `config.plugins`(纯函数,只读)。
6096
+ * @param presetPlugins - 所选 preset 生效的 plugins。
6097
+ * @param templatePlugins - 所选模板的 plugins(随包声明)。
6658
6098
  * @returns 两个状态点与弹窗需要的全部数据。
6659
6099
  */
6660
- function compareCompositions(presetText, templateText) {
6661
- const analysis = analyzePresetComposition(presetText);
6100
+ function comparePlugins(presetPlugins, templatePlugins) {
6101
+ if (!isRowList(presetPlugins) || !isRowList(templatePlugins)) return UNREADABLE_COMPARISON;
6102
+ const analysis = analyzePlugins(presetPlugins);
6662
6103
  if (analysis.shape === "unrecognized") return UNREADABLE_COMPARISON;
6663
- const delta = presetDelta(presetText, templateText, {
6104
+ const delta = presetDelta(presetPlugins, templatePlugins, {
6664
6105
  conflictIds: [],
6665
6106
  limit: Number.MAX_SAFE_INTEGER
6666
6107
  });
@@ -6673,19 +6114,420 @@ function compareCompositions(presetText, templateText) {
6673
6114
  items: delta.items
6674
6115
  };
6675
6116
  }
6117
+ //#endregion
6118
+ //#region src/presets/plan.ts
6119
+ /** 生效的 plugins(覆盖优先)。 */
6120
+ function effectivePlugins$1(input) {
6121
+ const plugins = input.effective?.plugins;
6122
+ return isRowList(plugins) ? plugins : void 0;
6123
+ }
6124
+ /**
6125
+ * 判定一个动作该不该写、会改什么。
6126
+ * @param input - 判定所需的事实。
6127
+ * @returns 计划;`noop` 时调用方**不要**调用 `configEditor.edit`。
6128
+ */
6129
+ function planPresetAction(input) {
6130
+ if (input.action === "revert") {
6131
+ if (!input.customized) return {
6132
+ kind: "noop",
6133
+ reason: "this preset carries no profile override to remove"
6134
+ };
6135
+ return { kind: "revert" };
6136
+ }
6137
+ if (input.action === "align") {
6138
+ if (!input.ours) return {
6139
+ kind: "noop",
6140
+ reason: "only presets this plugin declares can be aligned"
6141
+ };
6142
+ if (input.templateId === void 0) return {
6143
+ kind: "noop",
6144
+ reason: "align needs a template id"
6145
+ };
6146
+ if (!isRowList(input.templatePlugins)) return {
6147
+ kind: "noop",
6148
+ reason: `template ${input.templateId} has no readable plugin list`
6149
+ };
6150
+ if (sameValue(input.templatePlugins, input.inherited?.plugins)) {
6151
+ if (!input.customized) return {
6152
+ kind: "noop",
6153
+ reason: "this preset already matches the bundled declaration"
6154
+ };
6155
+ return { kind: "revert" };
6156
+ }
6157
+ if (sameValue(input.templatePlugins, input.effective?.plugins)) return {
6158
+ kind: "noop",
6159
+ reason: `this preset already matches template ${input.templateId}`
6160
+ };
6161
+ return { kind: "align" };
6162
+ }
6163
+ const plugins = effectivePlugins$1(input);
6164
+ if (plugins === void 0) return {
6165
+ kind: "noop",
6166
+ reason: "the plugin list is not a readable row list, nothing was written"
6167
+ };
6168
+ if (analyzePlugins(plugins).conflicts.length === 0) return {
6169
+ kind: "noop",
6170
+ reason: "no official tool row needs disabling"
6171
+ };
6172
+ const { changes } = disableConflicts(plugins);
6173
+ if (changes.length === 0) return {
6174
+ kind: "noop",
6175
+ reason: "no official tool row needs disabling"
6176
+ };
6177
+ return {
6178
+ kind: "upgrade",
6179
+ changes
6180
+ };
6181
+ }
6182
+ //#endregion
6183
+ //#region src/presets/status.ts
6184
+ /** 生效的 plugins:宿主的生效 config 优先,回落到覆盖层、再回落到随包声明。 */
6185
+ function effectivePlugins(facts) {
6186
+ if (facts.effective?.plugins !== void 0) return facts.effective.plugins;
6187
+ const overridePlugins = facts.override?.plugins;
6188
+ if (overridePlugins !== void 0) return overridePlugins;
6189
+ return facts.inherited?.plugins;
6190
+ }
6191
+ /** 覆盖里是不是真带了内容(`{}` 表示没有覆盖行)。 */
6192
+ function isCustomized(facts) {
6193
+ return facts.override !== void 0 && Object.keys(facts.override).length > 0;
6194
+ }
6195
+ /**
6196
+ * 把一个 preset 折成面板状态。
6197
+ * @param facts - 宿主收集的原始事实。
6198
+ * @param templateIds - 随包声明的模板清单(用于"与随包声明是否一致")。
6199
+ * @returns 面板状态行。
6200
+ */
6201
+ function toPresetStatus(facts, templateIds) {
6202
+ const plugins = effectivePlugins(facts);
6203
+ const analysis = analyzePlugins(plugins);
6204
+ const customized = isCustomized(facts);
6205
+ const rows = isRowList(plugins) ? flattenRows(plugins).size : 0;
6206
+ 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;
6207
+ const templateDelta = templateDiffers ? presetDelta(plugins, facts.inherited?.plugins) : void 0;
6208
+ const delta = templateDelta !== void 0 && templateDelta.total > 0 ? templateDelta : void 0;
6209
+ return {
6210
+ id: facts.id,
6211
+ ...facts.entryId === void 0 ? {} : { entryId: facts.entryId },
6212
+ ...facts.name === void 0 ? {} : { name: facts.name },
6213
+ ...facts.description === void 0 ? {} : { description: facts.description },
6214
+ source: facts.ours ? "ours" : "other",
6215
+ isDefault: facts.isDefault,
6216
+ conflicts: analysis.conflicts,
6217
+ clean: analysis.clean,
6218
+ unrecognized: analysis.shape === "unrecognized",
6219
+ ...facts.broken === void 0 ? {} : { broken: facts.broken },
6220
+ customized,
6221
+ templateDiffers,
6222
+ ...delta === void 0 ? {} : { templateDelta: delta },
6223
+ rowCount: rows
6224
+ };
6225
+ }
6226
+ /**
6227
+ * 列出每个事实的状态,并给出模板清单。
6228
+ * @param facts - 宿主收集的原始事实(顺序即面板顺序)。
6229
+ * @param writable - 本部署有没有可编辑的 profile(`configEditor` 是否在场)。
6230
+ * @returns `presets/status` 的完整返回值。
6231
+ */
6232
+ function listPresetStatuses$1(facts, writable) {
6233
+ const templateIds = facts.filter((item) => item.ours).map((item) => item.id);
6234
+ const templates = facts.filter((item) => item.ours).map((item) => item.name === void 0 ? { id: item.id } : {
6235
+ id: item.id,
6236
+ name: item.name
6237
+ });
6238
+ return {
6239
+ presets: facts.map((item) => toPresetStatus(item, templateIds)),
6240
+ templates,
6241
+ writable
6242
+ };
6243
+ }
6676
6244
  /**
6677
- * 读盘版比较:预设一侧用 roster 给的组合文件路径,模板一侧按 id 去包里找。
6678
- * 任一侧读不到都返回 `unreadable` —— 那是面板要渲染的红点,不是调用失败。
6679
- * @param presetPath - 所选预设的组合文件绝对路径(来自 roster)。
6680
- * @param templateId - 所选模板的 id。
6681
- * @param deps - 路径注入(测试用)。
6682
- * @returns 比较结果。
6245
+ * 取某个模板声明的 plugins(面板"对比 / 对齐"的右侧)。
6246
+ * @param facts - 同一个事实集合。
6247
+ * @param templateId - 模板 id(本插件声明的某个 preset)。
6248
+ * @returns 该模板的 plugins;不是我们的模板时 undefined。
6683
6249
  */
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);
6250
+ function templatePlugins(facts, templateId) {
6251
+ return facts.find((item) => item.ours && item.id === templateId)?.inherited?.plugins;
6252
+ }
6253
+ //#endregion
6254
+ //#region src/host/preset-host.ts
6255
+ /**
6256
+ * Host half of the preset surface: read the deployment's declared presets and
6257
+ * apply the panel's three actions through the harness's own config editor.
6258
+ *
6259
+ * WHY WE DO NOT WRITE FILES OURSELVES: dsh 0.1.7 removed the directory preset
6260
+ * mechanism and the `agent_preset` tool with it. The only supported programmatic
6261
+ * write path is `ctx.configEditor.edit()`, and it is a better one than we had —
6262
+ * it takes the profile lock, serializes with HMR, validates the next config
6263
+ * through the Loader's own waterfall, writes atomically, rolls back on a failed
6264
+ * reconcile, and refuses when a home patch or `--patch` overlay would defeat the
6265
+ * edit. It also gives us "revert to the bundled declaration" for free: handing
6266
+ * back the inherited config makes it delete the override's `config` key.
6267
+ *
6268
+ * The service is resolved per call, so a deployment that mounts the editor
6269
+ * after this plugin still works instead of caching an early `undefined`.
6270
+ *
6271
+ * Everything here is a thin adapter; the decisions live in `src/presets/**`
6272
+ * (pure, fs-free, unit-testable) and this module only resolves services and
6273
+ * shapes results.
6274
+ * @module @xiaoso/dsh-tool-plus/host/preset-host
6275
+ */
6276
+ /** Narrow a non-empty string, else undefined. */
6277
+ function text(value) {
6278
+ return typeof value === "string" && value.length > 0 ? value : void 0;
6279
+ }
6280
+ /** `{ key: value }` only when the value is present, so optional fields stay absent. */
6281
+ function optional(key, value) {
6282
+ return value === void 0 ? {} : { [key]: value };
6283
+ }
6284
+ /** Read a service only when it actually looks like the one we need. */
6285
+ function configEditorOf(ctx) {
6286
+ const service = ctx.get("configEditor");
6287
+ return service !== void 0 && typeof service.edit === "function" && typeof service.configuration === "function" ? service : void 0;
6288
+ }
6289
+ /** Read the preset registry roster only when it exposes one of its two readers. */
6290
+ async function rosterOf(ctx) {
6291
+ const service = ctx.get("agentPresets");
6292
+ if (service === void 0) return void 0;
6293
+ try {
6294
+ if (typeof service.remoteExportList === "function") {
6295
+ const exported = await service.remoteExportList();
6296
+ if (Array.isArray(exported?.presets)) return exported.presets;
6297
+ }
6298
+ if (typeof service.list === "function") return await service.list();
6299
+ return;
6300
+ } catch {
6301
+ return;
6302
+ }
6303
+ }
6304
+ /**
6305
+ * Collect every declared preset this deployment can see.
6306
+ *
6307
+ * Ordering is OURS FIRST (the panel's primary subject), then the rest in the
6308
+ * registry's own order — which the registry sorts by each declaration's `order`.
6309
+ * @param ctx - context used to resolve the services.
6310
+ * @returns the facts, the editor (when present) and the row→entry map.
6311
+ */
6312
+ async function collect(ctx) {
6313
+ const editor = configEditorOf(ctx);
6314
+ const roster = await rosterOf(ctx);
6315
+ const byId = /* @__PURE__ */ new Map();
6316
+ const entries = /* @__PURE__ */ new Map();
6317
+ if (editor !== void 0) for (const row of editor.configuration()) {
6318
+ const entryId = text(row.entry.options.id);
6319
+ if (entryId === void 0 || !entryId.startsWith("preset-")) continue;
6320
+ const effective = row.entry.options.config ?? {};
6321
+ const id = text(row.inherited.id) ?? text(row.override.id) ?? text(effective.id) ?? entryId.slice(7);
6322
+ entries.set(entryId, row.entry);
6323
+ byId.set(id, {
6324
+ id,
6325
+ entryId,
6326
+ ...optional("name", text(row.inherited.name) ?? text(effective.name)),
6327
+ ...optional("description", text(row.inherited.description) ?? text(effective.description)),
6328
+ isDefault: false,
6329
+ effective,
6330
+ inherited: row.inherited,
6331
+ override: row.override,
6332
+ ours: BUNDLED_PRESET_IDS.includes(id)
6333
+ });
6334
+ }
6335
+ const rosterOrder = [];
6336
+ for (const row of roster ?? []) {
6337
+ const id = text(row.id);
6338
+ if (id === void 0) continue;
6339
+ rosterOrder.push(id);
6340
+ const existing = byId.get(id);
6341
+ byId.set(id, {
6342
+ id,
6343
+ ...existing?.entryId === void 0 ? {} : { entryId: existing.entryId },
6344
+ ...optional("name", text(row.name) ?? existing?.name),
6345
+ ...optional("description", text(row.description) ?? existing?.description),
6346
+ isDefault: row.isDefault === true,
6347
+ ...optional("broken", text(row.broken)),
6348
+ ...existing?.effective === void 0 ? {} : { effective: existing.effective },
6349
+ ...existing?.inherited === void 0 ? {} : { inherited: existing.inherited },
6350
+ ...existing?.override === void 0 ? {} : { override: existing.override },
6351
+ ours: BUNDLED_PRESET_IDS.includes(id)
6352
+ });
6353
+ }
6354
+ const ours = BUNDLED_PRESET_IDS.map((id) => byId.get(id) ?? {
6355
+ id,
6356
+ isDefault: false,
6357
+ ours: true
6358
+ });
6359
+ const others = rosterOrder.filter((id) => !BUNDLED_PRESET_IDS.includes(id)).map((id) => byId.get(id)).filter((fact) => fact !== void 0);
6360
+ return {
6361
+ facts: [...ours, ...others],
6362
+ editor,
6363
+ entries
6364
+ };
6365
+ }
6366
+ /**
6367
+ * The pre-0.1.7 user preset directory, reported ONLY as a cleanup hint.
6368
+ *
6369
+ * dsh reads no directory any more ("Definitions are ordinary plugin rows; the
6370
+ * registry neither scans directories nor accepts preset paths"), so a leftover
6371
+ * `$DSH_HOME/.agent-presets` is dead weight. We surface its path so the panel can
6372
+ * tell the user it is safe to delete; we never read or write presets through it.
6373
+ * @returns the absolute path when the directory exists and holds entries.
6374
+ */
6375
+ function legacyPresetRoot() {
6376
+ const home = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
6377
+ const dir = join(home, ".agent-presets");
6378
+ try {
6379
+ return readdirSync(dir).length > 0 ? dir : void 0;
6380
+ } catch {
6381
+ return;
6382
+ }
6383
+ }
6384
+ /**
6385
+ * This plugin's own version, for the backup file name.
6386
+ *
6387
+ * Probes two candidates because the module URL differs between the built single
6388
+ * file (`lib/index.mjs` → `../package.json` is the package root) and the source
6389
+ * tree under test (`src/host/…` → `../../package.json`). Falls back to
6390
+ * `'unknown'` rather than failing an action over a cosmetic label.
6391
+ * @returns the version string, or `'unknown'`.
6392
+ */
6393
+ function pluginVersion() {
6394
+ for (const candidate of ["../package.json", "../../package.json"]) try {
6395
+ const raw = JSON.parse(readFileSync(fileURLToPath(new URL(candidate, import.meta.url)), "utf8"));
6396
+ if (typeof raw.version === "string" && raw.version.length > 0) return raw.version;
6397
+ } catch {}
6398
+ return "unknown";
6399
+ }
6400
+ /**
6401
+ * Copy the profile patch aside before we change it.
6402
+ *
6403
+ * The config editor already gives us atomicity and rollback, but NOT a durable
6404
+ * "what it looked like before" artifact — and `align` overwrites content the
6405
+ * user may have written there. This restores the pre-0.1.10 affordance exactly:
6406
+ * one `<patch>.bak-<plugin version>` beside the patch, written once and never
6407
+ * overwritten, so the earliest pre-change state survives repeated actions.
6408
+ * @param documentPath - the profile patch the editor is about to rewrite.
6409
+ * @returns the backup path, or undefined when there was nothing to copy.
6410
+ */
6411
+ function backupProfilePatch(documentPath) {
6412
+ if (!existsSync(documentPath)) return void 0;
6413
+ const target = `${documentPath}.bak-${pluginVersion()}`;
6414
+ if (existsSync(target)) return target;
6415
+ copyFileSync(documentPath, target);
6416
+ return target;
6417
+ }
6418
+ /** One failure in the gateway's shape. */
6419
+ function failure(reason) {
6420
+ return {
6421
+ ok: false,
6422
+ changed: false,
6423
+ reason,
6424
+ changes: []
6425
+ };
6426
+ }
6427
+ /**
6428
+ * Answer `presets/status`.
6429
+ * @param ctx - context used to resolve the config editor and the registry.
6430
+ * @returns the panel's full status payload.
6431
+ */
6432
+ async function listPresetStatuses(ctx) {
6433
+ const { facts, editor } = await collect(ctx);
6434
+ const list = listPresetStatuses$1(facts, editor !== void 0);
6435
+ const legacy = legacyPresetRoot();
6436
+ return legacy === void 0 ? list : {
6437
+ ...list,
6438
+ legacyPresetRoot: legacy
6439
+ };
6440
+ }
6441
+ /**
6442
+ * Answer `presets/apply` by planning the action and, when it is worth writing,
6443
+ * delegating the write to the harness's own config editor.
6444
+ * @param ctx - context used to resolve the services.
6445
+ * @param presetId - the preset to act on.
6446
+ * @param action - `upgrade`, `align` or `revert`.
6447
+ * @param templateId - the align target (required for `align`).
6448
+ * @returns the action result, never throwing.
6449
+ */
6450
+ async function applyPresetAction(ctx, presetId, action, templateId) {
6451
+ try {
6452
+ const { facts, editor, entries } = await collect(ctx);
6453
+ const fact = facts.find((item) => item.id === presetId);
6454
+ if (fact === void 0) return failure(`unknown preset: ${presetId}`);
6455
+ const wanted = templateId === void 0 ? void 0 : templatePlugins(facts, templateId);
6456
+ const plan = planPresetAction({
6457
+ action,
6458
+ ours: fact.ours,
6459
+ customized: fact.override !== void 0 && Object.keys(fact.override).length > 0,
6460
+ effective: fact.effective,
6461
+ inherited: fact.inherited,
6462
+ ...wanted === void 0 ? {} : { templatePlugins: wanted },
6463
+ ...templateId === void 0 ? {} : { templateId }
6464
+ });
6465
+ if (plan.kind === "noop") return {
6466
+ ok: true,
6467
+ changed: false,
6468
+ reason: plan.reason,
6469
+ changes: []
6470
+ };
6471
+ if (editor === void 0) return failure("this deployment has no editable profile: ctx.configEditor is unavailable");
6472
+ const entry = fact.entryId === void 0 ? void 0 : entries.get(fact.entryId);
6473
+ 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`);
6474
+ const backupPath = backupProfilePatch(editor.documentPath);
6475
+ if (plan.kind === "revert") await editor.edit(entry, (_current, inherited) => inherited);
6476
+ else if (plan.kind === "align") await editor.edit(entry, (current) => ({
6477
+ ...current,
6478
+ plugins: wanted
6479
+ }));
6480
+ else await editor.edit(entry, (current) => {
6481
+ if (!isRowList(current.plugins)) return current;
6482
+ return {
6483
+ ...current,
6484
+ plugins: disableConflicts(current.plugins).plugins
6485
+ };
6486
+ });
6487
+ return {
6488
+ ok: true,
6489
+ changed: true,
6490
+ ...backupPath === void 0 ? {} : { backupPath },
6491
+ changes: plan.kind === "upgrade" ? plan.changes : []
6492
+ };
6493
+ } catch (error) {
6494
+ return failure(error instanceof Error ? error.message : String(error));
6495
+ }
6496
+ }
6497
+ /**
6498
+ * Answer `presets/compare`: one preset's effective plugins against one of our
6499
+ * declared templates. Never throws; an unresolvable side reports `unreadable`
6500
+ * (that is a state the panel renders as a red dot, not a failed call).
6501
+ * @param ctx - context used to resolve the services.
6502
+ * @param presetId - the preset being inspected.
6503
+ * @param templateId - the template to compare it against.
6504
+ * @returns the comparison.
6505
+ */
6506
+ async function comparePresetAgainstTemplate(ctx, presetId, templateId) {
6507
+ const empty = {
6508
+ presetId,
6509
+ templateId,
6510
+ status: "unreadable",
6511
+ conflicts: [],
6512
+ identical: false,
6513
+ yoursCount: 0,
6514
+ behindCount: 0,
6515
+ items: []
6516
+ };
6517
+ try {
6518
+ const { facts } = await collect(ctx);
6519
+ const fact = facts.find((item) => item.id === presetId);
6520
+ if (fact === void 0) return empty;
6521
+ const template = templatePlugins(facts, templateId);
6522
+ if (template === void 0) return empty;
6523
+ return {
6524
+ presetId,
6525
+ templateId,
6526
+ ...comparePlugins(fact.effective?.plugins, template)
6527
+ };
6528
+ } catch {
6529
+ return empty;
6530
+ }
6689
6531
  }
6690
6532
  //#endregion
6691
6533
  //#region src/web/host/bash.ts
@@ -11558,7 +11400,7 @@ let specialHandlersPromise;
11558
11400
  * requires a special handler, so we keep them out of the cold-startup graph.
11559
11401
  */
11560
11402
  function loadSpecialHandlers() {
11561
- specialHandlersPromise ??= import("./scrapers-mhvFrvjH.mjs").then((m) => m.specialHandlers);
11403
+ specialHandlersPromise ??= import("./scrapers-DSArdJ6N.mjs").then((m) => m.specialHandlers);
11562
11404
  return specialHandlersPromise;
11563
11405
  }
11564
11406
  /**
@@ -23738,19 +23580,11 @@ function apply(ctx, config = {}) {
23738
23580
  const disposeBrowserProbe = installBrowserProbeRpc(ctx, {
23739
23581
  getRmSafe: () => cfg.rmSafe,
23740
23582
  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)
23583
+ listPresets: (presetCtx) => listPresetStatuses(presetCtx),
23584
+ applyAction: (presetCtx, id, action, templateId) => applyPresetAction(presetCtx, id, action, templateId),
23585
+ comparePreset: (presetCtx, presetId, templateId) => comparePresetAgainstTemplate(presetCtx, presetId, templateId)
23745
23586
  }
23746
23587
  });
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
23588
  ctx.systemPrompt.section({
23755
23589
  name: "tool:bash",
23756
23590
  order: 105,
@@ -23928,72 +23762,47 @@ function apply(ctx, config = {}) {
23928
23762
  ...commandEnv
23929
23763
  };
23930
23764
  const jobs = ctx.get("jobs");
23765
+ const owner = exec.agent?.id;
23931
23766
  const backgroundSlotsAvailable = () => {
23932
23767
  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
- };
23768
+ return jobs.list(owner).filter((j) => j.status === "running" || j.status === "stopping").length < cfg.maxBackgroundJobs;
23963
23769
  };
23770
+ const startManagedJob = (job) => startBashJob({
23771
+ sessionId,
23772
+ command,
23773
+ cwd: commandCwd,
23774
+ timeoutMs: args.timeoutMs ?? 0,
23775
+ env,
23776
+ config: cfg,
23777
+ ...job !== void 0 ? { job } : {},
23778
+ formatCompletion: (text, spillPath) => applyConfiguredTruncation(text, spillPath, cfg.outputTruncate)
23779
+ });
23780
+ const jobSpec = (run) => ({
23781
+ kind: "bash",
23782
+ label: command,
23783
+ ...owner !== void 0 ? { owner } : {},
23784
+ ...Number.isSafeInteger(cfg.outputMaxBytes) && cfg.outputMaxBytes > 0 ? { outputLimitBytes: cfg.outputMaxBytes } : {},
23785
+ run
23786
+ });
23964
23787
  if (args.run_in_background === true) {
23965
23788
  if (!cfg.enableRunInBackground) throw new Error("run_in_background is disabled for this deployment (enableRunInBackground: false)");
23966
23789
  if (jobs === void 0) throw new Error("background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs");
23967
23790
  if (!backgroundSlotsAvailable()) throw new Error(`background jobs at capacity (maxBackgroundJobs: ${cfg.maxBackgroundJobs})`);
23968
23791
  if (exec.signal.aborted) throw abortError();
23969
- let managed;
23970
23792
  return {
23971
23793
  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
- })
23794
+ jobId: jobs.start(jobSpec((job) => startManagedJob(job).hooks))
23981
23795
  };
23982
23796
  }
23983
23797
  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));
23798
+ const autoBgWaitMs = Math.max(1, timeoutMs === 0 ? cfg.autoBackgroundMs : Math.min(cfg.autoBackgroundMs, timeoutMs - 1e3));
23985
23799
  let managed;
23986
23800
  let id;
23987
23801
  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
- });
23802
+ id = jobs.start(jobSpec((job) => {
23803
+ managed = startManagedJob(job);
23804
+ return managed.hooks;
23805
+ }));
23997
23806
  } catch {
23998
23807
  const startedAt = performance.now();
23999
23808
  const result = await executeBash(command, {
@@ -24025,34 +23834,23 @@ function apply(ctx, config = {}) {
24025
23834
  if (result.workingDir !== void 0) state.cwd = result.workingDir;
24026
23835
  return buildForeground(result, performance.now() - startedAt, timeoutMs);
24027
23836
  }
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 {
23837
+ let settled = false;
23838
+ try {
23839
+ const view = await jobs.wait(id, autoBgWaitMs, owner, exec.signal);
23840
+ settled = view.status !== "running" && view.status !== "stopping";
23841
+ } catch {
23842
+ jobs.kill(id, owner, "tool call aborted");
23843
+ await jobs.wait(id, Math.max(1e3, timeoutMs), owner);
23844
+ throw abortError();
23845
+ }
23846
+ if (!settled) return {
24046
23847
  kind: "background",
24047
23848
  jobId: id
24048
23849
  };
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;
23850
+ const value = await managed.completion;
23851
+ jobs.remove(id, owner);
23852
+ if (value.workingDir !== void 0) state.cwd = value.workingDir;
23853
+ return value;
24056
23854
  }
24057
23855
  const startedAt = performance.now();
24058
23856
  const result = await executeBash(command, {