dsh-conversation-recycle-bin 0.2.0 → 0.2.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.
@@ -65,10 +65,17 @@ export function createSessionLogOps({ gateway, paths, config, now = Date.now, de
65
65
  * Before moving a log this process believes is cold, re-check the newest
66
66
  * write after a short grace window: a log that keeps growing is refused
67
67
  * instead of being moved out from under the other writer.
68
+ *
69
+ * Live sessions are NOT exempt: `quiesce` (which runs before this step in
70
+ * the cascade) only stops THIS process's Agent. A conversation open in a
71
+ * second Host is exactly the writer this check must catch, and it is also
72
+ * the one this process sees as `live`, so the recheck runs for every mode.
73
+ * The grace window is normally already satisfied at that point, which makes
74
+ * the extra cost one mtime scan.
68
75
  */
69
76
  async function assertNoForeignWriter(session, directory) {
70
77
  const grace = Number.isFinite(config?.foreignWriterGraceMs) ? config.foreignWriterGraceMs : 0
71
- if (grace <= 0 || session.live === true) return
78
+ if (grace <= 0) return
72
79
  const first = await latestWriteMs(directory)
73
80
  if (first === null) return
74
81
  const age = now() - first
@@ -135,6 +142,45 @@ export function createCatalogOps({ ctx, now = Date.now }) {
135
142
  return registry().get(snapshot.workspaceId) ?? null
136
143
  }
137
144
 
145
+ function isArchived(service, sessionId) {
146
+ try {
147
+ return Array.isArray(service.archivedSessionIds) && service.archivedSessionIds.includes(sessionId)
148
+ } catch {
149
+ return false
150
+ }
151
+ }
152
+
153
+ function isPinned(service, sessionId) {
154
+ try {
155
+ return Array.isArray(service.pinnedSessionIds) && service.pinnedSessionIds.includes(sessionId)
156
+ } catch {
157
+ return false
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Put a session back at the position its snapshot recorded.
163
+ *
164
+ * `insertSessionBefore` requires the session to be accounted (the real Host
165
+ * throws `WorkspaceMoveInvalidError` otherwise), so this helper attaches
166
+ * first and only then moves it. Position is cosmetic: a failed move leaves
167
+ * the session attached, which is still correct.
168
+ */
169
+ async function restorePosition(workspace, sessionId, nextSessionId) {
170
+ if (workspace === null) return
171
+ if (!workspace.sessionIds.includes(sessionId)) await workspace.attachSession(sessionId)
172
+ try {
173
+ if (nextSessionId === null || nextSessionId === undefined) {
174
+ await workspace.insertSessionBefore(sessionId, undefined)
175
+ } else if (workspace.sessionIds.includes(nextSessionId)) {
176
+ await workspace.insertSessionBefore(sessionId, nextSessionId)
177
+ }
178
+ } catch {
179
+ // The anchor disappeared or the workspace refused the move; the session
180
+ // is still attached, so only the ordering is lost.
181
+ }
182
+ }
183
+
138
184
  return {
139
185
  /** Capture the exact catalog state before any mutation. */
140
186
  async capture(session) {
@@ -177,25 +223,26 @@ export function createCatalogOps({ ctx, now = Date.now }) {
177
223
  }
178
224
 
179
225
  // Hard mode performs several mutations. Each one pushes its own inverse
180
- // as it commits, so a failure halfway (e.g. unarchive succeeds but detach
181
- // is refused) can put back exactly what this call changed instead of
182
- // leaving the conversation visibly unarchived/unpinned after a rolled
183
- // back deletion.
226
+ // as it commits, so a failure halfway can put back exactly what this call
227
+ // changed instead of leaving the conversation visibly unarchived,
228
+ // unpinned or moved after a rolled back deletion.
229
+ const wasArchived = isArchived(service, session.id)
230
+ const wasPinned = isPinned(service, session.id)
184
231
  const undone = []
185
232
  const undoAll = async () => {
186
233
  for (const step of [...undone].reverse()) await step()
187
234
  }
188
235
  try {
189
- // Unarchive to drop any archive marker (including the one quiesce adds
190
- // for a live session) — EXCEPT for a live Agent: its pending inbox may
236
+ // Drop an existing archive marker (including the one quiesce adds for
237
+ // a live session) — EXCEPT for a live Agent: its pending inbox may
191
238
  // still wake after this deletion. Keeping the archive gate as a
192
239
  // tombstone makes that wake a blocked pre-step instead of a writer that
193
240
  // recreates the moved log. Restore clears the tombstone.
194
- if (session.live !== true) {
241
+ if (session.live !== true && wasArchived) {
195
242
  await service.unarchiveSession(session.id)
196
- if (snapshot.archived) undone.push(async () => { await service.archiveSession(session.id, { stopActivity: true }) })
243
+ undone.push(async () => { await service.archiveSession(session.id, { stopActivity: true }) })
197
244
  }
198
- if (snapshot.pinned) {
245
+ if (wasPinned) {
199
246
  await service.unpinSession(session.id)
200
247
  undone.push(async () => { await service.pinSession(session.id) })
201
248
  }
@@ -205,11 +252,7 @@ export function createCatalogOps({ ctx, now = Date.now }) {
205
252
  undone.push(async () => {
206
253
  const current = workspaceOf(snapshot)
207
254
  if (current === null) return
208
- try {
209
- await current.insertSessionBefore(session.id, snapshot.nextSessionId ?? undefined)
210
- } catch {
211
- await current.attachSession(session.id)
212
- }
255
+ await restorePosition(current, session.id, snapshot.nextSessionId ?? null)
213
256
  })
214
257
  }
215
258
  } catch (error) {
@@ -221,39 +264,61 @@ export function createCatalogOps({ ctx, now = Date.now }) {
221
264
 
222
265
  /**
223
266
  * Rebuild the catalog state captured by `capture` (used by trash restore).
267
+ *
268
+ * The undo only reverts mutations THIS call made. A soft-deleted session is
269
+ * still attached to its workspace; an unconditional detach in the undo used
270
+ * to remove it from the workspace when a later (memory) step failed.
271
+ *
224
272
  * @returns {Promise<() => Promise<void>>} undo
225
273
  */
226
274
  async restore(snapshot) {
227
275
  const service = registry()
276
+ const sessionId = snapshot.sessionId
277
+ const workspace = workspaceOf(snapshot)
278
+ const wasAttached = workspace !== null && workspace.sessionIds.includes(sessionId)
279
+ const wasArchived = isArchived(service, sessionId)
280
+ const wasPinned = isPinned(service, sessionId)
281
+ const changed = new Set()
228
282
  try {
229
- const workspace = workspaceOf(snapshot)
230
- if (workspace !== null && !workspace.sessionIds.includes(snapshot.sessionId)) {
231
- try {
232
- await workspace.insertSessionBefore(snapshot.sessionId, snapshot.nextSessionId ?? undefined)
233
- } catch {
234
- await workspace.attachSession(snapshot.sessionId)
235
- }
283
+ if (workspace !== null && !wasAttached) {
284
+ await workspace.attachSession(sessionId)
285
+ changed.add('attached')
236
286
  }
237
- if (snapshot.pinned) await service.pinSession(snapshot.sessionId)
238
- if (snapshot.archived) {
239
- await service.archiveSession(snapshot.sessionId, { stopActivity: true })
240
- } else {
287
+ if (workspace !== null) await restorePosition(workspace, sessionId, snapshot.nextSessionId ?? null)
288
+ if (snapshot.pinned === true && !wasPinned) {
289
+ await service.pinSession(sessionId)
290
+ changed.add('pinned')
291
+ }
292
+ if (snapshot.archived === true) {
293
+ if (!wasArchived) {
294
+ await service.archiveSession(sessionId, { stopActivity: true })
295
+ changed.add('archived')
296
+ }
297
+ } else if (wasArchived) {
241
298
  // A soft delete archived the session; restoring an entry whose
242
- // pre-delete state was NOT archived must undo that archive. The call
243
- // is idempotent, so a hard-delete entry (never archived here) is a
244
- // no-op.
245
- await service.unarchiveSession(snapshot.sessionId)
299
+ // pre-delete state was NOT archived must undo that archive.
300
+ await service.unarchiveSession(sessionId)
301
+ changed.add('unarchived')
246
302
  }
247
303
  return async () => {
248
- const current = workspaceOf(snapshot)
249
- if (current !== null && current.sessionIds.includes(snapshot.sessionId)) {
250
- await current.detachSession(snapshot.sessionId)
304
+ if (changed.has('unarchived')) {
305
+ await service.archiveSession(sessionId, { stopActivity: true }).catch(() => {})
306
+ }
307
+ if (changed.has('archived')) {
308
+ await service.unarchiveSession(sessionId).catch(() => {})
309
+ }
310
+ if (changed.has('pinned')) {
311
+ await service.unpinSession(sessionId).catch(() => {})
312
+ }
313
+ if (changed.has('attached')) {
314
+ const current = workspaceOf(snapshot)
315
+ if (current !== null && current.sessionIds.includes(sessionId)) {
316
+ await current.detachSession(sessionId).catch(() => {})
317
+ }
251
318
  }
252
- if (snapshot.pinned) await service.unpinSession(snapshot.sessionId)
253
- if (snapshot.archived) await service.unarchiveSession(snapshot.sessionId)
254
319
  }
255
320
  } catch (error) {
256
- throw wrapCatalogError(error, 'restore', snapshot.sessionId)
321
+ throw wrapCatalogError(error, 'restore', sessionId)
257
322
  }
258
323
  },
259
324
  }
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { findSessionDir, readJsonFile } from './paths.js'
17
17
  import { join } from 'node:path'
18
+ import { isPathInside } from '../core/policy.js'
18
19
 
19
20
  const LIST_TTL_MS = 1_500
20
21
  const STATE_TTL_MS = 1_500
@@ -126,10 +127,21 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
126
127
  if (!workspaceBySession.has(sessionId)) workspaceBySession.set(sessionId, { workspace, index })
127
128
  })
128
129
  }
130
+ // Ownership is also decided by cwd containment, exactly like `inspect`'s
131
+ // `workspaceFor` fallback: a session whose directory moved out of every
132
+ // registered workspace is an orphan, and `/sessions` must say so per row.
133
+ // Without this, `evaluateSessionPolicy` saw `workspace: undefined` on every
134
+ // list row and marked the whole batch picker non-deletable.
135
+ const workspaceByCwd = (cwd) => {
136
+ if (typeof cwd !== 'string' || cwd === '') return null
137
+ return workspaces.find((workspace) => isPathInside(workspace.path, cwd)) ?? null
138
+ }
129
139
  const titles = await readTitles()
130
140
  const state = await readWorkspaceState()
131
141
  listCache = items.map((item) => {
132
- const owned = workspaceBySession.get(item.sessionId) ?? null
142
+ const member = workspaceBySession.get(item.sessionId) ?? null
143
+ const byCwd = member === null ? workspaceByCwd(item.cwd) : null
144
+ const owned = member ?? (byCwd === null ? null : { workspace: byCwd, index: -1 })
133
145
  const cached = titles.get(item.sessionId) ?? { title: '', blank: item.blank === true }
134
146
  return {
135
147
  id: String(item.sessionId),
@@ -145,7 +157,9 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
145
157
  updatedAt: item.updatedAt ?? null,
146
158
  events: null,
147
159
  sizeBytes: null,
160
+ workspace: owned === null ? null : { id: owned.workspace.id, title: owned.workspace.title, path: owned.workspace.path, index: owned.index },
148
161
  workspaceId: owned?.workspace?.id ?? null,
162
+ workspacePath: owned?.workspace?.path ?? null,
149
163
  workspaceIndex: owned?.index ?? -1,
150
164
  }
151
165
  })
@@ -169,9 +183,9 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
169
183
  if (byId !== undefined) return byId
170
184
  const byCwd = workspaces.find((workspace) => {
171
185
  if (typeof workspace.path !== 'string' || typeof session.cwd !== 'string') return false
172
- const root = workspace.path.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase()
173
- const cwd = session.cwd.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase()
174
- return cwd === root || cwd.startsWith(`${root}/`)
186
+ // Shared path semantics: drive-letter paths compare case-insensitively,
187
+ // POSIX paths stay case-sensitive, and UNC roots are handled like Windows.
188
+ return isPathInside(workspace.path, session.cwd)
175
189
  })
176
190
  return byCwd ?? null
177
191
  }
@@ -182,7 +196,7 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
182
196
 
183
197
  /** Normalize one session; throws E_NOT_FOUND-shaped data as `null`. */
184
198
  async inspect(sessionId) {
185
- if (typeof sessionId !== 'string' || !/^[A-Za-z0-9_-]{6,80}$/.test(sessionId)) return null
199
+ if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{6,80}$/.test(sessionId)) return null
186
200
  const persistence = service('sessionPersistence')
187
201
  let snapshot
188
202
  if (persistence !== undefined && typeof persistence.stat === 'function') {
package/lib/host/paths.js CHANGED
@@ -12,11 +12,22 @@
12
12
  */
13
13
  import { cp, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises'
14
14
  import { homedir } from 'node:os'
15
- import { dirname, join } from 'node:path'
15
+ import { dirname, join, resolve } from 'node:path'
16
16
 
17
+ /**
18
+ * Resolve the harness home exactly like `@deepseek-ai/dsh-home-paths`:
19
+ * explicit `$DSH_HOME` (with `~` expansion) wins over `~/.dsh`, and the result
20
+ * is absolute. A blank override counts as unset and must never resolve to the
21
+ * current working directory.
22
+ */
17
23
  export function resolveDshHome(env = process.env, home = homedir()) {
18
24
  const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
19
- return configured !== '' ? configured : join(home, '.dsh')
25
+ if (configured === '') return join(home, '.dsh')
26
+ if (configured === '~') return resolve(home)
27
+ if (configured.startsWith('~/') || configured.startsWith('~\\')) {
28
+ return resolve(join(home, configured.slice(2)))
29
+ }
30
+ return resolve(configured)
20
31
  }
21
32
 
22
33
  /**
package/lib/version.js CHANGED
@@ -5,4 +5,4 @@
5
5
  * asserted against it by `test/manifest.test.mjs` so the two can never drift
6
6
  * (they did: the constant stayed at 0.1.0 while the package reached 0.1.4).
7
7
  */
8
- export const PLUGIN_VERSION = '0.2.0'
8
+ export const PLUGIN_VERSION = '0.2.2'
package/package.json CHANGED
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/HERO476/dsh-conversation-recycle-bin.git"
8
+ },
9
+ "homepage": "https://github.com/HERO476/dsh-conversation-recycle-bin#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/HERO476/dsh-conversation-recycle-bin/issues"
12
+ },
13
+ "author": "HERO476",
5
14
  "type": "module",
6
15
  "main": "lib/index.js",
7
16
  "exports": {
@@ -9,7 +18,6 @@
9
18
  "./client": "./client.js",
10
19
  "./cordis.patch.yml": "./cordis.patch.yml",
11
20
  "./package.json": "./package.json",
12
- "./locale/*.json": "./locale/*.json",
13
21
  "./icon": "./icon.svg"
14
22
  },
15
23
  "files": [
@@ -17,11 +25,11 @@
17
25
  "client.js",
18
26
  "cordis.patch.yml",
19
27
  "config.schema.json",
20
- "locale/*.json",
21
28
  "icon.svg",
22
29
  "README.md",
23
30
  "INSTALL.md",
24
31
  "CHANGELOG.md",
32
+ "AUDIT-2026-10-17-desktop-web.md",
25
33
  "LICENSE"
26
34
  ],
27
35
  "engines": {
package/locale/en.json DELETED
@@ -1,63 +0,0 @@
1
- {
2
- "meta": {
3
- "title": "Delete Conversation",
4
- "description": "Delete a conversation with an optional cascade into its associated memory. Soft delete and recycle bin, audit trail, batch rate limits, and per-session rollback."
5
- },
6
- "section": {
7
- "label": "Conversation Deletion",
8
- "title": "Conversation Deletion",
9
- "subtitle": "Recycle bin, batch deletion and audit trail."
10
- },
11
- "action": {
12
- "menuDelete": "Delete conversation…",
13
- "headerDelete": "Delete…",
14
- "delete": "Delete",
15
- "cancel": "Cancel",
16
- "close": "Close",
17
- "restore": "Restore",
18
- "purge": "Delete permanently",
19
- "refresh": "Refresh",
20
- "selectAll": "Select all"
21
- },
22
- "dialog": {
23
- "titleOne": "Delete this conversation?",
24
- "titleMany": "Delete {count} conversations?",
25
- "bodyOne": "\u201c{title}\u201d will be removed from the session list.",
26
- "bodyMany": "{count} conversations will be removed from the session list.",
27
- "modeSoft": "Move to recycle bin (recommended)",
28
- "modeSoftHint": "Files stay on disk and can be restored from the recycle bin.",
29
- "modeHard": "Delete permanently",
30
- "modeHardHint": "Moves the session log out of the live store. Recoverable until the recycle-bin entry is purged.",
31
- "memoryLabel": "Also delete this conversation\u2019s associated memory",
32
- "memoryHint": "Off by default. Memory entries are counted before you confirm.",
33
- "memoryCount": "{count} associated memory entries will be moved to the recycle bin",
34
- "memoryNone": "No associated memory entries were found",
35
- "memoryUnsupported": "Memory store not available: {reason}",
36
- "memoryProtected": "{count} protected entries are kept",
37
- "messages": "{events} records · {size}",
38
- "hardWarning": "Type DELETE to confirm permanent deletion.",
39
- "batchWarning": "Type the number {count} to confirm this batch.",
40
- "batchPhrase": "Batch size",
41
- "working": "Deleting…",
42
- "done": "Deleted {count} conversation(s).",
43
- "partial": "Deleted {done}, failed {failed}.",
44
- "memoryDeleted": "{count} memory entries moved to the recycle bin.",
45
- "error": "Deletion failed: {message}"
46
- },
47
- "trash": {
48
- "title": "Recycle bin",
49
- "empty": "The recycle bin is empty.",
50
- "deletedAt": "Deleted {time}",
51
- "items": "{sessions} conversations, {memories} memory entries",
52
- "restoreDone": "Restored.",
53
- "purgeDone": "Permanently deleted.",
54
- "confirmPurge": "Permanently delete this entry? This cannot be undone.",
55
- "auditTitle": "Audit trail",
56
- "auditEmpty": "No deletion has been recorded yet."
57
- },
58
- "batch": {
59
- "title": "Batch deletion",
60
- "hint": "Select conversations to delete. Deletion is per-conversation atomic; a failure rolls that conversation back.",
61
- "selected": "{count} selected"
62
- }
63
- }
package/locale/zh.json DELETED
@@ -1,63 +0,0 @@
1
- {
2
- "meta": {
3
- "title": "删除对话",
4
- "description": "删除对话,并可选择一并删除该对话的关联记忆;支持软删除、回收站、审计留痕、批量限流与失败回滚。"
5
- },
6
- "section": {
7
- "label": "对话删除",
8
- "title": "对话删除",
9
- "subtitle": "回收站、批量删除与审计留痕。"
10
- },
11
- "action": {
12
- "menuDelete": "删除对话…",
13
- "headerDelete": "删除…",
14
- "delete": "删除",
15
- "cancel": "取消",
16
- "close": "关闭",
17
- "restore": "恢复",
18
- "purge": "彻底删除",
19
- "refresh": "刷新",
20
- "selectAll": "全选"
21
- },
22
- "dialog": {
23
- "titleOne": "删除这个对话?",
24
- "titleMany": "删除 {count} 个对话?",
25
- "bodyOne": "「{title}」将从会话列表中移除。",
26
- "bodyMany": "{count} 个对话将从会话列表中移除。",
27
- "modeSoft": "移入回收站(推荐)",
28
- "modeSoftHint": "文件仍保留在磁盘上,可从回收站恢复。",
29
- "modeHard": "彻底删除",
30
- "modeHardHint": "把会话日志移出活动存储;回收站条目被清理前仍可恢复。",
31
- "memoryLabel": "同时删除该对话的关联记忆",
32
- "memoryHint": "默认不勾选。确认前会实时统计受影响的记忆条数。",
33
- "memoryCount": "将把 {count} 条关联记忆移入回收站",
34
- "memoryNone": "未找到关联记忆",
35
- "memoryUnsupported": "记忆存储不可用:{reason}",
36
- "memoryProtected": "另有 {count} 条受保护记忆将保留",
37
- "messages": "{events} 条记录 · {size}",
38
- "hardWarning": "彻底删除需输入 DELETE 确认。",
39
- "batchWarning": "批量删除需输入数字 {count} 确认。",
40
- "batchPhrase": "批量数量",
41
- "working": "正在删除…",
42
- "done": "已删除 {count} 个对话。",
43
- "partial": "成功 {done} 个,失败 {failed} 个。",
44
- "memoryDeleted": "已移入回收站的记忆条目:{count} 条。",
45
- "error": "删除失败:{message}"
46
- },
47
- "trash": {
48
- "title": "回收站",
49
- "empty": "回收站为空。",
50
- "deletedAt": "删除于 {time}",
51
- "items": "{sessions} 个对话、{memories} 条记忆",
52
- "restoreDone": "已恢复。",
53
- "purgeDone": "已彻底删除。",
54
- "confirmPurge": "彻底删除该条目?此操作不可撤销。",
55
- "auditTitle": "审计留痕",
56
- "auditEmpty": "尚无删除记录。"
57
- },
58
- "batch": {
59
- "title": "批量删除",
60
- "hint": "勾选要删除的对话。每个对话独立事务,失败时仅回滚该对话。",
61
- "selected": "已选 {count} 个"
62
- }
63
- }