dsh-plugin-session-cleaner 0.0.0-stage → 0.2.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,96 @@
1
+ /**
2
+ * The Remote contract, shared verbatim by both halves.
3
+ *
4
+ * The Host's typert manifest and the Web client's mounted descriptors are both
5
+ * built from this file, so the two halves cannot drift.
6
+ *
7
+ * Written as CommonJS on purpose: the Web client bundles this file into a
8
+ * single self-contained `client.js`, and Node's ESM interop exposes these
9
+ * `exports.*` assignments to the Host half as named imports, so one file
10
+ * serves both without a build-time transform.
11
+ *
12
+ * dsh-typert-loader requires EVERY codec — parameters and results — to be
13
+ * `strict` (a string typeSymbol plus a create() factory), and the gateway's
14
+ * boundary check only calls `codec.create().parse(value)`. A pass-through
15
+ * parser therefore satisfies the wire contract exactly: the payloads are plain
16
+ * JSON projection data, and the gateway's own assertJsonValue still guards the
17
+ * boundary, so hand-rolling one line of validation beats shipping a schema
18
+ * library inside the client bundle.
19
+ */
20
+
21
+ /** Pass-through boundary schema: whatever crosses is already lossless JSON. */
22
+ const passthrough = () => ({ parse: (value) => value })
23
+
24
+ /** The one schema entry both halves register for their codecs. */
25
+ const SESSION_CLEANER_JSON_SCHEMA = Object.freeze({
26
+ name: 'SessionCleanerJson',
27
+ create: passthrough,
28
+ })
29
+
30
+ /** Strict JSON codec, valid on both the Host manifest and the client mount. */
31
+ const JSON_CODEC = Object.freeze({
32
+ mode: 'strict',
33
+ typeSymbol: 'dsh-plugin-session-cleaner#SessionCleanerJson',
34
+ create: passthrough,
35
+ })
36
+
37
+ /** One ordered business parameter. */
38
+ const param = (name, wire = name) => Object.freeze({
39
+ name,
40
+ wire,
41
+ source: 'json',
42
+ codec: JSON_CODEC,
43
+ })
44
+
45
+ const SESSION_CLEANER_LIST = Object.freeze({
46
+ id: 'dsh-plugin-session-cleaner#sessionCleaner/list',
47
+ service: 'sessionCleaner',
48
+ namespace: 'sessionCleaner',
49
+ method: 'list',
50
+ invocation: { kind: 'direct' },
51
+ parameters: [],
52
+ result: JSON_CODEC,
53
+ })
54
+
55
+ const SESSION_CLEANER_PREVIEW = Object.freeze({
56
+ id: 'dsh-plugin-session-cleaner#sessionCleaner/preview',
57
+ service: 'sessionCleaner',
58
+ namespace: 'sessionCleaner',
59
+ method: 'preview',
60
+ invocation: { kind: 'direct' },
61
+ parameters: [param('sessionIds')],
62
+ result: JSON_CODEC,
63
+ })
64
+
65
+ const SESSION_CLEANER_READ = Object.freeze({
66
+ id: 'dsh-plugin-session-cleaner#sessionCleaner/read',
67
+ service: 'sessionCleaner',
68
+ namespace: 'sessionCleaner',
69
+ method: 'read',
70
+ invocation: { kind: 'direct' },
71
+ parameters: [param('sessionId'), param('options')],
72
+ result: JSON_CODEC,
73
+ })
74
+
75
+ const SESSION_CLEANER_DELETE = Object.freeze({
76
+ id: 'dsh-plugin-session-cleaner#sessionCleaner/delete',
77
+ service: 'sessionCleaner',
78
+ namespace: 'sessionCleaner',
79
+ method: 'delete',
80
+ invocation: { kind: 'direct' },
81
+ parameters: [param('sessionIds'), param('options')],
82
+ result: JSON_CODEC,
83
+ })
84
+
85
+ const TYPERT_REMOTE = Object.freeze({
86
+ package: 'dsh-plugin-session-cleaner',
87
+ descriptors: [SESSION_CLEANER_LIST, SESSION_CLEANER_PREVIEW, SESSION_CLEANER_READ, SESSION_CLEANER_DELETE],
88
+ })
89
+
90
+ exports.SESSION_CLEANER_JSON_SCHEMA = SESSION_CLEANER_JSON_SCHEMA
91
+ exports.SESSION_CLEANER_LIST = SESSION_CLEANER_LIST
92
+ exports.SESSION_CLEANER_PREVIEW = SESSION_CLEANER_PREVIEW
93
+ exports.SESSION_CLEANER_READ = SESSION_CLEANER_READ
94
+ exports.SESSION_CLEANER_DELETE = SESSION_CLEANER_DELETE
95
+ exports.TYPERT_REMOTE = TYPERT_REMOTE
96
+ exports.default = TYPERT_REMOTE
package/src/index.js ADDED
@@ -0,0 +1,340 @@
1
+ /**
2
+ * session-cleaner Host half.
3
+ *
4
+ * DSH ships no way to delete a session: `sessionPersistence` exposes only
5
+ * create/open/stat/list, `sessionController` has no delete, and the sidebar
6
+ * menu offers pin/rename/fork/archive. This plugin adds the missing operation
7
+ * and performs it completely — session log, projection cache, workspace
8
+ * registry references, and derived search indexes.
9
+ *
10
+ * The service is the single authority for deletion; the Web UI and the model
11
+ * Tool are two callers of it.
12
+ *
13
+ * This module deliberately imports NO DSH package: the profile's
14
+ * `@deepseek-ai/*` entries are partly unresolved links, so a static dependency
15
+ * on any of them would turn a load-time resolution failure into a plugin that
16
+ * never activates. Everything it needs arrives through the Cordis context.
17
+ */
18
+
19
+ import { executeDeletion, findSearchIndexes, isValidSessionId, planDeletion, readSessionTranscript, resolveDshHome, rootsFor, scanSessions } from './cleaner.js'
20
+
21
+ export const name = 'sessionCleaner'
22
+ export const inject = ['tools', 'agents']
23
+
24
+ const SERVICE = 'sessionCleaner'
25
+ const RESTORE_NOTE = 'Deletion is permanent: DSH has no recycle bin and no soft-delete layer.'
26
+ const DEFAULT_STOP_TIMEOUT_MS = 8000
27
+
28
+ /** One human-readable line describing a byte count. */
29
+ function formatBytes(bytes) {
30
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
31
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
32
+ let value = bytes
33
+ let unit = 0
34
+ while (value >= 1024 && unit < units.length - 1) {
35
+ value /= 1024
36
+ unit += 1
37
+ }
38
+ return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
39
+ }
40
+
41
+ /** Resolve the stop timeout, allowing an environment override for testing. */
42
+ function stopTimeoutMs() {
43
+ const raw = Number(process.env.DSH_SESSION_CLEANER_STOP_MS)
44
+ if (Number.isFinite(raw) && raw >= 500 && raw <= 60000) return raw
45
+ return DEFAULT_STOP_TIMEOUT_MS
46
+ }
47
+
48
+ /** JSON-serializable projection of a deletion plan. */
49
+ function planToWire(plan) {
50
+ return {
51
+ targetCount: plan.targets.length,
52
+ bytes: plan.totals.bytes,
53
+ humanBytes: formatBytes(plan.totals.bytes),
54
+ targets: plan.targets.map((target) => ({
55
+ id: target.id,
56
+ depth: target.depth,
57
+ isRoot: target.isRoot,
58
+ workspaceKey: target.workspaceKey,
59
+ logFile: target.logFile,
60
+ bytes: target.bytes,
61
+ title: target.title,
62
+ cwd: target.cwd,
63
+ })),
64
+ registryChanges: plan.registryChanges,
65
+ searchIndexes: plan.searchIndexes,
66
+ rejected: plan.rejectedIds,
67
+ missing: plan.missing,
68
+ skipped: plan.skipped,
69
+ }
70
+ }
71
+
72
+ /** JSON-serializable projection of a deletion result. */
73
+ function resultToWire(result) {
74
+ return {
75
+ aborted: result.aborted === true,
76
+ stopped: result.stopped,
77
+ deleted: result.deleted.map((entry) => ({ id: entry.id, kind: entry.kind, bytes: entry.bytes ?? 0 })),
78
+ registryChanges: result.registryChanges,
79
+ searchIndexesRemoved: result.searchIndexesRemoved.map((entry) => entry.path),
80
+ failed: result.failed,
81
+ summary: result.summary,
82
+ humanBytes: formatBytes(result.summary?.bytesDeleted ?? 0),
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Build the deletion service over the live Cordis context.
88
+ * @param ctx - the plugin context, carrying `agents`.
89
+ */
90
+ function createService(ctx) {
91
+ const roots = rootsFor(resolveDshHome())
92
+ const timeoutMs = stopTimeoutMs()
93
+
94
+ /** Resolve a live agent for a session, if one is registered. */
95
+ const liveAgent = (sessionId) => {
96
+ try {
97
+ return ctx.agents?.get(sessionId)
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
102
+
103
+ /** Wait until an agent reports `idle`, or the timeout expires. */
104
+ const awaitIdle = async (agent, limitMs) => {
105
+ const deadline = Date.now() + limitMs
106
+ while (Date.now() < deadline) {
107
+ if (agent.status !== 'running') return true
108
+ await new Promise((resolve) => setTimeout(resolve, 120))
109
+ }
110
+ return agent.status !== 'running'
111
+ }
112
+
113
+ /**
114
+ * Stop one session before its files are removed: a running agent would keep
115
+ * appending to a log we just deleted, leaving a half session behind.
116
+ *
117
+ * `ctx.agents.get()` returns a bare Agent — the AgentHandle disposer is a
118
+ * capability held only by whoever created it — so cancellation is the
119
+ * correct lever here, not disposal.
120
+ */
121
+ const stopSession = async (sessionId) => {
122
+ const agent = liveAgent(sessionId)
123
+ if (agent === undefined) return { wasRunning: false, stillRunning: false }
124
+ if (agent.status !== 'running') return { wasRunning: false, stillRunning: false }
125
+
126
+ try {
127
+ if (typeof agent.cancel === 'function') {
128
+ agent.cancel({ kind: 'user' }, { keepInbox: false })
129
+ } else if (typeof agent.abort === 'function') {
130
+ agent.abort()
131
+ } else if (typeof agent.interrupt === 'function') {
132
+ agent.interrupt()
133
+ } else {
134
+ return { wasRunning: true, stillRunning: true, reason: 'the live agent exposes no cancellation method' }
135
+ }
136
+ } catch (error) {
137
+ return { wasRunning: true, stillRunning: true, reason: String(error?.message ?? error) }
138
+ }
139
+
140
+ const idle = await awaitIdle(agent, timeoutMs)
141
+ return {
142
+ wasRunning: true,
143
+ stillRunning: !idle,
144
+ ...(idle ? {} : { reason: `still running after ${timeoutMs} ms` }),
145
+ }
146
+ }
147
+
148
+ return {
149
+ dshHome: roots.dshHome,
150
+
151
+ /** Every session on disk with lightweight metadata, largest first. */
152
+ async list() {
153
+ const { records, skipped } = await scanSessions(roots)
154
+ const searchIndexes = await findSearchIndexes(roots)
155
+ const sessions = [...records.values()].map((record) => ({
156
+ id: record.id,
157
+ workspaceKey: record.workspaceKey,
158
+ bytes: record.bytes,
159
+ humanBytes: formatBytes(record.bytes),
160
+ parent: record.parent,
161
+ cwd: record.cwd,
162
+ title: record.title,
163
+ createdAt: record.createdAt,
164
+ }))
165
+ sessions.sort((left, right) => right.bytes - left.bytes)
166
+ return { sessions, skipped, searchIndexes, dshHome: roots.dshHome }
167
+ },
168
+
169
+ /** Preview exactly what deleting these sessions would remove. */
170
+ async preview(sessionIds) {
171
+ const planned = await planDeletion(roots, Array.isArray(sessionIds) ? sessionIds : [])
172
+ return { ...planToWire(planned), note: RESTORE_NOTE }
173
+ },
174
+
175
+ /**
176
+ * Read one session's conversation as a read-only transcript preview.
177
+ * Locating the log reuses the scan, so the id grammar and path
178
+ * containment checks guarding deletion guard the read too.
179
+ */
180
+ async read(sessionId, options = {}) {
181
+ if (!isValidSessionId(sessionId)) {
182
+ return { ok: false, error: 'not a valid session id' }
183
+ }
184
+ const { records } = await scanSessions(roots)
185
+ const record = records.get(sessionId)
186
+ if (record === undefined) {
187
+ return { ok: false, error: 'session not found on disk' }
188
+ }
189
+ try {
190
+ const transcript = await readSessionTranscript(record.logFile, options)
191
+ return {
192
+ ok: true,
193
+ id: record.id,
194
+ workspaceKey: record.workspaceKey,
195
+ cwd: record.cwd,
196
+ bytes: record.bytes,
197
+ humanBytes: formatBytes(record.bytes),
198
+ title: transcript.title ?? record.title,
199
+ createdAt: transcript.createdAt,
200
+ messages: transcript.messages,
201
+ totalMessages: transcript.totalMessages,
202
+ truncated: transcript.truncated,
203
+ }
204
+ } catch (error) {
205
+ return { ok: false, error: `failed to read the session log: ${String(error?.message ?? error)}` }
206
+ }
207
+ },
208
+
209
+ /**
210
+ * Permanently delete sessions and their descendants.
211
+ * @param sessionIds - root session ids to remove.
212
+ * @param options.guard - a session id that must NOT be deleted (the open one).
213
+ * @param options.cascade - reserved; descendants are always included.
214
+ */
215
+ async delete(sessionIds, options = {}) {
216
+ const requested = Array.isArray(sessionIds) ? sessionIds.filter(isValidSessionId) : []
217
+ if (requested.length === 0) {
218
+ return { ok: false, error: 'no valid session ids were supplied' }
219
+ }
220
+ if (typeof options?.guard === 'string' && requested.includes(options.guard)) {
221
+ return {
222
+ ok: false,
223
+ error: 'refused: the requested set contains the session that is currently open',
224
+ guard: options.guard,
225
+ }
226
+ }
227
+
228
+ const planned = await planDeletion(roots, requested)
229
+ if (typeof options?.guard === 'string' && planned.targets.some((target) => target.id === options.guard)) {
230
+ return {
231
+ ok: false,
232
+ error: 'refused: a descendant of the requested set is the session that is currently open',
233
+ guard: options.guard,
234
+ }
235
+ }
236
+
237
+ const result = await executeDeletion(roots, planned, { stop: stopSession })
238
+ return { ok: result.failed.length === 0 && result.aborted !== true, ...resultToWire(result) }
239
+ },
240
+ }
241
+ }
242
+
243
+ /**
244
+ * The model-facing definition. `parameters` is a JSON Schema root; the registry
245
+ * validates it and requires `output.schema` plus `output.render`.
246
+ * @param service - the deletion service.
247
+ * @param currentSessionId - the session running the call, refused as a target.
248
+ */
249
+ function deletionTool(service, currentSessionId) {
250
+ return {
251
+ name: 'session_delete',
252
+ description: [
253
+ 'Permanently delete DeepSeek Harness sessions: their logs, projection caches,',
254
+ 'workspace-registry references and derived search indexes. Descendant subagent',
255
+ 'sessions are removed together with their parent, and a running session is stopped first.',
256
+ 'There is NO undo and no recycle bin. Always preview first with dryRun: true (the default),',
257
+ 'show the user the sessions and total size, and only then re-run with dryRun: false.',
258
+ ].join(' '),
259
+ parameters: {
260
+ type: 'object',
261
+ additionalProperties: false,
262
+ properties: {
263
+ sessionIds: {
264
+ type: 'array',
265
+ items: { type: 'string' },
266
+ description: 'Session ids to delete. Descendant subagent sessions are included automatically.',
267
+ },
268
+ dryRun: {
269
+ type: 'boolean',
270
+ description: 'When true (the default) only report what would be deleted. Set false to actually delete.',
271
+ },
272
+ },
273
+ // Canonical JSON Schema: `required` is a TOP-LEVEL array of property
274
+ // names. A per-property `required: true` is a DSL-only spelling; the
275
+ // registry does not validate `parameters` at registration, and the model
276
+ // provider then rejects it — failing EVERY conversation carrying this
277
+ // tool (`Invalid schema for function 'session_delete': true is not of
278
+ // type "array"`).
279
+ required: ['sessionIds'],
280
+ },
281
+ output: {
282
+ // A permissive OBJECT root: the registry validates this as strict JSON
283
+ // Schema, so `type: 'json'` (a DSL-only spelling) is rejected. Every
284
+ // branch this tool returns is an object, so an open object is exact.
285
+ schema: { type: 'object', additionalProperties: true },
286
+ render(_args, value) {
287
+ return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
288
+ },
289
+ },
290
+ async execute(args) {
291
+ const ids = Array.isArray(args?.sessionIds) ? args.sessionIds : []
292
+ if (typeof currentSessionId === 'string' && ids.includes(currentSessionId)) {
293
+ return {
294
+ ok: false,
295
+ error: 'refused: that is the session running this tool call; deleting it would destroy the active conversation',
296
+ }
297
+ }
298
+
299
+ if (args?.dryRun !== false) {
300
+ const preview = await service.preview(ids)
301
+ return {
302
+ ok: true,
303
+ dryRun: true,
304
+ ...preview,
305
+ message: `Would delete ${preview.targetCount} session(s) and free ${preview.humanBytes}. Re-run with dryRun: false to confirm.`,
306
+ }
307
+ }
308
+
309
+ const result = await service.delete(ids, { guard: currentSessionId })
310
+ return {
311
+ ...result,
312
+ ...(result.ok === true
313
+ ? { message: `Deleted ${result.summary?.sessionsDeleted ?? 0} session(s), freed ${result.humanBytes}.` }
314
+ : {}),
315
+ }
316
+ },
317
+ }
318
+ }
319
+
320
+ export function apply(ctx) {
321
+ const service = createService(ctx)
322
+
323
+ // Public Cordis service plus its Remote binding, so the Web UI reaches the
324
+ // same deletion authority the Tool uses. The typert contribution itself is
325
+ // loaded by dsh-typert-loader from this package's `./typert` export; the
326
+ // consumer-side descriptors are mounted by the Web client — the Host half
327
+ // never mounts them itself.
328
+ service.typertRemote = Object.freeze({ service, serviceKey: name, namespace: name })
329
+ ctx.provide(name, service)
330
+
331
+ ctx.effect(() => {
332
+ const holder = ctx.agents?.currentInitiator?.()
333
+ const currentSessionId = typeof holder?.id === 'string' ? holder.id : undefined
334
+ const dispose = ctx.tools.register(deletionTool(service, currentSessionId))
335
+ return () => dispose()
336
+ }, 'session-cleaner.tool')
337
+ }
338
+
339
+ // Re-exported so a host (and the tests) can compose the core directly.
340
+ export { planDeletion, executeDeletion, rootsFor, resolveDshHome }
package/src/remote.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Remote contract entry point for the Host half.
3
+ * The descriptors live in `contract.cjs` because the Web client bundles that
4
+ * same file, so both halves are guaranteed to agree on the wire shape.
5
+ */
6
+ export {
7
+ SESSION_CLEANER_DELETE,
8
+ SESSION_CLEANER_LIST,
9
+ SESSION_CLEANER_PREVIEW,
10
+ TYPERT_REMOTE,
11
+ } from './contract.cjs'
12
+ export { default } from './contract.cjs'
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Strict reflection for the session-cleaner service.
3
+ *
4
+ * dsh-typert-loader imports this file for every bundle that declares the
5
+ * `./typert` export, so it MUST exist and MUST load cleanly — a missing or
6
+ * throwing contributor fails the plugin's activation entirely. The invocations
7
+ * are the same descriptors the Web client mounts, imported from the shared
8
+ * contract so the two halves cannot drift.
9
+ */
10
+
11
+ import { SESSION_CLEANER_DELETE, SESSION_CLEANER_JSON_SCHEMA, SESSION_CLEANER_LIST, SESSION_CLEANER_PREVIEW, SESSION_CLEANER_READ } from './contract.cjs'
12
+
13
+ export const TYPERT = {
14
+ package: 'dsh-plugin-session-cleaner',
15
+ face: 'host',
16
+ schemas: [SESSION_CLEANER_JSON_SCHEMA],
17
+ invocations: [SESSION_CLEANER_LIST, SESSION_CLEANER_PREVIEW, SESSION_CLEANER_READ, SESSION_CLEANER_DELETE],
18
+ model: {
19
+ services: [{
20
+ key: 'sessionCleaner',
21
+ exportName: 'SessionCleanerService',
22
+ description: 'Complete session deletion: session logs, projection caches, workspace-registry references and derived search indexes.',
23
+ summary: 'Session cleanup service',
24
+ tags: [],
25
+ members: [
26
+ { kind: 'method', name: 'list', signature: 'list(): Promise<{ sessions: SessionCleanupRecord[]; skipped: unknown[]; searchIndexes: string[]; dshHome: string }>' },
27
+ { kind: 'method', name: 'preview', signature: 'preview(sessionIds: string[]): Promise<SessionCleanupPlan>' },
28
+ { kind: 'method', name: 'read', signature: 'read(sessionId: string, options?: { maxMessages?: number; maxCharsPerMessage?: number }): Promise<SessionTranscriptResult>' },
29
+ { kind: 'method', name: 'delete', signature: 'delete(sessionIds: string[], options?: { guard?: string }): Promise<SessionCleanupResult>' },
30
+ ],
31
+ types: [],
32
+ }],
33
+ events: [],
34
+ objects: [],
35
+ },
36
+ }
37
+
38
+ export default TYPERT