dsh-managed-workspaces 1.0.0 → 1.1.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 CHANGED
@@ -18,24 +18,31 @@
18
18
 
19
19
  ## 安装
20
20
 
21
- 从 GitHub 安装(推荐):在 DSH 中让代理使用 `plugin_manager` 的 `install_bundle`:
21
+ ### 用 DSH 插件命令(推荐)
22
+
23
+ 已发布到 npm:<https://www.npmjs.com/package/dsh-managed-workspaces>
22
24
 
23
25
  ```
24
- plugin_manager({ action: "install_bundle", target: "https://github.com/CoraxEldridge/dsh-managed-workspaces" })
26
+ dsh plugin --profile desktop add dsh-managed-workspaces
25
27
  ```
26
28
 
27
- 也可以先把仓库克隆到本地,再用本地目录安装:
29
+ 把 `desktop` 换成你的 profile 名。该命令会在 profile 里安装包,并把新增的 bundle 自动加入 `dsh.profile.bundles`;重启 DSH 后生效。
30
+
31
+ ### 用 plugin_manager
28
32
 
29
33
  ```
30
- plugin_manager({ action: "install_bundle", target: "D:\\path\\to\\dsh-managed-workspaces" })
34
+ plugin_manager({ action: "install_bundle", target: "dsh-managed-workspaces" })
31
35
  ```
32
36
 
33
- 安装后侧边栏底部出现「托管工作区」入口;若结果显示 `restart-required`,重启 DSH 后生效。更新已安装版本:
37
+ 也可以从 GitHub 安装,或先用本地目录安装便于开发:
34
38
 
35
39
  ```
36
40
  plugin_manager({ action: "install_bundle", target: "https://github.com/CoraxEldridge/dsh-managed-workspaces" })
41
+ plugin_manager({ action: "install_bundle", target: "D:\\path\\to\\dsh-managed-workspaces" })
37
42
  ```
38
43
 
44
+ 安装后侧边栏底部出现「托管工作区」入口;若结果显示 `restart-required`,重启 DSH 后生效。更新已安装版本时重复对应的安装命令即可。
45
+
39
46
  ## 接入已有工作区
40
47
 
41
48
  侧边栏里已经存在的工作区可以「接入」插件,纳入同一套管理(这样就能给它们关联额外文件夹),而且**不移动、不改名、不新建任何文件**:
