@xiaoso/dsh-tool-plus 0.1.7 → 0.1.8-beta.3

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 (96) hide show
  1. package/lib/{abort-Caqff3M6.mjs → abort-BtBy5Svf.mjs} +1 -1
  2. package/lib/{alibaba-coding-plan-DTupMiAi.mjs → alibaba-coding-plan-oxHCHJtG.mjs} +2 -2
  3. package/lib/{alibaba-token-plan-Dj3OZhEE.mjs → alibaba-token-plan-B0WzvYnB.mjs} +2 -2
  4. package/lib/{amazon-bedrock-DbFSYXfJ.mjs → amazon-bedrock-BvfYGhrD.mjs} +6 -6
  5. package/lib/{anthropic-CXfWlp-x.mjs → anthropic-C6-vdduI.mjs} +1 -1
  6. package/lib/{anthropic-CTJ3IQVa.mjs → anthropic-Id561lwb.mjs} +13 -13
  7. package/lib/{anthropic-wire-CKuhDwZ1.mjs → anthropic-wire-Cb8AQ1tS.mjs} +13 -13
  8. package/lib/{api-key-validation-CGCJFbFr.mjs → api-key-validation-BXJKlsJg.mjs} +1 -1
  9. package/lib/{azure-openai-responses-ChFbWaGj.mjs → azure-openai-responses-CGws_ijz.mjs} +8 -8
  10. package/lib/{browser-render-9o0Rknop.mjs → browser-render-i7uYOl4f.mjs} +2 -2
  11. package/lib/client.js +2205 -19
  12. package/lib/{cloudflare-ai-gateway-BupNXyKK.mjs → cloudflare-ai-gateway-BK6eadm9.mjs} +2 -2
  13. package/lib/{cloudflare-ai-gateway-EWo0VkP5.mjs → cloudflare-ai-gateway-B_kWnF30.mjs} +1 -1
  14. package/lib/{collapse-DOR8sOGf.mjs → collapse-DU3g7d3B.mjs} +1 -1
  15. package/lib/{coreweave-Ct8cF-da.mjs → coreweave-CkR_FlQi.mjs} +2 -2
  16. package/lib/{cursor-LKGjucn2.mjs → cursor-BXx_5h4a.mjs} +2 -2
  17. package/lib/{cursor-DJV_dcgB.mjs → cursor-BcHwv8qq.mjs} +10 -10
  18. package/lib/{cursor-B95LzVWV.mjs → cursor-BjWbJJLe.mjs} +1 -1
  19. package/lib/{cursor-proto-B8Wj8bv9.mjs → cursor-proto-DPXXYx8S.mjs} +1 -1
  20. package/lib/{devin-DSp0Rw_E.mjs → devin-BZBfzyIz.mjs} +6 -6
  21. package/lib/{devin-BMTdx_hx.mjs → devin-Ce3Wz8_E.mjs} +5 -5
  22. package/lib/{devin-proto-Br8fdyNl.mjs → devin-proto-B6HwDwHK.mjs} +1 -1
  23. package/lib/{error-CORgGg5a.mjs → error-DdCn4m1q.mjs} +3 -3
  24. package/lib/{event-stream-ulcDenZD.mjs → event-stream-ldcUvOA0.mjs} +1 -1
  25. package/lib/{github-copilot-Dstq1D3s.mjs → github-copilot-BT5dkIJl.mjs} +2 -2
  26. package/lib/{github-copilot-headers-DOh1gt_w.mjs → github-copilot-headers-BGL8JO5c.mjs} +2 -2
  27. package/lib/{gitlab-duo-DSCMrU0m.mjs → gitlab-duo-B0qSYCa3.mjs} +1 -1
  28. package/lib/{google-CtsKkOQK.mjs → google-6cRgRmh0.mjs} +3 -3
  29. package/lib/{google-antigravity-z-5KsEci.mjs → google-antigravity-D1llOVad.mjs} +3 -3
  30. package/lib/{google-auth-ieLBaneO.mjs → google-auth-DYhqX1rJ.mjs} +5 -5
  31. package/lib/{google-gemini-cli-CAIPSl7e.mjs → google-gemini-cli-ByQUuKp9.mjs} +7 -7
  32. package/lib/{google-gemini-cli-C6NE5mWz.mjs → google-gemini-cli-CjHy6mFG.mjs} +3 -3
  33. package/lib/{google-oauth-shared-DhMllAkt.mjs → google-oauth-shared-CSwCT0Yl.mjs} +1 -1
  34. package/lib/{google-shared-B0aNCYEH.mjs → google-shared-DYjJp0mQ.mjs} +5 -5
  35. package/lib/{google-vertex-Bbd1tYNc.mjs → google-vertex-CbOvlE5j.mjs} +4 -4
  36. package/lib/{idle-iterator-DlUkEfzW.mjs → idle-iterator-DTBaEIbM.mjs} +2 -2
  37. package/lib/index.mjs +1278 -184
  38. package/lib/{kilo-ChJL54wq.mjs → kilo-cVG1aNZM.mjs} +1 -1
  39. package/lib/{kimi-C2c_Zq8A.mjs → kimi-BFfnFDhu.mjs} +4 -4
  40. package/lib/{launch-DKDH71En.mjs → launch-Uq7920ng.mjs} +7 -7
  41. package/lib/{markit-CmRfm4E2.mjs → markit-BmsfOFVh.mjs} +18 -18
  42. package/lib/{muse-code-Di1VZmB1.mjs → muse-code-CtcevJab.mjs} +1 -1
  43. package/lib/{ollama-B8ic3sEp.mjs → ollama-B5hR1P1p.mjs} +8 -8
  44. package/lib/{openai-codex-DuJNC99x.mjs → openai-codex-CxkPyvyt.mjs} +2 -2
  45. package/lib/{openai-codex-responses-BL29nh9K.mjs → openai-codex-responses-BbM3Q7nJ.mjs} +9 -9
  46. package/lib/{openai-completions-BK2f2dBv.mjs → openai-completions-CffXVOCL.mjs} +13 -13
  47. package/lib/{openai-reasoning-fallback-BdB3RCbj.mjs → openai-reasoning-fallback-DFdD1ok5.mjs} +2 -2
  48. package/lib/{openai-responses-CGQPlIIy.mjs → openai-responses-DgqOvSn_.mjs} +9 -9
  49. package/lib/{perplexity-TBjz3VMB.mjs → perplexity-JFp3knZN.mjs} +2 -2
  50. package/lib/{protobuf-aab1UGVn.mjs → protobuf-BJHNU9-E.mjs} +1 -1
  51. package/lib/{proxy-5nWqRL1f.mjs → proxy-B51IWEMt.mjs} +2 -2
  52. package/lib/{scrapers-CtQrQ5kG.mjs → scrapers-DU4u4-Co.mjs} +7 -7
  53. package/lib/{src-BwW51vA5.mjs → src-DUUqyo9y.mjs} +42 -42
  54. package/lib/{stream-B_7qN-bA.mjs → stream-DHdFrAG4.mjs} +54 -54
  55. package/lib/{stream-markup-healing-DKT1lnkW.mjs → stream-markup-healing-DlJ9abTw.mjs} +2 -2
  56. package/lib/{thinking-CiLa2LFB.mjs → thinking-D6LJxkz_.mjs} +1 -1
  57. package/lib/types/client/locales.d.ts +1 -1
  58. package/lib/types/config/settings.d.ts +8 -0
  59. package/lib/types/tools/ast-grep/adapter/index.d.ts +23 -0
  60. package/lib/types/tools/edit/adapter/index.d.ts +3 -0
  61. package/lib/types/tools/glob/adapter/index.d.ts +2 -0
  62. package/lib/types/tools/grep/adapter/index.d.ts +4 -0
  63. package/lib/types/tools/read/adapter/index.d.ts +18 -0
  64. package/lib/types/tools/write/adapter/index.d.ts +2 -0
  65. package/lib/types/web/client/ToolCardShell.d.ts +111 -0
  66. package/lib/types/web/client/labels.d.ts +36 -0
  67. package/lib/types/web/client/registerToolCards.d.ts +35 -0
  68. package/lib/types/web/client/row-utils.d.ts +177 -0
  69. package/lib/types/web/client/rows/AstEditRow.d.ts +22 -0
  70. package/lib/types/web/client/rows/FileMutationRow.d.ts +23 -0
  71. package/lib/types/web/client/rows/ReadRow.d.ts +31 -0
  72. package/lib/types/web/client/rows/SearchRow.d.ts +21 -0
  73. package/lib/types/web/client/rows/TerminalRow.d.ts +24 -0
  74. package/lib/types/web/client/rows/WriteRow.d.ts +22 -0
  75. package/lib/types/web/client/rows/command-card-facts.d.ts +45 -0
  76. package/lib/types/web/client/rows/image-card.d.ts +60 -0
  77. package/lib/types/web/client/rows/read-card-facts.d.ts +33 -0
  78. package/lib/types/web/client/rows/search-card-facts.d.ts +37 -0
  79. package/lib/types/web/contract.d.ts +249 -0
  80. package/lib/types/web/host/ast-edit.d.ts +10 -0
  81. package/lib/types/web/host/bash.d.ts +30 -0
  82. package/lib/types/web/host/edit.d.ts +33 -0
  83. package/lib/types/web/host/read.d.ts +48 -0
  84. package/lib/types/web/host/search.d.ts +41 -0
  85. package/lib/types/web/host/write.d.ts +13 -0
  86. package/lib/{types-PVueGi9S.mjs → types-DT9vgZ3S.mjs} +1 -1
  87. package/lib/{utils-CUPhpw9A.mjs → utils-BMmwLB0f.mjs} +2 -2
  88. package/lib/{utils-ECAwvaPQ.mjs → utils-C-Lo_JC6.mjs} +41 -41
  89. package/lib/{utils-DeWqNWV8.mjs → utils-D31VFael.mjs} +1 -1
  90. package/lib/{vision-guard-_XwsL1DF.mjs → vision-guard-BlAAqGUC.mjs} +1 -1
  91. package/lib/{xai-oauth-CXhnz0gO.mjs → xai-oauth-QPyScm4X.mjs} +1 -1
  92. package/lib/{xiaomi-psoUItal.mjs → xiaomi-CLzjxHAi.mjs} +1 -1
  93. package/lib/{zai-ApJ8HBgT.mjs → zai-wDp0g7VC.mjs} +1 -1
  94. package/lib/{zip-Kd602XNe.mjs → zip-CNtIFfyV.mjs} +7 -7
  95. package/package.json +3 -1
  96. package/presets/package.json +1 -1
package/lib/index.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  import { installBunShim as __installBunShim } from "./bun-shim.mjs";
2
2
  __installBunShim();
3
3
  import { n as fs_promises_shim_exports, t as exists } from "./fs-promises-shim-Bx0fpxuk.mjs";
4
- import { i as setRuntimeLogger, n as systemChromiumCandidatesForTest, r as runtimeLogger } from "./launch-DKDH71En.mjs";
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-BwW51vA5.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-ECAwvaPQ.mjs";
4
+ import { i as setRuntimeLogger, n as systemChromiumCandidatesForTest, r as runtimeLogger } from "./launch-Uq7920ng.mjs";
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-C-Lo_JC6.mjs";
7
7
  import { t as sanitizeText } from "./sanitize-text-BCf5KmCf.mjs";
8
- import { n as isRecord$2 } from "./type-guards-CttAANEx.mjs";
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
- 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-Kd602XNe.mjs";
11
- import { a as finalizeOutput, d as looksLikeHtml, n as MAX_OUTPUT_CHARS, t as MAX_BYTES, u as loadPage } from "./types-PVueGi9S.mjs";
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
+ 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
13
  import { existsSync } from "node:fs";
14
14
  import { TOOL_ABORTED, defineTool } from "@deepseek-ai/dsh-tools";
@@ -16,7 +16,8 @@ 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
- import * as path from "node:path";
19
+ import * as path$1 from "node:path";
20
+ import path from "node:path";
20
21
  import { spawn } from "node:child_process";
21
22
  import * as os from "node:os";
22
23
  import { tmpdir } from "node:os";
