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");