dsh-mindmap 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -23,8 +23,19 @@ All notable changes to this project are documented here. Release-specific notes
23
23
 
24
24
  ### Fixed
25
25
 
26
+ - Compatibility with dsh 0.1.2-rc.1 (statically verified, docs/023), while keeping 0.1.1-rc.2 working — all fixes are dual-path with the old path first:
27
+ - **Host tools**: `Agent` no longer carries a live `session` (it is reduced to `{ id }`), so `sessionCwd` now falls back from `exec.agent.session.header.cwd` (dsh ≤0.1.1) to looking the session up via `ctx.sessions.get(exec.agent.id).header.cwd` (0.1.2-rc.1). Without this, every tool failed with "the session has no working directory" on 0.1.2-rc.1.
28
+ - **Live panel data**: the session snapshot no longer carries a flat `nodes` array (conversation content moved to the Chat view), so the panel now reads nodes via `useChat → ChatSnapshot.legacy.nodes` (0.1.2-rc.1+) first and falls back to `useSession → SessionSnapshot.nodes` (dsh ≤0.1.1). `ToolResultNode` field names are unchanged across both, so document replay, auto-open, and error surfacing work as before.
29
+ - **Settings panel**: `settings.describe` over the client connection now returns the descriptor array directly (0.1.2-rc.1+) instead of an aggregate `result.value.namespaces` (dsh ≤0.1.1); both envelope shapes are parsed.
30
+ - **Hygiene**: `dsh.client.inject` additionally lists `@deepseek-ai/dsh-cordis-client-runner` (the 0.1.2-rc.1 browser runtime module id; `@deepseek-ai/dsh-client-runtime` is kept for 0.1.1-rc.2 — a missing id is silently skipped by the loader, so both coexist safely).
26
31
  - The panel now reliably auto-opens when the AI completes `mindmap_open` / `mindmap_create`. A structural fingerprint of the session nodes (`nodesFingerprint`) feeds a second `useSession` selector; its value comparison bypasses the reference-equality short-circuit that starved the auto-open effect whenever the host store mutated the nodes array in place.
27
32
  - The "AI 正在打开脑图…" loading state is no longer a dead end. Snapshot documents whose path differs from the tree-click key only by letter case (macOS case-insensitive filesystem) now merge automatically; errored mindmap tool results (`isError` or `ok !== true`) surface as an inline error; a ~30s watchdog switches to a timeout state. Both failure states offer a one-click retry that re-sends the open request.
