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 +4 -2
- package/README.zh.md +4 -2
- package/lib/client.js +50 -7
- package/lib/index.js +125 -48
- package/package.json +1 -1
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
|
|
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
|
-
- **
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
|
18
|
-
*
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* cold delete
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 request — no 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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
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
|
|
237
|
-
const
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
* {@link
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
await
|
|
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
|
+
"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",
|