dsh-plugin-session-cleaner 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -42,20 +42,35 @@ DSH 本身没有任何删除会话的途径:`sessionPersistence` 只有 `creat
42
42
  - **不能删除当前打开的会话**:UI 禁用入口,Host 侧独立再拒一次(`guard` 参数),Tool 也拒绝把自己所在的会话作为目标。
43
43
  - **运行中的会话先停止再删**:否则内存里的 Agent 会继续往刚删掉的日志追加写入,残留一个半截会话。若在超时内无法停止,**整个操作中止,一个文件都不删**。
44
44
  - **递归删除子代理会话**:通过日志头部的 `parentSession` 字段展开全部后代,避免产生永远无法访问的孤儿数据。
45
- - **先摘注册表、后删文件**,并且计划中的注册表引用先摘除,保证界面不会出现「看得见、点开报错」的空壳行。
45
+ - **注册表摘除走两条路**(见下节「删除如何立即反映到界面」):宿主的 `workspaceRegistry` 服务在线时,摘除通过 `unarchiveSession` / `detachSession` 走**内存注册表**(它同时是 `workspace.json` 在删除期间的唯一写者),并在文件删除**之后**执行;服务不可用时回退为删除前原子改写 `workspace.json` 文件,保证重启后不会出现「看得见、点开报错」的空壳行。
46
+
47
+ ## 删除如何立即反映到界面
48
+
49
+ DSH 侧边栏的会话行由两个数据源合成:行的**存在**来自客户端 `sessions` 服务的列表快照(只在连接建立时通过 `session/list` 拉取一次,而 Host 的 `session/list` 每次都实时扫盘),行的**分组/归档/置顶**来自 `workspaceController` 的 follow 流(内存注册表投影)。直接改磁盘文件两边都不会察觉——官方 README 明确把「外部变更延迟可见」列为已知限制。
50
+
51
+ 因此删除的收尾是两步正规通道:
52
+
53
+ 1. **Host 侧**(动态注入 `workspaceRegistry`,不在顶层 `inject` 里——缺服务的组合仍要能激活插件):对每个已删 id 先 `ctx.emit('api-session/removed', id)`——宿主 session-controller 释放会话时用的同一个事件,客户端收到后在**本地**移除该行,无 RPC 往返;随后依次 `unarchiveSession`(幂等,顺带清置顶互斥)、`unpinSession`(如置顶)、对记账实体 `detachSession`。emit 必须先于 registry 调用:detach 推送的 follow 增量会把仍留在快照里的行赶到「未分组」,直到 refresh 落地——先 emit,行在增量到达前就已消失。每次 registry 调用都更新内存注册表、持久化并向 follow 流推送增量;调用失败只记入结果的 `warnings`——文件已删,残留记账在下次启动时被宿主剪除,报「删除失败」反而误导。若网关不转发插件 emit 的该事件,行为退化为短暂的「未分组」闪现,不会更糟。
54
+ 2. **客户端**(动态注入 `sessions` 服务):删除成功后先调一次 `ctx.sessions.refresh()` 重新拉取 `session/list`,**等它落定后**再对每个已确认删除的 id 调一次 `ctx.sessions.handleSessionRemoved(id)`(官方转发事件 `api-session/removed` 的同一个公开处理器)。顺序是硬约束:`refreshList()` 启动时会换新变更数组,pull 之前记录的移除(比如转发事件带来的)会在 pull 落地时被丢弃;而 Host 的 `session/list` 对**仍 attach 在内存里的会话**无条件返回(`SessionCorpus.listSessions` 的 live 优先合并,agent.cancel 只停 turn 不 detach,detach 能力只在会话创建方手里)——所以单靠 refresh,刚删的行会被合并回来的 baseline 复活进「未分组」,直到重启。refresh 之后调用 `handleSessionRemoved` 则行当场消失,且移除变更留在当前变更数组里被**下一次** refresh 重放,对仍 attach 的被删会话有一轮抗复活保护。已确认删除的 id 会在面板里登记为「已忘」清单,客户端还挂着一个**快照守卫**:订阅 `sessions` 快照变更,凡「已忘」的 id 重新出现(重连重建 baseline、任何时序竞争的复活),立即再移除一次——按实际存在判定,行掉出快照后守卫自然收敛。批量删除**部分失败**时,已确认删除的 id 同样走这条链,不会因为整体报失败而把删掉的行留在「未分组」。与此同时,客户端内的**删除信号**会通知已打开的清理面板重扫——面板的列表是挂载时的扫描快照,不重扫的话菜单删除的行会残留到面板重开为止;面板工具栏的「重新扫描」按钮兜住模型 Tool 删除这类任何客户端都看不到的变更。面板**自己发起**的删除则再快一拍:RPC 确认返回的瞬间,已确认删除的行就按结果里的 `deleted` 清单从快照里乐观移除(选中集与展开态同步收缩),随后的静默重扫不闪「正在扫描」、不清报告,只负责对账——大 profile 上一次全盘扫盘不再把「行消失」拖到几百毫秒之后。
55
+
56
+ 先删文件、后摘注册表的顺序是有意的:行的存在由快照决定,若先摘注册表,仍留在快照里的行会先闪现到「未分组」。Tool(`session_delete`)路径没有客户端配合,侧边栏仍需等下一次连接或重启(面板可用「重新扫描」手动对齐)。
46
57
 
47
58
  ## 安装
48
59
 
49
- DSH 的 profile 使用 pnpm 的 `file:` 依赖 + `dsh.profile.bundles` 声明:
60
+ **推荐:DSH「添加插件」对话框**。在 DSH 的「插件」页打开「添加插件」,输入包名 `dsh-plugin-session-cleaner`(npm 官方源与中国大陆镜像源均已上架),确认添加后**完全退出并重启 DSH**。
61
+
62
+ 也可以手动声明 profile 依赖(效果等价):
50
63
 
51
64
  ```jsonc
