From b278ff06f6f61efa313aff828cb7f428987cec0f Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:09:32 +0800 Subject: [PATCH 1/2] feat(runtime): rebuild ACP execution as plugins --- docs/antigravity-acp-plugin-rebuild.md | 101 ++ package-lock.json | 32 + package.json | 6 +- packages/acp-executor-plugin/README.md | 28 + .../acp-executor-plugin/maka.composition.yml | 22 + .../acp-executor-plugin/maka.extension.json | 13 + packages/acp-executor-plugin/package.json | 23 + .../src/__tests__/acp-executor-plugin.test.ts | 346 +++++++ packages/acp-executor-plugin/src/index.ts | 887 ++++++++++++++++++ packages/acp-executor-plugin/tsconfig.json | 10 + packages/antigravity-acp-plugin/README.md | 53 ++ .../maka.extension.json | 30 + packages/antigravity-acp-plugin/package.json | 23 + .../__tests__/antigravity-acp-plugin.test.ts | 86 ++ packages/antigravity-acp-plugin/src/index.ts | 95 ++ packages/antigravity-acp-plugin/tsconfig.json | 10 + .../__tests__/plugin-executor-backend.test.ts | 63 +- .../__tests__/plugin-executor-service.test.ts | 36 + .../runtime/src/plugin-executor-backend.ts | 95 +- .../runtime/src/plugin-executor-service.ts | 115 ++- 20 files changed, 2066 insertions(+), 8 deletions(-) create mode 100644 docs/antigravity-acp-plugin-rebuild.md create mode 100644 packages/acp-executor-plugin/README.md create mode 100644 packages/acp-executor-plugin/maka.composition.yml create mode 100644 packages/acp-executor-plugin/maka.extension.json create mode 100644 packages/acp-executor-plugin/package.json create mode 100644 packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts create mode 100644 packages/acp-executor-plugin/src/index.ts create mode 100644 packages/acp-executor-plugin/tsconfig.json create mode 100644 packages/antigravity-acp-plugin/README.md create mode 100644 packages/antigravity-acp-plugin/maka.extension.json create mode 100644 packages/antigravity-acp-plugin/package.json create mode 100644 packages/antigravity-acp-plugin/src/__tests__/antigravity-acp-plugin.test.ts create mode 100644 packages/antigravity-acp-plugin/src/index.ts create mode 100644 packages/antigravity-acp-plugin/tsconfig.json diff --git a/docs/antigravity-acp-plugin-rebuild.md b/docs/antigravity-acp-plugin-rebuild.md new file mode 100644 index 0000000000..1fd2e76eaf --- /dev/null +++ b/docs/antigravity-acp-plugin-rebuild.md @@ -0,0 +1,101 @@ + + +# Antigravity ACP Plugin rebuild + +## Why PR #5224 cannot be carried forward unchanged + +PR #5224 predates the Plugin-backed Session executor architecture from #5283. It adds a dedicated +ACP backend and then threads external-Agent identity, model catalogs, session control, protocol +operations, Desktop bridges, and Composer state through the Host. The branch changes 139 files and +adds roughly 7,800 lines relative to its current merge base. + +The new architecture already owns those cross-cutting responsibilities generically: + +| Concern | PR #5224 implementation | Current `main` authority | +| --- | --- | --- | +| Session routing | `AcpAgentBackend` registered in Host execution composition | `executorId` plus `PluginExecutorBackend` | +| Executor lifetime | ACP-specific Host residency and backend registry logic | Plugin Entry generation, retirement, and executor binding | +| Transcript | ACP event conversion plus new external-session fields | Generic executor events enter the canonical Session stream | +| Process and credentials | Runtime Host ACP module | Contributing Plugin; opaque to Maka Runtime | +| Child/Graph execution | ACP-specific guards | Generic executor propagation from #5283 | +| Model choice | Provider catalog protocol and ACP-specific Desktop state | Plugin configuration for this rebuild; a generic executor-configuration capability can follow | +| Permission choices | ACP backend reaches `HostedInteractionBridge` directly | Generic executor permission request bridged by `PluginExecutorBackend` | + +Keeping both designs would create two backend authorities, two lifecycle paths, and provider-specific +state in otherwise generic Session and Desktop code. + +## Rebuilt boundary + +The rebuild is split into an intermediate runtime and a thin product adapter: + +```text +PluginExecutorService + -> ACP Runtime Plugin (`ctx.acp`) + -> Antigravity adapter + -> future ACP adapter +``` + +`@maka/acp-executor-plugin` owns the shared ACP protocol implementation. Its `acp-runtime` profile +Entry provides `ctx.acp`; external-Agent Entries are mounted below it and call `ctx.acp.register(...)`. +The service wraps every adapter as the generic executor contribution introduced by #5283, so it is +not a second backend or routing authority. + +`@maka/antigravity-acp-plugin` now contains only executable/helper validation, Antigravity environment +policy, and optional initial model configuration. The shared runtime owns: + +- ACP initialize, Session creation, prompt, cancellation, and cleanup; +- one retained ACP process/Session per Maka conversation; +- ACP file callbacks with workspace and symlink containment; +- ACP tool/thought/text projection into generic executor events; +- ACP permission option identity and settlement; +- generic initial ACP configuration validation/application; +- durable history-only detection so a Host/Plugin restart cannot silently fork an existing external + conversation into a new ACP Session. + +The Antigravity adapter owns: + +- the official Antigravity executable and helper paths; +- the `ANTIGRAVITY_HARNESS_PATH`, proxy-bypass, and browser environment policy; +- the optional model value passed to the shared initial-configuration mechanism. + +The Host owns only executor visibility and binding, Maka Session/run identity, canonical event +persistence, hosted form admission, and Plugin retirement. No ACP process, credential, or external +Session identifier crosses the Plugin boundary. + +## Deliberately not ported + +The rebuild does not carry forward the PR #5224 provider catalog protocol, external-Agent Session +protocol, renderer hot cache, draft/prewarm Session lease, ACP-specific backend registry branches, +custom storage fields, CLI transcript branches, model-picker forks, or visual-workaround changes. +Those components either duplicate #5283 or solve UI/configuration concerns that should be added as a +generic Plugin executor capability rather than an Antigravity branch. + +The installation and authentication surface already merged in #5164 remains in Runtime Host for +now. It is live mainline behavior and is not part of PR #5224's conflicting execution architecture. +A later migration can expose setup/authentication as a Plugin capability once the Plugin Platform has +a corresponding client-facing contribution contract. + +## Current limitation + +The Plugin can be installed and selected through existing Plugin and Session operations. Current +`main` does not provide a Desktop/default executor selector (#5283 explicitly introduced only the +Host extension point and routing bridge), so this rebuild does not reintroduce the ACP-specific +Composer state from #5224. A Desktop selector should consume generic `plugin.platform.query` executor +inspection and create the Session with its `executorId`. diff --git a/package-lock.json b/package-lock.json index f1fa4286d0..7cfd728df3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "packages/storage", "packages/mcp", "packages/runtime", + "packages/acp-executor-plugin", + "packages/antigravity-acp-plugin", "packages/runtime-host", "packages/eval", "packages/computer-use", @@ -3376,6 +3378,14 @@ "ws": "^8.19.0" } }, + "node_modules/@maka/acp-executor-plugin": { + "resolved": "packages/acp-executor-plugin", + "link": true + }, + "node_modules/@maka/antigravity-acp-plugin": { + "resolved": "packages/antigravity-acp-plugin", + "link": true + }, "node_modules/@maka/computer-use": { "resolved": "packages/computer-use", "link": true @@ -17074,6 +17084,28 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/acp-executor-plugin": { + "name": "@maka/acp-executor-plugin", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@agentclientprotocol/sdk": "1.4.0" + }, + "devDependencies": { + "@maka/runtime": "0.1.0" + } + }, + "packages/antigravity-acp-plugin": { + "name": "@maka/antigravity-acp-plugin", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@maka/acp-executor-plugin": "0.1.0" + }, + "devDependencies": { + "@maka/runtime": "0.1.0" + } + }, "packages/cli": { "name": "maka-agent", "version": "0.2.0", diff --git a/package.json b/package.json index be83413c74..884cb92d91 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "packages/storage", "packages/mcp", "packages/runtime", + "packages/acp-executor-plugin", + "packages/antigravity-acp-plugin", "packages/runtime-host", "packages/eval", "packages/computer-use", @@ -46,8 +48,8 @@ "dev:full": "npm run build && npm --workspace @maka/desktop run start", "dev:full:peer": "npm run build && npm --workspace @maka/desktop run start:peer", "cli:dev": "node packages/cli/dist/dev-cli.js", - "build": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build", - "build:test": "npm run clean && npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build:test", + "build": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/acp-executor-plugin run build && npm --workspace @maka/antigravity-acp-plugin run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build", + "build:test": "npm run clean && npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/acp-executor-plugin run build && npm --workspace @maka/antigravity-acp-plugin run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build:test", "clean": "node scripts/clean-build.mjs", "rebuild": "npm run clean && npm run build", "check:stale": "node scripts/check-stale-dist.mjs", diff --git a/packages/acp-executor-plugin/README.md b/packages/acp-executor-plugin/README.md new file mode 100644 index 0000000000..0139bede19 --- /dev/null +++ b/packages/acp-executor-plugin/README.md @@ -0,0 +1,28 @@ + + +# ACP Executor Runtime Plugin + +This package is the shared ACP transport and lifecycle layer. It provides `ctx.acp` to child Plugin +Entries. An external Agent package supplies only an `AcpAgentAdapter`; the service turns that adapter +into the generic executor contribution introduced by #5283. + +Installing this package creates the `acp-runtime` profile Entry. Keep adapter Entries below it so the +service follows normal Plugin Context inheritance. The Host remains unaware of ACP and sees only +`ctx.executors` registrations. diff --git a/packages/acp-executor-plugin/maka.composition.yml b/packages/acp-executor-plugin/maka.composition.yml new file mode 100644 index 0000000000..06efcfb515 --- /dev/null +++ b/packages/acp-executor-plugin/maka.composition.yml @@ -0,0 +1,22 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +- type: insert + rootId: profile + entry: + id: acp-runtime + packageId: acp-executor diff --git a/packages/acp-executor-plugin/maka.extension.json b/packages/acp-executor-plugin/maka.extension.json new file mode 100644 index 0000000000..d6c9fc9402 --- /dev/null +++ b/packages/acp-executor-plugin/maka.extension.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "id": "acp-executor", + "displayName": "ACP Executor Runtime", + "description": "Shared ACP protocol and process runtime for external Agent adapter plugins.", + "runtime": { + "entry": "dist/plugin.mjs" + }, + "composition": { + "patch": "maka.composition.yml", + "structuralDependencies": [] + } +} diff --git a/packages/acp-executor-plugin/package.json b/packages/acp-executor-plugin/package.json new file mode 100644 index 0000000000..3671bc2086 --- /dev/null +++ b/packages/acp-executor-plugin/package.json @@ -0,0 +1,23 @@ +{ + "name": "@maka/acp-executor-plugin", + "version": "0.1.0", + "license": "Apache-2.0", + "description": "Shared ACP runtime service for Maka executor adapter plugins.", + "type": "module", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": "./dist/index.js", + "scripts": { + "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", + "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/plugin.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test:dist": "node --test \"dist/**/*.test.js\"" + }, + "dependencies": { + "@agentclientprotocol/sdk": "1.4.0" + }, + "devDependencies": { + "@maka/runtime": "0.1.0" + } +} diff --git a/packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts b/packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts new file mode 100644 index 0000000000..055919a084 --- /dev/null +++ b/packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts @@ -0,0 +1,346 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { methods, type ClientApp, type ClientConnection } from '@agentclientprotocol/sdk'; +import type { PluginExecutorContext } from '@maka/runtime/plugin-executor-service'; +import { + AcpExecutor, + type AcpAgentAdapter, + type AcpConnectionFactory, + type AcpConversationStateStore, +} from '../index.js'; + +const adapter: AcpAgentAdapter<{ executable: string; model?: string }> = { + id: 'fixture-acp', + displayName: 'Fixture', + configure: (config) => ({ + launch: { + executable: config.executable, + ...(config.model ? { initialConfig: { model: config.model } } : {}), + }, + }), +}; + +test('runtime retains one ACP process and Session across prompts', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable, model: 'fast' }, + { + createConnection: protocol.factory, + }, + ); + const events: unknown[] = []; + const context = executorContext(events); + try { + assert.deepEqual(await executor.execute(request('first'), context), { + status: 'completed', + text: 'reply:first', + }); + assert.deepEqual(await executor.execute(request('second'), context), { + status: 'completed', + text: 'reply:second', + }); + assert.equal(protocol.connections, 1); + assert.equal(protocol.sessions, 1); + assert.equal(protocol.prompts, 2); + assert.equal(protocol.selectedModel, 'fast'); + assert.equal( + ( + events.find((event) => (event as { type: string }).type === 'tool_result') as { + content: { kind: string; paths: string[]; diff: string }; + } + ).content.kind, + 'file_diff', + ); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } + assert.equal(protocol.disposals, 1); +}); + +test('runtime rejects a historical conversation after process continuity was lost', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + const marked = new Set(); + const state: AcpConversationStateStore = { + has: async (key, cwd) => marked.has(`${key}\0${cwd}`), + mark: async (key, cwd) => { + marked.add(`${key}\0${cwd}`); + }, + }; + const first = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { + createConnection: protocol.factory, + state, + }, + ); + try { + assert.equal((await first.execute(request('first'), executorContext([]))).status, 'completed'); + await first.dispose(); + const restarted = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { + createConnection: protocol.factory, + state, + }, + ); + try { + assert.deepEqual(await restarted.execute(request('second'), executorContext([])), { + status: 'failed', + message: 'ACP conversation is history-only after the Plugin or Host was restarted', + code: 'acp_history_only', + recoverable: false, + }); + assert.equal(protocol.connections, 1); + } finally { + await restarted.dispose(); + } + } finally { + await first.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('runtime forwards cancellation to ACP and waits for settlement', async () => { + const fixture = await executableFixture(); + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + let settle!: () => void; + const settled = new Promise((resolve) => { + settle = resolve; + }); + let cancellations = 0; + const factory: AcpConnectionFactory = (input) => { + input.configureClient(chainableApp()); + return { + connection: { + agent: { + request: async (method: string) => { + if (method === methods.agent.initialize) return { protocolVersion: 1 }; + if (method === methods.agent.session.new) return { sessionId: 'acp-session' }; + if (method === methods.agent.session.prompt) { + started(); + await settled; + return { stopReason: 'cancelled' }; + } + throw new Error(`Unexpected ACP method: ${method}`); + }, + notify: async () => { + cancellations += 1; + settle(); + }, + }, + close: () => undefined, + } as unknown as ClientConnection, + failed: new Promise(() => undefined), + dispose: async () => undefined, + }; + }; + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: factory }, + ); + const abort = new AbortController(); + const execution = executor.execute(request('cancel'), executorContext([], abort.signal)); + await ready; + abort.abort(new Error('user_stop')); + try { + assert.deepEqual(await execution, { status: 'cancelled' }); + assert.equal(cancellations, 1); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +function request(text: string) { + return { + sessionId: 'session-a', + turnId: `turn-${text}`, + conversationKey: 'session-a', + cwd: process.cwd(), + text, + }; +} + +function executorContext( + events: unknown[], + signal = new AbortController().signal, +): PluginExecutorContext { + return { + signal, + emit: (event) => events.push(event), + requestPermission: async (request) => { + assert.equal(request.title, 'Allow edit?'); + return { outcome: 'selected', optionId: 'allow_once' }; + }, + }; +} + +async function executableFixture() { + const root = await mkdtemp(join(tmpdir(), 'maka-acp-runtime-')); + const executable = join(root, 'agent'); + await writeFile(executable, 'fixture'); + await chmod(executable, 0o700); + return { root, executable }; +} + +function chainableApp() { + const app = { + onNotification() { + return app; + }, + onRequest() { + return app; + }, + }; + return app as unknown as ClientApp; +} + +function fakeProtocol(): { + readonly factory: AcpConnectionFactory; + connections: number; + sessions: number; + prompts: number; + disposals: number; + selectedModel?: string; +} { + const fixture = { + connections: 0, + sessions: 0, + prompts: 0, + disposals: 0, + selectedModel: undefined as string | undefined, + factory: undefined as unknown as AcpConnectionFactory, + }; + fixture.factory = (input) => { + fixture.connections += 1; + const notifications = new Map unknown>(); + const requests = new Map unknown>(); + const app = { + onNotification(method: string, handler: (input: { params: never }) => unknown) { + notifications.set(method, handler); + return app; + }, + onRequest(method: string, handler: (input: { params: never }) => unknown) { + requests.set(method, handler); + return app; + }, + } as unknown as ClientApp; + input.configureClient(app); + const connection = { + agent: { + request: async (method: string, params: Record) => { + if (method === methods.agent.initialize) return { protocolVersion: 1 }; + if (method === methods.agent.session.new) { + fixture.sessions += 1; + return { + sessionId: 'acp-session', + configOptions: [ + { + type: 'select', + id: 'model', + name: 'Model', + currentValue: 'default', + options: [ + { value: 'default', name: 'Default' }, + { value: 'fast', name: 'Fast' }, + ], + }, + ], + }; + } + if (method === methods.agent.session.setConfigOption) { + fixture.selectedModel = String(params.value); + return { configOptions: [] }; + } + if (method === methods.agent.session.prompt) { + fixture.prompts += 1; + const text = (params.prompt as Array<{ text: string }>)[0]!.text; + await requests.get(methods.client.session.requestPermission)?.({ + params: { + sessionId: 'acp-session', + toolCall: { toolCallId: `tool-${text}`, title: 'Allow edit?' }, + options: [{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }], + } as never, + }); + notifications.get(methods.client.session.update)?.({ + params: { + sessionId: 'acp-session', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `reply:${text}` }, + }, + } as never, + }); + notifications.get(methods.client.session.update)?.({ + params: { + sessionId: 'acp-session', + update: { + sessionUpdate: 'tool_call', + toolCallId: `tool-${text}`, + title: 'Edit file', + kind: 'edit', + status: 'in_progress', + }, + } as never, + }); + notifications.get(methods.client.session.update)?.({ + params: { + sessionId: 'acp-session', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: `tool-${text}`, + status: 'completed', + content: [{ type: 'diff', path: 'README.md', oldText: 'old', newText: 'new' }], + }, + } as never, + }); + return { stopReason: 'end_turn' }; + } + throw new Error(`Unexpected ACP method: ${method}`); + }, + notify: async () => undefined, + }, + close: () => undefined, + } as unknown as ClientConnection; + return { + connection, + failed: new Promise(() => undefined), + dispose: async () => { + fixture.disposals += 1; + }, + }; + }; + return fixture; +} diff --git a/packages/acp-executor-plugin/src/index.ts b/packages/acp-executor-plugin/src/index.ts new file mode 100644 index 0000000000..662eebd338 --- /dev/null +++ b/packages/acp-executor-plugin/src/index.ts @@ -0,0 +1,887 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { access, readFile, realpath, stat, writeFile } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'; +import { Readable, Writable } from 'node:stream'; +import { setTimeout as delay } from 'node:timers/promises'; +import { + client, + methods, + ndJsonStream, + type ClientApp, + type ClientConnection, + type RequestPermissionRequest, + type SessionConfigOption, + type SessionUpdate, + type ToolCall, + type ToolCallContent, + type ToolCallUpdate, +} from '@agentclientprotocol/sdk'; +import type { PluginStorageService } from '@maka/runtime/plugin-data-services'; +import type { + PluginExecutorContext, + PluginExecutorProvider, + PluginExecutorRequest, + PluginExecutorResult, + PluginExecutorToolResultContent, +} from '@maka/runtime/plugin-executor-service'; +import { Service, type Context, type Disposable } from '@maka/runtime/plugin-kernel'; +import { terminateChildProcessTree } from '@maka/runtime/process-tree-terminator'; + +declare module '@maka/runtime/plugin-kernel' { + interface Context { + readonly acp: AcpRuntimeService; + } +} + +const CANCEL_TIMEOUT_MS = 15_000; +const INITIALIZE_TIMEOUT_MS = 30_000; +const PROCESS_EXIT_TIMEOUT_MS = 2_000; +const MAX_TEXT_FILE_BYTES = 8 * 1024 * 1024; +const MAX_EVENT_TEXT = 8_192; +const MAX_TOOL_RESULT_DIFF = 1024 * 1024; + +export interface AcpLaunchSpec { + readonly executable: string; + readonly args?: readonly string[]; + /** Sidecar binaries the adapter requires before the ACP process may start. */ + readonly requiredExecutables?: readonly string[]; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly initialConfig?: Readonly>; +} + +export interface AcpConfiguredAgent { + readonly launch: AcpLaunchSpec; + readonly supportsAttachments?: boolean; +} + +/** Product-specific code ends at this interface. */ +export interface AcpAgentAdapter { + readonly id: string; + readonly displayName: string; + readonly clientName?: string; + configure(config: TConfig): AcpConfiguredAgent; +} + +export interface AcpConnectionFactoryInput extends AcpLaunchSpec { + readonly clientName: string; + readonly executable: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly configureClient: (app: ClientApp) => void; +} + +export interface AcpConnectionOwner { + readonly connection: ClientConnection; + readonly failed: Promise; + dispose(): Promise; +} + +export type AcpConnectionFactory = (input: AcpConnectionFactoryInput) => AcpConnectionOwner; + +export interface AcpConversationStateStore { + has(conversationKey: string, cwd: string): Promise; + mark(conversationKey: string, cwd: string): Promise; +} + +interface ActivePrompt { + readonly context: PluginExecutorContext; + readonly tools: Map; + text: string; +} + +interface ToolSnapshot { + readonly id: string; + title: string; + name?: string; + kind?: ToolCall['kind']; + status?: ToolCall['status']; + content: ToolCallContent[]; + rawInput?: unknown; + rawOutput?: unknown; + started: boolean; + terminal: boolean; +} + +interface RetainedSession { + readonly conversationKey: string; + readonly cwd: string; + owner?: AcpConnectionOwner; + connection?: ClientConnection; + acpSessionId?: string; + configOptions: readonly SessionConfigOption[]; + initialization?: Promise; + active?: ActivePrompt; + lost: boolean; + loss?: Promise; +} + +export class AcpExecutor implements PluginExecutorProvider { + readonly id: string; + readonly displayName: string; + readonly capabilities = Object.freeze({ thinking: true, toolActivity: true }); + readonly #adapter: AcpAgentAdapter; + readonly #configured: AcpConfiguredAgent; + readonly #createConnection: AcpConnectionFactory; + readonly #state?: AcpConversationStateStore; + readonly #sessions = new Map(); + #disposed = false; + + constructor( + adapter: AcpAgentAdapter, + config: unknown, + options: { + readonly createConnection?: AcpConnectionFactory; + readonly state?: AcpConversationStateStore; + } = {}, + ) { + this.#adapter = validateAdapter(adapter); + this.id = adapter.id; + this.displayName = adapter.displayName; + this.#configured = validateConfiguredAgent(adapter.configure(config)); + this.#createConnection = options.createConnection ?? createAcpConnection; + this.#state = options.state; + } + + async execute( + request: Readonly, + context: PluginExecutorContext, + ): Promise { + if (this.#disposed) return failure('ACP executor is unavailable', 'acp_unavailable'); + if (request.attachments?.length && !this.#configured.supportsAttachments) { + return failure( + `${this.displayName} ACP supports project files and text only`, + 'acp_attachments_unsupported', + ); + } + let session: RetainedSession; + try { + session = this.#session(request); + } catch (error) { + return failure(safeErrorMessage(error), errorCode(error)); + } + if (session.active) return failure(`${this.displayName} ACP Session is busy`, 'acp_busy', true); + const active: ActivePrompt = { context, tools: new Map(), text: '' }; + session.active = active; + try { + await this.#ensureInitialized(session, context.signal); + context.signal.throwIfAborted(); + const prompt = session.connection!.agent.request(methods.agent.session.prompt, { + sessionId: session.acpSessionId!, + prompt: [{ type: 'text', text: promptText(request) }], + }); + const response = await this.#awaitPrompt(session, prompt, context.signal); + if (!response) return { status: 'cancelled', reason: 'timeout' }; + if (response === 'cancelled' || response.stopReason === 'cancelled') { + return { status: 'cancelled' }; + } + if (active.text.trimStart().startsWith('Agent execution error:')) { + return failure(`${this.displayName} reported an execution failure`, 'acp_prompt_failed'); + } + if (response.stopReason !== 'end_turn') { + return failure( + `${this.displayName} stopped before completing (${response.stopReason})`, + 'acp_prompt_incomplete', + ); + } + return { status: 'completed', text: active.text }; + } catch (error) { + if (context.signal.aborted) { + await this.#lose(session); + return { status: 'cancelled' }; + } + await this.#lose(session); + return failure(safeErrorMessage(error), errorCode(error)); + } finally { + if (session.active === active) session.active = undefined; + } + } + + async dispose(): Promise { + if (this.#disposed) return; + this.#disposed = true; + const sessions = [...this.#sessions.values()]; + this.#sessions.clear(); + const settlements = await Promise.allSettled( + sessions.map((session) => session.loss ?? this.#disposeSession(session)), + ); + const failures = settlements.flatMap((settlement) => + settlement.status === 'rejected' ? [settlement.reason] : [], + ); + if (failures.length) throw new AggregateError(failures, 'ACP process cleanup failed'); + } + + #session(request: Readonly): RetainedSession { + const cwd = resolve(request.cwd); + const existing = this.#sessions.get(request.conversationKey); + if (existing) { + if (existing.cwd !== cwd) + throw new AcpRuntimeError('ACP conversation cannot change workspace', 'acp_cwd_changed'); + if (existing.lost) + throw new AcpRuntimeError( + 'ACP conversation is history-only because its external process was lost', + 'acp_history_only', + ); + return existing; + } + const created: RetainedSession = { + conversationKey: request.conversationKey, + cwd, + configOptions: [], + lost: false, + }; + this.#sessions.set(request.conversationKey, created); + return created; + } + + async #ensureInitialized(session: RetainedSession, signal: AbortSignal): Promise { + if (session.initialization) return await session.initialization; + const initialization = this.#initialize(session, signal); + session.initialization = initialization; + try { + await initialization; + } catch (error) { + session.initialization = undefined; + throw error; + } + } + + async #initialize(session: RetainedSession, signal: AbortSignal): Promise { + if (await this.#state?.has(session.conversationKey, session.cwd)) { + throw new AcpRuntimeError( + 'ACP conversation is history-only after the Plugin or Host was restarted', + 'acp_history_only', + ); + } + const timeout = AbortSignal.timeout(INITIALIZE_TIMEOUT_MS); + const startupSignal = AbortSignal.any([signal, timeout]); + startupSignal.throwIfAborted(); + const launch = this.#configured.launch; + const executable = await checkedExecutable(launch.executable); + await Promise.all((launch.requiredExecutables ?? []).map(checkedExecutable)); + const owner = this.#createConnection({ + ...launch, + executable, + args: launch.args ?? [], + cwd: launch.cwd ?? dirname(executable), + env: launch.env ?? process.env, + clientName: this.#adapter.clientName ?? `maka-${this.id}`, + configureClient: (app) => this.#configureClient(session, app), + }); + session.owner = owner; + session.connection = owner.connection; + void owner.failed.catch(() => this.#lose(session)).catch(() => undefined); + const initialized = await Promise.race([ + owner.connection.agent.request( + methods.agent.initialize, + { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: false, + }, + }, + { cancellationSignal: startupSignal }, + ), + owner.failed, + ]); + if (initialized.protocolVersion !== 1) throw new Error('Unsupported ACP protocol version'); + const created = await Promise.race([ + owner.connection.agent.request( + methods.agent.session.new, + { cwd: session.cwd, mcpServers: [] }, + { cancellationSignal: startupSignal }, + ), + owner.failed, + ]); + session.acpSessionId = created.sessionId; + session.configOptions = created.configOptions ?? []; + await this.#applyInitialConfig(session, launch.initialConfig ?? {}, startupSignal); + await this.#state?.mark(session.conversationKey, session.cwd); + } + + async #applyInitialConfig( + session: RetainedSession, + values: Readonly>, + signal: AbortSignal, + ): Promise { + for (const [key, value] of Object.entries(values)) { + const option = session.configOptions.find( + (candidate) => + candidate.type === 'select' && (candidate.id === key || candidate.category === key), + ); + if (!option || option.type !== 'select') + throw new AcpRuntimeError( + `ACP configuration is unavailable: ${key}`, + 'acp_config_unavailable', + ); + const options = option.options.flatMap((entry) => + 'options' in entry ? entry.options : [entry], + ); + if (!options.some((entry) => entry.value === value)) + throw new AcpRuntimeError( + `ACP configuration value is unavailable: ${key}`, + 'acp_config_invalid', + ); + if (option.currentValue === value) continue; + const updated = await session.connection!.agent.request( + methods.agent.session.setConfigOption, + { sessionId: session.acpSessionId!, configId: option.id, value }, + { cancellationSignal: signal }, + ); + session.configOptions = updated.configOptions; + } + } + + #configureClient(session: RetainedSession, app: ClientApp): void { + app + .onNotification(methods.client.session.update, ({ params }) => { + if (params.sessionId === session.acpSessionId) this.#acceptUpdate(session, params.update); + }) + .onRequest(methods.client.fs.readTextFile, async ({ params }) => { + this.#assertSession(session, params.sessionId); + const path = await checkedWorkspacePath(session.cwd, params.path, false); + const info = await stat(path); + if (info.size > MAX_TEXT_FILE_BYTES) throw new Error('ACP text file is too large'); + const text = await readFile(path, 'utf8'); + const start = params.line ? params.line - 1 : 0; + return { + content: + params.line || params.limit + ? text + .split('\n') + .slice(start, params.limit ? start + params.limit : undefined) + .join('\n') + : text, + }; + }) + .onRequest(methods.client.fs.writeTextFile, async ({ params }) => { + this.#assertSession(session, params.sessionId); + if (Buffer.byteLength(params.content) > MAX_TEXT_FILE_BYTES) + throw new Error('ACP text file is too large'); + const path = await checkedWorkspacePath(session.cwd, params.path, true); + await writeFile(path, params.content, 'utf8'); + return {}; + }) + .onRequest(methods.client.session.requestPermission, ({ params }) => + this.#requestPermission(session, params), + ); + } + + async #requestPermission(session: RetainedSession, request: RequestPermissionRequest) { + this.#assertSession(session, request.sessionId); + const active = session.active; + if (!active) return { outcome: { outcome: 'cancelled' as const } }; + const outcome = await active.context.requestPermission({ + toolCallId: request.toolCall.toolCallId, + title: request.toolCall.title || `${this.displayName} requests permission`, + options: request.options.map((option) => ({ optionId: option.optionId, name: option.name })), + }); + return { outcome }; + } + + #acceptUpdate(session: RetainedSession, update: SessionUpdate): void { + const active = session.active; + if (!active) return; + if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') { + active.text += update.content.text; + emitText(active.context, 'output_delta', update.content.text); + return; + } + if (update.sessionUpdate === 'agent_thought_chunk' && update.content.type === 'text') { + emitText(active.context, 'thinking_delta', update.content.text); + return; + } + if (update.sessionUpdate === 'tool_call') this.#acceptTool(active, update, false); + if (update.sessionUpdate === 'tool_call_update') this.#acceptTool(active, update, true); + } + + #acceptTool(active: ActivePrompt, update: ToolCall | ToolCallUpdate, partial: boolean): void { + const snapshot = active.tools.get(update.toolCallId) ?? { + id: update.toolCallId, + title: 'External tool', + content: [], + started: false, + terminal: false, + }; + if ('title' in update && update.title != null) snapshot.title = update.title; + if (update.name != null) snapshot.name = update.name; + if (update.kind != null) snapshot.kind = update.kind; + if (update.status != null) snapshot.status = update.status; + if (update.content != null) snapshot.content = [...update.content]; + if (update.rawInput !== undefined) snapshot.rawInput = update.rawInput; + if (update.rawOutput !== undefined) snapshot.rawOutput = update.rawOutput; + active.tools.set(snapshot.id, snapshot); + if (!snapshot.started) { + snapshot.started = true; + active.context.emit({ + type: 'tool_start', + toolCallId: snapshot.id, + name: snapshot.name ?? snapshot.kind ?? 'external_tool', + displayName: snapshot.title, + input: snapshot.rawInput ?? {}, + activityKind: activityKind(snapshot.kind), + }); + } + if (partial && snapshot.status !== 'completed' && snapshot.status !== 'failed') { + emitText( + active.context, + 'tool_progress', + summarizeToolContent(snapshot.content), + snapshot.id, + ); + } + if (!snapshot.terminal && (snapshot.status === 'completed' || snapshot.status === 'failed')) { + snapshot.terminal = true; + active.context.emit({ + type: 'tool_result', + toolCallId: snapshot.id, + content: projectToolResult(snapshot.content, snapshot.rawOutput), + ...(snapshot.status === 'failed' ? { isError: true } : {}), + }); + } + } + + async #awaitPrompt( + session: RetainedSession, + prompt: Promise, + signal: AbortSignal, + ): Promise { + const running = Promise.race([prompt, session.owner!.failed]); + if (!signal.aborted) { + let onAbort!: () => void; + const aborted = new Promise((resolveAbort) => { + onAbort = resolveAbort; + signal.addEventListener('abort', onAbort, { once: true }); + }); + try { + await Promise.race([running.then(() => undefined), aborted]); + } finally { + signal.removeEventListener('abort', onAbort); + } + } + if (!signal.aborted) return await running; + await session.connection?.agent + .notify(methods.agent.session.cancel, { sessionId: session.acpSessionId! }) + .catch(() => undefined); + let timeout: ReturnType | undefined; + const completed = await Promise.race([ + running.then( + () => true, + () => true, + ), + new Promise((resolveTimeout) => { + timeout = setTimeout(() => resolveTimeout(false), CANCEL_TIMEOUT_MS); + }), + ]).finally(() => clearTimeout(timeout)); + if (!completed) await this.#lose(session); + return completed ? 'cancelled' : undefined; + } + + #assertSession(session: RetainedSession, sessionId: string): void { + if (!session.acpSessionId || session.acpSessionId !== sessionId) + throw new Error('Unknown ACP Session'); + } + + async #lose(session: RetainedSession): Promise { + if (!session.loss) { + session.lost = true; + session.loss = this.#disposeSession(session); + } + await session.loss; + } + + async #disposeSession(session: RetainedSession): Promise { + const owner = session.owner; + session.owner = undefined; + session.connection = undefined; + if (owner) await owner.dispose(); + } +} + +export class AcpRuntimeService extends Service { + constructor(ctx: Context) { + super(ctx, 'acp'); + } + + register(adapter: AcpAgentAdapter, config: TConfig): Disposable> { + const storage = this.ctx.get('storage'); + const provider = new AcpExecutor(adapter as AcpAgentAdapter, config, { + ...(storage ? { state: pluginStateStore(storage, adapter.id) } : {}), + }); + this.ctx.effect(() => () => provider.dispose(), `acp.dispose(${JSON.stringify(adapter.id)})`); + return this.ctx.executors.register(provider); + } +} + +export function createAcpConnection(input: AcpConnectionFactoryInput): AcpConnectionOwner { + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(input.executable, [...(input.args ?? [])], { + cwd: input.cwd, + env: input.env, + stdio: 'pipe', + detached: true, + shell: false, + }); + } catch { + throw new AcpRuntimeError('ACP executable is unavailable', 'acp_executable_unavailable'); + } + let disposing = false; + let disposed = false; + let disposal: Promise | undefined; + let rejectFailure!: (error: Error) => void; + const failed = new Promise((_resolve, reject) => { + rejectFailure = reject; + }); + void failed.catch(() => undefined); + const fail = () => { + if (!disposing) rejectFailure(new Error('ACP connection failed')); + }; + child.once('error', fail); + child.stdin.once('error', fail); + child.stderr.once('error', fail); + child.stderr.on('data', () => undefined); + child.once('close', fail); + const app = client({ name: input.clientName }); + input.configureClient(app); + const connection = app.connect( + ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ), + ); + void connection.closed.then(fail, fail); + return { + connection, + failed, + dispose() { + if (disposed) return Promise.resolve(); + if (disposal) return disposal; + disposing = true; + disposal = terminate(child, connection).then( + () => { + disposed = true; + }, + (error) => { + disposal = undefined; + throw error; + }, + ); + return disposal; + }, + }; +} + +async function terminate( + child: ChildProcessWithoutNullStreams, + connection: ClientConnection, +): Promise { + connection.close(); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + const alive = () => child.exitCode === null && child.signalCode === null; + if (!child.pid || !alive()) return; + await terminateChildProcessTree(child, 'SIGTERM'); + for (let attempt = 0; attempt < 40 && alive(); attempt += 1) await delay(50); + if (alive()) { + await terminateChildProcessTree(child, 'SIGKILL'); + for (let elapsed = 0; elapsed < PROCESS_EXIT_TIMEOUT_MS && alive(); elapsed += 50) + await delay(50); + } + if (alive()) throw new Error('ACP process cleanup failed'); +} + +function pluginStateStore( + storage: PluginStorageService, + executorId: string, +): AcpConversationStateStore { + const key = (conversationKey: string) => + `acp/${executorId}/${createHash('sha256').update(conversationKey).digest('hex')}`; + return { + async has(conversationKey) { + const value = (await storage.get<{ version?: unknown; cwd?: unknown }>(key(conversationKey))) + .value; + return value?.version === 1; + }, + async mark(conversationKey, cwd) { + await storage.set(key(conversationKey), { version: 1, cwd }); + }, + }; +} + +function validateAdapter(adapter: AcpAgentAdapter): AcpAgentAdapter { + if ( + !adapter || + typeof adapter !== 'object' || + typeof adapter.id !== 'string' || + !/^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$/u.test(adapter.id) || + typeof adapter.displayName !== 'string' || + !adapter.displayName.trim() || + (adapter.clientName !== undefined && + (typeof adapter.clientName !== 'string' || !adapter.clientName.trim())) || + typeof adapter.configure !== 'function' + ) { + throw new TypeError('Invalid ACP Agent adapter'); + } + return adapter; +} + +function validateConfiguredAgent(value: AcpConfiguredAgent): AcpConfiguredAgent { + if (!value || typeof value !== 'object' || !value.launch || typeof value.launch !== 'object') + throw new TypeError('Invalid ACP Agent configuration'); + if (value.launch.args !== undefined && !Array.isArray(value.launch.args)) + throw new TypeError('ACP launch arguments are invalid'); + if ( + value.launch.requiredExecutables !== undefined && + !Array.isArray(value.launch.requiredExecutables) + ) + throw new TypeError('ACP required executables are invalid'); + if (value.supportsAttachments !== undefined && typeof value.supportsAttachments !== 'boolean') + throw new TypeError('ACP attachment capability is invalid'); + const executable = absolutePath(value.launch.executable, 'executable'); + const args = value.launch.args?.map((argument) => { + if (typeof argument !== 'string' || /[\0\r\n]/u.test(argument)) + throw new TypeError('ACP launch argument is invalid'); + return argument; + }); + const requiredExecutables = value.launch.requiredExecutables?.map((path) => + absolutePath(path, 'required executable'), + ); + const cwd = value.launch.cwd === undefined ? undefined : absolutePath(value.launch.cwd, 'cwd'); + const initialConfig = validateInitialConfig(value.launch.initialConfig); + return Object.freeze({ + launch: Object.freeze({ + ...value.launch, + executable, + ...(args ? { args: Object.freeze(args) } : {}), + ...(requiredExecutables ? { requiredExecutables: Object.freeze(requiredExecutables) } : {}), + ...(cwd ? { cwd } : {}), + ...(value.launch.env ? { env: Object.freeze({ ...value.launch.env }) } : {}), + ...(initialConfig ? { initialConfig } : {}), + }), + ...(value.supportsAttachments === undefined + ? {} + : { supportsAttachments: value.supportsAttachments === true }), + }); +} + +function validateInitialConfig( + value: Readonly> | undefined, +): Readonly> | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new TypeError('ACP initial configuration is invalid'); + const entries = Object.entries(value); + if ( + entries.length > 64 || + entries.some( + ([key, item]) => + !/^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$/u.test(key) || + typeof item !== 'string' || + /[\0\r\n]/u.test(item), + ) + ) + throw new TypeError('ACP initial configuration is invalid'); + return Object.freeze(Object.fromEntries(entries)); +} + +function absolutePath(value: unknown, label: string): string { + if (typeof value !== 'string' || !isAbsolute(value) || /[\0\r\n]/u.test(value)) + throw new TypeError(`ACP ${label} must be an absolute path`); + return resolve(value); +} + +async function checkedExecutable(path: string): Promise { + const resolved = await realpath(path); + if (!(await stat(resolved)).isFile()) throw new Error('ACP executable is not a file'); + await access(resolved, constants.X_OK); + return resolved; +} + +async function checkedWorkspacePath(cwd: string, path: string, forWrite: boolean): Promise { + if (!isAbsolute(path)) throw new Error('ACP file path must be absolute'); + const candidate = resolve(path); + let resolvedPath: string; + if (!forWrite) resolvedPath = await realpath(candidate); + else { + try { + resolvedPath = await realpath(candidate); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + resolvedPath = resolve(await realpath(dirname(candidate)), basename(candidate)); + } + } + const relation = relative(await realpath(cwd), resolvedPath); + if (relation === '..' || relation.startsWith('../') || isAbsolute(relation)) + throw new Error('ACP file path leaves the workspace'); + if (!forWrite) await access(resolvedPath, constants.R_OK); + return resolvedPath; +} + +function promptText(request: Readonly): string { + const sections = [request.text]; + if (request.instructions) sections.push(`Agent instructions:\n${request.instructions}`); + for (const quote of request.quotes ?? []) sections.push(`Quoted context:\n${quote.text}`); + for (const reference of request.directoryReferences ?? []) + sections.push(`Project directory reference: ${reference.path}`); + return sections.filter(Boolean).join('\n\n'); +} + +function emitText( + context: PluginExecutorContext, + type: 'output_delta' | 'thinking_delta' | 'tool_progress', + text: string, + toolCallId?: string, +): void { + const safeText = text.replaceAll('\r', ''); + for (let offset = 0; offset < safeText.length; offset += MAX_EVENT_TEXT) { + const chunk = safeText.slice(offset, offset + MAX_EVENT_TEXT); + if (type === 'output_delta' || type === 'thinking_delta') context.emit({ type, text: chunk }); + else context.emit({ type, toolCallId: toolCallId!, text: chunk }); + } +} + +function projectToolResult( + content: readonly ToolCallContent[], + rawOutput: unknown, +): PluginExecutorToolResultContent { + const diffs = content.flatMap((item) => + item.type === 'diff' + ? [ + { + path: item.path, + diff: createWholeFileDiff(item.path, item.oldText ?? '', item.newText ?? ''), + }, + ] + : [], + ); + const combinedDiff = diffs.map(({ diff }) => diff).join('\n'); + if (diffs.length && combinedDiff.length <= MAX_TOOL_RESULT_DIFF) + return { + kind: 'file_diff', + paths: diffs.map(({ path }) => path), + diff: combinedDiff, + }; + if (diffs.length) + return { + kind: 'text', + text: boundedText( + `${diffs.map(({ path }) => `Updated ${path}`).join('\n')}\nDiff omitted because it exceeds the executor event limit.`, + ), + }; + return { kind: 'text', text: boundedText(summarizeToolResult(content, rawOutput)) }; +} + +function createWholeFileDiff(path: string, oldText: string, newText: string): string { + const oldLines = oldText.split('\n'); + const newLines = newText.split('\n'); + return [ + `--- a/${path}`, + `+++ b/${path}`, + `@@ -1,${oldLines.length} +1,${newLines.length} @@`, + ...oldLines.map((line) => `-${line}`), + ...newLines.map((line) => `+${line}`), + ].join('\n'); +} + +function summarizeToolContent(content: readonly ToolCallContent[]): string { + return content + .map((item) => { + if (item.type === 'diff') return `Updated ${item.path}`; + if (item.type === 'terminal') return item.terminalId ? `Terminal ${item.terminalId}` : ''; + return item.content.type === 'text' ? item.content.text : ''; + }) + .filter(Boolean) + .join('\n'); +} + +function summarizeToolResult(content: readonly ToolCallContent[], rawOutput: unknown): string { + const summary = summarizeToolContent(content); + if (summary) return summary; + if (rawOutput === undefined) return ''; + try { + return JSON.stringify(rawOutput); + } catch { + return 'External tool completed'; + } +} + +function boundedText(value: string): string { + const safe = value.replaceAll('\r', ''); + return safe.length <= MAX_EVENT_TEXT ? safe : `${safe.slice(0, MAX_EVENT_TEXT - 1)}…`; +} + +function activityKind(kind: ToolCall['kind'] | undefined) { + if (kind === 'read') return 'read' as const; + if (kind === 'edit' || kind === 'delete' || kind === 'move') return 'edit' as const; + if (kind === 'search') return 'search' as const; + if (kind === 'fetch') return 'webfetch' as const; + if (kind === 'execute') return 'command' as const; + if (kind === 'think') return 'explore' as const; + return 'tool' as const; +} + +class AcpRuntimeError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + this.name = 'AcpRuntimeError'; + } +} + +function failure( + message: string, + code: string, + recoverable = false, +): Extract { + return { status: 'failed', message, code, recoverable }; +} + +function errorCode(error: unknown): string { + if (error instanceof AcpRuntimeError) return error.code; + if (error instanceof DOMException && error.name === 'TimeoutError') + return 'acp_initialize_timed_out'; + return 'acp_execution_failed'; +} + +function safeErrorMessage(error: unknown): string { + if (error instanceof AcpRuntimeError) return error.message; + return 'ACP execution failed'; +} + +const host = Object.freeze({ + apply(ctx: Context) { + new AcpRuntimeService(ctx); + }, +}); + +export default Object.freeze({ + packageId: 'acp-executor', + contributions: Object.freeze([{ id: 'acp', kind: 'service' }]), + host, +}); diff --git a/packages/acp-executor-plugin/tsconfig.json b/packages/acp-executor-plugin/tsconfig.json new file mode 100644 index 0000000000..8d66f10a6b --- /dev/null +++ b/packages/acp-executor-plugin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../runtime" }] +} diff --git a/packages/antigravity-acp-plugin/README.md b/packages/antigravity-acp-plugin/README.md new file mode 100644 index 0000000000..d08f9f6505 --- /dev/null +++ b/packages/antigravity-acp-plugin/README.md @@ -0,0 +1,53 @@ + + +# Antigravity ACP executor Plugin + +This package is the thin Antigravity adapter for Maka's shared ACP Runtime Plugin. It owns only the +Antigravity executable/helper paths, launch environment, and optional initial model value. The parent +`acp-runtime` Entry owns ACP processes and Sessions; Maka sees only the registered +`antigravity-acp` executor and ordinary Session events. + +Build the repository, install this directory with `plugin.package.install`, then add a profile Entry: + +```json +{ + "type": "insert", + "rootId": "profile", + "parentId": "acp-runtime", + "entry": { + "id": "antigravity-acp", + "packageId": "antigravity-acp", + "config": { + "executable": "/absolute/path/to/agy_acp_server.par" + } + } +} +``` + +The parent Entry is contributed when the `acp-executor` dependency is installed. + +Create a Session with `executorId: "antigravity-acp"`. The executable and its +`localharness_external` helper remain adapter-owned. An optional `model` configuration is validated +against the live ACP Session before the first prompt. + +The Plugin retains one external process per Maka conversation while the Host is running. Reloading, +disabling, or uninstalling the Plugin cancels active work and terminates every owned process. Same- +Session restoration after a Host restart remains intentionally unsupported until ACP resume/load +semantics are implemented and verified. diff --git a/packages/antigravity-acp-plugin/maka.extension.json b/packages/antigravity-acp-plugin/maka.extension.json new file mode 100644 index 0000000000..1955f3d298 --- /dev/null +++ b/packages/antigravity-acp-plugin/maka.extension.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "id": "antigravity-acp", + "displayName": "Antigravity ACP", + "description": "Runs Google Antigravity as an external Agent through ACP.", + "dependencies": [{ "id": "acp-executor" }], + "runtime": { + "entry": "dist/plugin.mjs" + }, + "configuration": { + "properties": { + "executable": { + "type": "string", + "title": "ACP executable", + "description": "Absolute path to agy_acp_server.par." + }, + "helper": { + "type": "string", + "title": "ACP helper", + "description": "Optional absolute path to localharness_external; defaults beside the executable." + }, + "model": { + "type": "string", + "title": "Model", + "description": "Optional ACP model configuration value applied when each external Session is created." + } + }, + "required": ["executable"] + } +} diff --git a/packages/antigravity-acp-plugin/package.json b/packages/antigravity-acp-plugin/package.json new file mode 100644 index 0000000000..c8d9a6b7c1 --- /dev/null +++ b/packages/antigravity-acp-plugin/package.json @@ -0,0 +1,23 @@ +{ + "name": "@maka/antigravity-acp-plugin", + "version": "0.1.0", + "license": "Apache-2.0", + "description": "Antigravity ACP Session executor packaged for Maka's Host Plugin Platform.", + "type": "module", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": "./dist/index.js", + "scripts": { + "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", + "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/plugin.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test:dist": "node --test \"dist/**/*.test.js\"" + }, + "dependencies": { + "@maka/acp-executor-plugin": "0.1.0" + }, + "devDependencies": { + "@maka/runtime": "0.1.0" + } +} diff --git a/packages/antigravity-acp-plugin/src/__tests__/antigravity-acp-plugin.test.ts b/packages/antigravity-acp-plugin/src/__tests__/antigravity-acp-plugin.test.ts new file mode 100644 index 0000000000..900538467e --- /dev/null +++ b/packages/antigravity-acp-plugin/src/__tests__/antigravity-acp-plugin.test.ts @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import acpPackage from '@maka/acp-executor-plugin'; +import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader'; +import { PluginExecutorService } from '@maka/runtime/plugin-executor-service'; +import { Context } from '@maka/runtime/plugin-kernel'; +import pluginPackage, { + ANTIGRAVITY_ACP_EXECUTOR_ID, + antigravityAcpAdapter, + antigravityEnvironment, + validateConfig, +} from '../index.js'; + +test('adapter registers through a parent ACP Runtime Entry', async () => { + const root = new Context(); + const executors = new PluginExecutorService(root); + const loader = new MakaCompositionLoader({ root }); + await loader.install(acpPackage); + await loader.install(pluginPackage); + await loader.create('profile', { + id: 'acp-runtime-entry', + packageId: 'acp-executor', + children: [ + { + id: 'antigravity-entry', + packageId: 'antigravity-acp', + config: { executable: '/opt/antigravity/agy_acp_server.par' }, + }, + ], + }); + + assert.deepEqual( + executors.list('session-a').map(({ id, displayName }) => ({ id, displayName })), + [{ id: ANTIGRAVITY_ACP_EXECUTOR_ID, displayName: 'Antigravity' }], + ); + assert.deepEqual(pluginPackage.contributions, [ + { id: ANTIGRAVITY_ACP_EXECUTOR_ID, kind: 'executor' }, + ]); + await loader.close(); +}); + +test('adapter owns Antigravity launch policy only', () => { + const configured = antigravityAcpAdapter.configure({ + executable: '/opt/antigravity/agy_acp_server.par', + helper: '/opt/antigravity/localharness_external', + model: 'gemini-high', + }); + assert.equal(configured.launch.executable, '/opt/antigravity/agy_acp_server.par'); + assert.equal(configured.launch.cwd, '/opt/antigravity'); + assert.deepEqual(configured.launch.requiredExecutables, [ + '/opt/antigravity/localharness_external', + ]); + assert.equal( + configured.launch.env?.ANTIGRAVITY_HARNESS_PATH, + '/opt/antigravity/localharness_external', + ); + assert.deepEqual(configured.launch.initialConfig, { model: 'gemini-high' }); +}); + +test('adapter validates configuration and preserves proxy bypass', () => { + assert.throws(() => validateConfig({ executable: 'relative' }), /absolute path/u); + const env = antigravityEnvironment({ NO_PROXY: 'example.test,localhost' }, '/helper'); + assert.equal(env.BROWSER, '/usr/bin/true'); + assert.equal(env.ANTIGRAVITY_HARNESS_PATH, '/helper'); + assert.equal(env.NO_PROXY, 'example.test,localhost,127.0.0.1,::1'); + assert.equal(env.no_proxy, env.NO_PROXY); +}); diff --git a/packages/antigravity-acp-plugin/src/index.ts b/packages/antigravity-acp-plugin/src/index.ts new file mode 100644 index 0000000000..9e99a3292c --- /dev/null +++ b/packages/antigravity-acp-plugin/src/index.ts @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { dirname, isAbsolute, resolve } from 'node:path'; +import type { AcpAgentAdapter, AcpConfiguredAgent } from '@maka/acp-executor-plugin'; +import type { Context } from '@maka/runtime/plugin-kernel'; + +export const ANTIGRAVITY_ACP_EXECUTOR_ID = 'antigravity-acp'; + +export interface AntigravityAcpConfig { + readonly executable: string; + readonly helper?: string; + readonly model?: string; +} + +/** Antigravity-specific launch and environment policy; ACP mechanics live in the parent service. */ +export const antigravityAcpAdapter: AcpAgentAdapter = Object.freeze({ + id: ANTIGRAVITY_ACP_EXECUTOR_ID, + displayName: 'Antigravity', + clientName: 'maka-antigravity-acp-plugin', + configure(value: AntigravityAcpConfig): AcpConfiguredAgent { + const config = validateConfig(value); + const helper = config.helper ?? resolve(dirname(config.executable), 'localharness_external'); + return Object.freeze({ + launch: Object.freeze({ + executable: config.executable, + requiredExecutables: Object.freeze([helper]), + cwd: dirname(config.executable), + env: antigravityEnvironment(process.env, helper), + ...(config.model ? { initialConfig: Object.freeze({ model: config.model }) } : {}), + }), + }); + }, +}); + +export function validateConfig(value: AntigravityAcpConfig): AntigravityAcpConfig { + if (!value || typeof value !== 'object') throw new TypeError('ACP Plugin config is required'); + const executable = absolutePath(value.executable, 'executable'); + const helper = value.helper === undefined ? undefined : absolutePath(value.helper, 'helper'); + const model = value.model?.trim(); + if (value.model !== undefined && !model) throw new TypeError('ACP Plugin model is invalid'); + return Object.freeze({ executable, ...(helper ? { helper } : {}), ...(model ? { model } : {}) }); +} + +function absolutePath(value: unknown, label: string): string { + if (typeof value !== 'string' || !isAbsolute(value) || /[\0\r\n]/u.test(value)) + throw new TypeError(`ACP Plugin ${label} must be an absolute path`); + return resolve(value); +} + +export function antigravityEnvironment(base: NodeJS.ProcessEnv, helper: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...base, + BROWSER: '/usr/bin/true', + PYTHONUNBUFFERED: '1', + ANTIGRAVITY_HARNESS_PATH: helper, + }; + const bypass = [env.NO_PROXY, env.no_proxy, 'localhost', '127.0.0.1', '::1'] + .flatMap((entry) => entry?.split(',') ?? []) + .map((entry) => entry.trim()) + .filter((entry, index, entries) => entry.length > 0 && entries.indexOf(entry) === index) + .join(','); + env.NO_PROXY = bypass; + env.no_proxy = bypass; + return env; +} + +const host = Object.freeze({ + inject: ['acp'] as const, + apply(ctx: Context, config: AntigravityAcpConfig) { + ctx.acp.register(antigravityAcpAdapter, config); + }, +}); + +export default Object.freeze({ + packageId: 'antigravity-acp', + contributions: Object.freeze([{ id: ANTIGRAVITY_ACP_EXECUTOR_ID, kind: 'executor' }]), + host, +}); diff --git a/packages/antigravity-acp-plugin/tsconfig.json b/packages/antigravity-acp-plugin/tsconfig.json new file mode 100644 index 0000000000..9d05bd5ac8 --- /dev/null +++ b/packages/antigravity-acp-plugin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../runtime" }, { "path": "../acp-executor-plugin" }] +} diff --git a/packages/runtime/src/__tests__/plugin-executor-backend.test.ts b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts index cb0174d007..2f91475290 100644 --- a/packages/runtime/src/__tests__/plugin-executor-backend.test.ts +++ b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import type { HostedFormSettlement } from '@maka/core/backend-types'; import type { SessionEvent } from '@maka/core/events'; import { PluginExecutorBackend } from '../plugin-executor-backend.js'; import { Context } from '../plugin-kernel.js'; @@ -91,7 +92,11 @@ test('executor backend projects optional thinking and external tool activity', a activityKind: 'search', }); context.emit({ type: 'tool_progress', toolCallId: 'external-1', text: 'working' }); - context.emit({ type: 'tool_result', toolCallId: 'external-1', text: 'found' }); + context.emit({ + type: 'tool_result', + toolCallId: 'external-1', + content: { kind: 'file_diff', paths: ['README.md'], diff: '--- a/README.md' }, + }); return { status: 'completed', text: 'done' }; }, { thinking: true, toolActivity: true }, @@ -118,6 +123,11 @@ test('executor backend projects optional thinking and external tool activity', a ], ); assert.equal(events[1]?.type === 'tool_start' ? events[1].providerExecuted : undefined, true); + assert.deepEqual(events[3]?.type === 'tool_result' ? events[3].content : undefined, { + kind: 'file_diff', + paths: ['README.md'], + diff: '--- a/README.md', + }); const stepId = events[0]?.type === 'thinking_delta' ? events[0].messageId : undefined; assert.equal(events[1]?.type === 'tool_start' ? events[1].stepId : undefined, stepId); assert.equal(events[4]?.type === 'thinking_complete' ? events[4].messageId : undefined, stepId); @@ -179,6 +189,57 @@ test('executor failure closes rich output before publishing its terminal error', await root.fiber.dispose(); }); +test('executor permission requests use the hosted form authority', async () => { + const { root, binding } = fixture(async (_request, context) => { + const result = await context.requestPermission({ + toolCallId: 'external-1', + title: 'Allow Antigravity to edit?', + options: [ + { optionId: 'allow_once', name: 'Allow once' }, + { optionId: 'reject_once', name: 'Reject once' }, + ], + }); + assert.deepEqual(result, { outcome: 'selected', optionId: 'allow_once' }); + return { status: 'completed', text: 'approved' }; + }); + const backend = new PluginExecutorBackend({ + sessionId: 'session-a', + cwd: '/workspace', + binding, + newId: ids(), + now: () => 42, + }); + let settlement: HostedFormSettlement | undefined; + const events: SessionEvent[] = []; + for await (const event of backend.send({ + turnId: 'turn-a', + text: 'task', + hostedInteraction: { + sessionId: 'session-a', + turnId: 'turn-a', + runId: 'run-a', + admitUserQuestionRequest: async () => undefined, + admitSandboxBoundaryRequest: async () => undefined, + admitFormRequest: async (input) => { + settlement = input.settlement; + }, + withdrawFormRequest: async () => undefined, + }, + })) { + events.push(event); + if (event.type === 'form_request') { + assert.equal(event.requester.name, 'remote'); + assert.equal(event.fields[0]?.kind, 'single_select'); + await settlement?.applyAnswer({ action: 'accept', values: { optionId: 'allow_once' } }); + } + } + assert.deepEqual( + events.map((event) => event.type), + ['form_request', 'text_complete', 'complete'], + ); + await root.fiber.dispose(); +}); + function fixture( execute: Parameters[0]['execute'], capabilities?: Parameters[0]['capabilities'], diff --git a/packages/runtime/src/__tests__/plugin-executor-service.test.ts b/packages/runtime/src/__tests__/plugin-executor-service.test.ts index bd2862e8d3..5138974394 100644 --- a/packages/runtime/src/__tests__/plugin-executor-service.test.ts +++ b/packages/runtime/src/__tests__/plugin-executor-service.test.ts @@ -199,6 +199,42 @@ test('executor rich events require an explicitly declared capability', async () await root.fiber.dispose(); }); +test('executor permission choices cross the service boundary with validation', async () => { + const root = new Context(); + const service = new PluginExecutorService(root); + plugin(root, 'profile', 'provider', 1).executors.register({ + id: 'remote', + execute: async (_request, context) => { + const result = await context.requestPermission({ + toolCallId: 'external-tool', + title: 'Allow external edit?', + options: [ + { optionId: 'allow', name: 'Allow' }, + { optionId: 'deny', name: 'Deny' }, + ], + }); + return { status: 'completed', text: result.outcome }; + }, + }); + + const result = await service.execute('remote', request('session-a'), { + onPermissionRequest: async (permission) => { + assert.equal(Object.isFrozen(permission), true); + assert.equal(Object.isFrozen(permission.options), true); + return { outcome: 'selected', optionId: 'allow' }; + }, + }); + assert.deepEqual(result, { status: 'completed', text: 'selected' }); + await assert.rejects( + () => + service.execute('remote', request('session-a'), { + onPermissionRequest: async () => ({ outcome: 'selected', optionId: 'unknown' }), + }), + /permission result is invalid/u, + ); + await root.fiber.dispose(); +}); + function plugin( root: Context, rootId: 'profile' | `session:${string}`, diff --git a/packages/runtime/src/plugin-executor-backend.ts b/packages/runtime/src/plugin-executor-backend.ts index 4bedece094..a7bbda6a4f 100644 --- a/packages/runtime/src/plugin-executor-backend.ts +++ b/packages/runtime/src/plugin-executor-backend.ts @@ -19,13 +19,20 @@ import { randomUUID } from 'node:crypto'; import type { SessionEvent } from '@maka/core/events'; -import type { AgentBackend, BackendSendInput } from '@maka/core/backend-types'; +import type { + AgentBackend, + BackendSendInput, + HostedFormSettlement, +} from '@maka/core/backend-types'; +import type { FormRequestEvent } from '@maka/core/events'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; import { AsyncEventQueue } from './async-queue.js'; import type { PluginExecutorBinding, PluginExecutorOutputEvent, + PluginExecutorPermissionRequest, + PluginExecutorPermissionResult, PluginExecutorResult, } from './plugin-executor-service.js'; @@ -140,6 +147,8 @@ export class PluginExecutorBackend implements AgentBackend { if (event.type === 'thinking_delta') thinkingText += event.text; this.#publishOutputEvent(turnId, messageId, event, toolUseIds, queue); }, + onPermissionRequest: (request) => + this.#requestPermission(input, request, signal, toolUseIds, queue), }, ); } catch (error) { @@ -174,6 +183,85 @@ export class PluginExecutorBackend implements AgentBackend { this.#publishResult(turnId, messageId, result, queue); } + async #requestPermission( + input: BackendSendInput, + request: PluginExecutorPermissionRequest, + signal: AbortSignal, + toolUseIds: ReadonlyMap, + queue: AsyncEventQueue, + ): Promise { + const hosted = input.hostedInteraction; + if (!hosted || signal.aborted) return { outcome: 'cancelled' }; + const requestId = this.#newId(); + const event: FormRequestEvent = { + type: 'form_request', + id: this.#newId(), + turnId: input.turnId, + ts: this.#now(), + requestId, + toolUseId: toolUseIds.get(request.toolCallId) ?? request.toolCallId, + message: request.title, + requester: { + name: this.#binding.identity.displayName, + source: this.#binding.identity.extensionId, + }, + fields: [ + { + kind: 'single_select', + name: 'optionId', + label: 'Permission', + required: true, + options: request.options.map((option) => ({ + value: option.optionId, + label: option.name, + })), + }, + ], + }; + let settle!: (result: PluginExecutorPermissionResult) => void; + const answer = new Promise((resolve) => { + settle = resolve; + }); + let settled = false; + const finish = (result: PluginExecutorPermissionResult): void => { + if (settled) return; + settled = true; + settle(result); + }; + const settlement: HostedFormSettlement = { + applyAnswer: async (result) => { + if (result.action !== 'accept') return finish({ outcome: 'cancelled' }); + const selected = result.values.optionId; + if ( + typeof selected !== 'string' || + !request.options.some((option) => option.optionId === selected) + ) { + return finish({ outcome: 'cancelled' }); + } + finish({ outcome: 'selected', optionId: selected }); + }, + applyClosure: async () => finish({ outcome: 'cancelled' }), + }; + let admission: Promise | undefined; + const onAbort = (): void => { + finish({ outcome: 'cancelled' }); + void Promise.resolve().then(async () => { + await admission?.catch(() => undefined); + await hosted.withdrawFormRequest(requestId).catch(() => undefined); + }); + }; + signal.addEventListener('abort', onAbort, { once: true }); + try { + admission = hosted.admitFormRequest({ request: event, settlement }); + await admission; + if (signal.aborted) return { outcome: 'cancelled' }; + queue.push(event); + return await answer; + } finally { + signal.removeEventListener('abort', onAbort); + } + } + #closeOptionalOutput( turnId: string, messageId: string, @@ -343,7 +431,10 @@ export class PluginExecutorBackend implements AgentBackend { toolUseId, providerExecuted: true, isError: event.isError ?? false, - content: { kind: 'text', text: event.text }, + content: + event.content.kind === 'text' + ? event.content + : { kind: 'file_diff', paths: [...event.content.paths], diff: event.content.diff }, }); } diff --git a/packages/runtime/src/plugin-executor-service.ts b/packages/runtime/src/plugin-executor-service.ts index 26eb19fe39..2319ca5b41 100644 --- a/packages/runtime/src/plugin-executor-service.ts +++ b/packages/runtime/src/plugin-executor-service.ts @@ -78,12 +78,32 @@ export type PluginExecutorOutputEvent = | { readonly type: 'tool_result'; readonly toolCallId: string; - readonly text: string; + readonly content: PluginExecutorToolResultContent; readonly isError?: boolean; }; +/** Durable tool-result shapes that an external executor may publish directly. */ +export type PluginExecutorToolResultContent = + | { readonly kind: 'text'; readonly text: string } + | { readonly kind: 'file_diff'; readonly paths: readonly string[]; readonly diff: string }; + export type PluginExecutorCancellationSource = 'provider' | 'caller' | 'executor_retired'; +export interface PluginExecutorPermissionOption { + readonly optionId: string; + readonly name: string; +} + +export interface PluginExecutorPermissionRequest { + readonly toolCallId: string; + readonly title: string; + readonly options: readonly PluginExecutorPermissionOption[]; +} + +export type PluginExecutorPermissionResult = + | { readonly outcome: 'cancelled' } + | { readonly outcome: 'selected'; readonly optionId: string }; + export type PluginExecutorResult = | { readonly status: 'completed'; readonly text: string } | { @@ -102,6 +122,10 @@ export type PluginExecutorResult = export interface PluginExecutorContext { readonly signal: AbortSignal; emit(event: PluginExecutorOutputEvent): void; + /** Request one provider-defined permission choice through Maka's hosted form authority. */ + requestPermission( + request: PluginExecutorPermissionRequest, + ): Promise; } /** A black-box executor contributed by one Host plugin. */ @@ -118,6 +142,9 @@ export interface PluginExecutorProvider { export interface PluginExecutorExecutionOptions { readonly signal?: AbortSignal; readonly onEvent?: (event: PluginExecutorOutputEvent) => void; + readonly onPermissionRequest?: ( + request: PluginExecutorPermissionRequest, + ) => Promise; } export interface PluginExecutorInspection extends MakaContributionIdentity { @@ -306,6 +333,15 @@ export class PluginExecutorService extends Service { // A presentation observer must not change external execution. } }, + requestPermission: async (request) => { + if (signal.aborted || entry.retired) return Object.freeze({ outcome: 'cancelled' }); + const normalized = normalizePermissionRequest(request); + const result = options.onPermissionRequest + ? await options.onPermissionRequest(normalized) + : ({ outcome: 'cancelled' } as const); + if (signal.aborted || entry.retired) return Object.freeze({ outcome: 'cancelled' }); + return normalizePermissionResult(result, normalized); + }, }); if (signal.aborted) return cancelledResult(signal.reason); return normalizeResult(result); @@ -448,19 +484,41 @@ function normalizeOutputEvent( event.type === 'tool_result' && capabilities?.toolActivity === true && isSafeEventId(event.toolCallId) && - isSafeEventText(event.text) && + isPluginToolResultContent(event.content) && (event.isError === undefined || typeof event.isError === 'boolean') ) { return Object.freeze({ type: event.type, toolCallId: event.toolCallId, - text: event.text, + content: + event.content.kind === 'text' + ? Object.freeze({ kind: 'text' as const, text: event.content.text }) + : Object.freeze({ + kind: 'file_diff' as const, + paths: Object.freeze([...event.content.paths]), + diff: event.content.diff, + }), ...(event.isError === undefined ? {} : { isError: event.isError }), }); } throw new TypeError('Executor output event is invalid or undeclared'); } +function isPluginToolResultContent(value: PluginExecutorToolResultContent): boolean { + if (!value || typeof value !== 'object') return false; + if (value.kind === 'text') return isSafeEventText(value.text); + return ( + value.kind === 'file_diff' && + Array.isArray(value.paths) && + value.paths.length > 0 && + value.paths.length <= 64 && + value.paths.every((path) => isSafeEventText(path) && path.trim().length > 0) && + typeof value.diff === 'string' && + value.diff.length <= 1024 * 1024 && + !/[\0\r]/u.test(value.diff) + ); +} + function normalizeResult(result: PluginExecutorResult): PluginExecutorResult { if (!result || typeof result !== 'object') throw new TypeError('Executor result is invalid'); if (result.status === 'completed' && typeof result.text === 'string') { @@ -492,6 +550,57 @@ function normalizeResult(result: PluginExecutorResult): PluginExecutorResult { throw new TypeError('Executor result is invalid'); } +function normalizePermissionRequest( + request: PluginExecutorPermissionRequest, +): PluginExecutorPermissionRequest { + if ( + !request || + typeof request !== 'object' || + !isSafeEventId(request.toolCallId) || + !isSafeEventText(request.title) || + !request.title.trim() || + !Array.isArray(request.options) || + request.options.length === 0 || + request.options.length > 64 + ) { + throw new TypeError('Executor permission request is invalid'); + } + const seen = new Set(); + const options = request.options.map((option) => { + if ( + !option || + typeof option !== 'object' || + !isSafeEventId(option.optionId) || + !isSafeEventText(option.name) || + !option.name.trim() || + seen.has(option.optionId) + ) { + throw new TypeError('Executor permission option is invalid'); + } + seen.add(option.optionId); + return Object.freeze({ optionId: option.optionId, name: option.name }); + }); + return Object.freeze({ + toolCallId: request.toolCallId, + title: request.title, + options: Object.freeze(options), + }); +} + +function normalizePermissionResult( + result: PluginExecutorPermissionResult, + request: PluginExecutorPermissionRequest, +): PluginExecutorPermissionResult { + if (result?.outcome === 'cancelled') return Object.freeze({ outcome: 'cancelled' }); + if ( + result?.outcome === 'selected' && + request.options.some((option) => option.optionId === result.optionId) + ) { + return Object.freeze({ outcome: 'selected', optionId: result.optionId }); + } + throw new TypeError('Executor permission result is invalid'); +} + function providerStateIdentity(identity: PluginExecutorInspection): `sha256:${string}` { return `sha256:${createHash('sha256') .update( From 169b969dfd172504011fc5afc878604fb3d078ff Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:51:09 +0800 Subject: [PATCH 2/2] feat(runtime): activate built-in ACP plugins from setup policy --- apps/desktop/package.json | 2 +- docs/antigravity-acp-plugin-rebuild.md | 40 ++- package-lock.json | 2 + packages/acp-executor-plugin/README.md | 5 + .../acp-executor-plugin/maka.composition.yml | 2 + packages/acp-executor-plugin/package.json | 8 +- packages/acp-executor-plugin/src/index.ts | 27 ++- packages/antigravity-acp-plugin/README.md | 4 + packages/antigravity-acp-plugin/package.json | 8 +- .../__tests__/antigravity-acp-plugin.test.ts | 1 + packages/antigravity-acp-plugin/src/index.ts | 2 +- packages/runtime-host/package.json | 2 + .../builtin-external-agent-plugins.test.ts | 124 ++++++++++ .../server/builtin-external-agent-plugins.ts | 229 ++++++++++++++++++ .../src/server/execution-composition.ts | 11 + .../src/server/extension-bundle.ts | 5 + 16 files changed, 448 insertions(+), 24 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/builtin-external-agent-plugins.test.ts create mode 100644 packages/runtime-host/src/server/builtin-external-agent-plugins.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4ab6340f51..a9bdf2a102 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -33,7 +33,7 @@ "build:preload": "esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --outfile=dist/preload/preload.cjs --external:electron", "build:overlay": "node ../../scripts/build-cursor-overlay.mjs", "build:renderer": "vite build && node scripts/check-renderer-entry-output.mjs && node ../../scripts/check-third-party-notices.mjs", - "build:workspace-deps": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/ui run build", + "build:workspace-deps": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/acp-executor-plugin run build && npm --workspace @maka/antigravity-acp-plugin run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/ui run build", "package:macos-arm64": "electron-builder --config electron-builder.config.mjs --mac dmg zip --arm64 --publish never", "package:macos-x64": "electron-builder --config electron-builder.config.mjs --mac dmg zip --x64 --publish never", "package:windows-x64": "electron-builder --config electron-builder.config.mjs --win nsis zip --x64 --publish never", diff --git a/docs/antigravity-acp-plugin-rebuild.md b/docs/antigravity-acp-plugin-rebuild.md index 1fd2e76eaf..28eaaf0fca 100644 --- a/docs/antigravity-acp-plugin-rebuild.md +++ b/docs/antigravity-acp-plugin-rebuild.md @@ -53,7 +53,9 @@ PluginExecutorService ``` `@maka/acp-executor-plugin` owns the shared ACP protocol implementation. Its `acp-runtime` profile -Entry provides `ctx.acp`; external-Agent Entries are mounted below it and call `ctx.acp.register(...)`. +Entry provides `ctx.acp`; external-Agent Entries are mounted below it and call +`ctx.acp.register(ctx, ...)`, explicitly preserving the consuming Entry identity across independently +bundled Plugin generations. The service wraps every adapter as the generic executor contribution introduced by #5283, so it is not a second backend or routing authority. @@ -87,15 +89,27 @@ custom storage fields, CLI transcript branches, model-picker forks, or visual-wo Those components either duplicate #5283 or solve UI/configuration concerns that should be added as a generic Plugin executor capability rather than an Antigravity branch. -The installation and authentication surface already merged in #5164 remains in Runtime Host for -now. It is live mainline behavior and is not part of PR #5224's conflicting execution architecture. -A later migration can expose setup/authentication as a Plugin capability once the Plugin Platform has -a corresponding client-facing contribution contract. - -## Current limitation - -The Plugin can be installed and selected through existing Plugin and Session operations. Current -`main` does not provide a Desktop/default executor selector (#5283 explicitly introduced only the -Host extension point and routing bridge), so this rebuild does not reintroduce the ACP-specific -Composer state from #5224. A Desktop selector should consume generic `plugin.platform.query` executor -inspection and create the Session with its `executorId`. +The installation and authentication surface already merged in #5164 remains the producer of setup +facts. `HostBuiltinExternalAgentPluginCoordinator` projects the saved executable into system-managed +Plugin packages and a configured adapter Entry. The projection is content-addressed and idempotent: +it installs the ACP runtime before the adapter, replaces only a changed package layer, restores the +same state after Host restart, and removes the adapter before its runtime when the setting is cleared. +Adapter code never reads RuntimePolicy. + +Both production `plugin.mjs` bundles are release dependencies of Runtime Host, so the same path is +available to Desktop-owned and managed/remote Hosts. The installed ACP service uses an isolated +Context label and an explicit consumer Context instead of relying on cross-bundle `Service` +`instanceof` identity. + +## Remaining PR 2 work + +PR 2 remains one pull request, organized as four reviewable producer-to-consumer sets: + +1. Setup facts to active executor: implemented by the system-managed package projection above; + readiness projection still needs the bounded provider probe used by Desktop. +2. Provider catalog to Desktop choice to first prompt: add a generic executor catalog/configuration + contract and integrate it into the existing model menu without creating preview Sessions. +3. ACP updates/interactions to canonical conversation settlement: add generic Agent questions and + complete race, unsupported-input, and rendering coverage. +4. Process continuity facts to task readiness: project history-only/process-loss into generic Session + and Desktop readiness and finish controlled official-provider acceptance. diff --git a/package-lock.json b/package-lock.json index 7cfd728df3..a45e5bba4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17246,6 +17246,8 @@ "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "1.4.0", + "@maka/acp-executor-plugin": "0.1.0", + "@maka/antigravity-acp-plugin": "0.1.0", "@maka/core": "0.1.0", "@maka/runtime": "0.1.0", "@maka/storage": "0.1.0", diff --git a/packages/acp-executor-plugin/README.md b/packages/acp-executor-plugin/README.md index 0139bede19..357ca14763 100644 --- a/packages/acp-executor-plugin/README.md +++ b/packages/acp-executor-plugin/README.md @@ -26,3 +26,8 @@ into the generic executor contribution introduced by #5283. Installing this package creates the `acp-runtime` profile Entry. Keep adapter Entries below it so the service follows normal Plugin Context inheritance. The Host remains unaware of ACP and sees only `ctx.executors` registrations. + +Adapter packages register with `ctx.acp.register(ctx, adapter, config)`. Passing the adapter Entry's +Context explicitly is part of the package ABI: production packages are separate self-contained +bundles, and the shared runtime must register the executor against the consumer's scope rather than +against its parent Entry. diff --git a/packages/acp-executor-plugin/maka.composition.yml b/packages/acp-executor-plugin/maka.composition.yml index 06efcfb515..1b2581b570 100644 --- a/packages/acp-executor-plugin/maka.composition.yml +++ b/packages/acp-executor-plugin/maka.composition.yml @@ -20,3 +20,5 @@ entry: id: acp-runtime packageId: acp-executor + isolate: + acp: true diff --git a/packages/acp-executor-plugin/package.json b/packages/acp-executor-plugin/package.json index 3671bc2086..7ed514be6f 100644 --- a/packages/acp-executor-plugin/package.json +++ b/packages/acp-executor-plugin/package.json @@ -5,9 +5,15 @@ "description": "Shared ACP runtime service for Maka executor adapter plugins.", "type": "module", "private": true, + "files": [ + "dist" + ], "main": "./dist/index.js", "types": "./dist/index.d.ts", - "exports": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./plugin": "./dist/plugin.mjs" + }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/plugin.mjs", diff --git a/packages/acp-executor-plugin/src/index.ts b/packages/acp-executor-plugin/src/index.ts index 662eebd338..54135ee8a4 100644 --- a/packages/acp-executor-plugin/src/index.ts +++ b/packages/acp-executor-plugin/src/index.ts @@ -45,7 +45,7 @@ import type { PluginExecutorResult, PluginExecutorToolResultContent, } from '@maka/runtime/plugin-executor-service'; -import { Service, type Context, type Disposable } from '@maka/runtime/plugin-kernel'; +import type { Context, Disposable } from '@maka/runtime/plugin-kernel'; import { terminateChildProcessTree } from '@maka/runtime/process-tree-terminator'; declare module '@maka/runtime/plugin-kernel' { @@ -520,18 +520,31 @@ export class AcpExecutor implements PluginExecutorProvider { } } -export class AcpRuntimeService extends Service { +/** + * Shared ACP registration surface. + * + * Installed Plugin packages are self-contained bundles, so this class must not + * rely on `instanceof Service` across package generations. The adapter passes + * its own Context explicitly; that preserves the child Entry identity used by + * PluginExecutorService even when the ACP runtime was loaded from another + * immutable package generation. + */ +export class AcpRuntimeService { constructor(ctx: Context) { - super(ctx, 'acp'); + ctx.provide('acp', this); } - register(adapter: AcpAgentAdapter, config: TConfig): Disposable> { - const storage = this.ctx.get('storage'); + register( + consumer: Context, + adapter: AcpAgentAdapter, + config: TConfig, + ): Disposable> { + const storage = consumer.get('storage'); const provider = new AcpExecutor(adapter as AcpAgentAdapter, config, { ...(storage ? { state: pluginStateStore(storage, adapter.id) } : {}), }); - this.ctx.effect(() => () => provider.dispose(), `acp.dispose(${JSON.stringify(adapter.id)})`); - return this.ctx.executors.register(provider); + consumer.effect(() => () => provider.dispose(), `acp.dispose(${JSON.stringify(adapter.id)})`); + return consumer.executors.register(provider); } } diff --git a/packages/antigravity-acp-plugin/README.md b/packages/antigravity-acp-plugin/README.md index d08f9f6505..e2e51facdd 100644 --- a/packages/antigravity-acp-plugin/README.md +++ b/packages/antigravity-acp-plugin/README.md @@ -43,6 +43,10 @@ Build the repository, install this directory with `plugin.package.install`, then The parent Entry is contributed when the `acp-executor` dependency is installed. +In production, Runtime Host ships both bundles and derives this Entry from the executable saved by +the existing external-Agent setup flow. The derived package layer is content-addressed, restored on +restart, and removed when the setting is cleared; the adapter itself never reads RuntimePolicy. + Create a Session with `executorId: "antigravity-acp"`. The executable and its `localharness_external` helper remain adapter-owned. An optional `model` configuration is validated against the live ACP Session before the first prompt. diff --git a/packages/antigravity-acp-plugin/package.json b/packages/antigravity-acp-plugin/package.json index c8d9a6b7c1..7fb73f0d67 100644 --- a/packages/antigravity-acp-plugin/package.json +++ b/packages/antigravity-acp-plugin/package.json @@ -5,9 +5,15 @@ "description": "Antigravity ACP Session executor packaged for Maka's Host Plugin Platform.", "type": "module", "private": true, + "files": [ + "dist" + ], "main": "./dist/index.js", "types": "./dist/index.d.ts", - "exports": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./plugin": "./dist/plugin.mjs" + }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", "build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/plugin.mjs", diff --git a/packages/antigravity-acp-plugin/src/__tests__/antigravity-acp-plugin.test.ts b/packages/antigravity-acp-plugin/src/__tests__/antigravity-acp-plugin.test.ts index 900538467e..0c34e239de 100644 --- a/packages/antigravity-acp-plugin/src/__tests__/antigravity-acp-plugin.test.ts +++ b/packages/antigravity-acp-plugin/src/__tests__/antigravity-acp-plugin.test.ts @@ -39,6 +39,7 @@ test('adapter registers through a parent ACP Runtime Entry', async () => { await loader.create('profile', { id: 'acp-runtime-entry', packageId: 'acp-executor', + isolate: { acp: true }, children: [ { id: 'antigravity-entry', diff --git a/packages/antigravity-acp-plugin/src/index.ts b/packages/antigravity-acp-plugin/src/index.ts index 9e99a3292c..4a23df3050 100644 --- a/packages/antigravity-acp-plugin/src/index.ts +++ b/packages/antigravity-acp-plugin/src/index.ts @@ -84,7 +84,7 @@ export function antigravityEnvironment(base: NodeJS.ProcessEnv, helper: string): const host = Object.freeze({ inject: ['acp'] as const, apply(ctx: Context, config: AntigravityAcpConfig) { - ctx.acp.register(antigravityAcpAdapter, config); + ctx.acp.register(ctx, antigravityAcpAdapter, config); }, }); diff --git a/packages/runtime-host/package.json b/packages/runtime-host/package.json index aac1ce4427..27f6190e11 100644 --- a/packages/runtime-host/package.json +++ b/packages/runtime-host/package.json @@ -30,6 +30,8 @@ }, "dependencies": { "@agentclientprotocol/sdk": "1.4.0", + "@maka/acp-executor-plugin": "0.1.0", + "@maka/antigravity-acp-plugin": "0.1.0", "@maka/core": "0.1.0", "@maka/runtime": "0.1.0", "@maka/storage": "0.1.0", diff --git a/packages/runtime-host/src/__tests__/builtin-external-agent-plugins.test.ts b/packages/runtime-host/src/__tests__/builtin-external-agent-plugins.test.ts new file mode 100644 index 0000000000..3df8d70556 --- /dev/null +++ b/packages/runtime-host/src/__tests__/builtin-external-agent-plugins.test.ts @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { createDefaultRuntimePolicy, type RuntimePolicy } from '@maka/core/runtime-policy'; +import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader'; +import { PluginExecutorService } from '@maka/runtime/plugin-executor-service'; +import { Context } from '@maka/runtime/plugin-kernel'; +import { + HostBuiltinExternalAgentPluginCoordinator, + resolveBuiltinExternalAgentPluginEntries, +} from '../server/builtin-external-agent-plugins.js'; +import { HostPluginPlatform } from '../server/plugin-platform.js'; + +test('built-in ACP packages load their production bundles and follow RuntimePolicy', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-builtin-acp-plugins-')); + let policy = configuredPolicy('/opt/antigravity/agy_acp_server.par'); + try { + const first = createPlatform(join(root, 'control')); + await first.platform.recover(); + const coordinator = new HostBuiltinExternalAgentPluginCoordinator({ + platform: first.platform, + controlDirectory: join(root, 'control'), + readPolicy: async () => ({ revision: 4, policy }), + entries: resolveBuiltinExternalAgentPluginEntries(), + }); + + await coordinator.recover(); + assert.deepEqual( + first.platform.inspectExecutors('profile').map(({ id, entryId, extensionId }) => ({ + id, + entryId, + extensionId, + })), + [ + { + id: 'antigravity-acp', + entryId: 'antigravity-acp', + extensionId: 'antigravity-acp', + }, + ], + ); + assert.deepEqual( + (await first.platform.packageProjections()).map(({ extensionId }) => extensionId), + ['acp-executor', 'antigravity-acp'], + ); + const initialEpoch = (await first.platform.status()).authorityEpoch; + await coordinator.reconcile(); + assert.equal((await first.platform.status()).authorityEpoch, initialEpoch); + + policy = configuredPolicy('/Applications/Antigravity/agy_acp_server.par'); + await coordinator.reconcile(); + const adapter = first.platform + .desiredComposition() + .roots.profile[0]?.children?.find(({ id }) => id === 'antigravity-acp'); + assert.deepEqual(adapter?.config, { + executable: '/Applications/Antigravity/agy_acp_server.par', + }); + assert.equal((await first.platform.status()).authorityEpoch, initialEpoch + 1); + await first.platform.close(); + + const restarted = createPlatform(join(root, 'control')); + await restarted.platform.recover(); + const recoveredEpoch = (await restarted.platform.status()).authorityEpoch; + const recoveredCoordinator = new HostBuiltinExternalAgentPluginCoordinator({ + platform: restarted.platform, + controlDirectory: join(root, 'control'), + readPolicy: async () => ({ revision: 5, policy }), + entries: resolveBuiltinExternalAgentPluginEntries(), + }); + await recoveredCoordinator.recover(); + assert.equal((await restarted.platform.status()).authorityEpoch, recoveredEpoch); + assert.equal(restarted.platform.inspectExecutors('profile')[0]?.id, 'antigravity-acp'); + + policy = createDefaultRuntimePolicy(); + await recoveredCoordinator.reconcile(); + assert.deepEqual(restarted.platform.inspectExecutors('profile'), []); + assert.deepEqual(await restarted.platform.packageProjections(), []); + await restarted.platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +function createPlatform(controlDirectory: string): { + readonly platform: HostPluginPlatform; +} { + const root = new Context(); + const executors = new PluginExecutorService(root); + return { + platform: new HostPluginPlatform(controlDirectory, { + composition: new MakaCompositionLoader({ root }), + executors, + }), + }; +} + +function configuredPolicy(executable: string): RuntimePolicy { + const policy = createDefaultRuntimePolicy(); + return { + ...policy, + externalAgents: { antigravity: { executable } }, + }; +} diff --git a/packages/runtime-host/src/server/builtin-external-agent-plugins.ts b/packages/runtime-host/src/server/builtin-external-agent-plugins.ts new file mode 100644 index 0000000000..90aa7815a9 --- /dev/null +++ b/packages/runtime-host/src/server/builtin-external-agent-plugins.ts @@ -0,0 +1,229 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { RuntimePolicySnapshot } from '@maka/core/runtime-policy'; +import type { MakaCompositionOperation } from '@maka/runtime/plugin-runtime'; +import { extensionPackageDirectoryContentDigest } from './extension-bundle.js'; +import type { ExtensionPackageManifest } from './extension-package-manifest.js'; +import type { HostPluginPlatform } from './plugin-platform.js'; + +const ACP_RUNTIME_PACKAGE_ID = 'acp-executor'; +const ACP_RUNTIME_ENTRY_ID = 'acp-runtime'; +const ANTIGRAVITY_PACKAGE_ID = 'antigravity-acp'; +const ANTIGRAVITY_ENTRY_ID = 'antigravity-acp'; +const STAGING_DIRECTORY = 'builtin-plugin-staging-v1'; +const RUNTIME_ENTRY = 'plugin.mjs'; +const COMPOSITION_PATCH = 'maka.composition.json'; + +export interface BuiltinExternalAgentPluginEntries { + readonly acpRuntime: string; + readonly antigravity: string; +} + +interface StagedPackage { + readonly root: string; + readonly digest: string; + dispose(): Promise; +} + +/** + * Projects durable product settings into reserved Plugin packages and Entries. + * + * RuntimePolicy remains the owner of setup facts. The generated package layer + * is replaceable derived state, so adapter code never reads RuntimePolicy and + * repeated reconciliation does not append unbounded user composition overlays. + */ +export class HostBuiltinExternalAgentPluginCoordinator { + readonly #platform: Pick< + HostPluginPlatform, + 'installPackage' | 'uninstallPackage' | 'packageProjections' + >; + readonly #controlDirectory: string; + readonly #readPolicy: () => Promise; + readonly #entries: BuiltinExternalAgentPluginEntries; + #gate: Promise = Promise.resolve(); + + constructor(input: { + readonly platform: Pick< + HostPluginPlatform, + 'installPackage' | 'uninstallPackage' | 'packageProjections' + >; + readonly controlDirectory: string; + readonly readPolicy: () => Promise; + readonly entries?: BuiltinExternalAgentPluginEntries; + }) { + this.#platform = input.platform; + this.#controlDirectory = input.controlDirectory; + this.#readPolicy = input.readPolicy; + this.#entries = input.entries ?? resolveBuiltinExternalAgentPluginEntries(); + } + + async recover(): Promise { + await rm(join(this.#controlDirectory, STAGING_DIRECTORY), { recursive: true, force: true }); + return await this.reconcile(); + } + + reconcile(): Promise { + const task = this.#gate.then(() => this.#reconcileNow()); + this.#gate = task.catch(() => undefined); + return task; + } + + async #reconcileNow(): Promise { + const executable = (await this.#readPolicy()).policy.externalAgents.antigravity.executable; + if (!executable) { + await this.#removeIfInstalled(ANTIGRAVITY_PACKAGE_ID); + await this.#removeIfInstalled(ACP_RUNTIME_PACKAGE_ID); + return; + } + + await this.#ensurePackage( + ACP_RUNTIME_PACKAGE_ID, + this.#entries.acpRuntime, + acpRuntimeManifest(), + [ + { + type: 'insert', + rootId: 'profile', + entry: { + id: ACP_RUNTIME_ENTRY_ID, + packageId: ACP_RUNTIME_PACKAGE_ID, + isolate: { acp: true }, + }, + }, + ], + ); + await this.#ensurePackage( + ANTIGRAVITY_PACKAGE_ID, + this.#entries.antigravity, + antigravityManifest(), + [ + { + type: 'insert', + parentId: ACP_RUNTIME_ENTRY_ID, + entry: { + id: ANTIGRAVITY_ENTRY_ID, + packageId: ANTIGRAVITY_PACKAGE_ID, + config: { executable }, + }, + }, + ], + ); + } + + async #ensurePackage( + extensionId: string, + runtimeEntry: string, + manifest: ExtensionPackageManifest, + operations: readonly MakaCompositionOperation[], + ): Promise { + const staged = await stagePackage(this.#controlDirectory, runtimeEntry, manifest, operations); + try { + const installed = (await this.#platform.packageProjections()).find( + (candidate) => candidate.extensionId === extensionId, + ); + if (installed?.contentDigest === staged.digest) return; + await this.#platform.installPackage(staged.root); + } finally { + await staged.dispose(); + } + } + + async #removeIfInstalled(extensionId: string): Promise { + const installed = (await this.#platform.packageProjections()).some( + (candidate) => candidate.extensionId === extensionId, + ); + if (installed) await this.#platform.uninstallPackage(extensionId); + } +} + +export function resolveBuiltinExternalAgentPluginEntries(): BuiltinExternalAgentPluginEntries { + return Object.freeze({ + acpRuntime: fileURLToPath(import.meta.resolve('@maka/acp-executor-plugin/plugin')), + antigravity: fileURLToPath(import.meta.resolve('@maka/antigravity-acp-plugin/plugin')), + }); +} + +async function stagePackage( + controlDirectory: string, + runtimeEntry: string, + manifest: ExtensionPackageManifest, + operations: readonly MakaCompositionOperation[], +): Promise { + const stagingRoot = join(controlDirectory, STAGING_DIRECTORY); + await mkdir(stagingRoot, { recursive: true, mode: 0o700 }); + const root = await mkdtemp(join(stagingRoot, '.package-')); + try { + await copyFile(runtimeEntry, join(root, RUNTIME_ENTRY)); + await writeFile(join(root, 'maka.extension.json'), `${JSON.stringify(manifest, null, 2)}\n`, { + mode: 0o600, + }); + await writeFile(join(root, COMPOSITION_PATCH), `${JSON.stringify(operations, null, 2)}\n`, { + mode: 0o600, + }); + return Object.freeze({ + root, + digest: await extensionPackageDirectoryContentDigest(root), + dispose: () => rm(root, { recursive: true, force: true }), + }); + } catch (error) { + await rm(root, { recursive: true, force: true }).catch(() => undefined); + throw error; + } +} + +function acpRuntimeManifest(): ExtensionPackageManifest { + return Object.freeze({ + schemaVersion: 1, + id: ACP_RUNTIME_PACKAGE_ID, + displayName: 'ACP Executor Runtime', + description: 'Shared ACP protocol and process runtime for external Agent adapter plugins.', + dependencies: Object.freeze([]), + configuration: Object.freeze({ properties: Object.freeze({}), required: Object.freeze([]) }), + runtime: Object.freeze({ entry: RUNTIME_ENTRY }), + composition: Object.freeze({ + patch: COMPOSITION_PATCH, + structuralDependencies: Object.freeze([]), + }), + }); +} + +function antigravityManifest(): ExtensionPackageManifest { + return Object.freeze({ + schemaVersion: 1, + id: ANTIGRAVITY_PACKAGE_ID, + displayName: 'Antigravity ACP', + description: 'Runs Google Antigravity as an external Agent through ACP.', + dependencies: Object.freeze([{ id: ACP_RUNTIME_PACKAGE_ID }]), + configuration: Object.freeze({ + properties: Object.freeze({ + executable: Object.freeze({ type: 'string', title: 'ACP executable' }), + }), + required: Object.freeze(['executable']), + }), + runtime: Object.freeze({ entry: RUNTIME_ENTRY }), + composition: Object.freeze({ + patch: COMPOSITION_PATCH, + structuralDependencies: Object.freeze([ACP_RUNTIME_PACKAGE_ID]), + }), + }); +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 265f1e69d3..a31e117c15 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -206,6 +206,7 @@ import { } from './project-directory-authority.js'; import { HostProjectCatalogCoordinator } from './project-catalog-coordinator.js'; import { HostProjectMembershipGate } from './project-membership-gate.js'; +import { HostBuiltinExternalAgentPluginCoordinator } from './builtin-external-agent-plugins.js'; import { HostPluginPlatformCoordinator } from './plugin-platform-coordinator.js'; import { HostPluginPlatform } from './plugin-platform.js'; import { RootAdmissionOwner } from './root-admission-owner.js'; @@ -382,6 +383,11 @@ export async function createExecutionRuntimeHostComposition( const pluginPlatformCoordinator = new HostPluginPlatformCoordinator(pluginPlatform); const openedProjectCatalog = storage.projectCatalog; const runtimePolicyStores = storage.runtimePolicy; + const builtinExternalAgentPlugins = new HostBuiltinExternalAgentPluginCoordinator({ + platform: pluginPlatform, + controlDirectory: context.owner.controlDirectory, + readPolicy: () => runtimePolicyStores.runtimePolicy.getSnapshot(), + }); const oauthCredentials = new HostOAuthExecutionAuthority(runtimePolicyStores); const openedScheduledTaskStore = storage.scheduledTasks; const openedPlanStore = storage.plan; @@ -1955,6 +1961,7 @@ export async function createExecutionRuntimeHostComposition( }); async function applyRuntimePolicyMutationEffects(): Promise { try { + await builtinExternalAgentPlugins.reconcile(); await requireMemory(memory).refreshAfterPolicyMutation(); } catch (error) { context.requestDrain(); @@ -2494,6 +2501,10 @@ export async function createExecutionRuntimeHostComposition( drain: [() => pluginPlatform!.beginDrain()], close: [() => pluginPlatform!.close()], }), + createRuntimeHostDomainModule({ + id: 'builtin-external-agent-plugins', + recovery: { state: () => builtinExternalAgentPlugins.recover() }, + }), createRuntimeHostDomainModule({ id: 'memory', handlers: [requireMemory(memory).handlers], diff --git a/packages/runtime-host/src/server/extension-bundle.ts b/packages/runtime-host/src/server/extension-bundle.ts index 6fe513e9fe..550f4c3422 100644 --- a/packages/runtime-host/src/server/extension-bundle.ts +++ b/packages/runtime-host/src/server/extension-bundle.ts @@ -260,6 +260,11 @@ export function extensionPackageContentDigest( return `sha256-${hash.digest('hex')}`; } +/** Computes the canonical digest used by PluginPackageStore without installing the package. */ +export async function extensionPackageDirectoryContentDigest(root: string): Promise { + return extensionPackageContentDigest(await readDirectory(root)); +} + function safePath(value: string): string { if ( !value ||