33
+ - A code-review sweep (`_issues/002`) landed 11 fixes, each with a failing regression test first:
34
+ - **Markdown parsing**: an unclosed `---` frontmatter fence at the top no longer swallows the whole document into an empty tree; the root-title echo merge (`firstTopH1Seen`) is set only by a top-level H1 so an earlier H2 or a heading inside a quote block no longer suppresses it; GFM table column count is pinned to the **separator row** (short rows padded, extra cells dropped) instead of the header; fenced code blocks close only on a matching fence type (`~~~` is no longer ended by a ``` line) and `parseTableRow` honors an escaped `\|` preceded by an escaped backslash (`\\|`).
35
+ - **Document snapshots**: `mergeDocuments` only deletes a dropped local entry when no live snapshot owns the same path, so a recreated file that a snapshot also references keeps its panel openable.
36
+ - **Link rendering**: the bare-URL pattern no longer swallows CJK punctuation/full-width characters (`https://a.com,然后…` stops at the comma); `[text](url)` and bare links render as plain text unless the scheme is http/https/mailto (blocking `javascript:`/`data:`), and `openLink` calls `preventDefault` only when `window.open` actually succeeds, so host-blocked popups fall back to default navigation instead of a dead click.
37
+ - **Host safety & approval**: the tree route and all mindmap path resolutions now reject symlink escapes out of the working directory via a `realpath` walk-up (`resolvesInsideBase`); `requireApproval` gates `mindmap_create` as well as `mindmap_update`; and `listDirectoryLevel` stops iterating once it hits the entry cap (perf) while still flagging truncation.
38
+ - **PNG export**: wide tables measure their box height using the same clamped per-column width the renderer uses, so wrapped cell text no longer overflows the measured box bottom.
28
39
 
29
40
  ## [0.4.1] - 2026-08-24
30
41
 
package/README.md CHANGED
@@ -23,9 +23,9 @@ A DeepSeek Harness plugin that turns a plain Markdown file into a live mindmap.
23
23
  - **Single-mindmap mode** — two tabs only: 目录 (tree) and 脑图 (the current mindmap); opening another `.md` replaces the previous one.
24
24
  - **"What you see is what the AI edits"** — when the visible mindmap differs from the AI's working document, the panel automatically asks the AI to open it, keeping the chat focus in sync.
25
25
  - **MarkGrove-style mapping** — heading hierarchy, nested lists (empty items become placeholder nodes), code blocks as leaf nodes, paragraphs as node notes, stable structural IDs, and orthogonal connector lines between nodes.
26
- - **Centered canvas with zoom** — the mindmap opens centered in the canvas (scrollable without edge clipping when larger); a floating zoom bar at the canvas top-right (zoom out / percent / zoom in / fit) applies auto fit-to-view on open (small maps stay at 100%), steps through 25%–300% with a stable view center, and keeps re-fitting as the AI edits — until you zoom manually. Click any node to zoom in on it and its whole subtree, with the node pinned at the left-center of the canvas.
26
+ - **Centered canvas with zoom and pan** — the mindmap opens centered in the canvas (scrollable without edge clipping when larger); a floating zoom bar at the canvas top-right (zoom out / percent / zoom in / fit) applies auto fit-to-view on open (small maps stay at 100%), steps through 25%–300% with a stable view center, and keeps re-fitting as the AI edits — until you zoom manually. Click any node to zoom in on it and its whole subtree, with the node pinned at the left-center of the canvas. The canvas also pans by drag: the **middle button** anywhere (even over a node), the **left button on blank canvas** (the Mac trackpad「click and drag」path), or **Space + left button** when the drag must start on a card. Content follows the pointer 1:1, blank space shows a grab hand, and a 4px threshold separates drag from click — so clicking blank space still clears the selection and clicking a node still focuses it, while a real drag never wipes the selection ring.
27
27
  - **PNG export** — one click on 导出图片 exports the current mindmap.
28
- - **Safety** — `mindmap_update` is approval-free by default (files are git-managed) with a `requireApproval` switch as an escape hatch; the client has **no write path** to the filesystem — every edit goes through the AI tools.
28
+ - **Safety** — `mindmap_create` / `mindmap_update` are approval-free by default (files are git-managed) with a `requireApproval` switch as an escape hatch (when on, both write tools ask); the client has **no write path** to the filesystem — every edit goes through the AI tools.
29
29
 
30
30
  ## Requirements
31
31
 
package/README.zh-CN.md CHANGED
@@ -23,9 +23,9 @@
23
23
  - **单脑图模式**——面板只有「目录」和「脑图」两个 tab,打开新脑图替换旧的那颗。
24
24
  - **「所见即所编」焦点同步**——可见脑图与 AI 工作文档不一致时,面板自动让 AI 打开它,聊天焦点始终跟随你的眼睛。
25
25
  - **MarkGrove 同款映射与连线**——标题层级挂树、列表缩进(空项 = 占位节点)、代码块叶节点、段落挂节点说明、稳定结构 ID、节点间直角折线。
26
- - **画布居中与缩放**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。
26
+ - **画布居中、缩放与平移**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。画布还能拖着走:**中键**随处拖(含节点上)、**空白处左键拖**(Mac 触控板「点按并拖移」)、或 **Space + 左键拖**(必须从节点上起手时);内容跟手 1:1,空白处呈抓手光标,4px 阈值把拖与点分开——点空白仍取消选中、点节点仍聚焦,真拖不会误清选中圈。
27
27
  - **PNG 导出**——面板右上角「导出图片」一键导出当前脑图。
28
- - **安全**——`mindmap_update` 默认免原生审批(文件受 git 管控),留 `requireApproval` 开关作后悔药;客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
28
+ - **安全**——`mindmap_create`/`mindmap_update` 默认免原生审批(文件受 git 管控),留 `requireApproval` 开关作后悔药(开启后两个写工具都走审批);客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
29
29
 
30
30
  ## 环境要求
31
31
 
package/client.js CHANGED
@@ -7,9 +7,10 @@
7
7
  // - 脑图面板:014 起注册在 shell.overlay(list 槽、root scope、点击穿透层),
8
8
  // 右缘贴边全高悬浮、左缘拖拽调宽(280~80% 视口,localStorage 持久化);
9
9
  // details 槽已归还官方(原生「工具详情」栏恢复,003 的顶替方案退役)。
10
- // - 实时数据通路:消费会话快照(useSession → nodes)里 mindmap_* 工具的
11
- // ToolResultNode,重放出各文档的最新内容并渲染(002/003:无自定义事件通道,
12
- // 工具调用本身就是事件流)。
10
+ // - 实时数据通路:消费会话快照里 mindmap_* 工具的 ToolResultNode,重放出各文档
11
+ // 的最新内容并渲染(002/003:无自定义事件通道,工具调用本身就是事件流)。
12
+ // 023 双代:useChat → ChatSnapshot.legacy.nodes(dsh 0.1.2-rc.1+)优先,
13
+ // useSession → SessionSnapshot.nodes(dsh ≤0.1.1)兜底。
13
14
  // - markdown→脑图树:本文件内置零依赖解析器(MarkGrove mdastConverter 的映射
14
15
  // 语法移植:标题栈→树、列表→子节点、空列表项=占位节点、代码块→首行摘要叶
15
16
  // 节点、段落→挂标题的正文说明、结构路径稳定 ID)。
@@ -287,11 +288,12 @@ window.__ModuleLoader__.load({
287
288
  }
288
289
 
289
290
  /** 表格行 → 单元格数组(去首尾空段,保留中间空单元格)。
290
- * 支持 GFM 转义:`\|` 是字面竖线(占位符避位,剥标记后还原),不切单元格。 */
291
+ * 支持 GFM 转义:`\|` 是字面竖线(占位符避位,剥标记后还原),不切单元格;
292
+ * `\\|` 则是已转义的反斜杠 + 真分隔符,照常切开。 */
291
293
  function parseTableRow(line) {
292
294
  return String(line ?? "").trim()
293
295
  .replace(/^\|/, "").replace(/\|$/, "")
294
- .replace(/\\\|/g, "\u0000")
296
+ .replace(/(?<!\\)((?:\\\\)*)\\\|/g, "$1\u0000")
295
297
  .split("|")
296
298
  .map((c) => c.replace(/\u0000/g, "|").trim());
297
299
  }
@@ -333,13 +335,16 @@ window.__ModuleLoader__.load({
333
335
  data: {},
334
336
  };
335
337
  // 根标题回声标记:记录文档常以文件名作首行 H1(如 "# 002-spike结论.md"),
336
- // 而根节点标题就是文件名——首个 H1 与根标题一致(或仅多 .md 后缀)时
337
- // 并入根节点,避免标题显示两次。仅文档顶层参与回声(引用内不算)。
338
- let firstHeadingSeen = false;
338
+ // 而根节点标题就是文件名——首个顶层 H1 与根标题一致(或仅多 .md 后缀)
339
+ // 时并入根节点,避免标题显示两次。只有顶层 H1 参与回声:H2 等低级标题
340
+ // 不消耗名额,引用块递归(echoRoot=false)也不触碰本标记。
341
+ let firstTopH1Seen = false;
339
342
  const lines = String(markdown ?? "").split(/\r?\n/);
340
343
 
341
344
  let start = 0;
342
- // 跳过 YAML frontmatter(--- ... ---)
345
+ // 跳过 YAML frontmatter(--- ... ---):找不到闭合行说明不是
346
+ // frontmatter(如以水平线开头的合法文档),回退普通解析,
347
+ // 否则整篇会被吞成空树。
343
348
  if (lines.length > 0 && /^\s*---\s*$/.test(lines[0])) {
344
349
  for (start = 1; start < lines.length; start++) {
345
350
  if (/^\s*---\s*$/.test(lines[start])) {
@@ -347,6 +352,7 @@ window.__ModuleLoader__.load({
347
352
  break;
348
353
  }
349
354
  }
355
+ if (start >= lines.length) start = 0;
350
356
  }
351
357
 
352
358
  /**
@@ -393,12 +399,16 @@ window.__ModuleLoader__.load({
393
399
 
394
400
  // 围栏代码块:整块成为一个叶节点,标题 = [语言] 首行摘要(盒内紧凑,
395
401
  // 悬停浮层看全文——003 §5.3;data.code 全量保存)。
396
- if (/^\s*(```|~~~)/.test(line)) {
402
+ // GFM:闭合围栏须同字符且不少于开启长度——``` 块里的 ~~~、
403
+ // ```` 块里的 ``` 都是代码内容,不是围栏。
404
+ const fence = /^\s*(`{3,}|~{3,})/.exec(line);
405
+ if (fence) {
397
406
  flushParagraph();
398
407
  listStack = [];
399
- const lang = line.trim().slice(3).trim();
408
+ const lang = line.trim().slice(fence[1].length).trim();
409
+ const fenceClose = new RegExp(`^\\s*${fence[1][0]}{${fence[1].length},}\\s*$`);
400
410
  const buf = [];
401
- for (i += 1; i < lineList.length && !/^\s*(```|~~~)/.test(lineList[i]); i++) buf.push(lineList[i]);
411
+ for (i += 1; i < lineList.length && !fenceClose.test(lineList[i]); i++) buf.push(lineList[i]);
402
412
  const code = buf.join("\n");
403
413
  const firstLine = (code.split("\n")[0] || "").trim();
404
414
  const summary = firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
@@ -419,12 +429,15 @@ window.__ModuleLoader__.load({
419
429
  listStack = [];
420
430
  const level = heading[1].length;
421
431
  const text = heading[2].trim() || "(无标题)";
422
- // 首个 H1 与根标题一致(或仅多 .md 后缀)→ 并入根节点,不另建节点。
423
- if (echoRoot && !firstHeadingSeen && level === 1 && (text === root.topic || text === `${root.topic}.md`)) {
424
- firstHeadingSeen = true;
425
- continue;
432
+ // 首个顶层 H1 与根标题一致(或仅多 .md 后缀)→ 并入根节点,不另建节点。
433
+ // 名额只属于顶层 H1:低级标题先行、引用块内出现标题都不影响回声。
434
+ if (echoRoot && level === 1) {
435
+ if (!firstTopH1Seen && (text === root.topic || text === `${root.topic}.md`)) {
436
+ firstTopH1Seen = true;
437
+ continue;
438
+ }
439
+ firstTopH1Seen = true;
426
440
  }
427
- firstHeadingSeen = true;
428
441
  while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) headingStack.pop();
429
442
  const basePath = parentRec() ? parentRec().path : "";
430
443
  const node = {
@@ -475,19 +488,20 @@ window.__ModuleLoader__.load({
475
488
  flushParagraph();
476
489
  listStack = [];
477
490
  i = j - 1;
478
- const header = parseTableRow(rows[0]);
479
- // GFM 对齐契约:列数钉死在分隔行(=表头)。少列补空、
480
- // 多列截断——未转义竖线切碎的行顶多内容错位,网格永不参差。
491
+ // GFM 对齐契约:列数钉死在分隔行。表头与数据行同等待遇:
492
+ // 少列补空、多列截断——未转义竖线切碎的行顶多内容错位,网格永不参差。
493
+ const cols = parseTableRow(rows[1]).length;
481
494
  const toCols = (cells) => {
482
- if (cells.length >= header.length) return cells.slice(0, header.length);
483
- return cells.concat(new Array(header.length - cells.length).fill(""));
495
+ if (cells.length >= cols) return cells.slice(0, cols);
496
+ return cells.concat(new Array(cols - cells.length).fill(""));
484
497
  };
498
+ const header = toCols(parseTableRow(rows[0]));
485
499
  const body = rows.slice(2).map((row) => toCols(parseTableRow(row)));
486
500
  const tableRows = [header].concat(body);
487
501
  appendNode({
488
502
  id: idOf("table", tableRows.map((r) => r.join("\u0001")).join("\u0002"), parentPathOf()),
489
503
  kind: "table",
490
- topic: `${tableRows.length}×${header.length} 表格`,
504
+ topic: `${tableRows.length}×${cols} 表格`,
491
505
  children: [],
492
506
  data: { rows: tableRows },
493
507
  });
@@ -807,7 +821,9 @@ window.__ModuleLoader__.load({
807
821
  const lower = p.toLowerCase();
808
822
  if (snapPaths.some((sp) => sp.toLowerCase() === lower)) dropped.add(p);
809
823
  }
810
- for (const p of dropped) delete byPath[p];
824
+ // 只删本地条目;若该路径同时是存活快照文档
825
+ // (改名后又重建),快照保留,面板照常打开。
826
+ for (const p of dropped) if (!snapByPath[p]) delete byPath[p];
811
827
  const order = [...(snapshot?.order ?? [])];
812
828
  for (const p of Object.keys(localDocs)) {
813
829
  if (!snapByPath[p] && !dropped.has(p)) order.push(p);
@@ -997,14 +1013,16 @@ window.__ModuleLoader__.load({
997
1013
  return { text: stripInlineForExport(node.topic), lines: null };
998
1014
  }
999
1015
 
1000
- /** 019 盒尺寸估算:文本按折行行数生长;表格按行列数算网格尺寸。 */
1016
+ /** 019 盒尺寸估算:文本按折行行数生长;表格按行列数算网格尺寸。
1017
+ * 022:测量与渲染必须共用钳制后的列宽——先算盒宽再按 w/cols 折行,
1018
+ * 否则宽表(钳到 tableMaxW)按 110px 估行、渲染按更窄列宽折行,盒高不足。 */
1001
1019
  function measureExportBox(node) {
1002
1020
  if (node.kind === "table") {
1003
1021
  const rows = (node.data && node.data.rows) || [];
1004
1022
  const cols = rows.reduce((mx, row) => Math.max(mx, row.length), 0) || 1;
1005
- const cellInner = EXPORT.tableCellW - EXPORT.tableCellPad * 2;
1006
- const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), cellInner, EXPORT.fontSize - 1).length), 1));
1007
1023
  const w = Math.min(EXPORT.tableMaxW, Math.max(EXPORT.tableMinW, cols * EXPORT.tableCellW));
1024
+ const cellInner = w / cols - EXPORT.tableCellPad * 2;
1025
+ const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), cellInner, EXPORT.fontSize - 1).length), 1));
1008
1026
  const h = Math.max(EXPORT.lineHeight, rowLines.reduce((a, b) => a + b, 0) * EXPORT.lineHeight);
