dsh-cool-session 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +24 -2
- package/README.zh.md +25 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +1602 -0
- package/lib/index.js +690 -0
- package/lib/types/client/api.d.ts +30 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/archive.d.ts +43 -0
- package/lib/types/client/archive.d.ts.map +1 -0
- package/lib/types/client/icons.d.ts +32 -0
- package/lib/types/client/icons.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +28 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +143 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/nav-icon.d.ts +30 -0
- package/lib/types/client/nav-icon.d.ts.map +1 -0
- package/lib/types/client/reason.d.ts +24 -0
- package/lib/types/client/reason.d.ts.map +1 -0
- package/lib/types/client/session-menu.d.ts +65 -0
- package/lib/types/client/session-menu.d.ts.map +1 -0
- package/lib/types/client/settings-section.d.ts +43 -0
- package/lib/types/client/settings-section.d.ts.map +1 -0
- package/lib/types/client/styles.d.ts +26 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/host/deletion.d.ts +26 -0
- package/lib/types/host/deletion.d.ts.map +1 -0
- package/lib/types/host/index.d.ts +20 -0
- package/lib/types/host/index.d.ts.map +1 -0
- package/lib/types/host/refusal.d.ts +25 -0
- package/lib/types/host/refusal.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +28 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/shared/contract.d.ts +61 -0
- package/lib/types/shared/contract.d.ts.map +1 -0
- package/package.json +69 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
import { lstat, realpath, rm } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
// src/host/refusal.ts
|
|
4
|
+
/**
|
|
5
|
+
* One coded refusal, shaped as a payload carries it.
|
|
6
|
+
*
|
|
7
|
+
* @param code - the refusal, whose sentence the page owns.
|
|
8
|
+
* @param params - the values that sentence interpolates, when it has any.
|
|
9
|
+
* @returns the payload fragment to spread into an `ok: false` answer.
|
|
10
|
+
*/
|
|
11
|
+
function coded(code, params) {
|
|
12
|
+
return { failure: params === void 0 ? { code } : {
|
|
13
|
+
code,
|
|
14
|
+
params
|
|
15
|
+
} };
|
|
16
|
+
}
|
|
17
|
+
// src/host/deletion.ts
|
|
18
|
+
/**
|
|
19
|
+
* On-disk Session deletion, including the subagent Sessions beneath one root.
|
|
20
|
+
*
|
|
21
|
+
* DSH exposes no deletion command of its own, so this Host module treats the
|
|
22
|
+
* Session artifact as the unit of removal. It first stops whatever the Session
|
|
23
|
+
* still runs, retires the live Session entry, and only then removes the
|
|
24
|
+
* header-verified directory. Paths never come from the request: every directory
|
|
25
|
+
* is derived from the persistence backend's own `locate()` answer and checked
|
|
26
|
+
* to be exactly one Session directory below the configured root.
|
|
27
|
+
*
|
|
28
|
+
* The operation is intentionally fail-closed. A descendant scan that cannot be
|
|
29
|
+
* completed, a directory that leaves the root, a symlink, an artifact whose
|
|
30
|
+
* header names another Session, or a Session that cannot be retired all abort
|
|
31
|
+
* the delete before the first `rm`. A failure after some descendants were
|
|
32
|
+
* already removed is reported as `delete-partial`, with the remaining ids.
|
|
33
|
+
*/
|
|
34
|
+
/** Bound on one Agent teardown before the Session is considered still live. */
|
|
35
|
+
const STOP_TIMEOUT_MS = 3e3;
|
|
36
|
+
/** Window allowed for the persistence writer to finish after a Session detach. */
|
|
37
|
+
const RETIRE_WAIT_MS = 200;
|
|
38
|
+
/** One coded deletion refusal thrown inside this module. */
|
|
39
|
+
var DeleteError = class extends Error {
|
|
40
|
+
failure;
|
|
41
|
+
constructor(code, params, message) {
|
|
42
|
+
super(message ?? code);
|
|
43
|
+
this.name = "DeleteError";
|
|
44
|
+
this.failure = params === void 0 ? { code } : {
|
|
45
|
+
code,
|
|
46
|
+
params
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
function sleep(ms) {
|
|
51
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Turn an arbitrary service failure into the payload's `failure` / `error` pair.
|
|
55
|
+
*
|
|
56
|
+
* A coded refusal is worded by the page. Raw text stays raw because it belongs
|
|
57
|
+
* to DSH, not to this plugin's dictionaries. The one exception is a generic
|
|
58
|
+
* delete failure, where the service's own message is the useful diagnostic.
|
|
59
|
+
*
|
|
60
|
+
* @param error - what the service threw.
|
|
61
|
+
* @param fallback - the code to use when the error carries no known identity.
|
|
62
|
+
* @returns the failure fragment to spread into an `ok: false` answer.
|
|
63
|
+
*/
|
|
64
|
+
function failureOf(error, fallback) {
|
|
65
|
+
if (error instanceof DeleteError) return error.failure.code === "delete-failed" ? { error: error.message } : { failure: error.failure };
|
|
66
|
+
const name = error?.name;
|
|
67
|
+
if (name === "WorkspaceActiveSessionError") {
|
|
68
|
+
const kinds = (error.activity ?? []).map((entry) => typeof entry?.kind === "string" ? entry.kind : "").filter((kind) => kind !== "");
|
|
69
|
+
return { failure: {
|
|
70
|
+
code: "delete-active",
|
|
71
|
+
...kinds.length === 0 ? {} : { params: { activity: kinds.join(", ") } }
|
|
72
|
+
} };
|
|
73
|
+
}
|
|
74
|
+
if (name === "WorkspaceUnknownSessionError") return { failure: { code: "unknown-session" } };
|
|
75
|
+
if (error?.code === "session/not-found") return { failure: { code: "unknown-session" } };
|
|
76
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
77
|
+
return message === "" ? { failure: { code: fallback } } : { error: message };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Read every header this Host can name, live or persisted.
|
|
81
|
+
*
|
|
82
|
+
* @param ctx - the Host plugin context.
|
|
83
|
+
* @returns the persistence backend and the id-to-header lookup.
|
|
84
|
+
*/
|
|
85
|
+
async function readHeaders(ctx) {
|
|
86
|
+
const persistence = ctx.get("sessionPersistence");
|
|
87
|
+
if (persistence === void 0 || typeof persistence.list !== "function" || typeof persistence.locate !== "function") throw new DeleteError("delete-unsupported", void 0, "the mounted persistence backend cannot locate Session artifacts");
|
|
88
|
+
const headers = /* @__PURE__ */ new Map();
|
|
89
|
+
let snapshots;
|
|
90
|
+
try {
|
|
91
|
+
snapshots = await persistence.list();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new DeleteError("delete-failed", void 0, `could not list Session artifacts: ${error instanceof Error ? error.message : String(error)}`);
|
|
94
|
+
}
|
|
95
|
+
for (const item of Array.isArray(snapshots) ? snapshots : []) {
|
|
96
|
+
const header = item?.header ?? item;
|
|
97
|
+
if (typeof header?.id === "string") headers.set(header.id, {
|
|
98
|
+
id: header.id,
|
|
99
|
+
header
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const live = ctx.get("sessions")?.list?.();
|
|
103
|
+
for (const session of Array.isArray(live) ? live : []) {
|
|
104
|
+
const header = session?.header;
|
|
105
|
+
if (typeof header?.id === "string") headers.set(header.id, {
|
|
106
|
+
id: header.id,
|
|
107
|
+
header
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
persistence,
|
|
112
|
+
headers
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Flatten one Session-query descendant tree into its Session ids. */
|
|
116
|
+
function flattenDescendants(nodes) {
|
|
117
|
+
const ids = [];
|
|
118
|
+
const visit = (list) => {
|
|
119
|
+
for (const node of Array.isArray(list) ? list : []) {
|
|
120
|
+
const header = node?.session?.header ?? node?.header;
|
|
121
|
+
if (typeof header?.id === "string") ids.push(header.id);
|
|
122
|
+
visit(node?.descendants);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
visit(nodes);
|
|
126
|
+
return ids;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Collect every subagent descendant of one root.
|
|
130
|
+
*
|
|
131
|
+
* `sessionQuery.traceSession` is the authoritative relationship trace when the
|
|
132
|
+
* composition mounts it; the header parent pointers cover the same tree when it
|
|
133
|
+
* does not. Both are unioned so a live child absent from one source is still
|
|
134
|
+
* included.
|
|
135
|
+
*
|
|
136
|
+
* @param ctx - the Host plugin context.
|
|
137
|
+
* @param rootId - the Session whose tree is wanted.
|
|
138
|
+
* @param headers - the header lookup.
|
|
139
|
+
* @returns descendant ids, excluding the root.
|
|
140
|
+
*/
|
|
141
|
+
async function collectDescendants(ctx, rootId, headers) {
|
|
142
|
+
const ids = /* @__PURE__ */ new Set();
|
|
143
|
+
const query = ctx.get("sessionQuery");
|
|
144
|
+
if (query !== void 0 && typeof query.traceSession === "function") {
|
|
145
|
+
let lineage;
|
|
146
|
+
try {
|
|
147
|
+
lineage = await query.traceSession(rootId);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw new DeleteError("delete-failed", void 0, `could not trace Session descendants: ${error instanceof Error ? error.message : String(error)}`);
|
|
150
|
+
}
|
|
151
|
+
for (const id of flattenDescendants(lineage?.descendants)) {
|
|
152
|
+
if (id === rootId) continue;
|
|
153
|
+
if (headers.get(id)?.header?.origin === "subagent" && descendsFrom(id, rootId, headers)) ids.add(id);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const byParent = /* @__PURE__ */ new Map();
|
|
157
|
+
for (const { id, header } of headers.values()) {
|
|
158
|
+
if (header?.origin !== "subagent" || typeof header?.parentSession !== "string") continue;
|
|
159
|
+
const children = byParent.get(header.parentSession) ?? [];
|
|
160
|
+
children.push(id);
|
|
161
|
+
byParent.set(header.parentSession, children);
|
|
162
|
+
}
|
|
163
|
+
const queue = [rootId];
|
|
164
|
+
const seen = /* @__PURE__ */ new Set([rootId]);
|
|
165
|
+
while (queue.length > 0) {
|
|
166
|
+
const parent = queue.shift();
|
|
167
|
+
for (const child of byParent.get(parent) ?? []) {
|
|
168
|
+
if (seen.has(child)) continue;
|
|
169
|
+
seen.add(child);
|
|
170
|
+
ids.add(child);
|
|
171
|
+
queue.push(child);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
ids.delete(rootId);
|
|
175
|
+
return [...ids];
|
|
176
|
+
}
|
|
177
|
+
/** Whether one header's parent chain reaches the given root. */
|
|
178
|
+
function descendsFrom(id, rootId, headers) {
|
|
179
|
+
let current = headers.get(id)?.header;
|
|
180
|
+
const seen = /* @__PURE__ */ new Set();
|
|
181
|
+
while (typeof current?.parentSession === "string" && !seen.has(current.parentSession)) {
|
|
182
|
+
if (current.parentSession === rootId) return true;
|
|
183
|
+
seen.add(current.parentSession);
|
|
184
|
+
current = headers.get(current.parentSession)?.header;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
/** Distance from a Session to the root, used to delete leaves before parents. */
|
|
189
|
+
function depthOf(id, headers) {
|
|
190
|
+
let depth = 0;
|
|
191
|
+
let current = headers.get(id)?.header;
|
|
192
|
+
const seen = /* @__PURE__ */ new Set();
|
|
193
|
+
while (typeof current?.parentSession === "string" && !seen.has(current.parentSession)) {
|
|
194
|
+
seen.add(current.parentSession);
|
|
195
|
+
depth += 1;
|
|
196
|
+
current = headers.get(current.parentSession)?.header;
|
|
197
|
+
}
|
|
198
|
+
return depth;
|
|
199
|
+
}
|
|
200
|
+
/** DSH's on-disk session directory encoding, mirroring the JSONL backend. */
|
|
201
|
+
function encodeSegment(raw) {
|
|
202
|
+
if (raw === ".") return "~002E";
|
|
203
|
+
if (raw === "..") return "~002E~002E";
|
|
204
|
+
let out = "";
|
|
205
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
206
|
+
const code = raw.charCodeAt(index);
|
|
207
|
+
const character = String.fromCharCode(code);
|
|
208
|
+
out += character !== "~" && /^[A-Za-z0-9._-]$/u.test(character) ? character : `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Resolve and verify one Session's artifact directory.
|
|
214
|
+
*
|
|
215
|
+
* The directory is exactly `<root>/<project>/<session-directory>`; the session
|
|
216
|
+
* directory must name this Session, every component must be a real directory
|
|
217
|
+
* (never a symlink or junction), and its real path must stay below the real
|
|
218
|
+
* root. A missing artifact returns `undefined` so a blank Session can still be
|
|
219
|
+
* retired without inventing a path.
|
|
220
|
+
*
|
|
221
|
+
* @param persistence - the mounted persistence backend.
|
|
222
|
+
* @param header - the Session header whose artifact is wanted.
|
|
223
|
+
* @returns the verified directory, or undefined when no artifact was written.
|
|
224
|
+
*/
|
|
225
|
+
async function sessionDirectory(persistence, header) {
|
|
226
|
+
let located;
|
|
227
|
+
try {
|
|
228
|
+
located = persistence.locate(header);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
throw new DeleteError("delete-unsupported", void 0, `could not locate the Session artifact: ${error instanceof Error ? error.message : String(error)}`);
|
|
231
|
+
}
|
|
232
|
+
if (typeof located?.path !== "string" || located.path === "") return void 0;
|
|
233
|
+
const target = resolve(dirname(located.path));
|
|
234
|
+
const root = typeof persistence.root === "string" ? resolve(persistence.root) : resolve(target, "..", "..");
|
|
235
|
+
let base;
|
|
236
|
+
try {
|
|
237
|
+
base = await realpath(root);
|
|
238
|
+
} catch {
|
|
239
|
+
base = root;
|
|
240
|
+
}
|
|
241
|
+
const rel = relative(root, target);
|
|
242
|
+
const parts = rel.split(sep).filter((part) => part !== "");
|
|
243
|
+
const encoded = encodeSegment(header.id);
|
|
244
|
+
if (isAbsolute(rel) || parts.length !== 2 || parts.some((part) => part === "." || part === "..") || parts[1] !== header.id && parts[1] !== encoded) throw new DeleteError("delete-unsupported", void 0, "the Session artifact lies outside the allowed directory");
|
|
245
|
+
let current = root;
|
|
246
|
+
try {
|
|
247
|
+
for (const part of parts) {
|
|
248
|
+
current = join(current, part);
|
|
249
|
+
const info = await lstat(current);
|
|
250
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new DeleteError("delete-unsupported", void 0, "the Session artifact directory is not a real directory");
|
|
251
|
+
const canonical = await realpath(current);
|
|
252
|
+
const child = relative(base, canonical);
|
|
253
|
+
if (isAbsolute(child) || child === "" || child.split(sep).includes("..")) throw new DeleteError("delete-unsupported", void 0, "the Session artifact resolves outside the allowed directory");
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error?.code === "ENOENT") return void 0;
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
return target;
|
|
260
|
+
}
|
|
261
|
+
/** Read the activity families the workspace registry publishes for one Session. */
|
|
262
|
+
async function sessionActivity(ctx, id) {
|
|
263
|
+
if (typeof ctx.waterfall !== "function") return [];
|
|
264
|
+
try {
|
|
265
|
+
const answer = await ctx.waterfall("workspace/session-activity", { sessionId: id }, () => Promise.resolve([]));
|
|
266
|
+
return Array.isArray(answer) ? answer : [];
|
|
267
|
+
} catch (error) {
|
|
268
|
+
throw new DeleteError("delete-failed", void 0, `could not inspect the Session's activity: ${error instanceof Error ? error.message : String(error)}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** A human-readable activity list for a refusal sentence. */
|
|
272
|
+
function activityText(activities) {
|
|
273
|
+
const kinds = activities.map((entry) => typeof entry?.kind === "string" ? entry.kind : "").filter((kind) => kind !== "");
|
|
274
|
+
return kinds.length === 0 ? "running work" : kinds.join(", ");
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Stop whatever one Session still runs, or refuse when it cannot be stopped.
|
|
278
|
+
*
|
|
279
|
+
* The user-facing policy is stop-then-delete: a running turn, subagent, job, or
|
|
280
|
+
* reminder is cancelled first. The Session is re-inspected afterwards; if the
|
|
281
|
+
* work survived the stop request, the deletion aborts before any file changes.
|
|
282
|
+
*
|
|
283
|
+
* @param ctx - the Host plugin context.
|
|
284
|
+
* @param id - the Session to quiesce.
|
|
285
|
+
*/
|
|
286
|
+
async function stopSession(ctx, id) {
|
|
287
|
+
const sessions = ctx.get("sessions");
|
|
288
|
+
const agents = ctx.get("agents");
|
|
289
|
+
const session = sessions?.get?.(id);
|
|
290
|
+
const agent = agents?.get?.(id);
|
|
291
|
+
const activities = await sessionActivity(ctx, id);
|
|
292
|
+
const activeTurn = session?.activeTurn !== void 0;
|
|
293
|
+
const running = agent?.status === "running" || agent?.busy === true;
|
|
294
|
+
if (!activeTurn && !running && activities.length === 0) return;
|
|
295
|
+
const registry = ctx.get("workspaceRegistry");
|
|
296
|
+
if (!(typeof agent?.cancel === "function" || typeof agent?.scope?.dispose === "function" || typeof registry?.stopSessionActivity === "function" || typeof ctx.parallel === "function")) throw new DeleteError("delete-active", { activity: activityText(activities) }, "the Session is active and this Host exposes no stop interface");
|
|
297
|
+
try {
|
|
298
|
+
if (typeof agent?.cancel === "function") agent.cancel({ kind: "disposed" });
|
|
299
|
+
if (typeof agent?.scope?.dispose === "function") await Promise.race([agent.scope.dispose(), sleep(STOP_TIMEOUT_MS)]);
|
|
300
|
+
else if (typeof agent?.whenIdle === "function") await Promise.race([agent.whenIdle(), sleep(STOP_TIMEOUT_MS)]);
|
|
301
|
+
} catch {}
|
|
302
|
+
try {
|
|
303
|
+
if (typeof registry?.stopSessionActivity === "function") await registry.stopSessionActivity(id);
|
|
304
|
+
else if (typeof ctx.parallel === "function") await ctx.parallel("workspace/session-stop", { sessionId: id });
|
|
305
|
+
} catch (error) {
|
|
306
|
+
throw new DeleteError("delete-active", { activity: activityText(activities) }, `could not stop the Session's work: ${error instanceof Error ? error.message : String(error)}`);
|
|
307
|
+
}
|
|
308
|
+
await sleep(250);
|
|
309
|
+
const afterAgent = agents?.get?.(id);
|
|
310
|
+
const afterSession = sessions?.get?.(id);
|
|
311
|
+
if (afterAgent?.status === "running" || afterAgent?.busy === true || afterSession?.activeTurn !== void 0) throw new DeleteError("delete-active", { activity: activityText(activities) }, "the Session is still active after the stop request");
|
|
312
|
+
const remainingActivity = await sessionActivity(ctx, id);
|
|
313
|
+
if (remainingActivity.length > 0) throw new DeleteError("delete-active", { activity: activityText(remainingActivity) }, "the Session still has reported activity after the stop request");
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Retire one live Session and its Agent, then make sure neither survives.
|
|
317
|
+
*
|
|
318
|
+
* A cold Session still gets a `session/disposed` notification so every connected
|
|
319
|
+
* client drops its row; a live one is flushed and detached through its store
|
|
320
|
+
* entry, which is the path that also closes the persistence writer.
|
|
321
|
+
*
|
|
322
|
+
* @param ctx - the Host plugin context.
|
|
323
|
+
* @param id - the Session to retire.
|
|
324
|
+
*/
|
|
325
|
+
async function retireSession(ctx, id) {
|
|
326
|
+
const sessions = ctx.get("sessions");
|
|
327
|
+
const agents = ctx.get("agents");
|
|
328
|
+
const agent = agents?.get?.(id);
|
|
329
|
+
if (agent !== void 0) {
|
|
330
|
+
try {
|
|
331
|
+
if (typeof agent.cancel === "function") agent.cancel({ kind: "disposed" });
|
|
332
|
+
if (typeof agent.scope?.dispose === "function") await Promise.race([agent.scope.dispose(), sleep(STOP_TIMEOUT_MS)]);
|
|
333
|
+
} catch {}
|
|
334
|
+
try {
|
|
335
|
+
agents.store?.delete?.(id);
|
|
336
|
+
} catch {}
|
|
337
|
+
}
|
|
338
|
+
const session = sessions?.get?.(id);
|
|
339
|
+
let detached = false;
|
|
340
|
+
if (session !== void 0) {
|
|
341
|
+
try {
|
|
342
|
+
if (typeof sessions.flush === "function") await sessions.flush(session);
|
|
343
|
+
} catch {}
|
|
344
|
+
const entry = sessions.store?.get?.(id);
|
|
345
|
+
if (entry !== void 0 && typeof entry.detach === "function") try {
|
|
346
|
+
entry.detach();
|
|
347
|
+
detached = true;
|
|
348
|
+
await sleep(RETIRE_WAIT_MS);
|
|
349
|
+
} catch {
|
|
350
|
+
detached = false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (!detached) try {
|
|
354
|
+
ctx.emit?.("session/disposed", { id });
|
|
355
|
+
} catch {}
|
|
356
|
+
if (sessions?.get?.(id) !== void 0 || agents?.get?.(id) !== void 0) throw new DeleteError("session-live", void 0, "the Session is still live after its teardown");
|
|
357
|
+
}
|
|
358
|
+
/** Remove one Session from Workspace accounting, the archive set, and the pin set. */
|
|
359
|
+
async function cleanupRegistry(ctx, id, warnings) {
|
|
360
|
+
const registry = ctx.get("workspaceRegistry");
|
|
361
|
+
if (registry === void 0) return;
|
|
362
|
+
try {
|
|
363
|
+
for (const entity of registry.list?.() ?? []) {
|
|
364
|
+
const recordIds = entity?.record?.sessionIds;
|
|
365
|
+
const ids = Array.isArray(recordIds) ? recordIds : entity?.sessionIds;
|
|
366
|
+
if (Array.isArray(ids) && ids.includes(id) && typeof entity.detachSession === "function") try {
|
|
367
|
+
await entity.detachSession(id);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
warnings.push(`Workspace membership cleanup failed for ${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch (error) {
|
|
373
|
+
warnings.push(`Workspace cleanup failed for ${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
if (typeof registry.unarchiveSession === "function") await registry.unarchiveSession(id);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
warnings.push(`Archive cleanup failed for ${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
if (typeof registry.unpinSession === "function") await registry.unpinSession(id);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
warnings.push(`Pin cleanup failed for ${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Delete one Session and every subagent Session beneath it.
|
|
388
|
+
*
|
|
389
|
+
* @param ctx - the Host plugin context.
|
|
390
|
+
* @param sessionId - the root Session to delete.
|
|
391
|
+
* @returns the deletion summary, or one coded refusal.
|
|
392
|
+
*/
|
|
393
|
+
async function deleteSession(ctx, sessionId) {
|
|
394
|
+
try {
|
|
395
|
+
if (sessionId === "") throw new DeleteError("missing-session");
|
|
396
|
+
const { persistence, headers } = await readHeaders(ctx);
|
|
397
|
+
const target = headers.get(sessionId);
|
|
398
|
+
if (target === void 0) throw new DeleteError("unknown-session");
|
|
399
|
+
if (target.header?.origin === "subagent") throw new DeleteError("subagent-delete");
|
|
400
|
+
const descendants = await collectDescendants(ctx, sessionId, headers);
|
|
401
|
+
for (const id of descendants) if (!headers.has(id)) throw new DeleteError("delete-failed", void 0, `cannot resolve the descendant Session '${id}'`);
|
|
402
|
+
const orderedDescendants = [...descendants].sort((left, right) => depthOf(right, headers) - depthOf(left, headers));
|
|
403
|
+
const stopOrder = [...orderedDescendants, sessionId];
|
|
404
|
+
const directories = /* @__PURE__ */ new Map();
|
|
405
|
+
for (const id of stopOrder) {
|
|
406
|
+
const header = headers.get(id)?.header;
|
|
407
|
+
directories.set(id, header === void 0 ? void 0 : await sessionDirectory(persistence, header));
|
|
408
|
+
}
|
|
409
|
+
for (const id of stopOrder) await stopSession(ctx, id);
|
|
410
|
+
const deleteOrder = [...orderedDescendants, sessionId];
|
|
411
|
+
const deleted = [];
|
|
412
|
+
const warnings = [];
|
|
413
|
+
let filesRemoved = false;
|
|
414
|
+
for (let index = 0; index < deleteOrder.length; index += 1) {
|
|
415
|
+
const id = deleteOrder[index];
|
|
416
|
+
try {
|
|
417
|
+
await retireSession(ctx, id);
|
|
418
|
+
const header = headers.get(id)?.header;
|
|
419
|
+
const directory = directories.get(id) ?? (header === void 0 ? void 0 : await sessionDirectory(persistence, header));
|
|
420
|
+
if (directory !== void 0) try {
|
|
421
|
+
await rm(directory, {
|
|
422
|
+
recursive: true,
|
|
423
|
+
force: true
|
|
424
|
+
});
|
|
425
|
+
filesRemoved = true;
|
|
426
|
+
} catch (error) {
|
|
427
|
+
throw new DeleteError("delete-failed", void 0, `could not remove the Session artifact: ${error instanceof Error ? error.message : String(error)}`);
|
|
428
|
+
}
|
|
429
|
+
await cleanupRegistry(ctx, id, warnings);
|
|
430
|
+
deleted.push(id);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
const refusal = failureOf(error, "delete-failed");
|
|
433
|
+
const remaining = deleteOrder.slice(index);
|
|
434
|
+
if (deleted.length === 0) return {
|
|
435
|
+
ok: false,
|
|
436
|
+
...refusal,
|
|
437
|
+
sessionId,
|
|
438
|
+
deletedSessions: deleted,
|
|
439
|
+
deletedSubagents: [],
|
|
440
|
+
remainingSessions: remaining,
|
|
441
|
+
filesRemoved,
|
|
442
|
+
...warnings.length === 0 ? {} : { warnings }
|
|
443
|
+
};
|
|
444
|
+
const detail = refusal.error ?? (error instanceof Error && error.message !== "" ? error.message : void 0) ?? (refusal.failure === void 0 ? void 0 : `failure: ${refusal.failure.code}`);
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
failure: {
|
|
448
|
+
code: "delete-partial",
|
|
449
|
+
params: {
|
|
450
|
+
deleted: deleted.length,
|
|
451
|
+
remaining: remaining.length
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
...detail === void 0 ? {} : { error: detail },
|
|
455
|
+
sessionId,
|
|
456
|
+
deletedSessions: deleted,
|
|
457
|
+
deletedSubagents: deleted.filter((deletedId) => deletedId !== sessionId),
|
|
458
|
+
remainingSessions: remaining,
|
|
459
|
+
filesRemoved,
|
|
460
|
+
...warnings.length === 0 ? {} : { warnings }
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
ctx.logger?.info?.(`dsh-cool-session: deleted ${deleted.length} Session artifact(s): ${deleted.join(", ")}`);
|
|
466
|
+
} catch {}
|
|
467
|
+
return {
|
|
468
|
+
ok: true,
|
|
469
|
+
sessionId,
|
|
470
|
+
deletedSessions: deleted,
|
|
471
|
+
deletedSubagents: deleted.filter((deletedId) => deletedId !== sessionId),
|
|
472
|
+
filesRemoved,
|
|
473
|
+
...warnings.length === 0 ? {} : { warnings }
|
|
474
|
+
};
|
|
475
|
+
} catch (error) {
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
...failureOf(error, "delete-failed"),
|
|
479
|
+
sessionId
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// src/host/routes.ts
|
|
484
|
+
/**
|
|
485
|
+
* The Host half's HTTP surface.
|
|
486
|
+
*
|
|
487
|
+
* The browser half has no Cordis context and no remote namespace of its own, so
|
|
488
|
+
* the destructive command crosses same-origin routes registered on the
|
|
489
|
+
* `webServer` carrier: one Session per request from the native menu, and one
|
|
490
|
+
* selection per request from the management panel. The routes answer JSON and
|
|
491
|
+
* never reject: a transport, service, or filesystem failure becomes an
|
|
492
|
+
* `{ ok: false }` result the page reports in place.
|
|
493
|
+
*
|
|
494
|
+
* The Host stays authoritative about *what* is deleted. A request names a
|
|
495
|
+
* Session id but never a directory or an Agent: the artifact path is derived and
|
|
496
|
+
* verified by `deletion.ts`, never by the caller. That keeps the page a
|
|
497
|
+
* management surface over DSH's own state rather than a second source of truth.
|
|
498
|
+
*/
|
|
499
|
+
/** Route table, kept in one place so the two halves cannot drift. */
|
|
500
|
+
const ROUTES = {
|
|
501
|
+
delete: "/dsh-cool-session/api/delete",
|
|
502
|
+
deleteMany: "/dsh-cool-session/api/delete-many"
|
|
503
|
+
};
|
|
504
|
+
/** Longest Session id a route will accept; DSH ids are far shorter. */
|
|
505
|
+
const MAX_SESSION_ID_LENGTH = 200;
|
|
506
|
+
/** Most Sessions one batch request may name, so a page cannot pin the Host. */
|
|
507
|
+
const MAX_BATCH_SIZE = 200;
|
|
508
|
+
function sendJson(res, status, body) {
|
|
509
|
+
res.writeHead(status, {
|
|
510
|
+
"content-type": "application/json; charset=utf-8",
|
|
511
|
+
"cache-control": "no-store"
|
|
512
|
+
});
|
|
513
|
+
res.end(JSON.stringify(body));
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Answer 405 unless the request uses one of the allowed methods.
|
|
517
|
+
*
|
|
518
|
+
* @returns true when the handler may proceed.
|
|
519
|
+
*/
|
|
520
|
+
function methodAllowed(req, res, allowed) {
|
|
521
|
+
if (allowed.includes(req.method)) return true;
|
|
522
|
+
sendJson(res, 405, {
|
|
523
|
+
ok: false,
|
|
524
|
+
error: "method not allowed"
|
|
525
|
+
});
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
async function readBody(req) {
|
|
529
|
+
const chunks = [];
|
|
530
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
531
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
532
|
+
if (text === "") return {};
|
|
533
|
+
const parsed = JSON.parse(text);
|
|
534
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("body must be a JSON object");
|
|
535
|
+
return parsed;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Reject a cross-site request.
|
|
539
|
+
*
|
|
540
|
+
* This route can destroy a Session, so a page on another origin has no business
|
|
541
|
+
* asking. A same-origin `fetch` omits `Origin` on some requests, so an absent
|
|
542
|
+
* header is allowed.
|
|
543
|
+
*/
|
|
544
|
+
function sameOrigin(req) {
|
|
545
|
+
const origin = req.headers?.origin;
|
|
546
|
+
if (typeof origin !== "string" || origin === "" || origin === "null") return true;
|
|
547
|
+
const host = req.headers?.host;
|
|
548
|
+
if (typeof host !== "string" || host === "") return false;
|
|
549
|
+
try {
|
|
550
|
+
return new URL(origin).host === host;
|
|
551
|
+
} catch {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Read one request's Session id.
|
|
557
|
+
*
|
|
558
|
+
* The id names a Session, not a path or a command, so the shape checked here is
|
|
559
|
+
* only what can be passed back to DSH at all.
|
|
560
|
+
*
|
|
561
|
+
* @param body - the parsed request body.
|
|
562
|
+
* @returns the id, or undefined when the body names none.
|
|
563
|
+
*/
|
|
564
|
+
function readSessionId(body) {
|
|
565
|
+
const sessionId = body.sessionId;
|
|
566
|
+
if (typeof sessionId !== "string" || sessionId === "" || sessionId.length > MAX_SESSION_ID_LENGTH) return;
|
|
567
|
+
return sessionId;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Read one batch request's Session ids.
|
|
571
|
+
*
|
|
572
|
+
* Duplicates are collapsed because a page may pass a selection it built from a
|
|
573
|
+
* list that changed underneath it; deleting the same Session twice would turn
|
|
574
|
+
* the second attempt into an `unknown-session` refusal for work that already
|
|
575
|
+
* succeeded.
|
|
576
|
+
*
|
|
577
|
+
* @param body - the parsed request body.
|
|
578
|
+
* @returns the unique ids, or undefined when the array is malformed.
|
|
579
|
+
*/
|
|
580
|
+
function readSessionIds(body) {
|
|
581
|
+
const raw = body.sessionIds;
|
|
582
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_BATCH_SIZE) return void 0;
|
|
583
|
+
const ids = [];
|
|
584
|
+
for (const value of raw) {
|
|
585
|
+
if (typeof value !== "string" || value === "" || value.length > MAX_SESSION_ID_LENGTH) return void 0;
|
|
586
|
+
if (!ids.includes(value)) ids.push(value);
|
|
587
|
+
}
|
|
588
|
+
return ids;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Run one JSON route body.
|
|
592
|
+
*
|
|
593
|
+
* Every route repeats the same three steps — method, body, handler — so they are
|
|
594
|
+
* taken once here and the route only supplies its own work. A malformed body is
|
|
595
|
+
* a 400; everything later is a 200 carrying `ok: false`, because a service's own
|
|
596
|
+
* refusal is an answer the page should show, not an HTTP error.
|
|
597
|
+
*
|
|
598
|
+
* @param ctx - the Host plugin context.
|
|
599
|
+
* @param req - the incoming request.
|
|
600
|
+
* @param res - the response to write.
|
|
601
|
+
* @param run - the route's own work, given the parsed body.
|
|
602
|
+
*/
|
|
603
|
+
async function handle(ctx, req, res, run) {
|
|
604
|
+
if (!methodAllowed(req, res, ["POST"])) return;
|
|
605
|
+
let body;
|
|
606
|
+
try {
|
|
607
|
+
body = await readBody(req);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
sendJson(res, 400, {
|
|
610
|
+
ok: false,
|
|
611
|
+
error: error instanceof Error ? error.message : String(error)
|
|
612
|
+
});
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
sendJson(res, 200, await run(body));
|
|
617
|
+
} catch (error) {
|
|
618
|
+
sendJson(res, 200, {
|
|
619
|
+
ok: false,
|
|
620
|
+
error: error instanceof Error ? error.message : String(error)
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Serve the deletion routes for one plugin instance.
|
|
626
|
+
*
|
|
627
|
+
* @param ctx - the Host plugin context.
|
|
628
|
+
* @returns a disposer that unregisters exactly this route.
|
|
629
|
+
*/
|
|
630
|
+
function registerRoutes(ctx) {
|
|
631
|
+
const guard = (handler) => (req, res) => {
|
|
632
|
+
if (!sameOrigin(req)) {
|
|
633
|
+
sendJson(res, 403, {
|
|
634
|
+
ok: false,
|
|
635
|
+
error: "cross-origin request refused"
|
|
636
|
+
});
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
return handler(req, res);
|
|
640
|
+
};
|
|
641
|
+
const route = (path, handler) => ctx.webServer.register({
|
|
642
|
+
kind: "exact",
|
|
643
|
+
path,
|
|
644
|
+
handler: guard(handler)
|
|
645
|
+
});
|
|
646
|
+
const disposers = [route(ROUTES.delete, (req, res) => handle(ctx, req, res, (body) => {
|
|
647
|
+
const sessionId = readSessionId(body);
|
|
648
|
+
if (sessionId === void 0) return Promise.resolve({
|
|
649
|
+
ok: false,
|
|
650
|
+
...coded("missing-session")
|
|
651
|
+
});
|
|
652
|
+
return deleteSession(ctx, sessionId);
|
|
653
|
+
})), route(ROUTES.deleteMany, (req, res) => handle(ctx, req, res, async (body) => {
|
|
654
|
+
const sessionIds = readSessionIds(body);
|
|
655
|
+
if (sessionIds === void 0) return {
|
|
656
|
+
ok: false,
|
|
657
|
+
error: `sessionIds must be a non-empty array of at most ${MAX_BATCH_SIZE} Session ids`
|
|
658
|
+
};
|
|
659
|
+
const results = [];
|
|
660
|
+
for (const sessionId of sessionIds) results.push(await deleteSession(ctx, sessionId));
|
|
661
|
+
return {
|
|
662
|
+
ok: results.every((result) => result.ok),
|
|
663
|
+
results
|
|
664
|
+
};
|
|
665
|
+
}))];
|
|
666
|
+
return () => {
|
|
667
|
+
for (const dispose of disposers) dispose();
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
// src/host/index.ts
|
|
671
|
+
/**
|
|
672
|
+
* Host entry for dsh-cool-session.
|
|
673
|
+
*
|
|
674
|
+
* The browser half owns the native Session-menu row and its confirmation; this
|
|
675
|
+
* half owns the one destructive artifact operation behind that confirmation.
|
|
676
|
+
* Every question the page asks crosses a same-origin HTTP route served by the
|
|
677
|
+
* `webServer` carrier, because an out-of-tree bundle cannot generate a
|
|
678
|
+
* `ctx.remote` namespace and its Client half never sees the Host Cordis context.
|
|
679
|
+
*
|
|
680
|
+
* The Session and Agent capabilities are read per request rather than declared
|
|
681
|
+
* as hard dependencies: a composition without them still loads the plugin and
|
|
682
|
+
* answers one clear refusal instead of failing the menu row.
|
|
683
|
+
*/
|
|
684
|
+
const name = "dsh-cool-session";
|
|
685
|
+
/** The web carrier is a hard dependency: without it there is no route to serve. */
|
|
686
|
+
const inject = ["webServer"];
|
|
687
|
+
function apply(ctx) {
|
|
688
|
+
ctx.effect(() => registerRoutes(ctx), "dsh-cool-session: session routes");
|
|
689
|
+
}
|
|
690
|
+
export { ROUTES, apply, inject, name };
|