@@ -482,7 +483,7 @@ function buildNonInteractiveEnv(overrides, baseEnv = process.env, platform = pro
482
483
  * shell experience.
483
484
  */
484
485
  function loadFnEnvHelper() {
485
- const candidates = [path.join(fileURLToPath(new URL(".", import.meta.url)), "shell-snapshot-fn-env.sh"), path.join(path.dirname(process.execPath), "shell-snapshot-fn-env.sh")];
486
+ const candidates = [path$1.join(fileURLToPath(new URL(".", import.meta.url)), "shell-snapshot-fn-env.sh"), path$1.join(path$1.dirname(process.execPath), "shell-snapshot-fn-env.sh")];
486
487
  for (const candidate of candidates) try {
487
488
  return fs.readFileSync(candidate, "utf-8");
488
489
  } catch {}
@@ -564,9 +565,9 @@ function sanitizeSnapshotEnv(env) {
564
565
  */
565
566
  function getShellConfigFile(shell, env) {
566
567
  const home = env.HOME || os.homedir();
567
- if (shell.includes("zsh")) return path.join(home, ".zshrc");
568
- if (shell.includes("bash")) return path.join(home, ".bashrc");
569
- return path.join(home, ".profile");
568
+ if (shell.includes("zsh")) return path$1.join(home, ".zshrc");
569
+ if (shell.includes("bash")) return path$1.join(home, ".bashrc");
570
+ return path$1.join(home, ".profile");
570
571
  }
571
572
  /**
572
573
  * Generate the snapshot creation script.
@@ -676,8 +677,8 @@ async function getOrCreateSnapshot(shell, env, timeoutMs = SNAPSHOT_TIMEOUT_MS)
676
677
  if (cached) cachedSnapshotPaths.delete(cacheKey);
677
678
  if (process.platform === "win32") {
678
679
  const uid = process.getuid?.();
679
- const snapshotDir = path.join(os.tmpdir(), uid === void 0 ? "omp-shell-snapshots" : `omp-shell-snapshots-${uid}`);
680
- const snapshotPath = path.join(snapshotDir, `snapshot-bash-${crypto.randomUUID()}.sh`);
680
+ const snapshotDir = path$1.join(os.tmpdir(), uid === void 0 ? "omp-shell-snapshots" : `omp-shell-snapshots-${uid}`);
681
+ const snapshotPath = path$1.join(snapshotDir, `snapshot-bash-${crypto.randomUUID()}.sh`);
681
682
  try {
682
683
  fs.mkdirSync(snapshotDir, {
683
684
  recursive: true,
@@ -696,9 +697,9 @@ async function getOrCreateSnapshot(shell, env, timeoutMs = SNAPSHOT_TIMEOUT_MS)
696
697
  }
697
698
  const rcFile = getShellConfigFile(shell, env);
698
699
  const uid = process.getuid?.();
699
- const snapshotDir = path.join(os.tmpdir(), uid === void 0 ? "omp-shell-snapshots" : `omp-shell-snapshots-${uid}`);
700
+ const snapshotDir = path$1.join(os.tmpdir(), uid === void 0 ? "omp-shell-snapshots" : `omp-shell-snapshots-${uid}`);
700
701
  const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh";
701
- const snapshotPath = path.join(snapshotDir, `snapshot-${shellName}-${crypto.randomUUID()}.sh`);
702
+ const snapshotPath = path$1.join(snapshotDir, `snapshot-${shellName}-${crypto.randomUUID()}.sh`);
702
703
  try {
703
704
  fs.mkdirSync(snapshotDir, {
704
705
  recursive: true,
@@ -2023,23 +2024,23 @@ var TailBuffer = class {
2023
2024
  */
2024
2025
  function resolveWindowsShell(env = process.env) {
2025
2026
  const gitRoots = [
2026
- env.ProgramFiles && path.join(env.ProgramFiles, "Git"),
2027
- env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "Git"),
2028
- env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "Git"),
2027
+ env.ProgramFiles && path$1.join(env.ProgramFiles, "Git"),
2028
+ env["ProgramFiles(x86)"] && path$1.join(env["ProgramFiles(x86)"], "Git"),
2029
+ env.LOCALAPPDATA && path$1.join(env.LOCALAPPDATA, "Programs", "Git"),
2029
2030
  env.GIT_INSTALL_ROOT,
2030
- env.SCOOP && path.join(env.SCOOP, "apps", "git", "current"),
2031
- env.USERPROFILE && path.join(env.USERPROFILE, "scoop", "apps", "git", "current")
2031
+ env.SCOOP && path$1.join(env.SCOOP, "apps", "git", "current"),
2032
+ env.USERPROFILE && path$1.join(env.USERPROFILE, "scoop", "apps", "git", "current")
2032
2033
  ];
2033
2034
  for (const root of gitRoots) {
2034
2035
  if (!root) continue;
2035
- const candidate = path.join(root, "bin", "bash.exe");
2036
+ const candidate = path$1.join(root, "bin", "bash.exe");
2036
2037
  if (fs.existsSync(candidate)) return candidate;
2037
2038
  }
2038
2039
  const bashOnPath = findOnPath("bash.exe", env);
2039
2040
  if (bashOnPath) return bashOnPath;
2040
2041
  const shOnPath = findOnPath("sh.exe", env);
2041
2042
  if (shOnPath) {
2042
- const siblingBash = path.join(path.dirname(shOnPath), "bash.exe");
2043
+ const siblingBash = path$1.join(path$1.dirname(shOnPath), "bash.exe");
2043
2044
  return fs.existsSync(siblingBash) ? siblingBash : shOnPath;
2044
2045
  }
2045
2046
  return env.ComSpec || env.COMSPEC || "C:\\Windows\\System32\\cmd.exe";
@@ -2574,7 +2575,7 @@ function buildSessionKey(shell, prefix, snapshotPath, env, agentSessionKey, mini
2574
2575
  */
2575
2576
  /** Directory holding all bash spill files. */
2576
2577
  function spillDir() {
2577
- return path.join(os.tmpdir(), "dsh-bash-spill");
2578
+ return path$1.join(os.tmpdir(), "dsh-bash-spill");
2578
2579
  }
2579
2580
  /** Age after which a spill file is considered orphaned and swept. */
2580
2581
  const STALE_MS = 864e5;
@@ -2596,7 +2597,7 @@ function allocateOriginalFile() {
2596
2597
  function allocate(kind) {
2597
2598
  try {
2598
2599
  fs.mkdirSync(spillDir(), { recursive: true });
2599
- return path.join(spillDir(), `${kind}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}.log`);
2600
+ return path$1.join(spillDir(), `${kind}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}.log`);
2600
2601
  } catch {
2601
2602
  return;
2602
2603
  }
@@ -2636,11 +2637,11 @@ function sweepStaleSpillFiles(now = Date.now()) {
2636
2637
  } catch {}
2637
2638
  };
2638
2639
  try {
2639
- for (const entry of fs.readdirSync(spillDir())) sweepOne(path.join(spillDir(), entry));
2640
+ for (const entry of fs.readdirSync(spillDir())) sweepOne(path$1.join(spillDir(), entry));
2640
2641
  } catch {}
2641
2642
  try {
2642
2643
  const tmp = os.tmpdir();
2643
- for (const entry of fs.readdirSync(tmp)) if (/^dsh-bash-[0-9a-f]{8}\.log$/.test(entry)) sweepOne(path.join(tmp, entry));
2644
+ for (const entry of fs.readdirSync(tmp)) if (/^dsh-bash-[0-9a-f]{8}\.log$/.test(entry)) sweepOne(path$1.join(tmp, entry));
2644
2645
  } catch {}
2645
2646
  return removed;
2646
2647
  }
@@ -2788,7 +2789,7 @@ function startBashJob(options) {
2788
2789
  function expandTilde$1(filePath, home = os.homedir()) {
2789
2790
  if (filePath === "~") return home;
2790
2791
  if (filePath.startsWith("~/") || filePath.startsWith("~\\")) return home + filePath.slice(1);
2791
- if (filePath.startsWith("~")) return path.join(home, filePath.slice(1));
2792
+ if (filePath.startsWith("~")) return path$1.join(home, filePath.slice(1));
2792
2793
  return filePath;
2793
2794
  }
2794
2795
  /**
@@ -2941,7 +2942,7 @@ function fileExists(filePath) {
2941
2942
  function normalizeAtPrefix(filePath) {
2942
2943
  if (!filePath.startsWith("@")) return filePath;
2943
2944
  const withoutAt = filePath.slice(1);
2944
- if (withoutAt.startsWith("/") || withoutAt === "~" || withoutAt.startsWith("~/") || path.win32.isAbsolute(withoutAt) || withoutAt.startsWith("agent://") || withoutAt.startsWith("artifact://") || withoutAt.startsWith("skill://") || withoutAt.startsWith("rule://") || withoutAt.startsWith("security://") || withoutAt.startsWith("local:") || withoutAt.startsWith("mcp://")) return withoutAt;
2945
+ if (withoutAt.startsWith("/") || withoutAt === "~" || withoutAt.startsWith("~/") || path$1.win32.isAbsolute(withoutAt) || withoutAt.startsWith("agent://") || withoutAt.startsWith("artifact://") || withoutAt.startsWith("skill://") || withoutAt.startsWith("rule://") || withoutAt.startsWith("security://") || withoutAt.startsWith("local:") || withoutAt.startsWith("mcp://")) return withoutAt;
2945
2946
  return filePath;
2946
2947
  }
2947
2948
  function stripFileUrl(filePath) {
@@ -2956,7 +2957,7 @@ function expandTilde(filePath, home) {
2956
2957
  const h = home ?? os.homedir();
2957
2958
  if (filePath === "~") return h;
2958
2959
  if (filePath.startsWith("~/") || filePath.startsWith("~\\")) return h + filePath.slice(1);
2959
- if (filePath.startsWith("~")) return path.join(h, filePath.slice(1));
2960
+ if (filePath.startsWith("~")) return path$1.join(h, filePath.slice(1));
2960
2961
  return filePath;
2961
2962
  }
2962
2963
  function expandPath(filePath) {
@@ -3231,17 +3232,17 @@ function resolveToCwd(filePath, cwd) {
3231
3232
  const expanded = normalizeWindowsDriveAliasPath(expandPath(normalized));
3232
3233
  assertNotInternalUrl(normalizeLocalScheme(expanded), normalized);
3233
3234
  if (/^\/+$/.test(expanded)) return cwd;
3234
- if (path.isAbsolute(expanded)) return expanded;
3235
- return path.resolve(cwd, expanded);
3235
+ if (path$1.isAbsolute(expanded)) return expanded;
3236
+ return path$1.resolve(cwd, expanded);
3236
3237
  }
3237
3238
  function formatPathRelativeToCwd(filePath, cwd, options = {}) {
3238
- const resolvedCwd = path.resolve(cwd);
3239
+ const resolvedCwd = path$1.resolve(cwd);
3239
3240
  const normalized = normalizeLocalScheme(filePath);
3240
3241
  if (isInternalUrlPath(normalized)) return normalized;
3241
3242
  const expanded = expandPath(normalized);
3242
- const resolvedPath = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(cwd, expanded);
3243
- const relative = path.relative(resolvedCwd, resolvedPath);
3244
- let displayPath = normalizePosixPath(relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative) ? relative || "." : resolvedPath);
3243
+ const resolvedPath = path$1.isAbsolute(expanded) ? path$1.resolve(expanded) : path$1.resolve(cwd, expanded);
3244
+ const relative = path$1.relative(resolvedCwd, resolvedPath);
3245
+ let displayPath = normalizePosixPath(relative === "" || !relative.startsWith("..") && !path$1.isAbsolute(relative) ? relative || "." : resolvedPath);
3245
3246
  if (options.trailingSlash && displayPath !== "." && !displayPath.endsWith("/")) displayPath += "/";
3246
3247
  return displayPath;
3247
3248
  }
@@ -3484,16 +3485,16 @@ function buildBraceUnion(patterns) {
3484
3485
  }
3485
3486
  function findCommonBasePath(paths) {
3486
3487
  if (paths.length === 0) return ".";
3487
- let commonParts = path.resolve(paths[0]).split(path.sep);
3488
+ let commonParts = path$1.resolve(paths[0]).split(path$1.sep);
3488
3489
  for (const candidatePath of paths.slice(1)) {
3489
- const candidateParts = path.resolve(candidatePath).split(path.sep);
3490
+ const candidateParts = path$1.resolve(candidatePath).split(path$1.sep);
3490
3491
  let sharedCount = 0;
3491
3492
  const maxShared = Math.min(commonParts.length, candidateParts.length);
3492
3493
  while (sharedCount < maxShared && commonParts[sharedCount] === candidateParts[sharedCount]) sharedCount += 1;
3493
3494
  commonParts = commonParts.slice(0, sharedCount);
3494
3495
  }
3495
- if (commonParts.length === 0) return path.parse(path.resolve(paths[0])).root;
3496
- return commonParts.join(path.sep) || path.parse(path.resolve(paths[0])).root;
3496
+ if (commonParts.length === 0) return path$1.parse(path$1.resolve(paths[0])).root;
3497
+ return commonParts.join(path$1.sep) || path$1.parse(path$1.resolve(paths[0])).root;
3497
3498
  }
3498
3499
  function toScopeDisplay(items, cwd) {
3499
3500
  return items.map((item) => formatPathRelativeToCwd(item, cwd, { trailingSlash: item.endsWith("/") || item.endsWith("\\") })).join(", ");
@@ -3513,14 +3514,14 @@ async function resolveSearchPathItems(pathItems, cwd, suffixGlob, fanOutFileItem
3513
3514
  const allExactFiles = !suffixGlob && parsedItems.every((item) => !item.parsedPath.glob && item.stat.isFile());
3514
3515
  const commonBasePath = findCommonBasePath(parsedItems.map((item) => item.absoluteBasePath));
3515
3516
  const combinedPatterns = parsedItems.map((item) => {
3516
- const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || ".";
3517
+ const relativeBasePath = normalizePosixPath(path$1.relative(commonBasePath, item.absoluteBasePath)) || ".";
3517
3518
  if (item.parsedPath.glob) {
3518
3519
  const pathGlob = joinRelativeGlob(relativeBasePath, item.parsedPath.glob);
3519
3520
  return combineSearchGlobs(pathGlob, suffixGlob) ?? pathGlob;
3520
3521
  }
3521
3522
  if (suffixGlob) return combineSearchGlobs(relativeBasePath === "." ? void 0 : relativeBasePath, suffixGlob) ?? suffixGlob;
3522
3523
  if (item.stat.isDirectory()) return joinRelativeGlob(relativeBasePath, "**/*");
3523
- return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath;
3524
+ return relativeBasePath === "." ? path$1.basename(item.absoluteBasePath) : relativeBasePath;
3524
3525
  });
3525
3526
  const commonIsRequestedScope = parsedItems.some((item) => item.absoluteBasePath === commonBasePath);
3526
3527
  const demotesFileItem = fanOutFileItems && !allExactFiles && parsedItems.some((item) => !item.parsedPath.glob && item.stat.isFile());
@@ -3641,7 +3642,7 @@ async function findUniqueWorkspaceSuffix(rawPath, cwd, signal) {
3641
3642
  }
3642
3643
  if (matches.length !== 1) return null;
3643
3644
  return {
3644
- absolutePath: path.resolve(cwd, matches[0]),
3645
+ absolutePath: path$1.resolve(cwd, matches[0]),
3645
3646
  displayPath: matches[0]
3646
3647
  };
3647
3648
  }
@@ -3674,7 +3675,7 @@ async function resolveToolSearchScope(opts) {
3674
3675
  const resolved = opts.resolveExternalUrl ? await opts.resolveExternalUrl(rawPath) : void 0;
3675
3676
  if (resolved) {
3676
3677
  resolvedPathInputs.push(resolved.sourcePath);
3677
- if (opts.trackImmutableSources && resolved.immutable) immutableSourcePaths.add(path.resolve(resolved.sourcePath));
3678
+ if (opts.trackImmutableSources && resolved.immutable) immutableSourcePaths.add(path$1.resolve(resolved.sourcePath));
3678
3679
  continue;
3679
3680
  }
3680
3681
  throw new ToolError(`Cannot ${internalUrlAction} external URL: ${rawPath}. Use \`read\` to fetch web content, then search the returned text.`);
@@ -3694,7 +3695,7 @@ async function resolveToolSearchScope(opts) {
3694
3695
  pathOnly: true
3695
3696
  });
3696
3697
  if (!resource.sourcePath) throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
3697
- if (opts.trackImmutableSources && resource.immutable) immutableSourcePaths.add(path.resolve(resource.sourcePath));
3698
+ if (opts.trackImmutableSources && resource.immutable) immutableSourcePaths.add(path$1.resolve(resource.sourcePath));
3698
3699
  resolvedPathInputs.push(resource.sourcePath);
3699
3700
  }
3700
3701
  let missingPaths = [];
@@ -4235,6 +4236,15 @@ const TOOL_PLUS_FIELDS = [
4235
4236
  group: "behavior",
4236
4237
  tool: "bash"
4237
4238
  },
