@murchey/dsh-session-manager 0.1.0 → 0.1.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 +6 -4
- package/client.js +23 -3
- package/index.js +13 -4
- package/lib/core.js +118 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -41,11 +41,11 @@ dsh plugin --profile <profile> add link:<此目录绝对路径>
|
|
|
41
41
|
|
|
42
42
|
- 顶部显示总会话数与磁盘占用;
|
|
43
43
|
- 每个会话显示标题、ID、项目目录、创建日期、占用空间和比例条;
|
|
44
|
-
-
|
|
44
|
+
- 只有**正在生成**的会话标记为 `运行中` 并禁止选择;“已归档”会话显示 `已归档` 徽标,只要没有正在运行的一轮,就可以直接删除,不再需要重启 DSH;页面打开期间每 5 秒自动同步一次状态,切回后台页面时会立即同步;
|
|
45
45
|
- 支持按标题 / ID / 项目目录搜索,按大小或时间排序;
|
|
46
46
|
- 行尾“删除”按钮删除单个会话,“删除所选”批量删除;
|
|
47
47
|
- 删除前弹出确认对话框,明确显示数量、总字节数和“不可撤销”警告,并默认勾选“同时删除不再被其他会话引用的附件”;
|
|
48
|
-
-
|
|
48
|
+
- 删除成功后会同时把会话从 DSH 的归档 / 置顶集合中移除,避免侧边栏残留“已归档”空条目,并主动刷新 Client 会话目录;
|
|
49
49
|
- 顶部“清理失效栏目”按钮用于历史遗留:先向 Host 重新拉取权威会话目录,再把侧边栏中数据已不存在的条目从本地目录移除(不会删除任何仍然存在的会话);删除成功后也会顺带执行同样的清理。
|
|
50
50
|
|
|
51
51
|
页面通过 Connection 的已认证 `/api/session-shredder/*` 路由读写;UI 的确认对话框就是人工闸门,Host 路由仍要求 `confirm:true` 并只接受 owner 浏览器会话。
|
|
@@ -70,7 +70,9 @@ dsh plugin --profile <profile> add link:<此目录绝对路径>
|
|
|
70
70
|
## 安全规则
|
|
71
71
|
|
|
72
72
|
- 插件在 Host 进程内运行,**不受**会话 workspace 文件沙箱限制;它只按配置的数据根删除,并校验每个目标确实位于根内。
|
|
73
|
-
-
|
|
73
|
+
- 当前会话、正在运行的一轮(`agent.status === 'running'`)**拒绝删除**。
|
|
74
|
+
- “驻留内存但空闲”的 Agent 不再等同于“运行中”:DSH 归档只是把会话移入归档集合,并不会销毁 Agent,因此旧逻辑会把归档会话永久锁死,只能靠重启释放。现在这类会话可以删除,`plan` 会给出提示而不是阻止。
|
|
75
|
+
- 归档 / 置顶只是 `workspace.json` 里的元数据;插件在删除成功后会把对应 id 从这两个集合中移除,避免留下删不掉的空条目。
|
|
74
76
|
- 会话 id 只接受安全字符(顶层会话 `session-<token>`、子代理裸 UUID);路径编码与 DSH 的 JSONL 后端完全一致,避免路径穿越。
|
|
75
77
|
- 只删除 `sessionsRoot` / `projectionCacheRoot` / `attachmentsRoot` 内部的真实目录,不跟随符号链接。
|
|
76
78
|
- 子代理会话不能调用该工具,只有顶层会话可以。
|
|
@@ -108,7 +110,7 @@ dsh plugin --profile <profile> add link:<此目录绝对路径>
|
|
|
108
110
|
|
|
109
111
|
## 不会处理的内容
|
|
110
112
|
|
|
111
|
-
- `workspace.json` 里的会话排序
|
|
113
|
+
- `workspace.json` 里的会话排序 id:只是元数据,DSH 在下次 workspace 变更时会自动剪除;归档 / 置顶 id 会在删除成功后由插件主动移除(它们不包含会话内容)。
|
|
112
114
|
- 会话搜索索引(若启用 SQLite 后端):DSH 启动时会按持久化列表对账删除,重启后消失。
|
|
113
115
|
- `llm-deepseek/files-v3.json` 中的远端 `fileId` 映射:只有引用,不含内容,且远端文件会过期。
|
|
114
116
|
- 系统临时目录中由子进程产生的日志/溢出文件,以及 Web 端浏览器缓存。
|
package/client.js
CHANGED
|
@@ -314,9 +314,12 @@ window.__ModuleLoader__.load({
|
|
|
314
314
|
const deleteRef = React.useRef(null);
|
|
315
315
|
const selectAllRef = React.useRef(null);
|
|
316
316
|
const wasOpen = React.useRef(false);
|
|
317
|
+
const loadingRef = React.useRef(false);
|
|
317
318
|
|
|
318
|
-
const load = React.useCallback(async () => {
|
|
319
|
-
|
|
319
|
+
const load = React.useCallback(async (showLoading = true) => {
|
|
320
|
+
if (loadingRef.current) return;
|
|
321
|
+
loadingRef.current = true;
|
|
322
|
+
if (showLoading) setLoading(true);
|
|
320
323
|
try {
|
|
321
324
|
const value = await requestJson('/api/session-shredder/sessions');
|
|
322
325
|
setSessions(Array.isArray(value.sessions) ? value.sessions : []);
|
|
@@ -327,7 +330,8 @@ window.__ModuleLoader__.load({
|
|
|
327
330
|
} catch (loadError) {
|
|
328
331
|
setError(loadError instanceof Error ? loadError.message : String(loadError));
|
|
329
332
|
} finally {
|
|
330
|
-
|
|
333
|
+
loadingRef.current = false;
|
|
334
|
+
if (showLoading) setLoading(false);
|
|
331
335
|
}
|
|
332
336
|
}, []);
|
|
333
337
|
|
|
@@ -335,6 +339,22 @@ window.__ModuleLoader__.load({
|
|
|
335
339
|
void load();
|
|
336
340
|
}, [load]);
|
|
337
341
|
|
|
342
|
+
// The Host derives `live` from its current agent registry. Keep the page
|
|
343
|
+
// synchronized while it stays mounted, and refresh immediately when a
|
|
344
|
+
// backgrounded tab becomes visible again. This avoids requiring an app
|
|
345
|
+
// restart after an agent finishes or is stopped.
|
|
346
|
+
React.useEffect(() => {
|
|
347
|
+
const refreshIfVisible = () => {
|
|
348
|
+
if (typeof document === 'undefined' || document.visibilityState === 'visible') void load(false);
|
|
349
|
+
};
|
|
350
|
+
const timer = setInterval(refreshIfVisible, 5000);
|
|
351
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', refreshIfVisible);
|
|
352
|
+
return () => {
|
|
353
|
+
clearInterval(timer);
|
|
354
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', refreshIfVisible);
|
|
355
|
+
};
|
|
356
|
+
}, [load]);
|
|
357
|
+
|
|
338
358
|
React.useEffect(() => {
|
|
339
359
|
setSelected((previous) => {
|
|
340
360
|
if (previous.size === 0) return previous;
|
package/index.js
CHANGED
|
@@ -18,8 +18,8 @@ import {
|
|
|
18
18
|
resolveOptions,
|
|
19
19
|
} from './lib/core.js';
|
|
20
20
|
|
|
21
|
-
/** Cordis activation requires the tool
|
|
22
|
-
export const inject = ['tools', 'sessionPersistence'];
|
|
21
|
+
/** Cordis activation requires the tool, persistence, and live-agent services. */
|
|
22
|
+
export const inject = ['tools', 'sessionPersistence', 'agents'];
|
|
23
23
|
|
|
24
24
|
/** Stable plugin row name. */
|
|
25
25
|
export const name = 'session-shredder';
|
|
@@ -30,7 +30,7 @@ const TOOL_DESCRIPTION = [
|
|
|
30
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
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
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
|
|
33
|
+
'A purge refuses the current session and any session with a running turn, and asks the user for approval before deleting. Archived sessions are deletable once idle, and their archive/pin entries are dropped with the data.',
|
|
34
34
|
].join(' ');
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -136,6 +136,7 @@ function registerHttpRoutes(ctx, options) {
|
|
|
136
136
|
bytes: session.bytes,
|
|
137
137
|
eventCount: session.eventCount ?? null,
|
|
138
138
|
live: session.live,
|
|
139
|
+
resident: session.resident === true,
|
|
139
140
|
archived: session.archived === true,
|
|
140
141
|
orphan: session.orphan,
|
|
141
142
|
subagent: !session.id.startsWith('session-'),
|
|
@@ -187,6 +188,7 @@ function registerHttpRoutes(ctx, options) {
|
|
|
187
188
|
deletedBytes: result.deletedBytes,
|
|
188
189
|
attachments: result.attachments,
|
|
189
190
|
requestImageCache: result.requestImageCache,
|
|
191
|
+
metadata: result.metadata,
|
|
190
192
|
targets: result.targets.map((target) => ({
|
|
191
193
|
id: target.id,
|
|
192
194
|
bytes: target.bytes,
|
|
@@ -283,8 +285,12 @@ async function planAction(ctx, options, sessionIds, currentSessionId) {
|
|
|
283
285
|
`Purge plan (dry run) — ${plan.targets.length} session${plan.targets.length === 1 ? '' : 's'}, ${formatBytes(plan.totalBytes)} on disk.`,
|
|
284
286
|
];
|
|
285
287
|
for (const target of plan.targets) {
|
|
288
|
+
const flags = [target.archived ? 'ARCHIVED' : '', target.resident ? 'RESIDENT' : '', target.live ? 'RUNNING' : '']
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.join(', ');
|
|
286
291
|
lines.push(
|
|
287
|
-
`- ${target.id}${target.title !== undefined ? ` "${target.title}"` : ''} ${formatBytes(target.bytes)}
|
|
292
|
+
`- ${target.id}${target.title !== undefined ? ` "${target.title}"` : ''} ${formatBytes(target.bytes)}` +
|
|
293
|
+
`${flags === '' ? '' : ` [${flags}]`}`,
|
|
288
294
|
);
|
|
289
295
|
if (target.dirs.length > 0) {
|
|
290
296
|
for (const dir of target.dirs) lines.push(` log: ${dir}`);
|
|
@@ -345,6 +351,9 @@ async function purgeAction(ctx, options, args, exec, currentSessionId) {
|
|
|
345
351
|
`Request-image cache: cleared (${formatBytes(result.requestImageCache.bytes)}, ${result.requestImageCache.files} file(s)).`,
|
|
346
352
|
);
|
|
347
353
|
}
|
|
354
|
+
if (Array.isArray(result.metadata?.pruned) && result.metadata.pruned.length > 0) {
|
|
355
|
+
lines.push(`Archive/pin entries removed: ${result.metadata.pruned.join(', ')}.`);
|
|
356
|
+
}
|
|
348
357
|
for (const target of result.targets) {
|
|
349
358
|
lines.push(
|
|
350
359
|
`- ${target.id}: ${formatBytes(target.bytes)}, ${target.files} file${target.files === 1 ? '' : 's'}` +
|
package/lib/core.js
CHANGED
|
@@ -321,14 +321,42 @@ async function safeTitle(ctx, id) {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Snapshot the live agent registry as `session id -> running?`.
|
|
326
|
+
*
|
|
327
|
+
* `agents.list()` returns every Agent resident in this process, including idle
|
|
328
|
+
* ones that merely have an open session. Archiving a session does not dispose
|
|
329
|
+
* its Agent, so residency must never be reported as "running": a resident but
|
|
330
|
+
* idle agent is `status === 'idle'` and can be purged once its turn is over.
|
|
331
|
+
*
|
|
332
|
+
* @param {any} ctx
|
|
333
|
+
* @returns {Map<string, boolean>} session id -> true when a turn is running
|
|
334
|
+
*/
|
|
335
|
+
function agentRunningStates(ctx) {
|
|
336
|
+
const states = new Map();
|
|
325
337
|
const agents = ctx.get?.('agents');
|
|
326
|
-
if (agents === undefined || typeof agents.list !== 'function') return
|
|
338
|
+
if (agents === undefined || typeof agents.list !== 'function') return states;
|
|
339
|
+
let list;
|
|
327
340
|
try {
|
|
328
|
-
|
|
341
|
+
list = agents.list();
|
|
329
342
|
} catch {
|
|
330
|
-
return
|
|
343
|
+
return states;
|
|
331
344
|
}
|
|
345
|
+
for (const agent of Array.isArray(list) ? list : []) {
|
|
346
|
+
const id = agent?.id ?? agent?.session?.id;
|
|
347
|
+
if (typeof id !== 'string') continue;
|
|
348
|
+
// An agent without a readable status is treated as running: refusing is
|
|
349
|
+
// recoverable, deleting a live session is not.
|
|
350
|
+
let running = true;
|
|
351
|
+
try {
|
|
352
|
+
const status = agent?.status;
|
|
353
|
+
if (typeof status === 'string') running = status === 'running';
|
|
354
|
+
} catch {
|
|
355
|
+
running = true;
|
|
356
|
+
}
|
|
357
|
+
states.set(id, running);
|
|
358
|
+
}
|
|
359
|
+
return states;
|
|
332
360
|
}
|
|
333
361
|
|
|
334
362
|
/**
|
|
@@ -366,7 +394,7 @@ export async function listSessions(ctx, options, request = {}) {
|
|
|
366
394
|
const snapshots = await listSnapshots(persistence, request.signal);
|
|
367
395
|
const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot]));
|
|
368
396
|
const dirIndex = await indexSessionDirs(options.sessionsRoot);
|
|
369
|
-
const
|
|
397
|
+
const agents = agentRunningStates(ctx);
|
|
370
398
|
const archived = archivedSessionIds(ctx);
|
|
371
399
|
const currentSessionId = request.currentSessionId;
|
|
372
400
|
const limit = Number.isInteger(request.limit)
|
|
@@ -401,7 +429,8 @@ export async function listSessions(ctx, options, request = {}) {
|
|
|
401
429
|
logDirs: dirs,
|
|
402
430
|
stored: snapshot !== undefined,
|
|
403
431
|
orphan: snapshot === undefined,
|
|
404
|
-
live:
|
|
432
|
+
live: agents.get(id) === true,
|
|
433
|
+
resident: agents.has(id),
|
|
405
434
|
archived: archived.has(id),
|
|
406
435
|
current: id === currentSessionId,
|
|
407
436
|
latestMtime: latestMtime || undefined,
|
|
@@ -440,7 +469,8 @@ export async function planPurge(ctx, options, ids, request = {}) {
|
|
|
440
469
|
const snapshots = await listSnapshots(persistence, request.signal);
|
|
441
470
|
const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot]));
|
|
442
471
|
const dirIndex = await indexSessionDirs(options.sessionsRoot);
|
|
443
|
-
const
|
|
472
|
+
const agents = agentRunningStates(ctx);
|
|
473
|
+
const archived = archivedSessionIds(ctx);
|
|
444
474
|
const now = Date.now();
|
|
445
475
|
const targets = [];
|
|
446
476
|
const blockers = [];
|
|
@@ -489,7 +519,12 @@ export async function planPurge(ctx, options, ids, request = {}) {
|
|
|
489
519
|
if (request.currentSessionId !== undefined && id === request.currentSessionId) {
|
|
490
520
|
reasons.push('this is the current session; switch to another session to delete it');
|
|
491
521
|
}
|
|
492
|
-
if (
|
|
522
|
+
if (agents.get(id) === true) reasons.push('a running turn still owns this session in this process');
|
|
523
|
+
else if (agents.has(id)) {
|
|
524
|
+
warnings.push(
|
|
525
|
+
`${id}: the session is still resident in this DSH process but no turn is running; deletion is allowed`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
493
528
|
if (dirs.length === 0 && cacheFiles.length === 0 && snapshot === undefined) {
|
|
494
529
|
reasons.push('no session data found on disk or in persistence for this id');
|
|
495
530
|
}
|
|
@@ -508,7 +543,9 @@ export async function planPurge(ctx, options, ids, request = {}) {
|
|
|
508
543
|
createdAt: snapshot?.header?.createdAt,
|
|
509
544
|
cwd: snapshot?.header?.cwd,
|
|
510
545
|
stored: snapshot !== undefined,
|
|
511
|
-
live:
|
|
546
|
+
live: agents.get(id) === true,
|
|
547
|
+
resident: agents.has(id),
|
|
548
|
+
archived: archived.has(id),
|
|
512
549
|
current: request.currentSessionId !== undefined && id === request.currentSessionId,
|
|
513
550
|
blockers: reasons,
|
|
514
551
|
};
|
|
@@ -565,13 +602,14 @@ export async function purgeSessions(ctx, options, ids, request = {}) {
|
|
|
565
602
|
});
|
|
566
603
|
}
|
|
567
604
|
|
|
568
|
-
// The reference scan can take minutes; a target may have
|
|
569
|
-
// the first plan. Re-refuse before deleting anything.
|
|
570
|
-
|
|
571
|
-
const
|
|
605
|
+
// The reference scan can take minutes; a target may have started a turn
|
|
606
|
+
// since the first plan. Re-refuse before deleting anything. A resident but
|
|
607
|
+
// idle agent is fine: only a running turn keeps its log writer alive.
|
|
608
|
+
const runningNow = agentRunningStates(ctx);
|
|
609
|
+
const becameLive = plan.targets.filter((target) => runningNow.get(target.id) === true);
|
|
572
610
|
if (becameLive.length > 0) {
|
|
573
611
|
throw new PurgeError(
|
|
574
|
-
`purge refused: session(s)
|
|
612
|
+
`purge refused: session(s) started running during planning: ${becameLive.map((target) => target.id).join(', ')}`,
|
|
575
613
|
'BLOCKED',
|
|
576
614
|
);
|
|
577
615
|
}
|
|
@@ -696,9 +734,75 @@ export async function purgeSessions(ctx, options, ids, request = {}) {
|
|
|
696
734
|
report.verified = false;
|
|
697
735
|
warnings.push(`request-image cache still present after purge: ${options.requestImageCacheRoot}`);
|
|
698
736
|
}
|
|
737
|
+
|
|
738
|
+
// A fully removed session must also leave the registry's archive and pin
|
|
739
|
+
// sets. Otherwise the sidebar keeps an "archived" row whose data is gone:
|
|
740
|
+
// it can never be opened again and every later delete attempt refuses with
|
|
741
|
+
// "no session data found", which looks like an archived session that cannot
|
|
742
|
+
// be deleted.
|
|
743
|
+
report.metadata = { pruned: [], skipped: true };
|
|
744
|
+
const removable = plan.targets.filter((target, index) => report.targets[index].errors.length === 0);
|
|
745
|
+
if (removable.length > 0) {
|
|
746
|
+
report.metadata = await pruneSessionMetadata(ctx, removable.map((target) => target.id), warnings);
|
|
747
|
+
}
|
|
699
748
|
return { ...report, plan };
|
|
700
749
|
}
|
|
701
750
|
|
|
751
|
+
/**
|
|
752
|
+
* Drop purged ids from the workspace registry's global archive and pin sets.
|
|
753
|
+
* Best effort: the filesystem removal already succeeded, so an unavailable
|
|
754
|
+
* registry must not turn a real purge into a reported failure.
|
|
755
|
+
*
|
|
756
|
+
* @param {any} ctx
|
|
757
|
+
* @param {string[]} ids
|
|
758
|
+
* @param {string[]} warnings
|
|
759
|
+
* @returns {Promise<{ pruned: string[], skipped: boolean }>}
|
|
760
|
+
*/
|
|
761
|
+
async function pruneSessionMetadata(ctx, ids, warnings) {
|
|
762
|
+
let registry;
|
|
763
|
+
try {
|
|
764
|
+
registry = ctx.get?.('workspaceRegistry');
|
|
765
|
+
} catch {
|
|
766
|
+
registry = undefined;
|
|
767
|
+
}
|
|
768
|
+
if (registry === undefined || registry === null) return { pruned: [], skipped: true };
|
|
769
|
+
const archived = archivedSessionIds(ctx);
|
|
770
|
+
const pinned = pinnedSessionIds(ctx);
|
|
771
|
+
const pruned = [];
|
|
772
|
+
for (const id of ids) {
|
|
773
|
+
let changed = false;
|
|
774
|
+
if (archived.has(id) && typeof registry.unarchiveSession === 'function') {
|
|
775
|
+
try {
|
|
776
|
+
await registry.unarchiveSession(id);
|
|
777
|
+
changed = true;
|
|
778
|
+
} catch (error) {
|
|
779
|
+
warnings.push(`${id}: could not remove the archived entry (${error?.message ?? String(error)})`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (pinned.has(id) && typeof registry.unpinSession === 'function') {
|
|
783
|
+
try {
|
|
784
|
+
await registry.unpinSession(id);
|
|
785
|
+
changed = true;
|
|
786
|
+
} catch (error) {
|
|
787
|
+
warnings.push(`${id}: could not remove the pinned entry (${error?.message ?? String(error)})`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (changed) pruned.push(id);
|
|
791
|
+
}
|
|
792
|
+
return { pruned, skipped: false };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Read the registry-global pin set without requiring the service. */
|
|
796
|
+
function pinnedSessionIds(ctx) {
|
|
797
|
+
try {
|
|
798
|
+
const registry = ctx.get?.('workspaceRegistry');
|
|
799
|
+
const ids = registry?.pinnedSessionIds;
|
|
800
|
+
return new Set(Array.isArray(ids) ? ids : []);
|
|
801
|
+
} catch {
|
|
802
|
+
return new Set();
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
702
806
|
/**
|
|
703
807
|
* Decision support for the approval gate: a short human sentence with count,
|
|
704
808
|
* byte total, and a bounded title list.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murchey/dsh-session-manager",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "基于 DeepSeek Harness 的会话存储管理插件:查看会话磁盘占用、永久删除会话,并清理侧边栏失效条目。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -64,4 +64,4 @@
|
|
|
64
64
|
"publishConfig": {
|
|
65
65
|
"access": "public"
|
|
66
66
|
}
|
|
67
|
-
}
|
|
67
|
+
}
|