@zfdx123/dsh-session-cleaner 1.0.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.
@@ -0,0 +1,6 @@
1
+ # @zfdx123/dsh-session-cleaner bundle patch: inserts the plugin row over the
2
+ # dsh-base layer. The plugin registers one HTTP route on the webServer service
3
+ # (POST /api-ext/session.delete) and contributes the session-cleaner UI.
4
+ - insert:
5
+ - id: session-cleaner
6
+ name: '@zfdx123/dsh-session-cleaner'
package/lib/index.js ADDED
@@ -0,0 +1,414 @@
1
+ // dsh-session-cleaner-local — delete sessions from a running DeepSeek Harness
2
+ // web runtime, without restarting it.
3
+ //
4
+ // The product only archives: `workspace.archiveSession` hides a session by
5
+ // adding its id to a registry set, and the session's files stay on disk. There
6
+ // is no `session.delete`. This bundle closes that gap.
7
+ //
8
+ // Deletion is irreversible and does four things:
9
+ // 1. detaches the session's live SessionStore entry, when it has one, through
10
+ // the store's own entry disposer rather than around it. Only an agent
11
+ // session ever has an entry: the agent loop is the sole `sessions.enter`
12
+ // caller, and `session.list` reads live entries while summarizing every
13
+ // other session from persistence — it does not prepare cold sessions into
14
+ // the store. Because this route refuses any session with an attached
15
+ // agent, the detach is a defensive guard, kept so a store entry that
16
+ // outlived its agent cannot survive as a row over deleted files;
17
+ // 2. drops the id from the global archive set and from every workspace
18
+ // record that accounts for it;
19
+ // 3. deletes the on-disk artifact directory `<root>/<project>/<sessionId>`;
20
+ // 4. deletes the session's `session_projcache` row (the file search index is
21
+ // derived and prunes itself).
22
+ //
23
+ // The sidebar row itself is dropped by the CLIENT half: `session/disposed` —
24
+ // the event the session controller forwards as `api-session/removed` — only
25
+ // fires for an ANNOUNCED live entry, and a deleted session has no live entry
26
+ // for the reason above.
27
+ //
28
+ // Everything is `node:fs` — no shell, no quoting, no platform branch.
29
+ //
30
+ // One HTTP route, mirroring the host's JSON envelope:
31
+ // POST /api-ext/session.delete body: { "sessionId": "session-…" }
32
+ // → 200 { "ok": true, "value": { … } }
33
+ // → 400 { "ok": false, "error": { "code": "bad-request", … } }
34
+ // → 409 { "ok": false, "error": { "code": "refused", … } }
35
+ // → 415 { "ok": false, "error": { "code": "unsupported-media-type", … } }
36
+ // → 500 { "ok": false, "error": { "code": "internal", … } }
37
+
38
+ import { readdir, rm, stat } from 'node:fs/promises'
39
+ import { homedir } from 'node:os'
40
+ import { join, resolve } from 'node:path'
41
+
42
+ export const name = 'dsh-session-cleaner'
43
+
44
+ // `storageDomain` backs {@link removeProjection}: the provider is already
45
+ // present wherever `workspaceRegistry` is (dsh-workspace injects it), so
46
+ // declaring it costs nothing and removes the reflective read.
47
+ export const inject = ['webServer', 'workspaceRegistry', 'sessions', 'agents', 'storageDomain']
48
+
49
+ /** Route path, shared with the ecosystem convention for this feature. */
50
+ export const ROUTE_PATH = '/api-ext/session.delete'
51
+
52
+ /**
53
+ * Diagnostic route. The ⋮ menu entry is necessarily a DOM augmentation (the
54
+ * session row menu has no public slot), so the client half reports what it saw
55
+ * and did here, and the same endpoint reads those reports back. Bounded in
56
+ * memory, never persisted.
57
+ */
58
+ export const DIAG_PATH = '/api-ext/session.cleaner.diag'
59
+
60
+ /** How many diagnostic entries to keep. */
61
+ const DIAG_LIMIT = 100
62
+
63
+ /** Ring buffer behind {@link DIAG_PATH}. */
64
+ const diagnostics = []
65
+
66
+ /**
67
+ * Read-only introspection of one session's deletion surface: validity, artifact
68
+ * directories a delete would touch, live entry, attached agent, archive and
69
+ * workspace membership. Deletes nothing — this is the safe way to see what the
70
+ * plugin sees.
71
+ * @param {object} ctx - plugin context.
72
+ * @param {string} sessionId - session to inspect.
73
+ * @returns {Promise<object>} leaf-field report.
74
+ */
75
+ export async function inspectSession(ctx, sessionId) {
76
+ const root = sessionsRoot()
77
+ const report = {
78
+ sessionId: String(sessionId),
79
+ valid: typeof sessionId === 'string' && SESSION_ID_RE.test(sessionId),
80
+ root,
81
+ sessionsRootExists: false,
82
+ dirs: [],
83
+ liveEntry: false,
84
+ agentStatus: null,
85
+ archived: false,
86
+ workspaces: [],
87
+ }
88
+ const agent = ctx.agents?.get?.(sessionId)
89
+ report.agentStatus = agent === undefined ? null : String(agent.status ?? 'attached')
90
+ report.liveEntry = ctx.sessions?.get?.(sessionId) !== undefined
91
+ report.archived = (ctx.workspaceRegistry?.archivedSessionIds ?? []).includes(sessionId)
92
+ for (const workspace of ctx.workspaceRegistry?.list?.() ?? []) {
93
+ if (Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) {
94
+ report.workspaces.push(String(workspace.id))
95
+ }
96
+ }
97
+ try {
98
+ const projects = await readdir(root, { withFileTypes: true })
99
+ report.sessionsRootExists = true
100
+ for (const project of projects) {
101
+ if (!project.isDirectory()) continue
102
+ const dir = join(root, project.name, sessionId)
103
+ try {
104
+ if ((await stat(dir)).isDirectory()) report.dirs.push(dir)
105
+ } catch {
106
+ /* not under this project */
107
+ }
108
+ }
109
+ } catch {
110
+ /* root absent */
111
+ }
112
+ return report
113
+ }
114
+
115
+ /** Max accepted request body, in bytes. */
116
+ const BODY_LIMIT = 1 << 16
117
+
118
+ /**
119
+ * Session ids DSH mints: current `session-<uuid>`, or the legacy bare `<uuid>`
120
+ * used before the prefix existed. Anything else (a path separator, a dot, a
121
+ * colon) is rejected before it can reach a filesystem path.
122
+ */
123
+ const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
124
+
125
+ /** Default harness home; mirrors the host's `defaultDshHome` (`dsh-home-paths`). */
126
+ function defaultDshHome() {
127
+ return join(homedir(), '.dsh')
128
+ }
129
+
130
+ /** Expand `~`, `~/`, or `~\` against the OS home; mirrors the host's `expandHomePath`. */
131
+ function expandHomePath(path) {
132
+ if (path === '~') return homedir()
133
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
134
+ return path
135
+ }
136
+
137
+ /**
138
+ * The sessions root: `$DSH_HOME/sessions`, or `~/.dsh/sessions` when the
139
+ * override is unset or blank. Resolved the way the host's `resolveDshHome`
140
+ * does — tilde expansion first, then a resolve against the working directory —
141
+ * so the plugin and the persistence backend always name the same tree.
142
+ * @returns {string} absolute sessions root.
143
+ */
144
+ export function sessionsRoot() {
145
+ const configured = process.env.DSH_HOME
146
+ const home =
147
+ configured === undefined || configured.trim().length === 0 ? defaultDshHome() : expandHomePath(configured)
148
+ return join(resolve(home), 'sessions')
149
+ }
150
+
151
+ /**
152
+ * Delete every `<root>/<project>/<sessionId>` directory. Only an entry whose
153
+ * name is exactly the validated id is touched.
154
+ * @param {string} sessionId - validated session id.
155
+ * @param {string} [root] - sessions root override (tests).
156
+ * @returns {Promise<{root: string, removed: string[], failed: {path: string, reason: string}[]}>}
157
+ */
158
+ export async function removeArtifacts(sessionId, root = sessionsRoot()) {
159
+ const removed = []
160
+ const failed = []
161
+ let projects
162
+ try {
163
+ projects = await readdir(root, { withFileTypes: true })
164
+ } catch {
165
+ return { root, removed, failed } // root absent — nothing to remove
166
+ }
167
+ for (const project of projects) {
168
+ if (!project.isDirectory()) continue
169
+ const dir = join(root, project.name, sessionId)
170
+ try {
171
+ const info = await stat(dir)
172
+ if (!info.isDirectory()) continue
173
+ } catch {
174
+ continue // this project does not hold the session
175
+ }
176
+ try {
177
+ await rm(dir, { recursive: true, force: true })
178
+ removed.push(dir)
179
+ } catch (error) {
180
+ failed.push({ path: dir, reason: String(error?.message ?? error) })
181
+ }
182
+ }
183
+ return { root, removed, failed }
184
+ }
185
+
186
+ /**
187
+ * Detach a session from the live store. The entry is the one `SessionStore`
188
+ * itself keeps, and its `detach` is the disposer `enter()` returned, so this is
189
+ * the store's own removal path rather than a shortcut around it.
190
+ * @param {object} sessions - the `sessions` service.
191
+ * @param {string} sessionId - session to detach.
192
+ * @returns {boolean} whether a live entry was detached.
193
+ */
194
+ export function detachLiveEntry(sessions, sessionId) {
195
+ const entry = sessions?.store?.get?.(sessionId)
196
+ if (entry === undefined || typeof entry.detach !== 'function') return false
197
+ entry.detach()
198
+ return true
199
+ }
200
+
201
+ /**
202
+ * Remove the session from the archive set and from every accounting workspace.
203
+ * @param {object} ctx - plugin context (needs workspaceRegistry).
204
+ * @param {string} sessionId - session to detach.
205
+ * @returns {Promise<{unarchived: boolean, detached: string[]}>}
206
+ */
207
+ export async function detachAccounting(ctx, sessionId) {
208
+ const registry = ctx.workspaceRegistry
209
+ const result = { unarchived: false, detached: [] }
210
+ if (registry === undefined) return result
211
+ if ((registry.archivedSessionIds ?? []).includes(sessionId)) {
212
+ await registry.unarchiveSession(sessionId)
213
+ result.unarchived = true
214
+ }
215
+ for (const workspace of registry.list()) {
216
+ const ids = workspace?.sessionIds
217
+ if (!Array.isArray(ids) || !ids.includes(sessionId)) continue
218
+ await workspace.detachSession?.(sessionId)
219
+ result.detached.push(String(workspace.id))
220
+ }
221
+ return result
222
+ }
223
+
224
+ /**
225
+ * Delete the `session_projcache/sessions/<id>` row when that domain is open.
226
+ * The row is a fold shortcut, not an authority, so a closed domain does not
227
+ * fail the delete — the cache self-heals from the log on the next cold read.
228
+ * The provider itself is declared in {@link inject}, so an absent service is a
229
+ * load-time failure rather than a step that quietly does nothing.
230
+ * @param {object} ctx - plugin context (needs storageDomain).
231
+ * @param {string} sessionId - session whose cached projections to drop.
232
+ * @returns {Promise<{ok: boolean, deleted?: boolean, reason?: string}>}
233
+ */
234
+ export async function removeProjection(ctx, sessionId) {
235
+ const domain = ctx.get?.('storageDomain')?.get?.('session_projcache')
236
+ if (domain === undefined) return { ok: false, reason: 'session_projcache domain is not open' }
237
+ const table = domain.table('sessions')
238
+ const existed = table.get(sessionId) !== undefined
239
+ if (existed) await table.delete(sessionId)
240
+ return { ok: true, deleted: existed }
241
+ }
242
+
243
+ /** Write one JSON envelope and end the response. */
244
+ function sendJson(res, status, body) {
245
+ const text = JSON.stringify(body)
246
+ res.writeHead(status, {
247
+ 'content-type': 'application/json; charset=utf-8',
248
+ 'cache-control': 'no-store',
249
+ 'content-length': Buffer.byteLength(text),
250
+ })
251
+ res.end(text)
252
+ }
253
+
254
+ /** Read and parse a bounded JSON request body. */
255
+ async function readJsonBody(req, limit = BODY_LIMIT) {
256
+ const chunks = []
257
+ let size = 0
258
+ for await (const chunk of req) {
259
+ size += chunk.length
260
+ if (size > limit) throw new Error(`request body exceeds ${limit} bytes`)
261
+ chunks.push(chunk)
262
+ }
263
+ if (chunks.length === 0) return {}
264
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
265
+ }
266
+
267
+ /**
268
+ * Whether the request declares a JSON body: both routes accept JSON only, so a
269
+ * cross-site form post (which cannot set this header) never reaches the
270
+ * handlers, and a wrong-typed body fails before it is parsed.
271
+ */
272
+ function isJsonRequest(req) {
273
+ const header = req.headers?.['content-type']
274
+ return typeof header === 'string' && header.split(';')[0].trim().toLowerCase() === 'application/json'
275
+ }
276
+
277
+ /** The refusal both routes answer for a body that is not JSON. */
278
+ function sendUnsupportedMediaType(res) {
279
+ return sendJson(res, 415, {
280
+ ok: false,
281
+ error: { code: 'unsupported-media-type', message: 'content-type must be application/json' },
282
+ })
283
+ }
284
+
285
+ /**
286
+ * The deletion itself, independent of transport.
287
+ * @param {object} ctx - plugin context.
288
+ * @param {string} sessionId - requested session id.
289
+ * @param {{root?: string}} [options] - `root` overrides the sessions root; it
290
+ * exists so tests can run against a scratch tree instead of real sessions.
291
+ * @returns {Promise<object>} the success envelope's `value`.
292
+ * @throws {Error & {code?: string}} with `code: 'bad-request' | 'refused'`.
293
+ */
294
+ export async function deleteSession(ctx, sessionId, options = {}) {
295
+ if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) {
296
+ const error = new Error('missing or invalid sessionId')
297
+ error.code = 'bad-request'
298
+ throw error
299
+ }
300
+ // An attached agent — running OR idle — means the session is open in the UI.
301
+ // Tearing an open session down from under its owner is not this plugin's
302
+ // business, so it refuses and lets the user close it first.
303
+ const agent = ctx.agents?.get?.(sessionId)
304
+ if (agent !== undefined) {
305
+ const status = typeof agent.status === 'string' ? agent.status : 'attached'
306
+ const error = new Error(`session "${sessionId}" is open (agent status: ${status}); close it before deleting`)
307
+ error.code = 'refused'
308
+ throw error
309
+ }
310
+
311
+ const liveBefore = ctx.sessions?.get?.(sessionId) !== undefined
312
+ const liveDetached = detachLiveEntry(ctx.sessions, sessionId)
313
+ const accounting = await detachAccounting(ctx, sessionId)
314
+ const files = await removeArtifacts(sessionId, options.root)
315
+ const projection = await removeProjection(ctx, sessionId)
316
+ if (projection.ok !== true) {
317
+ // The delete still succeeds; logging is what keeps the skipped step from
318
+ // being invisible to anyone but the caller reading the envelope.
319
+ ctx.logger?.warn?.(`dsh-session-cleaner: ${sessionId}: projection row kept — ${projection.reason}`)
320
+ }
321
+
322
+ return {
323
+ sessionId,
324
+ liveBefore, // true when a live store entry existed (only an agent session has one)
325
+ liveDetached,
326
+ accounting,
327
+ files,
328
+ projection,
329
+ }
330
+ }
331
+
332
+ export function apply(ctx) {
333
+ ctx.effect(
334
+ () =>
335
+ ctx.webServer.register({
336
+ kind: 'exact',
337
+ path: ROUTE_PATH,
338
+ handler: async (req, res) => {
339
+ if (req.method !== 'POST') {
340
+ return sendJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'use POST' } })
341
+ }
342
+ if (!isJsonRequest(req)) return sendUnsupportedMediaType(res)
343
+ let payload
344
+ try {
345
+ payload = await readJsonBody(req)
346
+ } catch (error) {
347
+ return sendJson(res, 400, {
348
+ ok: false,
349
+ error: { code: 'bad-request', message: String(error?.message ?? error) },
350
+ })
351
+ }
352
+ try {
353
+ const value = await deleteSession(ctx, payload?.sessionId)
354
+ return sendJson(res, 200, { ok: true, value })
355
+ } catch (error) {
356
+ const code = typeof error?.code === 'string' ? error.code : 'internal'
357
+ const status = code === 'bad-request' ? 400 : code === 'refused' ? 409 : 500
358
+ if (code === 'internal') {
359
+ ctx.logger?.warn?.(
360
+ `dsh-session-cleaner: delete ${String(payload?.sessionId)} failed: ${String(error?.message ?? error)}`,
361
+ )
362
+ }
363
+ return sendJson(res, status, { ok: false, error: { code, message: String(error?.message ?? error) } })
364
+ }
365
+ },
366
+ }),
367
+ 'dsh-session-cleaner: delete route',
368
+ )
369
+
370
+ // Diagnostic carrier: the client half posts what it observed, and the same
371
+ // route reads the bounded log back (also usable with an `inspect` request to
372
+ // see one session's deletion surface without deleting it).
373
+ ctx.effect(
374
+ () =>
375
+ ctx.webServer.register({
376
+ kind: 'exact',
377
+ path: DIAG_PATH,
378
+ handler: async (req, res) => {
379
+ if (req.method !== 'POST') {
380
+ return sendJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'use POST' } })
381
+ }
382
+ if (!isJsonRequest(req)) return sendUnsupportedMediaType(res)
383
+ let payload
384
+ try {
385
+ payload = await readJsonBody(req)
386
+ } catch (error) {
387
+ return sendJson(res, 400, {
388
+ ok: false,
389
+ error: { code: 'bad-request', message: String(error?.message ?? error) },
390
+ })
391
+ }
392
+ try {
393
+ if (typeof payload?.report?.event === 'string') {
394
+ diagnostics.push({
395
+ at: new Date().toISOString(),
396
+ event: String(payload.report.event).slice(0, 200),
397
+ detail: payload.report.detail === undefined ? null : JSON.parse(JSON.stringify(payload.report.detail)),
398
+ })
399
+ while (diagnostics.length > DIAG_LIMIT) diagnostics.shift()
400
+ }
401
+ const value = { entries: diagnostics.slice() }
402
+ if (typeof payload?.inspect === 'string') value.inspect = await inspectSession(ctx, payload.inspect)
403
+ return sendJson(res, 200, { ok: true, value })
404
+ } catch (error) {
405
+ return sendJson(res, 500, {
406
+ ok: false,
407
+ error: { code: 'internal', message: String(error?.message ?? error) },
408
+ })
409
+ }
410
+ },
411
+ }),
412
+ 'dsh-session-cleaner: diag route',
413
+ )
414
+ }
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@zfdx123/dsh-session-cleaner",
3
+ "version": "1.0.0",
4
+ "description": "会话清理:在运行中的 web 运行时里彻底删除 DSH 会话——实时 store 条目、工作区记录、磁盘产物与投影缓存行一并清掉,并在会话行菜单里加一个删除入口。",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/zfdx123/dsh-atelier/tree/main/packages/dsh-session-cleaner#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/zfdx123/dsh-atelier.git",
10
+ "directory": "packages/dsh-session-cleaner"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/zfdx123/dsh-atelier/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "lib/index.js",
17
+ "exports": {
18
+ ".": {
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./client": {
22
+ "default": "./client.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "files": [
27
+ "lib",
28
+ "client.js",
29
+ "cordis.patch.yml",
30
+ "README.md"
31
+ ],
32
+ "engines": {
33
+ "node": "^22.19.0 || >=24.0.0",
34
+ "dsh": "^0.1.6-alpha.1"
35
+ },
36
+ "peerDependencies": {
37
+ "@deepseek-ai/cordis": "^4.0.2",
38
+ "@deepseek-ai/dsh-agent": "^0.1.6-alpha.1",
39
+ "@deepseek-ai/dsh-host-webserver": "^0.1.6-alpha.1",
40
+ "@deepseek-ai/dsh-session": "^0.1.6-alpha.1",
41
+ "@deepseek-ai/dsh-workspace": "^0.1.6-alpha.1"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@deepseek-ai/dsh-agent": {
45
+ "optional": true
46
+ },
47
+ "@deepseek-ai/dsh-host-webserver": {
48
+ "optional": true
49
+ },
50
+ "@deepseek-ai/dsh-session": {
51
+ "optional": true
52
+ },
53
+ "@deepseek-ai/dsh-workspace": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "scripts": {
58
+ "test": "node --test test/host.test.js && node test/run.js"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ },
63
+ "dsh": {
64
+ "bundle": {
65
+ "patch": "./cordis.patch.yml"
66
+ },
67
+ "client": {
68
+ "platform": "web",
69
+ "inject": [
70
+ "@deepseek-ai/dsh-api-session-controller",
71
+ "@deepseek-ai/dsh-api-workspace-controller",
72
+ "@deepseek-ai/dsh-client-locale",
73
+ "@deepseek-ai/dsh-client-ui-renderer",
74
+ "@deepseek-ai/dsh-client-ui-session",
75
+ "@deepseek-ai/dsh-client-ui-settings",
76
+ "@deepseek-ai/dsh-client-ui-workspace"
77
+ ]
78
+ }
79
+ }
80
+ }