├── frontend ├── src │ ├── vite-env.d.ts │ ├── types │ │ ├── enterBehavior.ts │ │ └── window.d.ts │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ ├── hooks │ │ ├── useClaudeStreaming.ts │ │ ├── useEnterBehavior.ts │ │ ├── useTheme.ts │ │ ├── streaming │ │ │ ├── useMessageProcessor.ts │ │ │ └── useToolHandling.ts │ │ ├── chat │ │ │ ├── useAbortController.ts │ │ │ └── usePermissions.ts │ │ └── useMessageConverter.ts │ ├── contexts │ │ ├── EnterBehaviorContextDefinition.ts │ │ └── EnterBehaviorContext.tsx │ ├── utils │ │ ├── pathUtils.ts │ │ ├── messageTypes.ts │ │ ├── time.ts │ │ ├── constants.ts │ │ ├── streamingDebug.ts │ │ └── id.ts │ ├── App.tsx │ ├── components │ │ ├── chat │ │ │ ├── HistoryButton.tsx │ │ │ ├── EnterBehaviorToggle.tsx │ │ │ ├── ThemeToggle.tsx │ │ │ ├── AgentSelector.tsx │ │ │ ├── EnterModeMenu.tsx │ │ │ └── ChatMessages.tsx │ │ ├── messages │ │ │ ├── MessageContainer.tsx │ │ │ └── CollapsibleDetails.tsx │ │ ├── TimestampComponent.tsx │ │ └── native │ │ │ ├── MessageBubble.tsx │ │ │ └── ChatHeader.tsx │ ├── test-setup.ts │ ├── config │ │ ├── agents.ts │ │ └── api.ts │ ├── App.test.tsx │ └── types.ts ├── openmemory.sqlite ├── tsconfig.json ├── .gitignore ├── tsconfig.node.json ├── tsconfig.app.json ├── eslint.config.js ├── index.html ├── vite.config.ts ├── tailwind.config.js ├── package.json ├── playwright.config.ts ├── README.md └── scripts │ └── README.md ├── .github ├── release.yml ├── workflows │ ├── tagpr.yml │ ├── ci.yml │ ├── claude.yml │ └── claude-code-review.yml └── pull_request_template.md ├── backend ├── .prettierignore ├── openmemory.sqlite ├── vitest.config.ts ├── types.ts ├── .samignore ├── .gitignore ├── eslint.config.js ├── middleware │ └── config.ts ├── tsconfig.json ├── scripts │ ├── generate-version.ts │ ├── generate-version.js │ ├── copy-frontend.js │ ├── copy-frontend.ts │ ├── build-bundle.js │ ├── prepack.js │ ├── start-with-preload.js │ └── dev-with-preload.js ├── LICENSE ├── samconfig.toml ├── deno.json ├── handlers │ ├── abort.ts │ ├── projects.ts │ ├── agentProjects.ts │ ├── conversations.ts │ ├── agentConversations.ts │ └── histories.ts ├── cli │ ├── deno.ts │ ├── node.ts │ └── args.ts ├── lambda.ts ├── runtime │ └── types.ts ├── test-runner.js ├── tests │ └── node │ │ └── runtime.test.ts ├── providers │ └── types.ts ├── history │ ├── pathUtils.ts │ ├── grouping.ts │ ├── timestampRestore.ts │ └── conversationLoader.ts ├── package.json ├── pathUtils.test.ts ├── DEPLOYMENT.md └── template.yml ├── .DS_Store ├── assets ├── icon.ico ├── icon.png ├── icon.icns ├── orange_users_icon.png ├── placeholder-icon.png ├── entitlements.mac.plist ├── placeholder-icon.svg └── create-icon.sh ├── openmemory.sqlite ├── ClaudeAgentHub ├── .DS_Store ├── ClaudeAgentHub │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── ClaudeAgentHubApp.swift │ ├── ClaudeAgentHub.entitlements │ ├── Models │ │ ├── Agent.swift │ │ └── Project.swift │ ├── Views │ │ └── ContentView.swift │ └── Services │ │ └── HistoryService.swift └── ClaudeAgentHub.xcodeproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcuserdata │ │ └── buryhuang.xcuserdatad │ │ └── UserInterfaceState.xcuserstate │ └── xcuserdata │ └── buryhuang.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── .gitignore ├── .tagpr ├── .lefthook.yml ├── .env ├── LICENSE ├── .env.example ├── database.js ├── shared └── types.ts ├── scripts └── build-windows.sh ├── package.json ├── electron └── preload.js └── Makefile /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - tagpr 5 | -------------------------------------------------------------------------------- /backend/.prettierignore: -------------------------------------------------------------------------------- 1 | # Deno-specific files 2 | cli/deno.ts 3 | runtime/deno.ts -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baryhuang/claude-code-by-agents/HEAD/.DS_Store -------------------------------------------------------------------------------- /frontend/src/types/enterBehavior.ts: -------------------------------------------------------------------------------- 1 | export type EnterBehavior = "send" | "newline"; 2 | -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baryhuang/claude-code-by-agents/HEAD/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baryhuang/claude-code-by-agents/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baryhuang/claude-code-by-agents/HEAD/assets/icon.icns -------------------------------------------------------------------------------- /openmemory.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baryhuang/claude-code-by-agents/HEAD/openmemory.sqlite -------------------------------------------------------------------------------- /ClaudeAgentHub/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baryhuang/claude-code-by-agents/HEAD/ClaudeAgentHub/.DS_Store -------------------------------------------------------------------------------- /backend/openmemory.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baryhuang/claude-code-by-agents/HEAD/backend/openmemory.sqlite -------------------------------------------------------------------------------- /assets/orange_users_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baryhuang/claude-code-by-agents/HEAD/assets/orange_users_icon.png -------------------------------------------------------------------------------- /assets/placeholder-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baryhuang/claude-code-by-agents/HEAD/assets/placeholder-icon.png -------------------------------------------------------------------------------- /frontend/openmemory.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baryhuang/claude-code-by-agents/HEAD/frontend/openmemory.sqlite -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } -------------------------------------------------------------------------------- /backend/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: ["**/node_modules/**", "**/dist/**"], 6 | testTimeout: 30000, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub/ClaudeAgentHubApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct ClaudeAgentHubApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView() 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./App.tsx"; 5 | 6 | createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub.xcodeproj/project.xcworkspace/xcuserdata/buryhuang.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baryhuang/claude-code-by-agents/HEAD/ClaudeAgentHub/ClaudeAgentHub.xcodeproj/project.xcworkspace/xcuserdata/buryhuang.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .claude/ 2 | .env 3 | 4 | # Build artifacts 5 | dist/ 6 | backend/dist/ 7 | 8 | # Demo recordings and Playwright artifacts 9 | frontend/demo-recordings/ 10 | frontend/test-results/ 11 | frontend/playwright-report/ 12 | frontend/playwright/.cache/ 13 | 14 | node_modules/ 15 | 16 | # sam build artifacts 17 | **/bin 18 | .aws-sam/ -------------------------------------------------------------------------------- /frontend/src/hooks/useClaudeStreaming.ts: -------------------------------------------------------------------------------- 1 | // Simplified useClaudeStreaming hook that uses the new modular hooks 2 | import { useStreamParser } from "./streaming/useStreamParser"; 3 | 4 | export function useClaudeStreaming() { 5 | const { processStreamLine } = useStreamParser(); 6 | 7 | return { 8 | processStreamLine, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub/ClaudeAgentHub.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env -------------------------------------------------------------------------------- /frontend/src/contexts/EnterBehaviorContextDefinition.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import type { EnterBehavior } from "../types/enterBehavior"; 3 | 4 | interface EnterBehaviorContextType { 5 | enterBehavior: EnterBehavior; 6 | toggleEnterBehavior: () => void; 7 | } 8 | 9 | export const EnterBehaviorContext = createContext< 10 | EnterBehaviorContextType | undefined 11 | >(undefined); 12 | -------------------------------------------------------------------------------- /backend/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Backend-specific type definitions 3 | */ 4 | 5 | import type { Runtime } from "./runtime/types.ts"; 6 | 7 | // Application configuration shared across backend handlers 8 | export interface AppConfig { 9 | debugMode: boolean; 10 | runtime: Runtime; 11 | claudePath: string; // Now required since validateClaudeCli always returns a path 12 | // Future configuration options can be added here 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/utils/pathUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Windows path normalization utilities 3 | */ 4 | 5 | /** 6 | * Normalize Windows paths for cross-platform compatibility 7 | * - Remove leading slash from Windows absolute paths like /C:/... 8 | * - Convert backslashes to forward slashes 9 | */ 10 | export function normalizeWindowsPath(path: string): string { 11 | return path.replace(/^\/([A-Za-z]:)/, "$1").replace(/\\/g, "/"); 12 | } 13 | -------------------------------------------------------------------------------- /.tagpr: -------------------------------------------------------------------------------- 1 | # tagpr configuration for automated release PR generation 2 | 3 | [tagpr] 4 | # Branch to track for releases 5 | releaseBranch = main 6 | 7 | # Enable changelog generation and updates 8 | changelog = true 9 | 10 | # Use "v" prefix for tags (v1.0.0, v1.0.1, etc.) 11 | vPrefix = false 12 | 13 | # Create GitHub Releases automatically 14 | release = true 15 | 16 | # Version file location - now using package.json 17 | versionFile = backend/package.json 18 | -------------------------------------------------------------------------------- /.github/workflows/tagpr.yml: -------------------------------------------------------------------------------- 1 | name: tagpr 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | jobs: 8 | tagpr: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | actions: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | token: ${{ secrets.GH_PAT }} 18 | - uses: Songmu/tagpr@v1 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 21 | -------------------------------------------------------------------------------- /frontend/src/hooks/useEnterBehavior.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { EnterBehaviorContext } from "../contexts/EnterBehaviorContextDefinition"; 3 | export type { EnterBehavior } from "../types/enterBehavior"; 4 | 5 | export function useEnterBehavior() { 6 | const context = useContext(EnterBehaviorContext); 7 | if (!context) { 8 | throw new Error( 9 | "useEnterBehavior must be used within an EnterBehaviorProvider", 10 | ); 11 | } 12 | return context; 13 | } 14 | -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub.xcodeproj/xcuserdata/buryhuang.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | ClaudeAgentHub.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { HashRouter as Router, Routes, Route } from "react-router-dom"; 2 | import { AgentHubPage } from "./components/native/AgentHubPage"; 3 | import { EnterBehaviorProvider } from "./contexts/EnterBehaviorContext"; 4 | 5 | function App() { 6 | return ( 7 | 8 | 9 | 10 | } /> 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | export default App; 18 | -------------------------------------------------------------------------------- /.lefthook.yml: -------------------------------------------------------------------------------- 1 | # Lefthook configuration for code quality enforcement 2 | # https://github.com/evilmartians/lefthook 3 | 4 | pre-commit: 5 | commands: 6 | quality-check: 7 | # Run all quality checks before commit 8 | run: make check 9 | fail_text: | 10 | ❌ Quality checks failed! 11 | 12 | To fix issues: 13 | • Run 'make format' to fix formatting 14 | • Fix any lint errors shown above 15 | • Fix any TypeScript type errors 16 | • Ensure all tests pass 17 | 18 | Then try committing again. 19 | -------------------------------------------------------------------------------- /backend/.samignore: -------------------------------------------------------------------------------- 1 | # SAM ignore file - exclude files from deployment package 2 | 3 | # Source files (only include built dist/) 4 | *.ts 5 | !dist/ 6 | cli/ 7 | handlers/ 8 | history/ 9 | middleware/ 10 | runtime/ 11 | scripts/ 12 | tests/ 13 | 14 | # Development and build files 15 | node_modules/ 16 | .env 17 | .env.* 18 | *.log 19 | *.test.* 20 | vitest.config.* 21 | eslint.config.* 22 | tsconfig.json 23 | deno.json 24 | deno.lock 25 | 26 | # Documentation and config 27 | README.md 28 | CHANGELOG.md 29 | .gitignore 30 | .prettierrc 31 | .github/ 32 | 33 | # SAM build artifacts 34 | .aws-sam/ -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Environment files 27 | .env 28 | .env.local 29 | 30 | # TypeScript 31 | tsconfig.tsbuildinfo 32 | 33 | # Auto-generated files 34 | cli/version.ts 35 | 36 | # Frontend build output (copied for testing) 37 | static/ -------------------------------------------------------------------------------- /backend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import tseslint from "@typescript-eslint/eslint-plugin"; 2 | import tsparser from "@typescript-eslint/parser"; 3 | 4 | export default [ 5 | { 6 | files: ["**/*.ts"], 7 | ignores: ["cli/deno.ts", "runtime/deno.ts", "dist/**"], 8 | languageOptions: { 9 | parser: tsparser, 10 | }, 11 | plugins: { 12 | "@typescript-eslint": tseslint, 13 | }, 14 | rules: { 15 | "@typescript-eslint/no-unused-vars": [ 16 | "error", 17 | { 18 | argsIgnorePattern: "^_", 19 | varsIgnorePattern: "^_", 20 | }, 21 | ], 22 | }, 23 | }, 24 | ]; 25 | -------------------------------------------------------------------------------- /frontend/src/components/chat/HistoryButton.tsx: -------------------------------------------------------------------------------- 1 | import { ClockIcon } from "@heroicons/react/24/outline"; 2 | 3 | interface HistoryButtonProps { 4 | onClick: () => void; 5 | } 6 | 7 | export function HistoryButton({ onClick }: HistoryButtonProps) { 8 | return ( 9 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /assets/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.disable-library-validation 10 | 11 | com.apple.security.network.client 12 | 13 | com.apple.security.network.server 14 | 15 | com.apple.security.files.user-selected.read-write 16 | 17 | com.apple.security.files.bookmarks.app-scope 18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/src/components/messages/MessageContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface MessageContainerProps { 4 | alignment: "left" | "right" | "center"; 5 | colorScheme: string; 6 | children: React.ReactNode; 7 | } 8 | 9 | export function MessageContainer({ 10 | alignment, 11 | colorScheme, 12 | children, 13 | }: MessageContainerProps) { 14 | const justifyClass = 15 | alignment === "right" 16 | ? "justify-end" 17 | : alignment === "center" 18 | ? "justify-center" 19 | : "justify-start"; 20 | 21 | return ( 22 |
23 |
26 | {children} 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | 3 | // Mock window.matchMedia for tests 4 | Object.defineProperty(window, "matchMedia", { 5 | writable: true, 6 | value: (query: string) => ({ 7 | matches: false, // Default to light mode for tests 8 | media: query, 9 | onchange: null, 10 | addListener: () => {}, // deprecated 11 | removeListener: () => {}, // deprecated 12 | addEventListener: () => {}, 13 | removeEventListener: () => {}, 14 | dispatchEvent: () => {}, 15 | }), 16 | }); 17 | 18 | // Mock localStorage for tests 19 | const localStorageMock = { 20 | getItem: () => null, 21 | setItem: () => {}, 22 | removeItem: () => {}, 23 | clear: () => {}, 24 | length: 0, 25 | key: () => null, 26 | }; 27 | 28 | Object.defineProperty(window, "localStorage", { 29 | value: localStorageMock, 30 | }); 31 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "erasableSyntaxOnly": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true, 25 | 26 | /* Path mapping */ 27 | "baseUrl": ".", 28 | "paths": { 29 | "@shared/*": ["../shared/*"] 30 | } 31 | }, 32 | "include": ["src"] 33 | } 34 | -------------------------------------------------------------------------------- /backend/middleware/config.ts: -------------------------------------------------------------------------------- 1 | import { createMiddleware } from "hono/factory"; 2 | import type { AppConfig } from "../types.ts"; 3 | 4 | /** 5 | * Creates configuration middleware that makes app-wide settings available to all handlers 6 | * via context variables. This eliminates the need to pass configuration parameters 7 | * to individual handler functions. 8 | * 9 | * @param options Configuration options 10 | * @returns Hono middleware function 11 | */ 12 | export function createConfigMiddleware(options: AppConfig) { 13 | return createMiddleware<{ 14 | Variables: { 15 | config: AppConfig; 16 | }; 17 | }>(async (c, next) => { 18 | // Set configuration in context for access by handlers 19 | c.set("config", options); 20 | 21 | await next(); 22 | }); 23 | } 24 | 25 | /** 26 | * Type helper to ensure handlers can access the config variable 27 | * This can be used to extend the context type in handlers if needed 28 | */ 29 | export type ConfigContext = { 30 | Variables: { 31 | config: AppConfig; 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Server Configuration 2 | # Port for the backend server (default: 8080) 3 | # This port is used by both backend and frontend development servers 4 | PORT=8080 5 | 6 | # API Configuration 7 | # Set to "true" to use local backend during development, otherwise uses orchestrator agent's API endpoint 8 | VITE_USE_LOCAL_API=false 9 | 10 | # Anthropic API Configuration 11 | # API key for direct Anthropic API access (used by group chat agent) 12 | # Get your API key from: https://console.anthropic.com/ 13 | ANTHROPIC_API_KEY=your-api-key-here 14 | 15 | # Usage Examples: 16 | # 1. Use orchestrator agent's API endpoint (default): 17 | # VITE_USE_LOCAL_API=false 18 | # Configure API endpoint in Settings > Agents > Orchestrator Agent 19 | # 20 | # 2. Use local backend for development: 21 | # VITE_USE_LOCAL_API=true 22 | # PORT=9000 npm run dev (for frontend) 23 | # PORT=9000 deno task dev (for backend) 24 | # 25 | # The frontend will use the orchestrator agent's API endpoint by default 26 | # Set VITE_USE_LOCAL_API=true to override and use local backend via Vite proxy -------------------------------------------------------------------------------- /frontend/src/components/chat/EnterBehaviorToggle.tsx: -------------------------------------------------------------------------------- 1 | import { CommandLineIcon } from "@heroicons/react/24/outline"; 2 | import { useEnterBehavior } from "../../hooks/useEnterBehavior"; 3 | 4 | export function EnterBehaviorToggle() { 5 | const { enterBehavior, toggleEnterBehavior } = useEnterBehavior(); 6 | 7 | return ( 8 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "allowSyntheticDefaultImports": true, 13 | "allowImportingTsExtensions": true, 14 | "types": ["node", "vitest/globals"], 15 | "lib": ["ES2022", "DOM"], 16 | "noEmit": true 17 | }, 18 | "include": [ 19 | "../shared/**/*.ts", 20 | "cli/node.ts", 21 | "runtime/node.ts", 22 | "app.ts", 23 | "cli/args.ts", 24 | "cli/validation.ts", 25 | "handlers/**/*.ts", 26 | "history/**/*.ts", 27 | "middleware/**/*.ts", 28 | "runtime/types.ts", 29 | "types.ts" 30 | ], 31 | "exclude": [ 32 | "node_modules", 33 | "dist", 34 | "deno.json", 35 | "deno.lock", 36 | "runtime/deno.ts", 37 | "cli/deno.ts", 38 | "pathUtils.test.ts", 39 | "tests/**/*.ts" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist"] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ["**/*.{ts,tsx}"], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | "react-hooks": reactHooks, 18 | "react-refresh": reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | "react-refresh/only-export-components": [ 23 | "warn", 24 | { allowConstantExport: true }, 25 | ], 26 | // Temporarily disable strict rules for CI 27 | "@typescript-eslint/no-explicit-any": "warn", 28 | "@typescript-eslint/no-unused-vars": "warn", 29 | "react-hooks/exhaustive-deps": "warn", 30 | }, 31 | }, 32 | ); 33 | -------------------------------------------------------------------------------- /backend/scripts/generate-version.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S deno run --allow-read --allow-write 2 | 3 | /** 4 | * Version generation script (Deno version) 5 | * 6 | * Reads package.json and generates cli/version.ts with the version information. 7 | * This ensures the version is available for both development and bundled builds. 8 | */ 9 | 10 | try { 11 | // Read version from package.json 12 | const packageJsonText = await Deno.readTextFile("package.json"); 13 | const packageJson = JSON.parse(packageJsonText); 14 | const version = packageJson.version; 15 | 16 | // Generate version.ts content 17 | const versionFileContent = `// Auto-generated file - do not edit manually 18 | // This file is generated by scripts/generate-version.ts 19 | export const VERSION = "${version}"; 20 | `; 21 | 22 | // Write version.ts file 23 | await Deno.writeTextFile("cli/version.ts", versionFileContent); 24 | 25 | console.log(`✅ Generated cli/version.ts with version: ${version}`); 26 | } catch (error) { 27 | console.error("❌ Failed to generate version.ts:", error.message); 28 | Deno.exit(1); 29 | } -------------------------------------------------------------------------------- /backend/scripts/generate-version.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Version generation script 5 | * 6 | * Reads package.json and generates cli/version.ts with the version information. 7 | * This ensures the version is available for both development and bundled builds. 8 | */ 9 | 10 | import { readFileSync, writeFileSync } from "node:fs"; 11 | import process from "node:process"; 12 | 13 | try { 14 | // Read version from package.json 15 | const packageJson = JSON.parse(readFileSync("package.json", "utf-8")); 16 | const version = packageJson.version; 17 | 18 | // Generate version.ts content 19 | const versionFileContent = `// Auto-generated file - do not edit manually 20 | // This file is generated by scripts/generate-version.js 21 | export const VERSION = "${version}"; 22 | `; 23 | 24 | // Write version.ts file 25 | writeFileSync("cli/version.ts", versionFileContent); 26 | 27 | console.log(`✅ Generated cli/version.ts with version: ${version}`); 28 | } catch (error) { 29 | console.error("❌ Failed to generate version.ts:", error.message); 30 | process.exit(1); 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/utils/messageTypes.ts: -------------------------------------------------------------------------------- 1 | import type { SDKMessage } from "../types"; 2 | 3 | // Type guard functions for SDKMessage 4 | export function isSystemMessage( 5 | data: SDKMessage, 6 | ): data is Extract { 7 | return data.type === "system"; 8 | } 9 | 10 | export function isAssistantMessage( 11 | data: SDKMessage, 12 | ): data is Extract { 13 | return data.type === "assistant"; 14 | } 15 | 16 | export function isResultMessage( 17 | data: SDKMessage, 18 | ): data is Extract { 19 | return data.type === "result"; 20 | } 21 | 22 | export function isUserMessage( 23 | data: SDKMessage, 24 | ): data is Extract { 25 | return data.type === "user"; 26 | } 27 | 28 | // Helper function to check if tool_result contains permission error 29 | export function isPermissionError(content: string): boolean { 30 | return ( 31 | content.includes("requested permissions") || 32 | content.includes("haven't granted it yet") || 33 | content.includes("permission denied") 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Claude Code Web Agent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /backend/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Claude Code Web Agent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /backend/samconfig.toml: -------------------------------------------------------------------------------- 1 | # SAM configuration file for Claude Code Web Agent 2 | 3 | version = 0.1 4 | 5 | [default] 6 | [default.global] 7 | [default.global.parameters] 8 | stack_name = "claude-web-agent" 9 | region = "us-east-1" 10 | confirm_changeset = true 11 | capabilities = "CAPABILITY_IAM" 12 | disable_rollback = true 13 | image_repositories = [] 14 | 15 | [default.build] 16 | [default.build.parameters] 17 | cached = true 18 | parallel = true 19 | 20 | [default.deploy] 21 | [default.deploy.parameters] 22 | capabilities = "CAPABILITY_IAM" 23 | confirm_changeset = true 24 | fail_on_empty_changeset = false 25 | stack_name = "claude-web-agent" 26 | s3_bucket = "aws-sam-cli-managed-default-samclisourcebucket-uch5mx0ixmvk" 27 | s3_prefix = "claude-web-agent" 28 | region = "us-east-1" 29 | parameter_overrides = "Stage=\"prod\"" 30 | 31 | [dev] 32 | [dev.deploy] 33 | [dev.deploy.parameters] 34 | capabilities = "CAPABILITY_IAM" 35 | confirm_changeset = true 36 | fail_on_empty_changeset = false 37 | stack_name = "claude-web-agent-dev" 38 | s3_bucket = "aws-sam-cli-managed-default-samclisourcebucket-uch5mx0ixmvk" 39 | s3_prefix = "claude-web-agent-dev" 40 | region = "us-east-1" 41 | parameter_overrides = "Stage=\"dev\"" -------------------------------------------------------------------------------- /frontend/src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export type Theme = "light" | "dark"; 4 | 5 | export function useTheme() { 6 | const [theme, setTheme] = useState("dark"); 7 | const [isInitialized, setIsInitialized] = useState(false); 8 | 9 | useEffect(() => { 10 | // Initialize theme on client side 11 | const saved = localStorage.getItem("theme") as Theme; 12 | 13 | if (saved && (saved === "light" || saved === "dark")) { 14 | setTheme(saved); 15 | } else { 16 | // Default to dark mode (our base theme) 17 | setTheme("dark"); 18 | } 19 | setIsInitialized(true); 20 | }, []); 21 | 22 | useEffect(() => { 23 | if (!isInitialized) return; 24 | 25 | const root = window.document.documentElement; 26 | 27 | if (theme === "light") { 28 | root.setAttribute("data-theme", "light"); 29 | } else { 30 | root.removeAttribute("data-theme"); 31 | } 32 | 33 | localStorage.setItem("theme", theme); 34 | }, [theme, isInitialized]); 35 | 36 | const toggleTheme = () => { 37 | setTheme((prev) => (prev === "light" ? "dark" : "light")); 38 | }; 39 | 40 | return { theme, toggleTheme }; 41 | } 42 | -------------------------------------------------------------------------------- /backend/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["deno.ns", "deno.window"], 4 | "strict": true 5 | }, 6 | "exclude": [ 7 | "runtime/node.ts", 8 | "cli/node.ts", 9 | "tests/node/" 10 | ], 11 | "tasks": { 12 | "generate-version": "deno run --allow-read --allow-write scripts/generate-version.ts", 13 | "copy-frontend": "deno run --allow-read --allow-write scripts/copy-frontend.ts", 14 | "dev": "deno task generate-version && deno run --allow-net --allow-run --allow-read --allow-env --watch cli/deno.ts --debug", 15 | "build": "deno task generate-version && deno task copy-frontend && deno compile --allow-net --allow-run --allow-read --allow-env --include ./dist/static --output ../dist/claude-code-webui cli/deno.ts", 16 | "format": "deno fmt cli/deno.ts runtime/deno.ts", 17 | "format:check": "deno fmt --check cli/deno.ts runtime/deno.ts", 18 | "lint": "deno lint cli/deno.ts runtime/deno.ts", 19 | "check": "deno check cli/deno.ts runtime/deno.ts" 20 | }, 21 | "imports": { 22 | "@std/assert": "jsr:@std/assert@1", 23 | "commander": "npm:commander@^14.0.0", 24 | "hono": "jsr:@hono/hono@^4", 25 | "@anthropic-ai/claude-code": "npm:@anthropic-ai/claude-code@1.0.51" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Server Configuration 2 | # Port for the backend server (default: 8080) 3 | # This port is used by both backend and frontend development servers 4 | PORT=8080 5 | 6 | # API Configuration 7 | # Set to "true" to use local backend during development, otherwise uses orchestrator agent's API endpoint 8 | VITE_USE_LOCAL_API=false 9 | 10 | # OpenAI API Configuration 11 | # API key for OpenAI access (used by UX Designer agent) 12 | # Get your API key from: https://platform.openai.com/api-keys 13 | OPENAI_API_KEY=your-openai-api-key-here 14 | 15 | # Anthropic API Configuration 16 | # API key for direct Anthropic API access (used by orchestrator agent) 17 | # Get your API key from: https://console.anthropic.com/ 18 | ANTHROPIC_API_KEY=your-anthropic-api-key-here 19 | 20 | # Usage Examples: 21 | # 1. Use orchestrator agent's API endpoint (default): 22 | # VITE_USE_LOCAL_API=false 23 | # Configure API endpoint in Settings > Agents > Orchestrator Agent 24 | # 25 | # 2. Use local backend for development: 26 | # VITE_USE_LOCAL_API=true 27 | # PORT=9000 npm run dev (for frontend) 28 | # PORT=9000 deno task dev (for backend) 29 | # 30 | # The frontend will use the orchestrator agent's API endpoint by default 31 | # Set VITE_USE_LOCAL_API=true to override and use local backend via Vite proxy -------------------------------------------------------------------------------- /frontend/src/components/TimestampComponent.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { formatAbsoluteTime, formatRelativeTime } from "../utils/time"; 3 | 4 | interface TimestampProps { 5 | timestamp: number; 6 | className?: string; 7 | mode?: "absolute" | "relative"; 8 | } 9 | 10 | export function TimestampComponent({ 11 | timestamp, 12 | className = "", 13 | mode = "absolute", 14 | }: TimestampProps) { 15 | const [displayTime, setDisplayTime] = useState(""); 16 | 17 | useEffect(() => { 18 | const updateTime = () => { 19 | setDisplayTime( 20 | mode === "absolute" 21 | ? formatAbsoluteTime(timestamp) 22 | : formatRelativeTime(timestamp), 23 | ); 24 | }; 25 | 26 | // Initial update 27 | updateTime(); 28 | 29 | // For relative time, update every minute 30 | // TODO: Consider using a shared timer/context for batch updates when many messages use relative mode 31 | if (mode === "relative") { 32 | const interval = setInterval(updateTime, 60000); 33 | return () => clearInterval(interval); 34 | } 35 | }, [timestamp, mode]); 36 | 37 | return ( 38 | 39 | {displayTime} 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /backend/handlers/abort.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | 3 | /** 4 | * Handles POST /api/abort/:requestId requests 5 | * Aborts an ongoing chat request by request ID 6 | * @param c - Hono context object with config variables 7 | * @param requestAbortControllers - Map of request IDs to AbortControllers 8 | * @returns JSON response indicating success or failure 9 | */ 10 | export function handleAbortRequest( 11 | c: Context, 12 | requestAbortControllers: Map, 13 | ) { 14 | const { debugMode } = c.var.config; 15 | const requestId = c.req.param("requestId"); 16 | 17 | if (!requestId) { 18 | return c.json({ error: "Request ID is required" }, 400); 19 | } 20 | 21 | if (debugMode) { 22 | console.debug(`[DEBUG] Abort attempt for request: ${requestId}`); 23 | console.debug( 24 | `[DEBUG] Active requests: ${Array.from(requestAbortControllers.keys())}`, 25 | ); 26 | } 27 | 28 | const abortController = requestAbortControllers.get(requestId); 29 | if (abortController) { 30 | abortController.abort(); 31 | requestAbortControllers.delete(requestId); 32 | 33 | if (debugMode) { 34 | console.debug(`[DEBUG] Aborted request: ${requestId}`); 35 | } 36 | 37 | return c.json({ success: true, message: "Request aborted" }); 38 | } else { 39 | return c.json({ error: "Request not found or already completed" }, 404); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/scripts/copy-frontend.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Copy frontend build files to dist/static 5 | * 6 | * This script copies the built frontend files from ../frontend/dist 7 | * to dist/static so they can be served by the bundled CLI application. 8 | */ 9 | 10 | import { cpSync, existsSync, mkdirSync } from "node:fs"; 11 | import { dirname, join } from "node:path"; 12 | import { fileURLToPath } from "node:url"; 13 | import process from "node:process"; 14 | 15 | const __filename = fileURLToPath(import.meta.url); 16 | const __dirname = dirname(__filename); 17 | 18 | // Paths 19 | const frontendDistPath = join(__dirname, "../../frontend/dist"); 20 | const backendStaticPath = join(__dirname, "../dist/static"); 21 | 22 | // Check if frontend build exists 23 | if (!existsSync(frontendDistPath)) { 24 | console.error("❌ Frontend build not found at:", frontendDistPath); 25 | console.error(" Please run 'cd ../frontend && npm run build' first"); 26 | process.exit(1); 27 | } 28 | 29 | // Ensure target directory exists 30 | mkdirSync(dirname(backendStaticPath), { recursive: true }); 31 | 32 | // Copy frontend files 33 | try { 34 | cpSync(frontendDistPath, backendStaticPath, { 35 | recursive: true, 36 | force: true, 37 | }); 38 | console.log("✅ Frontend files copied to dist/static"); 39 | } catch (error) { 40 | console.error("❌ Failed to copy frontend files:", error.message); 41 | process.exit(1); 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/components/chat/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { SunIcon, MoonIcon } from "@heroicons/react/24/outline"; 2 | 3 | interface ThemeToggleProps { 4 | theme: "light" | "dark"; 5 | onToggle: () => void; 6 | } 7 | 8 | export function ThemeToggle({ theme, onToggle }: ThemeToggleProps) { 9 | return ( 10 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /backend/scripts/copy-frontend.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S deno run --allow-read --allow-write 2 | 3 | /** 4 | * Copy frontend build files to dist/static (Deno version) 5 | * 6 | * This script copies the built frontend files from ../frontend/dist 7 | * to dist/static so they can be served by the bundled CLI application. 8 | */ 9 | 10 | import { copy, ensureDir, exists } from "https://deno.land/std@0.224.0/fs/mod.ts"; 11 | import { dirname, join } from "https://deno.land/std@0.224.0/path/mod.ts"; 12 | 13 | // Get current script directory 14 | const currentFile = new URL(import.meta.url).pathname; 15 | const currentDir = dirname(currentFile); 16 | 17 | // Paths 18 | const frontendDistPath = join(currentDir, "../../frontend/dist"); 19 | const backendStaticPath = join(currentDir, "../dist/static"); 20 | 21 | try { 22 | // Check if frontend build exists 23 | if (!(await exists(frontendDistPath))) { 24 | console.error("❌ Frontend build not found at:", frontendDistPath); 25 | console.error(" Please run 'cd ../frontend && npm run build' first"); 26 | Deno.exit(1); 27 | } 28 | 29 | // Ensure target directory exists 30 | await ensureDir(dirname(backendStaticPath)); 31 | 32 | // Copy frontend files 33 | await copy(frontendDistPath, backendStaticPath, { overwrite: true }); 34 | console.log("✅ Frontend files copied to dist/static"); 35 | } catch (error) { 36 | console.error("❌ Failed to copy frontend files:", error.message); 37 | Deno.exit(1); 38 | } -------------------------------------------------------------------------------- /backend/cli/deno.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deno-specific entry point 3 | * 4 | * This module handles Deno-specific initialization including CLI argument parsing, 5 | * Claude CLI validation, and server startup using the DenoRuntime. 6 | */ 7 | 8 | import { createApp } from "../app.ts"; 9 | import { DenoRuntime } from "../runtime/deno.ts"; 10 | import { parseCliArgs } from "./args.ts"; 11 | import { validateClaudeCli } from "./validation.ts"; 12 | 13 | async function main(runtime: DenoRuntime) { 14 | // Parse CLI arguments 15 | const args = parseCliArgs(runtime); 16 | 17 | // Validate Claude CLI availability and get the validated path 18 | const validatedClaudePath = await validateClaudeCli(runtime, args.claudePath); 19 | 20 | if (args.debug) { 21 | console.log("🐛 Debug mode enabled"); 22 | } 23 | 24 | // Create application 25 | const app = createApp(runtime, { 26 | debugMode: args.debug, 27 | staticPath: new URL("../dist/static", import.meta.url).pathname, 28 | claudePath: validatedClaudePath, 29 | }); 30 | 31 | // Start server (only show this message when everything is ready) 32 | console.log(`🚀 Server starting on ${args.host}:${args.port}`); 33 | runtime.serve(args.port, args.host, app.fetch); 34 | } 35 | 36 | // Run the application 37 | if (import.meta.main) { 38 | const runtime = new DenoRuntime(); 39 | main(runtime).catch((error) => { 40 | console.error("Failed to start server:", error); 41 | runtime.exit(1); 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Agentrooms 8 | 9 | 22 | 36 | 37 | 38 |
39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /frontend/src/contexts/EnterBehaviorContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback, useMemo } from "react"; 2 | import type { EnterBehavior } from "../types/enterBehavior"; 3 | import { EnterBehaviorContext } from "./EnterBehaviorContextDefinition"; 4 | 5 | export function EnterBehaviorProvider({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | const [enterBehavior, setEnterBehavior] = useState("send"); 11 | const [isInitialized, setIsInitialized] = useState(false); 12 | 13 | useEffect(() => { 14 | // Initialize enter behavior on client side 15 | const saved = localStorage.getItem("enterBehavior") as EnterBehavior; 16 | 17 | if (saved && (saved === "send" || saved === "newline")) { 18 | setEnterBehavior(saved); 19 | } else { 20 | // Default to "send" (traditional behavior) 21 | setEnterBehavior("send"); 22 | } 23 | setIsInitialized(true); 24 | }, []); 25 | 26 | useEffect(() => { 27 | if (!isInitialized) return; 28 | 29 | localStorage.setItem("enterBehavior", enterBehavior); 30 | }, [enterBehavior, isInitialized]); 31 | 32 | const toggleEnterBehavior = useCallback(() => { 33 | setEnterBehavior((prev) => (prev === "send" ? "newline" : "send")); 34 | }, []); 35 | 36 | const value = useMemo( 37 | () => ({ enterBehavior, toggleEnterBehavior }), 38 | [enterBehavior, toggleEnterBehavior], 39 | ); 40 | 41 | return ( 42 | 43 | {children} 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig, loadEnv } from "vite"; 3 | import react from "@vitejs/plugin-react-swc"; 4 | import tailwindcss from "@tailwindcss/vite"; 5 | import { dirname, resolve } from "path"; 6 | import { fileURLToPath } from "url"; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | 10 | // https://vite.dev/config/ 11 | export default defineConfig(({ mode }) => { 12 | const env = loadEnv(mode, resolve(__dirname, ".."), ""); 13 | const apiPort = env.PORT || "8080"; 14 | 15 | return { 16 | plugins: [react(), tailwindcss()], 17 | base: "./", // Use relative paths for Electron compatibility 18 | resolve: { 19 | alias: { 20 | "@shared": resolve(__dirname, "../shared"), 21 | }, 22 | }, 23 | server: { 24 | port: 3000, 25 | proxy: { 26 | "/api": { 27 | target: `http://localhost:${apiPort}`, 28 | changeOrigin: true, 29 | secure: false, 30 | }, 31 | }, 32 | }, 33 | test: { 34 | environment: "jsdom", 35 | setupFiles: ["./src/test-setup.ts"], 36 | globals: true, 37 | exclude: [ 38 | "**/node_modules/**", 39 | "**/dist/**", 40 | "**/cypress/**", 41 | "**/.{idea,git,cache,output,temp}/**", 42 | "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*", 43 | "**/scripts/**", // Exclude Playwright demo recording files 44 | "**/tests/**", // Exclude Playwright validation tests 45 | ], 46 | }, 47 | }; 48 | }); 49 | -------------------------------------------------------------------------------- /assets/placeholder-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | A 20 | B 21 | W 22 | K 23 | 24 | 25 | AgentHub 26 | -------------------------------------------------------------------------------- /frontend/src/hooks/streaming/useMessageProcessor.ts: -------------------------------------------------------------------------------- 1 | import type { AllMessage, ChatMessage } from "../../types"; 2 | import { useMessageConverter } from "../useMessageConverter"; 3 | 4 | export interface StreamingContext { 5 | currentAssistantMessage: ChatMessage | null; 6 | setCurrentAssistantMessage: (msg: ChatMessage | null) => void; 7 | addMessage: (msg: AllMessage) => void; 8 | updateLastMessage: (content: string) => void; 9 | onSessionId?: (sessionId: string) => void; 10 | shouldShowInitMessage?: () => boolean; 11 | onInitMessageShown?: () => void; 12 | hasReceivedInit?: boolean; 13 | setHasReceivedInit?: (received: boolean) => void; 14 | onPermissionError?: ( 15 | toolName: string, 16 | patterns: string[], 17 | toolUseId: string, 18 | ) => void; 19 | onAbortRequest?: () => void; 20 | onRequestComplete?: () => void; 21 | agentId?: string; // Agent ID for response attribution 22 | } 23 | 24 | /** 25 | * Hook that provides message processing functions for streaming context. 26 | * Now delegates to the unified message converter for consistency. 27 | */ 28 | export function useMessageProcessor() { 29 | const converter = useMessageConverter(); 30 | 31 | return { 32 | // Delegate to unified converter 33 | createSystemMessage: converter.createSystemMessage, 34 | createToolMessage: converter.createToolMessage, 35 | createResultMessage: converter.createResultMessage, 36 | createToolResultMessage: converter.createToolResultMessage, 37 | convertTimestampedSDKMessage: converter.convertTimestampedSDKMessage, 38 | convertConversationHistory: converter.convertConversationHistory, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/hooks/chat/useAbortController.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { getAbortUrl } from "../../config/api"; 3 | import { useAgentConfig } from "../useAgentConfig"; 4 | 5 | export function useAbortController() { 6 | const { getOrchestratorAgent } = useAgentConfig(); 7 | 8 | // Helper function to perform abort request 9 | const performAbortRequest = useCallback(async (requestId: string) => { 10 | const orchestratorAgent = getOrchestratorAgent(); 11 | await fetch(getAbortUrl(requestId, orchestratorAgent?.apiEndpoint), { 12 | method: "POST", 13 | headers: { "Content-Type": "application/json" }, 14 | }); 15 | }, [getOrchestratorAgent]); 16 | 17 | const abortRequest = useCallback( 18 | async ( 19 | requestId: string | null, 20 | isLoading: boolean, 21 | onAbortComplete: () => void, 22 | ) => { 23 | if (!requestId || !isLoading) return; 24 | 25 | try { 26 | await performAbortRequest(requestId); 27 | } catch (error) { 28 | console.error("Failed to abort request:", error); 29 | } finally { 30 | // Clean up state after successful abort or error 31 | onAbortComplete(); 32 | } 33 | }, 34 | [performAbortRequest], 35 | ); 36 | 37 | const createAbortHandler = useCallback( 38 | (requestId: string) => async () => { 39 | try { 40 | await performAbortRequest(requestId); 41 | } catch (error) { 42 | console.error("Failed to abort request:", error); 43 | } 44 | }, 45 | [performAbortRequest], 46 | ); 47 | 48 | return { 49 | abortRequest, 50 | createAbortHandler, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: { 9 | colors: { 10 | border: "hsl(var(--border))", 11 | input: "hsl(var(--input))", 12 | ring: "hsl(var(--ring))", 13 | background: "hsl(var(--background))", 14 | foreground: "hsl(var(--foreground))", 15 | primary: { 16 | DEFAULT: "hsl(var(--primary))", 17 | foreground: "hsl(var(--primary-foreground))", 18 | }, 19 | secondary: { 20 | DEFAULT: "hsl(var(--secondary))", 21 | foreground: "hsl(var(--secondary-foreground))", 22 | }, 23 | destructive: { 24 | DEFAULT: "hsl(var(--destructive))", 25 | foreground: "hsl(var(--destructive-foreground))", 26 | }, 27 | muted: { 28 | DEFAULT: "hsl(var(--muted))", 29 | foreground: "hsl(var(--muted-foreground))", 30 | }, 31 | accent: { 32 | DEFAULT: "hsl(var(--accent))", 33 | foreground: "hsl(var(--accent-foreground))", 34 | }, 35 | popover: { 36 | DEFAULT: "hsl(var(--popover))", 37 | foreground: "hsl(var(--popover-foreground))", 38 | }, 39 | card: { 40 | DEFAULT: "hsl(var(--card))", 41 | foreground: "hsl(var(--card-foreground))", 42 | }, 43 | }, 44 | borderRadius: { 45 | lg: "var(--radius)", 46 | md: "calc(var(--radius) - 2px)", 47 | sm: "calc(var(--radius) - 4px)", 48 | }, 49 | }, 50 | }, 51 | plugins: [], 52 | } -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "agentrooms-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=20.0.0" 8 | }, 9 | "scripts": { 10 | "dev": "vite", 11 | "build": "vite build", 12 | "lint": "eslint .", 13 | "format": "prettier --write src/ scripts/", 14 | "format:check": "prettier --check src/ scripts/", 15 | "typecheck": "tsc --noEmit", 16 | "preview": "vite preview", 17 | "test": "vitest", 18 | "test:run": "vitest run", 19 | "playwright:install": "npx playwright install", 20 | "record-demo": "npx tsx scripts/record-demo.ts" 21 | }, 22 | "dependencies": { 23 | "@heroicons/react": "^2.2.0", 24 | "dayjs": "^1.11.13", 25 | "lucide-react": "^0.525.0", 26 | "react": "^19.1.0", 27 | "react-dom": "^19.1.0", 28 | "react-router-dom": "^7.6.2" 29 | }, 30 | "devDependencies": { 31 | "@anthropic-ai/claude-code": "1.0.51", 32 | "@eslint/js": "^9.25.0", 33 | "@playwright/test": "^1.48.2", 34 | "@tailwindcss/vite": "^4.1.8", 35 | "@testing-library/jest-dom": "^6.6.3", 36 | "@testing-library/react": "^16.3.0", 37 | "@types/node": "^24.0.0", 38 | "@types/react": "^19.1.2", 39 | "@types/react-dom": "^19.1.2", 40 | "@vitejs/plugin-react-swc": "^3.9.0", 41 | "eslint": "^9.25.0", 42 | "eslint-plugin-react-hooks": "^5.2.0", 43 | "eslint-plugin-react-refresh": "^0.4.19", 44 | "globals": "^16.0.0", 45 | "jsdom": "^26.1.0", 46 | "prettier": "^3.6.2", 47 | "tsx": "^4.19.2", 48 | "typescript": "~5.8.3", 49 | "typescript-eslint": "^8.30.1", 50 | "vite": "^6.3.5", 51 | "vitest": "^3.2.3" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /backend/scripts/build-bundle.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Build script for esbuild bundling 5 | * 6 | * This script bundles the Node.js CLI application using esbuild. 7 | * Version information is handled via the auto-generated version.ts file. 8 | */ 9 | 10 | import { build } from "esbuild"; 11 | import { copyFileSync, mkdirSync } from "fs"; 12 | import { dirname } from "path"; 13 | 14 | // Build CLI bundle 15 | await build({ 16 | entryPoints: ["cli/node.ts"], 17 | bundle: true, 18 | platform: "node", 19 | target: "node18", 20 | format: "esm", 21 | outfile: "dist/cli/node.js", 22 | external: [ 23 | "@anthropic-ai/claude-code", 24 | "@anthropic-ai/sdk", 25 | "@hono/node-server", 26 | "hono", 27 | "commander", 28 | "openai", 29 | "node-fetch", 30 | "formdata-node", 31 | "abort-controller", 32 | "form-data-encoder", 33 | "formdata-node/file-from-path", 34 | ], 35 | sourcemap: true, 36 | }); 37 | 38 | // Build Lambda handler 39 | await build({ 40 | entryPoints: ["lambda.ts"], 41 | bundle: true, 42 | platform: "node", 43 | target: "node20", 44 | format: "esm", 45 | outfile: "dist/lambda.js", 46 | external: [ 47 | "@anthropic-ai/claude-code", 48 | ], 49 | sourcemap: true, 50 | }); 51 | 52 | // Copy auth files to dist directory 53 | try { 54 | mkdirSync("dist/auth", { recursive: true }); 55 | copyFileSync("auth/preload-script.cjs", "dist/auth/preload-script.cjs"); 56 | console.log("✅ Auth files copied to dist directory"); 57 | } catch (error) { 58 | console.warn("⚠️ Failed to copy auth files:", error.message); 59 | } 60 | 61 | console.log("✅ CLI bundle created successfully"); 62 | console.log("✅ Lambda bundle created successfully"); 63 | -------------------------------------------------------------------------------- /frontend/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import relativeTime from "dayjs/plugin/relativeTime"; 3 | 4 | // Initialize the relative time plugin 5 | dayjs.extend(relativeTime); 6 | 7 | /** 8 | * Format a timestamp as an absolute time string 9 | * @param timestamp - Unix timestamp in milliseconds 10 | * @returns Formatted absolute time string (e.g., "14:30", "Dec 15, 14:30") 11 | */ 12 | export function formatAbsoluteTime(timestamp: number): string { 13 | const messageTime = dayjs(timestamp); 14 | const now = dayjs(); 15 | 16 | // If it's today, show only time (HH:mm) 17 | if (messageTime.isSame(now, "day")) { 18 | return messageTime.format("HH:mm"); 19 | } 20 | 21 | // If it's this year, show date and time without year 22 | if (messageTime.isSame(now, "year")) { 23 | return messageTime.format("MMM D, HH:mm"); 24 | } 25 | 26 | // If it's from a different year, show full date and time 27 | return messageTime.format("MMM D, YYYY HH:mm"); 28 | } 29 | 30 | /** 31 | * Format a timestamp as a relative time string 32 | * @param timestamp - Unix timestamp in milliseconds 33 | * @returns Formatted relative time string (e.g., "2 minutes ago", "just now") 34 | */ 35 | export function formatRelativeTime(timestamp: number): string { 36 | const messageTime = dayjs(timestamp); 37 | const now = dayjs(); 38 | 39 | // If the timestamp is in the future, clamp to "just now" 40 | if (messageTime.isAfter(now)) { 41 | return "just now"; 42 | } 43 | 44 | const diffInMinutes = now.diff(messageTime, "minute"); 45 | 46 | // Show "just now" for very recent messages (< 1 minute) 47 | if (diffInMinutes < 1) { 48 | return "just now"; 49 | } 50 | 51 | return messageTime.fromNow(); 52 | } 53 | -------------------------------------------------------------------------------- /assets/create-icon.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Create app icon from source image 3 | # Usage: ./create-icon.sh source-image.png 4 | 5 | if [ $# -eq 0 ]; then 6 | echo "Usage: $0 " 7 | echo "Source image should be at least 1024x1024 pixels" 8 | exit 1 9 | fi 10 | 11 | SOURCE=$1 12 | ICONSET="icon.iconset" 13 | 14 | if [ ! -f "$SOURCE" ]; then 15 | echo "Error: Source image '$SOURCE' not found" 16 | exit 1 17 | fi 18 | 19 | # Create iconset directory 20 | mkdir -p "$ICONSET" 21 | 22 | # Generate different sizes 23 | sips -z 16 16 "$SOURCE" --out "${ICONSET}/icon_16x16.png" 24 | sips -z 32 32 "$SOURCE" --out "${ICONSET}/icon_16x16@2x.png" 25 | sips -z 32 32 "$SOURCE" --out "${ICONSET}/icon_32x32.png" 26 | sips -z 64 64 "$SOURCE" --out "${ICONSET}/icon_32x32@2x.png" 27 | sips -z 128 128 "$SOURCE" --out "${ICONSET}/icon_128x128.png" 28 | sips -z 256 256 "$SOURCE" --out "${ICONSET}/icon_128x128@2x.png" 29 | sips -z 256 256 "$SOURCE" --out "${ICONSET}/icon_256x256.png" 30 | sips -z 512 512 "$SOURCE" --out "${ICONSET}/icon_256x256@2x.png" 31 | sips -z 512 512 "$SOURCE" --out "${ICONSET}/icon_512x512.png" 32 | sips -z 1024 1024 "$SOURCE" --out "${ICONSET}/icon_512x512@2x.png" 33 | 34 | # Create icns file 35 | iconutil -c icns "$ICONSET" -o "icon.icns" 36 | 37 | # Create Windows ico (requires ImageMagick) 38 | if command -v convert &> /dev/null; then 39 | convert "$SOURCE" -resize 256x256 "icon.ico" 40 | echo "Created icon.ico for Windows" 41 | fi 42 | 43 | # Create Linux png 44 | cp "$SOURCE" "icon.png" 45 | 46 | echo "Created macOS icon.icns" 47 | echo "Place these files in the assets/ directory:" 48 | echo " - icon.icns (macOS)" 49 | echo " - icon.ico (Windows)" 50 | echo " - icon.png (Linux)" 51 | 52 | # Cleanup 53 | rm -rf "$ICONSET" -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Brief description of the changes in this PR. 4 | 5 | ## Type of Change 6 | 7 | Please add the appropriate label(s) to this PR and check the relevant box(es): 8 | 9 | - [ ] 🐛 `bug` - Bug fix (non-breaking change which fixes an issue) 10 | - [ ] ✨ `feature` - New feature (non-breaking change which adds functionality) 11 | - [ ] 💥 `breaking` - Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] 📚 `documentation` - Documentation update 13 | - [ ] ⚡ `performance` - Performance improvement 14 | - [ ] 🔨 `refactor` - Code refactoring 15 | - [ ] 🧪 `test` - Adding or updating tests 16 | - [ ] 🔧 `chore` - Maintenance, dependencies, tooling 17 | 18 | ## Changes Made 19 | 20 | - List key changes 21 | - Include any breaking changes 22 | - Mention new dependencies or configuration changes 23 | 24 | ## Testing 25 | 26 | - [ ] Tests pass locally (`make test`) 27 | - [ ] Code is formatted (`make format`) 28 | - [ ] Code is linted (`make lint`) 29 | - [ ] Type checking passes (`make typecheck`) 30 | - [ ] All quality checks pass (`make check`) 31 | - [ ] Manual testing performed (describe what was tested) 32 | 33 | ## Checklist 34 | 35 | - [ ] My code follows the project's style guidelines 36 | - [ ] I have performed a self-review of my own code 37 | - [ ] I have commented my code, particularly in hard-to-understand areas 38 | - [ ] I have made corresponding changes to the documentation 39 | - [ ] I have added/updated tests for my changes 40 | - [ ] All tests pass 41 | 42 | ## Screenshots (if applicable) 43 | 44 | Add screenshots to help explain your changes. 45 | 46 | ## Additional Notes 47 | 48 | Any additional information, dependencies, or context needed for reviewers. 49 | -------------------------------------------------------------------------------- /database.js: -------------------------------------------------------------------------------- 1 | // Stub database module for OAuth service in Electron context 2 | // This provides minimal implementation for the OAuth service dependencies 3 | 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const { app } = require('electron'); 7 | 8 | class SimpleDatabase { 9 | constructor() { 10 | // Use Electron userData path for database 11 | this.dbPath = path.join(app.getPath('userData'), 'auth-data.json'); 12 | this.data = this.loadData(); 13 | } 14 | 15 | loadData() { 16 | try { 17 | if (fs.existsSync(this.dbPath)) { 18 | const content = fs.readFileSync(this.dbPath, 'utf8'); 19 | return JSON.parse(content); 20 | } 21 | } catch (error) { 22 | console.warn('Failed to load auth database:', error); 23 | } 24 | return {}; 25 | } 26 | 27 | saveData() { 28 | try { 29 | fs.writeFileSync(this.dbPath, JSON.stringify(this.data, null, 2)); 30 | } catch (error) { 31 | console.error('Failed to save auth database:', error); 32 | } 33 | } 34 | 35 | // Minimal implementation for OAuth service 36 | prepare(query) { 37 | return { 38 | run: (...params) => { 39 | // Stub implementation - just log the query 40 | console.log('DB Query (stub):', query, params); 41 | return { changes: 1 }; 42 | }, 43 | get: (...params) => { 44 | // Stub implementation 45 | console.log('DB Get (stub):', query, params); 46 | return null; 47 | } 48 | }; 49 | } 50 | 51 | exec(query) { 52 | console.log('DB Exec (stub):', query); 53 | } 54 | } 55 | 56 | let database = null; 57 | 58 | function getMainDatabase() { 59 | if (!database) { 60 | database = new SimpleDatabase(); 61 | } 62 | return database; 63 | } 64 | 65 | module.exports = { getMainDatabase }; -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub/Models/Agent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Agent: Codable, Identifiable, Hashable { 4 | let id: String 5 | let name: String 6 | let description: String 7 | let workingDirectory: String 8 | let apiEndpoint: String 9 | let isOrchestrator: Bool 10 | let isEnabled: Bool 11 | 12 | init(id: String, name: String, description: String, workingDirectory: String, apiEndpoint: String, isOrchestrator: Bool = false, isEnabled: Bool = true) { 13 | self.id = id 14 | self.name = name 15 | self.description = description 16 | self.workingDirectory = workingDirectory 17 | self.apiEndpoint = apiEndpoint 18 | self.isOrchestrator = isOrchestrator 19 | self.isEnabled = isEnabled 20 | } 21 | 22 | static let sampleAgents: [Agent] = [ 23 | Agent( 24 | id: "orchestrator-agent", 25 | name: "Orchestrator Agent", 26 | description: "Orchestrates multi-agent conversations", 27 | workingDirectory: "/tmp/orchestrator", 28 | apiEndpoint: "https://api.claudecode.run", 29 | isOrchestrator: true 30 | ) 31 | ] 32 | } 33 | 34 | struct AgentSession { 35 | let agentId: String 36 | var sessionId: String? 37 | var messages: [ChatMessage] 38 | var lastActiveTime: Date 39 | 40 | init(agentId: String) { 41 | self.agentId = agentId 42 | self.sessionId = nil 43 | self.messages = [] 44 | self.lastActiveTime = Date() 45 | } 46 | } 47 | 48 | extension Agent { 49 | var displayName: String { 50 | return isOrchestrator ? "🎭 \(name)" : name 51 | } 52 | 53 | var statusColor: String { 54 | return isEnabled ? (isOrchestrator ? "purple" : "blue") : "gray" 55 | } 56 | } -------------------------------------------------------------------------------- /backend/scripts/prepack.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Prepack script - Copy required files for npm package 5 | * 6 | * This script copies README.md and LICENSE from the project root 7 | * to the backend directory for npm package distribution. 8 | * Replaces the Unix-specific `cp` command for Windows compatibility. 9 | */ 10 | 11 | import { copyFileSync, existsSync } from "node:fs"; 12 | import { dirname, join } from "node:path"; 13 | import { fileURLToPath } from "node:url"; 14 | import process from "node:process"; 15 | 16 | const __filename = fileURLToPath(import.meta.url); 17 | const __dirname = dirname(__filename); 18 | 19 | // Paths 20 | const projectRoot = join(__dirname, "../.."); 21 | const backendDir = join(__dirname, ".."); 22 | const readmePath = join(projectRoot, "README.md"); 23 | const licensePath = join(projectRoot, "LICENSE"); 24 | const targetReadmePath = join(backendDir, "README.md"); 25 | const targetLicensePath = join(backendDir, "LICENSE"); 26 | 27 | // Copy README.md 28 | if (existsSync(readmePath)) { 29 | try { 30 | copyFileSync(readmePath, targetReadmePath); 31 | console.log("✅ Copied README.md"); 32 | } catch (error) { 33 | console.error("❌ Failed to copy README.md:", error.message); 34 | process.exit(1); 35 | } 36 | } else { 37 | console.error("❌ README.md not found at:", readmePath); 38 | process.exit(1); 39 | } 40 | 41 | // Copy LICENSE 42 | if (existsSync(licensePath)) { 43 | try { 44 | copyFileSync(licensePath, targetLicensePath); 45 | console.log("✅ Copied LICENSE"); 46 | } catch (error) { 47 | console.error("❌ Failed to copy LICENSE:", error.message); 48 | process.exit(1); 49 | } 50 | } else { 51 | console.error("❌ LICENSE not found at:", licensePath); 52 | process.exit(1); 53 | } 54 | 55 | console.log("✅ Prepack completed successfully"); -------------------------------------------------------------------------------- /frontend/src/hooks/chat/usePermissions.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from "react"; 2 | 3 | interface PermissionDialog { 4 | isOpen: boolean; 5 | toolName: string; 6 | patterns: string[]; 7 | toolUseId: string; 8 | } 9 | 10 | export function usePermissions() { 11 | const [allowedTools, setAllowedTools] = useState([]); 12 | const [permissionDialog, setPermissionDialog] = 13 | useState(null); 14 | 15 | const showPermissionDialog = useCallback( 16 | (toolName: string, patterns: string[], toolUseId: string) => { 17 | setPermissionDialog({ 18 | isOpen: true, 19 | toolName, 20 | patterns, 21 | toolUseId, 22 | }); 23 | }, 24 | [], 25 | ); 26 | 27 | const closePermissionDialog = useCallback(() => { 28 | setPermissionDialog(null); 29 | }, []); 30 | 31 | const allowToolTemporary = useCallback( 32 | (pattern: string, baseTools?: string[]) => { 33 | const currentAllowedTools = baseTools || allowedTools; 34 | return [...currentAllowedTools, pattern]; 35 | }, 36 | [allowedTools], 37 | ); 38 | 39 | const allowToolPermanent = useCallback( 40 | (pattern: string, baseTools?: string[]) => { 41 | const currentAllowedTools = baseTools || allowedTools; 42 | const updatedAllowedTools = [...currentAllowedTools, pattern]; 43 | setAllowedTools(updatedAllowedTools); 44 | return updatedAllowedTools; 45 | }, 46 | [allowedTools], 47 | ); 48 | 49 | const resetPermissions = useCallback(() => { 50 | setAllowedTools([]); 51 | }, []); 52 | 53 | return { 54 | allowedTools, 55 | permissionDialog, 56 | showPermissionDialog, 57 | closePermissionDialog, 58 | allowToolTemporary, 59 | allowToolPermanent, 60 | resetPermissions, 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | @StateObject private var appSettings = AppSettings() 5 | @State private var selectedTab = 0 6 | 7 | var body: some View { 8 | TabView(selection: $selectedTab) { 9 | // Agent Hub Tab 10 | AgentHubView() 11 | .environmentObject(appSettings) 12 | .tabItem { 13 | Image(systemName: Constants.Icons.agents) 14 | Text(Constants.Strings.agentsTab) 15 | } 16 | .tag(0) 17 | 18 | // Chat Tab 19 | ChatView() 20 | .environmentObject(appSettings) 21 | .tabItem { 22 | Image(systemName: Constants.Icons.chat) 23 | Text(Constants.Strings.chatTab) 24 | } 25 | .tag(1) 26 | 27 | // Projects Tab 28 | ProjectSelectorView() 29 | .environmentObject(appSettings) 30 | .tabItem { 31 | Image(systemName: Constants.Icons.projects) 32 | Text(Constants.Strings.projectsTab) 33 | } 34 | .tag(2) 35 | 36 | // Settings Tab 37 | SettingsView() 38 | .environmentObject(appSettings) 39 | .tabItem { 40 | Image(systemName: Constants.Icons.settings) 41 | Text(Constants.Strings.settingsTab) 42 | } 43 | .tag(3) 44 | } 45 | .accentColor(Constants.Colors.primary) 46 | .preferredColorScheme(appSettings.isDarkMode ? .dark : .light) 47 | } 48 | } 49 | 50 | #Preview { 51 | ContentView() 52 | } -------------------------------------------------------------------------------- /frontend/src/types/window.d.ts: -------------------------------------------------------------------------------- 1 | // File System Access API type definitions 2 | declare global { 3 | interface Window { 4 | showDirectoryPicker?: () => Promise; 5 | electronAPI?: { 6 | platform: string; 7 | openExternal: (url: string) => void; 8 | auth: { 9 | startOAuth: () => Promise<{success: boolean, error?: string, message?: string, pendingAuth?: boolean}>; 10 | completeOAuth: (authCode: string) => Promise<{success: boolean, session?: any, error?: string}>; 11 | checkStatus: () => Promise<{success: boolean, isAuthenticated: boolean, session?: any, error?: string}>; 12 | signOut: () => Promise<{success: boolean, error?: string}>; 13 | }; 14 | storage: { 15 | // Agent Configuration 16 | saveAgentConfig: (config: any) => Promise<{success: boolean, error?: string}>; 17 | loadAgentConfig: () => Promise<{success: boolean, data?: any, error?: string}>; 18 | 19 | // Chat Messages 20 | saveConversation: (sessionId: string, messages: any[]) => Promise<{success: boolean, error?: string}>; 21 | loadConversation: (sessionId: string) => Promise<{success: boolean, data?: any, error?: string}>; 22 | listConversations: () => Promise<{success: boolean, data?: any[], error?: string}>; 23 | 24 | // App Settings 25 | saveSetting: (key: string, value: any) => Promise<{success: boolean, error?: string}>; 26 | loadSetting: (key: string) => Promise<{success: boolean, data?: any, error?: string}>; 27 | loadAllSettings: () => Promise<{success: boolean, data?: any, error?: string}>; 28 | }; 29 | }; 30 | } 31 | 32 | interface FileSystemDirectoryHandle { 33 | readonly kind: "directory"; 34 | readonly name: string; 35 | } 36 | } 37 | 38 | export {}; 39 | -------------------------------------------------------------------------------- /backend/lambda.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * AWS Lambda handler for Claude Code Web Agent 3 | * 4 | * This module adapts the Hono application to work with AWS Lambda and API Gateway. 5 | */ 6 | 7 | import { handle } from 'hono/aws-lambda'; 8 | import { createApp } from './app.js'; 9 | import { NodeRuntime } from './runtime/node.js'; 10 | 11 | // Create runtime and app instance 12 | const runtime = new NodeRuntime(); 13 | 14 | // Configure for Lambda environment 15 | const app = createApp(runtime, { 16 | debugMode: process.env.NODE_ENV !== 'production', 17 | staticPath: './dist/static', // Static files are bundled in dist/static in Lambda package 18 | claudePath: 'claude', // Assume claude is available in Lambda environment or provided as layer 19 | }); 20 | 21 | // Wrap the handler with error handling and logging 22 | export const handler = async (event: any, context: any) => { 23 | try { 24 | console.log('Lambda Event:', JSON.stringify(event, null, 2)); 25 | 26 | const result = await handle(app)(event, context); 27 | 28 | console.log('Lambda Response:', JSON.stringify(result, null, 2)); 29 | return result; 30 | } catch (error) { 31 | console.error('Lambda Handler Error:', error); 32 | 33 | // Return proper Lambda proxy response format on error 34 | return { 35 | statusCode: 500, 36 | headers: { 37 | 'Access-Control-Allow-Origin': '*', 38 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 39 | 'Access-Control-Allow-Headers': 'Content-Type, X-Amz-Date, Authorization, X-Api-Key, X-Amz-Security-Token, X-Requested-With', 40 | 'Content-Type': 'application/json', 41 | }, 42 | body: JSON.stringify({ 43 | error: 'Internal Server Error', 44 | message: process.env.NODE_ENV !== 'production' ? error.message : 'An error occurred' 45 | }), 46 | }; 47 | } 48 | }; -------------------------------------------------------------------------------- /backend/scripts/start-with-preload.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Production start script with automatic preload script patching 5 | * This sets up the environment for Claude Code OAuth credential interception 6 | */ 7 | 8 | import { spawn } from 'child_process'; 9 | import { join } from 'path'; 10 | import { fileURLToPath } from 'url'; 11 | import { dirname } from 'path'; 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = dirname(__filename); 15 | 16 | // Set up environment variables for preload script patching 17 | const env = { 18 | ...process.env, 19 | NODE_OPTIONS: '--require ./dist/auth/preload-script.cjs', 20 | CLAUDE_CREDENTIALS_PATH: join(process.env.HOME || process.cwd(), '.claude-credentials.json'), 21 | DEBUG_PRELOAD_SCRIPT: process.env.DEBUG_PRELOAD_SCRIPT || '0' 22 | }; 23 | 24 | console.log('🚀 Starting backend with Claude OAuth preload script patching...'); 25 | console.log('📁 Preload script:', './dist/auth/preload-script.cjs'); 26 | console.log('🗄️ Credentials path:', env.CLAUDE_CREDENTIALS_PATH); 27 | console.log('🐛 Debug logging:', env.DEBUG_PRELOAD_SCRIPT === '1' ? 'enabled' : 'disabled'); 28 | console.log(''); 29 | 30 | // Start the production server 31 | const child = spawn('node', ['dist/cli/node.js'], { 32 | env, 33 | stdio: 'inherit', 34 | shell: false 35 | }); 36 | 37 | child.on('error', (error) => { 38 | console.error('❌ Failed to start production server:', error); 39 | process.exit(1); 40 | }); 41 | 42 | child.on('close', (code) => { 43 | console.log(`\n🛑 Production server exited with code ${code}`); 44 | process.exit(code); 45 | }); 46 | 47 | // Handle process termination 48 | process.on('SIGINT', () => { 49 | console.log('\n🛑 Shutting down production server...'); 50 | child.kill('SIGINT'); 51 | }); 52 | 53 | process.on('SIGTERM', () => { 54 | console.log('\n🛑 Shutting down production server...'); 55 | child.kill('SIGTERM'); 56 | }); -------------------------------------------------------------------------------- /backend/runtime/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Minimal runtime abstraction layer 3 | * 4 | * Simple interfaces for abstracting runtime-specific operations 5 | * that are used in the backend application. 6 | */ 7 | 8 | import type { MiddlewareHandler } from "hono"; 9 | 10 | // Command execution result 11 | export interface CommandResult { 12 | success: boolean; 13 | stdout: string; 14 | stderr: string; 15 | code: number; 16 | } 17 | 18 | // File system information 19 | export interface FileStats { 20 | isFile: boolean; 21 | isDirectory: boolean; 22 | isSymlink: boolean; 23 | size: number; 24 | mtime: Date | null; 25 | } 26 | 27 | // Directory entry information 28 | export interface DirectoryEntry { 29 | name: string; 30 | isFile: boolean; 31 | isDirectory: boolean; 32 | isSymlink: boolean; 33 | } 34 | 35 | // Main runtime interface - minimal and focused 36 | export interface Runtime { 37 | // File operations 38 | readTextFile(path: string): Promise; 39 | readBinaryFile(path: string): Promise; 40 | exists(path: string): Promise; 41 | stat(path: string): Promise; 42 | lstat(path: string): Promise; 43 | lstatSync(path: string): FileStats; 44 | readDir(path: string): AsyncIterable; 45 | 46 | // Environment access 47 | getEnv(key: string): string | undefined; 48 | getArgs(): string[]; 49 | getPlatform(): "windows" | "darwin" | "linux"; 50 | getHomeDir(): string | undefined; 51 | exit(code: number): never; 52 | 53 | // Process execution 54 | runCommand(command: string, args: string[]): Promise; 55 | findExecutable(name: string): Promise; 56 | 57 | // HTTP server 58 | serve( 59 | port: number, 60 | hostname: string, 61 | handler: (req: Request) => Response | Promise, 62 | ): void; 63 | 64 | // Static file serving 65 | createStaticFileMiddleware(options: { root: string }): MiddlewareHandler; 66 | } 67 | -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "2x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "83.5x83.5" 82 | }, 83 | { 84 | "idiom" : "ios-marketing", 85 | "scale" : "1x", 86 | "size" : "1024x1024" 87 | } 88 | ], 89 | "info" : { 90 | "author" : "xcode", 91 | "version" : 1 92 | } 93 | } -------------------------------------------------------------------------------- /backend/scripts/dev-with-preload.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Development script with automatic preload script patching 5 | * This sets up the environment for Claude Code OAuth credential interception 6 | */ 7 | 8 | import { spawn } from 'child_process'; 9 | import { join } from 'path'; 10 | import { fileURLToPath } from 'url'; 11 | import { dirname } from 'path'; 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = dirname(__filename); 15 | 16 | // Set up environment variables for preload script patching 17 | const env = { 18 | ...process.env, 19 | NODE_OPTIONS: '--require ./auth/preload-script.cjs', 20 | CLAUDE_CREDENTIALS_PATH: join(process.env.HOME || process.cwd(), '.claude-credentials.json'), 21 | DEBUG_PRELOAD_SCRIPT: '1' 22 | }; 23 | 24 | console.log('🔧 Starting backend with Claude OAuth preload script patching...'); 25 | console.log('📁 Preload script:', './auth/preload-script.cjs'); 26 | console.log('🗄️ Credentials path:', env.CLAUDE_CREDENTIALS_PATH); 27 | console.log('🐛 Debug logging: enabled'); 28 | console.log(''); 29 | 30 | // Start the development server with dotenvx and tsx 31 | const child = spawn('npx', [ 32 | 'dotenvx', 33 | 'run', 34 | '--env-file=../.env', 35 | '--', 36 | 'tsx', 37 | 'watch', 38 | 'cli/node.ts', 39 | '--debug' 40 | ], { 41 | env, 42 | stdio: 'inherit', 43 | shell: true 44 | }); 45 | 46 | child.on('error', (error) => { 47 | console.error('❌ Failed to start development server:', error); 48 | process.exit(1); 49 | }); 50 | 51 | child.on('close', (code) => { 52 | console.log(`\n🛑 Development server exited with code ${code}`); 53 | process.exit(code); 54 | }); 55 | 56 | // Handle process termination 57 | process.on('SIGINT', () => { 58 | console.log('\n🛑 Shutting down development server...'); 59 | child.kill('SIGINT'); 60 | }); 61 | 62 | process.on('SIGTERM', () => { 63 | console.log('\n🛑 Shutting down development server...'); 64 | child.kill('SIGTERM'); 65 | }); -------------------------------------------------------------------------------- /backend/cli/node.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Node.js-specific entry point 4 | * 5 | * This module handles Node.js-specific initialization including CLI argument parsing, 6 | * Claude CLI validation, and server startup using the NodeRuntime. 7 | */ 8 | 9 | import { createApp } from "../app.ts"; 10 | import { NodeRuntime } from "../runtime/node.ts"; 11 | import { parseCliArgs } from "./args.ts"; 12 | import { validateClaudeCli } from "./validation.ts"; 13 | import { fileURLToPath } from "node:url"; 14 | import { dirname, join, relative } from "node:path"; 15 | 16 | async function main(runtime: NodeRuntime) { 17 | // Parse CLI arguments 18 | const args = parseCliArgs(runtime); 19 | 20 | // Validate Claude CLI availability and get the validated path 21 | const validatedClaudePath = await validateClaudeCli(runtime, args.claudePath); 22 | 23 | if (args.debug) { 24 | console.log("🐛 Debug mode enabled"); 25 | } 26 | 27 | // Calculate static path relative to current working directory 28 | // Node.js 20.11.0+ compatible with fallback for older versions 29 | const __dirname = 30 | import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); 31 | const staticAbsPath = join(__dirname, "../dist/static"); 32 | let staticRelPath = relative(process.cwd(), staticAbsPath); 33 | // Handle edge case where relative() returns empty string 34 | if (staticRelPath === "") { 35 | staticRelPath = "."; 36 | } 37 | 38 | // Create application 39 | const app = createApp(runtime, { 40 | debugMode: args.debug, 41 | staticPath: staticRelPath, 42 | claudePath: validatedClaudePath, 43 | }); 44 | 45 | // Start server (only show this message when everything is ready) 46 | console.log(`🚀 Server starting on ${args.host}:${args.port}`); 47 | runtime.serve(args.port, args.host, app.fetch); 48 | } 49 | 50 | // Run the application 51 | const runtime = new NodeRuntime(); 52 | main(runtime).catch((error) => { 53 | console.error("Failed to start server:", error); 54 | runtime.exit(1); 55 | }); 56 | -------------------------------------------------------------------------------- /backend/test-runner.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Simple test runner to verify our multi-agent system tests 5 | * This runs without external dependencies to simulate offline testing 6 | */ 7 | 8 | const { execSync } = require('child_process'); 9 | const path = require('path'); 10 | 11 | console.log('🧪 Running Multi-Agent System Tests...\n'); 12 | 13 | try { 14 | // Set environment variables for testing 15 | process.env.NODE_ENV = 'test'; 16 | 17 | // Change to backend directory 18 | process.chdir(__dirname); 19 | 20 | console.log('📦 Installing dependencies...'); 21 | // Only install if node_modules doesn't exist 22 | try { 23 | require('fs').statSync('node_modules'); 24 | console.log('✅ Dependencies already installed'); 25 | } catch { 26 | execSync('npm install', { stdio: 'inherit' }); 27 | } 28 | 29 | console.log('\n🔧 Running TypeScript checks...'); 30 | try { 31 | execSync('npx tsc --noEmit', { stdio: 'inherit' }); 32 | console.log('✅ TypeScript checks passed'); 33 | } catch (error) { 34 | console.log('⚠️ TypeScript warnings (continuing...)'); 35 | } 36 | 37 | console.log('\n🧪 Running unit tests...'); 38 | try { 39 | execSync('npx vitest run --reporter=verbose', { stdio: 'inherit' }); 40 | console.log('\n✅ All tests passed!'); 41 | } catch (error) { 42 | console.log('\n❌ Some tests failed'); 43 | throw error; 44 | } 45 | 46 | console.log('\n🎯 Test Summary:'); 47 | console.log('✅ Provider abstraction layer: OpenAI + Claude Code'); 48 | console.log('✅ Image handling: Screenshot capture & base64 encoding'); 49 | console.log('✅ Chat room protocol: Structured agent communication'); 50 | console.log('✅ Multi-agent workflow: UX analysis → Implementation'); 51 | console.log('✅ Offline testing: Mock implementations work correctly'); 52 | 53 | console.log('\n🚀 Multi-agent system is ready for deployment!'); 54 | 55 | } catch (error) { 56 | console.error('\n❌ Test run failed:', error.message); 57 | process.exit(1); 58 | } -------------------------------------------------------------------------------- /frontend/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * @see https://playwright.dev/docs/test-configuration 5 | */ 6 | export default defineConfig({ 7 | testDir: "./", 8 | testMatch: ["tests/**/*.spec.ts"], // Only actual tests, not recording scripts 9 | /* Run tests in files in parallel */ 10 | fullyParallel: true, 11 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 12 | forbidOnly: !!process.env.CI, 13 | /* Retry on CI only */ 14 | retries: process.env.CI ? 2 : 0, 15 | /* Opt out of parallel tests on CI. */ 16 | workers: process.env.CI ? 1 : undefined, 17 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 18 | reporter: "html", 19 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 20 | use: { 21 | /* Base URL to use in actions like `await page.goto('/')`. */ 22 | baseURL: "http://localhost:3000", 23 | 24 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 25 | trace: "on-first-retry", 26 | 27 | /* Video recording disabled - using manual control in recording scripts */ 28 | video: "off", 29 | 30 | /* Screenshot settings */ 31 | screenshot: "only-on-failure", 32 | }, 33 | 34 | /* Configure projects for major browsers */ 35 | projects: [ 36 | { 37 | name: "chromium", 38 | use: { 39 | ...devices["Desktop Chrome"], 40 | viewport: { width: 1280, height: 720 }, 41 | // Disable web security for demo recording 42 | launchOptions: { 43 | args: [ 44 | "--disable-web-security", 45 | "--disable-features=VizDisplayCompositor", 46 | ], 47 | }, 48 | }, 49 | }, 50 | ], 51 | 52 | /* Run your local dev server before starting the tests */ 53 | webServer: { 54 | command: "npm run dev", 55 | url: "http://localhost:3000", 56 | reuseExistingServer: !process.env.CI, 57 | timeout: 120 * 1000, 58 | }, 59 | }); 60 | -------------------------------------------------------------------------------- /frontend/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | // UI Constants 2 | export const UI_CONSTANTS = { 3 | NEAR_BOTTOM_THRESHOLD_PX: 100, 4 | TEXTAREA_MAX_HEIGHT: 200, 5 | } as const; 6 | 7 | // Keyboard shortcuts 8 | export const KEYBOARD_SHORTCUTS = { 9 | ABORT: "Escape", 10 | SUBMIT: "Enter", 11 | } as const; 12 | 13 | // Message display constants 14 | export const MESSAGE_CONSTANTS = { 15 | MAX_DISPLAY_WIDTH: { 16 | MOBILE: "85%", 17 | DESKTOP: "70%", 18 | }, 19 | SUMMARY_MAX_LENGTH: 50, 20 | SESSION_ID_DISPLAY_LENGTH: 8, 21 | } as const; 22 | 23 | // Tool-related constants 24 | export const TOOL_CONSTANTS = { 25 | MULTI_WORD_COMMANDS: ["cargo", "git", "npm", "yarn", "docker"], 26 | WILDCARD_COMMAND: "*", 27 | DEFAULT_TOOL_NAME: "Unknown", 28 | // Bash builtin commands that don't require permission (conservative list) 29 | // Only include commands that are purely internal navigation/state and never need external access 30 | BASH_BUILTINS: [ 31 | "cd", // Change directory - internal navigation only 32 | "pwd", // Print working directory - internal state only 33 | "export", // Set environment variables - internal state only 34 | "unset", // Unset environment variables - internal state only 35 | "alias", // Set command aliases - internal state only 36 | "unalias", // Remove command aliases - internal state only 37 | "history", // Show command history - internal state only 38 | "jobs", // List active jobs - internal state only 39 | "bg", // Move jobs to background - internal process control 40 | "fg", // Move jobs to foreground - internal process control 41 | "exit", // Exit shell - internal control 42 | "return", // Return from function - internal control 43 | "shift", // Shift positional parameters - internal control 44 | "break", // Break from loop - internal control 45 | "continue", // Continue loop - internal control 46 | "which", // Which command - safe builtin that doesn't require permission 47 | ], 48 | // Command separators for compound commands 49 | COMMAND_SEPARATORS: ["&&", "||", ";", "|"], 50 | } as const; 51 | -------------------------------------------------------------------------------- /frontend/src/config/agents.ts: -------------------------------------------------------------------------------- 1 | export interface Agent { 2 | id: string; 3 | name: string; 4 | workingDirectory: string; 5 | color: string; 6 | description: string; 7 | isOrchestrator?: boolean; // Indicates if this agent orchestrates others 8 | } 9 | 10 | export const PREDEFINED_AGENTS: Agent[] = [ 11 | { 12 | id: "orchestrator", 13 | name: "Orchestrator Agent", 14 | workingDirectory: "/tmp/orchestrator", 15 | color: "bg-gradient-to-r from-blue-500 to-purple-500", 16 | description: "Intelligent orchestrator that coordinates multi-agent workflows", 17 | isOrchestrator: true 18 | } 19 | ]; 20 | 21 | export const getAgentById = (id: string): Agent | undefined => { 22 | return PREDEFINED_AGENTS.find(agent => agent.id === id); 23 | }; 24 | 25 | export const getAgentByName = (name: string): Agent | undefined => { 26 | return PREDEFINED_AGENTS.find(agent => 27 | agent.name.toLowerCase() === name.toLowerCase() 28 | ); 29 | }; 30 | 31 | export const parseAgentMention = (message: string): { agentId: string | null; cleanMessage: string } => { 32 | // Check for multiple agent mentions - if found, use orchestrator 33 | const allMentions = message.match(/@(\w+(?:-\w+)*)/g); 34 | if (allMentions && allMentions.length > 1) { 35 | // Multiple mentions - should use orchestrator, don't clean the message 36 | return { agentId: "orchestrator", cleanMessage: message }; 37 | } 38 | 39 | // Single mention at start - extract and clean 40 | const mentionMatch = message.match(/^@(\w+(?:-\w+)*)\s+(.*)$/); 41 | if (mentionMatch) { 42 | const [, agentId, cleanMessage] = mentionMatch; 43 | const agent = getAgentById(agentId); 44 | if (agent) { 45 | return { agentId: agent.id, cleanMessage }; 46 | } 47 | } 48 | return { agentId: null, cleanMessage: message }; 49 | }; 50 | 51 | export const getOrchestratorAgent = (): Agent | undefined => { 52 | return PREDEFINED_AGENTS.find(agent => agent.isOrchestrator); 53 | }; 54 | 55 | export const getWorkerAgents = (): Agent[] => { 56 | return PREDEFINED_AGENTS.filter(agent => !agent.isOrchestrator); 57 | }; -------------------------------------------------------------------------------- /backend/cli/args.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CLI argument parsing using runtime abstraction 3 | * 4 | * Handles command-line argument parsing in a runtime-agnostic way. 5 | */ 6 | 7 | import { program } from "commander"; 8 | import type { Runtime } from "../runtime/types.ts"; 9 | import { VERSION } from "./version.ts"; 10 | 11 | export interface ParsedArgs { 12 | debug: boolean; 13 | port: number; 14 | host: string; 15 | claudePath?: string; 16 | } 17 | 18 | export function parseCliArgs(runtime: Runtime): ParsedArgs { 19 | // Use version from auto-generated version.ts file 20 | const version = VERSION; 21 | 22 | // Get default port from environment 23 | const defaultPort = parseInt(runtime.getEnv("PORT") || "8080", 10); 24 | 25 | // Configure program 26 | program 27 | .name("claude-code-webui") 28 | .version(version, "-v, --version", "display version number") 29 | .description("Claude Code Web Agent Backend Server") 30 | .option( 31 | "-p, --port ", 32 | "Port to listen on", 33 | (value) => { 34 | const parsed = parseInt(value, 10); 35 | if (isNaN(parsed)) { 36 | throw new Error(`Invalid port number: ${value}`); 37 | } 38 | return parsed; 39 | }, 40 | defaultPort, 41 | ) 42 | .option( 43 | "--host ", 44 | "Host address to bind to (use 0.0.0.0 for all interfaces)", 45 | "0.0.0.0", 46 | ) 47 | .option( 48 | "--claude-path ", 49 | "Path to claude executable (overrides automatic detection)", 50 | ) 51 | .option("-d, --debug", "Enable debug mode", false); 52 | 53 | // Parse arguments - Commander.js v14 handles this automatically 54 | program.parse(runtime.getArgs(), { from: "user" }); 55 | const options = program.opts(); 56 | 57 | // Handle DEBUG environment variable manually 58 | const debugEnv = runtime.getEnv("DEBUG"); 59 | const debugFromEnv = debugEnv?.toLowerCase() === "true" || debugEnv === "1"; 60 | 61 | return { 62 | debug: options.debug || debugFromEnv, 63 | port: options.port, 64 | host: options.host, 65 | claudePath: options.claudePath, 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /backend/tests/node/runtime.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Node.js Runtime Basic Functionality Test 3 | * 4 | * Simple test to verify that the NodeRuntime implementation 5 | * works correctly in a Node.js environment. 6 | */ 7 | 8 | import { describe, it, expect } from "vitest"; 9 | import { NodeRuntime } from "../../runtime/node.js"; 10 | 11 | describe("Node.js Runtime", () => { 12 | const runtime = new NodeRuntime(); 13 | 14 | it("should implement all required interface methods", () => { 15 | const requiredMethods = [ 16 | "readTextFile", 17 | "readBinaryFile", 18 | "exists", 19 | "stat", 20 | "lstat", 21 | "lstatSync", 22 | "readDir", 23 | "getEnv", 24 | "getArgs", 25 | "exit", 26 | "runCommand", 27 | "serve", 28 | ]; 29 | 30 | for (const method of requiredMethods) { 31 | expect( 32 | typeof (runtime as unknown as Record)[method], 33 | ).toBe("function"); 34 | } 35 | }); 36 | 37 | it("should access environment variables", () => { 38 | const path = runtime.getEnv("PATH"); 39 | expect(typeof path).toBe("string"); 40 | expect(path!.length).toBeGreaterThan(0); 41 | }); 42 | 43 | it("should return command line arguments as array", () => { 44 | const args = runtime.getArgs(); 45 | expect(Array.isArray(args)).toBe(true); 46 | }); 47 | 48 | it("should check file existence", async () => { 49 | const exists = await runtime.exists("package.json"); 50 | expect(exists).toBe(true); 51 | }); 52 | 53 | it("should read files asynchronously", async () => { 54 | const content = await runtime.readTextFile("package.json"); 55 | expect(typeof content).toBe("string"); 56 | expect(content.length).toBeGreaterThan(0); 57 | 58 | // Verify it's actually JSON 59 | const parsed = JSON.parse(content); 60 | expect(parsed.name).toBe("agentrooms"); 61 | }); 62 | 63 | it("should execute commands", async () => { 64 | const result = await runtime.runCommand("echo", ["test"]); 65 | expect(typeof result.success).toBe("boolean"); 66 | expect(typeof result.stdout).toBe("string"); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: 13 | 14 | ```js 15 | export default tseslint.config({ 16 | extends: [ 17 | // Remove ...tseslint.configs.recommended and replace with this 18 | ...tseslint.configs.recommendedTypeChecked, 19 | // Alternatively, use this for stricter rules 20 | ...tseslint.configs.strictTypeChecked, 21 | // Optionally, add this for stylistic rules 22 | ...tseslint.configs.stylisticTypeChecked, 23 | ], 24 | languageOptions: { 25 | // other options... 26 | parserOptions: { 27 | project: ["./tsconfig.node.json", "./tsconfig.app.json"], 28 | tsconfigRootDir: import.meta.dirname, 29 | }, 30 | }, 31 | }); 32 | ``` 33 | 34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: 35 | 36 | ```js 37 | // eslint.config.js 38 | import reactX from "eslint-plugin-react-x"; 39 | import reactDom from "eslint-plugin-react-dom"; 40 | 41 | export default tseslint.config({ 42 | plugins: { 43 | // Add the react-x and react-dom plugins 44 | "react-x": reactX, 45 | "react-dom": reactDom, 46 | }, 47 | rules: { 48 | // other rules... 49 | // Enable its recommended typescript rules 50 | ...reactX.configs["recommended-typescript"].rules, 51 | ...reactDom.configs.recommended.rules, 52 | }, 53 | }); 54 | ``` 55 | -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub/Models/Project.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Project: Codable, Identifiable, Hashable { 4 | let id: String 5 | let path: String 6 | let encodedName: String 7 | let name: String 8 | 9 | init(path: String, encodedName: String) { 10 | self.id = encodedName 11 | self.path = path 12 | self.encodedName = encodedName 13 | 14 | // Extract directory name from path for display 15 | let url = URL(fileURLWithPath: path) 16 | self.name = url.lastPathComponent 17 | } 18 | 19 | static let sampleProjects: [Project] = [ 20 | Project(path: "/Users/user/projects/my-app", encodedName: "my-app"), 21 | Project(path: "/Users/user/projects/backend-api", encodedName: "backend-api"), 22 | Project(path: "/Users/user/projects/mobile-app", encodedName: "mobile-app") 23 | ] 24 | } 25 | 26 | struct ProjectsResponse: Codable { 27 | let projects: [Project] 28 | } 29 | 30 | struct ConversationSummary: Codable, Identifiable { 31 | let id: String 32 | let sessionId: String 33 | let startTime: String 34 | let lastTime: String 35 | let messageCount: Int 36 | let lastMessagePreview: String 37 | 38 | init(sessionId: String, startTime: String, lastTime: String, messageCount: Int, lastMessagePreview: String) { 39 | self.id = sessionId 40 | self.sessionId = sessionId 41 | self.startTime = startTime 42 | self.lastTime = lastTime 43 | self.messageCount = messageCount 44 | self.lastMessagePreview = lastMessagePreview 45 | } 46 | 47 | var startDate: Date? { 48 | return ISO8601DateFormatter().date(from: startTime) 49 | } 50 | 51 | var lastDate: Date? { 52 | return ISO8601DateFormatter().date(from: lastTime) 53 | } 54 | } 55 | 56 | struct HistoryListResponse: Codable { 57 | let conversations: [ConversationSummary] 58 | } 59 | 60 | struct ConversationHistory: Codable { 61 | let sessionId: String 62 | let messages: [ChatMessage] 63 | let metadata: ConversationMetadata 64 | } 65 | 66 | struct ConversationMetadata: Codable { 67 | let startTime: String 68 | let endTime: String? 69 | let messageCount: Int 70 | } -------------------------------------------------------------------------------- /backend/handlers/projects.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import type { ProjectInfo, ProjectsResponse } from "../../shared/types.ts"; 3 | import { getEncodedProjectName } from "../history/pathUtils.ts"; 4 | 5 | /** 6 | * Handles GET /api/projects requests 7 | * Retrieves list of available project directories from Claude configuration 8 | * @param c - Hono context object 9 | * @returns JSON response with projects array 10 | */ 11 | export async function handleProjectsRequest(c: Context) { 12 | try { 13 | const { runtime } = c.var.config; 14 | 15 | const homeDir = runtime.getHomeDir(); 16 | if (!homeDir) { 17 | return c.json({ error: "Home directory not found" }, 500); 18 | } 19 | 20 | const claudeConfigPath = `${homeDir}/.claude.json`; 21 | 22 | try { 23 | const configContent = await runtime.readTextFile(claudeConfigPath); 24 | const config = JSON.parse(configContent); 25 | 26 | if (config.projects && typeof config.projects === "object") { 27 | const projectPaths = Object.keys(config.projects); 28 | 29 | // Get encoded names for each project, only include projects with history 30 | const projects: ProjectInfo[] = []; 31 | for (const path of projectPaths) { 32 | const encodedName = await getEncodedProjectName(path, runtime); 33 | // Only include projects that have history directories 34 | if (encodedName) { 35 | projects.push({ 36 | path, 37 | encodedName, 38 | }); 39 | } 40 | } 41 | 42 | const response: ProjectsResponse = { projects }; 43 | return c.json(response); 44 | } else { 45 | const response: ProjectsResponse = { projects: [] }; 46 | return c.json(response); 47 | } 48 | } catch (error) { 49 | // Handle file not found errors in a cross-platform way 50 | if (error instanceof Error && error.message.includes("No such file")) { 51 | const response: ProjectsResponse = { projects: [] }; 52 | return c.json(response); 53 | } 54 | throw error; 55 | } 56 | } catch (error) { 57 | console.error("Error reading projects:", error); 58 | return c.json({ error: "Failed to read projects" }, 500); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | export interface StreamResponse { 2 | type: "claude_json" | "error" | "done" | "aborted"; 3 | data?: unknown; // SDKMessage object for claude_json type 4 | error?: string; 5 | } 6 | 7 | export interface ChatRequest { 8 | message: string; 9 | sessionId?: string; 10 | requestId: string; 11 | allowedTools?: string[]; 12 | workingDirectory?: string; 13 | claudeAuth?: { 14 | accessToken: string; 15 | refreshToken: string; 16 | expiresAt: number; 17 | userId: string; 18 | subscriptionType: string; 19 | account: { 20 | email_address: string; 21 | uuid: string; 22 | }; 23 | }; 24 | availableAgents?: Array<{ 25 | id: string; 26 | name: string; 27 | description: string; 28 | workingDirectory: string; 29 | apiEndpoint: string; 30 | isOrchestrator?: boolean; 31 | }>; 32 | } 33 | 34 | export interface AbortRequest { 35 | requestId: string; 36 | } 37 | 38 | export interface ProjectInfo { 39 | path: string; 40 | encodedName: string; 41 | } 42 | 43 | export interface ProjectsResponse { 44 | projects: ProjectInfo[]; 45 | } 46 | 47 | // Conversation history types 48 | export interface ConversationSummary { 49 | sessionId: string; 50 | startTime: string; 51 | lastTime: string; 52 | messageCount: number; 53 | lastMessagePreview: string; 54 | agentId?: string; // Agent that created this conversation 55 | } 56 | 57 | export interface HistoryListResponse { 58 | conversations: ConversationSummary[]; 59 | } 60 | 61 | // Remote agent history types 62 | export interface RemoteAgentProjectsResponse { 63 | projects: ProjectInfo[]; 64 | agentId: string; 65 | } 66 | 67 | export interface RemoteAgentHistoryResponse { 68 | conversations: ConversationSummary[]; 69 | agentId: string; 70 | } 71 | 72 | // Conversation history types 73 | // Note: messages are typed as unknown[] to avoid frontend/backend dependency issues 74 | // Frontend should cast to TimestampedSDKMessage[] (defined in frontend/src/types.ts) 75 | export interface ConversationHistory { 76 | sessionId: string; 77 | messages: unknown[]; // TimestampedSDKMessage[] in practice, but avoiding frontend type dependency 78 | metadata: { 79 | startTime: string; 80 | endTime: string; 81 | messageCount: number; 82 | agentId?: string; // Agent that created this conversation 83 | }; 84 | } 85 | 86 | -------------------------------------------------------------------------------- /frontend/src/utils/streamingDebug.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Streaming debug utilities for diagnosing cloud deployment issues 3 | */ 4 | 5 | export const STREAMING_DEBUG = import.meta.env.VITE_STREAMING_DEBUG === 'true'; 6 | 7 | export function debugStreamingPerformance(startTime: number, firstResponseTime?: number, endTime?: number) { 8 | if (!STREAMING_DEBUG) return; 9 | 10 | const now = Date.now(); 11 | console.group('🌊 Streaming Performance Debug'); 12 | 13 | if (firstResponseTime) { 14 | console.log(`⏱️ Time to first response: ${firstResponseTime - startTime}ms`); 15 | } 16 | 17 | if (endTime) { 18 | console.log(`⏱️ Total request time: ${endTime - startTime}ms`); 19 | } else { 20 | console.log(`⏱️ Current request time: ${now - startTime}ms`); 21 | } 22 | 23 | console.groupEnd(); 24 | } 25 | 26 | export function debugStreamingConnection(url: string, headers: HeadersInit) { 27 | if (!STREAMING_DEBUG) return; 28 | 29 | console.group('🔗 Streaming Connection Debug'); 30 | console.log(`📡 URL: ${url}`); 31 | console.log(`📋 Headers:`, headers); 32 | console.groupEnd(); 33 | } 34 | 35 | export function debugStreamingChunk(chunk: string, lineCount: number) { 36 | if (!STREAMING_DEBUG) return; 37 | 38 | console.group('📦 Streaming Chunk Debug'); 39 | console.log(`📏 Chunk size: ${chunk.length} bytes`); 40 | console.log(`📝 Line count: ${lineCount}`); 41 | console.log(`🔍 First 100 chars: ${chunk.substring(0, 100)}`); 42 | console.groupEnd(); 43 | } 44 | 45 | export function debugStreamingLatency(messageType: string, timestamp: number) { 46 | if (!STREAMING_DEBUG) return; 47 | 48 | const now = Date.now(); 49 | const latency = now - timestamp; 50 | 51 | if (latency > 1000) { 52 | console.warn(`⚠️ High latency detected for ${messageType}: ${latency}ms`); 53 | } else { 54 | console.log(`⚡ ${messageType} latency: ${latency}ms`); 55 | } 56 | } 57 | 58 | export function warnProxyBuffering(detectionTime: number) { 59 | console.group('⚠️ Streaming Issue Detected'); 60 | console.warn(`Proxy buffering suspected - no streaming detected within ${detectionTime}ms`); 61 | console.log('💡 Possible solutions:'); 62 | console.log(' • Check NGINX/proxy configuration'); 63 | console.log(' • Verify CDN settings'); 64 | console.log(' • Check cloud platform streaming support'); 65 | console.log(' • See STREAMING_DEPLOYMENT.md for details'); 66 | console.groupEnd(); 67 | } -------------------------------------------------------------------------------- /frontend/src/components/native/MessageBubble.tsx: -------------------------------------------------------------------------------- 1 | import { User, Bot } from "lucide-react"; 2 | import type { ChatMessage } from "../../types"; 3 | import { useAgentConfig } from "../../hooks/useAgentConfig"; 4 | 5 | interface MessageBubbleProps { 6 | message: ChatMessage; 7 | isLast?: boolean; 8 | } 9 | 10 | const getAgentColor = (agentId: string) => { 11 | // Map agent IDs to CSS color variables, with fallback 12 | const colorMap: Record = { 13 | "readymojo-admin": "var(--agent-admin)", 14 | "readymojo-api": "var(--agent-api)", 15 | "readymojo-web": "var(--agent-web)", 16 | "peakmojo-kit": "var(--agent-kit)", 17 | }; 18 | return colorMap[agentId] || "var(--claude-text-accent)"; 19 | }; 20 | 21 | export function MessageBubble({ message, isLast = false }: MessageBubbleProps) { 22 | const isUser = message.role === "user"; 23 | const { getAgentById } = useAgentConfig(); 24 | const agent = message.agentId ? getAgentById(message.agentId) : null; 25 | 26 | const formatTime = (timestamp: number) => { 27 | return new Date(timestamp).toLocaleTimeString('en-US', { 28 | hour12: false, 29 | hour: '2-digit', 30 | minute: '2-digit' 31 | }); 32 | }; 33 | 34 | return ( 35 |
36 | {/* Avatar */} 37 |
43 | {isUser ? : } 44 |
45 | 46 | {/* Message Content */} 47 |
48 | {/* Header */} 49 |
50 | 51 | {isUser ? "You" : agent?.name || "Claude"} 52 | 53 | 54 | {formatTime(message.timestamp)} 55 | 56 | {agent && ( 57 | 58 | @{agent.id} 59 | 60 | )} 61 |
62 | 63 | {/* Message Body */} 64 |
65 |
66 | {message.content} 67 |
68 |
69 |
70 |
71 | ); 72 | } -------------------------------------------------------------------------------- /frontend/src/utils/id.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ID generation utility that works in both secure and non-secure contexts 3 | * 4 | * crypto.randomUUID() requires HTTPS in non-localhost environments. 5 | * This provides fallbacks for HTTP connections on local networks. 6 | */ 7 | 8 | /** 9 | * Generate a unique ID string 10 | * 11 | * Fallback hierarchy: 12 | * 1. crypto.randomUUID() - Most secure (HTTPS + localhost only) 13 | * 2. crypto.getRandomValues() - Secure random (works on HTTP) 14 | * 3. Math.random() - Pseudorandom fallback 15 | * 16 | * Note: Only the first method produces true UUID v4. 17 | * Others generate UUID-format strings with varying security levels. 18 | */ 19 | export function generateId(): string { 20 | // 1st choice: crypto.randomUUID() (HTTPS + localhost only) 21 | if (typeof crypto !== "undefined" && crypto.randomUUID) { 22 | try { 23 | return crypto.randomUUID(); 24 | } catch { 25 | console.debug( 26 | "crypto.randomUUID() not available, trying crypto.getRandomValues()", 27 | ); 28 | } 29 | } 30 | 31 | // 2nd choice: crypto.getRandomValues() (more secure than Math.random) 32 | if (typeof crypto !== "undefined" && crypto.getRandomValues) { 33 | try { 34 | const array = new Uint8Array(16); 35 | crypto.getRandomValues(array); 36 | 37 | // Set version (4) and variant bits according to UUID v4 spec 38 | array[6] = (array[6] & 0x0f) | 0x40; // Version 4 39 | array[8] = (array[8] & 0x3f) | 0x80; // Variant bits 40 | 41 | // Convert to hex string with dashes 42 | const hex = Array.from(array) 43 | .map((b) => b.toString(16).padStart(2, "0")) 44 | .join(""); 45 | 46 | return [ 47 | hex.slice(0, 8), 48 | hex.slice(8, 12), 49 | hex.slice(12, 16), 50 | hex.slice(16, 20), 51 | hex.slice(20, 32), 52 | ].join("-"); 53 | } catch { 54 | console.debug( 55 | "crypto.getRandomValues() not available, using Math.random() fallback", 56 | ); 57 | } 58 | } 59 | 60 | // 3rd choice: Math.random() fallback (least secure) 61 | console.debug( 62 | "Using Math.random() for ID generation - not cryptographically secure", 63 | ); 64 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { 65 | const r = (Math.random() * 16) | 0; 66 | const v = c === "x" ? r : (r & 0x3) | 0x8; 67 | return v.toString(16); 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /scripts/build-windows.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Windows Build Script for Agentrooms 4 | # This script handles the Wine dependency for cross-platform Windows builds on Linux 5 | 6 | echo "=== Agentrooms Windows Build Script ===" 7 | echo "" 8 | 9 | # Check if Wine is installed 10 | if command -v wine64 &> /dev/null || command -v wine &> /dev/null; then 11 | echo "✓ Wine is already installed" 12 | WINE_INSTALLED=true 13 | else 14 | echo "✗ Wine not found" 15 | WINE_INSTALLED=false 16 | fi 17 | 18 | # Function to install Wine 19 | install_wine() { 20 | echo "" 21 | echo "Installing Wine (requires sudo)..." 22 | 23 | # Update package list 24 | sudo apt update 25 | 26 | # Install Wine 27 | sudo apt install -y wine64 28 | 29 | if command -v wine64 &> /dev/null; then 30 | echo "✓ Wine installed successfully" 31 | return 0 32 | else 33 | echo "✗ Failed to install Wine" 34 | return 1 35 | fi 36 | } 37 | 38 | # Function to run the Windows build 39 | run_build() { 40 | echo "" 41 | echo "Building Windows distribution..." 42 | npm run dist:win 43 | 44 | if [ $? -eq 0 ]; then 45 | echo "" 46 | echo "✓ Windows build completed successfully!" 47 | echo " Output directory: ./dist/" 48 | echo " Files created:" 49 | ls -la dist/ | grep -E '\.(exe|zip)$' || echo " (check dist/ directory for build artifacts)" 50 | else 51 | echo "" 52 | echo "✗ Windows build failed" 53 | return 1 54 | fi 55 | } 56 | 57 | # Main execution 58 | if [ "$WINE_INSTALLED" = false ]; then 59 | echo "" 60 | echo "Wine is required for cross-platform Windows builds on Linux." 61 | echo "Options:" 62 | echo " 1. Install Wine automatically (requires sudo)" 63 | echo " 2. Install Wine manually then re-run this script" 64 | echo " 3. Build on a Windows machine instead" 65 | echo "" 66 | read -p "Install Wine automatically? (y/n): " -n 1 -r 67 | echo "" 68 | 69 | if [[ $REPLY =~ ^[Yy]$ ]]; then 70 | if install_wine; then 71 | run_build 72 | fi 73 | else 74 | echo "" 75 | echo "To install Wine manually, run:" 76 | echo " sudo apt update" 77 | echo " sudo apt install -y wine64" 78 | echo "" 79 | echo "Then re-run this script or run: npm run dist:win" 80 | exit 1 81 | fi 82 | else 83 | run_build 84 | fi -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | backend: 11 | name: Backend 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [20, 22, 24] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: "npm" 25 | cache-dependency-path: backend/package-lock.json 26 | 27 | - name: Install backend dependencies 28 | run: cd backend && npm ci 29 | 30 | - name: Generate version.ts 31 | run: cd backend && node scripts/generate-version.js 32 | 33 | - name: Setup Deno 34 | uses: denoland/setup-deno@v2 35 | with: 36 | deno-version: v2.x 37 | 38 | - name: Install and cache Deno dependencies 39 | run: cd backend && deno install && deno cache cli/deno.ts 40 | 41 | - name: Lint 42 | run: cd backend && npm run lint 43 | 44 | - name: Type check 45 | run: cd backend && npm run typecheck 46 | 47 | frontend: 48 | name: Frontend 49 | runs-on: ubuntu-latest 50 | strategy: 51 | matrix: 52 | node-version: [20, 22, 24] 53 | 54 | steps: 55 | - uses: actions/checkout@v4 56 | 57 | - name: Setup Node.js 58 | uses: actions/setup-node@v4 59 | with: 60 | node-version: ${{ matrix.node-version }} 61 | cache: "npm" 62 | cache-dependency-path: frontend/package-lock.json 63 | 64 | - name: Install dependencies 65 | run: cd frontend && npm ci 66 | 67 | - name: Lint 68 | run: cd frontend && npm run lint 69 | 70 | - name: Type check 71 | run: cd frontend && npm run typecheck 72 | 73 | - name: Build 74 | run: cd frontend && npm run build 75 | 76 | # Summary job for branch protection rules 77 | ci-success: 78 | name: CI Success 79 | runs-on: ubuntu-latest 80 | needs: [backend, frontend] 81 | if: always() 82 | steps: 83 | - name: Check all jobs 84 | run: | 85 | if [[ "${{ needs.backend.result }}" == "success" && "${{ needs.frontend.result }}" == "success" ]]; then 86 | echo "All CI jobs passed" 87 | exit 0 88 | else 89 | echo "Some CI jobs failed" 90 | exit 1 91 | fi 92 | -------------------------------------------------------------------------------- /frontend/src/components/chat/AgentSelector.tsx: -------------------------------------------------------------------------------- 1 | import { PREDEFINED_AGENTS } from "../../config/agents"; 2 | 3 | interface AgentSelectorProps { 4 | activeAgentId: string | null; 5 | onAgentSelect: (agentId: string) => void; 6 | agentSessions: Record; 7 | } 8 | 9 | export function AgentSelector({ activeAgentId, onAgentSelect, agentSessions }: AgentSelectorProps) { 10 | return ( 11 |
12 |
13 |

Agents

14 |

Click an agent to switch, or use @agent-name in your message

15 |
16 |
17 | {PREDEFINED_AGENTS.map((agent) => { 18 | const isActive = activeAgentId === agent.id; 19 | const hasMessages = agentSessions[agent.id]?.messages.length > 0; 20 | const messageCount = agentSessions[agent.id]?.messages.length || 0; 21 | 22 | return ( 23 | 50 | ); 51 | })} 52 |
53 | 54 | {activeAgentId && ( 55 |
56 | Active: {PREDEFINED_AGENTS.find(a => a.id === activeAgentId)?.description} 57 |
58 | )} 59 |
60 | ); 61 | } -------------------------------------------------------------------------------- /backend/handlers/agentProjects.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import type { ProjectInfo, ProjectsResponse } from "../../shared/types.ts"; 3 | import { getEncodedProjectName } from "../history/pathUtils.ts"; 4 | 5 | /** 6 | * Handles GET /api/agent-projects requests 7 | * Retrieves list of available project directories from Claude configuration for agent endpoints 8 | * @param c - Hono context object 9 | * @returns JSON response with projects array 10 | */ 11 | export async function handleAgentProjectsRequest(c: Context) { 12 | console.log("🔍 handleAgentProjectsRequest called"); 13 | try { 14 | const { runtime } = c.var.config; 15 | 16 | const homeDir = runtime.getHomeDir(); 17 | if (!homeDir) { 18 | return c.json({ error: "Home directory not found" }, 500); 19 | } 20 | 21 | const claudeConfigPath = `${homeDir}/.claude.json`; 22 | 23 | try { 24 | const configContent = await runtime.readTextFile(claudeConfigPath); 25 | const config = JSON.parse(configContent); 26 | 27 | if (config.projects && typeof config.projects === "object") { 28 | const projectPaths = Object.keys(config.projects); 29 | 30 | // Get encoded names for each project, only include projects with history 31 | const projects: ProjectInfo[] = []; 32 | for (const path of projectPaths) { 33 | const encodedName = await getEncodedProjectName(path, runtime); 34 | // Only include projects that have history directories 35 | if (encodedName) { 36 | projects.push({ 37 | path, 38 | encodedName, 39 | }); 40 | } 41 | } 42 | 43 | const response: ProjectsResponse = { projects }; 44 | console.log("🔍 Returning projects:", projects.length, "projects"); 45 | console.log("🔍 Projects:", projects); 46 | return c.json(response); 47 | } else { 48 | console.log("🔍 No projects found in config"); 49 | const response: ProjectsResponse = { projects: [] }; 50 | return c.json(response); 51 | } 52 | } catch (error) { 53 | // Handle file not found errors in a cross-platform way 54 | if (error instanceof Error && error.message.includes("No such file")) { 55 | const response: ProjectsResponse = { projects: [] }; 56 | return c.json(response); 57 | } 58 | throw error; 59 | } 60 | } catch (error) { 61 | console.error("Error reading agent projects:", error); 62 | return c.json({ error: "Failed to read agent projects" }, 500); 63 | } 64 | } -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@beta 36 | with: 37 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 | 39 | # This is an optional setting that allows Claude to read CI results on PRs 40 | additional_permissions: | 41 | actions: read 42 | 43 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 44 | # model: "claude-opus-4-20250514" 45 | 46 | # Optional: Customize the trigger phrase (default: @claude) 47 | # trigger_phrase: "/claude" 48 | 49 | # Optional: Trigger when specific user is assigned to an issue 50 | # assignee_trigger: "claude-bot" 51 | 52 | # Optional: Allow Claude to run specific commands 53 | # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" 54 | 55 | # Optional: Add custom instructions for Claude to customize its behavior for your project 56 | # custom_instructions: | 57 | # Follow our coding standards 58 | # Ensure all new code has tests 59 | # Use TypeScript for new files 60 | 61 | # Optional: Custom environment variables for Claude 62 | # claude_env: | 63 | # NODE_ENV: test 64 | 65 | -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from "@testing-library/react"; 2 | import { describe, it, expect, vi, beforeEach } from "vitest"; 3 | import { MemoryRouter, Routes, Route } from "react-router-dom"; 4 | import { ProjectSelector } from "./components/ProjectSelector"; 5 | import { AgentHubPage } from "./components/native/AgentHubPage"; 6 | import { EnterBehaviorProvider } from "./contexts/EnterBehaviorContext"; 7 | 8 | // Mock fetch globally 9 | global.fetch = vi.fn(); 10 | 11 | describe("App Routing", () => { 12 | beforeEach(() => { 13 | vi.clearAllMocks(); 14 | // Mock projects API response 15 | (global.fetch as ReturnType).mockResolvedValue({ 16 | ok: true, 17 | json: () => Promise.resolve({ projects: [] }), 18 | }); 19 | }); 20 | 21 | it("renders project selection page at root path", async () => { 22 | render( 23 | 24 | 25 | } /> 26 | 27 | , 28 | ); 29 | 30 | await waitFor(() => { 31 | expect(screen.getByText("Select a Project")).toBeInTheDocument(); 32 | }); 33 | }); 34 | 35 | it("renders agent hub page when navigating to projects path", () => { 36 | // Mock the agent config 37 | vi.mock('./config/agentConfig.json', () => ({ 38 | default: { 39 | agents: [ 40 | { 41 | id: "test-agent", 42 | name: "Test Agent", 43 | description: "Test Description", 44 | workingDirectory: "/test", 45 | apiEndpoint: "http://localhost:8080", 46 | isOrchestrator: false 47 | } 48 | ] 49 | } 50 | })); 51 | 52 | render( 53 | 54 | 55 | 56 | } /> 57 | 58 | 59 | , 60 | ); 61 | 62 | // Just check that the component renders without error 63 | expect(screen.getByRole("main")).toBeInTheDocument(); 64 | }); 65 | 66 | it("shows new directory selection button", async () => { 67 | render( 68 | 69 | 70 | } /> 71 | 72 | , 73 | ); 74 | 75 | await waitFor(() => { 76 | expect(screen.getByText("Select New Directory")).toBeInTheDocument(); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /backend/providers/types.ts: -------------------------------------------------------------------------------- 1 | export interface AgentProvider { 2 | readonly id: string; 3 | readonly name: string; 4 | readonly type: "openai" | "anthropic" | "claude-code"; 5 | 6 | /** 7 | * Execute a chat request with this provider 8 | * @param request - The chat request 9 | * @param options - Provider-specific options 10 | * @returns Async generator of streaming responses 11 | */ 12 | executeChat( 13 | request: ProviderChatRequest, 14 | options?: ProviderOptions 15 | ): AsyncGenerator; 16 | 17 | /** 18 | * Check if provider supports image analysis 19 | */ 20 | supportsImages(): boolean; 21 | } 22 | 23 | export interface ProviderChatRequest { 24 | message: string; 25 | sessionId?: string; 26 | requestId: string; 27 | workingDirectory?: string; 28 | images?: ProviderImage[]; 29 | context?: ProviderContext[]; 30 | } 31 | 32 | export interface ProviderImage { 33 | type: "base64" | "url"; 34 | data: string; // base64 data or URL 35 | mimeType: string; // image/png, image/jpeg, etc. 36 | } 37 | 38 | export interface ProviderContext { 39 | role: "user" | "assistant" | "system"; 40 | content: string; 41 | timestamp?: string; 42 | } 43 | 44 | export interface ProviderOptions { 45 | debugMode?: boolean; 46 | temperature?: number; 47 | maxTokens?: number; 48 | abortController?: AbortController; 49 | } 50 | 51 | export interface ProviderResponse { 52 | type: "text" | "image" | "tool_use" | "error" | "done"; 53 | content?: string; 54 | imageData?: string; // base64 for images 55 | toolName?: string; 56 | toolInput?: unknown; 57 | error?: string; 58 | metadata?: { 59 | model?: string; 60 | usage?: { 61 | inputTokens?: number; 62 | outputTokens?: number; 63 | }; 64 | }; 65 | } 66 | 67 | // Chat room protocol messages 68 | export interface ChatRoomMessage { 69 | type: "text" | "image" | "command" | "analysis" | "implementation"; 70 | content: string; 71 | imageData?: string; // base64 encoded image 72 | agentId: string; 73 | timestamp: string; 74 | metadata?: { 75 | command?: string; // For command type messages 76 | analysisType?: "ux" | "design" | "technical"; // For analysis type 77 | implementationType?: "frontend" | "backend" | "fullstack"; // For implementation type 78 | }; 79 | } 80 | 81 | // Structured commands for agent coordination 82 | export interface AgentCommand { 83 | command: "capture_screen" | "analyze_image" | "implement_changes" | "review_code"; 84 | target?: string; // file path, URL, or element selector 85 | parameters?: Record; 86 | } -------------------------------------------------------------------------------- /frontend/src/hooks/useMessageConverter.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import type { 3 | AllMessage, 4 | SystemMessage, 5 | ToolMessage, 6 | ToolResultMessage, 7 | SDKMessage, 8 | TimestampedSDKMessage, 9 | } from "../types"; 10 | import { 11 | convertSystemMessage, 12 | convertResultMessage, 13 | createToolMessage, 14 | createToolResultMessage, 15 | convertTimestampedSDKMessage, 16 | convertConversationHistory, 17 | } from "../utils/messageConversion"; 18 | 19 | /** 20 | * Unified message converter hook that provides both individual message conversion 21 | * and batch conversion capabilities. Used by both streaming and history loading. 22 | */ 23 | export function useMessageConverter() { 24 | const createSystemMessageCallback = useCallback( 25 | (claudeData: Extract): SystemMessage => { 26 | return convertSystemMessage(claudeData); 27 | }, 28 | [], 29 | ); 30 | 31 | const createToolMessageCallback = useCallback( 32 | (contentItem: { 33 | name?: string; 34 | input?: Record; 35 | }): ToolMessage => { 36 | return createToolMessage(contentItem); 37 | }, 38 | [], 39 | ); 40 | 41 | const createResultMessageCallback = useCallback( 42 | (claudeData: Extract): SystemMessage => { 43 | return convertResultMessage(claudeData); 44 | }, 45 | [], 46 | ); 47 | 48 | const createToolResultMessageCallback = useCallback( 49 | (toolName: string, content: string): ToolResultMessage => { 50 | return createToolResultMessage(toolName, content); 51 | }, 52 | [], 53 | ); 54 | 55 | const convertTimestampedSDKMessageCallback = useCallback( 56 | (message: TimestampedSDKMessage): AllMessage[] => { 57 | return convertTimestampedSDKMessage(message); 58 | }, 59 | [], 60 | ); 61 | 62 | const convertConversationHistoryCallback = useCallback( 63 | (timestampedMessages: TimestampedSDKMessage[]): AllMessage[] => { 64 | return convertConversationHistory(timestampedMessages); 65 | }, 66 | [], 67 | ); 68 | 69 | return { 70 | // Individual message creators (for streaming) 71 | createSystemMessage: createSystemMessageCallback, 72 | createToolMessage: createToolMessageCallback, 73 | createResultMessage: createResultMessageCallback, 74 | createToolResultMessage: createToolResultMessageCallback, 75 | 76 | // Batch converters (for history loading) 77 | convertTimestampedSDKMessage: convertTimestampedSDKMessageCallback, 78 | convertConversationHistory: convertConversationHistoryCallback, 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /frontend/src/components/chat/EnterModeMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from "react"; 2 | import { EllipsisHorizontalIcon } from "@heroicons/react/24/solid"; 3 | import { useEnterBehavior } from "../../hooks/useEnterBehavior"; 4 | 5 | export function EnterModeMenu() { 6 | const [isOpen, setIsOpen] = useState(false); 7 | const menuRef = useRef(null); 8 | const { enterBehavior, toggleEnterBehavior } = useEnterBehavior(); 9 | 10 | // Close menu when clicking outside 11 | useEffect(() => { 12 | function handleClickOutside(event: MouseEvent) { 13 | if (menuRef.current && !menuRef.current.contains(event.target as Node)) { 14 | setIsOpen(false); 15 | } 16 | } 17 | 18 | if (isOpen) { 19 | document.addEventListener("mousedown", handleClickOutside); 20 | return () => { 21 | document.removeEventListener("mousedown", handleClickOutside); 22 | }; 23 | } 24 | }, [isOpen]); 25 | 26 | const handleToggle = () => { 27 | toggleEnterBehavior(); 28 | setIsOpen(false); 29 | }; 30 | 31 | return ( 32 |
33 | 43 | 44 | {isOpen && ( 45 |
46 | 58 |
59 | {enterBehavior === "send" 60 | ? "Shift+Enter for newline" 61 | : "Shift+Enter to send"} 62 |
63 |
64 | )} 65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /backend/history/pathUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Path utilities for conversation history functionality 3 | * Handles conversion between project paths and Claude history directory names 4 | */ 5 | 6 | import type { Runtime } from "../runtime/types.ts"; 7 | 8 | /** 9 | * Get the encoded directory name for a project path by checking what actually exists 10 | * Example: "/Users/sugyan/tmp/" → "-Users-sugyan-tmp" 11 | */ 12 | export async function getEncodedProjectName( 13 | projectPath: string, 14 | runtime: Runtime, 15 | ): Promise { 16 | const homeDir = runtime.getHomeDir(); 17 | if (!homeDir) { 18 | return null; 19 | } 20 | 21 | const projectsDir = `${homeDir}/.claude/projects`; 22 | 23 | try { 24 | // Read all directories in .claude/projects 25 | const entries = []; 26 | for await (const entry of runtime.readDir(projectsDir)) { 27 | if (entry.isDirectory) { 28 | entries.push(entry.name); 29 | } 30 | } 31 | 32 | // Convert project path to expected encoded format for comparison 33 | const normalizedPath = projectPath.replace(/\/$/, ""); 34 | // Claude converts '/', '\', ':', and '.' to '-' 35 | const expectedEncoded = normalizedPath.replace(/[/\\:.]/g, "-"); 36 | 37 | console.log(`🔍 Looking for project: ${projectPath}`); 38 | console.log(`🔍 Expected encoded: ${expectedEncoded}`); 39 | console.log(`🔍 Available directories: ${entries.join(", ")}`); 40 | 41 | // First try exact match 42 | if (entries.includes(expectedEncoded)) { 43 | console.log(`🔍 Found exact match: ${expectedEncoded}`); 44 | return expectedEncoded; 45 | } 46 | 47 | // If no exact match, look for directories that start with the encoded path 48 | // This handles cases where history exists for subdirectories within the project 49 | const matchingDirs = entries.filter(entry => entry.startsWith(expectedEncoded + "-")); 50 | if (matchingDirs.length > 0) { 51 | console.log(`🔍 Found matching subdirectories: ${matchingDirs.join(", ")}`); 52 | // Return the first matching directory as representative 53 | return matchingDirs[0]; 54 | } 55 | 56 | console.log(`🔍 No match found for ${expectedEncoded}`); 57 | return null; 58 | } catch { 59 | return null; 60 | } 61 | } 62 | 63 | /** 64 | * Validate that an encoded project name is safe 65 | */ 66 | export function validateEncodedProjectName(encodedName: string): boolean { 67 | // Should not be empty 68 | if (!encodedName) { 69 | return false; 70 | } 71 | 72 | // Should not contain dangerous characters for directory names 73 | // deno-lint-ignore no-control-regex 74 | const dangerousChars = /[<>:"|?*\x00-\x1f\/\\]/; 75 | if (dangerousChars.test(encodedName)) { 76 | return false; 77 | } 78 | 79 | return true; 80 | } 81 | -------------------------------------------------------------------------------- /frontend/scripts/README.md: -------------------------------------------------------------------------------- 1 | # Demo Recording Scripts 2 | 3 | This directory contains Playwright-based scripts for automatically recording demo videos of the Claude Code Web Agent. 4 | 5 | ## Setup 6 | 7 | 1. Install dependencies: 8 | 9 | ```bash 10 | npm install 11 | npm run playwright:install 12 | ``` 13 | 14 | 2. Start the development server: 15 | ```bash 16 | npm run dev 17 | ``` 18 | 19 | ## Recording Demos 20 | 21 | ### Quick Start 22 | 23 | Record the default codeGeneration demo: 24 | 25 | ```bash 26 | npm run record-demo 27 | ``` 28 | 29 | ### Quick Commands 30 | 31 | - **Default (Light Mode)**: `npm run record-demo` 32 | - **Dark Mode**: `npm run record-demo basic --theme=dark` 33 | 34 | ### Advanced Usage 35 | 36 | Use the script directly for more control: 37 | 38 | ```bash 39 | # Specific scenarios 40 | npx tsx scripts/record-demo.ts basic 41 | npx tsx scripts/record-demo.ts codeGeneration 42 | npx tsx scripts/record-demo.ts debugging 43 | npx tsx scripts/record-demo.ts fileOperations 44 | 45 | # Theme options 46 | npx tsx scripts/record-demo.ts codeGeneration --theme=light 47 | npx tsx scripts/record-demo.ts codeGeneration --theme=dark 48 | npx tsx scripts/record-demo.ts codeGeneration --theme=both 49 | 50 | # Record all scenarios 51 | npx tsx scripts/record-demo.ts all --theme=dark 52 | npx tsx scripts/record-demo.ts all --theme=both 53 | ``` 54 | 55 | ### Validation Testing 56 | 57 | Run demo validation tests: 58 | 59 | ```bash 60 | npx playwright test tests/demo-validation.spec.ts 61 | ``` 62 | 63 | ## Output 64 | 65 | - Videos are saved to `demo-recordings/` directory 66 | - Format: WebM (1280x720, 25fps) 67 | - Automatic completion detection using `data-demo-completed` attribute 68 | 69 | ## How It Works 70 | 71 | 1. **Shared Constants**: `demo-constants.ts` defines scenarios and types 72 | 2. **Playwright Configuration**: `playwright.config.ts` sets up recording environment 73 | 3. **Recording Script**: `record-demo.ts` orchestrates the recording process with native video recording 74 | 4. **Validation Tests**: `../tests/demo-validation.spec.ts` validates demo functionality 75 | 5. **Demo Detection**: Waits for `[data-demo-completed="true"]` to stop recording 76 | 77 | ## Troubleshooting 78 | 79 | - **Recording doesn't start**: Ensure dev server is running on port 3000 80 | - **Demo doesn't complete**: Check demo automation in the browser console 81 | - **Quality issues**: Adjust video settings in `playwright.config.ts` 82 | - **Timeout errors**: Increase timeout values for slower demos 83 | 84 | ## Demo URL Format 85 | 86 | The recorder uses URLs like: 87 | 88 | ``` 89 | http://localhost:3000/demo?scenario=codeGeneration&theme=dark 90 | ``` 91 | 92 | Available parameters: 93 | 94 | - **scenario**: `basic`, `codeGeneration`, `debugging`, `fileOperations` 95 | - **theme**: `light`, `dark` (optional, defaults to light) 96 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "agentrooms", 3 | "version": "0.1.41", 4 | "type": "module", 5 | "description": "Agentrooms - Multi-agent workspace for collaborative development", 6 | "keywords": [ 7 | "agentrooms", 8 | "multi-agent", 9 | "ai", 10 | "workspace", 11 | "collaboration", 12 | "anthropic", 13 | "claude", 14 | "backend", 15 | "nodejs" 16 | ], 17 | "author": "sugyan", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/sugyan/claude-code-webui.git", 22 | "directory": "backend" 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/sugyan/claude-code-webui/issues" 26 | }, 27 | "homepage": "https://github.com/sugyan/claude-code-webui#readme", 28 | "main": "dist/cli/node.js", 29 | "bin": { 30 | "agentrooms": "dist/cli/node.js" 31 | }, 32 | "files": [ 33 | "dist/", 34 | "README.md", 35 | "LICENSE" 36 | ], 37 | "engines": { 38 | "node": ">=20.0.0" 39 | }, 40 | "scripts": { 41 | "predev": "node scripts/generate-version.js", 42 | "dev": "node scripts/dev-with-preload.js", 43 | "prebuild": "node scripts/generate-version.js", 44 | "build": "npm run build:clean && npm run build:bundle && npm run build:static", 45 | "build:clean": "rimraf dist", 46 | "build:bundle": "node scripts/build-bundle.js", 47 | "build:static": "node scripts/copy-frontend.js", 48 | "start": "node scripts/start-with-preload.js", 49 | "test": "vitest --run --reporter=verbose", 50 | "lint": "eslint \"**/*.ts\" --ignore-pattern dist/", 51 | "format": "prettier --write \"**/*.ts\"", 52 | "format:check": "prettier --check \"**/*.ts\"", 53 | "typecheck": "tsc --noEmit", 54 | "prepack": "node scripts/prepack.js", 55 | "prepublishOnly": "npm run build && npm run test" 56 | }, 57 | "dependencies": { 58 | "@anthropic-ai/claude-code": "1.0.51", 59 | "@anthropic-ai/sdk": "^0.57.0", 60 | "@hono/node-server": "^1.0.0", 61 | "abort-controller": "^3.0.0", 62 | "agentkeepalive": "^4.6.0", 63 | "commander": "^14.0.0", 64 | "form-data-encoder": "^4.1.0", 65 | "formdata-node": "^6.0.3", 66 | "hono": "^4.0.0", 67 | "node-fetch": "^3.3.2", 68 | "openai": "^4.24.0", 69 | "swagger-jsdoc": "^6.2.8", 70 | "swagger-ui-express": "^5.0.0" 71 | }, 72 | "devDependencies": { 73 | "@dotenvx/dotenvx": "^1.51.1", 74 | "@types/node": "^20.0.0", 75 | "@types/swagger-jsdoc": "^6.0.4", 76 | "@types/swagger-ui-express": "^4.1.6", 77 | "@typescript-eslint/eslint-plugin": "^8.0.0", 78 | "@typescript-eslint/parser": "^8.0.0", 79 | "esbuild": "^0.25.6", 80 | "eslint": "^9.0.0", 81 | "prettier": "^3.6.2", 82 | "rimraf": "^6.0.0", 83 | "tsx": "^4.0.0", 84 | "typescript": "^5.0.0", 85 | "vitest": "^2.0.0" 86 | }, 87 | "peerDependencies": { 88 | "@anthropic-ai/claude-code": "1.0.51" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /backend/handlers/conversations.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { validateEncodedProjectName } from "../history/pathUtils.ts"; 3 | import { loadConversation } from "../history/conversationLoader.ts"; 4 | 5 | /** 6 | * Handles GET /api/projects/:encodedProjectName/histories/:sessionId requests 7 | * Retrieves detailed conversation history for a specific session 8 | * @param c - Hono context object with config variables 9 | * @returns JSON response with conversation details 10 | */ 11 | export async function handleConversationRequest(c: Context) { 12 | try { 13 | const { debugMode, runtime } = c.var.config; 14 | const encodedProjectName = c.req.param("encodedProjectName"); 15 | const sessionId = c.req.param("sessionId"); 16 | 17 | if (!encodedProjectName) { 18 | return c.json({ error: "Encoded project name is required" }, 400); 19 | } 20 | 21 | if (!sessionId) { 22 | return c.json({ error: "Session ID is required" }, 400); 23 | } 24 | 25 | if (!validateEncodedProjectName(encodedProjectName)) { 26 | return c.json({ error: "Invalid encoded project name" }, 400); 27 | } 28 | 29 | if (debugMode) { 30 | console.debug( 31 | `[DEBUG] Fetching conversation details for project: ${encodedProjectName}, session: ${sessionId}`, 32 | ); 33 | } 34 | 35 | // Load the specific conversation (already returns processed ConversationHistory) 36 | const conversationHistory = await loadConversation( 37 | encodedProjectName, 38 | sessionId, 39 | runtime, 40 | ); 41 | 42 | if (!conversationHistory) { 43 | return c.json( 44 | { 45 | error: "Conversation not found", 46 | sessionId, 47 | }, 48 | 404, 49 | ); 50 | } 51 | 52 | if (debugMode) { 53 | console.debug( 54 | `[DEBUG] Loaded conversation with ${conversationHistory.messages.length} messages`, 55 | ); 56 | } 57 | 58 | return c.json(conversationHistory); 59 | } catch (error) { 60 | console.error("Error fetching conversation details:", error); 61 | 62 | // Handle specific error types 63 | if (error instanceof Error) { 64 | if (error.message.includes("Invalid session ID")) { 65 | return c.json( 66 | { 67 | error: "Invalid session ID format", 68 | details: error.message, 69 | }, 70 | 400, 71 | ); 72 | } 73 | 74 | if (error.message.includes("Invalid encoded project name")) { 75 | return c.json( 76 | { 77 | error: "Invalid project name", 78 | details: error.message, 79 | }, 80 | 400, 81 | ); 82 | } 83 | } 84 | 85 | return c.json( 86 | { 87 | error: "Failed to fetch conversation details", 88 | details: error instanceof Error ? error.message : String(error), 89 | }, 90 | 500, 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "agentrooms", 3 | "productName": "Agentrooms", 4 | "version": "0.0.7", 5 | "description": "Multi-Agent Programming Collaboration Tool", 6 | "main": "electron/main.js", 7 | "author": "Agentrooms Team", 8 | "scripts": { 9 | "electron": "electron .", 10 | "electron:dev": "NODE_ENV=development electron .", 11 | "build": "npm run build:frontend", 12 | "build:frontend": "cd frontend && npm run build", 13 | "build:backend": "cd backend && npm run build", 14 | "pack": "electron-builder --dir", 15 | "dist": "electron-builder", 16 | "dist:mac": "npm run build && electron-builder --mac", 17 | "dist:win": "npm run build && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win", 18 | "dist:win:setup": "./scripts/build-windows.sh", 19 | "dist:linux": "electron-builder --linux", 20 | "postinstall": "cd frontend && npm install && cd ../backend && npm install" 21 | }, 22 | "build": { 23 | "appId": "com.agentrooms.app", 24 | "productName": "Agentrooms", 25 | "electronVersion": "32.2.7", 26 | "directories": { 27 | "output": "dist" 28 | }, 29 | "files": [ 30 | "electron/**/*", 31 | "frontend/dist/**/*", 32 | "package.json" 33 | ], 34 | "mac": { 35 | "category": "public.app-category.developer-tools", 36 | "icon": "assets/icon.icns", 37 | "target": [ 38 | { 39 | "target": "dmg", 40 | "arch": [ 41 | "x64", 42 | "arm64" 43 | ] 44 | } 45 | ], 46 | "hardenedRuntime": true, 47 | "gatekeeperAssess": false, 48 | "entitlements": "assets/entitlements.mac.plist", 49 | "entitlementsInherit": "assets/entitlements.mac.plist" 50 | }, 51 | "dmg": { 52 | "title": "Agentrooms ${version}", 53 | "icon": "assets/icon.icns", 54 | "contents": [ 55 | { 56 | "x": 130, 57 | "y": 220 58 | }, 59 | { 60 | "x": 410, 61 | "y": 220, 62 | "type": "link", 63 | "path": "/Applications" 64 | } 65 | ], 66 | "window": { 67 | "width": 540, 68 | "height": 380 69 | } 70 | }, 71 | "win": { 72 | "target": [ 73 | { 74 | "target": "portable", 75 | "arch": [ 76 | "x64" 77 | ] 78 | }, 79 | { 80 | "target": "zip", 81 | "arch": [ 82 | "x64" 83 | ] 84 | } 85 | ], 86 | "icon": "assets/icon.ico" 87 | }, 88 | "linux": { 89 | "target": "AppImage", 90 | "icon": "assets/icon.png", 91 | "category": "Development" 92 | } 93 | }, 94 | "devDependencies": { 95 | "electron-builder": "^25.1.8" 96 | }, 97 | "dependencies": { 98 | "electron": "^37.4.0" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /backend/handlers/agentConversations.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { validateEncodedProjectName } from "../history/pathUtils.ts"; 3 | import { loadConversation } from "../history/conversationLoader.ts"; 4 | 5 | /** 6 | * Handles GET /api/agent-conversations/:encodedProjectName/:sessionId requests 7 | * Retrieves detailed conversation history for a specific session from agent endpoint 8 | * @param c - Hono context object with config variables 9 | * @returns JSON response with conversation details 10 | */ 11 | export async function handleAgentConversationRequest(c: Context) { 12 | try { 13 | const { debugMode, runtime } = c.var.config; 14 | const encodedProjectName = c.req.param("encodedProjectName"); 15 | const sessionId = c.req.param("sessionId"); 16 | 17 | if (!encodedProjectName) { 18 | return c.json({ error: "Encoded project name is required" }, 400); 19 | } 20 | 21 | if (!sessionId) { 22 | return c.json({ error: "Session ID is required" }, 400); 23 | } 24 | 25 | if (!validateEncodedProjectName(encodedProjectName)) { 26 | return c.json({ error: "Invalid encoded project name" }, 400); 27 | } 28 | 29 | if (debugMode) { 30 | console.debug( 31 | `[DEBUG] Fetching agent conversation details for project: ${encodedProjectName}, session: ${sessionId}`, 32 | ); 33 | } 34 | 35 | // Load the specific conversation (already returns processed ConversationHistory) 36 | const conversationHistory = await loadConversation( 37 | encodedProjectName, 38 | sessionId, 39 | runtime, 40 | ); 41 | 42 | if (!conversationHistory) { 43 | return c.json( 44 | { 45 | error: "Agent conversation not found", 46 | sessionId, 47 | }, 48 | 404, 49 | ); 50 | } 51 | 52 | if (debugMode) { 53 | console.debug( 54 | `[DEBUG] Loaded agent conversation with ${conversationHistory.messages.length} messages`, 55 | ); 56 | } 57 | 58 | return c.json(conversationHistory); 59 | } catch (error) { 60 | console.error("Error fetching agent conversation details:", error); 61 | 62 | // Handle specific error types 63 | if (error instanceof Error) { 64 | if (error.message.includes("Invalid session ID")) { 65 | return c.json( 66 | { 67 | error: "Invalid session ID format", 68 | details: error.message, 69 | }, 70 | 400, 71 | ); 72 | } 73 | 74 | if (error.message.includes("Invalid encoded project name")) { 75 | return c.json( 76 | { 77 | error: "Invalid project name", 78 | details: error.message, 79 | }, 80 | 400, 81 | ); 82 | } 83 | } 84 | 85 | return c.json( 86 | { 87 | error: "Failed to fetch agent conversation details", 88 | details: error instanceof Error ? error.message : String(error), 89 | }, 90 | 500, 91 | ); 92 | } 93 | } -------------------------------------------------------------------------------- /frontend/src/hooks/streaming/useToolHandling.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { extractToolInfo, generateToolPatterns } from "../../utils/toolUtils"; 3 | import { isPermissionError } from "../../utils/messageTypes"; 4 | import type { StreamingContext } from "./useMessageProcessor"; 5 | import type { ToolResultMessage } from "../../types"; 6 | 7 | interface ToolCache { 8 | name: string; 9 | input: Record; 10 | } 11 | 12 | export function useToolHandling() { 13 | // Store tool_use information for later matching with tool_result 14 | const toolUseCache = useCallback(() => { 15 | const cache = new Map(); 16 | return { 17 | set: (id: string, name: string, input: Record) => 18 | cache.set(id, { name, input }), 19 | get: (id: string) => cache.get(id), 20 | clear: () => cache.clear(), 21 | }; 22 | }, [])(); 23 | 24 | const handlePermissionError = useCallback( 25 | ( 26 | contentItem: { tool_use_id?: string; content: string }, 27 | context: StreamingContext, 28 | ) => { 29 | // Immediately abort the current request 30 | if (context.onAbortRequest) { 31 | context.onAbortRequest(); 32 | } 33 | 34 | // Get cached tool_use information 35 | const toolUseId = contentItem.tool_use_id || ""; 36 | const cachedToolInfo = toolUseCache.get(toolUseId); 37 | 38 | // Extract tool information for permission handling 39 | const { toolName, commands } = extractToolInfo( 40 | cachedToolInfo?.name, 41 | cachedToolInfo?.input, 42 | ); 43 | 44 | // Compute patterns based on tool type 45 | const patterns = generateToolPatterns(toolName, commands); 46 | 47 | // Notify parent component about permission error 48 | if (context.onPermissionError) { 49 | context.onPermissionError(toolName, patterns, toolUseId); 50 | } 51 | }, 52 | [toolUseCache], 53 | ); 54 | 55 | const processToolResult = useCallback( 56 | ( 57 | contentItem: { 58 | tool_use_id?: string; 59 | content: string; 60 | is_error?: boolean; 61 | }, 62 | context: StreamingContext, 63 | createToolResultMessage: ( 64 | toolName: string, 65 | content: string, 66 | ) => ToolResultMessage, 67 | ) => { 68 | const content = 69 | typeof contentItem.content === "string" 70 | ? contentItem.content 71 | : JSON.stringify(contentItem.content); 72 | 73 | // Check for permission errors 74 | if (contentItem.is_error && isPermissionError(content)) { 75 | handlePermissionError(contentItem, context); 76 | return; 77 | } 78 | 79 | // This is a regular tool result - create a ToolResultMessage 80 | const toolResultMessage = createToolResultMessage("Tool result", content); 81 | context.addMessage(toolResultMessage); 82 | }, 83 | [handlePermissionError], 84 | ); 85 | 86 | return { 87 | toolUseCache, 88 | processToolResult, 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /backend/handlers/histories.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import type { HistoryListResponse } from "../../shared/types.ts"; 3 | import { validateEncodedProjectName } from "../history/pathUtils.ts"; 4 | import { parseAllHistoryFiles } from "../history/parser.ts"; 5 | import { groupConversations } from "../history/grouping.ts"; 6 | 7 | /** 8 | * Handles GET /api/projects/:encodedProjectName/histories requests 9 | * Fetches conversation history list for a specific project 10 | * @param c - Hono context object with config variables 11 | * @returns JSON response with conversation history list 12 | */ 13 | export async function handleHistoriesRequest(c: Context) { 14 | try { 15 | const { debugMode, runtime } = c.var.config; 16 | const encodedProjectName = c.req.param("encodedProjectName"); 17 | 18 | if (!encodedProjectName) { 19 | return c.json({ error: "Encoded project name is required" }, 400); 20 | } 21 | 22 | if (!validateEncodedProjectName(encodedProjectName)) { 23 | return c.json({ error: "Invalid encoded project name" }, 400); 24 | } 25 | 26 | if (debugMode) { 27 | console.debug( 28 | `[DEBUG] Fetching histories for encoded project: ${encodedProjectName}`, 29 | ); 30 | } 31 | 32 | // Get home directory 33 | const homeDir = runtime.getHomeDir(); 34 | if (!homeDir) { 35 | return c.json({ error: "Home directory not found" }, 500); 36 | } 37 | 38 | // Build history directory path directly from encoded name 39 | const historyDir = `${homeDir}/.claude/projects/${encodedProjectName}`; 40 | 41 | if (debugMode) { 42 | console.debug(`[DEBUG] History directory: ${historyDir}`); 43 | } 44 | 45 | // Check if the directory exists 46 | try { 47 | const dirInfo = await runtime.stat(historyDir); 48 | if (!dirInfo.isDirectory) { 49 | return c.json({ error: "Project not found" }, 404); 50 | } 51 | } catch (error) { 52 | // Handle file not found errors in a cross-platform way 53 | if (error instanceof Error && error.message.includes("No such file")) { 54 | return c.json({ error: "Project not found" }, 404); 55 | } 56 | throw error; 57 | } 58 | 59 | const conversationFiles = await parseAllHistoryFiles(historyDir, runtime); 60 | 61 | if (debugMode) { 62 | console.debug( 63 | `[DEBUG] Found ${conversationFiles.length} conversation files`, 64 | ); 65 | } 66 | 67 | // Group conversations and remove duplicates 68 | const conversations = groupConversations(conversationFiles); 69 | 70 | if (debugMode) { 71 | console.debug( 72 | `[DEBUG] After grouping: ${conversations.length} unique conversations`, 73 | ); 74 | } 75 | 76 | const response: HistoryListResponse = { 77 | conversations, 78 | }; 79 | 80 | return c.json(response); 81 | } catch (error) { 82 | console.error("Error fetching conversation histories:", error); 83 | 84 | return c.json( 85 | { 86 | error: "Failed to fetch conversation histories", 87 | details: error instanceof Error ? error.message : String(error), 88 | }, 89 | 500, 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /frontend/src/config/api.ts: -------------------------------------------------------------------------------- 1 | // API configuration - uses orchestrator agent settings 2 | export const API_CONFIG = { 3 | ENDPOINTS: { 4 | CHAT: "/api/chat", 5 | ABORT: "/api/abort", 6 | PROJECTS: "/api/projects", 7 | HISTORIES: "/api/projects", 8 | CONVERSATIONS: "/api/projects", 9 | // Remote agent history endpoints 10 | AGENT_PROJECTS: "/api/agent-projects", 11 | AGENT_HISTORIES: "/api/agent-histories", 12 | AGENT_CONVERSATIONS: "/api/agent-conversations", 13 | }, 14 | } as const; 15 | 16 | // Helper function to get full API URL using orchestrator agent configuration 17 | export const getApiUrl = (endpoint: string, orchestratorEndpoint?: string) => { 18 | // In development, check if we should use local backend 19 | if (import.meta.env.DEV && import.meta.env.VITE_USE_LOCAL_API === "true") { 20 | return endpoint; // Use Vite proxy 21 | } 22 | 23 | // Use orchestrator endpoint if provided, otherwise fallback to default 24 | const baseUrl = orchestratorEndpoint || "https://api.claudecode.run"; 25 | return `${baseUrl}${endpoint}`; 26 | }; 27 | 28 | // Helper function to get abort URL 29 | export const getAbortUrl = (requestId: string, orchestratorEndpoint?: string) => { 30 | return getApiUrl(`${API_CONFIG.ENDPOINTS.ABORT}/${requestId}`, orchestratorEndpoint); 31 | }; 32 | 33 | // Helper function to get chat URL 34 | export const getChatUrl = (orchestratorEndpoint?: string) => { 35 | return getApiUrl(API_CONFIG.ENDPOINTS.CHAT, orchestratorEndpoint); 36 | }; 37 | 38 | // Helper function to get projects URL 39 | export const getProjectsUrl = (orchestratorEndpoint?: string) => { 40 | return getApiUrl(API_CONFIG.ENDPOINTS.PROJECTS, orchestratorEndpoint); 41 | }; 42 | 43 | // Helper function to get histories URL 44 | export const getHistoriesUrl = (projectPath: string, orchestratorEndpoint?: string) => { 45 | const encodedPath = encodeURIComponent(projectPath); 46 | return getApiUrl(`${API_CONFIG.ENDPOINTS.HISTORIES}/${encodedPath}/histories`, orchestratorEndpoint); 47 | }; 48 | 49 | // Helper function to get conversation URL 50 | export const getConversationUrl = ( 51 | encodedProjectName: string, 52 | sessionId: string, 53 | orchestratorEndpoint?: string, 54 | ) => { 55 | return getApiUrl(`${API_CONFIG.ENDPOINTS.CONVERSATIONS}/${encodedProjectName}/histories/${sessionId}`, orchestratorEndpoint); 56 | }; 57 | 58 | // Remote agent history helper functions 59 | export const getAgentProjectsUrl = (agentEndpoint: string) => { 60 | return `${agentEndpoint}${API_CONFIG.ENDPOINTS.AGENT_PROJECTS}`; 61 | }; 62 | 63 | export const getAgentHistoriesUrl = (agentEndpoint: string, projectPath: string, agentId?: string) => { 64 | const encodedPath = encodeURIComponent(projectPath); 65 | const baseUrl = `${agentEndpoint}${API_CONFIG.ENDPOINTS.AGENT_HISTORIES}/${encodedPath}`; 66 | if (agentId) { 67 | return `${baseUrl}?agentId=${encodeURIComponent(agentId)}`; 68 | } 69 | return baseUrl; 70 | }; 71 | 72 | export const getAgentConversationUrl = ( 73 | agentEndpoint: string, 74 | encodedProjectName: string, 75 | sessionId: string, 76 | ) => { 77 | return `${agentEndpoint}${API_CONFIG.ENDPOINTS.AGENT_CONVERSATIONS}/${encodedProjectName}/${sessionId}`; 78 | }; 79 | -------------------------------------------------------------------------------- /frontend/src/components/messages/CollapsibleDetails.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | interface CollapsibleDetailsProps { 4 | label: string; 5 | details: string; 6 | defaultCollapsed?: boolean; 7 | } 8 | 9 | export function CollapsibleDetails({ 10 | label, 11 | details, 12 | defaultCollapsed = true, 13 | }: CollapsibleDetailsProps) { 14 | const [isExpanded, setIsExpanded] = useState(!defaultCollapsed); 15 | const hasDetails = details.trim().length > 0; 16 | 17 | return ( 18 |
19 | {/* System message layout */} 20 |
21 | {/* Icon placeholder */} 22 |
23 | {/* Empty space for alignment */} 24 |
25 | 26 | {/* Collapsible content */} 27 |
28 |
setIsExpanded(!isExpanded) : undefined} 30 | style={{ 31 | cursor: hasDetails ? 'pointer' : 'default', 32 | background: 'var(--claude-message-bg)', 33 | border: '1px solid var(--claude-message-border)', 34 | borderRadius: '8px', 35 | padding: '6px 12px', 36 | marginBottom: hasDetails && isExpanded ? '0' : '8px', 37 | display: 'flex', 38 | alignItems: 'center', 39 | gap: '8px', 40 | fontSize: '13px', 41 | color: 'var(--claude-text-secondary)', 42 | borderBottomLeftRadius: hasDetails && isExpanded ? '0' : '8px', 43 | borderBottomRightRadius: hasDetails && isExpanded ? '0' : '8px', 44 | }} 45 | > 46 | {label} 47 | {hasDetails && ( 48 | 49 | {isExpanded ? "▼" : "▶"} 50 | 51 | )} 52 |
53 | 54 | {hasDetails && isExpanded && ( 55 |
66 |
77 |                 {details}
78 |               
79 |
80 | )} 81 |
82 |
83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@beta 37 | with: 38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 | 40 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 41 | # model: "claude-opus-4-20250514" 42 | 43 | # Direct prompt for automated review (no @claude mention needed) 44 | direct_prompt: | 45 | Please review this pull request and provide feedback on: 46 | - Code quality and best practices 47 | - Potential bugs or issues 48 | - Performance considerations 49 | - Security concerns 50 | - Test coverage 51 | 52 | Be constructive and helpful in your feedback. 53 | 54 | # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR 55 | # use_sticky_comment: true 56 | 57 | # Optional: Customize review based on file types 58 | # direct_prompt: | 59 | # Review this PR focusing on: 60 | # - For TypeScript files: Type safety and proper interface usage 61 | # - For API endpoints: Security, input validation, and error handling 62 | # - For React components: Performance, accessibility, and best practices 63 | # - For tests: Coverage, edge cases, and test quality 64 | 65 | # Optional: Different prompts for different authors 66 | # direct_prompt: | 67 | # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && 68 | # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || 69 | # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} 70 | 71 | # Optional: Add specific tools for running tests or linting 72 | # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" 73 | 74 | # Optional: Skip review for certain conditions 75 | # if: | 76 | # !contains(github.event.pull_request.title, '[skip-review]') && 77 | # !contains(github.event.pull_request.title, '[WIP]') 78 | 79 | -------------------------------------------------------------------------------- /backend/pathUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { getEncodedProjectName } from "./history/pathUtils.ts"; 3 | import type { Runtime } from "./runtime/types.ts"; 4 | import type { MiddlewareHandler } from "hono"; 5 | 6 | // Create a mock runtime for testing 7 | const mockRuntime: Runtime = { 8 | getEnv: (key: string) => (key === "HOME" ? "/mock/home" : undefined), 9 | getHomeDir: () => "/mock/home", 10 | async *readDir(_path: string) { 11 | // Mock empty directory - no entries 12 | // This async generator yields nothing, representing an empty directory 13 | // The `_path` parameter is required to match the `Runtime` interface but is not used here. 14 | }, 15 | getArgs: () => [], 16 | getPlatform: () => "linux" as const, 17 | exit: () => { 18 | throw new Error("exit called"); 19 | }, 20 | readTextFile: () => Promise.resolve("{}"), 21 | readBinaryFile: () => Promise.resolve(new Uint8Array()), 22 | exists: () => Promise.resolve(false), 23 | stat: () => 24 | Promise.resolve({ 25 | isFile: false, 26 | isDirectory: false, 27 | isSymlink: false, 28 | size: 0, 29 | mtime: null, 30 | }), 31 | lstat: () => 32 | Promise.resolve({ 33 | isFile: false, 34 | isDirectory: false, 35 | isSymlink: false, 36 | size: 0, 37 | mtime: null, 38 | }), 39 | lstatSync: () => ({ 40 | isFile: false, 41 | isDirectory: false, 42 | isSymlink: false, 43 | size: 0, 44 | mtime: null, 45 | }), 46 | runCommand: () => 47 | Promise.resolve({ success: false, stdout: "", stderr: "", code: 1 }), 48 | findExecutable: () => Promise.resolve([]), 49 | serve: () => {}, 50 | createStaticFileMiddleware: (): MiddlewareHandler => () => 51 | Promise.resolve(new Response()), 52 | }; 53 | 54 | describe("pathUtils", () => { 55 | it("getEncodedProjectName with dots and slashes", async () => { 56 | // Test with a path that contains both dots and slashes 57 | const testPath = "/Users/test/.example/github.com/project-name"; 58 | const result = await getEncodedProjectName(testPath, mockRuntime); 59 | 60 | const expectedEncoding = testPath.replace(/\/$/, "").replace(/[/.]/g, "-"); 61 | 62 | // Should convert both '/' and '.' to '-' 63 | expect(expectedEncoding).toBe( 64 | "-Users-test--example-github-com-project-name", 65 | ); 66 | 67 | // Note: result will be null since this test path doesn't exist in .claude/projects 68 | // but the encoding logic is verified above 69 | expect(result).toBe(null); 70 | }); 71 | 72 | it("test projects API response", async () => { 73 | // Import the projects handler 74 | const { handleProjectsRequest } = await import("./handlers/projects.ts"); 75 | 76 | // Create a mock Hono context with runtime 77 | const mockContext = { 78 | var: { 79 | config: { 80 | runtime: mockRuntime, 81 | }, 82 | }, 83 | json: (data: unknown, status?: number) => { 84 | // Debug logs removed to keep test output clean 85 | // console.log("Mock API response:", JSON.stringify(data, null, 2)); 86 | // console.log("Response status:", status || 200); 87 | return { data, status }; 88 | }, 89 | }; 90 | 91 | // deno-lint-ignore no-explicit-any 92 | await handleProjectsRequest(mockContext as any); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /backend/DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Remote Service Deployment with OAuth Support 2 | 3 | This guide explains how to deploy the backend service with automatic Claude OAuth credential patching. 4 | 5 | ## Quick Start 6 | 7 | 1. **Deploy the backend code** to your remote service 8 | 2. **Install dependencies**: `npm install` 9 | 3. **Run the service**: `npm run dev` or `npm start` 10 | 11 | That's it! The preload script patching is now automatic. 12 | 13 | ## What Happens Automatically 14 | 15 | When you run `npm run dev` or `npm start`, the service automatically: 16 | 17 | - ✅ **Loads the preload script** (`.cjs` format) that intercepts Claude SDK calls 18 | - ✅ **Sets up credential file path** at `$HOME/.claude-credentials.json` 19 | - ✅ **Enables debug logging** (in dev mode) to show interception working 20 | - ✅ **Uses OAuth credentials** from incoming requests instead of API keys 21 | 22 | ## Debug Output 23 | 24 | When running with `npm run dev`, you'll see: 25 | 26 | ```bash 27 | 🔧 Starting backend with Claude OAuth preload script patching... 28 | 📁 Preload script: ./auth/preload-script.cjs 29 | 🗄️ Credentials path: /Users/user/.claude-credentials.json 30 | 🐛 Debug logging: enabled 31 | 32 | ✅ Preload script loaded with platform support: darwin 33 | 🔀 Intercepted spawnSync call: security find-generic-password -a $USER -w -s "Claude Code" 34 | 📁 Reading credentials from: /Users/user/.claude-credentials.json 35 | ``` 36 | 37 | ## How OAuth Credential Flow Works 38 | 39 | 1. **Frontend sends request** with `claudeAuth` containing OAuth session 40 | 2. **Backend extracts credentials** and writes them to credentials file 41 | 3. **Preload script intercepts** Claude SDK credential lookups 42 | 4. **Claude SDK gets OAuth credentials** instead of system API keys 43 | 5. **Claude API calls succeed** using your authenticated subscription 44 | 45 | ## Environment Variables 46 | 47 | The service automatically sets these environment variables: 48 | 49 | - `NODE_OPTIONS`: `--require ./auth/preload-script.cjs` (dev) or `--require ./dist/auth/preload-script.cjs` (prod) 50 | - `CLAUDE_CREDENTIALS_PATH`: `$HOME/.claude-credentials.json` 51 | - `DEBUG_PRELOAD_SCRIPT`: `1` (dev mode only) 52 | 53 | ## Production Deployment 54 | 55 | For production environments: 56 | 57 | ```bash 58 | # Build the service 59 | npm run build 60 | 61 | # Start with preload script patching 62 | npm start 63 | ``` 64 | 65 | The production build automatically copies the preload script to `dist/auth/preload-script.js`. 66 | 67 | ## Troubleshooting 68 | 69 | ### "Unknown command cross-env" 70 | This is fixed! The new scripts use native Node.js without external dependencies. 71 | 72 | ### "Invalid API key" errors 73 | If you still see API key errors, check that: 74 | - The preload script debug output shows credential interception 75 | - OAuth credentials are being sent in the request `claudeAuth` field 76 | - The credentials file is being written to the correct path 77 | 78 | ### Debug logging 79 | To enable debug logging in production: 80 | ```bash 81 | DEBUG_PRELOAD_SCRIPT=1 npm start 82 | ``` 83 | 84 | ## Manual Environment Setup (Advanced) 85 | 86 | If you need to customize the environment setup, you can manually set: 87 | 88 | ```bash 89 | export NODE_OPTIONS="--require ./auth/preload-script.cjs" 90 | export CLAUDE_CREDENTIALS_PATH="$HOME/.claude-credentials.json" 91 | export DEBUG_PRELOAD_SCRIPT=1 92 | node dist/cli/node.js 93 | ``` 94 | 95 | But the automated scripts handle this for you. -------------------------------------------------------------------------------- /frontend/src/components/chat/ChatMessages.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from "react"; 2 | import type { AllMessage } from "../../types"; 3 | import { 4 | isChatMessage, 5 | isSystemMessage, 6 | isToolMessage, 7 | isToolResultMessage, 8 | isOrchestrationMessage, 9 | } from "../../types"; 10 | import { 11 | ChatMessageComponent, 12 | SystemMessageComponent, 13 | ToolMessageComponent, 14 | ToolResultMessageComponent, 15 | OrchestrationMessageComponent, 16 | LoadingComponent, 17 | } from "../MessageComponents"; 18 | // import { UI_CONSTANTS } from "../../utils/constants"; // Unused for now 19 | 20 | interface ChatMessagesProps { 21 | messages: AllMessage[]; 22 | isLoading: boolean; 23 | onExecuteStep?: (step: any) => void; 24 | onExecutePlan?: (steps: any[]) => void; 25 | currentAgentId?: string; // Agent ID for loading state 26 | } 27 | 28 | export function ChatMessages({ messages, isLoading, onExecuteStep, onExecutePlan, currentAgentId }: ChatMessagesProps) { 29 | const messagesEndRef = useRef(null); 30 | 31 | // Auto-scroll to bottom 32 | const scrollToBottom = () => { 33 | if (messagesEndRef.current && messagesEndRef.current.scrollIntoView) { 34 | messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); 35 | } 36 | }; 37 | 38 | // Check if user is near bottom of messages (unused but kept for future use) 39 | // const isNearBottom = () => { 40 | // const container = messagesContainerRef.current; 41 | // if (!container) return true; 42 | 43 | // const { scrollTop, scrollHeight, clientHeight } = container; 44 | // return ( 45 | // scrollHeight - scrollTop - clientHeight < 46 | // UI_CONSTANTS.NEAR_BOTTOM_THRESHOLD_PX 47 | // ); 48 | // }; 49 | 50 | // Auto-scroll when messages change 51 | useEffect(() => { 52 | scrollToBottom(); 53 | }, [messages]); 54 | 55 | const renderMessage = (message: AllMessage, index: number) => { 56 | // Use timestamp as key for stable rendering, fallback to index if needed 57 | const key = `${message.timestamp}-${index}`; 58 | 59 | if (isSystemMessage(message)) { 60 | return ; 61 | } else if (isToolMessage(message)) { 62 | return ; 63 | } else if (isToolResultMessage(message)) { 64 | return ; 65 | } else if (isOrchestrationMessage(message)) { 66 | return ; 67 | } else if (isChatMessage(message)) { 68 | return ; 69 | } 70 | return null; 71 | }; 72 | 73 | return ( 74 |
75 |
76 | {messages.length === 0 ? ( 77 | 78 | ) : ( 79 | <> 80 | {messages.map(renderMessage)} 81 | {isLoading && } 82 |
83 | 84 | )} 85 |
86 |
87 | ); 88 | } 89 | 90 | function EmptyState() { 91 | return ( 92 |
93 |
👨‍💻
94 |

Welcome to Agentrooms

95 |

Assign a task or @mention agents to start

96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /ClaudeAgentHub/ClaudeAgentHub/Services/HistoryService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class HistoryService: ObservableObject { 4 | private let apiService = APIService() 5 | 6 | @Published var conversations: [ConversationSummary] = [] 7 | @Published var currentConversation: ConversationHistory? 8 | @Published var isLoading = false 9 | @Published var errorMessage: String? 10 | 11 | func loadConversationHistories(baseURL: String, projectName: String) { 12 | isLoading = true 13 | errorMessage = nil 14 | 15 | Task { 16 | do { 17 | let histories = try await apiService.fetchConversationHistories( 18 | baseURL: baseURL, 19 | encodedProjectName: projectName 20 | ) 21 | 22 | await MainActor.run { 23 | self.conversations = histories.sorted { $0.lastDate ?? Date.distantPast > $1.lastDate ?? Date.distantPast } 24 | self.isLoading = false 25 | } 26 | } catch { 27 | await MainActor.run { 28 | self.errorMessage = error.localizedDescription 29 | self.isLoading = false 30 | } 31 | } 32 | } 33 | } 34 | 35 | func loadConversationDetail(baseURL: String, projectName: String, sessionId: String) { 36 | isLoading = true 37 | errorMessage = nil 38 | 39 | Task { 40 | do { 41 | let conversation = try await apiService.fetchConversationHistory( 42 | baseURL: baseURL, 43 | encodedProjectName: projectName, 44 | sessionId: sessionId 45 | ) 46 | 47 | await MainActor.run { 48 | self.currentConversation = conversation 49 | self.isLoading = false 50 | } 51 | } catch { 52 | await MainActor.run { 53 | self.errorMessage = error.localizedDescription 54 | self.isLoading = false 55 | } 56 | } 57 | } 58 | } 59 | 60 | func clearCurrentConversation() { 61 | currentConversation = nil 62 | } 63 | 64 | func formatTimestamp(_ dateString: String) -> String { 65 | let formatter = ISO8601DateFormatter() 66 | guard let date = formatter.date(from: dateString) else { 67 | return dateString 68 | } 69 | 70 | let displayFormatter = DateFormatter() 71 | displayFormatter.dateStyle = .medium 72 | displayFormatter.timeStyle = .short 73 | 74 | return displayFormatter.string(from: date) 75 | } 76 | 77 | func timeAgo(from dateString: String) -> String { 78 | let formatter = ISO8601DateFormatter() 79 | guard let date = formatter.date(from: dateString) else { 80 | return "Unknown" 81 | } 82 | 83 | let now = Date() 84 | let timeInterval = now.timeIntervalSince(date) 85 | 86 | if timeInterval < 60 { 87 | return "Just now" 88 | } else if timeInterval < 3600 { 89 | let minutes = Int(timeInterval / 60) 90 | return "\(minutes)m ago" 91 | } else if timeInterval < 86400 { 92 | let hours = Int(timeInterval / 3600) 93 | return "\(hours)h ago" 94 | } else { 95 | let days = Int(timeInterval / 86400) 96 | return "\(days)d ago" 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /electron/preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require('electron'); 2 | 3 | // Validate URL to prevent security issues 4 | function isValidUrl(string) { 5 | try { 6 | const url = new URL(string); 7 | return ['http:', 'https:', 'mailto:'].includes(url.protocol); 8 | } catch { 9 | return false; 10 | } 11 | } 12 | 13 | // Validate input parameters to prevent injection attacks 14 | function validateString(input, maxLength = 1000) { 15 | return typeof input === 'string' && input.length <= maxLength; 16 | } 17 | 18 | function validateObject(input) { 19 | return input !== null && typeof input === 'object' && !Array.isArray(input); 20 | } 21 | 22 | // Expose protected methods that allow the renderer process to use 23 | // the ipcRenderer without exposing the entire object 24 | contextBridge.exposeInMainWorld('electronAPI', { 25 | // Platform information (read-only) 26 | platform: process.platform, 27 | 28 | // External URL handler with validation 29 | openExternal: (url) => { 30 | if (!isValidUrl(url)) { 31 | console.error('Invalid URL provided to openExternal:', url); 32 | return Promise.reject(new Error('Invalid URL')); 33 | } 34 | return ipcRenderer.invoke('open-external', url); 35 | }, 36 | 37 | // Authentication API 38 | auth: { 39 | startOAuth: () => ipcRenderer.invoke('auth:start-oauth'), 40 | completeOAuth: (authCode) => ipcRenderer.invoke('auth:complete-oauth', authCode), 41 | checkStatus: () => ipcRenderer.invoke('auth:check-status'), 42 | signOut: () => ipcRenderer.invoke('auth:sign-out'), 43 | }, 44 | 45 | // Persistent Storage API with validation 46 | storage: { 47 | // Agent Configuration 48 | saveAgentConfig: (config) => { 49 | if (!validateObject(config)) { 50 | return Promise.reject(new Error('Invalid config object')); 51 | } 52 | return ipcRenderer.invoke('storage:save-agent-config', config); 53 | }, 54 | loadAgentConfig: () => ipcRenderer.invoke('storage:load-agent-config'), 55 | 56 | // Chat Messages 57 | saveConversation: (sessionId, messages) => { 58 | if (!validateString(sessionId, 100) || !Array.isArray(messages)) { 59 | return Promise.reject(new Error('Invalid conversation parameters')); 60 | } 61 | // Limit message array size to prevent memory issues 62 | if (messages.length > 1000) { 63 | return Promise.reject(new Error('Too many messages')); 64 | } 65 | return ipcRenderer.invoke('storage:save-conversation', sessionId, messages); 66 | }, 67 | loadConversation: (sessionId) => { 68 | if (!validateString(sessionId, 100)) { 69 | return Promise.reject(new Error('Invalid session ID')); 70 | } 71 | return ipcRenderer.invoke('storage:load-conversation', sessionId); 72 | }, 73 | listConversations: () => ipcRenderer.invoke('storage:list-conversations'), 74 | 75 | // App Settings 76 | saveSetting: (key, value) => { 77 | if (!validateString(key, 50)) { 78 | return Promise.reject(new Error('Invalid setting key')); 79 | } 80 | // Allow various value types but with size limits 81 | if (typeof value === 'string' && value.length > 10000) { 82 | return Promise.reject(new Error('Setting value too large')); 83 | } 84 | return ipcRenderer.invoke('storage:save-setting', key, value); 85 | }, 86 | loadSetting: (key) => { 87 | if (!validateString(key, 50)) { 88 | return Promise.reject(new Error('Invalid setting key')); 89 | } 90 | return ipcRenderer.invoke('storage:load-setting', key); 91 | }, 92 | loadAllSettings: () => ipcRenderer.invoke('storage:load-all-settings') 93 | } 94 | }); 95 | 96 | // Log when preload script loads 97 | console.log('AgentHub preload script loaded'); -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # AgentHub - Multi-Agent Programming Collaboration Tool 2 | # Makefile for building Electron app and DMG 3 | 4 | .PHONY: all clean install build dev electron dist dmg help 5 | 6 | # Default target 7 | all: build 8 | 9 | # Help target 10 | help: 11 | @echo "AgentHub Build System" 12 | @echo "====================" 13 | @echo "" 14 | @echo "Available targets:" 15 | @echo " install - Install all dependencies" 16 | @echo " dev - Start development servers" 17 | @echo " build - Build frontend and backend" 18 | @echo " build-frontend - Build frontend only" 19 | @echo " electron - Run Electron app (requires frontend dev server)" 20 | @echo " electron-standalone- Run Electron app with built frontend" 21 | @echo " dist - Build production Electron app" 22 | @echo " dmg - Build macOS DMG installer" 23 | @echo " clean - Clean build artifacts" 24 | @echo " help - Show this help message" 25 | 26 | # Install dependencies 27 | install: 28 | @echo "Installing dependencies..." 29 | npm install 30 | cd frontend && npm install 31 | cd backend && npm install 32 | 33 | # Development 34 | dev: 35 | @echo "Starting development servers..." 36 | @echo "Backend will start on port 8080" 37 | @echo "Frontend will start on port 3000" 38 | @echo "Press Ctrl+C to stop" 39 | make -j2 dev-backend dev-frontend 40 | 41 | dev-backend: 42 | cd backend && npm run dev 43 | 44 | dev-frontend: 45 | cd frontend && npm run dev 46 | 47 | # Run Electron in development 48 | electron: 49 | @echo "Starting Electron in development mode..." 50 | npm run electron:dev 51 | 52 | # Run Electron standalone (with built frontend) 53 | electron-standalone: build-frontend 54 | @echo "Starting Electron with built frontend..." 55 | npm run electron:dev 56 | 57 | # Build frontend only 58 | build-frontend: 59 | @echo "Building frontend..." 60 | cd frontend && npm run build 61 | 62 | # Build everything 63 | build: 64 | @echo "Building frontend and backend..." 65 | npm run build:frontend 66 | npm run build:backend 67 | 68 | # Build Electron app for distribution 69 | dist: build 70 | @echo "Building Electron app for distribution..." 71 | npm run dist 72 | 73 | # Build macOS DMG (frontend-only) 74 | dmg: build-frontend 75 | @echo "Building macOS DMG installer..." 76 | npm run dist:mac 77 | 78 | # Clean build artifacts 79 | clean: 80 | @echo "Cleaning build artifacts..." 81 | rm -rf dist/ 82 | rm -rf frontend/dist/ 83 | rm -rf backend/dist/ 84 | rm -rf node_modules/ 85 | rm -rf frontend/node_modules/ 86 | rm -rf backend/node_modules/ 87 | 88 | # Create app icon (requires iconutil on macOS) 89 | icon: 90 | @echo "Creating app icon..." 91 | mkdir -p assets/icon.iconset 92 | # Add different sizes of your icon PNG files to assets/icon.iconset/ 93 | # icon_16x16.png, icon_32x32.png, icon_128x128.png, icon_256x256.png, icon_512x512.png, icon_1024x1024.png 94 | # Then run: iconutil -c icns assets/icon.iconset -o assets/icon.icns 95 | 96 | # Quick test build 97 | test-build: clean install build 98 | 99 | # Full release build 100 | release: clean install build dmg 101 | @echo "Release build complete! Check dist/ folder for DMG file." 102 | 103 | # Development workflow 104 | quick-start: install 105 | @echo "Quick start: Installing and launching development environment..." 106 | make -j2 dev-backend electron 107 | 108 | # Check system requirements 109 | check: 110 | @echo "Checking system requirements..." 111 | @node --version || (echo "Node.js not found. Please install Node.js 18+"; exit 1) 112 | @npm --version || (echo "npm not found. Please install npm"; exit 1) 113 | @echo "System requirements OK" 114 | 115 | # Setup development environment 116 | setup: check install 117 | @echo "Development environment setup complete!" 118 | @echo "Run 'make dev' to start development servers" 119 | @echo "Run 'make electron' to start Electron app" -------------------------------------------------------------------------------- /backend/history/grouping.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Conversation grouping algorithm 3 | * Groups conversation files and removes duplicates from continued sessions 4 | */ 5 | 6 | import type { ConversationSummary } from "../../shared/types.ts"; 7 | import type { ConversationFile } from "./parser.ts"; 8 | import { isSubset } from "./parser.ts"; 9 | 10 | /** 11 | * Group conversations and remove duplicates from continued sessions 12 | * Based on the algorithm described in docs/histories.md 13 | */ 14 | export function groupConversations( 15 | conversationFiles: ConversationFile[], 16 | ): ConversationSummary[] { 17 | if (conversationFiles.length === 0) { 18 | return []; 19 | } 20 | 21 | // Sort conversations by message ID set size (ascending) 22 | // This ensures we process smaller conversations first 23 | const sortedConversations = [...conversationFiles].sort((a, b) => { 24 | return a.messageIds.size - b.messageIds.size; 25 | }); 26 | 27 | // Remove conversations whose message ID sets are subsets of larger ones 28 | const uniqueConversations: ConversationFile[] = []; 29 | 30 | for (const currentConv of sortedConversations) { 31 | // Check if this conversation's message IDs are a subset of any existing unique conversation 32 | const isSubsetOfExisting = uniqueConversations.some((existingConv) => 33 | isSubset(currentConv.messageIds, existingConv.messageIds), 34 | ); 35 | 36 | if (!isSubsetOfExisting) { 37 | // This is either a unique conversation or the "final" version of a continued conversation 38 | uniqueConversations.push(currentConv); 39 | } 40 | } 41 | 42 | // Convert to ConversationSummary format and sort by start time (newest first) 43 | const summaries = uniqueConversations.map((conv) => 44 | createConversationSummary(conv), 45 | ); 46 | 47 | // Sort by start time, newest first 48 | summaries.sort( 49 | (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime(), 50 | ); 51 | 52 | return summaries; 53 | } 54 | 55 | /** 56 | * Create a ConversationSummary from a ConversationFile 57 | */ 58 | function createConversationSummary( 59 | conversationFile: ConversationFile, 60 | ): ConversationSummary { 61 | return { 62 | sessionId: conversationFile.sessionId, 63 | startTime: conversationFile.startTime, 64 | lastTime: conversationFile.lastTime, 65 | messageCount: conversationFile.messageCount, 66 | lastMessagePreview: conversationFile.lastMessagePreview, 67 | agentId: conversationFile.agentId, 68 | }; 69 | } 70 | 71 | /** 72 | * Debug helper to analyze conversation relationships 73 | * Useful for understanding how conversations are grouped 74 | */ 75 | export function analyzeConversationRelationships( 76 | conversationFiles: ConversationFile[], 77 | ): { 78 | totalFiles: number; 79 | uniqueConversations: number; 80 | duplicateFiles: string[]; 81 | relationships: Array<{ 82 | file: string; 83 | messageIdCount: number; 84 | isSubsetOf: string[]; 85 | }>; 86 | } { 87 | const relationships = conversationFiles.map((conv) => { 88 | const isSubsetOf: string[] = []; 89 | 90 | for (const otherConv of conversationFiles) { 91 | if ( 92 | conv.sessionId !== otherConv.sessionId && 93 | isSubset(conv.messageIds, otherConv.messageIds) 94 | ) { 95 | isSubsetOf.push(otherConv.sessionId); 96 | } 97 | } 98 | 99 | return { 100 | file: conv.sessionId, 101 | messageIdCount: conv.messageIds.size, 102 | isSubsetOf, 103 | }; 104 | }); 105 | 106 | const duplicateFiles = relationships 107 | .filter((rel) => rel.isSubsetOf.length > 0) 108 | .map((rel) => rel.file); 109 | 110 | const uniqueConversations = conversationFiles.length - duplicateFiles.length; 111 | 112 | return { 113 | totalFiles: conversationFiles.length, 114 | uniqueConversations, 115 | duplicateFiles, 116 | relationships, 117 | }; 118 | } 119 | -------------------------------------------------------------------------------- /backend/history/timestampRestore.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Timestamp restoration utilities 3 | * Handles restoring accurate timestamps for continued conversations 4 | */ 5 | 6 | import type { RawHistoryLine } from "./parser.ts"; 7 | 8 | /** 9 | * Restore accurate timestamps for messages in a conversation 10 | * When conversations are continued, timestamps get overwritten 11 | * This function restores original timestamps from first occurrence of each message.id 12 | */ 13 | export function restoreTimestamps( 14 | messages: RawHistoryLine[], 15 | ): RawHistoryLine[] { 16 | // Create a map to track the earliest timestamp for each message ID 17 | const timestampMap = new Map(); 18 | 19 | // First pass: collect earliest timestamps for each message.id 20 | for (const msg of messages) { 21 | if (msg.type === "assistant" && msg.message && "id" in msg.message && msg.message.id) { 22 | const messageId = msg.message.id; 23 | if (!timestampMap.has(messageId)) { 24 | timestampMap.set(messageId, msg.timestamp); 25 | } else { 26 | // Keep the earliest timestamp 27 | const existingTimestamp = timestampMap.get(messageId)!; 28 | if (msg.timestamp < existingTimestamp) { 29 | timestampMap.set(messageId, msg.timestamp); 30 | } 31 | } 32 | } 33 | } 34 | 35 | // Second pass: restore timestamps and return updated messages 36 | return messages.map((msg) => { 37 | if (msg.type === "assistant" && msg.message && "id" in msg.message && msg.message.id) { 38 | const restoredTimestamp = timestampMap.get(msg.message.id); 39 | if (restoredTimestamp) { 40 | return { 41 | ...msg, 42 | timestamp: restoredTimestamp, 43 | }; 44 | } 45 | } 46 | // For user messages and messages without IDs, keep original timestamp 47 | return msg; 48 | }); 49 | } 50 | 51 | /** 52 | * Sort messages by timestamp (chronological order) 53 | */ 54 | export function sortMessagesByTimestamp( 55 | messages: RawHistoryLine[], 56 | ): RawHistoryLine[] { 57 | return [...messages].sort((a, b) => { 58 | return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); 59 | }); 60 | } 61 | 62 | /** 63 | * Calculate conversation metadata from messages 64 | */ 65 | export function calculateConversationMetadata(messages: RawHistoryLine[]): { 66 | startTime: string; 67 | endTime: string; 68 | messageCount: number; 69 | agentId?: string; 70 | } { 71 | if (messages.length === 0) { 72 | const now = new Date().toISOString(); 73 | return { 74 | startTime: now, 75 | endTime: now, 76 | messageCount: 0, 77 | }; 78 | } 79 | 80 | // Sort messages by timestamp to get accurate start/end times 81 | const sortedMessages = sortMessagesByTimestamp(messages); 82 | const startTime = sortedMessages[0].timestamp; 83 | const endTime = sortedMessages[sortedMessages.length - 1].timestamp; 84 | 85 | // Extract agent ID from the first message that has one 86 | const agentId = messages.find(msg => msg.agentId)?.agentId; 87 | 88 | return { 89 | startTime, 90 | endTime, 91 | messageCount: messages.length, 92 | agentId, 93 | }; 94 | } 95 | 96 | /** 97 | * Process messages with timestamp restoration and sorting 98 | * This is the main function to call for preparing messages for API response 99 | */ 100 | export function processConversationMessages( 101 | messages: RawHistoryLine[], 102 | _sessionId: string, 103 | ): { 104 | messages: unknown[]; 105 | metadata: { 106 | startTime: string; 107 | endTime: string; 108 | messageCount: number; 109 | agentId?: string; 110 | }; 111 | } { 112 | // Restore timestamps 113 | const restoredMessages = restoreTimestamps(messages); 114 | 115 | // Sort by timestamp 116 | const sortedMessages = sortMessagesByTimestamp(restoredMessages); 117 | 118 | // Calculate metadata 119 | const metadata = calculateConversationMetadata(sortedMessages); 120 | 121 | // Return as unknown[] for frontend compatibility 122 | return { 123 | messages: sortedMessages as unknown[], 124 | metadata, 125 | }; 126 | } 127 | -------------------------------------------------------------------------------- /backend/template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: Claude Code Web Agent - Backend API 4 | 5 | Globals: 6 | Function: 7 | Timeout: 300 8 | MemorySize: 512 9 | Runtime: nodejs20.x 10 | Environment: 11 | Variables: 12 | NODE_ENV: production 13 | 14 | Parameters: 15 | Stage: 16 | Type: String 17 | Default: prod 18 | AllowedValues: 19 | - dev 20 | - staging 21 | - prod 22 | Description: Deployment stage 23 | 24 | Resources: 25 | # Lambda function for the API 26 | ClaudeWebAgentFunction: 27 | Type: AWS::Serverless::Function 28 | Properties: 29 | FunctionName: !Sub "${AWS::StackName}-api-${Stage}" 30 | CodeUri: ./ 31 | Handler: dist/lambda.handler 32 | Description: Claude Code Web Agent Backend API 33 | Environment: 34 | Variables: 35 | STAGE: !Ref Stage 36 | Events: 37 | # Simplified API Gateway events - let Hono handle all routing 38 | ApiProxy: 39 | Type: Api 40 | Properties: 41 | RestApiId: !Ref ApiGateway 42 | Path: /{proxy+} 43 | Method: ANY 44 | ApiRoot: 45 | Type: Api 46 | Properties: 47 | RestApiId: !Ref ApiGateway 48 | Path: / 49 | Method: ANY 50 | Policies: 51 | - Version: '2012-10-17' 52 | Statement: 53 | - Effect: Allow 54 | Action: 55 | - logs:CreateLogGroup 56 | - logs:CreateLogStream 57 | - logs:PutLogEvents 58 | Resource: '*' 59 | 60 | # API Gateway 61 | ApiGateway: 62 | Type: AWS::Serverless::Api 63 | Properties: 64 | Name: !Sub "${AWS::StackName}-api-${Stage}" 65 | StageName: !Ref Stage 66 | # CloudWatch logging removed - requires account-level IAM role setup 67 | # Can be re-enabled after setting up API Gateway CloudWatch role in account settings 68 | # Removed CORS from API Gateway - handled by Hono in Lambda function 69 | # Having CORS in both places causes 500 errors on OPTIONS requests 70 | # Remove binary media types that cause OPTIONS 500 errors 71 | # BinaryMediaTypes: 72 | # - "*/*" 73 | 74 | # Gateway Responses for CORS error handling 75 | GatewayResponseDefault4XX: 76 | Type: AWS::ApiGateway::GatewayResponse 77 | Properties: 78 | ResponseParameters: 79 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'" 80 | gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'" 81 | gatewayresponse.header.Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" 82 | ResponseType: DEFAULT_4XX 83 | RestApiId: !Ref ApiGateway 84 | 85 | GatewayResponseDefault5XX: 86 | Type: AWS::ApiGateway::GatewayResponse 87 | Properties: 88 | ResponseParameters: 89 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'" 90 | gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'" 91 | gatewayresponse.header.Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" 92 | ResponseType: DEFAULT_5XX 93 | RestApiId: !Ref ApiGateway 94 | 95 | # CloudWatch Log Group 96 | ApiLogGroup: 97 | Type: AWS::Logs::LogGroup 98 | Properties: 99 | LogGroupName: !Sub "/aws/lambda/${ClaudeWebAgentFunction}" 100 | RetentionInDays: 14 101 | 102 | Outputs: 103 | ApiUrl: 104 | Description: API Gateway endpoint URL 105 | Value: !Sub "https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${Stage}/" 106 | Export: 107 | Name: !Sub "${AWS::StackName}-api-url" 108 | 109 | FunctionName: 110 | Description: Lambda function name 111 | Value: !Ref ClaudeWebAgentFunction 112 | Export: 113 | Name: !Sub "${AWS::StackName}-function-name" 114 | 115 | ApiGatewayId: 116 | Description: API Gateway ID 117 | Value: !Ref ApiGateway 118 | Export: 119 | Name: !Sub "${AWS::StackName}-api-id" -------------------------------------------------------------------------------- /frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SDKUserMessage, 3 | SDKAssistantMessage, 4 | SDKSystemMessage, 5 | SDKResultMessage, 6 | } from "@anthropic-ai/claude-code"; 7 | 8 | // Chat message for user/assistant interactions (not part of SDKMessage) 9 | export interface ChatMessage { 10 | type: "chat"; 11 | role: "user" | "assistant"; 12 | content: string; 13 | timestamp: number; 14 | agentId?: string; 15 | } 16 | 17 | // Error message for streaming errors 18 | export type ErrorMessage = { 19 | type: "error"; 20 | subtype: "stream_error"; 21 | message: string; 22 | timestamp: number; 23 | }; 24 | 25 | // Abort message for aborted operations 26 | export type AbortMessage = { 27 | type: "system"; 28 | subtype: "abort"; 29 | message: string; 30 | timestamp: number; 31 | }; 32 | 33 | // Warning message for system warnings 34 | export type WarningMessage = { 35 | type: "system"; 36 | subtype: "warning"; 37 | message: string; 38 | timestamp: number; 39 | }; 40 | 41 | // System message extending SDK types with timestamp 42 | export type SystemMessage = ( 43 | | SDKSystemMessage 44 | | SDKResultMessage 45 | | ErrorMessage 46 | | AbortMessage 47 | | WarningMessage 48 | ) & { 49 | timestamp: number; 50 | }; 51 | 52 | // Tool message for tool usage display 53 | export type ToolMessage = { 54 | type: "tool"; 55 | content: string; 56 | timestamp: number; 57 | }; 58 | 59 | // Tool result message for tool result display 60 | export type ToolResultMessage = { 61 | type: "tool_result"; 62 | toolName: string; 63 | content: string; 64 | summary: string; 65 | timestamp: number; 66 | }; 67 | 68 | // TimestampedSDKMessage types for conversation history API 69 | // These extend Claude SDK types with timestamp information 70 | type WithTimestamp = T & { timestamp: string }; 71 | 72 | export type TimestampedSDKUserMessage = WithTimestamp; 73 | export type TimestampedSDKAssistantMessage = WithTimestamp; 74 | export type TimestampedSDKSystemMessage = WithTimestamp; 75 | export type TimestampedSDKResultMessage = WithTimestamp; 76 | 77 | export type TimestampedSDKMessage = 78 | | TimestampedSDKUserMessage 79 | | TimestampedSDKAssistantMessage 80 | | TimestampedSDKSystemMessage 81 | | TimestampedSDKResultMessage; 82 | 83 | // Execution step from Agent Room orchestrator 84 | export type ExecutionStep = { 85 | id: string; 86 | agent: string; 87 | message: string; 88 | status: "pending" | "in_progress" | "completed" | "failed"; 89 | result?: string; 90 | timestamp: number; 91 | dependencies?: string[]; 92 | }; 93 | 94 | // Orchestration message for Agent Room responses 95 | export type OrchestrationMessage = { 96 | type: "orchestration"; 97 | steps: ExecutionStep[]; 98 | timestamp: number; 99 | }; 100 | 101 | export type AllMessage = 102 | | ChatMessage 103 | | SystemMessage 104 | | ToolMessage 105 | | ToolResultMessage 106 | | OrchestrationMessage; 107 | 108 | // Type guard functions 109 | export function isChatMessage(message: AllMessage): message is ChatMessage { 110 | return message.type === "chat"; 111 | } 112 | 113 | export function isSystemMessage(message: AllMessage): message is SystemMessage { 114 | return ( 115 | message.type === "system" || 116 | message.type === "result" || 117 | message.type === "error" 118 | ); 119 | } 120 | 121 | export function isToolMessage(message: AllMessage): message is ToolMessage { 122 | return message.type === "tool"; 123 | } 124 | 125 | export function isOrchestrationMessage( 126 | message: AllMessage, 127 | ): message is OrchestrationMessage { 128 | return message.type === "orchestration"; 129 | } 130 | 131 | export function isToolResultMessage( 132 | message: AllMessage, 133 | ): message is ToolResultMessage { 134 | return message.type === "tool_result"; 135 | } 136 | 137 | // Re-export shared types 138 | export type { 139 | StreamResponse, 140 | ChatRequest, 141 | ProjectsResponse, 142 | ProjectInfo, 143 | } from "../../shared/types"; 144 | 145 | // Re-export SDK types 146 | export type { 147 | SDKMessage, 148 | SDKSystemMessage, 149 | SDKResultMessage, 150 | SDKAssistantMessage, 151 | SDKUserMessage, 152 | } from "@anthropic-ai/claude-code"; 153 | -------------------------------------------------------------------------------- /backend/history/conversationLoader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Individual conversation loading utilities 3 | * Handles loading and parsing specific conversation files 4 | */ 5 | 6 | import type { RawHistoryLine } from "./parser.ts"; 7 | import type { ConversationHistory } from "../../shared/types.ts"; 8 | import type { Runtime } from "../runtime/types.ts"; 9 | import { processConversationMessages } from "./timestampRestore.ts"; 10 | import { validateEncodedProjectName } from "./pathUtils.ts"; 11 | 12 | /** 13 | * Load a specific conversation by session ID 14 | */ 15 | export async function loadConversation( 16 | encodedProjectName: string, 17 | sessionId: string, 18 | runtime: Runtime, 19 | ): Promise { 20 | // Validate inputs 21 | if (!validateEncodedProjectName(encodedProjectName)) { 22 | throw new Error("Invalid encoded project name"); 23 | } 24 | 25 | if (!validateSessionId(sessionId)) { 26 | throw new Error("Invalid session ID format"); 27 | } 28 | 29 | // Get home directory 30 | const homeDir = runtime.getHomeDir(); 31 | if (!homeDir) { 32 | throw new Error("Home directory not found"); 33 | } 34 | 35 | // Build file path 36 | const historyDir = `${homeDir}/.claude/projects/${encodedProjectName}`; 37 | const filePath = `${historyDir}/${sessionId}.jsonl`; 38 | 39 | // Check if file exists before trying to read it 40 | if (!(await runtime.exists(filePath))) { 41 | return null; // Session not found 42 | } 43 | 44 | try { 45 | const conversationHistory = await parseConversationFile( 46 | filePath, 47 | sessionId, 48 | runtime, 49 | ); 50 | return conversationHistory; 51 | } catch (error) { 52 | throw error; // Re-throw any parsing errors 53 | } 54 | } 55 | 56 | /** 57 | * Parse a specific conversation file 58 | * Converts JSONL lines to timestamped SDK messages 59 | */ 60 | async function parseConversationFile( 61 | filePath: string, 62 | sessionId: string, 63 | runtime: Runtime, 64 | ): Promise { 65 | const content = await runtime.readTextFile(filePath); 66 | const lines = content 67 | .trim() 68 | .split("\n") 69 | .filter((line) => line.trim()); 70 | 71 | if (lines.length === 0) { 72 | throw new Error("Empty conversation file"); 73 | } 74 | 75 | const rawLines: RawHistoryLine[] = []; 76 | 77 | for (const line of lines) { 78 | try { 79 | const parsed = JSON.parse(line) as RawHistoryLine; 80 | rawLines.push(parsed); 81 | } catch (parseError) { 82 | console.error(`Failed to parse line in ${filePath}:`, parseError); 83 | // Continue processing other lines 84 | } 85 | } 86 | 87 | // Process messages (restore timestamps, sort, etc.) 88 | const { messages: processedMessages, metadata } = processConversationMessages( 89 | rawLines, 90 | sessionId, 91 | ); 92 | 93 | return { 94 | sessionId, 95 | messages: processedMessages, 96 | metadata, 97 | }; 98 | } 99 | 100 | /** 101 | * Validate session ID format 102 | * Should be a valid filename without dangerous characters 103 | */ 104 | function validateSessionId(sessionId: string): boolean { 105 | // Should not be empty 106 | if (!sessionId) { 107 | return false; 108 | } 109 | 110 | // Should not contain dangerous characters for filenames 111 | // deno-lint-ignore no-control-regex 112 | const dangerousChars = /[<>:"|?*\x00-\x1f\/\\]/; 113 | if (dangerousChars.test(sessionId)) { 114 | return false; 115 | } 116 | 117 | // Should not be too long (reasonable filename length) 118 | if (sessionId.length > 255) { 119 | return false; 120 | } 121 | 122 | // Should not start with dots (hidden files) 123 | if (sessionId.startsWith(".")) { 124 | return false; 125 | } 126 | 127 | return true; 128 | } 129 | 130 | /** 131 | * Check if a conversation exists without loading it 132 | */ 133 | export async function conversationExists( 134 | encodedProjectName: string, 135 | sessionId: string, 136 | runtime: Runtime, 137 | ): Promise { 138 | try { 139 | const conversation = await loadConversation( 140 | encodedProjectName, 141 | sessionId, 142 | runtime, 143 | ); 144 | return conversation !== null; 145 | } catch { 146 | return false; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /frontend/src/components/native/ChatHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Users, User, MoreHorizontal, Download, Trash2 } from "lucide-react"; 2 | import { useState } from "react"; 3 | import { useAgentConfig } from "../../hooks/useAgentConfig"; 4 | 5 | interface ChatHeaderProps { 6 | currentMode: "group" | "agent"; 7 | activeAgentId: string | null; 8 | onModeToggle: () => void; 9 | } 10 | 11 | const getAgentColor = (agentId: string) => { 12 | // Map agent IDs to CSS color variables, with fallback 13 | const colorMap: Record = { 14 | "readymojo-admin": "var(--agent-admin)", 15 | "readymojo-api": "var(--agent-api)", 16 | "readymojo-web": "var(--agent-web)", 17 | "peakmojo-kit": "var(--agent-kit)", 18 | }; 19 | return colorMap[agentId] || "var(--claude-text-accent)"; 20 | }; 21 | 22 | export function ChatHeader({ currentMode, activeAgentId }: ChatHeaderProps) { 23 | const [showMenu, setShowMenu] = useState(false); 24 | const { getAgentById } = useAgentConfig(); 25 | const currentAgent = activeAgentId ? getAgentById(activeAgentId) : null; 26 | 27 | return ( 28 |
29 |
30 | {currentMode === "group" ? ( 31 | <> 32 |
36 | 37 |
38 |
39 |

Agent Room

40 |

@mention to call out agents

41 |
42 | 43 | ) : ( 44 | <> 45 |
49 | 50 |
51 |
52 |

{currentAgent?.name || "Select Agent"}

53 |

Agent Details

54 |
55 | 56 | )} 57 |
58 | 59 |
60 | {/* More Menu */} 61 |
62 | 68 | 69 | {showMenu && ( 70 |
86 | 99 | 113 |
114 | )} 115 |
116 |
117 | 118 |
119 | ); 120 | } --------------------------------------------------------------------------------