dsh-archived-chats 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,13 +18,15 @@ Restart DSH once after installing, then open **Settings → Archived Chats**.
18
18
  - **Search** by title, plus two filters: type (all / regular / subagent) and project.
19
19
  - **Unarchive** a single chat or a whole project group from the group's `⋯` menu — restored chats reappear in the sidebar immediately.
20
20
  - **Delete** one chat, a project group, or everything (**Delete All**), each behind a confirmation dialog. Deletion is thorough: the session log is removed from disk, the session is detached from its workspace record, and the registry's in-memory header index is purged, so the sidebar drops the rows live.
21
- - Sessions that are still resident in the background are **parked permanently** and physically removed on the next DSH start nothing is left half-deleted, and parked sessions stay hidden meanwhile.
21
+ - Sessions still resident in the background are **deleted in place too**: the plugin disposes the session through the official lifecycle teardown order (cancel → quiesce → flush → fiber teardown → registry detach), the persistence layer releases the write path, and the physical delete completes within the same request — no restart. If the running DSH build does not expose the required internal seams, the plugin falls back to "park permanently + delete on the next start", with parked sessions staying hidden meanwhile.
22
22
  - Works in light and dark schemes; localized in English and 中文.
23
23
 
24
24
  ## How it works
25
25
 
26
26
  - **Host half** (`lib/index.js`) registers the `/plugins/dsh-archived-chats/*` routes on the DSH web server: `GET /state`, `POST /unarchive`, `POST /unarchive-all`, `POST /delete`, `POST /delete-all`. Unarchiving writes through the workspace registry's own state path, so every connected client receives the `host/archived-sessions-changed` push. Mutating routes require a custom `x-dsh-archived-chats: 1` header as CSRF hardening.
27
- - **Pending-deletion store**: deleting a live session takes a "park and defer" paththe agent is cancelled permanently (`cancel({ kind: 'disposed' })`), the session stays archived and hidden, and the id is recorded in `$DSH_HOME/plugin-data/archived-chats/pending-deletions.json`. On the next boot the plugin sweeps the queue and completes the deletion through the ordinary delete path. Parked sessions are excluded from the listing; unarchiving one before the restart cancels its pending deletion.
27
+ - **In-place live deletion**: deleting a resident session replays the agent factory's own disposer sequence — `cancel({ kind: 'disposed' })` `whenIdle` `flush` `agent.scope.dispose()` detach of the `agents` and `sessions` store entries. The session detach emits `session/disposed`, the persistence coordinator retires (drains and releases) the write path, and the ordinary cold delete completes in the same request. The store entries are internal surfaces, so every step is feature-detected; anything missing falls back to park-and-defer.
28
+ - **Pending-deletion store** (fallback path and crash bracket): the id is recorded in `$DSH_HOME/plugin-data/archived-chats/pending-deletions.json` while the session stays archived and hidden; the next boot sweeps the queue through the ordinary delete path. In-place deletes are bracketed by the same store (recorded before disposal, cleared once the files are gone), so a crash mid-delete is completed on the next start. Parked sessions are excluded from the listing; unarchiving cancels a pending deletion.
29
+ - **Title cache**: resolved titles are memoized per id across list refreshes instead of re-reading every archived log; delete and unarchive invalidate their entries.
28
30
  - **Browser half** (`lib/client.js`) registers a `settings.section` slot entry (order 30) and renders the page with React and DSH design tokens.
29
31
 
30
32
  ## Uninstall
package/README.zh.md CHANGED
@@ -18,13 +18,15 @@ dsh plugin --profile web add dsh-archived-chats
18
18
  - **搜索**标题,外加两个筛选器:类型(全部 / 普通会话 / 子代理会话)和项目。
19
19
  - **取消归档**单个聊天,或从分组的 `⋯` 菜单整组取消——恢复的聊天会立刻回到侧边栏。
20
20
  - **删除**单个聊天、某个项目分组或全部(**全部删除**),均有确认弹窗。删除是彻底的:会话日志从磁盘移除、从工作区记录中摘除、注册表内存索引同步清理,主侧边栏的条目也会立即消失。
