@modesto_ma/dsh-channels 0.1.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.
Files changed (50) hide show
  1. package/README.md +89 -55
  2. package/docs/access-control-design.md +5 -5
  3. package/docs/feasibility-and-design.md +54 -26
  4. package/docs/feishu-onboarding.md +26 -20
  5. package/docs/persistence.md +4 -4
  6. package/docs/refactor-plan.md +6 -5
  7. package/docs/workspace-isolation-design.md +78 -0
  8. package/lib/client.js +1715 -882
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +1556 -680
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/cards/index.ts +1 -0
  14. package/src/cards/onboarding.ts +23 -0
  15. package/src/cards/primitives.ts +20 -0
  16. package/src/cards/streaming.ts +310 -291
  17. package/src/cleanup.ts +256 -119
  18. package/src/client/api.ts +20 -0
  19. package/src/client/catalog.ts +1 -1
  20. package/src/client/index.ts +1 -1
  21. package/src/client/panel/access-card.tsx +294 -0
  22. package/src/client/panel/access-tab.tsx +46 -0
  23. package/src/client/panel/advanced-tab.tsx +80 -0
  24. package/src/client/panel/cleanup-card.tsx +106 -0
  25. package/src/client/panel/connection-tab.tsx +214 -0
  26. package/src/client/panel/credentials-card.tsx +104 -0
  27. package/src/client/panel/index.tsx +862 -0
  28. package/src/client/panel/model-tab.tsx +23 -0
  29. package/src/client/panel/sessions-tab.tsx +21 -0
  30. package/src/client/panel/shared.tsx +488 -0
  31. package/src/client/panel/workspace-tab.tsx +40 -0
  32. package/src/client/styles.ts +24 -6
  33. package/src/config.ts +44 -12
  34. package/src/domain.ts +34 -3
  35. package/src/feishu/agent-context.ts +9 -5
  36. package/src/index.ts +32 -14
  37. package/src/routing/inbound.ts +88 -43
  38. package/src/service.ts +259 -65
  39. package/src/session/isolation.ts +277 -0
  40. package/src/session/manager.ts +63 -20
  41. package/src/session/repair.ts +2 -3
  42. package/src/session/routes.ts +2 -0
  43. package/src/session-commands.ts +41 -14
  44. package/src/settings/contract.ts +223 -47
  45. package/src/settings/directory-picker.ts +121 -0
  46. package/src/settings/routes.ts +49 -2
  47. package/src/settings/surface.ts +171 -25
  48. package/src/streamer.ts +70 -30
  49. package/src/workspaces.ts +50 -28
  50. package/src/client/panel.tsx +0 -1475
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;
1791
+ }
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() !== "";
1671
1800
  }
1672
- /** Raw card assembly — no clipping of any kind (the budget machinery wraps this). */
1673
- function assemble(phase, reasoning, body, stats, options, status, tool) {
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;
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);
1758
1864
  }
1759
1865
  /**
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
- };
1771
- }
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
- /**
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.
2659
2873
  *
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.
2874
+ * Conversations produce files below two kinds of directories the host never
2875
+ * removes on its own:
2669
2876
  *
2670
- * Activity precedence for one workspace:
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.
2671
2884
  *
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).
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.
2678
2890
  *
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.
2891
+ * Activity precedence:
2892
+ *
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).
4113
+ * Per-topic Session placement: which directory each Feishu conversation works
4114
+ * in, without growing the host workspace list with every topic.
3814
4115
  *
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>/`
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.
3818
4122
  *
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.
4123
+ * This module plans one topic's placement:
3822
4124
  *
3823
- * Feishu ids (`ou_…`, `oc_…`) are safe path segments, but they are validated
3824
- * anyway so a hostile id can never escape `baseDir`.
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.
3825
4130
  *
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.
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.
3833
4136
  *
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.
3840
- *
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;
4156
+ /**
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.
4160
+ */
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";
3911
4170
  }
3912
4171
  /**
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).
4172
+ * Decide how a new Session is placed and materialize everything that must
4173
+ * exist before the Session is created.
4174
+ *
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.
3916
4178
  */
3917
- function topicTokenFor(routeKey, kind, sessionId) {
3918
- return kind === "thread" ? safeSegment(routeKey, "route key") : safeSegment(sessionId, "session id");
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 };
3919
4215
  }
3920
4216
  /**
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>/`
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.
3926
4220
  *
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.
3930
- */
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);
3936
- }
3937
- /**
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.
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 {
5398
5787
  kind: "missing",
5399
- text: "当前飞书路由未记录可用的绝对工作目录;请检查路由持久化状态。"
5788
+ text: "当前飞书路由未记录可用的工作区目录;请检查路由持久化状态。"
5400
5789
  };
5401
- if (headerCwd !== routeWorkspacePath) return {
5790
+ if (route.sessionDir !== void 0 && !path.isAbsolute(route.sessionDir)) return {
5791
+ kind: "missing",
5792
+ text: "当前飞书路由未记录可用的会话目录;请检查路由持久化状态。"
5793
+ };
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;
@@ -6460,13 +6872,21 @@ var FeishuService = class {
6460
6872
  started = false;
6461
6873
  /** Epoch ms when `start()` completed (uptime for `/status`). */
6462
6874
  startedAt;
6463
- /** Last failed `boot()` message, surfaced in the Web settings panel. */
6875
+ /** Last failed connection attempt, surfaced in the Web settings panel. */
6464
6876
  lastStartError;
6877
+ /** Whether both Feishu application credentials currently resolve. */
6878
+ configured = false;
6879
+ /** Names of the missing credential references (never their values). */
6880
+ missingConfigItems = [];
6881
+ /** Guards the panel's explicit connect action (distinct from a restart). */
6882
+ connecting = false;
6465
6883
  /** Effective config captured at the last successful connect (restart-pending diff). */
6466
6884
  runningConfig;
6467
6885
  /** Guards overlapping panel-triggered reconnects. */
6468
6886
  restarting = false;
6469
6887
  unlisten;
6888
+ /** Disposers for host-scoped listeners registered by `start()`. */
6889
+ unlistenHost = [];
6470
6890
  /** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
6471
6891
  gcTimer;
6472
6892
  /** Periodic workspace-retention timer (only armed when auto cleanup is on). */
@@ -6565,24 +6985,82 @@ var FeishuService = class {
6565
6985
  });
6566
6986
  }
6567
6987
  /**
6568
- * Load the Web overlay, then open the channel. Startup errors are recorded
6569
- * for the settings panel and the partial service is torn down before the
6570
- * error is rethrown (the plugin fiber still fails the same way).
6988
+ * Load the Web overlay and open the long connection when — and only when —
6989
+ * the Feishu application credentials are complete.
6990
+ *
6991
+ * An incomplete configuration is a normal state, not an error: the plugin
6992
+ * stays loaded so the Web settings panel can guide the user through the
6993
+ * credentials form, and no connection is attempted. A failed connection
6994
+ * attempt is recorded for the panel instead of failing the plugin fiber.
6571
6995
  */
6572
6996
  async boot() {
6997
+ if (this.connecting || this.restarting) return;
6998
+ this.connecting = true;
6573
6999
  try {
6574
7000
  await this.loadOverlay();
7001
+ await this.refreshReadiness();
7002
+ if (!this.configured) {
7003
+ this.lastStartError = void 0;
7004
+ this.runningConfig = void 0;
7005
+ this.ctx.logger?.info?.("dsh-channels: 飞书应用凭据未配置完整(缺少 %s),本次不连接飞书;请在 Web 设置面板 → 飞书渠道中配置,保存后会自动连接", this.missingConfigItems.join("、"));
7006
+ return;
7007
+ }
7008
+ await this.openConnection();
7009
+ } catch {} finally {
7010
+ this.connecting = false;
7011
+ }
7012
+ }
7013
+ /**
7014
+ * Refresh the cached configuration readiness from the credentials seam.
7015
+ * Only presence is checked (describe/resolve), never values; the secrets
7016
+ * themselves are resolved by `start()` when a connection is opened.
7017
+ */
7018
+ async refreshReadiness() {
7019
+ const states = await this.credentialStates();
7020
+ const missing = [];
7021
+ if (!states.appId.configured) missing.push(`${this.config.appId}(飞书 App ID)`);
7022
+ if (!states.appSecret.configured) missing.push(`${this.config.appSecret}(飞书 App Secret)`);
7023
+ this.missingConfigItems = missing;
7024
+ this.configured = missing.length === 0;
7025
+ }
7026
+ /**
7027
+ * Open the connection with the live config. A failure tears the partial
7028
+ * service down and records the reason for the panel before rethrowing it to
7029
+ * the caller; the plugin fiber is never failed by a connection problem.
7030
+ */
7031
+ async openConnection() {
7032
+ try {
6575
7033
  await this.start();
6576
7034
  this.lastStartError = void 0;
6577
7035
  this.runningConfig = { ...this.config };
6578
7036
  } catch (error) {
6579
7037
  this.lastStartError = messageOf$3(error);
7038
+ this.ctx.logger?.error?.("dsh-channels: 飞书连接失败:%s", String(error));
7039
+ console.error("[dsh-channels] feishu connection failed:", error);
6580
7040
  try {
6581
7041
  await this.shutdown();
6582
7042
  } catch {}
6583
7043
  throw error;
6584
7044
  }
6585
7045
  }
