From 542458b7ed23fa0344624def73a9923d4497b75e Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 11 Apr 2026 17:36:08 +0400 Subject: [PATCH 01/15] fix: accept 1M-context model variants in agent-routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model-name validator in POST /api/agents/:id/start rejected any model string containing brackets, so `sonnet[1m]` (and other 1M-context variants) returned "Invalid model name" and broke delegate_task from the MCP orchestrator. Widen the regex to include `[` and `]` — matching the charset claude-provider.ts already uses for interactive sessions — and short-circuit the `default` Dorothy alias so the --model flag is omitted and the CLI picks its own default. Also updates mcp-orchestrator tool descriptions for start_agent and delegate_task to list the full accepted model set. Co-Authored-By: Claude Opus 4.6 --- electron/services/api-routes/agent-routes.ts | 7 +++++-- mcp-orchestrator/src/tools/agents.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/electron/services/api-routes/agent-routes.ts b/electron/services/api-routes/agent-routes.ts index 1d9f9953..4fb7582f 100644 --- a/electron/services/api-routes/agent-routes.ts +++ b/electron/services/api-routes/agent-routes.ts @@ -190,8 +190,11 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { command += ' --dangerously-skip-permissions'; } const resolvedModel = model || agent.model; - if (resolvedModel) { - if (!/^[a-zA-Z0-9._:/-]+$/.test(resolvedModel)) { + // 'default' is a Dorothy UI alias meaning "let Claude CLI pick"; omit the flag. + if (resolvedModel && resolvedModel !== 'default') { + // Allow the same characters as claude-provider.ts buildInteractiveCommand, + // including brackets used by 1M-context variants (e.g. sonnet[1m]). + if (!/^[a-zA-Z0-9._:\/\[\]-]+$/.test(resolvedModel)) { sendJson({ error: 'Invalid model name' }, 400); return; } diff --git a/mcp-orchestrator/src/tools/agents.ts b/mcp-orchestrator/src/tools/agents.ts index d592468a..56205ed6 100644 --- a/mcp-orchestrator/src/tools/agents.ts +++ b/mcp-orchestrator/src/tools/agents.ts @@ -178,7 +178,7 @@ export function registerAgentTools(server: McpServer): void { { id: z.string().describe("The agent ID"), prompt: z.string().describe("The task or instruction for the agent to work on"), - model: z.string().optional().describe("Optional model to use (e.g., 'sonnet', 'opus')"), + model: z.string().optional().describe("Optional model to use. Aliases: 'sonnet', 'opus', 'haiku', 'opusplan', 'sonnet[1m]' (1M context). Full IDs: 'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5-20251001'. Omit to use the agent's configured default."), }, async ({ id, prompt, model }) => { try { @@ -469,7 +469,7 @@ export function registerAgentTools(server: McpServer): void { { id: z.string().describe("The agent ID to delegate to"), prompt: z.string().describe("The task/instruction for the agent"), - model: z.string().optional().describe("Optional model to use (e.g., 'sonnet', 'opus')"), + model: z.string().optional().describe("Optional model to use. Aliases: 'sonnet', 'opus', 'haiku', 'opusplan', 'sonnet[1m]' (1M context). Full IDs: 'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5-20251001'. Omit to use the agent's configured default."), timeoutSeconds: z.number().optional().describe("Maximum time to wait in seconds (default: 300)"), }, async ({ id, prompt, model, timeoutSeconds = 300 }) => { From 1338f85e2969df522c5e7b26a3311da718d83444 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 11 Apr 2026 17:36:18 +0400 Subject: [PATCH 02/15] fix: anchor terminal output and add scroll-to-bottom button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During an active agent task, the terminal output view jumped away from the bottom and auto-scroll stopped working. The ResizeObserver and fullscreen effects in useAgentDialogTerminal called term.scrollToBottom() unconditionally after fitAddon.fit(), so any layout change (sidebar toggle, fullscreen, window resize) hijacked the viewport even when the user had scrolled up. Track user scroll position with an isAtBottomRef updated via term.onScroll (with a 2-line tolerance), and guard every post-fit scrollToBottom on that ref. Expose a scrollToBottom callback and an isAtBottom flag from the hook. AgentTerminalDialog now renders a ChevronDown "Scroll to bottom" button — styled to match the existing glass buttons — whenever the user is scrolled up; clicking it re-anchors and re-enables auto-scroll. Resets the lock to true on every new session so fresh dialogs still open anchored at the bottom. Co-Authored-By: Claude Opus 4.6 --- .../AgentWorld/AgentTerminalDialog.tsx | 17 ++++++-- .../AgentWorld/useAgentDialogTerminal.ts | 41 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/components/AgentWorld/AgentTerminalDialog.tsx b/src/components/AgentWorld/AgentTerminalDialog.tsx index 61ca57cb..bdf8e334 100644 --- a/src/components/AgentWorld/AgentTerminalDialog.tsx +++ b/src/components/AgentWorld/AgentTerminalDialog.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Loader2, PanelRight } from 'lucide-react'; +import { ChevronDown, Loader2, PanelRight } from 'lucide-react'; import type { AgentStatus } from '@/types/electron'; import 'xterm/css/xterm.css'; @@ -77,7 +77,7 @@ export default function AgentTerminalDialog({ }, [open, agent, initialPanel]); // Terminal hooks - const { terminalReady, terminalRef, xtermRef } = useAgentDialogTerminal({ + const { terminalReady, terminalRef, xtermRef, isAtBottom, scrollToBottom } = useAgentDialogTerminal({ open, agent, isFullscreen, @@ -211,7 +211,7 @@ export default function AgentTerminalDialog({ )} - {/* Sidebar toggle button (bottom-right of terminal) */} + {/* Sidebar toggle button (top-right of terminal) */} {!sidebarOpen && !isSuperAgentMode && ( )} + {/* Scroll-to-bottom button — appears when user has scrolled up */} + {terminalReady && !isAtBottom && ( + + )} {/* Right sidebar — collapsible */} diff --git a/src/components/AgentWorld/useAgentDialogTerminal.ts b/src/components/AgentWorld/useAgentDialogTerminal.ts index cdccf6f9..a08397c8 100644 --- a/src/components/AgentWorld/useAgentDialogTerminal.ts +++ b/src/components/AgentWorld/useAgentDialogTerminal.ts @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import type { AgentStatus } from '@/types/electron'; import { isElectron } from '@/hooks/useElectron'; import { attachShiftEnterHandler, stripCursorSequences } from '@/lib/terminal'; @@ -29,10 +29,12 @@ export function useAgentDialogTerminal({ skipHistoricalOutput, }: UseAgentDialogTerminalOptions) { const [terminalReady, setTerminalReady] = useState(false); + const [isAtBottom, setIsAtBottom] = useState(true); const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const agentIdRef = useRef(null); + const isAtBottomRef = useRef(true); // Keep agentIdRef current useEffect(() => { @@ -49,6 +51,10 @@ export function useAgentDialogTerminal({ fitAddonRef.current = null; } + // Reset scroll-lock state for new session + isAtBottomRef.current = true; + setIsAtBottom(true); + let cancelled = false; const initTerminal = async () => { @@ -84,6 +90,15 @@ export function useAgentDialogTerminal({ xtermRef.current = term; fitAddonRef.current = fitAddon; + // Track whether user is at the bottom so we don't hijack scroll position + term.onScroll(() => { + const buffer = term.buffer.active; + const maxY = Math.max(0, buffer.length - term.rows); + const atBottom = buffer.viewportY >= maxY - 2; + isAtBottomRef.current = atBottom; + setIsAtBottom(atBottom); + }); + const fitAndResize = () => { try { fitAddon.fit(); @@ -182,7 +197,10 @@ export function useAgentDialogTerminal({ if (fitAddonRef.current && xtermRef.current) { try { fitAddonRef.current.fit(); - xtermRef.current.scrollToBottom(); + // Only scroll to bottom if user was already at the bottom — don't hijack manual scroll position + if (isAtBottomRef.current) { + xtermRef.current.scrollToBottom(); + } const id = agentIdRef.current; if (id && window.electronAPI?.agent?.resize) { window.electronAPI.agent.resize({ id, cols: xtermRef.current.cols, rows: xtermRef.current.rows }).catch(() => {}); @@ -201,7 +219,9 @@ export function useAgentDialogTerminal({ if (!terminalReady || !fitAddonRef.current || !xtermRef.current) return; const t1 = setTimeout(() => { fitAddonRef.current?.fit(); - xtermRef.current?.scrollToBottom(); + if (isAtBottomRef.current) { + xtermRef.current?.scrollToBottom(); + } const id = agentIdRef.current; if (id && xtermRef.current && window.electronAPI?.agent?.resize) { window.electronAPI.agent.resize({ id, cols: xtermRef.current.cols, rows: xtermRef.current.rows }).catch(() => {}); @@ -209,10 +229,21 @@ export function useAgentDialogTerminal({ }, 50); const t2 = setTimeout(() => { fitAddonRef.current?.fit(); - xtermRef.current?.scrollToBottom(); + if (isAtBottomRef.current) { + xtermRef.current?.scrollToBottom(); + } }, 150); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [isFullscreen, terminalReady]); - return { terminalReady, terminalRef, xtermRef, agentIdRef }; + // Exposed scroll-to-bottom: re-anchors viewport and re-enables auto-scroll + const scrollToBottom = useCallback(() => { + if (xtermRef.current) { + xtermRef.current.scrollToBottom(); + isAtBottomRef.current = true; + setIsAtBottom(true); + } + }, []); + + return { terminalReady, terminalRef, xtermRef, agentIdRef, isAtBottom, scrollToBottom }; } From af7f2d7f7a2f6511fc747064b6714b3fff101f2c Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 11 Apr 2026 17:36:32 +0400 Subject: [PATCH 03/15] fix: reliable orchestrator reconnection to idle agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three compounding issues in agent-routes caused the orchestrator to see "not connected" errors when delegating to idle agents: 1. /start never killed the previous PTY before spawning a new one. The claude CLI stays attached to the TTY after each task, so every /start leaked another live claude process. Eventually spawns failed silently, which the orchestrator experienced as "not connected". 2. /message's reconnection path called initAgentPtyCallback, which spawns a bare `bash -l` shell — not a claude session. Messages were written into bash and silently dropped, and because the exit handler in agent-manager never emitted agentStatusEmitter, /wait hung until timeout. 3. /start's onExit handler only transitioned `running` → completed/error. If the PTY died while status was `waiting`, the status stayed `waiting` forever and blocked every future /wait. Fix: /start now kills any existing PTY before spawning, and onExit also transitions `waiting` → error. /message auto-respawns a fresh one-shot PTY with the message as the prompt when none is alive, registering the same onData/onExit handlers (including agentStatusEmitter.emit) as /start. Adds three regression tests covering each scenario. All 870 tests pass. Co-Authored-By: Claude Opus 4.6 --- .../services/api-routes/agent-routes.test.ts | 65 ++++++++++++--- electron/services/api-routes/agent-routes.ts | 80 ++++++++++++++++++- 2 files changed, 134 insertions(+), 11 deletions(-) diff --git a/__tests__/electron/services/api-routes/agent-routes.test.ts b/__tests__/electron/services/api-routes/agent-routes.test.ts index 2e4e27c6..25a94e88 100644 --- a/__tests__/electron/services/api-routes/agent-routes.test.ts +++ b/__tests__/electron/services/api-routes/agent-routes.test.ts @@ -215,6 +215,49 @@ describe('agent-routes', () => { }); }); + describe('POST /api/agents/:id/start', () => { + it('kills existing PTY before spawning a new one', async () => { + const existingPty = { kill: vi.fn() }; + ptyProcesses.set('existing-pty', existingPty as any); + const agent = makeAgent({ id: 'a1', status: 'idle', ptyId: 'existing-pty' }); + agents.set('a1', agent); + + const app = makeRouteApp(); + registerAgentRoutes(app, ctx); + const handler = findHandler(app, 'POST', 'start'); + + const sendJson = vi.fn(); + await handler(makeReq({ params: { id: 'a1' }, body: { prompt: 'Do something' } }), sendJson, ctx); + + expect(existingPty.kill).toHaveBeenCalled(); + expect(ptyProcesses.has('existing-pty')).toBe(false); + expect(sendJson).toHaveBeenCalledWith({ success: true, agent: { id: 'a1', status: 'running' } }); + }); + + it('transitions waiting→error when PTY exits while agent is waiting', async () => { + const agent = makeAgent({ id: 'a1', status: 'idle' }); + agents.set('a1', agent); + + const app = makeRouteApp(); + registerAgentRoutes(app, ctx); + const handler = findHandler(app, 'POST', 'start'); + + const sendJson = vi.fn(); + await handler(makeReq({ params: { id: 'a1' }, body: { prompt: 'Do something' } }), sendJson, ctx); + + // Simulate status being set to 'waiting' by a hook while the PTY is running + agent.status = 'waiting'; + + // Simulate PTY exit + const exitHandler = mockPtyProcess.onExit.mock.calls[0][0] as (args: { exitCode: number }) => void; + exitHandler({ exitCode: 1 }); + + // After the 1500ms delay, status should become 'error' not stay 'waiting' + await new Promise(r => setTimeout(r, 1600)); + expect(agent.status).toBe('error'); + }); + }); + describe('POST /api/agents/:id/message', () => { it('sends message to agent PTY', async () => { const mockPty = { write: vi.fn() }; @@ -233,22 +276,26 @@ describe('agent-routes', () => { expect(sendJson).toHaveBeenCalledWith({ success: true }); }); - it('initializes PTY if not present', async () => { - const mockPty = { write: vi.fn() }; - const agent = makeAgent({ id: 'a1', status: 'waiting' }); + it('auto-respawns PTY with message as prompt when PTY is missing', async () => { + const agent = makeAgent({ id: 'a1', status: 'waiting', projectPath: '/test/project' }); agents.set('a1', agent); - // initAgentPtyCallback returns 'new-pty-id', so set up that PTY - ptyProcesses.set('new-pty-id', mockPty as any); - const app = makeRouteApp(); registerAgentRoutes(app, ctx); const handler = findHandler(app, 'POST', 'message'); const sendJson = vi.fn(); - await handler(makeReq({ params: { id: 'a1' }, body: { message: 'hello' } }), sendJson, ctx); - - expect(ctx.initAgentPtyCallback).toHaveBeenCalledWith(agent); + await handler(makeReq({ params: { id: 'a1' }, body: { message: 'continue the task' } }), sendJson, ctx); + + // Should NOT call the legacy initAgentPtyCallback (bare bash shell) + expect(ctx.initAgentPtyCallback).not.toHaveBeenCalled(); + // Should have spawned a new one-shot PTY + const pty = await import('node-pty'); + expect(pty.spawn).toHaveBeenCalled(); + // Agent should be running and PTY registered + expect(agent.status).toBe('running'); + expect(agent.ptyId).toBe('test-uuid'); + expect(ptyProcesses.has('test-uuid')).toBe(true); expect(sendJson).toHaveBeenCalledWith({ success: true }); }); }); diff --git a/electron/services/api-routes/agent-routes.ts b/electron/services/api-routes/agent-routes.ts index 1d9f9953..98733253 100644 --- a/electron/services/api-routes/agent-routes.ts +++ b/electron/services/api-routes/agent-routes.ts @@ -208,6 +208,18 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { const shell = '/bin/bash'; const fullPath = buildFullPath(); + // Kill any existing PTY for this agent before spawning a new one. + // Agents started via the API use one-shot PTYs that stay alive (the claude + // process waits at a prompt after each task). Without this, every /start call + // orphans the previous PTY+claude process, eventually exhausting resources. + if (agent.ptyId) { + const existingPty = ptyProcesses.get(agent.ptyId); + if (existingPty) { + existingPty.kill(); + ptyProcesses.delete(agent.ptyId); + } + } + const ptyProcess = pty.spawn(shell, ['-l', '-c', command], { name: 'xterm-256color', cols: 120, @@ -253,6 +265,11 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { setTimeout(() => { if (agent.status === 'running') { agent.status = exitCode === 0 ? 'completed' : 'error'; + } else if (agent.status === 'waiting') { + // PTY exited while agent was waiting for input — the claude process + // crashed. Mark as error so /wait is unblocked and the orchestrator + // can retry rather than hanging until timeout. + agent.status = 'error'; } if (exitCode !== 0) { agent.error = `Process exited with code ${exitCode}`; @@ -305,8 +322,67 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { } if (!agent.ptyId || !ptyProcesses.has(agent.ptyId)) { - const ptyId = await ctx.initAgentPtyCallback(agent); - agent.ptyId = ptyId; + // No live PTY — the claude process exited (e.g. crashed while 'waiting'). + // Auto-respawn: start a fresh one-shot claude session using the message + // as the prompt, identical to the /start path. This ensures send_message + // and delegate_task reconnect transparently instead of timing out. + const workingDir = (agent.worktreePath || agent.projectPath).replace(/'/g, "'\\''"); + const effectiveMode = agent.permissionMode ?? (agent.skipPermissions ? 'auto' : 'normal'); + let reconnectCmd = `cd '${workingDir}' && claude`; + if (effectiveMode === 'auto' || effectiveMode === 'bypass') { + reconnectCmd += ' --dangerously-skip-permissions'; + } + reconnectCmd += ` '${message.replace(/'/g, "'\\''")}'`; + + const reconnectShell = '/bin/bash'; + const reconnectPath = buildFullPath(); + const reconnectPty = pty.spawn(reconnectShell, ['-l', '-c', reconnectCmd], { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: workingDir, + env: { + ...process.env as { [key: string]: string }, + PATH: reconnectPath, + CLAUDE_AGENT_ID: agent.id, + CLAUDE_PROJECT_PATH: agent.projectPath, + }, + }); + + const reconnectPtyId = uuidv4(); + ptyProcesses.set(reconnectPtyId, reconnectPty); + agent.ptyId = reconnectPtyId; + agent.status = 'running'; + agent.currentTask = message.slice(0, 100); + agent.lastActivity = new Date().toISOString(); + + reconnectPty.onData((data: string) => { + agent.output.push(data); + if (agent.output.length > 10000) agent.output = agent.output.slice(-5000); + agent.lastActivity = new Date().toISOString(); + if (ctx.mainWindow && !ctx.mainWindow.isDestroyed()) { + ctx.mainWindow.webContents.send('agent:output', { agentId: agent.id, data }); + } + }); + + reconnectPty.onExit(({ exitCode }) => { + setTimeout(() => { + if (agent.status === 'running') { + agent.status = exitCode === 0 ? 'completed' : 'error'; + } else if (agent.status === 'waiting') { + agent.status = 'error'; + } + if (exitCode !== 0) agent.error = `Process exited with code ${exitCode}`; + agent.lastActivity = new Date().toISOString(); + ptyProcesses.delete(reconnectPtyId); + saveAgents(); + ctx.agentStatusEmitter.emit(`status:${agent.id}`); + }, 1500); + }); + + saveAgents(); + sendJson({ success: true }); + return; } const ptyProcess = ptyProcesses.get(agent.ptyId); From f287b0e8aefba1537976ce3ad9f87c54143d7d30 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 11 Apr 2026 19:32:48 +0400 Subject: [PATCH 04/15] fix: prevent worktree agents from writing to main workspace Agents with a worktreePath could still edit files in the main workspace because agent:update let users add a worktree *after* the PTY had already been spawned with cwd=projectPath. The running bash kept its original cwd, and /message reused that stale PTY without any check. Fix is structural: track ptyCwd as ground truth at every spawn site and call killStalePty() at every PTY-reuse site. Any future code path that mutates worktreePath is now forced to respawn cleanly. - agent-manager: export killStalePty(); clear ptyCwd in loadAgents; record ptyCwd in initAgentPty - ipc-handlers: killStalePty on agent:start; killStalePty after mutating worktreePath in agent:update; record ptyCwd in agent:create and local provider recreation - agent-routes /start: pass the *raw* worktree path to pty.spawn's cwd option (the shell-escaped form is only for the `cd '...'` command string) and record ptyCwd - agent-routes /message: killStalePty before reusing PTY; fix cwd option and record ptyCwd on reconnect - slack-bot, telegram-bot, main.ts kanban startAgent: killStalePty before PTY init for every entry point that reuses agent PTYs - tests: regression coverage in agent-routes.test.ts under "BUG 4" Co-Authored-By: Claude Opus 4.6 --- .../services/api-routes/agent-routes.test.ts | 83 ++++++++++++++++++- electron/core/agent-manager.ts | 32 +++++++ electron/handlers/ipc-handlers.ts | 14 ++++ electron/main.ts | 5 ++ electron/services/api-routes/agent-routes.ts | 22 +++-- electron/services/slack-bot.ts | 10 ++- electron/services/telegram-bot.ts | 8 ++ electron/types/index.ts | 3 + 8 files changed, 170 insertions(+), 7 deletions(-) diff --git a/__tests__/electron/services/api-routes/agent-routes.test.ts b/__tests__/electron/services/api-routes/agent-routes.test.ts index 25a94e88..d1a151f7 100644 --- a/__tests__/electron/services/api-routes/agent-routes.test.ts +++ b/__tests__/electron/services/api-routes/agent-routes.test.ts @@ -25,6 +25,7 @@ vi.mock('../../../../electron/core/agent-manager', () => ({ agents: new Map(), saveAgents: vi.fn(), initAgentPty: vi.fn(), + killStalePty: vi.fn(), })); vi.mock('../../../../electron/core/pty-manager', () => ({ @@ -37,7 +38,7 @@ vi.mock('../../../../electron/utils/path-builder', () => ({ })); import { registerAgentRoutes } from '../../../../electron/services/api-routes/agent-routes'; -import { agents, saveAgents } from '../../../../electron/core/agent-manager'; +import { agents, saveAgents, killStalePty } from '../../../../electron/core/agent-manager'; import { ptyProcesses, writeProgrammaticInput } from '../../../../electron/core/pty-manager'; import { RouteApp, RouteContext, RouteRequest, SendJson } from '../../../../electron/services/api-routes/types'; import { AgentStatus, AppSettings } from '../../../../electron/types'; @@ -85,6 +86,7 @@ beforeEach(() => { agents.clear(); ptyProcesses.clear(); vi.mocked(saveAgents).mockClear(); + vi.mocked(killStalePty).mockClear(); mockPtyProcess.onData.mockClear(); mockPtyProcess.onExit.mockClear(); mockPtyProcess.kill.mockClear(); @@ -298,6 +300,85 @@ describe('agent-routes', () => { expect(ptyProcesses.has('test-uuid')).toBe(true); expect(sendJson).toHaveBeenCalledWith({ success: true }); }); + + it('BUG 4: calls killStalePty before reusing existing PTY', async () => { + const mockPty = { write: vi.fn() }; + ptyProcesses.set('pty-1', mockPty as any); + const agent = makeAgent({ + id: 'a1', + status: 'running', + ptyId: 'pty-1', + projectPath: '/test/project', + worktreePath: '/test/project/.worktrees/feat/backend', + ptyCwd: '/test/project/.worktrees/feat/backend', + }); + agents.set('a1', agent); + + const app = makeRouteApp(); + registerAgentRoutes(app, ctx); + const handler = findHandler(app, 'POST', 'message'); + + await handler(makeReq({ params: { id: 'a1' }, body: { message: 'hi' } }), vi.fn(), ctx); + + // killStalePty must be called on every /message so stale cwd is caught + expect(killStalePty).toHaveBeenCalledWith(agent); + }); + + it('BUG 4: reconnect path records worktreePath as ptyCwd', async () => { + const agent = makeAgent({ + id: 'a1', + status: 'waiting', + projectPath: '/test/project', + worktreePath: '/test/project/.worktrees/feat/backend', + }); + agents.set('a1', agent); + + const app = makeRouteApp(); + registerAgentRoutes(app, ctx); + const handler = findHandler(app, 'POST', 'message'); + + await handler(makeReq({ params: { id: 'a1' }, body: { message: 'go' } }), vi.fn(), ctx); + + // ptyCwd must match the worktree path — this is the cwd bash/claude + // actually inherits from pty.spawn. Without it, killStalePty can't + // detect a later worktree change. + expect(agent.ptyCwd).toBe('/test/project/.worktrees/feat/backend'); + + // pty.spawn must receive the raw path (not the shell-escaped version) + // so it works for paths that legitimately contain a single quote. + const pty = await import('node-pty'); + expect(pty.spawn).toHaveBeenCalled(); + const spawnOpts = (pty.spawn as any).mock.calls.at(-1)[2]; + expect(spawnOpts.cwd).toBe('/test/project/.worktrees/feat/backend'); + }); + }); + + describe('BUG 4 cwd invariants', () => { + it('POST /start uses worktreePath as raw spawn cwd and records ptyCwd', async () => { + const agent = makeAgent({ + id: 'a1', + status: 'idle', + projectPath: '/test/project', + worktreePath: '/test/project/.worktrees/feat/backend', + }); + agents.set('a1', agent); + + const app = makeRouteApp(); + registerAgentRoutes(app, ctx); + const handler = findHandler(app, 'POST', 'start'); + + await handler(makeReq({ params: { id: 'a1' }, body: { prompt: 'work' } }), vi.fn(), ctx); + + // ptyCwd must match the logical worktree path so killStalePty has + // ground truth to compare against when worktreePath later changes. + expect(agent.ptyCwd).toBe('/test/project/.worktrees/feat/backend'); + + // pty.spawn must receive the raw (non-shell-escaped) worktree path. + // Passing the escaped form would break paths containing single quotes. + const pty = await import('node-pty'); + const spawnOpts = (pty.spawn as any).mock.calls.at(-1)[2]; + expect(spawnOpts.cwd).toBe('/test/project/.worktrees/feat/backend'); + }); }); describe('DELETE /api/agents/:id', () => { diff --git a/electron/core/agent-manager.ts b/electron/core/agent-manager.ts index b678750e..3bdee09b 100644 --- a/electron/core/agent-manager.ts +++ b/electron/core/agent-manager.ts @@ -32,6 +32,36 @@ export function clearSuperAgentOutputBuffer() { superAgentOutputBuffer = []; } +/** + * Kill the agent's PTY if its recorded cwd no longer matches the agent's + * current logical working directory (worktreePath ?? projectPath). Returns + * true if the PTY was killed (caller should respawn before writing to it). + * + * This prevents the BUG 4 scenario where an agent has a worktree added + * after its PTY was created: the running PTY keeps its old cwd, so + * subsequent messages land in the main workspace instead of the worktree. + */ +export function killStalePty(agent: AgentStatus): boolean { + if (!agent.ptyId) return false; + const expectedCwd = agent.worktreePath || agent.projectPath; + if (agent.ptyCwd === expectedCwd) return false; + const existing = ptyProcesses.get(agent.ptyId); + if (existing) { + try { + existing.kill(); + } catch (err) { + console.warn(`Failed to kill stale PTY for agent ${agent.id}:`, err); + } + ptyProcesses.delete(agent.ptyId); + } + console.log( + `Killed stale PTY for agent ${agent.id}: ptyCwd=${agent.ptyCwd} expected=${expectedCwd}` + ); + agent.ptyId = undefined; + agent.ptyCwd = undefined; + return true; +} + const previousAgentStatus: Map = new Map(); const pendingStatusChanges: Map { const agentData = agents.get(agent.id); diff --git a/electron/handlers/ipc-handlers.ts b/electron/handlers/ipc-handlers.ts index fa7b9560..e8215594 100644 --- a/electron/handlers/ipc-handlers.ts +++ b/electron/handlers/ipc-handlers.ts @@ -19,6 +19,7 @@ import { buildFullPath } from '../utils/path-builder'; import { decodeProjectPath } from '../utils/decode-project-path'; import { getProvider, getAllProviders } from '../providers'; import { writeProgrammaticInput } from '../core/pty-manager'; +import { killStalePty } from '../core/agent-manager'; import { extractStatusLine } from '../utils/ansi'; import { scheduleTick } from '../utils/agents-tick'; @@ -367,6 +368,7 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { output: [], lastActivity: new Date().toISOString(), ptyId, + ptyCwd: cwd, character: config.character || 'robot', name: config.name || `Agent ${id.slice(0, 4)}`, permissionMode: config.permissionMode || 'normal', @@ -448,6 +450,11 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { throw new Error(`Invalid model name: ${options.model}`); } + // If the agent's worktreePath changed after the PTY was spawned, the + // existing PTY is stuck in the wrong cwd. Kill it so initAgentPty below + // respawns with the correct working directory. + killStalePty(agent); + // Initialize PTY if agent was restored from disk and doesn't have one let ptyJustCreated = false; if (!agent.ptyId || !ptyProcesses.has(agent.ptyId)) { @@ -528,6 +535,7 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { const newPtyId = uuidv4(); ptyProcesses.set(newPtyId, newPty); agent.ptyId = newPtyId; + agent.ptyCwd = cwd; // Re-attach event handlers newPty.onData((data) => { @@ -775,6 +783,12 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { } agent.worktreePath = worktreePath; agent.branchName = branchName; + // BUG 4: the running PTY (if any) was spawned with cwd=projectPath. + // It must be killed so the next agent:start respawns in the new + // worktree directory — otherwise writes leak into the main workspace. + killStalePty(agent); + agent.status = 'idle'; + agent.currentTask = undefined; } catch (err) { console.error('Failed to create worktree on update:', err); return { success: false, error: 'Failed to create git worktree' }; diff --git a/electron/main.ts b/electron/main.ts index 2b20d1de..d4003a5a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -37,6 +37,7 @@ import { handleStatusChangeNotification, getSuperAgentOutputBuffer, clearSuperAgentOutputBuffer, + killStalePty, } from './core/agent-manager'; import { @@ -460,6 +461,7 @@ app.whenReady().then(async () => { output: [], lastActivity: new Date().toISOString(), ptyId, + ptyCwd: cwd, character: config.character || 'robot', name: config.name || `Agent ${id.slice(0, 4)}`, permissionMode: config.permissionMode || 'auto', @@ -518,6 +520,9 @@ app.whenReady().then(async () => { const agent = agents.get(agentId); if (!agent) throw new Error('Agent not found'); + // BUG 4 guard: kill stale PTY if worktreePath changed after spawn. + killStalePty(agent); + // Initialize PTY if needed if (!agent.ptyId || !ptyProcesses.has(agent.ptyId)) { const ptyId = await initAgentPty( diff --git a/electron/services/api-routes/agent-routes.ts b/electron/services/api-routes/agent-routes.ts index c9a795dd..45774abe 100644 --- a/electron/services/api-routes/agent-routes.ts +++ b/electron/services/api-routes/agent-routes.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as pty from 'node-pty'; import { app } from 'electron'; import { v4 as uuidv4 } from 'uuid'; -import { agents, saveAgents } from '../../core/agent-manager'; +import { agents, saveAgents, killStalePty } from '../../core/agent-manager'; import { ptyProcesses, writeProgrammaticInput } from '../../core/pty-manager'; import { buildFullPath } from '../../utils/path-builder'; import { AgentStatus, AgentCharacter } from '../../types'; @@ -162,7 +162,11 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { return; } - const workingDir = (agent.worktreePath || agent.projectPath).replace(/'/g, "'\\''"); + // Raw cwd for pty.spawn, shell-escaped form for the `cd` command. These + // must be separate — passing the shell-escaped form to pty.spawn would + // break when the path legitimately contains a single quote. + const rawWorkingDir = agent.worktreePath || agent.projectPath; + const workingDir = rawWorkingDir.replace(/'/g, "'\\''"); let command = `cd '${workingDir}' && claude`; const isAutomationAgent = agent.name?.toLowerCase().includes('automation:'); @@ -227,7 +231,7 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { name: 'xterm-256color', cols: 120, rows: 40, - cwd: workingDir, + cwd: rawWorkingDir, env: { ...process.env, PATH: fullPath, @@ -242,6 +246,7 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { ptyProcesses.set(ptyId, ptyProcess); agent.ptyId = ptyId; + agent.ptyCwd = rawWorkingDir; agent.status = 'running'; agent.currentTask = prompt; agent.output = []; @@ -324,12 +329,18 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { return; } + // BUG 4 guard: if the agent's worktreePath changed after the PTY was + // spawned, the existing PTY is stuck in the wrong cwd. Kill it so the + // reconnect path below spawns fresh with the correct working directory. + killStalePty(agent); + if (!agent.ptyId || !ptyProcesses.has(agent.ptyId)) { // No live PTY — the claude process exited (e.g. crashed while 'waiting'). // Auto-respawn: start a fresh one-shot claude session using the message // as the prompt, identical to the /start path. This ensures send_message // and delegate_task reconnect transparently instead of timing out. - const workingDir = (agent.worktreePath || agent.projectPath).replace(/'/g, "'\\''"); + const rawWorkingDir = agent.worktreePath || agent.projectPath; + const workingDir = rawWorkingDir.replace(/'/g, "'\\''"); const effectiveMode = agent.permissionMode ?? (agent.skipPermissions ? 'auto' : 'normal'); let reconnectCmd = `cd '${workingDir}' && claude`; if (effectiveMode === 'auto' || effectiveMode === 'bypass') { @@ -343,7 +354,7 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { name: 'xterm-256color', cols: 120, rows: 40, - cwd: workingDir, + cwd: rawWorkingDir, env: { ...process.env as { [key: string]: string }, PATH: reconnectPath, @@ -355,6 +366,7 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { const reconnectPtyId = uuidv4(); ptyProcesses.set(reconnectPtyId, reconnectPty); agent.ptyId = reconnectPtyId; + agent.ptyCwd = rawWorkingDir; agent.status = 'running'; agent.currentTask = message.slice(0, 100); agent.lastActivity = new Date().toISOString(); diff --git a/electron/services/slack-bot.ts b/electron/services/slack-bot.ts index d45a95db..f86795c4 100644 --- a/electron/services/slack-bot.ts +++ b/electron/services/slack-bot.ts @@ -4,7 +4,7 @@ import { App as SlackApp, LogLevel } from '@slack/bolt'; import { AgentStatus, AppSettings } from '../types'; import { SLACK_CHARACTER_FACES } from '../constants'; import { formatSlackAgentStatus, isSuperAgent, getSuperAgent, getSuperAgentInstructions } from '../utils'; -import { agents, saveAgents, initAgentPty } from '../core/agent-manager'; +import { agents, saveAgents, initAgentPty, killStalePty } from '../core/agent-manager'; import { ptyProcesses, writeProgrammaticInput } from '../core/pty-manager'; import { getMainWindow } from '../core/window-manager'; import { app } from 'electron'; @@ -441,6 +441,10 @@ export async function handleSlackCommand( try { const workingPath = (agent.worktreePath || agent.projectPath).replace(/'/g, "'\\''"); + // BUG 4 guard: if worktreePath changed after PTY spawn, the running + // PTY is in the wrong cwd. Kill it so initAgentPty respawns correctly. + killStalePty(agent); + if (!agent.ptyId || !ptyProcesses.has(agent.ptyId)) { const ptyId = await initAgentPtyWithCallbacks(agent); agent.ptyId = ptyId; @@ -531,6 +535,10 @@ export async function sendToSuperAgentFromSlack( const sanitizedMessage = message.replace(/\r?\n/g, ' ').trim(); try { + // BUG 4 guard: if worktreePath changed after PTY spawn, the existing + // PTY is stuck in the wrong cwd. Kill it so initAgentPty respawns. + killStalePty(superAgent); + // Initialize PTY if needed if (!superAgent.ptyId || !ptyProcesses.has(superAgent.ptyId)) { const ptyId = await initAgentPtyWithCallbacks(superAgent); diff --git a/electron/services/telegram-bot.ts b/electron/services/telegram-bot.ts index 4dfedfd7..010e0cae 100644 --- a/electron/services/telegram-bot.ts +++ b/electron/services/telegram-bot.ts @@ -9,6 +9,7 @@ import { TG_CHARACTER_FACES, TELEGRAM_DOWNLOADS_DIR } from '../constants'; import { isSuperAgent, formatAgentStatus, getSuperAgentInstructions, getSuperAgentInstructionsPath, getTelegramInstructions, getTelegramInstructionsPath } from '../utils'; import { getProvider } from '../providers'; import { writeProgrammaticInput } from '../core/pty-manager'; +import { killStalePty } from '../core/agent-manager'; // ============== Telegram Bot State ============== let telegramBot: TelegramBot | null = null; @@ -688,6 +689,9 @@ export function initTelegramBot() { // Start the agent using the existing IPC mechanism const workingPath = (agent.worktreePath || agent.projectPath).replace(/'/g, "'\\''"); + // BUG 4 guard: kill PTY if its cwd is stale before reusing it. + killStalePty(agent); + // Initialize PTY if needed if (!agent.ptyId || !ptyProcesses.has(agent.ptyId)) { const ptyId = await initAgentPty(agent); @@ -1173,6 +1177,10 @@ export async function sendToSuperAgent(chatId: string, message: string, attached const sanitizedMessage = fullMessage.replace(/\r?\n/g, ' ').trim(); try { + // BUG 4 guard: if worktreePath changed since the PTY was spawned, its + // cwd is stale — kill it so initAgentPty respawns in the right directory. + killStalePty(superAgent); + // Initialize PTY if needed if (!superAgent.ptyId || !ptyProcesses.has(superAgent.ptyId)) { const ptyId = await initAgentPty(superAgent); diff --git a/electron/types/index.ts b/electron/types/index.ts index 27a2e77c..fc3f24bb 100644 --- a/electron/types/index.ts +++ b/electron/types/index.ts @@ -34,6 +34,9 @@ export interface AgentStatus { lastActivity: string; error?: string; ptyId?: string; + /** CWD the active PTY was spawned with. Used to detect stale PTYs when + * the agent's worktreePath changes after the PTY was started. Not persisted. */ + ptyCwd?: string; character?: AgentCharacter; name?: string; pathMissing?: boolean; From 95c8078985eca9a0c3cdc2e9c4483a3d85d3d012 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 11 Apr 2026 22:45:23 +0400 Subject: [PATCH 05/15] fix: orchestrator tool restrictions and bypass trust dialog - Orchestrator Mode now blocks Edit/Write/MultiEdit/NotebookEdit via --disallowed-tools, plumbed from NewChatModal through IPC, HTTP, Telegram, and Slack spawn paths. Super Agent is implicitly treated as orchestrator. Bash stays available so orchestrators can run git/gh. - Bypass-mode agents no longer hit the workspace trust dialog on first launch. New ensureProjectTrusted helper pre-populates ~/.claude.json's hasTrustDialogAccepted before every pty.spawn (IPC create/start, HTTP /start + /message reconnect, initAgentPty). Co-Authored-By: Claude Opus 4.6 --- .../services/api-routes/agent-routes.test.ts | 1 + electron/core/agent-manager.ts | 61 +++++++++++++++++++ electron/handlers/ipc-handlers.ts | 18 +++++- electron/preload.ts | 2 + electron/providers/claude-provider.ts | 8 +++ electron/providers/cli-provider.ts | 3 + electron/services/api-routes/agent-routes.ts | 21 ++++++- electron/services/slack-bot.ts | 7 +++ electron/services/telegram-bot.ts | 2 + electron/types/index.ts | 4 ++ src/app/agents/page.tsx | 5 +- .../CanvasView/hooks/useAgentActions.ts | 5 +- src/components/NewChatModal/index.tsx | 4 +- src/components/NewChatModal/types.ts | 3 + src/hooks/useElectron.ts | 2 + src/types/electron.d.ts | 4 ++ 16 files changed, 144 insertions(+), 6 deletions(-) diff --git a/__tests__/electron/services/api-routes/agent-routes.test.ts b/__tests__/electron/services/api-routes/agent-routes.test.ts index d1a151f7..8e63bf00 100644 --- a/__tests__/electron/services/api-routes/agent-routes.test.ts +++ b/__tests__/electron/services/api-routes/agent-routes.test.ts @@ -26,6 +26,7 @@ vi.mock('../../../../electron/core/agent-manager', () => ({ saveAgents: vi.fn(), initAgentPty: vi.fn(), killStalePty: vi.fn(), + ensureProjectTrusted: vi.fn(), })); vi.mock('../../../../electron/core/pty-manager', () => ({ diff --git a/electron/core/agent-manager.ts b/electron/core/agent-manager.ts index 3bdee09b..a31bf51b 100644 --- a/electron/core/agent-manager.ts +++ b/electron/core/agent-manager.ts @@ -16,6 +16,63 @@ import { scheduleTick } from '../utils/agents-tick'; export const agents: Map = new Map(); +/** + * Pre-populate Claude Code's workspace trust record for a given directory. + * + * BUG 6 fix: `--dangerously-skip-permissions` skips *runtime* permission + * prompts (Edit/Write/Bash confirmations), but Claude Code has a SEPARATE + * "workspace trust" dialog that fires on first launch in an unknown directory. + * That dialog is gated by `~/.claude.json`'s + * `projects[].hasTrustDialogAccepted` flag — NOT by the + * runtime permission mode. So even a bypass-mode agent hits the trust prompt + * on first launch in a new project. + * + * Writing the flag ourselves before we spawn the claude process makes the + * trust dialog never appear. Safe to call repeatedly and idempotent. + */ +export function ensureProjectTrusted(projectPath: string): void { + if (!projectPath) return; + const claudeJsonPath = path.join(os.homedir(), '.claude.json'); + type ClaudeConfig = { + projects?: Record; + [key: string]: unknown; + }; + let config: ClaudeConfig = {}; + try { + if (fs.existsSync(claudeJsonPath)) { + const raw = fs.readFileSync(claudeJsonPath, 'utf-8'); + if (raw.trim()) { + config = JSON.parse(raw) as ClaudeConfig; + } + } + } catch (err) { + console.warn(`ensureProjectTrusted: failed to read ${claudeJsonPath}:`, err); + // If the file exists but is unreadable/corrupt, don't overwrite it. + if (fs.existsSync(claudeJsonPath)) return; + } + + if (!config.projects) config.projects = {}; + const existing = config.projects[projectPath] ?? {}; + if (existing.hasTrustDialogAccepted === true) return; + + config.projects[projectPath] = { + ...existing, + hasTrustDialogAccepted: true, + projectOnboardingSeenCount: existing.projectOnboardingSeenCount ?? 1, + }; + + try { + fs.writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2)); + console.log(`ensureProjectTrusted: marked ${projectPath} trusted in ~/.claude.json`); + } catch (err) { + console.warn(`ensureProjectTrusted: failed to write ${claudeJsonPath}:`, err); + } +} + export let agentsLoaded = false; export let superAgentTelegramTask = false; export let superAgentOutputBuffer: string[] = []; @@ -273,6 +330,10 @@ export async function initAgentPty( cwd = os.homedir(); } + // BUG 6: pre-accept Claude Code's workspace trust dialog for this cwd so + // bypass-mode agents never see the first-launch prompt. + ensureProjectTrusted(cwd); + console.log(`Initializing PTY for restored agent ${agent.id} in ${cwd}`); // Build PATH that includes user-configured paths, nvm, and other common locations for claude diff --git a/electron/handlers/ipc-handlers.ts b/electron/handlers/ipc-handlers.ts index e8215594..9661ef20 100644 --- a/electron/handlers/ipc-handlers.ts +++ b/electron/handlers/ipc-handlers.ts @@ -19,7 +19,7 @@ import { buildFullPath } from '../utils/path-builder'; import { decodeProjectPath } from '../utils/decode-project-path'; import { getProvider, getAllProviders } from '../providers'; import { writeProgrammaticInput } from '../core/pty-manager'; -import { killStalePty } from '../core/agent-manager'; +import { killStalePty, ensureProjectTrusted } from '../core/agent-manager'; import { extractStatusLine } from '../utils/ansi'; import { scheduleTick } from '../utils/agents-tick'; @@ -223,6 +223,7 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { model?: string; localModel?: string; obsidianVaultPaths?: string[]; + orchestratorMode?: boolean; }) => { const id = uuidv4(); const shell = '/bin/bash'; @@ -324,6 +325,10 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { const agentProvider = getProvider(config.provider); const providerEnvVars = agentProvider.getPtyEnvVars(id, config.projectPath, allSkills); + // BUG 6: pre-accept Claude Code's workspace trust dialog for this cwd so + // bypass/auto-mode agents never see the first-launch trust prompt. + ensureProjectTrusted(cwd); + let ptyProcess: pty.IPty; try { ptyProcess = pty.spawn(shell, ['-l'], { @@ -373,6 +378,7 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { name: config.name || `Agent ${id.slice(0, 4)}`, permissionMode: config.permissionMode || 'normal', effort: config.effort, + orchestratorMode: config.orchestratorMode || false, provider: config.provider || 'claude', model: config.model, localModel: config.localModel, @@ -511,6 +517,9 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { const workingDir = agent.worktreePath || agent.projectPath; const cwd = fs.existsSync(workingDir) ? workingDir : os.homedir(); + // BUG 6: pre-accept Claude Code's workspace trust dialog for this cwd. + ensureProjectTrusted(cwd); + // Local provider uses Claude provider env vars + Tasmania env vars const localProviderEnvVars = getProvider('claude').getPtyEnvVars(agent.id, agent.projectPath, agent.skills); @@ -635,6 +644,9 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { skills: allAgentSkills, isSuperAgent: isSuperAgentCheck, chrome: appSettingsForCommand.chromeEnabled, + // BUG 5: Super Agent is implicitly an orchestrator; regular agents opt in + // via the "Orchestrator Mode" toggle in NewChatModal. + orchestratorMode: isSuperAgentCheck || agent.orchestratorMode, }); // Persist the prompt for future re-launches and update status @@ -712,6 +724,7 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { savedPrompt?: string | null; obsidianVaultPaths?: string[]; worktree?: WorktreeConfig; + orchestratorMode?: boolean; }) => { const agent = agents.get(params.id); if (!agent) { @@ -758,6 +771,9 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { if (params.obsidianVaultPaths !== undefined) { agent.obsidianVaultPaths = params.obsidianVaultPaths; } + if (params.orchestratorMode !== undefined) { + agent.orchestratorMode = params.orchestratorMode; + } if (params.worktree !== undefined && !agent.worktreePath) { // Only allow worktree setup if agent doesn't already have one // (worktree changes on a running agent could be destructive) diff --git a/electron/preload.ts b/electron/preload.ts index 9a3bd333..c6673981 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -55,6 +55,7 @@ contextBridge.exposeInMainWorld('electronAPI', { model?: string; localModel?: string; obsidianVaultPaths?: string[]; + orchestratorMode?: boolean; }) => ipcRenderer.invoke('agent:create', config), update: (params: { id: string; @@ -70,6 +71,7 @@ contextBridge.exposeInMainWorld('electronAPI', { savedPrompt?: string | null; obsidianVaultPaths?: string[]; worktree?: { enabled: boolean; branchName: string }; + orchestratorMode?: boolean; }) => ipcRenderer.invoke('agent:update', params), start: (params: { id: string; prompt: string; options?: { model?: string; resume?: boolean; provider?: string; localModel?: string } }) => ipcRenderer.invoke('agent:start', params), diff --git a/electron/providers/claude-provider.ts b/electron/providers/claude-provider.ts index 826cad40..09867877 100644 --- a/electron/providers/claude-provider.ts +++ b/electron/providers/claude-provider.ts @@ -68,6 +68,14 @@ export class ClaudeProvider implements CLIProvider { command += ' --dangerously-skip-permissions'; } + // Orchestrator mode: block all file-mutating tools so the agent cannot do + // implementation work itself and is forced to delegate. Bash is left + // available so it can still run git/gh and inspection commands — the + // orchestrator persona guides it to delegate coding instead of running it. + if (params.orchestratorMode) { + command += ' --disallowed-tools "Edit" "Write" "MultiEdit" "NotebookEdit"'; + } + // Effort level if (params.effort && params.effort !== 'medium') { command += ` --effort ${params.effort}`; diff --git a/electron/providers/cli-provider.ts b/electron/providers/cli-provider.ts index 98764942..1a39cd72 100644 --- a/electron/providers/cli-provider.ts +++ b/electron/providers/cli-provider.ts @@ -17,6 +17,9 @@ export interface InteractiveCommandParams { skills?: string[]; isSuperAgent?: boolean; chrome?: boolean; + /** Orchestrator mode: disable Edit/Write/MultiEdit/NotebookEdit so the agent + * cannot do implementation work itself and must delegate. See BUG 5. */ + orchestratorMode?: boolean; } /** diff --git a/electron/services/api-routes/agent-routes.ts b/electron/services/api-routes/agent-routes.ts index 45774abe..638b8c67 100644 --- a/electron/services/api-routes/agent-routes.ts +++ b/electron/services/api-routes/agent-routes.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as pty from 'node-pty'; import { app } from 'electron'; import { v4 as uuidv4 } from 'uuid'; -import { agents, saveAgents, killStalePty } from '../../core/agent-manager'; +import { agents, saveAgents, killStalePty, ensureProjectTrusted } from '../../core/agent-manager'; import { ptyProcesses, writeProgrammaticInput } from '../../core/pty-manager'; import { buildFullPath } from '../../utils/path-builder'; import { AgentStatus, AgentCharacter } from '../../types'; @@ -114,13 +114,14 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { // POST /api/agents app_.post('/api/agents', (req, sendJson) => { - const { projectPath, name, skills = [], character, permissionMode, secondaryProjectPath } = req.body as { + const { projectPath, name, skills = [], character, permissionMode, secondaryProjectPath, orchestratorMode } = req.body as { projectPath: string; name?: string; skills?: string[]; character?: AgentCharacter; permissionMode?: 'normal' | 'auto' | 'bypass'; secondaryProjectPath?: string; + orchestratorMode?: boolean; }; if (!projectPath) { @@ -140,6 +141,7 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { character, name: name || `Agent ${id.slice(0, 6)}`, permissionMode: permissionMode || 'auto', + orchestratorMode: orchestratorMode || false, }; agents.set(id, agent); saveAgents(); @@ -193,6 +195,10 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { if (effectiveMode === 'auto' || effectiveMode === 'bypass') { command += ' --dangerously-skip-permissions'; } + // BUG 5: orchestrator-mode agents cannot edit files directly — must delegate. + if (isSuperAgentApi || agent.orchestratorMode) { + command += ' --disallowed-tools "Edit" "Write" "MultiEdit" "NotebookEdit"'; + } const resolvedModel = model || agent.model; // 'default' is a Dorothy UI alias meaning "let Claude CLI pick"; omit the flag. if (resolvedModel && resolvedModel !== 'default') { @@ -227,6 +233,9 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { } } + // BUG 6: pre-accept Claude Code's workspace trust dialog for this cwd. + ensureProjectTrusted(rawWorkingDir); + const ptyProcess = pty.spawn(shell, ['-l', '-c', command], { name: 'xterm-256color', cols: 120, @@ -346,10 +355,18 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { if (effectiveMode === 'auto' || effectiveMode === 'bypass') { reconnectCmd += ' --dangerously-skip-permissions'; } + // BUG 5: orchestrator-mode agents cannot edit files directly — must delegate. + const reconnectIsSuper = agent.name?.toLowerCase().includes('super agent') || + agent.name?.toLowerCase().includes('orchestrator'); + if (reconnectIsSuper || agent.orchestratorMode) { + reconnectCmd += ' --disallowed-tools "Edit" "Write" "MultiEdit" "NotebookEdit"'; + } reconnectCmd += ` '${message.replace(/'/g, "'\\''")}'`; const reconnectShell = '/bin/bash'; const reconnectPath = buildFullPath(); + // BUG 6: pre-accept Claude Code's workspace trust dialog for this cwd. + ensureProjectTrusted(rawWorkingDir); const reconnectPty = pty.spawn(reconnectShell, ['-l', '-c', reconnectCmd], { name: 'xterm-256color', cols: 120, diff --git a/electron/services/slack-bot.ts b/electron/services/slack-bot.ts index f86795c4..e1959212 100644 --- a/electron/services/slack-bot.ts +++ b/electron/services/slack-bot.ts @@ -462,6 +462,10 @@ export async function handleSlackCommand( command += ` --add-dir '${agent.secondaryProjectPath.replace(/'/g, "'\\''")}'`; } command += ` --add-dir '${require('os').homedir()}/.dorothy'`; + // BUG 5: orchestrator-mode agents cannot edit files — must delegate. + if (isSuperAgent(agent) || agent.orchestratorMode) { + command += ' --disallowed-tools "Edit" "Write" "MultiEdit" "NotebookEdit"'; + } command += ` '${task.replace(/'/g, "'\\''")}'`; agent.status = 'running'; @@ -593,6 +597,9 @@ export async function sendToSuperAgentFromSlack( if (superAgent.permissionMode === 'auto' || superAgent.permissionMode === 'bypass' || (!superAgent.permissionMode && superAgent.skipPermissions)) command += ' --dangerously-skip-permissions'; + // BUG 5: Super Agent is an orchestrator — block file-mutating tools. + command += ' --disallowed-tools "Edit" "Write" "MultiEdit" "NotebookEdit"'; + // Simple prompt with Slack context - the detailed instructions come from the file const userPrompt = `[FROM SLACK - Use send_slack MCP tool to respond!] ${sanitizedMessage}`; command += ` '${userPrompt.replace(/'/g, "'\\''")}'`; diff --git a/electron/services/telegram-bot.ts b/electron/services/telegram-bot.ts index 010e0cae..7127aaf1 100644 --- a/electron/services/telegram-bot.ts +++ b/electron/services/telegram-bot.ts @@ -728,6 +728,7 @@ export function initTelegramBot() { mcpConfigPath, skills: [...new Set([...(agent.skills || []), 'world-builder'])], isSuperAgent: isSuperAgent(agent), + orchestratorMode: isSuperAgent(agent) || agent.orchestratorMode, }); agent.status = 'running'; @@ -1264,6 +1265,7 @@ export async function sendToSuperAgent(chatId: string, message: string, attached systemPromptFile, skills: [...new Set([...(superAgent.skills || []), 'world-builder'])], isSuperAgent: true, + orchestratorMode: true, }); superAgent.status = 'running'; diff --git a/electron/types/index.ts b/electron/types/index.ts index fc3f24bb..000f0f21 100644 --- a/electron/types/index.ts +++ b/electron/types/index.ts @@ -44,6 +44,10 @@ export interface AgentStatus { skipPermissions?: boolean; permissionMode?: AgentPermissionMode; effort?: AgentEffort; + /** When true, the agent is an orchestrator and should not have Edit/Write + * implementation tools available — it can only read, delegate, and use + * shell/git commands. See BUG 5. */ + orchestratorMode?: boolean; currentSessionId?: string; kanbanTaskId?: string; // For kanban task completion tracking statusLine?: string; // ANSI-stripped last meaningful output line diff --git a/src/app/agents/page.tsx b/src/app/agents/page.tsx index 690d3906..8e68491b 100644 --- a/src/app/agents/page.tsx +++ b/src/app/agents/page.tsx @@ -84,6 +84,7 @@ export default function AgentsPage() { branchName: agent.branchName, obsidianVaultPaths: agent.obsidianVaultPaths, savedPrompt: agent.savedPrompt, + orchestratorMode: agent.orchestratorMode, }; }, [editAgentId, agents]); @@ -102,10 +103,11 @@ export default function AgentsPage() { localModel?: string, obsidianVaultPaths?: string[], effort?: 'low' | 'medium' | 'high', + orchestratorMode?: boolean, ) => { try { const resolvedModel = (provider !== 'local' && model && model !== 'default') ? model : undefined; - const agent = await createAgent({ projectPath, skills, worktree, character, name, secondaryProjectPath, permissionMode, effort, provider, model: resolvedModel, localModel, obsidianVaultPaths }); + const agent = await createAgent({ projectPath, skills, worktree, character, name, secondaryProjectPath, permissionMode, effort, provider, model: resolvedModel, localModel, obsidianVaultPaths, orchestratorMode }); if (prompt) { const options = { model: resolvedModel, provider, localModel }; await startAgent(agent.id, prompt, options); @@ -129,6 +131,7 @@ export default function AgentsPage() { savedPrompt?: string | null; obsidianVaultPaths?: string[]; worktree?: { enabled: boolean; branchName: string }; + orchestratorMode?: boolean; }) => { try { await updateAgent({ id, ...updates }); diff --git a/src/components/CanvasView/hooks/useAgentActions.ts b/src/components/CanvasView/hooks/useAgentActions.ts index 353178a9..16d0825f 100644 --- a/src/components/CanvasView/hooks/useAgentActions.ts +++ b/src/components/CanvasView/hooks/useAgentActions.ts @@ -10,6 +10,7 @@ interface CreateAgentConfig { secondaryProjectPath?: string; permissionMode?: 'normal' | 'auto' | 'bypass'; effort?: 'low' | 'medium' | 'high'; + orchestratorMode?: boolean; } interface UseAgentActionsProps { @@ -78,9 +79,10 @@ export function useAgentActions({ _localModel?: string, _obsidianVaultPaths?: string[], effort?: 'low' | 'medium' | 'high', + orchestratorMode?: boolean, ) => { try { - const agent = await createAgent({ projectPath, skills, worktree, character, name, secondaryProjectPath, permissionMode, effort }); + const agent = await createAgent({ projectPath, skills, worktree, character, name, secondaryProjectPath, permissionMode, effort, orchestratorMode }); setShowCreateAgentModal(false); setCreateAgentProjectPath(null); @@ -131,6 +133,7 @@ export function useAgentActions({ character: 'wizard', name: 'Super Agent (Orchestrator)', permissionMode: 'auto', + orchestratorMode: true, }); await startAgent(agent.id, orchestratorPrompt); diff --git a/src/components/NewChatModal/index.tsx b/src/components/NewChatModal/index.tsx index 3488aea7..4b209c56 100644 --- a/src/components/NewChatModal/index.tsx +++ b/src/components/NewChatModal/index.tsx @@ -174,6 +174,7 @@ export default function NewChatModal({ setProvider(editAgent.provider || 'claude'); setLocalModel(editAgent.localModel || ''); setSelectedObsidianVaults(editAgent.obsidianVaultPaths || []); + setIsOrchestrator(editAgent.orchestratorMode || false); setDetectedVault(null); } else { // Create mode: reset everything @@ -353,6 +354,7 @@ export default function NewChatModal({ savedPrompt: prompt.trim() || null, obsidianVaultPaths: selectedObsidianVaults.length > 0 ? selectedObsidianVaults : [], worktree: worktreeConfig, + orchestratorMode: isOrchestrator, }); onClose(); return; @@ -363,7 +365,7 @@ export default function NewChatModal({ || (selectedSkills.length > 0 ? `Use the following skills: ${selectedSkills.join(', ')}` : ''); const worktreeConfig = useWorktree ? { enabled: true, branchName: branchName.trim() } : undefined; - onSubmit(projectPath, selectedSkills, finalPrompt, model, worktreeConfig, agentCharacter, finalName, secondaryPath, permissionMode, provider, localModel, selectedObsidianVaults.length > 0 ? selectedObsidianVaults : undefined, effort); + onSubmit(projectPath, selectedSkills, finalPrompt, model, worktreeConfig, agentCharacter, finalName, secondaryPath, permissionMode, provider, localModel, selectedObsidianVaults.length > 0 ? selectedObsidianVaults : undefined, effort, isOrchestrator); // Reset form setStep(1); diff --git a/src/components/NewChatModal/types.ts b/src/components/NewChatModal/types.ts index 41d17963..c8f55a64 100644 --- a/src/components/NewChatModal/types.ts +++ b/src/components/NewChatModal/types.ts @@ -34,6 +34,7 @@ export interface EditAgentData { branchName?: string; obsidianVaultPaths?: string[]; savedPrompt?: string; + orchestratorMode?: boolean; } export interface NewChatModalProps { @@ -53,6 +54,7 @@ export interface NewChatModalProps { localModel?: string, obsidianVaultPaths?: string[], effort?: AgentEffort, + orchestratorMode?: boolean, ) => void; onUpdate?: (id: string, updates: { skills?: string[]; @@ -67,6 +69,7 @@ export interface NewChatModalProps { savedPrompt?: string | null; obsidianVaultPaths?: string[]; worktree?: WorktreeConfig; + orchestratorMode?: boolean; }) => void; editAgent?: EditAgentData | null; projects: Project[]; diff --git a/src/hooks/useElectron.ts b/src/hooks/useElectron.ts index 924d0a36..fe9f6091 100644 --- a/src/hooks/useElectron.ts +++ b/src/hooks/useElectron.ts @@ -59,6 +59,7 @@ export function useElectronAgents() { model?: string; localModel?: string; obsidianVaultPaths?: string[]; + orchestratorMode?: boolean; }) => { if (!isElectron()) { throw new Error('Electron API not available'); @@ -83,6 +84,7 @@ export function useElectronAgents() { savedPrompt?: string | null; obsidianVaultPaths?: string[]; worktree?: { enabled: boolean; branchName: string }; + orchestratorMode?: boolean; }) => { if (!isElectron()) { throw new Error('Electron API not available'); diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 897acff0..475a95a0 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -148,6 +148,8 @@ export interface AgentStatus { skipPermissions?: boolean; permissionMode?: 'normal' | 'auto' | 'bypass'; effort?: 'low' | 'medium' | 'high'; + /** Orchestrator mode: agent cannot edit files directly; must delegate. */ + orchestratorMode?: boolean; provider?: AgentProvider; // 'claude' (default) or 'local' (Tasmania) model?: string; // Model name (e.g. 'sonnet', 'opus', 'haiku') localModel?: string; // Tasmania model name when provider is 'local' @@ -195,6 +197,7 @@ export interface ElectronAPI { provider?: AgentProvider; localModel?: string; obsidianVaultPaths?: string[]; + orchestratorMode?: boolean; }) => Promise; update: (params: { id: string; @@ -204,6 +207,7 @@ export interface ElectronAPI { effort?: 'low' | 'medium' | 'high'; name?: string; character?: AgentCharacter; + orchestratorMode?: boolean; }) => Promise<{ success: boolean; error?: string; agent?: AgentStatus }>; start: (params: { id: string; prompt: string; options?: { model?: string; resume?: boolean; provider?: AgentProvider; localModel?: string } }) => Promise<{ success: boolean }>; get: (id: string) => Promise; From a5d2e7f550feedc925e24130300b5b8ed5d1d2f0 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 11 Apr 2026 23:01:46 +0400 Subject: [PATCH 06/15] feat: wire six external AI providers via ANTHROPIC env injection Adds OpenRouter, DeepSeek, MiMo, Moonshot, Qwen, and ZhipuAI GLM as first-class providers. Each uses the existing claude binary and injects ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY into the PTY at spawn time, preferring the direct provider endpoint when its key is set and falling back to OpenRouter otherwise. - Extend AgentProvider union and AppSettings with per-provider enabled/apiKey fields. - Extend CLIProvider.getPtyEnvVars signature with optional appSettings and thread it through agent-manager + ipc-handlers spawn sites. - Register the six providers in the provider registry. - Add Settings > AI Providers section with one card per provider (name, docs link, enabled toggle, API key input, model list). Direct endpoints: - OpenRouter https://openrouter.ai/api/v1 - DeepSeek https://api.deepseek.com/v1 - MiMo https://api.mimo.com/v1 - Moonshot https://api.moonshot.cn/v1 - Qwen https://dashscope.aliyuncs.com/compatible-mode/v1 - ZhipuAI GLM https://open.bigmodel.cn/api/paas/v4/ Co-Authored-By: Claude Opus 4.6 --- electron/core/agent-manager.ts | 11 +- electron/handlers/ipc-handlers.ts | 4 +- electron/providers/claude-provider.ts | 2 +- electron/providers/cli-provider.ts | 2 +- electron/providers/codex-provider.ts | 2 +- electron/providers/deepseek-provider.ts | 201 ++++++++++++ electron/providers/gemini-provider.ts | 2 +- electron/providers/index.ts | 12 + electron/providers/mimo-provider.ts | 145 +++++++++ electron/providers/moonshot-provider.ts | 161 ++++++++++ electron/providers/opencode-provider.ts | 2 +- electron/providers/openrouter-provider.ts | 294 ++++++++++++++++++ electron/providers/pi-provider.ts | 2 +- electron/providers/qwen-provider.ts | 147 +++++++++ electron/providers/zhipu-provider.ts | 151 +++++++++ electron/types/index.ts | 28 +- src/app/settings/page.tsx | 9 + .../Settings/AIProvidersSection.tsx | 231 ++++++++++++++ src/components/Settings/constants.ts | 14 + src/components/Settings/index.ts | 1 + src/components/Settings/types.ts | 16 +- src/types/electron.d.ts | 14 +- 22 files changed, 1438 insertions(+), 13 deletions(-) create mode 100644 electron/providers/deepseek-provider.ts create mode 100644 electron/providers/mimo-provider.ts create mode 100644 electron/providers/moonshot-provider.ts create mode 100644 electron/providers/openrouter-provider.ts create mode 100644 electron/providers/qwen-provider.ts create mode 100644 electron/providers/zhipu-provider.ts create mode 100644 src/components/Settings/AIProvidersSection.tsx diff --git a/electron/core/agent-manager.ts b/electron/core/agent-manager.ts index a31bf51b..2458592d 100644 --- a/electron/core/agent-manager.ts +++ b/electron/core/agent-manager.ts @@ -383,9 +383,16 @@ export async function initAgentPty( } } - // Get provider-specific env vars + // Get provider-specific env vars. Pass loaded savedSettings so alt + // providers (OpenRouter, DeepSeek, etc.) can inject ANTHROPIC_BASE_URL + // and ANTHROPIC_API_KEY from the configured API keys. const agentProvider = getProvider(agent.provider); - const providerEnvVars = agentProvider.getPtyEnvVars(agent.id, agent.projectPath, agent.skills); + const providerEnvVars = agentProvider.getPtyEnvVars( + agent.id, + agent.projectPath, + agent.skills, + savedSettings as unknown as AppSettings, + ); const ptyProcess = pty.spawn(shell, ['-l'], { name: 'xterm-256color', diff --git a/electron/handlers/ipc-handlers.ts b/electron/handlers/ipc-handlers.ts index 9661ef20..9e814450 100644 --- a/electron/handlers/ipc-handlers.ts +++ b/electron/handlers/ipc-handlers.ts @@ -323,7 +323,7 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { // Get provider-specific env vars const agentProvider = getProvider(config.provider); - const providerEnvVars = agentProvider.getPtyEnvVars(id, config.projectPath, allSkills); + const providerEnvVars = agentProvider.getPtyEnvVars(id, config.projectPath, allSkills, currentSettings); // BUG 6: pre-accept Claude Code's workspace trust dialog for this cwd so // bypass/auto-mode agents never see the first-launch trust prompt. @@ -521,7 +521,7 @@ function registerAgentHandlers(deps: IpcHandlerDependencies): void { ensureProjectTrusted(cwd); // Local provider uses Claude provider env vars + Tasmania env vars - const localProviderEnvVars = getProvider('claude').getPtyEnvVars(agent.id, agent.projectPath, agent.skills); + const localProviderEnvVars = getProvider('claude').getPtyEnvVars(agent.id, agent.projectPath, agent.skills, currentSettings); const newPty = pty.spawn('/bin/bash', ['-l'], { name: 'xterm-256color', diff --git a/electron/providers/claude-provider.ts b/electron/providers/claude-provider.ts index 09867877..5c37f7d8 100644 --- a/electron/providers/claude-provider.ts +++ b/electron/providers/claude-provider.ts @@ -162,7 +162,7 @@ export class ClaudeProvider implements CLIProvider { return command; } - getPtyEnvVars(agentId: string, projectPath: string, skills: string[]): Record { + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], _appSettings?: AppSettings): Record { return { CLAUDE_SKILLS: skills.join(','), CLAUDE_AGENT_ID: agentId, diff --git a/electron/providers/cli-provider.ts b/electron/providers/cli-provider.ts index 1a39cd72..ae1eb451 100644 --- a/electron/providers/cli-provider.ts +++ b/electron/providers/cli-provider.ts @@ -88,7 +88,7 @@ export interface CLIProvider { buildOneShotCommand(params: OneShotCommandParams): string; /** Get environment variables to set for PTY sessions */ - getPtyEnvVars(agentId: string, projectPath: string, skills: string[]): Record; + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], appSettings?: AppSettings): Record; /** Get environment variable names to delete before spawning PTY */ getEnvVarsToDelete(): string[]; diff --git a/electron/providers/codex-provider.ts b/electron/providers/codex-provider.ts index 67e6a967..c1c2e2e1 100644 --- a/electron/providers/codex-provider.ts +++ b/electron/providers/codex-provider.ts @@ -108,7 +108,7 @@ export class CodexProvider implements CLIProvider { return command; } - getPtyEnvVars(agentId: string, projectPath: string, skills: string[]): Record { + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], _appSettings?: AppSettings): Record { return { DOROTHY_SKILLS: skills.join(','), DOROTHY_AGENT_ID: agentId, diff --git a/electron/providers/deepseek-provider.ts b/electron/providers/deepseek-provider.ts new file mode 100644 index 00000000..f0592717 --- /dev/null +++ b/electron/providers/deepseek-provider.ts @@ -0,0 +1,201 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import type { AppSettings } from '../types'; +import type { + CLIProvider, + InteractiveCommandParams, + ScheduledCommandParams, + OneShotCommandParams, + ProviderModel, + HookConfig, +} from './cli-provider'; + +const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1'; +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +export class DeepSeekProvider implements CLIProvider { + readonly id = 'deepseek' as const; + readonly displayName = 'DeepSeek'; + readonly binaryName = 'claude'; + readonly configDir = path.join(os.homedir(), '.claude'); + + getModels(): ProviderModel[] { + return [ + { id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', description: 'Reasoning model' }, + { id: 'deepseek/deepseek-chat', name: 'DeepSeek V3', description: 'Flagship chat' }, + { id: 'deepseek/deepseek-r1-distill-llama-70b', name: 'R1 Distill 70B', description: 'Distilled Llama' }, + { id: 'deepseek/deepseek-r1-distill-qwen-32b', name: 'R1 Distill Qwen 32B', description: 'Distilled Qwen' }, + ]; + } + + resolveBinaryPath(appSettings: AppSettings): string { + return appSettings.cliPaths?.claude || 'claude'; + } + + buildInteractiveCommand(params: InteractiveCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + + if (params.mcpConfigPath && fs.existsSync(params.mcpConfigPath)) { + command += ` --mcp-config '${params.mcpConfigPath.replace(/'/g, "'\\''")}'`; + } + + if (params.systemPromptFile && fs.existsSync(params.systemPromptFile)) { + command += ` --append-system-prompt-file '${params.systemPromptFile.replace(/'/g, "'\\''")}'`; + } + + if (params.model && params.model !== 'default') { + if (!/^[a-zA-Z0-9._:\/\-]+$/.test(params.model)) { + throw new Error('Invalid model name'); + } + command += ` --model '${params.model}'`; + } + + if (params.verbose) command += ' --verbose'; + + if (params.permissionMode === 'auto') { + command += ' --permission-mode auto'; + } else if (params.permissionMode === 'bypass') { + command += ' --permission-mode bypassPermissions'; + } + + if (params.effort && params.effort !== 'medium') { + command += ` --effort ${params.effort}`; + } + + command += ` --add-dir '${os.homedir()}/.dorothy'`; + + let finalPrompt = params.prompt; + if (params.skills && params.skills.length > 0 && !params.isSuperAgent) { + finalPrompt = `[IMPORTANT: Use these skills for this session: ${params.skills.join(', ')}.] ${params.prompt}`; + } + + if (finalPrompt) { + command += ` '${finalPrompt.replace(/'/g, "'\\''")}'`; + } + + return command; + } + + buildScheduledCommand(params: ScheduledCommandParams): string { + let command = `"${params.binaryPath}"`; + if (params.autonomous) command += ' --dangerously-skip-permissions'; + if (params.outputFormat) command += ` --output-format ${params.outputFormat}`; + if (params.verbose) command += ' --verbose'; + if (params.mcpConfigPath) command += ` --mcp-config "${params.mcpConfigPath}"`; + command += ` --add-dir "${os.homedir()}/.dorothy"`; + command += ` -p '${params.prompt.replace(/'/g, "'\\''")}'`; + return command; + } + + buildOneShotCommand(params: OneShotCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + command += ' -p'; + if (params.model && params.model !== 'default') command += ` --model ${params.model}`; + command += ` '${params.prompt.replace(/'/g, "'\\''")}'`; + return command; + } + + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], appSettings?: AppSettings): Record { + const vars: Record = { + CLAUDE_SKILLS: skills.join(','), + CLAUDE_AGENT_ID: agentId, + CLAUDE_PROJECT_PATH: projectPath, + }; + + // Prefer direct DeepSeek endpoint when a DeepSeek key is set, + // otherwise fall back to OpenRouter. + if (appSettings?.deepSeekApiKey) { + vars.ANTHROPIC_BASE_URL = DEEPSEEK_BASE_URL; + vars.ANTHROPIC_API_KEY = appSettings.deepSeekApiKey; + } else if (appSettings?.openRouterApiKey) { + vars.ANTHROPIC_BASE_URL = OPENROUTER_BASE_URL; + vars.ANTHROPIC_API_KEY = appSettings.openRouterApiKey; + } + + return vars; + } + + getEnvVarsToDelete(): string[] { + return ['CLAUDECODE']; + } + + getHookConfig(): HookConfig { + return { + supportsNativeHooks: true, + configDir: this.configDir, + settingsFile: path.join(this.configDir, 'settings.json'), + }; + } + + async configureHooks(_hooksDir: string): Promise {} + + async registerMcpServer(name: string, command: string, args: string[]): Promise { + const mcpConfigPath = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(this.configDir)) fs.mkdirSync(this.configDir, { recursive: true }); + let mcpConfig: { mcpServers?: Record } = { mcpServers: {} }; + if (fs.existsSync(mcpConfigPath)) { + try { mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8')); if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {}; } catch { mcpConfig = { mcpServers: {} }; } + } + mcpConfig.mcpServers![name] = { command, args }; + fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2)); + } + + async removeMcpServer(name: string): Promise { + const mcpConfigPath = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(mcpConfigPath)) return; + try { + const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8')); + if (mcpConfig?.mcpServers?.[name]) { delete mcpConfig.mcpServers[name]; fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2)); } + } catch { /* ignore */ } + } + + isMcpServerRegistered(name: string, expectedServerPath: string): boolean { + const mcpConfigPath = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(mcpConfigPath)) return false; + try { + const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8')); + const existing = mcpConfig?.mcpServers?.[name]; + if (!existing?.args) return false; + return existing.args[existing.args.length - 1] === expectedServerPath; + } catch { return false; } + } + + getMcpConfigStrategy(): 'flag' | 'config-file' { return 'flag'; } + + getSkillDirectories(): string[] { + return [path.join(this.configDir, 'skills'), path.join(os.homedir(), '.agents', 'skills')]; + } + + getInstalledSkills(): string[] { + const skills = new Set(); + for (const dir of this.getSkillDirectories()) { + if (fs.existsSync(dir)) { + try { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.isDirectory() || e.isSymbolicLink()) skills.add(e.name); } } catch { /* ignore */ } + } + } + return Array.from(skills); + } + + supportsSkills(): boolean { return true; } + getMemoryBasePath(): string { return path.join(this.configDir, 'projects'); } + getAddDirFlag(): string { return '--add-dir'; } + + buildScheduledScript(params: { + binaryPath: string; binaryDir: string; projectPath: string; prompt: string; + autonomous: boolean; mcpConfigPath: string; logPath: string; homeDir: string; + }): string { + const flags = params.autonomous ? '--dangerously-skip-permissions' : ''; + return `#!/bin/bash +export HOME="${params.homeDir}" +if [ -s "${params.homeDir}/.nvm/nvm.sh" ]; then source "${params.homeDir}/.nvm/nvm.sh" 2>/dev/null || true; fi +if [ -f "${params.homeDir}/.zshrc" ]; then source "${params.homeDir}/.zshrc" 2>/dev/null || true; fi +export PATH="${params.binaryDir}:$PATH" +cd "${params.projectPath}" +echo "=== Task started at $(date) ===" >> "${params.logPath}" +unset CLAUDECODE +"${params.binaryPath}" ${flags} --output-format stream-json --verbose --mcp-config "${params.mcpConfigPath}" --add-dir "${params.homeDir}/.dorothy" -p '${params.prompt}' >> "${params.logPath}" 2>&1 +echo "=== Task completed at $(date) ===" >> "${params.logPath}" +`; + } +} diff --git a/electron/providers/gemini-provider.ts b/electron/providers/gemini-provider.ts index a51f0cb6..7aa9dbb4 100644 --- a/electron/providers/gemini-provider.ts +++ b/electron/providers/gemini-provider.ts @@ -119,7 +119,7 @@ export class GeminiProvider implements CLIProvider { return command; } - getPtyEnvVars(agentId: string, projectPath: string, skills: string[]): Record { + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], _appSettings?: AppSettings): Record { return { DOROTHY_SKILLS: skills.join(','), DOROTHY_AGENT_ID: agentId, diff --git a/electron/providers/index.ts b/electron/providers/index.ts index ae929511..ff1314dd 100644 --- a/electron/providers/index.ts +++ b/electron/providers/index.ts @@ -5,6 +5,12 @@ import { CodexProvider } from './codex-provider'; import { GeminiProvider } from './gemini-provider'; import { OpenCodeProvider } from './opencode-provider'; import { PiProvider } from './pi-provider'; +import { OpenRouterProvider } from './openrouter-provider'; +import { DeepSeekProvider } from './deepseek-provider'; +import { MiMoProvider } from './mimo-provider'; +import { MoonshotProvider } from './moonshot-provider'; +import { QwenProvider } from './qwen-provider'; +import { ZhipuProvider } from './zhipu-provider'; export type { CLIProvider } from './cli-provider'; export type { @@ -21,6 +27,12 @@ const providers: Record = { gemini: new GeminiProvider(), opencode: new OpenCodeProvider(), pi: new PiProvider(), + openrouter: new OpenRouterProvider(), + deepseek: new DeepSeekProvider(), + mimo: new MiMoProvider(), + moonshot: new MoonshotProvider(), + qwen: new QwenProvider(), + zhipu: new ZhipuProvider(), }; /** diff --git a/electron/providers/mimo-provider.ts b/electron/providers/mimo-provider.ts new file mode 100644 index 00000000..b5942a20 --- /dev/null +++ b/electron/providers/mimo-provider.ts @@ -0,0 +1,145 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import type { AppSettings } from '../types'; +import type { + CLIProvider, + InteractiveCommandParams, + ScheduledCommandParams, + OneShotCommandParams, + ProviderModel, + HookConfig, +} from './cli-provider'; + +const MIMO_BASE_URL = 'https://api.mimo.com/v1'; +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +export class MiMoProvider implements CLIProvider { + readonly id = 'mimo' as const; + readonly displayName = 'MiMo (Xiaomi)'; + readonly binaryName = 'claude'; + readonly configDir = path.join(os.homedir(), '.claude'); + + getModels(): ProviderModel[] { + return [ + { id: 'xiaomi/mimo-v2-pro', name: 'MiMo V2 Pro', description: 'Flagship agentic' }, + { id: 'xiaomi/mimo-v2-flash', name: 'MiMo V2 Flash', description: 'Fast & efficient' }, + { id: 'xiaomi/mimo-v2-omni', name: 'MiMo V2 Omni', description: 'Multimodal' }, + ]; + } + + resolveBinaryPath(appSettings: AppSettings): string { + return appSettings.cliPaths?.claude || 'claude'; + } + + buildInteractiveCommand(params: InteractiveCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + if (params.mcpConfigPath && fs.existsSync(params.mcpConfigPath)) command += ` --mcp-config '${params.mcpConfigPath.replace(/'/g, "'\\''")}'`; + if (params.systemPromptFile && fs.existsSync(params.systemPromptFile)) command += ` --append-system-prompt-file '${params.systemPromptFile.replace(/'/g, "'\\''")}'`; + if (params.model && params.model !== 'default') { + if (!/^[a-zA-Z0-9._:\/\-]+$/.test(params.model)) throw new Error('Invalid model name'); + command += ` --model '${params.model}'`; + } + if (params.verbose) command += ' --verbose'; + if (params.permissionMode === 'auto') command += ' --permission-mode auto'; + else if (params.permissionMode === 'bypass') command += ' --permission-mode bypassPermissions'; + if (params.effort && params.effort !== 'medium') command += ` --effort ${params.effort}`; + command += ` --add-dir '${os.homedir()}/.dorothy'`; + let finalPrompt = params.prompt; + if (params.skills && params.skills.length > 0 && !params.isSuperAgent) { + finalPrompt = `[IMPORTANT: Use these skills: ${params.skills.join(', ')}.] ${params.prompt}`; + } + if (finalPrompt) command += ` '${finalPrompt.replace(/'/g, "'\\''")}'`; + return command; + } + + buildScheduledCommand(params: ScheduledCommandParams): string { + let command = `"${params.binaryPath}"`; + if (params.autonomous) command += ' --dangerously-skip-permissions'; + if (params.outputFormat) command += ` --output-format ${params.outputFormat}`; + if (params.verbose) command += ' --verbose'; + if (params.mcpConfigPath) command += ` --mcp-config "${params.mcpConfigPath}"`; + command += ` --add-dir "${os.homedir()}/.dorothy"`; + command += ` -p '${params.prompt.replace(/'/g, "'\\''")}'`; + return command; + } + + buildOneShotCommand(params: OneShotCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + command += ' -p'; + if (params.model && params.model !== 'default') command += ` --model ${params.model}`; + command += ` '${params.prompt.replace(/'/g, "'\\''")}'`; + return command; + } + + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], appSettings?: AppSettings): Record { + const vars: Record = { + CLAUDE_SKILLS: skills.join(','), + CLAUDE_AGENT_ID: agentId, + CLAUDE_PROJECT_PATH: projectPath, + }; + if (appSettings?.mimoApiKey) { + vars.ANTHROPIC_BASE_URL = MIMO_BASE_URL; + vars.ANTHROPIC_API_KEY = appSettings.mimoApiKey; + } else if (appSettings?.openRouterApiKey) { + vars.ANTHROPIC_BASE_URL = OPENROUTER_BASE_URL; + vars.ANTHROPIC_API_KEY = appSettings.openRouterApiKey; + } + return vars; + } + + getEnvVarsToDelete(): string[] { return ['CLAUDECODE']; } + getHookConfig(): HookConfig { return { supportsNativeHooks: true, configDir: this.configDir, settingsFile: path.join(this.configDir, 'settings.json') }; } + async configureHooks(_hooksDir: string): Promise {} + + async registerMcpServer(name: string, command: string, args: string[]): Promise { + const p = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(this.configDir)) fs.mkdirSync(this.configDir, { recursive: true }); + let c: { mcpServers?: Record } = { mcpServers: {} }; + if (fs.existsSync(p)) { try { c = JSON.parse(fs.readFileSync(p, 'utf-8')); if (!c.mcpServers) c.mcpServers = {}; } catch { c = { mcpServers: {} }; } } + c.mcpServers![name] = { command, args }; + fs.writeFileSync(p, JSON.stringify(c, null, 2)); + } + + async removeMcpServer(name: string): Promise { + const p = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(p)) return; + try { const c = JSON.parse(fs.readFileSync(p, 'utf-8')); if (c?.mcpServers?.[name]) { delete c.mcpServers[name]; fs.writeFileSync(p, JSON.stringify(c, null, 2)); } } catch { /* ignore */ } + } + + isMcpServerRegistered(name: string, expectedServerPath: string): boolean { + const p = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(p)) return false; + try { const c = JSON.parse(fs.readFileSync(p, 'utf-8')); const e = c?.mcpServers?.[name]; if (!e?.args) return false; return e.args[e.args.length - 1] === expectedServerPath; } catch { return false; } + } + + getMcpConfigStrategy(): 'flag' | 'config-file' { return 'flag'; } + getSkillDirectories(): string[] { return [path.join(this.configDir, 'skills'), path.join(os.homedir(), '.agents', 'skills')]; } + + getInstalledSkills(): string[] { + const skills = new Set(); + for (const dir of this.getSkillDirectories()) { + if (fs.existsSync(dir)) { try { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.isDirectory() || e.isSymbolicLink()) skills.add(e.name); } } catch { /* ignore */ } } + } + return Array.from(skills); + } + + supportsSkills(): boolean { return true; } + getMemoryBasePath(): string { return path.join(this.configDir, 'projects'); } + getAddDirFlag(): string { return '--add-dir'; } + + buildScheduledScript(params: { binaryPath: string; binaryDir: string; projectPath: string; prompt: string; autonomous: boolean; mcpConfigPath: string; logPath: string; homeDir: string; }): string { + const flags = params.autonomous ? '--dangerously-skip-permissions' : ''; + return `#!/bin/bash +export HOME="${params.homeDir}" +if [ -s "${params.homeDir}/.nvm/nvm.sh" ]; then source "${params.homeDir}/.nvm/nvm.sh" 2>/dev/null || true; fi +if [ -f "${params.homeDir}/.zshrc" ]; then source "${params.homeDir}/.zshrc" 2>/dev/null || true; fi +export PATH="${params.binaryDir}:$PATH" +cd "${params.projectPath}" +echo "=== Task started at $(date) ===" >> "${params.logPath}" +unset CLAUDECODE +"${params.binaryPath}" ${flags} --output-format stream-json --verbose --mcp-config "${params.mcpConfigPath}" --add-dir "${params.homeDir}/.dorothy" -p '${params.prompt}' >> "${params.logPath}" 2>&1 +echo "=== Task completed at $(date) ===" >> "${params.logPath}" +`; + } +} diff --git a/electron/providers/moonshot-provider.ts b/electron/providers/moonshot-provider.ts new file mode 100644 index 00000000..fa8071d7 --- /dev/null +++ b/electron/providers/moonshot-provider.ts @@ -0,0 +1,161 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import type { AppSettings } from '../types'; +import type { + CLIProvider, + InteractiveCommandParams, + ScheduledCommandParams, + OneShotCommandParams, + ProviderModel, + HookConfig, +} from './cli-provider'; + +const MOONSHOT_BASE_URL = 'https://api.moonshot.cn/v1'; +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +export class MoonshotProvider implements CLIProvider { + readonly id = 'moonshot' as const; + readonly displayName = 'MoonshotAI (Kimi)'; + readonly binaryName = 'claude'; + readonly configDir = path.join(os.homedir(), '.claude'); + + getModels(): ProviderModel[] { + return [ + { id: 'moonshotai/kimi-k2', name: 'Kimi K2', description: 'Flagship agentic' }, + { id: 'moonshotai/moonlight-16k', name: 'Moonlight 16K', description: 'Efficient chat' }, + { id: 'moonshotai/kimi-vl-a3b-thinking', name: 'Kimi VL Thinking', description: 'Vision + reasoning' }, + ]; + } + + resolveBinaryPath(appSettings: AppSettings): string { + return appSettings.cliPaths?.claude || 'claude'; + } + + buildInteractiveCommand(params: InteractiveCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + if (params.mcpConfigPath && fs.existsSync(params.mcpConfigPath)) command += ` --mcp-config '${params.mcpConfigPath.replace(/'/g, "'\\''")}'`; + if (params.systemPromptFile && fs.existsSync(params.systemPromptFile)) command += ` --append-system-prompt-file '${params.systemPromptFile.replace(/'/g, "'\\''")}'`; + if (params.model && params.model !== 'default') { + if (!/^[a-zA-Z0-9._:\/\-]+$/.test(params.model)) throw new Error('Invalid model name'); + command += ` --model '${params.model}'`; + } + if (params.verbose) command += ' --verbose'; + if (params.permissionMode === 'auto') command += ' --permission-mode auto'; + else if (params.permissionMode === 'bypass') command += ' --permission-mode bypassPermissions'; + if (params.effort && params.effort !== 'medium') command += ` --effort ${params.effort}`; + command += ` --add-dir '${os.homedir()}/.dorothy'`; + let finalPrompt = params.prompt; + if (params.skills && params.skills.length > 0 && !params.isSuperAgent) { + finalPrompt = `[IMPORTANT: Use these skills: ${params.skills.join(', ')}.] ${params.prompt}`; + } + if (finalPrompt) command += ` '${finalPrompt.replace(/'/g, "'\\''")}'`; + return command; + } + + buildScheduledCommand(params: ScheduledCommandParams): string { + let command = `"${params.binaryPath}"`; + if (params.autonomous) command += ' --dangerously-skip-permissions'; + if (params.outputFormat) command += ` --output-format ${params.outputFormat}`; + if (params.verbose) command += ' --verbose'; + if (params.mcpConfigPath) command += ` --mcp-config "${params.mcpConfigPath}"`; + command += ` --add-dir "${os.homedir()}/.dorothy"`; + command += ` -p '${params.prompt.replace(/'/g, "'\\''")}'`; + return command; + } + + buildOneShotCommand(params: OneShotCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + command += ' -p'; + if (params.model && params.model !== 'default') command += ` --model ${params.model}`; + command += ` '${params.prompt.replace(/'/g, "'\\''")}'`; + return command; + } + + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], appSettings?: AppSettings): Record { + const vars: Record = { + CLAUDE_SKILLS: skills.join(','), + CLAUDE_AGENT_ID: agentId, + CLAUDE_PROJECT_PATH: projectPath, + }; + if (appSettings?.moonshotApiKey) { + vars.ANTHROPIC_BASE_URL = MOONSHOT_BASE_URL; + vars.ANTHROPIC_API_KEY = appSettings.moonshotApiKey; + } else if (appSettings?.openRouterApiKey) { + vars.ANTHROPIC_BASE_URL = OPENROUTER_BASE_URL; + vars.ANTHROPIC_API_KEY = appSettings.openRouterApiKey; + } + return vars; + } + + getEnvVarsToDelete(): string[] { return ['CLAUDECODE']; } + + getHookConfig(): HookConfig { + return { supportsNativeHooks: true, configDir: this.configDir, settingsFile: path.join(this.configDir, 'settings.json') }; + } + + async configureHooks(_hooksDir: string): Promise {} + + async registerMcpServer(name: string, command: string, args: string[]): Promise { + const mcpConfigPath = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(this.configDir)) fs.mkdirSync(this.configDir, { recursive: true }); + let mcpConfig: { mcpServers?: Record } = { mcpServers: {} }; + if (fs.existsSync(mcpConfigPath)) { + try { mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8')); if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {}; } catch { mcpConfig = { mcpServers: {} }; } + } + mcpConfig.mcpServers![name] = { command, args }; + fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2)); + } + + async removeMcpServer(name: string): Promise { + const mcpConfigPath = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(mcpConfigPath)) return; + try { + const c = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8')); + if (c?.mcpServers?.[name]) { delete c.mcpServers[name]; fs.writeFileSync(mcpConfigPath, JSON.stringify(c, null, 2)); } + } catch { /* ignore */ } + } + + isMcpServerRegistered(name: string, expectedServerPath: string): boolean { + const mcpConfigPath = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(mcpConfigPath)) return false; + try { + const c = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8')); + const e = c?.mcpServers?.[name]; + if (!e?.args) return false; + return e.args[e.args.length - 1] === expectedServerPath; + } catch { return false; } + } + + getMcpConfigStrategy(): 'flag' | 'config-file' { return 'flag'; } + getSkillDirectories(): string[] { return [path.join(this.configDir, 'skills'), path.join(os.homedir(), '.agents', 'skills')]; } + + getInstalledSkills(): string[] { + const skills = new Set(); + for (const dir of this.getSkillDirectories()) { + if (fs.existsSync(dir)) { + try { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.isDirectory() || e.isSymbolicLink()) skills.add(e.name); } } catch { /* ignore */ } + } + } + return Array.from(skills); + } + + supportsSkills(): boolean { return true; } + getMemoryBasePath(): string { return path.join(this.configDir, 'projects'); } + getAddDirFlag(): string { return '--add-dir'; } + + buildScheduledScript(params: { binaryPath: string; binaryDir: string; projectPath: string; prompt: string; autonomous: boolean; mcpConfigPath: string; logPath: string; homeDir: string; }): string { + const flags = params.autonomous ? '--dangerously-skip-permissions' : ''; + return `#!/bin/bash +export HOME="${params.homeDir}" +if [ -s "${params.homeDir}/.nvm/nvm.sh" ]; then source "${params.homeDir}/.nvm/nvm.sh" 2>/dev/null || true; fi +if [ -f "${params.homeDir}/.zshrc" ]; then source "${params.homeDir}/.zshrc" 2>/dev/null || true; fi +export PATH="${params.binaryDir}:$PATH" +cd "${params.projectPath}" +echo "=== Task started at $(date) ===" >> "${params.logPath}" +unset CLAUDECODE +"${params.binaryPath}" ${flags} --output-format stream-json --verbose --mcp-config "${params.mcpConfigPath}" --add-dir "${params.homeDir}/.dorothy" -p '${params.prompt}' >> "${params.logPath}" 2>&1 +echo "=== Task completed at $(date) ===" >> "${params.logPath}" +`; + } +} diff --git a/electron/providers/opencode-provider.ts b/electron/providers/opencode-provider.ts index 79b5334e..0625f208 100644 --- a/electron/providers/opencode-provider.ts +++ b/electron/providers/opencode-provider.ts @@ -75,7 +75,7 @@ export class OpenCodeProvider implements CLIProvider { return command; } - getPtyEnvVars(agentId: string, projectPath: string, skills: string[]): Record { + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], _appSettings?: AppSettings): Record { return { DOROTHY_SKILLS: skills.join(','), DOROTHY_AGENT_ID: agentId, diff --git a/electron/providers/openrouter-provider.ts b/electron/providers/openrouter-provider.ts new file mode 100644 index 00000000..4aa6b94d --- /dev/null +++ b/electron/providers/openrouter-provider.ts @@ -0,0 +1,294 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import type { AppSettings } from '../types'; +import type { + CLIProvider, + InteractiveCommandParams, + ScheduledCommandParams, + OneShotCommandParams, + ProviderModel, + HookConfig, +} from './cli-provider'; + +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +export class OpenRouterProvider implements CLIProvider { + readonly id = 'openrouter' as const; + readonly displayName = 'OpenRouter'; + readonly binaryName = 'claude'; + readonly configDir = path.join(os.homedir(), '.claude'); + + getModels(): ProviderModel[] { + return [ + // DeepSeek + { id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', description: 'Reasoning • DeepSeek' }, + { id: 'deepseek/deepseek-chat', name: 'DeepSeek V3', description: 'Chat • DeepSeek' }, + // Moonshot / Kimi + { id: 'moonshotai/kimi-k2', name: 'Kimi K2', description: 'Agentic • MoonshotAI' }, + // Xiaomi MiMo + { id: 'xiaomi/mimo-v2-pro', name: 'MiMo V2 Pro', description: 'Agentic • Xiaomi' }, + // Alibaba Qwen + { id: 'qwen/qwq-32b', name: 'QwQ 32B', description: 'Reasoning • Alibaba' }, + { id: 'qwen/qwen-2.5-72b-instruct', name: 'Qwen 2.5 72B', description: 'Instruct • Alibaba' }, + // OpenAI + { id: 'openai/gpt-4.1', name: 'GPT-4.1', description: 'Flagship • OpenAI' }, + { id: 'openai/o4-mini', name: 'o4 mini', description: 'Reasoning • OpenAI' }, + // Google + { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Flagship • Google' }, + { id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash', description: 'Fast • Google' }, + // Meta + { id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick', description: 'Open • Meta' }, + ]; + } + + resolveBinaryPath(appSettings: AppSettings): string { + return appSettings.cliPaths?.claude || 'claude'; + } + + buildInteractiveCommand(params: InteractiveCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + + if (params.mcpConfigPath && fs.existsSync(params.mcpConfigPath)) { + command += ` --mcp-config '${params.mcpConfigPath.replace(/'/g, "'\\''")}'`; + } + + if (params.systemPromptFile && fs.existsSync(params.systemPromptFile)) { + command += ` --append-system-prompt-file '${params.systemPromptFile.replace(/'/g, "'\\''")}'`; + } + + if (params.model && params.model !== 'default') { + if (!/^[a-zA-Z0-9._:\/\-]+$/.test(params.model)) { + throw new Error('Invalid model name'); + } + command += ` --model '${params.model}'`; + } + + if (params.verbose) { + command += ' --verbose'; + } + + if (params.permissionMode === 'auto') { + command += ' --permission-mode auto'; + } else if (params.permissionMode === 'bypass') { + command += ' --permission-mode bypassPermissions'; + } + + if (params.effort && params.effort !== 'medium') { + command += ` --effort ${params.effort}`; + } + + command += ` --add-dir '${os.homedir()}/.dorothy'`; + + let finalPrompt = params.prompt; + if (params.skills && params.skills.length > 0 && !params.isSuperAgent) { + const skillsList = params.skills.join(', '); + finalPrompt = `[IMPORTANT: Use these skills for this session: ${skillsList}. Invoke them with / when relevant to the task.] ${params.prompt}`; + } + + if (finalPrompt) { + const escaped = finalPrompt.replace(/'/g, "'\\''"); + command += ` '${escaped}'`; + } + + return command; + } + + buildScheduledCommand(params: ScheduledCommandParams): string { + let command = `"${params.binaryPath}"`; + + if (params.autonomous) { + command += ' --dangerously-skip-permissions'; + } + + if (params.outputFormat) { + command += ` --output-format ${params.outputFormat}`; + } + + if (params.verbose) { + command += ' --verbose'; + } + + if (params.mcpConfigPath) { + command += ` --mcp-config "${params.mcpConfigPath}"`; + } + + command += ` --add-dir "${os.homedir()}/.dorothy"`; + + const escaped = params.prompt.replace(/'/g, "'\\''"); + command += ` -p '${escaped}'`; + + return command; + } + + buildOneShotCommand(params: OneShotCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + + command += ' -p'; + + if (params.model && params.model !== 'default') { + command += ` --model ${params.model}`; + } + + const escaped = params.prompt.replace(/'/g, "'\\''"); + command += ` '${escaped}'`; + + return command; + } + + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], appSettings?: AppSettings): Record { + const vars: Record = { + CLAUDE_SKILLS: skills.join(','), + CLAUDE_AGENT_ID: agentId, + CLAUDE_PROJECT_PATH: projectPath, + }; + + const apiKey = appSettings?.openRouterApiKey; + if (apiKey) { + vars.ANTHROPIC_BASE_URL = OPENROUTER_BASE_URL; + vars.ANTHROPIC_API_KEY = apiKey; + // OpenRouter HTTP-Referer header (optional but recommended) + vars.OR_SITE_URL = 'https://dorothy.app'; + vars.OR_APP_NAME = 'Dorothy'; + } + + return vars; + } + + getEnvVarsToDelete(): string[] { + return ['CLAUDECODE']; + } + + getHookConfig(): HookConfig { + return { + supportsNativeHooks: true, + configDir: this.configDir, + settingsFile: path.join(this.configDir, 'settings.json'), + }; + } + + async configureHooks(_hooksDir: string): Promise { + // OpenRouter uses the same Claude CLI, so hooks work normally + } + + async registerMcpServer(name: string, command: string, args: string[]): Promise { + const mcpConfigPath = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(this.configDir)) { + fs.mkdirSync(this.configDir, { recursive: true }); + } + + let mcpConfig: { mcpServers?: Record } = { mcpServers: {} }; + if (fs.existsSync(mcpConfigPath)) { + try { + mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8')); + if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {}; + } catch { + mcpConfig = { mcpServers: {} }; + } + } + + mcpConfig.mcpServers![name] = { command, args }; + fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2)); + } + + async removeMcpServer(name: string): Promise { + const mcpConfigPath = path.join(this.configDir, 'mcp.json'); + if (fs.existsSync(mcpConfigPath)) { + try { + const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8')); + if (mcpConfig?.mcpServers?.[name]) { + delete mcpConfig.mcpServers[name]; + fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2)); + } + } catch { /* ignore */ } + } + } + + isMcpServerRegistered(name: string, expectedServerPath: string): boolean { + const mcpConfigPath = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(mcpConfigPath)) return false; + try { + const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8')); + const existing = mcpConfig?.mcpServers?.[name]; + if (!existing?.args) return false; + return existing.args[existing.args.length - 1] === expectedServerPath; + } catch { + return false; + } + } + + getMcpConfigStrategy(): 'flag' | 'config-file' { + return 'flag'; + } + + getSkillDirectories(): string[] { + return [ + path.join(this.configDir, 'skills'), + path.join(os.homedir(), '.agents', 'skills'), + ]; + } + + getInstalledSkills(): string[] { + const skills = new Set(); + for (const dir of this.getSkillDirectories()) { + if (fs.existsSync(dir)) { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() || entry.isSymbolicLink()) { + skills.add(entry.name); + } + } + } catch { /* ignore */ } + } + } + return Array.from(skills); + } + + supportsSkills(): boolean { + return true; + } + + getMemoryBasePath(): string { + return path.join(this.configDir, 'projects'); + } + + getAddDirFlag(): string { + return '--add-dir'; + } + + buildScheduledScript(params: { + binaryPath: string; + binaryDir: string; + projectPath: string; + prompt: string; + autonomous: boolean; + mcpConfigPath: string; + logPath: string; + homeDir: string; + }): string { + const flags = params.autonomous ? '--dangerously-skip-permissions' : ''; + + return `#!/bin/bash + +export HOME="${params.homeDir}" + +if [ -s "${params.homeDir}/.nvm/nvm.sh" ]; then + source "${params.homeDir}/.nvm/nvm.sh" 2>/dev/null || true +fi + +if [ -f "${params.homeDir}/.zshrc" ]; then + source "${params.homeDir}/.zshrc" 2>/dev/null || true +elif [ -f "${params.homeDir}/.bash_profile" ]; then + source "${params.homeDir}/.bash_profile" 2>/dev/null || true +fi + +export PATH="${params.binaryDir}:$PATH" +cd "${params.projectPath}" +echo "=== Task started at $(date) ===" >> "${params.logPath}" +unset CLAUDECODE +"${params.binaryPath}" ${flags} --output-format stream-json --verbose --mcp-config "${params.mcpConfigPath}" --add-dir "${params.homeDir}/.dorothy" -p '${params.prompt}' >> "${params.logPath}" 2>&1 +echo "=== Task completed at $(date) ===" >> "${params.logPath}" +`; + } +} diff --git a/electron/providers/pi-provider.ts b/electron/providers/pi-provider.ts index 7297e818..f9a4fe6d 100644 --- a/electron/providers/pi-provider.ts +++ b/electron/providers/pi-provider.ts @@ -76,7 +76,7 @@ export class PiProvider implements CLIProvider { return command; } - getPtyEnvVars(agentId: string, projectPath: string, skills: string[]): Record { + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], _appSettings?: AppSettings): Record { return { DOROTHY_SKILLS: skills.join(','), DOROTHY_AGENT_ID: agentId, diff --git a/electron/providers/qwen-provider.ts b/electron/providers/qwen-provider.ts new file mode 100644 index 00000000..df4007fe --- /dev/null +++ b/electron/providers/qwen-provider.ts @@ -0,0 +1,147 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import type { AppSettings } from '../types'; +import type { + CLIProvider, + InteractiveCommandParams, + ScheduledCommandParams, + OneShotCommandParams, + ProviderModel, + HookConfig, +} from './cli-provider'; + +const QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +export class QwenProvider implements CLIProvider { + readonly id = 'qwen' as const; + readonly displayName = 'Qwen (Alibaba)'; + readonly binaryName = 'claude'; + readonly configDir = path.join(os.homedir(), '.claude'); + + getModels(): ProviderModel[] { + return [ + { id: 'qwen/qwq-32b', name: 'QwQ 32B', description: 'Extended reasoning' }, + { id: 'qwen/qwen-2.5-72b-instruct', name: 'Qwen 2.5 72B', description: 'Flagship instruct' }, + { id: 'qwen/qwen-2.5-coder-32b-instruct', name: 'Qwen 2.5 Coder 32B', description: 'Code specialist' }, + { id: 'qwen/qwen3-235b-a22b', name: 'Qwen3 235B', description: 'Ultra-scale MoE' }, + { id: 'qwen/qwen3-30b-a3b', name: 'Qwen3 30B', description: 'Compact MoE' }, + ]; + } + + resolveBinaryPath(appSettings: AppSettings): string { + return appSettings.cliPaths?.claude || 'claude'; + } + + buildInteractiveCommand(params: InteractiveCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + if (params.mcpConfigPath && fs.existsSync(params.mcpConfigPath)) command += ` --mcp-config '${params.mcpConfigPath.replace(/'/g, "'\\''")}'`; + if (params.systemPromptFile && fs.existsSync(params.systemPromptFile)) command += ` --append-system-prompt-file '${params.systemPromptFile.replace(/'/g, "'\\''")}'`; + if (params.model && params.model !== 'default') { + if (!/^[a-zA-Z0-9._:\/\-]+$/.test(params.model)) throw new Error('Invalid model name'); + command += ` --model '${params.model}'`; + } + if (params.verbose) command += ' --verbose'; + if (params.permissionMode === 'auto') command += ' --permission-mode auto'; + else if (params.permissionMode === 'bypass') command += ' --permission-mode bypassPermissions'; + if (params.effort && params.effort !== 'medium') command += ` --effort ${params.effort}`; + command += ` --add-dir '${os.homedir()}/.dorothy'`; + let finalPrompt = params.prompt; + if (params.skills && params.skills.length > 0 && !params.isSuperAgent) { + finalPrompt = `[IMPORTANT: Use these skills: ${params.skills.join(', ')}.] ${params.prompt}`; + } + if (finalPrompt) command += ` '${finalPrompt.replace(/'/g, "'\\''")}'`; + return command; + } + + buildScheduledCommand(params: ScheduledCommandParams): string { + let command = `"${params.binaryPath}"`; + if (params.autonomous) command += ' --dangerously-skip-permissions'; + if (params.outputFormat) command += ` --output-format ${params.outputFormat}`; + if (params.verbose) command += ' --verbose'; + if (params.mcpConfigPath) command += ` --mcp-config "${params.mcpConfigPath}"`; + command += ` --add-dir "${os.homedir()}/.dorothy"`; + command += ` -p '${params.prompt.replace(/'/g, "'\\''")}'`; + return command; + } + + buildOneShotCommand(params: OneShotCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + command += ' -p'; + if (params.model && params.model !== 'default') command += ` --model ${params.model}`; + command += ` '${params.prompt.replace(/'/g, "'\\''")}'`; + return command; + } + + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], appSettings?: AppSettings): Record { + const vars: Record = { + CLAUDE_SKILLS: skills.join(','), + CLAUDE_AGENT_ID: agentId, + CLAUDE_PROJECT_PATH: projectPath, + }; + if (appSettings?.qwenApiKey) { + vars.ANTHROPIC_BASE_URL = QWEN_BASE_URL; + vars.ANTHROPIC_API_KEY = appSettings.qwenApiKey; + } else if (appSettings?.openRouterApiKey) { + vars.ANTHROPIC_BASE_URL = OPENROUTER_BASE_URL; + vars.ANTHROPIC_API_KEY = appSettings.openRouterApiKey; + } + return vars; + } + + getEnvVarsToDelete(): string[] { return ['CLAUDECODE']; } + getHookConfig(): HookConfig { return { supportsNativeHooks: true, configDir: this.configDir, settingsFile: path.join(this.configDir, 'settings.json') }; } + async configureHooks(_hooksDir: string): Promise {} + + async registerMcpServer(name: string, command: string, args: string[]): Promise { + const p = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(this.configDir)) fs.mkdirSync(this.configDir, { recursive: true }); + let c: { mcpServers?: Record } = { mcpServers: {} }; + if (fs.existsSync(p)) { try { c = JSON.parse(fs.readFileSync(p, 'utf-8')); if (!c.mcpServers) c.mcpServers = {}; } catch { c = { mcpServers: {} }; } } + c.mcpServers![name] = { command, args }; + fs.writeFileSync(p, JSON.stringify(c, null, 2)); + } + + async removeMcpServer(name: string): Promise { + const p = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(p)) return; + try { const c = JSON.parse(fs.readFileSync(p, 'utf-8')); if (c?.mcpServers?.[name]) { delete c.mcpServers[name]; fs.writeFileSync(p, JSON.stringify(c, null, 2)); } } catch { /* ignore */ } + } + + isMcpServerRegistered(name: string, expectedServerPath: string): boolean { + const p = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(p)) return false; + try { const c = JSON.parse(fs.readFileSync(p, 'utf-8')); const e = c?.mcpServers?.[name]; if (!e?.args) return false; return e.args[e.args.length - 1] === expectedServerPath; } catch { return false; } + } + + getMcpConfigStrategy(): 'flag' | 'config-file' { return 'flag'; } + getSkillDirectories(): string[] { return [path.join(this.configDir, 'skills'), path.join(os.homedir(), '.agents', 'skills')]; } + + getInstalledSkills(): string[] { + const skills = new Set(); + for (const dir of this.getSkillDirectories()) { + if (fs.existsSync(dir)) { try { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.isDirectory() || e.isSymbolicLink()) skills.add(e.name); } } catch { /* ignore */ } } + } + return Array.from(skills); + } + + supportsSkills(): boolean { return true; } + getMemoryBasePath(): string { return path.join(this.configDir, 'projects'); } + getAddDirFlag(): string { return '--add-dir'; } + + buildScheduledScript(params: { binaryPath: string; binaryDir: string; projectPath: string; prompt: string; autonomous: boolean; mcpConfigPath: string; logPath: string; homeDir: string; }): string { + const flags = params.autonomous ? '--dangerously-skip-permissions' : ''; + return `#!/bin/bash +export HOME="${params.homeDir}" +if [ -s "${params.homeDir}/.nvm/nvm.sh" ]; then source "${params.homeDir}/.nvm/nvm.sh" 2>/dev/null || true; fi +if [ -f "${params.homeDir}/.zshrc" ]; then source "${params.homeDir}/.zshrc" 2>/dev/null || true; fi +export PATH="${params.binaryDir}:$PATH" +cd "${params.projectPath}" +echo "=== Task started at $(date) ===" >> "${params.logPath}" +unset CLAUDECODE +"${params.binaryPath}" ${flags} --output-format stream-json --verbose --mcp-config "${params.mcpConfigPath}" --add-dir "${params.homeDir}/.dorothy" -p '${params.prompt}' >> "${params.logPath}" 2>&1 +echo "=== Task completed at $(date) ===" >> "${params.logPath}" +`; + } +} diff --git a/electron/providers/zhipu-provider.ts b/electron/providers/zhipu-provider.ts new file mode 100644 index 00000000..292d12b7 --- /dev/null +++ b/electron/providers/zhipu-provider.ts @@ -0,0 +1,151 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import type { AppSettings } from '../types'; +import type { + CLIProvider, + InteractiveCommandParams, + ScheduledCommandParams, + OneShotCommandParams, + ProviderModel, + HookConfig, +} from './cli-provider'; + +const ZHIPU_BASE_URL = 'https://open.bigmodel.cn/api/paas/v4/'; +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +export class ZhipuProvider implements CLIProvider { + readonly id = 'zhipu' as const; + readonly displayName = 'ZhipuAI (GLM)'; + readonly binaryName = 'claude'; + readonly configDir = path.join(os.homedir(), '.claude'); + + getModels(): ProviderModel[] { + return [ + { id: 'zhipuai/glm-4.6', name: 'GLM-4.6', description: 'Flagship • ZhipuAI' }, + { id: 'zhipuai/glm-4.5', name: 'GLM-4.5', description: 'General • ZhipuAI' }, + { id: 'zhipuai/glm-4-plus', name: 'GLM-4 Plus', description: 'Advanced • ZhipuAI' }, + { id: 'zhipuai/glm-4-air', name: 'GLM-4 Air', description: 'Fast & efficient' }, + { id: 'zhipuai/glm-4-flash', name: 'GLM-4 Flash', description: 'Ultra-fast' }, + ]; + } + + resolveBinaryPath(appSettings: AppSettings): string { + return appSettings.cliPaths?.claude || 'claude'; + } + + buildInteractiveCommand(params: InteractiveCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + if (params.mcpConfigPath && fs.existsSync(params.mcpConfigPath)) command += ` --mcp-config '${params.mcpConfigPath.replace(/'/g, "'\\''")}'`; + if (params.systemPromptFile && fs.existsSync(params.systemPromptFile)) command += ` --append-system-prompt-file '${params.systemPromptFile.replace(/'/g, "'\\''")}'`; + if (params.model && params.model !== 'default') { + if (!/^[a-zA-Z0-9._:\/\-]+$/.test(params.model)) throw new Error('Invalid model name'); + command += ` --model '${params.model}'`; + } + if (params.verbose) command += ' --verbose'; + if (params.permissionMode === 'auto') command += ' --permission-mode auto'; + else if (params.permissionMode === 'bypass') command += ' --permission-mode bypassPermissions'; + if (params.effort && params.effort !== 'medium') command += ` --effort ${params.effort}`; + command += ` --add-dir '${os.homedir()}/.dorothy'`; + let finalPrompt = params.prompt; + if (params.skills && params.skills.length > 0 && !params.isSuperAgent) { + finalPrompt = `[IMPORTANT: Use these skills: ${params.skills.join(', ')}.] ${params.prompt}`; + } + if (finalPrompt) command += ` '${finalPrompt.replace(/'/g, "'\\''")}'`; + return command; + } + + buildScheduledCommand(params: ScheduledCommandParams): string { + let command = `"${params.binaryPath}"`; + if (params.autonomous) command += ' --dangerously-skip-permissions'; + if (params.outputFormat) command += ` --output-format ${params.outputFormat}`; + if (params.verbose) command += ' --verbose'; + if (params.mcpConfigPath) command += ` --mcp-config "${params.mcpConfigPath}"`; + command += ` --add-dir "${os.homedir()}/.dorothy"`; + command += ` -p '${params.prompt.replace(/'/g, "'\\''")}'`; + return command; + } + + buildOneShotCommand(params: OneShotCommandParams): string { + let command = `'${params.binaryPath.replace(/'/g, "'\\''")}'`; + command += ' -p'; + if (params.model && params.model !== 'default') command += ` --model ${params.model}`; + command += ` '${params.prompt.replace(/'/g, "'\\''")}'`; + return command; + } + + getPtyEnvVars(agentId: string, projectPath: string, skills: string[], appSettings?: AppSettings): Record { + const vars: Record = { + CLAUDE_SKILLS: skills.join(','), + CLAUDE_AGENT_ID: agentId, + CLAUDE_PROJECT_PATH: projectPath, + }; + if (appSettings?.zhipuApiKey) { + vars.ANTHROPIC_BASE_URL = ZHIPU_BASE_URL; + vars.ANTHROPIC_API_KEY = appSettings.zhipuApiKey; + } else if (appSettings?.openRouterApiKey) { + vars.ANTHROPIC_BASE_URL = OPENROUTER_BASE_URL; + vars.ANTHROPIC_API_KEY = appSettings.openRouterApiKey; + } + return vars; + } + + getEnvVarsToDelete(): string[] { return ['CLAUDECODE']; } + + getHookConfig(): HookConfig { + return { supportsNativeHooks: true, configDir: this.configDir, settingsFile: path.join(this.configDir, 'settings.json') }; + } + + async configureHooks(_hooksDir: string): Promise {} + + async registerMcpServer(name: string, command: string, args: string[]): Promise { + const p = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(this.configDir)) fs.mkdirSync(this.configDir, { recursive: true }); + let c: { mcpServers?: Record } = { mcpServers: {} }; + if (fs.existsSync(p)) { try { c = JSON.parse(fs.readFileSync(p, 'utf-8')); if (!c.mcpServers) c.mcpServers = {}; } catch { c = { mcpServers: {} }; } } + c.mcpServers![name] = { command, args }; + fs.writeFileSync(p, JSON.stringify(c, null, 2)); + } + + async removeMcpServer(name: string): Promise { + const p = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(p)) return; + try { const c = JSON.parse(fs.readFileSync(p, 'utf-8')); if (c?.mcpServers?.[name]) { delete c.mcpServers[name]; fs.writeFileSync(p, JSON.stringify(c, null, 2)); } } catch { /* ignore */ } + } + + isMcpServerRegistered(name: string, expectedServerPath: string): boolean { + const p = path.join(this.configDir, 'mcp.json'); + if (!fs.existsSync(p)) return false; + try { const c = JSON.parse(fs.readFileSync(p, 'utf-8')); const e = c?.mcpServers?.[name]; if (!e?.args) return false; return e.args[e.args.length - 1] === expectedServerPath; } catch { return false; } + } + + getMcpConfigStrategy(): 'flag' | 'config-file' { return 'flag'; } + getSkillDirectories(): string[] { return [path.join(this.configDir, 'skills'), path.join(os.homedir(), '.agents', 'skills')]; } + + getInstalledSkills(): string[] { + const skills = new Set(); + for (const dir of this.getSkillDirectories()) { + if (fs.existsSync(dir)) { try { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.isDirectory() || e.isSymbolicLink()) skills.add(e.name); } } catch { /* ignore */ } } + } + return Array.from(skills); + } + + supportsSkills(): boolean { return true; } + getMemoryBasePath(): string { return path.join(this.configDir, 'projects'); } + getAddDirFlag(): string { return '--add-dir'; } + + buildScheduledScript(params: { binaryPath: string; binaryDir: string; projectPath: string; prompt: string; autonomous: boolean; mcpConfigPath: string; logPath: string; homeDir: string; }): string { + const flags = params.autonomous ? '--dangerously-skip-permissions' : ''; + return `#!/bin/bash +export HOME="${params.homeDir}" +if [ -s "${params.homeDir}/.nvm/nvm.sh" ]; then source "${params.homeDir}/.nvm/nvm.sh" 2>/dev/null || true; fi +if [ -f "${params.homeDir}/.zshrc" ]; then source "${params.homeDir}/.zshrc" 2>/dev/null || true; fi +export PATH="${params.binaryDir}:$PATH" +cd "${params.projectPath}" +echo "=== Task started at $(date) ===" >> "${params.logPath}" +unset CLAUDECODE +"${params.binaryPath}" ${flags} --output-format stream-json --verbose --mcp-config "${params.mcpConfigPath}" --add-dir "${params.homeDir}/.dorothy" -p '${params.prompt}' >> "${params.logPath}" 2>&1 +echo "=== Task completed at $(date) ===" >> "${params.logPath}" +`; + } +} diff --git a/electron/types/index.ts b/electron/types/index.ts index 000f0f21..5a81e6d0 100644 --- a/electron/types/index.ts +++ b/electron/types/index.ts @@ -5,7 +5,19 @@ export interface WorktreeConfig { export type AgentCharacter = 'robot' | 'ninja' | 'wizard' | 'astronaut' | 'knight' | 'pirate' | 'alien' | 'viking'; -export type AgentProvider = 'claude' | 'codex' | 'gemini' | 'opencode' | 'pi' | 'local'; +export type AgentProvider = + | 'claude' + | 'codex' + | 'gemini' + | 'opencode' + | 'pi' + | 'local' + | 'openrouter' + | 'deepseek' + | 'mimo' + | 'moonshot' + | 'qwen' + | 'zhipu'; /** Permission mode for agent tool use: * - normal: Claude asks for confirmation on each tool use @@ -110,6 +122,20 @@ export interface AppSettings { cliPaths: CLIPaths; opencodeEnabled: boolean; opencodeDefaultModel: string; + /** External AI provider keys. All alt providers use the claude binary + * with ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY injected into the PTY. */ + openRouterEnabled?: boolean; + openRouterApiKey?: string; + deepSeekEnabled?: boolean; + deepSeekApiKey?: string; + mimoEnabled?: boolean; + mimoApiKey?: string; + moonshotEnabled?: boolean; + moonshotApiKey?: string; + qwenEnabled?: boolean; + qwenApiKey?: string; + zhipuEnabled?: boolean; + zhipuApiKey?: string; defaultProvider?: AgentProvider; obsidianVaultPaths?: string[]; notificationSounds?: { diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 9b6a423f..a56cfee2 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -21,6 +21,7 @@ import { OpenCodeSection, PiTerminalSection, GoogleWorkspaceSection, + AIProvidersSection, PermissionsSection, SkillsSection, McpSection, @@ -151,6 +152,14 @@ function SettingsPageInner() { onUpdateLocalSettings={updateLocalAppSettings} /> ); + case 'ai-providers': + return ( + + ); case 'permissions': return ; case 'skills': diff --git a/src/components/Settings/AIProvidersSection.tsx b/src/components/Settings/AIProvidersSection.tsx new file mode 100644 index 00000000..d8235643 --- /dev/null +++ b/src/components/Settings/AIProvidersSection.tsx @@ -0,0 +1,231 @@ +'use client'; + +import { useState } from 'react'; +import { Eye, EyeOff, ExternalLink } from 'lucide-react'; +import { Toggle } from './Toggle'; +import type { AppSettings } from './types'; + +interface AIProvidersSectionProps { + appSettings: AppSettings; + onSaveAppSettings: (updates: Partial) => void; + onUpdateLocalSettings: (updates: Partial) => void; +} + +interface ProviderCardProps { + title: string; + description: string; + docsUrl: string; + enabled: boolean; + onToggle: () => void; + apiKey: string; + apiKeyPlaceholder: string; + onApiKeyChange: (val: string) => void; + onApiKeyBlur: () => void; + badge?: string; + badgeColor?: string; + models: string[]; + routingNote?: string; +} + +function ProviderCard({ + title, description, docsUrl, enabled, onToggle, + apiKey, apiKeyPlaceholder, onApiKeyChange, onApiKeyBlur, + badge, badgeColor = 'bg-zinc-700 text-zinc-300', models, routingNote, +}: ProviderCardProps) { + const [showKey, setShowKey] = useState(false); + + return ( +
+ {/* Header */} +
+
+
+ {title} + {badge && ( + {badge} + )} +
+

{description}

+
+
+ + + + +
+
+ + {/* API Key Input */} +
+ +
+ onApiKeyChange(e.target.value)} + onBlur={onApiKeyBlur} + placeholder={apiKeyPlaceholder} + className="w-full px-3 py-2 pr-10 bg-secondary border border-border text-sm font-mono focus:border-foreground focus:outline-none" + /> + +
+
+ + {/* Models */} +
+

Available Models

+
+ {models.map((m) => ( + + {m} + + ))} +
+
+ + {/* Routing note */} + {routingNote && ( +

+ {routingNote} +

+ )} +
+ ); +} + +export const AIProvidersSection = ({ appSettings, onSaveAppSettings, onUpdateLocalSettings }: AIProvidersSectionProps) => { + return ( +
+
+

External AI Providers

+

+ Configure API keys to use models from other providers. All providers route through + the Claude CLI using the ANTHROPIC_BASE_URL override. +

+
+ + {/* OpenRouter */} + onSaveAppSettings({ openRouterEnabled: !appSettings.openRouterEnabled })} + apiKey={appSettings.openRouterApiKey || ''} + apiKeyPlaceholder="sk-or-v1-..." + onApiKeyChange={(v) => onUpdateLocalSettings({ openRouterApiKey: v })} + onApiKeyBlur={() => onSaveAppSettings({ openRouterApiKey: appSettings.openRouterApiKey })} + models={['deepseek/deepseek-r1', 'moonshotai/kimi-k2', 'xiaomi/mimo-v2-pro', 'qwen/qwq-32b', 'openai/gpt-4.1', 'google/gemini-2.5-pro', '300+ more…']} + routingNote="Provider: openrouter — uses Claude CLI with ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1" + /> + + {/* DeepSeek */} + onSaveAppSettings({ deepSeekEnabled: !appSettings.deepSeekEnabled })} + apiKey={appSettings.deepSeekApiKey || ''} + apiKeyPlaceholder="sk-..." + onApiKeyChange={(v) => onUpdateLocalSettings({ deepSeekApiKey: v })} + onApiKeyBlur={() => onSaveAppSettings({ deepSeekApiKey: appSettings.deepSeekApiKey })} + models={['deepseek/deepseek-r1', 'deepseek/deepseek-chat', 'deepseek/deepseek-r1-distill-llama-70b']} + routingNote="Provider: deepseek — direct via https://api.deepseek.com/v1. Falls back to OpenRouter key if no DeepSeek key set." + /> + + {/* Moonshot / Kimi */} + onSaveAppSettings({ moonshotEnabled: !appSettings.moonshotEnabled })} + apiKey={appSettings.moonshotApiKey || ''} + apiKeyPlaceholder="sk-..." + onApiKeyChange={(v) => onUpdateLocalSettings({ moonshotApiKey: v })} + onApiKeyBlur={() => onSaveAppSettings({ moonshotApiKey: appSettings.moonshotApiKey })} + models={['moonshotai/kimi-k2', 'moonshotai/moonlight-16k', 'moonshotai/kimi-vl-a3b-thinking']} + routingNote="Provider: moonshot — direct via https://api.moonshot.cn/v1. Falls back to OpenRouter key if no Moonshot key set." + /> + + {/* Xiaomi MiMo */} + onSaveAppSettings({ mimoEnabled: !appSettings.mimoEnabled })} + apiKey={appSettings.mimoApiKey || ''} + apiKeyPlaceholder="sk-..." + onApiKeyChange={(v) => onUpdateLocalSettings({ mimoApiKey: v })} + onApiKeyBlur={() => onSaveAppSettings({ mimoApiKey: appSettings.mimoApiKey })} + models={['xiaomi/mimo-v2-pro', 'xiaomi/mimo-v2-flash', 'xiaomi/mimo-v2-omni']} + routingNote="Provider: mimo — direct via https://api.mimo.com/v1. Falls back to OpenRouter key if no MiMo key set." + /> + + {/* Alibaba Qwen */} + onSaveAppSettings({ qwenEnabled: !appSettings.qwenEnabled })} + apiKey={appSettings.qwenApiKey || ''} + apiKeyPlaceholder="sk-..." + onApiKeyChange={(v) => onUpdateLocalSettings({ qwenApiKey: v })} + onApiKeyBlur={() => onSaveAppSettings({ qwenApiKey: appSettings.qwenApiKey })} + models={['qwen/qwq-32b', 'qwen/qwen-2.5-72b-instruct', 'qwen/qwen-2.5-coder-32b-instruct', 'qwen/qwen3-235b-a22b']} + routingNote="Provider: qwen — direct via https://dashscope.aliyuncs.com/compatible-mode/v1. Falls back to OpenRouter key if no Qwen key set." + /> + + {/* ZhipuAI GLM */} + onSaveAppSettings({ zhipuEnabled: !appSettings.zhipuEnabled })} + apiKey={appSettings.zhipuApiKey || ''} + apiKeyPlaceholder="..." + onApiKeyChange={(v) => onUpdateLocalSettings({ zhipuApiKey: v })} + onApiKeyBlur={() => onSaveAppSettings({ zhipuApiKey: appSettings.zhipuApiKey })} + models={['zhipuai/glm-4.6', 'zhipuai/glm-4.5', 'zhipuai/glm-4-plus', 'zhipuai/glm-4-air', 'zhipuai/glm-4-flash']} + routingNote="Provider: zhipu — direct via https://open.bigmodel.cn/api/paas/v4/. Falls back to OpenRouter key if no Zhipu key set." + /> + + {/* Routing note */} +
+

