@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +39 -11
  2. package/README.zh.md +25 -10
  3. package/backend/lib/access.js +44 -0
  4. package/backend/lib/agents.js +341 -0
  5. package/backend/lib/args.js +94 -0
  6. package/backend/lib/auth.js +195 -0
  7. package/backend/lib/board.js +6154 -0
  8. package/backend/lib/builtin-assets.js +1177 -0
  9. package/backend/lib/casescan.js +456 -0
  10. package/backend/lib/chat.js +2053 -0
  11. package/backend/lib/cookie.js +76 -0
  12. package/backend/lib/db.js +2260 -0
  13. package/backend/lib/dshdriver.js +1148 -0
  14. package/backend/lib/dshevents.js +851 -0
  15. package/backend/lib/export-cases.js +261 -0
  16. package/backend/lib/feishu.js +1738 -0
  17. package/backend/lib/feishu_cfg.js +228 -0
  18. package/backend/lib/feishu_conv.js +1484 -0
  19. package/backend/lib/feishu_device.js +428 -0
  20. package/backend/lib/feishu_inbound.js +1674 -0
  21. package/backend/lib/feishu_provision.js +855 -0
  22. package/backend/lib/feishu_rest.js +398 -0
  23. package/backend/lib/feishu_slash.js +568 -0
  24. package/backend/lib/feishu_ws.js +950 -0
  25. package/backend/lib/http.js +312 -0
  26. package/backend/lib/lib.js +973 -0
  27. package/backend/lib/loadcase.js +749 -0
  28. package/backend/lib/loadgen.js +1407 -0
  29. package/backend/lib/localbus.js +164 -0
  30. package/backend/lib/lock.js +405 -0
  31. package/backend/lib/proc.js +278 -0
  32. package/backend/lib/prompts.js +1021 -0
  33. package/backend/lib/pybase64.js +114 -0
  34. package/backend/lib/pyjson.js +214 -0
  35. package/backend/lib/query.js +125 -0
  36. package/backend/lib/rag.js +1310 -0
  37. package/backend/lib/runner.js +4520 -0
  38. package/backend/lib/servercaps.js +182 -0
  39. package/backend/lib/serverstate.js +237 -0
  40. package/backend/lib/sessparse.js +1176 -0
  41. package/backend/lib/sse.js +207 -0
  42. package/backend/lib/static.js +84 -0
  43. package/backend/lib/taskjson.js +302 -0
  44. package/backend/lib/waitq.js +2114 -0
  45. package/backend/lib/worktree.js +588 -0
  46. package/backend/lib/zstd-frames.js +218 -0
  47. package/backend/routes/admin.js +553 -0
  48. package/backend/routes/agents.js +149 -0
  49. package/backend/routes/assets.js +145 -0
  50. package/backend/routes/board.js +1220 -0
  51. package/backend/routes/feishu.js +269 -0
  52. package/backend/routes/feishu_app.js +215 -0
  53. package/backend/routes/feishu_hook.js +358 -0
  54. package/backend/routes/index.js +297 -0
  55. package/backend/routes/projects.js +1059 -0
  56. package/backend/routes/sessions.js +946 -0
  57. package/backend/routes/tasks.js +1530 -0
  58. package/backend/schema/fixtures/ancient.sql +189 -0
  59. package/backend/schema/fixtures/pre_alter.sql +177 -0
  60. package/backend/schema/migrations.json +154 -0
  61. package/backend/schema/schema.json +1451 -0
  62. package/backend/schema/schema.sql +216 -0
  63. package/backend/server.js +498 -0
  64. package/board.py +805 -72
  65. package/chat.py +590 -17
  66. package/db.py +356 -140
  67. package/dsh-plugin/cordis.patch.yml +7 -3
  68. package/dsh-plugin/install.sh +28 -11
  69. package/dsh-plugin/lib/agent-driver.js +710 -89
  70. package/dsh-plugin/lib/client.bundle.js +94 -9
  71. package/dsh-plugin/lib/index.js +529 -39
  72. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  73. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  74. package/dsh-plugin/src/client.js +94 -9
  75. package/dshdriver.py +74 -5
  76. package/dshevents.py +65 -3
  77. package/feishu.py +1197 -34
  78. package/feishu_conv.py +920 -0
  79. package/lifecycle.py +116 -13
  80. package/package.json +6 -1
  81. package/requirements.txt +2 -1
  82. package/server.py +218 -26
  83. package/sessparse.py +150 -44
  84. package/touchstone.py +6 -3
  85. package/waitq.py +23 -0
  86. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  87. package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
  88. package/webui/dist-plugin/index.html +2 -2
  89. package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
  90. package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