7046
+ /**
7047
+ * Panel action: open the long connection when the channel is stopped.
7048
+ * Credentials are re-checked first — an incomplete pair throws a message the
7049
+ * panel shows next to the credentials form instead of attempting a connect.
7050
+ */
7051
+ async connect() {
7052
+ if (this.started) return;
7053
+ if (this.connecting || this.restarting) throw new Error("飞书连接正在连接或重启,请稍候再试");
7054
+ this.connecting = true;
7055
+ try {
7056
+ await this.loadOverlay();
7057
+ await this.refreshReadiness();
7058
+ if (!this.configured) throw new Error(`飞书应用凭据尚未配置完整:缺少 ${this.missingConfigItems.join("、")}`);
7059
+ await this.openConnection();
7060
+ } finally {
7061
+ this.connecting = false;
7062
+ }
7063
+ }
6586
7064
  /** Re-read the Web overlay over the profile config (no channel touch). */
6587
7065
  async loadOverlay() {
6588
7066
  const overlay = this.overlay === void 0 ? {} : await this.overlay.load();
@@ -6613,22 +7091,24 @@ var FeishuService = class {
6613
7091
  });
6614
7092
  this.refreshRetentionTimer();
6615
7093
  }
6616
- /** Tear the connection down and bring it back with the latest config. */
7094
+ /**
7095
+ * Tear the connection down and bring it back with the latest config. When the
7096
+ * credentials are no longer complete the channel simply stays stopped (no
7097
+ * error): the panel then shows the configuration guide instead of a failure.
7098
+ */
6617
7099
  async restart() {
6618
- if (this.restarting) throw new Error("飞书连接正在重启,请稍候再试");
7100
+ if (this.restarting || this.connecting) throw new Error("飞书连接正在连接或重启,请稍候再试");
6619
7101
  this.restarting = true;
6620
7102
  try {
6621
7103
  await this.shutdown();
6622
7104
  await this.loadOverlay();
6623
- await this.start();
6624
- this.lastStartError = void 0;
6625
- this.runningConfig = { ...this.config };
6626
- } catch (error) {
6627
- this.lastStartError = messageOf$3(error);
6628
- try {
6629
- await this.shutdown();
6630
- } catch {}
6631
- throw error;
7105
+ await this.refreshReadiness();
7106
+ if (!this.configured) {
7107
+ this.lastStartError = void 0;
7108
+ this.runningConfig = void 0;
7109
+ return;
7110
+ }
7111
+ await this.openConnection();
6632
7112
  } finally {
6633
7113
  this.restarting = false;
6634
7114
  }
@@ -6639,6 +7119,18 @@ var FeishuService = class {
6639
7119
  isRestarting() {
6640
7120
  return this.restarting;
6641
7121
  }
7122
+ /** True while the panel's explicit connect action is in flight. */
7123
+ isConnecting() {
7124
+ return this.connecting;
7125
+ }
7126
+ /** Whether both Feishu application credentials currently resolve. */
7127
+ isConfigured() {
7128
+ return this.configured;
7129
+ }
7130
+ /** Names of the missing configuration items (never values). */
7131
+ missingConfig() {
7132
+ return this.missingConfigItems;
7133
+ }
6642
7134
  startedAtMs() {
6643
7135
  return this.startedAt;
6644
7136
  }
@@ -6662,6 +7154,7 @@ var FeishuService = class {
6662
7154
  const admin = this.config.adminOpenId;
6663
7155
  return {
6664
7156
  mode: this.config.accessMode,
7157
+ loaded: this.domain !== void 0,
6665
7158
  admin: {
6666
7159
  ...admin === void 0 ? {} : { openId: admin },
6667
7160
  configured: admin !== void 0
@@ -6706,7 +7199,7 @@ var FeishuService = class {
6706
7199
  const admin = this.config.adminOpenId;
6707
7200
  if (admin === void 0) return {
6708
7201
  ok: false,
6709
- error: "请先在常用配置中设置管理员 open_id"
7202
+ error: "请先在「访问授权」标签页设置管理员 open_id"
6710
7203
  };
6711
7204
  try {
6712
7205
  if (!this.accessStore.hasGroup(id)) await this.accessStore.addGroup({
@@ -6757,7 +7250,7 @@ var FeishuService = class {
6757
7250
  const admin = this.config.adminOpenId;
6758
7251
  if (admin === void 0) return {
6759
7252
  ok: false,
6760
- error: "请先在常用配置中设置管理员 open_id"
7253
+ error: "请先在「访问授权」标签页设置管理员 open_id"
6761
7254
  };
6762
7255
  try {
6763
7256
  if (!this.accessStore.hasUser(id)) await this.accessStore.addUser({
@@ -6808,7 +7301,7 @@ var FeishuService = class {
6808
7301
  const actor = this.config.adminOpenId;
6809
7302
  if (actor === void 0) return {
6810
7303
  ok: false,
6811
- error: "请先配置管理员 open_id"
7304
+ error: "请先在「访问授权」标签页配置管理员 open_id"
6812
7305
  };
6813
7306
  try {
6814
7307
  const result = await requests.resolve(token, decision, actor);
@@ -6841,9 +7334,9 @@ var FeishuService = class {
6841
7334
  return value;
6842
7335
  }
6843
7336
  /**
6844
- * Workspace-retention preview for the Web panel: how many plugin-created
6845
- * workspaces exist, how many are past the configured window, and their
6846
- * 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.
6847
7340
  */
6848
7341
  async cleanupPreview() {
6849
7342
  const retention = this.config.workspaceRetention;
@@ -6853,6 +7346,13 @@ var FeishuService = class {
6853
7346
  ...window === void 0 ? {} : { thresholdMs: window },
6854
7347
  auto: this.config.workspaceRetentionAuto
6855
7348
  };
7349
+ if (this.domain === void 0) return {
7350
+ ...base,
7351
+ workspaces: 0,
7352
+ candidates: 0,
7353
+ candidateBytes: 0,
7354
+ error: "飞书连接建立后即可预览和清理工作区"
7355
+ };
6856
7356
  try {
6857
7357
  const workspaces = this.ctx.workspaceRegistry.list().filter((workspace) => this.isPluginWorkspace(workspace));
6858
7358
  const plan = await planWorkspaceCleanup(this.cleanupDeps());
@@ -6873,26 +7373,36 @@ var FeishuService = class {
6873
7373
  }
6874
7374
  }
6875
7375
  /**
6876
- * Delete every stale plugin workspace the configured retention allows.
6877
- * Routes bound to a removed workspace are retired first (thread routes leave
6878
- * a tombstone), so an old topic fails with the explicit expired card instead
6879
- * 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.
6880
7385
  */
6881
7386
  async runWorkspaceCleanup() {
6882
7387
  const empty = {
6883
7388
  ok: false,
6884
7389
  deletedWorkspaces: 0,
7390
+ deletedSessionDirs: 0,
6885
7391
  retiredRoutes: 0,
6886
7392
  freedBytes: 0,
6887
7393
  failures: []
6888
7394
  };
7395
+ if (this.domain === void 0) return {
7396
+ ...empty,
7397
+ error: "飞书连接建立后才能清理工作区"
7398
+ };
6889
7399
  if (retentionMs(this.config.workspaceRetention) === void 0) return {
6890
7400
  ...empty,
6891
- error: "未启用工作区清理阈值(workspaceRetention 为 off)"
7401
+ error: "未启用目录清理阈值(workspaceRetention 为 off)"
6892
7402
  };
6893
7403
  if (this.cleanupRunning) return {
6894
7404
  ...empty,
6895
- error: "工作区清理正在进行中,请稍候"
7405
+ error: "目录清理正在进行中,请稍候"
6896
7406
  };
6897
7407
  this.cleanupRunning = true;
6898
7408
  try {
@@ -6900,13 +7410,14 @@ var FeishuService = class {
6900
7410
  const plan = await planWorkspaceCleanup(deps);
6901
7411
  if (plan === void 0) return {
6902
7412
  ...empty,
6903
- error: "工作区清理不可用:baseDir 配置无效"
7413
+ error: "目录清理不可用:baseDir 配置无效"
6904
7414
  };
6905
7415
  const outcome = await applyWorkspaceCleanup(deps, plan);
6906
- 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);
6907
7417
  return {
6908
7418
  ok: true,
6909
7419
  deletedWorkspaces: outcome.deletedWorkspaces,
7420
+ deletedSessionDirs: outcome.deletedSessionDirs,
6910
7421
  retiredRoutes: outcome.retiredRoutes,
6911
7422
  freedBytes: outcome.freedBytes,
6912
7423
  failures: outcome.failures.map((failure) => `${failure.path}: ${failure.message}`)
@@ -6931,40 +7442,35 @@ var FeishuService = class {
6931
7442
  baseDir,
6932
7443
  retention: this.config.workspaceRetention,
6933
7444
  listWorkspaces: () => this.ctx.workspaceRegistry.list(),
7445
+ listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
6934
7446
  activeRoutes: () => [...this.domain?.routes.entries() ?? []].map(([key, record]) => ({
6935
7447
  key,
6936
7448
  kind: record.kind,
6937
7449
  workspacePath: record.workspacePath,
7450
+ ...record.sessionDir === void 0 ? {} : { sessionDir: record.sessionDir },
6938
7451
  sessionId: record.sessionId,
6939
7452
  lastActivityAt: record.lastActivityAt
6940
7453
  })),
6941
7454
  tombstones: () => [...this.domain?.expiredRoutes.entries() ?? []].map(([key, record]) => ({
6942
7455
  key,
6943
7456
  ...record.workspacePath === void 0 ? {} : { workspacePath: record.workspacePath },
7457
+ ...record.sessionDir === void 0 ? {} : { sessionDir: record.sessionDir },
6944
7458
  expiredAt: record.expiredAt,
6945
7459
  ...record.lastActivityAt === void 0 ? {} : { lastActivityAt: record.lastActivityAt }
6946
7460
  })),
6947
7461
  isLive: (sessionId) => this.sessions.get(sessionId) !== void 0 || this.ctx.agents.get(sessionId) !== void 0,
6948
- statWorkspace: (workspacePath) => measureWorkspace(workspacePath),
7462
+ statWorkspace: (directory) => measureWorkspace(directory),
6949
7463
  removeWorkspace: async (workspace) => {
6950
- const target = path.resolve(workspace.path);
6951
- if (!isPathInside(baseDir, target)) throw new Error(`refusing to delete a path outside baseDir (${target})`);
6952
- try {
6953
- const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(target)]);
6954
- if (!isPathInside(physicalBase, physicalTarget)) throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`);
6955
- } catch (error) {
6956
- if (error instanceof Error && error.message.startsWith("refusing to delete a symlink escape")) throw error;
6957
- }
6958
- await rm(target, {
6959
- recursive: true,
6960
- force: true
6961
- });
7464
+ await this.deleteDirectoryInsideBase(baseDir, workspace.path);
6962
7465
  try {
6963
7466
  await this.ctx.workspaceRegistry.delete(workspace.id);
6964
7467
  } catch (error) {
6965
7468
  this.ctx.logger?.warn?.("dsh-channels: unregistering cleaned workspace %s failed: %s", workspace.path, String(error));
6966
7469
  }
6967
7470
  },
7471
+ removeSessionDir: async (directory) => {
7472
+ await this.deleteDirectoryInsideBase(baseDir, directory);
7473
+ },
6968
7474
  retireRoute: async (route) => {
6969
7475
  if (route.kind === "thread") await this.routes.retire(route.key);
6970
7476
  else await this.routes.remove(route.key);
@@ -6973,6 +7479,65 @@ var FeishuService = class {
6973
7479
  };
6974
7480
  }
6975
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
+ /**
6976
7541
  * (Re)arm the daily retention timer to match the live config. `runNow`
6977
7542
  * performs one pass immediately; it is only used at startup so saving any
6978
7543
  * unrelated setting can never trigger a surprise deletion pass.
@@ -6985,7 +7550,7 @@ var FeishuService = class {
6985
7550
  if (!this.started || !this.config.workspaceRetentionAuto || retentionMs(this.config.workspaceRetention) === void 0) return;
6986
7551
  const sweep = () => {
6987
7552
  this.runWorkspaceCleanup().then((result) => {
6988
- 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);
6989
7554
  });
6990
7555
  };
6991
7556
  if (runNow) sweep();
@@ -7051,6 +7616,7 @@ var FeishuService = class {
7051
7616
  } catch (error) {
7052
7617
  throw new Error(`写入凭据引用 ${ref} 失败:${messageOf$3(error)}`);
7053
7618
  }
7619
+ await this.refreshReadiness();
7054
7620
  }
7055
7621
  /** Remove the stored value of one Feishu credential reference. */
7056
7622
  async clearCredential(field) {
@@ -7062,6 +7628,7 @@ var FeishuService = class {
7062
7628
  } catch (error) {
7063
7629
  throw new Error(`移除凭据引用 ${ref} 失败:${messageOf$3(error)}`);
7064
7630
  }
7631
+ await this.refreshReadiness();
7065
7632
  }
7066
7633
  /** The overlay-independent ref name backing one editable credential slot. */
7067
7634
  credentialRef(field) {
@@ -7136,12 +7703,12 @@ var FeishuService = class {
7136
7703
  answerer.onCardAction(event);
7137
7704
  questions.onCardAction(event);
7138
7705
  });
7139
- this.ctx.on("session/event", (session, event) => {
7706
+ this.unlistenHost.push(this.ctx.on("session/event", (session, event) => {
7140
7707
  this.streamer?.onSessionEvent(session, event);
7141
- });
7142
- this.ctx.on("agent/assistant-stream", ({ agent, frame }) => {
7708
+ }));
7709
+ this.unlistenHost.push(this.ctx.on("agent/assistant-stream", ({ agent, frame }) => {
7143
7710
  this.streamer?.onAssistantFrame(agent.session, frame);
7144
- });
7711
+ }));
7145
7712
  this.unlisten = channel.onMessage((message) => {
7146
7713
  this.inbound.handle(message).catch((error) => {
7147
7714
  this.ctx.logger?.warn?.("dsh-channels inbound error: %s", String(error));
@@ -7198,6 +7765,7 @@ var FeishuService = class {
7198
7765
  this.unlistenApproval = void 0;
7199
7766
  this.unlistenQuestions?.();
7200
7767
  this.unlistenQuestions = void 0;
7768
+ for (const dispose of this.unlistenHost.splice(0)) dispose();
7201
7769
  this.approvalAnswerer?.dispose();
7202
7770
  this.approvalAnswerer = void 0;
7203
7771
  this.questionAnswerer?.dispose();
@@ -7699,7 +8267,7 @@ var FeishuService = class {
7699
8267
  await this.delivery.standaloneReply(msg, buildRenewCard({
7700
8268
  previousSessionId: renewAdmission.commit.previous.sessionId,
7701
8269
  currentSessionId: renewAdmission.commit.current.sessionId,
7702
- workspacePath: renewAdmission.commit.current.workspacePath
8270
+ sessionDir: sessionDirOf(renewAdmission.commit.current)
7703
8271
  }));
7704
8272
  return;
7705
8273
  }
@@ -7717,7 +8285,7 @@ var FeishuService = class {
7717
8285
  context,
7718
8286
  extras: {
7719
8287
  permissionPreset: this.permissionPresetOf(session),
7720
- workspacePath: current.workspacePath,
8288
+ sessionDir: sessionDirOf(current),
7721
8289
  uptimeMs: this.startedAt === void 0 ? void 0 : Date.now() - this.startedAt,
7722
8290
  liveAgents: this.sessions.liveCount(),
7723
8291
  routeCount: this.routes.size()
@@ -7726,7 +8294,10 @@ var FeishuService = class {
7726
8294
  return;
7727
8295
  }
7728
8296
  if (spec.name === "workspace" && result.kind === "success") {
7729
- 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
+ });
7730
8301
  if (resolved.kind === "success") {
7731
8302
  await this.delivery.standaloneReply(msg, buildWorkDirCard(current.sessionId, resolved.path));
7732
8303
  return;
@@ -8168,6 +8739,9 @@ const FEISHU_LOGGER_LEVELS = [
8168
8739
  ];
8169
8740
  /** Same shape as the host credentials seam's `CredentialRef` (shell identifier). */
8170
8741
  const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
8742
+ /** Conventional credential-reference names used when the loader passes no config. */
8743
+ const DEFAULT_APP_ID_REF = "FEISHU_APP_ID";
8744
+ const DEFAULT_APP_SECRET_REF = "FEISHU_APP_SECRET";
8171
8745
  /** Default route-table GC threshold: 30 days without activity. */
8172
8746
  const DEFAULT_ROUTE_TTL_MS = 720 * 60 * 60 * 1e3;
8173
8747
  /** Default HITL approval decision window: 5 minutes per question. */
@@ -8177,10 +8751,10 @@ const DEFAULT_QUESTION_TIMEOUT_MS = 300 * 1e3;
8177
8751
  /** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
8178
8752
  const credentialRefSchema = z.string().regex(CREDENTIAL_REF_PATTERN, "must be a credential reference name such as FEISHU_APP_ID (resolved through the dsh credentials seam, e.g. $DSH_HOME/.credentials.yaml) — plaintext secrets are not accepted");
8179
8753
  const configSchema = z.object({
8180
- /** Feishu app id credential reference (e.g. `FEISHU_APP_ID`). */
8181
- appId: credentialRefSchema,
8182
- /** Feishu app secret credential reference (e.g. `FEISHU_APP_SECRET`). */
8183
- appSecret: credentialRefSchema,
8754
+ /** Feishu app id credential reference (defaults to `FEISHU_APP_ID`). */
8755
+ appId: credentialRefSchema.default(DEFAULT_APP_ID_REF),
8756
+ /** Feishu app secret credential reference (defaults to `FEISHU_APP_SECRET`). */
8757
+ appSecret: credentialRefSchema.default(DEFAULT_APP_SECRET_REF),
8184
8758
  /**
8185
8759
  * Root directory for per-(user, chat) workspace directories — the filesystem
8186
8760
  * root where files produced by channel sessions land. Read per session
@@ -8240,6 +8814,27 @@ const configSchema = z.object({
8240
8814
  */
8241
8815
  threadFallback: z.enum(["degrade", "strict"]).default("degrade"),
8242
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
+ /**
8243
8838
  * Route-table GC threshold. Route records (and stale in-flight repair
8244
8839
  * snapshots) whose last activity is older than this many milliseconds are
8245
8840
  * retired at startup and on every periodic sweep. Real thread routes leave a
@@ -8265,17 +8860,19 @@ const configSchema = z.object({
8265
8860
  */
8266
8861
  acceptAttachments: z.boolean().default(true),
8267
8862
  /**
8268
- * Delete the workspace directories of stale conversations. `off` (default)
8269
- * never deletes; `7d`/`30d`/`90d`/`180d` remove plugin-created workspaces
8270
- * whose last activity is older than the threshold. The managed Web panel
8271
- * exposes this as a select, previews the candidates, and can run one
8272
- * 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.
8273
8870
  */
8274
8871
  workspaceRetention: z.enum(WORKSPACE_RETENTION_VALUES).default("off"),
8275
8872
  /**
8276
- * Run the workspace-retention cleanup at startup and once per day. Off by
8277
- * default: deletion is irreversible, so it stays an explicit opt-in even
8278
- * 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.
8279
8876
  */
8280
8877
  workspaceRetentionAuto: z.boolean().default(false),
8281
8878
  /**
@@ -8323,6 +8920,14 @@ const configSchema = z.object({
8323
8920
  questionTimeoutMs: z.number().int().min(1e4).max(36e5).default(DEFAULT_QUESTION_TIMEOUT_MS)
8324
8921
  });
8325
8922
  /**
8923
+ * DSH loader-facing schema. The bundle patch inserts this plugin without a
8924
+ * `config` object; the loader validates that row with `undefined` before the
8925
+ * user's patch layer supplies values. Accept that absence and fill the same
8926
+ * defaults as {@link resolveConfig}, so enabling the plugin from the Web UI
8927
+ * cannot fail with "expected object, received undefined".
8928
+ */
8929
+ const pluginConfigSchema = configSchema.prefault({});
8930
+ /**
8326
8931
  * Validate the raw loader config. Values stay credential *references*; the
8327
8932
  * secrets themselves resolve through `ctx.credentials` at startup.
8328
8933
  * @param raw - the config object the cordis loader passed (may be undefined).
@@ -8351,10 +8956,15 @@ const STATUS_ROUTE = "/dsh-channels/status";
8351
8956
  const PANEL_ROUTE = "/dsh-channels/panel";
8352
8957
  const CONFIG_ROUTE = "/dsh-channels/config";
8353
8958
  const CREDENTIALS_ROUTE = "/dsh-channels/credentials";
8959
+ /**
8960
+ * Connection lifecycle action (POST). While the channel runs it restarts the
8961
+ * long connection; while it is stopped it opens one — so the panel has exactly
8962
+ * one "connect / reconnect" button. Credentials are resolved again either way.
8963
+ */
8354
8964
  const RECONNECT_ROUTE = "/dsh-channels/reconnect";
8355
8965
  /** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
8356
8966
  const CATALOG_ROUTE = "/dsh-channels/catalog";
8357
- /** 手动执行一次过期工作区清理(POST,无请求体)。 */
8967
+ /** 手动执行一次过期目录清理(POST,无请求体)。 */
8358
8968
  const CLEANUP_ROUTE = "/dsh-channels/cleanup";
8359
8969
  /** 访问与授权只读快照(GET/HEAD):准入模式、管理员、群/用户白名单、待处理申请。 */
8360
8970
  const ACCESS_ROUTE = "/dsh-channels/access";
@@ -8365,6 +8975,14 @@ const ACCESS_USERS_ROUTE = "/dsh-channels/access/users";
8365
8975
  /** 结算一条待处理授权申请(POST,body `{ token, decision }`)。 */
8366
8976
  const ACCESS_REQUESTS_ROUTE = "/dsh-channels/access/requests";
8367
8977
  /**
8978
+ * 打开宿主机的系统目录选择框(POST,无请求体),返回绝对路径。
8979
+ *
8980
+ * 只有宿主挂载了 `directoryPicker` 的 native 后端(桌面/本地 GUI 会话)才
8981
+ * 会注册实际选择逻辑;远程/无头会话的 browse 后端不可用,面板不下发
8982
+ * [`EditableFieldWire.browse`],保留手写输入。
8983
+ */
8984
+ const PICK_DIRECTORY_ROUTE = "/dsh-channels/pick-directory";
8985
+ /**
8368
8986
  * 可由 Web 面板覆盖的配置项。全部是 `src/config.ts` 里已存在的字段;
8369
8987
  * 覆盖值写进 `$DSH_HOME/dsh-channels/settings.json`,优先级高于 profile
8370
8988
  * 的 `cordis.patch.yml`(见 README「Web 设置面板」)。
@@ -8374,7 +8992,7 @@ const ACCESS_REQUESTS_ROUTE = "/dsh-channels/access/requests";
8374
8992
  * 因此不会落进覆盖层文件。
8375
8993
  *
8376
8994
  * `baseDir`(工作区根目录 = 模型/工具产生文件的落点)可覆盖,但面板只接受
8377
- * **绝对路径**(`~` 会展开);已有 Session / 工作区不迁移,只影响之后新建的
8995
+ * **绝对路径**(`~` 会展开);已有 Session / 目录不迁移,只影响之后新建的
8378
8996
  * 会话。校验与保存时建目录见 `src/workspaces.ts`。
8379
8997
  */
8380
8998
  const EDITABLE_KEYS = [
@@ -8412,22 +9030,55 @@ const RESTART_KEYS = [
8412
9030
  /** Display labels for the panel header/cards (kept on the wire so the client owns no copy). */
8413
9031
  const PANEL_COPY = {
8414
9032
  title: "飞书渠道",
8415
- description: "飞书长连接状态、应用凭据与常用配置。凭据写入 DSH 凭据库且不回显;标注「需重连」的项需重启飞书连接生效。",
9033
+ description: "飞书长连接、应用凭据与渠道配置。凭据只写入 DSH 凭据库且不回显;标注「需重连」的项在连接建立时读取。",
8416
9034
  statusCard: "连接状态",
9035
+ phaseUnconfigured: "待配置",
9036
+ phaseIdle: "未连接",
9037
+ phaseConnecting: "连接中",
9038
+ phaseConnected: "已连接",
9039
+ phaseError: "连接失败",
9040
+ configuredBadge: "凭据已配置",
9041
+ unconfiguredBadge: "凭据未配置",
9042
+ statusSummaryConnected: "飞书长连接已建立,机器人可以收发消息。",
9043
+ statusSummaryUnconfigured: "尚未配置飞书应用凭据:插件已加载,但不会连接飞书。填写凭据并保存后会自动连接。",
9044
+ statusSummaryIdle: "飞书应用凭据已就绪,但当前没有建立长连接。",
9045
+ statusSummaryConnecting: "正在连接飞书长连接…",
9046
+ statusSummaryError: "最近一次连接飞书失败,请检查凭据与网络后重试。",
9047
+ lastError: "最近一次连接失败",
9048
+ missingConfig: "缺少配置",
9049
+ statusHintUnconfigured: "配置完成后插件会自动连接;也可以稍后在本页手动连接。",
9050
+ connectionInfo: "连接信息",
9051
+ connectionStateLabel: "飞书长连接",
9052
+ botOpenId: "Bot open_id",
9053
+ uptime: "运行时长",
9054
+ routeCount: "话题路由",
9055
+ liveAgentCount: "活动会话",
9056
+ inflightTurnCount: "在途回复",
9057
+ healthReasons: "健康判定",
9058
+ unknownValue: "—",
9059
+ connect: "连接飞书",
9060
+ connecting: "连接中…",
9061
+ onboardingTitle: "开始使用飞书渠道",
9062
+ onboardingIntro: "只需三步:在飞书开放平台创建自建应用,把 App ID / App Secret 填入本页,保存后自动连接。",
9063
+ onboardingStep1Title: "创建飞书自建应用",
9064
+ onboardingStep1Text: "在飞书开放平台创建企业自建应用,开启机器人能力;事件订阅选择「长连接」,订阅 im.message.receive_v1(接收消息)。",
9065
+ onboardingStep2Title: "填写应用凭据",
9066
+ onboardingStep2Text: "把应用的 App ID(cli_ 开头)与 App Secret 填入下方「飞书应用凭据」,值会写入 DSH 凭据库。",
9067
+ onboardingStep3Title: "保存并等待连接",
9068
+ onboardingStep3Text: "两项凭据都保存后插件会自动连接;状态变为「已连接」即可在飞书里私聊机器人。",
9069
+ onboardingDocsHint: "开放平台地址:open.feishu.cn/app;应用需要开通 im:message、im:message:send_as_bot 等消息权限。",
8417
9070
  credentialsCard: "飞书应用凭据",
8418
- modelCard: "模型与推理",
8419
- modelCardHelp: "模型服务商、模型 id、推理档位与新会话的 Agent 预设。下拉选项来自宿主机实时目录;保存后对新会话生效。",
8420
- configCard: "常用配置",
8421
- advancedCard: "只读与高级信息",
9071
+ credentialsCardHelp: "凭据值经 DSH 凭据服务写入本机凭据库;面板只显示「已配置 / 未配置」,永不回显已存值。两项都配置后才会连接飞书。",
8422
9072
  appIdLabel: "飞书 App ID",
8423
9073
  appSecretLabel: "飞书 App Secret",
8424
- credentialsHelp: "值通过 DSH 凭据服务写入本机凭据库;面板只显示是否已配置,永不回显已存值。留空表示保持当前值。",
8425
9074
  credentialsRef: "凭据引用",
8426
9075
  saveCredentials: "保存凭据",
8427
9076
  savingCredentials: "保存中…",
8428
9077
  credentialSaved: "凭据已保存",
8429
- credentialSavedReconnected: "凭据已保存,已自动重连飞书通道",
9078
+ credentialSavedReconnected: "凭据已保存,飞书已连接",
8430
9079
  credentialSavedPending: "凭据已保存,重启飞书连接后生效",
9080
+ credentialSavedUnconfigured: "凭据已保存;还缺少另一项凭据,补齐后会自动连接",
9081
+ credentialAutoConnectFailed: "凭据已保存,但自动连接失败",
8431
9082
  credentialRemoved: "已移除存储的凭据",
8432
9083
  credentialRemovedPending: "已移除存储的凭据,重启飞书连接后生效",
8433
9084
  removeCredential: "移除已存值",
@@ -8437,40 +9088,62 @@ const PANEL_COPY = {
8437
9088
  credentialReadOnly: "当前凭据存储不可写",
8438
9089
  appIdPlaceholder: "输入 App ID(cli_…)",
8439
9090
  appSecretPlaceholder: "输入 App Secret",
8440
- save: "保存配置",
8441
- saveAll: "保存全部配置",
8442
- saveModel: "保存模型配置",
8443
- modelActionsHint: "只保存模型与推理字段",
9091
+ credentialsPendingHint: "填写后点「保存凭据」;两项齐全时插件会自动连接飞书。",
9092
+ connectionTabHelp: "连接状态与飞书应用凭据。凭据不完整时不会连接飞书。",
9093
+ sessionsTabHelp: "私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。",
9094
+ modelTabHelp: "飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。",
9095
+ workspaceTabHelp: "每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。",
9096
+ accessTabHelp: "准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。",
9097
+ advancedTabHelp: "流式刷新等低层参数、Web 覆盖文件与只读信息。",
9098
+ save: "保存更改",
8444
9099
  saving: "保存中…",
9100
+ saveHintDirty: "只保存改动过的字段;标注「需重连」的项在下次连接时生效。",
9101
+ discardTab: "放弃本页更改",
9102
+ saveHintClean: "没有未保存的更改。",
9103
+ dirtyCount: "未保存项",
9104
+ browseDirectory: "选择目录…",
9105
+ browsingDirectory: "选择中…",
9106
+ directoryPicked: "已选择目录",
9107
+ directoryPickFailed: "无法打开系统目录选择器",
9108
+ saved: "已保存",
9109
+ cleared: "已清除 Web 覆盖,恢复为 profile 配置",
8445
9110
  clear: "清除 Web 覆盖",
8446
9111
  clearing: "清除中…",
9112
+ noOverrides: "当前没有 Web 覆盖",
9113
+ overridden: "Web 覆盖",
9114
+ inherited: "继承 profile",
9115
+ restartTag: "需重连",
9116
+ restartBanner: "以下配置需重启飞书连接后生效:",
9117
+ restartWarning: "重启会断开当前飞书长连接并中断进行中的回复,请确认空闲时操作。",
8447
9118
  reconnect: "重启飞书连接",
8448
9119
  reconnecting: "重连中…",
8449
9120
  refresh: "刷新状态",
8450
9121
  retry: "重试",
9122
+ pollFailed: "状态刷新失败",
9123
+ connectDone: "已连接飞书",
9124
+ reconnectDone: "已重启飞书连接",
9125
+ saveInvalid: "请先修正标红的配置项",
9126
+ enabled: "已启用",
9127
+ disabled: "已关闭",
9128
+ credentialRemoveConfirm: "确定移除已存储的凭据值?移除后不会回显,需要时须重新填写。",
9129
+ accessNotLoaded: "飞书连接建立后加载授权列表",
8451
9130
  loading: "加载中…",
8452
9131
  loadFailed: "设置面板加载失败",
8453
- saved: "已保存",
8454
- savedModel: "模型配置已保存",
8455
- cleared: "已清除 Web 覆盖,恢复为 profile 配置",
8456
9132
  catalogFailed: "模型目录加载失败,已退回手填。",
8457
9133
  catalogLoading: "正在读取宿主机选项…",
8458
9134
  catalogUnavailable: "宿主机未提供可选项,请手填。",
8459
- noOverrides: "当前没有 Web 覆盖",
8460
- overridden: "Web 覆盖",
8461
- inherited: "继承 profile",
8462
- restartTag: "需重连",
8463
- restartBanner: "以下配置需重启飞书连接后生效:",
8464
- restartWarning: "重启会断开当前飞书长连接并中断进行中的回复,请确认空闲时操作。",
8465
- lastError: "最近一次启动失败",
8466
- notRunning: "未运行",
8467
9135
  version: "插件版本",
8468
9136
  overlayPath: "覆盖文件",
8469
- cleanupCard: "工作区清理",
8470
- cleanupCardHelp: "删除超过保留期且不再活跃的飞书工作区目录;只会删除本插件在「工作区根目录」下创建的目录,DSH 会话日志不删除。删除不可恢复:相关旧话题会失效并提示已过期,工作区列表记录同步移除。",
9137
+ overlayPathHelp: "可以直接编辑该 JSON 文件;面板保存会整体重写它,「清除 Web 覆盖」会删除它并回落到 profile 配置。",
9138
+ advancedCard: "只读与高级信息",
9139
+ advancedHelp: "这些值来自 profile 配置,不能在面板里编辑。",
9140
+ readOnlyCard: "部署级参数",
9141
+ cleanupCard: "目录清理",
9142
+ cleanupCardHelp: "删除超过保留期且不再活跃的飞书话题目录(以及旧版本按话题建立的工作区目录);用户工作区本身、DSH 会话日志都不会删除。删除不可恢复:相关旧话题会失效并提示已过期。",
8471
9143
  cleanupRetention: "保留期",
8472
9144
  cleanupAutoLabel: "自动清理",
8473
- cleanupCandidates: "可清理工作区",
9145
+ cleanupCandidates: "可清理目录",
9146
+ cleanupWorkspaces: "插件工作区",
8474
9147
  cleanupCandidateBytes: "预计释放空间",
8475
9148
  cleanupRetentionOff: "关闭(不删除)",
8476
9149
  cleanupAutoOn: "已开启(启动时 + 每天一次)",
@@ -8478,24 +9151,22 @@ const PANEL_COPY = {
8478
9151
  cleanupAutoInactive: "未执行(保留期为关闭)",
8479
9152
  cleanupNow: "立即清理",
8480
9153
  cleanupRunning: "清理中…",
8481
- cleanupConfirm: "确定清理过期工作区吗?该操作不可恢复,会删除这些工作区内的全部文件(包括 attachments/ 附件)。",
8482
- cleanupConfirmHint: "删除前会再次确认;保留期为「关闭」或没有过期工作区时按钮不可用。",
8483
- cleanupDisabledOff: "保留策略为「关闭」:请先在「常用配置 → 工作区与文件」中选择保留天数。",
8484
- cleanupDisabledEmpty: "没有超过保留期的过期工作区。",
9154
+ cleanupConfirm: "确定清理过期目录吗?该操作不可恢复,会删除这些目录内的全部文件(包括 attachments/ 附件)。",
9155
+ cleanupConfirmHint: "删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。",
9156
+ cleanupDisabledOff: "保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。",
9157
+ cleanupDisabledEmpty: "没有超过保留期的过期目录。",
8485
9158
  cleanupPreviewFailed: "无法读取清理预览",
8486
9159
  cleanupDone: "清理完成",
8487
9160
  cleanupFailed: "清理失败",
8488
9161
  cleanupDeleted: "已删除工作区",
9162
+ cleanupDeletedDirs: "话题目录",
8489
9163
  cleanupRetired: "已清理话题路由",
8490
9164
  cleanupFreed: "释放空间",
8491
- cleanupFailures: "以下工作区未能删除:",
9165
+ cleanupFailures: "以下目录未能删除:",
8492
9166
  accessCard: "访问与授权",
8493
- accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员 open_id 在「常用配置 → 行为」中填写,私聊机器人发送 /userid 获取。",
9167
+ accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。",
8494
9168
  accessModeOpenWarning: "当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。",
8495
- accessAdminMissingWarning: "当前准入=guarded 但未配置管理员,所有对话都会被拒绝。请先在「常用配置 → 行为 → 管理员 open_id」填写。",
8496
- accessAdminLabel: "管理员",
8497
- accessAdminMissing: "未配置",
8498
- accessAdminHint: "修改管理员请到「常用配置 → 行为 → 管理员 open_id」;私聊机器人发送 /userid 可查看自己的 open_id(ou_ 开头,不是 user_id/邮箱)。",
9169
+ accessAdminMissingWarning: "当前准入=guarded 但未配置管理员,所有对话都会被拒绝。请在上方「准入与授权」填写管理员 open_id。",
8499
9170
  accessGroupsTitle: "已授权群",
8500
9171
  accessGroupsEmpty: "尚未授权任何群。机器人被拉进群后,管理员会收到带 chat_id 的群信息卡;也可在群内发送 /groupid 获取。",
8501
9172
  accessUsersTitle: "已授权用户",
@@ -8951,7 +9622,7 @@ function registerSettingsRoutes(server, surface) {
8951
9622
  return;
8952
9623
  }
8953
9624
  const result = await surface.reconnect();
8954
- sendJson(res, result.ok ? 200 : 500, result);
9625
+ sendJson(res, result.ok ? 200 : result.unconfigured === true ? 400 : 500, result);
8955
9626
  })
8956
9627
  }),
8957
9628
  server.register({
@@ -8967,6 +9638,41 @@ function registerSettingsRoutes(server, surface) {
8967
9638
  sendJson(res, result.ok ? 200 : 500, result);
8968
9639
  })
8969
9640
  }),
9641
+ server.register({
9642
+ kind: "exact",
9643
+ path: PICK_DIRECTORY_ROUTE,
9644
+ handler: guarded(async (req, res) => {
9645
+ if (!allowOnly(req, res, ["POST"])) return;
9646
+ if (!isSameOriginRequest(req)) {
9647
+ forbiddenWrite(res);
9648
+ return;
9649
+ }
9650
+ const controller = new AbortController();
9651
+ let answered = false;
9652
+ if (typeof res.on === "function") res.on("close", () => {
9653
+ if (!answered) controller.abort();
9654
+ });
9655
+ let path;
9656
+ try {
9657
+ path = await surface.pickDirectory(controller.signal);
9658
+ } catch (error) {
9659
+ answered = true;
9660
+ if (controller.signal.aborted) {
9661
+ res.end();
9662
+ return;
9663
+ }
9664
+ throw error;
9665
+ }
9666
+ answered = true;
9667
+ sendJson(res, 200, path === null ? {
9668
+ ok: true,
9669
+ canceled: true
9670
+ } : {
9671
+ ok: true,
9672
+ path
9673
+ });
9674
+ })
9675
+ }),
8970
9676
  server.register({
8971
9677
  kind: "exact",
8972
9678
  path: ACCESS_ROUTE,
@@ -9145,7 +9851,7 @@ const SELECT_OPTIONS = {
9145
9851
  const FIELD_META = {
9146
9852
  baseDir: {
9147
9853
  label: "工作区根目录(文件落点)",
9148
- 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 也沿用原目录,旧文件不会迁移。",
9149
9855
  placeholder: "/Users/you/.dsh/dsh-channels/workspaces",
9150
9856
  wide: true
9151
9857
  },
@@ -9180,7 +9886,7 @@ const FIELD_META = {
9180
9886
  },
9181
9887
  authRequestTtlDays: {
9182
9888
  label: "授权申请有效期(天)",
9183
- help: "入群/用户授权申请卡的有效期,1–30 天;过期后卡片需重新触发申请(也可直接在「访问与授权」面板处理)。",
9889
+ help: "入群/用户授权申请卡的有效期,1–30 天;过期后卡片需重新触发申请(也可在本页下方「待处理申请」中处理)。",
9184
9890
  min: 1,
9185
9891
  max: 30,
9186
9892
  step: 1
@@ -9190,12 +9896,12 @@ const FIELD_META = {
9190
9896
  help: "接收飞书图片、文件与合并转发记录,保存到工作区的 attachments/ 目录;模型不会自动读取,需要时用文件工具自行查看。关闭后附件消息不保存、不触发回复。"
9191
9897
  },
9192
9898
  workspaceRetention: {
9193
- label: "过期工作区保留期",
9194
- help: "超过所选天数没有活动的飞书工作区会进入下方「工作区清理」列表;「关闭」表示永不删除。DSH 会话日志不受影响。"
9899
+ label: "过期目录保留期",
9900
+ help: "超过所选天数没有活动的飞书话题目录(以及旧版本按话题建立的工作区目录)会进入下方清理列表;用户工作区本身永不删除。DSH 会话日志不受影响。"
9195
9901
  },
9196
9902
  workspaceRetentionAuto: {
9197
- label: "自动执行工作区清理",
9198
- help: "开启后插件在启动时及每天执行一次过期工作区清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。"
9903
+ label: "自动执行目录清理",
9904
+ help: "开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。"
9199
9905
  },
9200
9906
  provider: {
9201
9907
  label: "模型服务商",
@@ -9245,9 +9951,9 @@ const GROUP_OF = {
9245
9951
  threadFallback: "behavior",
9246
9952
  welcomeOnboarding: "behavior",
9247
9953
  allowPermissionSwitch: "behavior",
9248
- adminOpenId: "behavior",
9249
- accessMode: "behavior",
9250
- authRequestTtlDays: "behavior",
9954
+ adminOpenId: "access",
9955
+ accessMode: "access",
9956
+ authRequestTtlDays: "access",
9251
9957
  acceptAttachments: "content",
9252
9958
  provider: "model",
9253
9959
  model: "model",
@@ -9343,6 +10049,22 @@ function withCurrentOption(options, value) {
9343
10049
  ...rest
9344
10050
  ];
9345
10051
  }
10052
+ /**
10053
+ * Mark the workspace-root field as browsable when the host can open its native
10054
+ * directory chooser. Every other host (no `directoryPicker`, or the `browse`
10055
+ * backend of remote/headless sessions) keeps the plain text input.
10056
+ *
10057
+ * @param fields - Host field descriptors after the selector catalog is applied.
10058
+ * @param available - whether a native directory chooser can be opened.
10059
+ * @returns the descriptors with `browse: 'directory'` on `baseDir` when usable.
10060
+ */
10061
+ function withDirectoryBrowse(fields, available) {
10062
+ if (!available) return [...fields];
10063
+ return fields.map((field) => field.key === "baseDir" ? {
10064
+ ...field,
10065
+ browse: "directory"
10066
+ } : field);
10067
+ }
9346
10068
  function messageOf$1(error) {
9347
10069
  return error instanceof Error ? error.message : String(error);
9348
10070
  }
@@ -9443,6 +10165,12 @@ function normalizeAccessName(raw) {
9443
10165
  /** Build the read-only advanced card from the effective config. */
9444
10166
  function readOnlyFields(config) {
9445
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
+ },
9446
10174
  {
9447
10175
  key: "routeTtlMs",
9448
10176
  label: "话题路由过期",
@@ -9467,12 +10195,28 @@ function readOnlyFields(config) {
9467
10195
  }
9468
10196
  ];
9469
10197
  }
10198
+ /**
10199
+ * Derive the panel phase from readiness + runtime facts. The order matters:
10200
+ * an incomplete configuration always wins (no connection is attempted), and a
10201
+ * failed attempt is only reported while the channel is stopped.
10202
+ */
10203
+ function phaseOf(target) {
10204
+ if (target.isRestarting()) return "connecting";
10205
+ if (target.isConnecting()) return target.isConfigured() ? "connecting" : "unconfigured";
10206
+ if (target.isRunning()) return "connected";
10207
+ if (!target.isConfigured()) return "unconfigured";
10208
+ if (target.startError() !== void 0) return "error";
10209
+ return "idle";
10210
+ }
9470
10211
  /** Assemble the status half of the panel payload. */
9471
10212
  function buildStatus(target, overlayPath) {
9472
10213
  const health = target.health();
9473
10214
  const connection = health.connection;
9474
10215
  return {
9475
10216
  running: target.isRunning(),
10217
+ configured: target.isConfigured(),
10218
+ phase: phaseOf(target),
10219
+ missingConfig: [...target.missingConfig()],
9476
10220
  level: health.level,
9477
10221
  startedAt: target.startedAtMs(),
9478
10222
  uptimeMs: target.uptimeMs(),
@@ -9490,7 +10234,7 @@ function buildStatus(target, overlayPath) {
9490
10234
  reasons: health.reasons,
9491
10235
  version: DSH_CHANNELS_VERSION,
9492
10236
  overlayPath,
9493
- busy: target.isRestarting()
10237
+ busy: target.isRestarting() || target.isConnecting()
9494
10238
  };
9495
10239
  }
9496
10240
  /**
@@ -9505,14 +10249,41 @@ var SettingsSurface = class {
9505
10249
  store;
9506
10250
  logger;
9507
10251
  catalog;
9508
- constructor(target, store, logger, catalog) {
10252
+ directoryPicker;
10253
+ constructor(target, store, logger, catalog, directoryPicker) {
9509
10254
  this.target = target;
9510
10255
  this.store = store;
9511
10256
  this.logger = logger;
9512
10257
  this.catalog = catalog;
10258
+ this.directoryPicker = directoryPicker;
10259
+ }
10260
+ /**
10261
+ * Re-read credential presence. The credentials store is hot-reloaded from
10262
+ * disk, so a value edited outside the panel becomes visible within one poll
10263
+ * instead of only after a restart/connect.
10264
+ */
10265
+ async syncReadiness() {
10266
+ try {
10267
+ await this.target.refreshReadiness();
10268
+ } catch (error) {
10269
+ this.logger?.warn?.("dsh-channels: 凭据状态刷新失败:%s", messageOf$1(error));
10270
+ }
10271
+ }
10272
+ /**
10273
+ * Reject panel writes while the connection lifecycle is in flight: saving an
10274
+ * overlay or credentials then would race `applyOverlay()`/`start()` and can
10275
+ * record a running-config snapshot that never took effect.
10276
+ */
10277
+ writeBusy() {
10278
+ if (!this.target.isRestarting() && !this.target.isConnecting()) return void 0;
10279
+ return {
10280
+ ok: false,
10281
+ error: "飞书连接正在连接或重启,请稍候再试"
10282
+ };
9513
10283
  }
9514
10284
  /** Wire status only (used by the client's 5 s poll). */
9515
10285
  async status() {
10286
+ await this.syncReadiness();
9516
10287
  return buildStatus(this.target, this.store.file);
9517
10288
  }
9518
10289
  /**
@@ -9531,6 +10302,7 @@ var SettingsSurface = class {
9531
10302
  }
9532
10303
  /** Status + editable config with provenance + read-only facts. */
9533
10304
  async panel() {
10305
+ await this.syncReadiness();
9534
10306
  const base = this.target.baseConfigSnapshot();
9535
10307
  const effective = this.target.configSnapshot();
9536
10308
  const overlay = await this.store.load();
@@ -9538,12 +10310,12 @@ var SettingsSurface = class {
9538
10310
  const overriddenKeys = EDITABLE_KEYS.filter((key) => overlay[key] !== void 0);
9539
10311
  const restartPendingKeys = this.target.isRunning() && running !== void 0 ? RESTART_KEYS.filter((key) => effective[key] !== running[key]) : [];
9540
10312
  const catalog = await this.catalogFor(effective.provider, effective.model);
9541
- const fields = applyCatalog(FIELD_DESCRIPTORS, catalog, {
10313
+ const fields = withDirectoryBrowse(applyCatalog(FIELD_DESCRIPTORS, catalog, {
9542
10314
  provider: effective.provider,
9543
10315
  model: effective.model,
9544
10316
  reasoningEffort: effective.reasoningEffort,
9545
10317
  agentPreset: effective.agentPreset
9546
- });
10318
+ }), this.directoryPicker?.available === true);
9547
10319
  let credentialStates = {
9548
10320
  appId: {
9549
10321
  configured: false,
@@ -9573,7 +10345,7 @@ var SettingsSurface = class {
9573
10345
  try {
9574
10346
  cleanup = await this.target.cleanupPreview();
9575
10347
  } catch (error) {
9576
- this.logger?.warn?.("dsh-channels: 工作区清理预览失败:%s", messageOf$1(error));
10348
+ this.logger?.warn?.("dsh-channels: 目录清理预览失败:%s", messageOf$1(error));
9577
10349
  cleanup = {
9578
10350
  retention: effective.workspaceRetention,
9579
10351
  auto: effective.workspaceRetentionAuto,
@@ -9591,6 +10363,7 @@ var SettingsSurface = class {
9591
10363
  access = {
9592
10364
  mode: effective.accessMode,
9593
10365
  admin: { configured: false },
10366
+ loaded: false,
9594
10367
  groups: [],
9595
10368
  users: [],
9596
10369
  requests: []
@@ -9614,6 +10387,8 @@ var SettingsSurface = class {
9614
10387
  }
9615
10388
  /** Validate + persist a partial edit; live fields take effect immediately. */
9616
10389
  async save(values) {
10390
+ const busy = this.writeBusy();
10391
+ if (busy !== void 0) return busy;
9617
10392
  const unknown = Object.keys(values).filter((key) => !EDITABLE_KEYS.includes(key));
9618
10393
  if (unknown.length > 0) return {
9619
10394
  ok: false,
@@ -9696,6 +10471,8 @@ var SettingsSurface = class {
9696
10471
  }
9697
10472
  /** Drop every Web override and fall back to the profile config. */
9698
10473
  async clear() {
10474
+ const busy = this.writeBusy();
10475
+ if (busy !== void 0) return busy;
9699
10476
  try {
9700
10477
  await this.store.clear();
9701
10478
  } catch (error) {
@@ -9712,6 +10489,8 @@ var SettingsSurface = class {
9712
10489
  }
9713
10490
  /** Write provided credential values through the host seam (never echoed back). */
9714
10491
  async saveCredentials(values) {
10492
+ const busy = this.writeBusy();
10493
+ if (busy !== void 0) return busy;
9715
10494
  const writes = [];
9716
10495
  const issues = [];
9717
10496
  for (const field of ["appId", "appSecret"]) {
@@ -9751,6 +10530,8 @@ var SettingsSurface = class {
9751
10530
  }
9752
10531
  /** Remove the stored value behind one credential reference. */
9753
10532
  async clearCredential(field) {
10533
+ const busy = this.writeBusy();
10534
+ if (busy !== void 0) return busy;
9754
10535
  if (field !== "appId" && field !== "appSecret") return {
9755
10536
  ok: false,
9756
10537
  error: "未知的凭据字段"
@@ -9766,9 +10547,11 @@ var SettingsSurface = class {
9766
10547
  return this.finishCredentialChange("clear");
9767
10548
  }
9768
10549
  /**
9769
- * Credential values are resolved on connect. When the channel is stopped,
9770
- * retry immediately (nothing to interrupt); when it is running, ask the user
9771
- * to reconnect. A failed retry still reports the save as successful.
10550
+ * Credential values are resolved on connect. Opening a connection is gated on
10551
+ * readiness: saving one half of the pair stores the value but keeps the
10552
+ * plugin idle until the other half exists. When the channel is stopped and
10553
+ * the pair is complete, connect immediately (nothing to interrupt); when it
10554
+ * is running, the user reconnects to pick the value up.
9772
10555
  */
9773
10556
  async finishCredentialChange(kind) {
9774
10557
  if (this.target.isRunning()) return {
@@ -9776,13 +10559,13 @@ var SettingsSurface = class {
9776
10559
  panel: await this.panel(),
9777
10560
  reconnect: "pending"
9778
10561
  };
9779
- if (kind === "clear") return {
10562
+ if (kind === "clear" || !this.target.isConfigured()) return {
9780
10563
  ok: true,
9781
10564
  panel: await this.panel(),
9782
- reconnect: "not-needed"
10565
+ reconnect: kind === "clear" ? "not-needed" : "unconfigured"
9783
10566
  };
9784
10567
  try {
9785
- await this.target.restart();
10568
+ await this.target.connect();
9786
10569
  return {
9787
10570
  ok: true,
9788
10571
  panel: await this.panel(),
@@ -9792,21 +10575,39 @@ var SettingsSurface = class {
9792
10575
  return {
9793
10576
  ok: true,
9794
10577
  panel: await this.panel(),
9795
- reconnect: "auto",
10578
+ reconnect: "failed",
9796
10579
  autoReconnectError: messageOf$1(error)
9797
10580
  };
9798
10581
  }
9799
10582
  }
9800
- /** Close and reopen the Feishu long connection with the latest config. */
10583
+ /**
10584
+ * The panel's single connect/reconnect action: reopen a running connection,
10585
+ * or open the first one when the channel is stopped. Credentials must be
10586
+ * complete — otherwise the panel points the user back at the credentials form
10587
+ * instead of surfacing a startup error.
10588
+ */
9801
10589
  async reconnect() {
10590
+ await this.syncReadiness();
10591
+ if (!this.target.isConfigured()) return {
10592
+ ok: false,
10593
+ error: "飞书应用凭据尚未配置完整,请先填写 App ID 与 App Secret 并保存",
10594
+ unconfigured: true
10595
+ };
10596
+ const wasRunning = this.target.isRunning();
9802
10597
  try {
9803
- await this.target.restart();
10598
+ if (wasRunning) await this.target.restart();
10599
+ else await this.target.connect();
9804
10600
  } catch (error) {
9805
10601
  return {
9806
10602
  ok: false,
9807
- error: `重连失败:${messageOf$1(error)}`
10603
+ error: `${wasRunning ? "重连" : "连接"}失败:${messageOf$1(error)}`
9808
10604
  };
9809
10605
  }
10606
+ if (!this.target.isRunning()) return {
10607
+ ok: false,
10608
+ unconfigured: !this.target.isConfigured(),
10609
+ error: this.target.startError() ?? (this.target.isConfigured() ? "飞书连接未建立" : "飞书应用凭据尚未配置完整,请先填写并保存凭据")
10610
+ };
9810
10611
  return {
9811
10612
  ok: true,
9812
10613
  panel: await this.panel()
@@ -9890,11 +10691,24 @@ var SettingsSurface = class {
9890
10691
  }
9891
10692
  }
9892
10693
  /**
10694
+ * Open the host's native directory chooser (the panel's「选择目录…」button).
10695
+ *
10696
+ * @param signal - caller lifetime; abort closes the OS dialog.
10697
+ * @returns the chosen absolute path, or `null` when the operator cancels.
10698
+ * @throws when the host provides no native directory chooser.
10699
+ */
10700
+ async pickDirectory(signal) {
10701
+ if (this.directoryPicker === void 0) throw new Error(PANEL_COPY.directoryPickFailed);
10702
+ return await this.directoryPicker.pick(signal);
10703
+ }
10704
+ /**
9893
10705
  * Run the workspace-retention cleanup once. Irreversible deletions are the
9894
10706
  * target's responsibility; the surface only reports the outcome and returns
9895
10707
  * a refreshed panel (its cleanup preview then reflects the remaining workspaces).
9896
10708
  */
9897
10709
  async runCleanup() {
10710
+ const busy = this.writeBusy();
10711
+ if (busy !== void 0) return busy;
9898
10712
  let result;
9899
10713
  try {
9900
10714
  result = await this.target.runWorkspaceCleanup();
@@ -10158,6 +10972,68 @@ function createHostCatalog(ctx, warn) {
10158
10972
  } };
10159
10973
  }
10160
10974
  //#endregion
10975
+ //#region src/settings/directory-picker.ts
10976
+ /** 取出 native 能力;服务缺失、后端为 browse 或 capability 抛错都返回 undefined。 */
10977
+ function nativeCapability(service) {
10978
+ if (service === void 0 || typeof service.capability !== "function") return void 0;
10979
+ let capability;
10980
+ try {
10981
+ capability = service.capability();
10982
+ } catch {
10983
+ return;
10984
+ }
10985
+ if (typeof capability !== "object" || capability === null) return void 0;
10986
+ const record = capability;
10987
+ if (record.kind !== "native" || typeof record.pick !== "function") return void 0;
10988
+ return capability;
10989
+ }
10990
+ /**
10991
+ * 从宿主上下文取出可用的系统目录选择器。
10992
+ *
10993
+ * 解析是**惰性**的:插件可能先于 `dsh-web-app` 的 directory-picker 行完成
10994
+ * apply,而设置面板只会在宿主全部加载完成后被请求。因此 `available` 与
10995
+ * `pick()` 每次都按需解析(成功结果缓存;失败不缓存,晚挂载的服务仍能被
10996
+ * 发现),避免把加载时序固化成「永远没有按钮」。
10997
+ *
10998
+ * @param ctx - 宿主上下文;`ctx.get('directoryPicker')` 是可选服务。
10999
+ * @returns picker;`available` 为 false 时面板保留手写输入。
11000
+ */
11001
+ function createSettingsDirectoryPicker(ctx) {
11002
+ let resolved;
11003
+ const resolve = () => {
11004
+ if (resolved !== void 0) return resolved;
11005
+ try {
11006
+ const service = ctx.get?.("directoryPicker");
11007
+ resolved = nativeCapability(service);
11008
+ } catch {
11009
+ resolved = void 0;
11010
+ }
11011
+ return resolved;
11012
+ };
11013
+ /** 互斥:一个原生模态框同时只应存在一个(双击/多标签页不得叠窗)。 */
11014
+ let picking = false;
11015
+ return {
11016
+ get available() {
11017
+ return resolve() !== void 0;
11018
+ },
11019
+ async pick(signal) {
11020
+ const capability = resolve();
11021
+ if (capability === void 0) throw new Error("当前宿主机不提供系统目录选择器");
11022
+ if (picking) throw new Error("已有目录选择窗口正在打开");
11023
+ if (signal.aborted) return null;
11024
+ picking = true;
11025
+ try {
11026
+ const picked = await capability.pick.call(capability, signal);
11027
+ if (picked === null || picked === void 0) return null;
11028
+ if (typeof picked !== "string" || picked.trim() === "") throw new Error("目录选择器返回了无效路径");
11029
+ return picked;
11030
+ } finally {
11031
+ picking = false;
11032
+ }
11033
+ }
11034
+ };
11035
+ }
11036
+ //#endregion
10161
11037
  //#region src/index.ts
10162
11038
  /** Cordis function-plugin name. */
10163
11039
  const name = "channels";
@@ -10188,7 +11064,7 @@ const inject = [
10188
11064
  * its `resolveConfig()` fallback for direct invocation (unit tests, embedded
10189
11065
  * hosts) where config has not been pre-validated.
10190
11066
  */
10191
- const Config = configSchema;
11067
+ const Config = pluginConfigSchema;
10192
11068
  /**
10193
11069
  * Install the bridge. Credentials stay references here — the actual secrets
10194
11070
  * resolve through `ctx.credentials` (env > `$DSH_HOME/.credentials.yaml`
@@ -10202,16 +11078,16 @@ function apply(ctx, config) {
10202
11078
  const warn = (message, ...args) => {
10203
11079
  ctx.logger?.warn?.(message, ...args);
10204
11080
  };
10205
- mountSettingsRoutes(ctx, new SettingsSurface(service, overlay, { warn }, createHostCatalog(ctx, warn)));
11081
+ mountSettingsRoutes(ctx, new SettingsSurface(service, overlay, { warn }, createHostCatalog(ctx, warn), createSettingsDirectoryPicker(ctx)));
10206
11082
  ctx.effect(() => {
10207
11083
  service.boot().catch((error) => {
10208
- ctx.logger?.error?.("dsh-channels failed to start: %s", String(error));
10209
- console.error("[dsh-channels] failed to start:", error);
11084
+ ctx.logger?.error?.("dsh-channels failed to boot: %s", String(error));
11085
+ console.error("[dsh-channels] failed to boot:", error);
10210
11086
  });
10211
11087
  return () => service.shutdown();
10212
11088
  }, "dsh-channels");
10213
11089
  }
10214
11090
  //#endregion
10215
- 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, 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, 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, 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 };
10216
11092
 
10217
11093
  //# sourceMappingURL=index.js.map