dsh-session-drag-move 0.0.0-stage → 0.1.1

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
@@ -1,3 +1,98 @@
1
- # Temporary Holding Version
1
+ # dsh-session-drag-move
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 左侧栏「工作区」面板里,把**会话拖到别的工作区**——真正改归属,不只是改个名字分组。
4
+
5
+ 官方只支持同一工作区内部拖排序;跨工作区拖拽是**完全不响应**的(`onDrop` 只在
6
+ `drag.accountKey === group.key` 时生效,`insertSessionBefore` 也拒绝不属于本工作区的会话)。
7
+ 这个插件只接管这段本来就是空的行为,同工作区拖拽照旧交给官方。
8
+
9
+ ## 它到底做了什么
10
+
11
+ DSH 里会话的归属是**派生的,不是存出来的**:
12
+
13
+ ```
14
+ 工作区拥有「会话日志 header 的 cwd 等于本工作区路径」的那些会话
15
+ ```
16
+
17
+ `~/.dsh/storages/workspace.json` 里的 `sessionIds` 只是顺序账本,path 不匹配的 id
18
+ 会在下次写入时被剪掉。所以换工作区必须改**会话日志 header 里的 cwd**。
19
+
20
+ 会话日志是独立 Zstandard 帧的拼接,第一帧就是 header:
21
+
22
+ ```
23
+ <root>/--<projectKey(cwd)>--/<sessionId>/session.v4.jsonl.zstd
24
+ └ 项目目录由 cwd 决定 └ 首帧 = header,其后每帧 = 一个追加批次
25
+ ```
26
+
27
+ 帧彼此独立,所以移动 = **重新压缩新的第一帧 + 原文件第二个 magic 之后的字节原样复制**,
28
+ 事件数据一个字节都不动。写完再自行解压校验一遍,然后把旧目录移到备份区。
29
+
30
+ 最后同步正在运行的进程(否则要重启才生效):更新 registry 的 header 缓存与
31
+ session-path 索引,再走官方的 `attachSession` / `insertSessionBefore` / `detachSession`。
32
+ `attachSession` 会重新读 header 并校验 cwd,它成功就证明存储层改动真的落盘了。
33
+
34
+ ## 安装
35
+
36
+ ```powershell
37
+ # 1. 放进 profile 的 node_modules
38
+ Copy-Item 'E:\dsh插件开发' "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-session-drag-move" -Recurse -Force
39
+
40
+ # 2. 在 profile 的 cordis.patch.yml 里加一行
41
+ # - insert:
42
+ # - id: session-drag-move
43
+ # name: 'dsh-session-drag-move'
44
+
45
+ # 3. 重启 DSH
46
+ ```
47
+
48
+ 也可以用官方 CLI:
49
+
50
+ ```bash
51
+ dsh plugin --profile desktop add dsh-session-drag-move
52
+ ```
53
+
54
+ ## 配置(host 半边)
55
+
56
+ | 字段 | 默认 | 含义 |
57
+ |---|---|---|
58
+ | `root` | `~/.dsh/sessions` | 会话存储根目录 |
59
+ | `backupRoot` | `<root>/../session-drag-move-backup` | 移动前的会话目录停放处 |
60
+ | `allowRemote` | `false` | 默认只接受 loopback 请求;局域网部署需显式打开 |
61
+
62
+ ## 使用
63
+
64
+ - 拖会话行到**另一个工作区的头行** → 归入该工作区(追加到末尾)
65
+ - 拖会话行到**另一个工作区的某个会话行**上半 → 插到它前面;下半 → 插到它后面
66
+ - 同工作区内拖拽 → 不拦截,仍是官方排序
67
+ - 运行中 / 当前会话会被拒绝
68
+
69
+ ## 安全边界
70
+
71
+ - 只重写第一帧;尾部原样复制;写完重新解压自检(id + cwd + 能解出 session header)
72
+ - 旧目录**移出** sessions 根(留着会被扫到重复 id),停在备份区,可回滚
73
+ - 目标文件已存在则整体拒绝,不做半截写入
74
+ - 跨区移动前检查会话是否在运行
75
+
76
+ ## 已知限制
77
+
78
+ - 依赖 `workspaceRegistry` 的内部字段(`headers` / `sessionPaths` / `host.rememberSessionPath`)
79
+ 同步运行状态。这些是普通字段不是 `#private`,能用,但**版本敏感**——所有访问都做了
80
+ feature-probe,探测不到会返回结构化错误而不是抛栈。
81
+ - 目标针对官方原版左侧栏。若启用 `dsh-better-workspace`(它会整区替换
82
+ `sidebar.workspaces`,且不迁移工作区),其自绘 DOM 是另一套结构,需要另加适配分支。
83
+ - 投影缓存 `~/.dsh/storages/session_projcache/sessions/<id>.json` 里的
84
+ `record.identity.cwd` 目前未改写(可重建缓存,不影响归属)。
85
+
86
+ ## 开发
87
+
88
+ ```powershell
89
+ $node = "$env:USERPROFILE\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe"
90
+ & $node --test tests\smoke.mjs
91
+ ```
92
+
93
+ 测试覆盖路径编码(与本机磁盘目录逐字比对)、header 帧定位与校验、
94
+ 真实会话日志的 cwd 改写 round-trip(含尾部字节完全一致断言)。
95
+
96
+ ## 许可证
97
+
98
+ MIT
@@ -0,0 +1,14 @@
1
+ # dsh-session-drag-move bundle patch.
2
+ #
3
+ # Manual mount equivalent (profile cordis.patch.yml):
4
+ #
5
+ # - insert:
6
+ # - id: session-drag-move
7
+ # name: 'dsh-session-drag-move'
8
+ #
9
+ # Only the Node half loads through this row; the web client half is served
10
+ # through the package's exports["./client"] entry and mounted by the browser
11
+ # boot graph (window.__DSH_BOOT__).
12
+ - insert:
13
+ - id: session-drag-move
14
+ name: 'dsh-session-drag-move'
package/lib/client.js ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * dsh-session-drag-move — client half (plain JavaScript, no build step).
3
+ *
4
+ * The shipped workspace browser only handles drags INSIDE one workspace: its
5
+ * `onDrop` acts when the drag's account key equals the group's key, and
6
+ * `insertSessionBefore` rejects any session the workspace does not already
7
+ * account for. A cross-workspace drag is therefore inert today.
8
+ *
9
+ * This half intercepts only that inert case — in the CAPTURE phase, ahead of
10
+ * React's delegated handlers — and asks the host half to re-home the session.
11
+ * Same-workspace drags are deliberately left alone so official ordering keeps
12
+ * working exactly as it does now.
13
+ *
14
+ * Anchors come from the official DOM contract: every row carries
15
+ * `data-row-key`, `session:<id>` for sessions and `workspace:<workspaceId>`
16
+ * for workspace headers (the official buildGroup passes workspaceId as the
17
+ * group key). Nothing here depends on hashed CSS module class names.
18
+ */
19
+ window.__ModuleLoader__.load({
20
+ id: 'dsh-session-drag-move',
21
+ factory: (_require) => {
22
+ const NS = 'dshSessionDragMove'
23
+ const API = '/session-drag-move/api/'
24
+ const SESSION_PREFIX = 'session:'
25
+ const WORKSPACE_PREFIX = 'workspace:'
26
+
27
+ const CSS = `
28
+ [data-dsh-sm-dragging] { opacity: .45; }
29
+ [data-dsh-sm-target] .dsh-sm-row,
30
+ [data-dsh-sm-target] > * {
31
+ outline: 1px dashed var(--dsh-accent, #4c8dff);
32
+ outline-offset: -1px;
33
+ border-radius: 6px;
34
+ background: color-mix(in srgb, var(--dsh-accent, #4c8dff) 10%, transparent);
35
+ }
36
+ .${NS}-toast {
37
+ position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
38
+ z-index: 2147483000; max-width: 78vw;
39
+ padding: 8px 14px; border-radius: 8px;
40
+ background: rgba(28,28,30,.94); color: #fff;
41
+ font: 12px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
42
+ box-shadow: 0 6px 24px rgba(0,0,0,.28);
43
+ }
44
+ `
45
+
46
+ /* ------------------------------ helpers ------------------------------ */
47
+
48
+ const rowKey = (el) => (el instanceof Element ? (el.dataset?.rowKey ?? '') : '')
49
+
50
+ const closestKey = (el, prefix) => {
51
+ if (!(el instanceof Element)) return null
52
+ const hit = el.closest(`[data-row-key^="${prefix}"]`)
53
+ return hit
54
+ }
55
+
56
+ /** The session row an event was raised on, if any. */
57
+ const sessionRowOf = (target) => closestKey(target, SESSION_PREFIX)
58
+
59
+ /**
60
+ * Which workspace a session row belongs to: walk up until an ancestor has
61
+ * a workspace header as one of its own children. The workspace header is
62
+ * the first child of the group section, so the first hit is the owner.
63
+ */
64
+ function owningWorkspaceId(row) {
65
+ let node = row?.parentElement
66
+ while (node instanceof Element) {
67
+ const own = node.querySelector?.(`:scope > [data-row-key^="${WORKSPACE_PREFIX}"]`)
68
+ if (own instanceof Element) return rowKey(own).slice(WORKSPACE_PREFIX.length)
69
+ for (const child of node.children) {
70
+ const key = rowKey(child)
71
+ if (key.startsWith(WORKSPACE_PREFIX)) return key.slice(WORKSPACE_PREFIX.length)
72
+ const nested = child.querySelector?.(`[data-row-key^="${WORKSPACE_PREFIX}"]`)
73
+ if (nested instanceof Element) return rowKey(nested).slice(WORKSPACE_PREFIX.length)
74
+ }
75
+ node = node.parentElement
76
+ }
77
+ return ''
78
+ }
79
+
80
+ /** Every session row of a group, in DOM order. */
81
+ function siblingSessionRows(row) {
82
+ let node = row?.parentElement
83
+ while (node instanceof Element) {
84
+ const rows = node.querySelectorAll(`[data-row-key^="${SESSION_PREFIX}"]`)
85
+ if (rows.length > 0) return Array.from(rows)
86
+ node = node.parentElement
87
+ }
88
+ return []
89
+ }
90
+
91
+ /**
92
+ * Resolve a drop target from the element under the pointer.
93
+ * @returns {{workspaceId: string, beforeSessionId?: string}|null}
94
+ */
95
+ function dropTargetOf(target, fromWorkspaceId) {
96
+ if (!(target instanceof Element)) return null
97
+
98
+ const workspaceRow = closestKey(target, WORKSPACE_PREFIX)
99
+ if (workspaceRow instanceof Element) {
100
+ const workspaceId = rowKey(workspaceRow).slice(WORKSPACE_PREFIX.length)
101
+ if (workspaceId === '' || workspaceId === fromWorkspaceId) return null
102
+ return { workspaceId }
103
+ }
104
+
105
+ const row = sessionRowOf(target)
106
+ if (!(row instanceof Element)) return null
107
+ const workspaceId = owningWorkspaceId(row)
108
+ if (workspaceId === '' || workspaceId === fromWorkspaceId) return null
109
+
110
+ const rect = row.getBoundingClientRect()
111
+ const below = target instanceof Element
112
+ ? (target.getBoundingClientRect().top + target.getBoundingClientRect().height / 2) > rect.top + rect.height / 2
113
+ : false
114
+ const anchorId = rowKey(row).slice(SESSION_PREFIX.length)
115
+
116
+ if (!below) return { workspaceId, beforeSessionId: anchorId }
117
+ const rows = siblingSessionRows(row)
118
+ const next = rows[rows.indexOf(row) + 1]
119
+ const nextId = next instanceof Element ? rowKey(next).slice(SESSION_PREFIX.length) : undefined
120
+ return { workspaceId, beforeSessionId: nextId }
121
+ }
122
+
123
+ /* ------------------------------- state ------------------------------- */
124
+
125
+ let drag = null
126
+ let marker = null
127
+ let toastTimer = null
128
+
129
+ function ensureStyle() {
130
+ if (document.head.querySelector(`style[data-plugin-css="${NS}"]`) !== null) return
131
+ const style = document.createElement('style')
132
+ style.setAttribute('data-plugin-css', NS)
133
+ style.textContent = CSS
134
+ document.head.appendChild(style)
135
+ }
136
+
137
+ function clearMarker() {
138
+ if (marker instanceof Element) marker.removeAttribute('data-dsh-sm-target')
139
+ marker = null
140
+ }
141
+
142
+ function markTarget(target) {
143
+ if (marker === target) return
144
+ clearMarker()
145
+ marker = target
146
+ if (target instanceof Element) target.setAttribute('data-dsh-sm-target', '')
147
+ }
148
+
149
+ function toast(message) {
150
+ const existing = document.querySelector(`.${NS}-toast`)
151
+ if (existing instanceof Element) existing.remove()
152
+ const el = document.createElement('div')
153
+ el.className = `${NS}-toast`
154
+ el.textContent = message
155
+ document.body.appendChild(el)
156
+ clearTimeout(toastTimer)
157
+ toastTimer = setTimeout(() => el.remove(), 3200)
158
+ }
159
+
160
+ async function call(method, payload) {
161
+ const response = await fetch(API + method, {
162
+ method: 'POST',
163
+ headers: { 'content-type': 'application/json' },
164
+ body: JSON.stringify(payload ?? {})
165
+ })
166
+ const body = await response.json().catch(() => ({ ok: false, error: { message: 'bad response' } }))
167
+ if (!response.ok || body.ok !== true) {
168
+ throw new Error(body?.error?.message ?? `request failed (${response.status})`)
169
+ }
170
+ return body.result
171
+ }
172
+
173
+ /* ------------------------------ listeners ---------------------------- */
174
+
175
+ function onDragStart(event) {
176
+ const row = sessionRowOf(event.target)
177
+ if (!(row instanceof Element)) return
178
+ const workspaceId = owningWorkspaceId(row)
179
+ if (workspaceId === '') return
180
+ drag = {
181
+ row,
182
+ sessionId: rowKey(row).slice(SESSION_PREFIX.length),
183
+ fromWorkspaceId: workspaceId
184
+ }
185
+ row.setAttribute('data-dsh-sm-dragging', '')
186
+ }
187
+
188
+ function onDragOver(event) {
189
+ if (drag === null) return
190
+ const target = dropTargetOf(event.target, drag.fromWorkspaceId)
191
+ if (target === null) return
192
+ // The official handler never preventDefaults a cross-workspace drag, so
193
+ // the drop would be rejected as "no drop target" without this.
194
+ event.preventDefault()
195
+ event.stopPropagation()
196
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
197
+ markTarget(sessionRowOf(event.target) ?? closestKey(event.target, WORKSPACE_PREFIX) ?? null)
198
+ }
199
+
200
+ function onDrop(event) {
201
+ if (drag === null) return
202
+ const target = dropTargetOf(event.target, drag.fromWorkspaceId)
203
+ if (target === null) return
204
+ event.preventDefault()
205
+ event.stopPropagation()
206
+ const payload = {
207
+ sessionId: drag.sessionId,
208
+ targetWorkspaceId: target.workspaceId,
209
+ beforeSessionId: target.beforeSessionId
210
+ }
211
+ const source = drag
212
+ drag = null
213
+ clearMarker()
214
+ source.row?.removeAttribute('data-dsh-sm-dragging')
215
+
216
+ call('move', payload).catch((error) => {
217
+ toast(`移动会话失败:${error.message}`)
218
+ })
219
+ }
220
+
221
+ function onDragEnd(event) {
222
+ if (drag !== null) drag.row?.removeAttribute('data-dsh-sm-dragging')
223
+ drag = null
224
+ clearMarker()
225
+ }
226
+
227
+ function apply(ctx) {
228
+ ensureStyle()
229
+
230
+ // Capture phase: run before React's delegated listeners at the root.
231
+ const options = { capture: true }
232
+ document.addEventListener('dragstart', onDragStart, options)
233
+ document.addEventListener('dragover', onDragOver, options)
234
+ document.addEventListener('drop', onDrop, options)
235
+ document.addEventListener('dragend', onDragEnd, options)
236
+
237
+ ctx?.effect?.(() => () => {
238
+ document.removeEventListener('dragstart', onDragStart, options)
239
+ document.removeEventListener('dragover', onDragOver, options)
240
+ document.removeEventListener('drop', onDrop, options)
241
+ document.removeEventListener('dragend', onDragEnd, options)
242
+ clearMarker()
243
+ })
244
+ }
245
+
246
+ return {
247
+ name: 'dsh-session-drag-move',
248
+ // Deliberately NO inject. This half is a pure DOM enhancement bound to
249
+ // the official row contract; it consumes no client service. Declaring
250
+ // one it does not use makes the boot graph wait forever for a service
251
+ // that never activates, and web boot then fails with
252
+ // "1 entry did not activate".
253
+ apply
254
+ }
255
+ }
256
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Fork family resolution.
3
+ *
4
+ * A fork records where it came from in its session header's `parentSession`,
5
+ * so the family is derived from stored headers — never from a workspace
6
+ * ledger. This is what lets a move keep a branch with its origin: the rule
7
+ * this plugin enforces is that a fork lives in its parent's workspace, and the
8
+ * only way to honour it while moving is to move the subtree together.
9
+ *
10
+ * Pure functions: no filesystem, no services.
11
+ */
12
+
13
+ /**
14
+ * Build the parent → children relation.
15
+ *
16
+ * A header whose `parentSession` names an id that is not in `headers` is
17
+ * treated as a root: the parent may have been deleted, or may live outside
18
+ * this store. Either way there is nothing to follow.
19
+ *
20
+ * @param {Array<{id?: string, parentSession?: string}>} headers - stored headers.
21
+ * @returns {{byId: Map<string, object>, children: Map<string, string[]>}}
22
+ */
23
+ export function forkTree(headers) {
24
+ const byId = new Map()
25
+ for (const header of headers) {
26
+ if (typeof header?.id === 'string' && header.id !== '') byId.set(header.id, header)
27
+ }
28
+
29
+ const children = new Map()
30
+ for (const header of byId.values()) {
31
+ const parent = header.parentSession
32
+ if (typeof parent !== 'string' || !byId.has(parent) || parent === header.id) continue
33
+ const siblings = children.get(parent)
34
+ if (siblings === undefined) children.set(parent, [header.id])
35
+ else siblings.push(header.id)
36
+ }
37
+
38
+ return { byId, children }
39
+ }
40
+
41
+ /**
42
+ * Every session forked from `sessionId`, transitively — the branches to carry
43
+ * along. Breadth-first so the result reads parent-before-child.
44
+ *
45
+ * @param {{children: Map<string, string[]>}} tree - from {@link forkTree}.
46
+ * @param {string} sessionId - the session being moved.
47
+ * @returns {string[]} descendants, excluding `sessionId` itself.
48
+ */
49
+ export function descendantsOf(tree, sessionId) {
50
+ const found = []
51
+ const seen = new Set([sessionId])
52
+ const queue = [sessionId]
53
+ while (queue.length > 0) {
54
+ for (const child of tree.children.get(queue.shift()) ?? []) {
55
+ if (seen.has(child)) continue
56
+ seen.add(child)
57
+ found.push(child)
58
+ queue.push(child)
59
+ }
60
+ }
61
+ return found
62
+ }
63
+
64
+ /**
65
+ * Every ancestor of `sessionId`, nearest first.
66
+ *
67
+ * Not used to move anything: dragging a branch must not silently drag its
68
+ * origin out from under other branches. It exists so a move can REPORT an
69
+ * origin left in another workspace instead of leaving the caller unaware.
70
+ *
71
+ * @param {{byId: Map<string, object>}} tree - from {@link forkTree}.
72
+ * @param {string} sessionId
73
+ * @returns {string[]} ancestors, nearest first.
74
+ */
75
+ export function ancestorsOf(tree, sessionId) {
76
+ const found = []
77
+ const seen = new Set([sessionId])
78
+ let current = tree.byId.get(sessionId)?.parentSession
79
+ while (typeof current === 'string' && tree.byId.has(current) && !seen.has(current)) {
80
+ seen.add(current)
81
+ found.push(current)
82
+ current = tree.byId.get(current)?.parentSession
83
+ }
84
+ return found
85
+ }
86
+
87
+ /**
88
+ * The whole connected family: every ancestor, `sessionId`, and every
89
+ * descendant. Available for callers that want the invariant "one family, one
90
+ * workspace" enforced in a single move.
91
+ *
92
+ * @param {object} tree - from {@link forkTree}.
93
+ * @param {string} sessionId
94
+ * @returns {string[]} family members, root-first order not guaranteed.
95
+ */
96
+ export function familyOf(tree, sessionId) {
97
+ const ancestors = ancestorsOf(tree, sessionId)
98
+ const root = ancestors.length > 0 ? ancestors[ancestors.length - 1] : sessionId
99
+ return [root, ...descendantsOf(tree, root)]
100
+ }
package/lib/index.js ADDED
@@ -0,0 +1,358 @@
1
+ /**
2
+ * dsh-session-drag-move — host half.
3
+ *
4
+ * Adds the one move the shipped workspace domain refuses: re-homing a session
5
+ * into another workspace. Membership is derived, never stored — a workspace
6
+ * owns exactly the sessions whose STORED HEADER cwd is its path — so the move
7
+ * is a storage operation (rewrite that header's cwd and park the log under the
8
+ * new project directory) followed by re-attaching the session on the registry.
9
+ *
10
+ * Exposes `POST /session-drag-move/api/<method>` over the host web server; the
11
+ * browser half calls `move`.
12
+ */
13
+ import { homedir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+
16
+ import {
17
+ findSessionDirs,
18
+ indexStore,
19
+ readHeaderPrefix,
20
+ readSessionState,
21
+ relocateSession,
22
+ sessionDir
23
+ } from './session-log.js'
24
+ import { forkTree, descendantsOf } from './fork-tree.js'
25
+
26
+ export const name = 'dsh-session-drag-move'
27
+
28
+ /**
29
+ * Cordis inject is a LIST of service names. Only `webServer` is declared:
30
+ * without it there is nowhere to hang the move route. `workspaceRegistry` is
31
+ * resolved lazily inside the handler instead — declaring it here would leave
32
+ * this entry pending forever (and web boot failing with "1 entry did not
33
+ * activate") on any profile where that service is absent or renamed, which is
34
+ * exactly the failure mode a hard dependency must not have.
35
+ */
36
+ export const inject = ['webServer']
37
+
38
+ /** Load-time defaults; a patch entry's `config` overrides them field by field. */
39
+ const DEFAULTS = {
40
+ /** Session store root; the shipped default is `~/.dsh/sessions`. */
41
+ root: '',
42
+ /** Where pre-move session directories are parked. */
43
+ backupRoot: '',
44
+ /** Refuse requests whose Host header is not loopback (LAN deploys opt in). */
45
+ allowRemote: false
46
+ }
47
+
48
+ const API_PREFIX = '/session-drag-move/api/'
49
+
50
+ class ApiError extends Error {
51
+ constructor(code, message, status = 400) {
52
+ super(message)
53
+ this.code = code
54
+ this.status = status
55
+ }
56
+ }
57
+
58
+ const json = (res, status, body) => {
59
+ const payload = JSON.stringify(body)
60
+ res.writeHead(status, {
61
+ 'content-type': 'application/json; charset=utf-8',
62
+ 'content-length': Buffer.byteLength(payload)
63
+ })
64
+ res.end(payload)
65
+ }
66
+
67
+ /** Loopback-only by default; a LAN deployment flips `allowRemote`. */
68
+ function trusted(req, allowRemote) {
69
+ if (allowRemote) return true
70
+ const authority = (req.headers.host ?? '').replace(/:\d+$/, '').replace(/^\[|\]$/g, '')
71
+ return authority === '' || authority === 'localhost' || authority === '127.0.0.1' || authority === '::1'
72
+ }
73
+
74
+ async function readBody(req) {
75
+ const chunks = []
76
+ for await (const chunk of req) chunks.push(chunk)
77
+ if (chunks.length === 0) return {}
78
+ const text = Buffer.concat(chunks).toString('utf8')
79
+ return text === '' ? {} : JSON.parse(text)
80
+ }
81
+
82
+ /**
83
+ * Resolve a service this plugin did not declare in `inject`. `ctx.get` is the
84
+ * documented accessor for undeclared services (the shipped workspace registry
85
+ * itself reads `sessions` this way); a plain property read can miss behind a
86
+ * context proxy.
87
+ */
88
+ const serviceOf = (ctx, name) => {
89
+ try {
90
+ return ctx.get?.(name)
91
+ } catch {
92
+ return undefined
93
+ }
94
+ }
95
+
96
+ const isActive = (ctx, sessionId) => {
97
+ const sessions = serviceOf(ctx, 'sessions')
98
+ if (sessions === undefined) return false
99
+ try {
100
+ const live = sessions.get?.(sessionId)
101
+ if (live === undefined) return false
102
+ return live.running === true || live.active === true || live.state === 'running'
103
+ } catch {
104
+ return false
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Make the running process agree with the bytes that just moved.
110
+ *
111
+ * `WorkspaceEntity.attachSession` cannot be used here. It validates the
112
+ * session's cwd by calling `registry.host.readSessionHeader`, which prefers a
113
+ * LIVE session's in-memory header, and a live session keeps the cwd it was
114
+ * opened with. After a move that check therefore compares the OLD cwd against
115
+ * the new workspace path and throws — leaving the bytes moved and the ledger
116
+ * unchanged, which is the inconsistency this function exists to avoid.
117
+ *
118
+ * So the header index is taught the new truth first, and the ledger is then
119
+ * written through the entity's public mutators. `detachSession` runs last: it
120
+ * prunes by `sessionPath(id) === path`, so the source drops the id only once
121
+ * the index already says the session lives elsewhere.
122
+ */
123
+ async function reconcile(ctx, registry, target, source, sessionId, header, beforeSessionId) {
124
+ registry.headers?.set(sessionId, header)
125
+ registry.host?.rememberSessionPath?.(sessionId, target.path)
126
+ registry.invalidSessionPaths?.delete(sessionId)
127
+
128
+ const accounted = (entity) => entity.record.sessionIds.includes(sessionId)
129
+
130
+ if (!accounted(target)) {
131
+ try {
132
+ await target.attachSession(sessionId)
133
+ } catch (error) {
134
+ // The official path re-validates against the live in-memory header and
135
+ // refuses when that header is stale. The index is already correct, so
136
+ // write the ledger through the entity's own single write path instead;
137
+ // `mutate` applies the same idempotence and path pruning as attach.
138
+ ctx.logger?.warn(`dsh-session-drag-move: attachSession refused '${sessionId}' (${error?.message ?? error}); writing the ledger directly`)
139
+ await target.mutate((record) => record.sessionIds.includes(sessionId)
140
+ ? record
141
+ : { ...record, sessionIds: [sessionId, ...record.sessionIds] })
142
+ }
143
+ }
144
+
145
+ if (accounted(target)) await target.insertSessionBefore(sessionId, beforeSessionId)
146
+ if (source !== undefined && accounted(source)) await source.detachSession(sessionId)
147
+ }
148
+
149
+ /**
150
+ * Re-home one session into another workspace.
151
+ *
152
+ * Bytes first, ledger second: the stored header's cwd is what a workspace
153
+ * derives membership from, so the storage rewrite is the real move, and the
154
+ * ledger write is bookkeeping that follows it (see {@link reconcile}).
155
+ */
156
+ async function move(ctx, config, request) {
157
+ const registry = serviceOf(ctx, 'workspaceRegistry')
158
+ if (registry === undefined) throw new ApiError('no-registry', 'workspace registry is unavailable', 503)
159
+
160
+ const sessionId = String(request?.sessionId ?? '')
161
+ const targetWorkspaceId = String(request?.targetWorkspaceId ?? '')
162
+ const beforeSessionId = request?.beforeSessionId === undefined || request?.beforeSessionId === null
163
+ ? undefined
164
+ : String(request.beforeSessionId)
165
+
166
+ if (sessionId === '' || !sessionId.startsWith('session-')) throw new ApiError('bad-session', `not a session id: '${sessionId}'`)
167
+ if (targetWorkspaceId === '') throw new ApiError('bad-workspace', 'targetWorkspaceId is required')
168
+
169
+ const target = registry.get(targetWorkspaceId)
170
+ if (target === undefined) throw new ApiError('no-workspace', `unknown workspace '${targetWorkspaceId}'`, 404)
171
+
172
+ // The ledger is NOT membership: a session can sit in one workspace's ledger
173
+ // while its bytes already belong to another (an interrupted move leaves
174
+ // exactly that). Membership is the stored cwd, so source is resolved from
175
+ // the ledger only to know what to detach afterwards.
176
+ const source = registry.list().find((entity) => entity.id !== targetWorkspaceId && entity.record.sessionIds.includes(sessionId))
177
+
178
+ const root = config.root === '' ? join(homedir(), '.dsh', 'sessions') : config.root
179
+ const backupRoot = config.backupRoot === '' ? join(dirname(root), 'session-drag-move-backup') : config.backupRoot
180
+
181
+ // Where are the bytes, really? Scan the store instead of trusting the
182
+ // in-memory header, which a live session never refreshes after a move.
183
+ const dirs = await findSessionDirs(root, sessionId)
184
+
185
+ if (dirs.length === 0) {
186
+ // Bytes are nowhere under this root: either the session never persisted,
187
+ // or its cwd is a directory this profile cannot see. Both are refused
188
+ // loudly rather than guessed at.
189
+ if (target.record.sessionIds.includes(sessionId)) {
190
+ await target.insertSessionBefore(sessionId, beforeSessionId)
191
+ return { moved: false, reordered: true, workspaceId: targetWorkspaceId }
192
+ }
193
+ throw new ApiError('no-session-dir', `no session directory for '${sessionId}' under '${root}'`, 404)
194
+ }
195
+ if (dirs.length > 1) {
196
+ throw new ApiError('duplicate-session', `'${sessionId}' exists in ${dirs.length} project directories (${dirs.join(', ')}); resolve the duplicate first`, 409)
197
+ }
198
+
199
+ const from = dirs[0]
200
+ const state = await readSessionState(from, sessionId)
201
+ if (state === undefined) throw new ApiError('unreadable-session', `session directory '${from}' holds no readable session log`, 422)
202
+ const oldCwd = state.header.cwd
203
+
204
+ const alreadyThere = from === sessionDir(root, target.path, sessionId)
205
+
206
+ // Which sessions travel with this one. A fork belongs in its parent's
207
+ // workspace, so dragging a session carries its whole branch subtree — a
208
+ // branch left behind would be a fork in a different workspace from the
209
+ // conversation it came out of. Only DESCENDANTS move: dragging a branch must
210
+ // never drag its origin (and therefore its origin's other branches) along.
211
+ const store = await indexStore(root)
212
+ const tree = forkTree(await readHeadersFor(store))
213
+ const branch = descendantsOf(tree, sessionId)
214
+
215
+ // A pure ordering request inside one workspace — and the already-in-place
216
+ // case — never touches storage.
217
+ if (alreadyThere) {
218
+ if (oldCwd !== target.path) {
219
+ // Bytes are in the target project directory but the header still names
220
+ // the old one: repair the header, then fix the ledger.
221
+ const result = await relocateSession({ root, dir: from, newCwd: target.path, sessionId, backupRoot })
222
+ await reconcile(ctx, registry, target, source, sessionId, result.header, beforeSessionId)
223
+ return { moved: true, repaired: true, sessionId, workspaceId: targetWorkspaceId, from: source?.id, cwd: { from: oldCwd, to: target.path } }
224
+ }
225
+ await target.insertSessionBefore(sessionId, beforeSessionId)
226
+ if (source !== undefined) await source.detachSession(sessionId)
227
+ const carried = await carryBranches(ctx, registry, root, backupRoot, target, branch)
228
+ return { moved: false, reordered: true, workspaceId: targetWorkspaceId, branches: carried }
229
+ }
230
+
231
+ if (isActive(ctx, sessionId)) throw new ApiError('session-active', `session '${sessionId}' is running; stop it before moving`, 409)
232
+
233
+ const result = await relocateSession({ root, dir: from, newCwd: target.path, sessionId, backupRoot })
234
+ await reconcile(ctx, registry, target, source, sessionId, result.header, beforeSessionId)
235
+ const carried = await carryBranches(ctx, registry, root, backupRoot, target, branch)
236
+
237
+ return {
238
+ moved: true,
239
+ sessionId,
240
+ workspaceId: targetWorkspaceId,
241
+ from: source?.id,
242
+ cwd: { from: oldCwd, to: target.path },
243
+ backup: result.backup,
244
+ branches: carried
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Read every stored header, so one session can be placed in its fork family.
250
+ *
251
+ * `indexStore` keys are already unique, so this reads each log once. Only a
252
+ * bounded prefix of each file is touched: a header is its first frame, while
253
+ * the log behind it may be many megabytes.
254
+ *
255
+ * @param {Map<string, string>} store - sessionId → directory, from indexStore.
256
+ * @returns {Promise<object[]>} readable headers (unreadable logs are skipped).
257
+ */
258
+ async function readHeadersFor(store) {
259
+ const headers = []
260
+ for (const [id, dir] of store) {
261
+ const header = await readHeaderPrefix(dir, id)
262
+ if (header !== undefined) headers.push(header)
263
+ }
264
+ return headers
265
+ }
266
+
267
+ /**
268
+ * Move the branch subtree into the target workspace alongside its origin.
269
+ *
270
+ * Each branch is an independent session, so a failure on one is reported
271
+ * rather than aborting the rest: the dragged session has already moved by the
272
+ * time this runs, and rolling that back because a branch could not follow
273
+ * would be worse than leaving a branch behind with a reason.
274
+ *
275
+ * @returns {Promise<Array<{sessionId: string, moved: boolean, error?: string}>>}
276
+ */
277
+ async function carryBranches(ctx, registry, root, backupRoot, target, branchIds) {
278
+ const carried = []
279
+ for (const branchId of branchIds) {
280
+ try {
281
+ const dirs = await findSessionDirs(root, branchId)
282
+ if (dirs.length !== 1) {
283
+ carried.push({ sessionId: branchId, moved: false, error: dirs.length === 0 ? 'no session directory' : `${dirs.length} session directories` })
284
+ continue
285
+ }
286
+ const state = await readSessionState(dirs[0], branchId)
287
+ if (state === undefined) {
288
+ carried.push({ sessionId: branchId, moved: false, error: 'unreadable session log' })
289
+ continue
290
+ }
291
+ if (state.header.cwd === target.path) {
292
+ carried.push({ sessionId: branchId, moved: false, error: undefined, alreadyThere: true })
293
+ continue
294
+ }
295
+ if (isActive(ctx, branchId)) {
296
+ carried.push({ sessionId: branchId, moved: false, error: 'session is running' })
297
+ continue
298
+ }
299
+ const source = registry.list().find((entity) => entity.id !== target.id && entity.record.sessionIds.includes(branchId))
300
+ const result = await relocateSession({ root, dir: dirs[0], newCwd: target.path, sessionId: branchId, backupRoot })
301
+ await reconcile(ctx, registry, target, source, branchId, result.header, undefined)
302
+ carried.push({ sessionId: branchId, moved: true })
303
+ } catch (error) {
304
+ ctx.logger?.warn(`dsh-session-drag-move: could not carry branch '${branchId}': ${error?.message ?? error}`)
305
+ carried.push({ sessionId: branchId, moved: false, error: error?.message ?? String(error) })
306
+ }
307
+ }
308
+ return carried
309
+ }
310
+
311
+ const methods = {
312
+ async move(ctx, config, payload) {
313
+ return await move(ctx, config, payload)
314
+ },
315
+ async status(ctx, config) {
316
+ const registry = serviceOf(ctx, 'workspaceRegistry')
317
+ return {
318
+ ok: registry !== undefined,
319
+ root: config.root === '' ? join(homedir(), '.dsh', 'sessions') : config.root
320
+ }
321
+ }
322
+ }
323
+
324
+ function apply(ctx, config = {}) {
325
+ const resolved = { ...DEFAULTS, ...config }
326
+ const webServer = ctx.webServer
327
+ if (webServer === undefined) {
328
+ ctx.logger?.warn('dsh-session-drag-move: no webServer service; the move API is not mounted')
329
+ return
330
+ }
331
+
332
+ ctx.effect(() => webServer.register({
333
+ kind: 'prefix',
334
+ path: '/session-drag-move/api',
335
+ handler: async (req, res) => {
336
+ if (!trusted(req, resolved.allowRemote)) return json(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
337
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
338
+
339
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
340
+ const method = pathname.startsWith(API_PREFIX) ? pathname.slice(API_PREFIX.length) : undefined
341
+ if (method === undefined || method.includes('/')) return json(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method' } })
342
+
343
+ try {
344
+ const handler = methods[method]
345
+ if (handler === undefined) throw new ApiError('not-found', `unknown method "${method}"`, 404)
346
+ const payload = await readBody(req)
347
+ json(res, 200, { ok: true, result: await handler(ctx, resolved, payload) })
348
+ } catch (error) {
349
+ const status = error instanceof ApiError ? error.status : 500
350
+ const code = error instanceof ApiError ? error.code : 'internal'
351
+ ctx.logger?.warn(`dsh-session-drag-move: ${method} failed: ${error?.message ?? error}`)
352
+ json(res, status, { ok: false, error: { code, message: error?.message ?? String(error) } })
353
+ }
354
+ }
355
+ }), 'dsh-session-drag-move: /session-drag-move/api routes')
356
+ }
357
+
358
+ export { apply }
@@ -0,0 +1,461 @@
1
+ /**
2
+ * dsh-session-drag-move — session log file helpers.
3
+ *
4
+ * The shipped session store is a per-session directory of Zstandard frames:
5
+ *
6
+ * <root>/--<projectKey(cwd)>--/<sessionId>/session.vN.jsonl[.zstd]
7
+ *
8
+ * The FIRST frame is the physical header (one JSONL line carrying `cwd`),
9
+ * every later frame is an appended batch. Frames are independent, so moving a
10
+ * session to another project is "re-encode frame 0 with a new cwd, then copy
11
+ * every byte after it verbatim" — no event is ever re-serialized.
12
+ *
13
+ * `projectKey` and `encodeSegment` are transcribed from
14
+ * @deepseek-ai/dsh-session-persistence-jsonl (lib/index.js) so a target
15
+ * directory is byte-identical to the one the backend itself would choose.
16
+ */
17
+ import { constants } from 'node:fs'
18
+ import { access, mkdir, open, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises'
19
+ import { join } from 'node:path'
20
+ import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
21
+
22
+ /** Zstandard frame magic number, `0x28B52FFD` little-endian. */
23
+ const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
24
+
25
+ /**
26
+ * Build the readable directory key for a project path.
27
+ * @param {string} cwd - absolute project directory.
28
+ * @returns {string} one filesystem-safe directory name, `--…--`.
29
+ */
30
+ export function projectKey(cwd) {
31
+ if (cwd.length === 0) throw new Error('cannot encode an empty project path')
32
+ let readable = ''
33
+ let separatorRun = false
34
+ for (let i = 0; i < cwd.length; i++) {
35
+ const code = cwd.charCodeAt(i)
36
+ const ch = String.fromCharCode(code)
37
+ if (ch === '/' || ch === '\\' || ch === ':') {
38
+ if (!separatorRun) readable += '-'
39
+ separatorRun = true
40
+ } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
41
+ readable += ch
42
+ separatorRun = false
43
+ } else {
44
+ readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
45
+ separatorRun = false
46
+ }
47
+ }
48
+ return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`
49
+ }
50
+
51
+ /**
52
+ * Encode a string as one safe path segment (session ids are unbranded input).
53
+ * @param {string} raw - non-empty string to encode.
54
+ * @returns {string} escaped segment; `session-<uuid>` round-trips unchanged.
55
+ */
56
+ export function encodeSegment(raw) {
57
+ if (raw.length === 0) throw new Error('cannot encode an empty path segment')
58
+ if (raw === '.') return '~002E'
59
+ if (raw === '..') return '~002E~002E'
60
+ let out = ''
61
+ for (let i = 0; i < raw.length; i++) {
62
+ const code = raw.charCodeAt(i)
63
+ const ch = String.fromCharCode(code)
64
+ if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch
65
+ else out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
66
+ }
67
+ return out
68
+ }
69
+
70
+ /**
71
+ * Inverse of {@link encodeSegment}: recover the id a directory name encodes.
72
+ *
73
+ * Exact inverse only for strings this encoder produced; `undefined` means the
74
+ * segment is not valid encoded output, which is how a foreign directory under
75
+ * the store root is skipped rather than mistaken for a session.
76
+ *
77
+ * @param {string} segment - one directory name under a project directory.
78
+ * @returns {string|undefined} the decoded id, or `undefined` when malformed.
79
+ */
80
+ export function decodeSegment(segment) {
81
+ if (segment.length === 0) return undefined
82
+ let out = ''
83
+ for (let i = 0; i < segment.length; ) {
84
+ const ch = segment[i]
85
+ if (ch !== '~') {
86
+ if (!/^[A-Za-z0-9._-]$/.test(ch)) return undefined
87
+ out += ch
88
+ i += 1
89
+ continue
90
+ }
91
+ const hex = segment.slice(i + 1, i + 5)
92
+ if (!/^[0-9A-F]{4}$/.test(hex)) return undefined
93
+ out += String.fromCharCode(Number.parseInt(hex, 16))
94
+ i += 5
95
+ }
96
+ // Re-encode to confirm this segment is canonical (e.g. `~0041` must be `A`).
97
+ return encodeSegment(out) === segment ? out : undefined
98
+ }
99
+
100
+ /** @param {string} root @param {string} cwd @returns {string} project directory. */
101
+ export function projectDir(root, cwd) {
102
+ return join(root, projectKey(cwd))
103
+ }
104
+
105
+ /** @param {string} root @param {string} cwd @param {string} id @returns {string} */
106
+ export function sessionDir(root, cwd, id) {
107
+ return join(projectDir(root, cwd), encodeSegment(id))
108
+ }
109
+
110
+ /** Whether a path exists at all. */
111
+ const exists = async (p) => {
112
+ try {
113
+ await access(p, constants.F_OK)
114
+ return true
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ /** Whether a path exists and is a directory. */
121
+ const isDir = async (p) => {
122
+ try {
123
+ return (await stat(p)).isDirectory()
124
+ } catch {
125
+ return false
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Locate a session's directory on disk. The direct `projectKey(cwd)` guess is
131
+ * tried first; a scan of `<root>/*` covers a cwd that was normalized
132
+ * differently when the log was written.
133
+ *
134
+ * @param {string} root - session store root.
135
+ * @param {string} cwd - the session's recorded cwd.
136
+ * @param {string} sessionId - session id.
137
+ * @returns {Promise<string|undefined>} the directory, when it exists.
138
+ */
139
+ export async function locateSessionDir(root, cwd, sessionId) {
140
+ const direct = sessionDir(root, cwd, sessionId)
141
+ if (await isDir(direct)) return direct
142
+ let entries
143
+ try {
144
+ entries = await readdir(root, { withFileTypes: true })
145
+ } catch {
146
+ return undefined
147
+ }
148
+ for (const entry of entries) {
149
+ if (!entry.isDirectory()) continue
150
+ const candidate = join(root, entry.name, encodeSegment(sessionId))
151
+ if (await isDir(candidate)) return candidate
152
+ }
153
+ return undefined
154
+ }
155
+
156
+ /** Physical generation number parsed out of `session.vN.jsonl[.zstd]`. */
157
+ const generationOf = (name) => {
158
+ const match = /^session(?:\.v(\d+))?\.jsonl(?:\.zstd)?$/.exec(name)
159
+ if (match === null) return undefined
160
+ return match[1] === undefined ? 0 : Number(match[1])
161
+ }
162
+
163
+ /**
164
+ * The session's current log file — highest released generation, like the
165
+ * backend's own selection.
166
+ * @param {string} dir - session directory.
167
+ * @returns {Promise<{path: string, compressed: boolean}|undefined>}
168
+ */
169
+ export async function currentLogFile(dir) {
170
+ const entries = await readdir(dir, { withFileTypes: true })
171
+ let best
172
+ let bestGeneration = -1
173
+ for (const entry of entries) {
174
+ if (!entry.isFile()) continue
175
+ const generation = generationOf(entry.name)
176
+ if (generation === undefined || generation <= bestGeneration) continue
177
+ best = entry.name
178
+ bestGeneration = generation
179
+ }
180
+ if (best === undefined) return undefined
181
+ return { path: join(dir, best), compressed: best.endsWith('.zstd') }
182
+ }
183
+
184
+ /** Every offset where a Zstandard frame magic begins. */
185
+ function magicOffsets(buf) {
186
+ const offsets = []
187
+ let at = 0
188
+ for (;;) {
189
+ const hit = buf.indexOf(MAGIC, at)
190
+ if (hit === -1) return offsets
191
+ offsets.push(hit)
192
+ at = hit + 4
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Read the log's physical header without touching anything after it.
198
+ *
199
+ * Frame boundaries are found by magic scan and then VERIFIED: a candidate end
200
+ * counts only when the bytes before it decompress to a JSONL line that is a
201
+ * valid session header for `expectId`. A magic that happens to occur inside
202
+ * compressed data is therefore skipped instead of truncating the log.
203
+ *
204
+ * @param {Buffer} buf - whole log file.
205
+ * @param {string} expectId - session id the header must name.
206
+ * @returns {{frameEnd: number, header: object}|undefined}
207
+ */
208
+ export function readHeader(buf, expectId) {
209
+ const candidates = magicOffsets(buf).slice(1)
210
+ candidates.push(buf.length)
211
+ for (const end of candidates) {
212
+ try {
213
+ const text = zstdDecompressSync(buf.subarray(0, end)).toString('utf8')
214
+ const line = text.split('\n')[0]
215
+ const header = JSON.parse(line)
216
+ if (header?.type !== 'session') continue
217
+ if (typeof header.id === 'string' && header.id !== expectId) continue
218
+ if (typeof header.cwd !== 'string') continue
219
+ return { frameEnd: end, header }
220
+ } catch {
221
+ /* not a frame boundary, or not a session header: try the next candidate */
222
+ }
223
+ }
224
+ return undefined
225
+ }
226
+
227
+ /**
228
+ * Every project directory currently holding this session.
229
+ *
230
+ * A cwd-agnostic scan, deliberately: the stored header is what says where a
231
+ * session *belongs*, but the directory listing is what says where its bytes
232
+ * *are*, and a move that failed mid-way leaves those two disagreeing. Returns
233
+ * every hit so the caller can notice a duplicate instead of silently picking
234
+ * one.
235
+ *
236
+ * @param {string} root - session store root.
237
+ * @param {string} sessionId - session id.
238
+ * @returns {Promise<string[]>} matching session directories, in scan order.
239
+ */
240
+ export async function findSessionDirs(root, sessionId) {
241
+ let entries
242
+ try {
243
+ entries = await readdir(root, { withFileTypes: true })
244
+ } catch {
245
+ return []
246
+ }
247
+ const found = []
248
+ for (const entry of entries) {
249
+ if (!entry.isDirectory()) continue
250
+ const candidate = join(root, entry.name, encodeSegment(sessionId))
251
+ if (await isDir(candidate)) found.push(candidate)
252
+ }
253
+ return found
254
+ }
255
+
256
+ /**
257
+ * Every session id present in the store, with the directory holding it.
258
+ *
259
+ * Reads directory names only — no header decode — so it stays cheap enough to
260
+ * run on every move. `encodeSegment` is injective, so a directory name decodes
261
+ * back to exactly one id.
262
+ *
263
+ * @param {string} root - session store root.
264
+ * @returns {Promise<Map<string, string>>} sessionId → session directory.
265
+ */
266
+ export async function indexStore(root) {
267
+ const index = new Map()
268
+ let projects
269
+ try {
270
+ projects = await readdir(root, { withFileTypes: true })
271
+ } catch {
272
+ return index
273
+ }
274
+ for (const project of projects) {
275
+ if (!project.isDirectory()) continue
276
+ let sessions
277
+ try {
278
+ sessions = await readdir(join(root, project.name), { withFileTypes: true })
279
+ } catch {
280
+ continue
281
+ }
282
+ for (const session of sessions) {
283
+ if (!session.isDirectory()) continue
284
+ const id = decodeSegment(session.name)
285
+ if (id === undefined) continue
286
+ // First hit wins; a duplicate id in two project directories is the
287
+ // caller's business (relocateSession reports it).
288
+ if (!index.has(id)) index.set(id, join(root, project.name, session.name))
289
+ }
290
+ }
291
+ return index
292
+ }
293
+
294
+ /**
295
+ * Read only a log's header, from a bounded prefix of the file.
296
+ *
297
+ * The header is the log's first frame and runs to a couple hundred bytes, so
298
+ * building the fork tree over a whole store must not read every session's full
299
+ * multi-megabyte log. {@link readHeader} verifies the frame boundary, so a
300
+ * truncated read either yields the header or yields nothing — never a
301
+ * half-decoded one.
302
+ *
303
+ * @param {string} dir - session directory.
304
+ * @param {string} sessionId - session id.
305
+ * @param {number} [maxBytes] - how much of the file to read.
306
+ * @returns {Promise<object|undefined>} the stored header, when readable.
307
+ */
308
+ export async function readHeaderPrefix(dir, sessionId, maxBytes = 8192) {
309
+ const log = await currentLogFile(dir)
310
+ if (log === undefined) return undefined
311
+ let handle
312
+ try {
313
+ handle = await open(log.path, 'r')
314
+ const buffer = Buffer.alloc(maxBytes)
315
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0)
316
+ const prefix = buffer.subarray(0, bytesRead)
317
+ if (log.compressed) return readHeader(prefix, sessionId)?.header
318
+ return readRawHeader(prefix, sessionId).header
319
+ } catch {
320
+ return undefined
321
+ } finally {
322
+ await handle?.close()
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Read a session directory's current log and stored header, without writing.
328
+ *
329
+ * @param {string} dir - session directory.
330
+ * @param {string} sessionId - session id.
331
+ * @returns {Promise<{log: {path: string, compressed: boolean}, header: object, buf: Buffer}|undefined>}
332
+ */
333
+ export async function readSessionState(dir, sessionId) {
334
+ const log = await currentLogFile(dir)
335
+ if (log === undefined) return undefined
336
+ const buf = await readFile(log.path)
337
+ try {
338
+ const found = log.compressed ? readHeader(buf, sessionId) : readRawHeader(buf, sessionId)
339
+ if (found === undefined) return undefined
340
+ return { log, header: found.header, buf }
341
+ } catch {
342
+ return undefined
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Rebuild a log with the same header except for a new `cwd`.
348
+ *
349
+ * @param {Buffer} buf - original log bytes.
350
+ * @param {string} newCwd - the project directory the session moves to.
351
+ * @param {string} sessionId - session id.
352
+ * @returns {{content: Buffer, header: object, frameEnd: number}}
353
+ */
354
+ export function rewriteHeaderCwd(buf, newCwd, sessionId) {
355
+ const found = readHeader(buf, sessionId)
356
+ if (found === undefined) throw new Error(`session log holds no readable header for '${sessionId}'`)
357
+ const header = { ...found.header, cwd: newCwd }
358
+ const line = Buffer.from(`${JSON.stringify(header)}\n`, 'utf8')
359
+ return {
360
+ content: Buffer.concat([zstdCompressSync(line), buf.subarray(found.frameEnd)]),
361
+ header,
362
+ frameEnd: found.frameEnd
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Read the plain-text (compression: none) first line as a header.
368
+ * @param {Buffer} buf @param {string} sessionId
369
+ */
370
+ export function readRawHeader(buf, sessionId) {
371
+ const newline = buf.indexOf(0x0a)
372
+ const text = buf.subarray(0, newline === -1 ? buf.length : newline).toString('utf8')
373
+ const header = JSON.parse(text)
374
+ if (header?.type !== 'session' || header.id !== sessionId) throw new Error('not a session header')
375
+ return { frameEnd: newline === -1 ? buf.length : newline + 1, header }
376
+ }
377
+
378
+ /**
379
+ * Re-encode a log's header with a new cwd.
380
+ * @param {Buffer} buf @param {string} newCwd @param {string} sessionId
381
+ * @param {boolean} compressed
382
+ * @returns {{content: Buffer, header: object}}
383
+ */
384
+ function rewriteHeader(buf, newCwd, sessionId, compressed) {
385
+ if (compressed) return rewriteHeaderCwd(buf, newCwd, sessionId)
386
+ const found = readRawHeader(buf, sessionId)
387
+ return {
388
+ content: Buffer.concat([
389
+ Buffer.from(`${JSON.stringify({ ...found.header, cwd: newCwd })}\n`, 'utf8'),
390
+ buf.subarray(found.frameEnd)
391
+ ]),
392
+ header: { ...found.header, cwd: newCwd }
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Move a session's bytes into the project directory of `newCwd`, rewriting the
398
+ * stored header's cwd on the way.
399
+ *
400
+ * Idempotent by construction: when the session is already in the target
401
+ * directory it rewrites the header in place instead of copying onto itself,
402
+ * and an occupied destination is a reported conflict rather than an
403
+ * overwrite. The old directory is relocated (never copied-and-deleted), so a
404
+ * crash can leave at most one of the two.
405
+ *
406
+ * @param {object} args
407
+ * @param {string} args.root - session store root.
408
+ * @param {string} args.dir - the session directory found on disk.
409
+ * @param {string} args.newCwd - project directory to move the session to.
410
+ * @param {string} args.sessionId
411
+ * @param {string} args.backupRoot - where the pre-move directory is parked.
412
+ * @returns {Promise<{header: object, from: string, to: string, backup?: string, relocated: boolean}>}
413
+ */
414
+ export async function relocateSession({ root, dir, newCwd, sessionId, backupRoot }) {
415
+ const state = await readSessionState(dir, sessionId)
416
+ if (state === undefined) throw new Error(`session directory '${dir}' holds no readable session log`)
417
+
418
+ const { log, buf } = state
419
+ const name = log.path.slice(dir.length + 1)
420
+ const to = sessionDir(root, newCwd, sessionId)
421
+
422
+ // Bytes already sit in the target project directory: rewrite the header
423
+ // where it lies, whether or not the header agrees yet. This is the repair
424
+ // path for a move that landed the bytes and then failed before the header
425
+ // (or the ledger) caught up — the exact state that used to collide with
426
+ // itself and fail EEXIST on a re-drop.
427
+ if (to === dir) {
428
+ const { content, header } = rewriteHeader(buf, newCwd, sessionId, log.compressed)
429
+ if (!buf.equals(content)) await writeFile(log.path, content)
430
+ return { header, from: dir, to, relocated: false }
431
+ }
432
+
433
+ // A different directory already holds a log for this session: refuse rather
434
+ // than clobber it.
435
+ if ((await isDir(to)) && (await exists(join(to, name)))) {
436
+ throw new Error(`destination already holds a log for '${sessionId}': ${join(to, name)}`)
437
+ }
438
+
439
+ const { content, header } = rewriteHeader(buf, newCwd, sessionId, log.compressed)
440
+ if (log.compressed) {
441
+ const again = readHeader(content, sessionId)
442
+ if (again === undefined || again.header.cwd !== newCwd || again.header.id !== sessionId) {
443
+ throw new Error('rewritten session log failed its own header re-read')
444
+ }
445
+ }
446
+
447
+ await mkdir(to, { recursive: true })
448
+ await writeFile(join(to, name), content, { flag: 'wx' })
449
+
450
+ // Everything else the session owns (older generations, side artifacts).
451
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
452
+ if (entry.name === name) continue
453
+ await rename(join(dir, entry.name), join(to, entry.name))
454
+ }
455
+
456
+ await mkdir(backupRoot, { recursive: true })
457
+ const backup = join(backupRoot, `${sessionId}-${Date.now()}`)
458
+ await rename(dir, backup)
459
+
460
+ return { header, from: dir, to, backup, relocated: true }
461
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "dsh-session-drag-move",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "宸︿晶鏍忓伐浣滃尯锛氭妸浼氳瘽鎷栧埌鍒殑宸ヤ綔鍖猴紝鐪熸鏀瑰彉褰掑睘锛堟敼鍐欎細璇濇棩蹇?header 鐨?cwd 骞惰縼绉讳細璇濈洰褰曪級銆侱rag a session onto another workspace in the DSH sidebar and it really moves: the session log header's cwd is rewritten and the log is re-homed under the target project.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "engines": {
13
+ "node": ">=22",
14
+ "dsh": ">=0.1.6-alpha.2"
15
+ },
16
+ "dsh": {
17
+ "bundle": {
18
+ "patch": "./cordis.patch.yml"
19
+ },
20
+ "client": {
21
+ "platform": "web"
22
+ }
23
+ },
24
+ "files": [
25
+ "lib/index.js",
26
+ "lib/client.js",
27
+ "lib/session-log.js",
28
+ "lib/fork-tree.js",
29
+ "cordis.patch.yml",
30
+ "README.md"
31
+ ],
32
+ "keywords": [
33
+ "dsh",
34
+ "deepseek-harness",
35
+ "dsh-plugin",
36
+ "sidebar",
37
+ "workspace",
38
+ "session",
39
+ "drag-and-drop"
40
+ ],
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/FurryBear2025/dsh-session-drag-move.git"
45
+ },
46
+ "homepage": "https://github.com/FurryBear2025/dsh-session-drag-move#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/FurryBear2025/dsh-session-drag-move/issues"
49
+ }
6
50
  }