// 会话删除与回收站(主机半边)。 // // 语义(与官方「归档会话」不同): // - 「归档」只把会话从列表隐藏,日志原地保留、恢复位保留; // - 「删除」把该会话的日志目录整体移入系统回收站(Windows 回收站 / // macOS 废纸篓 / XDG Trash),同时从工作区账本移除该会话槽位—— // 不保留恢复位。工作区账本槽位移除后,该会话不会再出现在列表、 // 搜索或归档集合里;若用户之后从系统回收站手动还原目录,也只会被 // 视为孤儿数据,绝不自动复活。 // // 实现要点: // 1. 目标定位优先用上游服务面权威 header,物理路径按版本演进兼容: // - 0.1.2-rc.1:sessionPersistence 公开 list / readRaw / locate; // locate() 是「副作用为零的位置提示」,JSONL 后端据此给出真实 // 文件路径(session.jsonl[.zstd]);我们移动的是它的父目录(该 // 目录归该会话独有,可含未来的会话私有工件)。 // - 0.1.3-alpha.1 起:公开面句柄化(create/open/stat/list), // readRaw / locate 不再公开,list 返回 { header, ... } 快照。 // 定位改用 stat 快照的 header;物理目录按 DSH 会话根目录布局 // `/<项目目录>/<会话 id>/` 扫描(locateSessionDirById, // 目录名精确匹配、不复制上游编码算法)。定位失败或后端类型 // 无法回收时**直接中止删除**(报错、不改动任何数据)——绝不 // 退化为「逻辑删除」(只移账本不动日志会让会话残留在列表/ // 「未分组」桶,2026-09-07 0.1.3-alpha.1 实测定界)。 // 2. 删除顺序:先移动物理目录(必须成功,失败即中止)→ 移除工作区 // 账本槽位 → 登记回收站清单 → 仅内存驻留(live)会话加入官方 // 归档集合隐藏(上游无卸载 API,见第 5 步)→ 记入已删除集合。 // 任何一步失败都保持现状并报告错误,绝不留下「列表已删但日志 // 还在原地」或「日志已删但列表还显示」的中间态。 // 3. 活跃会话:只有「正在运行」的会话拒绝删除(边写日志边移动文件 // 不安全)。已打开但空闲的会话允许删除:先 cancel(kind 'hook', // 同官方「用户取消」语义)清空待处理消息,等待 whenIdle 收敛,再 // 移走日志目录。日志写盘完全由 session/event 驱动,空闲会话不落盘, // 因此移走文件后不会「复活」;删除后内存 agent 仍驻留(上游没有按 // id 卸载 live agent 的公开 API),由归档集合隐藏,正常用户流程 // 不会再次访问它。 // 4. 所有副作用随 Fiber 可逆:路由注销、监听器移除、定时器清理。 // 5. 回收站清单:由本文件维护的内存清单 + 每项的唯一 token,通过 // 路由 /dsh-zh/api 提供给网页(列表/恢复)。清单在进程重启后 // 丢失属预期(回收站内容已由操作系统管理)。 // 6. GET /dsh-zh/api/service-monitor:「服务监控」快照——拉取即触发一次 // 主机扫描(无后台定时任务,节奏完全由网页轮询决定),仅此一个 GET // 端点。POST 同路径探活自定义监控项(与扫描并行);POST // /dsh-zh/api/service-monitor/resolve 按需解析监听进程归属(悬停触发, // 端点级缓存,服务消失即清);POST /dsh-zh/api/service-monitor/open // 按已缓存归属在文件管理器中定位监听进程目录(路径不接受请求传入); // POST /dsh-zh/api/service-monitor/rebaseline 把端点重新放入基线 // (persist:true 时为「永久监控」主机半步);POST // /dsh-zh/api/service-monitor/kill 用普通权限尝试终止监听进程 // (PID 来自已缓存归属,不接受请求传入)。 import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { readdir, rm, stat as fsStat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { PKG } from '../bin/dsh-zh.mjs' import { ZH_SETTINGS_NS } from './constants.js' import { log, warn } from './util.js' import { trashItem, restoreItem } from './trash.js' import { handleDiagnosticsRoute } from './diagnostics.js' import { handleSearchCredentialRoute } from './search-credential.js' import { handlePluginSyncRoute } from './plugin-sync.js' import { handleSearchToggleRoute } from './session-search-toggle.js' import { ensureFreshScan, getServiceMonitorSnapshot, killServiceOwner, openServiceOwnerDirectory, persistEndpointToBaseline, probeTargets, rebaselineEndpoint, resolveServiceOwner, unbaselineEndpoint } from './service-monitor.js' import type { HostContext } from './types.js' // 会话 id 校验:形如 /^[A-Za-z0-9_-]{1,128}$/ 的字符串,防御路径注入。 const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ export function isValidSessionId(id: string): boolean { return SESSION_ID_PATTERN.test(id) } // 回收站清单项(内存态):进程内可见,供面板列出与恢复。 // sessionId / title / projectName 用于展示; // originalPath 是删除前的绝对目录路径(恢复目标); // trashLocation 是回收站里的位置(win32 上与原路径相同)。 export interface TrashEntry { sessionId: string title: string /** 会话的工作目录(绝对路径);恢复时用于匹配工作区。 */ cwd: string originalPath: string trashLocation: string trashedAt: number token: string } function createSessionTrash() { const items = new Map() return { remember(entry: Omit): TrashEntry { const token = randomUUID() const full: TrashEntry = { ...entry, token } items.set(entry.sessionId, full) return full }, forget(sessionId: string): boolean { return items.delete(sessionId) }, list(): TrashEntry[] { return [...items.values()].sort((a, b) => b.trashedAt - a.trashedAt) }, get(sessionId: string): TrashEntry | undefined { return items.get(sessionId) }, } } export const sessionTrash = createSessionTrash() // 已删除会话 id 集合(进程内存):deleteSession 成功即记入(无论日志是否 // 进了回收站——locate 失败的逻辑删除同样算已删除),restoreSession 成功 // 移除。归档视图用 `/dsh-zh/api/session.deleted` 拉取它,把「已删除但 // 驻留内存」的会话从归档集合里排除:上游没有按 id 卸载 live agent 的 // 公开 API,删除驻留会话时必须把它加入官方归档集合才能从主列表隐藏, // 若不反向排除,归档视图会把已删除会话当归档行显示(2026-09 批量删除 // 回归:删除的会话出现在「未分组」的归档视图里)。 const deletedSessionIds = new Set() /** * 归档视图可见的「已删除」全集:显式集合 ∪ 回收站清单(进程生命周期内 * 的全部删除,包括插件挂载早于本次修复时已记入回收站的项)。 */ function collectDeletedSessionIds(): string[] { const ids = new Set(deletedSessionIds) for (const item of sessionTrash.list()) ids.add(item.sessionId) return [...ids] } // 惰性对账标记:session.deleted 首次查询时执行一次「归档集合 × 日志存在性」 // 对账(见 pruneDeletedSessionIds)。 let deletedSetPruned = false /** * 自愈对账:Fiber 重建(热更)会丢失进程内存态——sessionTrash 与 * deletedSessionIds 清空,但宿主进程里已删除会话的驻留摘要仍在(sessions * 服务不随本插件 Fiber 重建),归档视图会重新显示死会话。首次查询 * session.deleted 时对官方归档集合逐项 readRaw:活归档会话的日志必然在 * 磁盘(归档不动日志);readRaw 为空 = 日志已被删除移走 → 死 id,恢复进 * deletedSessionIds。历史残留的死 id(早于本修复的删除)同样被识别。 * 对账失败不影响响应(保持现状)。 */ async function pruneDeletedSessionIds(deps: DeleteDeps): Promise { if (deletedSetPruned) return deletedSetPruned = true const persistence = deps.sessionPersistence // 0.1.2-rc.1 用 readRaw、0.1.3-alpha.1 起用 stat(句柄模型),任一可用即可对账。 if (persistence === undefined || (typeof persistence.readRaw !== 'function' && typeof persistence.stat !== 'function')) return const storage = deps.storageDomain if (storage === undefined || typeof storage.get !== 'function') return let archived: readonly string[] = [] try { const domain = storage.get('workspace') as { global?: unknown } | undefined const global = domain !== undefined && domain !== null ? domain.global : undefined const state = global !== undefined && global !== null && typeof (global as { get?: unknown }).get === 'function' ? (global as { get(): { archivedSessionIds?: readonly string[] } | undefined }).get() : undefined if (state !== undefined && state !== null && Array.isArray(state.archivedSessionIds)) { archived = state.archivedSessionIds.map(String) } } catch { return } for (const id of archived) { if (deletedSessionIds.has(id)) continue let raw: unknown try { if (typeof persistence.readRaw === 'function') { raw = await persistence.readRaw(id) } else { raw = await persistence.stat!(id) } } catch { raw = undefined } if (raw === undefined || raw === null) { deletedSessionIds.add(id) continue } // 驻留内存的已删除会话:persistence.stat 会从内存快照成功返回(误报 // 「存在」),但物理日志目录已随删除进回收站。以磁盘布局扫描为准—— // 扫不到目录即死 id,否则归档视图会把僵尸行显示给用户,且「取消归档」 // 会把它捞回主列表成为不可删除的行(2026-09-25 hihi1 实测)。 if (await locateSessionDirById(id) === null) deletedSessionIds.add(id) } } // ---------- 信任围栏(与 /api 网关同策略:Host 回环 + 同源) ---------- function isLoopbackHostname(hostname: string): boolean { if (hostname === 'localhost' || hostname === '[::1]') return true const parts = hostname.split('.') return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) } function isTrustedApiRequest(req: { headers: Record }): boolean { const host = req.headers['host'] if (typeof host !== 'string') return false let hostname: string try { hostname = new URL(`http://${host}`).hostname } catch { return false } if (!isLoopbackHostname(hostname)) return false if (req.headers['sec-fetch-site'] === 'cross-site') return false const origin = req.headers['origin'] if (origin === undefined) return true if (Array.isArray(origin)) return false try { return new URL(origin).host === host } catch { return false } } // ---------- 删除/恢复编排 ---------- interface DeleteDeps { sessions?: { get(id: string): unknown } agents?: { get(id: string): unknown } sessionPersistence?: { list?(): Promise> /** DSH 0.1.2-rc.1 公开面:读取 artifact(返回 meta)与定位物理路径。 */ locate?(meta: { id: string; cwd?: string }): { kind: string; path: string } | undefined readRaw?(id: string): Promise<{ meta: { id: string; cwd?: string } } | undefined> /** * DSH 0.1.3-alpha.1 起的新公开面:`readRaw`/`locate` 不再公开, * 改用 create/open/stat/list 句柄模型。stat 返回快照(含 header), * 但不提供物理路径——物理目录由 {@link locateSessionDirById} 按 * `~/.dsh/sessions/<项目>/<会话 id>` 布局扫描定位。 */ stat?(id: string): Promise<{ header?: { id: string; cwd?: string }; meta?: { id: string; cwd?: string } } | undefined> } workspaceRegistry?: { list(): Array<{ path: string sessionIds: readonly string[] detachSession(id: string): Promise attachSession(id: string): Promise }> archivedSessionIds?: readonly string[] archiveSession?(id: string): Promise /** * 上游 2026-09-12(5f773a0ded)起公开:registry 串行链上的官方取消 * 归档(全量状态写入 + 内存缓存同步)。存在时必须优先于 storageDomain * 直写——后者绕过 registry 串行器,仅作旧版回退。 */ unarchiveSession?(id: string): Promise } storageDomain?: { get?(name: string): { global?: { get(): { archivedSessionIds?: readonly string[] } | undefined /** * 整体替换写入(DSH DomainGlobal.set 无合并):value 必须是回读到的 * 全量 state 加字段覆盖——只写单字段会丢掉 workspace 域 schema 必填 * 的 initialized/workspaceIds,下次启动 domain 打开时校验失败, * workspaceRegistry 整体挂载失败、dsh 无法启动(issue #8)。 */ set(value: { archivedSessionIds: readonly string[] }): Promise } } | undefined } } // 会话驻留内存时的收敛等待上限:取消后等待 agent 归位到空闲。 const IDLE_CONVERGE_TIMEOUT_MS = 3000 const CONFIRMED_JSONL_KINDS = new Set(['jsonl', 'jsonl-zstd']) function warnRouteFailure(context: string, error: unknown): void { const detail = error instanceof Error ? error.message : String(error) warn(`[dsh-zh] ${context}: ${detail}`) } function routeErrorMessage(code: string): string { switch (code) { case 'session-busy': return '该会话正在运行,请先停止或等待其结束。' case 'trash-failed': return '移入回收站失败,请稍后重试。' case 'locate-failed': return '无法定位会话日志目录,已中止删除(未改动任何数据)。' case 'unsupported-backend': return '该会话的日志后端不支持移入系统回收站,已中止删除(未改动任何数据)。' case 'reattach-failed': return '恢复未完成,请稍后重试。' case 'restore-failed': return '恢复失败,请稍后重试。' case 'unarchive-failed': return '取消归档失败,请稍后重试。' default: return '操作失败,请稍后重试。' } } let unarchiveFallbackWarned = false /** * 把会话从工作区归档集合移除(取消归档)。 * * 优先走上游公开 API:workspaceRegistry.unarchiveSession(上游 2026-09-12 * 起公开)在 registry 串行链上做全量状态写入并同步内存缓存,即官方取消 * 归档语义。该 API 缺席(旧版 dsh)才回退 storageDomain 直写——此时 * registry 没有公开 unarchive/事务写 API,只能绕过串行器直写持久层; * 且 global.set 是整体替换(无合并):必须回写「回读到的全量 state + * archivedSessionIds 覆盖」,只写单字段会把 workspace 域 schema 必填的 * initialized/workspaceIds 冲掉,下次启动 domain 打开校验失败、整个 * workspaceRegistry 挂载失败、dsh 无法启动(issue #8)。 * * 回退路径的写入无事务保障,但 global.set 排队在域的单一 FIFO 写链上: * set resolve 时所有先前写入均已完成,随后的同步 get 读到的是链上权威 * 真值(内存即权威,无需穿透介质)。因此写后重读一次,目标 id 仍在则 * 基于当时的全量真值重放一次覆盖写;无法覆盖的仅剩“排队更晚的官方 * archiveSession 落地覆盖本写”——那属于归档请求后到、归档生效,语义 * 本应如此。 */ export async function unarchiveSession( deps: DeleteDeps, sessionId: string, ): Promise<{ ok: boolean; changed: boolean }> { const registry = deps.workspaceRegistry if (registry !== undefined && typeof registry.unarchiveSession === 'function') { try { const wasArchived = Array.isArray(registry.archivedSessionIds) ? registry.archivedSessionIds.includes(sessionId) : true await registry.unarchiveSession(sessionId) return { ok: true, changed: wasArchived } } catch (error) { warnRouteFailure(`取消归档会话 ${sessionId} 失败`, error) return { ok: false, changed: false } } } const storage = deps.storageDomain if (storage === undefined || typeof storage.get !== 'function') return { ok: false, changed: false } if (!unarchiveFallbackWarned) { unarchiveFallbackWarned = true warn(JSON.stringify({ code: 'workspace-unarchive-partial', message: 'workspaceRegistry 未公开 unarchive API,回退 storageDomain 直写归档集合(整体替换写入,保全量 state)', })) } try { const domain = storage.get('workspace') as { global?: { get(): { archivedSessionIds?: readonly string[] } | undefined; set(value: { archivedSessionIds: readonly string[] }): Promise } } | undefined const global = domain?.global const state = global?.get() if (global === undefined || state === undefined) return { ok: false, changed: false } const archived = state.archivedSessionIds ?? [] const removeFrom = (ids: readonly string[]): readonly string[] | null => { const next = ids.filter(id => String(id) !== sessionId) return next.length !== ids.length ? next : null } const first = removeFrom(archived) if (first === null) return { ok: true, changed: false } // 整体替换:展开回读到的全量 state,只覆盖 archivedSessionIds 字段。 await global.set({ ...state, archivedSessionIds: first }) // global.set 在域 FIFO 写链上串行:resolve 后重读即链上权威真值。 // 目标 id 仍在(先前并发的 archiveSession 已落地、被本写覆盖)则基于 // 真值重放一次过滤;排队更晚的归档写覆盖本结果属于“归档后到、归档生效”。 const rereadState = global.get() const reread = rereadState?.archivedSessionIds if (rereadState !== undefined && reread !== undefined && reread.some(id => String(id) === sessionId)) { const retry = removeFrom(reread) if (retry !== null) await global.set({ ...rereadState, archivedSessionIds: retry }) } return { ok: true, changed: true } } catch (error) { warnRouteFailure(`取消归档会话 ${sessionId} 失败`, error) return { ok: false, changed: false } } } /** * DSH 会话存储根目录:`DSH_HOME/sessions`(默认 `~/.dsh/sessions`),与 * `dshHomePath('sessions')` 的部署约定一致。0.1.3-alpha.1 起公开服务面 * 不再提供物理路径,删除回收依赖它做目录扫描。 */ function sessionRootDir(): string { const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh') return join(home, 'sessions') } /** * 按会话 id 扫描定位物理日志目录:`/<项目目录>/<会话 id>/`。 * 会话 id 是安全字符(`[A-Za-z0-9_-]`,编码后目录名等于原 id),项目目录 * 名由 DSH 私有编码生成(不复制该算法),因此直接枚举根下项目目录并按 * 目录名精确匹配——对 DSH 布局算法变化免疫。目录存在即视为该会话私有 * 目录(可含未来的会话私有工件);找不到返回 null(删除退化为逻辑删除)。 */ async function locateSessionDirById(sessionId: string): Promise { const root = sessionRootDir() let projects: string[] = [] try { const entries = await readdir(root, { withFileTypes: true }) projects = entries .filter(entry => entry.isDirectory()) .map(entry => entry.name) } catch { return null // 根不存在(无会话)或不可读:不视为可定位。 } for (const project of projects) { const candidate = join(root, project, sessionId) try { const info = await fsStat(candidate) if (info.isDirectory()) return candidate } catch { // 该项目下无此会话,继续。 } } return null } /** * 定位会话的物理日志目录(绝对路径)与展示信息。 * 返回 null 表示无法定位(后端不提供 locate / 日志从未落盘)。 * * 契约演进:0.1.2-rc.1 公开 `readRaw`/`locate`(locate 给出物理路径); * 0.1.3-alpha.1 起句柄化(create/open/stat/list),公开面不再提供 * 物理路径,改由 {@link locateSessionDirById} 按布局扫描补齐。 */ async function resolveSessionTarget( deps: DeleteDeps, sessionId: string, ): Promise<{ header: { id: string; cwd?: string }; dir: string | null; kind: string | null } | null> { const persistence = deps.sessionPersistence if (persistence === undefined) return null let header: { id: string; cwd?: string } | undefined // 1) 旧契约:readRaw 读 artifact(内容包含 header),避免扫描全部会话。 if (typeof persistence.readRaw === 'function') { try { const artifact = await persistence.readRaw(sessionId) if (artifact !== undefined && artifact !== null && artifact.meta !== undefined) { header = artifact.meta } } catch { header = undefined } } // 2) 新契约:stat 返回快照(header 在快照上),不再需要读全量日志。 if (header === undefined && typeof persistence.stat === 'function') { try { const snapshot = await persistence.stat(sessionId) as | { header?: { id: string; cwd?: string } | undefined } | undefined if (snapshot !== undefined && snapshot !== null && snapshot.header !== undefined) { header = snapshot.header } } catch { header = undefined } } // 3) 兜底:list() 扫描 header(兼容旧 `{id,cwd}` 数组与新 `{header}` 快照)。 if (header === undefined && typeof persistence.list === 'function') { try { const headers = await persistence.list() const match = headers.find(candidate => { const candidateId = (candidate as { id?: string }).id ?? (candidate as { header?: { id?: string } }).header?.id return String(candidateId) === sessionId }) if (match !== undefined) { header = (match as { header?: { id: string; cwd?: string } }).header ?? (match as { id: string; cwd?: string }) } } catch { header = undefined } } if (header === undefined) return null // 4) 旧契约:locate() 给出物理路径;取父目录作为会话私有目录。 let dir: string | null = null let kind: string | null = null if (typeof persistence.locate === 'function') { try { const location = persistence.locate(header) if (location !== undefined && location !== null && typeof location.kind === 'string') { kind = location.kind if (CONFIRMED_JSONL_KINDS.has(location.kind) && typeof location.path === 'string' && location.path !== '') { const parent = dirname(location.path) if (parent !== '' && parent !== '.') dir = parent } } } catch { dir = null } } // 5) 新契约:无 locate 且 kind 未确认(确认过的非 JSONL kind 不扫描)时, // 按目录布局扫描(JSONL 后端目录存在即可回收)。 if (dir === null && kind === null) { dir = await locateSessionDirById(sessionId) if (dir !== null) kind = 'jsonl' } return { header, dir, kind } } /** * 执行会话删除。 * @param deps - 主机服务面(从 ctx.get 解析)。 * @param sessionId - 目标会话 id。 * @param options - { trash: 是否移入系统回收站;false 为彻底删除(不进回收站) }。 * @returns 结果对象;ok=false 时带 code/message。 */ export async function deleteSession( deps: DeleteDeps, sessionId: string, options: { trash: boolean; title?: string; currentSessionId?: string }, ): Promise<{ ok: true; trashed: boolean; hint?: string } | { ok: false; code: string; message: string }> { // 0) 活跃会话检查:只有「正在运行」才拒绝——运行中的 agent 一边写日志 // 一边移动文件不安全。已打开但空闲(idle)的会话允许删除(包括 // 当前正在查看的会话:客户端删除成功后会自动跳转到新会话页面): // 先取消待处理消息并等待收敛(日志写盘完全由 session/event 驱动, // 空闲会话不会落盘,移走文件不会复活)。删除后内存中该 agent 仍 // 驻留(上游没有按 id 卸载 live agent 的公开 API),删除流程会把它 // 归档隐藏,保证从列表消失。 const agent = deps.agents?.get(sessionId) as | { status?: string; cancel?: (cause: unknown, options?: unknown) => void; whenIdle?: () => Promise } | undefined if (agent !== undefined && agent !== null && agent.status === 'running') { return { ok: false, code: 'session-busy', message: '该会话正在运行,请先停止或等待其结束。' } } // 已打开但空闲:取消待处理消息并等待归位,防止删除瞬间有写入在途。 if (agent !== undefined && agent !== null) { try { if (typeof agent.cancel === 'function') { agent.cancel({ kind: 'hook', reason: 'dsh-zh 删除会话(移入回收站)' }) } if (typeof agent.whenIdle === 'function') { await Promise.race([ agent.whenIdle(), new Promise(resolve => setTimeout(resolve, IDLE_CONVERGE_TIMEOUT_MS)), ]) } } catch { // 收敛失败不阻塞删除:日志写盘只在有事件时发生,空闲会话无写入。 } } // 1) 定位物理目录。定位失败(服务面拿不到 header / 目录扫描无结果)时 // 直接中止:绝不退化为「假删除」——先 detach 账本会把已删除的会话 // 丢进官方「未分组」桶而日志仍在原地(2026-09-07 DSH 0.1.3-alpha.1 // 实测定界:sessionPersistence 公开面句柄化后 locate/readRaw 消失)。 const target = await resolveSessionTarget(deps, sessionId) if (target === null || target.dir === null || target.kind === null || !CONFIRMED_JSONL_KINDS.has(target.kind)) { // 幂等完成:会话仍驻留内存而日志目录已不在磁盘 = 此前删除已把日志 // 移入回收站(或日志从未落盘),本次属重复删除。重新确保官方归档集合 // 隐藏(可能被「取消归档」解除过)并记入已删除集合,返回成功——中止 // 会留下「查看得到却删不掉」的僵尸行(2026-09-25 hihi1 实测)。 const liveStill = deps.sessions?.get(sessionId) if (liveStill !== undefined && liveStill !== null) { const registryForHide = deps.workspaceRegistry if (registryForHide !== undefined && typeof registryForHide.archiveSession === 'function') { try { await registryForHide.archiveSession(sessionId) } catch (error) { warnRouteFailure(`重新归档已删除的驻留会话 ${sessionId} 失败`, error) } } deletedSessionIds.add(sessionId) return { ok: true, trashed: false, hint: '会话日志已在此前删除,已将其从会话列表隐藏。' } } if (target === null) { warnRouteFailure(`删除会话 ${sessionId} 中止:无法定位会话日志目录`, new Error('not-located')) return { ok: false, code: 'locate-failed', message: '无法定位会话日志目录,已中止删除(未改动任何数据)。', } } warnRouteFailure(`删除会话 ${sessionId} 中止:后端类型不支持移入回收站`, new Error(`kind=${String(target.kind)}`)) return { ok: false, code: 'unsupported-backend', message: '该会话的日志后端不支持移入系统回收站,已中止删除(未改动任何数据)。', } } const cwd = target.header.cwd ?? '' const title = options.title !== undefined && options.title !== '' ? options.title : sessionId let trashLocation = '' let dirRemoved = false try { if (options.trash) { const result = await trashItem(target.dir) trashLocation = result.location } else { await rm(target.dir, { recursive: true, force: true }) trashLocation = target.dir } dirRemoved = true } catch (error) { warnRouteFailure(`删除会话 ${sessionId} 的物理目录失败`, error) return { ok: false, code: 'trash-failed', message: '移入回收站失败,请稍后重试。', } } // 3) 工作区账本:移除该会话的槽位(「不保留恢复位」)。 const registry = deps.workspaceRegistry if (registry !== undefined) { try { const workspaces = registry.list() for (const workspace of workspaces) { if (workspace.sessionIds.includes(sessionId)) { await workspace.detachSession(sessionId) } } } catch (error) { warnRouteFailure(`移除会话 ${sessionId} 的工作区账本槽位失败`, error) // 不视为致命:日志已移走,账本残项在列表渲染时被过滤(byId 缺失)。 } } // 4) 清单登记(仅回收站路径需要;彻底删除不登记)。 if (dirRemoved && options.trash && target !== null && target.dir !== null) { sessionTrash.remember({ sessionId, title, cwd, originalPath: target.dir, trashLocation, trashedAt: Date.now(), }) } // 5) 内存驻留(live)的会话无法从会话存储移除(上游没有按 id 卸载 // live agent/session 的公开 API),删除后官方列表数据源仍会合并它 // (session-query corpus = 持久化 ∪ 内存会话),造成「删除后还在 // 列表里(只是无法对话)」。仅这类会话用官方归档集合 // (archivedSessionIds,官方语义:在所有分组不可见)隐藏——这是 // 上游限制下唯一能让它从列表消失的手段;归档视图按主机已删除 // 集合过滤,用户不会在归档里看到它。冷会话(无内存驻留)删除后 // 持久化数据源不再包含它,不触碰归档集合。 const liveAfter = deps.sessions?.get(sessionId) if (liveAfter !== undefined && registry !== undefined && typeof registry.archiveSession === 'function') { try { await registry.archiveSession(sessionId) } catch (error) { warnRouteFailure(`归档已删除的驻留会话 ${sessionId} 失败`, error) } } deletedSessionIds.add(sessionId) return { ok: true, trashed: dirRemoved && options.trash, } } /** * 从回收站恢复一个会话目录(并重新挂回工作区账本)。 * @param deps - 服务面解析器。 * @param entry - 回收站条目。 * @param restoreItemImpl - 恢复实现(可注入;默认平台实现,测试用)。 */ export async function restoreSession( deps: DeleteDeps, entry: TrashEntry, restoreItemImpl: (location: string, originalPath: string) => Promise = restoreItem, ): Promise<{ ok: true } | { ok: false; code: string; message: string }> { try { await restoreItemImpl(entry.trashLocation, entry.originalPath) } catch (error) { warnRouteFailure(`恢复会话 ${entry.sessionId} 失败`, error) return { ok: false, code: 'restore-failed', message: `恢复失败:${error instanceof Error ? error.message : String(error)}` } } // 重新挂回工作区:找到 cwd 匹配的 workspace(恢复后日志已回原位)。 const registry = deps.workspaceRegistry let reattachFailed = registry === undefined if (registry !== undefined) { try { const workspaces = registry.list() const workspace = workspaces.find(candidate => candidate.path === entry.cwd) if (workspace === undefined) { reattachFailed = true } else if (!workspace.sessionIds.includes(entry.sessionId)) { await workspace.attachSession(entry.sessionId) } } catch (error) { reattachFailed = true warnRouteFailure(`恢复会话 ${entry.sessionId} 后重新挂载工作区失败`, error) } } // 删除驻留内存会话时会把它加入归档集合(隐藏);恢复后取消归档。 // attach 已失败时跳过:失败路径不动账本(归档集合保留、条目可重试), // 避免"目录已恢复+归档集合被顺手清理"的部分副作用泄漏。 const unarchive = reattachFailed ? { ok: false as const, changed: false } : await unarchiveSession(deps, entry.sessionId) if (reattachFailed || !unarchive.ok) { return { ok: false, code: 'reattach-failed', message: '目录已恢复到原位置,但重新挂载工作区/取消归档未完成;请重试恢复或在列表刷新后检查', } } sessionTrash.forget(entry.sessionId) deletedSessionIds.delete(entry.sessionId) return { ok: true } } // ---------- HTTP 路由 ---------- interface RouteRequest { method?: string url?: string headers: Record [Symbol.asyncIterator](): AsyncIterator } interface RouteResponse { statusCode: number writeHead(status: number, headers?: Record): void end(body?: string | Uint8Array): void } /** 请求携带的刷新间隔(秒)→ 缓存允许的最大年龄(毫秒);非法回退 0(要求最新)。 */ function parseScanMaxAgeMs(raw: unknown): number { const seconds = typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : Number.parseInt(String(raw ?? ''), 10) if (!Number.isFinite(seconds) || seconds <= 0) return 0 return Math.min(300, seconds) * 1000 } async function handleServiceMonitorRoutes( req: RouteRequest, res: RouteResponse, pathname: string, payload: Record, url: URL, ): Promise { if (pathname !== '/dsh-zh/api/service-monitor') return false const maxAgeMs = req.method === 'GET' ? parseScanMaxAgeMs(url.searchParams.get('intervalSec')) : parseScanMaxAgeMs(payload.intervalSec) // 拉取即查缓存:距上次扫描超过网页设置的刷新间隔才重新扫描,否则 // 直接返回缓存(POST 的自定义项探活与扫描/缓存判定并行执行)。 const [, probeResults] = await Promise.all([ ensureFreshScan(process.platform, maxAgeMs), req.method === 'GET' ? Promise.resolve([]) : probeTargets(payload.targets), ]) writeJson(res, 200, { ok: true, value: Object.assign(getServiceMonitorSnapshot(), { targets: probeResults as unknown[], }) }) return true } async function readJsonBody(req: RouteRequest): Promise { const chunks: Uint8Array[] = [] let total = 0 for await (const chunk of req) { const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk) total += buffer.length if (total > 1 << 20) throw new Error('request body too large') chunks.push(buffer) } const text = Buffer.concat(chunks).toString('utf8') if (text.trim() === '') return {} try { return JSON.parse(text) as unknown } catch { throw new Error('request body is not valid JSON') } } function writeJson(res: RouteResponse, status: number, body: unknown): void { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) res.end(JSON.stringify(body)) } /** * 安装 /dsh-zh/api 路由:会话删除 / 回收站列表 / 恢复 + GET 服务监控快照。 * @param ctx - 主机上下文(webServer 服务)。 * @param deps - 服务面解析器(惰性读取,支持 HMR 后重新解析)。 */ export function installSessionDeleteRoute(ctx: HostContext, deps: () => DeleteDeps): void { const install = function (): boolean { const webServer = ctx.get('webServer') if (webServer === undefined || webServer === null || typeof webServer.register !== 'function') { return false } const handler = async (req: RouteRequest, res: RouteResponse): Promise => { if (!isTrustedApiRequest(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } }) return } const url = new URL(req.url ?? '/', 'http://dsh.internal') const pathname = url.pathname // GET 仅服务「服务监控」快照与运行态诊断:拉取即查缓存(超过网页设置的刷新 // 间隔才重新扫描,间隔经查询参数 intervalSec 携带)。 if (req.method === 'GET') { if (handleDiagnosticsRoute(req, res, pathname)) return if (handleSearchCredentialRoute(req, res, pathname, null)) return if (handleSearchToggleRoute(req, res, pathname, null, url)) return if (await handlePluginSyncRoute(req, res, pathname, null, ctx)) return if (await handleServiceMonitorRoutes(req, res, pathname, {}, url)) return writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method' } }) return } if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } }) return } let payload: Record try { const body = await readJsonBody(req) if (body === null || typeof body !== 'object' || Array.isArray(body)) { writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'payload must be a JSON object' } }) return } payload = body as Record } catch (error) { writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) return } try { if (handleSearchCredentialRoute(req, res, pathname, payload)) return if (handleSearchToggleRoute(req, res, pathname, payload, url)) return if (await handlePluginSyncRoute(req, res, pathname, payload, ctx)) return if (await handleServiceMonitorRoutes(req, res, pathname, payload, url)) return if (pathname === '/dsh-zh/api/service-monitor/unbaseline') { // 把基线端点移出基线(右栏 tab「基线端口」区点击恢复监控): // 请求体 { address, port };命中基线则立即入监控列表并返回 // 新快照,未命中(已监控中/已停止监听)返回 404 语义。 const address = typeof payload.address === 'string' ? payload.address : '' const port = typeof payload.port === 'number' && Number.isFinite(payload.port) ? Math.round(payload.port) : 0 if (address === '' || port < 1 || port > 65535) { writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'address/port required' } }) return } const restored = unbaselineEndpoint(address, port, Date.now()) if (!restored) { writeJson(res, 404, { ok: false, error: { code: 'not-in-baseline', message: '端点不在基线中(可能已在监控或已停止监听)' } }) return } writeJson(res, 200, { ok: true, value: Object.assign(getServiceMonitorSnapshot(), { targets: await probeTargets(payload.targets).catch(() => []), }) }) return } if (pathname === '/dsh-zh/api/service-monitor/resolve') { // 按需解析监听进程归属(悬停触发):请求体 { address, port }, // 端点级缓存;服务停止监听后缓存由扫描清运,重现后重新解析。 // 目标不是本机监听时 value.owner 为 null(不缓存,不报错)。 try { const owner = await resolveServiceOwner(process.platform, payload.address, payload.port) writeJson(res, 200, { ok: true, value: { owner } }) } catch (error) { writeJson(res, 200, { ok: true, value: { owner: null } }) } return } if (pathname === '/dsh-zh/api/service-monitor/open') { // 在文件管理器中定位监听进程目录:请求体 { address, port }, // 主机读取该端点**已缓存**的归属(悬停查询过才有)后执行平台 // reveal 命令;无缓存归属时返回 404,绝不接受请求传入的路径。 try { const opened = await openServiceOwnerDirectory(process.platform, payload.address, payload.port) if (opened === null) { writeJson(res, 404, { ok: false, error: { code: 'owner-unavailable', message: '未定位到监听进程目录' } }) return } writeJson(res, 200, { ok: true, value: opened }) } catch (error) { warnRouteFailure('打开服务目录失败', error) writeJson(res, 500, { ok: false, error: { code: 'open-failed', message: '打开服务目录失败,请稍后重试。' } }) } return } if (pathname === '/dsh-zh/api/service-monitor/rebaseline') { // 「排除监控」(右栏 tab 条目按钮):请求体 { address, port }, // persist:true 时为「永久监控」的主机半步(端点入基线 + // 客户端把它写入设置的自定义监控项)。成功返回新快照, // 未命中(已在基线/已停止监听)返回 404 语义。 const address = typeof payload.address === 'string' ? payload.address : '' const port = typeof payload.port === 'number' && Number.isFinite(payload.port) ? Math.round(payload.port) : 0 if (address === '' || port < 1 || port > 65535) { writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'address/port required' } }) return } const changed = payload.persist === true ? persistEndpointToBaseline(address, port) : rebaselineEndpoint(address, port) if (!changed) { writeJson(res, 404, { ok: false, error: { code: 'not-monitored', message: '端点不在监控列表中(可能已在基线或已停止监听)' } }) return } writeJson(res, 200, { ok: true, value: Object.assign(getServiceMonitorSnapshot(), { targets: await probeTargets(payload.targets).catch(() => []), }) }) return } if (pathname === '/dsh-zh/api/service-monitor/kill') { // 「终止进程」(右栏 tab 条目按钮):请求体 { address, port }, // 主机读取该端点**已缓存**的归属(悬停查询过才有)后用普通 // 权限执行平台终止命令;无缓存归属/PID 或命令失败返回 404。 // PID 永远来自主机进程枚举,绝不接受请求传入。 try { const killed = await killServiceOwner(process.platform, payload.address, payload.port) if (killed === null) { writeJson(res, 404, { ok: false, error: { code: 'kill-unavailable', message: '未定位到可终止的监听进程,请先悬停条目解析归属' } }) return } writeJson(res, 200, { ok: true, value: killed }) } catch (error) { warnRouteFailure('终止监听进程失败', error) writeJson(res, 500, { ok: false, error: { code: 'kill-failed', message: '终止进程失败(权限不足或进程已退出),请稍后重试。' } }) } return } if (pathname === '/dsh-zh/api/session.deleted') { // 已删除会话集合(归档视图过滤用):deleteSession 成功即记入, // 恢复成功移除;先做一次自愈对账(热更丢内存态时从归档集合 × // 日志存在性恢复死 id),再并入回收站清单。 await pruneDeletedSessionIds(deps()) writeJson(res, 200, { ok: true, value: { ids: collectDeletedSessionIds() } }) return } if (pathname === '/dsh-zh/api/session.unarchive') { const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : '' if (!isValidSessionId(sessionId)) { writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'invalid sessionId' } }) return } const result = await unarchiveSession(deps(), sessionId) if (!result.ok) { writeJson(res, 400, { ok: false, error: { code: 'unarchive-failed', message: 'unarchive failed' } }) return } writeJson(res, 200, { ok: true, value: { unarchived: true, changed: result.changed } }) return } if (pathname === '/dsh-zh/api/session.delete') { const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : '' if (!isValidSessionId(sessionId)) { writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'invalid sessionId' } }) return } const title = typeof payload.title === 'string' ? payload.title : '' const currentSessionId = typeof payload.currentSessionId === 'string' ? payload.currentSessionId : '' const result = await deleteSession(deps(), sessionId, { trash: true, title, ...(currentSessionId === '' ? {} : { currentSessionId }), }) if (!result.ok) { writeJson(res, 400, { ok: false, error: { code: result.code, message: routeErrorMessage(result.code) } }) return } writeJson(res, 200, { ok: true, value: { ...result, deletedIds: collectDeletedSessionIds() } }) return } if (pathname === '/dsh-zh/api/trash.list') { writeJson(res, 200, { ok: true, value: { items: sessionTrash.list() } }) return } if (pathname === '/dsh-zh/api/trash.restore') { const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : '' const token = typeof payload.token === 'string' ? payload.token : '' if (!isValidSessionId(sessionId) || token === '') { writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'invalid sessionId or token' } }) return } const entry = sessionTrash.get(sessionId) if (entry === undefined || entry.token !== token) { writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'trash entry not found' } }) return } const result = await restoreSession(deps(), entry) if (!result.ok) { writeJson(res, 400, { ok: false, error: { code: result.code, message: routeErrorMessage(result.code) } }) return } writeJson(res, 200, { ok: true, value: { restored: true } }) return } writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method' } }) } catch (error) { warnRouteFailure('API 路由处理失败', error) writeJson(res, 500, { ok: false, error: { code: 'internal', message: '服务器内部错误,请稍后重试。' } }) } } const disposer = webServer.register({ kind: 'prefix', path: '/dsh-zh/api', handler, }) ctx.effect(() => disposer, 'dsh-zh: /dsh-zh/api routes') log('「删除会话(回收站)」路由已就绪') return true } if (!install()) { // webServer 晚于本插件出现:等待 internal/service 事件。 // 退订用 ctx.on 返回的 disposer(cordis 没有 ctx.off)。 let stopRetry: (() => unknown) | undefined const retryService = function (name: string): void { if (name !== 'webServer') return if (!install()) return const dispose = stopRetry stopRetry = undefined if (typeof dispose === 'function') dispose() } stopRetry = ctx.on('internal/service', retryService) } }