diff --git a/.env.example b/.env.example index 42ef528..78e62b0 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,17 @@ NEXUS_COLLAB_SERVER_PORT=1234 # Public WebSocket URL that browsers use to connect to the collab server. NEXT_PUBLIC_COLLAB_SERVER_URL=ws://localhost:1234 +# ── ACP Bridge permissions ────────────────────────────────────────────────── +# Default permission handling for packages/nexus-acp-bridge. +# "auto" preserves compatibility by selecting an allow option when available. +# "forward" emits permission.requested SSE events and waits for +# POST /session/:id/permission before continuing. +NEXUS_ACP_BRIDGE_PERMISSION_MODE=auto + +# Timeout for forwarded bridge permissions in milliseconds. If no response is +# received before the timeout, the request is cancelled. +NEXUS_ACP_BRIDGE_PERMISSION_TIMEOUT_MS=60000 + # ── Authentication (OIDC/OAuth2) ───────────────────────────────────────────── # When all four AUTH_* variables below are set, Nexus requires SSO login. # When absent, the app is open (no authentication). diff --git a/.gitignore b/.gitignore index 26296b9..09604a1 100644 --- a/.gitignore +++ b/.gitignore @@ -67,14 +67,16 @@ storage.json .dev.log .dev.pid /run.sh +/.claude/ /temp_examples/ /test-results/ -_workspace* +_workspace /graphify-out +_workspace* # Local runtime data stores (Brain / collab) -.nexus-brain/ -.nexus-collab/ +/.nexus-brain/ +/.nexus-collab/ -# nexus-acp-bridge vendored agent installs -/packages/nexus-acp-bridge/vendor/ +/.adw/config.yaml +/.adw/templates/.prefs.json diff --git a/docs/tasks/phase-a-acp-bridge-foundation-e1d223a4/plan-phase-a-acp-bridge-foundation-e1d223a4.md b/docs/tasks/phase-a-acp-bridge-foundation-e1d223a4/plan-phase-a-acp-bridge-foundation-e1d223a4.md new file mode 100644 index 0000000..7d4f88a --- /dev/null +++ b/docs/tasks/phase-a-acp-bridge-foundation-e1d223a4/plan-phase-a-acp-bridge-foundation-e1d223a4.md @@ -0,0 +1,317 @@ +# feature: Phase A ACP Bridge Foundation + +## Metadata +adw_id: `e1d223a4` +document_description: `Plan — Phase A: ACP Bridge (Foundation)` + +## Description +The task document captures the remaining Phase A foundation work needed after the `feat/nexus-acp-bridge` branch. The bridge already runs as a separate Bun HTTP/SSE server and exposes an OpenCode-shaped API that Nexus can consume through the existing `OpenCodeClient`. The remaining work is to make that bridge suitable for the upcoming AI side-kick UX by surfacing ACP tool-call activity, supporting forwarded permission prompts instead of unconditional auto-approval, and preserving the Nexus assistant `messageID` association for tool events. + +Complexity assessment: `complex`. This work crosses the bridge package API, ACP adapter event translation, HTTP route handling, config parsing, browser-side OpenCode event types, and tests. + +## Objective +Implement the Phase A bridge gaps so the ACP bridge can: + +- emit OpenCode-compatible SSE events for ACP `tool_call` and `tool_call_update` updates; +- include `sessionID`, `messageID`, and `callID` in tool-call events so the future side-kick can render tool cards in the correct assistant-message slot; +- support permission handling in both safe default `auto` mode and side-kick-ready `forward` mode; +- expose `POST /session/:id/permission` to resolve forwarded ACP permission requests; +- keep existing workflow generation and prompt generation behavior unchanged for consumers that ignore the new events. + +## Problem Statement +The bridge currently converts ACP text chunks into OpenCode-style text deltas, but drops tool-call and permission context. ACP `session/update` notifications for `tool_call` / `tool_call_update` are not exposed to the browser, and `session/request_permission` is silently auto-approved. That prevents the side-kick from showing collapsible tool cards or allowing users to approve/deny risky actions. + +## Solution Statement +Extend the bridge's OpenCode-compatible event model and ACP adapter plumbing while preserving existing defaults. Add typed event variants for tool calls and permission requests; pass an event sink and assistant `messageID` from the HTTP server into the adapter during generation; track ACP session ↔ Nexus session associations; support per-session permission mode with config defaults; route permission replies back to pending JSON-RPC reverse requests; and validate behavior with bridge unit/integration tests. + +## Code Patterns to Follow +Reference implementations: + +- `packages/nexus-acp-bridge/src/adapters/acp-protocol.ts` — current source of truth for ACP JSON-RPC initialization, `session/new`, `session/prompt`, command discovery, reverse `fs/*` handlers, and current auto-permission behavior. +- `packages/nexus-acp-bridge/src/server/http-server.ts` — existing OpenCode-shaped REST/SSE routes, `EventBroker.publish()`, `runPromptAsync()`, zod request schemas, CORS handling, and session lifecycle. +- `packages/nexus-acp-bridge/src/types.ts` — bridge wire-type and interface definitions that should be updated before implementation code. +- `packages/nexus-acp-bridge/src/config.ts` — established CLI/env parsing pattern (`readEnv`, `readNumber`, `readBoolean`, `parseCliArgs`, bundled defaults, presets). +- `packages/nexus-acp-bridge/src/__tests__/acp-protocol-adapter.test.ts` — fake ACP client pattern for reverse handler and session update tests. +- `packages/nexus-acp-bridge/src/__tests__/server.test.ts` — Bun server integration-test pattern using `port: 0`, `fetch`, and cleanup in `afterEach`. +- `src/lib/opencode/types.ts` — browser OpenCode event union that must stay compatible with bridge-emitted SSE payloads. +- `src/lib/opencode/services/permissions.ts` and `src/lib/opencode/services/sessions.ts` — existing service wrappers to follow if adding a client helper for the bridge permission endpoint. + +Exhaustive pattern research performed: + +- `OpenCodeEvent` occurrences: + - `packages/nexus-acp-bridge/src/server/http-server.ts`: 4 + - `packages/nexus-acp-bridge/src/types.ts`: 1 + - `src/lib/opencode/services/events.ts`: 3 + - `src/lib/opencode/types.ts`: 2 +- `session/request_permission` occurrences: + - `packages/nexus-acp-bridge/README.md`: 1 + - `packages/nexus-acp-bridge/src/adapters/acp-protocol.ts`: 1 + - `packages/nexus-acp-bridge/src/__tests__/acp-protocol-adapter.test.ts`: 2 +- `tool_call` / `tool_call_update` occurrences: + - `src/lib/opencode/types.ts`: 1 +- `permission` / `permissions` route/service occurrences: + - `src/lib/opencode/services/index.ts`: 1 + - `packages/nexus-acp-bridge/src/adapters/acp-protocol.ts`: 1 + - `src/lib/opencode/services/permissions.ts`: 7 + - `packages/nexus-acp-bridge/src/__tests__/acp-protocol-adapter.test.ts`: 3 + - `src/lib/opencode/index.ts`: 2 + - `src/lib/opencode/types.ts`: 9 + +## Relevant Files +Use these files to complete the task: + +- `CLAUDE.md` — project-level coding rules: use Bun, keep OpenCode integration optional, preserve browser/localStorage assumptions, avoid new backend assumptions outside existing bridge package. +- `packages/nexus-acp-bridge/CLAUDE.md` — package-specific rules: keep all env/CLI resolution in `config.ts`, keep `fs/*` sandboxing, preserve abortable streaming, expose public API through `src/index.ts`. +- `.app_config.yaml` — validation command source for build/lint/typecheck and confirms this is primarily a frontend app with a bridge package. +- `README.md` — user-facing ACP bridge behavior and commands; update if new bridge flags/env vars become user-facing. +- `package.json` — root scripts including `test:bridge`, `typecheck:bridge`, `typecheck`, `lint`, and `build`. +- `packages/nexus-acp-bridge/package.json` — bridge package test/typecheck scripts. +- `packages/nexus-acp-bridge/src/types.ts` — add event variants, permission-mode types, request/reply payload types, adapter interface updates, and session record metadata. +- `packages/nexus-acp-bridge/src/config.ts` — parse `--permission-mode`, `NEXUS_ACP_BRIDGE_PERMISSION_MODE`, and optional timeout configuration with defaults. +- `packages/nexus-acp-bridge/src/adapters/acp-protocol.ts` — emit tool events from ACP `session/update`, map ACP sessions to Nexus sessions/messages, forward permission requests when configured, resolve/cancel pending permission requests. +- `packages/nexus-acp-bridge/src/server/http-server.ts` — accept per-session permission mode in `POST /session`, pass generation event sink/message context to adapter, add `POST /session/:id/permission`, publish new SSE events. +- `packages/nexus-acp-bridge/src/__tests__/acp-protocol-adapter.test.ts` — add adapter tests for tool events, auto permissions, forwarded permissions, and forwarded permission timeout. +- `packages/nexus-acp-bridge/src/__tests__/server.test.ts` — add server tests for session permission mode parsing, permission route behavior, and SSE delivery of new events. +- `packages/nexus-acp-bridge/src/__tests__/test-helpers.ts` — update `makeBridgeConfig()` and `makeGenerateTextRequest()` defaults for new config/request fields. +- `src/lib/opencode/types.ts` — mirror bridge event union additions so browser consumers can type side-kick events safely. +- `src/lib/opencode/services/permissions.ts` — optionally add a bridge-specific helper for `POST /session/:id/permission`, while preserving existing OpenCode permission methods. +- `src/lib/opencode/services/sessions.ts` — update `SessionCreatePayload` usage/types if the side-kick will create sessions with `permissionMode: "forward"` through the regular session service. +- `.env.example` — document new bridge permission environment variables if they are intended for users. +- `packages/nexus-acp-bridge/README.md` — replace the current auto-approval-only statement with the new default/forward behavior and endpoint/flag examples. +- `docs/tasks/conditional_docs.md` — reviewed; no additional conditional documentation applies because this task does not touch Brain persistence or workspace foundation routes. + +### New Files +No new source files are required. Prefer modifying the existing bridge package and OpenCode type/service files. + +## Implementation Plan +### Phase 1: Foundation +Define shared types and configuration first so later implementation code is strongly typed. Add `PermissionMode`, bridge permission request/reply payloads, `ToolCall` event variants, and adapter request/reply hooks in `packages/nexus-acp-bridge/src/types.ts`. Extend config parsing with safe defaults (`auto`, timeout around 60 seconds) and update test helpers. + +### Phase 2: Core Implementation +Update `ACPProtocolAdapter` so it translates ACP `tool_call` and `tool_call_update` updates into OpenCode-style events and manages pending forwarded permissions. Add maps for ACP session → Nexus session/message context and pending permission resolvers. In `auto` mode, preserve the existing behavior. In `forward` mode, emit `permission.requested`, park the JSON-RPC response promise, and resolve it through a new adapter method called by the HTTP route. + +### Phase 3: Integration +Wire the HTTP server to store per-session permission mode, pass assistant `messageID` and an event publisher into `adapter.generateText()`, add `POST /session/:id/permission`, and mirror event types in the frontend OpenCode union. Update tests and user-facing docs/env examples. + +## Step by Step Tasks +IMPORTANT: Execute every step in order, top to bottom. + +### 1. Confirm Baseline and Branch State +- Verify the bridge package files listed in the task document exist in this worktree. +- Run `git status --short` and avoid overwriting unrelated local changes. +- Review `packages/nexus-acp-bridge/src/types.ts`, `src/config.ts`, `src/adapters/acp-protocol.ts`, `src/server/http-server.ts`, and current bridge tests before editing. + +### 2. Add Shared Bridge Types +- In `packages/nexus-acp-bridge/src/types.ts`, add: + - `export type PermissionMode = "auto" | "forward"`; + - `export type PermissionOutcome = { outcome: "selected"; optionId: string } | { outcome: "cancelled" }` or equivalent matching ACP response shape; + - `ToolCallEvent` and `ToolCallUpdatedEvent` variants with `sessionID`, `messageID`, `callID`, `title`, optional `kind`, optional `rawInput`, `status`, optional `rawOutput`, and optional `error`; + - `PermissionRequestedEvent` with `sessionID`, `requestID`, `toolCall: { title: string; kind?: string }`, and `options: Array<{ name: string; kind: string; optionId: string }>`; + - request/reply payload types for `POST /session/:id/permission`. +- Extend the bridge `OpenCodeEvent` union with: + - `{ type: "tool.call"; properties: ... }`; + - `{ type: "tool.call.updated"; properties: ... }`; + - `{ type: "permission.requested"; properties: ... }`. +- Extend `BridgeConfig` with `permissionMode: PermissionMode` and `permissionTimeoutMs: number`. +- Extend `GenerateTextRequest` with enough context for event translation, for example: + - `assistantMessageID: string`; + - `permissionMode: PermissionMode`; + - `publishEvent?: (event: OpenCodeEvent) => void`. +- Extend `ACPAdapter` with an optional or required permission response method, for example: + - `respondToPermission?(input: { sessionID: string; requestID: string; outcome: PermissionOutcome }): Promise`. +- Extend `SessionRecord` with `permissionMode: PermissionMode`. + +### 3. Parse Permission Config Defaults +- In `packages/nexus-acp-bridge/src/config.ts`, update `parseCliArgs()` to recognize `--permission-mode` and `--permission-mode=`. +- Accept only `auto` or `forward`; default to `auto` for backwards compatibility. +- Add `NEXUS_ACP_BRIDGE_PERMISSION_MODE` as the env fallback. +- Add `NEXUS_ACP_BRIDGE_PERMISSION_TIMEOUT_MS` with a default around `60_000`, clamped to a sane positive value. +- Ensure CLI values keep the same highest-precedence pattern as `--port`, `--host`, and `--cors`. +- Add or update config tests in `packages/nexus-acp-bridge/src/__tests__/config.test.ts` for CLI/env/default behavior. + +### 4. Update Test Helpers +- In `packages/nexus-acp-bridge/src/__tests__/test-helpers.ts`, add defaults for `permissionMode: "auto"` and `permissionTimeoutMs: 60_000` in `makeBridgeConfig()`. +- Add defaults for `assistantMessageID`, `permissionMode`, and a no-op or capturable `publishEvent` in `makeGenerateTextRequest()` as needed. +- Keep helper overrides ergonomic for tests that need `permissionMode: "forward"` or a very short timeout. + +### 5. Implement ACP Tool Event Translation +- In `packages/nexus-acp-bridge/src/adapters/acp-protocol.ts`, add helpers to normalize ACP tool-call updates safely from unknown JSON: + - extract `callID` from common fields such as `callId`, `callID`, `id`, or nested tool-call records; + - extract human-readable `title` with a fallback such as `kind` or `"Tool call"`; + - extract `kind`, `rawInput`, `rawOutput`, and error text without assuming a single ACP vendor shape; + - map ACP statuses to `"pending" | "running" | "completed" | "failed"`. +- When handling `session/update` notifications in the active generation subscription, detect `sessionUpdate === "tool_call"` and publish a `tool.call` event. +- Detect `sessionUpdate === "tool_call_update"` and publish a `tool.call.updated` event. +- Ensure every emitted tool event includes the Nexus `sessionID` from `request.session.id` and the assistant `messageID` from `request.assistantMessageID`. +- Preserve existing text streaming for `agent_message_chunk` and `agent_thought_chunk`. +- Consumers that ignore unknown events should continue to work; do not change workflow-gen/prompt-gen behavior unless type errors require a safe default branch. + +Pseudo-code shape: + +```ts +if (update.sessionUpdate === "tool_call") { + const event = toToolCallEvent(update, request.session.id, request.assistantMessageID); + if (event) request.publishEvent?.(event); + return; +} + +if (update.sessionUpdate === "tool_call_update") { + const event = toToolCallUpdatedEvent(update, request.session.id, request.assistantMessageID); + if (event) request.publishEvent?.(event); + return; +} +``` + +### 6. Implement Permission Forwarding in the ACP Adapter +- Add adapter maps for: + - Nexus session ID → ACP session ID (existing map can remain); + - ACP session ID → Nexus session ID; + - ACP session ID → current permission mode; + - pending permission request ID → resolver/timeout/session metadata. +- In `resolveAcpSession()`, populate both session maps when a new ACP session is created. +- In `generateText()`, record `request.permissionMode` for the ACP session before sending `session/prompt`. +- Keep current `handleRequestPermission()` auto-approval behavior when mode is `auto` or no Nexus session can be resolved. +- In `forward` mode: + - allocate a stable `requestID` such as `permission_${crypto.randomUUID()}`; + - normalize ACP options into `{ name, kind, optionId }[]`; + - normalize tool call metadata into `{ title, kind? }`; + - publish `permission.requested` through the active session event sink or an adapter-level event callback; + - return a promise that resolves when `respondToPermission()` is called; + - set a timeout using `config.permissionTimeoutMs`; on timeout, resolve `{ outcome: { outcome: "cancelled" } }` and remove pending state. +- Implement `respondToPermission()` to: + - validate that the pending request exists and belongs to the supplied Nexus session; + - clear the timeout; + - resolve the pending JSON-RPC response as `{ outcome: { outcome: "selected", optionId } }` or `{ outcome: { outcome: "cancelled" } }`; + - return `true` when resolved, `false` when missing/already resolved. +- On `dispose()`, clear all pending permission timeouts and resolve or reject safely so child JSON-RPC calls do not hang. + +### 7. Wire Per-Session Permission Mode and Event Publishing in the Server +- In `packages/nexus-acp-bridge/src/server/http-server.ts`, extend `CreateSessionSchema` with optional `permissionMode: z.enum(["auto", "forward"])`. +- Store `permissionMode: payload.permissionMode ?? config.permissionMode` in `SessionRecord` when creating sessions. +- In `runPromptAsync()`, pass `assistantMessageId`, `record.permissionMode`, and an event publisher into `adapter.generateText()`: + +```ts +publishEvent: (event) => this.eventBroker.publish(event, record.session.directory) +``` + +- Ensure text delta publishing remains exactly as it is today. +- Add `PermissionResponseSchema` for `POST /session/:id/permission` with: + - `requestID: string`; + - `outcome: "selected" | "cancelled"`; + - `optionId?: string`. +- Add route `POST /session/:id/permission` before the generic `DELETE /session/:id` match. +- The route should: + - require the session to exist; + - validate that selected outcomes include an `optionId`; + - call `adapter.respondToPermission` if implemented; + - return `true` for resolved requests; + - return a clear `404` or `400` error if no pending request exists or the adapter cannot handle forwarded permissions. +- Ensure CORS allows the new route through existing `POST` settings. + +### 8. Mirror Browser OpenCode Types and Optional Service Helper +- In `src/lib/opencode/types.ts`, add the same `tool.call`, `tool.call.updated`, and `permission.requested` event variants to the `OpenCodeEvent` union. +- Add exported types for the permission requested event if useful for Phase B UI code. +- If needed, extend `SessionCreatePayload` to accept optional `permissionMode?: "auto" | "forward"`; keep this optional so existing OpenCode/direct server calls remain compatible. +- Optionally add a bridge-specific method in `src/lib/opencode/services/permissions.ts`: + +```ts +async respondBridgeSession( + sessionID: string, + payload: { requestID: string; outcome: "selected" | "cancelled"; optionId?: string }, + opts?: RequestOptions, +): Promise { + return http.post(`/session/${encodeURIComponent(sessionID)}/permission`, payload, opts); +} +``` + +- Do not create side-kick UI components in this task; Phase B will consume the events and endpoint. + +### 9. Add Adapter Tests +- In `packages/nexus-acp-bridge/src/__tests__/acp-protocol-adapter.test.ts`, extend `FakeACPClient` if necessary so tests can emit tool-call updates while a generation is active. +- Add a test that emits ACP `tool_call` and `tool_call_update` notifications and asserts captured `publishEvent` calls include: + - `type: "tool.call"` with exact `sessionID`, `messageID`, `callID`, `title`, and raw input; + - `type: "tool.call.updated"` with exact `sessionID`, `messageID`, `callID`, status, and raw output/error. +- Add an auto-permission test confirming default mode still selects the first allow option exactly as before. +- Add a forward-permission test that: + - invokes the registered `session/request_permission` handler with an ACP session already mapped to a Nexus session; + - asserts a `permission.requested` event is emitted with exact `sessionID`, `requestID`, tool call title/kind, and options; + - calls `adapter.respondToPermission({ sessionID, requestID, outcome: { outcome: "selected", optionId } })`; + - asserts the handler resolves to `{ outcome: { outcome: "selected", optionId } }`. +- Add a forward-cancel test for `outcome: "cancelled"`. +- Add a timeout test using a very short `permissionTimeoutMs` and assert unresolved forwarded permissions return cancelled. + +### 10. Add Server Tests +- In `packages/nexus-acp-bridge/src/__tests__/server.test.ts`, add a small test adapter or extend `MockACPAdapter` only if appropriate to emit custom events through `GenerateTextRequest.publishEvent`. +- Add a server integration test that subscribes to `/event`, starts a session/prompt, and confirms SSE includes the new tool event payload. +- Add a test for `POST /session` with `{ "permissionMode": "forward" }` and a prompt that triggers a forwarded permission request through the test adapter, if feasible. +- Add a direct route test for `POST /session/:id/permission` that verifies: + - missing session returns 404; + - selected outcome without `optionId` returns 400; + - valid selected/cancelled payloads call the adapter and return `true`. +- Keep tests deterministic and avoid real ACP child processes. + +### 11. Update Documentation and Environment Examples +- In `.env.example`, document: + - `NEXUS_ACP_BRIDGE_PERMISSION_MODE=auto` or `forward`; + - `NEXUS_ACP_BRIDGE_PERMISSION_TIMEOUT_MS=60000`. +- In `packages/nexus-acp-bridge/README.md`, update the reverse-handler section that currently says `session/request_permission` auto-approves. +- Document that `auto` is the default for compatibility, `forward` emits `permission.requested`, and callers reply through `POST /session/:id/permission`. +- Mention that per-session `permissionMode` can be supplied when creating a bridge session, if implemented. + +### 12. Run Validation Commands +- Run all commands listed in the `Validation Commands` section. +- Fix any type, lint, test, or build failures. +- Re-run failed commands until all pass. + +## Testing Strategy +### Unit Tests +- Bridge config tests for defaults, env parsing, and CLI parsing of permission mode/timeout. +- ACP adapter tests for tool-call event translation, auto permission selection, forwarded permission selected/cancelled responses, and forwarded permission timeout. +- HTTP server tests for new route validation and SSE delivery of new event types. +- Type-level/compile validation that browser `OpenCodeEvent` can represent bridge events. + +### Edge Cases +- ACP tool-call update lacks a call ID: skip event or generate a deterministic fallback only if safe; tests should define expected behavior. +- ACP tool-call title/kind/input/output shapes differ by backend: normalize conservatively and preserve raw data fields. +- `tool_call_update` arrives before `tool_call`: still emit an update if `callID` is available so UI can reconcile later. +- No browser/SSE listener exists in `forward` mode: permission request should timeout and cancel instead of hanging forever. +- `respondToPermission()` receives an unknown, duplicate, or wrong-session `requestID`: return false/404 without resolving unrelated requests. +- Selected permission response omits `optionId`: reject with 400 before reaching adapter. +- Session abort or adapter dispose while permission is pending: clear timeout and resolve/cancel pending permission safely. +- Existing workflow/prompt generation ignores new event types and still receives text deltas normally. +- Default config remains `auto` so existing AI workflow generation does not require a permission UI. + +## Acceptance Criteria +- `packages/nexus-acp-bridge/src/types.ts` and `src/lib/opencode/types.ts` include synchronized `tool.call`, `tool.call.updated`, and `permission.requested` SSE event variants. +- ACP `tool_call` and `tool_call_update` notifications are emitted as SSE events with exact Nexus `sessionID`, assistant `messageID`, `callID`, and normalized metadata. +- Bridge permission handling defaults to existing auto-approval behavior. +- A session can opt into `permissionMode: "forward"` and receive `permission.requested` events instead of auto-approval. +- `POST /session/:id/permission` resolves pending forwarded ACP permission requests for both selected and cancelled outcomes. +- Forwarded permission requests timeout to cancelled after the configured timeout. +- Existing workflow generation and prompt generation continue to process `message.part.delta` and ignore unrelated event types without crashes. +- Bridge tests cover tool events, auto permissions, forwarded permissions, timeout behavior, and the new HTTP route. +- Documentation/env examples describe the new permission mode and timeout settings. +- All validation commands pass. + +## Validation Commands +Execute every command to validate the work is complete with zero regressions. + +Use validation commands from `.app_config.yaml` where available, plus bridge-focused checks: + +```bash +bun run test:bridge +bun run typecheck:bridge +npm run typecheck +npm run lint +npm run build +``` + +Notes: +- `.app_config.yaml` has no global test command configured, so `bun run test:bridge` is included for the touched package. +- Do not run browser/E2E commands here; this task adds bridge/API behavior and E2E is handled by a separate pipeline only when a UI-facing spec is created. + +## Notes +- The task document recommends per-session permission mode after initially suggesting a global bridge mode. Implement both a config default and per-session override so workflow generation remains safe by default while the side-kick can opt in to forwarded permissions. +- Keep `src/lib/acp/*`, `src/store/acp/*`, WebSocket `/acp`, and separate ACP connect dialogs out of scope. The browser should continue to use the OpenCode-compatible client and bridge URL. +- Preserve `fs/read_text_file` and `fs/write_text_file` sandboxing exactly; this task changes permission flow, not filesystem trust boundaries. +- If ACP vendor payload shapes are uncertain, preserve original objects in `rawInput` / `rawOutput` and normalize only the fields the side-kick needs for stable rendering. diff --git a/packages/nexus-acp-bridge/README.md b/packages/nexus-acp-bridge/README.md index e66d539..3067325 100644 --- a/packages/nexus-acp-bridge/README.md +++ b/packages/nexus-acp-bridge/README.md @@ -148,15 +148,16 @@ When `NEXUS_ACP_BRIDGE_ADAPTER=acp`, the bridge: 2. speaks the real [Agent Client Protocol](https://agentclientprotocol.com) over stdio (JSON-RPC 2.0, newline-framed by default, `Content-Length`-framed on request) 3. negotiates via `initialize` with `protocolVersion: 1` and advertises `fs.readTextFile` + `fs.writeTextFile` client capabilities 4. creates an ACP session per bridge session via `session/new`, keyed by the bridge session id -5. streams agent output from `session/update` notifications with the `agent_message_chunk` variant (text content blocks) -6. caches slash-command advertisements from `session/update` notifications with the `available_commands_update` variant and exposes them via `GET /command` -7. sends `session/cancel` when Nexus aborts a prompt +5. streams agent output from `session/update` notifications with the `agent_message_chunk` and `agent_thought_chunk` variants (text content blocks) +6. forwards `tool_call` / `tool_call_update` notifications as OpenCode-compatible `tool.call` / `tool.call.updated` SSE events with the bridge `sessionID`, assistant `messageID`, and ACP call id +7. caches slash-command advertisements from `session/update` notifications with the `available_commands_update` variant and exposes them via `GET /command` +8. sends `session/cancel` when Nexus aborts a prompt The bridge responds to agent-initiated requests: - `fs/read_text_file` — reads files inside configured project roots, honoring optional `line` / `limit` parameters - `fs/write_text_file` — writes files inside configured project roots -- `session/request_permission` — auto-approves with the first `allow_once` / `allow_always` option (or the first option if none are explicitly "allow") +- `session/request_permission` — defaults to `auto`, which preserves compatibility by selecting the first `allow_once` / `allow_always` option (or the first option if none are explicitly "allow"). Set `NEXUS_ACP_BRIDGE_PERMISSION_MODE=forward` or create a session with `{ "permissionMode": "forward" }` to emit `permission.requested` SSE events instead. Callers resolve forwarded requests with `POST /session/:id/permission` and a body such as `{ "requestID": "permission_...", "outcome": "selected", "optionId": "allow_once" }` or `{ "requestID": "permission_...", "outcome": "cancelled" }`. Unanswered forwarded requests cancel after `NEXUS_ACP_BRIDGE_PERMISSION_TIMEOUT_MS` (default `60000`). Tools, MCP status, provider metadata, and resources are served locally from the bridge's configured defaults — ACP does not expose discovery endpoints for these. Slash commands are the exception: ACP advertises them dynamically through `session/update`, and the bridge normalizes those into an OpenCode-style `GET /command` response. `POST /session/:id/command` is translated into a slash-command prompt like `/plan add tests` before it is forwarded to ACP. diff --git a/packages/nexus-acp-bridge/src/__tests__/acp-protocol-adapter.test.ts b/packages/nexus-acp-bridge/src/__tests__/acp-protocol-adapter.test.ts index d323bc2..40c082c 100644 --- a/packages/nexus-acp-bridge/src/__tests__/acp-protocol-adapter.test.ts +++ b/packages/nexus-acp-bridge/src/__tests__/acp-protocol-adapter.test.ts @@ -5,6 +5,7 @@ import type { ACPRequestHandler, ACPSessionUpdateHandler, } from "../transport/jsonrpc-client"; +import type { OpenCodeEvent } from "../types"; import { ACPProtocolAdapter } from "../adapters/acp-protocol"; import { makeBridgeConfig, makeGenerateTextRequest } from "./test-helpers"; @@ -21,6 +22,8 @@ class FakeACPClient implements ACPJsonRpcClientLike { private readonly requestHandlers = new Map(); private nextAcpSessionId = 1; sessionNewResult: unknown = null; + promptUpdates: unknown[] = []; + promptHook: ((sessionId: string) => Promise) | null = null; requestHandler: ((method: string, params: unknown) => Promise) | null = null; @@ -55,6 +58,9 @@ class FakeACPClient implements ACPJsonRpcClientLike { if (method === "session/prompt") { const sessionId = (params as { sessionId?: string })?.sessionId; if (sessionId) { + for (const update of this.promptUpdates) { + queueMicrotask(() => this.emitSessionUpdate(sessionId, update)); + } queueMicrotask(() => this.emitSessionUpdate(sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hello " }, @@ -63,6 +69,7 @@ class FakeACPClient implements ACPJsonRpcClientLike { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "world" }, })); + await this.promptHook?.(sessionId); } return { stopReason: "end_turn" } as T; } @@ -109,7 +116,7 @@ class FakeACPClient implements ACPJsonRpcClientLike { return await handler(params); } - private emitSessionUpdate(sessionId: string, update: unknown): void { + emitSessionUpdate(sessionId: string, update: unknown): void { const notification = { jsonrpc: "2.0", method: "session/update", params: { sessionId, update } } as const; for (const listener of this.notificationListeners) { listener(notification); @@ -272,7 +279,61 @@ describe("ACPProtocolAdapter", () => { } }); - test("registers fs/read_text_file and session/request_permission handlers", async () => { + test("emits tool call events with bridge session and assistant message IDs", async () => { + const client = new FakeACPClient(); + client.promptUpdates = [ + { + sessionUpdate: "tool_call", + toolCall: { callId: "call-1", title: "Read file", kind: "read", input: { path: "README.md" } }, + status: "running", + }, + { + sessionUpdate: "tool_call_update", + toolCall: { callId: "call-1", title: "Read file", kind: "read", output: { lines: 3 } }, + status: "completed", + }, + ]; + const adapter = new ACPProtocolAdapter(makeBridgeConfig({ adapterMode: "acp" }), client); + const events: unknown[] = []; + + try { + const request = { + ...makeGenerateTextRequest(), + assistantMessageID: "assistant-123", + publishEvent: (event: unknown) => events.push(event), + }; + for await (const _ of adapter.generateText(request)) { /* drain */ } + + expect(events).toContainEqual({ + type: "tool.call", + properties: { + sessionID: "session-1", + messageID: "assistant-123", + callID: "call-1", + title: "Read file", + kind: "read", + rawInput: { path: "README.md" }, + status: "running", + }, + }); + expect(events).toContainEqual({ + type: "tool.call.updated", + properties: { + sessionID: "session-1", + messageID: "assistant-123", + callID: "call-1", + title: "Read file", + kind: "read", + status: "completed", + rawOutput: { lines: 3 }, + }, + }); + } finally { + await adapter.dispose(); + } + }); + + test("registers fs/read_text_file and keeps auto permission approval by default", async () => { const client = new FakeACPClient(); const adapter = new ACPProtocolAdapter( makeBridgeConfig({ adapterMode: "acp", projectDirs: [process.cwd()] }), @@ -299,6 +360,92 @@ describe("ACPProtocolAdapter", () => { await adapter.dispose(); } }); + + test("forwards permission requests and resolves selected/cancelled responses", async () => { + const client = new FakeACPClient(); + const adapter = new ACPProtocolAdapter( + makeBridgeConfig({ adapterMode: "acp", permissionTimeoutMs: 1_000 }), + client, + ); + const events: OpenCodeEvent[] = []; + + client.promptHook = async (sessionId) => { + const pending = client.invokeRequestHandler("session/request_permission", { + sessionId, + toolCall: { title: "Write file", kind: "write" }, + options: [{ optionId: "allow_once", name: "Allow once", kind: "allow_once" }], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const requestID = events.find((event) => event.type === "permission.requested")?.properties?.requestID; + expect(requestID).toBeString(); + const resolved = await adapter.respondToPermission({ + sessionID: "session-1", + requestID: requestID as string, + outcome: { outcome: "selected", optionId: "allow_once" }, + }); + expect(resolved).toBe(true); + expect(await pending).toEqual({ outcome: { outcome: "selected", optionId: "allow_once" } }); + + const cancelPending = client.invokeRequestHandler("session/request_permission", { + sessionId, + toolCall: { title: "Run command" }, + options: [{ optionId: "reject_once", name: "Reject", kind: "reject_once" }], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const cancelID = events.filter((event) => event.type === "permission.requested").at(-1)?.properties.requestID; + expect(cancelID).toBeString(); + expect(await adapter.respondToPermission({ + sessionID: "session-1", + requestID: cancelID as string, + outcome: { outcome: "cancelled" }, + })).toBe(true); + expect(await cancelPending).toEqual({ outcome: { outcome: "cancelled" } }); + }; + + try { + const request = { + ...makeGenerateTextRequest(), + permissionMode: "forward" as const, + publishEvent: (event: OpenCodeEvent) => { events.push(event); }, + }; + for await (const _ of adapter.generateText(request)) { /* drain */ } + + expect(events[0]).toMatchObject({ + type: "permission.requested", + properties: { + sessionID: "session-1", + toolCall: { title: "Write file", kind: "write" }, + options: [{ optionId: "allow_once", name: "Allow once", kind: "allow_once" }], + }, + }); + } finally { + await adapter.dispose(); + } + }); + + test("forwarded permissions timeout to cancelled", async () => { + const client = new FakeACPClient(); + const adapter = new ACPProtocolAdapter( + makeBridgeConfig({ adapterMode: "acp", permissionTimeoutMs: 5 }), + client, + ); + let permissionResult: unknown; + + client.promptHook = async (sessionId) => { + permissionResult = await client.invokeRequestHandler("session/request_permission", { + sessionId, + toolCall: { title: "Dangerous" }, + options: [{ optionId: "allow_once", name: "Allow", kind: "allow_once" }], + }); + }; + + try { + for await (const _ of adapter.generateText({ ...makeGenerateTextRequest(), permissionMode: "forward" })) { /* drain */ } + expect(permissionResult).toEqual({ outcome: { outcome: "cancelled" } }); + } finally { + await adapter.dispose(); + } + }); }); diff --git a/packages/nexus-acp-bridge/src/__tests__/config.test.ts b/packages/nexus-acp-bridge/src/__tests__/config.test.ts index b87ea9f..59321e9 100644 --- a/packages/nexus-acp-bridge/src/__tests__/config.test.ts +++ b/packages/nexus-acp-bridge/src/__tests__/config.test.ts @@ -74,6 +74,34 @@ describe("bridge config", () => { expect(config.serverIdleTimeoutSeconds).toBe(45); }); + test("defaults permission handling to auto with a 60 second timeout", () => { + delete process.env.NEXUS_ACP_BRIDGE_PERMISSION_MODE; + delete process.env.NEXUS_ACP_BRIDGE_PERMISSION_TIMEOUT_MS; + + const config = loadBridgeConfig([]); + + expect(config.permissionMode).toBe("auto"); + expect(config.permissionTimeoutMs).toBe(60_000); + }); + + test("parses permission mode and timeout from env", () => { + process.env.NEXUS_ACP_BRIDGE_PERMISSION_MODE = "forward"; + process.env.NEXUS_ACP_BRIDGE_PERMISSION_TIMEOUT_MS = "1234"; + + const config = loadBridgeConfig([]); + + expect(config.permissionMode).toBe("forward"); + expect(config.permissionTimeoutMs).toBe(1234); + }); + + test("CLI permission mode overrides env and invalid values fall back to auto", () => { + process.env.NEXUS_ACP_BRIDGE_PERMISSION_MODE = "invalid"; + expect(loadBridgeConfig([]).permissionMode).toBe("auto"); + + const config = loadBridgeConfig(["--permission-mode=forward"]); + expect(config.permissionMode).toBe("forward"); + }); + test("explicit environment variables override bundled defaults", () => { process.env.NEXUS_ACP_BRIDGE_ADAPTER = "stdio"; process.env.NEXUS_ACP_BRIDGE_AGENT_COMMAND = "custom-agent"; diff --git a/packages/nexus-acp-bridge/src/__tests__/server.test.ts b/packages/nexus-acp-bridge/src/__tests__/server.test.ts index b842e9e..b3ec7f8 100644 --- a/packages/nexus-acp-bridge/src/__tests__/server.test.ts +++ b/packages/nexus-acp-bridge/src/__tests__/server.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { MockACPAdapter } from "../adapters/mock"; import { NexusACPBridgeServer } from "../server/http-server"; +import type { GenerateTextRequest, PermissionOutcome } from "../types"; import { makeBridgeConfig } from "./test-helpers"; const activeServers: NexusACPBridgeServer[] = []; @@ -11,6 +12,29 @@ afterEach(() => { } }); +class EventingAdapter extends MockACPAdapter { + permissionCalls: Array<{ sessionID: string; requestID: string; outcome: PermissionOutcome }> = []; + + async *generateText(request: GenerateTextRequest): AsyncIterable { + request.publishEvent?.({ + type: "tool.call", + properties: { + sessionID: request.session.id, + messageID: request.assistantMessageID, + callID: "call-server-1", + title: "Server tool", + status: "running", + }, + }); + yield "server output"; + } + + async respondToPermission(input: { sessionID: string; requestID: string; outcome: PermissionOutcome }): Promise { + this.permissionCalls.push(input); + return input.requestID === "pending-1"; + } +} + function startTestServer() { const config = makeBridgeConfig({ port: 0 }); const bridge = new NexusACPBridgeServer(config, new MockACPAdapter(config)); @@ -84,6 +108,93 @@ describe("NexusACPBridgeServer", () => { expect(message.parts[0]?.text).toContain("/plan triage production incidents"); }); + test("publishes adapter tool events over SSE", async () => { + const config = makeBridgeConfig({ port: 0 }); + const bridge = new NexusACPBridgeServer(config, new EventingAdapter(config)); + const server = bridge.start(); + activeServers.push(bridge); + const baseUrl = `http://${server.hostname}:${server.port}`; + + const eventResponse = await fetch(`${baseUrl}/event`); + const reader = eventResponse.body?.getReader(); + expect(reader).toBeDefined(); + + const sessionResponse = await fetch(`${baseUrl}/session`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "SSE" }), + }); + const session = await sessionResponse.json() as { id: string }; + await fetch(`${baseUrl}/session/${encodeURIComponent(session.id)}/message`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }), + }); + + const decoder = new TextDecoder(); + let body = ""; + for (let index = 0; index < 10 && !body.includes("tool.call"); index += 1) { + const chunk = await reader!.read(); + if (chunk.done) break; + body += decoder.decode(chunk.value); + } + await reader!.cancel(); + + expect(body).toContain('"type":"tool.call"'); + expect(body).toContain('"callID":"call-server-1"'); + }); + + test("accepts per-session forward permission mode and validates permission replies", async () => { + const config = makeBridgeConfig({ port: 0 }); + const adapter = new EventingAdapter(config); + const bridge = new NexusACPBridgeServer(config, adapter); + const server = bridge.start(); + activeServers.push(bridge); + const baseUrl = `http://${server.hostname}:${server.port}`; + + const missing = await fetch(`${baseUrl}/session/missing/permission`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestID: "pending-1", outcome: "cancelled" }), + }); + expect(missing.status).toBe(404); + + const sessionResponse = await fetch(`${baseUrl}/session`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "Forward", permissionMode: "forward" }), + }); + const session = await sessionResponse.json() as { id: string }; + expect(sessionResponse.ok).toBe(true); + + const invalid = await fetch(`${baseUrl}/session/${encodeURIComponent(session.id)}/permission`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestID: "pending-1", outcome: "selected" }), + }); + expect(invalid.status).toBe(400); + + const valid = await fetch(`${baseUrl}/session/${encodeURIComponent(session.id)}/permission`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestID: "pending-1", outcome: "selected", optionId: "allow_once" }), + }); + expect(valid.ok).toBe(true); + expect(await valid.json()).toBe(true); + expect(adapter.permissionCalls[0]).toEqual({ + sessionID: session.id, + requestID: "pending-1", + outcome: { outcome: "selected", optionId: "allow_once" }, + }); + + const cancelled = await fetch(`${baseUrl}/session/${encodeURIComponent(session.id)}/permission`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestID: "pending-1", outcome: "cancelled" }), + }); + expect(cancelled.ok).toBe(true); + }); + test("falls back to a random port when the configured port is already in use", async () => { const firstConfig = makeBridgeConfig({ port: 0 }); const firstBridge = new NexusACPBridgeServer(firstConfig, new MockACPAdapter(firstConfig)); diff --git a/packages/nexus-acp-bridge/src/__tests__/test-helpers.ts b/packages/nexus-acp-bridge/src/__tests__/test-helpers.ts index 036ee9f..ff1d5f4 100644 --- a/packages/nexus-acp-bridge/src/__tests__/test-helpers.ts +++ b/packages/nexus-acp-bridge/src/__tests__/test-helpers.ts @@ -23,6 +23,8 @@ export function makeBridgeConfig(overrides: Partial = {}): BridgeC acpProtocolVersion: 1, mockStreamDelayMs: 0, maxFileReadBytes: 2 * 1024 * 1024, + permissionMode: "auto", + permissionTimeoutMs: 60_000, ...overrides, }; } @@ -51,6 +53,9 @@ export function makeGenerateTextRequest(): GenerateTextRequest { model: { providerID: "acp", modelID: "model" }, }, signal: new AbortController().signal, + assistantMessageID: "assistant-message-1", + permissionMode: "auto", + publishEvent: () => {}, }; } diff --git a/packages/nexus-acp-bridge/src/adapters/acp-protocol.ts b/packages/nexus-acp-bridge/src/adapters/acp-protocol.ts index 02144d9..b2cbe39 100644 --- a/packages/nexus-acp-bridge/src/adapters/acp-protocol.ts +++ b/packages/nexus-acp-bridge/src/adapters/acp-protocol.ts @@ -16,6 +16,9 @@ import type { GenerateTextRequest, HealthInfo, MCPStatus, + OpenCodeEvent, + PermissionMode, + PermissionOutcome, McpResource, Model, Project, @@ -48,6 +51,117 @@ function isPathInside(root: string, candidate: string): boolean { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } +function pickFirstString(record: Record | null | undefined, keys: string[]): string | null { + if (!record) return null; + for (const key of keys) { + const value = asString(record[key]); + if (value) return value; + } + return null; +} + +function extractToolRecord(update: Record): Record { + return asRecord(update.toolCall) ?? asRecord(update.tool_call) ?? asRecord(update.call) ?? asRecord(update.content) ?? update; +} + +function extractCallID(update: Record): string | null { + const tool = extractToolRecord(update); + return pickFirstString(tool, ["callId", "callID", "call_id", "id", "toolCallId", "toolCallID"]) + ?? pickFirstString(update, ["callId", "callID", "call_id", "id", "toolCallId", "toolCallID"]); +} + +function extractTitle(update: Record): string { + const tool = extractToolRecord(update); + return pickFirstString(tool, ["title", "name", "tool", "kind"]) + ?? pickFirstString(update, ["title", "name", "tool", "kind"]) + ?? "Tool call"; +} + +function extractKind(update: Record): string | undefined { + const tool = extractToolRecord(update); + return pickFirstString(tool, ["kind", "type", "tool"]) + ?? pickFirstString(update, ["kind", "tool"]) + ?? undefined; +} + +function extractRawInput(update: Record): unknown { + const tool = extractToolRecord(update); + return tool.input ?? tool.arguments ?? tool.params ?? update.input ?? update.arguments; +} + +function extractRawOutput(update: Record): unknown { + const tool = extractToolRecord(update); + return tool.output ?? tool.result ?? tool.content ?? update.output ?? update.result; +} + +function extractError(update: Record): string | undefined { + const tool = extractToolRecord(update); + const raw = tool.error ?? update.error; + if (typeof raw === "string" && raw.length > 0) return raw; + const record = asRecord(raw); + return asString(record?.message) ?? undefined; +} + +function normalizeToolStatus(update: Record): "pending" | "running" | "completed" | "failed" { + const tool = extractToolRecord(update); + const status = (asString(tool.status) ?? asString(update.status) ?? "").toLowerCase(); + if (["completed", "complete", "done", "success", "succeeded"].includes(status)) return "completed"; + if (["failed", "error", "errored", "cancelled", "canceled"].includes(status)) return "failed"; + if (["running", "in_progress", "started"].includes(status)) return "running"; + return update.sessionUpdate === "tool_call" ? "running" : "pending"; +} + +function toToolCallEvent(update: Record, sessionID: string, messageID: string): OpenCodeEvent | null { + const callID = extractCallID(update); + if (!callID) return null; + const rawInput = extractRawInput(update); + return { + type: "tool.call", + properties: { + sessionID, + messageID, + callID, + title: extractTitle(update), + ...(extractKind(update) ? { kind: extractKind(update) } : {}), + ...(rawInput !== undefined ? { rawInput } : {}), + status: normalizeToolStatus(update), + }, + }; +} + +function toToolCallUpdatedEvent(update: Record, sessionID: string, messageID: string): OpenCodeEvent | null { + const callID = extractCallID(update); + if (!callID) return null; + const rawInput = extractRawInput(update); + const rawOutput = extractRawOutput(update); + const error = extractError(update); + return { + type: "tool.call.updated", + properties: { + sessionID, + messageID, + callID, + title: extractTitle(update), + ...(extractKind(update) ? { kind: extractKind(update) } : {}), + ...(rawInput !== undefined ? { rawInput } : {}), + status: error ? "failed" : normalizeToolStatus(update), + ...(rawOutput !== undefined ? { rawOutput } : {}), + ...(error ? { error } : {}), + }, + }; +} + +function normalizePermissionOptions(options: unknown): Array<{ name: string; kind: string; optionId: string }> { + if (!Array.isArray(options)) return []; + return options.flatMap((entry) => { + const record = asRecord(entry); + const optionId = asString(record?.optionId) ?? asString(record?.id); + if (!record || !optionId) return []; + const kind = asString(record.kind) ?? "option"; + return [{ optionId, kind, name: asString(record.name) ?? asString(record.label) ?? optionId }]; + }); +} + function pickAllowOptionId(options: unknown): string | null { if (!Array.isArray(options)) return null; for (const entry of options) { @@ -201,9 +315,20 @@ function extractConfigProvidersFromSession(result: unknown): ConfigProviders | n }; } +interface PendingPermission { + sessionID: string; + acpSessionId: string; + timeout: ReturnType; + resolve: (value: { outcome: PermissionOutcome }) => void; +} + export class ACPProtocolAdapter implements ACPAdapter { private readonly client: ACPJsonRpcClientLike; private readonly sessionMap = new Map(); + private readonly acpToNexusSessionMap = new Map(); + private readonly acpPermissionModeMap = new Map(); + private readonly activePublishers = new Map void>(); + private readonly pendingPermissions = new Map(); private readonly commandDiscoverySessionMap = new Map(); private readonly commandCache = new Map(); private readonly commandReadyResolvers = new Map void>(); @@ -247,6 +372,14 @@ export class ACPProtocolAdapter implements ACPAdapter { } async dispose(): Promise { + for (const [requestID, pending] of this.pendingPermissions) { + clearTimeout(pending.timeout); + pending.resolve({ outcome: { outcome: "cancelled" } }); + this.pendingPermissions.delete(requestID); + } + this.acpToNexusSessionMap.clear(); + this.acpPermissionModeMap.clear(); + this.activePublishers.clear(); this.commandDiscoverySessionMap.clear(); this.commandCache.clear(); this.commandReadyResolvers.clear(); @@ -314,6 +447,8 @@ export class ACPProtocolAdapter implements ACPAdapter { async *generateText(request: GenerateTextRequest): AsyncIterable { await this.ensureInitialized(); const acpSessionId = await this.resolveAcpSession(request); + this.acpPermissionModeMap.set(acpSessionId, request.permissionMode); + if (request.publishEvent) this.activePublishers.set(acpSessionId, request.publishEvent); const queue = new AsyncQueue(); @@ -327,6 +462,18 @@ export class ACPProtocolAdapter implements ACPAdapter { // as text content surfaces visible streaming for every agent backend // without losing fidelity — the downstream JSON parser ignores // non-JSON prose anyway. + if (update.sessionUpdate === "tool_call") { + const event = toToolCallEvent(update, request.session.id, request.assistantMessageID); + if (event) request.publishEvent?.(event); + return; + } + + if (update.sessionUpdate === "tool_call_update") { + const event = toToolCallUpdatedEvent(update, request.session.id, request.assistantMessageID); + if (event) request.publishEvent?.(event); + return; + } + if ( update.sessionUpdate !== "agent_message_chunk" && update.sessionUpdate !== "agent_thought_chunk" @@ -379,9 +526,19 @@ export class ACPProtocolAdapter implements ACPAdapter { } finally { request.signal.removeEventListener("abort", abortHandler); unsubscribe(); + if (request.publishEvent) this.activePublishers.delete(acpSessionId); } } + async respondToPermission(input: { sessionID: string; requestID: string; outcome: PermissionOutcome }): Promise { + const pending = this.pendingPermissions.get(input.requestID); + if (!pending || pending.sessionID !== input.sessionID) return false; + this.pendingPermissions.delete(input.requestID); + clearTimeout(pending.timeout); + pending.resolve({ outcome: input.outcome }); + return true; + } + private ensureInitialized(): Promise { if (this.initialized) return Promise.resolve(); if (this.initializePromise) return this.initializePromise; @@ -425,6 +582,7 @@ export class ACPProtocolAdapter implements ACPAdapter { } this.sessionMap.set(request.session.id, acpSessionId); + this.acpToNexusSessionMap.set(acpSessionId, request.session.id); return acpSessionId; } @@ -502,13 +660,52 @@ export class ACPProtocolAdapter implements ACPAdapter { return {}; } - private async handleRequestPermission(params: unknown): Promise<{ outcome: unknown }> { + private async handleRequestPermission(params: unknown): Promise<{ outcome: PermissionOutcome }> { const record = asRecord(params); - const optionId = pickAllowOptionId(record?.options); - if (optionId) { - return { outcome: { outcome: "selected", optionId } }; + if (!record) return { outcome: { outcome: "cancelled" } }; + const acpSessionId = asString(record.sessionId) ?? asString(record.sessionID); + const nexusSessionId = acpSessionId ? this.acpToNexusSessionMap.get(acpSessionId) : null; + const mode = acpSessionId ? this.acpPermissionModeMap.get(acpSessionId) : null; + + if (mode !== "forward" || !acpSessionId || !nexusSessionId) { + const optionId = pickAllowOptionId(record.options); + if (optionId) { + return { outcome: { outcome: "selected", optionId } }; + } + return { outcome: { outcome: "cancelled" } }; } - return { outcome: { outcome: "cancelled" } }; + + const requestID = `permission_${crypto.randomUUID()}`; + const toolRecord = asRecord(record.toolCall) ?? asRecord(record.tool_call) ?? record; + const toolCall = { + title: extractTitle(toolRecord), + ...(extractKind(toolRecord) ? { kind: extractKind(toolRecord) } : {}), + }; + const options = normalizePermissionOptions(record.options); + const publisher = this.activePublishers.get(acpSessionId); + publisher?.({ + type: "permission.requested", + properties: { + sessionID: nexusSessionId, + requestID, + toolCall, + options, + }, + }); + + return await new Promise<{ outcome: PermissionOutcome }>((resolve) => { + const timeout = setTimeout(() => { + this.pendingPermissions.delete(requestID); + resolve({ outcome: { outcome: "cancelled" } }); + }, this.config.permissionTimeoutMs); + + this.pendingPermissions.set(requestID, { + sessionID: nexusSessionId, + acpSessionId, + timeout, + resolve, + }); + }); } private requirePathInsideProject(requestedPath: string): string { diff --git a/packages/nexus-acp-bridge/src/config.ts b/packages/nexus-acp-bridge/src/config.ts index 4520551..0b8e225 100644 --- a/packages/nexus-acp-bridge/src/config.ts +++ b/packages/nexus-acp-bridge/src/config.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { BRIDGE_TOOL_PRESET_IDS, getBridgeToolPreset } from "./tool-presets"; -import type { BridgeConfig } from "./types"; +import type { BridgeConfig, PermissionMode } from "./types"; const BUNDLED_ENV_FILES = [ new URL("../.env.defaults", import.meta.url), @@ -67,12 +67,14 @@ function parseCliArgs(argv: string[]): { host: string | null; projectDirs: string[]; autoSetupClaude: boolean | null; + permissionMode: string | null; } { let tool: string | null = null; let cors: string | null = null; let port: string | null = null; let host: string | null = null; let autoSetupClaude: boolean | null = null; + let permissionMode: string | null = null; const projectDirs: string[] = []; const takeValue = (flagName: string, index: number): { value: string | null; consumed: number } => { @@ -118,6 +120,13 @@ function parseCliArgs(argv: string[]): { continue; } + if (arg === "--permission-mode" || arg.startsWith("--permission-mode=")) { + const { value, consumed } = takeValue("--permission-mode", index); + permissionMode = value; + index += consumed; + continue; + } + if (arg === "--project-dir" || arg.startsWith("--project-dir=")) { const { value, consumed } = takeValue("--project-dir", index); if (value) projectDirs.push(value); @@ -135,7 +144,7 @@ function parseCliArgs(argv: string[]): { } } - return { tool, cors, port, host, projectDirs, autoSetupClaude }; + return { tool, cors, port, host, projectDirs, autoSetupClaude, permissionMode }; } const TOOL_ALIASES: Record = { @@ -188,6 +197,11 @@ function readNumber(name: string, fallback: number): number { return Number.isFinite(parsed) ? parsed : fallback; } +function readPermissionMode(name: string, fallback: PermissionMode): PermissionMode { + const raw = readEnv(name)?.toLowerCase(); + return raw === "auto" || raw === "forward" ? raw : fallback; +} + function readCsv(name: string): string[] { const raw = readEnv(name); if (!raw) return []; @@ -226,11 +240,14 @@ export function loadBridgeConfig(argv: string[] = process.argv.slice(2)): Bridge if (cliArgs.autoSetupClaude !== null) { process.env.NEXUS_ACP_BRIDGE_AUTO_SETUP_CLAUDE = cliArgs.autoSetupClaude ? "1" : "0"; } + if (cliArgs.permissionMode) { + process.env.NEXUS_ACP_BRIDGE_PERMISSION_MODE = cliArgs.permissionMode; + } const shellSnapshot: Record = { ...process.env }; applyBundledEnvDefaults(shellSnapshot); const selectedTool = resolveToolAlias( - cliArgs.tool ?? shellSnapshot.NEXUS_ACP_BRIDGE_TOOL ?? readEnv("NEXUS_ACP_BRIDGE_TOOL") ?? null, + cliArgs.tool ?? shellSnapshot.NEXUS_ACP_BRIDGE_TOOL ?? readEnv("NEXUS_ACP_BRIDGE_TOOL") ?? "claude-code", ); applyToolPreset(selectedTool, shellSnapshot); @@ -252,6 +269,7 @@ export function loadBridgeConfig(argv: string[] = process.argv.slice(2)): Bridge const acpProtocol = readEnv("NEXUS_ACP_BRIDGE_ACP_PROTOCOL") === "content-length" ? "content-length" : "newline"; + const permissionTimeoutMs = Math.max(1, Math.floor(readNumber("NEXUS_ACP_BRIDGE_PERMISSION_TIMEOUT_MS", 60_000))); return { adapterMode, @@ -283,6 +301,8 @@ export function loadBridgeConfig(argv: string[] = process.argv.slice(2)): Bridge acpProtocolVersion: readNumber("NEXUS_ACP_BRIDGE_ACP_PROTOCOL_VERSION", 1), mockStreamDelayMs: readNumber("NEXUS_ACP_BRIDGE_STREAM_DELAY_MS", 12), maxFileReadBytes: readNumber("NEXUS_ACP_BRIDGE_MAX_FILE_READ_BYTES", 2 * 1024 * 1024), + permissionMode: readPermissionMode("NEXUS_ACP_BRIDGE_PERMISSION_MODE", "auto"), + permissionTimeoutMs, }; } diff --git a/packages/nexus-acp-bridge/src/index.ts b/packages/nexus-acp-bridge/src/index.ts index 6df7f2d..1254b60 100644 --- a/packages/nexus-acp-bridge/src/index.ts +++ b/packages/nexus-acp-bridge/src/index.ts @@ -27,6 +27,8 @@ export { export type { ACPAdapter, BridgeConfig, + BridgePermissionRequestPayload, + BridgePermissionResponsePayload, Command, ConfigProviders, GenerateTextRequest, @@ -34,8 +36,13 @@ export type { MCPStatus, McpResource, Model, + OpenCodeEvent, + PermissionMode, + PermissionOutcome, Project, Provider, + ToolCallEventProperties, + ToolCallUpdatedEventProperties, ToolListItem, } from "./types"; diff --git a/packages/nexus-acp-bridge/src/server/http-server.ts b/packages/nexus-acp-bridge/src/server/http-server.ts index 3b8d2ca..42f9d7c 100644 --- a/packages/nexus-acp-bridge/src/server/http-server.ts +++ b/packages/nexus-acp-bridge/src/server/http-server.ts @@ -11,6 +11,7 @@ import type { MessageWithParts, OpenCodeEvent, Part, + PermissionOutcome, Project, PromptPayload, Session, @@ -49,10 +50,23 @@ const PromptPayloadSchema = z.object({ parts: z.array(PromptPartSchema).min(1, "At least one prompt part is required"), }); +const PermissionModeSchema = z.enum(["auto", "forward"]); + const CreateSessionSchema = z.object({ title: z.string().optional(), + permissionMode: PermissionModeSchema.optional(), }).partial(); +const PermissionResponseSchema = z.object({ + requestID: z.string().min(1, "requestID is required"), + outcome: z.enum(["selected", "cancelled"]), + optionId: z.string().optional(), +}).superRefine((value, ctx) => { + if (value.outcome === "selected" && !value.optionId) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["optionId"], message: "optionId is required when outcome is selected" }); + } +}); + const CommandPayloadSchema = z.object({ messageID: z.string().optional(), agent: z.string().optional(), @@ -418,7 +432,7 @@ export class NexusACPBridgeServer { if (request.method === "POST" && pathname === "/session") { const payload = await this.readJsonWithSchema(request, CreateSessionSchema); const project = await this.resolveProject(new URL(request.url).searchParams); - const session = this.createSession(project, payload.title); + const session = this.createSession(project, payload.title, payload.permissionMode ?? this.config.permissionMode); return withCors(json(session), this.config); } @@ -460,6 +474,28 @@ export class NexusACPBridgeServer { return withCors(json(true), this.config); } + const sessionPermissionMatch = pathname.match(/^\/session\/([^/]+)\/permission$/); + if (request.method === "POST" && sessionPermissionMatch) { + const sessionId = decodeURIComponent(sessionPermissionMatch[1] ?? ""); + this.requireSession(sessionId); + const payload = await this.readJsonWithSchema(request, PermissionResponseSchema); + if (!this.adapter.respondToPermission) { + throw new HttpBridgeError(400, "Adapter does not support forwarded permission responses"); + } + const outcome: PermissionOutcome = payload.outcome === "selected" + ? { outcome: "selected", optionId: payload.optionId as string } + : { outcome: "cancelled" }; + const resolved = await this.adapter.respondToPermission({ + sessionID: sessionId, + requestID: payload.requestID, + outcome, + }); + if (!resolved) { + throw new HttpBridgeError(404, `Pending permission request not found: ${payload.requestID}`); + } + return withCors(json(true), this.config); + } + const sessionAbortMatch = pathname.match(/^\/session\/([^/]+)\/abort$/); if (request.method === "POST" && sessionAbortMatch) { const sessionId = decodeURIComponent(sessionAbortMatch[1] ?? ""); @@ -652,7 +688,7 @@ export class NexusACPBridgeServer { return candidate; } - private createSession(project: Project, title?: string): Session { + private createSession(project: Project, title?: string, permissionMode = this.config.permissionMode): Session { const session: Session = { id: createId("session"), slug: slugify(title ?? project.name ?? "nexus-session"), @@ -672,6 +708,7 @@ export class NexusACPBridgeServer { messages: [], abortController: null, status: "idle", + permissionMode, }); return session; @@ -765,6 +802,9 @@ export class NexusACPBridgeServer { project: record.project, payload, signal: abortController.signal, + assistantMessageID: assistantMessageId, + permissionMode: record.permissionMode, + publishEvent: (event) => this.eventBroker.publish(event, record.session.directory), })) { if (abortController.signal.aborted) { break; diff --git a/packages/nexus-acp-bridge/src/types.ts b/packages/nexus-acp-bridge/src/types.ts index 1a454b5..d154629 100644 --- a/packages/nexus-acp-bridge/src/types.ts +++ b/packages/nexus-acp-bridge/src/types.ts @@ -165,9 +165,52 @@ export interface MessageWithParts { parts: Part[]; } +export type PermissionMode = "auto" | "forward"; + +export type PermissionOutcome = + | { outcome: "selected"; optionId: string } + | { outcome: "cancelled" }; + +export interface BridgePermissionOption { + name: string; + kind: string; + optionId: string; +} + +export interface BridgePermissionRequestPayload { + sessionID: string; + requestID: string; + toolCall: { title: string; kind?: string }; + options: BridgePermissionOption[]; +} + +export interface BridgePermissionResponsePayload { + requestID: string; + outcome: PermissionOutcome["outcome"]; + optionId?: string; +} + +export interface ToolCallEventProperties { + sessionID: string; + messageID: string; + callID: string; + title: string; + kind?: string; + rawInput?: unknown; + status: "pending" | "running" | "completed" | "failed"; +} + +export interface ToolCallUpdatedEventProperties extends ToolCallEventProperties { + rawOutput?: unknown; + error?: string; +} + export type OpenCodeEvent = | { type: "message.updated"; properties: { info: Message } } | { type: "message.part.delta"; properties: { sessionID: string; messageID: string; partID: string; field: string; delta: string } } + | { type: "tool.call"; properties: ToolCallEventProperties } + | { type: "tool.call.updated"; properties: ToolCallUpdatedEventProperties } + | { type: "permission.requested"; properties: BridgePermissionRequestPayload } | { type: "session.updated"; properties: { info: Session } } | { type: "session.idle"; properties: { sessionID: string } } | { type: "session.error"; properties: { sessionID?: string; error?: { name: string; data?: { message?: string } } } }; @@ -218,6 +261,8 @@ export interface BridgeConfig { acpProtocolVersion: number; mockStreamDelayMs: number; maxFileReadBytes: number; + permissionMode: PermissionMode; + permissionTimeoutMs: number; } export interface GenerateTextRequest { @@ -225,6 +270,9 @@ export interface GenerateTextRequest { project: Project; payload: PromptPayload; signal: AbortSignal; + assistantMessageID: string; + permissionMode: PermissionMode; + publishEvent?: (event: OpenCodeEvent) => void; } export interface ACPAdapter { @@ -235,6 +283,7 @@ export interface ACPAdapter { getMcpStatus(input: { project: Project }): Promise>; listResources(input: { project: Project }): Promise>; generateText(request: GenerateTextRequest): AsyncIterable; + respondToPermission?(input: { sessionID: string; requestID: string; outcome: PermissionOutcome }): Promise; dispose?(): Promise | void; } @@ -244,6 +293,7 @@ export interface SessionRecord { messages: MessageWithParts[]; abortController: AbortController | null; status: "idle" | "busy"; + permissionMode: PermissionMode; } export interface ResolvedDirectory { diff --git a/src/lib/opencode/services/permissions.ts b/src/lib/opencode/services/permissions.ts index e9af5dd..824c9da 100644 --- a/src/lib/opencode/services/permissions.ts +++ b/src/lib/opencode/services/permissions.ts @@ -1,5 +1,5 @@ import type { HttpClient, RequestOptions } from "../client"; -import type { PermissionRequest, PermissionReply } from "../types"; +import type { BridgePermissionResponsePayload, PermissionRequest, PermissionReply } from "../types"; export function createPermissionService(http: HttpClient) { return { @@ -38,6 +38,19 @@ export function createPermissionService(http: HttpClient) { opts, ); }, + + /** POST /session/{sessionID}/permission — Reply to an ACP bridge forwarded permission request. */ + async respondBridgeSession( + sessionID: string, + payload: BridgePermissionResponsePayload, + opts?: RequestOptions, + ): Promise { + return http.post( + `/session/${encodeURIComponent(sessionID)}/permission`, + payload, + opts, + ); + }, } as const; } diff --git a/src/lib/opencode/types.ts b/src/lib/opencode/types.ts index f9bc9e8..178b4e6 100644 --- a/src/lib/opencode/types.ts +++ b/src/lib/opencode/types.ts @@ -130,6 +130,42 @@ export type PermissionObjectConfig = Record; export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig; export type PermissionConfig = Record | PermissionActionConfig; +export type BridgePermissionMode = "auto" | "forward"; + +export interface BridgePermissionOption { + name: string; + kind: string; + optionId: string; +} + +export interface BridgePermissionRequestedEvent { + sessionID: string; + requestID: string; + toolCall: { title: string; kind?: string }; + options: BridgePermissionOption[]; +} + +export interface BridgePermissionResponsePayload { + requestID: string; + outcome: "selected" | "cancelled"; + optionId?: string; +} + +export interface BridgeToolCallEventProperties { + sessionID: string; + messageID: string; + callID: string; + title: string; + kind?: string; + rawInput?: unknown; + status: "pending" | "running" | "completed" | "failed"; +} + +export interface BridgeToolCallUpdatedEventProperties extends BridgeToolCallEventProperties { + rawOutput?: unknown; + error?: string; +} + export interface PermissionRequest { id: string; sessionID: string; @@ -401,6 +437,7 @@ export interface SessionCreatePayload { parentID?: string; title?: string; permission?: PermissionRuleset; + permissionMode?: BridgePermissionMode; } export interface SessionUpdatePayload { @@ -1004,6 +1041,9 @@ export type OpenCodeEvent = | { type: "message.removed"; properties: { sessionID: string; messageID: string } } | { type: "message.part.updated"; properties: { part: Part } } | { type: "message.part.delta"; properties: { sessionID: string; messageID: string; partID: string; field: string; delta: string } } + | { type: "tool.call"; properties: BridgeToolCallEventProperties } + | { type: "tool.call.updated"; properties: BridgeToolCallUpdatedEventProperties } + | { type: "permission.requested"; properties: BridgePermissionRequestedEvent } | { type: "message.part.removed"; properties: { sessionID: string; messageID: string; partID: string } } | { type: "session.compacted"; properties: { sessionID: string } } | { type: "session.created"; properties: { info: Session } }