diff --git a/README.md b/README.md index e25d1936..5aee5c5e 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,63 @@ Optional overrides: - `SMC_CREDENTIALS_PATH` โ€” custom path for `credentials.json5` - `SMC_SKILLS_ENV_PATH` โ€” custom path for `skills.env.json5` +### LLM Providers + +Super Multica supports multiple LLM providers with two authentication methods: + +**OAuth Providers** (use external CLI login): +- `claude-code` โ€” Claude Code OAuth (requires `claude login`) +- `openai-codex` โ€” OpenAI Codex OAuth (requires `codex login`) + +**API Key Providers** (configure in `credentials.json5`): +- `anthropic`, `openai`, `kimi-coding`, `google`, `groq`, `mistral`, `xai`, `openrouter` + +#### Check Provider Status + +```bash +# In interactive mode +/provider + +# Output shows all providers with status +๐Ÿ”Œ Provider Status + +Current: kimi-coding + +Available Providers: + ID Name Auth Status + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + โœ“ claude-code Claude Code (OAuth) OAuth ready + โœ— openai-codex Codex (OAuth) OAuth not logged in + โœ“ kimi-coding Kimi Code API Key configured (current) + ... +``` + +#### Using OAuth Providers + +```bash +# 1. Install and login to Claude Code +npm install -g @anthropic-ai/claude-code +claude login + +# 2. Start multica with claude-code provider +multica --provider claude-code +``` + +#### Using API Key Providers + +Add your API key to `~/.super-multica/credentials.json5`: + +```json5 +{ + llm: { + provider: "openai", + providers: { + openai: { apiKey: "sk-xxx" } + } + } +} +``` + ### Configuration Priority Each setting is resolved in order (first match wins): diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index 4108b33e..4cec9104 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -9,7 +9,9 @@ lerna-debug.log* node_modules dist +dist-electron dist-ssr +release *.local # Editor directories and files diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f02aedf8..d5bcf73d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,30 +1,22 @@ -# React + TypeScript + Vite +# @multica/desktop -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Electron desktop app. Vite + React + `createHashRouter`. -Currently, two official plugins are available: +## Development -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -} +```bash +multica dev desktop ``` -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list +## Build + +```bash +pnpm --filter @multica/desktop build +``` + +## Conventions + +- **Routing**: `react-router-dom` v7 with `createHashRouter` (Electron loads via `file://`, BrowserRouter won't work). Pages go in `src/pages/`. +- **UI**: All components from `@multica/ui`. No local UI components. +- **State**: Store hooks from `@multica/store`. +- **Styles**: Tailwind CSS v4 via `@multica/ui/globals.css`, imported in `src/main.tsx`. diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 302852f4..94948866 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -4,18 +4,8 @@ import path from 'node:path' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -// The built directory structure -// -// โ”œโ”€โ”ฌโ”€โ”ฌ dist -// โ”‚ โ”‚ โ””โ”€โ”€ index.html -// โ”‚ โ”‚ -// โ”‚ โ”œโ”€โ”ฌ dist-electron -// โ”‚ โ”‚ โ”œโ”€โ”€ main.js -// โ”‚ โ”‚ โ””โ”€โ”€ preload.mjs -// โ”‚ process.env.APP_ROOT = path.join(__dirname, '..') -// ๐Ÿšง Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron') export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist') @@ -26,17 +16,11 @@ let win: BrowserWindow | null function createWindow() { win = new BrowserWindow({ - icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'), webPreferences: { preload: path.join(__dirname, 'preload.mjs'), }, }) - // Test active push message to Renderer-process. - win.webContents.on('did-finish-load', () => { - win?.webContents.send('main-process-message', (new Date).toLocaleString()) - }) - if (VITE_DEV_SERVER_URL) { win.loadURL(VITE_DEV_SERVER_URL) } else { @@ -45,9 +29,6 @@ function createWindow() { } } -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() @@ -56,8 +37,6 @@ app.on('window-all-closed', () => { }) app.on('activate', () => { - // On OS X it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) { createWindow() } diff --git a/apps/desktop/index.html b/apps/desktop/index.html index 1136ddeb..d17771da 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -2,9 +2,8 @@ - - Vite + React + TS + Multica
diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8eb3132b..f98f7e98 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,7 +12,8 @@ "dependencies": { "@multica/ui": "workspace:*", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "react-router-dom": "^7.13.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", diff --git a/apps/desktop/public/electron-vite.animate.svg b/apps/desktop/public/electron-vite.animate.svg deleted file mode 100644 index ea3e7770..00000000 --- a/apps/desktop/public/electron-vite.animate.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/desktop/public/electron-vite.svg b/apps/desktop/public/electron-vite.svg deleted file mode 100644 index 8a6aefe6..00000000 --- a/apps/desktop/public/electron-vite.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/desktop/public/vite.svg b/apps/desktop/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/apps/desktop/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index fc7cead5..bd26458b 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,7 +1,12 @@ -import { ComponentExample } from '@multica/ui/components/component-example' +import { createHashRouter, RouterProvider } from 'react-router-dom' +import HomePage from './pages/home' +import ChatPage from './pages/chat' -function App() { - return +const router = createHashRouter([ + { path: '/', element: }, + { path: '/chat', element: }, +]) + +export default function App() { + return } - -export default App diff --git a/apps/desktop/src/assets/react.svg b/apps/desktop/src/assets/react.svg deleted file mode 100644 index 6c87de9b..00000000 --- a/apps/desktop/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index ad3387ad..a4e610e1 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -8,8 +8,3 @@ ReactDOM.createRoot(document.getElementById('root')!).render( , ) - -// Use contextBridge -window.ipcRenderer.on('main-process-message', (_event, message) => { - console.log(message) -}) diff --git a/apps/desktop/src/pages/chat.tsx b/apps/desktop/src/pages/chat.tsx new file mode 100644 index 00000000..f3495285 --- /dev/null +++ b/apps/desktop/src/pages/chat.tsx @@ -0,0 +1,15 @@ +import { useNavigate } from 'react-router-dom' +import { Button } from '@multica/ui/components/ui/button' + +export default function ChatPage() { + const navigate = useNavigate() + + return ( +
+

Chat

+ +
+ ) +} diff --git a/apps/desktop/src/pages/home.tsx b/apps/desktop/src/pages/home.tsx new file mode 100644 index 00000000..c8042511 --- /dev/null +++ b/apps/desktop/src/pages/home.tsx @@ -0,0 +1,12 @@ +import { useNavigate } from 'react-router-dom' +import { Button } from '@multica/ui/components/ui/button' + +export default function HomePage() { + const navigate = useNavigate() + + return ( +
+ +
+ ) +} diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index d39cc5b0..2f6ab2e3 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -27,4 +27,4 @@ export { type UpdateGatewayResult, } from "./rpc"; -export { StreamAction, type StreamPayload } from "./stream"; +export { StreamAction, type StreamState, type StreamPayload } from "./stream"; diff --git a/packages/sdk/src/actions/stream.ts b/packages/sdk/src/actions/stream.ts index 98f52423..da8dae31 100644 --- a/packages/sdk/src/actions/stream.ts +++ b/packages/sdk/src/actions/stream.ts @@ -2,10 +2,19 @@ export const StreamAction = "stream" as const; +/** ๆตๆถˆๆฏ็Šถๆ€ */ +export type StreamState = "delta" | "final" | "error"; + /** ๆตๆถˆๆฏ payload */ -export interface StreamPayload { - /** ๆต ID๏ผŒ็”จไบŽๅ…ณ่”ๅŒไธ€ไธชๆต็š„ๆ‰€ๆœ‰ๆถˆๆฏ */ +export interface StreamPayload { + /** ๆต ID๏ผˆๅณ messageId๏ผ‰๏ผŒๅ…ณ่”ๅŒไธ€ไธชๆต็š„ๆ‰€ๆœ‰ๆถˆๆฏ */ streamId: string; - /** ๆ•ฐๆฎ */ - data: T; + /** ๆ‰€ๅฑž agent ID */ + agentId: string; + /** ๆต็Šถๆ€ */ + state: StreamState; + /** ็ดฏ่ฎกๆ–‡ๆœฌๅ†…ๅฎน๏ผˆdelta/final ๆ—ถ๏ผ‰ */ + content?: string; + /** ้”™่ฏฏไฟกๆฏ๏ผˆerror ๆ—ถ๏ผ‰ */ + error?: string; } diff --git a/packages/store/README.md b/packages/store/README.md new file mode 100644 index 00000000..38e02ee4 --- /dev/null +++ b/packages/store/README.md @@ -0,0 +1,17 @@ +# @multica/store + +Zustand state management for Multica apps. + +## Usage + +```tsx +// From barrel +import { useHubStore, useMessagesStore, useGatewayStore } from '@multica/store' + +// Per-file subpath import +import { useGatewayStore } from '@multica/store/gateway' +import { useHubStore } from '@multica/store/hub' +import { useMessagesStore } from '@multica/store/messages' +import { useHubInit } from '@multica/store/hub-init' +import { useDeviceId } from '@multica/store/device-id' +``` diff --git a/packages/store/src/gateway.ts b/packages/store/src/gateway.ts index fb70f6c0..5682b70a 100644 --- a/packages/store/src/gateway.ts +++ b/packages/store/src/gateway.ts @@ -1,5 +1,5 @@ import { create } from "zustand" -import { GatewayClient, type ConnectionState, type DeviceInfo, type SendErrorResponse } from "@multica/sdk" +import { GatewayClient, StreamAction, type ConnectionState, type DeviceInfo, type SendErrorResponse, type StreamPayload } from "@multica/sdk" import { useMessagesStore } from "./messages" const DEFAULT_GATEWAY_URL = "http://localhost:3000" @@ -45,6 +45,32 @@ export const useGatewayStore = create()((set, get) => ({ }) .onStateChange((connectionState) => set({ connectionState })) .onMessage((msg) => { + // Handle streaming messages + if (msg.action === StreamAction) { + const payload = msg.payload as StreamPayload + const store = useMessagesStore.getState() + switch (payload.state) { + case "delta": { + const exists = store.messages.some((m) => m.id === payload.streamId) + if (!exists) { + store.startStream(payload.streamId, payload.agentId) + } + if (payload.content) { + store.appendStream(payload.streamId, payload.content) + } + break + } + case "final": + store.endStream(payload.streamId, payload.content ?? "") + break + case "error": + store.endStream(payload.streamId, `[error] ${payload.error}`) + break + } + return + } + + // Fallback: complete message handling const payload = msg.payload as { agentId?: string; content?: string } if (payload?.agentId && payload?.content) { useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId) diff --git a/packages/store/src/messages.ts b/packages/store/src/messages.ts index f2df5f2e..a25625d9 100644 --- a/packages/store/src/messages.ts +++ b/packages/store/src/messages.ts @@ -10,6 +10,7 @@ export interface Message { interface MessagesState { messages: Message[] + streamingIds: Set } interface MessagesActions { @@ -18,12 +19,16 @@ interface MessagesActions { updateMessage: (id: string, content: string) => void loadMessages: (agentId: string, msgs: Message[]) => void clearMessages: (agentId?: string) => void + startStream: (streamId: string, agentId: string) => void + appendStream: (streamId: string, content: string) => void + endStream: (streamId: string, content: string) => void } export type MessagesStore = MessagesState & MessagesActions export const useMessagesStore = create()((set, get) => ({ messages: [], + streamingIds: new Set(), addUserMessage: (content, agentId) => { set((s) => ({ @@ -54,4 +59,32 @@ export const useMessagesStore = create()((set, get) => ({ messages: agentId ? s.messages.filter((m) => m.agentId !== agentId) : [], })) }, + + startStream: (streamId, agentId) => { + set((s) => { + const ids = new Set(s.streamingIds) + ids.add(streamId) + return { + messages: [...s.messages, { id: streamId, role: "assistant" as const, content: "", agentId }], + streamingIds: ids, + } + }) + }, + + appendStream: (streamId, content) => { + set((s) => ({ + messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)), + })) + }, + + endStream: (streamId, content) => { + set((s) => { + const ids = new Set(s.streamingIds) + ids.delete(streamId) + return { + messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)), + streamingIds: ids, + } + }) + }, })) diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 00000000..e61096c6 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,32 @@ +# @multica/ui + +Shared UI component library. Shadcn + Tailwind CSS v4. + +## Usage + +```tsx +// UI components โ€” subpath imports, no barrel +import { Button } from '@multica/ui/components/ui/button' +import { Card, CardContent } from '@multica/ui/components/ui/card' + +// Feature components +import { ThemeProvider } from '@multica/ui/components/theme-provider' +import { Chat } from '@multica/ui/components/chat' +import { Markdown } from '@multica/ui/components/markdown' + +// Hooks +import { useIsMobile } from '@multica/ui/hooks/use-mobile' +import { useAutoScroll } from '@multica/ui/hooks/use-auto-scroll' + +// Utilities +import { cn } from '@multica/ui/lib/utils' + +// Styles (app entry point) +import '@multica/ui/globals.css' +``` + +## Adding Components + +```bash +pnpm --filter @multica/ui dlx shadcn@latest add +``` diff --git a/packages/ui/src/components/chat.tsx b/packages/ui/src/components/chat.tsx index 53476365..4363c7c0 100644 --- a/packages/ui/src/components/chat.tsx +++ b/packages/ui/src/components/chat.tsx @@ -6,6 +6,7 @@ import { Badge } from "@multica/ui/components/ui/badge"; import { Button } from "@multica/ui/components/ui/button"; import { ChatInput } from "@multica/ui/components/chat-input"; import { MemoizedMarkdown } from "@multica/ui/components/markdown"; +import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown"; import { HugeiconsIcon } from "@hugeicons/react"; import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons"; import { toast } from "@multica/ui/components/ui/sonner"; @@ -27,6 +28,7 @@ export function Chat() { const gwState = useGatewayStore((s) => s.connectionState) const messages = useMessagesStore((s) => s.messages) + const streamingIds = useMessagesStore((s) => s.streamingIds) const filtered = useMemo(() => messages.filter(m => m.agentId === activeAgentId), [messages, activeAgentId]) const handleSend = useCallback((text: string) => { @@ -99,25 +101,32 @@ export function Chat() { ) : (
- {filtered.map((msg) => ( -
+ {filtered.map((msg) => { + const isStreaming = streamingIds.has(msg.id) + return (
- - {msg.content} - +
+ {isStreaming ? ( + + ) : ( + + {msg.content} + + )} +
-
- ))} + ) + })}
)} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fc53daf..a71a43c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: react-dom: specifier: 'catalog:' version: 19.2.3(react@19.2.3) + react-router-dom: + specifier: ^7.13.0 + version: 7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) devDependencies: '@tailwindcss/vite': specifier: ^4.1.18 @@ -5282,6 +5285,23 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-router-dom@7.13.0: + resolution: {integrity: sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.13.0: + resolution: {integrity: sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -5485,6 +5505,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -10008,7 +10031,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10041,7 +10064,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -10056,7 +10079,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -12368,6 +12391,20 @@ snapshots: react-refresh@0.17.0: {} + react-router-dom@7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-router: 7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + + react-router@7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + cookie: 1.1.1 + react: 19.2.3 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + react@19.2.3: {} read-config-file@6.3.2: @@ -12663,6 +12700,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 diff --git a/src/agent/cli/commands/chat.ts b/src/agent/cli/commands/chat.ts index b9eaf7d8..e083175c 100644 --- a/src/agent/cli/commands/chat.ts +++ b/src/agent/cli/commands/chat.ts @@ -11,7 +11,14 @@ import { Agent } from "../../runner.js"; import type { AgentOptions } from "../../types.js"; import { SkillManager } from "../../skills/index.js"; import { autocompleteInput, type AutocompleteOption } from "../autocomplete.js"; -import { colors, dim, cyan, brightCyan, yellow, green, gray } from "../colors.js"; +import { colors, dim, cyan, brightCyan, yellow, green, gray, red } from "../colors.js"; +import { + getProviderList, + getCurrentProvider, + getLoginInstructions, + getProviderMeta, + type ProviderInfo, +} from "../../providers/index.js"; type ChatOptions = { profile?: string; @@ -31,6 +38,8 @@ const COMMANDS = { session: "Show current session ID", new: "Start a new session", multiline: "Toggle multi-line input mode (end with a line containing only '.')", + provider: "Show current provider and available options", + model: "Show or switch model (usage: /model [model-name])", }; function printHelp() { @@ -455,6 +464,14 @@ class InteractiveCLI { } return true; + case "provider": + this.showProviderStatus(); + return true; + + case "model": + this.handleModelCommand(input); + return true; + default: const invocation = this.skillManager.resolveCommand(input); if (invocation) { @@ -468,6 +485,126 @@ class InteractiveCLI { } } + private handleModelCommand(input: string) { + const parts = input.trim().split(/\s+/); + const modelArg = parts.slice(1).join(" ").trim(); + const currentProvider = this.opts.provider ?? getCurrentProvider(); + const providerMeta = getProviderMeta(currentProvider); + + if (!providerMeta) { + console.log(`${red("Error:")} Unknown provider: ${currentProvider}\n`); + return; + } + + // No argument - show current model and available models + if (!modelArg) { + console.log(`\n${cyan("๐ŸŽฏ Model Status")}\n`); + console.log(`${dim("Provider:")} ${green(currentProvider)}`); + console.log(`${dim("Current model:")} ${yellow(this.opts.model ?? providerMeta.defaultModel)}`); + console.log(`${dim("Default model:")} ${gray(providerMeta.defaultModel)}`); + + console.log(`\n${dim("Available models for")} ${green(currentProvider)}${dim(":")}`); + for (const model of providerMeta.models) { + const isCurrent = model === (this.opts.model ?? providerMeta.defaultModel); + const marker = isCurrent ? yellow(" (current)") : ""; + const modelDisplay = isCurrent ? yellow(model) : model; + console.log(` โ€ข ${modelDisplay}${marker}`); + } + + console.log(`\n${dim("Switch model:")} ${yellow(`/model `)}`); + console.log(`${dim("Example:")} ${yellow(`/model ${providerMeta.models[0]}`)}`); + console.log(""); + return; + } + + // Check if model is valid for current provider + const normalizedModel = modelArg.toLowerCase(); + const matchedModel = providerMeta.models.find( + (m) => m.toLowerCase() === normalizedModel + ); + + if (!matchedModel) { + console.log(`${red("Error:")} Model "${modelArg}" is not available for provider "${currentProvider}".`); + console.log(`\n${dim("Available models:")}`); + for (const model of providerMeta.models) { + console.log(` โ€ข ${model}`); + } + console.log(""); + return; + } + + // Switch model + const oldModel = this.opts.model ?? providerMeta.defaultModel; + this.opts.model = matchedModel; + + // Recreate agent with new model + this.agent = this.createAgent(this.agent.sessionId); + this.updateStatusBar(); + + console.log(`${green("โœ“")} Model switched: ${gray(oldModel)} โ†’ ${yellow(matchedModel)}`); + console.log(`${dim("Session preserved:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`); + } + + private showProviderStatus() { + const providers = getProviderList(); + const currentProvider = this.opts.provider ?? getCurrentProvider(); + + console.log(`\n${cyan("๐Ÿ”Œ Provider Status")}\n`); + console.log(`${dim("Current:")} ${green(currentProvider)}`); + if (this.opts.model) { + console.log(`${dim("Model:")} ${yellow(this.opts.model)}`); + } + + console.log(`\n${dim("Available Providers:")}`); + console.log(` ${dim("ID".padEnd(16))} ${dim("Name".padEnd(20))} ${dim("Auth".padEnd(12))} ${dim("Status")}`); + console.log(` ${dim("โ”€".repeat(70))}`); + + // Group by auth method + const apiKeyProviders = providers.filter(p => p.authMethod === "api-key"); + const oauthProviders = providers.filter(p => p.authMethod === "oauth"); + + // OAuth providers first (more interesting) + for (const p of oauthProviders) { + const status = p.available ? green("โœ“") : red("โœ—"); + const isCurrent = p.id === currentProvider || (p.id === "claude-code" && currentProvider === "anthropic" && p.available); + const current = isCurrent ? yellow(" (current)") : ""; + const idDisplay = isCurrent ? yellow(p.id.padEnd(16)) : p.id.padEnd(16); + const authLabel = cyan("OAuth"); + const statusLabel = p.available ? green("ready") : dim("not logged in"); + console.log(` ${status} ${idDisplay} ${p.name.padEnd(20)} ${authLabel.padEnd(12)} ${statusLabel}${current}`); + } + + // API Key providers + for (const p of apiKeyProviders) { + const status = p.available ? green("โœ“") : red("โœ—"); + const isCurrent = p.id === currentProvider; + const current = isCurrent ? yellow(" (current)") : ""; + const idDisplay = isCurrent ? yellow(p.id.padEnd(16)) : p.id.padEnd(16); + const authLabel = dim("API Key"); + const statusLabel = p.available ? green("configured") : dim("not configured"); + console.log(` ${status} ${idDisplay} ${p.name.padEnd(20)} ${authLabel.padEnd(12)} ${statusLabel}${current}`); + } + + console.log(`\n${dim("Usage:")}`); + console.log(` ${yellow("multica --provider ")} ${dim("Start chat with specific provider")}`); + console.log(` ${yellow("multica --provider --model ")} ${dim("Specify model too")}`); + + console.log(`\n${dim("Examples:")}`); + console.log(` ${yellow("multica --provider claude-code")} ${dim("Use Claude Code OAuth")}`); + console.log(` ${yellow("multica --provider openai")} ${dim("Use OpenAI with API Key")}`); + + // If user hasn't logged into Claude Code, show instructions + const claudeCode = providers.find(p => p.id === "claude-code"); + if (claudeCode && !claudeCode.available) { + console.log(`\n${cyan("๐Ÿ’ก Tip:")} To use Claude Code (free with Claude subscription):`); + console.log(` 1. Install: ${yellow("npm install -g @anthropic-ai/claude-code")}`); + console.log(` 2. Login: ${yellow("claude login")}`); + console.log(` 3. Use: ${yellow("multica --provider claude-code")}`); + } + + console.log(""); + } + private async handleInput(input: string) { try { console.log(""); diff --git a/src/agent/cli/output.test.ts b/src/agent/cli/output.test.ts index 3a6f6030..f292dd0e 100644 --- a/src/agent/cli/output.test.ts +++ b/src/agent/cli/output.test.ts @@ -112,6 +112,14 @@ describe("output", () => { expect(extractResultDetails(result)).toEqual(result); }); + it("should prefer details when present", () => { + const result = { + content: [{ type: "text", text: "not json" }], + details: { count: 3, truncated: false }, + }; + expect(extractResultDetails(result)).toEqual({ count: 3, truncated: false }); + }); + it("should return direct object if no content array", () => { const result = { count: 10, truncated: true }; expect(extractResultDetails(result)).toEqual({ count: 10, truncated: true }); diff --git a/src/agent/cli/output.ts b/src/agent/cli/output.ts index 3c6c9835..b2f77cfa 100644 --- a/src/agent/cli/output.ts +++ b/src/agent/cli/output.ts @@ -1,5 +1,6 @@ import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core"; import { colors, createSpinner } from "./colors.js"; +import { extractText } from "../extract-text.js"; export type AgentOutputState = { lastAssistantText: string; @@ -12,16 +13,6 @@ export type AgentOutput = { handleEvent: (event: AgentEvent) => void; }; -function extractText(message: AgentMessage | undefined): string { - if (!message || typeof message !== "object" || !("content" in message)) return ""; - const content = (message as { content?: Array<{ type: string; text?: string }> }).content; - if (!Array.isArray(content)) return ""; - return content - .filter((c) => c.type === "text") - .map((c) => c.text ?? "") - .join(""); -} - function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max) + "โ€ฆ" : s; } @@ -118,6 +109,11 @@ export function extractResultDetails(result: unknown): Record | } } + const withDetails = result as { details?: unknown }; + if (withDetails.details && typeof withDetails.details === "object") { + return withDetails.details as Record; + } + // Try direct object access return result as Record; } @@ -252,8 +248,18 @@ export function createAgentOutput(params: { } case "tool_execution_end": { // Stop spinner and show final result with summary - if (event.isError) { - const errorText = extractText(event.result) || "Tool failed"; + const details = extractResultDetails(event.result); + const errorField = details?.error; + const hasError = + event.isError || + Boolean(errorField) || + details?.success === false; + if (hasError) { + const errorText = + (typeof details?.message === "string" && details.message) || + (typeof errorField === "string" && errorField) || + extractText(event.result) || + "Tool failed"; const bullet = colors.toolError("โœ—"); const title = colors.toolName(toolDisplayName(event.toolName)); spinner.stop(`${bullet} ${title}: ${colors.toolError(errorText)}`); diff --git a/src/agent/extract-text.ts b/src/agent/extract-text.ts new file mode 100644 index 00000000..145a2c97 --- /dev/null +++ b/src/agent/extract-text.ts @@ -0,0 +1,12 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; + +/** Extract plain text content from an AgentMessage */ +export function extractText(message: AgentMessage | undefined): string { + if (!message || typeof message !== "object" || !("content" in message)) return ""; + const content = (message as { content?: Array<{ type: string; text?: string }> }).content; + if (!Array.isArray(content)) return ""; + return content + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join(""); +} diff --git a/src/agent/providers/index.ts b/src/agent/providers/index.ts new file mode 100644 index 00000000..25108916 --- /dev/null +++ b/src/agent/providers/index.ts @@ -0,0 +1,34 @@ +/** + * Provider Management + * + * Unified exports for LLM provider management: + * - Registry: Provider metadata, status checking, listing + * - Resolver: API key resolution, model resolution + */ + +// Registry exports +export { + type AuthMethod, + type ProviderInfo, + type ProviderMeta, + PROVIDER_ALIAS, + isOAuthProvider, + isProviderAvailable, + getCurrentProvider, + getProviderMeta, + getDefaultModel, + getProviderList, + getAvailableProviders, + formatProviderStatus, + getLoginInstructions, +} from "./registry.js"; + +// Resolver exports +export { + type ProviderConfig, + resolveProviderConfig, + resolveApiKey, + resolveBaseUrl, + resolveModelId, + resolveModel, +} from "./resolver.js"; diff --git a/src/agent/providers/oauth/cli-credentials.ts b/src/agent/providers/oauth/cli-credentials.ts new file mode 100644 index 00000000..2d1da4e0 --- /dev/null +++ b/src/agent/providers/oauth/cli-credentials.ts @@ -0,0 +1,363 @@ +/** + * CLI Credentials Reader + * + * Read OAuth credentials from external CLI tools: + * - Claude Code: ~/.claude/.credentials.json or macOS Keychain + * - Codex: ~/.codex/auth.json or macOS Keychain + * + * Based on OpenClaw's implementation. + */ + +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +// ============================================================ +// Types +// ============================================================ + +export type OAuthCredential = { + type: "oauth"; + provider: string; + access: string; + refresh: string; + expires: number; +}; + +export type TokenCredential = { + type: "token"; + provider: string; + token: string; + expires: number; +}; + +export type ClaudeCliCredential = (OAuthCredential | TokenCredential) & { + provider: "anthropic"; +}; + +export type CodexCliCredential = OAuthCredential & { + provider: "openai-codex"; + accountId?: string; +}; + +// ============================================================ +// Paths +// ============================================================ + +const CLAUDE_CLI_CREDENTIALS_PATH = ".claude/.credentials.json"; +const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials"; +const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code"; + +const CODEX_CLI_AUTH_FILENAME = "auth.json"; +const CODEX_CLI_KEYCHAIN_SERVICE = "Codex Auth"; + +function resolveHomePath(relativePath: string): string { + const home = os.homedir(); + return path.join(home, relativePath); +} + +function resolveCodexHomePath(): string { + const configured = process.env.CODEX_HOME; + const home = configured ? configured.replace(/^~/, os.homedir()) : resolveHomePath(".codex"); + try { + return fs.realpathSync(home); + } catch { + return home; + } +} + +function computeCodexKeychainAccount(codexHome: string): string { + const hash = createHash("sha256").update(codexHome).digest("hex"); + return `cli|${hash.slice(0, 16)}`; +} + +// ============================================================ +// Claude Code Credentials +// ============================================================ + +function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null { + if (process.platform !== "darwin") return null; + + try { + const result = execSync( + `security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`, + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ); + + const data = JSON.parse(result.trim()); + const claudeOauth = data?.claudeAiOauth; + if (!claudeOauth || typeof claudeOauth !== "object") return null; + + const accessToken = claudeOauth.accessToken; + const refreshToken = claudeOauth.refreshToken; + const expiresAt = claudeOauth.expiresAt; + + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof expiresAt !== "number" || expiresAt <= 0) return null; + + if (typeof refreshToken === "string" && refreshToken) { + return { + type: "oauth", + provider: "anthropic", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; + } + + return { + type: "token", + provider: "anthropic", + token: accessToken, + expires: expiresAt, + }; + } catch { + return null; + } +} + +function readClaudeCliFileCredentials(): ClaudeCliCredential | null { + const credPath = resolveHomePath(CLAUDE_CLI_CREDENTIALS_PATH); + + try { + if (!fs.existsSync(credPath)) return null; + const raw = JSON.parse(fs.readFileSync(credPath, "utf8")); + if (!raw || typeof raw !== "object") return null; + + const claudeOauth = raw.claudeAiOauth; + if (!claudeOauth || typeof claudeOauth !== "object") return null; + + const accessToken = claudeOauth.accessToken; + const refreshToken = claudeOauth.refreshToken; + const expiresAt = claudeOauth.expiresAt; + + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof expiresAt !== "number" || expiresAt <= 0) return null; + + if (typeof refreshToken === "string" && refreshToken) { + return { + type: "oauth", + provider: "anthropic", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; + } + + return { + type: "token", + provider: "anthropic", + token: accessToken, + expires: expiresAt, + }; + } catch { + return null; + } +} + +/** + * Read Claude Code CLI credentials. + * Priority: macOS Keychain > File (~/.claude/.credentials.json) + */ +export function readClaudeCliCredentials(): ClaudeCliCredential | null { + // Try keychain first (macOS only) + const keychainCreds = readClaudeCliKeychainCredentials(); + if (keychainCreds) return keychainCreds; + + // Fall back to file + return readClaudeCliFileCredentials(); +} + +/** + * Check if Claude Code credentials exist and are valid. + */ +export function hasValidClaudeCliCredentials(): boolean { + const creds = readClaudeCliCredentials(); + if (!creds) return false; + // Check if not expired (with 5 minute buffer) + return creds.expires > Date.now() + 5 * 60 * 1000; +} + +/** + * Get the access token from Claude Code credentials. + */ +export function getClaudeCliAccessToken(): string | null { + const creds = readClaudeCliCredentials(); + if (!creds) return null; + if (creds.type === "oauth") return creds.access; + if (creds.type === "token") return creds.token; + return null; +} + +// ============================================================ +// Codex CLI Credentials +// ============================================================ + +function readCodexKeychainCredentials(): CodexCliCredential | null { + if (process.platform !== "darwin") return null; + + const codexHome = resolveCodexHomePath(); + const account = computeCodexKeychainAccount(codexHome); + + try { + const secret = execSync( + `security find-generic-password -s "${CODEX_CLI_KEYCHAIN_SERVICE}" -a "${account}" -w`, + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ).trim(); + + const parsed = JSON.parse(secret); + const tokens = parsed.tokens; + const accessToken = tokens?.access_token; + const refreshToken = tokens?.refresh_token; + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof refreshToken !== "string" || !refreshToken) return null; + + const lastRefreshRaw = parsed.last_refresh; + const lastRefresh = + typeof lastRefreshRaw === "string" || typeof lastRefreshRaw === "number" + ? new Date(lastRefreshRaw).getTime() + : Date.now(); + const expires = Number.isFinite(lastRefresh) + ? lastRefresh + 60 * 60 * 1000 + : Date.now() + 60 * 60 * 1000; + + return { + type: "oauth", + provider: "openai-codex", + access: accessToken, + refresh: refreshToken, + expires, + accountId: typeof tokens?.account_id === "string" ? tokens.account_id : undefined, + }; + } catch { + return null; + } +} + +function readCodexFileCredentials(): CodexCliCredential | null { + const authPath = path.join(resolveCodexHomePath(), CODEX_CLI_AUTH_FILENAME); + + try { + if (!fs.existsSync(authPath)) return null; + const raw = JSON.parse(fs.readFileSync(authPath, "utf8")); + if (!raw || typeof raw !== "object") return null; + + const tokens = raw.tokens; + if (!tokens || typeof tokens !== "object") return null; + + const accessToken = tokens.access_token; + const refreshToken = tokens.refresh_token; + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof refreshToken !== "string" || !refreshToken) return null; + + let expires: number; + try { + const stat = fs.statSync(authPath); + expires = stat.mtimeMs + 60 * 60 * 1000; + } catch { + expires = Date.now() + 60 * 60 * 1000; + } + + return { + type: "oauth", + provider: "openai-codex", + access: accessToken, + refresh: refreshToken, + expires, + accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined, + }; + } catch { + return null; + } +} + +/** + * Read Codex CLI credentials. + * Priority: macOS Keychain > File (~/.codex/auth.json) + */ +export function readCodexCliCredentials(): CodexCliCredential | null { + // Try keychain first (macOS only) + const keychainCreds = readCodexKeychainCredentials(); + if (keychainCreds) return keychainCreds; + + // Fall back to file + return readCodexFileCredentials(); +} + +/** + * Check if Codex credentials exist and are valid. + */ +export function hasValidCodexCliCredentials(): boolean { + const creds = readCodexCliCredentials(); + if (!creds) return false; + return creds.expires > Date.now() + 5 * 60 * 1000; +} + +/** + * Get the access token from Codex credentials. + */ +export function getCodexCliAccessToken(): string | null { + const creds = readCodexCliCredentials(); + if (!creds) return null; + return creds.access; +} + +// ============================================================ +// Unified Interface +// ============================================================ + +export type CliCredentialSource = "claude-code" | "codex"; + +export interface CliCredentialStatus { + source: CliCredentialSource; + available: boolean; + expires?: number; + expiresIn?: string; +} + +/** + * Get status of all CLI credential sources. + */ +export function getCliCredentialStatus(): CliCredentialStatus[] { + const results: CliCredentialStatus[] = []; + + // Claude Code + const claudeCreds = readClaudeCliCredentials(); + if (claudeCreds) { + const expiresIn = claudeCreds.expires - Date.now(); + results.push({ + source: "claude-code", + available: expiresIn > 0, + expires: claudeCreds.expires, + expiresIn: formatDuration(expiresIn), + }); + } else { + results.push({ source: "claude-code", available: false }); + } + + // Codex + const codexCreds = readCodexCliCredentials(); + if (codexCreds) { + const expiresIn = codexCreds.expires - Date.now(); + results.push({ + source: "codex", + available: expiresIn > 0, + expires: codexCreds.expires, + expiresIn: formatDuration(expiresIn), + }); + } else { + results.push({ source: "codex", available: false }); + } + + return results; +} + +function formatDuration(ms: number): string { + if (ms <= 0) return "expired"; + const hours = Math.floor(ms / (60 * 60 * 1000)); + const minutes = Math.floor((ms % (60 * 60 * 1000)) / (60 * 1000)); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} diff --git a/src/agent/providers/oauth/index.ts b/src/agent/providers/oauth/index.ts new file mode 100644 index 00000000..e666b667 --- /dev/null +++ b/src/agent/providers/oauth/index.ts @@ -0,0 +1,7 @@ +/** + * OAuth Credential Reading + * + * Read OAuth credentials from external CLI tools (Claude Code, Codex). + */ + +export * from "./cli-credentials.js"; diff --git a/src/agent/providers/registry.ts b/src/agent/providers/registry.ts new file mode 100644 index 00000000..cd406482 --- /dev/null +++ b/src/agent/providers/registry.ts @@ -0,0 +1,276 @@ +/** + * Provider Registry + * + * Central registry for all LLM providers with metadata, + * status checking, and display formatting. + */ + +import { credentialManager } from "../credentials.js"; +import { + hasValidClaudeCliCredentials, + hasValidCodexCliCredentials, +} from "./oauth/cli-credentials.js"; + +// ============================================================ +// Types +// ============================================================ + +export type AuthMethod = "api-key" | "oauth"; + +export interface ProviderInfo { + id: string; + name: string; + authMethod: AuthMethod; + available: boolean; + configured: boolean; + current: boolean; + defaultModel: string; + models: string[]; + loginUrl?: string | undefined; + loginCommand?: string | undefined; +} + +/** Static provider metadata (without runtime status) */ +export interface ProviderMeta { + id: string; + name: string; + authMethod: AuthMethod; + defaultModel: string; + models: string[]; + loginUrl?: string | undefined; + loginCommand?: string | undefined; +} + +// ============================================================ +// Provider Registry +// ============================================================ + +const PROVIDER_REGISTRY: Record = { + "claude-code": { + id: "claude-code", + name: "Claude Code (OAuth)", + authMethod: "oauth", + defaultModel: "claude-opus-4-5", + models: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"], + loginCommand: "claude login", + }, + "openai-codex": { + id: "openai-codex", + name: "Codex (OAuth)", + authMethod: "oauth", + defaultModel: "gpt-5.2", + models: ["gpt-5.2", "gpt-5.2-codex", "gpt-5.1-codex", "gpt-5.1-codex-mini", "gpt-5.1-codex-max"], + loginCommand: "codex login", + }, + "anthropic": { + id: "anthropic", + name: "Anthropic (API Key)", + authMethod: "api-key", + defaultModel: "claude-sonnet-4-5", + models: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"], + loginUrl: "https://console.anthropic.com/", + }, + "openai": { + id: "openai", + name: "OpenAI", + authMethod: "api-key", + defaultModel: "gpt-4o", + models: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini"], + loginUrl: "https://platform.openai.com/api-keys", + }, + "kimi-coding": { + id: "kimi-coding", + name: "Kimi Code", + authMethod: "api-key", + defaultModel: "kimi-k2-thinking", + models: ["kimi-k2-thinking", "k2p5"], + loginUrl: "https://kimi.moonshot.cn/", + }, + "google": { + id: "google", + name: "Google AI", + authMethod: "api-key", + defaultModel: "gemini-2.0-flash", + models: ["gemini-2.0-flash", "gemini-1.5-pro"], + loginUrl: "https://aistudio.google.com/apikey", + }, + "groq": { + id: "groq", + name: "Groq", + authMethod: "api-key", + defaultModel: "llama-3.3-70b-versatile", + models: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"], + loginUrl: "https://console.groq.com/keys", + }, + "mistral": { + id: "mistral", + name: "Mistral", + authMethod: "api-key", + defaultModel: "mistral-large-latest", + models: ["mistral-large-latest", "codestral-latest"], + loginUrl: "https://console.mistral.ai/api-keys", + }, + "xai": { + id: "xai", + name: "xAI (Grok)", + authMethod: "api-key", + defaultModel: "grok-beta", + models: ["grok-beta", "grok-vision-beta"], + loginUrl: "https://console.x.ai/", + }, + "openrouter": { + id: "openrouter", + name: "OpenRouter", + authMethod: "api-key", + defaultModel: "anthropic/claude-3.5-sonnet", + models: ["anthropic/claude-3.5-sonnet", "openai/gpt-4o"], + loginUrl: "https://openrouter.ai/keys", + }, +}; + +/** + * Provider alias mapping for OAuth providers. + * Maps friendly names to actual pi-ai provider names. + */ +export const PROVIDER_ALIAS: Record = { + "claude-code": "anthropic", // Claude Code OAuth uses anthropic API + "openai-codex": "openai", // Codex OAuth uses OpenAI API +}; + +// ============================================================ +// Status Checking +// ============================================================ + +/** + * Check if a provider is configured with API key in credentials.json5 + */ +function isApiKeyConfigured(providerId: string): boolean { + const config = credentialManager.getLlmProviderConfig(providerId); + return !!config?.apiKey; +} + +/** + * Check if OAuth provider has valid credentials + */ +function isOAuthAvailable(providerId: string): boolean { + if (providerId === "claude-code") { + return hasValidClaudeCliCredentials(); + } + if (providerId === "openai-codex") { + return hasValidCodexCliCredentials(); + } + return false; +} + +/** + * Check if a provider uses OAuth authentication + */ +export function isOAuthProvider(providerId: string): boolean { + const info = PROVIDER_REGISTRY[providerId]; + return info?.authMethod === "oauth"; +} + +/** + * Check if provider is available (has valid credentials) + */ +export function isProviderAvailable(providerId: string): boolean { + const info = PROVIDER_REGISTRY[providerId]; + if (!info) return false; + + if (info.authMethod === "oauth") { + return isOAuthAvailable(providerId); + } + return isApiKeyConfigured(providerId); +} + +/** + * Get current provider from credentials + */ +export function getCurrentProvider(): string { + return credentialManager.getLlmProvider() ?? "kimi-coding"; +} + +// ============================================================ +// Provider Listing +// ============================================================ + +/** + * Get static provider metadata + */ +export function getProviderMeta(providerId: string): ProviderMeta | undefined { + return PROVIDER_REGISTRY[providerId]; +} + +/** + * Get default model for a provider + */ +export function getDefaultModel(providerId: string): string | undefined { + return PROVIDER_REGISTRY[providerId]?.defaultModel; +} + +/** + * Get list of all providers with their runtime status + */ +export function getProviderList(): ProviderInfo[] { + const currentProvider = getCurrentProvider(); + + return Object.values(PROVIDER_REGISTRY).map((meta) => { + const isOAuth = meta.authMethod === "oauth"; + const available = isOAuth ? isOAuthAvailable(meta.id) : isApiKeyConfigured(meta.id); + + // Check if this is the current provider + // For claude-code, check if current is "anthropic" and OAuth is available + let isCurrent = currentProvider === meta.id; + if (meta.id === "claude-code" && currentProvider === "anthropic") { + isCurrent = hasValidClaudeCliCredentials(); + } + + return { + ...meta, + available, + configured: available, + current: isCurrent, + }; + }); +} + +/** + * Get available providers only + */ +export function getAvailableProviders(): ProviderInfo[] { + return getProviderList().filter((p) => p.available); +} + +// ============================================================ +// Display Helpers +// ============================================================ + +/** + * Format provider for display + */ +export function formatProviderStatus(provider: ProviderInfo): string { + const status = provider.available ? "โœ“" : "โœ—"; + const current = provider.current ? " (current)" : ""; + const auth = provider.authMethod === "oauth" ? " [OAuth]" : ""; + return `${status} ${provider.name}${auth}${current}`; +} + +/** + * Get login instructions for a provider + */ +export function getLoginInstructions(providerId: string): string { + const info = PROVIDER_REGISTRY[providerId]; + if (!info) return `Unknown provider: ${providerId}`; + + if (info.authMethod === "oauth") { + if (info.loginCommand) { + return `Run: ${info.loginCommand}\nThen restart Super Multica to use the credentials.`; + } + } + + if (info.loginUrl) { + return `Get your API key at: ${info.loginUrl}\nThen add it to ~/.super-multica/credentials.json5`; + } + + return "No login instructions available."; +} diff --git a/src/agent/providers/resolver.ts b/src/agent/providers/resolver.ts new file mode 100644 index 00000000..7ec8dd14 --- /dev/null +++ b/src/agent/providers/resolver.ts @@ -0,0 +1,166 @@ +/** + * Provider Resolver + * + * Resolves provider configuration for making API calls, + * including API keys, OAuth tokens, and model selection. + */ + +import { getModel } from "@mariozechner/pi-ai"; +import { credentialManager } from "../credentials.js"; +import { + readClaudeCliCredentials, + readCodexCliCredentials, +} from "./oauth/cli-credentials.js"; +import { + PROVIDER_ALIAS, + getProviderMeta, + getDefaultModel, + isOAuthProvider, +} from "./registry.js"; +import type { AgentOptions } from "../types.js"; + +// ============================================================ +// Types +// ============================================================ + +export interface ProviderConfig { + provider: string; + model?: string | undefined; + apiKey?: string | undefined; + baseUrl?: string | undefined; + // OAuth specific + accessToken?: string | undefined; + refreshToken?: string | undefined; + expires?: number | undefined; +} + +// ============================================================ +// Provider Config Resolution +// ============================================================ + +/** + * Get provider config for making API calls. + * Handles both OAuth and API Key authentication. + */ +export function resolveProviderConfig(providerId: string): ProviderConfig | null { + const meta = getProviderMeta(providerId); + if (!meta) return null; + + if (meta.authMethod === "oauth") { + if (providerId === "claude-code") { + const creds = readClaudeCliCredentials(); + if (!creds) return null; + + const accessToken = creds.type === "oauth" ? creds.access : creds.token; + return { + provider: "anthropic", // Use anthropic API + apiKey: accessToken, + accessToken, + refreshToken: creds.type === "oauth" ? creds.refresh : undefined, + expires: creds.expires, + }; + } + + if (providerId === "openai-codex") { + const creds = readCodexCliCredentials(); + if (!creds) return null; + + return { + provider: "openai-codex", + accessToken: creds.access, + refreshToken: creds.refresh, + expires: creds.expires, + }; + } + } + + // API Key based + const config = credentialManager.getLlmProviderConfig(providerId); + if (!config?.apiKey) return null; + + return { + provider: providerId, + model: config.model, + apiKey: config.apiKey, + baseUrl: config.baseUrl, + }; +} + +// ============================================================ +// API Key Resolution +// ============================================================ + +/** + * Get API Key based on provider. + * Priority: explicit key > OAuth credentials > credentials.json5 config. + */ +export function resolveApiKey(provider: string, explicitKey?: string): string | undefined { + if (explicitKey) return explicitKey; + + // Try OAuth providers first (claude-code, openai-codex) + const providerConfig = resolveProviderConfig(provider); + if (providerConfig?.apiKey) { + return providerConfig.apiKey; + } + if (providerConfig?.accessToken) { + return providerConfig.accessToken; + } + + // Fall back to credentials.json5 + return credentialManager.getLlmProviderConfig(provider)?.apiKey; +} + +/** + * Get Base URL based on provider. + * Priority: explicit URL > credentials.json5 config. + */ +export function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefined { + if (explicitUrl) return explicitUrl; + return credentialManager.getLlmProviderConfig(provider)?.baseUrl; +} + +/** + * Get Model ID based on provider. + * Priority: explicit model > credentials.json5 config > default. + */ +export function resolveModelId(provider: string, explicitModel?: string): string | undefined { + if (explicitModel) return explicitModel; + return credentialManager.getLlmProviderConfig(provider)?.model ?? getDefaultModel(provider); +} + +// ============================================================ +// Model Resolution +// ============================================================ + +/** + * Resolve model for pi-ai based on provider and options. + */ +export function resolveModel(options: AgentOptions) { + if (options.provider && options.model) { + // Map provider alias (e.g., claude-code -> anthropic) + const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider; + + // Type assertion needed because provider/model come from dynamic user config + return (getModel as (p: string, m: string) => ReturnType)( + actualProvider, + options.model, + ); + } + + // If only provider specified, use default model for that provider + if (options.provider) { + const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider; + const defaultModel = getDefaultModel(options.provider) ?? getDefaultModel(actualProvider); + if (defaultModel) { + return (getModel as (p: string, m: string) => ReturnType)( + actualProvider, + defaultModel, + ); + } + } + + return getModel("kimi-coding", "kimi-k2-thinking"); +} + +// Re-export for convenience +export { isOAuthProvider }; diff --git a/src/agent/runner.ts b/src/agent/runner.ts index dfccb9bc..90f5416a 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -3,6 +3,7 @@ import { v7 as uuidv7 } from "uuid"; import type { AgentOptions, AgentRunResult } from "./types.js"; import { createAgentOutput } from "./cli/output.js"; import { resolveModel, resolveTools } from "./tools.js"; +import { resolveApiKey, resolveBaseUrl, resolveModelId } from "./providers/index.js"; import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js"; import { SkillManager } from "./skills/index.js"; @@ -14,33 +15,6 @@ import { } from "./context-window/index.js"; import { mergeToolsConfig, type ToolsConfig } from "./tools/policy.js"; -/** - * Get API Key based on provider. - * Priority: explicit key > provider-specific env var > generic env var format. - */ -function resolveApiKey(provider: string, explicitKey?: string): string | undefined { - if (explicitKey) return explicitKey; - return credentialManager.getLlmProviderConfig(provider)?.apiKey; -} - -/** - * Get Base URL based on provider. - * Priority: explicit URL > provider-specific env var > generic env var format. - */ -function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefined { - if (explicitUrl) return explicitUrl; - return credentialManager.getLlmProviderConfig(provider)?.baseUrl; -} - -/** - * Get Model ID based on provider. - * Priority: explicit model > provider-specific env var > generic env var format. - */ -function resolveModelId(provider: string, explicitModel?: string): string | undefined { - if (explicitModel) return explicitModel; - return credentialManager.getLlmProviderConfig(provider)?.model; -} - export class Agent { private readonly agent: PiAgentCore; private readonly output; @@ -155,7 +129,9 @@ export class Agent { const compactionMode = options.compactionMode ?? "tokens"; // ้ป˜่ฎคไฝฟ็”จ token ๆจกๅผ // ่Žทๅ– API Key๏ผˆ็”จไบŽ summary ๆจกๅผ๏ผ‰ - const summaryApiKey = compactionMode === "summary" ? resolveApiKey(model.provider, options.apiKey) : undefined; + const summaryApiKey = compactionMode === "summary" + ? resolveApiKey(resolvedProvider, options.apiKey) + : undefined; // ๅˆ›ๅปบ SessionManager๏ผˆๅธฆ context window ้…็ฝฎ๏ผ‰ this.session = new SessionManager({ diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 6578a238..8190cb86 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1,32 +1,84 @@ import type { AgentOptions } from "./types.js"; -import { getModel } from "@mariozechner/pi-ai"; import { createCodingTools } from "@mariozechner/pi-coding-agent"; -import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { createExecTool } from "./tools/exec.js"; import { createProcessTool } from "./tools/process.js"; import { createGlobTool } from "./tools/glob.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js"; import { createMemoryTools } from "./tools/memory/index.js"; import { filterTools } from "./tools/policy.js"; +import { isMulticaError, isRetryableError } from "../shared/errors.js"; -export function resolveModel(options: AgentOptions) { - if (options.provider && options.model) { - // Type assertion needed because provider/model come from dynamic user config - return (getModel as (p: string, m: string) => ReturnType)( - options.provider, - options.model, - ); - } - return getModel("kimi-coding", "kimi-k2-thinking"); -} +// Re-export resolveModel from providers for backwards compatibility +export { resolveModel } from "./providers/index.js"; /** Options for creating tools */ export interface CreateToolsOptions { cwd: string; /** Profile ID for memory tools (optional) */ - profileId?: string; + profileId?: string | undefined; /** Base directory for profiles (optional) */ - profileBaseDir?: string; + profileBaseDir?: string | undefined; +} + +type ToolErrorPayload = { + error: true; + message: string; + name?: string; + code?: string; + retryable?: boolean; + details?: Record; +}; + +function toToolErrorPayload(error: unknown): ToolErrorPayload { + if (isMulticaError(error)) { + return { + error: true, + message: error.message, + name: error.name, + code: error.code, + retryable: error.retryable, + details: error.details, + }; + } + + if (error instanceof Error) { + return { + error: true, + message: error.message, + name: error.name, + retryable: isRetryableError(error), + }; + } + + return { + error: true, + message: String(error), + }; +} + +function toolErrorResult(error: unknown): AgentToolResult { + const payload = toToolErrorPayload(error); + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + details: payload, + }; +} + +function wrapTool( + tool: AgentTool, +): AgentTool { + const execute = tool.execute; + return { + ...tool, + execute: async (...args) => { + try { + return await execute(...args); + } catch (error) { + return toolErrorResult(error) as AgentToolResult; + } + }, + }; } /** @@ -95,7 +147,7 @@ export function resolveTools(options: AgentOptions): AgentTool[] { isSubagent: options.isSubagent, }); - return filtered; + return filtered.map((tool) => wrapTool(tool)); } /** diff --git a/src/agent/tools/memory/types.ts b/src/agent/tools/memory/types.ts index 81912eed..58bc15bf 100644 --- a/src/agent/tools/memory/types.ts +++ b/src/agent/tools/memory/types.ts @@ -37,7 +37,7 @@ export interface MemoryStorageOptions { /** Profile ID (required for storage path) */ profileId: string; /** Base directory for profiles */ - baseDir?: string; + baseDir?: string | undefined; } /** Result from memory_list */ diff --git a/src/agent/tools/policy.test.ts b/src/agent/tools/policy.test.ts index 1862fd6f..e0902708 100644 --- a/src/agent/tools/policy.test.ts +++ b/src/agent/tools/policy.test.ts @@ -1,32 +1,6 @@ -/** - * Tests for tool policy system. - * Run with: npx tsx src/agent/tools/policy.test.ts - */ - -import { filterTools, type ToolsConfig } from "./policy.js"; -import { TOOL_GROUPS, TOOL_PROFILES, expandToolGroups } from "./groups.js"; - -// Simple test helper -function test(name: string, fn: () => void) { - try { - fn(); - console.log(`โœ“ ${name}`); - } catch (e) { - console.error(`โœ— ${name}`); - console.error(e); - process.exit(1); - } -} - -function assertEqual(actual: T, expected: T, msg?: string) { - const actualStr = JSON.stringify(actual); - const expectedStr = JSON.stringify(expected); - if (actualStr !== expectedStr) { - throw new Error( - `${msg || "Assertion failed"}\n Expected: ${expectedStr}\n Actual: ${actualStr}`, - ); - } -} +import { describe, it, expect } from "vitest"; +import { filterTools } from "./policy.js"; +import { TOOL_PROFILES, expandToolGroups } from "./groups.js"; // Mock tools for testing const mockTools = [ @@ -40,177 +14,171 @@ const mockTools = [ { name: "web_search" }, ] as any[]; -console.log("=== Tool Groups Tests ===\n"); - -test("expandToolGroups: group:fs", () => { - const expanded = expandToolGroups(["group:fs"]); - assertEqual(expanded.sort(), ["edit", "glob", "read", "write"]); -}); - -test("expandToolGroups: group:runtime", () => { - const expanded = expandToolGroups(["group:runtime"]); - assertEqual(expanded.sort(), ["exec", "process"]); -}); - -test("expandToolGroups: group:web", () => { - const expanded = expandToolGroups(["group:web"]); - assertEqual(expanded.sort(), ["web_fetch", "web_search"]); -}); - -test("expandToolGroups: mixed groups and tools", () => { - const expanded = expandToolGroups(["group:runtime", "web_fetch"]); - assertEqual(expanded.sort(), ["exec", "process", "web_fetch"]); -}); - -console.log("\n=== Tool Profiles Tests ===\n"); - -test("TOOL_PROFILES: minimal has empty allow", () => { - assertEqual(TOOL_PROFILES.minimal.allow, []); -}); - -test("TOOL_PROFILES: coding has fs and runtime", () => { - assertEqual(TOOL_PROFILES.coding.allow, ["group:fs", "group:runtime"]); -}); - -test("TOOL_PROFILES: full has no restrictions", () => { - assertEqual(TOOL_PROFILES.full.allow, undefined); - assertEqual(TOOL_PROFILES.full.deny, undefined); -}); - -console.log("\n=== Filter Tests ===\n"); - -test("filterTools: no config returns all tools", () => { - const filtered = filterTools(mockTools, {}); - assertEqual(filtered.length, mockTools.length); -}); - -test("filterTools: minimal profile returns no tools", () => { - const filtered = filterTools(mockTools, { config: { profile: "minimal" } }); - assertEqual(filtered.length, 0); -}); - -test("filterTools: coding profile returns fs and runtime", () => { - const filtered = filterTools(mockTools, { config: { profile: "coding" } }); - const names = filtered.map((t) => t.name).sort(); - assertEqual(names, ["edit", "exec", "glob", "process", "read", "write"]); -}); - -test("filterTools: web profile returns all", () => { - const filtered = filterTools(mockTools, { config: { profile: "web" } }); - const names = filtered.map((t) => t.name).sort(); - assertEqual(names, [ - "edit", - "exec", - "glob", - "process", - "read", - "web_fetch", - "web_search", - "write", - ]); -}); - -test("filterTools: full profile returns all tools", () => { - const filtered = filterTools(mockTools, { config: { profile: "full" } }); - assertEqual(filtered.length, mockTools.length); -}); - -test("filterTools: deny specific tool", () => { - const filtered = filterTools(mockTools, { config: { deny: ["exec"] } }); - const names = filtered.map((t) => t.name); - assertEqual(names.includes("exec"), false); - assertEqual(names.length, mockTools.length - 1); -}); - -test("filterTools: allow specific tools", () => { - const filtered = filterTools(mockTools, { - config: { allow: ["read", "write"] }, +describe("tool groups", () => { + it("expandToolGroups: group:fs", () => { + const expanded = expandToolGroups(["group:fs"]); + expect(expanded.sort()).toEqual(["edit", "glob", "read", "write"]); }); - const names = filtered.map((t) => t.name).sort(); - assertEqual(names, ["read", "write"]); -}); -test("filterTools: deny takes precedence over allow", () => { - const filtered = filterTools(mockTools, { - config: { allow: ["read", "write", "exec"], deny: ["exec"] }, + it("expandToolGroups: group:runtime", () => { + const expanded = expandToolGroups(["group:runtime"]); + expect(expanded.sort()).toEqual(["exec", "process"]); + }); + + it("expandToolGroups: group:web", () => { + const expanded = expandToolGroups(["group:web"]); + expect(expanded.sort()).toEqual(["web_fetch", "web_search"]); + }); + + it("expandToolGroups: mixed groups and tools", () => { + const expanded = expandToolGroups(["group:runtime", "web_fetch"]); + expect(expanded.sort()).toEqual(["exec", "process", "web_fetch"]); }); - const names = filtered.map((t) => t.name).sort(); - assertEqual(names, ["read", "write"]); }); -console.log("\n=== Provider-specific Tests ===\n"); +describe("tool profiles", () => { + it("minimal has empty allow", () => { + expect(TOOL_PROFILES.minimal.allow).toEqual([]); + }); -test("filterTools: provider-specific deny", () => { - const filtered = filterTools(mockTools, { - config: { - byProvider: { - google: { deny: ["exec", "process"] }, + it("coding has fs and runtime", () => { + expect(TOOL_PROFILES.coding.allow).toEqual(["group:fs", "group:runtime"]); + }); + + it("full has no restrictions", () => { + expect(TOOL_PROFILES.full.allow).toBeUndefined(); + expect(TOOL_PROFILES.full.deny).toBeUndefined(); + }); +}); + +describe("filterTools", () => { + it("no config returns all tools", () => { + const filtered = filterTools(mockTools, {}); + expect(filtered.length).toBe(mockTools.length); + }); + + it("minimal profile returns no tools", () => { + const filtered = filterTools(mockTools, { config: { profile: "minimal" } }); + expect(filtered.length).toBe(0); + }); + + it("coding profile returns fs and runtime", () => { + const filtered = filterTools(mockTools, { config: { profile: "coding" } }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]); + }); + + it("web profile returns all", () => { + const filtered = filterTools(mockTools, { config: { profile: "web" } }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual([ + "edit", + "exec", + "glob", + "process", + "read", + "web_fetch", + "web_search", + "write", + ]); + }); + + it("full profile returns all tools", () => { + const filtered = filterTools(mockTools, { config: { profile: "full" } }); + expect(filtered.length).toBe(mockTools.length); + }); + + it("deny specific tool", () => { + const filtered = filterTools(mockTools, { config: { deny: ["exec"] } }); + const names = filtered.map((t) => t.name); + expect(names.includes("exec")).toBe(false); + expect(names.length).toBe(mockTools.length - 1); + }); + + it("allow specific tools", () => { + const filtered = filterTools(mockTools, { + config: { allow: ["read", "write"] }, + }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual(["read", "write"]); + }); + + it("deny takes precedence over allow", () => { + const filtered = filterTools(mockTools, { + config: { allow: ["read", "write", "exec"], deny: ["exec"] }, + }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual(["read", "write"]); + }); +}); + +describe("provider-specific filtering", () => { + it("provider-specific deny", () => { + const filtered = filterTools(mockTools, { + config: { + byProvider: { + google: { deny: ["exec", "process"] }, + }, }, - }, - provider: "google", + provider: "google", + }); + const names = filtered.map((t) => t.name); + expect(names.includes("exec")).toBe(false); + expect(names.includes("process")).toBe(false); + expect(names.length).toBe(mockTools.length - 2); }); - const names = filtered.map((t) => t.name); - assertEqual(names.includes("exec"), false); - assertEqual(names.includes("process"), false); - assertEqual(names.length, mockTools.length - 2); -}); -test("filterTools: provider not matching does not apply", () => { - const filtered = filterTools(mockTools, { - config: { - byProvider: { - google: { deny: ["exec", "process"] }, + it("provider not matching does not apply", () => { + const filtered = filterTools(mockTools, { + config: { + byProvider: { + google: { deny: ["exec", "process"] }, + }, }, - }, - provider: "openai", + provider: "openai", + }); + expect(filtered.length).toBe(mockTools.length); }); - assertEqual(filtered.length, mockTools.length); }); -console.log("\n=== Subagent Tests ===\n"); - -test("filterTools: subagent restrictions apply", () => { - // Currently DEFAULT_SUBAGENT_TOOL_DENY is empty, so no tools are denied - const filtered = filterTools(mockTools, { isSubagent: true }); - // With empty deny list, all tools are allowed - assertEqual(filtered.length, mockTools.length); -}); - -console.log("\n=== Combined Tests ===\n"); - -test("filterTools: profile + deny", () => { - const filtered = filterTools(mockTools, { - config: { - profile: "coding", - deny: ["exec"], - }, +describe("subagent restrictions", () => { + it("subagent restrictions apply", () => { + const filtered = filterTools(mockTools, { isSubagent: true }); + expect(filtered.length).toBe(mockTools.length); }); - const names = filtered.map((t) => t.name).sort(); - // coding = fs + runtime, minus exec - assertEqual(names, ["edit", "glob", "process", "read", "write"]); }); -test("filterTools: profile + provider deny", () => { - const filtered = filterTools(mockTools, { - config: { - profile: "web", - byProvider: { - google: { deny: ["exec"] }, +describe("combined filtering", () => { + it("profile + deny", () => { + const filtered = filterTools(mockTools, { + config: { + profile: "coding", + deny: ["exec"], }, - }, - provider: "google", + }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual(["edit", "glob", "process", "read", "write"]); }); - const names = filtered.map((t) => t.name).sort(); - // web profile - exec - assertEqual(names, [ - "edit", - "glob", - "process", - "read", - "web_fetch", - "web_search", - "write", - ]); -}); -console.log("\n=== All tests passed! ===\n"); + it("profile + provider deny", () => { + const filtered = filterTools(mockTools, { + config: { + profile: "web", + byProvider: { + google: { deny: ["exec"] }, + }, + }, + provider: "google", + }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual([ + "edit", + "glob", + "process", + "read", + "web_fetch", + "web_search", + "write", + ]); + }); +}); diff --git a/src/agent/tools/policy.ts b/src/agent/tools/policy.ts index 7e1a9407..5b8c2fc0 100644 --- a/src/agent/tools/policy.ts +++ b/src/agent/tools/policy.ts @@ -183,11 +183,11 @@ export function getSubagentPolicy(extraDeny?: string[]): ToolPolicy { export interface FilterToolsOptions { /** Tool configuration */ - config?: ToolsConfig; + config?: ToolsConfig | undefined; /** Current LLM provider (for provider-specific rules) */ - provider?: string; + provider?: string | undefined; /** Whether this is a subagent (applies subagent restrictions) */ - isSubagent?: boolean; + isSubagent?: boolean | undefined; } /** diff --git a/src/hub/hub.ts b/src/hub/hub.ts index bee58b66..3d5deb71 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -7,7 +7,6 @@ import { type RequestPayload, type ResponseSuccessPayload, type ResponseErrorPayload, - type StreamPayload, } from "@multica/sdk"; import { AsyncAgent } from "../agent/async-agent.js"; import { getHubId } from "./hub-identity.js"; @@ -23,6 +22,8 @@ import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js"; export class Hub { private readonly agents = new Map(); private readonly agentSenders = new Map(); + private readonly agentStreamIds = new Map(); + private readonly agentStreamCounters = new Map(); private readonly rpc: RpcDispatcher; private client: GatewayClient; url: string; @@ -145,13 +146,42 @@ export class Hub { addAgentRecord({ id: agent.sessionId, createdAt: Date.now() }); } - // Internally consume messages produced by agent + // Internally consume agent output (AgentEvent stream + error Messages) void this.consumeAgent(agent); console.log(`Agent created: ${agent.sessionId}`); return agent; } + private getMessageIdFromEvent(event: unknown): string | undefined { + if (!event || typeof event !== "object") return undefined; + const maybeMsg = (event as { message?: unknown }).message; + if (!maybeMsg || typeof maybeMsg !== "object") return undefined; + const id = (maybeMsg as { id?: unknown }).id; + return typeof id === "string" && id.length > 0 ? id : undefined; + } + + private beginStream(agentId: string, event: unknown): string { + const explicitId = this.getMessageIdFromEvent(event); + if (explicitId) { + this.agentStreamIds.set(agentId, explicitId); + return explicitId; + } + const next = (this.agentStreamCounters.get(agentId) ?? 0) + 1; + this.agentStreamCounters.set(agentId, next); + const fallback = `${agentId}:${next}`; + this.agentStreamIds.set(agentId, fallback); + return fallback; + } + + private getActiveStreamId(agentId: string, event: unknown): string { + return this.agentStreamIds.get(agentId) ?? this.getMessageIdFromEvent(event) ?? agentId; + } + + private endStream(agentId: string): void { + this.agentStreamIds.delete(agentId); + } + /** Internally read agent output and send via Gateway */ private async consumeAgent(agent: AsyncAgent): Promise { for await (const item of agent.read()) { @@ -166,11 +196,21 @@ export class Hub { content: item.content, }); } else { + const maybeMessage = (item as { message?: { role?: string } }).message; + const isAssistantMessage = maybeMessage?.role === "assistant"; + if (item.type === "message_start" && isAssistantMessage) { + this.beginStream(agent.sessionId, item); + } + const streamId = this.getActiveStreamId(agent.sessionId, item); // Raw AgentEvent โ€” forward via StreamAction - this.client.send(targetDeviceId, StreamAction, { - streamId: agent.sessionId, - data: item, + this.client.send(targetDeviceId, StreamAction, { + streamId, + agentId: agent.sessionId, + event: item, }); + if (item.type === "message_end" && isAssistantMessage) { + this.endStream(agent.sessionId); + } } } } @@ -213,6 +253,8 @@ export class Hub { agent.close(); this.agents.delete(id); this.agentSenders.delete(id); + this.agentStreamIds.delete(id); + this.agentStreamCounters.delete(id); removeAgentRecord(id); return true; } @@ -221,6 +263,9 @@ export class Hub { for (const [id, agent] of this.agents) { agent.close(); this.agents.delete(id); + this.agentSenders.delete(id); + this.agentStreamIds.delete(id); + this.agentStreamCounters.delete(id); } this.client.disconnect(); console.log("Hub shut down");