How routing works

+

+ All providers use Claude CLI with ANTHROPIC_BASE_URL pointing to OpenRouter's + Anthropic-compatible endpoint. Your API key is injected via ANTHROPIC_API_KEY. +

+

+ When creating an agent, select the provider in the "Model" dropdown — + each provider shows its own model list. If a provider-specific key is set, it is used; + otherwise the OpenRouter key is used as fallback. +

+

+ Direct API routing (bypassing OpenRouter) is planned for a future release. +

+
+
+ ); +}; diff --git a/src/components/Settings/constants.ts b/src/components/Settings/constants.ts index 5c0fa2e8..4545846b 100644 --- a/src/components/Settings/constants.ts +++ b/src/components/Settings/constants.ts @@ -11,6 +11,7 @@ import { Cloud, Cpu, Plug, + Zap, } from 'lucide-react'; import { SlackIcon } from './SlackIcon'; import { JiraIcon } from './JiraIcon'; @@ -32,6 +33,7 @@ export const SECTIONS: { id: SettingsSection; label: string; icon: React.Compone { id: 'opencode', label: 'OpenCode', icon: Cpu }, { id: 'pi', label: 'Pi Terminal', icon: Cpu }, { id: 'google-workspace', label: 'Google Workspace', icon: Cloud }, + { id: 'ai-providers', label: 'AI Providers', icon: Zap }, { id: 'permissions', label: 'Permissions', icon: Shield }, { id: 'skills', label: 'Skills & Plugins', icon: Sparkles }, { id: 'mcp', label: 'Custom MCP', icon: Plug }, @@ -76,6 +78,18 @@ export const DEFAULT_APP_SETTINGS = { autoCheckUpdates: true, opencodeEnabled: false, opencodeDefaultModel: '', + openRouterEnabled: false, + openRouterApiKey: '', + deepSeekEnabled: false, + deepSeekApiKey: '', + mimoEnabled: false, + mimoApiKey: '', + moonshotEnabled: false, + moonshotApiKey: '', + qwenEnabled: false, + qwenApiKey: '', + zhipuEnabled: false, + zhipuApiKey: '', defaultProvider: 'claude', obsidianVaultPaths: [] as string[], terminalFontSize: 11, diff --git a/src/components/Settings/index.ts b/src/components/Settings/index.ts index 549b90a5..f1858fe3 100644 --- a/src/components/Settings/index.ts +++ b/src/components/Settings/index.ts @@ -28,6 +28,7 @@ export { TasmaniaIcon } from './TasmaniaIcon'; export { OpenCodeSection } from './OpenCodeSection'; export { PiTerminalSection } from './PiTerminalSection'; export { GoogleWorkspaceSection } from './GoogleWorkspaceSection'; +export { AIProvidersSection } from './AIProvidersSection'; export { PermissionsSection } from './PermissionsSection'; export { SkillsSection } from './SkillsSection'; export { McpSection } from './McpSection'; diff --git a/src/components/Settings/types.ts b/src/components/Settings/types.ts index f6878515..92bc2333 100644 --- a/src/components/Settings/types.ts +++ b/src/components/Settings/types.ts @@ -77,6 +77,20 @@ export interface AppSettings { obsidianVaultPaths?: string[]; opencodeEnabled: boolean; opencodeDefaultModel: string; + // External AI providers. Each injects ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY + // into the claude PTY process. All keys fall back to openRouterApiKey. + openRouterEnabled?: boolean; + openRouterApiKey?: string; + deepSeekEnabled?: boolean; + deepSeekApiKey?: string; + mimoEnabled?: boolean; + mimoApiKey?: string; + moonshotEnabled?: boolean; + moonshotApiKey?: string; + qwenEnabled?: boolean; + qwenApiKey?: string; + zhipuEnabled?: boolean; + zhipuApiKey?: string; notificationSounds?: { waiting?: string; complete?: string; @@ -91,4 +105,4 @@ export interface AppSettings { defaultProjectPath?: string; } -export type SettingsSection = 'general' | 'terminal' | 'git' | 'notifications' | 'telegram' | 'slack' | 'jira' | 'socialdata' | 'tasmania' | 'opencode' | 'pi' | 'google-workspace' | 'obsidian' | 'permissions' | 'skills' | 'mcp' | 'cli' | 'system'; +export type SettingsSection = 'general' | 'terminal' | 'git' | 'notifications' | 'telegram' | 'slack' | 'jira' | 'socialdata' | 'tasmania' | 'opencode' | 'pi' | 'google-workspace' | 'obsidian' | 'ai-providers' | 'permissions' | 'skills' | 'mcp' | 'cli' | 'system'; diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 475a95a0..934bfbfd 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -125,7 +125,19 @@ export interface WorktreeConfig { export type AgentCharacter = 'robot' | 'ninja' | 'wizard' | 'astronaut' | 'knight' | 'pirate' | 'alien' | 'viking' | 'frog'; -export type AgentProvider = 'claude' | 'codex' | 'gemini' | 'opencode' | 'pi' | 'local'; +export type AgentProvider = + | 'claude' + | 'codex' + | 'gemini' + | 'opencode' + | 'pi' + | 'local' + | 'openrouter' + | 'deepseek' + | 'mimo' + | 'moonshot' + | 'qwen' + | 'zhipu'; export interface AgentStatus { id: string; From ec453827e14d726e2ecc09e14f501a638c269778 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 11 Apr 2026 23:41:37 +0400 Subject: [PATCH 07/15] feat: share provider registry between NewChatModal and Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create src/lib/providers.ts as the single frontend source of truth for all 11 AI providers (claude, codex, gemini, opencode, pi, openrouter, deepseek, moonshot, mimo, qwen, zhipu) with id, label, typed icon, accent colour, model list, and default model per provider. - NewChatModal/StepModel: replace 5-provider hardcoded grid with a registry-driven map; derive PROVIDER_MODELS and PROVIDER_DEFAULT_MODEL from registry; replace sentinel-string icon rendering with typed ProviderIcon component. - Settings/GeneralSection: replace 3-option hardcoded Default Provider select with a registry-driven map; uses requiresCli flag to show "(not installed)" only for CLI providers detected as absent. Adding a new provider in future requires a single entry in src/lib/providers.ts — no UI sites need to be touched. Co-Authored-By: Claude Sonnet 4.6 --- src/components/NewChatModal/StepModel.tsx | 100 ++++------- src/components/Settings/GeneralSection.tsx | 18 +- src/lib/providers.ts | 191 +++++++++++++++++++++ 3 files changed, 236 insertions(+), 73 deletions(-) create mode 100644 src/lib/providers.ts diff --git a/src/components/NewChatModal/StepModel.tsx b/src/components/NewChatModal/StepModel.tsx index 51bbaf0f..6671f485 100644 --- a/src/components/NewChatModal/StepModel.tsx +++ b/src/components/NewChatModal/StepModel.tsx @@ -8,6 +8,7 @@ import { } from 'lucide-react'; import type { AgentPersonaValues } from './types'; import type { AgentProvider } from '@/types/electron'; +import { PROVIDER_REGISTRY, type ProviderIconDef } from '@/lib/providers'; import AgentPersonaEditor from './AgentPersonaEditor'; interface TasmaniaModel { @@ -28,48 +29,35 @@ interface ProviderModel { description: string; } -/** Static model definitions per provider */ -const PROVIDER_MODELS: Record = { - claude: [ - { id: 'default', name: 'Default', description: 'Recommended' }, - { id: 'sonnet', name: 'Sonnet', description: 'Daily coding' }, - { id: 'opus', name: 'Opus', description: 'Complex reasoning' }, - { id: 'haiku', name: 'Haiku', description: 'Fast & efficient' }, - { id: 'sonnet[1m]', name: 'Sonnet 1M', description: '1M context window' }, - { id: 'opusplan', name: 'Opus Plan', description: 'Extended thinking' }, - ], - codex: [ - { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', description: 'Recommended' }, - { id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', description: 'Balanced' }, - { id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex', description: 'Previous gen' }, - { id: 'gpt-5-codex-mini', name: 'GPT-5 Codex Mini', description: 'Fast & efficient' }, - ], - gemini: [ - { id: 'gemini-3-pro', name: 'Gemini 3 Pro', description: 'Most capable' }, - { id: 'gemini-3-flash', name: 'Gemini 3 Flash', description: 'Fast & capable' }, - { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Stable' }, - { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', description: 'Balanced' }, - ], - opencode: [ - { id: 'default', name: 'Default', description: 'Use configured default' }, - ], - pi: [ - { id: 'default', name: 'Default', description: 'Use configured model' }, - { id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet', description: 'Anthropic' }, - { id: 'anthropic/claude-opus-4-20250514', name: 'Claude Opus', description: 'Anthropic' }, - { id: 'openai/gpt-4o', name: 'GPT-4o', description: 'OpenAI' }, - { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Google' }, - ], -}; +/** Model definitions per provider — derived from shared registry */ +const PROVIDER_MODELS: Record = Object.fromEntries( + PROVIDER_REGISTRY.map((p) => [p.id, p.models]), +); -/** Default model per provider */ -const PROVIDER_DEFAULT_MODEL: Record = { - claude: 'default', - codex: 'gpt-5.2-codex', - gemini: 'gemini-3-flash', - opencode: 'default', - pi: 'default', -}; +/** Default model per provider — derived from shared registry */ +const PROVIDER_DEFAULT_MODEL: Record = Object.fromEntries( + PROVIDER_REGISTRY.map((p) => [p.id, p.defaultModel]), +); + +/** Render a typed provider icon */ +function ProviderIcon({ icon, selected, accent }: { icon: ProviderIconDef; selected: boolean; accent: string }) { + const colorClass = selected ? `text-${accent}` : 'text-text-muted'; + if (icon.type === 'svg-gemini') { + return ( + + + + ); + } + if (icon.type === 'cpu') { + return ; + } + if (icon.type === 'text') { + return {icon.content}; + } + // image + return ; +} interface StepModelProps { provider: AgentProvider; @@ -144,14 +132,8 @@ const StepModel = React.memo(function StepModel({ {/* Provider Selector */}
-
- {([ - { id: 'claude' as const, label: 'Claude', icon: '/claude-ai-icon.webp', accent: 'accent-blue' }, - { id: 'codex' as const, label: 'Codex', icon: '/chatgpt-icon.webp', accent: 'accent-green' }, - { id: 'gemini' as const, label: 'Gemini', icon: 'gemini-svg', accent: 'accent-purple' }, - { id: 'opencode' as const, label: 'OpenCode', icon: 'opencode-text', accent: 'accent-cyan' }, - { id: 'pi' as const, label: 'Pi', icon: 'pi-icon', accent: 'accent-cyan' }, - ] as const).map(({ id, label, icon, accent }) => { +
+ {PROVIDER_REGISTRY.map(({ id, label, icon, accent }) => { const installed = installedProviders?.[id] !== false; return (
diff --git a/src/lib/providers.ts b/src/lib/providers.ts new file mode 100644 index 00000000..010679fb --- /dev/null +++ b/src/lib/providers.ts @@ -0,0 +1,191 @@ +/** + * Frontend provider registry — single source of truth for all AI providers. + * + * Adding a new provider: add one entry here. NewChatModal and Settings + * Default Provider both import from this file. + */ + +import type { AgentProvider } from '@/types/electron'; + +export type ProviderIconDef = + | { type: 'image'; src: string } + | { type: 'svg-gemini' } + | { type: 'text'; content: string } + | { type: 'cpu' }; + +export interface ProviderModel { + id: string; + name: string; + description: string; +} + +export interface ProviderDef { + id: AgentProvider; + label: string; + icon: ProviderIconDef; + /** Tailwind color fragment, e.g. 'accent-blue', 'amber-500' */ + accent: string; + /** + * True for providers that ship as a CLI binary and can be "not installed". + * Checked via cliPaths.detect() for claude/codex/gemini; others default to available. + */ + requiresCli?: boolean; + models: ProviderModel[]; + defaultModel: string; +} + +export const PROVIDER_REGISTRY: ProviderDef[] = [ + { + id: 'claude', + label: 'Claude', + icon: { type: 'image', src: '/claude-ai-icon.webp' }, + accent: 'accent-blue', + requiresCli: true, + models: [ + { id: 'default', name: 'Default', description: 'Recommended' }, + { id: 'sonnet', name: 'Sonnet', description: 'Daily coding' }, + { id: 'opus', name: 'Opus', description: 'Complex reasoning' }, + { id: 'haiku', name: 'Haiku', description: 'Fast & efficient' }, + { id: 'sonnet[1m]', name: 'Sonnet 1M', description: '1M context window' }, + { id: 'opusplan', name: 'Opus Plan', description: 'Extended thinking' }, + ], + defaultModel: 'default', + }, + { + id: 'codex', + label: 'Codex', + icon: { type: 'image', src: '/chatgpt-icon.webp' }, + accent: 'accent-green', + requiresCli: true, + models: [ + { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', description: 'Recommended' }, + { id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', description: 'Balanced' }, + { id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex', description: 'Previous gen' }, + { id: 'gpt-5-codex-mini', name: 'GPT-5 Codex Mini', description: 'Fast & efficient' }, + ], + defaultModel: 'gpt-5.2-codex', + }, + { + id: 'gemini', + label: 'Gemini', + icon: { type: 'svg-gemini' }, + accent: 'accent-purple', + requiresCli: true, + models: [ + { id: 'gemini-3-pro', name: 'Gemini 3 Pro', description: 'Most capable' }, + { id: 'gemini-3-flash', name: 'Gemini 3 Flash', description: 'Fast & capable' }, + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Stable' }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', description: 'Balanced' }, + ], + defaultModel: 'gemini-3-flash', + }, + { + id: 'opencode', + label: 'OpenCode', + icon: { type: 'text', content: 'OC' }, + accent: 'accent-cyan', + requiresCli: true, + models: [ + { id: 'default', name: 'Default', description: 'Use configured default' }, + ], + defaultModel: 'default', + }, + { + id: 'pi', + label: 'Pi', + icon: { type: 'cpu' }, + accent: 'accent-cyan', + requiresCli: true, + models: [ + { id: 'default', name: 'Default', description: 'Use configured model' }, + { id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet', description: 'Anthropic' }, + { id: 'anthropic/claude-opus-4-20250514', name: 'Claude Opus', description: 'Anthropic' }, + { id: 'openai/gpt-4o', name: 'GPT-4o', description: 'OpenAI' }, + { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Google' }, + ], + defaultModel: 'default', + }, + { + id: 'openrouter', + label: 'OpenRouter', + icon: { type: 'text', content: 'OR' }, + accent: 'amber-500', + models: [ + { id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', description: 'Reasoning' }, + { id: 'moonshotai/kimi-k2', name: 'Kimi K2', description: 'Agentic' }, + { id: 'xiaomi/mimo-v2-pro', name: 'MiMo V2 Pro', description: 'Code' }, + { id: 'qwen/qwq-32b', name: 'QwQ 32B', description: 'Math & code' }, + { id: 'openai/gpt-4.1', name: 'GPT-4.1', description: 'OpenAI' }, + { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Google' }, + ], + defaultModel: 'deepseek/deepseek-r1', + }, + { + id: 'deepseek', + label: 'DeepSeek', + icon: { type: 'text', content: 'DS' }, + accent: 'sky-500', + models: [ + { id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', description: 'Reasoning' }, + { id: 'deepseek/deepseek-chat', name: 'DeepSeek V3', description: 'Flagship chat' }, + { id: 'deepseek/deepseek-r1-distill-llama-70b', name: 'R1 Distill 70B', description: 'Fast' }, + ], + defaultModel: 'deepseek/deepseek-r1', + }, + { + id: 'moonshot', + label: 'Moonshot', + icon: { type: 'text', content: '🌙' }, + accent: 'violet-500', + models: [ + { id: 'moonshotai/kimi-k2', name: 'Kimi K2', description: 'Agentic flagship' }, + { id: 'moonshotai/moonlight-16k', name: 'Moonlight 16K', description: 'Fast' }, + { id: 'moonshotai/kimi-vl-a3b-thinking', name: 'Kimi VL', description: 'Vision' }, + ], + defaultModel: 'moonshotai/kimi-k2', + }, + { + id: 'mimo', + label: 'MiMo', + icon: { type: 'text', content: 'MM' }, + accent: 'orange-500', + models: [ + { id: 'xiaomi/mimo-v2-pro', name: 'MiMo V2 Pro', description: 'Flagship' }, + { id: 'xiaomi/mimo-v2-flash', name: 'MiMo V2 Flash', description: 'Fast' }, + { id: 'xiaomi/mimo-v2-omni', name: 'MiMo V2 Omni', description: 'Multimodal' }, + ], + defaultModel: 'xiaomi/mimo-v2-pro', + }, + { + id: 'qwen', + label: 'Qwen', + icon: { type: 'text', content: 'QW' }, + accent: 'blue-500', + models: [ + { id: 'qwen/qwq-32b', name: 'QwQ 32B', description: 'Reasoning' }, + { id: 'qwen/qwen3-235b-a22b', name: 'Qwen3 235B', description: 'Flagship' }, + { id: 'qwen/qwen-2.5-72b-instruct', name: 'Qwen 2.5 72B', description: 'Balanced' }, + { id: 'qwen/qwen-2.5-coder-32b-instruct', name: 'Qwen Coder', description: 'Code' }, + ], + defaultModel: 'qwen/qwq-32b', + }, + { + id: 'zhipu', + label: 'ZhipuAI', + icon: { type: 'text', content: 'ZH' }, + accent: 'indigo-500', + models: [ + { id: 'zhipuai/glm-4.6', name: 'GLM-4.6', description: 'Flagship' }, + { id: 'zhipuai/glm-4.5', name: 'GLM-4.5', description: 'Stable' }, + { id: 'zhipuai/glm-4-plus', name: 'GLM-4 Plus', description: 'Balanced' }, + { id: 'zhipuai/glm-4-air', name: 'GLM-4 Air', description: 'Fast' }, + { id: 'zhipuai/glm-4-flash', name: 'GLM-4 Flash', description: 'Economy' }, + ], + defaultModel: 'zhipuai/glm-4.6', + }, +]; + +/** Look up a provider definition by ID. */ +export function getProviderDef(id: string): ProviderDef | undefined { + return PROVIDER_REGISTRY.find((p) => p.id === id); +} From 1c45ab99d72a72a3e984b5ca38bdafc7b79fcd85 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 11 Apr 2026 23:42:28 +0400 Subject: [PATCH 08/15] fix: pass CLI paths, MCP configs, and skills to all providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-routes.ts (the one-shot API path used by delegate_task / /start / /message) was hardcoding 'claude' as the binary and omitting provider env vars. Gaps vs the interactive ipc-handlers.ts path: - CLI binary: hardcoded 'claude', ignored appSettings.cliPaths custom path - Provider env vars: ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY never injected for OpenRouter / DeepSeek / MiMo / Moonshot / Qwen / ZhipuAI agents - MCP config: only passed to super-agent / automation agents; all other agents (including new provider workers) got no --mcp-config flag - Skills in /message reconnect: /start injects the skills prompt prefix; the auto-respawn reconnect path did not Fixes: - Resolve provider via getProvider(agent.provider) and call resolveBinaryPath(appSettings) for the correct binary in both /start and /message reconnect - Call getPtyEnvVars(..., appSettings) and spread into PTY env in both spawn sites — injects ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY for alt providers, CLAUDE_* vars for all - Pass --mcp-config to all agents whose provider uses getMcpConfigStrategy() === 'flag' (all 11 registered providers) - Inject skills prompt prefix in /message reconnect path (matches /start) - Add getAppSettings mock to agent-routes.test.ts (RouteContext already declared it; mock was missing the method) Co-Authored-By: Claude Sonnet 4.6 --- .../services/api-routes/agent-routes.test.ts | 1 + electron/services/api-routes/agent-routes.ts | 50 +++++++++++++++---- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/__tests__/electron/services/api-routes/agent-routes.test.ts b/__tests__/electron/services/api-routes/agent-routes.test.ts index 8e63bf00..b28ee5bc 100644 --- a/__tests__/electron/services/api-routes/agent-routes.test.ts +++ b/__tests__/electron/services/api-routes/agent-routes.test.ts @@ -95,6 +95,7 @@ beforeEach(() => { ctx = { mainWindow: { isDestroyed: () => false, webContents: { send: vi.fn() } } as any, appSettings: {} as AppSettings, + getAppSettings: () => ({} as AppSettings), getTelegramBot: () => null, getSlackApp: () => null, slackResponseChannel: null, diff --git a/electron/services/api-routes/agent-routes.ts b/electron/services/api-routes/agent-routes.ts index 638b8c67..b45450dd 100644 --- a/electron/services/api-routes/agent-routes.ts +++ b/electron/services/api-routes/agent-routes.ts @@ -5,6 +5,7 @@ import { app } from 'electron'; import { v4 as uuidv4 } from 'uuid'; import { agents, saveAgents, killStalePty, ensureProjectTrusted } from '../../core/agent-manager'; import { ptyProcesses, writeProgrammaticInput } from '../../core/pty-manager'; +import { getProvider } from '../../providers'; import { buildFullPath } from '../../utils/path-builder'; import { AgentStatus, AgentCharacter } from '../../types'; import { RouteApp, RouteContext } from './types'; @@ -169,7 +170,14 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { // break when the path legitimately contains a single quote. const rawWorkingDir = agent.worktreePath || agent.projectPath; const workingDir = rawWorkingDir.replace(/'/g, "'\\''"); - let command = `cd '${workingDir}' && claude`; + + // Resolve provider and binary — honours custom CLI paths in Settings and + // the agent's provider (claude / openrouter / deepseek / mimo / etc.). + const appSettings = ctx.getAppSettings(); + const cliProvider = getProvider(agent.provider); + const binaryPath = cliProvider.resolveBinaryPath(appSettings); + const escapedBinary = binaryPath.replace(/'/g, "'\\''"); + let command = `cd '${workingDir}' && '${escapedBinary}'`; const isAutomationAgent = agent.name?.toLowerCase().includes('automation:'); const usePrintMode = printMode || isAutomationAgent; @@ -181,7 +189,9 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { const isSuperAgentApi = agent.name?.toLowerCase().includes('super agent') || agent.name?.toLowerCase().includes('orchestrator'); - if (isSuperAgentApi || isAutomationAgent) { + // Pass MCP config to all agents using a flag-strategy provider (all claude-based + // providers, including OpenRouter/DeepSeek/etc.). Mirrors ipc-handlers behaviour. + if (cliProvider.getMcpConfigStrategy() === 'flag') { const mcpConfigPath = path.join(app.getPath('home'), '.claude', 'mcp.json'); if (fs.existsSync(mcpConfigPath)) { command += ` --mcp-config '${mcpConfigPath}'`; @@ -236,6 +246,10 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { // BUG 6: pre-accept Claude Code's workspace trust dialog for this cwd. ensureProjectTrusted(rawWorkingDir); + // Inject provider env vars: CLAUDE_* tracking vars + ANTHROPIC_BASE_URL / + // ANTHROPIC_API_KEY for alt providers (OpenRouter, DeepSeek, MiMo, Moonshot, + // Qwen, ZhipuAI). Claude provider just returns the CLAUDE_* vars. + const providerEnvVars = cliProvider.getPtyEnvVars(agent.id, agent.projectPath, agent.skills || [], appSettings); const ptyProcess = pty.spawn(shell, ['-l', '-c', command], { name: 'xterm-256color', cols: 120, @@ -245,9 +259,7 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { ...process.env, PATH: fullPath, TERM: 'xterm-256color', - CLAUDE_SKILLS: agent.skills?.join(',') || '', - CLAUDE_AGENT_ID: agent.id, - CLAUDE_PROJECT_PATH: agent.projectPath, + ...providerEnvVars, }, }); @@ -351,7 +363,13 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { const rawWorkingDir = agent.worktreePath || agent.projectPath; const workingDir = rawWorkingDir.replace(/'/g, "'\\''"); const effectiveMode = agent.permissionMode ?? (agent.skipPermissions ? 'auto' : 'normal'); - let reconnectCmd = `cd '${workingDir}' && claude`; + + // Resolve provider and binary for the reconnect session (mirrors /start path). + const reconnectAppSettings = ctx.getAppSettings(); + const reconnectProvider = getProvider(agent.provider); + const reconnectBinaryPath = reconnectProvider.resolveBinaryPath(reconnectAppSettings); + const reconnectEscapedBinary = reconnectBinaryPath.replace(/'/g, "'\\''"); + let reconnectCmd = `cd '${workingDir}' && '${reconnectEscapedBinary}'`; if (effectiveMode === 'auto' || effectiveMode === 'bypass') { reconnectCmd += ' --dangerously-skip-permissions'; } @@ -361,12 +379,27 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { if (reconnectIsSuper || agent.orchestratorMode) { reconnectCmd += ' --disallowed-tools "Edit" "Write" "MultiEdit" "NotebookEdit"'; } - reconnectCmd += ` '${message.replace(/'/g, "'\\''")}'`; + // Pass MCP config to reconnected sessions (mirrors /start path). + if (reconnectProvider.getMcpConfigStrategy() === 'flag') { + const reconnectMcpPath = path.join(app.getPath('home'), '.claude', 'mcp.json'); + if (fs.existsSync(reconnectMcpPath)) { + reconnectCmd += ` --mcp-config '${reconnectMcpPath}'`; + } + } + // Inject skills into reconnect prompt (mirrors /start path behaviour). + let reconnectFinalMessage = message; + if (agent.skills && agent.skills.length > 0 && !reconnectIsSuper) { + const skillsList = agent.skills.join(', '); + reconnectFinalMessage = `[IMPORTANT: Use these skills for this session: ${skillsList}. Invoke them with / when relevant to the task.] ${message}`; + } + reconnectCmd += ` '${reconnectFinalMessage.replace(/'/g, "'\\''")}'`; const reconnectShell = '/bin/bash'; const reconnectPath = buildFullPath(); // BUG 6: pre-accept Claude Code's workspace trust dialog for this cwd. ensureProjectTrusted(rawWorkingDir); + // Inject provider env vars (ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY for alt providers). + const reconnectProviderEnvVars = reconnectProvider.getPtyEnvVars(agent.id, agent.projectPath, agent.skills || [], reconnectAppSettings); const reconnectPty = pty.spawn(reconnectShell, ['-l', '-c', reconnectCmd], { name: 'xterm-256color', cols: 120, @@ -375,8 +408,7 @@ export function registerAgentRoutes(app_: RouteApp, ctx: RouteContext): void { env: { ...process.env as { [key: string]: string }, PATH: reconnectPath, - CLAUDE_AGENT_ID: agent.id, - CLAUDE_PROJECT_PATH: agent.projectPath, + ...reconnectProviderEnvVars, }, }); From b21cbee6670fb748011f18f0403c906cf32494d5 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 11 Apr 2026 23:54:39 +0400 Subject: [PATCH 09/15] fix: use provider registry for skills install dialog and skill badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hardcoded provider lists missed in the initial registry pass: - src/app/skills/page.tsx: availableProviders=['claude','codex','gemini'] → derived from PROVIDER_REGISTRY.filter(p => p.requiresCli) so all current and future CLI providers (claude, codex, gemini, opencode, pi) appear in the TerminalDialog "Install to:" selector. Env-injection providers (openrouter, deepseek, mimo, moonshot, qwen, zhipu) are correctly excluded — they share Claude's skill directory via ANTHROPIC_BASE_URL and need no separate install step. - src/components/NewChatModal/StepTools.tsx: PROVIDER_IDS constant → same requiresCli derivation; drives the installed-on badge row shown next to each skill in the NewChatModal skills browser. - src/components/ProviderBadge.tsx: extend PROVIDER_CONFIG with opencode (OC) and pi (π) entries so TerminalDialog can render their badge icons when they appear in the install selector. Co-Authored-By: Claude Sonnet 4.6 --- src/app/skills/page.tsx | 6 +++++- src/components/NewChatModal/StepTools.tsx | 4 +++- src/components/ProviderBadge.tsx | 10 ++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app/skills/page.tsx b/src/app/skills/page.tsx index 11d34de2..32004a82 100644 --- a/src/app/skills/page.tsx +++ b/src/app/skills/page.tsx @@ -20,9 +20,13 @@ import { import { useClaude } from '@/hooks/useClaude'; import { useElectronSkills } from '@/hooks/useElectron'; import { SKILLS_DATABASE, fetchSkillsFromMarketplace, type Skill } from '@/lib/skills-database'; +import { PROVIDER_REGISTRY } from '@/lib/providers'; import TerminalDialog from '@/components/TerminalDialog'; import ProviderBadge from '@/components/ProviderBadge'; +/** Providers with a local CLI binary that have their own skill directory */ +const CLI_PROVIDER_IDS = PROVIDER_REGISTRY.filter((p) => p.requiresCli).map((p) => p.id); + const COL_STYLES = { rank: { width: '4%' }, skill: { width: '30%' }, @@ -487,7 +491,7 @@ export default function SkillsPage() { open={showInstallTerminal} repo={currentInstallRepo} title={currentInstallTitle} - availableProviders={['claude', 'codex', 'gemini']} + availableProviders={CLI_PROVIDER_IDS} onClose={(success) => { setShowInstallTerminal(false); setInstallingSkill(null); diff --git a/src/components/NewChatModal/StepTools.tsx b/src/components/NewChatModal/StepTools.tsx index cb81b637..9c7664fd 100644 --- a/src/components/NewChatModal/StepTools.tsx +++ b/src/components/NewChatModal/StepTools.tsx @@ -15,6 +15,7 @@ import { import { SKILLS_DATABASE, SKILL_CATEGORIES, type Skill } from '@/lib/skills-database'; import type { ClaudeSkill } from '@/lib/claude-code'; import type { AgentProvider } from '@/types/electron'; +import { PROVIDER_REGISTRY } from '@/lib/providers'; import ProviderBadge from '@/components/ProviderBadge'; interface StepToolsProps { @@ -31,7 +32,8 @@ interface StepToolsProps { onToggleVault: (vaultPath: string) => void; } -const PROVIDER_IDS = ['claude', 'codex', 'gemini'] as const; +/** CLI-based providers that have their own skill directories */ +const PROVIDER_IDS = PROVIDER_REGISTRY.filter((p) => p.requiresCli).map((p) => p.id); const StepTools = React.memo(function StepTools({ selectedSkills, diff --git a/src/components/ProviderBadge.tsx b/src/components/ProviderBadge.tsx index 74c73c3e..da9a3c6e 100644 --- a/src/components/ProviderBadge.tsx +++ b/src/components/ProviderBadge.tsx @@ -8,6 +8,14 @@ function GeminiLogo({ className }: { className?: string }) { ); } +function OpenCodeLogo(_: { className?: string }) { + return OC; +} + +function PiLogo(_: { className?: string }) { + return π; +} + const PROVIDER_CONFIG: Record; @@ -15,6 +23,8 @@ const PROVIDER_CONFIG: Record Date: Sun, 12 Apr 2026 00:10:39 +0400 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20full=20provider=20audit=20?= =?UTF-8?q?=E2=80=94=20eliminate=20hardcoded=20provider=20lists=20in=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep of all src/app/** and src/components/** files for hardcoded provider arrays and badge-color ternaries. Every finding replaced with PROVIDER_REGISTRY / getProviderDef() from @/lib/providers. Changes: - src/lib/providers.ts: add `badgeClass` field (full concrete Tailwind class string) to ProviderDef interface and all 11 provider entries, to avoid dynamic class generation that Tailwind JIT would purge - src/components/AgentList/AgentCard.tsx: remove PROVIDER_ICONS const (only had claude/codex/pi); add ProviderIcon component backed by getProviderDef(); covers all 11 registry providers - src/components/AgentList/AgentDetailPanel.tsx: replace codex/gemini badge-color ternary with getProviderDef(agent.provider)?.badgeClass - src/components/Settings/McpSection.tsx: replace hardcoded `type Provider = 'claude' | 'codex' | 'gemini'` with AgentProvider; derive PROVIDER_TABS from PROVIDER_REGISTRY.filter(requiresCli); rewrite ProviderIcon() to use registry icon descriptors; drop hand-coded GeminiSvg - src/app/memory/page.tsx: replace codex/gemini badge-color ternary with getProviderDef(project.provider)?.badgeClass - src/app/usage/page.tsx: add TODO noting usage tracking is Claude-native (useClaude() reads Claude Code JSONL files; other providers don't generate them) All 868 tests pass. tsc --noEmit and tsc -p electron/tsconfig.json --noEmit both clean on src/ files. Co-Authored-By: Claude Sonnet 4.6 --- src/app/memory/page.tsx | 5 +- src/app/usage/page.tsx | 4 ++ src/components/AgentList/AgentCard.tsx | 55 ++++++++++--------- src/components/AgentList/AgentDetailPanel.tsx | 5 +- src/components/Settings/McpSection.tsx | 44 ++++++++------- src/lib/providers.ts | 16 ++++++ 6 files changed, 76 insertions(+), 53 deletions(-) diff --git a/src/app/memory/page.tsx b/src/app/memory/page.tsx index 9c77de17..10ff9fd7 100644 --- a/src/app/memory/page.tsx +++ b/src/app/memory/page.tsx @@ -24,6 +24,7 @@ import { import { useMemory, formatBytes, timeAgo } from '@/hooks/useMemory'; import type { ProjectMemory, MemoryFile } from '@/types/electron'; import AgentKnowledgeGraph from '@/components/Memory/AgentKnowledgeGraph'; +import { getProviderDef } from '@/lib/providers'; // ─── Inline editor with save/cancel ──────────────────────────────────────── @@ -163,9 +164,7 @@ function ProjectCard({ {project.projectName} {project.provider && project.provider !== 'claude' && ( {project.provider} diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx index 9acab8ea..92a2b209 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -22,6 +22,10 @@ import { FolderOpen, } from 'lucide-react'; import { useClaude } from '@/hooks/useClaude'; +// TODO: Usage tracking is Claude-native — useClaude() reads Claude Code's JSONL usage files. +// Other providers (codex, gemini, openrouter, deepseek, etc.) don't produce these files, +// so their token/cost data is not tracked here. A future enhancement would read per-provider +// usage logs if those CLIs expose them. // Token pricing per million tokens (MTok) const MODEL_PRICING: Record = { - claude: { src: '/claude-ai-icon.webp', alt: 'Claude' }, - codex: { src: '/chatgpt-icon.webp', alt: 'ChatGPT' }, - pi: { src: '', alt: 'Pi Terminal' }, -}; +/** Renders the provider icon for a given provider id using the shared registry. */ +function ProviderIcon({ providerId }: { providerId: string }) { + const def = getProviderDef(providerId); + if (!def) return null; + const { icon, label } = def; + if (icon.type === 'image') { + return {label}; + } + if (icon.type === 'svg-gemini') { + return ( + + + + ); + } + if (icon.type === 'cpu') { + return ; + } + // text + return ( + + {icon.content} + + ); +} interface AgentCardProps { agent: AgentStatus; @@ -67,27 +88,9 @@ export function AgentCard({ agent, isSelected, onSelect, onEdit }: AgentCardProp

{isSuper && } {agent.name || 'Unnamed Agent'} - {(() => { - const p = agent.provider && agent.provider !== 'local' ? agent.provider : 'claude'; - const icon = PROVIDER_ICONS[p]; - if (icon) return {icon.alt}; - if (p === 'opencode') return ( - OC - ); - if (p === 'pi') return ( - - - - ); - if (p === 'gemini') return ( - - - - - - ); - return null; - })()} +

+ ); + })} +
+
+ )} )}
diff --git a/src/app/recurring-tasks/components/CreateTaskModal.tsx b/src/app/recurring-tasks/components/CreateTaskModal.tsx index e01ba86c..2eefbdac 100644 --- a/src/app/recurring-tasks/components/CreateTaskModal.tsx +++ b/src/app/recurring-tasks/components/CreateTaskModal.tsx @@ -4,6 +4,7 @@ import type { Agent } from '../types'; import { ScheduleFieldPicker } from './ScheduleFieldPicker'; import { TaskOptionsFields } from './TaskOptionsFields'; import { NotificationFields } from './NotificationFields'; +import { PROVIDER_REGISTRY } from '@/lib/providers'; interface CreateTaskModalProps { show: boolean; @@ -24,11 +25,15 @@ interface CreateTaskModalProps { useWorktree: boolean; notifyTelegram: boolean; notifySlack: boolean; + provider: string; + skills: string[]; }; onFormChange: (data: CreateTaskModalProps['formData']) => void; isCreating: boolean; createError: string | null; onSubmit: () => void; + availableSkills?: string[]; + defaultProvider?: string; } export function CreateTaskModal({ @@ -40,7 +45,11 @@ export function CreateTaskModal({ isCreating, createError, onSubmit, + availableSkills = [], + defaultProvider = 'claude', }: CreateTaskModalProps) { + // Initialize provider to defaultProvider on first render if still at default + const effectiveProvider = formData.provider || defaultProvider; return ( {show && ( @@ -154,6 +163,54 @@ export function CreateTaskModal({ onWorktreeChange={(v) => onFormChange({ ...formData, useWorktree: v })} /> + {/* Provider */} +
+ + +
+ + {/* Skills */} + {availableSkills.length > 0 && ( +
+ +
+ {availableSkills.map(skill => { + const selected = formData.skills.includes(skill); + return ( + + ); + })} +
+
+ )} + {/* Notifications */} )} + {task.provider && task.provider !== 'claude' && (() => { + const def = getProviderDef(task.provider); + if (!def) return null; + return ( +
+ {def.icon.type === 'image' && {def.label}} + {def.icon.type === 'svg-gemini' && } + {def.icon.type === 'cpu' && } + {(def.icon.type === 'text') && {def.icon.content}} + {def.label} +
+ ); + })()} + + {task.skills && task.skills.length > 0 && ( +
+ + {task.skills.length} skill{task.skills.length !== 1 ? 's' : ''} + +
+ )} +
{task.projectPath.split('/').pop()} diff --git a/src/app/recurring-tasks/hooks/useTaskForm.ts b/src/app/recurring-tasks/hooks/useTaskForm.ts index 2a0a400d..135823ae 100644 --- a/src/app/recurring-tasks/hooks/useTaskForm.ts +++ b/src/app/recurring-tasks/hooks/useTaskForm.ts @@ -18,6 +18,8 @@ const INITIAL_FORM = { useWorktree: false, notifyTelegram: false, notifySlack: false, + provider: 'claude', + skills: [] as string[], }; export function useTaskForm( @@ -78,6 +80,8 @@ export function useTaskForm( prompt: fullPrompt, schedule: cron, projectPath, + provider: formData.provider || undefined, + skills: formData.skills.length > 0 ? formData.skills : undefined, autonomous: formData.autonomous, useWorktree: formData.useWorktree, notifications: { diff --git a/src/app/recurring-tasks/page.tsx b/src/app/recurring-tasks/page.tsx index 90b0dbac..219c4dee 100644 --- a/src/app/recurring-tasks/page.tsx +++ b/src/app/recurring-tasks/page.tsx @@ -3,6 +3,7 @@ import { useState, useCallback } from 'react'; import { Loader2 } from 'lucide-react'; import { isElectron } from '@/hooks/useElectron'; +import { useClaude } from '@/hooks/useClaude'; import SchedulerCalendar from '@/components/SchedulerCalendar'; import { useToast, useScheduledTasks, useTaskForm, useEditForm, useTaskLogs } from './hooks'; import { @@ -21,6 +22,9 @@ export default function RecurringTasksPage() { const taskForm = useTaskForm(scheduled.agents, scheduled.loadTasks, showToast); const editForm = useEditForm(scheduled.loadTasks, showToast); const taskLogs = useTaskLogs(); + const { data: claudeData } = useClaude(); + const availableSkills = claudeData?.skills?.map(s => s.name) ?? []; + const defaultProvider = claudeData?.settings?.defaultProvider ?? 'claude'; const [expandedTasks, setExpandedTasks] = useState>(new Set()); @@ -107,6 +111,8 @@ export default function RecurringTasksPage() { isCreating={taskForm.isCreating} createError={taskForm.createError} onSubmit={taskForm.handleCreateTask} + availableSkills={availableSkills} + defaultProvider={defaultProvider} /> )} + {/* Usage by Provider */} + +
+ + Usage by Provider +
+ {(() => { + const providerTotals = data?.tokenStats?.providerTotals; + const entries = providerTotals ? Object.entries(providerTotals) : []; + const sorted = entries + .sort(([, a], [, b]) => b.cost !== a.cost ? b.cost - a.cost : b.sessions - a.sessions); + + if (sorted.length === 0) { + return ( +

+ No usage recorded yet for any provider. Start a chat to see token usage appear here. +

+ ); + } + + return ( +
+ + + + + + + + + + + + {sorted.map(([providerId, totals]) => { + const def = getProviderDef(providerId); + const label = def?.label ?? providerId; + const icon = def?.icon; + const fmtTokens = (n: number) => + n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n); + return ( + + + + + + + + ); + })} + +
ProviderSessionsTokens InTokens OutCost
+
+ {icon?.type === 'image' && {label}} + {icon?.type === 'svg-gemini' && ( + + + + )} + {icon?.type === 'cpu' && } + {icon?.type === 'text' && {icon.content}} + {label} +
+
{totals.sessions}{fmtTokens(totals.in)}{fmtTokens(totals.out)} + {totals.cost > 0 ? `$${totals.cost.toFixed(2)}` : '—'} +
+
+ ); + })()} +
+ {/* Pricing Reference Table */} - - + + + + + ); } if (icon.type === 'cpu') { - return ; + return ( + + + + ); } // text return ( diff --git a/src/hooks/useClaude.ts b/src/hooks/useClaude.ts index c6771ef4..ec3543d3 100644 --- a/src/hooks/useClaude.ts +++ b/src/hooks/useClaude.ts @@ -26,6 +26,7 @@ interface TokenStats { sessionCount: number; modelTokens?: Record; dailyCosts?: Record; + providerTotals?: Record; } interface ClaudeData { diff --git a/src/lib/claude-code.ts b/src/lib/claude-code.ts index 87071691..a7fd5bd6 100644 --- a/src/lib/claude-code.ts +++ b/src/lib/claude-code.ts @@ -66,6 +66,7 @@ export interface ClaudeSettings { allow: string[]; deny: string[]; }; + defaultProvider?: string; } export interface ClaudeStats { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 934bfbfd..d09a30e7 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -560,6 +560,7 @@ export interface ElectronAPI { projectPath: string; agentId?: string; agentName?: string; + provider?: string; autonomous: boolean; worktree?: { enabled: boolean; @@ -581,6 +582,8 @@ export interface ElectronAPI { prompt: string; schedule: string; projectPath: string; + provider?: string; + skills?: string[]; autonomous: boolean; useWorktree?: boolean; notifications?: { @@ -639,6 +642,8 @@ export interface ElectronAPI { agentEnabled?: boolean; agentPrompt?: string; agentProjectPath?: string; + agentProvider?: string; + agentSkills?: string[]; outputTelegram?: boolean; outputSlack?: boolean; outputGitHubComment?: boolean; From af928d01076c9e0dae57d9af15bbc2d6d42c254a Mon Sep 17 00:00:00 2001 From: Noah Date: Sun, 12 Apr 2026 12:11:43 +0400 Subject: [PATCH 15/15] feat(polish): multi-provider UX improvements across agents, settings, and usage - Fix duplicate React key warnings by filtering .worktrees/ paths from project lists - Rename ZhipuAI to Zai in registry, settings, and UI - Add SVG provider logos for OpenRouter, DeepSeek, Moonshot, MiMo, Qwen, Zai - Block provider selection without API key; show tooltip in model picker and default provider - Replace fixed model grid with dynamic dropdown in NewChatModal - Usage page: update header for multi-provider, add provider pricing table, add per-provider breakdown - Settings: remove Quick Info (duplicate of System), move AI Providers below Terminal - Merge OpenCode/Pi Terminal pages into CLI Paths section - Update Git co-author label for all providers, update Permissions/Skills descriptions - Add Terminal scope clarification, add electron.d.ts types for provider API keys Co-Authored-By: Claude Opus 4.6 --- src/app/settings/page.tsx | 19 +- src/app/usage/page.tsx | 181 +++++++++++++----- src/components/AgentList/AgentCard.tsx | 11 +- .../KanbanBoard/components/NewTaskModal.tsx | 4 +- src/components/NewChatModal/StepModel.tsx | 94 ++++++--- src/components/NewChatModal/index.tsx | 31 +-- .../Settings/AIProvidersSection.tsx | 6 +- src/components/Settings/CLIPathsSection.tsx | 102 +++++++++- src/components/Settings/GeneralSection.tsx | 46 ++--- src/components/Settings/GitSection.tsx | 4 +- src/components/Settings/McpSection.tsx | 6 +- .../Settings/PermissionsSection.tsx | 5 +- src/components/Settings/SkillsSection.tsx | 2 +- src/components/Settings/TerminalSection.tsx | 2 +- src/components/Settings/constants.ts | 6 +- src/components/Settings/index.ts | 2 - src/components/Settings/types.ts | 2 +- src/hooks/useElectron.ts | 3 +- src/lib/providers.ts | 20 +- src/types/electron.d.ts | 14 ++ 20 files changed, 400 insertions(+), 160 deletions(-) diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index a56cfee2..f01fbfd3 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -18,8 +18,6 @@ import { JiraSection, SocialDataSection, TasmaniaSection, - OpenCodeSection, - PiTerminalSection, GoogleWorkspaceSection, AIProvidersSection, PermissionsSection, @@ -128,22 +126,6 @@ function SettingsPageInner() { onUpdateLocalSettings={updateLocalAppSettings} /> ); - case 'opencode': - return ( - - ); - case 'pi': - return ( - - ); case 'google-workspace': return ( ); case 'system': diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx index c5ddd029..920faa63 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -23,7 +23,22 @@ import { Cpu, } from 'lucide-react'; import { useClaude } from '@/hooks/useClaude'; -import { getProviderDef } from '@/lib/providers'; +import { getProviderDef, PROVIDER_REGISTRY } from '@/lib/providers'; +import { Pencil as PencilIcon } from 'lucide-react'; + +// Per-provider pricing defaults (input/output per MTok) for the pricing reference table. +// Users can override these via the edit button. +const DEFAULT_PROVIDER_PRICING: Record = { + claude: null, // Claude uses the detailed MODEL_PRICING below + codex: { inputPerMTok: 2, outputPerMTok: 8 }, + gemini: { inputPerMTok: 1.25, outputPerMTok: 10 }, + openrouter: null, // varies per model + deepseek: { inputPerMTok: 0.55, outputPerMTok: 2.19 }, + moonshot: { inputPerMTok: 1, outputPerMTok: 4 }, + mimo: { inputPerMTok: 1, outputPerMTok: 4 }, + qwen: { inputPerMTok: 0.30, outputPerMTok: 1.20 }, + zhipu: { inputPerMTok: 0.70, outputPerMTok: 2.80 }, +}; // Token pricing per million tokens (MTok) const MODEL_PRICING: Record

- Claude Code Usage & Quota + Usage & Quota Usage & Quota

- Track your subscription quota, session activity, and estimated API costs + Track your subscription quota, session activity, and estimated API costs across all providers

@@ -1250,7 +1265,7 @@ export default function UsagePage() { })()} - {/* Pricing Reference Table */} + {/* Pricing Reference Table — all providers */} {showPricingTable && ( -
- - - - - - - - - - - - - {[ - { name: 'Claude Opus 4.6', key: 'claude-opus-4-6' }, - { name: 'Claude Opus 4.6 (Fast Mode)', key: 'fast-opus-4-6', fast: true }, - { name: 'Claude Opus 4.5', key: 'claude-opus-4-5' }, - { name: 'Claude Opus 4.1', key: 'claude-opus-4-1' }, - { name: 'Claude Opus 4', key: 'claude-opus-4' }, - { name: 'Claude Sonnet 4.6', key: 'claude-sonnet-4-6' }, - { name: 'Claude Sonnet 4.5', key: 'claude-sonnet-4-5' }, - { name: 'Claude Sonnet 4', key: 'claude-sonnet-4' }, - { name: 'Claude Haiku 4.5', key: 'claude-haiku-4-5' }, - { name: 'Claude Haiku 3.5', key: 'claude-haiku-3-5' }, - { name: 'Claude Haiku 3', key: 'claude-haiku-3' }, - ].map((model) => { - if ('fast' in model) { +
+ {/* Claude Models — detailed */} +
+

Claude (Anthropic)

+
ModelInputOutputCache Hits5m Cache Write1h Cache Write
+ + + + + + + + + + + + {[ + { name: 'Opus 4.6', key: 'claude-opus-4-6' }, + { name: 'Opus 4.5', key: 'claude-opus-4-5' }, + { name: 'Opus 4.1', key: 'claude-opus-4-1' }, + { name: 'Sonnet 4.6', key: 'claude-sonnet-4-6' }, + { name: 'Sonnet 4.5', key: 'claude-sonnet-4-5' }, + { name: 'Sonnet 4', key: 'claude-sonnet-4' }, + { name: 'Haiku 4.5', key: 'claude-haiku-4-5' }, + { name: 'Haiku 3.5', key: 'claude-haiku-3-5' }, + { name: 'Haiku 3', key: 'claude-haiku-3' }, + ].map((model) => { + const pricing = MODEL_PRICING[model.key]; return ( - - - - - + + + + + + + ); + })} + +
ModelInputOutputCache Hits5m Cache Write1h Cache Write
{model.name}$30/MTok$150/MTok$3/MTok$37.50/MTok$60/MTok${pricing.inputPerMTok}/MTok${pricing.outputPerMTok}/MTok${pricing.cacheHitsPerMTok}/MTok${pricing.cache5mWritePerMTok}/MTok${pricing.cache1hWritePerMTok}/MTok
+
+ + {/* Other Providers — simplified input/output pricing */} +
+

Other Providers

+ + + + + + + + + + {PROVIDER_REGISTRY.filter(p => p.id !== 'claude').map((p) => { + const pricing = DEFAULT_PROVIDER_PRICING[p.id]; + return ( + + + + ); - } - const pricing = MODEL_PRICING[model.key]; - return ( - - - - - - - - - ); - })} - -
ProviderInput /MTokOutput /MTok
{p.label} + {pricing ? `$${pricing.inputPerMTok}` : } + + {pricing ? `$${pricing.outputPerMTok}` : } +
{model.name}${pricing.inputPerMTok}/MTok${pricing.outputPerMTok}/MTok${pricing.cacheHitsPerMTok}/MTok${pricing.cache5mWritePerMTok}/MTok${pricing.cache1hWritePerMTok}/MTok
+ })} + + +

+ Pricing shown is approximate default rates. Actual costs may vary by model and routing. + OpenRouter pricing varies per model — check openrouter.ai/models for details. +

+
)}
+ + {/* Provider Usage Breakdown */} + {data?.tokenStats?.providerTotals && Object.keys(data.tokenStats.providerTotals).length > 0 && ( + +
+ + Usage by Provider +
+
+ {Object.entries(data.tokenStats.providerTotals) + .sort(([, a], [, b]) => b.cost - a.cost) + .map(([providerId, totals]) => { + const providerDef = getProviderDef(providerId); + return ( +
+
+ {providerDef?.label || providerId} + ${totals.cost.toFixed(2)} +
+
+
+ Tokens + {((totals.in + totals.out) / 1000000).toFixed(2)}M +
+
+ Sessions + {totals.sessions} +
+
+
+ ); + })} +
+
+ )} ); } diff --git a/src/components/AgentList/AgentCard.tsx b/src/components/AgentList/AgentCard.tsx index 43f9d37c..c113aab9 100644 --- a/src/components/AgentList/AgentCard.tsx +++ b/src/components/AgentList/AgentCard.tsx @@ -29,10 +29,17 @@ function ProviderIcon({ providerId }: { providerId: string }) { ); } - // text + if (icon.type === 'text') { + return ( + + {icon.content} + + ); + } + // SVG provider icons — show provider label as compact badge return ( - {icon.content} + {label.slice(0, 2)} ); } diff --git a/src/components/KanbanBoard/components/NewTaskModal.tsx b/src/components/KanbanBoard/components/NewTaskModal.tsx index 6c67378c..be29298f 100644 --- a/src/components/KanbanBoard/components/NewTaskModal.tsx +++ b/src/components/KanbanBoard/components/NewTaskModal.tsx @@ -83,7 +83,9 @@ export function NewTaskModal({ onClose, onCreate }: NewTaskModalProps) { useEffect(() => { const loadProjects = async () => { if (isElectron() && window.electronAPI?.fs?.listProjects) { - const projectList = await window.electronAPI.fs.listProjects(); + const rawProjects = await window.electronAPI.fs.listProjects(); + // Filter out worktree paths to avoid duplicate React keys + const projectList = rawProjects.filter((p: Project) => !p.path.includes('/.worktrees/')); // Load app settings for favorites, hidden, and default project const settings = await window.electronAPI?.appSettings?.get(); diff --git a/src/components/NewChatModal/StepModel.tsx b/src/components/NewChatModal/StepModel.tsx index 6671f485..96d8bafa 100644 --- a/src/components/NewChatModal/StepModel.tsx +++ b/src/components/NewChatModal/StepModel.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect } from 'react'; import { - Zap, Cpu, AlertCircle, Loader2, @@ -49,6 +48,49 @@ function ProviderIcon({ icon, selected, accent }: { icon: ProviderIconDef; selec ); } + if (icon.type === 'svg-openrouter') { + return ( + + + + ); + } + if (icon.type === 'svg-deepseek') { + return ( + + + + ); + } + if (icon.type === 'svg-moonshot') { + return ( + + + + ); + } + if (icon.type === 'svg-mimo') { + return ( + + + + + ); + } + if (icon.type === 'svg-qwen') { + return ( + + + + ); + } + if (icon.type === 'svg-zai') { + return ( + + + + ); + } if (icon.type === 'cpu') { return ; } @@ -133,8 +175,11 @@ const StepModel = React.memo(function StepModel({
- {PROVIDER_REGISTRY.map(({ id, label, icon, accent }) => { + {PROVIDER_REGISTRY.map(({ id, label, icon, accent, requiresCli }) => { const installed = installedProviders?.[id] !== false; + const disabledReason = !installed + ? requiresCli ? 'Not installed' : 'Add API key in Settings' + : null; return (
- {!installed && ( - Not installed + {disabledReason && ( + {disabledReason} )} ); @@ -182,32 +228,26 @@ const StepModel = React.memo(function StepModel({
- {/* Model Selection — dynamic based on provider */} + {/* Model Selection — dynamic dropdown based on provider */} {provider !== 'local' ? (
-
- {(PROVIDER_MODELS[provider] || PROVIDER_MODELS.claude).map((m) => { - const accentColor = PROVIDER_REGISTRY.find(p => p.id === provider)?.accent ?? 'accent-blue'; - return ( - - ); - })} -
+ + {model && ( +

+ {(PROVIDER_MODELS[provider] || PROVIDER_MODELS.claude).find(m => m.id === model)?.description} +

+ )}
) : (
diff --git a/src/components/NewChatModal/index.tsx b/src/components/NewChatModal/index.tsx index 4b209c56..bdd754c4 100644 --- a/src/components/NewChatModal/index.tsx +++ b/src/components/NewChatModal/index.tsx @@ -231,17 +231,26 @@ export default function NewChatModal({ setRegisteredVaults(info?.vaultPaths || []); }); - // Detect installed CLI providers - window.electronAPI?.cliPaths?.detect().then((paths) => { - if (paths) { - setInstalledProviders({ - claude: !!paths.claude, - codex: !!paths.codex, - gemini: !!paths.gemini, - opencode: !!(paths as Record).opencode, - pi: !!(paths as Record).pi, - }); - } + // Detect installed CLI providers + API key availability + Promise.all([ + window.electronAPI?.cliPaths?.detect(), + window.electronAPI?.appSettings?.get(), + ]).then(([paths, settings]) => { + const providers: Record = { + claude: !!paths?.claude, + codex: !!paths?.codex, + gemini: !!paths?.gemini, + opencode: !!(paths as Record | undefined)?.opencode, + pi: !!(paths as Record | undefined)?.pi, + // Non-CLI providers: available only when API key is configured + openrouter: !!(settings?.openRouterEnabled && settings?.openRouterApiKey), + deepseek: !!(settings?.deepSeekEnabled && settings?.deepSeekApiKey) || !!(settings?.openRouterEnabled && settings?.openRouterApiKey), + moonshot: !!(settings?.moonshotEnabled && settings?.moonshotApiKey) || !!(settings?.openRouterEnabled && settings?.openRouterApiKey), + mimo: !!(settings?.mimoEnabled && settings?.mimoApiKey) || !!(settings?.openRouterEnabled && settings?.openRouterApiKey), + qwen: !!(settings?.qwenEnabled && settings?.qwenApiKey) || !!(settings?.openRouterEnabled && settings?.openRouterApiKey), + zhipu: !!(settings?.zhipuEnabled && settings?.zhipuApiKey) || !!(settings?.openRouterEnabled && settings?.openRouterApiKey), + }; + setInstalledProviders(providers); }); // Fetch per-provider installed skills diff --git a/src/components/Settings/AIProvidersSection.tsx b/src/components/Settings/AIProvidersSection.tsx index d8235643..61631c5b 100644 --- a/src/components/Settings/AIProvidersSection.tsx +++ b/src/components/Settings/AIProvidersSection.tsx @@ -195,9 +195,9 @@ export const AIProvidersSection = ({ appSettings, onSaveAppSettings, onUpdateLoc routingNote="Provider: qwen — direct via https://dashscope.aliyuncs.com/compatible-mode/v1. Falls back to OpenRouter key if no Qwen key set." /> - {/* ZhipuAI GLM */} + {/* Zai GLM */} onUpdateLocalSettings({ zhipuApiKey: v })} onApiKeyBlur={() => onSaveAppSettings({ zhipuApiKey: appSettings.zhipuApiKey })} models={['zhipuai/glm-4.6', 'zhipuai/glm-4.5', 'zhipuai/glm-4-plus', 'zhipuai/glm-4-air', 'zhipuai/glm-4-flash']} - routingNote="Provider: zhipu — direct via https://open.bigmodel.cn/api/paas/v4/. Falls back to OpenRouter key if no Zhipu key set." + routingNote="Provider: zai — direct via https://open.bigmodel.cn/api/paas/v4/. Falls back to OpenRouter key if no Zai key set." /> {/* Routing note */} diff --git a/src/components/Settings/CLIPathsSection.tsx b/src/components/Settings/CLIPathsSection.tsx index be0c30fa..fe02786e 100644 --- a/src/components/Settings/CLIPathsSection.tsx +++ b/src/components/Settings/CLIPathsSection.tsx @@ -1,10 +1,12 @@ import { useState, useEffect } from 'react'; -import { RefreshCw, Check, AlertCircle, Plus, X, FolderOpen } from 'lucide-react'; +import { RefreshCw, Check, AlertCircle, Plus, X, FolderOpen, Cpu, Loader2, CheckCircle, XCircle } from 'lucide-react'; +import { Toggle } from './Toggle'; import type { AppSettings, CLIPaths } from './types'; interface CLIPathsSectionProps { appSettings: AppSettings; onSaveAppSettings: (settings: Partial) => void; + onUpdateLocalSettings?: (settings: Partial) => void; } interface DetectedPaths { @@ -19,10 +21,14 @@ interface DetectedPaths { node: string; } -export const CLIPathsSection = ({ appSettings, onSaveAppSettings }: CLIPathsSectionProps) => { +export const CLIPathsSection = ({ appSettings, onSaveAppSettings, onUpdateLocalSettings }: CLIPathsSectionProps) => { const [detecting, setDetecting] = useState(false); const [detectedPaths, setDetectedPaths] = useState(null); const [newPath, setNewPath] = useState(''); + const [testingOpencode, setTestingOpencode] = useState(false); + const [testingPi, setTestingPi] = useState(false); + const [opencodeResult, setOpencodeResult] = useState<{ success: boolean; message: string } | null>(null); + const [piResult, setPiResult] = useState<{ success: boolean; message: string } | null>(null); const [localPaths, setLocalPaths] = useState( appSettings.cliPaths || { claude: '', codex: '', gemini: '', opencode: '', pi: '', gws: '', gcloud: '', gh: '', node: '', additionalPaths: [] } ); @@ -173,6 +179,98 @@ export const CLIPathsSection = ({ appSettings, onSaveAppSettings }: CLIPathsSect )}
+ {/* Provider Toggles — OpenCode & Pi */} +
+

CLI Agent Providers

+

+ Enable additional CLI-based agent providers. Paths are configured below. +

+ +
+
+ +
+

OpenCode

+

75+ LLM providers via opencode.ai

+
+
+
+ + onSaveAppSettings({ opencodeEnabled: !appSettings.opencodeEnabled })} + /> +
+
+ {opencodeResult && ( +
+ {opencodeResult.success ? : } + {opencodeResult.message} +
+ )} + +
+
+ +
+

Pi Terminal

+

15+ AI providers via shittycodingagent.ai

+
+
+
+ + ).piEnabled === true} + onChange={() => { + const newVal = !((appSettings as unknown as Record).piEnabled === true); + onUpdateLocalSettings?.({ piEnabled: newVal } as Partial); + onSaveAppSettings({ piEnabled: newVal } as Partial); + }} + /> +
+
+ {piResult && ( +
+ {piResult.success ? : } + {piResult.message} +
+ )} +
+ {/* Path inputs */}

CLI Tool Paths

diff --git a/src/components/Settings/GeneralSection.tsx b/src/components/Settings/GeneralSection.tsx index a5f72b9b..d504eb86 100644 --- a/src/components/Settings/GeneralSection.tsx +++ b/src/components/Settings/GeneralSection.tsx @@ -45,12 +45,23 @@ export const GeneralSection = ({ info, appSettings, onSaveAppSettings }: General const [installedProviders, setInstalledProviders] = useState>({ claude: true, codex: true, gemini: true }); useEffect(() => { - window.electronAPI?.cliPaths?.detect().then((paths) => { - if (paths) { + Promise.all([ + window.electronAPI?.cliPaths?.detect(), + window.electronAPI?.appSettings?.get(), + ]).then(([paths, settings]) => { + if (paths || settings) { setInstalledProviders({ - claude: !!paths.claude, - codex: !!paths.codex, - gemini: !!paths.gemini, + claude: !!paths?.claude, + codex: !!paths?.codex, + gemini: !!paths?.gemini, + opencode: !!(paths as Record | undefined)?.opencode, + pi: !!(paths as Record | undefined)?.pi, + openrouter: !!(settings?.openRouterEnabled && settings?.openRouterApiKey), + deepseek: !!(settings?.deepSeekEnabled && settings?.deepSeekApiKey) || !!(settings?.openRouterEnabled && settings?.openRouterApiKey), + moonshot: !!(settings?.moonshotEnabled && settings?.moonshotApiKey) || !!(settings?.openRouterEnabled && settings?.openRouterApiKey), + mimo: !!(settings?.mimoEnabled && settings?.mimoApiKey) || !!(settings?.openRouterEnabled && settings?.openRouterApiKey), + qwen: !!(settings?.qwenEnabled && settings?.qwenApiKey) || !!(settings?.openRouterEnabled && settings?.openRouterApiKey), + zhipu: !!(settings?.zhipuEnabled && settings?.zhipuApiKey) || !!(settings?.openRouterEnabled && settings?.openRouterApiKey), }); } }); @@ -331,31 +342,20 @@ export const GeneralSection = ({ info, appSettings, onSaveAppSettings }: General className="w-full sm:w-64 px-3 py-2 bg-background border border-border text-sm text-foreground focus:outline-none focus:border-foreground" > {PROVIDER_REGISTRY.map(({ id, label, requiresCli }) => { - const notInstalled = requiresCli && installedProviders[id] === false; + const notAvailable = installedProviders[id] === false; + const reason = notAvailable + ? requiresCli ? ' (not installed)' : ' (add API key in Settings)' + : ''; return ( - ); })}
- {info && ( -
-

Quick Info

-
-
-

Claude Version

-

{info.claudeVersion || 'Not found'}

-
-
-

Platform

-

{info.platform} ({info.arch})

-
-
-
- )} + {/* Quick Info removed — duplicate of System section */} ); }; diff --git a/src/components/Settings/GitSection.tsx b/src/components/Settings/GitSection.tsx index c52377ff..6d960ff2 100644 --- a/src/components/Settings/GitSection.tsx +++ b/src/components/Settings/GitSection.tsx @@ -19,7 +19,7 @@ export const GitSection = ({ settings, onUpdateSettings }: GitSectionProps) => {

Include Co-Authored-By

-

Add Claude as co-author in git commits

+

Add the active AI provider as co-author in git commits

{

- When enabled, commits made with Claude's assistance will include a co-authored-by trailer. + When enabled, commits made with AI assistance will include a co-authored-by trailer using the active provider name.

diff --git a/src/components/Settings/McpSection.tsx b/src/components/Settings/McpSection.tsx index 18ca5036..887d9f75 100644 --- a/src/components/Settings/McpSection.tsx +++ b/src/components/Settings/McpSection.tsx @@ -56,7 +56,11 @@ function ProviderIcon({ provider, className }: { provider: Provider; className?: if (icon.type === 'cpu') { return ; } - return {icon.content}; + if (icon.type === 'text') { + return {icon.content}; + } + // SVG provider icons — show label abbreviation + return {label.slice(0, 2)}; } export function McpSection() { diff --git a/src/components/Settings/PermissionsSection.tsx b/src/components/Settings/PermissionsSection.tsx index 3e965a9c..c7d87275 100644 --- a/src/components/Settings/PermissionsSection.tsx +++ b/src/components/Settings/PermissionsSection.tsx @@ -9,7 +9,7 @@ export const PermissionsSection = ({ settings }: PermissionsSectionProps) => {

Permissions

-

Manage allowed and denied actions for Claude

+

Manage allowed and denied actions for all CLI agents

@@ -55,7 +55,8 @@ export const PermissionsSection = ({ settings }: PermissionsSectionProps) => {

- Permissions are managed through Claude Code CLI. Use claude config to modify. + Permissions are managed through the CLI. Use claude config to modify. + These settings apply to all providers that use the Claude binary for execution.

diff --git a/src/components/Settings/SkillsSection.tsx b/src/components/Settings/SkillsSection.tsx index d54f6c62..1064b789 100644 --- a/src/components/Settings/SkillsSection.tsx +++ b/src/components/Settings/SkillsSection.tsx @@ -15,7 +15,7 @@ export const SkillsSection = ({ skills }: SkillsSectionProps) => {

Skills & Plugins

-

Installed skills and plugins for Claude Code

+

Installed skills and plugins available to all CLI providers

Terminal

-

Configure terminal appearance for all views

+

Configure terminal appearance for all agent views (applies to Claude, Codex, Gemini, and all CLI providers)

{/* Theme */} diff --git a/src/components/Settings/constants.ts b/src/components/Settings/constants.ts index 4545846b..22422257 100644 --- a/src/components/Settings/constants.ts +++ b/src/components/Settings/constants.ts @@ -22,6 +22,8 @@ import type { SettingsSection } from './types'; export const SECTIONS: { id: SettingsSection; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ { id: 'general', label: 'General', icon: Settings }, { id: 'terminal', label: 'Terminal', icon: Terminal }, + { id: 'ai-providers', label: 'AI Providers', icon: Zap }, + { id: 'cli', label: 'CLI Paths', icon: Terminal }, { id: 'obsidian', label: 'Obsidian', icon: ObsidianIcon }, { id: 'git', label: 'Git', icon: GitCommit }, { id: 'notifications', label: 'Notifications', icon: Bell }, @@ -30,14 +32,10 @@ export const SECTIONS: { id: SettingsSection; label: string; icon: React.Compone { id: 'jira', label: 'JIRA', icon: JiraIcon }, { id: 'socialdata', label: 'X (Twitter)', icon: Twitter }, { id: 'tasmania', label: 'Tasmania', icon: TasmaniaIcon }, - { id: 'opencode', label: 'OpenCode', icon: Cpu }, - { id: 'pi', label: 'Pi Terminal', icon: Cpu }, { id: 'google-workspace', label: 'Google Workspace', icon: Cloud }, - { id: 'ai-providers', label: 'AI Providers', icon: Zap }, { id: 'permissions', label: 'Permissions', icon: Shield }, { id: 'skills', label: 'Skills & Plugins', icon: Sparkles }, { id: 'mcp', label: 'Custom MCP', icon: Plug }, - { id: 'cli', label: 'CLI Paths', icon: Terminal }, { id: 'system', label: 'System', icon: Monitor }, ]; diff --git a/src/components/Settings/index.ts b/src/components/Settings/index.ts index f1858fe3..cf5e081c 100644 --- a/src/components/Settings/index.ts +++ b/src/components/Settings/index.ts @@ -25,8 +25,6 @@ export { JiraSection } from './JiraSection'; export { SocialDataSection } from './SocialDataSection'; export { TasmaniaSection } from './TasmaniaSection'; export { TasmaniaIcon } from './TasmaniaIcon'; -export { OpenCodeSection } from './OpenCodeSection'; -export { PiTerminalSection } from './PiTerminalSection'; export { GoogleWorkspaceSection } from './GoogleWorkspaceSection'; export { AIProvidersSection } from './AIProvidersSection'; export { PermissionsSection } from './PermissionsSection'; diff --git a/src/components/Settings/types.ts b/src/components/Settings/types.ts index 92bc2333..3c106509 100644 --- a/src/components/Settings/types.ts +++ b/src/components/Settings/types.ts @@ -105,4 +105,4 @@ export interface AppSettings { defaultProjectPath?: string; } -export type SettingsSection = 'general' | 'terminal' | 'git' | 'notifications' | 'telegram' | 'slack' | 'jira' | 'socialdata' | 'tasmania' | 'opencode' | 'pi' | 'google-workspace' | 'obsidian' | 'ai-providers' | 'permissions' | 'skills' | 'mcp' | 'cli' | 'system'; +export type SettingsSection = 'general' | 'terminal' | 'git' | 'notifications' | 'telegram' | 'slack' | 'jira' | 'socialdata' | 'tasmania' | 'google-workspace' | 'obsidian' | 'ai-providers' | 'permissions' | 'skills' | 'mcp' | 'cli' | 'system'; diff --git a/src/hooks/useElectron.ts b/src/hooks/useElectron.ts index fe9f6091..614cc8af 100644 --- a/src/hooks/useElectron.ts +++ b/src/hooks/useElectron.ts @@ -293,7 +293,8 @@ export function useElectronFS() { try { const list = await window.electronAPI!.fs.listProjects(); - setProjects(list); + // Filter out worktree paths to avoid duplicate React keys + setProjects(list.filter((p: { path: string }) => !p.path.includes('/.worktrees/'))); } catch (error) { console.error('Failed to fetch projects:', error); } finally { diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 6fe7b3e7..fefc4e46 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -10,6 +10,12 @@ import type { AgentProvider } from '@/types/electron'; export type ProviderIconDef = | { type: 'image'; src: string } | { type: 'svg-gemini' } + | { type: 'svg-openrouter' } + | { type: 'svg-deepseek' } + | { type: 'svg-moonshot' } + | { type: 'svg-mimo' } + | { type: 'svg-qwen' } + | { type: 'svg-zai' } | { type: 'text'; content: string } | { type: 'cpu' }; @@ -118,7 +124,7 @@ export const PROVIDER_REGISTRY: ProviderDef[] = [ { id: 'openrouter', label: 'OpenRouter', - icon: { type: 'text', content: 'OR' }, + icon: { type: 'svg-openrouter' }, accent: 'amber-500', badgeClass: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', models: [ @@ -134,7 +140,7 @@ export const PROVIDER_REGISTRY: ProviderDef[] = [ { id: 'deepseek', label: 'DeepSeek', - icon: { type: 'text', content: 'DS' }, + icon: { type: 'svg-deepseek' }, accent: 'sky-500', badgeClass: 'bg-sky-500/15 text-sky-600 dark:text-sky-400', models: [ @@ -147,7 +153,7 @@ export const PROVIDER_REGISTRY: ProviderDef[] = [ { id: 'moonshot', label: 'Moonshot', - icon: { type: 'text', content: '🌙' }, + icon: { type: 'svg-moonshot' }, accent: 'violet-500', badgeClass: 'bg-violet-500/15 text-violet-600 dark:text-violet-400', models: [ @@ -160,7 +166,7 @@ export const PROVIDER_REGISTRY: ProviderDef[] = [ { id: 'mimo', label: 'MiMo', - icon: { type: 'text', content: 'MM' }, + icon: { type: 'svg-mimo' }, accent: 'orange-500', badgeClass: 'bg-orange-500/15 text-orange-600 dark:text-orange-400', models: [ @@ -173,7 +179,7 @@ export const PROVIDER_REGISTRY: ProviderDef[] = [ { id: 'qwen', label: 'Qwen', - icon: { type: 'text', content: 'QW' }, + icon: { type: 'svg-qwen' }, accent: 'blue-500', badgeClass: 'bg-blue-500/15 text-blue-600 dark:text-blue-400', models: [ @@ -186,8 +192,8 @@ export const PROVIDER_REGISTRY: ProviderDef[] = [ }, { id: 'zhipu', - label: 'ZhipuAI', - icon: { type: 'text', content: 'ZH' }, + label: 'Zai', + icon: { type: 'svg-zai' }, accent: 'indigo-500', badgeClass: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400', models: [ diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index d09a30e7..69028692 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -349,6 +349,20 @@ export interface ElectronAPI { tasmaniaEnabled: boolean; tasmaniaServerPath: string; defaultProvider?: string; + opencodeEnabled?: boolean; + opencodeDefaultModel?: string; + openRouterEnabled?: boolean; + openRouterApiKey?: string; + deepSeekEnabled?: boolean; + deepSeekApiKey?: string; + mimoEnabled?: boolean; + mimoApiKey?: string; + moonshotEnabled?: boolean; + moonshotApiKey?: string; + qwenEnabled?: boolean; + qwenApiKey?: string; + zhipuEnabled?: boolean; + zhipuApiKey?: string; notificationSounds?: { waiting?: string; complete?: string;