/** * 文件编辑侧边审查器。 * * edit/write 已经执行完成后,提取实际文件变化并并发交给配置的审查模型。 * 审查失败不回滚文件;只有明确拒绝才会把诊断追加到 Agent 可见的 tool result。 * 配置文件位于 Pi 的用户扩展配置目录: * ~/.pi/agent/extensions/pi-tool-supervisor/config.json */ import { complete } from "@earendil-works/pi-ai/compat"; import { createTranslator, installNoticeRenderer, loadCatalog, notifyWithSource, type NoticeColor, type NoticeLevel, type NoticeSource } from "pi-extensions-i18n"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ToolResultEvent, } from "@earendil-works/pi-coding-agent"; import { performance } from "node:perf_hooks"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { appendSupervisorFallbackAudit, registerSupervisorFallbackRenderer, } from "./fallback-renderer.ts"; import { isSupervisorToolDisplayMiddlewareActive, registerSupervisorToolDisplayMiddleware, } from "./tool-display-bridge.ts"; import { CONDITION_MATCHED_STATUS, CONDITION_NOT_MATCHED_STATUS, evaluateToolCondition, type ToolConditionEvent, } from "./condition-utils.ts"; import { buildCurrentFileContext, buildEditFallbackDiff, buildFileEditReviewDiff, buildMergedReviewPrompt, getPiSupervisorConfigPath, getOverallReviewStatus, loadFileEditReviewConfig, loadReviewRules, parseReviewResponse, reviewerAppliesToFile, reviewerIsEditorLocal, reviewerMatchesTool, reviewerTrigger, safeSerialize, type CurrentFileContext, type FileEditReviewAudit, type FileEditReviewConfig, type FileEditReviewReviewerConfig, type FileEditReviewResult, type FileEditReviewRule, type ReviewTrigger, } from "./review-utils.ts"; const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url))); /** 本扩展的提示标签;短且唯一,便于在会话里定位来源。 */ const NOTICE_TAG = "supervisor"; /** 提示标签颜色;与其它扩展错开,避免看起来像同一条消息。 */ const NOTICE_COLOR: NoticeColor = "success"; /** 本扩展的提示来源。 */ const NOTICE_SOURCE: NoticeSource = { tag: NOTICE_TAG, color: NOTICE_COLOR }; const AFTER_TRIGGER: ReviewTrigger = "after"; const BEFORE_TRIGGER: ReviewTrigger = "before"; const MILLISECONDS_PER_SECOND = 1000; const REVIEW_MAX_TOKENS = 1200; const MAX_REVIEW_PAYLOAD_CHARS = 10_000; const REJECTED_STATUS = "rejected" as const; const SKIPPED_STATUS = "skipped" as const; const EDIT_TOOL = "edit"; const WRITE_TOOL = "write"; const ALL_TOOLS = "*"; const DEFAULT_REVIEW_TOOLS = [EDIT_TOOL, WRITE_TOOL]; const REVIEW_TRIGGERS = [BEFORE_TRIGGER, AFTER_TRIGGER]; const CONFIG_ENABLED_CHOICE_INDEX = 0; const CONFIG_TIMEOUT_CHOICE_INDEX = 1; const CONFIG_FILE_CONTEXT_CHOICE_INDEX = 2; const CONFIG_RULES_CHOICE_INDEX = 3; const CONFIG_REVIEWER_CHOICE_OFFSET = 4; const REVIEWER_CONDITION_CHOICE_INDEX = 6; const REVIEWER_PATTERNS_CHOICE_INDEX = 7; const REVIEWER_DELETE_CHOICE_INDEX = 8; const CONDITION_NOT_MATCHED_MESSAGE_KEY = "conditionNotMatched"; const CONDITION_FAILED_MESSAGE_KEY = "conditionFailed"; type ToolResult = { content: Array<{ type?: string; text?: string }>; details?: Record; isError?: boolean; }; type FileReviewExecutionContext = { event: ToolConditionEvent; toolName: string; toolCallId: string; params: Record; signal?: AbortSignal; ctx: ExtensionContext; }; type FileSnapshot = { filePath: string; before?: string; after?: string; beforeError?: string; afterError?: string; }; type PendingFileReviewCall = { toolName: string; params: Record; loaded: ReturnType; snapshot?: FileSnapshot; fallbackDiff: string; beforeAudit?: FileEditReviewAudit; afterReviewers: FileEditReviewReviewerConfig[]; }; type ReviewerConditionSelection = { matched: FileEditReviewReviewerConfig[]; results: FileEditReviewResult[]; }; interface SelectReviewerConditionsOptions { reviewers: FileEditReviewReviewerConfig[]; event: ToolConditionEvent; ctx: ExtensionContext; timeoutMs: number; } /** Builds an audit result for a reviewer whose condition did not run or failed. */ function buildConditionResult( reviewer: FileEditReviewReviewerConfig, evaluation: Awaited>, ): FileEditReviewResult { const ruleReference = reviewer.rulesFile ? { rulesFile: reviewer.rulesFile } : { rulesFiles: reviewer.rulesFiles }; if (evaluation.status === CONDITION_NOT_MATCHED_STATUS) { return { name: reviewer.name, model: reviewer.model, ...ruleReference, status: SKIPPED_STATUS, durationMs: evaluation.durationMs, error: i18n.t(CONDITION_NOT_MATCHED_MESSAGE_KEY), }; } return { name: reviewer.name, model: reviewer.model, ...ruleReference, status: REJECTED_STATUS, durationMs: evaluation.durationMs, summary: i18n.t(CONDITION_FAILED_MESSAGE_KEY, { path: evaluation.path ?? reviewer.condition ?? "", message: evaluation.error ?? "", }), error: evaluation.error, }; } /** Selects reviewers by condition without invoking a model for non-matching inputs. */ async function selectReviewersByCondition( options: SelectReviewerConditionsOptions, ): Promise { const evaluations = await Promise.all(options.reviewers.map(async (reviewer) => ({ reviewer, evaluation: await evaluateToolCondition({ conditionPath: reviewer.condition, cwd: options.ctx.cwd, event: options.event, ctx: options.ctx, timeoutMs: options.timeoutMs, }), }))); return { matched: evaluations .filter(({ evaluation }) => evaluation.status === CONDITION_MATCHED_STATUS) .map(({ reviewer }) => reviewer), results: evaluations .filter(({ evaluation }) => evaluation.status !== CONDITION_MATCHED_STATUS) .map(({ reviewer, evaluation }) => buildConditionResult(reviewer, evaluation)), }; } /** Extracts a file path when the selected tool exposes one. */ function getPath(params: Record): string | undefined { const value = params.file_path ?? params.path; return typeof value === "string" && value.trim() ? value.trim() : undefined; } /** Narrows unknown event payloads without trusting arbitrary tool data. */ function getRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } /** * 统一提示出口:加来源标签后交给 Pi 的 notify。 * 沿用原有 `ctx.ui?.notify` 语义:上下文没有 UI 时跳过提示,而不是把异常抛到审查流程里。 */ function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: NoticeLevel): void { if (!ctx.ui) return; notifyWithSource({ ctx: { mode: ctx.mode, ui: ctx.ui }, source: NOTICE_SOURCE, level, message }); } /** Detects tool execution failure without inspecting arbitrary detail fields. */ function isFailedToolResult(result: ToolResult): boolean { return result.isError === true || getRecord(result).isError === true; } /** Reads a snapshot target and returns explicit errors for missing or inaccessible files. */ async function readOptionalFile(filePath: string): Promise<{ content?: string; error?: string }> { try { return { content: await readFile(filePath, "utf8") }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { error: message }; } } /** Captures the file state immediately before an edit or write executes. */ async function captureBefore(filePath: string): Promise<{ content?: string; error?: string }> { return readOptionalFile(filePath); } /** Captures the post-tool file state, using write input only as a narrow fallback. */ async function captureAfter( toolName: "edit" | "write", filePath: string, params: Record, ): Promise<{ content?: string; error?: string }> { const result = await readOptionalFile(filePath); if (result.content !== undefined) return result; if (toolName === "write" && typeof params.content === "string") { return { content: params.content }; } return result; } /** Adds one visible parent-cancellation warning without duplicating merged audit warnings. */ function withReviewAbortedWarning(warnings: string[], signal?: AbortSignal): string[] { if (!signal?.aborted) return warnings; const warning = i18n.t("reviewAborted"); return warnings.includes(warning) ? warnings : [...warnings, warning]; } /** Produces an Agent-visible diagnostic only for explicit reviewer rejections. */ function createReviewRejectionDiagnostic( audit: FileEditReviewAudit, ): string | undefined { const rejected = audit.reviewers.filter((reviewer) => reviewer.status === "rejected"); if (rejected.length === 0) return undefined; const lines: string[] = ["[文件编辑审查未通过,必须立即修正]"]; lines.push(audit.filePath ? `文件:${audit.filePath}` : `工具:${audit.toolName}`); for (const reviewer of rejected) { lines.push(`规则审查:${reviewer.name}(${reviewer.rulesFiles?.join(", ") ?? reviewer.rulesFile ?? "未指定规则文件"})`); if (reviewer.summary) lines.push(`结论:${reviewer.summary}`); for (const finding of reviewer.findings ?? []) { const location = finding.line ? `第 ${finding.line} 行:` : ""; const ruleGroup = finding.ruleGroup ? `[${finding.ruleGroup}] ` : ""; lines.push(`- ${ruleGroup}${location}${finding.message}`); } } lines.push("请先修正以上问题,再继续后续任务。不要忽略这条审查结果。"); return lines.join("\n"); } /** Executes one reviewer with parent cancellation and timeout fail-open behavior. */ async function reviewWithModel(options: { context: FileReviewExecutionContext; config: FileEditReviewConfig; reviewer: FileEditReviewRule["reviewer"]; rules: FileEditReviewRule[]; toolName: string; filePath?: string; diff: string; currentFileContext?: CurrentFileContext; trigger?: ReviewTrigger; }): Promise { const { context, config, reviewer, rules, toolName, filePath, diff, currentFileContext, trigger = AFTER_TRIGGER } = options; const startedAt = performance.now(); const base = { name: reviewer.name, model: reviewer.model, rulesFiles: rules.map((rule) => rule.reviewer.rulesFile).filter((file): file is string => Boolean(file)), }; /** Reports parent cancellation as a skipped review rather than a provider failure. */ const createAbortedResult = (): FileEditReviewResult => ({ ...base, status: SKIPPED_STATUS, durationMs: Math.round(performance.now() - startedAt), error: i18n.t("reviewAborted"), }); if (context.signal?.aborted) return createAbortedResult(); const separator = reviewer.model.indexOf("/"); const modelProvider = reviewer.model.slice(0, separator); const modelId = reviewer.model.slice(separator + 1); const model = context.ctx.modelRegistry.find(modelProvider, modelId); if (!model) { return { ...base, status: "failed", durationMs: Math.round(performance.now() - startedAt), error: `审查模型不存在:${reviewer.model}`, }; } const controller = new AbortController(); /** Propagates the parent abort signal to the reviewer request. */ const abortFromParent = () => controller.abort(); context.signal?.addEventListener("abort", abortFromParent, { once: true }); const timeout = setTimeout(() => controller.abort(), config.timeoutSeconds * MILLISECONDS_PER_SECOND); try { const auth = await context.ctx.modelRegistry.getApiKeyAndHeaders(model); if (auth.ok === false) throw new Error(`审查模型鉴权失败:${auth.error}`); if (context.signal?.aborted) return createAbortedResult(); const response = await complete( model, { messages: [{ role: "user", content: [{ type: "text", text: buildMergedReviewPrompt({ toolName, filePath, diff, currentFileContext, rules, trigger }) }], timestamp: Date.now(), }], }, { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, maxTokens: REVIEW_MAX_TOKENS, signal: controller.signal, }, ); if (context.signal?.aborted) return createAbortedResult(); if (response.stopReason === "error" || response.stopReason === "aborted") { throw new Error(response.errorMessage ?? `审查模型结束原因:${response.stopReason}`); } const text = response.content .filter((content): content is { type: "text"; text: string } => content.type === "text") .map((content) => content.text) .join("\n"); const parsed = parseReviewResponse(text); return { ...base, status: parsed.passed ? "passed" : "rejected", summary: parsed.summary, findings: parsed.findings, durationMs: Math.round(performance.now() - startedAt), }; } catch (error) { if (context.signal?.aborted) return createAbortedResult(); return { ...base, status: "failed", durationMs: Math.round(performance.now() - startedAt), error: error instanceof Error ? error.message : String(error), }; } finally { clearTimeout(timeout); context.signal?.removeEventListener("abort", abortFromParent); } } /** Captures the pre-tool file state and a fallback edit payload. */ async function createSnapshot( context: FileReviewExecutionContext, toolName: "edit" | "write", ): Promise<{ snapshot?: FileSnapshot; fallbackDiff: string }> { const filePath = getPath(context.params); if (!filePath) return { fallbackDiff: "无法从工具参数读取文件路径。" }; const absolutePath = isAbsolute(filePath) ? filePath : resolve(context.ctx.cwd, filePath); const before = await captureBefore(absolutePath); return { snapshot: { filePath, before: before.content, beforeError: before.error, }, fallbackDiff: toolName === "edit" ? buildEditFallbackDiff(context.params) : "", }; } /** Reviews a successful file result while preserving the original tool result on failures. */ async function reviewToolResult(options: { context: FileReviewExecutionContext; toolName: "edit" | "write"; config: FileEditReviewConfig; configWarnings: string[]; snapshot: FileSnapshot; fallbackDiff: string; result: ToolResult; afterReviewers?: FileEditReviewReviewerConfig[]; beforeAudit?: FileEditReviewAudit; }): Promise { const { context, toolName, config, configWarnings, snapshot, fallbackDiff, result, afterReviewers, beforeAudit } = options; const configuredAfterReviewers = afterReviewers ?? config.reviewers.filter((reviewer) => reviewer.enabled !== false && reviewerTrigger(reviewer) === AFTER_TRIGGER && reviewerMatchesTool(reviewer, toolName)); const beforeReviewers = beforeAudit?.reviewers ?? []; const startedAt = performance.now(); if (context.signal?.aborted) { const reviewers = [...beforeReviewers]; const audit: FileEditReviewAudit = { filePath: snapshot.filePath, toolName, status: reviewers.length > 0 ? getOverallReviewStatus(reviewers) : SKIPPED_STATUS, reviewers, durationMs: Math.round(performance.now() - startedAt), warnings: withReviewAbortedWarning( [...configWarnings, ...(beforeAudit?.warnings ?? [])], context.signal, ), }; return { ...result, details: { ...(result.details ?? {}), fileEditReview: audit } }; } const after = await captureAfter( toolName, isAbsolute(snapshot.filePath) ? snapshot.filePath : resolve(context.ctx.cwd, snapshot.filePath), context.params, ); snapshot.after = after.content; snapshot.afterError = after.error; const auditBase = { filePath: snapshot.filePath, toolName, warnings: [...configWarnings], } satisfies Pick; if (isFailedToolResult(result)) { const skippedAfter = configuredAfterReviewers.map((reviewer) => ({ name: reviewer.name, model: reviewer.model, status: SKIPPED_STATUS, durationMs: 0, error: i18n.t("failedAfterReview"), })); const reviewers = [...beforeReviewers, ...skippedAfter]; const audit: FileEditReviewAudit = { ...auditBase, status: getOverallReviewStatus(reviewers), trigger: AFTER_TRIGGER, reviewers, durationMs: Math.round(performance.now() - startedAt), warnings: [...configWarnings, ...(beforeAudit?.warnings ?? [])], }; return { ...result, details: { ...(result.details ?? {}), fileEditReview: audit } }; } const diff = buildFileEditReviewDiff(snapshot.filePath, snapshot.before, snapshot.after, fallbackDiff); if (!diff) { const reviewers = [...beforeReviewers]; const audit: FileEditReviewAudit = { ...auditBase, status: reviewers.length > 0 ? getOverallReviewStatus(reviewers) : SKIPPED_STATUS, reviewers, durationMs: Math.round(performance.now() - startedAt), warnings: [...configWarnings, ...(beforeAudit?.warnings ?? []), i18n.t("unchangedFileSkipped")], }; const diagnostic = createReviewRejectionDiagnostic(audit); if (diagnostic) notify(context.ctx, diagnostic, "error"); return { ...result, details: { ...(result.details ?? {}), fileEditReview: audit }, content: diagnostic ? [...result.content, { type: "text", text: diagnostic }] : result.content, }; } const conditionSelection = await selectReviewersByCondition({ reviewers: configuredAfterReviewers, event: context.event, ctx: context.ctx, timeoutMs: config.timeoutSeconds * MILLISECONDS_PER_SECOND, }); const selectedReviewers = conditionSelection.matched; const conditionResults = conditionSelection.results; const currentFileContext = buildCurrentFileContext(snapshot.before, snapshot.after, config.maxFileContextChars); const reviewerGroups = selectedReviewers .map((reviewer) => { const loaded = loadReviewRules(reviewer, context.ctx.cwd, config.maxRuleLines); const applicableRules = loaded.rules.filter((rule) => rule.reviewer.enabled !== false && reviewerIsEditorLocal(rule.reviewer) && reviewerAppliesToFile(rule.reviewer, snapshot.filePath), ); return { reviewer, rules: applicableRules, errors: loaded.errors }; }); const applicableGroups = reviewerGroups.filter((group) => group.rules.length > 0 || group.errors.length > 0); const applicableErrors = applicableGroups.flatMap((group) => group.errors); if (applicableGroups.length === 0 && applicableErrors.length === 0) { const hasConditionRejection = conditionResults.some((reviewer) => reviewer.status === REJECTED_STATUS); if (!beforeAudit && !hasConditionRejection) return result; const reviewers = [...beforeReviewers, ...conditionResults]; const audit: FileEditReviewAudit = { ...auditBase, status: getOverallReviewStatus(reviewers), trigger: AFTER_TRIGGER, reviewers, durationMs: Math.round(performance.now() - startedAt), warnings: [...configWarnings, ...(beforeAudit?.warnings ?? [])], }; const diagnostic = createReviewRejectionDiagnostic(audit); if (diagnostic) notify(context.ctx, diagnostic, "error"); return { ...result, details: { ...(result.details ?? {}), fileEditReview: audit }, content: diagnostic ? [...result.content, { type: "text", text: diagnostic }] : result.content, }; } const warnings = [ ...configWarnings, ...(beforeAudit?.warnings ?? []), ...applicableGroups.flatMap((group) => group.rules.flatMap((rule) => rule.warning ? [rule.warning] : [])), ]; const reviewResults = await Promise.all([ ...conditionResults, ...applicableGroups.map((group) => reviewWithModel({ context, config, reviewer: group.reviewer, rules: group.rules, toolName, filePath: snapshot.filePath, diff, currentFileContext, }), ), ...applicableErrors.map((error) => Promise.resolve(error)), ]); const auditWarnings = withReviewAbortedWarning(warnings, context.signal); const audit: FileEditReviewAudit = { ...auditBase, status: getOverallReviewStatus(reviewResults), trigger: AFTER_TRIGGER, reviewers: [...beforeReviewers, ...reviewResults], durationMs: Math.round(performance.now() - startedAt), warnings: auditWarnings, }; const diagnostic = createReviewRejectionDiagnostic(audit); if (diagnostic) { notify(context.ctx, diagnostic, "error"); } return { ...result, details: { ...(result.details ?? {}), fileEditReview: audit }, content: diagnostic ? [...result.content, { type: "text", text: diagnostic }] : result.content, }; } /** Executes matching before reviewers and returns the visible audit used for blocking. */ async function runBeforeReview(options: { context: FileReviewExecutionContext; loaded: ReturnType; filePath?: string; fallbackDiff: string; }): Promise { const { context, loaded, filePath, fallbackDiff } = options; const configuredReviewers = loaded.config.reviewers.filter((reviewer) => reviewer.enabled !== false && reviewerTrigger(reviewer) === BEFORE_TRIGGER && reviewerMatchesTool(reviewer, context.toolName), ); if (configuredReviewers.length === 0) return undefined; const startedAt = performance.now(); const conditionSelection = await selectReviewersByCondition({ reviewers: configuredReviewers, event: context.event, ctx: context.ctx, timeoutMs: loaded.config.timeoutSeconds * MILLISECONDS_PER_SECOND, }); const reviewers = conditionSelection.matched; const results: FileEditReviewResult[] = [...conditionSelection.results]; if (reviewers.length === 0) { return { status: getOverallReviewStatus(results), filePath, toolName: context.toolName, trigger: BEFORE_TRIGGER, reviewers: results, durationMs: Math.round(performance.now() - startedAt), warnings: withReviewAbortedWarning([...loaded.warnings], context.signal), }; } const isFileTool = context.toolName === EDIT_TOOL || context.toolName === WRITE_TOOL; const selectedFilePath = isFileTool ? getPath(context.params) : undefined; const serializedPayload = selectedFilePath ? { text: buildFileEditReviewDiff(selectedFilePath, undefined, typeof context.params.content === "string" ? context.params.content : undefined, fallbackDiff) } : safeSerialize(context.params, MAX_REVIEW_PAYLOAD_CHARS); const warnings = [...loaded.warnings]; for (const reviewer of reviewers) { const loadedRules = loadReviewRules(reviewer, context.ctx.cwd, loaded.config.maxRuleLines); const rules = loadedRules.rules.filter((rule) => { if (rule.reviewer.enabled === false || !reviewerIsEditorLocal(rule.reviewer)) return false; return selectedFilePath ? reviewerAppliesToFile(rule.reviewer, selectedFilePath) : (rule.reviewer.filePatterns ?? []).length === 0; }); warnings.push(...rules.flatMap((rule) => rule.warning ? [rule.warning] : [])); results.push(...loadedRules.errors); if (rules.length > 0 && serializedPayload.error) { results.push({ name: reviewer.name, model: reviewer.model, status: "failed", durationMs: 0, error: serializedPayload.error, }); } else if (rules.length > 0) { results.push(await reviewWithModel({ context, config: loaded.config, reviewer, rules, toolName: context.toolName, filePath: selectedFilePath, diff: serializedPayload.text ?? "", trigger: BEFORE_TRIGGER })); } else if (loadedRules.errors.length === 0) { results.push({ name: reviewer.name, model: reviewer.model, status: "skipped", durationMs: 0, error: i18n.t("noApplicableRules") }); } } return { status: getOverallReviewStatus(results), filePath, toolName: context.toolName, trigger: BEFORE_TRIGGER, reviewers: results, durationMs: Math.round(performance.now() - startedAt), warnings: withReviewAbortedWarning(warnings, context.signal) }; } /** Prepares snapshots, before audits, and after reviewer state for one tool call. */ async function prepareFileReviewCall( context: FileReviewExecutionContext, ): Promise { const loaded = loadFileEditReviewConfig(); const pending: PendingFileReviewCall = { toolName: context.toolName, params: { ...context.params }, loaded, fallbackDiff: "", afterReviewers: [], }; if (!loaded.config.enabled) { if (loaded.warnings.length > 0) { notify(context.ctx, loaded.warnings.join(" | "), "warning"); } return pending; } if (context.signal?.aborted) return pending; const isFileTool = context.toolName === EDIT_TOOL || context.toolName === WRITE_TOOL; const filePath = isFileTool ? getPath(context.params) : undefined; if (isFileTool && filePath && loaded.config.reviewers.some((reviewer) => reviewer.enabled && reviewerMatchesTool(reviewer, context.toolName) && reviewerAppliesToFile(reviewer, filePath))) { const prepared = await createSnapshot(context, context.toolName as typeof EDIT_TOOL | typeof WRITE_TOOL); pending.snapshot = prepared.snapshot; pending.fallbackDiff = prepared.fallbackDiff; } pending.afterReviewers = loaded.config.reviewers.filter((reviewer) => reviewer.enabled !== false && reviewerTrigger(reviewer) === AFTER_TRIGGER && reviewerMatchesTool(reviewer, context.toolName), ); pending.beforeAudit = await runBeforeReview({ context, loaded, filePath, fallbackDiff: pending.fallbackDiff }); return pending; } /** Processes generic tool results with input/result review and before-audit merging. */ async function processGenericReviewResult(context: FileReviewExecutionContext, pending: PendingFileReviewCall, result: ToolResult): Promise { if (pending.afterReviewers.length === 0 && !pending.beforeAudit) return result; const startedAt = performance.now(); const configuredReviewers = pending.afterReviewers; const results: FileEditReviewResult[] = []; if (isFailedToolResult(result)) { results.push(...configuredReviewers.map((reviewer) => ({ name: reviewer.name, model: reviewer.model, status: SKIPPED_STATUS, durationMs: 0, error: i18n.t("failedAfterReview"), }))); } else { const conditionSelection = await selectReviewersByCondition({ reviewers: configuredReviewers, event: context.event, ctx: context.ctx, timeoutMs: pending.loaded.config.timeoutSeconds * MILLISECONDS_PER_SECOND, }); results.push(...conditionSelection.results); const reviewers = conditionSelection.matched; const serializedPayload = safeSerialize( { input: pending.params, result: { content: result.content, details: result.details, isError: result.isError } }, MAX_REVIEW_PAYLOAD_CHARS, ); for (const reviewer of reviewers) { const loadedRules = loadReviewRules(reviewer, context.ctx.cwd, pending.loaded.config.maxRuleLines); const rules = loadedRules.rules.filter((rule) => rule.reviewer.enabled !== false && reviewerIsEditorLocal(rule.reviewer) && (rule.reviewer.filePatterns ?? []).length === 0); results.push(...loadedRules.errors); if (rules.length > 0 && serializedPayload.error) { results.push({ name: reviewer.name, model: reviewer.model, status: "failed", durationMs: 0, error: serializedPayload.error }); } else if (rules.length > 0) { results.push(await reviewWithModel({ context, config: pending.loaded.config, reviewer, rules, toolName: context.toolName, diff: serializedPayload.text ?? "", trigger: AFTER_TRIGGER })); } else if (loadedRules.errors.length === 0) { results.push({ name: reviewer.name, model: reviewer.model, status: "skipped", durationMs: 0, error: i18n.t("noApplicableGenericRules") }); } } } const reviewersWithBefore = [...(pending.beforeAudit?.reviewers ?? []), ...results]; const afterDurationMs = Math.round(performance.now() - startedAt); const warnings = withReviewAbortedWarning( [...pending.loaded.warnings, ...(pending.beforeAudit?.warnings ?? [])], context.signal, ); const audit: FileEditReviewAudit = { status: getOverallReviewStatus(reviewersWithBefore), toolName: context.toolName, trigger: AFTER_TRIGGER, reviewers: reviewersWithBefore, durationMs: (pending.beforeAudit?.durationMs ?? 0) + afterDurationMs, warnings }; const diagnostic = createReviewRejectionDiagnostic({ ...audit, filePath: context.toolName }); if (diagnostic) notify(context.ctx, diagnostic, "error"); return { ...result, details: { ...(result.details ?? {}), fileEditReview: audit }, content: diagnostic ? [...result.content, { type: "text", text: diagnostic }] : result.content }; } /** Completes the file review lifecycle and applies the configured output bound. */ async function processFileReviewResult( context: FileReviewExecutionContext, pending: PendingFileReviewCall, result: ToolResult, ): Promise { const { loaded } = pending; if (pending.beforeAudit && pending.beforeAudit.status === REJECTED_STATUS) { return { ...result, details: { ...(result.details ?? {}), fileEditReview: pending.beforeAudit } }; } if (!pending.snapshot) return processGenericReviewResult(context, pending, result); return reviewToolResult({ context, toolName: pending.toolName as typeof EDIT_TOOL | typeof WRITE_TOOL, config: loaded.config, configWarnings: loaded.warnings, snapshot: pending.snapshot, fallbackDiff: pending.fallbackDiff, result, afterReviewers: pending.afterReviewers, beforeAudit: pending.beforeAudit, }); } async function inputPositiveInteger( ctx: ExtensionCommandContext, title: string, current: number, ): Promise { const value = await ctx.ui.input(title, String(current)); if (value === undefined) return undefined; if (!/^\d+$/.test(value.trim()) || Number(value) <= 0) { notify(ctx, i18n.t("positiveInteger"), "error"); return undefined; } return Number(value); } async function inputModel( ctx: ExtensionCommandContext, current: string, ): Promise { const value = await ctx.ui.input(i18n.t("modelInput"), current); if (value === undefined) return undefined; const normalized = value.trim(); if (!/^[^/\s]+\/[^/\s]+$/.test(normalized)) { notify(ctx, i18n.t("modelInvalid"), "error"); return undefined; } return normalized; } async function inputList( ctx: ExtensionCommandContext, title: string, current: string[], required: boolean, ): Promise { const value = await ctx.ui.input(title, current.join(", ")); if (value === undefined) return undefined; const items = value.split(",").map((item) => item.trim()).filter(Boolean); if (required && items.length === 0) { notify(ctx, i18n.t("listRequired"), "error"); return undefined; } return items; } /** Edits reviewer lifecycle fields and persists validated user choices through the caller. */ async function editReviewer( ctx: ExtensionCommandContext, reviewer: FileEditReviewReviewerConfig, ): Promise<"deleted" | "back"> { while (true) { const rulesFiles = reviewer.rulesFiles ?? (reviewer.rulesFile ? [reviewer.rulesFile] : []); const choices = [ i18n.t("status", { value: reviewer.enabled === false ? i18n.t("disabled") : i18n.t("enabled") }), i18n.t("name", { value: reviewer.name }), i18n.t("model", { value: reviewer.model }), i18n.t("rules", { value: rulesFiles.join(", ") }), i18n.t("tools", { value: (reviewer.tools ?? DEFAULT_REVIEW_TOOLS).join(", ") }), i18n.t("triggerConfig", { value: reviewerTrigger(reviewer) }), i18n.t("conditionConfig", { value: reviewer.condition ?? i18n.t("conditionNone") }), i18n.t("patterns", { value: reviewer.filePatterns?.join(", ") || i18n.t("allFiles") }), i18n.t("deleteReviewer"), i18n.t("back"), ]; const choice = await ctx.ui.select(i18n.t("editReviewer", { name: reviewer.name }), choices); if (choice === undefined || choice === i18n.t("back")) return "back"; if (choice === choices[0]) { reviewer.enabled = reviewer.enabled === false; } else if (choice === choices[1]) { const value = await ctx.ui.input(i18n.t("reviewerName"), reviewer.name); if (value?.trim()) reviewer.name = value.trim(); } else if (choice === choices[2]) { const value = await inputModel(ctx, reviewer.model); if (value !== undefined) reviewer.model = value; } else if (choice === choices[3]) { const value = await inputList(ctx, i18n.t("listInput"), rulesFiles, true); if (value !== undefined) { delete reviewer.rulesFile; reviewer.rulesFiles = value; } } else if (choice === choices[4]) { const value = await inputList(ctx, i18n.t("toolsInput"), reviewer.tools ?? DEFAULT_REVIEW_TOOLS, true); if (value !== undefined) reviewer.tools = value.includes(ALL_TOOLS) ? [ALL_TOOLS] : value; } else if (choice === choices[5]) { const value = await ctx.ui.select(i18n.t("triggerInput"), REVIEW_TRIGGERS); if (value) reviewer.trigger = value as ReviewTrigger; } else if (choice === choices[REVIEWER_CONDITION_CHOICE_INDEX]) { const value = await ctx.ui.input(i18n.t("conditionInput"), reviewer.condition ?? ""); if (value !== undefined) { const condition = value.trim(); if (condition) reviewer.condition = condition; else delete reviewer.condition; } } else if (choice === choices[REVIEWER_PATTERNS_CHOICE_INDEX]) { const value = await inputList(ctx, i18n.t("listInputOptional"), reviewer.filePatterns ?? [], false); if (value !== undefined) reviewer.filePatterns = value; } else if (choice === choices[REVIEWER_DELETE_CHOICE_INDEX]) { const confirmed = await ctx.ui.confirm( i18n.t("deleteTitle"), i18n.t("deleteMessage", { name: reviewer.name }), ); if (confirmed) return "deleted"; } } } async function saveFileEditReviewConfig( ctx: ExtensionCommandContext, config: FileEditReviewConfig, configPath: string, ): Promise { await mkdir(dirname(configPath), { recursive: true }); await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); const saved = loadFileEditReviewConfig(configPath); if (saved.warnings.length > 0) { notify(ctx, i18n.t("savedWarnings", { warnings: saved.warnings.join(" ") }), "warning"); } } async function addReviewer( ctx: ExtensionCommandContext, reviewers: FileEditReviewReviewerConfig[], ): Promise { const name = await ctx.ui.input(i18n.t("reviewerName"), `reviewer-${reviewers.length + 1}`); if (!name?.trim()) return false; const model = await inputModel(ctx, "llm-proxy/LOW"); if (!model) return false; const rulesFiles = await inputList(ctx, i18n.t("listInput"), [], true); if (!rulesFiles) return false; reviewers.push({ name: name.trim(), model, rulesFiles, enabled: true, tools: [...DEFAULT_REVIEW_TOOLS], trigger: AFTER_TRIGGER }); return true; } async function runReviewConfigUi(ctx: ExtensionCommandContext, configPath: string): Promise { const loaded = loadFileEditReviewConfig(); if (loaded.warnings.length > 0) { notify(ctx, i18n.t("configWarnings", { warnings: loaded.warnings.join(" ") }), "warning"); } const config: FileEditReviewConfig = { ...loaded.config, reviewers: loaded.config.reviewers.map((reviewer) => ({ ...reviewer })), }; while (true) { const reviewerChoices = config.reviewers.map( (reviewer) => `${reviewer.enabled === false ? "○" : "●"} ${reviewer.name} · ${reviewer.model}`, ); const choices = [ i18n.t("enabledConfig", { value: config.enabled ? i18n.t("enabled") : i18n.t("disabled") }), i18n.t("timeoutConfig", { value: config.timeoutSeconds }), i18n.t("fileContextConfig", { value: config.maxFileContextChars }), i18n.t("rulesConfig", { value: config.maxRuleLines }), ...reviewerChoices, i18n.t("addReviewer"), ]; const choice = await ctx.ui.select(i18n.t("configTitle"), choices); if (choice === undefined) return; if (choice === choices[CONFIG_ENABLED_CHOICE_INDEX]) { config.enabled = !config.enabled; await saveFileEditReviewConfig(ctx, config, configPath); } else if (choice === choices[CONFIG_TIMEOUT_CHOICE_INDEX]) { const value = await inputPositiveInteger(ctx, i18n.t("timeoutInput"), config.timeoutSeconds); if (value !== undefined) { config.timeoutSeconds = value; await saveFileEditReviewConfig(ctx, config, configPath); } } else if (choice === choices[CONFIG_FILE_CONTEXT_CHOICE_INDEX]) { const value = await inputPositiveInteger(ctx, i18n.t("fileContextInput"), config.maxFileContextChars); if (value !== undefined) { config.maxFileContextChars = value; await saveFileEditReviewConfig(ctx, config, configPath); } } else if (choice === choices[CONFIG_RULES_CHOICE_INDEX]) { const value = await inputPositiveInteger(ctx, i18n.t("rulesInput"), config.maxRuleLines); if (value !== undefined) { config.maxRuleLines = value; await saveFileEditReviewConfig(ctx, config, configPath); } } else if (choice === choices[CONFIG_REVIEWER_CHOICE_OFFSET + config.reviewers.length]) { const added = await addReviewer(ctx, config.reviewers); if (added) await saveFileEditReviewConfig(ctx, config, configPath); } else if (choice.startsWith("● ") || choice.startsWith("○ ")) { const index = reviewerChoices.indexOf(choice); if (index >= 0) { const result = await editReviewer(ctx, config.reviewers[index]); if (result === "deleted") config.reviewers.splice(index, 1); await saveFileEditReviewConfig(ctx, config, configPath); } } } } function registerReviewConfigCommand(pi: ExtensionAPI): void { const command = { description: i18n.t("commandDescription"), handler: async (_args: string, ctx: ExtensionCommandContext) => { if (!ctx.hasUI) { notify(ctx, i18n.t("interactiveOnly"), "warning"); return; } await runReviewConfigUi(ctx, getPiSupervisorConfigPath()); }, }; for (const name of ["config:tool-supervisor", "pi-tool-supervisor"] as const) { pi.registerCommand(name, command); } } export default function piSupervisorExtension(pi: ExtensionAPI) { // 提示画成会话区里的带底色消息块;渲染器在本包这个模块实例里注册一次。 installNoticeRenderer(pi); const pendingCalls = new Map(); const disposeToolDisplayMiddleware = registerSupervisorToolDisplayMiddleware(); registerSupervisorFallbackRenderer(pi); pi.on("tool_call", async (event, ctx) => { const context: FileReviewExecutionContext = { event, toolName: event.toolName, toolCallId: event.toolCallId, params: event.input, signal: ctx.signal, ctx, }; const pending = await prepareFileReviewCall(context); pendingCalls.set(event.toolCallId, pending); if (pending.beforeAudit?.status === REJECTED_STATUS) { const diagnostic = createReviewRejectionDiagnostic(pending.beforeAudit); if (diagnostic) notify(ctx, diagnostic, "error"); pendingCalls.delete(event.toolCallId); appendSupervisorFallbackAudit(pi, event.toolName, { fileEditReview: pending.beforeAudit }); return { block: true, reason: diagnostic ?? i18n.t("beforeReviewRejectedFallback", { toolName: event.toolName }) }; } }); pi.on("tool_result", async (event: ToolResultEvent, ctx) => { const pending = pendingCalls.get(event.toolCallId); if (!pending) return; pendingCalls.delete(event.toolCallId); const result = await processFileReviewResult( { event, toolName: event.toolName, toolCallId: event.toolCallId, params: pending.params, signal: ctx.signal, ctx, }, pending, { content: event.content, details: event.details as Record | undefined, isError: event.isError, }, ); if (!isSupervisorToolDisplayMiddlewareActive(event.toolName)) { appendSupervisorFallbackAudit(pi, event.toolName, result.details); } return { content: result.content as ToolResultEvent["content"], details: result.details, isError: result.isError, }; }); pi.on("agent_end", () => pendingCalls.clear()); pi.on("session_shutdown", () => disposeToolDisplayMiddleware()); registerReviewConfigCommand(pi); }