2914 lines
119 KiB
JavaScript
2914 lines
119 KiB
JavaScript
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,
|
||
clearAssembleGuard,
|
||
modePromptText,
|
||
peelChatmlEnvelope,
|
||
escapeDshInterpolation,
|
||
resolveCwdFromContext,
|
||
rewritePromptAssembly,
|
||
} from "./identity.js";
|
||
import { capabilityProtocolText, CAPABILITY_SECTION } from "./capability-protocol.js";
|
||
import { registerAnchor, anchorTextFor, ANCHOR_SECTION } from "./anchor.js";
|
||
import { installSubagentControl } from "./subagent-control.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 {
|
||
cancelClearOwnedSelectedDefault,
|
||
clearOwnedSelectedDefault,
|
||
removeStaleAgentPresetDir,
|
||
scheduleClearOwnedSelectedDefault,
|
||
} from "./preset-default.js";
|
||
import {
|
||
applyShouldRestart,
|
||
isInactiveContextError,
|
||
settleDelayMs,
|
||
settleMayReapply,
|
||
settleMayRestart,
|
||
watchContextLife,
|
||
} from "./settle-policy.js";
|
||
import { ensureAllProfileInstall } from "./profile-insert.js";
|
||
import {
|
||
beginApply,
|
||
beginJob,
|
||
endApply,
|
||
getApplyProgress,
|
||
resetApplyProgress,
|
||
setApplyProgress,
|
||
} from "./apply-progress.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,
|
||
anchorMode: "auto", // "auto" | "off" —— off 立即关锚点,不用改代码
|
||
subagentControl: true, // false 立即回滚子代理审批接管
|
||
};
|
||
|
||
/* YAML 里写成 "false" 的布尔会被 JS 当真值:两个默认开启的开关一旦被写成字符串,
|
||
用户以为关了其实还开着。统一收敛一次。 */
|
||
export const BOOLEAN_CONFIG_KEYS = [
|
||
"enabled", "autoApplyOnStart", "autoUpdateOnStart", "autoRevertOnMissing",
|
||
"injectOnce", "stripMnemon", "verbose", "autoRetry", "autoContinue",
|
||
"capabilityProtocol", "hooksDenyBypass", "subagentControl",
|
||
];
|
||
|
||
export function normalizeConfig(raw) {
|
||
const cfg = { ...DEFAULTS, ...(raw || {}) };
|
||
for (const key of BOOLEAN_CONFIG_KEYS) {
|
||
const value = cfg[key];
|
||
if (typeof value === "string") cfg[key] = !/^(false|0|no|off)$/i.test(value.trim());
|
||
}
|
||
return cfg;
|
||
}
|
||
|
||
const log = (config, ...args) => {
|
||
if (config?.verbose) console.log("[dsh-purge]", ...args);
|
||
};
|
||
|
||
let webTreeReady = false;
|
||
let lastStatusPayload = null;
|
||
|
||
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`
|
||
+ (displayTally.drifted ? `, ${displayTally.drifted} drifted(官方原文已改,需更新 pattern)` : ""));
|
||
if (state.host_version) {
|
||
const supported = state.host_supported !== false;
|
||
const compat = supported
|
||
? ""
|
||
: ` ← 未适配:插件声明 ${state.host_target || "?"}`;
|
||
out.push(`宿主 / host: ${state.host_version}${compat}`);
|
||
}
|
||
for (const row of displayRows) {
|
||
const s = row.status || "n/a";
|
||
const mark = s === "applied" ? "✓" : s === "pending" ? "✗" : s === "drifted" ? "!" : "·";
|
||
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)。 */
|
||
// 缓存按 (home, mtime, 提交版本) 失效,不再靠 200ms 时间窗:
|
||
// host 每轮 refresh 都会调 __dshPurgeHungPrompt,时间窗一过就全量 sync + 解密 23KB,
|
||
// 桌面宿主在这段时间里被拖住(#194)。只有文件或内存正文真的变了才重算。
|
||
let hungCache = { home: "", mtime: 0, rev: -1, resolved: null };
|
||
// 风暴熔断:正常一轮请求只有个位数调用;10s 内上百次说明有循环在踩,
|
||
// 直接返回上一次结果,避免继续拖住宿主主线程。
|
||
const HUNG_STORM_WINDOW_MS = 10000;
|
||
const HUNG_STORM_LIMIT = 120;
|
||
let hungStorm = { at: 0, calls: 0, tripped: false };
|
||
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);
|
||
const rev = core.committedOverrideRevision();
|
||
if (
|
||
hungCache.resolved
|
||
&& hungCache.home === home
|
||
&& hungCache.mtime === mtimeBefore
|
||
&& hungCache.rev === rev
|
||
) {
|
||
return hungCache.resolved;
|
||
}
|
||
if (now - hungStorm.at > HUNG_STORM_WINDOW_MS) {
|
||
hungStorm = { at: now, calls: 0, tripped: false };
|
||
}
|
||
hungStorm.calls += 1;
|
||
if (hungStorm.calls > HUNG_STORM_LIMIT) {
|
||
if (!hungStorm.tripped) {
|
||
hungStorm.tripped = true;
|
||
purgeLog("host.hungStorm", { calls: hungStorm.calls, windowMs: HUNG_STORM_WINDOW_MS });
|
||
}
|
||
if (hungCache.resolved) return hungCache.resolved;
|
||
const pinned = core.hungOperatorPrompt() || "";
|
||
if (pinned) return { text: pinned, source: "storm", ruleId: null };
|
||
}
|
||
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),
|
||
rev: core.committedOverrideRevision(),
|
||
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 — 加密槽解包,除非插件安装残缺否则非空
|
||
// 超时熔断(#194):这是 host 主线程同步调用的函数,一次全量 resolve 若被磁盘/解密
|
||
// 拖到秒级,宿主就整段挂住。连续慢调用后进入冷却期,只交内存里已挂上的正文。
|
||
const HUNG_SLOW_MS = 1500;
|
||
const HUNG_SLOW_COOLDOWN_MS = 30000;
|
||
let hungSlowAt = 0;
|
||
let hungDepth = 0;
|
||
const fn = () => {
|
||
const norm = (s) => peelChatmlEnvelope(String(s || "").replace(/\r\n/g, "\n"));
|
||
// 重入保护:同一轮里再进来只回内存钉子,绝不再走磁盘。
|
||
if (hungDepth > 0) return norm(core.hungOperatorPrompt());
|
||
if (hungSlowAt && Date.now() - hungSlowAt < HUNG_SLOW_COOLDOWN_MS) {
|
||
const pinned = norm(core.hungOperatorPrompt());
|
||
if (pinned) {
|
||
purgeLog("host.hungFn", { tier: "breaker", bytes: pinned.length });
|
||
return pinned;
|
||
}
|
||
}
|
||
let home = "";
|
||
try { home = core.findDshHome(); } catch { home = ""; }
|
||
hungDepth += 1;
|
||
const started = Date.now();
|
||
try {
|
||
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 "";
|
||
} finally {
|
||
hungDepth -= 1;
|
||
const cost = Date.now() - started;
|
||
if (cost >= HUNG_SLOW_MS) {
|
||
hungSlowAt = Date.now();
|
||
purgeLog("host.hungFn", { tier: "slow", ms: cost });
|
||
}
|
||
}
|
||
};
|
||
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",
|
||
};
|
||
}
|
||
|
||
/** 框里正在显示的字优先;框空则 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: false,
|
||
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 覆盖 */ }
|
||
}
|
||
|
||
// assemble 可能被 guard 和 waterfall 各处理一次;同一对象只改写一次。
|
||
const liveRewriteCache = new WeakMap();
|
||
|
||
function rewriteLiveAssembly(config, assembled, context, decision = { mode: "full", taskKind: "general" }) {
|
||
// 同一份 assembly 可能同时经过 assemble guard 和 system-prompt/assemble 瀑布:
|
||
// 第二次进来内容没变,没必要再走一遍 22k 正文处理。WeakMap 随对象回收,不累积。
|
||
// 缓存键必须带上配置与会话签名:宿主复用同一个 assembly 对象时,光按对象做键
|
||
// 会把上一轮的改写结果套到这一轮(改了开关/换了会话却看不到变化)。
|
||
const sigHome = config.enabled !== false ? core.findDshHome() : "";
|
||
const sigPolicy = config.enabled !== false && sigHome ? rules.readWorkspacePolicy(sigHome) : "always";
|
||
const sigRedteam = config.enabled !== false && isRedteamSession(context);
|
||
const sigRevision = config.enabled !== false ? core.committedOverrideRevision() : "";
|
||
const cacheSig = [
|
||
config.enabled !== false,
|
||
config.capabilityProtocol !== false,
|
||
config.stripMnemon !== false,
|
||
config.anchorMode || "",
|
||
config.subagentControl !== false,
|
||
decision && decision.mode,
|
||
decision && decision.taskKind,
|
||
sigRedteam,
|
||
sigPolicy,
|
||
sigRevision,
|
||
(() => { try { const h = context?.agent?.session?.header || {}; return `${h.id || h.sessionId || ""}|${h.cwd || ""}`; } catch { return ""; } })(),
|
||
].join("");
|
||
if (assembled && typeof assembled === "object") {
|
||
const cached = liveRewriteCache.get(assembled);
|
||
if (cached && cached.sig === cacheSig) return cached.value;
|
||
}
|
||
const enabled = config.enabled !== false;
|
||
const redteam = sigRedteam;
|
||
const full = enabled ? readInjectFallback(context) : "";
|
||
const dshHome = sigHome;
|
||
const cwd = resolveCwdFromContext(context);
|
||
if (dshHome && cwd) {
|
||
try { rules.writeLastCwd(dshHome, cwd); } catch { /* ignore */ }
|
||
}
|
||
const policy = sigPolicy;
|
||
// 提示词框为空时才启用勾选的本地 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 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,
|
||
anchorText: enabled ? anchorTextFor(config) : "",
|
||
});
|
||
const anchorApplied = Array.isArray(rewritten?.sections)
|
||
&& rewritten.sections.some((section) => String(section?.name || "") === ANCHOR_SECTION);
|
||
const closerFollow = closerFollowBytes(rewritten?.sections);
|
||
// 主会话审批策略探针:never 会让 decide() 在 approval/request 瀑布之前直接 rejected,
|
||
// 这是「一直拒绝」最直接的证据,必须随每轮装配打出来。
|
||
const approvalPolicy = (() => {
|
||
try {
|
||
const session = context?.agent?.session;
|
||
const svc = context?.agent?.ctx?.get?.("approval");
|
||
return typeof svc?.effectivePolicy === "function" ? svc.effectivePolicy(session) : "n/a";
|
||
} catch { return "n/a"; }
|
||
})();
|
||
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,
|
||
approval: approvalPolicy,
|
||
dropWorkspaceBaseline,
|
||
injectHead: Array.isArray(rewritten?.sections) && rewritten.sections[0]?.name === "dsh-purge",
|
||
// 接上锚点之后最后一段的 name 是 dsh-purge:anchor,不是 dsh-purge。
|
||
injectTail: Array.isArray(rewritten?.sections) && rewritten.sections.length > 0
|
||
&& /^dsh-purge(:|$)/.test(String(rewritten.sections[rewritten.sections.length - 1]?.name || "")),
|
||
chatml: Array.isArray(rewritten?.sections) && /<project_instructions>|<\|im_start\|>/i.test(String(rewritten.sections[0]?.text || "")),
|
||
afterCloser: closerFollow,
|
||
cwd,
|
||
});
|
||
if (assembled && typeof assembled === "object" && rewritten && typeof rewritten === "object") {
|
||
liveRewriteCache.set(assembled, { sig: cacheSig, value: rewritten });
|
||
}
|
||
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);
|
||
}
|
||
|
||
/** 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 parentPermissionPreset(ctx, session) {
|
||
const parentId = session?.header?.parentSession;
|
||
if (!parentId || !ctx) return "";
|
||
try {
|
||
const registry = ctx.agents || ctx.get?.("agents");
|
||
const parent = registry?.get?.(parentId);
|
||
let parentSession = parent?.session;
|
||
if (!parentSession) {
|
||
const store = ctx.get?.("sessions");
|
||
parentSession = typeof store?.get === "function" ? store.get(parentId) : null;
|
||
}
|
||
if (!parentSession) return "";
|
||
const svc = permissionPresetsOf(ctx);
|
||
const state = typeof svc?.permissionState === "function" ? svc.permissionState(parentSession) : null;
|
||
return state?.preset || parentSession?.header?.permissionPreset || "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function shouldKeepFreshFullAccess(session, svc) {
|
||
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) return;
|
||
const inherited = parentPermissionPreset(ctx, session);
|
||
if (inherited) {
|
||
const state = typeof svc.permissionState === "function" ? svc.permissionState(session) : null;
|
||
if (state?.preset !== inherited) svc.set(session, inherited);
|
||
return;
|
||
}
|
||
if (!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 pinExistingSessionAccess(ctx) {
|
||
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 || []) upgradeFreshSessionAccess(ctx, session);
|
||
} catch { /* 注册表还没起来时等 session/created */ }
|
||
}
|
||
|
||
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 */ }
|
||
queueMicrotask(() => pinExistingSessionAccess(ctx));
|
||
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) => {
|
||
// 停用后旧 guard(包括隔离 loopCtx 上那份)必须立刻失效。不枚举 prompt:
|
||
// 每个 rewriteFn 都捕获同一个 ctx,读一个运行时标志即可。
|
||
if (ctx.__dshPurgeDisabled === true) return assembled;
|
||
// [方案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);
|
||
// 不补这一行,切换隔离上下文后锚点会消失。
|
||
registerAnchor(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;
|
||
/* 这两面 flag 以前没人清:热重载后 MnemonStrip / RegionGate 以为还装着,直接跳过。 */
|
||
delete ctx.__dshPurgeMnemonStrip;
|
||
delete ctx.__dshPurgeRegionGate;
|
||
// [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,不替代提示词",
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 停用插件时把运行期钩子收干净。
|
||
* 宿主侧的补丁还在(要等用户 Apply/卸载才还原),它们靠 typeof 判断调用全局钩子;
|
||
* 这里把全局钩子换成空实现 —— 既不会让宿主崩,也不会在停用后继续注入提示词。
|
||
*/
|
||
function resetRuntimeHooks(ctx) {
|
||
for (const key of [
|
||
"__dshPurgeSessionHook", "__dshPurgePresetHook", "__dshPurgePreStepHook",
|
||
"__dshPurgeAssembleHook", "__dshPurgeMnemonStrip", "__dshPurgeRegionGate",
|
||
]) {
|
||
try { delete ctx[key]; } catch { /* ignore */ }
|
||
}
|
||
try { globalThis.__dshPurgeHungPrompt = () => ""; } catch { /* ignore */ }
|
||
try { globalThis.__dshPurgePrepareAssemble = () => null; } catch { /* ignore */ }
|
||
// guard 的 dispose 是 noop,停用后 slot.rewrite 仍指向旧闭包(enabled:true)→ 继续注入。
|
||
try { clearAssembleGuard(ctx.systemPrompt); } catch { /* 无 systemPrompt 时无需清理 */ }
|
||
// 隔离 loopCtx 上的 guard 与主 ctx 共用这个标志,一次置位全部失效。
|
||
try { ctx.__dshPurgeDisabled = true; } catch { /* ignore */ }
|
||
}
|
||
|
||
export function apply(ctx, config) {
|
||
const cfg = normalizeConfig(config);
|
||
if (!cfg.enabled) {
|
||
purgeLog("boot", { enabled: false });
|
||
try { clearOwnedSelectedDefault({ ctx, dshHome: core.findDshHome() }); } catch { /* 停用不被清默认挡住 */ }
|
||
try { removeStaleAgentPresetDir(core.findDshHome(), "redteam"); } catch { /* ignore */ }
|
||
resetRuntimeHooks(ctx);
|
||
return;
|
||
}
|
||
try { delete ctx.__dshPurgeDisabled; } catch { /* ignore */ }
|
||
cancelClearOwnedSelectedDefault();
|
||
try {
|
||
ctx.effect(() => () => {
|
||
try { scheduleClearOwnedSelectedDefault({ ctx, dshHome: core.findDshHome() }); } catch { /* 停用不被清默认挡住 */ }
|
||
});
|
||
} catch { /* 无 effect 时靠 uninstall / enabled:false 清 */ }
|
||
purgeLog("boot", {
|
||
enabled: true,
|
||
surface: hostctl.currentSurface(),
|
||
stripMnemon: cfg.stripMnemon !== false,
|
||
cap: cfg.capabilityProtocol !== false,
|
||
});
|
||
core.setHooksDenyConfigValue(cfg.hooksDenyBypass);
|
||
core.refreshHooksDenySettings();
|
||
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");
|
||
try {
|
||
const home = core.pinDshHome() || core.findDshHome();
|
||
const healed = ensureAllProfileInstall(home);
|
||
for (const item of healed) {
|
||
if (item.insert?.wrote || item.bundles?.wrote) {
|
||
log(cfg, "profile heal:", item.dir, item.insert?.wrote ? "insert" : "", item.bundles?.wrote ? "bundles" : "");
|
||
}
|
||
}
|
||
} catch { /* 自愈失败不挡主入口 */ }
|
||
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 isDuplicateCommandError(error) {
|
||
return /is already registered|already registered/i.test(String(error && error.message || error));
|
||
}
|
||
|
||
/** 逐命令注册:撞名只丢那一条,可退到备用名(#162)。 */
|
||
function tryRegisterCommand(commands, spec, fallbacks = []) {
|
||
const names = [spec.name, ...fallbacks];
|
||
for (const name of names) {
|
||
try {
|
||
commands.register({ ...spec, name });
|
||
if (name !== spec.name) {
|
||
console.warn(`[dsh-purge] /${spec.name} taken, registered /${name}`);
|
||
}
|
||
return name;
|
||
} catch (error) {
|
||
if (isDuplicateCommandError(error)) continue;
|
||
throw error;
|
||
}
|
||
}
|
||
console.warn(`[dsh-purge] skip /${spec.name}: already registered`);
|
||
return null;
|
||
}
|
||
|
||
function applyHostHooks(ctx, cfg) {
|
||
try {
|
||
const home = core.pinDshHome() || core.findDshHome();
|
||
refreshHungOperator(home);
|
||
} catch { /* 主目录尚未就绪 */ }
|
||
// 桌面端 office 技能缺失会让启动直接退出;异步补回,别挡启动热路径。
|
||
if (hostctl.currentSurface() === "desktop") {
|
||
setImmediate(() => {
|
||
try {
|
||
const office = core.healOfficeSkillsRuntimeOnDisk();
|
||
if (office && office.healed) purgeLog("heal-office-skills", office);
|
||
} catch { /* 没装桌面或目录不可写 */ }
|
||
});
|
||
}
|
||
installIdentityOverride(ctx, cfg);
|
||
installRegionGate(ctx);
|
||
installFreshFullAccess(ctx);
|
||
installRewindDeriveFilter(ctx);
|
||
installCwdCapture(ctx);
|
||
installMnemonMessageStrip(ctx, cfg);
|
||
registerPromptSections(ctx, cfg);
|
||
// 尾部锚点 + 子代理审批接管。两者都返回 disposer,统一挂到 ctx.effect 回收;
|
||
// 老宿主没有 effect 时靠 cordis scope 自己回收。
|
||
try {
|
||
const disposeAnchor = registerAnchor(ctx, cfg);
|
||
const disposeSubagent = installSubagentControl(ctx, cfg);
|
||
try {
|
||
ctx.effect(() => () => {
|
||
try { disposeAnchor?.(); } catch { /* 释放过就忽略 */ }
|
||
try { disposeSubagent?.(); } catch { /* 释放过就忽略 */ }
|
||
});
|
||
} catch { /* 已释放 */ }
|
||
} catch (e) {
|
||
purgeLog("apply-hooks", { name: "anchor/subagent", err: String(e?.message || e) });
|
||
}
|
||
|
||
// 本次 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) {
|
||
tryRegisterCommand(commands, {
|
||
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),
|
||
}, ["purge-cmd"]);
|
||
tryRegisterCommand(commands, {
|
||
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 ?? ""),
|
||
}, ["purge-rules"]);
|
||
tryRegisterCommand(commands, {
|
||
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 ?? ""),
|
||
}, ["purge-skills"]);
|
||
tryRegisterCommand(commands, {
|
||
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"
|
||
? "已回退上一轮。还在这条对话里,上一句已放回输入框。再发送时,之前的回复不会带上。"
|
||
: "已回退一次。还在这条对话里,上一句已放回输入框。再发送时,之前的回复不会带上。",
|
||
};
|
||
},
|
||
}, ["purge-rewind"]);
|
||
}
|
||
|
||
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}` : "";
|
||
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}`);
|
||
return { text: `回滚完成 / ${lines.join(", ")}. 重启 dsh 恢复.` };
|
||
},
|
||
});
|
||
}
|
||
|
||
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 {
|
||
const progress = getApplyProgress();
|
||
if (progress.active) {
|
||
const base = lastStatusPayload || { ok: true };
|
||
sendJson(response, 200, {
|
||
...base,
|
||
ok: true,
|
||
ready: webTreeReady || Boolean(lastStatusPayload),
|
||
apply_progress: progress,
|
||
});
|
||
return;
|
||
}
|
||
if (!webTreeReady) {
|
||
sendJson(response, 503, { ok: false, ready: false, apply_progress: progress });
|
||
return;
|
||
}
|
||
const state = await core.gatherState();
|
||
const patch_names = {};
|
||
for (const p of core.ALL_PATCHES) patch_names[String(p.id)] = p.name;
|
||
const payload = {
|
||
ok: true,
|
||
ready: true,
|
||
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_drifted: state.display_drifted ?? state.patches_drifted ?? 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,
|
||
host_version: state.host_version || "",
|
||
host_target: state.host_target || "",
|
||
host_supported: state.host_supported !== false,
|
||
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: [] }))),
|
||
};
|
||
lastStatusPayload = payload;
|
||
sendJson(response, 200, { ...payload, apply_progress: progress });
|
||
} catch (e) {
|
||
sendJson(response, 500, { ok: false, error: String(e), apply_progress: getApplyProgress() });
|
||
}
|
||
},
|
||
}, "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, 500, { ok: false, error: String(e) });
|
||
}
|
||
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, 500, { ok: false, error: String(e) });
|
||
}
|
||
return;
|
||
}
|
||
response.writeHead(405, { allow: "GET, POST" });
|
||
response.end();
|
||
},
|
||
}, "dsh-purge: hooks-deny");
|
||
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, 500, { ok: false, error: String(e) });
|
||
}
|
||
},
|
||
}, "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, 500, { ok: false, error: String(e) });
|
||
}
|
||
},
|
||
}, "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, 500, { ok: false, error: String(e) });
|
||
}
|
||
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, 500, { ok: false, error: String(e) });
|
||
}
|
||
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, 500, { ok: false, error: String(e) });
|
||
}
|
||
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, 500, { ok: false, error: String(e) });
|
||
}
|
||
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;
|
||
}
|
||
beginApply("home");
|
||
try {
|
||
const body = await readJsonBody(request);
|
||
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;
|
||
setApplyProgress({ stage: "locate" });
|
||
try {
|
||
located = await core.resolveOfficialAiBase(state.ai_base, { forApply: true });
|
||
} catch (e) {
|
||
endApply({ stage: "locate", error: String(e && e.message ? e.message : e) });
|
||
sendJson(response, 500, { ok: false, error: String(e && e.message ? e.message : e) });
|
||
return;
|
||
}
|
||
if (!located.aiBase) {
|
||
endApply({ stage: "locate", error: "no_base" });
|
||
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) {
|
||
resetApplyProgress();
|
||
const ui = await core.readOverrideForUi(state.dsh_home);
|
||
sendJson(response, 400, {
|
||
ok: false,
|
||
needPrompt: true,
|
||
override_content: incoming || "",
|
||
defaultContent: ui.defaultContent,
|
||
...blocked,
|
||
});
|
||
return;
|
||
}
|
||
setApplyProgress({ stage: "override" });
|
||
try {
|
||
await core.commitOverrideOnApply(state.dsh_home, incoming);
|
||
} catch (e) {
|
||
console.warn("[dsh-purge] apply override:", String(e && e.message ? e.message : e));
|
||
}
|
||
setApplyProgress({ stage: "patch" });
|
||
const httpMulti = await core.applyPatchesAllHosts(core.ALL_PATCHES, {
|
||
preserveClientBundles: false,
|
||
forApply: true,
|
||
onHost(info) {
|
||
setApplyProgress({
|
||
stage: "patch",
|
||
applied: info.index || 0,
|
||
detail: info.aiBase || "",
|
||
});
|
||
},
|
||
});
|
||
const report = httpMulti.report;
|
||
setApplyProgress({ stage: "shim" });
|
||
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) };
|
||
}
|
||
setApplyProgress({ stage: "flash" });
|
||
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";
|
||
setApplyProgress({ stage: "seed" });
|
||
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);
|
||
endApply({
|
||
stage: "done",
|
||
error: complete || (promptReady && hostWritten) ? "" : "incomplete",
|
||
});
|
||
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,
|
||
});
|
||
} catch (e) {
|
||
endApply({ error: String(e && e.message ? e.message : e) });
|
||
sendJson(response, 500, { ok: false, error: String(e) });
|
||
}
|
||
},
|
||
}, "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;
|
||
}
|
||
beginJob("revert", "locate");
|
||
try {
|
||
const state = await core.gatherState();
|
||
const result = {};
|
||
setApplyProgress({ kind: "revert", stage: "revert" });
|
||
if (state.ai_base) {
|
||
const { reverted, errors } = await core.revertAll(state.ai_base);
|
||
result.reverted = reverted.length;
|
||
result.errors = errors.length;
|
||
}
|
||
setApplyProgress({ kind: "revert", stage: "shim" });
|
||
result.shim = await core.revertAllShims();
|
||
endApply({
|
||
kind: "revert",
|
||
stage: "done",
|
||
error: result.errors ? "incomplete" : "",
|
||
});
|
||
sendJson(response, 200, { ok: true, ...result, note: "重启 dsh 恢复; prompt-inject.md 保留" });
|
||
} catch (e) {
|
||
endApply({ kind: "revert", error: String(e && e.message ? e.message : e) });
|
||
sendJson(response, 500, { ok: false, error: String(e) });
|
||
}
|
||
},
|
||
}, "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;
|
||
}
|
||
beginJob("uninstall", "locate");
|
||
try {
|
||
const result = await uninstaller.uninstallPurge({
|
||
onStage(stage) {
|
||
setApplyProgress({ kind: "uninstall", stage });
|
||
},
|
||
});
|
||
endApply({
|
||
kind: "uninstall",
|
||
stage: "done",
|
||
error: result.ok ? "" : (result.errors && result.errors[0]) || "incomplete",
|
||
});
|
||
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) {
|
||
endApply({ kind: "uninstall", error: String(e && e.message ? e.message : e) });
|
||
sendJson(response, 500, { ok: false, error: String(e && e.message ? e.message : e) });
|
||
}
|
||
},
|
||
}, "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, 500, { ok: false, error: String(e) });
|
||
}
|
||
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, 500, { ok: false, error: String(e) });
|
||
}
|
||
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, 500, { ok: false, error: String(e && e.message ? e.message : e) });
|
||
}
|
||
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, 500, { ok: false, error: String(e) });
|
||
}
|
||
},
|
||
}, "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, 500, { ok: false, error: String(e && e.message ? e.message : e) });
|
||
}
|
||
};
|
||
}
|
||
|
||
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, 500, { ok: false, error: String(e && e.message ? e.message : e) });
|
||
}
|
||
};
|
||
}
|
||
|
||
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 */ }
|
||
}
|
||
};
|
||
}
|
||
|
||
function sendJson(response, status, payload) {
|
||
response.writeHead(status, {
|
||
"cache-control": "no-store",
|
||
"content-type": "application/json; charset=utf-8",
|
||
});
|
||
response.end(JSON.stringify(payload));
|
||
}
|
||
|
||
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 () => {};
|
||
}
|
||
}
|
||
|
||
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) throw new Error("request body too large");
|
||
chunks.push(buffer);
|
||
}
|
||
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
||
if (!raw) return {};
|
||
return JSON.parse(raw);
|
||
}
|
||
|
||
const plugin = {
|
||
name,
|
||
inject,
|
||
apply,
|
||
};
|
||
|
||
export default plugin;
|