lantern-harness 0.1.1 → 0.1.2

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 (27) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +4 -4
  3. package/builtin/session/lh-spaces.mjs +3 -0
  4. package/cli/acp-server.mjs +15 -1
  5. package/cli/lantern.mjs +25 -9
  6. package/cli/spaces.mjs +187 -33
  7. package/dist/500.html +1 -1
  8. package/dist/_astro/{SettingsDialog.DD-XOFEX.js → SettingsDialog.DC0e7580.js} +1 -1
  9. package/dist/cases/generate/index.html +1 -1
  10. package/dist/cases/library/index.html +1 -1
  11. package/dist/docs/changelog/index.html +13 -2
  12. package/dist/docs/install/index.html +5 -5
  13. package/dist/docs/manual/index.html +1 -1
  14. package/dist/index.html +1 -1
  15. package/dist/knowledge/index.html +1 -1
  16. package/dist/knowledge//345/212/237/350/203/275/346/265/213/350/257/225/347/224/250/344/276/213/347/274/226/345/206/231/350/247/204/350/214/203/index.html +1 -1
  17. package/dist/plugins/index.html +1 -1
  18. package/dist/prompts/index.html +1 -1
  19. package/dist/requirements/index.html +1 -1
  20. package/dist/templates/index.html +1 -1
  21. package/dist/templates//345/212/237/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212//345/212/237/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212/346/250/241/346/235/277/index.html +1 -1
  22. package/dist/templates//346/200/247/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212//346/200/247/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212/346/250/241/346/235/277/index.html +1 -1
  23. package/dist/templates//346/200/247/350/203/275/346/265/213/350/257/225/347/224/250/344/276/213//346/200/247/350/203/275/346/265/213/350/257/225/347/224/250/344/276/213/346/250/241/346/235/277/index.html +1 -1
  24. package/dist/templates//346/216/245/345/217/243/346/265/213/350/257/225/346/212/245/345/221/212//346/216/245/345/217/243/346/265/213/350/257/225/346/212/245/345/221/212/346/250/241/346/235/277/index.html +1 -1
  25. package/dist/templates//350/207/252/345/212/250/345/214/226/346/265/213/350/257/225/347/224/250/344/276/213//350/207/252/345/212/250/345/214/226/346/265/213/350/257/225/347/224/250/344/276/213/346/250/241/346/235/277/index.html +1 -1
  26. package/docs//345/256/211/350/243/205/346/211/213/345/206/214.md +4 -4
  27. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # 版本日志
2
2
 
3
+ ## [0.1.2] - 2026-10-18
4
+
5
+ ### 变更
6
+
7
+ - 升级命令由 `lantern install [版本]` 改为 `lantern update` / `lantern update <版本>`,帮助与文档同步;`lantern install` 保留为兼容别名
8
+ - ACP `initialize` 的版本号改为读 `package.json`,不再随发版手改
9
+
10
+ ### 修复
11
+
12
+ - 重新 `lantern init` 换了数据目录后,空间列表与「当前空间」仍指向上一个数据目录里的空间:注册表改为按池根分区(每条空间记 `root`,激活项记 `activeByRoot`),列表只看当前数据目录;旧版全局 `activeId` 与无 `root` 的条目在读取时按路径推断归属,向后兼容
13
+ - `lantern web` / `lantern status` 把生效的空间目录标成「当前空间」,不再和数据目录混为一谈
14
+
3
15
  ## [0.1.1] - 2026-10-18
4
16
 
5
17
  ### 变更
@@ -151,5 +163,6 @@ Lantern Harness 首个版本:AI 工作台。一份进程里隔离多套项目
151
163
  - 开发服忽略打开内容库目录与空间元数据目录导致的整页热更新
152
164
  - 文档与 CLI 统一 pnpm 优先:安装 / 卸载 / 版本列表优先调用 pnpm(未安装则回退 npm);README、安装手册、操作手册与贡献约定的开发命令改为 pnpm
153
165
 
166
+ [0.1.2]: https://www.npmjs.com/package/lantern-harness/v/0.1.2
154
167
  [0.1.1]: https://www.npmjs.com/package/lantern-harness/v/0.1.1
155
168
  [0.1.0]: https://www.npmjs.com/package/lantern-harness/v/0.1.0
package/README.md CHANGED
@@ -28,7 +28,7 @@ AI 工作台:对话与插件工具、需求 / 用例 / 知识 / 提示词内
28
28
  npx lantern-harness
