diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3d9607a4d..0b7ace52c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -30,7 +30,7 @@ Node.js backend with full system access: |------|---------| | `index.ts` | App entry, IPC handlers, window management | | `process-manager.ts` | PTY and child process spawning | -| `web-server.ts` | Fastify HTTP/WebSocket server (port 8000) | +| `web-server.ts` | Fastify HTTP/WebSocket server for mobile remote control | | `agent-detector.ts` | Auto-detect CLI tools via PATH | | `preload.ts` | Secure IPC bridge via contextBridge | | `utils/execFile.ts` | Safe command execution utility | @@ -272,7 +272,6 @@ Manages all application settings with automatic persistence. **What it manages:** - LLM settings (provider, model, API key) -- Tunnel settings - Agent settings (default agent) - Shell settings (default shell) - Font settings (family, size, custom fonts) @@ -284,7 +283,6 @@ Manages all application settings with automatic persistence. **Current Persistent Settings:** - `llmProvider`, `modelSlug`, `apiKey` -- `tunnelProvider`, `tunnelApiKey` - `defaultAgent`, `defaultShell` - `fontFamily`, `fontSize`, `customFonts` - `activeThemeId` diff --git a/README.md b/README.md index fac8245c3..d626ffecc 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Download the latest release for your platform from the [Releases](https://github - 📝 **Scratchpad** - Built-in markdown editor with live preview for task management - ⚡ **Slash Commands** - Extensible command system with autocomplete - 📬 **Message Queueing** - Queue messages while AI is busy; they're sent automatically when ready -- 🌐 **Remote Access** - Built-in web server with optional ngrok/Cloudflare tunneling +- 🌐 **Mobile Remote Control** - Access sessions from your phone with QR codes, live sessions, and a mobile-optimized web interface - 💰 **Cost Tracking** - Real-time token usage and cost tracking per session > **Note**: Maestro currently supports Claude Code only. Support for other agentic coding tools may be added in future releases based on community demand. @@ -236,18 +236,27 @@ Settings are stored in: ## Remote Access -Maestro includes a built-in web server for remote access: +Maestro includes a built-in web server for mobile remote control: -1. **Local Access**: `http://localhost:8000` -2. **LAN Access**: `http://[your-ip]:8000` -3. **Public Access**: Enable ngrok or Cloudflare tunnel in Settings +1. **Automatic Security**: Web server runs on a random port with an auto-generated security token embedded in the URL +2. **QR Code Access**: Scan a QR code to connect instantly from your phone +3. **Global Access**: All sessions are accessible when the web interface is enabled - the security token protects access -### Enabling Public Tunnels +### Mobile Web Interface -1. Get an API token from [ngrok.com](https://ngrok.com) or Cloudflare -2. Open Settings > Network -3. Select your tunnel provider and enter your API key -4. Start the tunnel from the session interface +The mobile web interface provides: +- Real-time session monitoring and command input +- Device color scheme preference support (light/dark mode) +- Connection status indicator with automatic reconnection +- Offline queue for commands typed while disconnected +- Swipe gestures for common actions +- Quick actions menu for the send button + +To access sessions from your phone: +1. Click the "OFFLINE" button in the header (next to the MAESTRO logo) to enable the web interface +2. The button changes to "LIVE" and shows a QR code overlay +3. Scan the QR code or copy the secure URL to access all your sessions remotely +4. Click "LIVE" again to view the QR code, or toggle off to disable remote access ## Contributing diff --git a/package-lock.json b/package-lock.json index 8fc700565..cae866af4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@fastify/cors": "^8.5.0", + "@fastify/rate-limit": "^9.1.0", + "@fastify/static": "^7.0.4", "@fastify/websocket": "^9.0.0", "@types/dompurify": "^3.0.5", "ansi-to-html": "^0.7.2", @@ -22,6 +24,8 @@ "fastify": "^4.25.2", "mermaid": "^11.12.1", "node-pty": "^1.0.0", + "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", @@ -31,6 +35,7 @@ }, "devDependencies": { "@types/node": "^20.10.6", + "@types/qrcode": "^1.5.6", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", "@types/react-syntax-highlighter": "^15.5.13", @@ -1120,6 +1125,15 @@ "node": ">=12" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", + "integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@fastify/ajv-compiler": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", @@ -1203,6 +1217,100 @@ "fast-deep-equal": "^3.1.3" } }, + "node_modules/@fastify/rate-limit": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-9.1.0.tgz", + "integrity": "sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==", + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.1", + "fastify-plugin": "^4.0.0", + "toad-cache": "^3.3.1" + } + }, + "node_modules/@fastify/send": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", + "integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==", + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.1", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "2.0.0", + "mime": "^3.0.0" + } + }, + "node_modules/@fastify/send/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@fastify/static": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz", + "integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==", + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^1.0.0", + "@fastify/send": "^2.0.0", + "content-disposition": "^0.5.3", + "fastify-plugin": "^4.0.0", + "fastq": "^1.17.0", + "glob": "^10.3.4" + } + }, + "node_modules/@fastify/static/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/static/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/static/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/@fastify/websocket": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-9.0.0.tgz", @@ -1247,7 +1355,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -1265,7 +1372,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1278,7 +1384,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1291,14 +1396,12 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -1316,7 +1419,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -1332,7 +1434,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -1396,6 +1497,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", @@ -1573,7 +1683,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -2366,6 +2475,16 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", @@ -2642,7 +2761,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2652,7 +2770,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3052,7 +3169,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -3141,7 +3257,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3450,6 +3565,15 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -3760,7 +3884,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3773,7 +3896,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-support": { @@ -4026,6 +4148,18 @@ "dev": true, "license": "ISC" }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4102,7 +4236,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4695,6 +4828,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -4824,6 +4966,15 @@ "dev": true, "license": "MIT" }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4886,6 +5037,12 @@ "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", @@ -5083,7 +5240,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ejs": { @@ -5447,7 +5603,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -5601,6 +5756,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5953,7 +6114,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -6144,7 +6304,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -6593,6 +6752,22 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -6866,7 +7041,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6981,14 +7155,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -8550,7 +8722,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=8" @@ -9224,7 +9395,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/package-manager-detector": { @@ -9287,7 +9457,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9304,7 +9473,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -9321,7 +9489,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/pathe": { @@ -9468,6 +9635,15 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -9745,6 +9921,144 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -10071,7 +10385,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10086,6 +10399,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -10448,7 +10767,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, "license": "ISC" }, "node_modules/set-cookie-parser": { @@ -10457,6 +10775,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shallow-equal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz", @@ -10467,7 +10791,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -10480,7 +10803,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10503,7 +10825,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -10711,6 +11032,15 @@ "node": ">= 6" } }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -10730,7 +11060,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -10746,7 +11075,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -10775,7 +11103,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -10789,7 +11116,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11158,6 +11484,15 @@ "node": ">=12" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11641,7 +11976,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -11653,6 +11987,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -11686,7 +12026,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", diff --git a/package.json b/package.json index 0a5e9e829..22c60108c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "maestro", "version": "0.1.0", "description": "Multi-Instance AI Coding Console - Unified IDE for managing multiple AI coding assistants", - "main": "dist/index.js", + "main": "dist/main/index.js", "author": { "name": "Maestro Team", "email": "maestro@example.com" @@ -16,9 +16,12 @@ "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", "dev:main": "tsc -p tsconfig.main.json && NODE_ENV=development electron .", "dev:renderer": "vite", - "build": "npm run build:main && npm run build:renderer", + "dev:web": "vite --config vite.config.web.mts", + "generate:pwa-icons": "node scripts/generate-pwa-icons.mjs", + "build": "npm run build:main && npm run build:renderer && npm run build:web", "build:main": "tsc -p tsconfig.main.json", "build:renderer": "vite build", + "build:web": "vite build --config vite.config.web.mts", "package": "npm run build && electron-builder --mac --win --linux", "package:mac": "npm run build && electron-builder --mac", "package:win": "npm run build && electron-builder --win", @@ -94,6 +97,8 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@fastify/cors": "^8.5.0", + "@fastify/rate-limit": "^9.1.0", + "@fastify/static": "^7.0.4", "@fastify/websocket": "^9.0.0", "@types/dompurify": "^3.0.5", "ansi-to-html": "^0.7.2", @@ -103,6 +108,8 @@ "fastify": "^4.25.2", "mermaid": "^11.12.1", "node-pty": "^1.0.0", + "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", @@ -112,6 +119,7 @@ }, "devDependencies": { "@types/node": "^20.10.6", + "@types/qrcode": "^1.5.6", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/scripts/generate-pwa-icons.mjs b/scripts/generate-pwa-icons.mjs new file mode 100644 index 000000000..690d5beba --- /dev/null +++ b/scripts/generate-pwa-icons.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * Generate PWA icons from the source icon.png + * + * This script creates all the necessary icon sizes for the PWA manifest. + * Run with: node scripts/generate-pwa-icons.mjs + * + * Requires: sharp (npm install --save-dev sharp) + * + * If sharp is not available, it falls back to copying the source icon + * and the PWA will use whatever size is available. + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.join(__dirname, '..'); + +const SOURCE_ICON = path.join(rootDir, 'build', 'icon.png'); +const OUTPUT_DIR = path.join(rootDir, 'src', 'web', 'public', 'icons'); + +const ICON_SIZES = [72, 96, 128, 144, 152, 192, 384, 512]; + +async function main() { + console.log('Generating PWA icons...'); + + // Ensure output directory exists + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + + // Check if source icon exists + try { + await fs.access(SOURCE_ICON); + } catch { + console.error(`Source icon not found: ${SOURCE_ICON}`); + console.log('Creating placeholder icons instead...'); + await createPlaceholderIcons(); + return; + } + + // Try to use sharp for resizing + let sharp; + try { + sharp = (await import('sharp')).default; + } catch { + console.log('sharp not installed. To enable proper icon generation:'); + console.log(' npm install --save-dev sharp'); + console.log(''); + console.log('Copying source icon as fallback...'); + await copySourceIcon(); + return; + } + + // Generate icons at each size + for (const size of ICON_SIZES) { + const outputPath = path.join(OUTPUT_DIR, `icon-${size}x${size}.png`); + try { + await sharp(SOURCE_ICON) + .resize(size, size, { + fit: 'contain', + background: { r: 26, g: 26, b: 46, alpha: 1 } // #1a1a2e + }) + .png() + .toFile(outputPath); + console.log(` Created: icon-${size}x${size}.png`); + } catch (err) { + console.error(` Failed to create icon-${size}x${size}.png:`, err.message); + } + } + + console.log('Done!'); +} + +async function copySourceIcon() { + // Copy the source icon to each size location + const sourceBuffer = await fs.readFile(SOURCE_ICON); + for (const size of ICON_SIZES) { + const outputPath = path.join(OUTPUT_DIR, `icon-${size}x${size}.png`); + await fs.writeFile(outputPath, sourceBuffer); + console.log(` Copied: icon-${size}x${size}.png (not resized)`); + } + console.log(''); + console.log('Note: Icons were copied but not resized.'); + console.log('Install sharp for proper icon generation: npm install --save-dev sharp'); +} + +async function createPlaceholderIcons() { + // Create a simple SVG placeholder and convert to PNG-like format + // This creates a minimal valid PNG for each size + const svg = ` + + M + `; + + for (const size of ICON_SIZES) { + const outputPath = path.join(OUTPUT_DIR, `icon-${size}x${size}.svg`); + await fs.writeFile(outputPath, svg); + console.log(` Created placeholder: icon-${size}x${size}.svg`); + } + + // Also update manifest to use SVG + console.log(''); + console.log('Note: Created SVG placeholders. Run with source icon for PNG output.'); +} + +main().catch(console.error); diff --git a/src/main/agent-detector.ts b/src/main/agent-detector.ts index 8fb2051db..122c8c30b 100644 --- a/src/main/agent-detector.ts +++ b/src/main/agent-detector.ts @@ -68,15 +68,35 @@ const AGENT_DEFINITIONS: Omit[] = [ export class AgentDetector { private cachedAgents: AgentConfig[] | null = null; + private detectionInProgress: Promise | null = null; /** * Detect which agents are available on the system + * Uses promise deduplication to prevent parallel detection when multiple calls arrive simultaneously */ async detectAgents(): Promise { if (this.cachedAgents) { return this.cachedAgents; } + // If detection is already in progress, return the same promise to avoid parallel runs + if (this.detectionInProgress) { + return this.detectionInProgress; + } + + // Start detection and track the promise + this.detectionInProgress = this.doDetectAgents(); + try { + return await this.detectionInProgress; + } finally { + this.detectionInProgress = null; + } + } + + /** + * Internal method that performs the actual agent detection + */ + private async doDetectAgents(): Promise { const agents: AgentConfig[] = []; const expandedEnv = this.getExpandedEnv(); diff --git a/src/main/index.ts b/src/main/index.ts index d0e1b4069..ac5843bfc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,11 +3,11 @@ import path from 'path'; import fs from 'fs/promises'; import { ProcessManager } from './process-manager'; import { WebServer } from './web-server'; -import { SessionWebServerManager } from './session-web-server'; import { AgentDetector } from './agent-detector'; import { execFileNoThrow } from './utils/execFile'; import { logger } from './utils/logger'; import { detectShells } from './utils/shellDetector'; +import { getThemeById } from './themes'; import Store from 'electron-store'; // Type definitions @@ -25,6 +25,9 @@ interface MaestroSettings { customFonts: string[]; logLevel: 'debug' | 'info' | 'warn' | 'error'; defaultShell: string; + // Web interface authentication + webAuthEnabled: boolean; + webAuthToken: string | null; } const store = new Store({ @@ -43,6 +46,8 @@ const store = new Store({ customFonts: [], logLevel: 'info', defaultShell: 'zsh', + webAuthEnabled: false, + webAuthToken: null, }, }); @@ -157,9 +162,182 @@ const claudeSessionOriginsStore = new Store({ let mainWindow: BrowserWindow | null = null; let processManager: ProcessManager | null = null; let webServer: WebServer | null = null; -let sessionWebServerManager: SessionWebServerManager | null = null; let agentDetector: AgentDetector | null = null; +/** + * Create and configure the web server with all necessary callbacks. + * Called when user enables the web interface. + */ +function createWebServer(): WebServer { + const server = new WebServer(); // Random port with auto-generated security token + + // Set up callback for web server to fetch sessions list + server.setGetSessionsCallback(() => { + const sessions = sessionsStore.get('sessions', []); + const groups = groupsStore.get('groups', []); + return sessions.map((s: any) => { + // Find the group for this session + const group = s.groupId ? groups.find((g: any) => g.id === s.groupId) : null; + + // Extract last AI response for mobile preview (first 3 lines, max 500 chars) + let lastResponse = null; + if (s.aiLogs && s.aiLogs.length > 0) { + // Find the last stdout/stderr entry from the AI (not user messages) + const lastAiLog = [...s.aiLogs].reverse().find((log: any) => + log.source === 'stdout' || log.source === 'stderr' + ); + if (lastAiLog && lastAiLog.text) { + const fullText = lastAiLog.text; + // Get first 3 lines or 500 chars, whichever is shorter + const lines = fullText.split('\n').slice(0, 3); + let previewText = lines.join('\n'); + if (previewText.length > 500) { + previewText = previewText.slice(0, 497) + '...'; + } else if (fullText.length > previewText.length) { + previewText = previewText + '...'; + } + lastResponse = { + text: previewText, + timestamp: lastAiLog.timestamp, + source: lastAiLog.source, + fullLength: fullText.length, + }; + } + } + + return { + id: s.id, + name: s.name, + toolType: s.toolType, + state: s.state, + inputMode: s.inputMode, + cwd: s.cwd, + groupId: s.groupId || null, + groupName: group?.name || null, + groupEmoji: group?.emoji || null, + usageStats: s.usageStats || null, + lastResponse, + claudeSessionId: s.claudeSessionId || null, + }; + }); + }); + + // Set up callback for web server to fetch single session details + server.setGetSessionDetailCallback((sessionId: string) => { + const sessions = sessionsStore.get('sessions', []); + const session = sessions.find((s: any) => s.id === sessionId); + if (!session) return null; + return { + id: session.id, + name: session.name, + toolType: session.toolType, + state: session.state, + inputMode: session.inputMode, + cwd: session.cwd, + aiLogs: session.aiLogs || [], + shellLogs: session.shellLogs || [], + usageStats: session.usageStats, + claudeSessionId: session.claudeSessionId, + isGitRepo: session.isGitRepo, + }; + }); + + // Set up callback for web server to fetch current theme + server.setGetThemeCallback(() => { + const themeId = store.get('activeThemeId', 'dracula'); + return getThemeById(themeId); + }); + + // Set up callback for web server to fetch custom AI commands + server.setGetCustomCommandsCallback(() => { + const customCommands = store.get('customAICommands', []) as Array<{ + id: string; + command: string; + description: string; + prompt: string; + }>; + return customCommands; + }); + + // Set up callback for web server to write commands to sessions + // Note: Process IDs have -ai or -terminal suffix based on session's inputMode + server.setWriteToSessionCallback((sessionId: string, data: string) => { + if (!processManager) { + logger.warn('processManager is null for writeToSession', 'WebServer'); + return false; + } + + // Get the session's current inputMode to determine which process to write to + const sessions = sessionsStore.get('sessions', []); + const session = sessions.find((s: any) => s.id === sessionId); + if (!session) { + logger.warn(`Session ${sessionId} not found for writeToSession`, 'WebServer'); + return false; + } + + // Append -ai or -terminal suffix based on inputMode + const targetSessionId = session.inputMode === 'ai' ? `${sessionId}-ai` : `${sessionId}-terminal`; + logger.debug(`Writing to ${targetSessionId} (inputMode=${session.inputMode})`, 'WebServer'); + + const result = processManager.write(targetSessionId, data); + logger.debug(`Write result: ${result}`, 'WebServer'); + return result; + }); + + // Set up callback for web server to execute commands through the desktop + // This forwards AI commands to the renderer, ensuring single source of truth + // The renderer handles all spawn logic, state management, and broadcasts + server.setExecuteCommandCallback(async (sessionId: string, command: string) => { + if (!mainWindow) { + logger.warn('mainWindow is null for executeCommand', 'WebServer'); + return false; + } + + // Look up the session to get Claude session ID for logging + const sessions = sessionsStore.get('sessions', []); + const session = sessions.find((s: any) => s.id === sessionId); + const claudeSessionId = session?.claudeSessionId || 'none'; + + // Forward to renderer - it will handle spawn, state, and everything else + // This ensures web commands go through exact same code path as desktop commands + logger.info(`[Web → Renderer] Forwarding command | Maestro: ${sessionId} | Claude: ${claudeSessionId} | Command: ${command.substring(0, 100)}`, 'WebServer'); + mainWindow.webContents.send('remote:executeCommand', sessionId, command); + return true; + }); + + // Set up callback for web server to interrupt sessions through the desktop + // This forwards to the renderer which handles state updates and broadcasts + server.setInterruptSessionCallback(async (sessionId: string) => { + if (!mainWindow) { + logger.warn('mainWindow is null for interrupt', 'WebServer'); + return false; + } + + // Forward to renderer - it will handle interrupt, state update, and broadcasts + // This ensures web interrupts go through exact same code path as desktop interrupts + logger.debug(`Forwarding interrupt to renderer for session ${sessionId}`, 'WebServer'); + mainWindow.webContents.send('remote:interrupt', sessionId); + return true; + }); + + // Set up callback for web server to switch session mode through the desktop + // This forwards to the renderer which handles state updates and broadcasts + server.setSwitchModeCallback(async (sessionId: string, mode: 'ai' | 'terminal') => { + if (!mainWindow) { + logger.warn('mainWindow is null for switchMode', 'WebServer'); + return false; + } + + // Forward to renderer - it will handle mode switch and broadcasts + // This ensures web mode switches go through exact same code path as desktop + logger.debug(`Forwarding mode switch to renderer for session ${sessionId}: ${mode}`, 'WebServer'); + mainWindow.webContents.send('remote:switchMode', sessionId, mode); + return true; + }); + + return server; +} + function createWindow() { // Restore saved window state const savedState = windowStateStore.store; @@ -276,33 +454,9 @@ app.whenReady().then(() => { // Initialize core services logger.info('Initializing core services', 'Startup'); processManager = new ProcessManager(); - webServer = new WebServer(8000); + // Note: webServer is created on-demand when user enables web interface (see setupWebServerCallbacks) agentDetector = new AgentDetector(); - // Initialize session web server manager with callbacks - sessionWebServerManager = new SessionWebServerManager( - // getSessionData callback - fetch session from store - (sessionId: string) => { - const sessions = sessionsStore.get('sessions', []); - const session = sessions.find((s: any) => s.id === sessionId); - if (!session) return null; - return { - id: session.id, - name: session.name, - toolType: session.toolType, - state: session.state, - inputMode: session.inputMode, - cwd: session.cwd, - aiLogs: session.aiLogs || [], - shellLogs: session.shellLogs || [], - }; - }, - // writeToSession callback - write to process - (sessionId: string, data: string) => { - if (!processManager) return false; - return processManager.write(sessionId, data); - } - ); logger.info('Core services initialized', 'Startup'); // Set up IPC handlers @@ -317,9 +471,8 @@ app.whenReady().then(() => { logger.info('Creating main window', 'Startup'); createWindow(); - // Start web server for remote access - logger.info('Starting web server on port 8000', 'WebServer'); - webServer.start(); + // Note: Web server is not auto-started - it starts when user enables web interface + // via live:startServer IPC call from the renderer app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { @@ -339,10 +492,8 @@ app.on('before-quit', async () => { // Clean up all running processes logger.info('Killing all running processes', 'Shutdown'); processManager?.killAll(); - logger.info('Stopping session web servers', 'Shutdown'); - await sessionWebServerManager?.stopAll(); logger.info('Stopping web server', 'Shutdown'); - webServer?.stop(); + await webServer?.stop(); logger.info('Shutdown complete', 'Shutdown'); }); @@ -357,6 +508,22 @@ function setupIpcHandlers() { ipcMain.handle('settings:set', async (_, key: string, value: any) => { store.set(key, value); logger.info(`Settings updated: ${key}`, 'Settings', { key, value }); + + // Broadcast theme changes to connected web clients + if (key === 'activeThemeId' && webServer && webServer.getWebClientCount() > 0) { + const theme = getThemeById(value); + if (theme) { + webServer.broadcastThemeChange(theme); + logger.info(`Broadcasted theme change to web clients: ${value}`, 'WebServer'); + } + } + + // Broadcast custom commands changes to connected web clients + if (key === 'customAICommands' && webServer && webServer.getWebClientCount() > 0) { + webServer.broadcastCustomCommands(value); + logger.info(`Broadcasted custom commands change to web clients: ${value.length} commands`, 'WebServer'); + } + return true; }); @@ -372,6 +539,49 @@ function setupIpcHandlers() { }); ipcMain.handle('sessions:setAll', async (_, sessions: any[]) => { + // Get previous sessions to detect changes + const previousSessions = sessionsStore.get('sessions', []); + const previousSessionMap = new Map(previousSessions.map((s: any) => [s.id, s])); + const currentSessionMap = new Map(sessions.map((s: any) => [s.id, s])); + + // Detect and broadcast changes to web clients + if (webServer && webServer.getWebClientCount() > 0) { + // Check for state changes in existing sessions + for (const session of sessions) { + const prevSession = previousSessionMap.get(session.id); + if (prevSession) { + // Session exists - check if state changed + if (prevSession.state !== session.state || + prevSession.inputMode !== session.inputMode || + prevSession.name !== session.name) { + webServer.broadcastSessionStateChange(session.id, session.state, { + name: session.name, + toolType: session.toolType, + inputMode: session.inputMode, + cwd: session.cwd, + }); + } + } else { + // New session added + webServer.broadcastSessionAdded({ + id: session.id, + name: session.name, + toolType: session.toolType, + state: session.state, + inputMode: session.inputMode, + cwd: session.cwd, + }); + } + } + + // Check for removed sessions + for (const prevSession of previousSessions) { + if (!currentSessionMap.has(prevSession.id)) { + webServer.broadcastSessionRemoved(prevSession.id); + } + } + } + sessionsStore.set('sessions', sessions); return true; }); @@ -386,6 +596,15 @@ function setupIpcHandlers() { return true; }); + // Broadcast user input to web clients (called when desktop sends a message) + ipcMain.handle('web:broadcastUserInput', async (_, sessionId: string, command: string, inputMode: 'ai' | 'terminal') => { + if (webServer && webServer.getWebClientCount() > 0) { + webServer.broadcastUserInput(sessionId, command, inputMode); + return true; + } + return false; + }); + // Session/Process management ipcMain.handle('process:spawn', async (_, config: { sessionId: string; @@ -649,37 +868,148 @@ function setupIpcHandlers() { } }); - // Tunnel management - per-session local web server - ipcMain.handle('tunnel:start', async (_, sessionId: string) => { - if (!sessionWebServerManager) { - throw new Error('Session web server manager not initialized'); + // Live session management - toggle sessions as live/offline in web interface + ipcMain.handle('live:toggle', async (_, sessionId: string, claudeSessionId?: string) => { + if (!webServer) { + throw new Error('Web server not initialized'); + } + + // Ensure web server is running before allowing live toggle + if (!webServer.isActive()) { + logger.warn('Web server not yet started, waiting...', 'Live'); + // Wait for server to start (with timeout) + const startTime = Date.now(); + while (!webServer.isActive() && Date.now() - startTime < 5000) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + if (!webServer.isActive()) { + throw new Error('Web server failed to start'); + } + } + + const isLive = webServer.isSessionLive(sessionId); + + if (isLive) { + // Turn off live mode + webServer.setSessionOffline(sessionId); + logger.info(`Session ${sessionId} is now offline`, 'Live'); + return { live: false, url: null }; + } else { + // Turn on live mode + logger.info(`Enabling live mode for session ${sessionId} (claude: ${claudeSessionId || 'none'})`, 'Live'); + webServer.setSessionLive(sessionId, claudeSessionId); + const url = webServer.getSessionUrl(sessionId); + logger.info(`Session ${sessionId} is now live at ${url}`, 'Live'); + return { live: true, url }; } - logger.info(`Starting tunnel for session ${sessionId}`, 'Tunnel'); - const result = await sessionWebServerManager.startServer(sessionId); - logger.info(`Tunnel started for session ${sessionId}: ${result.url}`, 'Tunnel'); - return result; }); - ipcMain.handle('tunnel:stop', async (_, sessionId: string) => { - if (!sessionWebServerManager) { - throw new Error('Session web server manager not initialized'); + ipcMain.handle('live:getStatus', async (_, sessionId: string) => { + if (!webServer) { + return { live: false, url: null }; + } + const isLive = webServer.isSessionLive(sessionId); + return { + live: isLive, + url: isLive ? webServer.getSessionUrl(sessionId) : null, + }; + }); + + ipcMain.handle('live:getDashboardUrl', async () => { + if (!webServer) { + return null; + } + return webServer.getSecureUrl(); + }); + + ipcMain.handle('live:getLiveSessions', async () => { + if (!webServer) { + return []; + } + return webServer.getLiveSessions(); + }); + + ipcMain.handle('live:broadcastActiveSession', async (_, sessionId: string) => { + if (webServer) { + webServer.broadcastActiveSessionChange(sessionId); } - logger.info(`Stopping tunnel for session ${sessionId}`, 'Tunnel'); - await sessionWebServerManager.stopServer(sessionId); - logger.info(`Tunnel stopped for session ${sessionId}`, 'Tunnel'); - return true; }); - ipcMain.handle('tunnel:getStatus', async (_, sessionId: string) => { - if (!sessionWebServerManager) { - return { active: false }; + // Start web server (creates if needed, starts if not running) + ipcMain.handle('live:startServer', async () => { + try { + // Create web server if it doesn't exist + if (!webServer) { + logger.info('Creating web server', 'WebServer'); + webServer = createWebServer(); + } + + // Start if not already running + if (!webServer.isActive()) { + logger.info('Starting web server', 'WebServer'); + const { port, url } = await webServer.start(); + logger.info(`Web server running at ${url} (port ${port})`, 'WebServer'); + return { success: true, url }; + } + + // Already running + return { success: true, url: webServer.getSecureUrl() }; + } catch (error: any) { + logger.error(`Failed to start web server: ${error.message}`, 'WebServer'); + return { success: false, error: error.message }; + } + }); + + // Stop web server and clean up + ipcMain.handle('live:stopServer', async () => { + if (!webServer) { + return { success: true }; + } + + try { + logger.info('Stopping web server', 'WebServer'); + await webServer.stop(); + webServer = null; // Allow garbage collection, will recreate on next start + logger.info('Web server stopped and cleaned up', 'WebServer'); + return { success: true }; + } catch (error: any) { + logger.error(`Failed to stop web server: ${error.message}`, 'WebServer'); + return { success: false, error: error.message }; + } + }); + + // Disable all live sessions and stop the server + ipcMain.handle('live:disableAll', async () => { + if (!webServer) { + return { success: true, count: 0 }; + } + + // First mark all sessions as offline + const liveSessions = webServer.getLiveSessions(); + const count = liveSessions.length; + for (const session of liveSessions) { + webServer.setSessionOffline(session.sessionId); + } + + // Then stop the server + try { + logger.info(`Disabled ${count} live sessions, stopping server`, 'Live'); + await webServer.stop(); + webServer = null; + return { success: true, count }; + } catch (error: any) { + logger.error(`Failed to stop web server during disableAll: ${error.message}`, 'WebServer'); + return { success: false, count, error: error.message }; } - return sessionWebServerManager.getStatus(sessionId); }); // Web server management ipcMain.handle('webserver:getUrl', async () => { - return webServer?.getUrl(); + return webServer?.getSecureUrl(); + }); + + ipcMain.handle('webserver:getConnectedClients', async () => { + return webServer?.getWebClientCount() || 0; }); // Helper to strip non-serializable functions from agent configs @@ -1651,10 +1981,43 @@ function setupProcessListeners() { processManager.on('data', (sessionId: string, data: string) => { console.log('[IPC] Forwarding process:data to renderer:', { sessionId, dataLength: data.length, hasMainWindow: !!mainWindow }); mainWindow?.webContents.send('process:data', sessionId, data); + + // Broadcast to web clients - extract base session ID (remove -ai or -terminal suffix) + // IMPORTANT: Skip PTY terminal output (-terminal suffix) as it contains raw ANSI codes. + // Web interface terminal commands use runCommand() which emits with plain session IDs. + if (webServer) { + // Don't broadcast raw PTY terminal output to web clients + if (sessionId.endsWith('-terminal')) { + console.log(`[WebBroadcast] SKIPPING PTY terminal output for web: session=${sessionId}`); + return; + } + + const baseSessionId = sessionId.replace(/-ai$|-batch-\d+$|-synopsis-\d+$/, ''); + const isAiOutput = sessionId.endsWith('-ai') || sessionId.includes('-batch-') || sessionId.includes('-synopsis-'); + console.log(`[WebBroadcast] Broadcasting session_output: session=${baseSessionId}, source=${isAiOutput ? 'ai' : 'terminal'}, dataLen=${data.length}`); + webServer.broadcastToSessionClients(baseSessionId, { + type: 'session_output', + sessionId: baseSessionId, + data, + source: isAiOutput ? 'ai' : 'terminal', + timestamp: Date.now(), + }); + } }); processManager.on('exit', (sessionId: string, code: number) => { mainWindow?.webContents.send('process:exit', sessionId, code); + + // Broadcast exit to web clients + if (webServer) { + const baseSessionId = sessionId.replace(/-ai$|-terminal$|-batch-\d+$|-synopsis-\d+$/, ''); + webServer.broadcastToSessionClients(baseSessionId, { + type: 'session_exit', + sessionId: baseSessionId, + exitCode: code, + timestamp: Date.now(), + }); + } }); processManager.on('session-id', (sessionId: string, claudeSessionId: string) => { diff --git a/src/main/preload.ts b/src/main/preload.ts index 79dbadecb..05de41d16 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -83,6 +83,29 @@ contextBridge.exposeInMainWorld('maestro', { ipcRenderer.on('process:session-id', handler); return () => ipcRenderer.removeListener('process:session-id', handler); }, + // Remote command execution from web interface + // This allows web commands to go through the same code path as desktop commands + onRemoteCommand: (callback: (sessionId: string, command: string) => void) => { + console.log('[Preload] Registering onRemoteCommand listener'); + const handler = (_: any, sessionId: string, command: string) => { + console.log('[Preload] Received remote:executeCommand IPC:', { sessionId, command: command?.substring(0, 50) }); + callback(sessionId, command); + }; + ipcRenderer.on('remote:executeCommand', handler); + return () => ipcRenderer.removeListener('remote:executeCommand', handler); + }, + // Remote mode switch from web interface - forwards to desktop's toggleInputMode logic + onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => { + const handler = (_: any, sessionId: string, mode: 'ai' | 'terminal') => callback(sessionId, mode); + ipcRenderer.on('remote:switchMode', handler); + return () => ipcRenderer.removeListener('remote:switchMode', handler); + }, + // Remote interrupt from web interface - forwards to desktop's handleInterrupt logic + onRemoteInterrupt: (callback: (sessionId: string) => void) => { + const handler = (_: any, sessionId: string) => callback(sessionId); + ipcRenderer.on('remote:interrupt', handler); + return () => ipcRenderer.removeListener('remote:interrupt', handler); + }, // Stderr listener for runCommand (separate stream) onStderr: (callback: (sessionId: string, data: string) => void) => { const handler = (_: any, sessionId: string, data: string) => callback(sessionId, data); @@ -110,6 +133,13 @@ contextBridge.exposeInMainWorld('maestro', { }, }, + // Web interface API + web: { + // Broadcast user input to web clients (for keeping web interface in sync) + broadcastUserInput: (sessionId: string, command: string, inputMode: 'ai' | 'terminal') => + ipcRenderer.invoke('web:broadcastUserInput', sessionId, command, inputMode), + }, + // Git API git: { status: (cwd: string) => ipcRenderer.invoke('git:status', cwd), @@ -133,13 +163,21 @@ contextBridge.exposeInMainWorld('maestro', { // Web Server API webserver: { getUrl: () => ipcRenderer.invoke('webserver:getUrl'), + getConnectedClients: () => ipcRenderer.invoke('webserver:getConnectedClients'), }, - // Tunnel API - per-session local web server - tunnel: { - start: (sessionId: string) => ipcRenderer.invoke('tunnel:start', sessionId), - stop: (sessionId: string) => ipcRenderer.invoke('tunnel:stop', sessionId), - getStatus: (sessionId: string) => ipcRenderer.invoke('tunnel:getStatus', sessionId), + // Live Session API - toggle sessions as live/offline in web interface + live: { + toggle: (sessionId: string, claudeSessionId?: string) => + ipcRenderer.invoke('live:toggle', sessionId, claudeSessionId), + getStatus: (sessionId: string) => ipcRenderer.invoke('live:getStatus', sessionId), + getDashboardUrl: () => ipcRenderer.invoke('live:getDashboardUrl'), + getLiveSessions: () => ipcRenderer.invoke('live:getLiveSessions'), + broadcastActiveSession: (sessionId: string) => + ipcRenderer.invoke('live:broadcastActiveSession', sessionId), + disableAll: () => ipcRenderer.invoke('live:disableAll'), + startServer: () => ipcRenderer.invoke('live:startServer'), + stopServer: () => ipcRenderer.invoke('live:stopServer'), }, // Agent API @@ -274,6 +312,9 @@ export interface MaestroAPI { onData: (callback: (sessionId: string, data: string) => void) => () => void; onExit: (callback: (sessionId: string, code: number) => void) => () => void; onSessionId: (callback: (sessionId: string, claudeSessionId: string) => void) => () => void; + onRemoteCommand: (callback: (sessionId: string, command: string) => void) => () => void; + onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => () => void; + onRemoteInterrupt: (callback: (sessionId: string) => void) => () => void; onStderr: (callback: (sessionId: string, data: string) => void) => () => void; onCommandExit: (callback: (sessionId: string, code: number) => void) => () => void; onUsage: (callback: (sessionId: string, usageStats: { @@ -318,11 +359,15 @@ export interface MaestroAPI { }; webserver: { getUrl: () => Promise; + getConnectedClients: () => Promise; }; - tunnel: { - start: (sessionId: string) => Promise<{ port: number; uuid: string; url: string }>; - stop: (sessionId: string) => Promise; - getStatus: (sessionId: string) => Promise<{ active: boolean; port?: number; uuid?: string; url?: string }>; + live: { + toggle: (sessionId: string, claudeSessionId?: string) => Promise<{ live: boolean; url: string | null }>; + getStatus: (sessionId: string) => Promise<{ live: boolean; url: string | null }>; + getDashboardUrl: () => Promise; + getLiveSessions: () => Promise>; + broadcastActiveSession: (sessionId: string) => Promise; + disableAll: () => Promise<{ success: boolean; count: number }>; }; agents: { detect: () => Promise; diff --git a/src/main/session-web-server.ts b/src/main/session-web-server.ts deleted file mode 100644 index 5fc273f68..000000000 --- a/src/main/session-web-server.ts +++ /dev/null @@ -1,843 +0,0 @@ -import Fastify, { FastifyInstance } from 'fastify'; -import cors from '@fastify/cors'; -import websocket from '@fastify/websocket'; -import { randomUUID } from 'crypto'; -import { WebSocket } from 'ws'; - -const GITHUB_REDIRECT_URL = 'https://github.com/pedramamini/Maestro'; - -// Find a random available port in the ephemeral range -async function findAvailablePort(): Promise { - return new Promise((resolve, reject) => { - const net = require('net'); - const server = net.createServer(); - server.listen(0, '0.0.0.0', () => { - const port = server.address().port; - server.close(() => resolve(port)); - }); - server.on('error', reject); - }); -} - -interface SessionData { - id: string; - name: string; - toolType: string; - state: string; - inputMode: string; - cwd: string; - aiLogs: Array<{ - id: string; - timestamp: number; - source: string; - content: string; - }>; - shellLogs: Array<{ - id: string; - timestamp: number; - source: string; - content: string; - }>; -} - -type GetSessionDataFn = (sessionId: string) => SessionData | null; -type WriteToSessionFn = (sessionId: string, data: string) => boolean; - -/** - * SessionWebServer - Per-session HTTP/WebSocket server for browser access - * - * Each session can have its own web server instance that: - * - Binds to a random high port on 0.0.0.0 - * - Uses a UUID token for authentication - * - Serves a web UI for viewing and interacting with the session - * - Streams real-time log updates via WebSocket - */ -export class SessionWebServer { - private server: FastifyInstance; - private port: number = 0; - private uuid: string; - private sessionId: string; - private isRunning: boolean = false; - private getSessionData: GetSessionDataFn; - private writeToSession: WriteToSessionFn; - private wsClients: Set = new Set(); - - constructor( - sessionId: string, - getSessionData: GetSessionDataFn, - writeToSession: WriteToSessionFn - ) { - this.sessionId = sessionId; - this.uuid = randomUUID(); - this.getSessionData = getSessionData; - this.writeToSession = writeToSession; - - this.server = Fastify({ - logger: false, // Disable logging for cleaner output - }); - - this.setupMiddleware(); - this.setupRoutes(); - } - - private async setupMiddleware() { - await this.server.register(cors, { - origin: true, - }); - - await this.server.register(websocket); - } - - private setupRoutes() { - // Root path - redirect to GitHub - this.server.get('/', async (_request, reply) => { - return reply.redirect(302, GITHUB_REDIRECT_URL); - }); - - // Catch-all for invalid UUIDs - redirect to GitHub - this.server.get('/:uuid', async (request, reply) => { - const { uuid } = request.params as { uuid: string }; - - if (uuid !== this.uuid) { - return reply.redirect(302, GITHUB_REDIRECT_URL); - } - - // Serve the main UI - return reply.type('text/html').send(this.generateHtml()); - }); - - // Session data API - this.server.get('/:uuid/api/session', async (request, reply) => { - const { uuid } = request.params as { uuid: string }; - - if (uuid !== this.uuid) { - return reply.redirect(302, GITHUB_REDIRECT_URL); - } - - const session = this.getSessionData(this.sessionId); - if (!session) { - return reply.status(404).send({ error: 'Session not found' }); - } - - return { - id: session.id, - name: session.name, - toolType: session.toolType, - state: session.state, - inputMode: session.inputMode, - cwd: session.cwd, - }; - }); - - // Logs API - this.server.get('/:uuid/api/logs', async (request, reply) => { - const { uuid } = request.params as { uuid: string }; - - if (uuid !== this.uuid) { - return reply.redirect(302, GITHUB_REDIRECT_URL); - } - - const session = this.getSessionData(this.sessionId); - if (!session) { - return reply.status(404).send({ error: 'Session not found' }); - } - - // Return logs based on current input mode - const logs = session.inputMode === 'ai' ? session.aiLogs : session.shellLogs; - return { logs, inputMode: session.inputMode }; - }); - - // Input API - send command to session - this.server.post('/:uuid/api/input', async (request, reply) => { - const { uuid } = request.params as { uuid: string }; - - if (uuid !== this.uuid) { - return reply.redirect(302, GITHUB_REDIRECT_URL); - } - - const { input } = request.body as { input: string }; - if (!input) { - return reply.status(400).send({ error: 'Input required' }); - } - - const success = this.writeToSession(this.sessionId, input + '\n'); - return { success }; - }); - - // WebSocket for real-time updates - this.server.get('/:uuid/ws', { websocket: true }, (connection, request) => { - const params = request.params as { uuid: string }; - - if (params.uuid !== this.uuid) { - connection.socket.close(4001, 'Invalid UUID'); - return; - } - - this.wsClients.add(connection.socket); - - connection.socket.on('close', () => { - this.wsClients.delete(connection.socket); - }); - - connection.socket.on('message', (message) => { - try { - const data = JSON.parse(message.toString()); - if (data.type === 'input' && data.content) { - this.writeToSession(this.sessionId, data.content + '\n'); - } - } catch { - // Ignore invalid messages - } - }); - - // Send initial connection confirmation - connection.socket.send(JSON.stringify({ - type: 'connected', - sessionId: this.sessionId, - })); - }); - - // Static assets (CSS served inline in HTML for simplicity) - this.server.get('/:uuid/styles.css', async (request, reply) => { - const { uuid } = request.params as { uuid: string }; - if (uuid !== this.uuid) { - return reply.redirect(302, GITHUB_REDIRECT_URL); - } - return reply.type('text/css').send(this.generateCss()); - }); - - // JavaScript - this.server.get('/:uuid/app.js', async (request, reply) => { - const { uuid } = request.params as { uuid: string }; - if (uuid !== this.uuid) { - return reply.redirect(302, GITHUB_REDIRECT_URL); - } - return reply.type('application/javascript').send(this.generateJs()); - }); - } - - /** - * Broadcast a message to all connected WebSocket clients - */ - broadcast(message: object) { - const data = JSON.stringify(message); - for (const client of this.wsClients) { - if (client.readyState === WebSocket.OPEN) { - client.send(data); - } - } - } - - /** - * Broadcast new log entry to all clients - */ - broadcastLog(log: { id: string; timestamp: number; source: string; content: string }, inputMode: string) { - this.broadcast({ - type: 'log', - log, - inputMode, - }); - } - - /** - * Broadcast session state change - */ - broadcastState(state: string) { - this.broadcast({ - type: 'state', - state, - }); - } - - async start(): Promise<{ port: number; uuid: string; url: string }> { - if (this.isRunning) { - return { port: this.port, uuid: this.uuid, url: this.getUrl() }; - } - - try { - this.port = await findAvailablePort(); - await this.server.listen({ port: this.port, host: '0.0.0.0' }); - this.isRunning = true; - console.log(`Session web server started: ${this.getUrl()}`); - return { port: this.port, uuid: this.uuid, url: this.getUrl() }; - } catch (error) { - console.error('Failed to start session web server:', error); - throw error; - } - } - - async stop() { - if (!this.isRunning) { - return; - } - - try { - // Close all WebSocket connections - for (const client of this.wsClients) { - client.close(1000, 'Server shutting down'); - } - this.wsClients.clear(); - - await this.server.close(); - this.isRunning = false; - console.log(`Session web server stopped for session ${this.sessionId}`); - } catch (error) { - console.error('Failed to stop session web server:', error); - } - } - - getUrl(): string { - return `http://localhost:${this.port}/${this.uuid}`; - } - - getPort(): number { - return this.port; - } - - getUuid(): string { - return this.uuid; - } - - isActive(): boolean { - return this.isRunning; - } - - private generateHtml(): string { - return ` - - - - - Maestro - Session View - - - -
-
-
-

MAESTRO

- Loading... - -- -
-
- -- - -- -
-
- -
-
-
- -
-
- - -
-
- - Disconnected -
-
-
- - -`; - } - - private generateCss(): string { - return ` -:root { - --bg-main: #0d1117; - --bg-sidebar: #161b22; - --bg-activity: #21262d; - --border: #30363d; - --text-main: #e6edf3; - --text-dim: #7d8590; - --accent: #58a6ff; - --success: #3fb950; - --warning: #d29922; - --error: #f85149; -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; - background-color: var(--bg-main); - color: var(--text-main); - height: 100vh; - overflow: hidden; -} - -#app { - display: flex; - flex-direction: column; - height: 100vh; -} - -header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 1.5rem; - background-color: var(--bg-sidebar); - border-bottom: 1px solid var(--border); -} - -.header-left, .header-right { - display: flex; - align-items: center; - gap: 1rem; -} - -h1 { - font-size: 1rem; - font-weight: 700; - letter-spacing: 0.1em; - color: var(--accent); -} - -.session-name { - font-size: 0.875rem; - color: var(--text-main); -} - -.state-badge, .tool-badge, .mode-badge { - font-size: 0.625rem; - font-weight: 700; - text-transform: uppercase; - padding: 0.25rem 0.5rem; - border-radius: 9999px; - border: 1px solid; -} - -.state-badge { - border-color: var(--success); - color: var(--success); - background-color: rgba(63, 185, 80, 0.1); -} - -.state-badge.busy { - border-color: var(--warning); - color: var(--warning); - background-color: rgba(210, 153, 34, 0.1); -} - -.state-badge.error { - border-color: var(--error); - color: var(--error); - background-color: rgba(248, 81, 73, 0.1); -} - -.tool-badge { - border-color: var(--accent); - color: var(--accent); - background-color: rgba(88, 166, 255, 0.1); -} - -.mode-badge { - border-color: var(--text-dim); - color: var(--text-dim); - background-color: var(--bg-activity); -} - -main { - flex: 1; - overflow: hidden; - display: flex; - flex-direction: column; -} - -.logs-container { - flex: 1; - overflow-y: auto; - padding: 1rem; - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; - font-size: 0.8125rem; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; -} - -.log-entry { - margin-bottom: 0.25rem; - padding: 0.25rem 0; -} - -.log-entry.stdin { - color: var(--accent); -} - -.log-entry.stdout { - color: var(--text-main); -} - -.log-entry.stderr { - color: var(--error); -} - -.log-entry.system { - color: var(--text-dim); - font-style: italic; -} - -footer { - padding: 1rem 1.5rem; - background-color: var(--bg-sidebar); - border-top: 1px solid var(--border); - display: flex; - gap: 1rem; - align-items: center; -} - -#input-form { - flex: 1; - display: flex; - gap: 0.5rem; -} - -#input-field { - flex: 1; - padding: 0.75rem 1rem; - border: 1px solid var(--border); - border-radius: 0.375rem; - background-color: var(--bg-main); - color: var(--text-main); - font-family: inherit; - font-size: 0.875rem; - outline: none; -} - -#input-field:focus { - border-color: var(--accent); -} - -button[type="submit"] { - padding: 0.75rem 1.5rem; - border: none; - border-radius: 0.375rem; - background-color: var(--accent); - color: white; - font-weight: 600; - cursor: pointer; - transition: opacity 0.2s; -} - -button[type="submit"]:hover { - opacity: 0.9; -} - -.connection-status { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.75rem; - color: var(--text-dim); -} - -.ws-indicator { - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--error); -} - -.ws-indicator.connected { - background-color: var(--success); -} - -.ws-indicator.disconnected { - background-color: var(--error); -} - -/* Scrollbar styling */ -.logs-container::-webkit-scrollbar { - width: 8px; -} - -.logs-container::-webkit-scrollbar-track { - background: var(--bg-main); -} - -.logs-container::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; -} - -.logs-container::-webkit-scrollbar-thumb:hover { - background: var(--text-dim); -} - -/* Mobile responsive */ -@media (max-width: 640px) { - header { - flex-direction: column; - gap: 0.75rem; - align-items: flex-start; - } - - footer { - flex-direction: column; - } - - #input-form { - width: 100%; - } - - .connection-status { - width: 100%; - justify-content: center; - } -} -`; - } - - private generateJs(): string { - return ` -(function() { - const uuid = '${this.uuid}'; - const sessionId = '${this.sessionId}'; - - let ws = null; - let reconnectAttempts = 0; - const maxReconnectAttempts = 10; - const reconnectDelay = 2000; - - const logsContainer = document.getElementById('logs'); - const inputForm = document.getElementById('input-form'); - const inputField = document.getElementById('input-field'); - const sessionName = document.getElementById('session-name'); - const sessionState = document.getElementById('session-state'); - const toolType = document.getElementById('tool-type'); - const inputMode = document.getElementById('input-mode'); - const wsStatus = document.getElementById('ws-status'); - const wsStatusText = document.getElementById('ws-status-text'); - - // Fetch initial session data - async function fetchSessionData() { - try { - const response = await fetch('/' + uuid + '/api/session'); - if (response.ok) { - const data = await response.json(); - updateSessionInfo(data); - } - } catch (error) { - console.error('Failed to fetch session data:', error); - } - } - - // Fetch initial logs - async function fetchLogs() { - try { - const response = await fetch('/' + uuid + '/api/logs'); - if (response.ok) { - const data = await response.json(); - renderLogs(data.logs); - inputMode.textContent = data.inputMode.toUpperCase(); - } - } catch (error) { - console.error('Failed to fetch logs:', error); - } - } - - function updateSessionInfo(data) { - sessionName.textContent = data.name; - sessionState.textContent = data.state.toUpperCase(); - sessionState.className = 'state-badge ' + (data.state === 'busy' ? 'busy' : data.state === 'error' ? 'error' : ''); - toolType.textContent = data.toolType.toUpperCase().replace('-', ' '); - inputMode.textContent = data.inputMode.toUpperCase(); - } - - function clearLogs() { - while (logsContainer.firstChild) { - logsContainer.removeChild(logsContainer.firstChild); - } - } - - function renderLogs(logs) { - clearLogs(); - logs.forEach(function(log) { appendLog(log); }); - scrollToBottom(); - } - - function appendLog(log) { - const entry = document.createElement('div'); - entry.className = 'log-entry ' + log.source; - entry.textContent = log.content; - logsContainer.appendChild(entry); - } - - function scrollToBottom() { - logsContainer.scrollTop = logsContainer.scrollHeight; - } - - function setConnectionStatus(connected) { - wsStatus.className = 'ws-indicator ' + (connected ? 'connected' : 'disconnected'); - wsStatusText.textContent = connected ? 'Connected' : 'Disconnected'; - } - - function connectWebSocket() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = protocol + '//' + window.location.host + '/' + uuid + '/ws'; - - ws = new WebSocket(wsUrl); - - ws.onopen = function() { - console.log('WebSocket connected'); - setConnectionStatus(true); - reconnectAttempts = 0; - }; - - ws.onclose = function() { - console.log('WebSocket disconnected'); - setConnectionStatus(false); - - if (reconnectAttempts < maxReconnectAttempts) { - reconnectAttempts++; - setTimeout(connectWebSocket, reconnectDelay); - } - }; - - ws.onerror = function(error) { - console.error('WebSocket error:', error); - }; - - ws.onmessage = function(event) { - try { - const data = JSON.parse(event.data); - - switch (data.type) { - case 'log': - appendLog(data.log); - scrollToBottom(); - if (data.inputMode) { - inputMode.textContent = data.inputMode.toUpperCase(); - } - break; - case 'state': - sessionState.textContent = data.state.toUpperCase(); - sessionState.className = 'state-badge ' + (data.state === 'busy' ? 'busy' : data.state === 'error' ? 'error' : ''); - break; - case 'connected': - console.log('Session connected:', data.sessionId); - break; - } - } catch (error) { - console.error('Failed to parse WebSocket message:', error); - } - }; - } - - // Handle form submission - inputForm.addEventListener('submit', async function(e) { - e.preventDefault(); - - const input = inputField.value.trim(); - if (!input) return; - - try { - // Send via WebSocket if connected, otherwise via HTTP - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'input', content: input })); - } else { - await fetch('/' + uuid + '/api/input', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: input }), - }); - } - - inputField.value = ''; - } catch (error) { - console.error('Failed to send input:', error); - } - }); - - // Initialize - fetchSessionData(); - fetchLogs(); - connectWebSocket(); - - // Periodically refresh session state - setInterval(fetchSessionData, 10000); -})(); -`; - } -} - -// Manager for multiple session web servers -export class SessionWebServerManager { - private servers: Map = new Map(); - private getSessionData: GetSessionDataFn; - private writeToSession: WriteToSessionFn; - - constructor(getSessionData: GetSessionDataFn, writeToSession: WriteToSessionFn) { - this.getSessionData = getSessionData; - this.writeToSession = writeToSession; - } - - async startServer(sessionId: string): Promise<{ port: number; uuid: string; url: string }> { - // Stop existing server if any - await this.stopServer(sessionId); - - const server = new SessionWebServer(sessionId, this.getSessionData, this.writeToSession); - const result = await server.start(); - this.servers.set(sessionId, server); - return result; - } - - async stopServer(sessionId: string): Promise { - const server = this.servers.get(sessionId); - if (server) { - await server.stop(); - this.servers.delete(sessionId); - } - } - - getServer(sessionId: string): SessionWebServer | undefined { - return this.servers.get(sessionId); - } - - getStatus(sessionId: string): { active: boolean; port?: number; uuid?: string; url?: string } { - const server = this.servers.get(sessionId); - if (!server || !server.isActive()) { - return { active: false }; - } - return { - active: true, - port: server.getPort(), - uuid: server.getUuid(), - url: server.getUrl(), - }; - } - - /** - * Broadcast a log entry to clients of a specific session - */ - broadcastLog(sessionId: string, log: { id: string; timestamp: number; source: string; content: string }, inputMode: string) { - const server = this.servers.get(sessionId); - if (server?.isActive()) { - server.broadcastLog(log, inputMode); - } - } - - /** - * Broadcast state change to clients of a specific session - */ - broadcastState(sessionId: string, state: string) { - const server = this.servers.get(sessionId); - if (server?.isActive()) { - server.broadcastState(state); - } - } - - async stopAll(): Promise { - const promises = Array.from(this.servers.keys()).map(id => this.stopServer(id)); - await Promise.all(promises); - } -} diff --git a/src/main/themes.ts b/src/main/themes.ts new file mode 100644 index 000000000..c529b503b --- /dev/null +++ b/src/main/themes.ts @@ -0,0 +1,326 @@ +// Theme definitions for the web interface +// This mirrors src/renderer/constants/themes.ts for use in the main process +// When themes are updated in the renderer, this file should also be updated + +import type { Theme, ThemeId } from '../shared/theme-types'; + +// Re-export types from shared for convenience +export type { Theme, ThemeId } from '../shared/theme-types'; + +export const THEMES: Record = { + // Dark themes + dracula: { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#0b0b0d', + bgSidebar: '#111113', + bgActivity: '#1c1c1f', + border: '#27272a', + textMain: '#e4e4e7', + textDim: '#a1a1aa', + accent: '#6366f1', + accentDim: 'rgba(99, 102, 241, 0.2)', + accentText: '#a5b4fc', + success: '#22c55e', + warning: '#eab308', + error: '#ef4444' + } + }, + monokai: { + id: 'monokai', + name: 'Monokai', + mode: 'dark', + colors: { + bgMain: '#272822', + bgSidebar: '#1e1f1c', + bgActivity: '#3e3d32', + border: '#49483e', + textMain: '#f8f8f2', + textDim: '#8f908a', + accent: '#fd971f', + accentDim: 'rgba(253, 151, 31, 0.2)', + accentText: '#fdbf6f', + success: '#a6e22e', + warning: '#e6db74', + error: '#f92672' + } + }, + nord: { + id: 'nord', + name: 'Nord', + mode: 'dark', + colors: { + bgMain: '#2e3440', + bgSidebar: '#3b4252', + bgActivity: '#434c5e', + border: '#4c566a', + textMain: '#eceff4', + textDim: '#d8dee9', + accent: '#88c0d0', + accentDim: 'rgba(136, 192, 208, 0.2)', + accentText: '#8fbcbb', + success: '#a3be8c', + warning: '#ebcb8b', + error: '#bf616a' + } + }, + 'tokyo-night': { + id: 'tokyo-night', + name: 'Tokyo Night', + mode: 'dark', + colors: { + bgMain: '#1a1b26', + bgSidebar: '#16161e', + bgActivity: '#24283b', + border: '#414868', + textMain: '#c0caf5', + textDim: '#9aa5ce', + accent: '#7aa2f7', + accentDim: 'rgba(122, 162, 247, 0.2)', + accentText: '#7dcfff', + success: '#9ece6a', + warning: '#e0af68', + error: '#f7768e' + } + }, + 'catppuccin-mocha': { + id: 'catppuccin-mocha', + name: 'Catppuccin Mocha', + mode: 'dark', + colors: { + bgMain: '#1e1e2e', + bgSidebar: '#181825', + bgActivity: '#313244', + border: '#45475a', + textMain: '#cdd6f4', + textDim: '#bac2de', + accent: '#89b4fa', + accentDim: 'rgba(137, 180, 250, 0.2)', + accentText: '#89dceb', + success: '#a6e3a1', + warning: '#f9e2af', + error: '#f38ba8' + } + }, + 'gruvbox-dark': { + id: 'gruvbox-dark', + name: 'Gruvbox Dark', + mode: 'dark', + colors: { + bgMain: '#282828', + bgSidebar: '#1d2021', + bgActivity: '#3c3836', + border: '#504945', + textMain: '#ebdbb2', + textDim: '#a89984', + accent: '#83a598', + accentDim: 'rgba(131, 165, 152, 0.2)', + accentText: '#8ec07c', + success: '#b8bb26', + warning: '#fabd2f', + error: '#fb4934' + } + }, + // Light themes + 'github-light': { + id: 'github-light', + name: 'GitHub', + mode: 'light', + colors: { + bgMain: '#ffffff', + bgSidebar: '#f6f8fa', + bgActivity: '#eff2f5', + border: '#d0d7de', + textMain: '#24292f', + textDim: '#57606a', + accent: '#0969da', + accentDim: 'rgba(9, 105, 218, 0.1)', + accentText: '#0969da', + success: '#1a7f37', + warning: '#9a6700', + error: '#cf222e' + } + }, + 'solarized-light': { + id: 'solarized-light', + name: 'Solarized', + mode: 'light', + colors: { + bgMain: '#fdf6e3', + bgSidebar: '#eee8d5', + bgActivity: '#e6dfc8', + border: '#d3cbb7', + textMain: '#657b83', + textDim: '#93a1a1', + accent: '#2aa198', + accentDim: 'rgba(42, 161, 152, 0.1)', + accentText: '#2aa198', + success: '#859900', + warning: '#b58900', + error: '#dc322f' + } + }, + 'one-light': { + id: 'one-light', + name: 'One Light', + mode: 'light', + colors: { + bgMain: '#fafafa', + bgSidebar: '#f0f0f0', + bgActivity: '#e5e5e6', + border: '#d0d0d0', + textMain: '#383a42', + textDim: '#a0a1a7', + accent: '#4078f2', + accentDim: 'rgba(64, 120, 242, 0.1)', + accentText: '#4078f2', + success: '#50a14f', + warning: '#c18401', + error: '#e45649' + } + }, + 'gruvbox-light': { + id: 'gruvbox-light', + name: 'Gruvbox Light', + mode: 'light', + colors: { + bgMain: '#fbf1c7', + bgSidebar: '#ebdbb2', + bgActivity: '#d5c4a1', + border: '#bdae93', + textMain: '#3c3836', + textDim: '#7c6f64', + accent: '#458588', + accentDim: 'rgba(69, 133, 136, 0.1)', + accentText: '#076678', + success: '#98971a', + warning: '#d79921', + error: '#cc241d' + } + }, + 'catppuccin-latte': { + id: 'catppuccin-latte', + name: 'Catppuccin Latte', + mode: 'light', + colors: { + bgMain: '#eff1f5', + bgSidebar: '#e6e9ef', + bgActivity: '#dce0e8', + border: '#bcc0cc', + textMain: '#4c4f69', + textDim: '#5c5f77', + accent: '#1e66f5', + accentDim: 'rgba(30, 102, 245, 0.1)', + accentText: '#1e66f5', + success: '#40a02b', + warning: '#df8e1d', + error: '#d20f39' + } + }, + 'ayu-light': { + id: 'ayu-light', + name: 'Ayu Light', + mode: 'light', + colors: { + bgMain: '#fafafa', + bgSidebar: '#f3f4f5', + bgActivity: '#e7e8e9', + border: '#d9d9d9', + textMain: '#5c6166', + textDim: '#828c99', + accent: '#55b4d4', + accentDim: 'rgba(85, 180, 212, 0.1)', + accentText: '#399ee6', + success: '#86b300', + warning: '#f2ae49', + error: '#f07171' + } + }, + // Vibe themes + pedurple: { + id: 'pedurple', + name: 'Pedurple', + mode: 'vibe', + colors: { + bgMain: '#1a0f24', + bgSidebar: '#140a1c', + bgActivity: '#2a1a3a', + border: '#4a2a6a', + textMain: '#e8d5f5', + textDim: '#b89fd0', + accent: '#d4af37', + accentDim: 'rgba(212, 175, 55, 0.25)', + accentText: '#ffd700', + success: '#7cb342', + warning: '#ff69b4', + error: '#da70d6' + } + }, + 'maestros-choice': { + id: 'maestros-choice', + name: "Maestro's Choice", + mode: 'vibe', + colors: { + bgMain: '#0a0a0f', + bgSidebar: '#05050a', + bgActivity: '#12121a', + border: '#2a2a3a', + textMain: '#f0e6d3', + textDim: '#8a8078', + accent: '#c9a227', + accentDim: 'rgba(201, 162, 39, 0.2)', + accentText: '#e6b830', + success: '#4a9c6d', + warning: '#c9a227', + error: '#8b2942' + } + }, + 'dre-synth': { + id: 'dre-synth', + name: 'Dre Synth', + mode: 'vibe', + colors: { + bgMain: '#0d0221', + bgSidebar: '#0a0118', + bgActivity: '#150530', + border: '#2a1050', + textMain: '#f0e6ff', + textDim: '#9080b0', + accent: '#ff2a6d', + accentDim: 'rgba(255, 42, 109, 0.25)', + accentText: '#ff6b9d', + success: '#05ffa1', + warning: '#00f5d4', + error: '#ff2a6d' + } + }, + inquest: { + id: 'inquest', + name: 'InQuest', + mode: 'vibe', + colors: { + bgMain: '#0a0a0a', + bgSidebar: '#050505', + bgActivity: '#141414', + border: '#2a2a2a', + textMain: '#f5f5f5', + textDim: '#888888', + accent: '#cc0033', + accentDim: 'rgba(204, 0, 51, 0.25)', + accentText: '#ff3355', + success: '#f5f5f5', + warning: '#cc0033', + error: '#cc0033' + } + } +}; + +/** + * Get a theme by its ID + * Returns null if the theme ID is not found + */ +export function getThemeById(themeId: string): Theme | null { + return THEMES[themeId as ThemeId] || null; +} diff --git a/src/main/utils/networkUtils.ts b/src/main/utils/networkUtils.ts new file mode 100644 index 000000000..1cf170fee --- /dev/null +++ b/src/main/utils/networkUtils.ts @@ -0,0 +1,169 @@ +/** + * Network Utilities + * + * Provides utilities for network-related operations, including + * detecting the local IP address that routes to the internet. + */ + +import { networkInterfaces } from 'os'; +import * as dgram from 'dgram'; + +/** + * Get the local IP address that routes to the internet. + * + * This uses a UDP socket to connect to an external IP (8.8.8.8 - Google DNS) + * without actually sending data. The OS will pick the right interface, + * and we can get the local address from the socket. + * + * Falls back to scanning network interfaces if the UDP approach fails. + * + * @returns Promise resolving to the local IP address, or 'localhost' if none found + */ +export async function getLocalIpAddress(): Promise { + // Try UDP socket approach first - most reliable + try { + const ip = await getIpViaUdp(); + if (ip && ip !== '127.0.0.1') { + return ip; + } + } catch { + // Fall through to interface scanning + } + + // Fall back to scanning network interfaces + return getIpFromInterfaces(); +} + +/** + * Get local IP by creating a UDP socket that "connects" to an external address. + * The OS routes the connection and we can read which local IP it would use. + */ +function getIpViaUdp(): Promise { + return new Promise((resolve, reject) => { + const socket = dgram.createSocket('udp4'); + let settled = false; + + const cleanup = () => { + if (!settled) { + settled = true; + socket.removeAllListeners(); + try { + socket.close(); + } catch { + // Ignore close errors + } + } + }; + + socket.on('error', (err) => { + if (settled) return; + cleanup(); + reject(err); + }); + + // Connect to Google DNS - doesn't actually send data + socket.connect(53, '8.8.8.8', () => { + if (settled) return; + try { + const address = socket.address(); + cleanup(); + resolve(address.address); + } catch (err) { + cleanup(); + reject(err); + } + }); + + // Timeout after 1 second + setTimeout(() => { + if (settled) return; + cleanup(); + reject(new Error('Timeout')); + }, 1000); + }); +} + +/** + * Get local IP by scanning network interfaces. + * Prefers interfaces that look like they connect to the internet. + */ +function getIpFromInterfaces(): string { + const interfaces = networkInterfaces(); + const candidates: Array<{ ip: string; priority: number }> = []; + + for (const [name, addrs] of Object.entries(interfaces)) { + if (!addrs) continue; + + for (const addr of addrs) { + // Skip internal and non-IPv4 addresses + if (addr.internal || addr.family !== 'IPv4') continue; + if (addr.address === '127.0.0.1') continue; + + // Prioritize interfaces that are likely to route to internet + let priority = 0; + + // Ethernet interfaces (en0, eth0) get highest priority + if (/^(en|eth)\d+$/.test(name)) { + priority = 100; + } + // WiFi interfaces + else if (/wifi|wlan|wl/i.test(name)) { + priority = 90; + } + // Bridge interfaces (common on Mac for internet sharing) + else if (/bridge/i.test(name)) { + priority = 50; + } + // Virtual interfaces get lower priority + else if (/veth|docker|vmnet|vbox|tun|tap/i.test(name)) { + priority = 10; + } + // Other interfaces + else { + priority = 30; + } + + // Private IP ranges are preferred over other ranges + if (isPrivateIp(addr.address)) { + priority += 5; + } + + candidates.push({ ip: addr.address, priority }); + } + } + + if (candidates.length === 0) { + return 'localhost'; + } + + // Sort by priority (highest first) and return the best + candidates.sort((a, b) => b.priority - a.priority); + return candidates[0].ip; +} + +/** + * Check if an IP address is in a private range + */ +function isPrivateIp(ip: string): boolean { + const parts = ip.split('.').map(Number); + if (parts.length !== 4) return false; + + // 10.0.0.0 - 10.255.255.255 + if (parts[0] === 10) return true; + + // 172.16.0.0 - 172.31.255.255 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; + + // 192.168.0.0 - 192.168.255.255 + if (parts[0] === 192 && parts[1] === 168) return true; + + return false; +} + +/** + * Synchronous version that only uses interface scanning. + * Use this when async is not available. + */ +export function getLocalIpAddressSync(): string { + return getIpFromInterfaces(); +} diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 47af9c4c9..6efa18f08 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -1,36 +1,208 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import websocket from '@fastify/websocket'; -import { FastifyInstance } from 'fastify'; +import rateLimit from '@fastify/rate-limit'; +import fastifyStatic from '@fastify/static'; +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { WebSocket } from 'ws'; +import { randomUUID } from 'crypto'; +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; +import type { Theme } from '../shared/theme-types'; +import { getLocalIpAddressSync } from './utils/networkUtils'; +import { logger } from './utils/logger'; + +// Logger context for all web server logs +const LOG_CONTEXT = 'WebServer'; + +const GITHUB_REDIRECT_URL = 'https://github.com/pedramamini/Maestro'; + +// Types for web client messages +interface WebClientMessage { + type: string; + [key: string]: unknown; +} + +// Web client connection info +interface WebClient { + socket: WebSocket; + id: string; + connectedAt: number; + subscribedSessionId?: string; // Which session this client is viewing (if any) +} + +// Live session info +interface LiveSessionInfo { + sessionId: string; + claudeSessionId?: string; + enabledAt: number; +} + +// Rate limiting configuration +export interface RateLimitConfig { + // Maximum requests per time window + max: number; + // Time window in milliseconds + timeWindow: number; + // Maximum requests for POST endpoints (typically lower) + maxPost: number; + // Enable/disable rate limiting + enabled: boolean; +} /** * WebServer - HTTP and WebSocket server for remote access * - * STATUS: Partial implementation (Phase 6 - Remote Access & Tunneling) - * - * Current functionality: - * - Health check endpoint (/health) - WORKING - * - WebSocket echo endpoint (/ws) - PLACEHOLDER (echoes messages for connectivity testing) - * - Session list endpoint (/api/sessions) - PLACEHOLDER (returns empty array) - * - Session detail endpoint (/api/sessions/:id) - PLACEHOLDER (returns stub data) + * Architecture: + * - Single server on random port + * - Security token (UUID) generated at startup, required in all URLs + * - Routes: /$TOKEN/ (dashboard), /$TOKEN/session/:id (session view) + * - Live sessions: Only sessions marked as "live" appear in dashboard + * - WebSocket: Real-time updates for session state, logs, theme * - * Phase 6 implementation plan: - * - Integrate with ProcessManager to expose real session data - * - Implement real-time session state broadcasting via WebSocket - * - Stream process output to connected clients - * - Handle input commands from remote clients - * - Add authentication and authorization - * - Support mobile/tablet responsive UI - * - Integrate with ngrok tunneling for public access + * URL Structure: + * http://localhost:PORT/$TOKEN/ → Dashboard (all live sessions) + * http://localhost:PORT/$TOKEN/session/$UUID → Single session view + * http://localhost:PORT/$TOKEN/api/* → REST API + * http://localhost:PORT/$TOKEN/ws → WebSocket * - * See PRD.md Phase 6 for full requirements. + * Security: + * - Token regenerated on each app restart + * - Invalid/missing token redirects to GitHub + * - No access without knowing the token */ +// Usage stats type for session cost/token tracking +export interface SessionUsageStats { + inputTokens?: number; + outputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + totalCostUsd?: number; + contextWindow?: number; +} + +// Last response type for mobile preview (truncated to save bandwidth) +export interface LastResponsePreview { + text: string; // First 3 lines or ~500 chars of the last AI response + timestamp: number; + source: 'stdout' | 'stderr' | 'system'; + fullLength: number; // Total length of the original response +} + +// Callback type for fetching sessions data +export type GetSessionsCallback = () => Array<{ + id: string; + name: string; + toolType: string; + state: string; + inputMode: string; + cwd: string; + groupId: string | null; + groupName: string | null; + groupEmoji: string | null; + usageStats?: SessionUsageStats | null; + lastResponse?: LastResponsePreview | null; + claudeSessionId?: string | null; +}>; + +// Session detail type for single session endpoint +export interface SessionDetail { + id: string; + name: string; + toolType: string; + state: string; + inputMode: string; + cwd: string; + aiLogs?: Array<{ timestamp: number; content: string; type?: string }>; + shellLogs?: Array<{ timestamp: number; content: string; type?: string }>; + usageStats?: { + inputTokens?: number; + outputTokens?: number; + totalCost?: number; + }; + claudeSessionId?: string; + isGitRepo?: boolean; +} + +// Callback type for fetching single session details +export type GetSessionDetailCallback = (sessionId: string) => SessionDetail | null; + +// Callback type for sending commands to a session +// Returns true if successful, false if session not found or write failed +export type WriteToSessionCallback = (sessionId: string, data: string) => boolean; + +// Callback type for executing a command through the desktop's existing logic +// This forwards the command to the renderer which handles spawn, state, and broadcasts +// Returns true if command was accepted (session not busy) +export type ExecuteCommandCallback = ( + sessionId: string, + command: string +) => Promise; + +// Callback type for interrupting a session through the desktop's existing logic +// This forwards to the renderer which handles state updates and broadcasts +export type InterruptSessionCallback = (sessionId: string) => Promise; + +// Callback type for switching session input mode through the desktop's existing logic +// This forwards to the renderer which handles state updates and broadcasts +export type SwitchModeCallback = ( + sessionId: string, + mode: 'ai' | 'terminal' +) => Promise; + +// Re-export Theme type from shared for backwards compatibility +export type { Theme } from '../shared/theme-types'; + +// Callback type for fetching current theme +export type GetThemeCallback = () => Theme | null; + +// Custom AI command definition (matches renderer's CustomAICommand) +export interface CustomAICommand { + id: string; + command: string; + description: string; + prompt: string; +} + +// Callback type for fetching custom AI commands +export type GetCustomCommandsCallback = () => CustomAICommand[]; + +// Default rate limit configuration +const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig = { + max: 100, // 100 requests per minute for GET endpoints + timeWindow: 60000, // 1 minute in milliseconds + maxPost: 30, // 30 requests per minute for POST endpoints (more restrictive) + enabled: true, +}; + export class WebServer { private server: FastifyInstance; private port: number; private isRunning: boolean = false; + private webClients: Map = new Map(); + private clientIdCounter: number = 0; + private rateLimitConfig: RateLimitConfig = { ...DEFAULT_RATE_LIMIT_CONFIG }; + private getSessionsCallback: GetSessionsCallback | null = null; + private getSessionDetailCallback: GetSessionDetailCallback | null = null; + private getThemeCallback: GetThemeCallback | null = null; + private getCustomCommandsCallback: GetCustomCommandsCallback | null = null; + private writeToSessionCallback: WriteToSessionCallback | null = null; + private executeCommandCallback: ExecuteCommandCallback | null = null; + private interruptSessionCallback: InterruptSessionCallback | null = null; + private switchModeCallback: SwitchModeCallback | null = null; + private webAssetsPath: string | null = null; + + // Security token - regenerated on each app startup + private securityToken: string; - constructor(port: number = 8000) { + // Local IP address for generating URLs (detected at startup) + private localIpAddress: string = 'localhost'; + + // Live sessions - only these appear in the web interface + private liveSessions: Map = new Map(); + + constructor(port: number = 0) { + // Use port 0 to let OS assign a random available port this.port = port; this.server = Fastify({ logger: { @@ -38,8 +210,256 @@ export class WebServer { }, }); - this.setupMiddleware(); - this.setupRoutes(); + // Generate a new security token (UUID v4) + this.securityToken = randomUUID(); + logger.debug('Security token generated', LOG_CONTEXT); + + // Determine web assets path (production vs development) + this.webAssetsPath = this.resolveWebAssetsPath(); + + // Note: setupMiddleware and setupRoutes are called in start() to handle async properly + } + + /** + * Resolve the path to web assets + * In production: dist/web relative to app root + * In development: same location but might not exist until built + */ + private resolveWebAssetsPath(): string | null { + // Try multiple locations for the web assets + const possiblePaths = [ + // Production: relative to the compiled main process + path.join(__dirname, '..', 'web'), + // Development: from project root + path.join(process.cwd(), 'dist', 'web'), + // Alternative: relative to __dirname going up to dist + path.join(__dirname, 'web'), + ]; + + for (const p of possiblePaths) { + if (existsSync(path.join(p, 'index.html'))) { + logger.debug(`Web assets found at: ${p}`, LOG_CONTEXT); + return p; + } + } + + logger.warn('Web assets not found. Web interface will not be served. Run "npm run build:web" to build web assets.', LOG_CONTEXT); + return null; + } + + /** + * Serve the index.html file for SPA routes + * Rewrites asset paths to include the security token + */ + private serveIndexHtml(reply: FastifyReply, sessionId?: string): void { + if (!this.webAssetsPath) { + reply.code(503).send({ + error: 'Service Unavailable', + message: 'Web interface not built. Run "npm run build:web" to build web assets.', + }); + return; + } + + const indexPath = path.join(this.webAssetsPath, 'index.html'); + if (!existsSync(indexPath)) { + reply.code(404).send({ + error: 'Not Found', + message: 'Web interface index.html not found.', + }); + return; + } + + try { + // Read and transform the HTML to fix asset paths + let html = readFileSync(indexPath, 'utf-8'); + + // Transform relative paths to use the token-prefixed absolute paths + html = html.replace(/\.\/assets\//g, `/${this.securityToken}/assets/`); + html = html.replace(/\.\/manifest\.json/g, `/${this.securityToken}/manifest.json`); + html = html.replace(/\.\/icons\//g, `/${this.securityToken}/icons/`); + html = html.replace(/\.\/sw\.js/g, `/${this.securityToken}/sw.js`); + + // Inject config for the React app to know the token and session context + const configScript = ``; + html = html.replace('', `${configScript}`); + + reply.type('text/html').send(html); + } catch (err) { + logger.error('Error serving index.html', LOG_CONTEXT, err); + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to serve web interface.', + }); + } + } + + // ============ Live Session Management ============ + + /** + * Mark a session as live (visible in web interface) + */ + setSessionLive(sessionId: string, claudeSessionId?: string): void { + this.liveSessions.set(sessionId, { + sessionId, + claudeSessionId, + enabledAt: Date.now(), + }); + logger.info(`Session ${sessionId} marked as live (total: ${this.liveSessions.size})`, LOG_CONTEXT); + + // Broadcast to all connected clients + this.broadcastToWebClients({ + type: 'session_live', + sessionId, + claudeSessionId, + timestamp: Date.now(), + }); + } + + /** + * Mark a session as offline (no longer visible in web interface) + */ + setSessionOffline(sessionId: string): void { + const wasLive = this.liveSessions.delete(sessionId); + if (wasLive) { + logger.info(`Session ${sessionId} marked as offline (remaining: ${this.liveSessions.size})`, LOG_CONTEXT); + + // Broadcast to all connected clients + this.broadcastToWebClients({ + type: 'session_offline', + sessionId, + timestamp: Date.now(), + }); + } + } + + /** + * Check if a session is currently live + */ + isSessionLive(sessionId: string): boolean { + return this.liveSessions.has(sessionId); + } + + /** + * Get all live session IDs + */ + getLiveSessions(): LiveSessionInfo[] { + return Array.from(this.liveSessions.values()); + } + + /** + * Get the security token (for constructing URLs) + */ + getSecurityToken(): string { + return this.securityToken; + } + + /** + * Get the full secure URL (with token) + * Uses the detected local IP address for LAN accessibility + */ + getSecureUrl(): string { + return `http://${this.localIpAddress}:${this.port}/${this.securityToken}`; + } + + /** + * Get URL for a specific session + * Uses the detected local IP address for LAN accessibility + */ + getSessionUrl(sessionId: string): string { + return `http://${this.localIpAddress}:${this.port}/${this.securityToken}/session/${sessionId}`; + } + + /** + * Validate the security token from a request + */ + private validateToken(token: string): boolean { + return token === this.securityToken; + } + + /** + * Set the callback function for fetching current sessions list + * This is called when a new client connects to send the initial state + */ + setGetSessionsCallback(callback: GetSessionsCallback) { + this.getSessionsCallback = callback; + } + + /** + * Set the callback function for fetching single session details + * This is called by the /api/session/:id endpoint + */ + setGetSessionDetailCallback(callback: GetSessionDetailCallback) { + this.getSessionDetailCallback = callback; + } + + /** + * Set the callback function for fetching current theme + * This is called when a new client connects to send the initial theme + */ + setGetThemeCallback(callback: GetThemeCallback) { + this.getThemeCallback = callback; + } + + /** + * Set the callback function for fetching custom AI commands + * This is called when a new client connects to send the initial custom commands + */ + setGetCustomCommandsCallback(callback: GetCustomCommandsCallback) { + this.getCustomCommandsCallback = callback; + } + + /** + * Set the callback function for writing commands to a session + * This is called by the /api/session/:id/send endpoint + */ + setWriteToSessionCallback(callback: WriteToSessionCallback) { + this.writeToSessionCallback = callback; + } + + /** + * Set the callback function for executing commands through the desktop + * This forwards commands to the renderer which handles spawn, state management, and broadcasts + */ + setExecuteCommandCallback(callback: ExecuteCommandCallback) { + this.executeCommandCallback = callback; + } + + /** + * Set the callback function for interrupting a session through the desktop + * This forwards to the renderer which handles state updates and broadcasts + */ + setInterruptSessionCallback(callback: InterruptSessionCallback) { + this.interruptSessionCallback = callback; + } + + /** + * Set the callback function for switching session mode through the desktop + * This forwards to the renderer which handles state updates and broadcasts + */ + setSwitchModeCallback(callback: SwitchModeCallback) { + this.switchModeCallback = callback; + } + + /** + * Set the rate limiting configuration + */ + setRateLimitConfig(config: Partial) { + this.rateLimitConfig = { ...this.rateLimitConfig, ...config }; + logger.info(`Rate limiting ${this.rateLimitConfig.enabled ? 'enabled' : 'disabled'} (max: ${this.rateLimitConfig.max}/min, maxPost: ${this.rateLimitConfig.maxPost}/min)`, LOG_CONTEXT); + } + + /** + * Get the current rate limiting configuration + */ + getRateLimitConfig(): RateLimitConfig { + return { ...this.rateLimitConfig }; } private async setupMiddleware() { @@ -50,72 +470,822 @@ export class WebServer { // Enable WebSocket support await this.server.register(websocket); + + // Enable rate limiting for web interface endpoints to prevent abuse + await this.server.register(rateLimit, { + global: false, + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + errorResponseBuilder: (_request: FastifyRequest, context) => { + return { + statusCode: 429, + error: 'Too Many Requests', + message: `Rate limit exceeded. Try again later.`, + retryAfter: context.after, + }; + }, + allowList: (request: FastifyRequest) => { + if (!this.rateLimitConfig.enabled) return true; + if (request.url === '/health') return true; + return false; + }, + keyGenerator: (request: FastifyRequest) => { + return request.ip; + }, + }); + + // Register static file serving for web assets + if (this.webAssetsPath) { + const assetsPath = path.join(this.webAssetsPath, 'assets'); + if (existsSync(assetsPath)) { + await this.server.register(fastifyStatic, { + root: assetsPath, + prefix: `/${this.securityToken}/assets/`, + decorateReply: false, + }); + } + + // Register icons directory + const iconsPath = path.join(this.webAssetsPath, 'icons'); + if (existsSync(iconsPath)) { + await this.server.register(fastifyStatic, { + root: iconsPath, + prefix: `/${this.securityToken}/icons/`, + decorateReply: false, + }); + } + } } private setupRoutes() { - // Health check + const token = this.securityToken; + + // Root path - redirect to GitHub (no access without token) + this.server.get('/', async (_request, reply) => { + return reply.redirect(302, GITHUB_REDIRECT_URL); + }); + + // Health check (no auth required) this.server.get('/health', async () => { return { status: 'ok', timestamp: Date.now() }; }); - // WebSocket endpoint for real-time updates - // NOTE: This is a placeholder implementation for Phase 6 (Remote Access & Tunneling) - // Current behavior: Echoes messages back to test connectivity - // Future implementation (Phase 6): - // - Broadcast session state changes to all connected clients - // - Stream process output in real-time - // - Handle input commands from remote clients - // - Implement authentication and authorization - // - Support multiple simultaneous connections - this.server.get('/ws', { websocket: true }, (connection) => { - connection.socket.on('message', (message) => { - // PLACEHOLDER: Echo back for testing connectivity only - connection.socket.send(JSON.stringify({ - type: 'echo', - data: message.toString(), - })); + // PWA manifest.json + this.server.get(`/${token}/manifest.json`, async (_request, reply) => { + if (!this.webAssetsPath) { + return reply.code(404).send({ error: 'Not Found' }); + } + const manifestPath = path.join(this.webAssetsPath, 'manifest.json'); + if (!existsSync(manifestPath)) { + return reply.code(404).send({ error: 'Not Found' }); + } + return reply.type('application/json').send(readFileSync(manifestPath, 'utf-8')); + }); + + // PWA service worker + this.server.get(`/${token}/sw.js`, async (_request, reply) => { + if (!this.webAssetsPath) { + return reply.code(404).send({ error: 'Not Found' }); + } + const swPath = path.join(this.webAssetsPath, 'sw.js'); + if (!existsSync(swPath)) { + return reply.code(404).send({ error: 'Not Found' }); + } + return reply.type('application/javascript').send(readFileSync(swPath, 'utf-8')); + }); + + // Dashboard - list all live sessions + this.server.get(`/${token}`, async (_request, reply) => { + this.serveIndexHtml(reply); + }); + + // Dashboard with trailing slash + this.server.get(`/${token}/`, async (_request, reply) => { + this.serveIndexHtml(reply); + }); + + // Single session view - works for any valid session (security token protects access) + this.server.get(`/${token}/session/:sessionId`, async (request, reply) => { + const { sessionId } = request.params as { sessionId: string }; + // Note: Session validation happens in the frontend via the sessions list + this.serveIndexHtml(reply, sessionId); + }); + + // Catch-all for invalid tokens - redirect to GitHub + this.server.get('/:token', async (request, reply) => { + const { token: reqToken } = request.params as { token: string }; + if (!this.validateToken(reqToken)) { + return reply.redirect(302, GITHUB_REDIRECT_URL); + } + // Valid token but no specific route - serve dashboard + this.serveIndexHtml(reply); + }); + + // API Routes - all under /$TOKEN/api/* + this.setupApiRoutes(); + + // WebSocket route + this.setupWebSocketRoute(); + } + + /** + * Setup API routes under /$TOKEN/api/* + */ + private setupApiRoutes() { + const token = this.securityToken; + + // Get all sessions (not just "live" ones - security token protects access) + this.server.get(`/${token}/api/sessions`, { + config: { + rateLimit: { + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }, async () => { + const sessions = this.getSessionsCallback ? this.getSessionsCallback() : []; + + // Enrich all sessions with live info if available + const sessionData = sessions.map(s => { + const liveInfo = this.liveSessions.get(s.id); + return { + ...s, + claudeSessionId: liveInfo?.claudeSessionId || s.claudeSessionId, + liveEnabledAt: liveInfo?.enabledAt, + isLive: this.isSessionLive(s.id), + }; }); - connection.socket.send(JSON.stringify({ - type: 'connected', - message: 'Connected to Maestro WebSocket', - })); + return { + sessions: sessionData, + count: sessionData.length, + timestamp: Date.now(), + }; }); - // Session list endpoint - // NOTE: Placeholder for Phase 6. Currently returns empty array. - // Future: Return actual session list from ProcessManager - this.server.get('/api/sessions', async () => { + // Session detail endpoint - works for any valid session (security token protects access) + this.server.get(`/${token}/api/session/:id`, { + config: { + rateLimit: { + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }, async (request, reply) => { + const { id } = request.params as { id: string }; + + if (!this.getSessionDetailCallback) { + return reply.code(503).send({ + error: 'Service Unavailable', + message: 'Session detail service not configured', + timestamp: Date.now(), + }); + } + + const session = this.getSessionDetailCallback(id); + if (!session) { + return reply.code(404).send({ + error: 'Not Found', + message: `Session with id '${id}' not found`, + timestamp: Date.now(), + }); + } + + const liveInfo = this.liveSessions.get(id); return { - sessions: [], + session: { + ...session, + claudeSessionId: liveInfo?.claudeSessionId || session.claudeSessionId, + liveEnabledAt: liveInfo?.enabledAt, + isLive: this.isSessionLive(id), + }, timestamp: Date.now(), }; }); - // Session detail endpoint - // NOTE: Placeholder for Phase 6. Currently returns stub data. - // Future: Return actual session details including state, output, etc. - this.server.get('/api/sessions/:id', async (request) => { + // Send command to session - works for any valid session (security token protects access) + this.server.post(`/${token}/api/session/:id/send`, { + config: { + rateLimit: { + max: this.rateLimitConfig.maxPost, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }, async (request, reply) => { const { id } = request.params as { id: string }; + const body = request.body as { command?: string } | undefined; + const command = body?.command; + + // Note: We don't check isSessionLive() here - the callback validates the session + // exists and the security token already protects access. + + if (!command || typeof command !== 'string') { + return reply.code(400).send({ + error: 'Bad Request', + message: 'Command is required and must be a string', + timestamp: Date.now(), + }); + } + + if (!this.writeToSessionCallback) { + return reply.code(503).send({ + error: 'Service Unavailable', + message: 'Session write service not configured', + timestamp: Date.now(), + }); + } + + const success = this.writeToSessionCallback(id, command + '\n'); + if (!success) { + return reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to send command to session', + timestamp: Date.now(), + }); + } + return { + success: true, + message: 'Command sent successfully', sessionId: id, - status: 'idle', + timestamp: Date.now(), + }; + }); + + // Theme endpoint + this.server.get(`/${token}/api/theme`, { + config: { + rateLimit: { + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }, async (_request, reply) => { + if (!this.getThemeCallback) { + return reply.code(503).send({ + error: 'Service Unavailable', + message: 'Theme service not configured', + timestamp: Date.now(), + }); + } + + const theme = this.getThemeCallback(); + if (!theme) { + return reply.code(404).send({ + error: 'Not Found', + message: 'No theme currently configured', + timestamp: Date.now(), + }); + } + + return { + theme, + timestamp: Date.now(), }; }); + + // Interrupt session - works for any valid session (security token protects access) + this.server.post(`/${token}/api/session/:id/interrupt`, { + config: { + rateLimit: { + max: this.rateLimitConfig.maxPost, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }, async (request, reply) => { + const { id } = request.params as { id: string }; + + // Note: We don't check isSessionLive() here - the callback validates the session + // exists and the security token already protects access. + + if (!this.interruptSessionCallback) { + return reply.code(503).send({ + error: 'Service Unavailable', + message: 'Session interrupt service not configured', + timestamp: Date.now(), + }); + } + + try { + // Forward to desktop's interrupt logic - handles state updates and broadcasts + const success = await this.interruptSessionCallback(id); + if (!success) { + return reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to interrupt session', + timestamp: Date.now(), + }); + } + + return { + success: true, + message: 'Interrupt signal sent successfully', + sessionId: id, + timestamp: Date.now(), + }; + } catch (error: any) { + return reply.code(500).send({ + error: 'Internal Server Error', + message: `Failed to interrupt session: ${error.message}`, + timestamp: Date.now(), + }); + } + }); + } + + /** + * Setup WebSocket route under /$TOKEN/ws + */ + private setupWebSocketRoute() { + const token = this.securityToken; + + this.server.get(`/${token}/ws`, { websocket: true }, (connection, request) => { + const clientId = `web-client-${++this.clientIdCounter}`; + + // Extract sessionId from query string if provided (for session-specific subscriptions) + const url = new URL(request.url || '', `http://${request.headers.host || 'localhost'}`); + const sessionId = url.searchParams.get('sessionId') || undefined; + + const client: WebClient = { + socket: connection.socket, + id: clientId, + connectedAt: Date.now(), + subscribedSessionId: sessionId, + }; + + this.webClients.set(clientId, client); + logger.info(`Client connected: ${clientId} (session: ${sessionId || 'dashboard'}, total: ${this.webClients.size})`, LOG_CONTEXT); + + // Send connection confirmation + connection.socket.send(JSON.stringify({ + type: 'connected', + clientId, + message: 'Connected to Maestro Web Interface', + subscribedSessionId: sessionId, + timestamp: Date.now(), + })); + + // Send initial sessions list (all sessions, not just "live" ones) + if (this.getSessionsCallback) { + const allSessions = this.getSessionsCallback(); + const sessionsWithLiveInfo = allSessions.map(s => { + const liveInfo = this.liveSessions.get(s.id); + return { + ...s, + claudeSessionId: liveInfo?.claudeSessionId || s.claudeSessionId, + liveEnabledAt: liveInfo?.enabledAt, + isLive: this.isSessionLive(s.id), + }; + }); + connection.socket.send(JSON.stringify({ + type: 'sessions_list', + sessions: sessionsWithLiveInfo, + timestamp: Date.now(), + })); + } + + // Send current theme + if (this.getThemeCallback) { + const theme = this.getThemeCallback(); + if (theme) { + connection.socket.send(JSON.stringify({ + type: 'theme', + theme, + timestamp: Date.now(), + })); + } + } + + // Send custom AI commands + if (this.getCustomCommandsCallback) { + const customCommands = this.getCustomCommandsCallback(); + connection.socket.send(JSON.stringify({ + type: 'custom_commands', + commands: customCommands, + timestamp: Date.now(), + })); + } + + // Handle incoming messages + connection.socket.on('message', (message) => { + try { + const data = JSON.parse(message.toString()) as WebClientMessage; + this.handleWebClientMessage(clientId, data); + } catch { + connection.socket.send(JSON.stringify({ + type: 'error', + message: 'Invalid message format', + })); + } + }); + + // Handle disconnection + connection.socket.on('close', () => { + this.webClients.delete(clientId); + logger.info(`Client disconnected: ${clientId} (total: ${this.webClients.size})`, LOG_CONTEXT); + }); + + // Handle errors + connection.socket.on('error', (error) => { + logger.error(`Client error (${clientId})`, LOG_CONTEXT, error); + this.webClients.delete(clientId); + }); + }); + } + + /** + * Handle incoming messages from web clients + */ + private handleWebClientMessage(clientId: string, message: WebClientMessage) { + const client = this.webClients.get(clientId); + if (!client) return; + + switch (message.type) { + case 'ping': + // Respond to ping with pong + client.socket.send(JSON.stringify({ + type: 'pong', + timestamp: Date.now(), + })); + break; + + case 'subscribe': + // Update client's session subscription + if (message.sessionId) { + client.subscribedSessionId = message.sessionId as string; + } + client.socket.send(JSON.stringify({ + type: 'subscribed', + sessionId: message.sessionId, + timestamp: Date.now(), + })); + break; + + case 'send_command': { + // Send a command to a session (AI or terminal) + const sessionId = message.sessionId as string; + const command = message.command as string; + + if (!sessionId || !command) { + client.socket.send(JSON.stringify({ + type: 'error', + message: 'Missing sessionId or command', + timestamp: Date.now(), + })); + return; + } + + // Get session details to check state and determine how to handle + const sessionDetail = this.getSessionDetailCallback?.(sessionId); + if (!sessionDetail) { + client.socket.send(JSON.stringify({ + type: 'error', + message: 'Session not found', + timestamp: Date.now(), + })); + return; + } + + // Check if session is busy - prevent race conditions between desktop and web + if (sessionDetail.state === 'busy') { + client.socket.send(JSON.stringify({ + type: 'error', + message: 'Session is busy - please wait for the current operation to complete', + sessionId, + timestamp: Date.now(), + })); + logger.debug(`Command rejected - session ${sessionId} is busy`, LOG_CONTEXT); + return; + } + + const isAiMode = sessionDetail.inputMode === 'ai'; + const mode = isAiMode ? 'AI' : 'CLI'; + const claudeId = sessionDetail.claudeSessionId || 'none'; + + // Log all web interface commands prominently + logger.info(`[Web Command] Mode: ${mode} | Session: ${sessionId}${isAiMode ? ` | Claude: ${claudeId}` : ''} | Message: ${command}`, LOG_CONTEXT); + + // Route ALL commands through the renderer for consistent handling + // The renderer handles both AI and terminal modes, updating UI and state + if (this.executeCommandCallback) { + this.executeCommandCallback(sessionId, command) + .then((success) => { + client.socket.send(JSON.stringify({ + type: 'command_result', + success, + sessionId, + timestamp: Date.now(), + })); + if (!success) { + logger.warn(`[Web Command] ${mode} command rejected for session ${sessionId}`, LOG_CONTEXT); + } + }) + .catch((error) => { + logger.error(`[Web Command] ${mode} command failed for session ${sessionId}: ${error.message}`, LOG_CONTEXT); + client.socket.send(JSON.stringify({ + type: 'error', + message: `Failed to execute command: ${error.message}`, + timestamp: Date.now(), + })); + }); + } else { + client.socket.send(JSON.stringify({ + type: 'error', + message: 'Command execution not configured', + timestamp: Date.now(), + })); + } + break; + } + + case 'switch_mode': { + // Switch session input mode between AI and terminal + const sessionId = message.sessionId as string; + const mode = message.mode as 'ai' | 'terminal'; + + if (!sessionId || !mode) { + client.socket.send(JSON.stringify({ + type: 'error', + message: 'Missing sessionId or mode', + timestamp: Date.now(), + })); + return; + } + + if (!this.switchModeCallback) { + client.socket.send(JSON.stringify({ + type: 'error', + message: 'Mode switching not configured', + timestamp: Date.now(), + })); + return; + } + + // Forward to desktop's mode switching logic + // This ensures single source of truth - desktop handles state updates and broadcasts + logger.debug(`Forwarding mode switch to desktop for session ${sessionId}: ${mode}`, LOG_CONTEXT); + this.switchModeCallback(sessionId, mode) + .then((success) => { + client.socket.send(JSON.stringify({ + type: 'mode_switch_result', + success, + sessionId, + mode, + timestamp: Date.now(), + })); + logger.debug(`Mode switch for session ${sessionId} to ${mode}: ${success ? 'success' : 'failed'}`, LOG_CONTEXT); + }) + .catch((error) => { + client.socket.send(JSON.stringify({ + type: 'error', + message: `Failed to switch mode: ${error.message}`, + timestamp: Date.now(), + })); + }); + break; + } + + case 'get_sessions': { + // Request updated sessions list - returns all sessions (not just "live" ones) + // The security token already protects access to this endpoint + if (this.getSessionsCallback) { + const allSessions = this.getSessionsCallback(); + // Enrich sessions with live info if available + const sessionsWithLiveInfo = allSessions.map(s => { + const liveInfo = this.liveSessions.get(s.id); + return { + ...s, + claudeSessionId: liveInfo?.claudeSessionId || s.claudeSessionId, + liveEnabledAt: liveInfo?.enabledAt, + isLive: this.isSessionLive(s.id), + }; + }); + client.socket.send(JSON.stringify({ + type: 'sessions_list', + sessions: sessionsWithLiveInfo, + timestamp: Date.now(), + })); + } + break; + } + + default: + // Echo unknown message types for debugging + logger.debug(`Unknown message type: ${message.type}`, LOG_CONTEXT); + client.socket.send(JSON.stringify({ + type: 'echo', + originalType: message.type, + data: message, + })); + } + } + + /** + * Broadcast a message to all connected web clients + */ + broadcastToWebClients(message: object) { + const data = JSON.stringify(message); + for (const client of this.webClients.values()) { + if (client.socket.readyState === WebSocket.OPEN) { + client.socket.send(data); + } + } + } + + /** + * Broadcast a message to clients subscribed to a specific session + */ + broadcastToSessionClients(sessionId: string, message: object) { + const data = JSON.stringify(message); + let sentCount = 0; + const msgType = (message as any).type || 'unknown'; + + for (const client of this.webClients.values()) { + const isOpen = client.socket.readyState === WebSocket.OPEN; + const matchesSession = client.subscribedSessionId === sessionId || !client.subscribedSessionId; + const shouldSend = isOpen && matchesSession; + + if (msgType === 'session_output') { + console.log(`[WebBroadcast] Client ${client.id}: isOpen=${isOpen}, subscribedTo=${client.subscribedSessionId || 'none'}, matchesSession=${matchesSession}, shouldSend=${shouldSend}`); + } + + if (shouldSend) { + client.socket.send(data); + sentCount++; + } + } + + // Log summary for session_output + if (msgType === 'session_output') { + console.log(`[WebBroadcast] Sent session_output to ${sentCount}/${this.webClients.size} clients for session ${sessionId}`); + } + } + + /** + * Broadcast a session state change to all connected web clients + * Called when any session's state changes (idle, busy, error, connecting) + */ + broadcastSessionStateChange(sessionId: string, state: string, additionalData?: { + name?: string; + toolType?: string; + inputMode?: string; + cwd?: string; + }) { + this.broadcastToWebClients({ + type: 'session_state_change', + sessionId, + state, + ...additionalData, + timestamp: Date.now(), + }); + } + + /** + * Broadcast when a session is added + */ + broadcastSessionAdded(session: { + id: string; + name: string; + toolType: string; + state: string; + inputMode: string; + cwd: string; + groupId?: string | null; + groupName?: string | null; + groupEmoji?: string | null; + }) { + this.broadcastToWebClients({ + type: 'session_added', + session, + timestamp: Date.now(), + }); + } + + /** + * Broadcast when a session is removed + */ + broadcastSessionRemoved(sessionId: string) { + this.broadcastToWebClients({ + type: 'session_removed', + sessionId, + timestamp: Date.now(), + }); } - async start() { + /** + * Broadcast the full sessions list to all connected web clients + * Used for initial sync or bulk updates + */ + broadcastSessionsList(sessions: Array<{ + id: string; + name: string; + toolType: string; + state: string; + inputMode: string; + cwd: string; + groupId?: string | null; + groupName?: string | null; + groupEmoji?: string | null; + }>) { + this.broadcastToWebClients({ + type: 'sessions_list', + sessions, + timestamp: Date.now(), + }); + } + + /** + * Broadcast active session change to all connected web clients + * Called when the user switches sessions in the desktop app + */ + broadcastActiveSessionChange(sessionId: string) { + this.broadcastToWebClients({ + type: 'active_session_changed', + sessionId, + timestamp: Date.now(), + }); + } + + /** + * Broadcast theme change to all connected web clients + * Called when the user changes the theme in the desktop app + */ + broadcastThemeChange(theme: Theme) { + this.broadcastToWebClients({ + type: 'theme', + theme, + timestamp: Date.now(), + }); + } + + /** + * Broadcast custom commands update to all connected web clients + * Called when the user modifies custom AI commands in the desktop app + */ + broadcastCustomCommands(commands: CustomAICommand[]) { + this.broadcastToWebClients({ + type: 'custom_commands', + commands, + timestamp: Date.now(), + }); + } + + /** + * Broadcast user input to web clients subscribed to a session + * Called when a command is sent from the desktop app so web clients stay in sync + */ + broadcastUserInput(sessionId: string, command: string, inputMode: 'ai' | 'terminal') { + this.broadcastToSessionClients(sessionId, { + type: 'user_input', + sessionId, + command, + inputMode, + timestamp: Date.now(), + }); + } + + /** + * Get the number of connected web clients + */ + getWebClientCount(): number { + return this.webClients.size; + } + + async start(): Promise<{ port: number; token: string; url: string }> { if (this.isRunning) { - console.log('Web server already running'); - return; + return { + port: this.port, + token: this.securityToken, + url: this.getSecureUrl(), + }; } try { + // Detect local IP address for LAN accessibility (sync - no network delay) + this.localIpAddress = getLocalIpAddressSync(); + logger.info(`Using IP address: ${this.localIpAddress}`, LOG_CONTEXT); + + // Setup middleware and routes (must be done before listen) + await this.setupMiddleware(); + this.setupRoutes(); + await this.server.listen({ port: this.port, host: '0.0.0.0' }); + + // Get the actual port (important when using port 0 for random assignment) + const address = this.server.server.address(); + if (address && typeof address === 'object') { + this.port = address.port; + } + this.isRunning = true; - console.log(`Maestro web server running on http://localhost:${this.port}`); + + return { + port: this.port, + token: this.securityToken, + url: this.getSecureUrl(), + }; } catch (error) { - console.error('Failed to start web server:', error); + logger.error('Failed to start server', LOG_CONTEXT, error); throw error; } } @@ -125,17 +1295,30 @@ export class WebServer { return; } + // Mark all live sessions as offline + for (const sessionId of this.liveSessions.keys()) { + this.setSessionOffline(sessionId); + } + try { await this.server.close(); this.isRunning = false; - console.log('Web server stopped'); + logger.info('Server stopped', LOG_CONTEXT); } catch (error) { - console.error('Failed to stop web server:', error); + logger.error('Failed to stop server', LOG_CONTEXT, error); } } getUrl(): string { - return `http://localhost:${this.port}`; + return `http://${this.localIpAddress}:${this.port}`; + } + + getPort(): number { + return this.port; + } + + isActive(): boolean { + return this.isRunning; } getServer(): FastifyInstance { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3ffea409a..76435b19d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -186,6 +186,10 @@ export default function MaestroConsole() { // Images Staging (only for AI mode - terminal doesn't support images) const [aiStagedImages, setAiStagedImages] = useState([]); + // Global Live Mode State (web interface for all sessions) + const [isLiveMode, setIsLiveMode] = useState(false); + const [webInterfaceUrl, setWebInterfaceUrl] = useState(null); + // Restore focus when LogViewer closes to ensure global hotkeys work useEffect(() => { // When LogViewer closes, restore focus to main container or input @@ -250,7 +254,9 @@ export default function MaestroConsole() { ...correctedSession, aiPid: -1, terminalPid: -1, - state: 'error' as SessionState + state: 'error' as SessionState, + isLive: false, + liveUrl: undefined }; } @@ -261,7 +267,9 @@ export default function MaestroConsole() { ...correctedSession, aiPid: -1, terminalPid: -1, - state: 'error' as SessionState + state: 'error' as SessionState, + isLive: false, + liveUrl: undefined }; } @@ -306,6 +314,8 @@ export default function MaestroConsole() { terminalPid: terminalSpawnResult.pid, state: 'idle' as SessionState, isGitRepo, // Update Git status + isLive: false, // Always start offline on app restart + liveUrl: undefined, // Clear any stale URL aiLogs: correctedSession.aiLogs, // Preserve existing AI Terminal logs shellLogs: correctedSession.shellLogs, // Preserve existing Command Terminal logs messageQueue: correctedSession.messageQueue || [], // Ensure backwards compatibility @@ -318,7 +328,9 @@ export default function MaestroConsole() { ...session, aiPid: -1, terminalPid: -1, - state: 'error' as SessionState + state: 'error' as SessionState, + isLive: false, + liveUrl: undefined }; } } catch (error) { @@ -327,7 +339,9 @@ export default function MaestroConsole() { ...session, aiPid: -1, terminalPid: -1, - state: 'error' as SessionState + state: 'error' as SessionState, + isLive: false, + liveUrl: undefined }; } }; @@ -431,8 +445,8 @@ export default function MaestroConsole() { actualSessionId = sessionId.slice(0, -3); // Remove "-ai" suffix isFromAi = true; } else if (sessionId.endsWith('-terminal')) { - // Ignore PTY terminal output - we use runCommand for terminal commands now, - // which emits data without the -terminal suffix + // Ignore PTY terminal output - we use runCommand for terminal commands, + // which emits data with plain session ID (not -terminal suffix) return; } else if (sessionId.includes('-batch-')) { // Ignore batch task output - these are handled separately by spawnAgentForSession @@ -530,78 +544,74 @@ export default function MaestroConsole() { isFromAi = false; } + // For AI exits, gather toast data BEFORE state update to avoid side effects in updater + // React 18 StrictMode may call state updater functions multiple times + let toastData: { title: string; summary: string; groupName: string; projectName: string; duration: number } | null = null; + let queuedMessageToProcess: { sessionId: string; message: LogEntry } | null = null; + + if (isFromAi) { + const currentSession = sessionsRef.current.find(s => s.id === actualSessionId); + if (currentSession) { + // Check if there are queued messages + if (currentSession.messageQueue.length > 0) { + queuedMessageToProcess = { + sessionId: actualSessionId, + message: currentSession.messageQueue[0] + }; + } else { + // Task complete - gather toast notification data + const lastUserLog = currentSession.aiLogs.filter(log => log.source === 'user').pop(); + const lastAiLog = currentSession.aiLogs.filter(log => log.source === 'stdout' || log.source === 'ai').pop(); + const duration = currentSession.thinkingStartTime ? Date.now() - currentSession.thinkingStartTime : 0; + + // Get group name for this session + const sessionGroup = groupsRef.current.find((g: any) => g.sessionIds?.includes(actualSessionId)); + const groupName = sessionGroup?.name || 'Ungrouped'; + const projectName = currentSession.name || currentSession.cwd.split('/').pop() || 'Unknown'; + + // Create title from user's request (truncated) + let title = 'Task Complete'; + if (lastUserLog?.text) { + const userText = lastUserLog.text.trim(); + title = userText.length > 50 ? userText.substring(0, 47) + '...' : userText; + } + + // Create a short summary from the last AI response + let summary = ''; + if (lastAiLog?.text) { + const text = lastAiLog.text.trim(); + if (text.length > 10) { + const firstSentence = text.match(/^[^.!?\n]*[.!?]/)?.[0] || text.substring(0, 120); + summary = firstSentence.length < text.length ? firstSentence : text.substring(0, 120) + (text.length > 120 ? '...' : ''); + } + } + if (!summary) { + summary = 'Completed successfully'; + } + + toastData = { title, summary, groupName, projectName, duration }; + } + } + } + + // Update state (pure function - no side effects) setSessions(prev => prev.map(s => { if (s.id !== actualSessionId) return s; - // For AI agent exits, check if there are queued messages to process - // For terminal exits, show the exit code if (isFromAi) { // Check if there are queued messages if (s.messageQueue.length > 0) { - // Dequeue first message and add to logs const [nextMessage, ...remainingQueue] = s.messageQueue; - - // Schedule the next message to be sent (async, after state update) - setTimeout(() => { - processQueuedMessage(actualSessionId, nextMessage); - }, 0); - return { ...s, - state: 'busy' as SessionState, // Explicitly keep busy for queued message processing + state: 'busy' as SessionState, aiLogs: [...s.aiLogs, nextMessage], messageQueue: remainingQueue, thinkingStartTime: Date.now() }; } - // Task complete - show toast notification - // Get the last user request and AI response - const lastUserLog = s.aiLogs.filter(log => log.source === 'user').pop(); - const lastAiLog = s.aiLogs.filter(log => log.source === 'stdout' || log.source === 'ai').pop(); - const duration = s.thinkingStartTime ? Date.now() - s.thinkingStartTime : 0; - - // Get group name for this session - const sessionGroup = groupsRef.current.find((g: any) => g.sessionIds?.includes(actualSessionId)); - const groupName = sessionGroup?.name || 'Ungrouped'; - const projectName = s.name || s.cwd.split('/').pop() || 'Unknown'; - - // Create title from user's request (truncated) - let title = 'Task Complete'; - if (lastUserLog?.text) { - const userText = lastUserLog.text.trim(); - // Truncate to ~50 chars for title - title = userText.length > 50 ? userText.substring(0, 47) + '...' : userText; - } - - // Create a short summary from the last AI response - let summary = ''; - if (lastAiLog?.text) { - const text = lastAiLog.text.trim(); - // Skip empty or very short responses - if (text.length > 10) { - // Extract first meaningful sentence or first 120 chars - const firstSentence = text.match(/^[^.!?\n]*[.!?]/)?.[0] || text.substring(0, 120); - summary = firstSentence.length < text.length ? firstSentence : text.substring(0, 120) + (text.length > 120 ? '...' : ''); - } - } - // Fallback if no good summary - if (!summary) { - summary = 'Completed successfully'; - } - - // Fire toast notification (async, don't block state update) - setTimeout(() => { - addToastRef.current({ - type: 'success', - title, - message: summary, - group: groupName, - project: projectName, - taskDuration: duration, - }); - }, 0); - + // Task complete return { ...s, state: 'idle' as SessionState, @@ -623,6 +633,24 @@ export default function MaestroConsole() { shellLogs: [...s.shellLogs, exitLog] }; })); + + // Fire side effects AFTER state update (outside the updater function) + if (queuedMessageToProcess) { + setTimeout(() => { + processQueuedMessage(queuedMessageToProcess!.sessionId, queuedMessageToProcess!.message); + }, 0); + } else if (toastData) { + setTimeout(() => { + addToastRef.current({ + type: 'success', + title: toastData!.title, + message: toastData!.summary, + group: toastData!.groupName, + project: toastData!.projectName, + taskDuration: toastData!.duration, + }); + }, 0); + } }); // Handle Claude session ID capture for interactive sessions only @@ -815,10 +843,22 @@ export default function MaestroConsole() { const addToastRef = useRef(addToast); const sessionsRef = useRef(sessions); const updateGlobalStatsRef = useRef(updateGlobalStats); + const customAICommandsRef = useRef(customAICommands); groupsRef.current = groups; addToastRef.current = addToast; sessionsRef.current = sessions; updateGlobalStatsRef.current = updateGlobalStats; + customAICommandsRef.current = customAICommands; + + // Refs for slash command functions (to access latest values in remote command handler) + const spawnBackgroundSynopsisRef = useRef(null); + const addHistoryEntryRef = useRef(null); + const spawnAgentWithPromptRef = useRef(null); + const startNewClaudeSessionRef = useRef(null); + + // Ref for handling remote commands from web interface + // This allows web commands to go through the exact same code path as desktop commands + const pendingRemoteCommandRef = useRef<{ sessionId: string; command: string } | null>(null); // Expose addToast to window for debugging/testing useEffect(() => { @@ -848,7 +888,131 @@ export default function MaestroConsole() { [sessions, activeSessionId] ); const theme = THEMES[activeThemeId]; - const anyTunnelActive = sessions.some(s => s.tunnelActive); + + // Broadcast active session change to web clients + useEffect(() => { + if (activeSessionId && isLiveMode) { + window.maestro.live.broadcastActiveSession(activeSessionId); + } + }, [activeSessionId, isLiveMode]); + + // Handle remote commands from web interface + // This allows web commands to go through the exact same code path as desktop commands + useEffect(() => { + console.log('[Remote] Setting up onRemoteCommand listener...'); + const unsubscribeRemote = window.maestro.process.onRemoteCommand((sessionId: string, command: string) => { + // Verify the session exists + const targetSession = sessionsRef.current.find(s => s.id === sessionId); + + console.log('[Remote] Received command from web interface:', { + maestroSessionId: sessionId, + claudeSessionId: targetSession?.claudeSessionId || 'none', + state: targetSession?.state || 'NOT_FOUND', + inputMode: targetSession?.inputMode || 'unknown', + command: command.substring(0, 100) + }); + + if (!targetSession) { + console.log('[Remote] ERROR: Session not found:', sessionId); + return; + } + + // Check if session is busy (should have been checked by web server, but double-check) + if (targetSession.state === 'busy') { + console.log('[Remote] REJECTED: Session is busy:', sessionId); + return; + } + + // Switch to the target session (for visual feedback) + console.log('[Remote] Switching to target session...'); + setActiveSessionId(sessionId); + + // Dispatch event directly - handleRemoteCommand handles all the logic + // Don't set inputValue - we don't want command text to appear in the input bar + console.log('[Remote] Dispatching maestro:remoteCommand event'); + window.dispatchEvent(new CustomEvent('maestro:remoteCommand', { + detail: { sessionId, command } + })); + }); + + return () => { + unsubscribeRemote(); + }; + }, []); + + // Handle remote mode switches from web interface + // This allows web mode switches to go through the same code path as desktop + useEffect(() => { + const unsubscribeSwitchMode = window.maestro.process.onRemoteSwitchMode((sessionId: string, mode: 'ai' | 'terminal') => { + console.log('[Remote] Received mode switch from web interface:', { sessionId, mode }); + + // Find the session and update its mode + setSessions(prev => { + const session = prev.find(s => s.id === sessionId); + if (!session) { + console.log('[Remote] Session not found for mode switch:', sessionId); + return prev; + } + + // Only switch if mode is different + if (session.inputMode === mode) { + console.log('[Remote] Session already in mode:', mode); + return prev; + } + + console.log('[Remote] Switching session mode:', sessionId, 'to', mode); + return prev.map(s => { + if (s.id !== sessionId) return s; + return { ...s, inputMode: mode }; + }); + }); + }); + + return () => { + unsubscribeSwitchMode(); + }; + }, []); + + // Handle remote interrupts from web interface + // This allows web interrupts to go through the same code path as desktop (handleInterrupt) + useEffect(() => { + const unsubscribeInterrupt = window.maestro.process.onRemoteInterrupt(async (sessionId: string) => { + console.log('[Remote] Received interrupt from web interface:', { sessionId }); + + // Find the session + const session = sessionsRef.current.find(s => s.id === sessionId); + if (!session) { + console.log('[Remote] Session not found for interrupt:', sessionId); + return; + } + + // Use the same logic as handleInterrupt + const currentMode = session.inputMode; + const targetSessionId = currentMode === 'ai' ? `${session.id}-ai` : `${session.id}-terminal`; + + try { + // Send interrupt signal (Ctrl+C) + await window.maestro.process.interrupt(targetSessionId); + + // Set state to idle (same as handleInterrupt) + setSessions(prev => prev.map(s => { + if (s.id !== session.id) return s; + return { + ...s, + state: 'idle' + }; + })); + + console.log('[Remote] Interrupt successful for session:', sessionId); + } catch (error) { + console.error('[Remote] Failed to interrupt session:', error); + } + }); + + return () => { + unsubscribeInterrupt(); + }; + }, []); // Combine built-in slash commands with custom AI commands for autocomplete const allSlashCommands = useMemo(() => { @@ -1080,6 +1244,12 @@ export default function MaestroConsole() { setActiveClaudeSessionId(null); }, [activeSession]); + // Update refs for slash command functions (so remote command handler can access latest versions) + spawnBackgroundSynopsisRef.current = spawnBackgroundSynopsis; + addHistoryEntryRef.current = addHistoryEntry; + spawnAgentWithPromptRef.current = spawnAgentWithPrompt; + startNewClaudeSessionRef.current = startNewClaudeSession; + // Initialize batch processor (supports parallel batches per session) const { batchRunStates, @@ -1785,7 +1955,7 @@ export default function MaestroConsole() { aiPid: aiSpawnResult.pid, terminalPid: terminalSpawnResult.pid, port: 3000 + Math.floor(Math.random() * 100), - tunnelActive: false, + isLive: false, changedFiles: [], fileTree: [], fileExplorerExpanded: [], @@ -1813,44 +1983,28 @@ export default function MaestroConsole() { })); }; - const toggleTunnel = async (sessId: string) => { - const session = sessions.find(s => s.id === sessId); - if (!session) return; - - if (session.tunnelActive) { - // Stop the tunnel - try { - await window.maestro.tunnel.stop(sessId); - setSessions(prev => prev.map(s => { - if (s.id !== sessId) return s; - return { - ...s, - tunnelActive: false, - tunnelUrl: undefined, - tunnelPort: undefined, - tunnelUuid: undefined - }; - })); - } catch (error) { - console.error('Failed to stop tunnel:', error); - } - } else { - // Start the tunnel - try { - const result = await window.maestro.tunnel.start(sessId); - setSessions(prev => prev.map(s => { - if (s.id !== sessId) return s; - return { - ...s, - tunnelActive: true, - tunnelUrl: result.url, - tunnelPort: result.port, - tunnelUuid: result.uuid - }; - })); - } catch (error) { - console.error('Failed to start tunnel:', error); + // Toggle global live mode (enables web interface for all sessions) + const toggleGlobalLive = async () => { + try { + if (isLiveMode) { + // Turn off - stop the server and clear state + const result = await window.maestro.live.disableAll(); + setIsLiveMode(false); + setWebInterfaceUrl(null); + console.log('[toggleGlobalLive] Stopped web server, disconnected', result.count, 'sessions'); + } else { + // Turn on - start the server and get the URL + const result = await window.maestro.live.startServer(); + if (result.success && result.url) { + setIsLiveMode(true); + setWebInterfaceUrl(result.url); + console.log('[toggleGlobalLive] Started web server:', result.url); + } else { + console.error('[toggleGlobalLive] Failed to start server:', result.error); + } } + } catch (error) { + console.error('[toggleGlobalLive] Error:', error); } }; @@ -1986,7 +2140,17 @@ export default function MaestroConsole() { }; const processInput = () => { - if (!activeSession || (!inputValue.trim() && stagedImages.length === 0)) return; + console.log('[processInput] Called with:', { + hasActiveSession: !!activeSession, + activeSessionId: activeSession?.id, + inputValue: inputValue?.substring(0, 50), + inputValueLength: inputValue?.length, + stagedImagesCount: stagedImages.length + }); + if (!activeSession || (!inputValue.trim() && stagedImages.length === 0)) { + console.log('[processInput] EARLY RETURN - missing activeSession or empty input'); + return; + } // Block slash commands when agent is busy (in AI mode) if (inputValue.trim().startsWith('/') && activeSession.state === 'busy' && activeSession.inputMode === 'ai') { @@ -2245,6 +2409,9 @@ export default function MaestroConsole() { const capturedInputValue = inputValue; const capturedImages = [...stagedImages]; + // Broadcast user input to web clients so they stay in sync + window.maestro.web.broadcastUserInput(activeSession.id, capturedInputValue, currentMode); + setInputValue(''); setStagedImages([]); @@ -2285,7 +2452,15 @@ export default function MaestroConsole() { // If images are present, they will be passed via stream-json input format // Use agent.path (full path) if available, otherwise fall back to agent.command const commandToUse = agent.path || agent.command; - console.log('[processInput] Spawning Claude:', { command: commandToUse, path: agent.path, fallback: agent.command }); + console.log('[processInput] Spawning Claude:', { + maestroSessionId: activeSession.id, + targetSessionId, + claudeSessionId: activeSession.claudeSessionId || 'NEW SESSION', + isResume: !!activeSession.claudeSessionId, + command: commandToUse, + args: spawnArgs, + prompt: capturedInputValue.substring(0, 100) + }); await window.maestro.process.spawn({ sessionId: targetSessionId, toolType: 'claude-code', @@ -2363,6 +2538,284 @@ export default function MaestroConsole() { } }; + // Listen for remote commands from web interface + // This event is triggered by the remote command handler with command data in detail + useEffect(() => { + const handleRemoteCommand = async (event: Event) => { + const customEvent = event as CustomEvent<{ sessionId: string; command: string }>; + const { sessionId, command } = customEvent.detail; + + console.log('[Remote] Processing remote command via event:', { sessionId, command: command.substring(0, 50) }); + + // Find the session directly from sessionsRef (not from React state which may be stale) + const session = sessionsRef.current.find(s => s.id === sessionId); + if (!session) { + console.log('[Remote] ERROR: Session not found in sessionsRef:', sessionId); + return; + } + + console.log('[Remote] Found session:', { + id: session.id, + claudeSessionId: session.claudeSessionId || 'none', + state: session.state, + inputMode: session.inputMode, + toolType: session.toolType + }); + + // Handle terminal mode commands + if (session.inputMode === 'terminal') { + console.log('[Remote] Terminal mode - using runCommand for clean output'); + + // Add user message to shell logs and set state to busy + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + return { + ...s, + state: 'busy' as SessionState, + shellLogs: [...s.shellLogs, { + id: generateId(), + timestamp: Date.now(), + source: 'user', + text: command + }] + }; + })); + + // Use runCommand for clean stdout/stderr capture (same as desktop) + // This spawns a fresh shell with -l -c to run the command + try { + await window.maestro.process.runCommand({ + sessionId: sessionId, // Plain session ID (not suffixed) + command: command, + cwd: session.shellCwd || session.cwd + }); + console.log('[Remote] Terminal command completed successfully'); + } catch (error: unknown) { + console.error('[Remote] Terminal command failed:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + return { + ...s, + state: 'idle' as SessionState, + shellLogs: [...s.shellLogs, { + id: generateId(), + timestamp: Date.now(), + source: 'system', + text: `Error: Failed to run command - ${errorMessage}` + }] + }; + })); + } + return; + } + + // Handle AI mode for Claude Code + if (session.toolType !== 'claude' && session.toolType !== 'claude-code') { + console.log('[Remote] Not Claude Code, skipping'); + return; + } + + // Check if session is busy + if (session.state === 'busy') { + console.log('[Remote] Session is busy, cannot process command'); + return; + } + + // Check for slash commands (built-in and custom) + let promptToSend = command; + let commandMetadata: { command: string; description: string } | undefined; + + if (command.trim().startsWith('/')) { + const commandText = command.trim(); + console.log('[Remote] Detected slash command:', commandText); + + // First, check for built-in slash commands (like /synopsis, /clear) + const isTerminalMode = session.inputMode === 'terminal'; + const matchingBuiltinCommand = slashCommands.find(cmd => { + if (cmd.command !== commandText) return false; + // Apply mode filtering + if (cmd.terminalOnly && !isTerminalMode) return false; + if (cmd.aiOnly && isTerminalMode) return false; + return true; + }); + + if (matchingBuiltinCommand) { + console.log('[Remote] Found matching built-in slash command:', matchingBuiltinCommand.command); + + // Execute the built-in command with full context (using refs for latest function versions) + matchingBuiltinCommand.execute({ + activeSessionId: sessionId, + sessions: sessionsRef.current, + setSessions, + currentMode: session.inputMode, + groups: groupsRef.current, + setRightPanelOpen, + setActiveRightTab, + setActiveFocus, + setSelectedFileIndex, + sendPromptToAgent: spawnAgentWithPromptRef.current || undefined, + addHistoryEntry: addHistoryEntryRef.current || undefined, + startNewClaudeSession: startNewClaudeSessionRef.current || undefined, + spawnBackgroundSynopsis: spawnBackgroundSynopsisRef.current || undefined, + addToast: addToastRef.current, + refreshHistoryPanel: () => rightPanelRef.current?.refreshHistoryPanel(), + }); + + // Built-in command executed - don't continue to spawn AI + return; + } + + // Check if command exists but isn't available in current mode + const existingBuiltinCommand = slashCommands.find(cmd => cmd.command === commandText); + if (existingBuiltinCommand) { + const modeLabel = isTerminalMode ? 'AI' : 'terminal'; + console.log('[Remote] Built-in command exists but not available in', session.inputMode, 'mode'); + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + return { + ...s, + aiLogs: [...s.aiLogs, { + id: generateId(), + timestamp: Date.now(), + source: 'system', + text: `${commandText} is only available in ${modeLabel} mode.` + }] + }; + })); + return; + } + + // Next, look up in custom AI commands + const matchingCustomCommand = customAICommandsRef.current.find( + cmd => cmd.command === commandText + ); + + if (matchingCustomCommand) { + console.log('[Remote] Found matching custom AI command:', matchingCustomCommand.command); + + // Get git branch for template substitution + let gitBranch: string | undefined; + if (session.isGitRepo) { + try { + const status = await gitService.getStatus(session.cwd); + gitBranch = status.branch; + } catch { + // Ignore git errors + } + } + + // Substitute template variables + promptToSend = substituteTemplateVariables( + matchingCustomCommand.prompt, + { session, gitBranch } + ); + commandMetadata = { + command: matchingCustomCommand.command, + description: matchingCustomCommand.description + }; + + console.log('[Remote] Substituted prompt (first 100 chars):', promptToSend.substring(0, 100)); + } else { + // Unknown slash command - show error and don't send to AI + console.log('[Remote] Unknown slash command:', commandText); + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + return { + ...s, + aiLogs: [...s.aiLogs, { + id: generateId(), + timestamp: Date.now(), + source: 'system', + text: `Unknown command: ${commandText}` + }] + }; + })); + return; + } + } + + try { + // Get agent configuration + const agent = await window.maestro.agents.get('claude-code'); + if (!agent) { + console.log('[Remote] ERROR: Claude Code agent not found'); + return; + } + + // Build spawn args with resume if we have a Claude session ID + const spawnArgs = [...agent.args]; + if (session.claudeSessionId) { + spawnArgs.push('--resume', session.claudeSessionId); + } + + const targetSessionId = `${sessionId}-ai`; + const commandToUse = agent.path || agent.command; + + console.log('[Remote] Spawning Claude directly:', { + maestroSessionId: sessionId, + targetSessionId, + claudeSessionId: session.claudeSessionId || 'NEW SESSION', + isResume: !!session.claudeSessionId, + command: commandToUse, + args: spawnArgs, + prompt: promptToSend.substring(0, 100) + }); + + // Add user message to logs and set state to busy + // For custom commands, show the substituted prompt with command metadata + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + return { + ...s, + state: 'busy' as SessionState, + thinkingStartTime: Date.now(), + aiLogs: [...s.aiLogs, { + id: generateId(), + timestamp: Date.now(), + source: 'user', + text: promptToSend, + ...(commandMetadata && { aiCommand: commandMetadata }) + }], + // Track AI command usage + ...(commandMetadata && { + aiCommandHistory: Array.from(new Set([...(s.aiCommandHistory || []), command.trim()])).slice(-50) + }) + }; + })); + + // Spawn Claude with the prompt (original or substituted) + await window.maestro.process.spawn({ + sessionId: targetSessionId, + toolType: 'claude-code', + cwd: session.cwd, + command: commandToUse, + args: spawnArgs, + prompt: promptToSend + }); + + console.log('[Remote] Claude spawn initiated successfully'); + } catch (error) { + console.error('[Remote] Failed to spawn Claude:', error); + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + return { + ...s, + state: 'idle' as SessionState, + aiLogs: [...s.aiLogs, { + id: generateId(), + timestamp: Date.now(), + source: 'system', + text: `Error: Failed to process remote command - ${error.message}` + }] + }; + })); + } + }; + window.addEventListener('maestro:remoteCommand', handleRemoteCommand); + return () => window.removeEventListener('maestro:remoteCommand', handleRemoteCommand); + }, []); + // Process a queued message (called from onExit when queue has items) const processQueuedMessage = async (sessionId: string, entry: LogEntry) => { // Use sessionsRef.current to get the latest session state (avoids stale closure) @@ -3148,8 +3601,10 @@ export default function MaestroConsole() { editingGroupId={editingGroupId} editingSessionId={editingSessionId} draggingSessionId={draggingSessionId} - anyTunnelActive={anyTunnelActive} shortcuts={shortcuts} + isLiveMode={isLiveMode} + webInterfaceUrl={webInterfaceUrl} + toggleGlobalLive={toggleGlobalLive} setActiveFocus={setActiveFocus} setActiveSessionId={setActiveSessionId} setLeftSidebarOpen={setLeftSidebarOpen} @@ -3288,7 +3743,6 @@ export default function MaestroConsole() { terminalOutputRef={terminalOutputRef} fileTreeContainerRef={fileTreeContainerRef} fileTreeFilterInputRef={fileTreeFilterInputRef} - toggleTunnel={toggleTunnel} toggleInputMode={toggleInputMode} processInput={processInput} handleInterrupt={handleInterrupt} diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index e25a82600..c68be0d1a 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Wand2, Radio, ExternalLink, Columns, Copy, List, Loader2, Clock, GitBranch, ArrowUp, ArrowDown, FileEdit, Play, Star, Edit2, Check, X } from 'lucide-react'; +import { Wand2, ExternalLink, Columns, Copy, List, Loader2, Clock, GitBranch, ArrowUp, ArrowDown, FileEdit, Play, Star, Edit2, Check, X } from 'lucide-react'; import { LogViewer } from './LogViewer'; import { TerminalOutput } from './TerminalOutput'; import { InputArea } from './InputArea'; @@ -86,7 +86,6 @@ interface MainPanelProps { fileTreeFilterInputRef: React.RefObject; // Functions - toggleTunnel: (sessionId: string) => void; toggleInputMode: () => void; processInput: () => void; handleInterrupt: () => void; @@ -123,7 +122,7 @@ export function MainPanel(props: MainPanelProps) { setCommandHistoryFilter, setCommandHistorySelectedIndex, setSlashCommandOpen, setSelectedSlashCommandIndex, setPreviewFile, setMarkdownRawMode, setAboutModalOpen, setRightPanelOpen, setGitLogOpen, inputRef, logsEndRef, terminalOutputRef, - fileTreeContainerRef, fileTreeFilterInputRef, toggleTunnel, toggleInputMode, processInput, handleInterrupt, + fileTreeContainerRef, fileTreeFilterInputRef, toggleInputMode, processInput, handleInterrupt, handleInputKeyDown, handlePaste, handleDrop, getContextColor, setActiveSessionId, batchRunState, onStopBatchRun, showConfirmation, onRemoveQueuedMessage } = props; @@ -131,8 +130,6 @@ export function MainPanel(props: MainPanelProps) { const isAutoModeActive = batchRunState?.isRunning || false; const isStopping = batchRunState?.isStopping || false; - // Tunnel tooltip hover state - const [tunnelTooltipOpen, setTunnelTooltipOpen] = useState(false); // Context window tooltip hover state const [contextTooltipOpen, setContextTooltipOpen] = useState(false); // Session ID copied notification @@ -497,72 +494,6 @@ export function MainPanel(props: MainPanelProps) { -
activeSession.tunnelActive && setTunnelTooltipOpen(true)} - onMouseLeave={() => setTunnelTooltipOpen(false)} - > - - {activeSession.tunnelActive && tunnelTooltipOpen && activeSession.tunnelUrl && ( -
-
-
Web Interface URL
-
-
- - {activeSession.tunnelUrl} -
- -
- {activeSession.tunnelPort && ( - <> -
Port
-
- {activeSession.tunnelPort} -
- - )} - -
-
- )} -
- {/* Git Status Widget */} (null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!value) { + setDataUrl(null); + return; + } + + // Generate QR code as data URL + QRCodeLib.toDataURL(value, { + width: size, + margin: 1, + color: { + dark: fgColor, + light: bgColor, + }, + errorCorrectionLevel: 'M', + }) + .then((url) => { + setDataUrl(url); + setError(null); + }) + .catch((err) => { + console.error('Failed to generate QR code:', err); + setError('Failed to generate QR code'); + setDataUrl(null); + }); + }, [value, size, bgColor, fgColor]); + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!dataUrl) { + return ( +
+
+
+ ); + } + + return ( + {alt} + ); +} diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index 568ffea8f..386f82f1d 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect, useRef } from 'react'; import { Wand2, Plus, Settings, ChevronRight, ChevronDown, Activity, X, Keyboard, - Globe, Network, PanelLeftClose, PanelLeftOpen, Folder, Info, FileText, GitBranch, Bot, Clock, + Radio, Copy, ExternalLink, PanelLeftClose, PanelLeftOpen, Folder, Info, FileText, GitBranch, Bot, Clock, ScrollText, Cpu, Menu, Bookmark, Tag } from 'lucide-react'; +import { QRCodeSVG } from 'qrcode.react'; import type { Session, Group, Theme, Shortcut } from '../types'; import { getStatusColor, getContextColor, formatActiveTime } from '../utils/theme'; import { gitService } from '../services/git'; @@ -36,9 +37,13 @@ interface SessionListProps { editingGroupId: string | null; editingSessionId: string | null; draggingSessionId: string | null; - anyTunnelActive: boolean; shortcuts: Record; + // Global Live Mode + isLiveMode: boolean; + webInterfaceUrl: string | null; + toggleGlobalLive: () => void; + // Handlers setActiveFocus: (focus: string) => void; setActiveSessionId: (id: string) => void; @@ -73,7 +78,8 @@ export function SessionList(props: SessionListProps) { const { theme, sessions, groups, sortedSessions, activeSessionId, leftSidebarOpen, leftSidebarWidthState, activeFocus, selectedSidebarIndex, editingGroupId, - editingSessionId, draggingSessionId, anyTunnelActive, shortcuts, + editingSessionId, draggingSessionId, shortcuts, + isLiveMode, webInterfaceUrl, toggleGlobalLive, setActiveFocus, setActiveSessionId, setLeftSidebarOpen, setLeftSidebarWidthState, setShortcutsHelpOpen, setSettingsModalOpen, setSettingsTab, setAboutModalOpen, setLogViewerOpen, setProcessMonitorOpen, toggleGroup, handleDragStart, handleDragOver, handleDropOnGroup, handleDropOnUngrouped, @@ -88,8 +94,32 @@ export function SessionList(props: SessionListProps) { const [bookmarksCollapsed, setBookmarksCollapsed] = useState(false); const [preFilterGroupStates, setPreFilterGroupStates] = useState>(new Map()); const [menuOpen, setMenuOpen] = useState(false); - const [globeTooltipOpen, setGlobeTooltipOpen] = useState(false); + const [liveOverlayOpen, setLiveOverlayOpen] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); const menuRef = useRef(null); + const liveOverlayRef = useRef(null); + + // Copy URL to clipboard + const copyUrlToClipboard = () => { + if (webInterfaceUrl) { + navigator.clipboard.writeText(webInterfaceUrl); + setUrlCopied(true); + setTimeout(() => setUrlCopied(false), 2000); + } + }; + + // Close live overlay when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (liveOverlayRef.current && !liveOverlayRef.current.contains(e.target as Node)) { + setLiveOverlayOpen(false); + } + }; + if (liveOverlayOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [liveOverlayOpen]); // Toggle bookmark for a session const toggleBookmark = (sessionId: string) => { @@ -111,6 +141,25 @@ export function SessionList(props: SessionListProps) { } }, [menuOpen]); + // Close overlays/menus with Escape key + useEffect(() => { + const handleEscKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (liveOverlayOpen) { + setLiveOverlayOpen(false); + e.stopPropagation(); + } else if (menuOpen) { + setMenuOpen(false); + e.stopPropagation(); + } + } + }; + if (liveOverlayOpen || menuOpen) { + document.addEventListener('keydown', handleEscKey); + return () => document.removeEventListener('keydown', handleEscKey); + } + }, [liveOverlayOpen, menuOpen]); + // Track git file change counts per session const [gitFileCounts, setGitFileCounts] = useState>(new Map()); @@ -231,73 +280,104 @@ export function SessionList(props: SessionListProps) {

MAESTRO

-
anyTunnelActive && setGlobeTooltipOpen(true)} - onMouseLeave={() => setGlobeTooltipOpen(false)} - title={anyTunnelActive ? "Index Active" : "No Public Tunnels"} - > - - {anyTunnelActive && globeTooltipOpen && ( -
+ {/* Global LIVE Toggle */} +
+ + + {/* LIVE Overlay with URL and QR Code */} + {isLiveMode && liveOverlayOpen && webInterfaceUrl && ( +
-
-
Live Agents
+ {/* URL Section */} +
+
+ Web Interface URL +
+
+
+ {webInterfaceUrl.replace(/^https?:\/\//, '')} +
+ +
+ + {/* Open in Browser Button */} +
-
- {sessions.filter(s => s.tunnelActive).map(session => { - const group = groups.find(g => g.id === session.groupId); - return ( -
-
- - {group && ( - - {group.name} - - )} -
-
- - {session.tunnelUrl && ( - - )} -
-
- ); - })} + + {/* QR Code Section */} +
+
+ Scan with Mobile +
+
+ +
+
+ + {/* Turn Off Button */} +
+
diff --git a/src/renderer/hooks/useSessionManager.ts b/src/renderer/hooks/useSessionManager.ts index 4f231baf0..e3478e8df 100644 --- a/src/renderer/hooks/useSessionManager.ts +++ b/src/renderer/hooks/useSessionManager.ts @@ -18,7 +18,7 @@ export interface UseSessionManagerReturn { createNewSession: (agentId: string, workingDir: string, name: string) => void; deleteSession: (id: string, showConfirmation: (message: string, onConfirm: () => void) => void) => void; toggleInputMode: () => void; - toggleTunnel: (sessId: string, tunnelProvider: string) => void; + toggleLive: (sessId: string) => void; updateScratchPad: (content: string) => void; updateScratchPadState: (state: { mode: 'edit' | 'preview'; @@ -237,7 +237,7 @@ export function useSessionManager(): UseSessionManagerReturn { aiPid: aiSpawnResult.pid, terminalPid: terminalSpawnResult.pid, port: 3000 + Math.floor(Math.random() * 100), - tunnelActive: false, + isLive: false, changedFiles: [], fileTree: [], fileExplorerExpanded: [], @@ -289,14 +289,15 @@ export function useSessionManager(): UseSessionManagerReturn { })); }; - const toggleTunnel = (sessId: string, tunnelProvider: string) => { + const toggleLive = (sessId: string) => { + // Live toggle is handled in App.tsx via IPC + // This is just a stub for the interface setSessions(prev => prev.map(s => { if (s.id !== sessId) return s; - const isActive = !s.tunnelActive; return { ...s, - tunnelActive: isActive, - tunnelUrl: isActive ? `https://${generateId()}.${tunnelProvider === 'ngrok' ? 'ngrok.io' : 'trycloudflare.com'}` : undefined + isLive: !s.isLive, + liveUrl: undefined }; })); }; @@ -405,7 +406,7 @@ export function useSessionManager(): UseSessionManagerReturn { createNewSession, deleteSession, toggleInputMode, - toggleTunnel, + toggleLive, updateScratchPad, updateScratchPadState, startRenamingSession, diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index c6addc381..985ef509c 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -1,34 +1,16 @@ // Type definitions for Maestro renderer +// Re-export theme types from shared location +export { Theme, ThemeId, ThemeMode, ThemeColors, isValidThemeId } from '../../shared/theme-types'; + export type ToolType = 'claude' | 'aider' | 'opencode' | 'terminal'; export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'connecting' | 'error'; export type FileChangeType = 'modified' | 'added' | 'deleted'; export type RightPanelTab = 'files' | 'history' | 'scratchpad'; export type ScratchPadMode = 'raw' | 'preview' | 'wysiwyg'; -export type ThemeId = 'dracula' | 'monokai' | 'github-light' | 'solarized-light' | 'nord' | 'tokyo-night' | 'one-light' | 'gruvbox-light' | 'catppuccin-mocha' | 'gruvbox-dark' | 'catppuccin-latte' | 'ayu-light' | 'pedurple' | 'maestros-choice' | 'dre-synth' | 'inquest'; export type FocusArea = 'sidebar' | 'main' | 'right'; export type LLMProvider = 'openrouter' | 'anthropic' | 'ollama'; -export interface Theme { - id: ThemeId; - name: string; - mode: 'light' | 'dark' | 'vibe'; - colors: { - bgMain: string; - bgSidebar: string; - bgActivity: string; - border: string; - textMain: string; - textDim: string; - accent: string; - accentDim: string; - accentText: string; - success: string; - warning: string; - error: string; - }; -} - export interface Shortcut { id: string; label: string; @@ -140,10 +122,9 @@ export interface Session { aiPid: number; terminalPid: number; port: number; - tunnelActive: boolean; - tunnelUrl?: string; - tunnelPort?: number; - tunnelUuid?: string; + // Live mode - makes session accessible via web interface + isLive: boolean; + liveUrl?: string; changedFiles: FileArtifact[]; isGitRepo: boolean; // File Explorer per-session state diff --git a/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 000000000..064eb2931 --- /dev/null +++ b/src/shared/index.ts @@ -0,0 +1,10 @@ +/** + * Shared types and utilities for Maestro + * + * This module exports types that are used across multiple parts of the application: + * - Main process (Electron) + * - Renderer process (Desktop React app) + * - Web interface (Mobile and Desktop web builds) + */ + +export * from './theme-types'; diff --git a/src/shared/theme-types.ts b/src/shared/theme-types.ts new file mode 100644 index 000000000..1bc7ba715 --- /dev/null +++ b/src/shared/theme-types.ts @@ -0,0 +1,106 @@ +/** + * Shared theme type definitions for Maestro + * + * This file contains theme types used across: + * - Main process (Electron) + * - Renderer process (Desktop React app) + * - Web interface (Mobile and Desktop web builds) + * + * Keep this file dependency-free to ensure it can be imported anywhere. + */ + +/** + * Available theme identifiers + */ +export type ThemeId = + | 'dracula' + | 'monokai' + | 'github-light' + | 'solarized-light' + | 'nord' + | 'tokyo-night' + | 'one-light' + | 'gruvbox-light' + | 'catppuccin-mocha' + | 'gruvbox-dark' + | 'catppuccin-latte' + | 'ayu-light' + | 'pedurple' + | 'maestros-choice' + | 'dre-synth' + | 'inquest'; + +/** + * Theme mode indicating the overall brightness/style + */ +export type ThemeMode = 'light' | 'dark' | 'vibe'; + +/** + * Color palette for a theme + * Each color serves a specific purpose in the UI + */ +export interface ThemeColors { + /** Main background color for primary content areas */ + bgMain: string; + /** Sidebar background color */ + bgSidebar: string; + /** Background for interactive/activity elements */ + bgActivity: string; + /** Border color for dividers and outlines */ + border: string; + /** Primary text color */ + textMain: string; + /** Dimmed/secondary text color */ + textDim: string; + /** Accent color for highlights and interactive elements */ + accent: string; + /** Dimmed accent (typically with alpha transparency) */ + accentDim: string; + /** Text color for accent contexts */ + accentText: string; + /** Success state color (green tones) */ + success: string; + /** Warning state color (yellow/orange tones) */ + warning: string; + /** Error state color (red tones) */ + error: string; +} + +/** + * Complete theme definition + */ +export interface Theme { + /** Unique identifier for the theme */ + id: ThemeId; + /** Human-readable display name */ + name: string; + /** Theme mode (light, dark, or vibe) */ + mode: ThemeMode; + /** Color palette */ + colors: ThemeColors; +} + +/** + * Type guard to check if a string is a valid ThemeId + */ +export function isValidThemeId(id: string): id is ThemeId { + const validIds: ThemeId[] = [ + 'dracula', + 'monokai', + 'github-light', + 'solarized-light', + 'nord', + 'tokyo-night', + 'one-light', + 'gruvbox-light', + 'catppuccin-mocha', + 'gruvbox-dark', + 'catppuccin-latte', + 'ayu-light', + 'pedurple', + 'maestros-choice', + 'dre-synth', + 'inquest', + ]; + return validIds.includes(id as ThemeId); +} diff --git a/src/web/components/Badge.tsx b/src/web/components/Badge.tsx new file mode 100644 index 000000000..7640086bd --- /dev/null +++ b/src/web/components/Badge.tsx @@ -0,0 +1,299 @@ +/** + * Badge component for Maestro web interface + * + * A reusable badge/status indicator component that supports multiple variants + * and sizes. Ideal for showing session states, labels, and status information. + * Uses theme colors via CSS custom properties for consistent styling. + */ + +import React, { forwardRef, type HTMLAttributes, type ReactNode } from 'react'; +import { useTheme } from './ThemeProvider'; + +/** + * Badge variant types + * - default: Neutral badge using subtle colors + * - success: Positive state (green) - Ready/idle sessions + * - warning: Warning state (yellow) - Agent thinking/busy + * - error: Error state (red) - No connection/error + * - info: Informational (accent color) + * - connecting: Orange pulsing state for connecting sessions + */ +export type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'connecting'; + +/** + * Badge size options + */ +export type BadgeSize = 'sm' | 'md' | 'lg'; + +/** + * Badge style options + * - solid: Filled background with contrasting text + * - outline: Transparent background with colored border + * - subtle: Soft colored background with matching text + * - dot: Minimal dot indicator (no text shown) + */ +export type BadgeStyle = 'solid' | 'outline' | 'subtle' | 'dot'; + +export interface BadgeProps extends HTMLAttributes { + /** Visual variant of the badge */ + variant?: BadgeVariant; + /** Size of the badge */ + size?: BadgeSize; + /** Visual style of the badge */ + badgeStyle?: BadgeStyle; + /** Optional icon to display before the text */ + icon?: ReactNode; + /** Whether to show a pulsing animation (useful for "connecting" states) */ + pulse?: boolean; + /** Children content (text or elements) */ + children?: ReactNode; +} + +/** + * Size-based style configurations + */ +const sizeStyles: Record = { + sm: { + className: 'px-1.5 py-0.5 text-xs gap-1', + borderRadius: '4px', + dotSize: '6px', + }, + md: { + className: 'px-2 py-0.5 text-sm gap-1.5', + borderRadius: '6px', + dotSize: '8px', + }, + lg: { + className: 'px-2.5 py-1 text-base gap-2', + borderRadius: '8px', + dotSize: '10px', + }, +}; + +/** + * Badge component for the Maestro web interface + * + * @example + * ```tsx + * // Status badges + * Ready + * Processing + * Disconnected + * + * // Connecting state with pulse + * Connecting + * + * // Dot-only indicator + * + * + * // Outline style + * AI Mode + * + * // With icon + * }> + * Complete + * + * ``` + */ +export const Badge = forwardRef(function Badge( + { + variant = 'default', + size = 'md', + badgeStyle = 'subtle', + icon, + pulse = false, + children, + className = '', + style, + ...props + }, + ref +) { + const { theme } = useTheme(); + const colors = theme.colors; + + const sizeConfig = sizeStyles[size]; + const shouldPulse = pulse || variant === 'connecting'; + + /** + * Get the primary color for the variant + */ + const getVariantColor = (): string => { + switch (variant) { + case 'success': + return colors.success; + case 'warning': + return colors.warning; + case 'error': + return colors.error; + case 'info': + return colors.accent; + case 'connecting': + // Orange color for connecting state + return '#f97316'; + case 'default': + default: + return colors.textDim; + } + }; + + /** + * Get variant-specific styles based on badgeStyle + */ + const getStyles = (): React.CSSProperties => { + const primaryColor = getVariantColor(); + + switch (badgeStyle) { + case 'solid': + return { + backgroundColor: primaryColor, + color: '#ffffff', + border: 'none', + }; + case 'outline': + return { + backgroundColor: 'transparent', + color: primaryColor, + border: `1px solid ${primaryColor}`, + }; + case 'subtle': + return { + backgroundColor: `${primaryColor}20`, // 20 = ~12% opacity in hex + color: primaryColor, + border: 'none', + }; + case 'dot': + return { + backgroundColor: primaryColor, + border: 'none', + }; + default: + return {}; + } + }; + + // Render dot-only badge + if (badgeStyle === 'dot') { + return ( + + ); + } + + const combinedStyles: React.CSSProperties = { + ...getStyles(), + borderRadius: sizeConfig.borderRadius, + display: 'inline-flex', + alignItems: 'center', + fontWeight: 500, + whiteSpace: 'nowrap', + lineHeight: 1, + ...style, + }; + + return ( + + {icon && {icon}} + {children && {children}} + + ); +}); + +/** + * StatusDot component - A simple circular status indicator + * + * Convenience component for dot-only badges commonly used in session lists. + * + * @example + * ```tsx + * // In a session list item + * + * + * + * + * ``` + */ +export type SessionStatus = 'idle' | 'busy' | 'error' | 'connecting'; + +export interface StatusDotProps extends Omit { + /** Session status to display */ + status: SessionStatus; +} + +/** + * Map session status to badge variant + */ +const statusToVariant: Record = { + idle: 'success', + busy: 'warning', + error: 'error', + connecting: 'connecting', +}; + +export const StatusDot = forwardRef(function StatusDot( + { status, size = 'sm', ...props }, + ref +) { + return ( + + ); +}); + +/** + * ModeBadge component - Shows AI or Terminal mode indicator + * + * @example + * ```tsx + * + * + * ``` + */ +export type InputMode = 'ai' | 'terminal'; + +export interface ModeBadgeProps extends Omit { + /** Current input mode */ + mode: InputMode; +} + +export const ModeBadge = forwardRef(function ModeBadge( + { mode, size = 'sm', badgeStyle = 'outline', ...props }, + ref +) { + return ( + + {mode === 'ai' ? 'AI' : 'Terminal'} + + ); +}); + +export default Badge; diff --git a/src/web/components/Button.tsx b/src/web/components/Button.tsx new file mode 100644 index 000000000..cb0dc986b --- /dev/null +++ b/src/web/components/Button.tsx @@ -0,0 +1,296 @@ +/** + * Button component for Maestro web interface + * + * A reusable button component that supports multiple variants, sizes, and states. + * Uses theme colors via CSS custom properties for consistent styling. + */ + +import React, { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'; +import { useTheme } from './ThemeProvider'; + +/** + * Button variant types + * - primary: Main call-to-action, uses accent color + * - secondary: Secondary action, uses subtle background + * - ghost: No background, hover reveals background + * - danger: Destructive action, uses error color + * - success: Positive action, uses success color + */ +export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success'; + +/** + * Button size options + */ +export type ButtonSize = 'sm' | 'md' | 'lg'; + +export interface ButtonProps extends ButtonHTMLAttributes { + /** Visual variant of the button */ + variant?: ButtonVariant; + /** Size of the button */ + size?: ButtonSize; + /** Whether the button is in a loading state */ + loading?: boolean; + /** Icon to display before the text */ + leftIcon?: ReactNode; + /** Icon to display after the text */ + rightIcon?: ReactNode; + /** Whether the button should take full width */ + fullWidth?: boolean; + /** Children content */ + children?: ReactNode; +} + +/** + * Size-based style configurations + */ +const sizeStyles: Record = { + sm: { + className: 'px-2 py-1 text-xs gap-1', + borderRadius: '4px', + }, + md: { + className: 'px-3 py-1.5 text-sm gap-1.5', + borderRadius: '6px', + }, + lg: { + className: 'px-4 py-2 text-base gap-2', + borderRadius: '8px', + }, +}; + +/** + * Loading spinner component + */ +function LoadingSpinner({ size }: { size: ButtonSize }) { + const spinnerSize = size === 'sm' ? 12 : size === 'md' ? 14 : 16; + return ( + + + + + ); +} + +/** + * Button component for the Maestro web interface + * + * @example + * ```tsx + * // Primary button + * + * + * // Button with loading state + * + * + * // Button with icons + * + * + * // Danger button + * + * ``` + */ +export const Button = forwardRef(function Button( + { + variant = 'primary', + size = 'md', + loading = false, + leftIcon, + rightIcon, + fullWidth = false, + disabled, + children, + className = '', + style, + ...props + }, + ref +) { + const { theme } = useTheme(); + const colors = theme.colors; + + const isDisabled = disabled || loading; + + /** + * Get variant-specific styles + */ + const getVariantStyles = (): React.CSSProperties => { + const baseTransition = 'background-color 150ms ease, border-color 150ms ease, opacity 150ms ease'; + + switch (variant) { + case 'primary': + return { + backgroundColor: colors.accent, + color: '#ffffff', + border: 'none', + transition: baseTransition, + }; + case 'secondary': + return { + backgroundColor: colors.bgActivity, + color: colors.textMain, + border: `1px solid ${colors.border}`, + transition: baseTransition, + }; + case 'ghost': + return { + backgroundColor: 'transparent', + color: colors.textMain, + border: '1px solid transparent', + transition: baseTransition, + }; + case 'danger': + return { + backgroundColor: colors.error, + color: '#ffffff', + border: 'none', + transition: baseTransition, + }; + case 'success': + return { + backgroundColor: colors.success, + color: '#ffffff', + border: 'none', + transition: baseTransition, + }; + default: + return {}; + } + }; + + /** + * Get disabled styles + */ + const getDisabledStyles = (): React.CSSProperties => { + if (!isDisabled) return {}; + return { + opacity: 0.5, + cursor: 'not-allowed', + }; + }; + + const sizeConfig = sizeStyles[size]; + const variantStyles = getVariantStyles(); + const disabledStyles = getDisabledStyles(); + + const combinedStyles: React.CSSProperties = { + ...variantStyles, + ...disabledStyles, + borderRadius: sizeConfig.borderRadius, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 500, + cursor: isDisabled ? 'not-allowed' : 'pointer', + outline: 'none', + userSelect: 'none', + width: fullWidth ? '100%' : undefined, + ...style, + }; + + // Construct class names + const classNames = [ + sizeConfig.className, + 'font-medium whitespace-nowrap', + 'focus:ring-2 focus:ring-offset-1', + 'transition-colors', + fullWidth ? 'w-full' : '', + className, + ] + .filter(Boolean) + .join(' '); + + return ( + + ); +}); + +/** + * IconButton component for icon-only buttons + * + * @example + * ```tsx + * + * + * + * ``` + */ +export interface IconButtonProps extends Omit { + /** Accessible label for the button */ + 'aria-label': string; +} + +export const IconButton = forwardRef(function IconButton( + { size = 'md', className = '', style, children, ...props }, + ref +) { + // Square padding for icon buttons + const iconSizeStyles: Record = { + sm: { padding: '4px', minSize: '24px' }, + md: { padding: '6px', minSize: '32px' }, + lg: { padding: '8px', minSize: '40px' }, + }; + + const sizeConfig = iconSizeStyles[size]; + + return ( + + ); +}); + +export default Button; diff --git a/src/web/components/Card.tsx b/src/web/components/Card.tsx new file mode 100644 index 000000000..e5196df12 --- /dev/null +++ b/src/web/components/Card.tsx @@ -0,0 +1,522 @@ +/** + * Card component for Maestro web interface + * + * A reusable card container component that supports multiple variants, padding options, + * and interactive states. Ideal for session cards, information panels, and grouped content. + * Uses theme colors via CSS custom properties for consistent styling. + */ + +import React, { forwardRef, type HTMLAttributes, type ReactNode } from 'react'; +import { useTheme } from './ThemeProvider'; + +/** + * Card variant types + * - default: Standard card with subtle background + * - elevated: Card with shadow for emphasis + * - outlined: Card with border, transparent background + * - filled: Card with solid activity background + * - ghost: Minimal card, only visible on hover + */ +export type CardVariant = 'default' | 'elevated' | 'outlined' | 'filled' | 'ghost'; + +/** + * Card padding options + */ +export type CardPadding = 'none' | 'sm' | 'md' | 'lg'; + +/** + * Card border radius options + */ +export type CardRadius = 'none' | 'sm' | 'md' | 'lg' | 'full'; + +export interface CardProps extends HTMLAttributes { + /** Visual variant of the card */ + variant?: CardVariant; + /** Padding inside the card */ + padding?: CardPadding; + /** Border radius of the card */ + radius?: CardRadius; + /** Whether the card is interactive (clickable) */ + interactive?: boolean; + /** Whether the card is in a selected/active state */ + selected?: boolean; + /** Whether the card is disabled */ + disabled?: boolean; + /** Whether the card should take full width */ + fullWidth?: boolean; + /** Children content */ + children?: ReactNode; +} + +/** + * Padding style configurations + */ +const paddingStyles: Record = { + none: '', + sm: 'p-2', + md: 'p-3', + lg: 'p-4', +}; + +/** + * Border radius style configurations + */ +const radiusStyles: Record = { + none: '0', + sm: '4px', + md: '8px', + lg: '12px', + full: '9999px', +}; + +/** + * Card component for the Maestro web interface + * + * @example + * ```tsx + * // Basic card + * + *

Card content here

+ *
+ * + * // Interactive session card + * + * + * + * + * // Elevated card for emphasis + * + * + * + * + * // Card with custom padding and radius + * + * + * + * ``` + */ +export const Card = forwardRef(function Card( + { + variant = 'default', + padding = 'md', + radius = 'md', + interactive = false, + selected = false, + disabled = false, + fullWidth = false, + children, + className = '', + style, + onClick, + ...props + }, + ref +) { + const { theme } = useTheme(); + const colors = theme.colors; + + /** + * Get variant-specific styles + */ + const getVariantStyles = (): React.CSSProperties => { + const baseTransition = 'background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease'; + + switch (variant) { + case 'default': + return { + backgroundColor: colors.bgActivity, + color: colors.textMain, + border: 'none', + transition: baseTransition, + }; + case 'elevated': + return { + backgroundColor: colors.bgActivity, + color: colors.textMain, + border: 'none', + boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', + transition: baseTransition, + }; + case 'outlined': + return { + backgroundColor: 'transparent', + color: colors.textMain, + border: `1px solid ${colors.border}`, + transition: baseTransition, + }; + case 'filled': + return { + backgroundColor: colors.bgSidebar, + color: colors.textMain, + border: 'none', + transition: baseTransition, + }; + case 'ghost': + return { + backgroundColor: 'transparent', + color: colors.textMain, + border: '1px solid transparent', + transition: baseTransition, + }; + default: + return {}; + } + }; + + /** + * Get interactive/hover styles + */ + const getInteractiveStyles = (): React.CSSProperties => { + if (!interactive || disabled) return {}; + return { + cursor: 'pointer', + }; + }; + + /** + * Get selected state styles + */ + const getSelectedStyles = (): React.CSSProperties => { + if (!selected) return {}; + return { + borderColor: colors.accent, + backgroundColor: variant === 'outlined' ? colors.accentDim : colors.bgActivity, + boxShadow: `0 0 0 1px ${colors.accent}`, + }; + }; + + /** + * Get disabled styles + */ + const getDisabledStyles = (): React.CSSProperties => { + if (!disabled) return {}; + return { + opacity: 0.5, + cursor: 'not-allowed', + pointerEvents: 'none', + }; + }; + + const variantStyles = getVariantStyles(); + const interactiveStyles = getInteractiveStyles(); + const selectedStyles = getSelectedStyles(); + const disabledStyles = getDisabledStyles(); + + const combinedStyles: React.CSSProperties = { + ...variantStyles, + ...interactiveStyles, + ...selectedStyles, + ...disabledStyles, + borderRadius: radiusStyles[radius], + width: fullWidth ? '100%' : undefined, + ...style, + }; + + // Construct class names + const classNames = [ + paddingStyles[padding], + interactive && !disabled ? 'hover:brightness-110 active:scale-[0.99]' : '', + fullWidth ? 'w-full' : '', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1', + className, + ] + .filter(Boolean) + .join(' '); + + // Handle keyboard interaction for interactive cards + const handleKeyDown = (e: React.KeyboardEvent) => { + if (interactive && !disabled && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onClick?.(e as unknown as React.MouseEvent); + } + props.onKeyDown?.(e); + }; + + return ( +
+ {children} +
+ ); +}); + +/** + * CardHeader component for consistent card headers + * + * @example + * ```tsx + * + * + * Content + * + * ``` + */ +export interface CardHeaderProps extends HTMLAttributes { + /** Main title text */ + title?: ReactNode; + /** Subtitle or secondary text */ + subtitle?: ReactNode; + /** Action element (button, icon, etc.) on the right side */ + action?: ReactNode; +} + +export const CardHeader = forwardRef(function CardHeader( + { title, subtitle, action, className = '', style, children, ...props }, + ref +) { + const { theme } = useTheme(); + const colors = theme.colors; + + // If children are provided, render them directly + if (children) { + return ( +
+ {children} +
+ ); + } + + return ( +
+
+ {title && ( +
+ {title} +
+ )} + {subtitle && ( +
+ {subtitle} +
+ )} +
+ {action &&
{action}
} +
+ ); +}); + +/** + * CardBody component for main card content + * + * @example + * ```tsx + * + * + * + * Main content goes here + * + * + * ``` + */ +export interface CardBodyProps extends HTMLAttributes { + /** Padding inside the body */ + padding?: CardPadding; +} + +export const CardBody = forwardRef(function CardBody( + { padding = 'none', className = '', children, ...props }, + ref +) { + return ( +
+ {children} +
+ ); +}); + +/** + * CardFooter component for card footer content + * + * @example + * ```tsx + * + * Content + * + * + * + * + * ``` + */ +export interface CardFooterProps extends HTMLAttributes { + /** Whether to add a border at the top */ + bordered?: boolean; +} + +export const CardFooter = forwardRef(function CardFooter( + { bordered = false, className = '', style, children, ...props }, + ref +) { + const { theme } = useTheme(); + const colors = theme.colors; + + return ( +
+ {children} +
+ ); +}); + +/** + * SessionCard component - A pre-composed card specifically for session items + * + * This is a convenience component that combines Card with common session display patterns. + * + * @example + * ```tsx + * selectSession(id)} + * /> + * ``` + */ +export type SessionStatus = 'idle' | 'busy' | 'error' | 'connecting'; +export type InputMode = 'ai' | 'terminal'; + +export interface SessionCardProps extends Omit { + /** Session name */ + name: string; + /** Session status */ + status: SessionStatus; + /** Current input mode */ + mode: InputMode; + /** Working directory path */ + cwd?: string; + /** Status indicator element (optional, if you want custom indicator) */ + statusIndicator?: ReactNode; + /** Additional info shown below the title */ + info?: ReactNode; + /** Actions shown on the right side */ + actions?: ReactNode; +} + +/** + * Get status color based on session state + */ +const getStatusColor = (status: SessionStatus, colors: { success: string; warning: string; error: string }): string => { + switch (status) { + case 'idle': + return colors.success; + case 'busy': + return colors.warning; + case 'error': + return colors.error; + case 'connecting': + return '#f97316'; // Orange + default: + return colors.success; + } +}; + +export const SessionCard = forwardRef(function SessionCard( + { + name, + status, + mode, + cwd, + statusIndicator, + info, + actions, + variant = 'outlined', + ...props + }, + ref +) { + const { theme } = useTheme(); + const colors = theme.colors; + const statusColor = getStatusColor(status, colors); + + // Truncate cwd for display + const displayCwd = cwd ? (cwd.length > 30 ? '...' + cwd.slice(-27) : cwd) : undefined; + + return ( + +
+ {/* Status indicator */} + {statusIndicator || ( + + )} + + {/* Main content */} +
+
+ + {name} + + + {mode === 'ai' ? 'AI' : 'Terminal'} + +
+ {(displayCwd || info) && ( +
+ {info || displayCwd} +
+ )} +
+ + {/* Actions */} + {actions &&
{actions}
} +
+
+ ); +}); + +export default Card; diff --git a/src/web/components/Input.tsx b/src/web/components/Input.tsx new file mode 100644 index 000000000..4fdb5d169 --- /dev/null +++ b/src/web/components/Input.tsx @@ -0,0 +1,497 @@ +/** + * Input and TextArea components for Maestro web interface + * + * Reusable input components that support multiple variants, sizes, and states. + * Uses theme colors via CSS custom properties for consistent styling. + */ + +import React, { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes, type ReactNode } from 'react'; +import { useTheme } from './ThemeProvider'; + +/** + * Input variant types + * - default: Standard input with border + * - filled: Input with filled background + * - ghost: Minimal input with no border until focused + */ +export type InputVariant = 'default' | 'filled' | 'ghost'; + +/** + * Input size options + */ +export type InputSize = 'sm' | 'md' | 'lg'; + +/** + * Base props shared between Input and TextArea + */ +interface BaseInputProps { + /** Visual variant of the input */ + variant?: InputVariant; + /** Size of the input */ + size?: InputSize; + /** Whether the input has an error */ + error?: boolean; + /** Whether the input should take full width */ + fullWidth?: boolean; + /** Icon to display at the start of the input */ + leftIcon?: ReactNode; + /** Icon to display at the end of the input */ + rightIcon?: ReactNode; +} + +export interface InputProps extends Omit, 'size'>, BaseInputProps {} + +export interface TextAreaProps extends Omit, 'size'>, Omit { + /** Minimum number of rows */ + minRows?: number; + /** Maximum number of rows before scrolling */ + maxRows?: number; + /** Whether to auto-resize based on content */ + autoResize?: boolean; +} + +/** + * Size-based style configurations + */ +const sizeStyles: Record = { + sm: { + className: 'px-2 py-1 text-xs', + borderRadius: '4px', + iconSize: 14, + }, + md: { + className: 'px-3 py-1.5 text-sm', + borderRadius: '6px', + iconSize: 16, + }, + lg: { + className: 'px-4 py-2 text-base', + borderRadius: '8px', + iconSize: 18, + }, +}; + +/** + * Icon padding adjustments based on size + */ +const iconPadding: Record = { + sm: { left: 'pl-7', right: 'pr-7' }, + md: { left: 'pl-9', right: 'pr-9' }, + lg: { left: 'pl-11', right: 'pr-11' }, +}; + +/** + * Input component for the Maestro web interface + * + * @example + * ```tsx + * // Basic input + * + * + * // Input with error state + * + * + * // Input with icons + * } + * placeholder="Search..." + * /> + * + * // Filled variant + * + * ``` + */ +export const Input = forwardRef(function Input( + { + variant = 'default', + size = 'md', + error = false, + fullWidth = false, + leftIcon, + rightIcon, + disabled, + className = '', + style, + ...props + }, + ref +) { + const { theme } = useTheme(); + const colors = theme.colors; + + const sizeConfig = sizeStyles[size]; + + /** + * Get variant-specific styles + */ + const getVariantStyles = (): React.CSSProperties => { + const baseTransition = 'background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease'; + + switch (variant) { + case 'default': + return { + backgroundColor: colors.bgMain, + color: colors.textMain, + border: `1px solid ${error ? colors.error : colors.border}`, + transition: baseTransition, + }; + case 'filled': + return { + backgroundColor: colors.bgActivity, + color: colors.textMain, + border: `1px solid ${error ? colors.error : 'transparent'}`, + transition: baseTransition, + }; + case 'ghost': + return { + backgroundColor: 'transparent', + color: colors.textMain, + border: `1px solid ${error ? colors.error : 'transparent'}`, + transition: baseTransition, + }; + default: + return {}; + } + }; + + /** + * Get disabled styles + */ + const getDisabledStyles = (): React.CSSProperties => { + if (!disabled) return {}; + return { + opacity: 0.5, + cursor: 'not-allowed', + }; + }; + + const variantStyles = getVariantStyles(); + const disabledStyles = getDisabledStyles(); + + const combinedStyles: React.CSSProperties = { + ...variantStyles, + ...disabledStyles, + borderRadius: sizeConfig.borderRadius, + outline: 'none', + width: fullWidth ? '100%' : undefined, + ...style, + }; + + // Construct class names + const baseClasses = [ + sizeConfig.className, + 'font-normal', + 'placeholder:text-opacity-50', + 'focus:ring-2 focus:ring-offset-1', + 'transition-colors', + fullWidth ? 'w-full' : '', + leftIcon ? iconPadding[size].left : '', + rightIcon ? iconPadding[size].right : '', + className, + ] + .filter(Boolean) + .join(' '); + + // If we have icons, wrap in a container + if (leftIcon || rightIcon) { + return ( +
+ {leftIcon && ( + + {leftIcon} + + )} + + {rightIcon && ( + + {rightIcon} + + )} +
+ ); + } + + return ( + + ); +}); + +/** + * TextArea component for the Maestro web interface + * + * @example + * ```tsx + * // Basic textarea + *