feat: implement PI-090 product tools

This commit is contained in:
2026-08-23 15:14:50 +08:00
parent 77cdee7e73
commit 13ab383e53
32 changed files with 2035 additions and 49 deletions

View File

@@ -13,6 +13,10 @@ import {
parsePiSubagentDispatchRequest,
type PiSubagentScheduler,
} from './subagent';
import {
type PiProductToolName,
type PiProductTools,
} from './product-tools';
const MAX_REQUEST_BYTES = 64 * 1024;
@@ -21,6 +25,8 @@ interface WorkerRegistrationRecord {
conversationId: string;
generation: number;
projectId: string;
projectPath: string | null;
skillIds: string[];
role: 'parent' | 'child';
contextFile: string;
runId: string | null;
@@ -39,6 +45,8 @@ export interface RegisterPiExtensionWorkerInput {
conversationId: string;
generation: number;
projectId: string;
projectPath?: string;
skillIds?: readonly string[];
extensionsDir: string;
role?: 'parent' | 'child';
runId?: string;
@@ -62,7 +70,25 @@ interface SubagentBridgeRequest {
request: unknown;
}
type BridgeRequest = LeaseBridgeRequest | SubagentBridgeRequest;
interface ProductToolBridgeRequest {
action: 'product.invoke';
conversationId: string;
workerGeneration: number;
runId: string;
resourceId: string;
toolName: PiProductToolName;
input: unknown;
}
interface ChangeRefreshBridgeRequest {
action: 'changes.bash';
conversationId: string;
workerGeneration: number;
runId: string;
resourceId: string;
}
type BridgeRequest = LeaseBridgeRequest | SubagentBridgeRequest | ProductToolBridgeRequest | ChangeRefreshBridgeRequest;
export interface PiExtensionSubagentBridge {
scheduler: PiSubagentScheduler;
@@ -81,6 +107,10 @@ function bridgeRequest(value: unknown): value is BridgeRequest {
&& typeof value.resourceId === 'string';
if (!common) return false;
if (value.action === 'subagent.dispatch') return 'request' in value;
if (value.action === 'product.invoke') {
return typeof value.toolName === 'string' && 'input' in value;
}
if (value.action === 'changes.bash') return true;
return (value.action === 'lease.acquire' || value.action === 'lease.release')
&& (value.leaseId === undefined || typeof value.leaseId === 'string');
}
@@ -91,6 +121,7 @@ export class PiManagedExtensionHost {
private readonly runBindings = new Map<string, string>();
private readonly requestFlights = new Set<Promise<void>>();
private subagentBridge: PiExtensionSubagentBridge | undefined;
private productTools: PiProductTools | undefined;
private server: Server | null = null;
private bridgeUrl: string | null = null;
private startFlight: Promise<string> | null = null;
@@ -104,6 +135,10 @@ export class PiManagedExtensionHost {
this.subagentBridge = bridge;
}
configureProductTools(productTools: PiProductTools): void {
this.productTools = productTools;
}
async registerWorker(input: RegisterPiExtensionWorkerInput): Promise<PiExtensionWorkerRegistration> {
if (!Number.isSafeInteger(input.generation) || input.generation <= 0) {
throw new Error('Worker generation must be a positive safe integer');
@@ -115,6 +150,9 @@ export class PiManagedExtensionHost {
if (role === 'child' && !input.runId?.trim()) {
throw new Error('Child extension registration requires a parent run id');
}
if (this.productTools && !input.projectPath?.trim()) {
throw new Error('Product tools require a worker project path');
}
const token = randomBytes(32).toString('base64url');
const contextFile = path.join(input.extensionsDir, `worker-${randomUUID()}.json`);
const record: WorkerRegistrationRecord = {
@@ -122,6 +160,8 @@ export class PiManagedExtensionHost {
conversationId: input.conversationId,
generation: input.generation,
projectId: input.projectId,
projectPath: input.projectPath?.trim() || null,
skillIds: [...new Set((input.skillIds ?? []).map((id) => id.trim()).filter(Boolean))],
role,
contextFile,
runId: role === 'child'
@@ -154,6 +194,9 @@ export class PiManagedExtensionHost {
async bindRun(conversationId: string, generation: number, runId: string): Promise<void> {
const record = this.findWorker(conversationId, generation);
if (!record) throw new Error('Pi extension worker registration is unavailable');
if (this.productTools && record.projectPath) {
await this.productTools.beginRun({ conversationId, runId, projectPath: record.projectPath });
}
this.releaseWorkerResources(record);
this.runBindings.set(conversationId, runId);
record.runId = runId;
@@ -166,6 +209,9 @@ export class PiManagedExtensionHost {
this.runBindings.delete(conversationId);
}
if (!record || (runId && record.runId !== runId)) return;
if (this.productTools && record.runId) {
await this.productTools.settleRun(conversationId, record.runId).catch(() => null);
}
this.releaseWorkerResources(record);
record.runId = null;
await this.writeContext(record);
@@ -249,6 +295,35 @@ export class PiManagedExtensionHost {
await this.dispatchSubagents(request, response, record, value);
return;
}
if (value.action === 'changes.bash') {
if (!this.productTools) {
this.respond(response, 503, { error: 'Conversation change tracker is unavailable' });
return;
}
await this.productTools.markBash(record.conversationId, value.runId);
this.respond(response, 200, { marked: true });
return;
}
if (value.action === 'product.invoke') {
if (record.role !== 'parent') {
this.respond(response, 403, { error: 'Child workers cannot invoke parent product tools' });
return;
}
if (!this.productTools || !record.projectPath) {
this.respond(response, 503, { error: 'Product tools are unavailable' });
return;
}
const productResult = await this.productTools.execute(value.toolName, {
conversationId: record.conversationId,
runId: value.runId,
resourceId: value.resourceId,
projectId: record.projectId,
projectPath: record.projectPath,
skillIds: record.skillIds,
}, value.input);
this.respond(response, 200, { result: productResult });
return;
}
if (value.action === 'lease.release') {
const lease = record.leases.get(value.resourceId);
if (!lease || !value.leaseId || lease.id !== value.leaseId) {