mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat: 移除 Codex Fast mode 与 AutoReview 后台分析,模型改为配置驱动
移除 pi-extension-codex-fast-mode:
- 删除整个扩展目录;同步清理根 package.json 的 files / pi.extensions /
dependencies 条目、package-lock.json、tests/extension-load.test.ts 断言
与文档章节。
- pi-minimal-footer 的 Fast on 渲染是该扩展的唯一消费者。扩展移除后 accent
分支不再有生产者,collectFooterStatusItems 简化为直接返回非空状态字符串,
去掉 FooterStatusItem 与 accent 标志,避免留下死代码。
移除 500 次裁决后的后台分析体系:
- 删除 analysis-agent.ts(192 行)与 ledger.ts(296 行),以及对应的
auto-review-runtime / auto-review-ledger 测试。
- runtime.ts 由 177 行收敛到 85 行:安装选项缩为 { service, config };删除
handleAgentSettled 与 agent_settled 注册;保留 session_start / turn_start
(denial circuit breaker 属安全语义)/ session_shutdown。
- 删除 analysis 配置键:config、extension-config、config-schema、生成的
permissions.schema.json 与种子配置。
- 删除 reviewer 的 onDecision 回调(唯一消费者是 ledger)。
- 删除迁移机制 scripts/migrate-permission-policy.mjs、其测试与 update.sh 中
46 行 migrate_permission_policy 接线。种子部署保持“目标缺失才写入”,已存在
的 policy.jsonc 永不被覆盖。
AutoReview 模型改为纯配置驱动:
- 删除 DEFAULT_PROVIDER / DEFAULT_MODEL 常量与 findCodexTemplate 合成逻辑;
model.ts 由 65 行收敛到 48 行。
- provider / model 任一缺失时返回新增失败类别 model-unconfigured 并 defer 到
人工审批;provider 未注册或模型未发布同样 defer。
- reasoning 不再有默认常量,完全由配置决定。
- session_start 在 auto-review 已列入 authorizerChain 且 provider/model 未配齐
时 ui.notify 提示一次,避免不使用 AutoReview 的用户被打扰。
- 种子 autoReview 只保留 enabled,provider/model/reasoning 由用户按本机实际
可用的模型填写。
破坏性变更:analysis 键已从 strictObject schema 删除,任何仍带该键的
policy.jsonc 会使整份配置被拒(fail-closed 到全 ask)。这是刻意的变更,已由
config-loader 测试固定,防止将来无声放宽。
测试:
- 新增 auto-review-model.test.ts(7 例),覆盖此前完全缺失的模型解析:真实
命中、未配置、provider 未知、模型未发布,以及不再合成替代模型。
- 重写 auto-review-runtime.test.ts(6 例):authorizer 注册、未配置时提示
一次、已配置 / disabled / 无 UI / 不在 chain 时静默。
- config-loader.test.ts 新增 2 例:带 analysis 的旧配置被拒且 fail-closed、
autoReview 逐字段合并。
- composition-root.test.ts 的 EXPECTED_HANDLERS 移除 agent_settled。
文档:AGENTS.md、README.md、pi-permission-system 的 README / CHANGELOG /
configuration.md / architecture.md / auto-review UPSTREAM.md。
验证:pi-permission-system 的 typecheck、2577 个测试与 build 全部通过;根 50
个测试通过;schema 重新生成无漂移;新种子通过 schema 校验;5 个脚本 sh -n
通过;git diff --check 无空白问题。未安装 shellcheck;未执行真实 pi install /
update(属外部写操作)。
This commit is contained in:
@@ -1,192 +0,0 @@
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type ModelRegistry,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { z } from "zod";
|
||||
import type { AutoReviewAnalysisConfig, AutoReviewConfig } from "./config.js";
|
||||
import type { AutoReviewAnalysisBatch, AutoReviewLedgerRecord } from "./ledger.js";
|
||||
import { resolveReviewModel } from "./model.js";
|
||||
|
||||
const candidateSchema = z.strictObject({
|
||||
surface: z.string().min(1).max(100),
|
||||
toolName: z.string().min(1).max(100).optional(),
|
||||
pattern: z.string().min(1).max(240),
|
||||
reason: z.string().min(1).max(1_000),
|
||||
evidenceCount: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const proposalSchema = z.strictObject({
|
||||
summary: z.string().min(1).max(4_000),
|
||||
allowCandidates: z.array(candidateSchema).max(50),
|
||||
denyCandidates: z.array(candidateSchema).max(50),
|
||||
keepReview: z.array(candidateSchema).max(100),
|
||||
hardGuardCandidates: z.array(candidateSchema).max(25),
|
||||
});
|
||||
|
||||
export type AutoReviewAnalysisProposal = z.infer<typeof proposalSchema>;
|
||||
|
||||
interface Aggregate {
|
||||
surface: string;
|
||||
toolName?: string;
|
||||
pattern: string;
|
||||
total: number;
|
||||
allow: number;
|
||||
deny: number;
|
||||
authorizationDependent: number;
|
||||
low: number;
|
||||
medium: number;
|
||||
high: number;
|
||||
critical: number;
|
||||
}
|
||||
|
||||
function aggregateRecords(records: AutoReviewLedgerRecord[]): Aggregate[] {
|
||||
const groups = new Map<string, Aggregate>();
|
||||
for (const record of records) {
|
||||
const key = record.fingerprint;
|
||||
const current = groups.get(key) ?? {
|
||||
surface: record.surface,
|
||||
...(record.toolName ? { toolName: record.toolName } : {}),
|
||||
pattern: record.normalizedPattern,
|
||||
total: 0,
|
||||
allow: 0,
|
||||
deny: 0,
|
||||
authorizationDependent: 0,
|
||||
low: 0,
|
||||
medium: 0,
|
||||
high: 0,
|
||||
critical: 0,
|
||||
};
|
||||
current.total += 1;
|
||||
current[record.outcome] += 1;
|
||||
current[record.riskLevel] += 1;
|
||||
if (record.authorizationDependent) current.authorizationDependent += 1;
|
||||
groups.set(key, current);
|
||||
}
|
||||
return [...groups.values()]
|
||||
.sort((left, right) => right.total - left.total)
|
||||
.slice(0, 250);
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): unknown {
|
||||
try { return JSON.parse(text); }
|
||||
catch {
|
||||
const start = text.indexOf("{");
|
||||
const end = text.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) throw new Error("analysis response was not JSON");
|
||||
return JSON.parse(text.slice(start, end + 1));
|
||||
}
|
||||
}
|
||||
|
||||
function assistantText(messages: readonly unknown[]): string {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index] as { role?: string; content?: unknown };
|
||||
if (message?.role !== "assistant") continue;
|
||||
if (typeof message.content === "string") return message.content;
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.flatMap((block) => {
|
||||
if (typeof block === "string") return [block];
|
||||
if (typeof block === "object" && block !== null && "text" in block) {
|
||||
const text = (block as { text?: unknown }).text;
|
||||
return typeof text === "string" ? [text] : [];
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
}
|
||||
throw new Error("analysis agent returned no assistant text");
|
||||
}
|
||||
|
||||
function keyOf(value: { surface: string; toolName?: string; pattern: string }): string {
|
||||
return `${value.surface}\u0000${value.toolName ?? ""}\u0000${value.pattern}`;
|
||||
}
|
||||
|
||||
function validateProposal(
|
||||
proposal: AutoReviewAnalysisProposal,
|
||||
aggregates: Aggregate[],
|
||||
): AutoReviewAnalysisProposal {
|
||||
const evidence = new Map(aggregates.map((item) => [keyOf(item), item]));
|
||||
const validateList = (
|
||||
values: AutoReviewAnalysisProposal["allowCandidates"],
|
||||
kind: "allow" | "deny" | "review" | "hard",
|
||||
) => values.filter((candidate) => {
|
||||
const item = evidence.get(keyOf(candidate));
|
||||
if (!item || candidate.evidenceCount > item.total) return false;
|
||||
if (kind === "allow") {
|
||||
return item.deny === 0 && item.authorizationDependent === 0 && item.high === 0 && item.critical === 0;
|
||||
}
|
||||
if (kind === "deny") return item.allow === 0;
|
||||
if (kind === "hard") return item.allow === 0 && item.high + item.critical > 0;
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
...proposal,
|
||||
allowCandidates: validateList(proposal.allowCandidates, "allow"),
|
||||
denyCandidates: validateList(proposal.denyCandidates, "deny"),
|
||||
keepReview: validateList(proposal.keepReview, "review"),
|
||||
hardGuardCandidates: validateList(proposal.hardGuardCandidates, "hard"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAutoReviewAnalysisAgent(options: {
|
||||
batch: AutoReviewAnalysisBatch;
|
||||
registry: Pick<ModelRegistry, "find" | "getAll" | "getApiKeyAndHeaders" | "getProvider">;
|
||||
autoReview: AutoReviewConfig;
|
||||
analysis: AutoReviewAnalysisConfig;
|
||||
stateDir: string;
|
||||
agentDir: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<AutoReviewAnalysisProposal> {
|
||||
const resolved = resolveReviewModel(options.registry, options.autoReview);
|
||||
if (!resolved.ok) throw new Error(`analysis model unavailable: ${resolved.category}`);
|
||||
const aggregates = aggregateRecords(options.batch.records);
|
||||
const systemPrompt = `You are a read-only permission-policy analyst. Analyze aggregated automatic-review decisions and return one JSON object only. Never suggest an unconditional allow when authorizationDependent is non-zero, any denial exists, or high/critical risk exists. Do not modify files or execute tools. Candidate keys must exactly copy surface, toolName (when present), and pattern from the evidence. The schema is: {summary:string,allowCandidates:Candidate[],denyCandidates:Candidate[],keepReview:Candidate[],hardGuardCandidates:Candidate[]}; Candidate is {surface:string,toolName?:string,pattern:string,reason:string,evidenceCount:integer}.`;
|
||||
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: options.stateDir,
|
||||
agentDir: options.agentDir,
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
noContextFiles: true,
|
||||
systemPrompt,
|
||||
appendSystemPrompt: [],
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: options.stateDir,
|
||||
agentDir: options.agentDir,
|
||||
model: resolved.value.model,
|
||||
thinkingLevel: options.autoReview.reasoning,
|
||||
noTools: "all",
|
||||
resourceLoader: loader,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager: SettingsManager.inMemory(),
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => session.abort(), options.analysis.timeoutMs);
|
||||
const abort = () => session.abort();
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
await session.prompt(JSON.stringify({
|
||||
batch: {
|
||||
fromSequence: options.batch.fromSequence,
|
||||
toSequence: options.batch.toSequence,
|
||||
policyHash: options.batch.policyHash,
|
||||
},
|
||||
aggregates,
|
||||
}));
|
||||
const parsed = proposalSchema.parse(parseJsonObject(assistantText(session.state.messages)));
|
||||
return validateProposal(parsed, aggregates);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
options.signal?.removeEventListener("abort", abort);
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const DEFAULT_PROVIDER = "openai-codex";
|
||||
export const DEFAULT_MODEL = "codex-auto-review";
|
||||
/** Total retry budget for one review call. */
|
||||
export const DEFAULT_TIMEOUT_MS = 90_000;
|
||||
export const DEFAULT_ANALYSIS_THRESHOLD = 500;
|
||||
export const DEFAULT_RETAIN_ANALYZED_BATCHES = 2;
|
||||
|
||||
export const REASONING_LEVELS = [
|
||||
"off",
|
||||
@@ -36,49 +33,31 @@ export const autoReviewConfigFileSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
export const analysisConfigFileSchema = z.strictObject({
|
||||
enabled: z.boolean().optional(),
|
||||
threshold: z.number().int().min(1).max(100_000).optional(),
|
||||
retainAnalyzedBatches: z.number().int().min(0).max(20).optional(),
|
||||
timeoutMs: z.number().int().positive().max(300_000).optional(),
|
||||
});
|
||||
|
||||
export type AutoReviewConfigFile = z.infer<typeof autoReviewConfigFileSchema>;
|
||||
export type AutoReviewAnalysisConfigFile = z.infer<typeof analysisConfigFileSchema>;
|
||||
|
||||
/**
|
||||
* Effective reviewer configuration.
|
||||
*
|
||||
* `provider` and `model` have no built-in default and no synthesized fallback:
|
||||
* the review model is whatever the operator names in the config file. When
|
||||
* either is absent the reviewer cannot run and defers to the human prompt.
|
||||
*/
|
||||
export interface AutoReviewConfig {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
reasoning: (typeof REASONING_LEVELS)[number];
|
||||
provider?: string;
|
||||
model?: string;
|
||||
reasoning?: (typeof REASONING_LEVELS)[number];
|
||||
timeoutMs: number;
|
||||
includeBaselinePolicy: boolean;
|
||||
additionalPolicy?: string;
|
||||
}
|
||||
|
||||
export interface AutoReviewAnalysisConfig {
|
||||
enabled: boolean;
|
||||
threshold: number;
|
||||
retainAnalyzedBatches: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_AUTO_REVIEW_CONFIG: AutoReviewConfig = {
|
||||
enabled: true,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
model: DEFAULT_MODEL,
|
||||
reasoning: "low",
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
includeBaselinePolicy: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG: AutoReviewAnalysisConfig = {
|
||||
enabled: true,
|
||||
threshold: DEFAULT_ANALYSIS_THRESHOLD,
|
||||
retainAnalyzedBatches: DEFAULT_RETAIN_ANALYZED_BATCHES,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
export function normalizeAutoReviewConfig(
|
||||
value?: AutoReviewConfigFile,
|
||||
): AutoReviewConfig {
|
||||
@@ -87,12 +66,3 @@ export function normalizeAutoReviewConfig(
|
||||
...value,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAutoReviewAnalysisConfig(
|
||||
value?: AutoReviewAnalysisConfigFile,
|
||||
): AutoReviewAnalysisConfig {
|
||||
return {
|
||||
...DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG,
|
||||
...value,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { PromptPermissionDetails } from "../service.js";
|
||||
import type { AutoReviewAnalysisConfig, AutoReviewConfig } from "./config.js";
|
||||
import type { ReviewAssessment } from "./verdict.js";
|
||||
|
||||
const DIRECTORY_MODE = 0o700;
|
||||
const FILE_MODE = 0o600;
|
||||
const MAX_PATTERN_LENGTH = 240;
|
||||
|
||||
interface LedgerState {
|
||||
version: 1;
|
||||
completedSequence: number;
|
||||
lastAnalyzedSequence: number;
|
||||
proposalSequence: number;
|
||||
analysisPending: boolean;
|
||||
}
|
||||
|
||||
export interface AutoReviewLedgerRecord {
|
||||
version: 1;
|
||||
sequence: number;
|
||||
timestamp: string;
|
||||
requestId: string;
|
||||
source: string;
|
||||
agentName: string;
|
||||
surface: string;
|
||||
toolName?: string;
|
||||
normalizedPattern: string;
|
||||
fingerprint: string;
|
||||
outcome: "allow" | "deny";
|
||||
riskLevel: ReviewAssessment["riskLevel"];
|
||||
userAuthorization: ReviewAssessment["userAuthorization"];
|
||||
authorizationDependent: boolean;
|
||||
policyRevision: string;
|
||||
policyHash: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface AutoReviewAnalysisBatch {
|
||||
fromSequence: number;
|
||||
toSequence: number;
|
||||
records: AutoReviewLedgerRecord[];
|
||||
policyHash: string;
|
||||
}
|
||||
|
||||
export interface StoredAnalysisProposal {
|
||||
version: 1;
|
||||
proposalId: string;
|
||||
createdAt: string;
|
||||
fromSequence: number;
|
||||
toSequence: number;
|
||||
policyHash: string;
|
||||
proposal: unknown;
|
||||
}
|
||||
|
||||
function initialState(): LedgerState {
|
||||
return {
|
||||
version: 1,
|
||||
completedSequence: 0,
|
||||
lastAnalyzedSequence: 0,
|
||||
proposalSequence: 0,
|
||||
analysisPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
function hash(value: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
function bounded(value: string): string {
|
||||
return value.length <= MAX_PATTERN_LENGTH
|
||||
? value
|
||||
: `${value.slice(0, MAX_PATTERN_LENGTH - 1)}…`;
|
||||
}
|
||||
|
||||
const SAFE_SUBCOMMAND_COMMANDS = new Set([
|
||||
"bun", "cargo", "docker", "git", "go", "kubectl", "npm", "pnpm",
|
||||
"sudo", "systemctl", "yarn",
|
||||
]);
|
||||
const SAFE_PATH_PREFIXES = new Set([
|
||||
"app", "apps", "config", "docs", "etc", "lib", "opt", "packages",
|
||||
"private", "src", "test", "tests", "tmp", "Users", "var", "workspace",
|
||||
"workspaces",
|
||||
]);
|
||||
|
||||
function normalizedExecutable(value: string): string {
|
||||
const executable = value.replace(/\\/g, "/").split("/").filter(Boolean).at(-1) ?? "";
|
||||
return /^[A-Za-z0-9._+-]{1,64}$/u.test(executable) ? executable : "<command>";
|
||||
}
|
||||
|
||||
function normalizeCommand(command: string): string {
|
||||
const words = command.trim().replace(/\s+/g, " ").split(" ").filter(Boolean);
|
||||
if (words.length === 0) return "<empty-command>";
|
||||
const executable = normalizedExecutable(words[0]!);
|
||||
const subcommand = words[1];
|
||||
if (
|
||||
subcommand !== undefined &&
|
||||
SAFE_SUBCOMMAND_COMMANDS.has(executable) &&
|
||||
/^[A-Za-z0-9._+-]{1,64}$/u.test(subcommand)
|
||||
) {
|
||||
return bounded(`${executable} ${subcommand} *`);
|
||||
}
|
||||
return bounded(words.length === 1 ? executable : `${executable} *`);
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
const normalized = value.trim().replace(/\\/g, "/");
|
||||
if (normalized.length === 0) return "<empty-path>";
|
||||
if (normalized === "~" || normalized.startsWith("~/")) return "~/*";
|
||||
const absolute = normalized.startsWith("/");
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
const first = segments[0];
|
||||
if (first && SAFE_PATH_PREFIXES.has(first)) {
|
||||
return bounded(`${absolute ? "/" : ""}${first}/*`);
|
||||
}
|
||||
if (!absolute && segments.length === 1) {
|
||||
const extension = normalized.match(/\.[A-Za-z0-9]{1,10}$/u)?.[0];
|
||||
if (extension) return `*${extension.toLowerCase()}`;
|
||||
}
|
||||
return absolute ? "/<path>/*" : "<relative>/*";
|
||||
}
|
||||
|
||||
function derivePattern(details: PromptPermissionDetails): string {
|
||||
if (typeof details.command === "string") return normalizeCommand(details.command);
|
||||
if (typeof details.path === "string") return normalizePath(details.path);
|
||||
const values = details.accessIntent?.matchValues;
|
||||
if (Array.isArray(values) && typeof values[0] === "string") return normalizePath(values[0]);
|
||||
if (details.toolName) return bounded(details.toolName);
|
||||
return bounded(details.surface ?? "unknown");
|
||||
}
|
||||
|
||||
function readJson<T>(path: string): T | undefined {
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class AutoReviewLedger {
|
||||
private readonly ledgerDir: string;
|
||||
private readonly proposalsDir: string;
|
||||
private readonly statePath: string;
|
||||
private readonly decisionsPath: string;
|
||||
private state: LedgerState;
|
||||
|
||||
constructor(rootDir: string) {
|
||||
this.ledgerDir = join(rootDir, "ledger");
|
||||
this.proposalsDir = join(rootDir, "proposals");
|
||||
this.statePath = join(rootDir, "state.json");
|
||||
this.decisionsPath = join(this.ledgerDir, "decisions.jsonl");
|
||||
for (const dir of [rootDir, this.ledgerDir, this.proposalsDir]) {
|
||||
mkdirSync(dir, { recursive: true, mode: DIRECTORY_MODE });
|
||||
try { chmodSync(dir, DIRECTORY_MODE); } catch { /* best effort on non-POSIX */ }
|
||||
}
|
||||
this.state = readJson<LedgerState>(this.statePath) ?? initialState();
|
||||
this.persistState();
|
||||
}
|
||||
|
||||
recordDecision(event: {
|
||||
details: PromptPermissionDetails;
|
||||
assessment: ReviewAssessment;
|
||||
policyRevision: string;
|
||||
config: AutoReviewConfig;
|
||||
analysis: AutoReviewAnalysisConfig;
|
||||
}): AutoReviewLedgerRecord {
|
||||
const sequence = this.state.completedSequence + 1;
|
||||
const pattern = derivePattern(event.details);
|
||||
const surface = bounded(event.details.accessIntent?.surface ?? event.details.surface ?? "unknown");
|
||||
const toolName = event.details.toolName ? bounded(event.details.toolName) : undefined;
|
||||
const record: AutoReviewLedgerRecord = {
|
||||
version: 1,
|
||||
sequence,
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId: bounded(event.details.requestId),
|
||||
source: bounded(event.details.source),
|
||||
agentName: bounded(event.details.agentName ?? "main"),
|
||||
surface,
|
||||
...(toolName ? { toolName } : {}),
|
||||
normalizedPattern: pattern,
|
||||
fingerprint: hash({ surface, toolName, pattern }),
|
||||
outcome: event.assessment.outcome,
|
||||
riskLevel: event.assessment.riskLevel,
|
||||
userAuthorization: event.assessment.userAuthorization,
|
||||
authorizationDependent: event.assessment.userAuthorization !== "unknown",
|
||||
policyRevision: bounded(event.policyRevision),
|
||||
policyHash: hash(event.config),
|
||||
provider: bounded(event.config.provider),
|
||||
model: bounded(event.config.model),
|
||||
};
|
||||
appendFileSync(this.decisionsPath, `${JSON.stringify(record)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: FILE_MODE,
|
||||
});
|
||||
try { chmodSync(this.decisionsPath, FILE_MODE); } catch { /* best effort */ }
|
||||
|
||||
this.state.completedSequence = sequence;
|
||||
if (
|
||||
event.analysis.enabled &&
|
||||
sequence - this.state.lastAnalyzedSequence >= event.analysis.threshold
|
||||
) {
|
||||
this.state.analysisPending = true;
|
||||
}
|
||||
this.persistState();
|
||||
return record;
|
||||
}
|
||||
|
||||
hasPendingAnalysis(): boolean {
|
||||
return this.state.analysisPending;
|
||||
}
|
||||
|
||||
prepareBatch(config: AutoReviewAnalysisConfig): AutoReviewAnalysisBatch | undefined {
|
||||
const records = this.readRecords();
|
||||
const fromSequence = this.state.lastAnalyzedSequence + 1;
|
||||
const eligible = records.filter((record) => record.sequence >= fromSequence);
|
||||
if (eligible.length < config.threshold) return undefined;
|
||||
const selected = eligible.slice(0, config.threshold);
|
||||
const policyHash = selected[0]?.policyHash ?? "";
|
||||
return {
|
||||
fromSequence,
|
||||
toSequence: selected.at(-1)!.sequence,
|
||||
records: selected,
|
||||
policyHash,
|
||||
};
|
||||
}
|
||||
|
||||
saveProposal(batch: AutoReviewAnalysisBatch, proposal: unknown): StoredAnalysisProposal {
|
||||
const next = this.state.proposalSequence + 1;
|
||||
const proposalId = `proposal-${String(next).padStart(6, "0")}`;
|
||||
const stored: StoredAnalysisProposal = {
|
||||
version: 1,
|
||||
proposalId,
|
||||
createdAt: new Date().toISOString(),
|
||||
fromSequence: batch.fromSequence,
|
||||
toSequence: batch.toSequence,
|
||||
policyHash: batch.policyHash,
|
||||
proposal,
|
||||
};
|
||||
this.writeAtomic(join(this.proposalsDir, `${proposalId}.json`), stored);
|
||||
this.state.proposalSequence = next;
|
||||
this.state.lastAnalyzedSequence = batch.toSequence;
|
||||
this.state.analysisPending =
|
||||
this.state.completedSequence - this.state.lastAnalyzedSequence > 0;
|
||||
this.persistState();
|
||||
return stored;
|
||||
}
|
||||
|
||||
compact(config: AutoReviewAnalysisConfig): void {
|
||||
const keep = Math.max(config.threshold, config.threshold * config.retainAnalyzedBatches);
|
||||
const records = this.readRecords();
|
||||
const retained = records.slice(-keep);
|
||||
const content = retained.map((record) => JSON.stringify(record)).join("\n");
|
||||
this.writeAtomic(this.decisionsPath, content ? `${content}\n` : "");
|
||||
}
|
||||
|
||||
private readRecords(): AutoReviewLedgerRecord[] {
|
||||
if (!existsSync(this.decisionsPath)) return [];
|
||||
return readFileSync(this.decisionsPath, "utf8")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.flatMap((line) => {
|
||||
try { return [JSON.parse(line) as AutoReviewLedgerRecord]; }
|
||||
catch { return []; }
|
||||
})
|
||||
.sort((left, right) => left.sequence - right.sequence);
|
||||
}
|
||||
|
||||
private persistState(): void {
|
||||
this.writeAtomic(this.statePath, this.state);
|
||||
}
|
||||
|
||||
private writeAtomic(path: string, value: unknown): void {
|
||||
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
const content = typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`;
|
||||
writeFileSync(temporaryPath, content, { encoding: "utf8", mode: FILE_MODE, flag: "wx" });
|
||||
try {
|
||||
renameSync(temporaryPath, path);
|
||||
try { chmodSync(path, FILE_MODE); } catch { /* best effort */ }
|
||||
} catch (error) {
|
||||
try { unlinkSync(temporaryPath); } catch { /* best effort */ }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,48 @@
|
||||
import type { AutoReviewConfig } from './config.js'
|
||||
import type { Api, Model, Provider } from '@earendil-works/pi-ai'
|
||||
import type { ModelRegistry } from '@earendil-works/pi-coding-agent'
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from './config.js'
|
||||
|
||||
export type ReviewModelRegistry = Pick<ModelRegistry, 'find' | 'getAll' | 'getApiKeyAndHeaders' | 'getProvider'>
|
||||
|
||||
interface ResolvedReviewModel {
|
||||
export interface ResolvedReviewModel {
|
||||
model: Model<Api>
|
||||
provider: Provider<Api>
|
||||
synthesized: boolean
|
||||
}
|
||||
|
||||
export type ResolveReviewModelFailure =
|
||||
| 'model-unconfigured'
|
||||
| 'provider-unresolved'
|
||||
| 'model-unresolved'
|
||||
|
||||
export type ResolveReviewModelResult =
|
||||
| { ok: true; value: ResolvedReviewModel }
|
||||
| {
|
||||
ok: false
|
||||
category: 'provider-unresolved' | 'model-unresolved'
|
||||
}
|
||||
|
||||
function findCodexTemplate(registry: ReviewModelRegistry, provider: Provider<Api>): Model<Api> | undefined {
|
||||
return (
|
||||
registry.getAll().find(model => model.provider === DEFAULT_PROVIDER && model.api === 'openai-codex-responses') ??
|
||||
provider.getModels().find(model => model.api === 'openai-codex-responses')
|
||||
)
|
||||
}
|
||||
| { ok: false; category: ResolveReviewModelFailure }
|
||||
|
||||
/**
|
||||
* Resolve the reviewer model from operator configuration alone.
|
||||
*
|
||||
* There is no built-in provider/model pair and no synthesized stand-in: the
|
||||
* reviewer runs exactly the model named in `autoReview.provider` /
|
||||
* `autoReview.model`. A configuration that names neither, names an unknown
|
||||
* provider, or names a model the provider does not publish resolves to a
|
||||
* failure, and the reviewer defers the ask to the human prompt.
|
||||
*/
|
||||
export function resolveReviewModel(registry: ReviewModelRegistry, config: AutoReviewConfig): ResolveReviewModelResult {
|
||||
const provider = registry.getProvider(config.provider)
|
||||
const providerId = config.provider
|
||||
const modelId = config.model
|
||||
if (providerId === undefined || modelId === undefined) {
|
||||
return { ok: false, category: 'model-unconfigured' }
|
||||
}
|
||||
|
||||
const provider = registry.getProvider(providerId)
|
||||
if (provider === undefined) {
|
||||
return { ok: false, category: 'provider-unresolved' }
|
||||
}
|
||||
|
||||
const registeredModel = registry.find(config.provider, config.model)
|
||||
if (registeredModel !== undefined) {
|
||||
return {
|
||||
ok: true,
|
||||
value: { model: registeredModel, provider, synthesized: false },
|
||||
}
|
||||
}
|
||||
|
||||
if (config.provider !== DEFAULT_PROVIDER || config.model !== DEFAULT_MODEL) {
|
||||
const model = registry.find(providerId, modelId)
|
||||
if (model === undefined) {
|
||||
return { ok: false, category: 'model-unresolved' }
|
||||
}
|
||||
|
||||
const template = findCodexTemplate(registry, provider)
|
||||
if (template === undefined) {
|
||||
return { ok: false, category: 'model-unresolved' }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
model: {
|
||||
...template,
|
||||
id: DEFAULT_MODEL,
|
||||
name: 'Codex Auto Review',
|
||||
reasoning: true,
|
||||
input: ['text'],
|
||||
},
|
||||
provider,
|
||||
synthesized: true,
|
||||
},
|
||||
}
|
||||
return { ok: true, value: { model, provider } }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const FAILURE_EVENT = 'auto_review.failure'
|
||||
const CIRCUIT_OPEN_EVENT = 'auto_review.circuit_open'
|
||||
|
||||
type FailureCategory =
|
||||
| 'model-unconfigured'
|
||||
| 'provider-unresolved'
|
||||
| 'model-unresolved'
|
||||
| 'auth-unresolved'
|
||||
@@ -35,12 +36,6 @@ export interface ReviewerRuntime {
|
||||
sessionManager: Pick<SessionManager, 'getBranch'>
|
||||
circuitBreaker: DenialCircuitBreaker
|
||||
sessionSignal?: AbortSignal
|
||||
onDecision?: (event: {
|
||||
details: PromptPermissionDetails
|
||||
assessment: ReviewAssessment
|
||||
durationMs: number
|
||||
policyRevision: string
|
||||
}) => void
|
||||
}
|
||||
|
||||
export interface ReviewerDependencies {
|
||||
@@ -154,7 +149,7 @@ function buildStreamOptions(
|
||||
if (auth.env !== undefined) {
|
||||
options.env = auth.env
|
||||
}
|
||||
if (reasoning && runtime.config.reasoning !== 'off') {
|
||||
if (reasoning && runtime.config.reasoning !== undefined && runtime.config.reasoning !== 'off') {
|
||||
options.reasoning = runtime.config.reasoning
|
||||
}
|
||||
return options
|
||||
@@ -358,16 +353,6 @@ export function createPermissionReviewer(
|
||||
durationMs,
|
||||
...contextDiagnostics,
|
||||
})
|
||||
try {
|
||||
runtime.onDecision?.({
|
||||
details,
|
||||
assessment,
|
||||
durationMs,
|
||||
policyRevision: POLICY_REVISION,
|
||||
})
|
||||
} catch {
|
||||
// Audit/analysis persistence must never change the permission verdict.
|
||||
}
|
||||
|
||||
if (assessment.outcome === 'allow') {
|
||||
runtime.circuitBreaker.recordNonDenial()
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ConfigReader } from "../config-store";
|
||||
import { getAnalysisStateDir } from "../config-paths";
|
||||
import type { PermissionsService } from "../service";
|
||||
import type { ReviewLogger } from "../session-logger";
|
||||
import { runAutoReviewAnalysisAgent } from "./analysis-agent.js";
|
||||
import {
|
||||
DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG,
|
||||
DEFAULT_AUTO_REVIEW_CONFIG,
|
||||
} from "./config.js";
|
||||
import { DEFAULT_AUTO_REVIEW_CONFIG } from "./config.js";
|
||||
import { DenialCircuitBreaker } from "./circuit-breaker.js";
|
||||
import { AutoReviewLedger } from "./ledger.js";
|
||||
import { createPermissionReviewer } from "./reviewer.js";
|
||||
|
||||
const AUTHORIZER_NAME = "auto-review";
|
||||
const ANALYSIS_RETRY_BACKOFF_MS = 60_000;
|
||||
|
||||
interface SessionRuntime {
|
||||
registry: ModelRegistry;
|
||||
@@ -26,40 +18,38 @@ interface SessionRuntime {
|
||||
}
|
||||
|
||||
export interface AutoReviewLifecycleController {
|
||||
handleSessionStart(context: {
|
||||
modelRegistry: ModelRegistry;
|
||||
sessionManager: Pick<SessionManager, "getBranch">;
|
||||
}): void;
|
||||
handleSessionStart(ctx: ExtensionContext): void;
|
||||
handleTurnStart(): void;
|
||||
handleAgentSettled(): Promise<void>;
|
||||
handleSessionShutdown(): void;
|
||||
}
|
||||
|
||||
/** Warn once per session when AutoReview is active but names no usable model. */
|
||||
function warnWhenUnconfigured(config: ConfigReader, ctx: ExtensionContext): void {
|
||||
const active = config.current();
|
||||
const autoReview = active.autoReview ?? DEFAULT_AUTO_REVIEW_CONFIG;
|
||||
if (!autoReview.enabled) return;
|
||||
if (!(active.authorizerChain ?? []).includes(AUTHORIZER_NAME)) return;
|
||||
if (autoReview.provider !== undefined && autoReview.model !== undefined) return;
|
||||
if (!ctx.hasUI) return;
|
||||
ctx.ui.notify(
|
||||
"AutoReview is enabled but autoReview.provider / autoReview.model are not both set, so automatic review cannot run. Every ask will go to the human permission prompt. Set both in the global policy.jsonc to enable automatic review.",
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
|
||||
export function installBuiltinAutoReview(options: {
|
||||
pi: ExtensionAPI;
|
||||
service: PermissionsService;
|
||||
config: ConfigReader;
|
||||
logger: ReviewLogger;
|
||||
agentDir: string;
|
||||
/** Test seam; production always uses the isolated AgentSession implementation. */
|
||||
runAnalysis?: typeof runAutoReviewAnalysisAgent;
|
||||
}): AutoReviewLifecycleController {
|
||||
const { pi, service, config, logger, agentDir } = options;
|
||||
const analyze = options.runAnalysis ?? runAutoReviewAnalysisAgent;
|
||||
const { service, config } = options;
|
||||
const circuitBreaker = new DenialCircuitBreaker();
|
||||
const stateDir = getAnalysisStateDir(agentDir);
|
||||
const ledger = new AutoReviewLedger(stateDir);
|
||||
let runtime: SessionRuntime | undefined;
|
||||
let analysisRunning = false;
|
||||
let analysisGeneration = 0;
|
||||
let nextAnalysisAttemptAt = 0;
|
||||
|
||||
service.registerAuthorizer(
|
||||
AUTHORIZER_NAME,
|
||||
async (details, query, log) => {
|
||||
const active = config.current();
|
||||
const autoReview = active.autoReview ?? DEFAULT_AUTO_REVIEW_CONFIG;
|
||||
const analysis = active.analysis ?? DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG;
|
||||
if (!autoReview.enabled || runtime === undefined) return { kind: "defer" };
|
||||
const reviewer = createPermissionReviewer({
|
||||
config: autoReview,
|
||||
@@ -67,110 +57,28 @@ export function installBuiltinAutoReview(options: {
|
||||
sessionManager: runtime.sessionManager,
|
||||
circuitBreaker,
|
||||
sessionSignal: runtime.controller.signal,
|
||||
onDecision: (event) => {
|
||||
ledger.recordDecision({
|
||||
details: event.details,
|
||||
assessment: event.assessment,
|
||||
policyRevision: event.policyRevision,
|
||||
config: autoReview,
|
||||
analysis,
|
||||
});
|
||||
},
|
||||
});
|
||||
return reviewer(details, query, log);
|
||||
},
|
||||
);
|
||||
|
||||
const runPendingAnalysis = async (): Promise<void> => {
|
||||
if (analysisRunning || runtime === undefined || !ledger.hasPendingAnalysis()) return;
|
||||
if (Date.now() < nextAnalysisAttemptAt) return;
|
||||
const active = config.current();
|
||||
const autoReview = active.autoReview ?? DEFAULT_AUTO_REVIEW_CONFIG;
|
||||
const analysis = active.analysis ?? DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG;
|
||||
if (!analysis.enabled) return;
|
||||
|
||||
const batch = ledger.prepareBatch(analysis);
|
||||
if (!batch) return;
|
||||
const activeRuntime = runtime;
|
||||
const generation = ++analysisGeneration;
|
||||
const isCurrent = (): boolean =>
|
||||
runtime === activeRuntime &&
|
||||
analysisGeneration === generation &&
|
||||
!activeRuntime.controller.signal.aborted;
|
||||
analysisRunning = true;
|
||||
try {
|
||||
const proposal = await analyze({
|
||||
batch,
|
||||
registry: activeRuntime.registry,
|
||||
autoReview,
|
||||
analysis,
|
||||
stateDir,
|
||||
agentDir,
|
||||
signal: activeRuntime.controller.signal,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "permission-policy-analysis-ready",
|
||||
content: [
|
||||
`Permission policy analysis is ready for automatic review decisions ${batch.fromSequence}-${batch.toSequence}.`,
|
||||
"Present these suggestions to the user in this conversation. Do not edit either policy file until the user explicitly selects and confirms rules.",
|
||||
JSON.stringify(proposal),
|
||||
].join("\n\n"),
|
||||
display: true,
|
||||
details: {
|
||||
fromSequence: batch.fromSequence,
|
||||
toSequence: batch.toSequence,
|
||||
},
|
||||
},
|
||||
{ deliverAs: "followUp", triggerTurn: true },
|
||||
);
|
||||
if (!isCurrent()) return;
|
||||
const stored = ledger.saveProposal(batch, proposal);
|
||||
ledger.compact(analysis);
|
||||
logger.review("auto_review.analysis_completed", {
|
||||
proposalId: stored.proposalId,
|
||||
fromSequence: stored.fromSequence,
|
||||
toSequence: stored.toSequence,
|
||||
allowCandidates: proposal.allowCandidates.length,
|
||||
denyCandidates: proposal.denyCandidates.length,
|
||||
hardGuardCandidates: proposal.hardGuardCandidates.length,
|
||||
});
|
||||
nextAnalysisAttemptAt = 0;
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
nextAnalysisAttemptAt = Date.now() + ANALYSIS_RETRY_BACKOFF_MS;
|
||||
logger.review("auto_review.analysis_failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
if (analysisGeneration === generation) analysisRunning = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleSessionStart(context): void {
|
||||
handleSessionStart(ctx): void {
|
||||
runtime?.controller.abort();
|
||||
analysisGeneration += 1;
|
||||
analysisRunning = false;
|
||||
runtime = {
|
||||
registry: context.modelRegistry,
|
||||
sessionManager: context.sessionManager,
|
||||
registry: ctx.modelRegistry,
|
||||
sessionManager: ctx.sessionManager,
|
||||
controller: new AbortController(),
|
||||
};
|
||||
circuitBreaker.resetTurn();
|
||||
warnWhenUnconfigured(config, ctx);
|
||||
},
|
||||
handleTurnStart(): void {
|
||||
circuitBreaker.resetTurn();
|
||||
},
|
||||
async handleAgentSettled(): Promise<void> {
|
||||
await runPendingAnalysis();
|
||||
},
|
||||
handleSessionShutdown(): void {
|
||||
runtime?.controller.abort();
|
||||
runtime = undefined;
|
||||
analysisGeneration += 1;
|
||||
analysisRunning = false;
|
||||
circuitBreaker.resetTurn();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -254,13 +254,13 @@ export function mergeUnifiedConfigs(
|
||||
) as ShellToolsConfig;
|
||||
}
|
||||
|
||||
// Built-in AutoReview and analyzer objects merge field-by-field so a project
|
||||
// may narrow runtime settings without erasing global defaults.
|
||||
for (const key of ["autoReview", "analysis"] as const) {
|
||||
const baseValue = base[key];
|
||||
const overrideValue = override[key];
|
||||
// The built-in AutoReview object merges field-by-field so a project may
|
||||
// narrow runtime settings without erasing global defaults.
|
||||
{
|
||||
const baseValue = base.autoReview;
|
||||
const overrideValue = override.autoReview;
|
||||
if (baseValue !== undefined || overrideValue !== undefined) {
|
||||
merged[key] = { ...baseValue, ...overrideValue };
|
||||
merged.autoReview = { ...baseValue, ...overrideValue };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,6 @@ export function getHardGuardConfigPath(agentDir: string): string {
|
||||
return join(getGlobalConfigDir(agentDir), "hard-guards.jsonc");
|
||||
}
|
||||
|
||||
export function getAnalysisStateDir(agentDir: string): string {
|
||||
return join(getGlobalConfigDir(agentDir), "analysis");
|
||||
}
|
||||
|
||||
|
||||
export function getGlobalLogsDir(agentDir: string): string {
|
||||
return join(getGlobalConfigDir(agentDir), "logs");
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
analysisConfigFileSchema,
|
||||
autoReviewConfigFileSchema,
|
||||
} from "./auto-review/config.ts";
|
||||
import { autoReviewConfigFileSchema } from "./auto-review/config.ts";
|
||||
|
||||
/**
|
||||
* Single source of truth for the permission-system config file shape.
|
||||
@@ -251,10 +248,6 @@ export const unifiedConfigSchema = z
|
||||
description:
|
||||
"Built-in model-assisted reviewer used for static-policy review decisions.",
|
||||
}),
|
||||
analysis: analysisConfigFileSchema.optional().meta({
|
||||
description:
|
||||
"Background policy analysis triggered after a configured number of completed automatic reviews.",
|
||||
}),
|
||||
shellTools: shellToolsSchema.optional(),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -2,9 +2,7 @@ import { mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
normalizeAutoReviewAnalysisConfig,
|
||||
normalizeAutoReviewConfig,
|
||||
type AutoReviewAnalysisConfig,
|
||||
type AutoReviewConfig,
|
||||
} from "./auto-review/config.js";
|
||||
import type {
|
||||
@@ -38,8 +36,6 @@ export interface PermissionSystemExtensionConfig {
|
||||
authorizerChain?: string[];
|
||||
/** Built-in model reviewer configuration. */
|
||||
autoReview?: AutoReviewConfig;
|
||||
/** Count-based background policy analysis configuration. */
|
||||
analysis?: AutoReviewAnalysisConfig;
|
||||
}
|
||||
|
||||
export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
|
||||
@@ -82,9 +78,6 @@ export function normalizePermissionSystemConfig(
|
||||
if (raw.autoReview !== undefined) {
|
||||
result.autoReview = normalizeAutoReviewConfig(raw.autoReview);
|
||||
}
|
||||
if (raw.analysis !== undefined) {
|
||||
result.analysis = normalizeAutoReviewAnalysisConfig(raw.analysis);
|
||||
}
|
||||
if (raw.forwardingTimeoutMs !== undefined) {
|
||||
result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
|
||||
}
|
||||
|
||||
@@ -243,11 +243,8 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
||||
);
|
||||
|
||||
const autoReview = installBuiltinAutoReview({
|
||||
pi,
|
||||
service: permissionsService,
|
||||
config: configStore,
|
||||
logger,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
||||
@@ -316,9 +313,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
||||
lifecycle.handleResourcesDiscover(event, ctx),
|
||||
);
|
||||
pi.on("turn_start", () => autoReview.handleTurnStart());
|
||||
pi.on("agent_settled", () => {
|
||||
void autoReview.handleAgentSettled();
|
||||
});
|
||||
pi.on("session_shutdown", async () => {
|
||||
autoReview.handleSessionShutdown();
|
||||
await lifecycle.handleSessionShutdown();
|
||||
|
||||
Reference in New Issue
Block a user