29
29
  ```
30
30
 
31
- 执行后会安装全局短命令 `lantern`,之后用 `lantern init` / `lantern web` 操作即可。升级用 `lantern install`。
31
+ 执行后会安装全局短命令 `lantern`,之后用 `lantern init` / `lantern web` 操作即可。升级用 `lantern update`。
32
32
 
33
33
  2、 初始化数据空间(首次必做;可再次执行以更换数据空间):
34
34
 
@@ -61,9 +61,9 @@ lantern logs # 查看服务日志
61
61
  lantern doctor # 检查运行环境与配置是否正常
62
62
  lantern help # 显示操作命令列表
63
63
  lantern version # 显示当前版本号
64
- lantern list # 显示可安装的版本列表
65
- lantern install # 安装/更新到最新版本
66
- lantern install 0.1.x # 安装/更新到指定版本
64
+ lantern list # 显示可用版本列表
65
+ lantern update # 更新到最新版本
66
+ lantern update 0.1.x # 更新到指定版本
67
67
  lantern uninstall # 卸载本机 lantern 命令(保留数据空间)
68
68
  lantern wipe # 删除当前数据空间(需确认;不可恢复)
69
69
  lantern space list # 列出全部数据空间与当前项
@@ -37,6 +37,7 @@ import {
37
37
  resolveSpace,
38
38
  sanitizeSpaceName,
39
39
  setActiveSpace,
40
+ setRegistryRoot,
40
41
  spaceSize,
41
42
  } from '../../cli/spaces.mjs';
42
43
  import { loadRunState, saveRunState } from '../../cli/user-config.mjs';
@@ -212,6 +213,8 @@ export function lhSpacesPlugin() {
212
213
  configureServer(server) {
213
214
  // 首次进入确定基准根;已切换过的空间不会被覆盖
214
215
  ensureSpaceContext(server.config.root);
216
+ // 注册表按池根分区:先告诉它当前是哪个数据目录,才能只认自己的空间与激活项
217
+ setRegistryRoot(getBaseRoot());
215
218
  // 新进程里 activeRoot 默认是公共空间:按注册表回到用户上次选的空间
216
219
  syncActiveRoot();
217
220
 
@@ -13,10 +13,24 @@
13
13
  * - session/update { sessionId, toolCall: { name, status, args?, result? } }
14
14
  */
15
15
  import readline from 'node:readline';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
16
20
  import { headlessAsk, loadHeadlessSettings } from './headless.mjs';
17
21
 
18
22
  const PROTOCOL_VERSION = 1;
19
23
 
24
+ /** 与 package.json 保持同源,避免发版时漏改 */
25
+ const SERVER_VERSION = (() => {
26
+ try {
27
+ const pkg = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
28
+ return JSON.parse(fs.readFileSync(pkg, 'utf8')).version || '0.0.0';
29
+ } catch {
30
+ return '0.0.0';
31
+ }
32
+ })();
33
+
20
34
  function send(msg) {
21
35
  process.stdout.write(`${JSON.stringify(msg)}\n`);
22
36
  }
@@ -74,7 +88,7 @@ export async function runAcpServer(opts) {
74
88
  protocolVersion: PROTOCOL_VERSION,
75
89
  serverInfo: {
76
90
  name: 'lantern-harness-acp',
77
- version: '0.1.1',
91
+ version: SERVER_VERSION,
78
92
  },
79
93
  capabilities: {
80
94
  sessions: true,
package/cli/lantern.mjs CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  createSpace,
37
37
  discoverSpaces,
38
38
  exportSpaceToZip,
39
+ forgetRegistryRoot,
39
40
  importSpaceFromZip,
40
41
  listSpaces,
41
42
  PUBLIC_SPACE_LABEL,
@@ -43,6 +44,7 @@ import {
43
44
  renameSpace,
44
45
  resolveSpace,
45
46
  setActiveSpace,
47
+ setRegistryRoot,
46
48
  } from './spaces.mjs';
47
49
 
48
50
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -143,8 +145,9 @@ function printHelp() {
143
145
  lantern doctor 检查运行环境与配置
144
146
  lantern help 显示本帮助
145
147
  lantern version 显示当前版本号
146
- lantern list 显示可安装的版本列表
147
- lantern install [版本] 安装/更新(默认 latest)
148
+ lantern list 显示可用版本列表
149
+ lantern update 更新到最新版本
150
+ lantern update <版本> 更新到指定版本
148
151
  lantern uninstall 卸载本机 lantern(保留数据空间)
149
152
  lantern wipe 删除当前数据空间(需确认)
150
153
 
@@ -205,6 +208,7 @@ function requireDataRoot(dirOverride) {
205
208
  if (dirOverride) {
206
209
  const dataRoot = path.resolve(dirOverride);
207
210
  if (!fs.existsSync(dataRoot)) fs.mkdirSync(dataRoot, { recursive: true });
211
+ setRegistryRoot(dataRoot);
208
212
  return dataRoot;
209
213
  }
210
214
  const result = resolveConfiguredDataRoot();
@@ -212,6 +216,8 @@ function requireDataRoot(dirOverride) {
212
216
  printDataRootUnavailable(result);
213
217
  process.exit(1);
214
218
  }
219
+ // 注册表按池根分区:换过数据空间后,只认这个目录下的空间
220
+ setRegistryRoot(result.dataRoot);
215
221
  return result.dataRoot;
216
222
  }
217
223
 
@@ -239,6 +245,8 @@ async function cmdInit() {
239
245
  }
240
246
  prepareSpacesRoot(spacesRoot);
241
247
  saveUserConfig({ dataRoot: spacesRoot, host: '127.0.0.1', port: 4321 });
248
+ // 换数据目录后注册表要按新池根分区,别把上一个目录的空间与激活项带过来
249
+ setRegistryRoot(spacesRoot);
242
250
  console.log('');
243
251
  console.log('数据目录已关联:');
244
252
  console.log(` ${spacesRoot}`);
@@ -267,7 +275,7 @@ async function runForegroundServer(dataRoot) {
267
275
  console.log('');
268
276
  console.log('Lantern Harness 已启动');
269
277
  console.log(` 地址:${url}`);
270
- console.log(` 数据空间:${root}`);
278
+ console.log(` 当前空间:${root}`);
271
279
  console.log(' 按 Ctrl+C 停止');
272
280
  console.log('');
273
281
  }
@@ -278,7 +286,7 @@ function cmdStart({ foreground = false, dir = null } = {}) {
278
286
  if (alive) {
279
287
  console.log(`服务已在运行(pid ${alive.pid})`);
280
288
  console.log(` 地址:${alive.url}`);
281
- console.log(` 数据空间:${alive.dataRoot}`);
289
+ console.log(` 当前空间:${alive.dataRoot}`);
282
290
  return;
283
291
  }
284
292
 
@@ -311,7 +319,7 @@ function cmdStart({ foreground = false, dir = null } = {}) {
311
319
  console.log('');
312
320
  console.log('Lantern Harness 已在后台启动');
313
321
  console.log(` 地址:${state.url}`);
314
- console.log(` 数据空间:${state.dataRoot}`);
322
+ console.log(` 当前空间:${state.dataRoot}`);
315
323
  console.log(` 日志:${logPath}`);
316
324
  console.log(' 停止:lantern stop');
317
325
  console.log('');
@@ -388,6 +396,7 @@ function cmdStatus() {
388
396
  if (run) {
389
397
  console.log(`状态:运行中(pid ${run.pid})`);
390
398
  console.log(`地址:${run.url}`);
399
+ console.log(`当前空间:${run.dataRoot}`);
391
400
  } else {
392
401
  console.log('状态:未运行');
393
402
  }
@@ -510,10 +519,10 @@ function cmdList() {
510
519
  }
511
520
  }
512
521
 
513
- function cmdInstall(version) {
522
+ function cmdUpdate(version) {
514
523
  const pm = getPackageManager();
515
524
  const spec = version ? `${PKG_NAME}@${version}` : `${PKG_NAME}@latest`;
516
- console.log(`正在安装 ${spec} …`);
525
+ console.log(version ? `正在更新到 ${spec} …` : `正在更新到最新版本(${spec})…`);
517
526
  const r = spawnSync(pm, pmGlobalArgs(pm, 'install', spec), {
518
527
  stdio: 'inherit',
519
528
  shell: process.platform === 'win32',
@@ -526,8 +535,12 @@ function cmdInstall(version) {
526
535
  /** CLI 里的「基准根」:与网页端一致 —— 已记住的数据空间 */
527
536
  function spaceBaseRoot() {
528
537
  const result = resolveConfiguredDataRoot();
529
- if (result.ok) return result.dataRoot;
538
+ if (result.ok) {
539
+ setRegistryRoot(result.dataRoot);
540
+ return result.dataRoot;
541
+ }
530
542
  // 尚未 init 时,池根退化到当前目录,保证 space 命令本身可用
543
+ setRegistryRoot(process.cwd());
531
544
  return process.cwd();
532
545
  }
533
546
 
@@ -823,6 +836,7 @@ async function cmdWipe() {
823
836
  if (run) cmdStop();
824
837
 
825
838
  fs.rmSync(resolved, { recursive: true, force: true });
839
+ forgetRegistryRoot(resolved);
826
840
  clearDataRootFromConfig();
827
841
  console.log('数据空间已删除,配置中的关联已清除。');
828
842
  console.log('如需继续使用,请使用 lantern init 初始化或选择已有数据空间。');
@@ -933,8 +947,10 @@ async function main() {
933
947
  case 'list':
934
948
  cmdList();
935
949
  break;
950
+ case 'update':
951
+ // 旧文档 / 老脚本里的 install 仍可用,输出与 update 一致
936
952
  case 'install':
937
- cmdInstall(args[0]);
953
+ cmdUpdate(args[0]);
938
954
  break;
939
955
  case 'uninstall':
940
956
  cmdUninstall();
package/cli/spaces.mjs CHANGED
@@ -3,6 +3,11 @@
3
3
  *
4
4
  * 文件位置:`~/.config/lantern-harness/spaces.json`(与 CLI 的 config.json 同目录)
5
5
  *
6
+ * 一份文件里按「池根」分区:每个空间条目记着自己属于哪个数据目录(`root`),
7
+ * 激活项也按池根分别记录(`activeByRoot`)。`lantern init` 换了数据空间之后,
8
+ * 列表只看新池根下的空间,不会把上一个数据目录里的空间一并列出来。
9
+ * 旧版全局 `activeId` / 无 `root` 的条目在读取时按路径推断归属,向后兼容。
10
+ *
6
11
  * 「公共空间」是内建虚拟项,不入库:用户数据目录下直接是 `公共空间/`,源码仓库则在 `空间池/公共空间/`。
7
12
  * 新建空间会自动归进同一层(与公共空间同级)。界面路径隐去「空间池」。
8
13
  * 与自定义空间同构,因此可以整体拷贝 / 备份。
@@ -81,6 +86,46 @@ export function getSpacesPath() {
81
86
  return path.join(getUserConfigDir(), 'spaces.json');
82
87
  }
83
88
 
89
+ /** 当前池根:由 CLI / 服务端在知道数据目录后告知,供无 baseRoot 参数的调用(激活项)使用 */
90
+ let registryRoot = '';
91
+
92
+ /** @param {string} [root] */
93
+ export function setRegistryRoot(root) {
94
+ if (root) registryRoot = path.resolve(root);
95
+ return registryRoot;
96
+ }
97
+
98
+ /** 条目所属池根:优先读登记值,其次按路径推断(`<根>/空间池/<名称>` 或 `<根>/<名称>`) */
99
+ function inferEntryRoot(p) {
100
+ const abs = path.resolve(String(p || ''));
101
+ const marker = `${path.sep}${SPACE_POOL_DIR}${path.sep}`;
102
+ const idx = abs.lastIndexOf(marker);
103
+ return idx > 0 ? abs.slice(0, idx) : '';
104
+ }
105
+
106
+ function sameRoot(a, b) {
107
+ if (!a || !b) return false;
108
+ return path.resolve(a) === path.resolve(b);
109
+ }
110
+
111
+ /** abs 是否落在 dir 内(含 dir 本身) */
112
+ function isInsideDir(abs, dir) {
113
+ if (!abs || !dir) return false;
114
+ const rel = path.relative(path.resolve(dir), path.resolve(abs));
115
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
116
+ }
117
+
118
+ function rootKey(root) {
119
+ const abs = root || registryRoot;
120
+ return abs ? path.resolve(abs) : '';
121
+ }
122
+
123
+ function activeIdForRoot(reg, root) {
124
+ const key = rootKey(root);
125
+ const id = key ? reg.activeByRoot?.[key] : '';
126
+ return String(id || PUBLIC_SPACE_ID);
127
+ }
128
+
84
129
  /**
85
130
  * 新建空间的默认父目录:与公共空间同级(用户数据目录 = 选中的文件夹;源码仓库 = 空间池)。
86
131
  * @param {string} [baseRoot] 池根;缺省用 cwd
@@ -89,6 +134,13 @@ export function defaultManagedParent(baseRoot) {
89
134
  return poolPath(baseRoot || process.cwd());
90
135
  }
91
136
 
137
+ /** 登记条目时推导它属于哪个池根:优先用调用方给的池根,其次从父目录反推 */
138
+ function resolveEntryRoot(baseRoot, parent) {
139
+ return path.resolve(
140
+ baseRoot || inferEntryRoot(parent) || parent || registryRoot || process.cwd(),
141
+ );
142
+ }
143
+
92
144
  /**
93
145
  * 注册表里的绝对路径失效时(整个仓库/数据目录被改名或搬移)按清单身份在新池根下重定位。
94
146
  * 只认清单 id / 名称都对得上的目录,避免把同名但无关的文件夹当成原空间。
@@ -120,16 +172,36 @@ function rebaseMissingSpacePath(abs, root, pool, entry) {
120
172
 
121
173
  /**
122
174
  * 按当前池布局改写注册表路径:用户目录把 `空间池/X` 抬到 `X`;源码仓库把误摊的 `X` 收回 `空间池/X`。
175
+ *
176
+ * 只改写「属于本池根」的条目。其它池根的条目原样保留 —— 除非它登记的池根已经不存在
177
+ * (整个数据目录被改名 / 搬移),这时按清单身份把它接到当前池根下,继续可用。
123
178
  * @param {string} [baseRoot]
124
179
  */
125
180
  export function relocateSpaceRegistryPaths(baseRoot) {
126
- const root = path.resolve(baseRoot || process.cwd());
181
+ const root = path.resolve(baseRoot || registryRoot || process.cwd());
127
182
  const pool = poolPath(root);
128
183
  const nestedPrefix = path.join(root, SPACE_POOL_DIR);
129
- const reg = loadSpacesRegistry();
184
+ const reg = loadSpacesRegistry(root);
130
185
  let dirty = false;
131
186
  for (const s of reg.spaces) {
132
187
  const abs = path.resolve(s.path);
188
+ const own = s.root ? path.resolve(s.root) : root;
189
+ if (!sameRoot(own, root)) {
190
+ // 别的数据空间:池根还在就不要动,否则换空间时会串台
191
+ if (isDir(own)) continue;
192
+ // 池根整个被改名 / 搬走:按清单身份接到当前池根
193
+ let next = isDir(abs) ? null : rebaseMissingSpacePath(abs, root, pool, s);
194
+ if (!next && s.external) {
195
+ const byName = path.resolve(pool, s.name);
196
+ if (byName !== abs && isDir(byName)) next = byName;
197
+ }
198
+ if (next) {
199
+ s.path = next;
200
+ s.root = root;
201
+ dirty = true;
202
+ }
203
+ continue;
204
+ }
133
205
  let next = null;
134
206
  if (!isDir(abs)) {
135
207
  // 旧根被改名 / 搬走:先按清单身份找回来
@@ -176,34 +248,63 @@ function newSpaceId() {
176
248
 
177
249
  function emptyRegistry() {
178
250
  // managedParent 不再落进注册表:它由池根推导,存下来只会在换机器后变成死路径
179
- return { activeId: PUBLIC_SPACE_ID, spaces: [] };
251
+ return { activeId: PUBLIC_SPACE_ID, activeByRoot: {}, spaces: [] };
180
252
  }
181
253
 
182
254
  /**
183
255
  * 读取注册表;任何损坏都退回空注册表(宁可重置元数据,也不能让服务起不来)。
184
256
  *
185
257
  * 不再读取/推断 `managedParent`:空间位置一律由池根推导(见 `defaultManagedParent`)。
186
- * @returns {{ activeId: string, spaces: Array<{id:string,name:string,path:string,createdAt:number}> }}
258
+ * 条目缺 `root` 时按路径推断;推断不出来的(老版本的外部纳入)归到当前池根,保持旧行为。
259
+ * @param {string} [root] 当前池根;缺省用 `setRegistryRoot` 记下的值
260
+ * @returns {{ activeId: string, activeByRoot: Record<string, string>, spaces: Array<{id:string,name:string,path:string,createdAt:number,root:string}> }}
187
261
  */
188
- export function loadSpacesRegistry() {
262
+ export function loadSpacesRegistry(root) {
189
263
  const fp = getSpacesPath();
190
264
  if (!fs.existsSync(fp)) return emptyRegistry();
265
+ const cur = rootKey(root);
191
266
  try {
192
267
  const raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
193
268
  if (!raw || typeof raw !== 'object') return emptyRegistry();
194
269
  const spaces = Array.isArray(raw.spaces)
195
270
  ? raw.spaces
196
- .map((it) => ({
197
- id: String(it?.id || '').trim(),
198
- name: String(it?.name || '').trim(),
199
- path: path.resolve(String(it?.path || '')),
200
- createdAt: Number(it?.createdAt) || 0,
201
- // 用户手工确认纳入的文件夹:不写 space.json,保持目录原样
202
- ...(it?.external === true ? { external: true } : {}),
203
- }))
271
+ .map((it) => {
272
+ const abs = path.resolve(String(it?.path || ''));
273
+ // 归属推断:登记值 → 路径里的「空间池」段 → 当前池根内(老版本的外部纳入)
274
+ // 都不成立时留空:不属于任何池根,不会被任何数据目录列出来
275
+ const own =
276
+ String(it?.root || '').trim() ||
277
+ inferEntryRoot(abs) ||
278
+ (cur && isInsideDir(abs, cur) ? cur : '');
279
+ return {
280
+ id: String(it?.id || '').trim(),
281
+ name: String(it?.name || '').trim(),
282
+ path: abs,
283
+ createdAt: Number(it?.createdAt) || 0,
284
+ // 用户手工确认纳入的文件夹:不写 space.json,保持目录原样
285
+ ...(it?.external === true ? { external: true } : {}),
286
+ ...(own ? { root: path.resolve(own) } : {}),
287
+ };
288
+ })
204
289
  .filter((it) => it.id && it.name && it.path && it.path !== path.sep)
205
290
  : [];
206
- return { activeId: String(raw.activeId || PUBLIC_SPACE_ID), spaces };
291
+ const activeByRoot = {};
292
+ const rawActive = raw.activeByRoot;
293
+ if (rawActive && typeof rawActive === 'object' && !Array.isArray(rawActive)) {
294
+ for (const [k, v] of Object.entries(rawActive)) {
295
+ const key = String(k || '').trim();
296
+ const id = String(v || '').trim();
297
+ if (!key || !id) continue;
298
+ activeByRoot[path.resolve(key)] = id;
299
+ }
300
+ }
301
+ // 旧版只有一个全局 activeId:归到它所属空间的那个池根,别让它跨数据目录生效
302
+ const legacyActive = String(raw.activeId || PUBLIC_SPACE_ID);
303
+ if (legacyActive !== PUBLIC_SPACE_ID) {
304
+ const owner = spaces.find((s) => s.id === legacyActive && s.root);
305
+ if (owner?.root && !activeByRoot[owner.root]) activeByRoot[owner.root] = legacyActive;
306
+ }
307
+ return { activeId: legacyActive, activeByRoot, spaces };
207
308
  } catch {
208
309
  return emptyRegistry();
209
310
  }
@@ -212,9 +313,17 @@ export function loadSpacesRegistry() {
212
313
  export function saveSpacesRegistry(reg) {
213
314
  const dir = getUserConfigDir();
214
315
  fs.mkdirSync(dir, { recursive: true });
316
+ const spaces = Array.isArray(reg.spaces) ? reg.spaces : [];
317
+ const activeByRoot = { ...(reg.activeByRoot || {}) };
318
+ for (const [k, v] of Object.entries(activeByRoot)) {
319
+ if (!k || !v) delete activeByRoot[k];
320
+ }
321
+ // activeId 只作旧版本兼容:写当前池根的激活项,读代码以 activeByRoot 为准
322
+ const cur = rootKey();
215
323
  const next = {
216
- activeId: String(reg.activeId || PUBLIC_SPACE_ID),
217
- spaces: Array.isArray(reg.spaces) ? reg.spaces : [],
324
+ activeId: String((cur && activeByRoot[cur]) || reg.activeId || PUBLIC_SPACE_ID),
325
+ activeByRoot,
326
+ spaces,
218
327
  };
219
328
  fs.writeFileSync(getSpacesPath(), `${JSON.stringify(next, null, 2)}\n`, 'utf8');
220
329
  return next;
@@ -376,8 +485,9 @@ function hasKnownContentDir(abs) {
376
485
  * @param {{ managedParent?: string, baseRoot?: string, adopt?: string[] }} [opts]
377
486
  */
378
487
  export function discoverSpaces(opts = {}) {
379
- const reg = loadSpacesRegistry();
488
+ const reg = loadSpacesRegistry(opts.baseRoot);
380
489
  const parent = path.resolve(opts.managedParent || defaultManagedParent(opts.baseRoot));
490
+ const ownerRoot = resolveEntryRoot(opts.baseRoot, parent);
381
491
  const added = [];
382
492
  const backfilled = [];
383
493
  const skipped = [];
@@ -428,6 +538,7 @@ export function discoverSpaces(opts = {}) {
428
538
  name: clean.name,
429
539
  path: abs,
430
540
  createdAt: Date.now(),
541
+ root: ownerRoot,
431
542
  external: true,
432
543
  };
433
544
  reg.spaces.push(entry);
@@ -500,6 +611,7 @@ export function discoverSpaces(opts = {}) {
500
611
  name: clean.name,
501
612
  path: abs,
502
613
  createdAt: (read.ok ? read.manifest.createdAt : 0) || Date.now(),
614
+ root: ownerRoot,
503
615
  };
504
616
  reg.spaces.push(entry);
505
617
  usedIds.add(id);
@@ -524,10 +636,14 @@ export function discoverSpaces(opts = {}) {
524
636
  * @param {string} baseRoot 池根
525
637
  */
526
638
  export function listSpaces(baseRoot) {
527
- relocateSpaceRegistryPaths(baseRoot);
528
- const reg = loadSpacesRegistry();
639
+ const root = path.resolve(baseRoot);
640
+ setRegistryRoot(root);
641
+ relocateSpaceRegistryPaths(root);
642
+ const reg = loadSpacesRegistry(root);
643
+ // 只列本池根下的空间:换数据目录后不会串到上一个数据目录
644
+ const mine = reg.spaces.filter((s) => sameRoot(s.root, root));
529
645
  // 公共空间也是选中文件夹里的一个目录,与自定义空间同构
530
- const publicRoot = path.resolve(publicSpacePath(baseRoot));
646
+ const publicRoot = path.resolve(publicSpacePath(root));
531
647
  const all = [
532
648
  {
533
649
  id: PUBLIC_SPACE_ID,
@@ -536,16 +652,17 @@ export function listSpaces(baseRoot) {
536
652
  createdAt: 0,
537
653
  isPublic: true,
538
654
  },
539
- ...reg.spaces.map((s) => ({ ...s, isPublic: false })),
655
+ ...mine.map((s) => ({ ...s, isPublic: false })),
540
656
  ];
657
+ const activeId = activeIdForRoot(reg, root);
541
658
  return {
542
- baseRoot: path.resolve(baseRoot),
543
- managedParent: defaultManagedParent(baseRoot),
544
- activeId: reg.activeId,
659
+ baseRoot: root,
660
+ managedParent: defaultManagedParent(root),
661
+ activeId,
545
662
  spaces: all.map((s) => ({
546
663
  ...s,
547
664
  exists: isDir(s.path),
548
- isActive: s.id === reg.activeId,
665
+ isActive: s.id === activeId,
549
666
  })),
550
667
  };
551
668
  }
@@ -573,10 +690,11 @@ export function createSpace({ name, managedParent, baseRoot }) {
573
690
  const clean = sanitizeSpaceName(name);
574
691
  if (!clean.ok) return { ok: false, error: clean.error };
575
692
 
693
+ const parent = path.resolve(managedParent || defaultManagedParent(baseRoot));
694
+ setRegistryRoot(baseRoot || inferEntryRoot(parent));
576
695
  const reg = loadSpacesRegistry();
577
696
  if (nameTaken(reg, clean.name)) return { ok: false, error: `已存在同名空间「${clean.name}」` };
578
697
 
579
- const parent = path.resolve(managedParent || defaultManagedParent(baseRoot));
580
698
  const dir = path.join(parent, clean.name);
581
699
 
582
700
  // 目录可能已存在(例如用户手工建过):不能贸然复用,否则会把无关内容当成数据空间
@@ -588,7 +706,13 @@ export function createSpace({ name, managedParent, baseRoot }) {
588
706
  return { ok: false, error: `创建目录失败:${e instanceof Error ? e.message : String(e)}` };
589
707
  }
590
708
 
591
- const entry = { id: newSpaceId(), name: clean.name, path: dir, createdAt: Date.now() };
709
+ const entry = {
710
+ id: newSpaceId(),
711
+ name: clean.name,
712
+ path: dir,
713
+ createdAt: Date.now(),
714
+ root: resolveEntryRoot(baseRoot, parent),
715
+ };
592
716
  try {
593
717
  writeSpaceManifest(dir, entry);
594
718
  } catch (e) {
@@ -667,7 +791,13 @@ export function copySpace(id, nextName, baseRoot) {
667
791
  return { ok: false, error: `复制失败:${e instanceof Error ? e.message : String(e)}` };
668
792
  }
669
793
 
670
- const entry = { id: newSpaceId(), name: clean.name, path: dir, createdAt: Date.now() };
794
+ const entry = {
795
+ id: newSpaceId(),
796
+ name: clean.name,
797
+ path: dir,
798
+ createdAt: Date.now(),
799
+ root: resolveEntryRoot(baseRoot, parent),
800
+ };
671
801
  try {
672
802
  // 拷贝继承的是源空间的清单(含源 id),必须改写成新身份
673
803
  writeSpaceManifest(dir, entry);
@@ -691,6 +821,9 @@ export function removeSpace({ id, purge = false, baseRoot }) {
691
821
 
692
822
  const reg = loadSpacesRegistry();
693
823
  reg.spaces = reg.spaces.filter((s) => s.id !== space.id);
824
+ for (const [key, val] of Object.entries(reg.activeByRoot || {})) {
825
+ if (val === space.id) delete reg.activeByRoot[key];
826
+ }
694
827
  if (reg.activeId === space.id) reg.activeId = PUBLIC_SPACE_ID;
695
828
  saveSpacesRegistry(reg);
696
829
 
@@ -744,16 +877,35 @@ export function assertLooksLikeSpace(dir, managedParent) {
744
877
  return { ok: true };
745
878
  }
746
879
 
747
- /** 设置当前空间(持久化,重启后沿用) */
880
+ /** 设置当前空间(按池根分别持久化,重启后沿用) */
748
881
  export function setActiveSpace(id) {
749
882
  const reg = loadSpacesRegistry();
750
- reg.activeId = String(id || PUBLIC_SPACE_ID);
883
+ const wanted = String(id || PUBLIC_SPACE_ID);
884
+ const owner = reg.spaces.find((s) => s.id === wanted);
885
+ const key = rootKey() || owner?.root || '';
886
+ if (key) {
887
+ if (!reg.activeByRoot) reg.activeByRoot = {};
888
+ reg.activeByRoot[path.resolve(key)] = wanted;
889
+ }
890
+ reg.activeId = wanted;
751
891
  saveSpacesRegistry(reg);
752
- return reg.activeId;
892
+ return wanted;
753
893
  }
754
894
 
755
895
  export function getActiveSpaceId() {
756
- return loadSpacesRegistry().activeId;
896
+ const reg = loadSpacesRegistry();
897
+ const key = rootKey();
898
+ if (!key) return String(reg.activeId || PUBLIC_SPACE_ID);
899
+ return String(reg.activeByRoot?.[key] || PUBLIC_SPACE_ID);
900
+ }
901
+
902
+ /** 忘掉某个池根的全部登记(`lantern wipe` 删掉数据目录后调用,避免留下死路径) */
903
+ export function forgetRegistryRoot(root) {
904
+ const key = path.resolve(root);
905
+ const reg = loadSpacesRegistry(key);
906
+ reg.spaces = reg.spaces.filter((s) => !sameRoot(s.root, key));
907
+ if (reg.activeByRoot) delete reg.activeByRoot[key];
908
+ saveSpacesRegistry(reg);
757
909
  }
758
910
 
759
911
  // ── 文件系统工具 ──────────────────────────────────────────────
@@ -925,10 +1077,11 @@ export async function importSpaceFromZip({ zipFile, name, managedParent, baseRoo
925
1077
  const clean = sanitizeSpaceName(name ?? manifest?.name ?? fallbackName);
926
1078
  if (!clean.ok) return { ok: false, error: clean.error };
927
1079
 
1080
+ const parent = path.resolve(managedParent || defaultManagedParent(baseRoot));
1081
+ setRegistryRoot(baseRoot || inferEntryRoot(parent));
928
1082
  const reg = loadSpacesRegistry();
929
1083
  if (nameTaken(reg, clean.name)) return { ok: false, error: `已存在同名空间「${clean.name}」` };
930
1084
 
931
- const parent = path.resolve(managedParent || defaultManagedParent(baseRoot));
932
1085
  const dir = path.join(parent, clean.name);
933
1086
  if (fs.existsSync(dir)) return { ok: false, error: `目录已存在:${dir}` };
934
1087
 
@@ -952,6 +1105,7 @@ export async function importSpaceFromZip({ zipFile, name, managedParent, baseRoo
952
1105
  name: clean.name,
953
1106
  path: dir,
954
1107
  createdAt: Date.now(),
1108
+ root: resolveEntryRoot(baseRoot, parent),
955
1109
  };
956
1110
  writeSpaceManifest(dir, entry);
957
1111