feat: pivot to AI-native task management platform (#232)

Replace the agent framework codebase with a new monorepo structure
for an AI-native Linear-like product where agents are first-class citizens.

New architecture:
- server/ — Go backend (Chi + gorilla/websocket + sqlc)
  - API server with REST routes for issues, agents, inbox, workspaces
  - WebSocket hub for real-time updates
  - Local daemon entry point for agent runtime connection
  - PostgreSQL migration with 13 tables (issue, agent, inbox, etc.)
  - WebSocket protocol types for server<->daemon communication
- apps/web/ — Next.js 16 frontend
  - Dashboard layout with sidebar navigation
  - Route skeleton: inbox, issues, agents, board, settings
- packages/ui/ — Preserved shadcn/ui design system (26+ components)
- packages/types/ — Full API contract types (Issue, Agent, Workspace, Inbox, Events)
- packages/sdk/ — REST ApiClient + WebSocket WSClient
- packages/store/ — Zustand stores (issue, agent, inbox, auth)
- packages/hooks/ — React hooks (useIssues, useAgents, useInbox, useRealtime)
- packages/utils/ — Shared utilities

Removed: apps/cli, apps/desktop, apps/mobile, apps/gateway,
packages/core, skills/, and all agent-framework code.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang 2026-03-20 17:55:49 +08:00 committed by GitHub
parent 3f589d8326
commit d4f5c5b16f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
677 changed files with 2779 additions and 122531 deletions

View file

@ -1,45 +0,0 @@
# Dependencies
node_modules
# Build output
dist
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs
# Environment files
.env
.env.*
# Docker
Dockerfile
.dockerignore
docker-compose*.yml
# Documentation
README.md
docs
# Tests
*.test.ts
*.spec.ts
__tests__
coverage
# Context directory
.context

View file

@ -1,9 +1,15 @@
# Telegram Bot
# Get a token from @BotFather on Telegram
TELEGRAM_BOT_TOKEN=
# Database
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Optional: webhook secret token for production
# TELEGRAM_WEBHOOK_SECRET_TOKEN=
# Server
PORT=8080
JWT_SECRET=change-me-in-production
# Optional: webhook URL (if not set, uses long-polling mode for local dev)
# TELEGRAM_WEBHOOK_URL=https://your-domain.ngrok-free.dev/telegram/webhook
# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws

View file

@ -11,28 +11,7 @@ concurrency:
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm --filter @multica/web --filter @multica/desktop lint
build-and-typecheck:
frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
@ -61,3 +40,21 @@ jobs:
- name: Build, type check, and test
run: pnpm turbo build typecheck test
backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
cache-dependency-path: server/go.sum
- name: Build
run: cd server && go build ./...
- name: Test
run: cd server && go test ./...

23
.gitignore vendored
View file

@ -9,25 +9,22 @@ out
.turbo
build
bin
dist-electron
release
*.tsbuildinfo
# env
.env*
!.env.example
!apps/desktop/.env.production
!apps/desktop/.env.development
# test coverage
coverage
# Go
server/bin/
server/tmp/
# context (agent workspace)
.context
# platform specific
*.dmg
*.app
*.apk
*.ipa
monorepo.md
# python
__pycache__
# test coverage
coverage

View file

@ -1 +0,0 @@
CLAUDE.md

View file

@ -4,63 +4,56 @@ This file gives coding agents high-signal guidance for this repository.
## 1. Project Context
Super Multica is a distributed AI agent framework/product monorepo.
It is used to run local-first agent workflows and support CLI/Desktop/Web/Gateway-based usage.
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
Core purpose:
- Agents can be assigned issues, create issues, comment, and change status
- Supports local (daemon) and cloud agent runtimes
- Built for 2-10 person AI-native teams
- execute agent tasks with tools and skills
- persist sessions/profiles/credentials across runs
- support development, testing, and operational automation workflows
## 2. Architecture
## 2. Documentation Scope
**Polyglot monorepo** — Go backend + TypeScript frontend.
Documentation in this repo should prioritize:
1. Development workflow
2. Testing methods
3. Operational process
Architecture explanations should stay minimal in docs.
Treat source code as the architecture source of truth.
- `server/` — Go backend (Chi + sqlc + gorilla/websocket)
- `apps/web/` — Next.js 16 frontend
- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils)
## 3. Core Workflow Commands
```bash
# Frontend
pnpm install
pnpm multica
pnpm multica run "<prompt>"
pnpm dev
pnpm dev:gateway
pnpm dev:web
pnpm dev:local
pnpm build
pnpm typecheck
pnpm test
pnpm dev:web # Next.js dev server
pnpm build # Build all TS packages
pnpm typecheck # TypeScript check
pnpm test # TS tests
# Backend (Go)
make dev # Run Go server with hot-reload
make daemon # Run local daemon
make test # Go tests
make sqlc # Regenerate sqlc code
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
# Infrastructure
docker compose up -d # Start PostgreSQL
```
## 4. Data and Credentials Workflow
- Default data dir: `~/.super-multica` (override with `SMC_DATA_DIR`)
- Credentials: `~/.super-multica/credentials.json5` (override with `SMC_CREDENTIALS_PATH`)
- Initialize credentials via `pnpm multica credentials init`
## 5. Coding Rules
## 4. Coding Rules
- TypeScript strict mode is enabled; keep types explicit.
- Go code follows standard Go conventions (gofmt, go vet).
- Keep comments in code **English only**.
- Prefer existing patterns/components over introducing parallel abstractions.
- Avoid broad refactors unless required by the task.
- Keep docs concise and aligned with current code behavior.
## 6. Testing Rules
## 5. Testing Rules
- Test runner: Vitest.
- Mock policy: mock external/third-party dependencies only.
- Do not mock internal modules when real integration can be tested.
- Prefer temp directories and real file I/O for storage-related tests.
- **TypeScript**: Vitest. Mock external/third-party dependencies only.
- **Go**: Standard `go test`. Use testcontainers or test database for DB tests.
## 7. Commit Rules
## 6. Commit Rules
- Use atomic commits grouped by logical intent.
- Conventional format:
@ -71,14 +64,10 @@ pnpm test
- `test(scope): ...`
- `chore(scope): ...`
## 8. Minimum Pre-Push Checks
## 7. Minimum Pre-Push Checks
```bash
pnpm typecheck
pnpm test
make test
```
## 9. E2E Process Docs
- `docs/e2e-testing-guide.md`
- `docs/e2e-finance-benchmark.md`

32
Makefile Normal file
View file

@ -0,0 +1,32 @@
.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean
# Go server
dev:
cd server && go run ./cmd/server
daemon:
cd server && go run ./cmd/daemon
build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -o bin/daemon ./cmd/daemon
test:
cd server && go test ./...
# Database
migrate-up:
cd server && go run ./cmd/migrate up
migrate-down:
cd server && go run ./cmd/migrate down
sqlc:
cd server && sqlc generate
seed:
cd server && go run ./cmd/seed
# Cleanup
clean:
rm -rf server/bin server/tmp

129
README.md
View file

@ -1,129 +0,0 @@
# Super Multica
Super Multica is a distributed AI agent framework and product monorepo.
It provides a local-first agent runtime plus CLI, gateway, web, and mobile integration surfaces.
What this project does:
- runs AI agent sessions with tools, skills, and persistent session state
- supports scheduled/automated execution workflows
- supports both standalone local usage and remote-access client workflows
This repository keeps docs focused on:
1. Development workflow
2. Testing workflow
3. Operational process
Architecture details are still source-of-truth in code, but docs keep minimal project context for onboarding.
## Quick Start (Workflow)
```bash
pnpm install
pnpm multica credentials init
pnpm multica
```
Run local desktop workflow:
```bash
pnpm dev
```
## Local Full-Stack Development (`pnpm dev:local`)
Use this when you need **Gateway + Web + Desktop** together for end-to-end dev.
Setup:
1. Copy `.env.example` to `.env` in repo root
2. Set `TELEGRAM_BOT_TOKEN` in `.env` (from `@BotFather`)
3. Run:
```bash
pnpm dev:local
```
What starts:
| Service | Address | Notes |
|---------|---------|-------|
| Gateway | `http://localhost:4000` | Telegram long-polling mode |
| Web | `http://localhost:3000` | OAuth login flow |
| Desktop | — | Connects to local Gateway + Web |
Data isolation:
- runtime data: `~/.super-multica-dev`
- workspace data: `~/Documents/Multica-dev`
Related:
```bash
pnpm dev:local:archive
```
## Workflow Commands
```bash
# CLI
pnpm multica
pnpm multica run "Hello"
pnpm multica chat
pnpm multica help
# Development
pnpm dev
pnpm dev:desktop
pnpm dev:gateway
pnpm dev:web
pnpm dev:local
pnpm dev:local:archive
# Build / quality
pnpm build
pnpm typecheck
pnpm test
```
## Testing Workflow
```bash
# Unit/integration
pnpm test
pnpm test:watch
pnpm test:coverage
# Type safety gate
pnpm typecheck
# Agent E2E
pnpm multica run --run-log "your test prompt"
```
E2E process docs:
- `docs/e2e-testing-guide.md`
- `docs/e2e-finance-benchmark.md`
## Runtime Paths
By default, runtime data is stored under:
- `~/.super-multica`
You can isolate environments with:
- `SMC_DATA_DIR=~/.super-multica-dev` (or other path)
## Process Docs
- `CLAUDE.md`
- `docs/development.md`
- `docs/cli.md`
- `docs/credentials.md`
- `docs/skills-and-tools.md`
- `docs/package-management.md`
- `docs/e2e-testing-guide.md`
- `docs/e2e-finance-benchmark.md`

View file

@ -1,24 +0,0 @@
{
"name": "@multica/cli",
"version": "1.0.0",
"type": "module",
"bin": {
"multica": "./dist/index.js",
"mu": "./dist/index.js"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsup",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@multica/core": "workspace:*",
"@multica/utils": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"tsup": "^8.0.0",
"tsx": "catalog:",
"typescript": "catalog:"
}
}

View file

@ -1,397 +0,0 @@
/**
* Autocomplete Input
*
* Real-time dropdown autocomplete for terminal input
* No external dependencies - uses raw terminal control
*
* Falls back to simple readline when terminal doesn't support advanced features
*/
import * as readline from "readline";
import { colors } from "./colors.js";
export interface AutocompleteOption {
value: string;
label?: string;
}
export interface AutocompleteConfig {
/** Function to get suggestions based on current input */
getSuggestions: (input: string) => AutocompleteOption[];
/** Prompt string */
prompt?: string;
/** Max suggestions to show */
maxSuggestions?: number;
}
// ANSI escape codes
const ESC = "\x1b";
const CLEAR_LINE = `${ESC}[2K`;
const CURSOR_UP = (n: number) => (n > 0 ? `${ESC}[${n}A` : "");
const CURSOR_TO_COL = (n: number) => `${ESC}[${n}G`;
const RESET = `${ESC}[0m`;
const INVERSE = `${ESC}[7m`;
const SHOW_CURSOR = `${ESC}[?25h`;
const CLEAR_TO_END = `${ESC}[J`;
// Strip ANSI escape codes to get visual length
const ANSI_REGEX = /\x1b\[[0-9;]*m/g;
function stripAnsi(str: string): string {
return str.replace(ANSI_REGEX, "");
}
/**
* Get the visual width of a string in terminal columns
* Full-width characters (CJK, etc.) take 2 columns
*/
function getStringWidth(str: string): number {
let width = 0;
for (const char of str) {
const code = char.codePointAt(0);
if (code === undefined) continue;
// Check for full-width characters:
// - CJK Unified Ideographs (Chinese, Japanese Kanji, Korean Hanja)
// - CJK Symbols and Punctuation
// - Hiragana, Katakana
// - Hangul
// - Full-width ASCII and symbols
if (
(code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
(code >= 0x2e80 && code <= 0x9fff) || // CJK
(code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
(code >= 0xfe10 && code <= 0xfe1f) || // Vertical forms
(code >= 0xfe30 && code <= 0xfe6f) || // CJK Compatibility Forms
(code >= 0xff00 && code <= 0xff60) || // Full-width ASCII
(code >= 0xffe0 && code <= 0xffe6) || // Full-width symbols
(code >= 0x20000 && code <= 0x2ffff) // CJK Extension B and beyond
) {
width += 2;
} else {
width += 1;
}
}
return width;
}
/**
* Check if terminal supports advanced cursor control
*/
function isTerminalSupported(): boolean {
// Check TERM environment variable
const term = process.env.TERM;
if (!term) {
return false;
}
// Check if running in known unsupported environments
const unsupportedTerms = ["dumb", "emacs"];
if (unsupportedTerms.includes(term.toLowerCase())) {
return false;
}
// Check if stdout is a TTY
if (!process.stdout.isTTY) {
return false;
}
return true;
}
/**
* Simple readline input (fallback for unsupported terminals)
*/
function simpleInput(config: AutocompleteConfig): Promise<string> {
return new Promise((resolve) => {
const { prompt = "> " } = config;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
rl.question(prompt, (answer) => {
rl.close();
resolve(answer);
});
rl.on("close", () => {
resolve("");
});
});
}
/**
* Read a line with real-time autocomplete dropdown
* Falls back to simple readline on unsupported terminals
*/
export function autocompleteInput(config: AutocompleteConfig): Promise<string> {
// Fall back to simple input if terminal doesn't support advanced features
if (!isTerminalSupported()) {
return simpleInput(config);
}
return new Promise((resolve) => {
const { getSuggestions, prompt = "> ", maxSuggestions = 5 } = config;
const stdin = process.stdin;
const stdout = process.stdout;
let input = "";
let cursorPos = 0;
let suggestions: AutocompleteOption[] = [];
let selectedIndex = -1;
let lastRenderedLines = 0; // Track how many lines we rendered (for cleanup)
// Enable raw mode
if (stdin.isTTY) {
stdin.setRawMode(true);
}
// Set up keypress events
readline.emitKeypressEvents(stdin);
const cleanup = () => {
stdout.write(SHOW_CURSOR);
if (stdin.isTTY) {
stdin.setRawMode(false);
}
stdin.removeListener("keypress", onKeypress);
};
const clearDisplay = () => {
// Move to beginning of current line
stdout.write("\r");
// Clear current line
stdout.write(CLEAR_LINE);
// Clear any suggestion lines below
if (lastRenderedLines > 0) {
stdout.write(CLEAR_TO_END);
}
};
const render = () => {
clearDisplay();
// Write prompt and input
stdout.write(`${prompt}${input}`);
// Calculate cursor position accounting for line wrapping and wide characters
const termWidth = stdout.columns || 80;
const promptVisualWidth = getStringWidth(stripAnsi(prompt));
// Calculate visual width of input up to cursor position
const inputBeforeCursor = input.slice(0, cursorPos);
const inputVisualWidth = getStringWidth(inputBeforeCursor);
const cursorOffset = promptVisualWidth + inputVisualWidth;
// Handle edge case: when cursor is exactly at line boundary,
// show it at end of current line, not start of next line
let cursorCol: number;
if (cursorOffset > 0 && cursorOffset % termWidth === 0) {
cursorCol = termWidth;
} else {
cursorCol = (cursorOffset % termWidth) + 1;
}
// Get and display suggestions if input starts with /
if (input.startsWith("/") && input.length > 1) {
suggestions = getSuggestions(input).slice(0, maxSuggestions);
if (suggestions.length > 0) {
// Ensure selectedIndex is valid
if (selectedIndex >= suggestions.length) {
selectedIndex = suggestions.length - 1;
}
// Move to new line for suggestions
stdout.write("\n");
for (let i = 0; i < suggestions.length; i++) {
const opt = suggestions[i]!;
const isSelected = i === selectedIndex;
const value = isSelected
? `${INVERSE} ${opt.value}${RESET}`
: ` ${colors.suggestionDim(opt.value)}`;
const label = opt.label ? ` ${colors.suggestionLabel(opt.label)}` : "";
const line = `${value}${label}`;
stdout.write(`${CLEAR_LINE}${line}`);
if (i < suggestions.length - 1) {
stdout.write("\n");
}
}
lastRenderedLines = suggestions.length;
// Move cursor back up to input line
stdout.write(CURSOR_UP(suggestions.length));
stdout.write(CURSOR_TO_COL(cursorCol));
} else {
lastRenderedLines = 0;
}
} else {
suggestions = [];
selectedIndex = -1;
lastRenderedLines = 0;
}
// Position cursor correctly within input
stdout.write(CURSOR_TO_COL(cursorCol));
};
const submit = (value: string) => {
clearDisplay();
stdout.write(`${prompt}${value}\n`);
cleanup();
resolve(value);
};
const onKeypress = (_char: string, key: readline.Key) => {
if (!key) return;
// Handle Ctrl+C
if (key.ctrl && key.name === "c") {
clearDisplay();
cleanup();
process.exit(0);
}
// Handle Ctrl+D (EOF)
if (key.ctrl && key.name === "d") {
clearDisplay();
cleanup();
stdout.write("\n");
resolve("");
return;
}
// Handle Enter
if (key.name === "return" || key.name === "enter") {
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
// Use selected suggestion
const selected = suggestions[selectedIndex]!;
submit(selected.value);
} else {
submit(input);
}
return;
}
// Handle Tab - cycle through suggestions or complete selected one
if (key.name === "tab") {
if (suggestions.length > 0) {
if (selectedIndex >= 0) {
// Already have a selection - complete it to input
const selected = suggestions[selectedIndex]!;
input = selected.value + " ";
cursorPos = input.length;
selectedIndex = -1;
render();
} else {
// No selection yet - select first item
if (key.shift) {
selectedIndex = suggestions.length - 1;
} else {
selectedIndex = 0;
}
render();
}
}
return;
}
// Handle arrow keys
if (key.name === "up") {
if (suggestions.length > 0) {
selectedIndex = selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1;
render();
}
return;
}
if (key.name === "down") {
if (suggestions.length > 0) {
selectedIndex = selectedIndex >= suggestions.length - 1 ? 0 : selectedIndex + 1;
render();
}
return;
}
// Handle Escape - clear selection
if (key.name === "escape") {
selectedIndex = -1;
render();
return;
}
// Handle backspace
if (key.name === "backspace") {
if (cursorPos > 0) {
input = input.slice(0, cursorPos - 1) + input.slice(cursorPos);
cursorPos--;
selectedIndex = -1;
render();
}
return;
}
// Handle delete
if (key.name === "delete") {
if (cursorPos < input.length) {
input = input.slice(0, cursorPos) + input.slice(cursorPos + 1);
selectedIndex = -1;
render();
}
return;
}
// Handle left arrow
if (key.name === "left") {
if (cursorPos > 0) {
cursorPos--;
render();
}
return;
}
// Handle right arrow
if (key.name === "right") {
if (cursorPos < input.length) {
cursorPos++;
render();
}
return;
}
// Handle home
if (key.name === "home" || (key.ctrl && key.name === "a")) {
cursorPos = 0;
render();
return;
}
// Handle end
if (key.name === "end" || (key.ctrl && key.name === "e")) {
cursorPos = input.length;
render();
return;
}
// Handle printable characters
if (key.sequence && !key.ctrl && !key.meta) {
const char = key.sequence;
if (char.length === 1 && char.charCodeAt(0) >= 32) {
input = input.slice(0, cursorPos) + char + input.slice(cursorPos);
cursorPos++;
selectedIndex = -1;
render();
}
}
};
stdin.on("keypress", onKeypress);
render();
});
}

View file

@ -1,151 +0,0 @@
/**
* Terminal Colors and Styling
*
* Simple ANSI color utilities for terminal output
*/
// Check if colors should be disabled
const NO_COLOR = process.env.NO_COLOR !== undefined || process.env.TERM === "dumb";
type StyleFn = (s: string) => string;
const identity: StyleFn = (s) => s;
function style(code: number, reset: number = 0): StyleFn {
if (NO_COLOR) return identity;
return (s: string) => `\x1b[${code}m${s}\x1b[${reset}m`;
}
// Basic styles
export const reset = "\x1b[0m";
export const bold = style(1, 22);
export const dim = style(2, 22);
export const italic = style(3, 23);
export const underline = style(4, 24);
export const inverse = style(7, 27);
// Foreground colors
export const black = style(30, 39);
export const red = style(31, 39);
export const green = style(32, 39);
export const yellow = style(33, 39);
export const blue = style(34, 39);
export const magenta = style(35, 39);
export const cyan = style(36, 39);
export const white = style(37, 39);
export const gray = style(90, 39);
// Bright colors
export const brightRed = style(91, 39);
export const brightGreen = style(92, 39);
export const brightYellow = style(93, 39);
export const brightBlue = style(94, 39);
export const brightMagenta = style(95, 39);
export const brightCyan = style(96, 39);
// Background colors
export const bgRed = style(41, 49);
export const bgGreen = style(42, 49);
export const bgYellow = style(43, 49);
export const bgBlue = style(44, 49);
// Semantic colors for the CLI
export const colors = {
// UI elements
prompt: cyan,
promptSymbol: brightCyan,
sessionId: dim,
// Tool output
toolName: yellow,
toolArgs: dim,
toolBullet: cyan,
toolArrow: dim,
toolError: red,
// Messages
error: red,
warning: yellow,
success: green,
info: blue,
// Status bar
statusBg: inverse,
statusLabel: dim,
statusValue: white,
// Welcome banner
bannerBorder: cyan,
bannerText: brightCyan,
// Suggestions
suggestionSelected: inverse,
suggestionDim: dim,
suggestionLabel: gray,
};
// Spinner frames for thinking indicator
export const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
// Alternative spinner styles
export const spinnerStyles = {
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
line: ["-", "\\", "|", "/"],
arc: ["◜", "◠", "◝", "◞", "◡", "◟"],
bounce: ["⠁", "⠂", "⠄", "⠂"],
pulse: ["◯", "◔", "◑", "◕", "●", "◕", "◑", "◔"],
};
/**
* Create a spinner instance
*/
export function createSpinner(options: {
stream?: NodeJS.WritableStream;
frames?: string[];
interval?: number;
} = {}) {
const {
stream = process.stderr,
frames = spinnerFrames,
interval = 80,
} = options;
let frameIndex = 0;
let timer: ReturnType<typeof setInterval> | null = null;
let currentText = "";
const render = () => {
const frame = colors.toolBullet(frames[frameIndex % frames.length]!);
stream.write(`\r\x1b[K${frame} ${currentText}`);
frameIndex++;
};
return {
start(text: string) {
currentText = text;
frameIndex = 0;
if (timer) clearInterval(timer);
render();
timer = setInterval(render, interval);
},
update(text: string) {
currentText = text;
},
stop(finalText?: string) {
if (timer) {
clearInterval(timer);
timer = null;
}
stream.write("\r\x1b[K");
if (finalText) {
stream.write(finalText + "\n");
}
},
isSpinning() {
return timer !== null;
},
};
}

View file

@ -1,640 +0,0 @@
/**
* Chat command - Interactive REPL mode
*
* Usage:
* multica chat [options]
* multica [options] (default command)
*/
import * as readline from "readline";
import { Agent } from "@multica/core";
import type { AgentOptions } from "@multica/core";
import { SkillManager } from "@multica/core";
import { autocompleteInput, type AutocompleteOption } from "../autocomplete.js";
import { colors, dim, cyan, brightCyan, yellow, green, gray, red } from "../colors.js";
import {
getProviderList,
getCurrentProvider,
getLoginInstructions,
getProviderMeta,
type ProviderInfo,
} from "@multica/core";
type ChatOptions = {
profile?: string | undefined;
provider?: string | undefined;
model?: string | undefined;
system?: string | undefined;
thinking?: string | undefined;
cwd?: string | undefined;
session?: string | undefined;
help?: boolean;
};
const COMMANDS = {
help: "Show this help message",
exit: "Exit the CLI (aliases: quit, q)",
clear: "Clear the current session and start fresh",
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() {
console.log(`
${cyan("Usage:")} multica chat [options]
multica [options]
${cyan("Options:")}
${yellow("--profile")} ID Load agent profile
${yellow("--provider")} NAME LLM provider (openai, anthropic, kimi, etc.)
${yellow("--model")} NAME Model name
${yellow("--system")} TEXT System prompt (ignored if --profile set)
${yellow("--thinking")} LEVEL Thinking level
${yellow("--cwd")} DIR Working directory
${yellow("--session")} ID Session ID to resume
${yellow("--help")}, -h Show this help
${cyan("Interactive Commands:")}
`);
for (const [cmd, desc] of Object.entries(COMMANDS)) {
console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`);
}
console.log();
}
function parseArgs(argv: string[]): ChatOptions {
const args = [...argv];
const opts: ChatOptions = {};
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--help" || arg === "-h") {
opts.help = true;
break;
}
if (arg === "--profile") {
opts.profile = args.shift();
continue;
}
if (arg === "--provider") {
opts.provider = args.shift();
continue;
}
if (arg === "--model") {
opts.model = args.shift();
continue;
}
if (arg === "--system") {
opts.system = args.shift();
continue;
}
if (arg === "--thinking") {
opts.thinking = args.shift();
continue;
}
if (arg === "--cwd") {
opts.cwd = args.shift();
continue;
}
if (arg === "--session") {
opts.session = args.shift();
continue;
}
}
return opts;
}
function printWelcome(sessionId: string, opts: ChatOptions) {
const border = cyan("│");
const topBorder = cyan("╭─────────────────────────────────────────╮");
const bottomBorder = cyan("╰─────────────────────────────────────────╯");
console.log(topBorder);
console.log(`${border} ${brightCyan("Super Multica Interactive CLI")} ${border}`);
console.log(bottomBorder);
// Show configuration
const configLines: string[] = [];
configLines.push(`${dim("Session:")} ${gray(sessionId.slice(0, 8))}...`);
if (opts.profile) {
configLines.push(`${dim("Profile:")} ${yellow(opts.profile)}`);
}
if (opts.provider) {
configLines.push(`${dim("Provider:")} ${green(opts.provider)}`);
}
if (opts.model) {
configLines.push(`${dim("Model:")} ${green(opts.model)}`);
}
console.log(configLines.join(" "));
console.log(`${dim("Type")} ${cyan("/help")} ${dim("for commands,")} ${cyan("/exit")} ${dim("to quit.")}`);
console.log("");
}
function printCommandHelp(skillManager?: SkillManager) {
console.log(`\n${cyan("Built-in commands:")}`);
for (const [cmd, desc] of Object.entries(COMMANDS)) {
console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`);
}
// Show skill commands if available
if (skillManager) {
const reservedNames = new Set(Object.keys(COMMANDS));
const skillCommands = skillManager.getSkillCommands({ reservedNames });
if (skillCommands.length > 0) {
console.log(`\n${cyan("Skill commands:")}`);
for (const cmd of skillCommands) {
console.log(` ${yellow(`/${cmd.name}`.padEnd(14))} ${dim(cmd.description)}`);
}
}
}
console.log(`\n${dim("Just type your message and press Enter to chat with the agent.")}`);
console.log("");
}
/**
* Status Bar - renders a persistent status line at the bottom of the terminal
*/
class StatusBar {
private enabled: boolean;
private currentStatus: string = "";
private stream: NodeJS.WriteStream;
constructor(stream: NodeJS.WriteStream = process.stdout) {
this.stream = stream;
this.enabled = stream.isTTY === true;
}
update(parts: { session?: string; provider?: string; model?: string; tokens?: number }) {
if (!this.enabled) return;
const segments: string[] = [];
if (parts.session) {
segments.push(`${dim("session:")}${gray(parts.session.slice(0, 8))}`);
}
if (parts.provider) {
segments.push(`${dim("provider:")}${green(parts.provider)}`);
}
if (parts.model) {
segments.push(`${dim("model:")}${yellow(parts.model)}`);
}
if (parts.tokens !== undefined) {
segments.push(`${dim("tokens:")}${cyan(String(parts.tokens))}`);
}
this.currentStatus = segments.join(" ");
this.render();
}
private render() {
if (!this.enabled || !this.currentStatus) return;
const termWidth = this.stream.columns || 80;
const termHeight = this.stream.rows || 24;
const statusLine = ` ${this.currentStatus} `.slice(0, termWidth);
this.stream.write(
`\x1b[s` + // Save cursor
`\x1b[${termHeight};1H` + // Move to last row
`\x1b[7m` + // Inverse video
`\x1b[2K` + // Clear line
statusLine.padEnd(termWidth) +
`\x1b[0m` + // Reset
`\x1b[u` // Restore cursor
);
}
clear() {
if (!this.enabled) return;
const termHeight = this.stream.rows || 24;
this.stream.write(
`\x1b[s` +
`\x1b[${termHeight};1H` +
`\x1b[2K` +
`\x1b[u`
);
this.currentStatus = "";
}
hide() {
this.clear();
}
show() {
this.render();
}
}
class InteractiveCLI {
private agent: Agent;
private opts: ChatOptions;
private rl: readline.Interface | null = null;
private multilineMode = false;
private multilineBuffer: string[] = [];
private running = true;
private skillManager: SkillManager;
private reservedNames: Set<string>;
private statusBar: StatusBar;
constructor(opts: ChatOptions) {
this.opts = opts;
this.agent = this.createAgent(opts.session);
this.statusBar = new StatusBar();
this.skillManager = new SkillManager({
profileId: opts.profile,
});
this.reservedNames = new Set(Object.keys(COMMANDS));
process.on("SIGINT", () => {
this.statusBar.clear();
console.log(`\n${dim("Goodbye!")}`);
process.exit(0);
});
}
private getReadline(): readline.Interface {
if (!this.rl) {
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
this.rl.on("close", () => {
this.running = false;
this.statusBar.clear();
console.log(`\n${dim("Goodbye!")}`);
process.exit(0);
});
}
return this.rl;
}
private closeReadline() {
if (this.rl) {
this.rl.close();
this.rl = null;
}
}
private getSuggestions(input: string): AutocompleteOption[] {
if (!input.startsWith("/")) {
return [];
}
const prefix = input.slice(1).toLowerCase();
const suggestions: AutocompleteOption[] = [];
for (const [cmd, desc] of Object.entries(COMMANDS)) {
if (cmd.toLowerCase().startsWith(prefix)) {
suggestions.push({
value: `/${cmd}`,
label: desc.slice(0, 40),
});
}
}
const skillCommands = this.skillManager.getSkillCommands({ reservedNames: this.reservedNames });
for (const cmd of skillCommands) {
if (cmd.name.toLowerCase().startsWith(prefix)) {
suggestions.push({
value: `/${cmd.name}`,
label: cmd.description.slice(0, 40),
});
}
}
suggestions.sort((a, b) => {
if (a.value.length !== b.value.length) return a.value.length - b.value.length;
return a.value.localeCompare(b.value);
});
return suggestions;
}
private createAgent(sessionId?: string): Agent {
return new Agent({
profileId: this.opts.profile,
provider: this.opts.provider,
model: this.opts.model,
systemPrompt: this.opts.system,
thinkingLevel: this.opts.thinking as AgentOptions["thinkingLevel"],
cwd: this.opts.cwd,
sessionId,
});
}
private prompt(): string {
if (this.multilineMode) {
return this.multilineBuffer.length === 0 ? cyan(">>> ") : cyan("... ");
}
return `${brightCyan("You:")} `;
}
private updateStatusBar() {
const statusUpdate: { session?: string; provider?: string; model?: string; tokens?: number } = {
session: this.agent.sessionId,
provider: this.opts.provider ?? "default",
};
if (this.opts.model) {
statusUpdate.model = this.opts.model;
}
this.statusBar.update(statusUpdate);
}
async run() {
printWelcome(this.agent.sessionId, this.opts);
this.updateStatusBar();
await this.loop();
}
private async loop() {
while (this.running) {
let input: string;
if (this.multilineMode) {
const lineInput = await this.readline(this.prompt());
if (lineInput === null) break;
input = lineInput;
if (input === ".") {
const fullInput = this.multilineBuffer.join("\n");
this.multilineBuffer = [];
this.multilineMode = false;
this.closeReadline();
if (fullInput.trim()) {
await this.handleInput(fullInput);
}
} else {
this.multilineBuffer.push(input);
}
continue;
}
try {
this.statusBar.hide();
input = await autocompleteInput({
prompt: this.prompt(),
getSuggestions: (text) => this.getSuggestions(text),
maxSuggestions: 8,
});
this.statusBar.show();
} catch {
break;
}
const trimmed = input.trim();
if (!trimmed) continue;
if (trimmed.startsWith("/")) {
const handled = await this.handleCommand(trimmed);
if (!handled) {
await this.handleInput(trimmed);
}
} else {
await this.handleInput(trimmed);
}
}
}
private readline(prompt: string): Promise<string | null> {
return new Promise((resolve) => {
this.getReadline().question(prompt, (answer) => {
resolve(answer);
});
});
}
private async handleCommand(input: string): Promise<boolean> {
const cmd = input.slice(1).toLowerCase().split(/\s+/)[0];
switch (cmd) {
case "help":
printCommandHelp(this.skillManager);
return true;
case "exit":
case "quit":
case "q":
this.statusBar.clear();
console.log(dim("Goodbye!"));
this.running = false;
this.closeReadline();
process.exit(0);
return true;
case "clear":
this.agent = this.createAgent();
this.updateStatusBar();
console.log(`${green("Session cleared.")} ${dim("New session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
return true;
case "session":
console.log(`${dim("Current session:")} ${cyan(this.agent.sessionId)}\n`);
return true;
case "new":
this.agent = this.createAgent();
this.updateStatusBar();
console.log(`${green("Started new session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
return true;
case "multiline":
this.multilineMode = !this.multilineMode;
if (this.multilineMode) {
console.log(`${green("Multi-line mode enabled.")} ${dim("End input with a line containing only '.'")}`);
this.multilineBuffer = [];
} else {
console.log(dim("Multi-line mode disabled."));
this.multilineBuffer = [];
this.closeReadline();
}
return true;
case "provider":
this.showProviderStatus();
return true;
case "model":
this.handleModelCommand(input);
return true;
default:
const invocation = this.skillManager.resolveCommand(input);
if (invocation) {
const skillPrompt = invocation.args
? `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}\n\nUser request: ${invocation.args}`
: `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}`;
await this.handleInput(skillPrompt);
return true;
}
return false;
}
}
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 <model-name>`)}`);
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 <id>")} ${dim("Start chat with specific provider")}`);
console.log(` ${yellow("multica --provider <id> --model <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("");
this.statusBar.hide();
const result = await this.agent.run(input);
this.statusBar.show();
if (result.error) {
console.error(`\n${colors.error(`Error: ${result.error}`)}`);
}
console.log("");
} catch (err) {
console.error(`\n${colors.error(`Error: ${err instanceof Error ? err.message : String(err)}`)}`);
console.log("");
}
}
}
export async function chatCommand(args: string[]): Promise<void> {
const opts = parseArgs(args);
if (opts.help) {
printHelp();
return;
}
if (!process.stdin.isTTY) {
console.error(colors.error("Error: Interactive mode requires a TTY. Use 'multica run' for non-interactive mode."));
process.exit(1);
}
const cli = new InteractiveCLI(opts);
await cli.run();
}

View file

@ -1,173 +0,0 @@
/**
* Credentials command - Manage credentials and environment files
*
* Usage:
* multica credentials init Create credential files
* multica credentials show Show credential paths
* multica credentials edit Open credentials in editor
*/
import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
import { dirname } from "node:path";
import { getCredentialsPath } from "@multica/core";
import { cyan, yellow, green, dim, red } from "../colors.js";
type Command = "init" | "show" | "edit" | "help";
interface CredentialsOptions {
command: Command;
force: boolean;
pathOverride?: string | undefined;
}
function printHelp() {
console.log(`
${cyan("Usage:")} multica credentials <command> [options]
${cyan("Commands:")}
${yellow("init")} Create credentials.json5
${yellow("show")} Show credential file paths
${yellow("edit")} Open credentials directory in file manager
${yellow("help")} Show this help
${cyan("Options for 'init':")}
${yellow("--force")} Overwrite existing files
${yellow("--path")} PATH Override credentials path
${cyan("Files Created:")}
~/.super-multica/credentials.json5 LLM providers + tools config
${dim("Skill-specific API keys are stored in .env files within each skill's directory.")}
${dim("Example: ~/.super-multica/skills/<skill-id>/.env")}
${cyan("Examples:")}
${dim("# Initialize credentials")}
multica credentials init
${dim("# Force overwrite")}
multica credentials init --force
`);
}
function parseArgs(argv: string[]): CredentialsOptions {
const args = [...argv];
const opts: CredentialsOptions = {
command: "help",
force: false,
};
const positional: string[] = [];
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--help" || arg === "-h") {
opts.command = "help";
return opts;
}
if (arg === "--force" || arg === "-f") {
opts.force = true;
continue;
}
if (arg === "--path") {
opts.pathOverride = args.shift();
continue;
}
positional.push(arg);
}
opts.command = (positional[0] || "help") as Command;
return opts;
}
function buildCoreTemplate(): string {
return `{
version: 1,
llm: {
// provider: "openai",
providers: {
// openai: { apiKey: "sk-...", baseUrl: "https://api.openai.com/v1", model: "gpt-4.1" }
}
},
tools: {
// brave: { apiKey: "brv-..." },
// perplexity: { apiKey: "pplx-...", baseUrl: "https://api.perplexity.ai", model: "perplexity/sonar-pro" }
}
}
`;
}
function cmdInit(opts: CredentialsOptions): void {
const path = opts.pathOverride ?? getCredentialsPath();
if (existsSync(path) && !opts.force) {
console.error(`${red("Error:")} Credentials file already exists at ${path}`);
console.error("Use --force to overwrite.");
process.exit(1);
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, buildCoreTemplate(), "utf8");
chmodSync(path, 0o600);
console.log(`${green("Created:")} ${path}`);
console.log("");
console.log("Edit this file to add your LLM provider credentials.");
console.log(`${dim("Skill-specific API keys go in .env files within each skill's directory.")}`);
}
function cmdShow(): void {
const credentialsPath = getCredentialsPath();
console.log(`\n${cyan("Credential Files:")}\n`);
console.log(`${yellow("credentials.json5")}`);
console.log(` Path: ${credentialsPath}`);
console.log(` Exists: ${existsSync(credentialsPath) ? green("Yes") : red("No")}`);
console.log("");
console.log(`${dim("Skill-specific API keys are stored in .env files within each skill's directory.")}`);
console.log("");
if (!existsSync(credentialsPath)) {
console.log(`${dim("Run 'multica credentials init' to create missing files.")}`);
}
}
async function cmdEdit(): Promise<void> {
const credentialsPath = getCredentialsPath();
const dir = dirname(credentialsPath);
if (!existsSync(dir)) {
console.error(`${red("Error:")} Credentials directory does not exist: ${dir}`);
console.error("Run 'multica credentials init' first.");
process.exit(1);
}
const { spawn } = await import("node:child_process");
// Open in default file manager
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
spawn(cmd, [dir], { detached: true, stdio: "ignore" }).unref();
console.log(`${green("Opened:")} ${dir}`);
}
export async function credentialsCommand(args: string[]): Promise<void> {
const opts = parseArgs(args);
switch (opts.command) {
case "init":
cmdInit(opts);
break;
case "show":
cmdShow();
break;
case "edit":
await cmdEdit();
break;
case "help":
default:
printHelp();
break;
}
}

View file

@ -1,466 +0,0 @@
/**
* Cron command - Manage scheduled tasks
*
* Usage:
* multica cron status Show cron service status
* multica cron list List all jobs
* multica cron add <options> Add a new job
* multica cron run <id> Run a job immediately
* multica cron enable <id> Enable a job
* multica cron disable <id> Disable a job
* multica cron remove <id> Remove a job
* multica cron logs <id> Show job run logs
*/
import { cyan, yellow, green, dim, red, brightCyan } from "../colors.js";
import {
getCronService,
formatSchedule,
formatDuration,
parseTimeInput,
parseIntervalInput,
isValidCronExpr,
type CronSchedule,
type CronJobInput,
} from "@multica/core";
type Command = "status" | "list" | "add" | "run" | "enable" | "disable" | "remove" | "logs" | "help";
function printHelp() {
console.log(`
${brightCyan("Cron")} - Scheduled Task Management
${cyan("Usage:")} multica cron <command> [options]
${cyan("Commands:")}
${yellow("status")} Show cron service status
${yellow("list")} List all scheduled jobs
${yellow("add")} [options] Create a new scheduled job
${yellow("run")} <id> Run a job immediately
${yellow("enable")} <id> Enable a disabled job
${yellow("disable")} <id> Disable a job (keeps schedule)
${yellow("remove")} <id> Delete a job
${yellow("logs")} <id> Show run history for a job
${yellow("help")} Show this help
${cyan("Add Options:")}
${yellow("-n, --name")} <name> Job name (required)
${yellow("--at")} <time> One-time at ISO timestamp or relative (e.g., "10m", "2h")
${yellow("--every")} <interval> Repeat interval (e.g., "30m", "1h", "1d")
${yellow("--cron")} <expr> Cron expression (5-field, e.g., "0 9 * * *")
${yellow("--tz")} <timezone> Timezone for cron expression (e.g., "Asia/Shanghai")
${yellow("--message")} <text> Message to inject or prompt for agent
${yellow("--isolated")} Run in isolated session (default: main)
${yellow("--delete-after-run")} Delete after one-time run completes
${cyan("Examples:")}
${dim("# Show service status")}
multica cron status
${dim("# 10 minutes from now (one-shot)")}
multica cron add -n "Reminder" --at "10m" --message "Time to take a break!"
${dim("# Every day at 9am Beijing time")}
multica cron add -n "Morning check" --cron "0 9 * * *" --tz "Asia/Shanghai" \\
--message "Good morning! Check your tasks."
${dim("# Every 30 minutes")}
multica cron add -n "Health check" --every "30m" --message "System health check"
${dim("# Run a job now")}
multica cron run abc12345
${dim("# View job logs")}
multica cron logs abc12345
`);
}
function cmdStatus() {
const service = getCronService();
const status = service.status();
console.log(`\n${brightCyan("Cron Service Status")}\n`);
console.log(` ${cyan("Running:")} ${status.running ? green("Yes") : red("No")}`);
console.log(` ${cyan("Enabled:")} ${status.enabled ? green("Yes") : red("No")}`);
console.log(` ${cyan("Jobs:")} ${status.jobCount} total, ${status.enabledJobCount} enabled`);
if (status.nextWakeAtMs) {
const nextWake = new Date(status.nextWakeAtMs);
const relativeMs = status.nextWakeAtMs - Date.now();
console.log(` ${cyan("Next wake:")} ${nextWake.toLocaleString()} (in ${formatDuration(relativeMs)})`);
} else {
console.log(` ${cyan("Next wake:")} ${dim("none scheduled")}`);
}
console.log(` ${cyan("Store:")} ${dim(status.storePath)}`);
console.log("");
}
function cmdList(args: string[]) {
const service = getCronService();
const showEnabled = args.includes("--enabled");
const showDisabled = args.includes("--disabled");
let filter: { enabled?: boolean } | undefined;
if (showEnabled) filter = { enabled: true };
else if (showDisabled) filter = { enabled: false };
const jobs = service.list(filter);
if (jobs.length === 0) {
console.log("\nNo cron jobs found.");
console.log(`${dim("Create one with:")} multica cron add -n "Name" --at "10m" --message "Hello"`);
return;
}
console.log(`\n${brightCyan("Scheduled Jobs")}\n`);
for (const job of jobs) {
const statusIcon = job.enabled ? green("✓") : red("✗");
const shortId = job.id.slice(0, 8);
console.log(`${statusIcon} ${yellow(job.name)} ${dim(`(${shortId})`)}`);
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
console.log(` ${cyan("Target:")} ${job.sessionTarget}`);
if (job.state.nextRunAtMs) {
const nextRun = new Date(job.state.nextRunAtMs);
const relativeMs = job.state.nextRunAtMs - Date.now();
if (relativeMs > 0) {
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()} ${dim(`(in ${formatDuration(relativeMs)})`)}`);
} else {
console.log(` ${cyan("Next run:")} ${dim("pending execution")}`);
}
}
if (job.state.lastRunAtMs) {
const lastRun = new Date(job.state.lastRunAtMs);
const statusColor = job.state.lastStatus === "ok" ? green : job.state.lastStatus === "error" ? red : yellow;
console.log(` ${cyan("Last run:")} ${lastRun.toLocaleString()} ${statusColor(`[${job.state.lastStatus}]`)} ${dim(`(${formatDuration(job.state.lastDurationMs ?? 0)})`)}`);
if (job.state.lastError) {
console.log(` ${red("Error:")} ${job.state.lastError}`);
}
}
console.log("");
}
console.log(dim(`Total: ${jobs.length} job(s)`));
}
function cmdAdd(args: string[]) {
const service = getCronService();
// Parse arguments
let name: string | undefined;
let at: string | undefined;
let every: string | undefined;
let cronExpr: string | undefined;
let tz: string | undefined;
let message: string | undefined;
let isolated = false;
let deleteAfterRun = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case "-n":
case "--name":
name = args[++i];
break;
case "--at":
at = args[++i];
break;
case "--every":
every = args[++i];
break;
case "--cron":
cronExpr = args[++i];
break;
case "--tz":
tz = args[++i];
break;
case "--message":
message = args[++i];
break;
case "--isolated":
isolated = true;
break;
case "--delete-after-run":
deleteAfterRun = true;
break;
}
}
// Validate
if (!name) {
console.error(`${red("Error:")} --name is required`);
console.error(`${dim("Usage:")} multica cron add -n "Job name" --at "10m" --message "Hello"`);
process.exit(1);
}
if (!message) {
console.error(`${red("Error:")} --message is required`);
process.exit(1);
}
// Parse schedule
let schedule: CronSchedule;
if (at) {
const atMs = parseTimeInput(at);
if (!atMs) {
console.error(`${red("Error:")} Invalid time format: ${at}`);
console.error(`${dim("Examples:")} "10m", "2h", "2024-12-31T23:59:00Z"`);
process.exit(1);
}
schedule = { kind: "at", atMs };
} else if (every) {
const everyMs = parseIntervalInput(every);
if (!everyMs) {
console.error(`${red("Error:")} Invalid interval format: ${every}`);
console.error(`${dim("Examples:")} "30s", "5m", "2h", "1d"`);
process.exit(1);
}
schedule = { kind: "every", everyMs };
} else if (cronExpr) {
if (!isValidCronExpr(cronExpr, tz)) {
console.error(`${red("Error:")} Invalid cron expression: ${cronExpr}`);
console.error(`${dim("Format:")} "minute hour day month weekday" (e.g., "0 9 * * *")`);
process.exit(1);
}
// Only include tz if it's defined (exactOptionalPropertyTypes)
schedule = tz ? { kind: "cron", expr: cronExpr, tz } : { kind: "cron", expr: cronExpr };
} else {
console.error(`${red("Error:")} Must specify --at, --every, or --cron`);
process.exit(1);
}
// Create job
const input: CronJobInput = {
name,
enabled: true,
deleteAfterRun,
schedule,
sessionTarget: isolated ? "isolated" : "main",
wakeMode: "now",
payload: {
kind: "system-event",
text: message,
},
};
const job = service.add(input);
console.log(`\n${green("Created job:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}`);
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
if (job.state.nextRunAtMs) {
const nextRun = new Date(job.state.nextRunAtMs);
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()}`);
}
console.log("");
}
async function cmdRun(args: string[]) {
const service = getCronService();
const jobId = args[0];
const force = args.includes("--force");
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
console.error(`${dim("Usage:")} multica cron run <id> [--force]`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
console.error("Please provide a more specific ID.");
process.exit(1);
}
const job = matches[0]!;
console.log(`Running job: ${job.name} (${job.id.slice(0, 8)})...`);
const result = await service.run(job.id, force);
if (result.ok) {
console.log(`${green("Success:")} Job executed`);
} else {
console.error(`${red("Error:")} ${result.reason}`);
process.exit(1);
}
}
function cmdEnableDisable(args: string[], enabled: boolean) {
const service = getCronService();
const jobId = args[0];
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
process.exit(1);
}
const job = matches[0]!;
service.update(job.id, { enabled });
const action = enabled ? "Enabled" : "Disabled";
console.log(`${green(action + ":")} ${job.name} (${job.id.slice(0, 8)})`);
}
function cmdRemove(args: string[]) {
const service = getCronService();
const jobId = args[0];
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
process.exit(1);
}
const job = matches[0]!;
service.remove(job.id);
console.log(`${green("Removed:")} ${job.name} (${job.id.slice(0, 8)})`);
}
function cmdLogs(args: string[]) {
const service = getCronService();
const jobId = args[0];
const limitArg = args.indexOf("--limit");
const limitStr = limitArg !== -1 ? args[limitArg + 1] : undefined;
const limit = limitStr ? parseInt(limitStr, 10) : 20;
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
console.error(`${dim("Usage:")} multica cron logs <id> [--limit N]`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
process.exit(1);
}
const job = matches[0]!;
const logs = service.getRunLogs(job.id, limit);
console.log(`\n${brightCyan("Run Logs:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}\n`);
if (logs.length === 0) {
console.log(dim("No run logs found."));
return;
}
for (const log of logs) {
const timestamp = new Date(log.ts).toLocaleString();
const statusColor = log.status === "ok" ? green : log.status === "error" ? red : yellow;
const duration = log.durationMs ? formatDuration(log.durationMs) : "-";
console.log(` ${dim(timestamp)} ${statusColor(`[${log.status}]`)} ${dim(`(${duration})`)}`);
if (log.error) {
console.log(` ${red("Error:")} ${log.error}`);
}
if (log.summary) {
console.log(` ${dim(log.summary)}`);
}
}
console.log(`\n${dim(`Showing ${logs.length} most recent entries`)}`);
}
export async function cronCommand(args: string[]): Promise<void> {
const command = (args[0] || "help") as Command;
const restArgs = args.slice(1);
if (args.includes("--help") || args.includes("-h")) {
printHelp();
return;
}
// Ensure service is started
const service = getCronService();
await service.start();
switch (command) {
case "status":
cmdStatus();
break;
case "list":
cmdList(restArgs);
break;
case "add":
cmdAdd(restArgs);
break;
case "run":
await cmdRun(restArgs);
break;
case "enable":
cmdEnableDisable(restArgs, true);
break;
case "disable":
cmdEnableDisable(restArgs, false);
break;
case "remove":
cmdRemove(restArgs);
break;
case "logs":
cmdLogs(restArgs);
break;
case "help":
default:
printHelp();
break;
}
}

View file

@ -1,180 +0,0 @@
/**
* Dev command - Start development servers
*
* Usage:
* multica dev Start desktop app (with embedded Hub)
* multica dev gateway Start gateway only (:3000) - for remote clients
* multica dev web Start web app only (:3001)
* multica dev all Start all services (gateway + web)
*/
import { spawn } from "node:child_process";
import { cyan, yellow, green, dim, red } from "../colors.js";
type Service = "all" | "gateway" | "web" | "desktop" | "help";
function printHelp() {
console.log(`
${cyan("Usage:")} multica dev [service]
${cyan("Services:")}
${yellow("(default)")} Start Desktop app (with embedded Hub)
${yellow("gateway")} Start Gateway server (:3000) - for remote clients
${yellow("web")} Start Web app (:3001)
${yellow("all")} Start all services (gateway + web)
${yellow("help")} Show this help
${cyan("Architecture:")}
Desktop App (standalone)
Embedded Hub + Agent Engine
(Optional) Gateway connection for remote access
Web App (requires Gateway)
Gateway (WebSocket, :3000)
Hub + Agent Engine
${cyan("Examples:")}
${dim("# Start desktop app (recommended for local development)")}
multica dev
${dim("# Start desktop with remote Gateway for mobile access")}
GATEWAY_URL=http://localhost:3000 multica dev &
multica dev gateway
${dim("# Start web app with gateway")}
multica dev gateway &
multica dev web
`);
}
interface DevOptions {
service: Service;
watch: boolean;
}
function parseArgs(argv: string[]): DevOptions {
const args = [...argv];
let service: Service = "desktop";
let watch = true;
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--help" || arg === "-h") {
return { service: "help", watch };
}
if (arg === "--no-watch") {
watch = false;
continue;
}
// Service name
if (["gateway", "web", "desktop", "all", "help"].includes(arg)) {
service = arg as Service;
}
}
return { service, watch };
}
function runCommand(command: string, args: string[], options: { name: string; color: string }) {
console.log(`${options.color}[${options.name}]${"\x1b[0m"} Starting...`);
const child = spawn(command, args, {
stdio: "inherit",
shell: true,
});
child.on("error", (err) => {
console.error(`${red(`[${options.name}]`)} Error: ${err.message}`);
});
child.on("exit", (code) => {
if (code !== 0 && code !== null) {
console.error(`${red(`[${options.name}]`)} Exited with code ${code}`);
}
});
return child;
}
async function startGateway(watch: boolean) {
const watchFlag = watch ? "--watch" : "";
return runCommand("tsx", [watchFlag, "src/gateway/main.ts"].filter(Boolean), {
name: "gateway",
color: "\x1b[34m", // blue
});
}
async function startWeb() {
return runCommand("pnpm", ["--filter", "@multica/web", "dev"], {
name: "web",
color: "\x1b[32m", // green
});
}
async function startDesktop() {
return runCommand("pnpm", ["--filter", "@multica/desktop", "dev"], {
name: "desktop",
color: "\x1b[35m", // magenta
});
}
async function startAll(watch: boolean) {
console.log(`\n${cyan("Starting all services...")}\n`);
console.log(` ${"\x1b[34m"}Gateway${"\x1b[0m"} → http://localhost:3000`);
console.log(` ${"\x1b[32m"}Web${"\x1b[0m"} → http://localhost:3001`);
console.log("");
// Start all services
const gateway = await startGateway(watch);
const web = await startWeb();
// Handle Ctrl+C
const cleanup = () => {
console.log(`\n${dim("Stopping all services...")}`);
gateway.kill();
web.kill();
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// Wait for all to exit
await Promise.all([
new Promise((resolve) => gateway.on("exit", resolve)),
new Promise((resolve) => web.on("exit", resolve)),
]);
}
export async function devCommand(args: string[]): Promise<void> {
const opts = parseArgs(args);
switch (opts.service) {
case "gateway":
console.log(`\n${cyan("Starting Gateway...")} → http://localhost:3000\n`);
await startGateway(opts.watch);
break;
case "web":
console.log(`\n${cyan("Starting Web App...")} → http://localhost:3001\n`);
await startWeb();
break;
case "desktop":
console.log(`\n${cyan("Starting Desktop App...")}\n`);
await startDesktop();
break;
case "all":
await startAll(opts.watch);
break;
case "help":
default:
printHelp();
break;
}
}

View file

@ -1,391 +0,0 @@
/**
* Profile command - Manage agent profiles
*
* Usage:
* multica profile list List all profiles
* multica profile new <id> Create a new profile
* multica profile show <id> Show profile contents
* multica profile edit <id> Open profile in file manager
* multica profile delete <id> Delete a profile
*/
import { existsSync, readdirSync, rmSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import * as readline from "node:readline";
import {
createAgentProfile,
loadAgentProfile,
getProfileDir,
profileExists,
} from "@multica/core";
import { DATA_DIR } from "@multica/utils";
import { cyan, yellow, green, dim, red, brightCyan, gray, colors } from "../colors.js";
import { Agent } from "@multica/core";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SETUP_SKILL_PATH = join(__dirname, "../../../../skills/profile-setup/SKILL.md");
const PROFILES_DIR = join(DATA_DIR, "agent-profiles");
type Command = "new" | "list" | "show" | "edit" | "delete" | "setup" | "help";
function printHelp() {
console.log(`
${cyan("Usage:")} multica profile <command> [options]
${cyan("Commands:")}
${yellow("list")} List all profiles
${yellow("new")} <id> Create a new profile
${yellow("setup")} <id> Interactive setup for a profile
${yellow("show")} <id> Show profile contents
${yellow("edit")} <id> Open profile directory in file manager
${yellow("delete")} <id> Delete a profile
${yellow("help")} Show this help
${cyan("Profile Structure:")}
Each profile is a directory containing:
- soul.md Agent identity, personality and behavior
- user.md Information about the user
- workspace.md Workspace rules and conventions
${cyan("Examples:")}
${dim("# Create a new profile")}
multica profile new my-agent
${dim("# Interactive setup")}
multica profile setup my-agent
${dim("# List all profiles")}
multica profile list
${dim("# Use a profile")}
multica chat --profile my-agent
`);
}
function cmdNew(profileId: string | undefined) {
if (!profileId) {
console.error("Error: Profile ID is required");
console.error("Usage: multica profile new <id>");
process.exit(1);
}
// Validate profile ID
if (!/^[a-zA-Z0-9_-]+$/.test(profileId)) {
console.error("Error: Profile ID can only contain letters, numbers, hyphens, and underscores");
process.exit(1);
}
if (profileExists(profileId)) {
console.error(`Error: Profile "${profileId}" already exists`);
console.error(`Location: ${getProfileDir(profileId)}`);
process.exit(1);
}
const profile = createAgentProfile(profileId);
const dir = getProfileDir(profileId);
console.log(`${green("Created profile:")} ${yellow(profile.id)}`);
console.log(`${dim("Location:")} ${dir}`);
console.log("");
console.log("Files created:");
console.log(" - soul.md (identity, personality and behavior)");
console.log(" - user.md (information about the user)");
console.log(" - workspace.md (workspace rules and conventions)");
console.log("");
console.log("Run interactive setup to personalize your agent:");
console.log(` multica profile setup ${profileId}`);
console.log("");
console.log("Or start chatting directly:");
console.log(` multica chat --profile ${profileId}`);
}
function cmdList() {
if (!existsSync(PROFILES_DIR)) {
console.log("No profiles found.");
console.log(`Create one with: multica profile new <id>`);
return;
}
const entries = readdirSync(PROFILES_DIR, { withFileTypes: true });
const profiles = entries.filter((e) => e.isDirectory()).map((e) => e.name);
if (profiles.length === 0) {
console.log("No profiles found.");
console.log(`Create one with: multica profile new <id>`);
return;
}
console.log(`\n${cyan("Available profiles:")}\n`);
for (const id of profiles) {
const dir = getProfileDir(id);
console.log(` ${yellow(id)}`);
console.log(` ${dim(dir)}`);
}
console.log("");
console.log(`${dim(`Total: ${profiles.length} profile(s)`)}`);
}
function cmdShow(profileId: string | undefined) {
if (!profileId) {
console.error("Error: Profile ID is required");
console.error("Usage: multica profile show <id>");
process.exit(1);
}
const profile = loadAgentProfile(profileId);
if (!profile) {
console.error(`Error: Profile "${profileId}" not found`);
console.error(`Create it with: multica profile new ${profileId}`);
process.exit(1);
}
console.log(`\n${cyan("Profile:")} ${yellow(profile.id)}`);
console.log(`${dim("Location:")} ${getProfileDir(profileId)}`);
console.log("");
if (profile.soul) {
console.log(`${green("=== soul.md ===")}`);
console.log(profile.soul.trim());
console.log("");
}
if (profile.user) {
console.log(`${green("=== user.md ===")}`);
console.log(profile.user.trim());
console.log("");
}
if (profile.workspace) {
console.log(`${green("=== workspace.md ===")}`);
console.log(profile.workspace.trim());
console.log("");
}
}
async function cmdEdit(profileId: string | undefined) {
if (!profileId) {
console.error("Error: Profile ID is required");
console.error("Usage: multica profile edit <id>");
process.exit(1);
}
if (!profileExists(profileId)) {
console.error(`Error: Profile "${profileId}" not found`);
console.error(`Create it with: multica profile new ${profileId}`);
process.exit(1);
}
const dir = getProfileDir(profileId);
const { spawn } = await import("node:child_process");
// Open in default file manager
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
spawn(cmd, [dir], { detached: true, stdio: "ignore" }).unref();
console.log(`${green("Opened:")} ${dir}`);
}
function cmdDelete(profileId: string | undefined) {
if (!profileId) {
console.error("Error: Profile ID is required");
console.error("Usage: multica profile delete <id>");
process.exit(1);
}
if (!profileExists(profileId)) {
console.error(`Error: Profile "${profileId}" not found`);
process.exit(1);
}
const dir = getProfileDir(profileId);
try {
rmSync(dir, { recursive: true });
console.log(`${green("Deleted:")} ${profileId}`);
} catch (err) {
console.error(`${red("Error:")} Failed to delete profile: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
/**
* Load setup skill instructions from SKILL.md
*/
function loadSetupSkillInstructions(): string | undefined {
try {
if (!existsSync(SETUP_SKILL_PATH)) {
return undefined;
}
const content = readFileSync(SETUP_SKILL_PATH, "utf-8");
// Extract instructions after frontmatter (after the second ---)
const parts = content.split("---");
if (parts.length >= 3) {
return parts.slice(2).join("---").trim();
}
return content;
} catch {
return undefined;
}
}
/**
* Interactive setup for a profile
*/
async function cmdSetup(profileId: string | undefined) {
if (!profileId) {
console.error("Error: Profile ID is required");
console.error("Usage: multica profile setup <id>");
process.exit(1);
}
if (!profileExists(profileId)) {
console.error(`Error: Profile "${profileId}" not found`);
console.error(`Create it first with: multica profile new ${profileId}`);
process.exit(1);
}
// Check TTY
if (!process.stdin.isTTY) {
console.error(colors.error("Error: Interactive setup requires a TTY."));
process.exit(1);
}
// Load setup skill instructions
const setupInstructions = loadSetupSkillInstructions();
if (!setupInstructions) {
console.error(colors.error("Error: Could not load setup skill instructions."));
process.exit(1);
}
const profileDir = getProfileDir(profileId);
// Build system prompt with setup instructions
const systemPrompt = `${setupInstructions}
## Profile Context
You are setting up the profile "${profileId}".
The profile directory is: ${profileDir}
Available profile files to update:
- ${profileDir}/user.md - Information about the user
- ${profileDir}/workspace.md - Workspace rules and conventions
- ${profileDir}/config.json - Configuration (provider, model, etc.)
Start the setup conversation now.`;
// Create agent with setup instructions
const agent = new Agent({
profileId,
systemPrompt,
});
// Print welcome
console.log("");
console.log(cyan("╭─────────────────────────────────────────╮"));
console.log(`${cyan("│")} ${brightCyan("Profile Setup Wizard")} ${cyan("│")}`);
console.log(cyan("╰─────────────────────────────────────────╯"));
console.log("");
console.log(`${dim("Profile:")} ${yellow(profileId)}`);
console.log(`${dim("Location:")} ${gray(profileDir)}`);
console.log(`${dim("Type")} ${cyan("/exit")} ${dim("to finish setup.")}`);
console.log("");
// Start the conversation with an initial prompt
try {
await agent.run("Start the setup process.");
console.log("");
} catch (err) {
console.error(`\n${colors.error(`Error: ${err instanceof Error ? err.message : String(err)}`)}`);
}
// Interactive loop
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const askQuestion = (prompt: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
resolve(answer);
});
});
};
let running = true;
// Handle Ctrl+C
process.on("SIGINT", () => {
console.log(`\n${dim("Setup cancelled.")}`);
rl.close();
process.exit(0);
});
while (running) {
const input = await askQuestion(`${brightCyan("You:")} `);
const trimmed = input.trim();
if (!trimmed) continue;
// Check for exit command
if (trimmed.toLowerCase() === "/exit" || trimmed.toLowerCase() === "/quit" || trimmed.toLowerCase() === "/q") {
console.log("");
console.log(`${green("Setup complete!")} Your profile has been updated.`);
console.log(`${dim("Start chatting with:")} multica chat --profile ${profileId}`);
console.log("");
running = false;
break;
}
// Send to agent
try {
console.log("");
await agent.run(trimmed);
console.log("");
} catch (err) {
console.error(`\n${colors.error(`Error: ${err instanceof Error ? err.message : String(err)}`)}`);
console.log("");
}
}
rl.close();
}
export async function profileCommand(args: string[]): Promise<void> {
const command = (args[0] || "help") as Command;
const arg1 = args[1];
if (args.includes("--help") || args.includes("-h")) {
printHelp();
return;
}
switch (command) {
case "new":
cmdNew(arg1);
break;
case "list":
cmdList();
break;
case "show":
cmdShow(arg1);
break;
case "edit":
await cmdEdit(arg1);
break;
case "delete":
cmdDelete(arg1);
break;
case "setup":
await cmdSetup(arg1);
break;
case "help":
default:
printHelp();
break;
}
}

View file

@ -1,245 +0,0 @@
/**
* Run command - Execute a single prompt non-interactively
*
* Usage:
* multica run [options] <prompt>
* echo "prompt" | multica run
*/
import { join } from "node:path";
import { Agent, Hub } from "@multica/core";
import type { AgentOptions } from "@multica/core";
import type { ToolsConfig } from "@multica/core";
import { DATA_DIR } from "@multica/utils";
import { cyan, yellow, dim } from "../colors.js";
type RunOptions = {
profile?: string | undefined;
provider?: string | undefined;
model?: string | undefined;
apiKey?: string | undefined;
baseUrl?: string | undefined;
system?: string | undefined;
thinking?: string | undefined;
reasoning?: string | undefined;
cwd?: string | undefined;
session?: string | undefined;
debug?: boolean;
runLog?: boolean;
toolsAllow?: string[];
toolsDeny?: string[];
contextWindow?: number;
help?: boolean;
};
function printHelp() {
console.log(`
${cyan("Usage:")} multica run [options] <prompt>
echo "prompt" | multica run
${cyan("Options:")}
${yellow("--profile")} ID Load agent profile
${yellow("--provider")} NAME LLM provider (openai, anthropic, kimi, etc.)
${yellow("--model")} NAME Model name
${yellow("--api-key")} KEY API key (overrides environment)
${yellow("--base-url")} URL Custom base URL for provider
${yellow("--system")} TEXT System prompt (ignored if --profile set)
${yellow("--thinking")} LEVEL Thinking level
${yellow("--reasoning")} MODE Reasoning display mode (off, on, stream)
${yellow("--cwd")} DIR Working directory
${yellow("--session")} ID Session ID for persistence
${yellow("--debug")} Enable debug logging
${yellow("--run-log")} Enable structured run logging (run-log.jsonl)
${yellow("--context-window")} N Override context window token count
${yellow("--help")}, -h Show this help
${cyan("Tools Configuration:")}
${yellow("--tools-allow")} T Allow specific tools (comma-separated)
${yellow("--tools-deny")} T Deny specific tools (comma-separated)
${cyan("Examples:")}
${dim("# Run with default settings")}
multica run "What is 2+2?"
${dim("# Use a specific profile")}
multica run --profile coder "List files in this directory"
${dim("# Pipe input")}
echo "Explain this code" | multica run
${dim("# Resume a session")}
multica run --session abc123 "Continue from where we left off"
`);
}
function parseArgs(argv: string[]): { opts: RunOptions; prompt: string } {
const args = [...argv];
const opts: RunOptions = {};
const promptParts: string[] = [];
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--help" || arg === "-h") {
opts.help = true;
break;
}
if (arg === "--profile") {
opts.profile = args.shift();
continue;
}
if (arg === "--provider") {
opts.provider = args.shift();
continue;
}
if (arg === "--model") {
opts.model = args.shift();
continue;
}
if (arg === "--api-key") {
opts.apiKey = args.shift();
continue;
}
if (arg === "--base-url") {
opts.baseUrl = args.shift();
continue;
}
if (arg === "--system") {
opts.system = args.shift();
continue;
}
if (arg === "--thinking") {
opts.thinking = args.shift();
continue;
}
if (arg === "--reasoning") {
opts.reasoning = args.shift();
continue;
}
if (arg === "--cwd") {
opts.cwd = args.shift();
continue;
}
if (arg === "--session") {
opts.session = args.shift();
continue;
}
if (arg === "--debug") {
opts.debug = true;
continue;
}
if (arg === "--run-log") {
opts.runLog = true;
continue;
}
if (arg === "--tools-allow") {
const value = args.shift();
opts.toolsAllow = value?.split(",").map((s) => s.trim()) ?? [];
continue;
}
if (arg === "--tools-deny") {
const value = args.shift();
opts.toolsDeny = value?.split(",").map((s) => s.trim()) ?? [];
continue;
}
if (arg === "--context-window") {
const value = args.shift();
opts.contextWindow = value ? parseInt(value, 10) : undefined;
continue;
}
if (arg === "--") {
promptParts.push(...args);
break;
}
promptParts.push(arg);
}
return { opts, prompt: promptParts.join(" ") };
}
async function readStdin(): Promise<string> {
if (process.stdin.isTTY) return "";
return new Promise((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => (data += chunk));
process.stdin.on("end", () => resolve(data.trim()));
process.stdin.on("error", reject);
});
}
export async function runCommand(args: string[]): Promise<void> {
const { opts, prompt } = parseArgs(args);
if (opts.help) {
printHelp();
return;
}
const stdinPrompt = await readStdin();
const finalPrompt = prompt || stdinPrompt;
if (!finalPrompt) {
printHelp();
process.exit(1);
}
// Build tools config if any tools options are set
let toolsConfig: ToolsConfig | undefined;
if (opts.toolsAllow || opts.toolsDeny) {
toolsConfig = {};
if (opts.toolsAllow) {
toolsConfig.allow = opts.toolsAllow;
}
if (opts.toolsDeny) {
toolsConfig.deny = opts.toolsDeny;
}
}
const enableRunLog = opts.runLog || !!process.env.MULTICA_RUN_LOG;
// Initialize Hub to enable full agent capabilities (sub-agents, channels, cron).
// Matches Desktop environment where Hub is always active.
// Gateway connection failures are non-blocking (auto-reconnect with backoff).
const gatewayUrl = process.env.GATEWAY_URL || "http://localhost:3000";
const hub = new Hub(gatewayUrl);
try {
const agent = new Agent({
profileId: opts.profile,
provider: opts.provider,
model: opts.model,
apiKey: opts.apiKey,
baseUrl: opts.baseUrl,
systemPrompt: opts.system,
thinkingLevel: opts.thinking as any,
reasoningMode: opts.reasoning as AgentOptions["reasoningMode"],
cwd: opts.cwd,
sessionId: opts.session,
debug: opts.debug,
enableRunLog,
tools: toolsConfig,
contextWindowTokens: opts.contextWindow,
});
const sessionDir = join(DATA_DIR, "sessions", agent.sessionId);
// If it's a newly created session, notify user of sessionId
if (!opts.session) {
console.error(`[session: ${agent.sessionId}]`);
}
if (enableRunLog) {
console.error(`[session-dir: ${sessionDir}]`);
}
const result = await agent.run(finalPrompt);
if (result.error) {
console.error(`Error: ${result.error}`);
process.exitCode = 1;
}
} finally {
hub.shutdown();
}
}

View file

@ -1,274 +0,0 @@
/**
* Session command - Manage conversation sessions
*
* Usage:
* multica session list List all sessions
* multica session show <id> Show session details
* multica session delete <id> Delete a session
*/
import { existsSync, readdirSync, readFileSync, unlinkSync, statSync } from "node:fs";
import { join } from "node:path";
import { DATA_DIR } from "@multica/utils";
import { cyan, yellow, green, dim, red } from "../colors.js";
const SESSIONS_DIR = join(DATA_DIR, "sessions");
type Command = "list" | "show" | "delete" | "help";
function printHelp() {
console.log(`
${cyan("Usage:")} multica session <command> [options]
${cyan("Commands:")}
${yellow("list")} List all sessions
${yellow("show")} <id> Show session details (use --show-internal to include internal messages)
${yellow("delete")} <id> Delete a session
${yellow("help")} Show this help
${cyan("Examples:")}
${dim("# List all sessions")}
multica session list
${dim("# Show session details")}
multica session show abc12345
${dim("# Delete a session")}
multica session delete abc12345
${dim("# Resume a session")}
multica --session abc12345
multica chat --session abc12345
`);
}
function formatDate(date: Date): string {
return date.toLocaleString();
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
interface SessionInfo {
id: string;
path: string;
size: number;
mtime: Date;
messageCount: number;
}
function getSessionInfo(sessionId: string): SessionInfo | null {
const sessionPath = join(SESSIONS_DIR, `${sessionId}.jsonl`);
if (!existsSync(sessionPath)) {
return null;
}
const stat = statSync(sessionPath);
const content = readFileSync(sessionPath, "utf8");
const lines = content.trim().split("\n").filter(Boolean);
return {
id: sessionId,
path: sessionPath,
size: stat.size,
mtime: stat.mtime,
messageCount: lines.length,
};
}
function listSessions(): SessionInfo[] {
if (!existsSync(SESSIONS_DIR)) {
return [];
}
const files = readdirSync(SESSIONS_DIR);
const sessions: SessionInfo[] = [];
for (const file of files) {
if (!file.endsWith(".jsonl")) continue;
const sessionId = file.replace(".jsonl", "");
const info = getSessionInfo(sessionId);
if (info) {
sessions.push(info);
}
}
// Sort by modification time, newest first
sessions.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
return sessions;
}
function cmdList() {
const sessions = listSessions();
if (sessions.length === 0) {
console.log("No sessions found.");
console.log(`${dim("Sessions are stored in:")} ${SESSIONS_DIR}`);
return;
}
console.log(`\n${cyan("Sessions:")}\n`);
for (const session of sessions) {
const shortId = session.id.slice(0, 8);
console.log(` ${yellow(shortId)} ${dim(formatDate(session.mtime))} ${dim(`${session.messageCount} msgs`)} ${dim(formatSize(session.size))}`);
}
console.log(`\n${dim(`Total: ${sessions.length} session(s)`)}`);
console.log(`${dim("Resume with:")} multica --session <id>`);
}
function cmdShow(sessionId: string | undefined, showInternal = false) {
if (!sessionId) {
console.error("Error: Session ID is required");
console.error("Usage: multica session show <id>");
process.exit(1);
}
// Support partial ID matching
const sessions = listSessions();
const matches = sessions.filter((s) => s.id.startsWith(sessionId));
if (matches.length === 0) {
console.error(`Error: Session "${sessionId}" not found`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`Error: Multiple sessions match "${sessionId}":`);
for (const s of matches) {
console.error(` ${s.id.slice(0, 8)}`);
}
console.error("Please provide a more specific ID.");
process.exit(1);
}
const session = matches[0]!;
const content = readFileSync(session.path, "utf8");
const lines = content.trim().split("\n").filter(Boolean);
console.log(`\n${cyan("Session:")} ${yellow(session.id)}`);
console.log(`${dim("Path:")} ${session.path}`);
console.log(`${dim("Size:")} ${formatSize(session.size)}`);
console.log(`${dim("Modified:")} ${formatDate(session.mtime)}`);
console.log(`${dim("Messages:")} ${session.messageCount}`);
console.log("");
console.log(cyan("─".repeat(60)));
console.log("");
// Parse and display messages as SessionEntry objects
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Only display message entries
if (entry.type !== "message") continue;
// Skip internal messages unless --show-internal
if (entry.internal && !showInternal) continue;
const msg = entry.message;
if (!msg) continue;
const role = msg.role || "unknown";
const roleColor = role === "user" ? green : role === "assistant" ? cyan : dim;
const internalTag = entry.internal ? dim(" [internal]") : "";
console.log(`${roleColor(`[${role}]`)}${internalTag}`);
if (typeof msg.content === "string") {
// Truncate long content
const preview = msg.content.length > 500
? msg.content.slice(0, 500) + "..."
: msg.content;
console.log(preview);
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text") {
const preview = part.text.length > 500
? part.text.slice(0, 500) + "..."
: part.text;
console.log(preview);
} else if (part.type === "tool_use") {
console.log(`${dim(`[Tool: ${part.name}]`)}`);
} else if (part.type === "tool_result") {
console.log(`${dim(`[Tool Result]`)}`);
}
}
}
console.log("");
} catch {
// Skip invalid JSON lines
}
}
console.log(cyan("─".repeat(60)));
console.log(`\n${dim("Resume with:")} multica --session ${session.id.slice(0, 8)}`);
}
function cmdDelete(sessionId: string | undefined) {
if (!sessionId) {
console.error("Error: Session ID is required");
console.error("Usage: multica session delete <id>");
process.exit(1);
}
// Support partial ID matching
const sessions = listSessions();
const matches = sessions.filter((s) => s.id.startsWith(sessionId));
if (matches.length === 0) {
console.error(`Error: Session "${sessionId}" not found`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`Error: Multiple sessions match "${sessionId}":`);
for (const s of matches) {
console.error(` ${s.id.slice(0, 8)}`);
}
console.error("Please provide a more specific ID.");
process.exit(1);
}
const session = matches[0]!;
try {
unlinkSync(session.path);
console.log(`${green("Deleted:")} ${session.id}`);
} catch (err) {
console.error(`${red("Error:")} Failed to delete session: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
export async function sessionCommand(args: string[]): Promise<void> {
const command = (args[0] || "help") as Command;
const arg1 = args[1];
const showInternal = args.includes("--show-internal");
if (args.includes("--help") || args.includes("-h")) {
printHelp();
return;
}
switch (command) {
case "list":
cmdList();
break;
case "show":
cmdShow(arg1, showInternal);
break;
case "delete":
cmdDelete(arg1);
break;
case "help":
default:
printHelp();
break;
}
}

View file

@ -1,527 +0,0 @@
/**
* Skills command - Manage agent skills
*
* Usage:
* multica skills list List all skills
* multica skills status [id] Show skill status
* multica skills install <id> Install skill dependencies
* multica skills add <source> Add skill from GitHub
* multica skills remove <name> Remove a skill
*/
import {
SkillManager,
installSkill,
getInstallOptions,
addSkill,
removeSkill,
listInstalledSkills,
checkEligibilityDetailed,
type DiagnosticItem,
} from "@multica/core";
import { cyan, yellow, green, dim, red } from "../colors.js";
type Command = "list" | "status" | "install" | "add" | "remove" | "help";
interface ParsedArgs {
command: Command;
args: string[];
verbose: boolean;
force: boolean;
profile?: string | undefined;
}
function printHelp() {
console.log(`
${cyan("Usage:")} multica skills <command> [options]
${cyan("Commands:")}
${yellow("list")} List all available skills
${yellow("status")} [id] Show skill status (detailed diagnostics)
${yellow("install")} <id> Install dependencies for a skill
${yellow("add")} <source> Add skill from GitHub
${yellow("remove")} <name> Remove an installed skill
${yellow("help")} Show this help
${cyan("Options:")}
${yellow("-v, --verbose")} Show more details
${yellow("-f, --force")} Force overwrite existing skill
${yellow("-p, --profile")} <id> Install to specific profile's skills directory
${cyan("Source Formats:")} ${dim("(for add command)")}
owner/repo Clone entire repository
owner/repo/skill-name Clone single skill directory
owner/repo@branch Clone specific branch/tag
${cyan("Examples:")}
${dim("# List all skills")}
multica skills list
${dim("# Check skill status")}
multica skills status commit
${dim("# Install skill dependencies")}
multica skills install nano-pdf
${dim("# Add skills from GitHub")}
multica skills add vercel-labs/agent-skills
multica skills add vercel-labs/agent-skills/perplexity
${dim("# Remove a skill")}
multica skills remove agent-skills
${dim("# Add skill to a specific profile")}
multica skills add owner/repo --profile my-agent
`);
}
function parseArgs(argv: string[]): ParsedArgs {
const args = [...argv];
let verbose = false;
let force = false;
let profile: string | undefined;
const positional: string[] = [];
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--verbose" || arg === "-v") {
verbose = true;
continue;
}
if (arg === "--force" || arg === "-f") {
force = true;
continue;
}
if (arg === "--profile" || arg === "-p") {
profile = args.shift();
continue;
}
if (arg === "--help" || arg === "-h") {
return { command: "help", args: [], verbose, force, profile };
}
positional.push(arg);
}
const command = (positional[0] ?? "help") as Command;
const commandArgs = positional.slice(1);
return { command, args: commandArgs, verbose, force, profile };
}
function cmdList(manager: SkillManager, verbose: boolean): void {
const skills = manager.listAllSkillsWithStatus();
if (skills.length === 0) {
console.log("No skills found.");
return;
}
console.log(`\n${cyan("Available Skills:")}\n`);
for (const skill of skills) {
const status = skill.eligible ? "✓" : "✗";
const statusColor = skill.eligible ? green : red;
console.log(` ${statusColor(status)} ${skill.emoji} ${skill.name} (${skill.id})`);
console.log(` ${dim(skill.description)}`);
console.log(` ${dim(`Source: ${skill.source}`)}`);
if (!skill.eligible && skill.reasons) {
for (const reason of skill.reasons) {
console.log(` ${red(`${reason}`)}`);
}
}
if (verbose) {
console.log();
}
}
console.log();
const eligibleCount = skills.filter((s) => s.eligible).length;
console.log(`${dim(`Total: ${skills.length} skills (${eligibleCount} eligible)`)}`);
}
function cmdStatus(manager: SkillManager, skillId?: string, verbose?: boolean): void {
if (!skillId) {
cmdStatusSummary(manager, verbose);
return;
}
cmdStatusDetail(manager, skillId, verbose);
}
function cmdStatusSummary(manager: SkillManager, verbose?: boolean): void {
const skills = manager.listAllSkillsWithStatus();
const eligible = skills.filter((s) => s.eligible);
const ineligible = skills.filter((s) => !s.eligible);
console.log(`\n${cyan("Skills Status Summary:")}\n`);
console.log(` Total: ${skills.length}`);
console.log(` ${green(`Eligible: ${eligible.length}`)}`);
console.log(` ${red(`Ineligible: ${ineligible.length}`)}`);
if (ineligible.length > 0) {
console.log("\n" + dim("─".repeat(45)));
console.log("Ineligible Skills:");
const byIssue: Map<string, string[]> = new Map();
for (const s of ineligible) {
const skill = manager.getSkillFromAll(s.id);
if (skill) {
const detailed = checkEligibilityDetailed(skill);
const mainIssue = detailed.diagnostics?.[0]?.type ?? "unknown";
const existing = byIssue.get(mainIssue) ?? [];
existing.push(s.id);
byIssue.set(mainIssue, existing);
}
}
const issueLabels: Record<string, string> = {
disabled: "Disabled in config",
not_in_allowlist: "Not in allowlist",
platform: "Platform mismatch",
binary: "Missing binaries",
any_binary: "Missing binaries (any)",
env: "Missing environment variables",
config: "Missing config values",
unknown: "Unknown issues",
};
for (const [issue, skillIds] of byIssue) {
const label = issueLabels[issue] ?? issue;
console.log(`\n ${yellow(label + ":")}`);
for (const id of skillIds) {
const skill = manager.getSkillFromAll(id);
if (skill && verbose) {
const detailed = checkEligibilityDetailed(skill);
const diag = detailed.diagnostics?.[0];
console.log(` - ${id}`);
if (diag?.hint) {
console.log(` ${cyan(`Hint: ${diag.hint}`)}`);
}
} else {
console.log(` - ${id}`);
}
}
}
console.log("\n" + dim("─".repeat(45)));
console.log(`${cyan("Tip:")} Run 'multica skills status <skill-id>' for detailed diagnostics`);
}
}
function cmdStatusDetail(manager: SkillManager, skillId: string, verbose?: boolean): void {
const skill = manager.getSkillFromAll(skillId);
if (!skill) {
console.error(`${red("Error:")} Skill not found: ${skillId}`);
process.exit(1);
}
const detailed = checkEligibilityDetailed(skill);
const metadata = skill.frontmatter.metadata;
console.log(`\n${metadata?.emoji ?? "🔧"} ${skill.frontmatter.name}`);
console.log("═".repeat(50));
console.log(`ID: ${skill.id}`);
console.log(`Description: ${skill.frontmatter.description ?? "N/A"}`);
console.log(`Version: ${skill.frontmatter.version ?? "N/A"}`);
console.log(`Source: ${skill.source}`);
console.log(`Path: ${skill.filePath}`);
console.log(`Homepage: ${skill.frontmatter.homepage ?? metadata?.homepage ?? "N/A"}`);
console.log();
console.log("─".repeat(50));
console.log(`Status: ${detailed.eligible ? green("✓ ELIGIBLE") : red("✗ NOT ELIGIBLE")}`);
if (!detailed.eligible && detailed.diagnostics) {
console.log("\nDiagnostics:");
for (const diag of detailed.diagnostics) {
printDiagnostic(diag);
}
}
const requirements = metadata?.requires;
const hasBins = requirements?.bins?.length ?? metadata?.requiresBinaries?.length ?? 0;
const hasAnyBins = requirements?.anyBins?.length ?? 0;
const hasEnvs = requirements?.env?.length ?? metadata?.requiresEnv?.length ?? 0;
if (hasBins > 0 || hasAnyBins > 0 || hasEnvs > 0) {
console.log("\n" + "─".repeat(50));
console.log("Requirements:");
if (hasBins > 0) {
const bins = requirements?.bins ?? metadata?.requiresBinaries ?? [];
printRequirementStatus("Binaries (all required)", bins, checkBinaries);
}
if (hasAnyBins > 0) {
const anyBins = requirements?.anyBins ?? [];
printRequirementStatus("Binaries (any one)", anyBins, checkBinaries, true);
}
if (hasEnvs > 0) {
const envs = requirements?.env ?? metadata?.requiresEnv ?? [];
printRequirementStatus("Environment vars", envs, (e) => checkEnvVars(e, skill.env));
}
}
const installOptions = getInstallOptions(skill);
if (installOptions.length > 0) {
console.log("\n" + "─".repeat(50));
console.log("Install Options:");
for (const opt of installOptions) {
const status = opt.available ? green("✓") : red("✗");
console.log(` ${status} [${opt.id}] ${opt.label}`);
if (!opt.available && opt.reason) {
console.log(`${opt.reason}`);
}
}
}
if (!detailed.eligible) {
console.log("\n" + "─".repeat(50));
console.log(`${yellow("Quick Actions:")}`);
for (const diag of detailed.diagnostics ?? []) {
if (diag.hint) {
console.log(`${diag.hint}`);
}
}
if (installOptions.length > 0) {
console.log(` → multica skills install ${skillId}`);
}
}
}
function printDiagnostic(diag: DiagnosticItem): void {
const typeColors: Record<string, (s: string) => string> = {
disabled: yellow,
not_in_allowlist: yellow,
platform: dim,
binary: red,
any_binary: red,
env: cyan,
config: cyan,
};
const color = typeColors[diag.type] ?? dim;
console.log(`\n ${color(`[${diag.type.toUpperCase()}]`)}`);
console.log(` ${diag.message}`);
if (diag.values && diag.values.length > 0) {
console.log(` Values: ${diag.values.join(", ")}`);
}
if (diag.hint) {
console.log(` ${cyan(`💡 ${diag.hint}`)}`);
}
}
function printRequirementStatus(
label: string,
items: string[],
checker: (items: string[]) => Map<string, boolean>,
anyMode: boolean = false,
): void {
const status = checker(items);
const found = Array.from(status.entries()).filter(([, ok]) => ok).map(([name]) => name);
const missing = Array.from(status.entries()).filter(([, ok]) => !ok).map(([name]) => name);
const allOk = anyMode ? found.length > 0 : missing.length === 0;
const statusIcon = allOk ? green("✓") : red("✗");
console.log(`\n ${statusIcon} ${label}:`);
for (const [name, ok] of status) {
const icon = ok ? green("✓") : red("✗");
console.log(` ${icon} ${name}`);
}
}
function checkBinaries(bins: string[]): Map<string, boolean> {
const result = new Map<string, boolean>();
for (const bin of bins) {
try {
const cmd = process.platform === "win32" ? `where ${bin}` : `which ${bin}`;
require("child_process").execSync(cmd, { stdio: "ignore" });
result.set(bin, true);
} catch {
result.set(bin, false);
}
}
return result;
}
function checkEnvVars(envs: string[], skillEnv: Record<string, string>): Map<string, boolean> {
const result = new Map<string, boolean>();
for (const env of envs) {
const found = Object.prototype.hasOwnProperty.call(skillEnv, env) || env in process.env;
result.set(env, found);
}
return result;
}
async function cmdInstall(manager: SkillManager, skillId: string, installId?: string): Promise<void> {
const skill = manager.getSkillFromAll(skillId);
if (!skill) {
console.error(`${red("Error:")} Skill not found: ${skillId}`);
process.exit(1);
}
const installOptions = getInstallOptions(skill);
if (installOptions.length === 0) {
console.error(`${red("Error:")} Skill '${skillId}' has no install specifications.`);
process.exit(1);
}
if (!installId && installOptions.length > 1) {
console.log(`\nMultiple install options available for '${skillId}':\n`);
for (const opt of installOptions) {
const status = opt.available ? "available" : `unavailable: ${opt.reason}`;
console.log(` [${opt.id}] ${opt.label} (${status})`);
}
console.log(`\nUse: multica skills install ${skillId} <install-id>`);
return;
}
console.log(`\nInstalling dependencies for '${skillId}'...`);
const result = await installSkill({
skill,
installId,
});
if (result.ok) {
console.log(`\n${green(`${result.message}`)}`);
} else {
console.error(`\n${red(`${result.message}`)}`);
if (result.stderr) {
console.error("\nError output:");
console.error(result.stderr);
}
process.exit(1);
}
}
async function cmdAdd(source: string, force: boolean, profileId?: string): Promise<void> {
const destination = profileId ? `profile '${profileId}'` : "global skills";
console.log(`\nAdding skill from '${source}' to ${destination}...`);
const result = await addSkill({
source,
force,
profileId,
});
if (result.ok) {
console.log(`\n${green(`${result.message}`)}`);
if (result.skills && result.skills.length > 1) {
console.log("\nSkills found:");
for (const name of result.skills) {
console.log(` - ${name}`);
}
}
if (result.path) {
console.log(`\nInstalled to: ${result.path}`);
}
} else {
console.error(`\n${red(`${result.message}`)}`);
process.exit(1);
}
}
async function cmdRemove(name: string): Promise<void> {
console.log(`\nRemoving skill '${name}'...`);
const result = await removeSkill(name);
if (result.ok) {
console.log(`\n${green(`${result.message}`)}`);
} else {
console.error(`\n${red(`${result.message}`)}`);
process.exit(1);
}
}
async function cmdListInstalled(): Promise<void> {
const skills = await listInstalledSkills();
if (skills.length === 0) {
console.log("\nNo skills installed in ~/.super-multica/skills/");
console.log("Use 'multica skills add <source>' to add skills.");
return;
}
console.log("\nInstalled skills (~/.super-multica/skills/):\n");
for (const name of skills) {
console.log(` - ${name}`);
}
console.log(`\n${dim(`Total: ${skills.length} installed`)}`);
}
export async function skillsCommand(args: string[]): Promise<void> {
const { command, args: cmdArgs, verbose, force, profile } = parseArgs(args);
if (command === "help") {
printHelp();
return;
}
switch (command) {
case "add":
if (!cmdArgs[0]) {
console.error("Usage: multica skills add <source> [--force] [--profile <id>]");
console.error("\nSource formats:");
console.error(" owner/repo Clone entire repository");
console.error(" owner/repo/skill-name Clone single skill directory");
console.error(" owner/repo@branch Clone specific branch/tag");
console.error("\nOptions:");
console.error(" --force, -f Overwrite existing skill");
console.error(" --profile, -p <id> Install to profile's skills directory");
process.exit(1);
}
await cmdAdd(cmdArgs[0], force, profile);
return;
case "remove":
if (!cmdArgs[0]) {
console.error("Usage: multica skills remove <skill-name>");
await cmdListInstalled();
process.exit(1);
}
await cmdRemove(cmdArgs[0]);
return;
}
const manager = new SkillManager();
switch (command) {
case "list":
cmdList(manager, verbose);
break;
case "status":
cmdStatus(manager, cmdArgs[0], verbose);
break;
case "install":
if (!cmdArgs[0]) {
console.error("Usage: multica skills install <skill-id> [install-id]");
process.exit(1);
}
await cmdInstall(manager, cmdArgs[0], cmdArgs[1]);
break;
default:
console.error(`Unknown command: ${command}`);
printHelp();
process.exit(1);
}
}

View file

@ -1,175 +0,0 @@
/**
* Tools command - Inspect and test tool policies
*
* Usage:
* multica tools list [options] List available tools
* multica tools groups Show all tool groups
*/
import { createAllTools } from "@multica/core";
import { filterTools, type ToolsConfig } from "@multica/core";
import { TOOL_GROUPS, expandToolGroups } from "@multica/core";
import { cyan, yellow, green, dim } from "../colors.js";
type Command = "list" | "groups" | "help";
interface ToolsOptions {
command: Command;
allow?: string[];
deny?: string[];
provider?: string | undefined;
isSubagent?: boolean;
}
function printHelp() {
console.log(`
${cyan("Usage:")} multica tools <command> [options]
${cyan("Commands:")}
${yellow("list")} List available tools (with optional filtering)
${yellow("groups")} Show all tool groups
${yellow("help")} Show this help
${cyan("Options for 'list':")}
${yellow("--allow")} TOOLS Allow specific tools (comma-separated)
${yellow("--deny")} TOOLS Deny specific tools (comma-separated)
${yellow("--provider")} NAME Apply provider-specific rules
${yellow("--subagent")} Apply subagent restrictions
${cyan("Examples:")}
${dim("# List all tools")}
multica tools list
${dim("# List tools with allow/deny")}
multica tools list --deny exec
multica tools list --allow group:fs,web_fetch
${dim("# Show tool groups")}
multica tools groups
`);
}
function parseArgs(argv: string[]): ToolsOptions {
const args = [...argv];
const raw = args.shift() || "help";
if (raw === "--help" || raw === "-h") {
return { command: "help" };
}
const command = raw as Command;
const opts: ToolsOptions = { command };
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--help" || arg === "-h") {
return { command: "help" };
}
if (arg === "--allow") {
const value = args.shift();
opts.allow = value?.split(",").map((s) => s.trim()) ?? [];
continue;
}
if (arg === "--deny") {
const value = args.shift();
opts.deny = value?.split(",").map((s) => s.trim()) ?? [];
continue;
}
if (arg === "--provider") {
opts.provider = args.shift();
continue;
}
if (arg === "--subagent") {
opts.isSubagent = true;
continue;
}
}
return opts;
}
function cmdList(opts: ToolsOptions) {
const allTools = createAllTools(process.cwd());
console.log(`\n${cyan("Tools Overview")}`);
console.log(`Total tools available: ${allTools.length}\n`);
// Build config
let config: ToolsConfig | undefined;
if (opts.allow || opts.deny) {
config = {};
if (opts.allow) {
config.allow = opts.allow;
}
if (opts.deny) {
config.deny = opts.deny;
}
}
const filterOpts: import("@multica/core").FilterToolsOptions = {};
if (config) {
filterOpts.config = config;
}
if (opts.provider) {
filterOpts.provider = opts.provider;
}
if (opts.isSubagent) {
filterOpts.isSubagent = opts.isSubagent;
}
const filtered = filterTools(allTools, filterOpts);
if (config || opts.provider || opts.isSubagent) {
console.log("Applied filters:");
if (opts.allow) console.log(` ${dim("Allow:")} ${opts.allow.join(", ")}`);
if (opts.deny) console.log(` ${dim("Deny:")} ${opts.deny.join(", ")}`);
if (opts.provider) console.log(` ${dim("Provider:")} ${opts.provider}`);
if (opts.isSubagent) console.log(` ${dim("Subagent:")} true`);
console.log("");
console.log(`Tools after filtering: ${green(String(filtered.length))}`);
console.log("");
}
console.log("Tools:");
for (const tool of filtered) {
const desc = tool.description?.slice(0, 55) || "";
console.log(` ${yellow(tool.name.padEnd(15))} ${dim(desc)}${desc.length >= 55 ? "..." : ""}`);
}
if (filtered.length < allTools.length) {
const removed = allTools.filter((t) => !filtered.find((f) => f.name === t.name));
console.log("");
console.log(`${dim(`Filtered out (${removed.length}):`)}`);
for (const tool of removed) {
console.log(` ${dim(tool.name)}`);
}
}
}
function cmdGroups() {
console.log(`\n${cyan("Tool Groups:")}\n`);
for (const [name, tools] of Object.entries(TOOL_GROUPS)) {
console.log(` ${yellow(name)}:`);
console.log(` ${dim(tools.join(", "))}`);
console.log("");
}
}
export async function toolsCommand(args: string[]): Promise<void> {
const opts = parseArgs(args);
switch (opts.command) {
case "list":
cmdList(opts);
break;
case "groups":
cmdGroups();
break;
case "help":
default:
printHelp();
break;
}
}

View file

@ -1,192 +0,0 @@
#!/usr/bin/env node
/**
* Multica CLI - Unified command-line interface
*
* Usage:
* multica Interactive mode (default)
* multica run <prompt> Run a single prompt
* multica chat Interactive mode (explicit)
* multica session <cmd> Session management
* multica profile <cmd> Profile management
* multica skills <cmd> Skills management
* multica tools <cmd> Tool policy inspection
* multica credentials <cmd> Credentials management
* multica cron <cmd> Scheduled task management
* multica dev [service] Development servers
* multica help Show help
*/
import { cyan, yellow, green, dim, brightCyan } from "./colors.js";
// Subcommand handlers (lazy imports for faster startup)
type SubcommandHandler = (args: string[]) => Promise<void>;
const subcommands: Record<string, () => Promise<SubcommandHandler>> = {
run: async () => (await import("./commands/run.js")).runCommand,
chat: async () => (await import("./commands/chat.js")).chatCommand,
session: async () => (await import("./commands/session.js")).sessionCommand,
profile: async () => (await import("./commands/profile.js")).profileCommand,
skills: async () => (await import("./commands/skills.js")).skillsCommand,
tools: async () => (await import("./commands/tools.js")).toolsCommand,
credentials: async () => (await import("./commands/credentials.js")).credentialsCommand,
dev: async () => (await import("./commands/dev.js")).devCommand,
cron: async () => (await import("./commands/cron.js")).cronCommand,
};
function printHelp() {
console.log(`
${brightCyan("Multica CLI")} - AI Agent Framework
${cyan("Usage:")}
${yellow("multica")} Start interactive mode (default)
${yellow("multica run")} <prompt> Run a single prompt
${yellow("multica chat")} [options] Start interactive mode
${yellow("multica session")} <command> Manage sessions
${yellow("multica profile")} <command> Manage agent profiles
${yellow("multica skills")} <command> Manage skills
${yellow("multica tools")} <command> Inspect tool policies
${yellow("multica credentials")} <command> Manage credentials
${yellow("multica cron")} <command> Manage scheduled tasks
${yellow("multica dev")} [service] Start development servers
${yellow("multica help")} Show this help
${cyan("Agent Options:")} ${dim("(for run/chat)")}
${yellow("--profile")} ID Load agent profile
${yellow("--provider")} NAME LLM provider (openai, anthropic, kimi, etc.)
${yellow("--model")} NAME Model name
${yellow("--system")} TEXT System prompt
${yellow("--session")} ID Resume session
${yellow("--cwd")} DIR Working directory
${cyan("Commands:")}
${green("session")}
list List all sessions
show <id> Show session details
delete <id> Delete a session
${green("profile")}
list List all profiles
new <id> Create a new profile
show <id> Show profile contents
edit <id> Open profile in file manager
delete <id> Delete a profile
${green("skills")}
list List all skills
status [id] Show skill status
install <id> Install skill dependencies
add <source> Add skill from GitHub
remove <name> Remove a skill
${green("tools")}
list [--profile P] List tools (with optional filter)
groups Show tool groups
profiles Show tool profiles
${green("credentials")}
init [--force] Create credential files
show Show credential paths
edit Open credentials in editor
${green("cron")}
status Show cron service status
list List all scheduled jobs
add [options] Create a new scheduled job
run <id> Run a job immediately
enable <id> Enable a job
disable <id> Disable a job
remove <id> Delete a job
logs <id> Show job run logs
${green("dev")}
${dim("(default)")} Start all services (gateway + console + web)
gateway Start gateway only (:3000)
console Start console only (:4000)
web Start web app only (:3001)
desktop Start desktop app
${cyan("Examples:")}
${dim("# Start interactive mode")}
multica
${dim("# Run a single prompt")}
multica run "What files are in this directory?"
${dim("# Use a specific profile")}
multica chat --profile coder
${dim("# Resume a session")}
multica --session abc123
${dim("# Start development servers")}
multica dev
multica dev gateway
`);
}
function printVersion() {
// Read version from package.json would be ideal, but for now just print a placeholder
console.log("multica 1.0.0");
}
async function main() {
// Filter out standalone "--" (used by pnpm to pass args)
const args = process.argv.slice(2).filter((arg) => arg !== "--");
// Handle global flags
if (args.includes("--help") || args.includes("-h")) {
// If help is requested with a subcommand, delegate to that subcommand
const firstArg = args[0];
if (firstArg && !firstArg.startsWith("-") && subcommands[firstArg]) {
const handler = await subcommands[firstArg]();
await handler(["--help"]);
return;
}
printHelp();
return;
}
if (args.includes("--version") || args.includes("-V")) {
printVersion();
return;
}
// Determine command
const firstArg = args[0];
// No args or starts with -- means interactive mode
if (!firstArg || firstArg.startsWith("-")) {
const chatHandler = await subcommands.chat!();
await chatHandler(args);
return;
}
// Check if it's "help" command
if (firstArg === "help") {
const subcommand = args[1];
if (subcommand && subcommands[subcommand]) {
const handler = await subcommands[subcommand]();
await handler(["--help"]);
return;
}
printHelp();
return;
}
// Check if it's a known subcommand
if (subcommands[firstArg]) {
const handler = await subcommands[firstArg]();
await handler(args.slice(1));
return;
}
// Unknown command - show error and help
console.error(`Unknown command: ${firstArg}`);
console.error(`Run 'multica help' for usage information.`);
process.exit(1);
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

View file

@ -1,544 +0,0 @@
#!/usr/bin/env node
import * as readline from "readline";
import { Agent } from "@multica/core";
import type { AgentOptions } from "@multica/core";
import { SkillManager } from "@multica/core";
import { autocompleteInput, type AutocompleteOption } from "./autocomplete.js";
import { colors, dim, cyan, brightCyan, yellow, green, gray } from "./colors.js";
type CliOptions = {
profile?: string | undefined;
provider?: string | undefined;
model?: string | undefined;
system?: string | undefined;
thinking?: string | undefined;
reasoning?: string | undefined;
cwd?: string | undefined;
session?: string | undefined;
help?: boolean | undefined;
};
const COMMANDS = {
help: "Show this help message",
exit: "Exit the CLI (aliases: quit, q)",
clear: "Clear the current session and start fresh",
session: "Show current session ID",
new: "Start a new session",
multiline: "Toggle multi-line input mode (end with a line containing only '.')",
};
function printUsage() {
console.log(`${cyan("Usage:")} pnpm agent:interactive [options]`);
console.log("");
console.log(`${cyan("Options:")}`);
console.log(` ${yellow("--profile")} ID Load agent profile (identity, soul, tools)`);
console.log(` ${yellow("--provider")} NAME LLM provider (e.g., openai, anthropic, kimi)`);
console.log(` ${yellow("--model")} NAME Model name`);
console.log(` ${yellow("--system")} TEXT System prompt (ignored if --profile is set)`);
console.log(` ${yellow("--thinking")} LEVEL Thinking level`);
console.log(` ${yellow("--reasoning")} MODE Reasoning display mode (off, on, stream)`);
console.log(` ${yellow("--cwd")} DIR Working directory for commands`);
console.log(` ${yellow("--session")} ID Session ID to resume`);
console.log(` ${yellow("--help")}, -h Show this help`);
console.log("");
console.log(`${cyan("Commands")} (use during interaction):`);
for (const [cmd, desc] of Object.entries(COMMANDS)) {
console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`);
}
}
function parseArgs(argv: string[]) {
const args = [...argv];
const opts: CliOptions = {};
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--help" || arg === "-h") {
opts.help = true;
break;
}
if (arg === "--profile") {
opts.profile = args.shift();
continue;
}
if (arg === "--provider") {
opts.provider = args.shift();
continue;
}
if (arg === "--model") {
opts.model = args.shift();
continue;
}
if (arg === "--system") {
opts.system = args.shift();
continue;
}
if (arg === "--thinking") {
opts.thinking = args.shift();
continue;
}
if (arg === "--reasoning") {
opts.reasoning = args.shift();
continue;
}
if (arg === "--cwd") {
opts.cwd = args.shift();
continue;
}
if (arg === "--session") {
opts.session = args.shift();
continue;
}
}
return opts;
}
function printWelcome(sessionId: string, opts: CliOptions) {
const border = cyan("│");
const topBorder = cyan("╭─────────────────────────────────────────╮");
const bottomBorder = cyan("╰─────────────────────────────────────────╯");
console.log(topBorder);
console.log(`${border} ${brightCyan("Super Multica Interactive CLI")} ${border}`);
console.log(bottomBorder);
// Show configuration
const configLines: string[] = [];
configLines.push(`${dim("Session:")} ${gray(sessionId.slice(0, 8))}...`);
if (opts.profile) {
configLines.push(`${dim("Profile:")} ${yellow(opts.profile)}`);
}
if (opts.provider) {
configLines.push(`${dim("Provider:")} ${green(opts.provider)}`);
}
if (opts.model) {
configLines.push(`${dim("Model:")} ${green(opts.model)}`);
}
console.log(configLines.join(" "));
console.log(`${dim("Type")} ${cyan("/help")} ${dim("for commands,")} ${cyan("/exit")} ${dim("to quit.")}`);
console.log("");
}
function printHelp(skillManager?: SkillManager) {
console.log(`\n${cyan("Built-in commands:")}`);
for (const [cmd, desc] of Object.entries(COMMANDS)) {
console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`);
}
// Show skill commands if available
if (skillManager) {
const reservedNames = new Set(Object.keys(COMMANDS));
const skillCommands = skillManager.getSkillCommands({ reservedNames });
if (skillCommands.length > 0) {
console.log(`\n${cyan("Skill commands:")}`);
for (const cmd of skillCommands) {
console.log(` ${yellow(`/${cmd.name}`.padEnd(14))} ${dim(cmd.description)}`);
}
}
}
console.log(`\n${dim("Just type your message and press Enter to chat with the agent.")}`);
console.log("");
}
/**
* Status Bar - renders a persistent status line at the bottom of the terminal
*/
class StatusBar {
private enabled: boolean;
private currentStatus: string = "";
private stream: NodeJS.WriteStream;
constructor(stream: NodeJS.WriteStream = process.stdout) {
this.stream = stream;
this.enabled = stream.isTTY === true;
}
/**
* Update the status bar content
*/
update(parts: { session?: string; provider?: string; model?: string; tokens?: number }) {
if (!this.enabled) return;
const segments: string[] = [];
if (parts.session) {
segments.push(`${dim("session:")}${gray(parts.session.slice(0, 8))}`);
}
if (parts.provider) {
segments.push(`${dim("provider:")}${green(parts.provider)}`);
}
if (parts.model) {
segments.push(`${dim("model:")}${yellow(parts.model)}`);
}
if (parts.tokens !== undefined) {
segments.push(`${dim("tokens:")}${cyan(String(parts.tokens))}`);
}
this.currentStatus = segments.join(" ");
this.render();
}
/**
* Render the status bar
*/
private render() {
if (!this.enabled || !this.currentStatus) return;
const termWidth = this.stream.columns || 80;
const termHeight = this.stream.rows || 24;
// Save cursor, move to bottom, clear line, write status, restore cursor
const statusLine = ` ${this.currentStatus} `.slice(0, termWidth);
this.stream.write(
`\x1b[s` + // Save cursor
`\x1b[${termHeight};1H` + // Move to last row
`\x1b[7m` + // Inverse video (highlight)
`\x1b[2K` + // Clear line
statusLine.padEnd(termWidth) + // Write status padded to terminal width
`\x1b[0m` + // Reset
`\x1b[u` // Restore cursor
);
}
/**
* Clear the status bar
*/
clear() {
if (!this.enabled) return;
const termHeight = this.stream.rows || 24;
this.stream.write(
`\x1b[s` + // Save cursor
`\x1b[${termHeight};1H` + // Move to last row
`\x1b[2K` + // Clear line
`\x1b[u` // Restore cursor
);
this.currentStatus = "";
}
/**
* Temporarily hide status bar (for clean output)
*/
hide() {
this.clear();
}
/**
* Show status bar again
*/
show() {
this.render();
}
}
class InteractiveCLI {
private agent: Agent;
private opts: CliOptions;
private rl: readline.Interface | null = null;
private multilineMode = false;
private multilineBuffer: string[] = [];
private running = true;
private skillManager: SkillManager;
private reservedNames: Set<string>;
private statusBar: StatusBar;
constructor(opts: CliOptions) {
this.opts = opts;
this.agent = this.createAgent(opts.session);
this.statusBar = new StatusBar();
// Initialize SkillManager for tab completion
this.skillManager = new SkillManager({
profileId: opts.profile,
});
// Build list of reserved command names (built-in CLI commands)
this.reservedNames = new Set(Object.keys(COMMANDS));
// Handle Ctrl+C gracefully
process.on("SIGINT", () => {
this.statusBar.clear();
console.log(`\n${dim("Goodbye!")}`);
process.exit(0);
});
}
/**
* Get or create readline interface (lazy initialization)
* Only created when needed for multiline mode to avoid interfering with autocomplete
*/
private getReadline(): readline.Interface {
if (!this.rl) {
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
this.rl.on("close", () => {
this.running = false;
this.statusBar.clear();
console.log(`\n${dim("Goodbye!")}`);
process.exit(0);
});
}
return this.rl;
}
/**
* Close readline interface when not needed
*/
private closeReadline() {
if (this.rl) {
this.rl.close();
this.rl = null;
}
}
/**
* Get autocomplete suggestions for input
*/
private getSuggestions(input: string): AutocompleteOption[] {
if (!input.startsWith("/")) {
return [];
}
const prefix = input.slice(1).toLowerCase();
const suggestions: AutocompleteOption[] = [];
// Add built-in command suggestions
for (const [cmd, desc] of Object.entries(COMMANDS)) {
if (cmd.toLowerCase().startsWith(prefix)) {
suggestions.push({
value: `/${cmd}`,
label: desc.slice(0, 40),
});
}
}
// Add skill command suggestions
const skillCommands = this.skillManager.getSkillCommands({ reservedNames: this.reservedNames });
for (const cmd of skillCommands) {
if (cmd.name.toLowerCase().startsWith(prefix)) {
suggestions.push({
value: `/${cmd.name}`,
label: cmd.description.slice(0, 40),
});
}
}
// Sort: shorter first, then alphabetically
suggestions.sort((a, b) => {
if (a.value.length !== b.value.length) return a.value.length - b.value.length;
return a.value.localeCompare(b.value);
});
return suggestions;
}
private createAgent(sessionId?: string): Agent {
return new Agent({
profileId: this.opts.profile,
provider: this.opts.provider,
model: this.opts.model,
systemPrompt: this.opts.system,
thinkingLevel: this.opts.thinking as AgentOptions["thinkingLevel"],
reasoningMode: this.opts.reasoning as AgentOptions["reasoningMode"],
cwd: this.opts.cwd,
sessionId,
});
}
private prompt(): string {
if (this.multilineMode) {
return this.multilineBuffer.length === 0 ? cyan(">>> ") : cyan("... ");
}
return `${brightCyan("You:")} `;
}
private updateStatusBar() {
const statusUpdate: { session?: string; provider?: string; model?: string; tokens?: number } = {
session: this.agent.sessionId,
provider: this.opts.provider ?? "default",
};
if (this.opts.model) {
statusUpdate.model = this.opts.model;
}
this.statusBar.update(statusUpdate);
}
async run() {
printWelcome(this.agent.sessionId, this.opts);
this.updateStatusBar();
await this.loop();
}
private async loop() {
while (this.running) {
let input: string;
if (this.multilineMode) {
// Use simple readline for multiline mode
const lineInput = await this.readline(this.prompt());
if (lineInput === null) break;
input = lineInput;
if (input === ".") {
// End of multiline input
const fullInput = this.multilineBuffer.join("\n");
this.multilineBuffer = [];
this.multilineMode = false;
// Close readline to avoid interfering with autocomplete
this.closeReadline();
if (fullInput.trim()) {
await this.handleInput(fullInput);
}
} else {
this.multilineBuffer.push(input);
}
continue;
}
// Use autocomplete input for normal mode
try {
this.statusBar.hide();
input = await autocompleteInput({
prompt: this.prompt(),
getSuggestions: (text) => this.getSuggestions(text),
maxSuggestions: 8,
});
this.statusBar.show();
} catch {
break;
}
const trimmed = input.trim();
if (!trimmed) continue;
if (trimmed.startsWith("/")) {
const handled = await this.handleCommand(trimmed);
if (!handled) {
await this.handleInput(trimmed);
}
} else {
await this.handleInput(trimmed);
}
}
}
private readline(prompt: string): Promise<string | null> {
return new Promise((resolve) => {
this.getReadline().question(prompt, (answer) => {
resolve(answer);
});
});
}
private async handleCommand(input: string): Promise<boolean> {
const cmd = input.slice(1).toLowerCase().split(/\s+/)[0];
switch (cmd) {
case "help":
printHelp(this.skillManager);
return true;
case "exit":
case "quit":
case "q":
this.statusBar.clear();
console.log(dim("Goodbye!"));
this.running = false;
this.closeReadline();
process.exit(0);
return true;
case "clear":
this.agent = this.createAgent();
this.updateStatusBar();
console.log(`${green("Session cleared.")} ${dim("New session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
return true;
case "session":
console.log(`${dim("Current session:")} ${cyan(this.agent.sessionId)}\n`);
return true;
case "new":
this.agent = this.createAgent();
this.updateStatusBar();
console.log(`${green("Started new session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
return true;
case "multiline":
this.multilineMode = !this.multilineMode;
if (this.multilineMode) {
console.log(`${green("Multi-line mode enabled.")} ${dim("End input with a line containing only '.'")}`);
this.multilineBuffer = [];
} else {
console.log(dim("Multi-line mode disabled."));
this.multilineBuffer = [];
// Close readline to avoid interfering with autocomplete
this.closeReadline();
}
return true;
default:
// Check if it's a skill command
const invocation = this.skillManager.resolveCommand(input);
if (invocation) {
// Skill command found - send to agent with skill instructions as context
const skillPrompt = invocation.args
? `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}\n\nUser request: ${invocation.args}`
: `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}`;
await this.handleInput(skillPrompt);
return true;
}
// Unknown command - let the agent handle it as-is
return false;
}
}
private async handleInput(input: string) {
try {
console.log(""); // Add spacing before response
this.statusBar.hide();
const result = await this.agent.run(input);
this.statusBar.show();
if (result.error) {
console.error(`\n${colors.error(`Error: ${result.error}`)}`);
}
console.log(""); // Add spacing after response
} catch (err) {
console.error(`\n${colors.error(`Error: ${err instanceof Error ? err.message : String(err)}`)}`);
console.log("");
}
}
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.help) {
printUsage();
return;
}
// Check if running in a TTY
if (!process.stdin.isTTY) {
console.error(colors.error("Error: Interactive CLI requires a TTY. Use agent:cli for non-interactive mode."));
process.exit(1);
}
const cli = new InteractiveCLI(opts);
await cli.run();
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

View file

@ -1,199 +0,0 @@
#!/usr/bin/env node
import { Agent } from "@multica/core";
type CliOptions = {
profile?: string | undefined;
provider?: string | undefined;
model?: string | undefined;
apiKey?: string | undefined;
baseUrl?: string | undefined;
system?: string | undefined;
thinking?: string | undefined;
reasoning?: string | undefined;
cwd?: string | undefined;
session?: string | undefined;
debug?: boolean | undefined;
help?: boolean | undefined;
// Tools configuration
toolsProfile?: string | undefined;
toolsAllow?: string[] | undefined;
toolsDeny?: string[] | undefined;
};
function printUsage() {
console.log("Usage: pnpm agent:cli [options] <prompt>");
console.log(" echo \"your prompt\" | pnpm agent:cli");
console.log("");
console.log("Options:");
console.log(" --profile ID Load agent profile (identity, soul, tools)");
console.log(" --provider NAME LLM provider (e.g., openai, anthropic, kimi)");
console.log(" --model NAME Model name");
console.log(" --api-key KEY API key (overrides environment variable)");
console.log(" --base-url URL Custom base URL for the provider");
console.log(" --system TEXT System prompt (ignored if --profile is set)");
console.log(" --thinking LEVEL Thinking level");
console.log(" --reasoning MODE Reasoning display mode (off, on, stream)");
console.log(" --cwd DIR Working directory for commands");
console.log(" --session ID Session ID for conversation persistence");
console.log(" --debug Enable debug logging");
console.log(" --help, -h Show this help");
console.log("");
console.log("Tools Configuration:");
console.log(" --tools-profile PROFILE Tool profile (minimal, coding, web, full)");
console.log(" --tools-allow TOOLS Allow specific tools (comma-separated, supports group:*)");
console.log(" --tools-deny TOOLS Deny specific tools (comma-separated)");
console.log("");
console.log("Examples:");
console.log(' pnpm agent:cli --tools-profile coding "list files"');
console.log(' pnpm agent:cli --tools-profile minimal --tools-allow exec "run ls"');
console.log(' pnpm agent:cli --tools-deny exec,process "read file.txt"');
}
function parseArgs(argv: string[]) {
const args = [...argv];
const opts: CliOptions = {};
const promptParts: string[] = [];
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--help" || arg === "-h") {
opts.help = true;
break;
}
if (arg === "--profile") {
opts.profile = args.shift();
continue;
}
if (arg === "--provider") {
opts.provider = args.shift();
continue;
}
if (arg === "--model") {
opts.model = args.shift();
continue;
}
if (arg === "--api-key") {
opts.apiKey = args.shift();
continue;
}
if (arg === "--base-url") {
opts.baseUrl = args.shift();
continue;
}
if (arg === "--system") {
opts.system = args.shift();
continue;
}
if (arg === "--thinking") {
opts.thinking = args.shift();
continue;
}
if (arg === "--reasoning") {
opts.reasoning = args.shift();
continue;
}
if (arg === "--cwd") {
opts.cwd = args.shift();
continue;
}
if (arg === "--session") {
opts.session = args.shift();
continue;
}
if (arg === "--debug") {
opts.debug = true;
continue;
}
if (arg === "--tools-profile") {
opts.toolsProfile = args.shift();
continue;
}
if (arg === "--tools-allow") {
const value = args.shift();
opts.toolsAllow = value?.split(",").map((s) => s.trim()) ?? [];
continue;
}
if (arg === "--tools-deny") {
const value = args.shift();
opts.toolsDeny = value?.split(",").map((s) => s.trim()) ?? [];
continue;
}
if (arg === "--") {
promptParts.push(...args);
break;
}
promptParts.push(arg);
}
return { opts, prompt: promptParts.join(" ") };
}
async function readStdin() {
if (process.stdin.isTTY) return "";
return new Promise<string>((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => (data += chunk));
process.stdin.on("end", () => resolve(data.trim()));
process.stdin.on("error", reject);
});
}
async function main() {
const { opts, prompt } = parseArgs(process.argv.slice(2));
if (opts.help) {
printUsage();
return;
}
const stdinPrompt = await readStdin();
const finalPrompt = prompt || stdinPrompt;
if (!finalPrompt) {
printUsage();
process.exit(1);
}
// Build tools config if any tools options are set
let toolsConfig: import("@multica/core").ToolsConfig | undefined;
if (opts.toolsAllow || opts.toolsDeny) {
toolsConfig = {};
if (opts.toolsAllow) {
toolsConfig.allow = opts.toolsAllow;
}
if (opts.toolsDeny) {
toolsConfig.deny = opts.toolsDeny;
}
}
const agent = new Agent({
profileId: opts.profile,
provider: opts.provider,
model: opts.model,
apiKey: opts.apiKey,
baseUrl: opts.baseUrl,
systemPrompt: opts.system,
thinkingLevel: opts.thinking as any,
reasoningMode: opts.reasoning as any,
cwd: opts.cwd,
sessionId: opts.session,
debug: opts.debug,
tools: toolsConfig,
});
// If it's a newly created session, notify user of sessionId
if (!opts.session) {
console.error(`[session: ${agent.sessionId}]`);
}
const result = await agent.run(finalPrompt);
if (result.error) {
console.error(`Error: ${result.error}`);
process.exitCode = 1;
}
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

View file

@ -1,252 +0,0 @@
import { describe, it, expect } from "vitest";
import {
toolDisplayName,
formatToolArgs,
extractResultDetails,
formatResultSummary,
} from "./output.js";
describe("output", () => {
describe("toolDisplayName", () => {
it("should map known tool names to display names", () => {
expect(toolDisplayName("read")).toBe("ReadFile");
expect(toolDisplayName("write")).toBe("WriteFile");
expect(toolDisplayName("edit")).toBe("EditFile");
expect(toolDisplayName("glob")).toBe("Glob");
expect(toolDisplayName("web_search")).toBe("WebSearch");
expect(toolDisplayName("web_fetch")).toBe("WebFetch");
});
it("should return original name for unknown tools", () => {
expect(toolDisplayName("custom_tool")).toBe("custom_tool");
expect(toolDisplayName("unknown")).toBe("unknown");
});
});
describe("formatToolArgs", () => {
it("should return empty string for null/undefined args", () => {
expect(formatToolArgs("read", null)).toBe("");
expect(formatToolArgs("read", undefined)).toBe("");
});
it("should return empty string for non-object args", () => {
expect(formatToolArgs("read", "string")).toBe("");
expect(formatToolArgs("read", 123)).toBe("");
});
it("should format read tool args", () => {
expect(formatToolArgs("read", { path: "/foo/bar.ts" })).toBe("/foo/bar.ts");
expect(formatToolArgs("read", { file: "/foo/bar.ts" })).toBe("/foo/bar.ts");
});
it("should format glob tool args", () => {
expect(formatToolArgs("glob", { pattern: "**/*.ts" })).toBe("**/*.ts");
expect(formatToolArgs("glob", { pattern: "**/*.ts", cwd: "/src" })).toBe("**/*.ts in /src");
});
it("should format web_search tool args with truncation", () => {
expect(formatToolArgs("web_search", { query: "short query" })).toBe("short query");
const longQuery = "a".repeat(60);
expect(formatToolArgs("web_search", { query: longQuery })).toBe("a".repeat(50) + "…");
});
it("should format web_fetch tool args with URL parsing", () => {
expect(formatToolArgs("web_fetch", { url: "https://example.com" })).toBe("example.com");
expect(formatToolArgs("web_fetch", { url: "https://example.com/" })).toBe("example.com");
expect(formatToolArgs("web_fetch", { url: "https://example.com/path/to/page" })).toBe(
"example.com/path/to/page"
);
});
it("should truncate long URL paths", () => {
const longPath = "/very/long/path/that/exceeds/thirty/characters/limit";
expect(formatToolArgs("web_fetch", { url: `https://example.com${longPath}` })).toBe(
"example.com" + longPath.slice(0, 30) + "…"
);
});
it("should handle invalid URLs gracefully", () => {
expect(formatToolArgs("web_fetch", { url: "not-a-valid-url" })).toBe("not-a-valid-url");
const longInvalid = "x".repeat(60);
expect(formatToolArgs("web_fetch", { url: longInvalid })).toBe("x".repeat(50) + "…");
});
it("should return empty string for unknown tools", () => {
expect(formatToolArgs("unknown_tool", { foo: "bar" })).toBe("");
});
});
describe("extractResultDetails", () => {
it("should return null for null/undefined", () => {
expect(extractResultDetails(null)).toBeNull();
expect(extractResultDetails(undefined)).toBeNull();
});
it("should return null for non-objects", () => {
expect(extractResultDetails("string")).toBeNull();
expect(extractResultDetails(123)).toBeNull();
});
it("should extract JSON from AgentMessage content array", () => {
const result = {
content: [{ type: "text", text: '{"count": 5, "files": ["a.ts", "b.ts"]}' }],
};
expect(extractResultDetails(result)).toEqual({ count: 5, files: ["a.ts", "b.ts"] });
});
it("should skip non-text content items", () => {
const result = {
content: [
{ type: "image", data: "..." },
{ type: "text", text: '{"value": 42}' },
],
};
expect(extractResultDetails(result)).toEqual({ value: 42 });
});
it("should handle invalid JSON gracefully", () => {
const result = {
content: [{ type: "text", text: "not json" }],
};
// Falls back to returning the object itself
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 });
});
});
describe("formatResultSummary", () => {
describe("glob", () => {
it("should format file count from count field", () => {
const result = { content: [{ type: "text", text: '{"count": 5}' }] };
expect(formatResultSummary("glob", result)).toBe("5 files");
});
it("should format file count from files array length", () => {
const result = {
content: [{ type: "text", text: '{"files": ["a.ts", "b.ts", "c.ts"]}' }],
};
expect(formatResultSummary("glob", result)).toBe("3 files");
});
it("should show + for truncated results", () => {
const result = { content: [{ type: "text", text: '{"count": 100, "truncated": true}' }] };
expect(formatResultSummary("glob", result)).toBe("100+ files");
});
it("should handle zero files", () => {
const result = { content: [{ type: "text", text: '{"count": 0, "files": []}' }] };
expect(formatResultSummary("glob", result)).toBe("0 files");
});
});
describe("web_search", () => {
it("should format error results", () => {
const result = { content: [{ type: "text", text: '{"error": true, "message": "API error"}' }] };
expect(formatResultSummary("web_search", result)).toBe("error: API error");
});
it("should format Perplexity results with citations", () => {
const result = {
content: [
{
type: "text",
text: '{"content": "answer text", "citations": ["url1", "url2", "url3"]}',
},
],
};
expect(formatResultSummary("web_search", result)).toBe("3 citations");
});
it("should format Brave results with count", () => {
const result = { content: [{ type: "text", text: '{"count": 10}' }] };
expect(formatResultSummary("web_search", result)).toBe("10 results");
});
it("should count results array if no count field", () => {
const result = {
content: [{ type: "text", text: '{"results": [{}, {}, {}]}' }],
};
expect(formatResultSummary("web_search", result)).toBe("3 results");
});
});
describe("web_fetch", () => {
it("should format error results", () => {
const result = {
content: [{ type: "text", text: '{"error": true, "message": "404 Not Found"}' }],
};
expect(formatResultSummary("web_fetch", result)).toBe("error: 404 Not Found");
});
it("should format title", () => {
const result = { content: [{ type: "text", text: '{"title": "Example Page"}' }] };
expect(formatResultSummary("web_fetch", result)).toBe('"Example Page"');
});
it("should truncate long titles", () => {
const longTitle = "A".repeat(50);
const result = { content: [{ type: "text", text: `{"title": "${longTitle}"}` }] };
expect(formatResultSummary("web_fetch", result)).toBe(`"${"A".repeat(30)}…"`);
});
it("should format content length in KB", () => {
const result = { content: [{ type: "text", text: '{"length": 2048}' }] };
expect(formatResultSummary("web_fetch", result)).toBe("2.0KB");
});
it("should show cached indicator", () => {
const result = { content: [{ type: "text", text: '{"cached": true}' }] };
expect(formatResultSummary("web_fetch", result)).toBe("cached");
});
it("should combine multiple fields", () => {
const result = {
content: [{ type: "text", text: '{"title": "Page", "length": 1024, "cached": true}' }],
};
expect(formatResultSummary("web_fetch", result)).toBe('"Page", 1.0KB, cached');
});
});
describe("grep", () => {
it("should return 'no matches' for empty results", () => {
const result = { content: [{ type: "text", text: "No matches found" }] };
expect(formatResultSummary("grep", result)).toBe("no matches");
});
it("should count non-empty lines as matches", () => {
const result = {
content: [{ type: "text", text: "file.ts:1:match1\nfile.ts:2:match2\nfile.ts:3:match3" }],
};
expect(formatResultSummary("grep", result)).toBe("3 matches");
});
it("should ignore empty lines when counting", () => {
const result = {
content: [{ type: "text", text: "file.ts:1:match1\n\nfile.ts:2:match2\n" }],
};
expect(formatResultSummary("grep", result)).toBe("2 matches");
});
});
it("should return empty string for unknown tools", () => {
const result = { content: [{ type: "text", text: '{"data": "value"}' }] };
expect(formatResultSummary("unknown_tool", result)).toBe("");
});
it("should return empty string for null result", () => {
expect(formatResultSummary("glob", null)).toBe("");
});
});
});

View file

@ -1,318 +0,0 @@
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import { colors, createSpinner, dim } from "./colors.js";
import { extractText, extractThinking } from "@multica/core";
import type { ReasoningMode } from "@multica/core";
export type AgentOutputState = {
lastAssistantText: string;
lastAssistantThinking: string;
printedLen: number;
printedThinkingLen: number;
streaming: boolean;
};
export type AgentOutput = {
state: AgentOutputState;
handleEvent: (event: AgentEvent) => void;
};
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "…" : s;
}
// Exported for testing
export function toolDisplayName(name: string): string {
const map: Record<string, string> = {
read: "ReadFile",
write: "WriteFile",
edit: "EditFile",
exec: "Exec",
process: "Process",
grep: "Grep",
find: "FindFiles",
ls: "ListDir",
glob: "Glob",
web_search: "WebSearch",
web_fetch: "WebFetch",
};
return map[name] || name;
}
// Exported for testing
export function formatToolArgs(name: string, args: unknown): string {
if (!args || typeof args !== "object") return "";
const record = args as Record<string, unknown>;
const get = (key: string) => (record[key] !== undefined ? String(record[key]) : "");
switch (name) {
case "read":
return get("path") || get("file");
case "write":
return get("path") || get("file");
case "edit":
return get("path") || get("file");
case "grep":
return [get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
case "find":
return [get("glob") || get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
case "ls":
return get("path") || get("directory");
case "exec":
return get("command");
case "process":
return [get("action"), get("id")].filter(Boolean).join(" ");
case "glob":
return [get("pattern"), get("cwd")].filter(Boolean).join(" in ");
case "web_search":
return truncate(get("query"), 50);
case "web_fetch": {
const url = get("url");
try {
const parsed = new URL(url);
return parsed.hostname + (parsed.pathname !== "/" ? truncate(parsed.pathname, 30) : "");
} catch {
return truncate(url, 50);
}
}
default:
return "";
}
}
function formatToolLine(name: string, args: unknown, result?: unknown): string {
const title = colors.toolName(toolDisplayName(name));
const argText = formatToolArgs(name, args);
const resultSummary = formatResultSummary(name, result);
const bullet = colors.toolBullet("•");
let line = `${bullet} ${title}`;
if (argText) {
line += ` ${colors.toolArgs(`(${argText})`)}`;
}
if (resultSummary) {
line += ` ${colors.toolArrow("→")} ${colors.toolArgs(resultSummary)}`;
}
return line;
}
// Exported for testing
export function extractResultDetails(result: unknown): Record<string, unknown> | null {
if (!result || typeof result !== "object") return null;
// Try to extract from AgentMessage content array (JSON result)
const msg = result as { content?: Array<{ type: string; text?: string }> };
if (Array.isArray(msg.content)) {
for (const c of msg.content) {
if (c.type === "text" && c.text) {
try {
return JSON.parse(c.text) as Record<string, unknown>;
} catch {
continue;
}
}
}
}
const withDetails = result as { details?: unknown };
if (withDetails.details && typeof withDetails.details === "object") {
return withDetails.details as Record<string, unknown>;
}
// Try direct object access
return result as Record<string, unknown>;
}
// Exported for testing
export function formatResultSummary(name: string, result: unknown): string {
const details = extractResultDetails(result);
if (!details) return "";
switch (name) {
case "glob": {
const count = details.count ?? (Array.isArray(details.files) ? details.files.length : 0);
const truncated = details.truncated ? "+" : "";
return `${count}${truncated} files`;
}
case "web_search": {
if (details.error) return `error: ${details.message || details.error}`;
if (details.content) {
// Perplexity result
const citations = Array.isArray(details.citations) ? details.citations.length : 0;
return `${citations} citations`;
}
// Brave result
const count = details.count ?? (Array.isArray(details.results) ? details.results.length : 0);
return `${count} results`;
}
case "web_fetch": {
if (details.error) return `error: ${details.message || details.error}`;
const parts: string[] = [];
if (details.title) {
parts.push(`"${truncate(String(details.title), 30)}"`);
}
if (typeof details.length === "number") {
const kb = (details.length / 1024).toFixed(1);
parts.push(`${kb}KB`);
}
if (details.cached) {
parts.push("cached");
}
return parts.join(", ");
}
case "grep": {
// Try to count matches from result text
const text = extractText(result as AgentMessage | undefined);
if (text.includes("No matches found")) return "no matches";
const lines = text.split("\n").filter((l) => l.trim()).length;
if (lines > 0) return `${lines} matches`;
return "";
}
default:
return "";
}
}
export function createAgentOutput(params: {
stdout: NodeJS.WritableStream;
stderr: NodeJS.WritableStream;
reasoningMode?: ReasoningMode;
}): AgentOutput {
const reasoningMode = params.reasoningMode ?? "stream";
const state: AgentOutputState = {
lastAssistantText: "",
lastAssistantThinking: "",
printedLen: 0,
printedThinkingLen: 0,
streaming: false,
};
// Create spinner for thinking indicator
const spinner = createSpinner({ stream: params.stderr });
let pendingToolName = "";
let pendingToolArgs: unknown = null;
const handleEvent = (event: AgentEvent) => {
switch (event.type) {
case "message_start": {
const msg = event.message;
if (msg.role === "assistant") {
// Stop any running spinner when assistant starts responding
if (spinner.isSpinning()) {
spinner.stop();
}
state.streaming = true;
state.printedLen = 0;
state.printedThinkingLen = 0;
const text = extractText(msg);
if (text.length > 0) {
params.stdout.write(text);
state.printedLen = text.length;
}
// Stream thinking content in real-time
if (reasoningMode === "stream") {
const thinking = extractThinking(msg);
if (thinking.length > 0) {
params.stderr.write(dim(thinking));
state.printedThinkingLen = thinking.length;
}
}
}
break;
}
case "message_update": {
const msg = event.message;
if (msg.role === "assistant") {
const text = extractText(msg);
if (text.length > state.printedLen) {
params.stdout.write(text.slice(state.printedLen));
state.printedLen = text.length;
}
// Stream thinking content in real-time
if (reasoningMode === "stream") {
const thinking = extractThinking(msg);
if (thinking.length > state.printedThinkingLen) {
params.stderr.write(dim(thinking.slice(state.printedThinkingLen)));
state.printedThinkingLen = thinking.length;
}
}
}
break;
}
case "message_end": {
const msg = event.message;
if (msg.role === "assistant") {
const text = extractText(msg);
if (text.length > state.printedLen) {
params.stdout.write(text.slice(state.printedLen));
state.printedLen = text.length;
}
if (state.streaming) params.stdout.write("\n");
state.streaming = false;
state.lastAssistantText = text;
// Extract and store thinking content (skip when off)
const thinking = reasoningMode !== "off" ? extractThinking(msg) : "";
state.lastAssistantThinking = thinking;
// Show thinking at end for "on" mode
if (reasoningMode === "on" && thinking) {
params.stderr.write(`\n${dim("--- Thinking ---")}\n`);
params.stderr.write(dim(thinking));
params.stderr.write(`\n${dim("--- End Thinking ---")}\n`);
}
// Finish streaming thinking with a newline
if (reasoningMode === "stream" && state.printedThinkingLen > 0) {
params.stderr.write("\n");
}
}
break;
}
case "tool_execution_start": {
pendingToolName = event.toolName;
pendingToolArgs = event.args;
const title = colors.toolName(toolDisplayName(event.toolName));
const argText = formatToolArgs(event.toolName, event.args);
const displayText = argText ? `${title} ${colors.toolArgs(`(${argText})`)}` : title;
spinner.start(displayText);
break;
}
case "tool_execution_update": {
// Show real-time output updates (e.g., from exec tool)
const updateText = extractText(event.partialResult);
if (updateText && pendingToolName) {
const title = colors.toolName(toolDisplayName(pendingToolName));
const preview = colors.toolArgs(updateText.slice(-50).replace(/\n/g, " "));
spinner.update(`${title} ${colors.toolArrow("→")} ${preview}`);
}
break;
}
case "tool_execution_end": {
// Stop spinner and show final result with summary
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)}`);
} else {
spinner.stop(formatToolLine(event.toolName, pendingToolArgs, event.result));
}
pendingToolName = "";
pendingToolArgs = null;
break;
}
default:
break;
}
};
return { state, handleEvent };
}

View file

@ -1,192 +0,0 @@
#!/usr/bin/env node
/**
* Agent Profile CLI
*
* Commands:
* new <id> Create a new profile with default templates
* list List all profiles
* show <id> Show profile contents
* edit <id> Open profile directory
*/
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import {
createAgentProfile,
loadAgentProfile,
getProfileDir,
profileExists,
} from "@multica/core";
import { DATA_DIR } from "@multica/utils";
const DEFAULT_BASE_DIR = join(DATA_DIR, "agent-profiles");
type Command = "new" | "list" | "show" | "edit" | "help";
function printUsage() {
console.log("Usage: pnpm profile <command> [options]");
console.log("");
console.log("Commands:");
console.log(" new <id> Create a new profile with default templates");
console.log(" list List all profiles");
console.log(" show <id> Show profile contents");
console.log(" edit <id> Open profile directory in Finder/file manager");
console.log(" help Show this help");
console.log("");
console.log("Examples:");
console.log(" pnpm profile new my-agent");
console.log(" pnpm profile list");
console.log(" pnpm profile show my-agent");
}
function cmdNew(profileId: string | undefined) {
if (!profileId) {
console.error("Error: Profile ID is required");
console.error("Usage: pnpm profile new <id>");
process.exit(1);
}
// Validate profile ID
if (!/^[a-zA-Z0-9_-]+$/.test(profileId)) {
console.error("Error: Profile ID can only contain letters, numbers, hyphens, and underscores");
process.exit(1);
}
if (profileExists(profileId)) {
console.error(`Error: Profile "${profileId}" already exists`);
console.error(`Location: ${getProfileDir(profileId)}`);
process.exit(1);
}
const profile = createAgentProfile(profileId);
const dir = getProfileDir(profileId);
console.log(`Created profile: ${profile.id}`);
console.log(`Location: ${dir}`);
console.log("");
console.log("Files created:");
console.log(" - soul.md (identity, personality and behavior)");
console.log(" - user.md (information about the user)");
console.log(" - workspace.md (workspace rules and conventions)");
console.log("");
console.log("Edit these files to customize your agent, then run:");
console.log(` pnpm agent:cli --profile ${profileId} "Hello"`);
}
function cmdList() {
if (!existsSync(DEFAULT_BASE_DIR)) {
console.log("No profiles found.");
console.log(`Create one with: pnpm profile new <id>`);
return;
}
const entries = readdirSync(DEFAULT_BASE_DIR, { withFileTypes: true });
const profiles = entries.filter((e) => e.isDirectory()).map((e) => e.name);
if (profiles.length === 0) {
console.log("No profiles found.");
console.log(`Create one with: pnpm profile new <id>`);
return;
}
console.log("Available profiles:");
console.log("");
for (const id of profiles) {
const dir = getProfileDir(id);
console.log(` ${id}`);
console.log(` ${dir}`);
}
console.log("");
console.log(`Total: ${profiles.length} profile(s)`);
}
function cmdShow(profileId: string | undefined) {
if (!profileId) {
console.error("Error: Profile ID is required");
console.error("Usage: pnpm profile show <id>");
process.exit(1);
}
const profile = loadAgentProfile(profileId);
if (!profile) {
console.error(`Error: Profile "${profileId}" not found`);
console.error(`Create it with: pnpm profile new ${profileId}`);
process.exit(1);
}
console.log(`Profile: ${profile.id}`);
console.log(`Location: ${getProfileDir(profileId)}`);
console.log("");
if (profile.soul) {
console.log("=== soul.md ===");
console.log(profile.soul.trim());
console.log("");
}
if (profile.user) {
console.log("=== user.md ===");
console.log(profile.user.trim());
console.log("");
}
if (profile.workspace) {
console.log("=== workspace.md ===");
console.log(profile.workspace.trim());
console.log("");
}
}
async function cmdEdit(profileId: string | undefined) {
if (!profileId) {
console.error("Error: Profile ID is required");
console.error("Usage: pnpm profile edit <id>");
process.exit(1);
}
if (!profileExists(profileId)) {
console.error(`Error: Profile "${profileId}" not found`);
console.error(`Create it with: pnpm profile new ${profileId}`);
process.exit(1);
}
const dir = getProfileDir(profileId);
const { spawn } = await import("node:child_process");
// Open in default file manager
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
spawn(cmd, [dir], { detached: true, stdio: "ignore" }).unref();
console.log(`Opened: ${dir}`);
}
async function main() {
const args = process.argv.slice(2);
const command = (args[0] || "help") as Command;
const arg1 = args[1];
switch (command) {
case "new":
cmdNew(arg1);
break;
case "list":
cmdList();
break;
case "show":
cmdShow(arg1);
break;
case "edit":
await cmdEdit(arg1);
break;
case "help":
default:
printUsage();
break;
}
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

View file

@ -1,540 +0,0 @@
#!/usr/bin/env node
/**
* Skills CLI
*
* Command-line interface for managing skills
*
* Usage:
* pnpm skills:cli list List all skills
* pnpm skills:cli status [id] Show skill status
* pnpm skills:cli install <id> Install skill dependencies
* pnpm skills:cli add <source> Add skill from GitHub
* pnpm skills:cli remove <name> Remove an installed skill
*/
import {
SkillManager,
installSkill,
getInstallOptions,
addSkill,
removeSkill,
listInstalledSkills,
checkEligibilityDetailed,
type DiagnosticItem,
} from "@multica/core";
// ============================================================================
// Types
// ============================================================================
type Command = "list" | "status" | "install" | "add" | "remove" | "help";
interface ParsedArgs {
command: Command;
args: string[];
verbose: boolean;
force: boolean;
}
// ============================================================================
// Argument Parsing
// ============================================================================
function parseArgs(argv: string[]): ParsedArgs {
const args = [...argv];
let verbose = false;
let force = false;
const positional: string[] = [];
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--verbose" || arg === "-v") {
verbose = true;
continue;
}
if (arg === "--force" || arg === "-f") {
force = true;
continue;
}
if (arg === "--help" || arg === "-h") {
return { command: "help", args: [], verbose, force };
}
positional.push(arg);
}
const command = (positional[0] ?? "help") as Command;
const commandArgs = positional.slice(1);
return { command, args: commandArgs, verbose, force };
}
// ============================================================================
// Commands
// ============================================================================
function printHelp(): void {
console.log(`
Skills CLI - Manage super-multica skills
Usage:
pnpm skills:cli <command> [options]
Commands:
list List all available skills
status [id] Show detailed status of a skill (or all skills)
install <id> Install dependencies for a skill
add <source> Add skill from GitHub (owner/repo or owner/repo/skill)
remove <name> Remove an installed skill
Options:
-v, --verbose Show more details
-f, --force Force overwrite existing skill
-h, --help Show this help
Examples:
pnpm skills:cli list
pnpm skills:cli status commit
pnpm skills:cli install nano-pdf
pnpm skills:cli add vercel-labs/agent-skills
pnpm skills:cli add vercel-labs/agent-skills/perplexity
pnpm skills:cli remove agent-skills
`);
}
function cmdList(manager: SkillManager, verbose: boolean): void {
const skills = manager.listAllSkillsWithStatus();
if (skills.length === 0) {
console.log("No skills found.");
return;
}
console.log("\nAvailable Skills:\n");
for (const skill of skills) {
const status = skill.eligible ? "✓" : "✗";
const statusColor = skill.eligible ? "\x1b[32m" : "\x1b[31m";
const reset = "\x1b[0m";
console.log(` ${statusColor}${status}${reset} ${skill.emoji} ${skill.name} (${skill.id})`);
console.log(` ${skill.description}`);
console.log(` Source: ${skill.source}`);
if (!skill.eligible && skill.reasons) {
for (const reason of skill.reasons) {
console.log(` ${statusColor}${reason}${reset}`);
}
}
if (verbose) {
console.log();
}
}
console.log();
const eligibleCount = skills.filter((s) => s.eligible).length;
console.log(`Total: ${skills.length} skills (${eligibleCount} eligible)`);
}
function cmdStatus(manager: SkillManager, skillId?: string, verbose?: boolean): void {
if (!skillId) {
// Show summary status with diagnostics
cmdStatusSummary(manager, verbose);
return;
}
// Show specific skill status with detailed diagnostics
cmdStatusDetail(manager, skillId, verbose);
}
function cmdStatusSummary(manager: SkillManager, verbose?: boolean): void {
const skills = manager.listAllSkillsWithStatus();
const eligible = skills.filter((s) => s.eligible);
const ineligible = skills.filter((s) => !s.eligible);
console.log("\nSkills Status Summary:\n");
console.log(` Total: ${skills.length}`);
console.log(` \x1b[32mEligible: ${eligible.length}\x1b[0m`);
console.log(` \x1b[31mIneligible: ${ineligible.length}\x1b[0m`);
if (ineligible.length > 0) {
console.log("\n─────────────────────────────────────────");
console.log("Ineligible Skills:");
// Group by issue type
const byIssue: Map<string, string[]> = new Map();
for (const s of ineligible) {
const skill = manager.getSkillFromAll(s.id);
if (skill) {
const detailed = checkEligibilityDetailed(skill);
const mainIssue = detailed.diagnostics?.[0]?.type ?? "unknown";
const existing = byIssue.get(mainIssue) ?? [];
existing.push(s.id);
byIssue.set(mainIssue, existing);
}
}
// Print grouped issues
const issueLabels: Record<string, string> = {
disabled: "Disabled in config",
not_in_allowlist: "Not in allowlist",
platform: "Platform mismatch",
binary: "Missing binaries",
any_binary: "Missing binaries (any)",
env: "Missing environment variables",
config: "Missing config values",
unknown: "Unknown issues",
};
for (const [issue, skillIds] of byIssue) {
const label = issueLabels[issue] ?? issue;
console.log(`\n \x1b[33m${label}:\x1b[0m`);
for (const id of skillIds) {
const skill = manager.getSkillFromAll(id);
if (skill && verbose) {
const detailed = checkEligibilityDetailed(skill);
const diag = detailed.diagnostics?.[0];
console.log(` - ${id}`);
if (diag?.hint) {
console.log(` \x1b[36mHint: ${diag.hint}\x1b[0m`);
}
} else {
console.log(` - ${id}`);
}
}
}
console.log("\n─────────────────────────────────────────");
console.log(`\x1b[36mTip: Run 'pnpm skills:cli status <skill-id>' for detailed diagnostics\x1b[0m`);
}
}
function cmdStatusDetail(manager: SkillManager, skillId: string, verbose?: boolean): void {
const skill = manager.getSkillFromAll(skillId);
if (!skill) {
console.error(`Skill not found: ${skillId}`);
process.exit(1);
}
const detailed = checkEligibilityDetailed(skill);
const metadata = skill.frontmatter.metadata;
console.log(`\n${metadata?.emoji ?? "🔧"} ${skill.frontmatter.name}`);
console.log("═".repeat(50));
console.log(`ID: ${skill.id}`);
console.log(`Description: ${skill.frontmatter.description ?? "N/A"}`);
console.log(`Version: ${skill.frontmatter.version ?? "N/A"}`);
console.log(`Source: ${skill.source}`);
console.log(`Path: ${skill.filePath}`);
console.log(`Homepage: ${skill.frontmatter.homepage ?? metadata?.homepage ?? "N/A"}`);
console.log();
console.log("─".repeat(50));
console.log(`Status: ${detailed.eligible ? "\x1b[32m✓ ELIGIBLE\x1b[0m" : "\x1b[31m✗ NOT ELIGIBLE\x1b[0m"}`);
// Show detailed diagnostics
if (!detailed.eligible && detailed.diagnostics) {
console.log("\nDiagnostics:");
for (const diag of detailed.diagnostics) {
printDiagnostic(diag);
}
}
// Show requirements summary
const requirements = metadata?.requires;
const hasBins = requirements?.bins?.length ?? metadata?.requiresBinaries?.length ?? 0;
const hasAnyBins = requirements?.anyBins?.length ?? 0;
const hasEnvs = requirements?.env?.length ?? metadata?.requiresEnv?.length ?? 0;
if (hasBins > 0 || hasAnyBins > 0 || hasEnvs > 0) {
console.log("\n─".repeat(50));
console.log("Requirements:");
if (hasBins > 0) {
const bins = requirements?.bins ?? metadata?.requiresBinaries ?? [];
printRequirementStatus("Binaries (all required)", bins, checkBinaries);
}
if (hasAnyBins > 0) {
const anyBins = requirements?.anyBins ?? [];
printRequirementStatus("Binaries (any one)", anyBins, checkBinaries, true);
}
if (hasEnvs > 0) {
const envs = requirements?.env ?? metadata?.requiresEnv ?? [];
printRequirementStatus("Environment vars", envs, (e) => checkEnvVars(e, skill.env));
}
}
// Show install options
const installOptions = getInstallOptions(skill);
if (installOptions.length > 0) {
console.log("\n─".repeat(50));
console.log("Install Options:");
for (const opt of installOptions) {
const status = opt.available ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
console.log(` ${status} [${opt.id}] ${opt.label}`);
if (!opt.available && opt.reason) {
console.log(`${opt.reason}`);
}
}
}
// Show quick actions if not eligible
if (!detailed.eligible) {
console.log("\n─".repeat(50));
console.log("\x1b[33mQuick Actions:\x1b[0m");
for (const diag of detailed.diagnostics ?? []) {
if (diag.hint) {
console.log(`${diag.hint}`);
}
}
if (installOptions.length > 0) {
console.log(` → pnpm skills:cli install ${skillId}`);
}
}
}
function printDiagnostic(diag: DiagnosticItem): void {
const typeColors: Record<string, string> = {
disabled: "\x1b[33m",
not_in_allowlist: "\x1b[33m",
platform: "\x1b[35m",
binary: "\x1b[31m",
any_binary: "\x1b[31m",
env: "\x1b[34m",
config: "\x1b[36m",
};
const color = typeColors[diag.type] ?? "\x1b[37m";
const reset = "\x1b[0m";
console.log(`\n ${color}[${diag.type.toUpperCase()}]${reset}`);
console.log(` ${diag.message}`);
if (diag.values && diag.values.length > 0) {
console.log(` Values: ${diag.values.join(", ")}`);
}
if (diag.hint) {
console.log(` \x1b[36m💡 ${diag.hint}${reset}`);
}
}
function printRequirementStatus(
label: string,
items: string[],
checker: (items: string[]) => Map<string, boolean>,
anyMode: boolean = false,
): void {
const status = checker(items);
const found = Array.from(status.entries()).filter(([, ok]) => ok).map(([name]) => name);
const missing = Array.from(status.entries()).filter(([, ok]) => !ok).map(([name]) => name);
const allOk = anyMode ? found.length > 0 : missing.length === 0;
const statusIcon = allOk ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
console.log(`\n ${statusIcon} ${label}:`);
for (const [name, ok] of status) {
const icon = ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
console.log(` ${icon} ${name}`);
}
}
function checkBinaries(bins: string[]): Map<string, boolean> {
const result = new Map<string, boolean>();
for (const bin of bins) {
try {
const cmd = process.platform === "win32" ? `where ${bin}` : `which ${bin}`;
require("child_process").execSync(cmd, { stdio: "ignore" });
result.set(bin, true);
} catch {
result.set(bin, false);
}
}
return result;
}
function checkEnvVars(envs: string[], skillEnv: Record<string, string>): Map<string, boolean> {
const result = new Map<string, boolean>();
for (const env of envs) {
const found = Object.prototype.hasOwnProperty.call(skillEnv, env) || env in process.env;
result.set(env, found);
}
return result;
}
async function cmdInstall(manager: SkillManager, skillId: string, installId?: string): Promise<void> {
const skill = manager.getSkillFromAll(skillId);
if (!skill) {
console.error(`Skill not found: ${skillId}`);
process.exit(1);
}
const installOptions = getInstallOptions(skill);
if (installOptions.length === 0) {
console.error(`Skill '${skillId}' has no install specifications.`);
process.exit(1);
}
// Show available options if multiple
if (!installId && installOptions.length > 1) {
console.log(`\nMultiple install options available for '${skillId}':\n`);
for (const opt of installOptions) {
const status = opt.available ? "available" : `unavailable: ${opt.reason}`;
console.log(` [${opt.id}] ${opt.label} (${status})`);
}
console.log(`\nUse: pnpm skills:cli install ${skillId} <install-id>`);
return;
}
console.log(`\nInstalling dependencies for '${skillId}'...`);
const result = await installSkill({
skill,
installId,
});
if (result.ok) {
console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`);
} else {
console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`);
if (result.stderr) {
console.error("\nError output:");
console.error(result.stderr);
}
process.exit(1);
}
}
// ============================================================================
// Add/Remove Commands
// ============================================================================
async function cmdAdd(source: string, force: boolean): Promise<void> {
console.log(`\nAdding skill from '${source}'...`);
const result = await addSkill({
source,
force,
});
if (result.ok) {
console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`);
if (result.skills && result.skills.length > 1) {
console.log("\nSkills found:");
for (const name of result.skills) {
console.log(` - ${name}`);
}
}
if (result.path) {
console.log(`\nInstalled to: ${result.path}`);
}
} else {
console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`);
process.exit(1);
}
}
async function cmdRemove(name: string): Promise<void> {
console.log(`\nRemoving skill '${name}'...`);
const result = await removeSkill(name);
if (result.ok) {
console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`);
} else {
console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`);
process.exit(1);
}
}
async function cmdListInstalled(): Promise<void> {
const skills = await listInstalledSkills();
if (skills.length === 0) {
console.log("\nNo skills installed in ~/.super-multica/skills/");
console.log("Use 'pnpm skills:cli add <source>' to add skills.");
return;
}
console.log("\nInstalled skills (~/.super-multica/skills/):\n");
for (const name of skills) {
console.log(` - ${name}`);
}
console.log(`\nTotal: ${skills.length} installed`);
}
// ============================================================================
// Main
// ============================================================================
async function main(): Promise<void> {
const { command, args, verbose, force } = parseArgs(process.argv.slice(2));
if (command === "help") {
printHelp();
return;
}
switch (command) {
case "add":
if (!args[0]) {
console.error("Usage: pnpm skills:cli add <source> [--force]");
console.error("\nSource formats:");
console.error(" owner/repo Clone entire repository");
console.error(" owner/repo/skill-name Clone single skill directory");
console.error(" owner/repo@branch Clone specific branch/tag");
process.exit(1);
}
await cmdAdd(args[0], force);
return;
case "remove":
if (!args[0]) {
console.error("Usage: pnpm skills:cli remove <skill-name>");
await cmdListInstalled();
process.exit(1);
}
await cmdRemove(args[0]);
return;
}
// Commands that need SkillManager
const manager = new SkillManager();
switch (command) {
case "list":
cmdList(manager, verbose);
break;
case "status":
cmdStatus(manager, args[0], verbose);
break;
case "install":
if (!args[0]) {
console.error("Usage: pnpm skills:cli install <skill-id> [install-id]");
process.exit(1);
}
await cmdInstall(manager, args[0], args[1]);
break;
default:
console.error(`Unknown command: ${command}`);
printHelp();
process.exit(1);
}
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

View file

@ -1,168 +0,0 @@
#!/usr/bin/env node
/**
* CLI tool to inspect and test tool policy configuration.
*
* Usage:
* pnpm tools:cli list # List all available tools
* pnpm tools:cli list --allow group:fs # List tools after allowing fs group
* pnpm tools:cli list --deny exec # List tools after denying exec
* pnpm tools:cli groups # Show all tool groups
*/
import { createAllTools } from "@multica/core";
import { filterTools, type ToolsConfig } from "@multica/core";
import { TOOL_GROUPS, expandToolGroups } from "@multica/core";
type Command = "list" | "groups" | "help";
interface CliOptions {
command: Command;
allow?: string[];
deny?: string[];
provider?: string | undefined;
isSubagent?: boolean;
}
function printUsage() {
console.log("Usage: pnpm tools:cli <command> [options]");
console.log("");
console.log("Commands:");
console.log(" list List available tools (with optional filtering)");
console.log(" groups Show all tool groups");
console.log(" help Show this help");
console.log("");
console.log("Options for 'list':");
console.log(" --allow TOOLS Allow specific tools (comma-separated)");
console.log(" --deny TOOLS Deny specific tools (comma-separated)");
console.log(" --provider NAME Apply provider-specific rules");
console.log(" --subagent Apply subagent restrictions");
console.log("");
console.log("Examples:");
console.log(" pnpm tools:cli list");
console.log(" pnpm tools:cli list --deny exec");
console.log(" pnpm tools:cli list --allow group:fs,web_fetch");
console.log(" pnpm tools:cli groups");
}
function parseArgs(argv: string[]): CliOptions {
const args = [...argv];
const command = (args.shift() || "help") as Command;
const opts: CliOptions = { command };
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--allow") {
const value = args.shift();
opts.allow = value?.split(",").map((s) => s.trim()) ?? [];
continue;
}
if (arg === "--deny") {
const value = args.shift();
opts.deny = value?.split(",").map((s) => s.trim()) ?? [];
continue;
}
if (arg === "--provider") {
opts.provider = args.shift();
continue;
}
if (arg === "--subagent") {
opts.isSubagent = true;
continue;
}
}
return opts;
}
function listTools(opts: CliOptions) {
const allTools = createAllTools(process.cwd());
console.log(`Total tools available: ${allTools.length}`);
console.log("");
// Build config
let config: ToolsConfig | undefined;
if (opts.allow || opts.deny) {
config = {};
if (opts.allow) {
config.allow = opts.allow;
}
if (opts.deny) {
config.deny = opts.deny;
}
}
const filterOpts: import("@multica/core").FilterToolsOptions = {};
if (config) {
filterOpts.config = config;
}
if (opts.provider) {
filterOpts.provider = opts.provider;
}
if (opts.isSubagent) {
filterOpts.isSubagent = opts.isSubagent;
}
const filtered = filterTools(allTools, filterOpts);
if (config || opts.provider || opts.isSubagent) {
console.log("Applied filters:");
if (opts.allow) console.log(` Allow: ${opts.allow.join(", ")}`);
if (opts.deny) console.log(` Deny: ${opts.deny.join(", ")}`);
if (opts.provider) console.log(` Provider: ${opts.provider}`);
if (opts.isSubagent) console.log(` Subagent: true`);
console.log("");
console.log(`Tools after filtering: ${filtered.length}`);
console.log("");
}
console.log("Tools:");
for (const tool of filtered) {
const desc = tool.description?.slice(0, 60) || "";
console.log(` ${tool.name.padEnd(15)} ${desc}${desc.length >= 60 ? "..." : ""}`);
}
if (filtered.length < allTools.length) {
const removed = allTools.filter((t) => !filtered.find((f) => f.name === t.name));
console.log("");
console.log(`Filtered out (${removed.length}):`);
for (const tool of removed) {
console.log(` ${tool.name}`);
}
}
}
function showGroups() {
console.log("Tool Groups:");
console.log("");
for (const [name, tools] of Object.entries(TOOL_GROUPS)) {
console.log(` ${name}:`);
console.log(` ${tools.join(", ")}`);
console.log("");
}
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
switch (opts.command) {
case "list":
listTools(opts);
break;
case "groups":
showGroups();
break;
case "help":
default:
printUsage();
break;
}
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

View file

@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,15 +0,0 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
banner: {
js: '#!/usr/bin/env node',
},
external: [
/^node:/,
],
})

View file

@ -1,8 +0,0 @@
# Gateway WebSocket URL (dev)
MAIN_VITE_GATEWAY_URL=https://multica-dev.copilothub.ai
# Web URL for OAuth login (dev)
MAIN_VITE_WEB_URL=http://localhost:3000
# API URL for renderer (dev)
RENDERER_VITE_MULTICA_API_URL=https://api-dev.copilothub.ai
# API URL for core/tools and main process bridge (dev)
MULTICA_API_URL=https://api-dev.copilothub.ai

View file

@ -1,99 +0,0 @@
# =============================================================================
# Multica Desktop Environment Configuration
# =============================================================================
#
# This file documents all available environment variables for Desktop builds.
# Copy this file and rename it based on your target environment.
#
# =============================================================================
# Build Commands
# =============================================================================
#
# Development (no .env file needed):
# pnpm dev # Uses hardcoded dev defaults
#
# Staging Build:
# pnpm build:staging # Uses .env.staging
#
# Production Build:
# pnpm build:production # Uses .env.production
#
# Default Build (uses .env.production):
# pnpm build:desktop # Same as build:production
#
# =============================================================================
# Build Output
# =============================================================================
#
# After build completes, installer packages are located at:
#
# apps/desktop/release/{version}/
# ├── Multica-{version}-arm64.dmg # macOS Apple Silicon
# ├── Multica-{version}-x64.dmg # macOS Intel
# ├── Multica-Windows-{version}-Setup.exe # Windows
# ├── Multica-Linux-{version}.AppImage # Linux AppImage
# └── Multica-Linux-{version}.deb # Linux deb
#
# Compiled code (before packaging) is in:
# apps/desktop/out/
#
# =============================================================================
# Configuration Guide
# =============================================================================
#
# For production builds, create .env.production with the following variables:
#
# Required:
# MAIN_VITE_GATEWAY_URL - WebSocket Gateway URL for remote connections
# MAIN_VITE_WEB_URL - Web App URL for OAuth login flow
# RENDERER_VITE_MULTICA_API_URL - API URL for renderer process (UI requests)
# MULTICA_API_URL - API URL for core/tools and main process bridge
#
# Example .env.production:
# MAIN_VITE_GATEWAY_URL=https://gateway.multica.ai
# MAIN_VITE_WEB_URL=https://www.multica.ai
# RENDERER_VITE_MULTICA_API_URL=https://api.multica.ai
# MULTICA_API_URL=https://api.multica.ai
#
# =============================================================================
# Variable Naming Convention
# =============================================================================
#
# MAIN_VITE_* - Main process only (Node.js, full system access)
# RENDERER_VITE_* - Renderer process only (browser context)
# VITE_* - All processes
# MULTICA_* - Core/CLI/Gateway (read via process.env at runtime)
# Also available in main process via envPrefix config
#
# =============================================================================
# Environment Variables
# =============================================================================
# MAIN_VITE_GATEWAY_URL
# WebSocket Gateway URL - Hub connects here for remote device access (QR code pairing)
# Production example: https://gateway.multica.ai
MAIN_VITE_GATEWAY_URL=http://localhost:3000
# MAIN_VITE_WEB_URL
# Web App URL - Desktop opens this URL for user login (OAuth flow)
# Production example: https://www.multica.ai
MAIN_VITE_WEB_URL=http://localhost:3000
# RENDERER_VITE_MULTICA_API_URL
# API URL for renderer process - Used by the React UI for login, user info, etc.
# Production example: https://api.multica.ai
RENDERER_VITE_MULTICA_API_URL=http://localhost:8080
# MULTICA_API_URL
# API URL for core/tools and main process bridge - Used by agent engine tools
# (web-search, finance) and bridged to process.env in packaged builds.
# Read by core package via process.env.MULTICA_API_URL at runtime.
# Production example: https://api.multica.ai
MULTICA_API_URL=http://localhost:8080
# SMC_DATA_DIR
# Root data directory override - Isolates dev data from production.
# When set, all data (sessions, credentials, profiles, app-state, etc.)
# is stored under this directory instead of ~/.super-multica.
# Supports ~ expansion. Set automatically by scripts/dev-local.sh.
# SMC_DATA_DIR=~/.super-multica-dev

View file

@ -1,8 +0,0 @@
# Gateway WebSocket URL
MAIN_VITE_GATEWAY_URL=https://gateway.multica.ai
# Web URL for OAuth login
MAIN_VITE_WEB_URL=https://www.multica.ai
# API URL for renderer (UI requests)
RENDERER_VITE_MULTICA_API_URL=https://api.multica.ai
# API URL for core/tools and main process bridge
MULTICA_API_URL=https://api.multica.ai

View file

@ -1,19 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': 'warn',
},
}

View file

@ -1,8 +0,0 @@
# Build outputs (keep build/ for icons and entitlements)
!build/
dist
dist-electron
dist-ssr
out
release
*.local

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 B

View file

@ -1,3 +0,0 @@
provider: github
owner: multica-ai
repo: super-multica

View file

@ -1,87 +0,0 @@
// @see - https://www.electron.build/configuration/configuration
{
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "com.multica.app",
"asar": true,
"productName": "Multica",
"directories": {
"buildResources": "build",
"output": "release/${version}"
},
"files": [
"out",
"build/trayTemplate*.png",
"!**/.vscode/*",
"!src/*",
"!electron.vite.config.{js,ts,mjs,cjs}",
"!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml}",
"!{.env,.env.*,.npmrc}",
"!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
],
"mac": {
"hardenedRuntime": true,
"notarize": true,
"target": [
"dmg",
"zip"
],
"artifactName": "${productName}-${version}-${arch}-mac.${ext}",
"extendInfo": {
"NSCameraUsageDescription": "Application requests access to the device's camera.",
"NSMicrophoneUsageDescription": "Application requests access to the device's microphone.",
"NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.",
"NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder."
},
"protocols": [
{
"name": "Multica",
"schemes": ["multica"]
}
]
},
"dmg": {
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"win": {
"executableName": "multica",
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-Windows-${version}-Setup.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"shortcutName": "${productName}",
"uninstallDisplayName": "${productName}",
"createDesktopShortcut": "always"
},
"protocols": [
{
"name": "Multica",
"schemes": ["multica"]
}
],
"linux": {
"target": [
"AppImage",
"deb"
],
"maintainer": "multica.ai",
"category": "Utility",
"artifactName": "${productName}-Linux-${version}.${ext}"
},
"npmRebuild": false,
"publish": {
"provider": "github",
"owner": "multica-ai",
"repo": "super-multica"
}
}

View file

@ -1,29 +0,0 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import path from 'node:path'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
envPrefix: ['MAIN_VITE_', 'MULTICA_'],
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
output: {
format: 'cjs',
},
},
},
},
renderer: {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/renderer/src'),
},
},
},
})

View file

@ -1,54 +0,0 @@
{
"name": "@multica/desktop",
"private": true,
"version": "0.1.3",
"description": "Multica Desktop - AI Agent Hub",
"author": "Multica AI <dev@multica.ai>",
"homepage": "https://github.com/multica-ai/super-multica",
"type": "module",
"scripts": {
"dev": "electron-vite dev",
"dev:onboarding": "electron-vite dev -- --force-onboarding",
"build": "electron-vite build && electron-builder --publish never",
"build:staging": "electron-vite build --mode staging && electron-builder --publish never",
"build:production": "electron-vite build --mode production && electron-builder --publish never",
"preview": "electron-vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@multica/core": "workspace:*",
"@multica/hooks": "workspace:*",
"@multica/sdk": "workspace:*",
"@multica/store": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/utils": "workspace:*",
"electron-updater": "^6.7.3",
"lucide-react": "^0.563.0",
"qrcode.react": "^4.2.0",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "^7.13.0",
"socket.io-client": "catalog:",
"uuid": "catalog:",
"zustand": "catalog:"
},
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"@vitejs/plugin-react": "^4.2.1",
"electron": "^33.4.11",
"electron-builder": "^26.7.0",
"electron-builder-squirrel-windows": "^26.7.0",
"electron-vite": "^5.0.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "^5.1.6"
},
"main": "./out/main/index.js"
}

View file

@ -1,274 +0,0 @@
/// <reference types="vite-plugin-electron/electron-env" />
// Environment variables loaded from .env files
// See: .env.example, .env.staging, .env.production
interface ImportMetaEnv {
readonly MAIN_VITE_GATEWAY_URL: string
readonly MAIN_VITE_WEB_URL: string
readonly MULTICA_API_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare namespace NodeJS {
interface ProcessEnv {
/**
* The built directory structure
*
* ```tree
* dist
* index.html
*
* dist-electron
* main.js
* preload.js
*
* ```
*/
APP_ROOT: string
/** /dist/ or /public/ */
VITE_PUBLIC: string
}
}
// ============================================================================
// ElectronAPI type definitions
// ============================================================================
interface HubStatus {
hubId: string
status: string
agentCount: number
gatewayConnected: boolean
gatewayUrl?: string
defaultAgent?: {
agentId: string
status: string
} | null
}
interface AgentInfo {
agentId: string
status: string
}
interface ToolInfo {
name: string
group: string
enabled: boolean
}
interface SkillInfo {
id: string
name: string
description: string
version: string
enabled: boolean
source: 'bundled' | 'global' | 'profile'
triggers: string[]
}
interface DeviceMeta {
userAgent?: string
platform?: string
language?: string
clientName?: string
}
interface DeviceEntryInfo {
deviceId: string
agentId: string
conversationIds: string[]
addedAt: number
meta?: DeviceMeta
}
interface SkillAddResult {
ok: boolean
message: string
path?: string
skills?: string[]
}
interface ProfileData {
profileId: string | undefined
name: string | undefined
userContent: string | undefined
}
interface LocalChatEvent {
agentId: string
conversationId: string
streamId?: string
type?: 'error'
content?: string
event?: {
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end'
id?: string
message?: {
role: string
content?: Array<{ type: string; text?: string }>
}
[key: string]: unknown
}
}
interface LocalChatApproval {
approvalId: string
agentId: string
conversationId: string
command: string
cwd?: string
riskLevel: 'safe' | 'needs-review' | 'dangerous'
riskReasons: string[]
expiresAtMs: number
}
interface ProviderStatus {
id: string
name: string
authMethod: 'api-key' | 'oauth'
available: boolean
configured: boolean
current: boolean
defaultModel: string
models: string[]
loginUrl?: string
loginCommand?: string
loginInstructions?: string
}
interface CurrentProviderInfo {
provider: string
model: string | undefined
providerName: string | undefined
available: boolean
}
interface ChannelAccountStateInfo {
channelId: string
accountId: string
status: 'stopped' | 'starting' | 'running' | 'error'
error?: string
}
type MessageSource =
| { type: 'local' }
| { type: 'gateway'; deviceId: string }
| { type: 'channel'; channelId: string; accountId: string; conversationId: string }
interface InboundMessageEvent {
agentId: string
conversationId: string
content: string
source: MessageSource
timestamp: number
}
interface ElectronAPI {
app: {
getFlags: () => Promise<{ forceOnboarding: boolean }>
}
appState: {
getOnboardingCompleted: () => Promise<boolean>
setOnboardingCompleted: (completed: boolean) => Promise<void>
}
hub: {
init: () => Promise<unknown>
getStatus: () => Promise<HubStatus>
getAgentInfo: () => Promise<AgentInfo | null>
info: () => Promise<unknown>
reconnect: (url: string) => Promise<unknown>
listConversations: () => Promise<unknown>
createConversation: (id?: string) => Promise<unknown>
getConversation: (id: string) => Promise<unknown>
closeConversation: (id: string) => Promise<unknown>
sendMessage: (agentId: string, content: string, conversationId: string) => Promise<unknown>
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) => Promise<unknown>
onDeviceConfirmRequest: (callback: (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => void) => void
offDeviceConfirmRequest: () => void
deviceConfirmResponse: (deviceId: string, allowed: boolean) => void
listDevices: () => Promise<DeviceEntryInfo[]>
revokeDevice: (deviceId: string) => Promise<{ ok: boolean }>
onConnectionStateChanged: (callback: (state: string) => void) => void
offConnectionStateChanged: () => void
onDevicesChanged: (callback: () => void) => void
offDevicesChanged: () => void
onInboundMessage: (callback: (event: InboundMessageEvent) => void) => () => void
onConversationsChanged: (callback: () => void) => () => void
offInboundMessage: () => void
}
tools: {
list: () => Promise<ToolInfo[]>
toggle: (name: string) => Promise<unknown>
setStatus: (name: string, enabled: boolean) => Promise<unknown>
active: () => Promise<unknown>
reload: () => Promise<unknown>
}
skills: {
list: () => Promise<SkillInfo[]>
get: (id: string) => Promise<unknown>
toggle: (id: string) => Promise<unknown>
setStatus: (id: string, enabled: boolean) => Promise<unknown>
reload: () => Promise<unknown>
add: (source: string, options?: { name?: string; force?: boolean }) => Promise<SkillAddResult>
remove: (name: string) => Promise<SkillAddResult>
}
agent: {
status: () => Promise<unknown>
}
profile: {
get: () => Promise<ProfileData>
updateName: (name: string) => Promise<unknown>
updateUser: (content: string) => Promise<unknown>
}
provider: {
list: () => Promise<ProviderStatus[]>
listAvailable: () => Promise<ProviderStatus[]>
current: () => Promise<CurrentProviderInfo>
set: (providerId: string, modelId?: string) => Promise<{ ok: boolean; provider?: string; model?: string; error?: string }>
getMeta: (providerId: string) => Promise<unknown>
isAvailable: (providerId: string) => Promise<boolean>
saveApiKey: (providerId: string, apiKey: string) => Promise<{ ok: boolean; error?: string }>
importOAuth: (providerId: string) => Promise<{ ok: boolean; expiresAt?: number; error?: string }>
test: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }>
}
channels: {
listStates: () => Promise<ChannelAccountStateInfo[]>
getConfig: () => Promise<Record<string, Record<string, Record<string, unknown>> | undefined>>
saveToken: (channelId: string, accountId: string, token: string) => Promise<{ ok: boolean; error?: string }>
removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
stop: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
start: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
}
cron: {
list: () => Promise<unknown[]>
toggle: (jobId: string) => Promise<{ ok: boolean }>
remove: (jobId: string) => Promise<{ ok: boolean }>
}
heartbeat: {
last: () => Promise<unknown>
setEnabled: (enabled: boolean) => Promise<{ ok: boolean; enabled?: boolean; error?: string }>
wake: (reason?: string) => Promise<{ ok: boolean; result?: unknown; error?: string }>
}
localChat: {
subscribe: (conversationId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean; token?: number; isRunning?: boolean }>
unsubscribe: (conversationId: string, token?: number) => Promise<{ ok: boolean; skipped?: boolean; alreadyUnsubscribed?: boolean }>
getHistory: (conversationId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number; isRunning?: boolean }>
send: (conversationId: string, content: string) => Promise<{ ok?: boolean; error?: string }>
abort: (conversationId: string) => Promise<{ ok?: boolean; error?: string }>
resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }>
onEvent: (callback: (event: LocalChatEvent) => void) => () => void
offEvent: () => void
onApproval: (callback: (approval: LocalChatApproval) => void) => () => void
offApproval: () => void
}
}
// Used in Renderer process, expose in `preload.ts`
interface Window {
ipcRenderer: import('electron').IpcRenderer
electronAPI: ElectronAPI
}

View file

@ -1,250 +0,0 @@
// Patch console methods to handle EPIPE errors in Electron main process
// This MUST be done before any other imports that might use console
// EPIPE happens when stdout/stderr pipes are closed unexpectedly
const originalConsoleLog = console.log.bind(console)
const originalConsoleError = console.error.bind(console)
const originalConsoleWarn = console.warn.bind(console)
const safeLog = (...args: unknown[]) => {
try {
originalConsoleLog(...args)
} catch {
// Ignore EPIPE errors silently
}
}
const safeError = (...args: unknown[]) => {
try {
originalConsoleError(...args)
} catch {
// Ignore EPIPE errors silently
}
}
const safeWarn = (...args: unknown[]) => {
try {
originalConsoleWarn(...args)
} catch {
// Ignore EPIPE errors silently
}
}
// Override global console
console.log = safeLog
console.error = safeError
console.warn = safeWarn
// Also handle process stdout/stderr EPIPE errors
process.stdout?.on?.('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') return // Ignore
throw err
})
process.stderr?.on?.('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') return // Ignore
throw err
})
// Bridge Vite build-time env to process.env for externalized @multica/core
// In dev mode, electron-vite already loads .env into process.env;
// In packaged builds, only import.meta.env has the value (injected at build time).
if (import.meta.env.MULTICA_API_URL) {
process.env.MULTICA_API_URL ??= import.meta.env.MULTICA_API_URL
}
import { app, BrowserWindow, shell, ipcMain } from 'electron'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation, setAuthMainWindow, handleAuthDeepLink } from './ipc/index.js'
import { appStateManager } from '@multica/core'
import { createUpdater, AutoUpdater } from './updater/index.js'
import { createTray, destroyTray } from './tray.js'
// CJS output will have __dirname natively, but TypeScript source needs this for type checking
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// APP_ROOT points to apps/desktop (two levels up from out/main/)
process.env.APP_ROOT = path.join(__dirname, '../..')
// electron-vite uses ELECTRON_RENDERER_URL for dev server
export const VITE_DEV_SERVER_URL = process.env['ELECTRON_RENDERER_URL']
// electron-vite outputs to out/ directory
export const MAIN_DIST = path.join(__dirname)
export const RENDERER_DIST = path.join(__dirname, '../renderer')
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
// CLI flags
const forceOnboarding = process.argv.includes('--force-onboarding')
let win: BrowserWindow | null
let updater: AutoUpdater
let isQuitting = false
// ============================================================================
// Custom Protocol for Auth (multica://)
// ============================================================================
// Register custom protocol - must be called before app.whenReady()
if (process.defaultApp) {
// Development: need to pass the script path
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('multica', process.execPath, [path.resolve(process.argv[1])])
}
} else {
// Production
app.setAsDefaultProtocolClient('multica')
}
// Handle protocol URL on macOS (when app is already running)
app.on('open-url', (event, url) => {
event.preventDefault()
console.log('[Auth] Received open-url:', url)
if (url.startsWith('multica://')) {
handleAuthDeepLink(url)
}
})
// Handle second instance (Windows/Linux - when app is already running)
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', (_event, commandLine) => {
// Show and focus window
if (win) {
if (!win.isVisible()) win.show()
if (win.isMinimized()) win.restore()
win.focus()
}
// Handle protocol URL from command line (Windows)
const url = commandLine.find(arg => arg.startsWith('multica://'))
if (url) {
console.log('[Auth] Received second-instance URL:', url)
handleAuthDeepLink(url)
}
})
}
function createWindow() {
win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 500,
minHeight: 520,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 16, y: 17 }, // Vertically centered in 48px header
webPreferences: {
preload: path.join(__dirname, '../preload/index.cjs'),
// Enable node integration for IPC
contextIsolation: true,
nodeIntegration: false,
},
})
// Open external links in system browser instead of inside Electron
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
// Track renderer crashes for debugging
win.webContents.on('render-process-gone', (_event, details) => {
console.error('[Window] Renderer process gone:', details.reason, details.exitCode)
})
// Hide window on close instead of quitting (tray keeps running)
win.on('close', (event) => {
if (!isQuitting) {
event.preventDefault()
// On macOS, hiding a fullscreen window causes a black screen.
// Exit fullscreen first, then hide.
if (win?.isFullScreen()) {
win.once('leave-full-screen', () => {
win?.hide()
})
win.setFullScreen(false)
} else {
win?.hide()
}
}
})
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL)
} else {
win.loadFile(path.join(RENDERER_DIST, 'index.html'))
}
}
app.on('window-all-closed', () => {
// Keep app running with tray on all platforms
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
} else if (win && !win.isVisible()) {
win.show()
}
})
app.on('before-quit', () => {
isQuitting = true
destroyTray()
cleanupAll()
})
app.whenReady().then(async () => {
// Reset onboarding if --force-onboarding flag is passed (for development testing)
if (forceOnboarding) {
console.log('[dev] Resetting onboarding state...')
appStateManager.resetOnboarding()
console.log('[dev] Onboarding state reset')
}
// App-level IPC handlers
ipcMain.handle('app:getFlags', () => ({ forceOnboarding }))
// Register all IPC handlers before creating window
registerAllIpcHandlers()
// Initialize Hub and create default agent
await initializeApp()
createWindow()
// Initialize auto-updater
const forceDevUpdate = process.env.FORCE_DEV_UPDATE === 'true'
updater = createUpdater(forceDevUpdate)
updater.setMainWindow(() => win)
// Set up device confirmation flow, auth, and tray (requires window)
if (win) {
setupDeviceConfirmation(win)
setAuthMainWindow(win)
createTray(win, {
onCheckForUpdates: () => updater.checkForUpdates(),
})
}
// Auto-check for updates in production (or when forced in dev)
const isDev = !!VITE_DEV_SERVER_URL
if (!isDev || forceDevUpdate) {
win?.once('ready-to-show', () => {
updater.checkForUpdates()
})
}
// Update IPC handlers
ipcMain.handle('update:check', async () => {
await updater.checkForUpdates()
})
ipcMain.handle('update:download', async () => {
await updater.downloadUpdate()
})
ipcMain.handle('update:install', () => {
updater.quitAndInstall()
})
})

View file

@ -1,219 +0,0 @@
/**
* Agent IPC handlers for Electron main process.
*
* These handlers get tool information from the real Agent instance
* managed by the Hub.
*/
import { ipcMain } from 'electron'
import { getCurrentHub } from './hub.js'
// Tool groups (for UI display grouping)
const TOOL_GROUPS: Record<string, string[]> = {
'group:fs': ['read', 'write', 'edit', 'glob'],
'group:runtime': ['exec', 'process'],
'group:web': ['web_search', 'web_fetch'],
'group:subagent': ['delegate'],
'group:cron': ['cron'],
}
// All known tool names (for display when agent not available)
const ALL_KNOWN_TOOLS = [
...TOOL_GROUPS['group:fs'],
...TOOL_GROUPS['group:runtime'],
...TOOL_GROUPS['group:web'],
...TOOL_GROUPS['group:subagent'],
...TOOL_GROUPS['group:cron'],
]
/**
* Get the group for a tool name.
*/
function getToolGroup(name: string): string {
for (const [groupKey, tools] of Object.entries(TOOL_GROUPS)) {
const groupId = groupKey.replace('group:', '')
if (tools.includes(name)) {
return groupId
}
}
return 'other'
}
/**
* Get the default agent from Hub.
*/
function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getConversation(conversationIds[0]) ?? null
}
/**
* Register all Agent-related IPC handlers.
*/
export function registerAgentIpcHandlers(): void {
// ============================================================================
// Agent lifecycle
// ============================================================================
/**
* Get agent status
*/
ipcMain.handle('agent:status', async () => {
const agent = getDefaultAgent()
if (!agent) {
return {
running: false,
error: 'No agent available',
}
}
return {
running: !agent.closed,
agentId: agent.sessionId,
}
})
// ============================================================================
// Tools management
// ============================================================================
/**
* Get list of all tools with their enabled status.
* Returns active tools from the real Agent instance.
*/
ipcMain.handle('tools:list', async () => {
const agent = getDefaultAgent()
if (!agent) {
// Fallback: return all known tools as disabled when no agent
return ALL_KNOWN_TOOLS.map((name) => ({
name,
enabled: false,
group: getToolGroup(name),
}))
}
// Get active tools from agent
const activeTools = agent.getActiveTools()
const activeSet = new Set(activeTools)
// Build list with all known tools, marking which are active
const toolList = ALL_KNOWN_TOOLS.map((name) => ({
name,
enabled: activeSet.has(name),
group: getToolGroup(name),
}))
// Add any active tools not in our known list
for (const name of activeTools) {
if (!ALL_KNOWN_TOOLS.includes(name)) {
toolList.push({
name,
enabled: true,
group: getToolGroup(name),
})
}
}
return toolList
})
/**
* Toggle a tool's enabled status.
* Persists the change to profile config and reloads tools.
*/
ipcMain.handle('tools:toggle', async (_event, toolName: string) => {
const agent = getDefaultAgent()
if (!agent) {
return { error: 'No agent available' }
}
// Check current status
const activeTools = agent.getActiveTools()
const isCurrentlyEnabled = activeTools.includes(toolName)
// Toggle the tool status (enable if disabled, disable if enabled)
const result = agent.setToolStatus(toolName, !isCurrentlyEnabled)
if (!result) {
return { error: 'No profile loaded - cannot persist tool status' }
}
// Get updated status
const newActiveTools = agent.getActiveTools()
const isNowEnabled = newActiveTools.includes(toolName)
return {
name: toolName,
enabled: isNowEnabled,
}
})
/**
* Set a tool's enabled status explicitly.
* Persists the change to profile config and reloads tools.
*/
ipcMain.handle('tools:setStatus', async (_event, toolName: string, enabled: boolean) => {
console.log(`[IPC] tools:setStatus called for: ${toolName}, enabled: ${enabled}`)
const agent = getDefaultAgent()
if (!agent) {
return { error: 'No agent available' }
}
// Set the tool status and persist to profile config
const result = agent.setToolStatus(toolName, enabled)
if (!result) {
return { error: 'No profile loaded - cannot persist tool status' }
}
console.log(`[IPC] Tool ${toolName} status set to ${enabled}. Config: allow=${result.allow?.join(',') ?? 'none'}, deny=${result.deny?.join(',') ?? 'none'}`)
// Get updated status
const activeTools = agent.getActiveTools()
const isEnabled = activeTools.includes(toolName)
return {
name: toolName,
enabled: isEnabled,
config: result,
}
})
/**
* Get currently active tools in the agent.
*/
ipcMain.handle('tools:active', async () => {
const agent = getDefaultAgent()
if (!agent) {
return []
}
return agent.getActiveTools()
})
/**
* Force reload tools in the agent.
* This picks up any changes made to credentials.json5.
*/
ipcMain.handle('tools:reload', async () => {
const agent = getDefaultAgent()
if (!agent) {
return { error: 'No agent available' }
}
const reloadedTools = agent.reloadTools()
console.log(`[IPC] Reloaded ${reloadedTools.length} tools: ${reloadedTools.join(', ')}`)
return reloadedTools
})
}
/**
* Cleanup agent resources.
*/
export function cleanupAgent(): void {
// Agent cleanup is handled by Hub
}

View file

@ -1,31 +0,0 @@
/**
* App State IPC handlers for Electron main process.
*
* Manages application-level state like onboarding status.
* State is persisted to ~/.super-multica/app-state.json
*/
import { ipcMain } from 'electron'
import { appStateManager } from '@multica/core'
/**
* Register all App State IPC handlers.
*/
export function registerAppStateIpcHandlers(): void {
/**
* Get onboarding completed status.
*/
ipcMain.handle('appState:getOnboardingCompleted', async (): Promise<boolean> => {
return appStateManager.getOnboardingCompleted()
})
/**
* Set onboarding completed status.
*/
ipcMain.handle(
'appState:setOnboardingCompleted',
async (_event, completed: boolean): Promise<void> => {
appStateManager.setOnboardingCompleted(completed)
console.log(`[IPC] Onboarding completed set to: ${completed}`)
}
)
}

View file

@ -1,482 +0,0 @@
/**
* Auth IPC Handlers
*
* Desktop login flow, based on CAP project:
* - Dev mode: Start local HTTP Server, Web redirects back after login
* - Prod mode: Use Deep Link (multica://), Web redirects via deep link
*
* Reference: https://github.com/CapSoftware/Cap
*/
import http from "node:http";
import { ipcMain, shell, BrowserWindow } from "electron";
import {
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { DATA_DIR, generateEncryptedId, isValidEncryptedId } from "@multica/utils";
import type { AuthUser } from "@multica/types";
// ============================================================================
// Types
// ============================================================================
export type { AuthUser };
export interface AuthData {
sid: string;
user: AuthUser;
deviceId?: string;
}
// Internal type for the full file structure (deviceId is always present)
interface AuthFileData {
sid?: string;
user?: AuthUser;
deviceId: string;
}
// ============================================================================
// Device ID - 设备唯一标识
// ============================================================================
const AUTH_FILE_PATH = join(DATA_DIR, "auth.json");
/**
* Read raw auth file data, handling all edge cases.
* Returns null if file doesn't exist or is invalid.
*/
function readAuthFile(): Partial<AuthFileData> | null {
try {
if (!existsSync(AUTH_FILE_PATH)) {
return null;
}
const raw = readFileSync(AUTH_FILE_PATH, "utf8").trim();
if (!raw) {
return null;
}
const data = JSON.parse(raw);
if (typeof data !== "object" || data === null) {
console.warn("[Auth] Invalid auth file format, ignoring");
return null;
}
return data as Partial<AuthFileData>;
} catch (error) {
// JSON parse error or file read error
console.error("[Auth] Failed to read auth file:", error);
return null;
}
}
/**
* Write auth file data to disk.
*/
function writeAuthFile(data: Partial<AuthFileData>): boolean {
try {
mkdirSync(dirname(AUTH_FILE_PATH), { recursive: true });
writeFileSync(AUTH_FILE_PATH, JSON.stringify(data, null, 2), "utf8");
return true;
} catch (error) {
console.error("[Auth] Failed to write auth file:", error);
return false;
}
}
/**
* Get or create a persistent Device ID.
* Device ID persists across logins/logouts - it represents the device, not the user.
* The stored value is encrypted (40 hex chars).
*/
export function getOrCreateDeviceId(): string {
const existing = readAuthFile();
// If we have a valid encrypted deviceId, return it
if (existing?.deviceId && isValidEncryptedId(existing.deviceId)) {
return existing.deviceId;
}
// Generate new encrypted deviceId
const newDeviceId = generateEncryptedId();
console.log("[Auth] Generated new Device ID:", newDeviceId.slice(0, 8) + "...");
// Preserve any existing auth data while adding deviceId
const dataToSave: Partial<AuthFileData> = existing
? { ...existing, deviceId: newDeviceId }
: { deviceId: newDeviceId };
if (!writeAuthFile(dataToSave)) {
console.error("[Auth] Failed to persist new Device ID");
}
return newDeviceId;
}
// ============================================================================
// Storage - 认证数据持久化
// ============================================================================
function loadAuthData(): AuthData | null {
try {
const data = readAuthFile();
if (!data?.sid || !data?.user?.uid) {
return null;
}
return {
sid: data.sid,
user: data.user,
deviceId: data.deviceId,
};
} catch (error) {
console.error("[Auth] Failed to load auth data:", error);
return null;
}
}
/**
* Save auth data to disk.
* @param sid Session ID
* @param user User info
* @param passedDeviceId Optional Device ID from Web browser (encrypted 40-char format).
* If provided and valid, use it; otherwise fall back to local Device ID.
*/
function saveAuthData(sid: string, user: AuthUser, passedDeviceId?: string): boolean {
try {
// Use passed deviceId from Web if valid, otherwise use local one
let deviceId: string;
if (passedDeviceId && isValidEncryptedId(passedDeviceId)) {
deviceId = passedDeviceId;
console.log("[Auth] Using Device ID from Web browser:", deviceId.slice(0, 8) + "...");
} else {
deviceId = getOrCreateDeviceId();
if (passedDeviceId) {
console.warn("[Auth] Invalid Device ID from Web, using local:", passedDeviceId);
}
}
const data: AuthFileData = { sid, user, deviceId };
if (!writeAuthFile(data)) {
return false;
}
console.log("[Auth] Auth data saved successfully");
return true;
} catch (error) {
console.error("[Auth] Failed to save auth data:", error);
return false;
}
}
/**
* Clear auth data (logout) while preserving Device ID.
* Device ID persists across logins - it represents the device, not the user.
*/
function clearAuthData(): boolean {
try {
// Read existing data to preserve deviceId
const existing = readAuthFile();
const deviceId = existing?.deviceId || getOrCreateDeviceId();
// Write back only the deviceId
const preserved: Partial<AuthFileData> = { deviceId };
if (!writeAuthFile(preserved)) {
console.error("[Auth] Failed to preserve Device ID during logout");
return false;
}
console.log("[Auth] Auth data cleared (Device ID preserved)");
return true;
} catch (error) {
console.error("[Auth] Failed to clear auth data:", error);
return false;
}
}
// ============================================================================
// Login - 登录流程
// ============================================================================
let authServer: http.Server | null = null;
let mainWindowRef: BrowserWindow | null = null;
/**
* auth callback
*/
export function setMainWindow(win: BrowserWindow): void {
mainWindowRef = win;
}
/**
* HTML
* Cap/apps/desktop/src/components/callback.template.ts
*/
const callbackHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multica Auth</title>
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
body {
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
text-align: center;
background-color: #f8f9fa;
}
.container {
padding: 30px;
max-width: 400px;
}
h1 {
font-size: 24px;
color: #12161F;
margin-bottom: 12px;
}
p {
font-size: 16px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<h1>Sign in successful</h1>
<p>Please return to Multica app</p>
</div>
</body>
</html>
`;
/**
* HTTP Server
* Cap/apps/desktop/src/utils/auth.ts - createLocalServerSession
*/
async function createLocalServerSession(): Promise<number> {
// 如果已有 server先关闭
if (authServer) {
authServer.close();
authServer = null;
}
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
console.log("[Auth] Local server received request:", req.url);
try {
const url = new URL(req.url || "/", "http://localhost");
// 处理回调请求(只接受 /callback 路径)
if (url.pathname === "/callback") {
const sid = url.searchParams.get("sid");
const userJson = url.searchParams.get("user");
const deviceId = url.searchParams.get("deviceId");
// 返回成功页面
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store, no-cache, must-revalidate",
});
res.end(callbackHtml);
console.log("[Auth] Parsed params:", { sid, userJson, deviceId: deviceId?.slice(0, 8) + "..." });
if (sid && userJson) {
try {
// URLSearchParams already decodes, so just parse JSON directly
const user = JSON.parse(userJson) as AuthUser;
console.log("[Auth] Received auth callback:", {
sid: sid.substring(0, 8) + "...",
user: user.name,
deviceId: deviceId ? deviceId.slice(0, 8) + "..." : "not provided",
});
// 保存认证数据(使用 Web 传递的 deviceId
saveAuthData(sid, user, deviceId || undefined);
// 通知渲染进程
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
mainWindowRef.webContents.send("auth:callback", { sid, user, deviceId });
// 聚焦窗口
if (mainWindowRef.isMinimized()) mainWindowRef.restore();
mainWindowRef.focus();
} else {
console.log("[Auth] ERROR: mainWindowRef is null or destroyed!");
}
} catch (parseError) {
console.error("[Auth] Failed to parse user data:", parseError);
}
}
// 关闭 server
setTimeout(() => {
server.close();
authServer = null;
}, 1000);
} else {
res.writeHead(404);
res.end("Not Found");
}
} catch (error) {
console.error("[Auth] Error handling request:", error);
res.writeHead(500);
res.end("Internal Server Error");
}
});
server.on("error", (err) => {
console.error("[Auth] Server error:", err);
reject(err);
});
// 监听随机端口
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address && typeof address === "object") {
const port = address.port;
console.log("[Auth] Local server started on port:", port);
authServer = server;
resolve(port);
} else {
reject(new Error("Failed to get server address"));
}
});
});
}
/**
*
* Cap/apps/desktop/src/utils/auth.ts - createSignInMutation
*/
async function startLogin(): Promise<void> {
const isDev = !!process.env.ELECTRON_RENDERER_URL;
const webUrl =
(import.meta as unknown as { env: Record<string, string> }).env
.MAIN_VITE_WEB_URL || "http://localhost:3000";
console.log("[Auth] Starting login, isDev:", isDev, "webUrl:", webUrl);
if (isDev) {
// 开发模式:启动本地 ServerWeb 回调到这个 Server
try {
const port = await createLocalServerSession();
const loginUrl = `${webUrl}/api/desktop/session?port=${port}&platform=web`;
console.log("[Auth] Opening login URL:", loginUrl);
shell.openExternal(loginUrl);
} catch (error) {
console.error("[Auth] Failed to start local server:", error);
}
} else {
// 生产模式:直接打开登录页,通过 deep link 回调
const loginUrl = `${webUrl}/api/desktop/session?platform=desktop`;
console.log("[Auth] Opening login URL:", loginUrl);
shell.openExternal(loginUrl);
}
}
/**
* Deep Link
* main/index.ts app.on('open-url')
*/
export function handleAuthDeepLink(url: string): void {
console.log("[Auth] Handling deep link:", url);
try {
const parsedUrl = new URL(url);
// multica://focus - just focus the window
if (parsedUrl.host === "focus" || parsedUrl.pathname === "//focus") {
console.log("[Auth] Focus request received");
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
if (mainWindowRef.isMinimized()) mainWindowRef.restore();
mainWindowRef.focus();
}
return;
}
// multica://auth?sid=xxx&user=xxx&deviceId=xxx
if (
parsedUrl.host === "auth" ||
parsedUrl.pathname === "//auth" ||
parsedUrl.pathname === "/auth"
) {
const sid = parsedUrl.searchParams.get("sid");
const userJson = parsedUrl.searchParams.get("user");
const deviceId = parsedUrl.searchParams.get("deviceId");
if (sid && userJson) {
const user = JSON.parse(decodeURIComponent(userJson)) as AuthUser;
console.log("[Auth] Deep link auth received:", {
sid: sid.substring(0, 8) + "...",
user: user.name,
deviceId: deviceId ? deviceId.slice(0, 8) + "..." : "not provided",
});
// 保存认证数据(使用 Web 传递的 deviceId
saveAuthData(sid, user, deviceId || undefined);
// 通知渲染进程
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
mainWindowRef.webContents.send("auth:callback", { sid, user, deviceId });
if (mainWindowRef.isMinimized()) mainWindowRef.restore();
mainWindowRef.focus();
}
}
}
} catch (error) {
console.error("[Auth] Failed to handle deep link:", error);
}
}
// ============================================================================
// IPC Handlers
// ============================================================================
export function registerAuthHandlers(): void {
// 加载认证数据
ipcMain.handle("auth:load", () => {
return loadAuthData();
});
// 保存认证数据(支持传入 deviceId
ipcMain.handle("auth:save", (_, sid: string, user: AuthUser, deviceId?: string) => {
return saveAuthData(sid, user, deviceId);
});
// 清除认证数据(登出)
ipcMain.handle("auth:clear", () => {
return clearAuthData();
});
// 开始登录
ipcMain.handle("auth:startLogin", () => {
return startLogin();
});
// 获取 Device ID已加密的 40 字符格式)
ipcMain.handle("auth:getDeviceId", () => {
return getOrCreateDeviceId();
});
// 获取 Device-Id header 值(与 getDeviceId 相同,已加密)
ipcMain.handle("auth:getDeviceIdHeader", () => {
return getOrCreateDeviceId();
});
}

View file

@ -1,188 +0,0 @@
/**
* Channel IPC handlers for Electron main process.
*
* Manages channel account configuration, start/stop lifecycle.
* The Channels page in the renderer uses these to configure
* Telegram (and future channels) with immediate effect.
*/
import { ipcMain } from 'electron'
import { getCurrentHub } from './hub.js'
import { credentialManager, listChannels } from '@multica/core'
/** Validate that a string is a safe identifier (alphanumeric, dashes, underscores) */
function isValidId(value: unknown): value is string {
return typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value) && value.length <= 64
}
/**
* Mask a token string for safe display: show first 5 and last 5 chars.
* Returns undefined if the input is not a string.
*/
function maskToken(token: unknown): string | undefined {
if (typeof token !== 'string' || token.length === 0) return undefined
if (token.length <= 12) return '*'.repeat(token.length)
return `${token.slice(0, 5)}${'*'.repeat(10)}${token.slice(-5)}`
}
/**
* Register all Channel-related IPC handlers.
*/
export function registerChannelsIpcHandlers(): void {
/**
* List all channel account states (running / stopped / error).
* Waits for any "starting" status to settle before returning.
*/
ipcMain.handle('channels:listStates', async () => {
const hub = getCurrentHub()
if (!hub) return []
let states = hub.channelManager.listAccountStates()
// Wait for "starting" status to settle (max 3.5s, since internal timeout is 3s)
const start = Date.now()
while (states.some(s => s.status === 'starting') && Date.now() - start < 3500) {
await new Promise(r => setTimeout(r, 100))
states = hub.channelManager.listAccountStates()
}
return states
})
/**
* Get the channels config from credentials.json5.
* Returns a sanitized version with tokens masked (not the raw secret values).
*/
ipcMain.handle('channels:getConfig', async () => {
const raw = credentialManager.getChannelsConfig()
// Mask secret values before sending to renderer
const masked: Record<string, Record<string, Record<string, unknown>> | undefined> = {}
for (const [channelId, accounts] of Object.entries(raw)) {
if (!accounts) continue
const maskedAccounts: Record<string, Record<string, unknown>> = {}
for (const [accountId, accountConfig] of Object.entries(accounts)) {
const maskedConfig = { ...accountConfig }
if ('botToken' in maskedConfig) {
maskedConfig.botToken = maskToken(maskedConfig.botToken)
}
maskedAccounts[accountId] = maskedConfig
}
masked[channelId] = maskedAccounts
}
return masked
})
/**
* Save a channel account token and start the bot immediately.
* Flow: validate write to credentials.json5 start the channel account.
*/
ipcMain.handle(
'channels:saveToken',
async (_event, channelId: string, accountId: string, token: string): Promise<{ ok: boolean; error?: string }> => {
try {
// Validate inputs
if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' }
if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' }
if (typeof token !== 'string' || token.trim().length === 0 || token.length > 256) {
return { ok: false, error: 'Invalid token' }
}
const hub = getCurrentHub()
if (!hub) return { ok: false, error: 'Hub not initialized' }
// Find the plugin to validate channelId
const plugin = listChannels().find((p) => p.id === channelId)
if (!plugin) return { ok: false, error: `Unknown channel: ${channelId}` }
// Persist config to credentials.json5
credentialManager.setChannelAccountConfig(channelId, accountId, { botToken: token })
console.log(`[IPC] Channel config saved: ${channelId}:${accountId}`)
// Stop existing account if running (e.g. token update)
hub.channelManager.stopAccount(channelId, accountId)
// Start the account with the new config
await hub.channelManager.startAccount(channelId, accountId, { botToken: token })
console.log(`[IPC] Channel started: ${channelId}:${accountId}`)
return { ok: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[IPC] Failed to save channel token: ${message}`)
return { ok: false, error: message }
}
}
)
/**
* Remove a channel account token and stop the bot.
*/
ipcMain.handle(
'channels:removeToken',
async (_event, channelId: string, accountId: string): Promise<{ ok: boolean; error?: string }> => {
try {
if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' }
if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' }
const hub = getCurrentHub()
if (!hub) return { ok: false, error: 'Hub not initialized' }
// Stop the account
hub.channelManager.stopAccount(channelId, accountId)
// Remove from credentials.json5
credentialManager.removeChannelAccountConfig(channelId, accountId)
console.log(`[IPC] Channel config removed: ${channelId}:${accountId}`)
return { ok: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[IPC] Failed to remove channel token: ${message}`)
return { ok: false, error: message }
}
}
)
/**
* Stop a channel account without removing its config.
*/
ipcMain.handle(
'channels:stop',
async (_event, channelId: string, accountId: string): Promise<{ ok: boolean; error?: string }> => {
if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' }
if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' }
const hub = getCurrentHub()
if (!hub) return { ok: false, error: 'Hub not initialized' }
hub.channelManager.stopAccount(channelId, accountId)
return { ok: true }
}
)
/**
* Start a channel account using its saved config.
*/
ipcMain.handle(
'channels:start',
async (_event, channelId: string, accountId: string): Promise<{ ok: boolean; error?: string }> => {
try {
if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' }
if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' }
const hub = getCurrentHub()
if (!hub) return { ok: false, error: 'Hub not initialized' }
// Read config from credentials
const config = credentialManager.getChannelsConfig()
const accountConfig = config[channelId]?.[accountId]
if (!accountConfig) {
return { ok: false, error: `No config found for ${channelId}:${accountId}` }
}
await hub.channelManager.startAccount(channelId, accountId, accountConfig)
return { ok: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { ok: false, error: message }
}
}
)
}

View file

@ -1,68 +0,0 @@
/**
* Cron IPC handlers for Electron main process.
*
* These handlers expose CronService operations to the renderer process
* for the Cron Jobs management page.
*/
import { ipcMain } from 'electron'
import { getCronService, formatSchedule } from '@multica/core'
/**
* Register all Cron-related IPC handlers.
*/
export function registerCronIpcHandlers(): void {
/**
* List all cron jobs with formatted display fields.
*/
ipcMain.handle('cron:list', async () => {
const service = getCronService()
const jobs = service.list()
return jobs.map((job) => ({
id: job.id,
name: job.name,
description: job.description,
enabled: job.enabled,
schedule: formatSchedule(job.schedule),
sessionTarget: job.sessionTarget,
nextRunAt: job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null,
lastStatus: job.state.lastStatus ?? null,
lastRunAt: job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null,
lastDurationMs: job.state.lastDurationMs ?? null,
lastError: job.state.lastError ?? null,
}))
})
/**
* Toggle a cron job's enabled status.
*/
ipcMain.handle('cron:toggle', async (_event, jobId: string) => {
const service = getCronService()
const job = service.get(jobId)
if (!job) {
return { error: `Job not found: ${jobId}` }
}
const updated = service.update(jobId, { enabled: !job.enabled })
if (!updated) {
return { error: `Failed to update job: ${jobId}` }
}
return {
id: updated.id,
enabled: updated.enabled,
}
})
/**
* Remove a cron job.
*/
ipcMain.handle('cron:remove', async (_event, jobId: string) => {
const service = getCronService()
const removed = service.remove(jobId)
if (!removed) {
return { error: `Job not found: ${jobId}` }
}
return { ok: true }
})
}

View file

@ -1,39 +0,0 @@
/**
* Heartbeat IPC handlers for Electron main process.
*/
import { ipcMain } from "electron";
import { getCurrentHub } from "./hub.js";
export function registerHeartbeatIpcHandlers(): void {
ipcMain.handle("heartbeat:last", async () => {
const hub = getCurrentHub();
if (!hub) return null;
return hub.getLastHeartbeat();
});
ipcMain.handle("heartbeat:setEnabled", async (_event, enabled: boolean) => {
const hub = getCurrentHub();
if (!hub) {
return { ok: false, error: "Hub not initialized" };
}
if (typeof enabled !== "boolean") {
return { ok: false, error: "enabled must be boolean" };
}
hub.setHeartbeatsEnabled(enabled);
return { ok: true, enabled };
});
ipcMain.handle("heartbeat:wake", async (_event, reason?: string) => {
const hub = getCurrentHub();
if (!hub) {
return { ok: false, error: "Hub not initialized" };
}
const result = await hub.runHeartbeatOnce({
reason: typeof reason === "string" ? reason.trim() || "manual" : "manual",
});
return { ok: result.status !== "failed", result };
});
}

View file

@ -1,607 +0,0 @@
/**
* Hub IPC handlers for Electron main process.
*
* Creates and manages a Hub instance that connects to the Gateway.
* This follows the same pattern as the Console app.
*/
import { ipcMain, type BrowserWindow } from 'electron'
import { Hub, type AsyncAgent } from '@multica/core'
import type { ConnectionState } from '@multica/sdk'
// Singleton Hub instance
let hub: Hub | null = null
let defaultConversationId: string | null = null
let mainWindowRef: BrowserWindow | null = null
function isConversationBusy(conversation: AsyncAgent): boolean {
return conversation.isRunning
|| conversation.isStreaming
|| conversation.hasQueuedMessages()
|| conversation.getPendingWrites() > 0
}
interface IpcAgentSubscription {
token: number
unsubscribe: () => void
}
// Track which agents have active IPC subscriptions (for local direct chat)
const ipcAgentSubscriptions = new Map<string, IpcAgentSubscription>()
let nextIpcSubscriptionToken = 1
// Resolve gateway URL: GATEWAY_URL env > MAIN_VITE_GATEWAY_URL (.env file)
const gatewayUrl =
process.env.GATEWAY_URL || import.meta.env.MAIN_VITE_GATEWAY_URL
/**
* Safe log function that catches EPIPE errors.
* Electron main process stdout can be closed unexpectedly.
*/
function safeLog(...args: unknown[]): void {
try {
console.log(...args)
} catch {
// Ignore EPIPE errors when stdout is closed
}
}
/**
* Initialize Hub on app startup.
* Creates Hub and a default conversation automatically.
*/
export async function initializeHub(): Promise<void> {
if (hub) {
safeLog('[Desktop] Hub already initialized')
return
}
safeLog(`[Desktop] Initializing Hub, connecting to Gateway: ${gatewayUrl}`)
hub = new Hub(gatewayUrl)
// Create default conversation if none exists
const conversations = hub.listConversations()
if (conversations.length === 0) {
safeLog('[Desktop] Creating default conversation...')
const conversation = hub.createConversation()
defaultConversationId = conversation.sessionId
safeLog(`[Desktop] Default conversation created: ${defaultConversationId}`)
} else {
defaultConversationId = conversations[0]
safeLog(`[Desktop] Using existing conversation: ${defaultConversationId}`)
}
}
/**
* Get or create the Hub instance.
*/
function getHub(): Hub {
if (!hub) {
safeLog(`[Desktop] Creating Hub, connecting to Gateway: ${gatewayUrl}`)
hub = new Hub(gatewayUrl)
}
return hub
}
/**
* Get the default agent.
*/
export function getDefaultAgent(): AsyncAgent | null {
if (!hub || !defaultConversationId) return null
return hub.getConversation(defaultConversationId) ?? null
}
/**
* Hub info returned to renderer.
*/
export interface HubInfo {
hubId: string
url: string
connectionState: ConnectionState
agentCount: number
}
/**
* Agent info returned to renderer.
*/
export interface AgentInfo {
id: string
closed: boolean
}
/**
* Register all Hub-related IPC handlers.
*/
export function registerHubIpcHandlers(): void {
/**
* Initialize the Hub (creates singleton if not exists).
*/
ipcMain.handle('hub:init', async () => {
await initializeHub()
const h = getHub()
return {
hubId: h.hubId,
url: h.url,
connectionState: h.connectionState,
defaultConversationId,
}
})
/**
* Get Hub status info.
*/
ipcMain.handle('hub:info', async (): Promise<HubInfo> => {
const h = getHub()
return {
hubId: h.hubId,
url: h.url,
connectionState: h.connectionState,
agentCount: h.listConversations().length,
}
})
/**
* Get Hub status with default agent info (for home page).
*/
ipcMain.handle('hub:getStatus', async () => {
const h = getHub()
const agent = getDefaultAgent()
return {
hubId: h.hubId,
status: h.connectionState === 'connected' ? 'ready' : h.connectionState,
agentCount: h.listConversations().length,
gatewayConnected: h.connectionState === 'connected',
gatewayUrl: h.url,
defaultAgent: agent
? {
agentId: defaultConversationId ?? agent.sessionId,
status: agent.closed ? 'closed' : 'idle',
}
: null,
}
})
/**
* Get default agent info.
*/
ipcMain.handle('hub:getAgentInfo', async () => {
const agent = getDefaultAgent()
if (!agent) {
return null
}
return {
agentId: defaultConversationId ?? agent.sessionId,
status: agent.closed ? 'closed' : 'idle',
}
})
/**
* Reconnect Hub to a different Gateway URL.
*/
ipcMain.handle('hub:reconnect', async (_event, url: string) => {
const h = getHub()
h.reconnect(url)
return { url: h.url }
})
/**
* List all conversations.
*/
ipcMain.handle('hub:listConversations', async (): Promise<AgentInfo[]> => {
const h = getHub()
const conversationIds = h.listConversations()
return conversationIds.map((id) => {
const conversation = h.getConversation(id)
return {
id,
closed: conversation?.closed ?? true,
}
})
})
/**
* Create a new conversation.
*/
ipcMain.handle('hub:createConversation', async (_event, id?: string) => {
const h = getHub()
const conversation = h.createConversation(id)
return {
id: conversation.sessionId,
closed: conversation.closed,
}
})
/**
* Get a specific conversation.
*/
ipcMain.handle('hub:getConversation', async (_event, id: string) => {
const h = getHub()
const conversation = h.getConversation(id)
if (!conversation) {
return { error: `Conversation not found: ${id}` }
}
return {
id: conversation.sessionId,
closed: conversation.closed,
}
})
/**
* Close/delete a conversation.
*/
ipcMain.handle('hub:closeConversation', async (_event, id: string) => {
const h = getHub()
const result = h.closeConversation(id)
return { ok: result }
})
/**
* Send a message to an agent (for remote clients via Gateway).
* Note: For local direct chat, use 'localChat:send' instead.
*/
ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string, conversationId: string) => {
const h = getHub()
const resolvedConversationId = conversationId
const agent = h.getConversation(resolvedConversationId)
if (!agent) {
return { error: `Conversation not found: ${resolvedConversationId}` }
}
if (agent.closed) {
return { error: `Conversation is closed: ${resolvedConversationId}` }
}
h.channelManager.clearLastRoute()
agent.write(content)
return { ok: true }
})
/**
* Subscribe to local agent events (for direct IPC chat without Gateway).
* Uses agent.subscribe() which supports multiple subscribers.
*/
ipcMain.handle('localChat:subscribe', async (_event, conversationId: string) => {
const h = getHub()
const conversation = h.getConversation(conversationId)
if (!conversation) {
return { error: `Conversation not found: ${conversationId}` }
}
if (conversation.closed) {
return { error: `Conversation is closed: ${conversationId}` }
}
const logicalAgentId = h.getConversationAgentId(conversationId) ?? conversationId
const existingSubscription = ipcAgentSubscriptions.get(conversationId)
if (existingSubscription) {
const refreshedToken = nextIpcSubscriptionToken++
ipcAgentSubscriptions.set(conversationId, {
token: refreshedToken,
unsubscribe: existingSubscription.unsubscribe,
})
return {
ok: true,
alreadySubscribed: true,
token: refreshedToken,
isRunning: isConversationBusy(conversation),
}
}
// Track current stream ID for message grouping
let currentStreamId: string | null = null
// Subscribe to agent events using the multi-subscriber mechanism
const unsubscribe = conversation.subscribe((event) => {
if (!mainWindowRef || mainWindowRef.isDestroyed()) {
return
}
// Compaction and error events: forward with no stream tracking
const isPassthroughEvent =
event.type === 'compaction_start' || event.type === 'compaction_end' || event.type === 'agent_error'
if (isPassthroughEvent) {
safeLog(`[IPC] Sending ${event.type} event to renderer`)
mainWindowRef.webContents.send('localChat:event', {
agentId: logicalAgentId,
conversationId,
event,
})
return
}
// Filter events same as Hub.consumeAgent()
const maybeMessage = (event as { message?: { role?: string } }).message
const isAssistantMessage = maybeMessage?.role === 'assistant'
const shouldForward =
((event.type === 'message_start' || event.type === 'message_update' || event.type === 'message_end') && isAssistantMessage)
|| event.type === 'tool_execution_start'
|| event.type === 'tool_execution_update'
|| event.type === 'tool_execution_end'
if (!shouldForward) return
// Track stream ID for message grouping (extract from event.message.id, same as Hub.beginStream)
if (event.type === 'message_start') {
const msgId = (event as { message?: { id?: string } }).message?.id
currentStreamId = msgId ?? `stream-${Date.now()}`
safeLog(`[IPC] Starting stream: ${currentStreamId}`)
}
safeLog(`[IPC] Sending event to renderer: ${event.type}, streamId: ${currentStreamId}`)
mainWindowRef.webContents.send('localChat:event', {
agentId: logicalAgentId,
conversationId,
streamId: currentStreamId,
event,
})
if (event.type === 'message_end') {
safeLog(`[IPC] Ending stream: ${currentStreamId}`)
currentStreamId = null
}
})
const token = nextIpcSubscriptionToken++
ipcAgentSubscriptions.set(conversationId, { token, unsubscribe })
// Register local approval handler so exec approval requests route via IPC
h.setLocalApprovalHandler(conversationId, (payload) => {
if (!mainWindowRef || mainWindowRef.isDestroyed()) return
safeLog(`[IPC] Sending approval request to renderer: ${payload.approvalId}`)
mainWindowRef.webContents.send('localChat:approval', payload)
})
safeLog(`[IPC] Local chat subscribed to conversation: ${conversationId}`)
return { ok: true, token, isRunning: isConversationBusy(conversation) }
})
/**
* Unsubscribe from local agent events.
*/
ipcMain.handle('localChat:unsubscribe', async (_event, conversationId: string, token?: number) => {
const subscription = ipcAgentSubscriptions.get(conversationId)
if (!subscription) {
return { ok: true, alreadyUnsubscribed: true }
}
if (typeof token === 'number' && token !== subscription.token) {
safeLog(`[IPC] Skip stale local chat unsubscribe: conversation=${conversationId}, token=${token}`)
return { ok: true, skipped: true }
}
subscription.unsubscribe()
ipcAgentSubscriptions.delete(conversationId)
getHub().removeLocalApprovalHandler(conversationId)
safeLog(`[IPC] Local chat unsubscribed from conversation: ${conversationId}`)
return { ok: true }
})
/**
* Get message history for local chat with pagination.
* Returns raw AgentMessageItem[] so the renderer can render content blocks,
* tool results, thinking blocks, etc. same format as the Gateway RPC.
*
* Reads from session storage (not in-memory state) so that internal
* orchestration messages are excluded by default.
*/
ipcMain.handle('localChat:getHistory', async (
_event,
conversationId: string,
options?: { offset?: number; limit?: number },
) => {
const h = getHub()
const agent = h.getConversation(conversationId)
if (!agent) {
return {
messages: [],
total: 0,
offset: 0,
limit: 0,
contextWindowTokens: undefined,
isRunning: false,
}
}
try {
await agent.ensureInitialized()
const allMessages = agent.loadSessionMessagesForDisplay()
const contextWindowTokens = agent.getContextWindowTokens()
const isRunning = isConversationBusy(agent)
const total = allMessages.length
// Must match DEFAULT_MESSAGES_LIMIT from @multica/sdk/actions/rpc
const limit = options?.limit ?? 200
const offset = options?.offset ?? Math.max(0, total - limit)
const sliced = allMessages.slice(offset, offset + limit)
return { messages: sliced, total, offset, limit, contextWindowTokens, isRunning }
} catch {
return {
messages: [],
total: 0,
offset: 0,
limit: 0,
contextWindowTokens: undefined,
isRunning: false,
}
}
})
/**
* Send a message via local direct IPC (no Gateway).
* Events will be pushed to renderer via 'localChat:event' channel.
*/
ipcMain.handle('localChat:send', async (_event, conversationId: string, content: string) => {
const h = getHub()
const resolvedConversationId = conversationId
const agent = h.getConversation(resolvedConversationId)
if (!agent) {
return { error: `Conversation not found: ${resolvedConversationId}` }
}
if (agent.closed) {
return { error: `Conversation is closed: ${resolvedConversationId}` }
}
// Must be subscribed first to receive events
if (!ipcAgentSubscriptions.has(resolvedConversationId)) {
return { error: 'Not subscribed to conversation events. Call subscribe first.' }
}
h.channelManager.clearLastRoute()
const source = { type: 'local' as const }
// Broadcast as local source (for consistency, though UI already knows)
h.broadcastInbound({
agentId: h.getConversationAgentId(resolvedConversationId) ?? resolvedConversationId,
conversationId: resolvedConversationId,
content,
source,
timestamp: Date.now(),
})
agent.write(content, { source })
safeLog(`[IPC] Local chat message sent to conversation: ${resolvedConversationId}`)
return { ok: true }
})
/**
* Abort the current agent run for local chat.
*/
ipcMain.handle('localChat:abort', async (_event, conversationId: string) => {
const h = getHub()
const resolvedConversationId = conversationId
const agent = h.getConversation(resolvedConversationId)
if (!agent) {
return { error: `Conversation not found: ${resolvedConversationId}` }
}
agent.abort()
safeLog(`[IPC] Abort sent to conversation: ${resolvedConversationId}`)
return { ok: true }
})
/**
* Resolve an exec approval request for local chat.
*/
ipcMain.handle('localChat:resolveExecApproval', async (_event, approvalId: string, decision: string) => {
const h = getHub()
const ok = h.resolveExecApproval(approvalId, decision as 'allow-once' | 'allow-always' | 'deny')
return { ok }
})
/**
* Register a one-time token for device verification.
* Called by the QR code component when a token is generated or refreshed.
*/
ipcMain.handle(
'hub:registerToken',
async (_event, token: string, agentId: string, conversationId: string, expiresAt: number) => {
const h = getHub()
h.registerToken(token, agentId, conversationId, expiresAt)
return { ok: true }
},
)
/**
* List all verified (whitelisted) devices.
*/
ipcMain.handle('hub:listDevices', async () => {
const h = getHub()
return h.deviceStore.listDevices()
})
/**
* Revoke a device from the whitelist.
*/
ipcMain.handle('hub:revokeDevice', async (_event, deviceId: string) => {
const h = getHub()
const ok = h.deviceStore.revokeDevice(deviceId)
// Notify renderer that device list changed
if (ok && mainWindowRef && !mainWindowRef.isDestroyed()) {
mainWindowRef.webContents.send('hub:devices-changed')
}
return { ok }
})
}
/**
* Set up device confirmation flow between Hub (main process) and renderer.
* Also stores window reference for local chat IPC events.
* Must be called after both Hub initialization and window creation.
*/
export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): void {
// Store reference for local chat IPC
mainWindowRef = mainWindow
const h = getHub()
const pendingConfirms = new Map<string, (allowed: boolean) => void>()
// Listen for renderer responses to device confirm dialogs
ipcMain.on('hub:device-confirm-response', (_event, deviceId: string, allowed: boolean) => {
const resolve = pendingConfirms.get(deviceId)
if (resolve) {
pendingConfirms.delete(deviceId)
resolve(allowed)
}
})
// Register confirm handler on Hub — sends request to renderer, awaits response
h.setConfirmHandler((deviceId: string, agentId: string, conversationId: string, meta) => {
return new Promise<boolean>((resolve) => {
// Auto-reject if user doesn't respond within 60 seconds
const timeout = setTimeout(() => {
pendingConfirms.delete(deviceId)
resolve(false)
}, 60_000)
pendingConfirms.set(deviceId, (allowed: boolean) => {
clearTimeout(timeout)
resolve(allowed)
// Notify renderer that device list changed when a device is approved
if (allowed && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('hub:devices-changed')
}
})
mainWindow.webContents.send('hub:device-confirm-request', deviceId, agentId, conversationId, meta)
})
})
// Forward connection state changes to renderer
h.onConnectionStateChange((state) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('hub:connection-state-changed', state)
}
})
// Forward inbound messages from all sources (gateway, channel) to renderer
h.onInboundMessage((event) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('hub:inbound-message', event)
}
})
// Forward conversation list changes (e.g. created from Telegram / RPC).
h.onConversationsChanged(() => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('hub:conversations-changed')
}
})
}
/**
* Cleanup Hub resources.
*/
export function cleanupHub(): void {
// Unsubscribe all IPC listeners
for (const subscription of ipcAgentSubscriptions.values()) {
subscription.unsubscribe()
}
ipcAgentSubscriptions.clear()
if (hub) {
safeLog('[Desktop] Shutting down Hub')
hub.shutdown()
hub = null
}
}
/**
* Get the current Hub instance (for use by other IPC modules).
*/
export function getCurrentHub(): Hub | null {
return hub
}

View file

@ -1,60 +0,0 @@
/**
* IPC handlers index - register all handlers from main process.
*/
export { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
export { registerSkillsIpcHandlers } from './skills.js'
export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation, getDefaultAgent } from './hub.js'
export { registerProfileIpcHandlers } from './profile.js'
export { registerProviderIpcHandlers } from './provider.js'
export { registerChannelsIpcHandlers } from './channels.js'
export { registerCronIpcHandlers } from './cron.js'
export { registerHeartbeatIpcHandlers } from './heartbeat.js'
export { registerAppStateIpcHandlers } from './app-state.js'
export { registerAuthHandlers, setMainWindow as setAuthMainWindow, handleAuthDeepLink } from './auth.js'
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
import { registerAuthHandlers } from './auth.js'
import { registerSkillsIpcHandlers } from './skills.js'
import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js'
import { registerProfileIpcHandlers } from './profile.js'
import { registerProviderIpcHandlers } from './provider.js'
import { registerChannelsIpcHandlers } from './channels.js'
import { registerCronIpcHandlers } from './cron.js'
import { registerHeartbeatIpcHandlers } from './heartbeat.js'
import { registerAppStateIpcHandlers } from './app-state.js'
/**
* Register all IPC handlers.
* Call this in main.ts after app is ready.
*/
export function registerAllIpcHandlers(): void {
registerHubIpcHandlers()
registerAgentIpcHandlers()
registerSkillsIpcHandlers()
registerProfileIpcHandlers()
registerProviderIpcHandlers()
registerChannelsIpcHandlers()
registerCronIpcHandlers()
registerHeartbeatIpcHandlers()
registerAppStateIpcHandlers()
registerAuthHandlers()
}
/**
* Initialize Hub and create default agent.
* Call this after IPC handlers are registered.
*/
export async function initializeApp(): Promise<void> {
console.log('[Desktop] Initializing app...')
await initializeHub()
console.log('[Desktop] App initialized')
}
/**
* Cleanup all resources.
* Call this before app quits.
*/
export function cleanupAll(): void {
cleanupHub()
cleanupAgent()
}

View file

@ -1,92 +0,0 @@
/**
* Profile IPC handlers for Electron main process.
*
* Manages agent profile settings like name and user.md content.
*/
import { ipcMain } from 'electron'
import { getCurrentHub } from './hub.js'
/**
* Get the default agent from Hub.
*/
function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getConversation(conversationIds[0]) ?? null
}
/**
* Profile data returned to renderer.
*/
export interface ProfileData {
profileId: string | undefined
name: string | undefined
userContent: string | undefined
}
/**
* Register all Profile-related IPC handlers.
*/
export function registerProfileIpcHandlers(): void {
/**
* Get profile data (name + user content).
*/
ipcMain.handle('profile:get', async (): Promise<ProfileData> => {
const agent = getDefaultAgent()
if (!agent) {
return {
profileId: undefined,
name: undefined,
userContent: undefined,
}
}
return {
profileId: agent.getProfileId(),
name: agent.getAgentName(),
userContent: agent.getUserContent(),
}
})
/**
* Update agent display name.
*/
ipcMain.handle('profile:updateName', async (_event, name: string) => {
const agent = getDefaultAgent()
if (!agent) {
return { error: 'No agent available' }
}
agent.setAgentName(name)
return { ok: true, name }
})
/**
* Update user.md content.
*/
ipcMain.handle('profile:updateUser', async (_event, content: string) => {
const agent = getDefaultAgent()
if (!agent) {
console.error('[Profile IPC] No agent available for updateUser')
return { error: 'No agent available' }
}
console.log('[Profile IPC] Updating user content:', content.substring(0, 50) + '...')
agent.setUserContent(content)
// Reload system prompt to apply changes immediately
console.log('[Profile IPC] Reloading system prompt...')
agent.reloadSystemPrompt()
// Verify the change
const newUserContent = agent.getUserContent()
console.log('[Profile IPC] New user content:', newUserContent?.substring(0, 50) + '...')
return { ok: true }
})
}

View file

@ -1,327 +0,0 @@
/**
* Provider IPC handlers for Electron main process.
*
* Manages LLM provider listing, status checking, and switching.
* Mirrors the CLI `/provider` command functionality.
*/
import { ipcMain } from 'electron'
import { getCurrentHub } from './hub.js'
import {
getProviderList,
getAvailableProviders,
getCurrentProvider,
getProviderMeta,
isProviderAvailable,
getLoginInstructions,
readClaudeCliCredentials,
readCodexCliCredentials,
credentialManager,
type ProviderInfo,
} from '@multica/core'
/**
* Provider info returned to renderer (matches ProviderInfo from registry).
*/
export interface ProviderStatus {
id: string
name: string
authMethod: 'api-key' | 'oauth'
available: boolean
configured: boolean
current: boolean
defaultModel: string
models: string[]
loginUrl?: string
loginCommand?: string
loginInstructions?: string
}
/**
* Current provider/model info returned to renderer.
*/
export interface CurrentProviderInfo {
provider: string
model: string | undefined
providerName: string | undefined
available: boolean
}
/**
* Get the default agent from Hub.
*/
function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getConversation(conversationIds[0]) ?? null
}
/**
* Register all Provider-related IPC handlers.
*/
export function registerProviderIpcHandlers(): void {
/**
* List all providers with their status.
* This is the main listing function, similar to CLI `/provider` command.
*/
ipcMain.handle('provider:list', async (): Promise<ProviderStatus[]> => {
const providers = getProviderList()
return providers.map((p: ProviderInfo) => ({
id: p.id,
name: p.name,
authMethod: p.authMethod,
available: p.available,
configured: p.configured,
current: p.current,
defaultModel: p.defaultModel,
models: p.models,
loginUrl: p.loginUrl,
loginCommand: p.loginCommand,
loginInstructions: getLoginInstructions(p.id),
}))
})
/**
* List only available (configured) providers.
*/
ipcMain.handle('provider:listAvailable', async (): Promise<ProviderStatus[]> => {
const providers = getAvailableProviders()
return providers.map((p: ProviderInfo) => ({
id: p.id,
name: p.name,
authMethod: p.authMethod,
available: p.available,
configured: p.configured,
current: p.current,
defaultModel: p.defaultModel,
models: p.models,
loginUrl: p.loginUrl,
loginCommand: p.loginCommand,
loginInstructions: getLoginInstructions(p.id),
}))
})
/**
* Get current provider and model from the active agent.
*/
ipcMain.handle('provider:current', async (): Promise<CurrentProviderInfo> => {
const agent = getDefaultAgent()
if (agent) {
// Get from actual agent instance
const info = agent.getProviderInfo()
const meta = getProviderMeta(info.provider)
return {
provider: info.provider,
model: info.model,
providerName: meta?.name,
available: isProviderAvailable(info.provider),
}
}
// Fallback to credentials default
const defaultProvider = getCurrentProvider()
const meta = getProviderMeta(defaultProvider)
return {
provider: defaultProvider,
model: meta?.defaultModel,
providerName: meta?.name,
available: isProviderAvailable(defaultProvider),
}
})
/**
* Switch the agent to a different provider and/or model.
*/
ipcMain.handle(
'provider:set',
async (_event, providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> => {
const agent = getDefaultAgent()
if (!agent) {
return { ok: false, error: 'No agent available' }
}
// Validate provider exists
const meta = getProviderMeta(providerId)
if (!meta) {
return { ok: false, error: `Unknown provider: ${providerId}` }
}
// Check if provider is available
if (!isProviderAvailable(providerId)) {
const instructions = getLoginInstructions(providerId)
return {
ok: false,
error: `Provider "${providerId}" is not configured.\n${instructions}`,
}
}
try {
const result = agent.setProvider(providerId, modelId)
console.log(`[IPC] Provider switched to: ${result.provider}, model: ${result.model}`)
return {
ok: true,
provider: result.provider,
model: result.model,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[IPC] Failed to switch provider: ${message}`)
return { ok: false, error: message }
}
}
)
/**
* Get metadata for a specific provider.
*/
ipcMain.handle('provider:getMeta', async (_event, providerId: string) => {
const meta = getProviderMeta(providerId)
if (!meta) {
return { error: `Unknown provider: ${providerId}` }
}
return {
id: meta.id,
name: meta.name,
authMethod: meta.authMethod,
defaultModel: meta.defaultModel,
models: meta.models,
loginUrl: meta.loginUrl,
loginCommand: meta.loginCommand,
available: isProviderAvailable(providerId),
loginInstructions: getLoginInstructions(providerId),
}
})
/**
* Check if a specific provider is available (has valid credentials).
*/
ipcMain.handle('provider:isAvailable', async (_event, providerId: string): Promise<boolean> => {
return isProviderAvailable(providerId)
})
/**
* Save API key for a provider to credentials.json5.
* After saving, the provider should become available.
*/
ipcMain.handle(
'provider:saveApiKey',
async (_event, providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> => {
try {
// Validate provider exists and uses API key auth
const meta = getProviderMeta(providerId)
if (!meta) {
return { ok: false, error: `Unknown provider: ${providerId}` }
}
if (meta.authMethod !== 'api-key') {
return { ok: false, error: `Provider "${providerId}" uses ${meta.authMethod} authentication, not API key` }
}
// Save the API key
credentialManager.setLlmProviderApiKey(providerId, apiKey)
console.log(`[IPC] API key saved for provider: ${providerId}`)
return { ok: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[IPC] Failed to save API key: ${message}`)
return { ok: false, error: message }
}
}
)
/**
* Import OAuth credentials from CLI tools (claude-code, codex).
* Reads from CLI credential storage and saves to credentials.json5.
*/
ipcMain.handle(
'provider:importOAuth',
async (_event, providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> => {
try {
const meta = getProviderMeta(providerId)
if (!meta) {
return { ok: false, error: `Unknown provider: ${providerId}` }
}
if (meta.authMethod !== 'oauth') {
return { ok: false, error: `Provider "${providerId}" does not use OAuth authentication` }
}
// Read credentials from CLI tool
if (providerId === 'claude-code') {
const creds = readClaudeCliCredentials()
if (!creds) {
return { ok: false, error: 'No Claude Code credentials found. Run "claude login" first.' }
}
if (creds.expires <= Date.now()) {
return { ok: false, error: 'Claude Code credentials have expired. Run "claude login" again.' }
}
// Save to credentials.json5
const token = creds.type === 'oauth' ? creds.access : creds.token
const refreshToken = creds.type === 'oauth' ? creds.refresh : undefined
credentialManager.setLlmProviderOAuthToken(providerId, token, refreshToken, creds.expires)
console.log(`[IPC] OAuth credentials imported for: ${providerId}`)
return { ok: true, expiresAt: creds.expires }
}
if (providerId === 'openai-codex') {
const creds = readCodexCliCredentials()
if (!creds) {
return { ok: false, error: 'No Codex credentials found. Run "codex login" first.' }
}
if (creds.expires <= Date.now()) {
return { ok: false, error: 'Codex credentials have expired. Run "codex login" again.' }
}
// Save to credentials.json5
credentialManager.setLlmProviderOAuthToken(providerId, creds.access, creds.refresh, creds.expires)
console.log(`[IPC] OAuth credentials imported for: ${providerId}`)
return { ok: true, expiresAt: creds.expires }
}
return { ok: false, error: `OAuth import not supported for provider: ${providerId}` }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[IPC] Failed to import OAuth credentials: ${message}`)
return { ok: false, error: message }
}
}
)
/**
* Test a provider connection by sending a minimal prompt.
* Temporarily switches to the target provider, runs a test, then restores.
*/
ipcMain.handle(
'provider:test',
async (_event, providerId: string, modelId?: string): Promise<{ ok: boolean; error?: string }> => {
const agent = getDefaultAgent()
if (!agent) {
return { ok: false, error: 'No agent available. Please wait for initialization.' }
}
const meta = getProviderMeta(providerId)
if (!meta) {
return { ok: false, error: `Unknown provider: ${providerId}` }
}
if (!isProviderAvailable(providerId)) {
return { ok: false, error: `Provider "${meta.name}" is not configured.` }
}
return agent.testProvider(providerId, modelId)
}
)
}

View file

@ -1,278 +0,0 @@
/**
* Skills IPC handlers for Electron main process.
*
* These handlers get skill information from the real Agent instance
* managed by the Hub.
*/
import { ipcMain } from 'electron'
import { getCurrentHub } from './hub.js'
/**
* Skill info returned to renderer.
*/
export interface SkillInfo {
id: string
name: string
description: string
version: string
enabled: boolean
source: 'bundled' | 'global' | 'profile'
triggers: string[]
}
/**
* Get the default agent from Hub.
*/
function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getConversation(conversationIds[0]) ?? null
}
/**
* Get default bundled skills (fallback when no agent).
*/
function getDefaultSkills(): SkillInfo[] {
return [
{
id: 'commit',
name: 'Git Commit Helper',
description: 'Create well-formatted git commits following conventional commit standards',
version: '1.0.0',
enabled: true,
source: 'bundled',
triggers: ['/commit'],
},
{
id: 'code-review',
name: 'Code Review',
description: 'Review code for bugs, security issues, and best practices',
version: '1.0.0',
enabled: true,
source: 'bundled',
triggers: ['/review'],
},
{
id: 'skill-creator',
name: 'Skill Creator',
description: 'Create, edit, and manage custom skills',
version: '1.0.0',
enabled: true,
source: 'bundled',
triggers: ['/skill'],
},
{
id: 'profile-setup',
name: 'Profile Setup',
description: 'Interactive setup wizard to personalize your agent profile',
version: '1.0.0',
enabled: true,
source: 'bundled',
triggers: ['/profile'],
},
]
}
/**
* Register all Skills-related IPC handlers.
*/
export function registerSkillsIpcHandlers(): void {
/**
* Get list of all skills with their status.
* Returns skills from the real Agent instance.
*/
ipcMain.handle('skills:list', async () => {
const agent = getDefaultAgent()
if (!agent) {
// Fallback: return default skills when no agent
console.log('[IPC] skills:list - No agent available, returning defaults')
return getDefaultSkills()
}
try {
const skillsWithStatus = agent.getSkillsWithStatus()
// Transform to SkillInfo format
const skills: SkillInfo[] = skillsWithStatus.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
version: '1.0.0',
enabled: skill.eligible,
source: skill.source as 'bundled' | 'global' | 'profile',
triggers: [`/${skill.id}`],
}))
console.log(`[IPC] skills:list - Returning ${skills.length} skills from agent`)
return skills
} catch (err) {
console.error('[IPC] skills:list - Error getting skills from agent:', err)
return getDefaultSkills()
}
})
/**
* Toggle a skill's enabled status.
* NOTE: Skills eligibility is determined by requirements (env vars, binaries, etc.)
* This handler reports the current eligibility status.
*/
ipcMain.handle('skills:toggle', async (_event, skillId: string) => {
console.log(`[IPC] skills:toggle called for: ${skillId}`)
const agent = getDefaultAgent()
if (!agent) {
return { error: 'No agent available' }
}
const skillsWithStatus = agent.getSkillsWithStatus()
const skill = skillsWithStatus.find((s) => s.id === skillId)
if (!skill) {
return { error: `Skill not found: ${skillId}` }
}
// Skills can't be manually toggled - eligibility is based on requirements
// Return current status
return {
id: skillId,
enabled: skill.eligible,
reasons: skill.reasons,
}
})
/**
* Set a skill's enabled status explicitly.
* NOTE: Skills eligibility is automatic based on requirements.
* This handler is a no-op but returns current status.
*/
ipcMain.handle('skills:setStatus', async (_event, skillId: string, enabled: boolean) => {
console.log(`[IPC] skills:setStatus called for: ${skillId}, enabled: ${enabled}`)
const agent = getDefaultAgent()
if (!agent) {
return { error: 'No agent available' }
}
const skillsWithStatus = agent.getSkillsWithStatus()
const skill = skillsWithStatus.find((s) => s.id === skillId)
if (!skill) {
return { error: `Skill not found: ${skillId}` }
}
// TODO: Implement skill disable via config
// For now, just return current eligibility status
return {
id: skillId,
enabled: skill.eligible,
reasons: skill.reasons,
}
})
/**
* Get skill details by ID.
*/
ipcMain.handle('skills:get', async (_event, skillId: string) => {
const agent = getDefaultAgent()
if (!agent) {
// Fallback: check default skills
const defaults = getDefaultSkills()
const skill = defaults.find((s) => s.id === skillId)
if (skill) return skill
return { error: `Skill not found: ${skillId}` }
}
const skillsWithStatus = agent.getSkillsWithStatus()
const skill = skillsWithStatus.find((s) => s.id === skillId)
if (!skill) {
return { error: `Skill not found: ${skillId}` }
}
return {
id: skill.id,
name: skill.name,
description: skill.description,
version: '1.0.0',
enabled: skill.eligible,
source: skill.source as 'bundled' | 'global' | 'profile',
triggers: [`/${skill.id}`],
reasons: skill.reasons,
}
})
/**
* Reload skills from disk.
*/
ipcMain.handle('skills:reload', async () => {
const agent = getDefaultAgent()
if (!agent) {
return { error: 'No agent available' }
}
agent.reloadSkills()
console.log('[IPC] skills:reload - Skills reloaded')
return { ok: true }
})
/**
* Add a skill from GitHub repository.
* Source formats: owner/repo, owner/repo/skill-name, or full GitHub URL
*/
ipcMain.handle(
'skills:add',
async (
_event,
source: string,
options?: { name?: string; force?: boolean },
) => {
console.log(`[IPC] skills:add called: source=${source}, options=${JSON.stringify(options)}`)
const { addSkill } = await import('@multica/core')
const result = await addSkill({
source,
name: options?.name,
force: options?.force,
})
console.log(`[IPC] skills:add result: ${result.message}`)
// Reload skills in agent if available
const agent = getDefaultAgent()
if (agent && result.ok) {
agent.reloadSkills()
}
return result
},
)
/**
* Remove an installed skill by name.
*/
ipcMain.handle('skills:remove', async (_event, name: string) => {
console.log(`[IPC] skills:remove called: name=${name}`)
const { removeSkill } = await import('@multica/core')
const result = await removeSkill(name)
console.log(`[IPC] skills:remove result: ${result.message}`)
// Reload skills in agent if available
const agent = getDefaultAgent()
if (agent && result.ok) {
agent.reloadSkills()
}
return result
})
}

View file

@ -1,119 +0,0 @@
/**
* System tray (menu bar) for the desktop app.
*
* Shows agent/hub status and allows window show/hide even
* when the main window is closed.
*/
import { Tray, Menu, nativeImage, app, type BrowserWindow } from 'electron'
import path from 'node:path'
import { getCurrentHub, getDefaultAgent } from './ipc/hub.js'
let tray: Tray | null = null
let mainWindowRef: BrowserWindow | null = null
let statusInterval: ReturnType<typeof setInterval> | null = null
let checkForUpdatesFn: (() => void) | null = null
export interface TrayOptions {
onCheckForUpdates?: () => void
}
/**
* Create the system tray and start status polling.
*/
export function createTray(window: BrowserWindow, options?: TrayOptions): void {
mainWindowRef = window
checkForUpdatesFn = options?.onCheckForUpdates ?? null
// Use dedicated tray icon (asterisk shape matching MulticaIcon).
// On macOS, Electron auto-picks trayTemplate.png / trayTemplate@2x.png
// and treats "Template" suffix as a template image (adapts to dark/light menu bar).
const iconPath = path.join(process.env.APP_ROOT!, 'build', 'trayTemplate.png')
const icon = nativeImage.createFromPath(iconPath)
tray = new Tray(icon)
tray.setToolTip('Multica')
// Initial menu
updateTrayMenu()
// Poll status every 2 seconds
statusInterval = setInterval(updateTrayMenu, 2000)
}
/**
* Destroy tray and stop polling.
*/
export function destroyTray(): void {
if (statusInterval) {
clearInterval(statusInterval)
statusInterval = null
}
if (tray) {
tray.destroy()
tray = null
}
mainWindowRef = null
checkForUpdatesFn = null
}
function showMainWindow(): void {
if (!mainWindowRef || mainWindowRef.isDestroyed()) return
mainWindowRef.show()
mainWindowRef.focus()
}
function updateTrayMenu(): void {
if (!tray) return
const hub = getCurrentHub()
const agent = getDefaultAgent()
let agentStatus = 'Initializing'
let hubStatus = 'Disconnected'
let gatewayUrl = ''
if (hub) {
hubStatus = hub.connectionState === 'connected' ? 'Connected' : 'Disconnected'
gatewayUrl = hub.url
}
if (agent && !agent.closed) {
if (agent.isStreaming) {
agentStatus = 'Streaming'
} else if (agent.isRunning) {
agentStatus = 'Running'
} else {
agentStatus = 'Idle'
}
}
tray.setToolTip(`Multica - Agent: ${agentStatus}`)
const template: Electron.MenuItemConstructorOptions[] = [
{ label: `Agent: ${agentStatus}`, enabled: false },
{ label: `Hub: ${hubStatus}`, enabled: false },
]
if (gatewayUrl) {
template.push({ label: `Gateway: ${gatewayUrl}`, enabled: false })
}
template.push(
{ type: 'separator' },
{ label: 'Show Main Window', click: showMainWindow },
{ type: 'separator' },
{ label: `Version ${app.getVersion()}`, enabled: false },
)
if (checkForUpdatesFn) {
const fn = checkForUpdatesFn
template.push({ label: 'Check for Updates', click: () => fn() })
}
template.push(
{ type: 'separator' },
{ label: 'Quit Multica', accelerator: 'CommandOrControl+Q', click: () => app.quit() },
)
tray.setContextMenu(Menu.buildFromTemplate(template))
}

View file

@ -1,124 +0,0 @@
/**
* Auto-updater module using electron-updater
* Checks for updates from GitHub releases and handles download/install
*/
import pkg from 'electron-updater'
import type { UpdateInfo, ProgressInfo } from 'electron-updater'
const { autoUpdater } = pkg
import { BrowserWindow } from 'electron'
export interface UpdateStatus {
status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'
info?: UpdateInfo
progress?: ProgressInfo
error?: string
}
export class AutoUpdater {
private mainWindow: (() => BrowserWindow | null) | null = null
constructor(forceDevUpdateConfig = false) {
// Configure auto-updater
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
// Enable update checking in dev mode for testing
if (forceDevUpdateConfig) {
autoUpdater.forceDevUpdateConfig = true
console.log('[AutoUpdater] Force dev update config enabled')
}
// Enable logging
autoUpdater.logger = {
info: (msg) => console.log('[AutoUpdater]', msg),
warn: (msg) => console.warn('[AutoUpdater]', msg),
error: (msg) => console.error('[AutoUpdater]', msg),
debug: (msg) => console.log('[AutoUpdater:debug]', msg)
}
// Set up event handlers
autoUpdater.on('checking-for-update', () => {
this.sendStatus({ status: 'checking' })
})
autoUpdater.on('update-available', (info: UpdateInfo) => {
this.sendStatus({ status: 'available', info })
})
autoUpdater.on('update-not-available', (info: UpdateInfo) => {
this.sendStatus({ status: 'not-available', info })
})
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
this.sendStatus({ status: 'downloading', progress })
})
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
this.sendStatus({ status: 'downloaded', info })
})
autoUpdater.on('error', (err: Error) => {
this.sendStatus({ status: 'error', error: err.message })
})
}
/**
* Set the main window reference for sending IPC messages
*/
setMainWindow(getWindow: () => BrowserWindow | null): void {
this.mainWindow = getWindow
}
/**
* Check for updates
*/
async checkForUpdates(): Promise<void> {
try {
await autoUpdater.checkForUpdates()
} catch (err) {
console.error('[AutoUpdater] Check for updates failed:', err)
this.sendStatus({
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error'
})
}
}
/**
* Download the available update
*/
async downloadUpdate(): Promise<void> {
try {
await autoUpdater.downloadUpdate()
} catch (err) {
console.error('[AutoUpdater] Download update failed:', err)
this.sendStatus({
status: 'error',
error: err instanceof Error ? err.message : 'Download failed'
})
}
}
/**
* Quit and install the downloaded update
*/
quitAndInstall(): void {
autoUpdater.quitAndInstall()
}
/**
* Send update status to renderer
*/
private sendStatus(status: UpdateStatus): void {
const window = this.mainWindow?.()
if (window && !window.isDestroyed()) {
window.webContents.send('update:status', status)
}
}
}
// Factory function to create updater with options
export function createUpdater(forceDevUpdateConfig = false): AutoUpdater {
return new AutoUpdater(forceDevUpdateConfig)
}

View file

@ -1,420 +0,0 @@
import { ipcRenderer, contextBridge } from 'electron'
// ============================================================================
// Type definitions for IPC API
// ============================================================================
export interface HubStatus {
hubId: string
status: string
agentCount: number
gatewayConnected: boolean
gatewayUrl?: string
defaultAgent?: {
agentId: string
status: string
} | null
}
export interface AgentInfo {
agentId: string
status: string
}
export interface ToolInfo {
name: string
group: string
enabled: boolean
}
export interface SkillInfo {
id: string
name: string
description: string
version: string
enabled: boolean
source: 'bundled' | 'global' | 'profile'
triggers: string[]
}
export interface ProfileData {
profileId: string | undefined
name: string | undefined
userContent: string | undefined
}
export interface ProviderStatus {
id: string
name: string
authMethod: 'api-key' | 'oauth'
available: boolean
configured: boolean
current: boolean
defaultModel: string
models: string[]
loginUrl?: string
loginCommand?: string
loginInstructions?: string
}
export interface CurrentProviderInfo {
provider: string
model: string | undefined
providerName: string | undefined
available: boolean
}
// Local chat event types (for direct IPC communication without Gateway)
export interface LocalChatEvent {
agentId: string
conversationId: string
streamId?: string
type?: 'error'
content?: string
event?: {
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end'
id?: string
message?: {
role: string
content?: Array<{ type: string; text?: string }>
}
[key: string]: unknown
}
}
// Inbound message event (from any source: local, gateway, channel)
export type MessageSource =
| { type: 'local' }
| { type: 'gateway'; deviceId: string }
| { type: 'channel'; channelId: string; accountId: string; conversationId: string }
export interface InboundMessageEvent {
agentId: string
conversationId: string
content: string
source: MessageSource
timestamp: number
}
// Local chat approval request (mirrors ExecApprovalRequestPayload from @multica/sdk)
export interface LocalChatApproval {
approvalId: string
agentId: string
conversationId: string
command: string
cwd?: string
riskLevel: 'safe' | 'needs-review' | 'dangerous'
riskReasons: string[]
expiresAtMs: number
}
// ============================================================================
// Expose typed API to Renderer process
// ============================================================================
const electronAPI = {
// App-level
app: {
/** Get CLI flags passed to the app */
getFlags: (): Promise<{ forceOnboarding: boolean }> => ipcRenderer.invoke('app:getFlags'),
},
// App state (persisted to file system)
appState: {
/** Get onboarding completed status */
getOnboardingCompleted: (): Promise<boolean> => ipcRenderer.invoke('appState:getOnboardingCompleted'),
/** Set onboarding completed status */
setOnboardingCompleted: (completed: boolean): Promise<void> => ipcRenderer.invoke('appState:setOnboardingCompleted', completed),
},
// Auth management
auth: {
/** Load auth data from local file */
load: (): Promise<{ sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number }; deviceId?: string } | null> =>
ipcRenderer.invoke('auth:load'),
/** Save auth data to local file (with optional deviceId from Web) */
save: (sid: string, user: { uid: string; name: string; email?: string; icon?: string; vip?: number }, deviceId?: string): Promise<boolean> =>
ipcRenderer.invoke('auth:save', sid, user, deviceId),
/** Clear auth data (logout) */
clear: (): Promise<boolean> => ipcRenderer.invoke('auth:clear'),
/** Start login flow (opens browser) */
startLogin: (): Promise<void> => ipcRenderer.invoke('auth:startLogin'),
/** Get Device ID (raw UUID) */
getDeviceId: (): Promise<string> => ipcRenderer.invoke('auth:getDeviceId'),
/** Get encrypted Device-Id header value for API requests */
getDeviceIdHeader: (): Promise<string> => ipcRenderer.invoke('auth:getDeviceIdHeader'),
/** Listen for auth callback (includes deviceId from Web browser) */
onAuthCallback: (callback: (data: { sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number }; deviceId?: string }) => void) => {
ipcRenderer.on('auth:callback', (_event, data) => callback(data))
},
/** Remove auth callback listener */
offAuthCallback: () => {
ipcRenderer.removeAllListeners('auth:callback')
},
},
// Hub management
hub: {
init: () => ipcRenderer.invoke('hub:init'),
getStatus: (): Promise<HubStatus> => ipcRenderer.invoke('hub:getStatus'),
getAgentInfo: (): Promise<AgentInfo | null> => ipcRenderer.invoke('hub:getAgentInfo'),
info: () => ipcRenderer.invoke('hub:info'),
reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url),
listConversations: () => ipcRenderer.invoke('hub:listConversations'),
createConversation: (id?: string) => ipcRenderer.invoke('hub:createConversation', id),
getConversation: (id: string) => ipcRenderer.invoke('hub:getConversation', id),
closeConversation: (id: string) => ipcRenderer.invoke('hub:closeConversation', id),
sendMessage: (agentId: string, content: string, conversationId: string) =>
ipcRenderer.invoke('hub:sendMessage', agentId, content, conversationId),
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) =>
ipcRenderer.invoke('hub:registerToken', token, agentId, conversationId, expiresAt),
onDeviceConfirmRequest: (
callback: (
deviceId: string,
agentId: string,
conversationId: string,
meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string },
) => void,
) => {
ipcRenderer.on(
'hub:device-confirm-request',
(
_event,
deviceId: string,
agentId: string,
conversationId: string,
meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string },
) => callback(deviceId, agentId, conversationId, meta),
)
},
offDeviceConfirmRequest: () => {
ipcRenderer.removeAllListeners('hub:device-confirm-request')
},
deviceConfirmResponse: (deviceId: string, allowed: boolean) => {
ipcRenderer.send('hub:device-confirm-response', deviceId, allowed)
},
listDevices: () => ipcRenderer.invoke('hub:listDevices'),
revokeDevice: (deviceId: string) => ipcRenderer.invoke('hub:revokeDevice', deviceId),
onConnectionStateChanged: (callback: (state: string) => void) => {
ipcRenderer.on('hub:connection-state-changed', (_event, state: string) => callback(state))
},
offConnectionStateChanged: () => {
ipcRenderer.removeAllListeners('hub:connection-state-changed')
},
onDevicesChanged: (callback: () => void) => {
ipcRenderer.on('hub:devices-changed', () => callback())
},
offDevicesChanged: () => {
ipcRenderer.removeAllListeners('hub:devices-changed')
},
onInboundMessage: (callback: (event: InboundMessageEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: InboundMessageEvent): void => {
callback(data)
}
ipcRenderer.on('hub:inbound-message', listener)
return (): void => {
ipcRenderer.removeListener('hub:inbound-message', listener)
}
},
onConversationsChanged: (callback: () => void) => {
const listener = (): void => {
callback()
}
ipcRenderer.on('hub:conversations-changed', listener)
return (): void => {
ipcRenderer.removeListener('hub:conversations-changed', listener)
}
},
offInboundMessage: () => {
ipcRenderer.removeAllListeners('hub:inbound-message')
},
},
// Tools management
tools: {
list: (): Promise<ToolInfo[]> => ipcRenderer.invoke('tools:list'),
toggle: (name: string) => ipcRenderer.invoke('tools:toggle', name),
setStatus: (name: string, enabled: boolean) =>
ipcRenderer.invoke('tools:setStatus', name, enabled),
active: () => ipcRenderer.invoke('tools:active'),
reload: () => ipcRenderer.invoke('tools:reload'),
},
// Skills management
skills: {
list: (): Promise<SkillInfo[]> => ipcRenderer.invoke('skills:list'),
get: (id: string) => ipcRenderer.invoke('skills:get', id),
toggle: (id: string) => ipcRenderer.invoke('skills:toggle', id),
setStatus: (id: string, enabled: boolean) =>
ipcRenderer.invoke('skills:setStatus', id, enabled),
reload: () => ipcRenderer.invoke('skills:reload'),
add: (source: string, options?: { name?: string; force?: boolean }) =>
ipcRenderer.invoke('skills:add', source, options),
remove: (name: string) => ipcRenderer.invoke('skills:remove', name),
},
// Agent management
agent: {
status: () => ipcRenderer.invoke('agent:status'),
},
// Profile management
profile: {
get: (): Promise<ProfileData> => ipcRenderer.invoke('profile:get'),
updateName: (name: string) => ipcRenderer.invoke('profile:updateName', name),
updateUser: (content: string) => ipcRenderer.invoke('profile:updateUser', content),
},
// Provider management
provider: {
/** List all providers with their status */
list: (): Promise<ProviderStatus[]> => ipcRenderer.invoke('provider:list'),
/** List only available (configured) providers */
listAvailable: (): Promise<ProviderStatus[]> => ipcRenderer.invoke('provider:listAvailable'),
/** Get current provider and model from the active agent */
current: (): Promise<CurrentProviderInfo> => ipcRenderer.invoke('provider:current'),
/** Switch the agent to a different provider and/or model */
set: (providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> =>
ipcRenderer.invoke('provider:set', providerId, modelId),
/** Get metadata for a specific provider */
getMeta: (providerId: string) => ipcRenderer.invoke('provider:getMeta', providerId),
/** Check if a specific provider is available */
isAvailable: (providerId: string): Promise<boolean> => ipcRenderer.invoke('provider:isAvailable', providerId),
/** Save API key for a provider */
saveApiKey: (providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('provider:saveApiKey', providerId, apiKey),
/** Import OAuth credentials from CLI tools (claude-code, codex) */
importOAuth: (providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> =>
ipcRenderer.invoke('provider:importOAuth', providerId),
/** Test a provider connection with a minimal prompt */
test: (providerId: string, modelId?: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('provider:test', providerId, modelId),
},
// Channel management (Telegram, Discord, etc.)
channels: {
/** List all channel account states */
listStates: () => ipcRenderer.invoke('channels:listStates'),
/** Get channels config from credentials.json5 */
getConfig: () => ipcRenderer.invoke('channels:getConfig'),
/** Save a channel token and start the bot immediately */
saveToken: (channelId: string, accountId: string, token: string) =>
ipcRenderer.invoke('channels:saveToken', channelId, accountId, token),
/** Remove a channel token and stop the bot */
removeToken: (channelId: string, accountId: string) =>
ipcRenderer.invoke('channels:removeToken', channelId, accountId),
/** Stop a channel account */
stop: (channelId: string, accountId: string) =>
ipcRenderer.invoke('channels:stop', channelId, accountId),
/** Start a channel account from saved config */
start: (channelId: string, accountId: string) =>
ipcRenderer.invoke('channels:start', channelId, accountId),
},
// Cron jobs management
cron: {
list: () => ipcRenderer.invoke('cron:list'),
toggle: (jobId: string) => ipcRenderer.invoke('cron:toggle', jobId),
remove: (jobId: string) => ipcRenderer.invoke('cron:remove', jobId),
},
heartbeat: {
last: () => ipcRenderer.invoke('heartbeat:last'),
setEnabled: (enabled: boolean) => ipcRenderer.invoke('heartbeat:setEnabled', enabled),
wake: (reason?: string) => ipcRenderer.invoke('heartbeat:wake', reason),
},
// Auto-update
update: {
/** Check for updates */
check: () => ipcRenderer.invoke('update:check'),
/** Download the available update */
download: () => ipcRenderer.invoke('update:download'),
/** Quit and install the downloaded update */
install: () => ipcRenderer.invoke('update:install'),
/** Listen for update status changes (returns unsubscribe function) */
onStatus: (callback: (status: { status: string; info?: unknown; progress?: unknown; error?: string }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: Parameters<typeof callback>[0]): void => {
callback(status)
}
ipcRenderer.on('update:status', listener)
return (): void => {
ipcRenderer.removeListener('update:status', listener)
}
},
},
// Local chat (direct IPC, no Gateway required)
localChat: {
/** Subscribe to conversation events for local direct chat */
subscribe: (conversationId: string) => ipcRenderer.invoke('localChat:subscribe', conversationId),
/** Unsubscribe from conversation events */
unsubscribe: (conversationId: string, token?: number) => ipcRenderer.invoke('localChat:unsubscribe', conversationId, token),
/** Get message history for local chat with pagination (returns raw AgentMessageItem[]) */
getHistory: (conversationId: string, options?: { offset?: number; limit?: number }) =>
ipcRenderer.invoke('localChat:getHistory', conversationId, options),
/** Send message to conversation via direct IPC (no Gateway) */
send: (conversationId: string, content: string) =>
ipcRenderer.invoke('localChat:send', conversationId, content),
/** Abort the current agent run */
abort: (conversationId: string) =>
ipcRenderer.invoke('localChat:abort', conversationId),
/** Resolve an exec approval request */
resolveExecApproval: (approvalId: string, decision: string) =>
ipcRenderer.invoke('localChat:resolveExecApproval', approvalId, decision),
/** Listen for agent events */
onEvent: (callback: (event: LocalChatEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: LocalChatEvent): void => {
callback(data)
}
ipcRenderer.on('localChat:event', listener)
return (): void => {
ipcRenderer.removeListener('localChat:event', listener)
}
},
/** Remove event listener */
offEvent: () => {
ipcRenderer.removeAllListeners('localChat:event')
},
/** Listen for exec approval requests */
onApproval: (callback: (approval: LocalChatApproval) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: LocalChatApproval): void => {
callback(data)
}
ipcRenderer.on('localChat:approval', listener)
return (): void => {
ipcRenderer.removeListener('localChat:approval', listener)
}
},
/** Remove approval listener */
offApproval: () => {
ipcRenderer.removeAllListeners('localChat:approval')
},
},
}
// Expose to renderer
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
// Also expose ipcRenderer for backward compatibility
contextBridge.exposeInMainWorld('ipcRenderer', {
on(...args: Parameters<typeof ipcRenderer.on>) {
const [channel, listener] = args
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args))
},
off(...args: Parameters<typeof ipcRenderer.off>) {
const [channel, ...omit] = args
return ipcRenderer.off(channel, ...omit)
},
send(...args: Parameters<typeof ipcRenderer.send>) {
const [channel, ...omit] = args
return ipcRenderer.send(channel, ...omit)
},
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
const [channel, ...omit] = args
return ipcRenderer.invoke(channel, ...omit)
},
})
// Type declaration for window object
export type ElectronAPI = typeof electronAPI

View file

@ -1,23 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multica</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
/* Use system fonts for proper CJK punctuation support */
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-geist-mono: "Geist Mono", ui-monospace, monospace;
--font-brand: "Playfair Display", serif;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

View file

@ -1,140 +0,0 @@
import { useEffect, useState } from 'react'
import { createHashRouter, Navigate, RouterProvider } from 'react-router-dom'
import { ThemeProvider } from './components/theme-provider'
import { TooltipProvider } from '@multica/ui/components/ui/tooltip'
import { Toaster } from './components/toaster'
import Layout from './pages/layout'
import HomePage from './pages/home'
import ProfilePage from './pages/agent/profile'
import SkillsPage from './pages/agent/skills'
import ToolsPage from './pages/agent/tools'
import ClientsPage from './pages/clients'
import CronsPage from './pages/crons'
import OnboardingPage from './pages/onboarding'
import LoginPage from './pages/login'
import { useOnboardingStore } from './stores/onboarding'
import { useHubStore } from './stores/hub'
import { useProviderStore } from './stores/provider'
import { useChannelsStore } from './stores/channels'
import { useSkillsStore } from './stores/skills'
import { useToolsStore } from './stores/tools'
import { useCronJobsStore } from './stores/cron-jobs'
import { useAuthStore, setupAuthCallbackListener } from './stores/auth'
// Auth guard - redirects to login if not authenticated
function AuthGuard({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const isLoading = useAuthStore((s) => s.isLoading)
if (isLoading) {
return <div className="flex h-screen items-center justify-center bg-background" />
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const completed = useOnboardingStore((s) => s.completed)
if (!completed) return <Navigate to="/onboarding" replace />
return <>{children}</>
}
const router = createHashRouter([
{
path: '/login',
element: <LoginPage />,
},
{
path: '/onboarding',
element: (
<AuthGuard>
<OnboardingPage />
</AuthGuard>
),
},
{
path: '/',
element: (
<AuthGuard>
<Layout />
</AuthGuard>
),
children: [
{
index: true,
element: (
<OnboardingGuard>
<HomePage />
</OnboardingGuard>
),
},
{ path: 'chat', element: null },
{ path: 'agent/profile', element: <ProfilePage /> },
{ path: 'agent/skills', element: <SkillsPage /> },
{ path: 'agent/tools', element: <ToolsPage /> },
{ path: 'clients', element: <ClientsPage /> },
{ path: 'crons', element: <CronsPage /> },
],
},
])
export default function App() {
const [isHydrated, setIsHydrated] = useState(false)
const setCompleted = useOnboardingStore((s) => s.setCompleted)
useEffect(() => {
// Setup auth callback listener BEFORE async operations
// This ensures cleanup works correctly in React Strict Mode
const cleanupAuth = setupAuthCallbackListener()
async function hydrateState() {
try {
// Load auth state first
await useAuthStore.getState().loadAuth()
// Load onboarding state
const completed = await window.electronAPI.appState.getOnboardingCompleted()
setCompleted(completed)
} catch (err) {
console.error('[App] Failed to hydrate state:', err)
setCompleted(false)
} finally {
setIsHydrated(true)
}
}
hydrateState()
useHubStore.getState().init()
useProviderStore.getState().fetch()
useChannelsStore.getState().fetch()
useSkillsStore.getState().fetch()
useToolsStore.getState().fetch()
useCronJobsStore.getState().fetch()
return () => {
cleanupAuth()
}
}, [setCompleted])
if (!isHydrated) {
return (
<ThemeProvider defaultTheme="system" storageKey="multica-theme">
<div className="h-dvh bg-background" />
</ThemeProvider>
)
}
return (
<ThemeProvider defaultTheme="system" storageKey="multica-theme">
<TooltipProvider>
<RouterProvider router={router} />
<Toaster position="bottom-right" />
</TooltipProvider>
</ThemeProvider>
)
}

View file

@ -1,120 +0,0 @@
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@multica/ui/components/ui/dialog'
import { Button } from '@multica/ui/components/ui/button'
import { Input } from '@multica/ui/components/ui/input'
import { Textarea } from '@multica/ui/components/ui/textarea'
import { Label } from '@multica/ui/components/ui/label'
import { Loader2 } from 'lucide-react'
interface AgentSettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function AgentSettingsDialog({ open, onOpenChange }: AgentSettingsDialogProps) {
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [name, setName] = useState('')
const [userContent, setUserContent] = useState('')
// Load profile data when dialog opens
useEffect(() => {
if (open) {
loadProfile()
}
}, [open])
const loadProfile = async () => {
setLoading(true)
try {
const data = await window.electronAPI.profile.get()
setName(data.name ?? '')
setUserContent(data.userContent ?? '')
} catch (err) {
console.error('Failed to load profile:', err)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setSaving(true)
try {
// Update name if changed
await window.electronAPI.profile.updateName(name)
// Update user content
await window.electronAPI.profile.updateUser(userContent)
onOpenChange(false)
} catch (err) {
console.error('Failed to save profile:', err)
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit Agent</DialogTitle>
<DialogDescription>
Customize your agent's name and personal settings.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="agent-name">Name</Label>
<Input
id="agent-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Assistant"
/>
</div>
{/* User Content */}
<div className="space-y-2">
<Label htmlFor="user-content">About You</Label>
<p className="text-xs text-muted-foreground">
Help the agent understand you better. Share your preferences, role, or any context.
</p>
<Textarea
id="user-content"
value={userContent}
onChange={(e) => setUserContent(e.target.value)}
placeholder="- I'm a frontend developer&#10;- I prefer TypeScript&#10;- Please respond in Chinese"
className="min-h-[120px] font-mono text-sm"
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving && <Loader2 className="size-4 animate-spin mr-2" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default AgentSettingsDialog

View file

@ -1,212 +0,0 @@
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@multica/ui/components/ui/dialog'
import { Button } from '@multica/ui/components/ui/button'
import { Input } from '@multica/ui/components/ui/input'
import { Label } from '@multica/ui/components/ui/label'
import { Loader2, Key, Check } from 'lucide-react'
type Phase = 'input' | 'saving' | 'testing' | 'success' | 'error'
interface ApiKeyDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
providerId: string
providerName: string
showModelInput?: boolean
onSuccess?: (modelId?: string) => void
}
export function ApiKeyDialog({
open,
onOpenChange,
providerId,
providerName,
showModelInput,
onSuccess,
}: ApiKeyDialogProps) {
const [apiKey, setApiKey] = useState('')
const [modelId, setModelId] = useState('')
const [phase, setPhase] = useState<Phase>('input')
const [error, setError] = useState<string | null>(null)
const busy = phase === 'saving' || phase === 'testing'
const handleSave = async () => {
if (!apiKey.trim()) {
setError('API key is required')
return
}
if (showModelInput && !modelId.trim()) {
setError('Please enter a model name')
return
}
setError(null)
setPhase('saving')
try {
const result = await window.electronAPI.provider.saveApiKey(providerId, apiKey.trim())
if (!result.ok) {
setError(result.error ?? 'Failed to save API key')
setPhase('error')
return
}
// Test the connection
setPhase('testing')
const effectiveModel = showModelInput && modelId.trim() ? modelId.trim() : undefined
const testResult = await window.electronAPI.provider.test(providerId, effectiveModel)
if (!testResult.ok) {
setError(testResult.error ?? 'Connection test failed')
setPhase('error')
return
}
setPhase('success')
// Auto-close after brief success display
setTimeout(() => {
setApiKey('')
setModelId('')
setPhase('input')
setError(null)
onOpenChange(false)
onSuccess?.(effectiveModel)
}, 1000)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
setPhase('error')
}
}
const handleRetry = () => {
setError(null)
setPhase('input')
}
const handleClose = (isOpen: boolean) => {
if (!isOpen && !busy) {
setApiKey('')
setModelId('')
setPhase('input')
setError(null)
}
if (!busy) {
onOpenChange(isOpen)
}
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="size-5" />
Configure {providerName}
</DialogTitle>
<DialogDescription>
Enter your API key to enable {providerName}. The key will be saved and tested automatically.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-key">API Key</Label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
disabled={busy || phase === 'success'}
onKeyDown={(e) => {
if (e.key === 'Enter' && !busy) {
handleSave()
}
}}
/>
</div>
{showModelInput && (
<div className="space-y-2">
<Label htmlFor="model-id">Model</Label>
<Input
id="model-id"
value={modelId}
onChange={(e) => setModelId(e.target.value)}
placeholder="e.g. anthropic/claude-sonnet-4-5"
disabled={busy || phase === 'success'}
/>
</div>
)}
{/* Status messages */}
{phase === 'saving' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Saving API key...
</div>
)}
{phase === 'testing' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Testing connection...
</div>
)}
{phase === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Check className="size-4" />
Connected successfully!
</div>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<p className="text-xs text-muted-foreground">
Your API key is stored locally in <code className="bg-muted px-1 rounded">~/.super-multica/credentials.json5</code>
</p>
</div>
<DialogFooter>
{phase === 'error' ? (
<>
<Button variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button onClick={handleRetry}>
Try again
</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => handleClose(false)} disabled={busy || phase === 'success'}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={busy || phase === 'success' || !apiKey.trim() || (showModelInput && !modelId.trim())}
>
Save & Test
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default ApiKeyDialog

View file

@ -1,215 +0,0 @@
import { useState } from 'react'
import { Switch } from '@multica/ui/components/ui/switch'
import { Button } from '@multica/ui/components/ui/button'
import {
RotateCw,
Trash2,
Loader2,
Clock,
CheckCircle,
XCircle,
AlertCircle,
} from 'lucide-react'
import type { CronJobInfo } from '../stores/cron-jobs'
interface CronJobListProps {
jobs: CronJobInfo[]
loading: boolean
error: string | null
onToggleJob: (jobId: string) => Promise<void>
onRemoveJob: (jobId: string) => Promise<void>
onRefresh: () => Promise<void>
}
function StatusBadge({ status }: { status: CronJobInfo['lastStatus'] }) {
if (!status) {
return (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
no runs
</span>
)
}
const config = {
ok: { Icon: CheckCircle, className: 'text-emerald-600', label: 'ok' },
error: { Icon: XCircle, className: 'text-destructive', label: 'error' },
skipped: { Icon: AlertCircle, className: 'text-yellow-600', label: 'skipped' },
}[status]
return (
<span className={`flex items-center gap-1 text-xs ${config.className}`}>
<config.Icon className="size-3.5" />
{config.label}
</span>
)
}
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString)
const now = Date.now()
const diffMs = date.getTime() - now
if (Math.abs(diffMs) < 60_000) return 'just now'
const absMs = Math.abs(diffMs)
const minutes = Math.floor(absMs / 60_000)
const hours = Math.floor(absMs / 3_600_000)
const days = Math.floor(absMs / 86_400_000)
const unit = days > 0 ? `${days}d` : hours > 0 ? `${hours}h` : `${minutes}m`
return diffMs > 0 ? `in ${unit}` : `${unit} ago`
}
export function CronJobList({
jobs,
loading,
error,
onToggleJob,
onRemoveJob,
onRefresh,
}: CronJobListProps) {
const [togglingJobs, setTogglingJobs] = useState<Set<string>>(new Set())
const [removingJobs, setRemovingJobs] = useState<Set<string>>(new Set())
const handleToggle = async (jobId: string) => {
setTogglingJobs((prev) => new Set(prev).add(jobId))
try {
await onToggleJob(jobId)
} finally {
setTogglingJobs((prev) => {
const next = new Set(prev)
next.delete(jobId)
return next
})
}
}
const handleRemove = async (jobId: string) => {
setRemovingJobs((prev) => new Set(prev).add(jobId))
try {
await onRemoveJob(jobId)
} finally {
setRemovingJobs((prev) => {
const next = new Set(prev)
next.delete(jobId)
return next
})
}
}
if (loading && jobs.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading cron jobs...</span>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
{jobs.filter((j) => j.enabled).length} of {jobs.length} jobs enabled
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="gap-1.5"
disabled={loading}
>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RotateCw className="size-4" />
)}
Refresh
</Button>
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Empty state */}
{jobs.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Clock className="size-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No scheduled tasks</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Use the cron tool in Chat to create one.
</p>
</div>
)}
{/* Job list */}
{jobs.length > 0 && (
<div className="border rounded-lg divide-y">
{jobs.map((job) => {
const isToggling = togglingJobs.has(job.id)
const isRemoving = removingJobs.has(job.id)
return (
<div
key={job.id}
className="flex items-center gap-4 px-4 py-3 hover:bg-muted/20 transition-colors"
>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{job.name}</span>
<StatusBadge status={job.lastStatus} />
</div>
<div className="flex items-center gap-3 mt-0.5 text-xs text-muted-foreground">
<span className="font-mono">{job.schedule}</span>
{job.nextRunAt && job.enabled && (
<span>next: {formatRelativeTime(job.nextRunAt)}</span>
)}
{job.lastRunAt && (
<span>last: {formatRelativeTime(job.lastRunAt)}</span>
)}
</div>
{job.lastError && (
<p className="text-xs text-destructive mt-0.5 truncate">{job.lastError}</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(job.id)}
disabled={isRemoving}
>
{isRemoving ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
</Button>
{isToggling && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
<Switch
checked={job.enabled}
onCheckedChange={() => handleToggle(job.id)}
disabled={isToggling}
/>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
export default CronJobList

View file

@ -1,95 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@multica/ui/components/ui/alert-dialog'
import { parseUserAgent } from '../lib/parse-user-agent'
interface DeviceMeta {
userAgent?: string
platform?: string
language?: string
clientName?: string
}
interface PendingConfirm {
deviceId: string
agentId: string
conversationId: string
meta?: DeviceMeta
}
/**
* Device confirmation dialog shown when a new device tries to connect via QR code.
* Listens for 'hub:device-confirm-request' IPC events from the main process,
* shows an AlertDialog, and sends the user's response back.
*/
export function DeviceConfirmDialog() {
const [pending, setPending] = useState<PendingConfirm | null>(null)
useEffect(() => {
window.electronAPI?.hub.onDeviceConfirmRequest(
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => {
setPending({ deviceId, agentId, conversationId, meta })
},
)
return () => {
window.electronAPI?.hub.offDeviceConfirmRequest()
}
}, [])
const handleAllow = useCallback(() => {
if (!pending) return
window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, true)
setPending(null)
}, [pending])
const handleReject = useCallback(() => {
if (!pending) return
window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, false)
setPending(null)
}, [pending])
const parsed = pending?.meta?.userAgent
? parseUserAgent(pending.meta.userAgent)
: null
const deviceLabel = pending?.meta?.clientName
? pending.meta.clientName
: parsed
? `${parsed.browser} on ${parsed.os}`
: pending?.deviceId
return (
<AlertDialog open={pending !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>New Device Connection</AlertDialogTitle>
<AlertDialogDescription>
<span className="font-medium">{deviceLabel}</span> wants to connect.
{parsed && (
<span className="block text-xs font-mono text-muted-foreground truncate mt-1">
{pending?.deviceId}
</span>
)}
<span className="block mt-1">Allow this device?</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleReject}>
Reject
</AlertDialogCancel>
<AlertDialogAction onClick={handleAllow}>
Allow
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -1,139 +0,0 @@
import { useState } from 'react'
import { Button } from '@multica/ui/components/ui/button'
import {
Smartphone,
Trash2,
Loader2,
RotateCw,
} from 'lucide-react'
import { useDevices, type DeviceEntry } from '../hooks/use-devices'
import { parseUserAgent } from '../lib/parse-user-agent'
// ============ Relative Time ============
function relativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d ago`
const months = Math.floor(days / 30)
return `${months}mo ago`
}
// ============ Component ============
function DeviceItem({
device,
onRevoke,
}: {
device: DeviceEntry
onRevoke: (deviceId: string) => Promise<boolean>
}) {
const [revoking, setRevoking] = useState(false)
const parsed = device.meta?.userAgent
? parseUserAgent(device.meta.userAgent)
: null
const displayName = device.meta?.clientName
? device.meta.clientName
: parsed
? `${parsed.browser} on ${parsed.os}`
: device.deviceId
const handleRevoke = async () => {
setRevoking(true)
try {
await onRevoke(device.deviceId)
} finally {
setRevoking(false)
}
}
return (
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-3 min-w-0 flex-1">
<Smartphone className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{displayName}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono truncate max-w-[180px]">{device.deviceId}</span>
<span>·</span>
<span>{relativeTime(device.addedAt)}</span>
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive shrink-0"
onClick={handleRevoke}
disabled={revoking}
>
{revoking ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
</Button>
</div>
)
}
export function DeviceList() {
const { devices, loading, refreshing, refresh, revokeDevice } = useDevices()
if (loading) {
return null
}
if (devices.length === 0) {
return (
<div className="flex flex-col items-center justify-center text-center py-8">
<Smartphone className="size-8 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">No devices connected yet.</p>
</div>
)
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">
Verified Devices ({devices.length})
</h3>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={refresh}
disabled={refreshing}
>
{refreshing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RotateCw className="size-3" />
)}
Refresh
</Button>
</div>
<div className="border rounded-lg divide-y overflow-hidden">
{devices.map((device) => (
<DeviceItem
key={device.deviceId}
device={device}
onRevoke={revokeDevice}
/>
))}
</div>
</div>
)
}

View file

@ -1,162 +0,0 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loading } from '@multica/ui/components/ui/loading'
import { ChatView } from '@multica/ui/components/chat-view'
import { useLocalChat } from '../hooks/use-local-chat'
import { useProviderStore } from '../stores/provider'
import { ApiKeyDialog } from './api-key-dialog'
import { OAuthDialog } from './oauth-dialog'
import { QueuedMessageBar } from './queued-message-bar'
interface LocalChatProps {
initialPrompt?: string
conversationId?: string
}
export function LocalChat({ initialPrompt, conversationId }: LocalChatProps) {
const navigate = useNavigate()
const {
conversationId: activeConversationId,
initError,
messages,
streamingIds,
isLoading,
isLoadingHistory,
isLoadingMore,
hasMore,
contextWindowTokens,
error,
pendingApprovals,
queuedMessages,
sendMessage,
abortGeneration,
removeQueuedMessage,
clearQueuedMessages,
loadMore,
resolveApproval,
clearError,
} = useLocalChat({ conversationId })
const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProviderStore()
// Provider config dialog state
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
const handleConfigureProvider = useCallback(() => {
const providerId = current?.provider
if (!providerId) return
const meta = providers.find((p) => p.id === providerId)
if (!meta) return
if (meta.authMethod === 'oauth') {
setOauthDialogOpen(true)
} else {
setApiKeyDialogOpen(true)
}
}, [current, providers])
const handleProviderConfigSuccess = useCallback(async () => {
const providerId = current?.provider
if (!providerId) return
await refreshProviders()
await switchProvider(providerId)
clearError()
}, [current, refreshProviders, switchProvider, clearError])
// Derive provider info for dialogs
const currentMeta = current ? providers.find((p) => p.id === current.provider) : null
// Auto-send initial prompt after a short delay
const lastPromptRef = useRef<string | undefined>(undefined)
useEffect(() => {
if (!activeConversationId || !initialPrompt) return
if (initialPrompt === lastPromptRef.current) return
const timer = setTimeout(() => {
lastPromptRef.current = initialPrompt
sendMessage(initialPrompt)
// Remove prompt from URL to prevent re-sending on back navigation
const nextPath = activeConversationId
? `/chat?conversation=${encodeURIComponent(activeConversationId)}`
: '/chat'
navigate(nextPath, { replace: true })
}, 500)
return () => clearTimeout(timer)
}, [activeConversationId, initialPrompt, sendMessage, navigate])
if (initError) {
return (
<div className="flex-1 flex items-center justify-center text-sm text-destructive">
{initError}
</div>
)
}
if (!activeConversationId) {
return (
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
<Loading />
Initializing...
</div>
)
}
// Show "Configure" button when error is about provider/API key
const errorAction = error?.code === 'AGENT_ERROR' && currentMeta
? { label: 'Configure', onClick: handleConfigureProvider }
: undefined
return (
<>
<ChatView
messages={messages}
streamingIds={streamingIds}
isLoading={isLoading}
isLoadingHistory={isLoadingHistory}
isLoadingMore={isLoadingMore}
hasMore={hasMore}
contextWindowTokens={contextWindowTokens}
error={error}
pendingApprovals={pendingApprovals}
sendMessage={sendMessage}
onAbort={abortGeneration}
loadMore={loadMore}
resolveApproval={resolveApproval}
errorAction={errorAction}
bottomSlot={
<QueuedMessageBar
messages={queuedMessages}
isRunning={isLoading}
onRemove={removeQueuedMessage}
onClear={clearQueuedMessages}
/>
}
/>
{currentMeta && currentMeta.authMethod === 'api-key' && (
<ApiKeyDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
providerId={currentMeta.id}
providerName={currentMeta.name}
onSuccess={handleProviderConfigSuccess}
/>
)}
{currentMeta && currentMeta.authMethod === 'oauth' && (
<OAuthDialog
open={oauthDialogOpen}
onOpenChange={setOauthDialogOpen}
providerId={currentMeta.id}
providerName={currentMeta.name}
loginCommand={currentMeta.loginCommand}
onSuccess={handleProviderConfigSuccess}
/>
)}
</>
)
}

View file

@ -1,37 +0,0 @@
import { Sun, Moon, Monitor } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu"
import { useTheme } from "./theme-provider"
export function ModeToggle() {
const { theme, setTheme } = useTheme()
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor
return (
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center justify-center size-8 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Icon className="size-4" />
<span className="sr-only">Toggle theme</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="size-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="size-4" />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="size-4" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -1,145 +0,0 @@
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@multica/ui/components/ui/dialog'
import { Button } from '@multica/ui/components/ui/button'
import { Loader2, Terminal, RefreshCw, Check } from 'lucide-react'
interface OAuthDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
providerId: string
providerName: string
loginCommand?: string
onSuccess?: () => void
}
export function OAuthDialog({
open,
onOpenChange,
providerId,
providerName,
loginCommand,
onSuccess,
}: OAuthDialogProps) {
const [importing, setImporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [expiresAt, setExpiresAt] = useState<number | null>(null)
const handleImport = async () => {
setImporting(true)
setError(null)
setSuccess(false)
try {
const result = await window.electronAPI.provider.importOAuth(providerId)
if (result.ok) {
setSuccess(true)
setExpiresAt(result.expiresAt ?? null)
// Auto-close after a short delay
setTimeout(() => {
onOpenChange(false)
onSuccess?.()
}, 1500)
} else {
setError(result.error ?? 'Failed to import credentials')
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
} finally {
setImporting(false)
}
}
const handleClose = (isOpen: boolean) => {
if (!isOpen) {
setError(null)
setSuccess(false)
setExpiresAt(null)
}
onOpenChange(isOpen)
}
const formatExpiry = (timestamp: number) => {
const remaining = timestamp - Date.now()
if (remaining <= 0) return 'expired'
const hours = Math.floor(remaining / (60 * 60 * 1000))
const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000))
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Terminal className="size-5" />
Configure {providerName}
</DialogTitle>
<DialogDescription>
{providerName} uses OAuth authentication. Please log in via the command line first.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Login instructions */}
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
1. Open your terminal and run:
</p>
<div className="bg-muted rounded-md p-3 font-mono text-sm">
{loginCommand ?? `${providerId} login`}
</div>
<p className="text-sm text-muted-foreground">
2. Complete the login process in your browser
</p>
<p className="text-sm text-muted-foreground">
3. Click "Refresh" below to import your credentials
</p>
</div>
{/* Status messages */}
{error && (
<div className="bg-destructive/10 text-destructive rounded-md p-3 text-sm">
{error}
</div>
)}
{success && (
<div className="bg-green-500/10 text-green-600 dark:text-green-400 rounded-md p-3 text-sm flex items-center gap-2">
<Check className="size-4" />
<span>
Credentials imported successfully!
{expiresAt && ` (expires in ${formatExpiry(expiresAt)})`}
</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={importing}>
Cancel
</Button>
<Button onClick={handleImport} disabled={importing || success}>
{importing ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<RefreshCw className="size-4 mr-2" />
)}
Refresh
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default OAuthDialog

View file

@ -1,37 +0,0 @@
import { Switch } from '@multica/ui/components/ui/switch'
import type { LucideIcon } from 'lucide-react'
interface AcknowledgementItemProps {
icon: LucideIcon
title: string
description: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}
export function AcknowledgementItem({
icon: Icon,
title,
description,
checked,
onCheckedChange,
}: AcknowledgementItemProps) {
return (
<label className="flex items-start gap-4 p-4 rounded-xl border border-border bg-card cursor-pointer hover:bg-accent/30 transition-colors">
<div className="mt-0.5 flex items-center justify-center size-8 rounded-lg bg-muted shrink-0">
<Icon className="size-4 text-muted-foreground" />
</div>
<div className="flex-1 space-y-0.5">
<p className="font-medium text-sm">{title}</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{description}
</p>
</div>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
className="mt-1 shrink-0"
/>
</label>
)
}

View file

@ -1,42 +0,0 @@
import { Key, Database, Terminal } from 'lucide-react'
const privacyItems = [
{
icon: Database,
title: 'Everything stays local',
description:
'All sessions, history, and profiles are stored on your device. Nothing leaves your computer.',
},
{
icon: Key,
title: 'Your data, your control',
description:
'API keys and credentials are saved locally in ~/.super-multica/. We never access them.',
},
{
icon: Terminal,
title: 'Transparent execution',
description:
'Every shell command the agent wants to run requires your explicit approval first.',
},
]
export function PrivacyPanel() {
return (
<div className="rounded-2xl bg-muted/50 border border-border/50 p-6 space-y-5">
{privacyItems.map((item) => (
<div key={item.title} className="flex gap-3">
<div className="mt-0.5 flex items-center justify-center size-7 rounded-lg bg-primary/10 shrink-0">
<item.icon className="size-4 text-primary" />
</div>
<div className="space-y-0.5">
<p className="font-medium text-sm text-primary">{item.title}</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{item.description}
</p>
</div>
</div>
))}
</div>
)
}

View file

@ -1,134 +0,0 @@
import { Button } from '@multica/ui/components/ui/button'
import { Check } from 'lucide-react'
import { cn } from '@multica/ui/lib/utils'
interface ProviderSetupProps {
providers: ProviderStatus[]
loading: boolean
activeProviderId?: string
onConfigure: (provider: ProviderStatus) => void
onSelect: (provider: ProviderStatus) => void
onFocus?: (provider: ProviderStatus) => void
}
const SUPPORTED_PROVIDERS = ['kimi-coding', 'claude-code', 'openai-codex', 'openrouter']
function ProviderCard({
provider,
isActive,
onConfigure,
onSelect,
onFocus,
}: {
provider: ProviderStatus
isActive: boolean
onConfigure: (p: ProviderStatus) => void
onSelect: (p: ProviderStatus) => void
onFocus?: (p: ProviderStatus) => void
}) {
return (
<div
onClick={() => provider.available && onSelect(provider)}
onMouseEnter={() => onFocus?.(provider)}
className={cn(
'flex items-center justify-between p-4 rounded-xl border bg-card transition-colors',
provider.available && 'cursor-pointer hover:bg-accent/30',
isActive
? 'border-primary ring-1 ring-primary/20'
: provider.available
? 'border-primary/30'
: 'border-border'
)}
>
<div className="flex items-center gap-3">
{provider.available ? (
<div
className={cn(
'size-4 rounded-full border-2 shrink-0 flex items-center justify-center',
isActive ? 'border-primary' : 'border-muted-foreground/30'
)}
>
{isActive && <div className="size-2 rounded-full bg-primary" />}
</div>
) : (
<span className="size-2 rounded-full bg-muted-foreground/30 shrink-0" />
)}
<div>
<p className="font-medium text-sm">{provider.name}</p>
<p className="text-xs text-muted-foreground">
{provider.defaultModel}
</p>
</div>
</div>
{provider.available ? (
<div className="flex items-center gap-2">
<Check className="size-4 text-primary" />
<Button
size="sm"
variant="ghost"
className="text-xs text-muted-foreground h-auto py-1 px-2"
onClick={(e) => {
e.stopPropagation()
onConfigure(provider)
}}
>
Reconfigure
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
onConfigure(provider)
}}
>
Configure
</Button>
)}
</div>
)
}
export function ProviderSetup({
providers,
loading,
activeProviderId,
onConfigure,
onSelect,
onFocus,
}: ProviderSetupProps) {
const filtered = SUPPORTED_PROVIDERS
.map((id) => providers.find((p) => p.id === id))
.filter((p): p is ProviderStatus => p != null)
if (loading && filtered.length === 0) {
return (
<div className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-16 rounded-xl border border-border bg-card animate-pulse"
/>
))}
</div>
)
}
return (
<div className="space-y-2">
{filtered.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isActive={activeProviderId === provider.id}
onConfigure={onConfigure}
onSelect={onSelect}
onFocus={onFocus}
/>
))}
</div>
)
}

View file

@ -1,22 +0,0 @@
import { ArrowRight } from 'lucide-react'
interface SamplePromptProps {
title: string
prompt: string
onClick: () => void
}
export function SamplePrompt({ title, prompt, onClick }: SamplePromptProps) {
return (
<button
onClick={onClick}
className="w-full text-left p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors group flex items-center justify-between"
>
<div className="space-y-0.5 pr-4 min-w-0">
<p className="font-medium text-sm">{title}</p>
<p className="text-xs text-muted-foreground truncate">{prompt}</p>
</div>
<ArrowRight className="size-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0" />
</button>
)
}

View file

@ -1,69 +0,0 @@
import { cn } from '@multica/ui/lib/utils'
import { Check } from 'lucide-react'
const steps = [
{ label: 'Privacy' },
{ label: 'Provider' },
{ label: 'Connect' },
{ label: 'Try it' },
]
interface StepperProps {
currentStep: number // 1-based index
}
export function Stepper({ currentStep }: StepperProps) {
const currentIndex = currentStep - 1 // Convert to 0-based
// Progress: 0% at step 0, 50% at step 1, 100% at step 2
const progress = (currentIndex / (steps.length - 1)) * 100
return (
<div className="w-full space-y-3">
{/* Step labels */}
<nav className="flex items-center justify-center gap-3">
{steps.map((step, index) => {
const isCompleted = index < currentIndex
const isCurrent = index === currentIndex
return (
<div key={step.label} className="flex items-center gap-3">
{index > 0 && (
<span
className={cn(
'text-xs',
isCompleted || isCurrent
? 'text-muted-foreground'
: 'text-muted-foreground/40'
)}
>
</span>
)}
<span
className={cn(
'flex items-center gap-1 text-sm transition-colors',
isCurrent && 'text-foreground font-medium',
isCompleted && 'text-foreground',
!isCurrent && !isCompleted && 'text-muted-foreground/60'
)}
>
{isCompleted && (
<Check className="size-3.5 text-foreground" />
)}
{step.label}
</span>
</div>
)
})}
</nav>
{/* Progress bar */}
<div className="h-1 w-full bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)
}

View file

@ -1,32 +0,0 @@
interface TutorialStepProps {
number: number
text: string
link?: string
}
export function TutorialStep({ number, text, link }: TutorialStepProps) {
return (
<div className="flex gap-3">
<div className="flex items-center justify-center size-6 rounded-full bg-primary/10 text-primary text-xs font-medium shrink-0">
{number}
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{link ? (
<>
Go to{' '}
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:text-primary/80"
>
{new URL(link).hostname}
</a>
</>
) : (
text
)}
</p>
</div>
)
}

View file

@ -1,184 +0,0 @@
import { useState, useCallback, useMemo } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { Button } from '@multica/ui/components/ui/button'
import { Copy, Check } from 'lucide-react'
import { useQRToken, useCountdown } from './qr-hooks'
// ============ Types ============
export interface QRCodeData {
type: 'multica-connect'
gateway: string
hubId: string
agentId: string
conversationId?: string
token: string
expires: number
}
export interface ConnectionQRCodeProps {
gateway: string
hubId: string
agentId: string
conversationId: string
expirySeconds?: number
size?: number
}
// Hooks are in ./qr-hooks.ts (separate file for react-refresh compatibility)
/**
* Hook for clipboard copy with feedback
*/
function useCopyToClipboard(timeout = 2000) {
const [copied, setCopied] = useState(false)
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), timeout)
return true
} catch {
return false
}
}, [timeout])
return { copied, copy }
}
// ============ Components ============
/** Corner accent decoration */
function CornerAccent({ position }: { position: 'tl' | 'tr' | 'bl' | 'br' }) {
const positionClasses = {
tl: '-top-2 -left-2 border-t-2 border-l-2 rounded-tl-lg',
tr: '-top-2 -right-2 border-t-2 border-r-2 rounded-tr-lg',
bl: '-bottom-2 -left-2 border-b-2 border-l-2 rounded-bl-lg',
br: '-bottom-2 -right-2 border-b-2 border-r-2 rounded-br-lg',
}
return (
<div
className={`absolute w-5 h-5 border-muted-foreground/30 ${positionClasses[position]}`}
/>
)
}
/** QR code frame with corner accents */
export function QRCodeFrame({ children }: { children: React.ReactNode }) {
return (
<div className="relative inline-block">
<CornerAccent position="tl" />
<CornerAccent position="tr" />
<CornerAccent position="bl" />
<CornerAccent position="br" />
<div className="bg-white p-3 rounded-lg">{children}</div>
</div>
)
}
/** Format seconds as M:SS */
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
/** Expiry timer display */
export function ExpiryTimer({ remaining }: { remaining: number }) {
// Derive display state from remaining seconds (no extra state needed)
const isWarning = remaining > 0 && remaining < 10
return (
<span
className={`text-xs font-mono ${
isWarning ? 'text-orange-500' : 'text-muted-foreground'
}`}
>
Expires in {formatTime(remaining)}
</span>
)
}
/** Copy link button */
function CopyLinkButton({ url }: { url: string }) {
const { copied, copy } = useCopyToClipboard()
return (
<Button variant="ghost" size="sm" className="h-7 gap-1.5" onClick={() => copy(url)}>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? 'Copied!' : 'Copy Link'}
</Button>
)
}
// ============ Main Component ============
/**
* ConnectionQRCode - QR code for mobile app connection
*
* Architecture:
* - useQRToken: manages token generation and Hub registration
* - useCountdown: handles timer with auto-refresh on expiry
* - Pure child components for display (no state)
*/
export function ConnectionQRCode({
gateway,
hubId,
agentId,
conversationId,
expirySeconds = 30,
size = 200,
}: ConnectionQRCodeProps) {
const { token, expiresAt, refresh } = useQRToken(agentId, conversationId, expirySeconds)
const remaining = useCountdown(expiresAt, refresh)
// Derive QR data and URL from current token (computed during render)
const qrData: QRCodeData = useMemo(
() => ({
type: 'multica-connect',
gateway,
hubId,
agentId,
conversationId,
token,
expires: expiresAt,
}),
[gateway, hubId, agentId, conversationId, token, expiresAt]
)
const connectionUrl = useMemo(() => {
const params = new URLSearchParams({
gateway,
hub: hubId,
agent: agentId,
conversation: conversationId,
token,
exp: expiresAt.toString(),
})
return `multica://connect?${params.toString()}`
}, [gateway, hubId, agentId, conversationId, token, expiresAt])
return (
<div className="flex flex-col items-center gap-4">
<QRCodeFrame>
<QRCodeSVG
value={JSON.stringify(qrData)}
size={size}
level="M"
marginSize={0}
bgColor="#ffffff"
fgColor="#0a0a0a"
/>
</QRCodeFrame>
<div className="flex items-center gap-3">
<ExpiryTimer remaining={remaining} />
<CopyLinkButton url={connectionUrl} />
</div>
</div>
)
}
export default ConnectionQRCode

View file

@ -1,59 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react'
/** Generate a secure random token */
function generateToken(): string {
return crypto.randomUUID()
}
/**
* Hook to manage QR token lifecycle
* - Generates token on mount
* - Auto-refreshes when expired
* - Registers token with Hub
*/
export function useQRToken(agentId: string, conversationId: string, expirySeconds: number) {
const [token, setToken] = useState(generateToken)
const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000)
const refresh = useCallback(() => {
const newToken = generateToken()
const newExpiry = Date.now() + expirySeconds * 1000
setToken(newToken)
setExpiresAt(newExpiry)
window.electronAPI?.hub.registerToken(newToken, agentId, conversationId, newExpiry)
}, [agentId, conversationId, expirySeconds])
// Register initial token
useEffect(() => {
window.electronAPI?.hub.registerToken(token, agentId, conversationId, expiresAt)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { token, expiresAt, refresh }
}
/**
* Hook for countdown timer
* Returns remaining seconds, auto-updates every second
*/
export function useCountdown(expiresAt: number, onExpire: () => void) {
const [remaining, setRemaining] = useState(() =>
Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
)
const onExpireRef = useRef(onExpire)
onExpireRef.current = onExpire
useEffect(() => {
// Reset when expiresAt changes
setRemaining(Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)))
const id = setInterval(() => {
const next = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
setRemaining(next)
if (next === 0) onExpireRef.current()
}, 1000)
return () => clearInterval(id)
}, [expiresAt])
return remaining
}

View file

@ -1,87 +0,0 @@
import { useState } from 'react'
import type { QueuedLocalMessage } from '../hooks/use-local-chat'
interface QueuedMessageBarProps {
messages: QueuedLocalMessage[]
isRunning: boolean
onRemove: (id: string) => void
onClear: () => void
}
export function QueuedMessageBar({ messages, isRunning, onRemove, onClear }: QueuedMessageBarProps) {
const [expanded, setExpanded] = useState(false)
const statusText = isRunning
? 'Agent is running. Queued messages will send automatically.'
: 'Queued messages are being sent.'
if (messages.length === 0) return null
const firstMessage = messages[0]
const firstMessagePreview =
firstMessage.text.length <= 120
? firstMessage.text
: `${firstMessage.text.slice(0, 120)}...`
return (
<div className="container px-4 pb-2">
<div className="rounded-lg border bg-muted/40">
<div className="flex items-center justify-between gap-2 px-3 pt-2 pb-1">
<div className="text-xs font-medium text-foreground/80">
{messages.length} queued message{messages.length > 1 ? 's' : ''}
</div>
<div className="flex items-center gap-2">
{messages.length > 1 && (
<button
onClick={() => setExpanded((prev) => !prev)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? 'Collapse' : 'Expand'}
</button>
)}
<button
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
</div>
</div>
<div className="px-3 pb-2 text-xs text-muted-foreground">{statusText}</div>
{expanded ? (
<div className="px-2 pb-2">
<div className="max-h-40 space-y-1 overflow-y-auto pr-1">
{messages.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-2 rounded-md bg-background/70 px-2 py-1.5">
<div className="text-xs text-foreground/85 break-all">{item.text}</div>
<button
onClick={() => onRemove(item.id)}
className="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Remove
</button>
</div>
))}
</div>
</div>
) : (
<div className="px-2 pb-2 space-y-1">
<div className="flex items-start justify-between gap-2 rounded-md bg-background/70 px-2 py-1.5">
<div className="text-xs text-foreground/85 break-all">{firstMessagePreview}</div>
<button
onClick={() => onRemove(firstMessage.id)}
className="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Remove
</button>
</div>
{messages.length > 1 && (
<div className="px-1 text-xs text-muted-foreground">
+{messages.length - 1} more
</div>
)}
</div>
)}
</div>
</div>
)
}

View file

@ -1,166 +0,0 @@
import { useState } from 'react'
import { Button } from '@multica/ui/components/ui/button'
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from '@multica/ui/components/ui/collapsible'
import { RotateCw, Loader2, ChevronRight } from 'lucide-react'
import { cn } from '@multica/ui/lib/utils'
import type { SkillInfo, SkillSource } from '../stores/skills'
// Source section titles
const SOURCE_TITLES: Record<SkillSource, string> = {
bundled: 'Built-in Skills',
global: 'Global Skills',
profile: 'Profile Skills',
}
interface SkillListProps {
skills: SkillInfo[]
loading: boolean
error: string | null
onToggleSkill: (skillId: string) => Promise<void>
onRefresh: () => Promise<void>
}
export function SkillList({
skills,
loading,
error,
onRefresh,
}: SkillListProps) {
// Track which skills are expanded
const [expandedSkills, setExpandedSkills] = useState<Set<string>>(new Set())
const toggleSkill = (skillId: string) => {
setExpandedSkills((prev) => {
const next = new Set(prev)
if (next.has(skillId)) {
next.delete(skillId)
} else {
next.add(skillId)
}
return next
})
}
// Group skills by source
const skillsBySource: Record<SkillSource, SkillInfo[]> = {
bundled: skills.filter((s) => s.source === 'bundled'),
global: skills.filter((s) => s.source === 'global'),
profile: skills.filter((s) => s.source === 'profile'),
}
// Order of sources to display
const sourceOrder: SkillSource[] = ['bundled', 'global', 'profile']
if (loading && skills.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{skills.length} skill{skills.length !== 1 && 's'} available
</p>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={loading}
className="gap-1.5 h-8"
>
{loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RotateCw className="size-3.5" />
)}
Refresh
</Button>
</div>
{/* Error message */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Skills grouped by source */}
{sourceOrder.map((source) => {
const sourceSkills = skillsBySource[source]
if (sourceSkills.length === 0) return null
return (
<div key={source}>
{/* Section header */}
<h3 className="text-sm font-medium mb-3">{SOURCE_TITLES[source]}</h3>
{/* Skills card */}
<div className="rounded-lg border bg-card">
{sourceSkills.map((skill, index) => {
const isExpanded = expandedSkills.has(skill.id)
const isLast = index === sourceSkills.length - 1
return (
<Collapsible
key={skill.id}
open={isExpanded}
onOpenChange={() => toggleSkill(skill.id)}
>
<CollapsibleTrigger
className={cn(
'w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left',
!isLast && !isExpanded && 'border-b'
)}
>
<span className="text-sm font-medium flex-1">{skill.name}</span>
<code className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono">
{skill.triggers[0] || `/${skill.id}`}
</code>
<span className="text-xs text-muted-foreground">
{skill.version}
</span>
<ChevronRight
className={cn(
'size-4 text-muted-foreground transition-transform flex-shrink-0',
isExpanded && 'rotate-90'
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div
className={cn(
'text-sm text-muted-foreground p-4',
!isLast && 'border-b'
)}
>
{skill.description || 'No description'}
</div>
</CollapsibleContent>
</Collapsible>
)
})}
</div>
</div>
)
})}
{/* Empty state */}
{skills.length === 0 && !loading && (
<div className="text-center py-12 text-muted-foreground text-sm">
No skills found.
</div>
)}
</div>
)
}
export default SkillList

View file

@ -1,120 +0,0 @@
import { useState, useEffect } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { Loader2 } from 'lucide-react'
import { useQRToken, useCountdown } from './qr-hooks'
import { QRCodeFrame, ExpiryTimer } from './qr-code'
export interface TelegramConnectQRProps {
gateway: string
hubId: string
agentId: string
conversationId: string
expirySeconds?: number
size?: number
}
/**
* Telegram QR code for deep link connection flow.
*
* Generates a token, sends it to Gateway to create a short code,
* then renders a QR encoding https://t.me/{botUsername}?start={code}.
* Auto-refreshes when the token expires.
*/
export function TelegramConnectQR({
gateway,
hubId,
agentId,
conversationId,
expirySeconds = 30,
size = 200,
}: TelegramConnectQRProps) {
const { token, expiresAt, refresh } = useQRToken(agentId, conversationId, expirySeconds)
const remaining = useCountdown(expiresAt, refresh)
const [deepLink, setDeepLink] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
let cancelled = false
async function fetchCode() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${gateway}/telegram/connect-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gateway,
hubId,
agentId,
conversationId,
token,
expires: expiresAt,
}),
})
if (cancelled) return
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { message?: string } | null
const detail = body?.message || `HTTP ${res.status}`
setError(`Gateway error (${res.status}): ${detail}`)
setDeepLink(null)
return
}
const data = (await res.json()) as { code: string; botUsername: string }
if (cancelled) return
setDeepLink(`https://t.me/${data.botUsername}?start=${data.code}`)
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Failed to connect to Gateway')
setDeepLink(null)
} finally {
if (!cancelled) setLoading(false)
}
}
fetchCode()
return () => { cancelled = true }
}, [token, expiresAt, gateway, hubId, agentId, conversationId])
if (loading) {
return (
<div className="flex flex-col items-center gap-4 py-4">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center gap-2 py-4">
<p className="text-sm text-destructive">{error}</p>
</div>
)
}
if (!deepLink) return null
return (
<div className="flex flex-col items-center gap-4">
<QRCodeFrame>
<QRCodeSVG
value={deepLink}
size={size}
level="M"
marginSize={0}
bgColor="#ffffff"
fgColor="#0a0a0a"
/>
</QRCodeFrame>
<ExpiryTimer remaining={remaining} />
</div>
)
}

View file

@ -1,91 +0,0 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
resolvedTheme: "light" | "dark"
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
resolvedTheme: "light",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
function getSystemTheme(): "light" | "dark" {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
}
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "multica-theme",
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">(
() => (theme === "system" ? getSystemTheme() : theme)
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
const resolved = theme === "system" ? getSystemTheme() : theme
root.classList.add(resolved)
setResolvedTheme(resolved)
}, [theme])
// Listen for system theme changes
useEffect(() => {
if (theme !== "system") return
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
const handleChange = () => {
const resolved = getSystemTheme()
const root = window.document.documentElement
root.classList.remove("light", "dark")
root.classList.add(resolved)
setResolvedTheme(resolved)
}
mediaQuery.addEventListener("change", handleChange)
return () => mediaQuery.removeEventListener("change", handleChange)
}, [theme])
const value = {
theme,
resolvedTheme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View file

@ -1,30 +0,0 @@
import { Toaster as Sonner, type ToasterProps } from 'sonner'
import { CheckCircle, Info, AlertCircle, XCircle, Loader2 } from 'lucide-react'
import { useTheme } from './theme-provider'
export function Toaster(props: ToasterProps) {
const { resolvedTheme } = useTheme()
return (
<Sonner
theme={resolvedTheme as ToasterProps['theme']}
className="toaster group"
icons={{
success: <CheckCircle className="size-4 text-emerald-500" />,
info: <Info className="size-4 text-blue-500" />,
warning: <AlertCircle className="size-4 text-amber-500" />,
error: <XCircle className="size-4 text-red-500" />,
loading: <Loader2 className="size-4 text-muted-foreground animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
{...props}
/>
)
}

View file

@ -1,234 +0,0 @@
import { useState, useMemo } from 'react'
import { Switch } from '@multica/ui/components/ui/switch'
import { Button } from '@multica/ui/components/ui/button'
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from '@multica/ui/components/ui/collapsible'
import {
RotateCw,
FolderOpen,
Code,
Globe,
ChevronRight,
Loader2,
Clock,
Users,
} from 'lucide-react'
import { cn } from '@multica/ui/lib/utils'
import type { ToolInfo } from '../stores/tools'
// Group display names
const GROUP_NAMES: Record<string, string> = {
fs: 'File System',
runtime: 'Runtime',
web: 'Web',
subagent: 'Subagent',
cron: 'Cron',
other: 'Other',
}
// Group descriptions
const GROUP_DESCRIPTIONS: Record<string, string> = {
fs: 'Read, write, and manage files',
runtime: 'Execute code and commands',
web: 'Fetch and interact with web content',
subagent: 'Delegate tasks to sub-agents',
cron: 'Schedule recurring tasks',
other: 'Miscellaneous tools',
}
// Group icons
const GROUP_ICONS: Record<string, typeof FolderOpen> = {
fs: FolderOpen,
runtime: Code,
web: Globe,
subagent: Users,
cron: Clock,
other: Code,
}
interface ToolListProps {
tools: ToolInfo[]
loading: boolean
error: string | null
onToggleTool: (toolName: string) => Promise<void>
onRefresh: () => Promise<void>
}
export function ToolList({
tools,
loading,
error,
onToggleTool,
onRefresh,
}: ToolListProps) {
// Compute groups from tools
const groups = useMemo(() => {
const groupIds = [...new Set(tools.map((t) => t.group))]
return groupIds.map((id) => ({
id,
name: GROUP_NAMES[id] || id,
description: GROUP_DESCRIPTIONS[id] || '',
tools: tools.filter((t) => t.group === id),
enabledCount: tools.filter((t) => t.group === id && t.enabled).length,
totalCount: tools.filter((t) => t.group === id).length,
}))
}, [tools])
// Track which groups are expanded
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
() => new Set(groups.map((g) => g.id))
)
// Track toggling state for individual tools
const [togglingTools, setTogglingTools] = useState<Set<string>>(new Set())
const toggleGroup = (groupId: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev)
if (next.has(groupId)) {
next.delete(groupId)
} else {
next.add(groupId)
}
return next
})
}
const handleToggleTool = async (toolName: string) => {
setTogglingTools((prev) => new Set(prev).add(toolName))
try {
await onToggleTool(toolName)
} finally {
setTogglingTools((prev) => {
const next = new Set(prev)
next.delete(toolName)
return next
})
}
}
if (loading && tools.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{tools.filter((t) => t.enabled).length} of {tools.length} tools enabled
</p>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={loading}
className="gap-1.5 h-8"
>
{loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RotateCw className="size-3.5" />
)}
Refresh
</Button>
</div>
{/* Error message */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Tool groups */}
{groups.map((group) => {
const isExpanded = expandedGroups.has(group.id)
const GroupIcon = GROUP_ICONS[group.id] || Code
return (
<div key={group.id}>
{/* Section header */}
<div className="mb-3 flex items-start gap-2">
<GroupIcon className="size-4 text-muted-foreground mt-0.5" />
<div>
<h3 className="text-sm font-medium">{group.name}</h3>
<p className="text-xs text-muted-foreground">
{group.enabledCount}/{group.totalCount} enabled
</p>
</div>
</div>
{/* Tools card */}
<Collapsible open={isExpanded} onOpenChange={() => toggleGroup(group.id)}>
<div className="rounded-lg border bg-card">
<CollapsibleTrigger className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors text-left">
<span className="text-sm text-muted-foreground">
{group.description}
</span>
<ChevronRight
className={cn(
'size-4 text-muted-foreground transition-transform flex-shrink-0',
isExpanded && 'rotate-90'
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t">
{group.tools.map((tool, index) => {
const isToggling = togglingTools.has(tool.name)
const isLast = index === group.tools.length - 1
return (
<div
key={tool.name}
className={cn(
'flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors',
!isLast && 'border-b'
)}
>
<div className="min-w-0 flex-1">
<code className="text-sm font-mono">{tool.name}</code>
{tool.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{tool.description}
</p>
)}
</div>
<div className="flex items-center gap-2 ml-4">
{isToggling && (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
<Switch
checked={tool.enabled}
onCheckedChange={() => handleToggleTool(tool.name)}
disabled={isToggling}
/>
</div>
</div>
)
})}
</div>
</CollapsibleContent>
</div>
</Collapsible>
</div>
)
})}
{/* Note */}
<p className="text-xs text-muted-foreground">
Changes apply immediately to the running Agent.
</p>
</div>
)
}
export default ToolList

View file

@ -1,143 +0,0 @@
/**
* Update notification component
* Shows when a new version is available and allows user to download/install
*/
import { useState, useEffect } from 'react'
import {
Download,
Loader2,
CheckCircle,
AlertCircle,
X,
} from 'lucide-react'
import { Button } from '@multica/ui/components/ui/button'
interface UpdateInfo {
version: string
releaseDate?: string
releaseNotes?: string | null
}
interface UpdateProgress {
percent: number
bytesPerSecond: number
total: number
transferred: number
}
interface UpdateStatus {
status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'
info?: UpdateInfo
progress?: UpdateProgress
error?: string
}
export function UpdateNotification(): React.JSX.Element | null {
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
const unsubscribe = window.electronAPI.update.onStatus((status: UpdateStatus) => {
setUpdateStatus(status)
// Reset dismissed state when a new update becomes available
if (status.status === 'available') {
setDismissed(false)
}
})
return () => unsubscribe()
}, [])
const handleDownload = async (): Promise<void> => {
await window.electronAPI.update.download()
}
const handleInstall = (): void => {
window.electronAPI.update.install()
}
const handleDismiss = (): void => {
setDismissed(true)
}
// Don't show if dismissed or no relevant status
if (dismissed) return null
if (!updateStatus) return null
if (updateStatus.status === 'checking' || updateStatus.status === 'not-available') return null
const version = updateStatus.info?.version
const isError = updateStatus.status === 'error'
return (
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 fade-in duration-300">
<div className="flex items-center gap-3 rounded-lg border bg-card p-3 shadow-lg">
{/* Icon */}
<div
className={`flex h-8 w-8 items-center justify-center rounded-full ${isError ? 'bg-destructive/10' : 'bg-primary/10'}`}
>
{isError ? (
<AlertCircle className="h-4 w-4 text-destructive" />
) : updateStatus.status === 'downloaded' ? (
<CheckCircle className="h-4 w-4 text-primary" />
) : updateStatus.status === 'downloading' ? (
<Loader2 className="h-4 w-4 text-primary animate-spin" />
) : (
<Download className="h-4 w-4 text-primary" />
)}
</div>
{/* Content */}
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{isError
? 'Update failed'
: updateStatus.status === 'downloaded'
? 'Update ready'
: updateStatus.status === 'downloading'
? 'Downloading update...'
: 'Update available'}
</span>
<span className="text-xs text-muted-foreground">
{isError
? 'Please download manually from GitHub'
: updateStatus.status === 'downloading' && updateStatus.progress
? `${Math.round(updateStatus.progress.percent)}%`
: version
? `Version ${version}`
: 'New version available'}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-1 ml-2">
{updateStatus.status === 'available' && (
<Button size="sm" variant="default" onClick={handleDownload}>
Download
</Button>
)}
{updateStatus.status === 'downloaded' && (
<Button size="sm" variant="default" onClick={handleInstall}>
Restart
</Button>
)}
{isError && (
<Button
size="sm"
variant="outline"
onClick={() =>
window.open('https://github.com/multica-ai/super-multica/releases', '_blank')
}
>
View Releases
</Button>
)}
{updateStatus.status !== 'downloading' && (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={handleDismiss}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
)
}

View file

@ -1,105 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { toast } from '@multica/ui/components/ui/sonner'
// Minimum loading time for user perception (ms)
const MIN_LOADING_TIME = 600
export interface DeviceMeta {
userAgent?: string
platform?: string
language?: string
clientName?: string
}
export interface DeviceEntry {
deviceId: string
agentId: string
conversationIds: string[]
addedAt: number
meta?: DeviceMeta
}
export interface UseDevicesReturn {
devices: DeviceEntry[]
loading: boolean
refreshing: boolean
refresh: () => Promise<void>
revokeDevice: (deviceId: string) => Promise<boolean>
}
export function useDevices(): UseDevicesReturn {
const [devices, setDevices] = useState<DeviceEntry[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
// Initial fetch (silent, no toast)
const fetchDevices = useCallback(async () => {
try {
const list = await window.electronAPI?.hub.listDevices()
setDevices((list as DeviceEntry[]) ?? [])
} catch (err) {
console.error('Failed to load devices:', err)
} finally {
setLoading(false)
}
}, [])
// Manual refresh (with feedback)
const refresh = useCallback(async () => {
setRefreshing(true)
const startTime = Date.now()
try {
const list = await window.electronAPI?.hub.listDevices()
// Ensure minimum loading time for user perception
const elapsed = Date.now() - startTime
if (elapsed < MIN_LOADING_TIME) {
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
}
setDevices((list as DeviceEntry[]) ?? [])
toast.success('Device list refreshed')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error('Failed to refresh devices', { description: message })
console.error('Failed to refresh devices:', err)
} finally {
setRefreshing(false)
}
}, [])
const revokeDevice = useCallback(async (deviceId: string): Promise<boolean> => {
try {
const result = await window.electronAPI?.hub.revokeDevice(deviceId)
if (result?.ok) {
setDevices((prev) => prev.filter((d) => d.deviceId !== deviceId))
toast.success('Device removed')
return true
}
toast.error('Failed to remove device')
return false
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error('Failed to remove device', { description: message })
console.error('Failed to revoke device:', err)
return false
}
}, [])
useEffect(() => {
fetchDevices()
}, [fetchDevices])
// Subscribe to device list changes pushed from main process (silent refresh)
useEffect(() => {
window.electronAPI?.hub.onDevicesChanged(() => {
fetchDevices()
})
return () => {
window.electronAPI?.hub.offDevicesChanged()
}
}, [fetchDevices])
return { devices, loading, refreshing, refresh, revokeDevice }
}

View file

@ -1,371 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useChat, type MessageSource } from '@multica/hooks/use-chat'
import type {
StreamPayload,
ExecApprovalRequestPayload,
ApprovalDecision,
AgentMessageItem,
AgentErrorEvent,
} from '@multica/sdk'
import { DEFAULT_MESSAGES_LIMIT } from '@multica/sdk'
export interface QueuedLocalMessage {
id: string
text: string
createdAt: number
}
interface QueuedInboundMessage {
content: string
agentId: string
conversationId: string
source: MessageSource
}
interface UseLocalChatOptions {
conversationId?: string
}
interface LocalChatSubscribeResult {
ok?: boolean
error?: string
alreadySubscribed?: boolean
token?: number
isRunning?: boolean
}
interface LocalChatHistoryResult {
messages?: AgentMessageItem[]
total: number
offset: number
limit: number
contextWindowTokens?: number
isRunning?: boolean
}
function makeQueueId(): string {
return globalThis.crypto?.randomUUID?.() ?? `queued-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
export function useLocalChat(options: UseLocalChatOptions = {}) {
const requestedConversationId = options.conversationId
const chat = useChat()
const chatRef = useRef(chat)
chatRef.current = chat
const [agentId, setAgentId] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const isLoadingRef = useRef(false)
const [isLoadingHistory, setIsLoadingHistory] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const isLoadingMoreRef = useRef(false)
const [queuedMessages, setQueuedMessages] = useState<QueuedLocalMessage[]>([])
const [queuedInboundMessages, setQueuedInboundMessages] = useState<QueuedInboundMessage[]>([])
const [initError, setInitError] = useState<string | null>(null)
const initRef = useRef(false)
const offsetRef = useRef<number | null>(null)
const activeConversationId = requestedConversationId ?? agentId
// Initialize hub and get default agent ID
useEffect(() => {
if (initRef.current) return
initRef.current = true
window.electronAPI.hub.init()
.then((result) => {
const r = result as { defaultConversationId?: string }
const defaultConversationId = r.defaultConversationId
console.log('[LocalChat] hub.init → defaultConversationId:', defaultConversationId)
if (defaultConversationId) {
setAgentId(defaultConversationId)
} else if (requestedConversationId) {
setAgentId(requestedConversationId)
} else {
setInitError('No default agent available')
setIsLoadingHistory(false)
}
})
.catch((err: Error) => {
setInitError(err.message)
setIsLoadingHistory(false)
})
}, [requestedConversationId])
// Subscribe to events + fetch history once conversation is available
useEffect(() => {
if (!activeConversationId) return
let disposed = false
setQueuedMessages([])
setQueuedInboundMessages([])
offsetRef.current = null
setIsLoading(false)
setIsLoadingHistory(true)
chatRef.current.reset()
// Subscribe to agent events
const subscribePromise = window.electronAPI.localChat.subscribe(activeConversationId)
.then((result) => {
const typed = result as LocalChatSubscribeResult
if (!disposed && typed.isRunning) {
setIsLoading(true)
}
return typed
})
.catch(() => null)
// Listen for stream events
const unsubscribeEvent = window.electronAPI.localChat.onEvent((data) => {
// Cast IPC event to StreamPayload (same shape: { agentId, streamId, event })
const payload = data as unknown as StreamPayload
if (!payload.event) return
if (payload.conversationId !== activeConversationId) return
// Handle agent error events
if (payload.event.type === 'agent_error') {
const errorEvent = payload.event as AgentErrorEvent
chatRef.current.setError({ code: 'AGENT_ERROR', message: errorEvent.message })
setIsLoading(false)
return
}
chatRef.current.handleStream(payload)
if (payload.event.type === 'message_start') setIsLoading(true)
if (payload.event.type === 'tool_execution_start') setIsLoading(true)
if (payload.event.type === 'message_end') {
const stopReason =
'message' in payload.event
? (payload.event.message as { stopReason?: string } | undefined)?.stopReason
: undefined
// message_end with stopReason=toolUse is an intermediate step in the same run.
// Keep loading=true so queued user messages are not dispatched mid-run.
if (stopReason === 'toolUse') {
setIsLoading(true)
} else {
setIsLoading(false)
}
}
})
// Listen for exec approval requests
const unsubscribeApproval = window.electronAPI.localChat.onApproval((approval) => {
if (approval.conversationId !== activeConversationId) return
chatRef.current.addApproval(approval as ExecApprovalRequestPayload)
})
// Listen for inbound messages from all sources (gateway, channel)
// This allows the local UI to display messages from other sources
const unsubscribeInbound = window.electronAPI.hub.onInboundMessage((event: InboundMessageEvent) => {
const eventConversationId = event.conversationId
// Only add non-local messages (local messages are added by sendMessage)
if (event.source.type !== 'local' && eventConversationId === activeConversationId) {
const queuedInbound: QueuedInboundMessage = {
content: event.content,
agentId: event.agentId,
conversationId: eventConversationId,
source: event.source as MessageSource,
}
if (isLoadingRef.current) {
setQueuedInboundMessages((prev) => [...prev, queuedInbound])
return
}
chatRef.current.addUserMessage(
queuedInbound.content,
queuedInbound.agentId,
queuedInbound.conversationId,
queuedInbound.source,
)
setIsLoading(true)
}
})
// Fetch history with pagination
window.electronAPI.localChat.getHistory(activeConversationId, {
limit: DEFAULT_MESSAGES_LIMIT,
})
.then((result) => {
if (disposed) return
const typed = result as LocalChatHistoryResult
if (typed.isRunning) {
setIsLoading(true)
}
console.log('[LocalChat] getHistory result:', result.messages?.length, 'messages, total:', result.total)
if (result.messages?.length) {
chatRef.current.setHistory(result.messages as AgentMessageItem[], activeConversationId, activeConversationId, {
total: result.total,
offset: result.offset,
contextWindowTokens: result.contextWindowTokens,
})
offsetRef.current = result.offset
} else {
chatRef.current.setHistory([], activeConversationId, activeConversationId, {
total: 0,
offset: 0,
contextWindowTokens: result.contextWindowTokens,
})
}
})
.catch(() => {})
.finally(() => {
if (!disposed) {
setIsLoadingHistory(false)
}
})
return () => {
disposed = true
unsubscribeEvent?.()
unsubscribeApproval?.()
unsubscribeInbound?.()
void subscribePromise
.then((result) => {
if (typeof result?.token !== 'number') return
return window.electronAPI.localChat.unsubscribe(activeConversationId, result.token)
})
.catch(() => {})
}
}, [activeConversationId])
useEffect(() => {
isLoadingRef.current = isLoading
}, [isLoading])
const dispatchMessageNow = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed || !activeConversationId) return
chatRef.current.addUserMessage(trimmed, activeConversationId, activeConversationId, { type: 'local' })
chatRef.current.setError(null)
setIsLoading(true)
window.electronAPI.localChat.send(activeConversationId, trimmed)
.then((result) => {
const response = result as { ok?: boolean; error?: string } | undefined
if (response?.error) {
setIsLoading(false)
}
})
.catch(() => {
setIsLoading(false)
})
}, [activeConversationId])
const sendMessage = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed || !activeConversationId) return
if (isLoadingRef.current) {
setQueuedMessages((prev) => [
...prev,
{
id: makeQueueId(),
text: trimmed,
createdAt: Date.now(),
},
])
return
}
dispatchMessageNow(trimmed)
}, [activeConversationId, dispatchMessageNow])
const removeQueuedMessage = useCallback((id: string) => {
setQueuedMessages((prev) => prev.filter((item) => item.id !== id))
}, [])
const clearQueuedMessages = useCallback(() => {
setQueuedMessages([])
}, [])
useEffect(() => {
if (!activeConversationId || isLoading) return
// Inbound channel/gateway messages are already queued in backend.
// Render them first to keep frontend ordering aligned with agent run order.
const nextInbound = queuedInboundMessages[0]
if (nextInbound) {
setQueuedInboundMessages((prev) => prev.slice(1))
chatRef.current.addUserMessage(
nextInbound.content,
nextInbound.agentId,
nextInbound.conversationId,
nextInbound.source,
)
setIsLoading(true)
return
}
const nextLocal = queuedMessages[0]
if (!nextLocal) return
setQueuedMessages((prev) => prev.slice(1))
dispatchMessageNow(nextLocal.text)
}, [activeConversationId, isLoading, queuedInboundMessages, queuedMessages, dispatchMessageNow])
const abortGeneration = useCallback(() => {
if (!activeConversationId) return
window.electronAPI.localChat.abort(activeConversationId).catch(() => {})
setIsLoading(false)
}, [activeConversationId])
const loadMore = useCallback(async () => {
const currentOffset = offsetRef.current
if (!activeConversationId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return
isLoadingMoreRef.current = true
setIsLoadingMore(true)
try {
const newOffset = Math.max(0, currentOffset - DEFAULT_MESSAGES_LIMIT)
const limit = currentOffset - newOffset
const result = await window.electronAPI.localChat.getHistory(activeConversationId, {
offset: newOffset,
limit,
})
if (result.messages?.length) {
chatRef.current.prependHistory(result.messages as AgentMessageItem[], activeConversationId, activeConversationId, {
total: result.total,
offset: result.offset,
contextWindowTokens: result.contextWindowTokens,
})
offsetRef.current = result.offset
}
} catch {
// Best-effort — pagination failure does not block chat
} finally {
isLoadingMoreRef.current = false
setIsLoadingMore(false)
}
}, [activeConversationId])
const resolveApproval = useCallback(
(approvalId: string, decision: ApprovalDecision) => {
chatRef.current.removeApproval(approvalId)
window.electronAPI.localChat.resolveExecApproval(approvalId, decision).catch(() => {})
},
[],
)
const clearError = useCallback(() => {
chatRef.current.setError(null)
}, [])
return {
agentId,
conversationId: activeConversationId,
initError,
messages: chat.messages,
streamingIds: chat.streamingIds,
isLoading,
isLoadingHistory,
isLoadingMore,
hasMore: chat.hasMore,
contextWindowTokens: chat.contextWindowTokens,
error: chat.error,
pendingApprovals: chat.pendingApprovals,
queuedMessages,
sendMessage,
abortGeneration,
removeQueuedMessage,
clearQueuedMessages,
loadMore,
resolveApproval,
clearError,
}
}

View file

@ -1,27 +0,0 @@
export interface ParsedUA {
browser: string
os: string
}
export function parseUserAgent(ua: string): ParsedUA {
let os = 'Unknown'
if (/Mac OS X/.test(ua)) os = 'macOS'
else if (/Windows/.test(ua)) os = 'Windows'
else if (/Android/.test(ua)) os = 'Android'
else if (/iPhone|iPad/.test(ua)) os = 'iOS'
else if (/CrOS/.test(ua)) os = 'ChromeOS'
else if (/Linux/.test(ua)) os = 'Linux'
let browser = 'Unknown'
const edgeMatch = ua.match(/Edg\/(\d+)/)
const chromeMatch = ua.match(/Chrome\/(\d+)/)
const safariMatch = ua.match(/Version\/(\d+).*Safari/)
const firefoxMatch = ua.match(/Firefox\/(\d+)/)
if (edgeMatch) browser = `Edge ${edgeMatch[1]}`
else if (firefoxMatch) browser = `Firefox ${firefoxMatch[1]}`
else if (chromeMatch) browser = `Chrome ${chromeMatch[1]}`
else if (safariMatch) browser = `Safari ${safariMatch[1]}`
return { browser, os }
}

View file

@ -1,11 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import "@multica/ui/fonts"
import "@multica/ui/globals.css"
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View file

@ -1,408 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import {
MessageSquare,
Link2,
Loader2,
AlertCircle,
Pencil,
ChevronDown,
Check,
AlertTriangle,
} from 'lucide-react'
import { ConnectionQRCode } from '../components/qr-code'
import { DeviceList } from '../components/device-list'
import { AgentSettingsDialog } from '../components/agent-settings-dialog'
import { ApiKeyDialog } from '../components/api-key-dialog'
import { OAuthDialog } from '../components/oauth-dialog'
import { useHubStore } from '../stores/hub'
import { useProviderStore } from '../stores/provider'
export default function HomePage() {
const navigate = useNavigate()
const { hubInfo, agents, loading, error } = useHubStore()
const { providers, current, setProvider, refresh, loading: providerLoading } = useProviderStore()
const [settingsOpen, setSettingsOpen] = useState(false)
const [agentName, setAgentName] = useState<string | undefined>()
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false)
const [switching, setSwitching] = useState(false)
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
const [selectedProvider, setSelectedProvider] = useState<{
id: string
name: string
authMethod: 'api-key' | 'oauth'
loginCommand?: string
} | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setProviderDropdownOpen(false)
}
}
if (providerDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [providerDropdownOpen])
// Load agent profile info
useEffect(() => {
loadAgentInfo()
}, [])
// Reload agent info when settings dialog closes
useEffect(() => {
if (!settingsOpen) {
loadAgentInfo()
}
}, [settingsOpen])
const loadAgentInfo = async () => {
try {
const data = await window.electronAPI.profile.get()
setAgentName(data.name)
} catch (err) {
console.error('Failed to load agent info:', err)
}
}
// Get the first agent (or create one if none exists)
const primaryAgent = agents[0]
// Connection state indicator
// Note: 'registered' means fully connected and registered with Gateway
const connectionState = hubInfo?.connectionState ?? 'disconnected'
const isConnected = connectionState === 'connected' || connectionState === 'registered'
// Loading state
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span>Connecting to Hub...</span>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-destructive">
<AlertCircle className="size-8" />
<span className="font-medium">Connection Error</span>
<span className="text-sm text-muted-foreground">{error}</span>
</div>
</div>
)
}
return (
<div className="h-full flex flex-col">
{/* Main content - QR + Status */}
<div className="flex-1 flex gap-8 p-2">
{/* Left: QR Code */}
<div className="flex-1 flex flex-col items-center justify-center">
<ConnectionQRCode
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id}
conversationId={primaryAgent?.id}
expirySeconds={30}
size={180}
/>
</div>
{/* Right: Hub Status */}
<div className="flex-1 flex flex-col justify-center">
<div className="space-y-6">
{/* Hub Header */}
<div>
<div className="flex items-center gap-2 mb-1">
<span className="relative flex size-2.5">
{isConnected ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2.5 bg-green-500" />
</>
) : connectionState === 'connecting' || connectionState === 'reconnecting' ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2.5 bg-yellow-500" />
</>
) : (
<span className="relative inline-flex rounded-full size-2.5 bg-red-500" />
)}
</span>
<span className={`text-sm font-medium ${
isConnected
? 'text-green-600 dark:text-green-400'
: connectionState === 'connecting' || connectionState === 'reconnecting'
? 'text-yellow-600 dark:text-yellow-400'
: 'text-red-600 dark:text-red-400'
}`}>
{isConnected
? 'Hub Connected'
: connectionState === 'connecting'
? 'Connecting...'
: connectionState === 'reconnecting'
? 'Reconnecting...'
: 'Disconnected'}
</span>
</div>
<h2 className="text-2xl font-semibold tracking-tight">
Local Hub
</h2>
<p className="text-sm text-muted-foreground font-mono">
{hubInfo?.hubId ?? 'Initializing...'}
</p>
</div>
{/* Agent Settings */}
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Agent Settings
</p>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setSettingsOpen(true)}
>
<Pencil className="size-4" />
</Button>
</div>
<p className="font-medium">{agentName || 'Unnamed Agent'}</p>
</div>
{/* Provider Selector */}
<div className="p-4 rounded-lg bg-muted/50 border border-border/50 relative" ref={dropdownRef}>
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">
LLM Provider
</p>
<button
className="w-full flex items-center justify-between p-3 rounded-md bg-background border border-border hover:bg-accent/50 transition-colors disabled:opacity-50"
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
disabled={providerLoading || switching}
>
<div className="flex items-center gap-2">
{current?.available ? (
<Check className="size-4 text-green-500" />
) : (
<AlertTriangle className="size-4 text-yellow-500" />
)}
<div className="text-left">
<p className="font-medium text-sm">{current?.providerName ?? current?.provider ?? 'Loading...'}</p>
<p className="text-xs text-muted-foreground">{current?.model ?? '-'}</p>
</div>
</div>
<ChevronDown
className={`size-4 text-muted-foreground transition-transform ${providerDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Provider Dropdown - Compact Grid + Model List */}
{providerDropdownOpen && (
<div className="absolute left-0 right-0 top-full mt-1 z-10 bg-background border border-border rounded-md shadow-lg p-2 max-h-[60vh] overflow-y-auto">
<div className="grid grid-cols-3 gap-1.5">
{providers.map((p) => (
<button
key={p.id}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded text-left text-xs transition-colors ${
p.id === current?.provider
? 'bg-primary/10 border border-primary/30'
: 'hover:bg-accent/50 border border-transparent'
} ${!p.available ? 'opacity-60 hover:opacity-80' : ''}`}
onClick={async () => {
if (!p.available) {
// Show config dialog for unavailable providers
setSelectedProvider({
id: p.id,
name: p.name,
authMethod: p.authMethod,
loginCommand: p.loginCommand,
})
setProviderDropdownOpen(false)
if (p.authMethod === 'oauth') {
setOauthDialogOpen(true)
} else {
setApiKeyDialogOpen(true)
}
return
}
setSwitching(true)
setProviderDropdownOpen(false)
const result = await setProvider(p.id)
setSwitching(false)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
disabled={switching}
title={`${p.name}\n${p.authMethod === 'oauth' ? 'OAuth' : 'API Key'} · ${p.defaultModel}`}
>
<span className={`size-1.5 rounded-full flex-shrink-0 ${
p.available ? 'bg-green-500' : 'bg-muted-foreground/50'
}`} />
<span className="truncate font-medium">
{p.id === 'claude-code' ? 'Claude Code' :
p.id === 'openai-codex' ? 'Codex' :
p.id === 'kimi-coding' ? 'Kimi' :
p.id === 'anthropic' ? 'Anthropic' :
p.id === 'openai' ? 'OpenAI' :
p.id === 'openrouter' ? 'OpenRouter' :
p.name.split(' ')[0]}
</span>
</button>
))}
</div>
{/* Model List for current provider */}
{(() => {
const currentProvider = providers.find(p => p.id === current?.provider)
if (!currentProvider || currentProvider.models.length <= 1) return null
return (
<div className="border-t border-border mt-2 pt-2">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider px-1 mb-1">
Models {currentProvider.name}
</p>
<div className="space-y-0.5">
{currentProvider.models.map((model) => (
<button
key={model}
className={`w-full flex items-center gap-2 px-2 py-1 rounded text-left text-xs transition-colors ${
model === current?.model
? 'bg-primary/10 text-foreground'
: 'hover:bg-accent/50 text-muted-foreground'
}`}
onClick={async () => {
if (model === current?.model) return
setSwitching(true)
setProviderDropdownOpen(false)
const result = await setProvider(currentProvider.id, model)
setSwitching(false)
if (!result.ok) {
console.error('Failed to switch model:', result.error)
}
}}
disabled={switching}
>
<span className={`size-1.5 rounded-full flex-shrink-0 ${
model === current?.model ? 'bg-primary' : 'bg-muted-foreground/30'
}`} />
<span className="font-mono truncate">{model}</span>
</button>
))}
</div>
</div>
)
})()}
</div>
)}
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Gateway
</p>
<p className="font-medium text-sm truncate" title={hubInfo?.url}>
{hubInfo?.url ?? '-'}
</p>
</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Connection
</p>
<p className="font-medium capitalize">{connectionState}</p>
</div>
</div>
</div>
</div>
</div>
{/* Verified Devices */}
<div className="px-4 pb-2">
<DeviceList />
</div>
{/* Agent Settings Dialog */}
<AgentSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
{/* API Key Dialog */}
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
<ApiKeyDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
onSuccess={async () => {
// Refresh provider list and switch to the newly configured provider
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
{/* OAuth Dialog */}
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
<OAuthDialog
open={oauthDialogOpen}
onOpenChange={setOauthDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
loginCommand={selectedProvider.loginCommand}
onSuccess={async () => {
// Refresh provider list and switch to the newly configured provider
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
{/* Bottom: Actions */}
<div className="border-t p-4">
<div className="flex items-center justify-between">
{/* Primary Action: Chat */}
<Button
size="lg"
className="gap-2 px-6"
onClick={() => navigate('/chat')}
disabled={!isConnected}
>
<MessageSquare className="size-5" />
Open Chat
</Button>
{/* Secondary: Connect to Remote */}
<Button
variant="ghost"
size="sm"
className="text-muted-foreground gap-1.5"
>
<Link2 className="size-4" />
Connect to Remote Agent
</Button>
</div>
</div>
</div>
)
}

View file

@ -1,354 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@multica/ui/components/ui/button'
import { Input } from '@multica/ui/components/ui/input'
import { Textarea } from '@multica/ui/components/ui/textarea'
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from '@multica/ui/components/ui/combobox'
import {
Loader2,
Check,
AlertCircle,
ChevronDown,
} from 'lucide-react'
import { ApiKeyDialog } from '../../components/api-key-dialog'
import { OAuthDialog } from '../../components/oauth-dialog'
import { useProviderStore } from '../../stores/provider'
import { toast } from '@multica/ui/components/ui/sonner'
import { cn } from '@multica/ui/lib/utils'
export default function ProfilePage() {
const { providers, current, setProvider, refresh, loading: providerLoading } = useProviderStore()
const [profileLoading, setProfileLoading] = useState(true)
const [name, setName] = useState('')
const [userContent, setUserContent] = useState('')
const [hasChanges, setHasChanges] = useState(false)
const [originalName, setOriginalName] = useState('')
const [originalUserContent, setOriginalUserContent] = useState('')
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false)
const [switching, setSwitching] = useState(false)
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
const [selectedProvider, setSelectedProvider] = useState<{
id: string
name: string
authMethod: 'api-key' | 'oauth'
loginCommand?: string
} | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
loadProfile()
}, [])
useEffect(() => {
setHasChanges(name !== originalName || userContent !== originalUserContent)
}, [name, userContent, originalName, originalUserContent])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setProviderDropdownOpen(false)
}
}
if (providerDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [providerDropdownOpen])
const loadProfile = async () => {
setProfileLoading(true)
try {
const data = await window.electronAPI.profile.get()
const loadedName = data.name ?? ''
const loadedUserContent = data.userContent ?? ''
setName(loadedName)
setUserContent(loadedUserContent)
setOriginalName(loadedName)
setOriginalUserContent(loadedUserContent)
} catch (err) {
console.error('Failed to load profile:', err)
toast.error('Failed to load profile')
} finally {
setProfileLoading(false)
}
}
const handleSaveProfile = useCallback(async () => {
try {
await window.electronAPI.profile.updateName(name)
await window.electronAPI.profile.updateUser(userContent)
setOriginalName(name)
setOriginalUserContent(userContent)
setHasChanges(false)
toast.success('Profile saved')
} catch (err) {
console.error('Failed to save profile:', err)
toast.error('Failed to save profile')
}
}, [name, userContent])
// Keep ref to latest save function
const saveRef = useRef(handleSaveProfile)
saveRef.current = handleSaveProfile
// Show/hide persistent toast for unsaved changes
useEffect(() => {
const toastId = 'unsaved-changes'
if (hasChanges) {
toast('Unsaved changes', {
id: toastId,
duration: Infinity,
action: {
label: 'Save',
onClick: () => saveRef.current()
}
})
} else {
toast.dismiss(toastId)
}
}, [hasChanges])
const handleProviderClick = async (p: typeof providers[0]) => {
if (!p.available) {
setSelectedProvider({
id: p.id,
name: p.name,
authMethod: p.authMethod,
loginCommand: p.loginCommand,
})
setProviderDropdownOpen(false)
if (p.authMethod === 'oauth') {
setOauthDialogOpen(true)
} else {
setApiKeyDialogOpen(true)
}
return
}
setSwitching(true)
setProviderDropdownOpen(false)
const result = await setProvider(p.id)
setSwitching(false)
if (!result.ok) {
toast.error('Failed to switch provider')
}
}
const handleModelSelect = async (model: string) => {
if (!model || model === current?.model || !current?.provider) return
setSwitching(true)
const result = await setProvider(current.provider, model)
setSwitching(false)
if (!result.ok) {
toast.error('Failed to switch model')
}
}
if (profileLoading || providerLoading) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
const currentProvider = providers.find(p => p.id === current?.provider)
return (
<div className="h-full overflow-auto">
<div className="container p-6">
{/* Page Header */}
<div className="mb-8">
<h1 className="text-lg font-medium">Profile</h1>
<p className="text-sm text-muted-foreground">
Configure your agent's identity and the model that powers it.
</p>
</div>
{/* Model Section */}
<section className="mb-8">
<div className="mb-3">
<h2 className="text-sm font-medium">Model</h2>
<p className="text-xs text-muted-foreground">AI model for your agent</p>
</div>
<div className="rounded-lg border bg-card">
{/* Provider Row */}
<div className="flex items-center justify-between px-4 py-3 border-b" ref={dropdownRef}>
<div>
<div className="text-sm font-medium">Provider</div>
<div className="text-xs text-muted-foreground">LLM API connection</div>
</div>
<div className="relative">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5"
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
disabled={switching}
>
{current?.available ? (
<Check className="size-3 text-green-500" />
) : (
<AlertCircle className="size-3 text-yellow-500" />
)}
<span>{current?.providerName ?? 'Select'}</span>
<ChevronDown className={cn(
'size-3.5 text-muted-foreground transition-transform',
providerDropdownOpen && 'rotate-180'
)} />
</Button>
{providerDropdownOpen && (
<div className="absolute right-0 top-full mt-1 z-10 bg-popover border border-border rounded-lg shadow-lg p-1.5 min-w-[200px]">
{providers.map((p) => (
<button
key={p.id}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded text-left text-sm transition-colors',
p.id === current?.provider
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50',
!p.available && 'opacity-50'
)}
onClick={() => handleProviderClick(p)}
disabled={switching}
>
<span className={cn(
'size-2 rounded-full flex-shrink-0',
p.available ? 'bg-green-500' : 'bg-muted-foreground/40'
)} />
<span>{p.name}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Model Row - with Combobox */}
{currentProvider && (
<div className="flex items-center justify-between px-4 py-3">
<div>
<div className="text-sm font-medium">Model</div>
<div className="text-xs text-muted-foreground">Select or enter model ID</div>
</div>
<Combobox
items={currentProvider?.models ?? []}
value={current?.model ?? ''}
onValueChange={(value) => {
if (value) handleModelSelect(value)
}}
disabled={switching}
>
<ComboboxInput
placeholder="Select model"
className="w-48 h-8 text-sm font-mono"
/>
<ComboboxContent>
<ComboboxEmpty>No models found</ComboboxEmpty>
<ComboboxList>
{(model) => (
<ComboboxItem key={model} value={model}>
{model}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)}
</div>
{!current?.available && (
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
Select a provider and add your API key to enable your agent.
</p>
)}
</section>
{/* Identity Section */}
<section className="mb-8">
<div className="mb-3">
<h2 className="text-sm font-medium">Identity</h2>
<p className="text-xs text-muted-foreground">How your agent presents itself</p>
</div>
<div className="rounded-lg border bg-card">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex-1 mr-4">
<div className="text-sm font-medium mb-1">Agent Name</div>
<div className="text-xs text-muted-foreground">Personalize interactions</div>
</div>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Assistant"
className="h-8 w-48 text-sm"
/>
</div>
</div>
</section>
{/* Personalization Section */}
<section className="mb-8">
<div className="mb-3">
<h2 className="text-sm font-medium">Personalization</h2>
<p className="text-xs text-muted-foreground">Help the agent understand you better</p>
</div>
<Textarea
value={userContent}
onChange={(e) => setUserContent(e.target.value)}
placeholder="- I'm a frontend developer&#10;- I prefer TypeScript&#10;- Please respond in Chinese"
className="min-h-[120px] font-mono text-sm"
/>
</section>
</div>
{/* Dialogs */}
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
<ApiKeyDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
onSuccess={async () => {
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
<OAuthDialog
open={oauthDialogOpen}
onOpenChange={setOauthDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
loginCommand={selectedProvider.loginCommand}
onSuccess={async () => {
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
</div>
)
}

View file

@ -1,29 +0,0 @@
import { useSkillsStore } from '../../stores/skills'
import { SkillList } from '../../components/skill-list'
export default function SkillsPage() {
const { skills, loading, error, toggleSkill, refresh } = useSkillsStore()
return (
<div className="h-full overflow-auto">
<div className="container p-6">
{/* Page Header */}
<div className="mb-8">
<h1 className="text-lg font-medium">Skills</h1>
<p className="text-sm text-muted-foreground">
Skills are modular capabilities that expand what your agent can do.
</p>
</div>
{/* Content */}
<SkillList
skills={skills}
loading={loading}
error={error}
onToggleSkill={toggleSkill}
onRefresh={refresh}
/>
</div>
</div>
)
}

View file

@ -1,29 +0,0 @@
import { useToolsStore } from '../../stores/tools'
import { ToolList } from '../../components/tool-list'
export default function ToolsPage() {
const { tools, loading, error, toggleTool, refresh } = useToolsStore()
return (
<div className="h-full overflow-auto">
<div className="container p-6">
{/* Page Header */}
<div className="mb-8">
<h1 className="text-lg font-medium">Tools</h1>
<p className="text-sm text-muted-foreground">
Toggle tools to control what your agent can do.
</p>
</div>
{/* Content */}
<ToolList
tools={tools}
loading={loading}
error={error}
onToggleTool={toggleTool}
onRefresh={refresh}
/>
</div>
</div>
)
}

View file

@ -1,13 +0,0 @@
import { useSearchParams } from 'react-router-dom'
import { LocalChat } from '../components/local-chat'
export default function ChatPage() {
const [searchParams] = useSearchParams()
const initialPrompt = searchParams.get('prompt') ?? undefined
return (
<div className="h-full flex flex-col overflow-hidden">
<LocalChat initialPrompt={initialPrompt} />
</div>
)
}

View file

@ -1,111 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@multica/ui/components/ui/card'
import { WifiOff, Loader2 } from 'lucide-react'
import { useHubStore, selectPrimaryAgent } from '../stores/hub'
import { TelegramConnectQR } from '../components/telegram-qr'
import { DeviceList } from '../components/device-list'
function ChannelsContent() {
const { hubInfo, agents } = useHubStore()
const primaryAgent = selectPrimaryAgent(agents)
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Connect messaging platforms to chat with your agent.
</p>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Telegram</CardTitle>
<CardDescription>Scan with your phone camera to connect on Telegram.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex justify-center">
<TelegramConnectQR
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id ?? 'unknown'}
conversationId={primaryAgent?.id ?? 'unknown'}
expirySeconds={30}
size={200}
/>
</CardContent>
</Card>
<p className="text-sm text-muted-foreground">
Discord and Slack coming soon.
</p>
<Card>
<CardHeader>
<CardTitle className="text-base">Authorized Devices</CardTitle>
<CardDescription>Devices you've approved to access your agent.</CardDescription>
</CardHeader>
<CardContent>
<DeviceList />
</CardContent>
</Card>
</div>
)
}
/** Gateway status indicator - only shows when disconnected/error */
function GatewayStatus() {
const { hubInfo } = useHubStore()
const state = hubInfo?.connectionState ?? 'disconnected'
const url = hubInfo?.url ?? 'Unknown'
// Only show when not connected
const isConnected = state === 'connected' || state === 'registered'
if (isConnected) return null
const isConnecting = state === 'connecting' || state === 'reconnecting'
return (
<div className="flex items-center gap-2 text-sm rounded-md bg-destructive/10 text-destructive px-3 py-2">
{isConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<WifiOff className="size-4" />
)}
<span>
{state === 'connecting' && 'Connecting to gateway...'}
{state === 'reconnecting' && 'Reconnecting to gateway...'}
{state === 'disconnected' && 'Gateway disconnected'}
</span>
<span className="text-destructive/60 font-mono text-xs truncate max-w-[200px]" title={url}>
{url}
</span>
</div>
)
}
export default function ClientsPage() {
return (
<div className="h-full overflow-auto">
<div className="container flex flex-col p-6">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-lg font-medium">Clients</h1>
<p className="text-sm text-muted-foreground">
Access your agent from anywhere. Connect via third-party platforms.
</p>
<div className="mt-2">
<GatewayStatus />
</div>
</div>
<ChannelsContent />
</div>
</div>
)
}

View file

@ -1,32 +0,0 @@
import { useCronJobsStore } from '../stores/cron-jobs'
import { CronJobList } from '../components/cron-job-list'
export default function CronsPage() {
const { jobs, loading, error, toggleJob, removeJob, refresh } = useCronJobsStore()
return (
<div className="h-full overflow-auto">
<div className="container flex flex-col p-6">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-lg font-medium">Scheduled Tasks</h1>
<p className="text-sm text-muted-foreground">
Scheduled tasks run automatically at set times. Ask your agent to create one, like "remind me every morning" or "check my inbox daily."
</p>
</div>
{/* Configuration Area */}
<div className="flex-1 min-h-0">
<CronJobList
jobs={jobs}
loading={loading}
error={error}
onToggleJob={toggleJob}
onRemoveJob={removeJob}
onRefresh={refresh}
/>
</div>
</div>
</div>
)
}

View file

@ -1,100 +0,0 @@
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import {
Loader2,
ArrowRight,
Settings,
} from 'lucide-react'
import { useHubStore } from '../stores/hub'
import { useProviderStore } from '../stores/provider'
import { cn } from '@multica/ui/lib/utils'
export default function HomePage() {
const navigate = useNavigate()
const { loading } = useHubStore()
const { current, loading: providerLoading } = useProviderStore()
const isProviderAvailable = current?.available ?? false
const agentReady = !providerLoading && isProviderAvailable
if (loading || providerLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span>Starting agent...</span>
</div>
</div>
)
}
return (
<div className="h-full flex flex-col">
<div className="container shrink-0 px-6 pt-6">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-lg font-medium">Dashboard</h1>
<p className="text-sm text-muted-foreground">Overview of your agent's status.</p>
</div>
{/* Status Section */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-1 mt-4">
<span className="relative flex size-2">
{agentReady ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2 bg-green-500" />
</>
) : (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2 bg-yellow-500" />
</>
)}
</span>
<span className={cn(
'font-medium',
agentReady
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
)}>
{agentReady ? 'Your agent is running' : 'Configure LLM provider to start'}
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
{agentReady
? 'Ready to assist you. Start a conversation to get things done.'
: 'Go to Agent settings to configure your LLM provider.'}
</p>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="lg"
className="gap-2"
onClick={() => navigate('/chat')}
disabled={!agentReady}
>
Start Chat
<ArrowRight className="size-4" />
</Button>
{!agentReady && (
<Button
variant="ghost"
size="lg"
className="gap-2"
onClick={() => navigate('/agent/profile')}
>
<Settings className="size-4" />
Configure Agent
</Button>
)}
</div>
</div>
</div>
</div>
)
}

View file

@ -1,417 +0,0 @@
import { useState, useEffect } from 'react'
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { MulticaIcon } from '@multica/ui/components/multica-icon'
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from '@multica/ui/components/ui/collapsible'
import {
Home,
MessageSquare,
Users,
Clock,
Plus,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronsUpDown,
Bot,
LogOut,
} from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@multica/ui/components/ui/dropdown-menu'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from '@multica/ui/components/ui/sidebar'
import { cn } from '@multica/ui/lib/utils'
import { ModeToggle } from '../components/mode-toggle'
import { LocalChat } from '../components/local-chat'
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
import { UpdateNotification } from '../components/update-notification'
import { useAuthStore } from '../stores/auth'
import { useHubStore } from '../stores/hub'
const mainNavItems = [
{ path: '/', label: 'Home', icon: Home, exact: true },
{ path: '/chat', label: 'Chat', icon: MessageSquare },
]
const agentSubItems = [
{ path: '/agent/profile', label: 'Profile' },
{ path: '/agent/skills', label: 'Skills' },
{ path: '/agent/tools', label: 'Tools' },
]
const bottomNavItems = [
{ path: '/clients', label: 'Clients', icon: Users },
{ path: '/crons', label: 'Scheduled Tasks', icon: Clock },
]
// All nav items for header lookup
const allNavItems: Array<{ path: string; label: string; icon: typeof Home; exact?: boolean }> = [
...mainNavItems,
{ path: '/agent', label: 'Agent', icon: Bot },
...bottomNavItems,
]
function shortConversationId(id: string): string {
if (id.length <= 18) return id
return `${id.slice(0, 8)}...${id.slice(-8)}`
}
function NavigationButtons() {
const navigate = useNavigate()
useLocation()
const historyIdx = window.history.state?.idx ?? 0
const canGoBack = historyIdx > 0
const canGoForward = historyIdx < window.history.length - 1
return (
<div
className="flex items-center gap-0.5 ml-auto mr-2"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<Button
variant="ghost"
size="icon-sm"
onClick={() => navigate(-1)}
disabled={!canGoBack}
>
<ChevronLeft />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => navigate(1)}
disabled={!canGoForward}
>
<ChevronRight />
</Button>
</div>
)
}
function MainHeader() {
const { state, isMobile } = useSidebar()
const location = useLocation()
const needsTrafficLightSpace = state === 'collapsed' || isMobile
const currentPage = allNavItems.find((item) => {
if (item.exact) return location.pathname === item.path
return location.pathname.startsWith(item.path)
})
return (
<header
className="h-12 shrink-0 flex items-center px-4"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<div
className={cn(
'h-full shrink-0 transition-[width] duration-200 ease-linear',
needsTrafficLightSpace ? 'w-16' : 'w-0'
)}
/>
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<SidebarTrigger
className={cn(needsTrafficLightSpace && 'text-muted-foreground')}
/>
</div>
<div className="flex-1 flex justify-center">
{currentPage && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<currentPage.icon className="size-4" />
<span>{currentPage.label}</span>
</div>
)}
</div>
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<ModeToggle />
</div>
</header>
)
}
export default function Layout() {
const location = useLocation()
const navigate = useNavigate()
const isAgentActive = location.pathname.startsWith('/agent')
const isOnChat = location.pathname === '/chat'
const { user, clearAuth } = useAuthStore()
const { agents, refresh: refreshAgents, createConversation } = useHubStore()
const [isCreatingConversation, setIsCreatingConversation] = useState(false)
const [conversationError, setConversationError] = useState<string | null>(null)
const selectedConversationId = isOnChat
? new URLSearchParams(location.search).get('conversation') ?? undefined
: undefined
const activeConversationId = selectedConversationId ?? agents[0]?.id
// Lazy mount: only mount Chat on first visit, then keep it mounted forever
const [chatMounted, setChatMounted] = useState(false)
useEffect(() => {
if (isOnChat && !chatMounted) setChatMounted(true)
}, [isOnChat, chatMounted])
useEffect(() => {
if (!isOnChat) return
void refreshAgents()
}, [isOnChat, refreshAgents])
useEffect(() => {
if (!isOnChat || !selectedConversationId || agents.length === 0) return
const exists = agents.some((item) => item.id === selectedConversationId)
if (!exists) {
navigate('/chat', { replace: true })
}
}, [isOnChat, selectedConversationId, agents, navigate])
// Extract initialPrompt from URL search params when navigating to /chat?prompt=...
const initialPrompt = isOnChat
? new URLSearchParams(location.search).get('prompt') ?? undefined
: undefined
const handleLogout = async () => {
await clearAuth()
navigate('/login')
}
const openConversation = (id: string) => {
if (!id) return
navigate(`/chat?conversation=${encodeURIComponent(id)}`)
}
const handleCreateConversation = async () => {
setConversationError(null)
setIsCreatingConversation(true)
try {
const created = await createConversation()
if (!created?.id) {
setConversationError('Failed to create session')
return
}
openConversation(created.id)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setConversationError(message)
} finally {
setIsCreatingConversation(false)
}
}
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<SidebarProvider className="flex-1 overflow-hidden">
<Sidebar>
<SidebarHeader
className="h-12 shrink-0 flex items-center"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<NavigationButtons />
</SidebarHeader>
<SidebarContent>
<div className="flex items-center gap-2 px-3 py-2">
<MulticaIcon bordered noSpin />
<span className="text-sm font-brand">Multica</span>
</div>
<SidebarGroup>
<SidebarMenu className="space-y-0.5">
{/* Main nav items */}
{mainNavItems.map((item) => {
const isActive = item.exact
? location.pathname === item.path
: location.pathname.startsWith(item.path)
return (
<SidebarMenuItem key={item.path}>
<NavLink to={item.path}>
<SidebarMenuButton isActive={isActive}>
<item.icon
className={cn(
'size-4 transition-colors',
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
)}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
)
})}
{/* Agent collapsible */}
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger
render={<SidebarMenuButton isActive={isAgentActive} />}
>
<Bot
className={cn(
'size-4 transition-colors',
!isAgentActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
)}
/>
<span>Agent</span>
<ChevronDown className="ml-auto size-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{agentSubItems.map((item) => (
<SidebarMenuSubItem key={item.path}>
<SidebarMenuSubButton
render={<NavLink to={item.path} />}
isActive={location.pathname === item.path}
>
{item.label}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
{/* Bottom nav items */}
{bottomNavItems.map((item) => {
const isActive = location.pathname.startsWith(item.path)
return (
<SidebarMenuItem key={item.path}>
<NavLink to={item.path}>
<SidebarMenuButton isActive={isActive}>
<item.icon
className={cn(
'size-4 transition-colors',
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
)}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={<SidebarMenuButton size="lg" />}
>
<div className="size-8 rounded-lg bg-muted flex items-center justify-center text-sm font-medium">
{user?.name?.charAt(0)?.toUpperCase() || '?'}
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user?.name || 'User'}</span>
<span className="truncate text-xs text-muted-foreground">{user?.email || ''}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end">
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOut className="size-4" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<SidebarInset className="overflow-hidden">
<MainHeader />
<main className="flex-1 overflow-hidden min-h-1">
<div className={cn('h-full', isOnChat && 'hidden')}>
<Outlet />
</div>
{chatMounted && (
<div className={cn('h-full flex flex-col overflow-hidden', !isOnChat && 'hidden')}>
<div className="border-b px-4 py-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-muted-foreground">Session</span>
{activeConversationId ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" className="h-7 max-w-64 justify-start font-mono text-xs" />
}
>
{shortConversationId(activeConversationId)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-80">
{agents.length === 0 ? (
<DropdownMenuItem disabled>
No sessions available
</DropdownMenuItem>
) : (
agents.map((item) => (
<DropdownMenuItem key={item.id} onClick={() => openConversation(item.id)}>
<span className="font-mono text-xs truncate">{item.id}</span>
{item.id === activeConversationId && <span className="ml-auto text-xs">Current</span>}
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className="text-xs text-muted-foreground">Initializing...</span>
)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 shrink-0"
onClick={handleCreateConversation}
disabled={isCreatingConversation}
>
<Plus className="size-3.5" />
{isCreatingConversation ? 'Creating...' : 'New Session'}
</Button>
</div>
{conversationError && (
<div className="px-4 py-2 text-xs text-destructive border-b">
{conversationError}
</div>
)}
<LocalChat initialPrompt={initialPrompt} conversationId={activeConversationId} />
</div>
)}
</main>
</SidebarInset>
<DeviceConfirmDialog />
<UpdateNotification />
</SidebarProvider>
</div>
)
}

View file

@ -1,74 +0,0 @@
/**
* Login Page - Shown when user is not authenticated
*/
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { Loading } from '@multica/ui/components/ui/loading'
import { MulticaIcon } from '@multica/ui/components/multica-icon'
import { useAuthStore } from '../stores/auth'
export default function LoginPage() {
const navigate = useNavigate()
const { startLogin, isLoading, isAuthenticated } = useAuthStore()
// Redirect to home when authenticated
useEffect(() => {
if (isAuthenticated) {
console.log('[LoginPage] Authenticated, redirecting to home...')
navigate('/', { replace: true })
}
}, [isAuthenticated, navigate])
if (isLoading) {
return (
<div className="flex h-screen flex-col bg-background">
<header
className="shrink-0 h-12"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<div className="flex flex-1 items-center justify-center">
<Loading className="size-6" />
</div>
</div>
)
}
return (
<div className="flex h-screen flex-col bg-background">
<header
className="shrink-0 h-12"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<div className="flex flex-1 flex-col items-center justify-center p-8 animate-in fade-in duration-300">
<div className="w-full max-w-sm flex flex-col items-center text-center space-y-6">
{/* Brand */}
<div className="flex items-center gap-2">
<MulticaIcon bordered animate size="md" />
<h1 className="text-lg tracking-wide font-brand">Multica</h1>
</div>
{/* Tagline */}
<p className="text-sm text-muted-foreground leading-relaxed">
An AI assistant that gets things done.
</p>
{/* Sign In */}
<Button
onClick={startLogin}
size="lg"
className="px-8"
>
Sign In to Continue
</Button>
{/* Helper */}
<p className="text-xs text-muted-foreground/60">
Opens browser for authentication
</p>
</div>
</div>
</div>
)
}

View file

@ -1,174 +0,0 @@
import { useEffect, useState, useCallback } from 'react'
import { Button } from '@multica/ui/components/ui/button'
import { Separator } from '@multica/ui/components/ui/separator'
import { ChevronLeft, Info, Check, Smartphone } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@multica/ui/components/ui/alert-dialog'
import { useHubStore, selectPrimaryAgent } from '../../../stores/hub'
import { TelegramConnectQR } from '../../../components/telegram-qr'
import { StepDots } from './step-dots'
interface DeviceMeta {
userAgent?: string
platform?: string
language?: string
clientName?: string
}
interface PendingConfirm {
deviceId: string
agentId: string
conversationId: string
meta?: DeviceMeta
}
interface ConnectStepProps {
onNext: () => void
onBack: () => void
}
export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
const { hubInfo, agents } = useHubStore()
const primaryAgent = selectPrimaryAgent(agents)
const [connected, setConnected] = useState(false)
const [connectedDevice, setConnectedDevice] = useState<string | null>(null)
const [pending, setPending] = useState<PendingConfirm | null>(null)
// Listen for device confirm requests during onboarding
useEffect(() => {
window.electronAPI?.hub.onDeviceConfirmRequest(
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => {
setPending({ deviceId, agentId, conversationId, meta })
},
)
return () => {
window.electronAPI?.hub.offDeviceConfirmRequest()
}
}, [])
const handleAllow = useCallback(() => {
if (!pending) return
window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, true)
setConnectedDevice(pending.meta?.clientName ?? pending.deviceId)
setPending(null)
setConnected(true)
}, [pending])
const handleReject = useCallback(() => {
if (!pending) return
window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, false)
setPending(null)
}, [pending])
const deviceLabel = pending?.meta?.clientName ?? pending?.deviceId
return (
<div className="h-full flex items-center justify-center px-6 py-8 animate-in fade-in duration-300">
<div className="w-full max-w-md space-y-6">
{/* Back button */}
<button
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-4" />
Back
</button>
{/* Header */}
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">
Connect Telegram
</h1>
<p className="text-sm text-muted-foreground">
Scan the QR code with your phone camera to connect on Telegram.
</p>
</div>
{/* Info box */}
<div className="rounded-lg bg-muted/50 px-4 py-3 space-y-2">
<p className="text-sm text-muted-foreground">
Chat with your agent from your phone via Telegram.
Your messages are routed through the Gateway to this machine.
</p>
<p className="text-xs text-muted-foreground/70 flex items-center gap-1.5">
<Info className="size-3.5 shrink-0" />
Discord, Slack, and more coming soon.
</p>
</div>
{/* QR code or connected state */}
<div className="flex justify-center py-2">
{connected ? (
<div className="flex flex-col items-center gap-3 py-6">
<div className="size-12 rounded-full bg-green-500/10 flex items-center justify-center">
<Check className="size-6 text-green-500" />
</div>
<p className="text-sm font-medium">Telegram connected</p>
{connectedDevice && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded-lg px-3 py-2">
<Smartphone className="size-3.5 shrink-0" />
<span>{connectedDevice}</span>
</div>
)}
</div>
) : (
<TelegramConnectQR
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id ?? 'unknown'}
conversationId={primaryAgent?.id ?? 'unknown'}
expirySeconds={30}
size={180}
/>
)}
</div>
<Separator />
{/* Footer */}
<div className="flex items-center justify-between">
<StepDots />
<div className="flex gap-2">
{!connected && (
<Button size="sm" variant="outline" onClick={onNext}>
Skip
</Button>
)}
<Button size="sm" onClick={onNext}>
Continue
</Button>
</div>
</div>
</div>
{/* Device confirm dialog */}
<AlertDialog open={pending !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>New Device Connection</AlertDialogTitle>
<AlertDialogDescription>
<span className="font-medium">{deviceLabel}</span> wants to connect.
<span className="block mt-1">Allow this device?</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleReject}>
Reject
</AlertDialogCancel>
<AlertDialogAction onClick={handleAllow}>
Allow
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show more