1009
1027
  return { w, h };
1010
1028
  }
@@ -1481,20 +1499,35 @@ window.__ModuleLoader__.load({
1481
1499
  ] });
1482
1500
  }
1483
1501
 
1502
+ /**
1503
+ * 会话内容节点的双代快照选择(023):dsh ≤0.1.1 的 useSession 快照带
1504
+ * 平铺 nodes;0.1.2-rc.1 起 SessionSnapshot 拆成纯控制状态,会话内容
1505
+ * 迁入 useChat 的 ChatSnapshot.legacy.nodes(官方兼容面,ToolResultNode
1506
+ * 字段同名)。legacy 优先、旧 nodes 兜底,两代通吃。
1507
+ */
1508
+ function conversationNodesOf(s) {
1509
+ if (!s) return EMPTY_NODES;
1510
+ const legacy = s.legacy;
1511
+ if (legacy && Array.isArray(legacy.nodes)) return legacy.nodes;
1512
+ return Array.isArray(s.nodes) ? s.nodes : EMPTY_NODES;
1513
+ }
1514
+
1484
1515
  /**
1485
1516
  * 「思维脑图」槽位组件(014):同一槽位渲染 M 按钮 + 悬浮面板宿主层。
1486
1517
  * session scope 的 useSession/sessionId/inputActions 直给,经 props 传给
1487
1518
  * MindmapDetailsPanel(无桥、无 useSyncExternalStore——shell.overlay 跨槽
1488
- * 方案实测未渲染,弃用后顺手把桥也删了)。
1519
+ * 方案实测未渲染,弃用后顺手把桥也删了)。023:内容钩子改为
1520
+ * useChat(0.1.2-rc.1+)优先、useSession(≤0.1.1)兜底。
1489
1521
  */