4239
+ {
4240
+ name: "webCards",
4241
+ kind: "boolean",
4242
+ default: true,
4243
+ labelKey: "webCards",
4244
+ hintKey: "webCardsHint",
4245
+ group: "behavior",
4246
+ tool: "bash"
4247
+ },
4238
4248
  {
4239
4249
  name: "autoBackgroundMs",
4240
4250
  kind: "number",
@@ -4930,6 +4940,7 @@ const Config = z.object({
4930
4940
  snapshotEnabled: z.boolean().default(fieldDefault("snapshotEnabled", true)),
4931
4941
  rmSafe: z.boolean().default(fieldDefault("rmSafe", true)),
4932
4942
  useShellCommandWrapper: z.boolean().default(fieldDefault("useShellCommandWrapper", false)),
4943
+ webCards: z.boolean().default(fieldDefault("webCards", true)),
4933
4944
  maxBackgroundJobs: z.number().default(fieldDefault("maxBackgroundJobs", 15)),
4934
4945
  outputTruncateStrategy: z.union(["bytes", "lines"]).default(fieldDefault("outputTruncateStrategy", "bytes")),
4935
4946
  outputTruncateTriggerBytes: z.number().default(fieldDefault("outputTruncateTriggerBytes", 10240)),
@@ -5027,6 +5038,7 @@ function resolveConfig(config) {
5027
5038
  snapshotEnabled: config.snapshotEnabled ?? fieldDefault("snapshotEnabled", true),
5028
5039
  rmSafe: config.rmSafe ?? fieldDefault("rmSafe", true),
5029
5040
  useShellCommandWrapper: config.useShellCommandWrapper ?? fieldDefault("useShellCommandWrapper", false),
5041
+ webCards: config.webCards ?? fieldDefault("webCards", true),
5030
5042
  maxBackgroundJobs: config.maxBackgroundJobs ?? fieldDefault("maxBackgroundJobs", 15),
5031
5043
  outputTruncate: {
5032
5044
  strategy: config.outputTruncateStrategy ?? fieldDefault("outputTruncateStrategy", DEFAULT_OUTPUT_TRUNCATE.strategy),
@@ -5435,6 +5447,47 @@ function installBrowserProbeRpc(ctx, deps) {
5435
5447
  return () => disposer?.();
5436
5448
  }
5437
5449
  //#endregion
5450
+ //#region src/web/host/bash.ts
5451
+ /** Whether `value` is a plain object (never `null` or an array). */
5452
+ function isRecord$4(value) {
5453
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5454
+ }
5455
+ /**
5456
+ * Project a bash result value into terminal-card metadata.
5457
+ *
5458
+ * Every emitted field is a plain JSON scalar and no key is ever set to
5459
+ * `undefined`: the metadata rides `presentationMeta`, where a non-lossless
5460
+ * payload turns a successful call into a failed one. An unrecognized value
5461
+ * yields `null`, which simply leaves the row on the generic shell.
5462
+ * @param value - the tool's own output value (foreground or background arm).
5463
+ * @returns the card metadata, or `null` when the value is not a usable bash result.
5464
+ */
5465
+ function bashCardMeta(value) {
5466
+ if (!isRecord$4(value)) return null;
5467
+ if (value.kind === "background") {
5468
+ const jobId = value.jobId;
5469
+ if (typeof jobId !== "string" || jobId.trim() === "") return null;
5470
+ return {
5471
+ kind: "terminal",
5472
+ mode: "background",
5473
+ jobId
5474
+ };
5475
+ }
5476
+ if (value.kind !== "foreground") return null;
5477
+ const { exitCode, timedOut, aborted } = value;
5478
+ if (exitCode !== null && !(typeof exitCode === "number" && Number.isInteger(exitCode))) return null;
5479
+ if (typeof timedOut !== "boolean" || typeof aborted !== "boolean") return null;
5480
+ const workingDir = value.workingDir;
5481
+ return {
5482
+ kind: "terminal",
5483
+ mode: "foreground",
5484
+ exitCode,
5485
+ timedOut,
5486
+ aborted,
5487
+ ...typeof workingDir === "string" && workingDir !== "" ? { workingDir } : {}
5488
+ };
5489
+ }
5490
+ //#endregion
5438
5491
  //#region src/config/truncate.ts
5439
5492
  /**
5440
5493
  * Config-driven output truncation for background→foreground completion text.
@@ -5811,8 +5864,8 @@ function canonicalSnapshotKey(absolutePath) {
5811
5864
  return fs.realpathSync.native(absolutePath);
5812
5865
  } catch {
5813
5866
  try {
5814
- const parent = fs.realpathSync.native(path.dirname(absolutePath));
5815
- return path.join(parent, path.basename(absolutePath));
5867
+ const parent = fs.realpathSync.native(path$1.dirname(absolutePath));
5868
+ return path$1.join(parent, path$1.basename(absolutePath));
5816
5869
  } catch {
5817
5870
  return absolutePath;
5818
5871
  }
@@ -6071,9 +6124,9 @@ function unescapeMarkerLikeLine(line) {
6071
6124
  return ESCAPED_MARKER_RE.test(line) ? line.replace("# %%", "# %") : line;
6072
6125
  }
6073
6126
  function isNotebookPath(filePath) {
6074
- return path.extname(filePath).toLowerCase() === ".ipynb";
6127
+ return path$1.extname(filePath).toLowerCase() === ".ipynb";
6075
6128
  }
6076
- function isRecord$1(value) {
6129
+ function isRecord$3(value) {
6077
6130
  return typeof value === "object" && value !== null && !Array.isArray(value);
6078
6131
  }
6079
6132
  function isCellType(value) {
@@ -6107,11 +6160,11 @@ function createEmptyNotebook() {
6107
6160
  };
6108
6161
  }
6109
6162
  function validateNotebook(value, displayPath) {
6110
- if (!isRecord$1(value)) throw new Error(`Invalid notebook structure (expected object): ${displayPath}`);
6163
+ if (!isRecord$3(value)) throw new Error(`Invalid notebook structure (expected object): ${displayPath}`);
6111
6164
  if (!Array.isArray(value.cells)) throw new Error(`Invalid notebook structure (missing cells array): ${displayPath}`);
6112
6165
  for (let index = 0; index < value.cells.length; index++) {
6113
6166
  const cell = value.cells[index];
6114
- if (!isRecord$1(cell) || !isCellType(cell.cell_type)) throw new Error(`Invalid notebook cell ${index} in ${displayPath}`);
6167
+ if (!isRecord$3(cell) || !isCellType(cell.cell_type)) throw new Error(`Invalid notebook cell ${index} in ${displayPath}`);
6115
6168
  }
6116
6169
  return value;
6117
6170
  }
@@ -6325,7 +6378,7 @@ async function resolveArtifactFile(url, context) {
6325
6378
  }
6326
6379
  const match = files.find((f) => f.startsWith(`${id}.`));
6327
6380
  if (match) {
6328
- foundPath = path.join(dir, match);
6381
+ foundPath = path$1.join(dir, match);
6329
6382
  break;
6330
6383
  }
6331
6384
  for (const f of files) {
@@ -6737,7 +6790,7 @@ function renderProfileNode(node, indent, ctx) {
6737
6790
  function isCpuProfilePath(filePath) {
6738
6791
  return /\.cpuprofile$/i.test(filePath);
6739
6792
  }
6740
- function isRecord(value) {
6793
+ function isRecord$2(value) {
6741
6794
  return typeof value === "object" && value !== null;
6742
6795
  }
6743
6796
  /**
@@ -6752,14 +6805,14 @@ function parseCpuProfile(text) {
6752
6805
  } catch {
6753
6806
  return null;
6754
6807
  }
6755
- if (isRecord(data) && !("nodes" in data) && isRecord(data.profile)) data = data.profile;
6756
- if (!isRecord(data)) return null;
6808
+ if (isRecord$2(data) && !("nodes" in data) && isRecord$2(data.profile)) data = data.profile;
6809
+ if (!isRecord$2(data)) return null;
6757
6810
  const { nodes, startTime, endTime, samples, timeDeltas } = data;
6758
6811
  if (!Array.isArray(nodes) || nodes.length === 0) return null;
6759
6812
  if (typeof startTime !== "number" || typeof endTime !== "number") return null;
6760
6813
  for (const node of nodes) {
6761
- if (!isRecord(node) || typeof node.id !== "number") return null;
6762
- if (!isRecord(node.callFrame) || typeof node.callFrame.functionName !== "string") return null;
6814
+ if (!isRecord$2(node) || typeof node.id !== "number") return null;
6815
+ if (!isRecord$2(node.callFrame) || typeof node.callFrame.functionName !== "string") return null;
6763
6816
  }
6764
6817
  return {
6765
6818
  nodes,
@@ -7361,7 +7414,7 @@ function renderSampleProfile(text) {
7361
7414
  * walker. Used by the read tool's directory-listing path.
7362
7415
  */
7363
7416
  async function buildDirectoryTree(cwd, options = {}) {
7364
- const rootPath = path.resolve(cwd);
7417
+ const rootPath = path$1.resolve(cwd);
7365
7418
  const maxDepth = options.maxDepth ?? 1;
7366
7419
  const perDirLimit = options.perDirLimit === void 0 ? null : options.perDirLimit;
7367
7420
  const rootLimit = options.rootLimit === void 0 ? perDirLimit : options.rootLimit;
@@ -9708,7 +9761,7 @@ function normalizeMime(contentType) {
9708
9761
  }
9709
9762
  function getFilenameExtensionHint(filename) {
9710
9763
  if (filename.toLowerCase().endsWith(".tar.gz")) return ".tar.gz";
9711
- return path.extname(filename).toLowerCase();
9764
+ return path$1.extname(filename).toLowerCase();
9712
9765
  }
9713
9766
  /**
9714
9767
  * Get extension from URL or Content-Disposition
@@ -9860,7 +9913,7 @@ function extractDocumentLinks(html, baseUrl) {
9860
9913
  for (const tag of anchorTags) {
9861
9914
  const href = getHtmlAttribute(tag, "href");
9862
9915
  if (!href) continue;
9863
- const ext = path.extname(href).toLowerCase();
9916
+ const ext = path$1.extname(href).toLowerCase();
9864
9917
  if (!CONVERTIBLE_EXTENSIONS.has(ext)) continue;
9865
9918
  const resolved = href.startsWith("http") ? href : new URL(href, baseUrl).href;
9866
9919
  if (seen.has(resolved)) continue;
@@ -10043,7 +10096,7 @@ async function renderHtmlToText(url, html, timeout, settings, userSignal, storag
10043
10096
  },
10044
10097
  browser: async () => {
10045
10098
  if (settings.get("browser.readerEnabled") === false) return null;
10046
- const { renderUrlWithBrowser } = await import("./browser-render-9o0Rknop.mjs");
10099
+ const { renderUrlWithBrowser } = await import("./browser-render-i7uYOl4f.mjs");
10047
10100
  return renderUrlWithBrowser(url, {
10048
10101
  timeoutMs: remoteBudgetMs,
10049
10102
  signal: userSignal
@@ -10190,8 +10243,8 @@ function buildBinaryPayloadResult(url, finalUrl, mime, method, content, fetchedA
10190
10243
  };
10191
10244
  }
10192
10245
  async function withTempBinaryFile(prefix, extension, bytes, readTempFile) {
10193
- const tempDir = await fs_promises_shim_exports.mkdtemp(path.join(os.tmpdir(), prefix));
10194
- const tempPath = path.join(tempDir, `payload${extension}`);
10246
+ const tempDir = await fs_promises_shim_exports.mkdtemp(path$1.join(os.tmpdir(), prefix));
10247
+ const tempPath = path$1.join(tempDir, `payload${extension}`);
10195
10248
  try {
10196
10249
  await Bun.write(tempPath, bytes);
10197
10250
  return await readTempFile(tempPath);
@@ -10264,7 +10317,7 @@ let specialHandlersPromise;
10264
10317
  * requires a special handler, so we keep them out of the cold-startup graph.
10265
10318
  */
10266
10319
  function loadSpecialHandlers() {
10267
- specialHandlersPromise ??= import("./scrapers-CtQrQ5kG.mjs").then((m) => m.specialHandlers);
10320
+ specialHandlersPromise ??= import("./scrapers-DU4u4-Co.mjs").then((m) => m.specialHandlers);
10268
10321
  return specialHandlersPromise;
10269
10322
  }
10270
10323
  /**
@@ -10409,7 +10462,7 @@ async function renderUrl(url, timeout, raw, settings, signal, storage, fetchOver
10409
10462
  };
10410
10463
  }
10411
10464
  const imageBridge = session?.imageBridge?.();
10412
- const displayName = path.basename(new URL(finalUrl).pathname) || "image";
10465
+ const displayName = path$1.basename(new URL(finalUrl).pathname) || "image";
10413
10466
  let bridgeNote;
10414
10467
  let committed;
10415
10468
  let savedRef;
@@ -10728,7 +10781,7 @@ async function findArtifactPath(session, artifactId) {
10728
10781
  if (!artifactsDir) return null;
10729
10782
  try {
10730
10783
  const match = (await fs_promises_shim_exports.readdir(artifactsDir)).find((file) => file.startsWith(`${artifactId}.`));
10731
- return match ? path.join(artifactsDir, match) : null;
10784
+ return match ? path$1.join(artifactsDir, match) : null;
10732
10785
  } catch {
10733
10786
  return null;
10734
10787
  }
@@ -10766,10 +10819,10 @@ function readUrlContentExtension(finalUrl) {
10766
10819
  async function materializeReadUrlContent(session, entry, raw) {
10767
10820
  const root = session.getArtifactsDir?.();
10768
10821
  if (!root) throw new ToolError("Cannot search URL output because this session cannot materialize read artifacts.");
10769
- const dir = path.join(root, "url-search");
10822
+ const dir = path$1.join(root, "url-search");
10770
10823
  await fs_promises_shim_exports.mkdir(dir, { recursive: true });
10771
10824
  const hash = Bun.hash(`${raw ? "raw" : "rendered"}:${entry.details.finalUrl}`).toString(36);
10772
- const contentPath = path.join(dir, `${hash}${readUrlContentExtension(entry.details.finalUrl)}`);
10825
+ const contentPath = path$1.join(dir, `${hash}${readUrlContentExtension(entry.details.finalUrl)}`);
10773
10826
  await Bun.write(contentPath, entry.content);
10774
10827
  return contentPath;
10775
10828
  }
@@ -11097,7 +11150,7 @@ function themeExtensionKey(filePath) {
11097
11150
  * Language id for syntax highlighting and UI (icons, read tool), or undefined if unknown.
11098
11151
  */
11099
11152
  function getLanguageFromPath(filePath) {
11100
- const baseName = path.basename(filePath).toLowerCase();
11153
+ const baseName = path$1.basename(filePath).toLowerCase();
11101
11154
  if (baseName.startsWith(".env.")) return "env";
11102
11155
  if (baseName === "dockerfile" || baseName.startsWith("dockerfile.") || baseName === "containerfile") return "dockerfile";
11103
11156
  if (baseName === ".emacs") return "emacs-lisp";
@@ -11115,10 +11168,10 @@ function prependLineNumbers(text, startNum) {
11115
11168
  return text.split("\n").map((line, i) => `${startNum + i}|${line}`).join("\n");
11116
11169
  }
11117
11170
  function formatReadHashlineHeader(displayPath, tag) {
11118
- return formatHashlineHeader(path.isAbsolute(displayPath) ? shortenPath(displayPath) : displayPath, tag);
11171
+ return formatHashlineHeader(path$1.isAbsolute(displayPath) ? shortenPath(displayPath) : displayPath, tag);
11119
11172
  }
11120
11173
  function recordFullHashlineContext(session, absolutePath, displayPath, fullText) {
11121
- if (!absolutePath || !path.isAbsolute(absolutePath)) return void 0;
11174
+ if (!absolutePath || !path$1.isAbsolute(absolutePath)) return void 0;
11122
11175
  const normalized = normalizeToLF(fullText);
11123
11176
  const tag = getFileSnapshotStore(session).recordSnapshot(canonicalSnapshotKey(absolutePath), normalized);
11124
11177
  return {
@@ -11213,7 +11266,7 @@ function lineNumbersFromSpans(spans) {
11213
11266
  return lines;
11214
11267
  }
11215
11268
  function recordInMemorySeenLines(session, absolutePath, fullText, seenLines) {
11216
- if (!absolutePath || !path.isAbsolute(absolutePath) || !seenLines || seenLines.length === 0) return;
11269
+ if (!absolutePath || !path$1.isAbsolute(absolutePath) || !seenLines || seenLines.length === 0) return;
11217
11270
  getFileSnapshotStore(session).recordSnapshot(canonicalSnapshotKey(absolutePath), normalizeToLF(fullText), [...seenLines]);
11218
11271
  }
11219
11272
  function lineNumbersFromEntries(entries) {
@@ -11459,7 +11512,7 @@ function markMarkdownContentType(session, details, filePath) {
11459
11512
  }
11460
11513
  //#endregion
11461
11514
  //#region src/tools/omp/tools/read-path-resolution.ts
11462
- const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
11515
+ const REMOTE_MOUNT_PREFIX = getRemoteDir() + path$1.sep;
11463
11516
  function isRemoteMountPath(absolutePath) {
11464
11517
  return absolutePath.startsWith(REMOTE_MOUNT_PREFIX);
11465
11518
  }
@@ -11789,7 +11842,7 @@ const MAX_SUMMARY_LINES = 2e4;
11789
11842
  * unless `read.summarize.prose` opts them in.
11790
11843
  */
11791
11844
  function isProseSummaryPath(filePath) {
11792
- return isMarkdownPath(filePath) || path.extname(filePath).toLowerCase() === ".txt";
11845
+ return isMarkdownPath(filePath) || path$1.extname(filePath).toLowerCase() === ".txt";
11793
11846
  }
11794
11847
  async function trySummarize(session, absolutePath, fileSize, signal) {
11795
11848
  if (fileSize > MAX_SUMMARY_BYTES) return null;
@@ -12029,7 +12082,7 @@ function openAutoQaDb() {
12029
12082
  const dbPath = getAutoQaDbPath();
12030
12083
  if (!dbPath) return null;
12031
12084
  try {
12032
- fs.mkdirSync(path.dirname(dbPath), { recursive: true });
12085
+ fs.mkdirSync(path$1.dirname(dbPath), { recursive: true });
12033
12086
  const db = new Database(dbPath, { create: true });
12034
12087
  db.run("PRAGMA busy_timeout = 5000");
12035
12088
  db.exec(`
@@ -12741,7 +12794,7 @@ var ReadTool = class {
12741
12794
  "image/jpeg",
12742
12795
  "image/webp",
12743
12796
  "image/gif"
12744
- ])).has(loaded.mimeType) ? loaded.mimeType : void 0) ?? imageMediaTypeForExtension(path.extname(absolutePath));
12797
+ ])).has(loaded.mimeType) ? loaded.mimeType : void 0) ?? imageMediaTypeForExtension(path$1.extname(absolutePath));
12745
12798
  if (!commitMediaType) return finish([{
12746
12799
  type: "text",
12747
12800
  text: formatImageUnsupportedNote(displayPath, {
@@ -12754,7 +12807,7 @@ var ReadTool = class {
12754
12807
  const saved = await saveImageViaBridge(bridge, {
12755
12808
  data: new Uint8Array(Buffer.from(loaded.data, "base64")),
12756
12809
  mediaType: commitMediaType,
12757
- name: path.basename(absolutePath),
12810
+ name: path$1.basename(absolutePath),
12758
12811
  displayPath
12759
12812
  });
12760
12813
  const blocks = imageContentBlocks(displayPath, saved);
@@ -12774,15 +12827,15 @@ var ReadTool = class {
12774
12827
  #approvedPlanAlias(missingAbsolutePath) {
12775
12828
  const planReferencePath = this.session.getPlanReferencePath?.();
12776
12829
  if (!planReferencePath?.startsWith("local:")) return void 0;
12777
- const requestedPath = path.resolve(missingAbsolutePath);
12778
- if (path.dirname(requestedPath) !== path.resolve(this.session.cwd)) return void 0;
12830
+ const requestedPath = path$1.resolve(missingAbsolutePath);
12831
+ if (path$1.dirname(requestedPath) !== path$1.resolve(this.session.cwd)) return void 0;
12779
12832
  const localProtocolOptions = this.session.localProtocolOptions ?? {
12780
12833
  getArtifactsDir: () => this.session.getArtifactsDir?.() ?? null,
12781
12834
  getSessionId: () => this.session.getSessionId?.() ?? null
12782
12835
  };
12783
12836
  try {
12784
12837
  const approvedPlanPath = resolveLocalUrlToPath(planReferencePath, localProtocolOptions);
12785
- return path.basename(requestedPath) === path.basename(approvedPlanPath) ? approvedPlanPath : void 0;
12838
+ return path$1.basename(requestedPath) === path$1.basename(approvedPlanPath) ? approvedPlanPath : void 0;
12786
12839
  } catch {
12787
12840
  return;
12788
12841
  }
@@ -13078,7 +13131,7 @@ var ReadTool = class {
13078
13131
  if (parsed.kind === "conflicts") return this.#readFileConflicts(absolutePath, suffixResolution, signal);
13079
13132
  const imageMetadata = await readImageMetadata(absolutePath);
13080
13133
  const mimeType = imageMetadata?.mimeType;
13081
- const ext = path.extname(absolutePath).toLowerCase();
13134
+ const ext = path$1.extname(absolutePath).toLowerCase();
13082
13135
  const resolvedDisplayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
13083
13136
  const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext);
13084
13137
  if (!mimeType && !isRawSelector(parsed) && fileSize <= MAX_PROFILE_SUMMARY_BYTES) {
@@ -13718,6 +13771,253 @@ var ReadTool = class {
13718
13771
  }
13719
13772
  };
13720
13773
  //#endregion
13774
+ //#region src/web/contract.ts
13775
+ /**
13776
+ * Cap on the serialized `edit` metadata. The engine's unified diff is already
13777
+ * bounded per file but unbounded across files; past this budget whole trailing
13778
+ * files are dropped, and once nothing is left the call falls back to the
13779
+ * generic shell rather than writing an oversized session entry.
13780
+ */
13781
+ const EDIT_META_MAX_BYTES = 131072;
13782
+ /** Cap on the `ast_edit` change preview (the engine's own display text). */
13783
+ const AST_EDIT_META_MAX_BYTES = 65536;
13784
+ /** Cap on the serialized `read` metadata (the returned window's lines). */
13785
+ const READ_META_MAX_BYTES = 262144;
13786
+ /**
13787
+ * Cap on the serialized search metadata. Same value and meaning as the shipped
13788
+ * search card's own `searchMetaMaxBytes`.
13789
+ */
13790
+ const SEARCH_META_MAX_BYTES = 65536;
13791
+ /** UTF-8 byte length of `text`, without allocating an encoder. */
13792
+ function utf8ByteLength(text) {
13793
+ let bytes = 0;
13794
+ for (let index = 0; index < text.length; index += 1) {
13795
+ const code = text.charCodeAt(index);
13796
+ if (code < 128) bytes += 1;
13797
+ else if (code < 2048) bytes += 2;
13798
+ else if (code >= 55296 && code <= 56319) {
13799
+ bytes += 4;
13800
+ index += 1;
13801
+ } else bytes += 3;
13802
+ }
13803
+ return bytes;
13804
+ }
13805
+ /** UTF-8 byte length of a value's JSON form; the unit every cap above uses. */
13806
+ function jsonByteLength(value) {
13807
+ const json = JSON.stringify(value);
13808
+ return json === void 0 ? 0 : utf8ByteLength(json);
13809
+ }
13810
+ /**
13811
+ * Longest prefix of `text` that fits in `cap` UTF-8 bytes, never splitting a
13812
+ * surrogate pair.
13813
+ * @param text - the text to bound.
13814
+ * @param cap - the byte budget.
13815
+ * @returns `text` when it already fits, otherwise its longest fitting prefix.
13816
+ */
13817
+ function capText(text, cap) {
13818
+ if (utf8ByteLength(text) <= cap) return text;
13819
+ let low = 0;
13820
+ let high = text.length;
13821
+ while (low < high) {
13822
+ const mid = Math.ceil((low + high) / 2);
13823
+ if (utf8ByteLength(text.slice(0, mid)) <= cap) low = mid;
13824
+ else high = mid - 1;
13825
+ }
13826
+ if (low > 0 && low < text.length) {
13827
+ const code = text.charCodeAt(low - 1);
13828
+ if (code >= 55296 && code <= 56319) low -= 1;
13829
+ }
13830
+ return text.slice(0, low);
13831
+ }
13832
+ /**
13833
+ * Drop entries from the tail until the payload `wrap(retained)` serializes
13834
+ * within `cap`.
13835
+ *
13836
+ * `wrap` receives the retained slice only, so the caller measures the real
13837
+ * payload — including its fixed fields — and a single oversized entry ends as
13838
+ * an empty list rather than an oversized one.
13839
+ * @param items - the ordered entries to bound.
13840
+ * @param cap - byte budget for `wrap`'s result.
13841
+ * @param wrap - builds the payload that would be emitted for a retained slice.
13842
+ * @returns the retained head, in input order.
13843
+ */
13844
+ function capTail(items, cap, wrap) {
13845
+ const retained = [...items];
13846
+ while (retained.length > 0 && jsonByteLength(wrap(retained)) > cap) retained.pop();
13847
+ return retained;
13848
+ }
13849
+ //#endregion
13850
+ //#region src/web/host/read.ts
13851
+ /**
13852
+ * Host projection for the `read` card: the OMP engine already hands the renderer
13853
+ * a prefix-free window (`details.displayContent` —
13854
+ * `{text, startLine, lineNumbers?}`) exactly so the UI can draw its own gutter,
13855
+ * and that is what the shipped read card consumes
13856
+ * (`{kind:'read', path, offset, lines, totalLines, lang?}`).
13857
+ *
13858
+ * Three details drive the projection:
13859
+ * - the card path must be the file, never the model-facing argument: the OMP
13860
+ * argument carries an inline selector (`src/foo.ts:5-16,40-80`, `:raw`), so
13861
+ * the selector is stripped from whatever path source wins;
13862
+ * - `lineNumbers[i] === null` marks an elided span (the body renders `…`); such
13863
+ * entries carry no file line number and must be dropped, or the card's
13864
+ * "strictly increasing" rule breaks;
13865
+ * - only local, text-like reads take the card. Images, directories, internal
13866
+ * URLs (`skill://`, `conflict://N`, …), web URLs, SQLite, archives and
13867
+ * converted documents all fall back to the generic shell.
13868
+ *
13869
+ * The projection is bounded: past {@link READ_META_MAX_BYTES} whole trailing
13870
+ * lines are dropped, and an empty window projects to `null` rather than to an
13871
+ * empty card.
13872
+ *
13873
+ * @module @xiaoso/dsh-tool-plus/web/host/read
13874
+ */
13875
+ /** `scheme://` — `file://` is still a local read; every other scheme is not. */
13876
+ const SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i;
13877
+ /**
13878
+ * Extensions whose reads are never a plain line window: SQLite and archive
13879
+ * readers (`omp/tools/sqlite-reader.ts`, `read/adapter/omp/tools/read-archive.ts`)
13880
+ * and markit document conversions (`omp/utils/markit.ts` CONVERTIBLE_EXTENSIONS)
13881
+ * all render their own body, so a line-numbered card would misrepresent them.
13882
+ */
13883
+ const NON_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
13884
+ ".sqlite",
13885
+ ".sqlite3",
13886
+ ".db",
13887
+ ".db3",
13888
+ ".zip",
13889
+ ".jar",
13890
+ ".war",
13891
+ ".ear",
13892
+ ".apk",
13893
+ ".tar",
13894
+ ".tgz",
13895
+ ".pdf",
13896
+ ".docx",
13897
+ ".pptx",
13898
+ ".xlsx",
13899
+ ".epub"
13900
+ ]);
13901
+ /** Whether `value` is a plain object (never `null` or an array). */
13902
+ function isRecord$1(value) {
13903
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13904
+ }
13905
+ /** A positive integer, or `undefined`. */
13906
+ function positiveInteger(value) {
13907
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : void 0;
13908
+ }
13909
+ /** Non-blank string, or `undefined`. */
13910
+ function nonBlankString$2(value) {
13911
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
13912
+ }
13913
+ /** Whether the colon at `index` separates a Windows drive letter from its path. */
13914
+ function isDriveColon(target, index) {
13915
+ if (index < 1 || !/[a-zA-Z]/.test(target[index - 1] ?? "")) return false;
13916
+ const next = target[index + 1];
13917
+ if (next !== "/" && next !== "\\") return false;
13918
+ const before = index >= 2 ? target[index - 2] : void 0;
13919
+ return before === void 0 || before === "/" || before === "\\";
13920
+ }
13921
+ /**
13922
+ * Strip the model-facing inline selector from a read target, leaving the file
13923
+ * path the card should show.
13924
+ *
13925
+ * Handles `:5-16,40-80`, `:raw`, `:50+150`, `:conflicts` and the SQLite/archive
13926
+ * sub-selectors (`db.sqlite:table:key`), plus the `file://` form the engine
13927
+ * itself expands. Windows drive letters survive.
13928
+ * @param target - the raw read target (`args.path`, or a resolved path).
13929
+ * @returns the path without its selector.
13930
+ */
13931
+ function stripReadSelector(target) {
13932
+ let value = target.trim();
13933
+ if (/^file:\/\//i.test(value)) value = value.slice(7);
13934
+ let end = value.length;
13935
+ for (let index = 0; index < value.length; index += 1) {
13936
+ if (value[index] !== ":") continue;
13937
+ if (value[index + 1] === "/" && value[index + 2] === "/") continue;
13938
+ if (isDriveColon(value, index)) continue;
13939
+ end = index;
13940
+ break;
13941
+ }
13942
+ let base = value.slice(0, end);
13943
+ if (/^\/[a-zA-Z]:[\\/]/.test(base)) base = base.slice(1);
13944
+ return base;
13945
+ }
13946
+ /** Whether the target names something other than a local file path. */
13947
+ function isNonLocalTarget(target) {
13948
+ const scheme = SCHEME_RE.exec(target.trim());
13949
+ return scheme !== null && scheme[1].toLowerCase() !== "file";
13950
+ }
13951
+ /** Whether the path is one of the non-text extension families above. */
13952
+ function hasNonTextExtension(target) {
13953
+ const lower = target.toLowerCase();
13954
+ if (/\.tar\.gz$/.test(lower)) return true;
13955
+ return NON_TEXT_EXTENSIONS.has(path$1.extname(lower));
13956
+ }
13957
+ /** Read the display window into strictly increasing, numbered lines. */
13958
+ function readWindowLines(text, startLine, rawNumbers) {
13959
+ if (text === "") return [];
13960
+ const textLines = text.split("\n");
13961
+ const numbers = Array.isArray(rawNumbers) ? rawNumbers : void 0;
13962
+ const lines = [];
13963
+ for (let index = 0; index < textLines.length; index += 1) {
13964
+ const number = numbers === void 0 ? startLine + index : positiveInteger(numbers[index]);
13965
+ if (number === void 0) continue;
13966
+ if (lines.length > 0 && number <= lines[lines.length - 1].number) continue;
13967
+ lines.push({
13968
+ number,
13969
+ text: textLines[index] ?? ""
13970
+ });
13971
+ }
13972
+ return lines;
13973
+ }
13974
+ /**
13975
+ * Project a read result value into read-card metadata.
13976
+ *
13977
+ * Every emitted field is plain JSON and no key is ever `undefined`: the metadata
13978
+ * rides `presentationMeta`, where a non-lossless payload turns a successful call
13979
+ * into a failed one. A non-text read, or a window without a single numbered
13980
+ * line, yields `null` and leaves the row on the generic shell.
13981
+ * @param args - the tool's arguments (only `path` is read).
13982
+ * @param value - the tool's output value, as the adapter projects it.
13983
+ * @returns the card metadata, or `null` when this read does not take the card.
13984
+ */
13985
+ function readCardMeta(args, value) {
13986
+ if (!isRecord$1(value)) return null;
13987
+ if (value.image !== void 0 && value.image !== null) return null;
13988
+ if (value.isDirectory === true) return null;
13989
+ if (value.textBlocks !== void 0 && value.textBlocks !== 1) return null;
13990
+ const source = isRecord$1(value.source) ? value.source : void 0;
13991
+ if (source !== void 0 && (source.type === "url" || source.type === "internal")) return null;
13992
+ const requested = isRecord$1(args) ? nonBlankString$2(args.path) : void 0;
13993
+ if (requested !== void 0 && isNonLocalTarget(requested)) return null;
13994
+ const sourcePath = source !== void 0 && source.type === "path" ? nonBlankString$2(source.value) : void 0;
13995
+ const resolved = nonBlankString$2(value.path);
13996
+ const raw = sourcePath ?? resolved ?? requested;
13997
+ if (raw === void 0) return null;
13998
+ const target = stripReadSelector(raw);
13999
+ if (target === "" || isNonLocalTarget(target) || hasNonTextExtension(target)) return null;
14000
+ const display = isRecord$1(value.display) ? value.display : void 0;
14001
+ const startLine = display === void 0 ? void 0 : positiveInteger(display.startLine);
14002
+ if (display === void 0 || typeof display.text !== "string" || startLine === void 0) return null;
14003
+ const lines = readWindowLines(display.text, startLine, display.lineNumbers);
14004
+ if (lines.length === 0) return null;
14005
+ const declaredTotal = positiveInteger(value.totalLines);
14006
+ const lastLine = lines[lines.length - 1].number;
14007
+ const totalLines = declaredTotal !== void 0 && declaredTotal >= lastLine ? declaredTotal : lastLine;
14008
+ const language = getLanguageFromPath(target);
14009
+ const build = (kept) => ({
14010
+ kind: "read",
14011
+ path: target,
14012
+ offset: kept[0]?.number ?? lines[0].number,
14013
+ lines: [...kept],
14014
+ totalLines,
14015
+ ...language === void 0 || language === "" ? {} : { lang: language }
14016
+ });
14017
+ const retained = capTail(lines, READ_META_MAX_BYTES, build);
14018
+ return retained.length === 0 ? null : build(retained);
14019
+ }
14020
+ //#endregion
13721
14021
  //#region src/tools/read/adapter/index.ts
13722
14022
  /** Build the OMP ToolSession facade over a DSH exec context + tool-plus config. */
13723
14023
  function createToolSession$6(exec, cfg, ctx) {
@@ -13773,13 +14073,34 @@ function toReadToolResult(result, args) {
13773
14073
  const text = toText$6(result);
13774
14074
  const details = result.details ?? {};
13775
14075
  const notice = formatOutputNotice(details.meta);
14076
+ const source = details.meta?.source;
14077
+ const display = asDisplayContent(details.displayContent);
13776
14078
  return {
13777
14079
  path: String(details.resolvedPath ?? args.path ?? ""),
13778
14080
  text,
14081
+ textBlocks: result.content.filter((block) => block.type === "text").length,
13779
14082
  ...notice ? { notice } : {},
13780
14083
  ...typeof details.totalLines === "number" ? { totalLines: details.totalLines } : {},
13781
14084
  ...typeof details.truncation !== "undefined" ? { truncated: true } : {},
13782
- ...details.image !== void 0 && typeof details.image === "object" ? { image: details.image } : {}
14085
+ ...details.image !== void 0 && typeof details.image === "object" ? { image: details.image } : {},
14086
+ ...display === void 0 ? {} : { display },
14087
+ ...source === void 0 ? {} : { source: {
14088
+ type: String(source.type),
14089
+ value: String(source.value)
14090
+ } },
14091
+ ...details.isDirectory === true ? { isDirectory: true } : {}
14092
+ };
14093
+ }
14094
+ /** 校验并逐字带出引擎的 displayContent(形状不对就当没有,降级走通用行)。 */
14095
+ function asDisplayContent(raw) {
14096
+ if (raw === null || typeof raw !== "object") return void 0;
14097
+ const display = raw;
14098
+ if (typeof display.text !== "string" || typeof display.startLine !== "number") return void 0;
14099
+ const lineNumbers = Array.isArray(display.lineNumbers) ? display.lineNumbers : void 0;
14100
+ return {
14101
+ text: display.text,
14102
+ startLine: display.startLine,
14103
+ ...lineNumbers === void 0 ? {} : { lineNumbers }
13783
14104
  };
13784
14105
  }
13785
14106
  /** render:模型可见文本 = 工具文本 + session 层提示(OMP messages.ts 语义)。 */
@@ -13826,6 +14147,40 @@ function registerRead(ctx, getConfig) {
13826
14147
  truncated: { type: "boolean" },
13827
14148
  totalLines: { type: "number" },
13828
14149
  offset: { type: "number" },
14150
+ display: {
14151
+ type: "object",
14152
+ additionalProperties: false,
14153
+ properties: {
14154
+ text: {
14155
+ type: "string",
14156
+ required: true
14157
+ },
14158
+ startLine: {
14159
+ type: "integer",
14160
+ required: true
14161
+ },
14162
+ lineNumbers: {
14163
+ type: "array",
14164
+ items: { oneOf: [{ type: "integer" }, { type: "null" }] }
14165
+ }
14166
+ }
14167
+ },
14168
+ source: {
14169
+ type: "object",
14170
+ additionalProperties: false,
14171
+ properties: {
14172
+ type: {
14173
+ type: "string",
14174
+ required: true
14175
+ },
14176
+ value: {
14177
+ type: "string",
14178
+ required: true
14179
+ }
14180
+ }
14181
+ },
14182
+ isDirectory: { type: "boolean" },
14183
+ textBlocks: { type: "integer" },
13829
14184
  image: {
13830
14185
  type: "object",
13831
14186
  additionalProperties: false,
@@ -13889,11 +14244,7 @@ function registerRead(ctx, getConfig) {
13889
14244
  });
13890
14245
  return blocks;
13891
14246
  },
13892
- presentationMeta: (_args, value) => ({
13893
- ...value.path !== void 0 ? { path: value.path } : {},
13894
- ...value.offset !== void 0 ? { offset: value.offset } : {},
13895
- ...value.totalLines !== void 0 ? { lines: value.totalLines } : {}
13896
- })
14247
+ presentationMeta: (args, value) => readCardMeta(args, value) ?? null
13897
14248
  },
13898
14249
  async execute(args, exec) {
13899
14250
  return executeReadTool(exec, getConfig(), args, ctx);
@@ -13911,6 +14262,61 @@ function registerRead(ctx, getConfig) {
13911
14262
  }));
13912
14263
  }
13913
14264
  //#endregion
14265
+ //#region src/web/host/write.ts
14266
+ /**
14267
+ * `write` tool card: project the engine's result into the `{kind:'write'}` meta
14268
+ * this plugin's browser half consumes (plan §4.1).
14269
+ *
14270
+ * The card is a **content preview** (matching the OMP card the model already
14271
+ * sees), so metadata carries only the target path plus an optional grammar
14272
+ * hint — never a diff, never a `before` read, never `±` counts. Both fields are
14273
+ * pure computation over data the adapter already holds: `lang` comes from the
14274
+ * shared `getLanguageFromPath` table, and no I/O happens here.
14275
+ *
14276
+ * Zero-dependency by design, like the contract it fills in: the Node half
14277
+ * imports it from `presentationMeta`, the browser half never does.
14278
+ *
14279
+ * Defensive: a card never throws and never emits `undefined` values (the host
14280
+ * rejects non-lossless metadata and turns a successful call into an error), so
14281
+ * an unusable path projects to `null` and the row falls back to the generic
14282
+ * shell.
14283
+ *
14284
+ * @module @xiaoso/dsh-tool-plus/web/host/write
14285
+ */
14286
+ /** `value` as a plain record, or `null` (arrays, `null` and primitives drop). */
14287
+ function asRecord$2(value) {
14288
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
14289
+ }
14290
+ /** First non-blank string among `values`, or `null`. */
14291
+ function firstNonBlank(...values) {
14292
+ for (const value of values) if (typeof value === "string" && value.trim() !== "") return value;
14293
+ return null;
14294
+ }
14295
+ /**
14296
+ * Project the write tool's result (and its call arguments) into card metadata.
14297
+ *
14298
+ * The path is the engine's resolved target when it has one, falling back to the
14299
+ * authored `args.path` (matching the value the adapter already emits). A blank
14300
+ * or missing path projects to `null`; an unrecognized extension omits `lang`
14301
+ * instead of writing `undefined`.
14302
+ * @param details - the engine's `resolvedPath` / the adapter's value (`path`).
14303
+ * @param args - the tool call arguments (`path`), used only as a fallback.
14304
+ * @returns the write card metadata, or `null` when no usable path is present.
14305
+ */
14306
+ function projectWriteCardMeta(details, args) {
14307
+ const result = asRecord$2(details);
14308
+ const parameters = asRecord$2(args);
14309
+ const path = firstNonBlank(result?.resolvedPath, result?.path, parameters?.path);
14310
+ if (path === null) return null;
14311
+ const language = getLanguageFromPath(path);
14312
+ return {
14313
+ kind: "write",
14314
+ path,
14315
+ ...language !== void 0 && language !== "" ? { lang: language } : {},
14316
+ ...result?.madeExecutable === true ? { madeExecutable: true } : {}
14317
+ };
14318
+ }
14319
+ //#endregion
13914
14320
  //#region src/tools/omp/tools/writethrough.ts
13915
14321
  /** Write `content` to `dst` (or the passed file handle) — no LSP pass. */
13916
14322
  async function writethroughNoop(dst, content, _signal, file) {
@@ -14045,10 +14451,10 @@ function stripBom(content) {
14045
14451
  }
14046
14452
  function getCommentStylesForPath(filePath) {
14047
14453
  const normalizedPath = filePath.toLowerCase();
14048
- const fileName = path.basename(normalizedPath);
14454
+ const fileName = path$1.basename(normalizedPath);
14049
14455
  const stylesByName = COMMENT_STYLES_BY_BASENAME.get(fileName);
14050
14456
  if (stylesByName) return stylesByName;
14051
- const ext = path.extname(fileName);
14457
+ const ext = path$1.extname(fileName);
14052
14458
  return COMMENT_STYLES_BY_EXTENSION.get(ext) ?? [];
14053
14459
  }
14054
14460
  function extractLeadingHeaderCommentText(content, commentStyles) {
@@ -14232,7 +14638,7 @@ function planLocalProtocolOptions(session) {
14232
14638
  * Returns `null` when the session has no artifact wiring (e.g. tests). */
14233
14639
  function localSandboxRoot(session) {
14234
14640
  try {
14235
- return path.resolve(resolveLocalRoot(planLocalProtocolOptions(session)));
14641
+ return path$1.resolve(resolveLocalRoot(planLocalProtocolOptions(session)));
14236
14642
  } catch {
14237
14643
  return null;
14238
14644
  }
@@ -14240,7 +14646,7 @@ function localSandboxRoot(session) {
14240
14646
  /** True when `absolutePath` resolves inside `root` (== root or under it). */
14241
14647
  function isWithinRoot(absolutePath, root) {
14242
14648
  if (absolutePath === root) return true;
14243
- const sep = `${root}${path.sep}`;
14649
+ const sep = `${root}${path$1.sep}`;
14244
14650
  return absolutePath.startsWith(sep);
14245
14651
  }
14246
14652
  /** Strip the hashline `[path#TAG]` wrapper from a write/edit target so the inner
@@ -14273,14 +14679,14 @@ function targetsLocalSandbox(session, targetPath) {
14273
14679
  } catch {
14274
14680
  return false;
14275
14681
  }
14276
- if (!path.isAbsolute(resolved)) return false;
14277
- const absolute = path.resolve(resolved);
14682
+ if (!path$1.isAbsolute(resolved)) return false;
14683
+ const absolute = path$1.resolve(resolved);
14278
14684
  if (isWithinRoot(absolute, root)) return true;
14279
14685
  try {
14280
14686
  const realRoot = fs.realpathSync.native(root);
14281
14687
  if (isWithinRoot(absolute, realRoot)) return true;
14282
- const realParent = fs.realpathSync.native(path.dirname(absolute));
14283
- return isWithinRoot(path.join(realParent, path.basename(absolute)), realRoot);
14688
+ const realParent = fs.realpathSync.native(path$1.dirname(absolute));
14689
+ return isWithinRoot(path$1.join(realParent, path$1.basename(absolute)), realRoot);
14284
14690
  } catch {
14285
14691
  return false;
14286
14692
  }
@@ -14324,7 +14730,7 @@ const XD_SCHEME_NEAR_MISSES = {
14324
14730
  };
14325
14731
  function assertWriteTargetAddressable(target, router) {
14326
14732
  const trimmed = target.trim();
14327
- if (path.win32.isAbsolute(trimmed) || router.canHandle(trimmed)) return;
14733
+ if (path$1.win32.isAbsolute(trimmed) || router.canHandle(trimmed)) return;
14328
14734
  const missingDelimiter = trimmed.match(XD_MISSING_DELIMITER_RE);
14329
14735
  if (missingDelimiter) throw new ToolError(`Unknown URI-like write target '${trimmed}'. Did you mean 'xd://${missingDelimiter[1]}'? Prefix the path with './' to write it as a filesystem path.`);
14330
14736
  const uriLike = trimmed.match(URI_LIKE_WRITE_PATH_RE);
@@ -14683,7 +15089,7 @@ var WriteTool = class {
14683
15089
  const finalPath = resolvedArchivePath.exists ? await fs_promises_shim_exports.realpath(resolvedArchivePath.absolutePath).catch(() => resolvedArchivePath.absolutePath) : resolvedArchivePath.absolutePath;
14684
15090
  const format = archiveFormatFromPath(finalPath) ?? "tar";
14685
15091
  const tmpPath = `${finalPath}.tmp-${process.pid}`;
14686
- const parentDir = path.dirname(resolvedArchivePath.absolutePath);
15092
+ const parentDir = path$1.dirname(resolvedArchivePath.absolutePath);
14687
15093
  if (parentDir && parentDir !== ".") await fs_promises_shim_exports.mkdir(parentDir, { recursive: true });
14688
15094
  const entries = /* @__PURE__ */ new Map();
14689
15095
  if (resolvedArchivePath.exists) try {
@@ -14774,7 +15180,7 @@ var WriteTool = class {
14774
15180
  } catch (error) {
14775
15181
  throw new ToolError(`SQLite write content must be valid JSON5: ${error instanceof Error ? error.message : String(error)}`);
14776
15182
  }
14777
- if (!isRecord$2(parsedContent)) throw new ToolError("SQLite write content must be a JSON object");
15183
+ if (!isRecord$5(parsedContent)) throw new ToolError("SQLite write content must be a JSON object");
14778
15184
  if (resolvedSqlitePath.key) {
14779
15185
  const lookup = resolveTableRowLookup(db, resolvedSqlitePath.table);
14780
15186
  resultText = (lookup.kind === "pk" ? updateRowByKey(db, resolvedSqlitePath.table, lookup, resolvedSqlitePath.key, parsedContent) : updateRowByRowId(db, resolvedSqlitePath.table, resolvedSqlitePath.key, parsedContent)) > 0 ? `Updated row '${resolvedSqlitePath.key}' in ${resolvedSqlitePath.table}` : `No row updated in ${resolvedSqlitePath.table} for key '${resolvedSqlitePath.key}'`;
@@ -15112,9 +15518,11 @@ async function executeWriteTool(exec, cfg, args) {
15112
15518
  persistOmpSessionState(exec?.agent?.session, session);
15113
15519
  const text = toText$5(result);
15114
15520
  const details = result.details ?? {};
15521
+ const card = projectWriteCardMeta({ resolvedPath: details.resolvedPath }, args);
15115
15522
  return {
15116
15523
  path: String(details.resolvedPath ?? args.path ?? ""),
15117
- text
15524
+ text,
15525
+ ...card !== null && card.lang !== void 0 ? { lang: card.lang } : {}
15118
15526
  };
15119
15527
  }
15120
15528
  /**
@@ -15148,14 +15556,15 @@ function registerWrite(ctx, getConfig) {
15148
15556
  text: {
15149
15557
  type: "string",
15150
15558
  required: true
15151
- }
15559
+ },
15560
+ lang: { type: "string" }
15152
15561
  }
15153
15562
  },
15154
15563
  render: (_args, value) => [{
15155
15564
  type: "text",
15156
15565
  text: String(value.text ?? "")
15157
15566
  }],
15158
- presentationMeta: (_args, value) => ({ ...value.path !== void 0 ? { path: value.path } : {} })
15567
+ presentationMeta: (_args, value) => projectWriteCardMeta(value) ?? null
15159
15568
  },
15160
15569
  async execute(args, exec) {
15161
15570
  return executeWriteTool(exec, getConfig(), args);
@@ -17552,7 +17961,7 @@ async function applyNormalizedPatch(input, options) {
17552
17961
  const normalizedContent = normalizeCreateContent(input.diff);
17553
17962
  const content = normalizedContent.endsWith("\n") ? normalizedContent : `${normalizedContent}\n`;
17554
17963
  if (!dryRun) {
17555
- const parentDir = path.dirname(absolutePath);
17964
+ const parentDir = path$1.dirname(absolutePath);
17556
17965
  if (parentDir && parentDir !== ".") await fs.mkdir(parentDir);
17557
17966
  await fs.write(absolutePath, content);
17558
17967
  }
@@ -17589,7 +17998,7 @@ async function applyNormalizedPatch(input, options) {
17589
17998
  const isMove = Boolean(input.rename) && destPath !== absolutePath;
17590
17999
  if (!dryRun) {
17591
18000
  if (isMove) {
17592
- const parentDir = path.dirname(destPath);
18001
+ const parentDir = path$1.dirname(destPath);
17593
18002
  if (parentDir && parentDir !== ".") await fs.mkdir(parentDir);
17594
18003
  await fs.write(destPath, finalContent);
17595
18004
  await fs.delete(absolutePath);
@@ -18401,6 +18810,169 @@ async function executeSinglePathEntries(path, runs, outerBatchRequest, onUpdate,
18401
18810
  };
18402
18811
  }
18403
18812
  //#endregion
18813
+ //#region src/web/host/edit.ts
18814
+ /**
18815
+ * `edit` tool card: project the engine's already-computed diff into the
18816
+ * `{kind:'edit'}` meta this plugin's browser half consumes (plan §4.2).
18817
+ *
18818
+ * The engine puts the diff in `details.diff` (single file) and in
18819
+ * `details.perFileResults[].diff` (multi file), so the card costs no extra I/O
18820
+ * and no extra diff computation. Its rows are `<sign><line>|<content>`
18821
+ * (`omp/edit/diff.ts:54` `formatNumberedDiffLine`):
18822
+ * - replace / hashline emit numbered rows only (`generateDiffString`);
18823
+ * - patch / apply_patch emit a `@@ -x,y +a,b @@` header plus the same rows
18824
+ * (`generateUnifiedDiffString`).
18825
+ *
18826
+ * The repo's `parseDiffHunks` parses the *authored* patch format and only
18827
+ * strips a `12 content` line-number prefix (space-separated), not the engine's
18828
+ * `12|content` (pipe-separated) — reusing it would leave the gutter inside the
18829
+ * card's content. `parseNumberedDiffHunks` below is the display-side minimum:
18830
+ * it drops the gutter, splits hunks on `@@` headers, blank gap rows and line
18831
+ * number breaks, and returns the line content byte-for-byte.
18832
+ *
18833
+ * Zero-dependency by design: the Node half imports it from `presentationMeta`,
18834
+ * the browser half never does.
18835
+ *
18836
+ * Defensive: a card never throws and never emits `undefined` values (the host
18837
+ * rejects non-lossless metadata and turns a successful call into an error), so
18838
+ * an empty or unparseable diff projects to `null`.
18839
+ *
18840
+ * @module @xiaoso/dsh-tool-plus/web/host/edit
18841
+ */
18842
+ /** One engine diff row: `<sign><line>|<content>` (`-2|beta`, ` 3|gamma`). */
18843
+ const NUMBERED_ROW = /^([+\- ])(\d+)\|([\s\S]*)$/;
18844
+ /** One authored unified row without a gutter: `-beta`, `+BETA`, ` gamma`. */
18845
+ const PLAIN_ROW = /^([+\- ])([\s\S]*)$/;
18846
+ /** Diff metadata that is never line content (file headers, envelope markers). */
18847
+ const METADATA_PREFIXES = [
18848
+ "*** ",
18849
+ "diff --git ",
18850
+ "index ",
18851
+ "--- ",
18852
+ "+++ "
18853
+ ];
18854
+ /** Whether `line` continues the current hunk (a line-number break starts a new one). */
18855
+ function breaksContinuity(prefix, oldNumber, newNumber, nextOld, nextNew) {
18856
+ if (prefix === "-") return oldNumber !== null && nextOld !== null && oldNumber !== nextOld;
18857
+ if (prefix === "+") return newNumber !== null && nextNew !== null && newNumber !== nextNew;
18858
+ return oldNumber !== null && nextOld !== null && oldNumber !== nextOld || newNumber !== null && nextNew !== null && newNumber !== nextNew;
18859
+ }
18860
+ /**
18861
+ * Parse an engine diff body into display hunks.
18862
+ *
18863
+ * Boundaries: a `@@ … @@` header, a blank gap row (the engine's own
18864
+ * non-contiguity marker), or a line-number break after a change (how joined
18865
+ * per-entry diffs and skipped context regions read). Hunks without a change are
18866
+ * dropped — they would render as an empty diff.
18867
+ * @param diff - `details.diff` / `details.perFileResults[].diff`.
18868
+ * @returns the hunks, in diff order; empty when nothing parses.
18869
+ */
18870
+ function parseNumberedDiffHunks(diff) {
18871
+ const hunks = [];
18872
+ let current = null;
18873
+ let nextOld = null;
18874
+ let nextNew = null;
18875
+ const flush = () => {
18876
+ if (current !== null && current.changed) hunks.push(current);
18877
+ current = null;
18878
+ };
18879
+ for (const rawLine of diff.split("\n")) {
18880
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
18881
+ if (line === "" || line.startsWith("@@")) {
18882
+ flush();
18883
+ continue;
18884
+ }
18885
+ if (METADATA_PREFIXES.some((prefix) => line.startsWith(prefix))) continue;
18886
+ const numbered = NUMBERED_ROW.exec(line);
18887
+ const plain = numbered === null ? PLAIN_ROW.exec(line) : null;
18888
+ if (numbered === null && plain === null) continue;
18889
+ const prefix = numbered?.[1] ?? plain?.[1];
18890
+ const content = numbered?.[3] ?? plain?.[2] ?? "";
18891
+ const digits = numbered?.[2];
18892
+ const oldNumber = digits === void 0 || prefix === "+" ? null : Number.parseInt(digits, 10);
18893
+ const newNumber = digits === void 0 || prefix === "-" ? null : Number.parseInt(digits, 10);
18894
+ if (current !== null && current.changed && breaksContinuity(prefix, oldNumber, newNumber, nextOld, nextNew)) flush();
18895
+ if (current === null) {
18896
+ current = {
18897
+ oldLines: [],
18898
+ newLines: [],
18899
+ changed: false
18900
+ };
18901
+ nextOld = null;
18902
+ nextNew = null;
18903
+ }
18904
+ if (prefix === "-") {
18905
+ current.oldLines.push(content);
18906
+ current.changed = true;
18907
+ nextOld = oldNumber === null ? null : oldNumber + 1;
18908
+ } else if (prefix === "+") {
18909
+ current.newLines.push(content);
18910
+ current.changed = true;
18911
+ nextNew = newNumber === null ? null : newNumber + 1;
18912
+ } else {
18913
+ current.oldLines.push(content);
18914
+ current.newLines.push(content);
18915
+ nextOld = oldNumber === null ? null : oldNumber + 1;
18916
+ nextNew = newNumber === null ? null : newNumber + 1;
18917
+ }
18918
+ }
18919
+ flush();
18920
+ return hunks;
18921
+ }
18922
+ /** `value` as a plain record, or `null` (arrays, `null` and primitives drop). */
18923
+ function asRecord$1(value) {
18924
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
18925
+ }
18926
+ /**
18927
+ * Append one file's hunks to `out`.
18928
+ *
18929
+ * A pure addition (no removed and no context rows) carries `oldText: null`, the
18930
+ * shape the shipped diff card reads as "new file". A move/rename has no
18931
+ * pre-move side to show in a two-column diff, so its target is recorded the
18932
+ * same way (the source side is deliberately not recorded).
18933
+ */
18934
+ function pushFileHunks(out, pathValue, diffValue, isMove) {
18935
+ if (typeof pathValue !== "string" || pathValue.trim() === "") return;
18936
+ if (typeof diffValue !== "string" || diffValue.trim() === "") return;
18937
+ for (const hunk of parseNumberedDiffHunks(diffValue)) out.push({
18938
+ path: pathValue,
18939
+ oldText: isMove || hunk.oldLines.length === 0 ? null : hunk.oldLines.join("\n"),
18940
+ newText: hunk.newLines.join("\n")
18941
+ });
18942
+ }
18943
+ /**
18944
+ * Project the edit engine's result details into card metadata.
18945
+ *
18946
+ * Multi-file results (`perFileResults`) are read file by file; single-file
18947
+ * results use `path` + `diff`. The payload is capped at
18948
+ * {@link EDIT_META_MAX_BYTES} by dropping whole trailing files; when nothing is
18949
+ * left the call projects to `null` and falls back to the generic shell rather
18950
+ * than writing an oversized session entry.
18951
+ * @param details - the engine's `EditToolDetails` (or the adapter's value).
18952
+ * @returns the edit card metadata, or `null` when there is no usable diff.
18953
+ */
18954
+ function projectEditCardMeta(details) {
18955
+ const record = asRecord$1(details);
18956
+ if (record === null) return null;
18957
+ const hunks = [];
18958
+ const perFile = Array.isArray(record.perFileResults) ? record.perFileResults : null;
18959
+ if (perFile !== null) for (const entry of perFile) {
18960
+ const file = asRecord$1(entry);
18961
+ if (file === null) continue;
18962
+ pushFileHunks(hunks, file.path, file.diff, file.move !== void 0);
18963
+ }
18964
+ else pushFileHunks(hunks, record.path, record.diff, record.move !== void 0);
18965
+ if (hunks.length === 0) return null;
18966
+ const retained = capTail(hunks, EDIT_META_MAX_BYTES, (kept) => ({
18967
+ kind: "edit",
18968
+ diffs: kept
18969
+ }));
18970
+ return retained.length === 0 ? null : {
18971
+ kind: "edit",
18972
+ diffs: retained
18973
+ };
18974
+ }
18975
+ //#endregion
18404
18976
  //#region src/tools/shared/sandbox-policy.ts
18405
18977
  /**
18406
18978
  * Resolve the per-call mutation policy for the given exec context, or
@@ -18501,6 +19073,17 @@ function runPatchEntry(options) {
18501
19073
  });
18502
19074
  }
18503
19075
  /**
19076
+ * 把成功结果带上卡片投影(纯展示用;投影不抛异常,null → 不产 meta)。
19077
+ * 错误路径不经此函数,保证报错文案一字不变。
19078
+ */
19079
+ function withEditCard(result, text) {
19080
+ const diffs = projectEditCardMeta(result.details)?.diffs;
19081
+ return diffs === void 0 ? { text } : {
19082
+ text,
19083
+ diffs
19084
+ };
19085
+ }
19086
+ /**
18504
19087
  * 把 dispatch 聚合结果转成 DSH 输出。成功:保留引擎 per-entry 摘要原文;
18505
19088
  * 失败:抛出完整聚合文案,并在“失败即末条目”的空档补上游同款未应用指引。
18506
19089
  *
@@ -18515,7 +19098,7 @@ function toEditToolResult(result, entryCount, failedEntry, failedPath) {
18515
19098
  const supplement = entryCount > 1 && failedEntry !== void 0 && failedEntry === entryCount ? failedPath !== void 0 ? `\nFiles NOT applied: ${failedPath}; re-read the affected files and re-issue only the failed and unapplied files.` : `\nEntry ${failedEntry} was NOT applied; re-read the file and re-issue only the failed and unapplied entries.` : "";
18516
19099
  throw new Error(`${text || "edit failed"}${supplement}`);
18517
19100
  }
18518
- return { text };
19101
+ return withEditCard(result, text);
18519
19102
  }
18520
19103
  /**
18521
19104
  * S-13:edit.fuzzyThreshold 解析——上游 settings 面原样放行显式 0/负值
@@ -18575,31 +19158,35 @@ async function executeEditTool(exec, cfg, args, ctx) {
18575
19158
  };
18576
19159
  }), void 0, cwd, signal), entries.length, failedFile, failedFile !== void 0 ? entries[failedFile - 1]?.path : void 0);
18577
19160
  }
18578
- return { text: toText$4(await executeHashlineSingle({
19161
+ const result = await executeHashlineSingle({
18579
19162
  session,
18580
19163
  input: args.input,
18581
19164
  signal,
18582
19165
  writethrough
18583
- })) };
19166
+ });
19167
+ return withEditCard(result, toText$4(result));
18584
19168
  }
18585
19169
  const filePath = args.file_path ?? args.path ?? "";
18586
19170
  if (!filePath || typeof filePath !== "string" || filePath.trim().length === 0) throw new Error("file_path must be a non-empty string");
18587
- if (typeof args.patch === "string" && args.patch.trim().length > 0) return { text: toText$4(await runPatchEntry({
18588
- session,
18589
- path: await resolveEditPath(session, filePath, {
18590
- mustExist: true,
18591
- signal
18592
- }),
18593
- entry: {
18594
- op: "update",
18595
- diff: args.patch
18596
- },
18597
- signal,
18598
- writethrough,
18599
- allowFuzzy,
18600
- fuzzyThreshold,
18601
- allowCreateOverwrite: true
18602
- })) };
19171
+ if (typeof args.patch === "string" && args.patch.trim().length > 0) {
19172
+ const result = await runPatchEntry({
19173
+ session,
19174
+ path: await resolveEditPath(session, filePath, {
19175
+ mustExist: true,
19176
+ signal
19177
+ }),
19178
+ entry: {
19179
+ op: "update",
19180
+ diff: args.patch
19181
+ },
19182
+ signal,
19183
+ writethrough,
19184
+ allowFuzzy,
19185
+ fuzzyThreshold,
19186
+ allowCreateOverwrite: true
19187
+ });
19188
+ return withEditCard(result, toText$4(result));
19189
+ }
18603
19190
  const editsInput = Array.isArray(args.edits) && args.edits.length > 0 ? args.edits : typeof args.old_string === "string" ? [{
18604
19191
  oldText: args.old_string,
18605
19192
  newText: String(args.new_string ?? "")
@@ -18693,15 +19280,42 @@ function registerEdit(ctx, getConfig) {
18693
19280
  schema: {
18694
19281
  type: "object",
18695
19282
  additionalProperties: false,
18696
- properties: { text: {
18697
- type: "string",
18698
- required: true
18699
- } }
19283
+ properties: {
19284
+ text: {
19285
+ type: "string",
19286
+ required: true
19287
+ },
19288
+ diffs: {
19289
+ type: "array",
19290
+ items: {
19291
+ type: "object",
19292
+ additionalProperties: false,
19293
+ properties: {
19294
+ path: {
19295
+ type: "string",
19296
+ required: true
19297
+ },
19298
+ oldText: {
19299
+ required: true,
19300
+ oneOf: [{ type: "string" }, { type: "null" }]
19301
+ },
19302
+ newText: {
19303
+ type: "string",
19304
+ required: true
19305
+ }
19306
+ }
19307
+ }
19308
+ }
19309
+ }
18700
19310
  },
18701
19311
  render: (_args, value) => [{
18702
19312
  type: "text",
18703
19313
  text: String(value.text ?? "")
18704
- }]
19314
+ }],
19315
+ presentationMeta: (_args, value) => Array.isArray(value?.diffs) && value.diffs.length > 0 ? {
19316
+ kind: "edit",
19317
+ diffs: value.diffs
19318
+ } : null
18705
19319
  },
18706
19320
  async execute(args, exec) {
18707
19321
  return executeEditTool(exec, getConfig(), args, ctx);
@@ -18777,7 +19391,7 @@ function createFileRecorder() {
18777
19391
  */
18778
19392
  function formatResultPath(filePath, isDirectory, basePath, cwd) {
18779
19393
  const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
18780
- if (isDirectory) return formatPathRelativeToCwd(path.resolve(basePath, cleanPath), cwd);
19394
+ if (isDirectory) return formatPathRelativeToCwd(path$1.resolve(basePath, cleanPath), cwd);
18781
19395
  return formatPathRelativeToCwd(basePath, cwd);
18782
19396
  }
18783
19397
  //#endregion
@@ -18839,6 +19453,80 @@ function formatGroupedFiles(files, renderFile) {
18839
19453
  display
18840
19454
  };
18841
19455
  }
19456
+ const GROUPED_HEADER_RE = /^(#+)\s+(.*)$/;
19457
+ const HEADER_SUFFIX_RE = /\s+\([^)]*\)\s*$/;
19458
+ const HEADER_HASH_TAG_RE = /#[0-9a-f]+$/i;
19459
+ function resolveGroupedPath(parent, name) {
19460
+ if (parent === void 0) return void 0;
19461
+ if (name === "" || name === ".") return parent;
19462
+ return path.resolve(parent, name);
19463
+ }
19464
+ /**
19465
+ * Walk grouped output lines, tracking a directory stack keyed by header depth, so
19466
+ * each header and body line can be linked back to its absolute filesystem path.
19467
+ * Reconstruction is stack-based (not per-blank-group) so nested directory headers
19468
+ * resolve correctly across the whole output.
19469
+ *
19470
+ * `headerBase` is the directory the displayed (folded) header paths are relative
19471
+ * to — for grep/ast tools that is the session cwd, since display paths are
19472
+ * formatted relative to cwd regardless of the (sub)directory the search was
19473
+ * scoped to. `fileScope` is the initial owning file for body lines that appear
19474
+ * before any header (single-file scopes have no `#` headers); it defaults to
19475
+ * `headerBase` and should be passed the scoped file's absolute path.
19476
+ */
19477
+ function classifyGroupedLines(lines, headerBase, fileScope = headerBase) {
19478
+ const result = [];
19479
+ const dirAtDepth = /* @__PURE__ */ new Map();
19480
+ let currentFile = fileScope;
19481
+ const clearDeeper = (depth) => {
19482
+ for (const key of dirAtDepth.keys()) if (key >= depth) dirAtDepth.delete(key);
19483
+ };
19484
+ for (const line of lines) {
19485
+ const match = GROUPED_HEADER_RE.exec(line);
19486
+ if (!match) {
19487
+ result.push({
19488
+ kind: "content",
19489
+ depth: 0,
19490
+ filePath: currentFile
19491
+ });
19492
+ continue;
19493
+ }
19494
+ const depth = match[1].length;
19495
+ const rest = match[2].trimEnd();
19496
+ if (isUrlLikePath(rest)) {
19497
+ clearDeeper(depth);
19498
+ currentFile = void 0;
19499
+ result.push({
19500
+ kind: "file",
19501
+ depth,
19502
+ isUrl: true
19503
+ });
19504
+ continue;
19505
+ }
19506
+ const parent = depth > 1 ? dirAtDepth.get(depth - 1) : headerBase;
19507
+ if (rest.endsWith("/")) {
19508
+ const abs = resolveGroupedPath(parent, rest.slice(0, -1).replace(HEADER_SUFFIX_RE, ""));
19509
+ clearDeeper(depth);
19510
+ if (abs !== void 0) dirAtDepth.set(depth, abs);
19511
+ currentFile = void 0;
19512
+ result.push({
19513
+ kind: "dir",
19514
+ depth,
19515
+ headerPath: abs
19516
+ });
19517
+ continue;
19518
+ }
19519
+ const name = rest.replace(HEADER_SUFFIX_RE, "").replace(HEADER_HASH_TAG_RE, "");
19520
+ const abs = name ? resolveGroupedPath(parent, name) : void 0;
19521
+ currentFile = abs;
19522
+ result.push({
19523
+ kind: "file",
19524
+ depth,
19525
+ headerPath: abs
19526
+ });
19527
+ }
19528
+ return result;
19529
+ }
18842
19530
  //#endregion
18843
19531
  //#region src/tools/omp/tools/match-line-format.ts
18844
19532
  /**
@@ -18869,8 +19557,8 @@ function formatMatchLine(lineNumber, line, isMatch, options) {
18869
19557
  */
18870
19558
  function matchAbsolutePath(matchPath, searchPath) {
18871
19559
  if (matchPath === "") return searchPath;
18872
- if (path.isAbsolute(matchPath)) return path.resolve(matchPath);
18873
- return path.resolve(searchPath, matchPath);
19560
+ if (path$1.isAbsolute(matchPath)) return path$1.resolve(matchPath);
19561
+ return path$1.resolve(searchPath, matchPath);
18874
19562
  }
18875
19563
  //#endregion
18876
19564
  //#region src/tools/omp/tools/grep.ts
@@ -19015,9 +19703,9 @@ async function resolveArchiveSearchPaths(pathSpecs, cwd) {
19015
19703
  unreadable.push(`${entry} (non-UTF-8 archive entry)`);
19016
19704
  continue;
19017
19705
  }
19018
- if (!tempDir) tempDir = await (0, fs_promises_shim_exports.mkdtemp)(path.join(tmpdir(), "omp-search-archive-"));
19019
- const safeBase = path.basename(member.subPath).replace(/[^\w.-]+/g, "_") || "entry";
19020
- const tempPath = path.join(tempDir, `${idx}-${safeBase}`);
19706
+ if (!tempDir) tempDir = await (0, fs_promises_shim_exports.mkdtemp)(path$1.join(tmpdir(), "omp-search-archive-"));
19707
+ const safeBase = path$1.basename(member.subPath).replace(/[^\w.-]+/g, "_") || "entry";
19708
+ const tempPath = path$1.join(tempDir, `${idx}-${safeBase}`);
19021
19709
  await (0, fs_promises_shim_exports.writeFile)(tempPath, text);
19022
19710
  resolvedPaths[idx] = tempPath;
19023
19711
  displayMap.set(tempPath, entry);
@@ -19038,7 +19726,7 @@ async function resolveArchiveSearchPaths(pathSpecs, cwd) {
19038
19726
  };
19039
19727
  }
19040
19728
  function isImmutableSourcePath(filePath, immutableSourcePaths) {
19041
- for (const immutablePath of immutableSourcePaths) if (filePath === immutablePath || filePath.startsWith(`${immutablePath}${path.sep}`)) return true;
19729
+ for (const immutablePath of immutableSourcePaths) if (filePath === immutablePath || filePath.startsWith(`${immutablePath}${path$1.sep}`)) return true;
19042
19730
  return false;
19043
19731
  }
19044
19732
  const OMP_ROOT_URL_RE = /^omp:\/\/(?:\/?|docs\/?)$/i;
@@ -19156,7 +19844,7 @@ async function nativeChunkedLineIndexes(dir, resourceIdx, content, pattern, igno
19156
19844
  let chunkSeq = 0;
19157
19845
  const flush = async () => {
19158
19846
  if (chunkLines.length === 0) return;
19159
- const scratch = path.resolve(dir, `${resourceIdx}-chunk-${chunkSeq++}`);
19847
+ const scratch = path$1.resolve(dir, `${resourceIdx}-chunk-${chunkSeq++}`);
19160
19848
  await (0, fs_promises_shim_exports.writeFile)(scratch, chunkLines.join("\n"));
19161
19849
  const probe = await grep({
19162
19850
  pattern,
@@ -19264,7 +19952,7 @@ async function searchVirtualResources(resources, pattern, ignoreCase, multiline,
19264
19952
  const filesWithMatches = /* @__PURE__ */ new Set();
19265
19953
  let totalMatches = 0;
19266
19954
  let limitReached = false;
19267
- const dir = await (0, fs_promises_shim_exports.mkdtemp)(path.join(tmpdir(), "omp-search-virtual-"));
19955
+ const dir = await (0, fs_promises_shim_exports.mkdtemp)(path$1.join(tmpdir(), "omp-search-virtual-"));
19268
19956
  try {
19269
19957
  for (let idx = 0; idx < resources.length; idx++) {
19270
19958
  const resource = resources[idx];
@@ -19277,7 +19965,7 @@ async function searchVirtualResources(resources, pattern, ignoreCase, multiline,
19277
19965
  let matchedIndexes;
19278
19966
  if (Buffer.byteLength(resource.content, "utf8") > NATIVE_GREP_MAX_FILE_BYTES) matchedIndexes = (multiline ? jsMatchedLineIndexes(resource.content, lines, pattern, ignoreCase, true) : await nativeChunkedLineIndexes(dir, idx, resource.content, pattern, ignoreCase, signal)).filter((lineIndex) => lineAllowed(lineIndex + 1, resource.ranges));
19279
19967
  else {
19280
- const scratch = path.resolve(dir, `${idx}`);
19968
+ const scratch = path$1.resolve(dir, `${idx}`);
19281
19969
  await (0, fs_promises_shim_exports.writeFile)(scratch, resource.content);
19282
19970
  const probe = await grep({
19283
19971
  pattern,
@@ -19380,7 +20068,7 @@ async function resolveInternalSearchInputs(opts) {
19380
20068
  if (resource.isDirectory && !resource.sourcePath) throw new ToolError(`search cannot recurse the directory listing at ${rawPath}; search a specific file under it (e.g. ${rawPath.replace(/\/+$/, "")}/<file>) or read ${rawPath} to list its entries`);
19381
20069
  if (resource.sourcePath) {
19382
20070
  paths[idx] = resource.sourcePath;
19383
- if (resource.immutable) immutableSourcePaths.add(path.resolve(resource.sourcePath));
20071
+ if (resource.immutable) immutableSourcePaths.add(path$1.resolve(resource.sourcePath));
19384
20072
  continue;
19385
20073
  }
19386
20074
  if (context.pathOnly) resource = await internalRouter.resolve(rawPath, {
@@ -19520,16 +20208,16 @@ var GrepTool = class {
19520
20208
  if (!resolved) continue;
19521
20209
  const materializedExternalPath = materializedExternalPaths.get(spec.clean);
19522
20210
  if (materializedExternalPath) {
19523
- mergeRangesInto(rangesByAbsPath, path.resolve(materializedExternalPath), spec.ranges);
20211
+ mergeRangesInto(rangesByAbsPath, path$1.resolve(materializedExternalPath), spec.ranges);
19524
20212
  continue;
19525
20213
  }
19526
20214
  if (resolved === spec.clean && !archiveDisplayMap.has(resolved)) {
19527
- const absKey = path.resolve(resolveReadPath(resolved, this.session.cwd));
20215
+ const absKey = path$1.resolve(resolveReadPath(resolved, this.session.cwd));
19528
20216
  const stats = await (0, fs_promises_shim_exports.stat)(absKey).catch(() => null);
19529
20217
  if (!stats) throw new ToolError(`Path not found for line-range selector: ${spec.original}`);
19530
20218
  if (!stats.isFile()) throw new ToolError(`Line-range selector requires a single file: ${spec.original} is a directory`);
19531
20219
  mergeRangesInto(rangesByAbsPath, absKey, spec.ranges);
19532
- } else mergeRangesInto(rangesByAbsPath, path.resolve(resolved), spec.ranges);
20220
+ } else mergeRangesInto(rangesByAbsPath, path$1.resolve(resolved), spec.ranges);
19533
20221
  }
19534
20222
  const physicalScopePath = searchablePaths.length === 1 && archiveDisplayMap.get(searchPath) ? archiveDisplayMap.get(searchPath) : scope.scopePath;
19535
20223
  scopePath = internalResolution.virtualScopePath ? `${physicalScopePath}, ${internalResolution.virtualScopePath}` : physicalScopePath;
@@ -19596,14 +20284,14 @@ var GrepTool = class {
19596
20284
  totalMatches += targetResult.totalMatches;
19597
20285
  filesSearched += targetResult.filesSearched;
19598
20286
  for (const match of targetResult.matches) {
19599
- const absolute = path.resolve(target.basePath, match.path);
20287
+ const absolute = path$1.resolve(target.basePath, match.path);
19600
20288
  const matchKey = `${absolute}\0${match.lineNumber}`;
19601
20289
  if (seenMatchKeys.has(matchKey)) {
19602
20290
  totalMatches = Math.max(0, totalMatches - 1);
19603
20291
  continue;
19604
20292
  }
19605
20293
  seenMatchKeys.add(matchKey);
19606
- const rebased = path.relative(searchPath, absolute).replace(/\\/g, "/");
20294
+ const rebased = path$1.relative(searchPath, absolute).replace(/\\/g, "/");
19607
20295
  matches.push({
19608
20296
  ...match,
19609
20297
  path: rebased
@@ -19740,7 +20428,7 @@ var GrepTool = class {
19740
20428
  await Promise.all(explicitFileTargets.map(async (target) => {
19741
20429
  try {
19742
20430
  const st = await (0, fs_promises_shim_exports.stat)(target);
19743
- if (st.isFile() && st.size > NATIVE_GREP_MAX_FILE_BYTES) oversized.push(path.relative(this.session.cwd, target) || target);
20431
+ if (st.isFile() && st.size > NATIVE_GREP_MAX_FILE_BYTES) oversized.push(path$1.relative(this.session.cwd, target) || target);
19744
20432
  } catch {}
19745
20433
  }));
19746
20434
  if (oversized.length === 0) return void 0;
@@ -19785,7 +20473,7 @@ var GrepTool = class {
19785
20473
  const hashContexts = /* @__PURE__ */ new Map();
19786
20474
  if (baseDisplayMode.hashLines) for (const relativePath of fileList) {
19787
20475
  if (archiveDisplaySet.has(relativePath) || virtualPathSet.has(relativePath)) continue;
19788
- const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
20476
+ const absoluteFilePath = path$1.resolve(this.session.cwd, relativePath);
19789
20477
  if (isImmutableSourcePath(absoluteFilePath, immutableSourcePaths)) continue;
19790
20478
  const tag = await recordFileSnapshot(this.session, absoluteFilePath);
19791
20479
  if (tag) hashContexts.set(relativePath, { tag });
@@ -19821,7 +20509,7 @@ var GrepTool = class {
19821
20509
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
19822
20510
  }
19823
20511
  if (hashContext?.tag) {
19824
- const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
20512
+ const absoluteFilePath = path$1.resolve(this.session.cwd, relativePath);
19825
20513
  recordSeenLinesFromBody(this.session, absoluteFilePath, hashContext.tag, modelOut.join("\n"));
19826
20514
  }
19827
20515
  return {
@@ -19889,6 +20577,204 @@ var GrepTool = class {
19889
20577
  }
19890
20578
  };
19891
20579
  //#endregion
20580
+ //#region src/web/host/search.ts
20581
+ /**
20582
+ * Host projection for the `grep` / `ast_grep` / `glob` search cards.
20583
+ *
20584
+ * The engines already render the display text the TUI used to read: the grouped
20585
+ * file output (`# dir/` / `## file.ts#<tag>` headers) followed by code-frame
20586
+ * body lines (`*12│text` for a match, ` 12│text` for context, `…` for a gap).
20587
+ * That text is the only place the matched *line numbers* live — `details` only
20588
+ * carries per-file counts — so the card is rebuilt by parsing it with the same
20589
+ * `classifyGroupedLines` helper the TUI renderers used, then reading each
20590
+ * `formatCodeFrameLine` gutter back.
20591
+ *
20592
+ * Everything is defensive and bounded:
20593
+ * - an engine that explicitly reports zero (a `matchCount` of `0`, or an empty
20594
+ * `paths` array backed by a zero count) is a *known empty result*: it projects
20595
+ * to the empty card the client already renders, so the same tool never shows
20596
+ * two different shells;
20597
+ * - a display that yields no file group at all projects to `null` unless that
20598
+ * zero was reported (the row then falls back to the generic shell — the result
20599
+ * text stays fully visible);
20600
+ * - the payload is capped at {@link SEARCH_META_MAX_BYTES} by dropping trailing
20601
+ * groups/paths, with `truncated` raised and `total` left at its pre-cap value.
20602
+ *
20603
+ * @module @xiaoso/dsh-tool-plus/web/host/search
20604
+ */
20605
+ /**
20606
+ * One rendered body line's gutter: optional `formatCodeFrameLine` match marker,
20607
+ * the 1-based line number, and the display separator. Both shipped modes end up
20608
+ * in `displayContent`, so the parser also accepts the model-facing `|` / `:`
20609
+ * separators an older engine version could emit.
20610
+ */
20611
+ const CODE_FRAME_RE = /^\s*(\*?)(\d+)[│|:]([\s\S]*)$/;
20612
+ /** Whether `value` is a plain object (never `null` or an array). */
20613
+ function isRecord(value) {
20614
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20615
+ }
20616
+ /** Non-negative integer, or `undefined`. */
20617
+ function nonNegativeInteger$1(value) {
20618
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
20619
+ }
20620
+ /** The non-blank string `value` holds, or `undefined`. */
20621
+ function nonBlankString$1(value) {
20622
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
20623
+ }
20624
+ /**
20625
+ * The pre-cap total to report: the engine's own count when it is present and at
20626
+ * least what the display actually holds, otherwise the parsed count. The
20627
+ * metadata contract requires `total >= files.length`, and an engine count below
20628
+ * the parsed one only means the display and the count disagree.
20629
+ */
20630
+ function reportedTotal(declared, parsed) {
20631
+ const count = nonNegativeInteger$1(declared);
20632
+ return count !== void 0 && count >= parsed ? count : parsed;
20633
+ }
20634
+ /** One parsed code-frame body line, or `null` for headers/gaps/unparsable text. */
20635
+ function parseFrameLine(line) {
20636
+ const match = CODE_FRAME_RE.exec(line);
20637
+ if (match === null) return null;
20638
+ const lineNumber = Number(match[2]);
20639
+ if (!Number.isInteger(lineNumber) || lineNumber < 1) return null;
20640
+ return {
20641
+ lineNumber,
20642
+ line: match[3] ?? "",
20643
+ isMatch: match[1] === "*"
20644
+ };
20645
+ }
20646
+ /**
20647
+ * Parse one grouped display into per-file match groups, in first-seen order.
20648
+ *
20649
+ * Only `*`-marked body lines become matches; context lines and `…` gap lines are
20650
+ * dropped. A body line whose owning file cannot be resolved (a URL group, or a
20651
+ * display read without a session base) is skipped rather than attributed to the
20652
+ * wrong file.
20653
+ * @param display - the engine's `displayContent`.
20654
+ * @param base - directory the display-relative header paths are relative to (the session cwd).
20655
+ * @param fileScope - absolute path of the only file, for a display without headers.
20656
+ * @returns the match groups; empty when nothing could be attributed.
20657
+ */
20658
+ function parseMatchGroups(display, base, fileScope) {
20659
+ const lines = display.split("\n");
20660
+ const contexts = classifyGroupedLines(lines, base, fileScope);
20661
+ const groups = [];
20662
+ const byPath = /* @__PURE__ */ new Map();
20663
+ for (let index = 0; index < lines.length; index += 1) {
20664
+ const context = contexts[index];
20665
+ if (context === void 0 || context.kind !== "content") continue;
20666
+ const parsed = parseFrameLine(lines[index] ?? "");
20667
+ if (parsed === null || !parsed.isMatch) continue;
20668
+ const filePath = context.filePath;
20669
+ if (typeof filePath !== "string" || filePath === "") continue;
20670
+ let group = byPath.get(filePath);
20671
+ if (group === void 0) {
20672
+ group = {
20673
+ path: filePath,
20674
+ matches: []
20675
+ };
20676
+ byPath.set(filePath, group);
20677
+ groups.push(group);
20678
+ }
20679
+ const matchLine = {
20680
+ lineNumber: parsed.lineNumber,
20681
+ line: parsed.line
20682
+ };
20683
+ group.matches.push(matchLine);
20684
+ }
20685
+ return groups;
20686
+ }
20687
+ /**
20688
+ * The known-empty matches card: the engine explicitly reported `matchCount: 0`,
20689
+ * so there is no grouping to parse but the empty result itself is definitive.
20690
+ */
20691
+ function emptyMatchesMeta() {
20692
+ return {
20693
+ kind: "search",
20694
+ shape: "matches",
20695
+ files: [],
20696
+ truncated: false,
20697
+ total: 0
20698
+ };
20699
+ }
20700
+ /**
20701
+ * Project a `grep` / `ast_grep` result value into grouped-matches card metadata.
20702
+ *
20703
+ * @param input - the tool's output value: `displayContent`, `cwd`, `files`,
20704
+ * `matchCount` and `truncated` as the adapters project them.
20705
+ * @returns the card metadata, or `null` when no file group could be parsed.
20706
+ */
20707
+ function searchMatchesCardMeta(input) {
20708
+ if (!isRecord(input)) return null;
20709
+ const zeroMatches = input.matchCount === 0;
20710
+ const display = typeof input.displayContent === "string" ? input.displayContent : void 0;
20711
+ if (display === void 0 || display === "") return zeroMatches ? emptyMatchesMeta() : null;
20712
+ const base = nonBlankString$1(input.cwd);
20713
+ const files = Array.isArray(input.files) ? input.files : [];
20714
+ let fileScope = files.length === 1 ? nonBlankString$1(files[0]) : void 0;
20715
+ if (fileScope !== void 0 && base !== void 0 && !path$1.isAbsolute(fileScope)) fileScope = path$1.resolve(base, fileScope);
20716
+ const groups = parseMatchGroups(display, base, fileScope);
20717
+ if (groups.length === 0) return zeroMatches ? emptyMatchesMeta() : null;
20718
+ const parsedMatches = groups.reduce((total, group) => total + group.matches.length, 0);
20719
+ const total = reportedTotal(input.matchCount, parsedMatches);
20720
+ const retained = capTail(groups, SEARCH_META_MAX_BYTES, (kept) => ({
20721
+ kind: "search",
20722
+ shape: "matches",
20723
+ files: kept,
20724
+ truncated: true,
20725
+ total
20726
+ }));
20727
+ if (retained.length === 0) return null;
20728
+ const retainedMatches = retained.reduce((count, group) => count + group.matches.length, 0);
20729
+ return {
20730
+ kind: "search",
20731
+ shape: "matches",
20732
+ files: retained,
20733
+ truncated: input.truncated === true || retainedMatches < total,
20734
+ total
20735
+ };
20736
+ }
20737
+ /**
20738
+ * Project a `glob` result value into path-list card metadata.
20739
+ *
20740
+ * @param input - the tool's output value: `paths`, `fileCount`, `total` and `truncated`.
20741
+ * @returns the card metadata, or `null` when the value is not a glob result (no
20742
+ * `paths` key, or an empty one the engine never backed with a zero count).
20743
+ */
20744
+ function searchPathsCardMeta(input) {
20745
+ if (!isRecord(input)) return null;
20746
+ const listed = input.paths;
20747
+ if (!Array.isArray(listed)) return null;
20748
+ if (listed.length === 0) {
20749
+ if (!(nonNegativeInteger$1(input.fileCount) === 0 || nonNegativeInteger$1(input.total) === 0)) return null;
20750
+ return {
20751
+ kind: "search",
20752
+ shape: "paths",
20753
+ paths: [],
20754
+ truncated: false,
20755
+ total: reportedTotal(input.total, 0)
20756
+ };
20757
+ }
20758
+ const raw = listed.filter((entry) => typeof entry === "string");
20759
+ if (raw.length === 0) return null;
20760
+ const total = reportedTotal(input.total, raw.length);
20761
+ const retained = capTail(raw, SEARCH_META_MAX_BYTES, (kept) => ({
20762
+ kind: "search",
20763
+ shape: "paths",
20764
+ paths: kept,
20765
+ truncated: true,
20766
+ total
20767
+ }));
20768
+ if (retained.length === 0) return null;
20769
+ return {
20770
+ kind: "search",
20771
+ shape: "paths",
20772
+ paths: retained,
20773
+ truncated: input.truncated === true || retained.length < total,
20774
+ total
20775
+ };
20776
+ }
20777
+ //#endregion
19892
20778
  //#region src/tools/grep/adapter/index.ts
19893
20779
  /** Build the OMP ToolSession facade over a DSH exec context. */
19894
20780
  function createToolSession$3(exec, cfg) {
@@ -19921,7 +20807,9 @@ function toGrepToolResult(result) {
19921
20807
  ...typeof details.fileCount === "number" ? { fileCount: details.fileCount } : {},
19922
20808
  ...Array.isArray(details.files) ? { files: details.files } : {},
19923
20809
  ...typeof details.scopePath === "string" ? { scopePath: details.scopePath } : {},
19924
- ...details.truncated === true ? { truncated: true } : {}
20810
+ ...details.truncated === true ? { truncated: true } : {},
20811
+ ...typeof details.displayContent === "string" ? { displayContent: details.displayContent } : {},
20812
+ ...typeof details.cwd === "string" ? { cwd: details.cwd } : {}
19925
20813
  };
19926
20814
  }
19927
20815
  /**
@@ -19988,18 +20876,16 @@ function registerGrep(ctx, getConfig) {
19988
20876
  items: { type: "string" }
19989
20877
  },
19990
20878
  scopePath: { type: "string" },
19991
- truncated: { type: "boolean" }
20879
+ truncated: { type: "boolean" },
20880
+ displayContent: { type: "string" },
20881
+ cwd: { type: "string" }
19992
20882
  }
19993
20883
  },
19994
20884
  render: (_args, value) => [{
19995
20885
  type: "text",
19996
20886
  text: String(value.text ?? "")
19997
20887
  }],
19998
- presentationMeta: (_args, value) => ({
19999
- ...typeof value.matchCount === "number" ? { matches: value.matchCount } : {},
20000
- ...typeof value.fileCount === "number" ? { files: value.fileCount } : {},
20001
- ...value.scopePath !== void 0 ? { path: value.scopePath } : {}
20002
- })
20888
+ presentationMeta: (_args, value) => searchMatchesCardMeta(value) ?? null
20003
20889
  },
20004
20890
  async execute(args, exec) {
20005
20891
  return executeGrepTool(exec, getConfig(), args, ctx);
@@ -20132,7 +21018,7 @@ var GlobTool = class {
20132
21018
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
20133
21019
  const formatMatchPath = (matchPath, base, fileType) => {
20134
21020
  const hadTrailingSlash = matchPath.endsWith("/") || matchPath.endsWith("\\");
20135
- return formatPathRelativeToCwd(path.isAbsolute(matchPath) ? matchPath : path.resolve(base, matchPath), this.session.cwd, { trailingSlash: fileType === natives.FileType.Dir || hadTrailingSlash });
21021
+ return formatPathRelativeToCwd(path$1.isAbsolute(matchPath) ? matchPath : path$1.resolve(base, matchPath), this.session.cwd, { trailingSlash: fileType === natives.FileType.Dir || hadTrailingSlash });
20136
21022
  };
20137
21023
  const missingPathsNote = missingPaths.length > 0 ? `Skipped missing paths: ${missingPaths.join(", ")}` : void 0;
20138
21024
  const buildResult = (files, opts) => {
@@ -20342,7 +21228,8 @@ function toGlobToolResult(result, args) {
20342
21228
  path: String(details.scopePath ?? args.path ?? ""),
20343
21229
  text: text + notice,
20344
21230
  ...typeof details.fileCount === "number" ? { fileCount: details.fileCount } : {},
20345
- ...typeof details.truncated === "boolean" ? { truncated: details.truncated } : {}
21231
+ ...typeof details.truncated === "boolean" ? { truncated: details.truncated } : {},
21232
+ ...Array.isArray(details.files) ? { paths: details.files.filter((entry) => typeof entry === "string") } : {}
20346
21233
  };
20347
21234
  }
20348
21235
  /**
@@ -20399,17 +21286,18 @@ function registerGlob(ctx, getConfig) {
20399
21286
  required: true
20400
21287
  },
20401
21288
  fileCount: { type: "number" },
20402
- truncated: { type: "boolean" }
21289
+ truncated: { type: "boolean" },
21290
+ paths: {
21291
+ type: "array",
21292
+ items: { type: "string" }
21293
+ }
20403
21294
  }
20404
21295
  },
20405
21296
  render: (_args, value) => [{
20406
21297
  type: "text",
20407
21298
  text: String(value.text ?? "")
20408
21299
  }],
20409
- presentationMeta: (_args, value) => ({
20410
- ...value.fileCount !== void 0 ? { files: value.fileCount } : {},
20411
- ...value.truncated !== void 0 ? { truncated: value.truncated } : {}
20412
- })
21300
+ presentationMeta: (_args, value) => searchPathsCardMeta(value) ?? null
20413
21301
  },
20414
21302
  async execute(args, exec) {
20415
21303
  return executeGlobTool(exec, getConfig(), args, ctx);
@@ -20462,16 +21350,16 @@ async function runAstEditTargets(targets, commonBasePath, options) {
20462
21350
  applied = applied && targetResult.applied;
20463
21351
  if (targetResult.parseErrors) parseErrors.push(...targetResult.parseErrors);
20464
21352
  for (const change of targetResult.changes) {
20465
- const absolute = path.resolve(target.basePath, change.path);
20466
- const rebased = path.relative(commonBasePath, absolute).replace(/\\/g, "/");
21353
+ const absolute = path$1.resolve(target.basePath, change.path);
21354
+ const rebased = path$1.relative(commonBasePath, absolute).replace(/\\/g, "/");
20467
21355
  aggregatedChanges.push({
20468
21356
  ...change,
20469
21357
  path: rebased
20470
21358
  });
20471
21359
  }
20472
21360
  for (const fileChange of targetResult.fileChanges) {
20473
- const absolute = path.resolve(target.basePath, fileChange.path);
20474
- const rebased = path.relative(commonBasePath, absolute).replace(/\\/g, "/");
21361
+ const absolute = path$1.resolve(target.basePath, fileChange.path);
21362
+ const rebased = path$1.relative(commonBasePath, absolute).replace(/\\/g, "/");
20475
21363
  fileCounts.set(rebased, (fileCounts.get(rebased) ?? 0) + fileChange.count);
20476
21364
  }
20477
21365
  }
@@ -20670,7 +21558,7 @@ var AstEditTool = class {
20670
21558
  if (useHashLines) {
20671
21559
  const snapshotStore = getFileSnapshotStore(this.session);
20672
21560
  for (const relativePath of fileList) {
20673
- const absolutePath = path.resolve(this.session.cwd, relativePath);
21561
+ const absolutePath = path$1.resolve(this.session.cwd, relativePath);
20674
21562
  try {
20675
21563
  const fullText = normalizeToLF(await Bun.file(absolutePath).text());
20676
21564
  const tag = snapshotStore.recordSnapshot(canonicalSnapshotKey(absolutePath), fullText);
@@ -20762,7 +21650,7 @@ var AstEditTool = class {
20762
21650
  if (useHashLines) {
20763
21651
  const snapshotStore = getFileSnapshotStore(this.session);
20764
21652
  for (const relativePath of appliedFileList) {
20765
- const appliedAbsolutePath = path.resolve(this.session.cwd, relativePath);
21653
+ const appliedAbsolutePath = path$1.resolve(this.session.cwd, relativePath);
20766
21654
  try {
20767
21655
  const fullText = normalizeToLF(await Bun.file(appliedAbsolutePath).text());
20768
21656
  const freshTag = snapshotStore.recordSnapshot(canonicalSnapshotKey(appliedAbsolutePath), fullText);
@@ -20814,6 +21702,116 @@ var AstEditTool = class {
20814
21702
  }
20815
21703
  };
20816
21704
  //#endregion
21705
+ //#region src/web/host/ast-edit.ts
21706
+ /**
21707
+ * `ast_edit` tool card: project the engine's own change preview plus its
21708
+ * counters into the `{kind:'ast_edit'}` meta this plugin's browser half
21709
+ * consumes (plan §4.3).
21710
+ *
21711
+ * The engine renders a preview (`displayContent`: grouped hits with a `│` line
21712
+ * gutter) for exactly the replacements it found, so the card reuses that text
21713
+ * verbatim instead of sampling `before`/`after`. The preview is bounded by
21714
+ * {@link AST_EDIT_META_MAX_BYTES}: past that budget the text is truncated (the
21715
+ * file list and counters survive), and if even an empty preview cannot fit the
21716
+ * payload, trailing files are dropped.
21717
+ *
21718
+ * Zero-dependency by design (no React, no Node built-ins beyond the contract's
21719
+ * own helpers), so the Node half can import it from `presentationMeta` without
21720
+ * dragging anything into the browser bundle.
21721
+ *
21722
+ * Defensive: a card never throws and never emits `undefined` values (the host
21723
+ * rejects non-lossless metadata and turns a successful call into an error); no
21724
+ * replacement, or no preview text, projects to `null` so the row falls back to
21725
+ * the generic shell.
21726
+ *
21727
+ * @module @xiaoso/dsh-tool-plus/web/host/ast-edit
21728
+ */
21729
+ /** `value` as a plain record, or `null` (arrays, `null` and primitives drop). */
21730
+ function asRecord(value) {
21731
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
21732
+ }
21733
+ /** Non-negative integer, or `null`. */
21734
+ function nonNegativeInteger(value) {
21735
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
21736
+ }
21737
+ /** Non-blank string, or `null`. */
21738
+ function nonBlankString(value) {
21739
+ return typeof value === "string" && value.trim() !== "" ? value : null;
21740
+ }
21741
+ /** The engine's `fileReplacements[{path,count}]`, malformed entries dropped. */
21742
+ function projectFiles(value) {
21743
+ if (!Array.isArray(value)) return [];
21744
+ const files = [];
21745
+ for (const entry of value) {
21746
+ const file = asRecord(entry);
21747
+ if (file === null) continue;
21748
+ const path = nonBlankString(file.path);
21749
+ const count = nonNegativeInteger(file.count);
21750
+ if (path === null || count === null) continue;
21751
+ files.push({
21752
+ path,
21753
+ count
21754
+ });
21755
+ }
21756
+ return files;
21757
+ }
21758
+ /**
21759
+ * Total replacements: the engine's own count when present, else the file
21760
+ * counts' sum (a missing array then counts as zero).
21761
+ */
21762
+ function replacementsOf(record, files) {
21763
+ const reported = nonNegativeInteger(record.totalReplacements);
21764
+ if (reported !== null) return reported;
21765
+ return files.reduce((total, file) => total + file.count, 0);
21766
+ }
21767
+ /**
21768
+ * Build the payload within {@link AST_EDIT_META_MAX_BYTES}.
21769
+ *
21770
+ * Trailing files are dropped first (the counters survive), then the preview
21771
+ * gets every remaining byte. The preview budget is found by binary search over
21772
+ * `capText` — the raw text is measured in UTF-8 bytes while the payload is
21773
+ * measured as JSON, and escaping can inflate the latter, so spending the exact
21774
+ * cap on text is not enough.
21775
+ */
21776
+ function fitAstEditMeta(raw, files, replacements, applied) {
21777
+ const build = (preview, retained) => ({
21778
+ kind: "ast_edit",
21779
+ preview,
21780
+ files: [...retained],
21781
+ replacements,
21782
+ applied
21783
+ });
21784
+ const retained = capTail(files, AST_EDIT_META_MAX_BYTES, (kept) => build("", kept));
21785
+ const fits = (budget) => jsonByteLength(build(capText(raw, budget), retained)) <= AST_EDIT_META_MAX_BYTES;
21786
+ if (!fits(0)) return null;
21787
+ let low = 0;
21788
+ let high = AST_EDIT_META_MAX_BYTES;
21789
+ while (low < high) {
21790
+ const mid = Math.ceil((low + high) / 2);
21791
+ if (fits(mid)) low = mid;
21792
+ else high = mid - 1;
21793
+ }
21794
+ return build(capText(raw, low), retained);
21795
+ }
21796
+ /**
21797
+ * Project the ast_edit engine's result into card metadata.
21798
+ * @param details - the engine's `displayContent` / `fileReplacements` /
21799
+ * `totalReplacements` / `applied` fields (the adapter's value carries the
21800
+ * same names).
21801
+ * @returns the ast_edit card metadata, or `null` when the call previews no
21802
+ * replacement or produced no preview text.
21803
+ */
21804
+ function projectAstEditCardMeta(details) {
21805
+ const record = asRecord(details);
21806
+ if (record === null) return null;
21807
+ const preview = nonBlankString(record.displayContent);
21808
+ if (preview === null) return null;
21809
+ const files = projectFiles(record.fileReplacements);
21810
+ const replacements = replacementsOf(record, files);
21811
+ if (replacements <= 0) return null;
21812
+ return fitAstEditMeta(preview, files, replacements, record.applied === true);
21813
+ }
21814
+ //#endregion
20817
21815
  //#region src/tools/ast-edit/adapter/index.ts
20818
21816
  /**
20819
21817
  * Build the OMP ToolSession facade over a DSH exec context.
@@ -20850,6 +21848,18 @@ function toText$1(result) {
20850
21848
  return text;
20851
21849
  }
20852
21850
  /**
21851
+ * ast_edit 卡片 value 字段(output.schema 镜像)。预览已按 AST_EDIT_META_MAX_BYTES
21852
+ * 在投影里截断,这里只做搬运,保证 meta 里不出现 undefined。
21853
+ */
21854
+ function astEditCardFields(card) {
21855
+ return {
21856
+ preview: card.preview,
21857
+ files: card.files,
21858
+ replacements: card.replacements,
21859
+ applied: card.applied
21860
+ };
21861
+ }
21862
+ /**
20853
21863
  * Register the ast_edit tool. Argument shape matches OMP verbatim:
20854
21864
  * `ops` is a non-empty array of `{ pat, out }` rewrite rules, and `paths` is a
20855
21865
  * non-empty array of files, directories, globs, or internal URLs.
@@ -20891,15 +21901,44 @@ function registerAstEdit(ctx, getConfig) {
20891
21901
  schema: {
20892
21902
  type: "object",
20893
21903
  additionalProperties: false,
20894
- properties: { text: {
20895
- type: "string",
20896
- required: true
20897
- } }
21904
+ properties: {
21905
+ text: {
21906
+ type: "string",
21907
+ required: true
21908
+ },
21909
+ preview: { type: "string" },
21910
+ files: {
21911
+ type: "array",
21912
+ items: {
21913
+ type: "object",
21914
+ additionalProperties: false,
21915
+ properties: {
21916
+ path: {
21917
+ type: "string",
21918
+ required: true
21919
+ },
21920
+ count: {
21921
+ type: "integer",
21922
+ required: true
21923
+ }
21924
+ }
21925
+ }
21926
+ },
21927
+ replacements: { type: "integer" },
21928
+ applied: { type: "boolean" }
21929
+ }
20898
21930
  },
20899
21931
  render: (_args, value) => [{
20900
21932
  type: "text",
20901
21933
  text: String(value.text ?? "")
20902
- }]
21934
+ }],
21935
+ presentationMeta: (_args, value) => typeof value?.preview === "string" && value.preview !== "" ? {
21936
+ kind: "ast_edit",
21937
+ preview: value.preview,
21938
+ files: Array.isArray(value.files) ? value.files : [],
21939
+ replacements: typeof value.replacements === "number" ? value.replacements : 0,
21940
+ applied: value.applied === true
21941
+ } : null
20903
21942
  },
20904
21943
  async execute(args, exec) {
20905
21944
  const session = createToolSession$1(exec, getConfig());
@@ -20907,8 +21946,27 @@ function registerAstEdit(ctx, getConfig) {
20907
21946
  ops: Array.isArray(args.ops) ? args.ops : [],
20908
21947
  paths: Array.isArray(args.paths) ? args.paths : []
20909
21948
  }, exec.signal);
20910
- if (session.pendingInvoker) return { text: toText$1(await session.pendingInvoker()) };
20911
- return { text: toText$1(result) };
21949
+ const previewDetails = result.details ?? {};
21950
+ if (session.pendingInvoker) {
21951
+ const applied = await session.pendingInvoker();
21952
+ const resolveDetails = applied.details ?? {};
21953
+ const appliedDetails = typeof resolveDetails.sourceResultDetails === "object" && resolveDetails.sourceResultDetails !== null ? resolveDetails.sourceResultDetails : resolveDetails;
21954
+ const card = projectAstEditCardMeta({
21955
+ ...appliedDetails,
21956
+ displayContent: appliedDetails.displayContent ?? previewDetails.displayContent
21957
+ });
21958
+ const text = toText$1(applied);
21959
+ return card === null ? { text } : {
21960
+ text,
21961
+ ...astEditCardFields(card)
21962
+ };
21963
+ }
21964
+ const card = projectAstEditCardMeta(previewDetails);
21965
+ const text = toText$1(result);
21966
+ return card === null ? { text } : {
21967
+ text,
21968
+ ...astEditCardFields(card)
21969
+ };
20912
21970
  },
20913
21971
  presentCall: (args) => ({
20914
21972
  card: "generic",
@@ -20977,8 +22035,8 @@ async function runMultiTargetAstGrep(targets, options) {
20977
22035
  limitReached = limitReached || targetResult.limitReached;
20978
22036
  if (targetResult.parseErrors) parseErrors.push(...targetResult.parseErrors);
20979
22037
  for (const match of targetResult.matches) {
20980
- const absolute = path.resolve(target.basePath, match.path);
20981
- const rebased = path.relative(options.commonBasePath, absolute).replace(/\\/g, "/");
22038
+ const absolute = path$1.resolve(target.basePath, match.path);
22039
+ const rebased = path$1.relative(options.commonBasePath, absolute).replace(/\\/g, "/");
20982
22040
  retainAstFindMatch(retainedMatches, retainedCapacity, {
20983
22041
  ...match,
20984
22042
  path: rebased
@@ -21127,7 +22185,7 @@ var AstGrepTool = class {
21127
22185
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
21128
22186
  const hashContexts = /* @__PURE__ */ new Map();
21129
22187
  if (useHashLines) for (const relativePath of fileList) {
21130
- const absolutePath = path.resolve(this.session.cwd, relativePath);
22188
+ const absolutePath = path$1.resolve(this.session.cwd, relativePath);
21131
22189
  const tag = await recordFileSnapshot(this.session, absolutePath);
21132
22190
  if (tag) hashContexts.set(relativePath, { tag });
21133
22191
  }
@@ -21160,7 +22218,7 @@ var AstGrepTool = class {
21160
22218
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
21161
22219
  }
21162
22220
  if (hashContext?.tag) {
21163
- const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
22221
+ const absoluteFilePath = path$1.resolve(this.session.cwd, relativePath);
21164
22222
  recordSeenLinesFromBody(this.session, absoluteFilePath, hashContext.tag, modelOut.join("\n"));
21165
22223
  }
21166
22224
  return {
@@ -21224,6 +22282,28 @@ function toText(result) {
21224
22282
  return text;
21225
22283
  }
21226
22284
  /**
22285
+ * 把 OMP AgentToolResult 转成 DSH ast_grep 工具输出。
22286
+ *
22287
+ * 此前适配层只回 `{text}`,卡片拿不到任何结构化数据;现在把引擎的
22288
+ * `displayContent`(分组显示文本)+ `files` + `matchCount` + `limitReached`
22289
+ * 一并带出,供 `presentationMeta` 投影成 search 卡(§4.6)。isError 仍抛错,
22290
+ * 报错文案不变。
22291
+ */
22292
+ function toAstGrepToolResult(result) {
22293
+ const text = toText(result);
22294
+ const details = result.details ?? {};
22295
+ return {
22296
+ text,
22297
+ ...typeof details.matchCount === "number" ? { matchCount: details.matchCount } : {},
22298
+ ...typeof details.fileCount === "number" ? { fileCount: details.fileCount } : {},
22299
+ ...Array.isArray(details.files) ? { files: details.files.filter((entry) => typeof entry === "string") } : {},
22300
+ ...typeof details.scopePath === "string" ? { scopePath: details.scopePath } : {},
22301
+ ...details.limitReached === true || details.truncated === true ? { truncated: true } : {},
22302
+ ...typeof details.displayContent === "string" ? { displayContent: details.displayContent } : {},
22303
+ ...typeof details.cwd === "string" ? { cwd: details.cwd } : {}
22304
+ };
22305
+ }
22306
+ /**
21227
22307
  * Register the ast_grep tool. Argument shape matches OMP verbatim:
21228
22308
  * `pat` is a required AST pattern; `path` optionally selects files,
21229
22309
  * directories, globs, or internal URLs (semicolon-delimited list); `skip`
@@ -21252,22 +22332,35 @@ function registerAstGrep(ctx, getConfig) {
21252
22332
  schema: {
21253
22333
  type: "object",
21254
22334
  additionalProperties: false,
21255
- properties: { text: {
21256
- type: "string",
21257
- required: true
21258
- } }
22335
+ properties: {
22336
+ text: {
22337
+ type: "string",
22338
+ required: true
22339
+ },
22340
+ matchCount: { type: "number" },
22341
+ fileCount: { type: "number" },
22342
+ files: {
22343
+ type: "array",
22344
+ items: { type: "string" }
22345
+ },
22346
+ scopePath: { type: "string" },
22347
+ truncated: { type: "boolean" },
22348
+ displayContent: { type: "string" },
22349
+ cwd: { type: "string" }
22350
+ }
21259
22351
  },
21260
22352
  render: (_args, value) => [{
21261
22353
  type: "text",
21262
22354
  text: String(value.text ?? "")
21263
- }]
22355
+ }],
22356
+ presentationMeta: (_args, value) => searchMatchesCardMeta(value) ?? null
21264
22357
  },
21265
22358
  async execute(args, exec) {
21266
- return { text: toText(await new AstGrepTool(createToolSession(exec, getConfig())).execute("ast_grep", {
22359
+ return toAstGrepToolResult(await new AstGrepTool(createToolSession(exec, getConfig())).execute("ast_grep", {
21267
22360
  pat: String(args.pat ?? ""),
21268
22361
  ...args.path !== void 0 ? { path: String(args.path) } : {},
21269
22362
  ...args.skip !== void 0 ? { skip: Number(args.skip) } : {}
21270
- }, exec.signal)) };
22363
+ }, exec.signal));
21271
22364
  },
21272
22365
  presentCall: (args) => ({
21273
22366
  card: "generic",
@@ -21540,7 +22633,8 @@ function apply(ctx, config = {}) {
21540
22633
  render: (_args, value) => [{
21541
22634
  type: "text",
21542
22635
  text: value.kind === "background" ? `Backgrounded as job ${value.jobId}; result will be delivered automatically when it finishes. Continue with other work — do not poll for it.` : renderBashResult(value)
21543
- }]
22636
+ }],
22637
+ presentationMeta: (_args, value) => bashCardMeta(value)
21544
22638
  },
21545
22639
  async execute(args, exec) {
21546
22640
  validateBashArgs(args);