@modesto_ma/dsh-channels 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -29
- package/docs/access-control-design.md +1 -1
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +15 -13
- 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 +23 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +1110 -603
- 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/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +27 -8
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +119 -41
- 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 +18 -14
- package/src/settings/surface.ts +12 -4
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
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;
|
|
1671
1791
|
}
|
|
1672
|
-
/**
|
|
1673
|
-
|
|
1792
|
+
/**
|
|
1793
|
+
* Whether the fold panel renders. `thinking` always does (it carries the
|
|
1794
|
+
* phase indicator while the card is otherwise empty); every later phase only
|
|
1795
|
+
* when there is actual reasoning to fold away — a settled non-reasoning turn
|
|
1796
|
+
* gets no empty bordered box.
|
|
1797
|
+
*/
|
|
1798
|
+
function panelShown(phase, reasoning) {
|
|
1799
|
+
return phase === "thinking" || stripTables(reasoning).trim() !== "";
|
|
1800
|
+
}
|
|
1801
|
+
/** Continuation header: `📄 回复分段 {n}`. */
|
|
1802
|
+
function segmentHeader(segment) {
|
|
1803
|
+
return `📄 回复分段 ${segment}`;
|
|
1804
|
+
}
|
|
1805
|
+
/** Interruption notice header for a repaired continuation card. */
|
|
1806
|
+
function interruptionHeader(segment) {
|
|
1807
|
+
return `📄 回复分段 ${segment} · 回复中断`;
|
|
1808
|
+
}
|
|
1809
|
+
function isSettled(phase) {
|
|
1810
|
+
return phase === "done" || phase === "error";
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Defensive cap on caller-supplied activity lines. The streamer already clips
|
|
1814
|
+
* them to 64 chars; this bound keeps `buildCard`'s "always ≤ CARD_MAX_BYTES"
|
|
1815
|
+
* guarantee even for a direct caller that passes an unbounded line.
|
|
1816
|
+
*/
|
|
1817
|
+
const ACTIVITY_LINE_MAX_CHARS = 512;
|
|
1818
|
+
/** One caller-supplied notation line, defensively capped; undefined when empty. */
|
|
1819
|
+
function activityNote(text) {
|
|
1820
|
+
if (text === void 0 || text === "") return void 0;
|
|
1821
|
+
return note(text.length <= ACTIVITY_LINE_MAX_CHARS ? text : `${text.slice(0, ACTIVITY_LINE_MAX_CHARS - 1)}…`);
|
|
1822
|
+
}
|
|
1823
|
+
/** Render one card. `reasoning`/`body` are the already-budgeted texts. */
|
|
1824
|
+
function renderCard(state, options, reasoning, body) {
|
|
1825
|
+
const segment = options.segment;
|
|
1826
|
+
const main = segment === void 0;
|
|
1674
1827
|
const elements = [];
|
|
1675
|
-
if (
|
|
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
|
|
1758
|
-
}
|
|
1759
|
-
/**
|
|
1760
|
-
* JSON-escaped UTF-8 size of the fold panel's markdown, as rendered by one
|
|
1761
|
-
* assembled card. Reasoning is budgeted against THIS rather than against the
|
|
1762
|
-
* whole card: a card-wide budget makes the panel collapse to the bare
|
|
1763
|
-
* truncation marker as soon as the reply body grows, so the visible reasoning
|
|
1764
|
-
* window would depend on how long the answer is.
|
|
1765
|
-
*/
|
|
1766
|
-
function panelMarkdownBytes(render) {
|
|
1767
|
-
return (content) => {
|
|
1768
|
-
const elements = render(content).body?.elements;
|
|
1769
|
-
return jsonBytes(elements?.[0]?.elements?.[0]?.content ?? "");
|
|
1770
|
-
};
|
|
1861
|
+
/** Reasoning as rendered: clipped against the panel's own fixed window. */
|
|
1862
|
+
function budgetReasoning(state) {
|
|
1863
|
+
return fitPrefix(state.reasoning, REASONING_MAX_BYTES, (content) => jsonBytes(panelContent(state.phase, content)), TRUNCATION_SUFFIX);
|
|
1771
1864
|
}
|
|
1772
|
-
/** JSON-escaped UTF-8 size of the truncation marker — reserved so clips can
|
|
1773
|
-
* never push a card past its budget. */
|
|
1774
|
-
const SUFFIX_JSON_BYTES = jsonBytes(TRUNCATION_SUFFIX);
|
|
1775
1865
|
/**
|
|
1776
1866
|
* Build one card.
|
|
1777
1867
|
*
|
|
1778
|
-
*
|
|
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.
|
|
2873
|
+
*
|
|
2874
|
+
* Conversations produce files below two kinds of directories the host never
|
|
2875
|
+
* removes on its own:
|
|
2659
2876
|
*
|
|
2660
|
-
*
|
|
2661
|
-
*
|
|
2662
|
-
*
|
|
2663
|
-
*
|
|
2664
|
-
*
|
|
2665
|
-
*
|
|
2666
|
-
*
|
|
2667
|
-
* testable and keeps the dangerous half (recursive deletion + registry
|
|
2668
|
-
* unregistration) in the caller.
|
|
2877
|
+
* 1. **Per-topic session directories** (current layout): the effective working
|
|
2878
|
+
* directory of one Feishu topic — `<user-workspace>/topics/<token>` or a Git
|
|
2879
|
+
* worktree checkout. They are not registered workspaces, so their activity
|
|
2880
|
+
* comes from route records/tombstones or from the directory's own `mtime`.
|
|
2881
|
+
* 2. **Legacy one-workspace-per-topic directories** written by older plugin
|
|
2882
|
+
* versions (`<baseDir>/topics/...`). Those are still registered host
|
|
2883
|
+
* workspaces and are unregistered when removed.
|
|
2669
2884
|
*
|
|
2670
|
-
*
|
|
2885
|
+
* User workspaces themselves (the durable project each user/chat is grouped
|
|
2886
|
+
* under) are never candidates. This module owns no I/O: every filesystem and
|
|
2887
|
+
* registry effect is injected through `CleanupDeps`, so the policy stays small
|
|
2888
|
+
* and testable while the dangerous half (recursive deletion + registry
|
|
2889
|
+
* unregistration) stays in the caller.
|
|
2671
2890
|
*
|
|
2672
|
-
*
|
|
2673
|
-
* live session is never offered for deletion,
|
|
2674
|
-
* 2. otherwise the newest tombstone that names the workspace
|
|
2675
|
-
* (`lastActivityAt`, falling back to `expiredAt` for tombstones written
|
|
2676
|
-
* before routes recorded their workspace path),
|
|
2677
|
-
* 3. otherwise the directory's own `mtime` (an orphan workspace).
|
|
2891
|
+
* Activity precedence:
|
|
2678
2892
|
*
|
|
2679
|
-
*
|
|
2680
|
-
*
|
|
2681
|
-
*
|
|
2682
|
-
*
|
|
2683
|
-
*
|
|
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
|
-
*
|
|
3814
|
-
*
|
|
3815
|
-
* Every Feishu topic gets its own workspace, grouped by the (chat, user) pair:
|
|
3816
|
-
* - p2p: `<baseDir>/p2p/<open_id>/<topic>/`
|
|
3817
|
-
* - group: `<baseDir>/groups/<chat_id>/<open_id>/<topic>/`
|
|
4113
|
+
* Per-topic Session placement: which directory each Feishu conversation works
|
|
4114
|
+
* in, without growing the host workspace list with every topic.
|
|
3818
4115
|
*
|
|
3819
|
-
*
|
|
3820
|
-
*
|
|
3821
|
-
*
|
|
4116
|
+
* A Session's **header cwd** is fixed at creation and decides two things:
|
|
4117
|
+
* its workspace-registry membership (the GUI project it is grouped under) and
|
|
4118
|
+
* the sandbox write boundary. The **effective working directory** is a mutable
|
|
4119
|
+
* Session projection (`ctx.workingDirectory`), persisted in the Session log
|
|
4120
|
+
* and restored on resume; the bash/fs/terminal tools resolve it before every
|
|
4121
|
+
* call.
|
|
3822
4122
|
*
|
|
3823
|
-
*
|
|
3824
|
-
* anyway so a hostile id can never escape `baseDir`.
|
|
4123
|
+
* This module plans one topic's placement:
|
|
3825
4124
|
*
|
|
3826
|
-
*
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
/**
|
|
3832
|
-
* Validate and normalize a workspace root supplied by the settings panel.
|
|
4125
|
+
* - `subdir`: `<workspace>/topics/<token>` — a plain directory switched to via
|
|
4126
|
+
* `workingDirectory.set`.
|
|
4127
|
+
* - `worktree`: a Git worktree created through `ctx.worktrees` (branch
|
|
4128
|
+
* `feishu-<token>`), used when the workspace is a Git repository or when the
|
|
4129
|
+
* deployment forces it.
|
|
3833
4130
|
*
|
|
3834
|
-
*
|
|
3835
|
-
*
|
|
3836
|
-
*
|
|
3837
|
-
*
|
|
3838
|
-
* the
|
|
3839
|
-
* directories are appended to it.
|
|
4131
|
+
* Both modes keep the Session's header cwd on the workspace root, so the
|
|
4132
|
+
* Session stays grouped under the user's project. When the host exposes no
|
|
4133
|
+
* `workingDirectory` service (unusual: it ships with `dsh-base`), the Session
|
|
4134
|
+
* is created directly in the topic directory instead — file isolation still
|
|
4135
|
+
* holds, but the Session can no longer be attached to the workspace.
|
|
3840
4136
|
*
|
|
3841
|
-
* @
|
|
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
|
-
function safeSegment(raw, what) {
|
|
3908
|
-
const segment = raw.replace(/^\/+|\/+$/g, "");
|
|
3909
|
-
if (!SAFE_SEGMENT.test(segment) || segment === "." || segment === "..") throw new Error(`dsh-channels: unsafe ${what} for workspace path: ${JSON.stringify(raw)}`);
|
|
3910
|
-
return segment;
|
|
3911
|
-
}
|
|
3912
4156
|
/**
|
|
3913
|
-
*
|
|
3914
|
-
*
|
|
3915
|
-
*
|
|
4157
|
+
* Pick the isolation mode for one topic. `auto` upgrades to a worktree only
|
|
4158
|
+
* when both the capability and a Git repository are present; an explicit
|
|
4159
|
+
* `worktree` preference still falls back (with a warning) when unavailable.
|
|
3916
4160
|
*/
|
|
3917
|
-
function
|
|
3918
|
-
|
|
4161
|
+
async function chooseTopicMode(services, config, workspacePath, warn) {
|
|
4162
|
+
const requested = config.topicIsolation;
|
|
4163
|
+
if (requested === "subdir") return "subdir";
|
|
4164
|
+
if (!(services.workingDirectory !== void 0 && services.worktrees !== void 0)) {
|
|
4165
|
+
if (requested === "worktree") warn("dsh-channels: topicIsolation=worktree needs the workingDirectory and worktrees host services; using a plain topic directory");
|
|
4166
|
+
return "subdir";
|
|
4167
|
+
}
|
|
4168
|
+
if (requested === "worktree") return "worktree";
|
|
4169
|
+
return await workspaceHasGitRepository(workspacePath) ? "worktree" : "subdir";
|
|
3919
4170
|
}
|
|
3920
4171
|
/**
|
|
3921
|
-
*
|
|
3922
|
-
*
|
|
3923
|
-
*
|
|
3924
|
-
* - p2p: `<baseDir>/topics/p2p/<open_id>/<topic>/`
|
|
3925
|
-
* - group: `<baseDir>/topics/groups/<chat_id>/<open_id>/<topic>/`
|
|
4172
|
+
* Decide how a new Session is placed and materialize everything that must
|
|
4173
|
+
* exist before the Session is created.
|
|
3926
4174
|
*
|
|
3927
|
-
*
|
|
3928
|
-
*
|
|
3929
|
-
*
|
|
4175
|
+
* The returned `cwd` is the header cwd: the workspace root when the runtime
|
|
4176
|
+
* can switch directories (so the Session is grouped under the user's project),
|
|
4177
|
+
* otherwise the topic directory itself.
|
|
3930
4178
|
*/
|
|
3931
|
-
function
|
|
3932
|
-
const
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
4179
|
+
async function planTopic(services, config, workspacePath, topicToken, warn) {
|
|
4180
|
+
const mode = await chooseTopicMode(services, config, workspacePath, warn);
|
|
4181
|
+
if (services.workingDirectory !== void 0) {
|
|
4182
|
+
const subdir = mode === "subdir" ? topicDirFor(workspacePath, topicToken) : void 0;
|
|
4183
|
+
if (subdir !== void 0) await mkdir(subdir, { recursive: true });
|
|
4184
|
+
return {
|
|
4185
|
+
mode,
|
|
4186
|
+
cwd: workspacePath,
|
|
4187
|
+
...subdir === void 0 ? {} : { subdir },
|
|
4188
|
+
switchable: true
|
|
4189
|
+
};
|
|
4190
|
+
}
|
|
4191
|
+
const subdir = topicDirFor(workspacePath, topicToken);
|
|
4192
|
+
await mkdir(subdir, { recursive: true });
|
|
4193
|
+
return {
|
|
4194
|
+
mode: "subdir",
|
|
4195
|
+
cwd: subdir,
|
|
4196
|
+
subdir,
|
|
4197
|
+
switchable: false
|
|
4198
|
+
};
|
|
4199
|
+
}
|
|
4200
|
+
/** Enter the planned topic directory once the Session (and its Agent) exists. */
|
|
4201
|
+
async function enterTopic(services, agent, plan, workspacePath, topicToken, warn) {
|
|
4202
|
+
if (plan.mode === "worktree" && services.worktrees !== void 0) try {
|
|
4203
|
+
const created = await services.worktrees.create(agent, { name: worktreeBranchFor(topicToken) });
|
|
4204
|
+
return {
|
|
4205
|
+
sessionDir: created.path,
|
|
4206
|
+
worktreeBranch: created.branch
|
|
4207
|
+
};
|
|
4208
|
+
} catch (error) {
|
|
4209
|
+
warn("dsh-channels: creating a worktree for topic %s failed, using a plain topic directory instead: %s", topicToken, String(error));
|
|
4210
|
+
}
|
|
4211
|
+
const dir = plan.subdir ?? topicDirFor(workspacePath, topicToken);
|
|
4212
|
+
await mkdir(dir, { recursive: true });
|
|
4213
|
+
if (plan.switchable) await alignWorkingDirectory(services, agent, dir, warn);
|
|
4214
|
+
return { sessionDir: dir };
|
|
3936
4215
|
}
|
|
3937
4216
|
/**
|
|
3938
|
-
*
|
|
3939
|
-
*
|
|
3940
|
-
*
|
|
4217
|
+
* Align one live Session with its recorded topic directory: make sure the
|
|
4218
|
+
* directory exists, recreate it (or a fresh worktree) when it disappeared,
|
|
4219
|
+
* and restore the effective working directory the route expects.
|
|
4220
|
+
*
|
|
4221
|
+
* The returned placement may carry a new directory/branch after recovery; the
|
|
4222
|
+
* caller persists it back into the route record.
|
|
3941
4223
|
*/
|
|
3942
|
-
function
|
|
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 {
|
|
5787
|
+
kind: "missing",
|
|
5788
|
+
text: "当前飞书路由未记录可用的工作区目录;请检查路由持久化状态。"
|
|
5789
|
+
};
|
|
5790
|
+
if (route.sessionDir !== void 0 && !path.isAbsolute(route.sessionDir)) return {
|
|
5398
5791
|
kind: "missing",
|
|
5399
|
-
text: "
|
|
5792
|
+
text: "当前飞书路由未记录可用的会话目录;请检查路由持久化状态。"
|
|
5400
5793
|
};
|
|
5401
|
-
|
|
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;
|
|
@@ -6922,9 +7334,9 @@ var FeishuService = class {
|
|
|
6922
7334
|
return value;
|
|
6923
7335
|
}
|
|
6924
7336
|
/**
|
|
6925
|
-
*
|
|
6926
|
-
*
|
|
6927
|
-
*
|
|
7337
|
+
* Retention preview for the Web panel: how many plugin workspaces exist,
|
|
7338
|
+
* how many directories are past the configured window, and their measured
|
|
7339
|
+
* footprint. Never deletes anything.
|
|
6928
7340
|
*/
|
|
6929
7341
|
async cleanupPreview() {
|
|
6930
7342
|
const retention = this.config.workspaceRetention;
|
|
@@ -6961,15 +7373,21 @@ var FeishuService = class {
|
|
|
6961
7373
|
}
|
|
6962
7374
|
}
|
|
6963
7375
|
/**
|
|
6964
|
-
* Delete every stale plugin
|
|
6965
|
-
*
|
|
6966
|
-
*
|
|
6967
|
-
*
|
|
7376
|
+
* Delete every stale plugin directory the configured retention allows: the
|
|
7377
|
+
* per-topic working directories of current conversations and the legacy
|
|
7378
|
+
* one-workspace-per-topic directories of older versions. User workspaces are
|
|
7379
|
+
* never deleted.
|
|
7380
|
+
*
|
|
7381
|
+
* Active routes bound to a removed directory are retired first (thread
|
|
7382
|
+
* routes leave a tombstone), so an old topic fails with the explicit expired
|
|
7383
|
+
* card instead of resuming into a missing directory. DSH Session logs are not
|
|
7384
|
+
* touched.
|
|
6968
7385
|
*/
|
|
6969
7386
|
async runWorkspaceCleanup() {
|
|
6970
7387
|
const empty = {
|
|
6971
7388
|
ok: false,
|
|
6972
7389
|
deletedWorkspaces: 0,
|
|
7390
|
+
deletedSessionDirs: 0,
|
|
6973
7391
|
retiredRoutes: 0,
|
|
6974
7392
|
freedBytes: 0,
|
|
6975
7393
|
failures: []
|
|
@@ -6980,11 +7398,11 @@ var FeishuService = class {
|
|
|
6980
7398
|
};
|
|
6981
7399
|
if (retentionMs(this.config.workspaceRetention) === void 0) return {
|
|
6982
7400
|
...empty,
|
|
6983
|
-
error: "
|
|
7401
|
+
error: "未启用目录清理阈值(workspaceRetention 为 off)"
|
|
6984
7402
|
};
|
|
6985
7403
|
if (this.cleanupRunning) return {
|
|
6986
7404
|
...empty,
|
|
6987
|
-
error: "
|
|
7405
|
+
error: "目录清理正在进行中,请稍候"
|
|
6988
7406
|
};
|
|
6989
7407
|
this.cleanupRunning = true;
|
|
6990
7408
|
try {
|
|
@@ -6992,13 +7410,14 @@ var FeishuService = class {
|
|
|
6992
7410
|
const plan = await planWorkspaceCleanup(deps);
|
|
6993
7411
|
if (plan === void 0) return {
|
|
6994
7412
|
...empty,
|
|
6995
|
-
error: "
|
|
7413
|
+
error: "目录清理不可用:baseDir 配置无效"
|
|
6996
7414
|
};
|
|
6997
7415
|
const outcome = await applyWorkspaceCleanup(deps, plan);
|
|
6998
|
-
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);
|
|
6999
7417
|
return {
|
|
7000
7418
|
ok: true,
|
|
7001
7419
|
deletedWorkspaces: outcome.deletedWorkspaces,
|
|
7420
|
+
deletedSessionDirs: outcome.deletedSessionDirs,
|
|
7002
7421
|
retiredRoutes: outcome.retiredRoutes,
|
|
7003
7422
|
freedBytes: outcome.freedBytes,
|
|
7004
7423
|
failures: outcome.failures.map((failure) => `${failure.path}: ${failure.message}`)
|
|
@@ -7023,40 +7442,35 @@ var FeishuService = class {
|
|
|
7023
7442
|
baseDir,
|
|
7024
7443
|
retention: this.config.workspaceRetention,
|
|
7025
7444
|
listWorkspaces: () => this.ctx.workspaceRegistry.list(),
|
|
7445
|
+
listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
|
|
7026
7446
|
activeRoutes: () => [...this.domain?.routes.entries() ?? []].map(([key, record]) => ({
|
|
7027
7447
|
key,
|
|
7028
7448
|
kind: record.kind,
|
|
7029
7449
|
workspacePath: record.workspacePath,
|
|
7450
|
+
...record.sessionDir === void 0 ? {} : { sessionDir: record.sessionDir },
|
|
7030
7451
|
sessionId: record.sessionId,
|
|
7031
7452
|
lastActivityAt: record.lastActivityAt
|
|
7032
7453
|
})),
|
|
7033
7454
|
tombstones: () => [...this.domain?.expiredRoutes.entries() ?? []].map(([key, record]) => ({
|
|
7034
7455
|
key,
|
|
7035
7456
|
...record.workspacePath === void 0 ? {} : { workspacePath: record.workspacePath },
|
|
7457
|
+
...record.sessionDir === void 0 ? {} : { sessionDir: record.sessionDir },
|
|
7036
7458
|
expiredAt: record.expiredAt,
|
|
7037
7459
|
...record.lastActivityAt === void 0 ? {} : { lastActivityAt: record.lastActivityAt }
|
|
7038
7460
|
})),
|
|
7039
7461
|
isLive: (sessionId) => this.sessions.get(sessionId) !== void 0 || this.ctx.agents.get(sessionId) !== void 0,
|
|
7040
|
-
statWorkspace: (
|
|
7462
|
+
statWorkspace: (directory) => measureWorkspace(directory),
|
|
7041
7463
|
removeWorkspace: async (workspace) => {
|
|
7042
|
-
|
|
7043
|
-
if (!isPathInside(baseDir, target)) throw new Error(`refusing to delete a path outside baseDir (${target})`);
|
|
7044
|
-
try {
|
|
7045
|
-
const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(target)]);
|
|
7046
|
-
if (!isPathInside(physicalBase, physicalTarget)) throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`);
|
|
7047
|
-
} catch (error) {
|
|
7048
|
-
if (error instanceof Error && error.message.startsWith("refusing to delete a symlink escape")) throw error;
|
|
7049
|
-
}
|
|
7050
|
-
await rm(target, {
|
|
7051
|
-
recursive: true,
|
|
7052
|
-
force: true
|
|
7053
|
-
});
|
|
7464
|
+
await this.deleteDirectoryInsideBase(baseDir, workspace.path);
|
|
7054
7465
|
try {
|
|
7055
7466
|
await this.ctx.workspaceRegistry.delete(workspace.id);
|
|
7056
7467
|
} catch (error) {
|
|
7057
7468
|
this.ctx.logger?.warn?.("dsh-channels: unregistering cleaned workspace %s failed: %s", workspace.path, String(error));
|
|
7058
7469
|
}
|
|
7059
7470
|
},
|
|
7471
|
+
removeSessionDir: async (directory) => {
|
|
7472
|
+
await this.deleteDirectoryInsideBase(baseDir, directory);
|
|
7473
|
+
},
|
|
7060
7474
|
retireRoute: async (route) => {
|
|
7061
7475
|
if (route.kind === "thread") await this.routes.retire(route.key);
|
|
7062
7476
|
else await this.routes.remove(route.key);
|
|
@@ -7065,6 +7479,65 @@ var FeishuService = class {
|
|
|
7065
7479
|
};
|
|
7066
7480
|
}
|
|
7067
7481
|
/**
|
|
7482
|
+
* Scan the live user workspaces for per-topic working directories that no
|
|
7483
|
+
* route record points at anymore (for example the old route of a degraded
|
|
7484
|
+
* chat conversation was deleted by TTL retirement). Route/tombstone evidence
|
|
7485
|
+
* wins for age; the scan mtime only covers true orphans.
|
|
7486
|
+
*/
|
|
7487
|
+
async listCleanableSessionDirs(baseDir) {
|
|
7488
|
+
const legacyRoot = legacyTopicWorkspaceRoot(baseDir);
|
|
7489
|
+
const roots = [];
|
|
7490
|
+
for (const workspace of this.ctx.workspaceRegistry.list()) {
|
|
7491
|
+
if (!this.isPluginWorkspace(workspace)) continue;
|
|
7492
|
+
const resolved = path.resolve(workspace.path);
|
|
7493
|
+
if (isPathInside(legacyRoot, resolved)) continue;
|
|
7494
|
+
roots.push(resolved);
|
|
7495
|
+
}
|
|
7496
|
+
const scanned = [];
|
|
7497
|
+
for (const root of roots) for (const relative of ["topics", path.join(".agents", "worktrees")]) {
|
|
7498
|
+
const directory = path.join(root, relative);
|
|
7499
|
+
let entries;
|
|
7500
|
+
try {
|
|
7501
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
7502
|
+
} catch {
|
|
7503
|
+
continue;
|
|
7504
|
+
}
|
|
7505
|
+
for (const entry of entries) {
|
|
7506
|
+
if (!entry.isDirectory()) continue;
|
|
7507
|
+
const candidate = path.join(directory, entry.name);
|
|
7508
|
+
try {
|
|
7509
|
+
const info = await lstat(candidate);
|
|
7510
|
+
if (info.isDirectory()) scanned.push({
|
|
7511
|
+
path: candidate,
|
|
7512
|
+
mtimeMs: info.mtimeMs
|
|
7513
|
+
});
|
|
7514
|
+
} catch {}
|
|
7515
|
+
}
|
|
7516
|
+
}
|
|
7517
|
+
return scanned;
|
|
7518
|
+
}
|
|
7519
|
+
/**
|
|
7520
|
+
* Recursively delete one plugin directory below `baseDir`.
|
|
7521
|
+
*
|
|
7522
|
+
* The lexical check refuses paths outside `baseDir`; the realpath check then
|
|
7523
|
+
* refuses a symlinked ancestor that would let the recursive delete escape
|
|
7524
|
+
* the plugin root. A missing directory is already effectively removed.
|
|
7525
|
+
*/
|
|
7526
|
+
async deleteDirectoryInsideBase(baseDir, target) {
|
|
7527
|
+
const resolved = path.resolve(target);
|
|
7528
|
+
if (!isPathInside(baseDir, resolved)) throw new Error(`refusing to delete a path outside baseDir (${resolved})`);
|
|
7529
|
+
try {
|
|
7530
|
+
const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(resolved)]);
|
|
7531
|
+
if (!isPathInside(physicalBase, physicalTarget)) throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`);
|
|
7532
|
+
} catch (error) {
|
|
7533
|
+
if (error instanceof Error && error.message.startsWith("refusing to delete a symlink escape")) throw error;
|
|
7534
|
+
}
|
|
7535
|
+
await rm(resolved, {
|
|
7536
|
+
recursive: true,
|
|
7537
|
+
force: true
|
|
7538
|
+
});
|
|
7539
|
+
}
|
|
7540
|
+
/**
|
|
7068
7541
|
* (Re)arm the daily retention timer to match the live config. `runNow`
|
|
7069
7542
|
* performs one pass immediately; it is only used at startup so saving any
|
|
7070
7543
|
* unrelated setting can never trigger a surprise deletion pass.
|
|
@@ -7077,7 +7550,7 @@ var FeishuService = class {
|
|
|
7077
7550
|
if (!this.started || !this.config.workspaceRetentionAuto || retentionMs(this.config.workspaceRetention) === void 0) return;
|
|
7078
7551
|
const sweep = () => {
|
|
7079
7552
|
this.runWorkspaceCleanup().then((result) => {
|
|
7080
|
-
if (!result.ok && result.error !== "
|
|
7553
|
+
if (!result.ok && result.error !== "目录清理正在进行中,请稍候") this.ctx.logger?.warn?.("dsh-channels: automatic workspace cleanup failed: %s", result.error);
|
|
7081
7554
|
});
|
|
7082
7555
|
};
|
|
7083
7556
|
if (runNow) sweep();
|
|
@@ -7794,7 +8267,7 @@ var FeishuService = class {
|
|
|
7794
8267
|
await this.delivery.standaloneReply(msg, buildRenewCard({
|
|
7795
8268
|
previousSessionId: renewAdmission.commit.previous.sessionId,
|
|
7796
8269
|
currentSessionId: renewAdmission.commit.current.sessionId,
|
|
7797
|
-
|
|
8270
|
+
sessionDir: sessionDirOf(renewAdmission.commit.current)
|
|
7798
8271
|
}));
|
|
7799
8272
|
return;
|
|
7800
8273
|
}
|
|
@@ -7812,7 +8285,7 @@ var FeishuService = class {
|
|
|
7812
8285
|
context,
|
|
7813
8286
|
extras: {
|
|
7814
8287
|
permissionPreset: this.permissionPresetOf(session),
|
|
7815
|
-
|
|
8288
|
+
sessionDir: sessionDirOf(current),
|
|
7816
8289
|
uptimeMs: this.startedAt === void 0 ? void 0 : Date.now() - this.startedAt,
|
|
7817
8290
|
liveAgents: this.sessions.liveCount(),
|
|
7818
8291
|
routeCount: this.routes.size()
|
|
@@ -7821,7 +8294,10 @@ var FeishuService = class {
|
|
|
7821
8294
|
return;
|
|
7822
8295
|
}
|
|
7823
8296
|
if (spec.name === "workspace" && result.kind === "success") {
|
|
7824
|
-
const resolved = resolveWorkDir(live.agent.session.header.cwd,
|
|
8297
|
+
const resolved = resolveWorkDir(live.agent.session.header.cwd, {
|
|
8298
|
+
workspacePath: current.workspacePath,
|
|
8299
|
+
...current.sessionDir === void 0 ? {} : { sessionDir: current.sessionDir }
|
|
8300
|
+
});
|
|
7825
8301
|
if (resolved.kind === "success") {
|
|
7826
8302
|
await this.delivery.standaloneReply(msg, buildWorkDirCard(current.sessionId, resolved.path));
|
|
7827
8303
|
return;
|
|
@@ -8338,6 +8814,27 @@ const configSchema = z.object({
|
|
|
8338
8814
|
*/
|
|
8339
8815
|
threadFallback: z.enum(["degrade", "strict"]).default("degrade"),
|
|
8340
8816
|
/**
|
|
8817
|
+
* Per-topic working-directory isolation inside one user workspace.
|
|
8818
|
+
*
|
|
8819
|
+
* - `auto` (default): use a Git worktree per topic when the user workspace
|
|
8820
|
+
* is a Git repository (the workspace directory contains `.git`), otherwise
|
|
8821
|
+
* a plain subdirectory.
|
|
8822
|
+
* - `subdir`: always a plain subdirectory (no Git requirement, no duplicated
|
|
8823
|
+
* checkouts).
|
|
8824
|
+
* - `worktree`: always try a Git worktree; when the workspace is not a Git
|
|
8825
|
+
* repository, the `worktrees` service is unavailable, or creation is
|
|
8826
|
+
* denied, log a warning and fall back to a subdirectory.
|
|
8827
|
+
*
|
|
8828
|
+
* The workspace itself stays the Session header cwd either way, so Sessions
|
|
8829
|
+
* remain grouped under the user's project; only the effective working
|
|
8830
|
+
* directory changes. See `docs/workspace-isolation-design.md`.
|
|
8831
|
+
*/
|
|
8832
|
+
topicIsolation: z.enum([
|
|
8833
|
+
"auto",
|
|
8834
|
+
"subdir",
|
|
8835
|
+
"worktree"
|
|
8836
|
+
]).default("auto"),
|
|
8837
|
+
/**
|
|
8341
8838
|
* Route-table GC threshold. Route records (and stale in-flight repair
|
|
8342
8839
|
* snapshots) whose last activity is older than this many milliseconds are
|
|
8343
8840
|
* retired at startup and on every periodic sweep. Real thread routes leave a
|
|
@@ -8363,17 +8860,19 @@ const configSchema = z.object({
|
|
|
8363
8860
|
*/
|
|
8364
8861
|
acceptAttachments: z.boolean().default(true),
|
|
8365
8862
|
/**
|
|
8366
|
-
* Delete the
|
|
8367
|
-
*
|
|
8368
|
-
*
|
|
8369
|
-
*
|
|
8370
|
-
*
|
|
8863
|
+
* Delete the topic directories of stale conversations, plus legacy
|
|
8864
|
+
* one-workspace-per-topic directories written by older plugin versions.
|
|
8865
|
+
* `off` (default) never deletes; `7d`/`30d`/`90d`/`180d` remove those
|
|
8866
|
+
* directories when their last activity is older than the threshold. User
|
|
8867
|
+
* workspaces themselves are never deleted. The managed Web panel exposes
|
|
8868
|
+
* this as a select, previews the candidates, and can run one cleanup
|
|
8869
|
+
* manually. DSH Session logs are never deleted.
|
|
8371
8870
|
*/
|
|
8372
8871
|
workspaceRetention: z.enum(WORKSPACE_RETENTION_VALUES).default("off"),
|
|
8373
8872
|
/**
|
|
8374
|
-
* Run the
|
|
8375
|
-
*
|
|
8376
|
-
*
|
|
8873
|
+
* Run the retention cleanup at startup and once per day. Off by default:
|
|
8874
|
+
* deletion is irreversible, so it stays an explicit opt-in even when a
|
|
8875
|
+
* retention threshold is configured.
|
|
8377
8876
|
*/
|
|
8378
8877
|
workspaceRetentionAuto: z.boolean().default(false),
|
|
8379
8878
|
/**
|
|
@@ -8465,7 +8964,7 @@ const CREDENTIALS_ROUTE = "/dsh-channels/credentials";
|
|
|
8465
8964
|
const RECONNECT_ROUTE = "/dsh-channels/reconnect";
|
|
8466
8965
|
/** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
|
|
8467
8966
|
const CATALOG_ROUTE = "/dsh-channels/catalog";
|
|
8468
|
-
/**
|
|
8967
|
+
/** 手动执行一次过期目录清理(POST,无请求体)。 */
|
|
8469
8968
|
const CLEANUP_ROUTE = "/dsh-channels/cleanup";
|
|
8470
8969
|
/** 访问与授权只读快照(GET/HEAD):准入模式、管理员、群/用户白名单、待处理申请。 */
|
|
8471
8970
|
const ACCESS_ROUTE = "/dsh-channels/access";
|
|
@@ -8493,7 +8992,7 @@ const PICK_DIRECTORY_ROUTE = "/dsh-channels/pick-directory";
|
|
|
8493
8992
|
* 因此不会落进覆盖层文件。
|
|
8494
8993
|
*
|
|
8495
8994
|
* `baseDir`(工作区根目录 = 模型/工具产生文件的落点)可覆盖,但面板只接受
|
|
8496
|
-
* **绝对路径**(`~` 会展开);已有 Session /
|
|
8995
|
+
* **绝对路径**(`~` 会展开);已有 Session / 目录不迁移,只影响之后新建的
|
|
8497
8996
|
* 会话。校验与保存时建目录见 `src/workspaces.ts`。
|
|
8498
8997
|
*/
|
|
8499
8998
|
const EDITABLE_KEYS = [
|
|
@@ -8593,7 +9092,7 @@ const PANEL_COPY = {
|
|
|
8593
9092
|
connectionTabHelp: "连接状态与飞书应用凭据。凭据不完整时不会连接飞书。",
|
|
8594
9093
|
sessionsTabHelp: "私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。",
|
|
8595
9094
|
modelTabHelp: "飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。",
|
|
8596
|
-
workspaceTabHelp: "
|
|
9095
|
+
workspaceTabHelp: "每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。",
|
|
8597
9096
|
accessTabHelp: "准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。",
|
|
8598
9097
|
advancedTabHelp: "流式刷新等低层参数、Web 覆盖文件与只读信息。",
|
|
8599
9098
|
save: "保存更改",
|
|
@@ -8639,11 +9138,12 @@ const PANEL_COPY = {
|
|
|
8639
9138
|
advancedCard: "只读与高级信息",
|
|
8640
9139
|
advancedHelp: "这些值来自 profile 配置,不能在面板里编辑。",
|
|
8641
9140
|
readOnlyCard: "部署级参数",
|
|
8642
|
-
cleanupCard: "
|
|
8643
|
-
cleanupCardHelp: "
|
|
9141
|
+
cleanupCard: "目录清理",
|
|
9142
|
+
cleanupCardHelp: "删除超过保留期且不再活跃的飞书话题目录(以及旧版本按话题建立的工作区目录);用户工作区本身、DSH 会话日志都不会删除。删除不可恢复:相关旧话题会失效并提示已过期。",
|
|
8644
9143
|
cleanupRetention: "保留期",
|
|
8645
9144
|
cleanupAutoLabel: "自动清理",
|
|
8646
|
-
cleanupCandidates: "
|
|
9145
|
+
cleanupCandidates: "可清理目录",
|
|
9146
|
+
cleanupWorkspaces: "插件工作区",
|
|
8647
9147
|
cleanupCandidateBytes: "预计释放空间",
|
|
8648
9148
|
cleanupRetentionOff: "关闭(不删除)",
|
|
8649
9149
|
cleanupAutoOn: "已开启(启动时 + 每天一次)",
|
|
@@ -8651,17 +9151,18 @@ const PANEL_COPY = {
|
|
|
8651
9151
|
cleanupAutoInactive: "未执行(保留期为关闭)",
|
|
8652
9152
|
cleanupNow: "立即清理",
|
|
8653
9153
|
cleanupRunning: "清理中…",
|
|
8654
|
-
cleanupConfirm: "
|
|
8655
|
-
cleanupConfirmHint: "
|
|
8656
|
-
cleanupDisabledOff: "
|
|
8657
|
-
cleanupDisabledEmpty: "
|
|
9154
|
+
cleanupConfirm: "确定清理过期目录吗?该操作不可恢复,会删除这些目录内的全部文件(包括 attachments/ 附件)。",
|
|
9155
|
+
cleanupConfirmHint: "删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。",
|
|
9156
|
+
cleanupDisabledOff: "保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。",
|
|
9157
|
+
cleanupDisabledEmpty: "没有超过保留期的过期目录。",
|
|
8658
9158
|
cleanupPreviewFailed: "无法读取清理预览",
|
|
8659
9159
|
cleanupDone: "清理完成",
|
|
8660
9160
|
cleanupFailed: "清理失败",
|
|
8661
9161
|
cleanupDeleted: "已删除工作区",
|
|
9162
|
+
cleanupDeletedDirs: "话题目录",
|
|
8662
9163
|
cleanupRetired: "已清理话题路由",
|
|
8663
9164
|
cleanupFreed: "释放空间",
|
|
8664
|
-
cleanupFailures: "
|
|
9165
|
+
cleanupFailures: "以下目录未能删除:",
|
|
8665
9166
|
accessCard: "访问与授权",
|
|
8666
9167
|
accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。",
|
|
8667
9168
|
accessModeOpenWarning: "当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。",
|
|
@@ -9350,7 +9851,7 @@ const SELECT_OPTIONS = {
|
|
|
9350
9851
|
const FIELD_META = {
|
|
9351
9852
|
baseDir: {
|
|
9352
9853
|
label: "工作区根目录(文件落点)",
|
|
9353
|
-
help: "
|
|
9854
|
+
help: "每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<open_id>/,群聊 <baseDir>/groups/<chat_id>/<open_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。",
|
|
9354
9855
|
placeholder: "/Users/you/.dsh/dsh-channels/workspaces",
|
|
9355
9856
|
wide: true
|
|
9356
9857
|
},
|
|
@@ -9395,12 +9896,12 @@ const FIELD_META = {
|
|
|
9395
9896
|
help: "接收飞书图片、文件与合并转发记录,保存到工作区的 attachments/ 目录;模型不会自动读取,需要时用文件工具自行查看。关闭后附件消息不保存、不触发回复。"
|
|
9396
9897
|
},
|
|
9397
9898
|
workspaceRetention: {
|
|
9398
|
-
label: "
|
|
9399
|
-
help: "
|
|
9899
|
+
label: "过期目录保留期",
|
|
9900
|
+
help: "超过所选天数没有活动的飞书话题目录(以及旧版本按话题建立的工作区目录)会进入下方清理列表;用户工作区本身永不删除。DSH 会话日志不受影响。"
|
|
9400
9901
|
},
|
|
9401
9902
|
workspaceRetentionAuto: {
|
|
9402
|
-
label: "
|
|
9403
|
-
help: "
|
|
9903
|
+
label: "自动执行目录清理",
|
|
9904
|
+
help: "开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。"
|
|
9404
9905
|
},
|
|
9405
9906
|
provider: {
|
|
9406
9907
|
label: "模型服务商",
|
|
@@ -9664,6 +10165,12 @@ function normalizeAccessName(raw) {
|
|
|
9664
10165
|
/** Build the read-only advanced card from the effective config. */
|
|
9665
10166
|
function readOnlyFields(config) {
|
|
9666
10167
|
return [
|
|
10168
|
+
{
|
|
10169
|
+
key: "topicIsolation",
|
|
10170
|
+
label: "话题目录隔离",
|
|
10171
|
+
value: config.topicIsolation === "auto" ? "自动(Git 仓库用 worktree,否则普通子目录)" : config.topicIsolation === "worktree" ? "Git worktree(失败回退子目录)" : "普通子目录",
|
|
10172
|
+
help: "每个话题的独立工作目录形式;在 profile 配置里用 topicIsolation 设置(auto / subdir / worktree)。"
|
|
10173
|
+
},
|
|
9667
10174
|
{
|
|
9668
10175
|
key: "routeTtlMs",
|
|
9669
10176
|
label: "话题路由过期",
|
|
@@ -9838,7 +10345,7 @@ var SettingsSurface = class {
|
|
|
9838
10345
|
try {
|
|
9839
10346
|
cleanup = await this.target.cleanupPreview();
|
|
9840
10347
|
} catch (error) {
|
|
9841
|
-
this.logger?.warn?.("dsh-channels:
|
|
10348
|
+
this.logger?.warn?.("dsh-channels: 目录清理预览失败:%s", messageOf$1(error));
|
|
9842
10349
|
cleanup = {
|
|
9843
10350
|
retention: effective.workspaceRetention,
|
|
9844
10351
|
auto: effective.workspaceRetentionAuto,
|
|
@@ -10581,6 +11088,6 @@ function apply(ctx, config) {
|
|
|
10581
11088
|
}, "dsh-channels");
|
|
10582
11089
|
}
|
|
10583
11090
|
//#endregion
|
|
10584
|
-
export { AccessController, AccessRequests, AccessStore, ApprovalAnswerer, CARD_MAX_BYTES,
|
|
11091
|
+
export { AccessController, AccessRequests, AccessStore, ApprovalAnswerer, CARD_MAX_BYTES, CARD_TARGET_BYTES, CATALOG_ROUTE, CHANNEL_COMMAND_NAMES, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, Config, DM_DENY_TEXT, DM_UNCONFIGURED_TEXT, DSH_CHANNELS_VERSION, EDITABLE_KEYS, EMPTY_TOTALS, FIELD_DESCRIPTORS, FeishuService, HEALTH_DEPENDENCY_NAMES, INTERRUPTION_NOTICE, LOGICAL_EXPORT_FORMAT, LOGICAL_EXPORT_FORMAT_VERSION, MAX_QUESTION_OPTIONS, MAX_TABLES_PER_CARD, OverlayStore, PANEL_CHEVRON_TOKEN, PANEL_COPY, PANEL_ROUTE, PANEL_TITLES, PICK_DIRECTORY_ROUTE, QuestionAnswerer, REASONING_MAX_BYTES, RECONNECT_ROUTE, RESTART_KEYS, RouteOperationQueue, SESSION_HISTORY_DISPLAY_LIMIT, STATUS_ROUTE, SUPPORTED_COMMAND_NAMES, SettingsSurface, TRUNCATION_SUFFIX, TurnStreamer, accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, addUsage, appendSessionHistory, apply, applyCatalog, applyProjectionViews, armModelCommand, averageDecodeTokensPerSecond, averageTtftMs, buildApprovalCard, buildApprovalSettledCard, buildCard, buildCommandResultCard, buildCompactComparisonCard, buildGroupInfoCard, buildHealthCard, buildInterruptionCard, buildQuestionCard, buildQuestionSettledCard, buildRenewCard, buildRequestSettledCard, buildSessionHistoryCard, buildSessionStatusCard, buildUserRequestCard, buildWorkDirCard, cacheHitPct, cardJsonBytes, chatRouteKey, commandResultCardKind, compactFailureContext, countFiberStates, createHostCatalog, createLogicalSessionExport, createSettingsDirectoryPicker, decideAccess, defaultOverlayPath, emptyCatalog, ensureWorkspace, expiredRouteRecordSchema, feishuDomain, foldAll, foldEvent, foldHealthSnapshot, foldSessionStats, inflightSnapshotSchema, inject, isAdminOpenId, isSameOriginRequest, isSupportedCommand, legacyTopicWorkspaceRoot, maskOpenIdTail, measureSessionContext, mergeOverlay, mountSettingsRoutes, name, normalizeCredentialValue, normalizeOverlay, normalizeRouteRecord, normalizeSessionHistory, parseOverlay, parseSlashCommand, pickEditable, processedRecordSchema, querySessionHistory, readOnlyFields, readProjectionViews, registerChannelCommands, registerModelCommand, registerSettingsRoutes, resolveDshHome, resolveWorkDir, routeRecordSchema, segmentCardContent, serializeLogicalSession, sessionDirOf, sessionHistoryEntrySchema, splitBodyForCard, stagingPathFor, stripTables, supportedCommandHelp, supportedCommandList, tableBlocks, topicDirFor, topicTokenFor, usageFromBuckets, withDirectoryBrowse, workspacePathFor, workspaceTitleFor, worktreeBranchFor };
|
|
10585
11092
|
|
|
10586
11093
|
//# sourceMappingURL=index.js.map
|