@@ -0,0 +1,291 @@
1
+ /**
2
+ * 发包闭包计算 —— 「npm 包里必须有哪些文件」的**唯一一份**算法(零依赖,只用 `node:`)。
3
+ *
4
+ * 为什么要有它:`package.json` 的 `files` 是**白名单**,漏一条就是「发出去的包起不来」,
5
+ * 而漏的形态是静默的 —— 2026-10-15 实测 `@gavinc-cn/touchstone-dsh@0.1.1` 的 105 个文件里
6
+ * **没有 `backend/routes/` 的 11 个文件**(白名单只写了 `backend/server.js` / `backend/lib` /
7
+ * `backend/schema`),解包后 `node backend/server.js` 当场
8
+ * `ERR_MODULE_NOT_FOUND: .../backend/routes/index.js`(`backend/server.js:61` 就 import 它)
9
+ * 并 exit 1。手写清单(`dsh-plugin/scripts/prepack-check.mjs` 的 `REQUIRED`)挡不住这种漏,
10
+ * 因为它只查「我记得的那些」;这里改成**从入口正推**:包内必需 = 两个入口的**静态 import
11
+ * 闭包** + 运行时时按路径读的数据根。新增一个 `backend/routes/xxx.js` 而忘了改白名单 ⇒
12
+ * 闭包自动长大 ⇒ 判据变红。
13
+ *
14
+ * 两个入口(`package.json` 的 `main` + Node 后端入口,见 `dsh-plugin/lib/index.js:117-118`):
15
+ * - `dsh-plugin/lib/index.js` —— dsh 插件壳,宿主 `require`/`import` 的就是它;
16
+ * - `backend/server.js` —— 薄壳 spawn 的 Node 后端进程。
17
+ * 只解析**相对说明符**(`./` / `../`):`node:*` 内建与裸包名不是包内文件,一律记为
18
+ * `unresolved` 供人看(不参与判据 —— 把裸包名当必需会把判据搞成假红)。
19
+ *
20
+ * 数据根(`RUNTIME_RESOURCES`)是**手写的**,条目都带「谁在哪一行读它」的证据;递归 import
21
+ * 扫描器看不到这些「按路径拼出来的读」,所以只能人工登记 —— 但每条都写清理由,不写「大概
22
+ * 需要」。注意 `backend/schema/fixtures/*` **不在**闭包里:它只被 `backend/test/db.test.mjs`、
23
+ * `tools/parity/db-run.mjs`、`tools/export_schema.py` 读(测试/量具),运行时没人读
24
+ * (实测 `grep -rn fixtures backend/lib backend/routes` 无命中)—— 别凭印象往里塞。
25
+ *
26
+ * 白名单展开(`expandFilesEntries`)按 npm 的 `files` 语义实现最小规则,行为经实测校准
27
+ * (`npm pack --dry-run --json`,见下),不是照文档猜的:
28
+ * - 条目相对包根;`*` 不跨 `/`(实测 `files:["*.py","*.md"]` 只收根级的 a.py/top.md,
29
+ * 子目录 `sub/b.py` 不收 ⇒ **没有** matchBase 语义),`**` 跨目录;
30
+ * - 条目指到目录 ⇒ 递归含其内容(`backend/lib`、`webui/dist-plugin` 都是这个形态);
31
+ * - `package.json` / `README*` / `LICENSE*`(含 `LICENCE*`)恒含。
32
+ * 有意**不**建模 `.gitignore`:本仓 `.gitignore` 里有 `webui/dist-plugin/`,但它照进包
33
+ * (实测 3 个文件都在)—— 说明显式 `files` 条目压过忽略规则。副作用写在文件末尾「已知缺口」。
34
+ */
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+
39
+ /** 包根(本文件在 `<包根>/dsh-plugin/lib/` 下, 上溯三级) */
40
+ export const PKG_ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
41
+
42
+ /** 闭包的两个入口(相对包根,posix 分隔) */
43
+ export const ENTRIES = Object.freeze([
44
+ 'dsh-plugin/lib/index.js',
45
+ 'backend/server.js',
46
+ ]);
47
+
48
+ /**
49
+ * 运行时时**按路径读**的数据根(静态扫描看不到;每条都要有证据行号)。
50
+ * `files` 列出「精确文件」(测试夹具不算,别往里塞);`dirs` 列出「整个目录都要」——
51
+ * 目录内容在判据里**当场递归枚举**,所以往里新增数据文件也自动进入闭包。
52
+ */
53
+ export const RUNTIME_RESOURCES = Object.freeze({
54
+ files: Object.freeze([
55
+ { rel: 'backend/schema/schema.sql', why: 'backend/lib/db.js:59 initDb 启动即读(目标 DDL)' },
56
+ { rel: 'backend/schema/schema.json', why: 'backend/lib/db.js:60 收敛器按它补列/建索引' },
57
+ { rel: 'backend/schema/migrations.json', why: 'backend/lib/db.js:61 声明式迁移清单,启动即执行' },
58
+ ]),
59
+ dirs: Object.freeze([
60
+ { rel: 'builtin_prompts/free_style', why: 'backend/lib/prompts.js:237 _ASSET_DIR,运行时读流程文档与文件模板' },
61
+ { rel: 'extensions', why: 'backend/lib/builtin-assets.js:262 DEFAULT_EXT_DIR,资产清单扫描根(routes/assets.js 的端点)' },
62
+ { rel: 'webui/dist-plugin', why: 'server 静态服务目录(薄壳启动参数 --web-dir 传的就是它,dsh-plugin/lib/index.js)' },
63
+ ]),
64
+ });
65
+
66
+ /** 目录递归枚举时跳过的东西(不进包,也不需要进闭包) */
67
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.run', '__pycache__']);
68
+
69
+ /** 相对说明符扫描(见文件头:锚在行首,避开注释里的散文;多行 import 靠 `[^'"]` 跨行) */
70
+ const IMPORT_FROM_RE = /^[ \t]*import\s+[^'"]*?\bfrom\s*['"]([^'"]+)['"]/gm;
71
+ const IMPORT_SIDE_EFFECT_RE = /^[ \t]*import\s*['"]([^'"]+)['"]/gm;
72
+ const EXPORT_FROM_RE = /^[ \t]*export\s+(?:\*|\{[^}]*\})\s*from\s*['"]([^'"]+)['"]/gm;
73
+ const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
74
+
75
+ /** posix 化(判据字符串一律用 `/`,Windows 上跑出来的也必须一样) */
76
+ export function toPosix(rel) {
77
+ return rel.split(path.sep).join('/');
78
+ }
79
+
80
+ /** 目录清单缓存:同一进程内按 root 复用(判据是只读的静态检查,进程内不假设文件树会变) */
81
+ const LIST_CACHE = new Map();
82
+
83
+ /** 递归列出 root 下所有**文件**(相对包根,posix 分隔,排序);结果按 root 缓存 */
84
+ export function listFiles(root = PKG_ROOT) {
85
+ const cached = LIST_CACHE.get(root);
86
+ if (cached) return cached;
87
+ const out = [];
88
+ const walk = (dir) => {
89
+ let entries;
90
+ try {
91
+ entries = fs.readdirSync(dir, { withFileTypes: true });
92
+ } catch {
93
+ return; // 读不动就当不存在(判据自己会报缺)
94
+ }
95
+ for (const entry of entries) {
96
+ const abs = path.join(dir, entry.name);
97
+ if (entry.isDirectory()) {
98
+ if (SKIP_DIRS.has(entry.name)) continue;
99
+ walk(abs);
100
+ } else if (entry.isFile()) {
101
+ out.push(toPosix(path.relative(root, abs)));
102
+ }
103
+ }
104
+ };
105
+ walk(root);
106
+ out.sort();
107
+ LIST_CACHE.set(root, out);
108
+ return out;
109
+ }
110
+
111
+ /** 抽一个源文件里的相对说明符(去重、保持出现顺序) */
112
+ export function scanRelativeSpecifiers(source) {
113
+ const found = [];
114
+ const add = (spec) => {
115
+ if (!spec) return;
116
+ if (!spec.startsWith('./') && !spec.startsWith('../')) return;
117
+ if (!found.includes(spec)) found.push(spec);
118
+ };
119
+ for (const re of [IMPORT_FROM_RE, IMPORT_SIDE_EFFECT_RE, EXPORT_FROM_RE, DYNAMIC_IMPORT_RE]) {
120
+ re.lastIndex = 0;
121
+ for (const m of source.matchAll(re)) add(m[1]);
122
+ }
123
+ return found;
124
+ }
125
+
126
+ /**
127
+ * 解析相对说明符 → 包内文件(posix 相对路径);解析不到返回 null。
128
+ * 支持无扩展名(补 `.js`、`/index.js`),与 Node 的 ESM 解析口径近似(本仓无扩展名导入)。
129
+ */
130
+ export function resolveSpecifier(fromRel, spec, root = PKG_ROOT) {
131
+ const baseDir = path.posix.dirname(fromRel);
132
+ const joined = path.posix.normalize(path.posix.join(baseDir, spec));
133
+ const candidates = [joined, `${joined}.js`, `${joined}.mjs`, path.posix.join(joined, 'index.js')];
134
+ for (const rel of candidates) {
135
+ if (rel.startsWith('..')) continue; // 跑出包根:不是包内文件
136
+ const abs = path.join(root, rel);
137
+ try {
138
+ if (fs.statSync(abs).isFile()) return rel;
139
+ } catch { /* 不存在的候选,继续试下一个 */ }
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * 计算运行时闭包:两个入口的静态 import 闭包 + `RUNTIME_RESOURCES`。
146
+ * @returns {{files: string[], unresolved: Array<{from: string, spec: string}>,
147
+ * entries: string[], resources: string[]}}
148
+ * `files` 排序去重;`unresolved` 是相对说明符解析不到(或裸包名/`node:`)的清单,
149
+ * 只报不用(避免把裸包名当必需导致假红)。
150
+ */
151
+ export function computeClosure(root = PKG_ROOT) {
152
+ const files = new Set();
153
+ const unresolved = [];
154
+ const queue = [...ENTRIES];
155
+ const seen = new Set();
156
+ while (queue.length > 0) {
157
+ const rel = queue.shift();
158
+ if (seen.has(rel)) continue;
159
+ seen.add(rel);
160
+ const abs = path.join(root, rel);
161
+ let source;
162
+ try {
163
+ source = fs.readFileSync(abs, 'utf8');
164
+ } catch {
165
+ unresolved.push({ from: rel, spec: '(入口本身读不到)' });
166
+ continue;
167
+ }
168
+ files.add(rel);
169
+ for (const spec of scanRelativeSpecifiers(source)) {
170
+ const resolved = resolveSpecifier(rel, spec, root);
171
+ if (resolved === null) unresolved.push({ from: rel, spec });
172
+ else queue.push(resolved);
173
+ }
174
+ }
175
+ const resources = [];
176
+ for (const item of RUNTIME_RESOURCES.files) {
177
+ files.add(item.rel);
178
+ resources.push(item.rel);
179
+ }
180
+ for (const item of RUNTIME_RESOURCES.dirs) {
181
+ const abs = path.join(root, item.rel);
182
+ if (!fs.existsSync(abs)) { unresolved.push({ from: '(资源根)', spec: item.rel }); continue; }
183
+ for (const rel of listFiles(abs)) {
184
+ const full = `${item.rel}/${rel}`;
185
+ files.add(full);
186
+ resources.push(full);
187
+ }
188
+ }
189
+ return {
190
+ files: [...files].sort(),
191
+ unresolved,
192
+ entries: [...ENTRIES],
193
+ resources: resources.sort(),
194
+ };
195
+ }
196
+
197
+ /** glob → 正则(npm `files` 最小语义:`**` 跨 `/`,`*`/`?` 不跨 `/`) */
198
+ function globToRegExp(pattern) {
199
+ let re = '^';
200
+ for (let i = 0; i < pattern.length; i++) {
201
+ const ch = pattern[i];
202
+ if (ch === '*') {
203
+ if (pattern[i + 1] === '*') {
204
+ i++;
205
+ if (pattern[i + 1] === '/') { i++; re += '(?:.*/)?'; } // `**/` 也匹配零层
206
+ else re += '.*';
207
+ } else {
208
+ re += '[^/]*';
209
+ }
210
+ } else if (ch === '?') {
211
+ re += '[^/]';
212
+ } else if ('\\^$.|+()[]{}'.includes(ch)) {
213
+ re += `\\${ch}`;
214
+ } else {
215
+ re += ch;
216
+ }
217
+ }
218
+ return new RegExp(`${re}$`);
219
+ }
220
+
221
+ /**
222
+ * npm 恒含的文件(`files` 白名单管不着)—— **只在包根**,实测校准过:
223
+ * `files:["a.txt"]` 时 `sub/README.md` / `sub/deep/LICENSE` 都不进包,
224
+ * 而根级 `package.json` 恒在。别把这条写成「任意层级的 README/LICENSE」(会假绿:
225
+ * 例如 `tests/README.md` 会被误判成包内 —— 实测它不在 105 个文件里)。
226
+ */
227
+ function isAlwaysIncluded(rel) {
228
+ if (rel === 'package.json') return true;
229
+ if (rel.includes('/')) return false;
230
+ return /^(README|LICENSE|LICENCE)(\..*)?$/i.test(rel);
231
+ }
232
+
233
+ /**
234
+ * 展开 `files` 白名单 → 包内文件集合(最小 npm 语义,见文件头)。
235
+ * @param {string[]} entries package.json 的 files 数组
236
+ * @param {{root?: string, allFiles?: string[]}} [opts] 便于测试注入(负对照就在内存里换 entries)
237
+ * @returns {Set<string>} posix 相对路径集合
238
+ */
239
+ export function expandFilesEntries(entries, opts = {}) {
240
+ const root = opts.root || PKG_ROOT;
241
+ const all = opts.allFiles || listFiles(root);
242
+ const packed = new Set();
243
+ for (const rel of all) if (isAlwaysIncluded(rel)) packed.add(rel);
244
+ for (const raw of entries) {
245
+ const entry = path.posix.normalize(String(raw).replace(/^\.\//, '').replace(/\/+$/, ''));
246
+ if (!entry || entry.startsWith('..')) continue;
247
+ const abs = path.join(root, entry);
248
+ let stat = null;
249
+ try { stat = fs.statSync(abs); } catch { /* 不存在:当 glob 处理 */ }
250
+ if (stat && stat.isDirectory()) {
251
+ for (const rel of listFiles(abs)) packed.add(`${entry}/${rel}`);
252
+ continue;
253
+ }
254
+ if (stat && stat.isFile()) { packed.add(entry); continue; }
255
+ const re = globToRegExp(entry);
256
+ for (const rel of all) if (re.test(rel)) packed.add(rel);
257
+ }
258
+ return packed;
259
+ }
260
+
261
+ /**
262
+ * 判据本体:闭包里但包内没有的文件(空数组 = 过)。
263
+ * @param {string[]} closureFiles `computeClosure().files`
264
+ * @param {Iterable<string>} packedFiles `expandFilesEntries(...)`
265
+ */
266
+ export function findUnpacked(closureFiles, packedFiles) {
267
+ const packed = packedFiles instanceof Set ? packedFiles : new Set(packedFiles);
268
+ return closureFiles.filter((rel) => !packed.has(rel));
269
+ }
270
+
271
+ /**
272
+ * 读仓库 `package.json` 的 `files` 并跑完整判据(`prepack-check.mjs` 与单测共用)。
273
+ * @returns {{ok: boolean, missing: string[], closure: object, packedCount: number, files: string[]}}
274
+ */
275
+ export function checkPackClosure(root = PKG_ROOT) {
276
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
277
+ const entries = Array.isArray(pkg.files) ? pkg.files : [];
278
+ const closure = computeClosure(root);
279
+ const packed = expandFilesEntries(entries, { root });
280
+ const missing = findUnpacked(closure.files, packed);
281
+ return { ok: missing.length === 0, missing, closure, packedCount: packed.size, files: entries };
282
+ }
283
+
284
+ /**
285
+ * 已知缺口(写在这里,不装作没有):
286
+ * ① `.gitignore` / `.npmignore` 未建模 —— 若将来某个运行时文件恰好落在被忽略的目录里、
287
+ * 而白名单**没有**显式列出它,本判据会假绿(今天不会:闭包里的路径全部由显式条目覆盖)。
288
+ * ② npm 的 `files` 对**符号链接**、`.npmrc`、`bundledDependencies` 有额外规则,本判据不管。
289
+ * ③ 运行时**动态拼**出来的路径(例如按用户输入拼文件名)静态看不到,只能靠人工登记进
290
+ * `RUNTIME_RESOURCES`;登记漏了就是假绿。
291
+ */
@@ -1,13 +1,20 @@
1
1
  /**
2
2
  * 发包前自检 —— package.json 的 `prepack` 钩子(`npm pack` / `npm publish` 前自动执行)。
3
3
  *
4
- * 两道检查:
4
+ * 三道检查:
5
5
  * ① **必需文件在不在**:本包是自包含的(插件壳 + Python 平台 + 预构建 webui/dist-plugin +
6
6
  * 内置提示词/内置资产),少任何一块装到 profile 里都跑不起来;
7
7
  * ② **三处「包名」逐字一致**:包根 package.json 的 `name`、`dsh-plugin/cordis.patch.yml`
8
8
  * 里 insert 行的 `name`、`dsh-plugin/lib/client.bundle.js` 里 `__ModuleLoader__.load({id})`
9
9
  * 的 id —— dsh 宿主按注册行的包名给浏览器半预留模块槽,三者不一致时页面报
10
10
  * "loaded without registering"(2026-10-07 改名 @gavinc-cn/touchstone-dsh 时踩过)。
11
+ * ③ **闭包 ⊆ 白名单**(2026-10-15 新增):从两个入口(`backend/server.js`、
12
+ * `dsh-plugin/lib/index.js`)正推运行时闭包(静态 import 图 + 运行时按路径读的数据根),
13
+ * 与 `files` 展开结果比对。①的手写清单挡不住「新加了一个文件、忘了改白名单」——
14
+ * 实测 0.1.1 发出去的包里 `backend/routes/` 11 个文件全缺,解包后
15
+ * `node backend/server.js` 直接 ERR_MODULE_NOT_FOUND(`backend/server.js:61` import 它)。
16
+ * 算法在 `dsh-plugin/lib/pack-closure.mjs`(与 `tools/parity/test/pack-closure.test.mjs`
17
+ * **同一份**,不是一个抄一个);这里只读文件、**绝不递归调用 `npm pack`**(会被自己再触发)。
11
18
  *
12
19
  * 任一项不过就非 0 退出,让 `npm pack` / `npm publish` 直接中断:宁可发不出去,不可发出坏包。
13
20
  * 用法: node dsh-plugin/scripts/prepack-check.mjs
@@ -16,6 +23,8 @@ import { readFileSync, existsSync } from 'node:fs';
16
23
  import { dirname, join } from 'node:path';
17
24
  import { fileURLToPath } from 'node:url';
18
25
 
26
+ import { checkPackClosure } from '../lib/pack-closure.mjs';
27
+
19
28
  /** 包根(本脚本在 <包根>/dsh-plugin/scripts/ 下, 上溯三级) */
20
29
  const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
21
30
 
@@ -24,6 +33,15 @@ const REQUIRED = [
24
33
  'package.json',
25
34
  'server.py',
26
35
  'requirements.txt',
36
+ // Node 后端骨架(2026-10-11 M0):profile 里配 `backend: node` 时薄壳拉起的就是它,
37
+ // 缺了等于「开关一打开就起不来」——必须在发包自检里挡住。
38
+ 'backend/server.js',
39
+ 'backend/lib',
40
+ // 数据层共享契约(2026-10-11 M1):`backend/lib/db.js` 启动即读 schema.sql /
41
+ // schema.json / migrations.json,缺了就是「Node 后端起不来」——同样必须在发包自检里挡住。
42
+ 'backend/schema/schema.sql',
43
+ 'backend/schema/schema.json',
44
+ 'backend/schema/migrations.json',
27
45
  'builtin_prompts',
28
46
  'extensions/dsh-autocommit/asset.json',
29
47
  'webui/dist-plugin/index.html',
@@ -53,9 +71,19 @@ if (!bundle.includes(`id: ${JSON.stringify(pkg.name)}`)) {
53
71
  }
54
72
  if (pkg.private === true) failures.push('package.json 里 private: true, 发不出去(发布前删掉)');
55
73
 
74
+ // 闭包 ⊆ 白名单: 从入口正推运行时必需文件, 逐条要求在 files 展开结果里
75
+ // (算法见 dsh-plugin/lib/pack-closure.mjs;只读文件, 不跑 npm pack ⇒ 可以安全地待在 prepack 里)
76
+ const closure = checkPackClosure(PKG_ROOT);
77
+ if (!closure.ok) {
78
+ failures.push(`files 白名单漏了 ${closure.missing.length} 个运行时必需文件`
79
+ + `(新增运行时文件/目录后忘了改 package.json 的 files 就会这样):`);
80
+ for (const rel of closure.missing) failures.push(`files 缺: ${rel}`);
81
+ }
82
+
56
83
  if (failures.length > 0) {
57
84
  console.error(`prepack 自检未通过(${failures.length} 项):`);
58
85
  for (const line of failures) console.error(` - ${line}`);
59
86
  process.exit(1);
60
87
  }
61
- console.log(`prepack 自检通过: ${pkg.name}@${pkg.version}, ${REQUIRED.length} 个必需路径齐全, 包名三处一致`);
88
+ console.log(`prepack 自检通过: ${pkg.name}@${pkg.version}, ${REQUIRED.length} 个必需路径齐全, 包名三处一致, `
89
+ + `运行时闭包 ${closure.closure.files.length} 个文件全在白名单内`);
@@ -105,20 +105,103 @@ function toggleByKey(e) {
105
105
  /** 宿主页 keydown 监听体(capture 阶段; 函数引用稳定, 便于 dispose 时精确解绑)。 */
106
106
  function onHostKey(e) { toggleByKey(e); }
107
107
 
108
+ // ── 侧栏入口按钮的配色与几何(2026-10-09 修) ──────────────────────────────────
109
+ // 血案: 原先写 color:'var(--dsw-alias-text-l1, #e8e8e8)' —— dsh **没有** text-l1 这族
110
+ // token(真实族是 --dsw-alias-label-primary/secondary/tertiary/caption/dimmed), var() 取不到
111
+ // 就一直吃 fallback: 浅色档在 #f9fafb 的侧栏底上画 #e8e8e8 的字(实测对比度 1.17, 同排的
112
+ // 「插件」「设置」是 18.08), 只有深色档碰巧能看。教训: 引用宿主 token 一律先核实名字。
113
+ // 现行口径(明暗两档都跟宿主走):
114
+ // · 颜色**只取 dsh 语义 token 且不写死 fallback** —— token 缺席时该声明失效并回落到继承
115
+ // (侧栏根 .hHd-Xa_root 已设 label-primary), 而不是钉死一个只对某一档成立的灰;
116
+ // · 尺寸/圆角/gap/行高逐条对齐 dsh 自己的 .SidebarRoot_panelRow(min-height 36 / 内边距
117
+ // 7px 8px / margin 0 2px / gap 8 / line-height 22 / --dsw-radius-md), 收起态对齐
118
+ // .hHd-Xa_collapsed .panelRow(36x36 居中);
119
+ // · 图标盒 16x16 —— 与 dsh 侧栏 SVG 图标同尺寸; 文字继承侧栏根的 14px。
120
+ const ENTRY_TITLE = 'Touchstone 测试平台(Alt+T 开关)';
121
+ const ENTRY_GLYPH_STYLE = {
122
+ flex: '0 0 16px', width: 16, height: 16, display: 'inline-flex',
123
+ alignItems: 'center', justifyContent: 'center', fontSize: 16, lineHeight: '16px',
124
+ };
125
+ const ENTRY_LABEL_STYLE = {
126
+ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
127
+ };
128
+
129
+ // 交互态样式(悬停/开合/键盘聚焦环): 伪类没法写在 inline style 里, 落一张最小样式表。
130
+ // 取值逐条对齐 dsh 侧栏行(.hHd-Xa_panelRow:hover 与 :focus-visible)。
131
+ const ENTRY_CSS = [
132
+ '.ts-sb-entry{background:transparent}',
133
+ '.ts-sb-entry:hover,.ts-sb-entry.ts-sb-on{background:'
134
+ + 'var(--dsw-alias-interactive-bg-hover,color-mix(in srgb,currentColor 8%,transparent))}',
135
+ '.ts-sb-entry:focus-visible{outline-width:var(--dsw-focus-ring-width,2px);outline-style:solid;'
136
+ + 'outline-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));'
137
+ + 'outline-offset:-2px}',
138
+ ].join('');
139
+ const ENTRY_CSS_ID = 'touchstone/sidebar-entry.css';
140
+
141
+ /** 幂等挂上入口行交互态样式表(形态与 dsh 插件自身的注入方式一致)。
142
+ * **失败一律吞掉**: 挂不上只是少了悬停/聚焦环的观感, 绝不能让侧栏入口或面板受影响
143
+ * (桩 DOM 自检环境里 document 面本就不全, 真机异常同理)。
144
+ * @returns 摘除函数(插件卸载时调用; 已存在/挂不上时返回空操作)。 */
145
+ function mountEntryStyle() {
146
+ try {
147
+ if (typeof document === 'undefined' || !document.head
148
+ || typeof document.createElement !== 'function'
149
+ || typeof document.querySelector !== 'function') {
150
+ return () => {};
151
+ }
152
+ if (document.querySelector('style[data-plugin-css=' + JSON.stringify(ENTRY_CSS_ID) + ']')) {
153
+ return () => {};
154
+ }
155
+ const tag = document.createElement('style');
156
+ tag.dataset.plugin = 'touchstone';
157
+ tag.dataset.pluginCss = ENTRY_CSS_ID;
158
+ tag.textContent = ENTRY_CSS;
159
+ document.head.appendChild(tag);
160
+ return () => tag.remove();
161
+ } catch (error) {
162
+ console.warn('[touchstone] 侧栏入口样式注入失败(只影响悬停/聚焦观感):', error);
163
+ return () => {};
164
+ }
165
+ }
166
+
108
167
  // 侧栏入口按钮(footer.action 槽; wide=侧栏展开态显示文字)
109
168
  function Toggle(props) {
110
- const wide = props && props.wide;
169
+ const wide = !!(props && props.wide);
111
170
  const isOpen = useOpen();
171
+ const style = {
172
+ boxSizing: 'border-box', display: 'flex', alignItems: 'center',
173
+ border: 'none', cursor: 'pointer', textAlign: 'left',
174
+ color: 'var(--dsw-alias-label-primary)', // 无 fallback: 取不到即回落继承(见上注)
175
+ borderRadius: 'var(--dsw-radius-md, 8px)',
176
+ fontSize: 'inherit', // 侧栏根是 14px, 不再自带 13px
177
+ };
178
+ if (wide) {
179
+ style.gap = 8;
180
+ style.margin = '0 2px';
181
+ style.padding = '7px 8px';
182
+ style.minHeight = 36;
183
+ style.lineHeight = '22px';
184
+ style.flex = '1 1 auto';
185
+ style.minWidth = 0;
186
+ } else {
187
+ style.width = 36;
188
+ style.height = 36;
189
+ style.margin = 0;
190
+ style.padding = 0;
191
+ style.flex = 'none';
192
+ style.justifyContent = 'center';
193
+ }
112
194
  return h('button', {
113
- style: {
114
- display: 'flex', alignItems: 'center', gap: 6, width: '100%', boxSizing: 'border-box',
115
- padding: '6px 12px', margin: '2px 0', borderRadius: 8, cursor: 'pointer', border: 'none',
116
- color: 'var(--dsw-alias-text-l1, #e8e8e8)', fontSize: 13, textAlign: 'left',
117
- background: 'transparent',
118
- },
119
- title: 'Touchstone 测试平台(Alt+T 开关)',
195
+ type: 'button',
196
+ className: 'ts-sb-entry' + (isOpen ? ' ts-sb-on' : ''),
197
+ style,
198
+ title: ENTRY_TITLE,
199
+ 'aria-label': ENTRY_TITLE,
200
+ 'aria-expanded': isOpen,
120
201
  onClick: () => setOpen(!isOpen),
121
- }, wide ? (isOpen ? '🧪 关闭 Touchstone' : '🧪 Touchstone') : '🧪');
202
+ },
203
+ h('span', { style: ENTRY_GLYPH_STYLE, 'aria-hidden': 'true' }, '🧪'),
204
+ wide ? h('span', { style: ENTRY_LABEL_STYLE }, isOpen ? '关闭 Touchstone' : 'Touchstone') : null);
122
205
  }
123
206
 
124
207
  // 关闭按钮自动隐藏(2026-10-03): 常显会盖住被嵌 SPA 侧栏底部的用户名, 所以平时完全隐藏,
@@ -330,6 +413,8 @@ function Panel() {
330
413
 
331
414
  // cordis 客户端插件入口: 等 slots 服务就位后注册两个 UI 槽, 再挂快捷键
332
415
  function apply(ctx) {
416
+ // 侧栏入口的交互态样式表(悬停/开合/聚焦环), 与本插件同生命周期
417
+ ctx.effect(() => mountEntryStyle(), 'touchstone: 侧栏入口样式');
333
418
  const slots = ctx.get('slots');
334
419
  if (!slots) return;
335
420
  slots.inject('sidebar.footer.action', () => slots.register(
package/dshdriver.py CHANGED
@@ -143,9 +143,30 @@ def available(timeout=HEALTH_TIMEOUT):
143
143
  return False
144
144
 
145
145
 
146
+ def live_snapshot(timeout=10):
147
+ """`/live` 的**原始响应**:`{sessions: [...], complete?: bool}`。
148
+
149
+ `complete`(A 批 2026-10-08 起真插件会上报)=插件是否已枚举宿主已有会话:
150
+ 只有它为 true,**空表**才具备「宿主里确实没有会话」的语义(见 dshevents._align)。
151
+ 旧装机副本没有该字段 ⇒ `complete` 缺省按未声明处理(空表=未知)。
152
+
153
+ 为什么单列一个读口(2026-10-08 修正):`live()` 返回的是**行表**(历史调用点
154
+ server.py 直接迭代),而中枢对齐需要「行表 + 完整性声明」两件东西。旧写法拿
155
+ `live()` 的返回值当 dict 取 `.get("sessions")` ⇒ `/live` 非空时必抛
156
+ AttributeError,被 `except` 吞掉后中枢**永久「未对齐」**(`aligned()` 恒 False ⇒
157
+ 看板 recover 不搬列、调和器缺席归位不触发)。契约由
158
+ `tests/test_session_visibility.py::test_live_readers_contract` 钉死。
159
+ """
160
+ return _request("GET", "/live", timeout=timeout) or {}
161
+
162
+
146
163
  def live():
147
- """当前可见会话表(含外部直跑会话):替代逐会话 REST 探测的实时态来源。"""
148
- return _request("GET", "/live", timeout=10).get("sessions", [])
164
+ """当前可见会话表(含外部直跑会话):替代逐会话 REST 探测的实时态来源。
165
+
166
+ 返回**行表 list**(历史契约:server.py 的外部会话汇总直接迭代);需要「完整性
167
+ 声明」的调用方(dshevents._align)用 `live_snapshot()`。
168
+ """
169
+ return list((live_snapshot() or {}).get("sessions") or [])
149
170
 
150
171
 
151
172
  def status(session_id):
@@ -245,9 +266,17 @@ def fork(session_id, at_seq=None):
245
266
  return _request("POST", "/fork", payload, timeout=60)
246
267
 
247
268
 
248
- def rename(session_id, title):
249
- """改会话标题(与 dsh 侧栏同一份标题)。返回 `{title}`。"""
250
- return _request("POST", "/rename", {"session_id": session_id, "title": title})
269
+ def rename(session_id, title, timeout=REQUEST_TIMEOUT):
270
+ """改会话标题(与 dsh 侧栏同一份标题)。返回 `{title, [external]}`。
271
+
272
+ `title` 是**宿主接受值**(可能被 `normalizeSessionTitle` 规范化/按 UTF-8 字节预算
273
+ 截断),调用方据此回写卡面可保证两侧逐字一致(见 board.rename_card_session)。
274
+
275
+ `timeout` 可缩短:卡面改名是 best-effort 的同步链路(`PATCH /board/cards/<cid>`),
276
+ 不能让驱动侧僵死把 HTTP worker 拖满默认 120s。
277
+ """
278
+ return _request("POST", "/rename", {"session_id": session_id, "title": title},
279
+ timeout=timeout)
251
280
 
252
281
 
253
282
  def set_model(session_id, model, provider="", reasoning_effort=""):
@@ -355,6 +384,46 @@ def unarchive(session_id, timeout=30):
355
384
  return archive(session_id, archived=False, timeout=timeout)
356
385
 
357
386
 
387
+ # ---------- 看管声明(外部会话投递通道,C 批 T5 2026-10-10) ----------
388
+
389
+ # 看管声明超时(秒):`/watch` 是驱动侧**纯内存写**,正常毫秒级返回;而投递前置闸
390
+ # (`chat._external_preflight`)是在 HTTP 请求线程上同步调用它的——不能沿用普通请求
391
+ # 的 120s,否则驱动僵死会把用户的一次「发送」卡住两分钟。2s 与「声明不成功即拒投」
392
+ # 的语义相称(设计 §5.1)。
393
+ WATCH_TIMEOUT = 2
394
+
395
+ # 旧插件没有 `/watch` 端点时的状态码(未知驱动端点,见插件 _serve 的兜底 404)。
396
+ # 平台据此把「外部会话投递」降级为拒投并给「插件过旧」文案,池内一切功能不受影响。
397
+ WATCH_UNSUPPORTED = 404
398
+
399
+
400
+ def watch_session(session_id, on=True):
401
+ """声明 / 撤销「平台看管该会话」(驱动 `POST /watch`,幂等)。返回看管是否生效。
402
+
403
+ 为什么需要(C 批设计 §3.1):外部会话(用户在 dsh GUI 里直跑/接管、`/live` 里
404
+ `owned:false`)既不在驱动池、也没有平台持有者,驱动侧因此不可能靠「在不在池内」
405
+ 推断平台是否有意投递——**范围必须由平台显式划定**(宿主里与 Touchstone 无关的
406
+ 会话多得是,无差别放开等于允许平台向任意会话注入消息与答案)。平台只对**平台确实
407
+ 要投递的**会话声明看管:有看板卡的会话由 `board._ensure_watch` 常态声明;任务侧
408
+ 会话在宿主上报为外部态(`owned:false`)时,由消息前置闸 `chat._external_preflight`
409
+ 与作答/审批送达前的 `board._watch_before_delivery` 补声明。驱动据此放行 `/prompt`·
410
+ `/steer`·`/answer`·`/approval` 的池外分支。
411
+
412
+ 幂等:重复声明同一 sid 不报错,驱动回执给的是 `watched` 的**当前实际状态**
413
+ (而非「这次有没有改动」),故本函数返回 `bool(resp["watched"])`——调用方按
414
+ 「True 才算声明成功」处置。`on=False` 撤销(卡删除/归档后的回收,见
415
+ `board._watch_prune`)。
416
+
417
+ 失败语义:**异常照抛**(`DshDriverError`:旧插件 404 / 驱动不可达 / 令牌错),
418
+ 「是否吞异常降级」由调用方决定——`board._ensure_watch` 选择留痕 + 返回 False
419
+ 且不重试(看管失败 ⇒ 投递前置闸拒投,见 `chat._external_preflight`)。
420
+ """
421
+ resp = _request("POST", "/watch",
422
+ {"session_id": session_id, "on": bool(on)},
423
+ timeout=WATCH_TIMEOUT)
424
+ return bool((resp or {}).get("watched"))
425
+
426
+
358
427
  def apply_session_defaults(session_id, model="", provider="",
359
428
  reasoning_effort="", permission_mode="", log=None):
360
429
  """把项目级默认(思考等级 / 权限档)应用到刚建或刚续的会话上(best-effort)。