diff --git a/.gitignore b/.gitignore index 84ea765..09604a1 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,5 @@ _workspace* /.nexus-brain/ /.nexus-collab/ +/.adw/config.yaml +/.adw/templates/.prefs.json diff --git a/CLAUDE.md b/CLAUDE.md index 5aba393..02d97b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ Keep the mental model high-level: - `src/hooks/` — reusable editor and data hooks - `src/types/` — shared type definitions - `docs/tasks/` — task-specific plans and notes -- `packages/` — auxiliary packages such as `nexus-acp-bridge` +- `packages/` — auxiliary packages such as `nexus-acp-bridge` (see `packages/nexus-acp-bridge/CLAUDE.md` for package-scoped guidance) --- diff --git a/README.md b/README.md index b2fe9eb..fd86d34 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,14 @@ Nexus is a visual workflow editor for designing, composing, and exporting AI wor - Export generated files as a ZIP or write them directly into a target folder - Include generated `run-.sh` and `run-.bat` helper scripts with exported workflow artifacts +### 🔎 Workspace Research and Planning + +- Open a workspace-native Research surface from every workspace dashboard at `/workspace/[id]/research` +- Create research spaces from Research Brief, PRD, Implementation Plan, and Decision Log templates +- Capture tile-based notes, tasks, sources, relationships, syntheses, and promote curated findings into Brain +- Import/export portable `.nodepad` files and copy/export research as markdown +- Research editing stays local-first when optional AI connector features are disconnected + ### 📝 Content and Agent Authoring - Fullscreen editing for prompts and documents @@ -119,16 +127,48 @@ bun run docker:down ### 3. Use AI features (optional) -AI features require a running [OpenCode](https://github.com/nichochar/opencode) server. +AI features require a running [OpenCode](https://github.com/nichochar/opencode) server **or** a compatible bridge endpoint such as the bundled ACP bridge. + +Nexus now defaults its connection UI to the bundled ACP bridge endpoint (`http://127.0.0.1:4080`). The connect dialog includes quick-start presets for: + +- Claude Code via the ACP bridge +- OpenCode via the ACP bridge +- Direct OpenCode server mode ```bash bun add -g opencode-ai opencode serve --cors http://localhost:3000 ``` -Then connect from the Nexus header. +Then connect from the Nexus header. If you prefer the bundled bridge, use one of the ACP presets below instead. + +### Optional: run the bundled ACP bridge + +The repository also includes a minimal ACP bridge under `packages/nexus-acp-bridge/`. It exposes the subset of the OpenCode-style HTTP/SSE API that Nexus currently uses, so you can point Nexus at the bridge URL instead of a direct OpenCode server. + +```bash +bun run nexus-acp-bridge +``` + +That command auto-loads the checked-in defaults in `packages/nexus-acp-bridge/.env.defaults`, which currently select the `claude-code` preset. Customise it with simple flags: + +```bash +bun run nexus-acp-bridge --agent claude --cors http://localhost:3000 +bun run nexus-acp-bridge --agent codex +bun run nexus-acp-bridge --agent opencode +``` + +The first time you run `--agent claude`, the bridge auto-vendors `@agentclientprotocol/claude-agent-acp`. Pass `--no-auto-setup` to opt out. Other supported flags include `--port`, `--host`, and repeatable `--project-dir`. Environment variables still override everything. + +By default the bridge listens on: + +```text +http://127.0.0.1:4080 +``` + +You can configure it with the environment variables documented in `packages/nexus-acp-bridge/README.md` and the example file at `packages/nexus-acp-bridge/examples/.env.claude.example`. -OpenCode is only required for AI-powered features such as: +An OpenCode server or compatible bridge is only required for AI-powered features such as: - AI workflow generation - AI prompt generation and editing @@ -334,6 +374,7 @@ The timer starts after the initial refresh on server startup completes. Manual r | `Ctrl/Cmd + Alt + E` | Export workflow JSON | | `Ctrl/Cmd + Alt + G` | Open generate/export dialog | | `Ctrl/Cmd + Alt + A` | AI generate workflow | +| `Ctrl/Cmd + Alt + I` | Toggle AI side-kick | | `Ctrl/Cmd + Alt + P` | Preview generated output | | `H` / `V` | Hand tool / Selection tool | | `?` | View all shortcuts | diff --git a/docs/tasks/ai-sidekick-acp-ux-caa41bd1/e2e-ai-sidekick-acp-ux-caa41bd1.md b/docs/tasks/ai-sidekick-acp-ux-caa41bd1/e2e-ai-sidekick-acp-ux-caa41bd1.md new file mode 100644 index 0000000..308a6d9 --- /dev/null +++ b/docs/tasks/ai-sidekick-acp-ux-caa41bd1/e2e-ai-sidekick-acp-ux-caa41bd1.md @@ -0,0 +1,37 @@ +# E2E Specification — AI Side-Kick ACP UX + +## User Story + +Validate that a workflow author can open the persistent AI side-kick, ask it to inspect the canvas, apply Nexus workflow actions, approve or deny destructive actions, and respond to forwarded ACP permission requests without leaving the editor. + +## Test Steps + +> Browser driving is reserved for the E2E pipeline and should use `playwright-cli` there only. + +1. Open the side-kick via the header button and capture screenshot `sidekick-open-empty`. +2. Send `What can you tell me about this canvas?` and assert an assistant text response appears with no Nexus action card. +3. Send `Add a Prompt node named Draft Prompt and connect it after Start` using a mocked/deterministic bridge response, then assert an `addNode` card is `done`, a `connectNodes` card is `done`, and the canvas contains `Draft Prompt`; capture `sidekick-action-success`. +4. Select the created node and send `Delete this node`, assert a destructive action card appears with `Allow once`, `Allow always`, and `Deny`; capture `sidekick-approval-card`. +5. Click `Deny`, assert the node remains and the card status is `denied`. +6. Repeat delete, click `Allow once`, assert the node is removed and status is `done`. +7. Trigger a mocked forwarded ACP permission request, assert the permission card displays option buttons, choose `allow_once`, and assert it becomes resolved; capture `sidekick-permission-resolved`. +8. Click `New conversation`, assert message history clears and side-kick remains open; capture `sidekick-new-conversation`. + +## Success Criteria + +- Side-kick opens from the header and remains anchored in the bottom-right editor area. +- Text-only assistant responses render as assistant messages with no action cards. +- Nexus action cards show action names, arguments, and terminal `done` or `error` state. +- Destructive actions show exactly `Allow once`, `Allow always`, and `Deny` while awaiting approval. +- Denying a destructive action leaves the target node on the canvas and marks the card `denied`. +- Allowing once removes the target node and marks the card `done`. +- Forwarded ACP permission cards show option buttons and transition to resolved after choosing an option. +- New conversation clears visible message history, resets approvals, and keeps the panel open. + +## Screenshot Capture Points + +- `sidekick-open-empty` +- `sidekick-action-success` +- `sidekick-approval-card` +- `sidekick-permission-resolved` +- `sidekick-new-conversation` diff --git a/docs/tasks/ai-sidekick-acp-ux-caa41bd1/plan-ai-sidekick-acp-ux-caa41bd1.md b/docs/tasks/ai-sidekick-acp-ux-caa41bd1/plan-ai-sidekick-acp-ux-caa41bd1.md new file mode 100644 index 0000000..caef828 --- /dev/null +++ b/docs/tasks/ai-sidekick-acp-ux-caa41bd1/plan-ai-sidekick-acp-ux-caa41bd1.md @@ -0,0 +1,430 @@ +# feature: AI Side-Kick ACP UX + +## Metadata +adw_id: `caa41bd1` +document_description: `Plan — Phase B: AI Side-Kick (UX on top of ACP via the bridge)` + +## Description +The Task document calls for a persistent bottom-right AI side-kick for Nexus Workflow Studio. Unlike the existing one-shot `useWorkflowGenStore` prompt-to-workflow flow, this side-kick must support multi-turn chat, on-screen explanation, Nexus client-side workflow actions, inline approvals for destructive Nexus actions, forwarded ACP permission requests, streamed ACP tool-call rendering, and owner-only writes during collaboration. + +The browser should keep using the existing OpenCode-shaped client/store (`src/lib/opencode/`, `src/store/opencode/`) against the bundled `nexus-acp-bridge`; it should not introduce a separate browser-side ACP client. ACP/native agent tools are represented by bridge SSE events such as `tool.call`, `tool.call.updated`, and `permission.requested`. Nexus app actions are not native ACP tools; they are parsed from assistant text as XML blocks of the form `{...}`, dispatched client-side, and fed back to the conversation as `` follow-up messages. + +Complexity assessment: `complex` because this spans new Zustand state, streaming orchestration, parser logic, tool/action registry, OpenCode type/service updates, workflow store integration, collaboration safeguards, several UI components, keyboard/header integration, persistence, and automated tests. + +## Objective +Implement a production-ready AI side-kick panel that can: + +- Maintain one OpenCode/bridge session per conversation. +- Chat multi-turn with view context injection. +- Parse and execute Nexus client-side actions from assistant text. +- Gate destructive Nexus actions with inline approvals and ephemeral “Allow always”. +- Render forwarded ACP tool calls and ACP permission requests as inline cards. +- Mutate the current root workflow or active sub-workflow correctly. +- Respect collaboration ownership by refusing client-side writes for guests. +- Integrate cleanly with the existing editor shell, header, shortcuts, OpenCode client, and local persistence patterns. + +## Problem Statement +Nexus currently has AI-powered generation flows, but they are one-shot and workflow-generation-specific. Users need an always-available assistant that understands the current editor state and can help incrementally: answer questions, inspect selected nodes, add/connect/edit nodes, save workflows, navigate sub-workflows, and coordinate with ACP-backed agent capabilities. The application also needs safe permission UX for destructive client-side actions and forwarded agent-side tool calls. + +## Solution Statement +Add a dedicated side-kick feature module with three layers: + +1. **Runtime/store layer** under `src/store/sidekick/`: + - A Zustand store for panel state, messages, session lifecycle, pending approvals, allow-list, errors, and cancellation. + - A runner that creates/reuses bridge sessions with `permissionMode: "forward"`, subscribes to SSE before sending prompts, routes events, parses assistant action blocks, dispatches Nexus tools, and sends follow-up `` messages. + - A client-side Nexus action registry using `zod/v4` schemas and scope-aware handlers. + - Context/system-prompt helpers for compact view snapshots and action catalog instructions. + +2. **UI layer** under `src/components/workflow/sidekick/`: + - Floating draggable/collapsible panel sharing existing workflow panel primitives. + - Message list, input bar, Nexus action cards, ACP tool cards, and permission cards. + +3. **Integration layer**: + - Render the panel in `workflow-editor.tsx`. + - Add `Mod+Alt+I` toggle and shortcuts-row documentation. + - Add header toggle button. + - Shrink the properties panel when both it and side-kick are open. + - Extend OpenCode types/services for bridge session permission mode, ACP event variants, and session permission responses. + +## Code Patterns to Follow +Reference implementations: + +- `src/store/workflow-gen/workflow-generator.ts` — SSE subscription-before-send pattern, abort controller lifecycle, `message.part.delta` and `message.part.updated` handling, session creation, error-state handling. +- `src/store/workflow-gen/streaming-parser.ts` — tolerant incremental parsing approach that never throws on incomplete streams. +- `src/store/prompt-gen/runner.ts` — smaller prompt runner pattern for async OpenCode messages. +- `src/components/workflow/floating-workflow-gen.tsx` and `src/components/workflow/floating-workflow-gen/*` — floating AI panel composition and dark UI patterns. +- `src/components/workflow/floating-workflow-gen/use-floating-workflow-gen-position.ts` — draggable-position persistence pattern to adapt for bottom-right anchoring. +- `src/components/workflow/panel-primitives.ts` — canonical workflow panel shell/surface/button styling. +- `src/components/workflow/shared-header-actions.tsx` — header toggle button pattern for Library/Brain and external dialog event pattern. +- `src/components/workflow/workflow-editor.tsx` — global hotkey pattern using `isModKey`, custom events, and editable-target guards. +- `src/components/workflow/properties-panel.tsx` — panel sizing and right-side coexistence pattern. +- `src/lib/opencode/services/messages.ts`, `sessions.ts`, `permissions.ts`, `events.ts` — typed OpenCode-shaped service wrapper style. +- `src/lib/opencode/types.ts` — central API and SSE discriminated-union types. +- `src/store/opencode/connector-bus.ts` — connector invalidation pattern for session-bearing AI features. +- `src/store/workflow/store.ts` and `src/store/workflow/subworkflow.ts` — workflow/sub-workflow mutation APIs and nested sub-workflow persistence helpers. +- `src/lib/node-registry.ts` — source of truth for node-type catalog and default node data. +- `src/store/library/store.ts`, `src/store/knowledge/store.ts` — library and knowledge panel state/actions used by side-kick tools. +- `src/store/__tests__/workflow-gen/workflow-generator.test.ts` and `src/store/__tests__/prompt-gen/runner.test.ts` — store runner mocking patterns for OpenCode event streams. + +## Relevant Files +Use these files to complete the task: + +- `CLAUDE.md` — project coding rules: use Bun as package manager, `@/*` imports, Zod from `zod/v4`, dark-theme-first UI, client-heavy/localStorage assumptions, node-system guardrails, and validation expectations. +- `.app_config.yaml` — app configuration and validation commands; note it lists npm script invocations while the repository itself documents Bun. +- `README.md` — product behavior, supported AI/OpenCode/ACP bridge flows, keyboard shortcut table, and user-facing setup context. +- `docs/tasks/conditional_docs.md` — reviewed conditional documentation guide; no listed conditional document applies because this task does not modify Brain persistence or workspace routing/autosave APIs. +- `package.json` — script names and available test/typecheck/lint/build commands. +- `packages/nexus-acp-bridge/README.md` — bridge endpoint contract and currently documented OpenCode-shaped routes/events; use to verify browser-facing expectations. +- `src/lib/opencode/types.ts` — add or verify bridge-facing `permissionMode` session payload support and SSE variants for `tool.call`, `tool.call.updated`, and `permission.requested`. +- `src/lib/opencode/services/sessions.ts` — ensure `client.sessions.create({ title, permissionMode })` forwards the field. +- `src/lib/opencode/services/permissions.ts` — add a `/session/:id/permission` responder while preserving existing OpenCode permission APIs. +- `src/lib/opencode/services/index.ts` — verify any new/changed permission service export remains included. +- `src/lib/opencode/client.ts` — use existing request/stream/abort semantics; no new HTTP client should be created. +- `src/store/opencode/store.ts` — source of connected OpenCode client/status and selected model/provider state. +- `src/store/opencode/connector-bus.ts` — subscribe to connector changes and invalidate side-kick sessions. +- `src/store/workflow/store.ts` — workflow action surface for add/update/delete/select/connect/open sub-workflow/save-state operations. +- `src/store/workflow/subworkflow.ts` — nested sub-workflow update helpers for scope-aware tools. +- `src/store/workflow/helpers.ts` — workflow JSON building/fingerprint helpers used by save/mark-saved tools. +- `src/store/library/store.ts` — save/list workflow library actions used by side-kick tools. +- `src/store/knowledge/store.ts` and `src/store/knowledge-store.ts` — knowledge document panel/listing state used by side-kick tools. +- `src/store/collaboration` files — collaboration owner/guest state for owner-only write guards. +- `src/types/workflow.ts` — canonical workflow, node, edge, and node-type types for tool schemas/handlers. +- `src/lib/node-registry.ts` — node catalog for `listNodeTypes`, node creation defaults, and system prompt documentation. +- `src/lib/workflow-connections.ts` — connection normalization logic for `connectNodes`. +- `src/lib/auto-layout.ts` or existing auto-layout helper location — reuse for `autoLayout` instead of duplicating layout logic. +- `src/components/workflow/workflow-editor.tsx` — render ``, wire `Mod+Alt+I`, and dispatch `nexus:toggle-sidekick`. +- `src/components/workflow/properties-panel.tsx` — adjust height when side-kick and properties panel are both open. +- `src/components/workflow/header/use-header-controller.ts` — expose `isSidekickOpen` and `toggleSidekick`. +- `src/components/workflow/header.tsx` — pass side-kick state/actions into header controls as needed. +- `src/components/workflow/header/workflow-actions.tsx` — add the compact side-kick toggle button or consume a shared toggle button. +- `src/components/workflow/shared-header-actions.tsx` — follow Library/Brain toggle patterns; add a `SidekickToggleButton` if shared placement is preferred. +- `src/components/workflow/shortcuts-dialog.tsx` — document `Mod+Alt+I`. +- `src/components/workflow/panel-primitives.ts` — reuse existing panel classes. +- `src/components/workflow/floating-workflow-gen/use-floating-workflow-gen-position.ts` — adapt draggable positioning for side-kick. + +### New Files +- `src/store/sidekick/types.ts` — side-kick message/status/tool/permission/approval types. +- `src/store/sidekick/store.ts` — Zustand store for side-kick state and public actions. +- `src/store/sidekick/runner.ts` — OpenCode session/message/event orchestration and action loop. +- `src/store/sidekick/streaming-action-parser.ts` — incremental parser for `` blocks, skipping fenced code. +- `src/store/sidekick/tools.ts` — Nexus client-side action registry and dispatch helper. +- `src/store/sidekick/context.ts` — compact view snapshot and `` message builders. +- `src/store/sidekick/system-prompt.ts` — side-kick system prompt and tool catalog instructions. +- `src/store/sidekick/index.ts` — barrel exports for side-kick store/types/helpers. +- `src/components/workflow/sidekick/panel.tsx` — floating panel shell. +- `src/components/workflow/sidekick/messages.tsx` — message list and role renderers. +- `src/components/workflow/sidekick/action-card.tsx` — Nexus action status/approval card. +- `src/components/workflow/sidekick/acp-tool-card.tsx` — ACP tool call/update card. +- `src/components/workflow/sidekick/permission-card.tsx` — forwarded ACP permission card. +- `src/components/workflow/sidekick/input-bar.tsx` — textarea, send/cancel controls, `Cmd/Ctrl+Enter` submit. +- `src/components/workflow/sidekick/use-sidekick-position.ts` — draggable bottom-right positioning hook. +- `src/store/sidekick/__tests__/streaming-action-parser.spec.ts` — parser unit tests. +- `src/store/sidekick/__tests__/tools.spec.ts` — tool registry/handler unit tests. +- `src/store/sidekick/__tests__/runner.integration.spec.ts` — mocked event-stream runner integration tests. +- `src/store/sidekick/__tests__/context.spec.ts` — view-snapshot and tool-result golden tests. +- `docs/tasks/ai-sidekick-acp-ux-caa41bd1/e2e-ai-sidekick-acp-ux-caa41bd1.md` — E2E specification to create during implementation. Do not execute it in implementation validation; the separate E2E pipeline will run it. + +## Implementation Plan +### Phase 1: Foundation +- Verify the Phase A bridge/API contract in the checked-out branch. If `src/lib/opencode/types.ts` and `packages/nexus-acp-bridge` do not yet expose `permissionMode`, `tool.call`, `tool.call.updated`, `permission.requested`, and `/session/:id/permission`, update browser-facing OpenCode types/services and coordinate with/merge Phase A before implementing side-kick behavior that depends on them. +- Define side-kick domain types, persistence keys, status state machine, and message model. +- Build the incremental action parser with robust partial-buffer and fenced-code handling. +- Build context and system-prompt helpers with a compact snapshot and action catalog. +- Build the `zod/v4` client-side tool registry with read-only, safe write, and destructive groups. + +### Phase 2: Core Implementation +- Implement store and runner orchestration: + - Ensure one session per conversation. + - Persist `sessionId` and panel position to localStorage. + - Restore history with `client.messages.list(sessionId)` on mount/init. + - Create sessions using `permissionMode: "forward"`. + - Subscribe to SSE before sending each prompt. + - Process text deltas/updates, ACP tool events, permission events, session idle, and session errors. + - Dispatch parsed Nexus action calls after idle. + - Pause for destructive approvals and resume/deny/skip correctly. + - Send `` follow-up turns until no actions run. +- Implement scope-aware tool handlers for root vs active sub-workflow contexts. +- Enforce collaboration write guards for all write/destructive tools. +- Add unit/integration coverage for parser, context, tools, and runner. + +### Phase 3: Integration +- Implement the floating side-kick panel and cards using existing workflow styling primitives. +- Add header button and `Mod+Alt+I` toggle event. +- Add shortcuts dialog row. +- Shrink properties panel height when side-kick is open. +- Subscribe to connector-bus invalidation and clear side-kick session/history appropriately. +- Create the task E2E spec file with browser steps and screenshot checkpoints, without running E2E during implementation. + +## Step by Step Tasks +IMPORTANT: Execute every step in order, top to bottom. + +### 1. Confirm Bridge/OpenCode API Contract +- Inspect `src/lib/opencode/types.ts`, `src/lib/opencode/services/sessions.ts`, `src/lib/opencode/services/permissions.ts`, and `packages/nexus-acp-bridge/src/server/http-server.ts`. +- If the branch does not already contain Phase A additions, update the browser-facing TypeScript types and services at minimum: + - Extend `SessionCreatePayload` with `permissionMode?: "auto" | "forward"`. + - Add event variants for `tool.call`, `tool.call.updated`, and `permission.requested` with property shapes matching the bridge. + - Add a permission response method that posts to `/session/${sessionId}/permission` with `{ requestId, outcome, optionId? }` or the exact Phase A payload shape. +- Preserve the existing `permission.asked` / `permission.replied` APIs for direct OpenCode compatibility. +- If server bridge support is missing in this branch, stop and reconcile with Phase A before relying on forwarded permissions in the side-kick runner. + +### 2. Define Side-Kick Types and Store Skeleton +- Create `src/store/sidekick/types.ts` with discriminated types for: + - `SidekickRole = "user" | "assistant" | "tool" | "acp-tool" | "permission"`. + - `SidekickMessage` variants for text, Nexus action cards, ACP tool cards, and permission cards. + - `SidekickStatus` values such as `idle`, `creating-session`, `streaming`, `running-tools`, `awaiting-approval`, `awaiting-permission`, `error`. + - `ToolCall`, `ToolResult`, `PendingApproval`, `AllowList`, and ACP permission request/option types. +- Create `src/store/sidekick/store.ts` with state for `messages`, `status`, `sessionId`, `panelOpen`, `panelCollapsed`, `panelPosition`, `pendingApproval`, `allowList`, `error`, and `_abortController`. +- Implement public actions: `send`, `cancel`, `newConversation`, `togglePanel`, `setPanelOpen`, `setPanelCollapsed`, `setPanelPosition`, `approve`, `deny`, and `respondToAcpPermission`. +- Persist only `sessionId` and `panelPosition` to localStorage. Keep `Allow always` ephemeral in memory so it resets on reload. +- Add `src/store/sidekick/index.ts` exports. + +### 3. Implement View Context and System Prompt Helpers +- Create `src/store/sidekick/context.ts`. +- Implement `buildViewSnapshot()` using current stores and keep it compact, e.g.: + ```xml + + workflow.name=... + workflow.dirty=true + workflow.activeId=... + workflow.nodes.count=... + workflow.edges.count=... + workflow.selectedNodeId=... + view.subWorkflowDepth=... + view.activeSubWorkflowParentLabel=... + view.propertiesPanelOpen=... + view.libraryOpen=... + view.knowledgePanelOpen=... + view.canvasMode=... + collab.inRoom=... + collab.isOwner=... + + ``` +- Implement `buildToolResultMessage(results)` that emits deterministic `` blocks with action ids, names, `ok`, serialized result payloads, and error codes/messages. +- Create `src/store/sidekick/system-prompt.ts`. +- Use `NODE_REGISTRY` to list node types with one-line summaries. +- Explain the two tool surfaces: ACP agent tools are native/bridge-rendered; Nexus actions must be emitted as XML action blocks. +- Document conditional handles (`IfElse` / `Switch` use `branch-${index}`) and automatic sub-workflow scope routing. + +### 4. Implement Streaming Action Parser +- Create `src/store/sidekick/streaming-action-parser.ts`. +- Implement an incremental parser that accepts text chunks and emits completed calls only when `` is seen. +- Track code-fence state so `` examples inside fenced Markdown are ignored. +- Support partial ``, `name="..."`, ``, JSON body, ``, and `` split across chunks. +- Return structured parse errors instead of throwing when JSON is malformed. +- Add tests in `src/store/sidekick/__tests__/streaming-action-parser.spec.ts` for: + - single complete action in one chunk, + - action split across many chunks, + - multiple actions in one stream, + - fenced-code action ignored, + - malformed args surfaced as an invalid-args call/result, + - ordinary assistant prose preserved for display. + +### 5. Implement Nexus Client-Side Tool Registry +- Create `src/store/sidekick/tools.ts`. +- Import Zod from `zod/v4`. +- Define `ToolDefinition` with `name`, `description`, `schema`, `destructive`, optional `write`, and `handler`. +- Implement `getToolCatalog()` for the system prompt. +- Implement `dispatchTool(call)` that validates args, checks unknown tools, enforces collaboration write guards, catches handler errors, and returns a `ToolResult`. +- Implement read-only tools: + - `getCurrentWorkflow`, `getNode`, `listNodes`, `listEdges`, `listNodeTypes`, `getViewState`, `listSavedWorkflows`, `listKnowledgeDocs`. +- Implement safe write tools: + - `addNode`, `updateNodeData`, `connectNodes`, `selectNode`, `selectAll`, `duplicateNode`, `setName`, `openPropertiesPanel`, `closePropertiesPanel`, `openSubWorkflow`, `closeSubWorkflow`, `navigateToBreadcrumb`, `groupIntoSubWorkflow`, `setCanvasMode`, `fitView`, `autoLayout`, `saveWorkflow`, `markWorkflowSaved`, `openLibrary`, `closeLibrary`, `openKnowledgePanel`, `closeKnowledgePanel`. +- Implement destructive tools: + - `deleteNode`, `deleteEdge`. +- For scope-aware mutation: + - Read `activeSubWorkflowNodeId` from `useWorkflowStore.getState()`. + - If not in a sub-workflow, use root `nodes`/`edges` actions. + - If in a sub-workflow, update `subWorkflowNodes`/`subWorkflowEdges` and persist changes into the parent sub-workflow data via existing store helpers/actions. +- For `addNode`, default missing `position` to viewport center plus deterministic jitter. Return the created node id; if the existing `addNode` action does not return the node, compute the id by comparing nodes before/after or add a minimal store helper if necessary. +- For `connectNodes`, reuse `normalizeWorkflowConnection`; default `sourceHandle` to `"output"` only for non-conditional nodes. +- For `deleteNode`, return `node_not_deletable` when the target is protected or missing rather than pretending success. +- Add tests in `src/store/sidekick/__tests__/tools.spec.ts` for all tool groups and critical error codes. + +### 6. Implement Runner Orchestration +- Create `src/store/sidekick/runner.ts`. +- Adapt the event-subscribe-before-send pattern from `src/store/workflow-gen/workflow-generator.ts`. +- Implement `ensureSidekickSession()`: + - Reuse `sessionId` when available. + - Create `client.sessions.create({ title: "Nexus Side-kick", permissionMode: "forward" })` when absent. + - Store the returned id. +- Implement history restoration on panel mount/init using `client.messages.list(sessionId)` and map bridge/OpenCode messages to `SidekickMessage` text messages. +- Implement `sendSidekickTurn(text)`: + - Add the user message locally. + - Build system prompt and prepend `buildViewSnapshot()` to the user payload. + - Subscribe to `client.events.subscribe({ signal })` and prime the iterator before `client.messages.sendAsync(...)`. + - Track assistant text by part id for both `message.part.delta` and `message.part.updated`. + - Update or append assistant message text as deltas arrive. + - Feed text deltas into `streaming-action-parser`. + - Render `tool.call` as pending ACP tool cards and `tool.call.updated` as updated cards. + - Render `permission.requested` as permission cards and allow `respondToAcpPermission` to POST the selected response. + - On `session.idle`, stop streaming for that turn and dispatch parsed actions in order. + - If actions ran, send a follow-up user message with `` blocks and repeat until the assistant emits no new actions. + - If `session.error` or fetch/stream errors occur, set `status: "error"`, preserve visible messages, and clear abort state. +- Implement cancellation via abort controller and `client.sessions.abort(sessionId)` when possible. +- Add `newConversation()` to delete the old session, clear history/allow-list, create a fresh forward-permission session on next send, and reset errors. +- Subscribe to `connector-bus` so connector/agent/preset changes clear session state and inform the user in the message thread or error banner. +- Add `src/store/sidekick/__tests__/runner.integration.spec.ts` with scripted async generators for event sequences covering text-only response, Nexus actions + follow-up, ACP tool call updates, permission response, session error, and cancellation. + +### 7. Build Floating Side-Kick UI Components +- Create `src/components/workflow/sidekick/use-sidekick-position.ts` by adapting the floating workflow-gen position hook for a bottom-right anchored panel. +- Create `src/components/workflow/sidekick/panel.tsx` as a client component. +- Reuse `WORKFLOW_PANEL_SHELL_BASE_CLASS`, `WORKFLOW_PANEL_SURFACE_CLASS`, and other primitives from `panel-primitives.ts`. +- Support: + - bottom-right default placement, + - drag handle, + - collapse to pill, + - reopen/toggle, + - “New conversation”, + - status/error banner, + - coexisting z-index with properties/library/brain panels. +- Create `messages.tsx` with renderers for user, assistant, tool/action, ACP tool, and permission messages. +- Use a lightweight Markdown/prose rendering approach consistent with existing dependencies; avoid adding new dependencies unless necessary. +- Create `input-bar.tsx` with auto-grow textarea, Send, Cancel, disabled states, and `Cmd/Ctrl+Enter` submit. +- Ensure all interactive controls have accessible labels and visible focus styles. + +### 8. Build Action, ACP Tool, and Permission Cards +- Create `action-card.tsx`. +- Render status states: `pending`, `awaiting-approval`, `running`, `done`, `error`, `denied`, `skipped`. +- For destructive tools in `awaiting-approval`, show exact buttons: + - `Allow once`, + - `Allow always`, + - `Deny`. +- Make `Allow always` apply only to the in-memory session allow-list and reset on reload/new conversation. +- Create `acp-tool-card.tsx`. +- Render ACP tool title/name, status, and collapsible raw input/output sections. +- Create `permission-card.tsx`. +- Render the bridge/ACP permission request and each option as a button. +- On click, call `useSidekickStore.getState().respondToAcpPermission(requestId, outcome, optionId)` and update the card state. +- Handle expired/cancelled permission states gracefully. + +### 9. Integrate Panel, Header, Hotkey, and Properties Coexistence +- In `src/components/workflow/workflow-editor.tsx`: + - Import and render `` next to ``. + - Add global `Mod+Alt+I` hotkey with the existing editable-target guard. + - Dispatch or handle `nexus:toggle-sidekick` consistently with other panel events. +- In `src/components/workflow/header/use-header-controller.ts`: + - Add `isSidekickOpen` and `toggleSidekick` to the controller surface. + - Listen for `nexus:toggle-sidekick` or implement direct store toggling, following the workflow-gen pattern. +- In `src/components/workflow/shared-header-actions.tsx` or `header/workflow-actions.tsx`: + - Add a side-kick toggle button with a sparkle/wand/message icon. + - Set `aria-pressed`, `aria-label`, active styling, and tooltip. +- In `src/components/workflow/header.tsx`: + - Wire side-kick state/action props to the chosen header button location. +- In `src/components/workflow/shortcuts-dialog.tsx`: + - Add a `Mod+Alt+I` row labeled `AI side-kick` or `Toggle AI side-kick`. +- In `src/components/workflow/properties-panel.tsx`: + - Subscribe to `useSidekickStore((s) => s.panelOpen)`. + - When side-kick and properties are open, shrink properties height, e.g. root canvas: `calc(50vh - 24px)`, while preserving existing sub-workflow height behavior. + +### 10. Create E2E Specification File +- Create `docs/tasks/ai-sidekick-acp-ux-caa41bd1/e2e-ai-sidekick-acp-ux-caa41bd1.md` during implementation. +- Do not execute E2E tests as part of implementation validation. +- Structure the E2E spec with these sections: + - `User Story` — validating that a workflow author can use the AI side-kick to inspect, modify, approve destructive actions, and respond to forwarded ACP permissions. + - `Test Steps` — browser interactions using `playwright-cli` only in the E2E pipeline. Include minimal flows: + 1. Open the side-kick via header button and capture screenshot `sidekick-open-empty`. + 2. Send `What can you tell me about this canvas?` and assert an assistant text response with no action card. + 3. Send `Add a Prompt node named Draft Prompt and connect it after Start` using a mocked/deterministic bridge response, then assert an `addNode` card is `done`, a `connectNodes` card is `done`, and the canvas contains `Draft Prompt`; capture `sidekick-action-success`. + 4. Select the created node and send `Delete this node`, assert a destructive action card appears with `Allow once`, `Allow always`, `Deny`; capture `sidekick-approval-card`. + 5. Click `Deny`, assert the node remains and the card status is `denied`. + 6. Repeat delete, click `Allow once`, assert the node is removed and status is `done`. + 7. Trigger a mocked forwarded ACP permission request, assert the permission card displays option buttons, choose `allow_once`, and assert it becomes resolved; capture `sidekick-permission-resolved`. + 8. Click `New conversation`, assert message history clears and side-kick remains open; capture `sidekick-new-conversation`. + - `Success Criteria` — exact UI states/cards/statuses expected. + - `Screenshot Capture Points` — list all named screenshots above. +- Keep browser-driving steps only in this E2E file, not in implementation task execution. + +### 11. Add Automated Tests +- Add `src/store/sidekick/__tests__/context.spec.ts` for `buildViewSnapshot()` and `buildToolResultMessage()` golden output. +- Add parser, tool, and runner tests from earlier steps if not already completed. +- Mock stores with `useWorkflowStore.setState`, `useSavedWorkflowsStore.setState`, and `useOpenCodeStore.setState` using existing test patterns. +- Include edge cases: + - malformed args, + - unknown tool name, + - action inside fenced code, + - bridge unreachable / no client, + - collab guest write refused with `collab_guest_readonly`, + - protected Start node deletion returns `node_not_deletable`, + - denied destructive action skips remaining destructive batch as `skipped_after_deny`, + - active sub-workflow mutations update sub-workflow state rather than root nodes. + +### 12. Update User-Facing Shortcut Documentation if Needed +- If README shortcut docs are maintained for every app shortcut, add `Ctrl/Cmd + Alt + I | Toggle AI side-kick` to `README.md`. +- Keep the description short and durable. + +### 13. Run Validation Commands +- Run every command listed in the `Validation Commands` section. +- Fix all TypeScript, lint, test, and build failures. +- Do not run browser/E2E tests here; the E2E pipeline consumes the E2E spec separately. + +## Testing Strategy +### Unit Tests +- Parser tests for incremental XML extraction, code-fence skipping, multiple actions, malformed args, and partial chunks. +- Context tests for compact view snapshots covering empty canvas, selected node, library/knowledge panel state, collab guest/owner, and active sub-workflow depth. +- Tool tests for every MVP tool category with seeded workflow/library/knowledge/collab stores. +- Runner integration tests with a mocked OpenCode client and scripted SSE generator covering: + - text-only assistant response, + - action parse + dispatch + tool-result follow-up, + - destructive approval pause/resume/deny, + - ACP tool cards, + - forwarded permission response POST, + - session idle loop termination, + - session error and abort cleanup, + - connector invalidation. + +### Edge Cases +- No OpenCode/bridge client connected: side-kick shows a recoverable error and does not mutate canvas. +- Bridge unreachable on first send: status becomes error; existing editor remains usable. +- Existing persisted `sessionId` no longer exists: history restore failure clears session id and creates a new session on next send. +- Malformed `` JSON: action card errors and a `` error is fed back. +- Unknown tool name: returns `unknown_tool` and allows the model to self-correct. +- Assistant emits `` syntax in a Markdown code fence: parser ignores it. +- Multiple destructive actions: first approval can allow once/always/deny; deny skips the rest deterministically. +- `deleteNode` against Start or missing node: returns explicit error without modifying workflow. +- Active sub-workflow: all node/edge writes route to the sub-workflow data and do not leak to root. +- Collaboration guest writes: all write/destructive tools return `collab_guest_readonly` while read-only chat continues. +- Permission request expires/cancels: card shows expired/cancelled and assistant can continue. +- User closes/collapses panel during streaming or pending permission: state remains consistent and visible when reopened. +- Connector/preset switch mid-conversation: old session is invalidated and next send uses a fresh session. +- Reload after “Allow always”: destructive actions ask again because allow-list is ephemeral. + +## Acceptance Criteria +- A bottom-right side-kick panel can be opened from the header and with `Mod+Alt+I`. +- The panel is draggable, collapsible, dark-theme consistent, and coexists with the properties panel by reducing properties height when both are open. +- Side-kick conversations use one persisted OpenCode/bridge session per conversation and restore message history when possible. +- New side-kick sessions are created with `permissionMode: "forward"`; existing workflow-gen and prompt-gen sessions retain their current default behavior. +- User turns include a compact view snapshot, not full workflow JSON by default. +- Assistant text streams into the panel incrementally. +- Nexus `` blocks are parsed from streamed assistant text, excluding fenced-code examples. +- MVP read-only, safe-write, and destructive Nexus tools are implemented with schema validation and typed result/error payloads. +- Destructive Nexus actions render inline approval cards with `Allow once`, `Allow always`, and `Deny`. +- `Allow always` is ephemeral and resets on reload/new conversation. +- ACP `tool.call` and `tool.call.updated` events render as collapsible cards. +- ACP `permission.requested` events render as inline permission cards and user responses POST back to the bridge. +- Client-side write tools are refused for non-owner users in collaborative standalone sessions. +- Root/sub-workflow scope routing works for node and edge mutations. +- Connector/preset changes invalidate side-kick sessions. +- The E2E spec file is created at `docs/tasks/ai-sidekick-acp-ux-caa41bd1/e2e-ai-sidekick-acp-ux-caa41bd1.md` with user story, test steps, success criteria, and screenshot checkpoints. +- All validation commands pass without errors. + +## Validation Commands +Execute every command to validate the work is complete with zero regressions. + +Use validation commands from `.app_config.yaml` if available: + +```bash +npm run typecheck +npm run lint +npm run build +bun test src/store/sidekick src/store/__tests__/workflow-gen src/store/__tests__/prompt-gen src/lib/__tests__ +``` + +No browser commands, Playwright commands, `playwright-cli` commands, or HTTP probes against a running app belong here; those are reserved for the separate E2E pipeline. + +## Notes +- The Task document assumes Phase A bridge work exists. The current tree should be checked before implementation; if the bridge route/event contract is absent, merge or implement Phase A compatibility first. +- Keep browser persistence minimal: session id and panel position are durable; approval allow-list is intentionally not durable. +- Do not introduce a browser ACP client. The side-kick uses `OpenCodeClient` exactly like existing AI features. +- Prefer extending existing store/actions over duplicating workflow mutation logic. +- Use `@/*` path aliases for app imports and `zod/v4` for schemas. +- Treat generated UI primitives in `src/components/ui/` as read-only; compose existing primitives instead. diff --git a/docs/tasks/workspace-research-planning-e83335e2/e2e-workspace-research-planning-e83335e2.md b/docs/tasks/workspace-research-planning-e83335e2/e2e-workspace-research-planning-e83335e2.md new file mode 100644 index 0000000..1c9c980 --- /dev/null +++ b/docs/tasks/workspace-research-planning-e83335e2/e2e-workspace-research-planning-e83335e2.md @@ -0,0 +1,54 @@ +# E2E: Workspace Research And Planning + +## User Story +Validate a workspace user can create and collaboratively use Research spaces, run/retry AI enrichment, switch views, synthesize, import/export, and promote to both Brain targets without regressing standalone `/editor`, workspace workflows, or Brain panel behavior. + +## Test Steps +Use `playwright-cli` only in the E2E pipeline (do not run from implementation validation): + +1. Start the app and create a new workspace from the landing page. +2. On the workspace dashboard, assert the `Workspace Research` entry is visible and open it. +3. Assert the route matches `/workspace/{id}/research` and the full-screen Research surface renders. +4. Create each planning template at least once, or use a minimal matrix that covers Research Brief, PRD, Implementation Plan, and Decision Log creation. +5. Add a freeform tile through the command input and edit its text. +6. Open a second browser context to the same Research URL, edit another tile, and verify live sync in the first context. +7. Trigger enrichment while the connector is unavailable; assert visible `AI not connected`, visible per-tile AI error, and visible `Re-enrich` control. +8. Click `Re-enrich` and assert the retry state/error remains visible instead of blocking note editing. +9. Switch between tiling, kanban, and graph views; assert the same tile content remains visible in each view. +10. Generate synthesis and assert the synthesis panel shows generated content with copy controls. +11. Export a `.nodepad` file, import it into a new/blank space, and assert core tiles and relationships are preserved. +12. Copy/export markdown and assert grouped research content appears in the exported text. +13. Promote selected notes to Workspace Brain and assert a successful promotion message. +14. Promote selected notes to Personal Brain and assert a successful promotion message. +15. Regression: open `/editor` and assert standalone workflow editing still renders. +16. Regression: create/open a workspace workflow and assert workflow saving/collaboration UI still renders. +17. Regression: open the Brain panel and assert promoted Brain documents are reachable. +18. Inspect storage/network where practical and assert there is no nodepad localStorage primary persistence dependency. + +## Success Criteria +- The Research dashboard entry is visible with text `Workspace Research` and opens `/workspace/{id}/research`. +- Blank Research page shows an empty-state message and template creation controls. +- Creating template spaces displays seeded tile text for each selected template. +- Two browser contexts show the same edited tile content after collaboration sync. +- Connector-unavailable enrichment displays exact visible text `AI not connected`. +- Per-tile AI errors are visible and include exact visible action text `Re-enrich`. +- Tiling, kanban, and graph controls switch views without losing tile data. +- Synthesis panel displays generated synthesis content and a copy control. +- `.nodepad` export/import preserves project name, blocks, annotations, relationships, pins, sources, and sub-tasks. +- Markdown copy/export includes headings, grouped notes, tasks, quotes, sources, and synthesis content. +- Workspace Brain promotion displays a success message and creates a Brain document. +- Personal Brain promotion displays a success message and creates a Brain document in the personal target. +- `/editor`, workspace workflow editing, and Brain panel still open successfully. +- No nodepad OpenRouter/OpenAI/Z.ai provider-key settings UI appears. +- No nodepad localStorage-only primary persistence dependency is required for research data. + +## Screenshot Capture Points +- Workspace dashboard Research entry. +- Blank Research page. +- Template-created space with seeded tiles. +- Two-browser sync state. +- AI error/retry state showing `AI not connected` and `Re-enrich`. +- Graph view. +- Synthesis panel. +- Promote menu. +- Brain document result after promotion. diff --git a/docs/tasks/workspace-research-planning-e83335e2/plan-workspace-research-planning-e83335e2.md b/docs/tasks/workspace-research-planning-e83335e2/plan-workspace-research-planning-e83335e2.md new file mode 100644 index 0000000..b800efa --- /dev/null +++ b/docs/tasks/workspace-research-planning-e83335e2/plan-workspace-research-planning-e83335e2.md @@ -0,0 +1,349 @@ +# feature: Workspace Research And Planning + +## Metadata +adw_id: `e83335e2` +document_description: `Workspace Research And Planning` + +## Description +The task adds a native, workspace-scoped research and planning surface to Nexus Workflow Studio. The new surface should live at `/workspace/[id]/research`, be launched from the workspace dashboard, and port the core user experience from the sibling `/media/falfaddaghi/extradrive2/repo/nodepad` clone into Nexus modules rather than hosting nodepad as a separate app. + +The V1 scope is broad and includes research spaces, note tiles, local-first collaboration, AI enrichment, inferred note relationships, tiling/kanban/graph views, synthesis, `.nodepad` import/export, markdown export/copy, planning templates, and promotion into both Workspace Brain and Personal Brain targets. Data must be persisted under the existing workspace data root at `{NEXUS_BRAIN_DATA_DIR}/workspaces/{workspaceId}/research/...`, and AI must reuse the existing Nexus connector path instead of introducing nodepad-local OpenRouter/OpenAI/Z.ai settings. + +Complexity assessment: `complex` because this work spans routing, dashboard UI, server persistence, schemas/types, collaboration/Yjs, AI integration, import/export, Brain promotion, package dependencies, and broad unit/E2E coverage. + +## Objective +Implement a fully integrated workspace Research page that gives each Nexus workspace collaborative nodepad-like research spaces with server-backed persistence, AI-assisted enrichment/synthesis through Nexus connectors, planning templates, import/export, and Brain promotion, while preserving existing standalone `/editor`, workspace workflow editing, and Brain panel behavior. + +## Problem Statement +Nexus currently centers on workflow editing and Brain documents but lacks a workspace-native surface for collecting research notes, organizing planning artifacts, collaboratively synthesizing information, and promoting curated research into the Brain. The sibling nodepad app has the desired interaction model, but running or embedding it separately would fragment routing, storage, styling, auth/connector behavior, collaboration, and data portability. + +## Solution Statement +Port the relevant nodepad concepts into a first-class `research` namespace inside Nexus. Add workspace API routes and a file-backed research store mirroring the existing workspace/Brain manifest patterns. Add a client Research page composed from ported/adapted nodepad UI components styled with Nexus theme tokens. Add a research-specific Yjs/Hocuspocus adapter using stable room IDs (`nexus-research-{workspaceId}-{spaceId}`) and debounced autosave to the new API. Add AI helper modules that preserve nodepad enrichment/synthesis result shapes and robust JSON parsing while routing requests through Nexus/OpenCode connector state. Add template seeding, `.nodepad` and markdown import/export helpers, and promotion helpers that create versioned Knowledge Brain documents for workspace or personal targets. + +## Code Patterns to Follow +Reference implementations: +- `CLAUDE.md` — project coding rules: use Bun, `@/*` imports, `zod/v4`, dark-theme-first UI, and preserve browser/localStorage safeguards. +- `README.md` — current app behavior, scripts, `/editor` standalone expectations, and OpenCode optional/offline behavior. +- `docs/tasks/conditional_docs.md` — conditional docs used to identify workspace and Brain documentation requirements. +- `docs/tasks/feature-workspace-foundation-616005e8/doc-feature-workspace-foundation-616005e8.md` — workspace route, server file-store, dashboard, stable room ID, and autosave conventions. +- `docs/tasks/persistent-brain/doc-persistent-brain.md` — Brain persistence, versioning, Hocuspocus, and server-backed collaboration conventions. +- `/media/falfaddaghi/extradrive2/repo/NexusWorkflowStudio/docs/spec/spec-workspace-research-planning.md` — source spec referenced by the task document. Note: this spec exists in the parent repo checkout, not in the current task tree. +- `src/lib/workspace/server.ts`, `src/lib/workspace/types.ts`, `src/lib/workspace/schemas.ts` — mirror the workspace file-store manifest pattern, nanoid usage, JSON read/write helpers, and Zod route validation. +- `src/app/api/workspaces/[id]/workflows/route.ts` and `src/app/api/workspaces/[id]/workflows/[wid]/route.ts` — follow current Next App Router route handler structure and error response style. +- `src/lib/brain/server.ts`, `src/lib/brain/client.ts`, `src/lib/brain/schemas.ts`, `src/types/knowledge.ts` — follow Brain document shape, versioned save behavior, token/session distinction, and `associatedWorkflowIds` usage for promotion. +- `src/lib/collaboration/collab-doc.ts`, `src/lib/collaboration/config.ts`, `scripts/collab-server.ts` — follow existing Hocuspocus provider, connection status, seeding, and persisted room-state patterns, but do not couple research state to workflow node/edge state. +- `src/components/workspace/dashboard.tsx`, `src/components/workspace/workspace-header.tsx`, `src/components/workspace/workflow-card.tsx`, `src/app/workspace/[id]/page.tsx` — add a dashboard Research entry consistently with current workspace UI. +- `src/components/workflow/brain-panel/*` — follow visual language and Brain document UX where promotion touches Brain. +- `src/lib/opencode/*`, `src/store/opencode/*`, `src/hooks/use-models.ts`, `src/hooks/use-tools.ts` — reuse existing Nexus connector/OpenCode state; do not port `nodepad/lib/ai-settings.ts` provider-key settings. +- Nodepad visual/functional references to port/adapt: `/media/falfaddaghi/extradrive2/repo/nodepad/app/page.tsx`, `components/project-sidebar.tsx`, `components/status-bar.tsx`, `components/vim-input.tsx`, `components/tile-card.tsx`, `components/tile-index.tsx`, `components/tiling-area.tsx`, `components/kanban-area.tsx`, `components/graph-area.tsx`, `components/ghost-panel.tsx`. +- Nodepad data/AI/export references to port/adapt: `/media/falfaddaghi/extradrive2/repo/nodepad/lib/ai-enrich.ts`, `lib/ai-ghost.ts`, `lib/content-types.ts`, `lib/detect-content-type.ts`, `lib/export.ts`, `lib/nodepad-format.ts`, `lib/initial-data.ts`. + +Research notes from exhaustive greps: +- Existing Nexus workspace/Brain/OpenCode/collaboration references are spread across these files with counts and must be checked for integration impact: `src/app/api/brain/documents/route.ts` (3), `src/app/api/brain/documents/[id]/route.ts` (2), `src/app/api/brain/documents/[id]/feedback/route.ts` (2), `src/app/api/brain/documents/[id]/restore/route.ts` (2), `src/app/api/brain/documents/[id]/versions/route.ts` (2), `src/app/api/brain/documents/[id]/view/route.ts` (2), `src/app/api/brain/session/route.ts` (2), `src/components/workflow/brain-panel/constants.ts` (8), `src/components/workflow/brain-panel/doc-editor.tsx` (17), `src/components/workflow/brain-panel/panel.tsx` (7), `src/components/workflow/connect-dialog.tsx` (19), `src/components/workflow/floating-workflow-gen.tsx` (4), `src/components/workflow/generated-export-dialog.tsx` (4), `src/components/workflow/header/session-actions.tsx` (3), `src/components/workflow/header.tsx` (4), `src/components/workflow/header/use-header-controller.ts` (5), `src/components/workflow/project-switcher.tsx` (8), `src/components/workflow/shared-header-actions.tsx` (4), `src/components/workflow/workflow-editor.tsx` (4), `src/hooks/use-models.ts` (7), `src/hooks/use-tools.ts` (3), `src/hooks/use-workspace-autosave.ts` (1), `src/lib/brain/client.ts` (18), `src/lib/brain/schemas.ts` (1), `src/lib/brain/server.ts` (18), `src/lib/brain/types.ts` (7), `src/lib/collaboration/collab-doc.ts` (11), `src/lib/collaboration/config.ts` (3), `src/lib/collaboration/index.ts` (1), `src/lib/knowledge.ts` (10), `src/lib/opencode/client.ts` (4), `src/lib/opencode/config.ts` (3), `src/lib/opencode/errors.ts` (10), `src/lib/opencode/index.ts` (22), `src/lib/opencode/services/events.ts` (3), `src/lib/opencode/types.ts` (4), `src/lib/__tests__/brain-server.test.ts` (13), `src/store/knowledge/helpers.ts` (6), `src/store/knowledge/store.ts` (7), `src/store/knowledge/types.ts` (11), `src/store/opencode-store.ts` (3), `src/store/opencode/store.ts` (17), `src/types/knowledge.ts` (9). +- Nodepad-local patterns that must not be blindly ported include `localStorage`, `.nodepad`, `OpenRouter`, `OpenAI`, `Z.ai`, and `research`. The grep found relevant references in `/media/falfaddaghi/extradrive2/repo/nodepad/app/page.tsx` (22), `app/layout.tsx` (9), `components/about-panel.tsx` (18), `components/project-sidebar.tsx` (3), `components/status-bar.tsx` (2), `components/vim-input.tsx` (2), `lib/ai-settings.ts` (17), `lib/ai-enrich.ts` (7), `lib/nodepad-format.ts` (8), `lib/export.ts` (5), plus smaller references in `app/api/fetch-url/route.ts`, `components/intro-modal.tsx`, `components/mobile-wall.tsx`, `components/tiling-area.tsx`, `lib/acp-client.ts`, and `lib/ai-ghost.ts`. Use these as a checklist to remove/replace localStorage-only and provider-key behavior in the Nexus port. + +## Relevant Files +Use these files to complete the task: + +- `CLAUDE.md` — mandatory project conventions and validation expectations. +- `.app_config.yaml` — app configuration and default validation commands. +- `README.md` — public behavior and scripts; update if Research becomes a user-facing feature documented in the main product overview. +- `package.json` / `bun.lock` — add direct dependencies if the ported UI keeps using `d3`, `framer-motion`, `cmdk`, `react-markdown`, and `remark-gfm`; validate scripts. +- `/media/falfaddaghi/extradrive2/repo/NexusWorkflowStudio/docs/spec/spec-workspace-research-planning.md` — source spec; ensure every FR/AC remains covered. +- `docs/tasks/conditional_docs.md` — conditional documentation rules; this task matches workspace and Brain conditions. +- `docs/tasks/feature-workspace-foundation-616005e8/doc-feature-workspace-foundation-616005e8.md` — workspace architecture reference. +- `docs/tasks/persistent-brain/doc-persistent-brain.md` — Brain and collaboration architecture reference. +- `src/app/workspace/[id]/page.tsx` — existing workspace dashboard route; add navigation path to Research through the dashboard component. +- `src/components/workspace/dashboard.tsx` — add Research card/entry and dashboard affordance that opens `/workspace/${workspaceId}/research`. +- `src/components/workspace/workspace-header.tsx` — consider adding a Research tab/button if dashboard navigation belongs in the header. +- `src/app/api/workspaces/[id]/route.ts` and `src/app/api/workspaces/[id]/workflows/**` — reference route conventions and workspace existence checks. +- `src/lib/workspace/config.ts` — use the existing workspace data root under `NEXUS_BRAIN_DATA_DIR`. +- `src/lib/workspace/server.ts` — extend or complement file persistence with research-specific storage under `workspaces/{workspaceId}/research`. +- `src/lib/workspace/types.ts` — keep workspace records unchanged; research types should live in `src/lib/research/types.ts` unless a shared workspace manifest extension is required. +- `src/lib/workspace/schemas.ts` — either add research route schemas here only if workspace-local, or prefer `src/lib/research/schemas.ts` for research namespace clarity. +- `src/lib/brain/server.ts`, `src/lib/brain/client.ts`, `src/lib/brain/schemas.ts`, `src/types/knowledge.ts` — implement Workspace Brain/Personal Brain promotion through existing Knowledge document conventions. +- `src/lib/knowledge.ts`, `src/store/knowledge/store.ts`, `src/store/knowledge/types.ts` — integrate personal Brain promotion if it uses the current browser/session Brain store APIs. +- `src/lib/collaboration/collab-doc.ts`, `src/lib/collaboration/config.ts`, `src/lib/collaboration/index.ts`, `scripts/collab-server.ts` — add research room id helpers and research-specific Yjs syncing without regressing workflow/Brain collaboration. +- `src/lib/opencode/index.ts`, `src/lib/opencode/client.ts`, `src/lib/opencode/types.ts`, `src/store/opencode-store.ts`, `src/store/opencode/store.ts`, `src/components/workflow/connect-dialog.tsx` — reuse existing connector/OpenCode status and calls for research AI. +- Nodepad reference files under `/media/falfaddaghi/extradrive2/repo/nodepad/components/*` and `/media/falfaddaghi/extradrive2/repo/nodepad/lib/*` listed above — port behavior, not app-level hosting or local provider settings. + +### New Files +- `src/app/workspace/[id]/research/page.tsx` — workspace Research route shell. +- `src/app/api/workspaces/[id]/research-spaces/route.ts` — `GET`/`POST` list and create research spaces. +- `src/app/api/workspaces/[id]/research-spaces/[rid]/route.ts` — `GET`/`PUT`/`PATCH`/`DELETE` single research space operations. +- `src/app/api/workspaces/[id]/research-spaces/[rid]/promote/route.ts` — promote selected research content to Workspace Brain or Personal Brain. +- `src/app/api/workspaces/[id]/research-spaces/[rid]/enrich/route.ts` — optional server-side enrichment endpoint if connector calls should not run directly from the browser. +- `src/app/api/workspaces/[id]/research-spaces/[rid]/synthesize/route.ts` — optional synthesis endpoint if implemented separately from enrichment. +- `src/components/research/research-page.tsx` — main full-screen research container. +- `src/components/research/status-bar.tsx` — compact status bar with workspace/space status, sync/AI states, and view controls. +- `src/components/research/space-sidebar.tsx` — left spaces/settings/templates/import/export panel. +- `src/components/research/command-input.tsx` — bottom command input adapted from nodepad VimInput. +- `src/components/research/tile-card.tsx` — note tile renderer with annotation, sources, errors, tasks, pinning, and `Re-enrich`. +- `src/components/research/tile-index.tsx` — index/search/filter panel. +- `src/components/research/views/tiling-view.tsx` — tiling note layout. +- `src/components/research/views/kanban-view.tsx` — kanban grouping by content type/category/status. +- `src/components/research/views/graph-view.tsx` — relationship graph view using `d3` if retained. +- `src/components/research/synthesis-panel.tsx` — generated synthesis output and copy/export controls. +- `src/components/research/promote-menu.tsx` — Workspace Brain default and Personal Brain secondary target with workflow linking. +- `src/components/research/template-picker.tsx` — Research Brief, PRD, Implementation Plan, and Decision Log template creation UI. +- `src/components/research/import-export-menu.tsx` — `.nodepad` import/export and markdown export/copy UI. +- `src/hooks/use-research-spaces.ts` — fetch/list/create/delete spaces for a workspace. +- `src/hooks/use-research-collaboration.ts` — start/stop research Yjs/Hocuspocus room, merge local/remote state, and expose connection status. +- `src/hooks/use-research-autosave.ts` — debounced snapshot saves via research API. +- `src/lib/research/types.ts` — `ResearchSpaceRecord`, `ResearchSpaceData`, `ResearchBlock`, `ResearchGhostNote`, `ResearchTemplateId`, `ResearchViewMode`, enrichment and synthesis types. +- `src/lib/research/schemas.ts` — Zod schemas using `zod/v4` for routes and import validation. +- `src/lib/research/server.ts` — file persistence under `workspaces/{workspaceId}/research/manifest.json` and `spaces/{spaceId}.json`. +- `src/lib/research/client.ts` — browser fetch helpers for the research API. +- `src/lib/research/templates.ts` — template seed data for Research Brief, PRD, Implementation Plan, Decision Log. +- `src/lib/research/nodepad-format.ts` — Nexus-adapted `.nodepad` parse/serialize helpers preserving nodepad portability. +- `src/lib/research/markdown-export.ts` — Nexus-adapted research-logical markdown export/copy helper. +- `src/lib/research/ai.ts` — enrichment/synthesis prompt construction, robust JSON parsing, and connector calls. +- `src/lib/research/content-types.ts` and `src/lib/research/detect-content-type.ts` — content-type taxonomy and local classifier adapted from nodepad. +- `src/lib/research/promotion.ts` — convert selected research content to versioned Knowledge Brain documents. +- `src/lib/research/collaboration.ts` — `buildResearchRoomId(workspaceId, spaceId)` and Yjs snapshot helpers. +- `src/store/research-store.ts` or `src/store/research/*` — client state for active space, blocks, view mode, panels, selection, AI states. +- `src/lib/__tests__/research-server.test.ts` — persistence and API helper unit tests. +- `src/lib/__tests__/research-schemas.test.ts` — schema validation tests. +- `src/lib/__tests__/research-nodepad-format.test.ts` — `.nodepad` import/export tests. +- `src/lib/__tests__/research-markdown-export.test.ts` — markdown grouping/export tests. +- `src/lib/__tests__/research-ai.test.ts` — robust JSON parsing and prompt behavior tests. +- `src/lib/__tests__/research-templates.test.ts` — planning template seed tests. +- `src/lib/__tests__/research-promotion.test.ts` — Workspace Brain/Personal Brain promotion conversion tests. +- `src/lib/__tests__/research-collaboration.test.ts` — room id, snapshot seeding, Yjs round-trip, and autosave helper tests. +- `docs/tasks/workspace-research-planning-e83335e2/e2e-workspace-research-planning-e83335e2.md` — E2E spec to be created during implementation, not during planning. + +## Implementation Plan +### Phase 1: Foundation +Create the research domain model, schemas, file-backed persistence, API routes, client fetch helpers, package dependencies, and dashboard route entry. Establish a research-specific room ID and snapshot format before building UI so all components share stable types. + +### Phase 2: Core Implementation +Port/adapt nodepad UI and behavior into `src/components/research` and `src/lib/research`. Implement spaces, blocks, notes, planning templates, views, index, command input, synthesis, `.nodepad`/markdown import/export, AI enrichment with visible retry/error states, and Brain promotion conversion. + +### Phase 3: Integration +Wire Research into workspace routing, existing connector state, Hocuspocus collaboration, autosave, and Brain APIs. Add tests for persistence, schemas, AI parsing, import/export, templates, collaboration, and promotion. Add the separate E2E specification covering browser interactions but do not execute E2E in validation commands. + +## Step by Step Tasks +IMPORTANT: Execute every step in order, top to bottom. + +### 1. Confirm Scope, Reference Behavior, and Dependencies +- Re-read the task source JSON and the spec file at `/media/falfaddaghi/extradrive2/repo/NexusWorkflowStudio/docs/spec/spec-workspace-research-planning.md` before coding. +- Re-open `CLAUDE.md`, `README.md`, `docs/tasks/conditional_docs.md`, `docs/tasks/feature-workspace-foundation-616005e8/doc-feature-workspace-foundation-616005e8.md`, and `docs/tasks/persistent-brain/doc-persistent-brain.md`. +- Inspect the nodepad reference files in `/media/falfaddaghi/extradrive2/repo/nodepad`, especially `app/page.tsx`, `components/*`, `lib/ai-enrich.ts`, `lib/ai-ghost.ts`, `lib/export.ts`, and `lib/nodepad-format.ts`. +- Add direct package dependencies with Bun only if retained by the ported implementation: `bun add d3 framer-motion cmdk react-markdown remark-gfm`. If any are not used, do not add them. +- Do not port `nodepad/lib/ai-settings.ts` settings UI or nodepad-local provider-key storage. + +### 2. Define Research Types, Schemas, and Seed Data +- Create `src/lib/research/types.ts` with at least: + - `ResearchSpaceRecord` + - `ResearchSpaceData` + - `ResearchBlock` + - `ResearchGhostNote` + - `ResearchTemplateId` + - `ResearchViewMode` + - enrichment result shape containing `contentType`, `category`, `annotation`, `confidence`, `influencedByIndices`, `isUnrelated`, `mergeWithIndex`, and optional `sources` +- Include fields needed for collaboration and persistence: stable `id`, `workspaceId`, `name`, `createdAt`, `updatedAt`, `createdBy`/`lastModifiedBy`, `blocks`, `collapsedIds`, `ghostNotes`, `syntheses`, `templateId`, `associatedWorkflowIds`, and view/UI state where appropriate. +- Create `src/lib/research/schemas.ts` using `zod/v4` for create/update/save/promote/import payloads. +- Create `src/lib/research/templates.ts` with deterministic starter tiles for: + - `research-brief` + - `prd` + - `implementation-plan` + - `decision-log` +- Ensure template seed blocks are structured enough to be useful but not tied to any single workspace. + +### 3. Implement File-Backed Research Persistence +- Create `src/lib/research/server.ts` modeled after `src/lib/workspace/server.ts`. +- Store data exactly under: + - `{NEXUS_BRAIN_DATA_DIR}/workspaces/{workspaceId}/research/manifest.json` + - `{NEXUS_BRAIN_DATA_DIR}/workspaces/{workspaceId}/research/spaces/{spaceId}.json` +- Ensure `manifest.json` tracks version, `workspaceId`, `spaces: ResearchSpaceRecord[]`, and updated timestamps. +- Add helpers: + - `listResearchSpaces(workspaceId)` + - `createResearchSpace(workspaceId, input)` + - `getResearchSpace(workspaceId, spaceId)` + - `saveResearchSpace(workspaceId, spaceId, data, lastModifiedBy)` + - `updateResearchSpaceMeta(workspaceId, spaceId, updates)` + - `deleteResearchSpace(workspaceId, spaceId)` +- Validate that the parent workspace exists via existing workspace helpers before creating research data. +- Make writes atomic enough for local server use by writing complete JSON snapshots and ensuring directories exist. +- Strip transient UI-only fields such as `isEnriching`, temporary status text, drag state, and local errors before saving unless errors are intentionally persisted as visible per-tile AI error state. + +### 4. Add Research API Routes +- Add `src/app/api/workspaces/[id]/research-spaces/route.ts` with: + - `GET` returning `{ spaces }` + - `POST` accepting name/template and returning `{ space }` with status `201` +- Add `src/app/api/workspaces/[id]/research-spaces/[rid]/route.ts` with: + - `GET` returning the full `ResearchSpaceData` + - `PUT` saving a complete snapshot + - `PATCH` updating metadata such as name/template/workflow links + - `DELETE` returning `204` +- Add `src/app/api/workspaces/[id]/research-spaces/[rid]/promote/route.ts` accepting selected block/synthesis/task/source IDs, target (`workspace` default or `personal`), and optional `associatedWorkflowIds`. +- If needed for connector safety, add enrichment/synthesis API routes under the same research-space route namespace rather than calling provider details directly from UI code. +- Match the existing route style: `export const dynamic = "force-dynamic"`, `NextResponse.json`, `params: Promise<...>`, `safeParse`, `400` for validation, `404` for missing workspace/space, and `500` for unexpected failures. + +### 5. Add Client API Helpers and State Store +- Create `src/lib/research/client.ts` for typed fetch wrappers around all research API routes. +- Create `src/store/research-store.ts` or a `src/store/research/` module for active space state, block operations, active view mode, panel open state, selection, synthesis state, and AI status. +- Keep store mutations local-first so note creation/editing works while AI is disconnected or failing. +- Avoid localStorage as the primary research persistence. If localStorage is used at all, limit it to safe UI preferences such as last selected view mode or panel open/closed state. + +### 6. Implement Research Collaboration and Autosave +- Add `buildResearchRoomId(workspaceId, spaceId): string` returning exactly `nexus-research-{workspaceId}-{spaceId}` in `src/lib/research/collaboration.ts` or `src/lib/collaboration/index.ts`. +- Implement Yjs snapshot serialization/deserialization for research space data. Prefer a Y.Map keyed by stable block IDs plus maps/text for metadata and syntheses; keep stable block IDs as the merge target for AI results. +- Create `src/hooks/use-research-collaboration.ts` using `HocuspocusProvider` and `getCollabServerUrl()`; follow `CollabDoc` connect/disconnect/awareness patterns without reusing workflow node/edge store internals. +- Seed the room from the saved `ResearchSpaceData` only when the Yjs room is empty. +- Merge remote edits into the research store and pause local-to-remote subscribers during remote application to avoid feedback loops. +- Add `src/hooks/use-research-autosave.ts` that debounces snapshot saves to `PUT /api/workspaces/[id]/research-spaces/[rid]` and performs a best-effort final save on unload when possible. +- Ensure failed enrichment does not block collaboration; AI results merge back by stable `block.id` as ordinary collaborative state. + +### 7. Port and Adapt Nodepad UI into Nexus Research Components +- Create `src/app/workspace/[id]/research/page.tsx` as a client route shell resolving `params` and rendering `ResearchPage`. +- Create `src/components/research/research-page.tsx` as the full-screen page with Nexus dark theme tokens. +- Port/adapt these nodepad UI pieces: + - compact status bar + - left space/settings/templates panel + - bottom command input + - tiling view + - kanban view + - graph view + - tile index panel + - synthesis panel + - visible per-tile `Re-enrich` retry controls +- Preserve the nodepad layout closely while adapting class names/colors to Nexus patterns (`src/lib/theme` where useful). +- Add empty/loading/error states for no spaces, missing workspace, missing space, API failures, collaboration disconnected, and AI disconnected. +- Do not include nodepad intro/about/provider settings unless they are adapted to Nexus UX and still needed. + +### 8. Add Workspace Dashboard Entry +- Update `src/components/workspace/dashboard.tsx` to show a Research entry on every workspace dashboard. +- The entry should route to `/workspace/${workspaceId}/research`. +- If the workspace has no workflows, still show the Research entry along with the existing empty state or adjust `EmptyState` so users can access Research without first creating a workflow. +- Optionally add a secondary Research tab/button in `src/components/workspace/workspace-header.tsx` if it fits existing navigation. +- Ensure existing workflow creation, workflow cards, workspace rename, and recent workspace tracking remain unchanged. + +### 9. Implement Note Blocks, Relationships, Views, and Synthesis +- Implement create/edit/delete/pin/collapse operations for `ResearchBlock`. +- Add inferred relationship fields using stable block IDs, converting nodepad's `influencedByIndices` into stable IDs at merge time. +- Implement tiling, kanban, and graph views over the same collaborative block state. +- Add synthesis generation and a synthesis panel that can store multiple synthesis records in `ResearchSpaceData`. +- Ensure synthesis output can be copied/exported and promoted to Brain alongside selected notes/tasks/sources. +- Include per-block sub-task support if ported from nodepad; tasks should be eligible for Brain promotion. + +### 10. Implement AI Enrichment Through Nexus Connector +- Create `src/lib/research/ai.ts` by adapting nodepad's prompt intent and robust JSON parsing. +- Preserve the enrichment result shape exactly: `contentType`, `category`, `annotation`, `confidence`, `influencedByIndices`, `isUnrelated`, `mergeWithIndex`, optional `sources`. +- Route calls through existing Nexus connector/OpenCode mechanisms. If a connector is not available, return a controlled error state such as `AI not connected` rather than blocking note creation. +- Show AI errors visibly on each tile with an explicit `Re-enrich` action. +- Keep note creation/editing/collaboration fully functional when AI is disconnected, times out, returns invalid JSON, or returns partial data. +- Add retry parsing behavior similar to nodepad's `parseEnrichResult`/`coerceLooseEnrichResult`, but do not include nodepad provider-key settings or OpenRouter/OpenAI/Z.ai-specific UI. + +### 11. Implement `.nodepad` Import/Export and Markdown Export/Copy +- Create `src/lib/research/nodepad-format.ts` adapted from nodepad's `lib/nodepad-format.ts`. +- Preserve `.nodepad` compatibility with `version`, `exportedAt`, project name, blocks, collapsed IDs, ghost notes, AI annotations, connections, confidence, sources, pins, and sub-tasks. +- On import, assign fresh Nexus research space IDs and preserve stable imported block IDs only when safe; otherwise remap relationships consistently. +- Create `src/lib/research/markdown-export.ts` adapted from nodepad's research-logical grouping. +- Add UI controls for `.nodepad` import, `.nodepad` export, markdown file export, and markdown copy. +- Validate malformed imports safely and show user-visible errors. + +### 12. Implement Brain Promotion +- Create `src/lib/research/promotion.ts` to convert selected tiles, syntheses, tasks, sources, template metadata, and `associatedWorkflowIds` into `KnowledgeDoc`/Brain save inputs. +- Workspace Brain must be the default target from workspace research. +- Personal Brain must be available as a secondary target in the promote menu. +- For Workspace Brain, write a versioned Brain document through existing Brain server/session patterns or a clear workspace target helper. +- For Personal Brain, use the current user/browser Brain session path without requiring workspace Brain to be selected. +- Include selected workflow links in `associatedWorkflowIds` when provided. +- Ensure promotion failures do not lose research edits and are surfaced to the user. + +### 13. Add Unit and Integration Tests +- Add schema tests in `src/lib/__tests__/research-schemas.test.ts` for valid/invalid blocks, templates, save payloads, and promotion payloads. +- Add persistence tests in `src/lib/__tests__/research-server.test.ts` using a temp data dir to verify manifest creation, space CRUD, missing workspace handling, snapshot save, delete, and path layout. +- Add `.nodepad` import/export tests in `src/lib/__tests__/research-nodepad-format.test.ts` for full-fidelity round trips and malformed input. +- Add markdown export tests in `src/lib/__tests__/research-markdown-export.test.ts` for type grouping, claims/tasks/quotes, sources, and empty spaces. +- Add AI tests in `src/lib/__tests__/research-ai.test.ts` for fenced JSON, loose/truncated JSON fallback, invalid JSON errors, connector-unavailable behavior, and confidence clamping. +- Add template tests in `src/lib/__tests__/research-templates.test.ts` for all four V1 templates and deterministic starter tiles. +- Add promotion tests in `src/lib/__tests__/research-promotion.test.ts` for Workspace Brain default, Personal Brain target, selected content, syntheses, tasks, sources, template metadata, and `associatedWorkflowIds`. +- Add collaboration helper tests in `src/lib/__tests__/research-collaboration.test.ts` for exact room id generation, initial snapshot seeding, Yjs state round-trip, and autosave snapshot preparation. +- Update existing Brain/workspace/collaboration tests if shared helpers change. + +### 14. Create the Separate E2E Test Specification File +- Create `docs/tasks/workspace-research-planning-e83335e2/e2e-workspace-research-planning-e83335e2.md` during implementation. +- Do not execute this E2E file in the implementation validation commands; a separate pipeline runs it. +- The E2E spec must contain these sections: + - `User Story`: validate a workspace user can create and collaboratively use Research spaces, run/retry AI enrichment, switch views, synthesize, import/export, and promote to both Brain targets. + - `Test Steps`: browser interactions using `playwright-cli` only, including creating a workspace, opening the Research dashboard entry, creating each required planning template at least once or using a minimal matrix, adding/editing tiles in two browser contexts, verifying live sync, triggering enrichment and `Re-enrich`, switching tiling/kanban/graph views, generating synthesis, exporting `.nodepad`, importing `.nodepad`, copying/exporting markdown, promoting to Workspace Brain and Personal Brain, and regression checks for `/editor`, workspace workflows, and Brain panel. + - `Success Criteria`: exact UI text/values to assert, including route `/workspace/{id}/research`, visible `AI not connected` when connector unavailable, visible per-tile AI errors, visible `Re-enrich`, successful promotion messages, and no nodepad localStorage dependency. + - `Screenshot Capture Points`: dashboard Research entry, blank Research page, template-created space, two-browser sync state, AI error/retry state, graph view, synthesis panel, promote menu, and Brain document result. + +### 15. Update Documentation Where User-Facing Behavior Changes +- Update `README.md` to mention workspace Research if project maintainers expect new user-facing features to be documented there. +- If configuration changes are needed for collaboration/Brain, update `.env.example`, Docker/start scripts, and docs consistently; avoid adding new env vars unless existing `NEXUS_BRAIN_DATA_DIR`/collab vars cannot satisfy the requirement. +- Add a task summary document under this task directory after implementation if the project convention requires documenting major features. + +### 16. Run Validation Commands +- Run all commands listed in the `Validation Commands` section. +- Fix every type, lint, test, and build failure before considering the task complete. +- Do not run browser/E2E commands here. + +## Testing Strategy +### Unit Tests +- Research schemas validate all route payloads, block shapes, enrichment result shapes, template IDs, view modes, and promotion target values. +- Research server tests verify exact filesystem layout, manifest updates, CRUD operations, missing workspace/space behavior, safe deletes, and snapshot persistence. +- `.nodepad` import/export tests verify full-fidelity serialization, transient-state stripping, relationship preservation/remapping, name conflict handling, and invalid file errors. +- Markdown export tests verify research-logical grouping order, claims table, task lists, quote formatting, sources, front matter, and empty export behavior. +- AI parsing tests verify strict JSON, fenced JSON, loose/truncated JSON fallback, invalid JSON errors, unavailable connector state, and confidence clamping. +- Template tests verify Research Brief, PRD, Implementation Plan, and Decision Log seed the expected starter tiles. +- Promotion tests verify Workspace Brain default, Personal Brain secondary target, selected content only, syntheses/tasks/sources inclusion, template metadata, workflow associations, and versioned document creation. +- Collaboration tests verify `nexus-research-{workspaceId}-{spaceId}` room IDs, Yjs snapshot round-trip, initial seeding only into empty docs, stable block ID merging, and autosave serialization. + +### Edge Cases +- Workspace does not exist when listing/creating research spaces. +- Research manifest exists but a space file is missing or malformed. +- Two clients create/edit/delete the same block while autosave is pending. +- AI connector unavailable, disconnected mid-request, times out, returns provider errors, returns invalid JSON, or returns relationship indices that no longer map to existing blocks. +- Imported `.nodepad` file has duplicate block IDs, unknown content types, missing optional fields, old/newer versions, malformed JSON, or broken relationship references. +- Markdown export of empty spaces, blocks with pipes/newlines, long annotations, missing categories, missing confidence, and source URLs with unusual characters. +- Promotion with no selected tiles, deleted selected tiles, missing Brain session, duplicate document title, invalid `associatedWorkflowIds`, and partial Brain save failure. +- Collaboration server unreachable; page should still allow local editing and show visible sync/AI states. +- Existing `/editor` standalone route, workspace workflows, workspace collaboration, and Brain panel continue to work. + +## Acceptance Criteria +- Workspace dashboard includes a Research entry that opens `/workspace/[id]/research`. +- `/workspace/[id]/research` renders a native Nexus full-screen research surface, not a separately hosted nodepad app. +- Research spaces persist under `{NEXUS_BRAIN_DATA_DIR}/workspaces/{workspaceId}/research/manifest.json` and `spaces/{spaceId}.json`. +- API routes exist and work for all required methods: list/create/get/save/update/delete/promote. +- Types exist for `ResearchSpaceRecord`, `ResearchSpaceData`, `ResearchBlock`, `ResearchGhostNote`, `ResearchTemplateId`, and `ResearchViewMode`. +- Planning templates exist for Research Brief, PRD, Implementation Plan, and Decision Log, each seeding structured starter tiles. +- Research collaboration uses room IDs exactly matching `nexus-research-{workspaceId}-{spaceId}`. +- Saved snapshots seed empty Yjs rooms, collaborative edits sync live, and debounced autosave writes back through the research API. +- Tiling, kanban, and graph views render the same research space state. +- Index and synthesis panels are available in the Research page. +- AI enrichment uses Nexus connector/OpenCode paths and does not add nodepad-local OpenRouter/OpenAI/Z.ai key settings. +- Notes save and collaborate when AI is disconnected, with a visible `AI not connected` state. +- Per-tile AI errors remain visible and include an explicit `Re-enrich` action. +- Enrichment result shape preserves `contentType`, `category`, `annotation`, `confidence`, `influencedByIndices`, `isUnrelated`, `mergeWithIndex`, and optional `sources`. +- `.nodepad` import/export works for project portability. +- Markdown export/copy uses nodepad's research-logical grouping adapted to Nexus. +- Brain promotion supports Workspace Brain as the default and Personal Brain as a secondary target. +- Promotion writes a versioned Brain document including selected tiles, syntheses, tasks, sources, template metadata, and optional `associatedWorkflowIds`. +- Unit tests cover schemas, persistence, import/export, markdown export, AI parsing, template seeding, Brain promotion, and collaboration helpers. +- A separate E2E spec file is created at `docs/tasks/workspace-research-planning-e83335e2/e2e-workspace-research-planning-e83335e2.md` with the required structure and browser flow, but is not executed by validation commands. +- Regression checks confirm `/editor` standalone still works, workspace workflows still save/collaborate, Brain panel still opens, and no nodepad localStorage primary persistence dependency is introduced. + +## Validation Commands +Execute every command to validate the work is complete with zero regressions. + +From `.app_config.yaml` and project scripts: +- `npm run typecheck` +- `npm run lint` +- `bun run test` +- `npm run build` + +Notes: +- `.app_config.yaml` provides `npm run typecheck`, `npm run lint`, and `npm run build`; `commands.test` is unset, but the task/spec and `package.json` require `bun run test` for this feature. +- No browser, Playwright, `playwright-cli`, or HTTP UI probing commands belong in this section; those belong to the separate E2E spec/pipeline. + +## Notes +- Use the project harness/skills described in `CLAUDE.md` for non-trivial implementation; this task is complex and multi-file. +- Treat the sibling nodepad repository as reference/source code only. Do not iframe, proxy, start, or separately host nodepad. +- Prefer a research namespace (`src/lib/research`, `src/components/research`, `src/store/research`) to avoid coupling workflow-node code to research tiles. +- Preserve existing sanitization and validation patterns. Avoid new `any` unless isolated to compatibility parsing with follow-up typed normalization. +- Be careful with browser-only APIs (`window`, `localStorage`, `navigator.clipboard`, `Blob`, file inputs) and keep them in client components/helpers. +- Keep OpenCode/AI optional: offline research editing is a first-class path. diff --git a/package.json b/package.json index 5cfa9e9..80168cc 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,23 @@ "start:docker": "bash ./scripts/start.sh --docker", "collab:server": "bun scripts/collab-server.ts", "dev": "next dev", + "nexus-acp-bridge": "bun run packages/nexus-acp-bridge/src/bin.ts", + "nexus-acp-bridge:claude": "bun run packages/nexus-acp-bridge/src/bin.ts --agent claude", + "nexus-acp-bridge:codex": "bun run packages/nexus-acp-bridge/src/bin.ts --agent codex", + "nexus-acp-bridge:opencode": "bun run packages/nexus-acp-bridge/src/bin.ts --agent opencode", + "nexus-acp-bridge:setup-claude": "bun run packages/nexus-acp-bridge/scripts/setup-claude-acp.ts", "build": "next build", "start": "next start", "lint": "eslint . --cache --cache-location .eslintcache --max-warnings=0", "lint:fix": "eslint . --fix --cache --cache-location .eslintcache", "format": "bun run lint:fix", "test": "bun test", + "test:bridge": "bun test packages/nexus-acp-bridge/src/__tests__", "test:store": "bun test src/store/__tests__", "test:lib": "bun test src/lib/__tests__", "test:nodes": "bun test src/nodes", "typecheck": "tsc --noEmit", + "typecheck:bridge": "tsc -p packages/nexus-acp-bridge/tsconfig.json --noEmit", "check": "bun run typecheck && bun run lint && bun run test", "docker:up": "docker compose up --build", "docker:bun": "docker compose up --build", diff --git a/packages/nexus-acp-bridge/CLAUDE.md b/packages/nexus-acp-bridge/CLAUDE.md new file mode 100644 index 0000000..0892746 --- /dev/null +++ b/packages/nexus-acp-bridge/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md — `packages/nexus-acp-bridge` + +Bun HTTP/SSE server that exposes the OpenCode-style API Nexus uses, fronting a real ACP backend (Claude Code, Codex, OpenCode) over JSON-RPC stdio. + +## Source of truth + +Prefer these over this file when details may drift: +- `package.json` — scripts, `bin`, exports +- `README.md` — user-facing run instructions, env vars, layout +- `src/types.ts` — `ACPAdapter`, `BridgeConfig`, all wire types +- `src/index.ts` — public API barrel + `createAdapter()` dispatch +- `src/config.ts` — CLI flags / env / preset → `BridgeConfig` resolution + +## Quick start + +```bash +bun run nexus-acp-bridge # repo root: launches with bundled defaults +bun run nexus-acp-bridge --agent claude # claude | codex | opencode +bun run nexus-acp-bridge:setup-claude # vendor @agentclientprotocol/claude-agent-acp + +# Inside the package: +cd packages/nexus-acp-bridge +bun run typecheck +bun test +``` + +Validate non-trivial changes with: +1. `bun run typecheck` (or root `bun run typecheck:bridge`) +2. `bun test` for the touched module's `__tests__/` file + +## Layout + +``` +src/ +├── bin.ts # CLI entrypoint (#!/usr/bin/env bun) +├── index.ts # Public barrel + createAdapter() +├── config.ts # CLI/env/preset → BridgeConfig +├── tool-presets.ts # claude-code / codex / opencode presets +├── types.ts # Shared interfaces +├── vendor-claude-acp.ts # Auto-vendoring helper for claude-agent-acp +├── adapters/ +│ ├── mock.ts # MockACPAdapter — deterministic fixtures +│ ├── stdio.ts # StdioACPAdapter — one-shot child per prompt +│ └── acp-protocol.ts # ACPProtocolAdapter — persistent JSON-RPC ACP +├── transport/ +│ ├── async-queue.ts +│ ├── jsonrpc.ts # newline + Content-Length framing +│ ├── jsonrpc-client.ts +│ └── stdio-transport.ts +├── server/ +│ ├── http-server.ts # NexusACPBridgeServer (Bun HTTP + SSE) +│ └── default-provider.ts # Locally served provider/tool/resource defaults +└── __tests__/ # one Bun test file per module +``` + +## Architecture notes that matter + +- **Three adapters, one interface.** `mock`, `stdio`, and `acp` all implement `ACPAdapter` from `src/types.ts`. Selection happens in `createAdapter(config)` — keep the dispatch there, not inside `bin.ts`. +- **HTTP layer is OpenCode-shaped.** `server/http-server.ts` mimics the OpenCode REST/SSE surface so the Nexus frontend stays adapter-agnostic. Route shape changes must keep that contract. +- **ACP is JSON-RPC 2.0 over child stdio.** `transport/jsonrpc{,client}.ts` handle framing (newline default, `Content-Length` opt-in via `NEXUS_ACP_BRIDGE_ACP_PROTOCOL`). `acp-protocol.ts` owns initialize / `session/new` / `session/prompt` / `session/cancel` and the `fs/*` + `session/request_permission` reverse handlers. +- **Slash commands are dynamic.** ACP advertises them via `session/update` → `available_commands_update`; the adapter caches them per session and `GET /command` reads that cache. +- **Tools, providers, MCP, resources are local.** ACP doesn't expose discovery for these — `server/default-provider.ts` synthesises them from `BridgeConfig`. + +## Guardrails + +- **No new `bridge*` script names.** The canonical command is `bun run nexus-acp-bridge[:claude|:codex|:opencode|:setup-claude]`. +- **Don't bypass `BridgeConfig`.** All env / CLI / preset resolution belongs in `src/config.ts`. Adapters and the server should only read the resolved config. +- **`fs/*` handlers must stay sandboxed.** `acp-protocol.ts` enforces that requested paths live under `config.projectDirs`. Don't relax that without an explicit config flag. +- **`projectDirs` paths are absolute and pre-resolved by `config.ts`.** Don't re-resolve at use sites. +- **Streaming must remain abortable.** `generateText` uses `AsyncQueue` + an `AbortSignal`-driven `session/cancel`. New streaming paths should follow the same shape. +- **Vendor directory is generated.** `vendor/claude-code/` is populated by `vendor-claude-acp.ts` / `scripts/setup-claude-acp.ts`; never hand-edit it. +- **Public API surface = `src/index.ts`.** Anything consumers should import goes through the barrel; don't deep-import from sibling packages. + +## Adding a new adapter + +At minimum: +1. Create `src/adapters/.ts` implementing `ACPAdapter`. +2. Add a discriminator value to `BridgeConfig.adapterMode` in `src/types.ts` and parse it in `src/config.ts`. +3. Wire it into `createAdapter()` in `src/index.ts`. +4. Re-export the class from `src/index.ts`. +5. Add `src/__tests__/.test.ts` covering happy path + abort. + diff --git a/packages/nexus-acp-bridge/README.md b/packages/nexus-acp-bridge/README.md new file mode 100644 index 0000000..e66d539 --- /dev/null +++ b/packages/nexus-acp-bridge/README.md @@ -0,0 +1,178 @@ +# Nexus ACP Bridge + +`nexus-acp-bridge` is a small Bun HTTP/SSE server that exposes the subset of the OpenCode-style API Nexus currently uses, so Nexus can connect to a bridge URL while the bridge talks to an ACP-compatible backend. + +## What it provides + +- `GET /global/health` +- `GET /command` +- `GET /config/providers` +- `GET /project` +- `GET /project/current` +- `GET /experimental/tool` +- `GET /experimental/tool/ids` +- `GET /mcp` +- `GET /experimental/resource` +- `GET /file` +- `GET /file/content` +- `GET /file/status` +- `GET /session` +- `POST /session` +- `GET /session/:id/message` +- `POST /session/:id/command` +- `POST /session/:id/message` +- `POST /session/:id/prompt_async` +- `POST /session/:id/abort` +- `DELETE /session/:id` +- `GET /event` (SSE) + +The bridge now supports three adapter modes: + +- `mock` — deterministic local responses for Nexus development +- `stdio` — launches a configured agent command and streams its stdout back through the bridge +- `acp` — keeps a persistent stdio connection open and speaks configurable JSON-RPC to a real ACP runtime + +## Run + +From the repo root: + +```bash +bun run nexus-acp-bridge +``` + +This starts the bridge with the bundled defaults (which select the `claude-code` preset and the `acp` adapter). + +### CLI flags (recommended) + +```bash +# Pick an agent and customise the bridge with one command: +bun run nexus-acp-bridge --agent claude --cors http://localhost:3000 + +# Other supported flags: +bun run nexus-acp-bridge \ + --agent claude \ # claude | claude-code | codex | opencode (alias of --tool) + --cors http://localhost:3000 \ + --port 4080 \ + --host 127.0.0.1 \ + --project-dir /path/to/repo \ # may be passed multiple times + --no-auto-setup # opt out of auto-vendoring claude-agent-acp +``` + +CLI flags take precedence over both `.env.defaults` and the selected tool preset, but explicit shell environment variables still win. + +### Auto setup for `--agent claude` + +When you use `--agent claude` (or any path that resolves to the `claude-code` preset) and the vendored binary is missing, the bridge will automatically run the equivalent of `bun run nexus-acp-bridge:setup-claude` to install `@agentclientprotocol/claude-agent-acp@0.31.0` into `packages/nexus-acp-bridge/vendor/claude-code/` before starting. + +To opt out, pass `--no-auto-setup` or set `NEXUS_ACP_BRIDGE_AUTO_SETUP_CLAUDE=0`. You can still trigger the install manually: + +```bash +bun run nexus-acp-bridge:setup-claude # install if missing +bun run nexus-acp-bridge:setup-claude -- --force # force reinstall +``` + +### Preset shortcuts + +```bash +bun run nexus-acp-bridge:claude +bun run nexus-acp-bridge:codex +bun run nexus-acp-bridge:opencode +``` + +These are equivalent to `bun run nexus-acp-bridge --agent `. + +### Environment-style usage (still supported) + +```bash +NEXUS_ACP_BRIDGE_CORS_ORIGIN="http://localhost:3000" bun run nexus-acp-bridge:claude +NEXUS_ACP_BRIDGE_TOOL=opencode bun run nexus-acp-bridge +``` + +If the configured bridge port is already occupied, the bridge automatically retries on a random available port and logs the resolved port at startup. + +Then point Nexus to the bridge URL, usually: + +```text +http://127.0.0.1:4080 +``` + +## Environment + +Use the example files in `examples/` as starting points. + +Bundled tool presets currently include: + +- `claude-code` → `npx --yes @agentclientprotocol/claude-agent-acp@0.31.0` +- `codex` → `npx --yes @zed-industries/codex-acp` +- `opencode` → `opencode acp` + +You can select one with `--tool ` or `NEXUS_ACP_BRIDGE_TOOL=`, and then override any individual setting with the standard bridge environment variables. + +## Adapter shape + +The mock adapter lives in `src/adapters/mock.ts`, the one-shot process-backed adapter lives in `src/adapters/stdio.ts`, and the persistent JSON-RPC ACP adapter lives in `src/adapters/acp-protocol.ts`. All implement the `ACPAdapter` interface from `src/types.ts`. Adapter selection is centralized in `src/index.ts`'s `createAdapter()`. + +To integrate a real ACP backend later, replace or extend the adapter selection in `src/index.ts` with an adapter that: + +1. discovers models/providers +2. discovers tools/MCP-backed capabilities +3. opens a session or conversation handle +4. streams text deltas back to the bridge +5. maps backend failures to `session.error` + +## `stdio` mode contract + +When `NEXUS_ACP_BRIDGE_ADAPTER=stdio`, the bridge launches `NEXUS_ACP_BRIDGE_AGENT_COMMAND` with `NEXUS_ACP_BRIDGE_AGENT_ARGS`, writes a JSON request envelope to the child process stdin, and forwards stdout chunks as streamed text deltas. + +Current request envelope shape: + +```json +{ + "session": { "id": "..." }, + "project": { "worktree": "/path/to/project" }, + "payload": { + "parts": [{ "type": "text", "text": "..." }], + "system": "...", + "model": { "providerID": "...", "modelID": "..." } + } +} +``` + +This is intentionally lightweight: it is a practical process adapter for now, not a formal ACP protocol implementation yet. + +## `acp` mode contract + +When `NEXUS_ACP_BRIDGE_ADAPTER=acp`, the bridge: + +1. starts a persistent child process using `NEXUS_ACP_BRIDGE_AGENT_COMMAND` +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 + +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") + +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. + +## Current behavior + +The mock adapter is intentionally deterministic so Nexus can exercise its flows: + +- workflow generation returns a minimal valid workflow JSON object +- example generation returns a JSON string array +- prompt generation/editing returns Markdown/plain text + +That makes it useful for local development even before a real ACP transport is plugged in. + +The `stdio` adapter is useful when you already have a local command-based agent runtime and want Nexus to talk to it through this bridge without changing the frontend. + +The `acp` adapter is what you should use once you have a real ACP runtime that supports persistent JSON-RPC messaging. + + diff --git a/packages/nexus-acp-bridge/package.json b/packages/nexus-acp-bridge/package.json new file mode 100644 index 0000000..9305ffa --- /dev/null +++ b/packages/nexus-acp-bridge/package.json @@ -0,0 +1,24 @@ +{ + "name": "nexus-acp-bridge", + "private": true, + "version": "1.0.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "bin": { + "nexus-acp-bridge": "./src/bin.ts" + }, + "exports": { + ".": "./src/index.ts", + "./bin": "./src/bin.ts" + }, + "scripts": { + "nexus-acp-bridge": "bun run src/bin.ts", + "dev": "bun run src/bin.ts", + "start": "bun run src/bin.ts", + "setup-claude": "bun run scripts/setup-claude-acp.ts", + "test": "bun test", + "typecheck": "tsc -p tsconfig.json --noEmit" + } +} + diff --git a/packages/nexus-acp-bridge/scripts/setup-claude-acp.ts b/packages/nexus-acp-bridge/scripts/setup-claude-acp.ts new file mode 100644 index 0000000..680178c --- /dev/null +++ b/packages/nexus-acp-bridge/scripts/setup-claude-acp.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env bun +import fs from "node:fs"; +import { CLAUDE_VENDOR_BIN, ensureClaudeAcpVendored } from "../src/vendor-claude-acp"; + +function log(message: string): void { + console.log(`[setup-claude-acp] ${message}`); +} + +const force = process.argv.includes("--force"); +const alreadyInstalled = fs.existsSync(CLAUDE_VENDOR_BIN); + +if (alreadyInstalled && !force) { + log(`already installed at ${CLAUDE_VENDOR_BIN}`); + log("pass --force to reinstall."); + process.exit(0); +} + +try { + ensureClaudeAcpVendored({ force, log }); + log("The claude-code preset will now pick this up automatically."); +} catch (error) { + log(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/packages/nexus-acp-bridge/src/__tests__/acp-jsonrpc.test.ts b/packages/nexus-acp-bridge/src/__tests__/acp-jsonrpc.test.ts new file mode 100644 index 0000000..509242d --- /dev/null +++ b/packages/nexus-acp-bridge/src/__tests__/acp-jsonrpc.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { decodeJsonRpcMessages, encodeJsonRpcMessage } from "../transport/jsonrpc"; + +describe("acp json-rpc framing", () => { + test("round-trips content-length framed messages", () => { + const payload = { + jsonrpc: "2.0" as const, + id: 1, + method: "ping", + params: { ok: true }, + }; + + const encoded = encodeJsonRpcMessage(payload, "content-length"); + const decoded = decodeJsonRpcMessages(encoded, "content-length"); + + expect(decoded.messages).toEqual([payload]); + expect(decoded.remainder.byteLength).toBe(0); + }); + + test("round-trips newline framed messages", () => { + const payload = { + jsonrpc: "2.0" as const, + method: "note", + params: { value: "x" }, + }; + + const encoded = encodeJsonRpcMessage(payload, "newline"); + const decoded = decodeJsonRpcMessages(encoded, "newline"); + + expect(decoded.messages).toEqual([payload]); + expect(decoded.remainder.byteLength).toBe(0); + }); +}); + 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 new file mode 100644 index 0000000..582045c --- /dev/null +++ b/packages/nexus-acp-bridge/src/__tests__/acp-protocol-adapter.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, test } from "bun:test"; +import type { JsonRpcNotification } from "../transport/jsonrpc"; +import type { + ACPJsonRpcClientLike, + ACPRequestHandler, + ACPSessionUpdateHandler, +} from "../transport/jsonrpc-client"; +import { ACPProtocolAdapter } from "../adapters/acp-protocol"; +import { makeBridgeConfig, makeGenerateTextRequest } from "./test-helpers"; + +interface Recorded { + method: string; + params: unknown; +} + +class FakeACPClient implements ACPJsonRpcClientLike { + readonly requestsSent: Recorded[] = []; + readonly notifications: Recorded[] = []; + private readonly notificationListeners = new Set<(notification: JsonRpcNotification) => void>(); + private readonly sessionHandlers = new Map>(); + private readonly requestHandlers = new Map(); + private nextAcpSessionId = 1; + sessionNewResult: unknown = null; + + requestHandler: ((method: string, params: unknown) => Promise) | null = null; + + async connect(): Promise {} + async close(): Promise {} + + async request(method: string, params?: unknown): Promise { + this.requestsSent.push({ method, params }); + + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {} } as T; + } + + if (method === "session/new") { + if (this.sessionNewResult) { + return this.sessionNewResult as T; + } + const sessionId = `acp-session-${this.nextAcpSessionId++}`; + queueMicrotask(() => this.emitSessionUpdate(sessionId, { + sessionUpdate: "available_commands_update", + availableCommands: [ + { + name: "plan", + description: "Create a detailed implementation plan", + input: { hint: "what to plan" }, + }, + ], + })); + return { sessionId } as T; + } + + if (method === "session/prompt") { + const sessionId = (params as { sessionId?: string })?.sessionId; + if (sessionId) { + queueMicrotask(() => this.emitSessionUpdate(sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello " }, + })); + queueMicrotask(() => this.emitSessionUpdate(sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "world" }, + })); + } + return { stopReason: "end_turn" } as T; + } + + if (this.requestHandler) { + return await this.requestHandler(method, params) as T; + } + + return {} as T; + } + + async notify(method: string, params?: unknown): Promise { + this.notifications.push({ method, params }); + } + + onNotification(listener: (notification: JsonRpcNotification) => void): () => void { + this.notificationListeners.add(listener); + return () => this.notificationListeners.delete(listener); + } + + onSessionUpdate(sessionId: string, handler: ACPSessionUpdateHandler): () => void { + let set = this.sessionHandlers.get(sessionId); + if (!set) { + set = new Set(); + this.sessionHandlers.set(sessionId, set); + } + set.add(handler); + return () => { + set?.delete(handler); + }; + } + + setRequestHandler(method: string, handler: ACPRequestHandler): () => void { + this.requestHandlers.set(method, handler); + return () => { + const current = this.requestHandlers.get(method); + if (current === handler) this.requestHandlers.delete(method); + }; + } + + async invokeRequestHandler(method: string, params: unknown): Promise { + const handler = this.requestHandlers.get(method); + if (!handler) throw new Error(`No handler registered for ${method}`); + return await handler(params); + } + + private 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); + } + + const set = this.sessionHandlers.get(sessionId); + if (!set) return; + for (const handler of set) { + handler(update, notification); + } + } +} + +describe("ACPProtocolAdapter", () => { + test("negotiates initialize and streams agent_message_chunk updates", async () => { + const client = new FakeACPClient(); + const adapter = new ACPProtocolAdapter( + makeBridgeConfig({ adapterMode: "acp", acpProtocol: "newline" }), + client, + ); + + try { + const health = await adapter.getHealth(); + expect(health.version).toMatch(/-acp$/); + + const request = makeGenerateTextRequest(); + let output = ""; + for await (const chunk of adapter.generateText(request)) { + output += chunk; + } + + expect(output).toBe("hello world"); + + const methods = client.requestsSent.map((r) => r.method); + expect(methods).toContain("initialize"); + expect(methods).toContain("session/new"); + expect(methods).toContain("session/prompt"); + + const initCall = client.requestsSent.find((r) => r.method === "initialize"); + expect((initCall?.params as { protocolVersion?: number })?.protocolVersion).toBe(1); + expect((initCall?.params as { clientCapabilities?: { fs?: { readTextFile?: boolean } } })?.clientCapabilities?.fs?.readTextFile).toBe(true); + + const promptCall = client.requestsSent.find((r) => r.method === "session/prompt"); + expect((promptCall?.params as { prompt?: unknown[] })?.prompt).toEqual([ + { type: "text", text: "System instructions:\nsystem prompt" }, + { type: "text", text: "hello from nexus" }, + ]); + } finally { + await adapter.dispose(); + } + }); + + test("discovers real ACP model catalogs from session/new when available", async () => { + const client = new FakeACPClient(); + client.sessionNewResult = { + sessionId: "acp-session-config", + models: { + currentModelId: "github-copilot/claude-sonnet-4.6", + availableModels: [ + { modelId: "github-copilot/claude-sonnet-4.6", name: "GitHub Copilot/Claude Sonnet 4.6" }, + { modelId: "github-copilot/claude-sonnet-4.6/low", name: "GitHub Copilot/Claude Sonnet 4.6 (low)" }, + { modelId: "opencode/big-pickle", name: "OpenCode Zen/Big Pickle" }, + ], + }, + }; + + const adapter = new ACPProtocolAdapter( + makeBridgeConfig({ + adapterMode: "acp", + selectedTool: "opencode", + defaultProviderId: "opencode", + defaultProviderName: "OpenCode", + defaultModelId: "default", + }), + client, + ); + + try { + const providers = await adapter.getConfigProviders(); + + expect(providers.default).toEqual({ + "github-copilot": "claude-sonnet-4.6", + opencode: "big-pickle", + }); + expect(providers.providers.map((provider) => provider.id)).toEqual([ + "github-copilot", + "opencode", + ]); + expect(providers.providers[0]?.name).toBe("GitHub Copilot"); + expect(providers.providers[0]?.models["claude-sonnet-4.6"]?.name).toBe("Claude Sonnet 4.6"); + expect(providers.providers[0]?.models["claude-sonnet-4.6/low"]?.family).toBe("claude-sonnet-4.6"); + expect(providers.providers[1]?.name).toBe("OpenCode Zen"); + expect(providers.providers[1]?.models["big-pickle"]?.name).toBe("Big Pickle"); + } finally { + await adapter.dispose(); + } + }); + + test("reuses ACP session across prompts on the same bridge session", async () => { + const client = new FakeACPClient(); + const adapter = new ACPProtocolAdapter( + makeBridgeConfig({ adapterMode: "acp", acpProtocol: "newline" }), + client, + ); + + try { + const request = makeGenerateTextRequest(); + for await (const _ of adapter.generateText(request)) { /* drain */ } + for await (const _ of adapter.generateText(request)) { /* drain */ } + + const newCalls = client.requestsSent.filter((r) => r.method === "session/new"); + expect(newCalls).toHaveLength(1); + } finally { + await adapter.dispose(); + } + }); + + test("closes mapped ACP sessions when bridge sessions are deleted", async () => { + const client = new FakeACPClient(); + const adapter = new ACPProtocolAdapter( + makeBridgeConfig({ adapterMode: "acp", acpProtocol: "newline" }), + client, + ); + + try { + const request = makeGenerateTextRequest(); + for await (const _ of adapter.generateText(request)) { /* drain */ } + + await adapter.closeSession(request.session.id); + + const closeCall = client.requestsSent.find((r) => r.method === "session/close"); + expect(closeCall?.params).toEqual({ sessionId: "acp-session-1" }); + } finally { + await adapter.dispose(); + } + }); + + test("caches ACP available commands for project discovery", async () => { + const client = new FakeACPClient(); + const adapter = new ACPProtocolAdapter( + makeBridgeConfig({ adapterMode: "acp", acpProtocol: "newline" }), + client, + ); + + try { + const request = makeGenerateTextRequest(); + const commands = await adapter.listCommands({ project: request.project }); + + expect(commands).toEqual([ + { + name: "plan", + description: "Create a detailed implementation plan", + source: "command", + template: "/plan {what to plan}", + hints: ["what to plan"], + }, + ]); + } finally { + await adapter.dispose(); + } + }); + + test("sends session/cancel notification on abort", async () => { + const client = new FakeACPClient(); + const adapter = new ACPProtocolAdapter( + makeBridgeConfig({ adapterMode: "acp", acpProtocol: "newline" }), + client, + ); + + try { + const controller = new AbortController(); + const request = { ...makeGenerateTextRequest(), signal: controller.signal }; + controller.abort(); + for await (const _ of adapter.generateText(request)) { /* no-op */ } + + const cancelCall = client.notifications.find((n) => n.method === "session/cancel"); + expect(cancelCall).toBeDefined(); + } finally { + await adapter.dispose(); + } + }); + + test("registers fs/read_text_file and session/request_permission handlers", async () => { + const client = new FakeACPClient(); + const adapter = new ACPProtocolAdapter( + makeBridgeConfig({ adapterMode: "acp", projectDirs: [process.cwd()] }), + client, + ); + + try { + const permissionResult = await client.invokeRequestHandler("session/request_permission", { + sessionId: "x", + toolCall: {}, + options: [ + { optionId: "allow_once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject_once", name: "Reject", kind: "reject_once" }, + ], + }); + expect(permissionResult).toEqual({ outcome: { outcome: "selected", optionId: "allow_once" } }); + + const readResult = await client.invokeRequestHandler("fs/read_text_file", { + sessionId: "x", + path: `${process.cwd()}/package.json`, + }); + expect(typeof (readResult as { content?: unknown }).content).toBe("string"); + } 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 new file mode 100644 index 0000000..9a2315c --- /dev/null +++ b/packages/nexus-acp-bridge/src/__tests__/config.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { loadBridgeConfig, __private__ } from "../config"; + +const originalEnv = { ...process.env }; + +beforeEach(() => { + process.env.NEXUS_ACP_BRIDGE_AUTO_SETUP_CLAUDE = "0"; +}); + +afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(originalEnv)) { + process.env[key] = value; + } +}); + +describe("bridge config", () => { + test("loads the bundled default tool preset automatically", () => { + delete process.env.NEXUS_ACP_BRIDGE_TOOL; + delete process.env.NEXUS_ACP_BRIDGE_ADAPTER; + delete process.env.NEXUS_ACP_BRIDGE_AGENT_COMMAND; + delete process.env.NEXUS_ACP_BRIDGE_AGENT_ARGS; + delete process.env.NEXUS_ACP_BRIDGE_PROVIDER_ID; + delete process.env.NEXUS_ACP_BRIDGE_PROVIDER_NAME; + delete process.env.NEXUS_ACP_BRIDGE_MODEL_ID; + delete process.env.NEXUS_ACP_BRIDGE_MODEL_NAME; + delete process.env.ACP_PERMISSION_MODE; + process.env.NEXUS_ACP_BRIDGE_AUTO_SETUP_CLAUDE = "0"; + + const config = loadBridgeConfig([]); + + expect(config.selectedTool).toBe("claude-code"); + expect(config.adapterMode).toBe("acp"); + // agentCommand resolves to the vendored claude-agent-acp binary when it exists, + // otherwise falls back to `npx`. Both forms end in `claude-agent-acp`. + expect(config.agentCommand).toMatch(/claude-agent-acp$|^npx$/); + if (config.agentCommand === "npx") { + expect(config.agentArgs).toEqual(["--yes", "@agentclientprotocol/claude-agent-acp@0.31.0"]); + } else { + expect(config.agentArgs).toEqual([]); + } + expect(config.defaultProviderId).toBe("claude-code"); + expect(config.defaultProviderName).toBe("Claude Code"); + expect(config.defaultModelId).toBe("sonnet"); + expect(config.defaultModelName).toBe("Claude Sonnet"); + expect(String(process.env.ACP_PERMISSION_MODE)).toBe("bypassPermissions"); + }); + + test("parses adapter mode and quoted agent args", () => { + process.env.NEXUS_ACP_BRIDGE_ADAPTER = "stdio"; + process.env.NEXUS_ACP_BRIDGE_AGENT_COMMAND = "claude"; + process.env.NEXUS_ACP_BRIDGE_AGENT_ARGS = '--model sonnet --label "two words"'; + + const config = loadBridgeConfig(); + + expect(config.adapterMode).toBe("stdio"); + expect(config.agentCommand).toBe("claude"); + expect(config.agentArgs).toEqual(["--model", "sonnet", "--label", "two words"]); + }); + + test("falls back to mock adapter mode by default", () => { + process.env.NEXUS_ACP_BRIDGE_ADAPTER = "mock"; + + const config = loadBridgeConfig([]); + + expect(config.adapterMode).toBe("mock"); + expect(config.serverIdleTimeoutSeconds).toBe(0); + }); + + test("allows overriding the Bun server idle timeout", () => { + process.env.NEXUS_ACP_BRIDGE_IDLE_TIMEOUT_SECONDS = "45"; + + const config = loadBridgeConfig([]); + + expect(config.serverIdleTimeoutSeconds).toBe(45); + }); + + test("explicit environment variables override bundled defaults", () => { + process.env.NEXUS_ACP_BRIDGE_ADAPTER = "stdio"; + process.env.NEXUS_ACP_BRIDGE_AGENT_COMMAND = "custom-agent"; + process.env.NEXUS_ACP_BRIDGE_AGENT_ARGS = "--flag custom"; + process.env.ACP_PERMISSION_MODE = "strict"; + + const config = loadBridgeConfig(); + + expect(config.adapterMode).toBe("stdio"); + expect(config.agentCommand).toBe("custom-agent"); + expect(config.agentArgs).toEqual(["--flag", "custom"]); + expect(String(process.env.ACP_PERMISSION_MODE)).toBe("strict"); + }); + + test("supports selecting Codex via CLI args", () => { + delete process.env.NEXUS_ACP_BRIDGE_AGENT_COMMAND; + delete process.env.NEXUS_ACP_BRIDGE_AGENT_ARGS; + delete process.env.NEXUS_ACP_BRIDGE_PROVIDER_ID; + delete process.env.NEXUS_ACP_BRIDGE_PROVIDER_NAME; + delete process.env.NEXUS_ACP_BRIDGE_MODEL_ID; + delete process.env.NEXUS_ACP_BRIDGE_MODEL_NAME; + delete process.env.ACP_PERMISSION_MODE; + + const config = loadBridgeConfig(["--tool", "codex"]); + + expect(config.selectedTool).toBe("codex"); + expect(config.adapterMode).toBe("acp"); + expect(config.agentCommand).toBe("npx"); + expect(config.agentArgs).toEqual(["--yes", "@zed-industries/codex-acp"]); + expect(config.defaultProviderId).toBe("codex"); + expect(process.env.ACP_PERMISSION_MODE).toBeUndefined(); + }); + + test("CLI tool selection wins over env tool selection", () => { + process.env.NEXUS_ACP_BRIDGE_TOOL = "claude-code"; + delete process.env.NEXUS_ACP_BRIDGE_AGENT_COMMAND; + delete process.env.NEXUS_ACP_BRIDGE_AGENT_ARGS; + delete process.env.NEXUS_ACP_BRIDGE_PROVIDER_ID; + delete process.env.NEXUS_ACP_BRIDGE_PROVIDER_NAME; + delete process.env.NEXUS_ACP_BRIDGE_MODEL_ID; + delete process.env.NEXUS_ACP_BRIDGE_MODEL_NAME; + delete process.env.ACP_PERMISSION_MODE; + + const config = loadBridgeConfig(["--tool=opencode"]); + + expect(config.selectedTool).toBe("opencode"); + expect(config.agentCommand).toBe("bunx"); + expect(config.agentArgs).toEqual(["opencode-ai", "acp"]); + expect(config.defaultProviderId).toBe("opencode"); + // OpenCode preset uses 4081 so it can run side-by-side with the Claude bridge on 4080. + expect(config.port).toBe(4081); + }); + + test("throws for unknown tool presets", () => { + expect(() => loadBridgeConfig(["--tool", "unknown-tool"])).toThrow("Unknown bridge tool preset"); + }); + + test("defaults to newline-framed ACP transport and protocol version 1", () => { + delete process.env.NEXUS_ACP_BRIDGE_ACP_PROTOCOL; + delete process.env.NEXUS_ACP_BRIDGE_ACP_PROTOCOL_VERSION; + process.env.NEXUS_ACP_BRIDGE_ADAPTER = "acp"; + + const config = loadBridgeConfig(); + + expect(config.adapterMode).toBe("acp"); + expect(config.acpProtocol).toBe("newline"); + expect(config.acpProtocolVersion).toBe(1); + }); + + test("allows overriding ACP transport framing to content-length", () => { + process.env.NEXUS_ACP_BRIDGE_ADAPTER = "acp"; + process.env.NEXUS_ACP_BRIDGE_ACP_PROTOCOL = "content-length"; + + const config = loadBridgeConfig(); + + expect(config.acpProtocol).toBe("content-length"); + }); + + test("parses command args helper with mixed quoting", () => { + expect(__private__.parseCommandArgs("--a 1 'two words' \"three words\"")).toEqual([ + "--a", + "1", + "two words", + "three words", + ]); + }); + + test("parses env files with comments, export syntax, and quotes", () => { + expect(__private__.parseEnvFile([ + "# comment", + 'FOO="bar baz"', + "export HELLO=world", + "TRIM=ok # trailing comment", + "", + ].join("\n"))).toEqual({ + FOO: "bar baz", + HELLO: "world", + TRIM: "ok", + }); + }); + + test("parses CLI tool args helper", () => { + expect(__private__.parseCliArgs(["--tool", "codex"])).toMatchObject({ tool: "codex" }); + expect(__private__.parseCliArgs(["--tool=opencode"])).toMatchObject({ tool: "opencode" }); + expect(__private__.parseCliArgs(["--agent", "claude"])).toMatchObject({ tool: "claude" }); + expect(__private__.parseCliArgs([])).toMatchObject({ tool: null }); + }); + + test("--agent claude resolves to claude-code preset", () => { + delete process.env.NEXUS_ACP_BRIDGE_TOOL; + delete process.env.NEXUS_ACP_BRIDGE_AGENT_COMMAND; + delete process.env.NEXUS_ACP_BRIDGE_AGENT_ARGS; + delete process.env.NEXUS_ACP_BRIDGE_PROVIDER_ID; + process.env.NEXUS_ACP_BRIDGE_AUTO_SETUP_CLAUDE = "0"; + + const config = loadBridgeConfig(["--agent", "claude"]); + + expect(config.selectedTool).toBe("claude-code"); + expect(config.defaultProviderId).toBe("claude-code"); + }); + + test("CLI flags override env defaults for cors, port, host, project dirs", () => { + delete process.env.NEXUS_ACP_BRIDGE_CORS_ORIGIN; + delete process.env.NEXUS_ACP_BRIDGE_PORT; + delete process.env.NEXUS_ACP_BRIDGE_HOST; + delete process.env.NEXUS_ACP_BRIDGE_PROJECT_DIRS; + delete process.env.NEXUS_ACP_BRIDGE_PROJECT_DIR; + + const config = loadBridgeConfig([ + "--cors", "http://localhost:9999", + "--port=5555", + "--host", "0.0.0.0", + "--project-dir", "/tmp/a", + "--project-dir=/tmp/b", + ]); + + expect(config.corsOrigin).toBe("http://localhost:9999"); + expect(config.port).toBe(5555); + expect(config.host).toBe("0.0.0.0"); + expect(config.projectDirs).toEqual(["/tmp/a", "/tmp/b"]); + }); + + test("--no-auto-setup disables claude vendor auto-install", () => { + delete process.env.NEXUS_ACP_BRIDGE_AUTO_SETUP_CLAUDE; + + loadBridgeConfig(["--agent", "claude", "--no-auto-setup"]); + + expect(String(process.env.NEXUS_ACP_BRIDGE_AUTO_SETUP_CLAUDE)).toBe("0"); + }); +}); diff --git a/packages/nexus-acp-bridge/src/__tests__/mock-acp-adapter.test.ts b/packages/nexus-acp-bridge/src/__tests__/mock-acp-adapter.test.ts new file mode 100644 index 0000000..d2e9c05 --- /dev/null +++ b/packages/nexus-acp-bridge/src/__tests__/mock-acp-adapter.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { MockACPAdapter, __private__ } from "../adapters/mock"; +import { makeBridgeConfig } from "./test-helpers"; + +const config = makeBridgeConfig(); + +describe("MockACPAdapter", () => { + test("returns OpenCode-compatible provider metadata", async () => { + const adapter = new MockACPAdapter(config); + const providers = await adapter.getConfigProviders(); + + expect(providers.providers).toHaveLength(1); + expect(providers.providers[0]?.models.model?.status).toBe("active"); + expect(providers.default.acp).toBe("model"); + }); + + test("emits parseable workflow JSON for workflow generation prompts", () => { + const output = __private__.buildWorkflowResponse( + "Output a WorkflowJSON object for this workflow. Workflow description: triage customer issues", + ); + const parsed = JSON.parse(output) as { name: string; nodes: unknown[]; edges: unknown[] }; + + expect(parsed.name).toContain("Triage customer issues"); + expect(parsed.nodes).toHaveLength(3); + expect(parsed.edges).toHaveLength(2); + }); + + test("emits parseable example arrays", () => { + const output = __private__.buildExamplesResponse(); + const parsed = JSON.parse(output) as string[]; + + expect(parsed).toHaveLength(5); + expect(parsed.every((item) => item.length > 0)).toBe(true); + }); + + test("exposes a default project resource", async () => { + const adapter = new MockACPAdapter(config); + const resources = await adapter.listResources({ + project: { + id: "project-1", + worktree: process.cwd(), + name: "Nexus", + time: { created: Date.now(), updated: Date.now() }, + sandboxes: [], + }, + }); + + expect(resources.project?.uri).toContain("file://"); + }); +}); + + diff --git a/packages/nexus-acp-bridge/src/__tests__/server.test.ts b/packages/nexus-acp-bridge/src/__tests__/server.test.ts new file mode 100644 index 0000000..b842e9e --- /dev/null +++ b/packages/nexus-acp-bridge/src/__tests__/server.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { MockACPAdapter } from "../adapters/mock"; +import { NexusACPBridgeServer } from "../server/http-server"; +import { makeBridgeConfig } from "./test-helpers"; + +const activeServers: NexusACPBridgeServer[] = []; + +afterEach(() => { + while (activeServers.length > 0) { + activeServers.pop()?.stop(); + } +}); + +function startTestServer() { + const config = makeBridgeConfig({ port: 0 }); + const bridge = new NexusACPBridgeServer(config, new MockACPAdapter(config)); + const server = bridge.start(); + activeServers.push(bridge); + return `http://${server.hostname}:${server.port}`; +} + +describe("NexusACPBridgeServer", () => { + test("serves OpenCode-compatible commands", async () => { + const baseUrl = startTestServer(); + const response = await fetch(`${baseUrl}/command`); + const commands = await response.json() as Array<{ name: string; template: string }>; + + expect(response.ok).toBe(true); + expect(commands.map((command) => command.name)).toEqual(["plan", "test", "web"]); + expect(commands[0]?.template).toBe("/plan {request}"); + }); + + test("advertises the Claude Code model catalog from /config/providers", async () => { + const config = makeBridgeConfig({ + port: 0, + selectedTool: "claude-code", + defaultProviderId: "claude-code", + defaultProviderName: "Claude Code", + defaultModelId: "sonnet", + defaultModelName: "Claude Sonnet", + }); + const bridge = new NexusACPBridgeServer(config, new MockACPAdapter(config)); + const server = bridge.start(); + activeServers.push(bridge); + + const response = await fetch(`http://${server.hostname}:${server.port}/config/providers`); + const payload = await response.json() as { + providers: Array<{ id: string; models: Record }>; + default: Record; + }; + + expect(response.ok).toBe(true); + expect(Object.keys(payload.providers[0]?.models ?? {})).toEqual(["haiku", "sonnet", "opus"]); + expect(payload.providers[0]?.models.sonnet?.name).toBe("Claude Sonnet"); + expect(payload.default["claude-code"]).toBe("sonnet"); + }); + + test("executes session commands by translating them into slash-command prompts", async () => { + const baseUrl = startTestServer(); + + const sessionResponse = await fetch(`${baseUrl}/session`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "Command Test" }), + }); + const session = await sessionResponse.json() as { id: string }; + + const response = await fetch(`${baseUrl}/session/${encodeURIComponent(session.id)}/command`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + command: "plan", + arguments: "triage production incidents", + }), + }); + const message = await response.json() as { + parts: Array<{ type: string; text?: string }>; + info: { role: string }; + }; + + expect(response.ok).toBe(true); + expect(message.info.role).toBe("assistant"); + expect(message.parts[0]?.type).toBe("text"); + expect(message.parts[0]?.text).toContain("/plan triage production incidents"); + }); + + 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)); + const firstServer = firstBridge.start(); + activeServers.push(firstBridge); + + const requestedPort = firstServer.port; + const secondConfig = makeBridgeConfig({ port: requestedPort }); + const secondBridge = new NexusACPBridgeServer(secondConfig, new MockACPAdapter(secondConfig)); + const secondServer = secondBridge.start(); + activeServers.push(secondBridge); + + expect(secondBridge.usedRandomPortFallback()).toBe(true); + expect(secondServer.port).not.toBe(requestedPort); + expect(secondServer.port).toBeGreaterThan(0); + + const response = await fetch(`http://${secondServer.hostname}:${secondServer.port}/global/health`); + const health = await response.json() as { healthy: boolean }; + + expect(response.ok).toBe(true); + expect(health.healthy).toBe(true); + }); +}); + diff --git a/packages/nexus-acp-bridge/src/__tests__/stdio-acp-adapter.test.ts b/packages/nexus-acp-bridge/src/__tests__/stdio-acp-adapter.test.ts new file mode 100644 index 0000000..467c2af --- /dev/null +++ b/packages/nexus-acp-bridge/src/__tests__/stdio-acp-adapter.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { StdioACPAdapter } from "../adapters/stdio"; +import { makeBridgeConfig, makeGenerateTextRequest } from "./test-helpers"; + +describe("StdioACPAdapter", () => { + test("streams stdout from the configured command", async () => { + const adapter = new StdioACPAdapter(makeBridgeConfig({ + adapterMode: "stdio", + agentCommand: process.execPath, + agentArgs: [ + "-e", + "process.stdin.resume();process.stdin.on('data',()=>{});process.stdout.write('chunk-one ');setTimeout(()=>process.stdout.write('chunk-two'),10);setTimeout(()=>process.exit(0),20);", + ], + })); + + let output = ""; + for await (const chunk of adapter.generateText(makeGenerateTextRequest())) { + output += chunk; + } + + expect(output).toBe("chunk-one chunk-two"); + }); + + test("throws a helpful error when stdio mode lacks a command", async () => { + const adapter = new StdioACPAdapter(makeBridgeConfig({ + adapterMode: "stdio", + agentCommand: null, + })); + + await expect(async () => { + for await (const _chunk of adapter.generateText(makeGenerateTextRequest())) { + // consume + } + }).toThrow("NEXUS_ACP_BRIDGE_AGENT_COMMAND is required"); + }); +}); + diff --git a/packages/nexus-acp-bridge/src/__tests__/test-helpers.ts b/packages/nexus-acp-bridge/src/__tests__/test-helpers.ts new file mode 100644 index 0000000..036ee9f --- /dev/null +++ b/packages/nexus-acp-bridge/src/__tests__/test-helpers.ts @@ -0,0 +1,56 @@ +import type { BridgeConfig, GenerateTextRequest } from "../types"; + +export function makeBridgeConfig(overrides: Partial = {}): BridgeConfig { + return { + adapterMode: "mock", + selectedTool: null, + host: "127.0.0.1", + port: 4080, + serverIdleTimeoutSeconds: 120, + corsOrigin: "http://localhost:3000", + version: "test", + projectDirs: [process.cwd()], + allowArbitraryDirectories: false, + defaultProviderId: "acp", + defaultProviderName: "ACP", + defaultModelId: "model", + defaultModelName: "Model", + defaultTools: ["read_file", "apply_patch"], + agentCommand: null, + agentArgs: [], + agentCwd: null, + acpProtocol: "newline", + acpProtocolVersion: 1, + mockStreamDelayMs: 0, + maxFileReadBytes: 2 * 1024 * 1024, + ...overrides, + }; +} + +export function makeGenerateTextRequest(): GenerateTextRequest { + return { + session: { + id: "session-1", + slug: "session-1", + projectID: "project-1", + directory: process.cwd(), + title: "Session", + version: "test", + time: { created: Date.now(), updated: Date.now() }, + }, + project: { + id: "project-1", + worktree: process.cwd(), + name: "Nexus", + time: { created: Date.now(), updated: Date.now() }, + sandboxes: [], + }, + payload: { + parts: [{ type: "text", text: "hello from nexus" }], + system: "system prompt", + model: { providerID: "acp", modelID: "model" }, + }, + signal: new AbortController().signal, + }; +} + diff --git a/packages/nexus-acp-bridge/src/adapters/acp-protocol.ts b/packages/nexus-acp-bridge/src/adapters/acp-protocol.ts new file mode 100644 index 0000000..35222da --- /dev/null +++ b/packages/nexus-acp-bridge/src/adapters/acp-protocol.ts @@ -0,0 +1,531 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { AsyncQueue } from "../transport/async-queue"; +import { ACPJsonRpcClient, type ACPJsonRpcClientLike } from "../transport/jsonrpc-client"; +import { + buildDefaultConfigProviders, + buildDefaultMcpStatus, + buildDefaultResources, + buildDefaultTools, +} from "../server/default-provider"; +import type { + ACPAdapter, + BridgeConfig, + Command, + ConfigProviders, + GenerateTextRequest, + HealthInfo, + MCPStatus, + McpResource, + Model, + Project, + Provider, + ToolListItem, +} from "../types"; + +const DISCOVERED_RELEASE_DATE = "1970-01-01T00:00:00Z"; + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) : []; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function isPathInside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function pickAllowOptionId(options: unknown): string | null { + if (!Array.isArray(options)) return null; + for (const entry of options) { + const record = asRecord(entry); + if (!record) continue; + const id = asString(record.optionId) ?? asString(record.id); + const kind = asString(record.kind); + if (id && (kind === "allow_once" || kind === "allow_always" || /allow/i.test(id))) { + return id; + } + } + const first = asRecord(options[0]); + return asString(first?.optionId) ?? asString(first?.id); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function normalizeAvailableCommands(value: unknown): Command[] { + if (!Array.isArray(value)) return []; + + return value.flatMap((entry) => { + const record = asRecord(entry); + const name = asString(record?.name); + if (!name) return []; + + const input = asRecord(record?.input); + const hint = asString(input?.hint); + const hints = [ + ...asStringArray(record?.hints), + ...(hint ? [hint] : []), + ]; + + return [{ + name, + description: asString(record?.description) ?? undefined, + source: "command" as const, + template: `/${name}${hint ? ` {${hint}}` : ""}`, + hints: [...new Set(hints)], + } satisfies Command]; + }); +} + +function splitProviderModel(modelId: string): { providerID: string; modelID: string } | null { + const separator = modelId.indexOf("/"); + if (separator <= 0 || separator >= modelId.length - 1) return null; + + return { + providerID: modelId.slice(0, separator), + modelID: modelId.slice(separator + 1), + }; +} + +function humanizeProviderId(providerID: string): string { + return providerID + .split(/[-_]/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function buildDiscoveredModel(providerID: string, modelID: string, name: string): Model { + const family = modelID.split("/")[0] ?? modelID; + return { + id: modelID, + providerID, + api: { + id: providerID, + url: "https://example.invalid/acp-discovery", + npm: "nexus-acp-bridge", + }, + name, + family, + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { output: 8192, context: 200000 }, + status: "active", + options: {}, + headers: {}, + release_date: DISCOVERED_RELEASE_DATE, + }; +} + +function extractConfigProvidersFromSession(result: unknown): ConfigProviders | null { + const record = asRecord(result); + const modelsRecord = asRecord(record?.models); + const availableModels = asArray(modelsRecord?.availableModels); + if (availableModels.length === 0) return null; + + const currentModel = splitProviderModel(asString(modelsRecord?.currentModelId) ?? ""); + const providers = new Map(); + + for (const entry of availableModels) { + const modelRecord = asRecord(entry); + const fullModelId = asString(modelRecord?.modelId) ?? asString(modelRecord?.value); + if (!fullModelId) continue; + + const split = splitProviderModel(fullModelId); + if (!split) continue; + + const fullName = asString(modelRecord?.name) ?? fullModelId; + const separator = fullName.indexOf("/"); + const providerName = separator > 0 ? fullName.slice(0, separator).trim() : humanizeProviderId(split.providerID); + const modelName = separator > 0 ? fullName.slice(separator + 1).trim() : fullName; + + let bucket = providers.get(split.providerID); + if (!bucket) { + bucket = { + provider: { + id: split.providerID, + name: providerName, + source: "api", + env: [], + options: {}, + models: {}, + }, + firstModelId: null, + }; + providers.set(split.providerID, bucket); + } + + if (!bucket.firstModelId) { + bucket.firstModelId = split.modelID; + } + + bucket.provider.models[split.modelID] = buildDiscoveredModel(split.providerID, split.modelID, modelName); + } + + if (providers.size === 0) return null; + + const defaults: Record = {}; + for (const [providerID, bucket] of providers.entries()) { + const currentForProvider = currentModel?.providerID === providerID ? currentModel.modelID : null; + defaults[providerID] = currentForProvider && bucket.provider.models[currentForProvider] + ? currentForProvider + : bucket.firstModelId ?? Object.keys(bucket.provider.models)[0] ?? "default"; + } + + return { + providers: [...providers.values()].map((bucket) => bucket.provider), + default: defaults, + }; +} + +export class ACPProtocolAdapter implements ACPAdapter { + private readonly client: ACPJsonRpcClientLike; + private readonly sessionMap = new Map(); + private readonly commandDiscoverySessionMap = new Map(); + private readonly commandCache = new Map(); + private readonly commandReadyResolvers = new Map void>(); + private readonly unregisterCallbacks: Array<() => void> = []; + private initializePromise: Promise | null = null; + private initialized = false; + + constructor( + private readonly config: BridgeConfig, + client?: ACPJsonRpcClientLike, + ) { + this.client = client ?? new ACPJsonRpcClient(config); + + const register = (method: string, handler: (params: unknown) => Promise | unknown) => { + const unregister = this.client.setRequestHandler?.(method, handler); + if (unregister) this.unregisterCallbacks.push(unregister); + }; + + register("fs/read_text_file", (params) => this.handleReadTextFile(params)); + register("fs/write_text_file", (params) => this.handleWriteTextFile(params)); + register("session/request_permission", (params) => this.handleRequestPermission(params)); + + const unregisterNotification = this.client.onNotification((notification) => { + if (notification.method !== "session/update") return; + + const params = asRecord(notification.params); + const acpSessionId = asString(params?.sessionId); + const update = asRecord(params?.update); + if (!acpSessionId || !update) return; + + if (update.sessionUpdate === "available_commands_update") { + this.commandCache.set(acpSessionId, normalizeAvailableCommands(update.availableCommands)); + const resolve = this.commandReadyResolvers.get(acpSessionId); + if (resolve) { + this.commandReadyResolvers.delete(acpSessionId); + resolve(); + } + } + }); + this.unregisterCallbacks.push(unregisterNotification); + } + + async dispose(): Promise { + this.commandDiscoverySessionMap.clear(); + this.commandCache.clear(); + this.commandReadyResolvers.clear(); + + for (const unregister of this.unregisterCallbacks) { + try { unregister(); } catch { /* ignore */ } + } + this.unregisterCallbacks.length = 0; + await this.client.close(); + } + + async getHealth(): Promise { + await this.ensureInitialized(); + return { + healthy: true, + version: `${this.config.version}-nexus-acp`, + }; + } + + async getConfigProviders(): Promise { + await this.ensureInitialized(); + + try { + const cwd = this.config.agentCwd ?? this.config.projectDirs[0] ?? process.cwd(); + const discovery = await this.client.request("session/new", { + cwd, + mcpServers: [], + }); + const providers = extractConfigProvidersFromSession(discovery); + if (providers) { + return providers; + } + } catch { + // Fall back to the synthetic catalog when the ACP agent does not advertise models. + } + + return buildDefaultConfigProviders(this.config, "acp"); + } + + async listCommands(input: { project: Project }): Promise { + await this.ensureInitialized(); + const acpSessionId = await this.resolveCommandDiscoverySession(input.project); + if (!this.commandCache.has(acpSessionId)) { + await this.waitForCommandAdvertisement(acpSessionId); + } + return this.commandCache.get(acpSessionId) ?? []; + } + + async listTools(_input: { provider: string; model: string; project: Project }): Promise { + return buildDefaultTools(this.config); + } + + async getMcpStatus(_input: { project: Project }): Promise> { + return buildDefaultMcpStatus(this.config); + } + + async listResources(input: { project: Project }): Promise> { + return buildDefaultResources( + this.config, + input.project, + "Current project root exposed by the ACP bridge.", + ); + } + + async *generateText(request: GenerateTextRequest): AsyncIterable { + await this.ensureInitialized(); + const acpSessionId = await this.resolveAcpSession(request); + + const queue = new AsyncQueue(); + + const unsubscribe = this.client.onSessionUpdate?.(acpSessionId, (body) => { + const update = asRecord(body); + if (!update) return; + // Accept both `agent_message_chunk` (final response stream) AND + // `agent_thought_chunk` (reasoning/plan stream). Agents differ in which + // they use: Claude Code emits message_chunk, OpenCode emits its + // workflow JSON via thought_chunk during the plan phase. Treating both + // 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 !== "agent_message_chunk" && + update.sessionUpdate !== "agent_thought_chunk" + ) return; + + const content = asRecord(update.content); + if (!content) return; + + if (content.type === "text") { + const text = asString(content.text); + if (text) queue.push(text); + } + }) ?? (() => {}); + + const abortHandler = () => { + void this.client.notify("session/cancel", { sessionId: acpSessionId }).catch(() => {}); + }; + if (request.signal.aborted) { + abortHandler(); + } else { + request.signal.addEventListener("abort", abortHandler, { once: true }); + } + + const promptPrompt = [ + ...(request.payload.system?.trim() + ? [{ + type: "text" as const, + text: `System instructions:\n${request.payload.system.trim()}`, + }] + : []), + ...request.payload.parts + .filter((part) => part.type === "text" && part.text.length > 0) + .map((part) => ({ type: "text" as const, text: part.text })), + ]; + + const promptPromise = this.client.request<{ stopReason?: string }>("session/prompt", { + sessionId: acpSessionId, + prompt: promptPrompt, + }).then( + () => { queue.close(); }, + (error) => { queue.fail(error); }, + ); + + try { + for await (const chunk of queue) { + if (request.signal.aborted) break; + yield chunk; + } + await promptPromise; + } finally { + request.signal.removeEventListener("abort", abortHandler); + unsubscribe(); + } + } + + async closeSession(sessionId: string): Promise { + const acpSessionId = this.sessionMap.get(sessionId); + if (!acpSessionId) return; + + this.sessionMap.delete(sessionId); + await this.client.request("session/close", { sessionId: acpSessionId }).catch(() => undefined); + } + + private ensureInitialized(): Promise { + if (this.initialized) return Promise.resolve(); + if (this.initializePromise) return this.initializePromise; + + this.initializePromise = (async () => { + await this.client.connect(); + await this.client.request("initialize", { + protocolVersion: this.config.acpProtocolVersion, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: false, + }, + clientInfo: { + name: "nexus-acp-bridge", + version: this.config.version, + }, + }); + this.initialized = true; + })().catch((error) => { + this.initializePromise = null; + throw error; + }).finally(() => { + if (this.initialized) this.initializePromise = null; + }); + + return this.initializePromise; + } + + private async resolveAcpSession(request: GenerateTextRequest): Promise { + const existing = this.sessionMap.get(request.session.id); + if (existing) return existing; + + const result = await this.client.request("session/new", { + cwd: request.project.worktree, + mcpServers: [], + }); + + const acpSessionId = asString(asRecord(result)?.sessionId); + if (!acpSessionId) { + throw new Error("ACP agent did not return a sessionId for session/new"); + } + + this.sessionMap.set(request.session.id, acpSessionId); + return acpSessionId; + } + + private async resolveCommandDiscoverySession(project: Project): Promise { + const existing = this.commandDiscoverySessionMap.get(project.worktree); + if (existing) return existing; + + const result = await this.client.request("session/new", { + cwd: project.worktree, + mcpServers: [], + }); + + const acpSessionId = asString(asRecord(result)?.sessionId); + if (!acpSessionId) { + throw new Error("ACP agent did not return a sessionId for session/new"); + } + + this.commandDiscoverySessionMap.set(project.worktree, acpSessionId); + return acpSessionId; + } + + private async waitForCommandAdvertisement(acpSessionId: string): Promise { + if (this.commandCache.has(acpSessionId)) return; + + await Promise.race([ + new Promise((resolve) => { + this.commandReadyResolvers.set(acpSessionId, resolve); + }), + sleep(250), + ]); + + this.commandReadyResolvers.delete(acpSessionId); + } + + private async handleReadTextFile(params: unknown): Promise<{ content: string }> { + const record = asRecord(params); + const requestedPath = asString(record?.path); + if (!requestedPath) { + throw new Error("fs/read_text_file requires an absolute path"); + } + + const absolutePath = this.requirePathInsideProject(requestedPath); + + const stat = await fs.stat(absolutePath); + if (stat.size > this.config.maxFileReadBytes) { + throw new Error(`File exceeds bridge size cap (${stat.size} > ${this.config.maxFileReadBytes})`); + } + + const raw = await fs.readFile(absolutePath, "utf8"); + + const line = typeof record?.line === "number" ? record.line : null; + const limit = typeof record?.limit === "number" ? record.limit : null; + + if (line === null && limit === null) { + return { content: raw }; + } + + const lines = raw.split(/\r?\n/); + const startIndex = line && line > 0 ? line - 1 : 0; + const endIndex = limit && limit > 0 ? Math.min(lines.length, startIndex + limit) : lines.length; + return { content: lines.slice(startIndex, endIndex).join("\n") }; + } + + private async handleWriteTextFile(params: unknown): Promise> { + const record = asRecord(params); + const requestedPath = asString(record?.path); + const content = typeof record?.content === "string" ? record.content : null; + if (!requestedPath || content === null) { + throw new Error("fs/write_text_file requires path and content"); + } + + const absolutePath = this.requirePathInsideProject(requestedPath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content, "utf8"); + return {}; + } + + private async handleRequestPermission(params: unknown): Promise<{ outcome: unknown }> { + const record = asRecord(params); + const optionId = pickAllowOptionId(record?.options); + if (optionId) { + return { outcome: { outcome: "selected", optionId } }; + } + return { outcome: { outcome: "cancelled" } }; + } + + private requirePathInsideProject(requestedPath: string): string { + const absolute = path.isAbsolute(requestedPath) ? path.resolve(requestedPath) : path.resolve(requestedPath); + for (const root of this.config.projectDirs) { + const normalizedRoot = path.resolve(root); + if (isPathInside(normalizedRoot, absolute)) return absolute; + } + throw new Error(`Path is outside of configured project roots: ${requestedPath}`); + } +} + diff --git a/packages/nexus-acp-bridge/src/adapters/mock.ts b/packages/nexus-acp-bridge/src/adapters/mock.ts new file mode 100644 index 0000000..6fa5756 --- /dev/null +++ b/packages/nexus-acp-bridge/src/adapters/mock.ts @@ -0,0 +1,253 @@ +import { + buildDefaultConfigProviders, + buildDefaultMcpStatus, + buildDefaultResources, + buildDefaultTools, +} from "../server/default-provider"; +import type { + ACPAdapter, + BridgeConfig, + Command, + ConfigProviders, + GenerateTextRequest, + HealthInfo, + MCPStatus, + McpResource, + Project, + ToolListItem, +} from "../types"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, " ") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") || "workflow"; +} + +function extractDescription(prompt: string): string { + const match = prompt.match(/workflow description:\s*([\s\S]+)$/i); + return match?.[1]?.trim() ?? prompt.trim(); +} + +function sentenceCase(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return "Generated Workflow"; + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); +} + +function buildWorkflowResponse(prompt: string): string { + const description = extractDescription(prompt); + const title = sentenceCase(description.split(/[\n.]/)[0] ?? "Generated Workflow"); + const baseSlug = slugify(title); + + return JSON.stringify({ + name: title, + nodes: [ + { + id: `${baseSlug}-start`, + type: "start", + position: { x: 0, y: 0 }, + data: { type: "start", label: "Start", name: "Start" }, + }, + { + id: `${baseSlug}-agent`, + type: "agent", + position: { x: 280, y: 0 }, + data: { + type: "agent", + label: "Agent", + name: `${title} Agent`, + promptText: `Handle the workflow request: ${description}`, + }, + }, + { + id: `${baseSlug}-end`, + type: "end", + position: { x: 560, y: 0 }, + data: { type: "end", label: "End", name: "End" }, + }, + ], + edges: [ + { id: `${baseSlug}-e1`, source: `${baseSlug}-start`, target: `${baseSlug}-agent` }, + { id: `${baseSlug}-e2`, source: `${baseSlug}-agent`, target: `${baseSlug}-end` }, + ], + ui: { + sidebarOpen: false, + minimapVisible: true, + viewport: { x: 0, y: 0, zoom: 1 }, + }, + }); +} + +function buildExamplesResponse(): string { + return JSON.stringify([ + "Triage inbound customer support issues and route them by severity", + "Review a pull request, summarize risks, and request human approval when needed", + "Turn meeting notes into a prioritized implementation workflow with owners", + "Analyze bug reports, gather code context, and draft a remediation plan", + "Process research documents and generate a stakeholder-ready summary pack", + ]); +} + +function buildResearchEnrichmentResponse(prompt: string): string { + const noteMatch = prompt.match(/Note:\s*([^\n]+)/i); + const note = noteMatch?.[1]?.trim() ?? "Research note"; + const contentType = /^https?:\/\//i.test(note) ? "source" : note.endsWith("?") ? "question" : "claim"; + + return JSON.stringify({ + contentType, + category: contentType === "source" ? "Sources" : "Findings", + annotation: `Mock AI annotation for: ${note}`, + confidence: 0.82, + influencedByIndices: [], + isUnrelated: false, + mergeWithIndex: null, + sources: contentType === "source" ? [{ id: "source-1", title: "Example source", url: note.split(/\s+/)[0] }] : [], + }); +} + +function buildResearchSynthesisResponse(prompt: string): string { + const titleMatch = prompt.match(/Preferred title:\s*([^\n]+)/i); + const title = titleMatch?.[1]?.trim() || "Research Synthesis"; + + return JSON.stringify({ + title, + content: [ + `# ${title}`, + "", + "## Findings", + "- Mock ACP bridge synthesized the selected research notes.", + "", + "## Next Steps", + "- Review the enriched tiles and promote relevant findings to Brain.", + ].join("\n"), + }); +} + +function buildPromptResponse(prompt: string, system?: string): string { + const description = extractDescription(prompt); + if (system?.includes("script generator")) { + return [ + 'export async function main() {', + ` console.log(${JSON.stringify(`Generated helper for: ${description}`)});`, + "}", + "", + "if (import.meta.main) {", + " await main();", + "}", + ].join("\n"); + } + + return [ + `# ${sentenceCase(description.split(/[\n.]/)[0] ?? "Generated Prompt")}`, + "", + "## Purpose", + `- ${description}`, + "", + "## Instructions", + "- Review the incoming context carefully.", + "- Produce a concise, structured response.", + "- Call out assumptions and edge cases when relevant.", + ].join("\n"); +} + +function buildMockCommands(): Command[] { + return [ + { + name: "plan", + description: "Create a detailed implementation plan", + source: "command", + template: "/plan {request}", + hints: ["description of what to plan"], + }, + { + name: "test", + description: "Run or suggest relevant tests for the current project", + source: "command", + template: "/test {target}", + hints: ["what should be tested"], + }, + { + name: "web", + description: "Search the web for supporting information", + source: "command", + template: "/web {query}", + hints: ["query to search for"], + }, + ]; +} + +export class MockACPAdapter implements ACPAdapter { + constructor(private readonly config: BridgeConfig) {} + + async getHealth(): Promise { + return { + healthy: true, + version: this.config.version, + }; + } + + async getConfigProviders(): Promise { + return buildDefaultConfigProviders(this.config, "acp-mock"); + } + + async listCommands(_input: { project: Project }): Promise { + return buildMockCommands(); + } + + async listTools(_input: { provider: string; model: string; project: Project }): Promise { + return buildDefaultTools(this.config); + } + + async getMcpStatus(_input: { project: Project }): Promise> { + return buildDefaultMcpStatus(this.config); + } + + async listResources(input: { project: Project }): Promise> { + return buildDefaultResources( + this.config, + input.project, + "Current project root exposed by the mock ACP adapter.", + ); + } + + async *generateText(request: GenerateTextRequest): AsyncIterable { + const prompt = request.payload.parts.map((part) => part.text).join("\n\n"); + const system = request.payload.system; + + let output = buildPromptResponse(prompt, system); + if (/Enrich this research note for Nexus Research/i.test(prompt)) { + output = buildResearchEnrichmentResponse(prompt); + } else if (/Synthesize these Nexus Research notes/i.test(prompt)) { + output = buildResearchSynthesisResponse(prompt); + } else if (/Output a WorkflowJSON object/i.test(prompt)) { + output = buildWorkflowResponse(prompt); + } else if (/JSON array/i.test(prompt)) { + output = buildExamplesResponse(); + } + + const chunkSize = output.startsWith("{") || output.startsWith("[") ? 48 : 64; + for (let index = 0; index < output.length; index += chunkSize) { + if (request.signal.aborted) { + break; + } + const chunk = output.slice(index, index + chunkSize); + yield chunk; + if (this.config.mockStreamDelayMs > 0) { + await sleep(this.config.mockStreamDelayMs); + } + } + } +} + +export const __private__ = { + buildWorkflowResponse, + buildExamplesResponse +}; + diff --git a/packages/nexus-acp-bridge/src/adapters/stdio.ts b/packages/nexus-acp-bridge/src/adapters/stdio.ts new file mode 100644 index 0000000..df0df40 --- /dev/null +++ b/packages/nexus-acp-bridge/src/adapters/stdio.ts @@ -0,0 +1,123 @@ +import { spawn } from "node:child_process"; +import { + buildDefaultConfigProviders, + buildDefaultMcpStatus, + buildDefaultResources, + buildDefaultTools, +} from "../server/default-provider"; +import type { + ACPAdapter, + BridgeConfig, + Command, + ConfigProviders, + GenerateTextRequest, + HealthInfo, + MCPStatus, + McpResource, + Project, + ToolListItem, +} from "../types"; + +export class StdioACPAdapter implements ACPAdapter { + constructor(private readonly config: BridgeConfig) {} + + async getHealth(): Promise { + return { + healthy: true, + version: `${this.config.version}-stdio`, + }; + } + + async getConfigProviders(): Promise { + return buildDefaultConfigProviders(this.config, "acp-stdio"); + } + + async listCommands(_input: { project: Project }): Promise { + return []; + } + + async listTools(_input: { provider: string; model: string; project: Project }): Promise { + return buildDefaultTools(this.config); + } + + async getMcpStatus(_input: { project: Project }): Promise> { + return buildDefaultMcpStatus(this.config); + } + + async listResources(input: { project: Project }): Promise> { + return buildDefaultResources( + this.config, + input.project, + "Current project root exposed by the stdio bridge adapter.", + ); + } + + async *generateText(request: GenerateTextRequest): AsyncIterable { + if (!this.config.agentCommand) { + throw new Error("NEXUS_ACP_BRIDGE_AGENT_COMMAND is required when NEXUS_ACP_BRIDGE_ADAPTER=stdio"); + } + + const cwd = this.config.agentCwd ?? request.project.worktree; + const child = spawn(this.config.agentCommand, this.config.agentArgs, { + cwd, + env: { + ...process.env, + NEXUS_ACP_BRIDGE_SESSION_ID: request.session.id, + NEXUS_ACP_BRIDGE_PROJECT_DIR: request.project.worktree, + NEXUS_ACP_BRIDGE_PROVIDER_ID: request.payload.model?.providerID ?? this.config.defaultProviderId, + NEXUS_ACP_BRIDGE_MODEL_ID: request.payload.model?.modelID ?? this.config.defaultModelId, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const stderrChunks: string[] = []; + child.stderr?.on("data", (chunk) => { + stderrChunks.push(chunk.toString("utf8")); + }); + + const terminate = () => { + if (!child.killed) { + child.kill("SIGTERM"); + } + }; + request.signal.addEventListener("abort", terminate, { once: true }); + + const exitPromise = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 0)); + }); + + const envelope = JSON.stringify({ + session: request.session, + project: request.project, + payload: request.payload, + }); + child.stdin?.end(envelope); + + try { + const stdout = child.stdout; + if (!stdout) { + throw new Error("Agent process did not expose stdout"); + } + + for await (const chunk of stdout) { + if (request.signal.aborted) { + break; + } + yield chunk.toString("utf8"); + } + + const exitCode = await exitPromise; + if (request.signal.aborted) { + return; + } + if (exitCode !== 0) { + const stderr = stderrChunks.join("").trim(); + throw new Error(stderr || `Agent process exited with code ${exitCode}`); + } + } finally { + request.signal.removeEventListener("abort", terminate); + } + } +} + diff --git a/packages/nexus-acp-bridge/src/bin.ts b/packages/nexus-acp-bridge/src/bin.ts new file mode 100644 index 0000000..ec53b53 --- /dev/null +++ b/packages/nexus-acp-bridge/src/bin.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env bun +import { createAdapter } from "./index"; +import { loadBridgeConfig } from "./config"; +import { NexusACPBridgeServer } from "./server/http-server"; + +const config = loadBridgeConfig(); +const adapter = createAdapter(config); +const bridge = new NexusACPBridgeServer(config, adapter); +const server = bridge.start(); + +console.log(`[nexus-acp-bridge] listening on http://${server.hostname}:${server.port}`); +if (bridge.usedRandomPortFallback()) { + console.warn( + `[nexus-acp-bridge] port ${config.port} was already in use; fell back to random port ${server.port}`, + ); +} +console.log(`[nexus-acp-bridge] adapter mode: ${config.adapterMode}`); +console.log(`[nexus-acp-bridge] selected tool: ${config.selectedTool ?? "custom"}`); +console.log(`[nexus-acp-bridge] CORS origin: ${config.corsOrigin}`); +console.log(`[nexus-acp-bridge] Project roots: ${config.projectDirs.join(", ")}`); diff --git a/packages/nexus-acp-bridge/src/config.ts b/packages/nexus-acp-bridge/src/config.ts new file mode 100644 index 0000000..119e4dd --- /dev/null +++ b/packages/nexus-acp-bridge/src/config.ts @@ -0,0 +1,306 @@ +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"; + +const BUNDLED_ENV_FILES = [ + new URL("../.env.defaults", import.meta.url), + new URL("../.env.local", import.meta.url), +]; + +function parseEnvValue(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + + const commentIndex = trimmed.search(/\s+#/); + return commentIndex >= 0 ? trimmed.slice(0, commentIndex).trimEnd() : trimmed; +} + +function parseEnvFile(content: string): Record { + const values: Record = {}; + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const normalized = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed; + const equalsIndex = normalized.indexOf("="); + if (equalsIndex <= 0) continue; + + const key = normalized.slice(0, equalsIndex).trim(); + const rawValue = normalized.slice(equalsIndex + 1); + if (!key) continue; + + values[key] = parseEnvValue(rawValue); + } + + return values; +} + +function applyBundledEnvDefaults(shellSnapshot: Record): void { + for (const fileUrl of BUNDLED_ENV_FILES) { + const filePath = fileURLToPath(fileUrl); + if (!fs.existsSync(filePath)) continue; + + const content = fs.readFileSync(filePath, "utf8"); + const values = parseEnvFile(content); + for (const [key, value] of Object.entries(values)) { + if (shellSnapshot[key] === undefined) { + process.env[key] = value; + } + } + } +} + +function parseCliArgs(argv: string[]): { + tool: string | null; + cors: string | null; + port: string | null; + host: string | null; + projectDirs: string[]; + autoSetupClaude: boolean | 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; + const projectDirs: string[] = []; + + const takeValue = (flagName: string, index: number): { value: string | null; consumed: number } => { + const current = argv[index] ?? ""; + const eqPrefix = `${flagName}=`; + if (current.startsWith(eqPrefix)) { + return { value: current.slice(eqPrefix.length).trim() || null, consumed: 0 }; + } + return { value: argv[index + 1]?.trim() || null, consumed: 1 }; + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg) continue; + + // --tool / --agent (aliases) + if (arg === "--tool" || arg === "--agent" || arg.startsWith("--tool=") || arg.startsWith("--agent=")) { + const flag = arg.startsWith("--agent") ? "--agent" : "--tool"; + const { value, consumed } = takeValue(flag, index); + tool = value; + index += consumed; + continue; + } + + if (arg === "--cors" || arg.startsWith("--cors=")) { + const { value, consumed } = takeValue("--cors", index); + cors = value; + index += consumed; + continue; + } + + if (arg === "--port" || arg.startsWith("--port=")) { + const { value, consumed } = takeValue("--port", index); + port = value; + index += consumed; + continue; + } + + if (arg === "--host" || arg.startsWith("--host=")) { + const { value, consumed } = takeValue("--host", index); + host = value; + index += consumed; + continue; + } + + if (arg === "--project-dir" || arg.startsWith("--project-dir=")) { + const { value, consumed } = takeValue("--project-dir", index); + if (value) projectDirs.push(value); + index += consumed; + continue; + } + + if (arg === "--no-auto-setup" || arg === "--no-auto-setup-claude") { + autoSetupClaude = false; + continue; + } + if (arg === "--auto-setup" || arg === "--auto-setup-claude") { + autoSetupClaude = true; + continue; + } + } + + return { tool, cors, port, host, projectDirs, autoSetupClaude }; +} + +const TOOL_ALIASES: Record = { + claude: "claude-code", + "claude-code": "claude-code", + codex: "codex", + opencode: "opencode", +}; + +function resolveToolAlias(tool: string | null): string | null { + if (!tool) return null; + return TOOL_ALIASES[tool.toLowerCase()] ?? tool; +} + +function applyToolPreset( + selectedTool: string | null, + shellSnapshot: Record, +): void { + if (!selectedTool) return; + + const preset = getBridgeToolPreset(selectedTool); + if (!preset) { + throw new Error( + `Unknown bridge tool preset: ${selectedTool}. Supported presets: ${BRIDGE_TOOL_PRESET_IDS.join(", ")}`, + ); + } + + for (const [key, value] of Object.entries(preset.resolveEnv())) { + if (shellSnapshot[key] === undefined) { + process.env[key] = value; + } + } +} + +function readEnv(name: string): string | undefined { + const value = process.env[name]?.trim(); + return value ? value : undefined; +} + +function readBoolean(name: string, fallback: boolean): boolean { + const value = readEnv(name); + if (!value) return fallback; + return ["1", "true", "yes", "on"].includes(value.toLowerCase()); +} + +function readNumber(name: string, fallback: number): number { + const raw = readEnv(name); + if (!raw) return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function readCsv(name: string): string[] { + const raw = readEnv(name); + if (!raw) return []; + return raw.split(",").map((value) => value.trim()).filter(Boolean); +} + +function hasExplicitRuntimeConfig(shellSnapshot: Record): boolean { + return Boolean( + shellSnapshot.NEXUS_ACP_BRIDGE_ADAPTER ?? + readEnv("NEXUS_ACP_BRIDGE_ADAPTER") ?? + shellSnapshot.NEXUS_ACP_BRIDGE_AGENT_COMMAND ?? + readEnv("NEXUS_ACP_BRIDGE_AGENT_COMMAND"), + ); +} + +function parseCommandArgs(raw: string | undefined): string[] { + if (!raw) return []; + + const matches = raw.match(/"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+/g) ?? []; + return matches + .map((token) => token.trim()) + .filter(Boolean) + .map((token) => { + if ( + (token.startsWith('"') && token.endsWith('"')) || + (token.startsWith("'") && token.endsWith("'")) + ) { + return token.slice(1, -1); + } + return token; + }); +} + +export function loadBridgeConfig(argv: string[] = process.argv.slice(2)): BridgeConfig { + const cliArgs = parseCliArgs(argv); + + // Apply CLI flags as the highest-precedence source by writing them into + // process.env BEFORE we snapshot, so they override .env.defaults and presets. + if (cliArgs.cors) process.env.NEXUS_ACP_BRIDGE_CORS_ORIGIN = cliArgs.cors; + if (cliArgs.port) process.env.NEXUS_ACP_BRIDGE_PORT = cliArgs.port; + if (cliArgs.host) process.env.NEXUS_ACP_BRIDGE_HOST = cliArgs.host; + if (cliArgs.projectDirs.length > 0) { + process.env.NEXUS_ACP_BRIDGE_PROJECT_DIRS = cliArgs.projectDirs.join(","); + } + if (cliArgs.autoSetupClaude !== null) { + process.env.NEXUS_ACP_BRIDGE_AUTO_SETUP_CLAUDE = cliArgs.autoSetupClaude ? "1" : "0"; + } + + const shellSnapshot: Record = { ...process.env }; + applyBundledEnvDefaults(shellSnapshot); + const selectedTool = resolveToolAlias( + cliArgs.tool ?? + shellSnapshot.NEXUS_ACP_BRIDGE_TOOL ?? + readEnv("NEXUS_ACP_BRIDGE_TOOL") ?? + (hasExplicitRuntimeConfig(shellSnapshot) ? null : "claude-code"), + ); + applyToolPreset(selectedTool, shellSnapshot); + + const explicitProjectDirs = readCsv("NEXUS_ACP_BRIDGE_PROJECT_DIRS"); + const singleProjectDir = readEnv("NEXUS_ACP_BRIDGE_PROJECT_DIR"); + const defaultProjectDir = path.resolve(singleProjectDir ?? process.cwd()); + const projectDirs = (explicitProjectDirs.length > 0 + ? explicitProjectDirs + : [defaultProjectDir]) + .map((dir) => path.resolve(dir)); + + const defaultTools = readCsv("NEXUS_ACP_BRIDGE_TOOLS"); + const adapterModeRaw = readEnv("NEXUS_ACP_BRIDGE_ADAPTER"); + const adapterMode = adapterModeRaw === "stdio" || adapterModeRaw === "acp" + ? adapterModeRaw + : "mock"; + const agentCommand = readEnv("NEXUS_ACP_BRIDGE_AGENT_COMMAND") ?? null; + const agentCwd = readEnv("NEXUS_ACP_BRIDGE_AGENT_CWD"); + const acpProtocol = readEnv("NEXUS_ACP_BRIDGE_ACP_PROTOCOL") === "content-length" + ? "content-length" + : "newline"; + + return { + adapterMode, + selectedTool, + host: readEnv("NEXUS_ACP_BRIDGE_HOST") ?? "127.0.0.1", + port: readNumber("NEXUS_ACP_BRIDGE_PORT", 4080), + serverIdleTimeoutSeconds: Math.max(0, readNumber("NEXUS_ACP_BRIDGE_IDLE_TIMEOUT_SECONDS", 0)), + corsOrigin: readEnv("NEXUS_ACP_BRIDGE_CORS_ORIGIN") ?? "http://localhost:3000", + version: readEnv("NEXUS_ACP_BRIDGE_VERSION") ?? "0.1.0", + projectDirs, + allowArbitraryDirectories: readBoolean("NEXUS_ACP_BRIDGE_ALLOW_ARBITRARY_DIRECTORIES", false), + defaultProviderId: readEnv("NEXUS_ACP_BRIDGE_PROVIDER_ID") ?? "acp", + defaultProviderName: readEnv("NEXUS_ACP_BRIDGE_PROVIDER_NAME") ?? "ACP Bridge", + defaultModelId: readEnv("NEXUS_ACP_BRIDGE_MODEL_ID") ?? "default", + defaultModelName: readEnv("NEXUS_ACP_BRIDGE_MODEL_NAME") ?? "ACP Default Model", + defaultTools: defaultTools.length > 0 + ? defaultTools + : [ + "read_file", + "grep_search", + "semantic_search", + "apply_patch", + "run_in_terminal", + ], + agentCommand, + agentArgs: parseCommandArgs(readEnv("NEXUS_ACP_BRIDGE_AGENT_ARGS")), + agentCwd: agentCwd ? path.resolve(agentCwd) : null, + acpProtocol, + 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), + }; +} + +export const __private__ = { + parseCliArgs, + parseEnvFile, + parseCommandArgs, +}; + diff --git a/packages/nexus-acp-bridge/src/index.ts b/packages/nexus-acp-bridge/src/index.ts new file mode 100644 index 0000000..6df7f2d --- /dev/null +++ b/packages/nexus-acp-bridge/src/index.ts @@ -0,0 +1,59 @@ +/** + * Public entrypoint for the `nexus-acp-bridge` package. + * + * Re-exports the bridge server, adapters, configuration helpers, and shared + * types so consumers can embed the bridge programmatically. The CLI lives in + * `./bin.ts` and is the intended way to launch the bridge from the command + * line (`bun run nexus-acp-bridge`). + */ + +// Server +export { NexusACPBridgeServer } from "./server/http-server"; + +// Adapters +export { MockACPAdapter } from "./adapters/mock"; +export { StdioACPAdapter } from "./adapters/stdio"; +export { ACPProtocolAdapter } from "./adapters/acp-protocol"; + +// Configuration +export { loadBridgeConfig } from "./config"; +export { + BRIDGE_TOOL_PRESET_IDS, + getBridgeToolPreset, + type BridgeToolPreset, +} from "./tool-presets"; + +// Shared types +export type { + ACPAdapter, + BridgeConfig, + Command, + ConfigProviders, + GenerateTextRequest, + HealthInfo, + MCPStatus, + McpResource, + Model, + Project, + Provider, + ToolListItem, +} from "./types"; + +import type { ACPAdapter, BridgeConfig } from "./types"; +import { MockACPAdapter } from "./adapters/mock"; +import { StdioACPAdapter } from "./adapters/stdio"; +import { ACPProtocolAdapter } from "./adapters/acp-protocol"; + +/** Construct the adapter implied by `config.adapterMode`. */ +export function createAdapter(config: BridgeConfig): ACPAdapter { + switch (config.adapterMode) { + case "acp": + return new ACPProtocolAdapter(config); + case "stdio": + return new StdioACPAdapter(config); + case "mock": + default: + return new MockACPAdapter(config); + } +} + diff --git a/packages/nexus-acp-bridge/src/server/default-provider.ts b/packages/nexus-acp-bridge/src/server/default-provider.ts new file mode 100644 index 0000000..0c0cfbc --- /dev/null +++ b/packages/nexus-acp-bridge/src/server/default-provider.ts @@ -0,0 +1,111 @@ +import type { + BridgeConfig, + ConfigProviders, + MCPStatus, + McpResource, + Model, + Project, + ToolListItem, +} from "../types"; + +const STABLE_RELEASE_DATE = "1970-01-01T00:00:00Z"; + +interface ModelSeed { + id: string; + name: string; + family?: string; +} + +const CLAUDE_CODE_MODELS: ModelSeed[] = [ + { id: "haiku", name: "Claude Haiku", family: "claude-haiku" }, + { id: "sonnet", name: "Claude Sonnet", family: "claude-sonnet" }, + { id: "opus", name: "Claude Opus", family: "claude-opus" }, +]; + +function resolveProviderModels(config: BridgeConfig): ModelSeed[] { + if (config.selectedTool === "claude-code" || config.defaultProviderId === "claude-code") { + return CLAUDE_CODE_MODELS; + } + + return [{ + id: config.defaultModelId, + name: config.defaultModelName, + family: config.defaultModelId === "default" ? "acp" : config.defaultModelId, + }]; +} + +export function buildDefaultModel(config: BridgeConfig, source: string, seed?: ModelSeed): Model { + return { + id: seed?.id ?? config.defaultModelId, + providerID: config.defaultProviderId, + api: { + id: config.defaultProviderId, + url: `https://example.invalid/${source}`, + npm: "nexus-acp-bridge", + }, + name: seed?.name ?? config.defaultModelName, + family: seed?.family ?? (config.defaultModelId === "default" ? "acp" : config.defaultModelId), + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { output: 8192, context: 200000 }, + status: "active", + options: {}, + headers: {}, + release_date: STABLE_RELEASE_DATE, + }; +} + +export function buildDefaultConfigProviders(config: BridgeConfig, source: string): ConfigProviders { + const models = Object.fromEntries( + resolveProviderModels(config).map((seed) => [seed.id, buildDefaultModel(config, source, seed)]), + ); + + return { + providers: [ + { + id: config.defaultProviderId, + name: config.defaultProviderName, + source: "api", + env: [], + options: {}, + models, + }, + ], + default: { [config.defaultProviderId]: config.defaultModelId }, + }; +} + +export function buildDefaultTools(config: BridgeConfig): ToolListItem[] { + return config.defaultTools.map((tool) => ({ + id: tool, + description: `Bridge-exposed tool: ${tool}`, + parameters: { type: "object", properties: {} }, + })); +} + +export function buildDefaultMcpStatus(config: BridgeConfig): Record { + return { [config.defaultProviderId]: { status: "connected" } }; +} + +export function buildDefaultResources( + config: BridgeConfig, + project: Project, + description: string, +): Record { + return { + project: { + name: `${project.name ?? "project"} root`, + uri: `file://${project.worktree}`, + client: config.defaultProviderId, + description, + }, + }; +} diff --git a/packages/nexus-acp-bridge/src/server/http-server.ts b/packages/nexus-acp-bridge/src/server/http-server.ts new file mode 100644 index 0000000..ee325cb --- /dev/null +++ b/packages/nexus-acp-bridge/src/server/http-server.ts @@ -0,0 +1,837 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import type { + ACPAdapter, + AssistantMessage, + BridgeConfig, + FileContent, + FileNode, + FileStatus, + MessageWithParts, + OpenCodeEvent, + Part, + Project, + PromptPayload, + Session, + SessionRecord, + UserMessage, +} from "../types"; + +const PromptPartSchema = z.object({ + id: z.string().optional(), + type: z.literal("text"), + text: z.string(), +}); + +const FilePartSchema = z.object({ + id: z.string().optional(), + type: z.literal("file"), + mime: z.string(), + filename: z.string().optional(), + url: z.string(), +}); + +const ModelRefSchema = z.object({ + providerID: z.string(), + modelID: z.string(), +}); + +const PromptPayloadSchema = z.object({ + messageID: z.string().optional(), + model: ModelRefSchema.optional(), + agent: z.string().optional(), + noReply: z.boolean().optional(), + tools: z.record(z.string(), z.boolean()).optional(), + format: z.object({ type: z.string() }).optional(), + system: z.string().optional(), + variant: z.string().optional(), + parts: z.array(PromptPartSchema).min(1, "At least one prompt part is required"), +}); + +const CreateSessionSchema = z.object({ + title: z.string().optional(), + permissionMode: z.enum(["auto", "forward"]).optional(), +}).partial(); + +const CommandPayloadSchema = z.object({ + messageID: z.string().optional(), + agent: z.string().optional(), + model: z.string().optional(), + arguments: z.string().optional().default(""), + command: z.string().min(1, "command is required"), + variant: z.string().optional(), + parts: z.array(FilePartSchema).optional(), +}); + +const MIME_BY_EXTENSION: Record = { + ".txt": "text/plain", + ".md": "text/markdown", + ".json": "application/json", + ".yaml": "application/yaml", + ".yml": "application/yaml", + ".toml": "application/toml", + ".ts": "text/typescript", + ".tsx": "text/typescript", + ".js": "text/javascript", + ".jsx": "text/javascript", + ".html": "text/html", + ".css": "text/css", + ".xml": "application/xml", + ".csv": "text/csv", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".pdf": "application/pdf", + ".zip": "application/zip", + ".gz": "application/gzip", + ".tar": "application/x-tar", + ".wasm": "application/wasm", +}; + +const BINARY_EXTENSIONS = new Set([ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", + ".pdf", ".zip", ".gz", ".tar", ".7z", ".rar", + ".mp3", ".mp4", ".wav", ".mov", ".avi", ".webm", ".ogg", + ".woff", ".woff2", ".ttf", ".otf", ".eot", + ".exe", ".dll", ".dylib", ".so", ".wasm", + ".db", ".sqlite", ".sqlite3", +]); + +function mimeTypeFor(extension: string, isBinary: boolean): string { + return MIME_BY_EXTENSION[extension] ?? (isBinary ? "application/octet-stream" : "text/plain"); +} + +function detectIsBinary(buffer: Buffer, extension: string): boolean { + if (BINARY_EXTENSIONS.has(extension)) return true; + const sample = buffer.subarray(0, Math.min(buffer.byteLength, 8192)); + for (let index = 0; index < sample.byteLength; index += 1) { + if (sample[index] === 0) return true; + } + return false; +} + +function createId(prefix: string): string { + return `${prefix}_${crypto.randomUUID()}`; +} + +function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, " ") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") || "session"; +} + +function json(data: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(data), { + ...init, + headers: { + "content-type": "application/json", + ...init?.headers, + }, + }); +} + +function toErrorResponse(message: string, status = 400): Response { + return json({ name: "BridgeError", data: { message } }, { status }); +} + +function getCorsHeaders(config: BridgeConfig): Record { + return { + "access-control-allow-origin": config.corsOrigin, + "access-control-allow-headers": "content-type", + "access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS", + }; +} + +function withCors(response: Response, config: BridgeConfig): Response { + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(getCorsHeaders(config))) { + headers.set(key, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function normalizePath(input: string): string { + return path.resolve(input); +} + +function parseCommandModel(model: string | undefined): PromptPayload["model"] | undefined { + const trimmed = model?.trim(); + if (!trimmed) return undefined; + + const separator = trimmed.indexOf("/"); + if (separator <= 0 || separator >= trimmed.length - 1) { + return undefined; + } + + return { + providerID: trimmed.slice(0, separator), + modelID: trimmed.slice(separator + 1), + }; +} + +function toCommandPrompt(payload: z.infer): PromptPayload { + const args = payload.arguments.trim(); + const attachments = payload.parts?.length + ? `\n\nAttached files:\n${payload.parts.map((part) => `- ${part.filename ?? part.url} (${part.url})`).join("\n")}` + : ""; + + return { + messageID: payload.messageID, + agent: payload.agent, + variant: payload.variant, + ...(parseCommandModel(payload.model) ? { model: parseCommandModel(payload.model) } : {}), + parts: [{ + type: "text", + text: `/${payload.command}${args ? ` ${args}` : ""}${attachments}`, + }], + }; +} + +function isPathInside(rootPath: string, candidatePath: string): boolean { + const relative = path.relative(rootPath, candidatePath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +class EventBroker { + private readonly subscribers = new Set<{ + directory: string | null; + send: (event: OpenCodeEvent) => void; + close: () => void; + }>(); + + subscribe(config: BridgeConfig, directory: string | null): Response { + const stream = new TransformStream(); + const writer = stream.writable.getWriter(); + const encoder = new TextEncoder(); + let isClosed = false; + + const safeWrite = (chunk: string) => { + if (isClosed) return; + void writer.write(chunk).catch(() => { + close(); + }); + }; + + const heartbeat = setInterval(() => { + safeWrite(": ping\n\n"); + }, 5_000); + + const send = (event: OpenCodeEvent) => { + safeWrite(`data: ${JSON.stringify(event)}\n\n`); + }; + + const close = () => { + if (isClosed) return; + isClosed = true; + clearInterval(heartbeat); + void writer.close().catch(() => {}); + this.subscribers.delete(subscriber); + }; + + const subscriber = { directory, send, close }; + this.subscribers.add(subscriber); + safeWrite(": connected\n\n"); + + const readable = stream.readable.pipeThrough(new TransformStream({ + transform(chunk, controller) { + controller.enqueue(encoder.encode(chunk)); + }, + })); + + return withCors(new Response(readable, { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }, + }), config); + } + + publish(event: OpenCodeEvent, directory: string): void { + for (const subscriber of this.subscribers) { + if (subscriber.directory && normalizePath(subscriber.directory) !== normalizePath(directory)) { + continue; + } + subscriber.send(event); + } + } + + dispose(): void { + for (const subscriber of this.subscribers) { + subscriber.close(); + } + this.subscribers.clear(); + } +} + +export class NexusACPBridgeServer { + private readonly eventBroker = new EventBroker(); + private readonly sessions = new Map(); + private readonly projectIds = new Map(); + private server: Bun.Server | null = null; + private didFallbackToRandomPort = false; + + constructor( + private readonly config: BridgeConfig, + private readonly adapter: ACPAdapter, + ) {} + + start(): Bun.Server { + if (this.server) return this.server; + + try { + this.server = this.startServer(this.config.port); + this.didFallbackToRandomPort = false; + } catch (error) { + if (!this.shouldFallbackToRandomPort(error)) { + throw error; + } + + this.server = this.startServer(0); + this.didFallbackToRandomPort = true; + } + + return this.server; + } + + usedRandomPortFallback(): boolean { + return this.didFallbackToRandomPort; + } + + stop(): void { + this.server?.stop(); + this.server = null; + this.didFallbackToRandomPort = false; + this.eventBroker.dispose(); + void this.adapter.dispose?.(); + } + + private startServer(port: number): Bun.Server { + return Bun.serve({ + hostname: this.config.host, + port, + idleTimeout: this.config.serverIdleTimeoutSeconds, + fetch: (request) => this.handleRequest(request), + }); + } + + private shouldFallbackToRandomPort(error: unknown): boolean { + return this.config.port !== 0 + && error instanceof Error + && "code" in error + && error.code === "EADDRINUSE"; + } + + private async handleRequest(request: Request): Promise { + if (request.method === "OPTIONS") { + return withCors(new Response(null, { status: 204 }), this.config); + } + + try { + const url = new URL(request.url); + const pathname = url.pathname; + + if (request.method === "GET" && pathname === "/global/health") { + return withCors(json(await this.adapter.getHealth()), this.config); + } + + if (request.method === "GET" && pathname === "/config/providers") { + return withCors(json(await this.adapter.getConfigProviders()), this.config); + } + + if (request.method === "GET" && pathname === "/command") { + const project = await this.resolveProject(url.searchParams); + return withCors(json(await this.adapter.listCommands({ project })), this.config); + } + + if (request.method === "GET" && pathname === "/project") { + return withCors(json(await this.listProjects()), this.config); + } + + if (request.method === "GET" && pathname === "/project/current") { + return withCors(json(await this.resolveProject(url.searchParams)), this.config); + } + + if (request.method === "GET" && pathname === "/experimental/tool") { + const project = await this.resolveProject(url.searchParams); + const provider = url.searchParams.get("provider") ?? this.config.defaultProviderId; + const model = url.searchParams.get("model") ?? this.config.defaultModelId; + return withCors(json(await this.adapter.listTools({ provider, model, project })), this.config); + } + + if (request.method === "GET" && pathname === "/experimental/tool/ids") { + const project = await this.resolveProject(url.searchParams); + const tools = await this.adapter.listTools({ + provider: this.config.defaultProviderId, + model: this.config.defaultModelId, + project, + }); + return withCors(json(tools.map((tool) => tool.id)), this.config); + } + + if (request.method === "GET" && pathname === "/mcp") { + const project = await this.resolveProject(url.searchParams); + return withCors(json(await this.adapter.getMcpStatus({ project })), this.config); + } + + if (request.method === "GET" && pathname === "/experimental/resource") { + const project = await this.resolveProject(url.searchParams); + return withCors(json(await this.adapter.listResources({ project })), this.config); + } + + if (request.method === "GET" && pathname === "/file") { + const project = await this.resolveProject(url.searchParams); + const entries = await this.listFiles(project, url.searchParams.get("path")); + return withCors(json(entries), this.config); + } + + if (request.method === "GET" && pathname === "/file/content") { + const project = await this.resolveProject(url.searchParams); + const content = await this.readFile(project, url.searchParams.get("path")); + return withCors(json(content), this.config); + } + + if (request.method === "GET" && pathname === "/file/status") { + const status: FileStatus[] = []; + return withCors(json(status), this.config); + } + + if (request.method === "GET" && pathname === "/event") { + const directory = url.searchParams.get("directory"); + return this.eventBroker.subscribe(this.config, directory); + } + + if (request.method === "GET" && pathname === "/session") { + return withCors(json([...this.sessions.values()].map((record) => record.session)), this.config); + } + + 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); + return withCors(json(session), this.config); + } + + const sessionMessageMatch = pathname.match(/^\/session\/([^/]+)\/message$/); + if (request.method === "GET" && sessionMessageMatch) { + const sessionId = decodeURIComponent(sessionMessageMatch[1] ?? ""); + const session = this.requireSession(sessionId); + const limitRaw = url.searchParams.get("limit"); + const limit = limitRaw ? Number(limitRaw) : undefined; + const messages = Number.isFinite(limit) + ? session.messages.slice(-Math.max(0, limit ?? 0)) + : session.messages; + return withCors(json(messages), this.config); + } + + if (request.method === "POST" && sessionMessageMatch) { + const sessionId = decodeURIComponent(sessionMessageMatch[1] ?? ""); + const payload = await this.readJsonWithSchema(request, PromptPayloadSchema); + const message = await this.runPromptAsync(sessionId, payload); + return withCors(json(message), this.config); + } + + const sessionCommandMatch = pathname.match(/^\/session\/([^/]+)\/command$/); + if (request.method === "POST" && sessionCommandMatch) { + const sessionId = decodeURIComponent(sessionCommandMatch[1] ?? ""); + const payload = await this.readJsonWithSchema(request, CommandPayloadSchema); + const message = await this.runPromptAsync(sessionId, toCommandPrompt(payload)); + return withCors(json(message), this.config); + } + + const promptAsyncMatch = pathname.match(/^\/session\/([^/]+)\/prompt_async$/); + if (request.method === "POST" && promptAsyncMatch) { + const sessionId = decodeURIComponent(promptAsyncMatch[1] ?? ""); + const payload = await this.readJsonWithSchema(request, PromptPayloadSchema); + this.requireSession(sessionId); + void this.runPromptAsync(sessionId, payload).catch((error) => { + console.error("[nexus-acp-bridge] async prompt failed:", error); + }); + 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); + await request.json().catch(() => ({})); + return withCors(json(true), this.config); + } + + const sessionAbortMatch = pathname.match(/^\/session\/([^/]+)\/abort$/); + if (request.method === "POST" && sessionAbortMatch) { + const sessionId = decodeURIComponent(sessionAbortMatch[1] ?? ""); + const session = this.requireSession(sessionId); + session.abortController?.abort(); + session.abortController = null; + session.status = "idle"; + this.eventBroker.publish({ type: "session.idle", properties: { sessionID: sessionId } }, session.session.directory); + return withCors(json(true), this.config); + } + + const sessionDeleteMatch = pathname.match(/^\/session\/([^/]+)$/); + if (request.method === "DELETE" && sessionDeleteMatch) { + const sessionId = decodeURIComponent(sessionDeleteMatch[1] ?? ""); + const session = this.requireSession(sessionId); + session.abortController?.abort(); + this.sessions.delete(sessionId); + void this.adapter.closeSession?.(sessionId); + return withCors(json(true), this.config); + } + + return withCors(toErrorResponse(`Unsupported route: ${request.method} ${pathname}`, 404), this.config); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown bridge error"; + const status = error instanceof HttpBridgeError ? error.status : 500; + return withCors(toErrorResponse(message, status), this.config); + } + } + + private async readJsonWithSchema( + request: Request, + schema: T, + ): Promise> { + const text = await request.text(); + let parsed: unknown; + if (!text.trim()) { + parsed = {}; + } else { + try { + parsed = JSON.parse(text); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid JSON body"; + throw new HttpBridgeError(400, `Invalid JSON: ${message}`); + } + } + + const result = schema.safeParse(parsed); + if (!result.success) { + const message = result.error.issues + .map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`) + .join("; "); + throw new HttpBridgeError(400, message); + } + + return result.data; + } + + private async listProjects(): Promise { + const results: Project[] = []; + for (const dir of this.config.projectDirs) { + try { + const stat = await fs.stat(dir); + if (!stat.isDirectory()) continue; + results.push(this.toProject(dir)); + } catch { + // Ignore missing directories. + } + } + return results; + } + + private async resolveProject(searchParams: URLSearchParams): Promise { + const requestedDirectory = searchParams.get("directory")?.trim(); + if (!requestedDirectory) { + return this.toProject(this.config.projectDirs[0] ?? process.cwd()); + } + + const normalized = normalizePath(requestedDirectory); + const knownProject = this.config.projectDirs.find((dir) => normalizePath(dir) === normalized); + if (knownProject) { + return this.toProject(knownProject); + } + + if (!this.config.allowArbitraryDirectories) { + throw new HttpBridgeError(404, `Unknown project directory: ${requestedDirectory}`); + } + + const stat = await fs.stat(normalized).catch(() => null); + if (!stat?.isDirectory()) { + throw new HttpBridgeError(404, `Project directory not found: ${requestedDirectory}`); + } + + return this.toProject(normalized); + } + + private toProject(directory: string): Project { + const normalized = normalizePath(directory); + const existingId = this.projectIds.get(normalized); + const id = existingId ?? `project_${slugify(path.basename(normalized) || "root")}`; + this.projectIds.set(normalized, id); + const now = Date.now(); + + return { + id, + worktree: normalized, + vcs: "git", + name: path.basename(normalized) || normalized, + time: { + created: now, + updated: now, + }, + sandboxes: [], + }; + } + + private async listFiles(project: Project, requestedPath: string | null): Promise { + const absolutePath = await this.resolveFilePath(project, requestedPath); + const entries = await fs.readdir(absolutePath, { withFileTypes: true }); + + return entries + .filter((entry) => entry.name !== ".git" && entry.name !== "node_modules") + .sort((left, right) => { + if (left.isDirectory() === right.isDirectory()) { + return left.name.localeCompare(right.name); + } + return left.isDirectory() ? -1 : 1; + }) + .map((entry) => { + const absolute = path.join(absolutePath, entry.name); + return { + name: entry.name, + path: absolute, + absolute, + type: entry.isDirectory() ? "directory" : "file", + ignored: false, + } satisfies FileNode; + }); + } + + private async readFile(project: Project, requestedPath: string | null): Promise { + const absolutePath = await this.resolveFilePath(project, requestedPath); + const stat = await fs.stat(absolutePath); + if (!stat.isFile()) { + throw new HttpBridgeError(400, `Path is not a regular file: ${requestedPath}`); + } + if (stat.size > this.config.maxFileReadBytes) { + throw new HttpBridgeError( + 413, + `File exceeds size cap (${stat.size} bytes > ${this.config.maxFileReadBytes} bytes)`, + ); + } + + const buffer = await fs.readFile(absolutePath); + const extension = path.extname(absolutePath).toLowerCase(); + const isBinary = detectIsBinary(buffer, extension); + const mimeType = mimeTypeFor(extension, isBinary); + + if (isBinary) { + return { + type: "binary", + content: buffer.toString("base64"), + encoding: "base64", + mimeType, + }; + } + + return { + type: "text", + content: buffer.toString("utf8"), + mimeType, + }; + } + + private async resolveFilePath(project: Project, requestedPath: string | null): Promise { + const root = normalizePath(project.worktree); + const candidate = !requestedPath || requestedPath === "." + ? root + : path.isAbsolute(requestedPath) + ? normalizePath(requestedPath) + : normalizePath(path.join(root, requestedPath)); + + if (!isPathInside(root, candidate)) { + throw new HttpBridgeError(400, `Path is outside project root: ${requestedPath}`); + } + + const stat = await fs.stat(candidate).catch(() => null); + if (!stat) { + throw new HttpBridgeError(404, `Path not found: ${requestedPath}`); + } + + return candidate; + } + + private createSession(project: Project, title?: string): Session { + const session: Session = { + id: createId("session"), + slug: slugify(title ?? project.name ?? "nexus-session"), + projectID: project.id, + directory: project.worktree, + title: title?.trim() || "Nexus ACP Session", + version: this.config.version, + time: { + created: Date.now(), + updated: Date.now(), + }, + }; + + this.sessions.set(session.id, { + session, + project, + messages: [], + abortController: null, + status: "idle", + }); + + return session; + } + + private requireSession(sessionId: string): SessionRecord { + const session = this.sessions.get(sessionId); + if (!session) { + throw new HttpBridgeError(404, `Session not found: ${sessionId}`); + } + return session; + } + + private async runPromptAsync(sessionId: string, payload: PromptPayload): Promise { + const record = this.requireSession(sessionId); + record.abortController?.abort(); + const abortController = new AbortController(); + record.abortController = abortController; + record.status = "busy"; + record.session.time.updated = Date.now(); + + const userMessageId = createId("msg"); + const assistantMessageId = createId("msg"); + const textPartId = createId("part"); + const now = Date.now(); + const promptText = payload.parts.map((part) => part.text).join("\n\n"); + + const userInfo: UserMessage = { + id: userMessageId, + sessionID: sessionId, + role: "user", + time: { created: now }, + model: payload.model, + system: payload.system, + tools: payload.tools, + }; + + const userMessage: MessageWithParts = { + info: userInfo, + parts: payload.parts.map((part) => ({ + id: part.id ?? createId("part"), + sessionID: sessionId, + messageID: userMessageId, + type: "text", + text: part.text, + })), + }; + + const assistantParts: Part[] = [{ + id: textPartId, + sessionID: sessionId, + messageID: assistantMessageId, + type: "text", + text: "", + }]; + + const assistantInfo: AssistantMessage = { + id: assistantMessageId, + sessionID: sessionId, + role: "assistant", + time: { created: now }, + parentID: userMessageId, + modelID: payload.model?.modelID ?? this.config.defaultModelId, + providerID: payload.model?.providerID ?? this.config.defaultProviderId, + mode: "chat", + agent: payload.agent ?? "acp-bridge", + path: { cwd: record.project.worktree, root: record.project.worktree }, + cost: 0, + tokens: { + input: Math.ceil(promptText.length / 4), + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }; + + const assistantMessage: MessageWithParts = { + info: assistantInfo, + parts: assistantParts, + }; + + record.messages.push(userMessage, assistantMessage); + + this.eventBroker.publish({ type: "message.updated", properties: { info: userInfo } }, record.session.directory); + this.eventBroker.publish({ type: "message.updated", properties: { info: assistantInfo } }, record.session.directory); + this.eventBroker.publish({ type: "session.updated", properties: { info: record.session } }, record.session.directory); + + try { + for await (const delta of this.adapter.generateText({ + session: record.session, + project: record.project, + payload, + signal: abortController.signal, + })) { + if (abortController.signal.aborted) { + break; + } + + const textPart = assistantParts[0]; + textPart.text += delta; + assistantInfo.tokens.output += Math.ceil(delta.length / 4); + + this.eventBroker.publish({ + type: "message.part.delta", + properties: { + sessionID: sessionId, + messageID: assistantMessageId, + partID: textPartId, + field: "text", + delta, + }, + }, record.session.directory); + } + + assistantInfo.time.completed = Date.now(); + assistantInfo.finish = abortController.signal.aborted ? "abort" : "stop"; + record.status = "idle"; + record.abortController = null; + record.session.time.updated = Date.now(); + this.eventBroker.publish({ type: "session.updated", properties: { info: record.session } }, record.session.directory); + this.eventBroker.publish({ type: "session.idle", properties: { sessionID: sessionId } }, record.session.directory); + return assistantMessage; + } catch (error) { + record.status = "idle"; + record.abortController = null; + assistantInfo.error = { + name: "UnknownError", + data: { message: error instanceof Error ? error.message : "Bridge generation failed" }, + }; + assistantInfo.time.completed = Date.now(); + this.eventBroker.publish({ + type: "session.error", + properties: { + sessionID: sessionId, + error: assistantInfo.error, + }, + }, record.session.directory); + return assistantMessage; + } + } +} + +class HttpBridgeError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "HttpBridgeError"; + } +} + + diff --git a/packages/nexus-acp-bridge/src/tool-presets.ts b/packages/nexus-acp-bridge/src/tool-presets.ts new file mode 100644 index 0000000..f56c630 --- /dev/null +++ b/packages/nexus-acp-bridge/src/tool-presets.ts @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import { CLAUDE_AGENT_ACP_VERSION, CLAUDE_VENDOR_BIN, ensureClaudeAcpVendored } from "./vendor-claude-acp"; + +export interface BridgeToolPreset { + id: string; + label: string; + description: string; + resolveEnv(): Record; +} + +let warnedAboutMissingVendor = false; + +function autoSetupEnabled(): boolean { + const raw = process.env.NEXUS_ACP_BRIDGE_AUTO_SETUP_CLAUDE?.trim().toLowerCase(); + // Default ON. Opt-out with NEXUS_ACP_BRIDGE_AUTO_SETUP_CLAUDE=0 / false / off / no. + if (!raw) return true; + return !["0", "false", "no", "off"].includes(raw); +} + +function resolveClaudeCodeCommand(): { command: string; args: string } { + if (fs.existsSync(CLAUDE_VENDOR_BIN)) { + return { command: CLAUDE_VENDOR_BIN, args: "" }; + } + + if (autoSetupEnabled()) { + console.log( + "[nexus-acp-bridge] claude-code vendored install missing — auto-installing " + + `@agentclientprotocol/claude-agent-acp@${CLAUDE_AGENT_ACP_VERSION}. ` + + "Set NEXUS_ACP_BRIDGE_AUTO_SETUP_CLAUDE=0 to opt out.", + ); + const installed = ensureClaudeAcpVendored({ + silent: true, + log: (msg) => console.log(`[nexus-acp-bridge] ${msg}`), + }); + if (installed) { + return { command: installed, args: "" }; + } + } + + if (!warnedAboutMissingVendor) { + warnedAboutMissingVendor = true; + console.warn( + "[nexus-acp-bridge] claude-code vendored install not found. " + + "Run `bun run nexus-acp-bridge:setup-claude` to vendor @agentclientprotocol/claude-agent-acp locally. " + + `Falling back to \`npx --yes @agentclientprotocol/claude-agent-acp@${CLAUDE_AGENT_ACP_VERSION}\`.`, + ); + } + + return { command: "npx", args: `--yes @agentclientprotocol/claude-agent-acp@${CLAUDE_AGENT_ACP_VERSION}` }; +} + +export const BRIDGE_TOOL_PRESETS: Record = { + "claude-code": { + id: "claude-code", + label: "Claude Code", + description: "Claude Code via the ACP wrapper package.", + resolveEnv: () => { + const { command, args } = resolveClaudeCodeCommand(); + return { + NEXUS_ACP_BRIDGE_ADAPTER: "acp", + NEXUS_ACP_BRIDGE_PROVIDER_ID: "claude-code", + NEXUS_ACP_BRIDGE_PROVIDER_NAME: "Claude Code", + NEXUS_ACP_BRIDGE_MODEL_ID: "sonnet", + NEXUS_ACP_BRIDGE_MODEL_NAME: "Claude Sonnet", + NEXUS_ACP_BRIDGE_AGENT_COMMAND: command, + NEXUS_ACP_BRIDGE_AGENT_ARGS: args, + ACP_PERMISSION_MODE: "bypassPermissions", + }; + }, + }, + codex: { + id: "codex", + label: "Codex ACP", + description: "Codex via the Zed ACP wrapper package.", + resolveEnv: () => ({ + NEXUS_ACP_BRIDGE_ADAPTER: "acp", + NEXUS_ACP_BRIDGE_PROVIDER_ID: "codex", + NEXUS_ACP_BRIDGE_PROVIDER_NAME: "Codex", + NEXUS_ACP_BRIDGE_MODEL_ID: "default", + NEXUS_ACP_BRIDGE_MODEL_NAME: "Codex Default Model", + NEXUS_ACP_BRIDGE_AGENT_COMMAND: "npx", + NEXUS_ACP_BRIDGE_AGENT_ARGS: "--yes @zed-industries/codex-acp", + }), + }, + opencode: { + id: "opencode", + label: "OpenCode ACP", + description: "OpenCode running in ACP process mode.", + resolveEnv: () => ({ + NEXUS_ACP_BRIDGE_ADAPTER: "acp", + NEXUS_ACP_BRIDGE_PROVIDER_ID: "opencode", + NEXUS_ACP_BRIDGE_PROVIDER_NAME: "OpenCode", + NEXUS_ACP_BRIDGE_MODEL_ID: "default", + NEXUS_ACP_BRIDGE_MODEL_NAME: "OpenCode Default Model", + // Default to port 4081 so the OpenCode bridge can run alongside the + // Claude Code bridge (which keeps the default 4080). + NEXUS_ACP_BRIDGE_PORT: "4081", + // Use `bunx` so OpenCode is installed/cached on demand — users no longer + // need to `bun add -g opencode-ai` manually. + NEXUS_ACP_BRIDGE_AGENT_COMMAND: "bunx", + NEXUS_ACP_BRIDGE_AGENT_ARGS: "opencode-ai acp", + }), + }, +}; + +export const BRIDGE_TOOL_PRESET_IDS = Object.keys(BRIDGE_TOOL_PRESETS); + +export function getBridgeToolPreset(toolId: string | null | undefined): BridgeToolPreset | null { + if (!toolId) return null; + return BRIDGE_TOOL_PRESETS[toolId] ?? null; +} diff --git a/packages/nexus-acp-bridge/src/transport/async-queue.ts b/packages/nexus-acp-bridge/src/transport/async-queue.ts new file mode 100644 index 0000000..5a23c31 --- /dev/null +++ b/packages/nexus-acp-bridge/src/transport/async-queue.ts @@ -0,0 +1,63 @@ +export class AsyncQueue implements AsyncIterable { + private readonly items: T[] = []; + private readonly waiters: Array<{ + resolve: (result: IteratorResult) => void; + reject: (error: unknown) => void; + }> = []; + private closed = false; + private error: unknown = null; + + push(value: T): void { + if (this.closed || this.error) return; + + const waiter = this.waiters.shift(); + if (waiter) { + waiter.resolve({ value, done: false }); + return; + } + + this.items.push(value); + } + + close(): void { + if (this.closed) return; + this.closed = true; + while (this.waiters.length > 0) { + const waiter = this.waiters.shift(); + waiter?.resolve({ value: undefined, done: true }); + } + } + + fail(error: unknown): void { + if (this.error || this.closed) return; + this.error = error; + while (this.waiters.length > 0) { + const waiter = this.waiters.shift(); + waiter?.reject(error); + } + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: (): Promise> => { + if (this.items.length > 0) { + const value = this.items.shift() as T; + return Promise.resolve({ value, done: false }); + } + + if (this.error) { + return Promise.reject(this.error); + } + + if (this.closed) { + return Promise.resolve({ value: undefined, done: true }); + } + + return new Promise>((resolve, reject) => { + this.waiters.push({ resolve, reject }); + }); + }, + }; + } +} + diff --git a/packages/nexus-acp-bridge/src/transport/jsonrpc-client.ts b/packages/nexus-acp-bridge/src/transport/jsonrpc-client.ts new file mode 100644 index 0000000..520c938 --- /dev/null +++ b/packages/nexus-acp-bridge/src/transport/jsonrpc-client.ts @@ -0,0 +1,226 @@ +import { + JsonRpcError, + isJsonRpcFailure, + isJsonRpcNotification, + type JsonRpcFailure, + type JsonRpcMessage, + type JsonRpcNotification, + type JsonRpcRequest, + type JsonRpcSuccess, +} from "./jsonrpc"; +import { ACPStdioTransport } from "./stdio-transport"; +import type { BridgeConfig } from "../types"; + +export type ACPSessionUpdateHandler = (update: unknown, raw: JsonRpcNotification) => void; + +export type ACPRequestHandler = (params: unknown) => Promise | unknown; + +export interface ACPJsonRpcClientLike { + connect(): Promise; + close(): Promise; + request(method: string, params?: unknown): Promise; + notify(method: string, params?: unknown): Promise; + onNotification(listener: (notification: JsonRpcNotification) => void): () => void; + onSessionUpdate?(sessionId: string, handler: ACPSessionUpdateHandler): () => void; + setRequestHandler?(method: string, handler: ACPRequestHandler): () => void; +} + +const SESSION_UPDATE_METHOD = "session/update"; + +function extractUpdateSessionId(params: unknown): string | null { + if (typeof params !== "object" || params === null) return null; + const candidate = (params as { sessionId?: unknown }).sessionId; + return typeof candidate === "string" && candidate.length > 0 ? candidate : null; +} + +function extractUpdateBody(params: unknown): unknown { + if (typeof params !== "object" || params === null) return null; + return (params as { update?: unknown }).update ?? null; +} + +export class ACPJsonRpcClient implements ACPJsonRpcClientLike { + private readonly transport: ACPStdioTransport; + private readonly pending = new Map void; + reject: (error: unknown) => void; + }>(); + private readonly notificationListeners = new Set<(notification: JsonRpcNotification) => void>(); + private readonly sessionUpdateListeners = new Map>(); + private readonly requestHandlers = new Map(); + private nextId = 1; + private connectStarted = false; + private connectPromise: Promise | null = null; + + constructor(private readonly config: BridgeConfig) { + this.transport = new ACPStdioTransport(config, config.acpProtocol); + this.transport.onMessage((message) => this.handleMessage(message)); + this.transport.onClose((error) => this.handleClose(error)); + } + + async connect(): Promise { + if (this.connectPromise) return this.connectPromise; + this.connectPromise = this.transport.connect(); + return this.connectPromise; + } + + async close(): Promise { + await this.transport.close(); + } + + async request(method: string, params?: unknown): Promise { + await this.connect(); + const id = this.nextId++; + + const response = new Promise((resolve, reject) => { + this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject }); + }); + + try { + await this.transport.send({ + jsonrpc: "2.0", + id, + method, + params, + }); + } catch (error) { + this.pending.delete(id); + throw error; + } + + return response; + } + + async notify(method: string, params?: unknown): Promise { + await this.connect(); + await this.transport.send({ + jsonrpc: "2.0", + method, + params, + }); + } + + onNotification(listener: (notification: JsonRpcNotification) => void): () => void { + this.notificationListeners.add(listener); + return () => this.notificationListeners.delete(listener); + } + + onSessionUpdate(sessionId: string, handler: ACPSessionUpdateHandler): () => void { + let handlers = this.sessionUpdateListeners.get(sessionId); + if (!handlers) { + handlers = new Set(); + this.sessionUpdateListeners.set(sessionId, handlers); + } + handlers.add(handler); + return () => { + const set = this.sessionUpdateListeners.get(sessionId); + if (!set) return; + set.delete(handler); + if (set.size === 0) this.sessionUpdateListeners.delete(sessionId); + }; + } + + setRequestHandler(method: string, handler: ACPRequestHandler): () => void { + this.requestHandlers.set(method, handler); + return () => { + const current = this.requestHandlers.get(method); + if (current === handler) this.requestHandlers.delete(method); + }; + } + + private handleMessage(message: JsonRpcMessage): void { + if (isJsonRpcNotification(message)) { + this.dispatchNotification(message); + return; + } + + if (isJsonRpcFailure(message)) { + if (message.id !== null && typeof message.id === "number") { + const pending = this.pending.get(message.id); + this.pending.delete(message.id); + pending?.reject(new JsonRpcError(message.error.message, message.error.code, message.error.data)); + } + return; + } + + if ("result" in message) { + const response = message as JsonRpcSuccess; + const pending = this.pending.get(response.id); + this.pending.delete(response.id); + pending?.resolve(response.result); + return; + } + + if ("method" in message && "id" in message) { + void this.dispatchInboundRequest(message as JsonRpcRequest); + } + } + + private dispatchNotification(notification: JsonRpcNotification): void { + for (const listener of this.notificationListeners) { + listener(notification); + } + + if (notification.method !== SESSION_UPDATE_METHOD) return; + + const sessionId = extractUpdateSessionId(notification.params); + if (!sessionId) return; + + const handlers = this.sessionUpdateListeners.get(sessionId); + if (!handlers) return; + + const body = extractUpdateBody(notification.params); + for (const handler of handlers) { + try { + handler(body, notification); + } catch { + // Handler errors must not break the transport; swallow and continue. + } + } + } + + private async dispatchInboundRequest(request: JsonRpcRequest): Promise { + const handler = this.requestHandlers.get(request.method); + if (!handler) { + await this.sendFailure(request.id, { + code: -32601, + message: `Method not found: ${request.method}`, + }); + return; + } + + try { + const result = await handler(request.params); + await this.transport.send({ + jsonrpc: "2.0", + id: request.id, + result: result ?? null, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Request handler failed"; + await this.sendFailure(request.id, { code: -32000, message }); + } + } + + private async sendFailure(id: number, error: JsonRpcFailure["error"]): Promise { + try { + await this.transport.send({ + jsonrpc: "2.0", + id, + error, + }); + } catch { + // Transport already closed — nothing we can do. + } + } + + private handleClose(error?: Error): void { + this.connectPromise = null; + this.connectStarted = false; + + for (const [id, pending] of this.pending.entries()) { + pending.reject(error ?? new Error(`ACP transport closed while waiting for response ${id}`)); + } + this.pending.clear(); + this.sessionUpdateListeners.clear(); + } +} diff --git a/packages/nexus-acp-bridge/src/transport/jsonrpc.ts b/packages/nexus-acp-bridge/src/transport/jsonrpc.ts new file mode 100644 index 0000000..bf15aa4 --- /dev/null +++ b/packages/nexus-acp-bridge/src/transport/jsonrpc.ts @@ -0,0 +1,117 @@ +import type { ACPTransportProtocol } from "../types"; + +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params?: unknown; +} + +export interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: unknown; +} + +export interface JsonRpcSuccess { + jsonrpc: "2.0"; + id: number; + result: unknown; +} + +export interface JsonRpcFailure { + jsonrpc: "2.0"; + id: number | null; + error: { + code: number; + message: string; + data?: unknown; + }; +} + +export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcSuccess | JsonRpcFailure; + +export class JsonRpcError extends Error { + constructor( + message: string, + readonly code = -32000, + readonly data?: unknown, + ) { + super(message); + this.name = "JsonRpcError"; + } +} + +export function encodeJsonRpcMessage( + message: JsonRpcMessage, + protocol: ACPTransportProtocol, +): Buffer { + const body = Buffer.from(JSON.stringify(message), "utf8"); + if (protocol === "newline") { + return Buffer.concat([body, Buffer.from("\n", "utf8")]); + } + + const header = Buffer.from(`Content-Length: ${body.byteLength}\r\n\r\n`, "utf8"); + return Buffer.concat([header, body]); +} + +export function decodeJsonRpcMessages( + buffer: Buffer, + protocol: ACPTransportProtocol, +): { messages: JsonRpcMessage[]; remainder: Buffer } { + return protocol === "newline" + ? decodeNewlineMessages(buffer) + : decodeContentLengthMessages(buffer); +} + +function decodeNewlineMessages(buffer: Buffer): { messages: JsonRpcMessage[]; remainder: Buffer } { + const messages: JsonRpcMessage[] = []; + let cursor = 0; + + while (cursor < buffer.length) { + const newline = buffer.indexOf(0x0a, cursor); + if (newline === -1) break; + const line = buffer.slice(cursor, newline).toString("utf8").trim(); + cursor = newline + 1; + if (!line) continue; + messages.push(JSON.parse(line) as JsonRpcMessage); + } + + return { messages, remainder: buffer.slice(cursor) }; +} + +function decodeContentLengthMessages(buffer: Buffer): { messages: JsonRpcMessage[]; remainder: Buffer } { + const messages: JsonRpcMessage[] = []; + let cursor = 0; + + while (cursor < buffer.length) { + const separator = buffer.indexOf("\r\n\r\n", cursor, "utf8"); + if (separator === -1) break; + + const headerText = buffer.slice(cursor, separator).toString("utf8"); + const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i); + if (!contentLengthMatch) { + throw new JsonRpcError("Missing Content-Length header in JSON-RPC payload"); + } + + const contentLength = Number(contentLengthMatch[1]); + const bodyStart = separator + 4; + const bodyEnd = bodyStart + contentLength; + if (buffer.length < bodyEnd) break; + + const body = buffer.slice(bodyStart, bodyEnd).toString("utf8"); + messages.push(JSON.parse(body) as JsonRpcMessage); + cursor = bodyEnd; + } + + return { messages, remainder: buffer.slice(cursor) }; +} + +export function isJsonRpcFailure(message: JsonRpcMessage): message is JsonRpcFailure { + return "error" in message; +} + +export function isJsonRpcNotification(message: JsonRpcMessage): message is JsonRpcNotification { + return "method" in message && !("id" in message); +} + diff --git a/packages/nexus-acp-bridge/src/transport/stdio-transport.ts b/packages/nexus-acp-bridge/src/transport/stdio-transport.ts new file mode 100644 index 0000000..cd8e3bd --- /dev/null +++ b/packages/nexus-acp-bridge/src/transport/stdio-transport.ts @@ -0,0 +1,129 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { decodeJsonRpcMessages, encodeJsonRpcMessage, type JsonRpcMessage } from "./jsonrpc"; +import type { ACPTransportProtocol, BridgeConfig } from "../types"; + +export class ACPStdioTransport { + private child: ChildProcessWithoutNullStreams | null = null; + private buffer = Buffer.alloc(0); + private readonly messageListeners = new Set<(message: JsonRpcMessage) => void>(); + private readonly closeListeners = new Set<(error?: Error) => void>(); + private connectPromise: Promise | null = null; + private stderrChunks: string[] = []; + + constructor( + private readonly config: BridgeConfig, + private readonly protocol: ACPTransportProtocol, + ) {} + + async connect(): Promise { + if (this.child && !this.child.killed) return; + if (this.connectPromise) return this.connectPromise; + + this.connectPromise = new Promise((resolve, reject) => { + if (!this.config.agentCommand) { + reject(new Error("NEXUS_ACP_BRIDGE_AGENT_COMMAND is required when NEXUS_ACP_BRIDGE_ADAPTER=acp")); + return; + } + + const child = spawn(this.config.agentCommand, this.config.agentArgs, { + cwd: this.config.agentCwd ?? this.config.projectDirs[0] ?? process.cwd(), + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + this.child = child; + this.stderrChunks = []; + + child.once("spawn", () => resolve()); + child.once("error", (error) => { + this.child = null; + reject(error); + }); + child.stdout.on("data", (chunk: Buffer) => { + this.handleStdoutChunk(chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + this.stderrChunks.push(chunk.toString("utf8")); + }); + child.once("close", (code, signal) => { + const stderr = this.stderrChunks.join("").trim(); + const error = code === 0 || code === null + ? undefined + : new Error(stderr || `ACP process exited with code ${code}${signal ? ` (${signal})` : ""}`); + this.child = null; + this.connectPromise = null; + this.buffer = Buffer.alloc(0); + for (const listener of this.closeListeners) { + listener(error); + } + }); + }); + + try { + await this.connectPromise; + } finally { + if (!this.child) { + this.connectPromise = null; + } + } + } + + async send(message: JsonRpcMessage): Promise { + await this.connect(); + const child = this.child; + if (!child) { + throw new Error("ACP stdio transport is not connected"); + } + + const payload = encodeJsonRpcMessage(message, this.protocol); + await new Promise((resolve, reject) => { + child.stdin.write(payload, (error) => { + if (error) reject(error); else resolve(); + }); + }); + } + + onMessage(listener: (message: JsonRpcMessage) => void): () => void { + this.messageListeners.add(listener); + return () => this.messageListeners.delete(listener); + } + + onClose(listener: (error?: Error) => void): () => void { + this.closeListeners.add(listener); + return () => this.closeListeners.delete(listener); + } + + async close(): Promise { + const child = this.child; + if (!child) return; + + await new Promise((resolve) => { + const timer = setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, 250); + + child.once("close", () => { + clearTimeout(timer); + resolve(); + }); + + child.stdin.end(); + child.kill("SIGTERM"); + }); + } + + private handleStdoutChunk(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]); + const { messages, remainder } = decodeJsonRpcMessages(this.buffer, this.protocol); + this.buffer = Buffer.from(remainder); + + for (const message of messages) { + for (const listener of this.messageListeners) { + listener(message); + } + } + } +} + + diff --git a/packages/nexus-acp-bridge/src/types.ts b/packages/nexus-acp-bridge/src/types.ts new file mode 100644 index 0000000..f0f3933 --- /dev/null +++ b/packages/nexus-acp-bridge/src/types.ts @@ -0,0 +1,257 @@ +import type { Stats } from "node:fs"; + +export interface HealthInfo { + healthy: true; + version: string; +} + +export interface Model { + id: string; + providerID: string; + api: { id: string; url: string; npm: string }; + name: string; + family?: string; + capabilities: { + temperature: boolean; + reasoning: boolean; + attachment: boolean; + toolcall: boolean; + input: { text: boolean; audio: boolean; image: boolean; video: boolean; pdf: boolean }; + output: { text: boolean; audio: boolean; image: boolean; video: boolean; pdf: boolean }; + interleaved: boolean; + }; + cost: { input: number; output: number; cache: { read: number; write: number } }; + limit: { output: number; context?: number; input?: number }; + status: "active" | "alpha" | "beta" | "deprecated"; + options: Record; + headers: Record; + release_date: string; +} + +export interface Provider { + id: string; + name: string; + source: "api" | "config" | "custom" | "env"; + env: string[]; + options: Record; + models: Record; +} + +export interface ConfigProviders { + providers: Provider[]; + default: Record; +} + +export interface ToolListItem { + id: string; + description: string; + parameters: unknown; +} + +export interface Command { + name: string; + description?: string; + agent?: string; + model?: string; + source?: "command" | "mcp" | "skill"; + template: string; + subtask?: boolean; + hints: string[]; +} + +export type MCPStatus = + | { status: "connected" } + | { status: "disabled" } + | { status: "failed"; error: string } + | { status: "needs_auth" } + | { status: "needs_client_registration"; error: string }; + +export interface McpResource { + name: string; + uri: string; + description?: string; + mimeType?: string; + client: string; +} + +export interface Project { + id: string; + worktree: string; + vcs?: "git"; + name?: string; + icon?: { url?: string; override?: string; color?: string }; + commands?: { start?: string }; + time: { created: number; updated: number; initialized?: number }; + sandboxes: string[]; +} + +export interface Session { + id: string; + slug: string; + projectID: string; + directory: string; + title: string; + version: string; + time: { + created: number; + updated: number; + }; +} + +export type PromptPartInput = { + id?: string; + type: "text"; + text: string; +}; + +export interface PromptPayload { + messageID?: string; + model?: { providerID: string; modelID: string }; + agent?: string; + noReply?: boolean; + tools?: Record; + format?: { type: string }; + system?: string; + variant?: string; + parts: PromptPartInput[]; +} + +export interface UserMessage { + id: string; + sessionID: string; + role: "user"; + time: { created: number }; + model?: { providerID: string; modelID: string }; + system?: string; + tools?: Record; +} + +export interface AssistantMessage { + id: string; + sessionID: string; + role: "assistant"; + time: { created: number; completed?: number }; + parentID: string; + modelID: string; + providerID: string; + mode: string; + agent: string; + path: { cwd: string; root: string }; + cost: number; + tokens: { + input: number; + output: number; + reasoning: number; + cache: { read: number; write: number }; + }; + finish?: string; + error?: { name: string; data?: { message?: string } }; +} + +export type Message = UserMessage | AssistantMessage; + +export interface TextPart { + id: string; + sessionID: string; + messageID: string; + type: "text"; + text: string; +} + +export type Part = TextPart; + +export interface MessageWithParts { + info: Message; + parts: Part[]; +} + +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: "session.updated"; properties: { info: Session } } + | { type: "tool.call"; properties: { sessionID?: string; id?: string; callID?: string; name?: string; tool?: string; status?: string; input?: unknown } } + | { type: "tool.call.updated"; properties: { sessionID?: string; id?: string; callID?: string; name?: string; tool?: string; status?: string; input?: unknown; output?: unknown; error?: string } } + | { type: "permission.requested"; properties: { sessionID?: string; requestId?: string; id?: string; title?: string; description?: string; options?: Array<{ id: string; label: string; description?: string; outcome?: string }> } } + | { type: "session.idle"; properties: { sessionID: string } } + | { type: "session.error"; properties: { sessionID?: string; error?: { name: string; data?: { message?: string } } } }; + +export interface FileNode { + name: string; + path: string; + absolute: string; + type: "file" | "directory"; + ignored: boolean; +} + +export interface FileContent { + type: "text" | "binary"; + content: string; + encoding?: "base64"; + mimeType?: string; +} + +export interface FileStatus { + path: string; + added: number; + removed: number; + status: "added" | "deleted" | "modified"; +} + +export type ACPTransportProtocol = "content-length" | "newline"; + +export interface BridgeConfig { + adapterMode: "mock" | "stdio" | "acp"; + selectedTool: string | null; + host: string; + port: number; + serverIdleTimeoutSeconds: number; + corsOrigin: string; + version: string; + projectDirs: string[]; + allowArbitraryDirectories: boolean; + defaultProviderId: string; + defaultProviderName: string; + defaultModelId: string; + defaultModelName: string; + defaultTools: string[]; + agentCommand: string | null; + agentArgs: string[]; + agentCwd: string | null; + acpProtocol: ACPTransportProtocol; + acpProtocolVersion: number; + mockStreamDelayMs: number; + maxFileReadBytes: number; +} + +export interface GenerateTextRequest { + session: Session; + project: Project; + payload: PromptPayload; + signal: AbortSignal; +} + +export interface ACPAdapter { + getHealth(): Promise; + getConfigProviders(): Promise; + listCommands(input: { project: Project }): Promise; + listTools(input: { provider: string; model: string; project: Project }): Promise; + getMcpStatus(input: { project: Project }): Promise>; + listResources(input: { project: Project }): Promise>; + generateText(request: GenerateTextRequest): AsyncIterable; + closeSession?(sessionId: string): Promise | void; + dispose?(): Promise | void; +} + +export interface SessionRecord { + session: Session; + project: Project; + messages: MessageWithParts[]; + abortController: AbortController | null; + status: "idle" | "busy"; +} + +export interface ResolvedDirectory { + absolutePath: string; + stats: Stats; +} + diff --git a/packages/nexus-acp-bridge/src/vendor-claude-acp.ts b/packages/nexus-acp-bridge/src/vendor-claude-acp.ts new file mode 100644 index 0000000..57fda59 --- /dev/null +++ b/packages/nexus-acp-bridge/src/vendor-claude-acp.ts @@ -0,0 +1,90 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CLAUDE_AGENT_ACP_VERSION = "0.31.0"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +export const CLAUDE_VENDOR_DIR = path.resolve(here, "..", "vendor", "claude-code"); +export const CLAUDE_VENDOR_BIN = path.join( + CLAUDE_VENDOR_DIR, + "node_modules", + ".bin", + "claude-agent-acp", +); + +export interface EnsureClaudeAcpOptions { + force?: boolean; + /** When true, swallow install failures and return null. */ + silent?: boolean; + log?: (message: string) => void; +} + +/** + * Ensure `@agentclientprotocol/claude-agent-acp` is vendored locally and return + * the absolute path to its CLI binary. When the binary is already present and + * `force` is false, this is a no-op. + * + * Returns the binary path on success, or `null` when `silent` is true and the + * install failed. + */ +export function ensureClaudeAcpVendored(options: EnsureClaudeAcpOptions = {}): string | null { + const log = options.log ?? ((msg: string) => console.log(`[setup-claude-acp] ${msg}`)); + + if (fs.existsSync(CLAUDE_VENDOR_BIN) && !options.force) { + return CLAUDE_VENDOR_BIN; + } + + fs.mkdirSync(CLAUDE_VENDOR_DIR, { recursive: true }); + + const manifest = { + name: "nexus-acp-bridge-vendored-claude-code", + private: true, + version: "0.0.0", + dependencies: { + "@agentclientprotocol/claude-agent-acp": CLAUDE_AGENT_ACP_VERSION, + }, + } as const; + + fs.writeFileSync( + path.join(CLAUDE_VENDOR_DIR, "package.json"), + JSON.stringify(manifest, null, 2) + "\n", + "utf8", + ); + + log(`vendoring @agentclientprotocol/claude-agent-acp@${CLAUDE_AGENT_ACP_VERSION} in ${CLAUDE_VENDOR_DIR}`); + log("running npm install..."); + + const installResult = spawnSync( + "npm", + ["install", "--no-audit", "--no-fund", "--loglevel=error"], + { + cwd: CLAUDE_VENDOR_DIR, + stdio: "inherit", + env: process.env, + }, + ); + + if (installResult.status !== 0) { + const message = `npm install failed with exit code ${installResult.status ?? "unknown"}`; + if (options.silent) { + log(message); + return null; + } + throw new Error(message); + } + + if (!fs.existsSync(CLAUDE_VENDOR_BIN)) { + const message = `install reported success but ${CLAUDE_VENDOR_BIN} is missing`; + if (options.silent) { + log(message); + return null; + } + throw new Error(message); + } + + log(`installed at ${CLAUDE_VENDOR_BIN}`); + return CLAUDE_VENDOR_BIN; +} + diff --git a/packages/nexus-acp-bridge/tsconfig.json b/packages/nexus-acp-bridge/tsconfig.json new file mode 100644 index 0000000..835eab9 --- /dev/null +++ b/packages/nexus-acp-bridge/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "lib": ["esnext", "dom"], + "types": ["bun-types", "node"] + }, + "include": ["src/**/*.ts"] +} + diff --git a/src/app/api/workspaces/[id]/research-spaces/[rid]/enrich/route.ts b/src/app/api/workspaces/[id]/research-spaces/[rid]/enrich/route.ts new file mode 100644 index 0000000..9daa158 --- /dev/null +++ b/src/app/api/workspaces/[id]/research-spaces/[rid]/enrich/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { parseEnrichResult, ResearchAiError } from "@/lib/research/ai"; +import { ResearchBlockSchema } from "@/lib/research/schemas"; +import { getResearchSpace } from "@/lib/research/server"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string; rid: string }> }; + +export async function POST(request: Request, { params }: RouteParams) { + try { + const { id, rid } = await params; + const space = await getResearchSpace(id, rid); + if (!space) return NextResponse.json({ error: "Research space not found" }, { status: 404 }); + const body = await request.json().catch(() => ({})); + const block = ResearchBlockSchema.safeParse(body.block); + if (!block.success) return NextResponse.json({ error: "Invalid block" }, { status: 400 }); + if (typeof body.rawResult !== "string") { + return NextResponse.json({ error: "AI not connected" }, { status: 503 }); + } + const result = parseEnrichResult(body.rawResult, block.data.content); + return NextResponse.json({ result }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to enrich research"; + const status = error instanceof ResearchAiError ? 422 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/workspaces/[id]/research-spaces/[rid]/promote/route.ts b/src/app/api/workspaces/[id]/research-spaces/[rid]/promote/route.ts new file mode 100644 index 0000000..1bc5092 --- /dev/null +++ b/src/app/api/workspaces/[id]/research-spaces/[rid]/promote/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { getBrainStore } from "@/lib/brain/server"; +import { PromoteResearchSchema } from "@/lib/research/schemas"; +import { getResearchSpace } from "@/lib/research/server"; +import { buildResearchPromotionDoc } from "@/lib/research/promotion"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string; rid: string }> }; + +export async function POST(request: Request, { params }: RouteParams) { + try { + const { id, rid } = await params; + const parsed = PromoteResearchSchema.safeParse(await request.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid promote payload" }, { status: 400 }); + } + const space = await getResearchSpace(id, rid); + if (!space) return NextResponse.json({ error: "Research space not found" }, { status: 404 }); + + const targetWorkspaceId = parsed.data.target === "personal" + ? request.headers.get("x-brain-workspace-id") ?? id + : id; + const doc = await getBrainStore().saveDoc(targetWorkspaceId, buildResearchPromotionDoc(space, parsed.data)); + return NextResponse.json({ doc, target: parsed.data.target }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to promote research"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/workspaces/[id]/research-spaces/[rid]/route.ts b/src/app/api/workspaces/[id]/research-spaces/[rid]/route.ts new file mode 100644 index 0000000..8f3e161 --- /dev/null +++ b/src/app/api/workspaces/[id]/research-spaces/[rid]/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { ResearchSpaceDataSchema, SaveResearchSpaceSchema, UpdateResearchSpaceMetaSchema } from "@/lib/research/schemas"; +import { deleteResearchSpace, getResearchSpace, saveResearchSpace, updateResearchSpaceMeta } from "@/lib/research/server"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string; rid: string }> }; + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const { id, rid } = await params; + const space = await getResearchSpace(id, rid); + if (!space) return NextResponse.json({ error: "Research space not found" }, { status: 404 }); + return NextResponse.json(space); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to read research space"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PUT(request: Request, { params }: RouteParams) { + try { + const { id, rid } = await params; + const body = await request.json().catch(() => ({})); + const parsed = SaveResearchSpaceSchema.safeParse(body); + const fallbackParsed = ResearchSpaceDataSchema.safeParse(body); + if (!parsed.success && !fallbackParsed.success) { + return NextResponse.json({ error: parsed.error?.issues[0]?.message ?? "Invalid save payload" }, { status: 400 }); + } + const data = parsed.success ? parsed.data.data : fallbackParsed.success ? fallbackParsed.data : null; + if (!data) { + return NextResponse.json({ error: "Invalid save payload" }, { status: 400 }); + } + const lastModifiedBy = parsed.success ? parsed.data.lastModifiedBy : "anonymous"; + const space = await saveResearchSpace(id, rid, data, lastModifiedBy); + if (!space) return NextResponse.json({ error: "Research space not found" }, { status: 404 }); + return NextResponse.json({ saved: true, space }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save research space"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PATCH(request: Request, { params }: RouteParams) { + try { + const { id, rid } = await params; + const parsed = UpdateResearchSpaceMetaSchema.safeParse(await request.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid update payload" }, { status: 400 }); + } + const space = await updateResearchSpaceMeta(id, rid, parsed.data); + if (!space) return NextResponse.json({ error: "Research space not found" }, { status: 404 }); + return NextResponse.json({ space }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update research space"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function DELETE(_request: Request, { params }: RouteParams) { + try { + const { id, rid } = await params; + const deleted = await deleteResearchSpace(id, rid); + if (!deleted) return NextResponse.json({ error: "Research space not found" }, { status: 404 }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete research space"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/workspaces/[id]/research-spaces/[rid]/synthesize/route.ts b/src/app/api/workspaces/[id]/research-spaces/[rid]/synthesize/route.ts new file mode 100644 index 0000000..e2f95ee --- /dev/null +++ b/src/app/api/workspaces/[id]/research-spaces/[rid]/synthesize/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { synthesizeResearch } from "@/lib/research/ai"; +import { getResearchSpace, saveResearchSpace } from "@/lib/research/server"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string; rid: string }> }; + +export async function POST(request: Request, { params }: RouteParams) { + try { + const { id, rid } = await params; + const space = await getResearchSpace(id, rid); + if (!space) return NextResponse.json({ error: "Research space not found" }, { status: 404 }); + const body = await request.json().catch(() => ({})); + const selectedIds = Array.isArray(body.blockIds) ? new Set(body.blockIds) : null; + const blocks = selectedIds ? space.blocks.filter((block) => selectedIds.has(block.id)) : space.blocks; + const synthesis = synthesizeResearch(blocks, body.title ?? "Research Synthesis", body.createdBy ?? "research"); + const saved = await saveResearchSpace(id, rid, { ...space, syntheses: [synthesis, ...space.syntheses] }, body.createdBy ?? "research"); + return NextResponse.json({ synthesis, space: saved }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to synthesize research"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/workspaces/[id]/research-spaces/route.ts b/src/app/api/workspaces/[id]/research-spaces/route.ts new file mode 100644 index 0000000..a1f3e0c --- /dev/null +++ b/src/app/api/workspaces/[id]/research-spaces/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { CreateResearchSpaceSchema } from "@/lib/research/schemas"; +import { createResearchSpace, listResearchSpaces } from "@/lib/research/server"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const spaces = await listResearchSpaces(id); + if (!spaces) return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + return NextResponse.json({ spaces }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to list research spaces"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function POST(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const parsed = CreateResearchSpaceSchema.safeParse(await request.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid research space payload" }, { status: 400 }); + } + const space = await createResearchSpace(id, parsed.data); + if (!space) return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + return NextResponse.json({ space }, { status: 201 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create research space"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/workspace/[id]/research/page.tsx b/src/app/workspace/[id]/research/page.tsx new file mode 100644 index 0000000..86d727d --- /dev/null +++ b/src/app/workspace/[id]/research/page.tsx @@ -0,0 +1,8 @@ +import { ResearchPage } from "@/components/research/research-page"; + +export const dynamic = "force-dynamic"; + +export default async function WorkspaceResearchRoute({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/src/components/research/command-input.tsx b/src/components/research/command-input.tsx new file mode 100644 index 0000000..27f8349 --- /dev/null +++ b/src/components/research/command-input.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useState } from "react"; +import { Send } from "lucide-react"; + +export function CommandInput({ onSubmit }: { onSubmit: (text: string) => void }) { + const [value, setValue] = useState(""); + return ( +
{ event.preventDefault(); const text = value.trim(); if (text) { onSubmit(text); setValue(""); } }} className="border-t border-zinc-800 bg-zinc-950 p-4"> +
+ setValue(event.target.value)} placeholder="Add a tile, question, source URL, quote, or task…" className="flex-1 bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-600" /> + +
+
+ ); +} diff --git a/src/components/research/import-export-menu.tsx b/src/components/research/import-export-menu.tsx new file mode 100644 index 0000000..6be5f0e --- /dev/null +++ b/src/components/research/import-export-menu.tsx @@ -0,0 +1,10 @@ +"use client"; + +export function ImportExportMenu() { + return ( +
+

Import / Export

+

Use the action bar to export .nodepad and markdown. Malformed imports are rejected by API helpers.

+
+ ); +} diff --git a/src/components/research/promote-menu.tsx b/src/components/research/promote-menu.tsx new file mode 100644 index 0000000..026bb21 --- /dev/null +++ b/src/components/research/promote-menu.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { promoteResearchClient } from "@/lib/research/client"; +import type { ResearchSpaceData } from "@/lib/research/types"; +import { toast } from "sonner"; + +export function PromoteMenu({ workspaceId, space }: { workspaceId: string; space: ResearchSpaceData | null }) { + const promote = async (target: "workspace" | "personal") => { + if (!space) return; + try { + await promoteResearchClient(workspaceId, space.id, { target, blockIds: space.selectedBlockIds }); + toast.success(`Promoted to ${target === "workspace" ? "Workspace Brain" : "Personal Brain"}`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Promotion failed"); + } + }; + return ( +
+ + +
+ ); +} diff --git a/src/components/research/research-page.tsx b/src/components/research/research-page.tsx new file mode 100644 index 0000000..cb89eec --- /dev/null +++ b/src/components/research/research-page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { ArrowLeft, Download, FileText, PanelLeftOpen, PanelRightOpen } from "lucide-react"; +import Link from "next/link"; +import ConnectDialog from "@/components/workflow/connect-dialog"; +import { getResearchSpaceClient, saveResearchSpaceClient } from "@/lib/research/client"; +import { exportResearchMarkdown } from "@/lib/research/markdown-export"; +import { serializeNodepad } from "@/lib/research/nodepad-format"; +import type { ResearchTemplateId } from "@/lib/research/types"; +import { useResearchStore } from "@/store/research-store"; +import { useResearchSpaces } from "@/hooks/use-research-spaces"; +import { useResearchAutosave } from "@/hooks/use-research-autosave"; +import { useResearchCollaboration } from "@/hooks/use-research-collaboration"; +import { useOpenCodeStore } from "@/store/opencode"; +import { toast } from "sonner"; +import { CommandInput } from "./command-input"; +import { PromoteMenu } from "./promote-menu"; +import { SpaceSidebar } from "./space-sidebar"; +import { StatusBar } from "./status-bar"; +import { SynthesisPanel } from "./synthesis-panel"; +import { TileIndex } from "./tile-index"; +import { GraphView } from "./views/graph-view"; +import { KanbanView } from "./views/kanban-view"; +import { TilingView } from "./views/tiling-view"; + +type ResearchLayout = { leftOpen: boolean; rightOpen: boolean; synthesisCollapsed: boolean }; + +const DEFAULT_RESEARCH_LAYOUT: ResearchLayout = { leftOpen: true, rightOpen: true, synthesisCollapsed: false }; +const layoutListeners = new Set<() => void>(); + +function layoutKey(workspaceId: string) { + return `nexus.research.layout.${workspaceId}`; +} + +function readLayoutJson(workspaceId: string) { + if (typeof window === "undefined") return ""; + return window.localStorage.getItem(layoutKey(workspaceId)) ?? ""; +} + +function parseLayout(json: string): ResearchLayout { + if (!json) return DEFAULT_RESEARCH_LAYOUT; + try { + const saved = JSON.parse(json) as Partial; + return { + leftOpen: typeof saved.leftOpen === "boolean" ? saved.leftOpen : DEFAULT_RESEARCH_LAYOUT.leftOpen, + rightOpen: typeof saved.rightOpen === "boolean" ? saved.rightOpen : DEFAULT_RESEARCH_LAYOUT.rightOpen, + synthesisCollapsed: typeof saved.synthesisCollapsed === "boolean" ? saved.synthesisCollapsed : DEFAULT_RESEARCH_LAYOUT.synthesisCollapsed, + }; + } catch { + return DEFAULT_RESEARCH_LAYOUT; + } +} + +function writeLayout(workspaceId: string, layout: ResearchLayout) { + window.localStorage.setItem(layoutKey(workspaceId), JSON.stringify(layout)); + layoutListeners.forEach((listener) => listener()); +} + +function subscribeLayout(listener: () => void) { + layoutListeners.add(listener); + return () => layoutListeners.delete(listener); +} + +function subscribeHydration() { + return () => undefined; +} + +export function ResearchPage({ workspaceId }: { workspaceId: string }) { + const { spaces, isLoading, error, createSpace, deleteSpace } = useResearchSpaces(workspaceId); + const { activeSpace, setActiveSpace, viewMode, setViewMode, addBlock, updateBlock, deleteBlock, selectedBlockIds, toggleSelected, aiStatus, enrichBlock, generateSynthesis, runningBlockIds } = useResearchStore(); + const [connectOpen, setConnectOpen] = useState(false); + const hydrated = useSyncExternalStore(subscribeHydration, () => true, () => false); + const layoutJson = useSyncExternalStore(subscribeLayout, () => readLayoutJson(workspaceId), () => ""); + const layout = parseLayout(layoutJson); + const { leftOpen, rightOpen, synthesisCollapsed } = layout; + const showLeftPanel = hydrated && leftOpen; + const updateLayout = (next: Partial) => writeLayout(workspaceId, { ...layout, ...next }); + const attemptedAutoConnect = useRef(false); + const opencodeStatus = useOpenCodeStore((state) => state.status); + const connectOpenCode = useOpenCodeStore((state) => state.connect); + useResearchAutosave(workspaceId, activeSpace); + useResearchCollaboration(workspaceId, activeSpace, setActiveSpace); + + useEffect(() => { + if (!activeSpace && spaces[0]) void getResearchSpaceClient(workspaceId, spaces[0].id).then(setActiveSpace).catch(() => undefined); + }, [activeSpace, spaces, workspaceId, setActiveSpace]); + + useEffect(() => { + if (attemptedAutoConnect.current || opencodeStatus !== "disconnected") return; + attemptedAutoConnect.current = true; + void connectOpenCode(); + }, [connectOpenCode, opencodeStatus]); + + const openSpace = async (id: string) => setActiveSpace(await getResearchSpaceClient(workspaceId, id)); + const createBlank = async () => setActiveSpace(await createSpace("Untitled Research Space")); + const createTemplate = async (templateId: ResearchTemplateId) => setActiveSpace(await createSpace(templateId.replaceAll("-", " "), templateId)); + const removeSpace = async (id: string) => { + const deletingActive = activeSpace?.id === id; + const nextSpaces = await deleteSpace(id); + if (!deletingActive) return; + + const nextSpace = nextSpaces.find((space) => space.id !== id); + setActiveSpace(nextSpace ? await getResearchSpaceClient(workspaceId, nextSpace.id) : null); + }; + const saveNow = async () => { + if (!activeSpace) return; + setActiveSpace(await saveResearchSpaceClient(workspaceId, activeSpace)); + toast.success("Research space saved"); + }; + const copyMarkdown = async () => { + if (!activeSpace) return; + await navigator.clipboard?.writeText(exportResearchMarkdown(activeSpace)); + toast.success("Markdown copied"); + }; + const downloadText = (name: string, content: string) => { + const url = URL.createObjectURL(new Blob([content], { type: "text/plain" })); + const a = document.createElement("a"); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url); + }; + + return ( +
+ +
+ {showLeftPanel ? void openSpace(id)} onCreate={() => void createBlank()} onCreateTemplate={(id) => void createTemplate(id)} onDelete={(id) => void removeSpace(id)} onCollapse={() => updateLayout({ leftOpen: false })} /> :
} +
+
+

{activeSpace?.name ?? "Workspace Research"}

{opencodeStatus !== "connected" &&

Connect AI to enable enrichment and synthesis.

}
+
+ {opencodeStatus !== "connected" && } + {opencodeStatus === "connected" && } +
+
+
+ {isLoading &&

Loading research spaces…

} + {error &&

{error}

} + {!isLoading && !activeSpace &&
No spaces yet. Create a blank space or choose a planning template.
} + {activeSpace && viewMode === "tiling" && void enrichBlock(id)} />} + {activeSpace && viewMode === "kanban" && } + {activeSpace && viewMode === "graph" && } +
+ {activeSpace && void generateSynthesis()} disabled={aiStatus === "running"} collapsed={synthesisCollapsed} onToggleCollapsed={() => updateLayout({ synthesisCollapsed: !synthesisCollapsed })} />} + +
+ {activeSpace && rightOpen && updateLayout({ rightOpen: false })} />} + {activeSpace && !rightOpen &&
} +
+ +
+ ); +} diff --git a/src/components/research/space-sidebar.tsx b/src/components/research/space-sidebar.tsx new file mode 100644 index 0000000..24f2ce4 --- /dev/null +++ b/src/components/research/space-sidebar.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { PanelLeftClose, Plus, Trash2 } from "lucide-react"; +import type { ResearchSpaceRecord, ResearchTemplateId } from "@/lib/research/types"; +import { TemplatePicker } from "./template-picker"; +import { ImportExportMenu } from "./import-export-menu"; + +export function SpaceSidebar({ spaces, activeId, onSelect, onCreate, onCreateTemplate, onDelete, onCollapse }: { spaces: ResearchSpaceRecord[]; activeId?: string; onSelect: (id: string) => void; onCreate: () => void; onCreateTemplate: (id: ResearchTemplateId) => void; onDelete: (id: string) => void; onCollapse?: () => void }) { + return ( + + ); +} diff --git a/src/components/research/status-bar.tsx b/src/components/research/status-bar.tsx new file mode 100644 index 0000000..8ed7968 --- /dev/null +++ b/src/components/research/status-bar.tsx @@ -0,0 +1,16 @@ +"use client"; + +import type { ResearchViewMode } from "@/lib/research/types"; + +export function StatusBar({ workspaceId, spaceName, viewMode, onViewMode }: { workspaceId: string; spaceName: string; viewMode: ResearchViewMode; onViewMode: (mode: ResearchViewMode) => void }) { + return ( +
+
Workspace {workspaceId}/{spaceName}
+
+
+ {(["tiling", "kanban", "graph"] as ResearchViewMode[]).map((mode) => )} +
+
+
+ ); +} diff --git a/src/components/research/synthesis-panel.tsx b/src/components/research/synthesis-panel.tsx new file mode 100644 index 0000000..b4cf454 --- /dev/null +++ b/src/components/research/synthesis-panel.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { ChevronDown, ChevronUp, Loader2 } from "lucide-react"; +import type { ResearchSynthesis } from "@/lib/research/types"; + +export function SynthesisPanel({ syntheses, onGenerate, disabled, collapsed, onToggleCollapsed }: { syntheses: ResearchSynthesis[]; onGenerate: () => void; disabled?: boolean; collapsed: boolean; onToggleCollapsed: () => void }) { + return ( +
+
+

Synthesis

+
+ + +
+
+ {collapsed &&

{syntheses.length} syntheses hidden

} + {!collapsed && ( +
+ {syntheses.length === 0 ?

No synthesis generated yet.

: syntheses.map((item) => ( +
+

{item.title}

+
{item.content}
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/research/template-picker.tsx b/src/components/research/template-picker.tsx new file mode 100644 index 0000000..78f7448 --- /dev/null +++ b/src/components/research/template-picker.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { RESEARCH_TEMPLATE_IDS, getResearchTemplateName } from "@/lib/research/templates"; +import type { ResearchTemplateId } from "@/lib/research/types"; + +export function TemplatePicker({ onCreate }: { onCreate: (templateId: ResearchTemplateId) => void }) { + return ( +
+

Planning templates

+ {RESEARCH_TEMPLATE_IDS.map((id) => )} +
+ ); +} diff --git a/src/components/research/tile-card.tsx b/src/components/research/tile-card.tsx new file mode 100644 index 0000000..27ff94a --- /dev/null +++ b/src/components/research/tile-card.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { AlertCircle, Loader2, Pin, RefreshCw, Tag, Trash2 } from "lucide-react"; +import { RESEARCH_CONTENT_TYPE_CONFIG } from "@/lib/research/content-types"; +import type { ResearchBlock, ResearchContentType } from "@/lib/research/types"; + +interface TileCardProps { + block: ResearchBlock; + selected?: boolean; + onChange: (patch: Partial) => void; + onDelete: () => void; + onSelect: () => void; + onEnrich: () => void; + enriching?: boolean; +} + +export function TileCard({ block, selected, onChange, onDelete, onSelect, onEnrich, enriching }: TileCardProps) { + const pointerStart = useRef<{ x: number; y: number } | null>(null); + const [typePickerOpen, setTypePickerOpen] = useState(false); + const typeButtonRef = useRef(null); + const typePickerRef = useRef(null); + const typeConfig = RESEARCH_CONTENT_TYPE_CONFIG[block.contentType]; + const TypeIcon = typeConfig.icon; + const cardStyle = { + "--research-type-glow": `color-mix(in oklch, ${typeConfig.color} 18%, transparent)`, + borderColor: selected ? typeConfig.color : `color-mix(in oklch, ${typeConfig.color} 30%, rgb(39 39 42))`, + boxShadow: selected ? `0 0 0 1px color-mix(in oklch, ${typeConfig.color} 45%, transparent)` : undefined, + } as CSSProperties; + + useEffect(() => { + if (!typePickerOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setTypePickerOpen(false); + }; + const handleMouseDown = (event: MouseEvent) => { + const target = event.target as Node; + if (!typeButtonRef.current?.contains(target) && !typePickerRef.current?.contains(target)) { + setTypePickerOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + document.addEventListener("mousedown", handleMouseDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("mousedown", handleMouseDown); + }; + }, [typePickerOpen]); + + const handleSelectClick = (event: React.MouseEvent) => { + const selection = window.getSelection()?.toString().trim(); + const start = pointerStart.current; + pointerStart.current = null; + if (selection) return; + if (start) { + const moved = Math.hypot(event.clientX - start.x, event.clientY - start.y); + if (moved > 4) return; + } + onSelect(); + }; + + return ( +
{ + pointerStart.current = { x: event.clientX, y: event.clientY }; + }} + onClick={handleSelectClick} + onKeyDown={(event) => { + if ( + event.target !== event.currentTarget + || (event.key !== "Enter" && event.key !== " ") + ) return; + event.preventDefault(); + onSelect(); + }} + role="button" + tabIndex={0} + aria-pressed={selected} + style={cardStyle} + className="cursor-pointer rounded-xl border bg-zinc-950/80 p-4 shadow-lg transition-shadow hover:shadow-[0_0_10px_5px_var(--research-type-glow)]" + > +
+
+ + {typePickerOpen && ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + className="absolute left-0 top-8 z-50 w-44 rounded-md border border-zinc-800 bg-zinc-950 p-1.5 shadow-xl" + > +

Change type

+
+ {(Object.entries(RESEARCH_CONTENT_TYPE_CONFIG) as Array<[ResearchContentType, typeof typeConfig]>).map(([type, config]) => { + const OptionIcon = config.icon; + const active = block.contentType === type; + return ( + + ); + })} +
+
+ )} +
+
+ + + + +
+
+