@@ -49,6 +56,21 @@ plugin_manager({ action: "install_bundle", target: "https://github.com/CoraxEldr
49
56
  - 「移出插件」对**接入**的工作区只删除插件记录,保留它在侧边栏的注册(因为那个工作区原本就属于你);对 **DSH 新建**的工作区则同时移除侧边栏注册。
50
57
  - 如果接入的工作区目录被外部删除,「修复」不会替你重建,只报告缺失。
51
58
 
59
+ ## 迁移工作区
60
+
61
+ 展开任一工作区,点「迁移…」,填入一个**尚不存在**的目标路径(或用「浏览…」选父目录后自动补上当前名称),确认即可。也可以用工具:`managed_workspace({ action: "migrate", destination: "D:\\新位置", workspace: "名称" })`。
62
+
63
+ 迁移会移动整个工作区目录(同一磁盘用重命名,跨磁盘用“复制 → 校验文件数/字节数 → 删除源”),并重建 `_roots` 下的关联链接。
64
+
65
+ **对话记录为什么不会丢**:DSH 的会话头里记录着创建时的工作目录,且该记录不可修改;侧边栏按这个路径把会话归到工作区。因此:
66
+
67
+ - 工作区**还有会话**时(自动 `anchor` 模式):目录被移走,但**原路径会保留为指向新位置的目录链接**,历史会话的 cwd 仍然有效,对话照常打开、继续、归组在同一个工作区下。
68
+ - 工作区**没有会话**时(自动 `clean` 模式):直接移动并在侧边栏把工作区重新注册到新路径,不留链接。
69
+
70
+ `anchor` 模式需要保留原位置的那一个链接;如果原磁盘将被移除,请先确认该路径可以长期保留(链接本身只占极小空间)。手动指定 `mode: "clean"` 时,若工作区仍有会话会被拒绝。
71
+
72
+ 迁移后界面会显示「实际位置」。若链接被误删,点「修复」即可重建。
73
+
52
74
  ## 使用
53
75
 
54
76
  1. 打开侧边栏底部的「托管工作区」,设置**工作区根目录**并保存。
@@ -80,6 +102,7 @@ plugin_manager({ action: "install_bundle", target: "https://github.com/CoraxEldr
80
102
  - **搜索不会自动穿越链接**:`glob`/`grep` 通过 ripgrep 执行且不带 `--follow`,所以从工作区根搜索一般不会进入 `_roots/<别名>`。请对每个关联文件夹显式指定路径,例如 `grep({ path: "_roots/web" })`。插件会在提示上下文中提醒模型。
81
103
  - **`workspace-write` 下无法写关联文件夹**:链接目标是工作区根之外的路径,文件沙箱会拒绝写入(Windows 上又无法用 `additionalWritableRoots` 表达,因为会让 pwsh 的 ACL 沙箱直接失败)。需要修改关联文件夹时把会话切到 `danger-full-access`。
82
104
  - **不支持 UNC 路径**:Windows junction 不能指向网络共享(会明确报错)。
105
+ - **会话不能跨工作区拖动或移动**:工作区归属由会话头中不可变的 `cwd` 推导(`attachSession` 要求它等于工作区路径),DSH 没有修改该记录的接口。侧边栏的拖拽只在同一工作区内排序;要整理对话位置,请用本插件的「迁移工作区」移动目录。
83
106
  - 工作区文件夹被外部删除时不会静默重建,需在界面点「修复」。
84
107
  - 若 `_roots/<别名>` 被真实文件或目录占用,插件只报告冲突,绝不覆盖或删除。
85
108
  - 卸载插件不会动磁盘上的任何工作区文件夹、链接或会话。
package/client.js CHANGED
@@ -42,6 +42,16 @@ window.__ModuleLoader__.load({
42
42
  'health.conflict': '冲突',
43
43
  register: '注册到侧边栏',
44
44
  repair: '修复',
45
+ migrate: '迁移…',
46
+ migrateCancel: '取消',
47
+ migrateStart: '开始迁移',
48
+ migrateTitle: '迁移到新位置',
49
+ migrateDestination: '目标路径(必须尚不存在)',
50
+ migrateHint: '会移动工作区目录;普通文件不受影响。',
51
+ migrateAnchorHint: '该工作区有 {count} 个会话。迁移后原路径会保留为指向新位置的目录链接,历史对话继续可用(会话记录里的工作目录不可修改)。',
52
+ migrateCleanHint: '该工作区没有会话,将直接迁移并在侧边栏更新为新路径。',
53
+ migrateConfirm: '现在迁移工作区「{name}」?\n\n{detail}\n\n目标:{destination}',
54
+ migratedTo: '实际位置',
45
55
  forget: '从插件移除',
46
56
  forgetConfirm: '从插件移除工作区「{name}」?\n只会删除侧边栏注册与插件记录,不会删除任何文件夹或会话。',
47
57
  forgetAdopted: '移出插件',
@@ -85,6 +95,16 @@ window.__ModuleLoader__.load({
85
95
  'health.conflict': 'Conflict',
86
96
  register: 'Register in sidebar',
87
97
  repair: 'Repair',
98
+ migrate: 'Migrate…',
99
+ migrateCancel: 'Cancel',
100
+ migrateStart: 'Migrate',
101
+ migrateTitle: 'Migrate to a new location',
102
+ migrateDestination: 'Destination path (must not exist yet)',
103
+ migrateHint: 'Moves the workspace directory; ordinary files are unaffected.',
104
+ migrateAnchorHint: 'This workspace has {count} session(s). After migrating, the original path stays as a directory link to the new location so the recorded conversations keep working (a session records its working directory immutably).',
105
+ migrateCleanHint: 'No session uses this workspace, so it is moved and re-registered at the new path directly.',
106
+ migrateConfirm: 'Migrate workspace "{name}" now?\n\n{detail}\n\nDestination: {destination}',
107
+ migratedTo: 'Content location',
88
108
  forget: 'Remove from plugin',
89
109
  forgetConfirm: 'Remove workspace "{name}" from the plugin?\nOnly the sidebar registration and the plugin record are removed; no folder or session is deleted.',
90
110
  forgetAdopted: 'Stop managing',
@@ -143,6 +163,7 @@ window.__ModuleLoader__.load({
143
163
  .dsh-mws-code{font-family:var(--dsw-font-family);font-size:12px;color:var(--dsw-alias-label-secondary);}
144
164
  .dsh-mws-arrow{color:var(--dsw-alias-label-tertiary);}
145
165
  .dsh-mws-addlink{display:flex;flex-direction:column;gap:6px;padding:8px;border:1px dashed var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm);}
166
+ .dsh-mws-migrate{display:flex;flex-direction:column;gap:8px;padding:10px;border:1px dashed var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);}
146
167
  .dsh-mws-candidate{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);}
147
168
  @media (max-width:520px){.dsh-mws-backdrop{padding:0;}.dsh-mws-dialog{max-height:100vh;height:100vh;border-radius:0;}}
148
169
  `
@@ -516,6 +537,7 @@ window.__ModuleLoader__.load({
516
537
  const ws = props.ws
517
538
  const busy = props.busy
518
539
  const [expanded, setExpanded] = React.useState(false)
540
+ const [migrating, setMigrating] = React.useState(false)
519
541
  const statusKey =
520
542
  ws.directory !== 'ok'
521
543
  ? 'status.missing'
@@ -551,10 +573,26 @@ window.__ModuleLoader__.load({
551
573
  h('span', { className: 'dsh-mws-chip ' + tone }, t(statusKey)),
552
574
  ),
553
575
  h('div', { className: 'dsh-mws-path', title: ws.path }, ws.path),
576
+ ws.relocatedTo !== null && ws.relocatedTo !== undefined
577
+ ? h('div', { className: 'dsh-mws-hint' }, t('migratedTo') + ': ' + ws.relocatedTo)
578
+ : null,
554
579
  ),
555
580
  h(
556
581
  'div',
557
582
  { className: 'dsh-mws-actions' },
583
+ h(
584
+ 'button',
585
+ {
586
+ type: 'button',
587
+ className: 'dsh-mws-btn',
588
+ disabled: busy !== null || ws.directory !== 'ok',
589
+ onClick: () => {
590
+ setExpanded(true)
591
+ setMigrating(true)
592
+ },
593
+ },
594
+ t('migrate'),
595
+ ),
558
596
  !ws.registered
559
597
  ? h(
560
598
  'button',
@@ -597,6 +635,7 @@ window.__ModuleLoader__.load({
597
635
  ? h(
598
636
  'div',
599
637
  { className: 'dsh-mws-card-body' },
638
+ migrating ? h(MigrateForm, { t, ws, busy, onClose: () => setMigrating(false) }) : null,
600
639
  h('div', { className: 'dsh-mws-label' }, t('linksLabel')),
601
640
  ws.links.length === 0
602
641
  ? h('div', { className: 'dsh-mws-hint' }, t('linksEmpty'))
@@ -608,6 +647,77 @@ window.__ModuleLoader__.load({
608
647
  )
609
648
  }
610
649
 
650
+ function MigrateForm(props) {
651
+ const t = props.t
652
+ const ws = props.ws
653
+ const busy = props.busy
654
+ const [destination, setDestination] = React.useState('')
655
+ const detail =
656
+ ws.sessionCount > 0
657
+ ? t('migrateAnchorHint').replace('{count}', String(ws.sessionCount))
658
+ : t('migrateCleanHint')
659
+ const submit = async () => {
660
+ const value = destination.trim()
661
+ if (value === '') return
662
+ const confirmed = window
663
+ .confirm(t('migrateConfirm').replace('{name}', ws.name).replace('{detail}', detail).replace('{destination}', value))
664
+ if (!confirmed) return
665
+ const ok = await mutate('migrate', { id: ws.id, destination: value, mode: 'auto' })
666
+ if (ok) {
667
+ setDestination('')
668
+ props.onClose()
669
+ }
670
+ }
671
+ const browse = async () => {
672
+ const picked = await pickFolder()
673
+ if (picked === null) return
674
+ const name = ws.path.split(/[\\/]/).filter(Boolean).pop() ?? 'workspace'
675
+ const separator = picked.includes('\\') ? '\\' : '/'
676
+ setDestination(picked.replace(/[\\/]+$/, '') + separator + name)
677
+ }
678
+ return h(
679
+ 'div',
680
+ { className: 'dsh-mws-migrate' },
681
+ h('div', { className: 'dsh-mws-label' }, t('migrateTitle')),
682
+ h(
683
+ 'div',
684
+ { className: 'dsh-mws-row' },
685
+ h('input', {
686
+ className: 'dsh-mws-input dsh-mws-grow',
687
+ value: destination,
688
+ placeholder: t('migrateDestination'),
689
+ spellCheck: false,
690
+ onChange: (event) => setDestination(event.target.value),
691
+ onKeyDown: (event) => {
692
+ if (event.key === 'Enter') submit()
693
+ },
694
+ }),
695
+ h('button', { type: 'button', className: 'dsh-mws-btn', onClick: browse }, t('browse')),
696
+ ),
697
+ h('div', { className: 'dsh-mws-hint' }, detail),
698
+ h('div', { className: 'dsh-mws-hint' }, t('migrateHint')),
699
+ h(
700
+ 'div',
701
+ { className: 'dsh-mws-row' },
702
+ h(
703
+ 'button',
704
+ { type: 'button', className: 'dsh-mws-btn', disabled: busy !== null, onClick: () => props.onClose() },
705
+ t('migrateCancel'),
706
+ ),
707
+ h(
708
+ 'button',
709
+ {
710
+ type: 'button',
711
+ className: 'dsh-mws-btn dsh-mws-primary',
712
+ disabled: busy !== null || destination.trim() === '',
713
+ onClick: submit,
714
+ },
715
+ t('migrateStart'),
716
+ ),
717
+ ),
718
+ )
719
+ }
720
+
611
721
  function LinkRow(props) {
612
722
  const t = props.t
613
723
  const ws = props.ws
package/context.js CHANGED
@@ -16,6 +16,9 @@ export function renderContext(workspace, linksDirectoryName) {
16
16
  `Managed workspace "${workspace.name}"`,
17
17
  `Root: "${workspace.path}"`,
18
18
  ]
19
+ if (typeof workspace.relocatedTo === 'string' && workspace.relocatedTo !== workspace.path) {
20
+ lines.push(`Content location: "${workspace.relocatedTo}" (Root is a link to it; use Root for every path).`)
21
+ }
19
22
  if (links.length === 0) {
20
23
  lines.push('Linked folders: none.')
21
24
  } else {
@@ -68,6 +71,9 @@ export function registerManagedWorkspaceContext(ctx, store) {
68
71
  {
69
72
  name: found.workspace.name,
70
73
  path: found.workspace.path,
74
+ ...(typeof found.workspace.relocatedTo === 'string'
75
+ ? { relocatedTo: found.workspace.relocatedTo }
76
+ : {}),
71
77
  links: found.workspace.links.map((link) => ({ alias: link.alias, target: link.target })),
72
78
  },
73
79
  linksDirectoryName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-managed-workspaces",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "DSH-owned workspaces created under a configurable base directory, with multiple linked folders per workspace.",
6
6
  "keywords": [
package/routes.js CHANGED
@@ -100,6 +100,7 @@ export function registerRoutes(ctx, store) {
100
100
  route('config', async (args) => store.setBaseDirectory(args.baseDirectory))
101
101
  route('create', async (args) => store.create(args.name))
102
102
  route('adopt', async (args) => (args.all === true ? store.adoptAll() : store.adopt(args.folder)))
103
+ route('migrate', async (args) => store.migrate(args.id, args.destination, { mode: args.mode }))
103
104
  route('attach', async (args) => store.attach(args.id, args.folder, args.alias))
104
105
  route('detach', async (args) => store.detach(args.id, args.alias))
105
106
  route('register', async (args) => store.register(args.id))
package/store.js CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { execFile } from 'node:child_process'
12
12
  import { randomUUID } from 'node:crypto'
13
- import { lstat, mkdir, readFile, readlink, realpath, rename, stat, symlink, unlink, writeFile } from 'node:fs/promises'
13
+ import { cp, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'
14
14
  import { homedir } from 'node:os'
15
15
  import { dirname, isAbsolute, join, basename, parse, resolve, sep } from 'node:path'
16
16
  import { promisify } from 'node:util'
@@ -161,6 +161,95 @@ async function createDirectoryLink(target, linkPath) {
161
161
  await symlink(target, linkPath, 'dir')
162
162
  }
163
163
 
164
+ /** Remove one directory link, refusing to touch a real directory or a link to another target. */
165
+ async function detachLinkNode(linkPath, expectedTarget) {
166
+ const node = await lstatOrNull(linkPath)
167
+ if (node === null) return 'missing'
168
+ if (!node.isSymbolicLink()) fail('conflict', `refusing to remove "${linkPath}": it is a real directory or file, not a link`)
169
+ const actual = await resolveLink(linkPath)
170
+ if (!samePath(actual, expectedTarget)) {
171
+ fail('conflict', `refusing to remove "${linkPath}": it points to ${actual}, not ${expectedTarget}`)
172
+ }
173
+ await unlink(linkPath)
174
+ return 'removed'
175
+ }
176
+
177
+ /** Ensure a directory link exists at `linkPath`, adopting an equivalent one and refusing conflicts. */
178
+ async function ensureLinkNode(linkPath, target) {
179
+ const node = await lstatOrNull(linkPath)
180
+ if (node === null) {
181
+ await createDirectoryLink(target, linkPath)
182
+ return
183
+ }
184
+ if (!node.isSymbolicLink()) fail('conflict', `path already exists and is not a link: ${linkPath}`)
185
+ const actual = await resolveLink(linkPath)
186
+ if (!samePath(actual, target)) fail('conflict', `link points to ${actual}, not ${target}`)
187
+ }
188
+
189
+ /** Count files, directories, links, and bytes below one directory for copy verification. */
190
+ async function treeSummary(root) {
191
+ let files = 0
192
+ let directories = 0
193
+ let symlinks = 0
194
+ let bytes = 0
195
+ const stack = [root]
196
+ while (stack.length > 0) {
197
+ const directory = stack.pop()
198
+ const entries = await readdir(directory, { withFileTypes: true })
199
+ for (const entry of entries) {
200
+ const full = join(directory, entry.name)
201
+ if (entry.isSymbolicLink()) {
202
+ symlinks += 1
203
+ } else if (entry.isDirectory()) {
204
+ directories += 1
205
+ stack.push(full)
206
+ } else {
207
+ files += 1
208
+ bytes += (await stat(full)).size
209
+ }
210
+ }
211
+ }
212
+ return { files, directories, symlinks, bytes }
213
+ }
214
+
215
+ /**
216
+ * Move a directory: rename when both paths share a volume, otherwise copy,
217
+ * verify the copy against the source, and only then remove the source.
218
+ * @param {string} source - existing directory.
219
+ * @param {string} destination - path that must not exist yet.
220
+ * @returns {Promise<'rename' | 'copy'>} which mechanism moved the tree.
221
+ */
222
+ async function moveDirectory(source, destination) {
223
+ try {
224
+ await rename(source, destination)
225
+ return 'rename'
226
+ } catch (error) {
227
+ if (error.code !== 'EXDEV') throw error
228
+ }
229
+ await cp(source, destination, {
230
+ recursive: true,
231
+ errorOnExist: true,
232
+ force: false,
233
+ preserveTimestamps: true,
234
+ })
235
+ const left = await treeSummary(source)
236
+ const right = await treeSummary(destination)
237
+ const matches =
238
+ left.files === right.files &&
239
+ left.directories === right.directories &&
240
+ left.symlinks === right.symlinks &&
241
+ left.bytes === right.bytes
242
+ if (!matches) {
243
+ await rm(destination, { recursive: true, force: true })
244
+ fail(
245
+ 'copy-mismatch',
246
+ `copied tree does not match the source (${JSON.stringify(left)} vs ${JSON.stringify(right)}); the source was kept`,
247
+ )
248
+ }
249
+ await rm(source, { recursive: true, force: true })
250
+ return 'copy'
251
+ }
252
+
164
253
  function freshManifest() {
165
254
  return {
166
255
  schemaVersion: MANIFEST_VERSION,
@@ -212,6 +301,9 @@ function normalizeManifest(raw, warnings) {
212
301
  id: entry.id,
213
302
  name: entry.name,
214
303
  path: normalizePath(entry.path),
304
+ ...(typeof entry.relocatedTo === 'string' && isAbsolute(entry.relocatedTo)
305
+ ? { relocatedTo: normalizePath(entry.relocatedTo) }
306
+ : {}),
215
307
  createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : new Date(0).toISOString(),
216
308
  origin: entry.origin === 'adopted' ? 'adopted' : 'created',
217
309
  links,
@@ -325,11 +417,37 @@ export class ManagedWorkspaceStore {
325
417
  return join(record.path, this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY)
326
418
  }
327
419
 
420
+ linksDirectoryAt(base) {
421
+ return join(base, this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY)
422
+ }
423
+
424
+ /** The registered workspace entity for a record (anchor or migrated location), or null. */
425
+ registryEntity(record) {
426
+ if (this.registry === null) return null
427
+ const match = this.registry.list().find(
428
+ (workspace) =>
429
+ samePath(workspace.path, record.path) ||
430
+ (typeof record.relocatedTo === 'string' && samePath(workspace.path, record.relocatedTo)),
431
+ )
432
+ return match ?? null
433
+ }
434
+
328
435
  /** The registered workspace id for a record, or null. */
329
436
  registryWorkspaceId(record) {
330
- if (this.registry === null) return null
331
- const match = this.registry.list().find((workspace) => samePath(workspace.path, record.path))
332
- return match === undefined ? null : String(match.id)
437
+ const entity = this.registryEntity(record)
438
+ return entity === null ? null : String(entity.id)
439
+ }
440
+
441
+ /** How many sessions the registry accounts to this workspace. */
442
+ sessionCountOf(record) {
443
+ const entity = this.registryEntity(record)
444
+ const ids = entity?.sessionIds
445
+ return Array.isArray(ids) ? ids.length : 0
446
+ }
447
+
448
+ /** The physical directory holding the workspace content (anchor resolved). */
449
+ async realPathOf(record) {
450
+ return normalizePath(await realpath(record.path))
333
451
  }
334
452
 
335
453
  /** All managed workspace records (loaded state only). */
@@ -371,6 +489,13 @@ export class ManagedWorkspaceStore {
371
489
  if (typeof workspace.path !== 'string') continue
372
490
  if (this.isFilesystemRoot(workspace.path)) continue
373
491
  if (this.manifest.workspaces.some((record) => samePath(record.path, workspace.path))) continue
492
+ if (
493
+ this.manifest.workspaces.some(
494
+ (record) => typeof record.relocatedTo === 'string' && samePath(record.relocatedTo, workspace.path),
495
+ )
496
+ ) {
497
+ continue
498
+ }
374
499
  const title = typeof workspace.title === 'string' && workspace.title.trim().length > 0 ? workspace.title.trim() : basename(workspace.path)
375
500
  out.push({ path: normalizePath(workspace.path), title, workspaceId: String(workspace.id) })
376
501
  }
@@ -382,6 +507,9 @@ export class ManagedWorkspaceStore {
382
507
  if (this.manifest === null || typeof path !== 'string') return null
383
508
  for (const workspace of this.manifest.workspaces) {
384
509
  if (isUnder(path, workspace.path)) return { workspace, via: 'home' }
510
+ if (typeof workspace.relocatedTo === 'string' && isUnder(path, workspace.relocatedTo)) {
511
+ return { workspace, via: 'relocated' }
512
+ }
385
513
  }
386
514
  for (const workspace of this.manifest.workspaces) {
387
515
  for (const link of workspace.links) {
@@ -415,11 +543,13 @@ export class ManagedWorkspaceStore {
415
543
  id: record.id,
416
544
  name: record.name,
417
545
  path: record.path,
546
+ relocatedTo: typeof record.relocatedTo === 'string' ? record.relocatedTo : null,
418
547
  createdAt: record.createdAt,
419
548
  origin: record.origin === 'adopted' ? 'adopted' : 'created',
420
549
  directory: (await isDirectory(record.path)) ? 'ok' : 'missing',
421
550
  registered: workspaceId !== null,
422
551
  workspaceId,
552
+ sessionCount: this.sessionCountOf(record),
423
553
  links,
424
554
  }
425
555
  }
@@ -552,6 +682,12 @@ export class ManagedWorkspaceStore {
552
682
  if (existing !== undefined) {
553
683
  return { workspace: await this.view(existing), adopted: false }
554
684
  }
685
+ const relocated = this.requireManifest().workspaces.find(
686
+ (record) => typeof record.relocatedTo === 'string' && samePath(record.relocatedTo, target),
687
+ )
688
+ if (relocated !== undefined) {
689
+ fail('already-managed', `this folder is already the migrated location of workspace "${relocated.name}"`)
690
+ }
555
691
  const preferred = this.registryTitle(target) ?? basename(target)
556
692
  const record = {
557
693
  id: `mws-${randomUUID()}`,
@@ -580,6 +716,193 @@ export class ManagedWorkspaceStore {
580
716
  }
581
717
  }
582
718
 
719
+ /** Recreate a set of recorded links under one base directory (best effort). */
720
+ async restoreLinks(links, base) {
721
+ const errors = []
722
+ if (links.length === 0) return errors
723
+ const roots = this.linksDirectoryAt(base)
724
+ await mkdir(roots, { recursive: true })
725
+ for (const link of links) {
726
+ try {
727
+ await ensureLinkNode(join(roots, link.alias), link.target)
728
+ } catch (error) {
729
+ errors.push(`${link.alias}: ${errorMessage(error)}`)
730
+ }
731
+ }
732
+ return errors
733
+ }
734
+
735
+ /** Remove every recorded link node below a base directory, returning the removed ones. */
736
+ async detachRecordLinks(record, base) {
737
+ const roots = this.linksDirectoryAt(base)
738
+ const detached = []
739
+ for (const link of record.links) {
740
+ const result = await detachLinkNode(join(roots, link.alias), link.target)
741
+ if (result === 'removed') detached.push(link)
742
+ }
743
+ return detached
744
+ }
745
+
746
+ /** Point the stable anchor path at the migrated directory, replacing a stale link. */
747
+ async ensureAnchorLink(record, destination, previous) {
748
+ const anchor = record.path
749
+ const node = await lstatOrNull(anchor)
750
+ if (node === null) {
751
+ await mkdir(dirname(anchor), { recursive: true })
752
+ await createDirectoryLink(destination, anchor)
753
+ return
754
+ }
755
+ if (!node.isSymbolicLink()) {
756
+ fail('conflict', `refusing to replace "${anchor}": it is a real directory or file`)
757
+ }
758
+ const actual = await resolveLink(anchor)
759
+ if (samePath(actual, destination)) return
760
+ if (!samePath(actual, previous) && !isUnder(actual, previous)) {
761
+ fail('conflict', `refusing to replace "${anchor}": it points to ${actual}`)
762
+ }
763
+ await unlink(anchor)
764
+ await createDirectoryLink(destination, anchor)
765
+ }
766
+
767
+ /**
768
+ * Move a workspace's directory to a new location.
769
+ *
770
+ * A session records its original working directory immutably, and the
771
+ * workspace registry groups sessions by that recorded path. Moving the
772
+ * folder would strand every conversation, so a workspace that still has
773
+ * sessions keeps its original path as a directory link to the new location
774
+ * (`anchor` mode). A workspace without sessions is re-registered at the new
775
+ * path instead (`clean` mode), leaving no link behind.
776
+ */
777
+ async migrate(id, destination, options = {}) {
778
+ await this.load()
779
+ return this.run(async () => {
780
+ const record = this.mustFind(id)
781
+ const raw = typeof destination === 'string' ? destination.trim() : ''
782
+ if (raw.length === 0) fail('invalid-target', 'destination must be a non-blank absolute path')
783
+ const expanded = expandHomePath(raw)
784
+ if (!isAbsolute(expanded)) fail('invalid-target', `destination must be absolute: ${JSON.stringify(destination)}`)
785
+ const target = normalizePath(expanded)
786
+ if (this.isFilesystemRoot(target)) fail('invalid-target', `a filesystem root cannot be a workspace: ${target}`)
787
+ if (samePath(target, this.requireManifest().baseDirectory)) {
788
+ fail('invalid-target', 'the workspace base directory cannot itself be a workspace')
789
+ }
790
+ if ((await lstatOrNull(target)) !== null) fail('exists', `destination already exists: ${target}`)
791
+
792
+ const source = await this.realPathOf(record)
793
+ if (samePath(target, source)) fail('invalid-target', 'destination is the current workspace location')
794
+ if (isUnder(target, source)) fail('invalid-target', 'cannot move a workspace into itself')
795
+ if (isUnder(source, target)) fail('invalid-target', 'cannot move a workspace onto its own parent')
796
+ for (const link of record.links) {
797
+ if (isUnder(target, link.target)) fail('invalid-target', 'cannot move a workspace into one of its linked folders')
798
+ }
799
+ if (this.registry !== null) {
800
+ const clash = this.registry
801
+ .list()
802
+ .find(
803
+ (workspace) =>
804
+ samePath(workspace.path, target) &&
805
+ !samePath(workspace.path, record.path) &&
806
+ !(typeof record.relocatedTo === 'string' && samePath(workspace.path, record.relocatedTo)),
807
+ )
808
+ if (clash !== undefined) fail('exists', `another registered workspace already uses ${target}`)
809
+ }
810
+ if (
811
+ this.listSync().some(
812
+ (other) =>
813
+ other.id !== record.id &&
814
+ (samePath(other.path, target) ||
815
+ (typeof other.relocatedTo === 'string' && samePath(other.relocatedTo, target))),
816
+ )
817
+ ) {
818
+ fail('exists', `another managed workspace already uses ${target}`)
819
+ }
820
+
821
+ const sessionCount = this.sessionCountOf(record)
822
+ const requested = options.mode === undefined ? 'auto' : String(options.mode)
823
+ if (!['auto', 'anchor', 'clean'].includes(requested)) fail('invalid-mode', `unknown migration mode: ${requested}`)
824
+ const mode = requested === 'auto' ? (sessionCount === 0 ? 'clean' : 'anchor') : requested
825
+ if (mode === 'clean' && sessionCount > 0) {
826
+ fail(
827
+ 'sessions-present',
828
+ `this workspace still has ${sessionCount} session(s); a clean migration would detach them. Use the anchor mode instead.`,
829
+ )
830
+ }
831
+
832
+ let detached = []
833
+ try {
834
+ detached = await this.detachRecordLinks(record, source)
835
+ } catch (error) {
836
+ const errors = await this.restoreLinks(detached, source)
837
+ fail(
838
+ 'conflict',
839
+ `cannot prepare links for the move: ${errorMessage(error)}${errors.length > 0 ? `; restoring them also failed: ${errors.join('; ')}` : ''}`,
840
+ )
841
+ }
842
+
843
+ let movedBy
844
+ try {
845
+ await mkdir(dirname(target), { recursive: true })
846
+ movedBy = await moveDirectory(source, target)
847
+ } catch (error) {
848
+ const errors = await this.restoreLinks(detached, source)
849
+ fail(
850
+ 'move-failed',
851
+ `${errorMessage(error)}${errors.length > 0 ? `; restoring links also failed: ${errors.join('; ')}` : ''}`,
852
+ )
853
+ }
854
+
855
+ const linksDirName = this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY
856
+ const linkErrors = record.links.length > 0 ? await this.restoreLinks(record.links, target) : []
857
+ if (!(await isDirectory(join(target, linksDirName))) && record.origin !== 'adopted') {
858
+ await mkdir(join(target, linksDirName), { recursive: true })
859
+ }
860
+
861
+ if (mode === 'clean') {
862
+ try {
863
+ const entity = this.registryEntity(record)
864
+ if (entity !== null) await this.registry.delete(String(entity.id))
865
+ if (this.registry !== null) await this.registry.create(target, record.name)
866
+ } catch (error) {
867
+ record.relocatedTo = target
868
+ await this.persist()
869
+ fail(
870
+ 'registration-failed',
871
+ `the directory moved to ${target}, but re-registering it failed: ${errorMessage(error)}. Run repair to re-anchor the original path.`,
872
+ )
873
+ }
874
+ record.path = target
875
+ delete record.relocatedTo
876
+ await this.persist()
877
+ return { workspace: await this.view(record), mode, source, destination: target, sessionCount, linkErrors, movedBy }
878
+ }
879
+
880
+ try {
881
+ await this.ensureAnchorLink(record, target, source)
882
+ } catch (anchorError) {
883
+ try {
884
+ await moveDirectory(target, source)
885
+ await this.restoreLinks(record.links, source)
886
+ if (typeof record.relocatedTo === 'string') await this.ensureAnchorLink(record, record.relocatedTo, source)
887
+ } catch (rollbackError) {
888
+ record.relocatedTo = target
889
+ await this.persist()
890
+ fail(
891
+ 'migration-incomplete',
892
+ `moved to ${target}, but the original path ${record.path} is unusable (${errorMessage(anchorError)}) and rolling back failed (${errorMessage(rollbackError)}). Create a link from ${record.path} to ${target} manually, then run repair.`,
893
+ )
894
+ }
895
+ fail(
896
+ 'migration-rollback',
897
+ `could not keep the original path usable (${errorMessage(anchorError)}); the directory was moved back to ${source}`,
898
+ )
899
+ }
900
+ record.relocatedTo = target
901
+ await this.persist()
902
+ return { workspace: await this.view(record), mode, source, destination: target, sessionCount, linkErrors, movedBy }
903
+ })
904
+ }
905
+
583
906
  async attach(id, folder, alias) {
584
907
  await this.load()
585
908
  return this.run(async () => {
@@ -694,14 +1017,33 @@ export class ManagedWorkspaceStore {
694
1017
  const record = this.mustFind(id)
695
1018
  const repaired = []
696
1019
  const conflicts = []
697
- if (!(await isDirectory(record.path))) {
1020
+ const relocated = typeof record.relocatedTo === 'string' ? record.relocatedTo : null
1021
+
1022
+ if (relocated !== null) {
1023
+ if (!(await isDirectory(relocated))) {
1024
+ fail('missing', `the migrated workspace folder is missing and will not be recreated: ${relocated}`)
1025
+ }
1026
+ // The original path is the anchor every recorded session still uses.
1027
+ try {
1028
+ await this.ensureAnchorLink(record, relocated, relocated)
1029
+ if (!(await isDirectory(record.path))) {
1030
+ conflicts.push(`anchor ${record.path} does not resolve to ${relocated}`)
1031
+ } else if (await samePath(await this.realPathOf(record), relocated)) {
1032
+ if (repaired.length === 0) repaired.push('anchor')
1033
+ }
1034
+ } catch (error) {
1035
+ conflicts.push(`anchor: ${errorMessage(error)}`)
1036
+ }
1037
+ } else if (!(await isDirectory(record.path))) {
698
1038
  if (record.origin === 'adopted') {
699
1039
  fail('missing', `the adopted workspace folder is missing and will not be recreated: ${record.path}`)
700
1040
  }
701
1041
  await mkdir(record.path, { recursive: true })
702
1042
  repaired.push('workspace folder')
703
1043
  }
704
- const roots = this.linksDirectory(record)
1044
+
1045
+ const base = relocated ?? record.path
1046
+ const roots = this.linksDirectoryAt(base)
705
1047
  // A created workspace always owns its _roots; an adopted one gets it only
706
1048
  // when a linked folder actually needs it, so adoption leaves no trace.
707
1049
  if (!(await isDirectory(roots)) && (record.links.length > 0 || record.origin !== 'adopted')) {
package/tool.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { ManagedWorkspaceError } from './store.js'
7
7
 
8
- const ACTIONS = ['status', 'list', 'create', 'adopt', 'attach', 'detach', 'register', 'forget']
8
+ const ACTIONS = ['status', 'list', 'create', 'adopt', 'attach', 'detach', 'migrate', 'register', 'forget']
9
9
 
10
10
  const PARAMETERS = {
11
11
  type: 'object',
@@ -14,7 +14,7 @@ const PARAMETERS = {
14
14
  action: {
15
15
  type: 'string',
16
16
  enum: ACTIONS,
17
- description: 'status reads the current session workspace; list reads every managed workspace and the existing workspaces that are not managed yet; create makes a new workspace folder; adopt registers an existing folder (or every existing sidebar workspace) without moving it; attach/detach add or remove one linked folder; register re-registers a workspace in the sidebar; forget removes the plugin record without deleting any folder.',
17
+ description: 'status reads the current session workspace; list reads every managed workspace and the existing workspaces that are not managed yet; create makes a new workspace folder; adopt registers an existing folder (or every existing sidebar workspace) without moving it; attach/detach add or remove one linked folder; migrate moves the workspace directory while keeping its conversations usable; register re-registers a workspace in the sidebar; forget removes the plugin record without deleting any folder.',
18
18
  },
19
19
  name: {
20
20
  type: 'string',
@@ -28,6 +28,15 @@ const PARAMETERS = {
28
28
  type: 'boolean',
29
29
  description: 'For action=adopt: adopt every sidebar workspace this plugin does not manage yet.',
30
30
  },
31
+ destination: {
32
+ type: 'string',
33
+ description: 'Absolute destination path for action=migrate. It must not exist yet; the workspace directory is moved there.',
34
+ },
35
+ mode: {
36
+ type: 'string',
37
+ enum: ['auto', 'anchor', 'clean'],
38
+ description: 'For action=migrate: auto keeps the original path as a link when the workspace still has sessions (required for conversations to keep working) and re-registers a session-less workspace at the new path; anchor always keeps the original path as a link; clean refuses a workspace that still has sessions.',
39
+ },
31
40
  alias: {
32
41
  type: 'string',
33
42
  description: 'Link name under the workspace _roots directory. Defaults to the folder name; ignored for detach when omitted.',
@@ -62,7 +71,13 @@ function linkLines(view) {
62
71
  }
63
72
 
64
73
  function workspaceBlock(view) {
65
- return [`Workspace "${view.name}" at ${view.path} [${statusLabel(view)}]`, 'Linked folders:', ...linkLines(view)].join('\n')
74
+ const lines = [`Workspace "${view.name}" at ${view.path} [${statusLabel(view)}]`]
75
+ if (view.relocatedTo !== null && view.relocatedTo !== undefined) {
76
+ lines.push(`Content location: ${view.relocatedTo} (the original path is kept as a link so recorded sessions stay usable)`)
77
+ }
78
+ lines.push(`Sessions: ${view.sessionCount ?? 0}`)
79
+ lines.push('Linked folders:', ...linkLines(view))
80
+ return lines.join('\n')
66
81
  }
67
82
 
68
83
  function listBlock(state) {
@@ -198,6 +213,23 @@ export function createManagedWorkspaceTool(ctx, store) {
198
213
  return { summary: `${headline}\n${workspaceBlock(result.workspace)}${warning}` }
199
214
  }
200
215
 
216
+ if (action === 'migrate') {
217
+ const record = resolveTarget(args, exec)
218
+ if (typeof args.destination !== 'string' || args.destination.trim().length === 0) {
219
+ throw new ManagedWorkspaceError('invalid-target', 'action=migrate requires "destination"')
220
+ }
221
+ const result = await store.migrate(record.id, args.destination, { mode: args.mode })
222
+ const explanation =
223
+ result.mode === 'anchor'
224
+ ? `The original path is kept as a directory link to the new location, so the ${result.sessionCount} recorded session(s) and their conversations keep working.`
225
+ : 'The workspace had no sessions, so it was re-registered at the new path with no link left behind.'
226
+ const linkNote =
227
+ result.linkErrors.length === 0 ? '' : `\nLink warnings: ${result.linkErrors.join('; ')}`
228
+ return {
229
+ summary: `Moved the workspace content from ${result.source} to ${result.destination} (${result.movedBy}).\n${explanation}${linkNote}\n${workspaceBlock(result.workspace)}`,
230
+ }
231
+ }
232
+
201
233
  if (action === 'register') {
202
234
  const record = resolveTarget(args, exec)
203
235
  const result = await store.register(record.id)