diff --git a/docs/IDE_BUILD_PLAN.md b/docs/IDE_BUILD_PLAN.md index c014f1b..9a69aa0 100644 --- a/docs/IDE_BUILD_PLAN.md +++ b/docs/IDE_BUILD_PLAN.md @@ -1,7 +1,7 @@ # Custom AI-Integrated IDE — Build Plan > **Project codename:** _aIDE_ -> **Last updated:** April 2, 2026 +> **Last updated:** April 8, 2026 > **Status:** Active development --- @@ -98,7 +98,7 @@ A desktop IDE built specifically for the workflow of running multiple AI coding ### Nice-to-have (v2+) -- [x] Agent chat panel UI — Dockview `chatPane` with Ask/Edit/Agent modes, streaming message display, tool call approval cards, working set picker, markdown rendering with syntax highlighting +- [x] Agent chat panel UI — Dockview `chatPane` with Ask/Edit/Agent modes, streaming message display, tool call approval cards, working set picker, markdown rendering with syntax highlighting, shared `@file` context mentions, and slash-command autocomplete - [ ] Cursor-style agent panel UI (structured diffs, progress, pause/resume) - [x] Claude Agent SDK integration (replacing raw Claude Code CLI subprocess spawning with `@anthropic-ai/claude-agent-sdk` `query()` async generator) - [ ] Tailwind CSS IntelliSense (via tailwindcss-language-server) @@ -115,6 +115,14 @@ A desktop IDE built specifically for the workflow of running multiple AI coding - [ ] Linked workspace groups — related workspaces (e.g. frontend + backend), cross-workspace terminal, shared env references - [ ] Worktree color coding — assign a distinct accent color to each worktree and apply it to terminal tabs, editor tabs, and pane borders so it's immediately clear which worktree a tab belongs to (branch badge pills already implemented in 5.1e) - [ ] Custom user themes beyond light/dark defaults + +### Theme System (Implemented foundation) + +- Themes now load through a registry-backed manifest system instead of hardcoded `one-dark` / `one-light` checks +- Built-in themes and user-installed themes share the same manifest shape: `id`, `label`, `appearance`, and token map +- User themes live in the app-level themes folder under Electron user data and can be reloaded without code changes +- App settings persist the active theme plus separate default dark and default light theme ids; the toggle switches between those configured defaults +- Command palette actions now cover selecting the active theme, changing default dark/light themes, reloading the registry, and opening the themes folder - [ ] Editor minimap (community CodeMirror extension or custom build) - [ ] Auto-update via `electron-updater` — notify + prompt (never silent restart). Compile-from-source only for MVP - [ ] React `ErrorBoundary` per pane (crash in one pane doesn't kill others), graceful error state UI, opt-in crash telemetry via `electron.crashReporter` or Sentry @@ -660,7 +668,7 @@ Terminal pane supports tabs internally. One tab for the interactive shell, one f Replace raw terminal with structured integration: ```typescript -import { query } from '@anthropic-ai/claude-code' +import { query } from '@anthropic-ai/claude-agent-sdk' const agentProcess = new UtilityProcess('./agent-worker.js', { env: { ANTHROPIC_API_KEY: getStoredApiKey() }, @@ -1043,7 +1051,7 @@ The full workspace switching experience. You can create two workspaces, start Cl - Display: estimated cost (token count × pricing) - [ ] **5.2** Claude Agent SDK integration - - Install `@anthropic-ai/claude-code` + - Install `@anthropic-ai/claude-agent-sdk` - Build `AgentWorker` UtilityProcess that runs the SDK's `query()` loop - Implement IPC message protocol between AgentWorker and renderer - Stream events to `AgentPane`: `file_read`, `file_write`, `command_run`, `thinking`, `complete` @@ -1294,6 +1302,8 @@ These need a decision before or during the relevant phase. Track milestone completion here. Update as you go. +**2026-04-08:** Full `@opencode-ai/sdk` integration — OpenCode is now a true first-class backend with parity to the SDK's surface. **Foundation:** new per-workspace `OpenCodeServerHost` (`packages/main/src/chat/openCodeServerHost.ts`) runs one persistent `opencode serve` process per `WorkspaceRuntime`, with a single shared SSE pump fanned out to per-session subscribers; the `openCodeAdapter` is now a thin per-turn driver that gets a client + session-scoped event stream from the host instead of spawning servers per turn. **Permissions:** new `permissionMatching.ts` lifts the IDE's `agent.permissionTier` + `agent.autoApprove` decision logic out of `agentManager.ts` so both built-in and CLI agents share it; new `openCodePermissionBridge.ts` maps the IDE's tool names to OpenCode's permission categories (`edit`/`bash`/`webfetch`/…) for both pre-flight agent-config and runtime decisions; new `approvalRouter.ts` (registered as a `WorkspaceRuntime` service slot) routes `CHAT_TOOL_APPROVE`/`CHAT_TOOL_REJECT` IPC to whichever manager owns the toolCallId so OpenCode permission prompts surface in the existing built-in approval UI (no new approval pane). **Telemetry:** existing per-session `totalCostUsd` accumulator extended with a per-session `totalTokens` (input/output/reasoning/cacheRead/cacheWrite) breakdown; both Claude and OpenCode adapters now extract token usage from result/message events; renderer shows a `CostTokenBadge` in the pane header. **Hot session config:** `CliAgentBackendState` extended with `providerID`/`modelID`/`agent`/`mode`/`systemPromptOverride`/`toolToggles`; new `cliAgentUpdateSessionConfig` IPC + `SessionSettingsPanel` collapsible disclosure in `CliAgentPane` exposes provider/model/agent/mode pickers, system prompt override, and a tool-toggle list (each lazy-fetched from `client.config.providers()` / `client.app.agents()` / `client.tool.list()`). **Rich part types:** `CliAgentMessageType` extended with `reasoning`/`patch`/`step`/`snapshot`/`retry`/`compaction`/`agent_change`/`subtask`/`file_attachment`; new `openCodePartConverter.ts` maps every SDK `Part` discriminant onto our normalized message shape; new `RichPartRenderer.tsx` renders each variant. **Session ops:** new IPC + manager methods + `SessionMenu.tsx` kebab for share/unshare/summarize/revert/unrevert/fork/abort/diff/todo/init/delete-remote (each delegates to `client.session.*`). **Workspace ops:** opt-in `OpenCodeToolsPane` Dockview pane with Files/Search/Shell/Status/Providers tabs that call `client.{file,find,session.shell,lsp,formatter}.*`; **Config/auth/providers:** `OpenCodeProvidersTab.tsx` lists providers, lets users sign in (paste-the-code OAuth or API key), and shows model lists; **Diagnostics + TUI:** `DiagnosticsPanel.tsx` shows server URL/mode/paths/LSP/formatter, `TuiControlPanel.tsx` exposes the TUI control surface. **Lifecycle integration:** `WorkspaceRuntime.refreshWorkload` sums pending approvals across both managers; `cliAgentManager.destroy()` disposes the `OpenCodeServerHost`; settings-changed listener mirrors `agent.permissionTier`/`agent.autoApprove` updates into `cliAgentManager.updatePermissions()` so live tier changes apply mid-session. **Tests:** new `openCodeServerHost`/`openCodeAdapter`/`openCodePermissionBridge`/`openCodePartConverter`/`cliAgentApprovalRouter` test files and 5 new assertions in `cliAgentManager.test.ts`; all 36 new tests pass alongside existing coverage. + **2026-03-29:** Built-in chat — `useChat` refreshes after `CHAT_STREAM_END` and tool-call IPC now call `chatGetHistory(workspaceId, sessionId)` so history stays scoped to the active tab; avoids main falling back to `getMostRecent` (multi-tab isolation + pre-persist race). **2026-04-08:** Pane focus handoff fix — `EditorPane` and `TerminalPane` now move DOM focus into CodeMirror/xterm when a Dockview panel becomes active and also when a newly-created panel finishes mounting while already active. This fixes keyboard pane/tab switching paths that selected a panel without moving the caret into the target surface. @@ -1365,7 +1375,7 @@ Track milestone completion here. Update as you go. | 5.1c CLI native session hydration | ✅ Complete | `ClaudeNativeSessionWatcher.loadMessages(sessionId)` reads `~/.claude/projects//.jsonl`, skips sidechains / thinking / file-history blocks, and maps rows to `CliAgentMessage[]`. IPC `cli-agent:load-messages` takes `(workspaceId, conversationId)`, returns `[]` when that workspace is not active (avoids native-prefix reads against the wrong project dir), and `useCliAgent` uses a hydrate generation guard, avoids clobbering a non-empty transcript with a stale empty IPC result, clears transcript only when the workspace+conversation key changes, and re-fetches native history once when `CONVERSATION_LIST_CHANGED` reports a `claude-native` row with messages but the pane is still empty. `CliAgentPane` waits on `historyHydrated`. Aide-managed sessions use `ConversationStore.loadMessages`. `CliAgentManager.start` sets `claudeSessionId` from `claude-native:` for `--resume`. | | 5.1d CLI multi-tab isolation | ✅ Complete | New `cliAgentPane` instances receive a provisional `conversationId` (`crypto.randomUUID()` from `agent.open` and default workspace layout in `workspaceSwitcher`). `useCliAgent` stops calling `cliAgentGetSession(workspaceId)` without a session id so tabs are not hydrated from `CliAgentManager.getSession`'s first in-memory match for the workspace. Command `agent.open` (e.g. Cmd+K Cmd+A) always adds a new agent panel for parallel work instead of focusing an existing tab. | | 5.1e Multi-worktree agent orchestration | ✅ Complete | Per-panel worktree isolation: `worktreePath` threaded through `CliAgentManager.start()` → spawn `cwd`, `CliAgentSession`, IPC, `useCliAgent` hook, `CliAgentPane` params. `AgentTab` custom tab component with branch badge pill (registered in `DockviewContainer.tabComponents`). "Start Agent in Worktree" button + context menu entry in `WorktreePanel`/`WorktreeItem`. Built-in agent isolation via `ToolContext.effectiveRoot` in `agentTools.ts` (terminal_exec, search_files, git_status, git_diff). `ChatSession.worktreePath` loaded from `ConversationMeta`. Worktree removal confirmation guard. Terminal panels from worktrees also get branch badges. | -| 5.2 Claude Agent SDK integration | ⬜ Not started | | +| 5.2 Claude Agent SDK integration | ✅ Complete | `CliAgentManager` now owns a generic external-agent session layer with backend adapters for Claude Code, OpenCode, and Codex. Claude still streams via `@anthropic-ai/claude-agent-sdk` and resolves the packaged executable from the SDK's own unpacked `cli.js`; OpenCode is wired through the official SDK/client flow against an `opencode serve` process; Codex is wired through `codex exec --json`. Sessions persist per-backend resume state so a conversation can hot-swap external backends, while `ConversationMeta.backend` tracks the active backend. Frontend hot-swap controls and richer mixed-backend transcript UI are still pending. | | 5.3 Diff preview before apply | ⬜ Not started | | | 5.4 Agent edit highlighting in editor | ⬜ Not started | | | 5.5 Crash recovery | ⬜ Not started | | diff --git a/docs/THEME_FILES.md b/docs/THEME_FILES.md new file mode 100644 index 0000000..9211669 --- /dev/null +++ b/docs/THEME_FILES.md @@ -0,0 +1,169 @@ +# Theme Files + +aIDE loads custom themes from JSON files in the app themes folder. + +## Location + +- Open the folder from: + - Settings > Workbench > Appearance > `Open Themes Folder` + - Command palette: `Open Themes Folder` +- Drop one or more `.json` files into that folder. +- Reload themes from: + - Settings > Workbench > Appearance > `Reload Themes` + - Command palette: `Reload Themes` + +## Required Structure + +Each file must be valid JSON and contain one theme object. + +Required fields: + +- `id`: unique string id for the theme +- `label`: human-readable name shown in the UI +- `appearance`: must be `"dark"` or `"light"` +- `tokens`: object whose keys are CSS custom properties beginning with `--` + +Optional fields: + +- `description` +- `author` + +## Minimal Example + +```json +{ + "id": "my-dark-theme", + "label": "My Dark Theme", + "appearance": "dark", + "tokens": { + "--bg-base": "#14161a", + "--text-primary": "#d7dae0", + "--accent": "#6aa0ff" + } +} +``` + +## Full Example + +```json +{ + "id": "forest-night", + "label": "Forest Night", + "appearance": "dark", + "description": "Muted green-tinted dark theme.", + "author": "You", + "tokens": { + "--bg-base": "#1a1f1c", + "--bg-elevated": "#151916", + "--bg-sunken": "#111512", + "--bg-overlay": "#101411", + "--bg-active-tab": "#1a1f1c", + "--bg-inactive-tab": "#171b18", + "--bg-hover": "rgba(255, 255, 255, 0.05)", + "--bg-selection": "rgba(98, 160, 120, 0.18)", + "--bg-info": "rgba(98, 160, 120, 0.12)", + "--bg-info-hover": "rgba(98, 160, 120, 0.22)", + "--text-primary": "#d7dae0", + "--text-secondary": "#9aa39c", + "--text-muted": "#68706a", + "--text-selected": "#ffffff", + "--text-info": "#74b7ff", + "--text-success": "#89c779", + "--text-warning": "#d8b36a", + "--text-error": "#e57c73", + "--border-base": "#0e120f", + "--border-subtle": "#283028", + "--accent": "#62a078", + "--accent-rgb": "98, 160, 120", + "--text-on-accent": "#ffffff", + "--syntax-keyword": "#c792ea", + "--syntax-fn": "#82aaff", + "--syntax-string": "#a5d6a7", + "--syntax-number": "#f7c873", + "--syntax-comment": "#5f6b66", + "--syntax-tag": "#f07178", + "--syntax-attr": "#7cc7ff", + "--merge-delete-bg": "rgba(240, 113, 120, 0.14)", + "--merge-delete-gutter": "#f07178", + "--merge-insert-bg": "rgba(165, 214, 167, 0.14)", + "--merge-insert-gutter": "#a5d6a7", + "--merge-char-insert": "rgba(165, 214, 167, 0.24)", + "--merge-char-delete": "rgba(240, 113, 120, 0.24)" + } +} +``` + +## Supported Tokens + +These are the tokens the built-in themes define and the custom theme system expects. + +Backgrounds: + +- `--bg-base` +- `--bg-elevated` +- `--bg-sunken` +- `--bg-overlay` +- `--bg-active-tab` +- `--bg-inactive-tab` +- `--bg-hover` +- `--bg-selection` +- `--bg-info` +- `--bg-info-hover` + +Text: + +- `--text-primary` +- `--text-secondary` +- `--text-muted` +- `--text-selected` +- `--text-info` +- `--text-success` +- `--text-warning` +- `--text-error` +- `--text-on-accent` + +Borders and accent: + +- `--border-base` +- `--border-subtle` +- `--accent` +- `--accent-rgb` + +Syntax: + +- `--syntax-keyword` +- `--syntax-fn` +- `--syntax-string` +- `--syntax-number` +- `--syntax-comment` +- `--syntax-tag` +- `--syntax-attr` + +Inline diff: + +- `--merge-delete-bg` +- `--merge-delete-gutter` +- `--merge-insert-bg` +- `--merge-insert-gutter` +- `--merge-char-insert` +- `--merge-char-delete` + +## Important Rules + +- `id` must be unique across all installed themes. +- `appearance` controls whether the theme can be chosen as the default dark or default light theme. +- Token keys must start with `--`. +- Token values must be strings. +- Only `.json` files are loaded. +- Invalid or malformed theme files are ignored. + +## Fallback Behavior + +- If a token is missing, aIDE fills it from the built-in fallback theme of the same appearance. +- If the active theme no longer exists, aIDE falls back to the configured default dark theme. +- If a configured default dark/light theme no longer exists, aIDE falls back to the built-in `one-dark` or `one-light` theme. + +## Notes + +- The theme toggle switches between the configured default dark and default light themes, not between fixed built-in themes. +- Built-in themes use the same manifest shape as custom themes. diff --git a/docs/cli-agent-backend-hotswap-report.md b/docs/cli-agent-backend-hotswap-report.md new file mode 100644 index 0000000..d481dcd --- /dev/null +++ b/docs/cli-agent-backend-hotswap-report.md @@ -0,0 +1,331 @@ +# CLI Agent Backend Hotswap Report + +## Goal + +Support `claude-code`, `opencode`, and `codex` as first-class agent-chat backends, with backend hot swapping inside the same CLI agent chat pane. + +This report covers: + +- what backend work will be done +- what data and IPC contracts will change +- what requires frontend changes from the separate frontend agent + +## Current state + +The current implementation is not a generic CLI-agent backend. It is a Claude-specific manager with a Codex stub. + +### Current backend limitations + +- `packages/shared/src/cliAgentTypes.ts` + - `AgentBackend` only includes `built-in | claude-code | codex` + - there is no `opencode` +- `packages/main/src/chat/cliAgentManager.ts` + - hard-coded to `@anthropic-ai/claude-agent-sdk` + - stores a single `claudeSessionId` + - normalizes only Claude SDK message shapes + - returns `Codex integration coming soon.` for `codex` +- `packages/shared/src/conversationTypes.ts` + - `ConversationMeta` stores `claudeSessionId`, not generic per-backend session state + - `source` only models `claude-native` +- `packages/main/src/index.ts` + - settings updates only push `agent.claudeCodePath` and `agent.codexPath` +- renderer code is hard-coded around only two external backends + - `CliAgentPane.tsx` + - `ChatHistoryPane.tsx` + - `AppShell.tsx` + - `workspaceSwitcher.ts` + - `commands/domains/agent.ts` + +### Hot swapping is not possible today + +The current `cliAgentStart()` path assumes a single fixed backend per pane/session. If the same conversation is reopened with a different backend, the backend-specific session semantics are wrong: + +- Claude resume uses `claudeSessionId` +- OpenCode and Codex will need their own resume/session state +- mixed-backend transcripts have no per-message backend attribution + +## Backend work I will do + +### 1. Introduce a real external-agent backend abstraction + +I will split the current Claude-specific manager into: + +- `CliAgentManager` + - session lifecycle + - persistence + - IPC emission + - backend switching +- backend adapters + - `ClaudeCodeAdapter` + - `OpenCodeAdapter` + - `CodexAdapter` + +Each adapter will be responsible for: + +- executable or SDK resolution +- creating a run for a prompt +- streaming normalized events back to the manager +- stop/cancel behavior +- per-backend resume/session token handling + +### 2. Make session persistence generic instead of Claude-only + +I will replace the single Claude-only resume field with generic per-backend external session state. + +Planned shape: + +```ts +type ExternalCliBackend = 'claude-code' | 'opencode' | 'codex' + +interface ExternalBackendState { + sessionId?: string + model?: string +} + +type ExternalBackendStateMap = Partial> +``` + +This will be stored with the conversation transcript so a single conversation can switch between backends and still resume correctly when switched back later. + +### 3. Add OpenCode as a first-class backend + +I will wire `opencode` into the same external-agent pipeline as Claude and Codex. + +OpenCode has a solid direct SDK story, so this backend will use the official `@opencode-ai/sdk` route instead of treating OpenCode as a raw terminal/TUI integration. + +Planned integration shape: + +- use `createOpencode()` when aIDE needs to spin up and own the OpenCode server process +- use `createOpencodeClient()` when connecting to an already-running OpenCode instance +- talk to OpenCode over the SDK's HTTP client surface rather than scraping terminal output +- normalize OpenCode session/message events into the same `CliAgentMessage` stream used by the pane + +Backend additions: + +- add `opencode` to `AgentBackend` +- add `agent.opencodePath` setting support alongside existing path settings +- add backend label helpers and path update plumbing +- add OpenCode adapter resolution and startup logic + +What I will not use for the initial backend pass: + +- `@opencode-ai/plugin` + - useful for extending OpenCode with custom tools/hooks, but not required just to embed OpenCode as a selectable backend in aIDE +- ACP + - useful for editor-native embedding over stdio/JSON-RPC, but the direct SDK client route is the cleaner fit for the current Electron main-process architecture + +### 4. Add Codex as a real backend instead of a stub + +I will replace the current stub with a real adapter. + +Important constraint: + +- if Codex exposes a stable machine-readable stream/protocol, I will normalize that into the existing message model +- if Codex only exposes an interactive TUI path, I will need to bridge it through a dedicated process transport instead of the current Claude-style message flow + +Either way, the backend manager will be refactored so Codex is no longer hard-coded as unsupported. + +### 5. Add backend hot swap support to the manager and IPC + +I will add explicit backend switching support rather than overloading `start()`. + +Planned behavior: + +- one CLI conversation can keep a single transcript +- each turn runs on the currently selected backend +- switching backends preserves prior transcript and per-backend external session state +- if a backend has never seen the conversation before, the manager will seed it from the existing conversation transcript instead of using a native resume token + +Backend/API work: + +- add a new `cliAgentSwitchBackend(sessionId, backend)` IPC path +- expose the active backend in `CliAgentSession` +- preserve per-backend state in persisted conversation data +- prevent or explicitly stop active runs before switching + +### 6. Attribute messages to the backend that produced them + +I will add backend attribution to normalized CLI messages so mixed-backend chats remain understandable. + +Planned shape: + +```ts +interface CliAgentMessage { + ... + backend?: 'claude-code' | 'opencode' | 'codex' +} +``` + +This is required once a single conversation can contain output from multiple external backends. + +### 7. Migrate existing stored conversations safely + +I will add a backward-compatible lazy migration path so existing Claude conversations continue to work. + +Migration plan: + +- existing `claudeSessionId` will be read and copied into generic backend state for `claude-code` +- existing conversation metadata with `backend: 'claude-code'` remains valid +- `claude-native` mirrored sessions remain Claude-only and stay separate from generic external session state + +## Backend files I expect to change + +Shared types / contracts: + +- `packages/shared/src/cliAgentTypes.ts` +- `packages/shared/src/conversationTypes.ts` +- `packages/shared/src/index.ts` + +Main process: + +- `packages/main/src/chat/cliAgentManager.ts` +- `packages/main/src/chat/conversationStore.ts` +- `packages/main/src/index.ts` +- `packages/main/src/preload.ts` +- `packages/main/src/workspace/settingsResolver.ts` + +Likely new backend adapter files: + +- `packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts` +- `packages/main/src/chat/cliAdapters/openCodeAdapter.ts` +- `packages/main/src/chat/cliAdapters/codexAdapter.ts` +- `packages/main/src/chat/cliAdapters/types.ts` + +Tests: + +- `tests/unit/cliAgentManager.test.ts` +- new adapter-focused unit tests + +## Frontend changes required + +These changes are required for the backend work to be fully usable in the UI. + +### 1. Add `opencode` everywhere backend choices are rendered + +Required files: + +- `packages/renderer/src/lib/settingsSchema.ts` +- `packages/renderer/src/components/panes/CliAgentPane.tsx` +- `packages/renderer/src/components/panes/ChatHistoryPane.tsx` +- `packages/renderer/src/components/layout/AppShell.tsx` +- `packages/renderer/src/lib/workspace/workspaceSwitcher.ts` +- `packages/renderer/src/commands/domains/agent.ts` + +The current renderer hard-codes only Claude and Codex labels/branches. + +### 2. Add a backend switcher UI in the CLI agent pane + +Required UI behavior: + +- show current backend in the pane header +- allow switching between `claude-code`, `opencode`, and `codex` +- disable the switcher while a turn is actively running, or require stop-first behavior +- call the new backend switch IPC instead of reopening a new pane + +This is the key frontend requirement for hot swapping. + +### 3. Render mixed-backend transcripts clearly + +Once one conversation can contain responses from multiple backends, the pane should show that clearly. + +Minimum required UI change: + +- display a backend badge on assistant/tool/result/error messages when the conversation contains more than one backend + +Without this, hot-swapped conversations will be confusing. + +### 4. Update history and new-chat affordances + +Current limitations: + +- `ChatHistoryPane` only exposes `+` for built-in conversations +- renderer routing logic assumes exact backend values instead of generic external-agent semantics + +Required frontend changes: + +- optionally allow creating a new CLI conversation from history UI with backend selection +- treat `opencode` as a CLI backend +- route any external CLI conversation to `cliAgentPane` + +### 5. Update settings UI for the new backend path + +Required UI/settings additions: + +- add `agent.opencodePath` +- adjust descriptions so backend settings no longer imply only Claude/Codex exist + +## Frontend contract changes to expect + +The frontend agent should expect these shared contract changes. + +### Shared type changes + +- `AgentBackend` will include `opencode` +- `CliAgentMessage` will likely gain `backend?: AgentBackend` +- `CliAgentSession` will likely gain `activeBackend` and generic backend-state data +- `ConversationMeta.backend` should be treated as the last-used or primary backend, not as the only backend that has ever touched the conversation + +### New or changed preload/window API calls + +Expected additions: + +```ts +cliAgentSwitchBackend: (sessionId: string, backend: AgentBackend) => + Promise<{ success: true } | { error: string }> +``` + +Possible session shape update: + +```ts +interface CliAgentSession { + ... + activeBackend: 'claude-code' | 'opencode' | 'codex' +} +``` + +## Risks and open questions + +### Codex transport risk + +Codex appears to be primarily exposed as a CLI package. If it does not expose a stable non-interactive structured stream, it will require a different adapter strategy than Claude/OpenCode. + +This does not block the manager refactor, but it may affect how deep Codex parity can go in the first backend pass. + +### Conversation semantics after a switch + +The backend implementation will preserve one transcript across all external backends. That means: + +- native resume is backend-specific +- cross-backend continuity is transcript-based, not native-session-based + +This is the correct model for hot swapping, but the frontend should present it as "switch backend for future turns" rather than "move the same vendor session between providers". + +## Recommended work split + +### Backend work owned here + +- adapter abstraction +- Claude adapter extraction +- OpenCode adapter wiring +- Codex adapter wiring +- generic persistence/session-state migration +- switch-backend IPC and session behavior +- tests and migrations + +### Frontend work for the separate agent + +- backend selector UI in `CliAgentPane` +- `opencode` labels and routing everywhere a backend is rendered +- message badges for mixed-backend transcripts +- settings UI for `agent.opencodePath` +- any create-new-chat UX that should expose CLI backend choice + +## Suggested frontend handoff summary + +Frontend should prepare for: + +1. `opencode` becoming a valid `AgentBackend` +2. a new `window.api.cliAgentSwitchBackend()` call +3. `CliAgentMessage.backend` being present on external-agent messages +4. `CliAgentSession.activeBackend` becoming the source of truth for the pane header +5. a conversation transcript containing output from more than one external backend diff --git a/electron-builder.yml b/electron-builder.yml index 6ad94a4..cac1d09 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -12,10 +12,12 @@ files: - node_modules/**/* asarUnpack: - - "**/node-pty/**" - - "**/@vscode/ripgrep/**" - - "**/@anthropic-ai/claude-code/**" - - "**/.pnpm/@anthropic-ai+claude-code@*/node_modules/@anthropic-ai/claude-code/**" + - '**/node-pty/**' + - '**/@vscode/ripgrep/**' + - '**/@anthropic-ai/claude-agent-sdk/**' + - '**/.pnpm/@anthropic-ai+claude-agent-sdk@*/node_modules/@anthropic-ai/claude-agent-sdk/**' + - '**/@anthropic-ai/claude-code/**' + - '**/.pnpm/@anthropic-ai+claude-code@*/node_modules/@anthropic-ai/claude-code/**' extraMetadata: main: packages/main/dist/index.js @@ -35,7 +37,7 @@ mac: gatekeeperAssess: false dmg: - artifactName: "${productName}-${version}-${os}-${arch}.${ext}" + artifactName: '${productName}-${version}-${os}-${arch}.${ext}' linux: target: diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 42b807c..8164e2e 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -25,7 +25,12 @@ function stripDockviewStyleInject(): Plugin { export default defineConfig({ main: { - plugins: [externalizeDepsPlugin({ include: ['node-pty', '@vscode/ripgrep'] })], + plugins: [ + externalizeDepsPlugin({ + include: ['node-pty', '@vscode/ripgrep'], + exclude: ['@opencode-ai/sdk'], + }), + ], build: { outDir: 'packages/main/dist', lib: { diff --git a/package.json b/package.json index 0e57ca6..e51d4a1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.91", "@anthropic-ai/claude-code": "*", + "@opencode-ai/sdk": "^1.4.0", "@vscode/ripgrep": "^1.17.1", "node-pty": "^1.1.0" }, diff --git a/packages/main/package.json b/packages/main/package.json index a32dc2a..b71e286 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -6,6 +6,7 @@ "dependencies": { "@aide/shared": "workspace:*", "@anthropic-ai/claude-agent-sdk": "^0.2.91", + "@opencode-ai/sdk": "^1.4.0", "electron-store": "^6.0.1", "node-pty": "^1.1.0", "simple-git": "^3.33.0" diff --git a/packages/main/src/chat/agentManager.ts b/packages/main/src/chat/agentManager.ts index 5ba242b..1fa8d75 100644 --- a/packages/main/src/chat/agentManager.ts +++ b/packages/main/src/chat/agentManager.ts @@ -14,6 +14,7 @@ import type { WebContents } from 'electron' import { IpcChannels, deriveTitle, + type ChatComposerSubmission, type ChatSession, type ChatMessage, type ChatMode, @@ -35,6 +36,9 @@ import { ToolRegistry } from './toolRegistry' import type { BrowserPaneManager } from '../browserPaneManager' import type { ConversationStore } from './conversationStore' import type { TaskVariableContext } from '../tasks/taskVariableResolver' +import { shouldAutoApprove as evalShouldAutoApprove } from './permissionMatching' +import type { ToolApprovalOwner } from './approvalRouter' +import { buildComposerContext } from './chatComposerContext' // ─── Constants ───────────────────────────────────────────────────── @@ -67,7 +71,7 @@ interface PendingApproval { // ─── Manager ─────────────────────────────────────────────────────── -export class AgentManager { +export class AgentManager implements ToolApprovalOwner { private sessions = new Map() private llmClient: LlmClient private toolRegistry: ToolRegistry @@ -112,29 +116,40 @@ export class AgentManager { */ async sendMessage( sessionId: string, - content: string, + submission: ChatComposerSubmission, ): Promise<{ messageId: string } | { error: string }> { const session = this.sessions.get(sessionId) if (!session) { return { error: `Session not found: ${sessionId}` } } + if (!submission.text.trim() && submission.mentionedFiles.length === 0) { + return { error: 'Message is empty' } + } // Don't allow sending while a loop is running if (this.activeLoops.has(sessionId)) { return { error: 'Agent is already processing a message' } } + const composerContext = await buildComposerContext( + submission, + session.worktreePath ?? this.workspaceRoot, + ) + // Append user message const userMsg: ChatMessage = { id: randomUUID(), role: 'user', - content, + content: submission.rawText?.trim() || submission.text.trim(), + contextualContent: composerContext.contextualContent, + mentionedFiles: composerContext.mentionedFiles, + commandId: composerContext.commandId, timestamp: Date.now(), } session.messages.push(userMsg) // Auto-title on first user message - await this.maybeAutoTitle(session, content) + await this.maybeAutoTitle(session, submission.text) // Prepare assistant message id const assistantMessageId = randomUUID() @@ -146,11 +161,9 @@ export class AgentManager { this.toolRetryCounters.set(sessionId, new Map()) // Fire-and-forget: the loop streams events to renderer - this.runAgentLoop(session, assistantMessageId, controller.signal).catch( - (err) => { - console.error('[AgentManager] Unhandled error in agent loop:', err) - }, - ) + this.runAgentLoop(session, assistantMessageId, controller.signal).catch((err) => { + console.error('[AgentManager] Unhandled error in agent loop:', err) + }) return { messageId: assistantMessageId } } @@ -168,8 +181,8 @@ export class AgentManager { // Try to load from ConversationStore if (this.conversationStore) { - const targetId = conversationId - ?? (await this.conversationStore.getMostRecent(workspaceId, 'built-in'))?.id + const targetId = + conversationId ?? (await this.conversationStore.getMostRecent(workspaceId, 'built-in'))?.id if (targetId) { // Check memory @@ -179,7 +192,7 @@ export class AgentManager { // Load worktreePath from conversation metadata const meta = await this.conversationStore.get(targetId) // Load from disk - const loaded = await this.conversationStore.loadMessages(targetId) as ChatSession | null + const loaded = (await this.conversationStore.loadMessages(targetId)) as ChatSession | null if (loaded) { loaded.status = 'idle' // Reset status on load if (meta?.worktreePath) loaded.worktreePath = meta.worktreePath @@ -219,16 +232,18 @@ export class AgentManager { // Register in store if this is a genuinely new session (no conversationId given) if (!conversationId && this.conversationStore) { - await this.conversationStore.create({ - workspaceId, - backend: 'built-in', - }).then(meta => { - // Re-key the session with the store-generated ID - this.sessions.delete(session.id) - session.id = meta.id - if (meta.worktreePath) session.worktreePath = meta.worktreePath - this.sessions.set(meta.id, session) - }) + await this.conversationStore + .create({ + workspaceId, + backend: 'built-in', + }) + .then((meta) => { + // Re-key the session with the store-generated ID + this.sessions.delete(session.id) + session.id = meta.id + if (meta.worktreePath) session.worktreePath = meta.worktreePath + this.sessions.set(meta.id, session) + }) } return session @@ -303,7 +318,10 @@ export class AgentManager { this.llmClient.updateConfig(config) } - updatePermissions(tier: PermissionTier, autoApprove: Record): void { + updatePermissions( + tier: PermissionTier, + autoApprove: Record, + ): void { this.permissionTier = tier this.autoApprove = autoApprove } @@ -324,7 +342,11 @@ export class AgentManager { /** * Pending built-in tool calls waiting for user approval (for global inbox hydration). */ - listPendingToolApprovals(): Array<{ workspaceId: string; sessionId: string; toolCall: ToolCall }> { + listPendingToolApprovals(): Array<{ + workspaceId: string + sessionId: string + toolCall: ToolCall + }> { const out: Array<{ workspaceId: string; sessionId: string; toolCall: ToolCall }> = [] for (const [toolCallId, pending] of this.pendingApprovals) { const session = this.sessions.get(pending.sessionId) @@ -415,11 +437,7 @@ export class AgentManager { // Execute tool calls and feed results back session.status = 'awaiting_approval' - const results = await this.executeToolCalls( - session, - toolCalls, - signal, - ) + const results = await this.executeToolCalls(session, toolCalls, signal) if (signal.aborted) break @@ -521,10 +539,7 @@ export class AgentManager { break case 'tool_use_delta': - toolJsonBuffers.set( - event.id, - (toolJsonBuffers.get(event.id) ?? '') + event.partialJson, - ) + toolJsonBuffers.set(event.id, (toolJsonBuffers.get(event.id) ?? '') + event.partialJson) break case 'tool_use_end': { @@ -589,8 +604,7 @@ export class AgentManager { signal: AbortSignal, ): Promise { const results: ToolResult[] = [] - const retryCounters = - this.toolRetryCounters.get(session.id) ?? new Map() + const retryCounters = this.toolRetryCounters.get(session.id) ?? new Map() for (const tc of toolCalls) { if (signal.aborted) { @@ -666,59 +680,15 @@ export class AgentManager { }) } - // ─── Permission Checks ─────────���──────────────────────────── - - private static readonly READ_ONLY_TOOLS = new Set([ - 'file_read', 'file_list', 'search_files', 'git_status', 'git_diff', 'browser_read', - ]) + // ─── Permission Checks ──────────────────────────────────────── private shouldAutoApprove(toolName: string, input: Record): boolean { - // Per-tool overrides take precedence over tier - const override = this.autoApprove[toolName] - if (override === true) return true - if (override === false) return false - if (typeof override === 'object') { - return this.matchesPatternConfig(override, toolName, input) - } - - // Fall back to tier logic - switch (this.permissionTier) { - case 'autopilot': - return true - case 'auto-approve': - return AgentManager.READ_ONLY_TOOLS.has(toolName) - case 'confirm': - default: - return false - } - } - - private matchesPatternConfig( - config: ToolPermissionConfig, - toolName: string, - input: Record, - ): boolean { - // For terminal_exec, match against the command string; otherwise match stringified input - const matchTarget = toolName === 'terminal_exec' - ? String(input.command ?? '') - : JSON.stringify(input) - - // Deny patterns take precedence - if (config.denyPatterns?.some((p) => this.globMatch(matchTarget, p))) { - return false - } - // Must match at least one allow pattern - if (config.allowPatterns && config.allowPatterns.length > 0) { - return config.allowPatterns.some((p) => this.globMatch(matchTarget, p)) - } - return false + return evalShouldAutoApprove(toolName, input, this.permissionTier, this.autoApprove) } - private globMatch(text: string, pattern: string): boolean { - // Simple glob: * matches any sequence of characters - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') - const regex = new RegExp('^' + escaped.replace(/\*/g, '.*') + '$') - return regex.test(text) + /** ToolApprovalOwner: does this manager own the given pending tool call? */ + ownsToolCall(toolCallId: string): boolean { + return this.pendingApprovals.has(toolCallId) } // ���── Message Conversion ─────────���──────────────────────────── @@ -732,7 +702,7 @@ export class AgentManager { case 'user': { result.push({ role: 'user', - content: [{ type: 'text', text: msg.content }], + content: [{ type: 'text', text: msg.contextualContent ?? msg.content }], }) break } @@ -793,9 +763,7 @@ export class AgentManager { ) break case 'edit': - lines.push( - 'You are in EDIT mode. You can read and write files within the working set.', - ) + lines.push('You are in EDIT mode. You can read and write files within the working set.') if (session.workingSet.length > 0) { lines.push( 'You may only modify files in the working set:', @@ -832,11 +800,11 @@ export class AgentManager { // Save messages to the conversation store await this.conversationStore.saveMessages(session.id, session) // Update metadata - const userMsgCount = session.messages.filter(m => m.role === 'user').length + const userMsgCount = session.messages.filter((m) => m.role === 'user').length await this.conversationStore.updateMeta(session.id, { updatedAt: Date.now(), messageCount: session.messages.length, - firstMessage: session.messages.find(m => m.role === 'user')?.content.slice(0, 100), + firstMessage: session.messages.find((m) => m.role === 'user')?.content.slice(0, 100), }) } else { // Legacy: write to single file @@ -867,7 +835,7 @@ export class AgentManager { private async maybeAutoTitle(session: ChatSession, content: string): Promise { if (!this.conversationStore) return - const userMessages = session.messages.filter(m => m.role === 'user') + const userMessages = session.messages.filter((m) => m.role === 'user') if (userMessages.length !== 1) return // Only auto-title on the very first user message const meta = await this.conversationStore.get(session.id) diff --git a/packages/main/src/chat/approvalRouter.ts b/packages/main/src/chat/approvalRouter.ts new file mode 100644 index 0000000..9025233 --- /dev/null +++ b/packages/main/src/chat/approvalRouter.ts @@ -0,0 +1,65 @@ +/** + * ApprovalRouter — single approval surface for tool calls across both the + * built-in `AgentManager` and the external `CliAgentManager`. + * + * The renderer talks to one channel pair (`CHAT_TOOL_APPROVE` / + * `CHAT_TOOL_REJECT`) and one approval UI. The router decides which manager + * actually owns the pending tool call (by id) and dispatches the user's + * decision there. + * + * This keeps a single approval pane in the IDE for both backends, while + * letting each manager keep its own pending-approval map and resolution + * semantics. + */ + +export interface ToolApprovalOwner { + /** Returns true iff this owner is currently waiting on the given tool call id. */ + ownsToolCall(toolCallId: string): boolean + approveToolCall(sessionId: string, toolCallId: string): void + rejectToolCall(sessionId: string, toolCallId: string): void + /** Number of approvals currently awaiting user decision (used by refreshWorkload). */ + getPendingApprovalCount(): number +} + +export class ApprovalRouter { + private owners: ToolApprovalOwner[] = [] + + register(owner: ToolApprovalOwner): void { + if (!this.owners.includes(owner)) { + this.owners.push(owner) + } + } + + unregister(owner: ToolApprovalOwner): void { + const idx = this.owners.indexOf(owner) + if (idx >= 0) this.owners.splice(idx, 1) + } + + approve(sessionId: string, toolCallId: string): boolean { + for (const owner of this.owners) { + if (owner.ownsToolCall(toolCallId)) { + owner.approveToolCall(sessionId, toolCallId) + return true + } + } + return false + } + + reject(sessionId: string, toolCallId: string): boolean { + for (const owner of this.owners) { + if (owner.ownsToolCall(toolCallId)) { + owner.rejectToolCall(sessionId, toolCallId) + return true + } + } + return false + } + + getPendingApprovalCount(): number { + let total = 0 + for (const owner of this.owners) { + total += owner.getPendingApprovalCount() + } + return total + } +} diff --git a/packages/main/src/chat/chatComposerContext.ts b/packages/main/src/chat/chatComposerContext.ts new file mode 100644 index 0000000..879158b --- /dev/null +++ b/packages/main/src/chat/chatComposerContext.ts @@ -0,0 +1,72 @@ +import { readFile } from 'fs/promises' +import { relative, resolve, sep } from 'path' +import type { ChatComposerSubmission } from '@aide/shared' + +const MAX_MENTIONED_FILE_BYTES = 12_000 + +const COMMAND_INSTRUCTIONS: Record = { + plan: 'Create a short implementation plan before making changes.', + explain: 'Focus on explaining the relevant code and behavior clearly.', + fix: 'Focus on identifying the root cause and implementing the smallest correct fix.', + tests: 'Focus on adding or updating the smallest useful tests for this work.', +} + +export async function buildComposerContext( + submission: ChatComposerSubmission, + rootPath: string, +): Promise<{ contextualContent: string; mentionedFiles: string[]; commandId?: string }> { + const mentionedFiles = sanitizeMentionedFiles(submission.mentionedFiles, rootPath) + const sections: string[] = [] + const commandInstruction = submission.commandId + ? COMMAND_INSTRUCTIONS[submission.commandId] + : undefined + if (commandInstruction) { + sections.push(`Requested mode: /${submission.commandId}\n${commandInstruction}`) + } + + if (mentionedFiles.length > 0) { + const fileSections = await Promise.all( + mentionedFiles.map(async (filePath) => formatMentionedFile(rootPath, filePath)), + ) + sections.push(['Referenced files:', ...fileSections].join('\n\n')) + } + + sections.push(submission.text) + return { + contextualContent: sections.filter(Boolean).join('\n\n'), + mentionedFiles, + commandId: submission.commandId, + } +} + +function sanitizeMentionedFiles(paths: string[], rootPath: string): string[] { + const unique = new Set() + for (const filePath of paths) { + if (!filePath) continue + const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '') + const absolute = resolve(rootPath, normalized) + if (!isWithinRoot(absolute, rootPath)) continue + unique.add(relative(rootPath, absolute).replace(/\\/g, '/')) + } + return [...unique] +} + +function isWithinRoot(candidate: string, rootPath: string): boolean { + const rel = relative(rootPath, candidate) + return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !rel.includes(`..${sep}`)) +} + +async function formatMentionedFile(rootPath: string, filePath: string): Promise { + const absolute = resolve(rootPath, filePath) + try { + const content = await readFile(absolute, 'utf-8') + const trimmed = + content.length > MAX_MENTIONED_FILE_BYTES + ? `${content.slice(0, MAX_MENTIONED_FILE_BYTES)}\n...[truncated by aIDE]` + : content + return [`- ${filePath}`, '```', trimmed, '```'].join('\n') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return `- ${filePath}\n(unavailable: ${message})` + } +} diff --git a/packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts b/packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts new file mode 100644 index 0000000..e030fa3 --- /dev/null +++ b/packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts @@ -0,0 +1,328 @@ +import { randomUUID } from 'crypto' +import { query } from '@anthropic-ai/claude-agent-sdk' +import type { Query } from '@anthropic-ai/claude-agent-sdk' +import type { CliAgentTokenUsage } from '@aide/shared' +import type { + CliBackendAdapter, + CliBackendEvent, + CliBackendRun, + CliBackendTurnContext, +} from './types' + +interface ClaudeCodeAdapterOptions { + executablePath: string +} + +export function createClaudeCodeAdapter(options: ClaudeCodeAdapterOptions): CliBackendAdapter { + return { + backend: 'claude-code', + startTurn(context, emit) { + const abortController = new AbortController() + const stderrChunks: string[] = [] + let queryInstance: Query | null = null + + const completed = (async () => { + const startedAt = Date.now() + const queryOptions: Record = { + cwd: context.cwd, + abortController, + includePartialMessages: true, + pathToClaudeCodeExecutable: options.executablePath, + permissionMode: 'default' as const, + settingSources: ['user', 'project', 'local'], + systemPrompt: { type: 'preset', preset: 'claude_code' }, + stderr: (data: string) => { + stderrChunks.push(data) + }, + } + + if (context.backendState.sessionId) { + queryOptions.resume = context.backendState.sessionId + } + + try { + queryInstance = query({ + prompt: context.prompt, + options: queryOptions as Parameters[0]['options'], + }) + } catch (error) { + throw new Error(renderClaudeError(error, stderrChunks)) + } + + let totalCostUsd = 0 + let totalTokens: CliAgentTokenUsage | undefined = undefined + let sawResult = false + for await (const message of queryInstance) { + const events = normalizeClaudeMessage(message) + for (const event of events) { + if (event.type === 'result') { + totalCostUsd += event.totalCostUsd + if (event.tokens) { + totalTokens = sumClaudeTokens(totalTokens, event.tokens) + } + sawResult = true + } + emit(event) + } + } + + if (!sawResult) { + emit({ + type: 'result', + durationMs: Date.now() - startedAt, + totalCostUsd, + tokens: totalTokens, + isSuccess: true, + }) + } + })() + + return { + close() { + abortController.abort() + queryInstance?.close() + }, + completed, + } satisfies CliBackendRun + }, + } +} + +function renderClaudeError(error: unknown, stderrChunks: string[]): string { + const message = error instanceof Error ? error.message : String(error) + const stderrText = stderrChunks.join('').trim() + if (!stderrText) return message + return `${message}\n\nstderr output:\n${stderrText.slice(-2000)}` +} + +function normalizeClaudeMessage(message: any): CliBackendEvent[] { + const type = message.type as string + const subtype = message.subtype as string | undefined + + if (type === 'system') { + if (subtype === 'init') { + const sessionId = message.session_id as string | undefined + const model = (message.model as string) ?? undefined + const tools = Array.isArray(message.tools) ? (message.tools as string[]) : undefined + if (sessionId) { + return [ + { + type: 'backend-state', + patch: { sessionId, model }, + }, + { + type: 'session-meta', + model, + tools, + }, + ] + } + return [ + { + type: 'session-meta', + model, + tools, + }, + ] + } + + if (subtype === 'status') { + return [ + { + type: 'message', + message: { + id: (message.uuid as string) ?? randomUUID(), + type: 'status', + content: String(message.status ?? message.message ?? 'status update'), + timestamp: Date.now(), + }, + }, + ] + } + + return [] + } + + if (type === 'assistant') { + const betaMessage = message.message + if (!betaMessage || !Array.isArray(betaMessage.content)) return [] + + let text = '' + const events: CliBackendEvent[] = [] + for (const block of betaMessage.content) { + if (block.type === 'text') { + text += block.text ?? '' + } + if (block.type === 'tool_use') { + events.push({ + type: 'message', + message: { + id: (block.id as string) ?? randomUUID(), + type: 'tool_use', + content: `Running ${block.name ?? 'tool'}...`, + timestamp: Date.now(), + toolName: block.name as string, + toolUseId: block.id as string, + }, + }) + } + } + + if (text) { + events.push({ + type: 'message', + message: { + id: (message.uuid as string) ?? randomUUID(), + type: 'assistant', + content: text, + timestamp: Date.now(), + raw: message, + }, + }) + } + + return events + } + + if (type === 'stream_event') { + const event = message.event + if (event?.type !== 'content_block_delta' || event.delta?.type !== 'text_delta') return [] + const text = (event.delta.text as string) ?? '' + if (!text) return [] + return [ + { + type: 'stream-delta', + messageId: (message.uuid as string) ?? randomUUID(), + delta: text, + }, + ] + } + + if (type === 'tool_progress') { + return [ + { + type: 'message', + message: { + id: (message.uuid as string) ?? randomUUID(), + type: 'tool_use', + content: `Running ${message.tool_name ?? 'tool'}...`, + timestamp: Date.now(), + toolName: (message.tool_name as string) ?? undefined, + toolUseId: (message.tool_use_id as string) ?? undefined, + }, + }, + ] + } + + if (type === 'tool_use_summary') { + return [ + { + type: 'message', + message: { + id: (message.uuid as string) ?? randomUUID(), + type: 'tool_result', + content: (message.summary as string) ?? 'Tool completed', + timestamp: Date.now(), + }, + }, + ] + } + + if (type === 'rate_limit_event') { + return [ + { + type: 'message', + message: { + id: randomUUID(), + type: 'status', + content: 'Rate limited', + timestamp: Date.now(), + }, + }, + ] + } + + if (type === 'result') { + const isSuccess = subtype === 'success' + const durationMs = (message.duration_ms as number) ?? 0 + const totalCostUsd = (message.total_cost_usd as number) ?? 0 + const sessionId = message.session_id as string | undefined + const errors = Array.isArray(message.errors) ? (message.errors as string[]) : [] + const errorDetail = errors.length > 0 ? errors.join('\n') : '' + const tokens = extractClaudeTokens(message.usage) + const events: CliBackendEvent[] = [] + + if (sessionId) { + events.push({ + type: 'backend-state', + patch: { sessionId }, + }) + } + + events.push({ + type: 'message', + message: { + id: (message.uuid as string) ?? randomUUID(), + type: isSuccess ? 'result' : 'error', + content: isSuccess + ? `Completed in ${(durationMs / 1000).toFixed(1)}s` + : `Failed: ${subtype ?? 'unknown error'}${errorDetail ? `\n\n${errorDetail}` : ''}`, + timestamp: Date.now(), + durationMs, + totalCostUsd, + tokens, + isSuccess, + raw: message, + }, + }) + + events.push({ + type: 'result', + durationMs, + totalCostUsd, + tokens, + isSuccess, + }) + + return events + } + + return [] +} + +/** Extract Claude SDK usage block (input_tokens / output_tokens / cache_*) into our shared shape. */ +function extractClaudeTokens(usage: unknown): CliAgentTokenUsage | undefined { + if (!usage || typeof usage !== 'object') return undefined + const u = usage as Record + const input = numberOr(u.input_tokens, 0) + const output = numberOr(u.output_tokens, 0) + const cacheRead = numberOr(u.cache_read_input_tokens, 0) + const cacheWrite = numberOr(u.cache_creation_input_tokens, 0) + if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) return undefined + return { + input, + output, + reasoning: 0, + cacheRead, + cacheWrite, + } +} + +function sumClaudeTokens( + a: CliAgentTokenUsage | undefined, + b: CliAgentTokenUsage | undefined, +): CliAgentTokenUsage | undefined { + if (!a) return b + if (!b) return a + return { + input: a.input + b.input, + output: a.output + b.output, + reasoning: a.reasoning + b.reasoning, + cacheRead: a.cacheRead + b.cacheRead, + cacheWrite: a.cacheWrite + b.cacheWrite, + } +} + +function numberOr(value: unknown, fallback: number): number { + return typeof value === 'number' ? value : fallback +} diff --git a/packages/main/src/chat/cliAdapters/codexAdapter.ts b/packages/main/src/chat/cliAdapters/codexAdapter.ts new file mode 100644 index 0000000..9932b4f --- /dev/null +++ b/packages/main/src/chat/cliAdapters/codexAdapter.ts @@ -0,0 +1,162 @@ +import { randomUUID } from 'crypto' +import { spawn } from 'child_process' +import { createInterface } from 'readline' +import type { CliBackendAdapter, CliBackendRun, CliBackendTurnContext } from './types' + +interface CodexAdapterOptions { + executablePath: string +} + +export function createCodexAdapter(options: CodexAdapterOptions): CliBackendAdapter { + return { + backend: 'codex', + startTurn(context, emit) { + const args = context.backendState.sessionId + ? [ + 'exec', + 'resume', + '--json', + '--skip-git-repo-check', + context.backendState.sessionId, + context.prompt, + ] + : ['exec', '--json', '--skip-git-repo-check', context.prompt] + + const proc = spawn(options.executablePath, args, { + cwd: context.cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + const completed = (async () => { + const startedAt = Date.now() + let stderr = '' + let sawResult = false + + proc.stderr?.on('data', (chunk) => { + stderr += chunk.toString() + }) + + if (proc.stdout) { + const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity }) + for await (const line of rl) { + const trimmed = line.trim() + if (!trimmed) continue + let event: Record + try { + event = JSON.parse(trimmed) as Record + } catch { + continue + } + + const type = typeof event.type === 'string' ? event.type : '' + if (type === 'thread.started' && typeof event.thread_id === 'string') { + emit({ type: 'backend-state', patch: { sessionId: event.thread_id } }) + continue + } + + if (type === 'item.started') { + const item = asRecord(event.item) + if (item?.type === 'command_execution') { + emit({ + type: 'message', + message: { + id: asString(item.id) ?? randomUUID(), + type: 'tool_use', + content: `Running command: ${asString(item.command) ?? 'shell command'}`, + timestamp: Date.now(), + toolName: 'shell', + }, + }) + } + continue + } + + if (type === 'item.completed') { + const item = asRecord(event.item) + if (!item) continue + if (item.type === 'agent_message') { + emit({ + type: 'message', + message: { + id: asString(item.id) ?? randomUUID(), + type: 'assistant', + content: asString(item.text) ?? '', + timestamp: Date.now(), + raw: event, + }, + }) + continue + } + + if (item.type === 'command_execution') { + const output = asString(item.aggregated_output)?.trim() + const command = asString(item.command) ?? 'shell command' + const exitCode = typeof item.exit_code === 'number' ? item.exit_code : null + emit({ + type: 'message', + message: { + id: asString(item.id) ?? randomUUID(), + type: 'tool_result', + content: + output || `${command}${exitCode === null ? '' : `\n(exit ${exitCode})`}`, + timestamp: Date.now(), + toolName: 'shell', + }, + }) + } + continue + } + + if (type === 'turn.completed') { + sawResult = true + const usage = asRecord(event.usage) + const outputTokens = + typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0 + emit({ + type: 'message', + message: { + id: randomUUID(), + type: 'result', + content: `Completed in ${((Date.now() - startedAt) / 1000).toFixed(1)}s${outputTokens > 0 ? ` (${outputTokens} output tokens)` : ''}`, + timestamp: Date.now(), + isSuccess: true, + }, + }) + emit({ + type: 'result', + durationMs: Date.now() - startedAt, + totalCostUsd: 0, + isSuccess: true, + }) + } + } + } + + const exitCode = await new Promise((resolve, reject) => { + proc.once('error', reject) + proc.once('close', resolve) + }) + + if ((exitCode ?? 0) !== 0 && !sawResult) { + throw new Error(stderr.trim() || `Codex exited with code ${exitCode}`) + } + })() + + return { + close() { + proc.kill('SIGTERM') + }, + completed, + } satisfies CliBackendRun + }, + } +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} diff --git a/packages/main/src/chat/cliAdapters/openCodeAdapter.ts b/packages/main/src/chat/cliAdapters/openCodeAdapter.ts new file mode 100644 index 0000000..8041ec3 --- /dev/null +++ b/packages/main/src/chat/cliAdapters/openCodeAdapter.ts @@ -0,0 +1,384 @@ +/** + * OpenCode adapter — thin per-turn driver for the OpenCode SDK. + * + * Unlike the previous implementation, this adapter no longer spawns or owns a + * server process. The workspace's `OpenCodeServerHost` already runs a + * persistent `opencode serve` and exposes a shared SSE pump. The adapter: + * + * 1. Gets a client from the host + * 2. Creates an OpenCode session if one doesn't already exist + * 3. Subscribes to per-session SSE events via the host + * 4. Issues `promptAsync` with all overrides from backend state + * (model / agent / system / tools) + * 5. Converts each part via `openCodePartConverter` + * 6. Aggregates cost + tokens and emits a `result` event when the session + * goes idle (or fails) + * + * Permission events are forwarded to the manager via the new + * `permission-request` event variant — the manager bridges them to the + * existing CHAT_TOOL_CALL approval surface and POSTs the response back via + * `host.respondPermission()`. + */ + +import { randomUUID } from 'crypto' +import type { CliAgentMessage, PermissionTier, ToolPermissionConfig } from '@aide/shared' +import type { OpenCodeServerHost } from '../openCodeServerHost' +import { decideOpenCodePermission } from './openCodePermissionBridge' +import { + convertOpenCodePart, + createConvertContext, + extractTokens, + sumTokens, +} from './openCodePartConverter' +import type { + CliBackendAdapter, + CliBackendEvent, + CliBackendRun, + CliBackendTurnContext, +} from './types' + +export interface OpenCodeAdapterOptions { + host: OpenCodeServerHost + /** Permission settings snapshot at the time the turn was initiated. */ + getPermissionSettings: () => { + tier: PermissionTier + autoApprove: Record + } +} + +export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBackendAdapter { + return { + backend: 'opencode', + startTurn(context, emit) { + return runOpenCodeTurn(options, context, emit) + }, + } +} + +function runOpenCodeTurn( + options: OpenCodeAdapterOptions, + context: CliBackendTurnContext, + emit: (event: CliBackendEvent) => void, +): CliBackendRun { + const startedAt = Date.now() + let unsubscribe: (() => void) | null = null + let closed = false + let promptSubmitted = false + let sessionIdRef: string | null = context.backendState.sessionId ?? null + + const partCtx = createConvertContext() + const textByMessageId = new Map() + const emittedAssistantIds = new Set() + const seenPartFinalIds = new Set() + const costByMessageId = new Map() + const tokensByMessageId = new Map>() + let totalCostUsd = 0 + let totalTokens: ReturnType = undefined + let failedError: string | null = null + let idleResolve: (() => void) | null = null + const idlePromise = new Promise((resolve) => { + idleResolve = resolve + }) + + const completed = (async () => { + const client = await options.host.getClient() + + // Ensure we have a session. + if (!sessionIdRef) { + const created = await ( + client as unknown as { + session: { + create: (opts?: { + body?: { title?: string } + query?: { directory?: string } + }) => Promise + } + } + ).session.create({ query: { directory: context.cwd } }) + const id = extractSessionId(created) + if (!id) throw new Error('OpenCode session.create returned no id') + sessionIdRef = id + emit({ type: 'backend-state', patch: { sessionId: id } }) + } + const sessionId = sessionIdRef + if (!sessionId) throw new Error('OpenCode session unavailable') + + // Subscribe BEFORE issuing the prompt so we don't miss early events. + unsubscribe = options.host.subscribe(sessionId, (rawEvent) => { + handleEvent(rawEvent) + }) + + // Build the prompt body using per-session backend state overrides. + const state = context.backendState + const body: Record = { + parts: [{ type: 'text', text: context.prompt }], + } + if (state.providerID && state.modelID) { + body.model = { providerID: state.providerID, modelID: state.modelID } + } else if (state.model && state.model.includes('/')) { + const [providerID, modelID] = state.model.split('/', 2) + body.model = { providerID, modelID } + } + if (state.agent) body.agent = state.agent + if (state.systemPromptOverride) body.system = state.systemPromptOverride + if (state.toolToggles) body.tools = state.toolToggles + + await ( + client as unknown as { + session: { + promptAsync: (opts: { + path: { id: string } + query?: { directory?: string } + body: Record + }) => Promise + } + } + ).session.promptAsync({ + path: { id: sessionId }, + query: { directory: context.cwd }, + body, + }) + promptSubmitted = true + + // Wait for session.idle (or session.error which sets failedError). + await idlePromise + + // Emit any final assistant text accumulators that weren't already emitted. + for (const [messageId, text] of textByMessageId) { + if (!text) continue + if (emittedAssistantIds.has(messageId)) continue + if (seenPartFinalIds.has(messageId)) continue + emittedAssistantIds.add(messageId) + const message: Omit = { + id: messageId, + type: 'assistant', + content: text, + timestamp: Date.now(), + tokens: tokensByMessageId.get(messageId), + costUsd: costByMessageId.get(messageId), + } + emit({ type: 'message', message }) + } + + if (failedError) { + emit({ + type: 'message', + message: { + id: randomUUID(), + type: 'error', + content: failedError, + timestamp: Date.now(), + }, + }) + emit({ + type: 'result', + durationMs: Date.now() - startedAt, + totalCostUsd, + tokens: totalTokens, + isSuccess: false, + }) + return + } + + emit({ + type: 'message', + message: { + id: randomUUID(), + type: 'result', + content: `Completed in ${((Date.now() - startedAt) / 1000).toFixed(1)}s`, + timestamp: Date.now(), + totalCostUsd, + tokens: totalTokens, + isSuccess: true, + }, + }) + emit({ + type: 'result', + durationMs: Date.now() - startedAt, + totalCostUsd, + tokens: totalTokens, + isSuccess: true, + }) + })().finally(() => { + if (unsubscribe) { + try { + unsubscribe() + } catch { + /* ignore */ + } + unsubscribe = null + } + }) + + function handleEvent(rawEvent: unknown): void { + if (closed) return + const event = rawEvent as { type?: string; properties?: Record } + const type = typeof event.type === 'string' ? event.type : '' + const props = (event.properties ?? {}) as Record + + if (type === 'message.updated' && (props.info as Record | undefined)) { + const info = props.info as Record + const role = info.role + const messageId = (info.id as string | undefined) ?? randomUUID() + if (role === 'assistant') { + const providerID = info.providerID as string | undefined + const modelID = info.modelID as string | undefined + const model = [providerID, modelID].filter(Boolean).join('/') || undefined + if (model) { + emit({ type: 'session-meta', model }) + emit({ type: 'backend-state', patch: { sessionId: sessionIdRef ?? undefined, model } }) + } + const cost = typeof info.cost === 'number' ? info.cost : 0 + const prevCost = costByMessageId.get(messageId) ?? 0 + const delta = Math.max(0, cost - prevCost) + totalCostUsd += delta + costByMessageId.set(messageId, cost) + + const tokens = extractTokens(info.tokens) + if (tokens) { + tokensByMessageId.set(messageId, tokens) + totalTokens = recomputeTotals(tokensByMessageId) + } + + const errorText = renderOpenCodeError(info.error) + if (errorText) failedError = errorText + } + return + } + + if (type === 'message.part.updated' && props.part) { + const part = props.part as Record + const messageId = (part.messageID as string | undefined) ?? randomUUID() + const delta = typeof props.delta === 'string' ? (props.delta as string) : undefined + const converted = convertOpenCodePart(part, delta, partCtx) + + if (converted.isTextDelta && converted.delta) { + const prior = textByMessageId.get(converted.messageId ?? messageId) ?? '' + textByMessageId.set(converted.messageId ?? messageId, prior + converted.delta) + emit({ + type: 'stream-delta', + messageId: converted.messageId ?? messageId, + delta: converted.delta, + }) + } + for (const message of converted.messages) { + if (message.type === 'assistant') { + emittedAssistantIds.add(message.id) + seenPartFinalIds.add(message.id) + } + emit({ type: 'message', message }) + } + return + } + + if (type === 'permission.updated' && props) { + const perm = props as Record + const permissionId = perm.id as string | undefined + const sessionID = perm.sessionID as string | undefined + if (!permissionId || !sessionID) return + + const settings = options.getPermissionSettings() + const decision = decideOpenCodePermission( + { + category: (perm.type as string) ?? 'unknown', + pattern: perm.pattern as string | string[] | undefined, + metadata: perm.metadata as Record | undefined, + }, + settings.tier, + settings.autoApprove, + ) + + if (decision === 'always' || decision === 'reject') { + void options.host.respondPermission(sessionID, permissionId, decision).catch(() => { + // Surface as error if response fails. + }) + return + } + + // Forward to the manager via a synthetic permission-request event. + emit({ + type: 'permission-request', + request: { + permissionId, + sessionId: sessionID, + category: (perm.type as string) ?? 'unknown', + title: (perm.title as string) ?? 'Permission required', + pattern: perm.pattern as string | string[] | undefined, + metadata: perm.metadata as Record | undefined, + }, + resolve: (response) => { + void options.host.respondPermission(sessionID, permissionId, response).catch(() => { + /* ignore */ + }) + }, + }) + return + } + + if (type === 'session.error' && props) { + if (sessionIdRef && typeof props.sessionID === 'string' && props.sessionID !== sessionIdRef) { + return + } + failedError = renderOpenCodeError(props.error) ?? 'OpenCode session failed' + if (idleResolve) { + idleResolve() + idleResolve = null + } + return + } + + if (type === 'session.idle' && props.sessionID === sessionIdRef && promptSubmitted) { + if (idleResolve) { + idleResolve() + idleResolve = null + } + return + } + } + + return { + close() { + closed = true + if (idleResolve) { + idleResolve() + idleResolve = null + } + if (unsubscribe) { + try { + unsubscribe() + } catch { + /* ignore */ + } + unsubscribe = null + } + }, + completed, + } +} + +function recomputeTotals( + byId: Map>, +): ReturnType { + let acc: ReturnType = undefined + for (const value of byId.values()) { + acc = sumTokens(acc, value) + } + return acc +} + +function extractSessionId(value: unknown): string | null { + if (!value || typeof value !== 'object') return null + const v = value as Record + if (typeof v.id === 'string') return v.id + const data = v.data as Record | undefined + if (data && typeof data.id === 'string') return data.id + return null +} + +function renderOpenCodeError(value: unknown): string | null { + if (!value || typeof value !== 'object') return null + const error = value as Record + const data = (error.data ?? {}) as Record + const message = typeof data.message === 'string' ? (data.message as string) : null + return message ?? null +} diff --git a/packages/main/src/chat/cliAdapters/openCodePartConverter.ts b/packages/main/src/chat/cliAdapters/openCodePartConverter.ts new file mode 100644 index 0000000..a51808b --- /dev/null +++ b/packages/main/src/chat/cliAdapters/openCodePartConverter.ts @@ -0,0 +1,418 @@ +/** + * OpenCode Part → CliAgentMessage(s) converter. + * + * Maps every part `type` discriminant exposed by `@opencode-ai/sdk` onto our + * normalized `CliAgentMessage` shape. Returns one or more messages per part + * (e.g. a tool part may emit a single tool_use OR tool_result message + * depending on its current state). + * + * Unknown / unhandled types fall back to a generic `system` message so the + * renderer can still display them. + */ + +import { randomUUID } from 'crypto' +import type { CliAgentMessage, CliAgentTokenUsage } from '@aide/shared' + +interface BasePart { + id?: string + sessionID?: string + messageID?: string + type?: string + [key: string]: unknown +} + +interface ConvertContext { + /** Optional delta string from the part.updated event (text deltas only). */ + delta?: string + /** Track which tool partIds we've already emitted in which state. */ + seenToolStates: Map + /** Track which reasoning partIds we've already emitted (to avoid duplicate emit on each delta). */ + seenReasoningIds: Set +} + +export function createConvertContext(): ConvertContext { + return { + seenToolStates: new Map(), + seenReasoningIds: new Set(), + } +} + +export interface ConvertedPart { + /** Messages to emit. May be empty if the part is purely a delta. */ + messages: CliAgentMessage[] + /** True if this part is a streaming text delta (caller should also emit a stream-delta event). */ + isTextDelta: boolean + /** Text delta value, present iff isTextDelta. */ + delta?: string + /** The messageId for the text accumulator. */ + messageId?: string +} + +/** + * Convert a single OpenCode `part` event payload into normalized messages. + * + * The `delta` argument comes from `props.delta` on `message.part.updated`. + */ +export function convertOpenCodePart( + rawPart: unknown, + rawDelta: string | undefined, + ctx: ConvertContext, +): ConvertedPart { + const part = (rawPart ?? {}) as BasePart + const partType = typeof part.type === 'string' ? part.type : '' + const partId = (typeof part.id === 'string' ? part.id : null) ?? randomUUID() + const messageId = typeof part.messageID === 'string' ? part.messageID : undefined + const now = Date.now() + + switch (partType) { + case 'text': { + const text = stringField(part, 'text') ?? '' + const synthetic = part.synthetic === true + const ignored = part.ignored === true + if (ignored) return { messages: [], isTextDelta: false } + if (rawDelta) { + return { + messages: [], + isTextDelta: true, + delta: rawDelta, + messageId: messageId ?? partId, + } + } + // Final / complete text part — emit as assistant message. + return { + messages: [ + { + id: messageId ?? partId, + type: synthetic ? 'system' : 'assistant', + content: text, + timestamp: now, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'reasoning': { + // Stream reasoning text incrementally; first occurrence creates the + // message, subsequent updates patch it. We emit one message at the end + // (when text is non-empty) for simplicity. + const text = stringField(part, 'text') ?? '' + if (!text || ctx.seenReasoningIds.has(partId)) { + return { messages: [], isTextDelta: false } + } + ctx.seenReasoningIds.add(partId) + return { + messages: [ + { + id: partId, + type: 'reasoning', + content: text, + timestamp: now, + reasoningCollapsed: true, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'file': { + const mime = stringField(part, 'mime') ?? 'application/octet-stream' + const url = stringField(part, 'url') ?? '' + const filename = stringField(part, 'filename') + return { + messages: [ + { + id: partId, + type: 'file_attachment', + content: filename ?? url, + timestamp: now, + fileMime: mime, + fileUrl: url, + fileName: filename, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'tool': { + const state = (part.state ?? {}) as Record + const status = stringField(state, 'status') ?? 'pending' + const priorStatus = ctx.seenToolStates.get(partId) + if (priorStatus === status) { + return { messages: [], isTextDelta: false } + } + ctx.seenToolStates.set(partId, status) + + const toolName = stringField(part, 'tool') ?? 'tool' + const toolUseId = stringField(part, 'callID') + + if (status === 'pending' || status === 'running') { + return { + messages: [ + { + id: partId, + type: 'tool_use', + content: `Running ${toolName}...`, + timestamp: now, + toolName, + toolUseId, + raw: part, + }, + ], + isTextDelta: false, + } + } + if (status === 'completed') { + const output = + stringField(state, 'output') ?? stringField(state, 'title') ?? `${toolName} completed` + return { + messages: [ + { + id: partId, + type: 'tool_result', + content: output, + timestamp: now, + toolName, + toolUseId, + raw: part, + }, + ], + isTextDelta: false, + } + } + if (status === 'error') { + return { + messages: [ + { + id: partId, + type: 'error', + content: stringField(state, 'error') ?? `${toolName} failed`, + timestamp: now, + toolName, + toolUseId, + raw: part, + }, + ], + isTextDelta: false, + } + } + return { messages: [], isTextDelta: false } + } + + case 'step-start': { + return { + messages: [ + { + id: partId, + type: 'step', + content: 'Step started', + timestamp: now, + stepPhase: 'start', + stepSnapshot: stringField(part, 'snapshot'), + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'step-finish': { + const cost = numberField(part, 'cost') ?? 0 + const tokens = extractTokens(part.tokens) + return { + messages: [ + { + id: partId, + type: 'step', + content: stringField(part, 'reason') ?? 'Step completed', + timestamp: now, + stepPhase: 'finish', + stepReason: stringField(part, 'reason'), + stepSnapshot: stringField(part, 'snapshot'), + costUsd: cost, + tokens, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'snapshot': { + return { + messages: [ + { + id: partId, + type: 'snapshot', + content: 'Snapshot captured', + timestamp: now, + snapshotHash: stringField(part, 'snapshot'), + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'patch': { + const files = Array.isArray(part.files) + ? (part.files as unknown[]).filter((f): f is string => typeof f === 'string') + : [] + return { + messages: [ + { + id: partId, + type: 'patch', + content: files.length === 1 ? files[0] : `${files.length} files`, + timestamp: now, + patchHash: stringField(part, 'hash'), + patchFiles: files, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'agent': { + return { + messages: [ + { + id: partId, + type: 'agent_change', + content: stringField(part, 'name') ?? 'agent', + timestamp: now, + agentName: stringField(part, 'name'), + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'retry': { + const error = (part.error ?? {}) as Record + const errMsg = + stringField((error.data as Record | undefined) ?? {}, 'message') ?? + stringField(error, 'message') ?? + 'retrying' + return { + messages: [ + { + id: partId, + type: 'retry', + content: `Attempt ${numberField(part, 'attempt') ?? 1}: ${errMsg}`, + timestamp: now, + retryAttempt: numberField(part, 'attempt'), + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'compaction': { + return { + messages: [ + { + id: partId, + type: 'compaction', + content: 'Context compacted', + timestamp: now, + compactionAuto: part.auto === true, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'subtask': { + return { + messages: [ + { + id: partId, + type: 'subtask', + content: stringField(part, 'description') ?? stringField(part, 'prompt') ?? 'Subtask', + timestamp: now, + subtaskPrompt: stringField(part, 'prompt'), + subtaskDescription: stringField(part, 'description'), + subtaskAgent: stringField(part, 'agent'), + raw: part, + }, + ], + isTextDelta: false, + } + } + + default: { + // Unknown part type — emit as a generic system message so it isn't lost. + return { + messages: [ + { + id: partId, + type: 'system', + content: `Unknown part: ${partType}`, + timestamp: now, + raw: part, + }, + ], + isTextDelta: false, + } + } + } +} + +function stringField(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object') return undefined + const v = (value as Record)[key] + return typeof v === 'string' ? v : undefined +} + +function numberField(value: unknown, key: string): number | undefined { + if (!value || typeof value !== 'object') return undefined + const v = (value as Record)[key] + return typeof v === 'number' ? v : undefined +} + +/** Extract token counts from an OpenCode AssistantMessage / StepFinishPart-style tokens object. */ +export function extractTokens(value: unknown): CliAgentTokenUsage | undefined { + if (!value || typeof value !== 'object') return undefined + const t = value as Record + const cache = (t.cache ?? {}) as Record + const input = numberField(t, 'input') ?? 0 + const output = numberField(t, 'output') ?? 0 + const reasoning = numberField(t, 'reasoning') ?? 0 + const cacheRead = numberField(cache, 'read') ?? 0 + const cacheWrite = numberField(cache, 'write') ?? 0 + if ( + input === 0 && + output === 0 && + reasoning === 0 && + cacheRead === 0 && + cacheWrite === 0 + ) { + return undefined + } + return { input, output, reasoning, cacheRead, cacheWrite } +} + +/** Sum two token usage records. */ +export function sumTokens( + a: CliAgentTokenUsage | undefined, + b: CliAgentTokenUsage | undefined, +): CliAgentTokenUsage | undefined { + if (!a) return b + if (!b) return a + return { + input: a.input + b.input, + output: a.output + b.output, + reasoning: a.reasoning + b.reasoning, + cacheRead: a.cacheRead + b.cacheRead, + cacheWrite: a.cacheWrite + b.cacheWrite, + } +} diff --git a/packages/main/src/chat/cliAdapters/openCodePermissionBridge.ts b/packages/main/src/chat/cliAdapters/openCodePermissionBridge.ts new file mode 100644 index 0000000..3f8a8eb --- /dev/null +++ b/packages/main/src/chat/cliAdapters/openCodePermissionBridge.ts @@ -0,0 +1,158 @@ +/** + * IDE ↔ OpenCode permission bridge. + * + * Two responsibilities: + * + * 1. **Pre-flight** (`buildOpenCodePermissionConfig`): convert the IDE's + * `agent.permissionTier` + `agent.autoApprove` settings into an OpenCode + * agent `permission` config block. Pushed to the SDK once at session + * start so OpenCode itself short-circuits cheap cases. + * + * 2. **Runtime** (`decideOpenCodePermission`): when OpenCode raises a + * `permission.updated` event mid-turn, decide whether to auto-allow, + * auto-deny, or prompt the user. Uses the same `evaluatePermission` + * logic as the built-in `AgentManager`, with a category mapping from + * OpenCode's permission categories ('edit' / 'bash' / 'webfetch' / …) + * to the IDE's tool names ('file_write' / 'terminal_exec' / …). + */ + +import type { PermissionTier, ToolPermissionConfig } from '@aide/shared' +import { evaluatePermission } from '../permissionMatching' + +/** + * OpenCode agent permission categories accepted by the SDK + * (see `@opencode-ai/sdk` AgentConfig.permission). + */ +export type OpenCodePermissionCategory = + | 'edit' + | 'bash' + | 'webfetch' + | 'doom_loop' + | 'external_directory' + +export type OpenCodePermissionDecision = 'allow' | 'ask' | 'deny' +export type OpenCodePermissionResponse = 'always' | 'once' | 'reject' + +/** + * Map an OpenCode permission category onto the IDE tool name used by + * `agent.autoApprove`. The mapping is intentionally narrow — IDE tool names + * with no OpenCode counterpart simply have no auto-approve effect on + * OpenCode-side prompts. + */ +const CATEGORY_TO_IDE_TOOL: Record = { + edit: 'file_write', + bash: 'terminal_exec', + webfetch: 'browser_read', + doom_loop: 'doom_loop', + external_directory: 'external_directory', +} + +/** Inverse for diagnostic / fall-through use. */ +export function ideToolNameForCategory(category: string): string { + return (CATEGORY_TO_IDE_TOOL as Record)[category] ?? category +} + +/** + * Build an OpenCode `agent.permission` config block from the IDE settings. + * + * Tier rules: + * - `autopilot` → all 'allow' + * - `auto-approve` → 'webfetch' (read-only) → 'allow'; 'edit' / 'bash' / others → 'ask' + * - `confirm` → all 'ask' + * + * `autoApprove` overrides: + * - `true` → 'allow' for that category + * - `false` → 'deny' for that category + * - patterns (object) → 'ask' (we still need the runtime decision) + */ +export function buildOpenCodePermissionConfig( + tier: PermissionTier, + autoApprove: Record, +): Record { + const out = {} as Record + const categories: OpenCodePermissionCategory[] = [ + 'edit', + 'bash', + 'webfetch', + 'doom_loop', + 'external_directory', + ] + + for (const category of categories) { + const ideTool = CATEGORY_TO_IDE_TOOL[category] + const override = autoApprove[ideTool] + + if (override === true) { + out[category] = 'allow' + continue + } + if (override === false) { + out[category] = 'deny' + continue + } + if (typeof override === 'object' && override !== null) { + // Pattern config — still need runtime evaluation for each call. + out[category] = 'ask' + continue + } + + switch (tier) { + case 'autopilot': + out[category] = 'allow' + break + case 'auto-approve': + // Only webfetch is "read-only" in IDE terms + out[category] = category === 'webfetch' ? 'allow' : 'ask' + break + case 'confirm': + default: + out[category] = 'ask' + break + } + } + return out +} + +export interface DecideInput { + category: string + pattern?: string | string[] + metadata?: Record +} + +/** + * Decide what to do with a runtime OpenCode permission request. Returns + * `'prompt'` when the user must be asked; otherwise returns the OpenCode + * response value to POST back. + */ +export function decideOpenCodePermission( + input: DecideInput, + tier: PermissionTier, + autoApprove: Record, +): OpenCodePermissionResponse | 'prompt' { + const ideTool = ideToolNameForCategory(input.category) + const matchInput = buildMatchInput(input) + + const decision = evaluatePermission(ideTool, matchInput, tier, autoApprove) + if (decision === 'allow') return 'always' + if (decision === 'deny') return 'reject' + return 'prompt' +} + +function buildMatchInput(input: DecideInput): Record { + // For bash-like categories, the pattern is the shell command(s) — feed it as + // `command` so the existing matchTargetFor() shortcut for terminal_exec + // matches against the command string. + if (input.category === 'bash') { + if (Array.isArray(input.pattern)) { + return { command: input.pattern.join(' && ') } + } + return { command: input.pattern ?? '' } + } + + // For edit / webfetch / external_directory, treat the pattern as a path/URL + // and stringify the whole input so glob patterns can match against it. + return { + pattern: input.pattern, + ...input.metadata, + } +} diff --git a/packages/main/src/chat/cliAdapters/types.ts b/packages/main/src/chat/cliAdapters/types.ts new file mode 100644 index 0000000..082f466 --- /dev/null +++ b/packages/main/src/chat/cliAdapters/types.ts @@ -0,0 +1,61 @@ +import type { + CliAgentBackendState, + CliAgentMessage, + CliAgentTokenUsage, + ExternalCliBackend, +} from '@aide/shared' + +export interface CliBackendTurnContext { + conversationId: string + cwd: string + prompt: string + backendState: CliAgentBackendState +} + +/** + * Permission request raised by an adapter (currently OpenCode) when the + * backend needs user approval for a tool/operation. The manager bridges these + * to the existing CHAT_TOOL_CALL surface via ApprovalRouter. + */ +export interface CliBackendPermissionRequest { + /** Stable id from the backend (e.g. OpenCode permission.id). */ + permissionId: string + /** Backend session id. */ + sessionId: string + /** Permission category (e.g. 'edit' | 'bash' | 'webfetch'). */ + category: string + /** Human-readable title. */ + title: string + /** Optional pattern (file path / shell command / URL). */ + pattern?: string | string[] + metadata?: Record +} + +export type CliBackendEvent = + | { type: 'stream-delta'; messageId: string; delta: string } + | { type: 'message'; message: Omit } + | { type: 'backend-state'; patch: Partial } + | { type: 'session-meta'; model?: string; tools?: string[] } + | { + type: 'result' + durationMs: number + totalCostUsd: number + tokens?: CliAgentTokenUsage + isSuccess: boolean + } + | { + type: 'permission-request' + request: CliBackendPermissionRequest + /** Resolved by the manager once the user (or auto-approval) decides. */ + resolve: (response: 'once' | 'always' | 'reject') => void + } + +export interface CliBackendRun { + close(): void + completed: Promise +} + +export interface CliBackendAdapter { + backend: ExternalCliBackend + startTurn(context: CliBackendTurnContext, emit: (event: CliBackendEvent) => void): CliBackendRun +} diff --git a/packages/main/src/chat/cliAgentManager.ts b/packages/main/src/chat/cliAgentManager.ts index cfa308a..0cbcaa9 100644 --- a/packages/main/src/chat/cliAgentManager.ts +++ b/packages/main/src/chat/cliAgentManager.ts @@ -1,152 +1,257 @@ /** * CLI Agent Manager — manages external CLI agent sessions. * - * Uses the Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) for the - * `claude-code` backend. The SDK spawns a Claude Code subprocess and - * provides a typed async generator of messages. Codex remains a stub. + * Owns the generic session lifecycle for all external backends (claude-code, + * opencode, codex), delegates per-turn transport details to backend adapters, + * and bridges OpenCode's full SDK surface (sessions / config / providers / + * file / find / shell / lsp / etc.) into IPC-callable manager methods. * - * Architecture: Each `send()` calls `query()` which returns an - * `AsyncGenerator`. The generator is consumed in a background - * loop that normalizes SDK messages into CliAgentMessage and emits them - * via IPC. Session continuity uses the SDK's `resume` option. - * - * The SDK needs `pathToClaudeCodeExecutable` to find the Claude Code CLI. - * Resolution order: explicit setting → bundled in app → workspace - * node_modules → global `claude` in PATH. + * Multi-workspace lifecycle: one CliAgentManager per WorkspaceRuntime, each + * owning its own per-workspace OpenCodeServerHost. The manager implements + * `ToolApprovalOwner` so its OpenCode permission prompts can flow through the + * single ApprovalRouter / CHAT_TOOL_CALL approval surface shared with the + * built-in agent. */ import { randomUUID } from 'crypto' import { execFileSync } from 'child_process' import { existsSync } from 'fs' -import { join, dirname } from 'path' +import { join } from 'path' import { app, type WebContents } from 'electron' -import { query } from '@anthropic-ai/claude-agent-sdk' -import type { Query } from '@anthropic-ai/claude-agent-sdk' import { IpcChannels, deriveTitle } from '@aide/shared' import type { - AgentBackend, CliAgentProcessStatus, CliAgentMessage, - CliAgentSession, CliAgentStreamDelta, - CliAgentStatusPayload, CliAgentResultPayload, CliAgentMessagePayload, + AgentBackend, + ChatComposerSubmission, + CliAgentBackendState, + CliAgentBackendStateMap, + CliAgentMessage, + CliAgentMessagePayload, + CliAgentPermissionRequest, + CliAgentProcessStatus, + CliAgentResultPayload, + CliAgentSession, + CliAgentStatusPayload, + CliAgentStreamDelta, + CliAgentTokenUsage, + CliAgentWorkspaceCostSummary, ConversationListChangedPayload, + ExternalCliBackend, + OpenCodeAgentSummary, + OpenCodeAuthMethod, + OpenCodeFileEntry, + OpenCodeFindResult, + OpenCodePathInfo, + OpenCodeProviderSummary, + OpenCodeServerInfo, + OpenCodeShellResult, + OpenCodeSymbolResult, + OpenCodeToolSummary, + OpenCodeTodoItem, + PermissionTier, + ToolPermissionConfig, } from '@aide/shared' import type { ConversationStore } from './conversationStore' - -// --------------------------------------------------------------------------- -// Internal session state -// --------------------------------------------------------------------------- +import { createClaudeCodeAdapter } from './cliAdapters/claudeCodeAdapter' +import { createCodexAdapter } from './cliAdapters/codexAdapter' +import { createOpenCodeAdapter } from './cliAdapters/openCodeAdapter' +import type { CliBackendAdapter, CliBackendEvent, CliBackendRun } from './cliAdapters/types' +import { OpenCodeServerHost } from './openCodeServerHost' +import type { ToolApprovalOwner } from './approvalRouter' +import type { ChatToolCallPayload, ToolCall } from '@aide/shared' +import { buildComposerContext } from './chatComposerContext' + +interface PersistedCliConversation { + messages?: CliAgentMessage[] + activeBackend?: ExternalCliBackend + backendStates?: CliAgentBackendStateMap + claudeSessionId?: string + totalCostUsd?: number + totalTokens?: CliAgentTokenUsage +} interface CliAgentSessionInternal { id: string workspaceId: string - backend: AgentBackend - queryInstance: Query | null - abortController: AbortController | null + backend: ExternalCliBackend + activeRun: CliBackendRun | null processStatus: CliAgentProcessStatus messages: CliAgentMessage[] model?: string sessionToolNames?: string[] lastError?: string totalCostUsd: number - /** Claude Code session ID for resume across sends */ - claudeSessionId?: string - /** Worktree path this session operates in (if any). */ + totalTokens?: CliAgentTokenUsage worktreePath?: string + backendStates: CliAgentBackendStateMap } -// --------------------------------------------------------------------------- -// Options -// --------------------------------------------------------------------------- +interface PendingPermission { + sessionId: string + resolve: (response: 'always' | 'once' | 'reject') => void +} + +/** + * User-scoped defaults applied as the *initial* `backendStates['opencode']` + * for new sessions. Resolved once at construction (and refreshed via + * `updateOpencodeDefaults` when settings change). Per-session edits made + * from the chat pane never write back to these. + */ +export interface OpencodeSessionDefaults { + providerID?: string + modelID?: string + agent?: string + mode?: string + systemPromptOverride?: string + toolToggles?: Record +} export interface CliAgentManagerOpts { workspaceRoot: string + workspaceId?: string getWebContents: () => WebContents | null claudeCodePath?: string + opencodePath?: string codexPath?: string conversationStore?: ConversationStore loadClaudeHistory?: (claudeSessionId: string) => Promise + permissionTier?: PermissionTier + autoApprove?: Record + opencodeDefaults?: OpencodeSessionDefaults + /** Called when pending approvals / running session counts change. */ + onWorkloadChanged?: () => void } function comparableHistoryCount(messages: CliAgentMessage[]): number { - return messages.filter((message) => - message.type === 'user' || - message.type === 'assistant' || - message.type === 'tool_use' || - message.type === 'tool_result' + return messages.filter( + (message) => + message.type === 'user' || + message.type === 'assistant' || + message.type === 'tool_use' || + message.type === 'tool_result', ).length } -// --------------------------------------------------------------------------- -// Manager -// --------------------------------------------------------------------------- +function isExternalBackend(backend: AgentBackend): backend is ExternalCliBackend { + return backend === 'claude-code' || backend === 'opencode' || backend === 'codex' +} -export class CliAgentManager { +function parsePersistedConversation(raw: unknown): PersistedCliConversation { + if (!raw || typeof raw !== 'object') return {} + const persisted = raw as PersistedCliConversation + const backendStates: CliAgentBackendStateMap = { ...(persisted.backendStates ?? {}) } + if (!backendStates['claude-code']?.sessionId && typeof persisted.claudeSessionId === 'string') { + backendStates['claude-code'] = { + ...(backendStates['claude-code'] ?? {}), + sessionId: persisted.claudeSessionId, + } + } + return { + messages: Array.isArray(persisted.messages) ? persisted.messages : [], + activeBackend: persisted.activeBackend, + backendStates, + claudeSessionId: persisted.claudeSessionId, + totalCostUsd: persisted.totalCostUsd, + totalTokens: persisted.totalTokens, + } +} + +function sumTokenUsage( + a: CliAgentTokenUsage | undefined, + b: CliAgentTokenUsage | undefined, +): CliAgentTokenUsage | undefined { + if (!a) return b + if (!b) return a + return { + input: a.input + b.input, + output: a.output + b.output, + reasoning: a.reasoning + b.reasoning, + cacheRead: a.cacheRead + b.cacheRead, + cacheWrite: a.cacheWrite + b.cacheWrite, + } +} + +export class CliAgentManager implements ToolApprovalOwner { private sessions = new Map() - private workspaceRoot: string - private getWebContents: () => WebContents | null + private readonly workspaceRoot: string + private readonly workspaceId: string + private readonly getWebContents: () => WebContents | null private claudeCodePath: string + private opencodePath: string private codexPath: string - private conversationStore: ConversationStore | null - private loadClaudeHistory: ((claudeSessionId: string) => Promise) | null - /** Resolved path to the Claude Code CLI, cached after first lookup */ + private readonly conversationStore: ConversationStore | null + private readonly loadClaudeHistory: + | ((claudeSessionId: string) => Promise) + | null private resolvedClaudeCodePath: string | null = null + private resolvedCodexPath: string | null = null + + private permissionTier: PermissionTier + private autoApprove: Record + private opencodeDefaults: OpencodeSessionDefaults + private readonly onWorkloadChanged?: () => void + + private openCodeHost: OpenCodeServerHost | null = null + private pendingPermissions = new Map() constructor(opts: CliAgentManagerOpts) { this.workspaceRoot = opts.workspaceRoot + this.workspaceId = opts.workspaceId ?? '' this.getWebContents = opts.getWebContents this.claudeCodePath = opts.claudeCodePath ?? '' + this.opencodePath = opts.opencodePath ?? '' this.codexPath = opts.codexPath ?? '' this.conversationStore = opts.conversationStore ?? null this.loadClaudeHistory = opts.loadClaudeHistory ?? null + this.permissionTier = opts.permissionTier ?? 'confirm' + this.autoApprove = opts.autoApprove ?? {} + this.opencodeDefaults = opts.opencodeDefaults ?? {} + this.onWorkloadChanged = opts.onWorkloadChanged } - // ─── Public API ────────────────────────────── + // ─── Lifecycle ────────────────────────────────────────────── - /** - * Initialize a CLI agent session. Does not start a query yet — that - * happens on the first `send()`. - * - * If `conversationId` is provided, resumes an existing conversation - * (loads claudeSessionId from the store for SDK resume). - */ async start( workspaceId: string, backend: AgentBackend, conversationId?: string, worktreePath?: string, ): Promise<{ sessionId: string } | { error: string }> { - if (backend === 'codex') { - return { error: 'Codex integration coming soon.' } - } - if (backend === 'built-in') { return { error: 'Use the built-in agent chat panel instead.' } } - // If resuming an existing conversation, load from store - let existingMessages: CliAgentMessage[] = [] - let existingClaudeSessionId: string | undefined + if (conversationId?.startsWith('claude-native:') && backend !== 'claude-code') { + return { error: 'Native Claude conversations cannot switch to a different backend.' } + } - if (conversationId && this.conversationStore) { - const meta = await this.conversationStore.get(conversationId) - if (meta?.claudeSessionId) { - existingClaudeSessionId = meta.claudeSessionId - } - const saved = await this.conversationStore.loadMessages(conversationId) as { messages?: CliAgentMessage[], claudeSessionId?: string } | null - if (saved?.messages) { - existingMessages = saved.messages - } - if (saved?.claudeSessionId) { - existingClaudeSessionId = saved.claudeSessionId - } + const sessionId = conversationId ?? randomUUID() + const persisted = + conversationId && this.conversationStore + ? parsePersistedConversation(await this.conversationStore.loadMessages(conversationId)) + : parsePersistedConversation(null) + + let existingMessages = persisted.messages ?? [] + const backendStates = persisted.backendStates ?? {} + + // Seed opencode state from user defaults *only* on the first time we + // touch this conversation as opencode (no persisted opencode block). + // We never overwrite existing per-session overrides. + if (backend === 'opencode' && !backendStates['opencode']) { + const seed = this.buildOpencodeSeed() + if (seed) backendStates['opencode'] = seed } - if (!existingClaudeSessionId && conversationId?.startsWith('claude-native:')) { + if (!backendStates['claude-code']?.sessionId && conversationId?.startsWith('claude-native:')) { const raw = conversationId.slice('claude-native:'.length) if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { - existingClaudeSessionId = raw + backendStates['claude-code'] = { + ...(backendStates['claude-code'] ?? {}), + sessionId: raw, + } } } + const existingClaudeSessionId = backendStates['claude-code']?.sessionId if (existingClaudeSessionId && this.loadClaudeHistory) { try { const nativeMessages = await this.loadClaudeHistory(existingClaudeSessionId) @@ -157,12 +262,10 @@ export class CliAgentManager { existingMessages = nativeMessages } } catch { - // Fall back to persisted shadow copy when native Claude history is unavailable. + // Fall back to the shadow copy when native Claude history is unavailable. } } - const sessionId = conversationId ?? randomUUID() - if (this.conversationStore && !sessionId.startsWith('claude-native:')) { await this.conversationStore.ensure(sessionId, { workspaceId, @@ -171,8 +274,18 @@ export class CliAgentManager { }) } - // If session already in memory, return it - if (this.sessions.has(sessionId)) { + const existing = this.sessions.get(sessionId) + if (existing) { + if (existing.activeRun) { + return { error: 'Agent is already processing a request. Stop it first or wait.' } + } + // Do NOT overwrite existing.backend — switchBackend() owns that transition. + // Re-running start() must not silently revert a user-selected backend or + // throw away its per-session state (model / providerID / sessionId / etc.). + existing.worktreePath = worktreePath ?? existing.worktreePath + await this.persistSession(existing) + await this.broadcastConversationList(existing.workspaceId) + this.emitStatus(existing) return { sessionId } } @@ -180,90 +293,185 @@ export class CliAgentManager { id: sessionId, workspaceId, backend, - queryInstance: null, - abortController: null, + activeRun: null, processStatus: 'stopped', messages: existingMessages, - totalCostUsd: 0, - claudeSessionId: existingClaudeSessionId, + totalCostUsd: persisted.totalCostUsd ?? 0, + totalTokens: persisted.totalTokens, + model: backendStates[backend]?.model, worktreePath, + backendStates, } this.sessions.set(sessionId, session) this.emitStatus(session) - return { sessionId } } + async switchBackend( + sessionId: string, + backend: AgentBackend, + ): Promise<{ success: true } | { error: string }> { + if (!isExternalBackend(backend)) { + return { error: 'Only external CLI backends can be selected here.' } + } + + const session = this.sessions.get(sessionId) + if (!session) return { error: 'Session not found' } + if (session.activeRun) { + return { error: 'Stop the active run before switching backends.' } + } + if (session.id.startsWith('claude-native:') && backend !== 'claude-code') { + return { error: 'Native Claude conversations cannot switch to a different backend.' } + } + + // First time this conversation switches to opencode → seed defaults. + if (backend === 'opencode' && !session.backendStates['opencode']) { + const seed = this.buildOpencodeSeed() + if (seed) session.backendStates['opencode'] = seed + } + + session.backend = backend + session.model = session.backendStates[backend]?.model + session.sessionToolNames = undefined + session.lastError = undefined + await this.persistSession(session) + await this.broadcastConversationList(session.workspaceId) + this.emitStatus(session) + return { success: true } + } + /** - * Send a prompt to the CLI agent. Starts an SDK query that streams - * messages back via IPC. Uses `resume` for conversation continuity. + * Build a fresh `CliAgentBackendState` for an opencode session from the + * user's defaults. Returns `null` when no defaults are set so we don't + * pollute the persisted state with empty objects. */ + private buildOpencodeSeed(): CliAgentBackendState | null { + const d = this.opencodeDefaults + const hasAny = + !!d.providerID || + !!d.modelID || + !!d.agent || + !!d.mode || + !!d.systemPromptOverride || + (d.toolToggles && Object.keys(d.toolToggles).length > 0) + if (!hasAny) return null + const seed: CliAgentBackendState = {} + if (d.providerID && d.modelID) { + seed.providerID = d.providerID + seed.modelID = d.modelID + seed.model = `${d.providerID}/${d.modelID}` + } + if (d.agent) seed.agent = d.agent + if (d.mode) seed.mode = d.mode + if (d.systemPromptOverride) seed.systemPromptOverride = d.systemPromptOverride + if (d.toolToggles && Object.keys(d.toolToggles).length > 0) { + seed.toolToggles = { ...d.toolToggles } + } + return seed + } + async send( sessionId: string, - content: string, + submission: ChatComposerSubmission, ): Promise<{ success: true } | { error: string }> { const session = this.sessions.get(sessionId) if (!session) return { error: 'Session not found' } - - // If a query is already running, reject (one at a time) - if (session.queryInstance) { + if (session.activeRun) { return { error: 'Agent is already processing a request. Stop it first or wait.' } } + if (!submission.text.trim() && submission.mentionedFiles.length === 0) { + return { error: 'Message is empty' } + } + + const composerContext = await buildComposerContext( + submission, + session.worktreePath ?? this.workspaceRoot, + ) - // Add user message to history const userMsg: CliAgentMessage = { id: randomUUID(), type: 'user', - content, + content: submission.rawText?.trim() || submission.text.trim(), + contextualContent: composerContext.contextualContent, + mentionedFiles: composerContext.mentionedFiles, + commandId: composerContext.commandId, timestamp: Date.now(), } session.messages.push(userMsg) this.emitMessage(session, userMsg) + await this.maybeAutoTitle(session, submission.text) - // Auto-title on first user message - await this.maybeAutoTitle(session, content) - - // Reset error state session.lastError = undefined + const prompt = this.buildTurnPrompt(session, userMsg) - // Fire and forget the consumption loop - this.consumeQuery(session, content).catch(err => { - const errMsg = err instanceof Error ? err.message : String(err) - console.error(`[CliAgentManager] Unhandled consumeQuery error for session ${session.id}:`, errMsg) - if (err instanceof Error && err.stack) console.error(`[CliAgentManager] Stack:`, err.stack) - session.lastError = errMsg - this.setStatus(session, 'error') - }) + let adapter: CliBackendAdapter + try { + adapter = this.createAdapter(session.backend) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + this.handleRunError(session, message) + return { error: message } + } + + const run = adapter.startTurn( + { + conversationId: session.id, + cwd: session.worktreePath ?? this.workspaceRoot, + prompt, + backendState: { ...(session.backendStates[session.backend] ?? {}) }, + }, + (event) => this.applyBackendEvent(session, event), + ) + + session.activeRun = run + this.setStatus(session, 'running') + this.notifyWorkloadChanged() + + run.completed + .catch((error) => { + if (session.processStatus === 'stopping') return + const message = error instanceof Error ? error.message : String(error) + this.handleRunError(session, message) + }) + .finally(async () => { + if (session.activeRun === run) { + session.activeRun = null + } + if (session.processStatus === 'stopping' || session.processStatus === 'running') { + this.setStatus(session, 'stopped') + } + // Clear any unresolved permissions for this session. + for (const [id, pending] of this.pendingPermissions) { + if (pending.sessionId === session.id) { + pending.resolve('reject') + this.pendingPermissions.delete(id) + } + } + await this.persistSession(session) + this.notifyWorkloadChanged() + }) return { success: true } } stop(sessionId: string): void { const session = this.sessions.get(sessionId) - if (!session) return - - if (session.queryInstance || session.abortController) { - this.setStatus(session, 'stopping') - session.abortController?.abort() - session.queryInstance?.close() - } + if (!session || !session.activeRun) return + this.setStatus(session, 'stopping') + session.activeRun.close() } getSession(workspaceId: string): CliAgentSession | null { - // Find the most recent session for this workspace for (const session of this.sessions.values()) { - if (session.workspaceId === workspaceId) { - return this.toPublicSession(session) - } + if (session.workspaceId === workspaceId) return this.toPublicSession(session) } return null } - /** Check if any active (running/starting) session uses the given worktree path. */ hasActiveSessionsForWorktree(worktreePath: string): boolean { for (const session of this.sessions.values()) { - if (session.worktreePath === worktreePath && session.queryInstance) { + if (session.worktreePath === worktreePath && session.activeRun) { return true } } @@ -272,455 +480,1106 @@ export class CliAgentManager { getSessionById(sessionId: string): CliAgentSession | null { const session = this.sessions.get(sessionId) - if (!session) return null - return this.toPublicSession(session) + return session ? this.toPublicSession(session) : null } ownsSession(sessionId: string): boolean { return this.sessions.has(sessionId) } + updatePaths(claudeCodePath: string, opencodePath: string, codexPath: string): void { + this.claudeCodePath = claudeCodePath + this.opencodePath = opencodePath + this.codexPath = codexPath + this.resolvedClaudeCodePath = null + this.resolvedCodexPath = null + if (this.openCodeHost) { + this.openCodeHost.setPath(opencodePath) + } + } + + /** Update permission tier / autoApprove map (called from settings-changed handler). */ + updatePermissions( + tier: PermissionTier, + autoApprove: Record, + ): void { + this.permissionTier = tier + this.autoApprove = autoApprove + } + + /** + * Replace the opencode session-default seed (called from the settings- + * changed handler when any `agent.opencode.default*` key is touched). + * Existing sessions keep their per-session overrides; the new defaults + * only apply to sessions created/switched-into-opencode after this call. + */ + updateOpencodeDefaults(defaults: OpencodeSessionDefaults): void { + this.opencodeDefaults = defaults + } + + getRunningSessionCount(): number { + let count = 0 + for (const session of this.sessions.values()) { + if (session.activeRun) count += 1 + } + return count + } + + /** Workspace-wide cost / token rollup across this manager's sessions. */ + getWorkspaceCostSummary(): CliAgentWorkspaceCostSummary { + let totalCostUsd = 0 + let totalTokens: CliAgentTokenUsage | undefined = undefined + let sessionCount = 0 + for (const session of this.sessions.values()) { + sessionCount += 1 + totalCostUsd += session.totalCostUsd + totalTokens = sumTokenUsage(totalTokens, session.totalTokens) + } + return { + workspaceId: this.workspaceId, + totalCostUsd, + totalTokens: totalTokens ?? { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + sessionCount, + } + } + + async destroy(): Promise { + for (const session of this.sessions.values()) { + session.activeRun?.close() + await this.persistSession(session).catch(() => {}) + } + this.sessions.clear() + if (this.openCodeHost) { + try { + await this.openCodeHost.dispose() + } catch { + /* ignore */ + } + this.openCodeHost = null + } + // Resolve any outstanding permissions as rejected. + for (const pending of this.pendingPermissions.values()) { + pending.resolve('reject') + } + this.pendingPermissions.clear() + } + + // ─── ToolApprovalOwner (for ApprovalRouter) ──────────────────── + + ownsToolCall(toolCallId: string): boolean { + return this.pendingPermissions.has(toolCallId) + } + + approveToolCall(_sessionId: string, toolCallId: string): void { + const pending = this.pendingPermissions.get(toolCallId) + if (!pending) return + this.pendingPermissions.delete(toolCallId) + pending.resolve('always') + this.notifyWorkloadChanged() + } + + rejectToolCall(_sessionId: string, toolCallId: string): void { + const pending = this.pendingPermissions.get(toolCallId) + if (!pending) return + this.pendingPermissions.delete(toolCallId) + pending.resolve('reject') + this.notifyWorkloadChanged() + } + + getPendingApprovalCount(): number { + return this.pendingPermissions.size + } + + // ─── Per-session config (Phase 2 wiring) ─────────────────────── + + async updateSessionConfig( + sessionId: string, + patch: Partial, + ): Promise<{ success: true } | { error: string }> { + const session = this.sessions.get(sessionId) + if (!session) return { error: 'Session not found' } + const prior = session.backendStates[session.backend] ?? {} + session.backendStates[session.backend] = { ...prior, ...patch } + if (patch.model) session.model = patch.model + await this.persistSession(session) + this.emitStatus(session) + return { success: true } + } + + // ─── OpenCode SDK passthroughs (Phase 2 / 6 / 7 / 8) ─────────── + + private async getOpenCodeClient(sessionId: string) { + const session = this.sessions.get(sessionId) + if (!session) throw new Error('Session not found') + if (session.backend !== 'opencode') { + throw new Error('This operation is only available for OpenCode sessions.') + } + const host = this.ensureOpenCodeHost() + return host.getClient() + } + + async listOpenCodeProviders(sessionId: string): Promise { + const client = await this.getOpenCodeClient(sessionId) + // Shape: { providers: Provider[], default: Record } + // Provider.models is Record where Model has nested + // capabilities + cost.cache subfields (NOT flat tool_call / cache_read). + const result = await callSdk<{ + providers?: Array<{ + id?: string + name?: string + models?: Record< + string, + { + id?: string + name?: string + capabilities?: { + reasoning?: boolean + attachment?: boolean + toolcall?: boolean + } + cost?: { + input: number + output: number + cache?: { read: number; write: number } + } + } + > + }> + }>(() => (client as any).config.providers()) + return (result?.providers ?? []).map((p) => ({ + id: p.id ?? '', + name: p.name ?? p.id ?? '', + models: Object.entries(p.models ?? {}).map(([modelId, m]) => ({ + id: m.id ?? modelId, + name: m.name ?? m.id ?? modelId, + reasoning: m.capabilities?.reasoning, + attachment: m.capabilities?.attachment, + toolCall: m.capabilities?.toolcall, + cost: m.cost + ? { + input: m.cost.input, + output: m.cost.output, + cacheRead: m.cost.cache?.read, + cacheWrite: m.cost.cache?.write, + } + : undefined, + })), + })) + } + + async listOpenCodeAgents(sessionId: string): Promise { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk>( + () => (client as any).app.agents(), + ) + if (!Array.isArray(result)) return [] + return result.map((a) => ({ + name: a.name ?? '', + description: a.description, + mode: a.mode, + })) + } + + async listOpenCodeModes(sessionId: string): Promise { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk<{ agents?: Record }>(() => + (client as any).config.get(), + ) + const modes = new Set(['primary', 'subagent', 'all']) + for (const agent of Object.values(result?.agents ?? {})) { + if (agent?.mode) modes.add(agent.mode) + } + return Array.from(modes) + } + + async listOpenCodeTools( + sessionId: string, + providerID: string, + modelID: string, + ): Promise { + const client = await this.getOpenCodeClient(sessionId) + try { + // SDK query expects { provider, model } (not providerID/modelID). + const result = await callSdk< + Array<{ id?: string; description?: string; parameters?: unknown }> + >(() => (client as any).tool.list({ query: { provider: providerID, model: modelID } })) + if (Array.isArray(result)) { + return result.map((t) => ({ + id: t.id ?? '', + description: t.description, + schema: t.parameters, + })) + } + } catch { + // Fallback to ids() if list() rejects + } + const ids = await callSdk(() => (client as any).tool.ids()) + return (ids ?? []).map((id) => ({ id })) + } + + // ─── Session ops ───────────────────────────────────────────── + + async sessionShare(sessionId: string): Promise<{ url?: string; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const session = this.sessions.get(sessionId) + const remoteId = session?.backendStates['opencode']?.sessionId + if (!remoteId) return { error: 'No active OpenCode session id' } + const result = await callSdk<{ url?: string; share?: { url?: string } }>(() => + (client as any).session.share({ path: { id: remoteId } }), + ) + return { url: result?.url ?? result?.share?.url } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionUnshare(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.unshare({ path: { id: remoteId } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionSummarize(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.summarize({ path: { id: remoteId } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionRevert( + sessionId: string, + messageId: string, + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => + (client as any).session.revert({ + path: { id: remoteId }, + body: { messageID: messageId }, + }), + ) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionUnrevert(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.unrevert({ path: { id: remoteId } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionFork( + sessionId: string, + messageId: string, + ): Promise<{ newSessionId?: string; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + const result = await callSdk<{ id?: string }>(() => + (client as any).session.fork({ + path: { id: remoteId }, + body: { messageID: messageId }, + }), + ) + return { newSessionId: result?.id } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionAbort(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.abort({ path: { id: remoteId } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionDiff(sessionId: string): Promise<{ diff?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + const result = await callSdk(() => + (client as any).session.diff({ path: { id: remoteId } }), + ) + return { diff: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionTodo(sessionId: string): Promise<{ todos?: OpenCodeTodoItem[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + const result = await callSdk>(() => + (client as any).session.todo({ path: { id: remoteId } }), + ) + const todos = (Array.isArray(result) ? result : []).map((t) => ({ + id: t.id ?? '', + text: t.text ?? '', + done: t.done, + })) + return { todos } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionInit(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.init({ path: { id: remoteId } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionDeleteRemote(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.delete({ path: { id: remoteId } })) + // Clear local backend state since the remote session is gone. + const session = this.sessions.get(sessionId) + if (session) { + delete session.backendStates['opencode'] + await this.persistSession(session) + } + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + // ─── Workspace ops (Phase 7) ─────────────────────────────────── + + async fileList( + sessionId: string, + path: string, + ): Promise<{ entries?: OpenCodeFileEntry[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk< + Array<{ + path?: string + name?: string + type?: string + size?: number + modified?: number + }> + >(() => (client as any).file.list({ query: { path, directory: this.workspaceRoot } })) + const entries = (Array.isArray(result) ? result : []).map((e) => ({ + path: e.path ?? '', + name: e.name ?? '', + isDirectory: e.type === 'directory' || e.type === 'dir', + size: e.size, + modified: e.modified, + })) + return { entries } + } catch (error) { + return { error: errMsg(error) } + } + } + + async fileRead(sessionId: string, path: string): Promise<{ content?: string; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk<{ content?: string } | string>(() => + (client as any).file.read({ query: { path, directory: this.workspaceRoot } }), + ) + if (typeof result === 'string') return { content: result } + return { content: result?.content } + } catch (error) { + return { error: errMsg(error) } + } + } + + async fileStatus(sessionId: string): Promise<{ status?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => + (client as any).file.status({ query: { directory: this.workspaceRoot } }), + ) + return { status: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async findText( + sessionId: string, + query: string, + ): Promise<{ results?: OpenCodeFindResult[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk< + Array<{ path?: string; line?: number; column?: number; text?: string; preview?: string }> + >(() => (client as any).find.text({ query: { query, directory: this.workspaceRoot } })) + const results = (Array.isArray(result) ? result : []).map((r) => ({ + path: r.path ?? '', + line: r.line, + column: r.column, + preview: r.preview ?? r.text, + matchText: r.text, + })) + return { results } + } catch (error) { + return { error: errMsg(error) } + } + } + + async findFiles( + sessionId: string, + pattern: string, + ): Promise<{ paths?: string[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk>(() => + (client as any).find.files({ query: { pattern, directory: this.workspaceRoot } }), + ) + const arr = Array.isArray(result) ? result : [] + const paths = arr.map((p) => (typeof p === 'string' ? p : (p.path ?? ''))) + return { paths } + } catch (error) { + return { error: errMsg(error) } + } + } + + async findSymbols( + sessionId: string, + query: string, + ): Promise<{ symbols?: OpenCodeSymbolResult[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk< + Array<{ + name?: string + kind?: string | number + path?: string + line?: number + column?: number + }> + >(() => (client as any).find.symbols({ query: { query, directory: this.workspaceRoot } })) + const symbols = (Array.isArray(result) ? result : []).map((s) => ({ + name: s.name ?? '', + kind: typeof s.kind === 'string' ? s.kind : String(s.kind ?? ''), + path: s.path ?? '', + line: s.line, + column: s.column, + })) + return { symbols } + } catch (error) { + return { error: errMsg(error) } + } + } + + async shellRun( + sessionId: string, + command: string, + ): Promise<{ result?: OpenCodeShellResult; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + const result = await callSdk<{ + exitCode?: number + stdout?: string + stderr?: string + }>(() => + (client as any).session.shell({ + path: { id: remoteId }, + body: { command }, + }), + ) + return { + result: { + exitCode: result?.exitCode ?? 0, + stdout: result?.stdout ?? '', + stderr: result?.stderr ?? '', + }, + } + } catch (error) { + return { error: errMsg(error) } + } + } + + async lspStatus(sessionId: string): Promise<{ status?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).lsp.status()) + return { status: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async formatterStatus(sessionId: string): Promise<{ status?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).formatter.status()) + return { status: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + // ─── Config / auth / providers (Phase 8) ─────────────────────── + + async configGet(sessionId: string): Promise<{ config?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).config.get()) + return { config: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async configUpdate( + sessionId: string, + patch: Record, + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + await callSdk(() => (client as any).config.update({ body: patch })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async configProviders(sessionId: string): Promise<{ providers?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).config.providers()) + return { providers: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async authSet( + sessionId: string, + key: string, + value: string, + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + await callSdk(() => (client as any).auth.set({ body: { key, value } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async providerList(sessionId: string): Promise<{ providers?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).provider.list()) + return { providers: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async providerAuth( + sessionId: string, + providerId: string, + ): Promise<{ methods?: OpenCodeAuthMethod[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk>(() => + (client as any).provider.auth({ query: { providerID: providerId } }), + ) + const methods = (Array.isArray(result) ? result : []).map((m) => ({ + id: m.id ?? '', + label: m.label, + type: (m.type === 'oauth' || m.type === 'apiKey' || m.type === 'env' + ? m.type + : 'unknown') as OpenCodeAuthMethod['type'], + })) + return { methods } + } catch (error) { + return { error: errMsg(error) } + } + } + + async providerOauthAuthorize( + sessionId: string, + providerId: string, + ): Promise<{ url?: string; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk<{ url?: string }>(() => + (client as any).provider.oauth.authorize({ query: { providerID: providerId } }), + ) + return { url: result?.url } + } catch (error) { + return { error: errMsg(error) } + } + } + + async providerOauthCallback( + sessionId: string, + code: string, + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + await callSdk(() => (client as any).provider.oauth.callback({ query: { code } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async pathGet(sessionId: string): Promise<{ paths?: OpenCodePathInfo; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).path.get()) + return { paths: result ?? {} } + } catch (error) { + return { error: errMsg(error) } + } + } + + async logWrite( + sessionId: string, + message: string, + level?: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR', + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + await callSdk(() => (client as any).app.log({ body: { message, level: level ?? 'INFO' } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + serverInfo(): OpenCodeServerInfo | null { + return this.openCodeHost?.getInfo() ?? null + } + + // ─── TUI control (Phase 8) ───────────────────────────────────── + + async tui( + sessionId: string, + method: + | 'appendPrompt' + | 'submitPrompt' + | 'clearPrompt' + | 'openHelp' + | 'openSessions' + | 'openThemes' + | 'openModels' + | 'executeCommand' + | 'showToast', + args?: Record, + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const tui = (client as any).tui + const opts = args ? { body: args } : undefined + switch (method) { + case 'appendPrompt': + await callSdk(() => tui.appendPrompt(opts)) + break + case 'submitPrompt': + await callSdk(() => tui.submitPrompt()) + break + case 'clearPrompt': + await callSdk(() => tui.clearPrompt()) + break + case 'openHelp': + await callSdk(() => tui.openHelp()) + break + case 'openSessions': + await callSdk(() => tui.openSessions()) + break + case 'openThemes': + await callSdk(() => tui.openThemes()) + break + case 'openModels': + await callSdk(() => tui.openModels()) + break + case 'executeCommand': + await callSdk(() => tui.executeCommand(opts)) + break + case 'showToast': + await callSdk(() => tui.showToast(opts)) + break + } + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + // ─── Internals ───────────────────────────────────────────────── + private toPublicSession(session: CliAgentSessionInternal): CliAgentSession { return { id: session.id, workspaceId: session.workspaceId, backend: session.backend, + activeBackend: session.backend, processStatus: session.processStatus, messages: session.messages, model: session.model, sessionToolNames: session.sessionToolNames, lastError: session.lastError, totalCostUsd: session.totalCostUsd, + totalTokens: session.totalTokens, worktreePath: session.worktreePath, + backendStates: session.backendStates, } } - updatePaths(claudeCodePath: string, codexPath: string): void { - this.claudeCodePath = claudeCodePath - this.codexPath = codexPath - // Invalidate cache so next query re-resolves - this.resolvedClaudeCodePath = null - } + private createAdapter(backend: ExternalCliBackend): CliBackendAdapter { + if (backend === 'claude-code') { + const executablePath = this.resolveClaudeCodeExecutable() + if (!executablePath) { + throw new Error( + 'Claude Code CLI not found. Install @anthropic-ai/claude-code globally or set agent.claudeCodePath in settings.', + ) + } + return createClaudeCodeAdapter({ executablePath }) + } - getRunningSessionCount(): number { - let count = 0 - for (const session of this.sessions.values()) { - if (session.queryInstance) count += 1 + if (backend === 'opencode') { + const host = this.ensureOpenCodeHost() + return createOpenCodeAdapter({ + host, + getPermissionSettings: () => ({ + tier: this.permissionTier, + autoApprove: this.autoApprove, + }), + }) } - return count + + const executablePath = this.resolveGenericExecutable( + 'codex', + this.codexPath, + this.resolvedCodexPath, + ) + if (!executablePath) { + throw new Error( + 'Codex CLI not found. Install @openai/codex or set agent.codexPath in settings.', + ) + } + this.resolvedCodexPath = executablePath + return createCodexAdapter({ executablePath }) } - async destroy(): Promise { - for (const session of this.sessions.values()) { - session.abortController?.abort() - session.queryInstance?.close() - await this.persistSession(session).catch(() => {}) + private ensureOpenCodeHost(): OpenCodeServerHost { + if (!this.openCodeHost) { + this.openCodeHost = new OpenCodeServerHost({ + workspaceRoot: this.workspaceRoot, + explicitPath: this.opencodePath, + }) } - this.sessions.clear() + return this.openCodeHost } - // ─── Claude Code CLI Resolution ───────────── + private requireRemoteId(localSessionId: string): string { + const session = this.sessions.get(localSessionId) + const remote = session?.backendStates['opencode']?.sessionId + if (!remote) throw new Error('No remote OpenCode session id; send a message first.') + return remote + } - /** - * Resolve the path to the Claude Code CLI executable. - * The SDK spawns Claude Code as a subprocess, so we need to tell it - * where the actual binary lives via `pathToClaudeCodeExecutable`. - */ private resolveClaudeCodeExecutable(): string | null { if (this.resolvedClaudeCodePath) return this.resolvedClaudeCodePath - // 1. Explicit path from settings if (this.claudeCodePath && existsSync(this.claudeCodePath)) { - console.log(`[CliAgentManager] Using explicit Claude Code path: ${this.claudeCodePath}`) this.resolvedClaudeCodePath = this.claudeCodePath return this.claudeCodePath } - // 2. Bundled in Electron app const bundledCandidates: string[] = [] if (app.isPackaged) { bundledCandidates.push( - join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'), + join( + process.resourcesPath, + 'app.asar.unpacked', + 'node_modules', + '@anthropic-ai', + 'claude-agent-sdk', + 'cli.js', + ), + join( + process.resourcesPath, + 'app.asar.unpacked', + 'node_modules', + '@anthropic-ai', + 'claude-code', + 'cli.js', + ), ) } bundledCandidates.push( + join(app.getAppPath(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'cli.js'), join(app.getAppPath(), 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'), + join(this.workspaceRoot, 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'cli.js'), + join(this.workspaceRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'), ) + for (const candidate of bundledCandidates) { if (existsSync(candidate)) { - console.log(`[CliAgentManager] Using bundled Claude Code: ${candidate}`) this.resolvedClaudeCodePath = candidate return candidate } } - // 3. Workspace-local installation - const workspaceCli = join(this.workspaceRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js') - if (existsSync(workspaceCli)) { - console.log(`[CliAgentManager] Using workspace Claude Code: ${workspaceCli}`) - this.resolvedClaudeCodePath = workspaceCli - return workspaceCli - } - - // 4. Global `claude` in PATH try { const result = execFileSync('which', ['claude'], { encoding: 'utf-8' }).trim() if (result) { - console.log(`[CliAgentManager] Using global Claude Code: ${result}`) this.resolvedClaudeCodePath = result return result } } catch { - // Not found in PATH + // Not found in PATH. } - console.warn('[CliAgentManager] Claude Code CLI not found in any location') return null } - // ─── SDK Query Consumption ────────────────── + private resolveGenericExecutable( + command: string, + explicitPath: string, + cachedPath: string | null, + ): string | null { + if (cachedPath) return cachedPath + if (explicitPath && existsSync(explicitPath)) return explicitPath - private async consumeQuery( - session: CliAgentSessionInternal, - prompt: string, - ): Promise { - const abortController = new AbortController() - session.abortController = abortController - - const cwd = session.worktreePath ?? this.workspaceRoot - console.log(`[CliAgentManager] Starting SDK query for session ${session.id}`) - console.log(`[CliAgentManager] cwd: ${cwd}`) - console.log(`[CliAgentManager] resume: ${session.claudeSessionId ?? '(new session)'}`) - console.log(`[CliAgentManager] prompt: ${prompt.slice(0, 200)}${prompt.length > 200 ? '...' : ''}`) - - // Collect stderr output for diagnostics - const stderrChunks: string[] = [] - - // Resolve the Claude Code executable path for the SDK - const executablePath = this.resolveClaudeCodeExecutable() - if (!executablePath) { - session.lastError = 'Claude Code CLI not found. Install @anthropic-ai/claude-code globally or set agent.claudeCodePath in settings.' - const errorMsg: CliAgentMessage = { - id: randomUUID(), - type: 'error', - content: session.lastError, - timestamp: Date.now(), - } - session.messages.push(errorMsg) - this.emitMessage(session, errorMsg) - this.setStatus(session, 'error') - return - } - console.log(`[CliAgentManager] executable: ${executablePath}`) - - const options: Record = { - cwd, - abortController, - pathToClaudeCodeExecutable: executablePath, - includePartialMessages: true, - permissionMode: 'default' as const, - settingSources: ['user', 'project', 'local'], - systemPrompt: { type: 'preset', preset: 'claude_code' }, - stderr: (data: string) => { - stderrChunks.push(data) - console.warn(`[CliAgentManager] stderr: ${data.trimEnd()}`) - }, + const candidates: string[] = [] + if (app.isPackaged) { + candidates.push( + join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', '.bin', command), + ) } + candidates.push( + join(app.getAppPath(), 'node_modules', '.bin', command), + join(this.workspaceRoot, 'node_modules', '.bin', command), + ) - if (session.claudeSessionId) { - options.resume = session.claudeSessionId + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate } - let queryInstance: ReturnType try { - queryInstance = query({ prompt, options: options as Parameters[0]['options'] }) - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err) - const errStack = err instanceof Error ? err.stack : undefined - console.error(`[CliAgentManager] Failed to create query:`, errMsg) - if (errStack) console.error(`[CliAgentManager] Stack:`, errStack) - session.lastError = `Failed to start agent: ${errMsg}` - this.setStatus(session, 'error') + const result = execFileSync('which', [command], { encoding: 'utf-8' }).trim() + if (result) return result + } catch { + // Not found in PATH. + } - // Surface the error in the chat as a message - const errorMsg: CliAgentMessage = { - id: randomUUID(), - type: 'error', - content: `Failed to start agent: ${errMsg}${stderrChunks.length ? '\n\nstderr:\n' + stderrChunks.join('') : ''}`, - timestamp: Date.now(), + return null + } + + private applyBackendEvent(session: CliAgentSessionInternal, event: CliBackendEvent): void { + if (event.type === 'stream-delta') { + const payload: CliAgentStreamDelta = { + workspaceId: session.workspaceId, + sessionId: session.id, + messageId: event.messageId, + delta: event.delta, } - session.messages.push(errorMsg) - this.emitMessage(session, errorMsg) + this.getWebContents()?.send(IpcChannels.CLI_AGENT_STREAM_DELTA, payload) return } - session.queryInstance = queryInstance - this.setStatus(session, 'running') + if (event.type === 'backend-state') { + const current = session.backendStates[session.backend] ?? {} + session.backendStates[session.backend] = { ...current, ...event.patch } + if (event.patch.model) session.model = event.patch.model + return + } - let messageCount = 0 - try { - for await (const message of queryInstance) { - if (abortController.signal.aborted) break - messageCount++ - this.handleSDKMessage(session, message) - } - console.log(`[CliAgentManager] Query completed for session ${session.id} — ${messageCount} messages received`) - } catch (err) { - if (!abortController.signal.aborted) { - const errMsg = err instanceof Error ? err.message : String(err) - const errStack = err instanceof Error ? err.stack : undefined - console.error(`[CliAgentManager] Query error for session ${session.id}:`, errMsg) - if (errStack) console.error(`[CliAgentManager] Stack:`, errStack) - if (stderrChunks.length) { - console.error(`[CliAgentManager] Captured stderr:\n${stderrChunks.join('')}`) + if (event.type === 'session-meta') { + if (event.model) { + session.model = event.model + session.backendStates[session.backend] = { + ...(session.backendStates[session.backend] ?? {}), + model: event.model, } - console.error(`[CliAgentManager] Messages received before error: ${messageCount}`) - - // Build a detailed error message for the UI - const stderrText = stderrChunks.join('').trim() - const detailedError = stderrText - ? `${errMsg}\n\nstderr output:\n${stderrText.slice(-2000)}` - : errMsg - session.lastError = detailedError - - // Surface the error in the chat - const errorMsg: CliAgentMessage = { - id: randomUUID(), - type: 'error', - content: detailedError, - timestamp: Date.now(), - } - session.messages.push(errorMsg) - this.emitMessage(session, errorMsg) + } + if (event.tools) session.sessionToolNames = event.tools + return + } - this.setStatus(session, 'error') + if (event.type === 'result') { + session.totalCostUsd += event.totalCostUsd + if (event.tokens) { + session.totalTokens = sumTokenUsage(session.totalTokens, event.tokens) } - } finally { - session.queryInstance = null - session.abortController = null - if (session.processStatus === 'stopping') { - this.setStatus(session, 'stopped') - } else if (session.processStatus !== 'error') { - this.setStatus(session, 'stopped') + const payload: CliAgentResultPayload = { + workspaceId: session.workspaceId, + sessionId: session.id, + durationMs: event.durationMs, + totalCostUsd: session.totalCostUsd, + totalTokens: session.totalTokens, + isSuccess: event.isSuccess, } - await this.persistSession(session).catch(() => {}) + this.getWebContents()?.send(IpcChannels.CLI_AGENT_RESULT, payload) + this.broadcastWorkspaceCost(session.workspaceId) + return } - } - // ─── SDK Message Handling ─────────────────── - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleSDKMessage(session: CliAgentSessionInternal, message: any): void { - const type = message.type as string - const subtype = message.subtype as string | undefined - - // Log all non-streaming messages (stream_event is too noisy) - if (type !== 'stream_event') { - console.log(`[CliAgentManager] SDK message: type=${type}${subtype ? ` subtype=${subtype}` : ''} session=${session.id.slice(0, 8)}`) + if (event.type === 'permission-request') { + this.handlePermissionRequest(session, event.request, event.resolve) + return } - if (type === 'system') { - this.handleSystemMessage(session, message) - } else if (type === 'assistant') { - this.handleAssistantMessage(session, message) - } else if (type === 'stream_event') { - this.handleStreamEvent(session, message) - } else if (type === 'tool_progress') { - this.handleToolProgress(session, message) - } else if (type === 'tool_use_summary') { - this.handleToolUseSummary(session, message) - } else if (type === 'result') { - this.handleResultMessage(session, message) - } else if (type === 'rate_limit_event') { - console.warn(`[CliAgentManager] Rate limited — session ${session.id.slice(0, 8)}`) - this.setStatus(session, 'rate_limited') - } else { - // Log unknown message types so we can add handling later - console.log(`[CliAgentManager] Unhandled SDK message type: ${type}`, JSON.stringify(message).slice(0, 500)) + const message: CliAgentMessage = { + ...event.message, + backend: event.message.type === 'user' ? undefined : session.backend, } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleSystemMessage(session: CliAgentSessionInternal, message: any): void { - const subtype = message.subtype as string | undefined - - if (subtype === 'init') { - // Capture session ID for resume - const sdkSessionId = message.session_id as string | undefined - if (sdkSessionId) { - session.claudeSessionId = sdkSessionId - this.conversationStore?.updateMeta(session.id, { claudeSessionId: sdkSessionId }).catch(() => {}) - } - - session.model = (message.model as string) ?? undefined - const tools = message.tools as string[] | undefined - session.sessionToolNames = tools + session.messages.push(message) + this.emitMessage(session, message) + if (message.type === 'status' && message.content.toLowerCase().includes('rate limited')) { + this.setStatus(session, 'rate_limited') + } else if (session.processStatus === 'rate_limited' && message.type !== 'status') { this.setStatus(session, 'running') - - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: 'system', - content: `Session initialized — model: ${session.model ?? 'unknown'}`, - timestamp: Date.now(), - raw: message, - } - session.messages.push(msg) - this.emitMessage(session, msg) - } else if (subtype === 'status') { - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: 'status', - content: String(message.status ?? message.message ?? 'status update'), - timestamp: Date.now(), - } - session.messages.push(msg) - this.emitMessage(session, msg) - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleAssistantMessage(session: CliAgentSessionInternal, message: any): void { - const betaMessage = message.message - if (!betaMessage || !Array.isArray(betaMessage.content)) return - - let text = '' - for (const block of betaMessage.content) { - if (block.type === 'text') { - text += block.text ?? '' - } else if (block.type === 'tool_use') { - // Emit tool use as separate message - const toolMsg: CliAgentMessage = { - id: (block.id as string) ?? randomUUID(), - type: 'tool_use', - content: `Running ${block.name ?? 'tool'}...`, - timestamp: Date.now(), - toolName: block.name as string, - toolUseId: block.id as string, - } - session.messages.push(toolMsg) - this.emitMessage(session, toolMsg) - } - } - - if (text) { - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: 'assistant', - content: text, - timestamp: Date.now(), - raw: message, - } - session.messages.push(msg) - this.emitMessage(session, msg) } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleStreamEvent(session: CliAgentSessionInternal, message: any): void { - const event = message.event - if (!event) return - - const eventType = event.type as string | undefined + /** + * Bridge an OpenCode permission event into the existing CHAT_TOOL_CALL + * approval surface so the IDE has one approval UI for both backends. + */ + private handlePermissionRequest( + session: CliAgentSessionInternal, + request: import('./cliAdapters/types').CliBackendPermissionRequest, + resolve: (response: 'always' | 'once' | 'reject') => void, + ): void { + const toolCallId = randomUUID() + this.pendingPermissions.set(toolCallId, { + sessionId: session.id, + resolve, + }) - if (eventType === 'content_block_delta') { - const delta = event.delta - if (delta?.type === 'text_delta') { - const text = (delta.text as string) ?? '' - if (text) { - const streamDelta: CliAgentStreamDelta = { - workspaceId: session.workspaceId, - sessionId: session.id, - messageId: (message.uuid as string) ?? session.id, - delta: text, - } - this.getWebContents()?.send(IpcChannels.CLI_AGENT_STREAM_DELTA, streamDelta) - } - } + const toolCall: ToolCall = { + id: toolCallId, + name: `opencode:${request.category}`, + input: { + title: request.title, + pattern: request.pattern, + ...(request.metadata ?? {}), + }, + status: 'pending', } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleToolProgress(session: CliAgentSessionInternal, message: any): void { - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: 'tool_use', - content: `Running ${message.tool_name ?? 'tool'}...`, - timestamp: Date.now(), - toolName: (message.tool_name as string) ?? undefined, - toolUseId: (message.tool_use_id as string) ?? undefined, + const payload: ChatToolCallPayload = { + workspaceId: session.workspaceId, + sessionId: session.id, + toolCall, } - session.messages.push(msg) - this.emitMessage(session, msg) - } + this.getWebContents()?.send(IpcChannels.CHAT_TOOL_CALL, payload) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleToolUseSummary(session: CliAgentSessionInternal, message: any): void { - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: 'tool_result', - content: (message.summary as string) ?? 'Tool completed', + // Also emit a structured CLI agent permission request payload for any + // surface that wants the rich form (badges, metadata). + const richPayload: CliAgentPermissionRequest = { + workspaceId: session.workspaceId, + sessionId: session.id, + toolCallId, + backend: 'opencode', + title: request.title, + category: request.category, + pattern: request.pattern, + metadata: request.metadata, timestamp: Date.now(), } - session.messages.push(msg) - this.emitMessage(session, msg) + void richPayload // (channel reserved for future granular UI; CHAT_TOOL_CALL is the active surface) + this.notifyWorkloadChanged() } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleResultMessage(session: CliAgentSessionInternal, message: any): void { - const subtype = message.subtype as string | undefined - const isSuccess = subtype === 'success' - const durationMs = (message.duration_ms as number) ?? 0 - const totalCostUsd = (message.total_cost_usd as number) ?? 0 + private buildTurnPrompt(session: CliAgentSessionInternal, userMessage: CliAgentMessage): string { + const backendState = session.backendStates[session.backend] + if (backendState?.sessionId) { + return userMessage.contextualContent ?? userMessage.content + } - session.totalCostUsd += totalCostUsd + const priorMessages = session.messages + .slice(0, -1) + .filter( + (message) => + message.type === 'user' || + message.type === 'assistant' || + message.type === 'tool_use' || + message.type === 'tool_result', + ) - // Capture session ID from result as well - const resultSessionId = message.session_id as string | undefined - if (resultSessionId && !session.claudeSessionId) { - session.claudeSessionId = resultSessionId - this.conversationStore?.updateMeta(session.id, { claudeSessionId: resultSessionId }).catch(() => {}) + if (priorMessages.length === 0) { + return userMessage.contextualContent ?? userMessage.content } - // Build detailed error content for non-success results - let errorDetail = '' - if (!isSuccess) { - const errors = message.errors as string[] | undefined - if (errors?.length) { - errorDetail = errors.join('\n') - } - console.error(`[CliAgentManager] Result error for session ${session.id.slice(0, 8)}: subtype=${subtype}`) - if (errorDetail) console.error(`[CliAgentManager] Error details:\n${errorDetail}`) - console.error(`[CliAgentManager] Full result message:`, JSON.stringify(message).slice(0, 2000)) - } + const transcript = priorMessages + .slice(-40) + .map((message) => { + const source = message.backend ? ` ${message.backend}` : '' + const content = + message.type === 'user' ? (message.contextualContent ?? message.content) : message.content + return `[${message.type}${source}]\n${content}` + }) + .join('\n\n') + + return [ + `You are taking over an existing IDE agent conversation using the ${session.backend} backend.`, + 'Continue from the prior transcript below and answer the latest user request directly.', + '', + 'Conversation transcript:', + transcript, + '', + 'Latest user request:', + userMessage.contextualContent ?? userMessage.content, + ].join('\n') + } - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: isSuccess ? 'result' : 'error', - content: isSuccess - ? `Completed in ${(durationMs / 1000).toFixed(1)}s` - : `Failed: ${subtype ?? 'unknown error'}${errorDetail ? '\n\n' + errorDetail : ''}`, + private handleRunError(session: CliAgentSessionInternal, error: string): void { + session.lastError = error + const message: CliAgentMessage = { + id: randomUUID(), + type: 'error', + content: error, timestamp: Date.now(), - durationMs, - totalCostUsd, - isSuccess, - raw: message, - } - session.messages.push(msg) - this.emitMessage(session, msg) - - const resultPayload: CliAgentResultPayload = { - workspaceId: session.workspaceId, - sessionId: session.id, - durationMs, - totalCostUsd: session.totalCostUsd, - isSuccess, + backend: session.backend, } - this.getWebContents()?.send(IpcChannels.CLI_AGENT_RESULT, resultPayload) + session.messages.push(message) + this.emitMessage(session, message) + this.setStatus(session, 'error') } - // ─── IPC Emission Helpers ──────────────────── - private setStatus(session: CliAgentSessionInternal, status: CliAgentProcessStatus): void { session.processStatus = status this.emitStatus(session) @@ -736,48 +1595,101 @@ export class CliAgentManager { this.getWebContents()?.send(IpcChannels.CLI_AGENT_STATUS, payload) } - private emitMessage(session: CliAgentSessionInternal, msg: CliAgentMessage): void { - const ipcMsg: CliAgentMessagePayload = { ...msg, workspaceId: session.workspaceId, sessionId: session.id } - this.getWebContents()?.send(IpcChannels.CLI_AGENT_MESSAGE, ipcMsg) - - if (session.processStatus === 'rate_limited' && msg.type !== 'status') { - this.setStatus(session, 'running') + private emitMessage(session: CliAgentSessionInternal, message: CliAgentMessage): void { + const payload: CliAgentMessagePayload = { + ...message, + workspaceId: session.workspaceId, + sessionId: session.id, } + this.getWebContents()?.send(IpcChannels.CLI_AGENT_MESSAGE, payload) + } + + private broadcastWorkspaceCost(workspaceId: string): void { + const summary = this.getWorkspaceCostSummary() + if (workspaceId) summary.workspaceId = workspaceId + this.getWebContents()?.send(IpcChannels.CLI_AGENT_WORKSPACE_COST, summary) } - // ─── Persistence Helpers ───────────────────── + private notifyWorkloadChanged(): void { + try { + this.onWorkloadChanged?.() + } catch { + /* ignore */ + } + } private async persistSession(session: CliAgentSessionInternal): Promise { - if (!this.conversationStore) return + if (!this.conversationStore || session.id.startsWith('claude-native:')) return + + const claudeSessionId = session.backendStates['claude-code']?.sessionId await this.conversationStore.saveMessages(session.id, { messages: session.messages, - claudeSessionId: session.claudeSessionId, - }) + activeBackend: session.backend, + backendStates: session.backendStates, + claudeSessionId, + totalCostUsd: session.totalCostUsd, + totalTokens: session.totalTokens, + } satisfies PersistedCliConversation) + await this.conversationStore.updateMeta(session.id, { + backend: session.backend, updatedAt: Date.now(), messageCount: session.messages.length, - firstMessage: session.messages.find(m => m.type === 'user')?.content.slice(0, 100), - claudeSessionId: session.claudeSessionId, + firstMessage: session.messages + .find((message) => message.type === 'user') + ?.content.slice(0, 100), + claudeSessionId, worktreePath: session.worktreePath, }) } private async maybeAutoTitle(session: CliAgentSessionInternal, content: string): Promise { - if (!this.conversationStore) return + if (!this.conversationStore || session.id.startsWith('claude-native:')) return - const userMessages = session.messages.filter(m => m.type === 'user') + const userMessages = session.messages.filter((message) => message.type === 'user') if (userMessages.length !== 1) return const meta = await this.conversationStore.get(session.id) if (!meta || !meta.autoTitled) return - const title = deriveTitle(content) - await this.conversationStore.updateMeta(session.id, { title, updatedAt: Date.now() }) + await this.conversationStore.updateMeta(session.id, { + title: deriveTitle(content), + updatedAt: Date.now(), + }) + + await this.broadcastConversationList(session.workspaceId) + } + private async broadcastConversationList(workspaceId: string): Promise { + if (!this.conversationStore) return const index = await this.conversationStore.loadIndex() this.getWebContents()?.send(IpcChannels.CONVERSATION_LIST_CHANGED, { - workspaceId: session.workspaceId, + workspaceId, conversations: index, } satisfies ConversationListChangedPayload) } } + +// ─── SDK call helpers ────────────────────────────────────────── + +/** + * Wraps an SDK promise so we can deal with both `responseStyle: 'data'` + * (returns the unwrapped data) and the older `{ data, error }` shape. + * If `error` is set, throws. + */ +async function callSdk(fn: () => Promise): Promise { + const result = await fn() + if (result && typeof result === 'object' && 'error' in (result as Record)) { + const wrapper = result as { data?: unknown; error?: unknown } + if (wrapper.error) { + const err = wrapper.error as { message?: string } | string + throw new Error(typeof err === 'string' ? err : (err.message ?? 'OpenCode SDK error')) + } + return wrapper.data as T | undefined + } + return result as T | undefined +} + +function errMsg(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/main/src/chat/conversationStore.ts b/packages/main/src/chat/conversationStore.ts index 1a873cb..c5d2844 100644 --- a/packages/main/src/chat/conversationStore.ts +++ b/packages/main/src/chat/conversationStore.ts @@ -124,7 +124,7 @@ export class ConversationStore { async ensure(conversationId: string, opts: ConversationCreateOpts): Promise { return this.withIndexLock(async () => { const index = await this.loadIndex() - const existing = index.find(c => c.id === conversationId) + const existing = index.find((c) => c.id === conversationId) if (existing) return existing const now = Date.now() @@ -150,7 +150,7 @@ export class ConversationStore { async delete(conversationId: string): Promise { await this.withIndexLock(async () => { const index = await this.loadIndex() - const filtered = index.filter(c => c.id !== conversationId) + const filtered = index.filter((c) => c.id !== conversationId) await this.saveIndex(filtered) }) @@ -165,16 +165,28 @@ export class ConversationStore { async get(conversationId: string): Promise { const index = await this.loadIndex() - return index.find(c => c.id === conversationId) ?? null + return index.find((c) => c.id === conversationId) ?? null } async updateMeta( conversationId: string, - patch: Partial>, + patch: Partial< + Pick< + ConversationMeta, + | 'backend' + | 'title' + | 'autoTitled' + | 'updatedAt' + | 'messageCount' + | 'firstMessage' + | 'claudeSessionId' + | 'worktreePath' + > + >, ): Promise { await this.withIndexLock(async () => { const index = await this.loadIndex() - const entry = index.find(c => c.id === conversationId) + const entry = index.find((c) => c.id === conversationId) if (!entry) return Object.assign(entry, patch) @@ -190,9 +202,9 @@ export class ConversationStore { const index = await this.loadIndex() // Index is sorted newest-first if (backend) { - return index.find(c => c.workspaceId === workspaceId && c.backend === backend) ?? null + return index.find((c) => c.workspaceId === workspaceId && c.backend === backend) ?? null } - return index.find(c => c.workspaceId === workspaceId) ?? null + return index.find((c) => c.workspaceId === workspaceId) ?? null } // ─── Message Data ──────────────────────────── @@ -239,7 +251,7 @@ export class ConversationStore { } const messages = oldSession.messages ?? [] - const firstUserMsg = messages.find(m => m.role === 'user') + const firstUserMsg = messages.find((m) => m.role === 'user') const meta: ConversationMeta = { id: oldSession.id, diff --git a/packages/main/src/chat/openCodeServerHost.ts b/packages/main/src/chat/openCodeServerHost.ts new file mode 100644 index 0000000..a1ac09b --- /dev/null +++ b/packages/main/src/chat/openCodeServerHost.ts @@ -0,0 +1,410 @@ +/** + * OpenCodeServerHost — per-workspace persistent OpenCode server. + * + * Owns one `opencode serve` child process per workspace and a shared SSE + * subscription that is fanned out to per-session listener bags. Adapters + * (one per turn) attach to the host, get a client + a per-session event + * subscription, run their turn, and detach — the server stays running for + * the workspace's lifetime. + * + * Lifecycle: + * - `start()` → idempotent; spawns + waits for "listening" + * - `subscribe(id)` → returns an unsubscribe fn; emits opencode events + * whose sessionID matches `id` (or null sessionID for + * broadcast events like installation.*) + * - `respondPermission(sessionId, permissionId, response)` → POSTs + * - `dispose()` → kills the process, aborts SSE, clears listeners + * - `setPath(path)` → swap the binary used on next (re)start + * + * Multi-workspace parallelism is the default: each workspace's CliAgentManager + * owns its own host instance; ports are reserved fresh per host so two hosts + * never collide. + */ + +import { spawn, type ChildProcess } from 'child_process' +import { createServer } from 'net' +import { existsSync } from 'fs' +import { join } from 'path' +import { app } from 'electron' +import { execFileSync } from 'child_process' +import { createOpencodeClient } from '@opencode-ai/sdk/client' + +type OpencodeClient = ReturnType + +export type OpenCodeHostMode = 'auto' | 'external' + +export interface OpenCodeHostOptions { + workspaceRoot: string + /** Explicit binary path; if set, "external" mode is forced. */ + explicitPath?: string +} + +export interface OpenCodeServerInfoSnapshot { + url: string + mode: 'bundled' | 'external' + pid?: number + startedAt: number +} + +type EventListener = (event: unknown) => void + +interface SubscriberEntry { + sessionId: string | null + listener: EventListener +} + +export class OpenCodeServerHost { + private workspaceRoot: string + private explicitPath: string + + private startPromise: Promise | null = null + private url: string | null = null + private serverProc: ChildProcess | null = null + private startedAt = 0 + private mode: 'bundled' | 'external' = 'bundled' + private client: OpencodeClient | null = null + private resolvedPath: string | null = null + + private subscribers = new Set() + private sseAbort: AbortController | null = null + private sseTask: Promise | null = null + private disposed = false + + constructor(options: OpenCodeHostOptions) { + this.workspaceRoot = options.workspaceRoot + this.explicitPath = options.explicitPath ?? '' + } + + /** Returns the OpencodeClient (starts the host on first call). */ + async getClient(): Promise { + await this.start() + if (!this.client) throw new Error('OpenCodeServerHost: client unavailable after start()') + return this.client + } + + /** Idempotent start. Re-entrant calls return the same promise. */ + async start(): Promise { + if (this.disposed) throw new Error('OpenCodeServerHost has been disposed') + if (this.startPromise) return this.startPromise + this.startPromise = this.doStart().catch((error) => { + this.startPromise = null + throw error + }) + return this.startPromise + } + + private async doStart(): Promise { + const binaryPath = this.resolveBinaryPath() + if (!binaryPath) { + throw new Error( + 'OpenCode CLI not found. Install opencode (npm i -g opencode-ai) or set agent.opencodePath in settings.', + ) + } + this.resolvedPath = binaryPath + this.mode = this.explicitPath ? 'external' : 'bundled' + + const port = await reservePort() + const url = `http://127.0.0.1:${port}` + const proc = spawn(binaryPath, ['serve', '--hostname=127.0.0.1', `--port=${port}`], { + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({}), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + if (!proc) throw new Error('Failed to spawn OpenCode server process') + + try { + await waitForOpenCodeServer(proc, url) + } catch (error) { + proc.kill('SIGTERM') + throw error + } + + this.serverProc = proc + this.url = url + this.startedAt = Date.now() + + this.client = createOpencodeClient({ + baseUrl: url, + directory: this.workspaceRoot, + responseStyle: 'data', + throwOnError: true, + } as Parameters[0]) + + proc.once('exit', (code, signal) => { + if (this.disposed) return + // Server crashed unexpectedly — clear state so the next call re-spawns. + this.handleServerExit(`server exited (code=${code} signal=${signal})`) + }) + + // Start the shared SSE stream. + this.beginSse() + } + + private beginSse(): void { + if (this.sseAbort) return + if (!this.client) return + this.sseAbort = new AbortController() + const signal = this.sseAbort.signal + const directory = this.workspaceRoot + this.sseTask = (async () => { + try { + // Use `/event` (Event.subscribe) — it yields the unwrapped Event union + // (`{ type, properties }`) directly and accepts a `directory` query + // filter so we only receive events for this workspace's server. + // + // The previous implementation used `client.global.event()` which yields + // `GlobalEvent = { directory, payload: Event }`; that wrapping caused + // every dispatched event to look like `{ type: undefined }` and the + // adapter would never observe `session.idle`, so OpenCode chats never + // replied. + const sse = await (this.client as unknown as { + event: { + subscribe: (opts: { + query?: { directory?: string } + signal?: AbortSignal + }) => Promise<{ stream: AsyncIterable }> + } + }).event.subscribe({ query: { directory }, signal }) + for await (const rawEvent of sse.stream) { + if (signal.aborted) break + this.dispatchEvent(rawEvent) + } + } catch (error) { + if (signal.aborted || this.disposed) return + // SSE crashed — surface as a synthetic event so listeners can react. + this.dispatchEvent({ + type: 'sse.error', + properties: { + error: error instanceof Error ? error.message : String(error), + }, + }) + } + })() + } + + private dispatchEvent(rawEvent: unknown): void { + const event = rawEvent as { type?: string; properties?: Record } + const props = (event?.properties ?? {}) as Record + const explicitSessionID = + typeof props.sessionID === 'string' + ? (props.sessionID as string) + : typeof (props.info as { sessionID?: string } | undefined)?.sessionID === 'string' + ? ((props.info as { sessionID?: string }).sessionID as string) + : typeof (props.part as { sessionID?: string } | undefined)?.sessionID === 'string' + ? ((props.part as { sessionID?: string }).sessionID as string) + : null + + for (const sub of this.subscribers) { + if (sub.sessionId === null || explicitSessionID === null || sub.sessionId === explicitSessionID) { + try { + sub.listener(rawEvent) + } catch { + // Listener errors must not break the SSE pump. + } + } + } + } + + /** + * Subscribe to events for a particular sessionId. Returns an unsubscribe fn. + * Pass `null` to receive every event (used by diagnostics surfaces). + */ + subscribe(sessionId: string | null, listener: EventListener): () => void { + const entry: SubscriberEntry = { sessionId, listener } + this.subscribers.add(entry) + return () => { + this.subscribers.delete(entry) + } + } + + /** + * Respond to an OpenCode permission request. Wraps the SDK's + * `postSessionIdPermissionsPermissionId` call so callers don't need to + * worry about its long name. + */ + async respondPermission( + sessionId: string, + permissionId: string, + response: 'always' | 'once' | 'reject', + ): Promise { + const client = await this.getClient() + await (client as unknown as { + postSessionIdPermissionsPermissionId: (opts: { + path: { id: string; permissionID: string } + body: { response: 'always' | 'once' | 'reject' } + }) => Promise + }).postSessionIdPermissionsPermissionId({ + path: { id: sessionId, permissionID: permissionId }, + body: { response }, + }) + } + + getInfo(): OpenCodeServerInfoSnapshot | null { + if (!this.url || !this.serverProc) return null + return { + url: this.url, + mode: this.mode, + pid: this.serverProc.pid, + startedAt: this.startedAt, + } + } + + /** Update the explicit path used on next (re)start. Doesn't restart automatically. */ + setPath(explicitPath: string): void { + this.explicitPath = explicitPath + this.resolvedPath = null + } + + /** + * Restart the host: dispose current process and re-start. Subscribers are + * preserved across restarts (they'll receive events from the new server). + */ + async restart(): Promise { + await this.shutdownProcess('restart requested') + this.disposed = false + await this.start() + } + + async dispose(): Promise { + if (this.disposed) return + this.disposed = true + await this.shutdownProcess('disposed') + this.subscribers.clear() + } + + private async shutdownProcess(reason: string): Promise { + void reason + this.startPromise = null + if (this.sseAbort) { + try { + this.sseAbort.abort() + } catch { + /* ignore */ + } + this.sseAbort = null + } + if (this.sseTask) { + try { + await this.sseTask + } catch { + /* ignore */ + } + this.sseTask = null + } + if (this.serverProc) { + try { + this.serverProc.kill('SIGTERM') + } catch { + /* ignore */ + } + this.serverProc = null + } + this.client = null + this.url = null + } + + private handleServerExit(reason: string): void { + void reason + this.startPromise = null + this.serverProc = null + this.client = null + this.url = null + if (this.sseAbort) { + try { + this.sseAbort.abort() + } catch { + /* ignore */ + } + this.sseAbort = null + } + this.sseTask = null + } + + private resolveBinaryPath(): string | null { + if (this.resolvedPath) return this.resolvedPath + + if (this.explicitPath && existsSync(this.explicitPath)) { + return this.explicitPath + } + + const candidates: string[] = [] + if (app.isPackaged) { + candidates.push( + join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', '.bin', 'opencode'), + ) + } + candidates.push( + join(app.getAppPath(), 'node_modules', '.bin', 'opencode'), + join(this.workspaceRoot, 'node_modules', '.bin', 'opencode'), + ) + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + + try { + const result = execFileSync('which', ['opencode'], { encoding: 'utf-8' }).trim() + if (result) return result + } catch { + // not on PATH + } + return null + } +} + +async function reservePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : null + server.close((error) => { + if (error) reject(error) + else if (typeof port === 'number') resolve(port) + else reject(new Error('Failed to reserve OpenCode port')) + }) + }) + }) +} + +async function waitForOpenCodeServer(proc: ChildProcess, url: string): Promise { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup() + reject(new Error('Timeout waiting for OpenCode server to start')) + }, 8000) + + let output = '' + const onData = (chunk: Buffer) => { + output += chunk.toString() + if (output.includes(url) || output.includes('opencode server listening')) { + cleanup() + resolve() + } + } + const onExit = (code: number | null) => { + cleanup() + reject(new Error(`OpenCode server exited with code ${code}${output ? `\n${output}` : ''}`)) + } + const onError = (error: Error) => { + cleanup() + reject(error) + } + const cleanup = () => { + clearTimeout(timeout) + proc.stdout?.off('data', onData) + proc.stderr?.off('data', onData) + proc.off('exit', onExit) + proc.off('error', onError) + } + + proc.stdout?.on('data', onData) + proc.stderr?.on('data', onData) + proc.once('exit', onExit) + proc.once('error', onError) + }) +} diff --git a/packages/main/src/chat/permissionMatching.ts b/packages/main/src/chat/permissionMatching.ts new file mode 100644 index 0000000..41f6a95 --- /dev/null +++ b/packages/main/src/chat/permissionMatching.ts @@ -0,0 +1,129 @@ +/** + * Shared permission decision utilities. + * + * Lifted from `agentManager.ts` so both the built-in `AgentManager` and the + * `CliAgentManager` (for OpenCode permission events) can apply the same + * permission tier + autoApprove rules to tool/operation requests. + * + * The IDE's permission model lives in user settings: + * - `agent.permissionTier`: 'confirm' | 'auto-approve' | 'autopilot' + * - `agent.autoApprove`: Record + */ + +import type { PermissionTier, ToolPermissionConfig } from '@aide/shared' + +/** + * IDE-canonical "read-only" tools — auto-approved under the `auto-approve` + * tier. These are the tool names used by the built-in `AgentManager`. The + * OpenCode permission bridge maps SDK permission categories onto these names + * (see `openCodePermissionBridge.ts`). + */ +export const READ_ONLY_TOOLS: ReadonlySet = new Set([ + 'file_read', + 'file_list', + 'search_files', + 'git_status', + 'git_diff', + 'browser_read', +]) + +/** + * Decide whether a tool call should be auto-approved without prompting the user. + * + * Precedence (highest first): + * 1. autoApprove[toolName] === false → deny + * 2. autoApprove[toolName] === true → allow + * 3. autoApprove[toolName] is a pattern config → glob match against input + * 4. permissionTier: + * - 'autopilot' → allow everything + * - 'auto-approve' → allow read-only tools, prompt for the rest + * - 'confirm' → prompt for everything + */ +export function shouldAutoApprove( + toolName: string, + input: Record, + tier: PermissionTier, + autoApprove: Record, +): boolean { + const override = autoApprove[toolName] + if (override === true) return true + if (override === false) return false + if (typeof override === 'object' && override !== null) { + return matchesPatternConfig(override, toolName, input) + } + + switch (tier) { + case 'autopilot': + return true + case 'auto-approve': + return READ_ONLY_TOOLS.has(toolName) + case 'confirm': + default: + return false + } +} + +/** + * Same as {@link shouldAutoApprove} but also distinguishes "explicitly denied" + * (deny pattern matched) from "not matched, fall through". The OpenCode bridge + * needs the three-way distinction to map onto OpenCode's `'always' | 'once' + * | 'reject'` permission response. + */ +export function evaluatePermission( + toolName: string, + input: Record, + tier: PermissionTier, + autoApprove: Record, +): 'allow' | 'deny' | 'prompt' { + const override = autoApprove[toolName] + if (override === false) return 'deny' + if (override === true) return 'allow' + if (typeof override === 'object' && override !== null) { + const matchTarget = matchTargetFor(toolName, input) + if (override.denyPatterns?.some((p) => globMatch(matchTarget, p))) return 'deny' + if (override.allowPatterns && override.allowPatterns.length > 0) { + return override.allowPatterns.some((p) => globMatch(matchTarget, p)) ? 'allow' : 'prompt' + } + return 'prompt' + } + + switch (tier) { + case 'autopilot': + return 'allow' + case 'auto-approve': + return READ_ONLY_TOOLS.has(toolName) ? 'allow' : 'prompt' + case 'confirm': + default: + return 'prompt' + } +} + +export function matchesPatternConfig( + config: ToolPermissionConfig, + toolName: string, + input: Record, +): boolean { + const matchTarget = matchTargetFor(toolName, input) + + if (config.denyPatterns?.some((p) => globMatch(matchTarget, p))) { + return false + } + if (config.allowPatterns && config.allowPatterns.length > 0) { + return config.allowPatterns.some((p) => globMatch(matchTarget, p)) + } + return false +} + +function matchTargetFor(toolName: string, input: Record): string { + if (toolName === 'terminal_exec') { + return String(input.command ?? '') + } + return JSON.stringify(input) +} + +export function globMatch(text: string, pattern: string): boolean { + // Simple glob: * matches any sequence of characters. + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') + const regex = new RegExp('^' + escaped.replace(/\*/g, '.*') + '$') + return regex.test(text) +} diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 20f3caf..d487629 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -7,7 +7,8 @@ import Store from 'electron-store' import { IpcChannels, DEFAULT_SETTINGS, SENSITIVE_AGENT_KEYS } from '@aide/shared' import type { AppSettings, - ThemeName, + ChatComposerSubmission, + ThemeId, DirEntry, SearchOpts, ReplaceOpts, @@ -38,8 +39,17 @@ import { } from './workspace/worktreeManager' import { startSearch, cancelSearch } from './search/ripgrepSearch' import { ensureAideFolder } from './workspace/aideInit' -import { resolveAppDefaults, resolveSettings, BUILT_IN_DEFAULTS } from './workspace/settingsResolver' -import { auditGitignore, appendToGitignore, isAuditDismissed, dismissAudit } from './git/gitignoreAudit' +import { + resolveAppDefaults, + resolveSettings, + BUILT_IN_DEFAULTS, +} from './workspace/settingsResolver' +import { + auditGitignore, + appendToGitignore, + isAuditDismissed, + dismissAudit, +} from './git/gitignoreAudit' import { TaskRunner } from './tasks/taskRunner' import { detectTasks, generateTasksFile, hasTasksFile } from './tasks/taskAutoDetect' import { WorkspaceRegistry } from './workspace/workspaceRegistry' @@ -51,13 +61,20 @@ import { resolveRepoRootForWorkspace, resolveWorkspaceIdForIpc, } from './workspace/workspaceRootResolution' -import { saveWorkspaceState, loadWorkspaceState, saveTerminalState, loadTerminalState } from './workspace/stateSerializer' +import { + saveWorkspaceState, + loadWorkspaceState, + saveTerminalState, + loadTerminalState, +} from './workspace/stateSerializer' import { BrowserPaneManager } from './browserPaneManager' import { registerGitDiffHandlers } from './git/gitDiff' import { AgentManager } from './chat/agentManager' import { CliAgentManager } from './chat/cliAgentManager' +import { ApprovalRouter } from './chat/approvalRouter' import { ConversationStore } from './chat/conversationStore' import { ClaudeNativeSessionWatcher } from './chat/claudeNativeSessionWatcher' +import { ThemeRegistry } from './themes/themeRegistry' import type { ChatMode, LlmProviderConfig, @@ -71,6 +88,9 @@ import type { const store = new Store({ defaults: DEFAULT_SETTINGS }) const workspaceRegistry = new WorkspaceRegistry() +const themeRegistry = new ThemeRegistry(store, (snapshot) => { + contentView?.webContents.send(IpcChannels.THEME_CHANGED, snapshot) +}) let mainWindow: BaseWindow | null = null let contentView: WebContentsView | null = null @@ -223,9 +243,7 @@ function createWindow(): void { if (process.env.ELECTRON_RENDERER_URL) { contentView.webContents.loadURL(process.env.ELECTRON_RENDERER_URL) } else { - contentView.webContents.loadFile( - join(__dirname, '../../renderer/dist/index.html'), - ) + contentView.webContents.loadFile(join(__dirname, '../../renderer/dist/index.html')) } // Forward fullscreen state to renderer @@ -256,11 +274,19 @@ ipcMain.on(IpcChannels.WINDOW_MAXIMIZE, () => { ipcMain.on(IpcChannels.WINDOW_CLOSE, () => mainWindow?.close()) // Theme IPC handlers -ipcMain.handle(IpcChannels.THEME_GET, () => store.get('theme')) -ipcMain.handle(IpcChannels.THEME_SET, (_event, theme: ThemeName) => { - store.set('theme', theme) - contentView?.webContents.send(IpcChannels.THEME_CHANGED, theme) -}) +ipcMain.handle(IpcChannels.THEME_GET, () => themeRegistry.getSnapshot()) +ipcMain.handle(IpcChannels.THEME_LIST, () => themeRegistry.listThemes()) +ipcMain.handle(IpcChannels.THEME_SET, (_event, themeId: ThemeId) => + themeRegistry.setActiveTheme(themeId), +) +ipcMain.handle(IpcChannels.THEME_SET_DEFAULT_DARK, (_event, themeId: ThemeId) => + themeRegistry.setDefaultTheme('dark', themeId), +) +ipcMain.handle(IpcChannels.THEME_SET_DEFAULT_LIGHT, (_event, themeId: ThemeId) => + themeRegistry.setDefaultTheme('light', themeId), +) +ipcMain.handle(IpcChannels.THEME_RELOAD, () => themeRegistry.reload()) +ipcMain.handle(IpcChannels.THEME_OPEN_DIRECTORY, () => themeRegistry.openThemesDirectory()) // Sidebar width IPC handlers ipcMain.handle(IpcChannels.SIDEBAR_WIDTH_GET, () => store.get('sidebarWidth')) @@ -268,7 +294,9 @@ ipcMain.handle(IpcChannels.SIDEBAR_WIDTH_SET, (_event, width: number) => { store.set('sidebarWidth', width) }) -ipcMain.handle(IpcChannels.BROWSER_ZOOM_GET, (_event, paneId: string) => browserPaneManager.getZoom(paneId)) +ipcMain.handle(IpcChannels.BROWSER_ZOOM_GET, (_event, paneId: string) => + browserPaneManager.getZoom(paneId), +) ipcMain.handle(IpcChannels.BROWSER_ZOOM_SET, (_event, paneId: string, zoomFactor: number) => browserPaneManager.setZoom(paneId, zoomFactor), ) @@ -324,7 +352,9 @@ function getConversationStore(runtime: WorkspaceRuntime | null): ConversationSto return (runtime?.services.conversationStore as ConversationStore | null) ?? null } -function getNativeSessionWatcher(runtime: WorkspaceRuntime | null): ClaudeNativeSessionWatcher | null { +function getNativeSessionWatcher( + runtime: WorkspaceRuntime | null, +): ClaudeNativeSessionWatcher | null { return (runtime?.services.nativeSessionWatcher as ClaudeNativeSessionWatcher | null) ?? null } @@ -334,36 +364,39 @@ async function loadPreferredClaudeMessages( conversationId: string, storedData?: unknown, ): Promise { - const stored = storedData ?? await conversationStore?.loadMessages(conversationId) + const stored = storedData ?? (await conversationStore?.loadMessages(conversationId)) const storedMessagesRaw = - stored && typeof stored === 'object' - ? (stored as { messages?: unknown }).messages - : undefined - const storedMessages = Array.isArray(storedMessagesRaw) ? (storedMessagesRaw as CliAgentMessage[]) : [] - const storedComparable = storedMessages.filter((message) => - message.type === 'user' || - message.type === 'assistant' || - message.type === 'tool_use' || - message.type === 'tool_result' + stored && typeof stored === 'object' ? (stored as { messages?: unknown }).messages : undefined + const storedMessages = Array.isArray(storedMessagesRaw) + ? (storedMessagesRaw as CliAgentMessage[]) + : [] + const storedComparable = storedMessages.filter( + (message) => + message.type === 'user' || + message.type === 'assistant' || + message.type === 'tool_use' || + message.type === 'tool_result', ).length const meta = await conversationStore?.get(conversationId) const claudeSessionId = meta?.claudeSessionId || - ( - stored && typeof stored === 'object' - ? (stored as { claudeSessionId?: unknown }).claudeSessionId - : undefined - ) + (stored && typeof stored === 'object' + ? ((stored as { claudeSessionId?: unknown }).claudeSessionId ?? + (stored as { backendStates?: { 'claude-code'?: { sessionId?: unknown } } }).backendStates?.[ + 'claude-code' + ]?.sessionId) + : undefined) if (typeof claudeSessionId === 'string' && nativeSessionWatcher) { try { const nativeMessages = await nativeSessionWatcher.loadMessages(claudeSessionId) - const nativeComparable = nativeMessages.filter((message) => - message.type === 'user' || - message.type === 'assistant' || - message.type === 'tool_use' || - message.type === 'tool_result' + const nativeComparable = nativeMessages.filter( + (message) => + message.type === 'user' || + message.type === 'assistant' || + message.type === 'tool_use' || + message.type === 'tool_result', ).length if (nativeMessages.length > 0 && nativeComparable > storedComparable) { return nativeMessages @@ -430,16 +463,20 @@ function loadLlmConfig(): LlmProviderConfig { apiKeyIsEnvRef: config.apiKey.includes('${env:'), baseUrl: config.baseUrl || '(default)', storeHasEditorDefaults: !!userDefaults, - storeKeys: Object.keys(userDefaults).filter(k => k.startsWith('agent.')), + storeKeys: Object.keys(userDefaults).filter((k) => k.startsWith('agent.')), }) return config } -function loadPermissionConfig(): { permissionTier: PermissionTier; autoApprove: Record } { +function loadPermissionConfig(): { + permissionTier: PermissionTier + autoApprove: Record +} { const userDefaults = (store.get('editorDefaults') ?? {}) as Record return { permissionTier: (userDefaults['agent.permissionTier'] as PermissionTier) || 'confirm', - autoApprove: (userDefaults['agent.autoApprove'] as Record) || {}, + autoApprove: + (userDefaults['agent.autoApprove'] as Record) || {}, } } @@ -469,21 +506,24 @@ async function startRuntimeServices(runtime: WorkspaceRuntime): Promise { nativeSessionCache = sessions runtime.setServices({ nativeSessionCache }) runtime.refreshWorkload() - void conversationStore.loadIndex().then((index) => { - const seen = new Set(index.map(c => c.id)) - const uniqueSessions = sessions.filter(s => !seen.has(s.id)) - contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { - workspaceId: runtime.workspaceId, - conversations: [...index, ...uniqueSessions], - source: 'claude-native', + void conversationStore + .loadIndex() + .then((index) => { + const seen = new Set(index.map((c) => c.id)) + const uniqueSessions = sessions.filter((s) => !seen.has(s.id)) + contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { + workspaceId: runtime.workspaceId, + conversations: [...index, ...uniqueSessions], + source: 'claude-native', + }) }) - }).catch(() => { - contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { - workspaceId: runtime.workspaceId, - conversations: sessions, - source: 'claude-native', + .catch(() => { + contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { + workspaceId: runtime.workspaceId, + conversations: sessions, + source: 'claude-native', + }) }) - }) }, }) await nativeSessionWatcher.start() @@ -509,19 +549,43 @@ async function startRuntimeServices(runtime: WorkspaceRuntime): Promise { const resolved = resolveAppDefaults(store) const cliAgentManager = new CliAgentManager({ workspaceRoot: rootPath, + workspaceId: runtime.workspaceId, getWebContents: () => contentView?.webContents ?? null, claudeCodePath: resolved['agent.claudeCodePath'], + opencodePath: resolved['agent.opencodePath'], codexPath: resolved['agent.codexPath'], conversationStore, - loadClaudeHistory: async (claudeSessionId: string) => nativeSessionWatcher.loadMessages(claudeSessionId), + loadClaudeHistory: async (claudeSessionId: string) => + nativeSessionWatcher.loadMessages(claudeSessionId), + permissionTier: permConfig.permissionTier, + autoApprove: permConfig.autoApprove, + opencodeDefaults: { + providerID: resolved['agent.opencode.defaultProvider'] || undefined, + modelID: resolved['agent.opencode.defaultModel'] || undefined, + agent: resolved['agent.opencode.defaultAgent'] || undefined, + mode: resolved['agent.opencode.defaultMode'] || undefined, + systemPromptOverride: resolved['agent.opencode.defaultSystemPrompt'] || undefined, + toolToggles: resolved['agent.opencode.defaultToolToggles'] ?? undefined, + }, + onWorkloadChanged: () => { + runtime.refreshWorkload() + }, }) + // ApprovalRouter dispatches CHAT_TOOL_APPROVE / CHAT_TOOL_REJECT to whichever + // manager owns the toolCallId, so OpenCode permission prompts and built-in + // chat approvals share a single approval surface. + const approvalRouter = new ApprovalRouter() + approvalRouter.register(agentManager) + approvalRouter.register(cliAgentManager) + runtime.setServices({ conversationStore, nativeSessionWatcher, nativeSessionCache, agentManager, cliAgentManager, + approvalRouter, }) const getWc = () => contentView?.webContents ?? null @@ -659,15 +723,18 @@ ipcMain.handle(IpcChannels.WORKSPACE_SWITCH, async (_event, id: string) => { await activateWorkspace(id) }) -ipcMain.handle(IpcChannels.WORKSPACE_UPDATE, (_event, id: string, patch: Partial<{ name: string; icon: string; color: string }>) => { - workspaceRegistry.update(id, patch) - const entry = workspaceRegistry.get(id) - if (entry) { - runtimeRegistry.get(id)?.syncEntry(entry) - } - broadcastWorkspaceRegistry() - broadcastRuntimeSnapshots() -}) +ipcMain.handle( + IpcChannels.WORKSPACE_UPDATE, + (_event, id: string, patch: Partial<{ name: string; icon: string; color: string }>) => { + workspaceRegistry.update(id, patch) + const entry = workspaceRegistry.get(id) + if (entry) { + runtimeRegistry.get(id)?.syncEntry(entry) + } + broadcastWorkspaceRegistry() + broadcastRuntimeSnapshots() + }, +) ipcMain.handle(IpcChannels.WORKSPACE_REORDER, (_event, ids: string[]) => { workspaceRegistry.reorder(ids) @@ -685,20 +752,26 @@ ipcMain.handle(IpcChannels.WORKSPACE_RUNTIME_SNAPSHOTS_GET, () => { // ─── Chat / Agent IPC handlers ───────────────────────────────────── -ipcMain.handle(IpcChannels.CHAT_SEND_MESSAGE, async (_event, sessionId: string, content: string) => { - const runtime = findRuntimeWithBuiltInSession(sessionId) - const agentManager = getAgentManager(runtime) - if (!agentManager) return { error: 'No workspace open' } - const result = await agentManager.sendMessage(sessionId, content) - runtime?.refreshWorkload() - return result -}) +ipcMain.handle( + IpcChannels.CHAT_SEND_MESSAGE, + async (_event, sessionId: string, payload: ChatComposerSubmission) => { + const runtime = findRuntimeWithBuiltInSession(sessionId) + const agentManager = getAgentManager(runtime) + if (!agentManager) return { error: 'No workspace open' } + const result = await agentManager.sendMessage(sessionId, payload) + runtime?.refreshWorkload() + return result + }, +) -ipcMain.handle(IpcChannels.CHAT_GET_HISTORY, async (_event, workspaceId: string, conversationId?: string) => { - const agentManager = getAgentManager(runtimeRegistry.get(workspaceId)) - if (!agentManager) return null - return agentManager.getHistory(workspaceId, conversationId) -}) +ipcMain.handle( + IpcChannels.CHAT_GET_HISTORY, + async (_event, workspaceId: string, conversationId?: string) => { + const agentManager = getAgentManager(runtimeRegistry.get(workspaceId)) + if (!agentManager) return null + return agentManager.getHistory(workspaceId, conversationId) + }, +) ipcMain.handle(IpcChannels.CHAT_PENDING_TOOL_APPROVALS_LIST, (): PendingToolApprovalInfo[] => { const out: PendingToolApprovalInfo[] = [] @@ -716,25 +789,41 @@ ipcMain.handle(IpcChannels.CHAT_SET_MODE, async (_event, sessionId: string, mode agentManager?.setMode(sessionId, mode) }) -ipcMain.handle(IpcChannels.CHAT_SET_WORKING_SET, async (_event, sessionId: string, paths: string[]) => { - const runtime = findRuntimeWithBuiltInSession(sessionId) - const agentManager = getAgentManager(runtime) - agentManager?.setWorkingSet(sessionId, paths) -}) +ipcMain.handle( + IpcChannels.CHAT_SET_WORKING_SET, + async (_event, sessionId: string, paths: string[]) => { + const runtime = findRuntimeWithBuiltInSession(sessionId) + const agentManager = getAgentManager(runtime) + agentManager?.setWorkingSet(sessionId, paths) + }, +) -ipcMain.handle(IpcChannels.CHAT_TOOL_APPROVE, async (_event, sessionId: string, toolCallId: string) => { - const runtime = findRuntimeWithBuiltInSession(sessionId) - const agentManager = getAgentManager(runtime) - agentManager?.approveToolCall(sessionId, toolCallId) - runtime?.refreshWorkload() -}) +ipcMain.handle( + IpcChannels.CHAT_TOOL_APPROVE, + async (_event, sessionId: string, toolCallId: string) => { + // Find the runtime that owns this toolCallId across both managers. + for (const runtime of runtimeRegistry.list()) { + const router = runtime.services.approvalRouter as ApprovalRouter | null + if (router?.approve(sessionId, toolCallId)) { + runtime.refreshWorkload() + return + } + } + }, +) -ipcMain.handle(IpcChannels.CHAT_TOOL_REJECT, async (_event, sessionId: string, toolCallId: string) => { - const runtime = findRuntimeWithBuiltInSession(sessionId) - const agentManager = getAgentManager(runtime) - agentManager?.rejectToolCall(sessionId, toolCallId) - runtime?.refreshWorkload() -}) +ipcMain.handle( + IpcChannels.CHAT_TOOL_REJECT, + async (_event, sessionId: string, toolCallId: string) => { + for (const runtime of runtimeRegistry.list()) { + const router = runtime.services.approvalRouter as ApprovalRouter | null + if (router?.reject(sessionId, toolCallId)) { + runtime.refreshWorkload() + return + } + } + }, +) ipcMain.on(IpcChannels.CHAT_STOP, (_event, sessionId: string) => { const runtime = findRuntimeWithBuiltInSession(sessionId) @@ -745,34 +834,61 @@ ipcMain.on(IpcChannels.CHAT_STOP, (_event, sessionId: string) => { // ─── CLI Agent IPC handlers ───────────────────────────────────── -ipcMain.handle(IpcChannels.CLI_AGENT_START, async (_event, workspaceId: string, backend: AgentBackend, conversationId?: string, worktreePath?: string) => { - const runtime = runtimeRegistry.get(workspaceId) - const cliAgentManager = getCliAgentManager(runtime) - if (!cliAgentManager) return { error: 'No workspace open' } - const result = await cliAgentManager.start(workspaceId, backend, conversationId, worktreePath) - runtime?.refreshWorkload() - return result -}) +ipcMain.handle( + IpcChannels.CLI_AGENT_START, + async ( + _event, + workspaceId: string, + backend: AgentBackend, + conversationId?: string, + worktreePath?: string, + ) => { + const runtime = runtimeRegistry.get(workspaceId) + const cliAgentManager = getCliAgentManager(runtime) + if (!cliAgentManager) return { error: 'No workspace open' } + const result = await cliAgentManager.start(workspaceId, backend, conversationId, worktreePath) + runtime?.refreshWorkload() + return result + }, +) -ipcMain.handle(IpcChannels.CLI_AGENT_SEND, async (_event, sessionId: string, content: string) => { - const runtime = findRuntimeWithCliSession(sessionId) - const cliAgentManager = getCliAgentManager(runtime) - if (!cliAgentManager) return { error: 'No workspace open' } - const result = await cliAgentManager.send(sessionId, content) - runtime?.refreshWorkload() - return result -}) +ipcMain.handle( + IpcChannels.CLI_AGENT_SWITCH_BACKEND, + async (_event, sessionId: string, backend: AgentBackend) => { + const runtime = findRuntimeWithCliSession(sessionId) + const cliAgentManager = getCliAgentManager(runtime) + if (!cliAgentManager) return { error: 'No workspace open' } + const result = await cliAgentManager.switchBackend(sessionId, backend) + runtime?.refreshWorkload() + return result + }, +) -ipcMain.handle(IpcChannels.CLI_AGENT_GET_SESSION, async (_event, workspaceId: string, sessionId?: string) => { - const cliAgentManager = getCliAgentManager(runtimeRegistry.get(workspaceId)) - if (!cliAgentManager) return null - if (sessionId) { - const s = cliAgentManager.getSessionById(sessionId) - if (!s || s.workspaceId !== workspaceId) return null - return s - } - return cliAgentManager.getSession(workspaceId) ?? null -}) +ipcMain.handle( + IpcChannels.CLI_AGENT_SEND, + async (_event, sessionId: string, payload: ChatComposerSubmission) => { + const runtime = findRuntimeWithCliSession(sessionId) + const cliAgentManager = getCliAgentManager(runtime) + if (!cliAgentManager) return { error: 'No workspace open' } + const result = await cliAgentManager.send(sessionId, payload) + runtime?.refreshWorkload() + return result + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_GET_SESSION, + async (_event, workspaceId: string, sessionId?: string) => { + const cliAgentManager = getCliAgentManager(runtimeRegistry.get(workspaceId)) + if (!cliAgentManager) return null + if (sessionId) { + const s = cliAgentManager.getSessionById(sessionId) + if (!s || s.workspaceId !== workspaceId) return null + return s + } + return cliAgentManager.getSession(workspaceId) ?? null + }, +) ipcMain.handle( IpcChannels.CLI_AGENT_LOAD_MESSAGES, @@ -784,15 +900,17 @@ ipcMain.handle( const nativeMeta = nativeSessionCache.find((c) => c.id === conversationId) ?? nativeSessionCache.find((c) => c.claudeSessionId === conversationId) - if (nativeMeta?.source === 'claude-native' && nativeMeta.claudeSessionId && nativeSessionWatcher) { + if ( + nativeMeta?.source === 'claude-native' && + nativeMeta.claudeSessionId && + nativeSessionWatcher + ) { return nativeSessionWatcher.loadMessages(nativeMeta.claudeSessionId) } const nativePrefix = 'claude-native:' if (conversationId.startsWith(nativePrefix) && nativeSessionWatcher) { const rawId = conversationId.slice(nativePrefix.length) - if ( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawId) - ) { + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawId)) { return nativeSessionWatcher.loadMessages(rawId) } } @@ -807,13 +925,257 @@ ipcMain.on(IpcChannels.CLI_AGENT_STOP, (_event, sessionId: string) => { runtime?.refreshWorkload() }) +// ─── CLI Agent: per-session config + provider/agent/mode/tool listings ── + +function withCliManager( + sessionId: string, + fn: (mgr: CliAgentManager) => Promise, +): Promise { + const runtime = findRuntimeWithCliSession(sessionId) + const mgr = getCliAgentManager(runtime) + if (!mgr) return Promise.resolve({ error: 'No workspace open' } as const) + return fn(mgr).catch((error) => ({ + error: error instanceof Error ? error.message : String(error), + })) +} + +ipcMain.handle( + IpcChannels.CLI_AGENT_UPDATE_SESSION_CONFIG, + async (_event, sessionId: string, patch: Record) => { + return withCliManager(sessionId, (mgr) => mgr.updateSessionConfig(sessionId, patch)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_LIST_PROVIDERS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.listOpenCodeProviders(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_LIST_AGENTS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.listOpenCodeAgents(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_LIST_MODES, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.listOpenCodeModes(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_LIST_TOOLS, + async (_event, sessionId: string, providerID: string, modelID: string) => { + return withCliManager(sessionId, (mgr) => mgr.listOpenCodeTools(sessionId, providerID, modelID)) + }, +) + +// ─── CLI Agent: session ops ───────────────────────────────────────────── + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_SHARE, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionShare(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_UNSHARE, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionUnshare(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_SUMMARIZE, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionSummarize(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_SESSION_REVERT, + async (_event, sessionId: string, messageId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionRevert(sessionId, messageId)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_UNREVERT, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionUnrevert(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_SESSION_FORK, + async (_event, sessionId: string, messageId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionFork(sessionId, messageId)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_ABORT, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionAbort(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_DIFF, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionDiff(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_TODO, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionTodo(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_INIT, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionInit(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_DELETE_REMOTE, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionDeleteRemote(sessionId)) +}) + +// ─── CLI Agent: workspace ops ─────────────────────────────────────────── + +ipcMain.handle(IpcChannels.CLI_AGENT_FILE_LIST, async (_event, sessionId: string, path: string) => { + return withCliManager(sessionId, (mgr) => mgr.fileList(sessionId, path)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_FILE_READ, async (_event, sessionId: string, path: string) => { + return withCliManager(sessionId, (mgr) => mgr.fileRead(sessionId, path)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_FILE_STATUS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.fileStatus(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_FIND_TEXT, + async (_event, sessionId: string, query: string) => { + return withCliManager(sessionId, (mgr) => mgr.findText(sessionId, query)) + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_FIND_FILES, + async (_event, sessionId: string, pattern: string) => { + return withCliManager(sessionId, (mgr) => mgr.findFiles(sessionId, pattern)) + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_FIND_SYMBOLS, + async (_event, sessionId: string, query: string) => { + return withCliManager(sessionId, (mgr) => mgr.findSymbols(sessionId, query)) + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_SHELL_RUN, + async (_event, sessionId: string, command: string) => { + return withCliManager(sessionId, (mgr) => mgr.shellRun(sessionId, command)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_LSP_STATUS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.lspStatus(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_FORMATTER_STATUS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.formatterStatus(sessionId)) +}) + +// ─── CLI Agent: config / auth / providers ─────────────────────────────── + +ipcMain.handle(IpcChannels.CLI_AGENT_CONFIG_GET, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.configGet(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_CONFIG_UPDATE, + async (_event, sessionId: string, patch: Record) => { + return withCliManager(sessionId, (mgr) => mgr.configUpdate(sessionId, patch)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_CONFIG_PROVIDERS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.configProviders(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_AUTH_SET, + async (_event, sessionId: string, key: string, value: string) => { + return withCliManager(sessionId, (mgr) => mgr.authSet(sessionId, key, value)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_PROVIDER_LIST, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.providerList(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_PROVIDER_AUTH, + async (_event, sessionId: string, providerId: string) => { + return withCliManager(sessionId, (mgr) => mgr.providerAuth(sessionId, providerId)) + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_PROVIDER_OAUTH_AUTHORIZE, + async (_event, sessionId: string, providerId: string) => { + return withCliManager(sessionId, (mgr) => mgr.providerOauthAuthorize(sessionId, providerId)) + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_PROVIDER_OAUTH_CALLBACK, + async (_event, sessionId: string, code: string) => { + return withCliManager(sessionId, (mgr) => mgr.providerOauthCallback(sessionId, code)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_PATH_GET, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.pathGet(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_LOG_WRITE, + async ( + _event, + sessionId: string, + message: string, + level?: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR', + ) => { + return withCliManager(sessionId, (mgr) => mgr.logWrite(sessionId, message, level)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_SERVER_INFO, async (_event, workspaceId: string) => { + const runtime = runtimeRegistry.get(workspaceId) + const mgr = getCliAgentManager(runtime) + return mgr?.serverInfo() ?? null +}) + +// ─── CLI Agent: TUI control ───────────────────────────────────────────── + +const TUI_HANDLERS: Array<{ + channel: string + method: + | 'appendPrompt' + | 'submitPrompt' + | 'clearPrompt' + | 'openHelp' + | 'openSessions' + | 'openThemes' + | 'openModels' + | 'executeCommand' + | 'showToast' +}> = [ + { channel: IpcChannels.CLI_AGENT_TUI_APPEND_PROMPT, method: 'appendPrompt' }, + { channel: IpcChannels.CLI_AGENT_TUI_SUBMIT_PROMPT, method: 'submitPrompt' }, + { channel: IpcChannels.CLI_AGENT_TUI_CLEAR_PROMPT, method: 'clearPrompt' }, + { channel: IpcChannels.CLI_AGENT_TUI_OPEN_HELP, method: 'openHelp' }, + { channel: IpcChannels.CLI_AGENT_TUI_OPEN_SESSIONS, method: 'openSessions' }, + { channel: IpcChannels.CLI_AGENT_TUI_OPEN_THEMES, method: 'openThemes' }, + { channel: IpcChannels.CLI_AGENT_TUI_OPEN_MODELS, method: 'openModels' }, + { channel: IpcChannels.CLI_AGENT_TUI_EXECUTE_COMMAND, method: 'executeCommand' }, + { channel: IpcChannels.CLI_AGENT_TUI_SHOW_TOAST, method: 'showToast' }, +] +for (const { channel, method } of TUI_HANDLERS) { + ipcMain.handle(channel, async (_event, sessionId: string, args?: Record) => { + return withCliManager(sessionId, (mgr) => mgr.tui(sessionId, method, args)) + }) +} + // ─── Conversation History IPC handlers ────────────────────────── ipcMain.handle(IpcChannels.CONVERSATION_LIST, async (_event, workspaceId: string) => { const runtime = runtimeRegistry.get(workspaceId) const conversationStore = getConversationStore(runtime) const nativeSessionCache = getNativeSessionCache(runtime) - const aideConvos = await conversationStore?.loadIndex() ?? [] + const aideConvos = (await conversationStore?.loadIndex()) ?? [] return [...aideConvos, ...nativeSessionCache] }) @@ -831,74 +1193,101 @@ ipcMain.handle(IpcChannels.CONVERSATION_CREATE, async (_event, opts: Conversatio return meta }) -ipcMain.handle(IpcChannels.CONVERSATION_DELETE, async (_event, workspaceId: string, conversationId: string) => { - const runtime = runtimeRegistry.get(workspaceId) - const conversationStore = getConversationStore(runtime) - const agentManager = getAgentManager(runtime) - const cliAgentManager = getCliAgentManager(runtime) - if (!conversationStore) return - const meta = await conversationStore.get(conversationId) - if (meta && meta.workspaceId !== workspaceId) return - await conversationStore.delete(conversationId) - agentManager?.stop(conversationId) - cliAgentManager?.stop(conversationId) - const nativeSessionCache = getNativeSessionCache(runtime) - if (meta) { - const index = await conversationStore.loadIndex() - contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { - workspaceId, - conversations: [...index, ...nativeSessionCache], - }) - } -}) - -ipcMain.handle(IpcChannels.CONVERSATION_RENAME, async (_event, workspaceId: string, conversationId: string, title: string) => { - const runtime = runtimeRegistry.get(workspaceId) - const conversationStore = getConversationStore(runtime) - if (!conversationStore) return - const existing = await conversationStore.get(conversationId) - if (!existing || existing.workspaceId !== workspaceId) return - await conversationStore.updateMeta(conversationId, { title, autoTitled: false, updatedAt: Date.now() }) - const meta = await conversationStore.get(conversationId) - if (meta) { - const index = await conversationStore.loadIndex() +ipcMain.handle( + IpcChannels.CONVERSATION_DELETE, + async (_event, workspaceId: string, conversationId: string) => { + const runtime = runtimeRegistry.get(workspaceId) + const conversationStore = getConversationStore(runtime) + const agentManager = getAgentManager(runtime) + const cliAgentManager = getCliAgentManager(runtime) + if (!conversationStore) return + const meta = await conversationStore.get(conversationId) + if (meta && meta.workspaceId !== workspaceId) return + await conversationStore.delete(conversationId) + agentManager?.stop(conversationId) + cliAgentManager?.stop(conversationId) const nativeSessionCache = getNativeSessionCache(runtime) - contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { - workspaceId, - conversations: [...index, ...nativeSessionCache], + if (meta) { + const index = await conversationStore.loadIndex() + contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { + workspaceId, + conversations: [...index, ...nativeSessionCache], + }) + } + }, +) + +ipcMain.handle( + IpcChannels.CONVERSATION_RENAME, + async (_event, workspaceId: string, conversationId: string, title: string) => { + const runtime = runtimeRegistry.get(workspaceId) + const conversationStore = getConversationStore(runtime) + if (!conversationStore) return + const existing = await conversationStore.get(conversationId) + if (!existing || existing.workspaceId !== workspaceId) return + await conversationStore.updateMeta(conversationId, { + title, + autoTitled: false, + updatedAt: Date.now(), }) - } -}) + const meta = await conversationStore.get(conversationId) + if (meta) { + const index = await conversationStore.loadIndex() + const nativeSessionCache = getNativeSessionCache(runtime) + contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { + workspaceId, + conversations: [...index, ...nativeSessionCache], + }) + } + }, +) -ipcMain.handle(IpcChannels.CONVERSATION_GET, async (_event, workspaceId: string, conversationId: string) => { - const runtime = runtimeRegistry.get(workspaceId) - const conversationStore = getConversationStore(runtime) - const meta = await conversationStore?.get(conversationId) - if (meta && meta.workspaceId !== workspaceId) return null - return meta ?? null -}) +ipcMain.handle( + IpcChannels.CONVERSATION_GET, + async (_event, workspaceId: string, conversationId: string) => { + const runtime = runtimeRegistry.get(workspaceId) + const conversationStore = getConversationStore(runtime) + const meta = await conversationStore?.get(conversationId) + if (meta && meta.workspaceId !== workspaceId) return null + return meta ?? null + }, +) // State persistence IPC handlers -ipcMain.handle(IpcChannels.STATE_SAVE, async (_event, rootPath: string, state: import('@aide/shared').AideLocalState) => { - await saveWorkspaceState(rootPath, state) -}) +ipcMain.handle( + IpcChannels.STATE_SAVE, + async (_event, rootPath: string, state: import('@aide/shared').AideLocalState) => { + await saveWorkspaceState(rootPath, state) + }, +) ipcMain.handle(IpcChannels.STATE_LOAD, async (_event, rootPath: string) => { return loadWorkspaceState(rootPath) }) -ipcMain.handle(IpcChannels.STATE_SAVE_TERMINALS, async (_event, rootPath: string, state: import('@aide/shared').AideLocalTerminals) => { - await saveTerminalState(rootPath, state) -}) +ipcMain.handle( + IpcChannels.STATE_SAVE_TERMINALS, + async (_event, rootPath: string, state: import('@aide/shared').AideLocalTerminals) => { + await saveTerminalState(rootPath, state) + }, +) ipcMain.handle(IpcChannels.STATE_LOAD_TERMINALS, async (_event, rootPath: string) => { return loadTerminalState(rootPath) }) // Browser pane IPC handlers -ipcMain.handle(IpcChannels.BROWSER_CREATE, (_event, paneId: string, workspaceId: string, sessionMode: import('@aide/shared').BrowserSessionMode) => { - return browserPaneManager.create(paneId, workspaceId, sessionMode) -}) +ipcMain.handle( + IpcChannels.BROWSER_CREATE, + ( + _event, + paneId: string, + workspaceId: string, + sessionMode: import('@aide/shared').BrowserSessionMode, + ) => { + return browserPaneManager.create(paneId, workspaceId, sessionMode) + }, +) ipcMain.on(IpcChannels.BROWSER_DESTROY, (_event, paneId: string) => { browserPaneManager.destroy(paneId) @@ -924,9 +1313,12 @@ ipcMain.on(IpcChannels.BROWSER_RELOAD, (_event, paneId: string) => { browserPaneManager.reload(paneId) }) -ipcMain.on(IpcChannels.BROWSER_HOST_UPDATE, (_event, update: import('@aide/shared').BrowserHostUpdate) => { - browserPaneManager.handleHostUpdate(update) -}) +ipcMain.on( + IpcChannels.BROWSER_HOST_UPDATE, + (_event, update: import('@aide/shared').BrowserHostUpdate) => { + browserPaneManager.handleHostUpdate(update) + }, +) ipcMain.on(IpcChannels.BROWSER_SUPPRESS_OVERLAYS, () => { browserPaneManager.suppressOverlays() @@ -966,11 +1358,14 @@ ipcMain.handle(IpcChannels.AIDE_INIT, async (_event, workspaceId?: string | null }) // .aide settings IPC handler -ipcMain.handle(IpcChannels.AIDE_GET_RESOLVED_SETTINGS, async (_event, workspaceId?: string | null) => { - const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) - if (!rootPath) return resolveAppDefaults(store) - return resolveSettings(rootPath, store) -}) +ipcMain.handle( + IpcChannels.AIDE_GET_RESOLVED_SETTINGS, + async (_event, workspaceId?: string | null) => { + const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) + if (!rootPath) return resolveAppDefaults(store) + return resolveSettings(rootPath, store) + }, +) // Settings IPC handlers ipcMain.handle(IpcChannels.SETTINGS_GET_DEFAULTS, () => BUILT_IN_DEFAULTS) @@ -982,9 +1377,7 @@ ipcMain.handle(IpcChannels.SETTINGS_GET_USER, () => { ipcMain.handle(IpcChannels.SETTINGS_SET_USER, async (_event, key: string, value: unknown) => { let current = (store.get('editorDefaults') ?? {}) as Record if (value === undefined || value === null) { - current = Object.fromEntries( - Object.entries(current).filter(([entryKey]) => entryKey !== key), - ) + current = Object.fromEntries(Object.entries(current).filter(([entryKey]) => entryKey !== key)) } else { current[key] = value } @@ -994,26 +1387,44 @@ ipcMain.handle(IpcChannels.SETTINGS_SET_USER, async (_event, key: string, value: if (key.startsWith('agent.')) { const config = loadLlmConfig() const permConfig = loadPermissionConfig() + const appDefs = resolveAppDefaults(store) for (const runtime of runtimeRegistry.list()) { getAgentManager(runtime)?.updateConfig(config) getAgentManager(runtime)?.updatePermissions(permConfig.permissionTier, permConfig.autoApprove) + // Mirror permission updates into the CLI agent manager so OpenCode + // permission decisions stay in sync with live tier changes. + const cm = getCliAgentManager(runtime) + cm?.updatePermissions(permConfig.permissionTier, permConfig.autoApprove) + // Refresh OpenCode session-default seeds whenever any opencode default + // is touched. New sessions started afterwards inherit the new values; + // existing sessions retain their per-session overrides. + cm?.updateOpencodeDefaults({ + providerID: appDefs['agent.opencode.defaultProvider'] || undefined, + modelID: appDefs['agent.opencode.defaultModel'] || undefined, + agent: appDefs['agent.opencode.defaultAgent'] || undefined, + mode: appDefs['agent.opencode.defaultMode'] || undefined, + systemPromptOverride: appDefs['agent.opencode.defaultSystemPrompt'] || undefined, + toolToggles: appDefs['agent.opencode.defaultToolToggles'] ?? undefined, + }) runtime.refreshWorkload() } } // Push CLI agent path updates - if (key === 'agent.claudeCodePath' || key === 'agent.codexPath') { + if (key === 'agent.claudeCodePath' || key === 'agent.opencodePath' || key === 'agent.codexPath') { const appDefs = resolveAppDefaults(store) for (const runtime of runtimeRegistry.list()) { - getCliAgentManager(runtime)?.updatePaths(appDefs['agent.claudeCodePath'], appDefs['agent.codexPath']) + getCliAgentManager(runtime)?.updatePaths( + appDefs['agent.claudeCodePath'], + appDefs['agent.opencodePath'], + appDefs['agent.codexPath'], + ) } } // Phase 8: only allowlisted implicit-active use — merged project settings for the focused workspace const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, undefined) - const resolved = rootPath - ? await resolveSettings(rootPath, store) - : resolveAppDefaults(store) + const resolved = rootPath ? await resolveSettings(rootPath, store) : resolveAppDefaults(store) contentView?.webContents.send(IpcChannels.SETTINGS_CHANGED, resolved) }) @@ -1030,51 +1441,57 @@ ipcMain.handle(IpcChannels.SETTINGS_GET_WORKSPACE, async (_event, workspaceId?: } }) -ipcMain.handle(IpcChannels.SETTINGS_SET_WORKSPACE, async (_event, key: string, value: unknown, workspaceId?: string | null) => { - // Block sensitive agent keys from being written to project-level settings - if (SENSITIVE_AGENT_KEYS.has(key)) return - - const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) - if (!rootPath) return - - const settingsPath = join(rootPath, '.aide', 'settings.json') - - // Ensure .aide directory exists - const aideDir = join(rootPath, '.aide') - if (!existsSync(aideDir)) await mkdir(aideDir, { recursive: true }) - - // Read existing settings - let current: Record = {} - if (existsSync(settingsPath)) { - try { - const raw = await readFile(settingsPath, 'utf-8') - current = JSON.parse(raw) - } catch { - current = {} +ipcMain.handle( + IpcChannels.SETTINGS_SET_WORKSPACE, + async (_event, key: string, value: unknown, workspaceId?: string | null) => { + // Block sensitive agent keys from being written to project-level settings + if (SENSITIVE_AGENT_KEYS.has(key)) return + + const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) + if (!rootPath) return + + const settingsPath = join(rootPath, '.aide', 'settings.json') + + // Ensure .aide directory exists + const aideDir = join(rootPath, '.aide') + if (!existsSync(aideDir)) await mkdir(aideDir, { recursive: true }) + + // Read existing settings + let current: Record = {} + if (existsSync(settingsPath)) { + try { + const raw = await readFile(settingsPath, 'utf-8') + current = JSON.parse(raw) + } catch { + current = {} + } } - } - if (value === undefined || value === null) { - current = Object.fromEntries( - Object.entries(current).filter(([entryKey]) => entryKey !== key), - ) - } else { - current[key] = value - } + if (value === undefined || value === null) { + current = Object.fromEntries(Object.entries(current).filter(([entryKey]) => entryKey !== key)) + } else { + current[key] = value + } - await fsWriteFile(settingsPath, JSON.stringify(current, null, 2) + '\n', 'utf-8') + await fsWriteFile(settingsPath, JSON.stringify(current, null, 2) + '\n', 'utf-8') - // Broadcast resolved settings - const resolved = await resolveSettings(rootPath, store) - contentView?.webContents.send(IpcChannels.SETTINGS_CHANGED, resolved) -}) + // Broadcast resolved settings + const resolved = await resolveSettings(rootPath, store) + contentView?.webContents.send(IpcChannels.SETTINGS_CHANGED, resolved) + }, +) // Keybinding overrides IPC handlers // Migrate old Record format to KeybindingRule[] on first read -function migrateKeybindingOverrides(stored: unknown): { key: string; command: string; when?: string }[] { +function migrateKeybindingOverrides( + stored: unknown, +): { key: string; command: string; when?: string }[] { if (Array.isArray(stored)) return stored if (stored && typeof stored === 'object' && !Array.isArray(stored)) { - const migrated = Object.entries(stored as Record).map(([command, key]) => ({ key, command })) + const migrated = Object.entries(stored as Record).map(([command, key]) => ({ + key, + command, + })) store.set('keybindingOverrides', migrated) return migrated } @@ -1085,10 +1502,13 @@ ipcMain.handle(IpcChannels.KEYBINDINGS_GET, () => { return migrateKeybindingOverrides(store.get('keybindingOverrides')) }) -ipcMain.handle(IpcChannels.KEYBINDINGS_SET, async (_event, rules: { key: string; command: string; when?: string }[]) => { - store.set('keybindingOverrides', rules) - contentView?.webContents.send(IpcChannels.KEYBINDINGS_CHANGED, rules) -}) +ipcMain.handle( + IpcChannels.KEYBINDINGS_SET, + async (_event, rules: { key: string; command: string; when?: string }[]) => { + store.set('keybindingOverrides', rules) + contentView?.webContents.send(IpcChannels.KEYBINDINGS_CHANGED, rules) + }, +) // Gitignore security audit IPC handlers ipcMain.handle(IpcChannels.GITIGNORE_AUDIT, async (_event, workspaceId?: string | null) => { @@ -1097,11 +1517,14 @@ ipcMain.handle(IpcChannels.GITIGNORE_AUDIT, async (_event, workspaceId?: string return auditGitignore(rootPath) }) -ipcMain.handle(IpcChannels.GITIGNORE_APPEND, async (_event, patterns: string[], workspaceId?: string | null) => { - const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) - if (!rootPath) return - await appendToGitignore(rootPath, patterns) -}) +ipcMain.handle( + IpcChannels.GITIGNORE_APPEND, + async (_event, patterns: string[], workspaceId?: string | null) => { + const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) + if (!rootPath) return + await appendToGitignore(rootPath, patterns) + }, +) ipcMain.handle(IpcChannels.GITIGNORE_DISMISS, async (_event, workspaceId?: string | null) => { const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) @@ -1226,25 +1649,28 @@ ipcMain.handle(IpcChannels.TASK_LIST_RUNNING, async (_event, workspaceId: string return taskRunner?.getRunning() ?? [] }) -ipcMain.handle(IpcChannels.TASK_RUN, async (_event, workspaceId: string, taskId: string, context?: TaskRunContext) => { - const runtime = runtimeRegistry.get(workspaceId) - const taskRunner = getTaskRunner(runtime) - if (!taskRunner) return { error: 'No workspace open' } - const rootPath = runtime?.rootPath ?? null - if (!rootPath) return { error: 'No workspace open' } - - const eff = getEffectiveWorkspaceRoot(workspaceId, rootPath) ?? rootPath - const ctx = { - workspaceRoot: eff, - workspaceName: eff.split('/').pop() ?? eff, - activeFile: context?.activeFile, - selectedText: context?.selectedText, - lineNumber: context?.lineNumber, - } - const result = await taskRunner.run(taskId, ctx) - runtime?.refreshWorkload() - return result -}) +ipcMain.handle( + IpcChannels.TASK_RUN, + async (_event, workspaceId: string, taskId: string, context?: TaskRunContext) => { + const runtime = runtimeRegistry.get(workspaceId) + const taskRunner = getTaskRunner(runtime) + if (!taskRunner) return { error: 'No workspace open' } + const rootPath = runtime?.rootPath ?? null + if (!rootPath) return { error: 'No workspace open' } + + const eff = getEffectiveWorkspaceRoot(workspaceId, rootPath) ?? rootPath + const ctx = { + workspaceRoot: eff, + workspaceName: eff.split('/').pop() ?? eff, + activeFile: context?.activeFile, + selectedText: context?.selectedText, + lineNumber: context?.lineNumber, + } + const result = await taskRunner.run(taskId, ctx) + runtime?.refreshWorkload() + return result + }, +) ipcMain.on(IpcChannels.TASK_KILL, (_event, workspaceId: string, executionId: string) => { const runtime = runtimeRegistry.get(workspaceId) @@ -1258,12 +1684,15 @@ ipcMain.handle(IpcChannels.TASK_RELOAD, async (_event, workspaceId: string) => { await taskRunner?.loadTasks() }) -ipcMain.on(IpcChannels.TASK_PROVIDE_INPUT, (_event, workspaceId: string, requestId: string, value: string | null) => { - const runtime = runtimeRegistry.get(workspaceId) - const taskRunner = getTaskRunner(runtime) - taskRunner?.provideInput(requestId, value) - runtime?.refreshWorkload() -}) +ipcMain.on( + IpcChannels.TASK_PROVIDE_INPUT, + (_event, workspaceId: string, requestId: string, value: string | null) => { + const runtime = runtimeRegistry.get(workspaceId) + const taskRunner = getTaskRunner(runtime) + taskRunner?.provideInput(requestId, value) + runtime?.refreshWorkload() + }, +) ipcMain.handle(IpcChannels.TASK_GENERATE, async (_event, workspaceId: string) => { const entry = workspaceRegistry.get(workspaceId) @@ -1324,120 +1753,152 @@ ipcMain.on(IpcChannels.TASK_FILE_SAVED, (_event, filePath: string) => { // Filesystem IPC handlers const HIDDEN_FILES = new Set(['.DS_Store', 'Thumbs.db']) -ipcMain.handle(IpcChannels.FS_READ_DIR, async (_event, dirPath: string): Promise => { - try { - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped: DirEntry[] = entries - .filter((e) => !HIDDEN_FILES.has(e.name)) - .map((e) => ({ - name: e.name, - path: join(dirPath, e.name), - isDirectory: e.isDirectory(), - })) - // Sort: directories first, then alphabetical (case-insensitive) - mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 - return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) - }) - return mapped - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error reading directory' - return { error: message } - } -}) +ipcMain.handle( + IpcChannels.FS_READ_DIR, + async (_event, dirPath: string): Promise => { + try { + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped: DirEntry[] = entries + .filter((e) => !HIDDEN_FILES.has(e.name)) + .map((e) => ({ + name: e.name, + path: join(dirPath, e.name), + isDirectory: e.isDirectory(), + })) + // Sort: directories first, then alphabetical (case-insensitive) + mapped.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + }) + return mapped + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error reading directory' + return { error: message } + } + }, +) // Read file IPC handler — enforces 10 MB limit, rejects binary files const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB -ipcMain.handle(IpcChannels.FS_READ_FILE, async (_event, filePath: string): Promise<{ content: string } | { error: string }> => { - try { - const info = await stat(filePath) - if (!info.isFile()) return { error: 'Not a file' } - if (info.size > MAX_FILE_SIZE) return { error: `File too large (${(info.size / 1024 / 1024).toFixed(1)} MB). Maximum is 10 MB.` } +ipcMain.handle( + IpcChannels.FS_READ_FILE, + async (_event, filePath: string): Promise<{ content: string } | { error: string }> => { + try { + const info = await stat(filePath) + if (!info.isFile()) return { error: 'Not a file' } + if (info.size > MAX_FILE_SIZE) + return { + error: `File too large (${(info.size / 1024 / 1024).toFixed(1)} MB). Maximum is 10 MB.`, + } - const content = await readFile(filePath, 'utf-8') + const content = await readFile(filePath, 'utf-8') - // Check for binary content (null bytes in first 8 KB) - const sample = content.slice(0, 8192) - if (sample.includes('\0')) return { error: 'Binary file — cannot display' } + // Check for binary content (null bytes in first 8 KB) + const sample = content.slice(0, 8192) + if (sample.includes('\0')) return { error: 'Binary file — cannot display' } - return { content } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error reading file' - return { error: message } - } -}) + return { content } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error reading file' + return { error: message } + } + }, +) // Write file IPC handler -ipcMain.handle(IpcChannels.FS_WRITE_FILE, async (_event, filePath: string, content: string): Promise<{ success: true } | { error: string }> => { - try { - await fsWriteFile(filePath, content, 'utf-8') - return { success: true } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error writing file' - return { error: message } - } -}) +ipcMain.handle( + IpcChannels.FS_WRITE_FILE, + async ( + _event, + filePath: string, + content: string, + ): Promise<{ success: true } | { error: string }> => { + try { + await fsWriteFile(filePath, content, 'utf-8') + return { success: true } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error writing file' + return { error: message } + } + }, +) // Create file IPC handler -ipcMain.handle(IpcChannels.FS_CREATE_FILE, async (_event, filePath: string): Promise<{ success: true } | { error: string }> => { - try { - // Check if already exists +ipcMain.handle( + IpcChannels.FS_CREATE_FILE, + async (_event, filePath: string): Promise<{ success: true } | { error: string }> => { try { - await stat(filePath) - return { error: 'File already exists' } - } catch { - // Expected — file doesn't exist yet + // Check if already exists + try { + await stat(filePath) + return { error: 'File already exists' } + } catch { + // Expected — file doesn't exist yet + } + // Ensure parent directory exists + await mkdir(dirname(filePath), { recursive: true }) + await fsWriteFile(filePath, '', 'utf-8') + return { success: true } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error creating file' + return { error: message } } - // Ensure parent directory exists - await mkdir(dirname(filePath), { recursive: true }) - await fsWriteFile(filePath, '', 'utf-8') - return { success: true } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error creating file' - return { error: message } - } -}) + }, +) // Create directory IPC handler -ipcMain.handle(IpcChannels.FS_CREATE_DIR, async (_event, dirPath: string): Promise<{ success: true } | { error: string }> => { - try { - await mkdir(dirPath) - return { success: true } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error creating directory' - return { error: message } - } -}) +ipcMain.handle( + IpcChannels.FS_CREATE_DIR, + async (_event, dirPath: string): Promise<{ success: true } | { error: string }> => { + try { + await mkdir(dirPath) + return { success: true } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error creating directory' + return { error: message } + } + }, +) // Delete file or directory IPC handler -ipcMain.handle(IpcChannels.FS_DELETE, async (_event, entryPath: string): Promise<{ success: true } | { error: string }> => { - try { - await rm(entryPath, { recursive: true }) - return { success: true } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error deleting' - return { error: message } - } -}) +ipcMain.handle( + IpcChannels.FS_DELETE, + async (_event, entryPath: string): Promise<{ success: true } | { error: string }> => { + try { + await rm(entryPath, { recursive: true }) + return { success: true } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error deleting' + return { error: message } + } + }, +) // Rename file or directory IPC handler -ipcMain.handle(IpcChannels.FS_RENAME, async (_event, oldPath: string, newPath: string): Promise<{ success: true } | { error: string }> => { - try { - // Check if target already exists +ipcMain.handle( + IpcChannels.FS_RENAME, + async ( + _event, + oldPath: string, + newPath: string, + ): Promise<{ success: true } | { error: string }> => { try { - await stat(newPath) - return { error: 'A file or folder with that name already exists' } - } catch { - // Expected — target doesn't exist + // Check if target already exists + try { + await stat(newPath) + return { error: 'A file or folder with that name already exists' } + } catch { + // Expected — target doesn't exist + } + await rename(oldPath, newPath) + return { success: true } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error renaming' + return { error: message } } - await rename(oldPath, newPath) - return { success: true } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error renaming' - return { error: message } - } -}) + }, +) // Reveal in Finder / file manager ipcMain.on(IpcChannels.FS_REVEAL_IN_FINDER, (_event, filePath: string) => { @@ -1446,11 +1907,7 @@ ipcMain.on(IpcChannels.FS_REVEAL_IN_FINDER, (_event, filePath: string) => { ipcMain.handle( IpcChannels.OPEN_IN_VSCODE, - async ( - _event, - rootPath: string, - files?: Array<{ path: string; line: number; col: number }>, - ) => { + async (_event, rootPath: string, files?: Array<{ path: string; line: number; col: number }>) => { const runCode = (args: string[]) => new Promise((resolve, reject) => { execFile('code', args, (err) => { @@ -1474,50 +1931,62 @@ ipcMain.handle( ) // List all files (quick open) — uses `git ls-files` for speed, falls back to recursive readdir -ipcMain.handle(IpcChannels.FS_LIST_ALL_FILES, async (_event, rootPath: string): Promise => { - // Try git ls-files first (fast, respects .gitignore) - if (existsSync(join(rootPath, '.git'))) { - try { - const files = await new Promise((resolve, reject) => { - execFile('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { cwd: rootPath, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { - if (err) return reject(err) - resolve(stdout.trim().split('\n').filter(Boolean)) +ipcMain.handle( + IpcChannels.FS_LIST_ALL_FILES, + async (_event, rootPath: string): Promise => { + // Try git ls-files first (fast, respects .gitignore) + if (existsSync(join(rootPath, '.git'))) { + try { + const files = await new Promise((resolve, reject) => { + execFile( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard'], + { cwd: rootPath, maxBuffer: 10 * 1024 * 1024 }, + (err, stdout) => { + if (err) return reject(err) + resolve(stdout.trim().split('\n').filter(Boolean)) + }, + ) }) - }) - return files - } catch { - // fall through to readdir + return files + } catch { + // fall through to readdir + } } - } - // Fallback: recursive readdir - const SKIP = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'out', '__pycache__']) - const results: string[] = [] - - /** - * Recursively traverses `dir` and appends discovered file paths (relative to `rootPath`) to the module-level `results` array. - * - * The walk skips entries whose names are in `SKIP` or that start with a dot. If `dir` cannot be read, the function returns without side effects. - * - * @param dir - The directory path to traverse - */ - function walk(dir: string) { - let entries - try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return } - for (const entry of entries) { - if (SKIP.has(entry.name) || entry.name.startsWith('.')) continue - const full = join(dir, entry.name) - if (entry.isDirectory()) { - walk(full) - } else { - results.push(relative(rootPath, full)) + // Fallback: recursive readdir + const SKIP = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'out', '__pycache__']) + const results: string[] = [] + + /** + * Recursively traverses `dir` and appends discovered file paths (relative to `rootPath`) to the module-level `results` array. + * + * The walk skips entries whose names are in `SKIP` or that start with a dot. If `dir` cannot be read, the function returns without side effects. + * + * @param dir - The directory path to traverse + */ + function walk(dir: string) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (SKIP.has(entry.name) || entry.name.startsWith('.')) continue + const full = join(dir, entry.name) + if (entry.isDirectory()) { + walk(full) + } else { + results.push(relative(rootPath, full)) + } } } - } - walk(rootPath) - return results -}) + walk(rootPath) + return results + }, +) // Search (find in files) — ripgrep-backed ipcMain.handle(IpcChannels.SEARCH_START, async (_event, opts: SearchOpts) => { @@ -1554,12 +2023,21 @@ ipcMain.handle(IpcChannels.SEARCH_REPLACE, async (_event, opts: ReplaceOpts) => for (const rep of sorted) { const lineIdx = rep.line - 1 - if (lineIdx < 0 || lineIdx >= lines.length) { skipped++; continue } + if (lineIdx < 0 || lineIdx >= lines.length) { + skipped++ + continue + } const line = lines[lineIdx] const colIdx = rep.column - 1 - if (colIdx < 0 || colIdx > line.length) { skipped++; continue } + if (colIdx < 0 || colIdx > line.length) { + skipped++ + continue + } const actual = line.slice(colIdx, colIdx + rep.matchText.length) - if (actual !== rep.matchText) { skipped++; continue } + if (actual !== rep.matchText) { + skipped++ + continue + } const before = line.slice(0, colIdx) const after = line.slice(colIdx + rep.matchText.length) lines[lineIdx] = before + rep.replaceText + after @@ -1586,14 +2064,19 @@ app.whenReady().then(async () => { const wasCleanShutdown = store.get('cleanShutdown') store.set('cleanShutdown', false) + await themeRegistry.reload() + buildAppMenu() createWindow() - registerPtyHandlers(() => contentView?.webContents ?? null, (workspaceId) => { - const entry = workspaceRegistry.get(workspaceId) - const root = entry?.rootPath ?? null - if (!root) return null - return getEffectiveWorkspaceRoot(workspaceId, root) - }) + registerPtyHandlers( + () => contentView?.webContents ?? null, + (workspaceId) => { + const entry = workspaceRegistry.get(workspaceId) + const root = entry?.rootPath ?? null + if (!root) return null + return getEffectiveWorkspaceRoot(workspaceId, root) + }, + ) registerFileWatcherHandlers(() => contentView?.webContents ?? null) const getWebContents = () => contentView?.webContents ?? null @@ -1652,7 +2135,9 @@ app.on('before-quit', (event) => { wc.send(IpcChannels.LIFECYCLE_REQUEST_SAVE) // Wait for renderer to confirm save, or timeout after 2s - const saveTimeout = setTimeout(() => { void finishQuit() }, 2000) + const saveTimeout = setTimeout(() => { + void finishQuit() + }, 2000) ipcMain.once(IpcChannels.LIFECYCLE_SAVE_COMPLETE, () => { clearTimeout(saveTimeout) diff --git a/packages/main/src/preload.ts b/packages/main/src/preload.ts index 16f1a42..99ebe71 100644 --- a/packages/main/src/preload.ts +++ b/packages/main/src/preload.ts @@ -1,17 +1,66 @@ import { contextBridge, ipcRenderer } from 'electron' import { IpcChannels } from '@aide/shared' import type { - ThemeName, FsWatchEvent, GitStatusResult, GitignoreAuditResult, WorktreeInfo, WorktreeCreateOpts, SearchOpts, - ReplaceOpts, ResolvedSettings, - AideProjectSettings, AideInitResult, AideTask, CompoundTask, TaskExecution, TaskInputRequest, TaskRunContext, - TaskTriggerResult, WorkspaceEntry, AideLocalState, AideLocalTerminals, WindowApi, BrowserSessionMode, - BrowserHostUpdate, BrowserDidNavigatePayload, BrowserPageTitlePayload, BrowserLoadingPayload, BrowserCanNavigatePayload, - BrowserFocusPayload, ZoomCommandPayload, KeybindingRule, ChatMode, ChatSession, ChatStreamChunk, ChatStreamEnd, - ChatToolCallPayload, PendingToolApprovalInfo, McpServerStatus, ToolDefinition, AgentBackend, CliAgentStreamDelta, CliAgentMessage, CliAgentSession, - CliAgentStatusPayload, CliAgentResultPayload, CliAgentMessagePayload, ConversationMeta, ConversationCreateOpts, - ConversationListChangedPayload, GitStatusChangedPayload, GitBranchChangedPayload, WorktreeListChangedPayload, - SearchResultsPayload, SearchCompletePayload, GitignoreAuditIpcPayload, TaskDiagnosticsPayload, TaskAutoDetectPayload, - PtyDataOutPayload, PtyExitPayload, + ThemeDefinition, + ThemeId, + ThemeStateSnapshot, + FsWatchEvent, + GitStatusResult, + GitignoreAuditResult, + WorktreeInfo, + WorktreeCreateOpts, + SearchOpts, + ReplaceOpts, + ResolvedSettings, + AideProjectSettings, + AideInitResult, + AideTask, + CompoundTask, + TaskExecution, + TaskInputRequest, + TaskRunContext, + TaskTriggerResult, + WorkspaceEntry, + AideLocalState, + AideLocalTerminals, + WindowApi, + BrowserSessionMode, + BrowserHostUpdate, + BrowserDidNavigatePayload, + BrowserPageTitlePayload, + BrowserLoadingPayload, + BrowserCanNavigatePayload, + BrowserFocusPayload, + ZoomCommandPayload, + KeybindingRule, + ChatMode, + ChatSession, + ChatStreamChunk, + ChatStreamEnd, + ChatToolCallPayload, + PendingToolApprovalInfo, + McpServerStatus, + ToolDefinition, + AgentBackend, + CliAgentStreamDelta, + CliAgentMessage, + CliAgentSession, + CliAgentStatusPayload, + CliAgentResultPayload, + CliAgentMessagePayload, + ConversationMeta, + ConversationCreateOpts, + ConversationListChangedPayload, + GitStatusChangedPayload, + GitBranchChangedPayload, + WorktreeListChangedPayload, + SearchResultsPayload, + SearchCompletePayload, + GitignoreAuditIpcPayload, + TaskDiagnosticsPayload, + TaskAutoDetectPayload, + PtyDataOutPayload, + PtyExitPayload, } from '@aide/shared' const api: WindowApi = { @@ -21,27 +70,40 @@ const api: WindowApi = { closeWindow: () => ipcRenderer.send(IpcChannels.WINDOW_CLOSE), // Theme - getTheme: (): Promise => ipcRenderer.invoke(IpcChannels.THEME_GET), - setTheme: (theme: ThemeName): Promise => ipcRenderer.invoke(IpcChannels.THEME_SET, theme), - onThemeChanged: (callback: (theme: ThemeName) => void) => { - const handler = (_event: Electron.IpcRendererEvent, theme: ThemeName) => callback(theme) + getThemeState: (): Promise => ipcRenderer.invoke(IpcChannels.THEME_GET), + listThemes: (): Promise => ipcRenderer.invoke(IpcChannels.THEME_LIST), + setTheme: (themeId: ThemeId): Promise => + ipcRenderer.invoke(IpcChannels.THEME_SET, themeId).then(() => undefined), + setDefaultDarkTheme: (themeId: ThemeId): Promise => + ipcRenderer.invoke(IpcChannels.THEME_SET_DEFAULT_DARK, themeId).then(() => undefined), + setDefaultLightTheme: (themeId: ThemeId): Promise => + ipcRenderer.invoke(IpcChannels.THEME_SET_DEFAULT_LIGHT, themeId).then(() => undefined), + reloadThemes: (): Promise => ipcRenderer.invoke(IpcChannels.THEME_RELOAD), + openThemesDirectory: (): Promise => ipcRenderer.invoke(IpcChannels.THEME_OPEN_DIRECTORY), + onThemeChanged: (callback: (state: ThemeStateSnapshot) => void) => { + const handler = (_event: Electron.IpcRendererEvent, state: ThemeStateSnapshot) => + callback(state) ipcRenderer.on(IpcChannels.THEME_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.THEME_CHANGED, handler) }, // Fullscreen onFullscreenChanged: (callback: (isFullscreen: boolean) => void) => { - const handler = (_event: Electron.IpcRendererEvent, isFullscreen: boolean) => callback(isFullscreen) + const handler = (_event: Electron.IpcRendererEvent, isFullscreen: boolean) => + callback(isFullscreen) ipcRenderer.on(IpcChannels.FULLSCREEN_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.FULLSCREEN_CHANGED, handler) }, // Zoom getBrowserZoom: (paneId: string) => ipcRenderer.invoke(IpcChannels.BROWSER_ZOOM_GET, paneId), - setBrowserZoom: (paneId: string, zoomFactor: number) => ipcRenderer.invoke(IpcChannels.BROWSER_ZOOM_SET, paneId, zoomFactor), - adjustBrowserZoom: (paneId: string, delta: number) => ipcRenderer.invoke(IpcChannels.BROWSER_ZOOM_ADJUST, paneId, delta), + setBrowserZoom: (paneId: string, zoomFactor: number) => + ipcRenderer.invoke(IpcChannels.BROWSER_ZOOM_SET, paneId, zoomFactor), + adjustBrowserZoom: (paneId: string, delta: number) => + ipcRenderer.invoke(IpcChannels.BROWSER_ZOOM_ADJUST, paneId, delta), onZoomCommand: (callback: (payload: ZoomCommandPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: ZoomCommandPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: ZoomCommandPayload) => + callback(payload) ipcRenderer.on(IpcChannels.APP_ZOOM_COMMAND, handler) return () => ipcRenderer.removeListener(IpcChannels.APP_ZOOM_COMMAND, handler) }, @@ -58,11 +120,13 @@ const api: WindowApi = { // Filesystem readDir: (dirPath: string) => ipcRenderer.invoke(IpcChannels.FS_READ_DIR, dirPath), readFile: (filePath: string) => ipcRenderer.invoke(IpcChannels.FS_READ_FILE, filePath), - writeFile: (filePath: string, content: string) => ipcRenderer.invoke(IpcChannels.FS_WRITE_FILE, filePath, content), + writeFile: (filePath: string, content: string) => + ipcRenderer.invoke(IpcChannels.FS_WRITE_FILE, filePath, content), createFile: (filePath: string) => ipcRenderer.invoke(IpcChannels.FS_CREATE_FILE, filePath), createDir: (dirPath: string) => ipcRenderer.invoke(IpcChannels.FS_CREATE_DIR, dirPath), deleteEntry: (entryPath: string) => ipcRenderer.invoke(IpcChannels.FS_DELETE, entryPath), - renameEntry: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannels.FS_RENAME, oldPath, newPath), + renameEntry: (oldPath: string, newPath: string) => + ipcRenderer.invoke(IpcChannels.FS_RENAME, oldPath, newPath), revealInFinder: (filePath: string) => ipcRenderer.send(IpcChannels.FS_REVEAL_IN_FINDER, filePath), // File watcher @@ -75,57 +139,71 @@ const api: WindowApi = { // Git getGitStatus: (workspaceId: string) => ipcRenderer.invoke(IpcChannels.GIT_STATUS, workspaceId), onGitStatusChanged: (callback: (payload: GitStatusChangedPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: GitStatusChangedPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: GitStatusChangedPayload) => + callback(payload) ipcRenderer.on(IpcChannels.GIT_STATUS_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.GIT_STATUS_CHANGED, handler) }, onGitBranchChanged: (callback: (payload: GitBranchChangedPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: GitBranchChangedPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: GitBranchChangedPayload) => + callback(payload) ipcRenderer.on(IpcChannels.GIT_BRANCH_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.GIT_BRANCH_CHANGED, handler) }, // Git diff - getGitFileOriginal: (rootPath: string | null, filePath: string): Promise<{ content: string | null }> => + getGitFileOriginal: ( + rootPath: string | null, + filePath: string, + ): Promise<{ content: string | null }> => ipcRenderer.invoke(IpcChannels.GIT_DIFF_ORIGINAL, rootPath, filePath), // Terminal - ptyCreate: (opts?: { id?: string; workspaceId?: string; cwd?: string; shell?: string; title?: string }) => - ipcRenderer.invoke(IpcChannels.PTY_CREATE, opts), - ptyWrite: (id: string, data: string) => - ipcRenderer.send(IpcChannels.PTY_DATA_IN, id, data), + ptyCreate: (opts?: { + id?: string + workspaceId?: string + cwd?: string + shell?: string + title?: string + }) => ipcRenderer.invoke(IpcChannels.PTY_CREATE, opts), + ptyWrite: (id: string, data: string) => ipcRenderer.send(IpcChannels.PTY_DATA_IN, id, data), ptyResize: (id: string, cols: number, rows: number) => ipcRenderer.send(IpcChannels.PTY_RESIZE, id, cols, rows), - ptyKill: (id: string) => - ipcRenderer.send(IpcChannels.PTY_KILL, id), + ptyKill: (id: string) => ipcRenderer.send(IpcChannels.PTY_KILL, id), ptyKillWorkspace: (workspaceId: string) => ipcRenderer.send(IpcChannels.PTY_KILL_WORKSPACE, workspaceId), onPtyData: (callback: (payload: PtyDataOutPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: PtyDataOutPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: PtyDataOutPayload) => + callback(payload) ipcRenderer.on(IpcChannels.PTY_DATA_OUT, handler) return () => ipcRenderer.removeListener(IpcChannels.PTY_DATA_OUT, handler) }, onPtyExit: (callback: (payload: PtyExitPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: PtyExitPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: PtyExitPayload) => + callback(payload) ipcRenderer.on(IpcChannels.PTY_EXIT, handler) return () => ipcRenderer.removeListener(IpcChannels.PTY_EXIT, handler) }, // Worktrees - listWorktrees: (workspaceId: string) => ipcRenderer.invoke(IpcChannels.WORKTREE_LIST, workspaceId), + listWorktrees: (workspaceId: string) => + ipcRenderer.invoke(IpcChannels.WORKTREE_LIST, workspaceId), createWorktree: (workspaceId: string, opts: WorktreeCreateOpts) => ipcRenderer.invoke(IpcChannels.WORKTREE_CREATE, workspaceId, opts), removeWorktree: (workspaceId: string, worktreePath: string) => ipcRenderer.invoke(IpcChannels.WORKTREE_REMOVE, workspaceId, worktreePath), setActiveWorktree: (workspaceId: string, worktreePath: string | null) => ipcRenderer.invoke(IpcChannels.WORKTREE_SET_ACTIVE, workspaceId, worktreePath), - getActiveWorktree: (workspaceId: string) => ipcRenderer.invoke(IpcChannels.WORKTREE_GET_ACTIVE, workspaceId), + getActiveWorktree: (workspaceId: string) => + ipcRenderer.invoke(IpcChannels.WORKTREE_GET_ACTIVE, workspaceId), onWorktreeListChanged: (callback: (payload: WorktreeListChangedPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: WorktreeListChangedPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: WorktreeListChangedPayload) => + callback(payload) ipcRenderer.on(IpcChannels.WORKTREE_LIST_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.WORKTREE_LIST_CHANGED, handler) }, - listBranches: (workspaceId: string) => ipcRenderer.invoke(IpcChannels.WORKTREE_LIST_BRANCHES, workspaceId), + listBranches: (workspaceId: string) => + ipcRenderer.invoke(IpcChannels.WORKTREE_LIST_BRANCHES, workspaceId), // File listing (quick open) listAllFiles: (rootPath: string) => ipcRenderer.invoke(IpcChannels.FS_LIST_ALL_FILES, rootPath), @@ -133,12 +211,14 @@ const api: WindowApi = { // Search (find in files) searchStart: (opts: SearchOpts) => ipcRenderer.invoke(IpcChannels.SEARCH_START, opts), onSearchResults: (callback: (payload: SearchResultsPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: SearchResultsPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: SearchResultsPayload) => + callback(payload) ipcRenderer.on(IpcChannels.SEARCH_RESULTS, handler) return () => ipcRenderer.removeListener(IpcChannels.SEARCH_RESULTS, handler) }, onSearchComplete: (callback: (payload: SearchCompletePayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: SearchCompletePayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: SearchCompletePayload) => + callback(payload) ipcRenderer.on(IpcChannels.SEARCH_COMPLETE, handler) return () => ipcRenderer.removeListener(IpcChannels.SEARCH_COMPLETE, handler) }, @@ -162,12 +242,17 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.SETTINGS_SET_USER, key, value), getWorkspaceSettings: (workspaceId?: string | null): Promise => ipcRenderer.invoke(IpcChannels.SETTINGS_GET_WORKSPACE, workspaceId), - setWorkspaceSetting: (key: string, value: unknown | undefined, workspaceId?: string | null): Promise => + setWorkspaceSetting: ( + key: string, + value: unknown | undefined, + workspaceId?: string | null, + ): Promise => ipcRenderer.invoke(IpcChannels.SETTINGS_SET_WORKSPACE, key, value, workspaceId), getBuiltInDefaults: (): Promise => ipcRenderer.invoke(IpcChannels.SETTINGS_GET_DEFAULTS), onSettingsChanged: (callback: (resolved: ResolvedSettings) => void) => { - const handler = (_event: Electron.IpcRendererEvent, resolved: ResolvedSettings) => callback(resolved) + const handler = (_event: Electron.IpcRendererEvent, resolved: ResolvedSettings) => + callback(resolved) ipcRenderer.on(IpcChannels.SETTINGS_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.SETTINGS_CHANGED, handler) }, @@ -191,7 +276,8 @@ const api: WindowApi = { dismissGitignoreAudit: (workspaceId?: string | null): Promise => ipcRenderer.invoke(IpcChannels.GITIGNORE_DISMISS, workspaceId), onGitignoreAuditResult: (callback: (payload: GitignoreAuditIpcPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: GitignoreAuditIpcPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: GitignoreAuditIpcPayload) => + callback(payload) ipcRenderer.on(IpcChannels.GITIGNORE_AUDIT_RESULT, handler) return () => ipcRenderer.removeListener(IpcChannels.GITIGNORE_AUDIT_RESULT, handler) }, @@ -201,7 +287,11 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.TASK_LIST, workspaceId), listRunningTasks: (workspaceId: string): Promise => ipcRenderer.invoke(IpcChannels.TASK_LIST_RUNNING, workspaceId), - runTask: (workspaceId: string, taskId: string, context?: TaskRunContext): Promise<{ executionId: string } | { error: string }> => + runTask: ( + workspaceId: string, + taskId: string, + context?: TaskRunContext, + ): Promise<{ executionId: string } | { error: string }> => ipcRenderer.invoke(IpcChannels.TASK_RUN, workspaceId, taskId, context), killTask: (workspaceId: string, executionId: string) => ipcRenderer.send(IpcChannels.TASK_KILL, workspaceId, executionId), @@ -211,37 +301,40 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.TASK_GENERATE, workspaceId), provideTaskInput: (workspaceId: string, requestId: string, value: string | null) => ipcRenderer.send(IpcChannels.TASK_PROVIDE_INPUT, workspaceId, requestId, value), - notifyFileSaved: (filePath: string) => - ipcRenderer.send(IpcChannels.TASK_FILE_SAVED, filePath), + notifyFileSaved: (filePath: string) => ipcRenderer.send(IpcChannels.TASK_FILE_SAVED, filePath), onTaskStatusChanged: (callback: (execution: TaskExecution) => void) => { - const handler = (_event: Electron.IpcRendererEvent, execution: TaskExecution) => callback(execution) + const handler = (_event: Electron.IpcRendererEvent, execution: TaskExecution) => + callback(execution) ipcRenderer.on(IpcChannels.TASK_STATUS_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.TASK_STATUS_CHANGED, handler) }, onTaskRequestInput: (callback: (request: TaskInputRequest) => void) => { - const handler = (_event: Electron.IpcRendererEvent, request: TaskInputRequest) => callback(request) + const handler = (_event: Electron.IpcRendererEvent, request: TaskInputRequest) => + callback(request) ipcRenderer.on(IpcChannels.TASK_REQUEST_INPUT, handler) return () => ipcRenderer.removeListener(IpcChannels.TASK_REQUEST_INPUT, handler) }, onTaskDiagnostics: (callback: (payload: TaskDiagnosticsPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: TaskDiagnosticsPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: TaskDiagnosticsPayload) => + callback(payload) ipcRenderer.on(IpcChannels.TASK_DIAGNOSTICS, handler) return () => ipcRenderer.removeListener(IpcChannels.TASK_DIAGNOSTICS, handler) }, onTaskAutoDetect: (callback: (payload: TaskAutoDetectPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: TaskAutoDetectPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: TaskAutoDetectPayload) => + callback(payload) ipcRenderer.on(IpcChannels.TASK_AUTO_DETECT, handler) return () => ipcRenderer.removeListener(IpcChannels.TASK_AUTO_DETECT, handler) }, onTaskTriggerResult: (callback: (result: TaskTriggerResult) => void) => { - const handler = (_event: Electron.IpcRendererEvent, result: TaskTriggerResult) => callback(result) + const handler = (_event: Electron.IpcRendererEvent, result: TaskTriggerResult) => + callback(result) ipcRenderer.on(IpcChannels.TASK_TRIGGER_RESULT, handler) return () => ipcRenderer.removeListener(IpcChannels.TASK_TRIGGER_RESULT, handler) }, // Workspace registry - listWorkspaces: (): Promise => - ipcRenderer.invoke(IpcChannels.WORKSPACE_LIST), + listWorkspaces: (): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_LIST), createWorkspace: (rootPath: string): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_CREATE, rootPath), createBlankWorkspace: (): Promise => @@ -252,8 +345,10 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.WORKSPACE_CLOSE, id), switchWorkspace: (id: string): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_SWITCH, id), - updateWorkspace: (id: string, patch: Partial>): Promise => - ipcRenderer.invoke(IpcChannels.WORKSPACE_UPDATE, id, patch), + updateWorkspace: ( + id: string, + patch: Partial>, + ): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_UPDATE, id, patch), reorderWorkspaces: (ids: string[]): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_REORDER, ids), setWorkspaceRoot: (id: string, rootPath: string): Promise => @@ -261,16 +356,21 @@ const api: WindowApi = { getActiveWorkspaceId: (): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_GET_ACTIVE), onWorkspaceRegistryChanged: (callback: (workspaces: WorkspaceEntry[]) => void) => { - const handler = (_event: Electron.IpcRendererEvent, workspaces: WorkspaceEntry[]) => callback(workspaces) + const handler = (_event: Electron.IpcRendererEvent, workspaces: WorkspaceEntry[]) => + callback(workspaces) ipcRenderer.on(IpcChannels.WORKSPACE_REGISTRY_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.WORKSPACE_REGISTRY_CHANGED, handler) }, getWorkspaceRuntimeSnapshots: () => ipcRenderer.invoke(IpcChannels.WORKSPACE_RUNTIME_SNAPSHOTS_GET), onWorkspaceRuntimeSnapshotsChanged: (callback) => { - const handler = (_event: Electron.IpcRendererEvent, snapshots: import('@aide/shared').WorkspaceRuntimeSnapshot[]) => callback(snapshots) + const handler = ( + _event: Electron.IpcRendererEvent, + snapshots: import('@aide/shared').WorkspaceRuntimeSnapshot[], + ) => callback(snapshots) ipcRenderer.on(IpcChannels.WORKSPACE_RUNTIME_SNAPSHOTS_CHANGED, handler) - return () => ipcRenderer.removeListener(IpcChannels.WORKSPACE_RUNTIME_SNAPSHOTS_CHANGED, handler) + return () => + ipcRenderer.removeListener(IpcChannels.WORKSPACE_RUNTIME_SNAPSHOTS_CHANGED, handler) }, // State persistence @@ -286,46 +386,45 @@ const api: WindowApi = { // Browser panes browserCreate: (paneId: string, workspaceId: string, sessionMode: BrowserSessionMode) => ipcRenderer.invoke(IpcChannels.BROWSER_CREATE, paneId, workspaceId, sessionMode), - browserDestroy: (paneId: string) => - ipcRenderer.send(IpcChannels.BROWSER_DESTROY, paneId), + browserDestroy: (paneId: string) => ipcRenderer.send(IpcChannels.BROWSER_DESTROY, paneId), browserDestroyWorkspace: (workspaceId: string) => ipcRenderer.send(IpcChannels.BROWSER_DESTROY_WORKSPACE, workspaceId), browserNavigate: (paneId: string, url: string) => ipcRenderer.invoke(IpcChannels.BROWSER_NAVIGATE, paneId, url), - browserGoBack: (paneId: string) => - ipcRenderer.send(IpcChannels.BROWSER_GO_BACK, paneId), - browserGoForward: (paneId: string) => - ipcRenderer.send(IpcChannels.BROWSER_GO_FORWARD, paneId), - browserReload: (paneId: string) => - ipcRenderer.send(IpcChannels.BROWSER_RELOAD, paneId), + browserGoBack: (paneId: string) => ipcRenderer.send(IpcChannels.BROWSER_GO_BACK, paneId), + browserGoForward: (paneId: string) => ipcRenderer.send(IpcChannels.BROWSER_GO_FORWARD, paneId), + browserReload: (paneId: string) => ipcRenderer.send(IpcChannels.BROWSER_RELOAD, paneId), browserHostUpdate: (update: BrowserHostUpdate) => ipcRenderer.send(IpcChannels.BROWSER_HOST_UPDATE, update), - browserSuppressOverlays: () => - ipcRenderer.send(IpcChannels.BROWSER_SUPPRESS_OVERLAYS), - browserUnsuppressOverlays: () => - ipcRenderer.send(IpcChannels.BROWSER_UNSUPPRESS_OVERLAYS), + browserSuppressOverlays: () => ipcRenderer.send(IpcChannels.BROWSER_SUPPRESS_OVERLAYS), + browserUnsuppressOverlays: () => ipcRenderer.send(IpcChannels.BROWSER_UNSUPPRESS_OVERLAYS), onBrowserDidNavigate: (callback: (payload: BrowserDidNavigatePayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: BrowserDidNavigatePayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: BrowserDidNavigatePayload) => + callback(payload) ipcRenderer.on(IpcChannels.BROWSER_DID_NAVIGATE, handler) return () => ipcRenderer.removeListener(IpcChannels.BROWSER_DID_NAVIGATE, handler) }, onBrowserTitleUpdated: (callback: (payload: BrowserPageTitlePayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: BrowserPageTitlePayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: BrowserPageTitlePayload) => + callback(payload) ipcRenderer.on(IpcChannels.BROWSER_PAGE_TITLE_UPDATED, handler) return () => ipcRenderer.removeListener(IpcChannels.BROWSER_PAGE_TITLE_UPDATED, handler) }, onBrowserLoadingChanged: (callback: (payload: BrowserLoadingPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: BrowserLoadingPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: BrowserLoadingPayload) => + callback(payload) ipcRenderer.on(IpcChannels.BROWSER_LOADING_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.BROWSER_LOADING_CHANGED, handler) }, onBrowserCanNavigateChanged: (callback: (payload: BrowserCanNavigatePayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: BrowserCanNavigatePayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: BrowserCanNavigatePayload) => + callback(payload) ipcRenderer.on(IpcChannels.BROWSER_CAN_NAVIGATE_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.BROWSER_CAN_NAVIGATE_CHANGED, handler) }, onBrowserFocusChanged: (callback: (payload: BrowserFocusPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: BrowserFocusPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: BrowserFocusPayload) => + callback(payload) ipcRenderer.on(IpcChannels.BROWSER_FOCUS_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.BROWSER_FOCUS_CHANGED, handler) }, @@ -336,8 +435,7 @@ const api: WindowApi = { ipcRenderer.on(IpcChannels.LIFECYCLE_REQUEST_SAVE, handler) return () => ipcRenderer.removeListener(IpcChannels.LIFECYCLE_REQUEST_SAVE, handler) }, - lifecycleSaveComplete: () => - ipcRenderer.send(IpcChannels.LIFECYCLE_SAVE_COMPLETE), + lifecycleSaveComplete: () => ipcRenderer.send(IpcChannels.LIFECYCLE_SAVE_COMPLETE), onCrashDetected: (callback: () => void) => { const handler = () => callback() ipcRenderer.on(IpcChannels.LIFECYCLE_CRASH_DETECTED, handler) @@ -345,8 +443,8 @@ const api: WindowApi = { }, // ─── Agent Chat ─────────────────────────────── - chatSendMessage: (sessionId: string, content: string) => - ipcRenderer.invoke(IpcChannels.CHAT_SEND_MESSAGE, sessionId, content), + chatSendMessage: (sessionId: string, payload: import('@aide/shared').ChatComposerSubmission) => + ipcRenderer.invoke(IpcChannels.CHAT_SEND_MESSAGE, sessionId, payload), chatGetHistory: (workspaceId: string, conversationId?: string): Promise => ipcRenderer.invoke(IpcChannels.CHAT_GET_HISTORY, workspaceId, conversationId), chatSetMode: (sessionId: string, mode: ChatMode): Promise => @@ -357,8 +455,7 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.CHAT_TOOL_APPROVE, sessionId, toolCallId), chatToolReject: (sessionId: string, toolCallId: string): Promise => ipcRenderer.invoke(IpcChannels.CHAT_TOOL_REJECT, sessionId, toolCallId), - chatStop: (sessionId: string) => - ipcRenderer.send(IpcChannels.CHAT_STOP, sessionId), + chatStop: (sessionId: string) => ipcRenderer.send(IpcChannels.CHAT_STOP, sessionId), onChatStreamChunk: (callback: (chunk: ChatStreamChunk) => void) => { const handler = (_event: Electron.IpcRendererEvent, chunk: ChatStreamChunk) => callback(chunk) ipcRenderer.on(IpcChannels.CHAT_STREAM_CHUNK, handler) @@ -370,7 +467,8 @@ const api: WindowApi = { return () => ipcRenderer.removeListener(IpcChannels.CHAT_STREAM_END, handler) }, onChatToolCall: (callback: (payload: ChatToolCallPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: ChatToolCallPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: ChatToolCallPayload) => + callback(payload) ipcRenderer.on(IpcChannels.CHAT_TOOL_CALL, handler) return () => ipcRenderer.removeListener(IpcChannels.CHAT_TOOL_CALL, handler) }, @@ -382,8 +480,7 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.MCP_LIST_SERVERS), mcpRestartServer: (serverName: string) => ipcRenderer.invoke(IpcChannels.MCP_RESTART_SERVER, serverName), - mcpListTools: (): Promise => - ipcRenderer.invoke(IpcChannels.MCP_LIST_TOOLS), + mcpListTools: (): Promise => ipcRenderer.invoke(IpcChannels.MCP_LIST_TOOLS), onMcpServerStatus: (callback: (status: McpServerStatus) => void) => { const handler = (_event: Electron.IpcRendererEvent, status: McpServerStatus) => callback(status) ipcRenderer.on(IpcChannels.MCP_SERVER_STATUS, handler) @@ -391,36 +488,162 @@ const api: WindowApi = { }, // ─── CLI Agent ─────────────────────────────── - cliAgentStart: (workspaceId: string, backend: AgentBackend, conversationId?: string, worktreePath?: string) => - ipcRenderer.invoke(IpcChannels.CLI_AGENT_START, workspaceId, backend, conversationId, worktreePath), - cliAgentStop: (sessionId: string) => - ipcRenderer.send(IpcChannels.CLI_AGENT_STOP, sessionId), - cliAgentSend: (sessionId: string, content: string) => - ipcRenderer.invoke(IpcChannels.CLI_AGENT_SEND, sessionId, content), + cliAgentStart: ( + workspaceId: string, + backend: AgentBackend, + conversationId?: string, + worktreePath?: string, + ) => + ipcRenderer.invoke( + IpcChannels.CLI_AGENT_START, + workspaceId, + backend, + conversationId, + worktreePath, + ), + cliAgentSwitchBackend: (sessionId: string, backend: AgentBackend) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SWITCH_BACKEND, sessionId, backend), + cliAgentStop: (sessionId: string) => ipcRenderer.send(IpcChannels.CLI_AGENT_STOP, sessionId), + cliAgentSend: (sessionId: string, payload: import('@aide/shared').ChatComposerSubmission) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SEND, sessionId, payload), cliAgentGetSession: (workspaceId: string, sessionId?: string): Promise => ipcRenderer.invoke(IpcChannels.CLI_AGENT_GET_SESSION, workspaceId, sessionId), cliAgentLoadMessages: (workspaceId: string, conversationId: string): Promise => ipcRenderer.invoke(IpcChannels.CLI_AGENT_LOAD_MESSAGES, workspaceId, conversationId), onCliAgentStreamDelta: (callback: (delta: CliAgentStreamDelta) => void) => { - const handler = (_event: Electron.IpcRendererEvent, delta: CliAgentStreamDelta) => callback(delta) + const handler = (_event: Electron.IpcRendererEvent, delta: CliAgentStreamDelta) => + callback(delta) ipcRenderer.on(IpcChannels.CLI_AGENT_STREAM_DELTA, handler) return () => ipcRenderer.removeListener(IpcChannels.CLI_AGENT_STREAM_DELTA, handler) }, onCliAgentMessage: (callback: (msg: CliAgentMessagePayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, msg: CliAgentMessagePayload) => callback(msg) + const handler = (_event: Electron.IpcRendererEvent, msg: CliAgentMessagePayload) => + callback(msg) ipcRenderer.on(IpcChannels.CLI_AGENT_MESSAGE, handler) return () => ipcRenderer.removeListener(IpcChannels.CLI_AGENT_MESSAGE, handler) }, onCliAgentStatus: (callback: (status: CliAgentStatusPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, status: CliAgentStatusPayload) => callback(status) + const handler = (_event: Electron.IpcRendererEvent, status: CliAgentStatusPayload) => + callback(status) ipcRenderer.on(IpcChannels.CLI_AGENT_STATUS, handler) return () => ipcRenderer.removeListener(IpcChannels.CLI_AGENT_STATUS, handler) }, onCliAgentResult: (callback: (result: CliAgentResultPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, result: CliAgentResultPayload) => callback(result) + const handler = (_event: Electron.IpcRendererEvent, result: CliAgentResultPayload) => + callback(result) ipcRenderer.on(IpcChannels.CLI_AGENT_RESULT, handler) return () => ipcRenderer.removeListener(IpcChannels.CLI_AGENT_RESULT, handler) }, + onCliAgentWorkspaceCost: (callback: (summary: unknown) => void) => { + const handler = (_event: Electron.IpcRendererEvent, summary: unknown) => callback(summary) + ipcRenderer.on(IpcChannels.CLI_AGENT_WORKSPACE_COST, handler) + return () => ipcRenderer.removeListener(IpcChannels.CLI_AGENT_WORKSPACE_COST, handler) + }, + + // ─── CLI Agent: per-session config + listings ── + cliAgentUpdateSessionConfig: (sessionId: string, patch: Record) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_UPDATE_SESSION_CONFIG, sessionId, patch), + cliAgentListProviders: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_LIST_PROVIDERS, sessionId), + cliAgentListAgents: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_LIST_AGENTS, sessionId), + cliAgentListModes: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_LIST_MODES, sessionId), + cliAgentListTools: (sessionId: string, providerID: string, modelID: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_LIST_TOOLS, sessionId, providerID, modelID), + + // ─── CLI Agent: session ops ──────────────────── + cliAgentSessionShare: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_SHARE, sessionId), + cliAgentSessionUnshare: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_UNSHARE, sessionId), + cliAgentSessionSummarize: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_SUMMARIZE, sessionId), + cliAgentSessionRevert: (sessionId: string, messageId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_REVERT, sessionId, messageId), + cliAgentSessionUnrevert: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_UNREVERT, sessionId), + cliAgentSessionFork: (sessionId: string, messageId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_FORK, sessionId, messageId), + cliAgentSessionAbort: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_ABORT, sessionId), + cliAgentSessionDiff: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_DIFF, sessionId), + cliAgentSessionTodo: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_TODO, sessionId), + cliAgentSessionInit: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_INIT, sessionId), + cliAgentSessionDeleteRemote: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_DELETE_REMOTE, sessionId), + + // ─── CLI Agent: workspace ops ────────────────── + cliAgentFileList: (sessionId: string, path: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FILE_LIST, sessionId, path), + cliAgentFileRead: (sessionId: string, path: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FILE_READ, sessionId, path), + cliAgentFileStatus: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FILE_STATUS, sessionId), + cliAgentFindText: (sessionId: string, query: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FIND_TEXT, sessionId, query), + cliAgentFindFiles: (sessionId: string, pattern: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FIND_FILES, sessionId, pattern), + cliAgentFindSymbols: (sessionId: string, query: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FIND_SYMBOLS, sessionId, query), + cliAgentShellRun: (sessionId: string, command: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SHELL_RUN, sessionId, command), + cliAgentLspStatus: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_LSP_STATUS, sessionId), + cliAgentFormatterStatus: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FORMATTER_STATUS, sessionId), + + // ─── CLI Agent: config / auth / providers ────── + cliAgentConfigGet: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_CONFIG_GET, sessionId), + cliAgentConfigUpdate: (sessionId: string, patch: Record) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_CONFIG_UPDATE, sessionId, patch), + cliAgentConfigProviders: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_CONFIG_PROVIDERS, sessionId), + cliAgentAuthSet: (sessionId: string, key: string, value: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_AUTH_SET, sessionId, key, value), + cliAgentProviderList: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_PROVIDER_LIST, sessionId), + cliAgentProviderAuth: (sessionId: string, providerId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_PROVIDER_AUTH, sessionId, providerId), + cliAgentProviderOauthAuthorize: (sessionId: string, providerId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_PROVIDER_OAUTH_AUTHORIZE, sessionId, providerId), + cliAgentProviderOauthCallback: (sessionId: string, code: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_PROVIDER_OAUTH_CALLBACK, sessionId, code), + cliAgentPathGet: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_PATH_GET, sessionId), + cliAgentLogWrite: ( + sessionId: string, + message: string, + level?: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR', + ) => ipcRenderer.invoke(IpcChannels.CLI_AGENT_LOG_WRITE, sessionId, message, level), + cliAgentServerInfo: (workspaceId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SERVER_INFO, workspaceId), + + // ─── CLI Agent: TUI control ──────────────────── + cliAgentTuiAppendPrompt: (sessionId: string, text: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_APPEND_PROMPT, sessionId, { text }), + cliAgentTuiSubmitPrompt: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_SUBMIT_PROMPT, sessionId), + cliAgentTuiClearPrompt: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_CLEAR_PROMPT, sessionId), + cliAgentTuiOpenHelp: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_OPEN_HELP, sessionId), + cliAgentTuiOpenSessions: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_OPEN_SESSIONS, sessionId), + cliAgentTuiOpenThemes: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_OPEN_THEMES, sessionId), + cliAgentTuiOpenModels: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_OPEN_MODELS, sessionId), + cliAgentTuiExecuteCommand: (sessionId: string, command: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_EXECUTE_COMMAND, sessionId, { command }), + cliAgentTuiShowToast: ( + sessionId: string, + args: { title?: string; message: string; variant: string }, + ) => ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_SHOW_TOAST, sessionId, args), // ─── Conversation History ───────────────────── conversationList: (workspaceId: string): Promise => @@ -431,19 +654,20 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.CONVERSATION_DELETE, workspaceId, conversationId), conversationRename: (workspaceId: string, conversationId: string, title: string): Promise => ipcRenderer.invoke(IpcChannels.CONVERSATION_RENAME, workspaceId, conversationId, title), - conversationGet: (workspaceId: string, conversationId: string): Promise => + conversationGet: ( + workspaceId: string, + conversationId: string, + ): Promise => ipcRenderer.invoke(IpcChannels.CONVERSATION_GET, workspaceId, conversationId), onConversationListChanged: (callback: (payload: ConversationListChangedPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: ConversationListChangedPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: ConversationListChangedPayload) => + callback(payload) ipcRenderer.on(IpcChannels.CONVERSATION_LIST_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.CONVERSATION_LIST_CHANGED, handler) }, // Open in VS Code - openInVSCode: ( - rootPath: string, - files?: Array<{ path: string; line: number; col: number }>, - ) => + openInVSCode: (rootPath: string, files?: Array<{ path: string; line: number; col: number }>) => ipcRenderer.invoke(IpcChannels.OPEN_IN_VSCODE, rootPath, files), // Platform info (for conditional UI like traffic lights) diff --git a/packages/main/src/themes/builtins.ts b/packages/main/src/themes/builtins.ts new file mode 100644 index 0000000..6bfbae6 --- /dev/null +++ b/packages/main/src/themes/builtins.ts @@ -0,0 +1,90 @@ +import type { ThemeManifest } from '@aide/shared' + +export const builtInThemeManifests: ThemeManifest[] = [ + { + id: 'one-dark', + label: 'One Dark', + appearance: 'dark', + tokens: { + '--bg-base': '#282c34', + '--bg-elevated': '#21252b', + '--bg-sunken': '#1b1e24', + '--bg-overlay': '#1d2026', + '--bg-active-tab': '#282c34', + '--bg-inactive-tab': '#24282f', + '--bg-hover': 'rgba(255, 255, 255, 0.04)', + '--bg-selection': 'rgba(82, 139, 255, 0.15)', + '--bg-info': 'rgba(82, 139, 255, 0.1)', + '--bg-info-hover': 'rgba(82, 139, 255, 0.2)', + '--text-primary': '#abb2bf', + '--text-secondary': '#7f8694', + '--text-muted': '#565c68', + '--text-selected': '#ffffff', + '--text-info': 'hsl(219, 79%, 66%)', + '--text-success': 'hsl(140, 44%, 62%)', + '--text-warning': 'hsl(36, 60%, 72%)', + '--text-error': 'hsl(9, 100%, 64%)', + '--border-base': '#181a1f', + '--border-subtle': '#2e333b', + '--accent': '#528bff', + '--accent-rgb': '82, 139, 255', + '--text-on-accent': '#ffffff', + '--syntax-keyword': '#c678dd', + '--syntax-fn': '#61afef', + '--syntax-string': '#98c379', + '--syntax-number': '#d19a66', + '--syntax-comment': '#5c6370', + '--syntax-tag': '#e06c75', + '--syntax-attr': '#528bff', + '--merge-delete-bg': 'rgba(224, 108, 117, 0.12)', + '--merge-delete-gutter': '#e06c75', + '--merge-insert-bg': 'rgba(152, 195, 121, 0.12)', + '--merge-insert-gutter': '#98c379', + '--merge-char-insert': 'rgba(152, 195, 121, 0.25)', + '--merge-char-delete': 'rgba(224, 108, 117, 0.25)', + }, + }, + { + id: 'one-light', + label: 'One Light', + appearance: 'light', + tokens: { + '--bg-base': '#fafafa', + '--bg-elevated': '#f0f0f1', + '--bg-sunken': '#e8e8e9', + '--bg-overlay': '#e5e5e6', + '--bg-active-tab': '#fafafa', + '--bg-inactive-tab': '#eeeeef', + '--bg-hover': 'rgba(0, 0, 0, 0.04)', + '--bg-selection': 'rgba(56, 113, 220, 0.12)', + '--bg-info': 'rgba(56, 113, 220, 0.1)', + '--bg-info-hover': 'rgba(56, 113, 220, 0.2)', + '--text-primary': '#383a42', + '--text-secondary': '#696c77', + '--text-muted': '#a0a1a7', + '--text-selected': '#000000', + '--text-info': 'hsl(220, 100%, 45%)', + '--text-success': 'hsl(119, 34%, 40%)', + '--text-warning': 'hsl(35, 84%, 44%)', + '--text-error': 'hsl(5, 74%, 50%)', + '--border-base': '#d4d4d5', + '--border-subtle': '#e0e0e1', + '--accent': '#4078f2', + '--accent-rgb': '64, 120, 242', + '--text-on-accent': '#ffffff', + '--syntax-keyword': '#a626a4', + '--syntax-fn': '#4078f2', + '--syntax-string': '#50a14f', + '--syntax-number': '#986801', + '--syntax-comment': '#a0a1a7', + '--syntax-tag': '#e45649', + '--syntax-attr': '#986801', + '--merge-delete-bg': 'rgba(228, 86, 73, 0.10)', + '--merge-delete-gutter': '#e45649', + '--merge-insert-bg': 'rgba(80, 161, 79, 0.10)', + '--merge-insert-gutter': '#50a14f', + '--merge-char-insert': 'rgba(80, 161, 79, 0.25)', + '--merge-char-delete': 'rgba(228, 86, 73, 0.25)', + }, + }, +] diff --git a/packages/main/src/themes/themeRegistry.ts b/packages/main/src/themes/themeRegistry.ts new file mode 100644 index 0000000..e29c368 --- /dev/null +++ b/packages/main/src/themes/themeRegistry.ts @@ -0,0 +1,261 @@ +import { app, shell } from 'electron' +import { existsSync } from 'fs' +import { mkdir, readdir, readFile } from 'fs/promises' +import { join } from 'path' +import type Store from 'electron-store' +import type { + AppSettings, + ThemeAppearance, + ThemeDefinition, + ThemeId, + ThemeManifest, + ThemeStateSnapshot, +} from '@aide/shared' +import { builtInThemeManifests } from './builtins' + +const BUILTIN_THEME_MANIFESTS = builtInThemeManifests as ThemeManifest[] +const BUILTIN_THEME_IDS = { + dark: 'one-dark', + light: 'one-light', +} as const satisfies Record + +function normalizeTokens(tokens: Record): Record { + const normalized: Record = {} + for (const [key, value] of Object.entries(tokens)) { + if (key.startsWith('--') && typeof value === 'string' && value.trim().length > 0) { + normalized[key] = value + } + } + return normalized +} + +function isAppearance(value: unknown): value is ThemeAppearance { + return value === 'dark' || value === 'light' +} + +function normalizeManifest( + input: unknown, + source: 'builtin' | 'user', + filePath?: string, +): ThemeDefinition | null { + if (!input || typeof input !== 'object') return null + const raw = input as Record + if (typeof raw.id !== 'string' || raw.id.trim().length === 0) return null + if (typeof raw.label !== 'string' || raw.label.trim().length === 0) return null + if (!isAppearance(raw.appearance)) return null + if (!raw.tokens || typeof raw.tokens !== 'object') return null + + const tokens = normalizeTokens(raw.tokens as Record) + if (Object.keys(tokens).length === 0) return null + + return { + id: raw.id.trim(), + label: raw.label.trim(), + appearance: raw.appearance, + tokens, + description: typeof raw.description === 'string' ? raw.description : undefined, + author: typeof raw.author === 'string' ? raw.author : undefined, + source, + path: filePath, + } +} + +function dedupeThemes(themes: ThemeDefinition[]): ThemeDefinition[] { + const seen = new Set() + const deduped: ThemeDefinition[] = [] + for (const theme of themes) { + if (seen.has(theme.id)) continue + seen.add(theme.id) + deduped.push(theme) + } + return deduped +} + +function fallbackThemeIdForAppearance(appearance: ThemeAppearance): ThemeId { + return BUILTIN_THEME_IDS[appearance] +} + +function themeMap(themes: ThemeDefinition[]): Map { + return new Map(themes.map((theme) => [theme.id, theme])) +} + +function resolveThemeTokens( + theme: ThemeDefinition, + byId: Map, +): Record { + const fallback = byId.get(fallbackThemeIdForAppearance(theme.appearance)) + return { + ...(fallback?.tokens ?? {}), + ...theme.tokens, + } +} + +function resolveThemeDefinition( + theme: ThemeDefinition, + byId: Map, +): ThemeDefinition { + return { + ...theme, + tokens: resolveThemeTokens(theme, byId), + } +} + +export class ThemeRegistry { + private snapshot: ThemeStateSnapshot | null = null + + constructor( + private readonly store: Store, + private readonly onChanged: (snapshot: ThemeStateSnapshot) => void, + ) {} + + private getThemesDirectoryPath(): string { + return join(app.getPath('userData'), 'themes') + } + + async ensureThemesDirectory(): Promise { + const dir = this.getThemesDirectoryPath() + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }) + } + return dir + } + + private migrateLegacySettings(): void { + const legacyStore = this.store as unknown as Store> + const legacyTheme = legacyStore.get('theme') + if (typeof legacyTheme === 'string' && legacyTheme.trim().length > 0) { + if (!this.store.get('activeThemeId')) this.store.set('activeThemeId', legacyTheme) + if (legacyTheme === BUILTIN_THEME_IDS.dark && !this.store.get('defaultDarkThemeId')) { + this.store.set('defaultDarkThemeId', legacyTheme) + } + if (legacyTheme === BUILTIN_THEME_IDS.light && !this.store.get('defaultLightThemeId')) { + this.store.set('defaultLightThemeId', legacyTheme) + } + legacyStore.delete('theme') + } + } + + async reload(): Promise { + this.migrateLegacySettings() + const builtins = BUILTIN_THEME_MANIFESTS.map((theme) => + normalizeManifest(theme, 'builtin'), + ).filter((theme): theme is ThemeDefinition => theme !== null) + const dir = await this.ensureThemesDirectory() + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) + const userThemes: ThemeDefinition[] = [] + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue + const filePath = join(dir, entry.name) + try { + const raw = JSON.parse(await readFile(filePath, 'utf-8')) as unknown + const theme = normalizeManifest(raw, 'user', filePath) + if (theme) userThemes.push(theme) + } catch { + // Ignore malformed theme files; the registry stays resilient and can be reloaded after fixes. + } + } + + const themes = dedupeThemes([...builtins, ...userThemes]) + const baseMap = themeMap(themes) + const resolvedThemes = themes.map((theme) => resolveThemeDefinition(theme, baseMap)) + const resolvedMap = themeMap(resolvedThemes) + + const darkThemes = resolvedThemes.filter((theme) => theme.appearance === 'dark') + const lightThemes = resolvedThemes.filter((theme) => theme.appearance === 'light') + + const defaultDarkThemeId = this.resolveStoredThemeId( + this.store.get('defaultDarkThemeId'), + darkThemes, + BUILTIN_THEME_IDS.dark, + ) + const defaultLightThemeId = this.resolveStoredThemeId( + this.store.get('defaultLightThemeId'), + lightThemes, + BUILTIN_THEME_IDS.light, + ) + + const requestedActiveThemeId = this.store.get('activeThemeId') + const activeThemeId = + typeof requestedActiveThemeId === 'string' && resolvedMap.has(requestedActiveThemeId) + ? requestedActiveThemeId + : defaultDarkThemeId + + const snapshot: ThemeStateSnapshot = { + themes: resolvedThemes, + activeThemeId, + defaultDarkThemeId, + defaultLightThemeId, + } + + this.store.set('activeThemeId', snapshot.activeThemeId) + this.store.set('defaultDarkThemeId', snapshot.defaultDarkThemeId) + this.store.set('defaultLightThemeId', snapshot.defaultLightThemeId) + this.snapshot = snapshot + this.onChanged(snapshot) + return snapshot + } + + private resolveStoredThemeId( + requested: unknown, + themes: ThemeDefinition[], + fallbackId: ThemeId, + ): ThemeId { + if (typeof requested === 'string' && themes.some((theme) => theme.id === requested)) { + return requested + } + const fallback = themes.find((theme) => theme.id === fallbackId) ?? themes[0] + return fallback?.id ?? fallbackId + } + + async getSnapshot(): Promise { + return this.snapshot ?? this.reload() + } + + async listThemes(): Promise { + return (await this.getSnapshot()).themes + } + + async setActiveTheme(themeId: ThemeId): Promise { + const snapshot = await this.getSnapshot() + if (!snapshot.themes.some((theme) => theme.id === themeId)) return snapshot + if (snapshot.activeThemeId === themeId) return snapshot + this.store.set('activeThemeId', themeId) + this.snapshot = { ...snapshot, activeThemeId: themeId } + this.onChanged(this.snapshot) + return this.snapshot + } + + async setDefaultTheme( + appearance: ThemeAppearance, + themeId: ThemeId, + ): Promise { + const snapshot = await this.getSnapshot() + const match = snapshot.themes.find( + (theme) => theme.id === themeId && theme.appearance === appearance, + ) + if (!match) return snapshot + + const key = appearance === 'dark' ? 'defaultDarkThemeId' : 'defaultLightThemeId' + if (snapshot[key] === themeId) return snapshot + + this.store.set(key, themeId) + this.snapshot = { ...snapshot, [key]: themeId } + this.onChanged(this.snapshot) + return this.snapshot + } + + async toggleTheme(): Promise { + const snapshot = await this.getSnapshot() + const current = snapshot.themes.find((theme) => theme.id === snapshot.activeThemeId) + if (!current) return snapshot + const nextThemeId = + current.appearance === 'dark' ? snapshot.defaultLightThemeId : snapshot.defaultDarkThemeId + return this.setActiveTheme(nextThemeId) + } + + async openThemesDirectory(): Promise { + const dir = await this.ensureThemesDirectory() + await shell.openPath(dir) + } +} diff --git a/packages/main/src/workspace/WorkspaceRuntime.ts b/packages/main/src/workspace/WorkspaceRuntime.ts index 785219f..f54fd75 100644 --- a/packages/main/src/workspace/WorkspaceRuntime.ts +++ b/packages/main/src/workspace/WorkspaceRuntime.ts @@ -55,6 +55,7 @@ export class WorkspaceRuntime { conversationStore: null, nativeSessionWatcher: null, nativeSessionCache: null, + approvalRouter: null, fileWatcher: null, gitStatus: null, worktreeManager: null, @@ -223,13 +224,18 @@ export class WorkspaceRuntime { } | null const cliAgentManager = this.services.cliAgentManager as { getRunningSessionCount?: () => number + getPendingApprovalCount?: () => number } | null + const pendingApproval = + (agentManager?.getPendingApprovalCount?.() ?? 0) + + (cliAgentManager?.getPendingApprovalCount?.() ?? 0) + this.workload = { tasksRunning: Boolean(taskRunner?.getRunning?.().length), pendingUserInput: Boolean(taskRunner?.getPendingInputCount?.()), agentsRunning: Boolean(agentManager?.getActiveSessionCount?.() || cliAgentManager?.getRunningSessionCount?.()), - pendingApproval: Boolean(agentManager?.getPendingApprovalCount?.()), + pendingApproval: pendingApproval > 0, } this.emitSnapshotChanged() } diff --git a/packages/main/src/workspace/runtimeTypes.ts b/packages/main/src/workspace/runtimeTypes.ts index f3bd7e9..c014b6d 100644 --- a/packages/main/src/workspace/runtimeTypes.ts +++ b/packages/main/src/workspace/runtimeTypes.ts @@ -35,6 +35,8 @@ export interface WorkspaceRuntimeServiceSlots { conversationStore: unknown | null nativeSessionWatcher: unknown | null nativeSessionCache: unknown | null + /** Single approval surface across built-in + CLI agent managers (CHAT_TOOL_APPROVE/REJECT). */ + approvalRouter: unknown | null /** Reserved — FS watchers use `fileWatcher.startWatchers(workspaceId)` keyed by runtime id */ fileWatcher: unknown | null /** Reserved — git polling uses `gitStatus` module map keyed by workspaceId */ diff --git a/packages/main/src/workspace/settingsResolver.ts b/packages/main/src/workspace/settingsResolver.ts index f3c22e7..b9c7321 100644 --- a/packages/main/src/workspace/settingsResolver.ts +++ b/packages/main/src/workspace/settingsResolver.ts @@ -11,7 +11,14 @@ import { existsSync } from 'fs' import { readFile } from 'fs/promises' import { join } from 'path' import type Store from 'electron-store' -import type { AppSettings, AideProjectSettings, ResolvedSettings, PermissionTier, ToolPermissionConfig, AgentBackend } from '@aide/shared' +import type { + AppSettings, + AideProjectSettings, + ResolvedSettings, + PermissionTier, + ToolPermissionConfig, + AgentBackend, +} from '@aide/shared' export const BUILT_IN_DEFAULTS: ResolvedSettings = { tabSize: 2, @@ -39,7 +46,16 @@ export const BUILT_IN_DEFAULTS: ResolvedSettings = { // Agent / Backend defaults 'agent.backend': 'built-in' as AgentBackend, 'agent.claudeCodePath': '', + 'agent.opencodePath': '', 'agent.codexPath': '', + + // OpenCode-specific session defaults + 'agent.opencode.defaultProvider': '', + 'agent.opencode.defaultModel': '', + 'agent.opencode.defaultAgent': '', + 'agent.opencode.defaultMode': '', + 'agent.opencode.defaultSystemPrompt': '', + 'agent.opencode.defaultToolToggles': {} as Record, } /** @@ -48,9 +64,7 @@ export const BUILT_IN_DEFAULTS: ResolvedSettings = { * @param store - Electron store used to read the saved `editorDefaults` entry * @returns A ResolvedSettings object where each scalar setting is the user value if present, otherwise the built-in default; `filesExclude` and `searchExclude` are shallow-merged so user entries override built-in entries */ -export function resolveAppDefaults( - store: Store, -): ResolvedSettings { +export function resolveAppDefaults(store: Store): ResolvedSettings { const userDefaults = (store.get('editorDefaults') ?? {}) as Partial return { @@ -79,7 +93,8 @@ export function resolveAppDefaults( 'agent.maxTokens': userDefaults['agent.maxTokens'] ?? BUILT_IN_DEFAULTS['agent.maxTokens'], // Agent / Permissions - 'agent.permissionTier': userDefaults['agent.permissionTier'] ?? BUILT_IN_DEFAULTS['agent.permissionTier'], + 'agent.permissionTier': + userDefaults['agent.permissionTier'] ?? BUILT_IN_DEFAULTS['agent.permissionTier'], 'agent.autoApprove': { ...BUILT_IN_DEFAULTS['agent.autoApprove'], ...(userDefaults['agent.autoApprove'] ?? {}), @@ -87,8 +102,32 @@ export function resolveAppDefaults( // Agent / Backend 'agent.backend': userDefaults['agent.backend'] ?? BUILT_IN_DEFAULTS['agent.backend'], - 'agent.claudeCodePath': userDefaults['agent.claudeCodePath'] ?? BUILT_IN_DEFAULTS['agent.claudeCodePath'], + 'agent.claudeCodePath': + userDefaults['agent.claudeCodePath'] ?? BUILT_IN_DEFAULTS['agent.claudeCodePath'], + 'agent.opencodePath': + userDefaults['agent.opencodePath'] ?? BUILT_IN_DEFAULTS['agent.opencodePath'], 'agent.codexPath': userDefaults['agent.codexPath'] ?? BUILT_IN_DEFAULTS['agent.codexPath'], + + // OpenCode session defaults + 'agent.opencode.defaultProvider': + userDefaults['agent.opencode.defaultProvider'] ?? + BUILT_IN_DEFAULTS['agent.opencode.defaultProvider'], + 'agent.opencode.defaultModel': + userDefaults['agent.opencode.defaultModel'] ?? + BUILT_IN_DEFAULTS['agent.opencode.defaultModel'], + 'agent.opencode.defaultAgent': + userDefaults['agent.opencode.defaultAgent'] ?? + BUILT_IN_DEFAULTS['agent.opencode.defaultAgent'], + 'agent.opencode.defaultMode': + userDefaults['agent.opencode.defaultMode'] ?? + BUILT_IN_DEFAULTS['agent.opencode.defaultMode'], + 'agent.opencode.defaultSystemPrompt': + userDefaults['agent.opencode.defaultSystemPrompt'] ?? + BUILT_IN_DEFAULTS['agent.opencode.defaultSystemPrompt'], + 'agent.opencode.defaultToolToggles': { + ...BUILT_IN_DEFAULTS['agent.opencode.defaultToolToggles'], + ...(userDefaults['agent.opencode.defaultToolToggles'] ?? {}), + }, } } @@ -158,6 +197,15 @@ export async function resolveSettings( // Agent / Backend (user-only — executable paths must not come from untrusted repos) 'agent.backend': appDefaults['agent.backend'], 'agent.claudeCodePath': appDefaults['agent.claudeCodePath'], + 'agent.opencodePath': appDefaults['agent.opencodePath'], 'agent.codexPath': appDefaults['agent.codexPath'], + + // OpenCode session defaults (user-only — see SENSITIVE_AGENT_KEYS) + 'agent.opencode.defaultProvider': appDefaults['agent.opencode.defaultProvider'], + 'agent.opencode.defaultModel': appDefaults['agent.opencode.defaultModel'], + 'agent.opencode.defaultAgent': appDefaults['agent.opencode.defaultAgent'], + 'agent.opencode.defaultMode': appDefaults['agent.opencode.defaultMode'], + 'agent.opencode.defaultSystemPrompt': appDefaults['agent.opencode.defaultSystemPrompt'], + 'agent.opencode.defaultToolToggles': appDefaults['agent.opencode.defaultToolToggles'], } } diff --git a/packages/renderer/src/commands/context.ts b/packages/renderer/src/commands/context.ts index 28e1998..3f140bc 100644 --- a/packages/renderer/src/commands/context.ts +++ b/packages/renderer/src/commands/context.ts @@ -49,6 +49,10 @@ export interface CommandContext { openCommandPalette: () => void openQuickOpen: () => void openNewBrowserModal: () => void + openThemePicker: (mode: 'active' | 'dark' | 'light') => void + toggleTheme: () => void + reloadThemes: () => Promise + openThemesDirectory: () => Promise persistWorkspaceRuntime: () => void diff --git a/packages/renderer/src/commands/domains/agent.ts b/packages/renderer/src/commands/domains/agent.ts index 5dbe1f6..c05a17d 100644 --- a/packages/renderer/src/commands/domains/agent.ts +++ b/packages/renderer/src/commands/domains/agent.ts @@ -4,10 +4,11 @@ * Backend choice comes from resolved settings (`agent.backend`). Built-in chat creates a conversation via IPC first when possible. */ -import type { ConversationMeta } from '@aide/shared' +import type { ConversationMeta, ExternalCliBackend } from '@aide/shared' import type { DockviewApi, IDockviewPanel } from 'dockview-react' import type { CommandContext, GetCommandContext } from '../context' import type { CommandSpec } from './types' +import { backendLabel, isCliBackend } from '../../lib/agentBackend' /** * Creates a `chatPane` with a server-issued `conversationId`, or falls back to a panel without id if create fails. @@ -61,7 +62,7 @@ function addCliAgentPanel( workspaceId: string, workspaceRoot: string | undefined, editorPanel: IDockviewPanel | undefined, - backend: Extract, + backend: ExternalCliBackend, ): void { void window.api.conversationCreate({ workspaceId, @@ -71,7 +72,7 @@ function addCliAgentPanel( id: `agent-${Date.now()}`, component: 'cliAgentPane', tabComponent: 'agentTab', - title: backend === 'claude-code' ? 'Claude Code' : 'Codex', + title: backendLabel(backend), params: { workspaceId, workspaceRoot, @@ -89,7 +90,7 @@ function addCliAgentPanel( id: `agent-${Date.now()}`, component: 'cliAgentPane', tabComponent: 'agentTab', - title: backend === 'claude-code' ? 'Claude Code' : 'Codex', + title: backendLabel(backend), params: { workspaceId, workspaceRoot, @@ -124,7 +125,7 @@ export function collectAgentCommands(getCtx: GetCommandContext): CommandSpec[] { void window.api.getResolvedSettings(workspaceId).then((resolved) => { const backend = resolved['agent.backend'] ?? 'built-in' - if (backend === 'claude-code' || backend === 'codex') { + if (isCliBackend(backend)) { addCliAgentPanel(ctx, api, workspaceId, workspaceRoot, editorPanel, backend) } else { addBuiltInAgentPanel(ctx, api, workspaceId, workspaceRoot, editorPanel) @@ -148,7 +149,7 @@ export function collectAgentCommands(getCtx: GetCommandContext): CommandSpec[] { api.addPanel({ id: `agent-${Date.now()}`, component: - conv.source === 'claude-native' || conv.backend === 'claude-code' || conv.backend === 'codex' + conv.source === 'claude-native' || isCliBackend(conv.backend) ? 'cliAgentPane' : 'chatPane', tabComponent: 'agentTab', diff --git a/packages/renderer/src/commands/domains/theme.ts b/packages/renderer/src/commands/domains/theme.ts new file mode 100644 index 0000000..e6ef68d --- /dev/null +++ b/packages/renderer/src/commands/domains/theme.ts @@ -0,0 +1,43 @@ +import type { GetCommandContext } from '../context' +import type { CommandSpec } from './types' + +export function collectThemeCommands(getCtx: GetCommandContext): CommandSpec[] { + return [ + { + def: { id: 'theme.select', label: 'Select Color Theme', category: 'Preferences' }, + handler: () => getCtx().openThemePicker('active'), + }, + { + def: { id: 'theme.toggle', label: 'Toggle Color Theme', category: 'Preferences' }, + handler: () => getCtx().toggleTheme(), + }, + { + def: { + id: 'theme.setDefaultDark', + label: 'Set Default Dark Theme', + category: 'Preferences', + }, + handler: () => getCtx().openThemePicker('dark'), + }, + { + def: { + id: 'theme.setDefaultLight', + label: 'Set Default Light Theme', + category: 'Preferences', + }, + handler: () => getCtx().openThemePicker('light'), + }, + { + def: { id: 'theme.reload', label: 'Reload Themes', category: 'Preferences' }, + handler: () => { + void getCtx().reloadThemes() + }, + }, + { + def: { id: 'theme.openFolder', label: 'Open Themes Folder', category: 'Preferences' }, + handler: () => { + void getCtx().openThemesDirectory() + }, + }, + ] +} diff --git a/packages/renderer/src/commands/registerAppCommands.ts b/packages/renderer/src/commands/registerAppCommands.ts index 2a5e513..4ad2daa 100644 --- a/packages/renderer/src/commands/registerAppCommands.ts +++ b/packages/renderer/src/commands/registerAppCommands.ts @@ -14,6 +14,7 @@ import { collectEditorCommands } from './domains/editor' import { collectPaneCommands } from './domains/panes' import { collectTaskCommands } from './domains/tasks' import { collectTerminalCommands } from './domains/terminal' +import { collectThemeCommands } from './domains/theme' import { collectViewCommands } from './domains/view' import { collectWorkspaceCommands } from './domains/workspace' @@ -33,6 +34,7 @@ export function registerAppCommands(getContext: GetCommandContext): void { collectAgentCommands, collectTerminalCommands, collectTaskCommands, + collectThemeCommands, collectAideCommands, ] diff --git a/packages/renderer/src/components/chat/ChatInput.tsx b/packages/renderer/src/components/chat/ChatInput.tsx index 751e3e2..2f2d84c 100644 --- a/packages/renderer/src/components/chat/ChatInput.tsx +++ b/packages/renderer/src/components/chat/ChatInput.tsx @@ -1,24 +1,51 @@ -import { useRef, useCallback, useState } from 'react' -import type { ChatMode, ChatSessionStatus } from '@aide/shared' +import { useRef, useCallback, useState, useMemo, useEffect } from 'react' +import type { ChatComposerSubmission, ChatMode, ChatSessionStatus } from '@aide/shared' +import { Button } from '../ui/Button' +import { + buildComposerSubmission, + CHAT_AUTOCOMPLETE_COMMANDS, + filterCommandSuggestions, + filterFileSuggestions, + getComposerTrigger, +} from '../../lib/chatComposer' interface ChatInputProps { - onSend: (content: string) => void + onSend: (payload: ChatComposerSubmission) => void onStop: () => void status: ChatSessionStatus mode: ChatMode + workspaceRoot?: string } -const PLACEHOLDERS: Record = { - ask: 'Ask a question...', - edit: 'Describe changes...', - agent: 'What should I do?', -} - -export function ChatInput({ onSend, onStop, status, mode }: ChatInputProps) { +export function ChatInput({ onSend, onStop, status, workspaceRoot }: ChatInputProps) { const textareaRef = useRef(null) const [value, setValue] = useState('') + const [mentionedFiles, setMentionedFiles] = useState([]) + const [allFiles, setAllFiles] = useState([]) + const [activeIndex, setActiveIndex] = useState(0) const isActive = status !== 'idle' + useEffect(() => { + let cancelled = false + if (!workspaceRoot) { + setAllFiles([]) + return () => { + cancelled = true + } + } + window.api + .listAllFiles(workspaceRoot) + .then((files) => { + if (!cancelled) setAllFiles(files) + }) + .catch(() => { + if (!cancelled) setAllFiles([]) + }) + return () => { + cancelled = true + } + }, [workspaceRoot]) + const resize = useCallback(() => { const el = textareaRef.current if (!el) return @@ -26,6 +53,31 @@ export function ChatInput({ onSend, onStop, status, mode }: ChatInputProps) { el.style.height = `${el.scrollHeight}px` }, []) + const cursor = textareaRef.current?.selectionStart ?? value.length + const trigger = useMemo(() => getComposerTrigger(value, cursor), [value, cursor]) + const fileSuggestions = useMemo( + () => + trigger?.kind === 'file' + ? filterFileSuggestions( + allFiles.filter((file) => !mentionedFiles.includes(file)), + trigger.query, + ) + : [], + [allFiles, mentionedFiles, trigger], + ) + const commandSuggestions = useMemo( + () => + trigger?.kind === 'command' + ? filterCommandSuggestions(CHAT_AUTOCOMPLETE_COMMANDS, trigger.query) + : [], + [trigger], + ) + const suggestions = trigger?.kind === 'file' ? fileSuggestions : commandSuggestions + + useEffect(() => { + setActiveIndex(0) + }, [trigger?.kind, trigger?.query, suggestions.length]) + const handleChange = useCallback( (e: React.ChangeEvent) => { setValue(e.target.value) @@ -34,57 +86,184 @@ export function ChatInput({ onSend, onStop, status, mode }: ChatInputProps) { [resize], ) - const handleSend = useCallback(() => { - const trimmed = value.trim() - if (!trimmed || isActive) return - onSend(trimmed) + const resetComposer = useCallback(() => { setValue('') - // Reset textarea height + setMentionedFiles([]) requestAnimationFrame(() => { const el = textareaRef.current - if (el) { - el.style.height = 'auto' - } + if (el) el.style.height = 'auto' }) - }, [value, isActive, onSend]) + }, []) + + const handleSend = useCallback(() => { + const submission = buildComposerSubmission(value, mentionedFiles) + if ((!submission.text && submission.mentionedFiles.length === 0) || isActive) return + onSend(submission) + resetComposer() + }, [value, mentionedFiles, isActive, onSend, resetComposer]) + + const insertCommand = useCallback( + (commandId: string) => { + if (!trigger || trigger.kind !== 'command') return + const nextValue = `${value.slice(0, trigger.start)}/${commandId} ${value.slice(trigger.end)}` + setValue(nextValue) + requestAnimationFrame(() => { + const el = textareaRef.current + if (!el) return + const pos = trigger.start + commandId.length + 2 + el.focus() + el.setSelectionRange(pos, pos) + resize() + }) + }, + [trigger, value, resize], + ) + + const insertMention = useCallback( + (filePath: string) => { + if (!trigger || trigger.kind !== 'file') return + const nextValue = `${value.slice(0, trigger.start)}${value.slice(trigger.end)}` + setValue(nextValue) + setMentionedFiles((prev) => (prev.includes(filePath) ? prev : [...prev, filePath])) + requestAnimationFrame(() => { + const el = textareaRef.current + if (!el) return + const pos = trigger.start + el.focus() + el.setSelectionRange(pos, pos) + resize() + }) + }, + [trigger, value, resize], + ) + + const handleSuggestionSelect = useCallback(() => { + if (!trigger || suggestions.length === 0) return false + const current = suggestions[Math.min(activeIndex, suggestions.length - 1)] + if (!current) return false + if (trigger.kind === 'file') insertMention(current as string) + else insertCommand((current as (typeof commandSuggestions)[number]).id) + return true + }, [activeIndex, commandSuggestions, insertCommand, insertMention, suggestions, trigger]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (trigger && suggestions.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((index) => (index + 1) % suggestions.length) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((index) => (index - 1 + suggestions.length) % suggestions.length) + return + } + if (e.key === 'Tab') { + e.preventDefault() + handleSuggestionSelect() + return + } + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault() + handleSuggestionSelect() + return + } + if (e.key === 'Escape') { + e.preventDefault() + setValue((current) => current) + return + } + } + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() handleSend() } else if (e.key === 'Escape') { textareaRef.current?.blur() + } else if (e.key === 'Backspace' && !value && mentionedFiles.length > 0) { + setMentionedFiles((prev) => prev.slice(0, -1)) } }, - [handleSend], + [handleSend, handleSuggestionSelect, mentionedFiles.length, suggestions.length, trigger, value], ) return ( -
-