21
- - 仍驻留后台的会话会被**永久停用**,待下次启动 DSH 时完成物理删除——不会留下半删除状态,停用期间会话保持隐藏。
21
+ - 仍驻留后台的会话也**当场删除**:插件按官方生命周期的拆除顺序原地停用并注销会话(取消 → 静默 → 落盘 → 拆纤程 → 摘出注册表),持久层随之释放写入通道,同一次请求内即完成物理删除——无需重启。若当前 DSH 版本不提供所需内部接口,则自动回退为「永久停用 + 下次启动完成删除」,停用期间会话保持隐藏。
22
22
  - 适配浅色/深色主题,支持中文和英文界面。
23
23
 
24
24
  ## 实现原理
25
25
 
26
26
  - **Host 半**(`lib/index.js`)在 DSH Web 服务器上注册 `/plugins/dsh-archived-chats/*` 路由:`GET /state`、`POST /unarchive`、`POST /unarchive-all`、`POST /delete`、`POST /delete-all`。取消归档走 workspace registry 自身的状态写入通道,所有已连接的客户端都会收到 `host/archived-sessions-changed` 推送。写操作路由要求自定义请求头 `x-dsh-archived-chats: 1` 作为 CSRF 加固。
27
- - **待删队列**:删除仍在后台运行的会话时采用「停用并延后」策略——agent 被永久取消(`cancel({ kind: 'disposed' })`),会话保持归档与隐藏,id 记入 `$DSH_HOME/plugin-data/archived-chats/pending-deletions.json`;下次启动时插件会清扫该队列,通过常规删除路径完成物理删除。已入队的会话不会出现在列表中;若在重启前取消归档,待删标记会被撤销。
27
+ - **活会话原地删除**:删除仍驻留后台的会话时,插件复刻 agent 工厂自身 disposer 的顺序——`cancel({ kind: 'disposed' })` `whenIdle` → `flush` → `agent.scope.dispose()` → 依次 detach `agents` 与 `sessions` 两个 store 条目;session detach 发出 `session/disposed`,持久化协调器随之 retire(排空并释放)该会话的写入通道,之后冷删除路径在同一请求内完成。所涉 store 条目属于内部接口,每一步都做特性探测,探测失败即回退为停用+延后。
28
+ - **待删队列**(回退路径与崩溃兜底):id 记入 `$DSH_HOME/plugin-data/archived-chats/pending-deletions.json`,会话保持归档与隐藏;下次启动时插件清扫该队列,通过常规删除路径完成物理删除。原地删除也用该队列包裹(删除前登记、文件移除后清除),中途崩溃由下次启动补完。已入队的会话不会出现在列表中;取消归档会撤销待删标记。
29
+ - **标题缓存**:列表刷新时按 id 记忆已解析的标题,不再每次全量读日志;删除与取消归档会使对应缓存失效。
28
30
  - **浏览器半**(`lib/client.js`)注册 `settings.section` 插槽项(order 30),用 React 和 DSH 设计令牌渲染页面。
29
31
 
30
32
  ## 卸载
