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.
- package/README.md +194 -2
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1675 -0
- package/lib/index.js +2 -0
- package/lib/remote.js +3 -0
- package/lib/typert.host.js +3 -0
- package/locale/en.json +13 -0
- package/locale/zh.json +13 -0
- package/package.json +44 -4
- package/src/cleaner.js +676 -0
- package/src/contract.cjs +96 -0
- package/src/index.js +340 -0
- package/src/remote.js +12 -0
- package/src/typert.host.js +38 -0
package/src/contract.cjs
ADDED
|
@@ -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
|