1490
1522
  function MindmapSlot(props) {
1491
- const { useSession, sessionId, inputActions, mindmapFace } = props;
1492
- const nodes = useSession ? useSession((s) => (s && s.nodes) || EMPTY_NODES) : EMPTY_NODES;
1523
+ const { useSession, useChat, sessionId, inputActions, mindmapFace } = props;
1524
+ const nodesHook = useChat ?? useSession;
1525
+ const nodes = nodesHook ? nodesHook(conversationNodesOf) : EMPTY_NODES;
1493
1526
  // 016 可靠性加固:结构指纹作第二 selector。store 原地改数组(引用
1494
1527
  // 不变)时,nodes prop 不换、memo 命中缓存、auto-open effect 永不
1495
1528
  // 重跑——「AI 打开了脑图但面板不展开」的根因。指纹是原始值字符串,
1496
- // 值比较天然绕过引用相等短路;useSession 不可用时回退空串。
1497
- const nodesVersion = useSession ? useSession((s) => nodesFingerprint((s && s.nodes) || EMPTY_NODES)) : "";
1529
+ // 值比较天然绕过引用相等短路;内容钩子不可用时回退空串。
1530
+ const nodesVersion = nodesHook ? nodesHook((s) => nodesFingerprint(conversationNodesOf(s))) : "";
1498
1531
  const [open, setOpen] = react.useState(false);
1499
1532
  return (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
1500
1533
  (0, react_jsx_runtime.jsxs)("button", {
@@ -1541,18 +1574,25 @@ window.__ModuleLoader__.load({
1541
1574
  //#region 019 血肉渲染:行内格式 + 大一统链接 + 表格块(规范源:003)
1542
1575
  // 行内格式统一扫描序:图片/链接 → 行内代码 → 粗体 → 删除线 → 斜体 → 裸链接。
1543
1576
  // 先命中先生效,裸链接放最后,避免吞掉已被 [文字](url) 消费的 URL。
1544
- const INLINE_PATTERN = /(!?\[[^\]]*\]\([^)]*\))|(`[^`]+`)|(\*\*[^*]+\*\*)|(~~[^~]+~~)|(\*[^*\s][^*]*\*)|(https?:\/\/[^\s)]+)/g;
1577
+ // 裸链接字符类排除 CJK 标点与全角符号(,。、;()……),
1578
+ // 否则中文句读被吞进 URL;ASCII 括号放行,由配平裁剪兜底。
1579
+ const INLINE_PATTERN = /(!?\[[^\]]*\]\([^)]*\))|(`[^`]+`)|(\*\*[^*]+\*\*)|(~~[^~]+~~)|(\*[^*\s][^*]*\*)|(https?:\/\/[^\s\u3000-\u303f\uff00-\uffef]+)/g;
1545
1580
 
1546
1581
  /** 大一统链接点击:在机器浏览器打开(新标签页),不触发画布聚焦缩放。 */
1547
1582
  function openLink(event, url) {
1548
- event.preventDefault();
1549
1583
  event.stopPropagation();
1584
+ // 只在 window.open 成功后 preventDefault:宿主拦截(返回 null 或
1585
+ // 抛错)时不拦,锚点自带的 target=_blank 原生导航接管——链接永远可达。
1586
+ // (旧版 catch 里给只读属性 defaultPrevented 赋值是死代码,拦了默认
1587
+ // 行为又开不了窗,链接彻底点不开。)
1588
+ let opened = null;
1550
1589
  try {
1551
- window.open(url, "_blank", "noopener");
1590
+ opened = window.open(url, "_blank", "noopener");
1552
1591
  } catch {
1553
- // 宿主环境拦截时退化为浏览器默认行为(不静默吞链接)。
1554
- event.defaultPrevented = false;
1592
+ opened = null;
1555
1593
  }
1594
+ if (!opened) return;
1595
+ event.preventDefault();
1556
1596
  }
1557
1597
 
1558
1598
  /**
@@ -1575,20 +1615,26 @@ window.__ModuleLoader__.load({
1575
1615
  // [文字](url) 或 ![alt](url)。图片块暂缓(003 §9):图语法退化为
1576
1616
  // 指向原图的链接,同时把 alt 与原图地址都完整呈现(不缩减)。
1577
1617
  const parsed = /^(!?)\[([^\]]*)\]\(([^)]*)\)$/.exec(token);
1578
- // 普通链接标签取文字(无文字显地址);图语法带 alt 时两者都完整呈现。
1579
- const label = parsed[1]
1580
- ? (parsed[2] ? `${parsed[2]} (${parsed[3]})` : parsed[3])
1581
- : (parsed[2] || parsed[3]);
1582
- out.push((0, react_jsx_runtime.jsx)("a", {
1583
- key,
1584
- href: parsed[3],
1585
- target: "_blank",
1586
- rel: "noopener noreferrer",
1587
- style: S.inlineLink,
1588
- title: parsed[3],
1589
- onClick: (e) => openLink(e, parsed[3]),
1590
- children: label,
1591
- }, key));
1618
+ // scheme 白名单:只放行 http/https/mailto。javascript:/data:
1619
+ // 等不进 href,整串原样退化为纯文本(不缩减,也不可执行)。
1620
+ if (!/^\s*(https?:|mailto:)/i.test(parsed[3])) {
1621
+ out.push(token);
1622
+ } else {
1623
+ // 普通链接标签取文字(无文字显地址);图语法带 alt 时两者都完整呈现。
1624
+ const label = parsed[1]
1625
+ ? (parsed[2] ? `${parsed[2]} (${parsed[3]})` : parsed[3])
1626
+ : (parsed[2] || parsed[3]);
1627
+ out.push((0, react_jsx_runtime.jsx)("a", {
1628
+ key,
1629
+ href: parsed[3],
1630
+ target: "_blank",
1631
+ rel: "noopener noreferrer",
1632
+ style: S.inlineLink,
1633
+ title: parsed[3],
1634
+ onClick: (e) => openLink(e, parsed[3]),
1635
+ children: label,
1636
+ }, key));
1637
+ }
1592
1638
  } else if (m[2]) {
1593
1639
  out.push((0, react_jsx_runtime.jsx)("code", { key, style: S.inlineCode, children: token.slice(1, -1) }, key));
1594
1640
  } else if (m[3]) {
@@ -1598,17 +1644,30 @@ window.__ModuleLoader__.load({
1598
1644
  } else if (m[5]) {
1599
1645
  out.push((0, react_jsx_runtime.jsx)("em", { key, children: token.slice(1, -1) }, key));
1600
1646
  } else {
1601
- // 裸链接:完整显示、可点击。
1647
+ // 裸链接:完整显示、可点击。维基式配平括号属于 URL;未配平的
1648
+ // 尾 ) 退回正文当纯文本——href 干净,可见文本不丢字符。
1649
+ let url = token;
1650
+ let opens = 0;
1651
+ let closes = 0;
1652
+ for (const ch of url) {
1653
+ if (ch === "(") opens += 1;
1654
+ else if (ch === ")") closes += 1;
1655
+ }
1656
+ while (closes > opens && url.endsWith(")")) {
1657
+ url = url.slice(0, -1);
1658
+ closes -= 1;
1659
+ }
1602
1660
  out.push((0, react_jsx_runtime.jsx)("a", {
1603
1661
  key,
1604
- href: token,
1662
+ href: url,
1605
1663
  target: "_blank",
1606
1664
  rel: "noopener noreferrer",
1607
1665
  style: S.inlineLink,
1608
- title: token,
1609
- onClick: (e) => openLink(e, token),
1610
- children: token,
1666
+ title: url,
1667
+ onClick: (e) => openLink(e, url),
1668
+ children: url,
1611
1669
  }, key));
1670
+ if (url.length < token.length) out.push(token.slice(url.length));
1612
1671
  }
1613
1672
  last = m.index + token.length;
1614
1673
  }
@@ -3227,6 +3286,19 @@ window.__ModuleLoader__.load({
3227
3286
  }
3228
3287
  //#endregion
3229
3288
 
3289
+ /**
3290
+ * settings describe 应答的双代信封解析(023):dsh ≤0.1.1 的远端把
3291
+ * 描述符聚合在 result.value.namespaces[];0.1.2-rc.1 起直接返回描述符
3292
+ * 数组(每项 {ns, schema, value, …},字段两代同名)。数组优先、
3293
+ * namespaces 兜底,两代通吃。
3294
+ */
3295
+ function settingsNamespacesOf(res) {
3296
+ const value = res?.result?.value;
3297
+ if (Array.isArray(value)) return value;
3298
+ const list = value?.namespaces;
3299
+ return Array.isArray(list) ? list : [];
3300
+ }
3301
+
3230
3302
  function apply(ctx) {
3231
3303
  const face = {};
3232
3304
 
@@ -3288,7 +3360,7 @@ window.__ModuleLoader__.load({
3288
3360
  face.readSettings = async () => {
3289
3361
  if (!settingsApi || typeof settingsApi.settings?.describe !== "function") return null;
3290
3362
  const res = await settingsApi.settings.describe({});
3291
- const namespaces = res?.result?.value?.namespaces ?? [];
3363
+ const namespaces = settingsNamespacesOf(res);
3292
3364
  const ns = namespaces.find((n) => n?.ns === "mindmap");
3293
3365
  return ns?.value ?? null;
3294
3366
  };
@@ -3352,6 +3424,8 @@ window.__ModuleLoader__.load({
3352
3424
  parseTableRow,
3353
3425
  nodeFullText,
3354
3426
  renderInline,
3427
+ // 链接点击:供测试验证开窗成功才拦默认行为(宿主拦截时退回原生导航)。
3428
+ openLink,
3355
3429
  stripInlineForExport,
3356
3430
  wrapExportText,
3357
3431
  COLOR_THEMES,
@@ -3368,6 +3442,9 @@ window.__ModuleLoader__.load({
3368
3442
  isActivatable,
3369
3443
  TOOL_NAMES,
3370
3444
  OPENING_OPS,
3445
+ // 023 双代兼容纯函数(供测试):会话内容节点 / settings 信封。
3446
+ conversationNodesOf,
3447
+ settingsNamespacesOf,
3371
3448
  // 021 画布组件:仅供测试驱动平移手势(不参与运行时契约)。
3372
3449
  MindmapCanvas,
3373
3450
  });
package/index.js CHANGED
@@ -9,12 +9,12 @@
9
9
  // - 四工具都带 path/name 参数(决策 3:多脑图并存,作用于指定那颗)。
10
10
  // - 结果 JSON {ok, op, path, rootTitle, content, renamedFrom?}:content 全文
11
11
  // 供模型续编辑,client 用同一份重放面板(工具结果即实时通道,002 第二节)。
12
- // - requireApproval 配置(决策 6):默认 false 免审批;置 true 时 mindmap_update
13
- // 走原生 ask(tools/pre-execute,照 dsh-grafana 的钩子模式)。015 起经 settings
14
- // namespace 可在设置面板运行时切换(见 SETTINGS_NAMESPACE/Config)。
12
+ // - requireApproval 配置(决策 6):默认 false 免审批;置 true 时 mindmap_create
13
+ // 与 mindmap_update 走原生 ask(tools/pre-execute,照 dsh-grafana 的钩子模式)。
14
+ // 015 起经 settings namespace 可在设置面板运行时切换(见 SETTINGS_NAMESPACE/Config)。
15
15
  // - 依赖:仅 @deepseek-ai/schemastery(settings schema;发布包正常解析,
16
16
  // link 开发需先 npm i)。工具参数 schema 仍手写 JSON Schema(003 偏差 1)。
17
- import { access, opendir, readFile, rename, stat, writeFile } from 'node:fs/promises'
17
+ import { access, opendir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises'
18
18
  import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path'
19
19
  import Schema from '@deepseek-ai/schemastery'
20
20
 
@@ -26,7 +26,7 @@ export const inject = ['tools', 'systemPrompt', 'webServer', 'sessions']
26
26
  // defaultPanelWidth 供客户端面板取默认宽度(20-80 钳制由客户端执行)。
27
27
  export const SETTINGS_NAMESPACE = 'mindmap'
28
28
  export const Config = Schema.object({
29
- requireApproval: Schema.boolean().default(false).description('Require native DSH approval for every mindmap_update (including renameRoot). Files are git-managed, so it defaults to off. Hidden from the settings panel; still honored at runtime.'),
29
+ requireApproval: Schema.boolean().default(false).description('Require native DSH approval for every mindmap_create and mindmap_update (including renameRoot). Files are git-managed, so it defaults to off. Hidden from the settings panel; still honored at runtime.'),
30
30
  defaultPanelWidth: Schema.number().default(42).description('Default floating-panel width as a percentage of the viewport (clamped 20-80 on the client).'),
31
31
  lineStyle: Schema.union(['curve', 'elbow']).default('elbow').description('Connector line style between nodes: curve (bezier) or elbow (orthogonal).'),
32
32
  cardStyle: Schema.union(['rounded', 'square']).default('rounded').description('Node card corner style.'),
@@ -65,9 +65,21 @@ function textOut(value) {
65
65
  return [{ type: 'text', text: String(value) }]
66
66
  }
67
67
 
68
- /** 会话工作目录:工具执行的 agent → session → header.cwd(dsh-session 契约)。 */
69
- function sessionCwd(exec) {
70
- return exec?.agent?.session?.header?.cwd
68
+ /**
69
+ * 会话工作目录:工具执行的 agent → session → header.cwd(dsh-session 契约)。
70
+ * 023 双路径:dsh ≤0.1.1 的 Agent 直挂 live session(agent.session.header.cwd);
71
+ * 0.1.2-rc.1 起 Agent 只剩 { id },改经 sessions 服务按 id 查 header.cwd
72
+ * (SessionStore.get / SessionHeader.cwd 两代同名)。旧链优先,新链兜底。
73
+ */
74
+ function sessionCwd(exec, sessions) {
75
+ const direct = exec?.agent?.session?.header?.cwd
76
+ if (direct) return direct
77
+ const id = exec?.agent?.id
78
+ if (id && sessions?.get) {
79
+ const cwd = sessions.get(id)?.header?.cwd
80
+ if (cwd) return cwd
81
+ }
82
+ return undefined
71
83
  }
72
84
 
73
85
  //#region 013 目录树 API(host 自建只读 HTTP 路由;dsh-better-sidebar 同款机制)
@@ -113,15 +125,44 @@ function escapesBase(rel) {
113
125
  return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)
114
126
  }
115
127
 
116
- /** 请求路径校验:缺省 = 根 cwd;显式路径必须绝对且落在 cwd 内。 */
117
- function resolveTreePath(cwd, input) {
128
+ /**
129
+ * realpath 包含性校验(#5):字符串规范化只挡字面 `..`,cwd 内指向外部
130
+ * 的符号链接能骗过它。尾部不存在的段(待建文件)向上走最近的存在祖先
131
+ * 逐个 realpath——符号链接只能藏在已存在的段里。解析后仍在 base 内返回
132
+ * true;base 自身不存在或越界返回 false。
133
+ */
134
+ async function resolvesInsideBase(resolved, base) {
135
+ let realBase
136
+ try {
137
+ realBase = await realpath(base)
138
+ } catch {
139
+ return false
140
+ }
141
+ let probe = resolved
142
+ for (;;) {
143
+ try {
144
+ const real = await realpath(probe)
145
+ if (real === realBase) return true
146
+ return !escapesBase(relative(realBase, real))
147
+ } catch (error) {
148
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error
149
+ const parent = dirname(probe)
150
+ if (parent === probe) return false
151
+ probe = parent
152
+ }
153
+ }
154
+ }
155
+
156
+ /** 请求路径校验:缺省 = 根 cwd;显式路径必须绝对、落在 cwd 内,
157
+ * 且解析符号链接后仍在内(#5)。 */
158
+ async function resolveTreePath(cwd, input) {
118
159
  if (!cwd) throw httpError(400, 'no-cwd', 'session has no working directory')
119
160
  if (input === undefined || input === null || String(input).trim() === '') return cwd
120
161
  const p = String(input).trim()
121
162
  if (!isAbsolute(p)) throw httpError(400, 'bad-request', `path must be absolute: ${JSON.stringify(p)}`)
122
163
  const resolved = resolvePath(p)
123
164
  const rel = relative(cwd, resolved)
124
- if (escapesBase(rel)) {
165
+ if (escapesBase(rel) || !(await resolvesInsideBase(resolved, cwd))) {
125
166
  throw httpError(400, 'bad-request', `path must stay inside the session working directory (${cwd})`)
126
167
  }
127
168
  return resolved
@@ -140,8 +181,10 @@ async function listDirectoryLevel(path, maxEntries = MAX_TREE_ENTRIES) {
140
181
  try {
141
182
  for await (const dirent of dir) {
142
183
  if (rows.length >= maxEntries) {
143
- overflow += 1
144
- continue
184
+ // 022:到达上限即停(旧实现 continue 会把巨型目录整个遍历一遍)。
185
+ // truncated 只取布尔语义,无需精确计数剩余条目。
186
+ overflow = 1
187
+ break
145
188
  }
146
189
  rows.push({
147
190
  name: dirent.name,
@@ -204,7 +247,7 @@ function sanitizeStem(input) {
204
247
  * 且必须以 .md 结尾。cwd 缺失时仅接受绝对路径。
205
248
  * @returns 绝对规范化路径。
206
249
  */
207
- function resolveMindmapPath(cwd, input) {
250
+ async function resolveMindmapPath(cwd, input) {
208
251
  if (typeof input !== 'string' || !input.trim()) throw new Error('path is required.')
209
252
  const p = input.trim()
210
253
  if (!/\.md$/i.test(p)) throw new Error(`mindmap path must end with .md: ${JSON.stringify(p)}.`)
@@ -214,7 +257,8 @@ function resolveMindmapPath(cwd, input) {
214
257
  }
215
258
  const resolved = resolvePath(cwd, p)
216
259
  const rel = relative(cwd, resolved)
217
- if (rel === '' || escapesBase(rel)) {
260
+ // realpath 兜底(#5):写路径经符号链接越狱是安全敏感操作。
261
+ if (rel === '' || escapesBase(rel) || !(await resolvesInsideBase(resolved, cwd))) {
218
262
  throw new Error(`mindmap path must stay inside the session working directory (${cwd}).`)
219
263
  }
220
264
  return resolved
@@ -288,10 +332,18 @@ export function apply(ctx, config = {}) {
288
332
 
289
333
  // 后悔药开关(决策 6):钩子常驻注册,运行时读 activeConfig().requireApproval
290
334
  // ——设置面板切换立即生效;关闭时直接放行(默认 false 免审批)。
335
+ // 022:create 同为写路径,一并纳入审批(CONTRIBUTING:所有写路径安全敏感)。
291
336
  ctx.on('tools/pre-execute', async (exec, next) => {
292
337
  const decision = await next()
293
338
  if (decision.kind !== 'allow') return decision
294
339
  if (!activeConfig().requireApproval) return decision
340
+ if (exec.name === 'mindmap_create') {
341
+ const args = exec.arguments ?? {}
342
+ return {
343
+ kind: 'ask',
344
+ reason: `Create mindmap ${JSON.stringify(String(args.name ?? '?'))}. dsh-mindmap is configured with requireApproval.`,
345
+ }
346
+ }
295
347
  if (exec.name !== 'mindmap_update') return decision
296
348
  const args = exec.arguments ?? {}
297
349
  const renameNote = typeof args.renameRoot === 'string' && args.renameRoot ? `, rename root to "${args.renameRoot}"` : ''
@@ -315,10 +367,10 @@ export function apply(ctx, config = {}) {
315
367
  output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
316
368
  timeoutMs: TOOL_TIMEOUT_MS,
317
369
  async execute(args, exec) {
318
- const cwd = sessionCwd(exec)
370
+ const cwd = sessionCwd(exec, ctx.sessions)
319
371
  if (!cwd) throw new Error('The session has no working directory; cannot create a mindmap.')
320
372
  const stem = sanitizeStem(args?.name)
321
- const path = resolveMindmapPath(cwd, `${stem}.md`)
373
+ const path = await resolveMindmapPath(cwd, `${stem}.md`)
322
374
  // wx = 不存在才创建:原子拒绝已存在(含并发竞态)与同名目录,无 TOCTOU 窗口。
323
375
  try {
324
376
  await writeFile(path, '', { encoding: 'utf8', flag: 'wx' })
@@ -345,7 +397,7 @@ export function apply(ctx, config = {}) {
345
397
  output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
346
398
  timeoutMs: TOOL_TIMEOUT_MS,
347
399
  async execute(args, exec) {
348
- const path = resolveMindmapPath(sessionCwd(exec), args?.path)
400
+ const path = await resolveMindmapPath(sessionCwd(exec, ctx.sessions), args?.path)
349
401
  const content = await readFile(path, 'utf8')
350
402
  return buildResult('open', path, { content })
351
403
  },
@@ -364,7 +416,7 @@ export function apply(ctx, config = {}) {
364
416
  output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
365
417
  timeoutMs: TOOL_TIMEOUT_MS,
366
418
  async execute(args, exec) {
367
- const path = resolveMindmapPath(sessionCwd(exec), args?.path)
419
+ const path = await resolveMindmapPath(sessionCwd(exec, ctx.sessions), args?.path)
368
420
  const content = await readFile(path, 'utf8')
369
421
  return buildResult('get', path, { content })
370
422
  },
@@ -385,8 +437,8 @@ export function apply(ctx, config = {}) {
385
437
  output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
386
438
  timeoutMs: TOOL_TIMEOUT_MS,
387
439
  async execute(args, exec) {
388
- const cwd = sessionCwd(exec)
389
- const path = resolveMindmapPath(cwd, args?.path)
440
+ const cwd = sessionCwd(exec, ctx.sessions)
441
+ const path = await resolveMindmapPath(cwd, args?.path)
390
442
  const hasContent = typeof args?.content === 'string'
391
443
  if (!hasContent && typeof args?.renameRoot !== 'string') {
392
444
  throw new Error('mindmap_update requires content (or renameRoot alone for a pure rename).')
@@ -453,7 +505,7 @@ export function apply(ctx, config = {}) {
453
505
  sendJson(res, 400, { ok: false, error: { code: 'no-cwd', message: 'session has no working directory' } })
454
506
  return
455
507
  }
456
- const dir = resolveTreePath(cwd, payload.path)
508
+ const dir = await resolveTreePath(cwd, payload.path)
457
509
  const listing = await listDirectoryLevel(dir)
458
510
  sendJson(res, 200, { ok: true, value: { ...listing, cwd } })
459
511
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mindmap",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Mindmap plugin for DeepSeek Harness: a plain markdown file in the working directory IS the mindmap; the chat edits it step by step and the right-side panel follows live.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -42,6 +42,7 @@
42
42
  "client": {
43
43
  "inject": [
44
44
  "@deepseek-ai/dsh-client-runtime",
45
+ "@deepseek-ai/dsh-cordis-client-runner",
45
46
  "@deepseek-ai/dsh-client-ui-layout"
46
47
  ],
47
48
  "platform": "web",