package/lib/client.js CHANGED
@@ -124,6 +124,19 @@ window.__ModuleLoader__.load({
124
124
  : n === 1
125
125
  ? "1 chat is parked and will be permanently deleted after DSH restarts"
126
126
  : `${n} chats are parked and will be permanently deleted after DSH restarts`;
127
+ // Success confirmation after a completed delete — same copy as CodeX.
128
+ const deletedText = (t, n) => isZh(t)
129
+ ? "已删除归档聊天"
130
+ : n === 1 ? "Archived chat deleted" : `${n} archived chats deleted`;
131
+ // Pin the success toast just inside the settings dialog's top edge: the
132
+ // toast is fixed-positioned (viewport coordinates), so measure where the
133
+ // dialog actually starts instead of hardcoding an offset.
134
+ const toastTop = () => {
135
+ try {
136
+ const top = document.querySelector('[role="dialog"]')?.getBoundingClientRect().top;
137
+ return typeof top === "number" && top >= 0 ? Math.round(top) + 16 : 24;
138
+ } catch { return 24; }
139
+ };
127
140
  function formatDate(t, ms) {
128
141
  if (typeof ms !== "number" || !Number.isFinite(ms)) return "";
129
142
  try {
@@ -139,7 +152,7 @@ window.__ModuleLoader__.load({
139
152
 
140
153
  //#region styles
141
154
  const CSS = `
142
- .dac-page{display:flex;flex-direction:column;gap:14px;padding:4px 0 28px;font-family:inherit}
155
+ .dac-page{position:relative;display:flex;flex-direction:column;gap:14px;padding:4px 0 28px;font-family:inherit}
143
156
  .dac-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
144
157
  .dac-title{color:var(--dsw-alias-label-primary);font-size:18px;font-weight:500;line-height:28px}
145
158
  .dac-deleteall{display:inline-flex;align-items:center;gap:6px;border:none;border-radius:999px;padding:6px 14px;background:rgba(229,72,77,.1);color:#e5484d;font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background .15s}
@@ -187,6 +200,12 @@ window.__ModuleLoader__.load({
187
200
  .dac-retry{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;padding:4px 12px;cursor:pointer}
188
201
  .dac-notice{display:flex;align-items:center;justify-content:space-between;gap:10px;border:1px solid rgba(229,72,77,.45);border-radius:10px;background:rgba(229,72,77,.08);color:#e5484d;font-size:12px;line-height:18px;padding:8px 12px}
189
202
  .dac-notice button{border:none;background:transparent;color:inherit;cursor:pointer;font:inherit;padding:0;display:inline-flex}
203
+ .dac-toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:1200;display:flex;align-items:center;gap:8px;border:1px solid rgba(48,164,108,.4);border-radius:999px;background:color-mix(in srgb,#30a46c 9%,var(--dsw-alias-bg-primary,#fff));color:#2f9e68;font-size:13px;font-weight:500;line-height:20px;padding:7px 12px 7px 14px;box-shadow:0 6px 20px rgba(0,0,0,.10),0 1px 3px rgba(0,0,0,.06);animation:dac-toast-in .28s cubic-bezier(.21,1.02,.55,1) both}
204
+ .dac-toast svg{flex:none}
205
+ .dac-toast button{border:none;background:transparent;color:inherit;opacity:.55;cursor:pointer;font:inherit;padding:2px;margin-left:2px;display:inline-flex;border-radius:999px}
206
+ .dac-toast button:hover{opacity:1;background:rgba(48,164,108,.12)}
207
+ @keyframes dac-toast-in{from{opacity:0;transform:translateX(-50%) translateY(-10px) scale(.96)}to{opacity:1;transform:translateX(-50%) translateY(0) scale(1)}}
208
+ @media (prefers-reduced-motion:reduce){.dac-toast{animation:none}}
190
209
  .dac-confirm-overlay{position:fixed;inset:0;z-index:1400;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.4)}
191
210
  .dac-confirm{width:min(400px,calc(100vw - 64px));display:flex;flex-direction:column;gap:12px;border-radius:16px;background:var(--dsw-alias-bg-layer-2);box-shadow:0 16px 64px rgba(0,0,0,.3);padding:20px}
192
211
  .dac-confirm-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:500;line-height:24px}
@@ -310,6 +329,9 @@ window.__ModuleLoader__.load({
310
329
  function IconClose({ size = 12 }) {
311
330
  return (0, jsx.jsx)("svg", { ...svgProps(size), children: (0, jsx.jsx)("path", { d: "M6 6l12 12M18 6 6 18" }) });
312
331
  }
332
+ function IconCheckCircle({ size = 15 }) {
333
+ return (0, jsx.jsxs)("svg", { ...svgProps(size), children: [(0, jsx.jsx)("circle", { cx: 12, cy: 12, r: 9 }), (0, jsx.jsx)("path", { d: "m8.5 12.2 2.4 2.4 4.6-5" })] });
334
+ }
313
335
  //#endregion
314
336
 
315
337
  //#region components
@@ -473,6 +495,16 @@ window.__ModuleLoader__.load({
473
495
  const [notice, setNotice] = _react.useState(null);
474
496
  const [collapsed, setCollapsed] = _react.useState(readCollapsed);
475
497
 
498
+ // Success confirmations are transient toasts (CodeX behavior);
499
+ // errors stay until dismissed.
500
+ _react.useEffect(() => {
501
+ if (notice?.kind !== "ok") return undefined;
502
+ const timer = setTimeout(() => {
503
+ setNotice((current) => (current === notice ? null : current));
504
+ }, 4000);
505
+ return () => clearTimeout(timer);
506
+ }, [notice]);
507
+
476
508
  const toggleCollapsed = (key) => {
477
509
  setCollapsed((prev) => {
478
510
  const next = { ...prev };
@@ -512,7 +544,7 @@ window.__ModuleLoader__.load({
512
544
  await post("/unarchive-all", { sessionIds: ids });
513
545
  setSessions((prev) => (prev ?? []).filter((s) => !ids.includes(s.id)));
514
546
  } catch (error) {
515
- setNotice(String(error.message ?? error));
547
+ setNotice({ kind: "error", text: String(error.message ?? error) });
516
548
  } finally {
517
549
  markBusy(ids, false);
518
550
  }
@@ -535,8 +567,9 @@ window.__ModuleLoader__.load({
535
567
  // next-boot sweep physically removes it.
536
568
  setSessions((prev) => (prev ?? []).filter((s) => !deleted.includes(s.id) && !pending.includes(s.id)));
537
569
  }
538
- if (failed.length > 0) setNotice(failureText(t, failed));
539
- else if (pending.length > 0) setNotice(pendingText(t, pending.length));
570
+ if (failed.length > 0) setNotice({ kind: "error", text: failureText(t, failed) });
571
+ else if (pending.length > 0) setNotice({ kind: "error", text: pendingText(t, pending.length) });
572
+ else if (deleted.length > 0) setNotice({ kind: "ok", text: deletedText(t, deleted.length) });
540
573
  } catch (error) {
541
574
  const deleted = error.body?.deleted ?? [];
542
575
  const pending = error.body?.pending ?? [];
@@ -545,7 +578,7 @@ window.__ModuleLoader__.load({
545
578
  changed = true;
546
579
  setSessions((prev) => (prev ?? []).filter((s) => !deleted.includes(s.id) && !pending.includes(s.id)));
547
580
  }
548
- setNotice(failed.length > 0 ? failureText(t, failed) : String(error.message ?? error));
581
+ setNotice({ kind: "error", text: failed.length > 0 ? failureText(t, failed) : String(error.message ?? error) });
549
582
  } finally {
550
583
  markBusy(ids, false);
551
584
  setConfirm(null);
@@ -669,10 +702,20 @@ window.__ModuleLoader__.load({
669
702
  })
670
703
  ]
671
704
  }),
672
- notice !== null && (0, jsx.jsxs)("div", {
705
+ notice !== null && notice.kind === "ok" && (0, jsx.jsxs)("div", {
706
+ className: "dac-toast",
707
+ role: "status",
708
+ style: { top: `${toastTop()}px` },
709
+ children: [
710
+ (0, jsx.jsx)(IconCheckCircle, {}),
711
+ (0, jsx.jsx)("span", { children: notice.text }),
712
+ (0, jsx.jsx)("button", { type: "button", "aria-label": t("notice.dismiss"), onClick: () => setNotice(null), children: (0, jsx.jsx)(IconClose, {}) })
713
+ ]
714
+ }),
715
+ notice !== null && notice.kind !== "ok" && (0, jsx.jsxs)("div", {
673
716
  className: "dac-notice",
674
717
  children: [
675
- (0, jsx.jsx)("span", { children: notice }),
718
+ (0, jsx.jsx)("span", { children: notice.text }),
676
719
  (0, jsx.jsx)("button", { type: "button", "aria-label": t("notice.dismiss"), onClick: () => setNotice(null), children: (0, jsx.jsx)(IconClose, {}) })
677
720
  ]
678
721
  }),
package/lib/index.js CHANGED
@@ -14,8 +14,10 @@
14
14
  * POST /plugins/dsh-archived-chats/delete-all → { sessionIds: [...] }
15
15
  *
16
16
  * Mutating responses carry { ok, deleted, pending, failed }: `deleted` is
17
- * complete cold deletes, `pending` is live sessions accepted for deferred
18
- * deletion (parked now, physically removed on the next boot).
17
+ * complete deletes (cold sessions, and live sessions this build lets us
18
+ * dispose in place), `pending` is live sessions accepted for deferred
19
+ * deletion (parked now, physically removed on the next boot — the fallback
20
+ * when in-place disposal is unavailable).
19
21
  *
20
22
  * The stock workspace registry only exposes `archiveSession` — unarchiving and
21
23
  * deleting live here, behind one HTTP surface the browser half can drive.
@@ -25,20 +27,32 @@
25
27
  * updates live). Delete additionally detaches the session from its workspace
26
28
  * record and removes the session-log directory from disk.
27
29
  *
28
- * Deleting a COLD session is immediate. A session that is still LIVE (opened
29
- * at least once this boot — web agents stay resident until shutdown and the
30
- * stock composition exposes no dispose) cannot have its log ripped out from
31
- * under it: the persistence coordinator would hit ENOENT on its next append.
32
- * Instead the live path parks the agent permanently (`cancel({kind:
33
- * 'disposed'})` parked input never wakes the driver again), waits for
34
- * quiescence, flushes durability, then records the id in a small
35
- * pending-deletions store while KEEPING it archived so it stays invisible.
36
- * The boot sweep (`sweepPendingDeletions`, launched once when the plugin's
37
- * three services have all bound) completes the removal through the ordinary
38
- * cold delete path on the next bootwhen every session is cold. Unarchiving
39
- * a parked session drops it from the pending store, and the sweep skips ids
40
- * that are no longer archived, so an unarchive always wins over a parked
41
- * deletion.
30
+ * Deleting a COLD session is immediate. A LIVE session (opened at least once
31
+ * this boot — web agents stay resident until shutdown) cannot have its log
32
+ * ripped out from under it: the persistence coordinator would hit ENOENT on
33
+ * its next append. The live path therefore disposes the session first, the
34
+ * same sequence the agent factory's own lifecycle disposer runs: cancel the
35
+ * agent with the `disposed` cause, wait for quiescence, flush durability,
36
+ * tear down the agent's fiber (`agent.scope.dispose()`), then detach the
37
+ * `agents` and `sessions` store entries the session-store detach emits
38
+ * `session/disposed`, which the persistence coordinator answers by retiring
39
+ * (draining and releasing) the session's write path. Once retired the session
40
+ * is cold and the ordinary delete completes in the same requestno restart.
41
+ *
42
+ * The store entries and their `detach` are internal surfaces of this dsh
43
+ * build (plain properties, not exported API), so every step is feature-
44
+ * detected. When any piece is missing the live path falls back to the
45
+ * original conservative behavior: the agent is left parked (cancelled,
46
+ * flushed) and the id is recorded in a small pending-deletions store while
47
+ * KEEPING it archived so it stays invisible. The boot sweep
48
+ * (`sweepPendingDeletions`, launched once when the plugin's three services
49
+ * have all bound) completes the removal through the ordinary cold delete path
50
+ * on the next boot — when every session is cold. The pending store also
51
+ * brackets the successful live path (recorded before disposal, cleared after
52
+ * the files are gone), so a crash mid-delete is swept on the next boot
53
+ * instead of leaving a half-deleted ghost. Unarchiving a parked session drops
54
+ * it from the pending store, and the sweep skips ids that are no longer
55
+ * archived, so an unarchive always wins over a parked deletion.
42
56
  *
43
57
  * Routes bind lazily (same posture as dsh-agent-teams): the web server, the
44
58
  * workspace registry, and session persistence may mount after this plugin
@@ -166,9 +180,13 @@ function extractTitle(events) {
166
180
  /**
167
181
  * Build the archived-session listing: one row per archived id with the
168
182
  * resolved title, creation time, and owning workspace (accounting slot first,
169
- * canonical cwd second, ungrouped last).
183
+ * canonical cwd second, ungrouped last). Resolved titles are memoized in
184
+ * `titleCache` (id → title) so refreshes stop re-reading every archived log:
185
+ * only non-null titles are cached — a title-less session stays cheap to
186
+ * re-inspect and picks up a late `session/title` event on the next refresh.
187
+ * Delete and unarchive invalidate their ids.
170
188
  */
171
- async function listArchived(ctx, registry, persistence) {
189
+ async function listArchived(ctx, registry, persistence, titleCache) {
172
190
  const archivedIds = registry.archivedSessionIds.map(String);
173
191
  if (archivedIds.length === 0) return [];
174
192
  const headerById = new Map();
@@ -182,12 +200,15 @@ async function listArchived(ctx, registry, persistence) {
182
200
  const rows = [];
183
201
  for (const id of archivedIds) {
184
202
  const header = headerById.get(id) ?? live?.get(id)?.header;
185
- let title;
186
- try {
187
- const inspection = await persistence.inspect(id);
188
- title = extractTitle(inspection.events);
189
- } catch {
190
- // A torn/unreadable log still lists — the row is manageable without a title.
203
+ let title = titleCache?.get(id);
204
+ if (title === undefined) {
205
+ try {
206
+ const inspection = await persistence.inspect(id);
207
+ title = extractTitle(inspection.events);
208
+ if (title !== undefined) titleCache?.set(id, title);
209
+ } catch {
210
+ // A torn/unreadable log still lists — the row is manageable without a title.
211
+ }
191
212
  }
192
213
  const workspace = workspaces.find((w) => w.sessionIds.map(String).includes(id));
193
214
  rows.push({
@@ -227,14 +248,29 @@ async function unarchiveIds(registry, ids) {
227
248
  }
228
249
 
229
250
  /**
230
- * Park a live session for deletion: cancel its agent with the `disposed`
231
- * cause (parked input never wakes the driver again), wait for quiescence so
232
- * in-flight closing events land, then flush durability. Afterwards the log is
233
- * frozen and the id joins the pending-deletions store; the session STAYS
234
- * archived so it keeps hidden until the next-boot sweep completes the delete.
251
+ * Dispose a live session in place so its deletion can complete without a
252
+ * restart. Mirrors the agent factory's own lifecycle disposer: cancel the
253
+ * agent with the `disposed` cause (parked input never wakes the driver
254
+ * again), wait for quiescence so in-flight closing events land, flush
255
+ * durability, tear down the agent's fiber, then detach the `agents` and
256
+ * `sessions` store entries. The session-store detach emits `session/disposed`
257
+ * — the persistence coordinator answers by retiring (draining and releasing)
258
+ * the session's write path, after which the log directory is free to remove.
259
+ *
260
+ * The store maps and their entries' `detach` are internal surfaces of this
261
+ * dsh build, so every step is feature-detected. Returns true when the session
262
+ * ends up fully detached (cold); false when disposal is unavailable or did
263
+ * not converge — the session is then left in the parked state (cancelled and
264
+ * flushed) and the caller falls back to the deferred next-boot deletion.
235
265
  */
236
- async function parkLiveSession(ctx, id) {
237
- const agent = ctx.get('agents')?.get(id);
266
+ async function disposeLiveSession(ctx, id) {
267
+ const sessions = ctx.get('sessions');
268
+ const agents = ctx.get('agents');
269
+ const session = sessions?.get(id);
270
+ if (session === undefined) return true;
271
+ // Park first — this is also the complete fallback outcome: the loop can
272
+ // never run again and the log's buffered tail is durable.
273
+ const agent = agents?.get(id);
238
274
  if (agent !== undefined) {
239
275
  try {
240
276
  agent.cancel({ kind: 'disposed' });
@@ -246,27 +282,60 @@ async function parkLiveSession(ctx, id) {
246
282
  ctx.logger.warn(`archived-chats: parking ${id} did not fully converge: ${String(error)}`);
247
283
  }
248
284
  }
249
- const sessions = ctx.get('sessions');
250
- const live = sessions?.get(id);
251
- if (live !== undefined && typeof sessions.flush === 'function') {
252
- try { await sessions.flush(live); }
253
- catch (error) { ctx.logger.warn(`archived-chats: flush before parking ${id} failed: ${String(error)}`); }
285
+ if (typeof sessions.flush === 'function') {
286
+ try { await sessions.flush(session); }
287
+ catch (error) { ctx.logger.warn(`archived-chats: flush before disposing ${id} failed: ${String(error)}`); }
288
+ }
289
+ // Feature-detect the internal store entries before touching anything else:
290
+ // without BOTH detach capabilities the session must stay parked. The
291
+ // session-store entry carries its own `detach`; the agent-registry entry
292
+ // does not (its detach closure lives with the creator), so the agent side
293
+ // goes through the registry's `detachEntered(entry)` — the same method
294
+ // that closure calls.
295
+ const sessionEntry = sessions.store instanceof Map ? sessions.store.get(id) : undefined;
296
+ const agentEntry = agents?.store instanceof Map ? agents.store.get(id) : undefined;
297
+ if (typeof sessionEntry?.detach !== 'function') return false;
298
+ if (agent !== undefined && (agentEntry === undefined
299
+ || typeof agents.detachEntered !== 'function'
300
+ || agentEntry.announcing === true)) return false;
301
+ // Factory disposer order: agent fiber first, then unregister the agent,
302
+ // then detach the session (emitting `session/disposed`).
303
+ try { await agent?.scope?.dispose?.(); }
304
+ catch (error) { ctx.logger.warn(`archived-chats: agent fiber teardown for ${id} failed: ${String(error)}`); }
305
+ try { if (agentEntry !== undefined) agents.detachEntered(agentEntry); }
306
+ catch (error) { ctx.logger.warn(`archived-chats: agent detach for ${id} failed: ${String(error)}`); }
307
+ try { sessionEntry.detach(); }
308
+ catch (error) {
309
+ ctx.logger.warn(`archived-chats: session detach for ${id} failed: ${String(error)}`);
310
+ return false;
254
311
  }
255
- await addPending(id);
312
+ // Detach can defer while a creation announce or append is unwinding — if
313
+ // the session is still in the store, treat it as parked, not disposed.
314
+ if (sessions.get(id) !== undefined) return false;
315
+ // The retirement triggered by `session/disposed` drains asynchronously.
316
+ // The pre-detach flush left nothing buffered, so a short settle keeps the
317
+ // log-directory removal below clear of the drain's final serialize.
318
+ await new Promise((resolve) => setTimeout(resolve, 250));
319
+ return true;
256
320
  }
257
321
 
258
322
  /**
259
323
  * Delete one archived session. A COLD session is removed end to end right
260
324
  * away: unarchive, detach from the owning workspace record, remove the
261
325
  * session-log directory from disk, and purge the registry's stale header
262
- * index. A LIVE session is parked and deferred instead (see
263
- * {@link parkLiveSession}) and reported as `pending`.
326
+ * index. A LIVE session is first disposed in place (see
327
+ * {@link disposeLiveSession}) and then follows the same cold path in this
328
+ * request; when in-place disposal is unavailable on this build it stays
329
+ * parked and is reported as `pending` for the next-boot sweep. Either way the
330
+ * id sits in the pending-deletions store from before disposal until the files
331
+ * are gone, so a crash mid-delete is completed by the sweep instead of
332
+ * leaving a half-deleted ghost.
264
333
  */
265
- async function deleteSession(ctx, registry, persistence, id) {
266
- const live = ctx.get('sessions');
267
- if (live?.get(id) !== undefined) {
268
- await parkLiveSession(ctx, id);
269
- return 'pending';
334
+ async function deleteSession(ctx, registry, persistence, id, titleCache) {
335
+ if (ctx.get('sessions')?.get(id) !== undefined) {
336
+ await addPending(id);
337
+ const disposed = await disposeLiveSession(ctx, id);
338
+ if (!disposed) return 'pending';
270
339
  }
271
340
  await unarchiveIds(registry, [id]);
272
341
  for (const workspace of registry.list()) {
@@ -295,6 +364,10 @@ async function deleteSession(ctx, registry, persistence, id) {
295
364
  const map = registry[key];
296
365
  if (map instanceof Map) map.delete(id);
297
366
  }
367
+ titleCache?.delete(id);
368
+ // Clear the crash bracket (and any pre-existing parked mark) now that the
369
+ // files are gone. A no-op for ids that were never pending.
370
+ await removePending([id]);
298
371
  return 'deleted';
299
372
  }
300
373
 
@@ -335,13 +408,13 @@ async function sweepPendingDeletions(ctx, registry, persistence) {
335
408
  //#endregion
336
409
 
337
410
  //#region routes
338
- function registerRoutes(ctx, webServer, registry, persistence) {
411
+ function registerRoutes(ctx, webServer, registry, persistence, titleCache) {
339
412
  ctx.effect(() => webServer.register({
340
413
  kind: 'exact',
341
414
  path: `${ROUTE_PREFIX}/state`,
342
415
  handler: async (req, res) => {
343
416
  try {
344
- const sessions = await listArchived(ctx, registry, persistence);
417
+ const sessions = await listArchived(ctx, registry, persistence, titleCache);
345
418
  send(res, 200, { sessions });
346
419
  } catch (error) {
347
420
  ctx.logger.warn(`archived-chats: state failed: ${String(error)}`);
@@ -362,6 +435,7 @@ function registerRoutes(ctx, webServer, registry, persistence) {
362
435
  return;
363
436
  }
364
437
  const archivedSessionIds = await unarchiveIds(registry, [body.sessionId]);
438
+ titleCache.delete(body.sessionId);
365
439
  await removePending([body.sessionId]);
366
440
  send(res, 200, { ok: true, archivedSessionIds });
367
441
  } catch (error) {
@@ -383,6 +457,7 @@ function registerRoutes(ctx, webServer, registry, persistence) {
383
457
  return;
384
458
  }
385
459
  const archivedSessionIds = await unarchiveIds(registry, ids);
460
+ for (const id of ids) titleCache.delete(id);
386
461
  await removePending(ids);
387
462
  send(res, 200, { ok: true, archivedSessionIds });
388
463
  } catch (error) {
@@ -407,7 +482,7 @@ function registerRoutes(ctx, webServer, registry, persistence) {
407
482
  const failed = [];
408
483
  for (const id of ids) {
409
484
  try {
410
- const outcome = await deleteSession(ctx, registry, persistence, id);
485
+ const outcome = await deleteSession(ctx, registry, persistence, id, titleCache);
411
486
  if (outcome === 'pending') pending.push(id);
412
487
  else deleted.push(id);
413
488
  } catch (error) {
@@ -439,6 +514,8 @@ function registerRoutes(ctx, webServer, registry, persistence) {
439
514
  */
440
515
  export function apply(ctx) {
441
516
  let registered = false;
517
+ /** Memoized session titles for {@link listArchived} (id → title). */
518
+ const titleCache = new Map();
442
519
  const registerWebSurface = () => {
443
520
  if (registered) return;
444
521
  const webServer = ctx.get(WEB_SERVER_KEYS[0]) ?? ctx.get(WEB_SERVER_KEYS[1]);
@@ -446,7 +523,7 @@ export function apply(ctx) {
446
523
  const persistence = ctx.get(PERSISTENCE_KEYS[0]);
447
524
  if (webServer === undefined || registry === undefined || persistence === undefined) return;
448
525
  registered = true;
449
- registerRoutes(ctx, webServer, registry, persistence);
526
+ registerRoutes(ctx, webServer, registry, persistence, titleCache);
450
527
  // Boot sweep: every pending-deletion id is cold now (plugin activation
451
528
  // precedes any client resume), so complete each deferred deletion.
452
529
  void sweepPendingDeletions(ctx, registry, persistence)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-archived-chats",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "DeepSeek Harness 已归档会话管理页:在设置里查看、搜索、取消归档、删除已归档的聊天,按项目分组。An Archived Chats settings page for DeepSeek Harness: browse, search, unarchive, and delete archived sessions, grouped by workspace.",
5
5
  "license": "MIT",
6
6
  "author": "Ultronen",