-
插件能力 · {details.operation}
+
+ {gameResourceProgress ?? `插件能力 · ${details.operation}`}
+
{details.success ? '成功' : details.error ?? '请求失败'} · HTTP {details.status}
@@ -856,6 +883,8 @@ function toolDetailsProgress(details: KnownToolDetails | undefined): string | nu
return `已启用 ${selectedSkills.length} 项技能 · ${details.commands.length} 条命令`;
}
if (details.schema === 'makelore-capability.v1') {
+ const gameResourceProgress = gameResourceProgressLabel(details);
+ if (gameResourceProgress) return gameResourceProgress;
return details.success
? `${details.operation} · 成功`
: `${details.operation} · ${details.error ?? '请求失败'}`;
@@ -879,6 +908,10 @@ function toolDetailsProgress(details: KnownToolDetails | undefined): string | nu
}
function toolProgressPreview(node: ConversationToolNode): string {
+ if (node.details?.schema === 'makelore-capability.v1') {
+ const gameResourceProgress = gameResourceProgressLabel(node.details);
+ if (gameResourceProgress) return gameResourceProgress;
+ }
for (let index = 0; index < node.output.length; index += 1) {
const block = node.output[index];
if (!block) continue;
diff --git a/tests/unit/coding-capability-registry.test.ts b/tests/unit/coding-capability-registry.test.ts
index 87cb191..a0602a8 100644
--- a/tests/unit/coding-capability-registry.test.ts
+++ b/tests/unit/coding-capability-registry.test.ts
@@ -5,7 +5,10 @@ import type { PluginPolicyClientState } from '../../electron/services/plugin-pol
import {
CodingCapabilityRegistryImpl,
} from '../../electron/coding-plugins/registry';
-import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry';
+import type {
+ AdapterInvocationResult,
+ CodingPluginAdapter,
+} from '../../electron/coding-plugins/registry';
import { createDataServicePluginAdapter } from '../../electron/coding-plugins/adapters/data-service';
import {
createDataServiceOperations,
@@ -277,14 +280,28 @@ describe('CodingCapabilityRegistry', () => {
getPolicyState: vi.fn(() => hostedPolicy),
getInstalledDefinition: vi.fn(async () => hostedDefinition),
} as unknown as EffectivePluginResolver;
- const invoke = vi.fn(async () => ({
- success: true as const, status: 202, code: null, error: null, retryable: false as const,
- payload_schema: 'game-resource.v1', data: { executionId: 'execution-a', status: 'accepted' },
- billing: {
- mode: 'platform_metered' as const, status: 'dispatched' as const,
- reserved_points: '2.00', usage_amount: 1, unit: 'generation',
- },
- }));
+ const dispatchedBilling = {
+ mode: 'platform_metered' as const, status: 'dispatched' as const,
+ reserved_points: '2.00', usage_amount: 1, unit: 'generation',
+ };
+ const invoke = vi.fn(async (
+ _context: unknown,
+ _tool: unknown,
+ _value: unknown,
+ onProgress?: (result: AdapterInvocationResult) => void,
+ ) => {
+ onProgress?.({
+ success: true, status: 202, code: null, error: null, retryable: false,
+ payload_schema: 'game-resource.v1',
+ data: { phase: 'generating', executionId: 'execution-a' },
+ billing: dispatchedBilling,
+ });
+ return {
+ success: true as const, status: 202, code: null, error: null, retryable: false as const,
+ payload_schema: 'game-resource.v1', data: { executionId: 'execution-a', status: 'accepted' },
+ billing: dispatchedBilling,
+ };
+ });
const capabilityRegistry = registry({
definitions: [], effectiveResolver,
adapters: [{ pluginId: hostedDefinition.id, inspect: async () => ({ status: 'ready' }), invoke }],
@@ -292,10 +309,12 @@ describe('CodingCapabilityRegistry', () => {
getEnabledPluginIds: async () => [hostedDefinition.id],
});
+ const onUpdate = vi.fn();
const result = await capabilityRegistry.invoke({
toolName: 'game_resource_generate',
context: { ...context, skillIds: ['game-resource'], effectiveSnapshot: frozenSnapshot },
workerRole: 'parent', effectiveSkillIds: ['game-resource'], value: { kind: 'pixel' },
+ onUpdate,
});
expect(effectiveResolver.getInstalledDefinition).toHaveBeenCalledWith(
@@ -305,7 +324,17 @@ describe('CodingCapabilityRegistry', () => {
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({
requestId: 'pi:run-a:resource-a', workerRole: 'parent', effectiveSkillIds: ['game-resource'],
pluginReleaseId: hostedDefinition.releaseId,
- }), hostedDefinition.tools[0], { kind: 'pixel' });
+ }), hostedDefinition.tools[0], { kind: 'pixel' }, expect.any(Function));
+ expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({
+ details: expect.objectContaining({
+ schema: 'makelore-capability.v1',
+ plugin_id: hostedDefinition.id,
+ operation: 'generate',
+ status: 202,
+ data: { phase: 'generating', executionId: 'execution-a' },
+ billing: dispatchedBilling,
+ }),
+ }));
expect(result.details).toMatchObject({
schema: 'makelore-capability.v1', plugin_id: hostedDefinition.id,
capability_id: 'game-resource.generate', operation: 'generate',
diff --git a/tests/unit/coding-conversation-timeline.test.tsx b/tests/unit/coding-conversation-timeline.test.tsx
index 0a17e51..c37e723 100644
--- a/tests/unit/coding-conversation-timeline.test.tsx
+++ b/tests/unit/coding-conversation-timeline.test.tsx
@@ -12,6 +12,100 @@ vi.mock('@/lib/coding-attachments', () => ({
describe('CodingConversationTimeline', () => {
afterEach(() => vi.unstubAllGlobals());
+ it('shows one Game Resource progress card from generation through automatic project save', async () => {
+ const { codingConversationStore } = await import('@/stores/coding-conversations');
+ const { CodingConversationTimeline } = await import(
+ '@/pages/Chat/CodingConversationTimeline'
+ );
+ const base = createProductSnapshot('conversation-game-resource-progress', 1);
+ const details = (phase: 'saving' | 'saved') => ({
+ schema: 'makelore-capability.v1' as const,
+ plugin_id: 'makelore.game-resource',
+ plugin_version: '1.0.0',
+ capability_id: 'game-resource.generate',
+ operation: 'generate',
+ request_id: 'pi:game-run:game-tool',
+ success: true,
+ status: phase === 'saved' ? 200 : 202,
+ code: null,
+ error: null,
+ retryable: false,
+ billing: phase === 'saved' ? {
+ mode: 'platform_metered' as const,
+ status: 'settled' as const,
+ reserved_points: '2.00',
+ actual_points: '2.00',
+ usage_amount: 1,
+ unit: 'generation',
+ } : {
+ mode: 'platform_metered' as const,
+ status: 'dispatched' as const,
+ reserved_points: '2.00',
+ usage_amount: 1,
+ unit: 'generation',
+ },
+ payload_schema: 'game-resource.v1',
+ data: {
+ phase,
+ executionId: '11111111-1111-4111-8111-111111111111',
+ providerStatus: 'succeeded',
+ deliveryStatus: phase,
+ outputCount: 2,
+ files: phase === 'saved' ? [
+ { path: 'assets/generated/game-resource/execution/output-1.png', bytes: 128 },
+ { path: 'assets/generated/game-resource/execution/output-2.png', bytes: 256 },
+ ] : [],
+ },
+ });
+ const snapshot = (seq: number, phase: 'saving' | 'saved') => {
+ const toolDetails = details(phase);
+ return {
+ ...base,
+ cursor: { ...base.cursor, seq },
+ nodes: [{
+ kind: 'tool' as const,
+ id: 'tool-game-resource-progress',
+ toolCallId: 'game-tool',
+ toolName: 'game_resource_generate',
+ title: '生成游戏资源',
+ inputText: '',
+ status: phase === 'saved' ? 'complete' as const : 'running' as const,
+ output: [{
+ kind: 'text' as const,
+ id: `tool-output-game-resource-${phase}`,
+ text: JSON.stringify(toolDetails),
+ status: phase === 'saved' ? 'complete' as const : 'streaming' as const,
+ }],
+ details: toolDetails,
+ }],
+ };
+ };
+ codingConversationStore.getState().applySnapshotEvent({
+ type: 'snapshot',
+ conversationId: 'conversation-game-resource-progress',
+ workerGeneration: 1,
+ seq: base.cursor.seq,
+ snapshot: snapshot(base.cursor.seq, 'saving'),
+ });
+
+ render(
);
+
+ expect(screen.getByTestId('tool-progress-preview')).toHaveTextContent(
+ '游戏资源 · 正在保存到项目',
+ );
+
+ const nextSeq = base.cursor.seq + 1;
+ act(() => codingConversationStore.getState().applySnapshotEvent({
+ type: 'snapshot',
+ conversationId: 'conversation-game-resource-progress',
+ workerGeneration: 1,
+ seq: nextSeq,
+ snapshot: snapshot(nextSeq, 'saved'),
+ }));
+ await waitFor(() => expect(screen.getByTestId('tool-progress-preview'))
+ .toHaveTextContent('游戏资源 · 已保存 2 个文件'));
+ });
+
it('resolves attachment refs into temporary object URLs without base64 state', async () => {
const createObjectURL = vi.fn(() => 'blob:authenticated-preview');
const revokeObjectURL = vi.fn();
diff --git a/tests/unit/coding-plugin-manifest.test.ts b/tests/unit/coding-plugin-manifest.test.ts
index 7ee6cb2..654ba0f 100644
--- a/tests/unit/coding-plugin-manifest.test.ts
+++ b/tests/unit/coding-plugin-manifest.test.ts
@@ -72,6 +72,20 @@ describe('bundled coding plugin manifests', () => {
tools: [],
},
]);
+ expect(definitions[1]?.tools.map(({ name }) => name)).toEqual([
+ 'game_resource_templates',
+ 'game_resource_generate',
+ 'game_resource_cancel',
+ 'game_asset_browser',
+ 'game_asset_review',
+ ]);
+ const gameResourceSkill = await readFile(
+ path.join(GAME_RESOURCE_ROOT, 'skills/game-resource/SKILL.md'),
+ 'utf8',
+ );
+ expect(gameResourceSkill).toContain('保存全部输出到当前项目');
+ expect(gameResourceSkill).not.toContain('game_resource_status');
+ expect(gameResourceSkill).not.toContain('game_resource_save_output');
expect(startupDefinitions).toEqual(definitions);
expect(Object.isFrozen(startupDefinitions)).toBe(true);
expect(Object.isFrozen(startupDefinitions[0]?.operations)).toBe(true);
diff --git a/tests/unit/game-resource-delivery.test.ts b/tests/unit/game-resource-delivery.test.ts
new file mode 100644
index 0000000..fd6fe64
--- /dev/null
+++ b/tests/unit/game-resource-delivery.test.ts
@@ -0,0 +1,494 @@
+// @vitest-environment node
+
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease';
+import {
+ GameResourceDeliveryCoordinator,
+ GameResourceDeliveryReceiptStore,
+} from '../../electron/services/game-resource-delivery';
+import type {
+ GameResourceClient,
+ GameResourceGeneration,
+} from '../../electron/services/game-resource-client';
+
+const EXECUTION_ID = '11111111-1111-4111-8111-111111111111';
+const RELEASE_ID = '22222222-2222-4222-8222-222222222222';
+const PROJECT_ID = '33333333-3333-4333-8333-333333333333';
+const roots: string[] = [];
+
+const dispatchedBilling = {
+ mode: 'platform_metered' as const,
+ status: 'dispatched' as const,
+ reserved_points: '2.00',
+ usage_amount: 1,
+ unit: 'generation',
+};
+
+const settledBilling = {
+ mode: 'platform_metered' as const,
+ status: 'settled' as const,
+ reserved_points: '2.00',
+ actual_points: '2.00',
+ usage_amount: 1,
+ unit: 'generation',
+};
+
+function generation(
+ status: GameResourceGeneration['status'],
+ overrides: Partial
= {},
+): GameResourceGeneration {
+ return {
+ executionId: EXECUTION_ID,
+ releaseId: RELEASE_ID,
+ projectId: PROJECT_ID,
+ logicalOperationId: 'pi:run-a:resource-a',
+ kind: 'pixel',
+ templateName: 'character',
+ status,
+ outputCount: status === 'succeeded' ? 2 : 0,
+ pollIntervalSeconds: 3,
+ billing: status === 'succeeded' ? settledBilling : dispatchedBilling,
+ ...overrides,
+ };
+}
+
+async function fixture(overrides: {
+ generate?: () => Promise;
+ get?: () => Promise;
+ download?: (executionId: string, outputIndex?: number) => Promise<{
+ bytes: Uint8Array;
+ fileName: string | null;
+ contentType: string;
+ }>;
+ writeOutput?: (filePath: string, bytes: Uint8Array) => Promise;
+ sleep?: (milliseconds: number, signal?: AbortSignal) => Promise;
+ acquireBackgroundLease?: () => () => void;
+} = {}) {
+ const root = await mkdtemp(path.join(tmpdir(), 'makelore-game-delivery-'));
+ const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-game-receipts-'));
+ roots.push(root, userDataDir);
+ const generate = vi.fn(overrides.generate ?? (async () => generation('accepted')));
+ const get = vi.fn(overrides.get ?? (async () => generation('succeeded')));
+ const download = vi.fn(overrides.download ?? (async (_executionId, index = 0) => ({
+ bytes: new Uint8Array([index + 1, index + 2]),
+ fileName: index === 0 ? 'hero.png' : 'portrait.webp',
+ contentType: index === 0 ? 'image/png' : 'image/webp',
+ })));
+ const client = { generate, get, download } as unknown as Pick<
+ GameResourceClient,
+ 'generate' | 'get' | 'download'
+ >;
+ const leases = new PiProjectWriteLeaseCoordinator();
+ const touched = vi.fn(async () => undefined);
+ const progress = vi.fn();
+ const receiptStore = new GameResourceDeliveryReceiptStore(
+ path.join(userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
+ );
+ const coordinator = new GameResourceDeliveryCoordinator({
+ client,
+ receipts: receiptStore,
+ leases,
+ sleep: overrides.sleep ?? (async () => undefined),
+ recordTouchedPaths: touched,
+ ...(overrides.writeOutput ? { writeOutput: overrides.writeOutput } : {}),
+ ...(overrides.acquireBackgroundLease
+ ? { acquireBackgroundLease: overrides.acquireBackgroundLease }
+ : {}),
+ });
+ const context = {
+ conversationId: 'conversation-a',
+ runId: 'run-a',
+ localProjectId: 'local-project-a',
+ durableProjectId: PROJECT_ID,
+ projectPath: root,
+ logicalOperationId: 'pi:run-a:resource-a',
+ };
+ const request = {
+ releaseAdmissionId: 'admission-a',
+ releaseId: RELEASE_ID,
+ projectId: PROJECT_ID,
+ logicalOperationId: context.logicalOperationId,
+ kind: 'pixel' as const,
+ templateName: 'character',
+ templateConfig: {},
+ requirement: 'A blue-armored hero',
+ };
+ return {
+ coordinator, receiptStore, context, request, root, userDataDir,
+ client, generate, get, download, leases, touched, progress,
+ };
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map(async (root) => await rm(root, { recursive: true, force: true })));
+});
+
+describe('GameResourceDeliveryCoordinator', () => {
+ it('submits once, hides polling, saves every output under the frozen project, and reports progress', async () => {
+ let activeProjectPath: string | undefined;
+ const states = [generation('running'), generation('succeeded')];
+ let fixtureValue: Awaited>;
+ fixtureValue = await fixture({
+ get: async () => {
+ expect(fixtureValue.leases.activeCount).toBe(0);
+ activeProjectPath = path.join(fixtureValue.root, '..', 'another-project');
+ return states.shift() as GameResourceGeneration;
+ },
+ download: async (_executionId, index = 0) => {
+ expect(fixtureValue.leases.activeCount).toBe(0);
+ expect(fixtureValue.progress.mock.calls.at(-1)?.[0].phase).toBe('saving');
+ return {
+ bytes: new Uint8Array([index + 1, index + 2]),
+ fileName: index === 0 ? 'hero.png' : 'portrait.webp',
+ contentType: index === 0 ? 'image/png' : 'image/webp',
+ };
+ },
+ });
+ const result = await fixtureValue.coordinator.generateAndMaterialize({
+ context: fixtureValue.context,
+ request: fixtureValue.request,
+ onProgress: fixtureValue.progress,
+ });
+
+ expect(activeProjectPath).not.toBe(fixtureValue.root);
+ expect(fixtureValue.generate).toHaveBeenCalledTimes(1);
+ expect(fixtureValue.get).toHaveBeenCalledTimes(2);
+ expect(fixtureValue.download.mock.calls.map((call) => call[1])).toEqual([0, 1]);
+ expect(result).toMatchObject({
+ executionId: EXECUTION_ID,
+ providerStatus: 'succeeded',
+ deliveryStatus: 'saved',
+ files: [
+ { path: `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`, bytes: 2 },
+ { path: `assets/generated/game-resource/${EXECUTION_ID}/output-2.webp`, bytes: 2 },
+ ],
+ billing: settledBilling,
+ });
+ await expect(readFile(path.join(
+ fixtureValue.root,
+ 'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-1.png',
+ ))).resolves.toEqual(Buffer.from([1, 2]));
+ await expect(readFile(path.join(
+ fixtureValue.root,
+ 'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-2.webp',
+ ))).resolves.toEqual(Buffer.from([2, 3]));
+ expect(fixtureValue.leases.activeCount).toBe(0);
+ expect(fixtureValue.touched).toHaveBeenCalledWith(
+ 'conversation-a',
+ 'run-a',
+ result.files.map(({ path: filePath }) => filePath),
+ );
+ expect(fixtureValue.progress.mock.calls.map(([item]) => item.phase)).toEqual([
+ 'submitted', 'generating', 'generating', 'saving', 'saved',
+ ]);
+ });
+
+ it('deduplicates concurrent replay and returns persisted saved paths without another submit or download', async () => {
+ let releaseGenerate: ((value: GameResourceGeneration) => void) | undefined;
+ const generated = new Promise((resolve) => { releaseGenerate = resolve; });
+ const value = await fixture({ generate: async () => await generated });
+
+ const first = value.coordinator.generateAndMaterialize({ context: value.context, request: value.request });
+ const second = value.coordinator.generateAndMaterialize({ context: value.context, request: value.request });
+ releaseGenerate?.(generation('succeeded'));
+ const [left, right] = await Promise.all([first, second]);
+
+ expect(left).toEqual(right);
+ expect(value.generate).toHaveBeenCalledTimes(1);
+ expect(value.download).toHaveBeenCalledTimes(2);
+
+ const replay = await value.coordinator.generateAndMaterialize({
+ context: value.context,
+ request: value.request,
+ });
+ expect(replay).toEqual(left);
+ expect(value.generate).toHaveBeenCalledTimes(1);
+ expect(value.download).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps a background lifecycle lease through polling and delivery', async () => {
+ let held = false;
+ const release = vi.fn(() => { held = false; });
+ const value = await fixture({
+ acquireBackgroundLease: () => {
+ held = true;
+ return release;
+ },
+ generate: async () => {
+ expect(held).toBe(true);
+ return generation('accepted');
+ },
+ get: async () => {
+ expect(held).toBe(true);
+ return generation('succeeded');
+ },
+ download: async (_executionId, index = 0) => {
+ expect(held).toBe(true);
+ return {
+ bytes: new Uint8Array([index + 1]),
+ fileName: 'hero.png',
+ contentType: 'image/png',
+ };
+ },
+ });
+
+ await expect(value.coordinator.generateAndMaterialize({
+ context: value.context,
+ request: value.request,
+ })).resolves.toMatchObject({ deliveryStatus: 'saved' });
+ expect(held).toBe(false);
+ expect(release).toHaveBeenCalledOnce();
+ });
+
+ it('resumes an accepted persisted execution after the original waiter stops', async () => {
+ const value = await fixture({
+ sleep: async () => { throw new Error('waiter stopped'); },
+ });
+ await expect(value.coordinator.generateAndMaterialize({
+ context: value.context,
+ request: value.request,
+ })).rejects.toThrow('waiter stopped');
+ expect(value.generate).toHaveBeenCalledTimes(1);
+
+ const restarted = new GameResourceDeliveryCoordinator({
+ client: value.client,
+ receipts: new GameResourceDeliveryReceiptStore(
+ path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
+ ),
+ leases: value.leases,
+ sleep: async () => undefined,
+ });
+ await expect(restarted.resumePending()).resolves.toEqual([expect.objectContaining({
+ executionId: EXECUTION_ID,
+ providerStatus: 'succeeded',
+ deliveryStatus: 'saved',
+ })]);
+ expect(value.generate).toHaveBeenCalledTimes(1);
+ });
+
+ it('persists provider success and resumes only local delivery after a write failure', async () => {
+ let failWrite = true;
+ const writeOutput = vi.fn(async (target: string, bytes: Uint8Array) => {
+ if (failWrite) throw Object.assign(new Error('disk unavailable'), { code: 'ENOSPC' });
+ await import('node:fs/promises').then(async ({ mkdir, writeFile }) => {
+ await mkdir(path.dirname(target), { recursive: true });
+ await writeFile(target, bytes, { flag: 'wx' });
+ });
+ });
+ const value = await fixture({
+ generate: async () => generation('succeeded'),
+ writeOutput,
+ });
+
+ await expect(value.coordinator.generateAndMaterialize({
+ context: value.context,
+ request: value.request,
+ })).resolves.toMatchObject({
+ providerStatus: 'succeeded',
+ deliveryStatus: 'delivery_failed',
+ errorCode: 'game_resource_delivery_failed',
+ files: [],
+ });
+ expect(value.generate).toHaveBeenCalledTimes(1);
+
+ failWrite = false;
+ const restarted = new GameResourceDeliveryCoordinator({
+ client: value.client,
+ receipts: new GameResourceDeliveryReceiptStore(
+ path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
+ ),
+ leases: value.leases,
+ sleep: async () => undefined,
+ writeOutput,
+ recordTouchedPaths: value.touched,
+ });
+ const resumed = await restarted.resumePending();
+
+ expect(resumed).toEqual([expect.objectContaining({
+ executionId: EXECUTION_ID,
+ providerStatus: 'succeeded',
+ deliveryStatus: 'saved',
+ })]);
+ expect(value.generate).toHaveBeenCalledTimes(1);
+ expect(value.get).not.toHaveBeenCalled();
+ expect(value.download).toHaveBeenCalledTimes(8);
+ });
+
+ it('never overwrites an existing project file with different bytes', async () => {
+ const value = await fixture({ generate: async () => generation('succeeded') });
+ const relativePath = `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`;
+ const target = path.join(value.root, ...relativePath.split('/'));
+ await mkdir(path.dirname(target), { recursive: true });
+ await writeFile(target, Buffer.from([99, 98]));
+
+ await expect(value.coordinator.generateAndMaterialize({
+ context: value.context,
+ request: value.request,
+ })).resolves.toMatchObject({
+ providerStatus: 'succeeded',
+ deliveryStatus: 'delivery_failed',
+ errorCode: 'game_resource_delivery_failed',
+ });
+ await expect(readFile(target)).resolves.toEqual(Buffer.from([99, 98]));
+ expect(value.generate).toHaveBeenCalledTimes(1);
+ });
+
+ it('persists provider success and resumes only local delivery after a download failure', async () => {
+ let failDownload = true;
+ const value = await fixture({
+ generate: async () => generation('succeeded'),
+ download: async (_executionId, index = 0) => {
+ if (failDownload) throw new Error('download unavailable');
+ return {
+ bytes: new Uint8Array([index + 1, index + 2]),
+ fileName: index === 0 ? 'hero.png' : 'portrait.webp',
+ contentType: index === 0 ? 'image/png' : 'image/webp',
+ };
+ },
+ });
+
+ await expect(value.coordinator.generateAndMaterialize({
+ context: value.context,
+ request: value.request,
+ })).resolves.toMatchObject({
+ providerStatus: 'succeeded',
+ deliveryStatus: 'delivery_failed',
+ errorCode: 'game_resource_delivery_failed',
+ files: [],
+ });
+ expect(value.generate).toHaveBeenCalledTimes(1);
+
+ failDownload = false;
+ const restarted = new GameResourceDeliveryCoordinator({
+ client: value.client,
+ receipts: new GameResourceDeliveryReceiptStore(
+ path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
+ ),
+ leases: value.leases,
+ sleep: async () => undefined,
+ recordTouchedPaths: value.touched,
+ });
+ await expect(restarted.resumePending()).resolves.toEqual([expect.objectContaining({
+ executionId: EXECUTION_ID,
+ providerStatus: 'succeeded',
+ deliveryStatus: 'saved',
+ })]);
+ expect(value.generate).toHaveBeenCalledTimes(1);
+ expect(value.get).not.toHaveBeenCalled();
+ });
+
+ it('retries a transient local delivery failure without submitting another generation', async () => {
+ let downloadAttempts = 0;
+ const sleep = vi.fn(async () => undefined);
+ const value = await fixture({
+ generate: async () => generation('succeeded'),
+ sleep,
+ download: async (_executionId, index = 0) => {
+ downloadAttempts += 1;
+ if (downloadAttempts === 1) throw new Error('temporary download failure');
+ return {
+ bytes: new Uint8Array([index + 1, index + 2]),
+ fileName: index === 0 ? 'hero.png' : 'portrait.webp',
+ contentType: index === 0 ? 'image/png' : 'image/webp',
+ };
+ },
+ });
+
+ await expect(value.coordinator.generateAndMaterialize({
+ context: value.context,
+ request: value.request,
+ })).resolves.toMatchObject({
+ providerStatus: 'succeeded',
+ deliveryStatus: 'saved',
+ });
+ expect(value.generate).toHaveBeenCalledTimes(1);
+ expect(value.get).not.toHaveBeenCalled();
+ expect(sleep).toHaveBeenCalledOnce();
+ });
+
+ it('continues resuming later receipts when an earlier reconciliation is unavailable', async () => {
+ let generationIndex = 0;
+ let sleepCalls = 0;
+ const value = await fixture({
+ generate: async () => {
+ const first = generationIndex++ === 0;
+ return generation(first ? 'accepted' : 'succeeded', {
+ executionId: first
+ ? '11111111-1111-4111-8111-111111111111'
+ : '44444444-4444-4444-8444-444444444444',
+ });
+ },
+ download: async () => { throw new Error('delivery unavailable'); },
+ sleep: async () => {
+ if (sleepCalls++ === 0) throw new Error('waiter stopped');
+ },
+ });
+ const secondContext = {
+ ...value.context,
+ logicalOperationId: 'pi:run-a:resource-b',
+ };
+ const secondRequest = {
+ ...value.request,
+ logicalOperationId: secondContext.logicalOperationId,
+ };
+ await expect(value.coordinator.generateAndMaterialize({
+ context: value.context,
+ request: value.request,
+ })).rejects.toThrow('waiter stopped');
+ await value.coordinator.generateAndMaterialize({
+ context: secondContext,
+ request: secondRequest,
+ });
+
+ const resumedClient = {
+ generate: vi.fn(async () => { throw new Error('must not regenerate'); }),
+ get: vi.fn(async () => { throw new Error('first reconciliation unavailable'); }),
+ download: vi.fn(async (executionId: string, index = 0) => {
+ if (executionId === EXECUTION_ID) throw new Error('first delivery remains unavailable');
+ return {
+ bytes: new Uint8Array([index + 1]),
+ fileName: 'result.png',
+ contentType: 'image/png',
+ };
+ }),
+ } as unknown as Pick;
+ const restarted = new GameResourceDeliveryCoordinator({
+ client: resumedClient,
+ receipts: new GameResourceDeliveryReceiptStore(
+ path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
+ ),
+ leases: value.leases,
+ sleep: async () => undefined,
+ });
+
+ const results = await restarted.resumePending();
+
+ expect(results).toEqual([expect.objectContaining({
+ executionId: '44444444-4444-4444-8444-444444444444',
+ deliveryStatus: 'saved',
+ })]);
+ expect(resumedClient.generate).not.toHaveBeenCalled();
+ expect(resumedClient.get).toHaveBeenCalledOnce();
+ });
+
+ it('does not hold the write lease or create files for a pending-review generation', async () => {
+ const value = await fixture({
+ generate: async () => generation('pending_review', { outputCount: 0 }),
+ });
+
+ await expect(value.coordinator.generateAndMaterialize({
+ context: value.context,
+ request: value.request,
+ })).resolves.toMatchObject({
+ providerStatus: 'pending_review',
+ deliveryStatus: 'not_started',
+ files: [],
+ });
+ expect(value.download).not.toHaveBeenCalled();
+ expect(value.leases.activeCount).toBe(0);
+ expect(value.touched).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/game-resource-plugin-adapter.test.ts b/tests/unit/game-resource-plugin-adapter.test.ts
index eeca4e6..619f11f 100644
--- a/tests/unit/game-resource-plugin-adapter.test.ts
+++ b/tests/unit/game-resource-plugin-adapter.test.ts
@@ -8,6 +8,11 @@ import type { CodingPluginToolDefinition } from '../../shared/coding-plugins';
import {
GameResourcePluginAdapter,
} from '../../electron/coding-plugins/adapters/game-resource';
+import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease';
+import {
+ GameResourceDeliveryCoordinator,
+ GameResourceDeliveryReceiptStore,
+} from '../../electron/services/game-resource-delivery';
import type { GameResourceClient, GameResourceGeneration } from '../../electron/services/game-resource-client';
import type { MarketplacePackageClientPort, PluginPackageStore } from '../../electron/coding-plugins/package-store';
import type { TrustedCodingCapabilityContext } from '../../electron/coding-plugins/registry';
@@ -52,8 +57,8 @@ function tool(
capabilityId,
operation,
roles: ['parent'],
- mutation: name === 'game_resource_status' ? 'read' : 'write',
- projectWriteLease: name === 'game_resource_save_output',
+ mutation: operation === 'templates' ? 'read' : 'write',
+ projectWriteLease: false,
permissions: [`hosted.game-resource.${operation.replaceAll('_', '-')}`],
inputSchema: { type: 'object' },
};
@@ -61,7 +66,8 @@ function tool(
async function fixture() {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-game-resource-'));
- roots.push(projectPath);
+ const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-game-resource-receipts-'));
+ roots.push(projectPath, userDataDir);
const resolve = vi.fn(async () => ({
resolveRequestId: 'pi:run-a:resource-a',
resolveRequestDigest: 'a'.repeat(64),
@@ -84,8 +90,15 @@ async function fixture() {
bytes: new Uint8Array([1, 2, 3]), fileName: 'hero.png', contentType: 'image/png',
})),
};
+ const delivery = new GameResourceDeliveryCoordinator({
+ client: client as unknown as GameResourceClient,
+ receipts: new GameResourceDeliveryReceiptStore(path.join(userDataDir, 'receipts.json')),
+ leases: new PiProjectWriteLeaseCoordinator(),
+ sleep: async () => undefined,
+ });
const adapter = new GameResourcePluginAdapter({
client: client as unknown as GameResourceClient,
+ delivery,
marketplace: { resolve } as unknown as MarketplacePackageClientPort,
packageStore: {
getInstalled: vi.fn(async () => ({
@@ -113,15 +126,16 @@ afterEach(async () => {
});
describe('GameResourcePluginAdapter', () => {
- it('resolves a fresh admission and keeps project reference bytes behind the hosted boundary', async () => {
+ it('resolves admission, hides polling, and automatically saves generated output in the project', async () => {
const { adapter, client, context, projectPath, resolve } = await fixture();
await writeFile(path.join(projectPath, 'reference.png'), new Uint8Array([4, 5, 6]));
+ const onProgress = vi.fn();
const result = await adapter.invoke(context, tool('game_resource_generate'), {
kind: 'pixel', templateName: 'character', requirement: 'Blue-armored hero',
confirmed: true,
referencePaths: ['reference.png'],
- });
+ }, onProgress);
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({
resolveRequestId: 'pi:run-a:resource-a', channel: 'stable',
@@ -133,9 +147,26 @@ describe('GameResourcePluginAdapter', () => {
referenceFiles: [{ name: 'reference.png', mimeType: 'image/png', dataBase64: 'BAUG' }],
}));
expect(result).toMatchObject({
- success: true, status: 202, billing,
- data: { executionId: EXECUTION_ID, status: 'accepted', outputCount: 0 },
+ success: true, status: 200, billing,
+ data: {
+ executionId: EXECUTION_ID,
+ providerStatus: 'succeeded',
+ deliveryStatus: 'saved',
+ files: [{
+ path: `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`,
+ bytes: 3,
+ }],
+ },
});
+ expect(client.get).toHaveBeenCalledTimes(1);
+ expect(client.download).toHaveBeenCalledWith(EXECUTION_ID, 0);
+ await expect(readFile(path.join(
+ projectPath,
+ 'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-1.png',
+ ))).resolves.toEqual(Buffer.from([1, 2, 3]));
+ expect(onProgress.mock.calls.map(([progress]) => progress.data.phase)).toEqual([
+ 'submitted', 'generating', 'saving', 'saved',
+ ]);
expect(JSON.stringify(result)).not.toContain(projectPath);
});
@@ -151,28 +182,6 @@ describe('GameResourcePluginAdapter', () => {
expect(client.generate).not.toHaveBeenCalled();
});
- it('requires explicit confirmation and never overwrites a project file', async () => {
- const { adapter, client, context, projectPath } = await fixture();
- const saveTool = tool('game_resource_save_output', 'game-resource.library', 'save_output');
-
- await expect(adapter.invoke(context, saveTool, {
- executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: false,
- })).resolves.toMatchObject({ success: false, code: 'confirmation_required' });
- expect(client.download).not.toHaveBeenCalled();
-
- await expect(adapter.invoke(context, saveTool, {
- executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: true,
- })).resolves.toMatchObject({
- success: true, data: { savedPath: 'assets/hero.png', bytes: 3 },
- });
- await expect(readFile(path.join(projectPath, 'assets/hero.png'))).resolves.toEqual(Buffer.from([1, 2, 3]));
-
- await expect(adapter.invoke(context, saveTool, {
- executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: true,
- })).resolves.toMatchObject({ success: false, code: 'game_resource_destination_exists' });
- await expect(readFile(path.join(projectPath, 'assets/hero.png'))).resolves.toEqual(Buffer.from([1, 2, 3]));
- });
-
it('lists server-owned templates through the same release admission', async () => {
const { adapter, context, client } = await fixture();
await expect(adapter.invoke(context, tool('game_resource_templates'), { kind: 'pixel' }))
diff --git a/tests/unit/pi-extension-bundle.test.ts b/tests/unit/pi-extension-bundle.test.ts
index f50981f..a1718f0 100644
--- a/tests/unit/pi-extension-bundle.test.ts
+++ b/tests/unit/pi-extension-bundle.test.ts
@@ -320,6 +320,115 @@ describe('Makelore Pi extension bundle', () => {
}
});
+ it('streams job-tool progress through the authenticated product bridge', async () => {
+ const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-product-stream-'));
+ roots.push(root);
+ const host = new PiManagedExtensionHost();
+ hosts.push(host);
+ const details = (phase: string) => ({
+ schema: 'makelore-capability.v1' as const,
+ plugin_id: 'makelore.game-resource',
+ plugin_version: '1.0.0',
+ capability_id: 'game-resource.generate',
+ operation: 'generate',
+ request_id: 'pi:stream-run:stream-tool',
+ success: true,
+ status: phase === 'saved' ? 200 : 202,
+ code: null,
+ error: null,
+ retryable: false,
+ billing: {
+ mode: 'platform_metered' as const,
+ status: phase === 'saved' ? 'settled' as const : 'dispatched' as const,
+ reserved_points: '2.00',
+ ...(phase === 'saved' ? { actual_points: '2.00' } : {}),
+ usage_amount: 1,
+ unit: 'generation',
+ },
+ payload_schema: 'game-resource.v1',
+ data: { phase, executionId: 'execution-a' },
+ });
+ const invoke = vi.fn(async (input: {
+ onUpdate?: (result: { content: []; details: ReturnType }) => void;
+ }) => {
+ input.onUpdate?.({ content: [], details: details('generating') });
+ input.onUpdate?.({ content: [], details: details('saving') });
+ return { content: [], details: details('saved') };
+ });
+ host.configureProductTools(new PiProductTools({
+ browser: {} as AgentBrowserModule,
+ attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
+ bundledSkillsDir: path.resolve('resources/coding-skills'),
+ capabilityRegistry: { invoke } as unknown as CodingCapabilityRegistryImpl,
+ }));
+ const jobTool: CodingPluginToolDefinition = {
+ name: 'game_resource_generate',
+ label: 'Generate game resource',
+ description: 'Generate and save a game resource.',
+ capabilityId: 'game-resource.generate',
+ operation: 'generate',
+ roles: ['parent'],
+ mutation: 'write',
+ projectWriteLease: false,
+ permissions: ['hosted.game-resource.generate'],
+ executionMode: 'job',
+ inputSchema: { type: 'object', additionalProperties: false, properties: {} },
+ };
+ const registration = await host.registerWorker({
+ conversationId: 'stream-conversation', generation: 1, projectId: 'stream-project',
+ projectPath: root, extensionsDir: root, tools: [jobTool],
+ });
+ await host.bindRun('stream-conversation', 1, 'stream-run');
+ const previous = {
+ bridge: process.env.MAKELORE_PI_BRIDGE_URL,
+ token: process.env.MAKELORE_PI_WORKER_TOKEN,
+ context: process.env.MAKELORE_PI_CONTEXT_FILE,
+ role: process.env.MAKELORE_PI_WORKER_ROLE,
+ };
+ Object.assign(process.env, registration.env);
+ try {
+ const module = await import(
+ /* @vite-ignore */ `${pathToFileURL(registration.extensionPath).href}?stream=${Date.now()}`
+ ) as {
+ default(factory: {
+ registerTool(tool: ExtensionTool): void;
+ on(event: string, handler: ExtensionHandler): void;
+ }): void | Promise;
+ };
+ const tools = new Map();
+ await module.default({
+ registerTool: (tool) => tools.set(tool.name, tool),
+ on: () => undefined,
+ });
+ const updates: unknown[] = [];
+ const result = await tools.get('game_resource_generate')?.execute?.(
+ 'stream-tool',
+ {},
+ new AbortController().signal,
+ (update: unknown) => updates.push(update),
+ );
+
+ expect(updates).toEqual([
+ { content: [], details: details('generating') },
+ { content: [], details: details('saving') },
+ ]);
+ expect(result).toEqual({ content: [], details: details('saved') });
+ expect(invoke).toHaveBeenCalledWith(expect.objectContaining({
+ toolName: 'game_resource_generate',
+ onUpdate: expect.any(Function),
+ }));
+ } finally {
+ for (const [key, value] of Object.entries(previous)) {
+ const environmentKey = key === 'bridge' ? 'MAKELORE_PI_BRIDGE_URL'
+ : key === 'token' ? 'MAKELORE_PI_WORKER_TOKEN'
+ : key === 'context' ? 'MAKELORE_PI_CONTEXT_FILE'
+ : 'MAKELORE_PI_WORKER_ROLE';
+ if (value === undefined) delete process.env[environmentKey];
+ else process.env[environmentKey] = value;
+ }
+ }
+ });
+
it('executes versioned product tools through the authenticated real bundle', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-product-bundle-'));
roots.push(root);
diff --git a/tests/unit/pi-managed-worker-opener.test.ts b/tests/unit/pi-managed-worker-opener.test.ts
index 1b3e6f8..faea2ee 100644
--- a/tests/unit/pi-managed-worker-opener.test.ts
+++ b/tests/unit/pi-managed-worker-opener.test.ts
@@ -289,7 +289,7 @@ describe('managed Pi worker opener', () => {
expect(argv).toContain('grilling');
expect(argv).toContain('--session-id');
expect(argv).toContain('--extension');
- expect(argv).toContain('makelore-runtime-v5.mjs');
+ expect(argv).toContain('makelore-runtime-v6.mjs');
expect(options.additionalArgs?.filter((argument) => argument === '--extension')).toHaveLength(2);
expect(options.additionalArgs).toEqual(expect.arrayContaining([
'--skill', deviceSkillPath, '--extension', deviceExtensionPath,
From 5c61110f465cc4172f712ad435c06041c0aed1ec Mon Sep 17 00:00:00 2001
From: brother7 <7brother7@gmail.com>
Date: Sun, 6 Sep 2026 09:56:47 +0800
Subject: [PATCH 06/12] docs: record automatic game resource delivery
---
.project-docs/00-brief/success-criteria.md | 3 +-
.../adr-008-interactive-ai-app-scaffold.md | 8 +-
.project-docs/10-decisions/decision-index.md | 2 +-
.project-docs/20-architecture/data-flow.md | 4 +-
.project-docs/20-architecture/module-map.md | 6 +-
.../20-architecture/system-overview.md | 4 +-
.project-docs/30-worklog/current-state.md | 20 +++-
...urce-auto-delivery-integration-8c4e1a72.md | 96 +++++++++++++++++++
.project-docs/40-domain/business-rules.md | 11 ++-
.project-docs/50-evidence/evidence-index.md | 1 +
10 files changed, 141 insertions(+), 14 deletions(-)
create mode 100644 .project-docs/30-worklog/tasks/20260906-game-resource-auto-delivery-integration-8c4e1a72.md
diff --git a/.project-docs/00-brief/success-criteria.md b/.project-docs/00-brief/success-criteria.md
index 70a75a3..2f6264f 100644
--- a/.project-docs/00-brief/success-criteria.md
+++ b/.project-docs/00-brief/success-criteria.md
@@ -9,6 +9,7 @@
- 可发布交互式 AI 应用的一键提交必须由 Electron Main 对安全源码快照执行固定 npm 11.6.2 的 `npm ci --ignore-scripts`,并调用项目 `package-lock.json` 锁定的 Vite 生成静态产物。
- 官方 bundled Project Scaffold Skill 可生成固定起步文件并只读说明发布要求、禁止项和证据缺口,但不得安装依赖、构建、上传、提审或把静态检查表述为平台批准;其 `.mjs` 只来自签名客户端固定资源,Marketplace 下载 artifact 仍必须拒绝脚本。
- 代码所有的官方项目插件 `makelore.data-service`、`makelore.game-resource` 与 `makelore.project-scaffold` 在满足各自既有账号获取或随应用提供条件并由当前项目启用后,必须自动进入每个 parent Agent,且客户端不得要求或展示伙伴分配;child Agent 仍为空,其他 Marketplace 和本机包的既有生命周期不变。
+- Game Resource 的一次显式确认必须对应一次 Main-owned Provider 提交;Main 内部轮询并把全部终态输出自动保存到调用时冻结项目的 `assets/generated/game-resource//`。响应丢失、保存失败或应用重启只能恢复本地下载/保存,不得再次生成或扣费;项目写租约只在终态落盘期间持有,Agent 不再拥有状态轮询、保存路径或第二次确认。
- Electron 双视口预检必须检查与最终 `built_archive` 相同的内存文件字节;预检失败不得上传,预检成功不得被表述为可信审核凭据。
- 上传协议必须同时携带源码归档、构建归档和严格版本化 artifact contract;服务端独立重算摘要、校验合同并固化不可变 Release。
- Renderer 不得获得发布凭据、归档、临时目录、构建 origin 或任意本地路径;旧客户端和旧 sandbox/browser 任务必须提示升级后重新构建提交。
@@ -39,4 +40,4 @@
## Last Reviewed
-2026-09-05
+2026-09-06
diff --git a/.project-docs/10-decisions/adr-008-interactive-ai-app-scaffold.md b/.project-docs/10-decisions/adr-008-interactive-ai-app-scaffold.md
index 63faffa..f1e3ab6 100644
--- a/.project-docs/10-decisions/adr-008-interactive-ai-app-scaffold.md
+++ b/.project-docs/10-decisions/adr-008-interactive-ai-app-scaffold.md
@@ -1,6 +1,6 @@
# ADR-008: 交互式 AI 应用 Scaffold 与官方项目插件采用项目级激活
-- Status: Accepted / implemented, amended 2026-09-05
+- Status: Accepted / implemented, amended 2026-09-06
- Date: 2026-09-04
- Applies to: Project creation, `ProjectType`, code-owned official project Plugins, scaffold Skill, release readiness
@@ -18,6 +18,7 @@
- `mini_game` 与 `mini_program` 只作为历史读取和脚手架兼容别名,在内存中归一为 `interactive_ai_app`。读取或运行脚手架不会改写原配置;后续普通配置变更可以保存规范值。缺少类型字段的旧项目仍归一为 `custom`。
- 项目创建只生成 `.makelore/project.json` 和 `knowledge/`,不生成业务源码、依赖、锁文件或发布模板。
- 代码所有的官方项目插件 `makelore.data-service`、`makelore.game-resource` 与 `makelore.project-scaffold` 采用统一的项目级激活规则。Data Service 随应用提供;Game Resource 与 Project Scaffold 保留 Account Library 获取;三者都保留项目启用语义,但不再要求或展示 Agent 分配。项目启用后,Main-owned effective resolver 自动把完整 Skill/tool 集合提供给该项目的每个父 Agent;child Agent 仍为空。需要伙伴分配的其他 Marketplace 插件继续使用原规则。
+- `makelore.game-resource` 的生成操作采用一次确认、一次提交、自动交付。Main 冻结调用时的项目身份与路径,内部轮询已接受的 Provider execution,把全部终态输出保存到该项目固定目录,并只在落盘期间获取共享项目写租约。持久交付 receipt 将 Provider/billing 与本地下载/保存分开;恢复本地交付不得重新提交或计费。Agent 不再暴露 status/save 工具、路径选择或第二次保存确认。
- `makelore.project-scaffold` 提供显式 Skill `makelore-project-scaffold`。固定版本资源随 MakeLore 客户端交付,不经过 Package Store 下载。其脚本生成六个交互式 AI 应用起步文件;写入前预检全部目标,不覆盖已有路径,受控失败时只回滚本次创建的文件和目录。
- Scaffold Skill 不安装依赖、不访问网络、不执行构建、不上传、不提交审核,也不提供 `--force`、类型覆盖或模板迁移状态。Main 继续独占固定 npm/Vite 构建、同字节预检、打包和上传;Works Square 继续独占服务端校验、不可变 Release 与运营审核。
- 官方 Plugin 的 `.mjs` 仅因它位于客户端固定、代码所有的 bundled resource root 中而可执行。应用通过不可覆盖的 `MAKELORE_NODE_EXECUTABLE` 向父 Pi worker/Agent Server 提供自身 Node;不得回退系统 Node。该例外不适用于 Marketplace 下载 artifact:P0 下载包仍只接受文本/图片 Skill 资源并拒绝 `.mjs`。第三方 Device Package 的可执行代码继续走自身的披露与确认边界。
@@ -29,6 +30,7 @@
- 旧项目无需批量迁移即可继续打开、生成脚手架和发布;新写入只使用规范类型。
- 发布规则在 Skill 中可被 Agent 解释和预检,但权威执行仍只有 Main 与 Works Square,避免形成第二套发布实现。
- 这三个代码所有官方身份的既有 Agent assignment 数据不再参与有效资源计算,可以原样保留;账号移除(适用时)或项目禁用仍会阻止新父 Agent 获得资源,运行中的 generation 仍按既有冻结边界切换。
+- Game Resource 的 Provider 作业可以继续由 Main 在后台收敛,而项目文件写入只占用短租约;项目切换不改变已冻结的交付目标,应用或响应中断也不把本地交付失败变成第二次付费生成。
- 对外分发仍需不可变 SemVer/Git 版本、服务端 bundled Release 元数据、已安装 Windows、签名 macOS 与 native Linux 运行证据;工作区测试不能替代这些发布门禁。
## Supersedes
@@ -51,4 +53,8 @@
- Official project-wide activation product commit: `e0de7aa28c1d6e97454f0e4073ae9153e746bb4b`
- Official project-wide activation source task: `20260905-official-plugin-project-scope-6e4a9c21`
- Official project-wide activation integration task: `20260905-official-plugin-project-scope-integration-8b3d6f42`
+- Automatic Game Resource delivery source commit: `6113a2453299141eab8420a56e93675712dd607b`
+- Automatic Game Resource delivery integration product commit: `4df4bc96245c01cd95e35ddf1b2b03d0e0d231c5`
+- Automatic Game Resource delivery source task: `20260906-game-resource-auto-delivery-7a4e2c91`
+- Automatic Game Resource delivery integration task: `20260906-game-resource-auto-delivery-integration-8c4e1a72`
- Proposal: `10-decisions/proposals/20260904-project-scaffold-implementation-7e4c2a91__interactive-ai-app-type.md`
diff --git a/.project-docs/10-decisions/decision-index.md b/.project-docs/10-decisions/decision-index.md
index d9dff27..6b107d7 100644
--- a/.project-docs/10-decisions/decision-index.md
+++ b/.project-docs/10-decisions/decision-index.md
@@ -4,7 +4,7 @@
| ID | Decision | Status | Date | Applies To | Detail |
|---|---|---|---|---|---|
-| ADR-008 | 交互式 AI 应用使用单一规范类型,项目创建、代码所有的官方项目插件激活与发布权威分离 | Accepted / implemented, amended 2026-09-05 | 2026-09-04 | Project creation、`ProjectType`、Official Plugins、Marketplace delivery、project-wide activation、release readiness | `adr-008-interactive-ai-app-scaffold.md` |
+| ADR-008 | 交互式 AI 应用使用单一规范类型,项目创建、代码所有的官方项目插件激活与发布权威分离 | Accepted / implemented, amended 2026-09-06 | 2026-09-04 | Project creation、`ProjectType`、Official Plugins、Marketplace delivery、project-wide activation、Game Resource auto-delivery、release readiness | `adr-008-interactive-ai-app-scaffold.md` |
| ADR-007 | AI Design 采用单一 Current Specification、Living Form 与不可变 Quote 的 V2 权威 | Accepted / implemented | 2026-08-30 | AI Design Renderer、Electron Main、Works Square V2 API | `adr-007-ai-design-living-form-v2.md` |
| ADR-002 | Robot V1 采用 Main 门控的引导式热点配网并衔接现有六位 Binding | Accepted / implemented, default on | 2026-08-16 | Robot Renderer、Host API、Electron Main、现有固件热点入口 | `adr-002-robot-guided-hotspot-binding-v1.md` |
| ADR-003 | Robot 配网页内扫描并连接 Windows/macOS 热点 | Accepted / implemented with physical release gates pending | 2026-08-16 | Robot Renderer、Host API、Electron Main、Windows WLAN、macOS CoreWLAN/CoreLocation | `adr-003-robot-in-app-hotspot-connection.md` |
diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md
index 0d3d734..a4c2065 100644
--- a/.project-docs/20-architecture/data-flow.md
+++ b/.project-docs/20-architecture/data-flow.md
@@ -10,7 +10,7 @@
| Selected-model Web Search | Parent Pi turn with an explicitly supported selected model | `makelore_web_search` core tool → frozen model/provider/credential request with provider-native forced search → ordinary model response/usage | No Marketplace Release, Account Library, Admission, Hosted Web Search client, Plugin Charge, or `agent_browser` fallback participates. Unsupported selected models expose no tool; child workers receive none. |
| Conversation-driven Device Package install | Agent tool inspects npm/Git/absolute local Plugin/loose Skill source | Main preview → distinct later user confirmation → immutable device-package generation → new/idle parent worker resources | Renderer has no install picker. Lifecycle scripts never run. Pi extensions and non-empty Skill `scripts/` are disclosed as desktop-user executable code before confirmation. Active workers retain their frozen generation until the turn settles; child workers remain empty. |
| Effective Plugin worker snapshot | Installed trusted package or code-owned official definition + project selection + applicable Agent assignments + current server policy | effective resolver → Registry/resource loader/Extension Host/tool catalog → parent Pi worker | One frozen snapshot supplies Skills, tools, package roots, and runtime authorization. Data Service, Game Resource, and Project Scaffold derive their applicable resource set directly from project enablement; assignments remain authoritative only for other Plugin identities that use that scope. Disable, account/project switch, logout, Renderer crash, Main shutdown, or worker generation change invalidates future actions without mutating persisted unknown assignments; child workers receive no Plugin projection. |
-| Hosted Game Resource operation | Eligible parent `makelore.game-resource` tool call plus explicit confirmation | frozen Plugin adapter → capability Registry → Main `GameResourceClient` → fixed Works Square game-resource route → provider-neutral receipt/result | Server policy owns pricing, payer, Admission and receipt state. Stable logical operation identity survives response loss/Main restart; `submission_unknown` is not replayed as a fresh request. Result saving uses a bounded project-relative path and the existing project write lease. |
+| Hosted Game Resource operation and delivery | Eligible parent `makelore.game-resource` generate call plus one explicit confirmation | frozen Plugin adapter → Main delivery coordinator → one `GameResourceClient` submission → internal status polling → all terminal downloads → `assets/generated/game-resource//` in the frozen original project | Server policy owns pricing, payer, Admission and Provider receipt state; Main owns the durable local delivery receipt and filesystem. `submission_unknown` is not replayed as a fresh request. Restart/retry resumes download/save only, the shared project write lease is held only during terminal materialization, and the Agent receives one progress/result card rather than status/save tools or a second confirmation. |
| 桌面认证生命周期 | Renderer 登录、刷新与注销请求 | Host API → Main Works Session → Works Square `/api/auth/{login,mobile-login,refresh,logout}` → one-feel auth | Main 加密持有并先持久化轮换 token;客户端不携带 OAuth client secret;连续 7 天未使用才清除会话,终止性 `400`/`401` fail closed |
| 用户模块入口策略 | 会话恢复 / 登录 / 刷新 | Electron Main → Works `/api/auth/me` → 四布尔安全投影 → Renderer auth store → 卡片/路由/provider gate | 缺失对象或字段默认 `true`;`design` 映射 `painting`;终止性 `401` 清理 Main/Renderer 会话;全局 `/settings` 不受 Code gate |
| 项目创建 | 新建项目对话框 | Host API → Main 项目初始化 | 固定 `interactive_ai_app` 或 `custom`,只生成 `.makelore/project.json` 与 `knowledge/`;历史双类型在读取边界归一,不因读取改写 |
@@ -81,4 +81,4 @@
## Last Updated
-2026-09-05
+2026-09-06
diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md
index 3715779..e4d0d7c 100644
--- a/.project-docs/20-architecture/module-map.md
+++ b/.project-docs/20-architecture/module-map.md
@@ -9,7 +9,7 @@
| `shared/coding-plugins.ts`, `electron/coding-plugins/effective-resolver.ts`, `registry.ts`, `project-service.ts`, and `electron/coding-runtime/pi/**` | Effective official Plugin projection, selected-model tools, Device Package resources, and frozen parent logical-thread runtime snapshot | The shared project-wide predicate covers `makelore.data-service`, `makelore.game-resource`, and `makelore.project-scaffold`: after their existing delivery/acquisition requirement and project enablement, all parent Agents receive their Skills/tools without assignment. Trusted Marketplace artifacts that require assignment and immutable local Device Package generations retain their distinct authorities. Child workers remain empty; active threads retain frozen resources until settlement/disposal. |
| `electron/coding-runtime/pi/model-tools/**` and `shared/model-tools.ts` | Closed selected-model tool registry and provider-specific Web Search request shaping | The frozen selected model capability controls whether `makelore_web_search` exists. The tool uses that model/provider/credential and normal model billing; no Hosted Plugin adapter, Admission, Plugin Charge, or browser fallback exists. |
| `electron/coding-packages/**`, `electron/api/routes/device-packages.ts`, `shared/device-packages.ts`, and `src/stores/device-packages.ts` | Main-owned conversation install preview/confirmation/commit, immutable local package generations, safe Renderer projection, and parent-worker refresh | Sources are npm, Git, absolute local Plugin directories, or loose `SKILL.md`. Lifecycle scripts are disabled; executable extensions and non-empty Skill `scripts/` run with desktop-user authority after disclosure and explicit confirmation. Every generation projects all explicitly installed and currently enabled Skills/extensions; Device Packages never join Account Library, Marketplace Package Store, Release, Channel, or Admission state. |
-| `electron/coding-plugins/adapters/game-resource.ts` and `electron/services/game-resource-client.ts` | Provider-neutral `makelore.game-resource` hosted tool adapter and Main-owned Works Square transport | Tools materialize only from an eligible frozen `platform_hosted` parent snapshot. Metered mutations require explicit confirmation and stable logical operation identity; Renderer/Pi never receive Provider URLs, credentials, balances, raw responses, or Provider job IDs. |
+| `electron/coding-plugins/adapters/game-resource.ts`, `electron/services/game-resource-client.ts`, and `electron/services/game-resource-delivery.ts` | Provider-neutral `makelore.game-resource` adapter, Main-owned Works Square transport, and durable local delivery coordinator | An eligible frozen parent submits one confirmed generation. Main polls internally, persists delivery state under userData, downloads every terminal output, and writes it to the frozen original project. A delivery retry resumes download/save only; Renderer/Pi never receive Provider URLs, credentials, balances, raw responses, Provider job IDs, or filesystem authority. |
| `electron/api/routes/plugin-marketplace.ts`, `src/stores/{plugin-marketplace,device-packages,coding-plugins}.ts`, and `src/pages/Plugins/` | Existing Main/store authorities plus the pure unified Renderer projection for official catalog, Account Library, official package-device state, local Device Packages, retained IDs, and current-project actions | `/plugins` is the sole canonical surface; legacy Plugin routes only replace-redirect into deterministic filters. `official:`, `local:`, and `retained:` identities stay separate, source failures are isolated, and no Account token, filesystem path, Admission, package bytes, signed URL, or visible install-source picker enters Renderer. |
| `src/components/works/ProjectPublishAction.tsx` | 可发布项目的一键提交、云构建轮询与用户可理解状态 | 只通过 Renderer API 提交非敏感元数据;绑定告警不终止轮询 |
| `shared/project-config.ts` and `electron/coding-projects/project-config.ts` | 规范 `ProjectType` 归一与最小项目创建 | 新写入只使用 `interactive_ai_app` / `custom`;历史 `mini_game` / `mini_program` 只读归一,项目创建只生成 metadata 与 `knowledge/` |
@@ -59,7 +59,7 @@
- Renderer UI → Renderer API contract → Main Host routes → Main services → Works Square;Renderer 不反向读取 Main 凭据、文件系统或归档。
- Plugin sidebar/legacy links → canonical `/plugins` projection → existing Renderer stores → bounded Main Marketplace and Device Package routes. Compatibility routes replace-redirect into deterministic filters and do not create a second lifecycle.
-- Hosted Plugin parent tool → frozen Registry adapter → Main `GameResourceClient` → fixed Works Square game-resource routes. Stable logical operation identity survives response loss and Main restart; ambiguous submission remains reviewable and is never converted into an automatic fresh mutation. Saving a result uses the existing bounded project path and project write lease.
+- Hosted Game Resource parent tool → frozen Registry adapter → Main delivery coordinator → `GameResourceClient` → fixed Works Square game-resource routes → durable local receipt → frozen project output directory. Stable logical operation identity survives response loss and Main restart; ambiguous submission remains reviewable and is never converted into an automatic fresh mutation. Main polls internally and acquires the shared project write lease only while materializing terminal outputs; resuming delivery never submits or charges again.
- AI 编程 Renderer product Snapshot/commands → typed `/api/coding/*` Host API → Main Coding composition → target `CodingConversationRuntime` → shared Agent Server 内的目标 Pi 逻辑线程;Pi Provider 请求再经 Main AI proxy 访问模型上游。Renderer 不持有 Pi wire、凭据或本地 runtime URL。
- Project configuration 只决定产品身份与分流;用户显式调用 Scaffold Skill 才生成固定起步文件;Main release builder 生成 source/built/contract,服务端独立重算和校验决定发布安全。本地 `ProjectType` 或 Skill 准备度结论都不是授权结论。
- Built artifact preflight 检查最终上传的同字节快照,但客户端可被绕过且不产生可信 receipt;服务端仍是合同、摘要和不可变 Release 安全权威。
@@ -93,4 +93,4 @@
## Last Updated
-2026-09-05
+2026-09-06
diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md
index ec4afb3..806b876 100644
--- a/.project-docs/20-architecture/system-overview.md
+++ b/.project-docs/20-architecture/system-overview.md
@@ -24,7 +24,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
| Pi Conversation Runtime | 一个长驻 Pi `0.84.2` Agent Server 承载每条 active/warm Conversation 的隔离逻辑 Runtime/Session/JSONL channel | 严格 LF JSONL RPC、generation recovery、Snapshot hydration;正式包从 staged `pi-runtime` manifest/root 定位并校验 Pi 包入口;top-level 逻辑 turn 并发 4、warm idle LRU 8;Server 退出统一使旧 channel 失效并按需单实例重启 |
| Pi Provider & Managed Resources | Provider catalog、thread-local secret projection、model/resource revision、Prompt/Skill/extension materialization、selected-model tools | 父凭据只进入选中逻辑线程的内存 credential store,child 凭据只进入该短命进程;Works `model_capabilities` 由 Main 严格归一化并作为安全 Provider metadata 持久化。Web Search 仅在精确 capability 存在时随冻结的 selected model/provider/credential 进入 parent tool catalog,并走普通模型计费;不回退 `agent_browser` 或独立 Hosted Provider。服务端 reasoning levels 优先于本地 profile,缺字段则清理 override 并回退;不扫描项目或用户的 `.pi/.agents/.codex`,不把 secret 或原始响应放进 argv、catalog 或 Renderer |
| Pi Extension, Subagents & Lifecycle | 必需的生成式 Makelore extension、Main 显式选定的已安装 extensions、UI interaction、ephemeral child、write lease 与 background run lease | Makelore bridge 固定为首个 extension,其余选定 extension 全部经 Pi 的 explicit additional paths 加载且 ambient discovery 关闭;child 并发 4、单次最多 8、禁止递归;active/uncertain run 不因页面隐藏或 confirmation timeout 被停止,replacement/stop 必须可解释并清理所有 ownership |
-| Code-owned Official Project Plugins | Existing Account acquisition or system-included delivery → project enablement → effective parent snapshot | Data Service、Game Resource 与 Project Scaffold 都不要求 Agent assignment,项目启用后自动进入每个父 Agent;child 不继承 Plugin。三者不经过设备下载、更新、Beta 或 artifact 签名;Game Resource 仍进入固定 Works Square hosted route,Project Scaffold 的 `.mjs` 仍只来自签名客户端固定资源。需要分配的 Marketplace 下载包保持原规则。 |
+| Code-owned Official Project Plugins | Existing Account acquisition or system-included delivery → project enablement → effective parent snapshot | Data Service、Game Resource 与 Project Scaffold 都不要求 Agent assignment,项目启用后自动进入每个父 Agent;child 不继承 Plugin。三者不经过设备下载、更新、Beta 或 artifact 签名;Game Resource 的一次确认由 Main 提交一次、内部轮询并把全部终态输出自动写入冻结的原项目,恢复本地交付不得重新生成或计费;Project Scaffold 的 `.mjs` 仍只来自签名客户端固定资源。需要分配的 Marketplace 下载包保持原规则。 |
| Device Packages | Conversation install tools → Main-owned inspect/preview/confirm/commit → immutable local generation → parent Skill/Pi-extension resources | 支持 npm、Git、绝对本地 Plugin 目录与 loose `SKILL.md`;没有可见安装入口、Account Library、Release、Admission 或 Marketplace Package Store。可执行 extension 与非空 Skill `scripts/` 拥有桌面用户权限,必须披露并独立确认;生命周期脚本禁用。每个 generation 包含所有显式安装且当前启用的 Skill/extension;新/idle parent 自动刷新,active parent 在 turn settled 后刷新,child 始终为空。 |
| AI Design Workspace & Living Form | 一个 Workspace 的当前 Direction、Current Specification、持久 Agent Session、conversation timeline、Tasks 与 Assets | 自然对话是主创作面;Living Form 仅以“AI 已理解”的紧凑辅助摘要与可选手动调整投影服务端 Current Specification,Renderer 只持有草稿和已接受投影 |
| AI Design Input & Reconciliation | Chat、字段/集合编辑、decision、proposal、lock、Asset binding 与 restore | 全部进入同一 `design.input.apply` reducer;稳定 command/operation ID 支持 unknown-result 重放,revision conflict 刷新权威状态;待提交 chat 从同一 pending operation 临时投影,assistant delta 只能在匹配该 operation 的一个未完成助手气泡中临时绘制且不生成独立整理进度栏 |
@@ -107,4 +107,4 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
## Last Updated
-2026-09-05
+2026-09-06
diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md
index 6e3c6de..14b9728 100644
--- a/.project-docs/30-worklog/current-state.md
+++ b/.project-docs/30-worklog/current-state.md
@@ -4,6 +4,24 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
## Integrated Through
+- Automatic Game Resource delivery source
+ `6113a2453299141eab8420a56e93675712dd607b` from task
+ `20260906-game-resource-auto-delivery-7a4e2c91` is integrated onto local `main`
+ as product commit `4df4bc96245c01cd95e35ddf1b2b03d0e0d231c5` by task
+ `20260906-game-resource-auto-delivery-integration-8c4e1a72`. One confirmed
+ `game_resource_generate` call is now a Main-owned submit-and-deliver operation:
+ Main submits once, polls the accepted execution internally, downloads every terminal
+ output, and saves the files under the frozen original project at
+ `assets/generated/game-resource//`. Provider execution, billing, and
+ local delivery remain separate states. A durable user-data receipt resumes only the
+ local download/save phase after an interruption, so it cannot create a second
+ Provider submission or Token Point charge. The shared project write lease is held
+ only while terminal outputs are materialized, and the Agent-visible status/save tools
+ and second save confirmation are removed. Source verification passed 99 focused
+ tests, the 1,901-test full unit suite with 2 skips plus pressure, typecheck, lint with
+ 0 errors/5 unchanged warnings, all Vite targets, 8 Electron tests, the unified Plugin
+ E2E journey, and Windows x64 runtime staging. No live paid Provider generation,
+ client installation, deployment, publication, or push is claimed.
- Official project-wide Plugin activation source
`718783f6837e29f56c9add633596249c03e5701f` from task
`20260905-official-plugin-project-scope-6e4a9c21` is integrated onto local `main`
@@ -677,7 +695,7 @@ Canvas 侧栏提供“获取灵感”进入 Prompt Museum。列表、筛选、
Makelore 在会话恢复、登录和刷新后由 Electron Main 请求 Works `/api/auth/me`,Renderer 只获得 Code、Canvas、Learning、Robot 四个布尔权限。缺失 `module_access` 或任一字段时默认开启;服务端 `design` 显式映射客户端 `painting`。被关闭的模块卡片置灰且不可点击,根路由、深层路由和别名路由均在 `MainLayout` 或模块初始化前阻断。Code provider 等待认证权限加载完成;权限查询返回终止性 `401` 时同时清理 Main 和 Renderer 会话。`/settings` 是全局设置,不受 Code 入口策略阻断。该机制只是客户端入口策略,不代替服务端 API 授权。
-插件在编程侧栏只有一个“插件”入口,`/plugins` 是唯一产品页面,并以统一列表投影 Marketplace、账号 Library、官方设备状态、本机 Device Packages、当前项目状态与 retained IDs;旧 `/plugin-marketplace`、`/my-plugins` 与 `/project-plugins` 路由只做确定性筛选重定向。获取、设备安装、项目启用、Agent Skill 分配、运行授权和计费仍是独立生命周期,不因界面统一而自动推进;原生 selected-model Web Search 不进入插件列表。
+插件在编程侧栏只有一个“插件”入口,`/plugins` 是唯一产品页面,并以统一列表投影 Marketplace、账号 Library、官方设备状态、本机 Device Packages、当前项目状态与 retained IDs;旧 `/plugin-marketplace`、`/my-plugins` 与 `/project-plugins` 路由只做确定性筛选重定向。获取、设备安装、项目启用、Agent Skill 分配、运行授权和计费仍是独立生命周期,不因界面统一而自动推进;原生 selected-model Web Search 不进入插件列表。Game Resource 在一次计费确认后由 Main 提交一次并内部轮询,终态全部输出自动写入调用时冻结的原项目;Agent 不再轮询状态、选择保存路径或进行第二次保存确认,交付恢复也不得重新生成或重复计费。
AI 学习现在是已启用的运营精选项目目录,并继续受登录和 `module_access.learning` 控制。Renderer 通过 Main-owned Host API 获取分页项目卡片和 README 详情;Markdown 支持 GFM、禁用原始 HTML。服务端发布时只校验图片 URL 为无凭据、默认端口、无 fragment 且当前 DNS 结果全部为公网地址的 HTTPS URL,保留地址而不下载、识别格式、转码或镜像;客户端仅为 README 图片节点启用直连,因此 SVG 和 Electron 支持的其他格式可直接显示,单图失败不阻断详情。封面和历史发布媒体继续走受控路径。详情页的下载按钮打开系统保存对话框;Main 将 ZIP 流式写入临时文件,只允许最多五跳同 Works origin 重定向,不校验 `Content-Length`、`archiveBytes`、实际流字节数或客户端大小上限,校验 SHA-256 和 ZIP 签名后原子保存,Renderer 只接收 `saved` 或 `cancelled`。课程生成、进度、本地课程库、OpenMAIC player、Agent、ASR、课堂 runtime、Learning IPC 和 player artifact 打包已删除且没有兼容读取路径;历史课程数据保留但不再读取。服务端和客户端源码契约已完成,不代表生产部署或真实账号安装包联调已经完成。
diff --git a/.project-docs/30-worklog/tasks/20260906-game-resource-auto-delivery-integration-8c4e1a72.md b/.project-docs/30-worklog/tasks/20260906-game-resource-auto-delivery-integration-8c4e1a72.md
new file mode 100644
index 0000000..5267e5b
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20260906-game-resource-auto-delivery-integration-8c4e1a72.md
@@ -0,0 +1,96 @@
+# Task: Integrate automatic Game Resource delivery
+
+## Identity
+
+- Task ID: 20260906-game-resource-auto-delivery-integration-8c4e1a72
+- Mode: Integration
+- Branch: codex/20260906-game-resource-auto-delivery-integration-8c4e1a72-game-resource-auto-delivery-integration
+- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260906-game-resource-auto-delivery-integration-8c4e1a72
+- Base commit: f721966f3c8db982279e34033b94b4347f0a8174
+- Owner: codex-root
+- Status: Ready for Integration
+
+## Scope
+
+- Integrate source commit `6113a2453299141eab8420a56e93675712dd607b`
+ from feature task `20260906-game-resource-auto-delivery-7a4e2c91` onto the
+ current local `main` frontier.
+- Preserve the source product and test patch unchanged, reconcile its promotion
+ candidates into canonical project memory, and verify the resulting integrated
+ tree at the affected Game Resource/Main/Pi/Plugin boundaries.
+- Advance only local `main`; do not push, publish, deploy, install a client, or
+ execute a paid Provider generation.
+
+## Intent And Constraints
+
+- The accepted product behavior is one confirmed Game Resource operation owned by
+ Electron Main: submit once, poll internally, save every output into the frozen
+ original project, and resume local delivery without a second Provider submission
+ or Token Point charge.
+- Provider execution and local delivery remain separate states. Electron Main keeps
+ filesystem, Works credentials, Provider, billing, retry, receipt, and write-lease
+ authority; Renderer, Pi, Skill, and model gain none of those authorities.
+- Preserve ADR-006's single Pi runtime, frozen parent worker, no-replay mutation,
+ background ownership, and shared project-write-lease rules, plus ADR-008's
+ project-wide Game Resource activation and child-empty behavior.
+- Apply the source with `git cherry-pick --no-commit`, register its task record as
+ an unchanged adopted foreign document before committing, and never edit the source
+ task record in this Integration task.
+- The user root worktree has three pre-existing untracked task records. They remain
+ unowned and untouched; all integration work stays in this isolated worktree.
+- Concurrent Task Gate: Passed. The exact integration task identity, branch,
+ worktree, base, and exclusive integration lock match task-context state.
+- Planning Gate: Passed after reading the required project memory, ADR-006, ADR-008,
+ the source task outcome and promotion candidate, the relevant reflection, and all
+ planning peer scopes. Historical placeholder peer scopes are unknown coordination
+ state but provide no evidenced semantic conflict with this exact source integration.
+
+## Outcome
+
+- Integrated source `6113a2453299141eab8420a56e93675712dd607b` as
+ product commit `4df4bc96245c01cd95e35ddf1b2b03d0e0d231c5`; both commits
+ resolve to tree `de2d63d6be23ca018742d3e7cbb2322a32014e3c`.
+- The accepted one-confirmation flow is present on the integrated tree: Electron Main
+ submits generation once, polls internally, downloads all terminal outputs, writes
+ deterministic create-only files beneath the frozen original project, and persists
+ delivery state outside the project so local recovery cannot submit or charge again.
+- One shared project write-lease coordinator is used by Pi writes and Game Resource
+ materialization; the delivery coordinator holds it only for the terminal write/change
+ tracking phase. The generated Pi bridge is version 6 and emits one progress/result
+ card. Agent-facing status and save tools plus the second save confirmation are absent.
+- Promoted the source task's durable decision into ADR-008, current state, system
+ overview, module map, data flow, business rules, success criteria, and evidence.
+ The adopted source task record remains byte-for-byte unchanged.
+- No server, Provider, Marketplace policy, price, Admission, or billing contract changed.
+ No live paid Provider request, build installation, deployment, publication, push, or
+ remote main mutation was performed.
+
+## Verification
+
+- Before the product commit, the adopted foreign task record matched the source worktree
+ byte-for-byte (`7,672` bytes), and `git diff --cached --check` passed.
+- Source/product tree equality passed with exact tree
+ `de2d63d6be23ca018742d3e7cbb2322a32014e3c`.
+- Integrated-tree focused regression: 14 files / 101 tests passed, covering the delivery
+ coordinator, client/adapter, Registry/manifest, Pi extension/release/product seams,
+ composition, artifact projection, and conversation timeline.
+- Integrated-tree `corepack pnpm run typecheck` passed.
+- Adopted source evidence remains valid for the identical product tree: 13 files / 99
+ focused tests; 227 files / 1,901 full unit tests / 2 skips plus pressure 1/1; lint with
+ 0 errors / 5 unchanged warnings; all Vite targets; Windows Electron 8/8; unified Plugin
+ E2E 3/3; and Windows x64 Pi runtime staging.
+- Dependency restoration used the committed lockfile in offline, frozen,
+ `--ignore-scripts` mode and changed no tracked product file.
+- Final project-document, drift, task-context, diff, clean-tree, exact-root-untracked-file,
+ local-main fast-forward, and task-retirement checks are completed at closeout.
+
+## Follow-ups
+
+- Rebuild and install MakeLore from the new local `main`, then run one explicitly
+ authorized live Game Resource generation to confirm the progress card and all output
+ files in a real project. The current integration intentionally does not incur a paid
+ Provider call or replace the installed client.
+
+## Promotion Candidates
+
+- Promoted in this Integration Gate; no unresolved candidate remains.
diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md
index 268d111..59ea49f 100644
--- a/.project-docs/40-domain/business-rules.md
+++ b/.project-docs/40-domain/business-rules.md
@@ -47,8 +47,13 @@
- Electron Main to fixed Works Square routes is the only hosted Plugin transport.
Packages, Renderer state, Pi arguments/results, logs, and saved project metadata must
not expose Provider credentials, URLs, credit balances, raw responses, or Provider job
- IDs. Saving a hosted result must use bounded project-relative paths and the existing
- project write lease.
+ IDs. A confirmed Game Resource generation is one Main-owned submit-and-deliver
+ operation: submit once, poll internally, download every terminal output, and save it
+ below `assets/generated/game-resource//` in the project frozen at call
+ time. Provider/billing state and local delivery state remain separate. A delivery
+ retry or application restart may resume only local download/save work and must never
+ submit or charge again. The shared project write lease is held only while materializing
+ terminal outputs; the Agent does not choose paths, poll status, or confirm saving again.
- Native Web Search is a selected-model capability, not a Marketplace Plugin. Only an
exact verified capability may place `makelore_web_search` in a frozen parent worker;
it uses that worker's current model/provider/credential and ordinary model billing.
@@ -164,4 +169,4 @@
## Last Reviewed
-2026-09-05
+2026-09-06
diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md
index 4d25d7d..d9b5983 100644
--- a/.project-docs/50-evidence/evidence-index.md
+++ b/.project-docs/50-evidence/evidence-index.md
@@ -4,6 +4,7 @@ Use this index for searchable, traceable evidence records.
| Date | Topic | Status | Source | Detail |
|---|---|---|---|---|
+| 2026-09-06 | Game Resource generation automatically delivers every output to the frozen project | Integrated on local `main`; live paid Provider and rebuilt installed-client smoke not run | Source `6113a2453299141eab8420a56e93675712dd607b`, product `4df4bc96245c01cd95e35ddf1b2b03d0e0d231c5`, source task `20260906-game-resource-auto-delivery-7a4e2c91`, integration task `20260906-game-resource-auto-delivery-integration-8c4e1a72`, ADR-008 | One confirmed generate call submits once; Main internally polls, downloads all terminal outputs, and writes them to the original frozen project. A durable receipt resumes local delivery without another Provider call or Token Point charge, and the shared write lease is held only during materialization. Agent-visible status/save tools and the second save confirmation are gone. Source evidence: 99 focused tests, 1,901 full unit tests/2 skips plus pressure, typecheck, lint, all Vite targets, 8 Electron tests, unified Plugins E2E 3/3, and Windows x64 runtime staging. No live paid generation, deploy, publication, push, installation, or packaged end-to-end smoke is claimed. |
| 2026-09-05 | Code-owned official project Plugins activate per project without partner assignment | Integrated on local `main`; rebuilt installed-client smoke pending | Initial Scaffold source/product `300ac89a81409440aac84ff45b1d9ca2fa186629` / `6710527e8f7150a6c4997d566a380454e33f455e`; broadened source/product `718783f6837e29f56c9add633596249c03e5701f` / `e0de7aa28c1d6e97454f0e4073ae9153e746bb4b`; tasks `20260905-official-plugin-project-scope-6e4a9c21` / `20260905-official-plugin-project-scope-integration-8b3d6f42`; ADR-008 | `makelore.data-service`, `makelore.game-resource`, and `makelore.project-scaffold` now enter every parent Agent after their existing delivery/acquisition and project-enable requirements, without partner assignment; child Agents remain empty and other package lifecycles are unchanged. TDD reproduced Main `skill_unassigned` and Renderer assignment-command failures. The broadened source passed 30 focused, 70 adjacent, and 37 resolver/composition tests, typecheck, lint, all Vite targets, and Electron E2E 1/1. The ordinary full unit run had one unrelated two-second real-process timing miss among 1,889 passes/2 skips; that file passed 6/6 alone. No rebuilt installed-client smoke is claimed. |
| 2026-09-03 | Device Package packaged prepare 与全部已启用资源加载 | Integrated on local `main`; rebuilt installed-client activation pending | Prepare source `5a2f0eb6785b59d8b455ed5cb1d9773351ff895a`, activation source `17664c5fffcfe695653b4146503e645f54767c4b`, tasks `20260902-local-skill-install-fix-6b3e91a4` / `20260903-load-installed-resources-8f3c1a72`, verified candidate `bd377c9`, main promotion task `20260903-promote-installed-resources-main-5c8e1a72` | Packaged package inspection now uses the distributed physical Pi runtime instead of importing an incomplete `app.asar` graph; closed Device Package failures remain closed across the bridge. The parent Agent Server keeps its generated Makelore extension first, passes every further Main-selected installed/enabled extension through Pi `0.84.2` `additionalExtensionPaths`, retains every selected Skill path, and keeps ambient discovery off. Packaged prepare without commit passed for loose `SKILL.md`, npm, and Git/Ponytail; a real Agent Server loaded two external extensions and exposed both commands. Across the two source tasks, focused tests, 222 files / 1,815 full unit tests / 2 conditional skips, pressure, typecheck, scoped lint, production build, Windows packaging, and artifact/Pi verification passed. The currently installed 1.2.6 client was not replaced, so no live installed-client success is claimed. |
| 2026-09-03 | Packaged Pi runtime-root resolver and local unsigned macOS arm64 artifact | Integrated locally; exact mounted-image bootstrap passed; signed release gate remains open | Source `5d7a235`, merge `4babd6d`, task `20260902-build-unsigned-mac-9d7e4a2c` | Node 24 did not honor the parent URL previously passed to `import.meta.resolve`, so an installed Agent Server searched beside the product resource script instead of staged `pi-runtime`. The replacement uses `findPackageJSON` from the explicit runtime manifest, selects the package import entry, rejects path escape, and imports the exact file URL. Focused real-process, full unit, typecheck, lint and Vite build passed at source. The corrected local-only unsigned DMG is 315,653,270 bytes with SHA-256 `6d0216da6c30f7fed537041b37c69811b8e025af3e9cccf85a64c690e29ecb7b`; direct initialize/shutdown passed both unpacked and from a read-only mounted image. It is not signed, notarized, published, or complete cross-platform evidence. |
From 2330401428e83bed55bb51dd4487fb65421b6e33 Mon Sep 17 00:00:00 2001
From: brother7 <7brother7@gmail.com>
Date: Sun, 6 Sep 2026 12:15:56 +0800
Subject: [PATCH 07/12] docs: record project setup UX review
---
.../20260906-project-setup-ux-90fe6cf2.md | 91 +++++++++++++++++++
1 file changed, 91 insertions(+)
create mode 100644 .project-docs/30-worklog/tasks/20260906-project-setup-ux-90fe6cf2.md
diff --git a/.project-docs/30-worklog/tasks/20260906-project-setup-ux-90fe6cf2.md b/.project-docs/30-worklog/tasks/20260906-project-setup-ux-90fe6cf2.md
new file mode 100644
index 0000000..5b9c79c
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20260906-project-setup-ux-90fe6cf2.md
@@ -0,0 +1,91 @@
+# Task: Review new project setup UX
+
+## Identity
+
+- Task ID: 20260906-project-setup-ux-90fe6cf2
+- Mode: Feature
+- Branch: codex/20260906-project-setup-ux-90fe6cf2-project-setup-ux
+- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260906-project-setup-ux-90fe6cf2
+- Base commit: 5c61110f465cc4172f712ad435c06041c0aed1ec
+- Owner: codex
+- Status: Ready for Integration
+
+## Scope
+
+- Review the new-project transition from Project Configuration to the first Coding
+ Conversation, with particular attention to the zero-Agent state and the discoverability
+ of the return/continue action.
+- Verify the persisted readiness rule, route behavior, empty states, model prerequisite,
+ and current product terminology before recommending an interaction change.
+- Produce an evidence-backed interaction recommendation only; do not change product code,
+ tests, routes, persistence, or canonical project memory.
+
+## Intent And Constraints
+
+- Preserve the explicit product decision that new projects have no default project Agent
+ and that the user chooses the Agent identity, model, responsibility, and Skills.
+- Keep project-owned parent Agents distinct from short-lived runtime child Agents; do not
+ describe the Project Configuration entity as a child Agent.
+- Preserve Pi `0.84.2`, `.makelore` ownership, the single light visual system, and the
+ current Main/Renderer boundary.
+- Prefer one explicit first-run completion path over a forced tutorial, compatibility
+ layer, new state machine, or automatically fabricated default Agent.
+
+## Outcome
+
+- Confirmed that the screenshot's “项目智能体” is a persisted project-owned parent Agent,
+ not a runtime child Agent. ADR-006 and ADR-008 deliberately keep child Agents ephemeral
+ and without inherited project Plugin resources.
+- Confirmed a state-invariant gap: Project Configuration validates only the Agents that
+ exist, so an empty Agent array passes; `submit()` then saves `initialized: true`.
+ `ProjectChatRoute` and `MainLayout` gate only on that boolean, despite the gate copy
+ claiming that at least one manually configured Agent is required.
+- Confirmed the resulting dead-end presentation: Coding chat has no selected Agent, clears
+ the Conversation selection, renders only a small “当前项目还没有可用智能体” sidebar
+ message, leaves the main canvas empty, and disables the Composer without a recovery CTA.
+- Confirmed the navigation gap: Project Configuration uses history-relative
+ `navigate(-1)`. The destination depends on how the page was entered and can even be a
+ visually identical Project Configuration history entry; saving does not navigate to
+ the Conversation page.
+- Confirmed an additional supported-path gap: first-run Setup does not configure a model.
+ With zero model options, the create-Agent icon shows a toast and opens a read-only model
+ drawer that contains no direct action to `/models`, so stronger Agent guidance alone
+ would still lead some users into a dead end.
+- Recommended the “required but escapable” flow: an incomplete project may exist and the
+ user may leave, but completion/Conversation readiness requires at least one enabled,
+ unarchived, fully configured project Agent. In the empty state, replace the icon-only
+ instruction with a prominent text CTA; after the first Agent is valid, make the primary
+ action “保存并进入对话” with deterministic `/chat` navigation. Keep a full chat recovery
+ state for legacy/archived-all data and route it directly back to Agent creation.
+- Recommended treating zero models as an explicit prerequisite state with a “先配置模型”
+ CTA and a return intent, and replacing history-relative back behavior with context-aware,
+ labelled destinations such as “返回对话” or “稍后设置”.
+- No product behavior or source code was changed.
+
+## Verification
+
+- Inspected the supplied 1493×891 Project Configuration screenshot at original detail.
+- Read the project memory startup set, relevant product/domain rules, ADR-006, ADR-008,
+ and related concurrent task scopes after passing the ownership and planning gates.
+- Inspected `src/pages/ProjectConfiguration/index.tsx`, `src/App.tsx`,
+ `src/components/layout/MainLayout.tsx`, `src/components/layout/Sidebar.tsx`,
+ `src/pages/Chat/CodingChatPanel.tsx`, `CodingConversationSidebar.tsx`,
+ `AgentCreationDialog.tsx`, the project configuration schema/service, Setup, README,
+ focused tests, and relevant Git blame/history.
+- Queried the UI/UX reference for onboarding, empty-state, primary-action, keyboard, and
+ predictable-back guidance. Its generated dark palette was rejected because Makelore's
+ repository contract requires the existing single light visual system.
+- No automated test was run because this task intentionally made no behavior change; a
+ future implementation requires focused unit coverage and the shared Electron E2E flow.
+
+## Follow-ups
+
+- If the recommended direction is accepted, implement one shared readiness predicate and
+ cover zero Agents, all Agents archived, no configured model, first Agent completion,
+ deterministic return/continue navigation, unsaved changes, and chat recovery.
+- Update README only with the accepted final interaction behavior, not this discussion or
+ a historical worklog.
+
+## Promotion Candidates
+
+- None recorded.
From 9fed0cc7c45df6b4ef01a52abc5045de478faa9c Mon Sep 17 00:00:00 2001
From: brother7 <7brother7@gmail.com>
Date: Sun, 6 Sep 2026 12:50:18 +0800
Subject: [PATCH 08/12] fix(agent-browser): restore bounded presentation
lifecycle
---
...20260906-agent-browser-failure-a7c91e4d.md | 230 ++++++++
README.md | 6 +-
electron/agent-browser/module.ts | 185 +++++-
electron/api/coding-composition.ts | 8 +
electron/api/routes/agent-browser.ts | 69 ++-
electron/coding-runtime/pi/extension-host.ts | 12 +
.../pi/extensions/agent-browser.ts | 73 ++-
electron/coding-runtime/pi/product-tools.ts | 21 +-
electron/main/index.ts | 12 +
src/lib/agent-browser.ts | 40 +-
src/pages/Chat/AgentBrowserPanel.tsx | 553 ++++++++++++++++++
src/pages/Chat/CodingChatPanel.tsx | 15 +
src/pages/Chat/CodingConversationHeader.tsx | 25 +
tests/e2e/pi-coding-first-chat.spec.ts | 70 +++
tests/unit/agent-browser-core.test.ts | 70 +++
tests/unit/agent-browser-panel.test.tsx | 167 ++++++
tests/unit/agent-browser-routes.test.ts | 45 ++
...oding-composition-background-sleep.test.ts | 30 +
tests/unit/pi-extension-host.test.ts | 38 ++
tests/unit/pi-product-tools.test.ts | 94 +++
20 files changed, 1714 insertions(+), 49 deletions(-)
create mode 100644 .project-docs/30-worklog/tasks/20260906-agent-browser-failure-a7c91e4d.md
create mode 100644 src/pages/Chat/AgentBrowserPanel.tsx
create mode 100644 tests/unit/agent-browser-panel.test.tsx
diff --git a/.project-docs/30-worklog/tasks/20260906-agent-browser-failure-a7c91e4d.md b/.project-docs/30-worklog/tasks/20260906-agent-browser-failure-a7c91e4d.md
new file mode 100644
index 0000000..6ad69b2
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20260906-agent-browser-failure-a7c91e4d.md
@@ -0,0 +1,230 @@
+# Task: Diagnose agent_browser call failures
+
+## Identity
+
+- Task ID: 20260906-agent-browser-failure-a7c91e4d
+- Mode: Feature
+- Branch: main
+- Worktree: D:\Datas\OthersProjects\makelore
+- Base commit: 5c61110f465cc4172f712ad435c06041c0aed1ec
+- Owner: developer
+- Status: Ready for Integration
+
+## Scope
+
+- Preserve the completed diagnosis of the parent Pi `agent_browser` failure and use
+ its deterministic reproduction as the implementation baseline.
+- Restore a lightweight Coding-owned shared browser surface and route Pi opens
+ through a bounded Main/Renderer presentation handshake before inspection actions
+ continue.
+- Preserve zero browser resources and zero browser polling while the surface is
+ closed; scope diagnostics to explicit Agent or user demand and keep event buffers
+ bounded.
+- Close the browser on panel dismissal, failed presentation, module/project exit,
+ window hide, or Renderer loss, and preserve structured `AgentBrowserFault` details
+ across the Pi bridge.
+- Add focused regression coverage plus typecheck, lint, unit, Electron UI, build,
+ and deterministic performance-boundary verification in proportion to the change.
+
+## Intent And Constraints
+
+- Preserve ADR-006: Pi `0.84.2` remains the sole Coding runtime; `agent_browser`
+ crosses the generated Makelore extension into the Main-owned browser service.
+- Keep `agent_browser`, selected-model `makelore_web_search`, and the unrelated
+ release-preflight `electron/agent-browser` module distinct.
+- Reproduce the exact failure before ranking or testing hypotheses. Redact credentials,
+ headers, prompts, tool arguments/results, account/session identities, and user paths
+ from reported evidence.
+- Do not restart, abort, recover, or replay a live Conversation, and do not invoke a
+ paid Provider while implementing or verifying the repair.
+- Establish a red-capable performance baseline before attributing cost. Prefer
+ profiler/timing/resource measurements over broad logging, and distinguish the
+ persistent interactive browser from the short-lived release-preflight browser.
+- Implement only the smallest replacement surface needed by the current Pi/Main
+ architecture; do not restore OpenCode state, speculative compatibility layers, or
+ the pre-optimization polling behavior.
+- The active legacy Web Search coordinator owns a separate old-base native-search
+ scope and does not semantically overlap this diagnosis.
+
+## Outcome
+
+- Restored a lightweight Coding right-side development-browser panel with an
+ explicit header toggle. An Agent `open` event expands the same panel
+ automatically; manual opening alone creates no `WebContentsView`.
+- Added an event-driven Main/Pi presentation handshake. Pi opens the browser
+ hidden, requests the Renderer surface, and waits at most five seconds for
+ visible bounds for the same browser generation. Timeout, close, crash,
+ debugger loss, or generation replacement rejects the wait; failed
+ presentation destroys the browser instead of retaining an unusable renderer.
+- Kept project paths Main-owned for the restored Renderer call sites. The panel
+ sends the active project id under the existing Renderer capability, and the
+ Host route resolves and revalidates the active real path before each operation.
+- Replaced the single diagnostics toggle with owner leases. Agent diagnostics
+ stay enabled only for the current run, Renderer diagnostics only while the
+ Console/Network drawer is expanded, and the domains plus buffers are released
+ after the last owner exits. Event rendering remains capped at 500 records and
+ reads use a five-second long poll with bounded drain backoff.
+- Closed the browser during idle `background_sleep` in addition to the existing
+ panel, project, window, Renderer, and application teardown paths. The panel
+ performs no interval polling and installs its document observer and resize
+ observer only while open; it does a single state refresh when the app regains
+ focus after a background close.
+- Preserved `AgentBrowserFault` code, retryability, generation, and outcome across
+ the Pi extension bridge, so callers receive `VIEWPORT_NOT_READY` and other
+ actionable faults instead of the generic `Bridge request failed` symptom.
+- Confirmed primary cause: the Pi product-tool path creates the Main-owned browser
+ without a Renderer presentation handshake. `PiAgentBrowserTool.open()` calls
+ `AgentBrowserModule.open()` directly with `visible: false` and no bounds. The
+ resulting live snapshot is therefore `state: attached`, `visible: false`, and
+ `bounds: null`.
+- `navigate`, `send_cdp`, `read_events`, and `read_payload` enter
+ `requirePresented()` (directly or through `requireAttached()`), which rejects that
+ state with `VIEWPORT_NOT_READY`. This is intentional browser-module behavior: the
+ shared development browser pauses agent debugging while its viewport is hidden.
+- The required presentation owner no longer exists in the current Coding Renderer.
+ Commit `5a275b9` removed `src/pages/Chat/AgentBrowserPanel.tsx` and its 619-line
+ unit test file as part of the legacy OpenCode removal. That panel was the only code
+ which measured the viewport and called `presentAgentBrowser()` with visible bounds.
+ Current source has no caller of `presentAgentBrowser()` and no consumer of
+ `agent-browser:show`; the installed Renderer bundle likewise contains that event
+ name only as the key/value pair in the generic event map.
+- The Pi path also bypasses the Host API route that emits `agent-browser:show` after
+ an open. Therefore merely retaining the route event is insufficient: the Pi
+ product-tool open requires the same presentation notification/coordinator, and the
+ current Coding Renderer requires a replacement shared-browser surface.
+- Confirmed secondary cause of the unhelpful symptom: the extension bridge receives
+ an HTTP 400 from `PiExtensionHost`, whose broad catch preserves only
+ `DevicePackageError`; it replaces `AgentBrowserFault` (including
+ `VIEWPORT_NOT_READY`) with the literal `Bridge request failed`. The generated Pi
+ extension correctly reports the text it receives, so the loss occurs in Main.
+- The direct Pi browser path was introduced by commit `13ab383` already using
+ `visible: false`. While the old panel still existed, a user-presented viewport
+ could satisfy the gate; after `5a275b9` removed that panel, current supported UI
+ has no way to do so, making the failure deterministic for inspection actions.
+- Historical performance concern is confirmed, but the deletion itself was not a
+ performance fix. Commit `927e133` first optimized the then-live browser surface:
+ it removed the 2.5-second state interval, replaced the 750 ms event interval with
+ a diagnostics-only 5-second long poll, limited the rendered event tail to 500,
+ activated the document-wide MutationObserver only while the panel was open,
+ disabled Runtime/Log/Network/Page CDP domains by default, cleared their buffers
+ when diagnostics closed, and destroyed the WebContentsView when the panel closed.
+ Five days later `5a275b9` deleted the already-optimized panel as part of the legacy
+ OpenCode hard cutover; its commit message and diff do not identify browser
+ performance as the deletion reason.
+- A deterministic historical work-budget comparison reproduced the old amplification:
+ a collapsed-but-active browser retained one Chromium renderer, dispatched 24
+ state requests per minute, kept the whole-document observer active, and captured
+ four CDP event domains continuously. An open panel dispatched another 80 event
+ reads per minute even when its diagnostics drawer was collapsed. The `927e133`
+ version passed the corresponding zero-background-work source assertions.
+- Current source retains the Main-side diagnostics gating and teardown, but has a
+ lifecycle regression around the Pi path. Pi `open` can create a hidden renderer
+ indefinitely; it emits no presentation event, and `codingProducts.sleep()` stops
+ Pi workers/the Agent Server without closing the browser. An executable red test
+ expected browser close during `background_sleep` and observed zero calls. Window
+ hide, renderer exit/navigation, and project deactivation do close it, but merely
+ leaving the Programming module does not.
+- No preserved profiler capture or hardware-normalized CPU/memory measurement was
+ found for the historical browser. Therefore the verified conclusions are about
+ retained resources and deterministic background work, not a fabricated MB, CPU,
+ startup-time, or frame-time improvement number.
+- Falsified alternatives:
+ - Tool registration/authentication is not the cause: live `open` and `status`
+ calls cross the extension/Main seam successfully.
+ - General request-schema drift is not the cause: a minimal `read_events` request
+ has the exact supported shape. One historical malformed `read_payload` call is
+ isolated and cannot explain the other failures.
+ - Installed/source component drift is not the cause of this symptom: the running
+ installed package contains the same hidden-open, viewport gate, and bridge mask.
+ - A GPU-process warning is present in the application log, but no evidence links
+ it to these failures; the browser reaches `attached` and the deterministic
+ viewport rejection occurs before actionable CDP work.
+- The three adopted pre-existing task records remain unchanged. Product changes
+ are limited to the current Agent Browser presentation, diagnostics, and
+ lifecycle path; the isolated release-preflight browsers remain unchanged.
+
+## Verification
+
+- Red baseline: the four focused implementation suites initially produced six
+ expected failures and 106 passes for the missing presentation wait, diagnostic
+ ownership, structured bridge fault, and background close behaviors.
+- Focused post-change verification passed across browser core/routes/panel, Pi
+ product tools/extension host, Coding background sleep, and Coding feature UI;
+ the final panel suite has 3 passing tests, including zero Host API calls while
+ closed and one-shot foreground resynchronization.
+- `pnpm run typecheck`: passed.
+- `pnpm run lint:check`: passed with zero errors and five pre-existing warnings in
+ `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`; no changed file
+ produced a warning.
+- `pnpm test`: 1,913 tests passed and 2 existing tests were skipped across the two
+ repository test phases.
+- `node ./node_modules/@playwright/test/cli.js test
+ tests/e2e/pi-coding-first-chat.spec.ts`: all 4 Electron E2E tests passed,
+ including the restored browser toggle and diagnostics-off initial state.
+- `pnpm run build:vite`: passed for Renderer, Electron Main, Preload, and utility
+ worker. Only existing Browserslist, mixed dynamic-import, and chunk-size
+ advisories were emitted.
+- `pnpm run perf:budget`: passed. Initial JS gzip was 205,846 / 358,400 bytes,
+ initial CSS gzip 24,377 / 30,720 bytes, fonts 1,729,900 / 2,097,152 bytes, and
+ module media 241,362 / 1,048,576 bytes.
+- `git diff --check`: passed.
+- Deterministic captured-trace assertion was run twice against the latest local Pi
+ session, without exposing prompts, arguments, results, identities, or paths. Both
+ runs reproduced the same red condition: 3 successful opens and 14 subsequent
+ `Bridge request failed` results.
+- Minimized captured sequence reproduced the failure as one successful `open`
+ followed by one failed `read_events`. Across the inspected session, 20
+ `agent_browser` results comprised 6 successes (`open`/`status`) and 14 failures
+ (`read_events`, `send_cdp`, `navigate`, and one malformed `read_payload`).
+- `pnpm exec vitest run tests/unit/pi-extension-bundle.test.ts
+ tests/unit/pi-product-tools.test.ts tests/unit/agent-browser-routes.test.ts
+ --maxWorkers=1` with the repository-pinned pnpm 10.33.4: 3 files, 35 tests passed.
+ These tests confirm registration/bridge schema/route forwarding but mock the
+ browser module and do not exercise the broken cross-layer sequence.
+- `pnpm exec vitest run tests/unit/agent-browser-core.test.ts -t "keeps a view
+ hidden until open/present receives current bounds" --maxWorkers=1`: 1 passed,
+ 77 skipped. It confirms that open without bounds yields `visible: false`,
+ `bounds: null`, and that `sendCdp`/`readEvents` then reject with
+ `VIEWPORT_NOT_READY` until a visible presentation with bounds occurs.
+- Read-only inspection of the running installed package confirmed all load-bearing
+ code literals and paths: Pi open is hidden, `requirePresented()` emits the
+ viewport fault, Main masks it, and the Renderer bundle has no show-event consumer.
+- Git history/blame confirmed the Pi path originated in `13ab383` and the presentation
+ surface plus its tests were removed in `5a275b9` without a Coding replacement.
+- Historical zero-idle budget check: pre-`927e133` intentionally failed with one
+ retained renderer, 24 collapsed-state requests/minute, 80 open-panel event
+ requests/minute, an always-on DOM observer, and always-on diagnostic CDP domains;
+ the `927e133` source passed all seven inverse assertions.
+- Current lifecycle red test (`debug-agent-browser-background-release.test.ts`,
+ removed immediately after diagnosis) failed as expected: `background_sleep`
+ invoked the browser `close` spy 0 times instead of 1.
+- Focused current-source verification passed: 2 files and 4 tests covering
+ diagnostics-off first-load events, hidden-until-present behavior, full close
+ teardown, and agent-only hidden open. The temporary diagnostic test was removed;
+ `git status` shows no product-source residue from the investigation.
+
+## Follow-ups
+
+- No required implementation follow-up remains.
+- If hardware-normalized CPU, memory, or frame-time numbers are needed, capture a
+ controlled before/after profile through the existing `app:performance` snapshot.
+ The current evidence deliberately claims deterministic resource/work bounds and
+ bundle-budget compliance, not an invented runtime percentage.
+
+## Promotion Candidates
+
+- Target: `.project-docs/30-worklog/current-state.md`
+ - Summary: Pi `agent_browser` now has a Coding-owned visible-surface handshake;
+ presentation failure tears down the browser and structured browser faults cross
+ the Pi bridge unchanged.
+ - Evidence: focused browser/Pi/bridge tests, Electron E2E, and production build.
+ - Future impact: keep the visibility gate and presentation coordinator together;
+ do not reintroduce hidden actionable CDP access.
+- Target: `.project-docs/30-worklog/current-state.md`
+ - Summary: the restored Agent Browser preserves the post-`927e133` zero-idle
+ lifecycle: no closed-state polling/observers/browser process, owner-scoped
+ diagnostics, bounded event retention, and idle background teardown.
+ - Evidence: panel/core/background regression tests, full unit suite, E2E, and the
+ passing bundle performance budget.
+ - Future impact: future browser UI changes must preserve these deterministic work
+ bounds and keep release-preflight browsers isolated from the interactive one.
diff --git a/README.md b/README.md
index 52b1050..2e441ef 100644
--- a/README.md
+++ b/README.md
@@ -106,9 +106,9 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
### 共享开发浏览器
-- Electron Main 持有 sandboxed `WebContentsView`、项目级持久浏览器配置和按需 CDP 连接;被调试页面不获得 Makelore Preload、Node.js 能力或 Host API 凭证。
-- 用户和 Agent 操作同一个页面。Renderer 只负责显示、收起和布局;Agent 通过 Main 代理的页面级 CDP 工具导航、读取 Console/Network 和执行调试命令。
-- 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。面板关闭时销毁 `WebContentsView`、detach debugger 并释放页面;诊断域只在用户打开诊断视图时连接,面板重新打开时按 URL 和轻量历史元数据恢复。该能力独立于发布和部署。
+- Electron Main 持有 sandboxed `WebContentsView`、项目级持久浏览器配置和按需 CDP 连接;被调试页面不获得 Makelore Preload、Node.js 能力或 Host API 凭证。Renderer 只提交当前项目 id 和可见区域,真实项目路径仍由 Main 解析和校验。
+- 用户和 Agent 操作同一个页面。Agent 发起 `open` 后,Main 通知 Coding 右侧面板展开,并最多等待 5 秒取得当前 generation 的可见 bounds;展示失败会销毁当前视图并返回原始 `AgentBrowserFault` 代码,不会把隐藏页面伪装成可调试状态。Agent 通过 Main 代理的页面级 CDP 工具导航、读取 Console/Network 和执行调试命令。
+- 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。关闭面板、离开模块或项目、隐藏窗口、Renderer 丢失及后台休眠都会销毁 `WebContentsView`、detach debugger 并释放页面;关闭态没有浏览器轮询或 DOM observer。诊断域按 owner 计数,只在 Agent 当前运行或用户展开 Console/Network 时启用,运行结算/抽屉收起后释放;事件读取使用 5 秒长轮询、最小 drain backoff 和 500 条界面上限。该能力独立于发布和部署。
- 一键提交时,Makelore 会从待上传构建归档的同一组 Main-owned 内存字节启动临时回环站点,并在两个独立的临时 Chromium profile 中检查桌面和移动视口的主页面加载、运行错误、失败资源与白屏。临时页面不挂载到界面,不读取或写入用户浏览器的 Cookie、历史和登录态;检查结束后始终销毁并清理,也不要求用户预先打开开发预览。
- 客户端复用 Electron 内置 Chromium,不安装 Playwright 或额外浏览器。预检只改善提交前反馈,可被非官方客户端绕过,也不会上传“已通过”凭据;平台仍把源码、构建归档和清单视为不可信输入,逐字节重算并在人工审核后发布。安装包携带固定 npm 运行时,项目依赖和 Vite 版本由 `package-lock.json` 锁定;依赖准备需要本地网络。
diff --git a/electron/agent-browser/module.ts b/electron/agent-browser/module.ts
index 6da3d03..bb7ced0 100644
--- a/electron/agent-browser/module.ts
+++ b/electron/agent-browser/module.ts
@@ -106,6 +106,7 @@ interface BrowserRecord {
title: string;
visible: boolean;
diagnosticsEnabled: boolean;
+ diagnosticOwners: Set;
bounds: AgentBrowserBounds | null;
error?: {
code: AgentBrowserErrorCode;
@@ -148,6 +149,7 @@ export interface AgentBrowserOpenInput {
bounds?: AgentBrowserBounds;
visible?: boolean;
injectProjectData?: boolean;
+ diagnosticsOwner?: string;
}
export interface AgentBrowserPresentInput {
@@ -156,6 +158,12 @@ export interface AgentBrowserPresentInput {
bounds?: AgentBrowserBounds;
}
+export interface AgentBrowserWaitForPresentationInput {
+ projectPath: string;
+ generation: number;
+ timeoutMs?: number;
+}
+
export interface AgentBrowserNavigateInput {
projectPath: string;
action: 'url' | 'back' | 'forward' | 'reload';
@@ -213,6 +221,7 @@ export class AgentBrowserModule {
private readonly payloadStore: AgentBrowserPayloadStore;
private readonly cdpGuard: AgentBrowserCdpGuard;
private readonly eventWaiters = new Set<() => void>();
+ private readonly presentationWaiters = new Set<() => void>();
private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>();
private readonly lifecycleListeners = new Set();
private record: BrowserRecord | null = null;
@@ -341,16 +350,32 @@ export class AgentBrowserModule {
}
if (this.record) {
const record = this.record;
- if (bounds) {
- this.applyPresentation(record, input.visible ?? true, bounds);
+ const diagnosticOwner = input.diagnosticsOwner?.trim();
+ const addedDiagnosticOwner = Boolean(
+ diagnosticOwner && !record.diagnosticOwners.has(diagnosticOwner),
+ );
+ try {
+ if (diagnosticOwner) {
+ await this.updateDiagnosticOwner(record, diagnosticOwner, true);
+ }
+ if (bounds) {
+ this.applyPresentation(record, input.visible ?? true, bounds);
+ }
+ if (record.url !== targetUrl || record.error) {
+ await this.navigateTo(record, targetUrl);
+ }
+ return this.snapshot(record);
+ } catch (error) {
+ if (addedDiagnosticOwner && diagnosticOwner) {
+ await this.updateDiagnosticOwner(record, diagnosticOwner, false).catch(() => undefined);
+ }
+ throw error;
}
- if (record.url !== targetUrl || record.error) {
- await this.navigateTo(record, targetUrl);
- }
- return this.snapshot(record);
}
const view = this.adapter.createView(agentBrowserPartition(projectPath));
+ const diagnosticOwners = new Set();
+ if (input.diagnosticsOwner?.trim()) diagnosticOwners.add(input.diagnosticsOwner.trim());
const record: BrowserRecord = {
browserId: randomUUID(),
projectId: input.projectId,
@@ -362,7 +387,8 @@ export class AgentBrowserModule {
url: targetUrl,
title: '',
visible: Boolean(bounds) && (input.visible ?? true),
- diagnosticsEnabled: false,
+ diagnosticsEnabled: diagnosticOwners.size > 0,
+ diagnosticOwners,
bounds,
eventBuffer: new AgentBrowserEventBuffer(),
childSessions: new Set(),
@@ -439,14 +465,73 @@ export class AgentBrowserModule {
}
const bounds = input.bounds ? normalizeBounds(input.bounds) : record.bounds;
this.applyPresentation(record, input.visible, bounds);
+ this.notifyPresentationWaiters();
return this.snapshot(record);
});
}
- setDiagnostics(input: { projectPath: string; enabled: boolean }): Promise {
+ waitForPresentation(
+ input: AgentBrowserWaitForPresentationInput,
+ ): Promise {
+ this.assertAvailable();
+ const timeoutMs = normalizeIntegerRange(
+ input.timeoutMs,
+ MAX_WAIT_MS,
+ 1,
+ MAX_CDP_TIMEOUT_MS,
+ 'timeoutMs',
+ );
+ const immediate = this.presentationSnapshot(input.projectPath, input.generation);
+ if (immediate) return Promise.resolve(immediate);
+
+ return new Promise((resolvePromise, rejectPromise) => {
+ let settled = false;
+ let timer: ReturnType | undefined;
+ const cleanup = () => {
+ this.presentationWaiters.delete(check);
+ if (timer) clearTimeout(timer);
+ };
+ const resolve = (snapshot: AgentBrowserSnapshot) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ resolvePromise(snapshot);
+ };
+ const reject = (error: unknown) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ rejectPromise(error);
+ };
+ const check = () => {
+ try {
+ const snapshot = this.presentationSnapshot(input.projectPath, input.generation);
+ if (snapshot) resolve(snapshot);
+ } catch (error) {
+ reject(error);
+ }
+ };
+ this.presentationWaiters.add(check);
+ timer = setTimeout(() => {
+ reject(new AgentBrowserFault(
+ 'VIEWPORT_NOT_READY',
+ '开发浏览器显示区域没有及时准备好。',
+ true,
+ input.generation,
+ ));
+ }, timeoutMs);
+ check();
+ });
+ }
+
+ setDiagnostics(input: {
+ projectPath: string;
+ enabled: boolean;
+ owner?: string;
+ }): Promise {
return this.serialize(async () => {
const record = this.requireRecord(input.projectPath);
- await this.configureDiagnostics(record, input.enabled);
+ await this.updateDiagnosticOwner(record, input.owner ?? 'renderer', input.enabled);
return this.snapshot(record);
});
}
@@ -766,6 +851,7 @@ export class AgentBrowserModule {
generation: record.generation,
url: record.url,
});
+ this.notifyPresentationWaiters();
}
record.state = 'attaching';
record.error = undefined;
@@ -1023,6 +1109,7 @@ export class AgentBrowserModule {
url: record.url,
});
this.notifyEventWaiters();
+ this.notifyPresentationWaiters();
};
this.addDebuggerListener(record, 'message', onDebuggerMessage);
this.addDebuggerListener(record, 'detach', onDebuggerDetach);
@@ -1088,6 +1175,7 @@ export class AgentBrowserModule {
code: 'DEVTOOLS_CONFLICT',
message: '原生 DevTools 已打开,智能体调试暂时暂停。',
};
+ this.notifyPresentationWaiters();
});
this.addWebContentsListener(record, 'devtools-closed', () => {
if (record !== this.record || record.state === 'closing' || record.state === 'crashed') {
@@ -1112,6 +1200,7 @@ export class AgentBrowserModule {
record.generation,
);
record.error = { code: fault.code, message: fault.message };
+ this.notifyPresentationWaiters();
}
}).catch(() => undefined);
});
@@ -1134,6 +1223,7 @@ export class AgentBrowserModule {
record.childSessions.clear();
record.ioHandles.clear();
this.notifyEventWaiters();
+ this.notifyPresentationWaiters();
});
this.addWebContentsListener(record, 'destroyed', () => {
if (record !== this.record || record.state === 'closing') return;
@@ -1152,9 +1242,29 @@ export class AgentBrowserModule {
});
record.eventBuffer.markGap('view-recreated');
this.notifyEventWaiters();
+ this.notifyPresentationWaiters();
});
}
+ private async updateDiagnosticOwner(
+ record: BrowserRecord,
+ ownerValue: string,
+ enabled: boolean,
+ ): Promise {
+ const owner = ownerValue.trim() || 'renderer';
+ const hadOwner = record.diagnosticOwners.has(owner);
+ if (hadOwner === enabled) return;
+ if (enabled) record.diagnosticOwners.add(owner);
+ else record.diagnosticOwners.delete(owner);
+ try {
+ await this.configureDiagnostics(record, record.diagnosticOwners.size > 0);
+ } catch (error) {
+ if (hadOwner) record.diagnosticOwners.add(owner);
+ else record.diagnosticOwners.delete(owner);
+ throw error;
+ }
+ }
+
private async configureDiagnostics(record: BrowserRecord, enabled: boolean): Promise {
if (record !== this.record || record.view.webContents.isDestroyed()) {
throw new AgentBrowserFault(
@@ -1520,6 +1630,56 @@ export class AgentBrowserModule {
return record;
}
+ private presentationSnapshot(
+ projectPath: string,
+ generation: number,
+ ): AgentBrowserSnapshot | null {
+ const record = this.requireRecord(projectPath);
+ if (record.generation !== generation) {
+ throw new AgentBrowserFault(
+ 'CLOSED',
+ '开发浏览器已切换到新的页面实例。',
+ true,
+ generation,
+ );
+ }
+ if (record.state === 'crashed') {
+ throw new AgentBrowserFault(
+ 'RENDERER_CRASHED',
+ '开发浏览器页面进程已退出。',
+ true,
+ generation,
+ );
+ }
+ if (record.state === 'suspended_devtools') {
+ throw new AgentBrowserFault(
+ 'DEVTOOLS_CONFLICT',
+ '请先关闭当前页面的原生 DevTools。',
+ true,
+ generation,
+ );
+ }
+ if (record.state === 'detached_fault') {
+ throw new AgentBrowserFault(
+ 'DEBUGGER_BUSY',
+ '开发浏览器调试器尚未就绪。',
+ true,
+ generation,
+ );
+ }
+ if (record.state === 'closing') {
+ throw new AgentBrowserFault(
+ 'CLOSED',
+ '开发浏览器正在关闭。',
+ true,
+ generation,
+ );
+ }
+ return record.state === 'attached' && record.visible && record.bounds
+ ? this.snapshot(record)
+ : null;
+ }
+
private assertProject(record: BrowserRecord, projectPath: string): void {
if (!samePath(record.projectPath, normalizeRequiredPath(projectPath))) {
throw new AgentBrowserFault(
@@ -1541,6 +1701,7 @@ export class AgentBrowserModule {
const record = expected ?? this.record;
if (!record) {
this.notifyEventWaiters();
+ this.notifyPresentationWaiters();
return;
}
if (expected && this.record !== expected) return;
@@ -1555,6 +1716,7 @@ export class AgentBrowserModule {
});
this.removeListeners(record);
this.record = null;
+ this.notifyPresentationWaiters();
const interrupted = new AgentBrowserFault(
'CLOSED',
'开发浏览器已关闭。',
@@ -1580,6 +1742,7 @@ export class AgentBrowserModule {
this.adapter.destroy(record.view);
record.childSessions.clear();
record.ioHandles.clear();
+ record.diagnosticOwners.clear();
record.eventBuffer.clear();
this.payloadStore.clear();
this.notifyEventWaiters();
@@ -1852,6 +2015,10 @@ export class AgentBrowserModule {
for (const wake of waiters) wake();
}
+ private notifyPresentationWaiters(): void {
+ for (const wake of [...this.presentationWaiters]) wake();
+ }
+
private notifyLifecycle(event: AgentBrowserLifecycleEvent): void {
for (const listener of this.lifecycleListeners) {
try {
diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts
index aa8ac13..7885b60 100644
--- a/electron/api/coding-composition.ts
+++ b/electron/api/coding-composition.ts
@@ -1,6 +1,7 @@
import { accessSync, constants } from 'node:fs';
import path from 'node:path';
import type { AgentBrowserModule } from '../agent-browser';
+import type { AgentBrowserSnapshot } from '../../shared/agent-browser';
import { CodingAttachmentStore } from '../coding-projects/attachment-store';
import { createCodingConversationStore } from '../coding-projects/conversation-store';
import { CodingProjectService } from '../coding-projects/project-service';
@@ -102,6 +103,8 @@ export interface CreateCodingCompositionOptions {
accountCache?: AccountPluginCache;
clientVersion?: string;
policyClient?: PluginPolicyClient;
+ requestAgentBrowserPresentation?(snapshot: AgentBrowserSnapshot): void;
+ publishAgentBrowserState?(snapshot: AgentBrowserSnapshot): void;
}
type PiWorkerExecutableProbe = (candidate: string) => boolean;
@@ -241,6 +244,8 @@ export function createCodingComposition(
bundledSkillsDir: options.paths.bundledSkillsDir,
modelToolRegistry,
devicePackageTools,
+ requestAgentBrowserPresentation: options.requestAgentBrowserPresentation,
+ publishAgentBrowserState: options.publishAgentBrowserState,
pluginSkillSources,
getPluginSkillSources: async () => effectiveResolver
? (await effectiveResolver.getSkillSources()).map((source) => ({
@@ -543,6 +548,9 @@ export function createCodingComposition(
runtime.dispose(conversationId, reason)
)));
if (reason === 'background_sleep' && runtime.hasActiveWork()) return;
+ if (reason === 'background_sleep') {
+ await options.browser.close().catch(() => undefined);
+ }
await agentServer.stop();
},
async shutdown() {
diff --git a/electron/api/routes/agent-browser.ts b/electron/api/routes/agent-browser.ts
index 2091af9..8422b70 100644
--- a/electron/api/routes/agent-browser.ts
+++ b/electron/api/routes/agent-browser.ts
@@ -7,6 +7,7 @@ import { hasRendererCapability } from '../renderer-capability';
import { parseJsonBody, sendJson } from '../route-utils';
type AgentBrowserBody = {
+ project_id?: unknown;
project_path?: unknown;
url?: unknown;
action?: unknown;
@@ -71,7 +72,12 @@ function parseStringArray(value: unknown): string[] | undefined {
return value.map((item) => item.trim()).filter(Boolean);
}
-async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown) {
+async function resolveActiveProject(
+ ctx: HostApiContext,
+ requestedPath?: unknown,
+ requestedId?: unknown,
+ rendererPresentation = false,
+) {
const activeProject = await ctx.codingProjectStore.getActiveProject();
if (!activeProject) {
throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '请先打开一个项目。', 409);
@@ -84,6 +90,24 @@ async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown
throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '当前项目目录不可用。', 409);
}
+ const projectId = nonEmptyString(requestedId);
+ if (projectId) {
+ if (!rendererPresentation) {
+ throw new AgentBrowserRouteError(
+ 'TARGET_DENIED',
+ '项目 ID 只能由 Makelore 界面用于浏览器操作。',
+ 403,
+ );
+ }
+ if (projectId !== activeProject.id) {
+ throw new AgentBrowserRouteError('PROJECT_MISMATCH', '智能体只能调试当前项目。', 403);
+ }
+ return {
+ ...activeProject,
+ path: activeRealPath,
+ };
+ }
+
const requested = nonEmptyString(requestedPath);
if (!requested) {
throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少当前项目路径。');
@@ -188,6 +212,19 @@ async function readBody(req: IncomingMessage): Promise {
return await parseJsonBody(req);
}
+function resolveRequestProject(
+ req: IncomingMessage,
+ ctx: HostApiContext,
+ body: AgentBrowserBody,
+) {
+ return resolveActiveProject(
+ ctx,
+ body.project_path,
+ body.project_id,
+ hasRendererCapability(req),
+ );
+}
+
export async function handleAgentBrowserRoutes(
req: IncomingMessage,
res: ServerResponse,
@@ -200,7 +237,12 @@ export async function handleAgentBrowserRoutes(
const service = requireService(ctx);
if (url.pathname === '/api/agent-browser/state' && req.method === 'GET') {
- const project = await resolveActiveProject(ctx, url.searchParams.get('project_path'));
+ const project = await resolveActiveProject(
+ ctx,
+ url.searchParams.get('project_path'),
+ url.searchParams.get('project_id'),
+ hasRendererCapability(req),
+ );
const browser = await service.getSnapshot(project.path);
await ensureProjectStillActive(ctx, project);
sendJson(res, 200, { success: true, browser });
@@ -209,8 +251,13 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/open' && req.method === 'POST') {
const body = await readBody(req);
- const project = await resolveActiveProject(ctx, body.project_path);
const rendererPresentation = hasRendererCapability(req);
+ const project = await resolveActiveProject(
+ ctx,
+ body.project_path,
+ body.project_id,
+ rendererPresentation,
+ );
if (body.bounds !== undefined && !rendererPresentation) {
requireRendererPresentation(req);
}
@@ -233,7 +280,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/present' && req.method === 'POST') {
requireRendererPresentation(req);
const body = await readBody(req);
- const project = await resolveActiveProject(ctx, body.project_path);
+ const project = await resolveActiveProject(ctx, body.project_path, body.project_id, true);
const browser = await service.present({
projectPath: project.path,
visible: body.visible === true,
@@ -248,7 +295,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/diagnostics' && req.method === 'POST') {
requireRendererPresentation(req);
const body = await readBody(req);
- const project = await resolveActiveProject(ctx, body.project_path);
+ const project = await resolveActiveProject(ctx, body.project_path, body.project_id, true);
const browser = await service.setDiagnostics({
projectPath: project.path,
enabled: body.enabled === true,
@@ -260,7 +307,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/navigate' && req.method === 'POST') {
const body = await readBody(req);
- const project = await resolveActiveProject(ctx, body.project_path);
+ const project = await resolveRequestProject(req, ctx, body);
const action = nonEmptyString(body.action);
if (action !== 'url' && action !== 'back' && action !== 'forward' && action !== 'reload') {
throw new AgentBrowserRouteError('INVALID_REQUEST', '浏览器导航动作无效。');
@@ -278,7 +325,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/cdp/send' && req.method === 'POST') {
const body = await readBody(req);
- const project = await resolveActiveProject(ctx, body.project_path);
+ const project = await resolveRequestProject(req, ctx, body);
const method = nonEmptyString(body.method);
if (!method) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 CDP method。');
const params = body.params === undefined
@@ -302,7 +349,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/cdp/events' && req.method === 'POST') {
const body = await readBody(req);
- const project = await resolveActiveProject(ctx, body.project_path);
+ const project = await resolveRequestProject(req, ctx, body);
const page = await service.readEvents({
projectPath: project.path,
after: finiteInteger(body.after),
@@ -317,7 +364,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/payload/read' && req.method === 'POST') {
const body = await readBody(req);
- const project = await resolveActiveProject(ctx, body.project_path);
+ const project = await resolveRequestProject(req, ctx, body);
const handle = nonEmptyString(body.handle);
if (!handle) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 payload handle。');
const chunk = await service.readPayload({
@@ -333,7 +380,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/close' && req.method === 'POST') {
const body = await readBody(req);
- const project = await resolveActiveProject(ctx, body.project_path);
+ const project = await resolveRequestProject(req, ctx, body);
const browser = await service.close(project.path);
await ensureProjectStillActive(ctx, project);
emitState(ctx, 'agent-browser:state', {
@@ -347,7 +394,7 @@ export async function handleAgentBrowserRoutes(
if (url.pathname === '/api/agent-browser/reset-profile' && req.method === 'POST') {
const body = await readBody(req);
- const project = await resolveActiveProject(ctx, body.project_path);
+ const project = await resolveRequestProject(req, ctx, body);
const browser = await service.resetProfile(project.path);
await ensureProjectStillActive(ctx, project);
emitState(ctx, 'agent-browser:state', {
diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts
index 22059ae..9072a07 100644
--- a/electron/coding-runtime/pi/extension-host.ts
+++ b/electron/coding-runtime/pi/extension-host.ts
@@ -18,6 +18,7 @@ import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins'
import type { PiSkillEntry } from './resource-loader';
import type { EffectivePluginSnapshot } from '../../coding-plugins/effective-resolver';
import { DevicePackageError } from '../../coding-packages/device-package-manager';
+import { AgentBrowserFault } from '../../agent-browser/fault';
const MAX_REQUEST_BYTES = 64 * 1024;
const PRODUCT_TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/u;
@@ -516,6 +517,17 @@ export class PiManagedExtensionHost {
record.waiters.delete(value.resourceId);
}
} catch (error) {
+ if (!response.writableEnded && error instanceof AgentBrowserFault) {
+ const message = error.message.slice(0, 512);
+ this.respond(response, 400, {
+ code: error.code,
+ error: `${error.code}: ${message}`,
+ retryable: error.retryable,
+ ...(error.generation === undefined ? {} : { generation: error.generation }),
+ ...(error.outcome === undefined ? {} : { outcome: error.outcome }),
+ });
+ return;
+ }
if (!response.writableEnded && error instanceof DevicePackageError) {
this.respond(response, 400, {
code: error.code,
diff --git a/electron/coding-runtime/pi/extensions/agent-browser.ts b/electron/coding-runtime/pi/extensions/agent-browser.ts
index 2eadc71..d4f85b6 100644
--- a/electron/coding-runtime/pi/extensions/agent-browser.ts
+++ b/electron/coding-runtime/pi/extensions/agent-browser.ts
@@ -4,10 +4,17 @@ import type { CodingAttachmentStore } from '../../../coding-projects/attachment-
import type { AgentBrowserDetailsV1 } from '../../contracts';
export interface AgentBrowserToolContext {
+ conversationId: string;
+ runId: string;
projectId: string;
projectPath: string;
}
+export type AgentBrowserPresentationRequester = (snapshot: AgentBrowserSnapshot) => void;
+export type AgentBrowserStatePublisher = (snapshot: AgentBrowserSnapshot) => void;
+
+const PRESENTATION_TIMEOUT_MS = 5_000;
+
export interface AgentBrowserToolResult {
content: Array<{ type: 'text'; text: string }>;
details: AgentBrowserDetailsV1;
@@ -70,11 +77,27 @@ async function readPayloadJson(
}
export class PiAgentBrowserTool {
+ private readonly diagnosticRuns = new Map();
+
constructor(
private readonly browser: AgentBrowserModule,
private readonly attachments: CodingAttachmentStore,
+ private readonly requestPresentation?: AgentBrowserPresentationRequester,
+ private readonly publishState?: AgentBrowserStatePublisher,
) {}
+ async releaseRun(conversationId: string, runId: string): Promise {
+ const key = `${conversationId}:${runId}`;
+ const diagnostic = this.diagnosticRuns.get(key);
+ if (!diagnostic) return;
+ this.diagnosticRuns.delete(key);
+ await this.browser.setDiagnostics({
+ projectPath: diagnostic.projectPath,
+ enabled: false,
+ owner: diagnostic.owner,
+ }).catch(() => undefined);
+ }
+
async execute(
context: AgentBrowserToolContext,
input: unknown,
@@ -85,20 +108,63 @@ export class PiAgentBrowserTool {
return result(action, publicSnapshot(await this.browser.getSnapshot(context.projectPath)));
}
if (action === 'open') {
- const snapshot = await this.browser.open({
+ const owner = `agent:${context.conversationId}:${context.runId}`;
+ let snapshot = await this.browser.open({
projectId: context.projectId,
projectPath: context.projectPath,
url: string(body.url, true) as string,
visible: false,
+ diagnosticsOwner: owner,
...(body.injectProjectData === true ? { injectProjectData: true } : {}),
});
+ const runKey = `${context.conversationId}:${context.runId}`;
+ this.diagnosticRuns.set(runKey, { projectPath: context.projectPath, owner });
+ if (this.requestPresentation) {
+ try {
+ this.requestPresentation(snapshot);
+ snapshot = await this.browser.waitForPresentation({
+ projectPath: context.projectPath,
+ generation: snapshot.generation,
+ timeoutMs: PRESENTATION_TIMEOUT_MS,
+ });
+ } catch (error) {
+ this.diagnosticRuns.delete(runKey);
+ const closed = await this.browser.close(context.projectPath).catch(() => null);
+ if (closed) {
+ this.publishState?.({
+ ...closed,
+ projectId: context.projectId,
+ projectPath: context.projectPath,
+ });
+ }
+ throw error;
+ }
+ }
return result(action, publicSnapshot(snapshot));
}
if (action === 'close') {
- return result(action, publicSnapshot(await this.browser.close(context.projectPath)));
+ for (const [key, diagnostic] of this.diagnosticRuns) {
+ if (diagnostic.projectPath === context.projectPath) this.diagnosticRuns.delete(key);
+ }
+ const snapshot = await this.browser.close(context.projectPath);
+ this.publishState?.({
+ ...snapshot,
+ projectId: context.projectId,
+ projectPath: context.projectPath,
+ });
+ return result(action, publicSnapshot(snapshot));
}
if (action === 'reset_profile') {
- return result(action, publicSnapshot(await this.browser.resetProfile(context.projectPath)));
+ for (const [key, diagnostic] of this.diagnosticRuns) {
+ if (diagnostic.projectPath === context.projectPath) this.diagnosticRuns.delete(key);
+ }
+ const snapshot = await this.browser.resetProfile(context.projectPath);
+ this.publishState?.({
+ ...snapshot,
+ projectId: context.projectId,
+ projectPath: context.projectPath,
+ });
+ return result(action, publicSnapshot(snapshot));
}
if (action === 'navigate') {
const navigation = string(body.navigation, true);
@@ -110,6 +176,7 @@ export class PiAgentBrowserTool {
action: navigation as 'url' | 'back' | 'forward' | 'reload',
...(body.url === undefined ? {} : { url: string(body.url) }),
});
+ this.publishState?.(snapshot);
return result(action, publicSnapshot(snapshot));
}
if (action === 'read_events') {
diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts
index 37997cf..08cba64 100644
--- a/electron/coding-runtime/pi/product-tools.ts
+++ b/electron/coding-runtime/pi/product-tools.ts
@@ -23,6 +23,10 @@ import type { EffectivePluginSnapshot } from '../../coding-plugins/effective-res
import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts';
import { BUNDLED_CODING_SKILL_IDS } from '../../../shared/coding-skills';
import { PiAgentBrowserTool } from './extensions/agent-browser';
+import type {
+ AgentBrowserPresentationRequester,
+ AgentBrowserStatePublisher,
+} from './extensions/agent-browser';
import { reportChangedFiles } from './extensions/changed-file';
import { projectTaskState } from './extensions/task-state';
import type { ModelToolRegistryPort } from './model-tools/model-tool-registry';
@@ -73,6 +77,8 @@ export interface PiProductToolsOptions {
capabilityRegistry?: CodingCapabilityRegistry;
modelToolRegistry?: ModelToolRegistryPort;
devicePackageTools?: DevicePackageTools;
+ requestAgentBrowserPresentation?: AgentBrowserPresentationRequester;
+ publishAgentBrowserState?: AgentBrowserStatePublisher;
}
export class PiProductTools {
@@ -82,7 +88,12 @@ export class PiProductTools {
constructor(private readonly options: PiProductToolsOptions) {
this.changeTracker = options.changeTracker ?? new ConversationChangeTracker();
- this.browser = new PiAgentBrowserTool(options.browser, options.attachments);
+ this.browser = new PiAgentBrowserTool(
+ options.browser,
+ options.attachments,
+ options.requestAgentBrowserPresentation,
+ options.publishAgentBrowserState,
+ );
this.capabilityRegistry = options.capabilityRegistry;
}
@@ -94,8 +105,12 @@ export class PiProductTools {
return this.changeTracker.beginRun(input);
}
- settleRun(conversationId: string, runId: string) {
- return this.changeTracker.settleRun(conversationId, runId);
+ async settleRun(conversationId: string, runId: string) {
+ try {
+ return await this.changeTracker.settleRun(conversationId, runId);
+ } finally {
+ await this.browser.releaseRun(conversationId, runId);
+ }
}
getChanges(conversationId: string): ConversationChangesSnapshot | null {
diff --git a/electron/main/index.ts b/electron/main/index.ts
index 2100f5c..ea23a8c 100644
--- a/electron/main/index.ts
+++ b/electron/main/index.ts
@@ -546,6 +546,18 @@ async function initialize(): Promise {
storage: codingProjectStorage,
projectStore: codingProjectStore,
browser: agentBrowser,
+ requestAgentBrowserPresentation: (snapshot) => {
+ hostEventBus.emit('agent-browser:show', snapshot);
+ if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
+ window.webContents.send('agent-browser:show', snapshot);
+ }
+ },
+ publishAgentBrowserState: (snapshot) => {
+ hostEventBus.emit('agent-browser:state', snapshot);
+ if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
+ window.webContents.send('agent-browser:state', snapshot);
+ }
+ },
getLocalProxyCredential: () => getHostApiToken() || undefined,
acquireBackgroundLease: (lease) => backgroundLifecycle!.acquireLease(lease),
paths: {
diff --git a/src/lib/agent-browser.ts b/src/lib/agent-browser.ts
index 780b926..8882643 100644
--- a/src/lib/agent-browser.ts
+++ b/src/lib/agent-browser.ts
@@ -80,9 +80,9 @@ function requireSuccess(
}
export async function getAgentBrowserState(
- projectPath: string,
+ projectId: string,
): Promise {
- const query = new URLSearchParams({ project_path: projectPath });
+ const query = new URLSearchParams({ project_id: projectId });
const response = await hostApiFetch(
`/api/agent-browser/state?${query.toString()}`,
);
@@ -90,14 +90,14 @@ export async function getAgentBrowserState(
}
export async function openAgentBrowser(input: {
- projectPath: string;
+ projectId: string;
url: string;
bounds?: AgentBrowserBounds;
}): Promise {
const response = await hostApiFetch(
'/api/agent-browser/open',
jsonBody({
- project_path: input.projectPath,
+ project_id: input.projectId,
url: input.url,
visible: true,
...(input.bounds ? { bounds: input.bounds } : {}),
@@ -107,14 +107,14 @@ export async function openAgentBrowser(input: {
}
export async function presentAgentBrowser(input: {
- projectPath: string;
+ projectId: string;
visible: boolean;
bounds?: AgentBrowserBounds;
}): Promise {
const response = await hostApiFetch(
'/api/agent-browser/present',
jsonBody({
- project_path: input.projectPath,
+ project_id: input.projectId,
visible: input.visible,
...(input.bounds ? { bounds: input.bounds } : {}),
}),
@@ -123,13 +123,13 @@ export async function presentAgentBrowser(input: {
}
export async function setAgentBrowserDiagnostics(input: {
- projectPath: string;
+ projectId: string;
enabled: boolean;
}): Promise {
const response = await hostApiFetch(
'/api/agent-browser/diagnostics',
jsonBody({
- project_path: input.projectPath,
+ project_id: input.projectId,
enabled: input.enabled,
}),
);
@@ -137,14 +137,14 @@ export async function setAgentBrowserDiagnostics(input: {
}
export async function navigateAgentBrowser(input: {
- projectPath: string;
+ projectId: string;
action: AgentBrowserNavigateAction;
url?: string;
}): Promise {
const response = await hostApiFetch(
'/api/agent-browser/navigate',
jsonBody({
- project_path: input.projectPath,
+ project_id: input.projectId,
action: input.action,
...(input.url ? { url: input.url } : {}),
}),
@@ -153,7 +153,7 @@ export async function navigateAgentBrowser(input: {
}
export async function sendAgentBrowserCdp(input: {
- projectPath: string;
+ projectId: string;
method: string;
params?: Record;
sessionRef?: string;
@@ -162,7 +162,7 @@ export async function sendAgentBrowserCdp(input: {
const response = await hostApiFetch(
'/api/agent-browser/cdp/send',
jsonBody({
- project_path: input.projectPath,
+ project_id: input.projectId,
method: input.method,
...(input.params ? { params: input.params } : {}),
...(input.sessionRef ? { session_ref: input.sessionRef } : {}),
@@ -173,7 +173,7 @@ export async function sendAgentBrowserCdp(input: {
}
export async function readAgentBrowserEvents(input: {
- projectPath: string;
+ projectId: string;
after?: number;
methods?: string[];
limit?: number;
@@ -182,7 +182,7 @@ export async function readAgentBrowserEvents(input: {
const response = await hostApiFetch(
'/api/agent-browser/cdp/events',
jsonBody({
- project_path: input.projectPath,
+ project_id: input.projectId,
...(input.after !== undefined ? { after: input.after } : {}),
...(input.methods ? { methods: input.methods } : {}),
...(input.limit !== undefined ? { limit: input.limit } : {}),
@@ -193,7 +193,7 @@ export async function readAgentBrowserEvents(input: {
}
export async function readAgentBrowserPayload(input: {
- projectPath: string;
+ projectId: string;
handle: string;
offset?: number;
maxBytes?: number;
@@ -201,7 +201,7 @@ export async function readAgentBrowserPayload(input: {
const response = await hostApiFetch(
'/api/agent-browser/payload/read',
jsonBody({
- project_path: input.projectPath,
+ project_id: input.projectId,
handle: input.handle,
...(input.offset !== undefined ? { offset: input.offset } : {}),
...(input.maxBytes !== undefined ? { max_bytes: input.maxBytes } : {}),
@@ -211,21 +211,21 @@ export async function readAgentBrowserPayload(input: {
}
export async function closeAgentBrowser(
- projectPath: string,
+ projectId: string,
): Promise {
const response = await hostApiFetch(
'/api/agent-browser/close',
- jsonBody({ project_path: projectPath }),
+ jsonBody({ project_id: projectId }),
);
return requireSuccess(response).browser;
}
export async function resetAgentBrowserProfile(
- projectPath: string,
+ projectId: string,
): Promise {
const response = await hostApiFetch(
'/api/agent-browser/reset-profile',
- jsonBody({ project_path: projectPath }),
+ jsonBody({ project_id: projectId }),
);
return requireSuccess(response).browser;
}
diff --git a/src/pages/Chat/AgentBrowserPanel.tsx b/src/pages/Chat/AgentBrowserPanel.tsx
new file mode 100644
index 0000000..b8c5a82
--- /dev/null
+++ b/src/pages/Chat/AgentBrowserPanel.tsx
@@ -0,0 +1,553 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type FormEvent,
+} from 'react';
+import {
+ ArrowLeft,
+ ArrowRight,
+ ChevronDown,
+ ChevronUp,
+ Globe2,
+ RefreshCw,
+ X,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ closeAgentBrowser,
+ deriveAgentBrowserDiagnostics,
+ getAgentBrowserState,
+ navigateAgentBrowser,
+ openAgentBrowser,
+ presentAgentBrowser,
+ readAgentBrowserEvents,
+ setAgentBrowserDiagnostics,
+} from '@/lib/agent-browser';
+import { subscribeHostEvent } from '@/lib/host-events';
+import { cn } from '@/lib/utils';
+import type {
+ AgentBrowserBounds,
+ AgentBrowserCdpEvent,
+ AgentBrowserSnapshot,
+} from '../../../shared/agent-browser';
+
+type DiagnosticsTab = 'console' | 'network';
+
+export interface AgentBrowserPanelProps {
+ projectId: string | null;
+ open: boolean;
+ onOpenChange(open: boolean): void;
+}
+
+const EVENT_METHODS = [
+ 'Runtime.consoleAPICalled',
+ 'Runtime.exceptionThrown',
+ 'Log.entryAdded',
+ 'Network.requestWillBeSent',
+ 'Network.responseReceived',
+ 'Network.loadingFinished',
+ 'Network.loadingFailed',
+];
+const MAX_RENDERED_EVENTS = 500;
+const EVENT_WAIT_MS = 5_000;
+const EVENT_DRAIN_DELAY_MS = 100;
+const EVENT_EMPTY_DELAY_MS = 250;
+
+function normalizeAddress(value: string): string | null {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ return /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed)
+ ? trimmed
+ : `http://${trimmed}`;
+}
+
+function readBounds(element: HTMLElement | null): AgentBrowserBounds | undefined {
+ if (!element) return undefined;
+ const rect = element.getBoundingClientRect();
+ const bounds = {
+ x: Math.round(rect.left),
+ y: Math.round(rect.top),
+ width: Math.round(rect.width),
+ height: Math.round(rect.height),
+ };
+ return bounds.width > 0 && bounds.height > 0 ? bounds : undefined;
+}
+
+function browserIsActive(snapshot: AgentBrowserSnapshot | null): boolean {
+ return Boolean(
+ snapshot?.browserId
+ && snapshot.state !== 'closed'
+ && snapshot.state !== 'closing',
+ );
+}
+
+function modalOccludesBrowser(): boolean {
+ return Array.from(
+ document.querySelectorAll('[role="dialog"], [role="alertdialog"]'),
+ ).some((element) => {
+ if (element.hidden || element.getAttribute('data-state') === 'closed') return false;
+ const style = window.getComputedStyle(element);
+ return style.display !== 'none' && style.visibility !== 'hidden';
+ });
+}
+
+function formatTime(timestamp: number): string {
+ const date = new Date(timestamp);
+ return Number.isNaN(date.getTime())
+ ? '--:--:--'
+ : date.toLocaleTimeString('zh-CN', { hour12: false });
+}
+
+function consoleTone(level: string): string {
+ if (level === 'error' || level === 'assert') return 'text-destructive';
+ if (level === 'warning' || level === 'warn') return 'text-amber-700';
+ return 'text-foreground';
+}
+
+function statusTone(status?: number): string {
+ if (status === undefined) return 'text-muted-foreground';
+ if (status >= 500) return 'text-destructive';
+ if (status >= 400) return 'text-amber-700';
+ return 'text-emerald-700';
+}
+
+export function AgentBrowserPanel({
+ projectId,
+ open,
+ onOpenChange,
+}: AgentBrowserPanelProps) {
+ const [snapshot, setSnapshot] = useState(null);
+ const [address, setAddress] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [occluded, setOccluded] = useState(false);
+ const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
+ const [activeTab, setActiveTab] = useState('console');
+ const [events, setEvents] = useState([]);
+ const viewportRef = useRef(null);
+ const addressInputRef = useRef(null);
+ const eventCursorRef = useRef(0);
+ const wasOpenRef = useRef(open);
+ const browserActive = browserIsActive(snapshot);
+ const diagnostics = useMemo(() => deriveAgentBrowserDiagnostics(events), [events]);
+
+ const applySnapshot = useCallback((next: AgentBrowserSnapshot) => {
+ setSnapshot(next);
+ if (next.url && document.activeElement !== addressInputRef.current) {
+ setAddress(next.url);
+ }
+ }, []);
+
+ useEffect(() => {
+ setSnapshot(null);
+ setAddress('');
+ setError(null);
+ setDiagnosticsOpen(false);
+ setEvents([]);
+ eventCursorRef.current = 0;
+ }, [projectId]);
+
+ useEffect(() => {
+ if (!projectId) return undefined;
+ const unsubscribeShow = subscribeHostEvent(
+ 'agent-browser:show',
+ (next) => {
+ if (next.projectId !== projectId) return;
+ applySnapshot(next);
+ onOpenChange(true);
+ },
+ );
+ const unsubscribeState = subscribeHostEvent(
+ 'agent-browser:state',
+ (next) => {
+ if (next.projectId && next.projectId !== projectId) return;
+ applySnapshot(next);
+ },
+ );
+ return () => {
+ unsubscribeShow();
+ unsubscribeState();
+ };
+ }, [applySnapshot, onOpenChange, projectId]);
+
+ useEffect(() => {
+ if (!open || !projectId) return undefined;
+ let cancelled = false;
+ void getAgentBrowserState(projectId)
+ .then((next) => {
+ if (!cancelled) applySnapshot(next);
+ })
+ .catch(() => {
+ if (!cancelled) setSnapshot(null);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [applySnapshot, open, projectId]);
+
+ useEffect(() => {
+ if (!open || !projectId) return undefined;
+ let cancelled = false;
+ const syncAfterResume = () => {
+ if (document.visibilityState === 'hidden') return;
+ void getAgentBrowserState(projectId)
+ .then((next) => {
+ if (!cancelled) applySnapshot(next);
+ })
+ .catch(() => {
+ if (!cancelled) setSnapshot(null);
+ });
+ };
+ window.addEventListener('focus', syncAfterResume);
+ document.addEventListener('visibilitychange', syncAfterResume);
+ return () => {
+ cancelled = true;
+ window.removeEventListener('focus', syncAfterResume);
+ document.removeEventListener('visibilitychange', syncAfterResume);
+ };
+ }, [applySnapshot, open, projectId]);
+
+ useEffect(() => {
+ const wasOpen = wasOpenRef.current;
+ wasOpenRef.current = open;
+ if (!wasOpen || open || !projectId) return;
+ setSnapshot(null);
+ setDiagnosticsOpen(false);
+ setEvents([]);
+ eventCursorRef.current = 0;
+ void closeAgentBrowser(projectId).catch(() => undefined);
+ }, [open, projectId]);
+
+ useEffect(() => () => {
+ if (projectId) void closeAgentBrowser(projectId).catch(() => undefined);
+ }, [projectId]);
+
+ useEffect(() => {
+ eventCursorRef.current = 0;
+ setEvents([]);
+ }, [snapshot?.browserId, snapshot?.generation]);
+
+ useEffect(() => {
+ if (!open) {
+ setOccluded(false);
+ return undefined;
+ }
+ const update = () => setOccluded(modalOccludesBrowser());
+ update();
+ const observer = new MutationObserver(update);
+ observer.observe(document.body, {
+ attributes: true,
+ attributeFilter: ['class', 'data-state', 'hidden', 'style'],
+ childList: true,
+ subtree: true,
+ });
+ return () => observer.disconnect();
+ }, [open]);
+
+ useEffect(() => {
+ if (!projectId || !browserActive) return undefined;
+ if (!open || occluded) {
+ void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined);
+ return undefined;
+ }
+ const viewport = viewportRef.current;
+ if (!viewport) return undefined;
+ let cancelled = false;
+ let timer: number | null = null;
+ let lastBounds = '';
+ const schedule = () => {
+ if (timer !== null) window.clearTimeout(timer);
+ timer = window.setTimeout(() => {
+ timer = null;
+ const bounds = readBounds(viewport);
+ if (!bounds) return;
+ const key = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}`;
+ if (key === lastBounds) return;
+ lastBounds = key;
+ void presentAgentBrowser({ projectId, visible: true, bounds })
+ .then((next) => {
+ if (!cancelled) applySnapshot(next);
+ })
+ .catch((cause) => {
+ if (!cancelled) {
+ setError(cause instanceof Error ? cause.message : '无法显示开发浏览器');
+ }
+ });
+ }, 50);
+ };
+ const observer = typeof ResizeObserver === 'undefined'
+ ? null
+ : new ResizeObserver(schedule);
+ observer?.observe(viewport);
+ window.addEventListener('resize', schedule);
+ schedule();
+ return () => {
+ cancelled = true;
+ if (timer !== null) window.clearTimeout(timer);
+ observer?.disconnect();
+ window.removeEventListener('resize', schedule);
+ void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined);
+ };
+ }, [applySnapshot, browserActive, occluded, open, projectId, snapshot?.generation]);
+
+ useEffect(() => {
+ if (!open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') {
+ return undefined;
+ }
+ let cancelled = false;
+ void setAgentBrowserDiagnostics({ projectId, enabled: true })
+ .then((next) => {
+ if (!cancelled) applySnapshot(next);
+ })
+ .catch(() => undefined);
+ return () => {
+ cancelled = true;
+ void setAgentBrowserDiagnostics({ projectId, enabled: false }).catch(() => undefined);
+ };
+ }, [applySnapshot, diagnosticsOpen, open, projectId, snapshot?.browserId, snapshot?.generation, snapshot?.state]);
+
+ useEffect(() => {
+ if (!open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') {
+ return undefined;
+ }
+ let cancelled = false;
+ let inFlight = false;
+ let timer: number | null = null;
+ const poll = async () => {
+ if (inFlight) return;
+ inFlight = true;
+ let retryDelay = EVENT_EMPTY_DELAY_MS;
+ try {
+ const page = await readAgentBrowserEvents({
+ projectId,
+ after: eventCursorRef.current,
+ methods: EVENT_METHODS,
+ limit: 200,
+ waitMs: EVENT_WAIT_MS,
+ });
+ if (cancelled) return;
+ eventCursorRef.current = page.nextCursor;
+ setEvents((current) => {
+ const base = page.gap ? [] : current;
+ return [...base, ...page.events].slice(-MAX_RENDERED_EVENTS);
+ });
+ retryDelay = page.events.length > 0 || page.hasMore
+ ? EVENT_DRAIN_DELAY_MS
+ : EVENT_EMPTY_DELAY_MS;
+ } catch {
+ // The state event or the next explicit open reports actionable failures.
+ } finally {
+ inFlight = false;
+ if (!cancelled) {
+ timer = window.setTimeout(() => {
+ timer = null;
+ void poll();
+ }, retryDelay);
+ }
+ }
+ };
+ void poll();
+ return () => {
+ cancelled = true;
+ if (timer !== null) window.clearTimeout(timer);
+ };
+ }, [diagnosticsOpen, open, projectId, snapshot?.generation, snapshot?.state]);
+
+ const runBrowserAction = useCallback(async (
+ action: () => Promise,
+ ) => {
+ setBusy(true);
+ setError(null);
+ try {
+ applySnapshot(await action());
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '开发浏览器操作失败');
+ } finally {
+ setBusy(false);
+ }
+ }, [applySnapshot]);
+
+ const handleOpenAddress = useCallback((event?: FormEvent) => {
+ event?.preventDefault();
+ if (!projectId) return;
+ const url = normalizeAddress(address);
+ if (!url) return;
+ setAddress(url);
+ void runBrowserAction(() => browserActive
+ ? navigateAgentBrowser({ projectId, action: 'url', url })
+ : openAgentBrowser({ projectId, url, bounds: readBounds(viewportRef.current) }));
+ }, [address, browserActive, projectId, runBrowserAction]);
+
+ const handleNavigate = useCallback((action: 'back' | 'forward' | 'reload') => {
+ if (!projectId || !browserActive) return;
+ void runBrowserAction(() => navigateAgentBrowser({ projectId, action }));
+ }, [browserActive, projectId, runBrowserAction]);
+
+ if (!open) return null;
+
+ return (
+
+ );
+}
diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx
index 35bdd7b..52b1a01 100644
--- a/src/pages/Chat/CodingChatPanel.tsx
+++ b/src/pages/Chat/CodingChatPanel.tsx
@@ -35,6 +35,7 @@ import type {
CodingConversationMetadata,
} from '@/types/coding-project';
import { CodingComposer } from './CodingComposer';
+import { AgentBrowserPanel } from './AgentBrowserPanel';
import { CodingChangesSummary } from './CodingChangesSummary';
import { CodingConversationSidebar } from './CodingConversationSidebar';
import { CodingConversationHeader } from './CodingConversationHeader';
@@ -147,6 +148,7 @@ export function CodingChatPanel({
const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState<
Record
>({});
+ const [agentBrowserOpen, setAgentBrowserOpen] = useState(false);
const appliedNavigationDraftRef = useRef(null);
const automaticCreationKeyRef = useRef(null);
const selectedConversationContextRef = useRef(null);
@@ -241,6 +243,10 @@ export function CodingChatPanel({
void loadWorkspace().catch(() => undefined);
}, [loadWorkspace]);
+ useEffect(() => {
+ setAgentBrowserOpen(false);
+ }, [activeProject?.id]);
+
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
useEffect(() => subscribeHostEvent('lifecycle:sleep', () => {
@@ -642,6 +648,9 @@ export function CodingChatPanel({
onRecover={async () => {
if (targetConversationId) await recoverConversation(targetConversationId);
}}
+ browserOpen={agentBrowserOpen}
+ browserAvailable={Boolean(activeProject)}
+ onToggleBrowser={() => setAgentBrowserOpen((current) => !current)}
/>
{(workspaceError || conversationMetadataError || connectionError) && (
@@ -761,6 +770,12 @@ export function CodingChatPanel({
}}
/>
+
+