52
65
  // ~/.dsh/profiles/<profile>/package.json
53
66
  {
54
- "dependencies": { "dsh-plugin-session-cleaner": "file:<本仓库的克隆路径>\\plugin" },
67
+ "dependencies": { "dsh-plugin-session-cleaner": "^0.3.2" },
55
68
  "dsh": { "profile": { "bundles": [ /* ... */, "dsh-plugin-session-cleaner" ] } }
56
69
  }
57
70
  ```
58
71
 
72
+ 随后在 profile 目录执行 `pnpm install`。本地开发则把依赖指回工作树 `"file:<本仓库的克隆路径>\plugin"`,改完代码重新 `pnpm install`。
73
+
59
74
  **安装后需要重启 DSH**:插件行在 Loader 配置中会先显示为 `absent`,只有重启才会激活(Host 侧与客户端 bundle 都是如此)。
60
75
 
61
76
  原因不是配置未生效,而是 Node 对 ESM 模块按 URL 永久缓存:同一进程内即便重新启用该条目,`import()` 仍会拿到首次求值的那个模块实例。因此**修改已安装的插件代码后必须重启**,`set_plugin` 的禁用/启用不足以让改动生效。
@@ -66,7 +81,7 @@ DSH 的 profile 使用 pnpm 的 `file:` 依赖 + `dsh.profile.bundles` 声明:
66
81
  node build/client.mjs # 构建 lib/
67
82
  node --test tests/cleaner.test.mjs \
68
83
  tests/plugin.test.mjs \
69
- tests/client.test.mjs # 51 个测试
84
+ tests/client.test.mjs # 67 个测试
70
85
  ```
71
86
 
72
87
  `build/client.mjs` 是一个零依赖的极简打包器:DSH 的客户端模块系统每个包只加载**一个自包含的 `client.js`**(entry 与 chunk 之间不能同步 require 另一个相对 `client*.js`),所以相对 require 被内联,React / react-dom 则保留为平台模块请求,由 shell 冻结的模块表应答。
@@ -171,7 +186,7 @@ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
171
186
 
172
187
  ### 改完必须重启
173
188
 
174
- Host 把每个包的 `dsh.client` 元数据缓存在内存里**直到重启**;禁用/启用条目不足以让它重新读取,`lib/client.js` 的新产物也不会被重新组合。
189
+ Host 把每个包的 `dsh.client` 元数据缓存在内存里**直到重启**;禁用/启用条目不足以让它重新读取,`lib/client.js` 的新产物也不会被重新组合。客户端同理:Web view 会缓存旧 bundle,重启后最好再硬刷新一次(`Ctrl+Shift+R`),否则客户端跑的仍是旧逻辑——症状是「Host 行为变了、界面没变」的混合版本。
175
190
 
176
191
  权威写法见 DSH 内置 `cordis-plugin-development` skill 的 `templates/decoration/{package.json,client.js}`,以及 `@deepseek-ai/dsh-client-ui-workspace/README.zh.md` 的「Session 行 action」一节。桌面版里它们都在 `app.asar` 内,用 `tools/asar-read.mjs` 取出来。
177
192
 
