@l1vide/dsh-session-delete 0.0.0-stage → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, relative, sep } from "node:path";
3
+
4
+ /** Directories whose contents are build/tool scaffolding, never a deliverable. */
5
+ const SCRATCH_SEGMENTS = new Set([
6
+ "node_modules",
7
+ ".venv",
8
+ "venv",
9
+ "__pycache__",
10
+ "dist",
11
+ "build",
12
+ "out",
13
+ "target",
14
+ "coverage",
15
+ ".cache",
16
+ ".tmp",
17
+ "tmp",
18
+ "temp",
19
+ ".next",
20
+ ".nuxt",
21
+ ".turbo",
22
+ ".parcel-cache"
23
+ ]);
24
+
25
+ /** Helper sources a session writes while working but nobody asks to keep. */
26
+ const SCRIPT_EXTENSIONS = new Set([
27
+ ".js",
28
+ ".mjs",
29
+ ".cjs",
30
+ ".jsx",
31
+ ".ts",
32
+ ".tsx",
33
+ ".py",
34
+ ".pyw",
35
+ ".ps1",
36
+ ".psm1",
37
+ ".sh",
38
+ ".bash",
39
+ ".zsh",
40
+ ".bat",
41
+ ".cmd",
42
+ ".vbs",
43
+ ".ahk",
44
+ ".rb",
45
+ ".pl",
46
+ ".php",
47
+ ".lua",
48
+ ".go",
49
+ ".rs",
50
+ ".c",
51
+ ".cc",
52
+ ".cpp",
53
+ ".h",
54
+ ".hpp",
55
+ ".java",
56
+ ".kt",
57
+ ".groovy",
58
+ ".cmake",
59
+ ".mk"
60
+ ]);
61
+
62
+ /** Things a session downloads or builds to get the job done. */
63
+ const ARTIFACT_EXTENSIONS = new Set([
64
+ ".exe",
65
+ ".msi",
66
+ ".dll",
67
+ ".so",
68
+ ".dylib",
69
+ ".bin",
70
+ ".iso",
71
+ ".img",
72
+ ".apk",
73
+ ".deb",
74
+ ".rpm",
75
+ ".jar",
76
+ ".whl",
77
+ ".zip",
78
+ ".7z",
79
+ ".rar",
80
+ ".tar",
81
+ ".gz",
82
+ ".tgz",
83
+ ".xz",
84
+ ".bz2",
85
+ ".zst",
86
+ ".part",
87
+ ".crdownload",
88
+ ".torrent",
89
+ ".pdb",
90
+ ".o",
91
+ ".obj",
92
+ ".class",
93
+ ".pyc"
94
+ ]);
95
+
96
+ /** Scratch output: logs, backups, editor droppings. */
97
+ const TEMP_EXTENSIONS = new Set([
98
+ ".log",
99
+ ".bak",
100
+ ".tmp",
101
+ ".temp",
102
+ ".cache",
103
+ ".lock",
104
+ ".pid",
105
+ ".map",
106
+ ".out",
107
+ ".err",
108
+ ".orig",
109
+ ".rej",
110
+ ".swp",
111
+ ".dump"
112
+ ]);
113
+
114
+ /** Documents and media: the kind of file a session exists to produce. */
115
+ const DOCUMENT_EXTENSIONS = new Set([
116
+ ".docx",
117
+ ".doc",
118
+ ".xlsx",
119
+ ".xls",
120
+ ".pptx",
121
+ ".ppt",
122
+ ".pdf",
123
+ ".md",
124
+ ".txt",
125
+ ".csv",
126
+ ".tsv",
127
+ ".rtf",
128
+ ".odt",
129
+ ".ods",
130
+ ".odp",
131
+ ".epub",
132
+ ".png",
133
+ ".jpg",
134
+ ".jpeg",
135
+ ".gif",
136
+ ".webp",
137
+ ".bmp",
138
+ ".svg",
139
+ ".ico",
140
+ ".mp4",
141
+ ".mov",
142
+ ".avi",
143
+ ".mkv",
144
+ ".mp3",
145
+ ".wav",
146
+ ".m4a",
147
+ ".flac",
148
+ ".json",
149
+ ".yml",
150
+ ".yaml",
151
+ ".toml",
152
+ ".xml",
153
+ ".html",
154
+ ".css"
155
+ ]);
156
+
157
+ function extensionOf(path) {
158
+ const base = path.slice(path.lastIndexOf("/") + 1);
159
+ const dot = base.lastIndexOf(".");
160
+ return dot <= 0 ? "" : base.slice(dot).toLowerCase();
161
+ }
162
+
163
+ function isInsideGitTree(cwd, absolute) {
164
+ let current = dirname(absolute);
165
+ const stop = cwd;
166
+ for (let depth = 0; depth < 24; depth += 1) {
167
+ if (existsSync(current + sep + ".git")) return true;
168
+ if (current === stop) break;
169
+ const parent = dirname(current);
170
+ if (parent === current) break;
171
+ if (relative(cwd, parent).startsWith("..")) break;
172
+ current = parent;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ /** Paths the session handed to the user as a deliverable (`present`). */
178
+ export function collectPresented(events) {
179
+ const presented = new Set();
180
+ for (const event of events) {
181
+ if (event.type !== "tool/call") continue;
182
+ const data = event.data;
183
+ if (!data || typeof data.name !== "string") continue;
184
+ if (!/present/i.test(data.name)) continue;
185
+ let args = null;
186
+ try {
187
+ args = typeof data.arguments === "string" ? JSON.parse(data.arguments) : data.arguments;
188
+ } catch {
189
+ continue;
190
+ }
191
+ const files = Array.isArray(args?.files) ? args.files : [];
192
+ for (const entry of files) {
193
+ if (typeof entry === "string") presented.add(entry);
194
+ else if (typeof entry?.path === "string") presented.add(entry.path);
195
+ }
196
+ }
197
+ return presented;
198
+ }
199
+
200
+ /** Text of the last few assistant replies, used to spot files it called results. */
201
+ export function collectFinalText(events) {
202
+ const replies = [];
203
+ for (let index = events.length - 1; index >= 0 && replies.length < 3; index -= 1) {
204
+ const event = events[index];
205
+ if (event.type !== "assistant/message") continue;
206
+ const content = event.data?.message?.content;
207
+ if (!Array.isArray(content)) continue;
208
+ const text = content
209
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
210
+ .map((part) => part.text)
211
+ .join("\n");
212
+ if (text.trim() !== "") replies.push(text);
213
+ }
214
+ return replies.join("\n");
215
+ }
216
+
217
+ /**
218
+ * Split the files a session touched into deliverables to keep and scaffolding
219
+ * to clean up.
220
+ *
221
+ * Order matters: an explicit hand-off, a version-controlled tree, or a mutated
222
+ * existing file always wins over the extension heuristics, because losing one
223
+ * of those is unrecoverable while leaving a stray script behind is not.
224
+ *
225
+ * @returns the same items, each with `role` (`keep` | `residue`) and `reason`.
226
+ */
227
+ export function classifyTouchedFiles(items, events, cwd) {
228
+ const presented = collectPresented(events);
229
+ const finalText = collectFinalText(events);
230
+ const presentedAbsolute = new Set();
231
+ const referencedAbsolute = new Set();
232
+
233
+ for (const raw of presented) {
234
+ presentedAbsolute.add(raw.replace(/\\/g, "/"));
235
+ presentedAbsolute.add(raw);
236
+ }
237
+
238
+ return items.map((item) => {
239
+ const normalized = item.path;
240
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
241
+ const ext = extensionOf(normalized);
242
+ const segments = normalized.split("/");
243
+ const keep = (reason) => ({ ...item, role: "keep", reason });
244
+ const drop = (reason) => ({ ...item, role: "residue", reason });
245
+
246
+ if (item.kind === "modified") return keep("edited");
247
+ if (
248
+ presentedAbsolute.has(normalized) ||
249
+ presentedAbsolute.has(item.absolute) ||
250
+ presentedAbsolute.has(item.absolute.replace(/\\/g, "/"))
251
+ ) {
252
+ return keep("presented");
253
+ }
254
+ if (finalText !== "" && (finalText.includes(base) || finalText.includes(normalized))) {
255
+ referencedAbsolute.add(normalized);
256
+ return keep("referenced");
257
+ }
258
+ if (isInsideGitTree(cwd, item.absolute)) return keep("versioned");
259
+ if (segments.some((segment) => SCRATCH_SEGMENTS.has(segment))) return drop("scratch-dir");
260
+ if (base.startsWith(".")) return drop("hidden");
261
+ if (SCRIPT_EXTENSIONS.has(ext)) return drop("script");
262
+ if (ARTIFACT_EXTENSIONS.has(ext)) return drop("artifact");
263
+ if (TEMP_EXTENSIONS.has(ext)) return drop("temp");
264
+ if (DOCUMENT_EXTENSIONS.has(ext)) return keep("document");
265
+ return keep("unknown");
266
+ });
267
+ }
package/lib/index.js ADDED
@@ -0,0 +1,367 @@
1
+ import { rm, rmdir, stat, readdir } from "node:fs/promises";
2
+ import { dirname, sep } from "node:path";
3
+ import { collectTouchedFiles, readSessionEvents } from "./transcript.js";
4
+ import { classifyTouchedFiles } from "./classify.js";
5
+
6
+ export const name = "dsh-session-delete";
7
+ export const inject = ["webServer"];
8
+
9
+ const DELETE_PATH = "/dsh-session-delete/delete";
10
+ const INSPECT_PATH = "/dsh-session-delete/inspect";
11
+ const MAX_BODY_BYTES = 256 * 1024;
12
+
13
+ export function apply(ctx) {
14
+ ctx.inject(["webServer"], (host) => {
15
+ host.effect(() => {
16
+ const disposers = [];
17
+ const track = (disposer) => {
18
+ if (typeof disposer === "function") disposers.push(disposer);
19
+ return disposer;
20
+ };
21
+ track(
22
+ host.webServer.register(
23
+ {
24
+ kind: "exact",
25
+ path: INSPECT_PATH,
26
+ handler: (request, response) => handleInspect(ctx, request, response)
27
+ },
28
+ "dsh-session-delete: inspect route"
29
+ )
30
+ );
31
+ track(
32
+ host.webServer.register(
33
+ {
34
+ kind: "exact",
35
+ path: DELETE_PATH,
36
+ handler: (request, response) => handleDelete(ctx, request, response)
37
+ },
38
+ "dsh-session-delete: delete route"
39
+ )
40
+ );
41
+ return () => {
42
+ for (const disposer of disposers.reverse()) {
43
+ try {
44
+ disposer();
45
+ } catch {}
46
+ }
47
+ };
48
+ }, "dsh-session-delete: routes");
49
+ });
50
+ }
51
+
52
+ const plugin = { name, inject, apply };
53
+ export default plugin;
54
+
55
+ /** Read the session id out of a POST body, or answer 400 and return null. */
56
+ async function readSessionId(request, response) {
57
+ if (!allowPluginRequest(request, response)) return null;
58
+ if (request.method !== "POST") {
59
+ response.writeHead(405, { allow: "POST" });
60
+ response.end();
61
+ return null;
62
+ }
63
+ let body;
64
+ try {
65
+ body = await readJsonBody(request, MAX_BODY_BYTES);
66
+ } catch {
67
+ sendJson(response, 400, { ok: false, error: "invalid request body" });
68
+ return null;
69
+ }
70
+ const sessionId = typeof body?.sessionId === "string" ? body.sessionId.trim() : "";
71
+ if (sessionId === "") {
72
+ sendJson(response, 400, { ok: false, error: "sessionId is required" });
73
+ return null;
74
+ }
75
+ return { sessionId, body };
76
+ }
77
+
78
+ /** Dry run: what a delete would clean up, and what it would leave alone. */
79
+ async function handleInspect(ctx, request, response) {
80
+ const input = await readSessionId(request, response);
81
+ if (input === null) return;
82
+ try {
83
+ const plan = await planSession(ctx, input.sessionId);
84
+ if (plan === null) {
85
+ sendJson(response, 200, { ok: true, cwd: null, residue: [], keep: [], skipped: 0 });
86
+ return;
87
+ }
88
+ sendJson(response, 200, {
89
+ ok: true,
90
+ cwd: plan.cwd,
91
+ residue: plan.items
92
+ .filter((item) => item.role === "residue")
93
+ .map((item) => ({ path: item.path, reason: item.reason })),
94
+ keep: plan.items
95
+ .filter((item) => item.role === "keep")
96
+ .map((item) => ({ path: item.path, reason: item.reason })),
97
+ skipped: plan.skipped
98
+ });
99
+ } catch (error) {
100
+ sendJson(response, 500, {
101
+ ok: false,
102
+ error: error instanceof Error ? error.message : String(error)
103
+ });
104
+ }
105
+ }
106
+
107
+ async function handleDelete(ctx, request, response) {
108
+ const input = await readSessionId(request, response);
109
+ if (input === null) return;
110
+ const clean = input.body?.clean !== false;
111
+ try {
112
+ const result = await deleteSession(ctx, input.sessionId, clean);
113
+ sendJson(response, 200, { ok: true, ...result });
114
+ } catch (error) {
115
+ sendJson(response, 500, {
116
+ ok: false,
117
+ error: error instanceof Error ? error.message : String(error)
118
+ });
119
+ }
120
+ }
121
+
122
+ /** Resolve a session header from the live store first, then from disk. */
123
+ async function findHeader(ctx, sessionId) {
124
+ const sessions = service(ctx, "sessions");
125
+ const persistence = service(ctx, "sessionPersistence");
126
+ try {
127
+ const live = sessions?.get(sessionId)?.header;
128
+ if (live) return live;
129
+ } catch {}
130
+ if (persistence === void 0) return null;
131
+ try {
132
+ const snapshots = await persistence.list();
133
+ return snapshots.find((snapshot) => snapshot?.header?.id === sessionId)?.header ?? null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Classify the workspace files a session touched into scaffolding to clean up
141
+ * and deliverables to keep. Read-only: nothing is removed here.
142
+ */
143
+ async function planSession(ctx, sessionId) {
144
+ const persistence = service(ctx, "sessionPersistence");
145
+ if (persistence === void 0) return null;
146
+ const header = await findHeader(ctx, sessionId);
147
+ if (header === null) return null;
148
+ const cwd = typeof header.cwd === "string" && header.cwd !== "" ? header.cwd : null;
149
+ if (cwd === null) return null;
150
+ const located = persistence.locate({ cwd: header.cwd, id: header.id });
151
+ if (!located || typeof located.path !== "string" || located.path === "") return null;
152
+
153
+ let events;
154
+ try {
155
+ events = await readSessionEvents(located.path);
156
+ } catch {
157
+ return null;
158
+ }
159
+
160
+ const collected = collectTouchedFiles(events, cwd);
161
+ const items = classifyTouchedFiles(collected.items, events, cwd);
162
+ return { cwd, items, skipped: collected.skipped };
163
+ }
164
+
165
+ /**
166
+ * Remove one session end to end: clean up the scaffolding it left in the
167
+ * workspace, stop its live agent, delete its stored transcript, drop it from
168
+ * every workspace and from the archived/pinned sets, then notify clients.
169
+ */
170
+ async function deleteSession(ctx, sessionId, clean) {
171
+ const sessions = service(ctx, "sessions");
172
+ const agents = service(ctx, "agents");
173
+
174
+ const header = await findHeader(ctx, sessionId);
175
+ const live = header !== null && sessions?.get(sessionId) !== void 0;
176
+
177
+ // Workspace cleanup comes first: the transcript is the only record of what
178
+ // the session wrote, so it has to be read before the transcript is deleted.
179
+ const workspace = { removed: [], kept: [], failed: [], cwd: null };
180
+ if (clean) {
181
+ try {
182
+ const plan = await planSession(ctx, sessionId);
183
+ if (plan !== null) {
184
+ workspace.cwd = plan.cwd;
185
+ workspace.kept = plan.items
186
+ .filter((item) => item.role === "keep")
187
+ .map((item) => item.path);
188
+ const outcome = await removeFiles(plan.cwd, plan.items.filter((item) => item.role === "residue"));
189
+ workspace.removed = outcome.removed;
190
+ workspace.failed = outcome.failed;
191
+ }
192
+ } catch (error) {
193
+ console.warn("[dsh-session-delete] workspace cleanup failed", error);
194
+ }
195
+ }
196
+
197
+ const agent = agents?.get(sessionId);
198
+ if (agent) {
199
+ try {
200
+ agent.cancel({ kind: "user" });
201
+ } catch {}
202
+ try {
203
+ await agent.scope?.dispose?.();
204
+ } catch {}
205
+ }
206
+
207
+ let removedFiles = false;
208
+ const persistence = service(ctx, "sessionPersistence");
209
+ if (header !== null && persistence !== void 0) {
210
+ try {
211
+ const located = persistence.locate({ cwd: header.cwd, id: header.id });
212
+ if (located && typeof located.path === "string" && located.path !== "") {
213
+ await rm(dirname(located.path), { recursive: true, force: true });
214
+ removedFiles = true;
215
+ }
216
+ } catch (error) {
217
+ console.warn("[dsh-session-delete] failed to remove session files", error);
218
+ }
219
+ }
220
+
221
+ const cacheRows = await dropProjectionCache(ctx, sessionId);
222
+
223
+ const registry = service(ctx, "workspaceRegistry");
224
+ if (registry) {
225
+ try {
226
+ for (const entity of registry.list()) {
227
+ try {
228
+ await entity.detachSession(sessionId);
229
+ } catch {}
230
+ }
231
+ } catch {}
232
+ try {
233
+ await registry.unarchiveSession(sessionId);
234
+ } catch {}
235
+ try {
236
+ await registry.unpinSession(sessionId);
237
+ } catch {}
238
+ }
239
+
240
+ try {
241
+ ctx.emit("api-session/removed", sessionId);
242
+ } catch {}
243
+
244
+ return { sessionId, live, removedFiles, cacheRows, headerFound: header !== null, workspace };
245
+ }
246
+
247
+ /**
248
+ * Delete the given files, then prune the directories that removal left empty
249
+ * (never above the session's cwd).
250
+ */
251
+ export async function removeFiles(cwd, items) {
252
+ if (cwd === null || items.length === 0) return { removed: [], failed: [] };
253
+ const removed = [];
254
+ const failed = [];
255
+ const parents = new Set();
256
+
257
+ for (const item of items) {
258
+ try {
259
+ const info = await stat(item.absolute);
260
+ if (!info.isFile()) continue;
261
+ await rm(item.absolute, { force: true });
262
+ removed.push(item.path);
263
+ parents.add(dirname(item.absolute));
264
+ } catch {
265
+ failed.push(item.path);
266
+ }
267
+ }
268
+
269
+ for (const start of parents) {
270
+ let current = start;
271
+ while (current !== cwd && current.startsWith(cwd + sep)) {
272
+ try {
273
+ if ((await readdir(current)).length > 0) break;
274
+ await rmdir(current);
275
+ } catch {
276
+ break;
277
+ }
278
+ current = dirname(current);
279
+ }
280
+ }
281
+
282
+ return { removed, failed };
283
+ }
284
+
285
+ /**
286
+ * Drop the session's projection-cache row so no projected message text is left
287
+ * behind in `storages/session_projcache`.
288
+ */
289
+ async function dropProjectionCache(ctx, sessionId) {
290
+ const facility = service(ctx, "storageDomain");
291
+ if (!facility) return 0;
292
+ try {
293
+ const domain = facility.get("session_projcache");
294
+ if (!domain) return 0;
295
+ const removed = await domain.table("sessions").delete(sessionId);
296
+ return removed ? 1 : 0;
297
+ } catch (error) {
298
+ console.warn("[dsh-session-delete] failed to drop projection cache row", error);
299
+ return 0;
300
+ }
301
+ }
302
+
303
+ function service(ctx, key) {
304
+ try {
305
+ return ctx.get(key, false);
306
+ } catch {
307
+ return void 0;
308
+ }
309
+ }
310
+
311
+ function headerOf(request, name) {
312
+ const headers = request?.headers;
313
+ if (!headers) return "";
314
+ const value = headers[name] ?? headers[String(name).toLowerCase()];
315
+ if (Array.isArray(value)) return String(value[0] ?? "");
316
+ return String(value ?? "");
317
+ }
318
+
319
+ function sendJson(response, status, payload) {
320
+ response.writeHead(status, {
321
+ "cache-control": "no-store",
322
+ "content-type": "application/json; charset=utf-8"
323
+ });
324
+ response.end(JSON.stringify(payload));
325
+ }
326
+
327
+ function denyCrossOrigin(response) {
328
+ sendJson(response, 403, { ok: false, error: "cross-origin blocked" });
329
+ }
330
+
331
+ function allowPluginRequest(request, response) {
332
+ const site = headerOf(request, "sec-fetch-site");
333
+ if (site === "cross-site") {
334
+ denyCrossOrigin(response);
335
+ return false;
336
+ }
337
+ const origin = headerOf(request, "origin");
338
+ if (origin === "null") {
339
+ denyCrossOrigin(response);
340
+ return false;
341
+ }
342
+ if (origin === "") return true;
343
+ let parsed;
344
+ try {
345
+ parsed = new URL(origin);
346
+ } catch {
347
+ denyCrossOrigin(response);
348
+ return false;
349
+ }
350
+ if (parsed.host !== headerOf(request, "host")) {
351
+ denyCrossOrigin(response);
352
+ return false;
353
+ }
354
+ return true;
355
+ }
356
+
357
+ async function readJsonBody(request, maxBytes) {
358
+ const chunks = [];
359
+ let size = 0;
360
+ for await (const chunk of request) {
361
+ size += chunk.length;
362
+ if (size > maxBytes) throw new Error("request body too large");
363
+ chunks.push(chunk);
364
+ }
365
+ if (size === 0) return {};
366
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
367
+ }