@murchey/dsh-session-manager 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +190 -3
- package/client.js +877 -0
- package/cordis.patch.yml +11 -0
- package/icon.svg +16 -0
- package/index.js +407 -0
- package/lib/core.js +1074 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +65 -4
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Session Shredder — DSH bundle patch
|
|
2
|
+
#
|
|
3
|
+
# Inserts the Host plugin that registers the `session_purge` tool. The empty
|
|
4
|
+
# config selects the safe defaults: approval required, normal (non-overwrite)
|
|
5
|
+
# deletion, attachment cleanup enabled, and the DSH home discovered from
|
|
6
|
+
# $DSH_HOME (falling back to ~/.dsh).
|
|
7
|
+
|
|
8
|
+
- insert:
|
|
9
|
+
- id: session-shredder
|
|
10
|
+
name: '@murchey/dsh-session-manager'
|
|
11
|
+
config: {}
|
package/icon.svg
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Session Shredder">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#6366f1"/>
|
|
5
|
+
<stop offset="1" stop-color="#4338ca"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="2" y="2" width="60" height="60" rx="14" fill="#eef2ff"/>
|
|
9
|
+
<path d="M22 8h20a3 3 0 0 1 3 3v13H19V11a3 3 0 0 1 3-3z" fill="#ffffff" stroke="#c7d2fe" stroke-width="2"/>
|
|
10
|
+
<path d="M25 13h8M25 18h14" stroke="#a5b4fc" stroke-width="2" stroke-linecap="round"/>
|
|
11
|
+
<rect x="10" y="24" width="44" height="14" rx="4" fill="url(#body)"/>
|
|
12
|
+
<circle cx="20" cy="31" r="2.5" fill="#c7d2fe"/>
|
|
13
|
+
<circle cx="44" cy="31" r="2.5" fill="#c7d2fe"/>
|
|
14
|
+
<path d="M18 38v14M26 38v10M34 38v14M42 38v10M50 38v14" stroke="#818cf8" stroke-width="3" stroke-linecap="round"/>
|
|
15
|
+
<path d="M14 56h36" stroke="#4338ca" stroke-width="3" stroke-linecap="round"/>
|
|
16
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Shredder — permanently remove DSH session data from disk.
|
|
3
|
+
*
|
|
4
|
+
* The Host owns the deletion logic (`lib/core.js`); this module registers one
|
|
5
|
+
* model-facing tool and enforces the two safety gates that cannot live in the
|
|
6
|
+
* filesystem layer:
|
|
7
|
+
*
|
|
8
|
+
* 1. only a top-level (non-subagent) session may purge;
|
|
9
|
+
* 2. every real deletion asks the composed approval answerers first.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
PurgeError,
|
|
13
|
+
describePlan,
|
|
14
|
+
formatBytes,
|
|
15
|
+
listSessions,
|
|
16
|
+
planPurge,
|
|
17
|
+
purgeSessions,
|
|
18
|
+
resolveOptions,
|
|
19
|
+
} from './lib/core.js';
|
|
20
|
+
|
|
21
|
+
/** Cordis activation requires the tool, persistence, and live-agent services. */
|
|
22
|
+
export const inject = ['tools', 'sessionPersistence', 'agents'];
|
|
23
|
+
|
|
24
|
+
/** Stable plugin row name. */
|
|
25
|
+
export const name = 'session-shredder';
|
|
26
|
+
|
|
27
|
+
const ACTIONS = ['list', 'plan', 'purge'];
|
|
28
|
+
|
|
29
|
+
const TOOL_DESCRIPTION = [
|
|
30
|
+
'Permanently delete DSH session data from disk: the append-only session log plus every historical format generation, the projection-cache record, the legacy cache-seed entry, and attachments that no surviving session references.',
|
|
31
|
+
'This is irreversible. Use it only when the user explicitly asks to permanently delete sessions. Never use it for "archive", "hide", cleanup suggestions, or the current session.',
|
|
32
|
+
'Workflow: call action:"list" to discover ids, call action:"plan" for the exact footprint and blockers, show that plan to the user, and call action:"purge" with confirm:true only after the user agrees.',
|
|
33
|
+
'A purge refuses the current session and any live session, and asks the user for approval before deleting.',
|
|
34
|
+
].join(' ');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register the tool on the plugin context.
|
|
38
|
+
*
|
|
39
|
+
* @param {any} ctx
|
|
40
|
+
* @param {Record<string, unknown>} [config]
|
|
41
|
+
*/
|
|
42
|
+
export function apply(ctx, config = {}) {
|
|
43
|
+
const options = resolveOptions(config);
|
|
44
|
+
const tool = {
|
|
45
|
+
name: 'session_purge',
|
|
46
|
+
description: TOOL_DESCRIPTION,
|
|
47
|
+
parameters: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
required: ['action'],
|
|
51
|
+
properties: {
|
|
52
|
+
action: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
enum: ACTIONS,
|
|
55
|
+
description: 'list stored sessions, plan the exact footprint of given ids, or permanently delete them.',
|
|
56
|
+
},
|
|
57
|
+
sessionIds: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
items: { type: 'string' },
|
|
60
|
+
description: 'Target session ids for plan/purge. Required for plan and purge.',
|
|
61
|
+
},
|
|
62
|
+
confirm: {
|
|
63
|
+
type: 'boolean',
|
|
64
|
+
description: 'Must be true for purge. Set only after the user has seen the plan and explicitly agreed.',
|
|
65
|
+
},
|
|
66
|
+
purgeAttachments: {
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
description: 'Override the deployment default for attachment removal (only objects no surviving session references).',
|
|
69
|
+
},
|
|
70
|
+
limit: {
|
|
71
|
+
type: 'integer',
|
|
72
|
+
description: 'Maximum rows for action:"list" (1-500). Defaults to the plugin config.',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
output: {
|
|
77
|
+
schema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
required: ['ok', 'summary'],
|
|
81
|
+
properties: {
|
|
82
|
+
ok: { type: 'boolean' },
|
|
83
|
+
summary: { type: 'string' },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
render(_args, value) {
|
|
87
|
+
return [{ type: 'text', text: value.summary }];
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
timeoutMs: 600_000,
|
|
91
|
+
async execute(args, exec) {
|
|
92
|
+
const agent = exec.agent;
|
|
93
|
+
if (agent === undefined) throw new PurgeError('session_purge must run inside an agent turn', 'NO_AGENT');
|
|
94
|
+
assertTopLevel(ctx, agent);
|
|
95
|
+
|
|
96
|
+
switch (args.action) {
|
|
97
|
+
case 'list':
|
|
98
|
+
return listAction(ctx, options, args.limit, agent.id);
|
|
99
|
+
case 'plan':
|
|
100
|
+
return planAction(ctx, options, args.sessionIds, agent.id);
|
|
101
|
+
case 'purge':
|
|
102
|
+
return purgeAction(ctx, options, args, exec, agent.id);
|
|
103
|
+
default:
|
|
104
|
+
throw new PurgeError(`unknown action: ${JSON.stringify(args.action)}`, 'INVALID_ACTION');
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
ctx.effect(() => ctx.tools.register(tool));
|
|
109
|
+
registerHttpRoutes(ctx, options);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The browser page reads and deletes through two authenticated `/api` Fetch
|
|
114
|
+
* routes. They live inside Connection's Host/Origin fence, so the browser
|
|
115
|
+
* session is the only caller; deletion requires `confirm:true` and the UI's
|
|
116
|
+
* own confirmation dialog is the human gate.
|
|
117
|
+
*/
|
|
118
|
+
function registerHttpRoutes(ctx, options) {
|
|
119
|
+
ctx.inject(['connection'], (connectionCtx) => {
|
|
120
|
+
const connection = connectionCtx.connection;
|
|
121
|
+
if (connection === undefined || typeof connection.fetch?.register !== 'function') return;
|
|
122
|
+
const routes = [
|
|
123
|
+
{
|
|
124
|
+
path: '/api/session-shredder/sessions',
|
|
125
|
+
methods: ['GET'],
|
|
126
|
+
requestBody: 'buffered',
|
|
127
|
+
fetch: async () => {
|
|
128
|
+
try {
|
|
129
|
+
const listing = await listSessions(ctx, options, { limit: 500 });
|
|
130
|
+
return jsonResponse(200, {
|
|
131
|
+
sessions: listing.shown.map((session) => ({
|
|
132
|
+
id: session.id,
|
|
133
|
+
title: session.title ?? null,
|
|
134
|
+
cwd: session.cwd ?? null,
|
|
135
|
+
createdAt: session.createdAt ?? null,
|
|
136
|
+
bytes: session.bytes,
|
|
137
|
+
eventCount: session.eventCount ?? null,
|
|
138
|
+
live: session.live,
|
|
139
|
+
archived: session.archived === true,
|
|
140
|
+
orphan: session.orphan,
|
|
141
|
+
subagent: !session.id.startsWith('session-'),
|
|
142
|
+
})),
|
|
143
|
+
total: listing.total,
|
|
144
|
+
totalBytes: listing.totalBytes,
|
|
145
|
+
hidden: listing.hidden,
|
|
146
|
+
root: listing.sessionsRoot,
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return jsonResponse(500, { error: errorMessage(error) });
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
path: '/api/session-shredder/delete',
|
|
155
|
+
methods: ['POST'],
|
|
156
|
+
requestBody: 'buffered',
|
|
157
|
+
fetch: async (request) => {
|
|
158
|
+
let body;
|
|
159
|
+
try {
|
|
160
|
+
body = await request.json();
|
|
161
|
+
} catch {
|
|
162
|
+
return jsonResponse(400, { error: 'request body must be JSON' });
|
|
163
|
+
}
|
|
164
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
165
|
+
return jsonResponse(400, { error: 'request body must be a JSON object' });
|
|
166
|
+
}
|
|
167
|
+
if (body.confirm !== true) return jsonResponse(400, { error: 'confirm:true is required' });
|
|
168
|
+
if (!Array.isArray(body.sessionIds) || body.sessionIds.length === 0) {
|
|
169
|
+
return jsonResponse(400, { error: 'sessionIds must be a non-empty array' });
|
|
170
|
+
}
|
|
171
|
+
if (body.sessionIds.some((id) => typeof id !== 'string')) {
|
|
172
|
+
return jsonResponse(400, { error: 'sessionIds must contain strings only' });
|
|
173
|
+
}
|
|
174
|
+
const ids = [...new Set(body.sessionIds)];
|
|
175
|
+
if (ids.length > options.maxTargets) {
|
|
176
|
+
return jsonResponse(400, { error: `at most ${options.maxTargets} sessions may be deleted per request` });
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const result = await purgeSessions(ctx, options, ids, {
|
|
180
|
+
purgeAttachments: body.purgeAttachments !== false,
|
|
181
|
+
});
|
|
182
|
+
const ok = result.verified && result.targets.every((target) => target.errors.length === 0);
|
|
183
|
+
return jsonResponse(200, {
|
|
184
|
+
ok,
|
|
185
|
+
verified: result.verified,
|
|
186
|
+
deletedFiles: result.deletedFiles,
|
|
187
|
+
deletedBytes: result.deletedBytes,
|
|
188
|
+
attachments: result.attachments,
|
|
189
|
+
requestImageCache: result.requestImageCache,
|
|
190
|
+
targets: result.targets.map((target) => ({
|
|
191
|
+
id: target.id,
|
|
192
|
+
bytes: target.bytes,
|
|
193
|
+
files: target.files,
|
|
194
|
+
errors: target.errors,
|
|
195
|
+
})),
|
|
196
|
+
warnings: result.warnings,
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const status = error instanceof PurgeError ? 409 : 500;
|
|
200
|
+
return jsonResponse(status, { error: errorMessage(error), code: error?.code });
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
for (const route of routes) {
|
|
206
|
+
connectionCtx.effect(() => {
|
|
207
|
+
const dispose = connection.fetch.register(route);
|
|
208
|
+
return () => {
|
|
209
|
+
void dispose();
|
|
210
|
+
};
|
|
211
|
+
}, `session-shredder: ${route.path}`);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function jsonResponse(status, value) {
|
|
217
|
+
return new Response(JSON.stringify(value), {
|
|
218
|
+
status,
|
|
219
|
+
headers: {
|
|
220
|
+
'content-type': 'application/json; charset=utf-8',
|
|
221
|
+
'cache-control': 'no-store',
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function errorMessage(error) {
|
|
227
|
+
return error instanceof Error ? error.message : String(error);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A subagent must not permanently delete user data; only a top-level session
|
|
232
|
+
* owns that authority.
|
|
233
|
+
*/
|
|
234
|
+
function assertTopLevel(ctx, agent) {
|
|
235
|
+
const session = ctx.get('sessions')?.get(agent.id);
|
|
236
|
+
const header = session?.header;
|
|
237
|
+
if (header === undefined) return;
|
|
238
|
+
// A user fork keeps `parentSession` for lineage and is still a top-level
|
|
239
|
+
// chat; only delegation marks a subagent.
|
|
240
|
+
if (header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0) {
|
|
241
|
+
throw new PurgeError(
|
|
242
|
+
'only a top-level session may purge session data; subagent sessions must not delete user data',
|
|
243
|
+
'NOT_TOP_LEVEL',
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function normalizeIds(value) {
|
|
249
|
+
if (!Array.isArray(value)) {
|
|
250
|
+
throw new PurgeError('sessionIds must be an array of session ids for plan and purge', 'INVALID_SESSION_ID');
|
|
251
|
+
}
|
|
252
|
+
if (value.length === 0) throw new PurgeError('sessionIds must name at least one session', 'INVALID_SESSION_ID');
|
|
253
|
+
return [...new Set(value)];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function listAction(ctx, options, limit, currentSessionId) {
|
|
257
|
+
const listing = await listSessions(ctx, options, {
|
|
258
|
+
limit,
|
|
259
|
+
currentSessionId,
|
|
260
|
+
});
|
|
261
|
+
const lines = [
|
|
262
|
+
`Session Shredder — ${listing.total} session${listing.total === 1 ? '' : 's'} under ${listing.sessionsRoot}`,
|
|
263
|
+
];
|
|
264
|
+
if (listing.shown.length === 0) lines.push('No sessions found.');
|
|
265
|
+
for (const session of listing.shown) {
|
|
266
|
+
const flags = [session.current ? 'CURRENT' : '', session.live ? 'LIVE' : '', session.archived ? 'ARCHIVED' : '', session.orphan ? 'ORPHAN (no stored header)' : '']
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
.join(', ');
|
|
269
|
+
lines.push(
|
|
270
|
+
`- ${session.id} ${formatBytes(session.bytes)}${flags ? ` [${flags}]` : ''}` +
|
|
271
|
+
`${session.title !== undefined ? ` "${session.title}"` : ''}` +
|
|
272
|
+
`${session.cwd !== undefined ? ` ${session.cwd}` : ''}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (listing.hidden > 0) lines.push(`... ${listing.hidden} more not shown (raise limit or filter by id).`);
|
|
276
|
+
lines.push('Use action:"plan" with ids before deleting.');
|
|
277
|
+
return { ok: true, summary: lines.join('\n') };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function planAction(ctx, options, sessionIds, currentSessionId) {
|
|
281
|
+
const plan = await planPurge(ctx, options, normalizeIds(sessionIds), { currentSessionId });
|
|
282
|
+
const lines = [
|
|
283
|
+
`Purge plan (dry run) — ${plan.targets.length} session${plan.targets.length === 1 ? '' : 's'}, ${formatBytes(plan.totalBytes)} on disk.`,
|
|
284
|
+
];
|
|
285
|
+
for (const target of plan.targets) {
|
|
286
|
+
lines.push(
|
|
287
|
+
`- ${target.id}${target.title !== undefined ? ` "${target.title}"` : ''} ${formatBytes(target.bytes)}`,
|
|
288
|
+
);
|
|
289
|
+
if (target.dirs.length > 0) {
|
|
290
|
+
for (const dir of target.dirs) lines.push(` log: ${dir}`);
|
|
291
|
+
} else {
|
|
292
|
+
lines.push(' log: none on disk');
|
|
293
|
+
}
|
|
294
|
+
for (const file of target.cacheFiles) lines.push(` cache: ${file}`);
|
|
295
|
+
if (target.blockers.length > 0) {
|
|
296
|
+
for (const reason of target.blockers) lines.push(` BLOCKER: ${reason}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const warning of plan.warnings) lines.push(`warning: ${warning}`);
|
|
300
|
+
lines.push(
|
|
301
|
+
plan.blockers.length > 0
|
|
302
|
+
? 'This plan cannot be purged as-is; resolve the blockers first.'
|
|
303
|
+
: 'No attachments are counted here: purge scans surviving sessions and deletes only unreferenced objects.',
|
|
304
|
+
);
|
|
305
|
+
return { ok: plan.blockers.length === 0, summary: lines.join('\n') };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function purgeAction(ctx, options, args, exec, currentSessionId) {
|
|
309
|
+
if (args.confirm !== true) {
|
|
310
|
+
throw new PurgeError('refusing to purge without confirm:true; show action:"plan" to the user first', 'CONFIRM_REQUIRED');
|
|
311
|
+
}
|
|
312
|
+
const ids = normalizeIds(args.sessionIds);
|
|
313
|
+
const plan = await planPurge(ctx, options, ids, { currentSessionId, signal: exec.signal });
|
|
314
|
+
if (plan.blockers.length > 0) {
|
|
315
|
+
const lines = plan.blockers.map((entry) => `${entry.id}: ${entry.reasons.join('; ')}`);
|
|
316
|
+
throw new PurgeError(`purge refused:\n${lines.join('\n')}`, 'BLOCKED');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await requireApproval(ctx, options, exec, plan);
|
|
320
|
+
|
|
321
|
+
const result = await purgeSessions(ctx, options, ids, {
|
|
322
|
+
currentSessionId,
|
|
323
|
+
signal: exec.signal,
|
|
324
|
+
purgeAttachments: typeof args.purgeAttachments === 'boolean' ? args.purgeAttachments : undefined,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const ok = result.verified && result.targets.every((target) => target.errors.length === 0);
|
|
328
|
+
const lines = [
|
|
329
|
+
`Purged ${result.targets.length} session${result.targets.length === 1 ? '' : 's'}: ` +
|
|
330
|
+
`${formatBytes(result.deletedBytes)} removed in ${result.deletedFiles} file${result.deletedFiles === 1 ? '' : 's'}. ` +
|
|
331
|
+
`Verified: ${result.verified ? 'yes' : 'NO'}.`,
|
|
332
|
+
];
|
|
333
|
+
if (result.attachments.enabled) {
|
|
334
|
+
const attachments = result.attachments;
|
|
335
|
+
lines.push(
|
|
336
|
+
`Attachments: ${attachments.deleted} deleted (${formatBytes(attachments.bytes)}), ` +
|
|
337
|
+
`${attachments.keptShared} kept as shared, ${attachments.protected} kept as recent` +
|
|
338
|
+
`${attachments.skipped !== undefined ? `, skipped: ${attachments.skipped}` : ''}.`,
|
|
339
|
+
);
|
|
340
|
+
} else {
|
|
341
|
+
lines.push('Attachments: left in place (disabled for this call).');
|
|
342
|
+
}
|
|
343
|
+
if (result.requestImageCache?.cleared) {
|
|
344
|
+
lines.push(
|
|
345
|
+
`Request-image cache: cleared (${formatBytes(result.requestImageCache.bytes)}, ${result.requestImageCache.files} file(s)).`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
for (const target of result.targets) {
|
|
349
|
+
lines.push(
|
|
350
|
+
`- ${target.id}: ${formatBytes(target.bytes)}, ${target.files} file${target.files === 1 ? '' : 's'}` +
|
|
351
|
+
`${target.errors.length > 0 ? ` — ${target.errors.length} error(s)` : ''}`,
|
|
352
|
+
);
|
|
353
|
+
for (const error of target.errors) lines.push(` ${error}`);
|
|
354
|
+
}
|
|
355
|
+
for (const warning of result.warnings) lines.push(`warning: ${warning}`);
|
|
356
|
+
if (!ok) lines.push('Some artifacts could not be removed; the tool did not report a clean purge.');
|
|
357
|
+
return { ok, summary: lines.join('\n') };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Ask the user through the composed approval answerers. A missing service or
|
|
362
|
+
* an unavailable answerer fails closed unless `requireApproval` is false.
|
|
363
|
+
*/
|
|
364
|
+
async function requireApproval(ctx, options, exec, plan) {
|
|
365
|
+
const summary = describePlan(plan);
|
|
366
|
+
const approval = ctx.get('approval');
|
|
367
|
+
// A session-level `never` policy is the user's explicit decision to disable
|
|
368
|
+
// prompt-gated actions; respect it instead of failing every purge. The
|
|
369
|
+
// plan + confirm:true + top-level-session checks still gate the call.
|
|
370
|
+
if (approval !== undefined) {
|
|
371
|
+
const session = ctx.get('sessions')?.get(exec.agent.id);
|
|
372
|
+
try {
|
|
373
|
+
if (session !== undefined && approval.overrideOf(session) === 'never') return;
|
|
374
|
+
} catch {
|
|
375
|
+
// Fall through to the configured behavior.
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (!options.requireApproval) return;
|
|
379
|
+
if (approval === undefined) {
|
|
380
|
+
throw new PurgeError(
|
|
381
|
+
'no approval service is composed; set requireApproval:false in the plugin config to allow unattended purges',
|
|
382
|
+
'APPROVAL_UNAVAILABLE',
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
let outcome;
|
|
386
|
+
try {
|
|
387
|
+
outcome = await approval.request({
|
|
388
|
+
agent: exec.agent,
|
|
389
|
+
toolName: 'session_purge',
|
|
390
|
+
callId: exec.callId,
|
|
391
|
+
reason: `Permanently delete ${summary} from disk. This cannot be undone.`,
|
|
392
|
+
displayReason: {
|
|
393
|
+
en: `Permanently delete ${summary} from disk. This cannot be undone.`,
|
|
394
|
+
zh: `将从磁盘彻底删除 ${summary},此操作不可撤销。`,
|
|
395
|
+
},
|
|
396
|
+
signal: exec.signal,
|
|
397
|
+
});
|
|
398
|
+
} catch (error) {
|
|
399
|
+
throw new PurgeError(
|
|
400
|
+
`approval could not be requested (${error?.message ?? String(error)}); nothing was deleted`,
|
|
401
|
+
'APPROVAL_FAILED',
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
if (outcome !== 'allowed-once') {
|
|
405
|
+
throw new PurgeError(`purge not approved (${String(outcome)}); nothing was deleted`, 'NOT_APPROVED');
|
|
406
|
+
}
|
|
407
|
+
}
|