package/lib/client.js CHANGED
@@ -414,6 +414,14 @@ function renderMessages(React, t, messages) {
414
414
 
415
415
  module.exports = function createPanelPage(React, t, acquire, readFailure, primitives) {
416
416
  const { useState, useEffect, useMemo, useRef } = React
417
+ // Optional: called after a successful batch deletion so the client can
418
+ // re-pull its session list and drop the deleted rows from the sidebar.
419
+ const afterDelete = primitives?.afterDelete
420
+ // Optional: fires when a deletion happens OUTSIDE this panel (the sidebar
421
+ // menu dialog). This page's list is a scan snapshot taken on mount, so it
422
+ // subscribes and re-scans — otherwise deleted rows would linger here while
423
+ // the sidebar has already dropped them.
424
+ const deletionSignal = primitives?.deletionSignal
417
425
 
418
426
  return function SessionCleanerPage() {
419
427
  const [state, setState] = useState({ phase: 'loading' })
@@ -435,13 +443,24 @@ module.exports = function createPanelPage(React, t, acquire, readFailure, primit
435
443
  const [previewSize, setPreviewSize] = useState(undefined)
436
444
  const previewPanelRef = useRef(null)
437
445
 
438
- const load = async () => {
439
- setState({ phase: 'loading' })
440
- setNotice(undefined)
446
+ /**
447
+ * (Re)scan the Host's disk. The quiet variant is the post-delete
448
+ * reconciliation: it keeps the current list, notice and dshHome on screen
449
+ * while the scan is in flight, and on failure keeps them too — the rows
450
+ * dropped optimistically after the delete are a better guess than a
451
+ * loading flash over an empty list.
452
+ */
453
+ const load = async ({ quiet = false } = {}) => {
454
+ if (!quiet) {
455
+ setState({ phase: 'loading' })
456
+ setNotice(undefined)
457
+ }
441
458
  const service = await acquire()
442
459
  if (service === undefined) {
443
- const reason = readFailure?.()
444
- setState({ phase: 'error', error: reason === undefined ? t('panel.serviceMissing') : t('service.mountFailed', { reason }) })
460
+ if (!quiet) {
461
+ const reason = readFailure?.()
462
+ setState({ phase: 'error', error: reason === undefined ? t('panel.serviceMissing') : t('panel.service.mountFailed', { reason }) })
463
+ }
445
464
  return
446
465
  }
447
466
  try {
@@ -449,12 +468,19 @@ module.exports = function createPanelPage(React, t, acquire, readFailure, primit
449
468
  setState({ phase: 'ready', sessions: data?.sessions ?? [], dshHome: data?.dshHome })
450
469
  setSelected(new Set())
451
470
  } catch (reason) {
452
- setState({ phase: 'error', error: String(reason?.message ?? reason) })
471
+ if (!quiet) setState({ phase: 'error', error: String(reason?.message ?? reason) })
453
472
  }
454
473
  }
455
474
 
456
475
  useEffect(() => { load() }, [])
457
476
 
477
+ // Deletions from the sidebar menu invalidate this snapshot: re-scan when
478
+ // signaled. The cleanup happens through the subscription's disposer.
479
+ useEffect(() => {
480
+ if (deletionSignal === undefined) return undefined
481
+ return deletionSignal.subscribe(() => { load() })
482
+ }, [])
483
+
458
484
  // Escape closes the preview, matching the platform Modal's behaviour.
459
485
  useEffect(() => {
460
486
  if (preview === undefined || typeof document?.addEventListener !== 'function') return undefined
@@ -514,6 +540,34 @@ module.exports = function createPanelPage(React, t, acquire, readFailure, primit
514
540
  }
515
541
  try {
516
542
  const result = unwrap(await service.delete(ids, {}))
543
+ const deletedIds = new Set(
544
+ (Array.isArray(result?.deleted) ? result.deleted : [])
545
+ .map((entry) => entry?.id)
546
+ .filter((id) => typeof id === 'string'),
547
+ )
548
+ if (deletedIds.size > 0) {
549
+ // Optimistic removal: the Host confirmed these deletions, so the
550
+ // rows drop the moment the RPC returns instead of after the
551
+ // reconciliation scan below (a full disk walk that flashes the
552
+ // loading state on large profiles). A refused or partially failed
553
+ // delete never reaches here with the un-deleted ids included, so
554
+ // surviving rows stay on screen until the scan reconciles them.
555
+ setState((current) => (current.phase === 'ready'
556
+ ? { ...current, sessions: current.sessions.filter((session) => !deletedIds.has(session.id)) }
557
+ : current))
558
+ const without = (current) => {
559
+ const next = new Set(current)
560
+ for (const id of deletedIds) next.delete(id)
561
+ return next
562
+ }
563
+ setSelected(without)
564
+ setOpenSessions(without)
565
+ // Hand the CONFIRMED ids to the client regardless of the batch
566
+ // outcome — a partially failed delete still deleted these, and
567
+ // leaving them on the sidebar (see forgetSessions) parks them in
568
+ // Ungrouped until the next restart.
569
+ try { afterDelete?.([...deletedIds]) } catch { /* best effort */ }
570
+ }
517
571
  if (result?.ok === false) {
518
572
  setNotice({ kind: 'error', text: t('panel.failed', { reason: result.error ?? 'unknown' }) })
519
573
  } else {
@@ -525,7 +579,10 @@ module.exports = function createPanelPage(React, t, acquire, readFailure, primit
525
579
  }),
526
580
  })
527
581
  }
528
- await load()
582
+ // Reconcile behind the optimistic update: the scan replaces the
583
+ // snapshot (and picks up deletions made elsewhere) without a loading
584
+ // flash, and the quiet path leaves the report above on screen.
585
+ await load({ quiet: true })
529
586
  } catch (reason) {
530
587
  setNotice({ kind: 'error', text: t('panel.failed', { reason: String(reason?.message ?? reason) }) })
531
588
  } finally {
@@ -859,6 +916,11 @@ module.exports = function createPanelPage(React, t, acquire, readFailure, primit
859
916
  React.createElement('button', {
860
917
  key: 'toggle', type: 'button', onClick: toggleAll, style: buttonStyle,
861
918
  }, allVisibleSelected ? t('panel.clear') : t('panel.selectAll')),
919
+ React.createElement('button', {
920
+ // The list is a snapshot; a manual re-scan also picks up deletions
921
+ // made through the model Tool, which no client sees happen.
922
+ key: 'refresh', type: 'button', disabled: busy, onClick: load, style: buttonStyle,
923
+ }, t('panel.refresh')),
862
924
  React.createElement('button', {
863
925
  key: 'delete',
864
926
  type: 'button',
@@ -1135,6 +1197,7 @@ const MESSAGES = Object.freeze({
1135
1197
  'panel.search': 'Filter by title, id, workspace or path',
1136
1198
  'panel.selectAll': 'Select all shown',
1137
1199
  'panel.clear': 'Clear selection',
1200
+ 'panel.refresh': 'Rescan',
1138
1201
  'panel.delete': 'Delete {count} selected ({size})',
1139
1202
  'panel.deleting': 'Deleting…',
1140
1203
  'panel.deleted': 'Deleted {count} session(s), freed {size}.',
@@ -1183,6 +1246,7 @@ const MESSAGES = Object.freeze({
1183
1246
  'panel.search': '按标题、id、工作区或路径筛选',
1184
1247
  'panel.selectAll': '全选当前列表',
1185
1248
  'panel.clear': '清空选择',
1249
+ 'panel.refresh': '重新扫描',
1186
1250
  'panel.delete': '删除选中的 {count} 个({size})',
1187
1251
  'panel.deleting': '正在删除…',
1188
1252
  'panel.deleted': '已删除 {count} 个会话,释放 {size}。',
@@ -1265,6 +1329,26 @@ function createConfirmStore() {
1265
1329
  }
1266
1330
  }
1267
1331
 
1332
+ /**
1333
+ * A deletion happened somewhere outside the panel (the sidebar menu dialog).
1334
+ *
1335
+ * The panel's list is a scan snapshot taken on mount; without this signal the
1336
+ * rows of sessions deleted through the menu would linger in an open panel
1337
+ * until it is reopened. The panel subscribes on mount and re-scans.
1338
+ */
1339
+ function createDeletionSignal() {
1340
+ const listeners = new Set()
1341
+ return {
1342
+ subscribe(listener) {
1343
+ listeners.add(listener)
1344
+ return () => { listeners.delete(listener) }
1345
+ },
1346
+ emit() {
1347
+ for (const listener of [...listeners]) listener()
1348
+ },
1349
+ }
1350
+ }
1351
+
1268
1352
  module.exports = function createPlugin(require) {
1269
1353
  const React = require('react')
1270
1354
 
@@ -1287,6 +1371,9 @@ module.exports = function createPlugin(require) {
1287
1371
  /** One live dialog per activation; the overlay entry reads it. */
1288
1372
  const confirmStore = createConfirmStore()
1289
1373
 
1374
+ /** Broadcasts deletions performed outside the panel (see createDeletionSignal). */
1375
+ const deletionSignal = createDeletionSignal()
1376
+
1290
1377
  /** Subscribe a component to the pending confirm request. */
1291
1378
  function useConfirmRequest() {
1292
1379
  const [state, setState] = React.useState(confirmStore.get)
@@ -1317,6 +1404,79 @@ module.exports = function createPlugin(require) {
1317
1404
  /** Human-readable reason from the most recent acquire failure, if any. */
1318
1405
  let acquireFailure
1319
1406
 
1407
+ /**
1408
+ * The client's session-list snapshot, when this composition publishes
1409
+ * one. The sidebar renders its rows from that snapshot, which the shell
1410
+ * only re-pulls on connect — so after a deletion the rows would linger
1411
+ * until the next restart even though the files are gone. Dynamic
1412
+ * injection: a composition without the service simply keeps the
1413
+ * restart-based behavior.
1414
+ */
1415
+ let sessionList
1416
+ try {
1417
+ if (typeof ctx.inject === 'function') {
1418
+ ctx.inject(['sessions'], (scope) => { sessionList = scope.sessions })
1419
+ }
1420
+ } catch { /* optional service: nothing to refresh */ }
1421
+
1422
+ /**
1423
+ * Drop deleted sessions from the client snapshot and re-pull the
1424
+ * baseline. Ordering is load-bearing: `handleSessionRemoved` must run
1425
+ * AFTER the refresh settles. `refreshList()` swaps in a fresh mutation
1426
+ * array the moment it starts, so a removal recorded earlier — from the
1427
+ * forwarded `api-session/removed` event — is discarded when the pull
1428
+ * lands, and the Host's session/list keeps returning a deleted session
1429
+ * that is still attached live (cancelling its agent never detaches it;
1430
+ * only its creator's disposer can), so the merged baseline resurrects
1431
+ * the row into Ungrouped. Calling `handleSessionRemoved` after the
1432
+ * refresh removes the row now AND leaves the removal mutation in the
1433
+ * array the NEXT refresh replays against the baseline — one round of
1434
+ * protection for as long as the session stays attached.
1435
+ */
1436
+ const forgottenIds = new Set()
1437
+
1438
+ /** Remove one deleted session's row from the client snapshot, if the snapshot service is present. */
1439
+ const forgetSession = (id) => {
1440
+ try { sessionList?.handleSessionRemoved?.(id) } catch { /* snapshot-only repair */ }
1441
+ }
1442
+
1443
+ const forgetSessions = (sessionIds) => {
1444
+ const ids = Array.isArray(sessionIds) ? sessionIds.filter((id) => typeof id === 'string') : []
1445
+ for (const id of ids) forgottenIds.add(id)
1446
+ const forget = () => {
1447
+ for (const id of ids) forgetSession(id)
1448
+ }
1449
+ try {
1450
+ const pending = sessionList?.refresh?.()
1451
+ if (pending && typeof pending.then === 'function') pending.then(forget, forget)
1452
+ else forget()
1453
+ } catch {
1454
+ forget()
1455
+ }
1456
+ }
1457
+
1458
+ try {
1459
+ if (sessionList && typeof sessionList.subscribe === 'function' && typeof sessionList.getListSnapshot === 'function') {
1460
+ // Last line of defense against snapshot resurrections. Any baseline
1461
+ // rebuild (a reconnect's handleConnected, a refresh we did not
1462
+ // trigger) drops the mutation array and re-merges the Host list —
1463
+ // which still contains a deleted-but-attached session. Whenever the
1464
+ // snapshot changes, re-remove every known-deleted id it still
1465
+ // carries. Gating on actual presence keeps this convergent: the
1466
+ // removal drops the row, so the next pass has nothing to do.
1467
+ sessionList.subscribe(() => {
1468
+ if (forgottenIds.size === 0) return
1469
+ let snapshot
1470
+ try { snapshot = sessionList.getListSnapshot() } catch { return }
1471
+ const items = Array.isArray(snapshot?.items) ? snapshot.items : []
1472
+ if (items.length === 0) return
1473
+ for (const entry of items) {
1474
+ if (forgottenIds.has(entry?.sessionId)) forgetSession(entry.sessionId)
1475
+ }
1476
+ })
1477
+ }
1478
+ } catch { /* without a subscribable snapshot, the call-side forget is the only repair */ }
1479
+
1320
1480
  const readFailure = () => acquireFailure
1321
1481
 
1322
1482
  /**
@@ -1494,6 +1654,11 @@ module.exports = function createPlugin(require) {
1494
1654
  setError(result.error ?? 'unknown failure')
1495
1655
  return
1496
1656
  }
1657
+ // The files are gone: drop the rows from the client snapshot (see
1658
+ // forgetSessions) and tell an open cleanup panel to re-scan, since
1659
+ // its list is a mount snapshot.
1660
+ forgetSessions([sessionId])
1661
+ deletionSignal.emit()
1497
1662
  confirmStore.dismiss()
1498
1663
  } catch (reason) {
1499
1664
  setError(String(reason?.message ?? reason))
@@ -1612,7 +1777,12 @@ module.exports = function createPlugin(require) {
1612
1777
  // to the shipped Plugins and Automation Tasks pages. `selectPanel(id)`
1613
1778
  // dispatches on this key, so a missing entry would make the sidebar
1614
1779
  // row throw on click — the two registrations below are one unit.
1615
- const page = createPanelPage(React, t, acquire, readFailure, { Modal, Button })
1780
+ const page = createPanelPage(React, t, acquire, readFailure, {
1781
+ Modal,
1782
+ Button,
1783
+ afterDelete: forgetSessions,
1784
+ deletionSignal,
1785
+ })
1616
1786
  ctx.slots.inject('main', () => ctx.slots.register({
1617
1787
  name: 'main',
1618
1788
  key: PANEL_ID,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-session-cleaner",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "description": "Completely delete DSH sessions: session logs, projection cache, workspace-registry references and derived search indexes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cleaner.js CHANGED
@@ -551,14 +551,24 @@ export function assertDeletable(target, allowedRoots) {
551
551
  /**
552
552
  * Execute a plan. Order is deliberate and must not be reordered:
553
553
  * 1. stop every running session,
554
- * 2. detach registry references,
555
- * 3. delete session directories and projection caches,
554
+ * 2. delete session directories and projection caches,
555
+ * 3. detach registry references,
556
556
  * 4. purge derived search indexes that can no longer be reconciled.
557
557
  *
558
- * `stop` is injected so this module stays testable and free of runtime coupling.
558
+ * Step 3 has two authorities. `options.detachRegistry(ids)` — injected when a
559
+ * live DSH registry service is reachable — detaches through the in-memory
560
+ * registry, which also updates the running app's workspace projection, and it
561
+ * runs AFTER the file deletions: the sidebar renders rows from the client's
562
+ * session-list snapshot, so detaching first would flash a still-listed row
563
+ * into Ungrouped before the client re-pulls the list. Without the service
564
+ * (offline runs, tests) the registry FILE is rewritten in place BEFORE the
565
+ * deletions, so no row can outlive its log across a restart.
566
+ *
567
+ * `stop` is injected the same way, so this module stays testable and free of
568
+ * runtime coupling.
559
569
  */
560
570
  export async function executeDeletion(roots, plan, options = {}) {
561
- const { stop, now = () => Date.now() } = options
571
+ const { stop, detachRegistry, now = () => Date.now() } = options
562
572
  const allowedRoots = [roots.sessions, roots.storages]
563
573
  const result = {
564
574
  startedAt: now(),
@@ -566,6 +576,7 @@ export async function executeDeletion(roots, plan, options = {}) {
566
576
  registryChanges: [],
567
577
  deleted: [],
568
578
  failed: [],
579
+ warnings: [],
569
580
  searchIndexesRemoved: [],
570
581
  searchIndexesKept: [],
571
582
  }
@@ -585,18 +596,21 @@ export async function executeDeletion(roots, plan, options = {}) {
585
596
 
586
597
  const blocked = new Set(result.failed.map((entry) => entry.id))
587
598
 
588
- // 2. Detach registry references first, so no row survives its own log.
589
- let registry = await readWorkspaceRegistry(roots)
590
- if (registry !== undefined) {
591
- const removable = plan.targets.filter((target) => !blocked.has(target.id)).map((target) => target.id)
592
- const projected = detachFromRegistry(registry, removable)
593
- if (projected.changes.length > 0) {
594
- try {
595
- await writeJsonAtomic(roots.workspaceRegistry, projected.registry)
596
- registry = projected.registry
597
- result.registryChanges = projected.changes
598
- } catch (error) {
599
- result.failed.push({ id: '*', stage: 'registry', reason: String(error?.message ?? error) })
599
+ // 2. Offline registry detach: rewrite the file before deleting anything, so
600
+ // no row survives its own log across a restart. The live-service path
601
+ // performs this step after the deletions instead (see the docblock).
602
+ if (detachRegistry === undefined) {
603
+ let registry = await readWorkspaceRegistry(roots)
604
+ if (registry !== undefined) {
605
+ const removable = plan.targets.filter((target) => !blocked.has(target.id)).map((target) => target.id)
606
+ const projected = detachFromRegistry(registry, removable)
607
+ if (projected.changes.length > 0) {
608
+ try {
609
+ await writeJsonAtomic(roots.workspaceRegistry, projected.registry)
610
+ result.registryChanges = projected.changes
611
+ } catch (error) {
612
+ result.failed.push({ id: '*', stage: 'registry', reason: String(error?.message ?? error) })
613
+ }
600
614
  }
601
615
  }
602
616
  }
@@ -632,6 +646,28 @@ export async function executeDeletion(roots, plan, options = {}) {
632
646
  }
633
647
  }
634
648
 
649
+ // 3b. Live registry detach, after the deletions. Failures are REPORTED, not
650
+ // fatal: the files are already gone, a stale accounting entry heals on
651
+ // the next start (the registry prunes ids with no session header), and
652
+ // declaring the whole deletion failed after the rows vanished would
653
+ // mislead more than a warning does.
654
+ if (detachRegistry !== undefined) {
655
+ const removable = result.deleted
656
+ .filter((entry) => entry.kind === 'session-dir')
657
+ .map((entry) => entry.id)
658
+ if (removable.length > 0) {
659
+ try {
660
+ const detached = await detachRegistry(removable)
661
+ if (Array.isArray(detached?.changes)) result.registryChanges.push(...detached.changes)
662
+ for (const failure of Array.isArray(detached?.failures) ? detached.failures : []) {
663
+ result.warnings.push(`registry detach for ${failure?.id ?? '*'}: ${failure?.reason ?? 'unknown failure'}`)
664
+ }
665
+ } catch (error) {
666
+ result.warnings.push(`registry detach: ${String(error?.message ?? error)}`)
667
+ }
668
+ }
669
+ }
670
+
635
671
  // 4. Derived search indexes are disposable: removing unreconcilable ones is
636
672
  // safe because they are rebuilt from the surviving logs on next search.
637
673
  for (const indexFile of plan.searchIndexes) {
package/src/index.js CHANGED
@@ -78,6 +78,7 @@ function resultToWire(result) {
78
78
  registryChanges: result.registryChanges,
79
79
  searchIndexesRemoved: result.searchIndexesRemoved.map((entry) => entry.path),
80
80
  failed: result.failed,
81
+ warnings: result.warnings,
81
82
  summary: result.summary,
82
83
  humanBytes: formatBytes(result.summary?.bytesDeleted ?? 0),
83
84
  }
@@ -91,6 +92,16 @@ function createService(ctx) {
91
92
  const roots = rootsFor(resolveDshHome())
92
93
  const timeoutMs = stopTimeoutMs()
93
94
 
95
+ /**
96
+ * The live workspace registry, when the running DSH publishes one. Present,
97
+ * it becomes the single write authority over `workspace.json`: deleting ids
98
+ * through it updates the in-memory registry and pushes follow increments to
99
+ * the client, so deleted rows disappear from the sidebar without a restart.
100
+ * Writing the file ourselves beside it would race the registry's own
101
+ * read-modify-write and resurrect detached ids on its next change.
102
+ */
103
+ let liveRegistry
104
+
94
105
  /** Resolve a live agent for a session, if one is registered. */
95
106
  const liveAgent = (sessionId) => {
96
107
  try {
@@ -145,9 +156,69 @@ function createService(ctx) {
145
156
  }
146
157
  }
147
158
 
159
+ /**
160
+ * Detach deleted sessions through the LIVE registry service, so the running
161
+ * app updates its workspace projection immediately instead of on next start.
162
+ *
163
+ * Every call is idempotent on the registry side: unarchiving an id that is
164
+ * not archived resolves without writing, and `detachSession` filters a list
165
+ * that no longer carries the id. Pin and archive are mutually exclusive and
166
+ * archiving drops the pin, but both sets are checked anyway — belt against
167
+ * a registry whose invariants drifted. Failures are collected per id and
168
+ * reported as warnings: the files are already deleted at this point, and a
169
+ * stale accounting entry heals on the next start.
170
+ */
171
+ const detachViaRegistry = async (ids) => {
172
+ const registry = liveRegistry
173
+ const changes = []
174
+ const failures = []
175
+ if (registry === undefined) return { changes, failures }
176
+
177
+ let entities
178
+ try {
179
+ entities = registry.list()
180
+ } catch (error) {
181
+ return { changes, failures: [{ id: '*', reason: `registry list() failed: ${String(error?.message ?? error)}` }] }
182
+ }
183
+
184
+ for (const id of ids) {
185
+ // Remove the row from the client BEFORE the accounting detach. The
186
+ // detach pushes a follow increment that re-roots a still-snapshotted
187
+ // row into Ungrouped, where it lingers until the list refresh lands —
188
+ // the event makes the client drop the row locally first, with no RPC
189
+ // round trip. It is the same event the shipped controller emits when it
190
+ // disposes a session; if the gateway does not relay it, behavior falls
191
+ // back to the brief Ungrouped flash, never worse.
192
+ try { ctx.emit?.('api-session/removed', id) } catch { /* advisory */ }
193
+ try {
194
+ if (Array.isArray(registry.archivedSessionIds) && registry.archivedSessionIds.includes(id)) {
195
+ await registry.unarchiveSession(id)
196
+ changes.push({ where: 'global.archivedSessionIds', removed: 1 })
197
+ }
198
+ if (Array.isArray(registry.pinnedSessionIds) && registry.pinnedSessionIds.includes(id)) {
199
+ await registry.unpinSession(id)
200
+ changes.push({ where: 'global.pinnedSessionIds', removed: 1 })
201
+ }
202
+ for (const entity of entities) {
203
+ if (!Array.isArray(entity?.sessionIds) || !entity.sessionIds.includes(id)) continue
204
+ await entity.detachSession(id)
205
+ changes.push({ where: `workspaces[${String(entity.id)}].sessionIds`, removed: 1 })
206
+ }
207
+ } catch (error) {
208
+ failures.push({ id, reason: String(error?.message ?? error) })
209
+ }
210
+ }
211
+ return { changes, failures }
212
+ }
213
+
148
214
  return {
149
215
  dshHome: roots.dshHome,
150
216
 
217
+ /** Attach or detach the live registry service (see apply). */
218
+ bindRegistry(registry) {
219
+ liveRegistry = registry && typeof registry === 'object' ? registry : undefined
220
+ },
221
+
151
222
  /** Every session on disk with lightweight metadata, largest first. */
152
223
  async list() {
153
224
  const { records, skipped } = await scanSessions(roots)
@@ -234,8 +305,20 @@ function createService(ctx) {
234
305
  }
235
306
  }
236
307
 
237
- const result = await executeDeletion(roots, planned, { stop: stopSession })
238
- return { ok: result.failed.length === 0 && result.aborted !== true, ...resultToWire(result) }
308
+ const hasLiveRegistry = liveRegistry !== undefined
309
+ const result = await executeDeletion(roots, planned, {
310
+ stop: stopSession,
311
+ ...(hasLiveRegistry ? { detachRegistry: detachViaRegistry } : {}),
312
+ })
313
+ return {
314
+ ok: result.failed.length === 0 && result.aborted !== true,
315
+ // Which authority detached the registry references: 'live-registry'
316
+ // means the running app's projection was updated in place (the client
317
+ // re-pulls its session list right after this), 'registry-file' is the
318
+ // offline fallback that edits workspace.json on disk.
319
+ registrySync: hasLiveRegistry ? 'live-registry' : 'registry-file',
320
+ ...resultToWire(result),
321
+ }
239
322
  },
240
323
  }
241
324
  }
@@ -328,6 +411,20 @@ export function apply(ctx) {
328
411
  service.typertRemote = Object.freeze({ service, serviceKey: name, namespace: name })
329
412
  ctx.provide(name, service)
330
413
 
414
+ // Dynamic — NOT part of `inject` — so a composition without the workspace
415
+ // registry still activates this plugin and keeps the file-based fallback.
416
+ // While the service is up it is the only writer of workspace.json during
417
+ // deletions (see createService). The disposable is kept for symmetry with
418
+ // Cordis' effect semantics but the plugin lives as long as the host.
419
+ try {
420
+ if (typeof ctx.inject === 'function') {
421
+ ctx.inject(['workspaceRegistry'], (scope) => {
422
+ service.bindRegistry(scope?.workspaceRegistry)
423
+ return () => { service.bindRegistry(undefined) }
424
+ })
425
+ }
426
+ } catch { /* the composition publishes no workspace registry */ }
427
+
331
428
  ctx.effect(() => {
332
429
  const holder = ctx.agents?.currentInitiator?.()
333
430
  const currentSessionId = typeof holder?.id === 'string' ? holder.id : undefined