@modesto_ma/dsh-channels 0.2.0 → 0.3.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/lib/index.js CHANGED
@@ -3,10 +3,10 @@ import path, { dirname, join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { z } from "zod";
5
5
  import { constants, createWriteStream, promises, readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
6
7
  import { Readable } from "node:stream";
7
8
  import { pipeline } from "node:stream/promises";
8
9
  import { LoggerLevel, createLarkChannel } from "@larksuiteoapi/node-sdk";
9
- import { homedir } from "node:os";
10
10
  import { promisify } from "node:util";
11
11
  import { gzip } from "node:zlib";
12
12
  //#region src/access/policy.ts
@@ -230,6 +230,34 @@ function textButton(options) {
230
230
  }]
231
231
  };
232
232
  }
233
+ /**
234
+ * Collapsible fold panel: collapsed by default, grey rounded border, and a
235
+ * built-in right-aligned chevron that flips when expanded. Used for the
236
+ * streaming card's reasoning window and any future "secondary detail" area.
237
+ */
238
+ function foldPanel(title, content, iconToken) {
239
+ return {
240
+ tag: "collapsible_panel",
241
+ expanded: false,
242
+ border: {
243
+ color: "grey",
244
+ corner_radius: "8px"
245
+ },
246
+ header: {
247
+ title: {
248
+ tag: "plain_text",
249
+ content: title
250
+ },
251
+ icon: {
252
+ tag: "standard_icon",
253
+ token: iconToken
254
+ },
255
+ icon_position: "right",
256
+ icon_expanded_angle: 180
257
+ },
258
+ elements: [markdown(content)]
259
+ };
260
+ }
233
261
  /** A row of equal-width columns, each holding one element. */
