dsh-ws-recency 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -4
- package/client.js +65 -70
- package/index.js +221 -9
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-ws-recency
|
|
2
2
|
|
|
3
|
-
DSH
|
|
3
|
+
DSH 侧栏的五件事:**工作区分组按最近活跃排序**、**过期历史自动折叠**、**运行中/未读/需跟进三色标记**、**所有置顶会话汇总到顶部一个区**、**删除会话到可恢复的回收站**。单独装上就有效,不依赖其它插件。
|
|
4
4
|
|
|
5
5
|
## 一、分组排序
|
|
6
6
|
|
|
@@ -12,8 +12,7 @@ DSH 已经把**组内**会话按 `updatedAt` 倒序排好了(排序方式 =
|
|
|
12
12
|
|
|
13
13
|
会话超过 `foldDays`(默认 **3 天**)没有更新就折叠起来;整个工作区都过期时,它只剩一行工作区标题——需要翻历史时点开就行。
|
|
14
14
|
|
|
15
|
-
-
|
|
16
|
-
- 点宿主的「展开其余」会同时放出被折叠的行;它变成「收起」时,折叠也一并复原;
|
|
15
|
+
- **两个控件各管一件事**:宿主的「展开其余 N 条会话」负责让**宿主渲染**更多行(数字由宿主自己报,插件不再改写它);插件的 `展开历史 N` / `收起历史` 只负责把**已渲染但过期**的行折叠起来,点两次就是展开、收起一对;
|
|
17
16
|
- 选中 / 运行中 / 等待处理的会话**永不折叠**(可在设置里关掉这个豁免);
|
|
18
17
|
- 空白草稿行(新会话)不参与折叠判断;
|
|
19
18
|
- 每 5 分钟自动重算一次,所以跨过 3 天边界的会话会自己折叠,不用刷新。
|
|
@@ -47,6 +46,21 @@ DSH 原生的置顶只把会话顶到**它所属工作区分组的第一行**,
|
|
|
47
46
|
- 工作区分组、组内顺序、折叠、颜色全部照旧;
|
|
48
47
|
- 只在按工作区分组时出现;搜索、扁平视图、侧栏收起时自动隐藏。
|
|
49
48
|
|
|
49
|
+
## 五、归档与删除
|
|
50
|
+
|
|
51
|
+
**归档**用宿主原生的:「…」菜单 →「归档会话」;看归档的内容用侧栏「视图选项」→ 显示已归档 / 仅显示已归档。本插件不改这套。
|
|
52
|
+
|
|
53
|
+
**删除**是宿主没有的,本插件补上(「…」菜单 →「删除会话(移入回收站)」):
|
|
54
|
+
|
|
55
|
+
1. 先停掉它正在跑的工作;
|
|
56
|
+
2. 摘掉归档标记与置顶;
|
|
57
|
+
3. 从每个工作区的 `sessionIds` 记账里 detach;
|
|
58
|
+
4. 删掉投影缓存记录;
|
|
59
|
+
5. 把日志目录移动到 `~/.hypers-work/.trash/ws-recency/<sessionId>-<时间戳>/`;
|
|
60
|
+
6. 广播 `api-session/removed`,浏览器立即移除该行。
|
|
61
|
+
|
|
62
|
+
**不是物理删除**:目录只是被改名搬走,恢复就是把 `.trash/ws-recency/<id>-<时间>/` 移回 `sessions/<project>/<id>/`。这条删除走的是宿主侧路由(`POST /ws-recency/session-trash`,仅接受回环来源),因为浏览器侧碰不到文件系统。
|
|
63
|
+
|
|
50
64
|
## 设置
|
|
51
65
|
|
|
52
66
|
**设置 → 通用**:
|
|
@@ -82,7 +96,7 @@ dsh plugin --profile <profile> add github:chyxhtc/dsh-ws-recency
|
|
|
82
96
|
或固定版本的预构建包:
|
|
83
97
|
|
|
84
98
|
```powershell
|
|
85
|
-
dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.
|
|
99
|
+
dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.9.1/dsh-ws-recency-0.9.1.tgz
|
|
86
100
|
```
|
|
87
101
|
|
|
88
102
|
装完把 `dsh-ws-recency` 加进 `~/.hypers-work/profiles/<profile>/package.json` 的 `dsh.profile.bundles`,重启应用。
|
package/client.js
CHANGED
|
@@ -78,13 +78,11 @@
|
|
|
78
78
|
var written = new Set();
|
|
79
79
|
var roots = new Set();
|
|
80
80
|
var hidden = new Set();
|
|
81
|
-
var labelled = new Set();
|
|
82
81
|
var toggles = new Set();
|
|
83
82
|
var dots = new Set();
|
|
84
83
|
var pinHidden = new Set();
|
|
85
84
|
var pinHost = null;
|
|
86
85
|
var expandedSections = new Set();
|
|
87
|
-
var clickMarked = new WeakSet();
|
|
88
86
|
var headerMarked = new WeakSet();
|
|
89
87
|
var disposed = false;
|
|
90
88
|
|
|
@@ -135,6 +133,9 @@
|
|
|
135
133
|
"pin.unpin": "\u53d6\u6d88\u7f6e\u9876",
|
|
136
134
|
"menu.flag": "\u6807\u8bb0\u9700\u8981\u8ddf\u8fdb",
|
|
137
135
|
"menu.unflag": "\u53d6\u6d88\u8ddf\u8fdb\u6807\u8bb0",
|
|
136
|
+
"menu.trash": "\u5220\u9664\u4f1a\u8bdd\uff08\u79fb\u5165\u56de\u6536\u7ad9\uff09",
|
|
137
|
+
"trash.confirm": "\u5220\u9664\u8fd9\u4e2a\u4f1a\u8bdd\uff1f\n\n\u4f1a\u5148\u505c\u6b62\u5b83\u6b63\u5728\u8fdb\u884c\u7684\u5de5\u4f5c\uff0c\u518d\u628a\u65e5\u5fd7\u76ee\u5f55\u79fb\u5165\u56de\u6536\u7ad9\uff08\u4e0d\u662f\u7269\u7406\u5220\u9664\uff0c\u53ef\u4ee5\u624b\u52a8\u6062\u590d\uff09\u3002",
|
|
138
|
+
"trash.failed": "\u5220\u9664\u5931\u8d25\uff1a",
|
|
138
139
|
};
|
|
139
140
|
var EN = {
|
|
140
141
|
"settings.title": "Fold history after (days)",
|
|
@@ -152,6 +153,9 @@
|
|
|
152
153
|
"pin.unpin": "Unpin",
|
|
153
154
|
"menu.flag": "Mark as needs follow-up",
|
|
154
155
|
"menu.unflag": "Clear follow-up mark",
|
|
156
|
+
"menu.trash": "Delete session (move to trash)",
|
|
157
|
+
"trash.confirm": "Delete this session?\n\nIt stops whatever the session is running, then moves the log directory into the trash folder (a move, not an erase: you can restore it by hand).",
|
|
158
|
+
"trash.failed": "Delete failed: ",
|
|
155
159
|
};
|
|
156
160
|
|
|
157
161
|
var FALLBACK = { "fold.show": "Show history ({n})", "fold.hide": "Hide history" };
|
|
@@ -319,11 +323,6 @@
|
|
|
319
323
|
return out;
|
|
320
324
|
}
|
|
321
325
|
|
|
322
|
-
function overflowButtonOf(section) {
|
|
323
|
-
if (typeof section.querySelector !== "function") return null;
|
|
324
|
-
return section.querySelector('button[class*="' + OVERFLOW_TOKEN + '"]');
|
|
325
|
-
}
|
|
326
|
-
|
|
327
326
|
function isActiveRow(row) {
|
|
328
327
|
if (typeof row.querySelector !== "function") return false;
|
|
329
328
|
return row.querySelector('[data-state="ongoing"], [data-state="warning"]') !== null;
|
|
@@ -365,55 +364,7 @@
|
|
|
365
364
|
return session !== undefined && session !== null && session.blank === true;
|
|
366
365
|
}
|
|
367
366
|
|
|
368
|
-
/**
|
|
369
|
-
* Rewrite the host button's count so it reports what is really hidden: the
|
|
370
|
-
* host counts only the rows it did not render, and the fold hides a few more.
|
|
371
|
-
*/
|
|
372
|
-
function rewriteLabel(button, extraHidden) {
|
|
373
|
-
if (button === null || button === undefined) return;
|
|
374
|
-
if (typeof button.textContent !== "string" || button.dataset === undefined) return;
|
|
375
|
-
var base = button.dataset.wsrBase;
|
|
376
|
-
if (typeof base !== "string") {
|
|
377
|
-
base = button.textContent;
|
|
378
|
-
button.dataset.wsrBase = base;
|
|
379
|
-
labelled.add(button);
|
|
380
|
-
}
|
|
381
|
-
var match = /\d+/.exec(base);
|
|
382
|
-
if (match === null) return;
|
|
383
|
-
var hostCount = Number(match[0]);
|
|
384
|
-
if (!Number.isFinite(hostCount)) return;
|
|
385
|
-
var total = hostCount + extraHidden;
|
|
386
|
-
var text = total <= 0 ? base : base.slice(0, match.index) + String(total) + base.slice(match.index + match[0].length);
|
|
387
|
-
if (button.textContent !== text) button.textContent = text;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function releaseLabel(button) {
|
|
391
|
-
if (button === null || button === undefined || button.dataset === undefined) return;
|
|
392
|
-
var base = button.dataset.wsrBase;
|
|
393
|
-
if (typeof base === "string") {
|
|
394
|
-
if (button.textContent !== base) button.textContent = base;
|
|
395
|
-
delete button.dataset.wsrBase;
|
|
396
|
-
}
|
|
397
|
-
labelled.delete(button);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function markButton(button, key) {
|
|
401
|
-
if (typeof button.addEventListener !== "function" || clickMarked.has(button)) return;
|
|
402
|
-
clickMarked.add(button);
|
|
403
|
-
button.addEventListener("click", function () {
|
|
404
|
-
if (disposed) return;
|
|
405
|
-
/* The host's own control is the only one while it exists, so it has to
|
|
406
|
-
carry the fold too: `aria-expanded` still reads the pre-click state
|
|
407
|
-
(React delegates at the root, this listener runs first), which is
|
|
408
|
-
exactly what decides whether the click reveals or hides. */
|
|
409
|
-
var expanded = typeof button.getAttribute === "function"
|
|
410
|
-
&& button.getAttribute("aria-expanded") === "true";
|
|
411
|
-
if (expanded) expandedSections.delete(key);
|
|
412
|
-
else expandedSections.add(key);
|
|
413
|
-
schedule();
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
|
|
367
|
+
/** Collapsing a group resets its fold state, so the click is observed. */
|
|
417
368
|
function markHeader(header, key) {
|
|
418
369
|
if (typeof header.addEventListener !== "function" || headerMarked.has(header)) return;
|
|
419
370
|
headerMarked.add(header);
|
|
@@ -435,11 +386,15 @@
|
|
|
435
386
|
button.addEventListener("click", function (event) {
|
|
436
387
|
if (disposed) return;
|
|
437
388
|
if (event !== undefined && typeof event.stopPropagation === "function") event.stopPropagation();
|
|
438
|
-
|
|
389
|
+
var current = button.wsrOnClick;
|
|
390
|
+
if (typeof current === "function") current();
|
|
439
391
|
});
|
|
440
392
|
section.appendChild(button);
|
|
441
393
|
toggles.add(button);
|
|
442
394
|
}
|
|
395
|
+
/* The handler is refreshed every pass: the button toggles between "show"
|
|
396
|
+
and "hide", and a captured first callback would only ever show. */
|
|
397
|
+
button.wsrOnClick = onClick;
|
|
443
398
|
if (button.textContent !== label) button.textContent = label;
|
|
444
399
|
return button;
|
|
445
400
|
}
|
|
@@ -463,10 +418,7 @@
|
|
|
463
418
|
var entry = entries[i];
|
|
464
419
|
var section = entry.section;
|
|
465
420
|
var rows = rowElements(section);
|
|
466
|
-
var hostButton = overflowButtonOf(section);
|
|
467
421
|
markHeader(entry.header, entry.key);
|
|
468
|
-
if (hostButton !== null) markButton(hostButton, entry.key);
|
|
469
|
-
releaseLabel(hostButton);
|
|
470
422
|
|
|
471
423
|
var expanded = expandedSections.has(entry.key);
|
|
472
424
|
var folded = 0;
|
|
@@ -487,14 +439,13 @@
|
|
|
487
439
|
}
|
|
488
440
|
}
|
|
489
441
|
|
|
490
|
-
/*
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
} else if (expanded) {
|
|
442
|
+
/* The host's own "show more" button and this fold are two different
|
|
443
|
+
things: the host decides which rows it RENDERS, this plugin decides
|
|
444
|
+
which rendered rows stay VISIBLE. Touching the host's label or its
|
|
445
|
+
click made the two fight (a count nobody could trust, and a collapse
|
|
446
|
+
that never came back), so they stay separate: the group gets its own
|
|
447
|
+
"show history" control whenever something is folded. */
|
|
448
|
+
if (expanded) {
|
|
498
449
|
(function (entryKey) {
|
|
499
450
|
ensureToggle(section, entryKey, t("fold.hide"), function () {
|
|
500
451
|
expandedSections.delete(entryKey);
|
|
@@ -810,6 +761,42 @@
|
|
|
810
761
|
});
|
|
811
762
|
}
|
|
812
763
|
|
|
764
|
+
/**
|
|
765
|
+
* Session "..." menu row: move the session into the trash folder.
|
|
766
|
+
*
|
|
767
|
+
* DSH itself only archives; the host half of this plugin stops the work,
|
|
768
|
+
* detaches the bookkeeping and renames the log directory into
|
|
769
|
+
* `<home>/.trash/ws-recency/`. The browser half only asks for confirmation
|
|
770
|
+
* and posts the id.
|
|
771
|
+
*/
|
|
772
|
+
function TrashMenuItem(props) {
|
|
773
|
+
var Item = ui.MenuItemButton;
|
|
774
|
+
if (typeof Item !== "function") return null;
|
|
775
|
+
var Icon = ui.IconTrashOutlineRegular;
|
|
776
|
+
return h(Item, {
|
|
777
|
+
icon: typeof Icon === "function" ? h(Icon, {}) : undefined,
|
|
778
|
+
onSelect: function () {
|
|
779
|
+
var question = t("trash.confirm");
|
|
780
|
+
var confirmed = typeof window.confirm === "function" ? window.confirm(question) : false;
|
|
781
|
+
if (!confirmed) return;
|
|
782
|
+
fetch("/ws-recency/session-trash", {
|
|
783
|
+
method: "POST",
|
|
784
|
+
headers: { "content-type": "application/json" },
|
|
785
|
+
body: JSON.stringify({ sessionId: props.sessionId }),
|
|
786
|
+
})
|
|
787
|
+
.then(function (response) { return response.json(); })
|
|
788
|
+
.then(function (result) {
|
|
789
|
+
if (result !== null && result.ok !== true && typeof window.alert === "function") {
|
|
790
|
+
window.alert(t("trash.failed") + (result.error || "unknown"));
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
.catch(function (error) {
|
|
794
|
+
if (typeof window.alert === "function") window.alert(t("trash.failed") + (error && error.message ? error.message : error));
|
|
795
|
+
});
|
|
796
|
+
},
|
|
797
|
+
}, props.t("menu.trash"));
|
|
798
|
+
}
|
|
799
|
+
|
|
813
800
|
/** Settings -> General row: how old a session must be before it is folded. */
|
|
814
801
|
function SettingsRow(props) {
|
|
815
802
|
var settings = props.useStore(function (state) { return state; });
|
|
@@ -916,8 +903,6 @@
|
|
|
916
903
|
if (element.removeAttribute !== undefined) element.removeAttribute(HIDDEN_ATTR);
|
|
917
904
|
});
|
|
918
905
|
hidden.clear();
|
|
919
|
-
labelled.forEach(function (button) { releaseLabel(button); });
|
|
920
|
-
labelled.clear();
|
|
921
906
|
toggles.forEach(function (button) { button.remove(); });
|
|
922
907
|
toggles.clear();
|
|
923
908
|
dots.forEach(function (dot) { dot.remove(); });
|
|
@@ -1044,6 +1029,16 @@
|
|
|
1044
1029
|
}, FollowUpDot);
|
|
1045
1030
|
});
|
|
1046
1031
|
|
|
1032
|
+
ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
|
|
1033
|
+
return ctx.slots.register({
|
|
1034
|
+
name: "sidebar.workspaces.session.menu.item",
|
|
1035
|
+
id: "ws-recency-trash",
|
|
1036
|
+
order: 700,
|
|
1037
|
+
store: prefs,
|
|
1038
|
+
locale: NS,
|
|
1039
|
+
}, TrashMenuItem);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1047
1042
|
ctx.effect(function () {
|
|
1048
1043
|
return function () { shutdown(); };
|
|
1049
1044
|
}, "dsh-ws-recency: list observer");
|
package/index.js
CHANGED
|
@@ -1,14 +1,226 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host half of dsh-ws-recency.
|
|
2
|
+
* Host half of dsh-ws-recency: the one part that needs the file system.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Deleting a conversation is the only sidebar operation the Client cannot do on
|
|
5
|
+
* its own: DSH only offers "archive" (a flag in `archivedSessionIds`), while the
|
|
6
|
+
* log directory and the projection cache stay on disk forever. This module moves
|
|
7
|
+
* a session into a trash folder instead of erasing it:
|
|
8
|
+
*
|
|
9
|
+
* 1. stop whatever the session is running (`workspace/session-stop`)
|
|
10
|
+
* 2. drop the archive flag and the pin
|
|
11
|
+
* 3. detach it from every workspace that lists it
|
|
12
|
+
* 4. delete its projection-cache row
|
|
13
|
+
* 5. move `<home>/sessions/<project>/<sessionId>` to
|
|
14
|
+
* `<home>/.trash/ws-recency/<sessionId>-<timestamp>`
|
|
15
|
+
* 6. broadcast `api-session/removed` so the browser drops the row
|
|
16
|
+
*
|
|
17
|
+
* Nothing is erased irreversibly: the log directory is renamed, so restoring is
|
|
18
|
+
* a move back. The route only answers loopback origins and only accepts POST.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-ws-recency
|
|
8
21
|
*/
|
|
22
|
+
import { mkdir, readdir, rename } from "node:fs/promises";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
|
|
25
|
+
/** Cordis plugin name shown by the loader. */
|
|
26
|
+
export const name = "ws-recency";
|
|
27
|
+
|
|
28
|
+
/** Host services this module asks for; `webServer` is injected separately. */
|
|
29
|
+
export const inject = ["sessionPersistence", "workspaceRegistry", "sessions"];
|
|
30
|
+
|
|
31
|
+
/** Exact route the client half calls. */
|
|
32
|
+
export const TRASH_SESSION_PATH = "/ws-recency/session-trash";
|
|
33
|
+
|
|
34
|
+
const STOP_SETTLE_MS = 500;
|
|
35
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
36
|
+
const RENAME_RETRY = 4;
|
|
37
|
+
const RENAME_GAP_MS = 150;
|
|
38
|
+
|
|
39
|
+
function loopbackOrigin(req) {
|
|
40
|
+
const origin = req.headers.origin;
|
|
41
|
+
if (origin === undefined) return true;
|
|
42
|
+
try {
|
|
43
|
+
const { hostname } = new URL(origin);
|
|
44
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readJsonBody(req) {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let size = 0;
|
|
53
|
+
for await (const chunk of req) {
|
|
54
|
+
size += chunk.length;
|
|
55
|
+
if (size > MAX_BODY_BYTES) throw new Error("request body too large");
|
|
56
|
+
chunks.push(chunk);
|
|
57
|
+
}
|
|
58
|
+
if (chunks.length === 0) return {};
|
|
59
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sendJson(res, status, value) {
|
|
63
|
+
const body = JSON.stringify(value);
|
|
64
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
65
|
+
res.end(body);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function dshHome() {
|
|
69
|
+
try {
|
|
70
|
+
const module = await import("@deepseek-ai/dsh-home-paths");
|
|
71
|
+
if (typeof module.resolveDshHome === "function") return module.resolveDshHome();
|
|
72
|
+
} catch {
|
|
73
|
+
/* fall through to the environment */
|
|
74
|
+
}
|
|
75
|
+
const fromEnv = process.env.DSH_HOME;
|
|
76
|
+
if (typeof fromEnv === "string" && fromEnv !== "") return fromEnv;
|
|
77
|
+
return join(process.env.USERPROFILE ?? process.env.HOME ?? ".", ".dsh");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Absolute path of a session's log file, or undefined when it cannot be located. */
|
|
81
|
+
function locateLog(ctx, meta) {
|
|
82
|
+
const persistence = ctx.sessionPersistence;
|
|
83
|
+
if (persistence !== undefined && typeof persistence.locate === "function") {
|
|
84
|
+
try {
|
|
85
|
+
const located = persistence.locate(meta);
|
|
86
|
+
if (located !== undefined && located !== null && typeof located.path === "string" && located.path !== "") return located.path;
|
|
87
|
+
} catch {
|
|
88
|
+
/* fall back to the scan below */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Rename with a short retry: Windows can hold a handle for a moment. */
|
|
95
|
+
async function moveWithRetry(source, target) {
|
|
96
|
+
for (let attempt = 0; attempt < RENAME_RETRY; attempt += 1) {
|
|
97
|
+
try {
|
|
98
|
+
await rename(source, target);
|
|
99
|
+
return true;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (attempt === RENAME_RETRY - 1) throw error;
|
|
102
|
+
await new Promise((resolve) => setTimeout(resolve, RENAME_GAP_MS));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Move the session log directory into the trash; returns the new path. */
|
|
109
|
+
async function trashSessionLog(ctx, meta, trashRoot) {
|
|
110
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
111
|
+
const target = join(trashRoot, `${meta.id}-${stamp}`);
|
|
112
|
+
await mkdir(trashRoot, { recursive: true });
|
|
113
|
+
|
|
114
|
+
const located = locateLog(ctx, meta);
|
|
115
|
+
if (located !== undefined) {
|
|
116
|
+
if (await moveWithRetry(dirname(located), target)) return target;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* No `locate()` (older host): scan `<home>/sessions/<project>/<sessionId>`. */
|
|
120
|
+
const home = await dshHome();
|
|
121
|
+
const root = join(home, "sessions");
|
|
122
|
+
let projects;
|
|
123
|
+
try {
|
|
124
|
+
projects = await readdir(root, { withFileTypes: true });
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
for (const project of projects) {
|
|
129
|
+
if (!project.isDirectory()) continue;
|
|
130
|
+
try {
|
|
131
|
+
if (await moveWithRetry(join(root, project.name, meta.id), target)) return target;
|
|
132
|
+
} catch {
|
|
133
|
+
/* not in this project directory */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Stop the session, drop its bookkeeping, trash its logs, tell the browser. */
|
|
140
|
+
export async function trashSession(ctx, sessionId) {
|
|
141
|
+
const live = typeof ctx.sessions?.get === "function" ? ctx.sessions.get(sessionId) : undefined;
|
|
142
|
+
let meta;
|
|
143
|
+
if (live !== undefined) {
|
|
144
|
+
meta = live.header;
|
|
145
|
+
} else {
|
|
146
|
+
const stat = await ctx.sessionPersistence.stat(sessionId);
|
|
147
|
+
if (stat === undefined) return { ok: false, error: "session not found" };
|
|
148
|
+
meta = stat.header;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await ctx.parallel("workspace/session-stop", { sessionId });
|
|
153
|
+
} catch {
|
|
154
|
+
/* stopping is best effort, exactly like "stop and archive" */
|
|
155
|
+
}
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, STOP_SETTLE_MS));
|
|
157
|
+
|
|
158
|
+
try { await ctx.workspaceRegistry.unarchiveSession(sessionId); } catch { /* not archived */ }
|
|
159
|
+
try { await ctx.workspaceRegistry.unpinSession(sessionId); } catch { /* not pinned */ }
|
|
160
|
+
|
|
161
|
+
for (const workspace of ctx.workspaceRegistry.list()) {
|
|
162
|
+
if (!Array.isArray(workspace.sessionIds) || !workspace.sessionIds.includes(sessionId)) continue;
|
|
163
|
+
try { await workspace.detachSession(sessionId); } catch { /* keep going */ }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const domain = ctx.get("storageDomain");
|
|
168
|
+
const projcache = typeof domain?.get === "function" ? domain.get("session_projcache") : undefined;
|
|
169
|
+
if (projcache !== undefined) await projcache.table("sessions").delete(sessionId);
|
|
170
|
+
} catch {
|
|
171
|
+
/* the cache is derived data: a miss only costs a cold read */
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const home = await dshHome();
|
|
175
|
+
const trashRoot = join(home, ".trash", "ws-recency");
|
|
176
|
+
let moved;
|
|
177
|
+
try {
|
|
178
|
+
moved = await trashSessionLog(ctx, meta, trashRoot);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
ctx.emit("api-session/removed", sessionId);
|
|
184
|
+
return { ok: true, trash: moved };
|
|
185
|
+
}
|
|
9
186
|
|
|
10
|
-
/**
|
|
11
|
-
export
|
|
187
|
+
/** POST { sessionId } -> move that session into the trash folder. */
|
|
188
|
+
export async function handleTrashRequest(ctx, req, res) {
|
|
189
|
+
if ((req.method ?? "POST").toUpperCase() !== "POST") {
|
|
190
|
+
sendJson(res, 405, { ok: false, error: "method not allowed" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (!loopbackOrigin(req)) {
|
|
194
|
+
sendJson(res, 403, { ok: false, error: "origin not trusted" });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const body = await readJsonBody(req);
|
|
199
|
+
const sessionId = body !== null && typeof body.sessionId === "string" ? body.sessionId.trim() : "";
|
|
200
|
+
if (sessionId === "") {
|
|
201
|
+
sendJson(res, 400, { ok: false, error: "sessionId required" });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const result = await trashSession(ctx, sessionId);
|
|
205
|
+
sendJson(res, result.ok ? 200 : 500, result);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
12
210
|
|
|
13
|
-
/**
|
|
14
|
-
export function apply() {
|
|
211
|
+
/** Cordis entry point: mount the route when the host has a web server. */
|
|
212
|
+
export function apply(ctx) {
|
|
213
|
+
try {
|
|
214
|
+
if (typeof ctx.inject === "function") {
|
|
215
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
216
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
217
|
+
kind: "exact",
|
|
218
|
+
path: TRASH_SESSION_PATH,
|
|
219
|
+
handler: (req, res) => handleTrashRequest(ctx, req, res),
|
|
220
|
+
}), "ws-recency: trash route");
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
/* a headless profile simply gets no route */
|
|
225
|
+
}
|
|
226
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ws-recency",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Sidebar session management for DeepSeek Harness: workspace groups sorted by latest activity, stale history folded, a global pinned section,
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "Sidebar session management for DeepSeek Harness: workspace groups sorted by latest activity, stale history folded, a global pinned section, follow-up marks, and session delete into a recoverable trash.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "chyxhtc",
|
|
7
7
|
"repository": {
|