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/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
+ }
@@ -0,0 +1,3 @@
1
+ export declare const SETTINGS_NS: string;
2
+ export declare function apply(ctx: unknown): void;
3
+ export declare const inject: string[];
@@ -0,0 +1,3 @@
1
+ /** Host loader entry — registers the /plugins/dsh-archived-chats/* routes. */
2
+ export declare function apply(ctx: unknown): void;
3
+ export declare const name: string;
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
+ }