234
262
  function columnSet(columns, options = {}) {
235
263
  return {
@@ -1540,6 +1568,46 @@ var QuestionAnswerer = class {
1540
1568
  };
1541
1569
  //#endregion
1542
1570
  //#region src/cards/streaming.ts
1571
+ /**
1572
+ * Streaming reply card: the reasoning fold panel, the transient activity
1573
+ * lines, the reply body and the settled footer — plus the byte/table budgets
1574
+ * that keep one card inside Feishu's limits.
1575
+ *
1576
+ * Composition, main card:
1577
+ *
1578
+ * [fold panel] [activity status] [activity tool] [body]
1579
+ * [hr] [model · session · reasoning time]
1580
+ * [context share · input · output · cache hit]
1581
+ *
1582
+ * - The panel shows the reasoning behind its own window. While the turn is
1583
+ * still thinking it doubles as the phase indicator; once the turn settles
1584
+ * without any reasoning content (non-reasoning models) it is omitted
1585
+ * entirely instead of leaving an empty bordered box.
1586
+ * - The two activity lines are transient: the streamer only passes them for
1587
+ * the card that is currently streaming, so frozen segment cards never repeat
1588
+ * a stale "正在调用工具" line.
1589
+ * - The settled footer is a divider plus two notation lines and renders
1590
+ * exactly once: on the main card, only after the turn settles. It carries
1591
+ * the model/effort, session id and the turn's accumulated reasoning time
1592
+ * (line 1), then the context pressure with its window share plus the
1593
+ * input/output token counts and cache hit ratio (line 2). First frames
1594
+ * therefore never show a row of zeroed counters.
1595
+ *
1596
+ * Continuation cards (`options.segment`) carry a segment header and the body;
1597
+ * they have no panel and no footer, so a long answer is not repeated chrome.
1598
+ *
1599
+ * Budgets, two independent ones:
1600
+ * - Each card is measured as a whole: segmentation targets `CARD_TARGET_BYTES`
1601
+ * (main cards probe the panel at its maximum window, so reasoning that keeps
1602
+ * growing after the split cannot push the card over), while `CARD_MAX_BYTES`
1603
+ * is the platform hard cap used only by `buildCard` as an absolute guard.
1604
+ * - The reasoning panel is a fixed `REASONING_MAX_BYTES` window, clipped from
1605
+ * the tail with a truncation marker. It neither competes with nor is squeezed
1606
+ * by the reply body: the visible reasoning window is identical whether the
1607
+ * answer is one line or a full segment.
1608
+ *
1609
+ * @module dsh-channels/cards/streaming
1610
+ */
1543
1611
  const PANEL_TITLES = {
1544
1612
  thinking: "推理中",
1545
1613
  replying: "回复中",
@@ -1550,28 +1618,18 @@ const PANEL_TITLES = {
1550
1618
  const PANEL_CHEVRON_TOKEN = "down-small-ccm_outlined";
1551
1619
  /** Feishu hard cap for one card message body. */
1552
1620
  const CARD_MAX_BYTES = 3e4;
1553
- /** Target budget per card — 2 KB below the hard cap as headroom. */
1621
+ /** Target budget per card — 2 KB below the hard cap as headroom for the
1622
+ * figures that keep growing between a segment split and the final patch. */
1554
1623
  const CARD_TARGET_BYTES = 28e3;
1555
1624
  /**
1556
- * Hard byte cap for the reasoning fold panel's markdown (5 KB, JSON-escaped
1557
- * UTF-8, including the truncation marker). The panel is clipped against this
1558
- * alone — never against the card — so it neither competes with nor is squeezed
1559
- * by the reply body: the visible reasoning window is identical whether the
1560
- * answer is one line or a full 28 KB segment. Measured at the cap: ≈4.96 K
1561
- * ASCII chars, ≈1.65 K CJK chars, ≈2.5 K emoji. The panel element's own
1562
- * wrapper (border, header, chevron) is charged separately — see
1563
- * `CARD_PANEL_RESERVE_BYTES`.
1625
+ * Cap for the reasoning fold panel's markdown (5 KB, JSON-escaped UTF-8,
1626
+ * including the truncation marker). The panel is clipped against this alone —
1627
+ * never against the card — so the visible reasoning window is the same whether
1628
+ * the reply is one line or a full segment. Visible window at the cap: ≈4.96 K
1629
+ * ASCII chars, ≈1.65 K CJK chars, ≈1.24 K emoji.
1564
1630
  */
1565
1631
  const REASONING_MAX_BYTES = 5e3;
1566
1632
  /**
1567
- * Bytes held back from the card target for the fold panel as a whole, so a
1568
- * segmented card's body plus its panel stay inside `CARD_TARGET_BYTES`.
1569
- * `REASONING_MAX_BYTES` caps the panel's markdown; the element wrapper around
1570
- * it (border, header, chevron icon, `elements` array) measures a further 315 B
1571
- * at the worst phase, and this reserve covers that with headroom.
1572
- */
1573
- const CARD_PANEL_RESERVE_BYTES = 5500;
1574
- /**
1575
1633
  * Feishu hard-limits the number of markdown tables a single card may contain
1576
1634
  * (API error 11310 "card table number over limit"; verified on device: the
1577
1635
  * platform allows 5). Segmentation therefore keeps at most 5 tables per card.
@@ -1579,9 +1637,14 @@ const CARD_PANEL_RESERVE_BYTES = 5500;
1579
1637
  const MAX_TABLES_PER_CARD = 5;
1580
1638
  /** Appended to clipped reasoning (and, as an absolute last resort, clipped body). */
1581
1639
  const TRUNCATION_SUFFIX = "\n\n…(内容过长,已截断)";
1640
+ const encoder = new TextEncoder();
1582
1641
  /** UTF-8 byte size of a card's JSON — the metric behind Feishu's 30 KB cap. */
1583
- function cardJsonBytes(card) {
1584
- return new TextEncoder().encode(JSON.stringify(card)).length;
1642
+ function cardJsonBytes(value) {
1643
+ return encoder.encode(JSON.stringify(value)).length;
1644
+ }
1645
+ /** JSON-escaped UTF-8 size of a string, as embedded in a card's JSON. */
1646
+ function jsonBytes(text) {
1647
+ return encoder.encode(JSON.stringify(text)).length;
1585
1648
  }
1586
1649
  /**
1587
1650
  * Locate markdown table blocks: runs of ≥ 2 consecutive lines that start with
@@ -1620,6 +1683,42 @@ function tableBlocks(body) {
1620
1683
  });
1621
1684
  return blocks;
1622
1685
  }
1686
+ /** Fenced code block ranges (opening fence line through the closing line). An
1687
+ * unclosed fence extends to the end of the body. */
1688
+ function codeFenceBlocks(body) {
1689
+ const blocks = [];
1690
+ let start = -1;
1691
+ let offset = 0;
1692
+ for (const line of body.split("\n")) {
1693
+ if (/^[ \t]*```/.test(line)) if (start < 0) start = offset;
1694
+ else {
1695
+ blocks.push({
1696
+ start,
1697
+ end: offset + line.length
1698
+ });
1699
+ start = -1;
1700
+ }
1701
+ offset += line.length + 1;
1702
+ }
1703
+ if (start >= 0) blocks.push({
1704
+ start,
1705
+ end: body.length
1706
+ });
1707
+ return blocks;
1708
+ }
1709
+ /**
1710
+ * Move a cut position out of any protected block (code fence or table) it
1711
+ * lands inside: the block is kept whole on the following card. A block that
1712
+ * starts at position 0 cannot be moved back (that would produce an empty
1713
+ * head), so that degenerate case is left to the hard-cut escape hatch.
1714
+ */
1715
+ function avoidProtected(blocks, at) {
1716
+ for (const block of blocks) {
1717
+ if (block.start >= at) break;
1718
+ if (at < block.end) return block.start > 0 ? block.start : at;
1719
+ }
1720
+ return at;
1721
+ }
1623
1722
  /**
1624
1723
  * Replace markdown table blocks with a placeholder. Used for the reasoning
1625
1724
  * panel, whose content is auxiliary (truncation has no impact) and is never
@@ -1642,21 +1741,42 @@ function stripTables(text) {
1642
1741
  function fmt(n) {
1643
1742
  return n.toLocaleString("en-US");
1644
1743
  }
1744
+ /** Context-window share: one decimal below 10%, a whole number from 10% up. */
1745
+ function fmtContextPct(tokens, window) {
1746
+ const pct = tokens / window * 100;
1747
+ return `${Number(pct.toFixed(1)) >= 10 ? pct.toFixed(0) : pct.toFixed(1)}%`;
1748
+ }
1749
+ /** `上下文 3,618/1,000,000 (0.4%)`; without a usable window the share is omitted. */
1645
1750
  function fmtContext(tokens, window) {
1646
1751
  if (tokens === void 0) return "—";
1647
- if (window === void 0) return `${fmt(tokens)} tokens`;
1648
- return `${fmt(tokens)} / ${fmt(window)}`;
1752
+ if (window === void 0 || window <= 0) return `${fmt(tokens)} tokens`;
1753
+ return `${fmt(tokens)}/${fmt(window)} (${fmtContextPct(tokens, window)})`;
1649
1754
  }
1650
1755
  function fmtCacheHit(pct) {
1651
1756
  return pct === void 0 ? "—" : `${(pct * 100).toFixed(1)}%`;
1652
1757
  }
1653
- /** Info line 2: `icon - 模型 {model}({effort}) · icon - 缓存命中 {cache} · icon - 上下文 {context}` */
1758
+ /** `推理 3.2s` / `推理 1分5s`; `—` when the turn had no reasoning phase. */
1759
+ function fmtReasoningTime(ms) {
1760
+ if (ms === void 0) return "—";
1761
+ const safe = Math.max(0, ms);
1762
+ if (safe < 6e4) return `${(safe / 1e3).toFixed(1)}s`;
1763
+ const total = Math.round(safe / 1e3);
1764
+ return `${Math.floor(total / 60)}分${total % 60}s`;
1765
+ }
1766
+ /**
1767
+ * Settled footer, line 1: 🧠 model/effort · 🔑 session id · ⏱ reasoning time.
1768
+ * Rendered once, after the turn settles, so live cards never flash a row of
1769
+ * zeroed counters.
1770
+ */
1654
1771
  function buildModelLine(stats) {
1655
- return `🧠 模型 ${stats.model ?? "—"}${stats.effort !== void 0 && stats.effort !== "" ? `(${stats.effort})` : ""} · ⚡ 缓存命中 ${fmtCacheHit(stats.cacheHitPct)} · 📊 上下文 ${fmtContext(stats.contextTokens, stats.contextWindow)}`;
1772
+ return `🧠 模型 ${stats.model ?? "—"}${stats.effort !== void 0 && stats.effort !== "" ? `(${stats.effort})` : ""} · 🔑 会话 ${stats.sessionId} · ⏱ 推理 ${fmtReasoningTime(stats.reasoningMs)}`;
1656
1773
  }
1657
- /** Info line 1: `icon - {session id} · icon - input token {input_token} · icon - output token {output_token}` */
1658
- function buildSessionLine(stats) {
1659
- return `🔑 ${stats.sessionId} · ⬆ input token ${fmt(stats.inputTokens)} · ⬇ output token ${fmt(stats.outputTokens)}`;
1774
+ /**
1775
+ * Settled footer, line 2: 📊 context pressure (counts + share) · 📩 input
1776
+ * tokens · 📨 output tokens · ⚡ cache hit ratio.
1777
+ */
1778
+ function buildUsageLine(stats) {
1779
+ return `📊 上下文 ${fmtContext(stats.contextTokens, stats.contextWindow)} · 📩 输入 ${fmt(stats.inputTokens)} · 📨 输出 ${fmt(stats.outputTokens)} · ⚡ 缓存命中 ${fmtCacheHit(stats.cacheHitPct)}`;
1660
1780
  }
1661
1781
  function reasoningPlaceholder(phase) {
1662
1782
  return phase === "thinking" ? "*等待模型输出…*" : "*(本次无推理内容)*";
@@ -1664,136 +1784,127 @@ function reasoningPlaceholder(phase) {
1664
1784
  function bodyPlaceholder(phase) {
1665
1785
  return phase === "done" || phase === "error" ? "*(本次无正文输出)*" : "*正在生成…*";
1666
1786
  }
1667
- /** Continuation header: `📄 回复分段 {n} · {phase}` (or 续上一条卡片 without a number). */
1668
- function segmentHeader(options, phase) {
1669
- const seg = options.segment;
1670
- return `📄 ${seg === void 0 ? "续上一条卡片" : `回复分段 ${seg}`} · ${PANEL_TITLES[phase]}`;
1787
+ /** The panel's markdown: stripped of tables, with a placeholder when empty. */
1788
+ function panelContent(phase, reasoning) {
1789
+ const stripped = stripTables(reasoning);
1790
+ return stripped.trim() === "" ? reasoningPlaceholder(phase) : stripped;
1671
1791
  }
1672
- /** Raw card assembly — no clipping of any kind (the budget machinery wraps this). */
1673
- function assemble(phase, reasoning, body, stats, options, status, tool) {
1792
+ /**
1793
+ * Whether the fold panel renders. `thinking` always does (it carries the
1794
+ * phase indicator while the card is otherwise empty); every later phase only
1795
+ * when there is actual reasoning to fold away — a settled non-reasoning turn
1796
+ * gets no empty bordered box.
1797
+ */
1798
+ function panelShown(phase, reasoning) {
1799
+ return phase === "thinking" || stripTables(reasoning).trim() !== "";
1800
+ }
1801
+ /** Continuation header: `📄 回复分段 {n}`. */
1802
+ function segmentHeader(segment) {
1803
+ return `📄 回复分段 ${segment}`;
1804
+ }
1805
+ /** Interruption notice header for a repaired continuation card. */
1806
+ function interruptionHeader(segment) {
1807
+ return `📄 回复分段 ${segment} · 回复中断`;
1808
+ }
1809
+ function isSettled(phase) {
1810
+ return phase === "done" || phase === "error";
1811
+ }
1812
+ /**
1813
+ * Defensive cap on caller-supplied activity lines. The streamer already clips
1814
+ * them to 64 chars; this bound keeps `buildCard`'s "always ≤ CARD_MAX_BYTES"
1815
+ * guarantee even for a direct caller that passes an unbounded line.
1816
+ */
1817
+ const ACTIVITY_LINE_MAX_CHARS = 512;
1818
+ /** One caller-supplied notation line, defensively capped; undefined when empty. */
1819
+ function activityNote(text) {
1820
+ if (text === void 0 || text === "") return void 0;
1821
+ return note(text.length <= ACTIVITY_LINE_MAX_CHARS ? text : `${text.slice(0, ACTIVITY_LINE_MAX_CHARS - 1)}…`);
1822
+ }
1823
+ /** Render one card. `reasoning`/`body` are the already-budgeted texts. */
1824
+ function renderCard(state, options, reasoning, body) {
1825
+ const segment = options.segment;
1826
+ const main = segment === void 0;
1674
1827
  const elements = [];
1675
- if (options.panel ?? true) {
1676
- const panelReasoning = stripTables(reasoning);
1677
- elements.push({
1678
- tag: "collapsible_panel",
1679
- expanded: false,
1680
- border: {
1681
- color: "grey",
1682
- corner_radius: "8px"
1683
- },
1684
- header: {
1685
- title: {
1686
- tag: "plain_text",
1687
- content: PANEL_TITLES[phase]
1688
- },
1689
- icon: {
1690
- tag: "standard_icon",
1691
- token: PANEL_CHEVRON_TOKEN
1692
- },
1693
- icon_position: "right",
1694
- icon_expanded_angle: 180
1695
- },
1696
- elements: [{
1697
- tag: "markdown",
1698
- content: panelReasoning.trim() === "" ? reasoningPlaceholder(phase) : panelReasoning
1699
- }]
1700
- });
1701
- } else elements.push({
1702
- tag: "markdown",
1703
- content: segmentHeader(options, phase),
1704
- text_size: "notation"
1705
- });
1706
- if (status !== void 0 && status !== "") elements.push({
1707
- tag: "markdown",
1708
- content: status,
1709
- text_size: "notation"
1710
- });
1711
- if (tool !== void 0 && tool !== "") elements.push({
1712
- tag: "markdown",
1713
- content: tool,
1714
- text_size: "notation"
1715
- });
1716
- elements.push({ tag: "hr" });
1717
- elements.push({
1718
- tag: "markdown",
1719
- content: body.trim() === "" ? bodyPlaceholder(phase) : body
1720
- });
1721
- elements.push({ tag: "hr" });
1722
- elements.push({
1723
- tag: "markdown",
1724
- content: buildSessionLine(stats),
1725
- text_size: "notation"
1726
- });
1727
- elements.push({
1728
- tag: "markdown",
1729
- content: buildModelLine(stats),
1730
- text_size: "notation"
1731
- });
1732
- return {
1733
- schema: "2.0",
1734
- config: { width_mode: "fill" },
1735
- body: { elements }
1736
- };
1828
+ if (main) {
1829
+ if (panelShown(state.phase, reasoning)) elements.push(foldPanel(PANEL_TITLES[state.phase], panelContent(state.phase, reasoning), PANEL_CHEVRON_TOKEN));
1830
+ } else elements.push(note(segmentHeader(segment)));
1831
+ const statusNote = activityNote(state.status);
1832
+ if (statusNote !== void 0) elements.push(statusNote);
1833
+ const toolNote = activityNote(state.tool);
1834
+ if (toolNote !== void 0) elements.push(toolNote);
1835
+ elements.push(markdown(body.trim() === "" ? bodyPlaceholder(state.phase) : body));
1836
+ if (main && isSettled(state.phase)) {
1837
+ elements.push(divider());
1838
+ elements.push(note(buildModelLine(state.stats)));
1839
+ elements.push(note(buildUsageLine(state.stats)));
1840
+ }
1841
+ return card(elements);
1737
1842
  }
1738
1843
  /**
1739
- * Fit `text` into a byte budget measured by `render`: return `text` unchanged
1740
- * when it already fits; otherwise binary-search the longest prefix that still
1741
- * fits once `suffix` is appended. The result is guaranteed to satisfy the
1742
- * same measurement within `budget`.
1844
+ * Longest prefix of `text` that fits `budget` under `measure`.
1845
+ *
1846
+ * Returns `text` unchanged when it already fits; otherwise the longest prefix
1847
+ * with `suffix` appended still fits, so a clipped result is guaranteed to stay
1848
+ * within the same budget.
1743
1849
  */
1744
- function clipText(text, suffix, render, budget) {
1745
- if (render(text) <= budget) return text;
1850
+ function fitPrefix(text, budget, measure, suffix = "") {
1851
+ if (measure(text) <= budget) return text;
1746
1852
  let lo = 0;
1747
1853
  let hi = text.length;
1748
1854
  while (lo < hi) {
1749
1855
  const mid = Math.ceil((lo + hi) / 2);
1750
- if (render(text.slice(0, mid) + suffix) <= budget) lo = mid;
1856
+ if (measure(text.slice(0, mid) + suffix) <= budget) lo = mid;
1751
1857
  else hi = mid - 1;
1752
1858
  }
1753
1859
  return text.slice(0, lo) + suffix;
1754
1860
  }
1755
- /** JSON-escaped UTF-8 size of a string — the metric Feishu's card cap counts. */
1756
- function jsonBytes(text) {
1757
- return new TextEncoder().encode(JSON.stringify(text)).length;
1758
- }
1759
- /**
1760
- * JSON-escaped UTF-8 size of the fold panel's markdown, as rendered by one
1761
- * assembled card. Reasoning is budgeted against THIS rather than against the
1762
- * whole card: a card-wide budget makes the panel collapse to the bare
1763
- * truncation marker as soon as the reply body grows, so the visible reasoning
1764
- * window would depend on how long the answer is.
1765
- */
1766
- function panelMarkdownBytes(render) {
1767
- return (content) => {
1768
- const elements = render(content).body?.elements;
1769
- return jsonBytes(elements?.[0]?.elements?.[0]?.content ?? "");
1770
- };
1861
+ /** Reasoning as rendered: clipped against the panel's own fixed window. */
1862
+ function budgetReasoning(state) {
1863
+ return fitPrefix(state.reasoning, REASONING_MAX_BYTES, (content) => jsonBytes(panelContent(state.phase, content)), TRUNCATION_SUFFIX);
1771
1864
  }
1772
- /** JSON-escaped UTF-8 size of the truncation marker — reserved so clips can
1773
- * never push a card past its budget. */
1774
- const SUFFIX_JSON_BYTES = jsonBytes(TRUNCATION_SUFFIX);
1775
1865
  /**
1776
1866
  * Build one card.
1777
1867
  *
1778
- * - Reasoning is clipped so its markdown stays within `REASONING_MAX_BYTES`
1779
- * (including the truncation marker): a fixed, self-contained budget that
1780
- * neither grants space to nor borrows space from the body, so the visible
1781
- * reasoning window is the same whether the reply is one line or a 28 KB
1782
- * segment.
1783
- * - The body gets whatever is left of `CARD_MAX_BYTES` once the clipped
1784
- * reasoning is accounted for, which keeps the total under Feishu's hard cap.
1785
- * The streamer pre-splits with `segmentCardContent` (probed at the
1786
- * worst-case phase), so a live stream never reaches this clip: it is a pure
1787
- * guard for direct callers.
1868
+ * The reasoning window is clipped against `REASONING_MAX_BYTES` alone, and the
1869
+ * body is then clipped against `CARD_MAX_BYTES` measured on the card that is
1870
+ * actually rendered — the guard exists only for direct callers and for the
1871
+ * turn-failure notice appended to a settled body; the streamer pre-splits with
1872
+ * `segmentCardContent`.
1788
1873
  *
1789
- * Guarantee: the returned card is always ≤ `CARD_MAX_BYTES`, and its panel is
1790
- * always ≤ `REASONING_MAX_BYTES`.
1874
+ * Guarantee: the returned card is ≤ `CARD_MAX_BYTES`, and its panel markdown
1875
+ * is ≤ `REASONING_MAX_BYTES`.
1791
1876
  */
1792
1877
  function buildCard(state, options = {}) {
1793
- const render = (reasoning, body) => assemble(state.phase, reasoning, body, state.stats, options, state.status, state.tool);
1794
- const panelBytes = panelMarkdownBytes((content) => render(content, ""));
1795
- const reasoning = clipText(state.reasoning, TRUNCATION_SUFFIX, panelBytes, REASONING_MAX_BYTES);
1796
- return render(reasoning, clipText(state.body, TRUNCATION_SUFFIX, (content) => cardJsonBytes(render("", content)), CARD_MAX_BYTES - panelBytes(reasoning) - SUFFIX_JSON_BYTES));
1878
+ const reasoning = options.segment === void 0 ? budgetReasoning(state) : state.reasoning;
1879
+ return renderCard(state, options, reasoning, fitPrefix(state.body, CARD_MAX_BYTES, (content) => cardJsonBytes(renderCard(state, options, reasoning, content)), TRUNCATION_SUFFIX));
1880
+ }
1881
+ /**
1882
+ * Worst-case panel markdown: exactly `REASONING_MAX_BYTES` JSON bytes of
1883
+ * ASCII text (the surrounding quotes cost 2 B).
1884
+ */
1885
+ const PANEL_PROBE_MARKDOWN = "r".repeat(REASONING_MAX_BYTES - 2);
1886
+ /**
1887
+ * Split a body so one card fits: `head` re-renders within `CARD_TARGET_BYTES`
1888
+ * and `tail` flows into the next segment card ('' when everything fits).
1889
+ *
1890
+ * The probe renders the worst card this segment can ever become: the main card
1891
+ * always budgets the panel's full window (reasoning may keep growing after the
1892
+ * split) and its settled footer, while continuation cards have neither. The
1893
+ * `error` phase is probed because it has the longest panel title.
1894
+ */
1895
+ function segmentCardContent(state, options = {}) {
1896
+ const main = options.segment === void 0;
1897
+ const probe = {
1898
+ ...state,
1899
+ phase: "error",
1900
+ reasoning: main ? PANEL_PROBE_MARKDOWN : ""
1901
+ };
1902
+ const limit = fitPrefix(state.body, CARD_TARGET_BYTES, (content) => cardJsonBytes(renderCard(probe, options, probe.reasoning, content))).length;
1903
+ return splitBodyForCard(state.body, limit);
1904
+ }
1905
+ /** Card-variant options for one segment (`0` is the main card). */
1906
+ function segmentCardOptions(index) {
1907
+ return index === 0 ? {} : { segment: index + 1 };
1797
1908
  }
1798
1909
  /** Interruption notice body for cards repaired after a restart mid-turn. */
1799
1910
  const INTERRUPTION_NOTICE = "⚠️ 服务已重启,本次回复中断,请重新发送消息。";
@@ -1808,57 +1919,18 @@ const INTERRUPTION_NOTICE = "⚠️ 服务已重启,本次回复中断,请
1808
1919
  */
1809
1920
  function buildInterruptionCard(options = {}) {
1810
1921
  const elements = [];
1811
- if (options.panel === false) elements.push({
1812
- tag: "markdown",
1813
- content: `📄 ${options.segment === void 0 ? "续上一条卡片" : `回复分段 ${options.segment}`} · 回复中断`,
1814
- text_size: "notation"
1815
- });
1816
- elements.push({
1817
- tag: "markdown",
1818
- content: INTERRUPTION_NOTICE
1819
- });
1820
- return {
1821
- schema: "2.0",
1822
- config: { width_mode: "fill" },
1823
- body: { elements }
1824
- };
1825
- }
1826
- /**
1827
- * Largest body prefix (char count) whose card renders within `CARD_TARGET_BYTES`
1828
- * minus `CARD_PANEL_RESERVE_BYTES`, reserving the panel's whole footprint for
1829
- * the reasoning that travels with the segment. `segmentCardContent` only ever
1830
- * segments bodies, so reasoning is never a segment budget input by construction
1831
- * — but the panel still occupies the card, so its cap is held back. The probe renders
1832
- * at phase `error`, the worst case (its segment header title is the longest),
1833
- * so a frozen segment can never outgrow the budget merely by the phase flipping
1834
- * 回复中 → 回复失败; stats growth between the split and the final patch consumes
1835
- * the 2 KB redundancy (`CARD_MAX_BYTES` − `CARD_TARGET_BYTES`).
1836
- */
1837
- function largestBodyPrefix(state, options) {
1838
- const text = state.body;
1839
- const budget = CARD_TARGET_BYTES - CARD_PANEL_RESERVE_BYTES;
1840
- const render = (content) => assemble("error", "", content, state.stats, {
1841
- ...options,
1842
- panel: false
1843
- }, state.status, state.tool);
1844
- if (cardJsonBytes(render(text)) <= budget) return text.length;
1845
- let lo = 0;
1846
- let hi = text.length;
1847
- while (lo < hi) {
1848
- const mid = Math.ceil((lo + hi) / 2);
1849
- if (cardJsonBytes(render(text.slice(0, mid))) <= budget) lo = mid;
1850
- else hi = mid - 1;
1851
- }
1852
- return lo;
1922
+ if (options.segment !== void 0) elements.push(note(interruptionHeader(options.segment)));
1923
+ elements.push(markdown(INTERRUPTION_NOTICE));
1924
+ return card(elements);
1853
1925
  }
1854
1926
  /**
1855
1927
  * Split a body at a clean breakpoint so `head` fits one card:
1856
1928
  * 1. the head holds at most `maxTables` whole markdown tables — the boundary
1857
1929
  * is capped at the first excess table (Feishu rejects cards with more);
1858
- * 2. never inside a fenced code block (pull the boundary back before the fence);
1859
- * 3. never inside a table block (a breakpoint that would land inside one is
1860
- * moved to its start);
1861
- * 4. then the clean-breakpoint tiers: paragraph blank line → `#` heading line
1930
+ * 2. never inside a fenced code block or a table block — the boundary is
1931
+ * pulled back to the block's start (a block beginning the body is left to
1932
+ * the hard-cut escape hatch);
1933
+ * 3. then the clean-breakpoint tiers: paragraph blank line → `#` heading line
1862
1934
  * → line break → hard cut as the escape hatch.
1863
1935
  */
1864
1936
  function splitBodyForCard(body, maxChars, maxTables = 5) {
@@ -1867,67 +1939,25 @@ function splitBodyForCard(body, maxChars, maxTables = 5) {
1867
1939
  head: body,
1868
1940
  tail: ""
1869
1941
  };
1870
- let limit = maxChars;
1871
- const firstExcess = tables[maxTables];
1872
- if (firstExcess !== void 0) limit = Math.min(limit, firstExcess.start);
1873
- const fencePattern = /^[ \t]*```[^\n]*$/gm;
1874
- let fenceStart = -1;
1875
- let inside = false;
1876
- for (const match of body.matchAll(fencePattern)) {
1877
- const index = match.index ?? 0;
1878
- if (!inside) {
1879
- if (index >= limit) break;
1880
- inside = true;
1881
- fenceStart = index;
1882
- } else if (index >= limit) {
1883
- if (fenceStart > 0) limit = fenceStart;
1884
- break;
1885
- } else inside = false;
1886
- }
1887
- if (inside && fenceStart > 0 && fenceStart < limit) limit = fenceStart;
1888
- for (const block of tables) {
1889
- if (block.start >= limit) break;
1890
- if (block.end > limit) {
1891
- if (block.start > 0) limit = block.start;
1892
- break;
1893
- }
1894
- }
1895
- /** Move a cut position out of any table block (to just before it). */
1896
- const avoidTables = (at) => {
1897
- for (const block of tables) {
1898
- if (block.start >= at) break;
1899
- if (at <= block.end && block.start > 0) return block.start;
1900
- }
1901
- return at;
1902
- };
1942
+ const blocks = [...codeFenceBlocks(body), ...tables].sort((a, b) => a.start - b.start);
1943
+ const limit = avoidProtected(blocks, Math.min(maxChars, tables[maxTables]?.start ?? body.length));
1903
1944
  const window = body.slice(0, limit);
1945
+ const minFill = Math.floor(limit * .7);
1904
1946
  const cut = (at) => ({
1905
1947
  head: body.slice(0, at).replace(/\s+$/u, ""),
1906
1948
  tail: body.slice(at).replace(/^\n+/u, "")
1907
1949
  });
1908
- const minFill = Math.floor(limit * .7);
1909
1950
  const paragraph = window.lastIndexOf("\n\n");
1910
- if (paragraph >= minFill) return cut(avoidTables(paragraph + 2));
1951
+ if (paragraph >= minFill) return cut(avoidProtected(blocks, paragraph + 2));
1911
1952
  let heading = -1;
1912
1953
  for (const match of window.matchAll(/^#{1,6} .*$/gm)) {
1913
1954
  const start = match.index ?? 0;
1914
1955
  heading = start === 0 ? 0 : start - 1;
1915
1956
  }
1916
- if (heading >= minFill) return cut(avoidTables(heading));
1957
+ if (heading >= minFill) return cut(avoidProtected(blocks, heading));
1917
1958
  const line = window.lastIndexOf("\n");
1918
- if (line >= minFill) return cut(avoidTables(line + 1));
1919
- return {
1920
- head: body.slice(0, avoidTables(limit)),
1921
- tail: body.slice(avoidTables(limit))
1922
- };
1923
- }
1924
- /**
1925
- * How the accumulated body should be laid out for one card: everything that
1926
- * fits (clean breakpoint) plus the remainder for the next segment card.
1927
- * `head` re-renders within `CARD_TARGET_BYTES`; the body is never dropped.
1928
- */
1929
- function segmentCardContent(state, options = {}) {
1930
- return splitBodyForCard(state.body, largestBodyPrefix(state, options));
1959
+ if (line >= minFill) return cut(avoidProtected(blocks, line + 1));
1960
+ return cut(limit);
1931
1961
  }
1932
1962
  /**
1933
1963
  * The initial (empty) streaming card for one session: thinking phase, empty
@@ -1946,38 +1976,25 @@ function initialStreamingCard(sessionId) {
1946
1976
  }
1947
1977
  });
1948
1978
  }
1979
+ //#endregion
1980
+ //#region src/cards/onboarding.ts
1949
1981
  /**
1950
1982
  * One-time onboarding card: what the bot does, how workspaces are scoped, and
1951
1983
  * the command cheat sheet. Sent on the first private message of a chat.
1984
+ *
1985
+ * Static content, deliberately kept apart from the streaming reply card.
1986
+ *
1987
+ * @module dsh-channels/cards/onboarding
1952
1988
  */
1989
+ /** First-contact onboarding card (private chats only, once per chat). */
1953
1990
  function buildOnboardingCard() {
1954
- return {
1955
- schema: "2.0",
1956
- config: { width_mode: "fill" },
1957
- body: { elements: [
1958
- {
1959
- tag: "markdown",
1960
- content: "**👋 欢迎使用飞书 DSH**"
1961
- },
1962
- {
1963
- tag: "markdown",
1964
- content: "直接发消息即可开始一个会话,我会在话题里流式回复;话题内继续发消息无需再 @ 我。"
1965
- },
1966
- {
1967
- tag: "markdown",
1968
- content: "**工作区**:私聊一人一个;群里每位成员各自一个,互不影响。"
1969
- },
1970
- {
1971
- tag: "markdown",
1972
- content: "**常用命令**:`/help` 命令列表 · `/status` 会话状态 · `/workspace` 工作目录\n`/effort` 推理强度 · `/model` 模型 · `/permissions` 权限预设\n`/new` 新建会话 · `/stop` 中断当前回合 · `/history` 历史 · `/export` 导出日志"
1973
- },
1974
- {
1975
- tag: "markdown",
1976
- content: "需要授权或补充信息时,我会发一张卡片,点按钮或直接回复即可。",
1977
- text_size: "notation"
1978
- }
1979
- ] }
1980
- };
1991
+ return card([
1992
+ markdown("**👋 欢迎使用飞书 DSH**"),
1993
+ markdown("直接发消息即可开始一个会话,我会在话题里流式回复;话题内继续发消息无需再 @ 我。"),
1994
+ markdown("**工作区**:私聊一人一个;群里每位成员各自一个,互不影响。"),
1995
+ markdown("**常用命令**:`/help` 命令列表 · `/status` 会话状态 · `/workspace` 工作目录\n`/effort` 推理强度 · `/model` 模型 · `/permissions` 权限预设\n`/new` 新建会话 · `/stop` 中断当前回合 · `/history` 历史 · `/export` 导出日志"),
1996
+ note("需要授权或补充信息时,我会发一张卡片,点按钮或直接回复即可。")
1997
+ ]);
1981
1998
  }
1982
1999
  //#endregion
1983
2000
  //#region src/commands/registry.ts
@@ -2436,8 +2453,16 @@ const routeRecordSchema = z.object({
2436
2453
  chatType: z.enum(["p2p", "group"]),
2437
2454
  /** Workspace owner (sender open_id). */
2438
2455
  senderOpenId: z.string().min(1),
2439
- /** Workspace directory for this (chat, user) pair. */
2456
+ /** Workspace root owning this route: the Session header cwd / sandbox root. */
2440
2457
  workspacePath: z.string().min(1),
2458
+ /**
2459
+ * Effective working directory of the bound Session (the per-topic directory
2460
+ * or Git worktree checkout). Absent on records written before per-topic
2461
+ * directories existed: `workspacePath` is then the working directory too.
2462
+ */
2463
+ sessionDir: z.string().min(1).optional(),
2464
+ /** Git branch owning `sessionDir` when it is a worktree checkout. */
2465
+ worktreeBranch: z.string().min(1).optional(),
2441
2466
  /** Message id of the latest streaming card (updated per turn). */
2442
2467
  cardMessageId: z.string().min(1),
2443
2468
  /** Epoch ms when the record was created. */
@@ -2469,6 +2494,12 @@ const expiredRouteRecordSchema = z.object({
2469
2494
  /** Workspace the retired route belonged to; absent in tombstones written by
2470
2495
  * older plugin versions, so the field must stay optional. */
2471
2496
  workspacePath: z.string().min(1).optional(),
2497
+ /** Effective working directory the retired route's Session used; absent in
2498
+ * legacy tombstones (retention cleanup then treats `workspacePath` as the
2499
+ * directory, matching pre-v2 routes). */
2500
+ sessionDir: z.string().min(1).optional(),
2501
+ /** Git branch of that directory when it was a worktree checkout. */
2502
+ worktreeBranch: z.string().min(1).optional(),
2472
2503
  /** Epoch ms of the last activity through the route before retirement.
2473
2504
  * Optional for the same legacy-compatibility reason; consumers fall back to
2474
2505
  * `expiredAt` when it is absent. */
@@ -2509,6 +2540,14 @@ function appendSessionHistory(record, sessionId, createdAt) {
2509
2540
  }]
2510
2541
  });
2511
2542
  }
2543
+ /**
2544
+ * Effective working directory of one route's Session. Records written before
2545
+ * per-topic directories have no `sessionDir`; their workspace path is the
2546
+ * directory the Session really works in.
2547
+ */
2548
+ function sessionDirOf(record) {
2549
+ return record.sessionDir ?? record.workspacePath;
2550
+ }
2512
2551
  const inflightCardSchema = z.object({
2513
2552
  /** Feishu message id of one segment card. */
2514
2553
  messageId: z.string().min(1),
@@ -2653,34 +2692,211 @@ function chatRouteKey(chatType, chatId, senderOpenId) {
2653
2692
  return chatType === "p2p" ? `chat:p2p:${chatId}:${senderOpenId}` : `chat:group:${chatId}:${senderOpenId}`;
2654
2693
  }
2655
2694
  //#endregion
2695
+ //#region src/workspaces.ts
2696
+ /**
2697
+ * Workspace directory resolution: one DSH workspace per user, one working
2698
+ * directory per Feishu topic.
2699
+ *
2700
+ * - p2p: workspace `<baseDir>/p2p/<open_id>/`
2701
+ * - group: workspace `<baseDir>/groups/<chat_id>/<open_id>/`
2702
+ * - topic: `<workspace>/topics/<topic-token>/` (the Session's effective cwd)
2703
+ *
2704
+ * The workspace root is the Session header cwd: it keeps the Session grouped
2705
+ * under the user's project in the GUI and stays the sandbox write boundary.
2706
+ * The per-topic directory is where the tools actually work (see
2707
+ * `./session/isolation.ts`), so files produced by different topics never mix.
2708
+ * Older versions registered one workspace per topic under `<baseDir>/topics/`;
2709
+ * those directories are kept verbatim for legacy routes and are only removed
2710
+ * by the retention cleanup, never migrated.
2711
+ *
2712
+ * Attachments received before a topic exists are staged below
2713
+ * `<baseDir>/_staging/<chatType>/<chat_id>/<open_id>/` and adopted into the
2714
+ * topic directory once its first Session is created.
2715
+ *
2716
+ * Feishu ids (`ou_…`, `oc_…`) are safe path segments, but they are validated
2717
+ * anyway so a hostile id can never escape `baseDir`.
2718
+ *
2719
+ * @module dsh-channels/workspaces
2720
+ */
2721
+ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
2722
+ /** Upper bound for a panel-supplied workspace root (filesystem path limit headroom). */
2723
+ const MAX_BASE_DIR_LENGTH = 4096;
2724
+ /**
2725
+ * Validate and normalize a workspace root supplied by the settings panel.
2726
+ *
2727
+ * The profile layer keeps accepting whatever the deployment wrote (including
2728
+ * the historical relative default), but a value *typed into the Web panel*
2729
+ * must name an unambiguous location: a relative path would resolve against
2730
+ * the host process's unpredictable cwd. `~` is expanded for convenience and
2731
+ * the result is normalized; the root itself is rejected because workspace
2732
+ * directories are appended to it.
2733
+ *
2734
+ * @param raw - untrusted panel value.
2735
+ * @param home - home directory seam (tests pin it; production uses `homedir()`).
2736
+ * @returns the normalized absolute path, or a user-facing message.
2737
+ */
2738
+ function normalizeWorkspaceBaseDir(raw, home = homedir()) {
2739
+ if (typeof raw !== "string") return {
2740
+ ok: false,
2741
+ message: "必须是字符串"
2742
+ };
2743
+ const trimmed = raw.trim();
2744
+ if (trimmed === "") return {
2745
+ ok: false,
2746
+ message: "不能为空"
2747
+ };
2748
+ if (trimmed.length > MAX_BASE_DIR_LENGTH) return {
2749
+ ok: false,
2750
+ message: `路径长度不得超过 ${MAX_BASE_DIR_LENGTH} 个字符`
2751
+ };
2752
+ if (/[\u0000-\u001f\u007f]/.test(trimmed)) return {
2753
+ ok: false,
2754
+ message: "包含非法控制字符"
2755
+ };
2756
+ let expanded = trimmed;
2757
+ if (expanded === "~") expanded = home;
2758
+ else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) expanded = path.join(home, expanded.slice(2));
2759
+ if (!path.isAbsolute(expanded)) return {
2760
+ ok: false,
2761
+ message: "必须是绝对路径(例如 /Users/you/.dsh/dsh-channels/workspaces)"
2762
+ };
2763
+ const normalized = path.resolve(expanded);
2764
+ if (path.parse(normalized).root === normalized) return {
2765
+ ok: false,
2766
+ message: "不能使用文件系统根目录"
2767
+ };
2768
+ return {
2769
+ ok: true,
2770
+ value: normalized
2771
+ };
2772
+ }
2773
+ /**
2774
+ * Materialize one panel-supplied workspace root and prove it is usable.
2775
+ *
2776
+ * Called when the panel saves `baseDir` so an unusable location fails at save
2777
+ * time instead of surfacing later as "会话创建失败" inside a Feishu chat.
2778
+ * The directory is created here (the same `mkdir -p` the first session would
2779
+ * perform); an existing non-directory is refused.
2780
+ *
2781
+ * @returns an error message, or `undefined` when the directory is writable.
2782
+ */
2783
+ async function prepareWorkspaceBaseDir(dir) {
2784
+ try {
2785
+ await mkdir(dir, { recursive: true });
2786
+ } catch (error) {
2787
+ return `无法创建工作区目录:${String(error)}`;
2788
+ }
2789
+ try {
2790
+ if (!(await stat(dir)).isDirectory()) return "该路径已存在但不是目录";
2791
+ } catch (error) {
2792
+ return `无法读取工作区目录:${String(error)}`;
2793
+ }
2794
+ try {
2795
+ await access(dir, constants.W_OK);
2796
+ } catch {
2797
+ return "工作区目录不可写";
2798
+ }
2799
+ }
2800
+ function safeSegment(raw, what) {
2801
+ const segment = raw.replace(/^\/+|\/+$/g, "");
2802
+ if (!SAFE_SEGMENT.test(segment) || segment === "." || segment === "..") throw new Error(`dsh-channels: unsafe ${what} for workspace path: ${JSON.stringify(raw)}`);
2803
+ return segment;
2804
+ }
2805
+ /**
2806
+ * Stable, filesystem-safe token for one Feishu conversation: a real topic uses
2807
+ * its thread id, while a degraded chat-scoped conversation uses the Session id
2808
+ * that owns it (there is no topic boundary to key on).
2809
+ */
2810
+ function topicTokenFor(routeKey, kind, sessionId) {
2811
+ return kind === "thread" ? safeSegment(routeKey, "route key") : safeSegment(sessionId, "session id");
2812
+ }
2813
+ /** Git branch (and worktree directory) name for one topic token. */
2814
+ function worktreeBranchFor(topicToken) {
2815
+ return `feishu-${safeSegment(topicToken, "topic token")}`;
2816
+ }
2817
+ /**
2818
+ * Deterministic workspace directory for one user in one chat context: the
2819
+ * durable project the user's Sessions are grouped under.
2820
+ *
2821
+ * - p2p: `<baseDir>/p2p/<open_id>/`
2822
+ * - group: `<baseDir>/groups/<chat_id>/<open_id>/`
2823
+ *
2824
+ * These are the same paths older versions used as their shared (user × chat)
2825
+ * workspaces, so an upgraded deployment reuses the existing registry record
2826
+ * instead of creating a second project for the same user.
2827
+ */
2828
+ function workspacePathFor(baseDir, chatType, chatId, senderOpenId) {
2829
+ const user = safeSegment(senderOpenId, "senderOpenId");
2830
+ if (chatType === "p2p") return path.join(baseDir, "p2p", user);
2831
+ return path.join(baseDir, "groups", safeSegment(chatId, "chatId"), user);
2832
+ }
2833
+ /** Per-topic working directory inside one user workspace. */
2834
+ function topicDirFor(workspacePath, topicToken) {
2835
+ return path.join(workspacePath, "topics", safeSegment(topicToken, "topic token"));
2836
+ }
2837
+ /**
2838
+ * Root of the legacy (one workspace per topic) directories. Legacy workspaces
2839
+ * live strictly below this path, which is how the retention cleanup tells them
2840
+ * apart from user workspaces that must never be deleted.
2841
+ */
2842
+ function legacyTopicWorkspaceRoot(baseDir) {
2843
+ return path.join(baseDir, "topics");
2844
+ }
2845
+ /**
2846
+ * Staging directory for attachments that arrive before their topic (and so
2847
+ * its topic directory) exists. Not a registered workspace: files move into the
2848
+ * real topic directory as soon as the first Session of that topic is created.
2849
+ */
2850
+ function stagingPathFor(baseDir, chatType, chatId, senderOpenId) {
2851
+ const chat = safeSegment(chatId, "chatId");
2852
+ const user = safeSegment(senderOpenId, "senderOpenId");
2853
+ return path.join(baseDir, "_staging", chatType, chat, user);
2854
+ }
2855
+ /** Human-friendly workspace title shown in the DSH workspace list. */
2856
+ function workspaceTitleFor(chatType, chatId, senderOpenId) {
2857
+ return chatType === "p2p" ? `飞书私聊 · ${senderOpenId}` : `飞书群聊 · ${chatId} · ${senderOpenId}`;
2858
+ }
2859
+ /**
2860
+ * Ensure one user's workspace directory exists and is registered with the
2861
+ * host's workspace registry. Idempotent: repeated calls return the same record,
2862
+ * and an existing record keeps its (possibly renamed) title.
2863
+ */
2864
+ async function ensureWorkspace(ctx, baseDir, chatType, chatId, senderOpenId) {
2865
+ const dir = workspacePathFor(baseDir, chatType, chatId, senderOpenId);
2866
+ await mkdir(dir, { recursive: true });
2867
+ return ctx.workspaceRegistry.create(dir, workspaceTitleFor(chatType, chatId, senderOpenId));
2868
+ }
2869
+ //#endregion
2656
2870
  //#region src/cleanup.ts
2657
2871
  /**
2658
- * Stale workspace cleanup: pure planning and application policy.
2872
+ * Retention cleanup policy: pure planning and application over injected seams.
2873
+ *
2874
+ * Conversations produce files below two kinds of directories the host never
2875
+ * removes on its own:
2659
2876
  *
2660
- * The plugin creates one host workspace per (user × chat) pair below its
2661
- * configured `baseDir` (see `workspaces.ts`). The host never removes those
2662
- * directories on its own, so a long-running deployment slowly accumulates
2663
- * attachments and agent output for conversations nobody uses anymore. This
2664
- * module decides which plugin-created workspaces are stale and applies the
2665
- * removal while deliberately owning no I/O: every filesystem and registry
2666
- * effect is injected through `CleanupDeps`, which keeps the policy small and
2667
- * testable and keeps the dangerous half (recursive deletion + registry
2668
- * unregistration) in the caller.
2877
+ * 1. **Per-topic session directories** (current layout): the effective working
2878
+ * directory of one Feishu topic — `<user-workspace>/topics/<token>` or a Git
2879
+ * worktree checkout. They are not registered workspaces, so their activity
2880
+ * comes from route records/tombstones or from the directory's own `mtime`.
2881
+ * 2. **Legacy one-workspace-per-topic directories** written by older plugin
2882
+ * versions (`<baseDir>/topics/...`). Those are still registered host
2883
+ * workspaces and are unregistered when removed.
2669
2884
  *
2670
- * Activity precedence for one workspace:
2885
+ * User workspaces themselves (the durable project each user/chat is grouped
2886
+ * under) are never candidates. This module owns no I/O: every filesystem and
2887
+ * registry effect is injected through `CleanupDeps`, so the policy stays small
2888
+ * and testable while the dangerous half (recursive deletion + registry
2889
+ * unregistration) stays in the caller.
2671
2890
  *
2672
- * 1. the newest `lastActivityAt` among its active routes; a workspace with a
2673
- * live session is never offered for deletion,
2674
- * 2. otherwise the newest tombstone that names the workspace
2675
- * (`lastActivityAt`, falling back to `expiredAt` for tombstones written
2676
- * before routes recorded their workspace path),
2677
- * 3. otherwise the directory's own `mtime` (an orphan workspace).
2891
+ * Activity precedence:
2678
2892
  *
2679
- * Tombstones written before this feature carry neither `workspacePath` nor
2680
- * `lastActivityAt`, so they cannot be attributed to a workspace; such a
2681
- * workspace falls through to the `mtime` rule instead. A workspace with no
2682
- * usable activity evidence at all (missing or unreadable directory) is never
2683
- * offered for deletion: irreversible removal requires positive evidence.
2893
+ * 1. the newest `lastActivityAt` among the active routes bound to the target;
2894
+ * a target with a live Session is never offered for deletion,
2895
+ * 2. otherwise the newest tombstone that names it,
2896
+ * 3. otherwise the directory's own `mtime` (an orphan directory).
2897
+ *
2898
+ * A directory with no usable activity evidence at all (missing/unreadable) is
2899
+ * never offered for deletion: irreversible removal requires positive evidence.
2684
2900
  *
2685
2901
  * @module dsh-channels/cleanup
2686
2902
  */
@@ -2712,14 +2928,21 @@ function retentionMs(value) {
2712
2928
  /** Title prefixes `workspaces.ts` gives plugin-created workspaces. */
2713
2929
  const PLUGIN_TITLE_PREFIXES = ["飞书私聊 ·", "飞书群聊 ·"];
2714
2930
  /**
2931
+ * Whether a title was produced by this plugin (as opposed to a project the
2932
+ * operator registered by hand). Cleanup only ever touches plugin workspaces.
2933
+ */
2934
+ function pluginWorkspaceTitle(title) {
2935
+ return PLUGIN_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix));
2936
+ }
2937
+ /**
2715
2938
  * Whether `target` is strictly below `baseDir`.
2716
2939
  *
2717
2940
  * Both sides are normalized lexically (`path.resolve`), so a `..` segment or a
2718
2941
  * relative value cannot escape `baseDir`; the base directory itself and
2719
2942
  * siblings sharing a name prefix (e.g. `base-other`) are not "inside". The
2720
2943
  * check is lexical on purpose: this module owns no I/O. The caller's
2721
- * `removeWorkspace` should still refuse symlinked escapes with a realpath
2722
- * check before deleting.
2944
+ * `removeWorkspace` / `removeSessionDir` must still refuse symlinked escapes
2945
+ * with a realpath check before deleting.
2723
2946
  *
2724
2947
  * @param baseDir - directory the target must stay below.
2725
2948
  * @param target - candidate path.
@@ -2733,60 +2956,51 @@ function isPathInside(baseDir, target) {
2733
2956
  if (relative === "" || path.isAbsolute(relative)) return false;
2734
2957
  return relative !== ".." && !relative.startsWith(`..${path.sep}`);
2735
2958
  }
2736
- /** Whether a host workspace title marks the workspace as plugin-created. */
2737
- function pluginWorkspaceTitle(title) {
2738
- return PLUGIN_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix));
2739
- }
2740
- /** Lexical path equality (both sides resolved the same way as `isPathInside`). */
2959
+ /** Lexically compare two paths (both resolved) without touching the disk. */
2741
2960
  function samePath(left, right) {
2742
2961
  return path.resolve(left) === path.resolve(right);
2743
2962
  }
2744
- /** Newest finite timestamp of a list, or `undefined` when none is usable. */
2963
+ /** Whether a path is a filesystem root (never deletable). */
2964
+ function isFilesystemRoot(target) {
2965
+ const resolved = path.resolve(target);
2966
+ return path.parse(resolved).root === resolved;
2967
+ }
2968
+ /** Newest value of a list, or `undefined` when empty. */
2745
2969
  function newestActivity(values) {
2746
- let newest;
2747
- for (const value of values) {
2748
- if (!Number.isFinite(value)) continue;
2749
- if (newest === void 0 || value > newest) newest = value;
2750
- }
2751
- return newest;
2970
+ return values.length === 0 ? void 0 : Math.max(...values);
2752
2971
  }
2753
- /** Count/byte values never go negative or non-finite on the outcome path. */
2754
2972
  function nonNegative(value) {
2755
2973
  return Number.isFinite(value) && value > 0 ? value : 0;
2756
2974
  }
2757
- /** Best-effort error text (never a stack trace). */
2758
- function errorMessage(error) {
2759
- return error instanceof Error ? error.message : String(error);
2760
- }
2761
- /** A filesystem root must never become the cleanup base directory. */
2762
- function isFilesystemRoot(dir) {
2763
- return path.parse(dir).root === dir;
2975
+ async function readWorkspaceStats(deps, directory) {
2976
+ try {
2977
+ return await deps.statWorkspace(directory);
2978
+ } catch (error) {
2979
+ deps.warn?.(`dsh-channels: cleanup stats unavailable for ${directory}: ${String(error)}`);
2980
+ return;
2981
+ }
2764
2982
  }
2765
- /** Liveness probe that fails safe: an unknowable session counts as live. */
2766
2983
  function sessionIsLive(deps, sessionId) {
2767
2984
  try {
2768
2985
  return deps.isLive(sessionId);
2769
2986
  } catch (error) {
2770
- deps.warn?.(`dsh-channels: workspace cleanup liveness check failed for ${sessionId}: ${errorMessage(error)}`);
2987
+ deps.warn?.(`dsh-channels: cleanup liveness probe failed for ${sessionId}: ${String(error)}`);
2771
2988
  return true;
2772
2989
  }
2773
2990
  }
2774
- /** Measure one directory, tolerating a throwing or absent implementation. */
2775
- async function readWorkspaceStats(deps, workspacePath) {
2776
- try {
2777
- return await deps.statWorkspace(workspacePath);
2778
- } catch (error) {
2779
- deps.warn?.(`dsh-channels: workspace stats unavailable for ${workspacePath}: ${errorMessage(error)}`);
2780
- return;
2781
- }
2991
+ function routesAreLive(deps, routes) {
2992
+ return routes.some((route) => sessionIsLive(deps, route.sessionId));
2993
+ }
2994
+ /** Effective directory of one route (workspace path on legacy routes). */
2995
+ function directoryOfRoute(route) {
2996
+ return route.sessionDir ?? route.workspacePath;
2782
2997
  }
2783
2998
  /**
2784
- * Build the dry-run cleanup plan for the configured retention window.
2999
+ * Decide which directories are stale, without deleting anything.
2785
3000
  *
2786
- * Only plugin-created workspaces (`飞书私聊 ·` / `飞书群聊 ·` titles) strictly
2787
- * below `baseDir` are considered. A workspace is a candidate when its newest
2788
- * activity is older than the cutoff; its footprint is measured for the panel
2789
- * preview and for `freedBytes` reporting.
3001
+ * Only plugin-created targets strictly below `baseDir` are considered. User
3002
+ * workspaces are never candidates; legacy one-workspace-per-topic directories
3003
+ * are candidates only below `<baseDir>/topics/`.
2790
3004
  *
2791
3005
  * @param deps - injected effect seams (nothing is deleted here).
2792
3006
  * @returns the plan, or `undefined` when retention is off or the base
@@ -2797,40 +3011,116 @@ async function planWorkspaceCleanup(deps) {
2797
3011
  if (window === void 0) return void 0;
2798
3012
  const baseDir = path.resolve(deps.baseDir);
2799
3013
  if (isFilesystemRoot(baseDir)) {
2800
- deps.warn?.("dsh-channels: workspace cleanup disabled: baseDir is a filesystem root");
3014
+ deps.warn?.("dsh-channels: retention cleanup disabled: baseDir is a filesystem root");
2801
3015
  return;
2802
3016
  }
3017
+ const legacyRoot = legacyTopicWorkspaceRoot(baseDir);
2803
3018
  const cutoff = (deps.now?.() ?? Date.now()) - window;
2804
3019
  const activeRoutes = await deps.activeRoutes();
2805
3020
  const tombstones = await deps.tombstones();
3021
+ const workspaces = await deps.listWorkspaces();
2806
3022
  const candidates = [];
2807
3023
  let skippedLive = 0;
2808
- for (const workspace of await deps.listWorkspaces()) {
3024
+ const userWorkspacePaths = /* @__PURE__ */ new Set();
3025
+ for (const workspace of workspaces) {
3026
+ const resolved = path.resolve(workspace.path);
3027
+ if (!pluginWorkspaceTitle(workspace.title)) continue;
3028
+ if (!isPathInside(baseDir, resolved)) continue;
3029
+ if (isPathInside(legacyRoot, resolved)) continue;
3030
+ userWorkspacePaths.add(resolved);
3031
+ }
3032
+ for (const workspace of workspaces) {
2809
3033
  if (!pluginWorkspaceTitle(workspace.title)) continue;
2810
- if (!isPathInside(baseDir, workspace.path)) continue;
2811
- const routes = activeRoutes.filter((route) => samePath(route.workspacePath, workspace.path));
2812
- if (routes.some((route) => sessionIsLive(deps, route.sessionId))) {
3034
+ const targetPath = path.resolve(workspace.path);
3035
+ if (!isPathInside(baseDir, targetPath)) continue;
3036
+ if (!isPathInside(legacyRoot, targetPath)) continue;
3037
+ const routes = activeRoutes.filter((route) => samePath(route.workspacePath, targetPath) && (route.sessionDir === void 0 || samePath(route.sessionDir, targetPath)));
3038
+ if (routesAreLive(deps, routes)) {
2813
3039
  skippedLive++;
2814
3040
  continue;
2815
3041
  }
2816
3042
  let stats;
2817
3043
  let activityAt = newestActivity(routes.map((route) => route.lastActivityAt));
2818
- if (activityAt === void 0) activityAt = newestActivity(tombstones.filter((tombstone) => tombstone.workspacePath !== void 0 && samePath(tombstone.workspacePath, workspace.path)).map((tombstone) => tombstone.lastActivityAt ?? tombstone.expiredAt));
3044
+ if (activityAt === void 0) activityAt = newestActivity(tombstones.filter((tombstone) => tombstone.workspacePath !== void 0 && samePath(tombstone.workspacePath, targetPath) && (tombstone.sessionDir === void 0 || samePath(tombstone.sessionDir, targetPath))).map((tombstone) => tombstone.lastActivityAt ?? tombstone.expiredAt));
2819
3045
  if (activityAt === void 0) {
2820
- stats = await readWorkspaceStats(deps, workspace.path);
3046
+ stats = await readWorkspaceStats(deps, targetPath);
2821
3047
  activityAt = stats !== void 0 && Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : void 0;
2822
3048
  }
2823
3049
  if (activityAt === void 0 || activityAt >= cutoff) continue;
2824
- stats ??= await readWorkspaceStats(deps, workspace.path);
3050
+ stats ??= await readWorkspaceStats(deps, targetPath);
2825
3051
  candidates.push({
2826
- workspace,
3052
+ target: {
3053
+ kind: "workspace",
3054
+ workspace
3055
+ },
3056
+ path: workspace.path,
2827
3057
  activityAt,
2828
3058
  bytes: stats === void 0 ? 0 : nonNegative(stats.bytes),
2829
3059
  files: stats === void 0 ? 0 : nonNegative(stats.files),
2830
3060
  routes
2831
3061
  });
2832
3062
  }
2833
- candidates.sort((left, right) => left.activityAt - right.activityAt || left.workspace.path.localeCompare(right.workspace.path));
3063
+ const dirs = /* @__PURE__ */ new Map();
3064
+ const evidenceFor = (directory) => {
3065
+ const key = path.resolve(directory);
3066
+ let evidence = dirs.get(key);
3067
+ if (evidence === void 0) {
3068
+ evidence = {
3069
+ routes: [],
3070
+ tombstoneActivity: []
3071
+ };
3072
+ dirs.set(key, evidence);
3073
+ }
3074
+ return evidence;
3075
+ };
3076
+ /** Whether a directory is a plausible per-topic dir (never a workspace root). */
3077
+ const acceptable = (directory) => {
3078
+ const resolved = path.resolve(directory);
3079
+ if (isFilesystemRoot(resolved)) return false;
3080
+ if (!isPathInside(baseDir, resolved)) return false;
3081
+ if (resolved === legacyRoot || isPathInside(legacyRoot, resolved)) return false;
3082
+ if (userWorkspacePaths.has(resolved)) return false;
3083
+ return true;
3084
+ };
3085
+ for (const route of activeRoutes) {
3086
+ if (route.sessionDir === void 0 || samePath(route.sessionDir, route.workspacePath)) continue;
3087
+ if (!acceptable(route.sessionDir)) continue;
3088
+ evidenceFor(route.sessionDir).routes.push(route);
3089
+ }
3090
+ for (const tombstone of tombstones) {
3091
+ if (tombstone.sessionDir === void 0) continue;
3092
+ if (tombstone.workspacePath !== void 0 && samePath(tombstone.sessionDir, tombstone.workspacePath)) continue;
3093
+ if (!acceptable(tombstone.sessionDir)) continue;
3094
+ evidenceFor(tombstone.sessionDir).tombstoneActivity.push(tombstone.lastActivityAt ?? tombstone.expiredAt);
3095
+ }
3096
+ for (const scanned of await deps.listSessionDirs()) {
3097
+ if (!acceptable(scanned.path)) continue;
3098
+ evidenceFor(scanned.path).mtimeMs = scanned.mtimeMs;
3099
+ }
3100
+ for (const [directory, evidence] of dirs) {
3101
+ if (routesAreLive(deps, evidence.routes)) {
3102
+ skippedLive++;
3103
+ continue;
3104
+ }
3105
+ let activityAt = newestActivity(evidence.routes.map((route) => route.lastActivityAt));
3106
+ activityAt ??= newestActivity(evidence.tombstoneActivity);
3107
+ activityAt ??= evidence.mtimeMs;
3108
+ if (activityAt === void 0 || activityAt >= cutoff) continue;
3109
+ const stats = await readWorkspaceStats(deps, directory);
3110
+ if (stats === void 0 && evidence.routes.length === 0) continue;
3111
+ candidates.push({
3112
+ target: {
3113
+ kind: "session-dir",
3114
+ path: directory
3115
+ },
3116
+ path: directory,
3117
+ activityAt,
3118
+ bytes: stats === void 0 ? 0 : nonNegative(stats.bytes),
3119
+ files: stats === void 0 ? 0 : nonNegative(stats.files),
3120
+ routes: evidence.routes
3121
+ });
3122
+ }
3123
+ candidates.sort((left, right) => left.activityAt - right.activityAt || left.path.localeCompare(right.path));
2834
3124
  return {
2835
3125
  retentionMs: window,
2836
3126
  cutoff,
@@ -2839,56 +3129,68 @@ async function planWorkspaceCleanup(deps) {
2839
3129
  };
2840
3130
  }
2841
3131
  /**
2842
- * Apply a plan: retire the candidate's active routes, then remove the
2843
- * workspace directory and its registry record.
3132
+ * Apply a plan: retire the candidate's active routes, then remove its
3133
+ * directory (and, for legacy workspaces, the registry record).
2844
3134
  *
2845
3135
  * A candidate is the unit of failure: if retiring any of its routes fails, the
2846
- * workspace is left untouched (route bookkeeping must not point at a deleted
3136
+ * directory is left untouched (route bookkeeping must not point at a deleted
2847
3137
  * directory), the failure is collected, and the remaining candidates are still
2848
3138
  * processed. Paths are revalidated immediately before the destructive step, so
2849
- * a stale or hand-built plan can never delete something outside `baseDir` (or
2850
- * `baseDir` itself).
3139
+ * a stale or hand-built plan can never delete `baseDir` itself, a user
3140
+ * workspace, or something outside `baseDir`.
2851
3141
  *
2852
3142
  * @param deps - injected effect seams.
2853
3143
  * @param plan - plan produced by `planWorkspaceCleanup` (or an equivalent preview).
2854
- * @returns aggregate counts plus per-workspace failures.
3144
+ * @returns aggregate counts plus per-target failures.
2855
3145
  */
2856
3146
  async function applyWorkspaceCleanup(deps, plan) {
2857
3147
  const baseDir = path.resolve(deps.baseDir);
2858
3148
  const baseDirIsRoot = isFilesystemRoot(baseDir);
3149
+ const legacyRoot = legacyTopicWorkspaceRoot(baseDir);
3150
+ const registered = new Set((await deps.listWorkspaces()).filter((workspace) => pluginWorkspaceTitle(workspace.title)).map((workspace) => path.resolve(workspace.path)));
2859
3151
  const failures = [];
2860
3152
  let deletedWorkspaces = 0;
3153
+ let deletedSessionDirs = 0;
2861
3154
  let retiredRoutes = 0;
2862
3155
  let freedBytes = 0;
2863
3156
  for (const candidate of plan.candidates) {
2864
- const target = candidate.workspace.path;
2865
- if (baseDirIsRoot || !isPathInside(baseDir, target)) {
3157
+ const target = candidate.target;
3158
+ const targetPath = candidate.path;
3159
+ const insideBase = !baseDirIsRoot && isPathInside(baseDir, targetPath);
3160
+ const inLegacyRoot = isPathInside(legacyRoot, targetPath);
3161
+ if (!(target.kind === "workspace" ? insideBase && inLegacyRoot : insideBase && !inLegacyRoot && !registered.has(path.resolve(targetPath)))) {
2866
3162
  failures.push({
2867
- path: target,
2868
- message: "refused: workspace path is outside the cleanup base directory"
3163
+ path: targetPath,
3164
+ message: "refused: path is not a cleanable plugin directory"
2869
3165
  });
2870
3166
  continue;
2871
3167
  }
2872
3168
  try {
2873
3169
  for (const route of candidate.routes) {
2874
- if (!samePath(route.workspacePath, target)) continue;
3170
+ if (!samePath(directoryOfRoute(route), targetPath)) continue;
2875
3171
  await deps.retireRoute(route);
2876
3172
  retiredRoutes++;
2877
3173
  }
2878
- await deps.removeWorkspace(candidate.workspace);
2879
- deletedWorkspaces++;
3174
+ if (target.kind === "workspace") {
3175
+ await deps.removeWorkspace(target.workspace);
3176
+ deletedWorkspaces++;
3177
+ } else {
3178
+ await deps.removeSessionDir(targetPath);
3179
+ deletedSessionDirs++;
3180
+ }
2880
3181
  freedBytes += nonNegative(candidate.bytes);
2881
3182
  } catch (error) {
2882
- const message = errorMessage(error);
3183
+ const message = error instanceof Error ? error.message : String(error);
2883
3184
  failures.push({
2884
- path: target,
3185
+ path: targetPath,
2885
3186
  message
2886
3187
  });
2887
- deps.warn?.(`dsh-channels: workspace cleanup failed for ${target}: ${message}`);
3188
+ deps.warn?.(`dsh-channels: cleanup failed for ${targetPath}: ${message}`);
2888
3189
  }
2889
3190
  }
2890
3191
  return {
2891
3192
  deletedWorkspaces,
3193
+ deletedSessionDirs,
2892
3194
  retiredRoutes,
2893
3195
  freedBytes,
2894
3196
  failures
@@ -3009,6 +3311,8 @@ var RouteStore = class {
3009
3311
  senderOpenId: record.senderOpenId,
3010
3312
  expiredAt: now,
3011
3313
  workspacePath: record.workspacePath,
3314
+ ...record.sessionDir === void 0 ? {} : { sessionDir: record.sessionDir },
3315
+ ...record.worktreeBranch === void 0 ? {} : { worktreeBranch: record.worktreeBranch },
3012
3316
  lastActivityAt: record.lastActivityAt
3013
3317
  };
3014
3318
  await this.deps.domain()?.expiredRoutes.put(key, tombstone);
@@ -3135,11 +3439,7 @@ var InflightRepair = class {
3135
3439
  if (channel === void 0) throw new Error("dsh-channels: channel not started");
3136
3440
  for (const [index, card] of snapshot.cards.entries()) {
3137
3441
  if (card.mode !== "card") continue;
3138
- const options = index === 0 ? {} : {
3139
- panel: false,
3140
- segment: index + 1
3141
- };
3142
- await channel.updateCard(card.messageId, buildInterruptionCard(options));
3442
+ await channel.updateCard(card.messageId, buildInterruptionCard(segmentCardOptions(index)));
3143
3443
  }
3144
3444
  if (snapshot.reactionId !== void 0) await channel.removeReaction(snapshot.triggerMessageId, snapshot.reactionId).catch(() => {});
3145
3445
  await channel.addReaction(snapshot.triggerMessageId, "DONE").catch(() => {});
@@ -3157,13 +3457,13 @@ const FEISHU_ATTACHMENTS_SECTION = "dsh-channels:feishu-attachments";
3157
3457
  const FEISHU_ATTACHMENTS_ORDER = 950;
3158
3458
  /** Model-facing text. Kept stable so unchanged sessions keep their KV cache. */
3159
3459
  const FEISHU_ATTACHMENTS_GUIDANCE = `## Feishu attachments
3160
- Every Feishu conversation (topic) has its own workspace and its own attachments directory. Images, files, and forwarded chat records sent through Feishu are archived verbatim in this conversation's \`attachments/\` directory. Each file name starts with its receipt time (\`YYYYMMDD-HHmmss-\`), so newer attachments sort later and \`ls -lt <dir>\` lists the newest first.
3460
+ Every Feishu conversation (topic) has its own working directory inside its user's workspace, and every working directory has its own \`attachments/\` directory. Images, files, and forwarded chat records sent through Feishu are archived verbatim in this conversation's \`attachments/\` directory. Each file name starts with its receipt time (\`YYYYMMDD-HHmmss-\`), so newer attachments sort later and \`ls -lt <dir>\` lists the newest first.
3161
3461
 
3162
3462
  Attachments are never inserted into the conversation automatically. Read them only when the user's request needs them, and do not read attachments the user did not mention:
3163
3463
  - images: use \`read_image\` with the file path;
3164
3464
  - text and code: use \`read\` when the session mounts that tool, otherwise \`bash\` (for example \`sed -n '1,200p' <file>\`);
3165
3465
  - other formats (PDF, Office, archives, ...): inspect or convert them with \`bash\`.
3166
- If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations have separate workspaces: never read outside this workspace, and never mention files from another conversation.`;
3466
+ If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations work in separate directories: never read outside this conversation's working directory, and never mention files from another conversation.`;
3167
3467
  /**
3168
3468
  * Full guidance for one Session. The absolute per-session directory is
3169
3469
  * appended when known so the convention still resolves after the Agent changes
@@ -3808,155 +4108,159 @@ function installRequestPolicy(agentCtx, config = {}) {
3808
4108
  };
3809
4109
  }
3810
4110
  //#endregion
3811
- //#region src/workspaces.ts
4111
+ //#region src/session/isolation.ts
3812
4112
  /**
3813
- * Workspace directory resolution: one DSH workspace per (user × chat).
3814
- *
3815
- * Every Feishu topic gets its own workspace, grouped by the (chat, user) pair:
3816
- * - p2p: `<baseDir>/p2p/<open_id>/<topic>/`
3817
- * - group: `<baseDir>/groups/<chat_id>/<open_id>/<topic>/`
4113
+ * Per-topic Session placement: which directory each Feishu conversation works
4114
+ * in, without growing the host workspace list with every topic.
3818
4115
  *
3819
- * Attachments received before a topic exists are staged below
3820
- * `<baseDir>/_staging/<chatType>/<chat_id>/<open_id>/` and adopted into the
3821
- * topic workspace once its first Session is created.
4116
+ * A Session's **header cwd** is fixed at creation and decides two things:
4117
+ * its workspace-registry membership (the GUI project it is grouped under) and
4118
+ * the sandbox write boundary. The **effective working directory** is a mutable
4119
+ * Session projection (`ctx.workingDirectory`), persisted in the Session log
4120
+ * and restored on resume; the bash/fs/terminal tools resolve it before every
4121
+ * call.
3822
4122
  *
3823
- * Feishu ids (`ou_…`, `oc_…`) are safe path segments, but they are validated
3824
- * anyway so a hostile id can never escape `baseDir`.
4123
+ * This module plans one topic's placement:
3825
4124
  *
3826
- * @module dsh-channels/workspaces
3827
- */
3828
- const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
3829
- /** Upper bound for a panel-supplied workspace root (filesystem path limit headroom). */
3830
- const MAX_BASE_DIR_LENGTH = 4096;
3831
- /**
3832
- * Validate and normalize a workspace root supplied by the settings panel.
4125
+ * - `subdir`: `<workspace>/topics/<token>` — a plain directory switched to via
4126
+ * `workingDirectory.set`.
4127
+ * - `worktree`: a Git worktree created through `ctx.worktrees` (branch
4128
+ * `feishu-<token>`), used when the workspace is a Git repository or when the
4129
+ * deployment forces it.
3833
4130
  *
3834
- * The profile layer keeps accepting whatever the deployment wrote (including
3835
- * the historical relative default), but a value *typed into the Web panel*
3836
- * must name an unambiguous location: a relative path would resolve against
3837
- * the host process's unpredictable cwd. `~` is expanded for convenience and
3838
- * the result is normalized; the root itself is rejected because workspace
3839
- * directories are appended to it.
4131
+ * Both modes keep the Session's header cwd on the workspace root, so the
4132
+ * Session stays grouped under the user's project. When the host exposes no
4133
+ * `workingDirectory` service (unusual: it ships with `dsh-base`), the Session
4134
+ * is created directly in the topic directory instead — file isolation still
4135
+ * holds, but the Session can no longer be attached to the workspace.
3840
4136
  *
3841
- * @param raw - untrusted panel value.
3842
- * @param home - home directory seam (tests pin it; production uses `homedir()`).
3843
- * @returns the normalized absolute path, or a user-facing message.
4137
+ * @module dsh-channels/session/isolation
3844
4138
  */
3845
- function normalizeWorkspaceBaseDir(raw, home = homedir()) {
3846
- if (typeof raw !== "string") return {
3847
- ok: false,
3848
- message: "必须是字符串"
3849
- };
3850
- const trimmed = raw.trim();
3851
- if (trimmed === "") return {
3852
- ok: false,
3853
- message: "不能为空"
3854
- };
3855
- if (trimmed.length > MAX_BASE_DIR_LENGTH) return {
3856
- ok: false,
3857
- message: `路径长度不得超过 ${MAX_BASE_DIR_LENGTH} 个字符`
3858
- };
3859
- if (/[\u0000-\u001f\u007f]/.test(trimmed)) return {
3860
- ok: false,
3861
- message: "包含非法控制字符"
3862
- };
3863
- let expanded = trimmed;
3864
- if (expanded === "~") expanded = home;
3865
- else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) expanded = path.join(home, expanded.slice(2));
3866
- if (!path.isAbsolute(expanded)) return {
3867
- ok: false,
3868
- message: "必须是绝对路径(例如 /Users/you/.dsh/dsh-channels/workspaces)"
3869
- };
3870
- const normalized = path.resolve(expanded);
3871
- if (path.parse(normalized).root === normalized) return {
3872
- ok: false,
3873
- message: "不能使用文件系统根目录"
3874
- };
4139
+ /** Resolve the placement capabilities from the host context (all optional). */
4140
+ function placementServices(ctx) {
4141
+ const get = (name) => ctx.get?.(name);
3875
4142
  return {
3876
- ok: true,
3877
- value: normalized
4143
+ workingDirectory: get("workingDirectory"),
4144
+ worktrees: get("worktrees")
3878
4145
  };
3879
4146
  }
3880
- /**
3881
- * Materialize one panel-supplied workspace root and prove it is usable.
3882
- *
3883
- * Called when the panel saves `baseDir` so an unusable location fails at save
3884
- * time instead of surfacing later as "会话创建失败" inside a Feishu chat.
3885
- * The directory is created here (the same `mkdir -p` the first session would
3886
- * perform); an existing non-directory is refused.
3887
- *
3888
- * @returns an error message, or `undefined` when the directory is writable.
3889
- */
3890
- async function prepareWorkspaceBaseDir(dir) {
3891
- try {
3892
- await mkdir(dir, { recursive: true });
3893
- } catch (error) {
3894
- return `无法创建工作区目录:${String(error)}`;
3895
- }
3896
- try {
3897
- if (!(await stat(dir)).isDirectory()) return "该路径已存在但不是目录";
3898
- } catch (error) {
3899
- return `无法读取工作区目录:${String(error)}`;
3900
- }
4147
+ /** Whether the workspace directory is itself a Git working tree. */
4148
+ async function workspaceHasGitRepository(workspacePath) {
3901
4149
  try {
3902
- await access(dir, constants.W_OK);
4150
+ const info = await stat(path.join(workspacePath, ".git"));
4151
+ return info.isDirectory() || info.isFile();
3903
4152
  } catch {
3904
- return "工作区目录不可写";
4153
+ return false;
3905
4154
  }
3906
4155
  }
3907
- function safeSegment(raw, what) {
3908
- const segment = raw.replace(/^\/+|\/+$/g, "");
3909
- if (!SAFE_SEGMENT.test(segment) || segment === "." || segment === "..") throw new Error(`dsh-channels: unsafe ${what} for workspace path: ${JSON.stringify(raw)}`);
3910
- return segment;
3911
- }
3912
4156
  /**
3913
- * Stable, filesystem-safe token for one Feishu conversation: a real topic uses
3914
- * its thread id, while a degraded chat-scoped conversation uses the Session id
3915
- * that owns it (there is no topic boundary to key on).
4157
+ * Pick the isolation mode for one topic. `auto` upgrades to a worktree only
4158
+ * when both the capability and a Git repository are present; an explicit
4159
+ * `worktree` preference still falls back (with a warning) when unavailable.
3916
4160
  */
3917
- function topicTokenFor(routeKey, kind, sessionId) {
3918
- return kind === "thread" ? safeSegment(routeKey, "route key") : safeSegment(sessionId, "session id");
4161
+ async function chooseTopicMode(services, config, workspacePath, warn) {
4162
+ const requested = config.topicIsolation;
4163
+ if (requested === "subdir") return "subdir";
4164
+ if (!(services.workingDirectory !== void 0 && services.worktrees !== void 0)) {
4165
+ if (requested === "worktree") warn("dsh-channels: topicIsolation=worktree needs the workingDirectory and worktrees host services; using a plain topic directory");
4166
+ return "subdir";
4167
+ }
4168
+ if (requested === "worktree") return "worktree";
4169
+ return await workspaceHasGitRepository(workspacePath) ? "worktree" : "subdir";
3919
4170
  }
3920
4171
  /**
3921
- * Deterministic workspace directory for one Feishu topic: every route gets its
3922
- * own workspace below its (user × chat) grouping directory.
3923
- *
3924
- * - p2p: `<baseDir>/topics/p2p/<open_id>/<topic>/`
3925
- * - group: `<baseDir>/topics/groups/<chat_id>/<open_id>/<topic>/`
4172
+ * Decide how a new Session is placed and materialize everything that must
4173
+ * exist before the Session is created.
3926
4174
  *
3927
- * A separate `topics/` root keeps new workspaces out of the legacy (user ×
3928
- * chat) workspace directories left by older versions, so cleaning one can
3929
- * never delete the other.
4175
+ * The returned `cwd` is the header cwd: the workspace root when the runtime
4176
+ * can switch directories (so the Session is grouped under the user's project),
4177
+ * otherwise the topic directory itself.
3930
4178
  */
3931
- function workspacePathFor(baseDir, chatType, chatId, senderOpenId, topicToken) {
3932
- const chat = safeSegment(chatId, "chatId");
3933
- const user = safeSegment(senderOpenId, "senderOpenId");
3934
- const topic = safeSegment(topicToken, "topic token");
3935
- return chatType === "p2p" ? path.join(baseDir, "topics", "p2p", user, topic) : path.join(baseDir, "topics", "groups", chat, user, topic);
4179
+ async function planTopic(services, config, workspacePath, topicToken, warn) {
4180
+ const mode = await chooseTopicMode(services, config, workspacePath, warn);
4181
+ if (services.workingDirectory !== void 0) {
4182
+ const subdir = mode === "subdir" ? topicDirFor(workspacePath, topicToken) : void 0;
4183
+ if (subdir !== void 0) await mkdir(subdir, { recursive: true });
4184
+ return {
4185
+ mode,
4186
+ cwd: workspacePath,
4187
+ ...subdir === void 0 ? {} : { subdir },
4188
+ switchable: true
4189
+ };
4190
+ }
4191
+ const subdir = topicDirFor(workspacePath, topicToken);
4192
+ await mkdir(subdir, { recursive: true });
4193
+ return {
4194
+ mode: "subdir",
4195
+ cwd: subdir,
4196
+ subdir,
4197
+ switchable: false
4198
+ };
4199
+ }
4200
+ /** Enter the planned topic directory once the Session (and its Agent) exists. */
4201
+ async function enterTopic(services, agent, plan, workspacePath, topicToken, warn) {
4202
+ if (plan.mode === "worktree" && services.worktrees !== void 0) try {
4203
+ const created = await services.worktrees.create(agent, { name: worktreeBranchFor(topicToken) });
4204
+ return {
4205
+ sessionDir: created.path,
4206
+ worktreeBranch: created.branch
4207
+ };
4208
+ } catch (error) {
4209
+ warn("dsh-channels: creating a worktree for topic %s failed, using a plain topic directory instead: %s", topicToken, String(error));
4210
+ }
4211
+ const dir = plan.subdir ?? topicDirFor(workspacePath, topicToken);
4212
+ await mkdir(dir, { recursive: true });
4213
+ if (plan.switchable) await alignWorkingDirectory(services, agent, dir, warn);
4214
+ return { sessionDir: dir };
3936
4215
  }
3937
4216
  /**
3938
- * Staging directory for attachments that arrive before their topic (and so
3939
- * its workspace) exists. Not a registered workspace: files move into the real
3940
- * workspace as soon as the first Session of that topic is created.
4217
+ * Align one live Session with its recorded topic directory: make sure the
4218
+ * directory exists, recreate it (or a fresh worktree) when it disappeared,
4219
+ * and restore the effective working directory the route expects.
4220
+ *
4221
+ * The returned placement may carry a new directory/branch after recovery; the
4222
+ * caller persists it back into the route record.
3941
4223
  */
3942
- function stagingPathFor(baseDir, chatType, chatId, senderOpenId) {
3943
- const chat = safeSegment(chatId, "chatId");
3944
- const user = safeSegment(senderOpenId, "senderOpenId");
3945
- return path.join(baseDir, "_staging", chatType, chat, user);
4224
+ async function restoreTopicDir(services, agent, input, warn) {
4225
+ const { sessionDir, worktreeBranch } = input;
4226
+ if (await isDirectory(sessionDir)) {
4227
+ await alignWorkingDirectory(services, agent, sessionDir, warn);
4228
+ return worktreeBranch === void 0 ? { sessionDir } : {
4229
+ sessionDir,
4230
+ worktreeBranch
4231
+ };
4232
+ }
4233
+ if (worktreeBranch !== void 0 && services.worktrees !== void 0 && services.workingDirectory !== void 0) try {
4234
+ const created = await services.worktrees.create(agent, { name: `${worktreeBranch}-r${randomUUID().slice(0, 8)}` });
4235
+ return {
4236
+ sessionDir: created.path,
4237
+ worktreeBranch: created.branch
4238
+ };
4239
+ } catch (error) {
4240
+ warn("dsh-channels: recreating the worktree for session %s failed, using a plain directory: %s", agent.id, String(error));
4241
+ }
4242
+ await mkdir(sessionDir, { recursive: true });
4243
+ await alignWorkingDirectory(services, agent, sessionDir, warn);
4244
+ return { sessionDir };
3946
4245
  }
3947
- /** Human-friendly workspace title shown in the DSH workspace list. */
3948
- function workspaceTitleFor(chatType, chatId, senderOpenId, topicToken) {
3949
- const base = chatType === "p2p" ? `飞书私聊 · ${senderOpenId}` : `飞书群聊 · ${chatId} · ${senderOpenId}`;
3950
- return topicToken === void 0 ? base : `${base} · ${topicToken}`;
4246
+ /** Best-effort check used only for placement decisions, never for security. */
4247
+ async function isDirectory(dir) {
4248
+ try {
4249
+ return (await stat(dir)).isDirectory();
4250
+ } catch {
4251
+ return false;
4252
+ }
3951
4253
  }
3952
- /**
3953
- * Ensure one topic's workspace directory exists and is registered with the
3954
- * host's workspace registry. Idempotent: repeated calls return the same record.
3955
- */
3956
- async function ensureWorkspace(ctx, baseDir, chatType, chatId, senderOpenId, topicToken) {
3957
- const dir = workspacePathFor(baseDir, chatType, chatId, senderOpenId, topicToken);
3958
- await mkdir(dir, { recursive: true });
3959
- return ctx.workspaceRegistry.create(dir, workspaceTitleFor(chatType, chatId, senderOpenId, topicToken));
4254
+ /** Point the Session's effective working directory at `dir`. */
4255
+ async function alignWorkingDirectory(services, agent, dir, warn) {
4256
+ const workingDirectory = services.workingDirectory;
4257
+ if (workingDirectory === void 0) return;
4258
+ try {
4259
+ if (workingDirectory.get(agent.session) === dir) return;
4260
+ await workingDirectory.set(agent, dir);
4261
+ } catch (error) {
4262
+ warn("dsh-channels: switching session %s to its topic directory failed: %s", agent.id, String(error));
4263
+ }
3960
4264
  }
3961
4265
  //#endregion
3962
4266
  //#region src/session/manager.ts
@@ -4024,10 +4328,12 @@ var SessionManager = class {
4024
4328
  }
4025
4329
  /**
4026
4330
  * Create the root agent for one freshly routed session and register it live.
4027
- * The caller owns the workspace attachment and the failure card.
4331
+ * `cwd` is the Session header cwd (the user workspace when the deployment can
4332
+ * switch directories). The caller owns placement, the workspace attachment,
4333
+ * the attachment guidance, and the failure card.
4028
4334
  */
4029
4335
  async createSession(sessionId, cwd, agentOptions = this.agentOptions()) {
4030
- const composition = await this.presetComposition(void 0, true, attachmentsDir(cwd));
4336
+ const composition = await this.presetComposition(void 0, true);
4031
4337
  const handle = await this.deps.ctx.agents.create({
4032
4338
  sessionId,
4033
4339
  meta: {
@@ -4045,6 +4351,20 @@ var SessionManager = class {
4045
4351
  return handle;
4046
4352
  }
4047
4353
  /**
4354
+ * Register the attachment-directory guidance for one Session. Called after
4355
+ * the effective working directory is known (a worktree checkout path is
4356
+ * only returned once the worktree exists), so the prompt section can name
4357
+ * the absolute directory. Best-effort: a missing system-prompt service
4358
+ * (headless) is a no-op and a late registration never blocks the turn.
4359
+ */
4360
+ async mountGuidance(agent, directory) {
4361
+ try {
4362
+ await mountChannelGuidance(agent.ctx, attachmentsDir(directory));
4363
+ } catch (error) {
4364
+ this.deps.warn("dsh-channels: mounting attachment guidance for session %s failed: %s", agent.id, String(error));
4365
+ }
4366
+ }
4367
+ /**
4048
4368
  * Live agent for a route: this plugin's own → a foreign live agent (borrow)
4049
4369
  * → a serialized cold resume.
4050
4370
  *
@@ -4080,7 +4400,7 @@ var SessionManager = class {
4080
4400
  async resume(record) {
4081
4401
  const sessionId = record.sessionId;
4082
4402
  try {
4083
- const composition = await this.presetComposition(void 0, true, attachmentsDir(record.workspacePath));
4403
+ const composition = await this.presetComposition(void 0, true);
4084
4404
  const handle = await this.deps.ctx.agents.resume({
4085
4405
  resumeSessionId: sessionId,
4086
4406
  agentOptions: this.agentOptions(),
@@ -4091,6 +4411,7 @@ var SessionManager = class {
4091
4411
  throw new Error("dsh-channels: session resume aborted by shutdown");
4092
4412
  }
4093
4413
  this.requestPolicy(handle.agent);
4414
+ await this.mountGuidance(handle.agent, sessionDirOf(record));
4094
4415
  const liveAgent = {
4095
4416
  agent: handle.agent,
4096
4417
  handle
@@ -4124,33 +4445,56 @@ var SessionManager = class {
4124
4445
  controller.sync(agent.session.snapshotEvents());
4125
4446
  return controller;
4126
4447
  }
4127
- /** Create, workspace-attach, and atomically publish one renewed route. */
4448
+ /**
4449
+ * Create, place, workspace-attach, and atomically publish one renewed route.
4450
+ *
4451
+ * A renewal keeps the topic's working directory: the new Session is created
4452
+ * with the user workspace as its header cwd (so it stays grouped under the
4453
+ * project) and then re-entered into the same topic directory or worktree.
4454
+ */
4128
4455
  async renew(record, oldLive) {
4129
4456
  const sessionId = randomUUID();
4130
4457
  const preset = this.effectivePreset(oldLive.agent.session);
4131
- const composition = await this.presetComposition(preset, false, attachmentsDir(record.workspacePath));
4132
- const workspace = await this.deps.ctx.workspaceRegistry.create(record.workspacePath, workspaceTitleFor(record.chatType, record.chatId, record.senderOpenId, record.kind === "thread" ? record.key : record.sessionId));
4458
+ const composition = await this.presetComposition(preset, false);
4459
+ const services = placementServices(this.deps.ctx);
4460
+ const switchable = services.workingDirectory !== void 0;
4461
+ const workspacePath = record.workspacePath;
4462
+ const workspace = await this.deps.ctx.workspaceRegistry.create(workspacePath, workspaceTitleFor(record.chatType, record.chatId, record.senderOpenId));
4133
4463
  let newHandle;
4134
4464
  let attached = false;
4135
4465
  try {
4136
4466
  newHandle = await this.deps.ctx.agents.create({
4137
4467
  sessionId,
4138
4468
  meta: {
4139
- cwd: record.workspacePath,
4469
+ cwd: switchable ? workspacePath : sessionDirOf(record),
4140
4470
  ...composition.agentPreset === void 0 ? {} : { agentPreset: composition.agentPreset }
4141
4471
  },
4142
4472
  agentOptions: { ...oldLive.agent.options },
4143
4473
  setup: composition.setup
4144
4474
  });
4145
4475
  this.requestPolicy(newHandle.agent);
4146
- await workspace.attachSession(sessionId);
4147
- attached = true;
4476
+ const placement = await restoreTopicDir(services, newHandle.agent, {
4477
+ workspacePath,
4478
+ sessionDir: sessionDirOf(record),
4479
+ ...record.worktreeBranch === void 0 ? {} : { worktreeBranch: record.worktreeBranch }
4480
+ }, this.deps.warn.bind(this.deps));
4481
+ if (switchable) {
4482
+ await workspace.attachSession(sessionId);
4483
+ attached = true;
4484
+ }
4485
+ await this.mountGuidance(newHandle.agent, placement.sessionDir);
4148
4486
  const createdAt = newHandle.agent.session.header.createdAt;
4149
- const current = {
4487
+ const base = {
4150
4488
  ...record,
4151
4489
  sessionId,
4152
4490
  sessionHistory: appendSessionHistory(record, sessionId, createdAt),
4153
- lastActivityAt: Date.now()
4491
+ lastActivityAt: Date.now(),
4492
+ sessionDir: placement.sessionDir
4493
+ };
4494
+ delete base.worktreeBranch;
4495
+ const current = placement.worktreeBranch === void 0 ? base : {
4496
+ ...base,
4497
+ worktreeBranch: placement.worktreeBranch
4154
4498
  };
4155
4499
  await this.deps.persistRoute(record.key, current);
4156
4500
  this.live.set(sessionId, {
@@ -4194,13 +4538,16 @@ var SessionManager = class {
4194
4538
  * resumes. The id resolves BEFORE the session exists because the session
4195
4539
  * boundary snapshots `meta` before asynchronous setup begins; mounting stays
4196
4540
  * inside `setup`, where a failure rolls the whole creation back.
4541
+ *
4542
+ * The attachment guidance is mounted later through {@link mountGuidance},
4543
+ * because a Git worktree's checkout path only exists after the Agent (and so
4544
+ * the worktree) has been created.
4197
4545
  */
4198
- async presetComposition(requestedPreset = this.deps.config.agentPreset, useRosterDefault = true, attachmentsDir) {
4546
+ async presetComposition(requestedPreset = this.deps.config.agentPreset, useRosterDefault = true) {
4199
4547
  const presets = this.deps.ctx.get?.("agentPresets");
4200
4548
  const channelCommandsOnly = async (agentCtx) => {
4201
4549
  this.mountRequestPolicy(agentCtx);
4202
4550
  await mountChannelCommands(agentCtx);
4203
- await mountChannelGuidance(agentCtx, attachmentsDir);
4204
4551
  };
4205
4552
  if (presets === void 0 || requestedPreset === void 0 && !useRosterDefault) return { setup: channelCommandsOnly };
4206
4553
  const resolved = await presets.resolve(requestedPreset);
@@ -4210,7 +4557,6 @@ var SessionManager = class {
4210
4557
  this.mountRequestPolicy(agentCtx);
4211
4558
  await presets.mount(agentCtx, resolved.id);
4212
4559
  await mountChannelCommands(agentCtx);
4213
- await mountChannelGuidance(agentCtx, attachmentsDir);
4214
4560
  }
4215
4561
  };
4216
4562
  }
@@ -4595,7 +4941,7 @@ var InboundRouter = class {
4595
4941
  attachmentOnly: true,
4596
4942
  text: ""
4597
4943
  };
4598
- const directory = record === void 0 ? stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId) : attachmentsDir(record.workspacePath);
4944
+ const directory = record === void 0 ? stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId) : attachmentsDir(sessionDirOf(record));
4599
4945
  return await this.attachments.archive(msg, directory);
4600
4946
  });
4601
4947
  }
@@ -4673,11 +5019,13 @@ var InboundRouter = class {
4673
5019
  const typingId = await typingPromise;
4674
5020
  const key = sent.degraded || sent.threadId === void 0 ? chatKey : sent.threadId;
4675
5021
  const kind = sent.degraded || sent.threadId === void 0 ? "chat" : "thread";
4676
- let workspace;
4677
- try {
4678
- workspace = await ensureWorkspace(this.ctx, this.config.baseDir, msg.chatType, msg.chatId, msg.senderId, topicTokenFor(key, kind, sessionId));
4679
- await adoptStagedAttachments(stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId), workspace.path);
4680
- } catch (error) {
5022
+ const topicToken = topicTokenFor(key, kind, sessionId);
5023
+ const services = placementServices(this.ctx);
5024
+ const warn = (message, ...args) => {
5025
+ this.ctx.logger?.warn?.(message, ...args);
5026
+ };
5027
+ /** Patch the streaming card to its terminal creation-failure state. */
5028
+ const failCreation = async (error) => {
4681
5029
  this.repair.clear(sent.messageId);
4682
5030
  await this.delivery.requireChannel().updateCard(sent.messageId, buildCard({
4683
5031
  phase: "done",
@@ -4685,6 +5033,14 @@ var InboundRouter = class {
4685
5033
  body: `会话创建失败:${String(error)}`,
4686
5034
  stats: { ...initialStats }
4687
5035
  })).catch(() => {});
5036
+ };
5037
+ let workspace;
5038
+ let plan;
5039
+ try {
5040
+ workspace = await ensureWorkspace(this.ctx, this.config.baseDir, msg.chatType, msg.chatId, msg.senderId);
5041
+ plan = await planTopic(services, this.config, workspace.path, topicToken, warn);
5042
+ } catch (error) {
5043
+ await failCreation(error);
4688
5044
  throw error;
4689
5045
  }
4690
5046
  await this.repair.write({
@@ -4701,21 +5057,23 @@ var InboundRouter = class {
4701
5057
  attempts: 0
4702
5058
  });
4703
5059
  let handle;
5060
+ let placement;
4704
5061
  try {
4705
- handle = await this.sessions.createSession(sessionId, workspace.path);
5062
+ handle = await this.sessions.createSession(sessionId, plan.cwd);
5063
+ placement = await enterTopic(services, handle.agent, plan, workspace.path, topicToken, warn);
5064
+ if (plan.cwd === workspace.path) Promise.resolve(workspace.attachSession(sessionId)).catch((error) => {
5065
+ warn("dsh-channels: attaching initial session %s to workspace failed: %s", sessionId, String(error));
5066
+ });
5067
+ await adoptStagedAttachments(stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId), placement.sessionDir).catch((error) => {
5068
+ warn("dsh-channels: adopting staged attachments failed: %s", String(error));
5069
+ });
5070
+ await this.sessions.mountGuidance(handle.agent, placement.sessionDir);
4706
5071
  } catch (error) {
4707
- this.repair.clear(sent.messageId);
4708
- await this.delivery.requireChannel().updateCard(sent.messageId, buildCard({
4709
- phase: "done",
4710
- reasoning: "",
4711
- body: `会话创建失败:${String(error)}`,
4712
- stats: { ...initialStats }
4713
- })).catch(() => {});
5072
+ await handle?.dispose().catch(() => {});
5073
+ await failCreation(error);
4714
5074
  throw error;
4715
5075
  }
4716
- Promise.resolve(workspace.attachSession(sessionId)).catch((error) => {
4717
- this.ctx.logger?.warn?.("dsh-channels: attaching initial session %s to workspace failed: %s", sessionId, String(error));
4718
- });
5076
+ if (handle === void 0 || placement === void 0) throw new Error("dsh-channels: session creation completed without a handle");
4719
5077
  const createdAt = handle.agent.session.header.createdAt;
4720
5078
  const record = {
4721
5079
  key,
@@ -4725,6 +5083,8 @@ var InboundRouter = class {
4725
5083
  chatType: msg.chatType,
4726
5084
  senderOpenId: msg.senderId,
4727
5085
  workspacePath: workspace.path,
5086
+ sessionDir: placement.sessionDir,
5087
+ ...placement.worktreeBranch === void 0 ? {} : { worktreeBranch: placement.worktreeBranch },
4728
5088
  cardMessageId: sent.messageId,
4729
5089
  createdAt,
4730
5090
  lastActivityAt: createdAt,
@@ -4740,16 +5100,37 @@ var InboundRouter = class {
4740
5100
  }
4741
5101
  async continueSession(msg, record, content) {
4742
5102
  if (!this.access.ownsRoute(msg, record)) return;
4743
- await adoptStagedAttachments(stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId), record.workspacePath).catch((error) => {
4744
- this.ctx.logger?.warn?.("dsh-channels: adopting staged attachments failed: %s", String(error));
4745
- });
5103
+ const services = placementServices(this.ctx);
5104
+ const warn = (message, ...args) => {
5105
+ this.ctx.logger?.warn?.(message, ...args);
5106
+ };
4746
5107
  const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, "Typing").catch(() => void 0);
4747
5108
  const live = await this.sessions.ensureLive(record);
4748
- const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(record.sessionId));
5109
+ const placement = await restoreTopicDir(services, live.agent, {
5110
+ workspacePath: record.workspacePath,
5111
+ sessionDir: sessionDirOf(record),
5112
+ ...record.worktreeBranch === void 0 ? {} : { worktreeBranch: record.worktreeBranch }
5113
+ }, warn);
5114
+ let current = record;
5115
+ if (placement.sessionDir !== sessionDirOf(record) || placement.worktreeBranch !== record.worktreeBranch) {
5116
+ current = {
5117
+ ...record,
5118
+ sessionDir: placement.sessionDir
5119
+ };
5120
+ delete current.worktreeBranch;
5121
+ if (placement.worktreeBranch !== void 0) current = {
5122
+ ...current,
5123
+ worktreeBranch: placement.worktreeBranch
5124
+ };
5125
+ }
5126
+ await adoptStagedAttachments(stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId), placement.sessionDir).catch((error) => {
5127
+ this.ctx.logger?.warn?.("dsh-channels: adopting staged attachments failed: %s", String(error));
5128
+ });
5129
+ const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(current.sessionId));
4749
5130
  const typingId = await typingPromise;
4750
5131
  await this.repair.write({
4751
5132
  key: sent.messageId,
4752
- sessionId: record.sessionId,
5133
+ sessionId: current.sessionId,
4753
5134
  chatId: msg.chatId,
4754
5135
  triggerMessageId: msg.messageId,
4755
5136
  reactionId: typingId,
@@ -4760,8 +5141,8 @@ var InboundRouter = class {
4760
5141
  startedAt: Date.now(),
4761
5142
  attempts: 0
4762
5143
  });
4763
- await this.routes.put(record.key, {
4764
- ...record,
5144
+ await this.routes.put(current.key, {
5145
+ ...current,
4765
5146
  cardMessageId: sent.messageId,
4766
5147
  lastActivityAt: Date.now()
4767
5148
  });
@@ -5367,7 +5748,7 @@ function statusExtrasElements(extras) {
5367
5748
  if (extras === void 0) return [];
5368
5749
  const lines = [];
5369
5750
  if (extras.permissionPreset !== void 0) lines.push(`**权限预设**:\`${escapeInlineCode(extras.permissionPreset)}\``);
5370
- if (extras.workspacePath !== void 0) lines.push(`**工作区**:\`${escapeInlineCode(extras.workspacePath)}\``);
5751
+ if (extras.sessionDir !== void 0) lines.push(`**会话目录**:\`${escapeInlineCode(extras.sessionDir)}\``);
5371
5752
  if (extras.uptimeMs !== void 0) lines.push(`**插件运行时长**:${fmtUptime(extras.uptimeMs)}`);
5372
5753
  if (extras.liveAgents !== void 0 || extras.routeCount !== void 0) {
5373
5754
  const parts = [];
@@ -5388,30 +5769,43 @@ function fmtUptime(ms) {
5388
5769
  const hours = Math.floor(totalSeconds / 3600);
5389
5770
  return hours === 0 ? `${minutes} 分` : `${hours} 小时 ${minutes} 分`;
5390
5771
  }
5391
- /** Validate the Session header cwd against the plugin's durable route. */
5392
- function resolveWorkDir(headerCwd, routeWorkspacePath) {
5772
+ /**
5773
+ * Validate the Session header cwd against the plugin's durable route and
5774
+ * return the Session's effective working directory.
5775
+ *
5776
+ * The header cwd is normally the workspace root (the Session stays grouped
5777
+ * under the user's project while working in a per-topic directory). In the
5778
+ * degraded placement used when the host exposes no `workingDirectory` service,
5779
+ * the header cwd is the topic directory itself.
5780
+ */
5781
+ function resolveWorkDir(headerCwd, route) {
5393
5782
  if (headerCwd === void 0 || headerCwd === "" || !path.isAbsolute(headerCwd)) return {
5394
5783
  kind: "missing",
5395
5784
  text: "当前 Session header 未记录可用的绝对工作目录;请检查 Session 创建或恢复配置。"
5396
5785
  };
5397
- if (!path.isAbsolute(routeWorkspacePath)) return {
5786
+ if (!path.isAbsolute(route.workspacePath)) return {
5787
+ kind: "missing",
5788
+ text: "当前飞书路由未记录可用的工作区目录;请检查路由持久化状态。"
5789
+ };
5790
+ if (route.sessionDir !== void 0 && !path.isAbsolute(route.sessionDir)) return {
5398
5791
  kind: "missing",
5399
- text: "当前飞书路由未记录可用的绝对工作目录;请检查路由持久化状态。"
5792
+ text: "当前飞书路由未记录可用的会话目录;请检查路由持久化状态。"
5400
5793
  };
5401
- if (headerCwd !== routeWorkspacePath) return {
5794
+ const expected = route.sessionDir ?? route.workspacePath;
5795
+ if (headerCwd !== route.workspacePath && headerCwd !== expected) return {
5402
5796
  kind: "mismatch",
5403
5797
  text: "Session header 与飞书路由记录的工作目录不一致;为避免返回错误目录,已拒绝显示。"
5404
5798
  };
5405
5799
  return {
5406
5800
  kind: "success",
5407
- path: headerCwd
5801
+ path: expected
5408
5802
  };
5409
5803
  }
5410
- /** Render one verified absolute workspace path. */
5411
- function buildWorkDirCard(sessionId, workspacePath) {
5804
+ /** Render one verified absolute working directory. */
5805
+ function buildWorkDirCard(sessionId, sessionDir) {
5412
5806
  return standaloneCard("**📁 当前工作目录**", [{
5413
5807
  tag: "markdown",
5414
- content: `\`${escapeInlineCode(workspacePath)}\``
5808
+ content: `\`${escapeInlineCode(sessionDir)}\``
5415
5809
  }, {
5416
5810
  tag: "markdown",
5417
5811
  content: sessionId,
@@ -5422,7 +5816,7 @@ function buildWorkDirCard(sessionId, workspacePath) {
5422
5816
  function buildRenewCard(input) {
5423
5817
  return standaloneCard("**🔄 Session 已续建**", [{
5424
5818
  tag: "markdown",
5425
- content: `**旧 Session**:${input.previousSessionId}\n**新 Session**:${input.currentSessionId}\n**复用工作区**:\`${escapeInlineCode(input.workspacePath)}\``
5819
+ content: `**旧 Session**:${input.previousSessionId}\n**新 Session**:${input.currentSessionId}\n**复用目录**:\`${escapeInlineCode(input.sessionDir)}\``
5426
5820
  }, {
5427
5821
  tag: "markdown",
5428
5822
  content: "旧 Session 及其日志仍由 Harness 保留。",
@@ -5562,7 +5956,7 @@ async function createLogicalSessionExport(session, options = {}) {
5562
5956
  * console can confirm which build of the activity status line is loaded.
5563
5957
  * Bump on every behavior change to this feature.
5564
5958
  */
5565
- const ACTIVITY_BUILD_MARKER = "activity-status-v6";
5959
+ const ACTIVITY_BUILD_MARKER = "activity-status-v7";
5566
5960
  /** Hard cap on the activity line so it can never threaten the card budget. */
5567
5961
  const ACTIVITY_MAX_CHARS = 64;
5568
5962
  /** Trim an activity line to `ACTIVITY_MAX_CHARS` (ellipsis counts as one char). */
@@ -5639,7 +6033,6 @@ var TurnStreamer = class {
5639
6033
  reasoning: "",
5640
6034
  header: void 0,
5641
6035
  lastUsage: void 0,
5642
- failure: void 0,
5643
6036
  cards: [{
5644
6037
  messageId: cardMessageId,
5645
6038
  body: "",
@@ -5657,6 +6050,8 @@ var TurnStreamer = class {
5657
6050
  finalized: false,
5658
6051
  disposed: false,
5659
6052
  turnStartedAt: Date.now(),
6053
+ reasoningSince: void 0,
6054
+ reasoningMs: 0,
5660
6055
  toolActivity: void 0,
5661
6056
  workflowRuns: /* @__PURE__ */ new Map(),
5662
6057
  workflowEndStatus: void 0,
@@ -5823,12 +6218,14 @@ var TurnStreamer = class {
5823
6218
  if (state === void 0 || state.disposed) return;
5824
6219
  switch (frame.type) {
5825
6220
  case "start":
6221
+ this.closeReasoning(state);
5826
6222
  state.attempts.set(frame.attemptId, this.attemptMark(state));
5827
6223
  if (state.turn === void 0) state.turn = frame.turn;
5828
6224
  break;
5829
6225
  case "chunk": {
5830
6226
  const chunk = frame.chunk;
5831
6227
  if (chunk.type === "text-delta") {
6228
+ if (chunk.text !== "") this.closeReasoning(state);
5832
6229
  const last = state.cards[state.cards.length - 1];
5833
6230
  if (last !== void 0 && !last.frozen && chunk.text !== "") {
5834
6231
  last.body += chunk.text;
@@ -5836,12 +6233,14 @@ var TurnStreamer = class {
5836
6233
  this.markDirty(session.id, state);
5837
6234
  }
5838
6235
  } else if (chunk.type === "reasoning-delta" && chunk.text !== "") {
6236
+ if (state.reasoningSince === void 0) state.reasoningSince = Date.now();
5839
6237
  state.reasoning += chunk.text;
5840
6238
  this.markDirty(session.id, state);
5841
6239
  }
5842
6240
  break;
5843
6241
  }
5844
6242
  case "end": {
6243
+ this.closeReasoning(state);
5845
6244
  const mark = state.attempts.get(frame.attemptId);
5846
6245
  state.attempts.delete(frame.attemptId);
5847
6246
  if (frame.outcome.kind === "abandoned" && mark !== void 0) {
@@ -5857,9 +6256,17 @@ var TurnStreamer = class {
5857
6256
  return {
5858
6257
  cardCount: state.cards.length,
5859
6258
  bodyLengths: state.cards.map((card) => card.body.length),
5860
- reasoningLength: state.reasoning.length
6259
+ reasoningLength: state.reasoning.length,
6260
+ reasoningMs: state.reasoningMs,
6261
+ reasoningSince: state.reasoningSince
5861
6262
  };
5862
6263
  }
6264
+ /** Close an open reasoning phase, accumulating its wall-clock time. */
6265
+ closeReasoning(state, now = Date.now()) {
6266
+ if (state.reasoningSince === void 0) return;
6267
+ state.reasoningMs += Math.max(0, now - state.reasoningSince);
6268
+ state.reasoningSince = void 0;
6269
+ }
5863
6270
  /**
5864
6271
  * Drop one abandoned attempt's deltas. The attempt's text is a contiguous
5865
6272
  * suffix of the logical body: segments created during the attempt are
@@ -5882,6 +6289,8 @@ var TurnStreamer = class {
5882
6289
  tail.dirty = true;
5883
6290
  }
5884
6291
  state.reasoning = state.reasoning.slice(0, mark.reasoningLength);
6292
+ state.reasoningMs = mark.reasoningMs;
6293
+ state.reasoningSince = mark.reasoningSince;
5885
6294
  }
5886
6295
  markDirty(sessionId, state) {
5887
6296
  if (state.disposed) return;
@@ -5915,7 +6324,8 @@ var TurnStreamer = class {
5915
6324
  contextTokens: context?.contextTokens,
5916
6325
  contextWindow: header === void 0 ? void 0 : context?.contextWindow ?? projected?.contextWindow,
5917
6326
  model: header?.model,
5918
- effort: header?.effort
6327
+ effort: header?.effort,
6328
+ reasoningMs: state.reasoning === "" ? void 0 : state.reasoningMs
5919
6329
  };
5920
6330
  }
5921
6331
  /**
@@ -5983,10 +6393,10 @@ var TurnStreamer = class {
5983
6393
  * an elapsed counter so the card never sits silently.
5984
6394
  * Once the turn is finalized, the line switches to the SETTLED form
5985
6395
  * (workflow conclusion + finished-subagent summary) — the final card keeps
5986
- * the delegation status; only the tool line disappears.
6396
+ * the delegation status; only the tool line disappears. A failed turn shows
6397
+ * its diagnostic once, in the body: the status line never repeats it.
5987
6398
  */
5988
6399
  delegationLine(state, now) {
5989
- if (state.failure !== void 0) return clipActivity(`🔴 本轮运行失败${state.failure.code === void 0 ? "" : ` · ${state.failure.code}`}`);
5990
6400
  if (state.finalized) return this.settledLine(state);
5991
6401
  let latest;
5992
6402
  for (const run of state.workflowRuns.values()) if (latest === void 0 || run.startedAt > latest.startedAt) latest = run;
@@ -6062,7 +6472,14 @@ var TurnStreamer = class {
6062
6472
  this.scheduleHeartbeat(sessionId, state);
6063
6473
  }, this.heartbeatMs);
6064
6474
  }
6065
- cardState(sessionId, state, body) {
6475
+ /**
6476
+ * Snapshot one card. `active` is true for the card currently streaming:
6477
+ * only that card renders the transient activity lines, so frozen segment
6478
+ * cards never repeat a stale tool/delegation line. Both lines are still
6479
+ * recomputed (and remembered) on every snapshot so heartbeat/transition
6480
+ * logging stays accurate.
6481
+ */
6482
+ cardState(sessionId, state, body, active = true) {
6066
6483
  const now = Date.now();
6067
6484
  const status = this.delegationLine(state, now);
6068
6485
  if (status !== state.status) {
@@ -6080,8 +6497,8 @@ var TurnStreamer = class {
6080
6497
  phase: state.phase,
6081
6498
  reasoning: state.reasoning,
6082
6499
  body,
6083
- status: state.status,
6084
- tool: state.tool,
6500
+ status: active ? state.status : "",
6501
+ tool: active ? state.tool : "",
6085
6502
  stats: this.statsFor(sessionId, state)
6086
6503
  };
6087
6504
  }
@@ -6106,10 +6523,7 @@ var TurnStreamer = class {
6106
6523
  const index = state.cards.length - 1;
6107
6524
  const card = state.cards[index];
6108
6525
  if (card === void 0 || card.frozen || card.mode === "text") return;
6109
- const options = index === 0 ? {} : {
6110
- panel: false,
6111
- segment: index + 1
6112
- };
6526
+ const options = segmentCardOptions(index);
6113
6527
  const split = segmentCardContent(this.cardState(sessionId, state, card.body), options);
6114
6528
  if (split.head === "") return;
6115
6529
  if (split.tail === "") {
@@ -6155,10 +6569,7 @@ var TurnStreamer = class {
6155
6569
  const next = (this.segmentChains.get(sessionId) ?? Promise.resolve()).then(async () => {
6156
6570
  let lastError;
6157
6571
  for (let attempt = 0; attempt < 3; attempt++) try {
6158
- const snapshot = buildCard(this.cardState(sessionId, state, card.body), {
6159
- panel: false,
6160
- segment: index + 1
6161
- });
6572
+ const snapshot = buildCard(this.cardState(sessionId, state, card.body, index === state.cards.length - 1), segmentCardOptions(index));
6162
6573
  const delivery = await this.hooks.sendSegmentCard(sessionId, index + 1, snapshot, card.body);
6163
6574
  card.sending = false;
6164
6575
  card.messageId = delivery.messageId;
@@ -6197,11 +6608,8 @@ var TurnStreamer = class {
6197
6608
  if (!force && !card.dirty && !turnDirty) return;
6198
6609
  if (!force && card.nextRetryAt > now) return;
6199
6610
  card.dirty = false;
6200
- const options = index === 0 ? {} : {
6201
- panel: false,
6202
- segment: index + 1
6203
- };
6204
- const snapshot = buildCard(this.cardState(sessionId, state, card.body), options);
6611
+ const options = segmentCardOptions(index);
6612
+ const snapshot = buildCard(this.cardState(sessionId, state, card.body, index === state.cards.length - 1), options);
6205
6613
  this.channel.updateCard(card.messageId, snapshot).catch((error) => {
6206
6614
  card.dirty = true;
6207
6615
  card.failures++;
@@ -6221,15 +6629,15 @@ var TurnStreamer = class {
6221
6629
  finalize(sessionId, state, failure) {
6222
6630
  if (state.finalized) return;
6223
6631
  state.finalized = true;
6224
- state.failure = failure;
6225
6632
  state.phase = failure === void 0 ? "done" : "error";
6633
+ this.closeReasoning(state);
6226
6634
  if (failure !== void 0) {
6227
6635
  const code = failure.code === void 0 ? "" : `\n\n错误码:\`${failure.code}\``;
6228
6636
  const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}`;
6229
6637
  const tail = state.cards[state.cards.length - 1];
6230
6638
  if (tail !== void 0) if (tail.mode === "text" || tail.failed) state.cards.push(this.freshSegment(notice));
6231
6639
  else {
6232
- tail.body = tail.body.trim() === "" ? notice : `${tail.body}\n\n---\n\n${notice}`;
6640
+ tail.body = tail.body.trim() === "" ? notice : `${tail.body}\n\n${notice}`;
6233
6641
  tail.dirty = true;
6234
6642
  }
6235
6643
  }
@@ -6281,6 +6689,10 @@ function turnFailure(reason) {
6281
6689
  * - p2p: one workspace per (bot, user), all of that user's sessions inside;
6282
6690
  * - group: one workspace per (chat, user), sessions grouped per user.
6283
6691
  *
6692
+ * Every topic's Session works in its own directory inside that workspace
6693
+ * (`topics/<token>` or a Git worktree), so topics never mix files while the
6694
+ * workspace list grows with users instead of conversations.
6695
+ *
6284
6696
  * Routing model:
6285
6697
  * - `thread_id` → bound session (no @ needed inside an owned topic);
6286
6698
  * - degraded chat-scoped keys when Feishu refuses topic replies;
@@ -6922,9 +7334,9 @@ var FeishuService = class {
6922
7334
  return value;
6923
7335
  }
6924
7336
  /**
6925
- * Workspace-retention preview for the Web panel: how many plugin-created
6926
- * workspaces exist, how many are past the configured window, and their
6927
- * measured footprint. Never deletes anything.
7337
+ * Retention preview for the Web panel: how many plugin workspaces exist,
7338
+ * how many directories are past the configured window, and their measured
7339
+ * footprint. Never deletes anything.
6928
7340
  */
6929
7341
  async cleanupPreview() {
6930
7342
  const retention = this.config.workspaceRetention;
@@ -6961,15 +7373,21 @@ var FeishuService = class {
6961
7373
  }
6962
7374
  }
6963
7375
  /**
6964
- * Delete every stale plugin workspace the configured retention allows.
6965
- * Routes bound to a removed workspace are retired first (thread routes leave
6966
- * a tombstone), so an old topic fails with the explicit expired card instead
6967
- * of resuming into a missing directory. DSH Session logs are not touched.
7376
+ * Delete every stale plugin directory the configured retention allows: the
7377
+ * per-topic working directories of current conversations and the legacy
7378
+ * one-workspace-per-topic directories of older versions. User workspaces are
7379
+ * never deleted.
7380
+ *
7381
+ * Active routes bound to a removed directory are retired first (thread
7382
+ * routes leave a tombstone), so an old topic fails with the explicit expired
7383
+ * card instead of resuming into a missing directory. DSH Session logs are not
7384
+ * touched.
6968
7385
  */
6969
7386
  async runWorkspaceCleanup() {
6970
7387
  const empty = {
6971
7388
  ok: false,
6972
7389
  deletedWorkspaces: 0,
7390
+ deletedSessionDirs: 0,
6973
7391
  retiredRoutes: 0,
6974
7392
  freedBytes: 0,
6975
7393
  failures: []
@@ -6980,11 +7398,11 @@ var FeishuService = class {
6980
7398
  };
6981
7399
  if (retentionMs(this.config.workspaceRetention) === void 0) return {
6982
7400
  ...empty,
6983
- error: "未启用工作区清理阈值(workspaceRetention 为 off)"
7401
+ error: "未启用目录清理阈值(workspaceRetention 为 off)"
6984
7402
  };
6985
7403
  if (this.cleanupRunning) return {
6986
7404
  ...empty,
6987
- error: "工作区清理正在进行中,请稍候"
7405
+ error: "目录清理正在进行中,请稍候"
6988
7406
  };
6989
7407
  this.cleanupRunning = true;
6990
7408
  try {
@@ -6992,13 +7410,14 @@ var FeishuService = class {
6992
7410
  const plan = await planWorkspaceCleanup(deps);
6993
7411
  if (plan === void 0) return {
6994
7412
  ...empty,
6995
- error: "工作区清理不可用:baseDir 配置无效"
7413
+ error: "目录清理不可用:baseDir 配置无效"
6996
7414
  };
6997
7415
  const outcome = await applyWorkspaceCleanup(deps, plan);
6998
- this.ctx.logger?.info?.("dsh-channels: workspace cleanup removed %d workspace(s), retired %d route(s), freed %d bytes", outcome.deletedWorkspaces, outcome.retiredRoutes, outcome.freedBytes);
7416
+ this.ctx.logger?.info?.("dsh-channels: retention cleanup removed %d legacy workspace(s) and %d topic dir(s), retired %d route(s), freed %d bytes", outcome.deletedWorkspaces, outcome.deletedSessionDirs, outcome.retiredRoutes, outcome.freedBytes);
6999
7417
  return {
7000
7418
  ok: true,
7001
7419
  deletedWorkspaces: outcome.deletedWorkspaces,
7420
+ deletedSessionDirs: outcome.deletedSessionDirs,
7002
7421
  retiredRoutes: outcome.retiredRoutes,
7003
7422
  freedBytes: outcome.freedBytes,
7004
7423
  failures: outcome.failures.map((failure) => `${failure.path}: ${failure.message}`)
@@ -7023,40 +7442,35 @@ var FeishuService = class {
7023
7442
  baseDir,
7024
7443
  retention: this.config.workspaceRetention,
7025
7444
  listWorkspaces: () => this.ctx.workspaceRegistry.list(),
7445
+ listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
7026
7446
  activeRoutes: () => [...this.domain?.routes.entries() ?? []].map(([key, record]) => ({
7027
7447
  key,
7028
7448
  kind: record.kind,
7029
7449
  workspacePath: record.workspacePath,
7450
+ ...record.sessionDir === void 0 ? {} : { sessionDir: record.sessionDir },
7030
7451
  sessionId: record.sessionId,
7031
7452
  lastActivityAt: record.lastActivityAt
7032
7453
  })),
7033
7454
  tombstones: () => [...this.domain?.expiredRoutes.entries() ?? []].map(([key, record]) => ({
7034
7455
  key,
7035
7456
  ...record.workspacePath === void 0 ? {} : { workspacePath: record.workspacePath },
7457
+ ...record.sessionDir === void 0 ? {} : { sessionDir: record.sessionDir },
7036
7458
  expiredAt: record.expiredAt,
7037
7459
  ...record.lastActivityAt === void 0 ? {} : { lastActivityAt: record.lastActivityAt }
7038
7460
  })),
7039
7461
  isLive: (sessionId) => this.sessions.get(sessionId) !== void 0 || this.ctx.agents.get(sessionId) !== void 0,
7040
- statWorkspace: (workspacePath) => measureWorkspace(workspacePath),
7462
+ statWorkspace: (directory) => measureWorkspace(directory),
7041
7463
  removeWorkspace: async (workspace) => {
7042
- const target = path.resolve(workspace.path);
7043
- if (!isPathInside(baseDir, target)) throw new Error(`refusing to delete a path outside baseDir (${target})`);
7044
- try {
7045
- const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(target)]);
7046
- if (!isPathInside(physicalBase, physicalTarget)) throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`);
7047
- } catch (error) {
7048
- if (error instanceof Error && error.message.startsWith("refusing to delete a symlink escape")) throw error;
7049
- }
7050
- await rm(target, {
7051
- recursive: true,
7052
- force: true
7053
- });
7464
+ await this.deleteDirectoryInsideBase(baseDir, workspace.path);
7054
7465
  try {
7055
7466
  await this.ctx.workspaceRegistry.delete(workspace.id);
7056
7467
  } catch (error) {
7057
7468
  this.ctx.logger?.warn?.("dsh-channels: unregistering cleaned workspace %s failed: %s", workspace.path, String(error));
7058
7469
  }
7059
7470
  },
7471
+ removeSessionDir: async (directory) => {
7472
+ await this.deleteDirectoryInsideBase(baseDir, directory);
7473
+ },
7060
7474
  retireRoute: async (route) => {
7061
7475
  if (route.kind === "thread") await this.routes.retire(route.key);
7062
7476
  else await this.routes.remove(route.key);
@@ -7065,6 +7479,65 @@ var FeishuService = class {
7065
7479
  };
7066
7480
  }
7067
7481
  /**
7482
+ * Scan the live user workspaces for per-topic working directories that no
7483
+ * route record points at anymore (for example the old route of a degraded
7484
+ * chat conversation was deleted by TTL retirement). Route/tombstone evidence
7485
+ * wins for age; the scan mtime only covers true orphans.
7486
+ */
7487
+ async listCleanableSessionDirs(baseDir) {
7488
+ const legacyRoot = legacyTopicWorkspaceRoot(baseDir);
7489
+ const roots = [];
7490
+ for (const workspace of this.ctx.workspaceRegistry.list()) {
7491
+ if (!this.isPluginWorkspace(workspace)) continue;
7492
+ const resolved = path.resolve(workspace.path);
7493
+ if (isPathInside(legacyRoot, resolved)) continue;
7494
+ roots.push(resolved);
7495
+ }
7496
+ const scanned = [];
7497
+ for (const root of roots) for (const relative of ["topics", path.join(".agents", "worktrees")]) {
7498
+ const directory = path.join(root, relative);
7499
+ let entries;
7500
+ try {
7501
+ entries = await readdir(directory, { withFileTypes: true });
7502
+ } catch {
7503
+ continue;
7504
+ }
7505
+ for (const entry of entries) {
7506
+ if (!entry.isDirectory()) continue;
7507
+ const candidate = path.join(directory, entry.name);
7508
+ try {
7509
+ const info = await lstat(candidate);
7510
+ if (info.isDirectory()) scanned.push({
7511
+ path: candidate,
7512
+ mtimeMs: info.mtimeMs
7513
+ });
7514
+ } catch {}
7515
+ }
7516
+ }
7517
+ return scanned;
7518
+ }
7519
+ /**
7520
+ * Recursively delete one plugin directory below `baseDir`.
7521
+ *
7522
+ * The lexical check refuses paths outside `baseDir`; the realpath check then
7523
+ * refuses a symlinked ancestor that would let the recursive delete escape
7524
+ * the plugin root. A missing directory is already effectively removed.
7525
+ */
7526
+ async deleteDirectoryInsideBase(baseDir, target) {
7527
+ const resolved = path.resolve(target);
7528
+ if (!isPathInside(baseDir, resolved)) throw new Error(`refusing to delete a path outside baseDir (${resolved})`);
7529
+ try {
7530
+ const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(resolved)]);
7531
+ if (!isPathInside(physicalBase, physicalTarget)) throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`);
7532
+ } catch (error) {
7533
+ if (error instanceof Error && error.message.startsWith("refusing to delete a symlink escape")) throw error;
7534
+ }
7535
+ await rm(resolved, {
7536
+ recursive: true,
7537
+ force: true
7538
+ });
7539
+ }
7540
+ /**
7068
7541
  * (Re)arm the daily retention timer to match the live config. `runNow`
7069
7542
  * performs one pass immediately; it is only used at startup so saving any
7070
7543
  * unrelated setting can never trigger a surprise deletion pass.
@@ -7077,7 +7550,7 @@ var FeishuService = class {
7077
7550
  if (!this.started || !this.config.workspaceRetentionAuto || retentionMs(this.config.workspaceRetention) === void 0) return;
7078
7551
  const sweep = () => {
7079
7552
  this.runWorkspaceCleanup().then((result) => {
7080
- if (!result.ok && result.error !== "工作区清理正在进行中,请稍候") this.ctx.logger?.warn?.("dsh-channels: automatic workspace cleanup failed: %s", result.error);
7553
+ if (!result.ok && result.error !== "目录清理正在进行中,请稍候") this.ctx.logger?.warn?.("dsh-channels: automatic workspace cleanup failed: %s", result.error);
7081
7554
  });
7082
7555
  };
7083
7556
  if (runNow) sweep();
@@ -7794,7 +8267,7 @@ var FeishuService = class {
7794
8267
  await this.delivery.standaloneReply(msg, buildRenewCard({
7795
8268
  previousSessionId: renewAdmission.commit.previous.sessionId,
7796
8269
  currentSessionId: renewAdmission.commit.current.sessionId,
7797
- workspacePath: renewAdmission.commit.current.workspacePath
8270
+ sessionDir: sessionDirOf(renewAdmission.commit.current)
7798
8271
  }));
7799
8272
  return;
7800
8273
  }
@@ -7812,7 +8285,7 @@ var FeishuService = class {
7812
8285
  context,
7813
8286
  extras: {
7814
8287
  permissionPreset: this.permissionPresetOf(session),
7815
- workspacePath: current.workspacePath,
8288
+ sessionDir: sessionDirOf(current),
7816
8289
  uptimeMs: this.startedAt === void 0 ? void 0 : Date.now() - this.startedAt,
7817
8290
  liveAgents: this.sessions.liveCount(),
7818
8291
  routeCount: this.routes.size()
@@ -7821,7 +8294,10 @@ var FeishuService = class {
7821
8294
  return;
7822
8295
  }
7823
8296
  if (spec.name === "workspace" && result.kind === "success") {
7824
- const resolved = resolveWorkDir(live.agent.session.header.cwd, current.workspacePath);
8297
+ const resolved = resolveWorkDir(live.agent.session.header.cwd, {
8298
+ workspacePath: current.workspacePath,
8299
+ ...current.sessionDir === void 0 ? {} : { sessionDir: current.sessionDir }
8300
+ });
7825
8301
  if (resolved.kind === "success") {
7826
8302
  await this.delivery.standaloneReply(msg, buildWorkDirCard(current.sessionId, resolved.path));
7827
8303
  return;
@@ -8338,6 +8814,27 @@ const configSchema = z.object({
8338
8814
  */
8339
8815
  threadFallback: z.enum(["degrade", "strict"]).default("degrade"),
8340
8816
  /**
8817
+ * Per-topic working-directory isolation inside one user workspace.
8818
+ *
8819
+ * - `auto` (default): use a Git worktree per topic when the user workspace
8820
+ * is a Git repository (the workspace directory contains `.git`), otherwise
8821
+ * a plain subdirectory.
8822
+ * - `subdir`: always a plain subdirectory (no Git requirement, no duplicated
8823
+ * checkouts).
8824
+ * - `worktree`: always try a Git worktree; when the workspace is not a Git
8825
+ * repository, the `worktrees` service is unavailable, or creation is
8826
+ * denied, log a warning and fall back to a subdirectory.
8827
+ *
8828
+ * The workspace itself stays the Session header cwd either way, so Sessions
8829
+ * remain grouped under the user's project; only the effective working
8830
+ * directory changes. See `docs/workspace-isolation-design.md`.
8831
+ */
8832
+ topicIsolation: z.enum([
8833
+ "auto",
8834
+ "subdir",
8835
+ "worktree"
8836
+ ]).default("auto"),
8837
+ /**
8341
8838
  * Route-table GC threshold. Route records (and stale in-flight repair
8342
8839
  * snapshots) whose last activity is older than this many milliseconds are
8343
8840
  * retired at startup and on every periodic sweep. Real thread routes leave a
@@ -8363,17 +8860,19 @@ const configSchema = z.object({
8363
8860
  */
8364
8861
  acceptAttachments: z.boolean().default(true),
8365
8862
  /**
8366
- * Delete the workspace directories of stale conversations. `off` (default)
8367
- * never deletes; `7d`/`30d`/`90d`/`180d` remove plugin-created workspaces
8368
- * whose last activity is older than the threshold. The managed Web panel
8369
- * exposes this as a select, previews the candidates, and can run one
8370
- * cleanup manually. DSH Session logs are never deleted.
8863
+ * Delete the topic directories of stale conversations, plus legacy
8864
+ * one-workspace-per-topic directories written by older plugin versions.
8865
+ * `off` (default) never deletes; `7d`/`30d`/`90d`/`180d` remove those
8866
+ * directories when their last activity is older than the threshold. User
8867
+ * workspaces themselves are never deleted. The managed Web panel exposes
8868
+ * this as a select, previews the candidates, and can run one cleanup
8869
+ * manually. DSH Session logs are never deleted.
8371
8870
  */
8372
8871
  workspaceRetention: z.enum(WORKSPACE_RETENTION_VALUES).default("off"),
8373
8872
  /**
8374
- * Run the workspace-retention cleanup at startup and once per day. Off by
8375
- * default: deletion is irreversible, so it stays an explicit opt-in even
8376
- * when a retention threshold is configured.
8873
+ * Run the retention cleanup at startup and once per day. Off by default:
8874
+ * deletion is irreversible, so it stays an explicit opt-in even when a
8875
+ * retention threshold is configured.
8377
8876
  */
8378
8877
  workspaceRetentionAuto: z.boolean().default(false),
8379
8878
  /**
@@ -8465,7 +8964,7 @@ const CREDENTIALS_ROUTE = "/dsh-channels/credentials";
8465
8964
  const RECONNECT_ROUTE = "/dsh-channels/reconnect";
8466
8965
  /** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
8467
8966
  const CATALOG_ROUTE = "/dsh-channels/catalog";
8468
- /** 手动执行一次过期工作区清理(POST,无请求体)。 */
8967
+ /** 手动执行一次过期目录清理(POST,无请求体)。 */
8469
8968
  const CLEANUP_ROUTE = "/dsh-channels/cleanup";
8470
8969
  /** 访问与授权只读快照(GET/HEAD):准入模式、管理员、群/用户白名单、待处理申请。 */
8471
8970
  const ACCESS_ROUTE = "/dsh-channels/access";
@@ -8493,7 +8992,7 @@ const PICK_DIRECTORY_ROUTE = "/dsh-channels/pick-directory";
8493
8992
  * 因此不会落进覆盖层文件。
8494
8993
  *
8495
8994
  * `baseDir`(工作区根目录 = 模型/工具产生文件的落点)可覆盖,但面板只接受
8496
- * **绝对路径**(`~` 会展开);已有 Session / 工作区不迁移,只影响之后新建的
8995
+ * **绝对路径**(`~` 会展开);已有 Session / 目录不迁移,只影响之后新建的
8497
8996
  * 会话。校验与保存时建目录见 `src/workspaces.ts`。
8498
8997
  */
8499
8998
  const EDITABLE_KEYS = [
@@ -8593,7 +9092,7 @@ const PANEL_COPY = {
8593
9092
  connectionTabHelp: "连接状态与飞书应用凭据。凭据不完整时不会连接飞书。",
8594
9093
  sessionsTabHelp: "私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。",
8595
9094
  modelTabHelp: "飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。",
8596
- workspaceTabHelp: "每个飞书话题一个独立工作区(模型/工具产生文件的落点);只影响之后新建的话题。",
9095
+ workspaceTabHelp: "每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。",
8597
9096
  accessTabHelp: "准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。",
8598
9097
  advancedTabHelp: "流式刷新等低层参数、Web 覆盖文件与只读信息。",
8599
9098
  save: "保存更改",
@@ -8639,11 +9138,12 @@ const PANEL_COPY = {
8639
9138
  advancedCard: "只读与高级信息",
8640
9139
  advancedHelp: "这些值来自 profile 配置,不能在面板里编辑。",
8641
9140
  readOnlyCard: "部署级参数",
8642
- cleanupCard: "工作区清理",
8643
- cleanupCardHelp: "删除超过保留期且不再活跃的飞书工作区目录;只会删除本插件在「工作区根目录」下创建的目录,DSH 会话日志不删除。删除不可恢复:相关旧话题会失效并提示已过期,工作区列表记录同步移除。",
9141
+ cleanupCard: "目录清理",
9142
+ cleanupCardHelp: "删除超过保留期且不再活跃的飞书话题目录(以及旧版本按话题建立的工作区目录);用户工作区本身、DSH 会话日志都不会删除。删除不可恢复:相关旧话题会失效并提示已过期。",
8644
9143
  cleanupRetention: "保留期",
8645
9144
  cleanupAutoLabel: "自动清理",
8646
- cleanupCandidates: "可清理工作区",
9145
+ cleanupCandidates: "可清理目录",
9146
+ cleanupWorkspaces: "插件工作区",
8647
9147
  cleanupCandidateBytes: "预计释放空间",
8648
9148
  cleanupRetentionOff: "关闭(不删除)",
8649
9149
  cleanupAutoOn: "已开启(启动时 + 每天一次)",
@@ -8651,17 +9151,18 @@ const PANEL_COPY = {
8651
9151
  cleanupAutoInactive: "未执行(保留期为关闭)",
8652
9152
  cleanupNow: "立即清理",
8653
9153
  cleanupRunning: "清理中…",
8654
- cleanupConfirm: "确定清理过期工作区吗?该操作不可恢复,会删除这些工作区内的全部文件(包括 attachments/ 附件)。",
8655
- cleanupConfirmHint: "删除前会再次确认;保留期为「关闭」或没有过期工作区时按钮不可用。",
8656
- cleanupDisabledOff: "保留策略为「关闭」:请先在上方「过期工作区保留期」中选择保留天数。",
8657
- cleanupDisabledEmpty: "没有超过保留期的过期工作区。",
9154
+ cleanupConfirm: "确定清理过期目录吗?该操作不可恢复,会删除这些目录内的全部文件(包括 attachments/ 附件)。",
9155
+ cleanupConfirmHint: "删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。",
9156
+ cleanupDisabledOff: "保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。",
9157
+ cleanupDisabledEmpty: "没有超过保留期的过期目录。",
8658
9158
  cleanupPreviewFailed: "无法读取清理预览",
8659
9159
  cleanupDone: "清理完成",
8660
9160
  cleanupFailed: "清理失败",
8661
9161
  cleanupDeleted: "已删除工作区",
9162
+ cleanupDeletedDirs: "话题目录",
8662
9163
  cleanupRetired: "已清理话题路由",
8663
9164
  cleanupFreed: "释放空间",
8664
- cleanupFailures: "以下工作区未能删除:",
9165
+ cleanupFailures: "以下目录未能删除:",
8665
9166
  accessCard: "访问与授权",
8666
9167
  accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。",
8667
9168
  accessModeOpenWarning: "当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。",
@@ -9350,7 +9851,7 @@ const SELECT_OPTIONS = {
9350
9851
  const FIELD_META = {
9351
9852
  baseDir: {
9352
9853
  label: "工作区根目录(文件落点)",
9353
- help: "每个飞书话题拥有独立工作区:私聊 <baseDir>/topics/p2p/<open_id>/<topic>/,群聊 <baseDir>/topics/groups/<chat_id>/<open_id>/<topic>/(topic 来自话题 id)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的工作区 —— 已有话题即使 /renew 也沿用原工作区,旧文件不会迁移。",
9854
+ help: "每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<open_id>/,群聊 <baseDir>/groups/<chat_id>/<open_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。",
9354
9855
  placeholder: "/Users/you/.dsh/dsh-channels/workspaces",
9355
9856
  wide: true
9356
9857
  },
@@ -9395,12 +9896,12 @@ const FIELD_META = {
9395
9896
  help: "接收飞书图片、文件与合并转发记录,保存到工作区的 attachments/ 目录;模型不会自动读取,需要时用文件工具自行查看。关闭后附件消息不保存、不触发回复。"
9396
9897
  },
9397
9898
  workspaceRetention: {
9398
- label: "过期工作区保留期",
9399
- help: "超过所选天数没有活动的飞书工作区会进入下方「工作区清理」列表;「关闭」表示永不删除。DSH 会话日志不受影响。"
9899
+ label: "过期目录保留期",
9900
+ help: "超过所选天数没有活动的飞书话题目录(以及旧版本按话题建立的工作区目录)会进入下方清理列表;用户工作区本身永不删除。DSH 会话日志不受影响。"
9400
9901
  },
9401
9902
  workspaceRetentionAuto: {
9402
- label: "自动执行工作区清理",
9403
- help: "开启后插件在启动时及每天执行一次过期工作区清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。"
9903
+ label: "自动执行目录清理",
9904
+ help: "开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。"
9404
9905
  },
9405
9906
  provider: {
9406
9907
  label: "模型服务商",
@@ -9664,6 +10165,12 @@ function normalizeAccessName(raw) {
9664
10165
  /** Build the read-only advanced card from the effective config. */
9665
10166
  function readOnlyFields(config) {
9666
10167
  return [
10168
+ {
10169
+ key: "topicIsolation",
10170
+ label: "话题目录隔离",
10171
+ value: config.topicIsolation === "auto" ? "自动(Git 仓库用 worktree,否则普通子目录)" : config.topicIsolation === "worktree" ? "Git worktree(失败回退子目录)" : "普通子目录",
10172
+ help: "每个话题的独立工作目录形式;在 profile 配置里用 topicIsolation 设置(auto / subdir / worktree)。"
10173
+ },
9667
10174
  {
9668
10175
  key: "routeTtlMs",
9669
10176
  label: "话题路由过期",
@@ -9838,7 +10345,7 @@ var SettingsSurface = class {
9838
10345
  try {
9839
10346
  cleanup = await this.target.cleanupPreview();
9840
10347
  } catch (error) {
9841
- this.logger?.warn?.("dsh-channels: 工作区清理预览失败:%s", messageOf$1(error));
10348
+ this.logger?.warn?.("dsh-channels: 目录清理预览失败:%s", messageOf$1(error));
9842
10349
  cleanup = {
9843
10350
  retention: effective.workspaceRetention,
9844
10351
  auto: effective.workspaceRetentionAuto,
@@ -10581,6 +11088,6 @@ function apply(ctx, config) {
10581
11088
  }, "dsh-channels");
10582
11089
  }
10583
11090
  //#endregion
10584
- export { AccessController, AccessRequests, AccessStore, ApprovalAnswerer, CARD_MAX_BYTES, CARD_PANEL_RESERVE_BYTES, CARD_TARGET_BYTES, CATALOG_ROUTE, CHANNEL_COMMAND_NAMES, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, Config, DM_DENY_TEXT, DM_UNCONFIGURED_TEXT, DSH_CHANNELS_VERSION, EDITABLE_KEYS, EMPTY_TOTALS, FIELD_DESCRIPTORS, FeishuService, HEALTH_DEPENDENCY_NAMES, INTERRUPTION_NOTICE, LOGICAL_EXPORT_FORMAT, LOGICAL_EXPORT_FORMAT_VERSION, MAX_QUESTION_OPTIONS, MAX_TABLES_PER_CARD, OverlayStore, PANEL_CHEVRON_TOKEN, PANEL_COPY, PANEL_ROUTE, PANEL_TITLES, PICK_DIRECTORY_ROUTE, QuestionAnswerer, REASONING_MAX_BYTES, RECONNECT_ROUTE, RESTART_KEYS, RouteOperationQueue, SESSION_HISTORY_DISPLAY_LIMIT, STATUS_ROUTE, SUPPORTED_COMMAND_NAMES, SettingsSurface, TRUNCATION_SUFFIX, TurnStreamer, accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, addUsage, appendSessionHistory, apply, applyCatalog, applyProjectionViews, armModelCommand, averageDecodeTokensPerSecond, averageTtftMs, buildApprovalCard, buildApprovalSettledCard, buildCard, buildCommandResultCard, buildCompactComparisonCard, buildGroupInfoCard, buildHealthCard, buildInterruptionCard, buildQuestionCard, buildQuestionSettledCard, buildRenewCard, buildRequestSettledCard, buildSessionHistoryCard, buildSessionStatusCard, buildUserRequestCard, buildWorkDirCard, cacheHitPct, cardJsonBytes, chatRouteKey, commandResultCardKind, compactFailureContext, countFiberStates, createHostCatalog, createLogicalSessionExport, createSettingsDirectoryPicker, decideAccess, defaultOverlayPath, emptyCatalog, ensureWorkspace, expiredRouteRecordSchema, feishuDomain, foldAll, foldEvent, foldHealthSnapshot, foldSessionStats, inflightSnapshotSchema, inject, isAdminOpenId, isSameOriginRequest, isSupportedCommand, maskOpenIdTail, measureSessionContext, mergeOverlay, mountSettingsRoutes, name, normalizeCredentialValue, normalizeOverlay, normalizeRouteRecord, normalizeSessionHistory, parseOverlay, parseSlashCommand, pickEditable, processedRecordSchema, querySessionHistory, readOnlyFields, readProjectionViews, registerChannelCommands, registerModelCommand, registerSettingsRoutes, resolveDshHome, resolveWorkDir, routeRecordSchema, segmentCardContent, serializeLogicalSession, sessionHistoryEntrySchema, splitBodyForCard, stagingPathFor, stripTables, supportedCommandHelp, supportedCommandList, tableBlocks, topicTokenFor, usageFromBuckets, withDirectoryBrowse, workspacePathFor, workspaceTitleFor };
11091
+ export { AccessController, AccessRequests, AccessStore, ApprovalAnswerer, CARD_MAX_BYTES, CARD_TARGET_BYTES, CATALOG_ROUTE, CHANNEL_COMMAND_NAMES, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, Config, DM_DENY_TEXT, DM_UNCONFIGURED_TEXT, DSH_CHANNELS_VERSION, EDITABLE_KEYS, EMPTY_TOTALS, FIELD_DESCRIPTORS, FeishuService, HEALTH_DEPENDENCY_NAMES, INTERRUPTION_NOTICE, LOGICAL_EXPORT_FORMAT, LOGICAL_EXPORT_FORMAT_VERSION, MAX_QUESTION_OPTIONS, MAX_TABLES_PER_CARD, OverlayStore, PANEL_CHEVRON_TOKEN, PANEL_COPY, PANEL_ROUTE, PANEL_TITLES, PICK_DIRECTORY_ROUTE, QuestionAnswerer, REASONING_MAX_BYTES, RECONNECT_ROUTE, RESTART_KEYS, RouteOperationQueue, SESSION_HISTORY_DISPLAY_LIMIT, STATUS_ROUTE, SUPPORTED_COMMAND_NAMES, SettingsSurface, TRUNCATION_SUFFIX, TurnStreamer, accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, addUsage, appendSessionHistory, apply, applyCatalog, applyProjectionViews, armModelCommand, averageDecodeTokensPerSecond, averageTtftMs, buildApprovalCard, buildApprovalSettledCard, buildCard, buildCommandResultCard, buildCompactComparisonCard, buildGroupInfoCard, buildHealthCard, buildInterruptionCard, buildQuestionCard, buildQuestionSettledCard, buildRenewCard, buildRequestSettledCard, buildSessionHistoryCard, buildSessionStatusCard, buildUserRequestCard, buildWorkDirCard, cacheHitPct, cardJsonBytes, chatRouteKey, commandResultCardKind, compactFailureContext, countFiberStates, createHostCatalog, createLogicalSessionExport, createSettingsDirectoryPicker, decideAccess, defaultOverlayPath, emptyCatalog, ensureWorkspace, expiredRouteRecordSchema, feishuDomain, foldAll, foldEvent, foldHealthSnapshot, foldSessionStats, inflightSnapshotSchema, inject, isAdminOpenId, isSameOriginRequest, isSupportedCommand, legacyTopicWorkspaceRoot, maskOpenIdTail, measureSessionContext, mergeOverlay, mountSettingsRoutes, name, normalizeCredentialValue, normalizeOverlay, normalizeRouteRecord, normalizeSessionHistory, parseOverlay, parseSlashCommand, pickEditable, processedRecordSchema, querySessionHistory, readOnlyFields, readProjectionViews, registerChannelCommands, registerModelCommand, registerSettingsRoutes, resolveDshHome, resolveWorkDir, routeRecordSchema, segmentCardContent, serializeLogicalSession, sessionDirOf, sessionHistoryEntrySchema, splitBodyForCard, stagingPathFor, stripTables, supportedCommandHelp, supportedCommandList, tableBlocks, topicDirFor, topicTokenFor, usageFromBuckets, withDirectoryBrowse, workspacePathFor, workspaceTitleFor, worktreeBranchFor };
10585
11092
 
10586
11093
  //# sourceMappingURL=index.js.map