dsh-plugin-session-cleaner 0.0.0-stage → 0.3.2

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,437 @@
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
+ warnings: result.warnings,
82
+ summary: result.summary,
83
+ humanBytes: formatBytes(result.summary?.bytesDeleted ?? 0),
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Build the deletion service over the live Cordis context.
89
+ * @param ctx - the plugin context, carrying `agents`.
90
+ */
91
+ function createService(ctx) {
92
+ const roots = rootsFor(resolveDshHome())
93
+ const timeoutMs = stopTimeoutMs()
94
+
95
+ /**
96
+ * The live workspace registry, when the running DSH publishes one. Present,
97
+ * it becomes the single write authority over `workspace.json`: deleting ids
98
+ * through it updates the in-memory registry and pushes follow increments to
99
+ * the client, so deleted rows disappear from the sidebar without a restart.
100
+ * Writing the file ourselves beside it would race the registry's own
101
+ * read-modify-write and resurrect detached ids on its next change.
102
+ */
103
+ let liveRegistry
104
+
105
+ /** Resolve a live agent for a session, if one is registered. */
106
+ const liveAgent = (sessionId) => {
107
+ try {
108
+ return ctx.agents?.get(sessionId)
109
+ } catch {
110
+ return undefined
111
+ }
112
+ }
113
+
114
+ /** Wait until an agent reports `idle`, or the timeout expires. */
115
+ const awaitIdle = async (agent, limitMs) => {
116
+ const deadline = Date.now() + limitMs
117
+ while (Date.now() < deadline) {
118
+ if (agent.status !== 'running') return true
119
+ await new Promise((resolve) => setTimeout(resolve, 120))
120
+ }
121
+ return agent.status !== 'running'
122
+ }
123
+
124
+ /**
125
+ * Stop one session before its files are removed: a running agent would keep
126
+ * appending to a log we just deleted, leaving a half session behind.
127
+ *
128
+ * `ctx.agents.get()` returns a bare Agent — the AgentHandle disposer is a
129
+ * capability held only by whoever created it — so cancellation is the
130
+ * correct lever here, not disposal.
131
+ */
132
+ const stopSession = async (sessionId) => {
133
+ const agent = liveAgent(sessionId)
134
+ if (agent === undefined) return { wasRunning: false, stillRunning: false }
135
+ if (agent.status !== 'running') return { wasRunning: false, stillRunning: false }
136
+
137
+ try {
138
+ if (typeof agent.cancel === 'function') {
139
+ agent.cancel({ kind: 'user' }, { keepInbox: false })
140
+ } else if (typeof agent.abort === 'function') {
141
+ agent.abort()
142
+ } else if (typeof agent.interrupt === 'function') {
143
+ agent.interrupt()
144
+ } else {
145
+ return { wasRunning: true, stillRunning: true, reason: 'the live agent exposes no cancellation method' }
146
+ }
147
+ } catch (error) {
148
+ return { wasRunning: true, stillRunning: true, reason: String(error?.message ?? error) }
149
+ }
150
+
151
+ const idle = await awaitIdle(agent, timeoutMs)
152
+ return {
153
+ wasRunning: true,
154
+ stillRunning: !idle,
155
+ ...(idle ? {} : { reason: `still running after ${timeoutMs} ms` }),
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Detach deleted sessions through the LIVE registry service, so the running
161
+ * app updates its workspace projection immediately instead of on next start.
162
+ *
163
+ * Every call is idempotent on the registry side: unarchiving an id that is
164
+ * not archived resolves without writing, and `detachSession` filters a list
165
+ * that no longer carries the id. Pin and archive are mutually exclusive and
166
+ * archiving drops the pin, but both sets are checked anyway — belt against
167
+ * a registry whose invariants drifted. Failures are collected per id and
168
+ * reported as warnings: the files are already deleted at this point, and a
169
+ * stale accounting entry heals on the next start.
170
+ */
171
+ const detachViaRegistry = async (ids) => {
172
+ const registry = liveRegistry
173
+ const changes = []
174
+ const failures = []
175
+ if (registry === undefined) return { changes, failures }
176
+
177
+ let entities
178
+ try {
179
+ entities = registry.list()
180
+ } catch (error) {
181
+ return { changes, failures: [{ id: '*', reason: `registry list() failed: ${String(error?.message ?? error)}` }] }
182
+ }
183
+
184
+ for (const id of ids) {
185
+ // Remove the row from the client BEFORE the accounting detach. The
186
+ // detach pushes a follow increment that re-roots a still-snapshotted
187
+ // row into Ungrouped, where it lingers until the list refresh lands —
188
+ // the event makes the client drop the row locally first, with no RPC
189
+ // round trip. It is the same event the shipped controller emits when it
190
+ // disposes a session; if the gateway does not relay it, behavior falls
191
+ // back to the brief Ungrouped flash, never worse.
192
+ try { ctx.emit?.('api-session/removed', id) } catch { /* advisory */ }
193
+ try {
194
+ if (Array.isArray(registry.archivedSessionIds) && registry.archivedSessionIds.includes(id)) {
195
+ await registry.unarchiveSession(id)
196
+ changes.push({ where: 'global.archivedSessionIds', removed: 1 })
197
+ }
198
+ if (Array.isArray(registry.pinnedSessionIds) && registry.pinnedSessionIds.includes(id)) {
199
+ await registry.unpinSession(id)
200
+ changes.push({ where: 'global.pinnedSessionIds', removed: 1 })
201
+ }
202
+ for (const entity of entities) {
203
+ if (!Array.isArray(entity?.sessionIds) || !entity.sessionIds.includes(id)) continue
204
+ await entity.detachSession(id)
205
+ changes.push({ where: `workspaces[${String(entity.id)}].sessionIds`, removed: 1 })
206
+ }
207
+ } catch (error) {
208
+ failures.push({ id, reason: String(error?.message ?? error) })
209
+ }
210
+ }
211
+ return { changes, failures }
212
+ }
213
+
214
+ return {
215
+ dshHome: roots.dshHome,
216
+
217
+ /** Attach or detach the live registry service (see apply). */
218
+ bindRegistry(registry) {
219
+ liveRegistry = registry && typeof registry === 'object' ? registry : undefined
220
+ },
221
+
222
+ /** Every session on disk with lightweight metadata, largest first. */
223
+ async list() {
224
+ const { records, skipped } = await scanSessions(roots)
225
+ const searchIndexes = await findSearchIndexes(roots)
226
+ const sessions = [...records.values()].map((record) => ({
227
+ id: record.id,
228
+ workspaceKey: record.workspaceKey,
229
+ bytes: record.bytes,
230
+ humanBytes: formatBytes(record.bytes),
231
+ parent: record.parent,
232
+ cwd: record.cwd,
233
+ title: record.title,
234
+ createdAt: record.createdAt,
235
+ }))
236
+ sessions.sort((left, right) => right.bytes - left.bytes)
237
+ return { sessions, skipped, searchIndexes, dshHome: roots.dshHome }
238
+ },
239
+
240
+ /** Preview exactly what deleting these sessions would remove. */
241
+ async preview(sessionIds) {
242
+ const planned = await planDeletion(roots, Array.isArray(sessionIds) ? sessionIds : [])
243
+ return { ...planToWire(planned), note: RESTORE_NOTE }
244
+ },
245
+
246
+ /**
247
+ * Read one session's conversation as a read-only transcript preview.
248
+ * Locating the log reuses the scan, so the id grammar and path
249
+ * containment checks guarding deletion guard the read too.
250
+ */
251
+ async read(sessionId, options = {}) {
252
+ if (!isValidSessionId(sessionId)) {
253
+ return { ok: false, error: 'not a valid session id' }
254
+ }
255
+ const { records } = await scanSessions(roots)
256
+ const record = records.get(sessionId)
257
+ if (record === undefined) {
258
+ return { ok: false, error: 'session not found on disk' }
259
+ }
260
+ try {
261
+ const transcript = await readSessionTranscript(record.logFile, options)
262
+ return {
263
+ ok: true,
264
+ id: record.id,
265
+ workspaceKey: record.workspaceKey,
266
+ cwd: record.cwd,
267
+ bytes: record.bytes,
268
+ humanBytes: formatBytes(record.bytes),
269
+ title: transcript.title ?? record.title,
270
+ createdAt: transcript.createdAt,
271
+ messages: transcript.messages,
272
+ totalMessages: transcript.totalMessages,
273
+ truncated: transcript.truncated,
274
+ }
275
+ } catch (error) {
276
+ return { ok: false, error: `failed to read the session log: ${String(error?.message ?? error)}` }
277
+ }
278
+ },
279
+
280
+ /**
281
+ * Permanently delete sessions and their descendants.
282
+ * @param sessionIds - root session ids to remove.
283
+ * @param options.guard - a session id that must NOT be deleted (the open one).
284
+ * @param options.cascade - reserved; descendants are always included.
285
+ */
286
+ async delete(sessionIds, options = {}) {
287
+ const requested = Array.isArray(sessionIds) ? sessionIds.filter(isValidSessionId) : []
288
+ if (requested.length === 0) {
289
+ return { ok: false, error: 'no valid session ids were supplied' }
290
+ }
291
+ if (typeof options?.guard === 'string' && requested.includes(options.guard)) {
292
+ return {
293
+ ok: false,
294
+ error: 'refused: the requested set contains the session that is currently open',
295
+ guard: options.guard,
296
+ }
297
+ }
298
+
299
+ const planned = await planDeletion(roots, requested)
300
+ if (typeof options?.guard === 'string' && planned.targets.some((target) => target.id === options.guard)) {
301
+ return {
302
+ ok: false,
303
+ error: 'refused: a descendant of the requested set is the session that is currently open',
304
+ guard: options.guard,
305
+ }
306
+ }
307
+
308
+ const hasLiveRegistry = liveRegistry !== undefined
309
+ const result = await executeDeletion(roots, planned, {
310
+ stop: stopSession,
311
+ ...(hasLiveRegistry ? { detachRegistry: detachViaRegistry } : {}),
312
+ })
313
+ return {
314
+ ok: result.failed.length === 0 && result.aborted !== true,
315
+ // Which authority detached the registry references: 'live-registry'
316
+ // means the running app's projection was updated in place (the client
317
+ // re-pulls its session list right after this), 'registry-file' is the
318
+ // offline fallback that edits workspace.json on disk.
319
+ registrySync: hasLiveRegistry ? 'live-registry' : 'registry-file',
320
+ ...resultToWire(result),
321
+ }
322
+ },
323
+ }
324
+ }
325
+
326
+ /**
327
+ * The model-facing definition. `parameters` is a JSON Schema root; the registry
328
+ * validates it and requires `output.schema` plus `output.render`.
329
+ * @param service - the deletion service.
330
+ * @param currentSessionId - the session running the call, refused as a target.
331
+ */
332
+ function deletionTool(service, currentSessionId) {
333
+ return {
334
+ name: 'session_delete',
335
+ description: [
336
+ 'Permanently delete DeepSeek Harness sessions: their logs, projection caches,',
337
+ 'workspace-registry references and derived search indexes. Descendant subagent',
338
+ 'sessions are removed together with their parent, and a running session is stopped first.',
339
+ 'There is NO undo and no recycle bin. Always preview first with dryRun: true (the default),',
340
+ 'show the user the sessions and total size, and only then re-run with dryRun: false.',
341
+ ].join(' '),
342
+ parameters: {
343
+ type: 'object',
344
+ additionalProperties: false,
345
+ properties: {
346
+ sessionIds: {
347
+ type: 'array',
348
+ items: { type: 'string' },
349
+ description: 'Session ids to delete. Descendant subagent sessions are included automatically.',
350
+ },
351
+ dryRun: {
352
+ type: 'boolean',
353
+ description: 'When true (the default) only report what would be deleted. Set false to actually delete.',
354
+ },
355
+ },
356
+ // Canonical JSON Schema: `required` is a TOP-LEVEL array of property
357
+ // names. A per-property `required: true` is a DSL-only spelling; the
358
+ // registry does not validate `parameters` at registration, and the model
359
+ // provider then rejects it — failing EVERY conversation carrying this
360
+ // tool (`Invalid schema for function 'session_delete': true is not of
361
+ // type "array"`).
362
+ required: ['sessionIds'],
363
+ },
364
+ output: {
365
+ // A permissive OBJECT root: the registry validates this as strict JSON
366
+ // Schema, so `type: 'json'` (a DSL-only spelling) is rejected. Every
367
+ // branch this tool returns is an object, so an open object is exact.
368
+ schema: { type: 'object', additionalProperties: true },
369
+ render(_args, value) {
370
+ return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
371
+ },
372
+ },
373
+ async execute(args) {
374
+ const ids = Array.isArray(args?.sessionIds) ? args.sessionIds : []
375
+ if (typeof currentSessionId === 'string' && ids.includes(currentSessionId)) {
376
+ return {
377
+ ok: false,
378
+ error: 'refused: that is the session running this tool call; deleting it would destroy the active conversation',
379
+ }
380
+ }
381
+
382
+ if (args?.dryRun !== false) {
383
+ const preview = await service.preview(ids)
384
+ return {
385
+ ok: true,
386
+ dryRun: true,
387
+ ...preview,
388
+ message: `Would delete ${preview.targetCount} session(s) and free ${preview.humanBytes}. Re-run with dryRun: false to confirm.`,
389
+ }
390
+ }
391
+
392
+ const result = await service.delete(ids, { guard: currentSessionId })
393
+ return {
394
+ ...result,
395
+ ...(result.ok === true
396
+ ? { message: `Deleted ${result.summary?.sessionsDeleted ?? 0} session(s), freed ${result.humanBytes}.` }
397
+ : {}),
398
+ }
399
+ },
400
+ }
401
+ }
402
+
403
+ export function apply(ctx) {
404
+ const service = createService(ctx)
405
+
406
+ // Public Cordis service plus its Remote binding, so the Web UI reaches the
407
+ // same deletion authority the Tool uses. The typert contribution itself is
408
+ // loaded by dsh-typert-loader from this package's `./typert` export; the
409
+ // consumer-side descriptors are mounted by the Web client — the Host half
410
+ // never mounts them itself.
411
+ service.typertRemote = Object.freeze({ service, serviceKey: name, namespace: name })
412
+ ctx.provide(name, service)
413
+
414
+ // Dynamic — NOT part of `inject` — so a composition without the workspace
415
+ // registry still activates this plugin and keeps the file-based fallback.
416
+ // While the service is up it is the only writer of workspace.json during
417
+ // deletions (see createService). The disposable is kept for symmetry with
418
+ // Cordis' effect semantics but the plugin lives as long as the host.
419
+ try {
420
+ if (typeof ctx.inject === 'function') {
421
+ ctx.inject(['workspaceRegistry'], (scope) => {
422
+ service.bindRegistry(scope?.workspaceRegistry)
423
+ return () => { service.bindRegistry(undefined) }
424
+ })
425
+ }
426
+ } catch { /* the composition publishes no workspace registry */ }
427
+
428
+ ctx.effect(() => {
429
+ const holder = ctx.agents?.currentInitiator?.()
430
+ const currentSessionId = typeof holder?.id === 'string' ? holder.id : undefined
431
+ const dispose = ctx.tools.register(deletionTool(service, currentSessionId))
432
+ return () => dispose()
433
+ }, 'session-cleaner.tool')
434
+ }
435
+
436
+ // Re-exported so a host (and the tests) can compose the core directly.
437
+ 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