import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, normalize, resolve } from "node:path"; import type { TextContent } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, MessageEndEvent, SessionStartEvent, ToolCallEvent, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolResultEvent, } from "@earendil-works/pi-coding-agent"; import { addSessionDecision, type CompiledPathPolicy, compilePathPolicy, createDamageControlEngine, createRuntimeState, type DamageControlEngine, type DamageControlPolicy, type DamageControlResult, type DamageControlRuntimeState, DYNAMIC_PATH, detectSystemLang, ensureBashReady, ensurePowerShellReady, enterDna, exitDna, type FileCheckResult, formatAskMessage, formatBlockedMessage, formatSessionDecisions, isInsideOrEqual, type Lang, type LoadedPolicy, loadPolicy, type MessageKey, type PathMatch, type PolicyAction, policyRuleIds, removeSessionDecisions, resolveLang, type SessionChoice, type SessionDecision, scopeKindFor, scopeOverlapsMatch, sessionDecisionFor, strongestAction, t, warmParsers, } from "ast-guard-core"; import { RUNTIME_STATE_CUSTOM_TYPE, runtimeForSessionStart, snapshotRuntimeState, } from "./engine/runtime-persistence.ts"; import { ensureHomeConfig } from "./extension/init-config.ts"; import { evaluateToolCall } from "./extension/tool-adapter.ts"; import { defaultPolicy, defaultPolicyPath } from "./policy/default-policy.ts"; const STATUS_KEY = "ast-guard"; const STATUS_WIDGET_KEY = "ast-guard-status"; const SCOPE_INDEX_SEPARATOR = /[\s,,]+/; const SCOPE_WILDCARD_RE = /[*?[\]{}()]/; const ASK_CHOICES: Record = { en: ["Allow once", "Allow this session", "Deny this session", "Deny Once"], zh: ["同意一次", "本会话允许", "本会话拒绝", "拒绝一次"], }; interface LoadedEngine { compiledPathPolicy: CompiledPathPolicy; engine: DamageControlEngine; loadedPolicy: LoadedPolicy; policy: DamageControlPolicy; } function parseIndices(input: string): { /** 非法输入(非 1 基正整数)。 */ invalid: string[]; /** 合法序号(0 基,去重,降序)。 */ indices: number[]; } { const invalid: string[] = []; const seen = new Set(); for (const token of input.split(SCOPE_INDEX_SEPARATOR)) { const trimmed = token.trim(); if (trimmed.length === 0) { continue; } const n = Number(trimmed); if (!Number.isInteger(n) || n < 1) { invalid.push(trimmed); continue; } seen.add(n - 1); } return { invalid, indices: [...seen].sort((a, b) => b - a) }; } export function statusText( runtime: DamageControlRuntimeState, lang: Lang ): string { const state = t(lang, runtime.dna ? "dnaOnShort" : "dnaOffShort"); return `${runtime.dna ? "⚠️" : "🛡"}DNA:${state}`; } function notify( ctx: ExtensionContext, message: string, type: "error" | "info" | "warning" = "info" ): void { ctx.ui.notify(message, type); } function refreshEngine(ctx: ExtensionContext): LoadedEngine { const loadedPolicy = loadPolicy(ctx.cwd, { defaultPolicy, defaultPolicyPath, globalPath: join(homedir(), ".pi", "agent", "ast-guard.yml"), projectPath: join(ctx.cwd, ".pi", "ast-guard.yml"), }); if (loadedPolicy.error) { notify( ctx, t("zh", "policyLoadFailed", { issues: loadedPolicy.error }), "error" ); } const policy = loadedPolicy.policy; // 预热 bash/pwsh 解析器(幂等),确保首个 tool_call 前就绪; // bash 也走 tree-sitter wasm,冷启动首个命令会按 parseError 兜底 Promise.all([ warmParsers(), ensureBashReady(), ensurePowerShellReady(), ]).catch(() => undefined); return { compiledPathPolicy: compilePathPolicy( policy.rules, ctx.cwd, policy.settings.extraDirs ), engine: createDamageControlEngine({ cwd: ctx.cwd, policy }), loadedPolicy, policy, }; } function violatingRuleIds( result: DamageControlResult | FileCheckResult, policy: DamageControlPolicy ): string[] { const ids = [ ...("commandViolations" in result ? result.commandViolations.map((violation) => violation.ruleId) : []), ...result.pathViolations.map((violation) => violation.ruleId), ]; const known = new Set(policyRuleIds(policy)); return [...new Set(ids)].filter((ruleId) => known.has(ruleId)); } /** * DNA 模式下的自动回答:路径违规按 4 个参数(工作区内/外 × 读/写)决定 * allow/block;命令违规默认 allow(DNA = Do Not Ask,不询问即放行)。 * 返回 strongestAction 聚合结果(block 优先)。 */ function dnaEffectiveAction( result: DamageControlResult | FileCheckResult, policy: DamageControlPolicy, workspaceDirs: readonly string[] ): PolicyAction { const settings = policy.settings; const actions: PolicyAction[] = []; for (const violation of result.pathViolations) { const inside = violation.matchedPath !== "" && workspaceDirs.some((dir) => isInsideOrEqual(violation.matchedPath, dir)); const read = violation.intent.access === "read"; let action: PolicyAction; if (read) { action = inside ? (settings.dna?.readInside ?? "allow") : (settings.dna?.readOutside ?? "allow"); } else { action = inside ? (settings.dna?.writeInside ?? "allow") : (settings.dna?.writeOutside ?? "block"); } actions.push(action); } if ("commandViolations" in result && result.commandViolations.length > 0) { actions.push("allow"); } if ("parseFailure" in result && result.parseFailure) { // DNA 模式下解析失败默认拦截(而非静默放行) actions.push(settings.dna?.parseFailure ?? "block"); } return strongestAction(actions) as PolicyAction; } interface SessionDecisionOptions { cwd: string; dnaViolationCount: { value: number }; /** 会话决策变更后回调,用于刷新状态面板。 */ onSessionDecision?: () => void; policy: DamageControlPolicy; /** 记录一条会话决策(本会话允许/拒绝)。 */ recordSessionDecision: (entry: SessionDecision) => void; runtime: DamageControlRuntimeState; sessionDecisions: readonly SessionDecision[]; workspaceDirs: readonly string[]; } /** 规则在某次违规中的命中路径列表。 */ function ruleMatchedPaths( result: NonNullable>["result"], ruleId: string ): string[] { return result.pathViolations .filter((violation) => violation.ruleId === ruleId) .map((violation) => violation.matchedPath); } function ruleHasCommandViolations( result: NonNullable>["result"], ruleId: string ): boolean { return ( "commandViolations" in result && result.commandViolations.some((violation) => violation.ruleId === ruleId) ); } /** 是否有违规已被会话级拒绝覆盖(命中即不再弹窗、直接拦截)。 */ function anyViolationDenied( result: NonNullable>["result"], sessionDecisions: readonly SessionDecision[], ruleId: string ): boolean { if ( ruleMatchedPaths(result, ruleId).some( (path) => sessionDecisionFor(sessionDecisions, ruleId, path) === "deny" ) ) { return true; } if ( ruleHasCommandViolations(result, ruleId) && sessionDecisionFor(sessionDecisions, ruleId) === "deny" ) { return true; } return false; } type ScopeInputError = | { kind: "wildcard" } | { kind: "dynamicOnly" } | { kind: "notExist"; path: string } | { kind: "outside"; path: string }; function scopeErrorKey(error: ScopeInputError): string { switch (error.kind) { case "wildcard": return "scopeWildcard"; case "dynamicOnly": return "scopeDynamicOnly"; case "notExist": return "scopeNotExist"; case "outside": return "scopeOutsideRule"; default: return "scopeOutsideRule"; } } /** 解析作用域输入:空 = 目标文件本身;相对输入相对目标文件目录解析;绝对路径直接使用。 */ function resolveScopeInput( input: string, anchor: string, dynamic: boolean ): { scope: string } | { error: ScopeInputError } { if (SCOPE_WILDCARD_RE.test(input)) { return { error: { kind: "wildcard" } }; } if (input.length === 0) { if (dynamic) { return { error: { kind: "dynamicOnly" } }; } return { scope: normalize(anchor) }; } if (dynamic && !isAbsolute(input)) { return { error: { kind: "dynamicOnly" } }; } return { scope: normalize( isAbsolute(input) ? input : resolve(dirname(anchor), input) ), }; } /** * 弹作用域输入框并校验: * 校验:作用域必须与规则匹配域相交;目录作用域必须真实存在 * (文件作用域只需父目录存在,容忍尚未创建的文件)。返回 undefined 表示用户取消。 */ async function promptRuleScope( ctx: ExtensionContext, lang: Lang, choice: SessionChoice, match: PathMatch, anchor: string, cwd: string, extraDirs: readonly string[] ): Promise { const choiceLabel = t( lang, choice === "allow" ? "auditAllowSession" : "auditDenySession" ); const dynamic = anchor === DYNAMIC_PATH; for (;;) { const raw = await ctx.ui.input( t(lang, "scopeTitle", { choice: choiceLabel }), t(lang, "scopePlaceholder"), { timeout: 60_000 } ); if (raw === undefined) { return; } const input = raw.trim(); const resolved = resolveScopeInput(input, anchor, dynamic); if ("error" in resolved) { notify( ctx, t(lang, scopeErrorKey(resolved.error) as MessageKey), "warning" ); continue; } const scope = resolved.scope; if (!scopeOverlapsMatch(match, scope, cwd, extraDirs)) { notify(ctx, t(lang, "scopeOutsideRule", { path: scope }), "warning"); continue; } const exists = existsSync(input.length === 0 ? dirname(scope) : scope); if (!exists) { notify(ctx, t(lang, "scopeNotExist", { path: scope }), "warning"); continue; } return scope; } } /** * 生成单个规则的会话决策条目(返回 undefined 表示用户取消作用域输入)。 * - 命令面规则(无路径可锚定)→ 规则级条目; * - 粗粒度路径规则 → 弹输入框; * - 无斜杠模式规则 → 自动限定到本次触发的目标文件(每个命中路径一条); * - 细粒度路径规则 → 规则级条目。 */ async function sessionEntryForRule( ctx: ExtensionContext, lang: Lang, choice: SessionChoice, rule: DamageControlPolicy["rules"][number] | undefined, paths: readonly string[], at: number, target: string, options: SessionDecisionOptions, extraDirs: readonly string[] ): Promise { if (rule && rule.type !== "command" && paths.length > 0) { const kind = scopeKindFor(rule.match, options.cwd); if (kind === "input") { const scope = await promptRuleScope( ctx, lang, choice, rule.match, paths[0] ?? DYNAMIC_PATH, options.cwd, extraDirs ); if (scope === undefined) { return; } return [{ at, choice, ruleId: rule.id, scope, target }]; } if (kind === "auto") { const scoped = [...new Set(paths)].filter( (path) => path !== DYNAMIC_PATH ); if (scoped.length > 0) { return scoped.map((path) => ({ at, choice, ruleId: rule.id, scope: path, target, })); } // 全部为动态路径:无法锚定,回退为规则级 } } return [{ at, choice, ruleId: rule?.id ?? "", target }]; } /** * 生成一次会话级决策(允许/拒绝)的决策层条目。 * 返回 undefined 表示用户在作用域输入中取消(整个流程中止)。 */ async function sessionEntriesFor( ctx: ExtensionContext, lang: Lang, result: NonNullable>["result"], ruleIds: readonly string[], choice: SessionChoice, options: SessionDecisionOptions, target: string ): Promise { const at = Date.now(); const entries: SessionDecision[] = []; const extraDirs = options.policy.settings.extraDirs ?? []; for (const ruleId of ruleIds) { const rule = options.policy.rules.find((item) => item.id === ruleId); const paths = ruleMatchedPaths(result, ruleId); const forRule = await sessionEntryForRule( ctx, lang, choice, rule, paths, at, target, options, extraDirs ); if (forRule === undefined) { return; } entries.push(...forRule); } return entries; } async function handleDecision( result: ReturnType, ctx: ExtensionContext, options: SessionDecisionOptions ): Promise<{ block: boolean; reason?: string } | undefined> { if ( !result || result.result.decision === "allow" || result.result.decision === "bypass" ) { return; } const lang = resolveLang(options.policy.settings.language ?? "auto"); if (result.result.decision === "block") { return { block: true, reason: formatBlockedMessage(result.result, lang) }; } return await handleAskDecision(result, ctx, options, lang); } /** DNA 模式:不弹窗,按 4 参数自动回答。 */ function dnaAutoAnswer( result: NonNullable>["result"], blockMsg: string, ctx: ExtensionContext, options: SessionDecisionOptions, lang: Lang ): { block: boolean; reason: string } | undefined { const dnaAction = dnaEffectiveAction( result, options.policy, options.workspaceDirs ); if (dnaAction === "allow") { // 放行:不重置全局累计计数 return; } // 自动拒绝:拦截但不中断会话,引导 AI 评估替代方案; // 本次 DNA 模式下累计违反(任何规则,含工具黑白名单)达到上限 // (settings.dna.maxViolations,默认 3)时强制中断,并清零计数 const maxViolations = options.policy.settings.dna?.maxViolations ?? 3; options.dnaViolationCount.value += 1; const forceAbort = options.dnaViolationCount.value >= maxViolations; if (forceAbort) { options.dnaViolationCount.value = 0; ctx.abort(); return { block: true, reason: `${blockMsg} ${t(lang, "dnaForceAbortReason", { max: `${maxViolations}`, })}`, }; } return { block: true, reason: `${blockMsg} ${t(lang, "dnaBlockedReason")}`, }; } /** 处理选择对话框结果(含会话级决策的作用域流程)。 */ async function handleDialogChoice( choice: string | undefined, choices: readonly string[], blockMsg: string, result: NonNullable>, ruleIds: readonly string[], target: string, ctx: ExtensionContext, options: SessionDecisionOptions, lang: Lang ): Promise<{ block: boolean; reason?: string } | undefined> { if (choice === choices[0]) { return; } let sessionChoice: SessionChoice | undefined; if (choice === choices[1]) { sessionChoice = "allow"; } else if (choice === choices[2]) { sessionChoice = "deny"; } if (sessionChoice) { const entries = await sessionEntriesFor( ctx, lang, result.result, ruleIds, sessionChoice, options, target ); if (entries === undefined) { // 用户取消作用域输入:fail-closed,中止当前轮 ctx.abort(); return { block: true, reason: `${blockMsg} ${t(lang, "scopeCancelled")}`, }; } for (const entry of entries) { options.recordSessionDecision(entry); } options.onSessionDecision?.(); if (sessionChoice === "deny") { ctx.abort(); return { block: true, reason: `${blockMsg} ${t(lang, "sessionDeniedReason")}`, }; } return; } if (choice === choices[3]) { ctx.abort(); return { block: true, reason: `${blockMsg} ${t(lang, "userDeniedReason")}`, }; } // 对话框被取消或超时:fail-closed,中止当前轮 ctx.abort(); return { block: true, reason: `${blockMsg} ${t(lang, "cancelledReason")}` }; } async function handleAskDecision( result: NonNullable>, ctx: ExtensionContext, options: SessionDecisionOptions, lang: Lang ): Promise<{ block: boolean; reason?: string } | undefined> { if (result.result.decision !== "ask") { return { block: true, reason: formatBlockedMessage(result.result, lang) }; } const blockMsg = formatBlockedMessage(result.result, lang); const ruleIds = violatingRuleIds(result.result, options.policy); const target = result.input; if (options.runtime.dna) { return dnaAutoAnswer(result.result, blockMsg, ctx, options, lang); } // 会话级拒绝覆盖:不再弹窗,直接拦截并中止当前轮 if ( ruleIds.some((ruleId) => anyViolationDenied(result.result, options.sessionDecisions, ruleId) ) ) { ctx.abort(); return { block: true, reason: `${blockMsg} ${t(lang, "sessionDeniedReason")}`, }; } if (!ctx.hasUI) { ctx.abort(); return { block: true, reason: `${blockMsg} ${t(lang, "noUIReason")}`, }; } const choices = ASK_CHOICES[lang]; const askMessage = formatAskMessage( result.result, result.toolName, target, lang ); const choice = await ctx.ui.select(askMessage, [...choices], { timeout: 30_000, }); return handleDialogChoice( choice, choices, blockMsg, result, ruleIds, target, ctx, options, lang ); } /** * DNA 提醒:纯 steer 消息引导(内容带 system_reminder 标签)。 * * 之前的状态注入(before_provider_request 里往 OpenAI messages[] 中间插 * role=system、往 Anthropic system blocks 追加)曾破坏 payload 结构,导致 * AI 回答异常结束(无内容)且无法从断点恢复。改为零 payload 手术: * - 进入/退出 DNA 时各发一条 custom 消息(deliverAs: "steer"),内容直接带 * 标签;custom 消息经 pi 归一化并以 user 角色进入 payload, * 不触发额外 turn(不 triggerTurn),等下一个模型边界生效; * - 会话恢复(runtime.dna 已持久化)时在 session_start 重发进入提醒。 */ function wrapSystemReminder(text: string): string { return `\n${text}\n`; } /** DNA 模式下每次请求附加给模型的指令文本(含格式报告契约)。 */ function dnaReminderText(policy: DamageControlPolicy, lang: Lang): string { const dnaCfg = policy.settings.dna; let msg = t(lang, "dnaEnterPrompt"); if (dnaCfg) { const allowTools = dnaCfg.allowTools ?? []; const blockTools = dnaCfg.blockTools ?? []; if (allowTools.length > 0) { msg += ` ${t(lang, "dnaAllowlistPrompt", { tools: allowTools.join(", "), })}`; } else if (blockTools.length > 0) { msg += ` ${t(lang, "dnaBlocklistPrompt", { tools: blockTools.join(", "), })}`; } } // 附加提示词:让 AI 自动选择方案(如 select/confirm 交互),不配置时使用内置默认 const extraPrompt = dnaCfg?.extraPrompt ?? t(lang, "dnaExtraPrompt"); msg += ` ${extraPrompt}`; // 格式报告契约:任务结束必须 REPORT: SUCCESS|FAILURE(agent_settled 检查兜底) msg += ` ${t(lang, "dnaReportFormat")}`; return msg; } /** 提取消息文本:content 可能是 string 或 text block 数组。 */ function messageText(content: unknown): string { if (typeof content === "string") { return content; } if (Array.isArray(content)) { return content .map((part) => part && typeof part === "object" && "text" in part ? String((part as { text: unknown }).text) : "" ) .join(""); } return ""; } /** 格式报告契约正则:任务结束必须包含 REPORT: SUCCESS|FAILURE。 */ const DNA_REPORT_RE = /\bREPORT:\s*(SUCCESS|FAILURE)\b/; /** DNA 模式切换提醒的 custom type(steer 消息)。 */ const DNA_REMINDER_CUSTOM_TYPE = "ag:dna-reminder"; /** 上下文超限错误特征(对齐 pi-ai dist/utils/overflow.js 的 OVERFLOW_PATTERNS)。 */ const DNA_OVERFLOW_RE = /prompt is too long|request_too_large|input is too long for requested model|exceeds the context window|maximum context length|input token count.*exceeds the maximum|maximum prompt length is \d+|reduce the length of the messages|maximum allowed input length|longer than the model'?s context length|exceeds the limit of \d+|exceeds the available context size|greater than the context length|context window exceeds limit|exceeded model token limit|too large for model with \d+ maximum context length|prompt has [\d,]+ tokens?|model_context_window_exceeded|prompt too long; exceeded (?:max )?context length|range of input length should be|context[_ ]length[_ ]exceeded|too many tokens|token limit exceeded|^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i; /** 限流 429 特征(对齐 NON_OVERFLOW_PATTERNS)。 */ const DNA_RATE_LIMIT_RE = /^(Throttling error|Service unavailable):|rate limit|too many requests/i; /** 服务器错误(500 类)特征。 */ const DNA_SERVER_ERROR_RE = /5\d\d|internal server error|server error|bad gateway|gateway timeout/i; /** 认证/权限错误特征。 */ const DNA_AUTH_ERROR_RE = /401|403|unauthorized|unauthenticated|authentication|invalid api key|permission denied|forbidden/i; /** 最近一条 assistant 消息的结束信息(message_end 跟踪)。 */ interface AssistantEndInfo { errorMessage?: string; hasNonTextBlocks: boolean; stopReason?: string; text: string; } /** 提取 assistant 消息的内容快照(文本 + 非文本块 + 结束原因)。 */ function assistantEndInfo( message: MessageEndEvent["message"] ): AssistantEndInfo | undefined { if (message.role !== "assistant") { return; } const content = Array.isArray(message.content) ? message.content : []; return { stopReason: message.stopReason, errorMessage: message.errorMessage, text: messageText(message.content).trim(), hasNonTextBlocks: content.some( (part) => part !== null && typeof part === "object" && !("text" in part) ), }; } /** * 是否值得 nudge:按结束原因区分。不可抗力(提醒了也收不到,且下次请求大概率 * 再失败)一律静默:用户中止、输出/上下文截断、上下文超限、限流 429、服务器 * 500、认证错误。仅剩两类值得提醒恢复:正常结束却无任何输出(异常空响应), * 以及格式类等其他错误。 */ function shouldNudgeDna(last: AssistantEndInfo): boolean { if (DNA_REPORT_RE.test(last.text)) { return false; } const reason = last.stopReason; if (reason === "aborted") { return false; } if (reason === "length" || reason === "max_tokens") { return false; } if (reason === "error") { const err = last.errorMessage ?? ""; if (DNA_OVERFLOW_RE.test(err)) { return false; } if (DNA_RATE_LIMIT_RE.test(err)) { return false; } if (DNA_SERVER_ERROR_RE.test(err)) { return false; } if (DNA_AUTH_ERROR_RE.test(err)) { return false; } return true; } // 正常结束(stop/end_turn/tool_use) if (last.hasNonTextBlocks) { return false; } return last.text.length === 0; } /** 发送 DNA 模式切换提醒:纯 steer 消息,内容直接带 system_reminder 标签。 */ function sendDnaReminder( pi: ExtensionAPI, policy: DamageControlPolicy, lang: Lang, enter: boolean ): void { const text = enter ? dnaReminderText(policy, lang) : t(lang, "dnaExitPrompt"); pi.sendMessage( { customType: DNA_REMINDER_CUSTOM_TYPE, content: wrapSystemReminder(text), display: false, }, { deliverAs: "steer" } ); } function checkDnaToolAccess( event: ToolCallEvent, ctx: ExtensionContext, engine: LoadedEngine, dnaViolationCount: { value: number } ): { block: true; reason: string } | undefined { const dnaCfg = engine.policy.settings.dna; if (!dnaCfg) { return; } const allowTools = dnaCfg.allowTools ?? []; const blockTools = dnaCfg.blockTools ?? []; const toolBlocked = allowTools.length > 0 ? !allowTools.includes(event.toolName) : blockTools.length > 0 && blockTools.includes(event.toolName); if (!toolBlocked) { return; } const maxViolations = dnaCfg.maxViolations ?? 3; const lang = resolveLang(engine.policy.settings.language ?? "auto"); // 工具黑白名单违规计入全局累计(与规则违规同一计数器) dnaViolationCount.value += 1; const forceAbort = dnaViolationCount.value >= maxViolations; if (forceAbort) { // 强制中断后清零全局计数 dnaViolationCount.value = 0; ctx.abort(); } let reason = t(lang, "dnaToolBlockedReason", { tool: event.toolName }); if (forceAbort) { reason += ` ${t(lang, "dnaForceAbortReason", { max: `${maxViolations}`, })}`; } return { block: true, reason }; } export default function damageControl(pi: ExtensionAPI): void { // 初始化:home 目录缺失配置/ schema 时自动创建(幂等,失败静默) ensureHomeConfig(); let runtime = createRuntimeState(); let loadedEngine: LoadedEngine | undefined; let sessionDecisions: SessionDecision[] = []; // DNA 模式:本次模式下累计自动拒绝次数(达到 dna.maxViolations 强制中断并清零) const dnaViolationCount = { value: 0 }; // 最近一条 assistant 消息的结束信息(agent_settled 判定结束原因用) let lastAssistantEnd: AssistantEndInfo | undefined; // DNA 模式:本次模式下累计格式报告 nudge 次数(达到 dna.maxNudges 后放弃) let dnaNudgeCount = 0; // 状态面板是否已展示(扩展无法感知用户关闭,仅供“已开过则刷新”使用) let panelOpen = false; function persistRuntimeState(): void { pi.appendEntry(RUNTIME_STATE_CUSTOM_TYPE, snapshotRuntimeState(runtime)); } function ensureEngine(ctx: ExtensionContext): LoadedEngine { loadedEngine ??= refreshEngine(ctx); return loadedEngine; } function updateStatus(ctx: ExtensionContext): void { if (loadedEngine?.policy.settings.showStatus ?? true) { const lang = resolveLang( loadedEngine?.policy.settings.language ?? "auto" ); ctx.ui.setStatus(STATUS_KEY, statusText(runtime, lang)); } } /** 展示/刷新状态面板(无 UI 环境回退为通知)。 */ function showStatus(ctx: ExtensionContext): void { if (!loadedEngine) { return; } const lang = resolveLang(loadedEngine.policy.settings.language ?? "auto"); const lines = formatSessionDecisions(sessionDecisions, lang); if (!ctx.hasUI) { notify(ctx, lines.join("\n"), runtime.dna ? "warning" : "info"); return; } panelOpen = true; ctx.ui.setWidget(STATUS_WIDGET_KEY, lines, { placement: "belowEditor", }); } /** 面板已展示时刷新内容(决策/命令变更后调用,不主动弹出)。 */ function refreshStatusPanel(ctx: ExtensionContext): void { if (panelOpen) { showStatus(ctx); } } function registerCommands(lang: Lang): void { pi.registerCommand("ag:forget", { description: t(lang, "cmdForgetDesc"), handler: async (args, ctx) => { await Promise.resolve(); const policy = ensureEngine(ctx).policy; const lang = resolveLang(policy.settings.language ?? "auto"); const total = sessionDecisions.length; if (total === 0) { notify(ctx, t(lang, "forgetNothing"), "info"); return; } const { invalid, indices } = parseIndices(args); if (invalid.length > 0) { notify( ctx, t(lang, "forgetBadIndex", { input: invalid.join(", "), max: `${total}`, }), "warning" ); return; } if (indices.length === 0) { notify(ctx, t(lang, "forgetUsage"), "warning"); return; } if (indices.some((index) => index >= total)) { notify( ctx, t(lang, "forgetBadIndex", { input: indices.map((index) => `${index + 1}`).join(", "), max: `${total}`, }), "warning" ); return; } sessionDecisions = removeSessionDecisions( sessionDecisions, new Set(indices) ); notify( ctx, t(lang, "forgetCleared", { indices: indices .slice() .sort((a, b) => a - b) .map((index) => `${index + 1}`) .join(","), count: `${sessionDecisions.length}`, }), "info" ); refreshStatusPanel(ctx); }, }); pi.registerCommand("ag:dna", { description: t(lang, "cmdDnaDesc"), handler: async (_args, ctx) => { await Promise.resolve(); const policy = ensureEngine(ctx).policy; const lang = resolveLang(policy.settings.language ?? "auto"); runtime = runtime.dna ? exitDna(runtime) : enterDna(runtime); if (runtime.dna) { // 进入:重置格式报告 nudge 计数 dnaNudgeCount = 0; } // 先持久化并刷新 UI,再发模式切换提醒(纯 steer 消息,不触发额外 turn), // 保证 steer 发出前用户已在界面上看到状态变化 persistRuntimeState(); updateStatus(ctx); refreshStatusPanel(ctx); notify(ctx, runtime.dna ? t(lang, "dnaOn") : t(lang, "dnaOff"), "info"); sendDnaReminder(pi, policy, lang, runtime.dna); }, }); pi.registerCommand("ag:status", { description: t(lang, "cmdStatusDesc"), handler: async (_args, ctx) => { await Promise.resolve(); // 合并了 reload:每次执行都重新加载策略,再展示/刷新面板 loadedEngine = refreshEngine(ctx); registerCommands( resolveLang(loadedEngine.policy.settings.language ?? "auto") ); updateStatus(ctx); showStatus(ctx); }, }); } registerCommands(detectSystemLang()); // 跟踪最后一条 assistant 消息的结束信息(不依赖 payload 注入) pi.on("message_end", (event: MessageEndEvent) => { if (event.message.role !== "assistant") { return; } lastAssistantEnd = assistantEndInfo(event.message); }); // 对话完全结束(任务完成或被中断,无后续自动续跑)后清零 DNA 累计违反计数, // 并做格式报告检查:DNA 模式下按最后一条 assistant 消息的结束原因决定是否 // nudge——不可抗力结束(中止/截断/上下文超限/限流/服务器/认证错误)一律静默, // 仅异常空响应与格式类错误值得提醒(预算 dna.maxNudges) pi.on("agent_settled", () => { dnaViolationCount.value = 0; if (!runtime.dna) { return; } const policy = loadedEngine?.policy; if (!policy) { return; } const lang = resolveLang(policy.settings.language ?? "auto"); const maxNudges = policy.settings.dna?.maxNudges ?? 2; if (dnaNudgeCount >= maxNudges) { return; } const last = lastAssistantEnd; if (!last) { return; } if (!shouldNudgeDna(last)) { if (DNA_REPORT_RE.test(last.text)) { dnaNudgeCount = 0; // 合规报告清零 nudge 预算 } return; } dnaNudgeCount += 1; // 不诱导“询问用户”:要么继续任务,要么按格式报告失败 pi.sendUserMessage(t(lang, "dnaNudgePrompt")); }); // ===================================================================== // DNA UI 拦截:共享的 ctx.ui 是所有扩展共用的普通对象,原地包裹提问型 // 方法(select/confirm/input/editor/custom),DNA 模式下立即返回“取消” // 值而不弹任何对话框;被拦截交互对应的工具 callId 记入 cancelledUiToolCallIds, // tool_result 时给结果追加说明文本,让 AI 明确收到“勿提问、自行决策”指引 // (拒绝文本可用 settings.dna.cancelledNote 覆盖)。 // ===================================================================== const wrappedUiContexts = new WeakSet(); // 正在执行中的工具 callId(工具在提问插件执行期间被取消交互时用于关联) const activeToolCallIds = new Set(); // DNA 模式下发生过被自动取消交互的工具 callId const cancelledUiToolCallIds = new Set(); /** 原地包裹共享 uiContext 的提问型方法(幂等:同一对象只包一次)。 */ function installUiInterceptor(ui: ExtensionUIContext): void { if (wrappedUiContexts.has(ui)) { return; } wrappedUiContexts.add(ui); const recordCancelled = (): void => { for (const callId of activeToolCallIds) { cancelledUiToolCallIds.add(callId); } }; const select = ui.select; if (typeof select === "function") { ui.select = (title, options, opts) => { if (runtime.dna) { recordCancelled(); return Promise.resolve(undefined); } return select(title, options, opts); }; } const confirm = ui.confirm; if (typeof confirm === "function") { ui.confirm = (title, message, opts) => { if (runtime.dna) { recordCancelled(); return Promise.resolve(false); } return confirm(title, message, opts); }; } const input = ui.input; if (typeof input === "function") { ui.input = (title, placeholder, opts) => { if (runtime.dna) { recordCancelled(); return Promise.resolve(undefined); } return input(title, placeholder, opts); }; } const editor = ui.editor; if (typeof editor === "function") { ui.editor = (title, prefill) => { if (runtime.dna) { recordCancelled(); return Promise.resolve(undefined); } return editor(title, prefill); }; } const custom = ui.custom; if (typeof custom === "function") { ui.custom = ((factory, opts) => { if (runtime.dna) { recordCancelled(); return Promise.resolve(null); } return custom(factory, opts); }) as typeof custom; } } pi.on("tool_execution_start", (event: ToolExecutionStartEvent) => { activeToolCallIds.add(event.toolCallId); }); pi.on("tool_execution_end", (event: ToolExecutionEndEvent) => { activeToolCallIds.delete(event.toolCallId); }); // 给被取消交互的工具结果追加“勿提问、自行决策”说明(文本可配置), // 其余字段不变(不覆盖 isError/details/usage) pi.on("tool_result", (event: ToolResultEvent, ctx) => { if (!cancelledUiToolCallIds.delete(event.toolCallId)) { return; } const engine = ensureEngine(ctx); const lang = resolveLang(engine.policy.settings.language ?? "auto"); const note = engine.policy.settings.dna?.cancelledNote ?? t(lang, "dnaUiCancelledNote"); return { content: [ ...event.content, { type: "text", text: note } satisfies TextContent, ], }; }); pi.on("session_start", (event: SessionStartEvent, ctx) => { activeToolCallIds.clear(); cancelledUiToolCallIds.clear(); installUiInterceptor(ctx.ui); loadedEngine = refreshEngine(ctx); sessionDecisions = []; dnaViolationCount.value = 0; lastAssistantEnd = undefined; runtime = runtimeForSessionStart( event.reason, ctx.sessionManager.getEntries() ); const lang = resolveLang(loadedEngine.policy.settings.language ?? "auto"); registerCommands(lang); updateStatus(ctx); // 会话初始化后自动执行一次 status(弹出面板) showStatus(ctx); // 会话恢复且 DNA 已开启:重发进入提醒(steer),新会话模型也能收到 if (runtime.dna) { sendDnaReminder(pi, loadedEngine.policy, lang, true); } }); pi.on("tool_call", async (event, ctx) => { const engine = ensureEngine(ctx); // DNA 工具黑白名单检查(对所有工具生效,在正常评估之前) if (runtime.dna) { const dnaResult = await checkDnaToolAccess( event, ctx, engine, dnaViolationCount ); if (dnaResult) { return dnaResult; } } const workspaceDirs = [ ctx.cwd, ...(engine.policy.settings.extraDirs ?? []), ].map(normalize); const sessionAllow = (ruleId: string, matchedPath?: string): boolean => sessionDecisionFor(sessionDecisions, ruleId, matchedPath) === "allow"; const result = await handleDecision( evaluateToolCall(event, { compiledPathPolicy: engine.compiledPathPolicy, cwd: ctx.cwd, engine: engine.engine, policy: engine.policy, runtime, sessionAllow, }), ctx, { policy: engine.policy, runtime, workspaceDirs, cwd: ctx.cwd, sessionDecisions, dnaViolationCount, recordSessionDecision: (entry) => { sessionDecisions = addSessionDecision(sessionDecisions, entry); }, onSessionDecision: () => refreshStatusPanel(ctx), } ); return result; }); }