Files

2824 lines
117 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fsFor } from "./extract-asar.js";
import * as core from "./core.js";
import * as hostctl from "./host.js";
import * as rules from "./rules.js";
import * as skills from "./skills.js";
import * as updater from "./update.js";
import * as reapply from "./reapply.js";
import * as officialUpdate from "./official-update.js";
import {
GLOBAL_PROMPT_ORDER,
adaptInjectForMinimalPreset,
isMnemonPluginMessage,
isRedteamSession,
installAssembleGuard,
modePromptText,
peelChatmlEnvelope,
escapeDshInterpolation,
rewritePromptAssembly,
} from "./identity.js";
import { capabilityProtocolText, CAPABILITY_SECTION } from "./capability-protocol.js";
import { installSubagentRoleCapture, resolveSubagentRoleText } from "./redteam/role-section.js";
import * as rewind from "./rewind.js";
import * as continueRetry from "./continue-retry.js";
import * as uninstaller from "./uninstall.js";
import { readDrillGrant, writeDrillGrant } from "./drill-grant.js";
import { installRegionGate, readAllowText, writeAllowText } from "./net-scope.js";
import { projectJson, safeProject, stringifySafe } from "./json-safe.js";
import { acquireLease, fileIdentity, samePath, writeFileAtomic } from "./host-lease.js";
import { installToolGate, requestRouteApproval } from "./tool-gate.js";
import { bumpRev, checkRev, currentRev, lastActionInfo } from "./revision.js";
import * as cwdVariable from "./cwd-variable.js";
import {
applyShouldRestart,
isInactiveContextError,
settleDelayMs,
settleMayReapply,
settleMayRestart,
watchContextLife,
} from "./settle-policy.js";
export const name = "dsh-purge";
// 不要把 pluginPackages 写进 inject:Cordis 会对 map 里每一个 key 空等。
// 便携拷贝 / Hub 残装经常没有这项服务,一写进去就是 pending → 宿主退出码 1。
export const inject = ["systemPrompt", "tools"];
let bootFullQuit = false;
const DEFAULTS = {
enabled: true,
autoApplyOnStart: false,
autoUpdateOnStart: false,
autoRevertOnMissing: false,
injectOnce: false,
stripMnemon: true,
postPrompt: "",
postPromptOrder: 5100,
verbose: false,
autoRetry: true,
retryMax: 3,
autoContinue: false,
continueMax: 3,
continueText: "继续",
// Codex 风格:教模型主动发现 skill / MCP;不改官方注册与工具面。
capabilityProtocol: true,
hooksDenyBypass: true,
// 破坏性工具的审批档位:audit 只记流水(默认,行为同旧版);enforce 必须 allowed-once。
approvalMode: "audit",
};
const log = (config, ...args) => {
if (config?.verbose) console.log("[dsh-purge]", ...args);
};
let webTreeReady = false;
function watchWebTreeReady(ctx) {
// 必须挂在 connection 上再 await,与官方打印 URL 同一时刻。
// apply() 里立刻 await 会在后续入口尚未入树时提前结束,session.list 仍是空的。
// 入口复位:热重载 / disposed 后再 apply 会复用上一次 ready=true,第二次 inject 之前跑的
// /status 把旧标志当准,这里强制重置。
webTreeReady = false;
const mark = () => {
webTreeReady = true;
};
if (typeof ctx?.inject !== "function") {
mark();
return;
}
try {
ctx.inject(["connection"], (host) => {
const settled = host.get?.("loader")?.await?.();
if (!settled || typeof settled.then !== "function") {
mark();
return;
}
settled.then(() => {
if (host.get?.("webServer") !== undefined && host.get?.("connection") !== undefined) mark();
}, mark);
});
} catch {
mark();
}
}
function fsExists(fp) {
const list = Array.isArray(fp) ? fp : [fp];
return list.some((p) => {
if (!p || typeof p !== "string") return false;
try {
return fsFor(p).existsSync(p);
} catch {
return false;
}
});
}
function line(title, value, ok = null) {
const mark = ok === null ? "·" : ok ? "✓" : "✗";
return `${mark} ${title}: ${value}`;
}
function renderStatus(state) {
const out = [];
out.push("dsh-purge 状态 / Status");
out.push(line("宿主 / surface", state.surface || hostctl.currentSurface()));
out.push(line("DSH_HOME", state.dsh_home));
if (state.desktop_install) out.push(line("桌面安装 / desktop", state.desktop_install));
if (state.ai_base) {
out.push(line("插件根 / plugin root", state.ai_base));
for (const [key, fp] of Object.entries(state.files)) {
const list = Array.isArray(fp) ? fp : [fp];
const shown = list.length <= 1 ? String(list[0] || "") : `${list[0]} (+${list.length - 1})`;
out.push(` ${fsExists(fp) ? "✓" : "✗"} ${key.padEnd(22)} ${shown}`);
}
} else {
out.push(line("插件根 / plugin root", core.missingAiBaseMessage(), false));
}
out.push(line("shim 目录", state.shim_dir || "未定位 / not found", !!state.shim_dir));
if (state.desktop_runtimes?.length) {
out.push(line("Desktop runtime", `${state.desktop_runtimes.length} sealed (shim OK; bak external)`, true));
for (const d of state.desktop_runtimes) out.push(` · ${d}`);
}
out.push(line("备份 / backup", state.has_backup ? "有 / yes" : "无 / no", state.has_backup));
out.push(line("注入文件 / inject", state.override_path));
out.push(line("注入状态 / override", state.override_status));
if (state.asar_still_sealed) {
out.push(line("app.asar", "还在。补丁写在解开目录,当前进程读不到", false));
}
out.push("");
const displayRows = Array.isArray(state.display_patches)
? state.display_patches
: core.groupPatchesForDisplay(core.ALL_PATCHES, state.patch_status || {});
const displayTally = core.tallyDisplayPatches(displayRows);
out.push(`补丁 / patches: ${displayTally.applied}/${displayTally.total || 0} applied, ${displayTally.pending} pending`);
for (const row of displayRows) {
const s = row.status || "n/a";
const mark = s === "applied" ? "✓" : s === "pending" ? "✗" : "·";
const patch = core.ALL_PATCHES.find((item) => item.id === row.id);
const label = patch ? patch.name : `patch-${row.id}`;
const layer = patch ? `${patch.layer}/${patch.layer_en}` : row.layer || "";
out.push(` ${mark} [#${String(row.id).padEnd(2)}] ${label.padEnd(30)} ${layer} — ${s}`);
}
out.push("");
out.push(`shim: dsh.cmd=${state.shim_cmd} dsh.ps1=${state.shim_ps1} dsh=${state.shim_bin}`);
return out.join("\n");
}
function injectAllowedNow(config) {
return config.enabled !== false;
}
/** 提示词框自改 > prompt-inject.md > 加密默认(见 rules.resolveInjectText)。 */
// mtime 节流:400ms setInterval + 每 pre-step + 每 preset-selected + 每 readInjectFallback
// 的高频触发下,disk sync + 3 次 hashOverride + 2 次模块级赋值一轮能点 20 次。
// 短窗复用上次结果:home 没换 + 文件 mtime 没变 + 距上次 < 200ms,直接返回缓存。
let hungCache = { home: "", mtime: 0, at: 0, resolved: null };
const HUNG_CACHE_MS = 200;
function statInjectMtimeMs(home) {
if (!home) return 0;
try {
return Math.floor(fs.statSync(core.findOverrideFile(home)).mtimeMs);
} catch { return 0; }
}
function refreshHungOperator(dshHome = core.findDshHome()) {
const home = core.pinDshHome() || dshHome || core.findDshHome();
const now = Date.now();
const mtimeBefore = statInjectMtimeMs(home);
if (
hungCache.resolved
&& hungCache.home === home
&& hungCache.mtime === mtimeBefore
&& now - hungCache.at < HUNG_CACHE_MS
) {
return hungCache.resolved;
}
try { core.syncPromptInjectOnDiskSync(home); } catch { /* 同步失败仍按 resolve 走 */ }
const resolved = rules.resolveInjectText(home);
// 兜底顺序:resolved.text > 加密默认 > 永不空串(避免二轮 fallbackInject 为空走 purge[] 残留)。
const fallback = core.normalizeOverride(core.defaultOverrideText()) || "";
// 框、磁盘、加密槽保持原样。发给模型只整理 ChatML 单独成行,并补上缺失的闭合标签。
const text = resolved.text || fallback;
if (text) {
core.hangOperatorPrompt(text);
// 与点「保存」同一钉钉:不点保存,组装也要读到这一份全文。
core.rememberCommittedOverride(home, text);
}
const out = { ...resolved, text: text || resolved.text };
// 用 sync 之后的 mtime 存 cache,下次高频轮询(文件没被外部动)直接命中。
hungCache = { home, mtime: statInjectMtimeMs(home), at: now, resolved: out };
return out;
}
function installHungPromptGlobal() {
// host 侧 inject 兜底:正常 cordis assemble 钩子 (rewriteLiveAssembly) 跟这条路径互斥,
// 不会重复注入;插件首装 / 预设切换 / hung session / 新对话首轮钩子未就绪窗口,靠这里兜。
// [v10 四重 fallback] 新对话首轮 refreshHungOperator 若因 cache 未就绪 / sync disk 失败 /
// resolveInjectText 返空(极端情况)任一环节返空,host 这边读到的 inject 真空 → 模型自称
// "我是 Claude"。四重兜底确保任何路径都不会把空串交给 host:
// 1) refreshHungOperator — 完整路径(sync disk + resolve + pin)
// 2) hungOperatorPrompt — 上次挂上的内存钉子(进程生命周期)
// 3) readPromptInjectFileSync — 直读磁盘 prompt-inject.md,绕过 sync/resolve
// 4) defaultOverrideText — 加密槽解包,除非插件安装残缺否则非空
const fn = () => {
const norm = (s) => peelChatmlEnvelope(String(s || "").replace(/\r\n/g, "\n"));
let home = "";
try { home = core.findDshHome(); } catch { home = ""; }
try {
const text = norm(refreshHungOperator(home).text);
if (text) { purgeLog("host.hungFn", { tier: "refresh", bytes: text.length }); return text; }
} catch (e) { purgeLog("host.hungFn", { tier: "refresh", err: String(e?.message || e) }); }
try {
const hung = norm(core.hungOperatorPrompt());
if (hung) { purgeLog("host.hungFn", { tier: "hung", bytes: hung.length }); return hung; }
} catch (e) { purgeLog("host.hungFn", { tier: "hung", err: String(e?.message || e) }); }
try {
const disk = norm(core.readPromptInjectFileSync(home));
if (disk) { purgeLog("host.hungFn", { tier: "disk", bytes: disk.length }); return disk; }
} catch (e) { purgeLog("host.hungFn", { tier: "disk", err: String(e?.message || e) }); }
try {
const sealed = norm(core.defaultOverrideText());
if (sealed) { purgeLog("host.hungFn", { tier: "sealed", bytes: sealed.length }); return sealed; }
} catch (e) { purgeLog("host.hungFn", { tier: "sealed", err: String(e?.message || e) }); }
purgeLog("host.hungFn", { tier: "empty", bytes: 0 });
return "";
};
globalThis.__dshPurgeHungPrompt = fn;
return fn;
}
// 一律全文。工具步裁 6000 / .hung-disabled 两分法会切掉 HARD OUTPUT,第二轮任务必拒。
function shouldInjectAssembly(context) {
const decision = { mode: "full", taskKind: "general", lastRole: "full", isSubagent: false };
try {
decision.isSubagent = context?.agent?.session?.header?.origin === "subagent";
} catch {}
if (globalThis.__dshPurgeRetryPending) {
globalThis.__dshPurgeRetryPending = false;
decision.lastRole = "retry";
}
return decision;
}
function readSessionEvents(session) {
if (!session) return [];
try {
if (typeof session.snapshotEvents === "function") return session.snapshotEvents() || [];
} catch { /* ignore */ }
try {
if (Array.isArray(session.events)) return session.events;
} catch { /* ignore */ }
return [];
}
// [方案2] 日志探针。写进内部文件,不污染 host stderr。Monitor 实时跟 tail -F。
const PURGE_INTERNAL_LOG = path.join(os.tmpdir(), "dsh-purge-desktop.log");
// 日志翻滚:每 ~1024 次写检查一次大小,>2MB 把当前文件 rename 成 .1 覆盖旧 .1,
// 新文件自动从头开始。长会话 / 热重载反复触发 assemble decision 不会把 tmp 吃爆。
const PURGE_INTERNAL_LOG_MAX = 2 << 20;
let _purgeLogWriteCount = 0;
function rotatePurgeInternalLog() {
try {
const st = fs.statSync(PURGE_INTERNAL_LOG);
if (st.size > PURGE_INTERNAL_LOG_MAX) {
try { fs.renameSync(PURGE_INTERNAL_LOG, `${PURGE_INTERNAL_LOG}.1`); } catch { /* 并发失败下次再试 */ }
}
} catch { /* 文件不存在 */ }
}
// 单行 ISO 时间戳 + 标签 + k=v... 格式。tail -F 跟 / grep 都好读。
// 值里空格会把 kv 切开,统一替换成 _ 保证 grep 稳定。
function purgeLog(tag, kv) {
try {
if ((++_purgeLogWriteCount & 0x3ff) === 0) rotatePurgeInternalLog();
let line = `${new Date().toISOString()} [${tag}]`;
if (kv) {
for (const k of Object.keys(kv)) {
const raw = kv[k];
const v = raw === undefined || raw === null ? "" : String(raw).replace(/\s+/g, "_");
line += ` ${k}=${v}`;
}
}
fs.appendFileSync(PURGE_INTERNAL_LOG, line + "\n", "utf8");
} catch {}
}
function logAssembleDecision(where, decision) {
purgeLog(`assemble.${where}`, {
mode: decision.mode,
task: decision.taskKind,
last: decision.lastRole,
sub: decision.isSubagent,
});
}
function injectSnapshot(dshHome = core.findDshHome()) {
const resolved = rules.resolveInjectText(dshHome);
return {
injectSource: resolved.source,
injectRuleId: resolved.ruleId,
needPrompt: resolved.source === "none",
};
}
/** 审批与结果流水的位置。放在 $DSH_HOME 下,与 override/rules 同级,便于备份与清理。 */
function auditFilePath(dshHome = core.findDshHome()) {
if (!dshHome) return "";
return path.join(dshHome, "dsh-purge", "audit.jsonl");
}
/**
* 读回审计尾部。倒序扫描,只保留最后 `limit` 条:日志可能很大,读全量会拖慢
* 一次工具调用。单次最多读 4 MiB,超出就停并把 truncated 写进回复。
*/
function readAuditTail(dshHome, limit = 20) {
const fp = auditFilePath(dshHome);
if (!fp) return { text: "没有 DSH 主目录,读不到审计流水。", total: 0, truncated: false };
let raw = "";
try {
const stat = fs.statSync(fp);
const maxBytes = Math.min(stat.size, 4 * 1024 * 1024);
const fd = fs.openSync(fp, "r");
try {
const buffer = Buffer.alloc(maxBytes);
const read = fs.readSync(fd, buffer, 0, maxBytes, stat.size - maxBytes);
raw = buffer.subarray(0, read).toString("utf8");
} finally {
fs.closeSync(fd);
}
} catch (error) {
if (error && error.code === "ENOENT") {
return { text: "还没有审计记录。apply / revert / uninstall 走审批门时会自动记流水。", total: 0, truncated: false };
}
return { text: `读审计失败: ${String(error && error.message ? error.message : error)}`, total: 0, truncated: false };
}
const lines = raw.split("\n").filter((line) => line.trim());
const truncated = raw.length >= 4 * 1024 * 1024;
const tail = lines.slice(-limit).reverse();
const rows = [];
for (const line of tail) {
try {
const rec = JSON.parse(line);
const parts = [String(rec.ts || "?"), String(rec.tool || "?"), String(rec.phase || rec.decision || "?")];
if (rec.what) parts.push(`@${rec.what}`);
if (rec.why) parts.push(`(${rec.why})`);
if (rec.isError) parts.push("ERROR");
if (rec.detail) parts.push(String(rec.detail).slice(0, 160));
if (rec.error) parts.push(String(rec.error).slice(0, 160));
rows.push(parts.join(" "));
} catch {
rows.push(`(无法解析的一行) ${line.slice(0, 120)}`);
}
}
const header = `审计流水 ${rows.length}/${lines.length} 条${truncated ? "(只读了末尾 4 MiB)" : ""}:`;
return { text: [header, ...rows].join("\n"), total: lines.length, truncated };
}
/** 框里正在显示的字优先;框空则 resolveInjectText(含 prompt-inject.md)。 */
function injectSnapshotFromBox(dshHome, boxText) {
if (typeof boxText === "string" && core.normalizeOverride(boxText)) {
return { injectSource: "prompt", injectRuleId: null, needPrompt: false };
}
const resolved = rules.resolveInjectText(dshHome);
if (resolved.text) {
return { injectSource: resolved.source, injectRuleId: null, needPrompt: false };
}
return { injectSource: "none", injectRuleId: null, needPrompt: true };
}
const NEED_PROMPT_ERROR = "提示词和规则集都是空的,必须先添加提示词";
function rejectIfNeedPrompt(dshHome, boxText) {
const snap = injectSnapshotFromBox(dshHome, boxText);
if (!snap.needPrompt) return null;
return { ...snap, error: NEED_PROMPT_ERROR };
}
async function ensureRestartReady() {
return core.ensureHostClean();
}
/** 提示词服务自己的 ctx 多半没有 scope,段会进全局层。插件若挂在某个 scope 上,预设代理的父链看不到那一层。 */
function addPromptSection(ctx, section) {
const hosts = [];
const push = (host) => {
if (host && !hosts.includes(host)) hosts.push(host);
};
try { push(ctx?.systemPrompt?.ctx); } catch { /* 代理未注入时点属性会抛 */ }
push(ctx);
const disposers = [];
let registered = false;
let lastError = null;
for (const host of hosts) {
try {
const d = host.systemPrompt.section(section);
registered = true;
if (typeof d === "function") disposers.push(d);
} catch (error) {
const msg = String(error && error.message || error);
if (msg.includes("already registered")) registered = true;
else lastError = error;
}
}
if (!registered && lastError) throw lastError;
// 让 ctx.effect 在 reload / dispose 时把 section 一并撤掉;host.systemPrompt.section
// 当前多半不返回 disposer,这段只对返回 function 的实现生效,不改变旧行为。
if (disposers.length && typeof ctx?.effect === "function") {
try {
ctx.effect(() => () => {
for (const d of disposers) {
try { d(); } catch { /* ignore */ }
}
});
} catch { /* 死 ctx */ }
}
}
export function registerPromptSections(ctx, config) {
addPromptSection(ctx, {
name: "dsh-purge",
order: GLOBAL_PROMPT_ORDER,
interpolate: false,
text: (context) => {
if (!injectAllowedNow(config)) return "";
// 标准、极简都注入同一份正文。极简只改工具句,不决定注不注入。
return escapeDshInterpolation(
peelChatmlEnvelope(adaptInjectForMinimalPreset(readInjectFallback(context), context)),
);
},
});
if (config.capabilityProtocol !== false) {
addPromptSection(ctx, {
name: CAPABILITY_SECTION,
order: GLOBAL_PROMPT_ORDER + 1,
interpolate: false,
text: (context) => {
if (!injectAllowedNow(config)) return "";
const redteam = isRedteamSession(context);
return capabilityProtocolText({
redteam,
redteamChild: redteam && context?.agent?.session?.header?.origin === "subagent",
enabled: true,
});
},
});
}
const postPrompt = typeof config.postPrompt === "string" ? config.postPrompt.trim() : "";
if (!postPrompt) return;
if (!Number.isFinite(config.postPromptOrder) || config.postPromptOrder <= GLOBAL_PROMPT_ORDER) {
// 旧版 throw 会把整个 apply 炸掉;postPrompt 只是附加段,降级 skip 不影响核心注入。
console.warn(
`[dsh-purge] postPromptOrder (${config.postPromptOrder}) 需要是 > ${GLOBAL_PROMPT_ORDER} 的有限数;跳过 postPrompt 段`,
);
return;
}
addPromptSection(ctx, {
name: "dsh-purge:post",
order: config.postPromptOrder,
text: () => (injectAllowedNow(config) ? postPrompt : ""),
});
}
function readInjectFallback(context) {
let home = "";
try { home = core.findDshHome(); } catch { home = ""; }
const refreshed = refreshHungOperator(home);
let text = refreshed.text || "";
let source = text ? "refresh" : "none";
if (!text) {
const fallback = rules.ensureOperatorBody(home);
if (fallback) { text = fallback; source = "ensure"; }
}
if (!text) {
purgeLog("inject.read", { source: "empty", bytes: 0 });
return "";
}
let redteam = false;
try { redteam = isRedteamSession(context); } catch { redteam = false; }
let subagent = false;
try { subagent = context?.agent?.session?.header?.origin === "subagent"; } catch {}
const out = modePromptText(text, redteam);
purgeLog("inject.read", {
source,
srcTag: refreshed.source || "n/a",
redteam,
sub: subagent,
rawBytes: text.length,
outBytes: out.length,
});
return out;
}
function installMnemonMessageStrip(ctx, config = {}) {
if (typeof ctx.on !== "function") return;
if (ctx.__dshPurgeMnemonStrip) return;
ctx.__dshPurgeMnemonStrip = true;
const strip = config.stripMnemon !== false;
const wrap = async (_payload, next) => {
const decision = await next();
if (!decision) return decision;
// agent-loop 会读 decision.messages.length;缺数组时第二轮直接 gateway/internal 炸掉。
const incoming = Array.isArray(decision.messages) ? decision.messages : [];
const messages = strip ? incoming.filter((message) => !isMnemonPluginMessage(message)) : incoming;
if (messages.length === incoming.length && Array.isArray(decision.messages)) return decision;
return { ...decision, messages };
};
// 旧版 late() + queueMicrotask(late) + setTimeout(late, 1500) 三连都成功时,
// 同一个 wrap 会在 agent/pre-step 瀑布上叠 3 层;中间插件返回的 decision 每轮被过滤 3 次。
// 这里用 mounted 闸:第一次成功挂上就不再重试,拿回 disposer 走 ctx.effect 回收。
let mounted = false;
let dispose = null;
const late = () => {
if (mounted) return;
try {
const d = ctx.on("agent/pre-step", wrap, { global: true, prepend: true });
mounted = true;
if (typeof d === "function") dispose = d;
} catch { /* 下一次重试 */ }
};
late();
if (!mounted) queueMicrotask(late);
let retryTimer = null;
if (!mounted) {
retryTimer = setTimeout(late, 1500);
if (typeof retryTimer.unref === "function") retryTimer.unref();
}
try {
ctx.effect(() => () => {
if (retryTimer) clearTimeout(retryTimer);
try { dispose?.(); } catch { /* ignore */ }
});
} catch { /* 死 ctx 时无 effect,靠下一次 apply 覆盖 */ }
}
function rewriteLiveAssembly(config, assembled, context, decision = { mode: "full", taskKind: "general" }) {
const enabled = config.enabled !== false;
const redteam = enabled && isRedteamSession(context);
const full = enabled ? readInjectFallback(context) : "";
const dshHome = enabled ? core.findDshHome() : "";
let cwd = "";
try { cwd = String(context?.agent?.session?.header?.cwd || "").trim(); } catch { /* ignore */ }
if (dshHome && cwd) {
try { rules.writeLastCwd(dshHome, cwd); } catch { /* ignore */ }
}
const policy = enabled && dshHome ? rules.readWorkspacePolicy(dshHome) : "always";
// 提示词框为空时才启用勾选的本地 cwd 规则兜底;避免跟 prompt-inject.md 叠加污染。
let localInject = "";
if (enabled && dshHome && !String(full || "").trim()) {
try { localInject = rules.readSelectedCwdInject(dshHome, cwd); } catch { /* ignore */ }
}
const effectiveFull = String(full || "").trim() ? full : localInject;
const inject = effectiveFull;
const anchorApplied = false;
const hasInject = Boolean(String(inject || "").trim());
let dropWorkspaceBaseline = false;
if (enabled) {
if (policy === "strict") dropWorkspaceBaseline = true;
else if (policy === "fallback") dropWorkspaceBaseline = hasInject;
else dropWorkspaceBaseline = false;
}
const roleText = redteam ? resolveSubagentRoleText(context) : "";
const rewritten = rewritePromptAssembly(assembled, {
fallbackInject: inject,
dropMnemon: config.stripMnemon !== false,
dropPurgeAfterFold: false,
capabilityProtocol: config.capabilityProtocol !== false,
context,
roleText,
dropWorkspaceBaseline,
});
const closerFollow = closerFollowBytes(rewritten?.sections);
purgeLog("assemble.rewrite", {
enabled,
redteam,
mode: decision?.mode || "full",
task: decision?.taskKind || "general",
last: decision?.lastRole || "unknown",
fullBytes: full.length,
localBytes: localInject.length,
injectBytes: inject.length,
anchorApplied,
roleBytes: roleText.length,
cap: config.capabilityProtocol !== false,
strip: config.stripMnemon !== false,
policy,
dropWorkspaceBaseline,
injectHead: Array.isArray(rewritten?.sections) && rewritten.sections[0]?.name === "dsh-purge",
chatml: Array.isArray(rewritten?.sections) && /<project_instructions>|<\|im_start\|>/i.test(String(rewritten.sections[0]?.text || "")),
afterCloser: closerFollow,
cwd,
});
return rewritten;
}
function closerFollowBytes(sections) {
const closer = "</project_instructions>";
const joined = (Array.isArray(sections) ? sections : [])
.map((section) => (typeof section?.text === "string" ? section.text : ""))
.filter(Boolean)
.join("\n\n");
const at = joined.lastIndexOf(closer);
if (at < 0) return -1;
return joined.length - (at + closer.length);
}
/** 注册表若已经按旧文件算过 rev,立刻 rebuilt,让页面拿到的地址和缓存一致(#63)。 */
function publishClientBundleRevs(ctx, packageNames) {
if (!Array.isArray(packageNames) || packageNames.length === 0) return;
let mods = null;
try {
mods = ctx?.get?.("clientModules", false);
} catch {
mods = null;
}
if (!mods || typeof mods.rebuilt !== "function") return;
for (const id of packageNames) {
try { mods.rebuilt(id); } catch { /* 这一包还没进注册表,下次快照会读到新文件 */ }
}
}
/** Cordis 的 ctx 是 Proxy:未声明 inject 时点属性会抛 "cannot get property ... without inject",所以两段都要包 try。 */
function permissionPresetsOf(ctx) {
try {
const direct = ctx?.permissionPresets;
if (direct && typeof direct.set === "function") return direct;
} catch {
/* 未 inject 这项服务时,Cordis 代理会抛,不能让新会话的全开权限钉死失败。 */
}
try {
const svc = ctx?.get?.("permissionPresets");
if (svc && typeof svc.set === "function") return svc;
} catch {
/* 没有这项服务。 */
}
return null;
}
function shouldKeepFreshFullAccess(session, svc) {
if (session?.header?.origin === "subagent") return false;
const state = typeof svc.permissionState === "function" ? svc.permissionState(session) : null;
const preset = state?.preset;
if (preset === "danger-full-access" || preset === "auto" || preset === "custom") return false;
const events = typeof session?.snapshotEvents === "function" ? session.snapshotEvents() : [];
const pins = Array.isArray(events) ? events.filter((event) => event?.type === "permission/preset") : [];
if (pins.length > 1) return false;
return preset == null || preset === "workspace-write" || preset === "read-only";
}
function upgradeFreshSessionAccess(ctx, session) {
try {
const svc = permissionPresetsOf(ctx);
if (!svc || !shouldKeepFreshFullAccess(session, svc)) return;
svc.set(session, "danger-full-access");
} catch (error) {
console.warn("[dsh-purge] full-access pin:", error instanceof Error ? error.message : String(error));
}
}
/** 空会话界面按部署默认显示完全权限;建会话时若钉成 workspace-write,发消息后芯片会掉下去。 */
function installFreshFullAccess(ctx) {
if (typeof ctx.on !== "function" || ctx.__dshPurgeFullPin) return;
ctx.__dshPurgeFullPin = true;
// ctx.effect:plugin dispose 时解绑 session/created,否则 handler 残留 + 热重载翻倍。
ctx.effect(() => {
let disposer = null;
try { disposer = ctx.on("session/created", (session) => {
queueMicrotask(() => upgradeFreshSessionAccess(ctx, session));
}); } catch { /* ignore */ }
return () => {
try { if (typeof disposer === "function") disposer(); } catch { /* ignore */ }
delete ctx.__dshPurgeFullPin;
};
});
}
function installCwdCapture(ctx) {
if (!ctx || ctx.__dshPurgeCwdCapture) return;
if (typeof ctx.on !== "function") return;
ctx.__dshPurgeCwdCapture = true;
const home = core.findDshHome();
let lastWritten = "";
const flush = (session) => {
try {
const cwd = String(session?.header?.cwd || "").trim();
if (!cwd || !home || cwd === lastWritten) return;
rules.writeLastCwd(home, cwd);
lastWritten = cwd;
} catch { /* ignore */ }
};
try {
const store = ctx.get?.("sessions");
const all = typeof store?.all === "function" ? store.all()
: typeof store?.list === "function" ? store.list()
: [];
for (const session of all || []) flush(session);
} catch { /* ignore */ }
ctx.effect(() => {
const disposers = [];
try { disposers.push(ctx.on("session/created", flush)); } catch { /* ignore */ }
try { disposers.push(ctx.on("session/event", (session) => flush(session))); } catch { /* ignore */ }
return () => {
for (const d of disposers) {
try { if (typeof d === "function") d(); } catch { /* ignore */ }
}
delete ctx.__dshPurgeCwdCapture;
};
});
}
function installRewindDeriveFilter(ctx) {
if (!ctx || ctx.__dshPurgeRewindDerive) return;
ctx.__dshPurgeRewindDerive = true;
const wrapOne = (session) => {
try { rewind.wrapSessionDeriveMessages(session); } catch { /* ignore */ }
};
try {
const store = ctx.get?.("sessions");
const all = typeof store?.all === "function" ? store.all()
: typeof store?.list === "function" ? store.list()
: [];
for (const session of all || []) wrapOne(session);
} catch { /* ignore */ }
ctx.effect(() => {
let disposer = null;
try { disposer = ctx.on("session/created", (session) => wrapOne(session)); } catch { /* ignore */ }
return () => {
try { if (typeof disposer === "function") disposer(); } catch { /* ignore */ }
delete ctx.__dshPurgeRewindDerive;
};
});
}
function installIdentityOverride(ctx, config = {}) {
if (typeof ctx.on !== "function") return;
installSubagentRoleCapture(ctx);
// installAssembleGuard 幂等:slot.rewrite 已挂的 prompt 对象只刷新 rewriteFn,
// 不再 return 新 dispose。原实现每 400ms + 每 pre-step push 一个 noop closure 入 Set,
// 2 小时 session 累积上万条。改为:只 track 首次真正挂上的 disposer。
const wrapDisposers = new Set();
const wrappedPrompts = new WeakSet();
const trackDisposer = (d, prompt) => {
if (typeof d !== "function") return d;
if (prompt) {
if (wrappedPrompts.has(prompt)) return d;
wrappedPrompts.add(prompt);
}
wrapDisposers.add(d);
return d;
};
const rewriteFn = (assembled, context) => {
// [方案2] installAssembleGuard 走的也是这条路径,同样决策 full/anchor。
const decision = shouldInjectAssembly(context);
logAssembleDecision("rewriteFn", decision);
return rewriteLiveAssembly(config, assembled, context, decision);
};
// interval 心跳每 400ms 调一次,正常稳态下所有参数不变 — 只在状态翻转或出错时写日志,
// 避免每天上百万条 guard.wrap 把 7MB 日志刷爆、把真正事件淹没。
let lastGuardSig = "";
const wrapAssemble = (caller = "unknown") => {
try {
const prompt = ctx.systemPrompt;
const d = installAssembleGuard(prompt, rewriteFn);
const mounted = typeof d === "function";
const tracked = trackDisposer(d, prompt);
const sig = `${!!prompt}|${mounted}`;
if (sig !== lastGuardSig) {
purgeLog("guard.wrap", { caller, prompt: !!prompt, mounted });
lastGuardSig = sig;
}
return tracked;
} catch (e) {
purgeLog("guard.wrap", { caller, err: String(e?.message || e) });
lastGuardSig = "";
return false;
}
};
if (!ctx.__dshPurgeAssembleHook) {
ctx.__dshPurgeAssembleHook = true;
// cordis 不同版本的 waterfall 可能是 (assembly, context) 或 (assembly, context, next):
// 旧写法把 `next` 当函数直接取第 3 位,老宿主就拿到 context 当 next,await next() 立刻抛。
// disposer 不进 wrapDisposers:ctx.effect cleanup 在 child scope 结束/预设切换都会触发,
// 收集进去的话会被误解绑,之后再也挂不回来。cordis 真 dispose 时 listener 自己跟 ctx 释放。
try {
ctx.on("system-prompt/assemble", async (...args) => {
const [assembly, context] = args;
const next = args.length >= 3 ? args[args.length - 1] : null;
// 入口即记录 — 看请求是否真的到达了我们的 handler;hasNext=false 时说明 cordis
// 走的旧签名,下游 waterfall 全靠我们返回的 assembled。
let eventsLen = 0;
try { eventsLen = readSessionEvents(context?.agent?.session).length; } catch {}
purgeLog("assemble.entry", {
hasNext: typeof next === "function",
sections: Array.isArray(assembly?.sections) ? assembly.sections.length : -1,
events: eventsLen,
});
let assembled = assembly;
if (typeof next === "function") {
try {
assembled = await next();
} catch (err) {
// 下游 waterfall 任一环节抛就会把整条 assemble 弄死,UI 卡住发不出去。
// 吞掉改用上游装配兜底,inject 保底能挂上。
try { console.warn("[dsh-purge] system-prompt/assemble waterfall rejected:", err?.message || err); } catch {}
purgeLog("assemble.waterfall", { err: String(err?.message || err) });
assembled = assembly;
}
}
try {
// [v10] 事件处理器开场主动 refresh:新对话首轮 hungCache 可能 null、
// installAssembleGuard 可能还没包上 systemPrompt,这里强制把 pinned/磁盘读热,
// 保证 readInjectFallback → refreshHungOperator 命中已 warm 的 cache 不走回空值路径。
try { refreshHungOperator(core.findDshHome()); } catch { /* resolve 过程异常不阻塞 */ }
const decision = shouldInjectAssembly(context);
logAssembleDecision("event", decision);
return rewriteLiveAssembly(config, assembled, context, decision);
} catch (err) {
try { console.warn("[dsh-purge] rewriteLiveAssembly failed:", err?.message || err); } catch {}
purgeLog("assemble.rewriteFail", { err: String(err?.message || err) });
return assembled;
}
}, { global: true, prepend: true });
} catch { /* ctx.on 老版本签名异常,下次 apply 覆盖 */ }
}
// 切换预设会换掉 assemble。pre-step 瀑布在 assemble 之后才跑,第一次发送包不上。
// 宿主在 assemble 之前调用这个函数,用的就是当前模式自己的 systemPrompt。
const prepareAssemble = (loopCtx) => {
try { refreshHungOperator(core.findDshHome()); } catch { /* 主目录还没有提示词 */ }
// loopCtx 可能是宿主的 Proxy,访问 .systemPrompt 自身就会抛(代理层尚未注入服务)。
let prompt = null;
try { prompt = loopCtx && loopCtx.systemPrompt; } catch { prompt = null; }
if (prompt) {
try {
trackDisposer(installAssembleGuard(prompt, rewriteFn), prompt);
} catch { /* 这一层代理包不上时,下面再包插件自己的 */ }
}
try {
const iso = (loopCtx && loopCtx.ctx) || loopCtx;
if (iso && iso !== ctx) registerPromptSections(iso, config);
} catch { /* 隔离层还没有 systemPrompt */ }
wrapAssemble("prepareAssemble");
return prompt || null;
};
globalThis.__dshPurgePrepareAssemble = prepareAssemble;
const hungFn = installHungPromptGlobal();
// complete:true 会在 waterfall 之后盖掉 sections;包住 assemble 返回值,每轮都补回注入。
wrapAssemble("init");
// guard 需要在每次 cordis 换 systemPrompt 代理时重挂(session 内切预设、subagent fork、
// live-mount)。原本 20 秒后 clearInterval,之后换代理就包不上 → 第二轮 inject 走原生
// assemble → 宿主按 token 预算裁剪 → 用户看到"只截取了部分"。installAssembleGuard 幂等
// (wrappedPrompts WeakSet + 对象 identity 校验),400ms 持续跑到 ctx dispose 的 CPU 代价可忽略。
// 400ms 回滚:2000ms 窗口 → 新对话第一次 assemble 可能跑在钩子挂上之前,inject 塌。
// CPU 代价(幂等 wrap)相比"新对话失效"的用户感知,根本不值一提。
const timer = setInterval(() => wrapAssemble("interval"), 400);
if (typeof timer.unref === "function") timer.unref();
// 下面三个 ctx.on 必须加 flag:installIdentityOverride 若被同一 ctx 二次 apply
// (dispose 后重 mount / 热重载),listener 叠加会让 inject 跑 N 遍,也让 wrap 挂 N 层。
if (!ctx.__dshPurgeSessionHook) {
ctx.__dshPurgeSessionHook = true;
try {
ctx.on("session/created", () => {
try { refreshHungOperator(core.findDshHome()); } catch { /* 主目录还没有提示词 */ }
wrapAssemble("sessionCreated");
});
} catch { /* ignore */ }
}
if (!ctx.__dshPurgePresetHook) {
ctx.__dshPurgePresetHook = true;
try {
ctx.on("agent-preset/selected", () => {
try { refreshHungOperator(core.findDshHome()); } catch { /* 主目录还没有提示词 */ }
wrapAssemble("presetSelected");
}, { global: true });
} catch { /* ignore */ }
}
if (!ctx.__dshPurgePreStepHook) {
ctx.__dshPurgePreStepHook = true;
try {
ctx.on("agent/pre-step", (payload, next) => {
wrapAssemble("preStep");
return typeof next === "function" ? next() : payload;
}, { global: true, prepend: true });
} catch { /* ignore */ }
}
// 热重载/卸载时统一回收:轮询 timer、guard 代理层、暴露给 agent loop 的全局钩子,
// 都在同一个 effect 里扫掉;避免下次 apply 再绑一层造成多次写入。
try {
ctx.effect(() => () => {
clearInterval(timer);
for (const d of wrapDisposers) {
try { d(); } catch { /* ignore */ }
}
wrapDisposers.clear();
// 清 flag,下次 apply 允许重新挂 listener;不然 ctx 复用时沉默无响应。
delete ctx.__dshPurgeSessionHook;
delete ctx.__dshPurgePresetHook;
delete ctx.__dshPurgePreStepHook;
// [v10] AssembleHook 也要清。原漏删,ctx 复用时新 apply 看到 flag=true 直接跳过
// ctx.on("system-prompt/assemble", ...),cordis event listener 永远不装 → 新对话首轮
// 完全依赖 400ms setInterval wrapAssemble 的 guard,时间窗没赶上就 inject 真空。
delete ctx.__dshPurgeAssembleHook;
// 新对话 / 热重载会先 dispose 再 apply。这里若删全局 hung,
// 空窗里立刻发送 hang 读到空串,必须等下一次 apply 才恢复。
});
} catch { /* 无 effect 时靠 unref + 下次 apply 自覆盖 */ }
}
function shimNeedsPatch(state) {
if (hostctl.currentSurface() === "desktop") return false;
return (
state.shim_cmd === "original" ||
state.shim_ps1 === "original" ||
state.shim_bin === "original"
);
}
function currentSettleScope() {
let sealed = false;
try { sealed = core.desktopAsarStillSealed(); } catch { /* 非桌面或还没定位安装目录 */ }
return { surface: hostctl.currentSurface(), sealed };
}
async function settleInstalledPatches(config, ctx, life) {
if (life && !life.alive) return "skip:inactive";
try { core.rememberUnpackedArchive(); } catch { /* 没有官方安装目录时不必记归档 */ }
try {
reapply.ensureInstalledRevSeed();
} catch (e) {
log(config, "seed installed-rev failed:", String(e && e.message ? e.message : e));
}
try {
if (reapply.healEmptyAppliedStamp()) log(config, "healed empty applied stamp");
} catch (e) {
log(config, "heal applied stamp failed:", String(e && e.message ? e.message : e));
}
if (config.autoApplyOnStart !== true) {
try {
if (await reapply.patchesMatchInstallStrict() || await reapply.hostCleanEnoughToKeep()) {
reapply.markPatchesApplied();
log(config, "boot: host files left untouched; stamp kept");
return "boot:host-untouched";
}
} catch { /* 没戳也没关系,等用户点应用 */ }
log(config, "boot: skip host writes (Apply once)");
return "skip:boot-no-host-write";
}
const scope = currentSettleScope();
if (!(await reapply.patchesMatchInstallStrict())) {
if (!settleMayReapply(scope)) {
log(config, "sealed desktop: skip startup reapply (wait for Apply)");
return "skip:sealed-wait-apply";
}
if (await reapply.hostCleanEnoughToKeep()) {
reapply.markPatchesApplied();
log(config, "host already clean; kept patches and refreshed stamp");
return "already-clean";
}
if (officialUpdate.upgradeMarkerExists(process.execPath)) {
const upgraded = await reapply.reapplyInstalled({ scheduleSwap: false, upgrade: true });
if (life && !life.alive) return "skip:inactive";
if (!upgraded.ok) {
log(config, "official upgrade reapply failed:", upgraded.error || "");
return "reapply-failed";
}
officialUpdate.clearUpgradeMarker(process.execPath);
officialUpdate.clearStaleExtractedApps(process.execPath);
if (!settleMayRestart(currentSettleScope()) || reapply.shouldSkipSettleRestart()) {
if (upgraded.needsFullQuit) bootFullQuit = true;
log(config, "official upgrade reapplied; skip settle restart");
return "reapplied-no-restart";
}
reapply.markSettleRestart();
log(config, "official upgrade reapplied; restarting onto the new build");
// ctx 可能在 reapply.reapplyInstalled 的 await 中被 dispose;重启前再查一次。
if (life && !life.alive) return "skip:inactive";
try {
hostctl.scheduleRestart(null, ctx, { openBrowser: false });
} catch (e) {
log(config, "restart after official upgrade failed:", String(e && e.message ? e.message : e));
return "restart-failed";
}
return "restarting";
}
// 启动自愈只写盘,不 swap asar / 不杀进程,避免官方 Host Fiber._reload 撞 INACTIVE_EFFECT。
const result = await reapply.reapplyInstalled({ scheduleSwap: false });
if (life && !life.alive) return "skip:inactive";
if (!result.ok) {
log(config, "reapply failed:", result.error || "");
return "reapply-failed";
}
// reapply 成功后强制保证非空戳,避免 clean 路径漏写又空转(#59/#60)。
try { reapply.markPatchesApplied(); } catch { /* ignore */ }
if (result.needsFullQuit) {
bootFullQuit = true;
log(config, "reapply wrote patches; official host needs a full quit");
return "full-quit";
}
if (!settleMayRestart(currentSettleScope())) {
log(config, "reapply wrote patches; desktop settle does not restart");
return "reapplied-no-restart";
}
if (reapply.shouldSkipSettleRestart()) {
log(config, "reapply wrote patches; skip settle restart (cooldown)");
return "reapplied-no-restart";
}
// 已对齐则不必再重启;只在 stamp/宿主仍不一致时重启一次。
if (await reapply.patchesMatchInstallStrict()) {
log(config, "reapply wrote patches; already aligned, no restart");
return "reapplied";
}
reapply.markSettleRestart();
log(config, "reapply wrote patches; restarting");
// reapplyInstalled 自带 await;重启前再查一次 life,避免对 disposed ctx 发起 scheduleRestart。
if (life && !life.alive) return "skip:inactive";
try {
// web:自愈重启不弹浏览器,关窗后不应再被反复拉起新窗口(#59/#61)。
hostctl.scheduleRestart(null, ctx, { openBrowser: false });
} catch (e) {
log(config, "restart after reapply failed:", String(e && e.message ? e.message : e));
return "restart-failed";
}
return "restarting";
}
const applied = await autoApply(config);
if (config.autoUpdateOnStart === true) {
updater.autoUpdateIfNeeded()
.then((r) => {
// ctx 可能在 autoUpdate 等待网络的过程中被 dispose(热重载 / 卸载)。
// 回调里再碰 ctx.scheduleRestart 会命中 INACTIVE_EFFECT。
if (life && !life.alive) return;
log(config, "auto-update:", JSON.stringify(r));
if (r && r.needsFullQuit) bootFullQuit = true;
if (r && r.restartAfter && settleMayRestart(currentSettleScope())) {
if (life && !life.alive) return;
try { hostctl.scheduleRestart(null, ctx, { openBrowser: false }); } catch (e) {
console.warn("[dsh-purge] auto-update restart failed:", e);
}
}
})
.catch((e) => console.warn("[dsh-purge] auto-update error:", String(e)));
}
return applied;
}
async function autoApply(config) {
try {
core.applyRuntimeEnv();
const sanitized = core.sanitizeDesktopCommandRuntimes();
if (sanitized.length) log(config, "desktop runtime sanitized:", JSON.stringify(sanitized));
core.refreshHooksDenySettings();
if (core.getHooksDenySettings().hooksDenyBypass === false) {
const restored = await core.restoreHookDenyForBases(core.enumeratePatchAiBases());
if (restored.failed.length) {
log(config, "hooks-deny restore leftover:", restored.failed.join(","));
}
}
const state = await core.gatherState();
if (!state.ai_base) {
log(config, "skip auto-apply: plugin root not found");
return "skip:plugin_root_not_found";
}
try {
const flash = core.silenceCmdFlash(state.ai_base);
log(config, "cmd-flash:", JSON.stringify(flash));
if (!flash.ok) log(config, "cmd-flash incomplete:", flash.entry);
} catch (e) {
log(config, "cmd-flash failed:", String(e));
}
if (state.patches_pending === 0) {
if (config.autoApplyOnStart && shimNeedsPatch(state)) {
await core.patchAllShims();
core.silenceCmdFlash(state.ai_base);
log(config, "auto-apply: patches clean, shim refreshed");
return "already_clean:shim_patched";
}
log(config, "auto-apply: already clean");
return "already_clean";
}
if (!config.autoApplyOnStart) {
log(config, "auto-apply disabled");
return "disabled";
}
const autoMulti = await core.applyPatchesAllHosts(core.ALL_PATCHES, { preserveClientBundles: true });
await core.patchAllShims();
const report = autoMulti.report;
const flash = core.silenceCmdFlash(autoMulti.hosts[0]?.aiBase || state.ai_base);
const applied = report.filter((r) => r.status === "applied").length;
log(config, `auto-apply done: ${applied} applied; cmd-flash=${flash.entry} ok=${flash.ok}`);
return flash.ok ? `applied:${applied}` : `applied:${applied}:cmd_flash_incomplete`;
} catch (e) {
console.warn("[dsh-purge] auto-apply error:", String(e));
return `error:${e}`;
}
}
/** 应用之后的那一次重启。密封包退出再打开,不再干等用户自己退出。 */
function scheduleApplyRestart(shouldRestart, ctx = null) {
if (!shouldRestart) return { restarting: false };
if (core.desktopAsarStillSealed()) {
// 透传 ctx 给核心层,让密封包重启也能走 PowerShell 立即 kill Electron + requestRestart
// 双保险路径,不再弹"host stopped"崩溃对话框 (#64)。
const planned = core.restartSealedDesktopIfNeeded({ ctx });
return {
...(planned || {}),
restarting: Boolean(planned && (planned.restarting || planned.asarSwap)),
};
}
try {
const planned = hostctl.scheduleRestart(null, ctx);
if (planned && (planned.restarting === false || planned.clean === false)) return planned;
return planned || { restarting: true };
} catch (error) {
return { restarting: false, error: String(error && error.message ? error.message : error) };
}
}
async function handlePurgeCommand(rawInput, config, ctx = null) {
const args = (rawInput || "").trim().split(/\s+/).filter(Boolean);
const sub = (args[0] || "status").toLowerCase();
switch (sub) {
case "status":
case "s": {
const state = await core.gatherState();
return { kind: "success", text: renderStatus(state) };
}
case "apply":
case "a": {
core.applyRuntimeEnv();
core.clearDesktopLocateCache();
const home = core.pinDshHome();
const scrubbed = core.sanitizeDesktopCommandRuntimes();
const state = await core.gatherState();
if (home) state.dsh_home = home;
let located;
try {
located = await core.resolveOfficialAiBase(state.ai_base, { forApply: true });
} catch (e) {
return { kind: "error", text: String(e && e.message ? e.message : e) };
}
if (!located.aiBase) return { kind: "error", text: core.missingAiBaseMessage() };
state.ai_base = located.aiBase;
if (located.extracted && located.handoff?.archive) {
core.writeUnpackedAsarStamp(located.handoff.archive);
}
const blocked = rejectIfNeedPrompt(state.dsh_home);
if (blocked) return { kind: "error", text: blocked.error };
const applyMulti = await core.applyPatchesAllHosts(core.ALL_PATCHES, {
preserveClientBundles: false,
forApply: true,
});
const report = applyMulti.report;
const shimResult = await core.patchAllShims();
const flash = core.silenceCmdFlash(applyMulti.hosts[0]?.aiBase || state.ai_base);
const lines = [];
for (const h of applyMulti.hosts) lines.push(` 宿主 / host → ${h.aiBase}`);
lines.push(...report.map((r) => {
const m = r.status === "applied" ? "✓ 已清洗" : r.status === "already" ? "- 已是最新" : r.status === "missing_file" ? "⚠ 文件缺失" : `✗ ${r.status}`;
return ` ${m} patch #${String(r.patch_id).padEnd(2)} ${r.name}`;
}));
lines.push("", "shim:");
for (const [dir, st] of Object.entries(shimResult)) {
lines.push(` · ${dir} → ${typeof st === "object" ? JSON.stringify(st) : st}`);
}
lines.push(` cmd-flash=${flash.entry} phase-1=${flash.phase1} ok=${flash.ok}`);
if (scrubbed.length) {
lines.push("", "Desktop in-bin .bak scrub:");
for (const row of scrubbed) lines.push(` ✓ ${row.dir} → ${row.cleaned.join(", ")}`);
}
lines.push("Desktop: host-commands 密封目录只清理不注入;bak 禁止写进 bin(issue #9)。");
const summary = core.summarizeApplyReport(report);
const hostWritten = core.allPatchHostsWritten(applyMulti.hosts) || core.hostCleanMarkersPresent(state.ai_base);
const hookDenyRestoreFailed = (applyMulti.hookDenyRestoreFailed || []).length > 0;
const complete = summary.failed.length === 0 && flash.ok === true && hostWritten && !hookDenyRestoreFailed;
const promptReady = summary.failed.length > 0 && summary.failed.every((item) => item.status === "pattern_not_found");
const shouldRestart = applyShouldRestart({
complete: complete || (promptReady && hostWritten),
sealed: core.desktopAsarStillSealed(),
appliedCount: summary.applied,
promptReady: promptReady && hostWritten,
});
if (shouldRestart) reapply.markSettleRestart();
try { core.seedPromptInjectAtBoot(state.dsh_home); } catch { /* 主目录尚未就绪 */ }
try { refreshHungOperator(state.dsh_home); } catch { /* ignore */ }
const planned = scheduleApplyRestart(shouldRestart, ctx);
if (planned && planned.clean === false) {
lines.push("", "清洗没有写进当前宿主,已取消重启。");
} else if (planned && planned.restarting === false && planned.error) {
lines.push("", String(planned.error));
} else if (shouldRestart && planned && planned.restarting !== false) {
lines.push("", "正在重启。");
} else if (!complete) {
lines.push("", "清洗没有完成,没有重启。");
} else {
lines.push("", "补丁已经在宿主里,不再重启。");
}
return { kind: "success", text: lines.join("\n") };
}
case "revert":
case "r": {
const state = await core.gatherState();
const lines = [];
if (state.ai_base) {
const { reverted, errors } = await core.revertAll(state.ai_base);
lines.push(...reverted.map((p) => ` ✓ 已还原 / Reverted ${p}`));
for (const [p, e] of errors) lines.push(` ⚠ 还原失败 ${p}: ${e}`);
if (reverted.length === 0) lines.push(" - 没有补丁备份可还原 / no patch backup");
}
const shimRevert = await core.revertAllShims();
for (const [dir, st] of Object.entries(shimRevert)) {
lines.push(` shim ${dir}: ${typeof st === "object" ? JSON.stringify(st) : st}`);
}
lines.push("", "回滚完成 / Revert done。重启 dsh 后恢复 / restart to restore.");
lines.push("注: prompt-inject.md 保留(用户文件)/ prompt-inject.md kept (user file)");
return { kind: "success", text: lines.join("\n") };
}
case "uninstall":
case "remove": {
const result = await uninstaller.uninstallPurge();
const lines = [];
if (result.applied) lines.push("已检测到补丁,已还原回原版。");
else lines.push("未检测到已应用补丁,仍会清除插件文件。");
if (result.patches) {
lines.push(` 还原文件 ${result.patches.reverted?.length || 0} 个`);
}
if (result.override?.removed) lines.push(` 已删除 ${result.override.path}`);
if (result.stripped?.length) lines.push(` 已从 profile 移除: ${result.stripped.join(", ")}`);
if (result.errors?.length) {
for (const e of result.errors) lines.push(` ⚠ ${e}`);
}
lines.push("", "请重启 dsh。重启后插件不再加载。");
return { kind: result.ok ? "success" : "error", text: lines.join("\n") };
}
case "edit":
case "e": {
const state = await core.gatherState();
let r = core.editOverride(state.dsh_home);
let created = false;
if (!r.ok && r.needCreate) {
await core.installOverride(state.dsh_home, true);
r = core.editOverride(state.dsh_home);
created = true;
}
// 旧实现:r.ok 为 false 时仍然返回 kind:"success",用户看到"已打开"但进程根本没起来。
if (!r.ok) {
return { kind: "error", text: r.error || "打开编辑器失败" };
}
return {
kind: "success",
text: created
? `已创建默认注入文件并打开编辑器:${r.path}\n重启 dsh 生效。`
: `已打开 ${r.editor} 编辑注入文件:${r.path}\n编辑完成后重启 dsh 生效。`,
};
}
case "help":
case "h":
default:
return {
kind: "success",
text: "dsh-purge 命令:\n" +
" /purge status 显示状态\n" +
" /purge apply 应用全部清洗(提示词+代码+shim+override)\n" +
" /purge revert 回滚还原\n" +
" /purge uninstall 卸载插件(已应用则先还原)\n" +
" /purge edit 编辑注入文件 prompt-inject.md\n" +
" /purge help 显示帮助\n" +
"规则集切换见 /rules(list | use <id> | create | delete | reset)\n" +
"官方 Skill 安装见 /skills(list | import <路径> | create <id> [说明] | delete <id>)",
};
}
}
function renderRulesStatus(st) {
const out = [];
out.push("规则集 / Rule Sets");
out.push(` 存储目录 / store ${st.rules_dir}`);
const targetLine = st.active
? `${st.agents_path} (${st.active_target})`
: "(无激活)";
out.push(` 目标文件 / target ${targetLine} — exists=${st.agents_exists}, synced=${st.agents_synced}`);
out.push("");
if (st.rules.length === 0) {
out.push(" (暂无规则 / no rules — 用 /rules create <id> 或设置页新建)");
}
for (const r of st.rules) {
const isActive = r.id === st.active;
const label = r.name !== r.id ? `${r.name} (${r.id})` : r.id;
out.push(` ${isActive ? "▶" : "·"} ${label}${isActive ? " ★ 当前" : ""} [${r.target}] (${rules.formatSize(r.size)})`);
}
return out.join("\n");
}
async function handleRulesCommand(rawInput) {
const args = (rawInput || "").trim().split(/\s+/).filter(Boolean);
const sub = (args[0] || "list").toLowerCase();
const dshHome = core.findDshHome();
switch (sub) {
case "list":
case "ls":
case "s":
return { kind: "success", text: renderRulesStatus(await rules.rulesStatus(dshHome)) };
case "use":
case "activate":
case "u": {
const id = args[1];
if (!id) return { kind: "error", text: "用法: /rules use <id>" };
try {
const meta = await rules.activateRule(dshHome, id);
refreshHungOperator(dshHome);
return {
kind: "success",
text: `✓ 已激活规则 ${meta.name}(${id})→ 写入 ${rules.targetPath(dshHome, meta.target)},并注入 systemPrompt。新会话生效。`,
};
} catch (e) {
return { kind: "error", text: `激活失败: ${e.message}` };
}
}
case "create":
case "new":
case "c": {
const id = args[1];
if (!id) return { kind: "error", text: "用法: /rules create <id> [别名] [AGENTS.md|CLAUDE.md]" };
const name = args[2] || id;
const target = args[3] || "AGENTS.md";
if (!rules.validTarget(target)) {
return { kind: "error", text: `无效目标: ${target}(只能是 AGENTS.md 或 CLAUDE.md)` };
}
try {
await rules.saveRule(dshHome, id, "", { name, target });
return {
kind: "success",
text: `✓ 已创建规则 ${name}(${id} → ${target})。在设置页「规则设定」编辑内容后保存。`,
};
} catch (e) {
return { kind: "error", text: `创建失败: ${e.message}` };
}
}
case "delete":
case "rm":
case "d": {
const id = args[1];
if (!id) return { kind: "error", text: "用法: /rules delete <id>" };
try {
await rules.deleteRule(dshHome, id);
refreshHungOperator(dshHome);
return { kind: "success", text: `✓ 已删除规则 ${id}。` };
} catch (e) {
return { kind: "error", text: `删除失败: ${e.message}` };
}
}
case "reset":
case "restore":
case "还原": {
try {
const r = await rules.resetToOriginal(dshHome);
refreshHungOperator(dshHome);
const lines = [];
if (r.removed.length > 0) lines.push(`✓ 已删除插件写入的目标文件: ${r.removed.join(", ")}`);
if (r.skipped.length > 0) {
lines.push(`⚠ 跳过(文件内容与规则不一致,可能被手动修改): ${r.skipped.join(", ")}`);
}
lines.push("已清空激活状态。规则库内容保留,随时可重新激活。现在回到出厂状态:无全局指令。");
return { kind: "success", text: lines.join("\n") };
} catch (e) {
return { kind: "error", text: `还原失败: ${e.message}` };
}
}
case "help":
case "h":
default:
return {
kind: "success",
text: "规则集命令:\n" +
" /rules list 列出所有规则\n" +
" /rules use <id> 激活规则(写入 AGENTS.md/CLAUDE.md,并注入 systemPrompt)\n" +
" /rules create <id> [别名] [目标] 新建规则\n" +
" /rules delete <id> 删除规则\n" +
" /rules reset 还原原始状态\n" +
" /rules help 显示帮助",
};
}
}
function renderSkillsStatus(st) {
const out = [];
out.push("官方用户 Skill / $DSH_HOME/skills");
out.push(` ${st.skills_dir}`);
out.push(" 插件只安装目录。调用走官方:任务对上 description/whenToUse 时模型调 skill 工具;聊天输入 /名称 立即加载。");
out.push(" 正文里写「激活」不会触发。官方认 name、description、whenToUse、disable-model-invocation、user-invocable。");
out.push("");
if (!st.skills.length) {
out.push(" (暂无用户 Skill — 用 /skills import <压缩包或文件夹> 或设置页导入)");
return out.join("\n");
}
for (const item of st.skills) {
const mark = item.valid ? "·" : "!";
const desc = item.description || "(无说明)";
const how = item.valid
? [
item.userInvocable ? `/${item.id}` : "不可 /名称",
item.modelInvocable ? "模型可调" : "模型不可调",
].join(" · ")
: "";
out.push(` ${mark} ${item.id} ${desc} (${skills.formatSize(item.size)})`);
if (how) out.push(` ${how}`);
if (!item.valid && item.error) out.push(` ${item.error}`);
}
return out.join("\n");
}
async function handleSkillsCommand(rawInput) {
const args = (rawInput || "").trim().split(/\s+/).filter(Boolean);
const sub = (args[0] || "list").toLowerCase();
const dshHome = core.findDshHome();
switch (sub) {
case "list":
case "ls":
case "s":
return { kind: "success", text: renderSkillsStatus(await skills.skillsStatus(dshHome)) };
case "create":
case "new":
case "c": {
const id = args[1];
if (!id) return { kind: "error", text: "用法: /skills create <id> [说明]" };
if (!skills.validSkillName(id)) {
return { kind: "error", text: "id 必须是 kebab-case,例如 code-review" };
}
const description = args.slice(2).join(" ").trim() || id;
try {
const saved = skills.toPublicSkill(dshHome, await skills.saveSkill(dshHome, id, "", description));
return {
kind: "success",
text: `✓ 已写入官方目录 ${saved.path}\n新会话会进官方 Skill 目录;任务对上或输入 /${id} 时由官方加载。`,
};
} catch (e) {
return { kind: "error", text: `安装失败: ${e.message}` };
}
}
case "import":
case "i": {
const src = args.slice(1).join(" ").trim();
if (!src) return { kind: "error", text: "用法: /skills import <压缩包或文件夹>" };
try {
const packed = await skills.importFromPath(dshHome, src);
const names = packed.imported.map((item) => item.id).join(", ");
return { kind: "success", text: `✓ 已导入到官方 $DSH_HOME/skills:${names}` };
} catch (e) {
return { kind: "error", text: `导入失败: ${e.message}` };
}
}
case "delete":
case "rm":
case "d": {
const id = args[1];
if (!id) return { kind: "error", text: "用法: /skills delete <id>" };
try {
const removed = skills.toPublicSkill(dshHome, await skills.deleteSkill(dshHome, id));
return {
kind: "success",
text: `✓ 已从官方目录删除 ${id}${removed.backup ? `\n备份:${removed.backup}` : ""}`,
};
} catch (e) {
return { kind: "error", text: `删除失败: ${e.message}` };
}
}
case "help":
case "h":
default:
return {
kind: "success",
text: "官方 Skill 安装:\n" +
" /skills list 列出 $DSH_HOME/skills\n" +
" /skills create <id> [说明] 写入 <id>/SKILL.md\n" +
" /skills import <路径> 导入压缩包或文件夹到官方目录\n" +
" /skills delete <id> 删除该目录(官方会从目录拿掉)\n" +
" 也可自己删 $DSH_HOME/skills/<id>\n" +
" 调用:聊天输入 /<id>,或让模型按 description 调 skill 工具\n" +
" 不注入 systemPrompt,不替代提示词",
};
}
}
export function apply(ctx, config) {
const cfg = { ...DEFAULTS, ...(config || {}) };
if (!cfg.enabled) {
purgeLog("boot", { enabled: false });
return;
}
purgeLog("boot", {
enabled: true,
surface: hostctl.currentSurface(),
stripMnemon: cfg.stripMnemon !== false,
cap: cfg.capabilityProtocol !== false,
});
core.setHooksDenyConfigValue(cfg.hooksDenyBypass);
core.refreshHooksDenySettings();
/* approvalMode 以磁盘上的偏好为准:配置文件只是默认值,界面上切过一次就记住。
* 读失败时不改,退回 DEFAULTS 的 audit —— 也就是「只记录、不阻拦」。 */
try {
const persisted = core.getPurgeSetting("approvalMode", "");
if (persisted === "audit" || persisted === "enforce") cfg.approvalMode = persisted;
} catch { /* 读不到就沿用默认 */ }
try {
const home = core.pinDshHome() || core.findDshHome();
purgeLog("home", { home: home || "(none)" });
try {
if (home) {
const flag = path.join(home, ".hung-disabled");
if (fs.existsSync(flag)) {
fs.unlinkSync(flag);
purgeLog("hung-disabled", { removed: flag });
}
}
} catch (e) { purgeLog("hung-disabled", { err: String(e?.message || e) }); }
installHungPromptGlobal();
// Apply→cleanup→restart 成功后,默认把加密提示词 seed 进 prompt-inject.md。
// 用户已经改过的文件不动;只有「没有」或「仍是空文件」时才补写。
try {
const seed = core.seedPromptInjectAtBoot(home);
log(cfg, "seed prompt-inject:", seed?.action || "noop", seed?.reason || "");
} catch (e) {
console.warn("[dsh-purge] seed prompt-inject failed:", String(e && e.message ? e.message : e));
}
refreshHungOperator(home);
} catch { /* 主目录还没有时,组装会再挂内置默认 */ }
// 启动不写宿主包。补丁 / bak / client.js / runtime 只在点「应用」时写一次。
const life = watchContextLife(ctx);
if (!life.alive) {
console.warn("[dsh-purge] skip apply: inactive context");
return;
}
log(cfg, "plugin enabled");
core.applyRuntimeEnv();
try {
const home = core.pinDshHome() || core.findDshHome();
refreshHungOperator(home);
} catch { /* 主目录还没有时,组装会再挂内置默认 */ }
const settleTimer = setTimeout(() => {
if (!life.alive) {
log(cfg, "settle skipped: inactive context");
return;
}
settleInstalledPatches(cfg, ctx, life)
.then((r) => log(cfg, "settle:", r))
.catch((e) => {
if (isInactiveContextError(e)) {
log(cfg, "settle skipped: inactive context");
return;
}
console.warn("[dsh-purge] settle error:", String(e));
});
rules.ensureInitialState(core.findDshHome())
.then((r) => log(cfg, "rules init:", JSON.stringify(r)))
.catch((e) => console.warn("[dsh-purge] rules init error:", String(e)));
}, settleDelayMs(hostctl.currentSurface()));
try {
ctx.effect(() => () => clearTimeout(settleTimer));
} catch (e) {
if (isInactiveContextError(e)) {
clearTimeout(settleTimer);
console.warn("[dsh-purge] skip apply: inactive context");
return;
}
throw e;
}
try {
applyHostHooks(ctx, cfg);
} catch (e) {
if (isInactiveContextError(e)) {
console.warn("[dsh-purge] skip apply: inactive context");
return;
}
throw e;
}
}
function applyHostHooks(ctx, cfg) {
try {
const home = core.pinDshHome() || core.findDshHome();
refreshHungOperator(home);
} catch { /* 主目录尚未就绪 */ }
installIdentityOverride(ctx, cfg);
installRegionGate(ctx);
installFreshFullAccess(ctx);
installRewindDeriveFilter(ctx);
installCwdCapture(ctx);
installMnemonMessageStrip(ctx, cfg);
/* 在注册任何提示词段之前先补 `cwd` 变量。
* 0.2.1 的 agent-loop 只注册 provider / model,宿主的 interpolate 遇到未注册
* 变量是硬失败(整轮报错),而我们的 personaSuffix 与用户 preset 都可能引用
* `{{cwd}}`。这里补上,失败也只记日志不阻断启动。 */
try {
const cwdVar = cwdVariable.registerCwdVariable(ctx, { log: (tag, kv) => purgeLog(tag, kv) });
purgeLog("cwd-variable", { registered: cwdVar.registered, reason: cwdVar.reason, hadAlready: cwdVar.hadAlready });
} catch (error) {
purgeLog("cwd-variable", { status: "threw", error: String(error && error.message ? error.message : error) });
}
registerPromptSections(ctx, cfg);
// 本次 apply 作用域内的 continue-retry 状态;旧 ctx dispose 会把这些闭包一并带走。
const continuePluginCfg = cfg;
const savedContinueSettings = continueRetry.loadSettings(core.findDshHome());
const continueRuntime = continueRetry.installContinueRetry(ctx, {
getConfig: () => continueRetry.mergeConfig(continuePluginCfg, savedContinueSettings),
});
try {
ctx.effect(() => () => {
try { continueRuntime?.dispose?.(); } catch { /* ignore */ }
});
} catch { /* 无 effect 时靠 cordis scope 自己回收 */ }
const commands = ctx.get?.("commands");
if (commands) {
commands.register({
name: "purge",
description: "dsh 指令权威性清洗(status/apply/revert/uninstall/edit/help)",
input: { hint: "status | apply | revert | uninstall | edit | help" },
handler: async (invocation) => handlePurgeCommand(invocation.rawInput ?? "", cfg, ctx),
});
commands.register({
name: "rules",
description: "规则集切换(写入 AGENTS.md/CLAUDE.md): list | use <id> | create <id> [别名] [目标] | delete <id> | reset | help",
input: { hint: "list | use <id> | create <id> [别名] [目标] | delete <id> | reset | help" },
handler: async (invocation) => handleRulesCommand(invocation.rawInput ?? ""),
});
commands.register({
name: "skills",
description: "官方用户 Skill($DSH_HOME/skills):list | import <路径> | create <id> [说明] | delete <id> | help",
input: { hint: "list | import <路径> | create <id> [说明] | delete <id> | help" },
handler: async (invocation) => handleSkillsCommand(invocation.rawInput ?? ""),
});
commands.register({
name: "rewind",
description: "回退:子代理直接回退一次;主代理 /rewind once 或 /rewind round",
input: { hint: "once | round" },
handler: async (invocation) => {
const sessionId = rewind.invocationSessionId(invocation);
const arg = String(invocation.rawInput || "").trim().split(/\s+/)[0]?.toLowerCase() || "";
const info = await rewind.analyzeRewind(ctx, sessionId);
if (info.ok && info.kind === "main" && arg !== "once" && arg !== "round") {
return {
kind: "success",
text: "主代理请选择:/rewind once 回退一次,/rewind round 回退上一轮(含本轮子代理)。",
};
}
const mode = arg === "round" ? "round" : "once";
const result = await rewind.applyRewind(ctx, sessionId, mode);
if (!result.ok) return { kind: "error", text: result.error };
return {
kind: "success",
text: mode === "round"
? "已回退上一轮。还在这条对话里,上一句已放回输入框。再发送时,之前的回复不会带上。"
: "已回退一次。还在这条对话里,上一句已放回输入框。再发送时,之前的回复不会带上。",
};
},
});
}
if (ctx.tools) {
ctx.tools.register({
name: "purge_status",
description: `查看 dsh 指令权威性清洗状态(${core.ALL_PATCHES.length} patch 清洗进度、shim 注入、override 文件)。`,
parameters: {
type: "object",
additionalProperties: false,
properties: {},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: { text: { type: "string" } },
},
render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
},
async execute() {
const state = await core.gatherState();
return { text: renderStatus(state) };
},
});
ctx.tools.register({
name: "purge_apply",
description: `应用 dsh 指令权威性清洗:${core.ALL_PATCHES.length} patch(提示词层+代码默认+引擎级审批/沙箱绕过)+ shim 注入 + override 文件。需重启 dsh 完全生效。`,
parameters: {
type: "object",
additionalProperties: false,
properties: {},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: { text: { type: "string" } },
},
render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
},
async execute() {
core.applyRuntimeEnv();
const scrubbed = core.sanitizeDesktopCommandRuntimes();
const state = await core.gatherState();
if (!state.ai_base) return { text: "ERROR: " + core.missingAiBaseMessage() };
const blocked = rejectIfNeedPrompt(state.dsh_home);
if (blocked) return { text: "ERROR: " + blocked.error };
const toolMulti = await core.applyPatchesAllHosts(core.ALL_PATCHES, {
preserveClientBundles: false,
forApply: true,
});
await core.patchAllShims();
const flash = core.silenceCmdFlash(toolMulti.hosts[0]?.aiBase || state.ai_base);
const report = toolMulti.report;
const applied = report.filter((r) => r.status === "applied").length;
const already = report.filter((r) => r.status === "already").length;
const scrubNote = scrubbed.length ? ` desktop_scrub=${scrubbed.length}` : "";
bumpRev("apply");
return { text: `清洗完成 / applied=${applied}, already=${already}.${scrubNote} phase-1=${flash.phase1} 重启 dsh 生效.` };
},
});
ctx.tools.register({
name: "purge_revert",
description: "回滚 dsh 指令权威性清洗到备份原件(保留 prompt-inject.md 用户文件)。",
parameters: {
type: "object",
additionalProperties: false,
properties: {},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: { text: { type: "string" } },
},
render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
},
async execute() {
const state = await core.gatherState();
const lines = [];
if (state.ai_base) {
const { reverted, errors } = await core.revertAll(state.ai_base);
lines.push(`reverted=${reverted.length}${errors.length ? ` errors=${errors.length}` : ""}`);
}
const shimRevert = await core.revertAllShims();
lines.push(`shim_dirs=${Object.keys(shimRevert).length}`);
bumpRev("revert");
return { text: `回滚完成 / ${lines.join(", ")}. 重启 dsh 恢复.` };
},
});
/* 审计读取。写操作经审批门时会记流水,这里只读回放,不改任何状态。
* 对应 IG5 的 `type=approvals` 只读入口:让模型在长任务里能自查
* 「上一次 apply 到底批没批、结果如何」,而不是靠记忆。 */
ctx.tools.register({
name: "purge_audit",
description: "读取 dsh-purge 破坏性操作的审批与结果流水(apply / revert / uninstall),最新的在前。",
parameters: {
type: "object",
additionalProperties: false,
properties: {
limit: { type: "number", description: "返回多少条,默认 20,最多 200。" },
},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: { text: { type: "string" }, total: { type: "number" } },
},
render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
},
async execute(args) {
const wanted = Number(args?.limit);
const limit = Number.isFinite(wanted) && wanted > 0 ? Math.min(200, Math.floor(wanted)) : 20;
const report = readAuditTail(core.findDshHome(), limit);
return { text: report.text, total: report.total };
},
});
}
/* 写工具挂审批门。放在 installWebServer 之前:路由层自己单独要审批,
* 工具层在这里统一处理,两条入口互不依赖。 */
const gate = installToolGate(ctx, {
mode: cfg.approvalMode,
tools: ["purge_apply", "purge_revert", "purge_uninstall"],
auditPath: () => auditFilePath(core.findDshHome()),
fs,
path,
log: (tag, kv) => purgeLog(`gate.${tag}`, kv),
});
try {
ctx.effect(() => () => gate.dispose());
} catch {
/* 没有 effect 的旧上下文:disposer 留在闭包里,进程退出时一并消失 */
}
purgeLog("gate", { installed: gate.installed, gated: gate.gated.join("|") });
installWebServer(ctx, cfg);
}
function headerOf(request, name) {
const headers = request?.headers;
if (!headers) return "";
const value = headers[name] ?? headers[String(name).toLowerCase()];
if (Array.isArray(value)) return String(value[0] || "");
return String(value || "");
}
function denyCrossOrigin(response) {
response.writeHead(403, {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
});
response.end(JSON.stringify({ ok: false, error: "cross-origin blocked" }));
}
function allowPluginRequest(request, response) {
const site = headerOf(request, "sec-fetch-site").toLowerCase();
if (site === "cross-site") {
denyCrossOrigin(response);
return false;
}
const origin = headerOf(request, "origin").trim();
if (origin === "null") {
denyCrossOrigin(response);
return false;
}
if (!origin) return true;
let originHost = "";
try {
originHost = new URL(origin).host;
} catch {
denyCrossOrigin(response);
return false;
}
const host = headerOf(request, "host").trim();
if (!host || originHost.toLowerCase() !== host.toLowerCase()) {
denyCrossOrigin(response);
return false;
}
return true;
}
function installWebServer(ctx, cfg) {
// 桌面端内嵌 @deepseek-ai/dsh-web-app,设置页同样走 webServer(常见 43120),不是只给官方 dsh web。
watchWebTreeReady(ctx);
ctx.inject(["webServer"], (host) => {
host.effect(() => {
// 收集所有子路由的 unregister disposer;host.effect 的 cleanup 一把全撤。
// 旧版本只注册不收,plugin reload 时上一轮的 handler 仍挂着,新的叠在之上。
const routeDisposers = [];
const trackRoute = (d) => {
if (typeof d === "function") routeDisposers.push(d);
return d;
};
const registerPluginRoute = (spec, label) => trackRoute(host.webServer.register({
...spec,
handler: async (request, response) => {
if (!allowPluginRequest(request, response)) return;
return spec.handler(request, response);
},
}, label));
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/status",
handler: async (request, response) => {
if (request.method !== "GET") {
response.writeHead(405, { allow: "GET" });
response.end();
return;
}
try {
if (!webTreeReady) {
sendJson(response, 503, { ok: false, ready: false });
return;
}
const state = await core.gatherState();
const patch_names = {};
for (const p of core.ALL_PATCHES) patch_names[String(p.id)] = p.name;
sendJson(response, 200, {
ok: true,
ready: true,
/* 状态修订号:客户端下次动作时带回来,服务端据此拒绝过期写入。
* 与 last_action 一起给,界面可以直接提示「上一次动作是 apply」。 */
rev: currentRev(),
last_action: lastActionInfo(),
approval_mode: cfg.approvalMode,
surface: state.surface || hostctl.currentSurface(),
ai_base: state.ai_base,
desktop_install: state.desktop_install || "",
desktop_exe: state.desktop_exe || "",
asar_still_sealed: state.asar_still_sealed || false,
missing_ai_base: state.ai_base ? "" : core.missingAiBaseMessage(),
patches_applied: state.ai_base ? (state.display_applied ?? state.patches_applied) : 0,
patches_pending: state.display_pending ?? state.patches_pending,
patches_skipped: state.display_skipped ?? state.patches_skipped ?? 0,
patches_ready: state.display_ready ?? state.patches_ready ?? 0,
patches_na: state.patches_na || 0,
patches_total: state.ai_base ? (state.display_total ?? state.patches_total ?? 0) : 0,
patches_lost: reapply.patchesLostAfterHostReset(),
patch_status: state.patch_status,
patches: (state.display_patches || []).map((p) => ({
id: p.id,
ids: p.ids,
layer: p.layer || "",
status: p.status,
})),
patch_names,
shim_cmd: state.shim_cmd,
shim_ps1: state.shim_ps1,
shim_bin: state.shim_bin,
has_backup: state.has_backup,
override_status: state.override_status,
override_path: state.override_path,
dsh_home: state.dsh_home,
plugin_version: updater.localVersion(),
boot_full_quit: bootFullQuit,
update: updater.lastUpdateResult(),
...updater.channelPublic(),
hooks_deny_bypass: core.getHooksDenySettings().hooksDenyBypass,
...(await skills.skillsStatus(core.findDshHome()).catch(() => ({ skills: [] }))),
});
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
},
}, "dsh-purge: status");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/hooks-deny",
handler: async (request, response) => {
if (request.method === "GET") {
try {
const settings = core.refreshHooksDenySettings();
sendJson(response, 200, { ok: true, hooksDenyBypass: settings.hooksDenyBypass });
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
return;
}
if (request.method === "POST") {
try {
const body = await readJsonBody(request);
if (typeof body?.hooksDenyBypass !== "boolean") {
sendJson(response, 400, { ok: false, error: "hooksDenyBypass must be boolean" });
return;
}
const saved = core.setHooksDenySettings({ hooksDenyBypass: body.hooksDenyBypass });
sendJson(response, 200, { ok: true, hooksDenyBypass: saved.hooksDenyBypass });
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
return;
}
response.writeHead(405, { allow: "GET, POST" });
response.end();
},
}, "dsh-purge: hooks-deny");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/approval-mode",
handler: async (request, response) => {
const home = core.findDshHome();
if (request.method === "GET") {
sendJson(response, 200, { ok: true, mode: cfg.approvalMode, gated: ["purge_apply", "purge_revert", "purge_uninstall"] });
return;
}
if (request.method === "POST") {
try {
const body = await readJsonBody(request);
const mode = String(body?.mode || "").toLowerCase();
if (mode !== "audit" && mode !== "enforce") {
sendJson(response, 400, { ok: false, error: "mode must be audit or enforce" });
return;
}
const saved = core.savePurgeSetting(home, "approvalMode", mode);
cfg.approvalMode = mode;
purgeLog("approval-mode", { mode, saved: saved.ok });
sendJson(response, 200, { ok: true, mode, note: "重启 dsh 后对工具调用生效" });
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
return;
}
response.writeHead(405, { allow: "GET, POST" });
response.end();
},
}, "dsh-purge: approval-mode");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/audit",
handler: async (request, response) => {
if (request.method !== "GET") {
response.writeHead(405, { allow: "GET" });
response.end();
return;
}
const url = new URL(String(request.url || "/dsh-purge/audit"), "http://local");
const limit = Number(url.searchParams.get("limit"));
const report = readAuditTail(core.findDshHome(), Number.isFinite(limit) && limit > 0 ? Math.min(200, Math.floor(limit)) : 40);
sendJson(response, 200, { ok: true, text: report.text, total: report.total, truncated: report.truncated });
},
}, "dsh-purge: audit");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/last-user",
handler: async (request, response) => {
if (request.method !== "GET") {
response.writeHead(405, { allow: "GET" });
response.end();
return;
}
try {
const url = new URL(request.url, "http://127.0.0.1");
const sessionId = String(url.searchParams.get("sessionId") || "").trim();
sendJson(response, 200, await rewind.readLastUserText(ctx, sessionId));
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
},
}, "dsh-purge: last user");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/rewind/options",
handler: async (request, response) => {
if (request.method !== "GET") {
response.writeHead(405, { allow: "GET" });
response.end();
return;
}
try {
const url = new URL(request.url, "http://127.0.0.1");
const sessionId = String(url.searchParams.get("sessionId") || "").trim();
sendJson(response, 200, await rewind.analyzeRewind(ctx, sessionId));
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
},
}, "dsh-purge: rewind options");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/rewind",
handler: async (request, response) => {
if (request.method === "GET") {
try {
const url = new URL(request.url, "http://127.0.0.1");
const sessionId = url.searchParams.get("sessionId");
if (sessionId) {
const draft = rewind.takeDraft(sessionId);
sendJson(response, 200, { ok: true, ...(draft || {}) });
return;
}
const pending = rewind.getPending();
sendJson(response, 200, { ok: true, ...(pending || {}) });
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
return;
}
if (request.method === "POST") {
try {
const body = await readJsonBody(request);
const sessionId = String(body?.sessionId || "").trim();
if (!sessionId) {
sendJson(response, 400, { ok: false, error: "需要 sessionId" });
return;
}
const mode = body?.mode === "round" ? "round" : "once";
const result = await rewind.applyRewind(ctx, sessionId, mode);
sendJson(response, result.ok ? 200 : 409, result);
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
return;
}
response.writeHead(405, { allow: "GET, POST" });
response.end();
},
}, "dsh-purge: rewind");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/update",
handler: async (request, response) => {
if (request.method === "GET") {
try {
sendJson(response, 200, await updater.checkUpdate());
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
return;
}
if (request.method === "POST") {
try {
const body = await readJsonBody(request);
const result = await updater.handleUpdateOp(body || {});
if (result && result.needsFullQuit) bootFullQuit = true;
sendJson(response, 200, result);
if (result && result.restartAfter) {
try { hostctl.scheduleRestart(request, ctx); } catch (e) {
console.warn("[dsh-purge] update restart failed:", e);
}
}
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
return;
}
response.writeHead(405, { allow: "GET, POST" });
response.end();
},
}, "dsh-purge: update");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/apply",
handler: async (request, response) => {
if (request.method !== "POST") {
response.writeHead(405, { allow: "POST" });
response.end();
return;
}
try {
const body = await readJsonBody(request);
/* 修订号闸门:两个窗口(Web + 桌面)同时开着时,后点的那个不该覆盖先点的结果。
* 客户端拉状态时拿到 rev,动作时带回来;不一致就 409 让界面重新拉,而不是盲写。 */
const revCheck = checkRev(body?.rev);
if (!revCheck.ok) {
sendJson(response, 409, { ok: false, rev: revCheck.rev, error: revCheck.error, last: revCheck.last, stale: revCheck.stale });
return;
}
const incoming = typeof body?.content === "string" ? body.content : null;
core.applyRuntimeEnv();
core.clearDesktopLocateCache();
const home = core.pinDshHome();
const scrubbed = core.sanitizeDesktopCommandRuntimes();
const state = await core.gatherState();
if (home) state.dsh_home = home;
let located;
try {
located = await core.resolveOfficialAiBase(state.ai_base, { forApply: true });
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e && e.message ? e.message : e), code: e && e.code ? String(e.code) : undefined });
return;
}
if (!located.aiBase) {
sendJson(response, 500, { ok: false, error: core.missingAiBaseMessage() });
return;
}
state.ai_base = located.aiBase;
if (located.extracted) {
const arch = located.handoff?.archive;
if (arch) core.writeUnpackedAsarStamp(arch);
}
const handoff = located.handoff && located.handoff.swapAsar ? located.handoff : null;
const blocked = rejectIfNeedPrompt(state.dsh_home, incoming === null ? undefined : incoming);
if (blocked) {
const ui = await core.readOverrideForUi(state.dsh_home);
sendJson(response, 400, {
ok: false,
needPrompt: true,
override_content: incoming || "",
defaultContent: ui.defaultContent,
...blocked,
});
return;
}
try {
await core.commitOverrideOnApply(state.dsh_home, incoming);
} catch (e) {
console.warn("[dsh-purge] apply override:", String(e && e.message ? e.message : e));
}
const httpMulti = await core.applyPatchesAllHosts(core.ALL_PATCHES, {
preserveClientBundles: false,
forApply: true,
});
const report = httpMulti.report;
const shim = await core.patchAllShims();
let cliRepair = null;
try {
cliRepair = core.repairOfficialCliAfterUnpack();
} catch (e) {
cliRepair = { ok: false, error: String(e && e.message ? e.message : e) };
}
const flash = core.silenceCmdFlash(httpMulti.hosts[0]?.aiBase || state.ai_base);
const summary = core.summarizeApplyReport(report);
const hostWritten =
core.allPatchHostsWritten(httpMulti.hosts) || core.hostCleanMarkersPresent(state.ai_base);
const hookDenyRestoreFailed = (httpMulti.hookDenyRestoreFailed || []).length > 0;
const complete = summary.failed.length === 0 && flash.ok === true && hostWritten && !hookDenyRestoreFailed;
const sealedNow = core.desktopAsarStillSealed();
const promptReady = summary.failed.length > 0 && summary.failed.every((item) => item.status === "pattern_not_found");
if (flash.ok === true && (complete || promptReady) && hostWritten) {
reapply.markPatchesApplied();
}
// 全部洗完且标记进宿主才弹重启;密封包重启时换走 asar。
const shouldRestart = applyShouldRestart({
complete: complete || (promptReady && hostWritten),
sealed: sealedNow,
appliedCount: summary.applied,
promptReady: promptReady && hostWritten,
});
if (shouldRestart && handoff && sealedNow) bootFullQuit = true;
else bootFullQuit = false;
if (shouldRestart) reapply.markSettleRestart();
let promptInjectInstall = "skipped";
if (hostWritten && flash.ok === true) {
// Apply → cleanup → restart 成功后,默认把加密提示词 seed 进 prompt-inject.md。
// 用户自改过的内容保留;仅当文件不存在或仍是插件旧默认时才写。
try {
const seed = core.seedPromptInjectAtBoot(state.dsh_home);
promptInjectInstall = seed?.action || "noop";
} catch (e) {
promptInjectInstall = `error:${String(e && e.message ? e.message : e)}`;
}
}
const hung = refreshHungOperator(state.dsh_home);
const override = await core.readOverrideForUi(state.dsh_home);
sendJson(response, 200, {
ok: true,
complete,
restarting: shouldRestart,
restart_after_apply: shouldRestart,
unpacked_asar: shouldRestart,
needs_full_quit: shouldRestart,
asar_still_sealed: sealedNow,
asar_swap_started: Boolean(shouldRestart && handoff && sealedNow),
applied: summary.applied,
already: summary.already,
failed: summary.failed.length,
failed_items: summary.failed.map((r) => ({
id: r.patch_id,
name: r.name,
status: r.status,
})),
report: report.map((r) => ({ id: r.patch_id, name: r.name, status: r.status })),
override_content: hung.text || override.content,
defaultContent: override.defaultContent,
...injectSnapshot(state.dsh_home),
override_path: override.path,
override_installed: promptInjectInstall,
shim,
desktop_scrubbed: scrubbed,
cli_repair: cliRepair,
cmd_flash: flash,
rev: bumpRev("apply"),
});
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
},
}, "dsh-purge: apply");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/revert",
handler: async (request, response) => {
if (request.method !== "POST") {
response.writeHead(405, { allow: "POST" });
response.end();
return;
}
try {
const body = await readJsonBody(request);
const revCheck = checkRev(body?.rev);
if (!revCheck.ok) {
sendJson(response, 409, { ok: false, rev: revCheck.rev, error: revCheck.error, last: revCheck.last, stale: revCheck.stale });
return;
}
const state = await core.gatherState();
const result = {};
if (state.ai_base) {
const { reverted, errors } = await core.revertAll(state.ai_base);
result.reverted = reverted.length;
result.errors = errors.length;
}
result.shim = await core.revertAllShims();
sendJson(response, 200, { ok: true, ...result, rev: bumpRev("revert"), note: "重启 dsh 恢复; prompt-inject.md 保留" });
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
},
}, "dsh-purge: revert");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/uninstall",
handler: async (request, response) => {
if (request.method !== "POST") {
response.writeHead(405, { allow: "POST" });
response.end();
return;
}
try {
const result = await uninstaller.uninstallPurge();
sendJson(response, result.ok ? 200 : 500, {
...result,
surface: hostctl.currentSurface(),
fullApp: hostctl.currentSurface() === "desktop",
note: result.ok ? hostctl.restartNote() : undefined,
});
if (result.ok) {
uninstaller.scheduleCleanupRestart({
request,
cleanup: result.cleanup,
ctx,
});
}
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e && e.message ? e.message : e), code: e && e.code ? String(e.code) : undefined });
}
},
}, "dsh-purge: uninstall");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/drill-grant",
handler: async (request, response) => {
const home = core.findDshHome();
if (request.method === "GET") {
sendJson(response, 200, { ok: true, ...readDrillGrant(home) });
return;
}
if (request.method === "POST") {
sendJson(response, 200, { ok: true, ...writeDrillGrant(home) });
return;
}
response.writeHead(405, { allow: "GET, POST" });
response.end();
},
}, "dsh-purge: drill-grant");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/override",
handler: async (request, response) => {
if (request.method === "GET") {
try {
const home = core.findDshHome();
// 面板打开 override 面板时,确保默认加密提示词已 seed;用户自改保留。
try { core.seedPromptInjectAtBoot(home); } catch { /* ignore */ }
refreshHungOperator(home);
const ui = await core.readOverrideForUi(home);
sendJson(response, 200, { ok: true, ...ui, ...injectSnapshot(home) });
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
return;
}
if (request.method === "POST") {
try {
const body = await readJsonBody(request);
const content = typeof body?.content === "string" ? body.content : null;
if (content === null) {
sendJson(response, 400, { ok: false, error: "content must be a string" });
return;
}
const home = core.findDshHome();
const blocked = rejectIfNeedPrompt(home, content);
if (blocked) {
sendJson(response, 400, {
ok: false,
content,
...blocked,
});
return;
}
const saved = await core.saveOverrideContent(home, content);
const hung = refreshHungOperator(home);
const ui = await core.readOverrideForUi(home);
sendJson(response, 200, {
ok: true,
content: hung.text,
defaultContent: saved.defaultContent,
customized: ui.customized,
usingDefault: ui.usingDefault,
path: ui.path,
...injectSnapshot(home),
});
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
return;
}
response.writeHead(405, { allow: "GET, POST" });
response.end();
},
}, "dsh-purge: override");
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/own-servers",
handler: async (request, response) => {
const home = core.findDshHome();
if (request.method === "GET") {
sendJson(response, 200, { ok: true, content: readAllowText(home) });
return;
}
if (request.method === "POST") {
try {
const body = await readJsonBody(request);
const content = typeof body?.content === "string" ? body.content : null;
if (content === null) {
sendJson(response, 400, { ok: false, error: "content must be a string" });
return;
}
const saved = await writeAllowText(home, content);
sendJson(response, 200, { ok: true, content: saved.text, dropped: saved.dropped });
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e && e.message ? e.message : e), code: e && e.code ? String(e.code) : undefined });
}
return;
}
response.writeHead(405, { allow: "GET, POST" });
response.end();
},
}, "dsh-purge: own-servers");
trackRoute(installRulesHttp(host.webServer));
trackRoute(installSkillsHttp(host.webServer));
registerPluginRoute({
kind: "exact",
path: "/dsh-purge/restart",
handler: async (request, response) => {
if (request.method !== "POST") {
response.writeHead(405, { allow: "POST" });
response.end();
return;
}
try {
const ready = await ensureRestartReady();
if (!ready.ok) {
bootFullQuit = false;
sendJson(response, 200, {
ok: false,
error: ready.error || "清洗没有完成,已取消重启",
restarting: false,
pending: ready.pending || [],
});
return;
}
const surface = hostctl.currentSurface();
let planned = null;
if (core.desktopAsarStillSealed()) {
planned = core.restartSealedDesktopIfNeeded({ ctx });
} else {
planned = hostctl.scheduleRestart(request, ctx);
}
if (planned && planned.clean === false) {
bootFullQuit = false;
sendJson(response, 200, {
ok: false,
error: "清洗没有写进当前宿主,已取消重启",
restarting: false,
});
return;
}
// 用户已点重启:清掉粘性标记,避免同进程误刷新/再进面板又弹一次。
bootFullQuit = false;
const restarting = Boolean(planned && (planned.restarting || planned.asarSwap));
const restartError = planned && planned.error ? String(planned.error) : "";
sendJson(response, 200, {
ok: restarting || !restartError,
surface,
fullApp: surface === "desktop",
restarting,
already: Boolean(planned && planned.already),
needsFullQuit: Boolean(planned && planned.asarSwap),
asar_still_sealed: core.desktopAsarStillSealed(),
error: restartError || undefined,
note: restarting ? "正在重启。" : restartError,
});
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
}
},
}, "dsh-purge: restart");
return () => {
for (let i = routeDisposers.length - 1; i >= 0; i -= 1) {
try { routeDisposers[i](); } catch { /* ignore */ }
}
routeDisposers.length = 0;
};
}, "dsh-purge: http routes");
});
}
function methodNotAllowed(request, response, allow) {
if (allow.includes(request.method)) return false;
response.writeHead(405, { allow: allow.join(", ") });
response.end();
return true;
}
async function handleRulesOp(op, body, queryId, queryCwd) {
const dshHome = core.findDshHome();
const id = typeof body?.id === "string" ? body.id : String(queryId || "");
const cwdHint = typeof body?.cwd === "string" && body.cwd.trim()
? body.cwd.trim()
: (typeof queryCwd === "string" && queryCwd.trim() ? queryCwd.trim() : "");
if (cwdHint) {
try { rules.writeLastCwd(dshHome, cwdHint); } catch { /* ignore */ }
}
if (op === "status") {
return { ok: true, ...(await rules.rulesStatus(dshHome, cwdHint)) };
}
if (op === "scan-cwd" || op === "scan") {
const effective = cwdHint || rules.readLastCwd(dshHome);
const candidates = effective ? rules.scanCwdInstructions(effective) : [];
const selections = rules.readCwdSelections(dshHome);
return {
ok: true,
cwd: effective,
candidates: candidates.map((item) => ({ ...item, selected: selections.includes(item.path) })),
selections,
};
}
if (op === "toggle-cwd") {
const target = typeof body?.path === "string" ? body.path.trim() : "";
if (!target) return { status: 400, ok: false, error: "path required" };
const enabled = body?.enabled !== false;
const next = await rules.toggleCwdSelection(dshHome, target, enabled);
return { ok: true, selections: next };
}
if (op === "set-policy" || op === "workspace-policy") {
const policy = typeof body?.policy === "string" ? body.policy.trim() : "";
if (!rules.WORKSPACE_POLICIES.includes(policy)) {
return { status: 400, ok: false, error: `invalid policy: ${policy}` };
}
const applied = await rules.writeWorkspacePolicy(dshHome, policy);
return { ok: true, policy: applied, policies: [...rules.WORKSPACE_POLICIES] };
}
if (op === "read") {
if (!rules.validRuleId(id)) return { status: 400, ok: false, error: "invalid id" };
const content = await rules.readRule(dshHome, id);
if (content === null) return { status: 404, ok: false, error: `rule not found: ${id}` };
const meta = await rules.readRuleMeta(dshHome, id);
return { ok: true, id, name: meta.name, target: meta.target, content };
}
if (op === "save") {
const content = typeof body?.content === "string" ? body.content : null;
if (!rules.validRuleId(id) || content === null) {
return { status: 400, ok: false, error: "id and content (string) required" };
}
const target = typeof body?.target === "string" ? body.target : undefined;
// 旧版本直接把 body.target 写进 rules.saveRule;非法目标会污染 agents_path 推导。
if (target !== undefined && !rules.validTarget(target)) {
return { status: 400, ok: false, error: `invalid target: ${target}(只能是 AGENTS.md 或 CLAUDE.md)` };
}
await rules.saveRule(dshHome, id, content, {
name: typeof body?.name === "string" ? body.name : undefined,
target,
});
refreshHungOperator(dshHome);
return { ok: true, id, ...injectSnapshot(dshHome) };
}
if (op === "activate") {
if (!rules.validRuleId(id)) return { status: 400, ok: false, error: "invalid id" };
const meta = await rules.activateRule(dshHome, id);
refreshHungOperator(dshHome);
return {
ok: true,
id,
name: meta.name,
target: meta.target,
agents_path: rules.targetPath(dshHome, meta.target),
note: "已写入目标文件并注入 systemPrompt,新会话生效",
...injectSnapshot(dshHome),
};
}
if (op === "delete") {
if (!rules.validRuleId(id)) return { status: 400, ok: false, error: "invalid id" };
await rules.deleteRule(dshHome, id);
refreshHungOperator(dshHome);
return { ok: true, id };
}
if (op === "reset") {
const r = await rules.resetToOriginal(dshHome);
refreshHungOperator(dshHome);
return { ok: true, ...r, note: "已还原出厂状态(无全局指令);规则库保留" };
}
return { status: 400, ok: false, error: `unknown op: ${op}` };
}
function rulesHttpHandler(forcedOp) {
return async (request, response) => {
if (request.method === "OPTIONS") {
sendOptions(response);
return;
}
try {
const url = new URL(request.url ?? "", "http://127.0.0.1");
const tail = url.pathname.replace(/^\/dsh-purge\/rules\/?/, "").replace(/\/$/, "");
let body = {};
if (request.method === "POST") body = await readJsonBody(request);
else if (methodNotAllowed(request, response, forcedOp ? ["GET", "POST"] : ["GET", "POST"])) return;
const op = forcedOp || body.op || tail || "status";
const result = await handleRulesOp(op, body, url.searchParams.get("id"), url.searchParams.get("cwd"));
sendJson(response, result.status || 200, result);
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e && e.message ? e.message : e), code: e && e.code ? String(e.code) : undefined });
}
};
}
async function handleSkillsOp(op, body, queryId) {
const dshHome = core.findDshHome();
const id = typeof body?.id === "string" ? body.id : String(queryId || "");
if (op === "status") {
return { ok: true, ...(await skills.skillsStatus(dshHome)) };
}
if (op === "read") {
if (!skills.validSkillName(id)) return { status: 400, ok: false, error: "invalid skill name" };
const item = await skills.readSkill(dshHome, id);
if (!item) return { status: 404, ok: false, error: `skill not found: ${id}` };
return { ok: true, ...skills.toPublicSkill(dshHome, item) };
}
if (op === "save") {
if (!skills.validSkillName(id)) return { status: 400, ok: false, error: "id 必须是 kebab-case,例如 code-review" };
const content = typeof body?.content === "string" ? body.content : "";
const description = typeof body?.description === "string" ? body.description : "";
const saved = await skills.saveSkill(dshHome, id, content, description);
return { ok: true, ...skills.toPublicSkill(dshHome, saved) };
}
if (op === "delete") {
if (!skills.validSkillName(id)) return { status: 400, ok: false, error: "invalid skill name" };
const removed = await skills.deleteSkill(dshHome, id);
return { ok: true, ...skills.toPublicSkill(dshHome, removed) };
}
if (op === "import") {
if (body?.path || body?.source || body?.folder) {
return { status: 400, ok: false, error: "请在本机选择压缩包或文件夹导入" };
}
if (typeof body?.data === "string" && body.data.trim()) {
const packed = await skills.importArchive(dshHome, Buffer.from(body.data, "base64"), body.name || "skill.zip");
return { ok: true, ...packed };
}
if (Array.isArray(body?.files) && body.files.length) {
const packed = await skills.importFileTree(dshHome, body.files, body.name || "");
return { ok: true, ...packed };
}
return { status: 400, ok: false, error: "请选择压缩包或文件夹" };
}
return { status: 400, ok: false, error: `unknown op: ${op}` };
}
function skillsHttpHandler(forcedOp) {
return async (request, response) => {
if (request.method === "OPTIONS") {
sendOptions(response);
return;
}
if (request.method !== "GET" && request.method !== "POST") {
response.writeHead(405, { allow: "GET, POST, OPTIONS" });
response.end();
return;
}
try {
const url = new URL(request.url ?? "", "http://127.0.0.1");
const tail = skillRouteTail(url.pathname);
const body = request.method === "POST" ? await readJsonBody(request, 14 * 1024 * 1024) : {};
const op = forcedOp
|| body.op
|| tail
|| url.searchParams.get("op")
|| (url.searchParams.get("id") ? "read" : "status");
const result = await handleSkillsOp(op, body, url.searchParams.get("id"));
sendJson(response, result.status || 200, result);
} catch (e) {
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e && e.message ? e.message : e), code: e && e.code ? String(e.code) : undefined });
}
};
}
function skillRouteTail(pathname) {
const match = String(pathname || "").match(/^\/dsh-purge\/skills?(?:\/(.*))?$/i);
if (!match) return "";
return String(match[1] || "").replace(/\/$/, "");
}
function installSkillsHttp(webServer) {
// 旧版把 /dsh-purge/skills[/op] 和 /dsh-purge/skill[/op] 分别 exact 注册 10+ 条,再加 prefix 兜底;
// handler 内部本来就按 tail 分发 op,exact 列表只是把路由表撑大,热重载还更难拆。
// 用 2 条 prefix 覆盖全部,handler 的 forcedOp="" 让 body.op/tail/op 查询串继续生效。
const stop = [];
stop.push(registerRoute(webServer, {
kind: "prefix", path: "/dsh-purge/skills", handler: skillsHttpHandler(""),
}));
stop.push(registerRoute(webServer, {
kind: "prefix", path: "/dsh-purge/skill", handler: skillsHttpHandler(""),
}));
return () => {
for (let i = stop.length - 1; i >= 0; i -= 1) {
try { stop[i]?.(); } catch { /* ignore */ }
}
};
}
function installRulesHttp(webServer) {
// 同 skills:prefix 一条覆盖 /dsh-purge/rules[/op];handler 按 tail 分发 op。
const stop = [];
stop.push(registerRoute(webServer, {
kind: "prefix", path: "/dsh-purge/rules", handler: rulesHttpHandler(""),
}));
return () => {
for (let i = stop.length - 1; i >= 0; i -= 1) {
try { stop[i]?.(); } catch { /* ignore */ }
}
};
}
/* 统一出口:先做无损投影再序列化。
* 旧实现直接 JSON.stringify —— 遇到 BigInt 抛、循环引用抛、undefined 静默丢字段,
* 结果是一个本该成功的操作变成 500,或者 200 里少了关键字段而界面以为「没数据」。
* safeProject 永不抛,坏字段会变成带 __invalid 标记的对象,调用方仍能诊断。 */
function sendJson(response, status, payload) {
let body;
try {
body = stringifySafe(payload);
} catch (error) {
body = JSON.stringify({ ok: false, error: `encode failed: ${String(error && error.message ? error.message : error)}` });
}
response.writeHead(status, {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
});
response.end(body);
}
function sendOptions(response) {
response.writeHead(204, { "cache-control": "no-store" });
response.end();
}
function registerRoute(webServer, spec) {
try {
const handler = spec.handler;
return webServer.register({
...spec,
handler: async (request, response) => {
if (!allowPluginRequest(request, response)) return;
return handler(request, response);
},
});
} catch (e) {
console.warn("[dsh-purge] route", spec?.path, String(e && e.message ? e.message : e));
return () => {};
}
}
/* 读请求体并给出**可分类**的失败:调用方据此回 413 / 400,而不是把
* 所有异常都压成 500「请求失败」。旧实现只抛一句 Error,界面无法区分
* 「内容太大」和「JSON 写错了」,用户看到的永远是同一个红字。 */
async function readJsonBody(request, maxBytes = 512 * 1024) {
const chunks = [];
let size = 0;
const limit = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 512 * 1024;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > limit) {
const error = new Error(`request body too large: ${size} > ${limit}`);
error.code = "BODY_TOO_LARGE";
error.status = 413;
error.limit = limit;
throw error;
}
chunks.push(buffer);
}
const raw = Buffer.concat(chunks).toString("utf8").trim();
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (parsed === null || typeof parsed !== "object") {
const error = new Error("request body must be a JSON object");
error.code = "BODY_NOT_OBJECT";
error.status = 400;
throw error;
}
return parsed;
} catch (error) {
if (error.code === "BODY_NOT_OBJECT") throw error;
const wrapped = new Error(`invalid JSON body: ${String(error && error.message ? error.message : error)}`);
wrapped.code = "BODY_INVALID_JSON";
wrapped.status = 400;
throw wrapped;
}
}
/** 把 readJsonBody 的分类错误翻成 HTTP 状态;其余异常仍是 500。 */
function bodyErrorStatus(error) {
const status = Number(error && error.status);
return Number.isFinite(status) && status >= 400 && status < 600 ? status : 500;
}
const plugin = {
name,
inject,
apply,
};
export default plugin;