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.
- package/README.md +209 -2
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1845 -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 +712 -0
- package/src/contract.cjs +96 -0
- package/src/index.js +437 -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,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
|