@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.
- package/README.md +89 -55
- package/docs/access-control-design.md +5 -5
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +26 -20
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +6 -5
- package/docs/workspace-isolation-design.md +78 -0
- package/lib/client.js +1715 -882
- package/lib/client.js.map +1 -1
- package/lib/index.js +1556 -680
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +256 -119
- package/src/client/api.ts +20 -0
- package/src/client/catalog.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/panel/access-card.tsx +294 -0
- package/src/client/panel/access-tab.tsx +46 -0
- package/src/client/panel/advanced-tab.tsx +80 -0
- package/src/client/panel/cleanup-card.tsx +106 -0
- package/src/client/panel/connection-tab.tsx +214 -0
- package/src/client/panel/credentials-card.tsx +104 -0
- package/src/client/panel/index.tsx +862 -0
- package/src/client/panel/model-tab.tsx +23 -0
- package/src/client/panel/sessions-tab.tsx +21 -0
- package/src/client/panel/shared.tsx +488 -0
- package/src/client/panel/workspace-tab.tsx +40 -0
- package/src/client/styles.ts +24 -6
- package/src/config.ts +44 -12
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +32 -14
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +259 -65
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +63 -20
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +223 -47
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +171 -25
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
- 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
|
-
*
|
|
1557
|
-
*
|
|
1558
|
-
*
|
|
1559
|
-
*
|
|
1560
|
-
*
|
|
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(
|
|
1584
|
-
return
|
|
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)}
|
|
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
|
-
/**
|
|
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})` : ""} ·
|
|
1772
|
+
return `🧠 模型 ${stats.model ?? "—"}${stats.effort !== void 0 && stats.effort !== "" ? `(${stats.effort})` : ""} · 🔑 会话 ${stats.sessionId} · ⏱ 推理 ${fmtReasoningTime(stats.reasoningMs)}`;
|
|
1656
1773
|
}
|
|
1657
|
-
/**
|
|
1658
|
-
|
|
1659
|
-
|
|
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
|
-
/**
|
|
1668
|
-
function
|
|
1669
|
-
const
|
|
1670
|
-
return
|
|
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
|
-
/**
|
|
1673
|
-
function
|
|
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 (
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
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
|
-
*
|
|
1740
|
-
*
|
|
1741
|
-
*
|
|
1742
|
-
*
|
|
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
|
|
1745
|
-
if (
|
|
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 (
|
|
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
|
-
/**
|
|
1756
|
-
function
|
|
1757
|
-
return
|
|
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
|
-
*
|
|
1779
|
-
*
|
|
1780
|
-
*
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
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
|
|
1790
|
-
*
|
|
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
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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.
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
|
1859
|
-
*
|
|
1860
|
-
*
|
|
1861
|
-
*
|
|
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
|
-
|
|
1871
|
-
const
|
|
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(
|
|
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(
|
|
1957
|
+
if (heading >= minFill) return cut(avoidProtected(blocks, heading));
|
|
1917
1958
|
const line = window.lastIndexOf("\n");
|
|
1918
|
-
if (line >= minFill) return cut(
|
|
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
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
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
|
|
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
|
-
*
|
|
2872
|
+
* Retention cleanup policy: pure planning and application over injected seams.
|
|
2659
2873
|
*
|
|
2660
|
-
*
|
|
2661
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2673
|
-
*
|
|
2674
|
-
*
|
|
2675
|
-
*
|
|
2676
|
-
*
|
|
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
|
-
*
|
|
2680
|
-
*
|
|
2681
|
-
*
|
|
2682
|
-
*
|
|
2683
|
-
*
|
|
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`
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
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:
|
|
2987
|
+
deps.warn?.(`dsh-channels: cleanup liveness probe failed for ${sessionId}: ${String(error)}`);
|
|
2771
2988
|
return true;
|
|
2772
2989
|
}
|
|
2773
2990
|
}
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
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
|
-
*
|
|
2999
|
+
* Decide which directories are stale, without deleting anything.
|
|
2785
3000
|
*
|
|
2786
|
-
* Only plugin-created
|
|
2787
|
-
*
|
|
2788
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2811
|
-
|
|
2812
|
-
if (
|
|
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,
|
|
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,
|
|
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,
|
|
3050
|
+
stats ??= await readWorkspaceStats(deps, targetPath);
|
|
2825
3051
|
candidates.push({
|
|
2826
|
-
|
|
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
|
-
|
|
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
|
|
2843
|
-
*
|
|
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
|
-
*
|
|
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
|
|
2850
|
-
* `baseDir
|
|
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-
|
|
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.
|
|
2865
|
-
|
|
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:
|
|
2868
|
-
message: "refused:
|
|
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
|
|
3170
|
+
if (!samePath(directoryOfRoute(route), targetPath)) continue;
|
|
2875
3171
|
await deps.retireRoute(route);
|
|
2876
3172
|
retiredRoutes++;
|
|
2877
3173
|
}
|
|
2878
|
-
|
|
2879
|
-
|
|
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 =
|
|
3183
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2883
3184
|
failures.push({
|
|
2884
|
-
path:
|
|
3185
|
+
path: targetPath,
|
|
2885
3186
|
message
|
|
2886
3187
|
});
|
|
2887
|
-
deps.warn?.(`dsh-channels:
|
|
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
|
-
|
|
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
|
|
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/
|
|
4111
|
+
//#region src/session/isolation.ts
|
|
3812
4112
|
/**
|
|
3813
|
-
*
|
|
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
|
-
*
|
|
3816
|
-
* -
|
|
3817
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3824
|
-
*
|
|
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
|
-
*
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
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
|
-
|
|
3877
|
-
|
|
4143
|
+
workingDirectory: get("workingDirectory"),
|
|
4144
|
+
worktrees: get("worktrees")
|
|
3878
4145
|
};
|
|
3879
4146
|
}
|
|
3880
|
-
/**
|
|
3881
|
-
|
|
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
|
|
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
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
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
|
-
*
|
|
3914
|
-
*
|
|
3915
|
-
*
|
|
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
|
|
3918
|
-
|
|
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
|
-
*
|
|
3922
|
-
*
|
|
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
|
-
*
|
|
3928
|
-
*
|
|
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
|
|
3943
|
-
const
|
|
3944
|
-
|
|
3945
|
-
|
|
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
|
-
/**
|
|
3948
|
-
function
|
|
3949
|
-
|
|
3950
|
-
|
|
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
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
4132
|
-
const
|
|
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
|
|
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
|
|
4147
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
}
|
|
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,
|
|
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
|
-
|
|
4708
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
4744
|
-
|
|
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
|
|
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:
|
|
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(
|
|
4764
|
-
...
|
|
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.
|
|
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
|
-
/**
|
|
5392
|
-
|
|
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(
|
|
5786
|
+
if (!path.isAbsolute(route.workspacePath)) return {
|
|
5398
5787
|
kind: "missing",
|
|
5399
|
-
text: "
|
|
5788
|
+
text: "当前飞书路由未记录可用的工作区目录;请检查路由持久化状态。"
|
|
5400
5789
|
};
|
|
5401
|
-
if (
|
|
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:
|
|
5801
|
+
path: expected
|
|
5408
5802
|
};
|
|
5409
5803
|
}
|
|
5410
|
-
/** Render one verified absolute
|
|
5411
|
-
function buildWorkDirCard(sessionId,
|
|
5804
|
+
/** Render one verified absolute working directory. */
|
|
5805
|
+
function buildWorkDirCard(sessionId, sessionDir) {
|
|
5412
5806
|
return standaloneCard("**📁 当前工作目录**", [{
|
|
5413
5807
|
tag: "markdown",
|
|
5414
|
-
content: `\`${escapeInlineCode(
|
|
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
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
6201
|
-
|
|
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
|
|
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
|
|
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
|
|
6569
|
-
*
|
|
6570
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
6624
|
-
this.
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
*
|
|
6845
|
-
*
|
|
6846
|
-
*
|
|
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
|
|
6877
|
-
*
|
|
6878
|
-
*
|
|
6879
|
-
*
|
|
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: "
|
|
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: "
|
|
7413
|
+
error: "目录清理不可用:baseDir 配置无效"
|
|
6904
7414
|
};
|
|
6905
7415
|
const outcome = await applyWorkspaceCleanup(deps, plan);
|
|
6906
|
-
this.ctx.logger?.info?.("dsh-channels:
|
|
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: (
|
|
7462
|
+
statWorkspace: (directory) => measureWorkspace(directory),
|
|
6949
7463
|
removeWorkspace: async (workspace) => {
|
|
6950
|
-
|
|
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 !== "
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
8181
|
-
appId: credentialRefSchema,
|
|
8182
|
-
/** Feishu app secret credential reference (
|
|
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
|
|
8269
|
-
*
|
|
8270
|
-
*
|
|
8271
|
-
*
|
|
8272
|
-
*
|
|
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
|
|
8277
|
-
*
|
|
8278
|
-
*
|
|
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
|
-
/**
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
8441
|
-
|
|
8442
|
-
|
|
8443
|
-
|
|
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
|
-
|
|
8470
|
-
|
|
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: "
|
|
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:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 +
|
|
9167
|
+
accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。",
|
|
8494
9168
|
accessModeOpenWarning: "当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。",
|
|
8495
|
-
accessAdminMissingWarning: "当前准入=guarded
|
|
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: "
|
|
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: "
|
|
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: "
|
|
9249
|
-
accessMode: "
|
|
9250
|
-
authRequestTtlDays: "
|
|
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
|
-
|
|
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:
|
|
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.
|
|
9770
|
-
*
|
|
9771
|
-
*
|
|
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.
|
|
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: "
|
|
10578
|
+
reconnect: "failed",
|
|
9796
10579
|
autoReconnectError: messageOf$1(error)
|
|
9797
10580
|
};
|
|
9798
10581
|
}
|
|
9799
10582
|
}
|
|
9800
|
-
/**
|
|
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:
|
|
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 =
|
|
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
|
|
10209
|
-
console.error("[dsh-channels] failed to
|
|
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,
|
|
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
|