dsh-archived-chats 0.3.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/LICENSE +21 -0
- package/README.md +40 -0
- package/README.zh.md +40 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +746 -0
- package/lib/index.js +463 -0
- package/lib/types/client/index.d.ts +3 -0
- package/lib/types/index.d.ts +3 -0
- package/package.json +59 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-archived-chats — host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers the `/plugins/dsh-archived-chats/*` HTTP routes on the Web server:
|
|
5
|
+
*
|
|
6
|
+
* GET /plugins/dsh-archived-chats/state → archived sessions with
|
|
7
|
+
* title, createdAt, and
|
|
8
|
+
* owning workspace (ids in
|
|
9
|
+
* the pending-deletion
|
|
10
|
+
* store are excluded).
|
|
11
|
+
* POST /plugins/dsh-archived-chats/unarchive → { sessionId }
|
|
12
|
+
* POST /plugins/dsh-archived-chats/unarchive-all → { sessionIds: [...] }
|
|
13
|
+
* POST /plugins/dsh-archived-chats/delete → { sessionId }
|
|
14
|
+
* POST /plugins/dsh-archived-chats/delete-all → { sessionIds: [...] }
|
|
15
|
+
*
|
|
16
|
+
* Mutating responses carry { ok, deleted, pending, failed }: `deleted` is
|
|
17
|
+
* complete cold deletes, `pending` is live sessions accepted for deferred
|
|
18
|
+
* deletion (parked now, physically removed on the next boot).
|
|
19
|
+
*
|
|
20
|
+
* The stock workspace registry only exposes `archiveSession` — unarchiving and
|
|
21
|
+
* deleting live here, behind one HTTP surface the browser half can drive.
|
|
22
|
+
* Unarchive goes through the registry's own state write path so the api-proxy
|
|
23
|
+
* observes the `domain/changed` emission and pushes
|
|
24
|
+
* `host/archived-sessions-changed` to every connected client (the sidebar
|
|
25
|
+
* updates live). Delete additionally detaches the session from its workspace
|
|
26
|
+
* record and removes the session-log directory from disk.
|
|
27
|
+
*
|
|
28
|
+
* Deleting a COLD session is immediate. A session that is still LIVE (opened
|
|
29
|
+
* at least once this boot — web agents stay resident until shutdown and the
|
|
30
|
+
* stock composition exposes no dispose) cannot have its log ripped out from
|
|
31
|
+
* under it: the persistence coordinator would hit ENOENT on its next append.
|
|
32
|
+
* Instead the live path parks the agent permanently (`cancel({kind:
|
|
33
|
+
* 'disposed'})` — parked input never wakes the driver again), waits for
|
|
34
|
+
* quiescence, flushes durability, then records the id in a small
|
|
35
|
+
* pending-deletions store while KEEPING it archived so it stays invisible.
|
|
36
|
+
* The boot sweep (`sweepPendingDeletions`, launched once when the plugin's
|
|
37
|
+
* three services have all bound) completes the removal through the ordinary
|
|
38
|
+
* cold delete path on the next boot — when every session is cold. Unarchiving
|
|
39
|
+
* a parked session drops it from the pending store, and the sweep skips ids
|
|
40
|
+
* that are no longer archived, so an unarchive always wins over a parked
|
|
41
|
+
* deletion.
|
|
42
|
+
*
|
|
43
|
+
* Routes bind lazily (same posture as dsh-agent-teams): the web server, the
|
|
44
|
+
* workspace registry, and session persistence may mount after this plugin
|
|
45
|
+
* under the Loader's concurrent activation, so registration is retried on
|
|
46
|
+
* every `internal/service` binding event until all three exist.
|
|
47
|
+
*
|
|
48
|
+
* @module dsh-archived-chats
|
|
49
|
+
*/
|
|
50
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
51
|
+
import { dirname, join } from 'node:path';
|
|
52
|
+
import { homedir } from 'node:os';
|
|
53
|
+
|
|
54
|
+
/** Cordis plugin name. */
|
|
55
|
+
export const name = 'archived-chats';
|
|
56
|
+
|
|
57
|
+
/** Web-server service key candidates, newest first. */
|
|
58
|
+
const WEB_SERVER_KEYS = ['webServer', 'httpServer'];
|
|
59
|
+
/** Workspace registry service key candidates, newest first. */
|
|
60
|
+
const WORKSPACE_KEYS = ['workspaceRegistry', 'workspace'];
|
|
61
|
+
/** Session persistence service key candidates, newest first. */
|
|
62
|
+
const PERSISTENCE_KEYS = ['sessionPersistence'];
|
|
63
|
+
|
|
64
|
+
const ROUTE_PREFIX = '/plugins/dsh-archived-chats';
|
|
65
|
+
/** Custom header required on POSTs: cheap CSRF hardening for a loopback UI. */
|
|
66
|
+
const GUARD_HEADER = 'x-dsh-archived-chats';
|
|
67
|
+
|
|
68
|
+
//#region wire helpers
|
|
69
|
+
/** Read and JSON-parse a request body (empty body → {}). */
|
|
70
|
+
function readBody(req) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
74
|
+
req.on('end', () => {
|
|
75
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
76
|
+
if (text === '') { resolve({}); return; }
|
|
77
|
+
try { resolve(JSON.parse(text)); }
|
|
78
|
+
catch { reject(Object.assign(new Error('request body is not valid JSON'), { status: 400 })); }
|
|
79
|
+
});
|
|
80
|
+
req.on('error', reject);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Send one JSON response. */
|
|
85
|
+
function send(res, status, value) {
|
|
86
|
+
res.writeHead(status, {
|
|
87
|
+
'content-type': 'application/json; charset=utf-8',
|
|
88
|
+
'cache-control': 'no-store',
|
|
89
|
+
});
|
|
90
|
+
res.end(JSON.stringify(value));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Guard mutating routes behind a custom header (cross-site forms cannot set one). */
|
|
94
|
+
function guard(req, res) {
|
|
95
|
+
if (req.method !== 'POST') {
|
|
96
|
+
send(res, 405, { error: 'method-not-allowed' });
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (req.headers[GUARD_HEADER] !== '1') {
|
|
100
|
+
send(res, 403, { error: 'forbidden' });
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
|
|
107
|
+
//#region pending-deletions store
|
|
108
|
+
/**
|
|
109
|
+
* Ids of sessions whose deletion was requested while they were live. They are
|
|
110
|
+
* parked (never to run again) and stay archived — invisible everywhere — until
|
|
111
|
+
* the next boot sweeps them through the ordinary cold delete path. The store
|
|
112
|
+
* is one small JSON document; writes are whole-file and best-effort.
|
|
113
|
+
*/
|
|
114
|
+
function pendingFilePath() {
|
|
115
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
116
|
+
return join(home, 'plugin-data', 'archived-chats', 'pending-deletions.json');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function loadPending() {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(await readFile(pendingFilePath(), 'utf8'));
|
|
122
|
+
const ids = Array.isArray(parsed?.ids) ? parsed.ids.filter((id) => typeof id === 'string') : [];
|
|
123
|
+
return new Set(ids);
|
|
124
|
+
} catch {
|
|
125
|
+
return new Set();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function savePending(pending) {
|
|
130
|
+
const path = pendingFilePath();
|
|
131
|
+
await mkdir(dirname(path), { recursive: true });
|
|
132
|
+
await writeFile(path, `${JSON.stringify({ ids: [...pending] }, null, 2)}\n`, 'utf8');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function addPending(id) {
|
|
136
|
+
const pending = await loadPending();
|
|
137
|
+
pending.add(String(id));
|
|
138
|
+
await savePending(pending);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Drop ids from the pending-deletion store (used when a parked session is unarchived). */
|
|
142
|
+
async function removePending(ids) {
|
|
143
|
+
const pending = await loadPending();
|
|
144
|
+
let changed = false;
|
|
145
|
+
for (const id of ids) {
|
|
146
|
+
if (pending.delete(String(id))) changed = true;
|
|
147
|
+
}
|
|
148
|
+
if (changed) await savePending(pending);
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
|
|
152
|
+
//#region domain helpers
|
|
153
|
+
/** The last `session/title` event wins (renames append later events). */
|
|
154
|
+
function extractTitle(events) {
|
|
155
|
+
let title;
|
|
156
|
+
for (const event of events) {
|
|
157
|
+
if (event?.type === 'session/title'
|
|
158
|
+
&& typeof event.data?.title === 'string'
|
|
159
|
+
&& event.data.title.trim() !== '') {
|
|
160
|
+
title = event.data.title;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return title;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build the archived-session listing: one row per archived id with the
|
|
168
|
+
* resolved title, creation time, and owning workspace (accounting slot first,
|
|
169
|
+
* canonical cwd second, ungrouped last).
|
|
170
|
+
*/
|
|
171
|
+
async function listArchived(ctx, registry, persistence) {
|
|
172
|
+
const archivedIds = registry.archivedSessionIds.map(String);
|
|
173
|
+
if (archivedIds.length === 0) return [];
|
|
174
|
+
const headerById = new Map();
|
|
175
|
+
try {
|
|
176
|
+
for (const header of await persistence.list()) headerById.set(String(header.id), header);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
ctx.logger.warn(`archived-chats: session header listing failed: ${String(error)}`);
|
|
179
|
+
}
|
|
180
|
+
const live = ctx.get('sessions');
|
|
181
|
+
const workspaces = registry.list();
|
|
182
|
+
const rows = [];
|
|
183
|
+
for (const id of archivedIds) {
|
|
184
|
+
const header = headerById.get(id) ?? live?.get(id)?.header;
|
|
185
|
+
let title;
|
|
186
|
+
try {
|
|
187
|
+
const inspection = await persistence.inspect(id);
|
|
188
|
+
title = extractTitle(inspection.events);
|
|
189
|
+
} catch {
|
|
190
|
+
// A torn/unreadable log still lists — the row is manageable without a title.
|
|
191
|
+
}
|
|
192
|
+
const workspace = workspaces.find((w) => w.sessionIds.map(String).includes(id));
|
|
193
|
+
rows.push({
|
|
194
|
+
id,
|
|
195
|
+
title: title ?? null,
|
|
196
|
+
createdAt: header?.createdAt ?? null,
|
|
197
|
+
origin: header?.origin ?? null,
|
|
198
|
+
workspaceId: workspace === undefined ? null : String(workspace.id),
|
|
199
|
+
workspaceTitle: workspace?.title ?? null,
|
|
200
|
+
workspacePath: workspace?.path ?? null,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// Parked-for-deletion sessions stay archived (invisible everywhere) but are
|
|
204
|
+
// excluded from the listing: they have been accepted for deletion and only
|
|
205
|
+
// await the next-boot sweep, so they must not reappear on refresh.
|
|
206
|
+
const pendingIds = await loadPending();
|
|
207
|
+
return rows.filter((row) => !pendingIds.has(row.id));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Remove ids from the registry-global archive set through the registry's own
|
|
212
|
+
* write path (`setState` is the same funnel `archiveSession` uses, so the
|
|
213
|
+
* domain change event — and every subscribed client — observes it).
|
|
214
|
+
*/
|
|
215
|
+
async function unarchiveIds(registry, ids) {
|
|
216
|
+
const state = registry.state;
|
|
217
|
+
if (state === undefined) throw new Error('workspace registry is not started yet');
|
|
218
|
+
const drop = new Set(ids.map(String));
|
|
219
|
+
const current = state.archivedSessionIds.map(String);
|
|
220
|
+
const next = current.filter((id) => !drop.has(id));
|
|
221
|
+
if (next.length === current.length) return current;
|
|
222
|
+
if (typeof registry.setState !== 'function') {
|
|
223
|
+
throw new Error('this dsh build exposes no workspace-registry state writer; unarchive is unsupported');
|
|
224
|
+
}
|
|
225
|
+
await registry.setState({ ...state, archivedSessionIds: next });
|
|
226
|
+
return next;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Park a live session for deletion: cancel its agent with the `disposed`
|
|
231
|
+
* cause (parked input never wakes the driver again), wait for quiescence so
|
|
232
|
+
* in-flight closing events land, then flush durability. Afterwards the log is
|
|
233
|
+
* frozen and the id joins the pending-deletions store; the session STAYS
|
|
234
|
+
* archived so it keeps hidden until the next-boot sweep completes the delete.
|
|
235
|
+
*/
|
|
236
|
+
async function parkLiveSession(ctx, id) {
|
|
237
|
+
const agent = ctx.get('agents')?.get(id);
|
|
238
|
+
if (agent !== undefined) {
|
|
239
|
+
try {
|
|
240
|
+
agent.cancel({ kind: 'disposed' });
|
|
241
|
+
await Promise.race([
|
|
242
|
+
agent.whenIdle(),
|
|
243
|
+
new Promise((resolve) => setTimeout(resolve, 20000)),
|
|
244
|
+
]);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
ctx.logger.warn(`archived-chats: parking ${id} did not fully converge: ${String(error)}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const sessions = ctx.get('sessions');
|
|
250
|
+
const live = sessions?.get(id);
|
|
251
|
+
if (live !== undefined && typeof sessions.flush === 'function') {
|
|
252
|
+
try { await sessions.flush(live); }
|
|
253
|
+
catch (error) { ctx.logger.warn(`archived-chats: flush before parking ${id} failed: ${String(error)}`); }
|
|
254
|
+
}
|
|
255
|
+
await addPending(id);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Delete one archived session. A COLD session is removed end to end right
|
|
260
|
+
* away: unarchive, detach from the owning workspace record, remove the
|
|
261
|
+
* session-log directory from disk, and purge the registry's stale header
|
|
262
|
+
* index. A LIVE session is parked and deferred instead (see
|
|
263
|
+
* {@link parkLiveSession}) and reported as `pending`.
|
|
264
|
+
*/
|
|
265
|
+
async function deleteSession(ctx, registry, persistence, id) {
|
|
266
|
+
const live = ctx.get('sessions');
|
|
267
|
+
if (live?.get(id) !== undefined) {
|
|
268
|
+
await parkLiveSession(ctx, id);
|
|
269
|
+
return 'pending';
|
|
270
|
+
}
|
|
271
|
+
await unarchiveIds(registry, [id]);
|
|
272
|
+
for (const workspace of registry.list()) {
|
|
273
|
+
if (workspace.sessionIds.map(String).includes(String(id))) {
|
|
274
|
+
try { await workspace.detachSession(id); }
|
|
275
|
+
catch (error) { ctx.logger.warn(`archived-chats: detach failed for ${id}: ${String(error)}`); }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
let header;
|
|
279
|
+
try {
|
|
280
|
+
header = (await persistence.list()).find((h) => String(h.id) === String(id));
|
|
281
|
+
} catch (error) {
|
|
282
|
+
ctx.logger.warn(`archived-chats: header re-list failed for ${id}: ${String(error)}`);
|
|
283
|
+
}
|
|
284
|
+
if (header !== undefined) {
|
|
285
|
+
const location = persistence.locate(header);
|
|
286
|
+
if (location?.path !== undefined) {
|
|
287
|
+
await rm(dirname(location.path), { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Purge the registry's in-memory header index (built once at startup from
|
|
291
|
+
// persistence). Without this the stale header keeps the deleted session
|
|
292
|
+
// "known" to the registry for the rest of this boot — it could be
|
|
293
|
+
// re-archived as a ghost or re-accounted on the next workspace mutation.
|
|
294
|
+
for (const key of ['headers', 'sessionPaths', 'invalidSessionPaths']) {
|
|
295
|
+
const map = registry[key];
|
|
296
|
+
if (map instanceof Map) map.delete(id);
|
|
297
|
+
}
|
|
298
|
+
return 'deleted';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Boot-time sweep: every pending-deletion id is cold now (plugin activation
|
|
303
|
+
* precedes any client resume), so each one completes the ordinary delete
|
|
304
|
+
* path. Ids that fail stay in the store for the next boot.
|
|
305
|
+
*/
|
|
306
|
+
async function sweepPendingDeletions(ctx, registry, persistence) {
|
|
307
|
+
let pending;
|
|
308
|
+
try {
|
|
309
|
+
pending = await loadPending();
|
|
310
|
+
} catch (error) {
|
|
311
|
+
ctx.logger.warn(`archived-chats: pending-deletions store unreadable: ${String(error)}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (pending.size === 0) return;
|
|
315
|
+
const archivedSet = new Set(registry.archivedSessionIds.map(String));
|
|
316
|
+
for (const id of [...pending]) {
|
|
317
|
+
try {
|
|
318
|
+
// A parked id that got unarchived (by any path) is no longer meant
|
|
319
|
+
// for deletion — drop it from the store and keep its files.
|
|
320
|
+
if (!archivedSet.has(String(id))) {
|
|
321
|
+
pending.delete(id);
|
|
322
|
+
ctx.logger.info?.(`archived-chats: pending ${id} was unarchived — dropping it`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
await deleteSession(ctx, registry, persistence, id);
|
|
326
|
+
pending.delete(id);
|
|
327
|
+
ctx.logger.info?.(`archived-chats: swept pending deletion ${id}`);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
ctx.logger.warn(`archived-chats: pending deletion ${id} failed again: ${String(error)}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
try { await savePending(pending); }
|
|
333
|
+
catch (error) { ctx.logger.warn(`archived-chats: pending-deletions store not saved: ${String(error)}`); }
|
|
334
|
+
}
|
|
335
|
+
//#endregion
|
|
336
|
+
|
|
337
|
+
//#region routes
|
|
338
|
+
function registerRoutes(ctx, webServer, registry, persistence) {
|
|
339
|
+
ctx.effect(() => webServer.register({
|
|
340
|
+
kind: 'exact',
|
|
341
|
+
path: `${ROUTE_PREFIX}/state`,
|
|
342
|
+
handler: async (req, res) => {
|
|
343
|
+
try {
|
|
344
|
+
const sessions = await listArchived(ctx, registry, persistence);
|
|
345
|
+
send(res, 200, { sessions });
|
|
346
|
+
} catch (error) {
|
|
347
|
+
ctx.logger.warn(`archived-chats: state failed: ${String(error)}`);
|
|
348
|
+
send(res, 500, { error: 'state-failed', message: String(error?.message ?? error) });
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
}), 'archived-chats: state route');
|
|
352
|
+
|
|
353
|
+
ctx.effect(() => webServer.register({
|
|
354
|
+
kind: 'exact',
|
|
355
|
+
path: `${ROUTE_PREFIX}/unarchive`,
|
|
356
|
+
handler: async (req, res) => {
|
|
357
|
+
if (!guard(req, res)) return;
|
|
358
|
+
try {
|
|
359
|
+
const body = await readBody(req);
|
|
360
|
+
if (typeof body.sessionId !== 'string' || body.sessionId === '') {
|
|
361
|
+
send(res, 400, { error: 'sessionId-required' });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const archivedSessionIds = await unarchiveIds(registry, [body.sessionId]);
|
|
365
|
+
await removePending([body.sessionId]);
|
|
366
|
+
send(res, 200, { ok: true, archivedSessionIds });
|
|
367
|
+
} catch (error) {
|
|
368
|
+
send(res, error.status ?? 500, { error: 'unarchive-failed', message: String(error?.message ?? error) });
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
}), 'archived-chats: unarchive route');
|
|
372
|
+
|
|
373
|
+
ctx.effect(() => webServer.register({
|
|
374
|
+
kind: 'exact',
|
|
375
|
+
path: `${ROUTE_PREFIX}/unarchive-all`,
|
|
376
|
+
handler: async (req, res) => {
|
|
377
|
+
if (!guard(req, res)) return;
|
|
378
|
+
try {
|
|
379
|
+
const body = await readBody(req);
|
|
380
|
+
const ids = Array.isArray(body.sessionIds) ? body.sessionIds.filter((id) => typeof id === 'string') : [];
|
|
381
|
+
if (ids.length === 0) {
|
|
382
|
+
send(res, 400, { error: 'sessionIds-required' });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const archivedSessionIds = await unarchiveIds(registry, ids);
|
|
386
|
+
await removePending(ids);
|
|
387
|
+
send(res, 200, { ok: true, archivedSessionIds });
|
|
388
|
+
} catch (error) {
|
|
389
|
+
send(res, error.status ?? 500, { error: 'unarchive-failed', message: String(error?.message ?? error) });
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
}), 'archived-chats: unarchive-all route');
|
|
393
|
+
|
|
394
|
+
const deleteHandler = (batch) => async (req, res) => {
|
|
395
|
+
if (!guard(req, res)) return;
|
|
396
|
+
try {
|
|
397
|
+
const body = await readBody(req);
|
|
398
|
+
const ids = batch
|
|
399
|
+
? (Array.isArray(body.sessionIds) ? body.sessionIds.filter((id) => typeof id === 'string') : [])
|
|
400
|
+
: (typeof body.sessionId === 'string' && body.sessionId !== '' ? [body.sessionId] : []);
|
|
401
|
+
if (ids.length === 0) {
|
|
402
|
+
send(res, 400, { error: batch ? 'sessionIds-required' : 'sessionId-required' });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const deleted = [];
|
|
406
|
+
const pending = [];
|
|
407
|
+
const failed = [];
|
|
408
|
+
for (const id of ids) {
|
|
409
|
+
try {
|
|
410
|
+
const outcome = await deleteSession(ctx, registry, persistence, id);
|
|
411
|
+
if (outcome === 'pending') pending.push(id);
|
|
412
|
+
else deleted.push(id);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
failed.push({ id, code: error.code ?? 'delete-failed', message: String(error?.message ?? error) });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
send(res, failed.length > 0 && deleted.length === 0 && pending.length === 0 ? 409 : 200, { ok: failed.length === 0, deleted, pending, failed });
|
|
418
|
+
} catch (error) {
|
|
419
|
+
send(res, error.status ?? 500, { error: 'delete-failed', message: String(error?.message ?? error) });
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
ctx.effect(() => webServer.register({
|
|
423
|
+
kind: 'exact',
|
|
424
|
+
path: `${ROUTE_PREFIX}/delete`,
|
|
425
|
+
handler: deleteHandler(false),
|
|
426
|
+
}), 'archived-chats: delete route');
|
|
427
|
+
ctx.effect(() => webServer.register({
|
|
428
|
+
kind: 'exact',
|
|
429
|
+
path: `${ROUTE_PREFIX}/delete-all`,
|
|
430
|
+
handler: deleteHandler(true),
|
|
431
|
+
}), 'archived-chats: delete-all route');
|
|
432
|
+
}
|
|
433
|
+
//#endregion
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Mount the plugin. The Web profile mounts `webServer`, `workspaceRegistry`,
|
|
437
|
+
* and `sessionPersistence`; headless profiles never bind them, in which case
|
|
438
|
+
* the plugin stays dormant instead of blocking boot.
|
|
439
|
+
*/
|
|
440
|
+
export function apply(ctx) {
|
|
441
|
+
let registered = false;
|
|
442
|
+
const registerWebSurface = () => {
|
|
443
|
+
if (registered) return;
|
|
444
|
+
const webServer = ctx.get(WEB_SERVER_KEYS[0]) ?? ctx.get(WEB_SERVER_KEYS[1]);
|
|
445
|
+
const registry = ctx.get(WORKSPACE_KEYS[0]) ?? ctx.get(WORKSPACE_KEYS[1]);
|
|
446
|
+
const persistence = ctx.get(PERSISTENCE_KEYS[0]);
|
|
447
|
+
if (webServer === undefined || registry === undefined || persistence === undefined) return;
|
|
448
|
+
registered = true;
|
|
449
|
+
registerRoutes(ctx, webServer, registry, persistence);
|
|
450
|
+
// Boot sweep: every pending-deletion id is cold now (plugin activation
|
|
451
|
+
// precedes any client resume), so complete each deferred deletion.
|
|
452
|
+
void sweepPendingDeletions(ctx, registry, persistence)
|
|
453
|
+
.catch((error) => ctx.logger.warn(`archived-chats: boot sweep failed: ${String(error)}`));
|
|
454
|
+
};
|
|
455
|
+
registerWebSurface();
|
|
456
|
+
ctx.on('internal/service', (serviceName) => {
|
|
457
|
+
if (WEB_SERVER_KEYS.includes(serviceName)
|
|
458
|
+
|| WORKSPACE_KEYS.includes(serviceName)
|
|
459
|
+
|| PERSISTENCE_KEYS.includes(serviceName)) {
|
|
460
|
+
registerWebSurface();
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-archived-chats",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "DeepSeek Harness 已归档会话管理页:在设置里查看、搜索、取消归档、删除已归档的聊天,按项目分组。An Archived Chats settings page for DeepSeek Harness: browse, search, unarchive, and delete archived sessions, grouped by workspace.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Ultronen",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Ultronen/dsh-archived-chats.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Ultronen/dsh-archived-chats",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Ultronen/dsh-archived-chats/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"deepseek-harness",
|
|
17
|
+
"dsh",
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"archive",
|
|
20
|
+
"sessions",
|
|
21
|
+
"web-ui"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "lib/index.js",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./lib/types/index.d.ts",
|
|
28
|
+
"default": "./lib/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./client": {
|
|
31
|
+
"types": "./lib/types/client/index.d.ts",
|
|
32
|
+
"default": "./lib/client.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"lib/index.js",
|
|
38
|
+
"lib/client.js",
|
|
39
|
+
"lib/types",
|
|
40
|
+
"cordis.patch.yml"
|
|
41
|
+
],
|
|
42
|
+
"dsh": {
|
|
43
|
+
"bundle": {
|
|
44
|
+
"patch": "./cordis.patch.yml"
|
|
45
|
+
},
|
|
46
|
+
"client": {
|
|
47
|
+
"inject": [
|
|
48
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
49
|
+
"@deepseek-ai/dsh-client-locale"
|
|
50
|
+
],
|
|
51
|
+
"platform": "web",
|
|
52
|
+
"immediately": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"react": "^18.2.0",
|
|
57
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
58
|
+
}
|
|
59
|
+
}
|