@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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/client.js +1147 -0
- package/cordis.patch.yml +6 -0
- package/lib/index.js +414 -0
- package/package.json +80 -0
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|