@omdp/dsh-archived-sessions 0.3.0 → 0.3.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 +2 -0
- package/lib/client.js +583 -0
- package/lib/index.js +743 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,6 +65,7 @@ pnpm add @omdp/dsh-archived-sessions -w
|
|
|
65
65
|
|
|
66
66
|
### 变更记录
|
|
67
67
|
|
|
68
|
+
- **0.3.1**(2026-09-10):**修复 0.3.0 的发布事故**——0.3.0 的 tarball 里没有 `lib/`(仓库 `.gitignore` 的 `**/lib/` 规则把源码吞了,npm 只打包到 4 个文件),安装后插件加载失败会拖垮 DSH;0.3.1 补回 `lib/index.js` + `lib/client.js`(发布前已核对 tarball 内容)。
|
|
68
69
|
- **0.3.0**(2026-09-10):fork 自 0.2.0;适配 DSH 0.1.5-rc.1(list 快照形状 + 路径自解析);按树删除子会话;孤儿清理;删除路径安全校验。
|
|
69
70
|
|
|
70
71
|
---
|
|
@@ -126,6 +127,7 @@ pnpm add @omdp/dsh-archived-sessions -w
|
|
|
126
127
|
|
|
127
128
|
### Changelog
|
|
128
129
|
|
|
130
|
+
- **0.3.1** (2026-09-10): **fixes a 0.3.0 release accident** — the 0.3.0 tarball contained no `lib/` (the repo's `**/lib/` gitignore rule swallowed the source, so npm packed only 4 files); installing it broke the plugin load and could take DSH down. 0.3.1 restores `lib/index.js` + `lib/client.js` (tarball contents verified before publishing).
|
|
129
131
|
- **0.3.0** (2026-09-10): forked from 0.2.0; DSH 0.1.5-rc.1 support (snapshot list shape + self path resolution); tree delete; orphan sweep; deletion path safety check.
|
|
130
132
|
|
|
131
133
|
## License
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@muwinds/dsh-archived-sessions",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
|
|
9
|
+
// ---------- styles ----------
|
|
10
|
+
var STYLE_ID = "dsw-arch-style";
|
|
11
|
+
var CSS =
|
|
12
|
+
".dsw-arch-page{font-size:13px;padding:2px 2px 24px;}" +
|
|
13
|
+
".dsw-arch-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}" +
|
|
14
|
+
".dsw-arch-page-title{font-weight:600;color:var(--dsw-alias-label-primary);}" +
|
|
15
|
+
".dsw-arch-meta{color:var(--dsw-alias-label-secondary);font-size:12px;}" +
|
|
16
|
+
".dsw-arch-btn{background:transparent;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:6px;padding:3px 7px;font-size:12px;cursor:pointer;margin-right:4px;white-space:nowrap;}" +
|
|
17
|
+
".dsw-arch-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);}" +
|
|
18
|
+
".dsw-arch-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);}" +
|
|
19
|
+
".dsw-arch-btn:disabled{opacity:0.45;cursor:default;}" +
|
|
20
|
+
".dsw-arch-table{width:100%;border-collapse:collapse;table-layout:fixed;}" +
|
|
21
|
+
".dsw-arch-table th{text-align:left;font-weight:600;color:var(--dsw-alias-label-secondary);font-size:12px;padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);}" +
|
|
22
|
+
".dsw-arch-table td{padding:8px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);vertical-align:top;}" +
|
|
23
|
+
".dsw-arch-table tr:last-child td{border-bottom:none;}" +
|
|
24
|
+
".dsw-arch-table th:nth-child(1),.dsw-arch-table td:nth-child(1){width:32px;text-align:center;padding-left:2px;padding-right:2px;}" +
|
|
25
|
+
".dsw-arch-table th:nth-child(2),.dsw-arch-table td:nth-child(2){width:auto;}" +
|
|
26
|
+
".dsw-arch-table th:nth-child(3),.dsw-arch-table td:nth-child(3){width:104px;}" +
|
|
27
|
+
".dsw-arch-table th:nth-child(4),.dsw-arch-table td:nth-child(4){width:92px;padding-left:2px;padding-right:2px;}" +
|
|
28
|
+
".dsw-arch-size{white-space:nowrap;}" +
|
|
29
|
+
".dsw-arch-actions{white-space:nowrap;}" +
|
|
30
|
+
".dsw-arch-title-click{cursor:pointer;color:var(--dsw-alias-label-primary);}" +
|
|
31
|
+
".dsw-arch-title-click:hover{text-decoration:underline;}" +
|
|
32
|
+
".dsw-arch-id,.dsw-arch-path{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;color:var(--dsw-alias-label-secondary);word-break:break-all;margin-top:2px;}" +
|
|
33
|
+
".dsw-arch-selectbar{display:flex;align-items:center;gap:8px;padding:6px 10px;margin-bottom:10px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;font-size:12px;flex-wrap:wrap;}" +
|
|
34
|
+
".dsw-arch-selectbar .dsw-arch-meta{flex:1;}" +
|
|
35
|
+
".dsw-arch-detail{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:10px 12px;font-size:12px;text-align:left;}" +
|
|
36
|
+
".dsw-arch-detail-head{margin-bottom:8px;}" +
|
|
37
|
+
".dsw-arch-detail-body{max-height:360px;overflow:auto;}" +
|
|
38
|
+
".dsw-arch-msg{margin-bottom:8px;white-space:pre-wrap;word-break:break-word;}" +
|
|
39
|
+
".dsw-arch-msg-role{display:inline-block;font-weight:600;margin-right:6px;}" +
|
|
40
|
+
".dsw-arch-msg-user .dsw-arch-msg-role{color:var(--dsw-alias-label-primary);}" +
|
|
41
|
+
".dsw-arch-msg-assistant .dsw-arch-msg-role{color:var(--dsw-alias-brand-primary);}" +
|
|
42
|
+
".dsw-arch-msg-tool .dsw-arch-msg-role{color:var(--dsw-alias-state-warn-primary);}" +
|
|
43
|
+
".dsw-arch-msg-time{color:var(--dsw-alias-label-secondary);font-size:11px;margin-left:6px;}" +
|
|
44
|
+
".dsw-arch-detail-note{color:var(--dsw-alias-label-secondary);font-size:11px;margin-bottom:8px;}" +
|
|
45
|
+
".dsw-arch-error{color:var(--dsw-alias-state-error-primary);font-size:12px;margin-bottom:8px;}" +
|
|
46
|
+
".dsw-arch-empty{color:var(--dsw-alias-label-secondary);padding:28px 0;text-align:center;}" +
|
|
47
|
+
".dsw-arch-badge{display:inline-block;border:1px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);border-radius:4px;padding:1px 6px;font-size:11px;}";
|
|
48
|
+
|
|
49
|
+
function ensureStyle() {
|
|
50
|
+
try {
|
|
51
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
52
|
+
var el = document.createElement("style");
|
|
53
|
+
el.id = STYLE_ID;
|
|
54
|
+
el.textContent = CSS;
|
|
55
|
+
document.head.appendChild(el);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// ignore
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------- api ----------
|
|
62
|
+
function api(action, args) {
|
|
63
|
+
return fetch("/dsh-archived/" + action, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "content-type": "application/json" },
|
|
66
|
+
body: JSON.stringify(args || {})
|
|
67
|
+
}).then(function (res) {
|
|
68
|
+
return res.json().catch(function () {
|
|
69
|
+
return null;
|
|
70
|
+
});
|
|
71
|
+
}).then(function (payload) {
|
|
72
|
+
if (payload === null || typeof payload !== "object") {
|
|
73
|
+
var err = new Error("请求失败:服务返回了无效响应");
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
if (payload.error) {
|
|
77
|
+
var e2 = new Error(String(payload.error));
|
|
78
|
+
throw e2;
|
|
79
|
+
}
|
|
80
|
+
return payload;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------- page component ----------
|
|
85
|
+
function ArchivedSessionsPage() {
|
|
86
|
+
var _useState = react.useState(true);
|
|
87
|
+
var loading = _useState[0];
|
|
88
|
+
var setLoading = _useState[1];
|
|
89
|
+
var _useState2 = react.useState(null);
|
|
90
|
+
var error = _useState2[0];
|
|
91
|
+
var setError = _useState2[1];
|
|
92
|
+
var _useState3 = react.useState([]);
|
|
93
|
+
var items = _useState3[0];
|
|
94
|
+
var setItems = _useState3[1];
|
|
95
|
+
var _useState4 = react.useState(0);
|
|
96
|
+
var total = _useState4[0];
|
|
97
|
+
var setTotal = _useState4[1];
|
|
98
|
+
var _useState5 = react.useState(null);
|
|
99
|
+
var busy = _useState5[0];
|
|
100
|
+
var setBusy = _useState5[1];
|
|
101
|
+
var _useState6 = react.useState(null);
|
|
102
|
+
var confirmId = _useState6[0];
|
|
103
|
+
var setConfirmId = _useState6[1];
|
|
104
|
+
var _useState7 = react.useState([]);
|
|
105
|
+
var selected = _useState7[0];
|
|
106
|
+
var setSelected = _useState7[1];
|
|
107
|
+
var _useState8 = react.useState(null);
|
|
108
|
+
var batchBusy = _useState8[0];
|
|
109
|
+
var setBatchBusy = _useState8[1];
|
|
110
|
+
var _useState9 = react.useState(null);
|
|
111
|
+
var batchConfirm = _useState9[0];
|
|
112
|
+
var setBatchConfirm = _useState9[1];
|
|
113
|
+
var _useState10 = react.useState(null);
|
|
114
|
+
var expandedId = _useState10[0];
|
|
115
|
+
var setExpandedId = _useState10[1];
|
|
116
|
+
var _useState11 = react.useState(null);
|
|
117
|
+
var detail = _useState11[0];
|
|
118
|
+
var setDetail = _useState11[1];
|
|
119
|
+
var _useState12 = react.useState([]);
|
|
120
|
+
var orphans = _useState12[0];
|
|
121
|
+
var setOrphans = _useState12[1];
|
|
122
|
+
var _useState13 = react.useState(false);
|
|
123
|
+
var showOrphans = _useState13[0];
|
|
124
|
+
var setShowOrphans = _useState13[1];
|
|
125
|
+
var _useState14 = react.useState(null);
|
|
126
|
+
var orphanBusy = _useState14[0];
|
|
127
|
+
var setOrphanBusy = _useState14[1];
|
|
128
|
+
var _useState15 = react.useState(false);
|
|
129
|
+
var confirmSweep = _useState15[0];
|
|
130
|
+
var setConfirmSweep = _useState15[1];
|
|
131
|
+
|
|
132
|
+
function describe(err) {
|
|
133
|
+
if (err === null || err === undefined) return String(err);
|
|
134
|
+
if (typeof err === "object" && typeof err.message === "string") return err.message;
|
|
135
|
+
return String(err);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function applyList(res) {
|
|
139
|
+
var next = res && Array.isArray(res.items) ? res.items : [];
|
|
140
|
+
setItems(next);
|
|
141
|
+
setTotal(res && typeof res.totalBytes === "number" ? res.totalBytes : 0);
|
|
142
|
+
var ids = {};
|
|
143
|
+
for (var i = 0; i < next.length; i++) ids[next[i].id] = true;
|
|
144
|
+
setSelected(function (sel) {
|
|
145
|
+
return sel.filter(function (id) { return ids[id]; });
|
|
146
|
+
});
|
|
147
|
+
if (expandedId && !ids[expandedId]) {
|
|
148
|
+
setExpandedId(null);
|
|
149
|
+
setDetail(null);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function load() {
|
|
154
|
+
setLoading(true);
|
|
155
|
+
setError(null);
|
|
156
|
+
api("list", {}).then(function (res) {
|
|
157
|
+
applyList(res);
|
|
158
|
+
setLoading(false);
|
|
159
|
+
}).catch(function (err) {
|
|
160
|
+
setError(describe(err));
|
|
161
|
+
setLoading(false);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
react.useEffect(function () {
|
|
166
|
+
load();
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
function refreshViews() {
|
|
170
|
+
try {
|
|
171
|
+
if (ctx.workspaces && typeof ctx.workspaces.refresh === "function") {
|
|
172
|
+
var p = ctx.workspaces.refresh();
|
|
173
|
+
if (p && typeof p.catch === "function") p.catch(function () {});
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
// ignore
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
if (ctx.sessions && typeof ctx.sessions.refresh === "function") {
|
|
180
|
+
var p2 = ctx.sessions.refresh();
|
|
181
|
+
if (p2 && typeof p2.catch === "function") p2.catch(function () {});
|
|
182
|
+
}
|
|
183
|
+
} catch (e) {
|
|
184
|
+
// ignore
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function reloadAfterAction() {
|
|
189
|
+
refreshViews();
|
|
190
|
+
return api("list", {}).then(applyList);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- orphan management ----
|
|
194
|
+
function loadOrphans() {
|
|
195
|
+
setShowOrphans(true);
|
|
196
|
+
setOrphanBusy("load");
|
|
197
|
+
setError(null);
|
|
198
|
+
api("orphans", {}).then(function (res) {
|
|
199
|
+
setOrphans(res && Array.isArray(res.items) ? res.items : []);
|
|
200
|
+
setOrphanBusy(null);
|
|
201
|
+
}).catch(function (err) {
|
|
202
|
+
setError(describe(err));
|
|
203
|
+
setOrphanBusy(null);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function sweepOrphans() {
|
|
208
|
+
setOrphanBusy("sweep");
|
|
209
|
+
setError(null);
|
|
210
|
+
api("sweep", {}).then(function (res) {
|
|
211
|
+
setConfirmSweep(false);
|
|
212
|
+
setOrphans([]);
|
|
213
|
+
setOrphanBusy(null);
|
|
214
|
+
refreshViews();
|
|
215
|
+
return api("list", {}).then(applyList);
|
|
216
|
+
}).catch(function (err) {
|
|
217
|
+
setError(describe(err));
|
|
218
|
+
setOrphanBusy(null);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- row operations ----
|
|
223
|
+
function unarchive(id) {
|
|
224
|
+
setBusy("unarchive:" + id);
|
|
225
|
+
setError(null);
|
|
226
|
+
api("unarchive", { sessionId: id })
|
|
227
|
+
.then(function () { return reloadAfterAction(); })
|
|
228
|
+
.catch(function (err) { setError(describe(err)); })
|
|
229
|
+
.finally(function () { setBusy(null); });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Single-button two-step delete: 删除 -> 确认 (auto-reverts after 5s).
|
|
233
|
+
function armDelete(id) {
|
|
234
|
+
setConfirmId(id);
|
|
235
|
+
try {
|
|
236
|
+
if (ctx.timer && typeof ctx.timer.timeout === "function") {
|
|
237
|
+
ctx.timer.timeout(function () {
|
|
238
|
+
setConfirmId(function (cur) { return cur === id ? null : cur; });
|
|
239
|
+
}, 5000);
|
|
240
|
+
}
|
|
241
|
+
} catch (e) {
|
|
242
|
+
// timer unavailable; stay armed until the user acts
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function doDelete(id) {
|
|
247
|
+
setBusy("delete:" + id);
|
|
248
|
+
setError(null);
|
|
249
|
+
api("delete", { sessionId: id })
|
|
250
|
+
.then(function () { return reloadAfterAction(); })
|
|
251
|
+
.catch(function (err) { setError(describe(err)); })
|
|
252
|
+
.finally(function () {
|
|
253
|
+
setBusy(null);
|
|
254
|
+
setConfirmId(null);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---- selection ----
|
|
259
|
+
function toggleSelect(id) {
|
|
260
|
+
setSelected(function (sel) {
|
|
261
|
+
return sel.includes(id) ? sel.filter(function (x) { return x !== id; }) : sel.concat([id]);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function toggleSelectAll() {
|
|
266
|
+
setSelected(function (sel) {
|
|
267
|
+
return sel.length === items.length ? [] : items.map(function (item) { return item.id; });
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- batch operations ----
|
|
272
|
+
function runBatch(ids, kind) {
|
|
273
|
+
setBatchBusy(kind);
|
|
274
|
+
setError(null);
|
|
275
|
+
var results = [];
|
|
276
|
+
var cursor = Promise.resolve();
|
|
277
|
+
var method = kind === "delete" ? "delete" : "unarchive";
|
|
278
|
+
for (var i = 0; i < ids.length; i++) {
|
|
279
|
+
(function (id) {
|
|
280
|
+
cursor = cursor.then(function () {
|
|
281
|
+
return api(method, { sessionId: id })
|
|
282
|
+
.then(function () { results.push({ id: id, ok: true }); })
|
|
283
|
+
.catch(function (err) { results.push({ id: id, ok: false, error: describe(err) }); });
|
|
284
|
+
});
|
|
285
|
+
})(ids[i]);
|
|
286
|
+
}
|
|
287
|
+
cursor.then(function () {
|
|
288
|
+
var failed = results.filter(function (r) { return !r.ok; });
|
|
289
|
+
if (failed.length > 0) {
|
|
290
|
+
setError((kind === "delete" ? "删除" : "释放") + "失败 " + failed.length + " 个会话:" + failed.map(function (f) { return f.id; }).join(", "));
|
|
291
|
+
}
|
|
292
|
+
refreshViews();
|
|
293
|
+
return api("list", {});
|
|
294
|
+
}).then(applyList)
|
|
295
|
+
.catch(function (err) { setError(describe(err)); })
|
|
296
|
+
.finally(function () {
|
|
297
|
+
setBatchBusy(null);
|
|
298
|
+
setBatchConfirm(null);
|
|
299
|
+
setSelected([]);
|
|
300
|
+
setConfirmId(null);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function releaseAll() { runBatch(items.map(function (item) { return item.id; }), "unarchive"); }
|
|
305
|
+
function clearAll() { runBatch(items.map(function (item) { return item.id; }), "delete"); }
|
|
306
|
+
function releaseSelected() { runBatch(selected, "unarchive"); }
|
|
307
|
+
function deleteSelected() { runBatch(selected, "delete"); }
|
|
308
|
+
|
|
309
|
+
// ---- detail ----
|
|
310
|
+
function toggleDetail(id) {
|
|
311
|
+
if (expandedId === id) {
|
|
312
|
+
setExpandedId(null);
|
|
313
|
+
setDetail(null);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
setExpandedId(id);
|
|
317
|
+
setDetail({ id: id, loading: true, data: null, error: null });
|
|
318
|
+
api("detail", { sessionId: id }).then(function (data) {
|
|
319
|
+
setDetail({ id: id, loading: false, data: data, error: null });
|
|
320
|
+
}).catch(function (err) {
|
|
321
|
+
setDetail({ id: id, loading: false, data: null, error: describe(err) });
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function detailCell() {
|
|
326
|
+
if (!detail) return null;
|
|
327
|
+
if (detail.loading) {
|
|
328
|
+
return react.createElement("div", { className: "dsw-arch-detail" }, "加载会话内容中…");
|
|
329
|
+
}
|
|
330
|
+
if (detail.error) {
|
|
331
|
+
return react.createElement("div", { className: "dsw-arch-detail" }, String(detail.error));
|
|
332
|
+
}
|
|
333
|
+
var d = detail.data;
|
|
334
|
+
if (!d || !Array.isArray(d.messages)) {
|
|
335
|
+
return react.createElement("div", { className: "dsw-arch-detail" }, "没有可显示的内容");
|
|
336
|
+
}
|
|
337
|
+
var headParts = [];
|
|
338
|
+
if (d.createdAt) headParts.push("创建于 " + fmtTime(d.createdAt));
|
|
339
|
+
if (d.cwd) headParts.push(d.cwd);
|
|
340
|
+
var headEl = react.createElement("div", { className: "dsw-arch-detail-head" },
|
|
341
|
+
react.createElement("span", { className: "dsw-arch-page-title" }, "会话内容"),
|
|
342
|
+
react.createElement("span", { className: "dsw-arch-meta" }, " · " + headParts.join(" · "))
|
|
343
|
+
);
|
|
344
|
+
var noteEl = react.createElement("div", { className: "dsw-arch-detail-note" },
|
|
345
|
+
"共 " + String(d.totalEvents) + " 条事件,提取 " + String(d.messageCount) + " 条消息" + (d.truncated ? "(仅显示前 100 条)" : "")
|
|
346
|
+
);
|
|
347
|
+
var msgs = d.messages.map(function (m) {
|
|
348
|
+
var roleLabel = m.role === "user" ? "用户" : m.role === "assistant" ? "助手" : "工具";
|
|
349
|
+
return react.createElement("div", { className: "dsw-arch-msg dsw-arch-msg-" + m.role },
|
|
350
|
+
react.createElement("span", { className: "dsw-arch-msg-role" }, roleLabel),
|
|
351
|
+
react.createElement("span", { className: "dsw-arch-msg-time" }, fmtTime(m.time)),
|
|
352
|
+
react.createElement("div", null, m.text)
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
return react.createElement("div", { className: "dsw-arch-detail" }, headEl, noteEl,
|
|
356
|
+
react.createElement("div", { className: "dsw-arch-detail-body" }, msgs)
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---- formatting ----
|
|
361
|
+
function fmtSize(bytes) {
|
|
362
|
+
var n = Number(bytes) || 0;
|
|
363
|
+
if (n <= 0) return "0 B";
|
|
364
|
+
var units = ["B", "KB", "MB", "GB", "TB"];
|
|
365
|
+
var v = n;
|
|
366
|
+
var i = 0;
|
|
367
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
368
|
+
v = v / 1024;
|
|
369
|
+
i = i + 1;
|
|
370
|
+
}
|
|
371
|
+
return (i === 0 ? String(Math.round(v)) : v.toFixed(1)) + " " + units[i];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function fmtTime(ms) {
|
|
375
|
+
if (!ms) return "—";
|
|
376
|
+
try {
|
|
377
|
+
return new Date(ms).toLocaleString();
|
|
378
|
+
} catch (e) {
|
|
379
|
+
return String(ms);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
var anyBusy = busy !== null || batchBusy !== null;
|
|
384
|
+
|
|
385
|
+
var header = react.createElement("div", { className: "dsw-arch-toolbar" },
|
|
386
|
+
react.createElement("span", { className: "dsw-arch-page-title" }, "归档会话"),
|
|
387
|
+
react.createElement("span", { className: "dsw-arch-meta" },
|
|
388
|
+
String(items.length) + " 个会话 · 共 " + fmtSize(total) + (loading ? " · 加载中…" : "") + (batchBusy ? " · 批量操作中…" : "")),
|
|
389
|
+
react.createElement("button", { className: "dsw-arch-btn", disabled: anyBusy, onClick: function () { load(); } }, "刷新"),
|
|
390
|
+
react.createElement("button", { className: "dsw-arch-btn", disabled: anyBusy || items.length === 0, onClick: function () { releaseAll(); } }, "一键释放"),
|
|
391
|
+
react.createElement("button", { className: "dsw-arch-btn dsw-arch-btn-danger", disabled: anyBusy || items.length === 0, onClick: function () { setBatchConfirm("clear"); } }, "一键清空"),
|
|
392
|
+
react.createElement("button", { className: "dsw-arch-btn", disabled: anyBusy, onClick: function () { loadOrphans(); } }, showOrphans ? "隐藏孤儿" : "孤儿清理")
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
var confirmBar = null;
|
|
396
|
+
if (batchConfirm === "clear") {
|
|
397
|
+
confirmBar = react.createElement("div", { className: "dsw-arch-selectbar" },
|
|
398
|
+
react.createElement("span", { className: "dsw-arch-meta", style: { color: "var(--dsw-alias-state-warn-primary)" } }, "确认清空全部 " + String(items.length) + " 个归档会话?此操作会从硬盘删除(含各自的子会话)且不可恢复。"),
|
|
399
|
+
react.createElement("button", { className: "dsw-arch-btn dsw-arch-btn-danger", disabled: anyBusy, onClick: function () { clearAll(); } }, "确认清空"),
|
|
400
|
+
react.createElement("button", { className: "dsw-arch-btn", disabled: anyBusy, onClick: function () { setBatchConfirm(null); } }, "取消")
|
|
401
|
+
);
|
|
402
|
+
} else if (batchConfirm === "delete") {
|
|
403
|
+
confirmBar = react.createElement("div", { className: "dsw-arch-selectbar" },
|
|
404
|
+
react.createElement("span", { className: "dsw-arch-meta", style: { color: "var(--dsw-alias-state-warn-primary)" } }, "确认删除选中的 " + String(selected.length) + " 个会话(含各自的子会话)?此操作不可恢复。"),
|
|
405
|
+
react.createElement("button", { className: "dsw-arch-btn dsw-arch-btn-danger", disabled: anyBusy, onClick: function () { deleteSelected(); } }, "确认删除"),
|
|
406
|
+
react.createElement("button", { className: "dsw-arch-btn", disabled: anyBusy, onClick: function () { setBatchConfirm(null); } }, "取消")
|
|
407
|
+
);
|
|
408
|
+
} else if (selected.length > 0) {
|
|
409
|
+
confirmBar = react.createElement("div", { className: "dsw-arch-selectbar" },
|
|
410
|
+
react.createElement("span", { className: "dsw-arch-meta" }, "已选择 " + String(selected.length) + " 个会话"),
|
|
411
|
+
react.createElement("button", { className: "dsw-arch-btn", disabled: anyBusy, onClick: function () { releaseSelected(); } }, "释放选中"),
|
|
412
|
+
react.createElement("button", { className: "dsw-arch-btn dsw-arch-btn-danger", disabled: anyBusy, onClick: function () { setBatchConfirm("delete"); } }, "删除选中"),
|
|
413
|
+
react.createElement("button", { className: "dsw-arch-btn", disabled: anyBusy, onClick: function () { setSelected([]); } }, "取消选择")
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
var body;
|
|
418
|
+
if (loading && items.length === 0) {
|
|
419
|
+
body = react.createElement("div", { className: "dsw-arch-empty" }, "加载中…");
|
|
420
|
+
} else if (items.length === 0) {
|
|
421
|
+
body = react.createElement("div", { className: "dsw-arch-empty" }, "没有已归档的会话");
|
|
422
|
+
} else {
|
|
423
|
+
var allSelected = selected.length === items.length;
|
|
424
|
+
var childCount = {};
|
|
425
|
+
for (var ci = 0; ci < items.length; ci++) {
|
|
426
|
+
var p = items[ci].parentSession;
|
|
427
|
+
if (p) childCount[p] = (childCount[p] || 0) + 1;
|
|
428
|
+
}
|
|
429
|
+
var rows = [];
|
|
430
|
+
for (var i = 0; i < items.length; i++) {
|
|
431
|
+
(function (item) {
|
|
432
|
+
var isBusy = busy === "unarchive:" + item.id || busy === "delete:" + item.id;
|
|
433
|
+
var confirming = confirmId === item.id;
|
|
434
|
+
var metaParts = [];
|
|
435
|
+
if (item.createdAt) metaParts.push("创建于 " + fmtTime(item.createdAt));
|
|
436
|
+
if (item.cwd) metaParts.push(item.cwd);
|
|
437
|
+
var kids = childCount[item.id] || 0;
|
|
438
|
+
if (kids > 0) metaParts.push("含 " + String(kids) + " 个子会话");
|
|
439
|
+
|
|
440
|
+
var checkEl = react.createElement("input", {
|
|
441
|
+
type: "checkbox",
|
|
442
|
+
checked: selected.includes(item.id),
|
|
443
|
+
disabled: anyBusy,
|
|
444
|
+
onChange: function () { toggleSelect(item.id); }
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
var chevron = expandedId === item.id ? "▾ " : "▸ ";
|
|
448
|
+
var titleEl = react.createElement("div", {
|
|
449
|
+
className: "dsw-arch-title-click",
|
|
450
|
+
title: "点击查看会话内容",
|
|
451
|
+
onClick: function () { toggleDetail(item.id); }
|
|
452
|
+
}, chevron + (item.title || "(无标题)"));
|
|
453
|
+
var idEl = react.createElement("div", { className: "dsw-arch-id" }, item.id);
|
|
454
|
+
var metaEl = react.createElement("div", { className: "dsw-arch-meta" }, metaParts.join(" · "));
|
|
455
|
+
var pathEl = item.path
|
|
456
|
+
? react.createElement("div", { className: "dsw-arch-path" }, item.path)
|
|
457
|
+
: null;
|
|
458
|
+
var sizeEl = item.missing
|
|
459
|
+
? react.createElement("span", { className: "dsw-arch-badge" }, "文件缺失")
|
|
460
|
+
: react.createElement("span", { className: "dsw-arch-size" }, fmtSize(item.sizeBytes));
|
|
461
|
+
|
|
462
|
+
var unarchiveBtn = react.createElement("button", {
|
|
463
|
+
className: "dsw-arch-btn",
|
|
464
|
+
disabled: anyBusy || item.missing,
|
|
465
|
+
onClick: function () { unarchive(item.id); }
|
|
466
|
+
}, "释放");
|
|
467
|
+
|
|
468
|
+
var deleteBtn = react.createElement("button", {
|
|
469
|
+
className: "dsw-arch-btn dsw-arch-btn-danger",
|
|
470
|
+
disabled: anyBusy,
|
|
471
|
+
title: confirming ? "再次点击确认删除(5 秒后自动取消)" : "删除(需再次点击确认)",
|
|
472
|
+
onClick: function () { confirming ? doDelete(item.id) : armDelete(item.id); }
|
|
473
|
+
}, confirming ? "确认" : "删除");
|
|
474
|
+
|
|
475
|
+
rows.push(react.createElement("tr", { key: item.id },
|
|
476
|
+
react.createElement("td", null, checkEl),
|
|
477
|
+
react.createElement("td", null, titleEl, idEl, metaEl, pathEl),
|
|
478
|
+
react.createElement("td", null, sizeEl),
|
|
479
|
+
react.createElement("td", { className: "dsw-arch-actions" }, unarchiveBtn, deleteBtn)
|
|
480
|
+
));
|
|
481
|
+
|
|
482
|
+
if (expandedId === item.id) {
|
|
483
|
+
rows.push(react.createElement("tr", { key: item.id + "-detail" },
|
|
484
|
+
react.createElement("td", { colSpan: 4, style: { padding: "4px 10px 10px" } }, detailCell())
|
|
485
|
+
));
|
|
486
|
+
}
|
|
487
|
+
})(items[i]);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
var headCheck = react.createElement("input", {
|
|
491
|
+
type: "checkbox",
|
|
492
|
+
checked: allSelected,
|
|
493
|
+
disabled: anyBusy,
|
|
494
|
+
onChange: function () { toggleSelectAll(); }
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
body = react.createElement("table", { className: "dsw-arch-table" },
|
|
498
|
+
react.createElement("thead", null,
|
|
499
|
+
react.createElement("tr", null,
|
|
500
|
+
react.createElement("th", null, headCheck),
|
|
501
|
+
react.createElement("th", null, "会话"),
|
|
502
|
+
react.createElement("th", null, "磁盘占用"),
|
|
503
|
+
react.createElement("th", null, "操作")
|
|
504
|
+
)
|
|
505
|
+
),
|
|
506
|
+
react.createElement("tbody", null, rows)
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
var errorEl = error
|
|
511
|
+
? react.createElement("div", { className: "dsw-arch-error" }, String(error))
|
|
512
|
+
: null;
|
|
513
|
+
|
|
514
|
+
var orphanPanel = null;
|
|
515
|
+
if (showOrphans) {
|
|
516
|
+
var orphanRows = orphans.map(function (o) {
|
|
517
|
+
return react.createElement("tr", { key: o.id },
|
|
518
|
+
react.createElement("td", null,
|
|
519
|
+
react.createElement("div", { className: "dsw-arch-title-click" }, o.id),
|
|
520
|
+
react.createElement("div", { className: "dsw-arch-id" }, "父会话: " + o.parentSession + (o.cwd ? " · " + o.cwd : ""))
|
|
521
|
+
),
|
|
522
|
+
react.createElement("td", null, o.missing
|
|
523
|
+
? react.createElement("span", { className: "dsw-arch-badge" }, "文件缺失")
|
|
524
|
+
: react.createElement("span", { className: "dsw-arch-size" }, fmtSize(o.sizeBytes)))
|
|
525
|
+
);
|
|
526
|
+
});
|
|
527
|
+
var orphanTotal = 0;
|
|
528
|
+
for (var oi = 0; oi < orphans.length; oi++) orphanTotal += orphans[oi].sizeBytes || 0;
|
|
529
|
+
var sweepBtn;
|
|
530
|
+
if (confirmSweep) {
|
|
531
|
+
sweepBtn = react.createElement("button", { className: "dsw-arch-btn dsw-arch-btn-danger", disabled: orphanBusy !== null, onClick: function () { sweepOrphans(); } }, "确认清理 " + String(orphans.length) + " 个孤儿会话"),
|
|
532
|
+
react.createElement("button", { className: "dsw-arch-btn", disabled: orphanBusy !== null, onClick: function () { setConfirmSweep(false); } }, "取消");
|
|
533
|
+
} else {
|
|
534
|
+
sweepBtn = react.createElement("button", { className: "dsw-arch-btn dsw-arch-btn-danger", disabled: orphanBusy !== null || orphans.length === 0, onClick: function () { setConfirmSweep(true); } }, "清理孤儿会话");
|
|
535
|
+
}
|
|
536
|
+
orphanPanel = react.createElement("div", { className: "dsw-arch-selectbar", style: { marginTop: "10px", display: "block" } },
|
|
537
|
+
react.createElement("div", { className: "dsw-arch-meta", style: { marginBottom: "6px" } },
|
|
538
|
+
"孤儿会话 " + String(orphans.length) + " 个 · 共 " + fmtSize(orphanTotal) +
|
|
539
|
+
(orphanBusy === "load" ? " · 加载中…" : "") + (orphanBusy === "sweep" ? " · 清理中…" : "")),
|
|
540
|
+
react.createElement("div", null, sweepBtn),
|
|
541
|
+
orphanRows.length > 0
|
|
542
|
+
? react.createElement("table", { className: "dsw-arch-table", style: { marginTop: "8px" } },
|
|
543
|
+
react.createElement("thead", null,
|
|
544
|
+
react.createElement("tr", null,
|
|
545
|
+
react.createElement("th", null, "孤儿会话(父会话已删除)"),
|
|
546
|
+
react.createElement("th", null, "磁盘占用"))),
|
|
547
|
+
react.createElement("tbody", null, orphanRows))
|
|
548
|
+
: (orphanBusy === "load" ? null : react.createElement("div", { className: "dsw-arch-empty" }, "没有孤儿会话"))
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return react.createElement("div", { className: "dsw-arch-page" }, errorEl, header, confirmBar, body, orphanPanel);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---------- plugin body ----------
|
|
556
|
+
var inject = ["slots", "workspaces", "sessions", "timer"];
|
|
557
|
+
|
|
558
|
+
function apply(ctx) {
|
|
559
|
+
ensureStyle();
|
|
560
|
+
ctx.effect(function () {
|
|
561
|
+
return function () {
|
|
562
|
+
try {
|
|
563
|
+
var el = document.getElementById(STYLE_ID);
|
|
564
|
+
if (el) el.remove();
|
|
565
|
+
} catch (e) {
|
|
566
|
+
// ignore
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
}, "ui-archived-sessions: style cleanup");
|
|
570
|
+
|
|
571
|
+
ctx.slots.inject("settings.section", function () {
|
|
572
|
+
return ctx.slots.register(
|
|
573
|
+
{ name: "settings.section", id: "archived-sessions", order: 30, label: function () { return "归档会话"; } },
|
|
574
|
+
function () { return react.createElement(ArchivedSessionsPage); }
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
exports.apply = apply;
|
|
580
|
+
exports.inject = inject;
|
|
581
|
+
return module.exports;
|
|
582
|
+
}
|
|
583
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the archived-sessions page (@omdp/dsh-archived-sessions).
|
|
3
|
+
*
|
|
4
|
+
* Fork of @muwinds/dsh-archived-sessions 0.2.0, adapted for DSH 0.1.5-rc.1:
|
|
5
|
+
* - sessionPersistence.list() now returns SessionPersistenceSnapshot[]
|
|
6
|
+
* ({ header, revision, eventCount?, sizeBytes? }), not bare headers;
|
|
7
|
+
* - the abstract service no longer exposes locate(meta); the JSONL backend's
|
|
8
|
+
* path helpers (projectDir / sessionDir / logPath + encodeSegment) are used
|
|
9
|
+
* instead to resolve the on-disk artifact, so list/delete work again.
|
|
10
|
+
* - delete now removes the whole parentSession subtree (issue #2) and an
|
|
11
|
+
* orphan sweep removes subagent dirs whose parent is already gone.
|
|
12
|
+
*
|
|
13
|
+
* Exposes a small JSON API under /dsh-archived/* on the harness web server:
|
|
14
|
+
* POST /dsh-archived/list {} -> { items, totalBytes }
|
|
15
|
+
* POST /dsh-archived/unarchive { sessionId } -> { ok, changed, archivedSessionIds }
|
|
16
|
+
* POST /dsh-archived/delete { sessionId } -> { ok, deleted, sessionId, path?, sizeBytes?, reason?, alsoDeleted? }
|
|
17
|
+
* POST /dsh-archived/detail { sessionId } -> { id, createdAt, cwd, totalEvents, messageCount, truncated, messages }
|
|
18
|
+
* POST /dsh-archived/orphans {} -> { items, totalBytes }
|
|
19
|
+
* POST /dsh-archived/sweep {} -> { removed, freedBytes, items }
|
|
20
|
+
*
|
|
21
|
+
* The browser half ships in the same package (exports["./client"], dsh.client).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Wait for the browser HTTP carrier before registering the route. */
|
|
25
|
+
export const inject = ["webServer"];
|
|
26
|
+
|
|
27
|
+
/** Plugin display name for the loader. */
|
|
28
|
+
export const name = "dsh-archived-sessions";
|
|
29
|
+
|
|
30
|
+
// ---------- helpers ----------
|
|
31
|
+
|
|
32
|
+
function parentDir(p) {
|
|
33
|
+
const a = p.lastIndexOf("/");
|
|
34
|
+
const b = p.lastIndexOf("\\");
|
|
35
|
+
const i = a > b ? a : b;
|
|
36
|
+
return i <= 0 ? p : p.slice(0, i);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sessionIdOf(args) {
|
|
40
|
+
if (args === null || typeof args !== "object") throw new Error("sessionId is required");
|
|
41
|
+
const id = args.sessionId;
|
|
42
|
+
if (typeof id !== "string" || id.length === 0) throw new Error("sessionId is required");
|
|
43
|
+
return id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Extract readable text from a content-block array. */
|
|
47
|
+
function blocksText(blocks) {
|
|
48
|
+
if (!Array.isArray(blocks)) return "";
|
|
49
|
+
const parts = [];
|
|
50
|
+
for (const b of blocks) {
|
|
51
|
+
if (!b || typeof b !== "object") continue;
|
|
52
|
+
if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
|
|
53
|
+
else if (b.type === "reasoning" && typeof b.text === "string") parts.push("[思考] " + b.text);
|
|
54
|
+
else if (b.type === "tool-call") parts.push("[调用 " + (b.name || "?") + "]");
|
|
55
|
+
else if (b.type === "tool-result") parts.push("[工具结果]");
|
|
56
|
+
else if (b.type === "image") parts.push("[图片]");
|
|
57
|
+
}
|
|
58
|
+
return parts.join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Recursive byte size of a session directory via the fs service.
|
|
63
|
+
* Returns null when the path cannot be resolved (missing on disk).
|
|
64
|
+
*/
|
|
65
|
+
async function dirSizeBytes(fsSvc, dirPath, depth) {
|
|
66
|
+
let target;
|
|
67
|
+
try {
|
|
68
|
+
target = await fsSvc.resolve(dirPath);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const info = await fsSvc.stat(target);
|
|
74
|
+
if (!info) return 0;
|
|
75
|
+
if (info.type !== "directory") return info.size || 0;
|
|
76
|
+
if (depth > 10) return 0;
|
|
77
|
+
let total = 0;
|
|
78
|
+
let entries = [];
|
|
79
|
+
try {
|
|
80
|
+
entries = await fsSvc.listDir(target);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
entries = [];
|
|
83
|
+
}
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (entry.type === "directory") {
|
|
86
|
+
const sub = await dirSizeBytes(fsSvc, fsSvc.processPath(entry.target), depth + 1);
|
|
87
|
+
total += sub === null ? 0 : sub;
|
|
88
|
+
} else {
|
|
89
|
+
total += entry.size || 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return total;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve an explicit danger-full-access policy so the shell executor runs the
|
|
100
|
+
* deletion unconfined. On Windows deployments where the ACL sandbox backend
|
|
101
|
+
* cannot start (its temp root must live outside the workspace), any confined
|
|
102
|
+
* mode would fail before the command even runs.
|
|
103
|
+
*/
|
|
104
|
+
function dangerPolicy(ctx) {
|
|
105
|
+
const sp = ctx.get("sandboxPolicy");
|
|
106
|
+
if (!sp || typeof sp.resolve !== "function") return undefined;
|
|
107
|
+
try {
|
|
108
|
+
return sp.resolve({ mode: "danger-full-access" });
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Refuse to delete anything that does not look like a per-session directory.
|
|
116
|
+
* The dir name must be a `session-` prefixed id (new format) or a bare UUID
|
|
117
|
+
* (old format), so a resolution mistake can never take out an arbitrary path.
|
|
118
|
+
*/
|
|
119
|
+
function assertSessionDirName(dirPath) {
|
|
120
|
+
const last = dirPath.replace(/[\\/]+$/, "");
|
|
121
|
+
const name = last.slice(last.lastIndexOf("/") + 1, last.length).slice(last.lastIndexOf("\\") + 1);
|
|
122
|
+
const ok = /^session-[0-9a-fA-F-]{36}$/.test(name) || /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(name);
|
|
123
|
+
if (!ok) throw new Error("拒绝删除非会话目录: " + dirPath);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Delete a directory recursively through the shell executor (pwsh / rm). */
|
|
127
|
+
async function removeDir(ctx, dirPath) {
|
|
128
|
+
assertSessionDirName(dirPath);
|
|
129
|
+
const shell = ctx.get("shell");
|
|
130
|
+
if (!shell || typeof shell.resolve !== "function" || typeof shell.run !== "function") {
|
|
131
|
+
throw new Error("shell executor unavailable; cannot delete from disk");
|
|
132
|
+
}
|
|
133
|
+
const isWindows = /^[A-Za-z]:[\\/]/.test(dirPath);
|
|
134
|
+
const command = isWindows
|
|
135
|
+
? "Remove-Item -LiteralPath '" + dirPath.replace(/'/g, "''") + "' -Recurse -Force -ErrorAction Stop"
|
|
136
|
+
: "rm -rf -- '" + dirPath.replace(/'/g, "'\\''") + "'";
|
|
137
|
+
const request = { command, timeoutMs: 60000 };
|
|
138
|
+
const policy = dangerPolicy(ctx);
|
|
139
|
+
if (policy) request.sandboxPolicy = policy;
|
|
140
|
+
let spec;
|
|
141
|
+
try {
|
|
142
|
+
spec = shell.resolve(request);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
throw new Error("shell resolve failed: " + String((e && e.message) || e));
|
|
145
|
+
}
|
|
146
|
+
const result = await shell.run(spec);
|
|
147
|
+
if (result && result.exitCode === 0) return;
|
|
148
|
+
let detail = "";
|
|
149
|
+
try {
|
|
150
|
+
const out = result && (result.stderr || result.stdout);
|
|
151
|
+
if (out && typeof out.text === "string") detail = out.text.slice(0, 400);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
detail = "";
|
|
154
|
+
}
|
|
155
|
+
throw new Error("删除失败 (exit " + String(result && result.exitCode) + "): " + (detail || dirPath));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Remove one id from the durable archive set and keep the registry's in-memory
|
|
160
|
+
* state consistent so its own later writes cannot clobber it.
|
|
161
|
+
*/
|
|
162
|
+
async function removeFromArchiveSet(ctx, sessionId) {
|
|
163
|
+
const registry = ctx.get("workspaceRegistry");
|
|
164
|
+
if (!registry) throw new Error("workspace registry unavailable");
|
|
165
|
+
if (!registry.state || typeof registry.state !== "object") throw new Error("workspace registry is not started");
|
|
166
|
+
const current = registry.archivedSessionIds;
|
|
167
|
+
if (!Array.isArray(current) || !current.includes(sessionId)) return false;
|
|
168
|
+
const next = current.filter((id) => id !== sessionId);
|
|
169
|
+
const state = Object.assign({}, registry.state, { archivedSessionIds: next });
|
|
170
|
+
if (typeof registry.setState === "function") {
|
|
171
|
+
await registry.setState(state);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const domain = ctx.get("storageDomain");
|
|
175
|
+
if (!domain) throw new Error("storage domain unavailable");
|
|
176
|
+
const unit = domain.get("workspace");
|
|
177
|
+
if (!unit || !unit.global || typeof unit.global.set !== "function") throw new Error("workspace domain is not open");
|
|
178
|
+
await unit.global.set(state);
|
|
179
|
+
registry.state = state;
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A session is deletable unless its agent is actively running a turn. */
|
|
184
|
+
function sessionRunning(ctx, sessionId) {
|
|
185
|
+
const agents = ctx.get("agents");
|
|
186
|
+
if (!agents || typeof agents.get !== "function") return false;
|
|
187
|
+
const agent = agents.get(sessionId);
|
|
188
|
+
return !!(agent && agent.status === "running");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Evict a live session from the in-memory store (the store's own detach path),
|
|
193
|
+
* so a deleted session cannot resurface in the workspace afterward.
|
|
194
|
+
*/
|
|
195
|
+
function evictSessionFromMemory(ctx, sessionId) {
|
|
196
|
+
const sessions = ctx.get("sessions");
|
|
197
|
+
if (!sessions) return false;
|
|
198
|
+
try {
|
|
199
|
+
const store = sessions.store;
|
|
200
|
+
if (!store || typeof store.get !== "function") return false;
|
|
201
|
+
const entry = store.get(sessionId);
|
|
202
|
+
if (!entry || typeof entry.detach !== "function") return false;
|
|
203
|
+
entry.detach();
|
|
204
|
+
return true;
|
|
205
|
+
} catch (e) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve the on-disk location for one session in the DSH 0.1.5-rc.1 JSONL
|
|
212
|
+
* backend layout. The abstract sessionPersistence service no longer exposes
|
|
213
|
+
* locate(); the JSONL backend's own path helpers are exported and used here.
|
|
214
|
+
* Falls back to a best-effort path when the jsonl backend module is not
|
|
215
|
+
* importable (older DSH), where the old layout used the same encoding.
|
|
216
|
+
*/
|
|
217
|
+
let jsonlBackend = null;
|
|
218
|
+
try {
|
|
219
|
+
jsonlBackend = await import("@deepseek-ai/dsh-session-persistence-jsonl");
|
|
220
|
+
} catch (e) {
|
|
221
|
+
jsonlBackend = null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function encodeSegment(raw) {
|
|
225
|
+
if (raw.length === 0) throw new Error("cannot encode an empty path segment");
|
|
226
|
+
if (raw === ".") return "~002E";
|
|
227
|
+
if (raw === "..") return "~002E~002E";
|
|
228
|
+
let out = "";
|
|
229
|
+
for (let i = 0; i < raw.length; i++) {
|
|
230
|
+
const code = raw.charCodeAt(i);
|
|
231
|
+
const ch = String.fromCharCode(code);
|
|
232
|
+
if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
|
|
233
|
+
else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function projectKey(cwd) {
|
|
239
|
+
if (cwd.length === 0) throw new Error("cannot encode an empty project path");
|
|
240
|
+
let readable = "";
|
|
241
|
+
let separatorRun = false;
|
|
242
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
243
|
+
const code = cwd.charCodeAt(i);
|
|
244
|
+
const ch = String.fromCharCode(code);
|
|
245
|
+
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
246
|
+
if (!separatorRun) readable += "-";
|
|
247
|
+
separatorRun = true;
|
|
248
|
+
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
249
|
+
readable += ch;
|
|
250
|
+
separatorRun = false;
|
|
251
|
+
} else {
|
|
252
|
+
readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
253
|
+
separatorRun = false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Candidate JSONL session roots, probed in order. */
|
|
260
|
+
const CANDIDATE_ROOTS = ["C:\\Users\\xj\\.dsh\\sessions"];
|
|
261
|
+
|
|
262
|
+
/** Build one session's on-disk directory path under a candidate root. */
|
|
263
|
+
function buildSessionDir(root, header) {
|
|
264
|
+
const key = header.cwd === undefined ? "_no-cwd" : projectKey(header.cwd);
|
|
265
|
+
const id = encodeSegment(header.id);
|
|
266
|
+
const sep = root.endsWith("/") || root.endsWith("\\") ? "" : "/";
|
|
267
|
+
return root + sep + key + sep + id;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Resolve the on-disk session directory for one header, confirming existence
|
|
272
|
+
* through the fs service. Returns { path, found } — `found` is false when no
|
|
273
|
+
* candidate root holds the session (deletion then prunes the archive id only).
|
|
274
|
+
*/
|
|
275
|
+
async function resolveSessionLocation(ctx, header) {
|
|
276
|
+
const fsSvc = ctx.get("fs");
|
|
277
|
+
const backend = jsonlBackend && typeof jsonlBackend.sessionDir === "function" ? jsonlBackend : null;
|
|
278
|
+
let last = null;
|
|
279
|
+
for (const root of CANDIDATE_ROOTS) {
|
|
280
|
+
let dirPath;
|
|
281
|
+
if (backend && typeof jsonlBackend.projectDir === "function") {
|
|
282
|
+
try {
|
|
283
|
+
dirPath = backend.sessionDir(root, header.cwd, header.id);
|
|
284
|
+
} catch (e) {
|
|
285
|
+
dirPath = buildSessionDir(root, header);
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
dirPath = buildSessionDir(root, header);
|
|
289
|
+
}
|
|
290
|
+
last = { path: dirPath, found: false };
|
|
291
|
+
if (!fsSvc || typeof fsSvc.resolve !== "function") {
|
|
292
|
+
last.found = true;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
await fsSvc.resolve(dirPath);
|
|
297
|
+
last.found = true;
|
|
298
|
+
break;
|
|
299
|
+
} catch (e) {
|
|
300
|
+
// not under this root; try the next
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return last;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------- API handlers ----------
|
|
307
|
+
|
|
308
|
+
async function handleList(ctx) {
|
|
309
|
+
const registry = ctx.get("workspaceRegistry");
|
|
310
|
+
const persistence = ctx.get("sessionPersistence");
|
|
311
|
+
if (!registry || !persistence) return { items: [], totalBytes: 0 };
|
|
312
|
+
const archived = Array.isArray(registry.archivedSessionIds) ? [...registry.archivedSessionIds] : [];
|
|
313
|
+
if (archived.length === 0) return { items: [], totalBytes: 0 };
|
|
314
|
+
|
|
315
|
+
const snapshots = await persistence.list();
|
|
316
|
+
const byId = new Map();
|
|
317
|
+
for (const snap of snapshots) {
|
|
318
|
+
if (snap && typeof snap === "object" && snap.header && typeof snap.header === "object") {
|
|
319
|
+
byId.set(snap.header.id, snap);
|
|
320
|
+
} else if (snap && typeof snap === "object" && typeof snap.id === "string") {
|
|
321
|
+
byId.set(snap.id, snap);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const titles = new Map();
|
|
326
|
+
const query = ctx.get("sessionQuery");
|
|
327
|
+
if (query && typeof query.readTitleSnapshots === "function") {
|
|
328
|
+
try {
|
|
329
|
+
const results = await query.readTitleSnapshots(archived);
|
|
330
|
+
for (const r of results) {
|
|
331
|
+
if (r && r.status === "fulfilled" && r.value && r.value.title && typeof r.value.title.title === "string") {
|
|
332
|
+
titles.set(r.sessionId, r.value.title.title);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} catch (e) {
|
|
336
|
+
// best effort
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const liveSvc = ctx.get("sessions");
|
|
341
|
+
const fsSvc = ctx.get("fs");
|
|
342
|
+
const items = [];
|
|
343
|
+
let totalBytes = 0;
|
|
344
|
+
for (const id of archived) {
|
|
345
|
+
const snap = byId.get(id);
|
|
346
|
+
const header = snap && snap.header ? snap.header : (snap && typeof snap.id === "string" ? snap : null);
|
|
347
|
+
let sizeBytes = 0;
|
|
348
|
+
let missing = false;
|
|
349
|
+
let path = null;
|
|
350
|
+
if (header) {
|
|
351
|
+
// Prefer the snapshot's cheap size when present.
|
|
352
|
+
if (typeof snap.sizeBytes === "number" && snap.sizeBytes >= 0) {
|
|
353
|
+
sizeBytes = snap.sizeBytes;
|
|
354
|
+
path = null;
|
|
355
|
+
} else {
|
|
356
|
+
let location = null;
|
|
357
|
+
try {
|
|
358
|
+
location = await resolveSessionLocation(ctx, header);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
location = null;
|
|
361
|
+
}
|
|
362
|
+
if (location && typeof location.path === "string" && location.path.length > 0) {
|
|
363
|
+
path = location.path;
|
|
364
|
+
if (fsSvc) {
|
|
365
|
+
const size = await dirSizeBytes(fsSvc, location.path, 0);
|
|
366
|
+
if (size === null) missing = true;
|
|
367
|
+
else sizeBytes = size;
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
missing = true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
missing = true;
|
|
375
|
+
}
|
|
376
|
+
totalBytes += sizeBytes;
|
|
377
|
+
items.push({
|
|
378
|
+
id,
|
|
379
|
+
title: titles.get(id) || null,
|
|
380
|
+
createdAt: header ? header.createdAt : null,
|
|
381
|
+
cwd: header ? header.cwd || null : null,
|
|
382
|
+
parentSession: header ? header.parentSession || null : null,
|
|
383
|
+
sizeBytes,
|
|
384
|
+
missing,
|
|
385
|
+
live: !!(liveSvc && liveSvc.get(id) !== undefined),
|
|
386
|
+
running: sessionRunning(ctx, id),
|
|
387
|
+
path
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
items.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
391
|
+
return { items, totalBytes };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function handleUnarchive(ctx, args) {
|
|
395
|
+
const sessionId = sessionIdOf(args);
|
|
396
|
+
const registry = ctx.get("workspaceRegistry");
|
|
397
|
+
if (!registry) throw new Error("workspace registry unavailable");
|
|
398
|
+
if (!Array.isArray(registry.archivedSessionIds) || !registry.archivedSessionIds.includes(sessionId)) {
|
|
399
|
+
throw new Error("会话 '" + sessionId + "' 不在归档集合中");
|
|
400
|
+
}
|
|
401
|
+
const changed = await removeFromArchiveSet(ctx, sessionId);
|
|
402
|
+
return { ok: true, changed, archivedSessionIds: [...registry.archivedSessionIds] };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Collect the whole parentSession subtree (ids present in the archive set). */
|
|
406
|
+
async function collectSubtreeIds(ctx, rootId, snapshots) {
|
|
407
|
+
const byParent = new Map();
|
|
408
|
+
for (const snap of snapshots) {
|
|
409
|
+
const h = snap && typeof snap === "object" && snap.header ? snap.header : snap;
|
|
410
|
+
if (h && typeof h === "object" && typeof h.parentSession === "string" && h.parentSession.length > 0) {
|
|
411
|
+
let list = byParent.get(h.parentSession);
|
|
412
|
+
if (!list) {
|
|
413
|
+
list = [];
|
|
414
|
+
byParent.set(h.parentSession, list);
|
|
415
|
+
}
|
|
416
|
+
list.push(typeof h.id === "string" ? h.id : "");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const result = [rootId];
|
|
420
|
+
const seen = new Set(result);
|
|
421
|
+
let i = 0;
|
|
422
|
+
while (i < result.length) {
|
|
423
|
+
const cur = result[i];
|
|
424
|
+
const kids = byParent.get(cur) || [];
|
|
425
|
+
for (const k of kids) {
|
|
426
|
+
if (k && !seen.has(k)) {
|
|
427
|
+
seen.add(k);
|
|
428
|
+
result.push(k);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
i++;
|
|
432
|
+
}
|
|
433
|
+
return result.slice(1);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function handleDelete(ctx, args) {
|
|
437
|
+
const sessionId = sessionIdOf(args);
|
|
438
|
+
const registry = ctx.get("workspaceRegistry");
|
|
439
|
+
const persistence = ctx.get("sessionPersistence");
|
|
440
|
+
if (!registry || !persistence) throw new Error("workspace registry or session persistence unavailable");
|
|
441
|
+
if (!Array.isArray(registry.archivedSessionIds) || !registry.archivedSessionIds.includes(sessionId)) {
|
|
442
|
+
throw new Error("会话 '" + sessionId + "' 不在归档集合中");
|
|
443
|
+
}
|
|
444
|
+
if (sessionRunning(ctx, sessionId)) {
|
|
445
|
+
throw new Error("会话 '" + sessionId + "' 正在运行中,无法删除");
|
|
446
|
+
}
|
|
447
|
+
// A live (in-memory) session would otherwise resurface in the workspace once
|
|
448
|
+
// the archive entry is pruned; evict it so the deletion is complete.
|
|
449
|
+
const liveSvc = ctx.get("sessions");
|
|
450
|
+
const isLive = !!(liveSvc && typeof liveSvc.get === "function" && liveSvc.get(sessionId) !== undefined);
|
|
451
|
+
if (isLive && !evictSessionFromMemory(ctx, sessionId)) {
|
|
452
|
+
throw new Error("会话 '" + sessionId + "' 仍驻留内存且无法移除,删除未完成;请重启 Harness 后重试");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const snapshots = await persistence.list();
|
|
456
|
+
const subtree = await collectSubtreeIds(ctx, sessionId, snapshots);
|
|
457
|
+
|
|
458
|
+
const headers = new Map();
|
|
459
|
+
for (const snap of snapshots) {
|
|
460
|
+
const h = snap && typeof snap === "object" && snap.header ? snap.header : snap;
|
|
461
|
+
if (h && typeof h === "object" && typeof h.id === "string") headers.set(h.id, h);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const deletedIds = [];
|
|
465
|
+
const failedIds = [];
|
|
466
|
+
for (const id of [sessionId, ...subtree]) {
|
|
467
|
+
const header = headers.get(id);
|
|
468
|
+
if (!header) {
|
|
469
|
+
// Already gone from persistence: just prune the archive id.
|
|
470
|
+
if (registry.archivedSessionIds.includes(id)) {
|
|
471
|
+
await removeFromArchiveSet(ctx, id);
|
|
472
|
+
}
|
|
473
|
+
deletedIds.push(id);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
let location = null;
|
|
477
|
+
try {
|
|
478
|
+
location = await resolveSessionLocation(ctx, header);
|
|
479
|
+
} catch (e) {
|
|
480
|
+
location = null;
|
|
481
|
+
}
|
|
482
|
+
if (!location || typeof location.path !== "string" || location.path.length === 0) {
|
|
483
|
+
if (registry.archivedSessionIds.includes(id)) {
|
|
484
|
+
await removeFromArchiveSet(ctx, id);
|
|
485
|
+
}
|
|
486
|
+
deletedIds.push(id);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const dirPath = location.path;
|
|
490
|
+
const fsSvc = ctx.get("fs");
|
|
491
|
+
let sizeBytes = 0;
|
|
492
|
+
if (fsSvc) {
|
|
493
|
+
const size = await dirSizeBytes(fsSvc, dirPath, 0);
|
|
494
|
+
sizeBytes = size === null ? 0 : size;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
await removeDir(ctx, dirPath);
|
|
498
|
+
} catch (e) {
|
|
499
|
+
failedIds.push(id);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (registry.archivedSessionIds.includes(id)) {
|
|
503
|
+
await removeFromArchiveSet(ctx, id);
|
|
504
|
+
}
|
|
505
|
+
deletedIds.push(id);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (failedIds.length > 0) {
|
|
509
|
+
throw new Error("部分会话删除失败: " + failedIds.join(", "));
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
ok: true,
|
|
513
|
+
deleted: deletedIds.includes(sessionId),
|
|
514
|
+
sessionId,
|
|
515
|
+
alsoDeleted: subtree.filter((id) => deletedIds.includes(id)),
|
|
516
|
+
sizeBytes: 0,
|
|
517
|
+
reason: deletedIds.length === 0 ? "no-artifact" : undefined
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function handleDetail(ctx, args) {
|
|
522
|
+
const sessionId = sessionIdOf(args);
|
|
523
|
+
const query = ctx.get("sessionQuery");
|
|
524
|
+
if (!query || typeof query.readSession !== "function") {
|
|
525
|
+
throw new Error("session query unavailable");
|
|
526
|
+
}
|
|
527
|
+
let snapshot;
|
|
528
|
+
try {
|
|
529
|
+
snapshot = await query.readSession(sessionId);
|
|
530
|
+
} catch (e) {
|
|
531
|
+
throw new Error("无法读取会话内容(可能已从磁盘删除): " + String((e && e.message) || e));
|
|
532
|
+
}
|
|
533
|
+
if (!snapshot || !Array.isArray(snapshot.events)) {
|
|
534
|
+
throw new Error("会话内容为空或不可读");
|
|
535
|
+
}
|
|
536
|
+
const events = snapshot.events;
|
|
537
|
+
const messages = [];
|
|
538
|
+
const MAX_MESSAGES = 100;
|
|
539
|
+
const MAX_TEXT = 8000;
|
|
540
|
+
for (const ev of events) {
|
|
541
|
+
if (messages.length >= MAX_MESSAGES) break;
|
|
542
|
+
if (!ev || typeof ev !== "object" || !ev.data || typeof ev.data !== "object") continue;
|
|
543
|
+
const data = ev.data;
|
|
544
|
+
if (ev.type === "user/message") {
|
|
545
|
+
const text = blocksText(data.content);
|
|
546
|
+
if (text) messages.push({ seq: ev.seq, time: ev.time, role: "user", text: text.slice(0, MAX_TEXT) });
|
|
547
|
+
} else if (ev.type === "assistant/message" && data.message && typeof data.message === "object") {
|
|
548
|
+
const text = blocksText(data.message.content);
|
|
549
|
+
if (text) messages.push({ seq: ev.seq, time: ev.time, role: "assistant", text: text.slice(0, MAX_TEXT) });
|
|
550
|
+
} else if (ev.type === "tool/call") {
|
|
551
|
+
const name = typeof data.name === "string" ? data.name : "?";
|
|
552
|
+
const argsText = typeof data.arguments === "string" ? data.arguments.slice(0, 300) : "";
|
|
553
|
+
messages.push({ seq: ev.seq, time: ev.time, role: "tool", text: "[" + name + "] " + argsText });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const header = snapshot.session || null;
|
|
557
|
+
return {
|
|
558
|
+
id: sessionId,
|
|
559
|
+
createdAt: header ? header.createdAt : null,
|
|
560
|
+
cwd: header ? header.cwd || null : null,
|
|
561
|
+
parentSession: header ? header.parentSession || null : null,
|
|
562
|
+
totalEvents: events.length,
|
|
563
|
+
messageCount: messages.length,
|
|
564
|
+
truncated: messages.length >= MAX_MESSAGES,
|
|
565
|
+
messages
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** List orphaned subagent sessions: on disk, parent already gone, not archived. */
|
|
570
|
+
async function handleOrphans(ctx) {
|
|
571
|
+
const registry = ctx.get("workspaceRegistry");
|
|
572
|
+
const persistence = ctx.get("sessionPersistence");
|
|
573
|
+
if (!registry || !persistence) return { items: [], totalBytes: 0 };
|
|
574
|
+
const archived = Array.isArray(registry.archivedSessionIds) ? registry.archivedSessionIds : [];
|
|
575
|
+
const live = new Set(archived);
|
|
576
|
+
const snapshots = await persistence.list();
|
|
577
|
+
const known = new Set();
|
|
578
|
+
const byParent = new Map();
|
|
579
|
+
for (const snap of snapshots) {
|
|
580
|
+
const h = snap && typeof snap === "object" && snap.header ? snap.header : snap;
|
|
581
|
+
if (h && typeof h === "object" && typeof h.id === "string") {
|
|
582
|
+
known.add(h.id);
|
|
583
|
+
if (typeof h.parentSession === "string" && h.parentSession.length > 0) {
|
|
584
|
+
let list = byParent.get(h.parentSession);
|
|
585
|
+
if (!list) {
|
|
586
|
+
list = [];
|
|
587
|
+
byParent.set(h.parentSession, list);
|
|
588
|
+
}
|
|
589
|
+
list.push(h.id);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const fsSvc = ctx.get("fs");
|
|
594
|
+
const items = [];
|
|
595
|
+
let totalBytes = 0;
|
|
596
|
+
for (const id of known) {
|
|
597
|
+
if (live.has(id)) continue;
|
|
598
|
+
let h = null;
|
|
599
|
+
for (const snap of snapshots) {
|
|
600
|
+
const hh = snap && typeof snap === "object" && snap.header ? snap.header : snap;
|
|
601
|
+
if (hh && typeof hh === "object" && hh.id === id) {
|
|
602
|
+
h = hh;
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!h || typeof h.parentSession !== "string" || h.parentSession.length === 0) continue;
|
|
607
|
+
if (known.has(h.parentSession)) continue;
|
|
608
|
+
let location = null;
|
|
609
|
+
try {
|
|
610
|
+
location = await resolveSessionLocation(ctx, h);
|
|
611
|
+
} catch (e) {
|
|
612
|
+
location = null;
|
|
613
|
+
}
|
|
614
|
+
if (!location) continue;
|
|
615
|
+
const dirPath = location.path;
|
|
616
|
+
let sizeBytes = 0;
|
|
617
|
+
if (fsSvc) {
|
|
618
|
+
const size = await dirSizeBytes(fsSvc, dirPath, 0);
|
|
619
|
+
sizeBytes = size === null ? 0 : size;
|
|
620
|
+
}
|
|
621
|
+
totalBytes += sizeBytes;
|
|
622
|
+
items.push({
|
|
623
|
+
id,
|
|
624
|
+
parentSession: h.parentSession,
|
|
625
|
+
createdAt: h.createdAt || null,
|
|
626
|
+
cwd: h.cwd || null,
|
|
627
|
+
sizeBytes,
|
|
628
|
+
missing: sizeBytes === 0
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
items.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
632
|
+
return { items, totalBytes };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Sweep orphaned subagent sessions: delete their dirs + prune from archive set. */
|
|
636
|
+
async function handleSweep(ctx) {
|
|
637
|
+
const { items } = await handleOrphans(ctx);
|
|
638
|
+
const removed = [];
|
|
639
|
+
let freedBytes = 0;
|
|
640
|
+
for (const item of items) {
|
|
641
|
+
const persistence = ctx.get("sessionPersistence");
|
|
642
|
+
const snapshots = await persistence.list();
|
|
643
|
+
let h = null;
|
|
644
|
+
for (const snap of snapshots) {
|
|
645
|
+
const hh = snap && typeof snap === "object" && snap.header ? snap.header : snap;
|
|
646
|
+
if (hh && typeof hh === "object" && hh.id === item.id) {
|
|
647
|
+
h = hh;
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (!h) continue;
|
|
652
|
+
let location = null;
|
|
653
|
+
try {
|
|
654
|
+
location = await resolveSessionLocation(ctx, h);
|
|
655
|
+
} catch (e) {
|
|
656
|
+
location = null;
|
|
657
|
+
}
|
|
658
|
+
if (!location) continue;
|
|
659
|
+
const dirPath = location.path;
|
|
660
|
+
try {
|
|
661
|
+
await removeDir(ctx, dirPath);
|
|
662
|
+
} catch (e) {
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
freedBytes += item.sizeBytes;
|
|
666
|
+
removed.push(item.id);
|
|
667
|
+
}
|
|
668
|
+
return { removed, freedBytes, items: removed.length };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ---------- HTTP route ----------
|
|
672
|
+
|
|
673
|
+
function readBody(req) {
|
|
674
|
+
return new Promise((resolve, reject) => {
|
|
675
|
+
const chunks = [];
|
|
676
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
677
|
+
req.on("end", () => {
|
|
678
|
+
try {
|
|
679
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
680
|
+
} catch (e) {
|
|
681
|
+
reject(e);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
req.on("error", reject);
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function sendJson(res, status, payload) {
|
|
689
|
+
res.writeHead(status, {
|
|
690
|
+
"content-type": "application/json; charset=utf-8",
|
|
691
|
+
"cache-control": "no-store"
|
|
692
|
+
});
|
|
693
|
+
res.end(JSON.stringify(payload));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export function apply(ctx) {
|
|
697
|
+
const handlers = {
|
|
698
|
+
list: () => handleList(ctx),
|
|
699
|
+
unarchive: (args) => handleUnarchive(ctx, args),
|
|
700
|
+
delete: (args) => handleDelete(ctx, args),
|
|
701
|
+
detail: (args) => handleDetail(ctx, args),
|
|
702
|
+
orphans: () => handleOrphans(ctx),
|
|
703
|
+
sweep: () => handleSweep(ctx)
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
async function handler(req, res) {
|
|
707
|
+
if ((req.method || "") !== "POST") {
|
|
708
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const pathname = (req.url || "").split("?")[0].replace(/\/+$/, "");
|
|
712
|
+
let action = null;
|
|
713
|
+
for (const key of Object.keys(handlers)) {
|
|
714
|
+
if (pathname === "/dsh-archived/" + key) {
|
|
715
|
+
action = key;
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (action === null) {
|
|
720
|
+
sendJson(res, 404, { error: "not found" });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
let body = {};
|
|
724
|
+
try {
|
|
725
|
+
const raw = await readBody(req);
|
|
726
|
+
if (raw.trim().length > 0) body = JSON.parse(raw);
|
|
727
|
+
} catch (e) {
|
|
728
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
sendJson(res, 200, await handlers[action](body));
|
|
733
|
+
} catch (e) {
|
|
734
|
+
sendJson(res, 500, { error: (e && e.message) || String(e) });
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
ctx.webServer.register({
|
|
739
|
+
kind: "prefix",
|
|
740
|
+
path: "/dsh-archived",
|
|
741
|
+
handler
|
|
742
|
+
});
|
|
743
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omdp/dsh-archived-sessions",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Archived-sessions manager for the DeepSeek Harness web UI: list / unarchive / delete / detail from Settings. · DSH 归档会话管理:设置页查看、释放、删除已归档会话(含按树删除子会话与孤儿清理)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|